zz-meridian 0.30.0 → 0.31.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.30.0",
3
+ "version": "0.31.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,27 @@
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.31.0] · 2026-10-26
6
+
7
+ ### New
8
+
9
+ - **`EmptyState` names its kind on its root: `data-empty="first-run"`, `"filtered"`, `"error"` or `"no-access"`.** Navigation readiness reads it (below), and a product's own empty state carries the same attribute to be read the same way.
10
+
11
+ ### Fixed
12
+
13
+ - **A rail route that redirects to a page under it is followed there** ([#97](https://github.com/zhixuan312/zz-meridian/issues/97)). A rail item at `/things` whose page sends the person to `/things/<slug>` failed its journey with `still on /things/a … after pressing /things`, so the page the person actually opens was never measured. The press has now arrived once the address is the route or a page under it, the entry's `title`, data and control are that page's, and the line says `landed on /things/a`. A press that lands anywhere else says where: `pressing /things led to /elsewhere, which is neither /things nor a page under it`.
14
+ - An entry of `navigationChecks` for a path the rail does not draw now says why it is refused: a journey times a press on the rail's link, so it names the rail route a redirect starts from, and a page reached from a list belongs in `detailRoutes`. Checking such paths against the build's route list instead was declined: they would pass the check and then have no link to press.
15
+ - **A list whose data arrived empty is not a page that never got ready** (#97). A list page with a row-link probe and no rows failed `interactive never-ready: the control … was not pressable in 10000 ms`. When the page draws its first-run `EmptyState` where the rows would be and nothing in `main` is still loading, that is its data, ready (the line says `data is the first-run state`), and the control it does not have is reported `interactive not measured: the page shows its first-run state (…)`, a warning and never a pass, as soon as the empty state shows rather than after the wait. The error state stays a failure, and its line now says `the page shows its error state`.
16
+ - **A schema change behind a role that may not change the schema has a recipe** ([#98](https://github.com/zhixuan312/zz-meridian/issues/98)). `references/existing-project.md` told an adopter both to migrate on start and to ship the schema as a one-off file, which agrees only for a schema that never changes. It now names the two roads for every later migration: a `MIGRATION_DATABASE_URL` for the owner's role that the start-up migrator prefers, or a release step that migrates with the owner's role before the new version serves. The health route names a refused migration (`SQLSTATE 42501`) and the role that can run it.
17
+ - **`brand --hex` keeps the step that fixed the accent.** Since 0.30.0 a brand colour whose white label failed contrast on its fill at first and passed one step darker was refused with the first step's failure, which no longer held: `zz-meridian brand --hex '#0E8A5F'` exited 1 with `the accent still fails contrast after 8 steps`. What `brand` reports now is the last contrast run's, and the eighth step's tokens are measured too.
18
+ - **A live page is not read as not live because it was still hydrating.** `scripts/perf.ts` decided which rail routes register live data by sending one `online` event as each page loaded. A page that had not hydrated yet had no listener, the event was lost, and on a loaded runner a different route each week had no live route to start its after-live journey from (`no other rail route registers live data to start from`). The event is now sent again every 250 ms until a resync starts, and a route has 8 s to start one, not 4 s, so the probe costs up to 8 s for each rail route that is not live. The after-live journey uses the same rule.
19
+ - **A dropped frame names the control whose gap it explains.** `scripts/vitals.ts` printed the last control that dropped a frame beside the cause of the longest gap, which another control had dropped.
20
+ - **HTML that grew with the data has a reading** (#98). `references/validation.md` says how to tell a page's HTML growing with its database (`noLiveApi`) from growing with its code, and when re-recording the baseline is the answer.
21
+
22
+ ### Known issues
23
+
24
+ - **Home and Revenue can drop a frame when the period changes to 7D on a slow phone.** At 7D the year is 52 windows, and Home draws them in two column charts and a trend: about a thousand new elements committed in one frame. On a quiet laptop at the emulated phone that frame's longest gap is 50 ms, the floor exactly (style and layout 36 ms); on the 4-CPU runner of the weekly run it is 67 to 83 ms (style and layout 62 to 69 ms), so the timed suite's vitals fail there. Making it cheaper is a decision about what a year draws at phone width, or about how a chart commits its marks, and is open for the next release.
25
+
5
26
  ## [0.30.0] · 2026-10-25
6
27
 
7
28
  ### Breaking
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.30.0",
3
+ "version": "0.31.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.",
@@ -132,20 +132,22 @@ if (hue !== undefined) {
132
132
 
133
133
  // Hold contrast: lower a theme's fill lightness where white text on it fails, a step at a time. A hue that still fails
134
134
  // after the last step stops the command: a brand that ships a failing pair in tokens.css is not a brand that worked.
135
+ // What remains is the last run's: a step that fixed the pair clears it (#0E8A5F passed after one step and was refused
136
+ // with the first step's failure), and the eighth step's tokens are measured too.
135
137
  const base = { light: 0.52, dark: 0.56 };
136
138
  let remaining: string[] = [];
137
- for (let step = 0; step < 8; step++) {
139
+ for (let step = 0; step <= 8; step++) {
138
140
  execFileSync('node', ['scripts/tokens.ts'], { cwd: ROOT, stdio: 'ignore' });
139
141
  let out = '';
140
142
  let why = '';
141
- try { out = execFileSync('node', ['scripts/contrast.ts'], { cwd: ROOT, encoding: 'utf8' }); break; }
143
+ try { out = execFileSync('node', ['scripts/contrast.ts'], { cwd: ROOT, encoding: 'utf8' }); remaining = []; break; }
142
144
  catch (e) { out = String((e as { stdout?: unknown }).stdout ?? ''); why = String((e as { stderr?: unknown }).stderr ?? '').trim(); }
143
145
  // A run with no summary did not measure anything: its exit code is a crash, and reading its empty output as "no
144
146
  // failures" passed a brand nobody had checked.
145
147
  if (!/^contrast: .*failures$/m.test(out)) throw new Error(`scripts/contrast.ts did not run to its summary: ${why || out.trim() || 'no output'}`);
146
- const fails = out.split('\n').filter((l) => l.startsWith('FAIL') && l.includes(` ${id} `));
147
- if (!fails.length) break;
148
- remaining = fails;
148
+ remaining = out.split('\n').filter((l) => l.startsWith('FAIL') && l.includes(` ${id} `));
149
+ if (!remaining.length || step === 8) break;
150
+ const fails = remaining;
149
151
  const t = JSON.parse(read(tokenFile));
150
152
  for (const theme of ['light', 'dark'] as const) {
151
153
  if (!fails.some((l) => l.includes(`FAIL ${theme}`) && /on-accent on accent/.test(l))) continue;
@@ -56,7 +56,9 @@ export function selectSmokeRoutes({ smokeRoutes, nav, landing }: { smokeRoutes?:
56
56
 
57
57
  function mappingErrors(check: NavigationCheck, routes: string[]): string[] {
58
58
  const errors: string[] = [];
59
- if (!routes.includes(check.path)) errors.push(`${check.path}: navigation check names a path that is not a route`);
59
+ // A journey presses the rail's link to the route, so a path the rail does not draw has no press to time (#97). A rail route
60
+ // that redirects is followed under it; a page reached from a list is a detail route, pressed and walked by the others.
61
+ if (!routes.includes(check.path)) errors.push(`${check.path}: a navigation check names a rail route, and this is not one: a journey times a press on the rail's link to it. For a rail route that redirects to a page under it (/things to /things/a), name the rail route: the journey follows it there. A page reached from a list belongs in detailRoutes`);
60
62
  for (const key of ['title', 'readySelector', 'controlSelector', 'resultSelector'] as const) {
61
63
  if (typeof check[key] !== 'string' || !check[key].trim()) errors.push(`${check.path}: navigation check has an empty ${key}`);
62
64
  }
@@ -33,9 +33,17 @@ export class Fail extends Error {
33
33
  kind: Kind;
34
34
  constructor(kind: Kind, message: string) { super(message); this.kind = kind; }
35
35
  }
36
- export type Measured = { ms: number } | { fail: Fail };
37
- /** `prefetched`, on a journey through the workspace menu: how many prefetches of the destination the page made before the press. */
38
- export type Sample = { shell: Measured; data: Measured | null; interactive: Measured | null; drawerMs?: number; prefetchCapped: boolean; refused?: number; prefetched?: number };
36
+ /** A time, a failure, or `absent`: nothing on the page to measure, and why (a first-run page has no row to press). */
37
+ export type Measured = { ms: number } | { fail: Fail } | { absent: string };
38
+ /**
39
+ * `prefetched`, on a journey through the workspace menu: how many prefetches of the destination the page made before the
40
+ * press. `landed`, when the press arrived under the route rather than at it (a rail route that redirects): where it did.
41
+ * `empty`, when the data was the page's first-run state rather than `readySelector`.
42
+ */
43
+ export type Sample = { shell: Measured; data: Measured | null; interactive: Measured | null; drawerMs?: number; prefetchCapped: boolean; refused?: number; prefetched?: number; landed?: string; empty?: boolean };
44
+
45
+ /** What a first-run EmptyState draws (`data-empty`): the data arrived and holds nothing yet (#97). */
46
+ export const FIRST_RUN = '[data-empty="first-run"]';
39
47
 
40
48
  // ---- in the page ----
41
49
 
@@ -65,6 +73,9 @@ const INSTRUMENT = `${DEEP_SOURCE}\n${DEEP_MATCHES}\n(() => {
65
73
  const nav = window.__nav = { prefetches: [], spec: null, t0: undefined, res: {}, expect: null, snap: ${SNAP} };
66
74
  const KEY = '__nav_journey';
67
75
  try { const kept = JSON.parse(sessionStorage.getItem(KEY) ?? 'null'); if (kept) { nav.spec = kept.spec; nav.t0 = kept.t0; } } catch { /* a fresh journey */ }
76
+ // The press arrived: at the route, or under it for a route that redirects to a child (/things to /things/a, #97), once
77
+ // the address is no longer the start's.
78
+ nav.arrived = () => { const s = nav.spec, p = location.pathname; return !!s && (p === s.route || (p !== s.from && p.startsWith(s.route + '/'))); };
68
79
  nav.arm = (spec) => { nav.before = document.querySelector('h1')?.textContent.trim(); nav.spec = spec; nav.t0 = undefined; nav.res = {}; nav.expect = null; try { sessionStorage.setItem(KEY, JSON.stringify({ spec, t0: null })); } catch { /* kept in memory */ } };
69
80
  document.addEventListener('pointerdown', (e) => {
70
81
  if (!nav.spec || nav.t0 != null) return;
@@ -76,7 +87,7 @@ const INSTRUMENT = `${DEEP_SOURCE}\n${DEEP_MATCHES}\n(() => {
76
87
  if (s && nav.t0 != null) {
77
88
  const at = Math.max(0, Math.round(performance.timeOrigin + ts - nav.t0));
78
89
  const r = nav.res;
79
- if (r.shell === undefined && location.pathname === s.route) {
90
+ if (r.shell === undefined && nav.arrived()) {
80
91
  const h = document.querySelector('h1');
81
92
  const text = h ? h.textContent.trim() : '';
82
93
  // Without a title, a heading that differs from the one on screen at the press is the target's.
@@ -87,7 +98,11 @@ const INSTRUMENT = `${DEEP_SOURCE}\n${DEEP_MATCHES}\n(() => {
87
98
  // Only what is on screen and real counts as the data. The router keeps routes it has shown mounted and hidden
88
99
  // (Cache Components: display none), so the previous page's rows are still in the document; and a loading.tsx
89
100
  // draws rows shaped like the real ones, which would hide the time React holds a shown fallback (300 ms or more).
90
- if (s.ready && r.data === undefined && location.pathname === s.route && deepMatches(s.ready).some((el) => el.getClientRects().length > 0 && el.textContent.toLowerCase().includes(s.text) && !el.querySelector('.shimmer') && !el.closest('.shimmer, [aria-busy="true"]'))) r.data = at;
101
+ if (s.ready && r.data === undefined && nav.arrived() && deepMatches(s.ready).some((el) => el.getClientRects().length > 0 && el.textContent.toLowerCase().includes(s.text) && !el.querySelector('.shimmer') && !el.closest('.shimmer, [aria-busy="true"]'))) r.data = at;
102
+ // A page whose data arrived empty shows its first-run state instead of the rows: that is its data, ready. Only once
103
+ // nothing in main is still loading, so a side card's empty state cannot stand in for a table on its way.
104
+ else if (s.ready && r.data === undefined && nav.arrived() && deepMatches('${FIRST_RUN}').some((el) => el.getClientRects().length > 0)
105
+ && !Array.from(document.querySelectorAll('main .shimmer, main [aria-busy="true"]')).some((el) => el.getClientRects().length > 0)) { r.data = at; r.empty = true; }
91
106
  // A link probe leaves the route: its result is the address changing.
92
107
  if (nav.expect && r.interactive === undefined && nav.snap(nav.expect.selector) !== nav.expect.before) r.interactive = at;
93
108
  }
@@ -267,13 +282,20 @@ async function awaitPrefetch(page: Page, route: string): Promise<boolean> {
267
282
  const REFRESH_WAIT = 15_000;
268
283
  /** How long the router's read may take to start once the action has answered. */
269
284
  const REFRESH_START_WAIT = 3000;
285
+ /** How often the `online` event is sent again while no resync has started: the provider may not be listening yet. */
286
+ const ONLINE_EVERY = 250;
270
287
 
271
288
  /**
272
289
  * Resyncs the tab's live provider without writing anything: the provider listens for `online`, reads the collections again
273
290
  * through `refreshCollections` and then refreshes the router. Resolves only when an action started after the event answered
274
291
  * `{ ok: true }` and the router read that followed it has its whole body, and throws never-ready otherwise.
292
+ *
293
+ * The provider listens only once the page has hydrated and registered its collections, and an event sent before that is
294
+ * lost: on a loaded runner one sent at once made a live page read as not live, a different route each week. So the event is
295
+ * sent again every `ONLINE_EVERY` ms until a resync starts, for at most `startWait`; a page that starts none in that time
296
+ * is not live. A resync that started then has `REFRESH_WAIT` to answer.
275
297
  */
276
- export async function liveRefresh(page: Page, wait = REFRESH_WAIT): Promise<void> {
298
+ export async function liveRefresh(page: Page, startWait = REFRESH_WAIT): Promise<void> {
277
299
  const state = `(() => { const n = window.__nav; return { started: n.act.started, results: n.act.results, arrivedAt: n.act.arrivedAt, inFlight: n.act.inFlight, reads: n.rsc.reads }; })()`;
278
300
  type State = { started: number; results: (boolean | null)[]; arrivedAt: number[]; inFlight: number; reads: { start: number; done: boolean }[] };
279
301
  // The provider's own opening resync may still be running; the refresh that counts starts after it.
@@ -281,15 +303,24 @@ export async function liveRefresh(page: Page, wait = REFRESH_WAIT): Promise<void
281
303
  if (quiet === null) throw new Fail('never-ready', `a live refresh was still running ${REFRESH_WAIT} ms after the page loaded`);
282
304
  await page.eval(`window.__nav.track = true`);
283
305
  const from = (await page.eval<State>(state)).started;
284
- await page.eval(`window.dispatchEvent(new Event('online'))`);
306
+ let sent = 0;
307
+ const begun = await poll(async () => {
308
+ if ((await page.eval<State>(state)).started > from) return true;
309
+ if (Date.now() - sent >= ONLINE_EVERY) { sent = Date.now(); await page.eval(`window.dispatchEvent(new Event('online'))`); }
310
+ return false;
311
+ }, Date.now() + startWait);
312
+ if (!begun) {
313
+ await page.eval(`window.__nav.track = false`);
314
+ throw new Fail('never-ready', `no refresh action started within ${startWait} ms of sending the page online events (is the starting page live?)`);
315
+ }
285
316
  const verified = await poll(async () => {
286
317
  const v = await page.eval<State>(state);
287
318
  const at = v.results.findIndex((r, i) => i >= from && r === true);
288
319
  return at >= 0 && v.inFlight === 0 ? { arrivedAt: v.arrivedAt[at] } : false;
289
- }, Date.now() + wait);
320
+ }, Date.now() + REFRESH_WAIT);
290
321
  if (!verified) {
291
322
  await page.eval(`window.__nav.track = false`);
292
- throw new Fail('never-ready', `no refresh action answered { ok: true } within ${wait} ms of the page's online event (is the starting page live?)`);
323
+ throw new Fail('never-ready', `no refresh action answered { ok: true } within ${REFRESH_WAIT} ms of starting (is the starting page live?)`);
293
324
  }
294
325
  // The router's read is the first one to begin after the answer; it is finished when its whole body is in.
295
326
  const refreshed = await poll(async () => {
@@ -408,17 +439,20 @@ export async function journey(page: Page, device: Device, start: string, route:
408
439
  const reached = await page.eval<string[]>(`performance.getEntriesByType('resource').filter((e) => new URL(e.name).pathname === ${JSON.stringify(route)}).map((e) => e.name)`);
409
440
  if (reached.length) throw new Fail('prefetched', `${reached.length} request(s) for ${route} had already been made before the press (${reached[0]})`);
410
441
  }
411
- const spec = { route, title: check?.title ?? '', ready: check?.readySelector ?? '', text: (check?.readyText ?? '').toLowerCase() };
442
+ const spec = { route, from: start, title: check?.title ?? '', ready: check?.readySelector ?? '', text: (check?.readyText ?? '').toLowerCase() };
412
443
  await page.eval(`window.__nav.arm(${JSON.stringify(spec)})`);
413
444
  const t0 = await press(page, at);
414
445
 
415
446
  // The shell: the path and the target heading.
416
447
  const shellMs = await result(page, 'shell', t0 + cap);
417
448
  if (shellMs === null) {
418
- const now = await page.eval<{ p: string; h: string }>(`({ p: location.pathname, h: document.querySelector('h1')?.textContent.trim() ?? '' })`);
419
- const wrong = now.p === route;
449
+ const now = await page.eval<{ p: string; h: string; there: boolean }>(`({ p: location.pathname, h: document.querySelector('h1')?.textContent.trim() ?? '', there: window.__nav.arrived() })`);
450
+ // A press that left for an address neither the route nor under it says where it went: a redirect the journey cannot follow.
451
+ const why = now.there ? `${now.p} shows the heading "${now.h}", not "${check?.title}"`
452
+ : now.p === start ? `still on ${now.p} ${cap} ms after pressing ${route}`
453
+ : `pressing ${route} led to ${now.p}, which is neither ${route} nor a page under it`;
420
454
  return {
421
- shell: { fail: new Fail(wrong ? 'wrong-result' : 'never-ready', wrong ? `${route} shows the heading "${now.h}", not "${check?.title}"` : `still on ${now.p} ${cap} ms after pressing ${route}`) },
455
+ shell: { fail: new Fail(now.there ? 'wrong-result' : 'never-ready', why) },
422
456
  data: check ? { fail: new Fail('never-ready', 'the shell was never reached') } : null,
423
457
  interactive: check ? { fail: new Fail('never-ready', 'the shell was never reached') } : null,
424
458
  drawerMs,
@@ -428,7 +462,9 @@ export async function journey(page: Page, device: Device, start: string, route:
428
462
  };
429
463
  }
430
464
  const shell = { ms: shellMs };
431
- if (!check) return { shell, data: null, interactive: null, drawerMs, prefetchCapped, refused, prefetched };
465
+ const where = await page.eval<string>('location.pathname');
466
+ const landed = where === route ? undefined : where;
467
+ if (!check) return { shell, data: null, interactive: null, drawerMs, prefetchCapped, refused, prefetched, landed };
432
468
 
433
469
  // Data and interaction run side by side: a control that is there at once does not wait for a body that is not. A link probe
434
470
  // is the exception: its press leaves the route, and the data counts only on the route, so it is pressed once the data is
@@ -439,8 +475,9 @@ export async function journey(page: Page, device: Device, start: string, route:
439
475
  data = await measureData(page, route, check, t0, cap);
440
476
  interactive = await measureInteraction(page, route, check, t0, cap, RESULT_WAIT[device]);
441
477
  } else [data, interactive] = await Promise.all([measureData(page, route, check, t0, cap), measureInteraction(page, route, check, t0, cap, RESULT_WAIT[device])]);
478
+ const empty = await page.eval<boolean>('window.__nav.res.empty === true');
442
479
  if (check.probe === 'dialog') await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
443
- return { shell, data, interactive, drawerMs, prefetchCapped, refused, prefetched };
480
+ return { shell, data, interactive, drawerMs, prefetchCapped, refused, prefetched, landed, empty };
444
481
  }
445
482
 
446
483
  /** The page's own time for `name`, in ms since the press, once it has one; null when `until` (a clock time) passes first. */
@@ -451,7 +488,9 @@ async function measureData(page: Page, route: string, check: NavigationCheck, t0
451
488
  const ms = await result(page, 'data', t0 + cap);
452
489
  if (ms !== null) return { ms };
453
490
  const there = await page.eval<boolean>(`!!deepQuery(${JSON.stringify(check.readySelector)})`);
454
- return { fail: new Fail(there ? 'wrong-result' : 'never-ready', there ? `${check.readySelector} is there but never shows "${check.readyText}"` : `${check.readySelector} did not appear in ${cap} ms`) };
491
+ // The data failing to load is a failure whichever state the page draws for it; the error state says so on the line.
492
+ const failed = !there && (await page.eval<boolean>(`!!document.querySelector('[data-empty="error"]')`));
493
+ return { fail: new Fail(there ? 'wrong-result' : 'never-ready', there ? `${check.readySelector} is there but never shows "${check.readyText}"` : `${check.readySelector} did not appear in ${cap} ms${failed ? '; the page shows its error state' : ''}`) };
455
494
  }
456
495
 
457
496
  /** Selectors already reported, so a note appears once per run, not once per sample. */
@@ -465,7 +504,16 @@ async function noteIfSeveral(page: Page, selector: string): Promise<void> {
465
504
  }
466
505
 
467
506
  async function measureInteraction(page: Page, route: string, check: NavigationCheck, t0: number, cap: number, wait: number): Promise<Measured> {
468
- const where = await poll(async () => (await page.eval<boolean>(`location.pathname === ${JSON.stringify(route)}`)) && point(page, check.controlSelector), t0 + cap);
507
+ // The control, or a page whose data arrived empty: a first-run page has no row to press, which is not a page that never got
508
+ // ready (#97). It is reported as not measured, never as a pass, and only once the data is there.
509
+ const found = await poll(async () => {
510
+ if (!(await page.eval<boolean>('window.__nav.arrived()'))) return null;
511
+ const at = await point(page, check.controlSelector);
512
+ if (at) return at;
513
+ return (await page.eval<boolean>('window.__nav.res.empty === true')) ? 'empty' : null;
514
+ }, t0 + cap);
515
+ if (found === 'empty') return { absent: `not measured: the page shows its first-run state (${FIRST_RUN}), so it has no ${check.controlSelector} to press` };
516
+ const where = found;
469
517
  if (!where) return { fail: new Fail('never-ready', `the control ${check.controlSelector} was not pressable in ${cap} ms`) };
470
518
  await noteIfSeveral(page, check.controlSelector);
471
519
  const isInput = await page.eval<boolean>(`(() => { const el = ${pickable(check.controlSelector)}; return !!el && el.tagName === 'INPUT' && ['text', 'search'].includes(el.type); })()`);
@@ -82,6 +82,10 @@ type Line = {
82
82
  workspace?: string;
83
83
  /** Through the menu: how many prefetches of the destination the starting page had made before the press. */
84
84
  prefetched?: number;
85
+ /** Where the press arrived, when it was under the route rather than at it. */
86
+ landed?: string;
87
+ /** The data was the page's first-run state, not `readySelector`. */
88
+ empty?: boolean;
85
89
  };
86
90
 
87
91
  const value = (m: Measured | null): number | null => (m && 'ms' in m ? m.ms : null);
@@ -96,13 +100,16 @@ async function measure(page: Page, device: Device, start: From, route: string, c
96
100
  if ('fail' in m) {
97
101
  line[name] = { ms: null, limit: limits[name], verdict: 'fail', reason: `${m.fail.kind}: ${m.fail.message}` };
98
102
  line.problems.push(`${name} ${m.fail.kind}: ${m.fail.message}`);
99
- } else line[name] = { ms: m.ms, limit: limits[name], verdict: 'pass' };
103
+ } else if ('absent' in m) line[name] = { ms: null, verdict: 'not-configured', reason: m.absent };
104
+ else line[name] = { ms: m.ms, limit: limits[name], verdict: 'pass' };
100
105
  };
101
106
  const run = { origin: base, condition: 'warm' as const, menu: start.menu };
102
107
  const first = await journey(page, device, start.from, route, check, run);
103
108
  line.drawerMs = first.drawerMs;
104
109
  line.prefetchCapped = first.prefetchCapped;
105
110
  line.prefetched = first.prefetched;
111
+ line.landed = first.landed;
112
+ line.empty = first.empty;
106
113
  for (const name of METRICS) fill(name, first[name]);
107
114
  // A wrong result, a never-ready page or a dead control is the answer: no retake can replace it.
108
115
  if (line.problems.length || timing === 'off') return finish(line);
@@ -138,6 +145,9 @@ const cell = (r: Reported): string => (r.verdict === 'not-configured' ? 'not-con
138
145
  const text = (l: Line): string =>
139
146
  `${l.status.padEnd(7)} ${l.route} ${l.device} shell ${cell(l.shell)} · data ${cell(l.data)} · interactive ${cell(l.interactive)}`
140
147
  + (l.noisy ? ' · noisy' : '')
148
+ + (l.landed ? ` · landed on ${l.landed}` : '')
149
+ + (l.empty ? ' · data is the first-run state' : '')
150
+ + METRICS.flatMap((name) => (l[name].verdict === 'not-configured' && l[name].reason ? [` · ${name} ${l[name].reason}`] : [])).join('')
141
151
  + (l.prefetchCapped ? ` · prefetch not idle after ${PREFETCH_WAIT / 1000} s` : '')
142
152
  + (l.drawerMs !== undefined ? ` · drawer ${l.drawerMs} ms` : '')
143
153
  + (l.via === 'menu' ? ` · through the workspace menu (${l.workspace})` : '')
@@ -309,7 +319,7 @@ async function main(): Promise<number> {
309
319
  status,
310
320
  routes,
311
321
  warnings: coverage.warnings,
312
- lines: lines.map(({ route, device, status: s, shell, data, interactive, drawerMs, prefetchCapped, noisy, problems: p, via, workspace, prefetched }) => ({ route, device, status: s, via, workspace, prefetched, shell, data, interactive, drawerMs, prefetchCapped, noisy, problems: p })),
322
+ lines: lines.map(({ route, device, status: s, shell, data, interactive, drawerMs, prefetchCapped, noisy, problems: p, via, workspace, prefetched, landed, empty }) => ({ route, device, status: s, via, workspace, prefetched, landed, empty, shell, data, interactive, drawerMs, prefetchCapped, noisy, problems: p })),
313
323
  prefetch: prefetch && { ...prefetch, problems },
314
324
  });
315
325
  return failed ? 1 : 0;
@@ -50,8 +50,8 @@ const args = process.argv.slice(2);
50
50
  const opt = (k: string) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : undefined; };
51
51
  const base = (opt('--base') ?? process.env.BASE ?? 'http://localhost:3000').replace(/\/$/, '');
52
52
  const PROTOCOL_SAMPLES = 20;
53
- /** How long a rail route has to answer a live resync before it is called not live. */
54
- const PROBE_WAIT = 4000;
53
+ /** How long a rail route has to start a live resync, while it is sent online events, before it is called not live. */
54
+ const PROBE_WAIT = 8000;
55
55
  const CONDITIONS: Condition[] = ['warm', 'cold', 'afterLive'];
56
56
  const started = Date.now();
57
57
 
@@ -219,7 +219,7 @@ async function main(): Promise<number> {
219
219
  for (const name of METRICS) {
220
220
  const m = sample[name];
221
221
  if (m && 'fail' in m) problems.push(`sample ${i + 1} ${name} ${m.fail.kind}: ${m.fail.message}`);
222
- else if (m) series[name].push(m.ms);
222
+ else if (m && 'ms' in m) series[name].push(m.ms);
223
223
  }
224
224
  } catch (e) {
225
225
  // The tab may be in any state after an error that is not a verdict: start the shared one again.
@@ -7,7 +7,10 @@ type DeviceBudget = { desktop: number; phone: number };
7
7
  type ReadinessBudget = { shellMs: DeviceBudget; dataMs: DeviceBudget; interactiveMs: DeviceBudget };
8
8
  /** What "this route is ready" means, and one harmless control to press on it. */
9
9
  type NavigationCheck = {
10
- /** The rail route this describes. */
10
+ /**
11
+ * The rail route this describes. A rail route that redirects to a page under it (`/things` to `/things/<slug>`) is
12
+ * followed there, and `title`, `readySelector` and the control are the page it lands on's.
13
+ */
11
14
  path: string;
12
15
  /** Text the page's heading shows as soon as the shell has arrived. */
13
16
  title: string;
@@ -114,10 +114,11 @@ async function measure(route: string): Promise<Measured> {
114
114
  const here = gaps.filter((f) => f > 50).length;
115
115
  frames += gaps.length;
116
116
  dropped += here;
117
+ // The control a line names is the one whose gap it explains: naming the last tap that dropped beside the worst gap's
118
+ // cause sent a reader to the drawer for a frame the period select had dropped.
117
119
  if (here) {
118
- droppedBy = name;
119
120
  const gap = Math.max(...gaps);
120
- if (gap > worst) { worst = gap; cause = frameCause(Math.round(gap), loafs.map(loafOf)); }
121
+ if (gap > worst) { worst = gap; droppedBy = name; cause = frameCause(Math.round(gap), loafs.map(loafOf)); }
121
122
  }
122
123
  // Close whatever the tap opened; its own events and its own frames are not counted.
123
124
  await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
@@ -210,8 +210,20 @@ Meridian's sample serves fixtures; a product's collections read a database. What
210
210
  migrator's bookkeeping in a schema the role may write (`public`); never stop the server on a failure, and report the
211
211
  outcome at a health route so a deploy can read it. A build has no database to migrate and must not need one.
212
212
  - **A role that may read and write but not create.** Deployed roles often lack `CREATE`, and a migrator that starts with
213
- `CREATE SCHEMA` is refused even where tables may be created. Ship the schema as a one-off SQL file a person with the
214
- owner's role runs once, and let the application role only migrate within what it may do.
213
+ `CREATE SCHEMA` is refused even where tables may be created. The first schema can ship as a one-off SQL file a person
214
+ with the owner's role runs once, but that settles only a schema that never changes: every later migration that adds a
215
+ table or a column is refused to the application role on each start, rolls back, and the server keeps serving the old
216
+ schema (issue #98). A release with a schema change takes one of two roads, and the project says which in its README:
217
+ - **A migration role the start-up migrator prefers.** Give the deployment a second connection string,
218
+ `MIGRATION_DATABASE_URL`, for a role that owns the schema, on the direct endpoint (not the transaction pooler). The
219
+ migrator in `instrumentation.ts` connects with it when it is set and with the application's otherwise; the pages
220
+ never read it. This suits a host that may hold the owner's credential.
221
+ - **A release step before the new version serves.** Where the running app must not hold the owner's credential, the
222
+ release runs the product's own migrate command with the owner's role, then switches traffic to the
223
+ new version. The start-up migrator then finds nothing pending.
224
+ Either way, read the health route after the deploy, and have it name the cause: a migration refused for permission
225
+ (Postgres `SQLSTATE 42501`, `insufficient_privilege`) reports which migration and says "the application role may not
226
+ change the schema: run the pending migrations with the owner's role", not a bare failure.
215
227
 
216
228
  ## Route A2: a static HTML page (data fetched as JSON)
217
229
 
@@ -39,7 +39,15 @@ line naming the command that runs them. Steps in order:
39
39
  a page is followed. A workspace's home that no other route's rail links to (the only destination of its workspace) is
40
40
  reached through the workspace menu: from the nearest rail route before it, by the menu's item for its workspace, from
41
41
  that press, and its line says `through the workspace menu (<workspace>)`. Any other destination no rail links to is
42
- reported as a problem, never as a pass.
42
+ reported as a problem, never as a pass. A `navigationChecks` entry names a rail route, since the journey presses the
43
+ rail's link to it. A rail route that redirects to a page under it (`/things` to `/things/<slug>` for the person
44
+ signed in) is followed there: the press has arrived once the address is the route or under it, the entry's `title`
45
+ is the page it lands on, and the line says `landed on <path>`. A page reached from a list is not a rail route: name
46
+ it in `detailRoutes`, where the audit, the presses, the keyboard walk and the vitals open it. A page whose data arrived empty
47
+ draws its first-run `EmptyState` (`data-empty="first-run"`) where the rows would be: once nothing in `main` is still
48
+ loading, that is its data, ready, the line says `data is the first-run state`, and a
49
+ control the empty page does not have is reported `interactive not measured: the page shows its first-run state`, a
50
+ warning and never a pass. The error state (`data-empty="error"`) stays a failure.
43
51
  1. **The gate** (`node scripts/gate.ts`): tokens regenerate to the same CSS; the card registry is fresh; every card and
44
52
  page specification follows the anatomy; the product's own rules in `optional:scripts/check.local.ts`, when the file exists;
45
53
  contrast holds for every pair in every theme and accent, and the chart palette passes the colour-vision checks;
@@ -156,7 +164,7 @@ and supply-chain check before a script runs; that is pnpm, not an install, and n
156
164
 
157
165
  ## Lines that are not about the page
158
166
 
159
- Two lines say something about what the suites could see, not about a defect in a page's own styles.
167
+ Three lines are not about a defect in a page's own styles: two say what the suites could see, and one what the data did.
160
168
 
161
169
  - **`unmeasured: <element> (no open shadow root|not defined)`** is the audit saying it did not measure that element, and
162
170
  it fails like any other finding so that a custom element is never passed unseen. The fix is in the element, as in the
@@ -168,6 +176,14 @@ Two lines say something about what the suites could see, not about a defect in a
168
176
  control you meant, write a more specific selector. A descendant combinator does not cross a shadow boundary, so name an
169
177
  element inside a root with a selector written for that root.
170
178
 
179
+ - **`<route>: HTML grew <n>% over its baseline (cap 5%)`** with no change to the code means the page renders data that
180
+ changed. Under a `fakeApi` the data is fixed and the figure measures code; a project whose pages read its own
181
+ database (`noLiveApi`) measures whatever rows the database held at the build. Tell the two apart by building the
182
+ commit the baseline was recorded on against the same data: when it grows by the same amount, the growth is the
183
+ data's. Then re-record with `node scripts/sizes.ts --write-baseline --base <url>` and say in the commit that the data
184
+ moved; the lasting fix is a fixed data set for verify (a seeded database), so the figure measures code again. Growth
185
+ the old commit does not show is the code's, and is fixed in the page.
186
+
171
187
  ## Asking the agent's questions: `scripts/ask.ts`
172
188
 
173
189
  verify proves the assistant works with a stand-in model; it cannot say whether the real model answers a page's own
@@ -54,6 +54,7 @@ When an agent's query finds nothing, the view shows the filtered state with the
54
54
 
55
55
  - The error kind is `role="alert"`.
56
56
  - No access is not an alert: nothing failed. Its root carries `data-no-access`, and the title and sentence carry the meaning.
57
+ - Every kind's root carries `data-empty` with its kind. `pnpm verify`'s navigation journey reads `data-empty="first-run"` as data that arrived empty, and `data-empty="error"` as data that failed, so an empty list is not reported as a page that never got ready. A product's own empty state carries the same attribute.
57
58
  - The disc is decorative; the title carries the meaning.
58
59
  - Title `ink` on `surface` 18.5:1; sentence `ink-2` 7.1:1 (light, cobalt).
59
60
 
@@ -60,7 +60,7 @@ export function EmptyState({
60
60
  );
61
61
  if (layout === 'inline')
62
62
  return (
63
- <div data-no-access={kind === 'no-access' ? '' : undefined} role={kind === 'error' ? 'alert' : undefined} className={cn('flex items-center gap-3.5 py-2', className)}>
63
+ <div data-empty={kind} data-no-access={kind === 'no-access' ? '' : undefined} role={kind === 'error' ? 'alert' : undefined} className={cn('flex items-center gap-3.5 py-2', className)}>
64
64
  {disc}
65
65
  <div className="min-w-0 flex-1">
66
66
  <p className="text-sm font-medium text-ink">{title}</p>
@@ -70,7 +70,7 @@ export function EmptyState({
70
70
  </div>
71
71
  );
72
72
  return (
73
- <div data-no-access={kind === 'no-access' ? '' : undefined} role={kind === 'error' ? 'alert' : undefined} className={cn('flex flex-col items-center px-6 py-14 text-center', className)}>
73
+ <div data-empty={kind} data-no-access={kind === 'no-access' ? '' : undefined} role={kind === 'error' ? 'alert' : undefined} className={cn('flex flex-col items-center px-6 py-14 text-center', className)}>
74
74
  {art && !icon ? <span aria-hidden className="grid place-items-center">{art}</span> : disc}
75
75
  <p className={cn('t-card max-w-sm text-balance', art && !icon ? 'mt-5' : 'mt-8')}>{title}</p>
76
76
  {children ? <p className="t-small mt-1.5 max-w-sm text-pretty text-ink-2">{children}</p> : null}