zz-meridian 0.16.1 → 0.16.2

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 (69) hide show
  1. package/dist/migrations.js +16 -0
  2. package/package.json +1 -1
  3. package/payload/CHANGELOG.md +21 -0
  4. package/payload/CONTRIBUTING.md +1 -1
  5. package/payload/app/(dashboard)/(home)/README.md +2 -2
  6. package/payload/app/(dashboard)/engineering/README.md +1 -1
  7. package/payload/app/embed/error.tsx +30 -0
  8. package/payload/app/embed/home/page.tsx +17 -3
  9. package/payload/app/embed/home/view.tsx +7 -3
  10. package/payload/app/embed/now/page.tsx +16 -3
  11. package/payload/app/embed/requests/page.tsx +16 -3
  12. package/payload/app/system/(atlas)/page.tsx +17 -6
  13. package/payload/app/system/preview/[section]/[card]/stage.tsx +13 -0
  14. package/payload/docs/distribution.md +2 -2
  15. package/payload/docs/product-workspaces.md +2 -2
  16. package/payload/package.json +1 -1
  17. package/payload/scripts/verify.ts +1 -1
  18. package/payload/scripts/vitals.ts +40 -14
  19. package/payload/skills/zz-meridian/references/update.md +2 -1
  20. package/payload/skills/zz-meridian/references/validation.md +9 -5
  21. package/payload/src/app.config.ts +4 -2
  22. package/payload/src/components/base/app-mark/index.tsx +11 -2
  23. package/payload/src/components/base/shell/index.tsx +11 -18
  24. package/payload/src/components/base/use-nav-drawer.ts +32 -0
  25. package/payload/src/components/charts/scale.ts +19 -3
  26. package/payload/src/components/charts/trend-chart/README.md +22 -7
  27. package/payload/src/components/charts/trend-chart/index.tsx +103 -23
  28. package/payload/src/components/charts/trend-chart/preview.tsx +31 -0
  29. package/payload/src/components/patterns/command-palette/index.tsx +5 -3
  30. package/payload/src/components/patterns/data-table/README.md +3 -2
  31. package/payload/src/components/patterns/data-table/index.tsx +24 -10
  32. package/payload/src/components/patterns/embed-frame/README.md +2 -0
  33. package/payload/src/components/patterns/embed-frame/index.tsx +71 -0
  34. package/payload/src/components/patterns/metric-tile/README.md +1 -0
  35. package/payload/src/components/patterns/metric-tile/index.tsx +4 -1
  36. package/payload/src/components/patterns/rail/README.md +1 -1
  37. package/payload/src/components/patterns/rail/index.tsx +6 -4
  38. package/payload/src/components/patterns/shell-tools/index.tsx +1 -1
  39. package/payload/src/components/ui/table/README.md +1 -1
  40. package/payload/src/components/ui/table/index.tsx +15 -3
  41. package/payload/src/components/ui/tabs/README.md +1 -1
  42. package/payload/src/components/ui/tabs/index.tsx +4 -3
  43. package/payload/src/lib/facts.ts +6 -2
  44. package/payload/src/styles/base.css +7 -0
  45. package/payload/src/styles/motion.css +2 -1
  46. package/payload/src/system/atlas-shell.tsx +7 -13
  47. package/payload/src/views/home-context.ts +23 -7
  48. package/payload/src/views/home-parts.ts +12 -4
  49. package/payload/src/views/home.tsx +14 -7
  50. package/payload/src/views/keys.tsx +3 -2
  51. package/payload/src/views/members.tsx +13 -2
  52. package/payload/src/views/now.tsx +16 -1
  53. package/payload/src/views/requests.tsx +2 -1
  54. package/payload/src/views/revenue-context.ts +2 -1
  55. package/payload/src/views/revenue.tsx +2 -2
  56. package/payload/src/views/services.tsx +11 -2
  57. package/payload/src/views/settings.tsx +20 -5
  58. package/payload/src/views/tickets.tsx +13 -1
  59. package/payload/src/views/usage-parts.ts +2 -1
  60. package/payload/src/views/usage.tsx +2 -2
  61. package/payload/tests/app-mark-sheen.test.tsx +28 -0
  62. package/payload/tests/chart-scale.test.ts +41 -0
  63. package/payload/tests/data-table.test.tsx +24 -4
  64. package/payload/tests/embed-page-failure.test.tsx +25 -0
  65. package/payload/tests/facts.test.ts +15 -0
  66. package/payload/tests/members-optimistic.test.tsx +8 -3
  67. package/payload/tests/rail-marker.test.tsx +11 -2
  68. package/payload/tests/shared-context.test.tsx +16 -10
  69. package/payload/tests/trend-chart-band.test.tsx +117 -0
@@ -422,7 +422,23 @@ export const RELEASE_MIGRATIONS = [
422
422
  instructions: 'The release renamed them with no redirect, and an update writes none of the team\'s pages, views or tests. Each file listed is one of them or names one: a page or a view, the view tools, `src/app.config.ts`\'s nav and the feature ids it names, the routes of `scripts/verify.config.ts`, or a shipped test that expects them. A project that kept the sample\'s pages as they were brings the release\'s over: `app/(dashboard)/` and `app/embed/`, `src/views/`, `src/data/`, `src/lib/assistant/` beside `prompt.ts`, `src/app.config.ts` with its own `app` kept, and each shipped test as the release has it; a file listed here that the release no longer has is deleted. A project that made the pages its own keeps its own names, makes the changes this release\'s other migrations list in them, and reads "not applicable" for the assistant walk-through\'s steps on Home and Now (`scripts/lib/sample.ts`).',
423
423
  checks: GATE_AND_BUILD,
424
424
  },
425
+ {
426
+ id: 'marker-measured-on-observe',
427
+ since: '0.16.2',
428
+ applies: railObserverNeverAnswers,
429
+ summary: 'A test mounts the rail over a `ResizeObserver` whose `observe` answers nothing: the rail takes its first measurement from the observer now, where it took one by hand.',
430
+ instructions: 'Bring `tests/rail-marker.test.tsx` over from the release as it has it. Since 0.16.2 `src/components/patterns/rail/index.tsx` reads no offset of its own — a rail mounts inside the tap that opens the phone drawer, and a read there lays the page out inside that tap — so the marker\'s first position is the one `observe` delivers, after the effect that called it. A test whose fake answers `observe` with an empty body expects the marker before any delivery and fails; the release\'s fake answers it with the callback, `observe() { for (const cb of observers) queueMicrotask(cb); }`, and reads the marker after that delivery. A test of your own that mounts the rail changes the same way.',
431
+ checks: ['gate'],
432
+ },
425
433
  ];
434
+ /**
435
+ * A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
436
+ * keeps `unobserve() {}` out of it: a fake that answers the callback and also stubs `unobserve` is the release's own
437
+ * shape, and the one thing to bring over.
438
+ */
439
+ function railObserverNeverAnswers(root) {
440
+ return matching(root, (rel, src) => /\.(?:test|spec)\.tsx?$/.test(rel) && /\bRail\b/.test(src) && /\bResizeObserver\b/.test(src) && /(?<![\w$.])observe\s*\(\s*\)\s*\{\s*\}/.test(src));
441
+ }
426
442
  /** The team's files that import an export 0.16.0 removed from a managed module, by name or through a namespace. */
