diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index 7dd3f93..d55b2c9 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -52,6 +52,7 @@ jobs: npm run test:photo npm run test:mobile-capture npm run test:sleek + npm run test:manual-fallback - name: Dependency audit run: npm audit --audit-level=high - name: Syntax checks diff --git a/README.md b/README.md index eaa8686..c81f2ff 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ npm test npm run test:ui # server required on port 4173 npm run test:photo # mocked positive suggestion through the real browser UX npm run test:sleek # simplified 390x844 shell + free-text Hermes chat +npm run test:manual-fallback # vision-offline manual path + in-session draft recovery npm audit --audit-level=high ``` @@ -112,6 +113,8 @@ npm audit --audit-level=high - Free-text smart chat with real server-side Hermes session continuity and local fallback - Authenticated, same-origin, bounded browser-to-agent gateway with no browser-side credentials - Red-flag symptom escalation +- Manual fallback that never depends on AI: when the vision worker is offline or disabled, no AI or consent control is offered and manual Bristol entry stays one action away +- Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies - Local browser ledger, calendar, pattern summary, JSON portability, and delete-all - Installable/offline PWA shell for the manual and saved-ledger paths diff --git a/app.js b/app.js index 55c7a4e..022975b 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,7 @@ import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.js'; +import { createDraft, draftNotice, isDraftDirty } from './src/draft.js'; +import { visionReadiness } from './src/vision-readiness.js'; const runtimeConfig = { basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/', @@ -22,7 +24,6 @@ if (BASE_PATH === '/') { const app = document.querySelector('#app'); let entries = loadEntries(); let view = 'home'; -let photoDataUrl = ''; let photoHint = ''; let aiSuggestion = null; let visionStatus = null; @@ -31,8 +32,15 @@ let agentStatus = null; let chatBusy = false; let chatError = ''; let chatMessages = [{ role: 'timmy', text: 'Ask me about your confirmed logs, visible patterns, privacy, or how Timmy works.' }]; -const draft = () => ({ bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: '', symptoms: {} }); -let form = draft(); +let draftResumed = false; +let form = createDraft(); + +function draftBanner(){ + if(!draftResumed||!isDraftDirty(form))return ''; + return `
Draft kept in this session: ${esc(draftNotice(form))}
`; +} +function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()} +function clearDraft(){form=createDraft();draftResumed=false} function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); } function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } } @@ -122,7 +130,7 @@ function exportData(){const blob=new Blob([exportLedger(entries)],{type:'applica async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}} function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}} -function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()} +function openPhotoFirst(){beginDraft();photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()} async function loadVisionStatus(){ try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}} catch{visionStatus={enabled:false,providerReady:false}} @@ -136,7 +144,7 @@ function visionStatusHtml(){ } function photoFirstBody(mode,error=''){ if(mode==='pick')return `${visionStatusHtml()}
Timmy

One photo. Two useful suggestions.

Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.

${error?`

${esc(error)}

`:''}
`; - if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}Photo awaiting AI analysis

${esc(photoHint)}

`;} + if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}Photo kept on this device

${esc(photoHint)}

${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.

`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}Photo awaiting AI analysis

${esc(photoHint)}

`;} if(mode==='analyzing')return `
Timmy

Timmy is looking at form and color…

Not symptoms. Not disease. Not whether Taco Bell was a strategic error.

`; if(mode==='error')return `
↻

Timmy couldn’t analyze that safely.

${esc(error||'Continue manually or try a clearer photo.')}

`; if(aiSuggestion?.status==='suggestion')return `
AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE
BRISTOL FORMType ${aiSuggestion.bristolType}
VISIBLE COLOR${esc(aiSuggestion.color)}

${esc(aiSuggestion.observations||'Visual match found.')}

${esc(aiSuggestion.warning)}

`; @@ -147,33 +155,34 @@ function showPhotoFirst(mode='pick',error=''){ document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()}; document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))}); const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis; - document.querySelector('#retake-photo')?.addEventListener('click',()=>{photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')}); + document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')}); document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)}); - document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)}); + document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)}); } -async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}} -async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}} +async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}} +async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}} -function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)} +function openLogger(){beginDraft();photoHint='';aiSuggestion=null;showLogStep(1)} function showLogStep(step){ document.querySelector('.sheet-backdrop')?.remove(); - const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;document.body.append(wrap); + const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;document.body.append(wrap); document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step); } function stepBody(step){ if(step===1)return `${aiSuggestion?.status==='suggestion'?`
AI PREFILLEDType ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}You’re in charge—tap any type to correct it.
`:'

Choose the closest match yourself. Timmy never treats a suggestion as fact.

'}
${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>``).join('')}
`; - if(step===2)return `${photoDataUrl?`Private entry preview`:''}${photoHint?`

