zz-meridian 0.12.2 → 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.
- package/package.json +1 -1
- package/payload/CHANGELOG.md +26 -0
- package/payload/CONTRIBUTING.md +1 -1
- package/payload/app/(dashboard)/members/README.md +2 -2
- package/payload/app/system/(atlas)/page.tsx +7 -7
- package/payload/docs/benchmark.md +8 -0
- package/payload/package.json +1 -1
- package/payload/scripts/assistant.ts +58 -46
- package/payload/scripts/contrast.ts +1 -49
- package/payload/scripts/lib/chrome.ts +3 -0
- package/payload/scripts/lib/contrast-pairs.ts +53 -0
- package/payload/scripts/lib/deep.ts +16 -5
- package/payload/scripts/live.ts +37 -18
- package/payload/scripts/tokens.ts +21 -5
- package/payload/scripts/vitals.ts +8 -4
- package/payload/skills/zz-meridian/references/existing-project.md +3 -2
- package/payload/skills/zz-meridian/references/validation.md +6 -3
- package/payload/src/components/base/shell/index.tsx +3 -2
- package/payload/src/components/base/shell/preview.tsx +2 -2
- package/payload/src/components/base/text-roles/README.md +8 -0
- package/payload/src/components/base/text-roles/index.tsx +11 -0
- package/payload/src/components/charts/column-chart/README.md +2 -2
- package/payload/src/components/charts/column-chart/index.tsx +9 -2
- package/payload/src/components/patterns/assistant/README.md +1 -1
- package/payload/src/components/patterns/assistant/index.tsx +10 -0
- package/payload/src/components/patterns/assistant/preview.tsx +1 -1
- package/payload/src/components/patterns/command-palette/README.md +1 -0
- package/payload/src/components/patterns/command-palette/index.tsx +3 -2
- package/payload/src/components/patterns/data-table/README.md +1 -1
- package/payload/src/components/patterns/data-table/index.tsx +20 -17
- package/payload/src/components/patterns/data-table/preview.tsx +4 -2
- package/payload/src/components/patterns/featured-metric/README.md +2 -0
- package/payload/src/components/patterns/featured-metric/index.tsx +2 -2
- package/payload/src/components/patterns/featured-metric/preview.tsx +7 -1
- package/payload/src/components/patterns/filter-bar/preview.tsx +13 -2
- package/payload/src/components/patterns/form-section/README.md +1 -1
- package/payload/src/components/patterns/form-section/index.tsx +2 -1
- package/payload/src/components/patterns/incident-card/index.tsx +1 -2
- package/payload/src/components/patterns/metric-tile/README.md +4 -2
- package/payload/src/components/patterns/metric-tile/index.tsx +14 -11
- package/payload/src/components/patterns/metric-tile/preview.tsx +8 -0
- package/payload/src/components/patterns/shell-tools/README.md +1 -1
- package/payload/src/components/patterns/shell-tools/index.tsx +3 -3
- package/payload/src/components/patterns/shell-tools/preview.tsx +2 -2
- package/payload/src/components/patterns/status-list/preview.tsx +2 -1
- package/payload/src/components/ui/banner/index.tsx +3 -2
- package/payload/src/components/ui/key-value/preview.tsx +5 -4
- package/payload/src/components/ui/select/index.tsx +2 -2
- package/payload/src/components/ui/sheet/preview.tsx +1 -1
- package/payload/src/components/ui/table/README.md +1 -1
- package/payload/src/components/ui/table/index.tsx +3 -2
- package/payload/src/components/ui/table/preview.tsx +2 -1
- package/payload/src/components/ui/toast/index.tsx +3 -2
- package/payload/src/lib/color.ts +22 -0
- package/payload/src/lib/format-date.ts +8 -0
- package/payload/src/lib/format.ts +19 -2
- package/payload/src/styles/tokens.css +85 -6
- package/payload/src/system/card-stage.tsx +37 -5
- package/payload/src/system/hero.tsx +1 -1
- package/payload/src/system/page-stage.tsx +28 -11
- package/payload/src/system/token-view.tsx +37 -21
- package/payload/src/system/tokens-data.ts +1 -1
- package/payload/src/views/members.tsx +4 -4
- package/payload/src/views/overview.tsx +2 -2
- package/payload/src/views/request.tsx +1 -1
- package/payload/tests/assistant-panel.test.tsx +3 -2
- package/payload/tests/assistant-thread.test.tsx +8 -0
- package/payload/tests/color.test.ts +8 -0
- 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.
|
|
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",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
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
|
+
|
|
5
31
|
## [0.12.2] · 2026-10-09
|
|
6
32
|
|
|
7
33
|
### Fixed
|
package/payload/CONTRIBUTING.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
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:
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.12.
|
|
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
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
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(
|
|
67
|
+
await page.eval(`${MESSAGE}.focus()`);
|
|
58
68
|
await page.send('Input.insertText', { text });
|
|
59
|
-
await until('Send to enable', () => page.eval<boolean>(
|
|
60
|
-
await page.eval(
|
|
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>(`
|
|
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[]>(
|
|
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>(`
|
|
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(
|
|
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>(`
|
|
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>(`
|
|
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 =
|
|
110
|
-
const panelOpen = () => page.eval<boolean>(
|
|
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[] }[]>(
|
|
115
|
-
|
|
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>(
|
|
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
|
|
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(
|
|
163
|
-
await until('the asked question in the thread', () => page.eval<string[]>(`
|
|
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 =
|
|
181
|
-
return b ? { list:
|
|
182
|
-
table:
|
|
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(`
|
|
196
|
-
await press(`
|
|
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
|
|
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>(`
|
|
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(`
|
|
233
|
-
await until('the row menu', () => page.eval<boolean>(`!!
|
|
234
|
-
await press(`
|
|
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[]>(`
|
|
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>(
|
|
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(`
|
|
290
|
-
await until('the panel to empty', () => page.eval<number>(`
|
|
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 = `(
|
|
301
|
-
const absent = () => page.eval<boolean>(
|
|
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>(`
|
|
312
|
-
if (await page.eval<boolean>(`!!
|
|
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>(
|
|
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>(
|
|
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 =
|
|
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(
|
|
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>(
|
|
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>(`
|
|
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
|
+
];
|