clearotron 0.3.2-beta.2 → 0.3.2-beta.3

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 (50) hide show
  1. package/.env.example +10 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +15 -10
  4. package/bin/onboard.mjs +4 -4
  5. package/build-info.json +2 -2
  6. package/docs/CLIENT-MCP.md +54 -54
  7. package/docs/DELIVERY.md +16 -15
  8. package/docs/E2E.md +8 -8
  9. package/docs/GLOSSARY.md +2 -2
  10. package/docs/INTAKE.md +11 -11
  11. package/docs/ONBOARDING.md +17 -16
  12. package/docs/PORTAL.md +18 -16
  13. package/docs/README.md +10 -10
  14. package/docs/SECURITY.md +20 -20
  15. package/docs/architecture/01-product-overview.md +17 -16
  16. package/docs/architecture/02-architecture.md +6 -6
  17. package/docs/architecture/03-run-lifecycle.md +7 -7
  18. package/docs/architecture/04-configuration-reference.md +14 -13
  19. package/docs/architecture/05-config-governance.md +30 -29
  20. package/docs/architecture/05-customer-profiles.md +35 -35
  21. package/docs/architecture/06-operations-runbook.md +20 -20
  22. package/docs/architecture/07-quality-and-audit.md +17 -17
  23. package/docs/architecture/08-development-guide.md +3 -3
  24. package/docs/architecture/09-security-and-data.md +27 -27
  25. package/docs/architecture/README.md +1 -1
  26. package/docs/branding.md +8 -3
  27. package/docs/configuration.md +18 -18
  28. package/docs/writing-standard.md +3 -3
  29. package/driver/CHANGELOG.md +27 -0
  30. package/driver/package.json +1 -1
  31. package/driver/portal-service.mjs +34 -4
  32. package/driver/suite-census.json +110 -44
  33. package/mcp-server/CHANGELOG.md +4 -0
  34. package/mcp-server/package.json +1 -1
  35. package/mcp-server/packs/client/CONNECT.md +6 -6
  36. package/package.json +1 -1
  37. package/portal-ui/dist/assets/{index-BsbasHjM.js → index-Bki5jT_N.js} +3376 -2971
  38. package/portal-ui/dist/assets/{index-DNQpLYZF.css → index-De2RFLbT.css} +972 -205
  39. package/portal-ui/dist/index.html +2 -2
  40. package/portal-ui/package.json +1 -1
  41. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  42. package/providers/oauth-mcp-bridge/package.json +1 -1
  43. package/scripts/ask-ai-render-check.mjs +287 -105
  44. package/scripts/release-note-required.mjs +16 -2
  45. package/scripts/settings-render-check.mjs +575 -0
  46. package/shared/brand.mjs +29 -0
  47. package/shared/connect-clients.mjs +99 -47
  48. package/shared/names-in-force.mjs +1 -0
  49. package/shared/stdio-connect.mjs +16 -2
  50. package/shared/writing-standard-classes.mjs +34 -3
@@ -1,37 +1,49 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
- // Ask AI on a report, pressed in a real browser, in the three states a reader actually meets.
4
+ // Ask AI on a report, pressed in a real browser, in the states a reader actually meets.
5
5
  //
6
- // node scripts/ask-ai-render-check.mjs [--keep] [--shots <dir>] [--width 1280]
6
+ // node scripts/ask-ai-render-check.mjs [--keep] [--shot-dir <dir>] [--width 1280]
7
7
  //
8
8
  // ── why a browser, and why a separate one ────────────────────────────────────────────────────────────
9
9
  //
10
10
  // The owner watched a lawyer press this button and give up on what opened. Nothing in portal-ui can ask
11
11
  // what a press leaves a reader looking at: `node --test` with type stripping carries no DOM, so the
12
- // source-text arms over Result.tsx can prove a string is in a file and no more. They cannot tell a menu
13
- // that renders from one that renders behind a closed panel, and they cannot tell a link that carries the
14
- // question from one that carries an empty `q=`.
12
+ // source-text arms over the control can prove a string is in a file and no more. They cannot tell a
13
+ // panel that renders from one that renders behind a closed one, and they cannot tell a link that carries
14
+ // the selected question from one that carries the first question whatever was selected.
15
15
  //
16
16
  // Separate from revisit-render-check.mjs, which drives the same screen: that one counts REQUESTS, and
17
- // opening menus inside its measurement windows would change the number it exists to measure.
17
+ // opening panels inside its measurement windows would change the number it exists to measure.
18
18
  //
19
- // ── the three states, and why the third is the one to get wrong ──────────────────────────────────────
19
+ // ── the states, and why the third is the one to get wrong ────────────────────────────────────────────
20
20
  //
21
- // connected a menu — Ask Claude, Ask ChatGPT, a divider, Connect another AI
21
+ // connected a panel — the report's name, four questions, "Open in Claude", and a line saying
22
+ // the question is typed in and not sent
22
23
  // never connected a panel — what to do once, Set it up, and "Already connected? Ask anyway"
23
24
  // no connector nothing is drawn. A button that can do nothing is not a button.
24
25
  //
25
26
  // The server answers `aiConnected` as true, false, or NULL — null meaning the installation could not be
26
- // asked. A null draws the panel, the same as false, and this check drives it as its own deck rather than
27
- // trusting that they look alike: they render the same by a decision about which way to be wrong, and a
28
- // deck that only ever saw `false` would go green on a build that crashed on null.
27
+ // asked. A null draws the connect panel, the same as false, and this check drives it as its own deck
28
+ // rather than trusting that they look alike: they render the same by a decision about which way to be
29
+ // wrong, and a deck that only ever saw `false` would go green on a build that crashed on null.
30
+ //
31
+ // ── and the way back ─────────────────────────────────────────────────────────────────────────────────
32
+ //
33
+ // "Set it up" remembers the report and opens Connect your AI marked as reached from a report. Once the
34
+ // reader is connected, that page offers "Continue with <mark>", which must land on the report with the
35
+ // panel already open in its connected form. Both halves are pressed here, in one browser, because the
36
+ // note lives in that browser's storage and nowhere else.
37
+ //
38
+ // With --shot-dir, the two panels a reader meets are captured in light and dark, at the page's full
39
+ // height (a beyond-viewport capture paints the sticky header where the viewport put it).
29
40
  //
