zz-meridian 0.28.0 → 0.29.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 (38) hide show
  1. package/dist/ownership.js +19 -2
  2. package/dist/session.js +9 -1
  3. package/package.json +1 -1
  4. package/payload/CHANGELOG.md +20 -0
  5. package/payload/app/system/(atlas)/page.tsx +7 -1
  6. package/payload/docs/data-display.md +1 -0
  7. package/payload/package.json +1 -1
  8. package/payload/scripts/lib/cls.ts +19 -0
  9. package/payload/scripts/vitals.ts +5 -4
  10. package/payload/skills/zz-meridian/references/ownership.md +1 -1
  11. package/payload/src/components/base/shell/index.tsx +21 -4
  12. package/payload/src/components/charts/bar-list/index.tsx +9 -1
  13. package/payload/src/components/charts/column-chart/index.tsx +7 -2
  14. package/payload/src/components/charts/sparkline/index.tsx +4 -1
  15. package/payload/src/components/charts/trend-chart/index.tsx +7 -2
  16. package/payload/src/components/patterns/activity-feed/index.tsx +3 -1
  17. package/payload/src/components/patterns/filter-bar/index.tsx +4 -1
  18. package/payload/src/components/patterns/incident-card/index.tsx +3 -1
  19. package/payload/src/components/ui/card/index.tsx +8 -3
  20. package/payload/src/components/ui/delta/index.tsx +5 -0
  21. package/payload/src/components/ui/key-value/index.tsx +8 -1
  22. package/payload/src/components/ui/status-dot/index.tsx +11 -3
  23. package/payload/src/lib/collection.ts +12 -4
  24. package/payload/src/lib/format.ts +18 -5
  25. package/payload/src/lib/safe-markdown.ts +20 -3
  26. package/payload/src/views/home.tsx +2 -0
  27. package/payload/src/views/now.tsx +1 -0
  28. package/payload/src/views/usage.tsx +1 -1
  29. package/payload/tests/card-header-title.test.tsx +15 -0
  30. package/payload/tests/collection.test.ts +38 -1
  31. package/payload/tests/env-not-shared.test.ts +24 -0
  32. package/payload/tests/filter-bar-search.test.tsx +29 -0
  33. package/payload/tests/format.test.ts +20 -2
  34. package/payload/tests/key-value.test.tsx +13 -0
  35. package/payload/tests/marks-name-what-they-encode.test.tsx +98 -0
  36. package/payload/tests/page-frame-title.test.tsx +31 -0
  37. package/payload/tests/prose.test.tsx +7 -0
  38. package/payload/tests/setup.ts +12 -1
