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
This commit is contained in:
timmy committed 2026-10-19 08:24:44 +00:00
1 parent fe5c3d40c0
commit ebee52fd8a
11 files changed
+366 -10

No files matched your search

+7 -2
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)
@@ -189,7 +193,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,6 +233,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"
"- 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"
+66 -1
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;
@@ -373,6 +375,62 @@ 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();
@@ -382,7 +440,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