diff --git a/apps/web/README.md b/apps/web/README.md index eb987db..f06fef8 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -12,7 +12,7 @@ window.__PROSPECT_CONFIG__ = Object.freeze({ apiBase: 'https://api.example.inval If `apiBase` is empty, the UI uses `window.API_BASE`, then `localStorage.prospect_api_base` when present, and otherwise targets the same origin. The dashboard requires an authenticated API session and shows the login screen until `/api/v1/auth/me` succeeds. Do not put tokens, passwords, or private keys in `config.js`. -`asset-manifest.json` records the public entrypoints, cache-busting version, and SHA-256 digests for release verification. The HTML references the static assets with the `phase-15` version query string; update those references and regenerate the manifest when changing the release version. +`asset-manifest.json` records the public entrypoints, cache-busting version, and SHA-256 digests for release verification. The HTML references the static assets with the current release version query string; update those references and regenerate the manifest when changing the release version. ## Deployment readiness checks @@ -51,9 +51,9 @@ The browser must not directly fetch arbitrary target URLs, follow redirects for ## Phase 5 source UI contract -The web client may display registered source metadata, query mode, approval/terms state, rate-limit status, retention class, health, and circuit state returned by the API. It must label `dry_run` as a plan/validation result and distinguish operator-supplied CSV/manual references from independently verified evidence. It must not offer a live-source control unless the API reports explicit approval and operational enablement; client visibility is never an authorization control. +The web client displays registered source metadata, adapter type, approval/terms state, configured/available/enabled status, credential state, rate-limit/quota status, health, and circuit state returned by the API. Optional adapters are shown as unavailable/configuration-gated until the API reports explicit approval and operational enablement; client visibility is never an authorization control. It labels `dry_run` as a plan/validation result and distinguishes operator-supplied CSV/manual references from independently verified evidence. -CSV and manual reference workflows must show source attribution, adapter/version, observed time, and any retention/redaction status. Raw source payloads should be hidden or minimized in the UI and remain tenant-scoped. A circuit-open or rate-limited source must be presented as unavailable/deferred, not as an empty discovery result. The current static client has no network discovery implementation; these are display and contract requirements for a future approved integration. +CSV and manual reference workflows must show source attribution, adapter/version, observed time, and any retention/redaction status. Raw source payloads should be hidden or minimized in the UI and remain tenant-scoped. A circuit-open or rate-limited source must be presented as unavailable/deferred, not as an empty discovery result. The current client provides governed CSV/manual setup, source registry status, and authenticated discovery-run controls while keeping optional network adapters fail-closed. ## Phase 4 job/live-log UI contract diff --git a/apps/web/app.js b/apps/web/app.js index 5714105..624b6d1 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -35,7 +35,7 @@ async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} if(response.status===403)throw new Error('Tenant/workspace access denied.'); return response; } async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; } function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;} - function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();} + function showDashboard(user){currentUser=user||{};const name=currentUser.display_name||currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userGreetingName').textContent=name.split(/\s+/)[0]||'there';$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();} function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=Number(summary?.high_fit??summary?.high_fit_count??prospects.filter(p=>scoreFor(p)>=80).length),review=Number(summary?.needs_review??summary?.review_count??prospects.filter(p=>statusOf(p)==='review').length),suppressed=Number(summary?.suppressed??summary?.suppressed_count??prospects.filter(p=>statusOf(p)==='suppressed').length),fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=review;$('metricHigh').textContent=high;$('metricFresh').textContent=`${summary?.freshness_under_7d??summary?.fresh_count??fresh}%`;if($('metricSuppressed'))$('metricSuppressed').textContent=suppressed;} function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value,source:$('sourceFilter')?.value||'all',geography:$('geographyFilter')?.value||'all',category:$('categoryFilter')?.value||'all',contact_status:$('contactStatusFilter')?.value||'all',freshness:$('explorerState')?.dataset?.filter||'all'};} function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps,source,geography,category,contact_status,freshness}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||p.contact_extractions||[], pSource=String(p.source_name||p.source||p.source_id||'all'), geo=String(p.city||p.province||p.country||p.location||'all'), cat=String(p.category||p.industry||p.categories?.[0]||'all'), contactState=contacts.length||p.email||p.phone?'present':statusOf(p)==='suppressed'?'suppressed':'missing';return(!q||text.includes(q.toLowerCase()))&&(source==='all'||pSource===source||String(p.source_id)===source)&&(geography==='all'||geo.toLowerCase().includes(geography.toLowerCase()))&&(category==='all'||cat===category)&&(contact_status==='all'||contactState===contact_status)&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps)&&(freshness!=='under_7d'||freshnessValue(p));});} @@ -272,15 +272,19 @@ let sources = [], selectedSourceId = null; const sourceState = source => Boolean(source?.enabled ?? source?.active); - const sourceStatus = source => sourceState(source) ? 'enabled' : 'disabled'; + const sourceStatus = source => source.optional && !source.configured ? 'unavailable' : sourceState(source) ? 'enabled' : 'disabled'; const sourceItems = payload => Array.isArray(payload) ? payload : (payload?.sources || payload?.items || payload?.records || []); + const sourceJson = (value, fallback={}) => { if (value && typeof value === 'object') return value; try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } }; + const sourceLabel = source => source.display_name || source.name || source.source_code || source.kind || `Source ${source.id}`; + const sourceType = source => source.source_code || source.kind || source.type || 'unknown'; + const sourceText = (source, keys, fallback='Not returned') => { for (const key of keys) if (source?.[key] !== undefined && source[key] !== null && source[key] !== '') return source[key]; return fallback; }; function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } } - function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `${sources.map(s => ``).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>``).join('') || ''; } - function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '
No sources returned by the workspace.
'; return; } list.innerHTML = sources.map(source => { const status = sourceStatus(source), health = source.health || source.health_status || 'Not tested', terms = source.terms_reviewed ?? source.terms_status ?? 'Not reviewed', config = source.config || {}; return `
${esc(source.name || source.label || `Source ${source.id}`)}${esc(source.kind || source.source_type || source.type || 'manual')}${source.url || config.url ? ` · ${esc(source.url || config.url)}` : ''}
${status}
Owner
${esc(source.owner || source.owner_name || config.owner || 'Not assigned')}
Terms
${esc(String(source.terms_reviewed ?? source.terms_status ?? (config.terms_url ? 'Provided' : 'Not reviewed')))}
Rate limit
${esc(source.rate_limit || source.rate_limit_label || config.rate_limit || 'Not set')}
Health
${esc(String(health))}
`; }).join(''); } + function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `${sources.filter(source=>!source.optional).map(s => ``).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>``).join('') || ''; } + function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '
No registered or available sources returned by the workspace.
'; return; } list.innerHTML = sources.map(source => { const policy=sourceJson(source.policy_json||source.policy), quota=sourceJson(source.quota_json||source.quota), config=sourceJson(source.config_json||source.config), status=sourceStatus(source), configured=source.configured ?? (!source.optional && (source.approved || sourceType(source)==='manual'||sourceType(source)==='csv')), available=source.available ?? (!source.optional || Boolean(source.configured)), health=sourceText(source,['health_status','health'],'Not tested'), failures=sourceText(source,['consecutive_failures'],'0'), circuit=source.circuit_open===true||source.circuit_open===1?'Open':'Closed', credential=sourceText(source,['api_credential_status','credential_status'],source.optional?'Required / not configured':'Not required'), terms=sourceText(source,['terms_status','terms_reviewed'],policy.terms_accepted===true?'Accepted':policy.terms_url||config.terms_url?'Provided':'Not reviewed'), owner=sourceText(source,['owner','owner_name'],policy.owner||config.owner||'Not assigned'), rate=sourceText(source,['rate_limit','rate_limit_label'],policy.rate_limit||config.rate_limit||'Not set'), daily=sourceText(source,['daily_quota','daily_limit'],quota.daily_limit||'Not set'), lastHealth=sourceText(source,['last_health_at','last_checked_at','updated_at'],'Not checked'), success=sourceText(source,['last_success_at'],'No successful run'), error=sourceText(source,['last_error','error'],'None recorded'); return `
${esc(sourceLabel(source))}${esc(sourceType(source))}
${source.optional?'Optional adapter · configuration-gated':'Registered workspace source'}
${esc(status==='unavailable'?'Unavailable':status)}
Integration
${esc(source.optional?'Optional adapter':'Registered')}
Configured
${configured?'Yes':'No'}
Available
${available?'Yes':'No'}
Enabled
${sourceState(source)?'Yes':'No'}
API credential
${esc(credential)}
Terms
${esc(terms)}
Owner
${esc(owner)}
Rate limit
${esc(rate)}
Daily quota
${esc(daily)}
Last health
${esc(lastHealth)} · ${esc(health)}
Success / error
${esc(success)}
${esc(error)}
Circuit
${esc(circuit)} · ${esc(failures)} failures
`; }).join(''); } function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '
No source records returned by the workspace.
'; return; } list.innerHTML = `
${items.slice(0,25).map(record => ``).join('')}
RecordSourceStatusObserved
${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}${esc(record.source_name || record.source || 'Unknown source')}${esc(record.status || 'Pending')}${esc(record.observed_at || record.created_at || 'Time unavailable')}
`; } - async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '
Loading source registry…
'; $('sourceRecordsList').innerHTML = '
Loading source records…
'; try { const [sourcePayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/source-records?page_size=25')]); sources = sourceItems(sourcePayload); renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } } + async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '
Loading source registry…
'; $('sourceRecordsList').innerHTML = '
Loading source records…
'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:true,configured:false,available:false,enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Optional integrations remain unavailable until configured and approved.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } } async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } } - async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source) return; try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } } + async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source || source.optional) return; if (action === 'review') { selectedSourceId=id; document.querySelector(`[data-source-id="${CSS.escape(String(id))}"]`)?.scrollIntoView({behavior:'smooth',block:'center'}); sourceMessage('Source details are shown below. Review terms, owner, limits, health, and circuit state before enabling.'); return; } try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } } async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } } let discoveryRuns = [], selectedDiscoveryRunId = null, discoveryPollTimer = null; const discoveryStatus = run => String(run?.status || run?.state || 'queued').toLowerCase().replaceAll('_','-'); diff --git a/apps/web/asset-manifest.json b/apps/web/asset-manifest.json index eb6c6ca..d78907f 100644 --- a/apps/web/asset-manifest.json +++ b/apps/web/asset-manifest.json @@ -1,6 +1,6 @@ { "schema": 1, - "version": "phase-19", + "version": "phase-20", "entrypoints": [ "config.js", "app.js", @@ -13,10 +13,10 @@ "healthz" ], "integrity": { - "config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6", - "app.js": "sha256-ab3d9046b53fd87950bedeec66da479da8b935bc070b60490a1aab7b22fff2a8", - "styles.css": "sha256-423d0f9489061aff6420bea3d854e49aaf1c35d94a8c0e0bca2f82434781a2b7", - "index.html": "sha256-1979f265a29009f7bd5380401c2185c6473394eb2412b592c50ca90271cb6d3f", + "config.js": "sha256-12a10f772029a5ee6d813ed9fd61dfc7ff877aa356bf90f65987948cc3274f90", + "app.js": "sha256-de1bc9074e8f41d5a964f199e220ce5b48fabbfd0e0301000d3870db84696a43", + "styles.css": "sha256-ddbb75572a2e80e19a99834a8fb61540e0d94fffe4658429c99116242034183c", + "index.html": "sha256-2cba8095f33d9eaed8d73c33eecb08b82b223448240e9ef0d96de8c836122da8", "health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81", "error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf", "healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22" diff --git a/apps/web/config.js b/apps/web/config.js index 88240ba..bccf855 100644 --- a/apps/web/config.js +++ b/apps/web/config.js @@ -1,5 +1,5 @@ /* Public, non-secret runtime configuration. Replace this file at deploy time if needed. */ window.__PROSPECT_CONFIG__ = Object.freeze({ apiBase: '', - assetVersion: 'phase-17' + assetVersion: 'phase-20' }); diff --git a/apps/web/index.html b/apps/web/index.html index f08b7ce..6c6030b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,7 +5,7 @@ ProspectOS · Pipeline intelligence - +
@@ -45,7 +45,7 @@
Workspace / Growth pipeline
● Connecting…
?
-

EVIDENCE-LED PROSPECTING

Good morning, Alex

Your pipeline has 0 prospects ready for review.

+

EVIDENCE-LED PROSPECTING

Good morning, there

Your pipeline has 0 prospects ready for review.

SOURCE INTELLIGENCE

Discovery runs

Build a criteria-first run across approved sources, monitor it live, and review every result with provenance before it enters your pipeline.

Bounded · review first
@@ -90,14 +90,14 @@
-

GOVERNANCE

Sources

Review source ownership, terms, limits, and health before using discovery.

+

GOVERNANCE · SOURCE REGISTRY

Sources

One operational view of every registered integration. Configure, test, and enable sources only after their terms, owner, limits, and health are reviewable.

Discovery is disabled by default. No live source is enabled in this workspace. Enable a source only after its terms, owner, rate limit, and health have been reviewed.
-

CONFIGURATION

Add a source

Manual or CSV
+

SOURCE SETUP

Add a governed source

Manual or CSV only

Bring in operator-controlled records without activating an external integration. New sources start disabled and remain review-only until explicitly enabled.

DISCOVERY

Query a source

No automatic runs
-

REGISTRY

Configured sources

Not loaded
Sign in to load sources from the workspace.
+

REGISTRY STATUS

Configured & available integrations

Configured sources are shown alongside optional adapters so unavailable capability is never mistaken for an active source.

Not loaded
Sign in to load sources from the workspace.

RECENT OUTPUT

Recent source records

No records loaded.

INTAKE

Add a prospect

Manual entry
@@ -130,7 +130,7 @@ - - + + diff --git a/apps/web/scripts/final-acceptance.mjs b/apps/web/scripts/final-acceptance.mjs index d8ebbda..ded8e60 100644 --- a/apps/web/scripts/final-acceptance.mjs +++ b/apps/web/scripts/final-acceptance.mjs @@ -55,7 +55,7 @@ const config = text('config.js'); const expectedIds = [ 'loginScreen', 'loginForm', 'loginEmail', 'loginPassword', 'dashboardShell', 'logoutBtn', 'apiStatus', - 'explorer', 'detailPanel', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect', + 'explorer', 'detailPanel', 'userGreetingName', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect', 'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel', 'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'directDiscoverySources', 'directDiscoveryDailyLimit', 'directDiscoverySchedule', 'directDiscoveryDryRun', 'sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'crmPipeline', 'pipelineBoard', 'crmActivity', 'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport', @@ -80,6 +80,10 @@ const routeContracts = [ ]; check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', ')); check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest')); +check('auth.display-name-greeting', 'authenticated display name drives the greeting and identity', js.includes('currentUser.display_name') && js.includes("$('userGreetingName').textContent") && !html.includes('Good morning, Alex')); +check('sources.registry-status', 'source registry exposes governed configuration and operational status fields', all(['source_code', 'display_name', 'configured', 'available', 'enabled', 'API credential', 'Terms', 'Owner', 'Rate limit', 'Daily quota', 'Last health', 'Success / error', 'Circuit', 'data-source-action="review"', 'data-source-action="test"'], token => `${html}\n${js}\n${css}`.includes(token))); +check('sources.optional-gated', 'optional adapters are rendered unavailable until configured', all(['/api/v1/sources/adapters', 'configuration-gated', 'Optional adapter', 'source.optional', 'available:false'], token => `${html}\n${js}\n${css}`.includes(token))); +check('sources.setup-affordances', 'manual and CSV setup affordances remain explicit and disabled by default', all(['Manual records', 'CSV import', 'CSV content is required', 'enabled:false', 'remains disabled'], token => `${html}\n${js}\n${css}`.includes(token))); check('discovery.operator-controls', 'discovery builder and run controls are represented', all(['data-run-action="pause"', 'data-run-action="resume"', 'data-run-action="cancel"', 'live-log', 'source-health', 'daily_limit', 'source_ids', 'schedule'], token => `${html}\n${js}\n${css}`.includes(token))); check('prospects.filter-contract', 'prospect explorer exposes source, geography, category, and contact filters', all(['sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'contact_status'], token => `${html}\n${js}\n${css}`.includes(token))); check('routes.no-arbitrary-target-fetch', 'browser does not fetch arbitrary target URLs directly', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js)); @@ -108,7 +112,7 @@ if (manifestAssets) { const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )]; check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', ')); check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board', '.ai-provider-grid'], selector => css.includes(selector))); -check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector))); +check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.source-registry-facts', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector))); const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`; const safetyCopy = ['No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.', 'Approval does not send a message.', 'no outreach will be sent', 'AUTOMATED_OUTREACH_ENABLED=false']; diff --git a/apps/web/styles.css b/apps/web/styles.css index c98f50d..7b3e347 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -103,7 +103,7 @@ h4 { font-size: 14px; } .hero h1 span { color: var(--accent); font-size: .7em; } .hero-sub { margin: 11px 0 0; color: var(--muted); font-size: 15px; } .hero-sub strong { color: var(--ink); } -.metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } +.metrics { display: grid; grid-template-columns: 1.25fr 1fr 1fr 1fr 1fr; gap: 12px; }.metric-card:first-child { border-color: #b8ccd7; background: linear-gradient(135deg, #fffdf9, #f3f9f8); }.metric-card:first-child h2 { font-size: 32px; }.metric-card:first-child .metric-icon { color: #fff; background: var(--navy-2); } .metric-card { min-width: 0; display: flex; gap: 13px; align-items: flex-start; padding: 17px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); transition: .18s ease; } .metric-card:hover { transform: translateY(-2px); border-color: #b5c9d5; box-shadow: var(--shadow); } .metric-icon { width: 35px; height: 35px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 10px; color: var(--navy); background: var(--blue-soft); font-size: 19px; } @@ -230,7 +230,7 @@ td:first-child { color: var(--ink-strong); font-weight: 750; } .job-empty, .source-empty, .crm-empty { padding: 34px 16px; color: var(--muted); text-align: center; } .job-detail { min-height: 320px; }.job-progress { margin: 20px 0; }.job-progress-meta { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 8px; color: var(--muted); font-size: 11px; }.job-progress-meta strong { color: var(--ink); } .event-timeline, .crm-timeline { margin-top: 20px; }.event-timeline h4 { margin-bottom: 10px; }.event-timeline ol, .crm-timeline { display: grid; gap: 15px; margin: 0; padding: 0; list-style: none; }.event-timeline li, .crm-timeline li { display: flex; gap: 10px; }.timeline-dot { width: 8px; height: 8px; flex: 0 0 auto; margin-top: 6px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); }.event-timeline small, .crm-timeline small { display: block; color: var(--muted); font-size: 11px; } -.source-safety, .suppression-warning { margin: 14px 0; padding: 12px 15px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.source-row { display: grid; grid-template-columns: minmax(170px,1fr) auto; gap: 12px 18px; padding: 16px 0; border-top: 1px solid var(--line); }.source-row-main { display: flex; min-width: 0; flex-direction: column; gap: 3px; }.source-row-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.source-row-main small { color: var(--muted); font-size: 11px; }.source-facts { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0; }.source-actions { grid-column: 1 / -1; display: flex; gap: 7px; }.csv-panel .panel-heading { align-items: center; }.upload-label { cursor: pointer; }.csv-empty { display: grid; place-items: center; min-height: 160px; margin-top: 17px; border: 1px dashed var(--line-strong); border-radius: 10px; color: var(--muted); text-align: center; }.csv-empty span { font-size: 28px; color: var(--teal); }.csv-empty p { margin: 6px 0; }.csv-empty small { font-size: 11px; }.csv-table { margin-top: 17px; overflow: auto; }.csv-table table { min-width: 480px; } +.source-safety, .suppression-warning { margin: 14px 0; padding: 12px 15px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.source-row { display: grid; grid-template-columns: minmax(170px,1fr) auto; gap: 12px 18px; padding: 16px 0; border-top: 1px solid var(--line); }.source-row-main { display: flex; min-width: 0; flex-direction: column; gap: 3px; }.source-row-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.source-row-main small { color: var(--muted); font-size: 11px; }.source-title-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.source-type { padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: var(--surface-alt); font-size: 10px; font-weight: 750; }.source-optional { opacity: .78; background: linear-gradient(90deg, rgba(247,245,239,.65), transparent); }.source-optional .source-row-main strong { color: var(--muted); }.source-registry-caption, .source-setup-copy { max-width: 720px; margin: 7px 0 0; font-size: 12px; }.source-facts { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0; }.source-registry-facts { grid-template-columns: repeat(6, minmax(0, 1fr)); }.source-facts div { padding: 9px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }.source-facts dt { color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }.source-facts dd { margin: 4px 0 0; overflow-wrap: anywhere; }.source-actions { grid-column: 1 / -1; display: flex; gap: 7px; }.source-status.unavailable { color: var(--muted); background: var(--surface-alt); }.source-status.disabled { color: var(--amber); background: var(--amber-soft); }.csv-panel .panel-heading { align-items: center; }.upload-label { cursor: pointer; }.csv-empty { display: grid; place-items: center; min-height: 160px; margin-top: 17px; border: 1px dashed var(--line-strong); border-radius: 10px; color: var(--muted); text-align: center; }.csv-empty span { font-size: 28px; color: var(--teal); }.csv-empty p { margin: 6px 0; }.csv-empty small { font-size: 11px; }.csv-table { margin-top: 17px; overflow: auto; }.csv-table table { min-width: 480px; } /* CRM and evidence modules */ .pipeline-board { display: grid; grid-template-columns: repeat(4, minmax(190px, 1fr)); gap: 11px; overflow-x: auto; align-items: start; }.pipeline-column { min-height: 180px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: #e9eeed; }.pipeline-column-head { display: flex; align-items: center; justify-content: space-between; }.pipeline-column-head h3 { margin: 0; font-size: 13px; }.pipeline-card { margin: 8px 0; padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); box-shadow: var(--shadow-sm); }.pipeline-card.is-suppressed { border-color: #e2a9b0; background: #fff7f7; }.pipeline-card-link { display: grid; width: 100%; gap: 4px; padding: 0; border: 0; color: inherit; background: none; text-align: left; }.pipeline-card-link small, .pipeline-card-link .score { font-size: 11px; color: var(--muted); }.pipeline-card-actions { display: flex; gap: 6px; margin-top: 9px; }.pipeline-card-actions select { min-width: 0; padding: 6px; font-size: 11px; }.pipeline-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; }.pipeline-list-view { display: block; }.crm-column-empty { padding: 20px 8px; color: var(--muted); font-size: 11px; text-align: center; }.crm-timeline .outcome-chip { margin-left: 7px; color: var(--navy-2); background: var(--blue-soft); }.crm-timeline p { margin: 5px 0; }.reports-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }.report-panel { min-height: 190px; }.report-rows { display: grid; gap: 7px; margin-top: 15px; }.report-rows > div { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }.report-rows strong { color: var(--teal); }.report-note { color: var(--muted); font-size: 11px; }.suppression-list { display: grid; gap: 8px; }.suppression-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; }.suppression-row .checkbox-label { flex: 1; margin: 0; }.suppression-row .checkbox-label > span { display: flex; min-width: 0; flex-direction: column; }.suppression-row small { color: var(--muted); }.provider-policy-panel { display: grid; gap: 10px; }.provider-policy-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }.provider-policy-row strong { display: block; }.provider-policy-row small { color: var(--muted); }.provider-policy-row dl { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 0; }.provider-policy-row dd { margin: 3px 0 0; }.provider-state { padding: 20px 4px; color: var(--muted); }.provider-state strong { color: var(--ink); }.provider-state.error strong { color: var(--red); }