Compare commits

..
Author SHA1 Message Date
rockachopa 4fa9fb1b17 docs: name grounded chat citations in the packaged release notes
Quality gates / quality (pull_request) Failing after 2m31s
2026-10-18 12:11:09 +00:00
11 changed files with 11 additions and 366 deletions

No files matched your search

-1
View File
@@ -58,7 +58,6 @@ jobs:
npm run test:vision-refusal
npm run test:worker-readiness
npm run test:pattern-summary
npm run test:photo-lifecycle
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks
-2
View File
@@ -99,7 +99,6 @@ 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 run test:vision-refusal # corpus refusals through a real server and a real browser
npm run test:grounded-chat # chat answers cite the ledger, or refuse to answer
npm run test:photo-lifecycle # photo presence, explicit reveal, remove-photo-keeps-log, delete-all
npm audit --audit-level=high
```
@@ -121,7 +120,6 @@ npm audit --audit-level=high
- 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
- A committed CC0 synthetic vision corpus that continuously proves every reply is schema-valid or fails closed. A schema-valid reply with no readable form or colour now **abstains** ("No confident match") instead of surfacing as a worker failure, and unparsable or out-of-range replies return plain copy — "No suggestion was applied. Continue manually, or try a clearer photo." — with no provider text, image bytes, or credentials echoed. See [tests/fixtures/vision-corpus](tests/fixtures/vision-corpus/README.md)
- Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies
- Private photo lifecycle: a confirmed log reports a saved photo as a label rather than a thumbnail, showing the image is an explicit request, removing the photo keeps every confirmed field, deleting a log is a separate two-step decision, and delete-all removes the ledger and every stored photo together
- Local browser ledger, calendar, pattern summary, JSON portability, and delete-all
- Explainable pattern summaries: the home insight card reports the confirmed-log count and form groupings for the last 28 days, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend. No line explains a cause, rules on food, or offers a health verdict
- Installable/offline PWA shell for the manual and saved-ledger paths
+3 -42
View File
@@ -6,7 +6,6 @@ import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp,
import { visionReadiness } from './src/vision-readiness.js';
import { buildPatternSummary } from './src/summary.js';
import { answerFromLedger, buildChatLedger, resolveAgentReply } from './src/grounded-chat.js';
import { photoState, withoutEntry, withoutPhoto } from './src/photo-lifecycle.js';
const runtimeConfig = {
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
@@ -100,49 +99,11 @@ function bindGlobal(){
document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger);
document.querySelectorAll('[data-scan]').forEach(btn=>btn.onclick=openPhotoFirst);
document.querySelectorAll('[data-statement-link]').forEach(btn=>btn.onclick=()=>showPatternRecords(btn.dataset.statementLink));
document.querySelectorAll('[data-entry-open]').forEach(btn=>btn.onclick=()=>showEntrySheet(btn.dataset.entryOpen));
}
function recentList(limit=5){
if(!entries.length)return `<div class="empty"><img src="${appPath('assets/timmy.svg')}" alt=""><h3>Quiet bowl, clean slate.</h3><p>Your first log takes about ten seconds.</p></div>`;
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>{
const photo=photoState(e);
return `<article class="entry"><div class="type-dot">T${e.bristolType}</div><div><strong>${formatDate(e.occurredAt)}</strong><span>${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}</span>${photo.hasPhoto?`<em class="photo-chip" data-photo-indicator="present">${esc(photo.label)}</em>`:''}</div><span class="entry-tail"><span class="bucket bucket-${bucketForBristolType(e.bristolType)}">${bucketForBristolType(e.bristolType)}</span><button class="entry-open" data-entry-open="${esc(e.id)}" aria-label="Open confirmed log from ${esc(formatDate(e.occurredAt))}, Type ${e.bristolType}"><span aria-hidden="true">›</span></button></span></article>`;
}).join('');
}
// Issue #36 — the saved photo and the confirmed log are independent. Presence is stated without
// rendering a thumbnail, showing the image is an explicit request, removing it keeps every confirmed
// field, and deleting the log is a separate two-step decision that takes the photo with it.
let entrySheetKeyHandler=null;
function showEntrySheet(id, armed=false){
const entry=entries.find(item=>item.id===id);
if(!entry)return;
const photo=photoState(entry);
const urgent=detectUrgentFlags(entry.symptoms);
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="entry-sheet-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Confirmed log</span><h2 id="entry-sheet-title">${formatDate(entry.occurredAt)}</h2></div><button class="icon-btn" id="entry-sheet-close" aria-label="Close">×</button></div><div class="entry-facts"><div><span>Form</span><strong>Type ${entry.bristolType}</strong></div><div><span>Color</span><strong>${esc(entry.color)}</strong></div><div><span>Urgency</span><strong>${entry.urgency}/4</strong></div><div><span>Discomfort</span><strong>${entry.discomfort}/4</strong></div></div>${entry.note?`<p class="fine entry-note">${esc(entry.note)}</p>`:''}${urgent.urgent?alertHtml(urgent.message):''}${photo.hasPhoto?`<section class="photo-lifecycle" data-photo-lifecycle="present"><strong>Photo saved with this log</strong><p class="fine">The image stays in this browser. The journal never shows it as a thumbnail until you ask.</p><div id="entry-photo-slot"></div><div class="row"><button class="btn btn-secondary" id="show-entry-photo">Show photo</button><button class="btn btn-ghost" id="remove-entry-photo">Remove photo, keep log</button></div></section>`:`<section class="photo-lifecycle" data-photo-lifecycle="absent"><strong>No photo saved</strong><p class="fine">This confirmed log is text only. Its record does not depend on an image.</p></section>`}<div class="entry-actions">${armed?`<button class="btn btn-danger btn-wide" id="delete-entry-confirm">Tap again to delete this log and its photo</button><p class="fine">This removes the confirmed log and its saved photo from this browser. It cannot be undone.</p>`:`<button class="btn btn-ghost btn-wide" id="delete-entry">Delete this log and its photo</button>`}</div></section>`;
document.body.append(wrap);
if(entrySheetKeyHandler)document.removeEventListener('keydown',entrySheetKeyHandler);
const close=()=>{wrap.remove();document.removeEventListener('keydown',onKey);if(entrySheetKeyHandler===onKey)entrySheetKeyHandler=null};
const onKey=event=>{if(event.key==='Escape')close()};
entrySheetKeyHandler=onKey;
document.addEventListener('keydown',onKey);
document.querySelector('#entry-sheet-close').onclick=close;
wrap.onclick=event=>{if(event.target===wrap)close()};
document.querySelector('#show-entry-photo')?.addEventListener('click',()=>{
const slot=document.querySelector('#entry-photo-slot');
slot.innerHTML=`<img class="photo-preview" id="entry-photo" src="${esc(entry.photoDataUrl)}" alt="Photo saved with this log">`;
document.querySelector('#show-entry-photo')?.remove();
});
document.querySelector('#remove-entry-photo')?.addEventListener('click',()=>{
entries=withoutPhoto(entries,id);saveEntries();render();showEntrySheet(id);toast('Photo removed. Your log is still here.');
});
document.querySelector('#delete-entry')?.addEventListener('click',()=>showEntrySheet(id,true));
document.querySelector('#delete-entry-confirm')?.addEventListener('click',()=>{
entries=withoutEntry(entries,id);saveEntries();close();render();toast('Log and photo deleted');
});
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`<article class="entry"><div class="type-dot">T${e.bristolType}</div><div><strong>${formatDate(e.occurredAt)}</strong><span>${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}</span></div><span class="bucket bucket-${bucketForBristolType(e.bristolType)}">${bucketForBristolType(e.bristolType)}</span></article>`).join('');
}
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()<week).length}
function currentStreak(){const dates=new Set(entries.map(e=>e.occurredAt.slice(0,10)));let n=0,d=new Date();while(dates.has(d.toISOString().slice(0,10))){n++;d.setDate(d.getDate()-1)}return n}
@@ -246,12 +207,12 @@ async function sendChat(event){
}
function privacy(){
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section><section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section><section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
}
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
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 and photos deleted')}}
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(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){
+2 -3
View File
@@ -4,7 +4,7 @@
"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/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 tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.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 tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.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",
@@ -16,8 +16,7 @@
"test:staging-smoke": "node tests/staging.acceptance.mjs",
"test:pattern-summary": "node tests/pattern-summary.acceptance.mjs",
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
"test:photo-lifecycle": "node tests/photo-lifecycle.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/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 src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.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 && node --check tests/vision-refusal.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 scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
"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 src/vision-corpus.js && node --check src/summary.js && node --check src/grounded-chat.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 && node --check tests/vision-refusal.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 scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
+3 -7
View File
@@ -34,10 +34,7 @@ DEMO_REQUIRED_CAPTIONS = (
"Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo",
"Vision worker offline — the AI and consent steps disappear instead of failing after upload",
"Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion",
"Photo presence is a label, not a thumbnail — nothing sensitive is on screen until you ask",
"Showing the photo is your explicit request, from this browser’s own storage",
"Remove just the photo: the confirmed log, form, and note are all still here",
"Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server",
"Delete all local data clears the ledger in this browser; there is no server copy of it to keep",
"Hermes Agent connected — free-text, contextual, and server-side",
"Urgent language is intercepted deterministically before Hermes",
"Bleeding reported as wiping still escalates, in the app’s own fixed words",
@@ -151,7 +148,6 @@ def main() -> int:
run(["npm", "run", "test:worker-readiness"], tree)
run(["npm", "run", "test:pattern-summary"], tree)
run(["npm", "run", "test:grounded-chat"], tree)
run(["npm", "run", "test:photo-lifecycle"], tree)
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
demo_env = dict(server_env)
@@ -193,7 +189,7 @@ def main() -> int:
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
raise SystemExit("Release demo contact sheet was not produced")
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/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.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", "src/vision-corpus.js", "src/summary.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.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)
@@ -233,7 +229,7 @@ def main() -> int:
"- Post-load readiness: the app offers AI only once the pinned model is actually loaded, so a reachable-but-unloaded worker falls back to manual logging in one tap instead of failing after the upload (issue #15)\n"
"- Target-GPU benchmark: scripts/record_worker_benchmark.mjs records cold start, warm latency, and VRAM on the target GPU and refuses to write a receipt it did not measure; no GPU measurement is bundled with this release\n"
"- Explainable pattern summaries (issue #34): the home insight card reports confirmed-log counts and form groupings per window, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend; no line explains a cause, rules on food, or offers a health verdict\n"
"- Private photo lifecycle (issue #36): the journal marks a log that holds a saved photo without rendering a thumbnail, showing the image is an explicit request, removing it keeps every confirmed field, deleting a log is a separate two-step decision that takes its photo, and delete-all removes the ledger and every stored photo together\n"
"- Grounded chat citations (issue #37): an answer that states a count, a date, or a visible form cites the confirmed records behind it, following a citation opens exactly that record, questions the journal cannot answer get boundary copy with no citations, and a Hermes reply whose ref does not resolve is labelled unverified instead of rendering a source; citations are ledger lookups, never inference\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"
+1 -66
View File
@@ -46,8 +46,6 @@ const context = await browser.newContext({
});
const page = await context.newPage();
const errors = [];
// Delete-all asks for confirmation; the recording accepts it so the resulting state is shown, not implied.
page.on('dialog', dialog => dialog.accept());
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
let visionOffline = false;
@@ -375,62 +373,6 @@ await caption('Two logs is not a pattern: the summary refuses to describe a tren
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// Issue #36 — a saved photo and its confirmed log are separate things a person owns. The journal
// states that a photo exists without rendering it, the image appears only when asked for, removing
// it keeps every confirmed field, and delete-all takes the ledger and every photo together. The
// only image here is a synthetic 1x1 PNG constant written by this script; no real photo is used.
const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
const photoDay = (id, daysAgo, bristolType = 4) => ({ ...sampleDay(id, daysAgo, bristolType), photoDataUrl: SYNTHETIC_PHOTO });
const storedLedger = async () => {
const key = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`);
const raw = await page.evaluate(storeKey => localStorage.getItem(storeKey), key);
return { key, raw };
};
await caption('A confirmed log can hold a private photo — the journal says so without showing it', 2300);
await seedSyntheticLedger([photoDay('demo-photo', 1), sampleDay('demo-nophoto', 4, 6)]);
await tap('[data-view="calendar"]', 550);
await page.locator('.entries-card .entry').first().waitFor();
if (await page.locator('.entries-card [data-photo-indicator="present"]').count() !== 1) {
throw new Error('exactly one log must be marked as holding a photo');
}
if (await page.locator('.entries-card img').count() !== 0) {
throw new Error('the journal must not show a thumbnail by default');
}
await caption('Photo presence is a label, not a thumbnail — nothing sensitive is on screen until you ask', 2700);
await indicate('[data-photo-indicator="present"]');
await tap('[data-entry-open="demo-photo"]', 550);
await page.locator('[data-photo-lifecycle="present"]').waitFor();
if (await page.locator('#entry-photo').count() !== 0) {
throw new Error('opening a log must not reveal its photo');
}
await caption('Opening the log states the photo is saved here — and still does not render it', 2500);
await indicate('[data-photo-lifecycle="present"]');
await tap('#show-entry-photo', 600);
await page.locator('#entry-photo').waitFor();
const revealedPhoto = await page.locator('#entry-photo').getAttribute('src');
if (!/^data:image\/png;base64,/.test(revealedPhoto || '')) throw new Error('the revealed image must be the stored bytes');
await caption('Showing the photo is your explicit request, from this browser’s own storage', 2300);
await indicate('#entry-photo');
await tap('#remove-entry-photo', 650);
await page.locator('[data-photo-lifecycle="absent"]').waitFor();
const afterRemoval = JSON.parse((await storedLedger()).raw || '[]');
const keptLog = afterRemoval.find(entry => entry.id === 'demo-photo');
if (afterRemoval.length !== 2 || !keptLog || keptLog.photoDataUrl !== '') {
throw new Error('removing a photo must keep the confirmed log and clear only its image bytes');
}
if (keptLog.bristolType !== 4 || keptLog.color !== 'brown') throw new Error('removing a photo must not alter the confirmed fields');
await caption('Remove just the photo: the confirmed log, form, and note are all still here', 2900);
await indicate('[data-photo-lifecycle="absent"]');
await tap('#entry-sheet-close', 450);
await page.locator('.entries-card .entry').first().waitFor();
if (await page.locator('.entries-card [data-photo-indicator="present"]').count() !== 0) {
throw new Error('the journal must drop the photo label once the bytes are gone');
}
await caption('The label follows the bytes: the log stays, the photo is gone', 2300);
// One photo-bearing log remains for the deletion pass below, so the wipe shows photos leaving with
// the ledger rather than an empty promise.
await seedSyntheticLedger([photoDay('demo-wipe', 2)]);
await caption('The threat model documents this surface, so the recording shows it live rather than describing it', 2200);
await tap('[data-view="calendar"]', 550);
await page.getByText(/Your private journal/i).waitFor();
@@ -440,14 +382,7 @@ await caption('Every byte-bearing control the model names, in the running app: l
await indicate('#export');
await caption('Export writes a plain JSON file you hold — the model records it as a data-lifetime boundary, not a server sync', 2600);
await indicate('#delete-all');
await tap('#delete-all', 650);
await page.getByText(/Local ledger and photos deleted/).waitFor();
const wiped = await storedLedger();
if (wiped.raw !== null) throw new Error('delete-all must remove the stored ledger');
if (await page.evaluate(() => JSON.stringify(localStorage).includes('data:image'))) {
throw new Error('no photo bytes may survive delete-all');
}
await caption('Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server', 2900);
await caption('Delete all local data clears the ledger in this browser; there is no server copy of it to keep', 2600);
await tap('[data-view="home"]', 500);
// Issue #37 — a chat answer about the journal must point at the confirmed records behind it. The
// scenes below run against the app's own local ledger contract with fictional logs, and every claim
-26
View File
@@ -1,26 +0,0 @@
// Photo lifecycle for saved entries. A confirmed log and the image attached to it are separate
// things a person owns: presence is reported from the stored image bytes, removing the image never
// touches the log, and deleting the log is a separate, explicit decision.
const IMAGE_DATA_URL = /^data:image\/[a-z0-9.+-]+(?:;base64)?,[A-Za-z0-9+/=%.-]+$/i;
export function photoState(entry) {
return IMAGE_DATA_URL.test(String(entry?.photoDataUrl || ''))
? { hasPhoto: true, label: 'Photo saved' }
: { hasPhoto: false, label: '' };
}
export function withoutPhoto(entries, id) {
if (!Array.isArray(entries)) return [];
if (!entries.some(entry => entry.id === id)) return entries;
return entries.map(entry => (entry.id === id ? { ...entry, photoDataUrl: '' } : entry));
}
export function withoutEntry(entries, id) {
if (!Array.isArray(entries)) return [];
if (!entries.some(entry => entry.id === id)) return entries;
return entries.filter(entry => entry.id !== id);
}
export function photoCount(entries) {
return Array.isArray(entries) ? entries.filter(entry => photoState(entry).hasPhoto).length : 0;
}
+1 -1
View File
@@ -11,7 +11,7 @@ button,input,textarea,select{font:inherit}button{color:inherit}.hidden,.sr-only{
main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--teal)}h1,h2,h3,p{margin-top:0}h1{font-size:clamp(32px,9vw,46px);line-height:1.02;letter-spacing:-1.8px;margin:8px 0 12px}h2{font-size:23px;line-height:1.1;letter-spacing:-.65px;margin:4px 0 8px}h3{font-size:16px;margin-bottom:7px}.lead,.page-title p{color:#625a53;line-height:1.5;font-size:15px}
.sleek-hero{padding:25px 2px 16px}.sleek-hero h1{font-size:42px;max-width:390px}.sleek-hero .lead{max-width:460px;margin-bottom:22px}.capture-cta{width:100%;border:0;border-radius:22px;background:var(--ink);color:#fff;display:grid;grid-template-columns:50px 1fr 28px;gap:13px;align-items:center;text-align:left;padding:13px 14px;min-height:78px;box-shadow:0 16px 28px rgba(33,29,26,.2);cursor:pointer}.capture-cta:active{transform:scale(.985)}.capture-cta>span:nth-child(2){display:flex;flex-direction:column;gap:2px}.capture-cta strong{font-size:17px}.capture-cta small{color:rgba(255,255,255,.65);font-size:12px;font-weight:500}.capture-cta i{font-style:normal;font-size:22px}.capture-icon{width:48px;height:48px;border-radius:16px;display:grid;place-items:center;background:var(--gold);color:var(--ink);font-size:25px}.text-action{border:0;background:transparent;color:var(--teal);font-weight:750;min-height:44px;padding:8px 2px;cursor:pointer}.sleek-hero>.text-action{width:100%;margin-top:7px}.text-action.compact{width:auto;min-height:42px;padding:7px 4px;font-size:14px;white-space:nowrap}
.glance{display:flex;align-items:center;justify-content:space-around;background:rgba(255,253,250,.68);border:1px solid var(--line);border-radius:20px;padding:13px 10px;margin-top:7px}.glance div{text-align:center;min-width:68px}.glance strong{font-size:21px;line-height:1;display:block}.glance span{font-size:11px;color:var(--muted)}.glance>i{width:1px;height:28px;background:var(--line)}
.section{margin-top:25px}.section-head{display:flex;justify-content:space-between;align-items:end;gap:12px;margin-bottom:11px}.section-head h2{margin-bottom:0}.insight-card{display:grid;grid-template-columns:54px 1fr;gap:13px;align-items:center;background:var(--teal-soft);border-radius:22px;padding:15px}.insight-card p{margin:3px 0 0;line-height:1.4;font-size:14px}.timmy-orb{width:54px;height:54px;border-radius:18px;background:var(--surface);display:grid;place-items:center}.timmy-orb img{width:45px;height:45px}.latest-card,.entry{display:grid;grid-template-columns:44px 1fr auto;gap:12px;align-items:center}.latest-card{background:var(--surface);border:1px solid var(--line);padding:13px;border-radius:19px}.latest-card>div:nth-child(2),.entry>div:nth-child(2){min-width:0}.latest-card strong,.entry strong{display:block;font-size:14px}.latest-card span:not(.type-dot),.entry span{display:block;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chevron{font-size:24px!important;color:#aaa!important}.type-dot{width:42px;height:42px;border-radius:14px;display:grid;place-items:center;background:#f0e2d7;color:var(--brown);font-weight:800;font-size:13px}.entries-card{background:var(--surface);border:1px solid var(--line);border-radius:20px;padding:4px 13px}.entry{grid-template-columns:42px minmax(0,1fr) auto;padding:12px 0;border-bottom:1px solid var(--line)}.entry:last-child{border-bottom:0}.bucket{padding:5px 7px;border-radius:999px;background:var(--soft);font-size:10px!important;font-weight:700}.entry-open{width:44px;height:44px;margin-right:-6px;border:0;border-radius:50%;background:transparent;color:#aaa;font-size:24px;line-height:1;cursor:pointer;display:grid;place-items:center}.entry-open:active{background:var(--soft)}.entry-open:focus-visible{outline:3px solid var(--teal);outline-offset:-3px}.entry-tail{display:flex!important;align-items:center;gap:8px}.photo-chip{display:inline-flex!important;align-items:center;gap:5px;margin-top:5px;padding:3px 8px;border-radius:999px;background:var(--teal-soft);color:var(--teal)!important;font-size:10px!important;font-style:normal;font-weight:800;letter-spacing:.04em;text-transform:uppercase}.entry-facts{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:14px 0}.entry-facts>div{background:#f7f3ed;border-radius:14px;padding:10px 12px}.entry-facts span{display:block;font-size:10px;font-weight:800;letter-spacing:.06em;color:var(--muted);text-transform:uppercase}.entry-facts strong{display:block;margin-top:3px;font-size:15px}.entry-note{background:#faf7f1;border-radius:14px;padding:10px 12px}.photo-lifecycle{background:var(--teal-soft);border-radius:16px;padding:13px;margin:13px 0}.photo-lifecycle[data-photo-lifecycle="absent"]{background:#f5f1e9}.photo-lifecycle strong{display:block;font-size:14px}.photo-lifecycle .row{margin-top:10px}.entry-actions{margin-top:16px;padding-top:14px;border-top:1px solid var(--line)}.entry-actions .btn{margin-bottom:8px}.bucket-typical{background:var(--teal-soft);color:var(--teal)}
.section{margin-top:25px}.section-head{display:flex;justify-content:space-between;align-items:end;gap:12px;margin-bottom:11px}.section-head h2{margin-bottom:0}.insight-card{display:grid;grid-template-columns:54px 1fr;gap:13px;align-items:center;background:var(--teal-soft);border-radius:22px;padding:15px}.insight-card p{margin:3px 0 0;line-height:1.4;font-size:14px}.timmy-orb{width:54px;height:54px;border-radius:18px;background:var(--surface);display:grid;place-items:center}.timmy-orb img{width:45px;height:45px}.latest-card,.entry{display:grid;grid-template-columns:44px 1fr auto;gap:12px;align-items:center}.latest-card{background:var(--surface);border:1px solid var(--line);padding:13px;border-radius:19px}.latest-card>div:nth-child(2),.entry>div:nth-child(2){min-width:0}.latest-card strong,.entry strong{display:block;font-size:14px}.latest-card span:not(.type-dot),.entry span{display:block;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chevron{font-size:24px!important;color:#aaa!important}.type-dot{width:42px;height:42px;border-radius:14px;display:grid;place-items:center;background:#f0e2d7;color:var(--brown);font-weight:800;font-size:13px}.entries-card{background:var(--surface);border:1px solid var(--line);border-radius:20px;padding:4px 13px}.entry{grid-template-columns:42px minmax(0,1fr) auto;padding:12px 0;border-bottom:1px solid var(--line)}.entry:last-child{border-bottom:0}.bucket{padding:5px 7px;border-radius:999px;background:var(--soft);font-size:10px!important;font-weight:700}.bucket-typical{background:var(--teal-soft);color:var(--teal)}
.page-title{padding:21px 3px 14px}.page-title h1{font-size:38px}.journal-title{padding-top:18px}.calendar-card,.card{background:var(--surface);border:1px solid var(--line);border-radius:22px;padding:16px}.calendar{display:grid;grid-template-columns:repeat(7,1fr);gap:7px}.day,.cal-head{aspect-ratio:1;display:grid;place-items:center;font-size:12px;border-radius:12px}.cal-head{aspect-ratio:auto;color:var(--muted);font-weight:700;padding-bottom:6px}.day{background:#f2eee6}.day.blank{background:transparent}.day.has-log{background:var(--teal);color:white;font-weight:800;box-shadow:inset 0 0 0 3px var(--teal-soft)}.journal-settings{margin-top:18px}.settings-row{width:100%;min-height:54px;border:1px solid var(--line);background:rgba(255,253,250,.6);border-radius:18px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:700;cursor:pointer}.settings-row b{font-size:23px;color:var(--muted)}
.bottom-nav{position:fixed;left:50%;bottom:calc(10px + env(safe-area-inset-bottom));transform:translateX(-50%);width:min(calc(100% - 24px),640px);height:68px;padding:5px;display:grid;grid-template-columns:repeat(3,1fr);background:rgba(255,253,249,.88);border:1px solid rgba(70,54,43,.12);border-radius:23px;box-shadow:0 16px 38px rgba(50,37,29,.18);backdrop-filter:blur(20px);z-index:20}.nav-btn{border:0;background:transparent;border-radius:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;color:var(--muted);font-size:11px;font-weight:700;cursor:pointer}.nav-btn b{font-size:19px;line-height:1}.nav-btn.active{background:var(--ink);color:#fff;box-shadow:0 6px 14px rgba(33,29,26,.15)}
.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}
-123
View File
@@ -1,123 +0,0 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
// Issue #36 — a saved photo is reported and removable without touching the confirmed log.
// Every image below is a synthetic 1x1 PNG constant; no real photo is loaded or written.
const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
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));
page.on('dialog', dialog => dialog.accept());
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
const sample = (id, daysAgo, photoDataUrl = '', overrides = {}) => ({
id,
occurredAt: new Date(Date.now() - daysAgo * 86400000).toISOString(),
bristolType: 4,
color: 'brown',
urgency: 1,
discomfort: 0,
note: '',
photoDataUrl,
symptoms: {},
...overrides,
});
const ledgerKey = () => page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`);
async function seedLedger(list) {
const key = await ledgerKey();
await page.evaluate(({ storeKey, entries }) => localStorage.setItem(storeKey, JSON.stringify(entries)), { storeKey: key, entries: list });
await page.reload({ waitUntil: 'networkidle' });
}
async function storedEntries() {
const key = await ledgerKey();
return page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]'), key);
}
// 1. Presence is visible in the journal without a thumbnail.
await seedLedger([sample('photo-log', 2, SYNTHETIC_PHOTO), sample('plain-log', 5, '', { bristolType: 6 })]);
await page.locator('[data-view="calendar"]').last().click();
await page.locator('.entries-card .entry').first().waitFor();
assert.equal(await page.locator('.entries-card .entry').count(), 2, 'both confirmed logs must be listed');
assert.equal(await page.locator('.entries-card [data-photo-indicator="present"]').count(), 1, 'exactly the log holding a photo is marked');
assert.equal(await page.locator('.entries-card img').count(), 0, 'the journal must not render a thumbnail by default');
assert.equal(await page.getByText('Photo saved', { exact: true }).count(), 1);
// Visual acceptance: the label sits inside the 390px viewport and adds no horizontal scroll.
const chipBox = await page.locator('.entries-card [data-photo-indicator="present"]').boundingBox();
assert.ok(chipBox && chipBox.width > 0, 'the photo label must be rendered');
assert.ok(chipBox.x + chipBox.width <= 390, 'the photo label must fit the mobile viewport');
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth) <= 390, 'the journal must not scroll horizontally');
// 2. The entry sheet reports the saved photo without showing it.
await page.locator('[data-entry-open="photo-log"]').click();
await page.locator('[data-photo-lifecycle="present"]').waitFor();
assert.equal(await page.locator('#entry-photo').count(), 0, 'opening a log must not show the photo until asked');
assert.match(await page.locator('[data-photo-lifecycle]').innerText(), /Photo saved with this log/i);
assert.match(await page.locator('[data-photo-lifecycle]').innerText(), /stays in this browser/i);
// Touch targets stay usable: every lifecycle action is at least 44px tall on mobile.
for (const selector of ['#show-entry-photo', '#remove-entry-photo', '#delete-entry']) {
const box = await page.locator(selector).boundingBox();
assert.ok(box && box.height >= 44, `${selector} must be a 44px touch target`);
}
// 3. Showing it is an explicit request.
await page.locator('#show-entry-photo').click();
const shown = page.locator('#entry-photo');
await shown.waitFor();
assert.match(await shown.getAttribute('src'), /^data:image\/png;base64,/, 'the revealed image is the stored bytes');
assert.match(await shown.getAttribute('alt'), /photo saved with this log/i);
// 4. Removing the photo keeps the confirmed log.
await page.locator('#remove-entry-photo').click();
await page.locator('[data-photo-lifecycle="absent"]').waitFor();
await page.getByText(/Photo removed\. Your log is still here\./).waitFor();
let stored = await storedEntries();
assert.equal(stored.length, 2, 'removing a photo must not delete the log');
const kept = stored.find(item => item.id === 'photo-log');
assert.ok(kept, 'the log that held the photo must still exist');
assert.equal(kept.photoDataUrl, '', 'only the image bytes are cleared');
assert.equal(kept.bristolType, 4);
assert.equal(kept.occurredAt, stored.find(item => item.id === 'photo-log').occurredAt);
await page.locator('#entry-sheet-close').click();
await page.locator('.entries-card .entry').first().waitFor();
assert.equal(await page.locator('.entries-card [data-photo-indicator="present"]').count(), 0, 'the indicator must follow the stored bytes');
// The sheet is a dialog: Escape dismisses it.
await page.locator('[data-entry-open="photo-log"]').click();
await page.locator('.sheet-backdrop').waitFor();
await page.keyboard.press('Escape');
await page.locator('.sheet-backdrop').waitFor({ state: 'detached' });
// 5. Deleting a log asks once and takes its photo with it.
await seedLedger([sample('delete-me', 1, SYNTHETIC_PHOTO), sample('keep-me', 3, '')]);
await page.locator('[data-view="calendar"]').last().click();
await page.locator('[data-entry-open="delete-me"]').click();
await page.locator('#delete-entry').click();
await page.locator('#delete-entry-confirm').waitFor();
assert.equal((await storedEntries()).length, 2, 'the first tap only arms the delete');
await page.locator('#delete-entry-confirm').click();
await page.locator('.entries-card .entry').first().waitFor();
stored = await storedEntries();
assert.deepEqual(stored.map(item => item.id), ['keep-me'], 'the confirmed log and its photo are gone together');
// 6. Delete-all removes the ledger and every saved photo.
await seedLedger([sample('wipe-1', 1, SYNTHETIC_PHOTO), sample('wipe-2', 2, SYNTHETIC_PHOTO)]);
await page.locator('[data-view="calendar"]').last().click();
await page.locator('.settings-row[data-view="privacy"]').click();
await page.locator('#delete-all').click();
await page.getByText(/Local ledger and photos deleted/).waitFor();
const key = await ledgerKey();
assert.equal(await page.evaluate(storeKey => localStorage.getItem(storeKey), key), null, 'delete-all must remove the ledger');
assert.equal(await page.evaluate(() => JSON.stringify(localStorage).includes('data:image')), false, 'no photo bytes may survive delete-all');
await page.locator('[data-view="calendar"]').last().click();
await page.getByText(/Quiet bowl, clean slate\./).waitFor();
assert.deepEqual(errors, []);
await browser.close();
console.log('PASS photo presence → reveal on request → remove photo keeps log → delete log → delete-all');
-71
View File
@@ -1,71 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { photoCount, photoState, withoutEntry, withoutPhoto } from '../src/photo-lifecycle.js';
const entry = (id, photoDataUrl = '', overrides = {}) => ({
id,
occurredAt: '2026-10-14T07:20:00.000Z',
bristolType: 4,
color: 'brown',
urgency: 1,
discomfort: 0,
note: '',
photoDataUrl,
symptoms: { blood: false },
...overrides,
});
test('photo state is decided by the saved image bytes, not a filename or a flag', () => {
assert.deepEqual(photoState(entry('a', 'data:image/png;base64,AAAA')), {
hasPhoto: true,
label: 'Photo saved',
});
});
test('a log with no usable image reports that no photo is saved', () => {
for (const value of [undefined, '', 'synthetic-type4.jpg', 'data:text/plain;base64,AAAA', 42]) {
assert.deepEqual(photoState(entry('a', value)), { hasPhoto: false, label: '' });
}
});
test('removing a photo clears only the image bytes and keeps the confirmed log intact', () => {
const before = entry('a', 'data:image/png;base64,AAAA', { note: 'after lunch', bristolType: 3 });
const [after] = withoutPhoto([before], 'a');
assert.equal(after.id, before.id);
assert.equal(after.occurredAt, before.occurredAt);
assert.equal(after.bristolType, 3);
assert.equal(after.note, 'after lunch');
assert.deepEqual(after.symptoms, before.symptoms);
assert.equal(after.photoDataUrl, '');
});
test('removing a photo never mutates the ledger it was given and leaves other logs alone', () => {
const withPhoto = entry('a', 'data:image/png;base64,AAAA');
const withoutOne = entry('b', '', { note: 'no photo here' });
const input = [withPhoto, withoutOne];
const result = withoutPhoto(input, 'a');
assert.notEqual(result, input);
assert.equal(input[0].photoDataUrl, 'data:image/png;base64,AAAA');
assert.deepEqual(result[1], withoutOne);
assert.deepEqual(withoutPhoto(input, 'missing'), input);
assert.equal(withoutPhoto(input, 'missing')[0], input[0]);
});
test('deleting a log removes exactly that confirmed entry, photo included', () => {
const input = [entry('a', 'data:image/png;base64,AAAA'), entry('b'), entry('c', '', { bristolType: 6 })];
const result = withoutEntry(input, 'a');
assert.deepEqual(result.map(item => item.id), ['b', 'c']);
assert.equal(input.length, 3);
assert.deepEqual(withoutEntry(input, 'missing'), input);
});
test('photo count reports how many confirmed logs still hold an image', () => {
assert.equal(photoCount([entry('a', 'data:image/png;base64,AAAA'), entry('b'), entry('c', 'data:image/jpeg;base64,BBBB')]), 2);
assert.equal(photoCount([]), 0);
assert.equal(photoCount(undefined), 0);
});
+1 -24
View File
@@ -181,36 +181,13 @@ test('release demo shows the byte-bearing controls the threat model governs, wit
assert.match(demo, /Every byte-bearing control the model names/);
assert.match(demo, /indicate\('#export'\)/);
assert.match(demo, /indicate\('#delete-all'\)/);
assert.match(demo, /Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server/);
assert.match(demo, /Delete all local data clears the ledger in this browser; there is no server copy of it to keep/);
// Real controls only: the selectors must exist in the app shell, not be demo-only scaffolding.
const app = await readFile(new URL('../app.js', import.meta.url), 'utf8');
assert.match(app, /id="export"/);
assert.match(app, /id="delete-all"/);
});
test('release demo exercises the photo lifecycle on camera without showing an image by default', async () => {
const demo = await readFile(demoPath, 'utf8');
// Presence first: a label in the journal, with no thumbnail rendered.
assert.match(demo, /Photo presence is a label, not a thumbnail/);
assert.match(demo, /\.entries-card \[data-photo-indicator="present"\]/);
assert.match(demo, /exactly one log must be marked as holding a photo/);
assert.match(demo, /the journal must not show a thumbnail by default/);
// Revealing the image is an explicit request, asserted before the caption that claims it.
assert.match(demo, /opening a log must not reveal its photo/);
assert.match(demo, /#show-entry-photo/);
assert.match(demo, /Showing the photo is your explicit request/);
// Removing the photo keeps every confirmed field of the log.
assert.match(demo, /#remove-entry-photo/);
assert.match(demo, /Remove just the photo: the confirmed log, form, and note are all still here/);
assert.match(demo, /removing a photo must keep the confirmed log and clear only its image bytes/);
// Delete-all is actually performed, not just pointed at.
assert.match(demo, /tap\('#delete-all'/);
assert.match(demo, /no photo bytes may survive delete-all|JSON\.stringify\(localStorage\)\.includes\('data:image'\)/);
// Synthetic bytes only: a committed 1x1 PNG constant, never a real photo.
assert.match(demo, /const SYNTHETIC_PHOTO = 'data:image\/png;base64,/);
});
test('release builder refuses to publish without the on-camera delta receipt', async () => {
const builder = await readFile(builderPath, 'utf8');