create-pmos 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +1 -1
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/find-browser.js +60 -0
  7. package/workspace/lib/four-panel.js +345 -0
  8. package/workspace/lib/git-info.js +28 -1
  9. package/workspace/lib/mobile-nav.js +32 -2
  10. package/workspace/package-lock.json +29 -2
  11. package/workspace/package.json +13 -5
  12. package/workspace/public/index.html +79 -409
  13. package/workspace/public/pm/app.js +242 -0
  14. package/workspace/public/pm/data.js +877 -0
  15. package/workspace/public/pm/dom.js +116 -0
  16. package/workspace/public/pm/fixture.js +979 -0
  17. package/workspace/public/pm/mobile.css +200 -0
  18. package/workspace/public/pm/mobile.js +403 -0
  19. package/workspace/public/pm/modes.js +1076 -0
  20. package/workspace/public/pm/panel-detail.js +648 -0
  21. package/workspace/public/pm/panel-initiatives.js +426 -0
  22. package/workspace/public/pm/panel-repos.js +460 -0
  23. package/workspace/public/pm/panel-tabs.js +487 -0
  24. package/workspace/public/pm/state.js +164 -0
  25. package/workspace/public/pm/tab-doc.js +279 -0
  26. package/workspace/public/pm/tab-task.js +83 -0
  27. package/workspace/public/pm/tab-term.js +560 -0
  28. package/workspace/public/pm/tokens.css +73 -0
  29. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  30. package/workspace/server.js +158 -8
  31. package/workspace/focus.test.js +0 -650
  32. package/workspace/handoff.test.js +0 -443
  33. package/workspace/profiles.test.js +0 -362
  34. package/workspace/public/app.js +0 -5560
  35. package/workspace/public/styles.css +0 -2258
  36. package/workspace/repo-scan.test.js +0 -327
package/README.md CHANGED
@@ -333,6 +333,44 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.16.0
337
+
338
+ **The Terminal Workspace is now a four-panel surface.** The three-column layout is retired and
339
+ replaced by one built to a PM-authored design: repositories, initiatives, initiative detail, and a
340
+ tab strip. Run `npx create-pmos@latest workspace`; the version bump means an existing install
341
+ auto-upgrades.
342
+
343
+ - **Panel 1 — repositories.** Every repo you've added, with its kind (`⎇` github / `▤` local) and
344
+ its initiative count, plus a settings & tools drawer holding the secondary modes — `agents`,
345
+ `memory`, `knowledge` for the current repository, and `focus`, `skills`, `connections`,
346
+ `scheduled` across all of them.
347
+ - **Panel 2 — initiatives, as a channel list.** Collapsible objective/KR groups over `#slug` rows,
348
+ one search box that forces the groups open and reports matches elsewhere, and an add form.
349
+ Initiatives with no anchor are filed and shown as rejected (D59) rather than quietly hidden.
350
+ - **Panel 3 — initiative detail.** The record's pills and derived footer, **task phases** whose
351
+ status is derived from the workflow ladder rather than declared, a **links** section for
352
+ references that are not repo files, and the artifact list. A record that fails to parse renders
353
+ red with the parse error — never a blank panel.
354
+ - **Panel 4 — tabs, scoped per repository.** Three kinds side by side: **term** (a real PTY running
355
+ your agent CLI untouched), **doc** (view/raw/edit/diff with save, revert and a dirty flag), and
356
+ **task** (the task spec view). New session, close, and split.
357
+ - **A phone surface.** The same four panels collapse into a mobile navigation model, so triage from
358
+ a phone is a first-class path and not a scaled-down desktop.
359
+ - **Artifact links are declarations, not integrations.** Linking an artifact to an external
360
+ destination appends `{artifact_path, kind, target}` to `pmos/state/artifact-links.jsonl`. The sync
361
+ panel is rendered, but with no connector configured its summary line says exactly that — *no
362
+ connector configured — nothing was read, and nothing will be written* — and the approval buttons
363
+ are disabled. It will not show you a difference it never read.
364
+ - **`?fixture=proto`** renders the whole surface from a bundled fixture with no repository at all,
365
+ which is also how the design comparison is run. It is never the default path.
366
+
367
+ Unchanged where it matters: **no database, no network at runtime, no telemetry.** The front end
368
+ loads only same-origin assets — fonts and the terminal emulator are served from the install, never
369
+ from a CDN — the server binds `127.0.0.1`, and the only outbound paths remain the ones you invoke
370
+ yourself (`git clone` on repo ingest, `pmos upgrade`, and the one-time `npm install` that builds
371
+ node-pty). No new runtime dependency was added. The file-mode kit itself is unchanged and still
372
+ dependency-free.
373
+
336
374
  ## What's new in 0.15.0
