zz-meridian 0.22.0 → 0.24.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.22.0",
3
+ "version": "0.24.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,18 @@
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.24.0] · 2026-10-20
6
+
7
+ ### New
8
+
9
+ - **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`).
10
+
11
+ ## [0.23.0] · 2026-10-20
12
+
13
+ ### New
14
+
15
+ - **`Prose` names each body cell's column, and stacks a narrow table into labelled cards** ([#75](https://github.com/zhixuan312/zz-meridian/issues/75)). Every body `td` carries its column's heading as `data-label`, written while the markdown is rendered, so it is in the server's HTML and a page can style a narrow table from `td[data-label]` on the first paint, with no second markdown parser. `stack` does it for you: a table under 480px of its own width reads as one card per row, the header row hidden, the first cell the title and each later cell under its column's heading. Wider, it is the same table; without `stack`, nothing changes.
16
+
5
17
  ## [0.22.0] · 2026-10-20
6
18
 
7
19
  ### New
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.22.0",
3
+ "version": "0.24.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.",
@@ -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>;
@@ -10,7 +10,7 @@ Status: beta
10
10
  2. **Headings**: the writer's `#` is an h2 (`text-xl`), `##` an h3 with a hairline above it, `###` an h4: the page keeps the only h1.
11
11
  3. **Lists**: discs and numbers in `ink-3`; task boxes drawn as checkboxes named "Done" or "Not done".
12
12
  4. **Code**: inline on `surface-sunk` with a `line` border; a fenced block on `surface-sunk`, `radius-lg`, wrapping instead of scrolling.
13
- 5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells.
13
+ 5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells. Every body cell carries its column's heading as `data-label`, in the server's HTML. With `stack`, a table under 480px of its own width reads as one card per row: the header row hidden, the first cell the title in `ink`, each later cell under its heading in `text-xs` `ink-3`.
14
14
  6. **Quote**: a 2px `accent` rule on the left, `ink` text.
15
15
  7. **Links**: `.link`, in `accent-ink`, turning `ink` on hover, never underlined; a link out of the product sends no referrer.
16
16
 
@@ -34,6 +34,7 @@ A markdown string. Line endings are normalised and the ends trimmed; nothing els
34
34
  | An image from another origin | Its alt text in italics (or "Image not shown"), never fetched |
35
35
  | A long token in code or a link: a URL, a path, a hash | Breaks anywhere rather than widen its paragraph or table; plain words wrap between words |
36
36
  | A table with more columns than the card holds | Scrolls inside its own frame; the page never scrolls sideways |
37
+ | `stack`, and a table under 480px of its own width | One card per row, each value under its column's heading; no header row |
37
38
  | Empty | Nothing: the caller shows the empty state ("No notes yet") |
38
39
 
39
40
  ## Surfaces
@@ -71,4 +72,7 @@ import { Prose } from '@/components/patterns/prose';
71
72
 
72
73
  <Card className="p-(--card-pad)"><Prose>{incident.review}</Prose></Card>
73
74
  <Prose size="sm">{comment.body}</Prose>
