Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eeb098245 | ||
|
|
ae9fe0173c | ||
|
|
47294a98aa | ||
|
|
877b8a01f3 | ||
|
|
067afaed4c |
No files matched your search
@@ -49,8 +49,20 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
npm run test:ui
|
||||
npm run test:a11y
|
||||
npm run test:photo
|
||||
npm run test:sleek
|
||||
- name: Upload accessibility screenshots
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: a11y-screenshots
|
||||
path: |
|
||||
artifacts/a11y-journal-mobile.png
|
||||
artifacts/a11y-privacy-mobile.png
|
||||
artifacts/a11y-redflag-alert.png
|
||||
artifacts/a11y-zoom-home.png
|
||||
if-no-files-found: ignore
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high
|
||||
- name: Syntax checks
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
||||
import { mergeVisualSuggestion } from './src/analysis.js';
|
||||
|
||||
const runtimeConfig = window.__TIMMY_CONFIG__ || { basePath: '/', stagingLabel: '' };
|
||||
const runtimeConfig = {
|
||||
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
|
||||
stagingLabel: document.querySelector('meta[name="timmy-staging-label"]')?.content || '',
|
||||
};
|
||||
const BASE_PATH = runtimeConfig.basePath || '/';
|
||||
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
|
||||
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
|
||||
@@ -42,7 +45,7 @@ function shell(content) {
|
||||
app.innerHTML = `<header class="topbar"><div class="brand"><img src="${appPath('assets/timmy.svg')}" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${label}${nav()}`;
|
||||
bindGlobal();
|
||||
}
|
||||
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
|
||||
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home" aria-label="Today navigation item" ${view==='home'?'aria-current="page"':''}><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar" aria-label="Journal navigation item" ${view==='calendar'||view==='privacy'?'aria-current="page"':''}><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy" aria-label="Timmy navigation item" ${view==='timmy'?'aria-current="page"':''}><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
|
||||
function bindGlobal(){
|
||||
document.querySelectorAll('[data-view]').forEach(btn=>btn.onclick=()=>{view=btn.dataset.view;render()});
|
||||
document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger);
|
||||
@@ -154,13 +157,14 @@ async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=aw
|
||||
function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)}
|
||||
function showLogStep(step){
|
||||
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="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i style="width:${step*33.34}%"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
|
||||
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
|
||||
wrap.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();wrap.remove()}});
|
||||
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
|
||||
}
|
||||
function stepBody(step){
|
||||
if(step===1)return `${aiSuggestion?.status==='suggestion'?`<div class="ai-prefill"><span class="ai-badge">AI PREFILLED</span><strong>Type ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}</strong><small>You’re in charge—tap any type to correct it.</small></div>`:'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
|
||||
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${photoDataUrl?`<img class="photo-preview" src="${photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${aiSuggestion?.status==='suggestion'?'<p class="fine">AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.</p>':'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
|
||||
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
|
||||
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box" role="alert" aria-live="assertive">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
|
||||
}
|
||||
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
|
||||
function bindStep(step){
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.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",
|
||||
"test:ui": "node tests/ui.acceptance.mjs",
|
||||
"test:a11y": "node tests/a11y.acceptance.mjs && node tests/a11y-urgent.acceptance.mjs && node tests/a11y-contrast.acceptance.mjs",
|
||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
||||
"test:staging-smoke": "node tests/staging.acceptance.mjs",
|
||||
|
||||
+3
-2
@@ -31,6 +31,7 @@ const appRoot=basePath?`${basePath}/`:'/';
|
||||
const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:'';
|
||||
|
||||
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
|
||||
function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&','<':'<','>':'>','"':'"'}[character]));}
|
||||
function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
|
||||
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
|
||||
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
|
||||
@@ -71,8 +72,8 @@ http.createServer(async(req,res)=>{
|
||||
const info=await stat(path);if(info.isDirectory())path=join(path,'index.html');
|
||||
let body=await readFile(path);
|
||||
if(appPath==='/'||appPath==='/index.html'){
|
||||
const browserConfig=JSON.stringify({basePath:publicBasePath,stagingLabel}).replace(/</g,'\\u003c');
|
||||
body=body.toString('utf8').replace(/(["'])\//g,`$1${appRoot}`).replace('<head>',`<head>\n <base href="${appRoot}">`).replace('<script type="module"',`<script>window.__TIMMY_CONFIG__=${browserConfig}</script>\n <script type="module"`);
|
||||
const configMeta=`<meta name="timmy-base-path" content="${escapeHtmlAttribute(publicBasePath)}">\n <meta name="timmy-staging-label" content="${escapeHtmlAttribute(stagingLabel)}">`;
|
||||
body=body.toString('utf8').replace(/(["'])\//g,`$1${appRoot}`).replace('<head>',`<head>\n <base href="${appRoot}">\n ${configMeta}`);
|
||||
}
|
||||
if(appPath==='/manifest.webmanifest'){
|
||||
const manifest=JSON.parse(body.toString('utf8'));manifest.start_url=appRoot;manifest.scope=appRoot;manifest.icons=manifest.icons.map(icon=>({...icon,src:`${appRoot}${icon.src.replace(/^\//,'')}`}));body=JSON.stringify(manifest);
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
:root{
|
||||
--ink:#211d1a;--muted:#756e68;--paper:#f6f2ea;--surface:#fffdfa;--soft:#eee9df;
|
||||
--ink:#211d1a;--muted:#6a635c;--paper:#f6f2ea;--surface:#fffdfa;--soft:#eee9df;
|
||||
--teal:#0e7771;--teal-soft:#dcefeb;--gold:#efc75e;--brown:#75452f;--red:#ae3a33;
|
||||
--red-soft:#fff0ed;--line:rgba(52,39,31,.1);--shadow:0 18px 45px rgba(58,43,32,.1);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none
|
||||
@@ -16,7 +16,7 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
|
||||
.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}
|
||||
.chat-page{display:flex;flex-direction:column;min-height:calc(100vh - 175px)}.chat-title{padding-bottom:8px}.agent-status{display:flex;align-items:center;gap:11px;padding:11px 13px;background:rgba(255,253,250,.7);border:1px solid var(--line);border-radius:17px;margin-bottom:12px}.agent-status>i{width:10px;height:10px;border-radius:50%;background:#a9a39d;box-shadow:0 0 0 5px rgba(169,163,157,.13)}.agent-status.connected>i{background:#209479;box-shadow:0 0 0 5px rgba(32,148,121,.13)}.agent-status.locked>i{background:#d19b27}.agent-status strong,.agent-status small{display:block}.agent-status strong{font-size:13px}.agent-status small{font-size:11px;color:var(--muted);margin-top:2px}.conversation{background:var(--surface);border:1px solid var(--line);border-radius:23px;padding:13px;box-shadow:0 10px 30px rgba(55,40,31,.05)}.chat{display:flex;flex-direction:column;gap:9px;min-height:195px;max-height:42vh;overflow:auto;padding:4px 1px 14px}.bubble{max-width:86%;padding:11px 13px;border-radius:17px;line-height:1.42;font-size:14px;white-space:pre-wrap}.bubble.timmy{align-self:flex-start;background:#efebe4;border-bottom-left-radius:5px}.bubble.user{align-self:flex-end;background:var(--teal);color:white;border-bottom-right-radius:5px}.thinking{display:flex;gap:4px}.thinking span{width:6px;height:6px;border-radius:50%;background:#8b847d;animation:blink 1s infinite}.thinking span:nth-child(2){animation-delay:.15s}.thinking span:nth-child(3){animation-delay:.3s}@keyframes blink{50%{opacity:.25;transform:translateY(-2px)}}.composer{display:grid;grid-template-columns:1fr 45px;gap:8px;align-items:end;background:#f0ece5;border-radius:18px;padding:6px}.composer textarea{border:0;background:transparent;resize:none;min-height:42px;max-height:110px;padding:10px 9px;outline:0;color:var(--ink)}.composer button{width:44px;height:44px;border:0;border-radius:14px;background:var(--ink);color:white;font-size:22px;cursor:pointer}.composer button:disabled{opacity:.4}.composer-note{font-size:10px;color:var(--muted);margin:7px 5px 0;line-height:1.35}.chat-error{font-size:12px;color:var(--red);margin:0 4px 8px}.safety-line{margin:13px 4px 0;color:var(--muted);font-size:11px;line-height:1.45}.safety-line strong{color:var(--ink)}.unlock-card{background:#fff8e6;border:1px solid #ead9a9;border-radius:20px;padding:14px;margin-bottom:12px}.unlock-card>label{display:block;font-size:12px;font-weight:800;margin-bottom:7px}.unlock-row{display:grid;grid-template-columns:1fr auto;gap:8px}.unlock-card .fine{margin:8px 2px 0}
|
||||
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}
|
||||
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}.progress-step-1{width:33.34%}.progress-step-2{width:66.68%}.progress-step-3{width:100%}
|
||||
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:22px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
|
||||
.privacy-list{display:flex;flex-direction:column;gap:4px}.privacy-item{display:grid;grid-template-columns:40px 1fr;gap:11px;padding:11px 0;border-bottom:1px solid var(--line)}.privacy-item:last-child{border-bottom:0}.privacy-item>b{width:38px;height:38px;border-radius:13px;background:var(--teal-soft);display:grid;place-items:center;color:var(--teal)}.privacy-item h3{margin-bottom:4px}.privacy-item p,.source-list p{font-size:13px;line-height:1.45;color:var(--muted);margin-bottom:5px}.source-list a{color:var(--teal)}.danger-zone{border-color:#e9c4bf}.toast{position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:var(--ink);color:white;border-radius:999px;padding:11px 16px;font-size:13px;font-weight:700;z-index:100;box-shadow:var(--shadow)}
|
||||
.staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
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));
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// WCAG 2.x contrast math computed from the CSS custom properties themselves.
|
||||
const report = await page.evaluate(() => {
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const channels = hex => [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||
.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
const luminance = hex => { const [r, g, b] = channels(hex); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
||||
const ratio = (fg, bg) => {
|
||||
const [a, b] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
|
||||
return (a + 0.05) / (b + 0.05);
|
||||
};
|
||||
const pick = name => root.getPropertyValue(name).trim().replace('#', '');
|
||||
const muted = pick('--muted');
|
||||
const paper = pick('--paper');
|
||||
const surface = pick('--surface');
|
||||
const soft = pick('--soft');
|
||||
const teal = pick('--teal');
|
||||
const tealSoft = pick('--teal-soft');
|
||||
return {
|
||||
mutedOnPaper: ratio(muted, paper),
|
||||
mutedOnSurface: ratio(muted, surface),
|
||||
mutedOnSoft: ratio(muted, soft),
|
||||
tealOnTealSoft: ratio(teal, tealSoft),
|
||||
};
|
||||
});
|
||||
|
||||
// Body-size text must reach 4.5:1 on every background it sits on.
|
||||
for (const [pair, value] of Object.entries(report)) {
|
||||
assert.ok(value >= 4.5, `${pair} contrast ${value.toFixed(2)} meets WCAG AA for body text`);
|
||||
}
|
||||
|
||||
// Text-zoom resilience: 200% zoom keeps the primary CTA usable and unclipped.
|
||||
await page.locator('[data-log]').first().click();
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const zoomed = await page.evaluate(() => {
|
||||
document.documentElement.style.fontSize = '200%';
|
||||
const cta = document.querySelector('.capture-cta');
|
||||
const box = cta.getBoundingClientRect();
|
||||
const nav = document.querySelector('.bottom-nav').getBoundingClientRect();
|
||||
return { ctaHeight: box.height, navVisible: nav.bottom <= window.innerHeight && nav.height > 40 };
|
||||
});
|
||||
assert.ok(zoomed.ctaHeight >= 44, `capture CTA stays at least 44px tall at 200% zoom (${zoomed.ctaHeight}px)`);
|
||||
assert.equal(zoomed.navVisible, true, 'bottom navigation stays visible at 200% text zoom');
|
||||
|
||||
await page.screenshot({ path: 'artifacts/a11y-zoom-home.png', fullPage: false });
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close();
|
||||
console.log('PASS contrast tokens meet WCAG AA and 200% text zoom keeps green paths usable');
|
||||
@@ -0,0 +1,65 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
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));
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// Open the manual logger, walk to the safety-check step.
|
||||
await page.locator('[data-log]').first().click();
|
||||
await page.locator('[data-type="6"]').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#next').click();
|
||||
|
||||
// The urgent alert container must be an assertive live region so a screen
|
||||
// reader announces it the moment a red-flag symptom is checked.
|
||||
assert.equal(
|
||||
await page.locator('#urgent-box[role="alert"]').count(), 1,
|
||||
'#urgent-box is an assertive role="alert" live region',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#urgent-box[aria-live="assertive"]').count(), 1,
|
||||
'#urgent-box carries aria-live="alert"',
|
||||
);
|
||||
|
||||
// Check blood → the alert appears and is announced.
|
||||
await page.locator('[data-symptom="blood"]').check();
|
||||
const alertBox = page.locator('#urgent-box');
|
||||
assert.equal(await alertBox.getAttribute('role'), 'alert');
|
||||
assert.match(await alertBox.innerText(), /Pause and get medical help/i);
|
||||
const liveValue = await alertBox.getAttribute('aria-live');
|
||||
assert.equal(liveValue, 'assertive');
|
||||
|
||||
// Dialog semantics: sheet traps description and close button has a name.
|
||||
const sheet = page.locator('.sheet').last();
|
||||
assert.equal(await sheet.getAttribute('role'), 'dialog');
|
||||
assert.equal(await sheet.getAttribute('aria-modal'), 'true');
|
||||
assert.ok(await sheet.getAttribute('aria-labelledby'), 'sheet names itself via aria-labelledby');
|
||||
assert.equal(await page.locator('.icon-btn[aria-label="Close"]').last().isVisible(), true);
|
||||
|
||||
// Escape closes the dialog (keyboard path out of the modal).
|
||||
await page.locator('.sheet').last().click(); // ensure focus is inside the dialog
|
||||
await page.locator('.icon-btn[aria-label="Close"]').last().focus();
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.locator('.sheet-backdrop').count(), 0, 'Escape dismisses the log sheet');
|
||||
|
||||
// Re-open and capture the red-flag alert visual for review.
|
||||
await page.locator('[data-log]').first().click();
|
||||
await page.locator('[data-type="6"]').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('[data-symptom="blood"]').check();
|
||||
await page.waitForTimeout(150);
|
||||
await page.screenshot({ path: 'artifacts/a11y-redflag-alert.png', fullPage: true });
|
||||
assert.equal(await page.locator('#urgent-box .alert').isVisible(), true);
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close();
|
||||
console.log('PASS urgent-alert screen-reader announcement + dialog keyboard semantics');
|
||||
@@ -0,0 +1,71 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
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));
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// --- Landmarks and semantics ---
|
||||
assert.equal(await page.locator('nav[aria-label="Primary"]').count(), 1, 'primary nav landmark');
|
||||
assert.equal(await page.locator('main').count(), 1, 'exactly one main landmark');
|
||||
const navButtons = page.locator('.bottom-nav .nav-btn');
|
||||
for (let i = 0; i < await navButtons.count(); i++) {
|
||||
const text = (await navButtons.nth(i).locator('span').innerText()).trim();
|
||||
assert.equal(await navButtons.nth(i).getAttribute('aria-label'), `${text} navigation item`, `nav button ${text} exposes its accessible name`);
|
||||
}
|
||||
// Active view is marked for screen readers.
|
||||
await navButtons.first().click(); // Today already active; click Journal
|
||||
await page.locator('[data-view="calendar"]').click();
|
||||
const activeBtn = page.locator('.bottom-nav .nav-btn.active');
|
||||
assert.equal(await activeBtn.getAttribute('aria-current'), 'page', 'active nav button has aria-current="page"');
|
||||
await page.screenshot({ path: 'artifacts/a11y-journal-mobile.png', fullPage: false });
|
||||
|
||||
// --- Focus visibility: keyboard through the journal surface lands on a visible ring ---
|
||||
await page.keyboard.press('Tab'); // first stop should be inside the document with a visible focus style
|
||||
const focused = page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
const style = getComputedStyle(el);
|
||||
return { tag: el.tagName, outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, outlineColor: style.outlineColor };
|
||||
});
|
||||
assert.notEqual((await focused).outlineStyle, 'none', 'focused element shows an outline');
|
||||
assert.notEqual((await focused).outlineWidth, '0px', 'focused element outline is visible');
|
||||
assert.notEqual((await focused).outlineColor, 'transparent', 'focused element outline is not transparent');
|
||||
|
||||
// --- Privacy path: import control is keyboard reachable and labeled ---
|
||||
await page.locator('.settings-row').click();
|
||||
const importControl = page.locator('#import');
|
||||
const importLabel = page.locator('label[for="import"]');
|
||||
assert.equal(await importLabel.count(), 1, 'Import JSON control has a programmatic label');
|
||||
const exportBtn = page.locator('#export');
|
||||
const exportBox = await exportBtn.boundingBox();
|
||||
assert.ok(exportBox.height >= 44 && exportBox.width >= 88, `export button meets touch target size (${exportBox.width}x${exportBox.height})`);
|
||||
const deleteBox = await page.locator('#delete-all').boundingBox();
|
||||
assert.ok(deleteBox.height >= 44, 'delete-all button meets 44px touch target height');
|
||||
await page.screenshot({ path: 'artifacts/a11y-privacy-mobile.png', fullPage: false });
|
||||
|
||||
// --- Reduced motion removes animation ---
|
||||
const reduced = await context.browser().newContext({
|
||||
viewport: { width: 390, height: 844 }, serviceWorkers: 'block',
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
const rpage = await reduced.newPage();
|
||||
await rpage.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
const animDurationMs = await rpage.evaluate(() => {
|
||||
const value = getComputedStyle(document.querySelector('.nav-btn') || document.body).animationDuration;
|
||||
const parsed = parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed * 1000 : 0;
|
||||
});
|
||||
assert.ok(animDurationMs < 5, `animations collapse under prefers-reduced-motion (got ${animDurationMs}ms)`);
|
||||
await reduced.close();
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close();
|
||||
console.log('PASS accessibility gates: landmarks, labels, focus ring, touch targets, reduced motion, synthetic screenshots');
|
||||
@@ -21,6 +21,8 @@ test('Gitea CI gates pull requests and main with the reproducible quality suite'
|
||||
assert.match(workflow, /npm run test:ui/);
|
||||
assert.match(workflow, /npm run test:photo/);
|
||||
assert.match(workflow, /npm run test:sleek/);
|
||||
assert.match(workflow, /uses: actions\/upload-artifact@v3/);
|
||||
assert.doesNotMatch(workflow, /uses: actions\/upload-artifact@v4/);
|
||||
assert.match(workflow, /npm audit --audit-level=high/);
|
||||
assert.match(workflow, /npm run check:syntax/);
|
||||
assert.match(workflow, /npm run check:diff/);
|
||||
|
||||
@@ -24,7 +24,7 @@ await page.route('**/api/agent/chat', async route => {
|
||||
|
||||
await page.goto(appUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
assert.equal(await page.evaluate(() => window.__TIMMY_CONFIG__.basePath), expectedBasePath);
|
||||
assert.equal(await page.locator('meta[name="timmy-base-path"]').getAttribute('content'), expectedBasePath);
|
||||
assert.equal(await page.locator('.bottom-nav .nav-btn').count(), 3, 'primary navigation must have exactly three destinations');
|
||||
assert.equal(await page.locator('main .btn-primary:visible').count(), 1, 'home must expose one dominant primary action');
|
||||
assert.equal(await page.locator('[data-log]:visible').count(), 1, 'manual fallback remains available once without competing styling');
|
||||
@@ -105,7 +105,7 @@ if (expectedBasePath === '/') {
|
||||
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
|
||||
|
||||
await migrationPage.evaluate(() => localStorage.setItem('timmy-ledger-v1', '[{"id":"resurrection-risk"}]'));
|
||||
await migrationPage.getByRole('button', { name: 'Journal', exact: true }).click();
|
||||
await migrationPage.getByRole('button', { name: 'Journal navigation item', exact: true }).click();
|
||||
await migrationPage.locator('[data-view="privacy"]').click();
|
||||
migrationPage.once('dialog', dialog => dialog.accept());
|
||||
await migrationPage.locator('#delete-all').click();
|
||||
|
||||
@@ -91,9 +91,9 @@ test('prefixed document, manifest, and service worker stay inside the app scope'
|
||||
|
||||
const html = await (await fetch(`${origin}/timmy-staging/`)).text();
|
||||
assert.match(html, /<base href="\/timmy-staging\/">/);
|
||||
assert.match(html, /window\.__TIMMY_CONFIG__=/);
|
||||
assert.match(html, /"basePath":"\/timmy-staging"/);
|
||||
assert.match(html, /"stagingLabel":"Staging · daily-2026-08-20\.3 · ca31e6d38bec"/);
|
||||
assert.doesNotMatch(html, /window\.__TIMMY_CONFIG__\s*=/);
|
||||
assert.match(html, /<meta name="timmy-base-path" content="\/timmy-staging">/);
|
||||
assert.match(html, /<meta name="timmy-staging-label" content="Staging · daily-2026-08-20\.3 · ca31e6d38bec">/);
|
||||
|
||||
const manifest = await (await fetch(`${origin}/timmy-staging/manifest.webmanifest`)).json();
|
||||
assert.equal(manifest.start_url, '/timmy-staging/');
|
||||
@@ -109,7 +109,7 @@ test('agent cookie is constrained to the normalized base path', async t => {
|
||||
const { origin } = await startServer(t, {
|
||||
TIMMY_BASE_PATH: '/timmy-staging/',
|
||||
TIMMY_AGENT_ENABLED: 'true',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: 'integration-access-code-2026',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: 'test-integration-access-code-2026',
|
||||
TIMMY_PUBLIC_ORIGIN: '__ORIGIN__',
|
||||
TIMMY_AGENT_WORKDIR: workdir,
|
||||
TIMMY_HERMES_COMMAND: hermesFixture,
|
||||
@@ -119,7 +119,7 @@ test('agent cookie is constrained to the normalized base path', async t => {
|
||||
const response = await fetch(`${origin}/timmy-staging/api/agent/unlock`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' },
|
||||
body: JSON.stringify({ accessCode: 'integration-access-code-2026' }),
|
||||
body: JSON.stringify({ accessCode: 'test-integration-access-code-2026' }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get('set-cookie'), /; Path=\/timmy-staging\//);
|
||||
|
||||
Reference in new issue
Block a user