create-pmos 0.46.0 → 0.48.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 (83) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +1 -1
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +13 -3
  7. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
  8. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
  9. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
  10. package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
  12. package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
  13. package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
  14. package/payload/okf/core/concepts/frontmatter-format.md +6 -1
  15. package/payload/planning/evals/eval-calibration.md +1 -1
  16. package/payload/planning/okrs/health-budgets.md +1 -1
  17. package/payload/scripts/pmos-log.sh +18 -8
  18. package/payload/scripts/pmos-model.js +233 -2
  19. package/payload/scripts/status-facts.js +204 -0
  20. package/payload/scripts/workflow-state.js +52 -0
  21. package/payload/skills/SKILL-FORMAT.md +1 -0
  22. package/payload/state/discoveries.md +1 -1
  23. package/workspace/README.md +3 -3
  24. package/workspace/bin/live-notes.js +133 -0
  25. package/workspace/design-parity.js +328 -21
  26. package/workspace/doc-category-bench.js +165 -0
  27. package/workspace/fixtures/doc-category-labelled.json +167 -0
  28. package/workspace/guide/initiatives-and-next-steps.md +16 -2
  29. package/workspace/guide/knowledge-graph.md +89 -2
  30. package/workspace/guide/layout.md +30 -2
  31. package/workspace/guide/proactive-engine.md +26 -4
  32. package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
  33. package/workspace/guide/statuses.md +1 -0
  34. package/workspace/guide/validate-with-typesafe.md +17 -1
  35. package/workspace/lib/claims.js +51 -2
  36. package/workspace/lib/doc-category-corrections.js +113 -0
  37. package/workspace/lib/doc-category-writeback.js +110 -0
  38. package/workspace/lib/doc-category.js +225 -0
  39. package/workspace/lib/engine/pmos-model.js +233 -2
  40. package/workspace/lib/engine/status-facts.js +204 -0
  41. package/workspace/lib/engine/workflow-state.js +52 -0
  42. package/workspace/lib/jev-categorize.js +399 -0
  43. package/workspace/lib/jev-claims.js +59 -15
  44. package/workspace/lib/jev-route.js +129 -0
  45. package/workspace/lib/kg-cli.js +5 -2
  46. package/workspace/lib/kg-dangling-baseline.json +1 -0
  47. package/workspace/lib/kg-dangling-rules.json +1 -0
  48. package/workspace/lib/kg.js +26 -9
  49. package/workspace/lib/learn.js +282 -0
  50. package/workspace/lib/live-notes.js +890 -0
  51. package/workspace/lib/proactive-cli.js +49 -1
  52. package/workspace/lib/proactive.js +8 -7
  53. package/workspace/lib/typesafe-settings.js +10 -2
  54. package/workspace/package-lock.json +1 -1
  55. package/workspace/package.json +5 -4
  56. package/workspace/public/pm/app.js +148 -79
  57. package/workspace/public/pm/data.js +58 -0
  58. package/workspace/public/pm/fixture.js +40 -2
  59. package/workspace/public/pm/modes-live.js +267 -21
  60. package/workspace/public/pm/panel-cockpit.js +636 -0
  61. package/workspace/public/pm/panel-detail.js +210 -8
  62. package/workspace/public/pm/panel-initiatives.js +9 -9
  63. package/workspace/public/pm/panel-newsess.js +145 -86
  64. package/workspace/public/pm/panel-repos.js +34 -17
  65. package/workspace/public/pm/panel-tabs.js +145 -27
  66. package/workspace/public/pm/state.js +45 -4
  67. package/workspace/public/pm/tab-doc.js +244 -16
  68. package/workspace/public/pm/tab-term.js +11 -1
  69. package/workspace/public/pm/tokens.css +4 -3
  70. package/workspace/public/pm/view-addrepo.js +207 -0
  71. package/workspace/public/pm/view-files.js +197 -0
  72. package/workspace/public/pm/view-guide.js +141 -0
  73. package/workspace/public/pm/view-knowledge.js +225 -0
  74. package/workspace/public/pm/view-memory.js +122 -0
  75. package/workspace/public/pm/view-portfolio.js +153 -0
  76. package/workspace/public/pm/view-reports.js +190 -0
  77. package/workspace/public/pm/view-scheduled.js +161 -0
  78. package/workspace/public/pm/view-skills.js +216 -0
  79. package/workspace/public/pm/view-today.js +168 -0
  80. package/workspace/public/pm/view-validate.js +171 -0
  81. package/workspace/server.js +394 -10
  82. package/workspace/skills/live-notes.skill +58 -0
  83. package/workspace/skills/morning-brief.skill +1 -0
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /* live-notes — Run 1 of jev-live-notes: place the talking points of a conversation against a repository's open
3
+ * initiatives, as proposals. No page, no microphone, no audio, no network call.
4
+ *
5
+ * node bin/live-notes.js run --repo <dir> [--segments <file>|-] [--answers <file>] [--session <id>] [--anchor <a>] [--json]
6
+ * node bin/live-notes.js decide --repo <dir> --session <id> --segment <id> --action accept|change|reject [--initiative <id>] [--relation <word>]
7
+ * node bin/live-notes.js close --repo <dir> --session <id>
8
+ * node bin/live-notes.js ingest --repo <dir> --transcript <file> --source <name> --session <id> a transcript, only what is new (Run 4)
9
+ * node bin/live-notes.js list --repo <dir> --session <id> [--json] the ranked list (Run 2)
10
+ * node bin/live-notes.js rate --repo <dir> --session <id>
11
+ * node bin/live-notes.js plan --repo <dir> --session <id> [--json] what an agent should write, on which tier (Run 3)
12
+ * node bin/live-notes.js context --repo <dir> --session <id> --answers <file> LIVECONTEXT and LIVECANDIDATE lines (Run 3)
13
+ * node bin/live-notes.js switch --repo <dir> on|off [--retention-days <n>]
14
+ *
15
+ * Segments are one per line: plain text, or a JSON object {"text","at","speaker","source","private"}.
16
+ * Without `--answers`, each point is left `pending` and the prompt for an agent session is printed; the agent's
17
+ * `LIVENOTE {json}` lines go back in with `--answers`. When all three gates hold (a key, consent for the repository,
18
+ * and `switch on`), the prompt asks that agent session to make the two choices with the Typesafe skill (Run 5). This
19
+ * command sends nothing itself and injects no sender. The key is never read, only whether one exists.
20
+ */
21
+ 'use strict';
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const live = require('../lib/live-notes');
25
+ const settings = require('../lib/typesafe-settings');
26
+ const { defaultRoot } = require('../lib/state-root');
27
+
28
+ const argv = process.argv.slice(2);
29
+ const cmd = argv[0];
30
+ const opt = (n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
31
+ const flag = (n) => argv.includes('--' + n);
32
+ const die = (m, c = 2) => { console.error('live-notes: ' + m); process.exit(c); };
33
+
34
+ if (!['run', 'decide', 'close', 'rate', 'switch', 'plan', 'context', 'ingest', 'list'].includes(cmd)) die('commands: run, ingest, list, decide, close, rate, switch, plan, context (see the header of this file)');
35
+ const root = path.resolve(opt('repo', process.cwd()));
36
+ if (!fs.existsSync(root)) die('no such directory: ' + root);
37
+ const runtimeDir = defaultRoot();
38
+ const exclude = settings.getRepo(runtimeDir, root).exclude;
39
+ const consent = settings.getRepo(runtimeDir, root).consent;
40
+ const sw = live.getSwitch(runtimeDir, root);
41
+ live.purgeExpired(runtimeDir);
42
+
43
+ function session(id, extra = {}) {
44
+ return live.openSession({ root, runtimeDir, session: id, key: settings.status(runtimeDir, process.env).present ? 'present' : null,
45
+ flags: { consent, live: sw.live }, exclude, anchor: opt('anchor'), ask: null, ...extra });
46
+ }
47
+
48
+ (async () => {
49
+ try {
50
+ if (cmd === 'switch') {
51
+ const pos = argv.filter((x, i) => !x.startsWith('--') && !(argv[i - 1] || '').startsWith('--'));
52
+ const on = pos[1] === 'on' ? true : pos[1] === 'off' ? false : null;
53
+ if (on === null && !opt('retention-days')) die('say on or off, or --retention-days <n>');
54
+ const r = live.setSwitch(runtimeDir, root, { live: on === null ? undefined : on, retention_days: opt('retention-days') ? Number(opt('retention-days')) : undefined });
55
+ console.log(`live notes may use Jev: ${r.live ? 'on' : 'off'} · raw transcript kept ${r.retention_days ? r.retention_days + ' day(s) after close' : 'only until close'}`);
56
+ return;
57
+ }
58
+ const id = opt('session');
59
+ if (cmd === 'run' || cmd === 'ingest') {
60
+ const src = cmd === 'ingest' ? opt('transcript') : opt('segments');
61
+ if (cmd === 'ingest' && !src) die('--transcript <file> is required');
62
+ if (!src && !opt('answers')) die('give --segments <file>|- , --answers <file>, or both');
63
+ const probe = session(id);
64
+ const out = { session: probe.id, path: probe.path, gates_missing: probe.gates.missing, withheld: [], pending: [], error: null, rows: [], rejected: [] };
65
+ let prompt = null;
66
+ let s = probe;
67
+ if (src) {
68
+ const text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
69
+ const sourceName = opt('source') || (src === '-' ? 'stdin' : path.basename(src));
70
+ const raw = cmd === 'ingest' ? live.segmentsFromTranscript(text, { source: sourceName }) : text.split(/\r?\n/).filter((l) => l.trim()).map((l) => { if (l.trim().startsWith('{')) { try { return JSON.parse(l); } catch { /* plain */ } } return l; });
71
+ const r = await s.add(raw, { source: sourceName, dedupe: cmd === 'ingest' });
72
+ out.skipped_duplicates = r.skipped_duplicates;
73
+ Object.assign(out, { withheld: r.withheld, pending: r.pending, error: r.error || null, rows: r.rows });
74
+ prompt = r.prompt;
75
+ }
76
+ if (opt('answers')) {
77
+ const ans = live.parseAgentAnswers(fs.readFileSync(opt('answers'), 'utf8'), s.items.map((i) => i.id));
78
+ out.rejected = ans.rejected;
79
+ const r = s.answer(ans.answers);
80
+ out.rows = out.rows.concat(r.answered);
81
+ out.pending = out.pending.filter((x) => !r.answered.some((a) => a.segment === x));
82
+ if (r.unknown.length) out.unknown_segments = r.unknown;
83
+ if (r.answered.length) prompt = null;
84
+ }
85
+ if (flag('json')) { console.log(JSON.stringify({ ...out, prompt }, null, 2)); return; }
86
+ const why = out.path === 'jev-agent' ? 'the prompt asks your agent session to use the Typesafe skill; this command sends nothing itself' : live.gateSentence(out.gates_missing) || 'all gates are open';
87
+ console.log(`session ${out.session} · path: ${out.path} (${why})`);
88
+ if (out.error) console.log('stopped: ' + out.error);
89
+ if (out.skipped_duplicates) console.log(`skipped ${out.skipped_duplicates} point(s) already in this session`);
90
+ if (out.rejected.length) console.log(`rejected ${out.rejected.length} off-shape answer line(s): ${out.rejected.join(', ')}`);
91
+ for (const x of out.rows) console.log(` ${x.segment}: ${x.status === 'proposed' ? `${x.relation} ${x.initiative} (${x.relation_band}) · ${x.confidence}${x.in_scope === false ? ' · outside scope' : ''}` : x.status}`);
92
+ for (const w of out.withheld) console.log(` ${w.id}: withheld — ${w.reason}`);
93
+ if (prompt) console.log('\nGive this to an agent session, then pass its LIVENOTE lines back with --answers <file> --session ' + out.session + ':\n\n' + prompt);
94
+ return;
95
+ }
96
+ if (!id) die('--session <id> is required');
97
+ { const known = live.apiList({ root, session: id }); if (!known.ok) die(known.error, 1); } // a command on a session that does not exist creates nothing
98
+ const s = session(id);
99
+ if (cmd === 'decide') { const row = s.decide({ segment: opt('segment'), action: opt('action'), initiative: opt('initiative'), relation: opt('relation') }); console.log(`recorded: ${row.action} ${row.segment}${row.overridden ? ' (override)' : ''}`); return; }
100
+ if (cmd === 'close') { const r = s.close({ retentionDays: sw.retention_days }); console.log(r.already ? 'already closed' : `closed · note: ${r.note} · raw transcript: ${r.transcript}`); return; }
101
+ if (cmd === 'list') {
102
+ const r = live.apiList({ root, session: id });
103
+ if (!r.ok) die(r.error, 1);
104
+ if (flag('json')) { console.log(JSON.stringify(r.ranked, null, 2)); return; }
105
+ const k = r.ranked;
106
+ console.log(`session ${id} · path: ${k.path}${k.closed ? ' · closed' : ''} · ${k.counts.points} point(s), ${k.counts.pending} not yet answered, ${k.withheld} withheld${k.paused ? ' · PAUSED: ' + k.paused.reason : ''}`);
107
+ for (const sec of k.sections) {
108
+ console.log(`\n${sec.title}`);
109
+ for (const x of sec.items) console.log(` ${x.segment}: "${x.quote}"${x.initiative && x.initiative !== 'none' ? ` — ${x.relation} ${x.initiative} (${x.relation_band})` : ''}${x.in_scope === false ? ' · outside scope' : ''}${x.decision ? ' · PM: ' + x.decision.action : ''}${x.context ? ' · note: ' + x.context.note : ''}`);
110
+ }
111
+ if (k.candidates.length) { console.log('\nPossible new initiatives'); for (const c of k.candidates) console.log(` ${c.candidate}: ${c.draft ? '"' + c.draft.title + '" (' + c.draft.lane + ')' : c.topic_terms.join(', ')} · ${c.segments.length} points · prior-rejection check found ${c.prior_rejection_found}`); }
112
+ return;
113
+ }
114
+ if (cmd === 'plan') {
115
+ const p = s.plan();
116
+ if (flag('json')) { console.log(JSON.stringify(p, null, 2)); return; }
117
+ console.log(`tagging: ${p.tagging_tier} tier · ${p.context.filter((x) => !x.done).length} context note(s) and ${p.candidates.filter((x) => !x.done).length} candidate draft(s) to write`);
118
+ for (const x of p.context) console.log(` ${x.done ? '✓' : '·'} ${x.segment} [${x.tier}] ${x.reasons.join('; ')} — read ${x.read.join(', ')}`);
119
+ for (const x of p.candidates) console.log(` ${x.done ? '✓' : '·'} ${x.candidate} [${x.tier}] ${x.topic_terms.join(', ')}`);
120
+ return;
121
+ }
122
+ if (cmd === 'context') {
123
+ if (!opt('answers')) die('--answers <file> is required');
124
+ const text = fs.readFileSync(opt('answers'), 'utf8');
125
+ const c = s.addContext(live.parseContext(text).entries);
126
+ const d = s.addDrafts(live.parseDrafts(text).entries);
127
+ console.log(`accepted ${c.accepted.length} context note(s) and ${d.accepted.length} candidate draft(s)`);
128
+ for (const r of c.rejected.concat(d.rejected)) console.log(` rejected ${r.segment || r.candidate || '?'}: ${r.reason}`);
129
+ return;
130
+ }
131
+ if (cmd === 'rate') { console.log(JSON.stringify(live.overrideRate(s.ledger()), null, 2)); return; }
132
+ } catch (e) { die(e.message, 1); }
133
+ })();
@@ -25,7 +25,10 @@
25
25
  * design difference. Babel is not vendored because the prototype does not need it (verified: the
26
26
  * render is node-for-node identical without it).
27
27
  *
28
- * node design-parity.js both themes, default state
28
+ * node design-parity.js --board=WsA the cockpit mockup's Option A board (planning/design/cockpit-mockup/), dark, 1600x900,
29
+ * against `?fixture=cockpit`. This is `npm run test:parity` and the BLOCKING CI step.
30
+ * node design-parity.js the four-panel prototype (the layout workspace-cockpit-nav replaced), both themes,
31
+ * against `?fixture=proto`. `npm run test:parity:four-panel`; no longer in CI.
29
32
  * node design-parity.js --theme=light one theme
30
33
  * node design-parity.js --states also walk the interaction states (see STATES below)
31
34
  * node design-parity.js --state=<id> one named state
@@ -52,15 +55,19 @@ const os = require('os');
52
55
 
53
56
  const ROOT = path.resolve(__dirname, '..');
54
57
  const DESIGN = path.join(ROOT, 'planning', 'design', 'four-panel');
58
+ const COCKPIT = path.join(ROOT, 'planning', 'design', 'cockpit-mockup');
55
59
  const FONTS = path.join(__dirname, 'public', 'vendor');
56
60
  const APP_URL = process.env.PMOS_PARITY_APP || 'http://127.0.0.1:7433';
57
61
  const REF_PORT = +(process.env.PMOS_PARITY_PORT || 8899);
58
62
  const OUT = process.env.PMOS_PARITY_OUT || path.join(os.tmpdir(), 'pmos-design-parity');
59
63
 
60
64
  const argv = process.argv.slice(2);
61
- const themes = argv.some((a) => a.startsWith('--theme=')) ? [argv.find((a) => a.startsWith('--theme=')).split('=')[1]] : ['dark', 'light'];
65
+ /* `--board=<Name>` points the check at a board of the cockpit mockup instead of the four-panel prototype. The boards are static, drawn
66
+ * at 1600x900 in the dark theme only, so a board run is one theme, one state, at that size. */
67
+ const BOARD = (argv.find((a) => a.startsWith('--board=')) || '').split('=')[1] || null;
68
+ const themes = BOARD ? ['dark'] : argv.some((a) => a.startsWith('--theme=')) ? [argv.find((a) => a.startsWith('--theme=')).split('=')[1]] : ['dark', 'light'];
62
69
  const KEEP = argv.includes('--keep');
63
- const W = 1600, H = 1000;
70
+ const W = 1600, H = BOARD ? 900 : 1000;
64
71
 
65
72
  /* THE CAPTURED INTERACTION STATES.
66
73
  *
@@ -85,7 +92,7 @@ const STATES = [
85
92
  { id: 'add-repository', steps: ['add repository'] },
86
93
  ];
87
94
  const stateArg = argv.find((a) => a.startsWith('--state='));
88
- const states = stateArg
95
+ const states = BOARD ? STATES.filter((s) => s.id === 'default') : stateArg
89
96
  ? STATES.filter((s) => s.id === stateArg.split('=')[1])
90
97
  : (argv.includes('--states') ? STATES : STATES.filter((s) => s.id === 'default'));
91
98
  if (!states.length) {
@@ -101,12 +108,190 @@ const PROPS = [
101
108
  'lineHeight', 'letterSpacing', 'textTransform', 'writingMode', 'overflowX', 'overflowY',
102
109
  ];
103
110
 
111
+ /* ── THE EXCLUSIONS: differences that are DECISIONS, named one by one ──────────────────────────────────────────────────────────
112
+ *
113
+ * A board run compares the app against a picture the PM drew before some later decisions were taken. Where the app differs from the
114
+ * picture BECAUSE OF A DECISION (a PRD requirement, a PM answer, a feature a later run added on purpose), the difference is listed
115
+ * here, by name, with the decision that makes it one. Nothing else is excluded, and no tolerance is loosened: the four axes, the 2px
116
+ * geometry bound and the 0.5% pixel bound are the same for every run.
117
+ *
118
+ * What an exclusion does, and only for the element or phrase it names:
119
+ * text the named element's text (or the named phrase) is left out of that page's word stream
120
+ * style the named element and everything inside it are not paired or measured
121
+ * pixels the named element's box, on the page where it is found, is painted identically on both screenshots before the diff
122
+ *
123
+ * A NARROW exclusion (`only`) leaves the element in every axis and skips just the listed comparisons on it — a list of computed-style
124
+ * properties, or `box` for its geometry. Its text is compared, the rest of its style is compared, and its pixels are diffed.
125
+ *
126
+ * Each side is located the way a person would point at it. The app side uses the app's own stable hooks (`sel`). The board has no
127
+ * hooks, so its side is anchored on visible text: `own` is the element whose own text is exactly that string, `up` climbs to an
128
+ * ancestor, `child` steps down to one of its children (an element with no text of its own, such as a dot), and `phrase` is a run of
129
+ * characters inside one text node. A side that is `null` means the element exists on the other
130
+ * page only.
131
+ *
132
+ * A STALE EXCLUSION FAILS THE RUN. If a named side matches nothing, the run fails and says which: an exclusion that no longer
133
+ * matches would otherwise be a silent hole that a future difference could hide in. Masked area is printed per exclusion, so how
134
+ * much of the screen is NOT compared is always on the report, never only in this file.
135
+ *
136
+ * No exclusion is a reworded label. A label the app says differently from the board is a defect and is fixed in the app.
137
+ */
138
+ const EXCLUSIONS = {
139
+ WsA: [
140
+ {
141
+ name: 'terminal content',
142
+ why: 'The body of a terminal tab is the session\'s own output: the agent CLI and the cockpit mod draw it (the "Blocked" band and '
143
+ + 'the acceptance band are pmos-cockpit-mod M8 and M10), not the workspace. The workspace-cockpit-nav PRD puts "the terminal '
144
+ + 'mod and its boards" out of scope. The tab strip above it is compared.',
145
+ design: { own: 'accepted. merge PR 41 and publish the kit', up: 1 },
146
+ app: { sel: '[data-tabs="panes"]' },
147
+ },
148
+ {
149
+ name: 'scratchpad bar',
150
+ why: 'The session scratchpad\'s handle under the panes (Run 3, PRD R10, drawn from ViewFilesPad.dc.html). WsA predates it and '
151
+ + 'the PRD keeps it (R12, nothing is lost). On the board that strip is terminal body, which the exclusion above leaves out.',
152
+ design: null,
153
+ app: { sel: '[data-tabs="bottom"]' },
154
+ },
155
+ {
156
+ name: 'breadcrumb: last crumb',
157
+ why: 'PRD R8 makes the breadcrumb "repository, initiative, step, active tab", so the last crumb is the active tab\'s title '
158
+ + '("claude · merge PR 41"). The board ends it with the highlighted task. The first three crumbs are compared.',
159
+ design: { own: 'Run independent review', nth: 0 },
160
+ app: { sel: '[data-top="crumb"] > [data-crumb="tab"]' },
161
+ follows: [{
162
+ what: 'the trail\'s width, and its clipping of a long name',
163
+ only: ['box', 'overflowX', 'overflowY'],
164
+ design: { own: 'microsites', up: 1 },
165
+ app: { sel: '[data-top="crumb"]' },
166
+ }],
167
+ },
168
+ {
169
+ name: 'grants: the Grant button and the grants count',
170
+ why: 'PRD Q3, answered by the PM on 2026-10-04: Grant buttons and the grants count are hidden until the grants ledger behind '
171
+ + 'them exists (R5\'s rule: what has no backing is hidden, not greyed). The blocker card keeps the button\'s height, so '
172
+ + 'nothing moves when it comes; the rest of the status bar is compared.',
173
+ design: { own: 'Grant' },
174
+ app: null,
175
+ also: [{ design: { phrase: 'grants 0 active · ' }, app: null }],
176
+ follows: [{
177
+ what: 'the width of the status bar\'s right-hand text',
178
+ only: ['box'],
179
+ design: { phrase: '⌘K jump · Esc closes tab', parent: true },
180
+ app: { sel: '[data-shell="status"] > span:last-child' },
181
+ }],
182
+ },
183
+ {
184
+ name: 'Done: the third row',
185
+ why: 'The board labels the group "Done · 3" and draws two rows. The seed has three done initiatives (the count is the board\'s '
186
+ + 'own), and the app draws every row its count names. The label and the first two rows are compared.',
187
+ design: null,
188
+ app: { sel: '[data-row="shortlink-redirects"]' },
189
+ follows: [{ what: 'the height of the rows\' container', only: ['box'], design: { own: 'Needs you · 2', up: 1 }, app: { sel: '#pm-list [data-list="rows"]' } }],
190
+ },
191
+ {
192
+ name: 'artifact: the sync claim, and the link and sync controls',
193
+ why: 'The board\'s artifact row ends in "⇄ in sync". The app never says "in sync": it has never compared the repository file '
194
+ + 'with the linked document, and the external-linking work decided it shows only states a row on disk records '
195
+ + '(panel-detail.js, SYNC_NOT_A_SYNC). The artifact\'s link and sync controls (D53) sit under the row instead (R12). The '
196
+ + 'row\'s role badge and path are compared.',
197
+ design: { own: '⇄ in sync' },
198
+ app: { sel: '[data-art-links]' },
199
+ follows: [
200
+ { what: 'the path\'s width (it fills the row)', only: ['box'], design: { own: 'planning/prd/ooh-weather…-prd.md' }, app: { sel: '[data-art-path]' } },
201
+ { what: 'the height of the artifacts section', only: ['box'], design: { own: 'Artifacts', up: 1 }, app: { sel: '[data-cockpit="artifacts"]' } },
202
+ ],
203
+ },
204
+ {
205
+ name: 'tab: second line',
206
+ why: 'A tab names its initiative and session under its title ("<initiative> · session <id>"): after a reload the PM could not '
207
+ + 'tell which session a reopened tab was on (a PM-reported defect; ui-test AC6). The board\'s tabs have one line. The second '
208
+ + 'line sits in the tab\'s own lower padding, so the title stays where the board puts it.',
209
+ design: null,
210
+ app: { sel: '[data-tab-sub]' },
211
+ },
212
+ {
213
+ name: 'tab: close control on a tab not in front',
214
+ why: 'The board draws × on the tab in front only. The app keeps it on every tab, so a tab can be closed without first being '
215
+ + 'brought forward (a control shown only on hover is the trap this surface\'s earlier reviews named). It sits in the tab\'s '
216
+ + 'right padding, so no tab moves. The × of the tab in front is compared.',
217
+ design: null,
218
+ app: { sel: '[data-tab-active="0"] > [data-tab-close]' },
219
+ },
220
+ {
221
+ name: 'tab strip: options and split',
222
+ why: '"⋯ options" is the pre-flight composer an accepted PRD (workspace-repo-first) kept reachable; "⫽ split" shows two tabs side '
223
+ + 'by side. Both are live controls the board does not draw (R12). They sit left of "+ session", which is compared where the '
224
+ + 'board puts it.',
225
+ design: null,
226
+ app: { sel: '#pm-tabs [data-tabs="bar"] > button:not([data-tab]):not(:last-child)' },
227
+ },
228
+ {
229
+ name: 'tab: a view\'s dot colour',
230
+ why: 'PRD R4: a view tab has its own dot colour. The board paints "tool · memory" in the purple the app gives task tabs. Only the '
231
+ + 'dot\'s colour is skipped; its box and its pixels are compared.',
232
+ only: ['backgroundColor', 'color', 'borderLeftColor', 'borderBottomColor'],
233
+ design: { own: 'tool · memory', child: 0 },
234
+ app: { sel: '[data-tab-dot="view"]' },
235
+ },
236
+ {
237
+ name: 'the tabs column\'s fill',
238
+ why: 'The tabs column is painted --term-bg so a terminal stays dark in the light theme. The board is drawn dark only, where '
239
+ + '--term-bg and the page are the same colour, so the board leaves the column unpainted. Only that property is skipped.',
240
+ only: ['backgroundColor'],
241
+ design: { own: '+ session', up: 2 },
242
+ app: { sel: '#pm-tabs' },
243
+ },
244
+ {
245
+ name: 'the body: a grid with a scroll chain',
246
+ why: 'The board lays its four columns out as a flex row; the app\'s body is a grid, so Focus and the legacy shell re-place the '
247
+ + 'same four hosts without moving a terminal (R12), and each host down to the scrolling one has a zero minimum and clips '
248
+ + '(app.js: one tall document once scrolled the whole page). The list\'s rows, the detail and the tab strip scroll, because a '
249
+ + 'real repository has more than one screen of each; the board is a still picture. Only the layout mechanism, overflow and '
250
+ + 'minimum height of those containers are skipped; every column\'s box, and everything inside, is compared.',
251
+ only: ['display', 'gridTemplateColumns', 'gridTemplateRows', 'overflowX', 'overflowY', 'minHeight'],
252
+ design: null,
253
+ app: { sel: '[data-shell="body"]' },
254
+ follows: [{
255
+ what: 'the overflow and minimum height of the four columns and of the three containers that scroll',
256
+ only: ['overflowX', 'overflowY', 'minHeight'],
257
+ design: null,
258
+ app: { sel: '#pm-rail, #pm-list, #pm-detail, #pm-tabs, #pm-list [data-list="rows"], #pm-tabs [data-tabs="bar"]' },
259
+ }],
260
+ },
261
+ ],
262
+ };
263
+ /* The run's list: each exclusion and its sides, then each of its consequences (`follows`), which is always narrow (`only`) and is
264
+ * printed under the decision it follows from. */
265
+ const exclusions = (BOARD ? (EXCLUSIONS[BOARD] || []) : []).flatMap((x) => [
266
+ x,
267
+ ...(x.also || []).map((y) => ({ ...y, name: x.name, why: x.why, also: true })),
268
+ ...(x.follows || []).map((f) => ({ ...f, name: x.name + ' → ' + f.what, why: 'follows from "' + x.name + '"', follows: true })),
269
+ ]);
270
+
104
271
  function skip(why) { console.log(`⏭ design-parity SKIPPED — ${why}`); process.exit(3); }
105
272
 
106
273
  function requireOptional(name) { try { return require(name); } catch { return null; } }
107
274
 
275
+ // ── a cockpit board as a static reference page ──────────────────────────────────────────────────
276
+ /* A board is a `*.dc.html` file: a <helmet> holding the board's own CSS (and a Google Fonts link), then the board markup inside
277
+ * <x-dc>. The boards render statically without the design canvas runtime, so the page is built here: the helmet's <style> goes in the
278
+ * head, the Google Fonts link is replaced by the workspace's own vendored faces, and the markup sits inside a `data-pm-theme="dark"`
279
+ * root so the comparer's walk starts at the same place on both pages. Nothing is fetched from the network. */
280
+ function boardHtml(name) {
281
+ const file = path.join(COCKPIT, name + '.dc.html');
282
+ if (!fs.existsSync(file)) throw new Error('no such board: ' + file);
283
+ const src = fs.readFileSync(file, 'utf8');
284
+ const style = (/<helmet>[\s\S]*?<style>([\s\S]*?)<\/style>[\s\S]*?<\/helmet>/.exec(src) || [])[1];
285
+ const body = (/<\/helmet>([\s\S]*?)<\/x-dc>/.exec(src) || [])[1];
286
+ if (!style || !body) throw new Error('board ' + name + ' has no <helmet><style> or no <x-dc> body — it cannot be rendered statically');
287
+ // The board's own root element carries `data-pm-theme`, so the walk starts at the board itself and not at a wrapper of this script's.
288
+ const rooted = body.replace(/<div\b/, '<div data-pm-theme="dark"');
289
+ return '<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="./fonts.css"><style>' + style + '</style></head><body>' + rooted + '</body></html>';
290
+ }
291
+
108
292
  // ── the reference page, patched offline in memory ───────────────────────────────────────────────
109
293
  function referenceHtml() {
294
+ if (BOARD) return boardHtml(BOARD);
110
295
  const src = fs.readFileSync(path.join(DESIGN, 'prototype-original.html'), 'utf8');
111
296
  const map = JSON.stringify({
112
297
  'https://unpkg.com/react@18.3.1/umd/react.production.min.js': './vendor/react.production.min.js',
@@ -179,7 +364,7 @@ async function drive(page, steps) {
179
364
  return applied;
180
365
  }
181
366
 
182
- async function snap(browser, url, theme, tag, steps = []) {
367
+ async function snap(browser, url, theme, tag, steps = [], side = null) {
183
368
  const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
184
369
  const errors = [];
185
370
  page.on('pageerror', (e) => errors.push(String(e.message)));
@@ -199,21 +384,77 @@ async function snap(browser, url, theme, tag, steps = []) {
199
384
  await page.waitForFunction(() => document.fonts.status === 'loaded', null, { timeout: 15000 }).catch(() => {});
200
385
  const png = path.join(OUT, `${tag}.png`);
201
386
  await page.screenshot({ path: png });
202
- const data = await page.evaluate((props) => {
387
+ // this page's side of each exclusion (see EXCLUSIONS); null when the excluded thing exists on the other page only
388
+ const xs = side ? exclusions.map((x) => ({ name: x.name, loc: x[side] || null, only: x.only || null })) : [];
389
+ const data = await page.evaluate(([props, xs, frame, tokenNames]) => {
390
+ const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3)
391
+ .map((n) => n.textContent.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
392
+ const shown = (e) => { const cs = getComputedStyle(e); const r = e.getBoundingClientRect(); return cs.display !== 'none' && r.width > 0 && r.height > 0; };
393
+ // ── mark the excluded elements and phrases, and keep their boxes for the pixel mask ──
394
+ const masks = [], phrases = [];
395
+ for (const x of xs) {
396
+ if (!x.loc) continue;
397
+ const m = { name: x.name, found: 0, boxes: [] };
398
+ masks.push(m);
399
+ if (x.loc.phrase) {
400
+ const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
401
+ for (let n = tw.nextNode(); n; n = tw.nextNode()) {
402
+ const at = n.textContent.indexOf(x.loc.phrase);
403
+ if (at < 0) continue;
404
+ if (x.loc.parent) { // the element that holds the phrase, for a narrow exclusion
405
+ if (x.only) { n.parentElement.setAttribute('data-parity-only', x.only.join(',')); m.found++; }
406
+ continue;
407
+ }
408
+ const rg = document.createRange(); rg.setStart(n, at); rg.setEnd(n, at + x.loc.phrase.length);
409
+ const r = rg.getBoundingClientRect();
410
+ m.found++; m.boxes.push([r.x, r.y, r.width, r.height]); phrases.push({ node: n, phrase: x.loc.phrase });
411
+ }
412
+ continue;
413
+ }
414
+ let hits = x.loc.sel ? [...document.querySelectorAll(x.loc.sel)]
415
+ : [...document.querySelectorAll('body *')].filter((e) => own(e) === x.loc.own && shown(e));
416
+ if (typeof x.loc.nth === 'number') hits = hits.slice(x.loc.nth, x.loc.nth + 1);
417
+ for (let e of hits) {
418
+ for (let i = 0; i < (x.loc.up || 0) && e.parentElement; i++) e = e.parentElement;
419
+ if (typeof x.loc.child === 'number') e = e.children[x.loc.child];
420
+ if (!e) continue;
421
+ if (!shown(e)) continue;
422
+ if (x.only) { e.setAttribute('data-parity-only', x.only.join(',')); m.found++; continue; }
423
+ e.setAttribute('data-parity-excluded', x.name);
424
+ const r = e.getBoundingClientRect();
425
+ m.found++; m.boxes.push([r.x, r.y, r.width, r.height]);
426
+ }
427
+ }
428
+ const cut = (n) => { let t = n.textContent; for (const p of phrases) if (p.node === n) t = t.split(p.phrase).join(' '); return t; };
429
+ const excluded = (e) => e.hasAttribute('data-parity-excluded');
430
+ // an element's whole visible text, leaving out what is excluded inside it
431
+ const fullText = (e) => {
432
+ let t = '';
433
+ for (const c of e.childNodes) {
434
+ if (c.nodeType === 3) t += cut(c);
435
+ else if (c.nodeType === 1 && !excluded(c)) t += ' ' + fullText(c) + ' ';
436
+ }
437
+ return t;
438
+ };
203
439
  const root = document.querySelector('[data-pm-theme]') || document.body;
204
440
  const nodes = [], texts = [];
205
441
  const walk = (e) => {
442
+ if (excluded(e)) return;
206
443
  const cs = getComputedStyle(e);
207
444
  if (cs.display === 'none') return;
445
+ // On a board run the walk's root is a FRAME on both pages and is not itself a compared node: the board's root is a flex column and
446
+ // the app's is a grid (the phone's stylesheet places its rows), two ways of drawing the same three bands. Its geometry is the
447
+ // viewport on both, its fill is diffed by the pixel axis, and everything inside it is compared.
448
+ if (frame && e === root) { for (const c of e.childNodes) { if (c.nodeType === 3) { const s0 = cut(c).replace(/\s+/g, ' ').trim(); if (s0) texts.push(s0); } else if (c.nodeType === 1) walk(c); } return; }
208
449
  const r = e.getBoundingClientRect();
209
- const own = [...e.childNodes].filter((n) => n.nodeType === 3)
210
- .map((n) => n.textContent.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
450
+ const ownT = [...e.childNodes].filter((n) => n.nodeType === 3)
451
+ .map((n) => cut(n).replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
211
452
  const st = {}; for (const p of props) st[p] = cs[p];
212
453
  // `full` is the element's WHOLE visible text, used to anchor style comparison; `own` is only
213
454
  // its own text nodes. They differ because the prototype's runtime wraps each interpolation in
214
455
  // a span, so its label sits one element deeper than the app's.
215
- nodes.push({ text: own.slice(0, 60), full: (e.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
216
- box: [r.x, r.y, r.width, r.height].map(Math.round), st });
456
+ nodes.push({ text: ownT.slice(0, 60), full: fullText(e).replace(/\s+/g, ' ').trim().slice(0, 80),
457
+ box: [r.x, r.y, r.width, r.height].map(Math.round), st, only: (e.getAttribute('data-parity-only') || '').split(',').filter(Boolean) });
217
458
  // Walk childNodes IN ORDER, emitting text where it actually sits rather than hoisting all of an
218
459
  // element's own text ahead of its children. The status bar is the case that forced this: the
219
460
  // design writes "local · persistent server · ", then a <span> holding the count, then
@@ -222,18 +463,44 @@ async function snap(browser, url, theme, tag, steps = []) {
222
463
  // the stream has to be.
223
464
  for (const c of e.childNodes) {
224
465
  if (c.nodeType === 3) {
225
- const s = c.textContent.replace(/\s+/g, ' ').trim();
466
+ const s = cut(c).replace(/\s+/g, ' ').trim();
226
467
  if (s) texts.push(s);
227
468
  } else if (c.nodeType === 1) walk(c);
228
469
  }
229
470
  };
230
471
  walk(root);
231
- return { nodes, texts };
232
- }, PROPS);
472
+ // what each design token resolves to on this page (see TOKENS)
473
+ const tokens = {};
474
+ for (const t of tokenNames) {
475
+ const probe = document.createElement('div');
476
+ probe.style.color = 'var(--' + t + ')';
477
+ root.appendChild(probe);
478
+ tokens[t] = getComputedStyle(probe).color;
479
+ probe.remove();
480
+ }
481
+ return { nodes, texts, masks, tokens };
482
+ }, [PROPS, xs, !!BOARD, BOARD ? TOKENS : []]);
233
483
  await page.close();
234
484
  return { ...data, errors, png, applied };
235
485
  }
236
486
 
487
+ /* THE TOKEN MAPPING (PRD R12). A board declares its own token values at its :root, and four of them are not the token system's:
488
+ * --card, --sidebar, --accent and --brand-wash are drawn a shade or two off the D38 values the app uses. R12 decided it: "the
489
+ * mockup's --brand, --success and similar values map to the token system, not to new hex values". So on a board run a computed
490
+ * colour that is exactly what a board token resolves to is read as that token, and matches when the app's element has the SAME
491
+ * token's value. It is a mapping by token name, never a tolerance: a colour the board wrote out by hand, or a different token, still
492
+ * differs. The tokens whose values differ are printed with each run. */
493
+ const TOKENS = ['background', 'foreground', 'card', 'sidebar', 'secondary', 'accent', 'muted-foreground', 'border', 'input', 'brand',
494
+ 'brand-wash', 'success', 'warning', 'info', 'destructive'];
495
+ let tokenMap = new Map(); // design-resolved value -> app-resolved value, for the tokens whose values differ
496
+
497
+ /* On a board run `font-family` is compared by its FIRST family only. The mockup's fallback tail (`system-ui, sans-serif`) and the token
498
+ * system's (`ui-sans-serif, system-ui, sans-serif, …`) differ by design (PRD R12: the mockup's values map to the tokens, they do not
499
+ * replace them), and a fallback is never drawn while the first family loads — the fonts are vendored, so the check would be measuring
500
+ * a stack nobody sees. Every other property is compared exactly. */
501
+ const sameValue = (p, a, b) => (BOARD && p === 'fontFamily') ? String(a).split(',')[0] === String(b).split(',')[0]
502
+ : a === b || (BOARD && tokenMap.has(a) && tokenMap.get(a) === b);
503
+
237
504
  function diff(A, B, theme) {
238
505
  const problems = [];
239
506
  if (B.errors.length) problems.push(`console errors in the app (${B.errors.length}): ${B.errors.slice(0, 5).join(' | ')}`);
@@ -265,11 +532,15 @@ function diff(A, B, theme) {
265
532
  const sd = [];
266
533
  for (const [t, na] of ia) {
267
534
  const nb = ib.get(t); if (!nb) continue;
268
- for (const p of PROPS) if (na.st[p] !== nb.st[p]) sd.push(` [${JSON.stringify(t.slice(0, 30))}] ${p}: design=${na.st[p]} app=${nb.st[p]}`);
535
+ const skipped = new Set([...(na.only || []), ...(nb.only || [])]); // a narrow exclusion's listed comparisons (see EXCLUSIONS)
536
+ for (const p of PROPS) if (!skipped.has(p) && !sameValue(p, na.st[p], nb.st[p])) sd.push(` [${JSON.stringify(t.slice(0, 30))}] ${p}: design=${na.st[p]} app=${nb.st[p]}`);
269
537
  const off = na.box.map((v, i) => Math.abs(v - nb.box[i]));
270
- if (Math.max(...off) > 2) sd.push(` [${JSON.stringify(t.slice(0, 30))}] box: design=${na.box} app=${nb.box}`);
538
+ if (!skipped.has('box') && Math.max(...off) > 2) sd.push(` [${JSON.stringify(t.slice(0, 30))}] box: design=${na.box} app=${nb.box}`);
539
+ }
540
+ if (sd.length) {
541
+ problems.push(`${sd.length} computed-style / geometry difference(s)${sd.length > 40 ? ' (the first 40; all of them are in ' + path.join(OUT, 'differences-' + theme + '.txt') + ')' : ''}:\n` + sd.slice(0, 40).join('\n'));
542
+ try { fs.writeFileSync(path.join(OUT, 'differences-' + theme + '.txt'), sd.join('\n') + '\n'); } catch { /* the report is still printed */ }
271
543
  }
272
- if (sd.length) problems.push(`${sd.length} computed-style / geometry difference(s):\n` + sd.slice(0, 40).join('\n'));
273
544
  return { problems, missing, extra, styleDiffs: sd, theme };
274
545
  }
275
546
 
@@ -281,7 +552,7 @@ function diff(A, B, theme) {
281
552
  // nothing and reported a SKIP — from a machine that had a browser the whole time.
282
553
  const exe = require('./lib/find-browser').findBrowser();
283
554
  if (!exe) skip('no Chrome/Chromium found (set PMOS_CHROME, or install one)');
284
- if (!fs.existsSync(path.join(DESIGN, 'prototype-original.html'))) skip(`the design reference is missing at ${DESIGN}`);
555
+ if (!BOARD && !fs.existsSync(path.join(DESIGN, 'prototype-original.html'))) skip(`the design reference is missing at ${DESIGN}`);
285
556
 
286
557
  const alive = await new Promise((r) => {
287
558
  const req = http.get(APP_URL, (res) => { res.resume(); r(true); });
@@ -297,8 +568,15 @@ function diff(A, B, theme) {
297
568
  const report = {};
298
569
  for (const theme of themes) for (const st of states) {
299
570
  const key = `${theme}/${st.id}`;
300
- const A = await snap(browser, `http://127.0.0.1:${REF_PORT}/index.html`, theme, `design-${theme}-${st.id}`, st.steps);
301
- const B = await snap(browser, `${APP_URL}/?fixture=proto`, theme, `app-${theme}-${st.id}`, st.steps);
571
+ const A = await snap(browser, `http://127.0.0.1:${REF_PORT}/index.html`, theme, `design-${theme}-${st.id}`, st.steps, 'design');
572
+ const B = await snap(browser, `${APP_URL}/?fixture=${BOARD ? 'cockpit' : 'proto'}`, theme, `app-${theme}-${st.id}`, st.steps, 'app');
573
+ tokenMap = new Map();
574
+ const mapped = [];
575
+ for (const t of TOKENS) {
576
+ const dv = A.tokens && A.tokens[t], av = B.tokens && B.tokens[t];
577
+ if (dv && av && dv !== av && !tokenMap.has(dv)) { tokenMap.set(dv, av); mapped.push(`--${t}: ${dv} → ${av}`); }
578
+ }
579
+ if (mapped.length) console.log(`\ntoken mapping (PRD R12), by name: ${mapped.join(' · ')}`);
302
580
  const d = diff(A, B, theme);
303
581
 
304
582
  // COULD THE STATE BE REACHED AT ALL? Asserted before anything is compared, and on BOTH pages: a
@@ -313,12 +591,31 @@ function diff(A, B, theme) {
313
591
  `nothing below this line is a comparison of ${st.id}`);
314
592
  }
315
593
 
316
- let pixels = null;
594
+ // A STALE EXCLUSION FAILS: every side an exclusion names must still match something on that page.
595
+ const stale = [];
596
+ for (const [who, P] of [['design', A], ['app', B]]) for (const m of P.masks) if (!m.found) stale.push(`"${m.name}" matches nothing in the ${who.toUpperCase()}`);
597
+ if (stale.length) d.problems.push('stale exclusion(s) — remove or repair them in EXCLUSIONS:\n ' + stale.join('\n '));
598
+ const maskBoxes = [...A.masks, ...B.masks].flatMap((m) => m.boxes.map((b) => ({ name: m.name, b })));
599
+
600
+ let pixels = null, maskedPct = 0;
317
601
  const pixelmatch = requireOptional('pixelmatch'); const pngjs = requireOptional('pngjs');
318
602
  if (pixelmatch && pngjs) {
319
603
  const pm = pixelmatch.default || pixelmatch;
320
604
  const a = pngjs.PNG.sync.read(fs.readFileSync(A.png)), b = pngjs.PNG.sync.read(fs.readFileSync(B.png));
321
605
  if (a.width === b.width && a.height === b.height) {
606
+ // Paint each excluded box identically on both screenshots (the design's pixels are copied into the app's), so only what is
607
+ // compared can differ. The masked area is counted and reported, not hidden.
608
+ const masked = new Uint8Array(a.width * a.height);
609
+ for (const { b: [x0, y0, w0, h0] } of maskBoxes) {
610
+ const x1 = Math.max(0, Math.floor(x0)), y1 = Math.max(0, Math.floor(y0));
611
+ const x2 = Math.min(a.width, Math.ceil(x0 + w0)), y2 = Math.min(a.height, Math.ceil(y0 + h0));
612
+ for (let y = y1; y < y2; y++) for (let x = x1; x < x2; x++) {
613
+ const i = y * a.width + x; masked[i] = 1;
614
+ for (let k = 0; k < 4; k++) b.data[i * 4 + k] = a.data[i * 4 + k];
615
+ }
616
+ }
617
+ let nMasked = 0; for (const v of masked) nMasked += v;
618
+ maskedPct = +(100 * nMasked / (a.width * a.height)).toFixed(2);
322
619
  const out = new pngjs.PNG({ width: a.width, height: a.height });
323
620
  const n = pm(a.data, b.data, out.data, a.width, a.height, { threshold: 0.12 });
324
621
  fs.writeFileSync(path.join(OUT, `diff-${theme}-${st.id}.png`), pngjs.PNG.sync.write(out));
@@ -332,6 +629,8 @@ function diff(A, B, theme) {
332
629
  designNodes: A.nodes.length, appNodes: B.nodes.length,
333
630
  textMissing: d.missing.length, textExtra: d.extra.length,
334
631
  styleDiffs: d.styleDiffs.length,
632
+ exclusions: (EXCLUSIONS[BOARD] || []).map((x) => x.name),
633
+ maskedPct,
335
634
  pixelPct: pixels === null ? 'not measured (pixelmatch/pngjs absent)' : +(pixels * 100).toFixed(3),
336
635
  clean: d.problems.length === 0,
337
636
  };
@@ -346,12 +645,20 @@ function diff(A, B, theme) {
346
645
  fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 1));
347
646
  if (!KEEP && !failed) for (const f of fs.readdirSync(OUT)) if (f.endsWith('.png')) fs.unlinkSync(path.join(OUT, f));
348
647
 
648
+ if (exclusions.length) {
649
+ const top = EXCLUSIONS[BOARD] || [];
650
+ console.log(`\n${top.length} named exclusion(s) on board ${BOARD} — each a decision, not a defect:`);
651
+ for (const x of top) {
652
+ console.log(` · ${x.name}${x.only ? ' (only: ' + x.only.join(', ') + ')' : ''} — ${x.why}`);
653
+ for (const f of x.follows || []) console.log(` ↳ follows: ${f.what} (only: ${f.only.join(', ')})`);
654
+ }
655
+ }
349
656
  const scope = `${themes.join(' and ')} × ${states.length} state(s) [${states.map((s) => s.id).join(', ')}]`;
350
657
  if (failed) {
351
658
  console.log(`\n❌ design parity FAILED in ${failed}/${compared} comparison(s). Renders kept in ${OUT}.`);
352
659
  process.exit(1);
353
660
  }
354
- console.log(`\n✅ design parity — the app is identical to the PM's prototype across ${scope} — ${compared} comparison(s).`);
661
+ console.log(`\n✅ design parity — the app is identical to ${BOARD ? 'the cockpit board ' + BOARD : "the PM's prototype"} across ${scope} — ${compared} comparison(s).`);
355
662
  if (states.length === 1 && states[0].id === 'default') {
356
663
  console.log(' (default state only. `--states` walks all ' + STATES.length + ', which is what the PRD asks for.)');
357
664
  }