75
+ <Prose stack>{decision.record}</Prose> // tables become labelled cards in a narrow column
74
76
  ```
77
+
78
+ `stack` is for a document whose tables carry sentences (a decision record, a comparison) read in a narrow column. A table of short figures reads better scrolling in its frame, which is the default. Without `stack`, a page can still style its own narrow layout from `td[data-label]`.
@@ -1,4 +1,4 @@
1
- import { memo } from 'react';
1
+ import { memo, type ReactNode } from 'react';
2
2
  import ReactMarkdown, { type Components } from 'react-markdown';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import { cn } from '@/lib/cn';
@@ -7,6 +7,25 @@ import { safeMarkdownUrl } from '@/lib/safe-markdown';
7
7
  /** A link that leaves the product sends no referrer: the reader's address is not the author's business. */
8
8
  const external = (href?: string) => Boolean(href && /^(https?:)?\/\//i.test(href));
9
9
 
10
+ type Hast = { type: string; tagName?: string; value?: string; properties?: Record<string, unknown>; children?: Hast[] };
11
+ const text = (n: Hast): string => n.value ?? (n.children ?? []).map(text).join('');
12
+ const elements = (n: Hast, tag: string): Hast[] => (n.children ?? []).flatMap((c) => (c.tagName === tag ? [c] : c.tagName === 'table' ? [] : elements(c, tag)));
13
+
14
+ /**
15
+ * Each body cell carries its column's heading as `data-label`, written while the markdown is turned into HTML, so a
16
+ * table can be read as labelled cards with CSS alone on the server's first paint. A cell past the last heading, or
17
+ * under an empty one, carries none.
18
+ */
19
+ const labelCells = () => (tree: Hast) => {
20
+ for (const table of elements({ type: 'root', children: [tree] }, 'table')) {
21
+ const headings = elements(table, 'th').map((th) => text(th).trim());
22
+ for (const tr of elements(table, 'tbody').flatMap((b) => elements(b, 'tr')))
23
+ elements(tr, 'td').forEach((td, i) => {
24
+ if (headings[i]) td.properties = { ...td.properties, dataLabel: headings[i] };
25
+ });
26
+ }
27
+ };
28
+
10
29
  /**
11
30
  * Markdown a person wrote (a document, a note, an agent's reply), set for reading on Meridian's roles: a 72-character
12
31
  * measure, quiet tables, code on the sunk surface. Each element is drawn here rather than by a typography plugin, so
@@ -39,7 +58,7 @@ const COMPONENTS: Components = {
39
58
  thead: ({ children }) => <thead className="border-b border-line bg-surface-sunk">{children}</thead>,
40
59
  th: ({ children }) => <th className="px-3 py-2 text-xs font-medium whitespace-nowrap text-ink-3">{children}</th>,
41
60
  tr: ({ children }) => <tr className="border-b border-line last:border-0">{children}</tr>,
42
- td: ({ children }) => <td className="px-3 py-2.5 align-top text-ink-2">{children}</td>,
61
+ td: ({ children, 'data-label': label }: { children?: ReactNode; 'data-label'?: string }) => <td data-label={label} className="px-3 py-2.5 align-top text-ink-2">{children}</td>,
43
62
  blockquote: ({ children }) => <blockquote className="my-4 border-l-2 border-accent pl-4 text-ink">{children}</blockquote>,
44
63
  hr: () => <hr className="my-8 border-line" />,
45
64
  // An image the URL policy dropped reads as its alt text, never as a broken-image icon.
@@ -52,10 +71,36 @@ const COMPONENTS: Components = {
52
71
  rest.type === 'checkbox' ? <input {...rest} aria-label={rest.checked ? 'Done' : 'Not done'} className="mr-2 size-3.5 align-[-2px] accent-(--accent)" /> : <input {...rest} />),
53
72
  };
54
73
 
55
- export const Prose = memo(function Prose({ children, size = 'base', className }: { children: string; /** `sm` for a side panel, a comment or a quoted body. */ size?: 'base' | 'sm'; className?: string }) {
74
+ /**
75
+ * `stack`: below 480px of its own width a table reads as one card per row: the header row hidden, the first cell the
76
+ * card's title, each later cell on its own line under its column's heading. The frame is the container, so a table in a
77
+ * narrow column stacks wherever the column is narrow, not only on a phone.
78
+ */
79
+ const STACKED: Components = {
80
+ ...COMPONENTS,
81
+ table: ({ children }) => (
82
+ <div className="my-5 overflow-x-auto rounded-lg border border-line @container">
83
+ <table className={cn('w-full text-left text-sm leading-normal',
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
+ '@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',
86
+ '@max-[480px]:[&_td:not(:first-child)[data-label]]:before:block @max-[480px]:[&_td:not(:first-child)[data-label]]:before:text-xs @max-[480px]:[&_td:not(:first-child)[data-label]]:before:font-medium @max-[480px]:[&_td:not(:first-child)[data-label]]:before:text-ink-3 @max-[480px]:[&_td:not(:first-child)[data-label]]:before:content-[attr(data-label)]')}>
87
+ {children}
88
+ </table>
89
+ </div>
90
+ ),
91
+ };
92
+
93
+ export const Prose = memo(function Prose({ children, size = 'base', stack = false, className }: {
94
+ children: string;
95
+ /** `sm` for a side panel, a comment or a quoted body. */
96
+ size?: 'base' | 'sm';
97
+ /** A table narrower than 480px reads as labelled cards, one per row. */
98
+ stack?: boolean;
99
+ className?: string;
100
+ }) {
56
101
  return (
57
102
  <div className={cn('min-w-0 text-ink-2 break-words', size === 'base' ? 'text-base leading-[1.7]' : 'text-sm leading-relaxed', className)}>
58
- <ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={safeMarkdownUrl} components={COMPONENTS}>
103
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[labelCells]} urlTransform={safeMarkdownUrl} components={stack ? STACKED : COMPONENTS}>
59
104
  {children.replace(/\r\n/g, '\n').trim()}
60
105
  </ReactMarkdown>
61
106
  </div>
@@ -40,6 +40,11 @@ const HOSTILE = `A reply pasted from somewhere else:
40
40
 
