zz-meridian 0.12.1 → 0.12.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 (70) hide show
  1. package/package.json +1 -1
  2. package/payload/CHANGELOG.md +32 -0
  3. package/payload/CONTRIBUTING.md +1 -1
  4. package/payload/app/(dashboard)/members/README.md +2 -2
  5. package/payload/app/system/(atlas)/page.tsx +7 -7
  6. package/payload/docs/benchmark.md +8 -0
  7. package/payload/package.json +1 -1
  8. package/payload/scripts/assistant.ts +58 -46
  9. package/payload/scripts/contrast.ts +1 -49
  10. package/payload/scripts/lib/chrome.ts +3 -0
  11. package/payload/scripts/lib/contrast-pairs.ts +53 -0
  12. package/payload/scripts/lib/deep.ts +16 -5
  13. package/payload/scripts/live.ts +37 -18
  14. package/payload/scripts/navigate.ts +4 -1
  15. package/payload/scripts/tokens.ts +21 -5
  16. package/payload/scripts/vitals.ts +8 -4
  17. package/payload/skills/zz-meridian/references/existing-project.md +3 -2
  18. package/payload/skills/zz-meridian/references/validation.md +6 -3
  19. package/payload/src/components/base/shell/index.tsx +3 -2
  20. package/payload/src/components/base/shell/preview.tsx +2 -2
  21. package/payload/src/components/base/text-roles/README.md +8 -0
  22. package/payload/src/components/base/text-roles/index.tsx +11 -0
  23. package/payload/src/components/charts/column-chart/README.md +2 -2
  24. package/payload/src/components/charts/column-chart/index.tsx +9 -2
  25. package/payload/src/components/patterns/assistant/README.md +1 -1
  26. package/payload/src/components/patterns/assistant/index.tsx +10 -0
  27. package/payload/src/components/patterns/assistant/preview.tsx +1 -1
  28. package/payload/src/components/patterns/command-palette/README.md +1 -0
  29. package/payload/src/components/patterns/command-palette/index.tsx +3 -2
  30. package/payload/src/components/patterns/data-table/README.md +1 -1
  31. package/payload/src/components/patterns/data-table/index.tsx +20 -17
  32. package/payload/src/components/patterns/data-table/preview.tsx +4 -2
  33. package/payload/src/components/patterns/featured-metric/README.md +2 -0
  34. package/payload/src/components/patterns/featured-metric/index.tsx +2 -2
  35. package/payload/src/components/patterns/featured-metric/preview.tsx +7 -1
  36. package/payload/src/components/patterns/filter-bar/preview.tsx +13 -2
  37. package/payload/src/components/patterns/form-section/README.md +1 -1
  38. package/payload/src/components/patterns/form-section/index.tsx +2 -1
  39. package/payload/src/components/patterns/incident-card/index.tsx +1 -2
  40. package/payload/src/components/patterns/metric-tile/README.md +4 -2
  41. package/payload/src/components/patterns/metric-tile/index.tsx +14 -11
  42. package/payload/src/components/patterns/metric-tile/preview.tsx +8 -0
  43. package/payload/src/components/patterns/shell-tools/README.md +1 -1
  44. package/payload/src/components/patterns/shell-tools/index.tsx +3 -3
  45. package/payload/src/components/patterns/shell-tools/preview.tsx +2 -2
  46. package/payload/src/components/patterns/status-list/preview.tsx +2 -1
  47. package/payload/src/components/ui/banner/index.tsx +3 -2
  48. package/payload/src/components/ui/key-value/preview.tsx +5 -4
  49. package/payload/src/components/ui/select/index.tsx +2 -2
  50. package/payload/src/components/ui/sheet/preview.tsx +1 -1
  51. package/payload/src/components/ui/table/README.md +1 -1
  52. package/payload/src/components/ui/table/index.tsx +3 -2
  53. package/payload/src/components/ui/table/preview.tsx +2 -1
  54. package/payload/src/components/ui/toast/index.tsx +3 -2
  55. package/payload/src/lib/color.ts +22 -0
  56. package/payload/src/lib/format-date.ts +8 -0
  57. package/payload/src/lib/format.ts +19 -2
  58. package/payload/src/styles/tokens.css +85 -6
  59. package/payload/src/system/card-stage.tsx +37 -5
  60. package/payload/src/system/hero.tsx +1 -1
  61. package/payload/src/system/page-stage.tsx +28 -11
  62. package/payload/src/system/token-view.tsx +37 -21
  63. package/payload/src/system/tokens-data.ts +1 -1
  64. package/payload/src/views/members.tsx +4 -4
  65. package/payload/src/views/overview.tsx +2 -2
  66. package/payload/src/views/request.tsx +1 -1
  67. package/payload/tests/assistant-panel.test.tsx +3 -2
  68. package/payload/tests/assistant-thread.test.tsx +8 -0
  69. package/payload/tests/color.test.ts +8 -0
  70. package/payload/tests/format.test.ts +22 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,38 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.12.3] · 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ - **The assistant and live-data walk-throughs, and the vitals' taps, reach controls inside web components.** `scripts/assistant.ts`, `scripts/live.ts` and `scripts/vitals.ts` looked for buttons, rows, menus and dialogs in the light DOM only, so on a product whose controls are web components the walk-throughs failed at their first press and vitals measured INP over no tap at all. They now search through open shadow roots with `scripts/lib/deep.ts`, which gains `deepQueryAll`; the live check also looks for a change every 50 ms, since a row a web component draws shows its text without a mutation the page's observer hears, so its timings are now the time to show rather than to the next mutation. Each vitals line ends with `taps: N`. `/deep-tap` pins the tap.
