Merge pull request 'feat: separate saved-photo lifecycle from the confirmed log (#36)' (#80) from timmy/36-photo-lifecycle-controls into main
This commit is contained in:
commit
0e4dde5f81
11 files changed
+366
-10
No files matched your search
@@ -58,6 +58,7 @@ jobs:
|
|||||||
npm run test:vision-refusal
|
npm run test:vision-refusal
|
||||||
npm run test:worker-readiness
|
npm run test:worker-readiness
|
||||||
npm run test:pattern-summary
|
npm run test:pattern-summary
|
||||||
|
npm run test:photo-lifecycle
|
||||||
- name: Dependency audit
|
- name: Dependency audit
|
||||||
run: npm audit --audit-level=high
|
run: npm audit --audit-level=high
|
||||||
- name: Syntax checks
|
- name: Syntax checks
|
||||||
|
|||||||
@@ -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: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: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: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
|
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
|
- 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)
|
- 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
|
- 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
|
- 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
|
- 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
|
- Installable/offline PWA shell for the manual and saved-ledger paths
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp,
|
|||||||
import { visionReadiness } from './src/vision-readiness.js';
|
import { visionReadiness } from './src/vision-readiness.js';
|
||||||
import { buildPatternSummary } from './src/summary.js';
|
import { buildPatternSummary } from './src/summary.js';
|
||||||
import { answerFromLedger, buildChatLedger, resolveAgentReply } from './src/grounded-chat.js';
|
import { answerFromLedger, buildChatLedger, resolveAgentReply } from './src/grounded-chat.js';
|
||||||
|
import { photoState, withoutEntry, withoutPhoto } from './src/photo-lifecycle.js';
|
||||||
|
|
||||||
const runtimeConfig = {
|
const runtimeConfig = {
|
||||||
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
|
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-log]').forEach(btn=>btn.onclick=openLogger);
|
||||||
document.querySelectorAll('[data-scan]').forEach(btn=>btn.onclick=openPhotoFirst);
|
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-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){
|
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>`;
|
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 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}
|
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(){
|
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;
|
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');}
|
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)}}
|
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()}
|
function openPhotoFirst(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
||||||
async function loadVisionStatus(){
|
async function loadVisionStatus(){
|
||||||
|
|||||||
+3
-2
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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:ui": "node tests/ui.acceptance.mjs",
|
||||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||||
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
|
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"test:staging-smoke": "node tests/staging.acceptance.mjs",
|
"test:staging-smoke": "node tests/staging.acceptance.mjs",
|
||||||
"test:pattern-summary": "node tests/pattern-summary.acceptance.mjs",
|
"test:pattern-summary": "node tests/pattern-summary.acceptance.mjs",
|
||||||
"test:grounded-chat": "node tests/grounded-chat.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",
|
"check:diff": "bash scripts/check_diff.sh",
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
|
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
|
||||||
|
|||||||
@@ -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",
|
"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",
|
"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",
|
"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",
|
"Hermes Agent connected — free-text, contextual, and server-side",
|
||||||
"Urgent language is intercepted deterministically before Hermes",
|
"Urgent language is intercepted deterministically before Hermes",
|
||||||
"Bleeding reported as wiping still escalates, in the app’s own fixed words",
|
"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:worker-readiness"], tree)
|
||||||
run(["npm", "run", "test:pattern-summary"], tree)
|
run(["npm", "run", "test:pattern-summary"], tree)
|
||||||
run(["npm", "run", "test:grounded-chat"], 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"
|
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||||
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
|
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
|
||||||
demo_env = dict(server_env)
|
demo_env = dict(server_env)
|
||||||
@@ -189,7 +193,7 @@ def main() -> int:
|
|||||||
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
|
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
|
||||||
raise SystemExit("Release demo contact sheet was not produced")
|
raise SystemExit("Release demo contact sheet was not produced")
|
||||||
run(["npm", "audit", "--audit-level=high"], tree)
|
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(["node", "--check", file], tree)
|
||||||
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
|
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
|
||||||
run(["bash", "-n", "scripts/run_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"
|
"- 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"
|
"- 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"
|
"- 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"
|
"- 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"
|
"- 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"
|
"- 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 page = await context.newPage();
|
||||||
const errors = [];
|
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('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||||
page.on('pageerror', error => errors.push(error.message));
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
let visionOffline = false;
|
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.evaluate(() => localStorage.clear());
|
||||||
await page.reload({ waitUntil: 'networkidle' });
|
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 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 tap('[data-view="calendar"]', 550);
|
||||||
await page.getByText(/Your private journal/i).waitFor();
|
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 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 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 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);
|
await tap('[data-view="home"]', 500);
|
||||||
// Issue #37 — a chat answer about the journal must point at the confirmed records behind it. The
|
// 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
|
// scenes below run against the app's own local ledger contract with fictional logs, and every claim
|
||||||
|
|||||||
@@ -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
@@ -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}
|
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}
|
.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)}
|
.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)}
|
.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)}
|
.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}
|
.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}
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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, /Every byte-bearing control the model names/);
|
||||||
assert.match(demo, /indicate\('#export'\)/);
|
assert.match(demo, /indicate\('#export'\)/);
|
||||||
assert.match(demo, /indicate\('#delete-all'\)/);
|
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.
|
// 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');
|
const app = await readFile(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
assert.match(app, /id="export"/);
|
assert.match(app, /id="export"/);
|
||||||
assert.match(app, /id="delete-all"/);
|
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 () => {
|
test('release builder refuses to publish without the on-camera delta receipt', async () => {
|
||||||
const builder = await readFile(builderPath, 'utf8');
|
const builder = await readFile(builderPath, 'utf8');
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user