feat: separate saved-photo lifecycle from the confirmed log (#36)
Quality gates / quality (pull_request) Successful in 3m9s
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:
1 parent
fe5c3d40c0
commit
ebee52fd8a
11 files changed
+366
-10
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user