create-pmos 0.252.0 → 0.253.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 (51) 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-test.js +438 -0
  11. package/workspace/briefing-premium-work-test.js +281 -0
  12. package/workspace/fixtures/board-premium-facts.js +38 -0
  13. package/workspace/fixtures/briefing-premium-facts.js +32 -0
  14. package/workspace/fixtures/initiative-premium-facts.js +42 -0
  15. package/workspace/fixtures/my-work-premium-facts.js +31 -0
  16. package/workspace/guide/briefing-premium.md +137 -0
  17. package/workspace/guide/index.md +1 -0
  18. package/workspace/lib/board-premium.js +133 -0
  19. package/workspace/lib/briefing-premium-agent.js +134 -0
  20. package/workspace/lib/briefing-premium-writes.js +138 -0
  21. package/workspace/lib/briefing-premium.js +514 -0
  22. package/workspace/lib/engine/workflow-state.js +6 -1
  23. package/workspace/lib/headless.js +1 -1
  24. package/workspace/lib/initiative-premium.js +147 -0
  25. package/workspace/lib/my-work-premium.js +151 -0
  26. package/workspace/lib/route-modules.js +4 -0
  27. package/workspace/lib/routes-board-premium.js +61 -0
  28. package/workspace/lib/routes-briefing-premium.js +83 -0
  29. package/workspace/lib/routes-initiative-premium.js +68 -0
  30. package/workspace/lib/routes-my-work-premium.js +31 -0
  31. package/workspace/lib/vendor/scripts/workflow-state.js +6 -1
  32. package/workspace/package-lock.json +2 -2
  33. package/workspace/package.json +1 -1
  34. package/workspace/public/pm/premium-board.js +180 -0
  35. package/workspace/public/pm/premium-initiative.js +336 -0
  36. package/workspace/public/pm/premium-shell.js +184 -0
  37. package/workspace/public/pm/premium-work.js +198 -0
  38. package/workspace/public/pm/premium.css +1198 -0
  39. package/workspace/public/pm/premium.js +228 -0
  40. package/workspace/public/premium-board.html +73 -0
  41. package/workspace/public/premium-initiative.html +46 -0
  42. package/workspace/public/premium-work.html +84 -0
  43. package/workspace/public/premium.html +68 -0
  44. package/workspace/public/vendor/geist-mono-var.woff2 +0 -0
  45. package/workspace/public/vendor/geist-var.woff2 +0 -0
  46. package/workspace/server.js +4 -2
  47. package/workspace/test-files.json +12 -0
  48. package/workspace/test-lists/briefing-premium.json +17 -0
  49. package/workspace/third-party/README.md +12 -0
  50. package/workspace/third-party/geist-OFL.LICENSE +93 -0
  51. package/workspace/third-party/geist-mono-OFL.LICENSE +93 -0
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* briefing-premium-work-test.js — the premium My work page (initiative briefing-premium-work; planning/prd/briefing-premium-work-prd.md M1 to M12).
4
+ * Serves the real page from the real server. Most checks answer GET /api/my-work-premium with the model lib/my-work-premium.js builds from a fixture
5
+ * (fixtures/my-work-premium-facts.js) and answer the presses themselves, so no real item is touched; the last block drives the real routes of a scratch state folder.
6
+ * M1 rail and place · M2 hero · M3 add · M4 the plate · M5 agents (held controls) · M6 waiting · M7 review · M8 presses · M9 one count
7
+ * M10 header · M11 refusals · M12 floors and fidelity against the mockup
8
+ */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { spawn } = require('child_process');
12
+ const { findBrowser } = require('./lib/find-browser');
13
+ const T = require('./test-run').configure({ name: 'briefing-premium-work' });
14
+ const WP = require('./lib/my-work-premium.js');
15
+ const F = require('./fixtures/my-work-premium-facts.js');
16
+
17
+ const BASE = T.BASE;
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ 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); } };
20
+ const results = [];
21
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
22
+ let srv = null; let browser = null;
23
+ const finish = async (code) => {
24
+ if (browser) await browser.close().catch(() => {});
25
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
26
+ T.killTmux();
27
+ const bad = results.filter((x) => !x.cond).length;
28
+ console.log(`\n${results.length - bad}/${results.length} briefing-premium-work assertions passed`);
29
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
30
+ };
31
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
32
+
33
+ const mk = () => WP.build({ v: F.view(), now: F.NOW });
34
+ const MOCKUP = path.join(__dirname, '..', 'prototype', 'premium-ux-concept', 'briefing-artifact.html');
35
+
36
+ (async () => {
37
+ const exe = findBrowser();
38
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the suite cannot run'); return finish(1); }
39
+ let chromium;
40
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed'); return finish(1); }
41
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
42
+ await T.waitForServer(BASE);
43
+ browser = await chromium.launch({ executablePath: exe });
44
+
45
+ /* a page whose list and presses the test answers */
46
+ const open = async ({ w = 1280, h = 900, theme = 'light', model = mk() } = {}) => {
47
+ const ctx = await browser.newContext({ viewport: { width: w, height: h } });
48
+ const page = await ctx.newPage();
49
+ let current = model; const errors = [], posts = []; let answer = { status: 200, body: { ok: true } }; let thread = { ok: true, turns: [], working: false }; let gets = 0;
50
+ page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
51
+ await page.route('**/api/my-work-premium', (r) => { gets++; r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: current, as_of: Date.now() }) }); });
52
+ await page.route('**/api/pm-items/**', (r) => { const rq = r.request(); 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) }); });
53
+ await page.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() }); return r.fulfill({ status: answer.status, contentType: 'application/json', body: JSON.stringify(answer.body) }); });
54
+ await page.goto(BASE + '/premium-work.html?theme=' + theme);
55
+ const up = await until(() => page.evaluate(() => document.querySelectorAll('#plateBody .wrow').length > 0), 20000);
56
+ return { ctx, page, errors, posts, up, setModel: (m) => { current = m; }, set: (a) => { answer = a; }, setThread: (t) => { thread = t; }, gets: () => gets };
57
+ };
58
+
59
+ const A = await open();
60
+ ok('0 the page draws the plate', A.up);
61
+ if (!A.up) return finish(1);
62
+ const p = A.page, M = mk();
63
+ const txt = (s) => p.evaluate((q) => (document.querySelector(q) || {}).textContent || '', s);
64
+
65
+ /* M1 */
66
+ 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), pi: getComputedStyle(document.querySelector('.rail')).getPropertyValue('--pi').trim(), title: document.title }));
67
+ ok('M1 the rail names four places, My work is current and the indicator sits on it', rail.names.join() === 'Briefing,My work,Board,Insights' && rail.cur === 'My work' && rail.go.join() === 'brief,work,init,rep' && rail.pi === '1', JSON.stringify(rail));
68
+ const res = await fetch(BASE + '/premium-work').then((r) => r.status);
69
+ ok('M1 the page is served at /premium-work and /premium-work.html', res === 200 && (await fetch(BASE + '/premium-work.html')).status === 200);
70
+ {
71
+ const r2 = await open(); await r2.page.locator('.rail [data-go="brief"]').click();
72
+ ok('M1 the Briefing button opens the Briefing page', await until(() => /\/premium\.html$/.test(r2.page.url()), 5000), r2.page.url());
73
+ await r2.ctx.close();
74
+ }
75
+
76
+ /* M2 */
77
+ const hero = await p.evaluate(() => ({ text: document.querySelector('#brief').textContent, k: [...document.querySelectorAll('#brief .k')].map((x) => x.textContent) }));
78
+ ok('M2 the hero is the model\'s sentences, numbers marked', hero.text === M.hero.map((s) => s.parts.map((x) => x.t).join('')).join(' ') && hero.k.length >= 5, hero.text);
79
+ ok('M2 the hero says what the records hold', /Three new in your inbox, one overdue, one due today\./.test(hero.text) && /longest for 8 days/.test(hero.text));
80
+
81
+ /* M10 */
82
+ ok('M10 the header says what is real and that Jira and Slack appear only when read', /^Real data · as of 18:05/.test(await txt('#snap')) && /only when an agent session has read them/.test(await txt('#snap')));
83
+
84
+ /* M4 */
85
+ const plate = await p.evaluate(() => ({ labels: [...document.querySelectorAll('#plateBody .wsec .label')].map((x) => x.textContent), counts: [...document.querySelectorAll('#plateBody .wsec em')].map((x) => x.textContent), cap: document.querySelector('#plateCap').textContent, first: [...document.querySelectorAll('.tgf')].map((x) => x.closest('.wrow').dataset.key), titles: [...document.querySelectorAll('#plateBody .wtit')].map((x) => x.textContent.replace('Do this first', '')), chips: [...document.querySelectorAll('#plateBody .due')].map((x) => x.textContent), od: [...document.querySelectorAll('#plateBody .due.od')].length, ck: [...document.querySelectorAll('#plateBody .wck')].map((b) => Math.round(b.getBoundingClientRect().height)), glyphs: [...document.querySelectorAll('#plateBody .inb .wgl')].map((x) => x.title) }));
86
+ ok('M4 four places in order with their counts', plate.labels.join() === 'Inbox,Today,Upcoming,Someday' && plate.counts.join() === '3 open,3 open,2 open,1 open', JSON.stringify(plate));
87
+ ok('M4 rows are the items, in order, with a due chip only where a date was set; the overdue one says so', plate.titles.length === 9 && plate.titles[3] === 'Sign off the fit-out contractor shortlist' && plate.chips.join() === 'Overdue · 15 Oct,Today,19 Oct,2 Nov' && plate.od === 1, JSON.stringify(plate.chips));
88
+ ok('M4 "Do this first" tags the overdue item only', plate.first.length === 1 && plate.first[0] === 'work:wbs:alpha:4.2', JSON.stringify(plate.first));
89
+ ok('M4 an inbox row shows its source, a check circle is 44 px', plate.glyphs.join() === 'Jira,Jira,Slack' && plate.ck.length === 6 && plate.ck.every((h) => h >= 44), JSON.stringify([plate.glyphs, plate.ck]));
90
+ const dec = await p.evaluate(() => { const b = document.querySelector('.wrow[data-key="work:wbs:alpha:4.2"] .wck'); const r = b.closest('.wrow'); return { dec: b.classList.contains('dec'), eye: !!b.querySelector('circle'), label: b.getAttribute('aria-label'), first: r.classList.contains('first'), tagBlock: getComputedStyle(r.querySelector('.tgf')).display, bar: getComputedStyle(r).boxShadow !== 'none', others: [...document.querySelectorAll('#plateBody .wck')].filter((x) => x !== b).every((x) => !x.classList.contains('dec')) }; });
91
+ ok('M4 an item whose agent draft is ready has a highlighted eye circle (not a check), the first overdue row carries the ember bar and its tag sits on its own line', dec.dec && dec.eye && /^Review the agent draft/.test(dec.label) && dec.first && dec.tagBlock === 'block' && dec.bar && dec.others, JSON.stringify(dec));
92
+ const tray = await p.evaluate(() => ({ cap: document.querySelector('#trayCap').textContent, titles: [...document.querySelectorAll('#trayBody .wtit')].map((x) => x.textContent), note: document.querySelector('#trayNote').textContent, checks: document.querySelectorAll('#trayBody .wck').length, inbox: [...document.querySelectorAll('#plateBody .wsec')].find((x) => x.dataset.lane === 'inbox').textContent }));
93
+ ok('M4 your tray holds what you threw in and have not placed, apart from the plate, and says how it works', tray.cap === '1 to place' && tray.titles.join() === 'Is the payment-step experiment worth another week?' && /Nothing here counts as work to clear/.test(tray.note) && tray.checks === 0 && /3 open/.test(tray.inbox), JSON.stringify(tray));
94
+ ok('M4 the add box has no Enter label', !(await p.evaluate(() => !!document.querySelector('#cap kbd'))));
95
+ ok('M4 no suggestion, no estimate and no demo label is drawn', !(await p.evaluate(() => /Accept all|~\d+ min|demo/i.test(document.querySelector('#plate').textContent))));
96
+
97
+ /* M5 */
98
+ const ag = await p.evaluate(() => ({ tiles: [...document.querySelectorAll('#pipe .pst')].map((x) => x.textContent.replace(/\s+/g, ' ').trim()), hot: document.querySelectorAll('#pipe .pst.hot').length, rows: [...document.querySelectorAll('#agl .ws')].map((x) => x.textContent), cap: document.querySelector('#agCap').textContent, hold: document.querySelector('#agHold').textContent, buttons: [...document.querySelectorAll('button')].map((b) => b.textContent).join('|') }));
99
+ ok('M5 three counts from the agent field and the rows that are running or ready', ag.tiles.join() === '1Running,1Failed,1To review' && ag.hot === 1 && ag.rows.length === 3 && /draft ready to review/.test(ag.rows[0]) && /the run failed/.test(ag.rows.join()) && ag.cap === 'one to review', JSON.stringify(ag));
100
+ ok('M5 Give to agent, Approve, Revise and Retry are not on the page, and the page says why', !/\b(Give to agent|Approve|Revise|Retry)\b/i.test(ag.buttons) && /not built yet/.test(ag.hold));
101
+
102
+ /* M6 */
103
+ const wt = await p.evaluate(() => ({ cap: document.querySelector('#waitCap').textContent, rows: [...document.querySelectorAll('#waitBody .wtit')].map((x) => x.textContent), subs: [...document.querySelectorAll('#waitBody .ws')].map((x) => x.textContent), nudge: /Nudge/.test(document.querySelector('#waitCard').textContent) }));
104
+ ok('M6 waiting on others: longest first, who and how long, no nudge', wt.cap === 'longest 8 days' && wt.rows[0] === 'Review the partner data-sharing clause' && /waiting on Dara/.test(wt.subs[0]) && !wt.nudge, JSON.stringify(wt));
105
+
106
+ /* M7 */
107
+ const rv = await p.evaluate(() => ({ hidden: document.querySelector('#revCard').hidden, txt: document.querySelector('#revTxt').textContent }));
108
+ ok('M7 the untouched card counts the items from the review list', !rv.hidden && /^2 items have sat untouched for 7 days/.test(rv.txt), JSON.stringify(rv));
109
+
110
+ /* M9 */
111
+ ok('M9 the hero, the plate and the dock agree on one count', await p.evaluate(() => document.querySelector('#plateCap').textContent === '7 to clear' && document.querySelector('#dN').textContent === '7' && document.querySelector('#dLab').textContent === 'to clear'));
112
+
113
+ /* M3 add */
114
+ A.set({ status: 200, body: { ok: true, item: { id: 'item-new', title: 'Reply to Lee', due: null, priority: 'P1' } } });
115
+ { const v = F.view(); v.work.inbox.push(F.it('item-new', 'Reply to Lee', { priority: 'P1' })); A.setModel(WP.build({ v, now: F.NOW })); }
116
+ await p.fill('#capIn', 'Reply to Lee !p1'); await p.press('#capIn', 'Enter');
117
+ ok('M3 one line is posted to the Work page\'s add route, and the page shows what was read', await until(() => A.posts.some((x) => x.path === '/api/pm-items/add' && x.body.line === 'Reply to Lee !p1' && Object.keys(x.body).join() === 'line')) && await until(() => txt('#capHint').then((t) => /Added: Reply to Lee \(P1\)\. It is in your tray until you place it\./.test(t))));
118
+ ok('M3 a new item lands in the tray, not on the plate, and its dialog opens at once so it can be placed', await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on') && document.querySelector('#dTitle').textContent === 'Reply to Lee' && /Tray/.test(document.querySelector('#oMain').textContent) && !!document.querySelector('#trayBody [data-key="work:item-new"]') && !document.querySelector('#plateBody [data-key="work:item-new"]')), 6000));
119
+ await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on'))); A.setModel(mk());
120
+ const n0 = A.posts.length; await p.fill('#capIn', ' '); await p.press('#capIn', 'Enter'); await sleep(200);
121
+ ok('M3 an empty line is not posted', A.posts.length === n0 && /Write one line/.test(await txt('#capHint')));
122
+
123
+ /* M8 presses from a check circle, and from the dialog */
124
+ {
125
+ const n = A.posts.length;
126
+ await p.locator('.wrow[data-key="work:wbs:alpha:4.2"] .wck.dec').click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
127
+ ok('M8 the eye circle opens the item to review the draft and posts nothing', A.posts.length === n && (await p.textContent('#dTitle')) === 'Sign off the fit-out contractor shortlist' && /Agent draft, not checked/.test(await p.textContent('#oMain')));
128
+ await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
129
+ }
130
+ const openKey = async (key) => { await p.locator(`.wrow[data-key="${key}"] .wrh`).first().click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(700); };
131
+ const shut = async () => { await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on'))); };
132
+ const lastPost = () => A.posts[A.posts.length - 1];
133
+ A.set({ status: 200, body: { ok: true } });
134
+ const g0 = A.gets();
135
+ await p.locator('.wrow[data-key="work:item-bbbbbbbbbbb2"] .wck').click();
136
+ ok('M8 a check circle posts Done with the item id only, offers Undo, and the page reads again', await until(() => A.posts.some((x) => x.path === '/api/pm-items/done' && Object.keys(x.body).join() === 'id' && x.body.id === 'item-bbbbbbbbbbb2')) && await until(() => p.evaluate(() => /Undo/.test(document.querySelector('#toast').textContent))) && await until(() => A.gets() > g0));
137
+ await p.locator('#toast .undo').click();
138
+ ok('M8 Undo posts Reopen for that item', await until(() => A.posts.some((x) => x.path === '/api/pm-items/reopen' && x.body.id === 'item-bbbbbbbbbbb2')));
139
+ await openKey('work:item-aaaaaaaaaaa1');
140
+ const dlg = await p.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, btn: [...document.querySelectorAll('#card button')].map((b) => b.textContent.trim()), mod: document.querySelector('#dlg').getAttribute('aria-modal'), ask: !!document.querySelector('#askGo'), wDis: document.querySelector('#wBtn').disabled }));
141
+ ok('M8 a dialog offers Done, Not yet, Move to, Waiting and Drop, and the agent box; Keep only where the review list holds the item', dlg.title === 'Decide how partial refunds are handled' && dlg.btn.join() === 'Done,Later today,Tomorrow,Next week,Do today,Later,Wait for them,Drop it' && dlg.mod === 'true' && dlg.ask && dlg.wDis, JSON.stringify(dlg));
142
+ await p.locator('#card [data-p="notyet"][data-when="tomorrow"]').click();
143
+ ok('M8 Not yet posts the item and the day, and closes the dialog', await until(() => A.posts.some((x) => x.path === '/api/pm-items/notyet' && x.body.id === 'item-aaaaaaaaaaa1' && x.body.when === 'tomorrow' && Object.keys(x.body).sort().join() === 'id,when')) && await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on'))));
144
+ for (const [sel, route, check] of [['[data-p="flag"]', '/api/pm-items/flag', (b) => b.flagged === true], ['[data-p="park"]', '/api/pm-items/park', (b) => Object.keys(b).join() === 'id'], ['[data-p="done"]', '/api/pm-items/done', (b) => Object.keys(b).join() === 'id'], ['[data-p="remove"]', '/api/pm-items/remove', (b) => Object.keys(b).join() === 'id']]) {
145
+ await openKey('work:item-aaaaaaaaaaa2'); const n = A.posts.length;
146
+ await p.locator('#card ' + sel).click();
147
+ ok(`M8 ${route.split('/').pop()} posts exactly what the Work page's route takes`, await until(() => A.posts.length > n && lastPost().path === route && lastPost().body.id === 'item-aaaaaaaaaaa2' && check(lastPost().body)), JSON.stringify(lastPost()));
148
+ await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
149
+ }
150
+ ok('M8 Done and Drop offer Undo for a short time', await p.evaluate(() => /Undo/.test(document.querySelector('#toast').textContent)));
151
+ await openKey('work:item-aaaaaaaaaaa3'); await p.fill('#wOn', ' Priya ');
152
+ ok('M8 Waiting needs a name', await p.evaluate(() => !document.querySelector('#wBtn').disabled));
153
+ await p.click('#wBtn');
154
+ ok('M8 Waiting posts the name', await until(() => A.posts.some((x) => x.path === '/api/pm-items/waiting' && x.body.id === 'item-aaaaaaaaaaa3' && x.body.on === 'Priya')));
155
+ await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
156
+ await openKey('work:item-ddddddddddd1');
157
+ ok('M7 an item on the review list offers Keep, and Keep posts it', await p.evaluate(() => !!document.querySelector('#card [data-p="keep"]')));
158
+ await p.click('#card [data-p="keep"]');
159
+ ok('M7 Keep posts the item id only', await until(() => A.posts.some((x) => x.path === '/api/pm-items/keep' && Object.keys(x.body).join() === 'id' && x.body.id === 'item-ddddddddddd1')));
160
+ await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
161
+ ok('M8 no press carried a writer field', A.posts.every((x) => !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
162
+ const mint = await T.nonce(BASE);
163
+ ok('M8 every press carried the launch nonce', !!mint && A.posts.filter((x) => x.path.startsWith('/api/pm-items/')).every((x) => x.headers['x-pmos-nonce'] === mint));
164
+
165
+ /* M11 refusal */
166
+ A.set({ status: 409, body: { ok: false, error: 'that item is already done' } });
167
+ await openKey('work:item-ccccccccccc1'); await p.locator('#card [data-p="done"]').click();
168
+ ok('M11 a refused press shows the route\'s reason, keeps the dialog open and turns the buttons back on', await until(() => p.evaluate(() => !document.querySelector('#cardErr').hidden && /already done/.test(document.querySelector('#cardErr').textContent) && document.querySelector('#dlg').classList.contains('on') && !document.querySelector('#card [data-p="done"]').disabled)));
169
+ A.set({ status: 200, body: { ok: true } });
170
+
171
+ /* agent beside the item */
172
+ A.setThread({ ok: true, working: false, turns: [{ id: 'bq-1', q: 'What is this about?', state: 'answered', a: 'It is about <b>shipping</b>.' }] });
173
+ await shut(); await openKey('work:item-aaaaaaaaaaa1');
174
+ ok('A1 the read-only agent is beside the item: its answer is labelled and shown as text', await until(() => p.evaluate(() => /Agent · not checked against records/.test(document.querySelector('#dBody .stream').textContent) && document.querySelector('#dBody .stream').textContent.includes('<b>shipping</b>')), 5000));
175
+ A.setThread({ ok: true, working: false, turns: [] });
176
+ await p.fill('#askIn', 'Why is this here?'); const nA = A.posts.length; await p.press('#askIn', 'Enter');
177
+ ok('A1 a question posts the item key and the text, and nothing else', await until(() => A.posts.length > nA && lastPost().path === '/api/briefing-premium/ask' && lastPost().body.key === 'work:item-aaaaaaaaaaa1' && lastPost().body.text === 'Why is this here?' && Object.keys(lastPost().body).sort().join() === 'key,text'), JSON.stringify(lastPost()));
178
+ await shut();
179
+
180
+ ok('0 no page error', A.errors.length === 0, A.errors.join(' | '));
181
+ await A.ctx.close();
182
+
183
+ /* M2 / M4 degraded: an empty list */
184
+ {
185
+ const E = await open({ model: WP.build({ v: { today: '2026-10-16', work: {} }, now: F.NOW }) });
186
+ ok('M2 an empty list says so, in one sentence, and the plate says nothing waits', await E.page.evaluate(() => document.querySelector('#brief').textContent === 'Your list is empty.' && /Nothing waits to be sorted/.test(document.querySelector('#plateBody').textContent) && document.querySelector('#revCard').hidden && /Nobody owes you anything/.test(document.querySelector('#waitBody').textContent) && document.querySelector('#dN').textContent === '0'));
187
+ await E.ctx.close();
188
+ }
189
+ {
190
+ // a failed read says why and draws nothing
191
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage();
192
+ await pg.route('**/api/my-work-premium', (r) => r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ ok: false, state: 'error', error: 'the list could not be read' }) }));
193
+ await pg.goto(BASE + '/premium-work.html');
194
+ ok('M10 a failed read says why and shows nothing instead of a guess', await until(() => pg.evaluate(() => /The reason: the list could not be read/.test(document.querySelector('#brief').textContent) && document.querySelectorAll('#plateBody .wrow').length === 0), 5000));
195
+ await ctx.close();
196
+ }
197
+
198
+ {
199
+ const R = await open({ w: 820, h: 1000 }), t = R.page;
200
+ await t.locator('.wrow[data-key="work:item-aaaaaaaaaaa1"] .wrh').click(); await until(() => t.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(900);
201
+ const g = await t.evaluate(() => { const r = document.querySelector('#dlg').getBoundingClientRect(); return { l: Math.round(r.left), w: Math.round(r.width), vw: innerWidth, tabs: getComputedStyle(document.querySelector('#dTabs')).display }; });
202
+ ok('M12 on a tablet (820 px) the dialog is centred with a 12 px margin, and the tab strip is hidden', Math.abs(g.l - (g.vw - g.w) / 2) <= 2 && g.w === g.vw - 24 && g.tabs === 'none', JSON.stringify(g));
203
+ await R.ctx.close();
204
+ }
205
+ /* M12 floors on a phone, both themes */
206
+ for (const theme of ['light', 'dark']) {
207
+ const R = await open({ w: 390, h: 844, theme }), q = R.page;
208
+ await q.locator('.wrow[data-key="work:item-aaaaaaaaaaa1"] .wrh').click(); await until(() => q.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(800);
209
+ const geo = () => q.evaluate(() => { const r = document.querySelector('#dlg').getBoundingClientRect(); return { l: Math.round(r.left), w: Math.round(r.width), vw: innerWidth }; });
210
+ const g1 = await geo();
211
+ ok(`M12 at 390 px (${theme}) the dialog opens on Detail at the full width of the screen`, g1.l === 0 && g1.w === g1.vw, JSON.stringify(g1));
212
+ await q.click('#dPane'); await sleep(900);
213
+ const g2 = await geo();
214
+ ok(`M12 at 390 px (${theme}) with the conversation brought in it is still full width`, g2.l === 0 && g2.w === g2.vw && await q.evaluate(() => document.querySelector('#dPane').getAttribute('aria-pressed') === 'true' && getComputedStyle(document.querySelector('#dBody')).display !== 'none' && getComputedStyle(document.querySelector('#dOut')).display === 'none'), JSON.stringify(g2));
215
+ 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 .cta'), hs('#card .seg2 button'), hs('#wOn'), hs('.askbar input'), hs('#askGo')).filter((h) => h < 44) }; });
216
+ ok(`M12 at 390 px (${theme}) the dialog has no sideways scroll and every control is 44 px`, !m.over && m.small.length === 0, JSON.stringify(m));
217
+ await q.keyboard.press('Escape'); await sleep(300);
218
+ const pg = await q.evaluate(() => ({ over: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('.wck, .capb, .wrh, #revBtn, .fchip')].filter((b) => !b.closest('[hidden]') && b.getBoundingClientRect().height < 44 && b.getBoundingClientRect().height > 0).map((b) => b.className + ':' + Math.round(b.getBoundingClientRect().height)) }));
219
+ ok(`M12 at 390 px (${theme}) the page has no sideways scroll and every control is 44 px`, !pg.over && pg.small.length === 0, JSON.stringify(pg));
220
+ await R.ctx.close();
221
+ }
222
+ {
223
+ const R = await open(); await R.page.locator('.wrow[data-key="work:item-aaaaaaaaaaa1"] .wrh').first().focus();
224
+ await R.page.keyboard.press('Enter'); await until(() => R.page.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
225
+ ok('M12 the page behind a dialog is inert, Escape closes it and focus returns to the row', await R.page.evaluate(() => ['.rail', 'main.stage', '#dock'].every((s) => document.querySelector(s).inert)) && (await R.page.keyboard.press('Escape'), await until(() => R.page.evaluate(() => !document.querySelector('#dlg').classList.contains('on') && document.activeElement.classList.contains('wrh')))));
226
+ await R.ctx.close();
227
+ }
228
+
229
+ /* the real routes, over a scratch state folder: add, read back, mark done, read back */
230
+ {
231
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
232
+ pg.on('pageerror', (e) => errs.push(String(e)));
233
+ await pg.goto(BASE + '/premium-work.html');
234
+ ok('E1 on an empty scratch state the real page says the list is empty', await until(() => pg.evaluate(() => document.querySelector('#brief').textContent === 'Your list is empty.'), 20000));
235
+ await pg.fill('#capIn', 'Reply to Rina about SSO !p1'); await pg.press('#capIn', 'Enter');
236
+ ok('E2 an added line comes back through the real routes in the tray, with its priority, not counted to clear, and its dialog opens', await until(() => pg.evaluate(() => /Reply to Rina about SSO/.test(document.querySelector('#trayBody').textContent) && /P1/.test(document.querySelector('#trayBody').textContent) && !/Reply to Rina/.test(document.querySelector('#plateBody').textContent) && document.querySelector('#dN').textContent === '0' && document.querySelector('#dlg').classList.contains('on')), 15000));
237
+ await sleep(700);
238
+ await pg.locator('#card [data-p="flag"]').click();
239
+ ok('E3 Do today, pressed in that dialog, moves it from the tray to Today through the real route', await until(() => pg.evaluate(() => { const s = [...document.querySelectorAll('#plateBody .wsec')].find((x) => x.dataset.lane === 'today'); return !!s && /1 open/.test(s.textContent) && /Nothing in your tray/.test(document.querySelector('#trayBody').textContent) && document.querySelector('#dN').textContent === '1'; }), 15000));
240
+ await pg.locator('#plateBody .wck').first().click();
241
+ ok('E4 the check circle marks it done, and the list is empty again', await until(() => pg.evaluate(() => document.querySelector('#brief').textContent === 'Your list is empty.' && document.querySelector('#dN').textContent === '0'), 15000));
242
+ const api = await fetch(BASE + '/api/my-work-premium').then((r) => r.json());
243
+ ok('E5 the model route answers ready with the list the Work page reads', api.ok && api.state === 'ready' && api.model.dock.needs === 0);
244
+ const hdr = await T.pressHeaders(BASE);
245
+ const ask = await fetch(BASE + '/api/briefing-premium/ask', { method: 'POST', headers: hdr, body: JSON.stringify({ key: 'work:item-ghost', text: 'q' }) }).then((r) => r.status);
246
+ ok('E6 a question about an item that is not on the page starts nothing (404)', ask === 404, String(ask));
247
+ const post = await fetch(BASE + '/api/my-work-premium', { method: 'POST', headers: hdr, body: '{}' }).then((r) => r.status);
248
+ ok('E7 the model route is read-only', post === 405, String(post));
249
+ ok('0 no page error against the real routes', errs.length === 0, errs.join(' | '));
250
+ await ctx.close();
251
+ }
252
+
253
+ /* M12 fidelity against the mockup: computed style and geometry of the regions both pages have */
254
+ if (!fs.existsSync(MOCKUP)) ok('M12 the mockup is in the repository', false, MOCKUP);
255
+ else {
256
+ const PAIRS = [['rail', '.rail'], ['name', '.top .name'], ['date', '.hero .date'], ['brief', '.brief'], ['cap', '.cap'], ['capin', '.cap input'], ['capbtn', '.cap .capb'], ['plate', '#plate'], ['heading', '#hPlate'], ['section', '#plateBody .wsec'], ['label', '#plateBody .wsec .label'], ['row', '#plateBody .wrow'], ['title', '#plateBody .wtit'], ['sub', '#plateBody .ws'], ['glyph', '#plateBody .wgl'], ['due', '#plateBody .due:not(.od)'], ['agents', '#agCard'], ['tile', '#pipe .pst:not(.hot)'], ['pipe', '#pipe'], ['waitrow', '#waitBody .wrow'], ['dock', '.dock']];
257
+ const SKIP_GEOM = { 1280: ['row', 'section'], 390: ['heading', 'row', 'section'] };
258
+ const PROPS = ['fontSize', 'fontWeight', 'fontFamily', 'color', 'backgroundColor', 'borderRadius', 'paddingTop', 'paddingLeft', 'letterSpacing', 'lineHeight', 'boxShadow', 'backdropFilter', 'borderTopColor'];
259
+ const grab = (pg, pairs) => pg.evaluate(([pairs, props]) => { const o = {}; for (const [n, s0] of pairs) { const s = (n === 'rail' || n === 'dock') ? s0 : 'main.stage:not([hidden]) ' + s0; 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]);
260
+ for (const [w, h] of [[1280, 900], [390, 844]]) for (const th of ['light', 'dark']) {
261
+ const m = await (await browser.newContext({ viewport: { width: w, height: h } })).newPage();
262
+ await m.goto('file://' + MOCKUP);
263
+ await until(() => m.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
264
+ await m.evaluate((t) => { document.documentElement.dataset.theme = t; document.querySelector('.pl[data-p="work"]').click(); }, th);
265
+ await until(() => m.evaluate(() => document.querySelectorAll('#plateBody .wrow').length > 0)); await sleep(900);
266
+ const R = await open({ w, h, theme: th }); await sleep(500);
267
+ const a = await grab(m, PAIRS), c = await grab(R.page, PAIRS), diffs = [];
268
+ for (const [n] of PAIRS) {
269
+ if (!a[n] || !c[n]) { diffs.push(n + ' missing'); continue; }
270
+ for (const k of Object.keys(a[n])) {
271
+ const geom = k === 'left' || k === 'width';
272
+ if (geom && (SKIP_GEOM[w].includes(n) || ['brief', 'title', 'sub', 'due', 'label', 'glyph', 'waitrow', 'tile', 'capin'].includes(n) && k === 'width' || ['brief', 'title', 'sub'].includes(n) && k === 'left')) continue;
273
+ if (geom ? 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]}`);
274
+ }
275
+ }
276
+ ok(`M12 styles and geometry match the mockup at ${w} px, ${th}`, diffs.length === 0, diffs.join(' ; '));
277
+ await m.context().close(); await R.ctx.close();
278
+ }
279
+ }
280
+ return finish();
281
+ })().catch((e) => { console.error(e); finish(1); });
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+ /* fixtures/board-premium-facts.js — the facts lib/board-premium.js builds from, for a plausible board on 17 Oct 2026, 09:12 local. Shapes follow the routes
3
+ * of lib/routes-boards.js and lib/routes-core-docs.js. Seven initiatives are loaded of nine open; one is late with a recorded end date in the past. */
4
+ const NOW = new Date(2026, 9, 17, 9, 12).getTime();
5
+ const WF = ['Discover', 'Ideate', 'Strategize', 'Prioritize', 'Plan', 'Build', 'Deliver', 'Monitor', 'Evaluate'].map((name, i) => ({ name, cat: ['Discover', 'Discover', 'Plan', 'Plan', 'Plan', 'Do', 'Do', 'Learn', 'Learn'][i] }));
6
+ const row = (id, title, o = {}) => ({ id, title, lane: 'feature', stage: 'Build', cat: 'Do', owner: null, anchor: 'q3-2026:o1:kr1', horizon: 'now', status: 'on track', start: null, end: null, progress: null, warnings: [], rank: 0, health: 'on', updated: '2026-10-15T08:00:00.000Z', reading: 'build', stageSource: 'derived', dupOf: null, ...o });
7
+ const ROWS = [
8
+ row('self-serve-v2', 'Self-serve onboarding v2', { owner: 'Dara', stage: 'Build', status: 'late', start: '2026-09-01', end: '2026-10-10', progress: 62 }),
9
+ row('partner-rate-limits', 'Partner API rate limits', { owner: 'Lee', stage: 'Plan', cat: 'Plan', status: 'at risk', start: '2026-09-20', end: '2026-11-30', progress: 18 }),
10
+ row('usage-billing', 'Usage-based billing', { stage: 'Discover', cat: 'Discover', status: 'at risk' }),
11
+ row('sso-partners', 'SSO for partners', { owner: 'Lee', stage: 'Deliver', status: 'on track', start: '2026-08-15', end: '2026-10-23', progress: 80 }),
12
+ row('audit-log-export', 'Audit log export', { owner: 'Dara', stage: 'Monitor', cat: 'Learn', status: 'on track' }),
13
+ row('refund-reason-codes', 'Refund reason codes', { stage: 'Evaluate', cat: 'Learn', status: 'on track', progress: 100, dupOf: 'refund-flow' }),
14
+ row('docs-search', 'Docs search', { lane: 'chore', stage: 'Build', status: 'on track', warnings: ['no recorded owner'] }),
15
+ ];
16
+ const columns = () => WF.map((s) => { const rs = ROWS.filter((r) => r.stage === s.name); return { stage: s.name, cat: s.cat, hint: 'Derived by scripts/workflow-state.js; not moved by hand.', count: rs.length + (s.name === 'Evaluate' ? 20 : 0), rows: rs }; });
17
+ const doc = (key, title, o = {}) => ({ key, title, ic: 'doc', for: 'What ' + title + ' is for.', reader: 'Team', cadence: 'Every two weeks', by: 'drafts', kind: 'doc', st: 'missing', act: { label: 'Draft it', do: 'draft' }, running: false, open: null, ...o });
18
+ const DOCS = [
19
+ doc('charter', 'Board charter', { cadence: 'When the board changes' }),
20
+ doc('backlog', 'Roadmap and backlog', { by: 'live', kind: 'live', st: 'ok', act: null, open: { tab: 'initiatives' } }),
21
+ doc('bstatus', 'Board status report', { kind: 'report', act: { label: 'Set it up', go: 'reports' } }),
22
+ doc('capacity', 'Capacity plan', { st: 'draft', act: { label: 'Review the draft', do: 'review' } }),
23
+ doc('brisks', 'Board risks', { st: 'stale' }),
24
+ doc('retro', 'Board retro', { running: true }),
25
+ ];
26
+ function facts(o = {}) {
27
+ return {
28
+ now: NOW, pending: false, missing: [],
29
+ boards: [{ ws: 'w1', id: 'w1', name: 'pmos' }],
30
+ board: { ws: 'w1', id: 'w1', name: 'pmos', workflow: WF },
31
+ counts: { total: 30, open: 9, byStage: {}, byStatus: { 'on track': 5, 'at risk': 3, late: 1, 'on hold': 0, done: 21, cancelled: 0 } },
32
+ rows: ROWS.map((r) => ({ ...r })), total: 7, columns: columns(),
33
+ sprints: { source: 'none', readAt: null, sprints: [] },
34
+ docs: { scope: 'board:w1', summary: { draft: 1, due: 0, missing: 4, stale: 1 }, docs: DOCS.map((d) => ({ ...d })) },
35
+ nextRelease: null, ...o,
36
+ };
37
+ }
38
+ module.exports = { NOW, facts, row, ROWS, WF, doc };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ /* fixtures/briefing-premium-facts.js — the facts the premium Briefing suites build the page from (initiative briefing-premium).
3
+ * They reproduce the shape of the mockup's snapshot: two blocked initiatives, a prototype waiting for a disposition with a meeting that
4
+ * needs it, one missed kill-condition date, five failed reviews, merges and runs today, one meeting ahead and one earlier. */
5
+ const NOW = new Date(2026, 9, 15, 18, 5).getTime();
6
+ const at = (h, m) => new Date(2026, 9, 15, h, m).getTime();
7
+ const day = (d) => NOW - d * 86400000;
8
+ const iso = (ms) => new Date(ms).toISOString();
9
+ const init = (id, o = {}) => ({ id, title: id + ' title', lane: 'feature', stage: 'build', accepted: false, stopped: false, legacy: false, anchor: 'q3-2026:o1:kr1', last_activity: Math.floor(day(2) / 1000), blockers: [], steps: [], ...o });
10
+ const blocked = (id, key, o = {}) => init(id, { blockers: [key], steps: [{ key, status: 'blocked', next: 'do the ' + key + ' first', blocking: true }], ...o });
11
+ const runs = (k, from, to, corr) => Array.from({ length: k }, (_, i) => ({ initiative_id: 'x', created_at: iso(from + (to - from) * (i / k)), human_corrections: corr(i) }));
12
+
13
+ function facts() {
14
+ return {
15
+ now: NOW, asOf: NOW,
16
+ initiatives: [
17
+ blocked('console-3-boards', 'spec', { title: 'Console 3 Initiatives as Portfolio, Board and Initiative' }),
18
+ blocked('onboarding-scan', 'prototype', { title: 'Onboarding scan and map' }),
19
+ init('premium-ux', { lane: 'prototype', stage: 'accept', title: 'Premium UX concept — principles and design system' }),
20
+ init('idle-1', { last_activity: Math.floor(day(40) / 1000) }), init('idle-2', { last_activity: Math.floor(day(50) / 1000) }),
21
+ ...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => init('up-' + n, { anchor: n % 2 ? 'health:tech' : 'health:ops' })),
22
+ init('f-1'), init('f-2'), init('f-3'), init('f-4'), init('f-5'),
23
+ ],
24
+ runs: runs(20, day(14), day(7) - 1, (i) => (i < 4 ? 1 : 0)).concat(runs(20, day(7), at(0, 0) - 1, (i) => (i < 2 ? 1 : 0))),
25
+ merges: [{ at: at(18, 0), title: 'One ranking for every Briefing list' }, { at: at(17, 38), title: 'Cut server lag: gzip, ETag, memoized delivery trunk' }, { at: at(15, 57), title: 'Browser suites never open the PM’s own Chrome' }],
26
+ evals: ['f-1', 'f-2', 'f-3', 'f-4', 'f-5'].map((id, i) => ({ initiative_id: id, verdict: 'FAIL', recorded_at: iso(day(71 + i * 1.5)) })),
27
+ meetings: [{ start: at(18, 30), end: at(19, 15), title: 'Design review', with: 'Lee, Priya', initiative: 'premium-ux' }, { start: at(14, 0), end: at(15, 0), title: 'Sprint planning', with: 'Dara, Priya, Sam', initiative: null }],
28
+ tripwires: [{ id: 'KC5', name: 'Evaluator leniency — κ stalls', date: '2026-09-30', decided: false }],
29
+ missing: [],
30
+ };
31
+ }
32
+ module.exports = { facts, NOW };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ /* fixtures/initiative-premium-facts.js — the facts lib/initiative-premium.js builds from, for one plausible initiative on 17 Oct 2026, 09:12 local.
3
+ * Shapes follow the routes of lib/routes-boards.js (initiative, items), lib/routes-initiatives.js (sheet) and lib/routes-core-docs.js (initiative level). */
4
+ const NOW = new Date(2026, 9, 17, 9, 12).getTime();
5
+ const WF = ['Discover', 'Ideate', 'Strategize', 'Prioritize', 'Plan', 'Build', 'Deliver', 'Monitor', 'Evaluate'].map((name, i) => ({ name, cat: ['Discover', 'Discover', 'Plan', 'Plan', 'Plan', 'Do', 'Do', 'Learn', 'Learn'][i] }));
6
+ const ROW = { id: 'self-serve-v2', title: 'Self-serve onboarding v2', lane: 'feature', stage: 'Build', cat: 'Do', owner: 'Dara', anchor: 'q3-2026:o1:kr1', horizon: 'now', status: 'at risk', start: '2026-09-01', end: '2026-10-30', progress: 40, warnings: ['no recorded owner for the rubric'], stageSource: 'derived', workflow: WF };
7
+ const SHEET = {
8
+ initiative: { id: 'self-serve-v2', health: { level: 'warn', reasons: ['the contract is not approved'] }, progress: { done: 2, total: 5, label: '2 of 5 · from self-serve-v2-prd.md' }, display: { word: 'Build', line2: 'The contract waits for you' } },
9
+ stepper: [{ id: 'intake', label: 'Intake', state: 'done', owed: null }, { id: 'spec', label: 'Spec', state: 'done', owed: null }, { id: 'contract', label: 'Contract', state: 'current', owed: 'Approve the contract' }, { id: 'build', label: 'Build', state: 'todo', owed: null }, { id: 'evaluate', label: 'Evaluate', state: 'todo', owed: null }],
10
+ waiting: { on: 'pm', act: 'contract', action: { label: 'Approve the contract', kind: 'accept' } },
11
+ files: [{ kind: 'prd', label: 'PRD', path: 'planning/prd/self-serve-v2-prd.md', source: 'record' }, { kind: 'record', label: 'Record', path: 'planning/initiatives/self-serve-v2.md', source: 'record' }],
12
+ derived: { source: 'planning/prd/self-serve-v2-prd.md', basis: 'no build is evidenced on the trunk yet', state: 'todo', items: [{ id: 'R1', ref: 'R1', title: 'A partner reaches a first call' }, { id: 'R2', ref: 'R2', title: 'The key is shown once' }, { id: 'R3', ref: 'R3', title: 'Errors say what to do' }] },
13
+ };
14
+ const ITEMS = [
15
+ { uid: 'ONB-12', key: 'ONB-12', kind: 'Story', src: 'jira', title: 'Sandbox key step', status: 'In progress', blocked: false, owner: null, ini: 'self-serve-v2', start: null, end: null },
16
+ { uid: 'ONB-13', key: 'ONB-13', kind: 'Task', src: 'jira', title: 'Checklist empty state', status: 'Done', blocked: false, owner: null, ini: 'self-serve-v2', start: null, end: null },
17
+ { uid: 't1', key: '', kind: 'Task', src: 'pmos', title: 'UAT with five partners', status: 'To do', blocked: false, owner: 'Dara', ini: 'self-serve-v2', start: '2026-10-20', end: '2026-10-27', ref: 't1' },
18
+ { uid: 'X-1', key: 'X-1', kind: 'Story', src: 'jira', title: 'Another initiative', status: 'To do', blocked: false, owner: null, ini: 'other', start: null, end: null },
19
+ ];
20
+ const LADDER = { steps: [
21
+ { key: 'spec', label: 'spec', status: 'done', evidence: 'planning/prd/self-serve-v2-prd.md + planning/evals/self-serve-v2-eval-rubric.md — first-appeared at or before the first build commit', rule: null, basis: { rule: 'The PRD and rubric named in the record exist.' } },
22
+ { key: 'contract', label: 'contract', status: 'pending', evidence: '1 run(s) logged, none with pre_run_contract.PM_approved: true', rule: null, basis: { rule: 'A run holds done criteria and a test method with PM_approved set.' } },
23
+ { key: 'build', label: 'build', status: 'pending', evidence: 'no implementation commits in this range', rule: null, basis: { rule: 'Commits attributed to this initiative change a build path.' } },
24
+ { key: 'quality', label: 'quality', status: 'unknown', evidence: 'CI decides on the PR — this engine never claims a test result it did not run', rule: null, basis: { rule: 'CI decides.' } },
25
+ { key: 'review', label: 'review', status: 'na', evidence: 'no Review-Gate verdict — it runs on request', rule: null, basis: { rule: 'evals.jsonl holds a verdict.' } },
26
+ { key: 'accept', label: 'accept', status: 'pending', evidence: 'no acceptance record needed (D94)', rule: null, basis: { rule: 'acceptances.jsonl holds an accepted line.' } },
27
+ { key: 'route', label: 'route', status: 'done', evidence: 'feature lane', rule: null, basis: { rule: 'The lane is one this repository offers.' } },
28
+ ], next: { step: 'contract', why: 'run(s) logged, none with pre_run_contract.PM_approved: true', action: 'agree done_criteria + test_method with the PM and record it in planning/runs/agent-runs.jsonl' } };
29
+ const doc = (key, title, o = {}) => Object.assign({ cat: 'Plan', key, title, ic: 'doc', for: 'What ' + title + ' is for.', by: 'drafts', kind: 'derived', st: 'missing', when: null, open: null, act: { label: 'Draft it', do: 'draft' }, running: false }, o);
30
+ const DOCS = { scope: 'initiative:self-serve-v2', docs: [
31
+ doc('record', 'Initiative record', { st: 'ok', when: '2026-10-16', open: { path: 'planning/initiatives/self-serve-v2.md' }, act: null }),
32
+ doc('problem', 'Problem statement', { cat: 'Discover' }),
33
+ doc('research', 'Research notes', { cat: 'Discover' }),
34
+ doc('prd', 'PRD', { st: 'ok', when: '2026-10-17', open: { path: 'planning/prd/self-serve-v2-prd.md' }, act: null }),
35
+ doc('risks', 'Risks', { st: 'draft', act: { label: 'Review the draft', do: 'review' } }),
36
+ doc('launch', 'Launch checklist and comms', { running: true }),
37
+ doc('rubric', 'Eval rubric', { st: 'ok', when: '2026-10-16', open: { path: 'planning/evals/self-serve-v2-eval-rubric.md' }, act: null }),
38
+ ] };
39
+ const BOARDS = [{ ws: 'w1', id: 'w1', name: 'Onboarding' }, { ws: 'w2', id: 'w2', name: 'Payments' }];
40
+ const SIBLINGS = [{ id: 'self-serve-v2', title: 'Self-serve onboarding v2' }, { id: 'partner-rate-limits', title: 'Partner API rate limits' }, { id: 'usage-billing', title: 'Usage-based billing' }];
41
+ const facts = (o = {}) => Object.assign({ now: NOW, board: { ws: 'w1', id: 'w1', name: 'Onboarding', workflow: WF }, row: ROW, sheet: SHEET, items: ITEMS, docs: DOCS, ladder: LADDER, boards: BOARDS, siblings: SIBLINGS, missing: [], pending: false }, o);
42
+ module.exports = { LADDER, BOARDS, SIBLINGS, facts, NOW, WF, ROW, SHEET, ITEMS, DOCS };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+ /* fixtures/my-work-premium-facts.js — the answer of GET /api/pm-items for a plausible day, shared by the unit and browser tests of the premium My work page.
3
+ * 16 Oct 2026, 18:05 local. Four in the inbox (one of them the PM's own capture, which waits in the tray), three due or flagged today (one overdue with an agent draft ready), two upcoming, two waiting, one someday. */
4
+ const NOW = new Date(2026, 9, 16, 18, 5).getTime();
5
+ const A = (state, extra) => ({ state, suggest: null, instruction: null, draft: null, sent: null, error: null, session: null, asks: null, revisions: 0, started_at: null, decided_at: null, log: null, ...extra });
6
+ const it = (id, title, o = {}) => ({ id, title, due: null, priority: null, waiting_on: null, job: null, source: 'local', created_at: '2026-10-14T09:00:00.000Z', touched_at: '2026-10-14T09:00:00.000Z', steps: [], agent: A('suggest'), ...o });
7
+ function view() {
8
+ const inbox = [
9
+ it('item-aaaaaaaaaaa1', 'Decide how partial refunds are handled', { source: 'jira', created_at: '2026-10-16T08:00:00.000Z', touched_at: '2026-10-16T08:00:00.000Z' }),
10
+ it('item-aaaaaaaaaaa2', 'Write acceptance criteria for partner rate limits', { source: 'jira', created_at: '2026-10-16T09:30:00.000Z', touched_at: '2026-10-16T09:30:00.000Z' }),
11
+ it('item-aaaaaaaaaaa3', 'Rina asks if the SSO beta can move to November', { source: 'slack', created_at: '2026-10-16T17:20:00.000Z', touched_at: '2026-10-16T17:20:00.000Z' }),
12
+ it('item-aaaaaaaaaaa4', 'Is the payment-step experiment worth another week?', { created_at: '2026-10-16T17:50:00.000Z', touched_at: '2026-10-16T17:50:00.000Z' }),
13
+ ];
14
+ const today = [
15
+ it('wbs:alpha:4.2', 'Sign off the fit-out contractor shortlist', { source: 'wbs', due: '2026-10-15', priority: 'P1', agent: A('ready', { draft: { summary: 'Shortlist sign-off', body: 'Two of the three quotes meet the brief. I would sign off A and B.' } }) }),
16
+ it('item-bbbbbbbbbbb2', 'Prepare for the Design review', { due: '2026-10-16' }),
17
+ it('item-bbbbbbbbbbb3', 'Send the revised roadmap to Finance', { flagged: true, agent: A('working') }),
18
+ ];
19
+ const upcoming = [
20
+ it('item-ccccccccccc1', 'Choose the win-back email subject line', { due: '2026-10-19' }),
21
+ it('item-ccccccccccc2', 'Outline the weekly partner digest', { due: '2026-11-02', agent: A('failed', { error: 'the run ended' }) }),
22
+ ];
23
+ const waiting = [
24
+ it('item-ddddddddddd1', 'Review the partner data-sharing clause', { waiting_on: 'Dara', waiting_since: '2026-10-08T09:00:00.000Z', touched_at: '2026-10-08T09:00:00.000Z' }),
25
+ it('item-ddddddddddd2', 'Send the funnel cut', { waiting_on: 'Priya', waiting_since: '2026-10-10T09:00:00.000Z', touched_at: '2026-10-10T09:00:00.000Z' }),
26
+ ];
27
+ const someday = [it('item-eeeeeeeeeee1', 'Rework the onboarding checklist', { parked: true })];
28
+ const review = [waiting[0], someday[0]].map((x) => ({ id: x.id }));
29
+ return { ok: true, today: '2026-10-16', lists: {}, jobs: [], items: [], work: { inbox, today, upcoming, waiting, someday }, review, review_days: 7, focus: {}, counts: { inbox: 4, today: 3, upcoming: 2, waiting: 2, someday: 1, review: 2 } };
30
+ }
31
+ module.exports = { NOW, view, it, A };