zz-meridian 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/migrations.js +8 -0
  2. package/dist/ownership.js +1 -1
  3. package/package.json +1 -1
  4. package/payload/CHANGELOG.md +20 -0
  5. package/payload/docs/data-display.md +2 -2
  6. package/payload/docs/distribution.md +2 -2
  7. package/payload/docs/testing.md +1 -1
  8. package/payload/package.json +1 -1
  9. package/payload/scripts/lib/budgets.ts +5 -1
  10. package/payload/scripts/lib/sizes.ts +24 -0
  11. package/payload/scripts/sizes.ts +56 -32
  12. package/payload/scripts/verify.baseline.json +26 -0
  13. package/payload/scripts/verify.config.ts +4 -12
  14. package/payload/scripts/verify.ts +9 -2
  15. package/payload/skills/zz-meridian/references/existing-project.md +2 -2
  16. package/payload/skills/zz-meridian/references/ownership.md +1 -1
  17. package/payload/skills/zz-meridian/references/standard.md +3 -2
  18. package/payload/skills/zz-meridian/references/update.md +3 -2
  19. package/payload/skills/zz-meridian/references/validation.md +1 -1
  20. package/payload/src/components/charts/column-chart/README.md +9 -7
  21. package/payload/src/components/charts/column-chart/index.tsx +23 -12
  22. package/payload/src/components/charts/column-chart/preview.tsx +12 -1
  23. package/payload/src/components/patterns/activity-feed/README.md +2 -2
  24. package/payload/src/components/patterns/activity-feed/index.tsx +5 -3
  25. package/payload/src/components/patterns/activity-feed/preview.tsx +2 -2
  26. package/payload/src/components/patterns/data-table/README.md +1 -1
  27. package/payload/src/components/patterns/data-table/index.tsx +4 -1
  28. package/payload/src/components/patterns/data-table/preview.tsx +2 -1
  29. package/payload/src/components/patterns/rail/README.md +1 -1
  30. package/payload/src/components/patterns/rail/index.tsx +12 -2
  31. package/payload/src/components/ui/button/README.md +2 -2
  32. package/payload/src/components/ui/button/index.tsx +9 -6
  33. package/payload/src/styles/base.css +14 -0
  34. package/payload/src/views/console-chrome.tsx +4 -1
  35. package/payload/tests/activity-feed-link.test.tsx +23 -0
  36. package/payload/tests/button-busy.test.tsx +38 -0
  37. package/payload/tests/column-chart-no-data.test.tsx +5 -1
  38. package/payload/tests/column-chart-stacked.test.tsx +23 -0
  39. package/payload/tests/data-table.test.tsx +11 -0
  40. package/payload/tests/rail-current-held.test.tsx +32 -0
  41. package/payload/tests/verify-sizes.test.ts +11 -2
@@ -462,6 +462,14 @@ export const RELEASE_MIGRATIONS = [
462
462
  instructions: 'Bring `tests/verify-suite-outcome.test.ts` over from the release as it has it. A file of your own that calls `finalOutcome` passes the tiers, `finalOutcome({ full, timed }, notRun)`, where it passed whether the run was full (`finalOutcome(true, notRun)`): `references/validation.md` describes the tiers, `pnpm verify`, `--full` and `--timed`.',
463
463
  checks: ['gate'],
464
464
  },