41
41
  ![A tracking pixel](https://elsewhere.example/pixel.png)`;
42
42
 
43
+ const DECISIONS = `| Choice | Chosen | Rejected because |
44
+ |---|---|---|
45
+ | Queue | Postgres \`SKIP LOCKED\` | a broker is one more service to run for 40 jobs a minute |
46
+ | Retries | Three, with backoff | a fourth retry has never succeeded in the last 90 days |`;
47
+
43
48
  export default function ProsePreview() {
44
49
  return (
45
50
  <>
@@ -58,6 +63,14 @@ export default function ProsePreview() {
58
63
  <div className="w-80 p-4"><Prose size="sm">{REVIEW.split('## What we are changing')[1]}</Prose></div>
59
64
  </Plane>
60
65
  </Specimen>
66
+ <Specimen label="Stacked" note="With stack, a table under 480px of its own width reads as one card per row: the first cell its title, each later cell under its column's heading. Wider, it is the same table." stack>
67
+ <Plane on="surface">
68
+ <div className="flex flex-wrap gap-6 p-4">
69
+ <div className="w-80"><Prose size="sm" stack>{DECISIONS}</Prose></div>
70
+ <div className="w-[560px] max-w-full"><Prose size="sm" stack>{DECISIONS}</Prose></div>
71
+ </div>
72
+ </Plane>
73
+ </Specimen>
61
74
  </>
62
75
  );
63
76
  }
@@ -0,0 +1,31 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { Prose } from '@/components/patterns/prose';
4
+
5
+ const DOC = `| Choice | Chosen | Rejected because |
6
+ |---|---|---|
7
+ | Store | **Postgres** | the queue needs order |
8
+ | Cache | none | |
9
+
10
+ | A | \`B\` |
11
+ |---|---|
12
+ | 1 | 2 | 3 |`;
13
+
14
+ describe('Prose names each body cell\'s column (#75)', () => {
15
+ test('writes the column heading on every body cell, per table, as plain text', () => {
16
+ const { container } = render(<Prose>{DOC}</Prose>);
17
+ const [first, second] = [...container.querySelectorAll('table')];
18
+ expect([...first.querySelectorAll('tbody td')].map((td) => td.getAttribute('data-label'))).toEqual(['Choice', 'Chosen', 'Rejected because', 'Choice', 'Chosen', 'Rejected because']);
19
+ expect([...second.querySelectorAll('tbody td')].map((td) => td.getAttribute('data-label'))).toEqual(['A', 'B']);
20
+ expect(container.querySelectorAll('th[data-label]')).toHaveLength(0);
21
+ });
22
+
23
+ test('stack makes each table\'s frame the container its rows stack in', () => {
24
+ const plain = render(<Prose>{DOC}</Prose>);
25
+ expect(plain.container.querySelector('.\\@container')).toBeNull();
26
+ plain.unmount();
27
+ const { container } = render(<Prose stack>{DOC}</Prose>);
28
+ expect(container.querySelectorAll('.\\@container > table')).toHaveLength(2);
29
+ expect(container.querySelector('tbody td')).toHaveAttribute('data-label', 'Choice');
30
+ });
31
+ });
@@ -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', () => {