12
+ - **A browser suite reads a page once it has loaded.** `open()` in `scripts/lib/chrome.ts` waited a fixed time and for fonts (at most 4 s), so on a busy machine a suite could read a page still loading; it now also waits for the load event, up to 25 s. `/slow-load` pins it.
13
+ - **A preset whose values differ by theme holds in a nested scope.** Graphite (and jade in dark) set their lightness with ancestor selectors, which cannot say which theme scope is nearest, so a dark scope inside a light page drew graphite's light values: a dark tile on a dark ground. `tokens.css` now picks them with `@scope`, whose proximity rule makes the nearest theme win; the unscoped rules stay, at one attribute's specificity, for a browser without `@scope`. Regenerate with `pnpm tokens`; nothing to change in a product. `tests/deep-accent-scope.test.ts` renders the fourteen nestings in Chrome.
14
+ - **The command palette's field shows that it has focus.** The search band's hairline takes `accent` and its icon `accent-ink` while focus is inside; the audit failed the card without it.
15
+ - **A region or key name never breaks at its hyphen.** `keepHyphenated` moved from the Incident card into `@/components/base/text-roles`; the Banner and the Toast use it too, so "eu-central-1" no longer splits after "eu-central-". Tokens over 24 characters still wrap.
16
+ - **A narrow table's lead column takes the space it is given.** Under 512px of table the `grow` column takes the slack instead of a third, so a phone no longer cuts a route beside empty space.
17
+ - **A disabled Select option is disabled on both lines.** Its description takes `ink-disabled` with its label.
18
+ - **The request timeline's first tick reads `0ms`**, through `formatDuration`, like the ticks after it.
19
+ - **Every category in a column chart keeps its label**, cut to its column while a column is at least 36px wide; dates still thin out.
20
+ - **The assistant says it is working before its first words**: a spinner and "Working on an answer" until text arrives.
21
+ - **A narrow metric tile drops its comparison whole** instead of "vs previou…", and its finding wraps instead of being cut.
22
+ - **The search pill folds by the top bar's own width** (under 40rem), so a canvas narrowed by the assistant folds it too. The top bar is now a container.
23
+ - **A read-only form section keeps its values legible** in `ink-2` instead of `ink-disabled`.
24
+ - **A phone's record card reads its facts as one sentence**: the DataTable card is a wrapping row instead of a six-column grid, so a separator dot never lands alone on a line and a fact is never cut to fit a third.
25
+ - **Members shows the date its order follows**: Joined appears from 768px of table (Role now drops below 1024px and Team below 1152px), so the newest-first order is explained wherever the table has columns; on a phone the card reads "Joined 1 Oct".
26
+ - **The Atlas home drops its numbered section labels** (01 to 05), a default Meridian's own register rules out.
27
+ - **A figure is never cut.** In a Metric tile or a Featured metric a number longer than nine characters is set at a glance with `fitFigure` ("9.9B", "$1.2M") instead of being clipped by its card; `splitFigure` keeps a compact amount's decimal in the number ("$1.2M", not "$1" and ".2M").
28
+ - **A day inside a sentence reads "1 Oct"**: `formatDay` in `src/lib/format-date.ts`; tables and axes keep `formatDate`'s "01 Oct".
29
+ - **The colour parser reads `lab()`.** A production build may ship an authored `oklch()` as `lab()`, which `src/lib/color.ts` could not parse; the contrast pairs the gate checks now live in `scripts/lib/contrast-pairs.ts`, so the Atlas measures the same ones.
30
+
31
+ ## [0.12.2] · 2026-10-09
32
+
33
+ ### Fixed
34
+
35
+ - **A `navigationChecks` control inside a web component is pressable once its page has hydrated.** On a Next page the navigation check counted a control as ready only when the control itself carried React's marker; React never renders into a shadow root, so a control inside one (a design system's input or button) was never ready and the check failed `never-ready`. Readiness is now its rendering host's.
36
+
5
37
  ## [0.12.1] · 2026-10-09
6
38
 
7
39
  ### Fixed
@@ -69,7 +69,7 @@ Write the status on the line under the title: `Status: beta`.
69
69
  ## Done means
70
70
 
71
71
  - Every state is specified and previewed: rest, hover, pressed, focus, selected, disabled, loading, empty, error.
72
- - It holds in both themes, all four accents and both densities, with contrast computed in each by `pnpm contrast`: text 4.5:1, focus rings, control outlines and chart marks 3:1. A new pair goes into `scripts/contrast.ts`.
72
+ - It holds in both themes, all four accents and both densities, with contrast computed in each by `pnpm contrast`: text 4.5:1, focus rings, control outlines and chart marks 3:1. A new pair goes into `scripts/lib/contrast-pairs.ts`, which the gate and the Atlas colour page both read.
73
73
  - It holds at 390, 768, 1024, 1280 and 1440px, and in an embed at 360 and 720px inline and in fullscreen.
74
74
  - Nothing scrolls sideways, nothing clips, and no label shrinks to fit (cut words instead).
75
75
  - Under reduced motion it shows its final state with nothing moving.
@@ -9,7 +9,7 @@ Status: beta
9
9
  | Row | Pattern | Console | Mobile |
10
10
  |---|---|---|---|
11
11
  | Masthead | PageFrame: kicker, "Members", one sentence, Invite member (primary) | One band | Stacked |
12
- | 1 | Data table: Name (avatar and name, grow), Email (from 1024px), Role (from 768px), Team (from 1280px), Status, Joined (from 1280px), Last active, and a row action menu | Columns | Cards: name and the menu, then status, team and last active |
12
+ | 1 | Data table: Name (avatar and name, grow), Email (from 1024px), Role (from 1024px), Team (from 1440px), Status, Joined (from 768px, so the order the table opens in is on screen wherever it has columns), Last active, and a row action menu | Columns | Cards: name and the menu, then status, when they joined (the order the list follows) and last active; a card holds three facts, so team is left to the record |
13
13
  | Sheet | Invite a member: Name and Email (required), Role, Team; Cancel and Send invitation | From the right, 440px | From the bottom |
14
14
  | Dialog | Remove: "Remove {name}?", the consequence, and the suspend alternative; Cancel and Remove member (danger) | Centred | Bottom sheet |
15
15
 
@@ -20,7 +20,7 @@ The row menu holds Suspend (Reactivate on a suspended member) and Remove.
20
20
  | State | What shows |
21
21
  |---|---|
22
22
  | Loading | `loading.tsx`: skeleton table rows; the members are read per request, so the prerendered shell holds none |
23
- | Default | Newest members first |
23
+ | Default | Newest members first, as the Joined column beside them shows |
24
24
  | Invited | The status Badge in `accent`; Last active reads "Never" in `ink-3` |
25
25
  | Suspended | The status Badge in `warning` |
26
26
  | Name or email missing | The field's error under it; nothing is sent |
