zz-meridian 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
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,22 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.14.1] · 2026-10-12
6
+
7
+ ### Fixed
8
+
9
+ - **A truncated cell keeps the whole value.** `DataTable`'s `truncate` adds `max-w-0 truncate` and nothing else, and this card's own spec has always said the cell carries the full text in its title — so a cut value was cut everywhere on the page and the whole one was kept nowhere. `TableCell` and `DataTable`'s cells now set that title themselves when the cell is a **value**; a column whose cell draws *more* than its value (an avatar beside a name, a method chip beside a route) passes the new `title: (row) => string`, because a component cannot keep what it cannot see. Measured on the template's own tables: 77 truncating cells across `/members`, `/keys`, `/customers` and `/requests`, every one now carrying its value. Additive: a column without `truncate` is unchanged, and a `title` you pass yourself still wins.
10
+ - **The audit proves it measured the page it asked for.** `MEASURE` never reported the path it was on, and every check it runs — sideways scroll, clipped text, an unnamed control, a heading skip, contrast, target size — is vacuously true of an empty page. A route that never rendered therefore passed, and the run ended with the standard's line over a page nobody had measured. It now reports the path and how many characters the page's own content region holds, and fails a route where either is wrong, **before** any other issue.
11
+ - **The keyboard check waits for the shell's chrome rather than for a duration.** The assistant's launcher enters the tab order when its streamed boundary resolves, so on a loaded machine the forward pass could measure 39 stops against a reverse pass of 40 and report the difference as a page defect. The wait is now anchored to the page's own signal — `[data-assistant-slot]`, in the page from the first byte and `aria-hidden` until the answer is known — bounded, then a few frames of the tab order holding still. With no assistant configured there is nothing to wait for and the check still returns in ~155 ms.
12
+ - **A phone card's title and its badge no longer depend on the column's order.** `title` and `status` shared one `order`, so a table that listed its status column first put the badge on the card's left edge and wrapped the title into the width left over; and a title cell kept its column's centre alignment on a phone. Both are fixed — the title leads, the badge follows, and a fact sits under them.
13
+ - **A count and its word come from one place.** `lib/format.ts` has a new `plural(n, one, other?)`, and the places that wrote their own `=== 1 ?` call it: a strip of one day's data read "over 1 days", a bar list read "1 others", and five more sites were doing the same arithmetic by hand. Additive: a new export, nothing else changed.
14
+ - **A stacked trend's legend keeps its ordering and becomes visible.** A stacked part is the accent faded toward `--surface`, so the palest of four is a whisper — fine as a band beside its neighbours, invisible as a 4px swatch alone on the light card. Every stacked swatch is now drawn on a 6px `ink-3` hairline: 4.66:1 in light, 6.86:1 in dark.
15
+
16
+ ### Changed
17
+
18
+ - **The craft recipe serves a built app.** `skills/zz-meridian/references/standard.md` prescribed `next dev` for the screenshot pass while line 142 of the same file prescribed `next start` for the audit. Under `next dev` a page's browser-side reads can fail to fire — one adopter produced **194 pictures of the loading state** and every check passed over them, because a skeleton has nothing to clip, nothing unnamed and nothing to contrast. The recipe now builds and serves, and says why.
19
+ - **The chart card states the rule a mark and a fill obey.** `chart-neutral` is a fill (a track, a band behind a mark) and `chart-neutral-strong` is what a mark takes when it is the quiet one; a stacked part's ramp is a rule about *areas that touch each other*, while a legend swatch has to differ from the **ground** behind it. Written with the measurements that produced it.
20
+
5
21
  ## [0.14.0] · 2026-10-11
6
22
 
7
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. DTCG tokens, five layers of React components and a Design Atlas, in a light and a dark theme.",
@@ -72,13 +72,14 @@ const WIDTHS = ATLAS ? [1440, 390] : [2560, 1440, 1024, 768, 390];
72
72
  const THEMES = ['dark', 'light'];
73
73
 
74
74
  type Report = {
75
+ path: string; text: number;
75
76
  sideways: string[]; clipped: string[]; focusless: string[]; unnamed: string[]; unmeasured: string[]; scrollers: string[]; contrast: string[]; targets: string[]; headings: string[];
76
77
  sizes: number[]; weights: number[]; radii: number[]; ratio: number;
77
78
  };
78
79
 
79
80
  /** Runs inside the page. Plain JavaScript: it is serialised into the browser. */