337
375
 
338
376
  **A major Terminal Workspace rebuild** (#242) — the PM spine, a Claude Design refresh, and a proper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@7b15792
1
+ kernel-source: pmos@198c57c
2
2
  assembled: 2026-07-30
3
3
  mode: vendor
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Reference
3
3
  title: "PMOS Terminal Workspace"
4
- timestamp: 2026-07-25
4
+ timestamp: 2026-07-30
5
5
  ---
6
6
 
7
7
  # PMOS Terminal Workspace
@@ -49,14 +49,34 @@ npm run test:ui # browser checks: terminal geometry, reader scroll, markdow
49
49
  ```
50
50
  </details>
51
51
 
52
- ## Layout (PM-directed, D62 R9 + round 2)
53
- Three columns. **Left = the workspace**: repos on top, running agents below, equal halves.
54
- **Middle = the terminal**, sessions as tabs. **Right rail = the library over the document
55
- reader** — either pane folds away (click its header), the whole rail hides (`rail`), and both
56
- the split and the rail width are draggable. The layout you choose survives a reload.
57
-
58
- - **Add a repo by picking a folder** — `+` opens your machine's own folder chooser (typing a
59
- path still works; where no chooser exists, an in-app folder browser stands in).
52
+ ## Layout — four panels (PM-directed design, built by `workspace-four-panel`)
53
+ The three-column layout is retired. The surface is now **four collapsible panels** under a 44px top
54
+ bar and a 26px status bar, on the oklch token layer, in both themes:
55
+
56
+ 1. **Repositories** — every repo you've added, with its kind (`⎇` github / `▤` local) and a
57
+ `github|local · N initiatives` sub-line. `+` adds one by picking a folder (typing a path still
58
+ works; where no chooser exists, an in-app folder browser stands in). The **settings & tools**
59
+ drawer holds the secondary modes in two groups: *this repository* (`agents` · `memory` ·
60
+ `knowledge`) and *all repositories* (`focus` · `skills` · `connections` · `scheduled`). There is
61
+ deliberately no `workflow` entry — workflows are decided by the agent at runtime, not configured.
62
+ 2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
63
+ box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
64
+ initiatives are filed honestly and shown **rejected** under D59, never hidden.
65
+ 3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the derived
66
+ footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
67
+ are derived from the workflow ladder unless the record authors them), a **links** section for
68
+ references that are not repo files, and **artifacts** with an external destination and the sync
69
+ panel. A record that fails to read renders **red with the parse error** — never a blank panel.
70
+ 4. **Tabs** — scoped per repository, two-line labels naming the initiative, three kinds: **term** (a
71
+ real PTY), **doc** (view/raw/edit/diff, save/revert, dirty flag), and **task** (the task spec).
72
+ `+ session`, close, split.
73
+
74
+ `⌘K`/`Ctrl-K` focuses search; `Esc` closes the active tab. The panel state you choose survives a
75
+ reload.
76
+
77
+ - **A phone surface.** The same four panel modules render on a phone — one shell, one renderer, no
78
+ phone-shaped copy of a list. `lib/mobile-nav.js` owns the destinations and the stack; the live
79
+ terminal host is never re-parented, so a session you navigate away from keeps running.
60
80
  - **Rename a session any time** — the ✎ on its row, a double-click on its tab, or `F2`. Live or
61
81
  exited, and it survives a server restart.
62
82
  - **Documents read like documents** — the identity block becomes a card, tables are tables,
@@ -66,6 +86,18 @@ the split and the rail width are draggable. The layout you choose survives a rel
66
86
  denied); markdown renders **repo-relative images inline**. A remote `![](https://…)` image is
67
87
  never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).
68
88
 
89
+ **Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the
90
+ server unable to read any repository — that is how the design comparison is run
91
+ (`npm run test:parity`), and it is never the default.
92
+
93
+ ## Artifact links are declarations, not integrations
94
+ PMOS makes no outbound request and holds no credential. Linking an artifact to an external
95
+ destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to
96
+ `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector
97
+ configured its summary line says exactly that: *no connector configured — nothing was read, and
98
+ nothing will be written*, with the approval buttons disabled. A difference summary it never read
99
+ would be the watermelon this repo has a word for.
100
+
69
101
  ## The gates are real, never automatic (R5)
70
102
  `approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and
71
103
  clear the matching needs entry — only ever on your click.
@@ -83,9 +115,9 @@ count them. `pm_label` is **never** derived from a click — κ labels are the P
83
115
 
84
116
  ## Your own runs are searchable (the history lens)
85
117
  Every session's scrollback is already mirrored to disk, which makes it a record of what earlier
86
- runs actually **did** — the thing structured run rows don't carry. The rail's fourth lens,
87
- **history**, searches it; the filter box becomes the query. An agent working in a pane can ask
88
- the same question itself: `GET /api/search?q=…&ws=…`.
118
+ runs actually **did** — the thing structured run rows don't carry. `GET /api/search?q=…&ws=…`
119
+ searches it, and an agent working in a pane can ask the question itself. (The four-panel rebuild
120
+ has not yet re-surfaced this as a UI lens — the endpoint is live, the front-end entry point is not.)
89
121
 
90
122
  Two guarantees. It is scoped to **one repo** — a workspace never searches another's history
91
123
  (R1/R3), and an unrecognised workspace id resolves to home, so it can only ever see less.
@@ -0,0 +1,358 @@
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 both themes, default state
29
+ * node design-parity.js --theme=light one theme
30
+ * node design-parity.js --states also walk the interaction states (see STATES below)
31
+ * node design-parity.js --state=<id> one named state
32
+ * node design-parity.js --keep keep the rendered PNGs for inspection
33
+ *
34
+ * THE STATE WALK. `--states` was advertised in this header for two review rounds while doing
35
+ * nothing — the flag was never read, so "identical across the captured interaction states" (PRD
36
+ * success criterion 1, and the rubric's test_method) was decided on the opening screen alone. It is
37
+ * implemented now: each state is a list of LABELS to click, applied to the prototype and to the app
38
+ * from a fresh load, and the same four axes are diffed at the end of the sequence. The steps are
39
+ * text, not selectors, deliberately — a selector would encode one page's DOM and quietly stop
40
+ * matching on the other, which is the shape a check takes when it goes decorative. A step that
41
+ * cannot find its label on EITHER page is a hard failure: "could not drive the app there" must
42
+ * never be recorded as "the app matched there".
43
+ *
44
+ * Exit 0 only when every axis is clean. Exit 3 means it could not run (no browser, no server) —
45
+ * which is a skip, and is reported as one rather than as a pass.
46
+ */
47
+ 'use strict';
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+ const http = require('http');
51
+ const os = require('os');
52
+
53
+ const ROOT = path.resolve(__dirname, '..');
54
+ const DESIGN = path.join(ROOT, 'planning', 'design', 'four-panel');
55
+ const FONTS = path.join(__dirname, 'public', 'vendor');
56
+ const APP_URL = process.env.PMOS_PARITY_APP || 'http://127.0.0.1:7433';
57
+ const REF_PORT = +(process.env.PMOS_PARITY_PORT || 8899);
58
+ const OUT = process.env.PMOS_PARITY_OUT || path.join(os.tmpdir(), 'pmos-design-parity');
59
+
60
+ const argv = process.argv.slice(2);
61
+ const themes = argv.some((a) => a.startsWith('--theme=')) ? [argv.find((a) => a.startsWith('--theme=')).split('=')[1]] : ['dark', 'light'];
62
+ const KEEP = argv.includes('--keep');
63
+ const W = 1600, H = 1000;
64
+
65
+ /* THE CAPTURED INTERACTION STATES.
66
+ *
67
+ * Each entry is a state the PM can put the surface into, expressed as the labels a person would
68
+ * click to get there — read as the element's OWN text, so "add repository" means the span holding
69
+ * exactly that, in either page's markup. `default` is the opening screen and always runs.
70
+ *
71
+ * Chosen because each one changes a DIFFERENT part of the layout, so the set covers the interaction
72
+ * model the handoff brief calls final rather than repeating one kind of change: a drawer over panel
73
+ * 1, a collapsed group in panel 2, an expanded task tree and an opened tab in panels 3 and 4, a
74
+ * collapsed column, and the panel-3 inline expansion. A state whose divergence is a DECISION rather
75
+ * than a defect does not belong here — it belongs in the PRD — so if one is ever excluded, the
76
+ * reason goes in `why` and gets printed, not left as an absence nobody can see.
77
+ */
78
+ const STATES = [
79
+ { id: 'default', steps: [] },
80
+ { id: 'settings-drawer', steps: ['settings & tools'] },
81
+ { id: 'group-collapsed', steps: ['health:tech'] },
82
+ { id: 'phase-collapsed', steps: ['intake'] },
83
+ { id: 'task-tab-open', steps: ['produce the build output'] },
84
+ { id: 'column-collapsed', steps: ['⟨'] },
85
+ { id: 'add-repository', steps: ['add repository'] },
86
+ ];
87
+ const stateArg = argv.find((a) => a.startsWith('--state='));
88
+ const states = stateArg
89
+ ? STATES.filter((s) => s.id === stateArg.split('=')[1])
90
+ : (argv.includes('--states') ? STATES : STATES.filter((s) => s.id === 'default'));
91
+ if (!states.length) {
92
+ console.error(`no such state: ${stateArg.split('=')[1]} — known: ${STATES.map((s) => s.id).join(', ')}`);
93
+ process.exit(2);
94
+ }
95
+
96
+ const PROPS = [
97
+ 'display', 'gridTemplateColumns', 'gridTemplateRows', 'flexDirection', 'alignItems',
98
+ 'justifyContent', 'gap', 'minHeight', 'padding', 'backgroundColor', 'color',
99
+ 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
100
+ 'borderLeftColor', 'borderBottomColor', 'borderRadius', 'fontFamily', 'fontSize', 'fontWeight',
101
+ 'lineHeight', 'letterSpacing', 'textTransform', 'writingMode', 'overflowX', 'overflowY',
102
+ ];
103
+
104
+ function skip(why) { console.log(`⏭ design-parity SKIPPED — ${why}`); process.exit(3); }
105
+
106
+ function requireOptional(name) { try { return require(name); } catch { return null; } }
107
+
108
+ // ── the reference page, patched offline in memory ───────────────────────────────────────────────
109
+ function referenceHtml() {
110
+ const src = fs.readFileSync(path.join(DESIGN, 'prototype-original.html'), 'utf8');
111
+ const map = JSON.stringify({
112
+ 'https://unpkg.com/react@18.3.1/umd/react.production.min.js': './vendor/react.production.min.js',
113
+ 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js': './vendor/react-dom.production.min.js',
114
+ });
115
+ let h = src.replace('<script src="./support.js"></script>',
116
+ `<script>window.__resources=${map};</script>\n<script src="./support.js"></script>`);
117
+ h = h.replace(/<link rel="preconnect"[^>]*>\s*/g, '');
118
+ h = h.replace(/<link href="https:\/\/fonts\.googleapis\.com[^>]*>/, '<link rel="stylesheet" href="./fonts.css">');
119
+ if (/fonts\.googleapis|unpkg\.com/.test(h.replace(map, ''))) {
120
+ throw new Error('reference page still carries a remote asset after patching — refusing to compare against a page that would need the network');
121
+ }
122
+ return h;
123
+ }
124
+
125
+ const FONTS_CSS = `
126
+ @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}
127
+ @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}
128
+ @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}
129
+ @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}
130
+ `;
131
+
132
+ function serveReference() {
133
+ const html = referenceHtml();
134
+ const types = { '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.html': 'text/html' };
135
+ const server = http.createServer((req, res) => {
136
+ const p = decodeURIComponent(req.url.split('?')[0]);
137
+ if (p === '/' || p === '/index.html') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(html); }
138
+ if (p === '/fonts.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(FONTS_CSS); }
139
+ let file = null;
140
+ if (p.startsWith('/fonts/')) file = path.join(FONTS, path.basename(p));
141
+ else if (p === '/support.js' || p.startsWith('/vendor/')) file = path.join(DESIGN, p.replace(/^\//, ''));
142
+ if (file && fs.existsSync(file)) {
143
+ res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' });
144
+ return res.end(fs.readFileSync(file));
145
+ }
146
+ res.writeHead(404).end('not found');
147
+ });
148
+ return new Promise((resolve, reject) => {
149
+ server.on('error', reject);
150
+ server.listen(REF_PORT, '127.0.0.1', () => resolve(server));
151
+ });
152
+ }
153
+
154
+ /* Click the element whose OWN text is `label`, on whichever page it is given. Returns false when
155
+ * nothing visible carries that text — the caller turns that into a failure rather than skipping the
156
+ * state, because a state that could not be reached is not a state that matched. `closest('button')`
157
+ * is what actually gets clicked: the prototype's runtime wraps each interpolated label in a span, so
158
+ * the text node and the control are usually different elements. */
159
+ async function drive(page, steps) {
160
+ const applied = [];
161
+ for (const label of steps) {
162
+ const hit = await page.evaluate((lab) => {
163
+ const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3)
164
+ .map((n) => n.textContent.replace(/\s+/g, ' ').trim()).join(' ').trim();
165
+ const visible = (e) => {
166
+ const cs = getComputedStyle(e);
167
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
168
+ const r = e.getBoundingClientRect();
169
+ return r.width > 0 && r.height > 0;
170
+ };
171
+ const target = [...document.querySelectorAll('*')].find((e) => own(e) === lab && visible(e));
172
+ if (!target) return false;
173
+ (target.closest('button,[role="button"]') || target).click();
174
+ return true;
175
+ }, label);
176
+ applied.push({ label, hit });
177
+ await page.waitForTimeout(600);
178
+ }
179
+ return applied;
180
+ }
181
+
182
+ async function snap(browser, url, theme, tag, steps = []) {
183
+ const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
184
+ const errors = [];
185
+ page.on('pageerror', (e) => errors.push(String(e.message)));
186
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
187
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
188
+ await page.waitForTimeout(1200);
189
+ if (theme === 'light') {
190
+ await page.evaluate(() => {
191
+ const b = [...document.querySelectorAll('button')].find((x) => /theme/i.test(x.textContent || ''));
192
+ if (b) b.click();
193
+ });
194
+ await page.waitForTimeout(500);
195
+ }
196
+ // The interaction steps are applied AFTER the theme is settled, so a state is diffed in the theme
197
+ // it was reached in rather than in whatever theme the toggle left behind.
198
+ const applied = await drive(page, steps);
199
+ await page.waitForFunction(() => document.fonts.status === 'loaded', null, { timeout: 15000 }).catch(() => {});
200
+ const png = path.join(OUT, `${tag}.png`);
201
+ await page.screenshot({ path: png });
202
+ const data = await page.evaluate((props) => {
203
+ const root = document.querySelector('[data-pm-theme]') || document.body;
204
+ const nodes = [], texts = [];
205
+ const walk = (e) => {
206
+ const cs = getComputedStyle(e);
207
+ if (cs.display === 'none') return;
208
+ const r = e.getBoundingClientRect();
209
+ const own = [...e.childNodes].filter((n) => n.nodeType === 3)
210
+ .map((n) => n.textContent.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
211
+ const st = {}; for (const p of props) st[p] = cs[p];
212
+ // `full` is the element's WHOLE visible text, used to anchor style comparison; `own` is only
213
+ // its own text nodes. They differ because the prototype's runtime wraps each interpolation in
214
+ // a span, so its label sits one element deeper than the app's.
215
+ nodes.push({ text: own.slice(0, 60), full: (e.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
216
+ box: [r.x, r.y, r.width, r.height].map(Math.round), st });
217
+ // Walk childNodes IN ORDER, emitting text where it actually sits rather than hoisting all of an
218
+ // element's own text ahead of its children. The status bar is the case that forced this: the
219
+ // design writes "local · persistent server · ", then a <span> holding the count, then
220
+ // " sessions running", so hoisting produced "…server · sessions running 3" and reported a text
221
+ // difference on a bar that is pixel-identical. Reading order is what the PM sees; it is what
222
+ // the stream has to be.
223
+ for (const c of e.childNodes) {
224
+ if (c.nodeType === 3) {
225
+ const s = c.textContent.replace(/\s+/g, ' ').trim();
226
+ if (s) texts.push(s);
227
+ } else if (c.nodeType === 1) walk(c);
228
+ }
229
+ };
230
+ walk(root);
231
+ return { nodes, texts };
232
+ }, PROPS);
233
+ await page.close();
234
+ return { ...data, errors, png, applied };
235
+ }
236
+
237
+ function diff(A, B, theme) {
238
+ const problems = [];
239
+ if (B.errors.length) problems.push(`console errors in the app (${B.errors.length}): ${B.errors.slice(0, 5).join(' | ')}`);
240
+ // Compare the CONCATENATED stream, not the node list. Node boundaries are an artefact of the
241
+ // prototype's interpolation wrappers: its template runtime wraps every {{ }} in a <span>, so the
242
+ // status bar's session count is three text nodes there and one in the app. The handoff brief calls
243
+ // that runtime an implementation detail, so reproducing its wrapper elements would be copying the
244
+ // scaffolding rather than the design. What the PM reads is the sequence of words on screen, and
245
+ // that is what has to match — in content and in order.
246
+ const streamA = A.texts.join(' ').replace(/\s+/g, ' ').trim();
247
+ const streamB = B.texts.join(' ').replace(/\s+/g, ' ').trim();
248
+ let missing = [], extra = [];
249
+ if (streamA !== streamB) {
250
+ const wa = streamA.split(' '), wb = streamB.split(' ');
251
+ let i = 0;
252
+ while (i < wa.length && i < wb.length && wa[i] === wb[i]) i++;
253
+ missing = wa.slice(i, i + 20); extra = wb.slice(i, i + 20);
254
+ problems.push('the rendered text differs — first divergence at word ' + i + ':\n design: …' +
255
+ wa.slice(Math.max(0, i - 6), i + 14).join(' ') + '\n app: …' +
256
+ wb.slice(Math.max(0, i - 6), i + 14).join(' '));
257
+ }
258
+
259
+ // Anchor on FULL text, keeping the OUTERMOST match. The walk is pre-order, so a parent is seen
260
+ // before its children and first-wins yields the outermost element carrying that text — pairing the
261
+ // design's <button> with the app's <button>, rather than pairing the design's interpolation <span>
262
+ // against the app's button and reporting the button's own padding and border as differences.
263
+ 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; };
264
+ const ia = index(A.nodes), ib = index(B.nodes);
265
+ const sd = [];
266
+ for (const [t, na] of ia) {
267
+ const nb = ib.get(t); if (!nb) continue;
268
+ for (const p of PROPS) if (na.st[p] !== nb.st[p]) sd.push(` [${JSON.stringify(t.slice(0, 30))}] ${p}: design=${na.st[p]} app=${nb.st[p]}`);
269
+ 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}`);
271
+ }
272
+ if (sd.length) problems.push(`${sd.length} computed-style / geometry difference(s):\n` + sd.slice(0, 40).join('\n'));
273
+ return { problems, missing, extra, styleDiffs: sd, theme };
274
+ }
275
+
276
+ (async () => {
277
+ const pw = requireOptional('playwright-core');
278
+ if (!pw) skip('playwright-core is not installed (npm install in workspace/)');
279
+ // Discovery is lib/find-browser.js's, not a third private candidate list. This file used to hunt
280
+ // only under /opt/pw-browsers, so on a runner whose browser is /usr/bin/chromium-browser it found
281
+ // nothing and reported a SKIP — from a machine that had a browser the whole time.
282
+ const exe = require('./lib/find-browser').findBrowser();
283
+ 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}`);
285
+
286
+ const alive = await new Promise((r) => {
287
+ const req = http.get(APP_URL, (res) => { res.resume(); r(true); });
288
+ req.on('error', () => r(false)); req.setTimeout(3000, () => { req.destroy(); r(false); });
289
+ });
290
+ if (!alive) skip(`the workspace is not running at ${APP_URL} (node server.js)`);
291
+
292
+ fs.mkdirSync(OUT, { recursive: true });
293
+ const server = await serveReference();
294
+ const browser = await pw.chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--font-render-hinting=none'] });
295
+
296
+ let failed = 0, compared = 0;
297
+ const report = {};
298
+ for (const theme of themes) for (const st of states) {
299
+ const key = `${theme}/${st.id}`;
300
+ const A = await snap(browser, `http://127.0.0.1:${REF_PORT}/index.html`, theme, `design-${theme}-${st.id}`, st.steps);
301
+ const B = await snap(browser, `${APP_URL}/?fixture=proto`, theme, `app-${theme}-${st.id}`, st.steps);
302
+ const d = diff(A, B, theme);
303
+
304
+ // COULD THE STATE BE REACHED AT ALL? Asserted before anything is compared, and on BOTH pages: a
305
+ // step that found no label leaves the page in the previous state, where a clean diff would say
306
+ // only that two unchanged screens still match. Reported as a failure with the step named.
307
+ const missedD = A.applied.filter((s) => !s.hit).map((s) => s.label);
308
+ const missedA = B.applied.filter((s) => !s.hit).map((s) => s.label);
309
+ if (missedD.length || missedA.length) {
310
+ d.problems.push(`the state could not be reached — ` +
311
+ (missedD.length ? `no visible "${missedD.join('", "')}" in the DESIGN; ` : '') +
312
+ (missedA.length ? `no visible "${missedA.join('", "')}" in the APP; ` : '') +
313
+ `nothing below this line is a comparison of ${st.id}`);
314
+ }
315
+
316
+ let pixels = null;
317
+ const pixelmatch = requireOptional('pixelmatch'); const pngjs = requireOptional('pngjs');
318
+ if (pixelmatch && pngjs) {
319
+ const pm = pixelmatch.default || pixelmatch;
320
+ const a = pngjs.PNG.sync.read(fs.readFileSync(A.png)), b = pngjs.PNG.sync.read(fs.readFileSync(B.png));
321
+ if (a.width === b.width && a.height === b.height) {
322
+ const out = new pngjs.PNG({ width: a.width, height: a.height });
323
+ const n = pm(a.data, b.data, out.data, a.width, a.height, { threshold: 0.12 });
324
+ fs.writeFileSync(path.join(OUT, `diff-${theme}-${st.id}.png`), pngjs.PNG.sync.write(out));
325
+ pixels = n / (a.width * a.height);
326
+ if (pixels > 0.005) d.problems.push(`pixel diff ${(pixels * 100).toFixed(2)}% of the viewport — ${path.join(OUT, `diff-${theme}-${st.id}.png`)}`);
327
+ } else d.problems.push(`screenshot sizes differ: design ${a.width}x${a.height}, app ${b.width}x${b.height}`);
328
+ }
329
+
330
+ report[key] = {
331
+ steps: st.steps,
332
+ designNodes: A.nodes.length, appNodes: B.nodes.length,
333
+ textMissing: d.missing.length, textExtra: d.extra.length,
334
+ styleDiffs: d.styleDiffs.length,
335
+ pixelPct: pixels === null ? 'not measured (pixelmatch/pngjs absent)' : +(pixels * 100).toFixed(3),
336
+ clean: d.problems.length === 0,
337
+ };
338
+ compared++;
339
+ console.log(`\n── ${key} ──`);
340
+ console.log(JSON.stringify(report[key], null, 1));
341
+ if (d.problems.length) { failed++; console.log('\n' + d.problems.join('\n\n')); }
342
+ }
343
+
344
+ await browser.close();
345
+ server.close();
346
+ fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 1));
347
+ if (!KEEP && !failed) for (const f of fs.readdirSync(OUT)) if (f.endsWith('.png')) fs.unlinkSync(path.join(OUT, f));
348
+
349
+ const scope = `${themes.join(' and ')} × ${states.length} state(s) [${states.map((s) => s.id).join(', ')}]`;
350
+ if (failed) {
351
+ console.log(`\n❌ design parity FAILED in ${failed}/${compared} comparison(s). Renders kept in ${OUT}.`);
352
+ process.exit(1);
353
+ }
354
+ console.log(`\n✅ design parity — the app is identical to the PM's prototype across ${scope} — ${compared} comparison(s).`);
355
+ if (states.length === 1 && states[0].id === 'default') {
356
+ console.log(' (default state only. `--states` walks all ' + STATES.length + ', which is what the PRD asks for.)');
357
+ }
358
+ })().catch((e) => { console.error('design-parity ERROR:', e.stack); process.exit(2); });
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+ /* find-browser — locate a Chrome/Chromium this machine already has.
3
+ *
4
+ * WHY THIS EXISTS AS A MODULE. Three suites need a browser (ui-test, pm-shell-test, design-parity)
5
+ * and each had its own candidate list. Two of them listed only system paths — /usr/bin/chromium and
6
+ * the two macOS bundles — and so found nothing on a machine whose browser is the Playwright-managed
7
+ * one under a browsers root. What happens then is the part worth naming: the suite prints
8
+ * "no Chrome/Chromium — skipped" and exits 0. **A skip is indistinguishable from a pass in a CI
9
+ * summary**, which is how the browser assertions came to be believed without ever having run. The
10
+ * explainer for this surface found the same thing from the other end: ui-test.js is the only suite
11
+ * that measures real geometry, and it does not run in CI.
12
+ *
13
+ * So discovery is one function, it knows about the Playwright layout, and the callers report a skip
14
+ * as its own outcome rather than as success.
15
+ *
16
+ * NO DOWNLOAD, EVER. This finds a browser; it never fetches one. PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is
17
+ * the environment's own stance and this module agrees with it: a local PM tool must not pull 150 MB
18
+ * off the network because a test ran.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // A Playwright browsers root holds versioned directories — chromium-1194, chromium_headless_shell-1194
24
+ // — each with the executable at a platform-specific path inside. Enumerated rather than hard-coded,
25
+ // because the version suffix changes with every Playwright bump and a pinned one silently stops
26
+ // matching (which is the same skip-looks-like-pass failure, arriving later).
27
+ function playwrightCandidates() {
28
+ const roots = [process.env.PLAYWRIGHT_BROWSERS_PATH, '/opt/pw-browsers',
29
+ path.join(process.env.HOME || '', '.cache', 'ms-playwright')].filter(Boolean);
30
+ const out = [];
31
+ for (const root of roots) {
32
+ let entries = [];
33
+ try { entries = fs.readdirSync(root); } catch { continue; }
34
+ for (const dir of entries.sort().reverse()) { // newest version first
35
+ if (!/^chromium/.test(dir)) continue;
36
+ out.push(
37
+ path.join(root, dir, 'chrome-linux', 'chrome'),
38
+ path.join(root, dir, 'chrome-linux', 'headless_shell'),
39
+ path.join(root, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
40
+ path.join(root, dir, 'chrome-win', 'chrome.exe'),
41
+ );
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function findBrowser() {
48
+ const cands = [
49
+ process.env.PMOS_CHROME,
50
+ process.env.PMOS_CHROMIUM,
51
+ ...playwrightCandidates(),
52
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
53
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
54
+ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
55
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
56
+ ].filter(Boolean);
57
+ return cands.find((c) => { try { fs.accessSync(c, fs.constants.X_OK); return true; } catch { return false; } });
58
+ }
59
+
60
+ module.exports = { findBrowser, playwrightCandidates };