30
41
  // MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one.
31
42
 
32
43
  import { navigateOrRefuse } from './headless-page.mjs' // Page.navigate returns an errorText, and nothing read it
33
44
  import { reapOnExit } from "../shared/reap-on-exit.mjs"; // a detached group dies with this script
34
45
  import { browserRun } from "../shared/browser-temp-root.mjs";
46
+ import { connectOffers, offersForWire } from '../shared/connect-clients.mjs'
35
47
  import { createServer } from 'node:http'
36
48
  import { readFileSync, existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
37
49
  import { join, extname, dirname } from 'node:path'
@@ -41,8 +53,10 @@ import { spawn } from 'node:child_process'
41
53
  const HERE = dirname(fileURLToPath(import.meta.url))
42
54
  const DIST = join(HERE, '..', 'portal-ui', 'dist')
43
55
  const keep = process.argv.includes('--keep')
44
- const shotsDir = process.argv.includes('--shots') ? process.argv[process.argv.indexOf('--shots') + 1] : null
45
- const WIDTH = process.argv.includes('--width') ? Number(process.argv[process.argv.indexOf('--width') + 1]) : 1280
56
+ const argValue = (flag) => (process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : null)
57
+ // `--shots` is the older spelling, kept so a command someone already has keeps working.
58
+ const shotsDir = argValue('--shot-dir') ?? argValue('--shots')
59
+ const WIDTH = process.argv.includes('--width') ? Number(argValue('--width')) : 1280
46
60
 
47
61
  if (!existsSync(join(DIST, 'index.html'))) {
48
62
  console.error(`no build at ${DIST} — run: npm run build:ui`)
@@ -52,10 +66,14 @@ if (shotsDir) mkdirSync(shotsDir, { recursive: true })
52
66
 
53
67
  // ── the fixture ─────────────────────────────────────────────────────────────────────────────────────
54
68
  //
55
- // One delivered clearance. The mark and the date are what the question is built from, so they are the
56
- // two facts this file cares about — and the date is deliberately one whose ISO spelling and its readable
69
+ // One delivered clearance. The mark and the date are what the questions are built from, so they are the
70
+ // facts this file cares about — and the date is deliberately one whose ISO spelling and its readable
57
71
  // form differ in both fields, so a formatter that printed the wrong part could not pass by coincidence.
58
72
  //
73
+ // THE ISSUE STAMP IS 22:30 UTC ON THE 14TH, which is already the 15th in Zurich, where the report's own
74
+ // Issued stamp is written. A header that sliced the UTC stamp would print the searched date twice; the
75
+ // right one prints two different days.
76
+ //
59
77
  // `productName` is resolved through the same expression portal-service.mjs evaluates, never typed: the
60
78
  // sibling instruments' rule, and it holds here for the same reason.
61
79
  const { reportIdentityFor } = await import('../driver/search-policy.mjs')
@@ -65,33 +83,47 @@ const NAME = 'Acme'
65
83
  const RUN_ID = 'tmpa-askai-1'
66
84
  const MARK = 'VENZY'
67
85
  const ISO_DATE = '2026-09-14'
86
+ const ISSUED_AT = '2026-09-14T22:30:00.000Z'
87
+ const ISSUED_DAY = '2026-09-15'
68
88
  const READABLE_DATE = '14 September'
69
- const EXPECTED_QUESTION = `Brief me on the ${MARK} clearance from ${READABLE_DATE}.`
89
+ const REPORT = `the ${MARK} clearance from ${READABLE_DATE}`
90
+ // The four questions as a reader's assistant receives them — the rows' words are shorter, because the
91
+ // panel's first line already names the report.
92
+ const QUESTIONS = [
93
+ { label: 'Brief me on this clearance', typed: `Brief me on ${REPORT}.` },
94
+ { label: 'Explain the main risks', typed: `Explain the main risks in ${REPORT}.` },
95
+ { label: 'What needs further investigation?', typed: `What needs further investigation in ${REPORT}?` },
96
+ { label: 'How would narrower goods change the assessment?', typed: `How would narrower goods change the assessment in ${REPORT}?` },
97
+ ]
70
98
 
71
99
  const PRODUCT = 'global-preliminary-search'
100
+ const PRODUCT_NAME = reportIdentityFor(PRODUCT).identity
72
101
  const RUN = {
73
102
  account: KEY, kind: 'clearance', state: 'delivered', band: 'Manageable', tone: 'low',
74
103
  bands: [{ label: 'Severe', tone: 'severe' }, { label: 'Medium', tone: 'medium' }, { label: 'Manageable', tone: 'low' }],
75
104
  marks: [], reportSchema: 2, held: false, report: `/portal/report/${RUN_ID}/`,
76
105
  step: null, stepN: null, stepTotal: null, reason: null, failedStage: null,
77
106
  pausedKind: null, resetsAt: null, startedAt: null, queuePos: null,
78
- projectKey: null, projectName: null, markName: MARK, title: MARK, date: ISO_DATE,
79
- product: PRODUCT, stageLabel: 'Depth 4', productName: reportIdentityFor(PRODUCT).identity, runId: RUN_ID,
107
+ projectKey: null, projectName: null, markName: MARK, title: MARK, date: ISO_DATE, issuedAt: ISSUED_AT,
108
+ product: PRODUCT, stageLabel: 'Depth 4', productName: PRODUCT_NAME, runId: RUN_ID,
80
109
  }
81
110
 
82
111
  // ── the decks ───────────────────────────────────────────────────────────────────────────────────────
83
112
  //
84
113
  // Only the mcp-access body varies. Everything else about the screen is held still, so a difference in
85
- // what the button does is a difference in what the deployment answered and nothing else.
114
+ // what the button does is a difference in what the deployment answered and nothing else. The offers are
115
+ // composed through the one table, as the route composes them, because Connect your AI renders them when
116
+ // "Set it up" and "Continue" cross to it.
86
117
  const WIRED = { url: 'https://clients-mcp.example/mcp', keyUrl: null, email: 'reader@example-firm.com' }
118
+ const offersAt = (url) => offersForWire(connectOffers({ stdioRoutes: {}, publicAddress: url, operator: WIRED.email, door: url ? 'sign-in' : null }))
87
119
  const DECKS = {
88
- connected: { ...WIRED, enabled: true, stdio: null, aiConnected: true },
89
- neverBefore: { ...WIRED, enabled: true, stdio: null, aiConnected: false },
90
- cannotTell: { ...WIRED, enabled: true, stdio: null, aiConnected: null },
91
- noConnector: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null },
120
+ connected: { ...WIRED, enabled: true, stdio: null, aiConnected: true, offers: offersAt(WIRED.url) },
121
+ neverBefore: { ...WIRED, enabled: true, stdio: null, aiConnected: false, offers: offersAt(WIRED.url) },
122
+ cannotTell: { ...WIRED, enabled: true, stdio: null, aiConnected: null, offers: offersAt(WIRED.url) },
123
+ noConnector: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, stdio: null, aiConnected: null, offers: [] },
92
124
  // A laptop install: no published door, but a local route. This reader must be offered the control —
93
125
  // it is the case the old panel stranded, because every address it could show was null.
94
- localOnly: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, aiConnected: true,
126
+ localOnly: { url: null, keyUrl: null, email: 'reader@example-firm.com', enabled: false, aiConnected: true, offers: [],
95
127
  stdio: { command: 'node server.mjs', note: 'from this install', verify: 'it prints ready' } },
96
128
  }
