Compare commits

..
Author SHA1 Message Date
rockachopa d0b3c10aea Merge pull request 'fix(release): paint demo captions and touch rings on camera and verify the pixels' (#81) from fix/demo-caption-pixels into main 2026-10-19 12:35:29 +00:00
rockachopa c2e9553be5 chore: update tests/release-demo.test.js for the caption pixel gate
Quality gates / quality (pull_request) Successful in 3m14s
2026-10-19 12:35:14 +00:00
rockachopa d1c858b940 chore: update scripts/build_release.py for the caption pixel gate 2026-10-19 12:35:12 +00:00
rockachopa fd09282d8d fix(release): paint demo captions and touch rings on camera and verify the pixels
The demo injected its caption CSS with page.addStyleTag(), which writes into the current document
only. The first page.reload() in the recording stripped that stylesheet, so every later caption
rendered as unstyled body text clipped at the bottom of the frame and every touch indicator was
invisible, while the caption receipt still listed each string and the release gate accepted it.

- caption and touch nodes now carry inline styles, which no reload can remove
- caption() proves each caption is a fixed bar at the top of the viewport, on top of what is
  beneath it, fully inside the frame, and that the caption band pixels change; the recording fails
  rather than shipping a caption nobody can read
- the receipt records per-caption evidence and build_release.py requires every required caption,
  including the release delta, to be pixel-verified
- the demo photo is a legible synthetic test card instead of a 1x1 white pixel that rendered on
  camera as an empty box
- tests assert the inline styling and the pixel gate
2026-10-19 12:35:11 +00:00
rockachopa 0e4dde5f81 Merge pull request 'feat: separate saved-photo lifecycle from the confirmed log (#36)' (#80) from timmy/36-photo-lifecycle-controls into main 2026-10-19 12:02:37 +00:00
timmy ebee52fd8a feat: separate saved-photo lifecycle from the confirmed log (#36)
Quality gates / quality (pull_request) Successful in 3m9s
A confirmed log and the image attached to it are separate things the
person owns. The journal now reports a saved photo as a label instead of
a thumbnail, reveals the image only on explicit request, removes the
photo without touching any confirmed field, and deletes a log through a
separate two-step decision that takes its photo with it. Delete-all is
regression tested to remove the ledger and every stored photo together.

- src/photo-lifecycle.js: presence decided by stored image bytes;
  withoutPhoto/withoutEntry return new lists and never mutate the ledger
- app.js: journal rows expose Open-entry controls; entry sheet states
  presence, offers explicit reveal, remove-photo-keeps-log, and a
  two-step log delete; privacy copy names photo removal and delete-all
- tests: 6 unit cases + browser acceptance (presence, no default
  thumbnail, explicit reveal, remove keeps log, two-step delete,
  delete-all leaves no image bytes, Escape dismiss, 44px targets)
- record_release_demo.mjs: on-camera photo lifecycle scenes driven by a
  synthetic 1x1 PNG, with delete-all actually performed and asserted;
  build_release gates the new captions
- CI/release/syntax gates include the new suite

Closes #36
2026-10-19 08:24:44 +00:00
11 changed files with 438 additions and 18 deletions

No files matched your search

+1
View File
@@ -58,6 +58,7 @@ 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,6 +99,7 @@ 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
```
@@ -120,6 +121,7 @@ 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
+42 -3
View File
@@ -6,6 +6,7 @@ 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 || '/',
@@ -99,11 +100,49 @@ 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=>`<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('');
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');
});
}
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}
@@ -207,12 +246,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.</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>`);
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>`);
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 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 and photos deleted')}}
function openPhotoFirst(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){
+3 -2
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",
"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:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
@@ -16,7 +16,8 @@
"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",
"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",
"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:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
+14 -4
View File
@@ -34,7 +34,10 @@ 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",
"Delete all local data clears the ledger in this browser; there is no server copy of it to keep",
"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",
"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",
@@ -148,6 +151,7 @@ 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)
@@ -159,11 +163,17 @@ def main() -> int:
raise SystemExit("Release demo produced no on-camera caption receipt")
recorded = json.loads(caption_receipt.read_text(encoding="utf-8"))
onscreen = [str(line) for line in (recorded.get("captions") or [])]
if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in onscreen):
# A pushed string is not proof of pixels. Only captions the demo measured as a painted,
# on-top, in-frame bar (style + elementFromPoint + band pixel change) count as on camera.
verified = [str(item.get("text")) for item in (recorded.get("captionsVerified") or [])
if item.get("visible") is True and item.get("bandChanged") is True]
if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in verified):
raise SystemExit("Release demo did not state the declared release delta on camera")
for required in DEMO_REQUIRED_CAPTIONS:
if not any(required in line for line in onscreen):
raise SystemExit(f"Release demo is missing a required on-camera caption: {required}")
if not any(required in line for line in verified):
raise SystemExit(f"Release demo caption never painted on screen: {required}")
finally:
server.terminate()
try:
@@ -189,7 +199,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", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.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", "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"):
run(["node", "--check", file], tree)
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
@@ -229,7 +239,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"
"- 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"
"- 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"
"- 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"
+110 -6
View File
@@ -46,6 +46,8 @@ 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;
@@ -111,21 +113,51 @@ await page.route('**/api/agent/chat', route => {
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
await page.addStyleTag({ content: `
#demo-caption{position:fixed;left:16px;right:16px;top:14px;z-index:20000;background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,.2)}
#demo-touch{position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;pointer-events:none;transform:translate(-50%,-50%)}
` });
const captionEvidence = [];
// Pixel proof that a caption actually painted: if the caption band looks identical with and without
// the node, the caption did not reach the screen even though the string was pushed.
async function captionBand() {
return page.screenshot({ clip: { x: 0, y: 0, width: 405, height: 68 } });
}
async function caption(text, duration = 1400) {
captionsShown.push(text);
const blankBand = await captionBand().catch(() => null);
await page.evaluate(text => {
document.querySelector('#demo-caption')?.remove();
const node = document.createElement('div');
node.id = 'demo-caption';
node.textContent = text;
// Inline styles, not a stylesheet: a stylesheet belongs to one document and the demo reloads the
// page, which silently stripped caption styling and left every later caption clipped body text.
node.style.cssText = 'position:fixed;left:16px;right:16px;top:14px;z-index:20000;'
+ 'background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;'
+ 'font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;'
+ 'box-shadow:0 8px 24px rgba(0,0,0,.2)';
document.body.append(node);
node.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 260, fill: 'forwards' });
}, text);
await sleep(320);
// Structural proof: a fixed bar at the top of the viewport, on top of whatever is beneath it, fully
// inside the frame. A caption that lost its stylesheet sits unstyled at the end of the body instead.
const painted = await page.evaluate(() => {
const node = document.querySelector('#demo-caption');
if (!node) return { ok: false, reason: 'caption node missing' };
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2));
return {
ok: style.position === 'fixed' && style.zIndex === '20000' && rect.top <= 40 && rect.height >= 24
&& rect.width > 200 && rect.bottom <= window.innerHeight && (topmost === node || (topmost && node.contains(topmost))),
top: Math.round(rect.top), height: Math.round(rect.height), position: style.position,
zIndex: style.zIndex, background: style.backgroundColor, topmost: topmost === node,
};
});
const shownBand = await captionBand().catch(() => null);
const bandChanged = Boolean(blankBand && shownBand && !blankBand.equals(shownBand));
if (!painted.ok) throw new Error(`Caption is not an on-screen bar: ${text} ${JSON.stringify(painted)}`);
if (!bandChanged) throw new Error(`Caption painted nothing into the caption band: ${text}`);
captionEvidence.push({ text, visible: true, position: painted.position, top: painted.top, height: painted.height, bandChanged });
await sleep(duration);
// A caption must not outlive its stated duration. It used to stay on screen until the next one
// replaced it, so a view change left the previous screen's claim hanging over a different screen —
@@ -148,6 +180,9 @@ async function tap(selector, after = 650) {
document.querySelector('#demo-touch')?.remove();
const ring = document.createElement('div');
ring.id = 'demo-touch';
ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;'
+ 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;'
+ 'pointer-events:none;transform:translate(-50%,-50%)';
ring.style.left = `${x}px`;
ring.style.top = `${y}px`;
document.body.append(ring);
@@ -168,6 +203,9 @@ async function indicate(selector) {
document.querySelector('#demo-touch')?.remove();
const ring = document.createElement('div');
ring.id = 'demo-touch';
ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;'
+ 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;'
+ 'pointer-events:none;transform:translate(-50%,-50%)';
ring.style.left = `${x}px`;
ring.style.top = `${y}px`;
document.body.append(ring);
@@ -373,6 +411,64 @@ 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.
// A 160x120 synthetic test card (checkerboard + 'SYNTHETIC DEMO PHOTO'), so the reveal is legibly
// an image on camera. It carries no photograph of any person or specimen.
const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAAG0ElEQVR4nO2deVATZxTAXxKSIldVZqyiTEWucMgpyCGggEK96gFYgSKVenCJoYAy1DpaBYpBnEqt1WoVb2071fHCqi2IiFo70gJaT7COWKdYBZExCW7/2HFJm8OFok5f3m/yx+6X9739Jr98m+zb+RKB1MYaCLwIX/UAiBcLCUaOEbfVEh/33OjGtAT+qYeWlvEPpsy9m9ly+w52g2YwckgwckgwckgwckgwckgwckgwckgwckgwckgwckgwcgTc7cILNeXPjUZTqkWf2dsvgt2gGYwcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEoycrqUrfG5Robmbhj6z5bMNmsHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHI6Vp8xv0Q2r3lBa9uPMR/YsBHuewG90NoWlYXmgxwevkjI3qFx/cusRu0utBQIMHIIcHIIcHIIcHIIcHIMXp+CAAANNTVFRcVqFQqkUiUXyRvf9QuS0/57nC5UCgEgKjJEwrkq6Pfnuju6bV15x62i6+7y7na+uPlR8u2bAaAn3867zXCBwDiEhKXLM4+V1vPJWcjAcDT2WG4uwfbGBo+dsgQaz199+3euWvHNlNTMxMT02Ur8wcOsuqdlwQZUhtr9tH+RwP7YLQxZXxEc/MdhmHKjxySpSUzDJO5IPXQgf0Mw/x48gTb4uPmHBc97eyZaraLj5uzegb1XV1P/atdT9/Tpypnxc7o6OhgGKbih5OJsTO0djQ0OImcVr6n6JaWPxVPngBAaPi4uIREAEhJX7j+s7VPnz7dsK40dYGMDUuTfbB2zeoX81b8B5s3fiHLyjE2NgaA4NFjrN8cqlKpXsJx/3fwFSzLWhQXM/3DRdkXzp/z9vEFAFs7O0ep09K83EFWg23t7dkwP/8AADh7pvoFDZfj2tUrTi6u3O7y/EIjI74fNwYF3xdlalR02Nhxx78vL/h4WXhEZFqGDABSFmRMHBd28NgJ9cj0hZmflhSP9A/Qk02pVCbMjFHf1WzPzF7k4eWtK0NnZyfPkRs4vATfv9/S1Njo6eU9LSpmdGjY5MixrGCbYbampqY2w2zVg339/EUiUY3eSSwWi8t27e3q4u6itV0PQ22GXW6od/PwBACGYXKzMwvlJXw6Ghq8TtECgUCWlny3+Q4APPjrgZXVYP3x6QszS0uKe2F0uomNT1hTvEqhUADA4YMH2A1CE14zuF+//svzCzNSk42NjYVC4coiuf74Eb4jxWJxD1509VO0h6dXZs5iXZFvTZzU2Hhz+qTx/S0tLS0tlyxf0d1jGQh0PxgVmveDqZKFHBKMHBKMHL7XwWyVWCAQKBWK6Hdip0ZFg0bpODFpjoeT/Ziw8JLSz9nGHFnGsaOHL166CgDffr13R9kWsViiVCrenTV7yvQozeQqpXLxkqXD3dy56jQLt6uZRGutu739ka5jGRp8BXNXqB2PH6fMmd3HxCRy/ATNy1aJRHLzxo3Ozk6RSMQwzK1bTRKJBACqKiu+2bN7y/bd5hYWba2t85MS3xg40D9w1L+SX/ntcl5O1r79B7WOQWuS8IjI8IhIAPB1d2GTVFVWlG3+UtexDI1un6L7mJhk5eZt+2qTrgBnV9e6X2oB4HJDg6NUyjZu2rA+OzfP3MICAMwtLLJy8zauX6fZ18FRevv277oy80zCM8xA6MlnsKPUqampUdezo4JCqiorAKDqVMWo4BC28cb1a+qlY2cX1+vXrmr2rak+LXVy1pWZZxKeYQZCTwr0KpVKbCQGHaXjwKDgndu2pmbIaqpPz4zX/msxDMMIBAJul83DMIy5ufmKwlWgu1itJ4kueIZhpSeCf6296CCVgo7S8et9+wqEQrauaWZmxjba2tk31Nd5Prt50FBfZ2fvwHXRzKO1WK0/CQfPMAOh26fo1ocP5Z8UJM2drycmKGR0ibwoQO17TdLc+fKClW1tbQDQ1tpaXJj//rzk7h6aZ5JeORYa+M5g9pzJXsnMmZfs6+cPukvHIWNC18iL9h85xnUPDAq+e7d5VmyMRPKaUqmIT3jPLyCwu2PlmaRXjoUGqkWjgmrRBgcJRg6tLkQFrS40OLR8i+beBZqg+bVdw8lMMxg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg59N+FODPTfxcaCiQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOVpWF+oBzd009JlpdaGhQIKRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KR01WLJlBCMxg5JBg5fwMX/GJFpYaDtAAAAABJRU5ErkJggg==';
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();
@@ -382,7 +478,14 @@ 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 caption('Delete all local data clears the ledger in this browser; there is no server copy of it to keep', 2600);
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 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
@@ -503,7 +606,8 @@ if (expectedFailClosed.length !== 2 || errors.length !== expectedFailClosed.leng
// Machine-readable receipt of exactly what was rendered on camera, so the release gate can prove
// the recorded delta statement reached the screen instead of trusting the script source.
if (captionsOut) {
await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown }, null, 2) + '\n', 'utf8');
await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown,
captionsVerified: captionEvidence }, null, 2) + '\n', 'utf8');
}
const recording = page.video();
await page.close();
+26
View File
@@ -0,0 +1,26 @@
// 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}.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}.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)}
.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
@@ -0,0 +1,123 @@
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
@@ -0,0 +1,71 @@
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);
});
+45 -2
View File
@@ -168,9 +168,13 @@ test('release demo states the declared release delta on camera and proves it wit
assert.match(demo, /TIMMY_RELEASE_DEMO_DELTA/);
assert.match(demo, /This release: \$\{releaseDelta\}/);
assert.match(demo, /no new feature is claimed/);
// A caption alone is not evidence; the demo must emit a machine-readable receipt of what rendered.
// A caption string alone is not evidence; each caption must be measured as a painted bar, and the
// receipt must carry that per-caption measurement.
assert.match(demo, /TIMMY_DEMO_CAPTIONS_OUT/);
assert.match(demo, /captionsShown\.push\(text\)/);
assert.match(demo, /captionsVerified: captionEvidence/);
assert.match(demo, /Caption is not an on-screen bar/);
assert.match(demo, /Caption painted nothing into the caption band/);
assert.match(demo, /writeFile\(captionsOut/);
});
@@ -181,13 +185,38 @@ 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 in this browser; there is no server copy of it to keep/);
assert.match(demo, /Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server/);
// 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 test-card PNG constant, never a real photo. A blank 1x1 pixel
// rendered as an empty white box on camera, which read as a broken image instead of a saved photo.
assert.match(demo, /const SYNTHETIC_PHOTO = 'data:image\/png;base64,/);
assert.match(demo, /synthetic test card/);
});
test('release builder refuses to publish without the on-camera delta receipt', async () => {
const builder = await readFile(builderPath, 'utf8');
@@ -196,10 +225,24 @@ test('release builder refuses to publish without the on-camera delta receipt', a
assert.match(builder, /Release demo produced no on-camera caption receipt/);
assert.match(builder, /Release demo did not state the declared release delta on camera/);
assert.match(builder, /Release demo is missing a required on-camera caption/);
assert.match(builder, /Release demo caption never painted on screen/);
assert.match(builder, /recorded\.get\("captionsVerified"\)/);
assert.match(builder, /"release_delta_visible_on_camera": "passed"/);
assert.match(builder, /"release_delta": release_delta/);
});
test('caption styling survives the demo reloads instead of dying with the first document', async () => {
const demo = await readFile(demoPath, 'utf8');
// A stylesheet belongs to one document; the demo reloads the page, which stripped caption and touch
// styling and left later captions as clipped body text. Both now carry inline styles per node.
assert.doesNotMatch(demo, /page\.addStyleTag/);
assert.doesNotMatch(demo, /addInitScript/);
assert.match(demo, /In a stylesheet belongs to one document|a stylesheet belongs to one document/);
assert.match(demo, /node\.style\.cssText = 'position:fixed;left:16px;right:16px;top:14px;z-index:20000;'/);
assert.match(demo, /ring\.style\.cssText = 'position:fixed;z-index:20001/);
});
test('release demo captions expire and the contact sheet spans the whole recording', async () => {
const demo = await readFile(demoPath, 'utf8');
const builder = await readFile(builderPath, 'utf8');