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 +1 -1
- package/payload/CHANGELOG.md +21 -0
- package/payload/package.json +1 -1
- package/payload/scripts/brand.ts +7 -5
- package/payload/scripts/lib/coverage.ts +3 -1
- package/payload/scripts/lib/journey.ts +65 -17
- package/payload/scripts/navigate.ts +12 -2
- package/payload/scripts/perf.ts +3 -3
- package/payload/scripts/verify.config.ts +4 -1
- package/payload/scripts/vitals.ts +3 -2
- package/payload/skills/zz-meridian/references/existing-project.md +14 -2
- package/payload/skills/zz-meridian/references/validation.md +18 -2
- package/payload/src/components/ui/empty-state/README.md +1 -0
- package/payload/src/components/ui/empty-state/index.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "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",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -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
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "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.",
|
package/payload/scripts/brand.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
147
|
-
if (!
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 &&
|
|
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 &&
|
|
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,
|
|
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
|
-
|
|
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() +
|
|
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 ${
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
package/payload/scripts/perf.ts
CHANGED
|
@@ -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
|
|
54
|
-
const PROBE_WAIT =
|
|
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
|
-
/**
|
|
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.
|
|
214
|
-
owner's role runs once,
|
|
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
|
-
|
|
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}
|