@@ -66,13 +66,13 @@ export default function AtlasHome() {
66
66
 
67
67
  {/* Layers */}
68
68
  <section className="mt-28">
69
- <SectionHead kicker="01" title={<>Five layers,<br />built from the bottom.</>} line="Each layer uses only the ones beneath it, so a change at the bottom moves everything above, and nothing above invents a value of its own." />
69
+ <SectionHead title={<>Five layers,<br />built from the bottom.</>} line="Each layer uses only the ones beneath it, so a change at the bottom moves everything above, and nothing above invents a value of its own." />
70
70
  <Strata sections={SECTIONS.filter((x) => x.num).map((x) => ({ ...x, count: all.filter((e) => e.section === x.id).length, href: all.find((e) => e.section === x.id)?.href ?? '/system' }))} />
71
71
  </section>
72
72
 
73
73
  {/* Surfaces and operators */}
74
74
  <section className="mt-28">
75
- <SectionHead kicker="02" title={<>Three surfaces.<br />Two operators.</>} line="People point, tap and type; agents call tools. Both use the same views, on the console, on a phone and in an MCP host, under five rules." />
75
+ <SectionHead title={<>Three surfaces.<br />Two operators.</>} line="People point, tap and type; agents call tools. Both use the same views, on the console, on a phone and in an MCP host, under five rules." />
76
76
  <div className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-2 lg:grid-cols-5">
77
77
  {[
78
78
  ['Addressable', 'Every view’s state is in its address: an agent opens exactly the view it means.'],
@@ -96,7 +96,7 @@ export default function AtlasHome() {
96
96
 
97
97
  {/* Principles */}
98
98
  <section className="mt-28">
99
- <SectionHead kicker="03" title={<>The principles<br />every layer answers to.</>} line="When two choices both fit the specification, pick the one that serves these better." />
99
+ <SectionHead title={<>The principles<br />every layer answers to.</>} line="When two choices both fit the specification, pick the one that serves these better." />
100
100
  <div className="grid border-t border-line md:grid-cols-2">
101
101
  {principles.map((p, i) => (
102
102
  <div key={p.title} className={'border-b border-line py-7 md:pr-10 ' + (i % 2 ? 'md:border-l md:pl-10' : '')}>
@@ -109,7 +109,7 @@ export default function AtlasHome() {
109
109
 
110
110
  {/* Guides */}
111
111
  <section className="mt-28">
112
- <SectionHead kicker="04" title="Guides." line="How the surfaces work, how agents fit, and how to start a dashboard from this one." />
112
+ <SectionHead title="Guides." line="How the surfaces work, how agents fit, and how to start a dashboard from this one." />
113
113
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
114
114
  {all.filter((e) => e.section === 'start' && e.id !== 'overview').map((e) => (
115
115
  <Link key={e.href} href={e.href} className="group flex flex-col rounded-lg border border-line bg-surface/70 p-5 hover:border-line-strong">
@@ -123,7 +123,7 @@ export default function AtlasHome() {
123
123
 
124
124
  {/* What changed: the latest release in one line and its headlines; the whole log is one link away. */}
125
125
  <section className="mt-24">
126
- <SectionHead kicker="05" title="What changed." line="Every release, with what breaks and what to do instead." />
126
+ <SectionHead title="What changed." line="Every release, with what breaks and what to do instead." />
127
127
  <div className="grid gap-8 rounded-xl border border-line bg-surface p-7 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
128
128
  <div>
129
129
  <p className="t-kicker">{release.version} · {release.date}</p>
@@ -150,11 +150,11 @@ export default function AtlasHome() {
150
150
  );
151
151
  }
152
152
 
153
- function SectionHead({ kicker, title, line, compact }: { kicker: string; title: React.ReactNode; line: string; compact?: boolean }) {
153
+ function SectionHead({ title, line, compact }: { title: React.ReactNode; line: string; compact?: boolean }) {
154
154
  return (
155
155
  <div className={compact ? 'mb-6' : 'mb-10 grid items-end gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'}>
156
156
  <div>
157
- <p className="t-kicker mb-5">{kicker}</p>
157
+ {/* No number above it: the headline names the section, and a counter would only be decoration. */}
158
158
  <h2 className="text-[clamp(32px,3.6vw,48px)] leading-[1.02] font-semibold tracking-[-0.03em]">{title}</h2>
159
159
  </div>
160
160
  <p className={'t-small max-w-[52ch] text-ink-2 ' + (compact ? 'mt-3' : 'lg:pb-1.5')}>{line}</p>
@@ -84,6 +84,14 @@ Those figures were measured before the suites looked inside open shadow roots. A
84
84
 
85
85
  - **Big screens.** The owner found that on a large monitor the dashboard was a 1560px strip with empty margins. No review had looked past 1440px. Dashboards now fill the canvas at every width (`data-width` is 100%; the gutter grows to 56px), standalone screens keep a `stage-width` of 1560px, and reading pages stay at 832px. The audit now runs at 2560px too, and fails a data page that does not fill its canvas.
86
86
 
87
+ - **A layer-by-layer pass, 2026-10-09.** Every token page and every card preview was rendered at 1440 and 390px in both themes and read from the bottom up, before the pages. What the audits could not see:
88
+ - **Tokens:** the colour page measured every role on a card surface, so tooltip text and the accent wash showed failing ratios they never have in use; each chip now measures the pair the gate checks. Graphite in a dark scope inside a light page drew its light values (ancestor selectors cannot say which theme is nearest); the presets now use `@scope`, held by a browser test of fourteen nestings.
89
+ - **Components:** "eu-central-1" broke at its hyphen in a banner; durations were spelled three ways; a phone table cut "/v1/messa…" beside empty space; a disabled option's second line stayed lit.
90
+ - **Patterns:** a column chart left every other category unnamed; the assistant showed nothing between Send and its first words; narrow tiles read "vs previou…"; a read-only form drew its values at 1.5:1; three specimens contradicted their own captions.
91
+ - **Pages:** a phone's record card left separator dots alone on a line and cut dates; Members looked unsorted because the column that explains its order was hidden; the Atlas home numbered its sections 01 to 05, which Meridian's own register rules out.
92
+ - **Extreme values:** fed the largest figures the formatters print, a row of four tiles cut "9,876,543,210" to "9,876,54", a different number with no ellipsis, which no audit flags because the card clips it. A figure past nine characters now reads at a glance ("9.9B", "$1.2M"); the Extreme values specimens hold it. A probe that rewrote every name and figure in tables, lists and records to its longest also found nothing pushed sideways on any route.
93
+ - **Measured:** `verify --full` on the final commit passes every suite: 0 audit issues on 22 routes at five widths in both themes, 361 controls and 56 links pressed, the keyboard walk, live data, and 18 of 18 pages under LCP 2.5 s, INP 200 ms and CLS 0.1. Web Vitals depend on a quiet machine: with other work holding the load at 11 to 38 on ten cores the same build read 10 to 17 of 18, because the four-times CPU slowdown applies to a host that is already busy; an A/B of the one shell change (the top bar as a container) showed INP tracking the load, not the build. Under reduced motion nothing moves on any route at 1440 or 390px.
94
+
87
95
  ## Open
88
96
 
89
97
  Taste calls the critic raised that are not taken yet, kept in view on purpose:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. DTCG tokens, five layers of React components and a Design Atlas, in a light and a dark theme.",
@@ -15,10 +15,15 @@
15
15
  * on (from /keys); and the Settings switch. A product that removed one of those pages has no such flow to walk: each
16
16
  * step says `n/a` with the reason, and the rest still runs. Whether a sample surface is there is decided from its files,
17
17
  * so Meridian's own repository, which has every one, always walks all of them.
18
+ *
19
+ * Controls are found through open shadow roots (scripts/lib/deep.ts), so a design system whose buttons, rows and menus are
20
+ * web components is walked the same way: a control is searched for under the light-DOM container that holds it (the panel,
21
+ * the page's scroll region, the rail's `nav`) and named by its rendered text.
18
22
  */
19
23
  import path from 'node:path';
20
24
  import { slug } from '../src/app.config.ts';
21
25
  import { launch } from './lib/chrome.ts';
26
+ import { DEEP_SOURCE } from './lib/deep.ts';
22
27
  import { APP_DIR, discover, railRoutes } from './lib/routes.ts';
23
28
  import { sampleSurfaces } from './lib/sample.ts';
24
29
 
@@ -47,26 +52,31 @@ const until = async <T>(what: string, read: () => Promise<T>, done: (v: T) => bo
47
52
  return done(v) ? v : fail(`timed out waiting for ${what} (last: ${JSON.stringify(v)})`);
48
53
  };
49
54
 
50
- const page = await launch();
51
- const MESSAGE = 'aside[data-assistant] textarea[aria-label="Message"]';
52
- const SEND = 'aside[data-assistant] button[aria-label="Send"]';
53
- const PROPOSALS = 'aside[data-assistant] article[aria-label^="Proposal from Assistant"]';
55
+ /** A browser whose every page defines the deep-DOM helpers. */
56
+ const browser = async () => { const p = await launch(); await p.send('Page.addScriptToEvaluateOnNewDocument', { source: DEEP_SOURCE }); return p; };
57
+ const page = await browser();
58
+ /** Page expressions: the panel, the page's own content, and the controls in the panel. */
59
+ const PANEL = `deepQuery('aside[data-assistant]')`;
60
+ const CONTENT = `deepQuery('[data-scroll-region]')`;
61
+ const MESSAGE = `deepQuery('textarea[aria-label="Message"]', ${PANEL})`;
62
+ const SEND = `deepQuery('button[aria-label="Send"]', ${PANEL})`;
63
+ const PROPOSALS = `deepQueryAll('article[aria-label^="Proposal from Assistant"]', ${PANEL})`;
54
64
 
55
65
  /** Type `text` into the panel and press Send. */
56
66
  async function ask(text: string) {
57
- await page.eval(`document.querySelector('${MESSAGE}').focus()`);
67
+ await page.eval(`${MESSAGE}.focus()`);
58
68
  await page.send('Input.insertText', { text });
59
- await until('Send to enable', () => page.eval<boolean>(`!document.querySelector('${SEND}').disabled`), Boolean);
60
- await page.eval(`document.querySelector('${SEND}').click()`);
69
+ await until('Send to enable', () => page.eval<boolean>(`!${SEND}.disabled`), Boolean);
70
+ await page.eval(`${SEND}.click()`);
61
71
  }
62
72
  /** What the assistant's latest text says (a Proposal card's own text is not the assistant speaking).
63
73
  * `data-assistant-text` is the reply block's own handle: a reply is markdown, so its text may be in a paragraph, a
64
74
  * list item or a table cell, and a selector that assumed a paragraph would break the day a model answers with a list. */
65
- const said = () => page.eval<string>(`[...document.querySelectorAll('aside[data-assistant] [data-assistant-text]')].at(-1)?.textContent.trim() ?? ''`);
75
+ const said = () => page.eval<string>(`deepQueryAll('[data-assistant-text]', ${PANEL}).at(-1)?.textContent.trim() ?? ''`);
66
76
  /** The titles of the Proposal cards in the panel. */
67
- const proposals = () => page.eval<string[]>(`[...document.querySelectorAll('${PROPOSALS}')].map((a) => a.getAttribute('aria-label'))`);
77
+ const proposals = () => page.eval<string[]>(`${PROPOSALS}.map((a) => a.getAttribute('aria-label'))`);
68
78
  /** A table row's text: the member's name, role, team, status and activity. */
69
- const row = (name: string) => page.eval<string | null>(`[...document.querySelectorAll('tbody tr')].find((r) => r.textContent.includes(${JSON.stringify(name)}))?.textContent ?? null`);
79
+ const row = (name: string) => page.eval<string | null>(`deepQueryAll('tr').map((r) => deepText(r)).find((t) => t.includes(${JSON.stringify(name)})) ?? null`);
70
80
  /** Press with the mouse, as a person does: menus open on the pointer, not on a synthetic click. */
71
81
  async function press(find: string) {
72
82
  const where = `(() => { const el = ${find}; if (!el) return null; el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`;
@@ -82,7 +92,7 @@ async function press(find: string) {
82
92
  for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased'] as const) await page.send('Input.dispatchMouseEvent', { type, x: at.x, y: at.y, button: 'left', clickCount: 1 });
83
93
  }
84
94
  /** Press the button named `label` in the open Proposal card. */
85
- const decide = (label: string) => press(`[...document.querySelectorAll('${PROPOSALS} button')].find((b) => b.textContent.trim() === ${JSON.stringify(label)})`);
95
+ const decide = (label: string) => press(`${PROPOSALS}.flatMap((a) => deepQueryAll('button', a)).find((b) => deepText(b).trim() === ${JSON.stringify(label)})`);
86
96
  try {
87
97
  if (expect === 'off') {
88
98
  // Without an assistant, and outside any host, no agent control appears anywhere: no launcher, no panel, no Ask.
@@ -91,34 +101,35 @@ try {
91
101
  for (const route of ROUTES) {
92
102
  await page.open(`${base}${route}`, { width: 1440 });
93
103
  await sleep(400);
94
- const found = await page.eval<number>(`document.querySelectorAll('[data-assistant], button[aria-label="Assistant"], button[aria-label^="Ask: "]').length`);
104
+ const found = await page.eval<number>(`deepQueryAll('[data-assistant], button[aria-label="Assistant"], button[aria-label^="Ask: "]').length`);
95
105
  if (found) fail(`${route} has ${found} agent control(s) with no assistant and no host`);
96
106
  }
97
107
  ok(`${ROUTES.length} pages and views have no panel, no launcher and no Ask`);
98
108
  // The person's own findings do not depend on any agent.
99
109
  if (SAMPLE.overview) {
100
110
  await page.open(`${base}/`, { width: 1440 });
101
- await until('the Overview finding', () => page.eval<boolean>(`[...document.querySelectorAll('[data-scroll-region] button')].some((b) => /usual on/.test(b.textContent))`), Boolean);
111
+ await until('the Overview finding', () => page.eval<boolean>(`deepQueryAll('button', ${CONTENT}).some((b) => /usual on/.test(deepText(b)))`), Boolean);
102
112
  ok('the Overview shows its finding with no assistant');
103
113
  } else na('the Overview shows its finding with no assistant', 'the sample Overview is not in this product');
104
114
  const status = (await fetch(`${base}/api/assistant`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"messages":[]}' })).status;
105
115
  if (status !== 404) fail(`POST /api/assistant answered ${status}, expected 404`);
106
116
  ok('POST /api/assistant answers 404');
107
117
  } else {
108
- const LAUNCHER = 'button[aria-label="Assistant"]';
109
- const LAUNCH = `document.querySelector('${LAUNCHER}').click()`;
110
- const panelOpen = () => page.eval<boolean>(`!!document.querySelector('aside[data-assistant]')`);
118
+ const LAUNCHER = `deepQuery('button[aria-label="Assistant"]')`;
119
+ const LAUNCH = `${LAUNCHER}.click()`;
120
+ const panelOpen = () => page.eval<boolean>(`!!${PANEL}`);
111
121
  const openPanel = async () => { await page.eval(LAUNCH); await until('the panel to open', panelOpen, Boolean); };
112
122
  const stored = () => page.eval<string | null>(`localStorage.getItem(${JSON.stringify(`${slug}.assistant`)})`);
113
123
  const here = () => page.eval<string>(`location.pathname`);
114
- const cards = () => page.eval<{ text: string; buttons: string[] }[]>(`[...document.querySelectorAll('${PROPOSALS}')].map((a) => ({ text: a.textContent, buttons: [...a.querySelectorAll('button')].map((b) => b.textContent.trim()) }))`);
115
- const follow = async (href: string) => { await press(`document.querySelector('aside[aria-label="Primary"] a[href="${href}"]')`); await until(`the address to be ${href}`, here, (p) => p === href); };
124
+ const cards = () => page.eval<{ text: string; buttons: string[] }[]>(`${PROPOSALS}.map((a) => ({ text: a.textContent, buttons: deepQueryAll('button', a).map((b) => deepText(b).trim()) }))`);
125
+ // The rail's link, as navigate.ts finds it: a link inside a nav, the one on screen.
126
+ const follow = async (href: string) => { await press(`deepQueryAll('nav a[href="${href}"]').find((a) => a.getBoundingClientRect().width > 0)`); await until(`the address to be ${href}`, here, (p) => p === href); };
116
127
  const recorded = async () => (await (await fetch(`${llm}/requests`)).json()) as { messages?: { role: string; content: unknown }[] }[];
117
128
  const LOCAL = `${slug}.assistant`;
118
129
  const ABOUT_PAGE = /^This is the .+ page\.$/;
119
130
 
120
131
  await page.open(`${base}${landing}`, { width: 1440 });
121
- if (!(await page.eval<boolean>(`!!document.querySelector('${LAUNCHER}')`))) fail(`no launcher on ${landing}`);
132
+ if (!(await page.eval<boolean>(`!!${LAUNCHER}`))) fail(`no launcher on ${landing}`);
122
133
  ok(`the launcher is on ${landing}`);
123
134
 
124
135
  // The panel is a SEPARATE CHUNK, and this is what says so. The page is measured with the panel closed, then again
@@ -155,12 +166,13 @@ try {
155
166
  ok(`the model was given the Overview's view context: ${viewContext.length} characters, with the error rate's definition, the spike against its median and what nothing recorded explains`);
156
167
 
157
168
  // Handoff: Ask on a card posts its question into the panel as the person's, with the page's view context.
158
- const askLabel = await page.eval<string>(`document.querySelector('[data-scroll-region] button[aria-label^="Ask: "]')?.getAttribute('aria-label') ?? ''`);
169
+ const ASK = `deepQuery('button[aria-label^="Ask: "]', ${CONTENT})`;
170
+ const askLabel = await page.eval<string>(`${ASK}?.getAttribute('aria-label') ?? ''`);
159
171
  if (!askLabel) fail('the Overview has no Ask on its featured card while the assistant is on');
160
172
  const question = askLabel.replace(/^Ask: /, '');
161
173
  const beforeAsk = (await recorded()).length;
162
- await press(`document.querySelector('[data-scroll-region] button[aria-label^="Ask: "]')`);
163
- await until('the asked question in the thread', () => page.eval<string[]>(`[...document.querySelectorAll('aside[data-assistant] [data-role="user"]')].map((m) => m.textContent)`), (ms) => ms.some((m) => m.includes(question)));
174
+ await press(ASK);
175
+ await until('the asked question in the thread', () => page.eval<string[]>(`deepQueryAll('[data-role="user"]', ${PANEL}).map((m) => m.textContent)`), (ms) => ms.some((m) => m.includes(question)));
164
176
  const asked = await until('the request carrying the question', async () => (await recorded()).slice(beforeAsk), (rs) => rs.some((r) => JSON.stringify(r.messages?.at(-1)).includes(question.slice(0, 40))));
165
177
  const askedSystem = String(asked.find((r) => JSON.stringify(r.messages?.at(-1)).includes(question.slice(0, 40)))?.messages?.find((m) => m.role === 'system')?.content ?? '');
166
178
  if (!askedSystem.includes('<view-context>')) fail('the question Ask posted reached the model without the view context');
@@ -177,9 +189,9 @@ try {
177
189
  // ELEMENTS, and the characters that made them are gone from the panel's text.
178
190
  await ask('Show me a markdown summary');
179
191
  const md = await until('the markdown reply', () => page.eval<{ list: number; bold: number; table: number; text: string }>(
180
- `(() => { const b = [...document.querySelectorAll('aside[data-assistant] [data-assistant-text]')].at(-1);
181
- return b ? { list: b.querySelectorAll('li').length, bold: b.querySelectorAll('strong').length,
182
- table: b.querySelectorAll('table').length, text: b.textContent } : { list: 0, bold: 0, table: 0, text: '' }; })()`),
192
+ `(() => { const b = deepQueryAll('[data-assistant-text]', ${PANEL}).at(-1);
193
+ return b ? { list: deepQueryAll('li', b).length, bold: deepQueryAll('strong', b).length,
194
+ table: deepQueryAll('table', b).length, text: b.textContent } : { list: 0, bold: 0, table: 0, text: '' }; })()`),
183
195
  (v) => v.table > 0);
184
196
  if (!md.list || !md.bold) fail(`the markdown reply rendered ${md.list} list items and ${md.bold} bold runs`);
185
197
  if (md.text.includes('**') || md.text.includes('|---') || md.text.includes('| ---')) fail(`the markdown reply still shows its markup: "${md.text}"`);
@@ -192,8 +204,8 @@ try {
192
204
  await openPanel();
193
205
 
194
206
  // The table is newest-joined first at 20 a page, so the long-tenured members are on page 2: show 50 to read them all.
195
- await press(`[...document.querySelectorAll('nav[aria-label="Pagination"] button')].find((b) => b.textContent.includes('per page'))`);
196
- await press(`[...document.querySelectorAll('[role="menuitem"]')].find((i) => i.textContent.includes('50 per page'))`);
207
+ await press(`deepQueryAll('button', deepQuery('nav[aria-label="Pagination"]')).find((b) => deepText(b).includes('per page'))`);
208
+ await press(`deepQueryAll('[role="menuitem"]').find((i) => deepText(i).includes('50 per page'))`);
197
209
  await until('every member to show', () => row('Alice Moreno'), Boolean);
198
210
 
199
211
  await ask('Which Support viewers have been inactive for more than 60 days?');
@@ -205,7 +217,7 @@ try {
205
217
  await ask('Suspend them');
206
218
  await until('a Proposal to change two members', proposals, (t) => t.some((l) => l?.startsWith('Proposal from Assistant: Change 2 members')));
207
219
  // A second tab holds the Overview open, as a teammate would: the Activity line must reach it without a reload.
208
- const watcher = SAMPLE.overview ? await launch() : null;
220
+ const watcher = SAMPLE.overview ? await browser() : null;
209
221
  await watcher?.open(`${base}/`, { width: 1440 });
210
222
  await decide('Approve');
211
223
  const suspended = await until('both rows to read Suspended', async () => [await row('Alice Moreno'), await row('Ravi Patel')], (r) => r.every((t) => t?.includes('Suspended')));
@@ -215,7 +227,7 @@ try {
215
227
  if (watcher) {
216
228
  // Provenance: the approved change left an Activity line naming the agent and the person it acted for.
217
229
  const object = 'status to Suspended for Alice Moreno and Ravi Patel';
218
- const line = await until('the Activity line on the open Overview', () => watcher.eval<string>(`[...document.querySelectorAll('[data-scroll-region] li')].map((l) => l.textContent).find((t) => t.includes(${JSON.stringify(object)})) ?? ''`), Boolean);
230
+ const line = await until('the Activity line on the open Overview', () => watcher.eval<string>(`deepQueryAll('li', ${CONTENT}).map((l) => l.textContent).find((t) => t.includes(${JSON.stringify(object)})) ?? ''`), Boolean);
219
231
  watcher.close();
220
232
  if (!/^Assistant ?changed/.test(line.trim()) || !line.includes('for Maya Chen')) fail(`the Activity line does not name the Assistant and the person it acted for: "${line}"`);
221
233
  ok(`the open Overview's Activity showed "${line.replace(/\s+/g, ' ').trim()}" without a reload`);
@@ -229,9 +241,9 @@ try {
229
241
  if (!kept) fail('the dismissed removal took Alice Moreno out of the table');
230
242
  ok('the dismissed removal left Alice Moreno in the table');
231
243
 
232
- await press(`document.querySelector('button[aria-label="Actions for Amara Okafor"]')`);
233
- await until('the row menu', () => page.eval<boolean>(`!!document.querySelector('[role="menu"]')`), Boolean);
234
- await press(`[...document.querySelectorAll('[role="menuitem"]')].find((i) => i.textContent.trim() === 'Suspend')`);
244
+ await press(`deepQuery('button[aria-label="Actions for Amara Okafor"]')`);
245
+ await until('the row menu', () => page.eval<boolean>(`!!deepQuery('[role="menu"]')`), Boolean);
246
+ await press(`deepQueryAll('[role="menuitem"]').find((i) => deepText(i).trim() === 'Suspend')`);
235
247
  await until('Amara Okafor to read Suspended', () => row('Amara Okafor'), (t) => !!t?.includes('Suspended'));
236
248
  await ask('Who is suspended?');
237
249
  const found = await until('the answer to name Amara Okafor', said, (t) => t.includes('Amara Okafor'));
@@ -254,10 +266,10 @@ try {
254
266
  if (to) {
255
267
  await follow(to);
256
268
  if (!(await panelOpen())) fail(`following the rail to ${to} closed the panel`);
257
- const labels = () => page.eval<string[]>(`[...document.querySelectorAll('aside[data-assistant] span')].map((s) => s.textContent.trim()).filter((t) => t.startsWith('On '))`);
269
+ const labels = () => page.eval<string[]>(`deepQueryAll('span', ${PANEL}).map((s) => s.textContent.trim()).filter((t) => t.startsWith('On '))`);
258
270
  const tagged = await labels();
259
271
  if (!tagged.includes(`On ${fromTitle}`)) fail(`${to} shows the labels ${JSON.stringify(tagged)}, none "On ${fromTitle}"`);
260
- if (!(await page.eval<boolean>(`document.querySelector('aside[data-assistant]').textContent.includes('Show me a markdown summary')`))) fail(`${to} lost the earlier messages`);
272
+ if (!(await page.eval<boolean>(`${PANEL}.textContent.includes('Show me a markdown summary')`))) fail(`${to} lost the earlier messages`);
261
273
  ok(`following the rail to ${to} kept the panel open with the earlier messages and their "On ${fromTitle}" labels (${tagged.length} labels)`);
262
274
  } else na('the thread across navigation', 'the rail has one route');
263
275
 
@@ -286,8 +298,8 @@ try {
286
298
  } else na('a waiting Proposal expiring on reload and on moving on', 'the sample Members and API keys pages are not both in this product');
287
299
 
288
300
  // Clear: the panel and the stored thread are both emptied.
289
- await press(`document.querySelector('aside[data-assistant] button[aria-label="Clear conversation"]')`);
290
- await until('the panel to empty', () => page.eval<number>(`document.querySelectorAll('aside[data-assistant] [data-role]').length`), (n) => n === 0);
301
+ await press(`deepQuery('button[aria-label="Clear conversation"]', ${PANEL})`);
302
+ await until('the panel to empty', () => page.eval<number>(`deepQueryAll('[data-role]', ${PANEL}).length`), (n) => n === 0);
291
303
  await until('the stored thread to go', stored, (v) => v === null);
292
304
  ok(`Clear conversation empties the panel and removes ${LOCAL} from local storage`);
293
305
 
@@ -297,8 +309,8 @@ try {
297
309
  await until('the reply', said, (t) => ABOUT_PAGE.test(t));
298
310
  await until('the thread to be stored', stored, (v) => !!v?.includes('What is this page?'));
299
311
  await follow('/settings');
300
- const SWITCH = `(() => { const l = [...document.querySelectorAll('label')].find((x) => x.textContent.includes('Show the assistant')); return l && document.getElementById(l.htmlFor); })()`;
301
- const absent = () => page.eval<boolean>(`!document.querySelector('${LAUNCHER}') && !document.querySelector('aside[data-assistant]')`);
312
+ const SWITCH = `deepQueryAll('label').find((x) => deepText(x).includes('Show the assistant'))?.control`;
313
+ const absent = () => page.eval<boolean>(`!${LAUNCHER} && !${PANEL}`);
302
314
  await press(SWITCH);
303
315
  await until('the launcher and panel to go from /settings', absent, Boolean);
304
316
  const elsewhere = SAMPLE.members ? '/members' : rail.find((r) => r !== '/settings' && r !== '/' && !r.startsWith('/system')) ?? landing;
@@ -308,16 +320,16 @@ try {
308
320
  // Switched off, the pages work as they would without any agent: no Ask anywhere, and the person's findings still there.
309
321
  if (SAMPLE.overview) {
310
322
  await page.open(`${base}/`, { width: 1440 });
311
- await until('the Overview to render its finding', () => page.eval<boolean>(`[...document.querySelectorAll('[data-scroll-region] button')].some((b) => /usual on/.test(b.textContent))`), Boolean);
312
- if (await page.eval<boolean>(`!!document.querySelector('button[aria-label^="Ask: "]')`)) fail('the Overview shows Ask with the assistant switched off');
323
+ await until('the Overview to render its finding', () => page.eval<boolean>(`deepQueryAll('button', ${CONTENT}).some((b) => /usual on/.test(deepText(b)))`), Boolean);
324
+ if (await page.eval<boolean>(`!!deepQuery('button[aria-label^="Ask: "]')`)) fail('the Overview shows Ask with the assistant switched off');
313
325
  if (!(await absent())) fail('/ still shows the assistant with the switch off');
314
326
  }
315
327
  await page.open(`${base}/settings`, { width: 1440 });
316
328
  if (!(await absent())) fail('/settings still shows the assistant with the switch off');
317
329
  await press(SWITCH);
318
- await until('the launcher to return', () => page.eval<boolean>(`!!document.querySelector('${LAUNCHER}')`), Boolean);
330
+ await until('the launcher to return', () => page.eval<boolean>(`!!${LAUNCHER}`), Boolean);
319
331
  await openPanel();
320
- await until('the thread to return', () => page.eval<boolean>(`document.querySelector('aside[data-assistant]').textContent.includes('What is this page?')`), Boolean);
332
+ await until('the thread to return', () => page.eval<boolean>(`${PANEL}.textContent.includes('What is this page?')`), Boolean);
321
333
  ok(`Show the assistant off hides the launcher on /settings and ${elsewhere}${SAMPLE.overview ? ' and / and every Ask, keeps the Overview\'s finding' : ''} and the thread; on brings the launcher and the thread back`);
322
334
  } else {
323
335
  na('the Settings switch', 'the sample Settings page, which holds "Show the assistant", is not in this product');
@@ -325,14 +337,14 @@ try {
325
337
 
326
338
  // Layout: a column beside the page at 1440px, over it at 390px; Escape closes it.
327
339
  if (!(await panelOpen())) await openPanel();
328
- const geometry = () => page.eval<{ position: string; main: number; panel: number; width: number }>(`(() => { const a = document.querySelector('aside[data-assistant]'); return { position: getComputedStyle(a).position, main: Math.round(document.querySelector('main').getBoundingClientRect().right), panel: Math.round(a.getBoundingClientRect().left), width: innerWidth }; })()`);
340
+ const geometry = () => page.eval<{ position: string; main: number; panel: number; width: number }>(`(() => { const a = ${PANEL}; return { position: getComputedStyle(a).position, main: Math.round(document.querySelector('main').getBoundingClientRect().right), panel: Math.round(a.getBoundingClientRect().left), width: innerWidth }; })()`);
329
341
  const wide = await geometry();
330
342
  if (wide.position === 'fixed' || Math.abs(wide.main - wide.panel) > 1) fail(`at 1440px the panel is ${wide.position} with the page ending at ${wide.main}px and the panel starting at ${wide.panel}px`);
331
343
  await page.open(`${base}${landing}`, { width: 390 });
332
344
  await openPanel();
333
345
  const narrow = await geometry();
334
346
  if (narrow.position !== 'fixed' || narrow.main !== narrow.width || narrow.panel >= narrow.main) fail(`at 390px the panel is ${narrow.position}, the page ends at ${narrow.main}px and the panel starts at ${narrow.panel}px`);
335
- await page.eval(`document.querySelector('${MESSAGE}').focus()`);
347
+ await page.eval(`${MESSAGE}.focus()`);
336
348
  for (const type of ['keyDown', 'keyUp'] as const) await page.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
337
349
  await until('Escape to close the panel', panelOpen, (v) => !v);
338
350
  ok(`at 1440px the panel is a column (page ends at ${wide.main}px, panel starts at ${wide.panel}px); at 390px it is fixed over the page; Escape closes it`);
@@ -342,9 +354,9 @@ try {
342
354
  await page.eval(`(() => { window.__api = []; const f = window.fetch; window.fetch = async (...a) => { const r = await f(...a); if (String(a[0]).includes('/api/assistant')) r.clone().text().then((t) => window.__api.push(t)); return r; }; })()`);
343
355
  await openPanel();
344
356
  await ask('Please fail now');
345
- const panelText = () => page.eval<string>(`document.querySelector('aside[data-assistant]').textContent`);
357
+ const panelText = () => page.eval<string>(`${PANEL}.textContent`);
346
358
  await until('the failure to show', panelText, (t) => t.includes('The assistant is not set up correctly'));
347
- if (!(await page.eval<boolean>(`[...document.querySelectorAll('aside[data-assistant] button')].some((b) => b.textContent.trim() === 'Retry')`))) fail('the failure offers no Retry');
359
+ if (!(await page.eval<boolean>(`deepQueryAll('button', ${PANEL}).some((b) => deepText(b).trim() === 'Retry')`))) fail('the failure offers no Retry');
348
360
  if ((await panelText()).includes(key!)) fail('the panel shows the key');
349
361
  ok('a refused key shows "The assistant is not set up correctly" with Retry');
350
362
  const seen = await page.eval<string[]>(`window.__api`);
@@ -9,55 +9,7 @@
9
9
  */
10
10
  import { load, resolver, tokensOf, cssValue, NS } from './tokens.ts';
11
11
  import { parse, over, ratio, simulate, deltaE, oklchOf, hex } from '../src/lib/color.ts';
12
-
13
- const TEXT = 4.5, UI = 3;
14
- type Pair = [fg: string, bg: string | string[], min: number, what: string];
15
-
16
- const PAIRS: Pair[] = [
17
- ['ink', 'ground', TEXT, 'body text on the page'],
18
- ['ink', 'surface', TEXT, 'body text in a card'],
19
- ['ink', 'surface-raised', TEXT, 'text in a menu or dialog'],
20
- ['ink-2', 'ground', TEXT, 'secondary copy on the page'],
21
- ['ink-2', 'surface', TEXT, 'secondary copy and inactive navigation'],
22
- ['ink-2', 'surface-sunk', TEXT, 'a segmented option at rest'],
23
- ['ink-2', ['frame'], TEXT, 'inactive navigation in the rail'],
24
- ['ink-3', ['frame'], TEXT, 'a group label in the rail'],
25
- ['accent-ink', ['frame', 'accent-tint'], TEXT, 'the active navigation label in the rail'],
26
- ['ink-3', 'ground', TEXT, 'captions on the page'],
27
- ['ink-3', 'surface', TEXT, 'axis ticks, captions and placeholders in a card'],
28
- ['ink-3', 'surface-sunk', TEXT, 'a table head'],
29
- ['ink-3', 'surface-raised', TEXT, 'a shortcut hint in a menu'],
30
- ['ink', ['surface', 'fill-hover'], TEXT, 'a row under the pointer'],
31
- ['ink-inverse', 'surface-inverse', TEXT, 'tooltip text'],
32
- ['on-accent', 'accent', TEXT, 'primary button label'],
33
- ['on-accent', 'accent-hover', TEXT, 'primary button label on hover'],
34
- ['accent-ink', 'surface', TEXT, 'an inline link in a card'],
35
- ['accent-ink', 'ground', TEXT, 'an inline link on the page'],
36
- ['accent-ink', ['surface', 'accent-tint'], TEXT, 'the active navigation label'],
37
- ['ink', ['surface', 'accent-tint'], TEXT, 'a selected row'],
38
- ['ink-2', ['surface', 'accent-tint'], TEXT, 'muted text in a selected row or on an accent wash (ink-3 is not enough there)'],
39
- ['ink-2', ['surface', 'fill-track'], TEXT, 'a count on an inactive tab'],
40
- ['positive-ink', 'surface', TEXT, 'an improving delta'],
41
- ['positive-ink', ['surface', 'positive-tint'], TEXT, 'a positive badge'],
42
- ['warning-ink', 'surface', TEXT, 'a warning message'],
43
- ['warning-ink', ['surface', 'warning-tint'], TEXT, 'a warning badge'],
44
- ['critical-ink', 'surface', TEXT, 'an error message'],
45
- ['critical-ink', 'ground', TEXT, 'an error under a field on the page'],
46
- ['critical-ink', ['surface', 'critical-tint'], TEXT, 'a critical badge'],
47
- ['on-critical', 'critical-fill', TEXT, 'the destructive button label'],
48
- ['accent', 'surface', UI, 'focus ring, active marker and the highlighted mark in a card'],
49
- ['accent', 'ground', UI, 'focus ring on the page'],
50
- ['line-control', 'surface', UI, 'a checkbox or radio at rest'],
51
- ['positive', 'surface', UI, 'a positive status dot'],
52
- ['warning', 'surface', UI, 'a warning status dot'],
53
- ['critical', 'surface', UI, 'a critical status dot'],
54
- ['chart-neutral-strong', 'surface', UI, 'a neutral comparison line'],
55
- ['series-1', 'surface', UI, 'chart slot 1'],
56
- ['series-2', 'surface', UI, 'chart slot 2'],
57
- ['series-3', 'surface', UI, 'chart slot 3'],
58
- ['series-4', 'surface', UI, 'chart slot 4'],
59
- ['series-6', 'surface', UI, 'chart slot 6'],
60
- ];
12
+ import { PAIRS } from './lib/contrast-pairs.ts';
61
13
 
62
14
  /** Every custom property of one context, as raw CSS strings. */
63
15
  function context(theme: string, accent: string): Record<string, string> {
@@ -102,6 +102,9 @@ async function attach(proc: ChildProcess, dir: string, handle: { proc: ChildProc
102
102
  await send('Emulation.setTouchEmulationEnabled', width < 600 ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
103
103
  await send('Page.navigate', { url });
104
104
  await sleep(wait);
105
+ // Then its load event: on a busy machine a page can still be loading after any fixed wait, and a suite reading it then
106
+ // reads a page that is not all there. Bounded, so a page whose load never ends still opens.
107
+ await page.eval('new Promise((r) => { if (document.readyState === "complete") return r(true); addEventListener("load", () => r(true), { once: true }); setTimeout(() => r(false), 25000); })');
105
108
  await page.eval('Promise.race([document.fonts.ready.then(() => true), new Promise((r) => setTimeout(() => r(false), 4000))])');
106
109
  },
107
110
  async shot(file, { full = false, width, height } = {}) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Every foreground the specifications put on a background, with the contrast it needs: text 4.5:1; focus rings,
3
+ * control outlines and chart marks 3:1 (WCAG 1.4.11). A background given as a list is composited in order over the
4
+ * ground. Pure data, so the contrast gate and the Atlas's colour page measure the same pairs.
5
+ */
6
+ export const TEXT = 4.5, UI = 3;
7
+ export type Pair = [fg: string, bg: string | string[], min: number, what: string];
8
+
9
+ export const PAIRS: Pair[] = [
10
+ ['ink', 'ground', TEXT, 'body text on the page'],
11
+ ['ink', 'surface', TEXT, 'body text in a card'],
12
+ ['ink', 'surface-raised', TEXT, 'text in a menu or dialog'],
13
+ ['ink-2', 'ground', TEXT, 'secondary copy on the page'],
14
+ ['ink-2', 'surface', TEXT, 'secondary copy and inactive navigation'],
15
+ ['ink-2', 'surface-sunk', TEXT, 'a segmented option at rest'],
16
+ ['ink-2', ['frame'], TEXT, 'inactive navigation in the rail'],
17
+ ['ink-3', ['frame'], TEXT, 'a group label in the rail'],
18
+ ['accent-ink', ['frame', 'accent-tint'], TEXT, 'the active navigation label in the rail'],
19
+ ['ink-3', 'ground', TEXT, 'captions on the page'],
20
+ ['ink-3', 'surface', TEXT, 'axis ticks, captions and placeholders in a card'],
21
+ ['ink-3', 'surface-sunk', TEXT, 'a table head'],
22
+ ['ink-3', 'surface-raised', TEXT, 'a shortcut hint in a menu'],
23
+ ['ink', ['surface', 'fill-hover'], TEXT, 'a row under the pointer'],
24
+ ['ink-inverse', 'surface-inverse', TEXT, 'tooltip text'],
25
+ ['on-accent', 'accent', TEXT, 'primary button label'],
26
+ ['on-accent', 'accent-hover', TEXT, 'primary button label on hover'],
27
+ ['accent-ink', 'surface', TEXT, 'an inline link in a card'],
28
+ ['accent-ink', 'ground', TEXT, 'an inline link on the page'],
29
+ ['accent-ink', ['surface', 'accent-tint'], TEXT, 'the active navigation label'],
30
+ ['ink', ['surface', 'accent-tint'], TEXT, 'a selected row'],
31
+ ['ink-2', ['surface', 'accent-tint'], TEXT, 'muted text in a selected row or on an accent wash (ink-3 is not enough there)'],
32
+ ['ink-2', ['surface', 'fill-track'], TEXT, 'a count on an inactive tab'],
33
+ ['positive-ink', 'surface', TEXT, 'an improving delta'],
34
+ ['positive-ink', ['surface', 'positive-tint'], TEXT, 'a positive badge'],
35
+ ['warning-ink', 'surface', TEXT, 'a warning message'],
36
+ ['warning-ink', ['surface', 'warning-tint'], TEXT, 'a warning badge'],
37
+ ['critical-ink', 'surface', TEXT, 'an error message'],
38
+ ['critical-ink', 'ground', TEXT, 'an error under a field on the page'],
39
+ ['critical-ink', ['surface', 'critical-tint'], TEXT, 'a critical badge'],
40
+ ['on-critical', 'critical-fill', TEXT, 'the destructive button label'],
41
+ ['accent', 'surface', UI, 'focus ring, active marker and the highlighted mark in a card'],
42
+ ['accent', 'ground', UI, 'focus ring on the page'],
43
+ ['line-control', 'surface', UI, 'a checkbox or radio at rest'],
44
+ ['positive', 'surface', UI, 'a positive status dot'],
45
+ ['warning', 'surface', UI, 'a warning status dot'],
46
+ ['critical', 'surface', UI, 'a critical status dot'],
47
+ ['chart-neutral-strong', 'surface', UI, 'a neutral comparison line'],
48
+ ['series-1', 'surface', UI, 'chart slot 1'],
49
+ ['series-2', 'surface', UI, 'chart slot 2'],
50
+ ['series-3', 'surface', UI, 'chart slot 3'],
51
+ ['series-4', 'surface', UI, 'chart slot 4'],
52
+ ['series-6', 'surface', UI, 'chart slot 6'],
53
+ ];