diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml
index d55b2c9..830a5e6 100644
--- a/.gitea/workflows/quality.yml
+++ b/.gitea/workflows/quality.yml
@@ -53,6 +53,7 @@ jobs:
npm run test:mobile-capture
npm run test:sleek
npm run test:manual-fallback
+ npm run test:provisional
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks
diff --git a/app.js b/app.js
index 022975b..0306d4e 100644
--- a/app.js
+++ b/app.js
@@ -1,6 +1,7 @@
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
import { mergeVisualSuggestion } from './src/analysis.js';
import { createDraft, draftNotice, isDraftDirty } from './src/draft.js';
+import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js';
import { visionReadiness } from './src/vision-readiness.js';
const runtimeConfig = {
@@ -26,6 +27,7 @@ let entries = loadEntries();
let view = 'home';
let photoHint = '';
let aiSuggestion = null;
+let suggestionState = createSuggestionState(null);
let visionStatus = null;
let photoFirstMode = 'pick';
let agentStatus = null;
@@ -40,7 +42,15 @@ function draftBanner(){
return `
Draft kept in this session: ${esc(draftNotice(form))}
`;
}
function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()}
-function clearDraft(){form=createDraft();draftResumed=false}
+function clearDraft(){form=createDraft();draftResumed=false;suggestionState=createSuggestionState(null)}
+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 `
${esc(copy.label)}Type ${form.bristolType} · ${esc(form.color)}You’re in charge—tap any type to correct it.
${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.
`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}
${esc(photoHint)}
`;}
if(mode==='analyzing')return `
Timmy is looking at form and color…
Not symptoms. Not disease. Not whether Taco Bell was a strategic error.
`;
if(mode==='error')return `
↻
Timmy couldn’t analyze that safely.
${esc(error||'Continue manually or try a clearer photo.')}
${esc(aiSuggestion.observations||'Visual match found.')}
${esc(copy.detail)}
${esc(aiSuggestion.warning)}
`;}
return `
?
No confident match.
${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}
`;
}
function showPhotoFirst(mode='pick',error=''){
@@ -155,29 +165,29 @@ function showPhotoFirst(mode='pick',error=''){
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`
Photo-first log
${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}
${photoFirstBody(mode,error)}`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis;
- document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')});
- document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)});
- document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;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);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
-async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
+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(){beginDraft();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=`
Step ${step} of 3
${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}
${draftBanner()}${stepBody(step)}`;document.body.append(wrap);
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
}
function stepBody(step){
- if(step===1)return `${aiSuggestion?.status==='suggestion'?`
AI PREFILLEDType ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}You’re in charge—tap any type to correct it.
`:'
Choose the closest match yourself. Timmy never treats a suggestion as fact.
'}
${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>``).join('')}
AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.
':'
Manual-mode photos stay in this browser and receive quality checks only.
'}`;
+ if(step===1)return `${provenanceBlock()||'
Choose the closest match yourself. Timmy never treats a suggestion as fact.
'}
${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>``).join('')}
Select anything you have now. This is where Timmy stops joking.
${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>``).join('')}
${urgent.urgent?alertHtml(urgent.message):''}
Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.
`;
}
function alertHtml(msg){return `
Pause and get medical help.
${esc(msg)}
`}
function bindStep(step){
document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)});
- if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{form.bristolType=Number(b.dataset.type);form.typeChosen=true;aiSuggestion=null;showLogStep(1)});document.querySelector('#next').onclick=()=>{form.typeChosen=true;showLogStep(2)}}
- if(step===2){const color=document.querySelector('#color');color.value=form.color;color.onchange=()=>form.color=color.value;['urgency','discomfort'].forEach(k=>{const n=document.querySelector('#'+k);n.oninput=()=>{form[k]=Number(n.value);n.nextElementSibling.value=n.value}});document.querySelector('#note').oninput=e=>form.note=e.target.value;document.querySelector('#photo').onchange=handlePhoto;document.querySelector('#back').onclick=()=>showLogStep(1);document.querySelector('#next').onclick=()=>showLogStep(3)}
+ if(step===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;document.querySelector('#urgent-box').innerHTML=detectUrgentFlags(form.symptoms).urgent?alertHtml(detectUrgentFlags(form.symptoms).message):''});document.querySelector('#back').onclick=()=>showLogStep(2);document.querySelector('#save').onclick=saveLog}
}
async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}}
diff --git a/artifacts/photo-first-result-mobile.png b/artifacts/photo-first-result-mobile.png
index 292e34b..3af6520 100644
Binary files a/artifacts/photo-first-result-mobile.png and b/artifacts/photo-first-result-mobile.png differ
diff --git a/artifacts/provisional-suggestion-confirmed-mobile.png b/artifacts/provisional-suggestion-confirmed-mobile.png
new file mode 100644
index 0000000..424ad17
Binary files /dev/null and b/artifacts/provisional-suggestion-confirmed-mobile.png differ
diff --git a/artifacts/provisional-suggestion-corrected-mobile.png b/artifacts/provisional-suggestion-corrected-mobile.png
new file mode 100644
index 0000000..31576ef
Binary files /dev/null and b/artifacts/provisional-suggestion-corrected-mobile.png differ
diff --git a/artifacts/provisional-suggestion-pending-mobile.png b/artifacts/provisional-suggestion-pending-mobile.png
new file mode 100644
index 0000000..3af6520
Binary files /dev/null and b/artifacts/provisional-suggestion-pending-mobile.png differ
diff --git a/package.json b/package.json
index 34020b3..6b2679d 100644
--- a/package.json
+++ b/package.json
@@ -4,14 +4,15 @@
"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/draft.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
+ "test": "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/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: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/draft.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
+ "check: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/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 && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs"
},
diff --git a/scripts/build_release.py b/scripts/build_release.py
index 350f06d..906a2fd 100755
--- a/scripts/build_release.py
+++ b/scripts/build_release.py
@@ -112,6 +112,7 @@ def main() -> int:
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)
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
demo_env = dict(server_env)
demo_env["TIMMY_RELEASE_VERSION"] = version
@@ -135,7 +136,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/draft.js", "src/vision-readiness.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/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)
@@ -163,6 +164,7 @@ def main() -> int:
"- 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"
"- 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"
@@ -207,7 +209,7 @@ def main() -> int:
"codec": "h264",
"pixel_format": "yuv420p",
"recorded_from_working_app": True,
- "fixture_data": "synthetic-Type-4, deterministic vision suggestion, offline vision status, and deterministic Hermes chat response",
+ "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, offline vision status, and deterministic Hermes chat response",
},
"gates": {
"unit_security": "passed",
@@ -215,6 +217,7 @@ def main() -> int:
"photo_first_acceptance": "passed",
"sleek_hermes_chat_acceptance": "passed",
"manual_fallback_draft_recovery": "passed",
+ "provisional_suggestion_state": "passed",
"dependency_audit_high": "passed",
"syntax": "passed",
"secret_scan": "passed",
diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs
index 7cec61d..e518b93 100644
--- a/scripts/record_release_demo.mjs
+++ b/scripts/record_release_demo.mjs
@@ -136,8 +136,18 @@ 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('#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);
@@ -151,8 +161,8 @@ await page.getByText(/Ready for your review/i).waitFor();
await caption('No consent prompt and no upload when analysis cannot run', 1600);
await tap('#manual-from-scan', 500);
const keptDraft = await page.locator('.draft-resume').innerText({ timeout: 5000 });
-if (!/Type 4/.test(keptDraft)) throw new Error('The retained draft must show exactly what the user is keeping');
-await caption('One tap still reaches manual Bristol entry', 1500);
+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);
diff --git a/src/suggestion.js b/src/suggestion.js
new file mode 100644
index 0000000..4e425f8
--- /dev/null
+++ b/src/suggestion.js
@@ -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.`,
+ };
+}
diff --git a/styles.css b/styles.css
index ca91348..8364f7f 100644
--- a/styles.css
+++ b/styles.css
@@ -23,3 +23,10 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
.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}
diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js
index c09b60b..0a58a8f 100644
--- a/tests/ci-workflow.test.js
+++ b/tests/ci-workflow.test.js
@@ -50,6 +50,20 @@ test('default unit suite covers the manual fallback and draft contracts', async
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('staging smoke is an explicit syntax-gated promotion command, never a PR network call', async () => {
const [packageJson, workflow] = await Promise.all([
readFile(packagePath, 'utf8').then(JSON.parse),
diff --git a/tests/provisional-suggestion.acceptance.mjs b/tests/provisional-suggestion.acceptance.mjs
new file mode 100644
index 0000000..5612e24
--- /dev/null
+++ b/tests/provisional-suggestion.acceptance.mjs
@@ -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');
diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js
index 7b80742..0d04bfc 100644
--- a/tests/release-demo.test.js
+++ b/tests/release-demo.test.js
@@ -38,6 +38,18 @@ test('release demo exercises the vision-offline manual path and the retained dra
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"/);
@@ -52,3 +64,12 @@ test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax',
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/);
+});
diff --git a/tests/suggestion.test.js b/tests/suggestion.test.js
new file mode 100644
index 0000000..ea9840d
--- /dev/null
+++ b/tests/suggestion.test.js
@@ -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);
+});