zz-meridian 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.23.0",
3
+ "version": "0.25.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,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.25.0] · 2026-10-21
6
+
7
+ ### Breaking
8
+
9
+ - **Every table column shares the spare width, and `TableHeader`'s `flexible` prop is removed.** A cut cell used to zero its own width (`max-w-0`) and the lead head claimed all the rest (`w-full`), so the record's name sat in empty room while the columns beside it were held at their minimum, cramped at both ends and wrapping. Now every column asks for its content width and a table with room to spare shares it across all of its columns in proportion to those widths. A column whose cells `truncate` asks for its whole content width too and shrinks only when the table lacks room for every column; a column given `width` keeps that width and takes no spare; the `grow` column keeps its floor, 160px, 256px once the table is 672px wide. Drop `flexible` from any `TableHeader`: a `truncate` column now asks for its own width. Meridian is pre-1.0, so a removed prop ships in a minor, as it did in 0.19.0.
10
+
11
+ ### New
12
+
13
+ - **`Cut`**, exported from `@/components/ui/table`: the wrapper a cut cell renders, one grid track of `minmax(0, max-content)` around a `truncate` span. `TableCell truncate` and `DataTable`'s `truncate` columns use it; use it in a hand-built cell that should be cut the same way. The audit's title check reaches through it.
14
+
15
+ ## [0.24.0] · 2026-10-20
16
+
17
+ ### New
18
+
19
+ - **First-load JS can be held to each route's baseline alone** ([#76](https://github.com/zhixuan312/zz-meridian/issues/76)), as every page's HTML is since 0.19.0. Set `budgets.firstLoadKb: null` in `scripts/verify.config.ts`: a route `scripts/verify.baseline.json` holds is then held to `firstLoadGrowthPct` over its own figure and nothing else, and the 820 KiB cap binds only a route the baseline does not hold yet. A product whose largest page sits near the cap no longer fails `pnpm verify` for a change its own baseline allows, and no longer derives a cap from the baseline to say the same rule twice. Leaving the key out keeps the cap on every route, as before; the size line says which applies (`cap 820 KiB for a route the baseline does not hold`).
20
+
5
21
  ## [0.23.0] · 2026-10-20
6
22
 
