diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml
index 830a5e6..96c4c78 100644
--- a/.gitea/workflows/quality.yml
+++ b/.gitea/workflows/quality.yml
@@ -54,6 +54,7 @@ jobs:
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
diff --git a/PRODUCT.md b/PRODUCT.md
index 0f9182b..b41978e 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -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
diff --git a/README.md b/README.md
index c81f2ff..3c14257 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,7 @@ 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
diff --git a/app.js b/app.js
index 0306d4e..0830a20 100644
--- a/app.js
+++ b/app.js
@@ -2,6 +2,7 @@ import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgen
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 = {
@@ -36,13 +37,14 @@ let chatError = '';
let chatMessages = [{ role: 'timmy', text: 'Ask me about your confirmed logs, visible patterns, privacy, or how Timmy works.' }];
let draftResumed = false;
let form = createDraft();
+let followUpAnswers = createFollowUpAnswers();
function draftBanner(){
if(!draftResumed||!isDraftDirty(form))return '';
return `
Draft kept in this session: ${esc(draftNotice(form))}
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.
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('')}
${followUpBlock()}
${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=()=>{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}
+ 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);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}}
function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i{URL.revokeObjectURL(url);reject()};img.src=url})}
diff --git a/artifacts/followup-hard-stool-mobile.png b/artifacts/followup-hard-stool-mobile.png
new file mode 100644
index 0000000..abaec89
Binary files /dev/null and b/artifacts/followup-hard-stool-mobile.png differ
diff --git a/package.json b/package.json
index 6b2679d..ff9e34f 100644
--- a/package.json
+++ b/package.json
@@ -4,15 +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/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": "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/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: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"
},
diff --git a/scripts/build_release.py b/scripts/build_release.py
index 906a2fd..738bf08 100755
--- a/scripts/build_release.py
+++ b/scripts/build_release.py
@@ -113,6 +113,7 @@ def main() -> int:
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
@@ -136,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/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"):
+ 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)
@@ -165,6 +166,7 @@ def main() -> int:
"- 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"
@@ -209,7 +211,7 @@ def main() -> int:
"codec": "h264",
"pixel_format": "yuv420p",
"recorded_from_working_app": True,
- "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, offline vision status, 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",
@@ -218,6 +220,7 @@ def main() -> int:
"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",
diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs
index e518b93..cc4ccbd 100644
--- a/scripts/record_release_demo.mjs
+++ b/scripts/record_release_demo.mjs
@@ -104,6 +104,7 @@ async function tap(selector, after = 650) {
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 }) => {
@@ -148,6 +149,25 @@ 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);
diff --git a/src/followup.js b/src/followup.js
new file mode 100644
index 0000000..e26a245
--- /dev/null
+++ b/src/followup.js
@@ -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,
+ };
+}
diff --git a/styles.css b/styles.css
index 8364f7f..12c7a28 100644
--- a/styles.css
+++ b/styles.css
@@ -30,3 +30,19 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
.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}
diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js
index 0a58a8f..9d346eb 100644
--- a/tests/ci-workflow.test.js
+++ b/tests/ci-workflow.test.js
@@ -64,6 +64,20 @@ test('provisional AI suggestion state is a unit-and-browser-gated contract', asy
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),
diff --git a/tests/followup.acceptance.mjs b/tests/followup.acceptance.mjs
new file mode 100644
index 0000000..b4b8435
--- /dev/null
+++ b/tests/followup.acceptance.mjs
@@ -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');
diff --git a/tests/followup.test.js b/tests/followup.test.js
new file mode 100644
index 0000000..fcd5eaf
--- /dev/null
+++ b/tests/followup.test.js
@@ -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, []);
+});
diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js
index 0d04bfc..8003d8f 100644
--- a/tests/release-demo.test.js
+++ b/tests/release-demo.test.js
@@ -73,3 +73,27 @@ test('release builder gates the provisional suggestion states the same way CI do
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/);
+});