427
443
  function removedExportReaders(root) {
428
444
  const removed = { 'src/lib/insight': ['deviations', 'largest', 'runs', 'weekendRatio'], 'src/lib/collection': ['queryInput'] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
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.16.2] · 2026-10-16
6
+
7
+ ### Breaking
8
+
9
+ - **A created project's shipped test of the rail fails until it is brought over.** `tests/rail-marker.test.tsx` — the template's test of the rail's marker — stands over a `ResizeObserver` whose `observe` answers with nothing, which only worked because the rail read its own offsets as well. That read is what 0.16.2 removed (see **Changed**), so the file now asserts a marker position that nothing has measured yet. The file is the project's own from the moment it is created, so the update does not write it: it reports it as the migration `marker-measured-on-observe`, with the fix — bring the release's `tests/rail-marker.test.tsx` over as the release has it. Its fake answers `observe` with the callback and reads the marker after that delivery; a test of your own that mounts the rail answers `observe` the same way.
10
+ - **The Web Vitals check fails on a dropped frame.** `scripts/vitals.ts` counted frames over 50 ms while its taps played out and printed the number on every line without ever failing on it, so a `verify --full` that passed was not being held to the floor its own header states. It fails now: `frames over 50 ms` must read `0 of N`. Two things about how it counts changed with it. Frames are read **in the tap's own window and no other** — the recording used to run from the moment the page opened, straight through the Escape that closes what a tap opened and the settle after it, which the INP measurement in the same loop already excludes because it is the check's own work and not the person's. And a page that drops one frame is **measured a second time in full** before it is failed, because a single frame in two hundred is inside the noise of the host a four-times throttle is measuring: a page that drops one twice is a page that stutters. A run that was passing still passes; a run that was failing now says so, and names the control the frame was dropped on.
11
+
12
+ ### New
13
+
14
+ - **`useNavDrawer(asked)`** (`src/components/base/use-nav-drawer.ts`) — whether a navigation drawer is open, for the frame that owns one. It carries the bookkeeping both shells had written for themselves: the drawer opens for every press of the top bar's button that it has not answered yet, remembers the page it opened on so a navigation closes it, and forgets that page when the address moves. The Atlas's copy was missing the last of those, so opening the contents on a phone, following a link and pressing Back re-opened the drawer with nobody pressing anything. A product that wrote the same bookkeeping for a drawer of its own should read it from here.
15
+
16
+ ### Changed
17
+
18
+ - **The rail reads its own geometry from the observer's first delivery, not by hand.** The rail measured itself with an `offsetTop` read as well as through its `ResizeObserver`, and a rail mounts inside the tap that opens the phone drawer — so that read made the browser lay the whole page out inside the tap: measured at a 50 ms frame on a throttled phone, against 33 ms for the mount alone. The observer's first delivery reports the same numbers after the browser has laid out for itself. Nothing moves while a measurement is missing: the current item draws the same pill itself.
19
+ - **The Atlas's front page names the latest release, not the work toward the next one.** It read the first section of `CHANGELOG.md` — which is `[Unreleased]` while a release is pending, and has no version — so the card showed pending work under the version it was going to be released as. It also read the version, the date and the summary with one pattern that refused the `#` of `### Breaking`, the first line under every heading in that file: the date fell to an empty fallback and the card printed `0.16.2 · ` with nothing after the separator, on every release. The heading and the paragraph are read separately now, and the "Full changelog" link no longer shares the kicker's line.
20
+ - **`AppMark` names its own gradient.** The mark is a client component now, because each draws a sheen whose id has to be its own: a page holds more than one mark (the Atlas draws one in the phone's bar and one in the shelf, both mounted at every width), and a fixed `id` was a duplicate on every one of those pages, with a `url(#…)` naming two elements. A product that overrode the mark's markup rather than its `app.logo` is unaffected; the first load of `/embed/now` in this template moved 809 → 811 KiB.
21
+
22
+ ### Fixed
23
+
24
+ - **A new dashboard's own `pnpm verify` failed on a test that named the template.** `tests/embed-page-failure.test.tsx` asserts the embed's way out, and wrote the copy out as `Open in ZZ Meridian` — the template's name, while the frame draws `Open in ${app.name}`. Every dashboard is created under its own name, so the test passed in this repository and failed in every project made from it, at the first `pnpm verify` a team runs. It reads the name from `src/app.config.ts` now.
25
+
5
26
  ## [0.16.1] · 2026-10-16
6
27
 
7
28
  ### Breaking
@@ -93,7 +93,7 @@ Write the status on the line under the title: `Status: beta`.
93
93
  | `pnpm typecheck`, `pnpm test` | TypeScript and the behaviour tests | Gate |
94
94
  | `node scripts/audit.ts` | With the app running: every page and embed view at 2560, 1440, 1024, 768 and 390px in both themes (embeds at 720 and 420 on a simulated host ground). It measures inside open shadow roots too. Fails on sideways scroll, text clipped without an ellipsis, text cut with an ellipsis that keeps the whole of it in no `title`, two pieces of text drawn in the same place or a stroked line drawn through one, a control with no accessible name, more than one page scroller, rendered text under its WCAG minimum (an SVG's text too, read by its `fill` against what the SVG paints behind it), a Tab stop with no focus ring (a change of outline, shadow or border when focus arrives), a custom element it cannot look inside (`unmeasured:`), and any uncaught exception or console error. Prints the design metrics: type sizes, weights, radii and the hierarchy ratio | Before release |
95
95
  | `node scripts/audit.ts --atlas` | With the app running: the same audit over the Design Atlas, at 1440 and 390px in both themes: every card's page and its bare preview, and every other entry the Atlas serves (the token views, the page specs, the guides). Its first line says what it reached, by kind, and `--list` prints the routes without opening a browser. Not part of `pnpm verify`, which stays fast: a card's preview is where a defect in its own box shows, so run it after changing a component | After changing a component |
96
- | `node scripts/vitals.ts [--atlas]` | With a production build running: LCP, INP and CLS on a mid-range phone for every static page, the Atlas's front page and one card page among them. `--atlas` measures every Atlas entry instead, about fifteen minutes, and is not part of `pnpm verify` | Inside `pnpm verify --full`; `--atlas` after changing what every card page shares |
96
+ | `node scripts/vitals.ts [--atlas]` | With a production build running: LCP, INP, CLS and dropped frames on a mid-range phone for every static page, the Atlas's front page and one card page among them. `--atlas` measures every Atlas entry instead, about fifteen minutes, and is not part of `pnpm verify` | Inside `pnpm verify --full`; `--atlas` after changing what every card page shares |
97
97
  | `node scripts/interactions.ts` | With the app running: presses every control on every page (mouse at 1440px, taps at 390px) and follows every link. Reaches controls inside open shadow roots. Fails on a control that changes nothing, one something covers, and a link that answers 4xx | Before release |
98
98
  | `node scripts/keyboard.ts` | With the app running: Tab through each page until focus comes back round, then Shift+Tab back. Fails on a stop with no focus ring, a control hidden under the sticky bar, a visible control never reached, and a reverse order that differs | Before release |
99
99
  | `pnpm verify` | The gate once, one production build, the route policy, the size checks and a smoke of up to three routes on desktop and the phone drawer; ends with a coverage line, and the report is in `out/verify.txt` | Before finishing any change |
@@ -35,14 +35,14 @@ The page is headed by what its revenue says for the period chosen, worked out by
35
35
  | The period is a record: its revenue is higher than that of every earlier whole period of its length, and at least six such periods are kept (`facts.recordNeedsPeriods` in `src/app.config.ts`) | "The highest 30 days yet: $3,300,438.74", then ", the 3rd record in a row" when the periods before it were each a record too. A record's title has no clause about the services |
36
36
  | Otherwise, with a period before it | "Revenue is up 25.8% on the 90 days before" ("down"; "Revenue is level with the 90 days before" when the change rounds to 0.0%), then the services' clause |
37
37
  | No period comes before it (All) | "$27,710,624.39 of revenue in 360 days", then the services' clause |
38
- | The services' clause | ", and every service is operational"; ", and Inference API is degraded" (or "is down") for the one service in the worst state any is in; ", and 2 services are degraded" for several. Left out when the services could not be read |
38
+ | The services' clause | As the last clause, ", and every service is operational". As its own sentence after the figures when something is wrong, ". Inference API is degraded" (or "is down") for the one service in the worst state any is in, ". 2 services are degraded" for several, and ". No service is monitored yet" for a read that returned none: the one thing a person may have to act on is not the fourth item of a list. Left out when the services could not be read |
39
39
  | The history could not be read, or no day is kept | The page is headed by its name |
40
40
 
41
41
  In the sample the four periods read "The highest 7 days yet: $810,137.13", "The highest 30 days yet: $3,300,438.74, the 3rd record in a row", "Revenue is up 25.8% on the 90 days before, and Inference API is degraded" and "$27,710,624.39 of revenue in 360 days, and Inference API is degraded". 90 days and All state no record: 360 days hold four whole periods of 90 and one of 360, fewer than a record needs.
42
42
 
43
43
  ### The line under the title
44
44
 
45
- One line, worked out by `homeLine` in `src/views/home-context.ts`: the customers that have not left, the period's requests and their change, gross margin for a person who may read what compute cost, and the services, each one that is not operational named with its state, those down first. Counts are written at a glance (`formatCompact`). In the sample at 30 days an Owner reads "320 customers, 1.1B requests in 30 days (up 4.7%), a gross margin of 62.4%, and Inference API is degraded." and a Member or a Viewer the same line without the margin clause. A clause whose figures could not be read is left out. What is wrong today is said here whatever the title says: a record's title has no clause about the services.
45
+ One line, worked out by `homeLine` in `src/views/home-context.ts`: the customers that have not left, the period's requests and their change, and gross margin for a person who may read what compute cost, with the services last. Counts are written at a glance (`formatCompact`). A service that is not operational, or a product monitoring none, is the line's own second sentence, after the figures: on a day whose title states a record it is the only place on the page that says what is wrong today. In the sample at 30 days an Owner reads "320 customers, 1.1B requests in 30 days (up 4.7%), and a gross margin of 62.4%. Inference API is degraded." and a Member or a Viewer the same line without the margin clause. A clause whose figures could not be read is left out.
46
46
 
47
47
  The revenue card and the tiles share the page's Meridian: its dates are the period's complete days, then the day in progress. Point at a day in the revenue chart and the featured figure reads it; at the day in progress its pill says "so far". A tile reads the pointed day only when its points are the Meridian's dates (the Metric tile's rule). While a day is in progress, the Requests, Compute cost and Gross margin tiles have a point for each complete day and none for the day in progress, one short of the Meridian's dates, so they keep the period's figure and a part-day is never read as a whole one. When the latest day kept is complete, no day is in progress, their points are the Meridian's dates, and pointing at a day reads it in those three tiles too.
48
48
 
@@ -11,7 +11,7 @@ Thirteen parts, in this order. Every chart and list is the card named, the page
11
11
  | Part | Split | Pattern | Holds |
12
12
  |---|---|---|---|
13
13
  | 1. Head | | Page frame | Kicker `ZZ Meridian · Production`; the title, a sentence about the services and the releases (below), with the page's name kept in the heading; the line under it (below), then who Engineering is for (`workspaces.engineering.audience`, read from the declaration); Freshness. No action |
14
- | 2. Days inside the band | full | Featured metric with a Trend chart, line, with `height="fill"` | The page's protagonist, marked `data-protagonist="Days inside the band"` on a wrapper whose display is `contents`: the days inside the band over every complete day kept, 357, its kicker naming of how many ("Days inside the band · of 360 complete days kept"); the steady fact as its finding, the line the context carries ("357 of 360 days inside the band of p95 under 350 ms and errors under 1%; outside it on 17 Aug 2026, 21 Sept 2026 and 22 Sept 2026"); and the p95 of each of the last 360 complete days as its chart. Its Ask asks what stands out in the days inside the band. With the days not read, its place says so; with none kept, it is not drawn |
14
+ | 2. Days inside the band | full | Featured metric with a Trend chart, line, with `height="fill"` | The page's protagonist, marked `data-protagonist="Days inside the band"` on a wrapper whose display is `contents`: the days inside the band over every complete day kept, 357, its kicker naming of how many ("Days inside the band · of 360 complete days kept"); the steady fact as its finding, the line the context carries ("357 of 360 days inside the band of p95 under 350 ms and errors under 1%; outside it on 17 Aug 2026, 21 Sept 2026 and 22 Sept 2026"); and the p95 of each of the last 360 complete days as its chart, with the band drawn as the region under its ceiling (`band`: `{ value: 350, label: 'Band: p95 under 350 ms' }` in `src/app.config.ts`) and a mark on each day outside it (`marks`, from the strip's own states, so the day that left the band on its error rate is marked where its p95 stands inside the region). Its Ask asks what stands out in the days inside the band. With the days not read, its place says so; with none kept, it is not drawn |
15
15
  | 3. Services, now | 2/3 | Card with a Status list, `run` | Every service in the team's order with its state, its last 90 days and how long it has run ("Operational for 69 days · its longest in 90"). Past eight services, the ones not operational, then the three with the longest current runs (below). Always a link to Services that names how many there are: "All 6 services" |
16
16
  | 4. Open incident | 1/3 | Incident card, under the part's own heading | The incident nobody has resolved, each update timed from its start ("at the start", "after 1.1h"), its owner and its ticket, which opens the Tickets page on it. With several, "Open incidents": one card each, the most severe first and then the newest. With none, one card says so with when the last one was resolved and how long it lasted ("the last was resolved on 17 Aug 2026 and lasted 2.4h") |
17
17
  | 5. N of M days inside the band | full | Card with Uptime bars, `lg`, and a list | Titled by its figure, "357 of 360 days inside the band". The last 360 complete days, ending on the latest complete day, a mark for each day outside the band (`facts.band` in `src/app.config.ts`); the share of the days shown inside it, read "99.17% inside the band"; then four facts: the days outside by date, the year of requests beside the range of the median day's p95, p95 on the busiest day yet, and the requests answered without an error |
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { app } from '@/app.config';
4
+
5
+ /**
6
+ * The last net in an embed: something threw that the page's own read did not catch (a view that fails to render). It
7
+ * imports nothing else, and that is the design, not an oversight.
8
+ *
9
+ * Measured: an error boundary here that drew the view's frame and an empty state cost 28 KiB on every embed route's
10
+ * first load — `/embed/now` went 14 KiB past the 820 KiB budget and all three embeds grew 5.8–7.7% — while the same
11
+ * boundary importing nothing costs none of it, because a segment's error boundary lands in the route's entry chunk and
12
+ * everything it imports lands with it. So the framed failure is the page's own (it catches a read that threw and draws
13
+ * the frame and a card), and this is the document underneath it: the product's name, one sentence, and Try again.
14
+ * `app.config` is the one import, and it is already in the entry chunk of every route that draws the product's mark.
15
+ */
16
+ export default function EmbedError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
17
+ return (
18
+ <main className="mx-auto flex min-h-dvh max-w-prose flex-col items-start justify-center gap-3 p-8">
19
+ <p className="t-kicker">{app.name}</p>
20
+ <p className="text-sm text-ink">This view did not load, and nothing was changed.</p>
21
+ <button
22
+ type="button"
23
+ onClick={reset}
24
+ className="press hit rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-ink hover:bg-fill-hover"
25
+ >
26
+ Try again
27
+ </button>
28
+ </main>
29
+ );
30
+ }
@@ -1,6 +1,7 @@
1
1
  import { Suspense } from 'react';
