zz-meridian 0.31.2 → 0.33.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.
Files changed (160) hide show
  1. package/dist/migrations.js +14 -0
  2. package/package.json +1 -1
  3. package/payload/CHANGELOG.md +85 -0
  4. package/payload/CONTRIBUTING.md +1 -1
  5. package/payload/app/embed/requests/view.tsx +2 -1
  6. package/payload/app/not-found.tsx +1 -1
  7. package/payload/app/system/(atlas)/page.tsx +8 -8
  8. package/payload/app/system/preview/[section]/[card]/keys.ts +2 -0
  9. package/payload/docs/atlas-scenario-coverage.md +4 -3
  10. package/payload/docs/benchmark.md +1 -1
  11. package/payload/docs/data-display.md +14 -2
  12. package/payload/docs/testing.md +7 -2
  13. package/payload/package.json +1 -1
  14. package/payload/scripts/assistant.ts +6 -2
  15. package/payload/scripts/audit.ts +21 -7
  16. package/payload/scripts/brand.ts +1 -1
  17. package/payload/scripts/check.ts +32 -2
  18. package/payload/scripts/gate.ts +11 -1
  19. package/payload/scripts/interactions.ts +8 -1
  20. package/payload/scripts/keyboard.ts +10 -2
  21. package/payload/scripts/lib/answered.ts +43 -0
  22. package/payload/scripts/lib/budgets.ts +1 -1
  23. package/payload/scripts/lib/chrome.ts +13 -4
  24. package/payload/scripts/lib/coverage.ts +10 -3
  25. package/payload/scripts/lib/phone.ts +17 -2
  26. package/payload/scripts/lib/timing.ts +16 -0
  27. package/payload/scripts/live.ts +1 -1
  28. package/payload/scripts/navigate.ts +19 -4
  29. package/payload/scripts/shot.ts +72 -38
  30. package/payload/scripts/verify.baseline.json +21 -21
  31. package/payload/scripts/verify.config.ts +15 -5
  32. package/payload/scripts/verify.ts +21 -20
  33. package/payload/scripts/vitals.ts +21 -9
  34. package/payload/skills/zz-meridian/SKILL.md +1 -1
  35. package/payload/skills/zz-meridian/references/cache.md +9 -10
  36. package/payload/skills/zz-meridian/references/existing-project.md +5 -2
  37. package/payload/skills/zz-meridian/references/standard.md +3 -2
  38. package/payload/skills/zz-meridian/references/update.md +6 -0
  39. package/payload/skills/zz-meridian/references/validation.md +9 -2
  40. package/payload/src/components/base/shell/index.tsx +2 -1
  41. package/payload/src/components/base/text-roles/README.md +2 -2
  42. package/payload/src/components/charts/bar-list/README.md +39 -5
  43. package/payload/src/components/charts/bar-list/index.tsx +85 -16
  44. package/payload/src/components/charts/bar-list/levels.tsx +33 -0
  45. package/payload/src/components/charts/bar-list/preview.tsx +79 -5
  46. package/payload/src/components/charts/column-chart/README.md +40 -21
  47. package/payload/src/components/charts/column-chart/grouped.tsx +205 -0
  48. package/payload/src/components/charts/column-chart/index.tsx +43 -137
  49. package/payload/src/components/charts/column-chart/preview.tsx +64 -2
  50. package/payload/src/components/charts/column-chart/shared.tsx +171 -0
  51. package/payload/src/components/charts/composition-bar/README.md +1 -1
  52. package/payload/src/components/charts/composition-bar/index.tsx +16 -4
  53. package/payload/src/components/charts/composition-bar/preview.tsx +5 -0
  54. package/payload/src/components/charts/diverging-bars/README.md +93 -0
  55. package/payload/src/components/charts/diverging-bars/index.tsx +151 -0
  56. package/payload/src/components/charts/diverging-bars/preview.tsx +141 -0
  57. package/payload/src/components/charts/scale.ts +55 -3
  58. package/payload/src/components/charts/sparkline/README.md +14 -2
  59. package/payload/src/components/charts/sparkline/index.tsx +18 -8
  60. package/payload/src/components/charts/sparkline/preview.tsx +10 -0
  61. package/payload/src/components/charts/trend-chart/README.md +18 -7
  62. package/payload/src/components/charts/trend-chart/index.tsx +91 -30
  63. package/payload/src/components/charts/trend-chart/preview.tsx +56 -4
  64. package/payload/src/components/charts/use-size.ts +4 -5
  65. package/payload/src/components/patterns/appearance-menu/index.tsx +1 -1
  66. package/payload/src/components/patterns/command-palette/index.tsx +1 -1
  67. package/payload/src/components/patterns/data-table/preview.tsx +1 -1
  68. package/payload/src/components/patterns/detail-head/README.md +1 -1
  69. package/payload/src/components/patterns/featured-metric/README.md +2 -2
  70. package/payload/src/components/patterns/featured-metric/index.tsx +1 -1
  71. package/payload/src/components/patterns/filter-bar/README.md +2 -2
  72. package/payload/src/components/patterns/filter-bar/index.tsx +5 -2
  73. package/payload/src/components/patterns/filter-bar/preview.tsx +19 -12
  74. package/payload/src/components/patterns/freshness/index.tsx +3 -2
  75. package/payload/src/components/patterns/metric-tile/index.tsx +8 -3
  76. package/payload/src/components/patterns/rail/index.tsx +4 -4
  77. package/payload/src/components/patterns/shell-tools/preview.tsx +2 -2
  78. package/payload/src/components/patterns/status-list/index.tsx +1 -1
  79. package/payload/src/components/templates/records/README.md +2 -2
  80. package/payload/src/components/templates/records/index.tsx +8 -2
  81. package/payload/src/components/templates/records/preview.tsx +2 -2
  82. package/payload/src/components/ui/dialog/README.md +4 -1
  83. package/payload/src/components/ui/dialog/index.tsx +2 -2
  84. package/payload/src/components/ui/dialog/preview.tsx +62 -3
  85. package/payload/src/components/ui/disclosure/README.md +89 -0
  86. package/payload/src/components/ui/disclosure/index.tsx +19 -0
  87. package/payload/src/components/ui/disclosure/preview.tsx +34 -0
  88. package/payload/src/components/ui/pagination/index.tsx +4 -3
  89. package/payload/src/components/ui/progress/index.tsx +2 -1
  90. package/payload/src/components/ui/radio-group/index.tsx +1 -1
  91. package/payload/src/data/read.ts +9 -10
  92. package/payload/src/lib/format.ts +51 -29
  93. package/payload/src/logic/customer-context.ts +5 -1
  94. package/payload/src/logic/customer-explore.ts +2 -1
  95. package/payload/src/logic/derivations.ts +5 -4
  96. package/payload/src/logic/fact-insight.ts +2 -1
  97. package/payload/src/logic/home-parts.ts +3 -2
  98. package/payload/src/logic/request-context.ts +5 -2
  99. package/payload/src/logic/requests-explore.ts +2 -1
  100. package/payload/src/logic/revenue-parts.ts +2 -1
  101. package/payload/src/logic/tools.ts +2 -2
  102. package/payload/src/styles/base.css +48 -7
  103. package/payload/src/styles/theme.css +0 -1
  104. package/payload/src/styles/tokens.css +0 -1
  105. package/payload/src/system/atlas-shell.tsx +3 -3
  106. package/payload/src/system/markdown.tsx +1 -1
  107. package/payload/src/system/preview-loaders.ts +2 -0
  108. package/payload/src/system/registry.ts +2 -0
  109. package/payload/src/system/sample-cells.tsx +2 -1
  110. package/payload/src/system/strata.tsx +1 -1
  111. package/payload/src/views/customer.tsx +2 -1
  112. package/payload/src/views/customers.tsx +3 -1
  113. package/payload/src/views/home.tsx +2 -1
  114. package/payload/src/views/keys.tsx +2 -2
  115. package/payload/src/views/members.tsx +11 -5
  116. package/payload/src/views/missing-page.tsx +2 -1
  117. package/payload/src/views/releases.tsx +2 -1
  118. package/payload/src/views/request.tsx +5 -3
  119. package/payload/src/views/requests.tsx +3 -1
  120. package/payload/src/views/revenue.tsx +3 -2
  121. package/payload/src/views/settings.tsx +2 -1
  122. package/payload/src/views/standalone.tsx +4 -1
  123. package/payload/src/views/tests.tsx +2 -1
  124. package/payload/src/views/tickets.tsx +2 -1
  125. package/payload/tests/atlas-product-slice.test.tsx +15 -2
  126. package/payload/tests/bar-list-axis-words.test.tsx +42 -0
  127. package/payload/tests/bar-list-forms.test.tsx +152 -0
  128. package/payload/tests/chart-scale-held.test.ts +99 -0
  129. package/payload/tests/chart-scale.test.ts +5 -0
  130. package/payload/tests/chart-semantics.test.tsx +155 -0
  131. package/payload/tests/column-chart-grouped.test.tsx +149 -0
  132. package/payload/tests/column-chart-stacked.test.tsx +1 -1
  133. package/payload/tests/composition-bar.test.tsx +13 -1
  134. package/payload/tests/derivations.test.ts +2 -0
  135. package/payload/tests/dialog-xl.test.tsx +25 -0
  136. package/payload/tests/disclosure.test.tsx +35 -0
  137. package/payload/tests/diverging-bars.test.tsx +116 -0
  138. package/payload/tests/feel-bar-list.test.tsx +22 -0
  139. package/payload/tests/feel-column-chart.test.tsx +27 -0
  140. package/payload/tests/feel-sparkline.test.tsx +21 -0
  141. package/payload/tests/forced-colors-states.test.ts +62 -0
  142. package/payload/tests/format-currency.test.ts +34 -0
  143. package/payload/tests/format-semantics.test.ts +99 -0
  144. package/payload/tests/hatch.test.ts +45 -0
  145. package/payload/tests/metric-tile-note.test.tsx +1 -1
  146. package/payload/tests/metric-tile-pointed-empty.test.tsx +2 -1
  147. package/payload/tests/request-view-replay.test.tsx +7 -0
  148. package/payload/tests/review-held-scales.test.tsx +81 -0
  149. package/payload/tests/sample.test.ts +16 -10
  150. package/payload/tests/settings-save.test.tsx +11 -0
  151. package/payload/tests/sparkline-forms.test.tsx +57 -0
  152. package/payload/tests/trend-chart-curve.test.tsx +57 -0
  153. package/payload/tests/trend-chart-forms.test.tsx +137 -0
  154. package/payload/tests/truncation-keeps-text.test.tsx +74 -0
  155. package/payload/tests/use-size-late.test.tsx +64 -0
  156. package/payload/tests/verify-coverage.test.ts +10 -1
  157. package/payload/tests/verify-sizes.test.ts +2 -2
  158. package/payload/tests/verify-suite-outcome.test.ts +6 -0
  159. package/payload/tests/verify-timing.test.ts +24 -1
  160. package/payload/tokens/core.tokens.json +0 -13
