create-pmos 0.52.0 → 0.53.1
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/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +24 -63
- package/workspace/bin/pmos.js +19 -3
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +28 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +102 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +75 -0
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +210 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +380 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +69 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +9 -6
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +93 -1
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +578 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +40 -19
- package/workspace/public/pm/app.js +330 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3135 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +182 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +175 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +99 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +200 -111
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +126 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2877 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +446 -40
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +21 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -1,673 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/* design-parity — decides whether the workspace matches the design the PM handed over.
|
|
3
|
-
*
|
|
4
|
-
* The PM's bar for this surface was one word: IDENTICAL. That is not a thing anyone can hold in
|
|
5
|
-
* their head across four panels, two themes and ten interaction states, so it is decided here
|
|
6
|
-
* mechanically instead. This renders the PM's own prototype and the built app side by side at the
|
|
7
|
-
* same viewport with the same font files, and diffs them on four axes:
|
|
8
|
-
*
|
|
9
|
-
* text the ordered list of rendered text nodes — a reworded label is a failure
|
|
10
|
-
* style computed styles on structurally-matched elements
|
|
11
|
-
* geometry bounding boxes, to a 2px tolerance
|
|
12
|
-
* pixels a screenshot diff, failing above 0.5% of the viewport
|
|
13
|
-
*
|
|
14
|
-
* WHY THE APP IS RENDERED IN FIXTURE MODE. A diff needs like-for-like content, and the app's real
|
|
15
|
-
* content is this repository. `?fixture=proto` loads the prototype's own seed data through the app's
|
|
16
|
-
* real renderer — so what is being compared is the rendering, which is the thing under test, and not
|
|
17
|
-
* the data, which is not. The same fixture doubles as the offline demo path: it proves the UI draws
|
|
18
|
-
* with no repository readable at all. What it deliberately does NOT prove is that the app is wired to
|
|
19
|
-
* anything; that is dimension 2 of the rubric and it is asserted in ui-test.js against real state.
|
|
20
|
-
*
|
|
21
|
-
* OFFLINE BY CONSTRUCTION. The prototype's runtime fetches React from unpkg. Both files are vendored
|
|
22
|
-
* under planning/design/four-panel/vendor/ and injected through the runtime's own `window.__resources`
|
|
23
|
-
* override, and the Google Fonts link is rewritten to the workspace's own vendored woff2 — the same
|
|
24
|
-
* byte-identical faces the app serves, so a font-fallback difference can never be mistaken for a
|
|
25
|
-
* design difference. Babel is not vendored because the prototype does not need it (verified: the
|
|
26
|
-
* render is node-for-node identical without it).
|
|
27
|
-
*
|
|
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.
|
|
32
|
-
* node design-parity.js --theme=light one theme
|
|
33
|
-
* node design-parity.js --states also walk the interaction states (see STATES below)
|
|
34
|
-
* node design-parity.js --state=<id> one named state
|
|
35
|
-
* node design-parity.js --keep keep the rendered PNGs for inspection
|
|
36
|
-
*
|
|
37
|
-
* THE STATE WALK. `--states` was advertised in this header for two review rounds while doing
|
|
38
|
-
* nothing — the flag was never read, so "identical across the captured interaction states" (PRD
|
|
39
|
-
* success criterion 1, and the rubric's test_method) was decided on the opening screen alone. It is
|
|
40
|
-
* implemented now: each state is a list of LABELS to click, applied to the prototype and to the app
|
|
41
|
-
* from a fresh load, and the same four axes are diffed at the end of the sequence. The steps are
|
|
42
|
-
* text, not selectors, deliberately — a selector would encode one page's DOM and quietly stop
|
|
43
|
-
* matching on the other, which is the shape a check takes when it goes decorative. A step that
|
|
44
|
-
* cannot find its label on EITHER page is a hard failure: "could not drive the app there" must
|
|
45
|
-
* never be recorded as "the app matched there".
|
|
46
|
-
*
|
|
47
|
-
* Exit 0 only when every axis is clean. Exit 3 means it could not run (no browser, no server) —
|
|
48
|
-
* which is a skip, and is reported as one rather than as a pass.
|
|
49
|
-
*/
|
|
50
|
-
'use strict';
|
|
51
|
-
const fs = require('fs');
|
|
52
|
-
const path = require('path');
|
|
53
|
-
const http = require('http');
|
|
54
|
-
const os = require('os');
|
|
55
|
-
|
|
56
|
-
const ROOT = path.resolve(__dirname, '..');
|
|
57
|
-
const DESIGN = path.join(ROOT, 'planning', 'design', 'four-panel');
|
|
58
|
-
const COCKPIT = path.join(ROOT, 'planning', 'design', 'cockpit-mockup');
|
|
59
|
-
const FONTS = path.join(__dirname, 'public', 'vendor');
|
|
60
|
-
const APP_URL = process.env.PMOS_PARITY_APP || 'http://127.0.0.1:7433';
|
|
61
|
-
const REF_PORT = +(process.env.PMOS_PARITY_PORT || 8899);
|
|
62
|
-
const OUT = process.env.PMOS_PARITY_OUT || path.join(os.tmpdir(), 'pmos-design-parity');
|
|
63
|
-
|
|
64
|
-
const argv = process.argv.slice(2);
|
|
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'];
|
|
69
|
-
const KEEP = argv.includes('--keep');
|
|
70
|
-
const W = 1600, H = BOARD ? 900 : 1000;
|
|
71
|
-
|
|
72
|
-
/* THE CAPTURED INTERACTION STATES.
|
|
73
|
-
*
|
|
74
|
-
* Each entry is a state the PM can put the surface into, expressed as the labels a person would
|
|
75
|
-
* click to get there — read as the element's OWN text, so "add repository" means the span holding
|
|
76
|
-
* exactly that, in either page's markup. `default` is the opening screen and always runs.
|
|
77
|
-
*
|
|
78
|
-
* Chosen because each one changes a DIFFERENT part of the layout, so the set covers the interaction
|
|
79
|
-
* model the handoff brief calls final rather than repeating one kind of change: a drawer over panel
|
|
80
|
-
* 1, a collapsed group in panel 2, an expanded task tree and an opened tab in panels 3 and 4, a
|
|
81
|
-
* collapsed column, and the panel-3 inline expansion. A state whose divergence is a DECISION rather
|
|
82
|
-
* than a defect does not belong here — it belongs in the PRD — so if one is ever excluded, the
|
|
83
|
-
* reason goes in `why` and gets printed, not left as an absence nobody can see.
|
|
84
|
-
*/
|
|
85
|
-
const STATES = [
|
|
86
|
-
{ id: 'default', steps: [] },
|
|
87
|
-
{ id: 'settings-drawer', steps: ['settings & tools'] },
|
|
88
|
-
{ id: 'group-collapsed', steps: ['health:tech'] },
|
|
89
|
-
{ id: 'phase-collapsed', steps: ['intake'] },
|
|
90
|
-
{ id: 'task-tab-open', steps: ['produce the build output'] },
|
|
91
|
-
{ id: 'column-collapsed', steps: ['⟨'] },
|
|
92
|
-
{ id: 'add-repository', steps: ['add repository'] },
|
|
93
|
-
];
|
|
94
|
-
const stateArg = argv.find((a) => a.startsWith('--state='));
|
|
95
|
-
const states = BOARD ? STATES.filter((s) => s.id === 'default') : stateArg
|
|
96
|
-
? STATES.filter((s) => s.id === stateArg.split('=')[1])
|
|
97
|
-
: (argv.includes('--states') ? STATES : STATES.filter((s) => s.id === 'default'));
|
|
98
|
-
if (!states.length) {
|
|
99
|
-
console.error(`no such state: ${stateArg.split('=')[1]} — known: ${STATES.map((s) => s.id).join(', ')}`);
|
|
100
|
-
process.exit(2);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const PROPS = [
|
|
104
|
-
'display', 'gridTemplateColumns', 'gridTemplateRows', 'flexDirection', 'alignItems',
|
|
105
|
-
'justifyContent', 'gap', 'minHeight', 'padding', 'backgroundColor', 'color',
|
|
106
|
-
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
|
107
|
-
'borderLeftColor', 'borderBottomColor', 'borderRadius', 'fontFamily', 'fontSize', 'fontWeight',
|
|
108
|
-
'lineHeight', 'letterSpacing', 'textTransform', 'writingMode', 'overflowX', 'overflowY',
|
|
109
|
-
];
|
|
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: 'column fold controls',
|
|
206
|
-
why: 'The PM could not fold the list and the detail away (2026-10-05, "i cannot minimize column 2 and 3"). Each column has a '
|
|
207
|
-
+ '"⟨" control that folds it to the 36px strip, which opens it again. The board draws no fold control. Both float over the '
|
|
208
|
-
+ 'column (the list\'s over the right end of "New initiative", the detail\'s at its top right), so nothing in the column moves.',
|
|
209
|
-
design: null,
|
|
210
|
-
app: { sel: '[data-fold]' },
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
name: 'tab: second line',
|
|
214
|
-
why: 'A tab names its initiative and session under its title ("<initiative> · session <id>"): after a reload the PM could not '
|
|
215
|
-
+ 'tell which session a reopened tab was on (a PM-reported defect; ui-test AC6). The board\'s tabs have one line. The second '
|
|
216
|
-
+ 'line sits in the tab\'s own lower padding, so the title stays where the board puts it.',
|
|
217
|
-
design: null,
|
|
218
|
-
app: { sel: '[data-tab-sub]' },
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
name: 'tab: close control on a tab not in front',
|
|
222
|
-
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 '
|
|
223
|
-
+ 'brought forward (a control shown only on hover is the trap this surface\'s earlier reviews named). It sits in the tab\'s '
|
|
224
|
-
+ 'right padding, so no tab moves. The × of the tab in front is compared.',
|
|
225
|
-
design: null,
|
|
226
|
-
app: { sel: '[data-tab-active="0"] > [data-tab-close]' },
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
name: 'tab strip: options and split',
|
|
230
|
-
why: '"⋯ options" is the pre-flight composer an accepted PRD (workspace-repo-first) kept reachable; "⫽ split" shows two tabs side '
|
|
231
|
-
+ 'by side. Both are live controls the board does not draw (R12). They sit left of "+ session", which is compared where the '
|
|
232
|
-
+ 'board puts it.',
|
|
233
|
-
design: null,
|
|
234
|
-
app: { sel: '#pm-tabs [data-tabs="bar"] > button:not([data-tab]):not(:last-child)' },
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
name: 'tab: a view\'s dot colour',
|
|
238
|
-
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 '
|
|
239
|
-
+ 'dot\'s colour is skipped; its box and its pixels are compared.',
|
|
240
|
-
only: ['backgroundColor', 'color', 'borderLeftColor', 'borderBottomColor'],
|
|
241
|
-
design: { own: 'tool · memory', child: 0 },
|
|
242
|
-
app: { sel: '[data-tab-dot="view"]' },
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
name: 'the tabs column\'s fill',
|
|
246
|
-
why: 'The tabs column is painted --term-bg so a terminal stays dark in the light theme. The board is drawn dark only, where '
|
|
247
|
-
+ '--term-bg and the page are the same colour, so the board leaves the column unpainted. Only that property is skipped.',
|
|
248
|
-
only: ['backgroundColor'],
|
|
249
|
-
design: { own: '+ session', up: 2 },
|
|
250
|
-
app: { sel: '#pm-tabs' },
|
|
251
|
-
},
|
|
252
|
-
{
|
|
253
|
-
name: 'the body: a grid with a scroll chain',
|
|
254
|
-
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 '
|
|
255
|
-
+ 'same four hosts without moving a terminal (R12), and each host down to the scrolling one has a zero minimum and clips '
|
|
256
|
-
+ '(app.js: one tall document once scrolled the whole page). The list\'s rows, the detail and the tab strip scroll, because a '
|
|
257
|
-
+ 'real repository has more than one screen of each; the board is a still picture. Only the layout mechanism, overflow and '
|
|
258
|
-
+ 'minimum height of those containers are skipped; every column\'s box, and everything inside, is compared.',
|
|
259
|
-
only: ['display', 'gridTemplateColumns', 'gridTemplateRows', 'overflowX', 'overflowY', 'minHeight'],
|
|
260
|
-
design: null,
|
|
261
|
-
app: { sel: '[data-shell="body"]' },
|
|
262
|
-
follows: [{
|
|
263
|
-
what: 'the overflow and minimum height of the four columns and of the three containers that scroll',
|
|
264
|
-
only: ['overflowX', 'overflowY', 'minHeight'],
|
|
265
|
-
design: null,
|
|
266
|
-
app: { sel: '#pm-rail, #pm-list, #pm-detail, #pm-tabs, #pm-list [data-list="rows"], #pm-tabs [data-tabs="bar"]' },
|
|
267
|
-
}],
|
|
268
|
-
},
|
|
269
|
-
],
|
|
270
|
-
};
|
|
271
|
-
/* The run's list: each exclusion and its sides, then each of its consequences (`follows`), which is always narrow (`only`) and is
|
|
272
|
-
* printed under the decision it follows from. */
|
|
273
|
-
const exclusions = (BOARD ? (EXCLUSIONS[BOARD] || []) : []).flatMap((x) => [
|
|
274
|
-
x,
|
|
275
|
-
...(x.also || []).map((y) => ({ ...y, name: x.name, why: x.why, also: true })),
|
|
276
|
-
...(x.follows || []).map((f) => ({ ...f, name: x.name + ' → ' + f.what, why: 'follows from "' + x.name + '"', follows: true })),
|
|
277
|
-
]);
|
|
278
|
-
|
|
279
|
-
function skip(why) { console.log(`⏭ design-parity SKIPPED — ${why}`); process.exit(3); }
|
|
280
|
-
|
|
281
|
-
function requireOptional(name) { try { return require(name); } catch { return null; } }
|
|
282
|
-
|
|
283
|
-
// ── a cockpit board as a static reference page ──────────────────────────────────────────────────
|
|
284
|
-
/* 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
|
|
285
|
-
* <x-dc>. The boards render statically without the design canvas runtime, so the page is built here: the helmet's <style> goes in the
|
|
286
|
-
* head, the Google Fonts link is replaced by the workspace's own vendored faces, and the markup sits inside a `data-pm-theme="dark"`
|
|
287
|
-
* root so the comparer's walk starts at the same place on both pages. Nothing is fetched from the network. */
|
|
288
|
-
function boardHtml(name) {
|
|
289
|
-
const file = path.join(COCKPIT, name + '.dc.html');
|
|
290
|
-
if (!fs.existsSync(file)) throw new Error('no such board: ' + file);
|
|
291
|
-
const src = fs.readFileSync(file, 'utf8');
|
|
292
|
-
const style = (/<helmet>[\s\S]*?<style>([\s\S]*?)<\/style>[\s\S]*?<\/helmet>/.exec(src) || [])[1];
|
|
293
|
-
const body = (/<\/helmet>([\s\S]*?)<\/x-dc>/.exec(src) || [])[1];
|
|
294
|
-
if (!style || !body) throw new Error('board ' + name + ' has no <helmet><style> or no <x-dc> body — it cannot be rendered statically');
|
|
295
|
-
// 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.
|
|
296
|
-
const rooted = body.replace(/<div\b/, '<div data-pm-theme="dark"');
|
|
297
|
-
return '<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="./fonts.css"><style>' + style + '</style></head><body>' + rooted + '</body></html>';
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ── the reference page, patched offline in memory ───────────────────────────────────────────────
|
|
301
|
-
function referenceHtml() {
|
|
302
|
-
if (BOARD) return boardHtml(BOARD);
|
|
303
|
-
const src = fs.readFileSync(path.join(DESIGN, 'prototype-original.html'), 'utf8');
|
|
304
|
-
const map = JSON.stringify({
|
|
305
|
-
'https://unpkg.com/react@18.3.1/umd/react.production.min.js': './vendor/react.production.min.js',
|
|
306
|
-
'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js': './vendor/react-dom.production.min.js',
|
|
307
|
-
});
|
|
308
|
-
let h = src.replace('<script src="./support.js"></script>',
|
|
309
|
-
`<script>window.__resources=${map};</script>\n<script src="./support.js"></script>`);
|
|
310
|
-
h = h.replace(/<link rel="preconnect"[^>]*>\s*/g, '');
|
|
311
|
-
h = h.replace(/<link href="https:\/\/fonts\.googleapis\.com[^>]*>/, '<link rel="stylesheet" href="./fonts.css">');
|
|
312
|
-
if (/fonts\.googleapis|unpkg\.com/.test(h.replace(map, ''))) {
|
|
313
|
-
throw new Error('reference page still carries a remote asset after patching — refusing to compare against a page that would need the network');
|
|
314
|
-
}
|
|
315
|
-
return h;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const FONTS_CSS = `
|
|
319
|
-
@font-face{font-family:"Space Grotesk";src:url("./fonts/space-grotesk-var.woff2") format("woff2-variations");font-weight:300 700;font-style:normal;font-display:block}
|
|
320
|
-
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-400.woff2") format("woff2");font-weight:400;font-style:normal;font-display:block}
|
|
321
|
-
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-500.woff2") format("woff2");font-weight:500;font-style:normal;font-display:block}
|
|
322
|
-
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-700.woff2") format("woff2");font-weight:700;font-style:normal;font-display:block}
|
|
323
|
-
`;
|
|
324
|
-
|
|
325
|
-
function serveReference() {
|
|
326
|
-
const html = referenceHtml();
|
|
327
|
-
const types = { '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.html': 'text/html' };
|
|
328
|
-
const server = http.createServer((req, res) => {
|
|
329
|
-
const p = decodeURIComponent(req.url.split('?')[0]);
|
|
330
|
-
if (p === '/' || p === '/index.html') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(html); }
|
|
331
|
-
if (p === '/fonts.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(FONTS_CSS); }
|
|
332
|
-
let file = null;
|
|
333
|
-
if (p.startsWith('/fonts/')) file = path.join(FONTS, path.basename(p));
|
|
334
|
-
else if (p === '/support.js' || p.startsWith('/vendor/')) file = path.join(DESIGN, p.replace(/^\//, ''));
|
|
335
|
-
if (file && fs.existsSync(file)) {
|
|
336
|
-
res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' });
|
|
337
|
-
return res.end(fs.readFileSync(file));
|
|
338
|
-
}
|
|
339
|
-
res.writeHead(404).end('not found');
|
|
340
|
-
});
|
|
341
|
-
return new Promise((resolve, reject) => {
|
|
342
|
-
server.on('error', reject);
|
|
343
|
-
server.listen(REF_PORT, '127.0.0.1', () => resolve(server));
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/* Click the element whose OWN text is `label`, on whichever page it is given. Returns false when
|
|
348
|
-
* nothing visible carries that text — the caller turns that into a failure rather than skipping the
|
|
349
|
-
* state, because a state that could not be reached is not a state that matched. `closest('button')`
|
|
350
|
-
* is what actually gets clicked: the prototype's runtime wraps each interpolated label in a span, so
|
|
351
|
-
* the text node and the control are usually different elements. */
|
|
352
|
-
async function drive(page, steps) {
|
|
353
|
-
const applied = [];
|
|
354
|
-
for (const label of steps) {
|
|
355
|
-
const hit = await page.evaluate((lab) => {
|
|
356
|
-
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3)
|
|
357
|
-
.map((n) => n.textContent.replace(/\s+/g, ' ').trim()).join(' ').trim();
|
|
358
|
-
const visible = (e) => {
|
|
359
|
-
const cs = getComputedStyle(e);
|
|
360
|
-
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
361
|
-
const r = e.getBoundingClientRect();
|
|
362
|
-
return r.width > 0 && r.height > 0;
|
|
363
|
-
};
|
|
364
|
-
const target = [...document.querySelectorAll('*')].find((e) => own(e) === lab && visible(e));
|
|
365
|
-
if (!target) return false;
|
|
366
|
-
(target.closest('button,[role="button"]') || target).click();
|
|
367
|
-
return true;
|
|
368
|
-
}, label);
|
|
369
|
-
applied.push({ label, hit });
|
|
370
|
-
await page.waitForTimeout(600);
|
|
371
|
-
}
|
|
372
|
-
return applied;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async function snap(browser, url, theme, tag, steps = [], side = null) {
|
|
376
|
-
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
|
|
377
|
-
const errors = [];
|
|
378
|
-
page.on('pageerror', (e) => errors.push(String(e.message)));
|
|
379
|
-
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
|
380
|
-
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
|
381
|
-
await page.waitForTimeout(1200);
|
|
382
|
-
if (theme === 'light') {
|
|
383
|
-
await page.evaluate(() => {
|
|
384
|
-
const b = [...document.querySelectorAll('button')].find((x) => /theme/i.test(x.textContent || ''));
|
|
385
|
-
if (b) b.click();
|
|
386
|
-
});
|
|
387
|
-
await page.waitForTimeout(500);
|
|
388
|
-
}
|
|
389
|
-
// The interaction steps are applied AFTER the theme is settled, so a state is diffed in the theme
|
|
390
|
-
// it was reached in rather than in whatever theme the toggle left behind.
|
|
391
|
-
const applied = await drive(page, steps);
|
|
392
|
-
await page.waitForFunction(() => document.fonts.status === 'loaded', null, { timeout: 15000 }).catch(() => {});
|
|
393
|
-
const png = path.join(OUT, `${tag}.png`);
|
|
394
|
-
await page.screenshot({ path: png });
|
|
395
|
-
// this page's side of each exclusion (see EXCLUSIONS); null when the excluded thing exists on the other page only
|
|
396
|
-
const xs = side ? exclusions.map((x) => ({ name: x.name, loc: x[side] || null, only: x.only || null })) : [];
|
|
397
|
-
const data = await page.evaluate(([props, xs, frame, tokenNames]) => {
|
|
398
|
-
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3)
|
|
399
|
-
.map((n) => n.textContent.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
|
|
400
|
-
const shown = (e) => { const cs = getComputedStyle(e); const r = e.getBoundingClientRect(); return cs.display !== 'none' && r.width > 0 && r.height > 0; };
|
|
401
|
-
// ── mark the excluded elements and phrases, and keep their boxes for the pixel mask ──
|
|
402
|
-
const masks = [], phrases = [];
|
|
403
|
-
for (const x of xs) {
|
|
404
|
-
if (!x.loc) continue;
|
|
405
|
-
const m = { name: x.name, found: 0, boxes: [] };
|
|
406
|
-
masks.push(m);
|
|
407
|
-
if (x.loc.phrase) {
|
|
408
|
-
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
409
|
-
for (let n = tw.nextNode(); n; n = tw.nextNode()) {
|
|
410
|
-
const at = n.textContent.indexOf(x.loc.phrase);
|
|
411
|
-
if (at < 0) continue;
|
|
412
|
-
if (x.loc.parent) { // the element that holds the phrase, for a narrow exclusion
|
|
413
|
-
if (x.only) { n.parentElement.setAttribute('data-parity-only', x.only.join(',')); m.found++; }
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
const rg = document.createRange(); rg.setStart(n, at); rg.setEnd(n, at + x.loc.phrase.length);
|
|
417
|
-
const r = rg.getBoundingClientRect();
|
|
418
|
-
m.found++; m.boxes.push([r.x, r.y, r.width, r.height]); phrases.push({ node: n, phrase: x.loc.phrase });
|
|
419
|
-
}
|
|
420
|
-
continue;
|
|
421
|
-
}
|
|
422
|
-
let hits = x.loc.sel ? [...document.querySelectorAll(x.loc.sel)]
|
|
423
|
-
: [...document.querySelectorAll('body *')].filter((e) => own(e) === x.loc.own && shown(e));
|
|
424
|
-
if (typeof x.loc.nth === 'number') hits = hits.slice(x.loc.nth, x.loc.nth + 1);
|
|
425
|
-
for (let e of hits) {
|
|
426
|
-
for (let i = 0; i < (x.loc.up || 0) && e.parentElement; i++) e = e.parentElement;
|
|
427
|
-
if (typeof x.loc.child === 'number') e = e.children[x.loc.child];
|
|
428
|
-
if (!e) continue;
|
|
429
|
-
if (!shown(e)) continue;
|
|
430
|
-
if (x.only) { e.setAttribute('data-parity-only', x.only.join(',')); m.found++; continue; }
|
|
431
|
-
e.setAttribute('data-parity-excluded', x.name);
|
|
432
|
-
const r = e.getBoundingClientRect();
|
|
433
|
-
m.found++; m.boxes.push([r.x, r.y, r.width, r.height]);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
const cut = (n) => { let t = n.textContent; for (const p of phrases) if (p.node === n) t = t.split(p.phrase).join(' '); return t; };
|
|
437
|
-
const excluded = (e) => e.hasAttribute('data-parity-excluded');
|
|
438
|
-
// an element's whole visible text, leaving out what is excluded inside it
|
|
439
|
-
const fullText = (e) => {
|
|
440
|
-
let t = '';
|
|
441
|
-
for (const c of e.childNodes) {
|
|
442
|
-
if (c.nodeType === 3) t += cut(c);
|
|
443
|
-
else if (c.nodeType === 1 && !excluded(c)) t += ' ' + fullText(c) + ' ';
|
|
444
|
-
}
|
|
445
|
-
return t;
|
|
446
|
-
};
|
|
447
|
-
const root = document.querySelector('[data-pm-theme]') || document.body;
|
|
448
|
-
const nodes = [], texts = [];
|
|
449
|
-
const walk = (e) => {
|
|
450
|
-
if (excluded(e)) return;
|
|
451
|
-
const cs = getComputedStyle(e);
|
|
452
|
-
if (cs.display === 'none') return;
|
|
453
|
-
// 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
|
|
454
|
-
// 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
|
|
455
|
-
// viewport on both, its fill is diffed by the pixel axis, and everything inside it is compared.
|
|
456
|
-
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; }
|
|
457
|
-
const r = e.getBoundingClientRect();
|
|
458
|
-
const ownT = [...e.childNodes].filter((n) => n.nodeType === 3)
|
|
459
|
-
.map((n) => cut(n).replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
|
|
460
|
-
const st = {}; for (const p of props) st[p] = cs[p];
|
|
461
|
-
// `full` is the element's WHOLE visible text, used to anchor style comparison; `own` is only
|
|
462
|
-
// its own text nodes. They differ because the prototype's runtime wraps each interpolation in
|
|
463
|
-
// a span, so its label sits one element deeper than the app's.
|
|
464
|
-
nodes.push({ text: ownT.slice(0, 60), full: fullText(e).replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
465
|
-
box: [r.x, r.y, r.width, r.height].map(Math.round), st, only: (e.getAttribute('data-parity-only') || '').split(',').filter(Boolean) });
|
|
466
|
-
// Walk childNodes IN ORDER, emitting text where it actually sits rather than hoisting all of an
|
|
467
|
-
// element's own text ahead of its children. The status bar is the case that forced this: the
|
|
468
|
-
// design writes "local · persistent server · ", then a <span> holding the count, then
|
|
469
|
-
// " sessions running", so hoisting produced "…server · sessions running 3" and reported a text
|
|
470
|
-
// difference on a bar that is pixel-identical. Reading order is what the PM sees; it is what
|
|
471
|
-
// the stream has to be.
|
|
472
|
-
for (const c of e.childNodes) {
|
|
473
|
-
if (c.nodeType === 3) {
|
|
474
|
-
const s = cut(c).replace(/\s+/g, ' ').trim();
|
|
475
|
-
if (s) texts.push(s);
|
|
476
|
-
} else if (c.nodeType === 1) walk(c);
|
|
477
|
-
}
|
|
478
|
-
};
|
|
479
|
-
walk(root);
|
|
480
|
-
// what each design token resolves to on this page (see TOKENS)
|
|
481
|
-
const tokens = {};
|
|
482
|
-
for (const t of tokenNames) {
|
|
483
|
-
const probe = document.createElement('div');
|
|
484
|
-
probe.style.color = 'var(--' + t + ')';
|
|
485
|
-
root.appendChild(probe);
|
|
486
|
-
tokens[t] = getComputedStyle(probe).color;
|
|
487
|
-
probe.remove();
|
|
488
|
-
}
|
|
489
|
-
return { nodes, texts, masks, tokens };
|
|
490
|
-
}, [PROPS, xs, !!BOARD, BOARD ? TOKENS : []]);
|
|
491
|
-
await page.close();
|
|
492
|
-
return { ...data, errors, png, applied };
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/* 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:
|
|
496
|
-
* --card, --sidebar, --accent and --brand-wash are drawn a shade or two off the D38 values the app uses. R12 decided it: "the
|
|
497
|
-
* mockup's --brand, --success and similar values map to the token system, not to new hex values". So on a board run a computed
|
|
498
|
-
* 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
|
|
499
|
-
* 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
|
|
500
|
-
* differs. The tokens whose values differ are printed with each run. */
|
|
501
|
-
const TOKENS = ['background', 'foreground', 'card', 'sidebar', 'secondary', 'accent', 'muted-foreground', 'border', 'input', 'brand',
|
|
502
|
-
'brand-wash', 'success', 'warning', 'info', 'destructive'];
|
|
503
|
-
let tokenMap = new Map(); // design-resolved value -> app-resolved value, for the tokens whose values differ
|
|
504
|
-
|
|
505
|
-
/* 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
|
|
506
|
-
* 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
|
|
507
|
-
* replace them), and a fallback is never drawn while the first family loads — the fonts are vendored, so the check would be measuring
|
|
508
|
-
* a stack nobody sees. Every other property is compared exactly. */
|
|
509
|
-
const sameValue = (p, a, b) => (BOARD && p === 'fontFamily') ? String(a).split(',')[0] === String(b).split(',')[0]
|
|
510
|
-
: a === b || (BOARD && tokenMap.has(a) && tokenMap.get(a) === b);
|
|
511
|
-
|
|
512
|
-
function diff(A, B, theme) {
|
|
513
|
-
const problems = [];
|
|
514
|
-
if (B.errors.length) problems.push(`console errors in the app (${B.errors.length}): ${B.errors.slice(0, 5).join(' | ')}`);
|
|
515
|
-
// Compare the CONCATENATED stream, not the node list. Node boundaries are an artefact of the
|
|
516
|
-
// prototype's interpolation wrappers: its template runtime wraps every {{ }} in a <span>, so the
|
|
517
|
-
// status bar's session count is three text nodes there and one in the app. The handoff brief calls
|
|
518
|
-
// that runtime an implementation detail, so reproducing its wrapper elements would be copying the
|
|
519
|
-
// scaffolding rather than the design. What the PM reads is the sequence of words on screen, and
|
|
520
|
-
// that is what has to match — in content and in order.
|
|
521
|
-
const streamA = A.texts.join(' ').replace(/\s+/g, ' ').trim();
|
|
522
|
-
const streamB = B.texts.join(' ').replace(/\s+/g, ' ').trim();
|
|
523
|
-
let missing = [], extra = [];
|
|
524
|
-
if (streamA !== streamB) {
|
|
525
|
-
const wa = streamA.split(' '), wb = streamB.split(' ');
|
|
526
|
-
let i = 0;
|
|
527
|
-
while (i < wa.length && i < wb.length && wa[i] === wb[i]) i++;
|
|
528
|
-
missing = wa.slice(i, i + 20); extra = wb.slice(i, i + 20);
|
|
529
|
-
problems.push('the rendered text differs — first divergence at word ' + i + ':\n design: …' +
|
|
530
|
-
wa.slice(Math.max(0, i - 6), i + 14).join(' ') + '\n app: …' +
|
|
531
|
-
wb.slice(Math.max(0, i - 6), i + 14).join(' '));
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Anchor on FULL text, keeping the OUTERMOST match. The walk is pre-order, so a parent is seen
|
|
535
|
-
// before its children and first-wins yields the outermost element carrying that text — pairing the
|
|
536
|
-
// design's <button> with the app's <button>, rather than pairing the design's interpolation <span>
|
|
537
|
-
// against the app's button and reporting the button's own padding and border as differences.
|
|
538
|
-
const index = (ns) => { const m = new Map(); for (const n of ns) if (n.full && !m.has(n.full)) m.set(n.full, n); return m; };
|
|
539
|
-
const ia = index(A.nodes), ib = index(B.nodes);
|
|
540
|
-
const sd = [];
|
|
541
|
-
for (const [t, na] of ia) {
|
|
542
|
-
const nb = ib.get(t); if (!nb) continue;
|
|
543
|
-
const skipped = new Set([...(na.only || []), ...(nb.only || [])]); // a narrow exclusion's listed comparisons (see EXCLUSIONS)
|
|
544
|
-
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]}`);
|
|
545
|
-
const off = na.box.map((v, i) => Math.abs(v - nb.box[i]));
|
|
546
|
-
if (!skipped.has('box') && Math.max(...off) > 2) sd.push(` [${JSON.stringify(t.slice(0, 30))}] box: design=${na.box} app=${nb.box}`);
|
|
547
|
-
}
|
|
548
|
-
if (sd.length) {
|
|
549
|
-
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'));
|
|
550
|
-
try { fs.writeFileSync(path.join(OUT, 'differences-' + theme + '.txt'), sd.join('\n') + '\n'); } catch { /* the report is still printed */ }
|
|
551
|
-
}
|
|
552
|
-
return { problems, missing, extra, styleDiffs: sd, theme };
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
(async () => {
|
|
556
|
-
const pw = requireOptional('playwright-core');
|
|
557
|
-
if (!pw) skip('playwright-core is not installed (npm install in workspace/)');
|
|
558
|
-
// Discovery is lib/find-browser.js's, not a third private candidate list. This file used to hunt
|
|
559
|
-
// only under /opt/pw-browsers, so on a runner whose browser is /usr/bin/chromium-browser it found
|
|
560
|
-
// nothing and reported a SKIP — from a machine that had a browser the whole time.
|
|
561
|
-
const exe = require('./lib/find-browser').findBrowser();
|
|
562
|
-
if (!exe) skip('no Chrome/Chromium found (set PMOS_CHROME, or install one)');
|
|
563
|
-
if (!BOARD && !fs.existsSync(path.join(DESIGN, 'prototype-original.html'))) skip(`the design reference is missing at ${DESIGN}`);
|
|
564
|
-
|
|
565
|
-
const alive = await new Promise((r) => {
|
|
566
|
-
const req = http.get(APP_URL, (res) => { res.resume(); r(true); });
|
|
567
|
-
req.on('error', () => r(false)); req.setTimeout(3000, () => { req.destroy(); r(false); });
|
|
568
|
-
});
|
|
569
|
-
if (!alive) skip(`the workspace is not running at ${APP_URL} (node server.js)`);
|
|
570
|
-
|
|
571
|
-
fs.mkdirSync(OUT, { recursive: true });
|
|
572
|
-
const server = await serveReference();
|
|
573
|
-
const browser = await pw.chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--font-render-hinting=none'] });
|
|
574
|
-
|
|
575
|
-
let failed = 0, compared = 0;
|
|
576
|
-
const report = {};
|
|
577
|
-
for (const theme of themes) for (const st of states) {
|
|
578
|
-
const key = `${theme}/${st.id}`;
|
|
579
|
-
const A = await snap(browser, `http://127.0.0.1:${REF_PORT}/index.html`, theme, `design-${theme}-${st.id}`, st.steps, 'design');
|
|
580
|
-
const B = await snap(browser, `${APP_URL}/?fixture=${BOARD ? 'cockpit' : 'proto'}`, theme, `app-${theme}-${st.id}`, st.steps, 'app');
|
|
581
|
-
tokenMap = new Map();
|
|
582
|
-
const mapped = [];
|
|
583
|
-
for (const t of TOKENS) {
|
|
584
|
-
const dv = A.tokens && A.tokens[t], av = B.tokens && B.tokens[t];
|
|
585
|
-
if (dv && av && dv !== av && !tokenMap.has(dv)) { tokenMap.set(dv, av); mapped.push(`--${t}: ${dv} → ${av}`); }
|
|
586
|
-
}
|
|
587
|
-
if (mapped.length) console.log(`\ntoken mapping (PRD R12), by name: ${mapped.join(' · ')}`);
|
|
588
|
-
const d = diff(A, B, theme);
|
|
589
|
-
|
|
590
|
-
// COULD THE STATE BE REACHED AT ALL? Asserted before anything is compared, and on BOTH pages: a
|
|
591
|
-
// step that found no label leaves the page in the previous state, where a clean diff would say
|
|
592
|
-
// only that two unchanged screens still match. Reported as a failure with the step named.
|
|
593
|
-
const missedD = A.applied.filter((s) => !s.hit).map((s) => s.label);
|
|
594
|
-
const missedA = B.applied.filter((s) => !s.hit).map((s) => s.label);
|
|
595
|
-
if (missedD.length || missedA.length) {
|
|
596
|
-
d.problems.push(`the state could not be reached — ` +
|
|
597
|
-
(missedD.length ? `no visible "${missedD.join('", "')}" in the DESIGN; ` : '') +
|
|
598
|
-
(missedA.length ? `no visible "${missedA.join('", "')}" in the APP; ` : '') +
|
|
599
|
-
`nothing below this line is a comparison of ${st.id}`);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// A STALE EXCLUSION FAILS: every side an exclusion names must still match something on that page.
|
|
603
|
-
const stale = [];
|
|
604
|
-
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()}`);
|
|
605
|
-
if (stale.length) d.problems.push('stale exclusion(s) — remove or repair them in EXCLUSIONS:\n ' + stale.join('\n '));
|
|
606
|
-
const maskBoxes = [...A.masks, ...B.masks].flatMap((m) => m.boxes.map((b) => ({ name: m.name, b })));
|
|
607
|
-
|
|
608
|
-
let pixels = null, maskedPct = 0;
|
|
609
|
-
const pixelmatch = requireOptional('pixelmatch'); const pngjs = requireOptional('pngjs');
|
|
610
|
-
if (pixelmatch && pngjs) {
|
|
611
|
-
const pm = pixelmatch.default || pixelmatch;
|
|
612
|
-
const a = pngjs.PNG.sync.read(fs.readFileSync(A.png)), b = pngjs.PNG.sync.read(fs.readFileSync(B.png));
|
|
613
|
-
if (a.width === b.width && a.height === b.height) {
|
|
614
|
-
// Paint each excluded box identically on both screenshots (the design's pixels are copied into the app's), so only what is
|
|
615
|
-
// compared can differ. The masked area is counted and reported, not hidden.
|
|
616
|
-
const masked = new Uint8Array(a.width * a.height);
|
|
617
|
-
for (const { b: [x0, y0, w0, h0] } of maskBoxes) {
|
|
618
|
-
const x1 = Math.max(0, Math.floor(x0)), y1 = Math.max(0, Math.floor(y0));
|
|
619
|
-
const x2 = Math.min(a.width, Math.ceil(x0 + w0)), y2 = Math.min(a.height, Math.ceil(y0 + h0));
|
|
620
|
-
for (let y = y1; y < y2; y++) for (let x = x1; x < x2; x++) {
|
|
621
|
-
const i = y * a.width + x; masked[i] = 1;
|
|
622
|
-
for (let k = 0; k < 4; k++) b.data[i * 4 + k] = a.data[i * 4 + k];
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
let nMasked = 0; for (const v of masked) nMasked += v;
|
|
626
|
-
maskedPct = +(100 * nMasked / (a.width * a.height)).toFixed(2);
|
|
627
|
-
const out = new pngjs.PNG({ width: a.width, height: a.height });
|
|
628
|
-
const n = pm(a.data, b.data, out.data, a.width, a.height, { threshold: 0.12 });
|
|
629
|
-
fs.writeFileSync(path.join(OUT, `diff-${theme}-${st.id}.png`), pngjs.PNG.sync.write(out));
|
|
630
|
-
pixels = n / (a.width * a.height);
|
|
631
|
-
if (pixels > 0.005) d.problems.push(`pixel diff ${(pixels * 100).toFixed(2)}% of the viewport — ${path.join(OUT, `diff-${theme}-${st.id}.png`)}`);
|
|
632
|
-
} else d.problems.push(`screenshot sizes differ: design ${a.width}x${a.height}, app ${b.width}x${b.height}`);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
report[key] = {
|
|
636
|
-
steps: st.steps,
|
|
637
|
-
designNodes: A.nodes.length, appNodes: B.nodes.length,
|
|
638
|
-
textMissing: d.missing.length, textExtra: d.extra.length,
|
|
639
|
-
styleDiffs: d.styleDiffs.length,
|
|
640
|
-
exclusions: (EXCLUSIONS[BOARD] || []).map((x) => x.name),
|
|
641
|
-
maskedPct,
|
|
642
|
-
pixelPct: pixels === null ? 'not measured (pixelmatch/pngjs absent)' : +(pixels * 100).toFixed(3),
|
|
643
|
-
clean: d.problems.length === 0,
|
|
644
|
-
};
|
|
645
|
-
compared++;
|
|
646
|
-
console.log(`\n── ${key} ──`);
|
|
647
|
-
console.log(JSON.stringify(report[key], null, 1));
|
|
648
|
-
if (d.problems.length) { failed++; console.log('\n' + d.problems.join('\n\n')); }
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
await browser.close();
|
|
652
|
-
server.close();
|
|
653
|
-
fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 1));
|
|
654
|
-
if (!KEEP && !failed) for (const f of fs.readdirSync(OUT)) if (f.endsWith('.png')) fs.unlinkSync(path.join(OUT, f));
|
|
655
|
-
|
|
656
|
-
if (exclusions.length) {
|
|
657
|
-
const top = EXCLUSIONS[BOARD] || [];
|
|
658
|
-
console.log(`\n${top.length} named exclusion(s) on board ${BOARD} — each a decision, not a defect:`);
|
|
659
|
-
for (const x of top) {
|
|
660
|
-
console.log(` · ${x.name}${x.only ? ' (only: ' + x.only.join(', ') + ')' : ''} — ${x.why}`);
|
|
661
|
-
for (const f of x.follows || []) console.log(` ↳ follows: ${f.what} (only: ${f.only.join(', ')})`);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
const scope = `${themes.join(' and ')} × ${states.length} state(s) [${states.map((s) => s.id).join(', ')}]`;
|
|
665
|
-
if (failed) {
|
|
666
|
-
console.log(`\n❌ design parity FAILED in ${failed}/${compared} comparison(s). Renders kept in ${OUT}.`);
|
|
667
|
-
process.exit(1);
|
|
668
|
-
}
|
|
669
|
-
console.log(`\n✅ design parity — the app is identical to ${BOARD ? 'the cockpit board ' + BOARD : "the PM's prototype"} across ${scope} — ${compared} comparison(s).`);
|
|
670
|
-
if (states.length === 1 && states[0].id === 'default') {
|
|
671
|
-
console.log(' (default state only. `--states` walks all ' + STATES.length + ', which is what the PRD asks for.)');
|
|
672
|
-
}
|
|
673
|
-
})().catch((e) => { console.error('design-parity ERROR:', e.stack); process.exit(2); });
|