deploy ProspectOS workspace v5 final

This commit is contained in:
Marco0300
2026-09-07 10:23:38 +02:00
parent e27a003703
commit 181b83fc5e
8 changed files with 649 additions and 17 deletions
+3
View File
@@ -53,3 +53,6 @@ AI_PROVIDER=custom
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
# Maximum time to wait for AI-written mockup copy before using the reliable
# premium fallback renderer. Keep this below your reverse-proxy timeout.
AI_MOCKUP_TIMEOUT_SECONDS=45
+30 -7
View File
@@ -39,6 +39,14 @@ a production site and should be checked and edited before sending.
The design prompt receives a small category-matched library of Unsplash stock
images; generated pages may use only those supplied image URLs, and the
fallback template uses the same curated set.
Each saved concept also has an **Edit template** action. It opens the shareable
site beside a chat-style editor where you can ask for copy, emphasis, palette,
imagery, or layout changes. The request is sent to the configured AI with the
prospect's public evidence and rendered through the same deterministic,
sanitised template renderer. Every successful edit creates a new version and
share token; earlier versions remain in the prospect drawer and are never
overwritten. If AI is unavailable, safe visual hints such as “make it darker”
still select one of the built-in palettes.
The **Logs** view separates API calls, user actions, AI requests, searches,
Browser Use/tool steps, and errors. AI request payloads and responses are
truncated and credentials such as API keys, authorization headers, tokens, and
@@ -111,6 +119,7 @@ error rather than remaining stuck forever.
| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint |
| `AI_API_KEY` | empty | Optional provider key; keep server-side |
| `AI_MODEL` | empty | Model name used for evidence-grounded drafts |
| `AI_MOCKUP_TIMEOUT_SECONDS` | `45` | Maximum AI wait for mockup copy before the deterministic premium renderer is used (20–300 seconds) |
### Nous Research Portal
@@ -171,10 +180,11 @@ The API equivalents are `POST /api/prospects/{id}/website-candidates`,
Open **Settings → Browser and discovery timing** to change Browser Use's
enabled state, total investigation timeout, maximum agent steps, and per-call
AI timeout. The same panel controls the Maps worker wait timeout/poll interval,
search depth, and website health/email scan timeout. Values are stored in the
local SQLite database, survive container restarts, and override `.env` defaults
for new runs. The safe UI ranges prevent a malformed value from creating an
unbounded worker.
search depth, website health/email scan timeout, and the maximum wait for
AI-written mockup copy. Values are stored in the local SQLite database, survive
container restarts, and override `.env` defaults for new runs. The safe UI
ranges prevent a malformed value from creating an unbounded worker; a slow
mockup provider automatically falls back to the complete local renderer.
The app stores its catalogue in the `prospect_data` Docker volume. The worker
stores its own job data in `gmaps_data`; keep both volumes if you want to retain
@@ -228,9 +238,22 @@ with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and
is sent as `promotion_id` and retained on that prospect's draft.
Mockups are created with `POST /api/prospects/{id}/mockup`, listed with
`GET /api/prospects/{id}/mockups`, and opened by their public
`/mockups/{token}` share URL. Set `PUBLIC_BASE_URL` when the reverse proxy does
not forward the public host; otherwise the link is built from the request's
forwarded host or `Host` header.
`/mockups/{token}` share URL. Choose `mode=onepage` for a single scrollable
landing page or `mode=multipage` for real Home, Services, About, and Contact
routes. Both modes use the same deterministic premium renderer: responsive
sticky navigation, layered hero imagery, editorial service cards, accessible
contact calls-to-action, a dark structured footer, hover states, and CSS-only
entrance/ambient animations with reduced-motion support. AI is asked only for
bounded copy and curated image choices; malformed, truncated, unavailable, or
slow AI responses fall back to the same complete renderer instead of failing
the request. Set `PUBLIC_BASE_URL` when the reverse proxy does not forward the
public host; otherwise the link is built from the request's forwarded host or
`Host` header.
Use `GET /api/mockups/{id}` to load editor metadata and
`POST /api/mockups/{id}/edit` with `{"instruction":"...","page":"all"}` to
create the next version. The edit endpoint requires reviewer access, limits
instructions to 3,000 characters, and accepts `all`, `home`, `services`,
`about`, or `contact` as the page scope.
## Tests
+413 -7
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -46,6 +46,7 @@ services:
AI_BASE_URL: ${AI_BASE_URL:-}
AI_API_KEY: ${AI_API_KEY:-}
AI_MODEL: ${AI_MODEL:-}
AI_MOCKUP_TIMEOUT_SECONDS: ${AI_MOCKUP_TIMEOUT_SECONDS:-45}
volumes:
- prospect_data:/data
depends_on:
+89 -1
View File
@@ -201,6 +201,92 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertIn("Services", body)
self.assertIn("images.unsplash.com", body)
self.assertIn("https://images.unsplash.com", response.getheader("Content-Security-Policy"))
self.assertIn("frame-ancestors 'none'", response.getheader("Content-Security-Policy"))
def test_mockup_editor_creates_a_new_version_and_keeps_original(self):
conn = server.db()
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Brightspark Electrical", "maps:bright-editor", "electrician", server.now()))
conn.commit(); conn.close()
status, original = self.request("POST", "/api/prospects/1/mockup", {"mode": "multipage"})
self.assertEqual(status, 201)
self.assertEqual(original.get("version"), None) # v1 rows remain backwards-compatible
conn = server.db()
for key, value in (("AI_PROVIDER", "nous"), ("AI_MODEL", "meituan/longcat-2.0:free"), ("AI_API_KEY", "test-key")):
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
conn.commit(); conn.close()
ai_response = {"choices": [{"message": {"content": json.dumps({
"tagline": "Powerful work. Presented with confidence.",
"intro": "A sharper digital home for Brightspark Electrical.",
"services": [{"name": "Electrical services", "description": "A clear way to understand the work available."}],
"about": "A refined concept based on the public business listing.",
"cta": "Let’s make the next step easy.",
"hero_image": server.STOCK_IMAGE_LIBRARY["electric"][0],
"gallery_image": server.STOCK_IMAGE_LIBRARY["electric"][1],
})}}]}
with patch("api.server.http_json", return_value=ai_response) as request:
status, edited = self.request("POST", f"/api/mockups/{original['id']}/edit", {"instruction": "Make the whole site darker and put more emphasis on services.", "page": "all"})
self.assertEqual(status, 201)
self.assertEqual(edited["version"], 2)
self.assertEqual(edited["parent_id"], original["id"])
self.assertEqual(edited["mode"], "multipage")
self.assertIn("Applied your request", edited["change_summary"])
self.assertIn("max_tokens", request.call_args.args[2])
self.assertEqual(request.call_args.args[2]["max_tokens"], 1800)
self.conn.request("GET", "/mockups/" + edited["token"] + "/services?embed=1")
response = self.conn.getresponse(); body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn("frame-ancestors 'self'", response.getheader("Content-Security-Policy"))
self.assertIn("--paper:#091321", body)
self.assertIn("Electrical services", body)
self.conn.request("GET", "/mockups/" + edited["token"])
response = self.conn.getresponse(); edited_home = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn("Powerful work", edited_home)
self.conn.request("GET", "/mockups/" + original["token"] + "/services")
response = self.conn.getresponse(); original_body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn("Brightspark Electrical", original_body)
status, metadata = self.request("GET", f"/api/mockups/{edited['id']}")
self.assertEqual(status, 200)
self.assertEqual(metadata["version"], 2)
status, listed = self.request("GET", "/api/prospects/1/mockups")
self.assertEqual(status, 200)
self.assertEqual([item["version"] for item in listed["items"]], [2, 1])
def test_multipage_ai_uses_compact_content_and_renders_all_pages(self):
conn = server.db()
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Brightspark Electrical", "maps:bright-ai", "electrician", server.now()))
for key, value in (("AI_PROVIDER", "nous"), ("AI_MODEL", "meituan/longcat-2.0:free"), ("AI_API_KEY", "test-key")):
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
conn.commit(); conn.close()
response = {"choices": [{"message": {"content": json.dumps({
"tagline": "Powerful work. Presented with clarity.",
"intro": "A considered digital home for Brightspark Electrical.",
"services": [{"name": "Electrical services", "description": "A clear way to understand the work available."}],
"about": "A refined concept grounded in the public business listing.",
"cta": "Let’s make the next step easy.",
"hero_image": server.STOCK_IMAGE_LIBRARY["electric"][0],
"gallery_image": server.STOCK_IMAGE_LIBRARY["electric"][1],
})}}]}
with patch("api.server.http_json", return_value=response) as request:
status, created = self.request("POST", "/api/prospects/1/mockup", {"mode": "multipage"})
self.assertEqual(status, 201)
self.assertEqual(created["generation_source"], "ai")
self.assertEqual({item["slug"] for item in created["pages"]}, {"home", "services", "about", "contact"})
payload = request.call_args.args[2]
self.assertEqual(payload["max_tokens"], 1800)
self.assertNotIn("pages", json.dumps(payload["messages"]))
stored = server.db().execute("SELECT pages_json FROM mockups WHERE id=?", (created["id"],)).fetchone()
pages = json.loads(stored[0])
self.assertIn("Powerful work", pages["home"])
self.assertTrue(all("Brightspark Electrical" in html and "@keyframes" in html and "<footer" in html for html in pages.values()))
def test_onepage_mockup_uses_in_page_navigation(self):
html = server.premium_render_pages({"name": "Acme", "category": "plumber"}, mode="onepage")["home"]
self.assertIn('href="#services"', html)
self.assertIn('id="services"', html)
self.assertIn('id="about"', html)
self.assertIn('id="contact"', html)
def test_browser_investigator_normalises_and_filters_candidates(self):
raw = '{"candidates":[{"url":"https://www.acme.co.za/about","confidence":88,"evidence":["name match"]},{"url":"https://facebook.com/acme","confidence":99},{"domain":"other.co.za","confidence":"42","evidence":"not a list"}]}'
@@ -314,18 +400,20 @@ class BrowserOnlyAppTests(unittest.TestCase):
status, body = self.request("POST", "/api/settings", {
"browser_use": {"enabled": True, "max_steps": 22, "timeout_seconds": 900, "llm_timeout_seconds": 120},
"maps": {"request_timeout_seconds": 1800, "poll_seconds": 4, "max_depth": 3},
"limits": {"website_scan_timeout_seconds": 15},
"limits": {"website_scan_timeout_seconds": 15, "ai_mockup_timeout_seconds": 75},
})
self.assertEqual(status, 200)
self.assertEqual(body["browser_use"]["timeout_seconds"], 900)
self.assertEqual(body["maps"]["request_timeout_seconds"], 1800)
self.assertEqual(body["limits"]["website_scan_timeout_seconds"], 15)
self.assertEqual(body["limits"]["ai_mockup_timeout_seconds"], 75)
status, settings = self.request("GET", "/api/settings")
self.assertEqual(status, 200)
self.assertEqual(settings["browser_use"]["max_steps"], 22)
self.assertEqual(settings["browser_use"]["llm_timeout_seconds"], 120)
self.assertEqual(settings["maps"]["poll_seconds"], 4.0)
self.assertEqual(settings["maps"]["max_depth"], 3)
self.assertEqual(settings["limits"]["ai_mockup_timeout_seconds"], 75)
self.assertEqual(server.browser_use_limits(), (22, 900))
self.assertEqual(server.website_scan_timeout_seconds(), 15)
+97
View File
@@ -150,3 +150,100 @@ function addMockupPanel(id){const body=$('drawerBody');const p=prospects.find(x=
async function generateMockupPreview(id,button,mode='onepage'){const status=$('mockupStatus');const chooser=document.querySelector('.mockup-mode-chooser');if(button)button.disabled=true;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=true);if(status){status.textContent=`Generating ${mode==='multipage'?'multi-page':'one-page'} preview… The current public website may take a moment to read.`;status.className='notice'}try{const data=await api(`/api/prospects/${id}/mockup`,{method:'POST',body:JSON.stringify({instructions:'',mode})});if(status){status.innerHTML=`Preview ready (${esc(data.mode==='multipage'?'multiple pages':'one page')}). <a href="${esc(data.share_url)}" target="_blank" rel="noopener">Open share link ↗</a>`;status.className='notice ok'}const list=$('mockupsList');if(list){const latest=await api(`/api/prospects/${id}/mockups`);list.innerHTML=renderMockupItems(latest.items||[])}return data}catch(error){if(status){status.textContent=error.message;status.className='notice error'}throw error}finally{if(button)button.disabled=false;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=false)}}
async function loadMessageMockupOption(id){const context=$('messageContext');if(!context)return;context.querySelector('.mockup-option')?.remove();try{const data=await api(`/api/prospects/${id}/mockups`);const latest=(data.items||[])[0];if(!latest)return;const option=document.createElement('label');option.className='mockup-option';option.innerHTML=`<input type="checkbox" id="includeMockup" data-mockup-id="${esc(latest.id)}" checked><span><b>Include latest concept preview</b><small>Share the editable preview link once in the email. <a href="${esc(latest.share_url)}" target="_blank" rel="noopener">Open it ↗</a></small></span>`;context.append(option)}catch(error){logAction('mockup_option_load_failed',{prospect_id:id,error:error.message},'warn')}}
document.addEventListener('click',async e=>{const generate=e.target.closest('[data-generate-mockup]');if(generate){e.preventDefault();try{await generateMockupPreview(generate.dataset.generateMockup,generate,generate.dataset.mockupMode||'onepage')}catch{}return}const copy=e.target.closest('[data-copy-mockup]');if(copy){e.preventDefault();try{await navigator.clipboard.writeText(copy.dataset.copyMockup);copy.textContent='Copied';setTimeout(()=>{copy.textContent='Copy link'},1400)}catch{prompt('Copy preview link',copy.dataset.copyMockup)}}});
/* Keep the mockup generation timeout visible with the other runtime controls. */
const _renderSettingsWithMockupTimeout=renderSettings;
renderSettings=function(){
_renderSettingsWithMockupTimeout();
const websiteTimeout=$('websiteTimeout');
const group=websiteTimeout?.closest('.setting-group');
if(!group||$('aiMockupTimeout'))return;
const label=document.createElement('label');
label.innerHTML=`AI mockup copy timeout (seconds)<input id="aiMockupTimeout" type="number" min="20" max="300" step="5" value="${Number(settings.limits?.ai_mockup_timeout_seconds||45)}"><small>After this wait the complete premium fallback renders automatically.</small>`;
group.append(label);
};
/* Capture this form before the legacy handler so the new value is saved in the same request. */
document.addEventListener('submit',async e=>{
if(e.target?.id!=='runtimeSettingsForm')return;
e.preventDefault();e.stopImmediatePropagation();
const message=$('runtimeSettingsMessage');
if(message){message.textContent='Saving…';message.className='notice'}
try{
const result=await api('/api/settings',{method:'POST',body:JSON.stringify({
browser_use:{enabled:$('browserEnabled').checked,max_steps:Number($('browserSteps').value),timeout_seconds:Number($('browserTimeout').value),llm_timeout_seconds:Number($('browserLlmTimeout').value)},
maps:{request_timeout_seconds:Number($('mapsTimeout').value),poll_seconds:Number($('mapsPoll').value),max_depth:Number($('mapsDepth').value)},
limits:{website_scan_timeout_seconds:Number($('websiteTimeout').value),ai_mockup_timeout_seconds:Number($('aiMockupTimeout')?.value||45)}
})});
settings.browser_use=result.browser_use||settings.browser_use;settings.maps={...settings.maps,...(result.maps||{})};settings.limits={...settings.limits,...(result.limits||{})};
if(message){message.textContent='Runtime settings saved.';message.className='notice ok'}
}catch(error){if(message){message.textContent=error.message;message.className='notice error'}}
},true);
/* AI template editor ----------------------------------------------------- */
/*
* Keep the preview in an iframe and the conversation in the parent app.
* The public preview itself is static and CSP/sandboxed, so an AI request
* can never inject a chat script, form handler, or tracking pixel into the
* share link.
*/
renderMockupItems=function(items=[]){
if(!items.length)return '<p class="muted">No preview yet. Generate one from the public facts already collected for this prospect.</p>';
return `<div class="mockup-list">${items.map(item=>`<div class="mockup-card"><div class="mockup-card-main"><b>${esc(item.title||'Website concept preview')}</b><small>${esc(item.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · version ${Number(item.version||1)} · ${date(item.created_at)}</small>${item.pages?.length>1?`<span class="muted">${item.pages.length} pages · ${item.pages.map(page=>`<a href="${esc(page.url)}" target="_blank" rel="noopener">${esc(page.slug)}</a>`).join(' · ')}</span>`:''}${item.source_url?`<span class="muted">Based on ${esc(item.source_url)}</span>`:''}${Number(item.version||1)>1?`<span class="muted">Edited from an earlier version${item.edit_instructions?`: ${esc(item.edit_instructions)}`:''}</span>`:''}</div><div class="mockup-card-actions"><button type="button" class="primary" data-edit-mockup="${esc(item.id)}" data-edit-prospect="${esc(item.prospect_id)}" data-edit-share="${esc(item.share_url)}" data-edit-mode="${esc(item.mode||'onepage')}" data-edit-version="${esc(item.version||1)}">Edit template</button><a class="secondary mockup-link" href="${esc(item.share_url)}" target="_blank" rel="noopener">Open preview ↗</a><button type="button" class="secondary" data-copy-mockup="${esc(item.share_url)}">Copy link</button></div></div>`).join('')}</div>`;
};
function editorPageOptions(mode){
return mode==='multipage'?'<option value="all">Entire site</option><option value="home">Home page</option><option value="services">Services page</option><option value="about">About page</option><option value="contact">Contact page</option>':'<option value="all">Entire page</option>';
}
function editorPreviewUrl(url,refresh=''){
const value=String(url||'');if(!value)return '';
const join=value.includes('?')?'&':'?';return `${value}${join}embed=1${refresh?`&refresh=${encodeURIComponent(refresh)}`:''}`;
}
function editorAddMessage(messages,kind,text){
if(!messages)return;
const bubble=document.createElement('div');bubble.className=`mockup-chat-message ${kind||'assistant'}`;bubble.textContent=String(text||'');messages.append(bubble);messages.scrollTop=messages.scrollHeight;
}
async function openMockupEditor(button){
const id=button?.dataset?.editMockup;
if(!id)return;
document.getElementById('mockupEditorDialog')?.remove();
let state={id:Number(id),prospect_id:Number(button.dataset.editProspect||0),share_url:button.dataset.editShare||'',mode:button.dataset.editMode||'onepage',version:Number(button.dataset.editVersion||1),title:'Website concept preview'};
const dialog=document.createElement('dialog');dialog.id='mockupEditorDialog';dialog.className='mockup-editor-dialog';
dialog.innerHTML=`<div class="mockup-editor-shell"><header class="mockup-editor-head"><div><span class="eyebrow">TEMPLATE EDITOR</span><h2>${esc(state.title)}</h2><p>Ask the AI for a focused change, then review the new version before sharing it.</p></div><div class="mockup-editor-head-actions"><span id="mockupEditorVersion" class="pill">Version ${state.version}</span><button type="button" class="icon-btn" data-editor-close aria-label="Close editor">×</button></div></header><div class="mockup-editor-layout"><section class="mockup-editor-preview"><div class="mockup-editor-preview-bar"><b>Live concept preview</b><a id="mockupEditorOpen" href="${esc(state.share_url)}" target="_blank" rel="noopener">Open full preview ↗</a></div><iframe id="mockupEditorFrame" title="Website concept preview" sandbox="allow-same-origin" referrerpolicy="no-referrer" src="${esc(editorPreviewUrl(state.share_url))}"></iframe></section><aside class="mockup-editor-chat"><div class="mockup-chat-head"><div><span class="eyebrow">AI DESIGN CHAT</span><h3>What should change?</h3></div><span class="mockup-chat-badge">Safe renderer</span></div><div id="mockupEditorMessages" class="mockup-chat-messages"></div><form id="mockupEditorForm" class="mockup-chat-form"><label>Apply to<select id="mockupEditorPage">${editorPageOptions(state.mode)}</select></label><textarea id="mockupEditorInput" rows="5" required maxlength="3000" placeholder="e.g. Make the design darker, make Services more prominent, and change the headline to something more confident."></textarea><div id="mockupEditorStatus" class="notice">The AI uses the prospect evidence and your instruction. It will not add scripts, forms, tracking, or unsupported claims.</div><button class="primary" type="submit" id="mockupEditorApply">Apply change <span>→</span></button></form><div class="mockup-editor-note"><b>Version history is preserved</b><span>Every applied change creates a new share link. The previous version remains available in the prospect drawer.</span></div></aside></div></div>`;
document.body.append(dialog);
const close=()=>{try{dialog.close()}catch{}dialog.remove()};
dialog.querySelector('[data-editor-close]')?.addEventListener('click',close);
dialog.addEventListener('cancel',e=>{e.preventDefault();close()});
dialog.addEventListener('click',e=>{if(e.target===dialog)close()});
try{dialog.showModal()}catch{dialog.setAttribute('open','')}
const messages=$('mockupEditorMessages'),frame=$('mockupEditorFrame'),openLink=$('mockupEditorOpen'),versionLabel=$('mockupEditorVersion'),form=$('mockupEditorForm'),input=$('mockupEditorInput'),status=$('mockupEditorStatus'),apply=$('mockupEditorApply');
editorAddMessage(messages,'assistant','I can refine the layout, copy, palette, emphasis, and imagery while keeping the concept grounded in the public prospect evidence. What would you like changed?');
try{
const remote=await api(`/api/mockups/${state.id}`);state={...state,...remote};
const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview';
if(frame&&state.share_url)frame.src=editorPreviewUrl(state.share_url);if(openLink)openLink.href=state.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(state.version||1)}`;
}catch(error){editorAddMessage(messages,'system',`Could not load editor metadata: ${error.message}. The preview can still be edited.`)}
form?.addEventListener('submit',async event=>{
event.preventDefault();
const instruction=String(input?.value||'').trim();if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}return}
const page=$('mockupEditorPage')?.value||'all';
if(apply)apply.disabled=true;if(input)input.disabled=true;if(status){status.textContent='Applying your request and rendering a new version…';status.className='notice'}
editorAddMessage(messages,'user',instruction);
try{
const result=await api(`/api/mockups/${state.id}/edit`,{method:'POST',body:JSON.stringify({instruction,page})});
state={...state,...result};const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview';editorAddMessage(messages,'assistant',`${result.change_summary||'Your change was applied.'} Version ${Number(result.version||1)} is ready to review.`);
if(frame&&result.share_url)frame.src=editorPreviewUrl(result.share_url,Date.now());if(openLink)openLink.href=result.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(result.version||1)}`;if(input)input.value='';if(status){status.textContent='New version ready. Review the preview, then ask for another change or copy the share link.';status.className='notice ok'}
if(state.prospect_id){try{const latest=await api(`/api/prospects/${state.prospect_id}/mockups`);const list=$('mockupsList');if(list)list.innerHTML=renderMockupItems(latest.items||[])}catch{}}
}catch(error){editorAddMessage(messages,'system',`I could not apply that change: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error'}}finally{if(apply)apply.disabled=false;if(input)input.disabled=false;input?.focus()}
});
input?.focus();
}
/* Capture before row-level click handlers so opening the editor does not
also open/close the prospect drawer underneath it. */
document.addEventListener('click',e=>{
const edit=e.target.closest('[data-edit-mockup]');
if(!edit)return;
e.preventDefault();e.stopPropagation();openMockupEditor(edit);
},true);
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v2"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v2"></script></body></html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v5"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v5"></script></body></html>
+14
View File
@@ -158,3 +158,17 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
@media(max-width:680px){.mockup-card{display:block}.mockup-card-actions{margin-top:9px;justify-content:flex-start}.mockup-card-actions a,.mockup-card-actions button{flex:1}.mockup-panel .primary{width:100%}}
.mockup-mode-chooser{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:12px 0}.mockup-mode-chooser button{display:grid;justify-items:start;gap:3px;text-align:left;padding:11px 12px}.mockup-mode-chooser button b{font-size:11px}.mockup-mode-chooser button small{font-size:10px;font-weight:500;opacity:.8}.mockup-mode-chooser .secondary{color:#48556c}
@media(max-width:560px){.mockup-mode-chooser{grid-template-columns:1fr}.mockup-mode-chooser button{width:100%}}
/* AI template editor */
.mockup-editor-dialog{width:min(1240px,calc(100vw - 28px));height:min(860px,calc(100vh - 28px));max-width:none;max-height:none;padding:0;border:0;border-radius:18px;background:#f8f9fd;color:#27344b;box-shadow:0 30px 100px #18233d45;overflow:hidden}
.mockup-editor-dialog::backdrop{background:#111a2dcc;backdrop-filter:blur(5px)}
.mockup-editor-shell{display:grid;grid-template-rows:auto minmax(0,1fr);height:100%;min-height:0}
.mockup-editor-head{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:20px 24px;border-bottom:1px solid #e0e5ef;background:#fff}
.mockup-editor-head h2{margin:5px 0 4px;font-size:20px;letter-spacing:-.035em}.mockup-editor-head p{margin:0;color:#7b879a;font-size:11px}.mockup-editor-head-actions{display:flex;align-items:center;gap:10px}.mockup-editor-head-actions .icon-btn{flex:0 0 auto}
.mockup-editor-layout{display:grid;grid-template-columns:minmax(0,1fr) 370px;min-height:0}
.mockup-editor-preview{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;background:#e9edf5}.mockup-editor-preview-bar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 17px;border-bottom:1px solid #dce2ed;background:#f7f8fc;color:#5a687d;font-size:11px}.mockup-editor-preview-bar a{color:#5b50d3;text-decoration:none;font-weight:800;white-space:nowrap}.mockup-editor-preview iframe{display:block;width:100%;height:100%;min-height:360px;border:0;background:#fff}
.mockup-editor-chat{display:grid;grid-template-rows:auto minmax(0,1fr) auto auto;min-height:0;border-left:1px solid #dfe4ed;background:#fff}.mockup-chat-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:19px 19px 13px;border-bottom:1px solid #edf0f5}.mockup-chat-head h3{margin:4px 0 0;font-size:16px;letter-spacing:-.025em}.mockup-chat-badge{padding:5px 8px;border-radius:999px;background:#eef8f5;color:#17876b;font-size:9px;font-weight:900;white-space:nowrap}
.mockup-chat-messages{display:grid;align-content:start;gap:10px;overflow:auto;padding:16px 16px 10px;background:linear-gradient(180deg,#fbfcff,#fff)}.mockup-chat-message{max-width:92%;padding:10px 11px;border-radius:11px;font-size:11px;line-height:1.5;white-space:pre-wrap}.mockup-chat-message.assistant{justify-self:start;border:1px solid #e1e5ef;background:#f5f6fb;color:#59677e}.mockup-chat-message.user{justify-self:end;background:#635bff;color:#fff;box-shadow:0 8px 18px #635bff2b}.mockup-chat-message.system{justify-self:start;border:1px solid #f1c7cd;background:#fff5f6;color:#a33e4d}
.mockup-chat-form{display:grid;gap:8px;padding:14px 16px 12px;border-top:1px solid #edf0f5}.mockup-chat-form label{display:grid;gap:5px;color:#627087;font-size:10px;font-weight:850}.mockup-chat-form select{margin:0;height:34px;font-size:11px}.mockup-chat-form textarea{margin:0;min-height:106px;resize:vertical;font-size:12px;line-height:1.5}.mockup-chat-form .notice{margin:0;font-size:10px;line-height:1.4}.mockup-chat-form button{width:100%;min-height:42px}.mockup-editor-note{display:grid;gap:4px;padding:12px 16px 16px;background:#fafbff;color:#8b96a8;font-size:10px;line-height:1.45}.mockup-editor-note b{color:#5c6a80;font-size:10px}.mockup-editor-note span{display:block}
@media(max-width:900px){.mockup-editor-dialog{width:min(760px,calc(100vw - 20px));height:min(900px,calc(100vh - 20px))}.mockup-editor-layout{grid-template-columns:1fr;grid-template-rows:minmax(310px,1fr) auto}.mockup-editor-preview iframe{min-height:300px}.mockup-editor-chat{grid-template-rows:auto minmax(95px,1fr) auto auto;border-left:0;border-top:1px solid #dfe4ed;max-height:430px}.mockup-chat-messages{min-height:95px;max-height:160px}}
@media(max-width:560px){.mockup-editor-dialog{width:100vw;height:100vh;border-radius:0}.mockup-editor-head{padding:15px 16px}.mockup-editor-head h2{font-size:17px}.mockup-editor-head p{max-width:230px}.mockup-editor-head-actions .pill{display:none}.mockup-editor-layout{grid-template-rows:minmax(260px,1fr) auto}.mockup-editor-preview-bar{padding:9px 12px}.mockup-editor-chat{max-height:430px}.mockup-chat-form{padding:11px 12px}.mockup-editor-note{padding:10px 12px 13px}}