zz-meridian 0.31.2 → 0.32.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/dist/migrations.js +14 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +53 -0
- package/payload/CONTRIBUTING.md +1 -1
- package/payload/app/embed/requests/view.tsx +2 -1
- package/payload/app/not-found.tsx +1 -1
- package/payload/app/system/(atlas)/page.tsx +8 -8
- package/payload/docs/benchmark.md +1 -1
- package/payload/docs/testing.md +6 -2
- package/payload/package.json +1 -1
- package/payload/scripts/assistant.ts +6 -2
- package/payload/scripts/audit.ts +21 -7
- package/payload/scripts/check.ts +32 -2
- package/payload/scripts/gate.ts +11 -1
- package/payload/scripts/interactions.ts +8 -1
- package/payload/scripts/keyboard.ts +10 -2
- package/payload/scripts/lib/answered.ts +43 -0
- package/payload/scripts/lib/chrome.ts +13 -4
- package/payload/scripts/lib/coverage.ts +10 -3
- package/payload/scripts/lib/phone.ts +17 -2
- package/payload/scripts/lib/timing.ts +16 -0
- package/payload/scripts/live.ts +1 -1
- package/payload/scripts/navigate.ts +19 -4
- package/payload/scripts/shot.ts +12 -3
- package/payload/scripts/verify.baseline.json +21 -21
- package/payload/scripts/verify.config.ts +14 -4
- package/payload/scripts/verify.ts +21 -20
- package/payload/scripts/vitals.ts +21 -9
- package/payload/skills/zz-meridian/SKILL.md +1 -1
- package/payload/skills/zz-meridian/references/cache.md +9 -10
- package/payload/skills/zz-meridian/references/existing-project.md +5 -2
- package/payload/skills/zz-meridian/references/standard.md +3 -2
- package/payload/skills/zz-meridian/references/update.md +6 -0
- package/payload/skills/zz-meridian/references/validation.md +9 -2
- package/payload/src/components/base/shell/index.tsx +2 -1
- package/payload/src/components/base/text-roles/README.md +2 -2
- package/payload/src/components/charts/bar-list/preview.tsx +1 -1
- package/payload/src/components/charts/column-chart/index.tsx +2 -2
- package/payload/src/components/charts/composition-bar/README.md +1 -1
- package/payload/src/components/charts/composition-bar/index.tsx +16 -4
- package/payload/src/components/charts/composition-bar/preview.tsx +5 -0
- package/payload/src/components/charts/scale.ts +10 -3
- package/payload/src/components/charts/sparkline/index.tsx +2 -2
- package/payload/src/components/charts/trend-chart/README.md +1 -1
- package/payload/src/components/charts/trend-chart/index.tsx +25 -13
- package/payload/src/components/charts/trend-chart/preview.tsx +17 -4
- package/payload/src/components/patterns/appearance-menu/index.tsx +1 -1
- package/payload/src/components/patterns/command-palette/index.tsx +1 -1
- package/payload/src/components/patterns/data-table/preview.tsx +1 -1
- package/payload/src/components/patterns/detail-head/README.md +1 -1
- package/payload/src/components/patterns/featured-metric/README.md +2 -2
- package/payload/src/components/patterns/featured-metric/index.tsx +1 -1
- package/payload/src/components/patterns/filter-bar/README.md +2 -2
- package/payload/src/components/patterns/filter-bar/index.tsx +5 -2
- package/payload/src/components/patterns/filter-bar/preview.tsx +19 -12
- package/payload/src/components/patterns/freshness/index.tsx +3 -2
- package/payload/src/components/patterns/metric-tile/index.tsx +8 -3
- package/payload/src/components/patterns/rail/index.tsx +4 -4
- package/payload/src/components/patterns/shell-tools/preview.tsx +2 -2
- package/payload/src/components/patterns/status-list/index.tsx +1 -1
- package/payload/src/components/templates/records/README.md +2 -2
- package/payload/src/components/templates/records/index.tsx +8 -2
- package/payload/src/components/templates/records/preview.tsx +2 -2
- package/payload/src/components/ui/pagination/index.tsx +4 -3
- package/payload/src/components/ui/progress/index.tsx +2 -1
- package/payload/src/components/ui/radio-group/index.tsx +1 -1
- package/payload/src/data/read.ts +9 -10
- package/payload/src/lib/format.ts +51 -29
- package/payload/src/logic/customer-context.ts +5 -1
- package/payload/src/logic/customer-explore.ts +2 -1
- package/payload/src/logic/derivations.ts +5 -4
- package/payload/src/logic/fact-insight.ts +2 -1
- package/payload/src/logic/home-parts.ts +3 -2
- package/payload/src/logic/request-context.ts +5 -2
- package/payload/src/logic/requests-explore.ts +2 -1
- package/payload/src/logic/revenue-parts.ts +2 -1
- package/payload/src/logic/tools.ts +2 -2
- package/payload/src/styles/base.css +41 -7
- package/payload/src/styles/theme.css +0 -1
- package/payload/src/styles/tokens.css +0 -1
- package/payload/src/system/atlas-shell.tsx +3 -3
- package/payload/src/system/markdown.tsx +1 -1
- package/payload/src/system/sample-cells.tsx +2 -1
- package/payload/src/system/strata.tsx +1 -1
- package/payload/src/views/customer.tsx +2 -1
- package/payload/src/views/customers.tsx +3 -1
- package/payload/src/views/home.tsx +2 -1
- package/payload/src/views/keys.tsx +2 -2
- package/payload/src/views/members.tsx +11 -5
- package/payload/src/views/missing-page.tsx +2 -1
- package/payload/src/views/releases.tsx +2 -1
- package/payload/src/views/request.tsx +5 -3
- package/payload/src/views/requests.tsx +3 -1
- package/payload/src/views/revenue.tsx +3 -2
- package/payload/src/views/settings.tsx +2 -1
- package/payload/src/views/standalone.tsx +4 -1
- package/payload/src/views/tests.tsx +2 -1
- package/payload/src/views/tickets.tsx +2 -1
- package/payload/tests/atlas-product-slice.test.tsx +15 -2
- package/payload/tests/chart-scale.test.ts +5 -0
- package/payload/tests/chart-semantics.test.tsx +136 -0
- package/payload/tests/composition-bar.test.tsx +13 -1
- package/payload/tests/derivations.test.ts +2 -0
- package/payload/tests/forced-colors-states.test.ts +62 -0
- package/payload/tests/format-currency.test.ts +34 -0
- package/payload/tests/format-semantics.test.ts +99 -0
- package/payload/tests/metric-tile-note.test.tsx +1 -1
- package/payload/tests/metric-tile-pointed-empty.test.tsx +2 -1
- package/payload/tests/request-view-replay.test.tsx +7 -0
- package/payload/tests/sample.test.ts +16 -10
- package/payload/tests/settings-save.test.tsx +11 -0
- package/payload/tests/truncation-keeps-text.test.tsx +72 -0
- package/payload/tests/verify-coverage.test.ts +10 -1
- package/payload/tests/verify-suite-outcome.test.ts +6 -0
- package/payload/tests/verify-timing.test.ts +24 -1
- package/payload/tokens/core.tokens.json +0 -13
package/dist/migrations.js
CHANGED
|
@@ -564,7 +564,21 @@ export const RELEASE_MIGRATIONS = [
|
|
|
564
564
|
instructions: '`src/data/live-stream.ts` is yours, so an update does not touch it, and the copy you have never ends a stream. A server told to stop (`next start` on SIGTERM or SIGINT, as a deploy or a restart does) closes its port and waits for its open connections: the old process stays up holding every tab\'s stream while the new one serves the port, so no tab hears a change hint again and every change waits for its next safety poll. Bring the block that begins `Every stream still open` over from `references/live.md` (the `open` set, `endLiveStreams` and `endOnShutdown`), call `open.delete(close)` first in the stream\'s `close`, and `endOnShutdown(close)` after the stream listens for its abort. A project that serves its stream some other way ends its streams on the signal the same way.',
|
|
565
565
|
checks: ['gate'],
|
|
566
566
|
},
|
|
567
|
+
{
|
|
568
|
+
id: 'missing-routes',
|
|
569
|
+
since: '0.32.0',
|
|
570
|
+
applies: detailRoutesWithoutMissing,
|
|
571
|
+
summary: 'A route that answers an error or draws the not-found screen fails every browser suite unless it is declared in missingRoutes.',
|
|
572
|
+
instructions: 'The audit, the presses, the keyboard walk, the vitals and `scripts/shot.ts` now read what a route answered: one that answered an error status or draws the not-found screen (`[data-missing-page]`) was measured as that screen and not as the route, so it fails. In `scripts/verify.config.ts`, which is yours, move every entry of `detailRoutes` that names a record that does not exist (the sample\'s `/requests/req_missing` and `/customers/missing`) into a new `missingRoutes` list; each must be the not-found screen, and one that renders a page fails. A not-found screen of your own carries `data-missing-page` on its root, as `MissingPage` does: inside a streamed shell a missing record has already answered 200.',
|
|
573
|
+
checks: ['verify'],
|
|
574
|
+
},
|
|
567
575
|
];
|
|
576
|
+
/** 0.32.0: a verify configuration that lists detail routes and declares no routes meant to be missing. */
|
|
577
|
+
function detailRoutesWithoutMissing(root) {
|
|
578
|
+
const rel = 'scripts/verify.config.ts';
|
|
579
|
+
const body = text(root, rel);
|
|
580
|
+
return body !== null && /\bdetailRoutes\s*:\s*\[\s*'/.test(body) && !/\bmissingRoutes\s*:/.test(body) ? [rel] : null;
|
|
581
|
+
}
|
|
568
582
|
/** 0.31.1: a live stream starter that does not end its streams when the server is told to stop. */
|
|
569
583
|
function liveStreamKeptOnStop(root) {
|
|
570
584
|
const rel = 'src/data/live-stream.ts';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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,59 @@
|
|
|
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.32.0] · 2026-10-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`Records` takes `empty`.** There are no records at all, not none that match a filter: the summary tiles are not drawn, and the table's empty state is the page's one word.
|
|
10
|
+
- **`--rate <n>` slows the phone n times beyond the host's own rate.** The timed suites calibrate the phone by a JavaScript benchmark, and a machine of the same benchmark can be slower at style and layout: the weekly runner measured about 1.6 times this laptop's times at the same rate, so a frame or a budget only the week saw red passed here. `pnpm verify --rate 1.6`, or `--rate 1.6` on `scripts/navigate.ts`, `scripts/vitals.ts` or `scripts/perf.ts`, reproduces it; the `phone:` line says the rate was scaled, and a rate under 1 is refused before the gate.
|
|
11
|
+
- **A time that passes by a hair says so.** A navigation time judged under `--timed`, LCP, INP, CLS or the longest frame that passes leaving less than a fifth of its limit prints `warn`, and its phase in `pnpm verify` reads `warn`: a pass at 95% of a budget here was a red on the runner, and nothing said how near the line a green run had been. `scripts/navigate.ts` ends with the run's least headroom, each vitals line gives its longest frame, and a product's own check that prints a line starting `warn` makes its phase `warn` too. A warn holds nothing; `docs/testing.md` `Headroom` says it is the cue to run the suite again with `--rate 1.6`.
|
|
12
|
+
- **The gate names a managed file a team changed and kept nowhere.** Nothing compared a project's managed files with the hashes `.meridian/manifest.json` records between releases, so an edit nobody recorded a reason for was first met as a question in the next update. `node scripts/check.ts` prints `drift: <n> managed files differ from the release and are not in .meridian/keep.json: <paths>`, the gate repeats it after its last line and `pnpm verify` under the gate. It fails nothing; restore the file, or keep it with its reason.
|
|
13
|
+
- **The Postgres code the references hand a team runs against a real Postgres, weekly.** `references/live.md`'s LISTEN and NOTIFY adapter and the pool and pooler guidance of `references/existing-project.md` were compiled and run by nothing, and each database finding reached Meridian from an adopter's production (#16, #17). `scripts/fixtures/postgres/postgres.test.ts` runs the adapter as the reference has it (a committed change reaches its tenant and collection only, a rollback sends nothing, a connection the database ends comes back and resyncs, the last subscriber leaving closes it), holds the pool to what the reference says happens with its error listener and without it, and holds a PgBouncer in transaction mode to refusing a session setting, honouring `SET LOCAL` and not keeping a session advisory lock. The weekly job `postgres` runs it, by its own config, against service containers; nothing else collects it. All nine held as written.
|
|
14
|
+
- **The week audits the Design Atlas.** No verify depth audits the Atlas, where each card's specimens draw its extreme shapes, so the two defects above were found only when someone looked. The weekly job `atlas` builds, serves and runs `node scripts/audit.ts --atlas` at the pixel ratio verify measures.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Labels are words, not code.** The kicker (`t-kicker`), the eyebrow (`t-eyebrow`) and a Featured metric's foot labels were monospace capitals, every award site's label; they are the text's own face and case at 12px, medium, in `ink-3`. Monospace is left to identifiers: ids, keys, routes, hashes. The Atlas's numbered `01`–`05` labels are gone, and an API key's environment badge reads `Live` or `Test`.
|
|
19
|
+
- **A row of tiles is one instrument.** `Row split="tiles"` drew its tiles as equal cards side by side; it draws one band with the card's edge and fill, the tiles divided by its hairline at every width. A tile has no fill of its own inside it: the surface is translucent, and two layers lifted the ground under the captions. The Atlas's five rules read as a list rather than five equal tiles.
|
|
20
|
+
- **The audit measures 320px.** WCAG's reflow asks content to hold at 320 CSS px, a small phone or a desktop at 400% zoom, and the narrowest width the audit measured was 390. It measures every page at 2560, 1440, 1024, 768, 390 and 320px; `verify --full` takes about a fifth longer.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **A narrowed audit measures an embed as an embed.** `node scripts/audit.ts --routes /,/embed/home` measured the embed as a page, on no host's ground, so its ink failed contrast against a transparent page (1.13:1), and it ran every other embed beside it. An embed in `--routes` is measured on its host's ground, only the embeds it names run, and `--list` marks each `(embed)`.
|
|
25
|
+
- **A tile's figure fits its tile.** Its size was set for the viewport, so a long figure ran past the tile's edge: `100.00%` in a row of four at 1440, `$91,367.01` beside its sparkline on a phone. The figure keeps its size until its characters would not fit the column it sits in, then steps down with it; a tile sized by its content keeps the width its figure and note need. The line under the figure is `data-tile-line`, which the tile's tests read by name.
|
|
26
|
+
- **The alerts panel's preview fits a phone.** It fixed the panel at 352px inside a narrower column, so "Mark all read" ran past the panel's edge on the Atlas page at 390px; the bell's own popover was right.
|
|
27
|
+
- **A list with nothing in it says so once.** Customers, Tickets, Tests, Releases and Requests drew their row of summary tiles as zeros above the table's "No customers yet": `Revenue, last 30 days $0`, `Open tickets 0`, figures drawn as if there were records. A page with no records at all passes `empty` to `Records`, and the summary is not drawn; a filter that matches nothing keeps its summary of the matching set. `tests/cli-views-without-data.test.tsx` draws every view with an empty product and Home, Usage, Revenue and Now with nothing readable.
|
|
28
|
+
- **A part past the palette has a colour.** The categorical palette has six slots, and a Composition bar of more parts named `--series-7` and on, which no theme defines: the part drew transparent, its share gone from the bar and its swatch blank in the key, while the percentages still added up. Past six parts it draws the first five and "Other", their sum, in the neutral; the Atlas draws it with twelve.
|
|
29
|
+
- **A link to a record names that record, whatever its id holds.** The sample's ids are slugs, and every link to a customer or a request was written `/customers/${id}`: an id holding a slash, a hash, a question mark or a space named another address (`/customers/acme/eu` is no page, `/requests/r#2` is `/requests/r`), on the page and in the addresses handed to an agent. `customerAddress` and the new `requestAddress` encode the id, and every link goes through them; the page reads its segment decoded.
|
|
30
|
+
- **A list's cut line keeps its whole in the title it sits under.** Releases cut "Inference API · Staging" at 320px under a title that read "staging", and Tests cut "Smoke" under "smoke on …": the title named the raw value where the line drew its label, so the whole of the cut line was nowhere. The titles name what the line draws.
|
|
31
|
+
- **A write that never answers is told, and the page stays.** A refusal answers with its reason, and every write said it; a write that never answered (a dropped connection) throws instead, and two did not catch it: Replay threw inside its transition and took the request's page to its error screen, and saving the workspace in Settings stayed on Saving for good. Each now says the change did not reach the server, the way the other writes do, and the form keeps what was typed.
|
|
32
|
+
- **A long name or address never holds its column past the table.** Members' email, API keys' creator and a ticket's assignee were held on one line, so a company's long domain or a person's long name widened the column until the table ran past its frame: Members by 2px at 1440 with `@northwind-global-payments-reconciliation.example`. A name breaks at its spaces and an address before its @ when its column has no room, and reads whole on one line when it has. `tests/cli-views-keep-cut-text.test.tsx` draws them with names and domains as long as real ones run.
|
|
33
|
+
- **An amount is written to its currency's minor unit.** Every amount had two decimals whatever `app.currency` named, so a product in yen read `¥1,234.57` for an amount with no fraction to show, and one in dinars lost a digit of every amount. `formatCost` and the money axis take the currency's own places (0 for the yen and the won, 3 for the dinar, 2 for the dollar and the euro), and an amount under one minor unit keeps two places more, so it still never reads as zero (`¥0.40`, `$0.0040`). `tests/format-currency.test.ts` draws the dollar, the yen and the dinar.
|
|
34
|
+
- **A chosen item is told apart under forced colours.** Windows' contrast themes replace every colour with a few of the system's and drop box-shadow, so a choice told by a fill, a tint or a ring was told by nothing: the rail's current page, the period, a Segmented's segment, a chosen radio, the page a pager is on and the current tab read like the rest, the accent swatches were blank and an off switch had no thumb. `base.css` tells each selection state (`aria-current=page`, `aria-pressed`, `aria-selected`, `aria-checked`, `data-state` `on`, `active` and `checked`) in the system's Highlight, outside every layer, keeps a swatch's own colour (`data-swatch`) and draws a switch's thumb. Where the reader is, likewise: a field that removed its outline told focus by its border and a ring, and a menu or a select its highlighted item by a fill, so a keyboard reader lost their place; focus is outlined in Highlight and a highlighted item filled with it. `tests/forced-colors-states.test.ts` holds every component that styles a choice by one of these states to the block.
|
|
35
|
+
- **Every page of a long read is asked at once.** `readAll` reads a page of 500 rows and asked each page after the one before it arrived, so a collection of N pages waited N round trips: Home, Business and Engineering read usage and compute (1,444 rows each) three in a row, and a product with twenty models over a year would wait fifteen. The first page says how many there are, and the rest are asked together; with each read taking 1.5 s, Home's largest paint came at 3.3 s where it came at 4.8 s. The member-name join and the request tool's route latency are asked the same way. `tests/cli-read-all-pages.test.ts` holds it.
|
|
36
|
+
- **The filter bar holds Ask at a phone's width and beside the open assistant.** The search's floor, Filters with its count and Ask need 336px on one line; a 320px phone's card gives the bar 246px and a 1024px window with the assistant open 273px, so Ask ran 60px past the card on Customers, Tickets, Releases, Requests and Tests. Under 24rem the search takes a line of its own and Filters and Ask share the line under it. The Atlas's narrow specimen draws the bar with Ask at 360px and 246px, where the weekly Atlas audit measures it; the audits run before this one built the product with no assistant, so Ask was never drawn at 320px.
|
|
37
|
+
- **Every chart and figure is drawn with the shapes a product's data takes, and none prints a number that is not one.** `tests/chart-semantics.test.tsx` draws each chart and figure (Sparkline, the Trend chart's area, whole, stacked and running forms, the Column chart's plain, whole, highlighted and stacked forms, BarList, CompositionBar, the Heatmap's three forms, MetricTile, FeaturedMetric, Delta, UptimeBars) with fifteen shapes: empty, one value, all missing, all zero, a gap, missing at either end, all negative, signed, values under a millionth, values over a trillion, counts topping at one, one outlier, a flat run and a year. None may put `NaN`, `Infinity` or `undefined` on the page or size a mark below zero; a point with no data reads as no data in a chart's table, never as zero; a chart of counts names whole levels only. It found two defects:
|
|
38
|
+
- **A count scale over values far under one draws every level at its floor.** `ticksBetween` rounded a top smaller than a millionth of its step onto zero, so the plot's top and floor were the same level and every gridline was drawn at `NaN`; a floor that small was rounded up past the data the same way. Each end now stays a whole step past the data.
|
|
39
|
+
- **A stacked Trend chart drew a day nothing was measured on as a total of zero.** The stack dipped to the axis there and the table read `0`. Such a day now breaks every band and the total, as a missing day breaks a line, and its total reads `—`; a part missing on a day the others were measured stands on the parts below it, as before. The Trend chart's preview draws it under Gaps.
|
|
40
|
+
- **A suite no longer measures an error page as the route it asked for.** A route that answered 404 was audited, pressed, walked, measured and shot as whatever its not-found screen drew, which has a heading, a main and controls like any page, so a mistyped id in `detailRoutes` or an address given by hand read `0 issues`. The browser helper now records the status the document answered with (`page.status()`), and the audit, the presses, the keyboard walk, the vitals and `scripts/shot.ts` fail a route that answered an error status (`<route> answered 404, so what was measured is its error page and not the route`) or drew the not-found screen (`<route> draws the not-found screen ([data-missing-page])`): a record page inside a streamed shell finds no record after it has answered 200, which is how the sample's missing records answer, so the screen's own mark counts as much as the status. A route meant to be missing is declared: `missingRoutes` in `scripts/verify.config.ts`, or `--missing` for one run; it must be the not-found screen, and one that renders a page fails, so a record that came to exist is noticed. The root not-found screen carries `data-missing-page` too. The fixtures hold every direction in every suite (`/gone/record`, `/drawn-missing`, `/came-to-exist`).
|
|
41
|
+
- **What a team does.** Move a detail route that names a record that does not exist from `detailRoutes` to `missingRoutes`, and give a not-found screen of your own `data-missing-page` on its root; the run names any route it finds. `update` reports `migration:missing-routes` for a configuration that lists detail routes and no missing ones.
|
|
42
|
+
- **A phone is measured at its own width.** The audit judged a page at a phone's width against `innerWidth`, which a phone's visual viewport widens to whatever overflows it: a row 600px wide at 390px measured the window as 616px, so the document's own sideways scroll never failed on a phone, the strip rule (#86), which runs only at 400px or less, was skipped on any page that overflowed, and every clip was taken against the wider box. Every check that asks whether something fits the window now reads the layout viewport (`document.documentElement.clientWidth`): the audit, the keyboard walk's on-top test, `scripts/shot.ts`'s readout check, the live run's sheet and the assistant's panel geometry. `/sideways-document` holds it; the sample's thirty routes pass at the true width. The fixture `/sideways-data-page-fixed` overflowed a phone itself, which the wider window had hidden, and is corrected.
|
|
43
|
+
- **A rule that judged nothing says so.** `scripts/check.ts` counts what each rule over a set of the project's files judged, and prints `judged nothing: <rules>` for a set that came out empty: the card contract, the page specifications, what the agent reads, what the agent does here, and a collection's rows read through it. An empty set passed exactly as a set judged right did, which is how view tools kept in modules (#43) and a route group not named `(dashboard)` (#23) passed. The presses print it when no control and no link was found on any route, the keyboard walk when no stop was. The gate adds it to the step's `ok` line and its last line, and `pnpm verify` to the coverage line (`judged nothing: …`). It is information, never a failure: a product without cards has none to judge. The assistant walk-through does the same for the sample steps a product removed: each prints `n/a` as it passes and the run ends with `judged nothing: assistant: <step>, …`, which `pnpm verify --full` names in its coverage line.
|
|
44
|
+
- **Every rule a browser suite judges has a page it fails and a page it passes.** `tests/cli-check-rule-pairs.test.ts` lists the 54 rules of the audit, the presses, the keyboard walk and the vitals, each with the fixture page it must fail and the one it must pass, and holds that every place a suite can fail from belongs to a listed rule, so a new rule cannot arrive without its pair. Fifteen had no page that made them fire: the document's own sideways scroll; a link that is not a URL, a control that leaves for good, a control under a layer, a page error and a page that stops answering, in the presses; a control under a fixed bar, a way back that differs, a skip link that is not the first stop, a control that leaves on focus and a page that stops answering, in the keyboard walk; and each of the vitals' four floors. Each now has its pages. The vitals line names the floor a page missed (`over the LCP floor (2.5 s)`, `dropped a frame (over 50 ms)`), where it printed only the figures. A corrected twin of a timed rule is a `clear` case: its own line is absent, whatever a floor it does not plant reads while the cases run side by side.
|
|
45
|
+
- **A text a card cuts keeps the whole of itself.** `tests/truncation-keeps-text.test.tsx` draws every card that cuts text with an ellipsis, with text far longer than any box, and holds each cut element to keep its whole text in a `title` on it, on what holds it, or on the one thing inside it: the audit's rule, which only ever met the sample's short names. Five cards cut text and kept it nowhere: Progress's label, a StatusList service's name, Pagination's range line, both forms of Freshness (whose title held the date and not the words it cut), and in the rail a scope's label, a destination's label and the signed-in person's name and role; the command palette's labels too. Each now keeps it. Pagination cuts each form of its line on its own, so the title is the one the pager's width shows.
|
|
46
|
+
- **A figure that rounds to zero prints no sign.** `-0` is a value a product's data holds (`Math.round(-0.4)`, a net change that rounded away), and `count`, `compact`, `cost-compact`, `percent` and every axis format printed it as `-0`, `-$0` or `-0.0%`, which reads as a fall. Every format passes its figure through one rule: a figure with no digit but zero carries no sign. `tests/format-semantics.test.ts` draws every formatter with sixteen values a product holds (negative zero, a millionth of a cent, a quadrillion, signed halves) and missing, and holds a split or shortened figure to its sign and symbol.
|
|
47
|
+
- **A figure at a rounding edge is written in the unit it rounds to, and a share that is not zero never reads as zero.** `formatDuration` chose its unit from the raw value and its digits from the rounded one, so a p95 of 999.6 ms read `1000ms` and a 59.96 s run `60.0s`; an axis tick of 999,999 read `1000K`. Each now reads `1.0s`, `1m` and `1M`, the step `formatCompact` already took. Past two days a duration reads in days: an incident open for three weeks read `504.0h` and reads `21.0d`. `formatPercent` printed an error rate of 0.003% as `0.0%`, which says there were none: a share too small for its places reads as the bound it is under, `<0.1%` (`<0.01%` at two places, `<1%` at none), with its own sign, the rule `formatCost` keeps for a cost. A title or a line that says a change held level judges it by its size, as Delta does, so a change under 0.05% still reads level. `tests/format-semantics.test.ts` holds every formatter at the edges.
|
|
48
|
+
|
|
49
|
+
### Breaking
|
|
50
|
+
|
|
51
|
+
- **A chart's `color` is a palette slot, `SeriesSlot` (1 to 6).** `ColumnPart`, a Composition bar's `Part`, `Sparkline` and a Trend chart's series took any number, and a slot past six drew nothing. A caller that computes a slot types it `SeriesSlot` or folds the parts past the palette.
|
|
52
|
+
- **The `tracking-caps` token is removed.** Nothing in Meridian sets text in spaced capitals any more. A product's own `tracking-(--tracking-caps)` renders nothing: write the letter-spacing it wants, or drop the capitals.
|
|
53
|
+
- **A detail route that answers an error fails.** `detailRoutes` held the missing record beside the real ones; it now goes in `missingRoutes`. The `Page` type of `scripts/lib/chrome.ts` has a `status()` member, so a stub of it in a project's own test adds one.
|
|
54
|
+
- **`trendTotals` returns `(number | null)[]`.** A day no band has a value for is `null`, not `0`. A caller that adds or formats the totals handles `null` as a day with no total.
|
|
55
|
+
- **Figures read differently at their edges.** A share that is not zero but rounds to zero reads `<0.1%` (`<0.01%`, `<1%` at two and no places), not `0.0%`; a duration reads in the unit its rounded figure belongs to (`1.0s`, not `1000ms`; `1m`, not `60.0s`) and past two days in days (`21.0d`); an axis tick of 999,999 reads `1M`; money takes `app.currency`'s own decimals (`¥1,235`, `KWD1,234.568`). A product's own test that holds the old text of a figure at one of these edges holds the new one.
|
|
56
|
+
- **The filter bar wraps under 24rem.** Below 336px of its own width the search takes a line of its own and Filters and Ask the line under it, on a 390px phone too. A product's own screenshot or layout test of a list on a phone expects the two lines.
|
|
57
|
+
|
|
5
58
|
## [0.31.2] · 2026-10-27
|
|
6
59
|
|
|
7
60
|
### Fixed
|
package/payload/CONTRIBUTING.md
CHANGED
|
@@ -93,7 +93,7 @@ Write the status on the line under the title: `Status: beta`.
|
|
|
93
93
|
| `rm -rf .next` before the next build, after editing a card's `README.md` | A production build in a working `.next` can serve the prerendered copy of the old text while the client hydrates the new one, which React reports as error #418 on that Atlas page. `pnpm dev` has no such copy, and a fresh clone (CI, a deployment) has none either | After changing what a card's page says |
|
|
94
94
|
| `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, an element that claims a control's role has a key handler, and the specification of every page with a view tool has its **What the agent does here**, with its Protagonist line and three questions in their form | Gate |
|
|
95
95
|
| `pnpm typecheck`, `pnpm test` | TypeScript and the behaviour tests | Gate |
|
|
96
|
-
| `node scripts/audit.ts` | With the app running: every page and embed view at 2560, 1440, 1024, 768 and
|
|
96
|
+
| `node scripts/audit.ts` | With the app running: every page and embed view at 2560, 1440, 1024, 768, 390 and 320px 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, text cut with an ellipsis that keeps the whole of it in no `title`, two pieces of text drawn in the same place or a stroked line drawn through one, a control with no accessible name, more than one page scroller, rendered text under its WCAG minimum (an SVG's text too, read by its `fill` against what the SVG paints behind it), 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 | Inside `pnpm verify --full` |
|
|
97
97
|
| `node scripts/audit.ts --atlas` | With the app running: the same audit over the Design Atlas, at 1440 and 390px in both themes: every card's page and its bare preview, and every other entry the Atlas serves (the token views, the page specs, the guides). Its first line says what it reached, by kind, and `--list` prints the routes without opening a browser. 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 a change to a card, a pattern or the Atlas |
|
|
98
98
|
| `node scripts/vitals.ts [--atlas]` | With a production build running: LCP, INP, CLS and dropped frames on a mid-range phone for every static page, the Atlas's front page and one card page among them. `--atlas` measures every Atlas entry instead, about fifteen minutes, and is not part of `pnpm verify` | Inside `pnpm verify --timed`; `--atlas` after a change to a card, a pattern or the Atlas, on a quiet machine |
|
|
99
99
|
| `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 | Inside `pnpm verify --full` |
|
|
@@ -17,6 +17,7 @@ import { REGION_NAMES } from '@/data/sample';
|
|
|
17
17
|
import { describeFilters, requestsContext, type RequestsData } from '@/logic/requests-context';
|
|
18
18
|
import { activeFilters, options, REQUEST_FILTERS, REQUEST_METHODS } from '@/views/requests';
|
|
19
19
|
import { MethodChip, requestColumns, StatusBadge } from '@/system/sample-cells';
|
|
20
|
+
import { requestAddress } from '@/logic/request-context';
|
|
20
21
|
|
|
21
22
|
/** A person's change replaces the tool's `by`: the default must be a value a write can differ from, because a key at its default is dropped from the address. */
|
|
22
23
|
const PERSON = 'you';
|
|
@@ -96,7 +97,7 @@ export function EmbedRequests(data: RequestsData) {
|
|
|
96
97
|
<li key={r.id}>
|
|
97
98
|
<button
|
|
98
99
|
type="button"
|
|
99
|
-
onClick={() => s.openLink(
|
|
100
|
+
onClick={() => s.openLink(requestAddress(r.id))}
|
|
100
101
|
className="group flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-fill-hover"
|
|
101
102
|
>
|
|
102
103
|
<MethodChip method={r.method} />
|
|
@@ -46,7 +46,7 @@ function AddressPlace() {
|
|
|
46
46
|
/** Any address that leads nowhere outside the console: say so plainly, show the address, offer the nearest way back. */
|
|
47
47
|
export default function NotFound() {
|
|
48
48
|
return (
|
|
49
|
-
<Standalone kicker={NOT_FOUND.kicker} sentence={NOT_FOUND.sentence} lead={NOT_FOUND.lead} footer={<SampleFooter services={() => readServices().catch(() => null)} />}>
|
|
49
|
+
<Standalone missing kicker={NOT_FOUND.kicker} sentence={NOT_FOUND.sentence} lead={NOT_FOUND.lead} footer={<SampleFooter services={() => readServices().catch(() => null)} />}>
|
|
50
50
|
<Suspense fallback={<AddressPlace />}>
|
|
51
51
|
<Address />
|
|
52
52
|
</Suspense>
|
|
@@ -84,21 +84,21 @@ export default function AtlasHome() {
|
|
|
84
84
|
{/* Surfaces and operators */}
|
|
85
85
|
<section className="mt-28">
|
|
86
86
|
<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." />
|
|
87
|
-
|
|
87
|
+
{/* The five rules read as a list a person can hold against a view, not a row of equal tiles. */}
|
|
88
|
+
<dl className="divide-y divide-line border-y border-line">
|
|
88
89
|
{[
|
|
89
90
|
['Addressable', 'Every view’s state is in its address: an agent opens exactly the view it means.'],
|
|
90
91
|
['Legible', 'Every view tells the model what is on screen, and again when it changes.'],
|
|
91
92
|
['Consent', 'An agent reads anything and changes nothing without a person’s Approve.'],
|
|
92
93
|
['Provenance', 'What an agent did keeps its name and the person it acted for.'],
|
|
93
94
|
['Handoff', 'Ask hands any card to the agent as a question, where one is listening.'],
|
|
94
|
-
].map(([t, d]
|
|
95
|
-
<div key={t} className="
|
|
96
|
-
<
|
|
97
|
-
<
|
|
98
|
-
<p className="t-small mt-2 text-ink-2">{d}</p>
|
|
95
|
+
].map(([t, d]) => (
|
|
96
|
+
<div key={t} className="grid gap-1 py-5 md:grid-cols-3 md:items-baseline md:gap-8">
|
|
97
|
+
<dt className="text-xl font-semibold tracking-[-0.02em] text-ink">{t}</dt>
|
|
98
|
+
<dd className="t-body text-ink-2 md:col-span-2">{d}</dd>
|
|
99
99
|
</div>
|
|
100
100
|
))}
|
|
101
|
-
</
|
|
101
|
+
</dl>
|
|
102
102
|
<div className="mt-4 flex flex-wrap gap-3">
|
|
103
103
|
<Link href="/system/start/surfaces" className="link inline-flex items-center gap-1.5 text-sm font-medium">Surfaces and inventory <ArrowRight className="size-3.5" /></Link>
|
|
104
104
|
<Link href="/system/pages/proposal" className="link inline-flex items-center gap-1.5 text-sm font-medium">See an agent’s proposal in a host <ArrowRight className="size-3.5" /></Link>
|
|
@@ -163,7 +163,7 @@ export default function AtlasHome() {
|
|
|
163
163
|
</section>
|
|
164
164
|
|
|
165
165
|
<footer className="mt-24 flex flex-wrap items-center gap-4 border-t border-line pt-8 text-xs text-ink-3">
|
|
166
|
-
<span className="font-
|
|
166
|
+
<span className="font-medium">Generated from this repository</span>
|
|
167
167
|
<span className="ml-auto">ZZ Meridian · MIT</span>
|
|
168
168
|
</footer>
|
|
169
169
|
</div>
|
|
@@ -6,7 +6,7 @@ Meridian held to the bar of award-winning sites (Awwwards, Webby, FWA) and of th
|
|
|
6
6
|
|
|
7
7
|
| Criterion | Verdict | Evidence |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| Typography | Strong | One family (Geist) with
|
|
9
|
+
| Typography | Strong | One family (Geist) for every label, with its mono for identifiers only; at 1440px 55px page titles, 78px hero figures, 37px metric figures, and a 32px title where a page is headed by a sentence (03 Oct: 52, 76 and 40px), with the unit and fraction stepped down to half size in `ink-3`. Eleven sizes in the scale. The audit counts every distinct size a page draws, and on 15 Oct (round 5) that count runs from 5 (Members, Settings, API keys) to 11 on Home and Revenue, whose four extra sizes (19, 21, 39 and 44px) are the stepped-down units and currency marks of their 37 and 78px figures; Usage and Services draw 7, Now 8: by text role a page uses 5 to 8, as before. Three weights everywhere on the product |
|
|
10
10
|
| Whitespace | Strong | A 4px scale; 20px between cards, 24–28px inside them; 32px between table columns with the card's padding on the outer edges; every analytical console page on one left edge, a form page centred on its own measure once the canvas is wider than a form needs, and a reading width of 832px for long documents. 15 Oct: a two-option Segmented had stretched to its card's width on Usage and in the Create a key sheet; it keeps to its options now (round 4, W2) |
|
|
11
11
|
| Visual hierarchy | Strong | One protagonist per page. Hierarchy ratio (largest text over median) on analytical pages, 15 Oct (round 5): Home 6× (6× on 03 Oct), Revenue 6×, Usage 6.5× (4.6×), Now 6.5×, Services 6.5× (6.5×), Requests 4.6× (4.6×); at 390px 4× to 4.3×; detail pages 2.5× (a request) and 2.9× (a customer), judged by their protagonist; standalone screens 7.8–8.4× (7.2–7.8×). Round 4 measured Revenue 2.9×, Now 3.1×, Usage and Services 2.6× (W1): each now leads with a Featured metric whose figure is at hero size, and W1 is fixed |
|
|
12
12
|
| Colour | Strong | A night-sky neutral leaning indigo; one accent that is two numbers (hue, chroma), four presets; status trio reserved; six categorical chart slots validated for colour-vision deficiency (worst adjacent ΔE 10.2 light, 8.7 dark). 352 contrast pairs pass in every theme and accent (328 on 03 Oct) |
|
package/payload/docs/testing.md
CHANGED
|
@@ -63,6 +63,9 @@ A moment is when a person or an agent runs a command. A project's own CI may run
|
|
|
63
63
|
- **Meridian's weekly run** (`.github/workflows/weekly.yml`, Mondays at 03:17 UTC and on demand, on master's current commit; Meridian's repository only):
|
|
64
64
|
- the jobs `full` (`pnpm verify --full`), `timed` (`pnpm verify --timed`) and `perf` (`pnpm verify --perf --shard <i>/5`, five shards), in parallel on separate runners, as on a schedule above; a red is rerun by its job, and a dispatch names its jobs with `-f jobs=timed`;
|
|
65
65
|
- `node cli/scripts/smoke.ts --tarball <tarball> --adopt --update --origins 3 --update-all --verify`, on a tarball packed from that commit: the adopted project's default verify cases, the update from each of the last three releases, and every recovery and failure case of the update.
|
|
66
|
+
- `pnpm exec vitest run tests/deep- --no-file-parallelism`, the browser-fixture tests of the checks, as the job `deep`: a release runs them on the commit it releases, and the week runs them again because Chrome is an input no commit pins, and a browser that changes what a suite measures turns a check blind between releases.
|
|
67
|
+
- `pnpm exec vitest run --config scripts/fixtures/postgres/vitest.config.ts`, as the job `postgres`: the Postgres code the skill's references hand a team, against a Postgres and a PgBouncer in transaction mode, both service containers. It needs `MERIDIAN_POSTGRES_URL`, `MERIDIAN_POOLER_URL` and `MERIDIAN_PG` (a directory holding `pg`, which the template does not install) and refuses to run without them; it lives outside `tests/`, so no other command collects it.
|
|
68
|
+
- `DPR=1 node scripts/audit.ts --atlas` against a build it serves, as the job `atlas`: every card's page and bare preview, the token views, the page specifications and the guides. No verify depth audits the Atlas, and a card's specimens hold its extreme shapes.
|
|
66
69
|
- Nothing waits on it: a failure notifies.
|
|
67
70
|
|
|
68
71
|
## Rules
|
|
@@ -79,6 +82,7 @@ A moment is when a person or an agent runs a command. A project's own CI may run
|
|
|
79
82
|
- CLS and dropped frames are never moved: a page that misses them is fixed.
|
|
80
83
|
- A moved limit is written where the limit lives (`scripts/lib/budgets.ts`, or the check), and recorded in a `## Budgets` section of this page, placed before `## The classification`: one row per limit with its old and new value, the run, machine and commit it was set from, the samples, the p95 and the margin.
|
|
81
84
|
- The release's time limit on the default verify is not such a limit, and the phone a timed suite emulates is a device, not a limit.
|
|
85
|
+
- **Headroom**: a timed check that passes leaving less than a fifth of its limit (a navigation time, LCP, INP, CLS, the longest frame) prints `warn`, and its phase in `pnpm verify` reads `warn`. A warn is not a red and holds nothing. It says the pass was near its line, where a slower machine fails it: on 2026-10-26 the weekly runner measured about 1.6 times this machine's times at the same phone rate. Before such a pass is evidence, the suite runs again with `--rate 1.6`, which slows the phone that much beyond the host's own rate; a red there is a red under `Fixed means`. The fifth does not predict the runner; `--rate` reproduces it.
|
|
82
86
|
- **Once per commit**: evidence recorded on a commit stands for it and for any later commit whose difference touches none of the command's inputs (defined under `Releases`). A check is run again only when its record is missing, its inputs changed, its record disagrees with what was reported, or it is in concrete doubt. A tier that runs the gate and a build inside it is one command, not a second run of them.
|
|
83
87
|
- **Flaky is red**: a check that fails sometimes is a red under `Fixed means`. A retry that passes does not clear it.
|
|
84
88
|
- **Releases**: a release runs its own checks; Meridian's are the steps of `.github/workflows/release.yml`.
|
|
@@ -122,7 +126,7 @@ Every check Meridian runs has one row, named by a key: a gate step, a group of t
|
|
|
122
126
|
| `gate/tests pass` | small, medium | functional | quick | no | `pnpm exec vitest run --exclude 'tests/deep-*'` | 48.8 s at 40d29c7 | The tests outside `tests/deep-*` pass (run with the lint and the type check). |
|
|
123
127
|
| `tests/shipped` | small, medium | functional | quick | no | `pnpm exec vitest run --exclude 'tests/deep-*' --exclude 'tests/cli-*'` | 42 s at 7e6de42 | The behaviour tests of the components, pages and data (summed file time). |
|
|
124
128
|
| `tests/cli-*` | small, medium | functional | quick | no | `pnpm exec vitest run tests/cli-` | 124 s at 7e6de42 | Meridian's repository only. The package's own tests: the CLI, its update and release paths (summed file time). Several read `cli/payload/`, the template snapshot the package carries, which is a build output: `node cli/scripts/build-payload.ts` writes it from HEAD (`--worktree` for what the tree holds), and a checkout that has never built it is told so by name rather than by an `ENOENT`. Rebuild it after changing a shipped file, or those tests read the tree as it was when it was built. |
|
|
125
|
-
| `tests/deep-*` | large | functional | complete | no | `pnpm exec vitest run tests/deep- --no-file-parallelism` |
|
|
129
|
+
| `tests/deep-*` | large | functional | complete | no | `pnpm exec vitest run tests/deep- --no-file-parallelism` | 1208 s at 1f5ff93 | Meridian's repository only. The check scripts against browser fixtures: each planted defect is found and each corrected twin passes. |
|
|
126
130
|
| `verify/build` | large | functional | change | no | `pnpm verify` | 9.6 s at 40d29c7 | One production build. |
|
|
127
131
|
| `verify/route policy` | large | non-functional | change | no | `pnpm verify` | 0.1 s at 40d29c7 | Every route is declared static or dynamic as its policy says. |
|
|
128
132
|
| `verify/first-load` | large | non-functional | change | no | `pnpm verify` | 0.3 s at 40d29c7 | The first-load size of each route is within its budget. |
|
|
@@ -130,7 +134,7 @@ Every check Meridian runs has one row, named by a key: a gate step, a group of t
|
|
|
130
134
|
| `verify/navigation` | large | functional | change | no | `pnpm verify` | 92.5 s at 269d307 | From a press in the rail, each route's shell, data and first interaction arrive, on desktop and through the phone drawer, judged on function only, never on time; up to three routes, every rail route under `--full` or `--timed`. |
|
|
131
135
|
| `verify/timing` | large | performance | timed | yes | `pnpm verify --timed` | 88.2 s at fa5bbff | The same navigation of every rail route, with each shell, data and interaction time judged against its budget. |
|
|
132
136
|
| `verify/personas` | large | functional | on request | no | `pnpm verify --as viewer` | 937.8 s at fa5bbff (the five personas) | Signed in as each named persona, the rail shows the routes that persona may open and a denied route draws its refusal. |
|
|
133
|
-
| `verify/audit` | large | non-functional | complete | no | `pnpm verify --full` | 652.0 s at 269d307 | Every page and embed view at
|
|
137
|
+
| `verify/audit` | large | non-functional | complete | no | `pnpm verify --full` | 652.0 s at 269d307 | Every page and embed view at six widths in both themes, 320px among them, for layout and accessibility defects. |
|
|
134
138
|
| `verify/presses` | large | functional | complete | no | `pnpm verify --full` | 1006.9 s at 269d307 | Every control pressed and every link followed, on desktop and phone. |
|
|
135
139
|
| `verify/keyboard` | large | functional | complete | no | `pnpm verify --full` | 205.6 s at bc1e10b | Tab through each page and back: focus rings, reach and order. |
|
|
136
140
|
| `verify/assistant` | large | functional | complete | no | `pnpm verify --full` | 101.2 s at bc1e10b | The assistant absent and in a walk-through (54.7 s and 46.5 s). |
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
|
|
@@ -56,7 +56,10 @@ if (!base || (expect !== 'off' && expect !== 'on') || (expect === 'on' && (!llm
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const ok = (what: string) => console.log(`ok assistant ${expect}: ${what}`);
|
|
59
|
-
|
|
59
|
+
/** The steps the product has no sample surface for: printed `n/a` as they pass, and once at the end as `judged nothing:`, which
|
|
60
|
+
* verify carries to the coverage line, so a walk-through that ran none of its sample steps does not read as one that ran all. */
|
|
61
|
+
const skipped: string[] = [];
|
|
62
|
+
const na = (what: string, why: string) => { skipped.push(what.replace(/, /g, '; ')); console.log(`n/a assistant ${expect}: ${what} (${why})`); };
|
|
60
63
|
const { routes: rail, landing } = await railRoutes();
|
|
61
64
|
const fail = (why: string): never => { throw new Error(why); };
|
|
62
65
|
const until = async <T>(what: string, read: () => Promise<T>, done: (v: T) => boolean, ms = 15000) => {
|
|
@@ -476,7 +479,7 @@ try {
|
|
|
476
479
|
|
|
477
480
|
// Layout: a column beside the page at 1440px, over it at 390px; Escape closes it.
|
|
478
481
|
if (!(await panelOpen())) await openPanel();
|
|
479
|
-
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:
|
|
482
|
+
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: document.documentElement.clientWidth }; })()`);
|
|
480
483
|
const wide = await geometry();
|
|
481
484
|
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`);
|
|
482
485
|
await page.open(`${base}${landing}`, { width: 390 });
|
|
@@ -527,4 +530,5 @@ try {
|
|
|
527
530
|
process.exitCode = 1;
|
|
528
531
|
} finally {
|
|
529
532
|
page.close();
|
|
533
|
+
if (skipped.length) console.log(`judged nothing: ${skipped.map((s) => `assistant: ${s}`).join(', ')}`);
|
|
530
534
|
}
|
package/payload/scripts/audit.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* node scripts/audit.ts [--base http://localhost:3100] [--routes /,/requests] [--embeds-only] [--atlas] [--list]
|
|
5
5
|
*
|
|
6
6
|
* Routes are discovered from app/ (every static page; embeds under /embed), with the detail pages in
|
|
7
|
-
* scripts/verify.config.ts beside them
|
|
7
|
+
* scripts/verify.config.ts beside them. --routes replaces both for one run: a route under /embed in it is measured as an
|
|
8
|
+
* embed, on its host's ground, and only the embeds it names run. `--list` marks an embed `(embed)`.
|
|
8
9
|
*
|
|
9
10
|
* --atlas audits the Design Atlas instead: every card's page and its bare preview, and every other entry the Atlas
|
|
10
11
|
* serves — the token views, the page specs and the guides — at 1440 and 390px in both themes.
|
|
@@ -17,7 +18,8 @@
|
|
|
17
18
|
* own content is measured like any other. A defined custom element that shows a box but exposes no open root and no
|
|
18
19
|
* light-DOM content, and one that is still undefined after the settle wait, are reported as `unmeasured:` instead of passed.
|
|
19
20
|
*
|
|
20
|
-
* Fails (exit 1) on: a route that
|
|
21
|
+
* Fails (exit 1) on: a route that answered an error status it is not declared to (`missingRoutes`, scripts/lib/answered.ts)
|
|
22
|
+
* or a declared missing route that did not answer 404, a route that served another page or dropped a parameter it was asked for, a page whose content
|
|
21
23
|
* region rendered nothing or that threw or logged a failure while it rendered, sideways scroll (a strip that scrolls at a
|
|
22
24
|
* phone width included), text clipped without an ellipsis, text drawn past a box that does not clip it, text cut with an
|
|
23
25
|
* ellipsis that keeps the whole of it nowhere, two pieces of text in the same place or a line
|
|
@@ -29,6 +31,7 @@
|
|
|
29
31
|
*/
|
|
30
32
|
import fs from 'node:fs';
|
|
31
33
|
import path from 'node:path';
|
|
34
|
+
import { answerProblem, DRAWS_NOT_FOUND, missingRoutes } from './lib/answered.ts';
|
|
32
35
|
import { atlasCoverage, atlasEntries } from './lib/atlas.ts';
|
|
33
36
|
import { launch } from './lib/chrome.ts';
|
|
34
37
|
import { DEEP_SOURCE } from './lib/deep.ts';
|
|
@@ -71,12 +74,18 @@ async function atlasRoutes(): Promise<string[]> {
|
|
|
71
74
|
const found = discover();
|
|
72
75
|
// verify.config.ts is the team's, so read the one field this needs through its own type.
|
|
73
76
|
const detailRoutes = (config as { detailRoutes?: string[] }).detailRoutes ?? [];
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
+
const MISSING = missingRoutes(args);
|
|
78
|
+
const asked = opt('--routes', '') ? [...new Set(opt('--routes', '').split(','))] : null;
|
|
79
|
+
const isEmbed = (r: string) => r.startsWith('/embed/');
|
|
80
|
+
// An embed named in --routes was measured as a page, on no host's ground, and every other embed ran beside it.
|
|
81
|
+
const ROUTES = EMBEDS_ONLY ? [] : ATLAS ? await atlasRoutes() : asked ? asked.filter((r) => !isEmbed(r)) : [...new Set([...found.filter((r) => !r.startsWith('/embed')), ...detailRoutes, ...MISSING])];
|
|
82
|
+
const EMBEDS = ATLAS ? [] : asked ? asked.filter(isEmbed) : found.filter(isEmbed);
|
|
83
|
+
// 320 is the narrowest width WCAG's reflow (1.4.10) asks content to hold: a small phone, or 1280px at 400% zoom. Below
|
|
84
|
+
// the sample's 390 only it cut a line under a title that did not hold it (2026-10-27).
|
|
85
|
+
const WIDTHS = ATLAS ? [1440, 390] : [2560, 1440, 1024, 768, 390, 320];
|
|
77
86
|
const THEMES = ['dark', 'light'];
|
|
78
87
|
if (args.includes('--list')) {
|
|
79
|
-
console.log([...ROUTES, ...EMBEDS].join('\n'));
|
|
88
|
+
console.log([...ROUTES, ...EMBEDS.map((r) => `${r} (embed)`)].join('\n'));
|
|
80
89
|
if (atlasLine) console.log(atlasLine);
|
|
81
90
|
process.exit(0);
|
|
82
91
|
}
|
|
@@ -90,7 +99,9 @@ type Report = {
|
|
|
90
99
|
|
|
91
100
|
/** Runs inside the page. Plain JavaScript: it is serialised into the browser. */
|
|
92
101
|
const MEASURE = `(() => {
|
|
93
|
-
|
|
102
|
+
// The layout viewport, never innerWidth: a phone's visual viewport widens to content that overflows it, so a page
|
|
103
|
+
// 600px wide at 390 measured 616 there and its sideways scroll, the strip rule and every clip were judged at 616.
|
|
104
|
+
const W = document.documentElement.clientWidth, out = { path: location.pathname, href: location.href, text: 0, sideways: [], clipped: [], spills: [], truncated: [], overlap: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
|
|
94
105
|
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);
|
|
95
106
|
/* closest() across open roots and slots: deepParent (injected from scripts/lib/deep.ts) steps through both */
|
|
96
107
|
const deepClosest = (el, sel) => { for (let n = el; n; n = deepParent(n)) if (n.matches(sel)) return n; return null; };
|
|
@@ -380,7 +391,10 @@ const measure = async (route: string, width: number, theme: string, embed: boole
|
|
|
380
391
|
}
|
|
381
392
|
r.focusless = [...new Set(r.focusless)].slice(0, 6);
|
|
382
393
|
const scrollers = r.scrollers.filter((s) => !ALLOWED_SCROLLERS.test(s));
|
|
394
|
+
const answer = answerProblem(route, page.status(), await page.eval<boolean>(DRAWS_NOT_FOUND), MISSING);
|
|
383
395
|
const issues = [
|
|
396
|
+
// A route that answered an error is measured as its error page: a not-found screen passes every rule below.
|
|
397
|
+
...(answer ? [answer] : []),
|
|
384
398
|
// First, because it is the one that fails in the direction of looking clean: a route that never rendered passes
|
|
385
399
|
// everything below, and the run would end with the standard's line over a page nobody measured.
|
|
386
400
|
//
|
package/payload/scripts/check.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - The agent context holds: docs/brief.md has its five sections, and every path and package script the managed block in
|
|
20
20
|
* AGENTS.md, the skill and the brief name exists.
|
|
21
21
|
* - An update session is resolved, `.meridian/keep.json` names files that are managed and present, and `app.logo` is an
|
|
22
|
-
* SVG under public/.
|
|
22
|
+
* SVG under public/. A managed file whose bytes are not the release's and that keep.json does not keep is named on a
|
|
23
|
+
* `drift:` line, a warning the gate carries: nothing else says so between releases.
|
|
23
24
|
* - Every nav item's `needs` has the shape the rail can read: 'public', a grant `object:read|create|update|remove`, or an
|
|
24
25
|
* `{ allOf: [...] }` of them.
|
|
25
26
|
*/
|
|
@@ -47,6 +48,14 @@ const lineOf = (src: string, at: number) => src.slice(0, at).split('\n').length;
|
|
|
47
48
|
const problems: string[] = [];
|
|
48
49
|
/** A deviation a line states with `allow-<rule>: <reason>`, printed beside the problems rather than passed in silence. */
|
|
49
50
|
const escapes: string[] = [];
|
|
51
|
+
/**
|
|
52
|
+
* How many things each rule over a set the project's files make judged. A rule whose set came out empty passes having
|
|
53
|
+
* judged nothing, which reads exactly like a rule that judged everything and found it right: the agent sections passed a
|
|
54
|
+
* project whose view tools were in modules the reader did not follow (#43), and the agent-reads rule passed a route
|
|
55
|
+
* group not named `(dashboard)` (#23). So a rule that judged nothing says so, in the line `judged nothing: <rules>`,
|
|
56
|
+
* which the gate and the coverage line of `pnpm verify` carry. It is not a failure: a product without cards has none.
|
|
57
|
+
*/
|
|
58
|
+
const judged: [rule: string, count: number][] = [];
|
|
50
59
|
|
|
51
60
|
// ── The four-layer switch: the project's own file turns the rules on ─────────────────────────────────
|
|
52
61
|
// `scripts/check.ts` reads one declaration from `scripts/check.local.ts` and nothing else. It is read as TEXT, never
|
|
@@ -93,8 +102,11 @@ for (const c of cards()) {
|
|
|
93
102
|
for (const s of CARD_SECTIONS) if (!md.includes(s)) problems.push(`${c.dir}/README.md: missing ${s}`);
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
judged.push(['the card contract', cards().length]);
|
|
106
|
+
|
|
96
107
|
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────
|
|
97
108
|
const PAGE_SPECS = walk(APP_DIR, /^README\.md$/);
|
|
109
|
+
judged.push(['the page specifications', PAGE_SPECS.length], ['what the agent reads', PAGE_SPECS.filter((p) => agentPageSpec(p)).length]);
|
|
98
110
|
for (const p of PAGE_SPECS) {
|
|
99
111
|
const md = read(p);
|
|
100
112
|
if (!/^# .+\n\n[^#\n].+/.test(md)) problems.push(`${p}: must open with "# Name" and a one-sentence summary`);
|
|
@@ -111,7 +123,9 @@ for (const p of PAGE_SPECS) {
|
|
|
111
123
|
// no view tool's page, so they are asked nothing; a project without `src/logic/tools.ts` has no view tools to read.
|
|
112
124
|
{
|
|
113
125
|
const pages = new Set((viewToolPages() ?? []).map((v) => v.page));
|
|
114
|
-
|
|
126
|
+
const viewed = PAGE_SPECS.filter((f) => agentPageSpec(f) && pages.has(specRoute(f)));
|
|
127
|
+
judged.push(['what the agent does here', viewed.length]);
|
|
128
|
+
for (const p of viewed) {
|
|
115
129
|
const md = read(p);
|
|
116
130
|
const section = agentSection(md);
|
|
117
131
|
if (!section || !/^## Agents$(?:\n(?!## ).*)*\n### What the agent does here$/m.test(md)) {
|
|
@@ -248,6 +262,7 @@ for (const f of LAYERS) {
|
|
|
248
262
|
// collection is covered without editing this rule). Other fixture exports it imports, such as DEMO_NOW or ROLES, are not records.
|
|
249
263
|
// A project without the collections seam (an existing app that brought Meridian in) has nothing for this rule to read.
|
|
250
264
|
const COLLECTIONS = 'src/data/collections.ts';
|
|
265
|
+
let collectionRows = 0;
|
|
251
266
|
if (fs.existsSync(path.join(ROOT, COLLECTIONS))) {
|
|
252
267
|
const FIXTURE_IMPORT = /(?:import|export)\s+(type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]*fixtures\/[^'"]*)['"]/g;
|
|
253
268
|
/** A whole fixture module taken at once: `import * as`, `export * from`, or a dynamic `import()`. */
|
|
@@ -259,6 +274,7 @@ if (fs.existsSync(path.join(ROOT, COLLECTIONS))) {
|
|
|
259
274
|
/** Each covered name, and the fixture module it comes from. */
|
|
260
275
|
const covered = new Map([...collectionsSrc.matchAll(FIXTURE_IMPORT)].filter((m) => !m[1]).flatMap((m) => valueNames(m[2]).filter((n) => served.has(n)).map((n) => [n, fixtureModule(m[3])] as const)));
|
|
261
276
|
const coveredModules = new Set(covered.values());
|
|
277
|
+
collectionRows = covered.size;
|
|
262
278
|
for (const f of LAYERS) {
|
|
263
279
|
if (f === COLLECTIONS || /^src\/system\/fixtures\//.test(f) || /(^|\/)preview\.tsx$/.test(f)) continue;
|
|
264
280
|
const src = read(f);
|
|
@@ -350,6 +366,8 @@ for (const item of nav.flatMap((g) => g.items)) {
|
|
|
350
366
|
const manifestFile = path.join(ROOT, '.meridian/manifest.json');
|
|
351
367
|
let effective: { route?: string; files: Record<string, string> } | null = fs.existsSync(manifestFile) ? JSON.parse(fs.readFileSync(manifestFile, 'utf8')) : null;
|
|
352
368
|
const retirements: Retirement[] = [];
|
|
369
|
+
/** The managed files that are not the release's bytes and that no keep entry explains (`drift:`, printed last). */
|
|
370
|
+
let drift: string[] = [];
|
|
353
371
|
{
|
|
354
372
|
const dirsOf = (d: string) => (fs.existsSync(path.join(ROOT, d)) ? fs.readdirSync(path.join(ROOT, d), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : []);
|
|
355
373
|
for (const v of dirsOf('.meridian/history')) {
|
|
@@ -389,6 +407,13 @@ const retirements: Retirement[] = [];
|
|
|
389
407
|
problems.push(`.meridian/keep.json: ${(e as Error).message}`);
|
|
390
408
|
}
|
|
391
409
|
}
|
|
410
|
+
// Between releases nothing else compares the managed files with their recorded hashes, so an edit nobody recorded a
|
|
411
|
+
// reason for met the team at the next update. An open session accounts for its own differences.
|
|
412
|
+
if (effective && sessions.length === 0) {
|
|
413
|
+
let kept = new Set<string>();
|
|
414
|
+
try { kept = new Set(parseKeep(read('.meridian/keep.json')).map((k) => k.path)); } catch { /* no register, or one reported above */ }
|
|
415
|
+
drift = Object.entries(effective.files).filter(([p, h]) => !kept.has(p) && sha256(p) !== h).map(([p]) => (sha256(p) === null ? `${p} (missing)` : p)).sort();
|
|
416
|
+
}
|
|
392
417
|
const logo = (app as { logo?: unknown }).logo;
|
|
393
418
|
if (logo !== undefined) {
|
|
394
419
|
const source = logoSource(logo);
|
|
@@ -646,8 +671,13 @@ for (const f of walk('src', /\.tsx?$/).filter((x) => SWEPT.test(x) && !TOOLKIT.t
|
|
|
646
671
|
problems.push(...scanReferences(docs, has, scripts));
|
|
647
672
|
}
|
|
648
673
|
|
|
674
|
+
judged.push(['a collection\'s rows read through it', collectionRows]);
|
|
675
|
+
|
|
649
676
|
console.log(problems.join('\n') || 'check: ok');
|
|
677
|
+
const none = judged.filter(([, n]) => n === 0).map(([rule]) => rule);
|
|
678
|
+
if (none.length) console.log(`judged nothing: ${none.join(', ')}`);
|
|
650
679
|
if (escapes.length) console.log(escapes.join('\n'));
|
|
651
680
|
if (fourLayersNote) console.log(fourLayersNote);
|
|
681
|
+
if (drift.length) console.log(`drift: ${drift.length} managed ${drift.length === 1 ? 'file differs from the release and is' : 'files differ from the release and are'} not in .meridian/keep.json: ${drift.slice(0, 8).join(', ')}${drift.length > 8 ? `, and ${drift.length - 8} more` : ''}. The next update asks about each, and tells a file whose imports are only spelled differently from one you changed; restore a file to the release, or name it in .meridian/keep.json with the reason it differs.`);
|
|
652
682
|
console.log(`${problems.length} problems · ${cards().length} cards · ${PAGE_SPECS.length} page specs · ${LAYERS.length} source files`);
|
|
653
683
|
process.exit(problems.length ? 1 : 0);
|
package/payload/scripts/gate.ts
CHANGED
|
@@ -38,9 +38,17 @@ const STEPS: [string, string[]][] = [
|
|
|
38
38
|
/** The steps that run side by side, from the first of them to the end. */
|
|
39
39
|
const TOGETHER = STEPS.findIndex(([name]) => name === 'types check');
|
|
40
40
|
|
|
41
|
+
/** The rules a step said it judged nothing with (`judged nothing: <rules>`, scripts/check.ts): carried to the last line. */
|
|
42
|
+
const nothing: string[] = [];
|
|
43
|
+
/** The managed files a step said differ from the release unkept (`drift: ...`, scripts/check.ts): carried to the last line. */
|
|
44
|
+
const drift: string[] = [];
|
|
41
45
|
const report = (name: string, t: number, r: { status: number | null; out: string; error?: Error }, cmd: string) => {
|
|
42
46
|
const ok = r.status === 0;
|
|
43
|
-
|
|
47
|
+
// A step that passed having judged nothing for a rule says so on its own line, so a pass is never read as more than it was.
|
|
48
|
+
const none = [...r.out.matchAll(/^judged nothing: (.+)$/gm)].map((m) => m[1]);
|
|
49
|
+
nothing.push(...none);
|
|
50
|
+
drift.push(...[...r.out.matchAll(/^drift: .+$/gm)].map((m) => m[0]));
|
|
51
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} (${(t / 1000).toFixed(1)}s)${ok && none.length ? ` · judged nothing: ${none.join(', ')}` : ''}`);
|
|
44
52
|
// A tool that could not start (not installed) has no output, only the error.
|
|
45
53
|
if (!ok) console.log(`${r.out}${r.error ? `${cmd}: ${r.error.message}` : ''}`.trim().split('\n').slice(-40).join('\n'));
|
|
46
54
|
return ok;
|
|
@@ -69,3 +77,5 @@ const left = withoutCliTests
|
|
|
69
77
|
? ' (the tests only this repository has left out: tests/cli-*, tests/deep-*)'
|
|
70
78
|
: hasDeepTests ? ' (tests/deep-* left out: pnpm exec vitest run tests/deep- --no-file-parallelism runs them)' : '';
|
|
71
79
|
console.log(`gate: every check passed${left}`);
|
|
80
|
+
if (nothing.length) console.log(`judged nothing: ${nothing.join(', ')}`);
|
|
81
|
+
for (const d of drift) console.log(d);
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Skipped: controls already in their chosen state (a selected tab, the current segment) and the Atlas's component
|
|
18
18
|
* pages, whose buttons are specimens of states, not actions.
|
|
19
19
|
*/
|
|
20
|
+
import { answerProblem, DRAWS_NOT_FOUND, missingRoutes } from './lib/answered.ts';
|
|
20
21
|
import { launch, type Page } from './lib/chrome.ts';
|
|
21
22
|
import { DEEP_SOURCE } from './lib/deep.ts';
|
|
22
23
|
import { linkPath } from './lib/links.ts';
|
|
@@ -29,7 +30,8 @@ const opt = (k: string, d: string) => { const i = args.indexOf(k); return i >= 0
|
|
|
29
30
|
const base = opt('--base', process.env.BASE ?? 'http://localhost:3100');
|
|
30
31
|
// verify.config.ts is the team's, so read the one field this needs through its own type.
|
|
31
32
|
const detailRoutes = (config as { detailRoutes?: string[] }).detailRoutes ?? [];
|
|
32
|
-
const
|
|
33
|
+
const MISSING = missingRoutes(args);
|
|
34
|
+
const ROUTES = opt('--routes', '') ? opt('--routes', '').split(',') : [...new Set([...discover().filter((r) => !r.startsWith('/system/components')), ...detailRoutes, ...MISSING])];
|
|
33
35
|
// `--as` signs every open in as one persona (scripts/verify.config.ts), through the cookie scripts/lib/chrome.ts sets
|
|
34
36
|
// before the first request; without it nothing changes. `--width` narrows the walk to the widths given (default: both).
|
|
35
37
|
const personas = (config as { personas?: Record<string, { cookie: { name: string; value: string } }> }).personas ?? {};
|
|
@@ -143,6 +145,9 @@ const walk = async (route: string, width: number) => {
|
|
|
143
145
|
// The page's late chrome first (scripts/lib/settle.ts): a control that arrives after the list is taken is never pressed, and the count reads as if it had been.
|
|
144
146
|
const fresh = async () => { await page.open(base + route, { width, theme: 'dark', cookie }); await inject(route); await settle(page); return page.eval<{ controls: string[]; keys: string[]; links: string[] }>(LIST); };
|
|
145
147
|
const { controls, keys, links } = await fresh();
|
|
148
|
+
// An error page's controls are not the route's: a route that answered other than declared is that, and nothing pressed.
|
|
149
|
+
const answer = answerProblem(route, page.status(), await page.eval<boolean>(DRAWS_NOT_FOUND), MISSING);
|
|
150
|
+
if (answer) { failures.push(`${route} @${width}: ${answer}`); return; }
|
|
146
151
|
// A link is followed as a browser follows it: its href as the page writes it, resolved against the page. Skipping every
|
|
147
152
|
// href that did not start with "/" let a relative link that answered 404 pass (/system/system/contributing).
|
|
148
153
|
for (const href of new Set(links)) {
|
|
@@ -217,4 +222,6 @@ for (const width of WIDTHS) {
|
|
|
217
222
|
page.close();
|
|
218
223
|
for (const f of failures) console.log('FAIL ' + f);
|
|
219
224
|
console.log(`\ninteractions: ${pressed} controls pressed and ${checkedLinks.size} links followed across ${ROUTES.length} routes at ${WIDTHS.join(' and ')}px; ${failures.length} issues`);
|
|
225
|
+
// No control pressed and no link followed passes on nothing, which reads like a pass on everything.
|
|
226
|
+
if (!pressed && !checkedLinks.size) console.log('judged nothing: presses (no control and no link on any route)');
|
|
220
227
|
process.exit(failures.length ? 1 : 0);
|