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.
- package/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +13 -3
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
- package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
- package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
- package/payload/okf/core/concepts/frontmatter-format.md +6 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +204 -0
- package/payload/scripts/workflow-state.js +52 -0
- package/payload/skills/SKILL-FORMAT.md +1 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +3 -3
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/initiatives-and-next-steps.md +16 -2
- package/workspace/guide/knowledge-graph.md +89 -2
- package/workspace/guide/layout.md +30 -2
- package/workspace/guide/proactive-engine.md +26 -4
- package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
- package/workspace/guide/statuses.md +1 -0
- package/workspace/guide/validate-with-typesafe.md +17 -1
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +204 -0
- package/workspace/lib/engine/workflow-state.js +52 -0
- package/workspace/lib/jev-categorize.js +399 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-route.js +129 -0
- package/workspace/lib/kg-cli.js +5 -2
- package/workspace/lib/kg-dangling-baseline.json +1 -0
- package/workspace/lib/kg-dangling-rules.json +1 -0
- package/workspace/lib/kg.js +26 -9
- package/workspace/lib/learn.js +282 -0
- package/workspace/lib/live-notes.js +890 -0
- package/workspace/lib/proactive-cli.js +49 -1
- package/workspace/lib/proactive.js +8 -7
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +58 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +267 -21
- package/workspace/public/pm/panel-cockpit.js +636 -0
- package/workspace/public/pm/panel-detail.js +210 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +145 -27
- package/workspace/public/pm/state.js +45 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +394 -10
- package/workspace/skills/live-notes.skill +58 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
210
|
-
.map((n) => n.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|