80
81
  const MEASURE = `(() => {
81
- const W = innerWidth, out = { sideways: [], clipped: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
82
+ const W = innerWidth, out = { path: location.pathname, text: 0, sideways: [], clipped: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
82
83
  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);
83
84
  /* closest() across open roots and slots: deepParent (injected from scripts/lib/deep.ts) steps through both */
84
85
  const deepClosest = (el, sel) => { for (let n = el; n; n = deepParent(n)) if (n.matches(sel)) return n; return null; };
@@ -173,6 +174,12 @@ const MEASURE = `(() => {
173
174
  out.ratio = sorted.length ? +(sorted[sorted.length - 1] / sorted[Math.floor(sorted.length / 2)]).toFixed(1) : 0;
174
175
  out.sizes = [...new Set(out.sizes)].sort((a, b) => a - b); out.weights = [...new Set(out.weights)].sort(); out.radii = [...new Set(out.radii)].sort((a, b) => a - b);
175
176
  out.contrast = [...new Set(out.contrast)].slice(0, 8); out.clipped = [...new Set(out.clipped)].slice(0, 8); out.unnamed = [...new Set(out.unnamed)].slice(0, 8); out.unmeasured = [...new Set(out.unmeasured)].slice(0, 8);
177
+ // A page that rendered nothing fails every check above without being told to: nothing sideways, nothing clipped, no
178
+ // unnamed control, no heading to skip, no colour, no target. So what was measured is recorded here — the path the
179
+ // browser is on, and how many characters the page's own content region holds — and the caller fails a route the
180
+ // audit never really reached. A loading skeleton holds none: its boxes have no text, its aria-label is an attribute.
181
+ const content = deepAll(document.body).find((n) => n.matches('[data-scroll-region]')) || document.body;
182
+ out.text = (content.textContent || '').replace(/\\s+/g, ' ').trim().length;
176
183
  return out;
177
184
  })()`;
178
185
 
@@ -225,6 +232,10 @@ const run = async (route: string, width: number, theme: string, embed: boolean)
225
232
  r.focusless = [...new Set(r.focusless)].slice(0, 6);
226
233
  const scrollers = r.scrollers.filter((s) => !ALLOWED_SCROLLERS.test(s));
227
234
  const issues = [
235
+ // First, because it is the one that fails in the direction of looking clean: a route that never rendered passes
236
+ // everything below, and the run would end with the standard's line over a page nobody measured.
237
+ ...(r.path.replace(/\/$/, '') !== route.replace(/\/$/, '') ? [`the audit measured ${r.path} while asking for ${route} — the route served another page`] : []),
238
+ ...(r.text < 24 ? [`the page holds ${r.text} characters of text — it rendered nothing, or only its loading state`] : []),
228
239
  ...r.sideways.map((x) => 'sideways: ' + x),
229
240
  ...r.clipped.map((x) => 'clipped: ' + x),
230
241
  ...r.unnamed.map((x) => 'unnamed: ' + x),
@@ -118,6 +118,32 @@ const press = async (modifiers: number) => {
118
118
  };
119
119
 
120
120
  const page = await launch();
121
+
122
+ /**
123
+ * Wait for the shell's chrome before walking the tab order, anchored to the page's own signal rather than to a duration.
124
+ * One control arrives late and is announced: the assistant's launcher sits inside a boundary that resolves when the
125
+ * request's own work does (`app/(dashboard)/layout.tsx` hands `AppShell` a promise, deliberately, so the frame never
126
+ * blocks). sgds-ac measured what a fixed wait costs: assistant off, 39 stops, pass; assistant on, 41 stops, pass idle and
127
+ * **fail under load**, a forward pass of 39 against a reverse pass of 40. So the wait is anchored to `[data-assistant-slot]`
128
+ * — in the page from the first byte, `aria-hidden` until the answer is known — and falls back to quiescence for anything
129
+ * else late. With no assistant configured there is nothing to wait for, so that is asked once, of the one endpoint that
130
+ * answers for it, and the wait is then a few frames.
131
+ */
132
+ const settle = async () => {
133
+ const hasAssistant = (await page.eval<number>('fetch("/api/assistant", { method: "POST" }).then((r) => r.status).catch(() => 404)')) !== 404;
134
+ await page.eval(`new Promise((done) => {
135
+ let last = '', stable = 0, tries = 0;
136
+ const count = () => deepAll(document.body).filter((el) => el.matches('a[href],button,input,select,textarea,[tabindex]') && !el.hasAttribute('disabled') && el.tabIndex >= 0).length;
137
+ const slotResolved = () => { const el = deepAll(document.body).find((n) => n.matches('[data-assistant-slot]')); return !el || el.getAttribute('aria-hidden') === null; };
138
+ const tick = () => {
139
+ const now = String(count()), ready = !${hasAssistant} || slotResolved();
140
+ if (ready && now === last) { if (++stable >= 3) return done(+now); } else { stable = 0; last = now; }
141
+ if (++tries > 140) return done(+now);
142
+ setTimeout(tick, 50);
143
+ };
144
+ tick();
145
+ })`);
146
+ };
121
147
  let failures = 0;
122
148
  for (const route of ROUTES) {
123
149
  await page.open(base + route, { width: 1440, height: 900, theme: 'dark', wait: 2500, cookie });
@@ -129,6 +155,7 @@ for (const route of ROUTES) {
129
155
  page.close();
130
156
  process.exit(1);
131
157
  }
158
+ await settle();
132
159
  const total = await page.eval<number>(TAG);
133
160
  const reached = new Set<string>();
134
161
  const order: string[] = [];
@@ -94,7 +94,7 @@ Rules that make every page look like it belongs:
94
94
  - **Charts on one page share one `Meridian`** (wrap the rows in `<Meridian dates={...}>`), so pointing at a day reads it
95
95
  everywhere. Two measures of a different size are two charts, never one chart with two axes.
96
96
  - **Tables**: mark one column `grow` (the lead), numbers `numeric`, low-value columns `hideBelow`; give `rowHref` so rows
97
- open their record. Below 768px a data table becomes a card list by itself.
97
+ open their record. Below 768px a `DataTable` becomes a card list by itself — a markdown table inside `Prose` does not: it keeps its columns and scrolls sideways, so a document meant to be read on a phone wants a list, not a table.
98
98
  - **Every state**: loading (`loading.tsx` with skeletons shaped like the page; read through `read()` so the page is
99
99
  in its prerendered shell and the skeleton is rarely seen, since a request-time read waits at least 300 ms behind it:
100
100
  `cache.md`), empty (Empty state with the one action), error (`error.tsx`, what failed and Retry).
@@ -96,13 +96,13 @@ refuses, listing every gap, until these hold.
96
96
 
97
97
  ### The craft: judged on the renders, with evidence
98
98
 
99
- Look at every view rendered, not at the code. With `node_modules/.bin/next dev --port 3100` running (the port the screenshot and audit
100
- scripts read; stop it again before `pnpm verify`, which builds in the same folder), run
99
+ Look at every view rendered, not at the code. Serve a production build — `pnpm build`, then `node_modules/.bin/next start --port 3100` (the port the screenshot and audit
100
+ scripts read; stop it again before `pnpm verify`, which builds in the same folder) — and run
101
101
  `node scripts/shot.ts <routes> --width 1440,390 --theme dark,light --full`, plus 768 and 2560 where the layout depends on
102
- width (tables, grids, side panels), and open every PNG in `out/shots/`. A dialog, sheet, menu or popover is shot open:
102
+ width (tables, grids, side panels), and open every PNG in `out/shots/`. **A craft screenshot is evidence only from a built app:** under `next dev` a page's browser-side reads can fail to fire, and the render is then a picture of the loading state — which passes every check, because a skeleton has nothing to clip, nothing unnamed and nothing to contrast. A dialog, sheet, menu or popover is shot open:
103
103
  `--press "<the name of the control that opens it>"` (the first visible control with that name; give row actions names
104
104
  of their own, "Delete order CR-10425"), repeated for a menu item inside it. With a fake API, every server a render or
105
- a check reads from is started with its address (`API_URL=<fake> node_modules/.bin/next dev …`), never the `.env` one. `node scripts/audit.ts --routes <routes>` against
105
+ a check reads from is started with its address (`API_URL=<fake> node_modules/.bin/next start …`), never the `.env` one. `node scripts/audit.ts --routes <routes>` against
106
106
  the same server ends with "Design metrics at 1440" for each route: the type sizes and weights in use, the radii, and the
107
107
  hierarchy ratio (the largest text over the median).
108
108
 
@@ -1,4 +1,5 @@
1
1
  import { cn } from '@/lib/cn';
2
+ import { plural } from '@/lib/format';
2
3
 
3
4
  export type BarItem = { key: string; label: React.ReactNode; value: number; meta?: React.ReactNode; href?: string };
4
5
 
@@ -31,7 +32,7 @@ export function BarList({
31
32
  const cut = sorted.length === limit + 1 ? limit + 1 : limit;
32
33
  const shown = sorted.slice(0, cut);
33
34
  const rest = sorted.slice(cut);
34
- if (rest.length) shown.push({ key: '__others', label: `${rest.length} others`, value: rest.reduce((a, b) => a + b.value, 0) });
35
+ if (rest.length) shown.push({ key: '__others', label: `${rest.length} ${plural(rest.length, 'other')}`, value: rest.reduce((a, b) => a + b.value, 0) });
35
36
  const max = Math.max(1, total ?? 0, ...shown.map((i) => i.value));
36
37
  return (
37
38
  <ul aria-label={label} className={cn('flex flex-col gap-3.5', className)}>
@@ -27,6 +27,8 @@ Status: beta
27
27
 
28
28
  Colour: `accent` for the series the page is about; categorical slots 1 to 6 in order for peers; never a status colour unless the series means a status.
29
29
 
30
+ **A mark takes the strong quiet; a swatch needs a floor, not just a neighbour.** `chart-neutral` (#D5D7E0 in light) is a *fill* — a track, a band behind a mark — and `chart-neutral-strong` is what a *mark* takes when it is the quiet one, because a mark's job is to be seen against the ground: measured in light, the strong variant is 3.19:1 while the fill is under 3:1 on any light ground, which is how seven bars in a share column came to render blank. A stacked part is the accent faded toward `--surface`, and *that* is a rule about areas that touch each other, where each step only has to differ from its neighbour — a legend swatch has to differ from the ground behind it, and nothing in the ramp's parameters knows that. So every stacked swatch is drawn on a 6px `ink-3` hairline (measured 4.66:1 light, 6.86:1 dark), which keeps the ramp's ordering readable instead of flattening it.
31
+
30
32
  ## Sizes
31
33
 
32
34
  | Height | Gridlines | Use |
@@ -257,6 +257,11 @@ export function TrendChart({
257
257
  {series.map((s, k) => (
258
258
  <li key={s.key} className="flex items-center gap-1.5">
259
259
  <svg width="14" height="4" className="shrink-0 overflow-visible">
260
+ {/* Stacked, a part can be the accent at 36% toward the surface — as a 4px line on a light card that is a whisper,
261
+ and a swatch is the one mark whose whole job is to be seen. The hairline behind it does that without
262
+ flattening the tone ordering, which is the rest of what the legend is telling: measured, the palest of four
263
+ parts in light sits at 0.83 alpha, in dark at 0.69. */}
264
+ {stacked ? <line x1="1" x2="13" y1="2" y2="2" stroke="var(--ink-3)" strokeWidth={6} strokeLinecap="round" /> : null}
260
265
  <line x1="1" x2="13" y1="2" y2="2" stroke={colorOf(s, k)} strokeWidth={stacked ? 4 : 2} strokeLinecap="round" strokeDasharray={s.kind === 'dashed' ? '2 3' : undefined} />
261
266
  </svg>
262
267
  {s.label}
@@ -3,6 +3,7 @@
3
3
  import { useId, useState } from 'react';
4
4
  import { cn } from '@/lib/cn';
5
5
  import { formatDate } from '@/lib/format-date';
6
+ import { plural } from '@/lib/format';
6
7
 
7
8
  export type DayState = 'operational' | 'degraded' | 'outage' | 'none';
8
9
  /* Healthy days are the baseline, one quiet rect across the period, so the eye lands on the days something happened.
@@ -101,7 +102,7 @@ export function UptimeBars({
101
102
  ) : null}
102
103
  <p className="sr-only" aria-live="polite">{hover !== null ? `${formatDate(dateOf(hover))}: ${WORD[shown[hover]]}` : ''}</p>
103
104
  <p id={summaryId} className="sr-only">
104
- {`${label}: ${pct(uptime)} ${measure} over ${shown.length} days, ${formatDate(dateOf(0))} to ${formatDate(dateOf(shown.length - 1))}.`}
105
+ {`${label}: ${pct(uptime)} ${measure} over ${shown.length} ${plural(shown.length, 'day')}, ${formatDate(dateOf(0))} to ${formatDate(dateOf(shown.length - 1))}.`}
105
106
  {marked.length === 0 ? ' No incidents.' : marked.map(({ d, i }) => ` ${formatDate(dateOf(i))}: ${WORD[d]}.`).join('')}
106
107
  </p>
107
108
  </figure>
@@ -26,6 +26,12 @@ export type Column<R> = {
26
26
  numeric?: boolean;
27
27
  muted?: boolean;
28
28
  truncate?: boolean;
29
+ /**
30
+ * The whole value, for a cell that draws *more* than it: an avatar beside a name, a method chip beside a route. A cell
31
+ * that is a plain value needs none — the component titles it from the value itself, because a value it can see is a
32
+ * value it can keep. A cell it cannot see (a node) it cannot keep, which is what this is for.
33
+ */
34
+ title?: (row: R) => string;
29
35
  /** Drop the column below this width (the least important columns go first). */
30
36
  hideBelow?: Breakpoint;
31
37
  /** The one column that takes the remaining width: the record's name. */
@@ -97,8 +103,11 @@ const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!h-auto @max-
97
103
  * card's own rule is the role, not the width. */
98
104
  const PHONE_ROLES = [
99
105
  '@max-[640px]:[&_td[data-mobile=check]]:order-0 @max-[640px]:[&_td[data-mobile=check]]:mr-1.5',
100
- '@max-[640px]:[&_td[data-mobile=title]]:order-1 @max-[640px]:[&_td[data-mobile=title]]:flex-1 @max-[640px]:[&_td[data-mobile=title]]:font-medium',
101
- '@max-[640px]:[&_td[data-mobile=status]]:order-1 @max-[640px]:[&_td[data-mobile=status]]:ml-auto @max-[640px]:[&_td[data-mobile=status]]:shrink-0',
106
+ '@max-[640px]:[&_td[data-mobile=title]]:order-1 @max-[640px]:[&_td[data-mobile=title]]:flex-1 @max-[640px]:[&_td[data-mobile=title]]:font-medium @max-[640px]:[&_td[data-mobile=title]]:!text-left',
107
+ // The badge sits beside the title, never before it: with the same `order` the column order decided which of the two took
108
+ // the card's left edge, so a table that listed a status column before its title wrapped the title into the width the
109
+ // badge left it. `check` stays ahead of both, and a fact follows them.
110
+ '@max-[640px]:[&_td[data-mobile=status]]:order-2 @max-[640px]:[&_td[data-mobile=status]]:ml-auto @max-[640px]:[&_td[data-mobile=status]]:shrink-0',
102
111
  // Facts flow on the second line, left to right whatever their column's alignment, each whole; one longer than the
103
112
  // card ends in an ellipsis.
104
113
  // A fact is a flex row of its content and its dot, so the dot stays beside it whatever element the cell renders.
@@ -308,9 +317,13 @@ export function DataTable<R>({
308
317
  const role = roleOf(c);
309
318
  // The title is the row's link wherever it is shown, phone included: the whole card takes the
310
319
  // pointer too (the row's own click), but the link is what a keyboard and a screen reader follow.
311
- const content = c === titleColumn && href ? <Link href={href}>{c.cell(r)}</Link> : c.cell(r);
320
+ // The value is called once: it is both what we draw and what the title carries when the column
321
+ // truncates. A cut value is cut everywhere on the page — a slug's tail is the part that identifies
322
+ // it — and this card's own spec has said so since before the component did it.
323
+ const value = c.cell(r);
324
+ const content = c === titleColumn && href ? <Link href={href}>{value}</Link> : value;
312
325
  return (
313
- <td key={c.key} data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} className={cellClass(c) || undefined}>
326
+ <td key={c.key} data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} title={c.title ? c.title(r) : c.truncate && (typeof value === 'string' || typeof value === 'number') ? String(value) : undefined} className={cellClass(c) || undefined}>
314
327
  {/* A phone may want other words for the same cell — "Used 1 min ago", not "1 min ago" — so
315
328
  both are rendered and one is hidden: a few words each, never a second copy of the row. */}
316
329
  {c.mobileCell ? (
@@ -6,6 +6,7 @@ import { StatusDot } from '@/components/ui/status-dot';
6
6
  import type { Tone } from '@/components/ui/badge';
7
7
  import { UptimeBars } from '@/components/charts/uptime-bars';
8
8
  import { summarise, SERVICE_NOUN, type Noun } from './summarise';
9
+ import { plural } from '@/lib/format';
9
10
 
10
11
  export { summarise };
11
12
 
@@ -67,7 +68,7 @@ export function StatusList({
67
68
  <div role="status" className="flex items-center gap-3 border-b border-line px-(--card-pad) py-4">
68
69
  <StatusDot tone={head.tone} live={s.status !== 'operational'} className="size-2.5" />
69
70
  <p className={cn('text-md font-semibold tracking-[-0.012em]', s.status === 'operational' ? 'text-ink' : head.ink)}>{s.text}</p>
70
- <p className="t-caption ml-auto hidden @md:block">{services.length} {services.length === 1 ? noun.one : noun.other}</p>
71
+ <p className="t-caption ml-auto hidden @md:block">{services.length} {plural(services.length, noun.one, noun.other)}</p>
71
72
  </div>
72
73
  ) : null}
73
74
  <ul className={ROWS}>
@@ -1,4 +1,5 @@
1
1
  import type { Service, ServiceStatus } from '@/components/patterns/status-list';
2
+ import { plural } from '@/lib/format';
2
3
 
3
4
  export type Noun = { one: string; other: string };
4
5
  export const SERVICE_NOUN: Noun = { one: 'service', other: 'services' };
@@ -7,8 +8,8 @@ export const SERVICE_NOUN: Noun = { one: 'service', other: 'services' };
7
8
  export function summarise(services: Service[], noun: Noun = SERVICE_NOUN) {
8
9
  const down = services.filter((s) => s.status === 'outage').length;
9
10
  const degraded = services.filter((s) => s.status === 'degraded').length;
10
- if (down) return { status: 'outage' as const, text: `${down} ${down === 1 ? `${noun.one} is` : `${noun.other} are`} down` };
11
- if (degraded) return { status: 'degraded' as const, text: `${degraded} ${degraded === 1 ? noun.one : noun.other} degraded` };
11
+ if (down) return { status: 'outage' as const, text: `${down} ${plural(down, `${noun.one} is`, `${noun.other} are`)} down` };
12
+ if (degraded) return { status: 'degraded' as const, text: `${degraded} ${plural(degraded, noun.one, noun.other)} degraded` };
12
13
  return { status: 'operational' as const, text: `All ${noun.other} operational` };
13
14
  }
14
15
 
@@ -132,6 +132,10 @@ export function TableCell({
132
132
  return (
133
133
  <td
134
134
  data-num={numeric || align === 'right' || undefined}
135
+ // A cut value is cut everywhere on the page, and this README has always said the cell carries the whole one in its
136
+ // title. The component does it now for a cell that is a value, rather than each page remembering; a cell that is a
137
+ // node is its author's, and a `title` passed in wins over this one because the spread comes after.
138
+ title={truncate && (typeof children === 'string' || typeof children === 'number') ? String(children) : undefined}
135
139
  className={cn(
136
140
  'h-(--row-height) px-4 align-middle',
137
141
  ALIGN[align ?? (numeric ? 'right' : 'left')],
@@ -12,6 +12,13 @@ import { app } from '@/app.config';
12
12
  /** The symbol for `app.currency`: $, €, £, ¥; the narrow symbol, so SGD reads $1,234, not SGD1,234. */
13
13
  const CURRENCY = new Intl.NumberFormat('en-US', { style: 'currency', currency: app.currency, currencyDisplay: 'narrowSymbol' }).formatToParts(0).find((p) => p.type === 'currency')?.value ?? app.currency;
14
14
 
15
+ /** A count with its word: 1 day, 2 days; 1 key has, 3 keys have. `other` is for the words that do not take an `s`.
16
+ * It lives here for the reason the header gives: a count reads the same in a tile, a table cell, a tooltip and an
17
+ * aria-label, and the ten places that wrote their own `=== 1 ?` are how two of them came to say "1 days" and "1 others". */
18
+ export function plural(n: number, one: string, other?: string): string {
19
+ return n === 1 ? one : (other ?? `${one}s`);
20
+ }
21
+
15
22
  /** Built once: an Intl formatter costs far more to construct than to use, and a table formats a figure per cell. */
16
23
  const INT = new Intl.NumberFormat('en-US');
17
24
  const CENTS = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -53,6 +53,9 @@ export const requestColumns: Column<RequestRow>[] = [
53
53
  {
54
54
  key: 'request', header: 'Request', grow: true, truncate: true, mobile: 'title',
55
55
  cell: (r) => <RouteCell method={r.method} route={r.route} />,
56
+ // The chip and the route are a node, so the cell cannot title itself: without this the one column this table
57
+ // truncates cut the value and kept it nowhere. The two columns beside it had remembered; this one had not.
58
+ title: (r) => `${r.method} ${r.route}`,
56
59
  sortValue: (r) => r.route,
57
60
  },
58
61
  { key: 'status', header: 'Status', mobile: 'status', cell: (r) => <StatusBadge status={r.status} />, sortValue: (r) => r.status },
@@ -28,7 +28,7 @@ const STATUS_LABEL = { active: 'Active', trial: 'Trial', 'past due': 'Past due'
28
28
 
29
29
  const columns: Column<CustomerRecord>[] = [
30
30
  {
31
- key: 'name', header: 'Customer', grow: true, truncate: true, mobile: 'title', sortValue: (c) => c.name,
31
+ key: 'name', header: 'Customer', grow: true, truncate: true, mobile: 'title', sortValue: (c) => c.name, title: (c) => c.name,
32
32
  cell: (c) => (
33
33
  <span className="flex min-w-0 items-center gap-3">
34
34
  <Avatar name={c.name} size="sm" />
@@ -3,6 +3,7 @@
3
3
  * unused. A key's secret, and its hash, are never in it: the secret is shown once, on this page, and nowhere else.
4
4
  */
5
5
  import type { ApiKey } from '@/data/sample';
6
+ import { plural } from '@/lib/format';
6
7
  import { formatDate } from '@/lib/format-date';
7
8
  import { listed, type Insight, type SharedContext } from '@/lib/shared-context';
8
9
 
@@ -16,7 +17,7 @@ export function keysContext(rows: ApiKey[], now: string, scopes: readonly string
16
17
  const live = rows.filter((k) => k.env === 'live');
17
18
  const insights: Insight[] = [];
18
19
  const idle = live.filter((k) => (unused(k) ?? Infinity) >= UNUSED_DAYS);
19
- if (idle.length) insights.push({ text: `${idle.length} live ${idle.length === 1 ? 'key has' : 'keys have'} not been used for ${UNUSED_DAYS} days or more: ${idle.map((k) => `${k.name} (${k.lastUsed ? `${unused(k)} days` : 'never used'}, owner ${k.owner})`).join(', ')}.` });
20
+ if (idle.length) insights.push({ text: `${idle.length} live ${plural(idle.length, 'key has', 'keys have')} not been used for ${UNUSED_DAYS} days or more: ${idle.map((k) => `${k.name} (${k.lastUsed ? `${unused(k)} days` : 'never used'}, owner ${k.owner})`).join(', ')}.` });
20
21
  const broad = live.filter((k) => scopes.every((s) => k.scopes.includes(s)));
21
22
  if (broad.length) insights.push({ text: `${broad.map((k) => k.name).join(', ')} ${broad.length === 1 ? 'is a live key' : 'are live keys'} with every scope; a narrower key limits what a leak can reach.` });
22
23
  const owners = new Map<string, number>();
@@ -86,7 +86,7 @@ export function KeysView({ rows, now, createKey, revokeKey, may = { create: true
86
86
  };
87
87
  const columns: Column<ApiKey>[] = [
88
88
  {
89
- key: 'name', header: 'Name', grow: true, truncate: true, mobile: 'title', sortValue: (k) => k.name,
89
+ key: 'name', header: 'Name', grow: true, truncate: true, mobile: 'title', sortValue: (k) => k.name, title: (k) => k.name,
90
90
  cell: (k) => (
91
91
  <span className="flex min-w-0 items-center gap-2.5">
92
92
  <span className="truncate font-medium">{k.name}</span>
@@ -3,6 +3,7 @@
3
3
  * agents read it.
4
4
  */
5
5
  import type { Member } from '@/data/sample';
6
+ import { plural } from '@/lib/format';
6
7
  import { ADD_ONS, GRANTS, MAIN_ROLES, roleLabel, whoCan } from '@/data/roles';
7
8
  import type { Grant } from '@/lib/collection';
8
9
  import { formatDate } from '@/lib/format-date';
@@ -37,8 +38,8 @@ export function membersContext(rows: Member[], now: string): SharedContext {
37
38
  const mayChange = whoCan('members:update');
38
39
  const admins = active.filter((m) => mayChange.includes(m.role));
39
40
  const insights: Insight[] = [];
40
- if (quiet.length) insights.push({ text: `${quiet.length} active ${quiet.length === 1 ? 'member has' : 'members have'} not been seen for ${QUIET_DAYS} days or more: ${listed(quiet, (m) => `${m.name} (${m.role}, ${m.team}, ${idle(m)} days)`, 'query_members finds them', 10)}.` });
41
- if (invited.length) insights.push({ text: `${invited.length} ${invited.length === 1 ? 'invitation is' : 'invitations are'} unanswered: ${listed(invited, (m) => `${m.name}, invited ${formatDate(m.joined)}`, 'query_members finds them', 10)}.` });
41
+ if (quiet.length) insights.push({ text: `${quiet.length} active ${plural(quiet.length, 'member has', 'members have')} not been seen for ${QUIET_DAYS} days or more: ${listed(quiet, (m) => `${m.name} (${m.role}, ${m.team}, ${idle(m)} days)`, 'query_members finds them', 10)}.` });
42
+ if (invited.length) insights.push({ text: `${invited.length} ${plural(invited.length, 'invitation is', 'invitations are')} unanswered: ${listed(invited, (m) => `${m.name}, invited ${formatDate(m.joined)}`, 'query_members finds them', 10)}.` });
42
43
  insights.push({ text: `${admins.length} of ${active.length} active members can change the workspace (${mayChange.join(' or ')}): ${listed(admins, (m) => m.name, 'query_members finds them', 10)}.` });
43
44
  return {
44
45
  view: 'members',
@@ -199,7 +199,7 @@ export function MembersView({ rows, now, actions, access, roles }: { rows: Membe
199
199
 
200
200
  const columns: Column<Shown>[] = [
201
201
  {
202
- key: 'name', header: 'Name', grow: true, truncate: true, mobile: 'title', sortValue: (m) => m.name,
202
+ key: 'name', header: 'Name', grow: true, truncate: true, mobile: 'title', sortValue: (m) => m.name, title: (m) => m.name,
203
203
  cell: (m) => (
204
204
  <span className="flex min-w-0 items-center gap-3">
205
205
  <Avatar name={m.name} size="sm" />
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { plural } from '@/lib/format';
3
+ import { summarise } from '@/components/patterns/status-list/summarise';
4
+
5
+ /** A count is a count: one of anything reads as one, whichever place writes the sentence. */
6
+ describe('a count and its word', () => {
7
+ it('takes the singular for one and the plural otherwise', () => {
8
+ expect(plural(0, 'day')).toBe('days');
9
+ expect(plural(1, 'day')).toBe('day');
10
+ expect(plural(2, 'day')).toBe('days');
11
+ expect(plural(1, 'key has', 'keys have')).toBe('key has');
12
+ expect(plural(4, 'key has', 'keys have')).toBe('keys have');
13
+ });
14
+
15
+ it('reads as one in the line a reader takes away, not "1 services are down"', () => {
16
+ const one = [{ name: 'Edge gateway', description: '', status: 'outage' as const, uptime: 0.99, latency: 38, days: [] }];
17
+ expect(summarise(one).text).toBe('1 service is down');
18
+ expect(summarise([...one, { ...one[0], name: 'Files' }]).text).toBe('2 services are down');
19
+ });
20
+ });
@@ -0,0 +1,52 @@
1
+ import type { ReactNode } from 'react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { render } from '@testing-library/react';
4
+ import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
5
+
6
+ /** A truncated cell keeps the whole value: `truncate` cuts what is drawn, never what can be read. */
7
+ describe('a truncated cell', () => {
8
+ const td = (node: ReactNode) =>
9
+ render(
10
+ <Table>
11
+ <TableBody>
12
+ <TableRow>
13
+ <TableCell truncate>{node}</TableCell>
14
+ </TableRow>
15
+ </TableBody>
16
+ </Table>,
17
+ ).container.querySelector('td')!;
18
+
19
+ it('carries the full value in its title', () => {
20
+ expect(td('/v1/messages/with/a/long/tail').getAttribute('title')).toBe('/v1/messages/with/a/long/tail');
21
+ expect(td(612).getAttribute('title')).toBe('612');
22
+ });
23
+
24
+ it('leaves a cell its author crafted, and a title the caller passed wins', () => {
25
+ expect(td(<span title="its own">a node</span>).getAttribute('title')).toBeNull();
26
+ const own = render(
27
+ <Table>
28
+ <TableBody>
29
+ <TableRow>
30
+ <TableCell truncate title="mine">
31
+ /v1/messages
32
+ </TableCell>
33
+ </TableRow>
34
+ </TableBody>
35
+ </Table>,
36
+ ).container.querySelector('td')!;
37
+ expect(own.getAttribute('title')).toBe('mine');
38
+ });
39
+
40
+ it('adds nothing when the column does not truncate', () => {
41
+ const plain = render(
42
+ <Table>
43
+ <TableBody>
44
+ <TableRow>
45
+ <TableCell>/v1/messages</TableCell>
46
+ </TableRow>
47
+ </TableBody>
48
+ </Table>,
49
+ ).container.querySelector('td')!;
50
+ expect(plain.getAttribute('title')).toBeNull();
51
+ });
52
+ });