2
2
  import { homeTool } from '@/views/tools';
3
3
  import { parsePeriod } from '@/lib/period';
4
+ import { EmbedBusy, EmbedFailed } from '@/components/patterns/embed-frame';
4
5
  import { EmbedHome } from './view';
5
6
 
6
7
  export const metadata = { title: 'Home' };
@@ -11,17 +12,30 @@ type SearchParams = Promise<{ period?: string; day?: string }>;
11
12
  * Tool: `zz_meridian_home { period, day? }` (`homeTool` in `src/views/tools.ts`). The route renders from the
12
13
  * tool's own read, so what the model is told and what the view shows are one. Inline: the revenue card and its three tiles.
13
14
  * Fullscreen: the console's Home rows.
15
+ *
16
+ * The period stays in the address and is read by the view below, inside its boundary: a page that awaited it here would
17
+ * wait for the request, and this route no longer prerenders. The fallback therefore names the view and not its period,
18
+ * as Requests' names the view and not its filters, and both gain theirs with the read.
14
19
  */
15
20
  export default function Page({ searchParams }: { searchParams: SearchParams }) {
16
21
  return (
17
- <Suspense>
22
+ <Suspense fallback={<Busy />}>
18
23
  <Home searchParams={searchParams} />
19
24
  </Suspense>
20
25
  );
21
26
  }