package/dist/ownership.js CHANGED
@@ -17,7 +17,7 @@ export const OWNERSHIP = [
17
17
  'Managed: the distributed scripts under `scripts/`, except `scripts/verify.config.ts`, the size baseline `optional:scripts/verify.baseline.json` (first-load JS and the HTML of each page) and a team-local `optional:scripts/check.local.ts`.',
18
18
  'Managed: the library helpers `src/lib/cn.ts`, `src/lib/format.ts`, `src/lib/format-date.ts`, `src/lib/period.ts`, `src/lib/color.ts`, `src/lib/host.ts`, `src/lib/preferences.ts`, `src/lib/csv.ts`, `src/lib/safe-markdown.ts`, `src/lib/logo.ts`, `src/lib/collection.ts`, `src/lib/explore.ts`, `src/lib/live.ts`, `src/lib/shared-context.ts`, `src/lib/agent-guidance.ts`, `src/lib/insight.ts` and `src/lib/facts.ts`, and the assistant prompt `src/lib/assistant/prompt.ts`.',
19
19
  'Managed: the three `src/logic/` helpers a shipped component imports — `src/logic/after-start.ts`, `src/logic/derivations.ts` and `src/logic/summarise.ts` — so a payload\'s cards build in an adopted project; every other module under `src/logic/` is the team\'s.',
20
- 'Managed: `src/views/console-chrome.tsx` and `tests/setup.ts`.',
20
+ 'Managed: `src/views/console-chrome.tsx`, `tests/setup.ts`, and — in a project `create` made — every test the release ships. A shipped test pins the behaviour of the module it covers, so a release that changes one changes the other; a test the team edits is staged for its decision like any other edited file.',
21
21
  'Managed: `optional:scripts/package.json`, only when the adopt or create that set the project up generated it for this project shape.',
22
22
  'Managed: both installed skill trees, `optional:.agents/skills/zz-meridian/` and `optional:.claude/skills/zz-meridian/`, taken from the release\'s skill payload.',
23
23
  'Managed: the brand token and stylesheet paths under `tokens/` and `src/styles/` that the release generated from the recorded brand.',
@@ -27,6 +27,15 @@ export const OWNERSHIP = [
27
27
  ];
28
28
  /** The rule as a markdown bullet list, for `references/ownership.md`. */
29
29
  export const renderOwnership = () => OWNERSHIP.map((l) => `- ${l}`).join('\n');
30
+ /**
31
+ * The managed set as one path pattern, for a caller that holds a manifest but no payload list — the consumer smoke checks
32
+ * a manifest read from a tarball it never enumerates. `managedPaths` is the rule; this is that rule said once more in a
33
+ * regex, and `tests/cli-ownership.test.ts` holds the two together over the payload paths it knows. The tests clause is the
34
+ * `create` route's, so it is permissive for an adopted project — whose manifest never names a test but `tests/setup.ts`.
35
+ */
36
+ export const MANAGED_PATH = /^(tokens\/|src\/styles\/|src\/components\/|scripts\/|src\/lib\/|\.agents\/|\.claude\/)|^(src\/views\/console-chrome\.tsx|tests\/(?:[^/]+\.test\.tsx?|setup\.ts)|src\/logic\/(?:after-start|derivations|summarise)\.ts)$/;
37
+ /** A path inside the managed folders that is the team's all the same: their own views, their app, their docs, their config. */
38
+ export const NEVER_MANAGED = (p) => p === 'src/app.config.ts' || p === 'scripts/verify.config.ts' || p.startsWith('app/') || p.startsWith('docs/') || (p.startsWith('src/views/') && p !== 'src/views/console-chrome.tsx');
30
39
  /** The raw `.meridian/manifest.json`, checked field by field; anything off throws an Error that names it. */
31
40
  export function parseManifest(text) {
32
41
  let raw;
@@ -75,8 +84,16 @@ export function adoptSetOf(payloadFiles) {
75
84
  * routes. `generated` is every path a replay or a real run produced; only the ones under `tokens/` or `src/styles/`
76
85
  * and `scripts/package.json` become managed, so a generated page or config stays the team's.
77
86
  */
78
- export function managedPaths(payloadFiles, _route, generated = []) {
87
+ export function managedPaths(payloadFiles, route, generated = []) {
79
88
  const out = new Set(adoptSetOf(payloadFiles));
89
+ // A created project's tests are Meridian's: it wrote every one of them, and each pins the behaviour of the module it
90
+ // covers. Left the team's, a release that changed `splitFigure` could not change the test that reads it, and the
91
+ // project's own gate failed after an update that was otherwise correct (0.29.0's consumer smoke). Adopt still copies
92
+ // none of them: that project has a test suite of its own.
93
+ if (route === 'create')
94
+ for (const f of payloadFiles)
95
+ if (/^tests\/[^/]+\.test\.tsx?$/.test(f))
96
+ out.add(f);
80
97
  for (const g of generated)
81
98
  if (g.startsWith('tokens/') || g.startsWith('src/styles/') || g === 'scripts/package.json')
82
99
  out.add(g);
package/dist/session.js CHANGED
@@ -154,7 +154,15 @@ function plan(ctx, manifest, startedAt) {
154
154
  const inBase = baseManaged.has(p);
155
155
  const inTarget = targetManaged.has(p);
156
156
  const managed = inBase || inTarget;
157
- const recorded = manifest.files[p] ?? (inBase && !sourcePayload.has(p) ? source.hashes.get(p) : undefined) ?? null;
157
+ // What the recorded release wrote at this path: the manifest's hash, or — for a project `create` made, which received
158
+ // every payload file — the recorded release's own bytes. The second term is what made 0.29.0's tests unclassifiable:
159
+ // a test that release shipped but did not record (it managed none but `tests/setup.ts` then) had no hash to compare
160
+ // with, so an unedited copy of it collided instead of being replaced, and the update stopped for a decision on a file
161
+ // nobody had touched.
162
+ const recorded = manifest.files[p]
163
+ ?? (manifest.route === 'create' && sourcePayload.has(p) ? source.hashes.get(p) : undefined)
164
+ ?? (inBase && !sourcePayload.has(p) ? source.hashes.get(p) : undefined)
165
+ ?? null;
158
166
  const targetHash = inTarget ? target.hashes.get(p) ?? null : null;
159
167
  if (targetHash)
160
168
  targetFiles[p] = targetHash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.28.0",
3
+ "version": "0.29.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,26 @@
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.29.0] · 2026-10-25
6
+
7
+ ### New
8
+
9
+ - **A mark says what it encodes** ([#94](https://github.com/zhixuan312/zz-meridian/issues/94)). A colour, a fill or a size a reader is meant to decode now carries its words, built from the data the component already holds, so it is right at the call site rather than added page by page. A Bar list row names its row, its figure and its share (`Checkout · 420 · 42.0% of 1,000`, and without a total too); a Trend chart's marked day names the day and what it is marked for; its dashed stretch into a period still running says "Today so far, dashed"; a Sparkline says the range it draws, which was the one chart carrying no readout; a Delta says whether its colour reads better or worse, which the colour alone could not; a Status dot takes a `label` and becomes an image to assistive technology where it stands with no word beside it, and an activity's tone says what it means ("Went well", "Needs attention", "Failed"). `ColumnChart` and `BarList` take `highlightLabel` — what makes the accented row or column the one the card is about — and say it on that mark and in the hidden table's caption. `docs/data-display.md`'s `## Marks` now states the rule, so a new chart is held to it.
10
+
11
+ ### Fixed
12
+
13
+ - **A card's head never draws its title over its controls** ([#87](https://github.com/zhixuan312/zz-meridian/issues/87)). `CardHeader` gave the title `min-w-0 flex-1` and the actions `shrink-0`, so in a narrow column the title's box collapsed: at 1024px the second column of `<Row split="2/3">` is 229px, a head with a `Select size="sm"` and two labels took 138 of them, and the title's first word was drawn 32px past its own box, across the controls. The head wraps now, and the title's box has a floor (8rem, the width a word or two needs), so the actions drop to their own line when the two cannot share one. A title that wraps breaks a word too long for the line instead of drawing it over its neighbour.
14
+ - **A page headed by a sentence that opens with its own name announces it once** ([#88](https://github.com/zhixuan312/zz-meridian/issues/88)). `PageTitle` drew the name out of sight and then the sentence, so "USDC: no rule met…" was read as "USDC : USDC: no rule met…" in the heading and twice in a screen reader's list of headings. A sentence that is a plain string is compared with the name, and one that reads data takes `speaksName` on `PageTitle` or `PageFrame`; the name stays in the markup either way, so the heading is in the prerendered shell, and a sentence still to come leaves the name read.
15
+ - **A Key value list wraps a value on a phone instead of cutting it behind a title no phone shows** ([#89](https://github.com/zhixuan312/zz-meridian/issues/89)). Every value truncated unless its item set `wrap`, with the whole of it in the row's `title` — which a pointer reads and a phone cannot. Below the width where a sentence value cannot fit beside its 8rem label (26rem of row) the value wraps as `wrap` asks for; above it the one line and the title stay.
16
+ - **The CLS observer bounds a session window at 5 s** ([#90](https://github.com/zhixuan312/zz-meridian/issues/90)). It summed shifts into a session while each was within 1 s of the last, which is half of web-vitals' definition: a page that filled in over half a minute reported one session the length of the page, so `verify --timed` could fail a page inside its budget and the number could not be compared with another tool's. `scripts/lib/cls.ts` is the rule now, both bounds with it, and `tests/cli-vitals-cls.test.ts` runs the source the page runs.
17
+ - **A collection may hide or page-own a field it computes** ([#91](https://github.com/zhixuan312/zz-meridian/issues/91)). `visibleFields` and `writableFields` built their `omit` masks from the names alone, so a `hidden` or `pageOnlyFields` entry for a joined label or a derived total — which both doc comments allow — threw on a key the shape never had, on every render. A name the shape does not hold is skipped; the ones it holds are still masked, and the write schema stays strict, so a hidden or page-owned field sent anyway is refused.
18
+ - **An image's address is read the way the URL parser reads it** ([#92](https://github.com/zhixuan312/zz-meridian/issues/92)). `safe-markdown` accepted a `src` with no scheme that was not written `//`, so `\\host/p.png`, `/\host/p.png` and `\/host/p.png` passed while the browser resolved each to `//host/p.png` and fetched a third party — the case the rule exists to stop. The address is now resolved against a fixed origin with the backslashes normalised, and kept only when it lands back on it.
19
+ - **A filter bar's search has a floor, so its hint reads the same on a 2x display as on a 1x one** ([#93](https://github.com/zhixuan312/zz-meridian/issues/93)). The field took whatever five facets left: 129px at a 1440px window on a Retina screen, where the placeholder needs 134, so the same page read "Headline, address, ru" there and the whole hint on a laptop. It is 10rem wide at least, and the facets truncate their own labels to make the room.
20
+ - **The environment is put back before every test** ([#95](https://github.com/zhixuan312/zz-meridian/issues/95)). Files share a worker (`pool: 'vmThreads'`), so a file that stubbed a store's variables in a `beforeAll` left them set for the file beside it — seen as an intermittent gate where a view test found a store configured and asked a stub for a snapshot. `tests/setup.ts` restores the environment before each test, so a stub belongs in a `beforeEach`, which runs after it.
21
+ - **A created project's tests move with the release that changes them.** A test the template ships pins the behaviour of the module it covers, and every shipped test but `tests/setup.ts` was the team's, so an update could change `src/lib/format.ts` and leave `tests/format.test.ts` asserting what it did before: a project created with 0.28.0 and updated to 0.29.0 failed its own gate on `splitFigure`. `create` now records the tests it writes as managed, so an update replaces an unedited one with the release's; a test your team edited is staged for your decision, as any other edited file is. `adopt` still copies none of them, because a project it joins has a suite of its own.
22
+
23
+ - **A figure keeps its symbol and its sign when it is split or shortened, and an amount too small for 20 places never reads as zero** ([#96](https://github.com/zhixuan312/zz-meridian/issues/96)). `splitFigure` failed on a leading mark-and-sign figure (`-$298.43` came back whole), so a tile set it in its number face, or lost both the `$` and the minus when it shortened; `fitFigure` re-built a shortened figure with the app's own currency, so `HK$12,345,678` read `$12.3M`. The minus belongs to the mark, and a figure shortens with the mark and the sign it was given. `formatCost` below 5e-5 keeps its two leading digits in exponent form (`$1.0e-21`) where `toFixed` printed a row of zeroes.
24
+
5
25
  ## [0.28.0] · 2026-10-24
6
26
 
7
27
  ### Breaking
@@ -148,10 +148,16 @@ export default function AtlasHome() {
148
148
  </Link>
149
149
  ) : null}
150
150
  </div>
151
+ {/* A digest, not the section: four headlines, and the rest one link away. Every headline used to be in this
152
+ page's HTML, so the page grew with the length of a release — 0.29.0's ten took it 5.8% over its HTML
153
+ baseline, which is a page whose size the changelog decides. The cap keeps the page's size its own. */}
151
154
  <ul className="grid content-start gap-x-6 gap-y-2.5 sm:grid-cols-2">
152
- {release.headlines.map((h) => (
155
+ {release.headlines.slice(0, 4).map((h) => (
153
156
  <li key={h} className="flex items-center gap-2.5 text-sm text-ink"><span aria-hidden className="size-1.5 shrink-0 rounded-full bg-ink-3" />{h}</li>
154
157
  ))}
158
+ {release.headlines.length > 4 ? (
159
+ <li className="flex items-center gap-2.5 text-sm text-ink-3"><span aria-hidden className="size-1.5 shrink-0 rounded-full bg-ink-3" />and {release.headlines.length - 4} more in the changelog</li>
160
+ ) : null}
155
161
  </ul>
156
162
  </div>
157
163
  </section>
@@ -47,6 +47,7 @@ Text never wears a series colour; values and labels stay in ink.
47
47
  - A label inside a plot stands where no series is drawn. A Trend chart sets its baseline's label beside the dashed line at whichever end is clear, and draws no label when neither is.
48
48
  - 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
49
  - A legend is always present for two or more series or parts; a single series is named by its title.
50
+ - 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.
50
51
  - 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.
51
52
 
52
53
  ## A period still in progress
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.28.0",
3
+ "version": "0.29.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.",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cumulative Layout Shift, as web-vitals defines it: the largest session window of shifts no input caused.
3
+ *
4
+ * A session window opens on a shift and keeps taking shifts while each lands within 1 s of the one before it AND within
5
+ * 5 s of the window's own first shift; the score is the largest window's sum, not the page's total (issue #90). Both
6
+ * bounds are the definition. With the 1 s gap alone, a page whose content arrives in small pieces — a table filling in,
7
+ * rows arriving — over half a minute reports one session the length of the page: it fails a page that is inside the
8
+ * budget, and its number cannot be compared with any other tool's.
9
+ *
10
+ * The body below is source, not a function: it is the layout-shift observer's body, added to the page before any page
11
+ * script runs (`scripts/vitals.ts`). It reads the shift entry as `e` and the accumulator as `v`, both the observer's own
12
+ * names — `v.started` is the window's first shift, `v.session` its sum, `v.last` the shift before this one.
13
+ * `tests/cli-vitals-cls.test.ts` builds the same text into a function so what it proves is what the page runs.
14
+ */
15
+ export const CLS_SESSION = `
16
+ if (!v.session || e.startTime - v.last >= 1000 || e.startTime - v.started >= 5000) { v.started = e.startTime; v.session = e.value; }
17
+ else v.session += e.value;
18
+ v.last = e.startTime;
19
+ v.cls = Math.max(v.cls, v.session);`;
@@ -6,7 +6,8 @@
6
6
  * The phone is Lighthouse's mobile profile: the device of benchmark index 800, the CPU slowed by the host's own index
7
7
  * over 800 (scripts/lib/phone.ts), Slow 4G (150 ms round trip, 1.6 Mbps down, 750 kbps up), a 390px touch screen,
8
8
  * motion not reduced. LCP and CLS come from PerformanceObservers installed before the page loads (CLS as web-vitals
9
- * defines it: the largest session window of shifts no input caused). INP is the slowest of real taps on controls that
9
+ * defines it: the largest session window of shifts no input caused, each within 1 s of the last and the window within
10
+ * 5 s — scripts/lib/cls.ts). INP is the slowest of real taps on controls that
10
11
  * change the screen (a period, the alerts, the workspace menu, the drawer), from Event Timing, counting only the tap's
11
12
  * own pointer, touch and click events: the Escape that closes what a tap opened is not a person's interaction, and a
12
13
  * headless browser can hold a no-op key event open until something else paints. A dropped frame is a gap over 50 ms
@@ -31,6 +32,7 @@
31
32
  */
32
33
  import { atlasCoverage, atlasEntries } from './lib/atlas.ts';
33
34
  import { launch } from './lib/chrome.ts';
35
+ import { CLS_SESSION } from './lib/cls.ts';
34
36
  import { DEEP_SOURCE } from './lib/deep.ts';
35
37
  import { frameCause, loafOf, type LoafEntry } from './lib/frame-cause.ts';
36
38
  import { measurePhone } from './lib/phone.ts';
@@ -55,13 +57,12 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
55
57
 
56
58
  /** Installed before any page script: every vital the page produces lands on window.__v. */
57
59
  const OBSERVE = `(() => {
58
- const v = window.__v = { lcp: 0, cls: 0, session: 0, last: 0, taps: [], frames: [], loafs: [], recording: false, since: 0, moved: {} };
60
+ const v = window.__v = { lcp: 0, cls: 0, session: 0, last: 0, started: 0, taps: [], frames: [], loafs: [], recording: false, since: 0, moved: {} };
59
61
  const named = (n) => !n || n.nodeType !== 1 ? 'an element since removed' : n.tagName.toLowerCase() + (n.id ? '#' + n.id : '') + (typeof n.className === 'string' && n.className.trim() ? '.' + n.className.trim().split(/\\s+/).slice(0, 2).join('.') : '');
60
62
  new PerformanceObserver((l) => { for (const e of l.getEntries()) v.lcp = e.startTime; }).observe({ type: 'largest-contentful-paint', buffered: true });
61
63
  new PerformanceObserver((l) => { for (const e of l.getEntries()) {
62
64
  if (e.hadRecentInput) continue;
63
- v.session = e.startTime - v.last < 1000 && v.session ? v.session + e.value : e.value;
64
- v.last = e.startTime; v.cls = Math.max(v.cls, v.session);
65
+ ${CLS_SESSION}
65
66
  for (const s of e.sources || []) { const k = named(s.node); v.moved[k] = (v.moved[k] || 0) + e.value; }
66
67
  } }).observe({ type: 'layout-shift', buffered: true });
67
68
  const TAP = /^(pointer|touch|mouse|click|gesture)/;
@@ -8,7 +8,7 @@ team's. This list is the rule the classifier itself uses; edit it in `cli/src/ow
8
8
  - Managed: the distributed scripts under `scripts/`, except `scripts/verify.config.ts`, the size baseline `optional:scripts/verify.baseline.json` (first-load JS and the HTML of each page) and a team-local `optional:scripts/check.local.ts`.
9
9
  - Managed: the library helpers `src/lib/cn.ts`, `src/lib/format.ts`, `src/lib/format-date.ts`, `src/lib/period.ts`, `src/lib/color.ts`, `src/lib/host.ts`, `src/lib/preferences.ts`, `src/lib/csv.ts`, `src/lib/safe-markdown.ts`, `src/lib/logo.ts`, `src/lib/collection.ts`, `src/lib/explore.ts`, `src/lib/live.ts`, `src/lib/shared-context.ts`, `src/lib/agent-guidance.ts`, `src/lib/insight.ts` and `src/lib/facts.ts`, and the assistant prompt `src/lib/assistant/prompt.ts`.
10
10
  - Managed: the three `src/logic/` helpers a shipped component imports — `src/logic/after-start.ts`, `src/logic/derivations.ts` and `src/logic/summarise.ts` — so a payload's cards build in an adopted project; every other module under `src/logic/` is the team's.
11
- - Managed: `src/views/console-chrome.tsx` and `tests/setup.ts`.
11
+ - Managed: `src/views/console-chrome.tsx`, `tests/setup.ts`, and — in a project `create` made — every test the release ships. A shipped test pins the behaviour of the module it covers, so a release that changes one changes the other; a test the team edits is staged for its decision like any other edited file.
12
12
  - Managed: `optional:scripts/package.json`, only when the adopt or create that set the project up generated it for this project shape.
13
13
  - Managed: both installed skill trees, `optional:.agents/skills/zz-meridian/` and `optional:.claude/skills/zz-meridian/`, taken from the release's skill payload.
14
14
  - Managed: the brand token and stylesheet paths under `tokens/` and `src/styles/` that the release generated from the recorded brand.
@@ -368,6 +368,14 @@ function SentencePending() {
368
368
  );
369
369
  }
370
370
 
371
+ /** Whether a sentence opens with the page's own name, which is the case it would then say twice: "Members: 12 joined".
372
+ * A name that is only the first part of a word does not count — "Membership" is not "Members" — so the character after
373
+ * it must end the word. */
374
+ function opensWith(sentence: string, name: string): boolean {
375
+ const s = sentence.trim(), n = name.trim();
376
+ return n !== '' && s.length > n.length && s.slice(0, n.length).toLowerCase() === n.toLowerCase() && !/[a-z0-9]/i.test(s[n.length] ?? '');
377
+ }
378
+
371
379
  /**
372
380
  * A page's heading, the one `h1`. With no `name` it is the title at the page-title size, a name such as "Members".
373
381
  *
@@ -379,13 +387,18 @@ function SentencePending() {
379
387
  * page, drawn as a page that passes no name draws its title. A loading state passes the name alone and draws the
380
388
  * placeholder.
381
389
  */
382
- export function PageTitle({ name, title, ref }: { name?: string; title?: ReactNode; ref?: Ref<HTMLHeadingElement> }) {
390
+ export function PageTitle({ name, title, speaksName, ref }: { name?: string; title?: ReactNode; speaksName?: boolean; ref?: Ref<HTMLHeadingElement> }) {
383
391
  if (name === undefined) return <h1 ref={ref} className="t-page">{title}</h1>;
392
+ // A sentence that opens with the name announces it twice (issue #88): "USDC : USDC: no rule met", in a heading and
393
+ // in a screen reader's list of headings. The name stays in the markup either way — the heading is in the prerendered
394
+ // shell — but it is hidden from assistive technology, which reads the sentence. A sentence that is a plain string
395
+ // says so by itself; one that reads data cannot be asked before it arrives, so a page passes `speaksName`.
396
+ const repeats = title !== undefined && (speaksName ?? (typeof title === 'string' && opensWith(title, name)));
384
397
  return (
385
398
  <h1 ref={ref} className="@container text-xl">
386
399
  {/* The name stays out of sight while a sentence or its placeholder follows it, and heads the page when the sentence came to nothing. */}
387
- <span className="t-page sr-only has-[~[data-title]:empty]:not-sr-only">{name}</span>
388
- <span className="sr-only">: </span>
400
+ <span aria-hidden={repeats || undefined} className="t-page sr-only has-[~[data-title]:empty]:not-sr-only">{name}</span>
401
+ <span aria-hidden={repeats || undefined} className="sr-only">: </span>
389
402
  {title === undefined ? <SentencePending /> : (
390
403
  <Suspense fallback={<SentencePending />}>
391
404
  <span data-title className={cn(SENTENCE, 'empty:hidden')}>{title}</span>
@@ -406,6 +419,7 @@ export function PageTitle({ name, title, ref }: { name?: string; title?: ReactNo
406
419
  export function PageFrame({
407
420
  name,
408
421
  title,
422
+ speaksName,
409
423
  description,
410
424
  kicker,
411
425
  meta,
@@ -418,6 +432,9 @@ export function PageFrame({
418
432
  name?: string;
419
433
  /** The page's title: its name, or with `name` the sentence that heads it. Left out with `name`, the sentence is still to come. */
420
434
  title?: ReactNode;
435
+ /** The sentence speaks for the name — it opens with it — so the name is not announced beside it (`PageTitle`). Only
436
+ * needed when the sentence reads data and cannot be compared with the name until it has arrived. */
437
+ speaksName?: boolean;
421
438
  /** One sentence under the title: what this page answers. */
422
439
  description?: ReactNode;
423
440
  /** Mono caps above the title: where this page sits ("ZZ Meridian · Production", or the parent record). */
@@ -487,7 +504,7 @@ export function PageFrame({
487
504
  everything under it dropped. Now its width follows the viewport, the rail and the assistant alone. */}
488
505
  <div>
489
506
  {kicker ? <p className="t-kicker mb-4">{kicker}</p> : null}
490
- <PageTitle ref={head} name={name} title={title} />
507
+ <PageTitle ref={head} name={name} title={title} speaksName={speaksName} />
491
508
  </div>
492
509
  {/* The description grows, so the meta and the actions end the row on its last line; with no description
493
510
  nothing is left to grow in its place, and they stand at the row's start. */}
@@ -15,6 +15,7 @@ export function BarList({
15
15
  items,
16
16
  format = (n) => n.toLocaleString('en-US'),
17
17
  highlight,
18
+ highlightLabel,
18
19
  limit = 6,
19
20
  total,
20
21
  label,
@@ -24,6 +25,9 @@ export function BarList({
24
25
  format?: (n: number) => string;
25
26
  /** The key of the one row worth pointing at. Omit it and every bar is neutral. */
26
27
  highlight?: string;
28
+ /** What makes that row the one worth pointing at, in a word: "The busiest hour". Said on its row, so the accent is
29
+ * not a colour a reader has to guess (issue #94). */
30
+ highlightLabel?: string;
27
31
  limit?: number;
28
32
  /** The whole the rows are a share of: each bar's length is its value over this, and its share shows on hover. Left
29
33
  * out, bars are drawn against the largest row. */
@@ -42,8 +46,12 @@ export function BarList({
42
46
  <ul aria-label={label} className={cn('flex flex-col gap-3.5', className)}>
43
47
  {shown.map((it, n) => {
44
48
  const hot = it.key === highlight;
49
+ // The row says what it is as well as its share (issue #94): "42.1% of 1,204" on its own never named the row, and
50
+ // a list with no total had no title at all. The label is the row's own word, so it is used when it is text.
51
+ const named = typeof it.label === 'string' || typeof it.label === 'number' ? String(it.label) : null;
52
+ const share = total ? `${((it.value / Math.max(1, total)) * 100).toFixed(1)}% of ${format(total)}` : null;
45
53
  return (
46
- <li key={it.key} className="group/bar" title={total ? `${((it.value / Math.max(1, total)) * 100).toFixed(1)}% of ${format(total)}` : undefined}>
54
+ <li key={it.key} className="group/bar" title={[named, format(it.value), share, hot ? highlightLabel : null].filter(Boolean).join(' · ')}>
47
55
  <div className="mb-1.5 flex items-baseline gap-3 text-sm">
48
56
  {it.href ? (
49
57
  // The row's look at rest, the accent under the pointer and the focus ring on the keyboard. The hit area is
@@ -21,6 +21,9 @@ export type ColumnPart = { key: string; label: string; color?: number; tone?: 'n
21
21
  type Shared = {
22
22
  /** The key of the one column the card is about. */
23
23
  highlight?: string;
24
+ /** What makes that column the one the card is about, in a word: "the day of the incident", "the period on trial". The
25
+ * chart says it wherever the column is read, so the accent is not a colour a reader has to guess (issue #94). */
26
+ highlightLabel?: string;
24
27
  /** One ISO date per column, to share the page's Meridian. */
25
28
  dates?: string[];
26
29
  /** The last column is a period still in progress: it is drawn unfinished and reads "so far". */
@@ -123,6 +126,7 @@ function stack(columns: StackedColumn[], parts: ColumnPart[]) {
123
126
  */
124
127
  export function ColumnChart({
125
128
  highlight,
129
+ highlightLabel,
126
130
  dates,
127
131
  running = false,
128
132
  format = count,
@@ -249,7 +253,8 @@ export function ColumnChart({
249
253
  data-running={unfinished ? '' : undefined}
250
254
  data-no-data={b.none ? '' : undefined}
251
255
  // A figure wider than its column is cut at rest, and keeps the whole of itself where the pointer finds it.
252
- title={rest && text.length * 7 > column ? text : undefined}
256
+ // The accented column says what makes it the one the card is about, beside its figure.
257
+ title={[rest && text.length * 7 > column ? text : null, b.key === highlight ? highlightLabel : null].filter(Boolean).join(' · ') || undefined}
253
258
  className="group/col relative h-full min-w-0 flex-1"
254
259
  onPointerEnter={() => set(i)}
255
260
  onPointerDown={() => set(i)}
@@ -361,7 +366,7 @@ export function ColumnChart({
361
366
  </div>
362
367
  <p className="sr-only" aria-live="polite">{reading}</p>
363
368
  <table className="sr-only">
364
- <caption>{label}</caption>
369
+ <caption>{label}{highlight && highlightLabel ? `. Accented: ${highlightLabel}` : ''}</caption>
365
370
  {drawn ? <thead><tr><th scope="col">Period</th>{drawn.map((p) => <th key={p.key} scope="col">{p.label}</th>)}<th scope="col">Total</th></tr></thead> : null}
366
371
  <tbody>
367
372
  {bars.map((b, i) => (
@@ -36,8 +36,11 @@ export function Sparkline({ values, color = 'accent', height = 36, className }:
36
36
  // Meridian by. Fourteen days of a customer on a page of thirty would otherwise mark the wrong day. A day with no value
37
37
  // is still that day: the hairline marks it, and there is no dot to stand on the line.
38
38
  const at = index !== null && values.length === dates.length ? { x: x(index), y: pts[index]?.[1] ?? null } : null;
39
+ // A shape with no readout says nothing about what it covers, and it is the one chart a page puts beside a figure
40
+ // without a table under it (issue #94). The range it draws is the words it has, on the pointer.
41
+ const range = `${Math.min(...numbers).toLocaleString('en-US')} to ${Math.max(...numbers).toLocaleString('en-US')}`;
39
42
  return (
40
- <div ref={ref} aria-hidden className={className} style={{ height }}>
43
+ <div ref={ref} aria-hidden title={range} className={className} style={{ height }}>
41
44
  {W ? (
42
45
  <svg width={W} height={H} className="block overflow-visible">
43
46
  <defs>
@@ -451,7 +451,10 @@ export function TrendChart({
451
451
  ) : null}
452
452
  {active !== null && width > 0 ? (
453
453
  <Readout at={cx} after={cx <= width / 2} top={pad.t - 4} visible={local || previewIndex !== undefined}>
454
- <p className="t-eyebrow mb-1.5 break-words">{tick ? tick(dates[active]) : formatDate(dates[active])}{active === soFar ? ' · so far' : null}</p>
454
+ {/* A marked day says what it is marked for where every other reading of the day is (#94): the dot and its stem
455
+ are a shape, the group they stand in takes no pointer, and the table's caption is read by a screen reader and
456
+ not by the person pointing at the chart. */}
457
+ <p className="t-eyebrow mb-1.5 break-words">{tick ? tick(dates[active]) : formatDate(dates[active])}{active === soFar ? ' · so far' : null}{marks?.dates.includes(dates[active]) ? ` · ${marks.label}` : null}</p>
455
458
  {/* Stacked, the readout lists the bands top to bottom, as they sit on the chart, then the total. */}
456
459
  {(stacked ? series.map((s, k) => ({ s, k })).reverse() : series.map((s, k) => ({ s, k }))).map(({ s, k }) => (
457
460
  <p key={s.key} className="flex flex-wrap items-start gap-x-2 text-xs leading-6">
@@ -479,5 +482,7 @@ export function TrendChart({
479
482
 
480
483
  /** The stretch into a point still running: the line's own colour, thinner and dashed, and it fades in as a reference does. */
481
484
  function Running({ d, stroke }: { d: string; stroke: string }) {
482
- return <path data-running d={d} fill="none" stroke={stroke} strokeWidth={1.5} strokeDasharray="3 4" strokeLinecap="round" className="fade-in" />;
485
+ // The stretch is the chart's own mark and its meaning is not in its shape: the words are `SO_FAR`'s, which the caption
486
+ // says too. A page that passes `running` says them above the chart; the pointer says them here (issue #94).
487
+ return <path data-running d={d} fill="none" stroke={stroke} strokeWidth={1.5} strokeDasharray="3 4" strokeLinecap="round" className="fade-in"><title>{SO_FAR}</title></path>;
483
488
  }
@@ -13,6 +13,8 @@ import { AgentMark } from '@/components/ui/agent-mark';
13
13
  export type ActivityEvent = { id: string; at: string; actor: string; system?: boolean; unknown?: boolean; via?: string; verb: string; object: string; href?: string; tone?: 'positive' | 'warning' | 'critical' };
14
14
 
15
15
  const DOT = { positive: 'bg-positive', warning: 'bg-warning', critical: 'bg-critical' } as const;
16
+ /** What the tone means, in a word: the dot is the only thing carrying it, so it cannot be a colour alone (issue #94). */
17
+ const TONE = { positive: 'Went well', warning: 'Needs attention', critical: 'Failed' } as const;
16
18
 
17
19
  /**
18
20
  * What happened, who did it, when: one line per event, newest first, on a quiet timeline. The actor is a person, the
@@ -25,7 +27,7 @@ export function ActivityFeed({ events, now, className }: { events: ActivityEvent
25
27
  <li key={e.id} className="relative flex gap-3 pb-4 last:pb-0">
26
28
  {i < events.length - 1 ? <span aria-hidden className="absolute top-7 bottom-0 left-3 w-px bg-line" /> : null}
27
29
  <span className="relative mt-0.5 shrink-0">
28
- {e.via ? <AgentMark size="md" /> : e.system ? <span className="grid size-6 place-items-center rounded-full bg-fill-track"><span className={cn('size-2 rounded-full', e.tone ? DOT[e.tone] : 'bg-ink-3')} /></span> : e.unknown ? <span data-actor-unknown className="block size-6 rounded-full border border-dashed border-line-strong" /> : <Avatar name={e.actor} size="sm" />}
30
+ {e.via ? <AgentMark size="md" /> : e.system ? <span className="grid size-6 place-items-center rounded-full bg-fill-track"><span role={e.tone ? 'img' : undefined} aria-label={e.tone ? TONE[e.tone] : undefined} title={e.tone ? TONE[e.tone] : undefined} className={cn('size-2 rounded-full', e.tone ? DOT[e.tone] : 'bg-ink-3')} /></span> : e.unknown ? <span data-actor-unknown className="block size-6 rounded-full border border-dashed border-line-strong" /> : <Avatar name={e.actor} size="sm" />}
29
31
  </span>
30
32
  <div className="min-w-0 flex-1 pt-0.5">
31
33
  <p className="text-sm text-ink-2">
@@ -85,7 +85,10 @@ export function FilterBar({
85
85
  onValueChange={search.onChange}
86
86
  placeholder={search.placeholder ?? 'Search'}
87
87
  shortcut="/"
88
- className="min-w-0 flex-1 @min-[52rem]:max-w-72"
88
+ // A floor, so the hint is never cut to a different word on a 2x display than on a 1x one (issue #93): the bar
89
+ // gave the field whatever five facets left, 129px at a 1440px window on 2x, where the placeholder needs 134.
90
+ // The facets truncate their own labels to make the room.
91
+ className="min-w-40 flex-1 @min-[52rem]:max-w-72"
89
92
  />
90
93
  ) : null}
91
94
  <div className="flex min-w-0 items-center gap-2 @max-[52rem]:hidden">{selects('sm')}</div>
@@ -55,7 +55,9 @@ export function IncidentCard({ incident, now, variant = 'card', className }: { i
55
55
  const updates = [...incident.updates].sort((a, b) => b.at.localeCompare(a.at));
56
56
  return (
57
57
  <Card className={cn('overflow-hidden', live && st.border, className)}>
58
- {live ? <span aria-hidden className={cn('absolute inset-x-0 top-0 h-0.5', st.edge)} /> : null}
58
+ {/* The edge's colour is the state's, and the state's word is the badge below; the line says it too, on the pointer,
59
+ because it is the first thing read and a colour is not a word (issue #94). */}
60
+ {live ? <span aria-hidden title={st.word} className={cn('absolute inset-x-0 top-0 h-0.5', st.edge)} /> : null}
59
61
  <div className="px-(--card-pad) pt-(--card-pad)">
60
62
  <div className="flex flex-wrap items-center gap-2">
61
63
  <Badge tone={st.tone} dot>{st.word}</Badge>
@@ -43,9 +43,14 @@ export function CardHeader({
43
43
  // as an attribute; a title built of elements is the caller's to keep short or to wrap.
44
44
  const whole = !wrap && typeof title === 'string' ? title : undefined;
45
45
  return (
46
- <div className={cn('flex items-start gap-4 px-(--card-pad) pt-[calc(var(--card-pad)-4px)]', divided ? 'border-b border-line pb-3.5' : 'pb-1', className)}>
47
- <div className="min-w-0 flex-1">
48
- <h2 title={whole} className={cn('t-card', wrap ? 'text-pretty' : 'truncate')}>{title}</h2>
46
+ // The head wraps rather than squeezing the title to a sliver (issue #87). The title's box has a floor — the width it
47
+ // needs to keep a word or two whole — and the actions never shrink, so when the two cannot share a line the actions
48
+ // take the next one. Without the floor the box collapsed to nothing in a narrow column: at 1024px the second column
49
+ // of `<Row split="2/3">` is 229px, a head with a Select and two labels takes 138px of it, and the first word drew
50
+ // 32px past its own box, over the controls.
51
+ <div className={cn('flex flex-wrap items-start gap-x-4 gap-y-2 px-(--card-pad) pt-[calc(var(--card-pad)-4px)]', divided ? 'border-b border-line pb-3.5' : 'pb-1', className)}>
52
+ <div className="min-w-32 flex-1">
53
+ <h2 title={whole} className={cn('t-card', wrap ? 'text-pretty [overflow-wrap:anywhere]' : 'truncate')}>{title}</h2>
49
54
  {description ? <p className="t-caption mt-1 text-pretty">{description}</p> : null}
50
55
  </div>
51
56
  {actions ? <div className="-my-1 flex shrink-0 items-center gap-1.5">{actions}</div> : null}
@@ -34,8 +34,13 @@ export function Delta({
34
34
  const read = typeof format === 'function' ? format : DELTA_FORMATS[format ?? 'percent'];
35
35
  const up = value > 0;
36
36
  const good = intent === 'neutral' || flat ? null : (up && intent === 'up') || (!up && intent === 'down');
37
+ // The arrow says which way, and the colour says whether that is good or bad — a judgement a reader who does not know
38
+ // the intent cannot decode (issue #94). The sentence is the whole reading, on the pointer. It is not in the reading
39
+ // itself: a tile heard in a screen reader gets the direction and the figure, which are the facts.
40
+ const sentence = [`${up ? 'Up' : 'Down'} ${read(value)}`, good === null ? null : good ? 'better' : 'worse'].filter(Boolean).join(', ');
37
41
  return (
38
42
  <span
43
+ title={flat ? 'Unchanged' : sentence}
39
44
  className={cn(
40
45
  't-num inline-flex items-center gap-0.5 text-xs font-medium whitespace-nowrap',
41
46
  good === null ? 'text-ink-2' : good ? 'text-positive-ink' : 'text-critical-ink',
@@ -28,7 +28,14 @@ export function KeyValue({ items, columns = 1, className }: { items: KeyValueIte
28
28
  <dt className="w-32 shrink-0 text-sm text-ink-3">{it.label}</dt>
29
29
  <dd
30
30
  title={it.wrap ? undefined : (it.title ?? (typeof it.value === 'string' || typeof it.value === 'number' ? String(it.value) : undefined))}
31
- className={cn('t-num min-w-0 flex-1 text-sm text-ink', it.wrap ? '[overflow-wrap:anywhere]' : 'truncate', it.mono && 'font-mono text-xs')}
31
+ className={cn(
32
+ 't-num min-w-0 flex-1 text-sm text-ink',
33
+ // A value the row cuts keeps its whole text in its title, which a pointer reads and a phone cannot (issue
34
+ // #89). Below the width where a sentence value cannot fit beside its label the row wraps instead, the way
35
+ // `wrap` asks for, so nothing is left unreadable behind a hover that does not exist there.
36
+ it.wrap ? '[overflow-wrap:anywhere]' : 'truncate @max-[26rem]:whitespace-normal @max-[26rem]:[overflow-wrap:anywhere]',
37
+ it.mono && 'font-mono text-xs',
38
+ )}
32
39
  >
33
40
  {it.value}
34
41
  </dd>
@@ -3,7 +3,15 @@ import type { Tone } from '@/components/ui/badge';
3
3
 
4
4
  const TONE: Record<Tone, string> = { neutral: 'bg-ink-3 text-ink-3', accent: 'bg-accent text-accent', positive: 'bg-positive text-positive', warning: 'bg-warning text-warning', critical: 'bg-critical text-critical' };
5
5
 
6
- /** A state at a glance, always beside its word. `live` pulses: the one loop allowed outside a skeleton. */
7
- export function StatusDot({ tone = 'neutral', live, className }: { tone?: Tone; live?: boolean; className?: string }) {
8
- return <span aria-hidden className={cn('inline-block size-2 shrink-0 rounded-full', TONE[tone], live && 'pulse', className)} />;
6
+ /**
7
+ * A state at a glance, always beside its word. `live` pulses: the one loop allowed outside a skeleton.
8
+ *
9
+ * Where there is no word beside it — a list row, a finding — the dot is the only thing carrying the state, so it takes
10
+ * one (issue #94): with `label` it is an image to assistive technology rather than a decoration, and the pointer reads
11
+ * the same word. Without one it stays out of the way, which is what a dot beside its word should do.
12
+ */
13
+ export function StatusDot({ tone = 'neutral', live, label, className }: { tone?: Tone; live?: boolean; label?: string; className?: string }) {
14
+ const shape = cn('inline-block size-2 shrink-0 rounded-full', TONE[tone], live && 'pulse', className);
15
+ if (label) return <span role="img" aria-label={label} title={label} className={shape} />;
16
+ return <span aria-hidden className={shape} />;
9
17
  }
@@ -480,9 +480,19 @@ function withoutDefault(field: z.ZodType): z.ZodType {
480
480
  : field;
481
481
  }
482
482
 
483
+ /**
484
+ * `fields` without the named ones, or `fields` itself when it names none of them (issue #91). A collection hides or
485
+ * page-owns a field it computes rather than declares — a joined label, a derived total — which the doc comments below
486
+ * allow: a name the shape does not have is named but not sent, and `omit` cannot mask a key that was never there.
487
+ */
488
+ function without(fields: z.ZodObject, names: string[]): z.ZodObject {
489
+ const known = names.filter((f) => Object.hasOwn(fields.shape, f));
490
+ return known.length ? fields.omit(Object.fromEntries(known.map((f) => [f, true])) as Record<string, true>).strict() : fields.strict();
491
+ }
492
+
483
493
  /** The collection's fields without its hidden ones. */
484
494
  export function visibleFields(c: AnyCollection): z.ZodObject {
485
- return c.fields.omit(Object.fromEntries(((c.hidden ?? []) as string[]).map((f) => [f, true])) as Record<string, true>).strict();
495
+ return without(c.fields, (c.hidden ?? []) as string[]);
486
496
  }
487
497
 
488
498
  /**
@@ -494,7 +504,5 @@ export function visibleFields(c: AnyCollection): z.ZodObject {
494
504
  * same as on any other visible field. Reading a field and changing it are different rules, and this is the second.
495
505
  */
496
506
  export function writableFields(c: AnyCollection): z.ZodObject {
497
- const base = visibleFields(c);
498
- const pageOnly = (c.pageOnlyFields ?? []) as string[];
499
- return pageOnly.length ? base.omit(Object.fromEntries(pageOnly.map((f) => [f, true])) as Record<string, true>).strict() : base;
507
+ return without(visibleFields(c), (c.pageOnlyFields ?? []) as string[]);
500
508
  }
@@ -35,13 +35,20 @@ export function formatCost(amount: number | null): string {
35
35
  if (amount === 0) return `${CURRENCY}0`;
36
36
  const sign = amount < 0 ? '-' : '';
37
37
  const a = Math.abs(amount);
38
- if (a < 0.00005) return `${sign}${CURRENCY}${a.toFixed(Math.min(20, 1 - Math.floor(Math.log10(a))))}`;
38
+ if (a < 0.00005) {
39
+ // The places grow with how small the amount is, one more than its leading zeroes, so a figure never reads as zero.
40
+ // Past 20 places `toFixed` prints only zeroes, which is an amount of nothing and not a small one: there the figure
41
+ // keeps its two leading digits in exponent form instead (`$1.2e-21`).
42
+ const places = 1 - Math.floor(Math.log10(a));
43
+ return `${sign}${CURRENCY}${places <= 20 ? a.toFixed(places) : a.toExponential(1)}`;
44
+ }
39
45
  if (a < 0.01) return `${sign}${CURRENCY}${a.toFixed(4)}`;
40
46
  // Two decimals at every size, so a column of amounts keeps one precision and its decimals line up.
41
47
  return `${sign}${CURRENCY}${CENTS.format(a)}`;
42
48
  }
43
49
 
44
- /** Money at a glance, for dense tables and tiles: $1.2M, $340K, $912. The exact amount belongs in the tooltip. */
50
+ /** Money at a glance, for dense tables and tiles: $1.2M, $340K, $912. The exact amount belongs in the tooltip. A figure
51
+ * that has to KEEP the mark its caller wrote is `fitFigure`'s, which shortens with that mark and not this one. */
45
52
  function formatCostCompact(amount: number | null): string {
46
53
  if (amount === null) return '—';
47
54
  const sign = amount < 0 ? '-' : '';
@@ -54,10 +61,12 @@ function formatCostCompact(amount: number | null): string {
54
61
  * ONE parser for the two figures that do this (the Featured metric and the Metric tile). It is total: a string it
55
62
  * cannot split comes back whole as `int`, so a caller never tests for a match — and never dereferences one that is
56
63
  * not there. The symbol is whatever abuts the number, not a list of currencies: `$`, `S$`, `CHF` — this
57
- * repository's own `formatCost` concatenates the narrow symbol with no space, so it always abuts.
64
+ * repository's own `formatCost` concatenates the narrow symbol with no space, so it always abuts. A credit's minus is
65
+ * part of it too (issue #96): `-$298.43` splits as mark `-$` and number `298.43`, so the sign the column is sorted by
66
+ * is still there when the figure is set in two faces.
58
67
  */
59
68
  export function splitFigure(s: string): { pre?: string; int: string; frac?: string; unit?: string } {
60
- const m = s.match(/^([^\d\s.,-]*)([\d,]+)(\.\d+)?\s*([%a-zA-Z]*)$/);
69
+ const m = s.match(/^([-+]?[^\d\s.,-]*)([\d,]+)(\.\d+)?\s*([%a-zA-Z]*)$/);
61
70
  if (!m) return { int: s };
62
71
  // Cents step down; the decimal of a compact amount ("$1.2M") is part of the number, not cents.
63
72
  const cents = Boolean(m[1]) && !m[4];
@@ -76,8 +85,12 @@ export const FIGURE_MAX = 9;
76
85
  export function fitFigure(text: string, value: number): string {
77
86
  const { pre, int, frac, unit } = splitFigure(text);
78
87
  if ((int + (frac ?? '')).length <= FIGURE_MAX || !Number.isFinite(value)) return text;
88
+ // The mark and the sign are the ones the caller's text carries, not a generic cost formatter's (issue #96): a tile
89
+ // reads "HK$12.3M" and "-$1.2M", and a cost shortens the way the same amount shortens anywhere else. Where `pre`
90
+ // already carries the minus, the number does not repeat it.
91
+ const number = pre?.includes('-') ? formatCompact(Math.abs(value)) : formatCompact(value);
79
92
  // The unit keeps the space it had: "12.5M tokens", "12.5Mms" never arises (durations step up to hours first).
80
- return pre ? formatCostCompact(value) : `${formatCompact(value)}${unit ? (/\s[%a-zA-Z]+$/.test(text) ? ' ' : '') + unit : ''}`;
93
+ return `${pre ?? ''}${number}${unit ? (/\s[%a-zA-Z]+$/.test(text) ? ' ' : '') + unit : ''}`;
81
94
  }
82
95
 
83
96
  /** The steps of a count at a glance from a million up, smallest first. */
@@ -14,12 +14,29 @@
14
14
  */
15
15
  const NAVIGABLE = new Set(['http:', 'https:', 'mailto:', 'tel:']);
16
16
 
17
+ /** The origin every image is resolved against. Any host nothing can reach does: only an address that lands back on it
18
+ * is kept, and the address is returned as it was written, never rewritten to this one. */
19
+ const THIS_ORIGIN = 'https://meridian.invalid';
20
+
21
+ /**
22
+ * Whether an image's address is a path on this page (issue #92). Read by resolution, not by looking for `//`: a
23
+ * backslash is a slash to the URL parser, so `\\host/p.png`, `/\host/p.png` and `\/host/p.png` are each `//host/p.png`
24
+ * to the fetch — a third party — while a check that reads them as written sees a relative path. Resolving and comparing
25
+ * the origin needs no list of the ways the parser folds one into the other, and a `//host` address resolves off-origin
26
+ * by the same rule.
27
+ */
28
+ function sameOrigin(url: string): boolean {
29
+ try {
30
+ return new URL(url.replace(/\\/g, '/'), `${THIS_ORIGIN}/`).origin === THIS_ORIGIN;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
17
36
  /** Returns the URL to use, or '' to drop it. `key` is the attribute being filled: `src` for an image, `href` for a link. */
18
37
  export function safeMarkdownUrl(url: string, key: string): string {
19
38
  const raw = url.trim();
20
39
  if (raw === '') return '';
21
- // `//host/p.png` has no scheme and is not relative: the browser supplies one and fetches a third party.
22
- const protocolRelative = raw.startsWith('//');
23
40
  // A scheme only counts before the first `/`, `?` or `#`, so `a/path:with-colon` is a path, not the scheme `a/path`.
24
41
  //
25
42
  // The scheme is read from a copy with the controls and spaces taken out, because that is what a browser does with a
@@ -28,7 +45,7 @@ export function safeMarkdownUrl(url: string, key: string): string {
28
45
  // returned exactly as it was written.
29
46
  const probe = raw.replace(/[\u0000- \u007F]/g, '');
30
47
  const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(probe.split(/[/?#]/, 1)[0] ?? '')?.[1];
31
- if (key === 'src') return scheme !== undefined || protocolRelative ? '' : raw;
48
+ if (key === 'src') return scheme === undefined && sameOrigin(probe) ? raw : '';
32
49
  if (scheme === undefined) return raw;
33
50
  return NAVIGABLE.has(`${scheme.toLowerCase()}:`) ? raw : '';
34
51
  }
@@ -180,6 +180,7 @@ function YearCard({ data, keys }: { data: HomeData; keys: string[] }) {
180
180
  label="Requests a window"
181
181
  columns={year.windows.map((w) => ({ key: w.to, label: formatShortDate(w.to), value: w.requests }))}
182
182
  highlight={keys.at(-1)}
183
+ highlightLabel="The latest window"
183
184
  dates={keys}
184
185
  format={formatCompact}
185
186
  axisFormat={AXIS_FORMATTERS.compact}
@@ -239,6 +240,7 @@ function RevenueWindows({ data, keys }: { data: HomeData; keys: string[] }) {
239
240
  label="Revenue a window"
240
241
  columns={data.year.windows.map((w: HomeWindow) => ({ key: w.to, label: formatShortDate(w.to), value: w.revenue }))}
241
242
  highlight={keys.at(-1)}
243
+ highlightLabel="The latest window"
242
244
  dates={keys}
243
245
  format={costCompact}
244
246
  axisFormat={AXIS_FORMATTERS['cost-compact']}
@@ -215,6 +215,7 @@ function Compute({ compute }: { compute: NonNullable<NowData['compute']> }) {
215
215
  total={1}
216
216
  format={(n) => formatPercent(n, 1)}
217
217
  highlight={compute.fullest ?? undefined}
218
+ highlightLabel="The fullest region"
218
219
  items={compute.regions.map((r) => ({ key: r.region, label: <span className="font-mono text-xs">{r.region}</span>, value: r.share, meta: `${r.gpus} GPUs provisioned` }))}
219
220
  />
220
221
  </CardBody>
@@ -125,7 +125,7 @@ function WhenTheyCome({ figures }: { figures: UsageFigures }) {
125
125
  <CardBody className="flex flex-col gap-4">
126
126
  <Segmented label="Count when they come" size="sm" value={when} onChange={setWhen} options={WHEN} />
127
127
  {when === 'hour' ? (
128
- <ColumnChart columns={figures.hours} highlight={busiest.key} format={formatCompact} axisFormat={AXIS_FORMATTERS.count} label="Requests by hour of day" labelEvery={6} height={220} />
128
+ <ColumnChart columns={figures.hours} highlight={busiest.key} highlightLabel="The busiest hour" format={formatCompact} axisFormat={AXIS_FORMATTERS.count} label="Requests by hour of day" labelEvery={6} height={220} />
129
129
  ) : (
130
130
  <Heatmap values={figures.heat} timezone={app.timezone} scale="quantile" label="Requests by weekday and hour" format={formatCompact} />
131
131
  )}
@@ -21,4 +21,19 @@ describe("a card heading's whole text", () => {
21
21
  const { container } = render(<CardHeader title={<span>Requests <em>by region</em></span>} />);
22
22
  expect(container.querySelector('h2')!.hasAttribute('title')).toBe(false);
23
23
  });
24
+
25
+ // A head that cannot hold a word of its title beside its actions puts the actions on their own line instead (#87).
26
+ // jsdom lays nothing out, so what it can hold is the rule: the row wraps, the title's box has a floor, and the
27
+ // actions never shrink. `scripts/audit.ts` at 1024px is what shows the heading inside its box.
28
+ it('wraps its actions below the title when the two cannot share a line', () => {
29
+ const { container } = render(<CardHeader title="Revenue by plan" wrap actions={<button>Export</button>} />);
30
+ const head = container.firstElementChild!;
31
+ expect(head.className).toMatch(/\bflex-wrap\b/);
32
+ const [title, actions] = [...head.children];
33
+ expect(title.className).toMatch(/\bmin-w-32\b/);
34
+ expect(title.className).toMatch(/\bflex-1\b/);
35
+ expect(actions.className).toMatch(/\bshrink-0\b/);
36
+ // A title that wraps breaks a word too long for the line rather than drawing it over what is beside it.
37
+ expect(container.querySelector('h2')!.className).toMatch(/\[overflow-wrap:anywhere\]/);
38
+ });
24
39
  });
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment node
2
2
  import { describe, expect, test } from 'vitest';
3
3
  import { z } from 'zod';
4
- import { arrayCollection } from '@/lib/collection';
4
+ import { arrayCollection, visibleFields, writableFields } from '@/lib/collection';
5
5
 
6
6
  type Person = { id: string; name: string; team: 'Support' | 'Sales'; seats: number; lastActive: string | null };
7
7
  const ROWS: Person[] = [
@@ -121,6 +121,43 @@ describe('derived fields', () => {
121
121
  });
122
122
  });
123
123
 
124
+ describe('a field the collection names but does not declare', () => {
125
+ // A collection hides or page-owns a field its query computes rather than its `fields` declares — a joined label, a
126
+ // derived total — which the doc comments allow ("named but not sent"). The mask was built from the name alone, so
127
+ // `omit` threw on a key the shape never had, on every render (issue #91).
128
+ type Joined = Person & { displayName: string };
129
+ const joined = () =>
130
+ arrayCollection<Joined, 'id'>({
131
+ name: `joined${++n}`, label: 'People', description: 'd',
132
+ fields: z.object({ name: z.string(), team: z.enum(['Support', 'Sales']), seats: z.number(), lastActive: z.string().nullable() }),
133
+ hidden: ['displayName'],
134
+ pageOnlyFields: ['seats'],
135
+ key: 'id',
136
+ title: (r) => r.name,
137
+ rows: structuredClone(ROWS).map((r) => ({ ...r, displayName: r.name.toUpperCase() })),
138
+ allow: ['create', 'update'],
139
+ });
140
+
141
+ test('still masks what it can, and keeps the rest of the shape', () => {
142
+ const c = joined();
143
+ // A name the shape does not have is skipped; the ones it has are still masked.
144
+ const visible = visibleFields(c);
145
+ expect(Object.keys(visible.shape)).toEqual(['name', 'team', 'seats', 'lastActive']);
146
+ expect(Object.keys(writableFields(c).shape)).toEqual(['name', 'team', 'lastActive']);
147
+ });
148
+
149
+ test('reads, and still refuses a write the mask leaves out', async () => {
150
+ const c = joined();
151
+ expect((await c.query({ where: [] })).rows.map((r) => r.displayName)).toEqual(['ALICE MORENO', 'RAVI PATEL', 'AMARA OKAFOR']);
152
+ // The write schema has no key for a field the collection hid, nor for one a page owns, and being strict it refuses
153
+ // one that is sent rather than dropping it.
154
+ const write = writableFields(c);
155
+ expect(write.safeParse({ name: 'A', team: 'Sales', lastActive: null }).success).toBe(true);
156
+ expect(write.safeParse({ name: 'A', team: 'Sales', lastActive: null, displayName: 'A' }).success).toBe(false);
157
+ expect(write.safeParse({ name: 'A', team: 'Sales', lastActive: null, seats: 1 }).success).toBe(false);
158
+ });
159
+ });
160
+
124
161
  describe('offset and ties', () => {
125
162
  test('offset skips before the limit applies, and total still counts every hit', async () => {
126
163
  const r = await people().query({ sort: { field: 'seats', dir: 'asc' }, offset: 1, limit: 1 });
@@ -0,0 +1,24 @@
1
+ // @vitest-environment node
2
+ // The environment is the process's, not the file's: files share a worker (`pool: 'vmThreads'`). A file that stubs a
3
+ // store's variable for itself in a beforeAll — the natural way to exercise a stubbed Postgres — left it set for the
4
+ // file beside it, which then found a store configured and died on `getaddrinfo ENOTFOUND`. `tests/setup.ts` puts the
5
+ // environment back before every test; this is the proof, so the stub below is in a beforeAll on purpose (issue #95).
6
+ import { beforeAll, beforeEach, describe, expect, test } from 'vitest';
7
+
8
+ const NAME = 'DATABASE_URL';
9
+
10
+ describe('the environment between test files', () => {
11
+ beforeAll(() => { process.env[NAME] = 'postgres://stub/db'; });
12
+
13
+ test('is the one the setup found, so a stub in a beforeAll reaches no test at all', () => {
14
+ expect(process.env[NAME]).toBeUndefined();
15
+ });
16
+ });
17
+
18
+ describe('a stub a file sets for itself', () => {
19
+ beforeEach(() => { process.env[NAME] = 'postgres://own/db'; });
20
+
21
+ test('is there for the test that set it, which is why the stub belongs in a beforeEach', () => {
22
+ expect(process.env[NAME]).toBe('postgres://own/db');
23
+ });
24
+ });
@@ -0,0 +1,29 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { FilterBar } from '@/components/patterns/filter-bar';
4
+
5
+ /**
6
+ * A bar with more facets than room gave its search whatever the facets left: 129px at a 1440px window on a 2x display,
7
+ * where the placeholder needs 134, so the same page read "Headline, address, ru" on a Retina screen and the whole hint
8
+ * on a laptop (issue #93). The field now has a floor, and the facets truncate their own labels instead.
9
+ */
10
+ const facets = ['Headline', 'Address', 'Route', 'Status', 'Owner'].map((label, i) => ({
11
+ key: `facet-${i}`,
12
+ label,
13
+ value: 'all',
14
+ options: [{ value: 'all', label: 'All' }, { value: 'some', label: label }],
15
+ onChange: () => {},
16
+ }));
17
+
18
+ describe('a filter bar beside five facets', () => {
19
+ it('gives its search a floor, and still lets it grow to its cap', () => {
20
+ const { container } = render(<FilterBar filters={facets} search={{ value: '', onChange: () => {}, placeholder: 'Headline, address, route' }} result={<span>214 requests</span>} />);
21
+ const field = [...container.querySelectorAll('*')].find((el) => /\bmin-w-40\b/.test(el.className))!;
22
+ expect(field, 'the search field').toBeDefined();
23
+ expect(field.className).toMatch(/\bflex-1\b/);
24
+ expect(field.className).toMatch(/@min-\[52rem\]:max-w-72/);
25
+ // Ten rem at Meridian's 14px root is 140px: the 134px hint and nothing cut off it.
26
+ expect(field.className).not.toMatch(/\bmin-w-0\b/);
27
+ expect(container.querySelectorAll('[aria-label]')).toHaveLength(5);
28
+ });
29
+ });
@@ -38,8 +38,13 @@ describe('formatters', () => {
38
38
  expect(splitFigure('0.90%')).toEqual({ pre: undefined, int: '0.90', frac: undefined, unit: '%' });
39
39
  // Total: a string it cannot split comes back whole, so no formatter a product passes can crash the figure.
40
40
  expect(splitFigure('—')).toEqual({ int: '—' });
41
- expect(splitFigure('-1,234')).toEqual({ int: '-1,234' });
41
+ // A credit's minus belongs to the mark, with any symbol beside it (issue #96): `-$298.43` as one string was no split
42
+ // at all, so a tile set a sign-and-symbol figure whole in its number face, or lost both when it shortened.
43
+ expect(splitFigure('-1,234')).toEqual({ pre: '-', int: '1,234', frac: undefined, unit: undefined });
44
+ expect(splitFigure('-$298.43')).toEqual({ pre: '-$', int: '298', frac: '.43', unit: undefined });
42
45
  expect(splitFigure('CHF1,234.50')).toEqual({ pre: 'CHF', int: '1,234', frac: '.50', unit: undefined });
46
+ // A multi-letter mark before the symbol splits the same way.
47
+ expect(splitFigure('HK$12,345,678')).toEqual({ pre: 'HK$', int: '12,345,678', frac: undefined, unit: undefined });
43
48
  // A formatter that writes something else — a word, a space in the symbol — is never split, and never crashes.
44
49
  expect(splitFigure('CHF 1,234.50')).toEqual({ int: 'CHF 1,234.50' });
45
50
  });
@@ -53,10 +58,14 @@ describe('formatters', () => {
53
58
  expect(formatCost(1269.4)).toBe(`${symbol}1,269.40`);
54
59
  expect(formatCost(997)).toBe(`${symbol}997.00`);
55
60
  // A credit or a refund is negative, and the minus stands where a reader looks for it: before the mark, at every
56
- // size, as `formatCostCompact` already writes it. `$-0.0050` reads as a typo, and hides the sign a column is sorted by.
61
+ // size. `$-0.0050` reads as a typo, and hides the sign a column is sorted by.
57
62
  expect(formatCost(-1269.4)).toBe(`-${symbol}1,269.40`);
58
63
  expect(formatCost(-0.005)).toBe(`-${symbol}0.0050`);
59
64
  expect(formatCost(-0.0000018)).toBe(`-${symbol}0.0000018`);
65
+ // An amount too small even for 20 places keeps two digits in exponent form: `toFixed` there prints only zeroes, and
66
+ // a figure that is not nothing must not read as nothing (issue #96).
67
+ expect(formatCost(1e-21)).toBe(`${symbol}1.0e-21`);
68
+ expect(formatCost(-2.5e-30)).toBe(`-${symbol}2.5e-30`);
60
69
  expect(formatDuration(294)).toBe('294ms');
61
70
  expect(formatDuration(1420)).toBe('1.4s');
62
71
  expect(formatPercent(0.0090, 2)).toBe('0.90%');
@@ -101,6 +110,15 @@ describe('fitFigure', () => {
101
110
  expect(fitFigure('12,480,000 tokens', 12_480_000)).toBe('12.5M tokens');
102
111
  expect(fitFigure('Likely new', NaN)).toBe('Likely new');
103
112
  });
113
+
114
+ // The figure shortens with the mark and the sign it was given, not with a generic cost formatter's (issue #96): a
115
+ // tile read "$12.3M" for an amount in Hong Kong dollars, and dropped a credit's minus altogether.
116
+ it('shortens with the mark and the sign the caller\'s own text carries', () => {
117
+ expect(fitFigure('HK$12,345,678', 12_345_678)).toBe('HK$12.3M');
118
+ expect(fitFigure('-$1,234,567.89', -1_234_567.89)).toBe('-$1.2M');
119
+ expect(fitFigure("-1,234,567.89", -1_234_567.89)).toBe('-1.2M');
120
+ expect(fitFigure('CHF12,943,120', 12_943_120)).toBe('CHF12.9M');
121
+ });
104
122
  });
105
123
 
106
124
  describe('formatDay', () => {
@@ -30,6 +30,19 @@ describe('a key value list', () => {
30
30
  expect(path.getAttribute('title')).toBeNull();
31
31
  });
32
32
 
33
+ // On a phone there is no hover, so a title is no answer there: below the width where a sentence value cannot fit
34
+ // beside its label, the value wraps the way `wrap` asks for (issue #89). Above it, the single line and its title stay.
35
+ it('wraps a value in a row too narrow to hold it, rather than cutting it behind a hover no phone has', () => {
36
+ const [region] = dd([{ label: 'Region', value: 'ap-southeast-1' }]);
37
+ expect(region.className).toMatch(/@max-\[26rem\]:whitespace-normal/);
38
+ expect(region.className).toMatch(/@max-\[26rem\]:\[overflow-wrap:anywhere\]/);
39
+ expect(region.className).toMatch(/\btruncate\b/);
40
+ // A value that already wraps asks for nothing more, and keeps its own rule.
41
+ const [path] = dd([{ label: 'Path', value: '/v1/a/long/path', wrap: true }]);
42
+ expect(path.className).toMatch(/\[overflow-wrap:anywhere\]/);
43
+ expect(path.className).not.toMatch(/\btruncate\b|@max-\[26rem\]/);
44
+ });
45
+
33
46
  // Two columns are for a wide card, and a card's width is not the window's: at a 1024px window the request card is
34
47
  // 458px wide, and two columns by the window's breakpoint left each value about 50px.
35
48
  it('counts its two columns from its own width, not the window\'s', () => {
@@ -0,0 +1,98 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ // The chart marks are drawn from a measured width, so the size hook is replaced with one, as the chart tests do.
5
+ const size = vi.hoisted(() => ({ width: 720, height: 0 }));
6
+ vi.mock('@/components/charts/use-size', async () => {
7
+ const { useRef } = await import('react');
8
+ return { useSize: () => [useRef(null), size] as const };
9
+ });
10
+
11
+ import { BarList } from '@/components/charts/bar-list';
12
+ import { ColumnChart } from '@/components/charts/column-chart';
13
+ import { Sparkline } from '@/components/charts/sparkline';
14
+ import { TrendChart } from '@/components/charts/trend-chart';
15
+ import { ActivityFeed } from '@/components/patterns/activity-feed';
16
+ import { Delta } from '@/components/ui/delta';
17
+ import { StatusDot } from '@/components/ui/status-dot';
18
+
19
+ /**
20
+ * A colour, a fill or a size a reader is meant to decode, with nothing on the page to decode it with: the product's own
21
+ * answer was a title at every call site, page by page (issue #94). Each mark now says what it encodes, built from the
22
+ * data the component already holds, so it is right by default.
23
+ */
24
+ describe('a mark says what it encodes', () => {
25
+ it('a Bar list row names the row, its figure and its share, and does so without a total too', () => {
26
+ const items = [{ key: 'a', label: 'Checkout', value: 420 }, { key: 'b', label: 'Search', value: 180 }];
27
+ const { container } = render(<BarList label="Requests by route" items={items} total={1000} />);
28
+ const rows = [...container.querySelectorAll('li')];
29
+ expect(rows[0].getAttribute('title')).toBe('Checkout · 420 · 42.0% of 1,000');
30
+ // A row with no total still names itself and its figure: the old title had nothing to say without one.
31
+ expect(render(<BarList label="Requests by route" items={items} />).container.querySelector('li')!.getAttribute('title')).toBe('Checkout · 420');
32
+ });
33
+
34
+ it('an accented column says why it is the accent, on the column and in the table', () => {
35
+ const columns = [{ key: 'd1', label: 'Mon', value: 10 }, { key: 'd2', label: 'Tue', value: 30 }];
36
+ const { container } = render(<ColumnChart label="Errors per day" columns={columns} highlight="d2" highlightLabel="The day of the incident" />);
37
+ expect(container.querySelector('[data-column="d2"]')!.getAttribute('title')).toBe('The day of the incident');
38
+ expect(container.querySelector('[data-column="d1"]')!.getAttribute('title')).toBeNull();
39
+ expect(screen.getByRole('table')).toHaveTextContent('Accented: The day of the incident');
40
+ });
41
+
42
+ it('a marked day says what it is marked for, where the day itself is read', () => {
43
+ // The readout is where a pointed day is named, and the mark group takes no pointer, so a tooltip on the dot would be
44
+ // markup nothing can reach. Pointed at the marked day, the reading names it.
45
+ const { container } = render(
46
+ <TrendChart
47
+ label="Uptime"
48
+ dates={['2026-10-01', '2026-10-02', '2026-10-03']}
49
+ series={[{ key: 'u', label: 'Uptime', values: [99.9, 97.2, 99.8] }]}
50
+ marks={{ dates: ['2026-10-02'], tone: 'warning', label: 'Dropped below 99%' }}
51
+ data-preview-index={1}
52
+ />,
53
+ );
54
+ expect(container.querySelector('[data-readout]')!.textContent).toContain('02 Oct 2026 · Dropped below 99%');
55
+ // The day beside it is nobody's mark, and the reading says nothing about marks for it.
56
+ document.body.innerHTML = '';
57
+ const other = render(
58
+ <TrendChart
59
+ label="Uptime"
60
+ dates={['2026-10-01', '2026-10-02', '2026-10-03']}
61
+ series={[{ key: 'u', label: 'Uptime', values: [99.9, 97.2, 99.8] }]}
62
+ marks={{ dates: ['2026-10-02'], tone: 'warning', label: 'Dropped below 99%' }}
63
+ data-preview-index={0}
64
+ />,
65
+ );
66
+ expect(other.container.querySelector('[data-readout]')!.textContent).not.toContain('Dropped below 99%');
67
+ });
68
+
69
+ it('a sparkline says the range it draws, since it carries no readout', () => {
70
+ const { container } = render(<Sparkline values={[12, 480, 61]} />);
71
+ expect(container.firstElementChild!.getAttribute('title')).toBe('12 to 480');
72
+ });
73
+
74
+ it('a delta says whether its colour reads better or worse', () => {
75
+ // Down is good for latency: the number says which way, the colour says whether that is good, and the sentence says both.
76
+ expect(render(<Delta value={-0.12} intent="down" />).container.firstElementChild!.getAttribute('title')).toBe('Down 12.0%, better');
77
+ // Up is good where up is what the page wants, and bad where it is not.
78
+ expect(render(<Delta value={0.12} />).container.firstElementChild!.getAttribute('title')).toBe('Up 12.0%, better');
79
+ expect(render(<Delta value={0.12} intent="down" />).container.firstElementChild!.getAttribute('title')).toBe('Up 12.0%, worse');
80
+ // A neutral intent paints both directions the same, so there is no judgement to state.
81
+ expect(render(<Delta value={0.12} intent="neutral" />).container.firstElementChild!.getAttribute('title')).toBe('Up 12.0%');
82
+ expect(render(<Delta value={0} />).container.firstElementChild!.getAttribute('title')).toBe('Unchanged');
83
+ });
84
+
85
+ it('a status dot is a decoration beside a word and an image when it stands alone', () => {
86
+ const { container } = render(<StatusDot tone="critical" label="Failing" />);
87
+ expect(screen.getByRole('img', { name: 'Failing' })).toBeInTheDocument();
88
+ expect(container.firstElementChild!.getAttribute('title')).toBe('Failing');
89
+ // Beside its word it stays out of the way.
90
+ expect(render(<StatusDot tone="critical" />).container.firstElementChild!.getAttribute('aria-hidden')).toBe('true');
91
+ });
92
+
93
+ it('an activity\'s tone carries a word, which the dot never did', () => {
94
+ { const { container } = render(<ActivityFeed now={new Date('2026-10-24T10:00:00Z')} events={[{ id: 'e1', at: '2026-10-24T09:58:00Z', actor: 'Meridian', system: true, verb: 'failed to', object: 'sync the catalog', tone: 'critical' }]} />);
95
+ expect(container.querySelector('[role="img"]')!.getAttribute('aria-label')).toBe('Failed');
96
+ }
97
+ });
98
+ });
@@ -82,6 +82,37 @@ describe('a page headed by a sentence', () => {
82
82
  });
83
83
  });
84
84
 
85
+ describe('a sentence that opens with the page\'s own name', () => {
86
+ // "USDC : USDC: no rule met" — the name, the colon and then the sentence saying the name again, in the heading and
87
+ // in a screen reader's list of headings. The name stays in the markup, hidden from assistive technology (#88).
88
+ it('is announced once, and the name is still in the markup', () => {
89
+ render(<PageFrame name="USDC" title="USDC: no rule met, price from par inside its band">body</PageFrame>);
90
+ const [name, colon, sentence] = [...h1().children];
91
+ expect(name.textContent).toBe('USDC');
92
+ expect(colon.getAttribute('aria-hidden')).toBe('true');
93
+ expect(name.getAttribute('aria-hidden')).toBe('true');
94
+ expect(sentence.getAttribute('aria-hidden')).toBeNull();
95
+ expect(h1().textContent).toBe('USDC: USDC: no rule met, price from par inside its band');
96
+ });
97
+
98
+ it('reads the name beside a sentence that only begins with the same letters', () => {
99
+ render(<PageFrame name="Members" title="Membership revenue: $12,480 this period">body</PageFrame>);
100
+ expect([...h1().children][0].getAttribute('aria-hidden')).toBeNull();
101
+ });
102
+
103
+ it('takes the page\'s word for a sentence it cannot read, and holds the name while the sentence is still to come', () => {
104
+ // A sentence that is a node — a part that reads data — cannot be compared with the name before it arrives, so the
105
+ // page says so. Without the prop the name is read, which is the safe default.
106
+ const spoken = render(<PageFrame name="USDC" title={<span>USDC: no rule met</span>} speaksName>body</PageFrame>);
107
+ const [name] = [...h1().children];
108
+ expect(name.getAttribute('aria-hidden')).toBe('true');
109
+ spoken.unmount();
110
+ // A sentence that has not arrived cannot be compared with the name, so the name is what the heading says.
111
+ render(<PageFrame name="USDC">body</PageFrame>);
112
+ expect([...h1().children][0].getAttribute('aria-hidden')).toBeNull();
113
+ });
114
+ });
115
+
85
116
  describe('the masthead of a page headed by a sentence', () => {
86
117
  const kicker = <span data-testid="kicker">ZZ Meridian · Production</span>;
87
118
  const meta = <span data-testid="meta">Updated 4 min ago</span>;
@@ -24,8 +24,15 @@ describe('safeMarkdownUrl', () => {
24
24
  });
25
25
  it('fetches an image only from this origin', () => {
26
26
  expect(safeMarkdownUrl('/images/chart.png', 'src')).toBe('/images/chart.png');
27
+ expect(safeMarkdownUrl('images/chart.png', 'src')).toBe('images/chart.png');
27
28
  for (const u of ['https://elsewhere.example/p.png', '//elsewhere.example/p.png', 'data:image/png;base64,AAAA']) expect(safeMarkdownUrl(u, 'src')).toBe('');
28
29
  });
30
+ it('reads an image address the way the URL parser does, backslashes and all', () => {
31
+ // A backslash is a slash to the parser, so each of these IS `//evil.example/p.png` to the fetch while a check that
32
+ // reads the text as written sees a relative path — the exact case the rule exists to stop (issue #92).
33
+ for (const u of ['\\\\evil.example/p.png', '/\\evil.example/p.png', '\\/evil.example/p.png', '/\\\\evil.example/p.png'])
34
+ expect(safeMarkdownUrl(u, 'src'), u).toBe('');
35
+ });
29
36
  it('reads a colon after a slash as part of a path', () => {
30
37
  expect(safeMarkdownUrl('a/path:with-colon', 'href')).toBe('a/path:with-colon');
31
38
  });
@@ -1,7 +1,7 @@
1
1
  import '@testing-library/jest-dom/vitest';
2
2
  import { cleanup, configure } from '@testing-library/react';
3
3
  import { ReadableStream, TransformStream, WritableStream } from 'node:stream/web';
4
- import { afterEach, vi } from 'vitest';
4
+ import { afterEach, beforeEach, vi } from 'vitest';
5
5
 
6
6
  // A test file runs in a context of its own (the `vmThreads` pool in vitest.config.ts), and a jsdom context has no web
7
7
  // streams, which the assistant's client needs to read a reply: without them the panel never loads.
@@ -9,6 +9,17 @@ Object.assign(globalThis, { ReadableStream: globalThis.ReadableStream ?? Readabl
9
9
 
10
10
  afterEach(cleanup);
11
11
 
12
+ // The environment is the process's, not the file's: files share a worker (`pool: 'vmThreads'`, vitest.config.ts), so a
13
+ // file that stubs a store's variables for itself leaks the value into whatever file runs beside it — the intermittent
14
+ // gate where a view test found a store configured and asked a stub for a snapshot (issue #95). Every variable is put
15
+ // back as this file found it before each test, and this hook runs first, so a stub belongs in a beforeEach of the file's
16
+ // own and never in a beforeAll.
17
+ const ENV = { ...process.env };
18
+ beforeEach(() => {
19
+ for (const k of Object.keys(process.env)) if (!Object.hasOwn(ENV, k)) delete process.env[k];
20
+ for (const [k, v] of Object.entries(ENV)) if (process.env[k] !== v) process.env[k] = v;
21
+ });
22
+
12
23
  // The assistant loads on demand (next/dynamic); its first import can take over a second while the gate also runs the
13
24
  // type check and lint, so every waitFor and findBy allows five.
14
25
  configure({ asyncUtilTimeout: 5000 });