97
129
  let deck = 'connected'
@@ -106,13 +138,19 @@ const server = createServer((req, res) => {
106
138
  if (p === '/portal/admin/families') return json(res, { of: {}, names: {} })
107
139
  if (p === '/portal/api/runs') return json(res, { runs: [RUN] })
108
140
  if (p === '/portal/api/searches') return json(res, { account: KEY, products: [], recipes: [], read: { available: false, maxBrief: 0, note: null } })
109
- if (p === '/portal/api/mcp-access') return json(res, { ...DECKS[deck], offers: [] })
141
+ if (p === '/portal/api/mcp-access') return json(res, DECKS[deck])
110
142
  if (/^\/portal\/api\/run\/[^/]+\/summary$/.test(p)) return json(res, { summary: [] })
111
143
  // A real document, because a 404 in the frame changes what Result draws around it. Its CONTENT is not
112
- // this file's subject — render-check.mjs owns the report's own layout.
144
+ // this file's subject — render-check.mjs owns the report's own layout. It announces the commands and
145
+ // the height a published report's bridge announces, so the header draws the Export menu a reader meets.
113
146
  if (p.startsWith('/portal/report/')) {
114
147
  res.writeHead(200, { 'content-type': 'text/html' })
115
- return res.end(`<!doctype html><meta charset="utf-8"><title>report</title><body><h1>${MARK}</h1><p>fixture report.</p></body>`)
148
+ return res.end(`<!doctype html><meta charset="utf-8"><title>report</title>
149
+ <body style="margin:0;font-family:sans-serif;background:#fff"><div style="padding:32px"><h1>${MARK}</h1><p>fixture report.</p></div>
150
+ <script>
151
+ parent.postMessage({ source: 'cordillera-report', type: 'controls', commands: ['exportPDF', 'pickAll', 'openAll'] }, '*');
152
+ parent.postMessage({ source: 'cordillera-report', type: 'height', height: 640 }, '*');
153
+ </script></body>`)
116
154
  }
117
155
  const base = p.split('?')[0]
118
156
  const file = base === '/' || (base.startsWith('/portal') && !base.includes('.')) ? '/index.html' : base.replace(/^\/portal/, '')
@@ -166,67 +204,112 @@ const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr,
166
204
 
167
205
  // ── what is measured, in the page ───────────────────────────────────────────────────────────────────
168
206
  //
169
- // READ OFF THE RENDERED DOM, never off a class name. A menu item that exists behind `display:none` and
207
+ // READ OFF THE RENDERED DOM, never off a class name alone. A row that exists behind `display:none` and
170
208
  // one a reader can press look identical to a selector; `offsetParent` and a non-zero rect are what tell
171
209
  // them apart, and that difference is the whole subject of this file.
172
210
  //
173
- // WINDOW.OPEN IS REPLACED, NOT FOLLOWED. Pressing Ask Claude opens claude.ai, which this browser has no
174
- // route to (`--host-resolver-rules`) — and following it would leave the drive on a page that is not the
175
- // one being measured. The stub records the href instead, so the assertion is about the link the button
176
- // actually carries rather than about a string in the source.
211
+ // WINDOW.OPEN IS REPLACED, NOT FOLLOWED. Pressing "Open in Claude" opens claude.ai, which this browser
212
+ // has no route to (`--host-resolver-rules`) — and following it would leave the drive on a page that is
213
+ // not the one being measured. The stub records the href instead, so the assertion is about the link the
214
+ // button actually carries rather than about a string in the source.
177
215
  const PROBE = `(async () => {
178
216
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
217
+ const flat = (s) => (s || '').replace(/\\s+/g, ' ').trim();
179
218
  window.__opened = [];
180
219
  if (!window.__openStubbed) { window.open = (href) => { window.__opened.push(href); return null; }; window.__openStubbed = true; }
181
220
 
182
221
  const visible = (el) => !!el && !!el.offsetParent && el.getBoundingClientRect().width > 0;
183
222
  const anchor = document.querySelector('[data-ask-ai]');
184
- const button = [...document.querySelectorAll('button')].find(b => /Ask AI/.test(b.textContent || ''));
223
+ const button = anchor ? anchor.querySelector(':scope > button') : null;
185
224
  if (!button || !visible(button)) return { drawn: false, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null };
186
225
 
187
- button.click();
188
- await sleep(220);
226
+ const openOnArrival = !!anchor.querySelector('.float');
227
+ if (!openOnArrival) { button.click(); await sleep(220); }
189
228
 
190
- const float = document.querySelector('[data-ask-ai] .float');
191
- if (!float) return { drawn: true, opened: false, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null };
229
+ const float = anchor.querySelector('.float');
230
+ if (!float) return { drawn: true, opened: false, anchorState: anchor.getAttribute('data-ask-ai') };
192
231
 
193
- const role = float.getAttribute('role');
194
- const items = [...float.querySelectorAll('button')].filter(visible).map(b => (b.textContent || '').trim());
232
+ const b = button.getBoundingClientRect();
233
+ const f = float.getBoundingClientRect();
195
234
  const text = (float.innerText || '').trim();
196
- const width = Math.round(float.getBoundingClientRect().width);
197
- // A divider is a one-pixel-high element between the assistants and the quiet item. Measured, not
198
- // matched by class: the mockup's separator is a rule, and a rule is a height.
199
- const seps = [...float.querySelectorAll('div')].filter(d => {
200
- const r = d.getBoundingClientRect();
201
- return r.height <= 2 && r.height > 0 && r.width > 40;
202
- }).length;
203
-
204
235
  return {
205
- drawn: true, opened: true, role, items, text, width, seps,
206
- anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null,
236
+ drawn: true, opened: true, openOnArrival,
237
+ role: float.getAttribute('role'), label: float.getAttribute('aria-label'),
238
+ anchorState: anchor.getAttribute('data-ask-ai'),
239
+ buttonText: flat(button.textContent), buttonAria: button.getAttribute('aria-label'),
240
+ head: flat(float.querySelector('.ask-ai-head') ? float.querySelector('.ask-ai-head').textContent : ''),
241
+ group: float.querySelector('[role="radiogroup"]') ? float.querySelector('[role="radiogroup"]').getAttribute('aria-label') : null,
242
+ radios: [...float.querySelectorAll('[role="radio"]')].filter(visible).map(r => ({ text: flat(r.textContent), checked: r.getAttribute('aria-checked') })),
243
+ primary: float.querySelector('.btn-primary') ? flat(float.querySelector('.btn-primary').textContent) : null,
244
+ note: flat(float.querySelector('.ask-ai-note') ? float.querySelector('.ask-ai-note').textContent : ''),
245
+ items: [...float.querySelectorAll('button')].filter(visible).map(x => flat(x.textContent)),
246
+ text, width: Math.round(f.width),
247
+ rightAligned: Math.abs(Math.round(f.right) - Math.round(b.right)) <= 1,
248
+ below: f.top >= b.bottom - 1,
207
249
  hasAddress: /mcp|https?:\\/\\//i.test(text),
208
250
  hasCopy: /copy/i.test(text),
209
251
  };
210
252
  })()`
211
253
 
212
- // PRESSING CLOSES THE MENU, which is correct and is why this re-opens it first: the second press found
213
- // nothing, reported an empty href, and the assertion read as "ChatGPT was opened with nothing" — a true
214
- // statement about a drive that never pressed anything.
215
- const PRESS = `(async () => {
254
+ // THE HEADER A READER READS ABOVE THE REPORT: the breadcrumb, the labelled line, the actions in order, and
255
+ // the top bar, which names no company on a report.
256
+ const HEADER = `(() => {
257
+ const flat = (s) => (s || '').replace(/\\s+/g, ' ').trim();
258
+ const head = document.querySelector('.report-head');
259
+ const h1 = head ? head.querySelector('h1') : null;
260
+ const line = h1 ? h1.nextElementSibling : null;
261
+ const crumbs = document.querySelector('.report-crumbs');
262
+ return {
263
+ mark: h1 ? flat(h1.textContent) : null,
264
+ line: line ? flat(line.textContent) : null,
265
+ crumbs: crumbs ? [...crumbs.children].map(c => flat(c.textContent)) : [],
266
+ actions: head ? [...head.querySelectorAll(':scope button, :scope a')].filter(x => !x.closest('.float')).map(x => flat(x.textContent)).filter(Boolean) : [],
267
+ actionKinds: head ? [...head.querySelectorAll('[data-ask-ai] > button, .report-action')].map(x => x.className) : [],
268
+ topbar: flat(document.querySelector('.topbar h1') ? document.querySelector('.topbar h1').textContent : ''),
269
+ followUp: /Ask a follow-up/i.test(document.body.innerText || ''),
270
+ };
271
+ })()`
272
+
273
+ // ONE QUESTION, CHOSEN AND SENT TO THE ASSISTANT: the row is pressed, the choice is read back, and the
274
+ // button is pressed. PRESSING CLOSES THE PANEL, which is correct and is why this re-opens it first.
275
+ const ASK = (index) => `(async () => {
216
276
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
217
- if (!document.querySelector('[data-ask-ai] .float')) {
218
- const opener = [...document.querySelectorAll('button')].find(b => /Ask AI/.test(b.textContent || ''));
277
+ let float = document.querySelector('[data-ask-ai] .float');
278
+ if (!float) {
279
+ const opener = document.querySelector('[data-ask-ai] > button');
219
280
  if (!opener) return { fatal: 'the control is gone from the header' };
220
281
  opener.click();
221
282
  await sleep(220);
283
+ float = document.querySelector('[data-ask-ai] .float');
222
284
  }
223
- const float = document.querySelector('[data-ask-ai] .float');
224
285
  if (!float) return { fatal: 'nothing open to press' };
225
- const btn = [...float.querySelectorAll('button')].find(b => /__LABEL__/.test(b.textContent || ''));
226
- if (!btn) return { fatal: 'no such item: __LABEL__' };
227
- btn.click();
286
+ const radio = float.querySelectorAll('[role="radio"]')[${index}];
287
+ if (!radio) return { fatal: 'no question ${index + 1}' };
288
+ radio.click();
289
+ await sleep(150);
290
+ float = document.querySelector('[data-ask-ai] .float');
291
+ const checked = [...float.querySelectorAll('[role="radio"]')].map(r => r.getAttribute('aria-checked'));
292
+ const go = float.querySelector('.btn-primary');
293
+ if (!go) return { fatal: 'no button to open the assistant' };
294
+ go.click();
228
295
  await sleep(200);
229
- return { opened: window.__opened || [] };
296
+ return { opened: window.__opened || [], checked, closed: !document.querySelector('[data-ask-ai] .float') };
297
+ })()`
298
+
299
+ // THE ARROW KEYS MOVE THE CHOICE, the way a radio group answers them.
300
+ const ARROW = `(async () => {
301
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
302
+ let float = document.querySelector('[data-ask-ai] .float');
303
+ if (!float) { document.querySelector('[data-ask-ai] > button').click(); await sleep(220); float = document.querySelector('[data-ask-ai] .float'); }
304
+ const radios = () => [...document.querySelectorAll('[data-ask-ai] [role="radio"]')];
305
+ radios()[0].click(); await sleep(100);
306
+ radios()[0].focus();
307
+ radios()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
308
+ await sleep(150);
309
+ const after = radios().map(r => r.getAttribute('aria-checked'));
310
+ const focused = radios().indexOf(document.activeElement);
311
+ radios()[0].click(); await sleep(100);
312
+ return { after, focused };
230
313
  })()`
231
314
 
232
315
  const ASK_ANYWAY = `(async () => {
@@ -240,30 +323,91 @@ const ASK_ANYWAY = `(async () => {
240
323
  const after = document.querySelector('[data-ask-ai] .float');
241
324
  return {
242
325
  role: after ? after.getAttribute('role') : null,
326
+ anchorState: document.querySelector('[data-ask-ai]') ? document.querySelector('[data-ask-ai]').getAttribute('data-ask-ai') : null,
327
+ radios: after ? after.querySelectorAll('[role="radio"]').length : 0,
243
328
  items: after ? [...after.querySelectorAll('button')].filter(b => !!b.offsetParent).map(b => (b.textContent || '').trim()) : [],
244
329
  };
245
330
  })()`
246
331
 
247
- const shoot = async (name) => {
332
+ // "SET IT UP" LEAVES A NOTE AND GOES TO THE PAGE THAT SETS A CONNECTOR UP, marked as reached from a report.
333
+ const SET_IT_UP = `(async () => {
334
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
335
+ let float = document.querySelector('[data-ask-ai] .float');
336
+ if (!float) { document.querySelector('[data-ask-ai] > button').click(); await sleep(220); float = document.querySelector('[data-ask-ai] .float'); }
337
+ if (!float) return { fatal: 'no panel open' };
338
+ const btn = [...float.querySelectorAll('button')].find(b => /^Set it up/.test((b.textContent || '').trim()));
339
+ if (!btn) return { fatal: 'no Set it up on the panel' };
340
+ btn.click();
341
+ await sleep(900);
342
+ let stored = null;
343
+ try { stored = localStorage.getItem('cordillera-ask-ai-report'); } catch (e) { stored = 'storage threw: ' + e; }
344
+ return { at: location.pathname + location.search, stored, heading: (document.querySelector('.page-title') || {}).textContent || null };
345
+ })()`
346
+
347
+ // ON CONNECT YOUR AI: the way back, and where pressing it lands.
348
+ const CONTINUE = `(async () => {
349
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
350
+ const flat = (s) => (s || '').replace(/\\s+/g, ' ').trim();
351
+ const pill = document.querySelector('.conn-state');
352
+ const row = document.querySelector('.back-to-report');
353
+ const btn = row ? row.querySelector('button') : null;
354
+ const shown = { pill: pill ? flat(pill.textContent) : null, continueShown: !!btn, label: btn ? flat(btn.textContent) : null,
355
+ line: row && row.querySelector('.back-to-report-line') ? flat(row.querySelector('.back-to-report-line').textContent) : null };
356
+ if (!btn || !window.__pressContinue) return shown;
357
+ btn.click();
358
+ await sleep(1800);
359
+ const anchor = document.querySelector('[data-ask-ai]');
360
+ const float = anchor ? anchor.querySelector('.float') : null;
361
+ return { ...shown, at: location.pathname + location.search, panelOpen: !!float,
362
+ role: float ? float.getAttribute('role') : null, anchorState: anchor ? anchor.getAttribute('data-ask-ai') : null,
363
+ radios: float ? float.querySelectorAll('[role="radio"]').length : 0 };
364
+ })()`
365
+
366
+ // A FULL-HEIGHT VIEWPORT, not a beyond-viewport capture: the report's header is sticky, and a
367
+ // beyond-viewport capture paints it where the 900px viewport put it. Light, then dark, then back.
368
+ const capture = async (name) => {
248
369
  if (!shotsDir) return
249
- const shot = await cmd('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })
250
- const data = shot.result?.result?.data ?? shot.result?.data
251
- if (data) writeFileSync(join(shotsDir, `${name}.png`), Buffer.from(data, 'base64'))
370
+ for (const theme of ['light', 'dark']) {
371
+ await value(`(() => { ${theme === 'dark' ? "document.documentElement.setAttribute('data-theme','dark')" : "document.documentElement.removeAttribute('data-theme')"}; return true })()`)
372
+ await new Promise((r) => setTimeout(r, 350))
373
+ const h = await value('Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)') ?? 900
374
+ await cmd('Emulation.setDeviceMetricsOverride', { width: WIDTH, height: Math.max(900, h), deviceScaleFactor: 1, mobile: false })
375
+ await new Promise((r) => setTimeout(r, 400))
376
+ const shot = await cmd('Page.captureScreenshot', { format: 'png' })
377
+ await cmd('Emulation.clearDeviceMetricsOverride', {})
378
+ const data = shot.result?.result?.data ?? shot.result?.data
379
+ if (!data) throw new Error(`no screenshot came back for ${name}-${theme}`)
380
+ writeFileSync(join(shotsDir, `${name}-${theme}.png`), Buffer.from(data, 'base64'))
381
+ }
382
+ await value(`(() => { document.documentElement.removeAttribute('data-theme'); return true })()`)
252
383
  }
253
384
 
254
- const visit = async () => {
255
- await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'ask-ai-render-check' })
256
- await new Promise((r) => setTimeout(r, 1400))
385
+ const visit = async (path = `/portal/result/${RUN_ID}`) => {
386
+ await navigateOrRefuse(cmd, `${origin}${path}`, { what: 'ask-ai-render-check' })
387
+ await new Promise((r) => setTimeout(r, 1500))
257
388
  }
258
389
 
259
390
  // ── the drive ───────────────────────────────────────────────────────────────────────────────────────
260
391
 
261
- deck = 'connected'; await visit(); const connected = await value(PROBE); await shoot('ask-ai-menu')
262
- const pressClaude = await value(PRESS.replace('__LABEL__', 'Ask Claude'))
263
- const pressChatGpt = await value(PRESS.replace('__LABEL__', 'Ask ChatGPT'))
392
+ deck = 'connected'; await visit(); const header = await value(HEADER); const connected = await value(PROBE)
393
+ await capture('ask-ai-connected')
394
+ const arrow = await value(ARROW)
395
+ const asked = []
396
+ for (const i of QUESTIONS.keys()) asked.push(await value(ASK(i)))
264
397
 
265
- deck = 'neverBefore'; await visit(); const never = await value(PROBE); await shoot('ask-ai-connect')
398
+ deck = 'neverBefore'; await visit(); const never = await value(PROBE); await capture('ask-ai-not-connected')
266
399
  const anyway = await value(ASK_ANYWAY)
400
+ await visit(); await value(PROBE); const setUp = await value(SET_IT_UP)
401
+ // Reached from a report, not yet connected: the steps are the point, and there is no way back to offer.
402
+ const notYet = await value(CONTINUE)
403
+
404
+ // Connected now. The rail first — nothing to go back to — then the page as "Set it up" left it.
405
+ deck = 'connected'
406
+ await visit('/portal/ai'); const fromRail = await value(CONTINUE)
407
+ await visit('/portal/ai?from=report')
408
+ await value('(() => { window.__pressContinue = true; return true })()')
409
+ const back = await value(CONTINUE)
410
+ const arrived = await value(PROBE)
267
411
 
268
412
  deck = 'cannotTell'; await visit(); const unknown = await value(PROBE)
269
413
  deck = 'noConnector'; await visit(); const none = await value(PROBE)
@@ -283,58 +427,96 @@ const show = (o) => JSON.stringify(o)
283
427
 
284
428
  // A FLOOR FIRST. Every assertion below reads a field off one of these objects, and a deck that failed to
285
429
  // render returns null — which would make `!x.hasAddress` true and half of this file pass on a blank page.
286
- for (const [name, r] of [['connected', connected], ['never connected', never], ['could not tell', unknown], ['no connector', none], ['local only', local]]) {
287
- ok(r && typeof r === 'object', `the ${name} deck measured nothing at all — the screen did not render`)
430
+ for (const [name, r] of [['header', header], ['connected', connected], ['never connected', never], ['could not tell', unknown],
431
+ ['no connector', none], ['local only', local], ['set it up', setUp], ['continue', back], ['arrival', arrived]]) {
432
+ ok(r && typeof r === 'object', `the ${name} step measured nothing at all — the screen did not render`)
288
433
  }
289
434
  if (fail.length) { for (const f of fail) console.error(` ✗ ${f}`); console.error('ask-ai-render-check: the drive did not reach the screen'); process.exit(1) }
290
435
 
291
- // ── connected: the menu ──
436
+ // ── the header above the report ──
437
+ ok(show(header.crumbs) === show(['All Clearances', '›', MARK]), `the breadcrumb reads ${show(header.crumbs)}`)
438
+ ok(header.mark === MARK, `the header names ${show(header.mark)}, not the mark`)
439
+ const LINE = `${NAME} · ${PRODUCT_NAME} · searched ${ISO_DATE} · issued ${ISSUED_DAY}`
440
+ ok(header.line === LINE, `the header line reads ${show(header.line)}, not ${show(LINE)} — the issued day is Zurich's`)
441
+ ok(show(header.actions) === show(['Ask AI', 'Export']), `the header's actions are ${show(header.actions)}, not Ask AI then Export`)
442
+ ok(header.actionKinds.length === 2 && header.actionKinds.every((c) => /\bbtn-ghost\b/.test(c)),
443
+ `the two actions are not both secondary buttons: ${show(header.actionKinds)}`)
444
+ ok(header.topbar === '', `the top bar names ${show(header.topbar)} over a report, which is not company-scoped`)
445
+ ok(!header.followUp, '"Ask a follow-up" is on the screen')
446
+
447
+ // ── connected: the panel ──
292
448
  ok(connected.drawn && connected.opened, `connected: the button did not open anything — ${show(connected)}`)
293
- ok(connected.role === 'menu', `connected: the float is role="${connected.role}", not a menu`)
294
- ok(connected.anchorState === 'menu', `connected: the control reports state "${connected.anchorState}"`)
295
- ok(connected.items.length === 3, `connected: ${connected.items.length} item(s), expected three — ${show(connected.items)}`)
296
- ok(connected.items[0] === 'Ask Claude' && connected.items[1] === 'Ask ChatGPT',
297
- `connected: the two assistants are not the first two items — ${show(connected.items)}`)
298
- ok(connected.items[2] === 'Connect another AI', `connected: the quiet item reads ${show(connected.items[2])}`)
299
- ok(connected.seps >= 1, 'connected: no divider between the assistants and the quiet item')
300
- ok(connected.width >= 200 && connected.width <= 280, `connected: the menu is ${connected.width}px wide; the design is 236`)
301
-
302
- // ── connected: what a press actually opens ──
449
+ ok(connected.buttonText === 'Ask AI' && connected.buttonAria === null,
450
+ `connected: the button reads ${show(connected.buttonText)} with an accessible name of ${show(connected.buttonAria)}`)
451
+ ok(connected.role === 'dialog' && connected.label === 'Ask AI', `connected: the panel is role="${connected.role}" named ${show(connected.label)}`)
452
+ ok(connected.anchorState === 'ask', `connected: the control reports state "${connected.anchorState}"`)
453
+ ok(connected.head === `${MARK} · ${PRODUCT_NAME} · searched ${ISO_DATE}`, `connected: the panel's first line reads ${show(connected.head)}`)
454
+ ok(connected.group === 'What to ask', `connected: the questions are not a named radio group — ${show(connected.group)}`)
455
+ ok(show(connected.radios.map((r) => r.text)) === show(QUESTIONS.map((q) => q.label)), `connected: the questions read ${show(connected.radios)}`)
456
+ ok(show(connected.radios.map((r) => r.checked)) === show(['true', 'false', 'false', 'false']), `connected: the first question is not the one selected — ${show(connected.radios)}`)
457
+ ok(connected.primary === 'Open in Claude', `connected: the button reads ${show(connected.primary)}`)
458
+ ok(connected.note === 'The question is typed into your assistant; press send there.', `connected: the quiet line reads ${show(connected.note)}`)
459
+ ok(connected.rightAligned && connected.below, `connected: the panel does not hang under the button with right edges aligned — ${show(connected)}`)
460
+ // THE DESIGN IS 360. Measured, so a panel squeezed to the old menu's width fails here rather than wrapping.
461
+ ok(connected.width >= 340 && connected.width <= 380, `connected: the panel is ${connected.width}px wide; the design is 360`)
462
+ ok(show(arrow.after) === show(['false', 'true', 'false', 'false']) && arrow.focused === 1,
463
+ `connected: the arrow key did not move the choice to the next question — ${show(arrow)}`)
464
+
465
+ // ── connected: what a press actually opens, for each question ──
303
466
  //
304
- // THE QUESTION HAS TO BE IN THE LINK, not merely in the source. This is the assertion the whole change
305
- // is for: a press that opened claude.ai with an empty `q=` would look identical on screen.
306
- const claudeHref = (pressClaude?.opened ?? []).at(-1) ?? ''
307
- const chatHref = (pressChatGpt?.opened ?? []).at(-1) ?? ''
308
- ok(claudeHref.startsWith('https://claude.ai/new?q='), `Ask Claude opened ${show(claudeHref)}`)
309
- ok(chatHref.startsWith('https://chatgpt.com/?q='), `Ask ChatGPT opened ${show(chatHref)}`)
310
- for (const [who, href] of [['Claude', claudeHref], ['ChatGPT', chatHref]]) {
311
- const q = (() => { try { return new URL(href).searchParams.get('q') ?? '' } catch { return '' } })()
312
- ok(q === EXPECTED_QUESTION, `${who} was opened with ${show(q)}, not ${show(EXPECTED_QUESTION)}`)
313
- ok(!/noref|tmpa-|run /i.test(q), `${who}'s question carries a run identifier: ${show(q)}`)
467
+ // THE SELECTED QUESTION HAS TO BE IN THE LINK, not merely in the source. This is the assertion the change
468
+ // is for: a press that opened claude.ai with the first question whatever was chosen would look identical
469
+ // on screen.
470
+ for (const [i, press] of asked.entries()) {
471
+ const href = (press?.opened ?? []).at(-1) ?? ''
472
+ const u = (() => { try { return new URL(href) } catch { return null } })()
473
+ ok(!press?.fatal, `question ${i + 1}: ${press?.fatal}`)
474
+ ok(press?.checked?.[i] === 'true', `question ${i + 1} was not the one selected when pressed — ${show(press?.checked)}`)
475
+ ok(u && u.origin + u.pathname === 'https://claude.ai/new', `question ${i + 1} opened ${show(href)}`)
476
+ ok(u && show([...u.searchParams.keys()]) === show(['q']), `question ${i + 1}'s link carries more than the text to type in: ${show(href)}`)
477
+ ok(u?.searchParams.get('q') === QUESTIONS[i].typed, `question ${i + 1} typed ${show(u?.searchParams.get('q'))}, not ${show(QUESTIONS[i].typed)}`)
478
+ ok(!/noref|tmpa-|run /i.test(u?.searchParams.get('q') ?? ''), `question ${i + 1} carries a run identifier`)
479
+ ok(press?.closed === true, `question ${i + 1}: the panel stayed open after it opened the assistant`)
314
480
  }
315
481
 
316
- // ── never connected: the panel ──
482
+ // ── never connected: the connect panel, unchanged ──
317
483
  ok(never.drawn && never.opened, `never connected: the button did not open anything — ${show(never)}`)
318
- ok(never.role === 'dialog', `never connected: the float is role="${never.role}", not a dialog`)
484
+ ok(never.role === 'dialog' && never.label === 'Connect your AI first', `never connected: the float is role="${never.role}" named ${show(never.label)}`)
319
485
  ok(never.anchorState === 'connect', `never connected: the control reports state "${never.anchorState}"`)
320
486
  ok(/Connect your AI first/.test(never.text), `never connected: the panel does not say what it is — ${show(never.text)}`)
321
487
  ok(/Connect Claude or ChatGPT once/.test(never.text), 'never connected: the explaining line is missing')
322
488
  ok(never.items.some((t) => /^Set it up/.test(t)), `never connected: no Set it up — ${show(never.items)}`)
323
489
  ok(never.items.some((t) => /Already connected\? Ask anyway/.test(t)), `never connected: no way past it — ${show(never.items)}`)
324
- ok(!never.items.some((t) => /^Ask Claude$|^Ask ChatGPT$/.test(t)),
325
- `never connected: the assistants are on the panel, so the setup step is decoration — ${show(never.items)}`)
490
+ ok(never.radios.length === 0 && never.primary !== 'Open in Claude',
491
+ `never connected: the questions are on the panel, so the setup step is decoration — ${show(never.items)}`)
326
492
  ok(never.width >= 240 && never.width <= 320, `never connected: the panel is ${never.width}px wide; the design is 276`)
327
493
 
328
494
  // ── the way past a measurement that is behind ──
329
- ok(anyway?.role === 'menu', `"Ask anyway" left the reader on role="${anyway?.role}" — ${show(anyway)}`)
330
- ok((anyway?.items ?? []).includes('Ask Claude'), `"Ask anyway" did not reach the assistants — ${show(anyway?.items)}`)
331
-
332
- // ── could not tell: the third state renders, and renders as the panel ──
495
+ ok(anyway?.anchorState === 'ask' && anyway?.radios === 4, `"Ask anyway" did not reach the questions — ${show(anyway)}`)
496
+
497
+ // ── Set it up: the note, and the page it opens ──
498
+ ok(setUp.at === '/portal/ai?from=report', `Set it up went to ${show(setUp.at)}, not the setup page marked as reached from a report`)
499
+ const note = (() => { try { return JSON.parse(setUp.stored ?? '') } catch { return null } })()
500
+ ok(note?.runId === RUN_ID && note?.mark === MARK && note?.markSlug === null, `Set it up left ${show(setUp.stored)} as its note`)
501
+ ok(setUp.heading === 'Connect your AI', `Set it up opened a page headed ${show(setUp.heading)}`)
502
+ ok(notYet && notYet.continueShown === false && notYet.pill === 'Not connected yet',
503
+ `reached from a report but not connected, the page offers the way back anyway — ${show(notYet)}`)
504
+
505
+ // ── Continue: from the rail there is nothing to go back to; from the report, it lands with the panel open ──
506
+ ok(fromRail && fromRail.pill === 'Connected' && fromRail.continueShown === false, `reached from the rail, the page offers a way back — ${show(fromRail)}`)
507
+ ok(back.continueShown && back.label === `Continue with ${MARK}`, `connected and reached from a report, the page offers ${show(back.label)}`)
508
+ ok(back.line === 'Back to the report you were reading.', `the line beside Continue reads ${show(back.line)}`)
509
+ ok(back.at === `/portal/result/${RUN_ID}`, `Continue landed on ${show(back.at)} — not the report, or with the signal still in the address`)
510
+ ok(back.panelOpen && back.role === 'dialog' && back.anchorState === 'ask' && back.radios === 4,
511
+ `Continue did not land with the panel open in its connected form — ${show(back)}`)
512
+ ok(arrived.openOnArrival === true, `the panel was not already open when the report arrived — ${show(arrived)}`)
513
+
514
+ // ── could not tell: the third state renders, and renders as the connect panel ──
333
515
  //
334
516
  // DRIVEN AS ITS OWN DECK. It reaching the same screen as `false` is a decision about which way to be
335
517
  // wrong, not an identity — and a build that threw on a null would fail here and nowhere else.
336
518
  ok(unknown.drawn && unknown.opened, `could not tell: nothing opened — ${show(unknown)}`)
337
- ok(unknown.role === 'dialog', `could not tell: a null was rendered as role="${unknown.role}", so an unmeasured reader was handed the menu`)
519
+ ok(unknown.anchorState === 'connect', `could not tell: a null was rendered as "${unknown.anchorState}", so an unmeasured reader was handed the questions`)
338
520
  ok(/Connect your AI first/.test(unknown.text), 'could not tell: the panel did not draw')
339
521
 
340
522
  // ── no connector: nothing is drawn ──
@@ -342,7 +524,7 @@ ok(none.drawn === false, `no connector: a button was drawn that can do nothing
342
524
 
343
525
  // ── a laptop install: a local route IS a connector ──
344
526
  ok(local.drawn && local.opened, `local only: the case the old panel stranded is stranded again — ${show(local)}`)
345
- ok(local.role === 'menu', `local only: role="${local.role}"`)
527
+ ok(local.anchorState === 'ask' && local.radios.length === 4, `local only: ${show(local.anchorState)} with ${local.radios.length} question(s)`)
346
528
 
347
529
  // ── NO ADDRESS AND NO COPY LINK, in any state that draws ──
348
530
  for (const [name, r] of [['connected', connected], ['never connected', never], ['could not tell', unknown], ['local only', local]]) {
@@ -355,5 +537,5 @@ if (fail.length) {
355
537
  console.error(`ask-ai-render-check: ${fail.length} thing(s) a reader would meet`)
356
538
  process.exit(1)
357
539
  }
358
- console.log(`ask-ai-render-check: the menu, the panel and the unmeasured state all render; both links carry ${JSON.stringify(EXPECTED_QUESTION)}; no button where there is no connector`)
540
+ console.log(`ask-ai-render-check: the header, both panels and the unmeasured state render; all four questions reach the link typed in; Set it up leaves its note and Continue lands on the report with the panel open; no button where there is no connector`)
359
541
  if (shotsDir) console.log(`ask-ai-render-check: shots in ${shotsDir}`)
@@ -153,8 +153,22 @@ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) =>
153
153
  export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
154
154
  // `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
155
155
  // reading of every changed path, which is what a pure table of paths means.
156
- const present = atHead ? new Set(atHead) : null;
157
- const notesOf = (c) => (c.added ?? c.paths).filter((p) => isNotePath(p) && (!present || present.has(p)));
156
+ // MATCHED ON THE NAME, NOT THE PATH, BECAUSE A PRE-RELEASE MOVES A NOTE IT CONSUMES.
157
+ //
158
+ // changesets in pre mode does not delete the note it publishes: it MOVES it from
159
+ // `.changeset/<name>.md` into `.changeset/pre/<name>.md`, and re-applies every one of them when the
160
+ // pre range is exited. The note still exists and still reaches the releases page — twice, once in the
161
+ // pre-release and once in the stable cut it rolls into.
162
+ //
163
+ // Asking whether the ADDED PATH still exists therefore loses every note written before the last
164
+ // pre-release cut, and the commit that wrote one is reported as owing a note it did write. The
165
+ // failure arrives the moment a branch merges a main that has had a beta cut on it, which is now the
166
+ // ordinary case rather than an edge one.
167
+ const presentNames = atHead
168
+ ? new Set(atHead.filter(isNotePath).map((p) => p.split("/").pop()))
169
+ : null;
170
+ const notesOf = (c) => (c.added ?? c.paths)
171
+ .filter((p) => isNotePath(p) && (!presentNames || presentNames.has(p.split("/").pop())));
158
172
  const notes = [...new Set(commits.flatMap(notesOf))];
159
173
  const noteNames = new Set(notes.map((p) => p.split("/").pop()));
160
174
  const visible = [...new Set(commits.flatMap((c) => shipped(c.paths, files)))];