zz-meridian 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/CHANGELOG.md +16 -0
- package/payload/CONTRIBUTING.md +4 -3
- package/payload/README.md +1 -0
- package/payload/docs/benchmark.md +2 -0
- package/payload/docs/distribution.md +3 -2
- package/payload/docs/skill-evals.md +1 -0
- package/payload/docs/start-a-dashboard.md +1 -1
- package/payload/package.json +1 -1
- package/payload/scripts/audit.ts +56 -16
- package/payload/scripts/brand.ts +2 -1
- package/payload/scripts/gate.ts +6 -5
- package/payload/scripts/interactions.ts +37 -12
- package/payload/scripts/keyboard.ts +105 -24
- package/payload/scripts/lib/deep.ts +74 -0
- package/payload/scripts/navigate.ts +32 -8
- package/payload/scripts/verify.config.ts +4 -0
- package/payload/scripts/verify.ts +1 -1
- package/payload/skills/zz-meridian/SKILL.md +8 -5
- package/payload/skills/zz-meridian/references/customize.md +2 -1
- package/payload/skills/zz-meridian/references/existing-project.md +2 -0
- package/payload/skills/zz-meridian/references/register.md +32 -0
- package/payload/skills/zz-meridian/references/standard.md +30 -19
- package/payload/skills/zz-meridian/references/update.md +3 -1
- package/payload/skills/zz-meridian/references/validation.md +38 -5
- package/payload/src/components/patterns/ask-about/preview.tsx +1 -1
- package/payload/src/components/ui/button/preview.tsx +1 -1
- package/payload/src/components/ui/checkbox/preview.tsx +1 -1
- package/payload/src/components/ui/icon-button/preview.tsx +1 -1
- package/payload/src/components/ui/input/preview.tsx +1 -1
- package/payload/src/components/ui/switch/preview.tsx +1 -1
- package/payload/src/system/specimen.tsx +7 -3
- package/payload/tests/specimen-still.test.tsx +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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,22 @@
|
|
|
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
|
+
## [0.12.0] · 2026-10-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **A project can bring its own look.** Meridian's register (dark first, the night-sky neutral, one accent with a job, the defaults that read as generated, and the look-dependent parts of Colour, Typography, Hierarchy and Originality) now lives in its own file, the skill's `references/register.md`. A project with its own design language writes `docs/register.md`, and the skill scores looks and takes brand guidance from it instead. A project register never changes a floor, a required state, accessibility, the loop or the budget policy. `docs/skill-evals.md` gains the scenario that probes it. How it is applied is stated once, in `standard.md`: a line that would lower a floor is not followed but recorded in `out/standard/questions.md`; Meridian's register fills any value the project's leaves unset; the project's anti-defaults and its own brand route replace Meridian's; and its face, radii and colours go through the token files, with any managed file changed for them listed in the keep register.
|
|
10
|
+
- **One way into open shadow roots for the browser checks.** `scripts/lib/deep.ts` walks the document and every open shadow root, finds the focused element through them, matches selectors inside them and steps from a slotted element or a root back to its host. Its fixtures live in `scripts/fixtures/deep/` and, with their tests, are not shipped.
|
|
11
|
+
- **The audit sees inside open shadow roots.** A product that uses web components had their text, names, targets, contrast and focus rings skipped without a word; the audit now measures them, composites a background through the host, and counts a focus ring only where focus changed the style, so an always-present border is no longer taken for one. A custom element it cannot measure (one with no open root, or one never defined) is reported as `unmeasured:` instead of passing.
|
|
12
|
+
- **The presses and the keyboard walk reach controls inside open shadow roots** and name them with their host (`Save (in x-card)`). The walk also presses Shift+Tab back through the stops and reports a reverse order that differs, or a control only Shift+Tab reaches.
|
|
13
|
+
- **A project's navigation selectors resolve inside open shadow roots.** `navigationChecks` ready, control and result selectors match elements inside open roots (a descendant combinator still does not cross a boundary), and a control selector that matches several elements prints a one-time `note:` naming the count; the first visible match is pressed, as before.
|
|
14
|
+
- **`State` in the Atlas takes `still`.** A specimen that depicts a state (the Focus specimens of Button, Checkbox, IconButton, Input, Switch and AskAbout) is drawn inert, so it is no longer an extra tab stop that never shows focus.
|
|
15
|
+
- **The browser checks are documented for web components.** `validation.md` explains `unmeasured:` (a custom element the audit cannot look inside: give it an open shadow root or light-DOM content, or define it) and `note:` (a `navigationChecks` selector that matched several elements: information, never a failure), and lists the keyboard walk's failures, including `reverse order differs` and `never reached … (Tab skips it; only Shift+Tab reaches it)`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **A focus ring is a change on focus.** The audit and the keyboard walk count a ring only when focusing the control changes its outline, shadow or border (on the control or the frame around it, or anywhere up to its host inside a shadow root). A control whose outline or shadow is always there no longer passes as ringed: give it a ring that appears on focus.
|
|
20
|
+
|
|
5
21
|
## [0.11.0] · 2026-10-08
|
|
6
22
|
|
|
7
23
|
### Added
|
package/payload/CONTRIBUTING.md
CHANGED
|
@@ -87,9 +87,10 @@ Write the status on the line under the title: `Status: beta`.
|
|
|
87
87
|
| `node scripts/registry.ts` | Regenerates the Atlas registry from the card folders (`--check` only compares) | After adding or renaming a card |
|
|
88
88
|
| `node scripts/check.ts` | Every card has its README and preview, every README follows the anatomy, no literal colour in components, every token a spec names exists and is written with its own value, every embed view a document offers is a route, and an element that claims a control's role has a key handler | Gate |
|
|
89
89
|
| `pnpm typecheck`, `pnpm test` | TypeScript and the behaviour tests | Gate |
|
|
90
|
-
| `node scripts/audit.ts` | With the app running: every page and embed view at 2560, 1440, 1024, 768 and 390px in both themes (embeds at 720 and 420 on a simulated host ground). Fails on sideways scroll, text clipped without an ellipsis, a control with no accessible name, more than one page scroller, rendered text under its WCAG minimum, a Tab stop with no
|
|
90
|
+
| `node scripts/audit.ts` | With the app running: every page and embed view at 2560, 1440, 1024, 768 and 390px in both themes (embeds at 720 and 420 on a simulated host ground). It measures inside open shadow roots too. Fails on sideways scroll, text clipped without an ellipsis, a control with no accessible name, more than one page scroller, rendered text under its WCAG minimum, a Tab stop with no focus ring (a change of outline, shadow or border when focus arrives), a custom element it cannot look inside (`unmeasured:`), and any uncaught exception or console error. Prints the design metrics: type sizes, weights, radii and the hierarchy ratio | Before release |
|
|
91
91
|
| `node scripts/audit.ts --atlas` | With the app running: the same audit over the Design Atlas, every card's page and its bare preview, at 1440 and 390px in both themes, about ten minutes. Not part of `pnpm verify`, which stays fast: a card's preview is where a defect in its own box shows, so run it after changing a component | After changing a component |
|
|
92
|
-
| `node scripts/interactions.ts` | With the app running: presses every control on every page (mouse at 1440px, taps at 390px) and follows every link. Fails on a control that changes nothing, one something covers, and a link that answers 4xx | Before release |
|
|
92
|
+
| `node scripts/interactions.ts` | With the app running: presses every control on every page (mouse at 1440px, taps at 390px) and follows every link. Reaches controls inside open shadow roots. Fails on a control that changes nothing, one something covers, and a link that answers 4xx | Before release |
|
|
93
|
+
| `node scripts/keyboard.ts` | With the app running: Tab through each page until focus comes back round, then Shift+Tab back. Fails on a stop with no focus ring, a control hidden under the sticky bar, a visible control never reached, and a reverse order that differs | Before release |
|
|
93
94
|
| `pnpm verify` | The gate once, one production build, the route policy, the size checks and a smoke of up to three routes on desktop and the phone drawer; ends with a coverage line, and the report is in `out/verify.txt` | Before finishing any change |
|
|
94
95
|
| `pnpm verify --full` | The navigation of every rail route, then every exhaustive suite: the audit, every control pressed and link followed, the keyboard walk, the assistant, the live data and Web Vitals | After a change to shared components, the shell or data, and before release |
|
|
95
96
|
| `pnpm verify --perf` | The 20-sample navigation protocol; combine with `--full` to run both without repeating the gate or the build | When timing is what changed |
|
|
@@ -100,7 +101,7 @@ Write the status on the line under the title: `Status: beta`.
|
|
|
100
101
|
|
|
101
102
|
1. Add an entry to `CHANGELOG.md` under a new version. 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. Say in one line what breaks and what to do instead.
|
|
102
103
|
2. `node cli/scripts/set-version.ts <v>`, then `pnpm gate` until it passes. Nothing else runs locally: the release workflow's gates are the proof.
|
|
103
|
-
3. Commit and push to `master`, then dispatch once: `gh workflow run release.yml -f version=<v>`. The workflow gates on the package's own tests (`tests/cli-*`), one timed default `verify` (whose gate leaves
|
|
104
|
+
3. Commit and push to `master`, then dispatch once: `gh workflow run release.yml -f version=<v>`. The workflow gates on the package's own tests (`tests/cli-*`) and the browser-fixture tests of the checks (`tests/deep-*`), one timed default `verify` (whose gate leaves both groups out) and the consumer smoke from the tarball, publishes with provenance, checks the registry's tarball is the tested one, and tags last. `verify --full --perf` runs weekly (`.github/workflows/weekly.yml`), not here.
|
|
104
105
|
|
|
105
106
|
The `/release` command (`.claude/commands/release.md`) is this procedure as a runbook.
|
|
106
107
|
|
package/payload/README.md
CHANGED
|
@@ -135,6 +135,7 @@ pnpm dev # the template at /, the Design Atlas at /system
|
|
|
135
135
|
pnpm gate # tokens fresh, registry fresh, specs consistent, contrast in every theme and accent, types, tests
|
|
136
136
|
pnpm verify # the gate once, one production build, the size checks and a smoke of up to three routes
|
|
137
137
|
pnpm verify --full # every route, every control and link, the keyboard walk, the assistant and the live data
|
|
138
|
+
# (the browser suites look inside open shadow roots; validation.md says what each line means)
|
|
138
139
|
pnpm brand --name "Acme" --hue 25 --chroma 0.16 # rename and rebrand in place
|
|
139
140
|
```
|
|
140
141
|
|
|
@@ -33,6 +33,8 @@ The same bar, held for the second operator. An agent judges a view by what it is
|
|
|
33
33
|
|
|
34
34
|
With no assistant configured, or switched off, and outside any host, no agent control appears: on a build with no `ASSISTANT_*` variables the audit found 0 issues on 22 routes, 327 controls and 56 links pressed with 0 issues, the keyboard walk 0, and none of 8 pages and views showed a launcher, a panel or an Ask.
|
|
35
35
|
|
|
36
|
+
Those figures were measured before the suites looked inside open shadow roots. A product that uses web components is now measured there too, and a custom element the audit cannot look inside is reported as `unmeasured:` rather than passed (`skills/zz-meridian/references/validation.md`).
|
|
37
|
+
|
|
36
38
|
## What changed because of the benchmark
|
|
37
39
|
|
|
38
40
|
- **The register** (decision 0002). The first render, a neutral light canvas with 28px titles and four equal tiles, was rejected as dated. Meridian adopted that register: dark first, a lit ground, a dramatic type scale, one featured card.
|
|
@@ -213,8 +213,9 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
|
|
|
213
213
|
|
|
214
214
|
1. **Dispatch**: `gh workflow run release.yml -f version=<v> [-f dry_run=true]`, from `master`. The version must equal
|
|
215
215
|
`cli/package.json`'s and the tag must be unused.
|
|
216
|
-
2. **Gates** (ubuntu-24.04, 4 CPUs, the reference profile), each run once: the package's own tests (`tests/cli
|
|
217
|
-
no adopted or created project has
|
|
216
|
+
2. **Gates** (ubuntu-24.04, 4 CPUs, the reference profile), each run once: the package's own tests (`tests/cli-*`) and the
|
|
217
|
+
browser-fixture tests of the checks (`tests/deep-*`), which no adopted or created project has and the timed default
|
|
218
|
+
`verify` leaves out; the template's default `verify` from a clean `.next` through
|
|
218
219
|
`cli/scripts/release-check.ts`, within 180 s on the shared runner (whose CPU varies: one commit measured 111 s on one
|
|
219
220
|
and 152 s on another) and with its browser smoke (the gate and the template's unit tests run
|
|
220
221
|
inside it); and the consumer smoke from the built tarball (step 4). The full, perf and recovery suites are never
|
|
@@ -31,6 +31,7 @@ the clean repository a team would have.
|
|
|
31
31
|
| Rebrand | the same | change our brand colour to #D97706 | `brand` with the manifest's version; sees the amber is 10° from the warning hue; nearest safe hue as the accent, the exact colour in the logo, recorded, no question |
|
|
32
32
|
| Ambiguous | `cli/test/fixture-crumb-ops` | build me a dashboard for this | one question at most, the route, with the recommendation first; without an answer it takes the recommendation and says so |
|
|
33
33
|
| Based on | `cli/test/fixture-crumb-ops` | based on this folder, make a new dashboard in ../crumb-v2 | `create ../crumb-v2`; reads the source and never writes it; ports its data layer and its contract |
|
|
34
|
+
| A project with its own register | a Meridian project with a `docs/register.md` for a fictional company with a light-first design language of its own (its theme default, signature elements, anti-defaults and a brand route), committed | restyle the settings page and add a billing page | reads `docs/register.md` before building; scores Colour, Typography, Hierarchy and Originality against it, and names it on the scoreboard's "Register:" line; keeps every floor, required state and accessibility check unchanged; follows its brand route instead of `brand`'s accent rules; refuses a register line that lowers a floor (a looser contrast, a skipped state) and records the refusal as a decision |
|
|
34
35
|
|
|
35
36
|
Every scenario is also held to what is true of all of them: the agent reads `standard.md` before building, asks nothing
|
|
36
37
|
but the route, says the route before the first command, records decisions with their reasons, lists every view it will
|
|
@@ -73,4 +73,4 @@ pnpm verify # the gate once, one production build, the size checks and
|
|
|
73
73
|
pnpm verify --full # every route, every control pressed and link followed, the keyboard walk, the assistant and the live data
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
`pnpm verify` is the check to run before you finish. Run `--full` after a change to shared components, the shell or data. Every run ends with a coverage line, such as `coverage: default; browser ran; 3 routes; data configured 3/3; interaction configured 3/3; not run: audit, presses, keyboard, assistant, live, vitals`, which says what ran and what did not. Nothing that did not run is reported as passed. `pnpm verify --perf` adds the 20-sample navigation protocol and reports it; a p95 over its budget is a `warn`, never a failure. The default is built to finish within two minutes; `--full` and `--perf` are what a team schedules, weekly for instance.
|
|
76
|
+
`pnpm verify` is the check to run before you finish. Run `--full` after a change to shared components, the shell or data. Every run ends with a coverage line, such as `coverage: default; browser ran; 3 routes; data configured 3/3; interaction configured 3/3; not run: audit, presses, keyboard, assistant, live, vitals`, which says what ran and what did not. Nothing that did not run is reported as passed. `pnpm verify --perf` adds the 20-sample navigation protocol and reports it; a p95 over its budget is a `warn`, never a failure. The browser suites look inside open shadow roots, so a web component's content is measured, pressed and walked like any other; a custom element they cannot look inside is reported as `unmeasured:`, and a selector in `navigationChecks` that matches several elements prints a `note:`. `skills/zz-meridian/references/validation.md` says what each line means and what to do about it. The default is built to finish within two minutes; `--full` and `--perf` are what a team schedules, weekly for instance.
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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.",
|
package/payload/scripts/audit.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* not part of `pnpm verify` (the default stays fast); run it when the question is the component surface. It takes
|
|
12
12
|
* about ten minutes, and a project without the Atlas has nothing for it to read.
|
|
13
13
|
*
|
|
14
|
+
* The walk enters open shadow roots and follows slots (scripts/lib/deep.ts, injected into every page), so a custom element's
|
|
15
|
+
* own content is measured like any other. A defined custom element that shows a box but exposes no open root and no
|
|
16
|
+
* light-DOM content, and one that is still undefined after the settle wait, are reported as `unmeasured:` instead of passed.
|
|
17
|
+
*
|
|
14
18
|
* Fails (exit 1) on: sideways scroll, text clipped without an ellipsis, a control with no accessible name, more than
|
|
15
19
|
* one page scroller, and rendered text under its WCAG minimum. Prints the design metrics per page: distinct type
|
|
16
20
|
* sizes, weights and radii, and the hierarchy ratio (largest text over the median), which should be 3 or more on
|
|
@@ -18,6 +22,7 @@
|
|
|
18
22
|
*/
|
|
19
23
|
import fs from 'node:fs';
|
|
20
24
|
import { launch } from './lib/chrome.ts';
|
|
25
|
+
import { DEEP_SOURCE } from './lib/deep.ts';
|
|
21
26
|
import { discover } from './lib/routes.ts';
|
|
22
27
|
import config from './verify.config.ts';
|
|
23
28
|
|
|
@@ -44,24 +49,26 @@ const WIDTHS = ATLAS ? [1440, 390] : [2560, 1440, 1024, 768, 390];
|
|
|
44
49
|
const THEMES = ['dark', 'light'];
|
|
45
50
|
|
|
46
51
|
type Report = {
|
|
47
|
-
sideways: string[]; clipped: string[]; focusless: string[]; unnamed: string[]; scrollers: string[]; contrast: string[]; targets: string[]; headings: string[];
|
|
52
|
+
sideways: string[]; clipped: string[]; focusless: string[]; unnamed: string[]; unmeasured: string[]; scrollers: string[]; contrast: string[]; targets: string[]; headings: string[];
|
|
48
53
|
sizes: number[]; weights: number[]; radii: number[]; ratio: number;
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
/** Runs inside the page. Plain JavaScript: it is serialised into the browser. */
|
|
52
57
|
const MEASURE = `(() => {
|
|
53
|
-
const W = innerWidth, out = { sideways: [], clipped: [], unnamed: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
|
|
58
|
+
const W = innerWidth, out = { sideways: [], clipped: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
|
|
54
59
|
const label = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).slice(0, 3).join('.') : '')).slice(0, 90);
|
|
55
|
-
|
|
60
|
+
/* closest() across open roots and slots: deepParent (injected from scripts/lib/deep.ts) steps through both */
|
|
61
|
+
const deepClosest = (el, sel) => { for (let n = el; n; n = deepParent(n)) if (n.matches(sel)) return n; return null; };
|
|
62
|
+
const visible = (el) => { const r = el.getBoundingClientRect(); const c = getComputedStyle(el); /* visually hidden (sr-only, also under a variant) is for screen readers, not the eye */ return r.width > 0 && r.height > 0 && c.visibility !== 'hidden' && c.display !== 'none' && parseFloat(c.opacity) > 0.05 && !deepClosest(el, '.sr-only,[aria-hidden="true"]') && !(c.position === 'absolute' && r.width <= 1 && r.height <= 1); };
|
|
56
63
|
const sr = document.scrollingElement;
|
|
57
64
|
if (sr.scrollWidth > W + 1) out.sideways.push('document ' + sr.scrollWidth + 'px wide at ' + W);
|
|
58
|
-
document.
|
|
65
|
+
const all = deepAll(document.body).filter((n) => n !== document.body);
|
|
66
|
+
all.filter((n) => n.matches('[data-scroll-region]')).forEach((s) => { if (s.scrollWidth > s.clientWidth + 1) out.sideways.push('scroll region ' + s.scrollWidth + ' > ' + s.clientWidth); });
|
|
59
67
|
// A dashboard fills its canvas at every size: a data page narrower than the scroll region (less the gutters) is a
|
|
60
68
|
// centred strip on a wide screen.
|
|
61
|
-
|
|
69
|
+
all.filter((n) => n.matches('[data-page-width="data"]')).forEach((p) => { const region = deepClosest(p, '[data-scroll-region]'); if (region && p.getBoundingClientRect().width < region.clientWidth - 2) out.sideways.push('data page ' + Math.round(p.getBoundingClientRect().width) + 'px wide in a ' + region.clientWidth + 'px canvas (it should fill it)'); });
|
|
62
70
|
// A table wider than its frame is clipped, not scrolled: its last columns (often the actions) are out of reach.
|
|
63
|
-
|
|
64
|
-
const all = [...document.body.querySelectorAll('*')];
|
|
71
|
+
all.filter((n) => n.localName === 'table').forEach((t) => { const f = deepParent(t); if (!deepClosest(t, '.sr-only') && f && t.offsetWidth > 0 && !/auto|scroll/.test(getComputedStyle(f).overflowX) && t.scrollWidth > f.clientWidth + 1) out.sideways.push('table ' + (t.querySelector('caption')?.textContent || label(t)) + ' ' + t.scrollWidth + ' > its frame ' + f.clientWidth + ' (hideBelow a column)'); });
|
|
65
72
|
// Pixel colours, whatever the colour space.
|
|
66
73
|
const cv = document.createElement('canvas'); cv.width = cv.height = 1; const cx = cv.getContext('2d', { willReadFrequently: true });
|
|
67
74
|
const rgba = (c) => { cx.clearRect(0, 0, 1, 1); cx.fillStyle = '#000'; cx.fillStyle = c; cx.fillRect(0, 0, 1, 1); const d = cx.getImageData(0, 0, 1, 1).data; return [d[0] / 255, d[1] / 255, d[2] / 255, d[3] / 255]; };
|
|
@@ -71,7 +78,7 @@ const MEASURE = `(() => {
|
|
|
71
78
|
const groundColour = rgba(window.__hostGround || getComputedStyle(document.documentElement).backgroundColor);
|
|
72
79
|
const backOf = (el) => {
|
|
73
80
|
const stack = [];
|
|
74
|
-
for (let n = el; n && n !== document.documentElement; n = n
|
|
81
|
+
for (let n = el; n && n !== document.documentElement; n = deepParent(n)) {
|
|
75
82
|
const c = getComputedStyle(n);
|
|
76
83
|
if (c.backgroundImage !== 'none' && !/url\\(/.test(c.backgroundImage) && n !== el) return null;
|
|
77
84
|
const bg = rgba(c.backgroundColor);
|
|
@@ -81,9 +88,17 @@ const MEASURE = `(() => {
|
|
|
81
88
|
for (let i = stack.length - 1; i >= 0; i--) b = over(stack[i], b);
|
|
82
89
|
return b;
|
|
83
90
|
};
|
|
91
|
+
const ringSig = (c) => [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow, c.borderTopStyle, c.borderTopWidth, c.borderTopColor, c.borderBottomColor, c.borderLeftColor, c.borderRightColor].join('|');
|
|
84
92
|
const texts = [];
|
|
85
93
|
for (const el of all) {
|
|
86
94
|
const c = getComputedStyle(el);
|
|
95
|
+
// A custom element the walk cannot see into is reported, not passed over: an undefined one with no open root has no content yet
|
|
96
|
+
// (one with an open root, as Next's route announcer has, is walked like any other), and a
|
|
97
|
+
// defined one with a closed root (or none) and nothing in the light DOM draws a box the audit cannot measure.
|
|
98
|
+
if (el.localName.includes('-')) {
|
|
99
|
+
if (!customElements.get(el.localName)) { if (!el.shadowRoot) out.unmeasured.push(el.localName + ' (not defined)'); }
|
|
100
|
+
else if (!el.shadowRoot && el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0 && ![...el.childNodes].some((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()))) out.unmeasured.push(el.localName + ' (no open shadow root)');
|
|
101
|
+
}
|
|
87
102
|
if (!visible(el)) continue;
|
|
88
103
|
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
|
89
104
|
if (own) {
|
|
@@ -92,7 +107,7 @@ const MEASURE = `(() => {
|
|
|
92
107
|
if (el.scrollWidth > el.clientWidth + 1 && c.textOverflow !== 'ellipsis' && /hidden|clip/.test(c.overflow) && el.clientWidth > 0) out.clipped.push(label(el) + ' "' + el.textContent.trim().slice(0, 30) + '"');
|
|
93
108
|
// Gradient text paints its glyphs with a clipped background (color is transparent); both ends sit at accent-ink's
|
|
94
109
|
// lightness, which the contrast gate already holds, so it is not measured here.
|
|
95
|
-
if (!el
|
|
110
|
+
if (!deepClosest(el, 'svg') && !/text/.test(c.backgroundClip + ' ' + c.webkitBackgroundClip) && !deepClosest(el, '[data-contrast-exempt]') && !deepClosest(el, ':disabled,[aria-disabled="true"],[data-disabled]')) {
|
|
96
111
|
const back = backOf(el);
|
|
97
112
|
if (back) {
|
|
98
113
|
const fg = over(rgba(c.color), back);
|
|
@@ -105,15 +120,15 @@ const MEASURE = `(() => {
|
|
|
105
120
|
const rad = parseFloat(c.borderTopLeftRadius);
|
|
106
121
|
if (rad > 0 && rad < 999) out.radii.push(Math.round(rad));
|
|
107
122
|
// A card drawn as a specimen in the Atlas keeps its own scroll regions; only the page's count against the one scroller.
|
|
108
|
-
if (/(auto|scroll)/.test(c.overflowY) && el.scrollHeight > el.clientHeight + 1 && el.clientHeight > 120 && !el
|
|
123
|
+
if (/(auto|scroll)/.test(c.overflowY) && el.scrollHeight > el.clientHeight + 1 && el.clientHeight > 120 && !deepClosest(el, '[data-specimen]')) out.scrollers.push(label(el));
|
|
109
124
|
if (el.matches('button,a[href],[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],input:not([type=hidden]),select,textarea')) {
|
|
110
|
-
const name = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title') || el.textContent.trim() || el.querySelector('img[alt]')?.getAttribute('alt') || (el.id &&
|
|
125
|
+
const name = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title') || el.textContent.trim() || el.querySelector('img[alt]')?.getAttribute('alt') || (el.id && el.getRootNode().querySelector('label[for="' + el.id + '"]')?.textContent.trim()) || el.closest('label')?.textContent.trim() || el.getAttribute('placeholder') || '';
|
|
111
126
|
if (!name) out.unnamed.push(label(el));
|
|
112
127
|
// On touch (a phone is emulated as one) every control answers at least 44px: its own box, its field's frame, or
|
|
113
128
|
// the .hit square around it. A link inside running text is exempt, as WCAG exempts it.
|
|
114
129
|
if (matchMedia('(pointer: coarse)').matches && !el.disabled && !(el.matches('a') && getComputedStyle(el).display === 'inline')) {
|
|
115
130
|
// A labelled control answers its label too (a press on the label toggles it), so the target is both together.
|
|
116
|
-
const lab = (el.id &&
|
|
131
|
+
const lab = (el.id && el.getRootNode().querySelector('label[for="' + el.id + '"]')) || el.closest('label');
|
|
117
132
|
const own = (el.closest('.control-frame') || el).getBoundingClientRect();
|
|
118
133
|
const lr = lab && !lab.contains(el.closest('.control-frame') || el) ? lab.getBoundingClientRect() : null;
|
|
119
134
|
const box = lr ? { width: Math.max(own.right, lr.right) - Math.min(own.left, lr.left), height: Math.max(own.bottom, lr.bottom) - Math.min(own.top, lr.top) } : own;
|
|
@@ -129,10 +144,12 @@ const MEASURE = `(() => {
|
|
|
129
144
|
const levels = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter((h) => !h.closest('[aria-hidden="true"]') && !h.closest('[data-specimen]')).map((h) => +h.tagName[1]);
|
|
130
145
|
levels.forEach((l, i) => { if (i && l > levels[i - 1] + 1) out.headings.push('h' + levels[i - 1] + ' then h' + l); });
|
|
131
146
|
out.focusless = [];
|
|
147
|
+
// What each element looks like unfocused, so the focus check can tell a ring from a decoration that is always there.
|
|
148
|
+
window.__ringBefore = new WeakMap(); for (const n of all) window.__ringBefore.set(n, ringSig(getComputedStyle(n)));
|
|
132
149
|
const sorted = texts.sort((a, b) => a - b);
|
|
133
150
|
out.ratio = sorted.length ? +(sorted[sorted.length - 1] / sorted[Math.floor(sorted.length / 2)]).toFixed(1) : 0;
|
|
134
151
|
out.sizes = [...new Set(out.sizes)].sort((a, b) => a - b); out.weights = [...new Set(out.weights)].sort(); out.radii = [...new Set(out.radii)].sort((a, b) => a - b);
|
|
135
|
-
out.contrast = [...new Set(out.contrast)].slice(0, 8); out.clipped = [...new Set(out.clipped)].slice(0, 8); out.unnamed = [...new Set(out.unnamed)].slice(0, 8);
|
|
152
|
+
out.contrast = [...new Set(out.contrast)].slice(0, 8); out.clipped = [...new Set(out.clipped)].slice(0, 8); out.unnamed = [...new Set(out.unnamed)].slice(0, 8); out.unmeasured = [...new Set(out.unmeasured)].slice(0, 8);
|
|
136
153
|
return out;
|
|
137
154
|
})()`;
|
|
138
155
|
|
|
@@ -146,6 +163,14 @@ const run = async (route: string, width: number, theme: string, embed: boolean)
|
|
|
146
163
|
await page.open(base + route, { width, height: width < 600 ? 844 : 900, theme: theme as 'dark' | 'light', wait: 1500 });
|
|
147
164
|
// An embed is a guest with a transparent ground: paint the host's ground behind it, as a host does.
|
|
148
165
|
if (embed) await page.eval(`(() => { const d = document.documentElement; d.setAttribute('data-theme','${theme}'); window.__hostGround = '${theme}' === 'dark' ? '#1C1C20' : '#FFFFFF'; return true; })()`);
|
|
166
|
+
// One implementation of the walk: the helper is injected, never copied. A page it cannot be injected into fails the audit.
|
|
167
|
+
try {
|
|
168
|
+
if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepActive === 'function' && typeof deepParent === 'function'`))) throw new Error('the helper is not defined after injection');
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.error(`audit: cannot inject the deep-DOM helper into ${route}: ${(e as Error).message}`);
|
|
171
|
+
page.close();
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
149
174
|
const r = await page.eval<Report>(MEASURE);
|
|
150
175
|
// Focus: press Tab through the page as a person would; every stop must show a ring (outline or box-shadow ring) on the
|
|
151
176
|
// focused element or the frame around it.
|
|
@@ -153,9 +178,23 @@ const run = async (route: string, width: number, theme: string, embed: boolean)
|
|
|
153
178
|
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
|
154
179
|
await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
|
155
180
|
const miss = await page.eval<string | null>(`(() => {
|
|
156
|
-
const el =
|
|
157
|
-
|
|
158
|
-
|
|
181
|
+
const el = deepActive(); if (!el || el === document.body) return null;
|
|
182
|
+
// A ring is a change on focus: an outline, a coloured shadow or a border that was not there unfocused. The element
|
|
183
|
+
// itself, its frame and the frame's frame count; inside a shadow root every composed ancestor up to the outermost
|
|
184
|
+
// host does, since the host is where :host(:focus-within) draws.
|
|
185
|
+
const sig = (c) => [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow, c.borderTopStyle, c.borderTopWidth, c.borderTopColor, c.borderBottomColor, c.borderLeftColor, c.borderRightColor].join('|');
|
|
186
|
+
const ringed = (n) => {
|
|
187
|
+
const c = getComputedStyle(n), before = window.__ringBefore && window.__ringBefore.get(n);
|
|
188
|
+
const outline = c.outlineStyle !== 'none' && parseFloat(c.outlineWidth) > 0, shadow = /rgb|oklch|oklab|color/.test(c.boxShadow) && c.boxShadow !== 'none';
|
|
189
|
+
if (!before) return outline || shadow;
|
|
190
|
+
const b = before.split('|'), now = sig(c).split('|');
|
|
191
|
+
const outlineMoved = now.slice(0, 3).join('|') !== b.slice(0, 3).join('|'), shadowMoved = now[3] !== b[3], borderMoved = now.slice(4).join('|') !== b.slice(4).join('|') && c.borderTopStyle !== 'none';
|
|
192
|
+
return (outline && outlineMoved) || (shadow && shadowMoved) || borderMoved;
|
|
193
|
+
};
|
|
194
|
+
const chain = [el];
|
|
195
|
+
if (el.getRootNode() instanceof ShadowRoot) { for (let n = deepParent(el); n; n = deepParent(n)) { chain.push(n); if (!(n.getRootNode() instanceof ShadowRoot)) break; } }
|
|
196
|
+
else { const p = deepParent(el); if (p) chain.push(p); const g = p && deepParent(p); if (g) chain.push(g); }
|
|
197
|
+
if (chain.some(ringed)) return null;
|
|
159
198
|
return el.tagName.toLowerCase() + '.' + String(el.className).split(' ').slice(0, 3).join('.');
|
|
160
199
|
})()`);
|
|
161
200
|
if (miss) r.focusless.push(miss);
|
|
@@ -166,6 +205,7 @@ const run = async (route: string, width: number, theme: string, embed: boolean)
|
|
|
166
205
|
...r.sideways.map((x) => 'sideways: ' + x),
|
|
167
206
|
...r.clipped.map((x) => 'clipped: ' + x),
|
|
168
207
|
...r.unnamed.map((x) => 'unnamed: ' + x),
|
|
208
|
+
...r.unmeasured.map((x) => 'unmeasured: ' + x),
|
|
169
209
|
...[...new Set(r.headings)].map((x) => 'heading skips a level: ' + x),
|
|
170
210
|
...[...new Set(r.targets)].slice(0, 8).map((x) => 'touch target under 44px: ' + x),
|
|
171
211
|
...(scrollers.length > 1 ? ['scrollers: ' + scrollers.join(', ')] : []),
|
package/payload/scripts/brand.ts
CHANGED
|
@@ -206,7 +206,8 @@ if (has('--product')) {
|
|
|
206
206
|
const walkDirs = (dir: string): string[] => fs.existsSync(file(dir)) ? fs.readdirSync(file(dir), { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walkDirs(path.join(dir, e.name)) : [path.join(dir, e.name)]) : [];
|
|
207
207
|
for (const f of walkDirs('src/components')) if (/\/(README\.md|preview\.tsx)$/.test(f)) fs.rmSync(file(f));
|
|
208
208
|
for (const f of walkDirs('app')) if (/\/README\.md$/.test(f)) fs.rmSync(file(f));
|
|
209
|
-
|
|
209
|
+
// A test of a module removed here goes with it (tests/cli-product-prune.test.ts holds the two lists together).
|
|
210
|
+
for (const f of ['src/system/specimen.tsx', 'tests/specimen-still.test.tsx', 'src/system/registry.ts', 'CONTRIBUTING.md', 'CHANGELOG.md']) fs.rmSync(file(f), { force: true });
|
|
210
211
|
for (const d of ['docs', 'decisions', 'skills']) fs.rmSync(file(d), { recursive: true, force: true });
|
|
211
212
|
const p = json('package.json');
|
|
212
213
|
delete p.scripts?.registry;
|
package/payload/scripts/gate.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Every gate the system must pass before a commit, in order; stops at the first failure.
|
|
3
3
|
*
|
|
4
4
|
* pnpm gate (or npm run gate: the tools run from node_modules/.bin, under any package manager)
|
|
5
|
-
* node scripts/gate.ts --without-cli-tests leaves out tests
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
5
|
+
* node scripts/gate.ts --without-cli-tests leaves out the tests only this repository has, neither shipped:
|
|
6
|
+
* tests/cli-* (the package's own) and tests/deep-* (the browser-fixture
|
|
7
|
+
* tests of the checks). The default verify runs it this way, and the
|
|
8
|
+
* release runs those tests in a step of their own
|
|
8
9
|
*/
|
|
9
10
|
import { spawnSync } from 'node:child_process';
|
|
10
11
|
import fs from 'node:fs';
|
|
@@ -27,7 +28,7 @@ const STEPS: [string, string[]][] = [
|
|
|
27
28
|
// eslint-config-next is what every Next project runs; a template that fails it hands its users errors on day one.
|
|
28
29
|
// The staged copies of an update session are for reading, never linted as source.
|
|
29
30
|
['lint passes', [bin('eslint'), '.', '--ignore-pattern', '.meridian/']],
|
|
30
|
-
['tests pass', [bin('vitest'), 'run', ...(withoutCliTests ? ['--exclude', 'tests/cli-*', '--exclude', '**/node_modules/**'] : [])]],
|
|
31
|
+
['tests pass', [bin('vitest'), 'run', ...(withoutCliTests ? ['--exclude', 'tests/cli-*', '--exclude', 'tests/deep-*', '--exclude', '**/node_modules/**'] : [])]],
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
for (const [name, [cmd, ...args]] of STEPS) {
|
|
@@ -41,4 +42,4 @@ for (const [name, [cmd, ...args]] of STEPS) {
|
|
|
41
42
|
process.exit(1);
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
|
-
console.log(`gate: every check passed${withoutCliTests ? " (the
|
|
45
|
+
console.log(`gate: every check passed${withoutCliTests ? " (the tests only this repository has left out: tests/cli-*, tests/deep-*)" : ''}`);
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* file download), or opens a tab. Desktop presses with a mouse at 1440px; phones tap at 390px, where there is no hover,
|
|
8
8
|
* so a tooltip-only control counts only on touch, or when it is an info button ("About …").
|
|
9
9
|
*
|
|
10
|
+
* The walk enters open shadow roots and follows slots (scripts/lib/deep.ts, injected into every page), so a control inside a
|
|
11
|
+
* custom element is listed, hit-tested and observed like any other, and named with its host: `Save (in x-card)`.
|
|
12
|
+
*
|
|
10
13
|
* Skipped: controls already in their chosen state (a selected tab, the current segment) and the Atlas's component
|
|
11
14
|
* pages, whose buttons are specimens of states, not actions.
|
|
12
15
|
*/
|
|
13
16
|
import { launch, type Page } from './lib/chrome.ts';
|
|
17
|
+
import { DEEP_SOURCE } from './lib/deep.ts';
|
|
14
18
|
import { discover } from './lib/routes.ts';
|
|
15
19
|
import config from './verify.config.ts';
|
|
16
20
|
|
|
@@ -23,28 +27,36 @@ const ROUTES = opt('--routes', '') ? opt('--routes', '').split(',') : [...discov
|
|
|
23
27
|
const WIDTHS = [1440, 390];
|
|
24
28
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
25
29
|
|
|
26
|
-
/** Tag every visible, enabled, not-yet-chosen control with its index; return their names and the page's links. */
|
|
30
|
+
/** Tag every visible, enabled, not-yet-chosen control with its index; return their names (with the hosts they sit in) and the page's links. */
|
|
27
31
|
const LIST = `(() => {
|
|
28
|
-
const
|
|
32
|
+
const deepClosest = (el, sel) => { for (let n = el; n; n = deepParent(n)) if (n.matches(sel)) return n; return null; };
|
|
33
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && !deepClosest(el, '[aria-hidden="true"],[inert],.sr-only'); };
|
|
29
34
|
const chosen = (el) => ['aria-pressed', 'aria-checked', 'aria-selected'].some((a) => el.getAttribute(a) === 'true') || el.getAttribute('aria-current') === 'page' || ['on', 'active', 'checked'].includes(el.getAttribute('data-state'));
|
|
30
|
-
const
|
|
35
|
+
const hosts = (el) => { const out = []; for (let r = el.getRootNode(); r instanceof ShadowRoot; r = r.host.getRootNode()) out.unshift(r.host.localName); return out; };
|
|
36
|
+
const every = deepAll(document.body);
|
|
37
|
+
every.forEach((n) => n.removeAttribute('data-press'));
|
|
38
|
+
const all = every.filter((b) => b.matches('button, [role="button"], [role="tab"], [role="switch"], [role="checkbox"], [role="radio"], [role="menuitem"]'))
|
|
31
39
|
.filter((b) => !b.disabled && b.getAttribute('aria-disabled') !== 'true' && vis(b) && !chosen(b));
|
|
32
40
|
return {
|
|
33
|
-
controls: all.map((b, i) =>
|
|
34
|
-
links:
|
|
41
|
+
controls: all.map((b, i) => { b.setAttribute('data-press', i); const h = hosts(b); return (b.getAttribute('aria-label') || b.textContent || b.title || '').trim().replace(/\\s+/g, ' ').slice(0, 60) + (h.length ? ' (in ' + h.join(' > ') + ')' : ''); }),
|
|
42
|
+
links: every.filter((a) => a.matches('a[href]') && vis(a)).map((a) => a.getAttribute('href')),
|
|
35
43
|
};
|
|
36
44
|
})()`;
|
|
37
45
|
|
|
38
46
|
/** Scroll the control into view, check nothing covers it, and start counting what changes. */
|
|
39
47
|
const ARM = (i: number) => `(() => {
|
|
40
|
-
const el = document.
|
|
48
|
+
const el = deepAll(document.body).find((n) => n.getAttribute('data-press') === '${i}'); if (!el) return null;
|
|
41
49
|
el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
42
50
|
const r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
43
51
|
const hit = document.elementFromPoint(x, y);
|
|
44
|
-
|
|
52
|
+
// A control inside an open root is hit as its outermost host: the control, or any shadow host that contains it, is on top.
|
|
53
|
+
let top = !!hit && el.contains(hit);
|
|
54
|
+
for (let n = el; n && !top; n = deepParent(n)) top = hit === n && (n === el || !!n.shadowRoot);
|
|
55
|
+
if (!top) return { covered: hit ? hit.tagName.toLowerCase() + '.' + String(hit.className).slice(0, 40) : 'nothing' };
|
|
45
56
|
window.__changes = 0; window.__href = location.href;
|
|
46
57
|
window.open = () => { window.__changes++; return null; };
|
|
47
58
|
const tipOnly = (n) => { const e = n.nodeType === 1 ? n : n.parentElement; return !!e && !!(e.closest('[role="tooltip"]') || e.closest('[data-radix-popper-content-wrapper]')?.querySelector('[role="tooltip"]')); };
|
|
59
|
+
const roots = () => [document.body, ...deepAll(document.body).filter((n) => n.shadowRoot).map((n) => n.shadowRoot)];
|
|
48
60
|
window.__mo?.disconnect();
|
|
49
61
|
window.__mo = new MutationObserver((ms) => { for (const m of ms) {
|
|
50
62
|
if (tipOnly(m.target)) continue;
|
|
@@ -52,7 +64,7 @@ const ARM = (i: number) => `(() => {
|
|
|
52
64
|
if (m.type === 'attributes' && m.target === el && ['data-state', 'class', 'style', 'data-press', 'aria-describedby'].includes(m.attributeName)) continue;
|
|
53
65
|
window.__changes++;
|
|
54
66
|
} });
|
|
55
|
-
setTimeout(() => window.__mo.observe(
|
|
67
|
+
setTimeout(() => roots().forEach((r) => window.__mo.observe(r, { subtree: true, childList: true, attributes: true, characterData: true })), 40);
|
|
56
68
|
return { x, y };
|
|
57
69
|
})()`;
|
|
58
70
|
|
|
@@ -66,6 +78,17 @@ async function press(page: Page, x: number, y: number, touch: boolean) {
|
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
const page = await launch();
|
|
81
|
+
|
|
82
|
+
/** One implementation of the walk: the helper is injected, never copied. A page it cannot be injected into fails the suite. */
|
|
83
|
+
async function inject(route: string) {
|
|
84
|
+
try {
|
|
85
|
+
if (!(await page.eval<boolean>(`${DEEP_SOURCE}\n;typeof deepAll === 'function' && typeof deepParent === 'function' && typeof deepQuery === 'function'`))) throw new Error('the helper is not defined after injection');
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error(`interactions: cannot inject the deep-DOM helper into ${route}: ${(e as Error).message}`);
|
|
88
|
+
page.close();
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
69
92
|
const failures: string[] = [];
|
|
70
93
|
const checkedLinks = new Map<string, number>();
|
|
71
94
|
let pressed = 0;
|
|
@@ -73,7 +96,7 @@ let pressed = 0;
|
|
|
73
96
|
for (const width of WIDTHS) {
|
|
74
97
|
const touch = width < 600;
|
|
75
98
|
for (const route of ROUTES) {
|
|
76
|
-
const fresh = async () => { await page.open(base + route, { width, theme: 'dark' }); return page.eval<{ controls: string[]; links: string[] }>(LIST); };
|
|
99
|
+
const fresh = async () => { await page.open(base + route, { width, theme: 'dark' }); await inject(route); return page.eval<{ controls: string[]; links: string[] }>(LIST); };
|
|
77
100
|
const { controls, links } = await fresh();
|
|
78
101
|
for (const href of new Set(links)) {
|
|
79
102
|
if (!href.startsWith('/') || checkedLinks.has(href)) continue;
|
|
@@ -85,7 +108,8 @@ for (const width of WIDTHS) {
|
|
|
85
108
|
let armed = await page.eval<{ x: number; y: number; covered?: string } | null>(ARM(i));
|
|
86
109
|
// Something left open or a toast in the way: start the page again and try once more.
|
|
87
110
|
if (!armed || armed.covered) { await fresh(); armed = await page.eval(ARM(i)); }
|
|
88
|
-
|
|
111
|
+
// Tagged by this suite a moment ago and gone after a fresh load: say so, never skip it.
|
|
112
|
+
if (!armed) { failures.push(`${route} @${width}: "${controls[i]}" was listed but could not be found again`); continue; }
|
|
89
113
|
if (armed.covered) { failures.push(`${route} @${width}: "${controls[i]}" is covered by ${armed.covered}`); continue; }
|
|
90
114
|
await sleep(100);
|
|
91
115
|
const choosers = page.fileChoosers();
|
|
@@ -95,14 +119,15 @@ for (const width of WIDTHS) {
|
|
|
95
119
|
// A file picker answers by opening the system's file chooser, which changes nothing on the page (issue #16).
|
|
96
120
|
if (page.fileChoosers() > choosers) continue;
|
|
97
121
|
const tip = touch || /^About /.test(controls[i]);
|
|
98
|
-
|
|
122
|
+
// A press that loads a new document leaves the helper and the counters behind: that page has moved, which is its answer.
|
|
123
|
+
const r = await page.eval<{ changes: number; moved: boolean }>(`({ changes: window.__changes + (${tip} && typeof deepAll === 'function' ? deepAll(document.body).filter((n) => n.matches('[role=tooltip]')).length : 0), moved: location.href !== window.__href })`);
|
|
99
124
|
if (!r.changes && !r.moved) failures.push(`${route} @${width}: "${controls[i]}" does nothing when ${touch ? 'tapped' : 'pressed'}`);
|
|
100
125
|
if (r.moved) await fresh();
|
|
101
126
|
else {
|
|
102
127
|
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
|
103
128
|
await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
|
104
129
|
await sleep(200);
|
|
105
|
-
if (await page.eval<boolean>(`!!
|
|
130
|
+
if (await page.eval<boolean>(`!!deepQuery('[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]', document.body)`)) await fresh();
|
|
106
131
|
}
|
|
107
132
|
}
|
|
108
133
|
if (page.errors.length) failures.push(`${route} @${width}: ${page.errors[0]}`);
|