create-pmos 0.252.0 → 0.254.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/workflow-state.js +6 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/payload/state/log.md +1 -1
  8. package/workspace/briefing-premium-board-test.js +275 -0
  9. package/workspace/briefing-premium-initiative-test.js +304 -0
  10. package/workspace/briefing-premium-products-test.js +164 -0
  11. package/workspace/briefing-premium-test.js +438 -0
  12. package/workspace/briefing-premium-work-test.js +281 -0
  13. package/workspace/fixtures/board-premium-facts.js +38 -0
  14. package/workspace/fixtures/briefing-premium-facts.js +32 -0
  15. package/workspace/fixtures/initiative-premium-facts.js +42 -0
  16. package/workspace/fixtures/my-work-premium-facts.js +31 -0
  17. package/workspace/guide/briefing-premium.md +151 -0
  18. package/workspace/guide/index.md +1 -0
  19. package/workspace/lib/board-premium.js +133 -0
  20. package/workspace/lib/briefing-premium-agent.js +134 -0
  21. package/workspace/lib/briefing-premium-products.js +121 -0
  22. package/workspace/lib/briefing-premium-writes.js +138 -0
  23. package/workspace/lib/briefing-premium.js +581 -0
  24. package/workspace/lib/engine/workflow-state.js +6 -1
  25. package/workspace/lib/headless.js +1 -1
  26. package/workspace/lib/initiative-premium.js +147 -0
  27. package/workspace/lib/my-work-premium.js +151 -0
  28. package/workspace/lib/route-modules.js +4 -0
  29. package/workspace/lib/routes-board-premium.js +61 -0
  30. package/workspace/lib/routes-briefing-premium.js +115 -0
  31. package/workspace/lib/routes-initiative-premium.js +68 -0
  32. package/workspace/lib/routes-my-work-premium.js +31 -0
  33. package/workspace/lib/settings-store.js +3 -0
  34. package/workspace/lib/vendor/scripts/workflow-state.js +6 -1
  35. package/workspace/package-lock.json +2 -2
  36. package/workspace/package.json +1 -1
  37. package/workspace/public/pm/premium-board.js +180 -0
  38. package/workspace/public/pm/premium-initiative.js +336 -0
  39. package/workspace/public/pm/premium-shell.js +184 -0
  40. package/workspace/public/pm/premium-work.js +198 -0
  41. package/workspace/public/pm/premium.css +1212 -0
  42. package/workspace/public/pm/premium.js +272 -0
  43. package/workspace/public/premium-board.html +73 -0
  44. package/workspace/public/premium-initiative.html +46 -0
  45. package/workspace/public/premium-work.html +84 -0
  46. package/workspace/public/premium.html +69 -0
  47. package/workspace/public/vendor/geist-mono-var.woff2 +0 -0
  48. package/workspace/public/vendor/geist-var.woff2 +0 -0
  49. package/workspace/server.js +5 -3
  50. package/workspace/test-files.json +14 -0
  51. package/workspace/test-lists/briefing-premium.json +19 -0
  52. package/workspace/third-party/README.md +12 -0
  53. package/workspace/third-party/geist-OFL.LICENSE +93 -0
  54. package/workspace/third-party/geist-mono-OFL.LICENSE +93 -0
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* briefing-premium-test.js — the premium Briefing page (initiative briefing-premium; planning/prd/briefing-premium-prd.md R1 to R13).
4
+ * Serves the real page from the real server and answers GET /api/briefing-premium with the model lib/briefing-premium.js builds from a
5
+ * fixture (fixtures/briefing-premium-facts.js), so the page is checked against known data.
6
+ * R1 the rail, the dock, the page opens at /premium.html and the console stays the default page
7
+ * R2 the hero is the model's sentences, with the numbers marked
8
+ * R4 rows: numbered, one "Do this first", an "If you skip" line each, a dialog with Detail first on a phone
9
+ * R5 "N of M done" counts rows that cleared since the page first read today
10
+ * R7 Today: Ahead, a now marker, Earlier today, source chips that filter
11
+ * R8 the dock count equals the list count
12
+ * R9 the header names what could not be read
13
+ * R10 a page where nothing was pressed to record sends only GETs
14
+ * W1-W8 (slice 2) record decisions, put rows off, return runs; the POST answers are mocked here, the refusals are the real server's
15
+ * R11 floors: 44 px targets, focus walk, inert background, reduced motion, no sideways scroll at 390 px in both themes
16
+ * R12 fidelity: computed styles and geometry against the mockup at 1280 and 390 px, light and dark
17
+ * Every wait polls the page; no fixed sleep stands in for a state.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { spawn } = require('child_process');
22
+ const { findBrowser } = require('./lib/find-browser');
23
+ const T = require('./test-run').configure({ name: 'briefing-premium' });
24
+ const B = require('./lib/briefing-premium.js');
25
+ const F = require('./fixtures/briefing-premium-facts.js');
26
+
27
+ const BASE = T.BASE;
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+ const until = async (fn, ms = 10000, step = 100) => { const t0 = Date.now(); for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
30
+ const results = [];
31
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
32
+ let srv = null; let browser = null;
33
+ const finish = async (code) => {
34
+ if (browser) await browser.close().catch(() => {});
35
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
36
+ T.killTmux();
37
+ const bad = results.filter((x) => !x.cond).length;
38
+ console.log(`\n${results.length - bad}/${results.length} briefing-premium assertions passed`);
39
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
40
+ };
41
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
42
+
43
+ const MODEL = B.build(F.facts());
44
+ const MOCKUP = path.join(__dirname, '..', 'prototype', 'premium-ux-concept', 'briefing-artifact.html');
45
+
46
+ (async () => {
47
+ const exe = findBrowser();
48
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the briefing-premium suite cannot run'); return finish(1); }
49
+ let chromium;
50
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the briefing-premium suite cannot run'); return finish(1); }
51
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
52
+ await T.waitForServer(BASE);
53
+ browser = await chromium.launch({ executablePath: exe });
54
+
55
+ const open = async ({ w = 1280, h = 900, theme = 'light', model = MODEL, ctxOpts = {}, pre = null } = {}) => {
56
+ const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...ctxOpts });
57
+ const page = await ctx.newPage();
58
+ const errors = [], reqs = [];
59
+ page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
60
+ page.on('request', (r) => { if (r.url().startsWith(BASE)) reqs.push(r.method() + ' ' + new URL(r.url()).pathname); });
61
+ await page.route('**/api/briefing-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model, as_of: Date.now() }) }));
62
+ if (pre) await page.addInitScript(pre);
63
+ await page.goto(BASE + '/premium.html?theme=' + theme);
64
+ const up = await until(() => page.evaluate(() => document.querySelectorAll('#rank .rr').length > 0 || !document.querySelector('#calm').hidden), 20000);
65
+ return { ctx, page, errors, reqs, up };
66
+ };
67
+
68
+ const A = await open();
69
+ ok('0 the page draws the list', A.up);
70
+ if (!A.up) return finish(1);
71
+ const p = A.page;
72
+
73
+ /* R1 */
74
+ const rail = await p.evaluate(() => ({ names: [...document.querySelectorAll('.rail .pl')].map((b) => b.textContent.trim()), cur: document.querySelector('.rail [aria-current="page"]').textContent.trim(), go: [...document.querySelectorAll('.rail [data-go]')].map((b) => b.dataset.go) }));
75
+ ok('R1 the rail names four places and Briefing is current', rail.names.join() === 'Briefing,My work,Board,Insights' && rail.cur === 'Briefing', JSON.stringify(rail));
76
+ ok('R1 each place has a target: Briefing and My work are premium pages, Board and Insights open the console tab', rail.go.join() === 'brief,work,init,rep');
77
+ const def = await fetch(BASE + '/').then((r) => r.text());
78
+ ok('R1 the console stays the default page', /id="c3-root"/.test(def) && !/class="rail/.test(def));
79
+
80
+ /* R2 */
81
+ const hero = await p.evaluate(() => ({ text: document.querySelector('#brief').textContent, k: [...document.querySelectorAll('#brief .k')].map((x) => x.textContent) }));
82
+ const want = MODEL.hero.map((s) => s.parts.map((x) => x.t).join('')).join(' ');
83
+ ok('R2 the hero reads the model, word for word', hero.text === want, hero.text);
84
+ ok('R2 the numbers and the stuck items are marked', hero.k.some((t) => /three changes today/.test(t)) && hero.k.some((t) => /20 to 10 per 100 runs/.test(t)) && hero.k.length >= 5, JSON.stringify(hero.k));
85
+ ok('R2 the date line is the model\'s', await p.evaluate((d) => document.querySelector('#date').textContent === d, MODEL.date));
86
+
87
+ /* R3, R4 */
88
+ const rows = await p.evaluate(() => [...document.querySelectorAll('#rank .rr')].map((r) => ({ n: r.querySelector('.rn').textContent, t: r.querySelector('.rt').childNodes[0].textContent, skip: (r.querySelector('.skip') || {}).textContent || '', first: !!r.querySelector('.tgf') })));
89
+ ok('R3 the rows follow the model\'s order', rows.map((r) => r.t).join('|') === MODEL.list.rows.map((r) => r.title).join('|'), JSON.stringify(rows.map((r) => r.t)));
90
+ ok('R4 rows are numbered 01, 02, …', rows.map((r) => r.n).join() === '01,02,03,04,05');
91
+ ok('R4 exactly one row says Do this first, and it is the first', rows.filter((r) => r.first).length === 1 && rows[0].first);
92
+ ok('R4 every row says what skipping costs', rows.every((r) => /^If you skip: /.test(r.skip)));
93
+ ok('R3 the order line is shown', await p.evaluate(() => /blocked work first, then decisions only you can make/.test(document.querySelector('#order').textContent)));
94
+
95
+ /* R5 */
96
+ ok('R5 the header counts rows left and rows done', await p.evaluate(() => /^0 of 5 done · 5 rows left$/.test(document.querySelector('#movesCap').textContent.trim())));
97
+ ok('R5 the progress bar says the same', await p.evaluate(() => { const b = document.querySelector('#mprog'); return b.getAttribute('role') === 'progressbar' && b.getAttribute('aria-valuemax') === '5' && b.getAttribute('aria-valuenow') === '0'; }));
98
+ {
99
+ const fewer = B.build({ ...F.facts(), initiatives: F.facts().initiatives.filter((i) => i.id !== 'console-3-boards') });
100
+ const R = await open({ model: fewer, pre: `try{localStorage.setItem('pmos.premium.base.'+(()=>{const d=new Date();return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')})(),JSON.stringify(${JSON.stringify(MODEL.list.rows.map((r) => r.key))}))}catch(e){}` });
101
+ ok('R5 a row that cleared since the first read counts as done', await p.evaluate(() => true) && await R.page.evaluate(() => /^1 of 5 done · 4 rows left$/.test(document.querySelector('#movesCap').textContent.trim())), await R.page.evaluate(() => document.querySelector('#movesCap').textContent));
102
+ await R.ctx.close();
103
+ }
104
+
105
+ /* R8 */
106
+ const dock = await p.evaluate(() => ({ n: document.querySelector('#dN').textContent, lab: document.querySelector('#dLab').textContent, next: document.querySelector('#dNext').textContent }));
107
+ ok('R8 the dock count equals the list count', dock.n === String(rows.length) && /need you/.test(dock.lab), JSON.stringify(dock));
108
+ ok('R8 the dock names the next event, not a portfolio total', /Next 18:30 Design review/.test(dock.next) && !/boards|running/.test(dock.next));
109
+
110
+ /* R7 */
111
+ const tl = await p.evaluate(() => ({ groups: [...document.querySelectorAll('#tl .grp')].map((g) => g.textContent), now: document.querySelector('#tl .nowl').textContent, ahead: document.querySelectorAll('#tl .nowl ~ .early .ev').length, all: document.querySelectorAll('#tl .ev').length, chips: [...document.querySelectorAll('#srcChips .fchip')].map((c) => c.textContent), cap: document.querySelector('#todayCap').textContent }));
112
+ ok('R7 Today has Ahead, a now marker and Earlier today', tl.groups.join() === 'Ahead,Earlier today' && /^18:05 · snapshot$/.test(tl.now), JSON.stringify(tl));
113
+ ok('R7 the caption counts earlier and ahead', tl.cap === `${MODEL.today.earlier.length} earlier · ${MODEL.today.ahead.length} ahead`, tl.cap);
114
+ ok('R7 one chip per source with its count', tl.chips.join() === 'Calendar2,Merges3,Agent runs0', tl.chips.join());
115
+ await p.click('.fbtn');
116
+ await p.click('#srcChips [data-k="merge"]');
117
+ ok('R7 turning a source off removes its rows', await p.evaluate(() => ![...document.querySelectorAll('#tl .ev')].some((e) => e.classList.contains('src-merge'))));
118
+ await p.click('#srcChips [data-k="merge"]');
119
+ await p.click('.fbtn');
120
+
121
+ /* dialog */
122
+ const row0 = p.locator('#rank .rhd').first();
123
+ await row0.focus();
124
+ await row0.press('Enter');
125
+ const dlg = await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on')), 4000);
126
+ ok('R4 Enter on a row opens the standard dialog', dlg);
127
+ const d = await p.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, inert: ['.rail', 'main.stage', '#dock'].every((s) => document.querySelector(s).inert), pane: document.querySelector('#oMain').textContent, card: document.querySelector('#card') ? document.querySelector('#card').textContent : '', log: document.querySelector('#logT') ? document.querySelector('#logT').textContent : '', label: document.querySelector('#oLab').textContent, input: document.querySelector('#askIn').disabled, role: document.querySelector('#dlg').getAttribute('aria-modal') }));
128
+ ok('R11 the page behind the dialog is inert and the dialog is modal', d.inert && d.role === 'true');
129
+ ok('R4 the dialog names the row, with an activity log of what was read', d.title === MODEL.list.rows[0].title && /Readfromtherepository3steps/.test(d.log.replace(/\s+/g, '')), d.log);
130
+ ok('R4 the detail pane repeats neither the dialog title nor its subtitle, and its record is a grid, not a bullet list', await p.evaluate(() => { const t = document.querySelector('#dTitle').textContent, sub = document.querySelector('#dSub').textContent, o = document.querySelector('#oMain'); return !o.querySelector('.d1') && !o.textContent.includes(t) && (!sub || !o.textContent.includes(sub)) && !o.querySelector('ul'); }), JSON.stringify(await p.evaluate(() => ({ t: document.querySelector('#dTitle').textContent, sub: document.querySelector('#dSub').textContent, pane: document.querySelector('#oMain').innerHTML.slice(0, 500) }))));
131
+ ok('R4 the card holds the evidence and the next step, and the right pane the record', /The way through/.test(d.card) && /Blocked at/.test(d.card) && /Initiative record/.test(d.label) && /planning\/initiatives|console-3-boards/.test(d.pane), d.card.slice(0, 120));
132
+ await until(() => p.evaluate(() => getComputedStyle(document.querySelector('#dlg')).transform === 'none' || /matrix\(1, 0, 0, 1, 0, 0\)/.test(getComputedStyle(document.querySelector('#dlg')).transform)), 3000);
133
+ const pane0 = await p.evaluate(() => { const b = document.querySelector('#dPane'); const r = b.getBoundingClientRect(); return { on: b.getAttribute('aria-pressed'), t: b.title, w: r.width, h: r.height, convo: getComputedStyle(document.querySelector('#dBody')).display }; });
134
+ ok('R4 the dialog has a button that hides the conversation, 44 px, shown pressed while it is visible', pane0.on === 'true' && /Hide the conversation/.test(pane0.t) && pane0.w >= 44 && pane0.h >= 44 && pane0.convo !== 'none', JSON.stringify(pane0));
135
+ await p.click('#dPane');
136
+ const pane1 = await p.evaluate(() => ({ mode: document.querySelector('#dlg').dataset.mode, convo: getComputedStyle(document.querySelector('#dBody')).display, detail: getComputedStyle(document.querySelector('#dOut')).display, t: document.querySelector('#dPane').title }));
137
+ ok('R4 pressing it hides the conversation and leaves the detail, and the button says how to bring it back', pane1.mode === 'detail' && pane1.convo === 'none' && pane1.detail !== 'none' && /Show the conversation/.test(pane1.t), JSON.stringify(pane1));
138
+ await p.click('#dPane');
139
+ ok('R4 pressing it again shows the conversation', await p.evaluate(() => document.querySelector('#dlg').dataset.mode === 'split' && getComputedStyle(document.querySelector('#dBody')).display !== 'none'));
140
+ ok('D12 the reply box asks a read-only agent, and says what the agent can and cannot do', !d.input && await p.evaluate(() => !document.querySelector('#askIn').disabled && /cannot change anything/.test(document.querySelector('#askNote').textContent)));
141
+ ok('W8 the start button of a blocked row is disabled and says why, in words and in its title', await p.evaluate(() => { const b = document.querySelector('#card .cta'); const h = document.querySelector('#holdWhy'); return !!b && b.disabled && /live conversation/.test(b.title) && !!h && /live conversation/.test(h.textContent); }));
142
+ await p.keyboard.press('Escape');
143
+ await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
144
+ // a decision row: no option is chosen for the PM, choosing one drafts the record, and "Not yet" is off
145
+ await p.locator('#rank .rhd').nth(3).click();
146
+ await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on')));
147
+ const dc = await p.evaluate(() => ({ ops: document.querySelectorAll('#card .op').length, pressed: document.querySelectorAll('#card .op[aria-pressed="true"]').length, rec: !!document.querySelector('#card .op em'), cta: document.querySelector('#card .cta').textContent, pane: document.querySelector('#oMain').textContent, label: document.querySelector('#oLab').textContent, snooze: [...document.querySelectorAll('#card .seg2 button')].every((b) => !b.disabled && b.hasAttribute('data-until')) }));
148
+ ok('R6 a decision opens with three options and none chosen, and none marked Recommended', dc.ops === 3 && dc.pressed === 0 && !dc.rec && /Your call: choose one/.test(dc.cta), JSON.stringify(dc));
149
+ ok('R6 the right pane is a draft decision record that says nothing is written until Record', /Decision record/.test(dc.label) && /pm-decisions.md · draft entry/.test(dc.pane) && /Nothing is written to the journal until you press Record/.test(dc.pane) && /Choose an option to draft the entry/.test(dc.pane));
150
+ await p.locator('#card .op').nth(1).click();
151
+ const dc2 = await p.evaluate(() => ({ pressed: document.querySelector('#card .op[aria-pressed="true"]').textContent, cta: document.querySelector('#card .cta').textContent, dis: document.querySelector('#card .cta').disabled, pane: document.querySelector('#oMain').textContent }));
152
+ ok('R6 choosing an option updates the button and the draft, and the button stays off', /Extend the date/.test(dc2.pressed) && /Record: extend/.test(dc2.cta) && dc2.dis && /Extend the date/.test(dc2.pane) && /extend the date/.test(dc2.pane), JSON.stringify(dc2).slice(0, 200));
153
+ ok('W3 "Not yet" has three live choices', dc.snooze);
154
+ await p.keyboard.press('Escape');
155
+ await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
156
+ // the failed-reviews row lists every review with its score
157
+ await p.locator('#rank .rhd').nth(4).click();
158
+ await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on')));
159
+ ok('R4 the reviews row lists each review with its age and score, and proposes nothing', await p.evaluate(() => document.querySelectorAll('#card .tri').length === 5 && /Scored|No score recorded/.test(document.querySelector('#card').textContent) && !document.querySelector('#card .cta') && /No proposal is on record/.test(document.querySelector('#dBody').textContent)));
160
+ await row0.focus();
161
+ await p.keyboard.press('Escape');
162
+ ok('R11 Escape closes the dialog and focus returns to the row', await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on') && document.activeElement.classList.contains('rhd'))));
163
+ ok('R11 the page is live again after the dialog', await p.evaluate(() => !document.querySelector('main.stage').inert));
164
+
165
+ /* the Today dialogs: a merge shows its files and message, a meeting its time and people */
166
+ await p.locator('#tl .ev.src-meeting').first().click();
167
+ await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on')));
168
+ const ev = await p.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, card: document.querySelector('#card').textContent, pane: document.querySelector('#oMain').textContent, label: document.querySelector('#oLab').textContent }));
169
+ ok('R7 a meeting opens a dialog with its time, length and people, from the calendar', /Design review/.test(ev.title) && /Time/.test(ev.card) && /18:30 to 19:15/.test(ev.card) && /Lee, Priya/.test(ev.card) && ev.label === 'Meeting', JSON.stringify(ev).slice(0, 200));
170
+ await p.keyboard.press('Escape');
171
+ await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
172
+ await p.locator('#tl .ev.src-merge').first().click();
173
+ await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on')));
174
+ ok('R7 a merge opens a dialog that says it reached main, with a disabled "Return this run to the agent"', await p.evaluate(() => /merged to main/.test(document.querySelector('#oMain').textContent) && /reached main today/.test(document.querySelector('#dBody').textContent) && document.querySelector('#card .cta').disabled && /Return this run to the agent/.test(document.querySelector('#card .cta').textContent) && /no run of its own/.test(document.querySelector('#holdWhy').textContent)));
175
+ await p.keyboard.press('Escape');
176
+ await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
177
+
178
+ /* R10 */
179
+ ok('R10 a page where nothing was pressed to record sent only GET requests', A.reqs.every((r) => r.startsWith('GET ')), A.reqs.filter((r) => !r.startsWith('GET ')).join());
180
+ ok('0 no page error', A.errors.length === 0, A.errors.join(' | '));
181
+
182
+ /* slice 2: the buttons that record (initiative briefing-premium-writes). The POST routes are answered by the test, so no real file is written;
183
+ the refusals of the real server are checked after, with requests that cannot write. */
184
+ {
185
+ const facts = F.facts(); facts.runs = facts.runs.concat([{ run_id: 'run-x1', initiative_id: 'x', created_at: new Date(facts.now - 3600e3).toISOString(), human_corrections: 0 }]);
186
+ const model2 = B.build(facts);
187
+ const posts = []; let answer = { status: 200, body: { ok: true } }; let gets = 0; let thread = { ok: true, turns: [], working: false };
188
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
189
+ pg.on('pageerror', (e) => errs.push(String(e)));
190
+ await pg.route('**/api/briefing-premium', (r) => { gets++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: model2, as_of: Date.now() }) }); });
191
+ await pg.route('**/api/briefing-premium/*', (r) => { const rq = r.request(); if (rq.method() === 'GET') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(thread) }); posts.push({ path: new URL(rq.url()).pathname, body: JSON.parse(rq.postData() || '{}'), headers: rq.headers() }); r.fulfill({ status: answer.status, contentType: 'application/json', body: JSON.stringify(answer.body) }); });
192
+ await pg.goto(BASE + '/premium.html');
193
+ await until(() => pg.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
194
+ const openIdx = async (i) => { await pg.locator('#rank .rhd').nth(i).click(); await until(() => pg.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(700); };
195
+ const shut = async () => { await pg.keyboard.press('Escape'); await until(() => pg.evaluate(() => !document.querySelector('#dlg').classList.contains('on'))); };
196
+ // a decision: the button waits for a choice and a reason
197
+ await openIdx(3);
198
+ const btn = () => pg.evaluate(() => { const b = document.querySelector('#recBtn'); return { dis: b.disabled, t: b.textContent.trim(), h: b.getBoundingClientRect().height }; });
199
+ ok('W1 before a choice the record button is off', (await btn()).dis);
200
+ await pg.locator('#card .op').nth(1).click();
201
+ ok('W1 with a choice and no reason it is still off', (await btn()).dis && /Record: extend/.test((await btn()).t));
202
+ await pg.fill('#why', 'ab');
203
+ ok('W1 a reason under three characters keeps it off', (await btn()).dis);
204
+ await pg.fill('#why', 'Give it a quarter');
205
+ ok('W1 a choice and a reason turn it on, at 48 px', !(await btn()).dis && (await btn()).h >= 44);
206
+ ok('W1 the reason box is labelled and tall enough to type in', await pg.evaluate(() => { const t = document.querySelector('#why'); const l = document.querySelector('label[for="why"]'); return !!l && t.getBoundingClientRect().height >= 44; }));
207
+ // a refusal: the reason is shown and what was typed is kept
208
+ answer = { status: 409, body: { ok: false, error: 'a decision that names KC5 is already on record' } };
209
+ await pg.click('#recBtn');
210
+ ok('W6 a refused press shows the server reason, keeps the dialog open and keeps the typed reason', await until(() => pg.evaluate(() => !document.querySelector('#cardErr').hidden && /already on record/.test(document.querySelector('#cardErr').textContent) && document.querySelector('#why').value === 'Give it a quarter' && document.querySelector('#dlg').classList.contains('on') && !document.querySelector('#recBtn').disabled)));
211
+ ok('W6 the refused press was sent once with the key, the choice and the reason, and no writer field', posts.length === 1 && posts[0].path === '/api/briefing-premium/decide' && posts[0].body.key === 'decision:KC5' && posts[0].body.choice === 'extend' && posts[0].body.why === 'Give it a quarter' && !['by', 'via', 'at', 'actor', 'pm'].some((k) => k in posts[0].body), JSON.stringify(posts[0]));
212
+ // success: the dialog closes and the page reads again
213
+ answer = { status: 200, body: { ok: true } }; const g0 = gets;
214
+ await pg.click('#recBtn');
215
+ ok('W7 a recorded press closes the dialog and reads the model again', await until(() => pg.evaluate(() => !document.querySelector('#dlg').classList.contains('on'))) && await until(() => gets > g0, 4000));
216
+ ok('W7 the page says where it was recorded', await pg.evaluate(() => /pm-decisions\.md/.test(document.querySelector('#toast').textContent)));
217
+ // not yet
218
+ await openIdx(3);
219
+ await pg.locator('#card .seg2 button[data-until="tomorrow"]').click();
220
+ ok('W3 "Tomorrow" posts the key and the time, and closes the dialog', await until(() => posts.some((x) => x.path === '/api/briefing-premium/put-off' && x.body.key === 'decision:KC5' && x.body.until === 'tomorrow')) && await until(() => pg.evaluate(() => !document.querySelector('#dlg').classList.contains('on'))));
221
+ // slice 3: the read-only agent behind the reply box
222
+ await shut(); await openIdx(3);
223
+ ok('A1 the reply box has one Ask the agent button, 44 px, and no note button', await pg.evaluate(() => { const a = document.querySelector('#askGo'); const h = (e) => e.getBoundingClientRect().height; return !!a && h(a) >= 44 && /Ask the agent/.test(a.title) && !document.querySelector('#noteGo') && !/note/i.test(document.querySelector('#askF').textContent); }));
224
+ answer = { status: 200, body: { ok: true, id: 'bq-1', state: 'working' } };
225
+ await pg.fill('#askIn', 'Why is KC5 on my list?');
226
+ const n0 = posts.length;
227
+ await pg.press('#askIn', 'Enter');
228
+ ok('A1 Enter asks the agent: one POST to /ask with the row key and the question, and no writer field', await until(() => posts.length === n0 + 1) && posts[n0].path === '/api/briefing-premium/ask' && posts[n0].body.key === 'decision:KC5' && posts[n0].body.text === 'Why is KC5 on my list?' && Object.keys(posts[n0].body).sort().join() === 'key,text', JSON.stringify(posts[n0]));
229
+ thread = { ok: true, working: true, turns: [{ id: 'bq-1', q: 'Why is KC5 on my list?', state: 'working' }] };
230
+ ok('A8 while it works the question stays visible, the page says it is reading, promises no time, and Ask is off', await until(() => pg.evaluate(() => /Why is KC5 on my list\?/.test(document.querySelector('#dBody .stream').textContent) && /The agent is reading the records/.test(document.querySelector('#dBody .stream').textContent) && document.querySelector('#askGo').disabled && !/seconds|minute/.test(document.querySelector('[role="status"]').textContent)), 8000));
231
+ thread = { ok: true, working: false, turns: [{ id: 'bq-1', q: 'Why is KC5 on my list?', state: 'answered', a: 'Its date, 30 Sep, passed.\n\nSee <b>planning/okrs/kill-conditions.md</b>.' }] };
232
+ ok('A5 the answer appears labelled, as text and never as markup, and Ask is on again', await until(() => pg.evaluate(() => { const s = document.querySelector('#dBody .stream'); return /Agent · not checked against records/.test(s.textContent) && /Its date, 30 Sep, passed\./.test(s.textContent) && s.textContent.includes('<b>planning/okrs/kill-conditions.md</b>') && !s.querySelector('#thr b') && !document.querySelector('#askGo').disabled; }), 8000));
233
+ await shut(); await openIdx(3);
234
+ ok('A5 the answer is back when the row is opened again (it is read from the thread, not kept in the page)', await until(() => pg.evaluate(() => /Its date, 30 Sep, passed\./.test(document.querySelector('#dBody .stream').textContent)), 5000));
235
+ thread = { ok: true, working: false, turns: [{ id: 'bq-2', q: 'Again?', state: 'failed', error: 'The agent took too long and was stopped. Ask again with a narrower question.' }] };
236
+ await shut(); await openIdx(3);
237
+ ok('A5 a failed run shows its own reason', await until(() => pg.evaluate(() => /The agent did not answer\. The agent took too long/.test(document.querySelector('#dBody .stream').textContent)), 5000));
238
+ answer = { status: 409, body: { ok: false, error: 'The agent is still answering the last question on this row.' } };
239
+ await pg.fill('#askIn', 'second question');
240
+ await pg.press('#askIn', 'Enter');
241
+ ok('A4 a refused question shows the reason and keeps what was typed', await until(() => pg.evaluate(() => /still answering/.test(document.querySelector('#askNote').textContent) && document.querySelector('#askIn').value === 'second question' && !document.querySelector('#askGo').disabled), 4000));
242
+ thread = { ok: true, turns: [], working: false }; answer = { status: 200, body: { ok: true } };
243
+ await pg.fill('#askIn', '');
244
+ ok('A1 an empty question is not sent', await (async () => { const n = posts.length; await pg.press('#askIn', 'Enter'); await sleep(200); return posts.length === n && /Write a question/.test(await pg.textContent('#askNote')); })());
245
+ await shut();
246
+ // return a run
247
+ await pg.locator('#tl .ev.src-run').first().click();
248
+ await until(() => pg.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(700);
249
+ ok('W2 a run dialog offers the four reasons and the return button waits for one', await pg.evaluate(() => [...document.querySelectorAll('#retWhy button')].map((b) => b.textContent).join() === 'wrong facts,wrong scope,not now,too long' && document.querySelector('#recBtn').disabled));
250
+ await pg.locator('#retWhy button').nth(1).click();
251
+ ok('W2 choosing a reason turns it on', await pg.evaluate(() => !document.querySelector('#recBtn').disabled && document.querySelector('#retWhy button[aria-pressed="true"]').textContent === 'wrong scope'));
252
+ await pg.click('#recBtn');
253
+ ok('W2 the press posts the run and the reason', await until(() => posts.some((x) => x.path === '/api/briefing-premium/return' && x.body.run === 'run-x1' && x.body.reason === 'wrong scope' && Object.keys(x.body).sort().join() === 'reason,run')));
254
+ await until(() => pg.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
255
+ // W10 floors on a phone, both themes: the new controls are 44 px and nothing scrolls sideways
256
+ for (const theme of ['light', 'dark']) {
257
+ const c = await browser.newContext({ viewport: { width: 390, height: 800 } }), q = await c.newPage();
258
+ await q.route('**/api/briefing-premium', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: model2, as_of: Date.now() }) }));
259
+ await q.goto(BASE + '/premium.html?theme=' + theme);
260
+ await until(() => q.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
261
+ await q.locator('#rank .rhd').nth(3).click(); await until(() => q.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(800);
262
+ await q.click('#dPane'); await sleep(300);
263
+ await q.locator('#card .op').nth(0).click(); await q.fill('#why', 'A reason');
264
+ const m = await q.evaluate(() => { const hs = (sel) => [...document.querySelectorAll(sel)].map((b) => Math.round(b.getBoundingClientRect().height)); return { over: document.documentElement.scrollWidth > innerWidth + 1 || document.querySelector('#dlg').scrollWidth > document.querySelector('#dlg').clientWidth + 1, small: [].concat(hs('#card .seg2 button'), hs('#recBtn'), hs('.askbar input'), hs('#askGo'), hs('#card .op')).filter((h) => h < 44) }; });
265
+ ok('W10 at 390 px (' + theme + ') the decision card has no sideways scroll and every new control is 44 px', !m.over && m.small.length === 0, JSON.stringify(m));
266
+ await c.close();
267
+ }
268
+ const mint = await T.nonce(BASE);
269
+ ok('W5 every press carried the launch nonce the server minted', !!mint && posts.length >= 4 && posts.every((x) => x.headers['x-pmos-nonce'] === mint), String(mint).slice(0, 4) + ' ' + posts.length);
270
+ ok('0 no page error on the write flow', errs.length === 0, errs.join(' | '));
271
+ await ctx.close();
272
+ }
273
+ {
274
+ // the real server refuses what it must, with requests that cannot write
275
+ const hdr = await T.pressHeaders(BASE);
276
+ const send = (h, body, route = 'decide') => fetch(BASE + '/api/briefing-premium/' + route, { method: 'POST', headers: h, body: JSON.stringify(body) }).then(async (r) => ({ s: r.status, j: await r.json().catch(() => ({})) }));
277
+ const good = { key: 'decision:nope', choice: 'kill', why: 'test only' };
278
+ const noN = await send({ 'Content-Type': 'application/json', Origin: BASE }, good);
279
+ const nonce = await T.nonce(BASE);
280
+ ok('W5 a press without the launch nonce is refused', !nonce || noN.s === 403, JSON.stringify(noN));
281
+ const ag = await send({ ...hdr, 'x-pmos-session': 'sess-1' }, good);
282
+ ok('W5 an agent session is refused with 403', ag.s === 403, JSON.stringify(ag));
283
+ const wf = await send(hdr, { ...good, by: 'pm' });
284
+ ok('W5 a body with a writer field is refused with 400', wf.s === 400, JSON.stringify(wf));
285
+ const before = fs.readFileSync(path.join(__dirname, '..', 'pm-decisions.md'), 'utf8');
286
+ const bad = await send(hdr, { key: 'decision:../../x', choice: 'kill', why: 'test only' });
287
+ const none = await send(hdr, { key: 'decision:proto-does-not-exist', choice: 'kill', why: 'test only' });
288
+ const after = fs.readFileSync(path.join(__dirname, '..', 'pm-decisions.md'), 'utf8');
289
+ ok('W6 a bad key and an unknown prototype are refused and pm-decisions.md is unchanged', bad.s === 400 && none.s === 404 && before === after, JSON.stringify([bad, none]));
290
+ const askBad = await send(hdr, { key: '../x', text: 'q' }, 'ask');
291
+ const askAgent = await send({ ...hdr, 'x-pmos-session': 'sess-1' }, { key: 'decision:KC5', text: 'q' }, 'ask');
292
+ const askNoNonce = await send({ 'Content-Type': 'application/json', Origin: BASE }, { key: 'decision:KC5', text: 'q' }, 'ask');
293
+ const askWriter = await send(hdr, { key: 'decision:KC5', text: 'q', by: 'pm' }, 'ask');
294
+ ok('A7 the ask route refuses an agent session (403), a missing nonce (403), a writer field (400) and a bad key (400), and starts nothing', askAgent.s === 403 && (!nonce || askNoNonce.s === 403) && askWriter.s === 400 && askBad.s === 400, JSON.stringify([askAgent, askNoNonce, askWriter, askBad]));
295
+ const thr = await fetch(BASE + '/api/briefing-premium/thread?key=../x').then((r) => r.status);
296
+ const thrGet = await fetch(BASE + '/api/briefing-premium/ask').then((r) => r.status);
297
+ ok('A7 the thread route refuses a bad key and the ask route refuses GET', thr === 400 && thrGet === 405, JSON.stringify([thr, thrGet]));
298
+ const get = await fetch(BASE + '/api/briefing-premium/decide').then((r) => r.status);
299
+ ok('W5 a GET on a recording route is refused', get === 405, String(get));
300
+ }
301
+
302
+ /* the first read: the page says how long it has run, asks again, and draws when the answer is ready */
303
+ {
304
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
305
+ pg.on('pageerror', (e) => errs.push(String(e)));
306
+ let calls = 0;
307
+ await pg.route('**/api/briefing-premium*', (r) => { calls++; if (calls < 2) return r.fulfill({ status: 202, contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'reading', elapsed_ms: 12000 }) }); return r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: MODEL, as_of: Date.now() - 20 * 60000, refreshing: true }) }); });
308
+ await pg.goto(BASE + '/premium.html');
309
+ ok('R13 while the first read goes on the page says how long it has run, and promises no time', await until(() => pg.evaluate(() => /for the first time\. \d+ s so far/.test(document.querySelector('#brief').textContent) && !/half a minute|30 s/.test(document.querySelector('#brief').textContent)), 4000));
310
+ ok('R13 it asks again and draws when the answer is ready', await until(() => pg.evaluate(() => document.querySelectorAll('#rank .rr').length === 5), 8000));
311
+ ok('R13 an answer older than 5 minutes says when it was read and that it is refreshing', await pg.evaluate(() => /read 20 min ago, refreshing/.test(document.querySelector('#snap').textContent)), await pg.evaluate(() => document.querySelector('#snap').textContent));
312
+ await ctx.close();
313
+ const c2 = await browser.newContext({ viewport: { width: 1280, height: 900 } }), p2 = await c2.newPage();
314
+ await p2.route('**/api/briefing-premium*', (r) => r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ ok: false, state: 'error', error: 'spawn failed' }) }));
315
+ await p2.goto(BASE + '/premium.html');
316
+ ok('R9 a failed read says why, on the page', await until(() => p2.evaluate(() => /The reason: spawn failed/.test(document.querySelector('#brief').textContent)), 4000));
317
+ await c2.close();
318
+ }
319
+
320
+ /* a partial answer: the quick sources are drawn at once, and what depends on the initiative state is not claimed */
321
+ {
322
+ const part = B.build({ ...F.facts(), initiatives: [], pending: true });
323
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
324
+ pg.on('pageerror', (e) => errs.push(String(e)));
325
+ let calls = 0;
326
+ await pg.route('**/api/briefing-premium*', (r) => { calls++; const body = calls < 2 ? { ok: true, state: 'ready', model: part, as_of: Date.now(), pending: true } : { ok: true, state: 'ready', model: MODEL, as_of: Date.now() }; return r.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); });
327
+ await pg.goto(BASE + '/premium.html');
328
+ await until(() => pg.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
329
+ const pv = await pg.evaluate(() => ({ snap: document.querySelector('#snap').textContent, cap: document.querySelector('#movesCap').textContent, calm: document.querySelector('#calm').hidden, note: document.querySelector('#order').textContent, dock: document.querySelector('#dN').textContent + ' ' + document.querySelector('#dLab').textContent, rows: [...document.querySelectorAll('#rank .rt')].map((x) => x.childNodes[0].textContent), bar: document.querySelector('#mprog').hidden }));
330
+ ok('R13 a partial answer says it is still reading the initiative state, in the header, the list and the dock', /reading initiative state/.test(pv.snap) && /so far · still reading/.test(pv.cap) && /appear when the initiative state has been read/.test(pv.note) && /still reading/.test(pv.dock), JSON.stringify(pv));
331
+ ok('R13 it never says nothing needs you or all clear while the state is unread', pv.calm === true && !/All clear/.test(pv.cap) && pv.bar === true);
332
+ ok('R13 it shows only what the quick sources hold (the kill-condition decision), not blocked work', pv.rows.length === 1 && /missed KC5 date/.test(pv.rows[0]), JSON.stringify(pv.rows));
333
+ ok('R13 it asks again and replaces the partial answer with the full one', await until(() => pg.evaluate(() => document.querySelectorAll('#rank .rr').length === 5 && !/reading initiative state/.test(document.querySelector('#snap').textContent)), 9000));
334
+ ok('0 no page error on a partial answer', errs.length === 0, errs.join(' | '));
335
+ await ctx.close();
336
+ }
337
+
338
+ /* a record the page cannot build a dialog for still opens, and a failed read says why instead of staying on "reading" */
339
+ {
340
+ const m = JSON.parse(JSON.stringify(MODEL)); m.list.rows[0].dialog = null; m.today.earlier[0].dialog = null;
341
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
342
+ pg.on('pageerror', (e) => errs.push(String(e)));
343
+ await pg.route('**/api/briefing-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: m, as_of: Date.now(), pending: false, error: 'boom in build' }) }));
344
+ await pg.goto(BASE + '/premium.html');
345
+ await until(() => pg.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
346
+ ok('R9 a failed read says why in the header, and the page does not keep saying it is reading', await pg.evaluate(() => /the latest read failed: boom in build/.test(document.querySelector('#snap').textContent) && !/reading initiative state/.test(document.querySelector('#snap').textContent)));
347
+ await pg.locator('#rank .rhd').first().click();
348
+ ok('R4 a row with no dialog still opens one from what the row says', await until(() => pg.evaluate(() => document.querySelector('#dlg').classList.contains('on') && /What is known/.test(document.querySelector('#card').textContent) && /If you skip/.test(document.querySelector('#card').textContent)), 4000));
349
+ await pg.keyboard.press('Escape');
350
+ await until(() => pg.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
351
+ await pg.locator('#tl .ev').first().click();
352
+ ok('R7 a Today item with no dialog still opens one', await until(() => pg.evaluate(() => document.querySelector('#dlg').classList.contains('on') && !!document.querySelector('#card')), 4000));
353
+ ok('0 no page error when a dialog is missing', errs.length === 0, errs.join(' | '));
354
+ await ctx.close();
355
+ }
356
+
357
+ /* R9 */
358
+ {
359
+ const m2 = B.build({ ...F.facts(), meetings: null, tripwires: null });
360
+ const R = await open({ model: m2 });
361
+ ok('R9 the header names what could not be read', await R.page.evaluate(() => /not read: the calendar, kill conditions/.test(document.querySelector('#snap').textContent)));
362
+ ok('R9 no calendar rows are drawn when the calendar is unread', await R.page.evaluate(() => ![...document.querySelectorAll('#tl .ev')].some((e) => e.classList.contains('src-meeting'))));
363
+ await R.ctx.close();
364
+ const empty = B.build({ now: F.NOW, initiatives: [], runs: [], merges: [], evals: [], meetings: [], tripwires: [] });
365
+ const E = await open({ model: empty });
366
+ ok('R2 an empty repository says nothing happened and shows no row', await E.page.evaluate(() => !document.querySelector('#brief').textContent.match(/\d/) && !document.querySelector('#calm').hidden && document.querySelectorAll('#rank .rr').length === 0));
367
+ ok('R8 and the dock says nothing needs you', await E.page.evaluate(() => document.querySelector('#dN').textContent === '0'));
368
+ await E.ctx.close();
369
+ }
370
+
371
+ /* R8, R11 the dock folds in on scroll, and is not reachable before it shows */
372
+ await p.evaluate(() => window.scrollTo(0, 0));
373
+ await until(() => p.evaluate(() => !document.body.classList.contains('docked')));
374
+ ok('R11 the dock is inert while it is out of sight', await p.evaluate(() => document.querySelector('#dock').inert === true && !document.body.classList.contains('docked')));
375
+ await p.evaluate(() => window.scrollTo(0, 900));
376
+ ok('R8 scrolling past the hero shows the dock and makes it reachable', await until(() => p.evaluate(() => document.body.classList.contains('docked') && document.querySelector('#dock').inert === false)));
377
+ ok('R4 hovering the decision lights the meeting that needs it', await (async () => { await p.evaluate(() => window.scrollTo(0, 0)); await p.locator('#rank .rr[data-link]').first().hover(); return p.evaluate(() => document.querySelector('#tl .ev[data-link]').classList.contains('hl-k')); })());
378
+ await p.evaluate(() => window.scrollTo(0, 900));
379
+ await sleep(400);
380
+ /* R11 floors */
381
+ {
382
+ const t = await p.evaluate(() => {
383
+ const small = [];
384
+ for (const s of ['.rr .rhd', '.rr .rpin', '#tl .ev', '#srcChips .fchip', '.fbtn', '.rail .pl', '#theme', '#theme2']) document.querySelectorAll(s).forEach((e) => { const r = e.getBoundingClientRect(); if (r.width && (r.height < 44 || r.width < 44)) small.push(s + ' ' + Math.round(r.width) + 'x' + Math.round(r.height)); });
385
+ return small;
386
+ });
387
+ ok('R11 every control is at least 44 px', t.length === 0, t.join(' | '));
388
+ await p.keyboard.press('Tab');
389
+ const ring = await p.evaluate(() => { document.activeElement.focus(); const cs = getComputedStyle(document.activeElement); return cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) >= 2; });
390
+ ok('R11 the focus ring is drawn', ring);
391
+ const arrows = await p.evaluate(() => { const L = [...document.querySelectorAll('#tl .ev')]; L[0].focus(); L[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); return document.activeElement === L[1] || L.length < 2; });
392
+ ok('R11 arrow keys move between timeline rows', arrows);
393
+ }
394
+ {
395
+ const R = await open({ ctxOpts: { reducedMotion: 'reduce' } });
396
+ ok('R11 reduced motion keeps every row and the hero', await R.page.evaluate(() => document.querySelectorAll('#rank .rr').length === 5 && document.querySelector('#brief').textContent.length > 40));
397
+ await R.ctx.close();
398
+ }
399
+ for (const th of ['light', 'dark']) {
400
+ const R = await open({ w: 390, h: 844, theme: th });
401
+ const ov = await R.page.evaluate(() => document.documentElement.scrollWidth - innerWidth);
402
+ ok(`R11 no sideways scroll at 390 px (${th})`, ov <= 0, String(ov));
403
+ await R.page.click('#rank .rhd');
404
+ const ph = await until(() => R.page.evaluate(() => document.querySelector('#dlg').classList.contains('on')), 3000) && await R.page.evaluate(() => ({ tab: document.querySelector('#dGrid').dataset.tab, pressed: document.querySelector('#dTabs [data-t="out"]').getAttribute('aria-pressed'), tabsShown: getComputedStyle(document.querySelector('#dTabs')).display !== 'none', w: Math.round(document.querySelector('#dlg').getBoundingClientRect().width), l: Math.round(document.querySelector('#dlg').getBoundingClientRect().left), vw: innerWidth, btn: document.querySelector('#dPane').getAttribute('aria-pressed'), ov: document.documentElement.scrollWidth - innerWidth }));
405
+ ok(`R4 on a phone the dialog opens on Detail, the tab strip is hidden and the header button brings the conversation (${th})`, ph && ph.tab === 'out' && ph.pressed === 'true' && !ph.tabsShown && ph.btn === 'false' && ph.l === 0 && ph.w === ph.vw && ph.ov <= 0, JSON.stringify(ph));
406
+ ok(`0 no page error at 390 px (${th})`, R.errors.length === 0, R.errors.join(' | '));
407
+ await R.ctx.close();
408
+ }
409
+
410
+ /* R12 fidelity: computed styles and geometry against the mockup */
411
+ if (!fs.existsSync(MOCKUP)) ok('R12 the mockup is in the repository', false, MOCKUP);
412
+ else {
413
+ const PAIRS = [['rail', '.rail'], ['name', '.top .name'], ['date', '.hero .date'], ['brief', '.brief'], ['card', '.cols2>div:first-child .card'], ['today', '#todayCard'], ['heading', '#hMoves'], ['row', '.rr'], ['number', '.rr .rn'], ['title', '.rr .rt'], ['subject', '.rr .rs'], ['skip', '.rr .skip'], ['tag', '.tgf'], ['group', '#tl .grp'], ['event', '#tl .ev'], ['now', '.nowl'], ['dock', '.dock']];
414
+ const SKIP_GEOM = { 1280: [], 390: ['heading'] }; // D5: on a phone the card heading wraps its caption under the title (the mockup wrapped the title over four lines)
415
+ const PROPS = ['fontSize', 'fontWeight', 'fontFamily', 'color', 'backgroundColor', 'borderRadius', 'paddingTop', 'paddingLeft', 'letterSpacing', 'lineHeight', 'boxShadow', 'backdropFilter', 'borderTopColor'];
416
+ const grab = (pg) => pg.evaluate(([pairs, props]) => { const o = {}; for (const [n, s] of pairs) { const e = document.querySelector(s); if (!e) { o[n] = null; continue; } const cs = getComputedStyle(e), r = e.getBoundingClientRect(), v = { left: Math.round(r.left), width: Math.round(r.width) }; props.forEach((k) => { v[k] = cs[k]; }); o[n] = v; } return o; }, [PAIRS, PROPS]);
417
+ for (const [w, h] of [[1280, 900], [390, 844]]) for (const th of ['light', 'dark']) {
418
+ const m = await (await browser.newContext({ viewport: { width: w, height: h } })).newPage();
419
+ await m.goto('file://' + MOCKUP);
420
+ await until(() => m.evaluate(() => document.querySelectorAll('#rank .rr').length > 0 && document.querySelectorAll('#tl .ev').length > 0));
421
+ await m.evaluate((t) => { document.documentElement.dataset.theme = t; }, th);
422
+ const R = await open({ w, h, theme: th });
423
+ await sleep(500);
424
+ const a = await grab(m, PAIRS), c = await grab(R.page, PAIRS), diffs = [];
425
+ for (const [n] of PAIRS) {
426
+ if (!a[n] || !c[n]) { diffs.push(n + ' missing'); continue; }
427
+ for (const k of Object.keys(a[n])) {
428
+ if ((k === 'left' || k === 'width') && (SKIP_GEOM[w].includes(n) || ['tag', 'brief', 'now', 'event'].includes(n) && k === 'left')) continue;
429
+ if (k === 'width' && ['tag', 'brief', 'title', 'subject', 'skip'].includes(n)) continue; // D8: the row text leaves room for the pin, so its lines are narrower
430
+ if ((k === 'left' || k === 'width') ? Math.abs(a[n][k] - c[n][k]) > 2 : a[n][k] !== c[n][k]) diffs.push(`${n}.${k} ${a[n][k]} | ${c[n][k]}`);
431
+ }
432
+ }
433
+ ok(`R12 styles and geometry match the mockup at ${w} px, ${th}`, diffs.length === 0, diffs.join(' ; '));
434
+ await m.context().close(); await R.ctx.close();
435
+ }
436
+ }
437
+ return finish();
438
+ })().catch((e) => { console.error(e); finish(1); });