${esc(photoHint)}

`:''}${aiSuggestion?.status==='suggestion'?'

AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.

':'

Manual-mode photos stay in this browser and receive quality checks only.

'}
`; + if(step===2)return `${form.photoDataUrl?`Private entry preview`:''}${photoHint?`

${esc(photoHint)}

`:''}${aiSuggestion?.status==='suggestion'?'

AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.

':'

Manual-mode photos stay in this browser and receive quality checks only.

'}
`; const urgent=detectUrgentFlags(form.symptoms);return `

Select anything you have now. This is where Timmy stops joking.

${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>``).join('')}
${urgent.urgent?alertHtml(urgent.message):''}

Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.

`; } function alertHtml(msg){return `
Pause and get medical help.

${esc(msg)}

`} function bindStep(step){ - if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{form.bristolType=Number(b.dataset.type);aiSuggestion=null;showLogStep(1)});document.querySelector('#next').onclick=()=>showLogStep(2)} + document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)}); + if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{form.bristolType=Number(b.dataset.type);form.typeChosen=true;aiSuggestion=null;showLogStep(1)});document.querySelector('#next').onclick=()=>{form.typeChosen=true;showLogStep(2)}} if(step===2){const color=document.querySelector('#color');color.value=form.color;color.onchange=()=>form.color=color.value;['urgency','discomfort'].forEach(k=>{const n=document.querySelector('#'+k);n.oninput=()=>{form[k]=Number(n.value);n.nextElementSibling.value=n.value}});document.querySelector('#note').oninput=e=>form.note=e.target.value;document.querySelector('#photo').onchange=handlePhoto;document.querySelector('#back').onclick=()=>showLogStep(1);document.querySelector('#next').onclick=()=>showLogStep(3)} if(step===3){document.querySelectorAll('[data-symptom]').forEach(c=>c.onchange=()=>{form.symptoms[c.dataset.symptom]=c.checked;document.querySelector('#urgent-box').innerHTML=detectUrgentFlags(form.symptoms).urgent?alertHtml(detectUrgentFlags(form.symptoms).message):''});document.querySelector('#back').onclick=()=>showLogStep(2);document.querySelector('#save').onclick=saveLog} } -async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}} +async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}} function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i{URL.revokeObjectURL(url);reject()};img.src=url})} -function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,photoDataUrl});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')} +function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}clearDraft();document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')} function render(){({home,calendar,timmy,privacy}[view]||home)()} render(); diff --git a/artifacts/draft-resumed-mobile.png b/artifacts/draft-resumed-mobile.png new file mode 100644 index 0000000..06a9fa3 Binary files /dev/null and b/artifacts/draft-resumed-mobile.png differ diff --git a/artifacts/manual-fallback-offline-mobile.png b/artifacts/manual-fallback-offline-mobile.png new file mode 100644 index 0000000..5b0e88d Binary files /dev/null and b/artifacts/manual-fallback-offline-mobile.png differ diff --git a/artifacts/manual-fallback-ready-offline-mobile.png b/artifacts/manual-fallback-ready-offline-mobile.png new file mode 100644 index 0000000..5b0e88d Binary files /dev/null and b/artifacts/manual-fallback-ready-offline-mobile.png differ diff --git a/docs/PRODUCT-DECISIONS.md b/docs/PRODUCT-DECISIONS.md index dc80006..464ab73 100644 --- a/docs/PRODUCT-DECISIONS.md +++ b/docs/PRODUCT-DECISIONS.md @@ -30,6 +30,10 @@ Timmy may expose a smart free-text conversation backed by a fully tool-capable H The application’s deterministic urgent-symptom guidance runs before chat and remains authoritative when Hermes is offline, slow, malformed, or conversationally mistaken. Hermes must not diagnose, invent symptoms, override escalation, or take external/destructive actions without explicit user intent and confirmation. +### Availability and draft retention + +Logging must never depend on AI availability. When the vision worker is offline, disabled, or still unknown, the photo-first flow offers no AI action and no analysis-consent prompt, and manual Bristol entry remains one action away from every photo step. A chosen photo and any entered context are retained as an in-session draft with a visible summary and an explicit start over; drafts are deliberately not persisted to storage, so a photo awaiting consent is never written to disk. The service worker never caches analysis requests or image bodies. + ### Release authority Hermes/Timmy is the release authority for routine engineering, test, build, deployment, and release-candidate preparation. Automated evidence must remain reproducible and must not overstate model accuracy or safety. diff --git a/package.json b/package.json index ffea87f..34020b3 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,14 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js", + "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js", "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:mobile-capture": "node tests/mobile-capture.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs", + "test:manual-fallback": "node tests/manual-fallback.acceptance.mjs", "test:staging-smoke": "node tests/staging.acceptance.mjs", - "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py", + "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py", "check:diff": "bash scripts/check_diff.sh", "start": "node server.mjs" }, diff --git a/scripts/build_release.py b/scripts/build_release.py index 2ec573c..350f06d 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -111,6 +111,7 @@ def main() -> int: run(["npm", "run", "test:photo"], tree) run(["npm", "run", "test:mobile-capture"], tree) run(["npm", "run", "test:sleek"], tree) + run(["npm", "run", "test:manual-fallback"], tree) demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm" demo_env = dict(server_env) demo_env["TIMMY_RELEASE_VERSION"] = version @@ -134,7 +135,7 @@ def main() -> int: contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg" run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", "fps=1/2,scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree) run(["npm", "audit", "--audit-level=high"], tree) - for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"): + for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"): run(["node", "--check", file], tree) run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree) run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree) @@ -158,6 +159,7 @@ def main() -> int: f"- Source commit: [{commit}]({PUBLIC_REPO}/commit/{commit})\n" f"- Build date (UTC): {release_date}\n" "- Sleek three-destination shell with one dominant photo action and quiet manual fallback\n" + "- Manual fallback: vision-offline logging stays one action away with an in-session draft and explicit start over\n" "- Smart free-text Timmy chat backed by authenticated server-side Hermes session continuity\n" "- Agent safety: exact-origin HttpOnly session, bounded text-only ledger context, no browser credentials or session IDs\n" "- Vision profiles: hosted and self-hosted SmolVLM2 bootstrap\n" @@ -205,13 +207,14 @@ def main() -> int: "codec": "h264", "pixel_format": "yuv420p", "recorded_from_working_app": True, - "fixture_data": "synthetic-Type-4, deterministic vision suggestion, and deterministic Hermes chat response", + "fixture_data": "synthetic-Type-4, deterministic vision suggestion, offline vision status, and deterministic Hermes chat response", }, "gates": { "unit_security": "passed", "mobile_green_path": "passed", "photo_first_acceptance": "passed", "sleek_hermes_chat_acceptance": "passed", + "manual_fallback_draft_recovery": "passed", "dependency_audit_high": "passed", "syntax": "passed", "secret_scan": "passed", diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index e704cb7..7cec61d 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -19,6 +19,7 @@ const page = await context.newPage(); const errors = []; page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.on('pageerror', error => errors.push(error.message)); +let visionOffline = false; await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', @@ -27,8 +28,9 @@ await page.route('**/api/vision-status', route => route.fulfill({ profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', - providerReady: true, - modelSeen: true, + // The second pass flips the app's own readiness contract so no AI control is offered at all. + providerReady: !visionOffline, + modelSeen: !visionOffline, }), })); await page.route('**/api/analyze', route => route.fulfill({ @@ -137,6 +139,23 @@ await caption('AI may suggest visible form, broad color, and image quality — n await tap('#use-suggestion', 450); await caption('The user reviews every suggestion before saving', 1100); await tap('#close-sheet', 450); +await caption('Closing the log keeps the draft in this session instead of throwing it away', 1600); +await caption('Now the same photo flow while the vision worker is offline', 1500); +visionOffline = true; +await tap('[data-scan]', 550); +await page.getByText(/Vision worker offline/i).waitFor(); +await caption('Vision worker offline — the AI and consent steps disappear instead of failing after upload', 1900); +await indicate('label[for="gallery-photo"]'); +await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); +await page.getByText(/Ready for your review/i).waitFor(); +await caption('No consent prompt and no upload when analysis cannot run', 1600); +await tap('#manual-from-scan', 500); +const keptDraft = await page.locator('.draft-resume').innerText({ timeout: 5000 }); +if (!/Type 4/.test(keptDraft)) throw new Error('The retained draft must show exactly what the user is keeping'); +await caption('One tap still reaches manual Bristol entry', 1500); +await tap('#discard-draft', 500); +await caption('Start over clears the kept draft — nothing lingers silently', 1500); +await tap('#close-sheet', 450); await tap('[data-view="timmy"]', 550); await page.getByText(/Hermes Agent connected/i).waitFor(); await caption('Hermes Agent connected — free-text, contextual, and server-side', 1400); diff --git a/service-worker.js b/service-worker.js index bbe69e7..1a4834d 100644 --- a/service-worker.js +++ b/service-worker.js @@ -37,7 +37,10 @@ self.addEventListener('fetch', event => { event.respondWith( fetch(event.request) .then(response => { - if (!/\bno-store\b/i.test(response.headers.get('cache-control') || '')) { + const cacheControl = response.headers.get('cache-control') || ''; + const contentType = response.headers.get('content-type') || ''; + // Never persist photo or image bodies: a private photo must stay a transient upload. + if (!/\bno-store\b/i.test(cacheControl) && !/^\s*image\//i.test(contentType)) { const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); } diff --git a/src/draft.js b/src/draft.js new file mode 100644 index 0000000..0d0a539 --- /dev/null +++ b/src/draft.js @@ -0,0 +1,39 @@ +// A draft is what the user has entered but not yet saved. +// It is kept in this browser only, so a recoverable close or navigation never loses it. +export function createDraft() { + return { + bristolType: 4, + typeChosen: false, + color: 'brown', + urgency: 0, + discomfort: 0, + note: '', + symptoms: {}, + photoDataUrl: '', + }; +} + +export function isDraftDirty(draft = {}) { + return Boolean( + draft.typeChosen + || draft.photoDataUrl + || String(draft.note || '').trim() + || Number(draft.urgency) > 0 + || Number(draft.discomfort) > 0 + || (draft.color && draft.color !== 'brown') + || Object.values(draft.symptoms || {}).some(Boolean), + ); +} + +export function draftNotice(draft = {}) { + if (!isDraftDirty(draft)) return ''; + const parts = []; + if (draft.typeChosen) parts.push(`Type ${draft.bristolType}`); + if (draft.color && draft.color !== 'brown') parts.push(`${draft.color} color`); + if (Number(draft.urgency) > 0) parts.push('urgency'); + if (Number(draft.discomfort) > 0) parts.push('discomfort'); + if (String(draft.note || '').trim()) parts.push('note'); + if (Object.values(draft.symptoms || {}).some(Boolean)) parts.push('symptoms'); + if (draft.photoDataUrl) parts.push('photo'); + return parts.join(' · '); +} diff --git a/src/vision-readiness.js b/src/vision-readiness.js new file mode 100644 index 0000000..f969aec --- /dev/null +++ b/src/vision-readiness.js @@ -0,0 +1,35 @@ +// Decides whether the photo-first flow can promise AI analysis. +// When it cannot, logging must still reach manual Bristol entry in one action. +const MANUAL_LABEL = 'Continue without AI'; +const DISABLED_LABEL = 'Log it manually'; + +export function visionReadiness(status) { + if (!status) { + return { + phase: 'checking', + ready: false, + manualOnly: true, + manualLabel: MANUAL_LABEL, + message: 'Checking the vision worker. Manual logging is already available.', + }; + } + if (status.enabled === false) { + return { + phase: 'disabled', + ready: false, + manualOnly: true, + manualLabel: DISABLED_LABEL, + message: 'AI photo analysis is off in this build. Manual logging is fully available.', + }; + } + if (status.providerReady !== true) { + return { + phase: 'offline', + ready: false, + manualOnly: true, + manualLabel: MANUAL_LABEL, + message: 'Vision worker offline. Manual logging is still available.', + }; + } + return { phase: 'ready', ready: true, manualOnly: false, manualLabel: '', message: '' }; +} diff --git a/styles.css b/styles.css index 9345d5e..ca91348 100644 --- a/styles.css +++ b/styles.css @@ -17,6 +17,7 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t .btn{min-height:50px;border:0;border-radius:16px;padding:0 17px;font-weight:800;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px}.btn:active{transform:scale(.98)}.btn:disabled{opacity:.45;cursor:not-allowed}.btn-primary{background:var(--ink);color:#fff}.btn-secondary{background:var(--teal-soft);color:var(--teal)}.btn-ghost{background:transparent;border:1px solid var(--line)}.btn-danger{background:var(--red);color:#fff}.btn-wide{width:100%}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.fine{font-size:12px;color:var(--muted);line-height:1.45}.empty{text-align:center;padding:22px 10px;color:var(--muted)}.empty img{width:68px}.empty h3{color:var(--ink);margin-top:8px}.empty p{margin-bottom:0} .chat-page{display:flex;flex-direction:column;min-height:calc(100vh - 175px)}.chat-title{padding-bottom:8px}.agent-status{display:flex;align-items:center;gap:11px;padding:11px 13px;background:rgba(255,253,250,.7);border:1px solid var(--line);border-radius:17px;margin-bottom:12px}.agent-status>i{width:10px;height:10px;border-radius:50%;background:#a9a39d;box-shadow:0 0 0 5px rgba(169,163,157,.13)}.agent-status.connected>i{background:#209479;box-shadow:0 0 0 5px rgba(32,148,121,.13)}.agent-status.locked>i{background:#d19b27}.agent-status strong,.agent-status small{display:block}.agent-status strong{font-size:13px}.agent-status small{font-size:11px;color:var(--muted);margin-top:2px}.conversation{background:var(--surface);border:1px solid var(--line);border-radius:23px;padding:13px;box-shadow:0 10px 30px rgba(55,40,31,.05)}.chat{display:flex;flex-direction:column;gap:9px;min-height:195px;max-height:42vh;overflow:auto;padding:4px 1px 14px}.bubble{max-width:86%;padding:11px 13px;border-radius:17px;line-height:1.42;font-size:14px;white-space:pre-wrap}.bubble.timmy{align-self:flex-start;background:#efebe4;border-bottom-left-radius:5px}.bubble.user{align-self:flex-end;background:var(--teal);color:white;border-bottom-right-radius:5px}.thinking{display:flex;gap:4px}.thinking span{width:6px;height:6px;border-radius:50%;background:#8b847d;animation:blink 1s infinite}.thinking span:nth-child(2){animation-delay:.15s}.thinking span:nth-child(3){animation-delay:.3s}@keyframes blink{50%{opacity:.25;transform:translateY(-2px)}}.composer{display:grid;grid-template-columns:1fr 45px;gap:8px;align-items:end;background:#f0ece5;border-radius:18px;padding:6px}.composer textarea{border:0;background:transparent;resize:none;min-height:42px;max-height:110px;padding:10px 9px;outline:0;color:var(--ink)}.composer button{width:44px;height:44px;border:0;border-radius:14px;background:var(--ink);color:white;font-size:22px;cursor:pointer}.composer button:disabled{opacity:.4}.composer-note{font-size:10px;color:var(--muted);margin:7px 5px 0;line-height:1.35}.chat-error{font-size:12px;color:var(--red);margin:0 4px 8px}.safety-line{margin:13px 4px 0;color:var(--muted);font-size:11px;line-height:1.45}.safety-line strong{color:var(--ink)}.unlock-card{background:#fff8e6;border:1px solid #ead9a9;border-radius:20px;padding:14px;margin-bottom:12px}.unlock-card>label{display:block;font-size:12px;font-weight:800;margin-bottom:7px}.unlock-row{display:grid;grid-template-columns:1fr auto;gap:8px}.unlock-card .fine{margin:8px 2px 0} .sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}.progress-step-1{width:33.34%}.progress-step-2{width:66.68%}.progress-step-3{width:100%} +.draft-resume{display:flex;align-items:center;justify-content:space-between;gap:10px;background:#fdf3d8;border:1px solid #ecd9a3;border-radius:14px;padding:8px 11px;margin:0 0 14px;font-size:12px;line-height:1.35;color:#5d4a12}.draft-resume strong{font-weight:800} .scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.capture-choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.capture-status{background:var(--red-soft);color:var(--red);border-radius:14px;padding:10px 12px;font-size:12px;line-height:1.4}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:16px 10px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4} .privacy-list{display:flex;flex-direction:column;gap:4px}.privacy-item{display:grid;grid-template-columns:40px 1fr;gap:11px;padding:11px 0;border-bottom:1px solid var(--line)}.privacy-item:last-child{border-bottom:0}.privacy-item>b{width:38px;height:38px;border-radius:13px;background:var(--teal-soft);display:grid;place-items:center;color:var(--teal)}.privacy-item h3{margin-bottom:4px}.privacy-item p,.source-list p{font-size:13px;line-height:1.45;color:var(--muted);margin-bottom:5px}.source-list a{color:var(--teal)}.danger-zone{border-color:#e9c4bf}.toast{position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:var(--ink);color:white;border-radius:999px;padding:11px 16px;font-size:13px;font-weight:700;z-index:100;box-shadow:var(--shadow)} .staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72} diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 40dad15..c09b60b 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -22,6 +22,7 @@ test('Gitea CI gates pull requests and main with the reproducible quality suite' assert.match(workflow, /npm run test:photo/); assert.match(workflow, /npm run test:mobile-capture/); assert.match(workflow, /npm run test:sleek/); + assert.match(workflow, /npm run test:manual-fallback/); assert.match(workflow, /npm audit --audit-level=high/); assert.match(workflow, /npm run check:syntax/); assert.match(workflow, /npm run check:diff/); @@ -39,6 +40,16 @@ test('default unit suite includes the self-host bootstrap contract', async () => assert.match(packageJson.scripts.test, /tests\/selfhost-bootstrap\.test\.js/); }); +test('default unit suite covers the manual fallback and draft contracts', async () => { + const packageJson = JSON.parse(await readFile(packagePath, 'utf8')); + + assert.match(packageJson.scripts.test, /tests\/draft\.test\.js/); + assert.match(packageJson.scripts.test, /tests\/vision-readiness\.test\.js/); + assert.equal(packageJson.scripts['test:manual-fallback'], 'node tests/manual-fallback.acceptance.mjs'); + assert.match(packageJson.scripts['check:syntax'], /node --check src\/draft\.js/); + assert.match(packageJson.scripts['check:syntax'], /node --check src\/vision-readiness\.js/); +}); + test('staging smoke is an explicit syntax-gated promotion command, never a PR network call', async () => { const [packageJson, workflow] = await Promise.all([ readFile(packagePath, 'utf8').then(JSON.parse), diff --git a/tests/draft.test.js b/tests/draft.test.js new file mode 100644 index 0000000..de0f114 --- /dev/null +++ b/tests/draft.test.js @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDraft, draftNotice, isDraftDirty } from '../src/draft.js'; + +test('a fresh draft is empty and reports nothing to resume', () => { + const draft = createDraft(); + assert.equal(isDraftDirty(draft), false); + assert.equal(draftNotice(draft), ''); + assert.equal(draft.bristolType, 4); + assert.equal(draft.color, 'brown'); + assert.deepEqual(draft.symptoms, {}); +}); + +test('a fresh draft is independent so one log cannot leak into the next', () => { + const first = createDraft(); + first.symptoms.blood = true; + first.note = 'leaked'; + const second = createDraft(); + assert.equal(second.symptoms.blood, undefined); + assert.equal(second.note, ''); + assert.equal(isDraftDirty(second), false); +}); + +test('any user-entered context makes the draft worth resuming', () => { + const cases = [ + [{ typeChosen: true, bristolType: 6 }, /type 6/i], + [{ note: 'After lunch' }, /note/i], + [{ urgency: 3 }, /urgency/i], + [{ discomfort: 2 }, /discomfort/i], + [{ photoDataUrl: 'data:image/jpeg;base64,abc' }, /photo/i], + [{ symptoms: { blood: true } }, /symptom/i], + ]; + for (const [patch, expected] of cases) { + const draft = { ...createDraft(), ...patch }; + assert.equal(isDraftDirty(draft), true, JSON.stringify(patch)); + assert.match(draftNotice(draft), expected); + } +}); + +test('untouched defaults are not treated as user context', () => { + assert.equal(isDraftDirty({ ...createDraft(), bristolType: 7 }), false); + assert.equal(isDraftDirty({ ...createDraft(), color: 'brown', urgency: 0, discomfort: 0, note: ' ' }), false); + assert.equal(draftNotice({ ...createDraft(), color: 'brown' }), ''); +}); + +test('a changed color is user context and unconfirmed AI defaults are not', () => { + assert.equal(isDraftDirty({ ...createDraft(), color: 'black' }), true); + assert.match(draftNotice({ ...createDraft(), color: 'black' }), /color/i); +}); + +test('draft copy never implies the photo or note left this device', () => { + const notice = draftNotice({ ...createDraft(), typeChosen: true, photoDataUrl: 'data:image/jpeg;base64,abc', note: 'x' }); + assert.doesNotMatch(notice, /upload|server|sent|cloud/i); +}); diff --git a/tests/manual-fallback.acceptance.mjs b/tests/manual-fallback.acceptance.mjs new file mode 100644 index 0000000..bde71ff --- /dev/null +++ b/tests/manual-fallback.acceptance.mjs @@ -0,0 +1,78 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; + +await mkdir('artifacts', { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' }); +const page = await context.newPage(); +const errors = []; +page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); +page.on('pageerror', error => errors.push(error.message)); +let analysisRequests = 0; +await page.route('**/api/vision-status', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: false, modelSeen: false }), +})); +await page.route('**/api/analyze', route => { analysisRequests += 1; return route.abort(); }); + +await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); +await page.evaluate(() => localStorage.clear()); +await page.reload({ waitUntil: 'networkidle' }); + +// 1. Provider down: manual Bristol entry must be reachable in one action, with no dead control. +await page.locator('[data-scan]').click(); +await page.getByText(/Vision worker offline/i).waitFor(); +assert.equal(await page.locator('#analyze-photo').count(), 0, 'offline flow must not offer a dead analyze control'); +const pickFallback = page.locator('#manual-from-scan'); +assert.equal(await pickFallback.isVisible(), true); +assert.match(await pickFallback.innerText(), /without AI|manual/i); +await pickFallback.click(); +assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'one action reaches manual Bristol entry'); +assert.equal(analysisRequests, 0, 'the offline path never uploads a photo'); +await page.screenshot({ path: 'artifacts/manual-fallback-offline-mobile.png' }); + +// 2. Provider down after a photo is chosen: the ready step still offers the one-action manual path. +await page.locator('#close-sheet').click(); +await page.locator('[data-scan]').click(); +await page.getByText(/Vision worker offline/i).waitFor(); +await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); +await page.getByText(/Ready for your review/i).waitFor(); +assert.equal(await page.locator('.consent-card').count(), 0, 'no consent is requested when the worker cannot analyze'); +assert.equal(await page.locator('#analyze-photo').count(), 0, 'no dead analyze control on the ready step'); +const readyFallback = page.locator('#manual-from-scan'); +assert.equal(await readyFallback.isVisible(), true); +await readyFallback.click(); +assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'ready-step offline path is still one action'); +assert.equal(analysisRequests, 0, 'a chosen photo is never uploaded when analysis is unavailable'); +await page.screenshot({ path: 'artifacts/manual-fallback-ready-offline-mobile.png' }); + +// 3. Entered context and the chosen photo survive recoverable navigation. +await page.locator('[data-type="6"]').click(); +await page.locator('#next').click(); +await page.locator('#urgency').fill('3'); +await page.locator('#note').fill('After lunch'); +await page.locator('#close-sheet').click(); +await page.locator('[data-view="calendar"]').first().click(); +await page.locator('[data-view="home"]').first().click(); +await page.locator('[data-log]').first().click(); + +assert.match(await page.locator('.draft-resume').innerText(), /draft kept/i); +assert.equal(await page.locator('.draft-resume').innerText().then(t => /Type 6/i.test(t)), true); +assert.equal(await page.locator('[data-type="6"]').getAttribute('class').then(v => v.includes('selected')), true, 'chosen type is restored'); +await page.locator('#next').click(); +assert.equal(await page.locator('#urgency').inputValue(), '3', 'urgency is restored'); +assert.equal(await page.locator('#note').inputValue(), 'After lunch', 'note is restored'); +await page.screenshot({ path: 'artifacts/draft-resumed-mobile.png' }); + +// 4. Start over clears the retained draft so nothing lingers silently. +await page.locator('#discard-draft').click(); +assert.equal(await page.locator('.draft-resume').count(), 0); +assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), true, 'start over returns to an empty draft'); +assert.equal(await page.locator('#note').count(), 0, 'the draft is back at step 1'); + +assert.deepEqual(errors, []); +await context.close(); +await browser.close(); +console.log('PASS offline one-action manual fallback, no dead controls, and resumable draft with explicit start over'); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index c2701f6..7b80742 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -23,10 +23,31 @@ test('release demo visibly explains the CI-protected browser path without overst assert.match(demo, /SLEEK\. SIMPLE\.
HERMES-POWERED\./); }); +test('release demo exercises the vision-offline manual path and the retained draft', async () => { + const demo = await readFile(demoPath, 'utf8'); + + assert.match(demo, /Vision worker offline — the AI and consent steps disappear instead of failing after upload/); + assert.match(demo, /No consent prompt and no upload when analysis cannot run/); + assert.match(demo, /One tap still reaches manual Bristol entry/); + assert.match(demo, /Closing the log keeps the draft in this session instead of throwing it away/); + assert.match(demo, /Start over clears the kept draft — nothing lingers silently/); + assert.match(demo, /#manual-from-scan/); + assert.match(demo, /#discard-draft/); + assert.match(demo, /\.draft-resume/); + // The offline pass must be a real state change driven by the app's own readiness contract. + assert.match(demo, /providerReady: false|visionOffline/); +}); + test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => { const builder = await readFile(builderPath, 'utf8'); assert.match(builder, /"test:sleek"/); assert.match(builder, /"test:mobile-capture"/); + assert.match(builder, /"test:manual-fallback"/); + assert.match(builder, /Manual fallback: vision-offline logging stays one action away with an in-session draft/); + assert.match(builder, /"manual_fallback_draft_recovery": "passed"/); + assert.match(builder, /"src\/draft\.js"/); + assert.match(builder, /"src\/vision-readiness\.js"/); + assert.match(builder, /offline vision status/); assert.match(builder, /"sleek_hermes_chat_acceptance": "passed"/); assert.match(builder, /Sleek three-destination shell/); assert.match(builder, /"bash", "-n", "scripts\/bootstrap_selfhost_smolvlm\.sh"/); diff --git a/tests/service-worker-runtime.test.js b/tests/service-worker-runtime.test.js index e93fa4e..54cf5ce 100644 --- a/tests/service-worker-runtime.test.js +++ b/tests/service-worker-runtime.test.js @@ -95,3 +95,38 @@ test('no-store shell responses are returned without being cached', async () => { assert.deepEqual(puts, []); }); + +function headerReader(values) { + return name => values[String(name).toLowerCase()] ?? null; +} + +test('image bodies are never written into the shell cache', async () => { + const request = { method: 'GET', url: 'https://example.test/timmy-staging/history/entry-photo.jpg' }; + const imageResponse = { headers: { get: headerReader({ 'content-type': 'image/jpeg' }) }, clone: () => ({}) }; + const { listeners, puts, getCacheOpenCount } = loadWorker({ + scope: 'https://example.test/timmy-staging/', + fetchImpl: async () => imageResponse, + }); + + const response = await dispatchFetch(listeners.get('fetch'), request); + await response; + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(await response, imageResponse); + assert.deepEqual(puts, []); + assert.equal(getCacheOpenCount(), 0); +}); + +test('ordinary shell assets are still cached so offline logging keeps working', async () => { + const request = { method: 'GET', url: 'https://example.test/app.js' }; + const assetResponse = { headers: { get: headerReader({ 'content-type': 'text/javascript' }) }, clone: () => ({}) }; + const { listeners, puts } = loadWorker({ fetchImpl: async () => assetResponse }); + + const response = await dispatchFetch(listeners.get('fetch'), request); + await response; + await Promise.resolve(); + await Promise.resolve(); + + assert.deepEqual(puts, [request.url]); +}); diff --git a/tests/vision-readiness.test.js b/tests/vision-readiness.test.js new file mode 100644 index 0000000..122d0f4 --- /dev/null +++ b/tests/vision-readiness.test.js @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { visionReadiness } from '../src/vision-readiness.js'; + +test('unsolved readiness means manual logging is always reachable in one action', () => { + for (const status of [null, undefined, { enabled: false }, { enabled: true, providerReady: false }]) { + const readiness = visionReadiness(status); + assert.equal(readiness.ready, false, JSON.stringify(status)); + assert.equal(readiness.manualOnly, true, JSON.stringify(status)); + assert.equal(typeof readiness.manualLabel, 'string'); + assert.match(readiness.manualLabel, /manual|without AI/i); + } +}); + +test('reports distinct phases so the UI never claims a worker is ready when it is not', () => { + assert.equal(visionReadiness(null).phase, 'checking'); + assert.equal(visionReadiness({ enabled: false }).phase, 'disabled'); + assert.equal(visionReadiness({ enabled: true, providerReady: false }).phase, 'offline'); + assert.equal(visionReadiness({ enabled: true, providerReady: true }).phase, 'ready'); +}); + +test('a ready provider keeps AI analysis available and does not force the manual path', () => { + const readiness = visionReadiness({ enabled: true, providerReady: true, model: 'SmolVLM2-2.2B-Instruct' }); + assert.equal(readiness.ready, true); + assert.equal(readiness.manualOnly, false); + assert.equal(readiness.manualLabel, ''); +}); + +test('readiness never promises a symptom or diagnosis capability', () => { + for (const status of [null, { enabled: false }, { enabled: true, providerReady: false }, { enabled: true, providerReady: true }]) { + const readiness = visionReadiness(status); + const copy = `${readiness.manualLabel} ${readiness.message}`; + assert.doesNotMatch(copy, /diagnos|bleeding|cancer|symptom/i); + } +});