7
23
  ### New
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.23.0",
3
+ "version": "0.25.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.",
@@ -148,7 +148,9 @@ const MEASURE = `(() => {
148
148
  // cell titled "Orders" over a cut account id passed as keeping it.
149
149
  const whole = (s) => (s || '').replace(/\\s+/g, ' ').trim();
150
150
  const keeps = (el, title) => { const t = whole(title), own = whole(el.textContent); return t.length > 0 && own.length > 0 && t.includes(own); };
151
- const titled = (el) => { for (let n = el, i = 0; n && i < 4; n = deepParent(n), i++) if (keeps(el, n.getAttribute('title'))) return true; return [...el.querySelectorAll('[title]')].some((n) => keeps(el, n.getAttribute('title'))); };
151
+ // The walk reaches through a table cell's wrapper: a name span in a flex row in a link in the wrapper's cut span in the
152
+ // wrapper is six levels, so the cell's title is still found.
153
+ const titled = (el) => { for (let n = el, i = 0; n && i < 6; n = deepParent(n), i++) if (keeps(el, n.getAttribute('title'))) return true; return [...el.querySelectorAll('[title]')].some((n) => keeps(el, n.getAttribute('title'))); };
152
154
  const inked = [];
153
155
  const ringSig = (c) => [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow, c.borderTopStyle, c.borderTopWidth, c.borderTopColor, c.borderBottomColor, c.borderLeftColor, c.borderRightColor].join('|');
154
156
  const texts = [];
@@ -24,7 +24,8 @@ export type NavigationCheck = {
24
24
  };
25
25
  export type Budgets = {
26
26
  navigation: { warm: ReadinessBudget; cold: ReadinessBudget; afterLive: ReadinessBudget };
27
- firstLoadKb: number;
27
+ /** `null`: each route the baseline holds is held to it alone, and the default cap binds only a route it does not. */
28
+ firstLoadKb: number | null;
28
29
  firstLoadGrowthPct: number;
29
30
  htmlGrowthPct: number;
30
31
  htmlKb: Record<string, number>;
@@ -33,7 +34,7 @@ export type Budgets = {
33
34
  export type BudgetsConfig = {
34
35
  budgets?: {
35
36
  navigation?: { warm?: ReadinessBudget; cold?: ReadinessBudget; afterLive?: ReadinessBudget };
36
- firstLoadKb?: number;
37
+ firstLoadKb?: number | null;
37
38
  firstLoadGrowthPct?: number;
38
39
  htmlGrowthPct?: number;
39
40
  htmlKb?: Record<string, number>;
@@ -47,6 +48,9 @@ const COLD: ReadinessBudget = {
47
48
  interactiveMs: { desktop: 2000, phone: 4000 },
48
49
  };
49
50
 
51
+ /** The first-load cap, in KiB, for a route the baseline does not hold, and for every route unless `firstLoadKb` is null. */
52
+ export const FIRST_LOAD_CAP_KB = 820;
53
+
50
54
  /** Each page's HTML is held to its own baseline; an absolute HTML cap is a project's route, so the defaults set none. */
51
55
  export const DEFAULT_BUDGETS: Budgets = {
52
56
  navigation: {
@@ -58,7 +62,7 @@ export const DEFAULT_BUDGETS: Budgets = {
58
62
  cold: COLD,
59
63
  afterLive: COLD,
60
64
  },
61
- firstLoadKb: 820,
65
+ firstLoadKb: FIRST_LOAD_CAP_KB,
62
66
  firstLoadGrowthPct: 5,
63
67
  htmlGrowthPct: 5,
64
68
  htmlKb: {},
@@ -92,7 +96,7 @@ export function budgetsOf(config: BudgetsConfig): Budgets {
92
96
  const cold = b.navigation?.cold ?? DEFAULT_BUDGETS.navigation.cold;
93
97
  return {
94
98
  navigation: { warm, cold, afterLive: b.navigation?.afterLive ?? cold },
95
- firstLoadKb: b.firstLoadKb ?? DEFAULT_BUDGETS.firstLoadKb,
99
+ firstLoadKb: b.firstLoadKb === null ? null : b.firstLoadKb ?? DEFAULT_BUDGETS.firstLoadKb,
96
100
  firstLoadGrowthPct: b.firstLoadGrowthPct ?? DEFAULT_BUDGETS.firstLoadGrowthPct,
97
101
  htmlGrowthPct: b.htmlGrowthPct ?? DEFAULT_BUDGETS.htmlGrowthPct,
98
102
  htmlKb: b.htmlKb ?? DEFAULT_BUDGETS.htmlKb,
@@ -19,16 +19,20 @@ export function firstLoad(
19
19
  });
20
20
  }
21
21
 
22
- /** Routes over the cap or past their growth allowance over a baseline; without a baseline growth is not configured. */
22
+ /**
23
+ * Routes over the cap or past their growth allowance over a baseline; without a baseline growth is not configured.
24
+ * `capUnheldOnly` holds a route the baseline holds to its baseline alone, so the cap binds only a route it does not.
25
+ */
23
26
  export function firstLoadProblems(
24
27
  routes: { route: string; bytes: number }[],
25
- { capKiB, growthPct, baseline }: { capKiB: number; growthPct: number; baseline: Record<string, number> | null },
28
+ { capKiB, growthPct, baseline, capUnheldOnly = false }: { capKiB: number; growthPct: number; baseline: Record<string, number> | null; capUnheldOnly?: boolean },
26
29
  ): { problems: string[]; growth: 'checked' | 'not-configured' } {
27
30
  const problems: string[] = [];
28
31
  for (const { route, bytes } of routes) {
29
- if (bytes > capKiB * KIB) problems.push(`${route}: first-load JS ${kib(bytes)} KiB over the ${capKiB} KiB cap`);
30
32
  const base = baseline?.[route];
31
- if (base === undefined || base <= 0) continue;
33
+ const held = base !== undefined && base > 0;
34
+ if (!(capUnheldOnly && held) && bytes > capKiB * KIB) problems.push(`${route}: first-load JS ${kib(bytes)} KiB over the ${capKiB} KiB cap`);
35
+ if (!held) continue;
32
36
  const grew = ((bytes - base) / base) * 100;
33
37
  if (grew > growthPct) problems.push(`${route}: first-load JS grew ${grew.toFixed(1)}% over its baseline (cap ${growthPct}%)`);
34
38
  }
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * First-load comes from the build's own `diagnostics/route-bundle-stats.json`, read once, with each chunk's size from
8
8
  * disk: every chunk a route loads counted once, uncompressed. It is checked against `budgets.firstLoadKb` and, when
9
- * `scripts/verify.baseline.json` exists, against `budgets.firstLoadGrowthPct` over it. A page's HTML is what a person
9
+ * `scripts/verify.baseline.json` exists, against `budgets.firstLoadGrowthPct` over it; with `firstLoadKb: null` a route
10
+ * the baseline holds is held to it alone, and the default cap binds only a route it does not. A page's HTML is what a person
10
11
  * downloads: the complete streamed response from the app running at `--base`, gzipped. Every page in the baseline's
11
12
  * `html` is held to `budgets.htmlGrowthPct` over its own figure, and a route in `budgets.htmlKb` also to that absolute
12
13
  * cap. Without a baseline a growth check is `not-configured`, a warning and never a pass; without `--base` the HTML is
@@ -28,7 +29,7 @@ import fs from 'node:fs';
28
29
  import path from 'node:path';
29
30
  import { gzipSync } from 'node:zlib';
30
31
 
31
- import { budgetsOf, type BudgetsConfig } from './lib/budgets.ts';
32
+ import { FIRST_LOAD_CAP_KB, budgetsOf, type BudgetsConfig } from './lib/budgets.ts';
32
33
  import { distDirOf } from './lib/next-config.ts';
33
34
  import { firstLoad, firstLoadProblems, htmlGrowthProblems, htmlProblems, htmlRoutes } from './lib/sizes.ts';
34
35
  import verifyConfig from './verify.config.ts';
@@ -112,11 +113,12 @@ async function main(): Promise<number> {
112
113
 
113
114
  const budgets = budgetsOf(verifyConfig as BudgetsConfig);
114
115
  const baseline = baselineOf();
115
- const size = firstLoadProblems(routes, { capKiB: budgets.firstLoadKb, growthPct: budgets.firstLoadGrowthPct, baseline: baseline?.firstLoad ?? null });
116
+ const capKiB = budgets.firstLoadKb ?? FIRST_LOAD_CAP_KB;
117
+ const size = firstLoadProblems(routes, { capKiB, growthPct: budgets.firstLoadGrowthPct, baseline: baseline?.firstLoad ?? null, capUnheldOnly: budgets.firstLoadKb === null });
116
118
  const largest = routes.reduce((a, b) => (b.bytes > a.bytes ? b : a), routes[0]);
117
119
  const lines: string[] = [];
118
120
  const firstStatus: Status = size.problems.length ? 'FAIL' : size.growth === 'not-configured' ? 'warn' : 'ok';
119
- lines.push(`${firstStatus === 'FAIL' ? 'FAIL ' : firstStatus === 'warn' ? 'warn ' : 'ok '} first-load ${routes.length} routes, largest ${largest.route} ${kib(largest.bytes)} KiB · cap ${budgets.firstLoadKb} KiB`
121
+ lines.push(`${firstStatus === 'FAIL' ? 'FAIL ' : firstStatus === 'warn' ? 'warn ' : 'ok '} first-load ${routes.length} routes, largest ${largest.route} ${kib(largest.bytes)} KiB · cap ${capKiB} KiB${budgets.firstLoadKb === null ? ' for a route the baseline does not hold' : ''}`
120
122
  + ` · growth ${size.growth === 'checked' ? `within ${budgets.firstLoadGrowthPct}% of the baseline` : 'not-configured (no scripts/verify.baseline.json)'}`);
121
123
  for (const p of size.problems) lines.push(`FAIL ${p}`);
122
124
 
@@ -101,11 +101,12 @@ type VerifyConfig = {
101
101
  * `scripts/verify.baseline.json`, the warm, cold and after-live navigation times, and no prefetch on a closed phone drawer.
102
102
  * Every page's HTML, gzipped through the end of the streamed response, is held to `htmlGrowthPct` (5%) over its own
103
103
  * figure in the baseline's `html`; `htmlKb` adds an absolute cap in KiB, the same gzipped figure, for a page a project
104
- * wants pinned, and has no default.
104
+ * wants pinned, and has no default. `firstLoadKb: null` holds first-load JS the same way: each route the baseline holds
105
+ * to its own figure alone, with the default cap binding only a route the baseline does not hold yet.
105
106
  */
106
107
  budgets?: {
107
108
  navigation?: { warm?: ReadinessBudget; cold?: ReadinessBudget; afterLive?: ReadinessBudget };
108
- firstLoadKb?: number;
109
+ firstLoadKb?: number | null;
109
110
  firstLoadGrowthPct?: number;
110
111
  htmlGrowthPct?: number;
111
112
  htmlKb?: Record<string, number>;
@@ -138,7 +138,7 @@ and supply-chain check before a script runs; that is pnpm, not an install, and n
138
138
  | `unmeasured: x-card (no open shadow root)` | A custom element shows a box but exposes no open root and no content in the page, so nothing inside it was measured | Attach its root open (`attachShadow({ mode: 'open' })`), or render its content as light DOM in a slot; the audit fails until it can look |
139
139
  | `unmeasured: x-card (not defined)` | A custom element was still not registered after the settle wait | Register it (`customElements.define`) before the page settles, or remove the tag from the page |
140
140
  | `"Export" does nothing when pressed` | A control with no action, or one that only works on hover | Wire it, or remove it; an info button uses `Tooltip toggle` so a tap opens it |
141
- | `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate` the lead column. When the lead (`grow`) column holds a long name and another column is wide too, hiding columns may not help: move `grow` to the shorter text column |
141
+ | `table … > its frame` | A table wider than its card, its last columns clipped | `hideBelow` one more column, or `truncate` a long column. Every column asks for its content width, the spare is shared in proportion, and a cut column shrinks only when the table lacks room; a fixed `width` keeps its width, and the `grow` floor is 160px, 256px from a 672px table |
142
142
  | `exception:` / `console.error:` | A runtime error or a hydration mismatch | Hydration: no `Date.now()`, `Math.random()` or locale-dependent formatting in render; use the demo clock and `src/lib/format*` |
143
143
 
144
144
  ## Keyboard walk failures
@@ -27,7 +27,7 @@ Status: beta
27
27
 
28
28
  ## Sizes
29
29
 
30
- Row height and card padding come from the density: comfortable 52px and 24px, compact 38px and 16px. The table never sets its own width; the columns whose cells can be cut take what remains — `grow` for the record's name, and every column whose cells `truncate` — sharing it where there is more than one, and every column that must show its whole value keeps its content width. A column whose content is short and fixed (an ID, a key, a date) can take `width` (`w-28`), so a two-column table does not hand the short column most of the row.
30
+ Row height and card padding come from the density: comfortable 52px and 24px, compact 38px and 16px. The table never sets its own width. Every column asks for its content width, and the spare is shared across all columns in proportion to those widths, with the `grow` column's floor (the record's name: 160px, 256px once the table is 672px wide) counted as its preferred width. A column whose cells `truncate` asks for its whole content width too and shrinks only when the table lacks room for every column; every column that must show its whole value keeps its content width. A column whose content is short and fixed (an ID, a key, a date) can take `width` (`w-28`), which it keeps, taking no spare, so a two-column table does not hand the short column most of the row.
31
31
 
32
32
  ## States
33
33
 
@@ -85,7 +85,7 @@ Rows arrive whole for client-side sorting and paging (a few hundred at most), or
85
85
 
86
86
  ## Do and do not
87
87
 
88
- - Do give the record's name `grow`. A second column may be cut only if it must be, since two of them then share what the table has left.
88
+ - Do give the record's name `grow`: it sets the floor and the phone card's title, and claims no spare. A column may be cut (`truncate`) if its values are long, since it shows them whole while the table has room and shrinks only when it has not.
89
89
  - Do drop columns with `hideBelow` rather than letting the table scroll sideways.
90
90
  - Do not put more than one action per row; a row with several actions opens a record that has them.
91
91
  - Do not show pagination for a list that fits on one page.
@@ -12,7 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox';
12
12
  import { EmptyState } from '@/components/ui/empty-state';
13
13
  import { Pagination } from '@/components/ui/pagination';
14
14
  import { Skeleton } from '@/components/ui/skeleton';
15
- import { Table, TableBody, TableHead, TableHeader, type Breakpoint, type SortDirection } from '@/components/ui/table';
15
+ import { Cut, Table, TableBody, TableHead, TableHeader, type Breakpoint, type SortDirection } from '@/components/ui/table';
16
16
  import { useQueryState } from './use-query-state';
17
17
 
18
18
  export { useQueryState } from './use-query-state';
@@ -36,9 +36,9 @@ export type Column<R> = {
36
36
  title?: (row: R) => string;
37
37
  /** Drop the column below this width (the least important columns go first). */
38
38
  hideBelow?: Breakpoint;
39
- /** The one column that takes the remaining width: the record's name. */
39
+ /** The record's name: the one column with a floor, so the last to be cut. It claims no spare width. */
40
40
  grow?: boolean;
41
- /** A width class for a column whose content is short and fixed (an ID, a key, a date), so the spare width goes to the others: `w-28`. */
41
+ /** A width class for a column whose content is short and fixed (an ID, a key, a date), so the spare is shared by the others: `w-28`. */
42
42
  width?: string;
43
43
  /**
44
44
  * Its place in the card a row becomes on phones: the `title` (one column, the record's name), a `status` at the end
@@ -96,7 +96,7 @@ const PHONE_ROWS = [
96
96
  ].join(' ');
97
97
 
98
98
  /** The resets a cell needs once the row is a card: not a row height, not a padding box, free to shrink and to clip. */
99
- const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!h-auto @max-[640px]:[&_td:not([data-mobile=hidden])]:!max-w-none @max-[640px]:[&_td:not([data-mobile=hidden])]:!p-0 @max-[640px]:[&_td:not([data-mobile=hidden])]:min-w-0 @max-[640px]:[&_td:not([data-mobile=hidden]):not([data-mobile=fact])]:!block @max-[640px]:[&_[data-sep]]:inline';
99
+ const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!h-auto @max-[640px]:[&_td:not([data-mobile=hidden])]:!p-0 @max-[640px]:[&_td:not([data-mobile=hidden])]:min-w-0 @max-[640px]:[&_td:not([data-mobile=hidden]):not([data-mobile=fact])]:!block @max-[640px]:[&_[data-sep]]:inline';
100
100
 
101
101
  /** Where a role sits in the card, and how it reads there.
102
102
  *
@@ -124,7 +124,7 @@ const HIDE: Record<Breakpoint, string> = { sm: '@max-[512px]:hidden', md: '@max-
124
124
 
125
125
  /** A cell's few own classes: what its column adds to the table's rules. */
126
126
  const cellClass = (c: { align?: 'left' | 'right' | 'center'; numeric?: boolean; muted?: boolean; truncate?: boolean; hideBelow?: Breakpoint }) =>
127
- cn(c.numeric && 't-num', c.align === 'center' && '!text-center', c.muted && 'text-ink-2', c.truncate && 'max-w-0 truncate', c.hideBelow && HIDE[c.hideBelow]);
127
+ cn(c.numeric && 't-num', c.align === 'center' && '!text-center', c.muted && 'text-ink-2', c.hideBelow && HIDE[c.hideBelow]);
128
128
 
129
129
  /** The table's sort and page, kept in the URL: `?sort=latency&dir=desc&page=2`. Wrap the page in <Suspense>. */
130
130
  export function useTableQuery(defaults: Partial<TableState> = {}) {
@@ -302,7 +302,7 @@ export function DataTable<R>({
302
302
  </TableHeader>
303
303
  ) : null}
304
304
  {columns.map((c) => (
305
- <TableHeader key={c.key} align={c.align ?? (c.numeric ? 'right' : 'left')} hideBelow={c.hideBelow} grow={c.grow} flexible={c.truncate} className={c.width} sort={c.sortValue ? dirOf(c) : undefined} onSort={c.sortValue ? () => toggleSort(c) : undefined}>
305
+ <TableHeader key={c.key} align={c.align ?? (c.numeric ? 'right' : 'left')} hideBelow={c.hideBelow} grow={c.grow} className={c.width} sort={c.sortValue ? dirOf(c) : undefined} onSort={c.sortValue ? () => toggleSort(c) : undefined}>
306
306
  {c.header}
307
307
  </TableHeader>
308
308
  ))}
@@ -350,12 +350,15 @@ export function DataTable<R>({
350
350
  {role === 'fact' && facts.indexOf(c) > 0 ? <span aria-hidden data-sep>·</span> : null}
351
351
  {/* A phone may want other words for the same cell — "Used 1 min ago", not "1 min ago" — so
352
352
  both are rendered and one is hidden: a few words each, never a second copy of the row. */}
353
- {c.mobileCell ? (
354
- <>
355
- <span className="@max-[640px]:hidden">{content}</span>
356
- <span className="hidden @max-[640px]:contents">{c.mobileCell(r)}</span>
357
- </>
358
- ) : content}
353
+ {(() => {
354
+ const body = c.mobileCell ? (
355
+ <>
356
+ <span className="@max-[640px]:hidden">{content}</span>
357
+ <span className="hidden @max-[640px]:contents">{c.mobileCell(r)}</span>
358
+ </>
359
+ ) : content;
360
+ return c.truncate ? <Cut>{body}</Cut> : body;
361
+ })()}
359
362
  </td>
360
363
  );
361
364
  })}
@@ -24,7 +24,7 @@ The names follow the HTML elements: `TableHead` is the `thead`, `TableHeader` is
24
24
 
25
25
  ## Sizes
26
26
 
27
- Column widths follow content. A column whose cells can be cut asks for all the width there is — `grow` on the head for the record's name, and `flexible` on any other head whose cells `truncate` — and takes what no other column needs; two or more of them share it, which is what a table with three long columns (a verification, its release, the reason it failed) needs. Every column that must show its whole value keeps its content width. A cut cell carries the full text in its `title`, or opens the record. Other text columns stay on one line. A flexible column is at least 160px, 256px once the table is 672px wide. `hideBelow` drops a column when the table itself is narrower than `sm` 512, `md` 672, `lg` 896 or `xl` 1152px, on its head and its cells alike, and marks each `data-hide-below`, so the column drawn last at that width, not the dropped one after it, takes the card's padding on its right.
27
+ Column widths follow content. Every column asks for its content width, and a table with spare width shares it across all of its columns in proportion to those widths, so no column holds the spare alone; `grow` on the head marks the record's name, whose floor counts as its preferred width when the floor is wider than the text. A column whose cells can be cut (`truncate`) asks for its whole content width too, and shrinks only when the table lacks room for every column; then the cut columns give way, which is what a table with three long columns (a verification, its release, the reason it failed) needs. Every column that must show its whole value keeps its content width. A column given `width` keeps that width and takes no spare. A cut cell carries the full text in its `title`, or opens the record. Other text columns stay on one line. The `grow` column is at least 160px, 256px once the table is 672px wide. `hideBelow` drops a column when the table itself is narrower than `sm` 512, `md` 672, `lg` 896 or `xl` 1152px, on its head and its cells alike, and marks each `data-hide-below`, so the column drawn last at that width, not the dropped one after it, takes the card's padding on its right.
28
28
 
29
29
  ## States
30
30
 
@@ -74,7 +74,6 @@ export function TableHeader({
74
74
  sort,
75
75
  onSort,
76
76
  grow,
77
- flexible,
78
77
  children,
79
78
  ...rest
80
79
  }: ThHTMLAttributes<HTMLTableCellElement> & {
@@ -82,10 +81,8 @@ export function TableHeader({
82
81
  hideBelow?: Breakpoint;
83
82
  sort?: SortDirection;
84
83
  onSort?: () => void;
85
- /** The one column that takes the remaining width: the record's name. Its truncating cells shrink last. */
84
+ /** The record's name: the one column with a floor, so it is the last to be cut. It claims no spare width. */
86
85
  grow?: boolean;
87
- /** The column's text can be cut rather than wrapped, so it can use width the other columns cannot. */
88
- flexible?: boolean;
89
86
  }) {
90
87
  const sortable = onSort !== undefined;
91
88
  const Icon = sort === 'asc' ? ArrowUp : sort === 'desc' ? ArrowDown : ChevronsUpDown;
@@ -99,17 +96,11 @@ export function TableHeader({
99
96
  'h-9 border-y border-line bg-surface-sunk px-4 text-xs font-medium whitespace-nowrap text-ink-3 first:border-l-0',
100
97
  ALIGN[align],
101
98
  hideBelow && HIDE[hideBelow],
102
- /* A column that can be cut asks for all the width there is, and the ones that must show their whole value —
103
- a count, a date, a badge — size to that value. It asked for a third before (`w-[30%]`), on the reading that
104
- auto layout spreads the remainder by content: it spreads it across every column, so the lead column was
105
- pinned at a third of the table, cutting titles while "Assigned to" sat in twice the room its longest name
106
- needed, and a second cut column beside it was left "1 of 2 pass…" on every row. Asking for all of it leaves
107
- the browser nothing to spread; two or more such columns share it between them, which is what the tables with
108
- three long columns (Tests: the verification, the release, the reason) need. */
109
- (grow || flexible) && 'w-full',
110
- // The floor belongs to the lead column alone. A second column that can be cut asks for the slack but must be
111
- // able to give it up: with the same floor on two columns the Releases table held 837px inside a 713px frame
112
- // at 1024 and 768, and no column could shrink because both had a minimum.
99
+ /* No head claims the spare. A cut column asks for its content's width through its cells (`Cut`), so auto layout
100
+ shares the spare across every column in proportion to that ask; a head with `w-full` took all of it for the
101
+ lead and left the rest at their minimum, the uneven spacing this replaced. */
102
+ // The floor belongs to the lead column alone (c8eccdc): a second column with a minimum cannot shrink, and the
103
+ // Releases table held 837px inside a 713px frame at 1024 and 768 when two columns had one.
113
104
  grow && 'min-w-40 @min-[672px]:min-w-64',
114
105
  className,
115
106
  )}
@@ -137,6 +128,19 @@ export function TableHeader({
137
128
  );
138
129
  }
139
130
 
131
+ /**
132
+ * A cut value: it asks for its whole content's width and shrinks only when the table has no room. One grid track of
133
+ * `minmax(0, max-content)` gives the cell that ask, where `max-w-0` on the cell zeroed it and sent every spare pixel to
134
+ * the lead column.
135
+ */
136
+ export function Cut({ children }: { children?: ReactNode }) {
137
+ return (
138
+ <span className="grid min-w-0 grid-cols-[minmax(0,max-content)]">
139
+ <span className="truncate">{children}</span>
140
+ </span>
141
+ );
142
+ }
143
+
140
144
  export function TableCell({
141
145
  className,
142
146
  align,
@@ -160,13 +164,12 @@ export function TableCell({
160
164
  ALIGN[align ?? (numeric ? 'right' : 'left')],
161
165
  numeric && 't-num whitespace-nowrap',
162
166
  muted ? 'text-ink-2' : 'text-ink',
163
- truncate && 'max-w-0 truncate',
164
167
  hideBelow && HIDE[hideBelow],
165
168
  className,
166
169
  )}
167
170
  {...rest}
168
171
  >
169
- {children}
172
+ {truncate ? <Cut>{children}</Cut> : children}
170
173
  </td>
171
174
  );
172
175
  }
@@ -196,11 +196,10 @@ export function MembersView({ rows, now, revisions, actions, access, roles, work
196
196
  </span>
197
197
  ),
198
198
  },
199
- // The name is the row's identifier and takes what the table has left; the email is not a second one, and it is a
200
- // fixed-width thing (a domain and a local part), so it is drawn whole on one line at the width its longest one
201
- // needs. Both asked to grow before, and the browser split the slack between them: the name sat in empty room while
202
- // "felix.andersson@zz-meridian.example" was cut to "felix.andersson@zz-meridian.examp…". Free to wrap, it broke
203
- // every address over two lines; held on one line, the column is as wide as the longest and nothing is cut.
199
+ // The name is the row's identifier and the table's floor; the email is not a second one, and it is a fixed-width
200
+ // thing (a domain and a local part), so it is drawn whole on one line at the width its longest one needs. Every
201
+ // column asks for its content width and the table shares the spare in proportion to those widths, so neither
202
+ // column claims the room. Held on one line, the email column is as wide as the longest address and nothing is cut.
204
203
  { key: 'email', header: 'Email', muted: true, hideBelow: 'lg', sortValue: (m) => m.email, cell: (m) => <span className="whitespace-nowrap">{m.email}</span> },
205
204
  // The role as a person reads it, add-ons and all, as a column and as a fact on the phone card (AC-3.2).
206
205
  { key: 'role', header: 'Role', hideBelow: 'sm', mobile: 'fact', sortValue: (m) => m.role, cell: (m) => roleLabel(m), mobileCell: (m) => roleLabel(m) },
@@ -0,0 +1,83 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('next/navigation', () => ({ useRouter: () => ({ push() {}, refresh() {}, replace() {} }), usePathname: () => '/' }));
5
+ import { DataTable, type Column } from '@/components/patterns/data-table';
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
7
+
8
+ const WRAP = ['grid', 'min-w-0', 'grid-cols-[minmax(0,max-content)]'];
9
+ const classes = (el: Element) => el.getAttribute('class')?.split(/\s+/) ?? [];
10
+ const wrapOf = (td: Element) => [...td.children].find((c) => WRAP.every((k) => classes(c).includes(k)));
11
+
12
+ /** A cut cell asks for its whole content and shrinks only when the table has no room; it never zeroes its width. */
13
+ function expectWrapped(td: Element, text: string) {
14
+ expect(classes(td)).not.toContain('max-w-0');
15
+ expect(classes(td)).not.toContain('truncate');
16
+ const wrap = wrapOf(td);
17
+ expect(wrap, 'the one-track wrapper').toBeTruthy();
18
+ expect(wrap!.children).toHaveLength(1);
19
+ expect(classes(wrap!.children[0])).toContain('truncate');
20
+ expect(wrap!.textContent).toContain(text);
21
+ return wrap!;
22
+ }
23
+
24
+ describe('a cut cell keeps its content width (spec AC-1.4)', () => {
25
+ it('TableCell wraps a truncating value and keeps its title', () => {
26
+ const { container } = render(
27
+ <Table>
28
+ <TableHead><TableRow><TableHeader grow>Request</TableHeader><TableHeader>Status</TableHeader></TableRow></TableHead>
29
+ <TableBody><TableRow><TableCell truncate>/v1/messages/with/a/long/tail</TableCell><TableCell>200</TableCell></TableRow></TableBody>
30
+ </Table>,
31
+ );
32
+ const td = container.querySelector('td')!;
33
+ expectWrapped(td, '/v1/messages/with/a/long/tail');
34
+ expect(td.getAttribute('title')).toBe('/v1/messages/with/a/long/tail');
35
+ const heads = [...container.querySelectorAll('th')];
36
+ expect(heads.some((th) => classes(th).includes('w-full'))).toBe(false);
37
+ expect(classes(heads[0])).toEqual(expect.arrayContaining(['min-w-40', '@min-[672px]:min-w-64']));
38
+ });
39
+ });
40
+
41
+ type Row = { id: string; name: string; plan: string; region: string };
42
+ const rows: Row[] = [{ id: 'cus_8f2a', name: 'Northwind Labs International Holdings', plan: 'Enterprise', region: 'eu-west-1' }];
43
+
44
+ describe('DataTable shares the spare (spec AC-1.4, AC-1.6)', () => {
45
+ it('wraps every cut column, claims no spare on any head, and keeps fixed widths and the floor', () => {
46
+ const columns: Column<Row>[] = [
47
+ { key: 'name', header: 'Customer', grow: true, truncate: true, mobile: 'title', title: (r) => r.name, cell: (r) => <span className="flex min-w-0 items-center gap-3"><span className="truncate font-medium">{r.name}</span></span> },
48
+ { key: 'id', header: 'ID', width: 'w-28', cell: (r) => r.id },
49
+ { key: 'plan', header: 'Plan', truncate: true, mobile: 'fact', cell: (r) => r.plan },
50
+ ];
51
+ const { container } = render(<DataTable caption="Customers" noun="customers" rows={rows} columns={columns} rowKey={(r) => r.id} selectable selected={new Set()} onSelectedChange={() => {}} />);
52
+ const cells = [...container.querySelectorAll('tbody tr:first-child td')];
53
+ const byText = (t: string) => cells.find((td) => td.textContent?.includes(t))!;
54
+ expectWrapped(byText('Northwind'), 'Northwind Labs International Holdings');
55
+ expect(byText('Northwind').getAttribute('title')).toBe('Northwind Labs International Holdings');
56
+ expectWrapped(byText('Enterprise'), 'Enterprise');
57
+ expect(classes(byText('cus_8f2a'))).not.toContain('max-w-0');
58
+ const heads = [...container.querySelectorAll('thead th')];
59
+ expect(heads.some((th) => classes(th).includes('w-full'))).toBe(false);
60
+ expect(classes(heads[0])).toContain('w-10');
61
+ const customer = heads.find((th) => th.textContent?.includes('Customer'))!;
62
+ expect(classes(customer)).toEqual(expect.arrayContaining(['min-w-40', '@min-[672px]:min-w-64']));
63
+ expect(classes(heads.find((th) => th.textContent?.includes('ID'))!)).toContain('w-28');
64
+ });
65
+
66
+ it("keeps a fact cell's separator outside the wrapper, and the row link and the phone span inside it", () => {
67
+ const columns: Column<Row>[] = [
68
+ { key: 'name', header: 'Customer', grow: true, truncate: true, mobile: 'title', title: (r) => r.name, cell: (r) => r.name },
69
+ { key: 'plan', header: 'Plan', truncate: true, mobile: 'fact', cell: (r) => r.plan, mobileCell: (r) => `${r.plan} plan` },
70
+ { key: 'region', header: 'Region', truncate: true, mobile: 'fact', cell: (r) => r.region },
71
+ ];
72
+ const { container } = render(<DataTable caption="Customers" noun="customers" rows={rows} columns={columns} rowKey={(r) => r.id} rowHref={(r) => `/customers/${r.id}`} />);
73
+ const [title, plan, region] = [...container.querySelectorAll('tbody tr:first-child td')];
74
+ const titleWrap = expectWrapped(title, 'Northwind Labs International Holdings');
75
+ expect(titleWrap.querySelector('a[href="/customers/cus_8f2a"]')).toBeTruthy();
76
+ expectWrapped(plan, 'Enterprise plan');
77
+ const sep = region.querySelector('[data-sep]')!;
78
+ expect(sep).toBeTruthy();
79
+ expect(sep.parentElement).toBe(region);
80
+ expect(sep.nextElementSibling).toBe(wrapOf(region));
81
+ expectWrapped(region, 'eu-west-1');
82
+ });
83
+ });
@@ -22,6 +22,13 @@ describe('first-load JS', () => {
22
22
  expect(firstLoadProblems([{ route: '/y', bytes: 535 * KiB }], { capKiB: 820, growthPct: 5, baseline: null })).toEqual({ problems: [], growth: 'not-configured' });
23
23
  expect(firstLoadProblems([{ route: '/y', bytes: 535 * KiB }], { capKiB: 1, growthPct: 5, baseline: null }).problems).toHaveLength(1);
24
24
  });
25
+ it('with the cap for unheld routes only, holds a route the baseline holds to its baseline alone (#76)', () => {
26
+ const routes = [{ route: '/x', bytes: 852 * KiB }, { route: '/y', bytes: 900 * KiB }, { route: '/z', bytes: 700 * KiB }];
27
+ const got = firstLoadProblems(routes, { capKiB: 820, growthPct: 5, baseline: { '/x': 850 * KiB, '/z': 600 * KiB }, capUnheldOnly: true });
28
+ expect(got.problems).toEqual(['/y: first-load JS 900 KiB over the 820 KiB cap', '/z: first-load JS grew 16.7% over its baseline (cap 5%)']);
29
+ expect(budgetsOf({ budgets: { firstLoadKb: null } }).firstLoadKb).toBeNull();
30
+ expect(budgetsOf({}).firstLoadKb).toBe(820);
31
+ });
25
32
  });
26
33
 
27
34
  describe('HTML and prefetch', () => {