diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml
index eafdd8a..cda55fe 100644
--- a/.gitea/workflows/quality.yml
+++ b/.gitea/workflows/quality.yml
@@ -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
diff --git a/README.md b/README.md
index 115b2d5..c3cdb96 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app.js b/app.js
index e0b6fe2..3ab1abd 100644
--- a/app.js
+++ b/app.js
@@ -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 `
${bucketForBristolType(e.bristolType)}`;
+ }).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=`
Confirmed log
${formatDate(entry.occurredAt)}
FormType ${entry.bristolType}
Color${esc(entry.color)}
Urgency${entry.urgency}/4
Discomfort${entry.discomfort}/4
${entry.note?`
${esc(entry.note)}
`:''}${urgent.urgent?alertHtml(urgent.message):''}${photo.hasPhoto?`Photo saved with this log
The image stays in this browser. The journal never shows it as a thumbnail until you ask.
`:`No photo saved
This confirmed log is text only. Its record does not depend on an image.
`}
${armed?`
This removes the confirmed log and its saved photo from this browser. It cannot be undone.
This prototype has no account, analytics, ad tracker, or server database.
⌂
Stored locally
Saved entries and optional photos live in this browser’s local storage.
⇩
Portable
Export a readable JSON file. Import it in another copy of Timmy.
◎
AI only when you ask
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.
Data controls
Exports can contain sensitive health notes and photos. Store them somewhere you trust.
Health sources
The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.
Permanently removes Timmy’s local ledger from this browser.
`);
+ shell(`
Private by design
Your poop. Your phone.
This prototype has no account, analytics, ad tracker, or server database.
⌂
Stored locally
Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.
⇩
Portable
Export a readable JSON file. Import it in another copy of Timmy.
◎
AI only when you ask
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.
Data controls
Exports can contain sensitive health notes and photos. Store them somewhere you trust.
Health sources
The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.
Permanently removes Timmy’s local ledger and every saved photo from this browser.
`);
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(){
diff --git a/package.json b/package.json
index 752d95c..8c299df 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/scripts/build_release.py b/scripts/build_release.py
index 8b68e99..2d9dfc8 100755
--- a/scripts/build_release.py
+++ b/scripts/build_release.py
@@ -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"
diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs
index 7e12598..4fc778a 100644
--- a/scripts/record_release_demo.mjs
+++ b/scripts/record_release_demo.mjs
@@ -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
diff --git a/src/photo-lifecycle.js b/src/photo-lifecycle.js
new file mode 100644
index 0000000..68a5a37
--- /dev/null
+++ b/src/photo-lifecycle.js
@@ -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;
+}
diff --git a/styles.css b/styles.css
index 70ccee9..a8fc520 100644
--- a/styles.css
+++ b/styles.css
@@ -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}
diff --git a/tests/photo-lifecycle.acceptance.mjs b/tests/photo-lifecycle.acceptance.mjs
new file mode 100644
index 0000000..1058ce6
--- /dev/null
+++ b/tests/photo-lifecycle.acceptance.mjs
@@ -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');
diff --git a/tests/photo-lifecycle.test.js b/tests/photo-lifecycle.test.js
new file mode 100644
index 0000000..85a2e6e
--- /dev/null
+++ b/tests/photo-lifecycle.test.js
@@ -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);
+});
diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js
index 2565e5a..9bf5cae 100644
--- a/tests/release-demo.test.js
+++ b/tests/release-demo.test.js
@@ -181,13 +181,36 @@ 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 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');