@@ -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.31.2",
3
+ "version": "0.33.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",
@@ -2,6 +2,91 @@
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.33.0] · 2026-10-28
6
+
7
+ The charts and parts a monitoring page needs (issue #99): bars in the order given, on a held and labelled scale, two values a row, signed rows about zero, straight and step lines with a dot per reading, grouped columns, a hatch for an estimate, a section that opens in place and a dialog wide enough for a table.
8
+
9
+ ### Breaking
10
+
11
+ - **A stacked Column chart's caption names its highlight as ringed.** Its table read `Accented:` for a highlighted column that wears the `ink` ring; it reads `Ringed:`, as the grouped form does. A plain column still reads `Accented:`. A test or a reader that looked for `Accented:` on a stacked chart looks for `Ringed:`.
12
+ - **A stacked column's parts are filled with `background-color`.** They were filled with the `background` shorthand, which would clear the estimate's hatch. The colours are unchanged; a test that read a part's `style.background` reads `style.backgroundColor`.
13
+ - **`useSize` returns a callback ref.** It returned a ref object and observed only the element present at mount, so a Trend chart's widening line that appeared later, when live data first crossed its `domain`, was never measured. `useSize()[0]` is now a callback that measures whatever element it is attached to, whenever it is attached, and stops when that element leaves: pass it only to a `ref` prop, and read the element some other way if code read its `.current`.
14
+
15
+ ### Added
16
+
17
+ - **A Bar list keeps the order given, holds its scale and reads two values a row.** `order="given"` keeps the rows in the order passed, for readings in time or cumulative shares. `max` holds the scale at a stated top, and a row past it widens the scale and is named: `Scale widened to 112% to show Beta`. `axis` draws the levels and their gridlines under the bars, in `text-2xs` and `ink-3`; a level word that would touch the word beside it is not drawn, and its gridline stays. `pair` reads each row against its `compare` value, drawn as an outline on the same scale under a legend. `estimate: true` on a row hatches its bar and writes `Hatched: estimate` once.
18
+ - **Diverging bars: signed rows about one zero line.** A new chart card for a change by category where some rose and some fell, on a scale symmetric about zero: `domain` holds it and a value past it widens it in words, the figure stands beyond each bar on the side away from zero, one `highlight` row with its `highlightLabel`, linked labels, hatched estimates, and every figure as text in a labelled list, so no table is needed behind it.
19
+ - **A Trend chart draws straight and step lines, a dot per reading, on a held scale.** `curve="linear"` or `"step"` draws a series (its stacked parts and its running stretch too) straight or held level between readings; `points` draws a dot on each reading, none on a running point; `domain={[min, max]}` holds the plot to a stated range so charts side by side share it, and a value outside it widens the levels and says so above the plot and in the table's caption: `Scale widened from 90.0%–100.0% to 80.0%–100.0%`. A line wrapped to two on a phone card moves the plot down. `domain` and `floor` are not passed together.
20
+ - **A Sparkline takes `curve` and `domain`.** `curve="linear"` or `"step"` draws it straight or in steps, as the Trend chart does. `domain` holds its scale so sparklines side by side compare, and the title names the range drawn: the domain, `0 to 100`, while every value is inside it; widened, when a value is outside it, `0 to 150, scale widened from 0–100`.
21
+ - **A Column chart groups its parts.** `grouped` with two or three `parts` stands each column's parts side by side, each an identity in its categorical slot: a legend, a value above each bar of 36px or more, a part under zero hanging from the zero line, a part with no data keeping its slot with the dashed no-data hairline, and no Total in the readout or the table. A highlighted or pointed group wears the `ink` ring. The grouped form is loaded only by a chart that passes `grouped`, so no other route's first load grows.
22
+ - **A Column chart marks an estimate.** `estimate: true` on a plain, stacked or grouped column hatches its fill, says "estimate" in the readout, the announcement and the table, and writes `Hatched: estimate` once under the labels.
23
+ - **The hatch: an estimate without a hue.** `[data-estimate]` is a base role: diagonal stripes in the surface's colour over the mark's own fill, so an estimated mark spends no colour or tone. Under forced colours (Windows' contrast themes) it keeps its stripes in `CanvasText` over `Canvas`, so an estimate never reads as a measured figure.
24
+ - **A section opens in place: the Disclosure.** `Disclosure` (`summary`, `defaultOpen`, `className`) is the native `<details>` with a summary row and a chevron that turns, so the browser owns the press, the keyboard and the open state and no client code ships. Use it for what a reader opens on purpose, such as the readings behind a figure; never for the figure itself.
25
+ - **A dialog wide enough for a table.** `DialogContent size="xl"` is 960px on a desk and the same bottom sheet on a phone. A table of up to six columns goes in an `xl` dialog; past that, or when the reader needs the page beside it, it goes on its own page.
26
+ - **The scale helpers behind them.** `heldTicks(min, max)` in `src/components/charts/scale.ts` gives levels that end exactly at both ends of a held range, and `linearPath` and `stepPath` draw the straight and step lines beside `monotonePath`.
27
+ - **`docs/data-display.md` names each new form** in "Choose the form first": bars in the order given, two values a row, signed rows, grouped columns, straight and step lines, a held scale and an estimate, each with its rule.
28
+
29
+ ### Changed
30
+
31
+ - **The first-load cap is 830 KiB.** It was 820 KiB; `/engineering`, which draws the Trend chart, Column chart, Bar list and Sparkline together, is 823 KiB with this release's forms. `FIRST_LOAD_CAP_KB` in `scripts/lib/budgets.ts` and the `## Budgets` row of `docs/testing.md` say 830; a project that set its own cap keeps it.
32
+
33
+ ### Fixed
34
+
35
+ - **A page too tall to capture whole says so.** `node scripts/shot.ts --full` died on a bare `Page.captureScreenshot timed out` when a page was taller than Chrome captures at once (the Column chart's preview, 12,047px at 1440 and 2x), and took the rest of the run with it. The shot prints the route, width, theme and the page's height in CSS and device pixels, and says to shoot it without `--full` or one part with `--press` or a narrower route; the browser stuck on that capture is closed, the run goes on in a fresh one, and it exits 1.
36
+
37
+ ## [0.32.0] · 2026-10-27
38
+
39
+ ### Added
40
+
41
+ - **`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.
42
+ - **`--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.
43
+ - **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`.
44
+ - **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.
45
+ - **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.
46
+ - **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.
47
+
48
+ ### Changed
49
+
50
+ - **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`.
51
+ - **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.
52
+ - **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.
53
+
54
+ ### Fixed
55
+
56
+ - **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)`.
57
+ - **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.
58
+ - **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.
59
+ - **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.
60
+ - **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.
61
+ - **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.
62
+ - **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.
63
+ - **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.
64
+ - **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.
65
+ - **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.
66
+ - **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.
67
+ - **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.
68
+ - **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.
69
+ - **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:
70
+ - **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.
71
+ - **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.
72
+ - **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`).
73
+ - **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.
74
+ - **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.
75
+ - **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.
76
+ - **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.
77
+ - **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.
78
+ - **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.
79
+ - **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.
80
+
81
+ ### Breaking
82
+
83
+ - **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.
84
+ - **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.
85
+ - **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.
86
+ - **`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.
87
+ - **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.
88
+ - **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.
89
+
5
90
  ## [0.31.2] · 2026-10-27
6
91
 
7
92
  ### Fixed
@@ -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 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, 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` |
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(`/requests/${r.id}`)}
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
- <div className="grid gap-px overflow-hidden rounded-xl border border-line bg-line sm:grid-cols-2 lg:grid-cols-5">
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], i) => (
95
- <div key={t} className="bg-surface p-6">
96
- <p className="t-eyebrow">0{i + 1}</p>
97
- <p className="t-card mt-6">{t}</p>
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
- </div>
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-mono tracking-[0.08em] uppercase">Generated from this repository</span>
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>
@@ -19,6 +19,7 @@ export const PREVIEW_KEYS: string[] = [
19
19
  'components/copy-field',
20
20
  'components/delta',
21
21
  'components/dialog',
22
+ 'components/disclosure',
22
23
  'components/empty-state',
23
24
  'components/field',
24
25
  'components/icon-button',
@@ -68,6 +69,7 @@ export const PREVIEW_KEYS: string[] = [
68
69
  'patterns/bar-list',
69
70
  'patterns/column-chart',
70
71
  'patterns/composition-bar',
72
+ 'patterns/diverging-bars',
71
73
  'patterns/heatmap',
72
74
  'patterns/meridian',
73
75
  'patterns/sparkline',
@@ -79,12 +79,13 @@ This is the inventory of the whole Meridian sample: every job the product is mea
79
79
  | shared | Pattern: Shell tools | search pill and alerts — `src/components/patterns/shell-tools/index.tsx` | L3 | `src/components/patterns/shell-tools` | keep | Used by the dashboard layout and the states layout. The alerts panel lists what needs a person across the product and links to where each is handled; the layout hands it alerts worked out from the sample's records, and the card's own preview keeps its fixture. It is the closest thing to A7. | `src/components/patterns/shell-tools` | `/system/patterns/shell-tools` | all console pages | normal, empty | `tests/clock.test.tsx`, `tests/shell-alerts.test.ts` |
80
80
  | S5 | Pattern: Status list | every service, its 90-day history and, when asked, how long it has been operational — `src/components/patterns/status-list/index.tsx` | L3 | `src/components/patterns/status-list` | keep | Used by Now, by Services and by the Now embed. With `run` each row says how long the service has been operational, and that the run is its longest when no run among the days kept is longer; Now and Services ask for it. It is also the nearest composition for release lifecycle (S8) and run verdicts (S7). | `src/components/patterns/status-list` | `/system/patterns/status-list` | Now, Services, Now embed | normal, empty | `tests/status-list.test.tsx`, `tests/plural.test.ts` |
81
81
  | shared | Chart: Bar list | rank items by one measure — `src/components/charts/bar-list/index.tsx` | L3 | `src/components/charts/bar-list` | keep | Used by Usage, Revenue, One customer and Now. | `src/components/charts/bar-list` | `/system/patterns/bar-list` | Usage, Revenue, One customer, Now | normal, empty | `scripts/audit.ts` |
82
- | shared | Chart: Column chart | compare counts side by side, or read each period as a whole split into its parts — `src/components/charts/column-chart/index.tsx` | L3 | `src/components/charts/column-chart` | keep | Used by Usage for the hours of the day, which are sums over the heatmap's rows, with the busiest hour highlighted. On that chart the card writes a value above each column while a column is at least 36px wide, is one Tab stop whose arrow keys move the cursor the pointer moves, and reads the column pointed at to a screen reader. The card also draws each column as its parts, in one hue or in tones when the parts are outcomes, with a legend, a fold into Other past four parts, a readout and a table of every part and total; marks a last column that is a period still running and says so far; and takes its card's height when asked. Home draws requests and revenue a period with the latest highlighted, Revenue draws its periods as parts in one hue, and Now its verification runs as parts in tones with the day in progress running; no page takes its card's height yet, and the card's preview does. | `src/components/charts/column-chart` | `/system/patterns/column-chart` | Main (`/`), Usage, Revenue, Now | normal, empty, partial | `tests/column-chart.test.tsx`, `tests/column-chart-stacked.test.tsx`, `scripts/audit.ts` |
82
+ | shared | Chart: Column chart | compare counts side by side, or read each period as a whole split into its parts — `src/components/charts/column-chart/index.tsx` | L3 | `src/components/charts/column-chart` | keep | Used by Usage for the hours of the day, which are sums over the heatmap's rows, with the busiest hour highlighted. On that chart the card writes a value above each column while a column is at least 36px wide, is one Tab stop whose arrow keys move the cursor the pointer moves, and reads the column pointed at to a screen reader. The card also draws each column as its parts, in one hue or in tones when the parts are outcomes, with a legend, a fold into Other past four parts, a readout and a table of every part and total; marks a last column that is a period still running and says so far; stands two or three identities side by side as a grouped form, with no Total, loaded only on a page that draws one; hatches an estimated column in every form and says estimate; and takes its card's height when asked. Home draws requests and revenue a period with the latest highlighted, Revenue draws its periods as parts in one hue, and Now its verification runs as parts in tones with the day in progress running; no page takes its card's height yet, and the card's preview does. | `src/components/charts/column-chart` | `/system/patterns/column-chart` | Main (`/`), Usage, Revenue, Now | normal, empty, partial | `tests/column-chart.test.tsx`, `tests/column-chart-stacked.test.tsx`, `tests/column-chart-grouped.test.tsx`, `scripts/audit.ts` |
83
83
  | shared | Chart: Composition bar | split one whole into parts — `src/components/charts/composition-bar/index.tsx` | L3 | `src/components/charts/composition-bar` | keep | Used by Usage for how the last 30 complete days were answered, by status class in status colours, and by Home and Revenue for what is sold and who pays. Single use is not a reason to remove a card the Atlas teaches. | `src/components/charts/composition-bar` | `/system/patterns/composition-bar` | Main (`/`), Revenue, Usage | normal | `scripts/audit.ts` |
84
+ | shared | Chart: Diverging bars | signed rows either side of one zero line — `src/components/charts/diverging-bars/index.tsx` | L3 | `src/components/charts/diverging-bars` | keep | Added for issue #99: a signed change by category on a scale symmetric about zero, held at a domain or widened past it in words, the neutral population with one accent row, linked labels and hatched estimates. No page of the sample draws it yet; the Atlas teaches it, and its preview draws every state from the sample's customers against the company. | `src/components/charts/diverging-bars` | `/system/patterns/diverging-bars` | none: no page draws it yet; the Atlas preview does | normal, empty, partial | `tests/diverging-bars.test.tsx`, `tests/chart-semantics.test.tsx`, `scripts/audit.ts` |
84
85
  | shared | Chart: Heatmap | weekday by hour load, or a year of days — `src/components/charts/heatmap/index.tsx` | L3 | `src/components/charts/heatmap` | keep | One consumer, Usage, which draws the rows of the `hours` collection, requests for each UTC weekday and hour, and every day kept. It answers when traffic comes, which is Usage's job, in Business. Usage also draws a year of days as a calendar, a cell a date and a column a week, joined to the page's Meridian, and its weekday-and-hour grid ranked by quantile. | `src/components/charts/heatmap` | `/system/patterns/heatmap` | Usage | normal, empty | `tests/usage-figures.test.ts`, `tests/heatmap-calendar.test.tsx`, `tests/heatmap-calendar-timezone.test.tsx`, `tests/heatmap-scale.test.tsx`, `scripts/audit.ts` |
85
86
  | shared | Chart: Meridian | one time cursor shared by every chart on a page — `src/components/charts/meridian/index.tsx` | L3 | `src/components/charts/meridian` | keep | Joined by the trend and column charts, the featured metric, the metric tile, the Home, Revenue, Usage and Now, the home embed and the sign-in screen. It is the system's namesake interaction. | `src/components/charts/meridian` | `/system/patterns/meridian` | Main (`/`), Revenue, Usage, Now, home embed, sign-in | normal | `tests/meridian.test.tsx` |
86
- | shared | Chart: Sparkline | trend shape beside a figure — `src/components/charts/sparkline/index.tsx` | L3 | `src/components/charts/sparkline` | keep | Used by the metric tile and the Customers table column. It marks the day the page points at only when it has one value for each of the Meridian's dates. | `src/components/charts/sparkline` | `/system/patterns/sparkline` | Customers, metric tile consumers | normal | `tests/sparkline.test.tsx` |
87
- | shared | Chart: Trend chart | measures over time on one axis — `src/components/charts/trend-chart/index.tsx` | L3 | `src/components/charts/trend-chart` | keep | Used by the Home, Revenue, Usage, Now, Services, the home embed and the sign-in screen. Its partial state is a day with no measurement, drawn as a gap, and a last point that is a period still running: the stretch into it is dashed with nothing filled under it, and its readout and its row of the table say so far. Home, Revenue, Usage and Services set a running point. | `src/components/charts/trend-chart` | `/system/patterns/trend-chart` | Main (`/`), Revenue, Usage, Now, Services, home embed, sign-in | normal, partial | `tests/trend-chart.test.tsx`, `tests/trend-chart-running.test.tsx` |
87
+ | shared | Chart: Sparkline | trend shape beside a figure — `src/components/charts/sparkline/index.tsx` | L3 | `src/components/charts/sparkline` | keep | Used by the metric tile and the Customers table column. It marks the day the page points at only when it has one value for each of the Meridian's dates. `curve` draws it smooth, straight or in steps; `domain` holds its scale and widens, saying so in its title, for a value outside it. | `src/components/charts/sparkline` | `/system/patterns/sparkline` | Customers, metric tile consumers | normal | `tests/sparkline.test.tsx`, `tests/sparkline-forms.test.tsx` |
88
+ | shared | Chart: Trend chart | measures over time on one axis — `src/components/charts/trend-chart/index.tsx` | L3 | `src/components/charts/trend-chart` | keep | Used by the Home, Revenue, Usage, Now, Services, the home embed and the sign-in screen. Its partial state is a day with no measurement, drawn as a gap, and a last point that is a period still running: the stretch into it is dashed with nothing filled under it, and its readout and its row of the table say so far. Home, Revenue, Usage and Services set a running point. | `src/components/charts/trend-chart` | `/system/patterns/trend-chart` | Main (`/`), Revenue, Usage, Now, Services, home embed, sign-in | normal, partial | `tests/trend-chart.test.tsx`, `tests/trend-chart-running.test.tsx`, `tests/trend-chart-forms.test.tsx`, `tests/cli-trend-chart-forms.test.tsx` |
88
89
  | shared | Chart: Uptime bars | recent days of one service as one SVG — `src/components/charts/uptime-bars/index.tsx` | L3 | `src/components/charts/uptime-bars` | keep | Used by the status list, on Now and Services, and by Now directly for the days inside the band. | `src/components/charts/uptime-bars` | `/system/patterns/uptime-bars` | Now, Services | normal | `tests/uptime-svg.test.tsx` |
89
90
  | A1 | Act on several rows at once (revoke or export many keys or tickets) | none | L3 | `src/components/patterns/data-table` | defer | Nothing was built. The Data table has an optional selection column, but no page turns it on, and the only bulk write the spec could use (revoke many) has no backend operation. An agent's bulk action is already a Proposal. It comes back when a page has a bulk write with a backend operation to call. | none | none: nothing was built, so there is no entry to specify | none | none | none |
90
91
  | A2 | Return to a filtered view without rebuilding it | none | L3 | `src/components/patterns/filter-bar`, `app/(dashboard)/requests` | defer | Nothing was built. The filtered Requests view is already a link, because the filters live in the address, and a bookmark meets the job today. A saved, named view needs per-person storage the sample does not have; it comes back when a record for a person's views exists. | none | none: nothing was built, so there is no entry to specify | none | none | none |
@@ -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 a mono for kickers and identifiers; 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 |
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) |
@@ -11,17 +11,25 @@ How Meridian shows numbers: which form to use, how colour is assigned, how figur
11
11
  | A number with its change and shape | A tile | Metric tile |
12
12
  | The same number for many things at once (eight customers, six regions), each with its change and shape | A wall of compact tiles: the name, the change, one line and the shape | Metric tile |
13
13
  | Change over time | A line or an area on one axis | Trend chart |
14
+ | A few readings over a long time (weekly figures, a handful of samples), one a period | Straight segments with a point on each reading (`curve="linear"`, `points`); a period with no reading is `null`, since a reading stands at its index, not its time | Trend chart |
15
+ | A level that changes at events (a balance, a limit, seats on a plan) | Steps: each value held until the next reading (`curve="step"`) | Trend chart |
14
16
  | The shape of a trend beside a figure | A line without axes | Sparkline |
15
17
  | A ranking | Horizontal bars, the longest first | Bar list |
18
+ | A ranking whose order is its meaning (the hours of a day, the steps of a funnel) | Horizontal bars in the order given (`order="given"`) | Bar list |
19
+ | A ranking beside an earlier reading (this week against last) | Horizontal bars, each with its earlier value as an outline under it (`pair`) | Bar list |
20
+ | A signed change by category (a net change, a customer ahead of or behind the company) | Rows either side of one zero line | Diverging bars |
16
21
  | Parts of a whole, at one moment | One stacked bar | Composition bar |
17
22
  | Counts per category or per period | Columns | Column chart |
18
23
  | Parts of a whole over periods, each period a total to read (weeks, months, a handful of days) | Stacked columns, each column a whole | Column chart |
24
+ | Two or three identities a period that are never summed (money in and money out, this year and last) | Columns side by side in each period, with no total (`grouped`) | Column chart |
19
25
  | Parts of a whole over a long run of days, where the shape is the story | Stacked bands | Trend chart |
20
26
  | Two dimensions of time (weekday by hour, or a year of days as week by weekday) | A grid of cells | Heatmap |
21
27
  | A service's history | Day bars | Uptime bars |
22
28
  | How long a service has been operational | One line under its day bars: its run | Status list |
23
29
  | An incident, step by step | A timeline, newest first, each update timed from the incident's start | Incident card |
24
30
  | Many records | A table | Data table |
31
+ | The same measure for several things, compared row by row (one chart a service, a plan or a region) | Charts on one held scale (`domain`, `max`) | Trend chart, Sparkline, Bar list, Diverging bars |
32
+ | A value derived rather than measured (a forecast, a day not yet reconciled) | Its mark hatched, with "Hatched: estimate" under the chart | Bar list, Column chart, Diverging bars |
25
33
 
26
34
  Sometimes the answer is not a chart: one number and a sentence beat a chart with one interesting point.
27
35
 
@@ -29,10 +37,12 @@ Sometimes the answer is not a chart: one number and a sentence beat a chart with
29
37
 
30
38
  Two measures of different size are two charts on one Meridian, not one chart with a second scale. A dashed comparison series is allowed only when it is the same unit, such as the previous period. Never scale one series to fit another ("Errors × 20"): the tooltip would print a number that is not true.
31
39
 
40
+ The same measure for several things, one chart each, is drawn on one held scale so the charts compare row by row: `domain` on a Trend chart, a Sparkline and Diverging bars, `max` on a Bar list. A held scale widens rather than clips. A value outside it takes the scale to the levels that hold it, and the chart says so in words: a Trend chart in a line above its plot and at the end of its table's caption ("Scale widened from 90.0%–100.0% to 80.0%–100.0%"), a Bar list in a line under its rows ("Scale widened to 140 to show Gamma"), Diverging bars in a line above theirs ("Scale widened to +35 to show Delta") and a Sparkline, which has no room for a line, in its title. A range is never chosen tighter than the data to make a change look larger: the chart widens and says so.
41
+
32
42
  ## Colour by job
33
43
 
34
- - **Identity** (which series): the six categorical slots in their fixed order, `series-1` to `series-6`. Assign in order; never skip; a seventh series folds into Other. The order is validated for colour-vision deficiency in both themes (`node scripts/contrast.ts`).
35
- - **The one that matters**: the accent, with every other mark in the neutral population (`chart-neutral`). One highlighted bar, one highlighted line.
44
+ - **Identity** (which series): the six categorical slots in their fixed order, `series-1` to `series-6`. Assign in order; never skip; a seventh series folds into Other. The order is validated for colour-vision deficiency in both themes (`node scripts/contrast.ts`). A grouped Column chart's parts are identities side by side, never the parts of a whole: they take the slots in order and never the accent, a pointed or highlighted group wears the `ink` ring instead, and nothing is summed. Past three identities, or over a long run of periods, the form is a Trend chart with a line a series.
45
+ - **The one that matters**: the accent, with every other mark in the neutral population (`chart-neutral`). One highlighted bar, one highlighted line. Diverging bars are the neutral population with one accent row, never coloured by their sign: which side of the zero line a bar stands on, and the figure's sign, already say it. Values all of one sign are a Bar list.
36
46
  - **Magnitude**: one hue, light to dark, built from the accent and the surface (the heatmap).
37
47
  - **Magnitude by rank**: a Heatmap's step is a cell's share of the largest cell (`max`) unless one outlier would leave every other cell at the lowest step. Then it is ranked (`scale="quantile"`): the cells above zero take five steps of equal count. A step no longer says how much, so the legend's upper end reads the value the top step starts at ("900 or more") in place of "More", and the readout and the hidden table give each cell's exact value.
38
48
  - **State**: positive, warning, critical, always with a word or an icon.
@@ -48,6 +58,8 @@ Text never wears a series colour; values and labels stay in ink.
48
58
  - Gridlines recessive (`chart-grid`), one baseline (`chart-axis`), axis labels at `text-2xs` in `ink-3`. Axis labels are a scale: `$12`, `1.2M`; the exact value is in the tooltip and the table.
49
59
  - A legend is always present for two or more series or parts; a single series is named by its title.
50
60
  - A mark that encodes something says what, and the component says it: on the pointer, and to a screen reader where the mark is the only carrier. A Bar list row names its row and its share, an accented column says why it is the accent (`highlightLabel`), a marked day says what it is marked for, a Sparkline says the range it draws, a Delta says whether its colour reads better or worse, and a Status dot or an activity's tone carries its word as its label. When the meaning is structural — a dashed stretch into a period still running, a delta's direction — the component draws the key or the sentence itself. A new chart is held to this by a reviewer: a colour, a fill or a size a reader is meant to decode, with nothing to decode it with, is not finished.
61
+ - A compare is the earlier reading (last week, a target) drawn as an unfilled outline on the same scale as the value, never a second fill: a Bar list's `pair` draws it as a 1px `chart-neutral-strong` outline under each bar, the figure reads the value and then the compare in `ink-3`, and a legend names both.
62
+ - An estimate, a value derived rather than measured, is told by a hatch and words, never by a colour: its mark keeps its own fill with stripes of the surface over it, it says "estimate" wherever it is read (a row's title, the readout, the announcement, the table), and the chart writes "Hatched: estimate" once. A Bar list, a Column chart and Diverging bars take `estimate`. A period with no data is not an estimate: there is no figure to hatch.
51
63
  - A column's value stands above it at rest, in `ink-3`, while a column is at least 36px wide; narrower, it shows for the column pointed at. Stacked, the value is the column's total.
52
64
 
53
65
  ## A period still in progress
@@ -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`.
@@ -104,6 +108,7 @@ A limit that moved, with what it was set from (`## Rules`, Limits).
104
108
  | Limit | Old | New | Run | Machine | Commit | Samples | p95 | Margin |
105
109
  |---|---|---|---|---|---|---|---|---|
106
110
  | `/system/start/start-a-dashboard` phone warm shell, its own `budget` in `scripts/verify.config.ts` | 300 ms | 250 ms, the shared phone warm shell: the route's own figure is withdrawn | weekly `perf` shard 5/5, run 39145209850 | GitHub's `ubuntu-24.04` runner, the phone at 3.2x | `dfdad67` (withdrawn in `55b7ece`) | 20 | 58 ms (median 43, max 59) | none: a withdrawal returns to the shared figure, which holds the p95 with room |
111
+ | First-load JS cap, every route (`FIRST_LOAD_CAP_KB` in `scripts/lib/budgets.ts`) | 820 KiB | 830 KiB, the owner's decision for 0.33.0: the cap is not a limit `## Rules` lets move, so it went to them with the count | `node scripts/sizes.ts` after `pnpm build` of Phase 1 wave 1 of issue #99 | local, macOS | `bf0fda4` plus wave 1 | 1 (a byte count does not vary between builds of one tree) | n/a | `/engineering` 823 KiB, 7 KiB under: its headroom went to growth since `09d9e0b` (5 to 23 KiB on every large route) and 5 KiB to the #99 forms |
107
112
 
108
113
  ## The classification
109
114
 
@@ -122,7 +127,7 @@ Every check Meridian runs has one row, named by a key: a gate step, a group of t
122
127
  | `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
128
  | `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
129
  | `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` | 782 s at fa5bbff | Meridian's repository only. The check scripts against browser fixtures: each planted defect is found and each corrected twin passes. |
130
+ | `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
131
  | `verify/build` | large | functional | change | no | `pnpm verify` | 9.6 s at 40d29c7 | One production build. |
127
132
  | `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
133
  | `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 +135,7 @@ Every check Meridian runs has one row, named by a key: a gate step, a group of t
130
135
  | `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
136
  | `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
137
  | `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 five widths in both themes, for layout and accessibility defects. |
138
+ | `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
139
  | `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
140
  | `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
141
  | `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). |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.31.2",
3
+ "version": "0.33.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
- const na = (what: string, why: string) => console.log(`n/a assistant ${expect}: ${what} (${why})`);
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: innerWidth }; })()`);
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
  }
@@ -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; --routes replaces the pages for one run.
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 served another page or dropped a parameter it was asked for, a page whose content
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 ROUTES = EMBEDS_ONLY ? [] : ATLAS ? await atlasRoutes() : (opt('--routes', '') ? opt('--routes', '').split(',') : [...found.filter((r) => !r.startsWith('/embed')), ...detailRoutes]);
75
- const EMBEDS = ATLAS ? [] : found.filter((r) => r.startsWith('/embed/'));
76
- const WIDTHS = ATLAS ? [1440, 390] : [2560, 1440, 1024, 768, 390];
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
- const W = innerWidth, 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 };
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
  //
@@ -219,7 +219,7 @@ if (has('--product')) {
219
219
  for (const f of walkDirs('src/components')) if (/\/(README\.md|preview\.tsx)$/.test(f)) fs.rmSync(file(f));
220
220
  for (const f of walkDirs('app')) if (/\/README\.md$/.test(f)) fs.rmSync(file(f));
221
221
  // A test of a module removed here goes with it (tests/cli-product-prune.test.ts holds the two lists together).
222
- for (const f of ['src/system/specimen.tsx', 'tests/specimen-still.test.tsx', 'tests/atlas-product-slice.test.tsx', 'tests/atlas-template-contracts.test.tsx', 'src/system/registry.ts', 'CONTRIBUTING.md', 'CHANGELOG.md']) fs.rmSync(file(f), { force: true });
222
+ for (const f of ['src/system/specimen.tsx', 'tests/specimen-still.test.tsx', 'tests/atlas-product-slice.test.tsx', 'tests/atlas-template-contracts.test.tsx', 'tests/dialog-xl.test.tsx', 'src/system/registry.ts', 'CONTRIBUTING.md', 'CHANGELOG.md']) fs.rmSync(file(f), { force: true });
223
223
  for (const d of ['docs', 'decisions', 'skills']) fs.rmSync(file(d), { recursive: true, force: true });
224
224
  const p = json('package.json');
225
225
  delete p.scripts?.registry;