465
+ {
466
+ id: 'no-data-mark',
467
+ since: '0.19.0',
468
+ applies: (root) => matching(root, (rel, src) => importsOf(rel, src).some(({ from }) => from === 'src/components/charts/column-chart') && /\.bg-fill-track['"]\)\)\.not\.toBeNull\(\)/.test(src)),
469
+ summary: 'A Column chart\'s period with no data draws a dashed hairline on the baseline, where it drew a `fill-track` track the height of the scale.',
470
+ instructions: 'Bring `tests/column-chart-no-data.test.tsx` over from the release as it has it. Since 0.19.0 `src/components/charts/column-chart/index.tsx` draws no column for a `null` period: its box has no height, a dashed hairline (`data-no-data-mark`) keeps the slot, and the word stands over it, because the track was the tallest mark on the chart. A test of your own that finds `.bg-fill-track` or `[data-track]` in a no-data column finds `[data-no-data-mark]` instead.',
471
+ checks: ['gate'],
472
+ },
465
473
  ];
466
474
  /**
467
475
  * A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
package/dist/ownership.js CHANGED
@@ -7,7 +7,7 @@ export const FIXED = [...LIB, 'src/lib/assistant/prompt.ts', 'src/views/console-
7
7
  /** The one rule, a plain-English line per clause. `references/ownership.md` renders it, so the guidance cannot drift. */
8
8
  export const OWNERSHIP = [
9
9
  'Managed: the payload files under `tokens/`, `src/styles/` and `src/components/`, except each component\'s `README.md` and `preview.tsx`.',
10
- 'Managed: the distributed scripts under `scripts/`, except `scripts/verify.config.ts`, the first-load baseline `optional:scripts/verify.baseline.json` and a team-local `optional:scripts/check.local.ts`.',
10
+ 'Managed: the distributed scripts under `scripts/`, except `scripts/verify.config.ts`, the size baseline `optional:scripts/verify.baseline.json` (first-load JS and the HTML of each page) and a team-local `optional:scripts/check.local.ts`.',
11
11
  'Managed: the library helpers `src/lib/cn.ts`, `src/lib/format.ts`, `src/lib/format-date.ts`, `src/lib/period.ts`, `src/lib/color.ts`, `src/lib/host.ts`, `src/lib/preferences.ts`, `src/lib/csv.ts`, `src/lib/safe-markdown.ts`, `src/lib/logo.ts`, `src/lib/collection.ts`, `src/lib/explore.ts`, `src/lib/live.ts`, `src/lib/shared-context.ts`, `src/lib/agent-guidance.ts`, `src/lib/insight.ts` and `src/lib/facts.ts`, and the assistant prompt `src/lib/assistant/prompt.ts`.',
12
12
  'Managed: `src/views/console-chrome.tsx` and `tests/setup.ts`.',
13
13
  'Managed: `optional:scripts/package.json`, only when the adopt or create that set the project up generated it for this project shape.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,26 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.19.0] · 2026-10-19
6
+
7
+ ### Breaking
8
+
9
+ - **Every page's HTML is held to its own baseline, and measured gzipped** ([#57](https://github.com/zhixuan312/zz-meridian/issues/57), [#56](https://github.com/zhixuan312/zz-meridian/issues/56)). `scripts/sizes.ts` measures a page's HTML as a person downloads it, the complete streamed response gzipped, where it measured the uncompressed bytes of the routes in `budgets.htmlKb` only. `node scripts/sizes.ts --write-baseline --base <url>`, against the running build, records the HTML of every page (no API, no dynamic segment, not the not-found screen) beside the first-load figures in `scripts/verify.baseline.json`, and `pnpm verify` holds each to `budgets.htmlGrowthPct` (5%) over its own figure, as first-load JS is held. A growth you accept is recorded once, with the same command, rather than by editing numbers. `budgets.htmlKb` stays as an optional absolute cap, now in gzipped KiB: a cap set on uncompressed bytes is several times too loose, so lower it to the gzipped figure `pnpm verify` prints, or remove it and rely on the baseline. Until the baseline holds `html`, the HTML line is a warning, and `pnpm verify --full` refuses, naming the command. Under `partialPrefetching` Next.js writes each shell's static trees into a page's HTML twice; gzipped, the second copy costs a few KiB rather than tens, so the figure follows the page.
10
+
11
+ ### New
12
+
13
+ - **A stacked Column chart's part can take a categorical slot** ([#61](https://github.com/zhixuan312/zz-meridian/issues/61)). `ColumnPart.color` (1 to 6) draws the part in `series-<n>`, as `TrendSeries.color` does, for parts that are identities, such as teams, or for two or more usual outcomes, which one neutral would make one. A part with no slot, Other among them, is neutral, and status colours stay out of such a chart: coral and saffron read as a warning beside a real one. `docs/data-display.md`'s Parts of one whole says when to use it.
14
+ - **`DataTable` takes `retrying`** ([#59](https://github.com/zhixuan312/zz-meridian/issues/59)): its error state's Retry is busy while the person's retry is on its way. Pass it for their own press only.
15
+ - **`ActivityEvent.href`** ([#63](https://github.com/zhixuan312/zz-meridian/issues/63)) links an event's object to its record, with the arrow that says it stays in the product.
16
+ - **`Rail` takes `declared`**, every destination the product declares; `ConsoleRail` passes it from the whole navigation.
17
+
18
+ ### Fixed
19
+
20
+ - **A busy Button keeps its focus** ([#58](https://github.com/zhixuan312/zz-meridian/issues/58)). It was `disabled`, so a focused button that turned busy from anything but its own press, a Retry whose read a live hint or a poll started, lost focus to the page and the next Tab began again at the top. It is `aria-disabled` now, stays in the tab order, and drops a press while busy, a submit's included; `aria-busy` and the spinner are unchanged. A test of yours that expects a busy button to be `disabled` expects `aria-disabled="true"`.
21
+ - **A Column chart's period with no data is no longer its tallest mark** ([#60](https://github.com/zhixuan312/zz-meridian/issues/60)). It drew a `fill-track` track the height of the scale, which in light theme read as the biggest period. It draws no column now, a dashed hairline just above the baseline, and its word ("No data") stands over it where a value would. The hairline carries `data-no-data-mark` where the track carried `data-track`. `npx zz-meridian@latest update` lists `migration:no-data-mark` for a project whose copy of `tests/column-chart-no-data.test.tsx` still expects the track: bring the release's over.
22
+ - **The rail lights nothing for an address under a destination the person does not hold** ([#62](https://github.com/zhixuan312/zz-meridian/issues/62)). It lit the longest destination they held, so someone refused `/admin/audit` saw `/admin` lit. The current page is found among every declared destination and lit only when their rail holds it. A product that passed `current=''` to work around it can stop.
23
+ - **A Button's class list is 178 bytes, not 394** ([#56](https://github.com/zhixuan312/zz-meridian/issues/56)): the frame every button shares is the `.btn` class in `src/styles/base.css`, so a table of row actions does not repeat it on every instance.
24
+
5
25
  ## [0.18.1] · 2026-10-19
6
26
 
7
27
  ### Changed
@@ -36,7 +36,7 @@ Two measures of different size are two charts on one Meridian, not one chart wit
36
36
  - **Magnitude**: one hue, light to dark, built from the accent and the surface (the heatmap).
37
37
  - **Magnitude by rank**: a Heatmap's step is a cell's share of the largest cell (`max`) unless one outlier would leave every other cell at the lowest step. Then it is ranked (`scale="quantile"`): the cells above zero take five steps of equal count. A step no longer says how much, so the legend's upper end reads the value the top step starts at ("900 or more") in place of "More", and the readout and the hidden table give each cell's exact value.
38
38
  - **State**: positive, warning, critical, always with a word or an icon.
39
- - **Parts of one whole**: one hue. The parts share the accent, mixed into the surface, deepest at the bottom and paler upward (the stacked Trend chart and the stacked Column chart take the same mix), with a 1px hairline of the card between neighbours and at most four parts, the fourth being Other. When the parts are outcomes they carry a tone instead: the usual outcome is neutral (`chart-neutral-strong`), one that needs somebody is warning or critical, the accent never stands beside a tone, and the positive colour is never the usual case.
39
+ - **Parts of one whole**: one hue. The parts share the accent, mixed into the surface, deepest at the bottom and paler upward (the stacked Trend chart and the stacked Column chart take the same mix), with a 1px hairline of the card between neighbours and at most four parts, the fourth being Other. When the parts are outcomes they carry a tone instead: the usual outcome is neutral (`chart-neutral-strong`), one that needs somebody is warning or critical, the accent never stands beside a tone, and the positive colour is never the usual case. Parts that are identities (teams), or two or more usual outcomes that one neutral would make one, take the categorical slots instead, in order (`series-1` onward, as Identity above), and a part with no slot is neutral. Status colours stay out of such a chart, as the accent stays out of a toned one, since coral and saffron read as a warning beside a real one: a chart with an outcome that needs somebody is a toned chart.
40
40
 
41
41
  Text never wears a series colour; values and labels stay in ink.
42
42
 
@@ -68,7 +68,7 @@ A period still in progress is counted in no total, change or finding. A figure f
68
68
  - The figure is set tight (the hero figure at `tracking-display`, a card's figure at -0.03em); its unit steps down to half size in `ink-3`; money steps its cents down; compact counts keep their decimal (`2.9M`).
69
69
  - Tabular figures where numbers stack or change in place (tables, axes, readouts, and a tile's or the Featured metric's figure, which changes as the Meridian moves); proportional for a standalone figure that stays put.
70
70
  - A missing value is a dash, never zero. Every figure names its period and unit somewhere visible.
71
- - A period with no data is a gap in a chart, never a zero and never left out: a `null` keeps its place on the time axis. A Trend chart's and a Sparkline's line breaks at it; a Column chart's column keeps its slot as the empty track and reads "No data" (or the product's word, `noData`) at rest, pointed at and in the table, and the scale leaves it out.
71
+ - A period with no data is a gap in a chart, never a zero and never left out: a `null` keeps its place on the time axis. A Trend chart's and a Sparkline's line breaks at it; a Column chart's column keeps its slot with a dashed hairline on the baseline, never a mark that stands up from it, and reads "No data" (or the product's word, `noData`) at rest, pointed at and in the table, and the scale leaves it out.
72
72
  - A Heatmap of hours names the timezone its hours are cut in, in its readout, its announcement and its table: UTC unless the product passes `timezone`. A product that cuts its days at local midnight cuts its hours there too and names the zone, so a local Monday stays in Monday's row. The template's Usage passes the product's reporting timezone, `app.timezone`, as its Heatmap's `timezone` and names it in its cards and its context, so a product that sets that zone keeps its hours cut in it.
73
73
  - A delta's arrow carries direction; its colour carries the judgement, which depends on the measure (latency down is good).
74
74
  - A change says what it is against, in words beside it: "vs previous period" unless the caller says otherwise ("vs the week before"). The Featured metric and the Metric tile take the words as `compare`. On a wall of compact tiles the card that holds the wall says it once, and a tile draws the change's figure alone.
@@ -148,10 +148,10 @@ Two environment variables exist for the updater's own tests and are used only wh
148
148
  reports as `migration:<id>` because it cannot make them. An entry applies when the project's recorded version is older
149
149
  than its `since` and the target is not, and its `applies(root)` returns the paths that still have the old shape, or
150
150
  null. It only reads, and only team-owned files (the ownership rule's managed paths are skipped); a file it cannot read is
151
- absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone` and `verify-tiers`, `gate` alone), and its
151
+ absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers` and `no-data-mark`, `gate` alone), and its
152
152
  instructions name the section of a reference (`cache.md`, `live.md`, `validation.md`, `agents.md`, `customize.md`), of the Start guide or of `docs/assistant.md` to follow.
153
153
 
154
- Fifteen arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), and `verify-tiers` (0.17.0). The ten of 0.5.0 are:
154
+ Sixteen arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), `verify-tiers` (0.17.0), and `no-data-mark` (0.19.0). The ten of 0.5.0 are:
155
155
 
156
156
  | Id | Reported when a team-owned file still has | Completion step |
157
157
  |----|-------------------------------------------|-----------------|
@@ -125,7 +125,7 @@ Every check Meridian runs has one row, named by a key: a gate step, a group of t
125
125
  | `verify/build` | large | functional | change | no | `pnpm verify` | 9.6 s at 40d29c7 | One production build. |
126
126
  | `verify/route policy` | large | non-functional | change | no | `pnpm verify` | 0.1 s at 40d29c7 | Every route is declared static or dynamic as its policy says. |
127
127
  | `verify/first-load` | large | non-functional | change | no | `pnpm verify` | 0.3 s at 40d29c7 | The first-load size of each route is within its budget. |
128
- | `verify/html` | large | non-functional | change | no | `pnpm verify` | 0.3 s at 40d29c7 | The served HTML of each route is within its size check. |
128
+ | `verify/html` | large | non-functional | change | no | `pnpm verify` | 1.5 s at dbf4106 | Every page's served HTML, gzipped, is within `htmlGrowthPct` of its own baseline figure and under any `htmlKb` cap. |
129
129
  | `verify/navigation` | large | functional | change | no | `pnpm verify` | 92.5 s at 269d307 | From a press in the rail, each route's shell, data and first interaction arrive, on desktop and through the phone drawer, judged on function only, never on time; up to three routes, every rail route under `--full` or `--timed`. |
130
130
  | `verify/timing` | large | performance | timed | yes | `pnpm verify --timed` | 88.2 s at fa5bbff | The same navigation of every rail route, with each shell, data and interaction time judged against its budget. |
131
131
  | `verify/personas` | large | functional | on request | no | `pnpm verify --as viewer` | 937.8 s at fa5bbff (the five personas) | Signed in as each named persona, the rail shows the routes that persona may open and a denied route draws its refusal. |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.18.1",
3
+ "version": "0.19.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.",
@@ -26,6 +26,7 @@ export type Budgets = {
26
26
  navigation: { warm: ReadinessBudget; cold: ReadinessBudget; afterLive: ReadinessBudget };
27
27
  firstLoadKb: number;
28
28
  firstLoadGrowthPct: number;
29
+ htmlGrowthPct: number;
29
30
  htmlKb: Record<string, number>;
30
31
  prefetchKb: { desktop: number; phoneClosed: number };
31
32
  };
@@ -34,6 +35,7 @@ export type BudgetsConfig = {
34
35
  navigation?: { warm?: ReadinessBudget; cold?: ReadinessBudget; afterLive?: ReadinessBudget };
35
36
  firstLoadKb?: number;
36
37
  firstLoadGrowthPct?: number;
38
+ htmlGrowthPct?: number;
37
39
  htmlKb?: Record<string, number>;
38
40
  prefetchKb?: { desktop: number; phoneClosed: number };
39
41
  };
@@ -45,7 +47,7 @@ const COLD: ReadinessBudget = {
45
47
  interactiveMs: { desktop: 2000, phone: 4000 },
46
48
  };
47
49
 
48
- /** The HTML caps are a project's routes, so the defaults set none; the template's config sets its own. */
50
+ /** Each page's HTML is held to its own baseline; an absolute HTML cap is a project's route, so the defaults set none. */
49
51
  export const DEFAULT_BUDGETS: Budgets = {
50
52
  navigation: {
51
53
  warm: {
@@ -58,6 +60,7 @@ export const DEFAULT_BUDGETS: Budgets = {
58
60
  },
59
61
  firstLoadKb: 820,
60
62
  firstLoadGrowthPct: 5,
63
+ htmlGrowthPct: 5,
61
64
  htmlKb: {},
62
65
  prefetchKb: { desktop: 80, phoneClosed: 0 },
63
66
  };
@@ -91,6 +94,7 @@ export function budgetsOf(config: BudgetsConfig): Budgets {
91
94
  navigation: { warm, cold, afterLive: b.navigation?.afterLive ?? cold },
92
95
  firstLoadKb: b.firstLoadKb ?? DEFAULT_BUDGETS.firstLoadKb,
93
96
  firstLoadGrowthPct: b.firstLoadGrowthPct ?? DEFAULT_BUDGETS.firstLoadGrowthPct,
97
+ htmlGrowthPct: b.htmlGrowthPct ?? DEFAULT_BUDGETS.htmlGrowthPct,
94
98
  htmlKb: b.htmlKb ?? DEFAULT_BUDGETS.htmlKb,
95
99
  prefetchKb: b.prefetchKb ?? DEFAULT_BUDGETS.prefetchKb,
96
100
  };
@@ -35,6 +35,30 @@ export function firstLoadProblems(
35
35
  return { problems, growth: baseline ? 'checked' : 'not-configured' };
36
36
  }
37
37
 
38
+ /**
39
+ * The pages whose HTML is held to a baseline: every page route but an API, one under a dynamic segment (its address
40
+ * needs an id the build does not know) and the not-found screen, which answers 404.
41
+ */
42
+ export function htmlRoutes(routes: string[]): string[] {
43
+ return routes.filter((r) => !r.startsWith('/api/') && !r.includes('[') && r !== '/_not-found');
44
+ }
45
+
46
+ /** Pages whose HTML grew past their allowance over the baseline, as first-load JS is held; without one growth is not configured. */
47
+ export function htmlGrowthProblems(
48
+ sizes: Record<string, number>,
49
+ { growthPct, baseline }: { growthPct: number; baseline: Record<string, number> | null },
50
+ ): { problems: string[]; growth: 'checked' | 'not-configured' } {
51
+ const problems: string[] = [];
52
+ for (const [route, base] of Object.entries(baseline ?? {})) {
53
+ const bytes = sizes[route];
54
+ if (bytes === undefined || !(base > 0)) continue;
55
+ const grew = ((bytes - base) / base) * 100;
56
+ if (grew > growthPct) problems.push(`${route}: HTML grew ${grew.toFixed(1)}% over its baseline (cap ${growthPct}%)`);
57
+ }
58
+ return { problems, growth: baseline ? 'checked' : 'not-configured' };
59
+ }
60
+
61
+ /** Pages over an absolute HTML cap, a route a project pins on top of its baseline. */
38
62
  export function htmlProblems(sizes: Record<string, number>, capsKiB: Record<string, number>): string[] {
39
63
  return Object.entries(capsKiB)
40
64
  .filter(([route, cap]) => (sizes[route] ?? 0) > cap * KIB)
@@ -1,36 +1,42 @@
1
1
  /**
2
- * The size checks of a production build: first-load JS of every non-API route against its cap and its baseline, and the
3
- * complete HTML of every route that has an `htmlKb` cap.
2
+ * The size checks of a production build: first-load JS of every non-API route, and the HTML of every page, each against
3
+ * its own baseline.
4
4
  *
5
5
  * node scripts/sizes.ts [--base http://127.0.0.1:3000] [--write-baseline]
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. Without a baseline the growth
10
- * check is `not-configured`, a warning and never a pass. The HTML is measured through the end of the streamed response
11
- * from the app already running at `--base`; without `--base` it is `not run`.
9
+ * `scripts/verify.baseline.json` exists, against `budgets.firstLoadGrowthPct` over it. A page's HTML is what a person
10
+ * downloads: the complete streamed response from the app running at `--base`, gzipped. Every page in the baseline's
11
+ * `html` is held to `budgets.htmlGrowthPct` over its own figure, and a route in `budgets.htmlKb` also to that absolute
12
+ * cap. Without a baseline a growth check is `not-configured`, a warning and never a pass; without `--base` the HTML is
13
+ * `not run`.
12
14
  *
13
- * `--write-baseline` records the build's first-load sizes as the team's baseline, `scripts/verify.baseline.json`, and
14
- * checks nothing. The file is the team's: commit it on its own, from a build whose sizes were reviewed.
15
+ * `--write-baseline` records the build's sizes as the team's baseline, `scripts/verify.baseline.json`, and checks
16
+ * nothing: first-load for every route and, with `--base`, the HTML of every page (no API, no dynamic segment, not the
17
+ * not-found screen). Without `--base` the HTML baseline already there is kept. The file is the team's: commit it on its
18
+ * own, from a build whose sizes were reviewed.
15
19
  *
16
20
  * One line per check, and a JSON summary on the last line:
17
21
  *
18
22
  * ok|FAIL|warn|not run <what> ...
19
- * sizes: {"status","firstLoad":{"status","routes","largest","growth","problems"},"html":{"status","sizes","reason","problems"}}
23
+ * sizes: {"status","firstLoad":{"status","routes","largest","growth","problems"},"html":{"status","sizes","growth","reason","problems"}}
20
24
  *
21
25
  * The exit code is non-zero on a FAIL and on a build or baseline that cannot be read.
22
26
  */
23
27
  import fs from 'node:fs';
24
28
  import path from 'node:path';
29
+ import { gzipSync } from 'node:zlib';
25
30
 
26
31
  import { budgetsOf, type BudgetsConfig } from './lib/budgets.ts';
27
32
  import { distDirOf } from './lib/next-config.ts';
28
- import { firstLoad, firstLoadProblems, htmlProblems } from './lib/sizes.ts';
33
+ import { firstLoad, firstLoadProblems, htmlGrowthProblems, htmlProblems, htmlRoutes } from './lib/sizes.ts';
29
34
  import verifyConfig from './verify.config.ts';
30
35
 
31
36
  const ROOT = path.resolve(import.meta.dirname, '..');
32
37
  const BASELINE = path.join(ROOT, 'scripts/verify.baseline.json');
33
38
  const METHOD = 'route-bundle-stats firstLoadChunkPaths, deduplicated, uncompressed bytes';
39
+ const HTML_METHOD = 'the complete streamed response, gzipped (zlib level 6), bytes';
34
40
  const KIB = 1024;
35
41
  const kib = (bytes: number): number => Math.round(bytes / KIB);
36
42
 
@@ -56,29 +62,32 @@ function measured(dist: string): { route: string; bytes: number }[] {
56
62
  } catch (e) { return fail((e as Error).message); }
57
63
  }
58
64
 
65
+ type Baseline = { firstLoad: Record<string, number>; html: Record<string, number> | null };
66
+ const SHAPE = '{ "method": string, "routes": { "<route>": <bytes> }, "html"?: { "method": string, "routes": { "<route>": <bytes> } } }';
67
+ const sized = (v: unknown): v is Record<string, number> => !!v && typeof v === 'object' && Object.values(v).every((n) => typeof n === 'number' && n > 0);
68
+
59
69
  /** The team's baseline, or null when it has none. A file that is there and wrong is an error, never a missing baseline. */
60
- function baselineOf(): Record<string, number> | null {
70
+ function baselineOf(): Baseline | null {
61
71
  if (!fs.existsSync(BASELINE)) return null;
62
72
  const rel = path.relative(ROOT, BASELINE);
63
- let raw: { routes?: Record<string, unknown> };
73
+ let raw: { routes?: unknown; html?: { routes?: unknown } };
64
74
  try { raw = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); } catch (e) { return fail(`${rel} is not JSON: ${(e as Error).message}`); }
65
- const routes = raw?.routes;
66
- if (!routes || typeof routes !== 'object' || Object.values(routes).some((v) => typeof v !== 'number' || !(v > 0))) {
67
- return fail(`${rel} must be { "method": string, "routes": { "<route>": <bytes> } }; write it with node scripts/sizes.ts --write-baseline`);
75
+ if (!sized(raw?.routes) || (raw.html !== undefined && !sized(raw.html?.routes))) {
76
+ return fail(`${rel} must be ${SHAPE}; write it with node scripts/sizes.ts --write-baseline --base <url>`);
68
77
  }
69
- return routes as Record<string, number>;
78
+ return { firstLoad: raw.routes, html: raw.html ? (raw.html.routes as Record<string, number>) : null };
70
79
  }
71
80
 
72
- /** The complete response of each route, in bytes, read through the end of the stream and left uncompressed. */
73
- async function htmlSizes(routes: string[]): Promise<{ sizes: Record<string, number>; problems: string[] }> {
81
+ /** Each page's complete response, read through the end of the stream and gzipped: what a person downloads. */
82
+ async function htmlSizes(routes: string[], why: string): Promise<{ sizes: Record<string, number>; problems: string[] }> {
74
83
  const sizes: Record<string, number> = {};
75
84
  const problems: string[] = [];
76
85
  for (const route of routes) {
77
86
  try {
78
- const res = await fetch(base + route, { headers: { 'accept-encoding': 'identity' } });
79
- const body = await res.arrayBuffer();
80
- if (res.status !== 200) problems.push(`${route}: HTML cap set, but the route answered ${res.status}`);
81
- else sizes[route] = body.byteLength;
87
+ const res = await fetch(base + route, { headers: { 'accept-encoding': 'identity' }, redirect: 'manual' });
88
+ const body = Buffer.from(await res.arrayBuffer());
89
+ if (res.status !== 200) problems.push(`${route}: ${why}, but the route answered ${res.status}`);
90
+ else sizes[route] = gzipSync(body).byteLength;
82
91
  } catch (e) { problems.push(`${route}: could not be fetched from ${base} (${(e as Error).message})`); }
83
92
  }
84
93
  return { sizes, problems };
@@ -87,15 +96,23 @@ async function htmlSizes(routes: string[]): Promise<{ sizes: Record<string, numb
87
96
  async function main(): Promise<number> {
88
97
  const routes = measured(path.join(ROOT, await distDirOf(ROOT)));
89
98
  if (args.includes('--write-baseline')) {
90
- const written = { method: METHOD, routes: Object.fromEntries(routes.map((r) => [r.route, r.bytes])) };
99
+ const kept = fs.existsSync(BASELINE) ? baselineOf()?.html ?? null : null;
100
+ let html: Record<string, number> | null = kept;
101
+ if (base) {
102
+ const got = await htmlSizes(htmlRoutes(routes.map((r) => r.route)), 'a page');
103
+ // A page that redirects or is refused here (a sign-in, a 404 by design) has no HTML of its own to hold.
104
+ for (const p of got.problems) console.log(`warn ${p}; left out of the HTML baseline`);
105
+ html = got.sizes;
106
+ }
107
+ const written = { method: METHOD, routes: Object.fromEntries(routes.map((r) => [r.route, r.bytes])), ...(html ? { html: { method: HTML_METHOD, routes: html } } : {}) };
91
108
  fs.writeFileSync(BASELINE, JSON.stringify(written, null, 2) + '\n');
92
- console.log(`ok wrote ${path.relative(ROOT, BASELINE)}: ${routes.length} routes`);
109
+ console.log(`ok wrote ${path.relative(ROOT, BASELINE)}: first-load of ${routes.length} routes, ${base ? `the HTML of ${Object.keys(html ?? {}).length} pages` : html ? 'the HTML baseline kept as it was (no --base to measure)' : 'no HTML (pass --base <url> of the running build to record it)'}`);
93
110
  return 0;
94
111
  }
95
112
 
96
113
  const budgets = budgetsOf(verifyConfig as BudgetsConfig);
97
114
  const baseline = baselineOf();
98
- const size = firstLoadProblems(routes, { capKiB: budgets.firstLoadKb, growthPct: budgets.firstLoadGrowthPct, baseline });
115
+ const size = firstLoadProblems(routes, { capKiB: budgets.firstLoadKb, growthPct: budgets.firstLoadGrowthPct, baseline: baseline?.firstLoad ?? null });
99
116
  const largest = routes.reduce((a, b) => (b.bytes > a.bytes ? b : a), routes[0]);
100
117
  const lines: string[] = [];
101
118
  const firstStatus: Status = size.problems.length ? 'FAIL' : size.growth === 'not-configured' ? 'warn' : 'ok';
@@ -104,23 +121,30 @@ async function main(): Promise<number> {
104
121
  for (const p of size.problems) lines.push(`FAIL ${p}`);
105
122
 
106
123
  const capped = Object.keys(budgets.htmlKb);
107
- let html: { status: Status; sizes: Record<string, number>; reason?: string; problems: string[] };
108
- if (!capped.length) html = { status: 'not run', sizes: {}, reason: 'no htmlKb caps are configured', problems: [] };
109
- else if (!base) html = { status: 'not run', sizes: {}, reason: 'no app is running to measure', problems: [] };
124
+ // A page the build no longer has (a created project leaves the Atlas out) is not measured: there is nothing to hold.
125
+ const built = new Set(routes.map((r) => r.route));
126
+ const held = Object.keys(baseline?.html ?? {}).filter((r) => built.has(r));
127
+ const pages = [...new Set([...held, ...capped])].sort();
128
+ let html: { status: Status; sizes: Record<string, number>; growth?: 'checked' | 'not-configured'; reason?: string; problems: string[] };
129
+ if (!base) html = { status: 'not run', sizes: {}, reason: 'no app is running to measure', problems: [] };
110
130
  else {
111
- const got = await htmlSizes(capped);
112
- const problems = [...got.problems, ...htmlProblems(got.sizes, budgets.htmlKb)];
113
- html = { status: problems.length ? 'FAIL' : 'ok', sizes: got.sizes, problems };
131
+ const got = await htmlSizes(pages, 'its HTML is held');
132
+ const grown = htmlGrowthProblems(got.sizes, { growthPct: budgets.htmlGrowthPct, baseline: baseline?.html ?? null });
133
+ const problems = [...got.problems, ...grown.problems, ...htmlProblems(got.sizes, budgets.htmlKb)];
134
+ html = { status: problems.length ? 'FAIL' : grown.growth === 'not-configured' ? 'warn' : 'ok', sizes: got.sizes, growth: grown.growth, problems };
114
135
  }
136
+ const largestPage = Object.entries(html.sizes).sort((a, b) => b[1] - a[1])[0];
115
137
  lines.push(html.status === 'not run'
116
138
  ? `not run html ${html.reason}`
117
- : `${html.status === 'FAIL' ? 'FAIL ' : 'ok '} html ${capped.map((r) => `${r} ${html.sizes[r] === undefined ? 'unmeasured' : `${kib(html.sizes[r])} KiB`} of ${budgets.htmlKb[r]}`).join(' · ')}`);
139
+ : `${html.status === 'FAIL' ? 'FAIL ' : html.status === 'warn' ? 'warn ' : 'ok '} html ${Object.keys(html.sizes).length} pages gzipped${largestPage ? `, largest ${largestPage[0]} ${(largestPage[1] / KIB).toFixed(1)} KiB` : ''}`
140
+ + ` · growth ${html.growth === 'checked' ? `within ${budgets.htmlGrowthPct}% of the baseline` : 'not-configured (no html in scripts/verify.baseline.json: node scripts/sizes.ts --write-baseline --base <url>)'}`
141
+ + (capped.length ? ` · caps ${capped.map((r) => `${r} ${html.sizes[r] === undefined ? 'unmeasured' : `${(html.sizes[r] / KIB).toFixed(1)} KiB`} of ${budgets.htmlKb[r]}`).join(', ')}` : ''));
118
142
  for (const p of html.problems) lines.push(`FAIL ${p}`);
119
143
  lines.forEach((l) => console.log(l));
120
144
 
121
145
  const failed = firstStatus === 'FAIL' || html.status === 'FAIL';
122
146
  summary({
123
- status: failed ? 'FAIL' : firstStatus === 'warn' ? 'warn' : 'ok',
147
+ status: failed ? 'FAIL' : firstStatus === 'warn' || html.status === 'warn' ? 'warn' : 'ok',
124
148
  firstLoad: { status: firstStatus, routes: routes.length, largest: { route: largest.route, kib: kib(largest.bytes) }, growth: size.growth, problems: size.problems },
125
149
  html,
126
150
  });
@@ -30,5 +30,31 @@
30
30
  "/system/system/[id]": 683852,
31
31
  "/tests": 804568,
32
32
  "/usage": 809012
33
+ },
34
+ "html": {
35
+ "method": "the complete streamed response, gzipped (zlib level 6), bytes",
36
+ "routes": {
37
+ "/": 33515,
38
+ "/business": 41229,
39
+ "/customers": 71693,
40
+ "/embed/home": 18484,
41
+ "/embed/now": 16620,
42
+ "/embed/proposal": 6052,
43
+ "/embed/requests": 8142,
44
+ "/engineering": 35698,
45
+ "/keys": 16088,
46
+ "/members": 22179,
47
+ "/operations-tickets": 21416,
48
+ "/releases": 19263,
49
+ "/requests": 22288,
50
+ "/services": 19814,
51
+ "/settings": 15697,
52
+ "/sign-in": 7864,
53
+ "/system": 32274,
54
+ "/system/states/error": 9816,
55
+ "/system/states/loading": 11576,
56
+ "/tests": 19773,
57
+ "/usage": 44731
58
+ }
33
59
  }
34
60
  }
@@ -99,12 +99,15 @@ type VerifyConfig = {
99
99
  /**
100
100
  * Limits verify enforces. Leave a key out for Meridian's default: first-load JS 820 KiB per route and 5% growth over
101
101
  * `scripts/verify.baseline.json`, the warm, cold and after-live navigation times, and no prefetch on a closed phone drawer.
102
- * `htmlKb` caps a route's uncompressed HTML, through the end of the streamed response; it has no default.
102
+ * Every page's HTML, gzipped through the end of the streamed response, is held to `htmlGrowthPct` (5%) over its own
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.
103
105
  */
104
106
  budgets?: {
105
107
  navigation?: { warm?: ReadinessBudget; cold?: ReadinessBudget; afterLive?: ReadinessBudget };
106
108
  firstLoadKb?: number;
107
109
  firstLoadGrowthPct?: number;
110
+ htmlGrowthPct?: number;
108
111
  htmlKb?: Record<string, number>;
109
112
  prefetchKb?: { desktop: number; phoneClosed: number };
110
113
  };
@@ -152,17 +155,6 @@ const config: VerifyConfig = {
152
155
  smokeRoutes: ['/', '/requests', '/settings'],
153
156
  // The sample's own journeys across its three workspaces, run beside the audit, the presses and the keyboard walk.
154
157
  browserChecks: ['journeys/workspaces.ts'],
155
- // Sizes to hold. /services is the longest streamed document of the analytical pages: six services each with their own
156
- // ninety days of bars and the screen-reader summary behind each, an incident timeline and the resolved incidents. It
157
- // measured 99-100 KiB when this cap was set, so the cap sat exactly on the measurement and the next legitimate
158
- // byte failed: the access work that gave every page a gate added 0.6% (101 KiB, 103,025 bytes), which is markup
159
- // the page needs rather than fat to trim. Then the page came to lead with a Featured metric of uptime, whose chart is
160
- // the share of services operational on each of the ninety days: 106 KiB. Held at 110 — above the measurement, so the
161
- // next few KiB still fail.
162
- // /customers hands its client every customer, because the list filters, sorts and pages them in the browser as the
163
- // request log does: 329 rows of the nine fields a row draws measured 318 KiB (325,841 bytes) when the sample became a
164
- // company. Held at 330, so a field added to a row, or a second copy of the rows, fails here.
165
- budgets: { htmlKb: { '/services': 110, '/requests': 150, '/customers': 330 } },
166
158
  navigationChecks: [
167
159
  { path: '/', title: 'Home', readySelector: 'main table tbody tr', probe: 'toggle', controlSelector: 'main [role="radio"][aria-checked="false"]', resultSelector: 'main [role="radio"]' },
168
160
  { path: '/members', title: 'Members', readySelector: 'main table tbody tr', probe: 'toggle', controlSelector: 'main button[aria-haspopup="menu"]', resultSelector: '[role="menu"]' },
@@ -299,6 +299,8 @@ covered = { data: coverage.routes.filter((r) => r.data === 'configured').length,
299
299
  const unsafe = unsafeBackend();
300
300
  const chromeMissing = fs.existsSync(chromePath) ? null : `Chrome not found at ${chromePath} (set CHROME)`;
301
301
  const baselinePath = 'scripts/verify.baseline.json';
302
+ /** Whether the baseline records each page's HTML; one that cannot be read is sizes.ts's to report, after the build. */
303
+ const baselineHasHtml = (file: string) => { try { return typeof JSON.parse(fs.readFileSync(file, 'utf8')).html === 'object'; } catch { return true; } };
302
304
 
303
305
  if (wide || perf) {
304
306
  // A deep mode needs all of it, and says every piece that is missing at once, before it spends a build.
@@ -307,7 +309,11 @@ if (wide || perf) {
307
309
  ...(railReason ? [railReason] : []),
308
310
  ...(unsafe ? [unsafe.fix] : []),
309
311
  ...(chromeMissing ? [chromeMissing] : []),
310
- ...(!full || fs.existsSync(path.join(ROOT, baselinePath)) ? [] : [`${baselinePath} is missing: record it with node scripts/sizes.ts --write-baseline from a build whose sizes you reviewed`]),
312
+ ...(!full ? [] : !fs.existsSync(path.join(ROOT, baselinePath))
313
+ ? [`${baselinePath} is missing: record it with node scripts/sizes.ts --write-baseline --base <url> from a running build whose sizes you reviewed`]
314
+ : !baselineHasHtml(path.join(ROOT, baselinePath))
315
+ ? [`${baselinePath} holds no HTML: record it with node scripts/sizes.ts --write-baseline --base <url> from a running build whose sizes you reviewed`]
316
+ : []),
311
317
  ];
312
318
  if (missing.length) {
313
319
  for (const m of missing) log(`FAIL ${m}`);
@@ -465,7 +471,8 @@ const sizes = await run('scripts/sizes.ts', baseUrl ? ['--base', baseUrl] : [],
465
471
  if (m) phase(m[1] as Status, m[2], sizes.took, m[3]); else beneath(l.replace(/^FAIL\s+/, ''));
466
472
  }
467
473
  const html = summaryOf<{ html: { status: Status } }>(sizes.out, 'sizes')?.html.status;
468
- htmlMissing = Object.keys(config.budgets?.htmlKb ?? {}).length > 0 && html !== 'ok' && html !== 'FAIL';
474
+ // Every page's HTML is held to its baseline, so HTML that was not measured is a check that did not run.
475
+ htmlMissing = html === 'not run';
469
476
  }
470
477
 
471
478
  // 5. The navigation smoke of the selected routes; every rail route in --full and --timed.
@@ -140,8 +140,8 @@ in `optional:src/views/settings.tsx`.
140
140
 
141
141
  Three things an adopter meets here. The layout's promise is a request-time part of every page, so each page's HTML
142
142
  carries that part's flight data beside the prerendered one: one adopter measured a page going from 65 to 90 KiB of
143
- HTML (11.2 to 12.0 KiB compressed), so a team with an `htmlKb` budget in `scripts/verify.config.ts` should expect the
144
- number to move and record it again. The assistant walk-through in `pnpm verify --full` drives the sample's own Home,
143
+ HTML (11.2 to 12.0 KiB compressed), so a team should expect each page's HTML
144
+ figure to move and record its baseline again (`node scripts/sizes.ts --write-baseline --base <url>`). The assistant walk-through in `pnpm verify --full` drives the sample's own Home,
145
145
  Now, Members (its lookups through `explore_members`, each request handing it), API keys (a key's revocation, found
146
146
  through `explore_keys`, expiring on moving on) and Settings, asks the sample's Usage and Tests through their prepared
147
147
  queries, and reports those steps as not applicable in a product that replaced them; what the product's own
@@ -5,7 +5,7 @@ team's. This list is the rule the classifier itself uses; edit it in `cli/src/ow
5
5
 
6
6
  <!-- BEGIN:ownership -->
7
7
  - Managed: the payload files under `tokens/`, `src/styles/` and `src/components/`, except each component's `README.md` and `preview.tsx`.
8
- - Managed: the distributed scripts under `scripts/`, except `scripts/verify.config.ts`, the first-load baseline `optional:scripts/verify.baseline.json` and a team-local `optional:scripts/check.local.ts`.
8
+ - Managed: the distributed scripts under `scripts/`, except `scripts/verify.config.ts`, the size baseline `optional:scripts/verify.baseline.json` (first-load JS and the HTML of each page) and a team-local `optional:scripts/check.local.ts`.
9
9
  - Managed: the library helpers `src/lib/cn.ts`, `src/lib/format.ts`, `src/lib/format-date.ts`, `src/lib/period.ts`, `src/lib/color.ts`, `src/lib/host.ts`, `src/lib/preferences.ts`, `src/lib/csv.ts`, `src/lib/safe-markdown.ts`, `src/lib/logo.ts`, `src/lib/collection.ts`, `src/lib/explore.ts`, `src/lib/live.ts`, `src/lib/shared-context.ts`, `src/lib/agent-guidance.ts`, `src/lib/insight.ts` and `src/lib/facts.ts`, and the assistant prompt `src/lib/assistant/prompt.ts`.
10
10
  - Managed: `src/views/console-chrome.tsx` and `tests/setup.ts`.
11
11
  - Managed: `optional:scripts/package.json`, only when the adopt or create that set the project up generated it for this project shape.
@@ -90,10 +90,11 @@ Each floor is measured by a command, and its evidence is the line the command pr
90
90
  you change it (its comments describe every field, and the template's own entries show the shape): a `navigationChecks`
91
91
  entry for every rail route (a ready selector and one harmless probe that works at 390px too, where a table is a card
92
92
  list with no column headers to sort: a filter, a link or a dialog), `detailRoutes` with ids from your data for each
93
- state worth seeing, `smokeRoutes` among the rail routes, `budgets.htmlKb` naming only routes that exist, a `fakeApi` as
93
+ state worth seeing, `smokeRoutes` among the rail routes, `budgets.htmlKb`, when the project pins a page's HTML, naming only routes that exist, a `fakeApi` as
94
94
  soon as a page calls an API, and in an adopted project a `fakeApi` or `noLiveApi` (a created project whose pages read
95
95
  only its own sample needs neither). After the pages change, look at the sizes `pnpm verify` prints and record them with
96
- `node scripts/sizes.ts --write-baseline` once you have judged them. `--full` and `--timed` refuse, listing every gap,
96
+ `node scripts/sizes.ts --write-baseline --base <url>` against the running build once you have judged them: first-load JS
97
+ for every route and the gzipped HTML of every page, each then held to 5% growth over its own figure. `--full` and `--timed` refuse, listing every gap,
97
98
  until these hold.
98
99
 
99
100
  ### The craft: judged on the renders, with evidence
@@ -169,7 +169,7 @@ update (`2` pending, `0` finalized, `1` failure). `--abort`: `0` aborted, `1` re
169
169
 
170
170
  ## Resolving 0.9.0's migrations
171
171
 
172
- Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0 a third for an assistant kept from before, to 0.13.0 a fourth for a navigation item without a `needs`, to 0.15.0 a fifth for a page under the project's own route group, and to 0.16.0 five more, for a configuration that does not declare `refusal`, an export gone from a managed module, a view tool without its page or its prepared query, a view tool's page whose specification does not say what the agent does there, and the sample's pages under their old names; to 0.16.2 one more, for a test of the rail standing over a `ResizeObserver` that answers `observe` with nothing, to 0.16.3 three more, for the assistant route's local `onPage`, a file importing the journey from `scripts/navigate.ts` and a route or a read that cuts today at the UTC date, and to 0.17.0 one more, for a file calling `finalOutcome` with a boolean. Each is resolved the same way as those below:
172
+ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0 a third for an assistant kept from before, to 0.13.0 a fourth for a navigation item without a `needs`, to 0.15.0 a fifth for a page under the project's own route group, and to 0.16.0 five more, for a configuration that does not declare `refusal`, an export gone from a managed module, a view tool without its page or its prepared query, a view tool's page whose specification does not say what the agent does there, and the sample's pages under their old names; to 0.16.2 one more, for a test of the rail standing over a `ResizeObserver` that answers `observe` with nothing, to 0.16.3 three more, for the assistant route's local `onPage`, a file importing the journey from `scripts/navigate.ts` and a route or a read that cuts today at the UTC date, to 0.17.0 one more, for a file calling `finalOutcome` with a boolean, and to 0.19.0 one more, for a test that expects a Column chart's period with no data to draw a track. Each is resolved the same way as those below:
173
173
 
174
174
  | Id | The old shape it found | The change |
175
175
  |----|------------------------|------------|
@@ -188,6 +188,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
188
188
  | `journey-moved` (0.16.3) | a file of the team's importing anything but `workspaceHomes` from `scripts/navigate.ts`, such as a copy of `optional:tests/navigate-start.test.ts` | the journey moved to `scripts/lib/journey.ts`: bring `optional:tests/navigate-start.test.ts` and `optional:tests/navigate-menu-start.test.ts` over from the release as it has them, and import the journey from `scripts/lib/journey.ts` in a file of your own; checked by the gate |
189
189
  | `today-in-timezone` (0.16.3) | `optional:app/api/assistant/route.ts` or `optional:src/data/metrics.ts` cutting today with `clock().toISOString().slice(0, 10)` | `dayOf(clock())` from `src/lib/format-date.ts`, so today is the day in `app.timezone`; a file of your own comparing an instant's day with a day row asks `dayOf` too; checked by the gate |
190
190
  | `verify-tiers` (0.17.0) | a file of the team's importing `finalOutcome` from `scripts/lib/coverage.ts` and calling it with a boolean, such as a copy of `optional:tests/verify-suite-outcome.test.ts` from before the tiers | `finalOutcome` takes `{ full, timed }` since verify has a timed tier: bring `optional:tests/verify-suite-outcome.test.ts` over from the release as it has it, and call `finalOutcome({ full, timed }, notRun)` in a file of your own, as `validation.md` describes; checked by the gate |
191
+ | `no-data-mark` (0.19.0) | a test of the team's importing the Column chart and expecting a `.bg-fill-track` in a period with no data, such as `optional:tests/column-chart-no-data.test.tsx` as the template wrote it | a `null` period draws a dashed hairline on the baseline since 0.19.0, not a track the height of the scale, which was the tallest mark on the chart: bring `optional:tests/column-chart-no-data.test.tsx` over from the release as it has it, and find `[data-no-data-mark]` where a test of your own found `.bg-fill-track` or `[data-track]`; checked by the gate |
191
192
 
192
193
  A created project that never changed the template's pages brings the release's version of each affected file over, with
193
194
  the files that arrived with them: the view contexts `optional:src/views/home-context.ts` and its siblings, the view
@@ -217,7 +218,7 @@ each one in the project, then record it as above, with the hashes of the files a
217
218
  | `live-provider` | collections without `optional:src/data/live-actions.ts`, or a console layout without the provider | add the live files: `live.md`, "The live starters" and "Refreshing" |
218
219
  | `authorized-endpoints` | the assistant route or an actions file that never calls `resolveAccess` | resolve the caller first, and bring `optional:src/lib/assistant/tools.ts` and `optional:src/lib/assistant/respond.ts` over, which take the guard: "The assistant route" |
219
220
 
220
- Every migration but `verify-modes` is checked by the gate and a build; `verify-modes` by the gate alone. After 0.5.0 the gate alone checks `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone` and `verify-tiers`.
221
+ Every migration but `verify-modes` is checked by the gate and a build; `verify-modes` by the gate alone. After 0.5.0 the gate alone checks `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers` and `no-data-mark`.
221
222
 
222
223
  How to make the change depends on whose file it is:
223
224
 
@@ -45,7 +45,7 @@ line naming the command that runs them. Steps in order:
45
45
  contrast holds for every pair in every theme and accent, and the chart palette passes the colour-vision checks;
46
46
  eslint-config-next; TypeScript; the tests.
47
47
  2. **A production build** (`next build`), against the fake API when `scripts/verify.config.ts` names one. Then the route
48
- policy and the size checks: first-load JS per route, and the HTML caps in `budgets.htmlKb`. Navigation readiness
48
+ policy and the size checks: first-load JS per route, and every page's gzipped HTML, each against its own figure in `optional:scripts/verify.baseline.json` (and any absolute cap in `budgets.htmlKb`). Navigation readiness
49
49
  (`scripts/navigate.ts`) follows each rail route's shell, data and interaction from a press — on a desktop and through
50
50
  the phone drawer — and judges them on function only. Data and interaction are measured side by side, except that a
51
51
  `link` probe is pressed once the data is measured or has failed, since its press leaves the route;