Compare commits

...
Author SHA1 Message Date
rockachopa 5505f3c3ef Merge pull request 'Hard-stool follow-up: user-reported symptoms with deterministic red-flag handoff' (#70) from timmy/67-hard-stool-followup into main 2026-10-09 12:01:54 +00:00
timmy 4cc5f4f7b1 feat: user-reported hard-stool follow-up with deterministic red-flag handoff (#67)
Quality gates / quality (pull_request) Successful in 2m53s
Field finding #67: a hard/lumpy (Bristol 1-2) entry now asks a short follow-up
before saving — difficult to pass, soreness, visible red blood, truly
black/sticky/tarry versus dark brown, and dehydration. Every answer is
user-reported and starts unanswered; nothing is inferred from the photo, and
answering "dark brown" never silently escalates as black stool.

A confirmed red-flag answer writes the same symptom flag manual logging already
uses, so the existing deterministic escalation is the only escalation path.
Mild reports get concise non-diagnostic self-care that states the photo cannot
rule out bleeding, and it is withdrawn whenever a red flag is confirmed.

- src/followup.js: pure follow-up contract (9 unit tests, tests/followup.test.js)
- app.js + styles.css: follow-up block on the safety step for Type 1-2 entries only
- tests/followup.acceptance.mjs: browser acceptance incl. that the AI suggestion
  surface still contains no symptom wording
- CI + release builder gates: npm run test:followup, syntax checks, manifest gate
- scripts/record_release_demo.mjs: records the real follow-up, mild guidance, and
  the black/tarry alert handoff with synthetic data
2026-10-09 08:10:43 +00:00
rockachopa 7c5c466b83 Merge pull request '#69' from timmy/13-provisional-suggestion-state
Make AI suggestions visibly provisional and correction-first (#13)

Closes #13
2026-10-08 12:02:33 +00:00
timmy 5aee7436f2 feat: make AI suggestions visibly provisional and correction-first (#13)
Quality gates / quality (pull_request) Successful in 1m45s
Suggested, confirmed, and corrected AI states are now distinct in the log
sheet, and correcting a suggested value clears the AI-confirmed state while
keeping every user-entered field. Screen-reader copy states the AI confidence
and who is in control.

- src/suggestion.js: provenance state machine (pending/confirmed/corrected/manual)
- app.js: renders provenance on the scan result and log steps, tracks corrections
- tests/suggestion.test.js: unit coverage for the state machine and a11y copy
- tests/provisional-suggestion.acceptance.mjs: browser acceptance + screenshots
- record_release_demo.mjs: demo now reviews, confirms, and corrects the suggestion
- CI, release builder, and release notes gate the new contract
2026-10-08 08:09:10 +00:00
rockachopa 3e87f881c5 Merge pull request '#68' from timmy/12-manual-fallback-draft-recovery 2026-10-07 12:02:30 +00:00
timmy dce668e72b feat: guarantee manual fallback and in-session draft recovery (#12)
Quality gates / quality (pull_request) Successful in 1m54s
Logging must never depend on AI availability, and an unsaved entry must not
disappear when the user navigates away mid-log.

- add src/vision-readiness.js so the photo flow consumes one readiness contract
- when the worker is offline/disabled/unknown, offer no AI action and no
  analysis-consent prompt, and keep manual Bristol entry one action away
- add src/draft.js and make the draft own the photo so a chosen photo and
  entered context survive a recoverable close or navigation, with a visible
  kept-draft summary and an explicit start over
- drafts stay in session memory only: a photo awaiting consent is never
  persisted, and reload starts clean
- service worker now refuses to cache image bodies in addition to analysis
  requests and no-store responses
- tests: 2 new unit files, 3 service-worker privacy assertions (1 hold-out),
  1 browser acceptance file, release-demo and CI/package assertions
- release demo now visibly replays the vision-offline pass and the retained
  draft with synthetic data and an in-script assertion
2026-10-07 08:12:43 +00:00
timmy 5dac04b1e6 Merge pull request 'Harden mobile camera and gallery recovery' (#66) from timmy/11-mobile-capture-recovery into main
Quality gates / quality (push) Successful in 1m56s
Merge nightly mobile capture recovery for issue #11 after exact-head review and green gates
2026-08-26 12:19:21 +00:00
timmy 87c7680aad ci: retry transient runner port collision
Quality gates / quality (pull_request) Successful in 1m26s
2026-08-26 08:10:55 +00:00
timmy 463dff7f22 feat: harden mobile capture recovery (#11)
Quality gates / quality (pull_request) Failing after 58s
2026-08-26 08:07:30 +00:00
35 changed files with 1223 additions and 34 deletions

No files matched your search

+4
View File
@@ -50,7 +50,11 @@ jobs:
done
npm run test:ui
npm run test:photo
npm run test:mobile-capture
npm run test:sleek
npm run test:manual-fallback
npm run test:provisional
npm run test:followup
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks
+1
View File
@@ -29,6 +29,7 @@ Most stool trackers are sterile diaries. Most AI-health demos overclaim. Timmy c
- Smart free-text chat with local fallback and an optional fully tool-capable Hermes backend behind exact-origin authentication.
- Portable JSON export and delete-all control.
- A Timmy voice that is playful about logging and serious about red flags.
- A short user-reported follow-up after a hard/lumpy (Bristol 1–2) log: difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, and hydration. Mild reports receive proportionate non-diagnostic self-care; a confirmed red flag routes into the existing deterministic escalation, and dark brown is never silently escalated as black stool.
## Honest AI boundary
+4
View File
@@ -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,9 @@ 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
- A short user-reported follow-up after a hard/lumpy (Bristol 1–2) log — difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, and hydration. Mild reports get concise non-diagnostic self-care that never claims a photo ruled out bleeding; a confirmed red flag sets the same deterministic escalation the manual checkboxes use
- 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
+70 -23
View File
@@ -1,5 +1,9 @@
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 { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js';
import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp, needsHardStoolFollowUp } from './src/followup.js';
import { visionReadiness } from './src/vision-readiness.js';
const runtimeConfig = {
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
@@ -22,17 +26,59 @@ if (BASE_PATH === '/') {
const app = document.querySelector('#app');
let entries = loadEntries();
let view = 'home';
let photoDataUrl = '';
let photoHint = '';
let aiSuggestion = null;
let suggestionState = createSuggestionState(null);
let visionStatus = null;
let photoFirstMode = 'pick';
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();
let followUpAnswers = createFollowUpAnswers();
function draftBanner(){
if(!draftResumed||!isDraftDirty(form))return '';
return `<div class="draft-resume"><span role="status">Draft kept in this session: <strong>${esc(draftNotice(form))}</strong></span><button class="text-action compact" id="discard-draft">Start over</button></div>`;
}
function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()}
function clearDraft(){form=createDraft();draftResumed=false;suggestionState=createSuggestionState(null);followUpAnswers=createFollowUpAnswers()}
function resetSuggestion(){aiSuggestion=null;suggestionState=createSuggestionState(null)}
// A single place where provisional, confirmed, and corrected AI states become visible copy.
function provenanceBlock(){
const copy=provenanceCopy(suggestionState,form);
if(!copy.label)return '';
return `<div class="ai-prefill ai-provenance" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)}</span><strong>Type ${form.bristolType} · ${esc(form.color)}</strong><small>You’re in charge—tap any type to correct it.</small><p class="provenance-note fine" role="status">${esc(copy.detail)}</p></div>`;
}
function refreshProvenance(){const copy=provenanceCopy(suggestionState,form);document.querySelectorAll('.provenance-note').forEach(node=>{node.textContent=copy.detail||''});const container=document.querySelector('.ai-provenance');if(container&&copy.aria)container.setAttribute('aria-label',copy.aria)}
// Hard-stool follow-up (field finding #67). After a Type 1-2 entry Timmy asks a short
// user-reported follow-up: soreness, difficulty passing, hydration, and black/tarry
// character are never inferred from pixels. A confirmed red-flag answer writes the same
// symptom flag the rest of the app already escalates on, so the safety path stays one path.
function followUpState(){return {...followUpAnswers}}
function syncFollowUpFlags(){for(const question of HARD_STOOL_QUESTIONS){if(!question.urgentFlag)continue;const box=document.querySelector(`[data-symptom="${question.urgentFlag}"]`);if(box)box.checked=form.symptoms[question.urgentFlag]===true}}
function followUpOutput(verdict){
if(verdict.urgent)return `<div id="followup-guidance" class="followup-guidance escalated" role="status"><strong>That answer sets the red-flag check below.</strong><p class="fine">Timmy is not diagnosing you here. The answer you gave is what triggers this, and the check below is the same one manual logging uses.</p></div>`;
if(!verdict.answered)return '<p class="fine" id="followup-guidance" role="status">Answer what applies. Anything you skip stays unanswered, and none of it is guessed from your photo.</p>';
return `<div id="followup-guidance" class="followup-guidance" role="status"><strong>Mild report — general self-care, not a diagnosis.</strong><ul>${verdict.guidance.map(line=>`<li>${esc(line)}</li>`).join('')}</ul><p class="fine">${esc(verdict.note)}</p><p class="fine">${esc(verdict.seekCare)}</p></div>`;
}
function followUpBlock(){
if(!needsHardStoolFollowUp(form))return '';
const state=followUpState();
const rows=HARD_STOOL_QUESTIONS.map(question=>`<div class="followup-row" data-question="${question.id}"><span>${esc(question.label)}${question.urgentFlag?'<small class="followup-map">Also sets the red-flag check below.</small>':''}</span><div class="followup-toggle"><button type="button" data-followup="${question.id}" data-answer="yes" class="${state[question.id]===true?'selected':''}" aria-pressed="${state[question.id]===true}">Yes</button><button type="button" data-followup="${question.id}" data-answer="no" class="${state[question.id]===false?'selected':''}" aria-pressed="${state[question.id]===false}">No</button></div></div>`).join('');
return `<section class="followup" data-followup-block aria-label="Hard stool follow-up questions"><h3>Type ${form.bristolType} — a few quick questions</h3><p class="fine">Timmy never guesses these from your photo. You report them, and you can skip any of them.</p>${rows}<div id="followup-output">${followUpOutput(evaluateHardStoolFollowUp(state))}</div></section>`;
}
function refreshFollowUp(){
const state=followUpState();
if(document.querySelector('[data-followup-block]')){
document.querySelectorAll('[data-followup]').forEach(button=>{const selected=state[button.dataset.followup]===(button.dataset.answer==='yes');button.classList.toggle('selected',selected);button.setAttribute('aria-pressed',String(selected))});
const output=document.querySelector('#followup-output');if(output)output.innerHTML=followUpOutput(evaluateHardStoolFollowUp(state));
}
const urgent=detectUrgentFlags(form.symptoms);const box=document.querySelector('#urgent-box');if(box)box.innerHTML=urgent.urgent?alertHtml(urgent.message):'';
}
function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } }
@@ -122,7 +168,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='';resetSuggestion();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}}
@@ -135,45 +181,46 @@ function visionStatusHtml(){
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
}
function photoFirstBody(mode,error=''){
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div><label class="photo-capture" for="ai-photo"><b>📷</b><strong>Take or choose a photo</strong><span>JPEG, PNG, or WebP · compressed before analysis</span><input id="ai-photo" type="file" accept="image/*" capture="environment"></label><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
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()}<img class="photo-preview scan-preview" src="${photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div>${error?`<p class="capture-status" role="status">${esc(error)}</p>`:''}<div class="capture-choice-grid"><label class="photo-capture" for="camera-photo"><b>📷</b><strong>Take photo</strong><span>Use the rear camera</span><input id="camera-photo" type="file" accept="image/*" capture="environment"></label><label class="photo-capture" for="gallery-photo"><b>▧</b><strong>Choose from gallery</strong><span>JPEG, PNG, or WebP</span><input id="gallery-photo" type="file" accept="image/*"></label></div><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo kept on this device"><p class="quality-note">${esc(photoHint)}</p><p class="fine">${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.</p><button class="btn btn-primary btn-wide" id="manual-from-scan">${esc(readiness.manualLabel)}</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;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()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
if(mode==='analyzing')return `<div class="analyzing"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;
if(suggestionState.status==='pending'){const copy=provenanceCopy(suggestionState,form);return `<div class="scan-result provisional" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)} · ${Math.round(suggestionState.suggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${suggestionState.suggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(suggestionState.suggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="provenance-note" role="status">${esc(copy.detail)}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;}
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
}
function showPhotoFirst(mode='pick',error=''){
photoFirstMode=mode;
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
const file=document.querySelector('#ai-photo');if(file)file.onchange=handleAiPhoto;
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('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)});
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)});
document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')});
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)});
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);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;suggestionState=createSuggestionState(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='';resetSuggestion();showLogStep(1)}
function showLogStep(step){
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${draftBanner()}${stepBody(step)}</section>`;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'?`<div class="ai-prefill"><span class="ai-badge">AI PREFILLED</span><strong>Type ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}</strong><small>You’re in charge—tap any type to correct it.</small></div>`:'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[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])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${photoDataUrl?`<img class="photo-preview" src="${photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${aiSuggestion?.status==='suggestion'?'<p class="fine">AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.</p>':'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['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])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
if(step===1)return `${provenanceBlock()||'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[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])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${form.photoDataUrl?`<img class="photo-preview" src="${form.photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${suggestionState.suggestion?`<p class="provenance-note fine" role="status">${esc(provenanceCopy(suggestionState,form).detail)}</p>`:'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['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])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div>${followUpBlock()}<div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
}
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
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)}
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}
document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)});
if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{const chosen=Number(b.dataset.type);form.bristolType=chosen;form.typeChosen=true;suggestionState=changeVisualField(suggestionState,'bristolType',chosen);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;suggestionState=changeVisualField(suggestionState,'color',form.color);refreshProvenance()};['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;const mirrored=HARD_STOOL_QUESTIONS.find(q=>q.urgentFlag===c.dataset.symptom);if(mirrored)followUpAnswers[mirrored.id]=c.checked;refreshFollowUp()});document.querySelectorAll('[data-followup]').forEach(b=>b.onclick=()=>{const question=HARD_STOOL_QUESTIONS.find(q=>q.id===b.dataset.followup);const value=b.dataset.answer==='yes';followUpAnswers[b.dataset.followup]=value;if(question?.urgentFlag)form.symptoms[question.urgentFlag]=value;syncFollowUpFlags();refreshFollowUp()});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<sample.length;i+=4)total+=(sample[i]+sample[i+1]+sample[i+2])/3;URL.revokeObjectURL(url);resolve({dataUrl:canvas.toDataURL('image/jpeg',.7),width:img.width,height:img.height,brightness:total/(sample.length/4)/255})};img.onerror=()=>{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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 224 KiB

+4
View File
@@ -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.
+6 -2
View File
@@ -4,12 +4,16 @@
"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/suggestion.test.js tests/followup.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:provisional": "node tests/provisional-suggestion.acceptance.mjs",
"test:followup": "node tests/followup.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/suggestion.js && node --check src/followup.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 && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.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"
},
+12 -2
View File
@@ -109,7 +109,11 @@ def main() -> int:
raise SystemExit("Acceptance server did not become ready")
run(["npm", "run", "test:ui"], tree)
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)
run(["npm", "run", "test:provisional"], tree)
run(["npm", "run", "test:followup"], tree)
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
demo_env = dict(server_env)
demo_env["TIMMY_RELEASE_VERSION"] = version
@@ -133,7 +137,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/suggestion.js", "src/followup.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)
@@ -157,9 +161,12 @@ 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"
"- AI suggestions stay provisional: suggested, confirmed, and corrected states are distinct and screen-reader described; correcting one clears the AI-confirmed state without losing user context\n"
"- Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions (difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, hydration); mild reports get non-diagnostic self-care, and a confirmed red-flag answer lands on the same deterministic escalation path as manual logging\n"
"- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\n"
"- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n"
"- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n"
@@ -204,13 +211,16 @@ 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 reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, 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",
"provisional_suggestion_state": "passed",
"hard_stool_followup": "passed",
"dependency_audit_high": "passed",
"syntax": "passed",
"secret_scan": "passed",
+75 -4
View File
@@ -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({
@@ -100,20 +102,89 @@ async function tap(selector, after = 650) {
await sleep(after);
}
async function indicate(selector) {
const target = page.locator(selector).first();
await target.scrollIntoViewIfNeeded();
const box = await target.boundingBox();
if (!box) throw new Error(`Missing demo target: ${selector}`);
await page.evaluate(({ x, y }) => {
document.querySelector('#demo-touch')?.remove();
const ring = document.createElement('div');
ring.id = 'demo-touch';
ring.style.left = `${x}px`;
ring.style.top = `${y}px`;
document.body.append(ring);
ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 });
setTimeout(() => ring.remove(), 550);
}, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
await sleep(650);
}
await caption(`TIMMY ${version} • FEATURE DEMO`, 1200);
await caption('Automated checks replay this synthetic path before review', 1200);
await caption('One clear photo action. Manual logging stays one tap away.', 1500);
await tap('[data-scan]', 450);
await page.getByText(/Self-hosted model ready/i).waitFor();
await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500);
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await indicate('label[for="camera-photo"]');
await page.locator('#camera-photo').dispatchEvent('cancel');
await page.getByText(/Camera or photo picker closed/i).waitFor();
await caption('Camera closed cleanly — gallery and manual logging are still available', 1600);
await indicate('label[for="gallery-photo"]');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await caption('Nothing uploads until explicit consent', 1100);
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 450);
await page.getByText(/83% confidence/i).waitFor();
await caption('AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis', 1700);
await indicate('.scan-result[data-provenance="pending"]');
await caption('The suggestion stays provisional — nothing is applied or saved until you confirm it', 1700);
await tap('#use-suggestion', 450);
await caption('The user reviews every suggestion before saving', 1100);
await page.locator('.ai-provenance[data-provenance="confirmed"]').waitFor();
await caption('Confirming is explicit, and every suggested value is still yours to overrule', 1600);
await tap('[data-type="2"]', 500);
await page.locator('[data-provenance="corrected"]').waitFor();
await caption('Change a suggested value and Timmy clears the AI-confirmed state', 1700);
await indicate('.ai-provenance[data-provenance="corrected"] .provenance-note');
await tap('#next', 450);
await page.getByText(/You changed it to Type 2/).waitFor();
await caption('Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours', 1900);
await tap('#next', 450);
await page.locator('[data-followup-block]').waitFor();
await caption('Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo', 2600);
await page.locator('[data-question="bloodVisible"]').scrollIntoViewIfNeeded();
await indicate('[data-question="bloodVisible"]');
await tap('[data-followup="bloodVisible"][data-answer="no"]', 400);
await caption('Every answer is yours to give, and a skipped question stays unanswered', 1900);
await tap('[data-followup="soreness"][data-answer="yes"]', 350);
await tap('[data-followup="dehydration"][data-answer="yes"]', 350);
await page.locator('#followup-guidance').waitFor();
await caption('Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis', 2600);
await indicate('#followup-guidance');
await caption('And it never claims the photo ruled out bleeding', 1900);
await tap('[data-followup="tarryBlack"][data-answer="yes"]', 600);
await page.locator('#urgent-box .alert').waitFor();
await caption('Say it was truly black and tarry and the same deterministic red-flag path fires instead', 2600);
await tap('[data-followup="tarryBlack"][data-answer="no"]', 500);
await page.locator('#urgent-box .alert').waitFor({ state: 'detached' });
await caption('Dark brown is not silently escalated, and the flag clears the moment the answer changes', 2200);
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 2/.test(keptDraft)) throw new Error('The retained draft must show the value the user corrected to');
await caption('One tap still reaches manual Bristol entry, and the draft keeps your corrected value', 1800);
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();
+4 -1
View File
@@ -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));
}
+39
View File
@@ -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(' · ');
}
+66
View File
@@ -0,0 +1,66 @@
// Hard-stool follow-up (field finding #67).
//
// After a hard/lumpy (Bristol 1-2) entry, Timmy asks a short follow-up. Every answer
// is user-reported: soreness, difficulty passing, hydration, and black/tarry character
// are never inferred from a photo. The two confirmed red flags map onto the same
// deterministic symptom keys the rest of the app already escalates on.
import { detectUrgentFlags } from './domain.js';
export const HARD_STOOL_TYPES = Object.freeze([1, 2]);
// `urgentFlag` is the existing deterministic symptom key a "yes" maps onto. Questions
// without one stay non-diagnostic self-care only.
export const HARD_STOOL_QUESTIONS = Object.freeze([
Object.freeze({ id: 'difficultToPass', label: 'Was it hard or difficult to pass?', urgentFlag: null }),
Object.freeze({ id: 'soreness', label: 'Any soreness or sharp pain afterward?', urgentFlag: null }),
Object.freeze({ id: 'bloodVisible', label: 'Any visible red blood?', urgentFlag: 'blood' }),
Object.freeze({ id: 'tarryBlack', label: 'Was it truly black, sticky, or tarry — not just dark brown?', urgentFlag: 'blackOrDarkRed' }),
Object.freeze({ id: 'dehydration', label: 'Do you feel dehydrated, or is your urine unusually dark?', urgentFlag: null }),
]);
export const HARD_STOOL_GUIDANCE = Object.freeze([
'Drink water steadily through the day rather than a lot at once.',
'Do not strain or force it; give it time and try again later if needed.',
'Clean gently, pat dry, and avoid scrubbing sore skin.',
'A few minutes in a warm sitz bath can ease soreness afterward.',
'A barrier ointment on the outside skin can reduce irritation.',
'Food-based fiber with fluids helps over days, not minutes.',
]);
export const HARD_STOOL_GUIDANCE_NOTE = 'General self-care information, not a diagnosis. Your photo cannot rule out bleeding or another cause.';
export const HARD_STOOL_SEEK_CARE = 'Contact a clinician or urgent service if pain is severe or worsening, you feel faint or weak, you pass very little urine, or any blood appears.';
export function isHardStoolForm(bristolType) {
return HARD_STOOL_TYPES.includes(Number(bristolType));
}
export function needsHardStoolFollowUp(draft = {}) {
return isHardStoolForm(draft?.bristolType);
}
export function createFollowUpAnswers() {
return Object.fromEntries(HARD_STOOL_QUESTIONS.map((question) => [question.id, null]));
}
function confirmedYes(answers, question) {
// Only an explicit `true` counts. `null` (unanswered) and `false` (explicit no) do not escalate.
return answers?.[question.id] === true;
}
export function evaluateHardStoolFollowUp(answers = {}) {
const answeredIds = HARD_STOOL_QUESTIONS.filter((question) => typeof answers[question.id] === 'boolean');
const flags = HARD_STOOL_QUESTIONS.filter((question) => question.urgentFlag && confirmedYes(answers, question))
.map((question) => question.urgentFlag);
const escalated = detectUrgentFlags(Object.fromEntries(flags.map((flag) => [flag, true])));
const urgent = escalated.urgent;
return {
answered: answeredIds.length > 0,
unanswered: HARD_STOOL_QUESTIONS.filter((question) => !answeredIds.includes(question)).map((question) => question.id),
urgent,
flags,
message: urgent ? escalated.message : '',
guidance: !urgent && answeredIds.length ? [...HARD_STOOL_GUIDANCE] : [],
note: HARD_STOOL_GUIDANCE_NOTE,
seekCare: urgent ? '' : HARD_STOOL_SEEK_CARE,
};
}
+93
View File
@@ -0,0 +1,93 @@
// Tracks where a log entry's visual fields came from, so an AI suggestion is never
// shown as if the user had confirmed it.
export const SUGGESTION_STATUS = {
none: 'none',
pending: 'pending',
confirmed: 'confirmed',
corrected: 'corrected',
manual: 'manual',
};
const VISUAL_FIELDS = ['bristolType', 'color'];
export function createSuggestionState(suggestion = null) {
if (!suggestion || suggestion.status !== 'suggestion') {
return { status: SUGGESTION_STATUS.none, suggestion: null, affirmed: false, correctedFields: [] };
}
return {
status: SUGGESTION_STATUS.pending,
suggestion: {
bristolType: suggestion.bristolType,
color: suggestion.color,
confidence: Number(suggestion.confidence),
},
affirmed: false,
correctedFields: [],
};
}
export function confirmSuggestion(state = {}) {
if (!state.suggestion || state.status !== SUGGESTION_STATUS.pending) return { ...state };
return { ...state, status: SUGGESTION_STATUS.confirmed, affirmed: true };
}
export function changeVisualField(state = {}, field, value) {
const correctedFields = [...(state.correctedFields || [])];
if (!VISUAL_FIELDS.includes(field)) return { ...state, correctedFields };
const suggested = state.suggestion ? state.suggestion[field] : undefined;
if (suggested === undefined) {
return {
...state,
status: state.status === SUGGESTION_STATUS.none ? SUGGESTION_STATUS.manual : state.status,
correctedFields,
};
}
if (value === suggested) return { ...state, correctedFields };
if (!correctedFields.includes(field)) correctedFields.push(field);
return { ...state, status: SUGGESTION_STATUS.corrected, affirmed: false, correctedFields };
}
function percent(value) {
return Math.round(Number(value) * 100);
}
export function provenanceCopy(state = {}, current = {}) {
const suggestion = state.suggestion;
const status = suggestion || state.status === SUGGESTION_STATUS.manual
? state.status
: SUGGESTION_STATUS.none;
if (status === SUGGESTION_STATUS.none) return { status, label: '', detail: '', aria: '' };
if (status === SUGGESTION_STATUS.manual) {
return {
status,
label: 'YOUR OWN ENTRY',
detail: 'You chose every field yourself. No AI suggestion is applied.',
aria: `You chose Type ${current.bristolType}, ${current.color} yourself. No AI suggestion is applied.`,
};
}
const confidence = percent(suggestion.confidence);
const suggestedSummary = `Type ${suggestion.bristolType}, ${suggestion.color}`;
if (status === SUGGESTION_STATUS.pending) {
return {
status,
label: 'AI SUGGESTION',
detail: `Suggested ${suggestedSummary} — provisional and not applied yet. You choose whether to use it.`,
aria: `AI suggestion with ${confidence} percent confidence: ${suggestedSummary}. This is a visual suggestion only, not a diagnosis, and it is not saved until you confirm it.`,
};
}
if (status === SUGGESTION_STATUS.corrected) {
const chosen = `Type ${current.bristolType}, ${current.color}`;
return {
status,
label: 'CORRECTED BY YOU',
detail: `AI suggested ${suggestedSummary}. You changed it to ${chosen} — your values are saved, not the suggestion. Urgency, discomfort, notes, and symptoms must come from you.`,
aria: `You changed the AI suggestion. It suggested ${suggestedSummary}; you chose ${chosen}. Your values are saved, not the suggestion.`,
};
}
return {
status,
label: 'AI PREFILLED',
detail: 'AI suggested only form and visible color, and you confirmed it. Urgency, discomfort, notes, and symptoms must come from you.',
aria: `You confirmed the AI suggestion with ${confidence} percent confidence: ${suggestedSummary}. You can still change any of it; the suggestion is a visual aid, not a diagnosis.`,
};
}
+35
View File
@@ -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: '' };
}
+25 -1
View File
@@ -17,8 +17,32 @@ 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%}
.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)}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:22px;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}
.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}
@media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
/* #13 AI suggestion provenance: provisional, confirmed, and corrected states never look alike */
.scan-result.provisional{border:1.5px dashed var(--teal)}
.ai-provenance[data-provenance="confirmed"]{border:1px solid #b7ded6;background:var(--teal-soft)}
.ai-provenance[data-provenance="corrected"]{border:1px solid #e6cd9c;background:#fdf4e4}
.ai-provenance[data-provenance="corrected"] .ai-badge{color:#8a6412}
.provenance-note{font-size:12px;line-height:1.45;color:var(--muted);margin:8px 0 0;padding-left:9px;border-left:3px solid #cfc5b6}
.ai-provenance[data-provenance="corrected"] .provenance-note{border-left-color:#e0b96a}
.followup{background:#fdf7ea;border:1px solid #eeddb5;border-radius:18px;padding:13px;margin:15px 0 0}
.followup h3{font-size:15px;margin:0 0 4px}
.followup>.fine{margin-bottom:6px}
.followup-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:9px 0;border-top:1px solid var(--line)}
.followup-row>span{font-size:13px;line-height:1.35}
.followup-map{display:block;font-size:10px;font-weight:600;color:var(--muted);margin-top:3px}
.followup-toggle{display:flex;gap:6px}
.followup-toggle button{min-height:40px;min-width:54px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--muted);font-weight:800;font-size:12px;cursor:pointer}
.followup-toggle button.selected{background:var(--ink);color:#fff;border-color:var(--ink)}
.followup-guidance{margin-top:12px;background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:12px}
.followup-guidance>strong{display:block;font-size:13px}
.followup-guidance ul{margin:8px 0 0;padding-left:18px}
.followup-guidance li{font-size:12.5px;line-height:1.45;color:#4d453e;margin-bottom:4px}
.followup-guidance .fine{margin:8px 0 0}
.followup-guidance.escalated{background:var(--red-soft);border-color:#eec1ba}
.followup-guidance.escalated>strong{color:#75261f}
+40
View File
@@ -20,7 +20,9 @@ test('Gitea CI gates pull requests and main with the reproducible quality suite'
assert.match(workflow, /npm test/);
assert.match(workflow, /npm run test:ui/);
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/);
@@ -38,6 +40,44 @@ 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('provisional AI suggestion state is a unit-and-browser-gated contract', async () => {
const [packageJsonText, workflow] = await Promise.all([
readFile(packagePath, 'utf8'),
readFile(workflowPath, 'utf8'),
]);
const packageJson = JSON.parse(packageJsonText);
assert.match(packageJson.scripts.test, /tests\/suggestion\.test\.js/);
assert.equal(packageJson.scripts['test:provisional'], 'node tests/provisional-suggestion.acceptance.mjs');
assert.match(packageJson.scripts['check:syntax'], /node --check src\/suggestion\.js/);
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/provisional-suggestion\.acceptance\.mjs/);
assert.match(workflow, /npm run test:provisional/);
});
test('hard-stool follow-up is a unit-and-browser-gated safety contract', async () => {
const [packageJsonText, workflow] = await Promise.all([
readFile(packagePath, 'utf8'),
readFile(workflowPath, 'utf8'),
]);
const packageJson = JSON.parse(packageJsonText);
assert.match(packageJson.scripts.test, /tests\/followup\.test\.js/);
assert.equal(packageJson.scripts['test:followup'], 'node tests/followup.acceptance.mjs');
assert.match(packageJson.scripts['check:syntax'], /node --check src\/followup\.js/);
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/followup\.acceptance\.mjs/);
assert.match(workflow, /npm run test:followup/);
});
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),
+54
View File
@@ -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);
});
+104
View File
@@ -0,0 +1,104 @@
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));
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: true, modelSeen: true }),
}));
await page.route('**/api/analyze', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
status: 'suggestion', isStool: true, bristolType: 2, color: 'brown', confidence: 0.71,
imageQuality: 'good', observations: 'Small, separate, hard-looking lumps.',
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
}),
}));
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// 1. A hard/lumpy photo suggestion still contains only visible form, colour, and quality.
await page.locator('[data-scan]').click();
await page.getByText(/Self-hosted model ready/i).waitFor();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.locator('#ai-consent').check();
await page.locator('#analyze-photo').click();
const suggestion = page.locator('[data-provenance="pending"]');
await suggestion.waitFor();
const suggestionText = await suggestion.innerText();
assert.match(suggestionText, /Type 2/);
assert.doesNotMatch(suggestionText, /sore|dehydrat|tarry|difficult to pass|blood/i, 'the photo suggestion may never mention user-reported symptoms');
// 2. Confirming the suggestion and moving to the safety step offers the follow-up for a hard form.
await page.locator('#use-suggestion').click();
await page.locator('#next').click();
await page.locator('#next').click();
const block = page.locator('[data-followup-block]');
await block.waitFor();
assert.equal(await block.count(), 1, 'a Bristol 2 entry asks the hard-stool follow-up');
assert.equal(await page.locator('[data-followup]').count(), 10, 'five questions, each with an explicit yes and no');
const askedIds = await page.locator('.followup-row').evaluateAll(rows => rows.map(row => row.dataset.question));
assert.deepEqual(askedIds.sort(), ['bloodVisible', 'dehydration', 'difficultToPass', 'soreness', 'tarryBlack']);
assert.match(await block.innerText(), /never (guesses|infers)/i, 'the copy states these are user-reported, never inferred');
assert.equal(await page.locator('[data-followup].selected').count(), 0, 'no question may be pre-answered');
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'an unanswered follow-up never escalates');
assert.match(await page.locator('#followup-output').innerText(), /answer/i, 'the user is asked to answer rather than given invented findings');
// 3. A mild hard-stool report gets proportionate non-diagnostic self-care and no escalation.
for (const id of ['difficultToPass', 'soreness', 'dehydration']) await page.locator(`[data-followup="${id}"][data-answer="yes"]`).click();
await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click();
await page.locator('[data-followup="bloodVisible"][data-answer="no"]').click();
const guidance = page.locator('#followup-guidance');
await guidance.waitFor();
assert.match(await guidance.innerText(), /not a diagnosis/i);
assert.match(await guidance.innerText(), /cannot rule out/i);
assert.match(await guidance.innerText(), /bleeding/i);
assert.match(await guidance.innerText(), /water|hydrat/i);
assert.match(await guidance.innerText(), /strain/i);
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'dark brown plus soreness must not escalate');
await page.screenshot({ path: 'artifacts/followup-hard-stool-mobile.png' });
// 4. Confirming truly black/tarry stool lands on the existing deterministic path.
await page.locator('[data-followup="tarryBlack"][data-answer="yes"]').click();
const alert = page.locator('#urgent-box .alert');
await alert.waitFor();
assert.match(await alert.innerText(), /Pause and get medical help\./);
assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), true, 'the confirmed answer sets the app\'s own red-flag control');
// 5. Clearing that answer clears the escalation instead of sticking.
await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click();
assert.equal(await page.locator('#urgent-box .alert').count(), 0);
assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), false);
// 6. Visible blood is the other existing deterministic flag.
await page.locator('[data-followup="bloodVisible"][data-answer="yes"]').click();
await page.locator('#urgent-box .alert').waitFor();
assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true);
// 7. Non-hard forms never see the follow-up.
await page.locator('#back').click();
await page.locator('#back').click();
await page.locator('[data-type="6"]').click();
await page.locator('#next').click();
await page.locator('#next').click();
assert.equal(await page.locator('[data-followup-block]').count(), 0, 'loose forms are not asked hard-stool questions');
assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true, 'an answer the user already confirmed is not silently dropped');
assert.equal(await page.locator('#urgent-box .alert').count(), 1, 'the deterministic path still reflects that confirmed answer');
await page.locator('[data-symptom="blood"]').uncheck();
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'clearing the confirmed flag clears the escalation');
assert.deepEqual(errors, []);
await context.close();
await browser.close();
console.log('PASS hard-stool follow-up is user-reported, non-diagnostic for mild reports, and lands on the deterministic escalation path');
+122
View File
@@ -0,0 +1,122 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectUrgentFlags } from '../src/domain.js';
import {
HARD_STOOL_QUESTIONS,
HARD_STOOL_TYPES,
createFollowUpAnswers,
evaluateHardStoolFollowUp,
isHardStoolForm,
needsHardStoolFollowUp,
} from '../src/followup.js';
// Field finding #67: after a hard/lumpy (Bristol 1-2) log, Timmy asks a short
// user-reported follow-up. Nothing here may be inferred from the photo, dark brown
// must never be silently escalated as black stool, and confirmed red flags must land
// on the same deterministic escalation path the rest of the app already uses.
const questionIds = () => HARD_STOOL_QUESTIONS.map((question) => question.id);
test('the hard-stool follow-up only appears for Bristol 1 and 2', () => {
assert.deepEqual([...HARD_STOOL_TYPES], [1, 2]);
assert.equal(isHardStoolForm(1), true);
assert.equal(isHardStoolForm(2), true);
for (const type of [3, 4, 5, 6, 7]) assert.equal(isHardStoolForm(type), false, `Type ${type} is not hard/lumpy`);
assert.equal(isHardStoolForm(undefined), false);
assert.equal(isHardStoolForm('2'), true, 'a form value from the DOM is still the same form');
assert.equal(needsHardStoolFollowUp({ bristolType: 2 }), true);
assert.equal(needsHardStoolFollowUp({ bristolType: 4 }), false);
assert.equal(needsHardStoolFollowUp({}), false);
});
test('every follow-up question starts unanswered and is never pre-filled', () => {
const answers = createFollowUpAnswers();
assert.deepEqual(questionIds().slice().sort(), [
'bloodVisible',
'dehydration',
'difficultToPass',
'soreness',
'tarryBlack',
]);
assert.equal(HARD_STOOL_QUESTIONS.length, 5);
for (const question of HARD_STOOL_QUESTIONS) {
assert.equal(answers[question.id], null, `${question.id} must start unanswered`);
assert.match(question.label, /was it|any |do you/i, `${question.id} must ask the user, not infer`);
}
});
test('an unanswered follow-up never escalates and never invents guidance', () => {
const verdict = evaluateHardStoolFollowUp(createFollowUpAnswers());
assert.equal(verdict.answered, false);
assert.equal(verdict.urgent, false);
assert.deepEqual(verdict.flags, []);
assert.equal(verdict.message, '');
assert.deepEqual(verdict.guidance, [], 'unanswered questions must not produce self-care copy');
});
test('a mild hard-stool report keeps non-diagnostic self-care and no escalation', () => {
const verdict = evaluateHardStoolFollowUp({
difficultToPass: true,
soreness: true,
bloodVisible: false,
tarryBlack: false,
dehydration: true,
});
assert.equal(verdict.answered, true);
assert.equal(verdict.urgent, false);
assert.deepEqual(verdict.flags, []);
assert.equal(verdict.message, '');
assert.ok(verdict.guidance.length >= 3, 'a mild report still gets proportionate self-care');
assert.equal(verdict.guidance.some((line) => /water|hydrat/i.test(line)), true);
assert.equal(verdict.guidance.some((line) => /strain/i.test(line)), true);
});
test('the mild guidance never implies the photo ruled out bleeding', () => {
const verdict = evaluateHardStoolFollowUp({ soreness: true, bloodVisible: false, tarryBlack: false });
assert.match(verdict.note, /not a diagnosis/i);
assert.match(verdict.note, /cannot rule out|did not rule out/i);
assert.match(verdict.note, /bleeding/i);
assert.doesNotMatch(verdict.note, /rules out|safe|cleared|no bleeding/i);
});
test('confirmed visible blood maps onto the existing deterministic blood red flag', () => {
const verdict = evaluateHardStoolFollowUp({ bloodVisible: true });
assert.equal(verdict.urgent, true);
assert.deepEqual(verdict.flags, ['blood']);
assert.equal(verdict.message, detectUrgentFlags({ blood: true }).message, 'urgent copy comes from the canonical path');
assert.deepEqual(verdict.guidance, [], 'escalation copy must not be softened with self-care');
});
test('confirmed black or tarry stool maps onto the existing deterministic blackOrDarkRed red flag', () => {
const verdict = evaluateHardStoolFollowUp({ tarryBlack: true });
assert.equal(verdict.urgent, true);
assert.deepEqual(verdict.flags, ['blackOrDarkRed']);
assert.equal(verdict.message, detectUrgentFlags({ blackOrDarkRed: true }).message);
});
test('dark brown is not silently escalated as black stool', () => {
const explicitDarkBrown = evaluateHardStoolFollowUp({ difficultToPass: true, tarryBlack: false });
const unansweredTarry = evaluateHardStoolFollowUp({ difficultToPass: true });
const colorAlone = evaluateHardStoolFollowUp({ color: 'black', note: 'dark brown' });
assert.equal(explicitDarkBrown.urgent, false, 'answering "dark brown" must not escalate');
assert.equal(unansweredTarry.urgent, false, 'an unanswered colour question is not a yes');
assert.equal(colorAlone.urgent, false, 'a draft colour value is never treated as a confirmed tarry finding');
});
test('an unanswered question is not silently treated as a no', () => {
const partial = evaluateHardStoolFollowUp({ soreness: true });
assert.equal(partial.answered, true);
assert.equal(partial.urgent, false);
assert.deepEqual(partial.flags, []);
assert.equal(partial.unanswered.length, 4, 'the verdict reports what the user still has not answered');
const all = evaluateHardStoolFollowUp({ bloodVisible: false, tarryBlack: false, soreness: false, difficultToPass: false, dehydration: false });
assert.deepEqual(all.unanswered, []);
});
+78
View File
@@ -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');
+59
View File
@@ -0,0 +1,59 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
const browser = await chromium.launch({ headless: true });
const viewports = [
{ name: '390x844', width: 390, height: 844 },
{ name: 'iPhone 15 class', width: 393, height: 852 },
];
for (const viewport of viewports) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 2,
serviceWorkers: 'block',
});
const page = await context.newPage();
let analysisRequests = 0;
await page.route('**/api/vision-status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'synthetic-test-model' }),
}));
await page.route('**/api/analyze', route => {
analysisRequests += 1;
return route.abort();
});
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.locator('[data-scan]').click();
const camera = page.locator('#camera-photo');
const gallery = page.locator('#gallery-photo');
assert.equal(await camera.getAttribute('capture'), 'environment', `${viewport.name}: camera input uses the rear camera`);
assert.equal(await gallery.getAttribute('capture'), null, `${viewport.name}: gallery input does not force camera capture`);
assert.equal(await page.getByText('Take photo', { exact: true }).isVisible(), true);
assert.equal(await page.getByText('Choose from gallery', { exact: true }).isVisible(), true);
await camera.dispatchEvent('cancel');
assert.match(await page.locator('[role="status"]').innerText(), /camera.*closed|permission.*denied/i);
assert.equal(await page.getByText('Continue without AI', { exact: true }).isVisible(), true);
assert.equal(analysisRequests, 0, `${viewport.name}: cancellation never uploads`);
await page.locator('#gallery-photo').setInputFiles({
name: 'corrupt-synthetic.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.from('not an image'),
});
assert.match(await page.locator('.scan-result').innerText(), /could not be read/i);
await page.getByText('Try another photo', { exact: true }).click();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
assert.equal(await page.locator('#retake-photo').isVisible(), true);
assert.equal(analysisRequests, 0, `${viewport.name}: corrupt and unconsented photos never upload`);
await context.close();
}
await browser.close();
console.log('PASS camera/gallery paths recover from cancellation at 390x844 and iPhone-class viewport without upload');
+1 -1
View File
@@ -31,7 +31,7 @@ await page.locator('[data-scan]').click();
await page.getByText(/Self-hosted model ready/i).waitFor();
await page.screenshot({ path: 'artifacts/selfhost-photo-first-mobile.png', fullPage: false });
assert.equal(await page.getByText('One photo. Two useful suggestions.').isVisible(), true);
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
assert.match(await page.locator('.consent-card').innerText(), /self-hosted model server/i);
assert.doesNotMatch(await page.locator('.consent-card').innerText(), /provider’s terms/i);
@@ -0,0 +1,95 @@
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));
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: true, modelSeen: true }),
}));
await page.route('**/api/analyze', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
status: 'suggestion', isStool: true, bristolType: 4, color: 'brown', confidence: 0.83,
imageQuality: 'good', observations: 'Smooth, formed appearance.',
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
}),
}));
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// 1. The AI suggestion is visibly provisional and its screen-reader copy states confidence and user control.
await page.locator('[data-scan]').click();
await page.getByText(/Self-hosted model ready/i).waitFor();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.locator('#ai-consent').check();
await page.locator('#analyze-photo').click();
await page.getByText(/83% confidence/i).waitFor();
const suggested = page.locator('[data-provenance="pending"]');
assert.equal(await suggested.count(), 1, 'the AI result is a distinct provisional state');
assert.match(await suggested.innerText(), /AI SUGGESTION/);
const suggestedAria = await suggested.getAttribute('aria-label');
assert.match(suggestedAria, /83 percent confidence/i, 'screen-reader copy states the AI confidence');
assert.match(suggestedAria, /not saved until you confirm/i, 'screen-reader copy states that the user is in control');
await page.screenshot({ path: 'artifacts/provisional-suggestion-pending-mobile.png' });
// 2. Confirming the suggestion produces a different, clearly labelled state.
await page.locator('#use-suggestion').click();
const confirmed = page.locator('[data-provenance="confirmed"]');
assert.equal(await confirmed.count(), 1, 'confirmation is a distinct state from the raw suggestion');
assert.equal(await page.locator('[data-provenance="pending"]').count(), 0);
assert.match(await confirmed.innerText(), /AI PREFILLED/);
assert.match(await confirmed.locator('[role="status"]').innerText(), /you confirmed/i);
await page.screenshot({ path: 'artifacts/provisional-suggestion-confirmed-mobile.png' });
// 3. User context entered after the suggestion survives a correction.
await page.locator('#next').click();
await page.locator('#urgency').fill('3');
await page.locator('#note').fill('After lunch');
await page.locator('#back').click();
await page.locator('[data-type="2"]').click();
const corrected = page.locator('[data-provenance="corrected"]');
assert.equal(await corrected.count(), 1, 'changing the suggestion clears AI-confirmed state');
assert.equal(await confirmed.count(), 0);
assert.equal(await page.locator('[data-provenance="confirmed"]').count(), 0);
assert.match(await corrected.innerText(), /CORRECTED BY YOU/);
assert.match(await corrected.innerText(), /AI suggested Type 4/);
const correctedAria = await corrected.getAttribute('aria-label');
assert.match(correctedAria, /You changed/i);
assert.match(correctedAria, /Type 4/);
assert.match(correctedAria, /Type 2/);
assert.equal(await page.locator('[data-type="2"]').getAttribute('class').then(v => v.includes('selected')), true);
assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), false);
await page.screenshot({ path: 'artifacts/provisional-suggestion-corrected-mobile.png' });
// 4. The correction keeps every user-entered field and reports it honestly on later steps.
await page.locator('#next').click();
assert.equal(await page.locator('#color').inputValue(), 'brown', 'the confirmed color is kept, not silently re-suggested');
assert.equal(await page.locator('#urgency').inputValue(), '3', 'user context is not lost when a suggestion is corrected');
assert.equal(await page.locator('#note').inputValue(), 'After lunch');
assert.match(await page.locator('.provenance-note').last().innerText(), /You changed it to Type 2/);
assert.match(await page.locator('.provenance-note').last().innerText(), /must come from you/i);
// 5. Correcting the second suggested field keeps both corrections in the provenance record.
await page.locator('#color').selectOption('black');
await page.locator('#back').click();
const recolored = page.locator('[data-provenance="corrected"]');
assert.equal(await recolored.count(), 1);
assert.match(await recolored.getAttribute('aria-label'), /Type 2, black/);
assert.deepEqual(errors, []);
await context.close();
await browser.close();
console.log('PASS AI suggestions stay provisional, confirmation is explicit, and correcting a suggestion clears AI-confirmed state without losing user context');
+70
View File
@@ -10,6 +10,9 @@ test('release demo visibly explains the CI-protected browser path without overst
assert.match(demo, /Automated checks replay this synthetic path before review/);
assert.match(demo, /One clear photo action\. Manual logging stays one tap away\./);
assert.match(demo, /Camera closed cleanly — gallery and manual logging are still available/);
assert.match(demo, /#camera-photo.*dispatchEvent\('cancel'\)/s);
assert.match(demo, /#gallery-photo.*synthetic-type4\.jpg/s);
assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/);
assert.match(demo, /The pinned bootstrap verifies both model files before starting on private loopback/);
assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/);
@@ -20,10 +23,77 @@ test('release demo visibly explains the CI-protected browser path without overst
assert.match(demo, /SLEEK\. SIMPLE\.<br>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 demo shows the provisional, confirmed, and corrected suggestion states on camera', async () => {
const demo = await readFile(demoPath, 'utf8');
assert.match(demo, /\[data-provenance="pending"\]/);
assert.match(demo, /The suggestion stays provisional — nothing is applied or saved until you confirm it/);
assert.match(demo, /Confirming is explicit, and every suggested value is still yours to overrule/);
assert.match(demo, /Change a suggested value and Timmy clears the AI-confirmed state/);
assert.match(demo, /\[data-provenance="corrected"\]/);
assert.match(demo, /Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours/);
assert.match(demo, /Type 2/, 'the retained draft in the demo must show the value the user corrected to');
});
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"/);
});
test('release builder gates the provisional suggestion states the same way CI does', async () => {
const builder = await readFile(builderPath, 'utf8');
assert.match(builder, /"test:provisional"/);
assert.match(builder, /"provisional_suggestion_state": "passed"/);
assert.match(builder, /"src\/suggestion\.js"/);
assert.match(builder, /AI suggestions stay provisional/);
});
test('release demo visibly exercises the user-reported hard-stool follow-up and its red-flag handoff', async () => {
const demo = await readFile(demoPath, 'utf8');
assert.match(demo, /\[data-followup-block\]/);
assert.match(demo, /Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo/);
assert.match(demo, /\[data-followup="bloodVisible"\]\[data-answer="no"\]/);
assert.match(demo, /Every answer is yours to give, and a skipped question stays unanswered/);
assert.match(demo, /#followup-guidance/);
assert.match(demo, /Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis/);
assert.match(demo, /And it never claims the photo ruled out bleeding/);
assert.match(demo, /\[data-followup="tarryBlack"\]\[data-answer="yes"\]/);
assert.match(demo, /Say it was truly black and tarry and the same deterministic red-flag path fires instead/);
assert.match(demo, /Dark brown is not silently escalated, and the flag clears the moment the answer changes/);
});
test('release builder gates the hard-stool follow-up the same way CI does', async () => {
const builder = await readFile(builderPath, 'utf8');
assert.match(builder, /"test:followup"/);
assert.match(builder, /"hard_stool_followup": "passed"/);
assert.match(builder, /"src\/followup\.js"/);
assert.match(builder, /Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions/);
});
+35
View File
@@ -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]);
});
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from '../src/suggestion.js';
test('keeps an AI suggestion provisional until the user acts on it', () => {
const state = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
assert.equal(state.status, 'pending');
assert.equal(state.affirmed, false);
assert.deepEqual(state.suggestion, { bristolType: 4, color: 'brown', confidence: 0.83 });
});
test('treats a missing or unusable suggestion as no suggestion at all', () => {
assert.deepEqual(createSuggestionState(null).status, 'none');
assert.deepEqual(createSuggestionState(undefined).suggestion, null);
assert.deepEqual(createSuggestionState({ status: 'needs_user_input', reason: 'The image is too uncertain to prefill safely.' }).status, 'none');
});
test('confirms a suggestion only when the user explicitly accepts it', () => {
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
const confirmed = confirmSuggestion(pending);
assert.equal(confirmed.status, 'confirmed');
assert.equal(confirmed.affirmed, true);
assert.deepEqual(confirmed.suggestion, pending.suggestion);
const nothing = confirmSuggestion(createSuggestionState(null));
assert.equal(nothing.status, 'none');
assert.equal(nothing.affirmed, false);
assert.equal(confirmSuggestion(confirmed).status, 'confirmed');
});
test('changing a suggested field clears AI-confirmed state but keeps the context', () => {
const confirmed = confirmSuggestion(createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 }));
const corrected = changeVisualField(confirmed, 'bristolType', 2);
assert.equal(corrected.status, 'corrected');
assert.equal(corrected.affirmed, false);
assert.deepEqual(corrected.suggestion, { bristolType: 4, color: 'brown', confidence: 0.83 });
assert.deepEqual(corrected.correctedFields, ['bristolType']);
const recorrected = changeVisualField(corrected, 'color', 'black');
assert.deepEqual(recorrected.correctedFields, ['bristolType', 'color']);
assert.deepEqual(changeVisualField(recorrected, 'note', 'ignored').correctedFields, ['bristolType', 'color']);
});
test('does not invent an AI correction when the user never had a suggestion', () => {
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
assert.equal(manual.status, 'manual');
assert.equal(manual.suggestion, null);
assert.deepEqual(manual.correctedFields, []);
});
test('re-picking the suggested value is not treated as a correction', () => {
const confirmed = confirmSuggestion(createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 }));
assert.equal(changeVisualField(confirmed, 'bristolType', 4).status, 'confirmed');
assert.deepEqual(changeVisualField(confirmed, 'bristolType', 4).correctedFields, []);
});
test('labels suggested, confirmed, corrected, and manual states distinctly', () => {
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
const confirmed = confirmSuggestion(pending);
const corrected = changeVisualField(confirmed, 'bristolType', 2);
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
const labels = [pending, confirmed, corrected, manual].map(state => provenanceCopy(state, { bristolType: 2, color: 'brown' }).label);
assert.deepEqual(labels, ['AI SUGGESTION', 'AI PREFILLED', 'CORRECTED BY YOU', 'YOUR OWN ENTRY']);
assert.equal(provenanceCopy(createSuggestionState(null), {}).label, '');
});
test('screen-reader copy carries the AI confidence and who is in control', () => {
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
const confirmed = confirmSuggestion(pending);
const corrected = changeVisualField(confirmed, 'bristolType', 2);
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
assert.match(provenanceCopy(pending, { bristolType: 4, color: 'brown' }).aria, /83 percent confidence/i);
assert.match(provenanceCopy(pending, { bristolType: 4, color: 'brown' }).aria, /not saved until you confirm/i);
assert.match(provenanceCopy(confirmed, { bristolType: 4, color: 'brown' }).aria, /83 percent confidence/i);
assert.match(provenanceCopy(confirmed, { bristolType: 4, color: 'brown' }).aria, /You confirmed/i);
const correctedAria = provenanceCopy(corrected, { bristolType: 2, color: 'brown' }).aria;
assert.match(correctedAria, /You changed/i);
assert.match(correctedAria, /Type 4/);
assert.match(correctedAria, /Type 2/);
assert.equal(corrected.affirmed, false);
assert.match(provenanceCopy(manual, { bristolType: 6, color: 'brown' }).aria, /No AI suggestion/i);
});
+35
View File
@@ -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);
}
});