zz-meridian 0.27.0 → 0.27.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.27.0",
3
+ "version": "0.27.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,17 @@
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.27.1] · 2026-10-24
6
+
7
+ ### Fixed
8
+
9
+ - **`verify` and `live` start the app they measure on loopback** ([#81](https://github.com/zhixuan312/zz-meridian/issues/81)). `next start` with no host bound every interface, so for the half hour a full run takes, the app under test — served with the fake data source — answered on the local network. Both start it with `-H 127.0.0.1`; every client of a run already reached it there.
10
+ - **The keyboard walk and the presses open a disclosure before reaching into it** ([#82](https://github.com/zhixuan312/zz-meridian/issues/82)). A closed `<details>` lays its content out without drawing it, so a button inside was tagged as visible and never reached, and its press found nothing at its centre; `verify --full` failed a page with a folded table. Both suites open the disclosures they walk into, and the presses leave shut the one whose own `summary` holds the control.
11
+ - **The shell's Search button keeps its name below 40rem** ([#83](https://github.com/zhixuan312/zz-meridian/issues/83)). The only word naming it was `display: none`, leaving an icon-only button with no accessible name on every view at 390px. It is `sr-only` now: the word is not drawn, and it is still the button's name.
12
+ - **The rail's menu trigger separates the product name from the workspace** ([#84](https://github.com/zhixuan312/zz-meridian/issues/84)). Its visible text ran the two together, and WCAG 2.5.3 asks for the visible label inside the accessible name, where a comma alone kept them apart. A space now sits between them: invisible between two block spans, present for the name.
13
+ - **A table written in a server component lays out like one written on the client** ([#85](https://github.com/zhixuan312/zz-meridian/issues/85)). `Table` found its columns by component identity, and a server component reaches the client table as lazy references, so it found none and drew every column as one. It counts them from the table's shape now: the first row of the first section that has one, its component cells, through fragments.
14
+ - **The audit fails a strip that scrolls sideways at a phone width, and text drawn past a box that does not clip it** ([#86](https://github.com/zhixuan312/zz-meridian/issues/86)). A strip of chips wider than 390px hid its last items with nothing saying so, and a figure too long for its grid column passed while drawn over its neighbour and past its card. The first is reported unless it is the page's scroll region or draws the system's cut-edge fade (`scroll-fade-x`); the second is measured on the glyphs with a `Range`. The stricter reading found the Atlas counts row overflowing its cells at 1024, which now keeps four columns until `xl` and draws a rule only at the start of a row. The prose tables and code blocks that legitimately scroll now draw that fade.
15
+
5
16
  ## [0.27.0] · 2026-10-23
6
17
 
7
18
  ### Breaking
@@ -63,9 +63,12 @@ export default function AtlasHome() {
63
63
  </section>
64
64
 
65
65
  {/* Counts */}
66
- <section aria-label="The system in numbers" className="mt-20 grid grid-cols-2 border-y border-line sm:grid-cols-4 lg:grid-cols-8">
66
+ {/* Eight columns only where the numbers and their labels fit them: at 1024 with the rail, eight cells were 49px wide
67
+ and five labels ran past the cell's edge and its neighbour's padding. Four until xl, and a rule only at the start
68
+ of a row, whichever count is in that row. */}
69
+ <section aria-label="The system in numbers" className="mt-20 grid grid-cols-2 border-y border-line sm:grid-cols-4 xl:grid-cols-8">
67
70
  {counts.map(([n, l], i) => (
68
- <div key={l} className={'px-1 py-6 sm:px-5 ' + (i > 0 ? 'lg:border-l lg:border-line' : '')}>
71
+ <div key={l} className={'border-line px-1 py-6 sm:px-5 ' + (i % 2 ? 'border-l ' : '') + (i % 4 === 0 ? 'sm:border-l-0 ' : 'sm:border-l ') + (i > 0 ? 'xl:border-l' : 'xl:border-l-0')}>
69
72
  <p className="t-figure t-num">{n}</p>
70
73
  <p className="t-eyebrow mt-2">{l}</p>
71
74
  </div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
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.",
@@ -18,8 +18,9 @@
18
18
  * light-DOM content, and one that is still undefined after the settle wait, are reported as `unmeasured:` instead of passed.
19
19
  *
20
20
  * Fails (exit 1) on: a route that served another page or dropped a parameter it was asked for, a page whose content
21
- * region rendered nothing or that threw or logged a failure while it rendered, sideways scroll, text clipped without an
22
- * ellipsis, text cut with an ellipsis that keeps the whole of it nowhere, two pieces of text in the same place or a line
21
+ * region rendered nothing or that threw or logged a failure while it rendered, sideways scroll (a strip that scrolls at a
22
+ * phone width included), text clipped without an ellipsis, text drawn past a box that does not clip it, text cut with an
23
+ * ellipsis that keeps the whole of it nowhere, two pieces of text in the same place or a line
23
24
  * drawn through one, a control with no accessible name or one under 44px on touch, a heading two levels below the one
24
25
  * before it, more than one page scroller, and rendered text under its WCAG minimum, an SVG's text included, read by its
25
26
  * `fill` against whatever the SVG paints behind it. Prints the design metrics per page: distinct type sizes, weights and
@@ -83,13 +84,13 @@ if (atlasLine) console.log(atlasLine);
83
84
 
84
85
  type Report = {
85
86
  path: string; href: string; text: number;
86
- sideways: string[]; clipped: string[]; truncated: string[]; overlap: string[]; focusless: string[]; unnamed: string[]; unmeasured: string[]; scrollers: string[]; contrast: string[]; targets: string[]; headings: string[];
87
+ sideways: string[]; clipped: string[]; spills: string[]; truncated: string[]; overlap: string[]; focusless: string[]; unnamed: string[]; unmeasured: string[]; scrollers: string[]; contrast: string[]; targets: string[]; headings: string[];
87
88
  sizes: number[]; weights: number[]; radii: number[]; ratio: number;
88
89
  };
89
90
 
90
91
  /** Runs inside the page. Plain JavaScript: it is serialised into the browser. */
91
92
  const MEASURE = `(() => {
92
- const W = innerWidth, out = { path: location.pathname, href: location.href, text: 0, sideways: [], clipped: [], truncated: [], overlap: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
93
+ const W = innerWidth, out = { path: location.pathname, href: location.href, text: 0, sideways: [], clipped: [], spills: [], truncated: [], overlap: [], unnamed: [], unmeasured: [], scrollers: [], contrast: [], targets: [], headings: [], sizes: [], weights: [], radii: [], ratio: 0 };
93
94
  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);
94
95
  /* closest() across open roots and slots: deepParent (injected from scripts/lib/deep.ts) steps through both */
95
96
  const deepClosest = (el, sel) => { for (let n = el; n; n = deepParent(n)) if (n.matches(sel)) return n; return null; };
@@ -98,6 +99,10 @@ const MEASURE = `(() => {
98
99
  if (sr.scrollWidth > W + 1) out.sideways.push('document ' + sr.scrollWidth + 'px wide at ' + W);
99
100
  const all = deepAll(document.body).filter((n) => n !== document.body);
100
101
  all.filter((n) => n.matches('[data-scroll-region]')).forEach((s) => { if (s.scrollWidth > s.clientWidth + 1) out.sideways.push('scroll region ' + s.scrollWidth + ' > ' + s.clientWidth); });
102
+ // At a phone width nothing may hide off a sideways edge: a strip that scrolls holds items a reader cannot see, and the
103
+ // page cannot be scrolled to them. Two are not reported here: the page's own scroll region, which the check above owns,
104
+ // and a strip that fades at the cut edge (.scroll-fade-x), the system's own way of saying there is more this way.
105
+ if (W <= 400) all.filter((n) => !n.matches('[data-scroll-region], .scroll-fade-x') && n.clientWidth > 0 && /auto|scroll/.test(getComputedStyle(n).overflowX) && n.scrollWidth > n.clientWidth + 1).forEach((n) => out.sideways.push('strip ' + label(n) + ' ' + n.scrollWidth + ' > ' + n.clientWidth));
101
106
  // A dashboard fills its canvas at every size: a data page narrower than the scroll region (less the gutters) is a
102
107
  // centred strip on a wide screen.
103
108
  all.filter((n) => n.matches('[data-page-width="data"]')).forEach((p) => { const region = deepClosest(p, '[data-scroll-region]'); if (region && p.getBoundingClientRect().width < region.clientWidth - 2) out.sideways.push('data page ' + Math.round(p.getBoundingClientRect().width) + 'px wide in a ' + region.clientWidth + 'px canvas (it should fill it)'); });
@@ -170,6 +175,29 @@ const MEASURE = `(() => {
170
175
  texts.push(fs); out.sizes.push(Math.round(fs)); out.weights.push(+c.fontWeight);
171
176
  if (el.scrollWidth > el.clientWidth + 1 && c.textOverflow !== 'ellipsis' && /hidden|clip/.test(c.overflow) && el.clientWidth > 0) out.clipped.push(label(el) + ' "' + el.textContent.trim().slice(0, 30) + '"');
172
177
  if (el.scrollWidth > el.clientWidth + 1 && c.textOverflow === 'ellipsis' && /hidden|clip/.test(c.overflowX) && el.clientWidth > 0 && !titled(el)) out.truncated.push(label(el) + ' "' + el.textContent.trim().slice(0, 30) + '"');
178
+ // Text drawn past a box that does not clip it runs over whatever is beside that box: a figure too long for its grid
179
+ // column, over its neighbour and past its card's edge. Measured on the glyphs with a Range, since scrollWidth also
180
+ // counts an enlarged touch target. A clipping box ends the walk: what it hides is the clipped check's business, and
181
+ // a positioned box may overhang on purpose.
182
+ if (!(el instanceof SVGElement) && !/absolute|fixed/.test(c.position)) {
183
+ let left = Infinity, right = -Infinity;
184
+ for (const node of el.childNodes) {
185
+ if (node.nodeType !== 3 || !node.textContent.trim()) continue;
186
+ const range = document.createRange(); range.selectNodeContents(node);
187
+ const b = range.getBoundingClientRect();
188
+ left = Math.min(left, b.left); right = Math.max(right, b.right);
189
+ }
190
+ if (right > left) for (let n = el; n && n !== document.documentElement; n = deepParent(n)) {
191
+ const nc = getComputedStyle(n);
192
+ if (/hidden|clip|auto|scroll/.test(nc.overflowX) || /absolute|fixed/.test(nc.position)) break;
193
+ const b = n.getBoundingClientRect();
194
+ // display: contents draws no box of its own (a card's phone layout uses it to unwrap a tile): its rect is at
195
+ // the origin and zero wide, and comparing glyphs to it reported every figure on the page.
196
+ if (nc.display === 'contents' || (!b.width && !b.height)) continue;
197
+ const past = Math.max(right - (b.right - (parseFloat(nc.borderRightWidth) || 0)), b.left + (parseFloat(nc.borderLeftWidth) || 0) - left);
198
+ if (past > 1) { out.spills.push(label(el) + ' "' + el.textContent.trim().slice(0, 30) + '" spills its box by ' + Math.round(past) + 'px'); break; }
199
+ }
200
+ }
173
201
  inked.push(el);
174
202
  // Gradient text paints its glyphs with a clipped background (color is transparent); both ends sit at accent-ink's
175
203
  // lightness, which the contrast gate already holds, so it is not measured here.
@@ -274,7 +302,7 @@ const MEASURE = `(() => {
274
302
  // Sampled every two pixels: four samples is eight pixels of line inside the text.
275
303
  for (const [el, k] of inside) if (k >= 4) out.overlap.push('a line (' + label(g) + ') is drawn through ' + quote(el));
276
304
  }
277
- out.overlap = [...new Set(out.overlap)].slice(0, 8); out.truncated = [...new Set(out.truncated)].slice(0, 8);
305
+ out.overlap = [...new Set(out.overlap)].slice(0, 8); out.truncated = [...new Set(out.truncated)].slice(0, 8); out.spills = [...new Set(out.spills)].slice(0, 8);
278
306
  // Headings descend one level at a time, so a screen reader's outline has no holes.
279
307
  // A specimen's headings are the card's own, at the level the card uses where it is placed, not the documentation page's.
280
308
  const levels = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter((h) => !h.closest('[aria-hidden="true"]') && !h.closest('[data-specimen]')).map((h) => +h.tagName[1]);
@@ -370,6 +398,7 @@ const run = async (route: string, width: number, theme: string, embed: boolean)
370
398
  ...(r.text < 24 ? [`the page holds ${r.text} characters of text — it rendered nothing, or only its loading state`] : []),
371
399
  ...r.sideways.map((x) => 'sideways: ' + x),
372
400
  ...r.clipped.map((x) => 'clipped: ' + x),
401
+ ...r.spills.map((x) => 'spills: ' + x),
373
402
  ...r.truncated.map((x) => 'truncated: ' + x + ' is cut with an ellipsis and no title holds the whole of it'),
374
403
  ...r.overlap.map((x) => 'overlap: ' + x),
375
404
  ...r.unnamed.map((x) => 'unnamed: ' + x),
@@ -77,6 +77,14 @@ const LIST = `(() => {${CONTROLS}
77
77
  const ARM = (name: string, nth: number, key: string, keyNth: number) => `(() => {${CONTROLS}
78
78
  const all = pressable(deepAll(document.body));
79
79
  const el = all.filter((b) => nameOf(b) === ${JSON.stringify(name)})[${nth}] ?? all.filter((b) => keyOf(b) === ${JSON.stringify(key)})[${keyNth}]; if (!el) return null;
80
+ // A control inside a closed disclosure is laid out but not drawn: open the ones it sits in, as a reader does before
81
+ // pressing it. The one whose own summary holds the control stays shut, since pressing that summary is the control (#82).
82
+ for (let n = deepParent(el); n; n = deepParent(n)) {
83
+ if (n.localName !== 'details' || n.open) continue;
84
+ const own = [...n.children].find((c) => c.localName === 'summary');
85
+ if (own && own.contains(el)) continue;
86
+ n.open = true;
87
+ }
80
88
  el.scrollIntoView({ block: 'center', inline: 'nearest' });
81
89
  const r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
82
90
  const hit = document.elementFromPoint(x, y);
@@ -51,6 +51,9 @@ const TAG = `(() => {
51
51
  // :disabled, not [disabled]: the attribute is only the control's own, and a fieldset with disabled disables every
52
52
  // control inside it without touching their attributes. A FormSection in its read-only or saving state is exactly
53
53
  // that, so the attribute form expected a correctly-disabled input to be reachable, and failed on a page that was right.
54
+ // A reader opens a disclosure to reach what is in it. A closed <details> lays its content out without drawing it, so
55
+ // the content has a real box while Tab skips it: tagged, it was reported as never reached (#82).
56
+ for (const n of deepAll(document.body)) if (n.localName === 'details' && !n.open) n.open = true;
54
57
  const every = deepAll(document.body).filter((n) => n !== document.body);
55
58
  const all = every.filter((el) => el.matches('a[href], button:not(:disabled), input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'))
56
59
  .filter((el) => vis(el) && el.tabIndex >= 0);
@@ -66,7 +66,8 @@ const status = (port: number) => new Promise<number | null>((resolve) => {
66
66
  });
67
67
 
68
68
  async function serve(port: number): Promise<ChildProcess> {
69
- const server = spawn(bin('next'), ['start', '-p', String(port)], { cwd: ROOT, env: process.env, stdio: 'ignore', detached: true });
69
+ // Loopback only, as scripts/verify.ts starts it: every client here is on this machine (#81).
70
+ const server = spawn(bin('next'), ['start', '-H', '127.0.0.1', '-p', String(port)], { cwd: ROOT, env: process.env, stdio: 'ignore', detached: true });
70
71
  started.push(server);
71
72
  for (let i = 0; i < 120; i++) {
72
73
  const code = await status(port);
@@ -391,10 +391,10 @@ process.on('exit', stopAll);
391
391
  // An interrupted run does not emit 'exit' by itself, and the detached servers and running suites would outlive it.
392
392
  for (const sig of ['SIGINT', 'SIGTERM'] as const) process.on(sig, () => process.exit(sig === 'SIGINT' ? 130 : 143));
393
393
 
394
- /** Start the built app on a free port with the given environment and wait until it answers. */
394
+ /** Start the built app on a free port, loopback only, and wait until it answers: every client here reaches 127.0.0.1 (#81). */
395
395
  async function start(env: NodeJS.ProcessEnv) {
396
396
  const port = await freePort();
397
- const server = spawn(bin('next'), ['start', '-p', String(port)], { cwd: ROOT, env, stdio: 'ignore', detached: true });
397
+ const server = spawn(bin('next'), ['start', '-H', '127.0.0.1', '-p', String(port)], { cwd: ROOT, env, stdio: 'ignore', detached: true });
398
398
  children.push(server);
399
399
  for (let i = 0; i < 120; i++) {
400
400
  try { if ((await fetch(`http://127.0.0.1:${port}/`)).status < 500) return { port, server }; } catch { await new Promise((r) => setTimeout(r, 500)); }
@@ -54,7 +54,7 @@ const COMPONENTS: Components = {
54
54
  code: ({ children, className }) =>
55
55
  className ? <code className={className}>{children}</code> : <code className="rounded-xs border border-line bg-surface-sunk px-1 py-px font-mono text-[0.86em] text-ink [overflow-wrap:anywhere]">{children}</code>,
56
56
  pre: ({ children }) => <pre className="my-4 rounded-lg border border-line bg-surface-sunk p-4 font-mono text-sm leading-relaxed whitespace-pre-wrap text-ink [overflow-wrap:anywhere] [&_code]:border-0 [&_code]:bg-transparent [&_code]:p-0">{children}</pre>,
57
- table: ({ children }) => <div className="my-5 overflow-x-auto rounded-lg border border-line"><table className="w-full text-left text-sm leading-normal">{children}</table></div>,
57
+ table: ({ children }) => <div className="scroll-fade-x my-5 overflow-x-auto rounded-lg border border-line"><table className="w-full text-left text-sm leading-normal">{children}</table></div>,
58
58
  thead: ({ children }) => <thead className="border-b border-line bg-surface-sunk">{children}</thead>,
59
59
  th: ({ children }) => <th className="px-3 py-2 text-xs font-medium whitespace-nowrap text-ink-3">{children}</th>,
60
60
  tr: ({ children }) => <tr className="border-b border-line last:border-0">{children}</tr>,
@@ -79,7 +79,7 @@ const COMPONENTS: Components = {
79
79
  const STACKED: Components = {
80
80
  ...COMPONENTS,
81
81
  table: ({ children }) => (
82
- <div className="my-5 overflow-x-auto rounded-lg border border-line @container">
82
+ <div className="scroll-fade-x my-5 overflow-x-auto rounded-lg border border-line @container">
83
83
  <table className={cn('w-full text-left text-sm leading-normal',
84
84
  '@max-[480px]:block @max-[480px]:[&>thead]:hidden @max-[480px]:[&>tbody]:block @max-[480px]:[&_tr]:block @max-[480px]:[&_tr]:px-3 @max-[480px]:[&_tr]:py-2.5',
85
85
  '@max-[480px]:[&_td]:block @max-[480px]:[&_td]:p-0 @max-[480px]:[&_td+td]:mt-2 @max-[480px]:[&_td:first-child]:font-medium @max-[480px]:[&_td:first-child]:text-ink',
@@ -145,7 +145,7 @@ export function Rail({
145
145
  <AppMark size={28} />
146
146
  <span className="min-w-0 flex-1">
147
147
  {/* Either may be cut to the rail's width; the title holds the whole of it, as every cut text keeps (#79). */}
148
- <span title={app.name} className="block truncate text-md leading-tight font-semibold tracking-[-0.015em]">{app.name}</span>
148
+ <span title={app.name} className="block truncate text-md leading-tight font-semibold tracking-[-0.015em]">{app.name}</span>{' '}
149
149
  <span title={workspace} className="t-eyebrow mt-0.5 block truncate">{workspace}</span>
150
150
  </span>
151
151
  {/* `ChevronsUpDown` is the glyph for choosing between things; with nothing to switch, the row is the menu. */}
@@ -31,7 +31,8 @@ export function ShellTools({ alerts = [], now }: { alerts?: Alert[]; /** The dat
31
31
  className="press hit flex h-9 items-center gap-2.5 rounded-full border border-line-strong bg-surface/60 pr-1.5 pl-3 text-sm text-ink-3 shadow-control backdrop-blur-md transition-[color,background-color,border-color,transform] hover:border-line-control/40 hover:text-ink-2 @max-[40rem]:w-9 @max-[40rem]:justify-center @max-[40rem]:px-0"
32
32
  >
33
33
  <Search className="size-4" strokeWidth={1.75} />
34
- <span className="w-36 text-left @max-[48rem]:w-20 @max-[40rem]:hidden">Search</span>
34
+ {/* Below 40rem the word is only its own name: `sr-only`, never `hidden`, so the icon-only button is still announced (#83). */}
35
+ <span className="w-36 text-left @max-[48rem]:w-20 @max-[40rem]:sr-only">Search</span>
35
36
  <Kbd className="keyboard-only @max-[40rem]:hidden">⌘K</Kbd>
36
37
  </button>
37
38
  <Popover open={open} onOpenChange={setOpen}>
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react';
4
- import { Children, isValidElement, type CSSProperties, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
4
+ import { Children, Fragment, isValidElement, type CSSProperties, type HTMLAttributes, type ReactElement, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
5
5
  import { cn } from '@/lib/cn';
6
6
 
7
7
  /**
@@ -13,8 +13,9 @@ import { cn } from '@/lib/cn';
13
13
  * between the columns, an equal share in every gap, so the gaps read even whatever each column's alignment. The table
14
14
  * algorithm put each column's spare inside it, after a left-aligned value and before a right-aligned one, so a text
15
15
  * column beside a number held both shares in one gap and a number beside a text column none. Each row is a subgrid of
16
- * the table's columns, so a row's border, hover and selection run across the gaps. The columns are counted from the
17
- * cells of the table's first row as its own JSX writes them, the head's `TableHeader` cells when it has a head.
16
+ * the table's columns, so a row's border, hover and selection run across the gaps. The columns are counted from the shape
17
+ * of the table as its own JSX writes it — the first row of its first section that has one — so a table written in a server
18
+ * component lays out exactly as one written on the client.
18
19
  *
19
20
  * `hideBelow` reads the table's own width, not the window's: the rail, a split row or an embed all narrow the table
20
21
  * without narrowing the window. sm under 512px, md under 672px, lg under 896px, xl under 1152px.
@@ -27,14 +28,26 @@ const JUSTIFY = { left: undefined, right: 'justify-end', center: 'justify-center
27
28
 
28
29
  type Props = { children?: ReactNode; hideBelow?: Breakpoint };
29
30
 
30
- /** Which columns the table draws: the `hideBelow` of each cell in its first row, the head's when it has one. */
31
+ /** The elements a node writes, through fragments: the table's own JSX as a list. */
32
+ function elementsOf(node: ReactNode): ReactElement<Props>[] {
33
+ return Children.toArray(node).flatMap((c) => {
34
+ if (!isValidElement<Props>(c)) return [];
35
+ return c.type === Fragment ? elementsOf(c.props.children) : [c];
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Which columns the table draws, read from its shape and never from which component a cell is: the first row of its
41
+ * first section that has one, and that row's component cells (a raw `<th data-check>` is DataTable's selection box, not a
42
+ * column). A server component renders the same parts as lazy references rather than these functions, and a table whose
43
+ * columns were counted by identity laid every one of them out as a single column (#85).
44
+ */
31
45
  function columnsOf(node: ReactNode): (Breakpoint | undefined)[] | undefined {
32
- const kids = Children.toArray(node).filter((c) => isValidElement<Props>(c));
33
- const cells = kids.filter((c) => c.type === TableHeader || c.type === TableCell);
34
- if (cells.length > 0) return cells.map((c) => c.props.hideBelow);
35
- for (const c of kids) {
36
- const found = columnsOf(c.props.children);
37
- if (found) return found;
46
+ for (const section of elementsOf(node)) {
47
+ const row = elementsOf(section.props.children)[0];
48
+ if (!row) continue;
49
+ const cells = elementsOf(row.props.children).filter((c) => typeof c.type !== 'string');
50
+ if (cells.length > 0) return cells.map((c) => c.props.hideBelow);
38
51
  }
39
52
  return undefined;
40
53
  }
@@ -34,9 +34,9 @@ export function Doc({ md, className }: { md: string; className?: string }) {
34
34
  a: ({ href, children }) => <a href={href} className="link">{children}</a>,
35
35
  code: ({ children, className: c }) =>
36
36
  c ? <code className={c}>{children}</code> : <code className="t-code">{children}</code>,
37
- pre: ({ children }) => <pre className="my-5 overflow-x-auto rounded-lg border border-line bg-surface-sunk p-5 font-mono text-sm leading-relaxed text-ink [&_code]:border-0 [&_code]:bg-transparent [&_code]:p-0">{children}</pre>,
37
+ pre: ({ children }) => <pre className="scroll-fade-x my-5 overflow-x-auto rounded-lg border border-line bg-surface-sunk p-5 font-mono text-sm leading-relaxed text-ink [&_code]:border-0 [&_code]:bg-transparent [&_code]:p-0">{children}</pre>,
38
38
  table: ({ children }) => (
39
- <div className="my-6 overflow-x-auto rounded-lg border border-line bg-surface/60">
39
+ <div className="scroll-fade-x my-6 overflow-x-auto rounded-lg border border-line bg-surface/60">
40
40
  <table className="w-full min-w-[560px] text-left text-sm leading-normal">{children}</table>
41
41
  </div>
42
42
  ),
@@ -21,4 +21,13 @@ describe('the rail\'s head (#79)', () => {
21
21
  expect(name).toHaveAttribute('title', 'Northwind Treasury Console');
22
22
  expect(workspace).toHaveAttribute('title', 'Treasury operations, Europe and Asia');
23
23
  });
24
+
25
+ // WCAG 2.5.3: the visible text of the menu trigger has to be in its name. The name is "<app>, <workspace>: account
26
+ // menu"; with no space between the two spans the visible text ran them together and axe read a word that name does
27
+ // not hold (#84). The blocks make the space invisible, so it costs the layout nothing.
28
+ it('separates the product name and the workspace, so the trigger\'s visible text is in its name', () => {
29
+ const { container } = render(<Rail nav={nav} user={null} workspace="Treasury operations, Europe and Asia" />);
30
+ const trigger = container.querySelector('button[aria-label$="account menu"]')!;
31
+ expect(trigger.textContent).toContain('Northwind Treasury Console Treasury operations, Europe and Asia');
32
+ });
24
33
  });
@@ -0,0 +1,16 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ShellTools } from '@/components/patterns/shell-tools';
4
+
5
+ // Below 40rem the button draws only its icon; the word stays in the DOM as its name. `display: none` on the only text
6
+ // left an icon-only button with no accessible name, which axe reported on every view at 390px (#83).
7
+ describe('the shell\'s Search button at a phone width (#83)', () => {
8
+ it('keeps "Search" as its name, visually hidden rather than removed', () => {
9
+ render(<ShellTools alerts={[]} now={new Date('2026-10-05T09:00:00Z')} />);
10
+ // The name is "Search ⌘K": the shortcut is drawn beside the word, which is what keeps the icon-only state named.
11
+ const button = screen.getAllByRole('button', { name: /Search/ })[0];
12
+ const word = [...button.querySelectorAll('span')].find((s) => s.textContent === 'Search')!;
13
+ expect(word.className).toContain('@max-[40rem]:sr-only');
14
+ expect(word.className).not.toContain('@max-[40rem]:hidden');
15
+ });
16
+ });
@@ -1,4 +1,5 @@
1
1
  import { render } from '@testing-library/react';
2
+ import type { ReactNode } from 'react';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
 
4
5
  vi.mock('next/navigation', () => ({ useRouter: () => ({ push() {}, refresh() {}, replace() {} }), usePathname: () => '/' }));
@@ -34,6 +35,36 @@ describe('the table counts its columns from its head', () => {
34
35
  expect(cols(container.querySelector('table')!)).toEqual(['repeat(2, auto)', 'repeat(2, auto)', 'repeat(2, auto)', 'repeat(2, auto)', 'repeat(1, auto)']);
35
36
  });
36
37
 
38
+ // A server component's JSX reaches the client Table as references to the client module, not as the functions it
39
+ // exported; only the props survive. A wrapper of another type reproduces that here: the Table cannot match a cell by
40
+ // identity, so it reads the shape, and a table that counted columns by identity laid every one out as a single
41
+ // column (#85).
42
+ it('counts a table a server component wrote, where no child is the component the client module exports', () => {
43
+ const Head = ({ children }: { children?: ReactNode }) => <TableHead>{children}</TableHead>;
44
+ const Body = ({ children }: { children?: ReactNode }) => <TableBody>{children}</TableBody>;
45
+ const Row = ({ children }: { children?: ReactNode }) => <TableRow>{children}</TableRow>;
46
+ const Th = ({ children, hideBelow, grow, align }: { children?: ReactNode; hideBelow?: 'xl' | 'md'; grow?: boolean; align?: 'left' | 'right' }) => (
47
+ <TableHeader hideBelow={hideBelow} grow={grow} align={align}>{children}</TableHeader>
48
+ );
49
+ const Td = ({ children }: { children?: ReactNode }) => <TableCell>{children}</TableCell>;
50
+ const { container } = render(
51
+ <Table>
52
+ <>
53
+ <Head>
54
+ <Row>
55
+ <Th grow>Request</Th>
56
+ <Th hideBelow="xl">Region</Th>
57
+ <Th hideBelow="md">Customer</Th>
58
+ <Th align="right">Latency</Th>
59
+ </Row>
60
+ </Head>
61
+ </>
62
+ <Body><Row><Td>a</Td><Td>b</Td><Td>c</Td><Td>d</Td></Row></Body>
63
+ </Table>,
64
+ );
65
+ expect(cols(container.querySelector('table')!)).toEqual(['repeat(4, auto)', 'repeat(3, auto)', 'repeat(3, auto)', 'repeat(2, auto)', 'repeat(2, auto)']);
66
+ });
67
+
37
68
  it("does not count DataTable's selection column, which sits over the lead's padding", () => {
38
69
  type Row = { id: string; name: string };
39
70
  const columns: Column<Row>[] = [
@@ -0,0 +1,18 @@
1
+ // @vitest-environment node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const ROOT = path.resolve(import.meta.dirname, '..');
7
+ const source = (file: string) => fs.readFileSync(path.join(ROOT, file), 'utf8');
8
+
9
+ // The built app a run starts is for that run: the readiness probe, the browser, the assistant and the live run all
10
+ // reach it at 127.0.0.1. `next start` with no host binds every interface, so for the half hour a full run takes the
11
+ // app under test answered on the network (#81).
12
+ describe('the built app a run starts for itself (#81)', () => {
13
+ for (const file of ['scripts/verify.ts', 'scripts/live.ts']) {
14
+ it(`${file} binds loopback`, () => {
15
+ expect(source(file)).toContain("['start', '-H', '127.0.0.1', '-p', String(port)]");
16
+ });
17
+ }
18
+ });