22
27
 
28
+ /** The frame the host sees while the read runs: this view's title, and a body of the shape the read will fill. */
29
+ function Busy() {
30
+ return (
31
+ <EmbedBusy title="Home" consolePath="/" name="Home" blocks={['chart', 'tiles']} />
32
+ );
33
+ }
34
+
23
35
  async function Home({ searchParams }: { searchParams: SearchParams }) {
24
36
  const { period, day } = await searchParams;
25
- const { data } = await homeTool.read({ period: parsePeriod(period), day: /^\d{4}-\d{2}-\d{2}$/.test(day ?? '') ? day : undefined });
26
- return <EmbedHome {...data} />;
37
+ // A read that threw is this view's own failure, drawn in the frame. The segment's error boundary is the last net
38
+ // under it, and imports nothing, because anything a boundary imports lands in every embed's first load.
39
+ const read = await homeTool.read({ period: parsePeriod(period), day: /^\d{4}-\d{2}-\d{2}$/.test(day ?? '') ? day : undefined }).catch(() => null);
40
+ return read ? <EmbedHome {...read.data} /> : <EmbedFailed title="Home" consolePath="/" name="Home" />;
27
41
  }
@@ -9,7 +9,11 @@ import { HomeBody, RevenueCard, ShareHome, companyTiles, isEmpty, meridianDates
9
9
  import type { HomeData } from '@/views/home-context';
10
10
  import { PERIOD_LABEL } from '@/lib/period';
11
11
 
12
- /** Inline: the revenue card and the three tiles beside it. Fullscreen: the console's Home, its nine parts. Both share the same context. */
12
+ /**
13
+ * Inline: the revenue card and the three tiles beside it. Fullscreen: the console's Home, its nine parts. Both share the
14
+ * same context. Both state the period's record on the revenue card: the frame above them names the view and its period,
15
+ * where the console's page names the record itself, so this is the one surface left that can say it.
16
+ */
13
17
  export function EmbedHome(p: HomeData) {
14
18
  const s = useSurface();
15
19
  return (
@@ -18,7 +22,7 @@ export function EmbedHome(p: HomeData) {
18
22
  meta={<Freshness updatedAt={new Date(p.updatedAt)} now={new Date(p.now)} />}
19
23
  consolePath={`/?period=${p.period}`}
20
24
  >
21
- {s.mode === 'fullscreen' || isEmpty(p) ? <HomeBody {...p} /> : <Inline {...p} />}
25
+ {s.mode === 'fullscreen' || isEmpty(p) ? <HomeBody {...p} finding={p.record} /> : <Inline {...p} />}
22
26
  </EmbedFrame>
23
27
  );
24
28
  }
@@ -30,7 +34,7 @@ function Inline(p: HomeData) {
30
34
  <Meridian dates={dates} day={p.day}>
31
35
  <ShareHome data={p} />
32
36
  <Stack className="gap-3">
33
- <RevenueCard data={p} dates={dates} />
37
+ <RevenueCard data={p} dates={dates} finding={p.record} />
34
38
  <Row split="tiles" className="gap-3">
35
39
  {companyTiles(p)}
36
40
  </Row>
@@ -1,6 +1,7 @@
1
1
  import { Suspense } from 'react';
2
2
  import { packNow } from '@/views/now-context';
3
3
  import { nowTool } from '@/views/tools';
4
+ import { EmbedBusy, EmbedFailed } from '@/components/patterns/embed-frame';
4
5
  import { EmbedNow } from './view';
5
6
 
6
7
  export const metadata = { title: 'Now' };
@@ -8,16 +9,28 @@ export const metadata = { title: 'Now' };
8
9
  /**
9
10
  * Tool: `zz_meridian_now {}` (`nowTool` in `src/views/tools.ts`), rendered from the tool's own read, at request time
10
11
  * for whoever is asking. Inline: the status list and the open incident. Fullscreen: the Now page's body.
12
+ *
13
+ * `Now` takes no argument, so its frame is the same whatever the host asks for: the host is framed at once rather than
14
+ * shown an empty rectangle while the read runs.
11
15
  */
12
16
  export default function Page() {
13
17
  return (
14
- <Suspense>
18
+ <Suspense fallback={<Busy />}>
15
19
  <Now />
16
20
  </Suspense>
17
21
  );
18
22
  }
19
23
 
24
+ /** The frame the host sees while the read runs: this view's title, and a body of the shape the read will fill. */
25
+ function Busy() {
26
+ return (
27
+ <EmbedBusy title="Now" consolePath="/engineering" name="Now" blocks={['tiles', 'table']} />
28
+ );
29
+ }
30
+
20
31
  async function Now() {
21
- const { data } = await nowTool.read({});
22
- return <EmbedNow {...packNow(data)} />;
32
+ // A read that threw is this view's own failure, drawn in the frame. The segment's error boundary is the last net
33
+ // under it, and imports nothing, because anything a boundary imports lands in every embed's first load.
34
+ const read = await nowTool.read({}).catch(() => null);
35
+ return read ? <EmbedNow {...packNow(read.data)} /> : <EmbedFailed title="Now" consolePath="/engineering" name="Now" />;
23
36
  }
@@ -1,5 +1,6 @@
1
1
  import { Suspense } from 'react';
2
2
  import { requestsTool } from '@/views/tools';
3
+ import { EmbedBusy, EmbedFailed } from '@/components/patterns/embed-frame';
3
4
  import { EmbedRequests } from './view';
4
5
 
5
6
  export const metadata = { title: 'Requests' };
@@ -11,19 +12,31 @@ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
11
12
  * tool's view and the console's view are the same address. Inline: the five latest that match. Fullscreen: the table.
12
13
  * Both read what the console page reads: one page of the filtered set and the summary of all of it, so the shared
13
14
  * context is the console's.
15
+ *
16
+ * The frame is drawn at once with the view's own name; the scope its title gains ("Requests · Failing") is the read's
17
+ * to say, and the console link carries the filters once it has: the unfiltered list is where the reader lands meanwhile.
14
18
  */
15
19
  export default function Page({ searchParams }: { searchParams: SearchParams }) {
16
20
  return (
17
- <Suspense>
21
+ <Suspense fallback={<Busy />}>
18
22
  <Requests searchParams={searchParams} />
19
23
  </Suspense>
20
24
  );
21
25
  }
22
26
 
27
+ /** The frame the host sees while the read runs: this view's title, and a body of the shape the read will fill. */
28
+ function Busy() {
29
+ return (
30
+ <EmbedBusy title="Requests" consolePath="/requests" name="Requests" blocks={['table']} />
31
+ );
32
+ }
33
+
23
34
  async function Requests({ searchParams }: { searchParams: SearchParams }) {
24
35
  const p = await searchParams;
25
36
  const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
26
37
  // The tool's own read: the address's filters are its arguments, and the sort and page the table may set ride along.
27
- const { data } = await requestsTool.read({ status: one(p.status) as 'all' | undefined, method: one(p.method), region: one(p.region), q: one(p.q), ...(p.sort || p.dir || p.page ? { sort: one(p.sort), dir: one(p.dir), page: one(p.page) } : {}) } as Parameters<typeof requestsTool.read>[0]);
28
- return <EmbedRequests {...data} />;
38
+ // A read that threw is this view's own failure, drawn in the frame. The segment's error boundary is the last net
39
+ // under it, and imports nothing, because anything a boundary imports lands in every embed's first load.
40
+ const read = await requestsTool.read({ status: one(p.status) as 'all' | undefined, method: one(p.method), region: one(p.region), q: one(p.q), ...(p.sort || p.dir || p.page ? { sort: one(p.sort), dir: one(p.dir), page: one(p.page) } : {}) } as Parameters<typeof requestsTool.read>[0]).catch(() => null);
41
+ return read ? <EmbedRequests {...read.data} /> : <EmbedFailed title="Requests" consolePath="/requests" name="Requests" />;
29
42
  }
@@ -13,9 +13,17 @@ export default function AtlasHome() {
13
13
  const readme = readRepo('README.md');
14
14
  const principles = [...(readme.split('## Principles')[1]?.split('\n## ')[0] ?? '').matchAll(/\*\*(.+?)\*\*\s*(.+)/g)].map((m) => ({ title: m[1].replace(/\.$/, ''), text: m[2] }));
15
15
  const all = entries();
16
- const latest = readRepo('CHANGELOG.md').split(/\n(?=## \[)/)[1] ?? '';
17
- const head = latest.match(/^## \[([^\]]+)\] · (\S+)\n+([^\n#][^\n]*)/);
18
- const release = { version: head?.[1] ?? s.version, date: head?.[2] ?? '', summary: head?.[3] ?? '', headlines: [...latest.matchAll(/^- \*\*(.+?)\*\*/gm)].map((m) => m[1].replace(/[.:]$/, '').replace(/`/g, '')) };
16
+ // The front page states the latest *release*, not the work toward the next one. `[Unreleased]` is a section like any
17
+ // other and it sits first, and reading it here named pending work with the version it is going to be released
18
+ // under, without a date and without its summary — the entry is written for the day it becomes `## [x.y.z] · date`.
19
+ const latest = readRepo('CHANGELOG.md').split(/\n(?=## \[)/).slice(1).find((s) => /^## \[\d+\.\d+\.\d+\] · \S+/.test(s)) ?? '';
20
+ // The version and its date are on the heading, and the summary is the paragraph under it — a line every one of these
21
+ // releases does without, since they all open on a `###` heading, but the card has the place for one. Read together as
22
+ // they were, `[^\n#]` refused the `#` of `### Breaking`, so the newest release lost its date to the fallback and the
23
+ // card printed a separator with nothing after it.
24
+ const heading = latest.match(/^## \[([^\]]+)\] · (\S+)/);
25
+ const summary = latest.split(/\n\n/).slice(1).map((p) => p.trim()).find((p) => p && !p.startsWith('#') && !p.startsWith('-') && !p.startsWith('|')) ?? '';
26
+ const release = { version: heading?.[1] ?? s.version, date: heading?.[2] ?? '', summary, headlines: [...latest.matchAll(/^- \*\*(.+?)\*\*/gm)].map((m) => m[1].replace(/[.:]$/, '').replace(/`/g, '')) };
19
27
  const changelog = all.find((e) => e.id === 'changelog')?.href;
20
28
  const series = demoSeries('30d').current;
21
29
  const counts = [
@@ -126,10 +134,13 @@ export default function AtlasHome() {
126
134
  <SectionHead title="What changed." line="Every release, with what breaks and what to do instead." />
127
135
  <div className="grid gap-8 rounded-xl border border-line bg-surface p-7 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
128
136
  <div>
129
- <p className="t-kicker">{release.version} · {release.date}</p>
130
- <p className="t-small mt-4 max-w-[48ch] text-ink-2">{release.summary}</p>
137
+ {/* A release is named and dated, always; the paragraph under it is a release's own to have. */}
138
+ <p className="t-kicker">{release.date ? `${release.version} · ${release.date}` : release.version}</p>
139
+ {release.summary ? <p className="t-small mt-4 max-w-[48ch] text-ink-2">{release.summary}</p> : null}
140
+ {/* The link takes its own line: the kicker above it is `inline-flex`, so an inline link shares its line and
141
+ reads as one word, which is what it did once the empty summary paragraph between them went. */}
131
142
  {changelog ? (
132
- <Link href={changelog} className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-accent-ink hover:text-ink">
143
+ <Link href={changelog} className="mt-6 flex w-fit items-center gap-1.5 text-sm font-medium text-accent-ink hover:text-ink">
133
144
  Full changelog <ArrowRight className="size-3.5" />
134
145
  </Link>
135
146
  ) : null}
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import Link from 'next/link';
3
4
  import { useSearchParams } from 'next/navigation';
4
5
  import { lazy, Suspense } from 'react';
6
+ import { ArrowLeft } from 'lucide-react';
5
7
  import { PREVIEW_LOADERS } from '@/system/preview-loaders';
6
8
 
7
9
  /** One lazy component per card, made once: the route's first load carries none of them, and the page loads only its own. */
@@ -10,6 +12,10 @@ const PREVIEWS = Object.fromEntries(Object.entries(PREVIEW_LOADERS).map(([id, lo
10
12
  function Inner({ id }: { id: string }) {
11
13
  const q = useSearchParams();
12
14
  const P = PREVIEWS[id];
15
+ // The card's own name, from its folder: the stage knows the id and not the title, and importing the Atlas's content
16
+ // reading to draw one word would weigh more than the word.
17
+ const slug = id.split('/').at(-1)!.replace(/-/g, ' ');
18
+ const label = slug[0].toUpperCase() + slug.slice(1);
13
19
  return (
14
20
  <div
15
21
  data-theme={q.get('theme') ?? undefined}
@@ -17,6 +23,13 @@ function Inner({ id }: { id: string }) {
17
23
  data-density={q.get('density') ?? undefined}
18
24
  className="min-h-dvh bg-ground p-8 text-ink max-sm:p-4"
19
25
  >
26
+ {/* The card page opens this in a new tab, which has no history to go back through: without a link here the only
27
+ way off a preview is to close the tab. One quiet line above the specimen, naming the card it belongs to. */}
28
+ <nav className="mx-auto mb-6 flex max-w-5xl items-center">
29
+ <Link href={`/system/${id}`} className="press hit inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-ink-2 hover:bg-fill-hover hover:text-ink">
30
+ <ArrowLeft className="size-3.5" /> {label}
31
+ </Link>
32
+ </nav>
20
33
  <div data-specimen className="mx-auto max-w-5xl"><Suspense><P /></Suspense></div>
21
34
  </div>
22
35
  );
@@ -147,10 +147,10 @@ Two environment variables exist for the updater's own tests and are used only wh
147
147
  reports as `migration:<id>` because it cannot make them. An entry applies when the project's recorded version is older
148
148
  than its `since` and the target is not, and its `applies(root)` returns the paths that still have the old shape, or
149
149
  null. It only reads, and only team-owned files (the ownership rule's managed paths are skipped); a file it cannot read is
150
- 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` and `agent-reads-own-group`, `gate` alone), and its
150
+ 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` and `marker-measured-on-observe`, `gate` alone), and its
151
151
  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.
152
152
 
153
- Six 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) and `refusal-declared` (0.16.0). The ten of 0.5.0 are:
153
+ Eleven 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), and `marker-measured-on-observe` (0.16.2). The ten of 0.5.0 are:
154
154
 
155
155
  | Id | Reported when a team-owned file still has | Completion step |
156
156
  |----|-------------------------------------------|-----------------|
@@ -4,7 +4,7 @@ ZZ Meridian's own dashboard is one product with fifteen destinations, grouped in
4
4
 
5
5
  ## The three workspaces
6
6
 
7
- A workspace is where a person looks. It decides which destinations the rail and the command palette (⌘K) list, and nothing else: what a person may open and change is decided by their role, page by page. Choose a workspace in the menu at the top of the rail, under Show; the menu opens that workspace's home.
7
+ A workspace is where a person looks. It decides which destinations the rail and the command palette (⌘K) list, and nothing else: what a person may open and change is decided by their role, page by page. Choose a workspace in the menu at the top of the rail, under Show. That menu is the one place a workspace and a grant meet, and only in which rows it offers: a workspace is listed when the person may open at least one of its destinations, and a row opens the workspace's home, or the first destination of it the person may open when the home is not one of them. Neither changes what any page allows.
8
8
 
9
9
  - **[Main](/)**: For everyone who needs the whole product at a glance: how it is doing, who is in it and how it is set up. Its home is Home, the company's page for the period you choose: revenue and its record, customers, tokens served and requests, the days inside the band and compute in use (with compute cost and gross margin for an Owner and an Admin), then the year counted a period at a time, what is sold, Business and Engineering at a glance with a way into each, and what is worth knowing across the company, each line opening the page where it is handled. Main also holds Members, Settings, and the Atlas (Design system and Docs).
10
10
  - **[Business](/business)**: For the people who answer for customers and usage: traffic, who relies on the product and what they ask of it. Its home is Revenue, which leads with what customers paid in the period you choose, in its Featured metric with its record and revenue a day, then shows it against a year ago, the best day yet, who is past due, who pays by plan, every customer's week, the customers from a trial up to Enterprise, what is worth a look, what they buy and where they are, each customer it names opening its page. It holds Customers, where a row opens that customer's own page; Usage, which leads with the requests of the period you choose in its Featured metric, against the period before with requests a day, then a year of requests a day a cell, the facts of the period, and over the last 30 days when requests come, where from, what is asked for and how it was answered; and Requests, where a single request opens inside it.
@@ -97,7 +97,7 @@ The word means three different things in this product. They share a name and not
97
97
 
98
98
  A product declares its workspaces once, in `src/app.config.ts`:
99
99
 
100
- 1. Export `workspaces`: an object with one key for each workspace, holding `label` (its name), `audience` (one sentence saying who it is for, in the voice of the rest of the product) and `homeHref` (the entry its menu row opens).
100
+ 1. Export `workspaces`: an object with one key for each workspace, holding `label` (its name), `audience` (one sentence saying who it is for, in the voice of the rest of the product) and `homeHref` (the entry its menu row opens; the first destination of the workspace the person may open is what the row opens instead, when the home is not one of them).
101
101
  2. Give every entry of `nav` a `workspace` naming one of those keys, and a `needs` that names a feature's need in `src/data/features.ts`.
102
102
  3. Keep `src/views/workspaces.ts`, which derives each workspace's navigation (`navOf`) and answers which workspace an address belongs to (`workspaceOf`), and `src/views/workspace-chrome.tsx`, the product's adapter over the console chrome. The layout uses `WorkspaceRail` and `WorkspacePalette` in place of the plain rail and palette.
103
103
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
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.",
@@ -601,7 +601,7 @@ if (full && browser.ran && baseUrl) {
601
601
  current = 'web vitals';
602
602
  announce('Web Vitals on a mid-range phone, alone');
603
603
  const vitals = await run('scripts/vitals.ts', ['--base', here]);
604
- reportSuite('vitals', 'LCP, INP and CLS on a mid-range phone', vitals);
604
+ reportSuite('vitals', 'LCP, INP, CLS and dropped frames on a mid-range phone', vitals);
605
605
  if (suiteOutcome(vitals.status) === 'FAIL') ok = false;
606
606
  }
607
607
 
@@ -9,8 +9,12 @@
9
9
  * slowest of real taps on controls that change the screen (a period, the alerts, the workspace menu, the drawer), from
10
10
  * Event Timing, counting only the tap's own pointer, touch and click events: the Escape that closes what a tap opened
11
11
  * is not a person's interaction, and a headless browser can hold a no-op key event open until something else paints.
12
- * A dropped frame is a gap over 50 ms between animation frames while those taps play out. Each line says how many controls
13
- * it tapped, found through open shadow roots (scripts/lib/deep.ts), so a page whose INP rests on no tap says so.
12
+ * A dropped frame is a gap over 50 ms between animation frames, and it is read in the same window as the tap that
13
+ * caused it and no other, for the same reason INP is: counting frames from the moment the page opened charged a page
14
+ * for the Escape and the settle between taps, which are the check's own work and not the person's. Each line says how
15
+ * many controls it tapped, found through open shadow roots (scripts/lib/deep.ts), so a page whose INP rests on no tap
16
+ * says so, and a page that dropped a frame names the control it dropped it on. A page that drops one is measured a
17
+ * second time before it is failed, and a page that drops one twice is a page that stutters.
14
18
  *
15
19
  * Every static page and the detail pages in scripts/verify.config.ts are measured, the Atlas's among them: its front
16
20
  * page and one card page, the sample every other suite takes. `--atlas` measures every Atlas entry instead (the guides,
@@ -19,7 +23,9 @@
19
23
  * floor, and nothing had been looking. `--list` prints the routes a run would visit and exits without a browser.
20
24
  *
21
25
  * Run it against a production build (`next start`), never `next dev`. Fails (exit 1) when a page's LCP is 2.5 s or more,
22
- * INP 200 ms or more, or CLS 0.1 or more.
26
+ * INP 200 ms or more, CLS 0.1 or more, or drops a frame. The frames are a floor and not a figure to read: a person
27
+ * tapping the phone drawer is the app's most repeated gesture, and one 50 ms frame in it is the whole cost of the
28
+ * drawer arriving late. It was reported and not failed until the rail stopped measuring itself inside that tap.
23
29
  */
24
30
  import { atlasCoverage, atlasEntries } from './lib/atlas.ts';
25
31
  import { launch } from './lib/chrome.ts';
@@ -74,38 +80,58 @@ const page = await launch();
74
80
  await page.send('Page.addScriptToEvaluateOnNewDocument', { source: OBSERVE });
75
81
  await page.send('Page.addScriptToEvaluateOnNewDocument', { source: DEEP_SOURCE });
76
82
  await page.send('Network.enable');
77
- let failures = 0;
78
- for (const route of ROUTES) {
83
+
84
+ /** One page measured once: what LCP, CLS and INP were, how many controls answered, and the frames that dropped on them. */
85
+ type Measured = { lcp: number; cls: number; moved: string; inp: number; slowest: string; tapped: number; frames: number; dropped: number; droppedBy: string };
86
+
87
+ async function measure(route: string): Promise<Measured> {
79
88
  await page.send('Network.emulateNetworkConditions', { offline: false, latency: 150, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8 });
80
89
  await page.send('Network.setCacheDisabled', { cacheDisabled: true });
81
90
  await page.send('Emulation.setCPUThrottlingRate', { rate: 4 });
82
91
  await page.open(base + route, { width: 390, height: 844, theme: 'dark', reduced: false, wait: 7000 });
83
92
  await page.eval('window.__v.recording = true');
84
- let inp = 0, slowest = '', tapped = 0;
93
+ let inp = 0, slowest = '', tapped = 0, frames = 0, dropped = 0, droppedBy = '';
85
94
  for (const sel of TAPS) {
86
95
  const at = await page.eval<{ x: number; y: number } | null>(`(() => { const el = deepQuery(${JSON.stringify(sel)}); if (!el) return null; const r = el.getBoundingClientRect(); if (!r.width || !r.height) return null; return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
87
96
  if (!at) continue;
88
97
  tapped++;
89
- await page.eval('(window.__v.taps = [], window.__v.since = performance.now(), true)');
98
+ await page.eval('(window.__v.taps = [], window.__v.frames = [], window.__v.since = performance.now(), true)');
90
99
  await page.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [at] });
91
100
  await page.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
92
101
  await sleep(1200);
93
102
  const ms = await page.eval<number>('Math.max(0, ...window.__v.taps)');
94
- if (ms > inp) { inp = ms; slowest = sel.replace(/\[aria-label[$^]?="?([^"\]]*)"?\]/, '$1').slice(0, 40); }
95
- // Close whatever the tap opened; its own events are not counted.
103
+ const gaps = await page.eval<number[]>('window.__v.frames');
104
+ const name = sel.replace(/\[aria-label[$^]?="?([^"\]]*)"?\]/, '$1').slice(0, 40);
105
+ if (ms > inp) { inp = ms; slowest = name; }
106
+ const here = gaps.filter((f) => f > 50).length;
107
+ frames += gaps.length;
108
+ dropped += here;
109
+ if (here) droppedBy = name;
110
+ // Close whatever the tap opened; its own events and its own frames are not counted.
96
111
  await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
97
112
  await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
98
113
  await sleep(500);
99
114
  }
100
- const v = await page.eval<{ lcp: number; cls: number; frames: number[]; moved: Record<string, number> }>('window.__v');
115
+ const v = await page.eval<{ lcp: number; cls: number; moved: Record<string, number> }>('window.__v');
101
116
  // A page over the CLS floor says what moved: the three elements whose shifts sum highest (a shift counts for each element it moved).
102
117
  const moved = v.cls >= 0.1 ? Object.entries(v.moved).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, n]) => `${k} ${n.toFixed(3)}`).join(', ') : '';
103
- const dropped = v.frames.filter((f) => f > 50).length;
104
- const bad = v.lcp >= 2500 || inp >= 200 || v.cls >= 0.1;
118
+ return { lcp: v.lcp, cls: v.cls, moved, inp, slowest, tapped, frames, dropped, droppedBy };
119
+ }
120
+
121
+ let failures = 0;
122
+ for (const route of ROUTES) {
123
+ let m = await measure(route);
124
+ // One dropped frame among two hundred is inside the noise of the machine this measures on, and the frame is not always
125
+ // the page's: the same route, measured again on the same build, reads zero (a throttled CPU four times over measures
126
+ // the host too, which is why this suite runs alone last). The floor is still no frames over 50 ms — so a page that
127
+ // drops one is measured a second time before it is failed, and a page that drops one twice is a page that stutters.
128
+ const again = m.dropped > 0;
129
+ if (again) m = await measure(route);
130
+ const bad = m.lcp >= 2500 || m.inp >= 200 || m.cls >= 0.1 || m.dropped > 0;
105
131
  if (bad) failures++;
106
- console.log(`${bad ? 'FAIL' : 'ok '} ${route.padEnd(36)} LCP ${(v.lcp / 1000).toFixed(2)} s · INP ${Math.round(inp)} ms · CLS ${v.cls.toFixed(3)} · frames over 50 ms: ${dropped} of ${v.frames.length} · taps: ${tapped}${inp >= 100 ? ` · slowest tap: ${slowest}` : ''}${moved ? ` · moved most: ${moved}` : ''}`);
132
+ console.log(`${bad ? 'FAIL' : 'ok '} ${route.padEnd(36)} LCP ${(m.lcp / 1000).toFixed(2)} s · INP ${Math.round(m.inp)} ms · CLS ${m.cls.toFixed(3)} · frames over 50 ms: ${m.dropped} of ${m.frames}${again ? ' (second measure)' : ''} · taps: ${m.tapped}${m.dropped ? ` · dropped by: ${m.droppedBy}` : ''}${m.inp >= 100 ? ` · slowest tap: ${m.slowest}` : ''}${m.moved ? ` · moved most: ${m.moved}` : ''}`);
107
133
  }
108
134
  await page.send('Emulation.setCPUThrottlingRate', { rate: 1 });
109
135
  page.close();
110
- console.log(`\nvitals: ${ROUTES.length - failures} of ${ROUTES.length} pages under LCP 2.5 s, INP 200 ms and CLS 0.1 on a mid-range phone`);
136
+ console.log(`\nvitals: ${ROUTES.length - failures} of ${ROUTES.length} pages under LCP 2.5 s, INP 200 ms, CLS 0.1 and no dropped frame on a mid-range phone`);
111
137
  process.exit(failures ? 1 : 0);
@@ -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, 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. Each is resolved the same way as those below:
173
173
 
174
174
  | Id | The old shape it found | The change |
175
175
  |----|------------------------|------------|
@@ -183,6 +183,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
183
183
  | `view-tool-page-explore` (0.16.0) | a `defineViewTool` call without `page` or `explore` | add `page`, the console route the view renders, and `explore`, its prepared query (`PreparedQuery` in `src/lib/explore.ts`), beside the view as `example:src/views/<view>-explore.ts`: `agents.md`, "Prepared queries"; checked by the gate and a build |
184
184
  | `agent-does-here-section` (0.16.0) | the README of a page a view tool's `page` names, with no `### What the agent does here`; while a tool names no page, every console page README without it | add the part under `## Agents`: `Protagonist: <name>.` and at least three `- "<question>" — <what the prepared query is asked>`, the card marked `data-protagonist`: `agents.md`, "Prepared queries"; checked by the gate |
185
185
  | `sample-pages-renamed` (0.16.0) | the sample's Overview, Analytics and Operations pages and views under their 0.15.0 folders and files, and every file that names them: the view tools, the nav's feature ids, the verify config's routes, a shipped test | a project that kept them as they were brings the release's Home, Usage and Services over with the rest of the sample and its tests, keeping its own `app` in `src/app.config.ts`, and deletes a listed file the release no longer has; a project that made them its own keeps its names and makes the other 0.16.0 changes in them; checked by the gate and a build |
186
+ | `marker-measured-on-observe` (0.16.2) | a test that mounts the rail over a `ResizeObserver` whose `observe` answers nothing, such as `optional:tests/rail-marker.test.tsx` as the template wrote it | the rail reads no offset of its own since 0.16.2 — a rail mounts inside the tap that opens the phone drawer, and a read there lays the page out inside that tap — so the marker's first position is the one `observe` delivers, as the platform delivers it, after the effect that called it: bring `optional:tests/rail-marker.test.tsx` over from the release as it has it, whose fake answers `observe` with the callback (`observe() { for (const cb of observers) queueMicrotask(cb); }`), and answer a fake of your own the same way, reading the marker after that delivery; checked by the gate |
186
187
 
187
188
  A created project that never changed the template's pages brings the release's version of each affected file over, with
188
189
  the files that arrived with them: the view contexts `optional:src/views/home-context.ts` and its siblings, the view
@@ -79,11 +79,15 @@ Steps in order:
79
79
  data`, which does not count against the outcome, and proves its own live pages through `browserChecks`. It presses
80
80
  the sheet, the row menu and the Remove dialog inside open shadow roots too.
81
81
  8. **Web Vitals on a mid-range phone** (`scripts/vitals.ts`): Lighthouse's mobile profile (CPU slowed four times, Slow 4G,
82
- 390px touch). Every page must hold LCP under 2.5 s, INP under 200 ms and CLS under 0.1, the template's Design Atlas
83
- included (its front page and one card page; `node scripts/vitals.ts --atlas` measures every entry); INP is the slowest
84
- tap on a control that changes the screen, found inside open shadow roots too, and each line carries `taps: N`, the
85
- controls it tapped (`taps: 0` means the page's INP rests on no tap). It runs alone, after the others, since throttling measures the machine
86
- too: on a busy machine, run it again before believing a near miss.
82
+ 390px touch). Every page must hold LCP under 2.5 s, INP under 200 ms and CLS under 0.1, and drop no frame, the
83
+ template's Design Atlas included (its front page and one card page; `node scripts/vitals.ts --atlas` measures every
84
+ entry); INP is the slowest tap on a control that changes the screen, found inside open shadow roots too, and each
85
+ line carries `taps: N`, the controls it tapped (`taps: 0` means the page's INP rests on no tap). A dropped frame is a
86
+ gap over 50 ms between animation frames, read in the tap's own window and no other, so one page's line is a count as
87
+ well as a verdict: the floor is met when it reads `0 of N`. A page that drops one frame is measured a second time
88
+ before it is failed — one frame in two hundred is inside the noise of a host the four-times throttle is measuring
89
+ too — and a page that drops one twice is a page that stutters. It runs alone, after the others, since throttling
90
+ measures the machine too: on a busy machine, run it again before believing a near miss.
87
91
 
88
92
  The report is in `out/verify.txt`. Fix the cause; never weaken a check to make it pass. pnpm may first print a lockfile
89
93
  and supply-chain check before a script runs; that is pnpm, not an install, and not a failure.