zz-meridian 0.16.1 → 0.16.3

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 (89) hide show
  1. package/dist/migrations.js +40 -0
  2. package/package.json +1 -1
  3. package/payload/CHANGELOG.md +37 -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/(dashboard)/members/actions.ts +2 -1
  8. package/payload/app/api/assistant/route.ts +5 -9
  9. package/payload/app/embed/error.tsx +30 -0
  10. package/payload/app/embed/home/page.tsx +17 -3
  11. package/payload/app/embed/home/view.tsx +7 -3
  12. package/payload/app/embed/now/page.tsx +16 -3
  13. package/payload/app/embed/requests/page.tsx +16 -3
  14. package/payload/app/system/(atlas)/page.tsx +17 -6
  15. package/payload/app/system/preview/[section]/[card]/stage.tsx +13 -0
  16. package/payload/docs/agents.md +1 -1
  17. package/payload/docs/distribution.md +2 -2
  18. package/payload/docs/product-workspaces.md +2 -2
  19. package/payload/next.config.ts +3 -0
  20. package/payload/package.json +1 -1
  21. package/payload/scripts/ask.ts +7 -2
  22. package/payload/scripts/gate.ts +29 -10
  23. package/payload/scripts/lib/journey.ts +467 -0
  24. package/payload/scripts/navigate.ts +2 -458
  25. package/payload/scripts/perf.ts +3 -2
  26. package/payload/scripts/verify.ts +5 -3
  27. package/payload/scripts/vitals.ts +40 -14
  28. package/payload/skills/zz-meridian/references/agents.md +1 -1
  29. package/payload/skills/zz-meridian/references/update.md +2 -1
  30. package/payload/skills/zz-meridian/references/validation.md +9 -5
  31. package/payload/src/app.config.ts +4 -2
  32. package/payload/src/components/base/app-mark/index.tsx +11 -2
  33. package/payload/src/components/base/shell/index.tsx +11 -18
  34. package/payload/src/components/base/use-nav-drawer.ts +32 -0
  35. package/payload/src/components/charts/scale.ts +19 -3
  36. package/payload/src/components/charts/trend-chart/README.md +22 -7
  37. package/payload/src/components/charts/trend-chart/index.tsx +103 -23
  38. package/payload/src/components/charts/trend-chart/preview.tsx +31 -0
  39. package/payload/src/components/patterns/command-palette/index.tsx +5 -3
  40. package/payload/src/components/patterns/data-table/README.md +3 -2
  41. package/payload/src/components/patterns/data-table/index.tsx +24 -10
  42. package/payload/src/components/patterns/embed-frame/README.md +2 -0
  43. package/payload/src/components/patterns/embed-frame/index.tsx +71 -0
  44. package/payload/src/components/patterns/metric-tile/README.md +1 -0
  45. package/payload/src/components/patterns/metric-tile/index.tsx +4 -1
  46. package/payload/src/components/patterns/rail/README.md +1 -1
  47. package/payload/src/components/patterns/rail/index.tsx +6 -4
  48. package/payload/src/components/patterns/shell-tools/index.tsx +1 -1
  49. package/payload/src/components/ui/table/README.md +1 -1
  50. package/payload/src/components/ui/table/index.tsx +15 -3
  51. package/payload/src/components/ui/tabs/README.md +1 -1
  52. package/payload/src/components/ui/tabs/index.tsx +4 -3
  53. package/payload/src/data/metrics.ts +5 -3
  54. package/payload/src/lib/facts.ts +6 -2
  55. package/payload/src/lib/format-date.ts +16 -0
  56. package/payload/src/lib/shared-context.ts +39 -1
  57. package/payload/src/styles/base.css +7 -0
  58. package/payload/src/styles/motion.css +2 -1
  59. package/payload/src/system/atlas-shell.tsx +7 -13
  60. package/payload/src/views/customer-context.ts +2 -2
  61. package/payload/src/views/customer.tsx +2 -2
  62. package/payload/src/views/home-context.ts +23 -7
  63. package/payload/src/views/home-parts.ts +12 -4
  64. package/payload/src/views/home.tsx +14 -7
  65. package/payload/src/views/keys.tsx +3 -2
  66. package/payload/src/views/members.tsx +15 -4
  67. package/payload/src/views/now-parts.ts +5 -5
  68. package/payload/src/views/now.tsx +16 -1
  69. package/payload/src/views/requests.tsx +2 -1
  70. package/payload/src/views/revenue-context.ts +2 -1
  71. package/payload/src/views/revenue-parts.ts +2 -2
  72. package/payload/src/views/revenue.tsx +2 -2
  73. package/payload/src/views/services.tsx +11 -2
  74. package/payload/src/views/settings.tsx +20 -5
  75. package/payload/src/views/tickets.tsx +13 -1
  76. package/payload/src/views/usage-parts.ts +2 -1
  77. package/payload/src/views/usage.tsx +2 -2
  78. package/payload/tests/app-mark-sheen.test.tsx +28 -0
  79. package/payload/tests/chart-scale.test.ts +41 -0
  80. package/payload/tests/data-table.test.tsx +24 -4
  81. package/payload/tests/embed-page-failure.test.tsx +25 -0
  82. package/payload/tests/facts.test.ts +15 -0
  83. package/payload/tests/members-optimistic.test.tsx +8 -3
  84. package/payload/tests/navigate-menu-start.test.ts +1 -1
  85. package/payload/tests/navigate-start.test.ts +1 -1
  86. package/payload/tests/rail-marker.test.tsx +11 -2
  87. package/payload/tests/shared-context.test.tsx +16 -10
  88. package/payload/tests/trend-chart-band.test.tsx +117 -0
  89. package/payload/tests/view-page-match.test.ts +56 -0
@@ -422,7 +422,47 @@ 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
+ },
433
+ {
434
+ id: 'view-page-matched-as-next',
435
+ since: '0.16.3',
436
+ applies: (root) => (/\bfunction onPage\s*\(/.test(text(root, 'app/api/assistant/route.ts') ?? '') ? ['app/api/assistant/route.ts'] : null),
437
+ summary: 'The assistant route finds the view of the page a person is on with `viewAt` from `src/lib/shared-context.ts`, which matches a catch-all `page` as Next.js does.',
438
+ instructions: 'In `app/api/assistant/route.ts`, delete the local `onPage` and find the view with `viewAt` from `@/lib/shared-context`: `const at = viewAt(viewTools, path); const view = at && views.includes(at) ? at : undefined;`, where `views` are the view tools the person may use. `onPage` matched a `[...name]` segment as one segment and a `[[...name]]` never, so a view whose `page` is a catch-all (`/docs/[...path]`) was handed its prepared query at one depth only. `viewAt` matches as Next.js does — `[...name]` one or more segments, `[[...name]]` zero or more — and where two views match one address it takes the one Next.js renders, before asking whether the person may use it, so a less specific view never stands in for the page.',
439
+ checks: ['gate'],
440
+ },
441
+ {
442
+ id: 'journey-moved',
443
+ since: '0.16.3',
444
+ applies: (root) => matching(root, (rel, src) => importsOf(rel, src).some(({ from, names, namespace }) => from === 'scripts/navigate' && (namespace !== null || names.some((n) => n !== 'workspaceHomes')))),
445
+ summary: 'The navigation journey moved from `scripts/navigate.ts` to `scripts/lib/journey.ts`, which keeps every source file Meridian ships under 700 lines.',
446
+ instructions: 'Bring `tests/navigate-start.test.ts` and `tests/navigate-menu-start.test.ts` over from the release as it has them. A file of your own that imports the journey — `startFor`, `journeyStart`, `journey`, `drawerRoutes`, `prepare`, `liveRefresh`, `Fail` and the types `Device`, `Metric`, `Condition`, `Measured`, `Sample` and `NavConfig` — imports it from `scripts/lib/journey.ts` instead; `scripts/navigate.ts` keeps the command and `workspaceHomes` only.',
447
+ checks: ['gate'],
448
+ },
449
+ {
450
+ id: 'today-in-timezone',
451
+ since: '0.16.3',
452
+ applies: (root) => matching(root, (rel, src) => (rel === 'app/api/assistant/route.ts' || rel === 'src/data/metrics.ts') && /\bconst today = clock\(\)\.toISOString\(\)\.slice\(0, 10\)|\btoday: clock\(\)\.toISOString\(\)\.slice\(0, 10\)/.test(src)),
453
+ summary: "Today is the day in `app.timezone` wherever a day is cut: `dayOf(clock())` from `src/lib/format-date.ts`, not the UTC date.",
454
+ instructions: "Replace `clock().toISOString().slice(0, 10)` with `dayOf(clock())` (`dayOf` in `src/lib/format-date.ts`) in each file listed: the prepared query's `today` in `app/api/assistant/route.ts`, and `readDays`'s today in `src/data/metrics.ts`. The prompt has told the model today in `app.timezone` all along, while the prepared query and the page cut their periods at the UTC date, so east of UTC after local midnight (west of UTC in the evening) the model's today and its `7d` window's end were a day apart. A day row is kept under its day in `app.timezone`, so a file of your own that compares an instant's day with one — `now.slice(0, 10)`, `at.slice(0, 10)` — asks `dayOf` too. A product whose `app.timezone` is UTC sees no change. A project that kept the sample's views brings `src/views/now-parts.ts`, `src/views/revenue-parts.ts`, `src/views/customer-context.ts`, `src/views/customer.tsx` and `src/views/members.tsx` over from the release.",
455
+ checks: ['gate'],
456
+ },
425
457
  ];
458
+ /**
459
+ * A test of the rail that stands over a fake `ResizeObserver` answering `observe` with nothing (0.16.2). The lookbehind
460
+ * keeps `unobserve() {}` out of it: a fake that answers the callback and also stubs `unobserve` is the release's own
461
+ * shape, and the one thing to bring over.
462
+ */
463
+ function railObserverNeverAnswers(root) {
464
+ 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));
465
+ }
426
466
  /** The team's files that import an export 0.16.0 removed from a managed module, by name or through a namespace. */
427
467
  function removedExportReaders(root) {
428
468
  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.3",
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,43 @@
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.3] · 2026-10-17
6
+
7
+ ### Breaking
8
+
9
+ - **The navigation journey moved to `scripts/lib/journey.ts`.** A created project's `tests/navigate-start.test.ts` and `tests/navigate-menu-start.test.ts` import `startFor` and `journeyStart` from `scripts/navigate.ts`, which no longer has them, so they fail the type check until they are brought over. The files are the project's own, so the update does not write them: it reports them as the migration `journey-moved` — bring the release's copies over, and import the journey (`journey`, `journeyStart`, `startFor`, `drawerRoutes`, `prepare`, `liveRefresh`, `Fail` and its types) from `scripts/lib/journey.ts` in a file of your own. `scripts/navigate.ts` keeps the command and `workspaceHomes`.
10
+
11
+ ### Changed
12
+
13
+ - **The default verify does less twice, and the gate runs its three long checks side by side.** `scripts/gate.ts` runs the type check, the lint and the tests together once the quick checks before them pass, and prints their lines in the same order as before. All three run to the end, so one failure never hides another. The build inside `pnpm verify` no longer type-checks the tree a second time, because the gate has just checked it with the same tsconfig: verify sets `MERIDIAN_TYPES_CHECKED` for that build, and the template's `next.config.ts` reads it as `typescript.ignoreBuildErrors`. Every other `next build` still checks types. An adopted project keeps its own `next.config`, so its verify checks types twice as before, unless it adds that line. Measured from a clean tree on one machine: the gate 22.9 → 15.4 s, the build 14.5 → 9.5 s, the default verify 62.2 → 49.8 s. The release's verify had reached 193 s against its 180 s limit on the slowest runner CPU.
14
+ - **Every source file Meridian ships is under 700 lines** ([#41](https://github.com/zhixuan312/zz-meridian/issues/41)). `scripts/navigate.ts` had grown to 775, the only managed file past a ceiling a project may hold its own files to — and a project cannot split a managed file, because the update restores it. The journey and its measurements are `scripts/lib/journey.ts` now (467 lines) and `scripts/navigate.ts` keeps the command and its report (319); nothing either does changed. A test of the package holds every shipped file under `src`, `app`, `scripts` and `tests` to 700 lines, so a release cannot grow one past it again.
15
+
16
+ ### Fixed
17
+
18
+ - **Today is the product's day wherever a day is cut** ([#42](https://github.com/zhixuan312/zz-meridian/issues/42)). The assistant's prompt told the model today in `app.timezone`, while the prepared query (`today` in `app/api/assistant/route.ts`) and the pages' periods (`readDays` in `src/data/metrics.ts`) cut their days at the UTC date. So east of UTC after local midnight, and west of it in the evening, the model's today and the end of its `7d` window were a day apart, and a day row — kept under its day in `app.timezone` — fell on the wrong side of the cut. **`dayOf(at)`** (`src/lib/format-date.ts`) is the calendar day of an instant in `app.timezone`, and the route, `readDays` and the sample's views (Now's month and its runs by day, Revenue's ladder and trials, a customer's busiest day and trial days left, a new member's joined date) ask it now. A product whose `app.timezone` is UTC sees no change. The route and `src/data/metrics.ts` are the team's own files, so the update reports them as the migration `today-in-timezone` — replace `clock().toISOString().slice(0, 10)` with `dayOf(clock())`.
19
+ - **A view whose page is a catch-all route is handed its prepared query at every depth** ([#40](https://github.com/zhixuan312/zz-meridian/issues/40)). The assistant route matched a view's `page` against the address segment by segment and required the two to have as many segments, reading `[...name]` as any one segment and `[[...name]]` as one too: a view on `/docs/[...path]` was the page at `/docs/a` and not at `/docs/a/b`, which the same Next.js route renders, and one on `/docs/[[...path]]` was never the page at `/docs`. The match is `viewAt` in `src/lib/shared-context.ts` now, and it matches as Next.js does — `[...name]` one or more segments, `[[...name]]` zero or more — and where two views match one address it takes the one Next.js renders: a static segment over `[name]`, that over a catch-all. The route asks whether the person may use that view after choosing it, so a less specific view never stands in for the page. `app/api/assistant/route.ts` is the team's own file, so the update does not write it: it reports it as the migration `view-page-matched-as-next` — delete the route's `onPage` and find the view with `viewAt`. `node scripts/ask.ts` asks a catch-all page's questions on a link of any depth, and an optional catch-all's on the page with none.
20
+
21
+ ## [0.16.2] · 2026-10-16
22
+
23
+ ### Breaking
24
+
25
+ - **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.
26
+ - **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.
27
+
28
+ ### New
29
+
30
+ - **`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.
31
+
32
+ ### Changed
33
+
34
+ - **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.
35
+ - **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.
36
+ - **`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.
37
+
38
+ ### Fixed
39
+
40
+ - **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.
41
+
5
42
  ## [0.16.1] · 2026-10-16
6
43
 
7
44
  ### 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 |
@@ -4,6 +4,7 @@ import { clock } from '@/data/collections';
4
4
  import { can, collectionFor, resolveAccess, Unauthenticated } from '@/data/access';
5
5
  import { assignMemberRole, REFUSALS } from '@/data/member-mutations';
6
6
  import type { AnyCollection } from '@/lib/collection';
7
+ import { dayOf } from '@/lib/format-date';
7
8
  import type { Result } from '@/views/members';
8
9
  import { STATUSES, TEAMS, type Member } from '@/data/sample';
9
10
  import { MAIN_ROLES, type AddOn, type MainRole } from '@/data/roles';
@@ -38,7 +39,7 @@ export async function inviteMember(input: { name: string; email: string; role: M
38
39
  if (!MAIN_ROLES.includes(input.role) || !TEAMS.includes(input.team)) return { ok: false, error: 'Choose a role and a team from the lists.' };
39
40
  // The boundary decides the role the caller may give — an Admin inviting an Owner is refused there with the frozen
40
41
  // rule's message — so this path only shapes the invitation.
41
- return run('create', undefined, (c) => c.create!({ name, email, role: input.role, team: input.team, status: 'Invited', joined: clock().toISOString().slice(0, 10), lastActive: null }));
42
+ return run('create', undefined, (c) => c.create!({ name, email, role: input.role, team: input.team, status: 'Invited', joined: dayOf(clock()), lastActive: null }));
42
43
  }
43
44
 
44
45
  export async function setMemberStatus(id: string, status: Member['status']): Promise<Result> {
@@ -12,7 +12,8 @@ import { handedTo } from '@/lib/assistant/scoped';
12
12
  import type { AgentChange, HandedExplore } from '@/lib/assistant/tools';
13
13
  import type { AnyCollection, Grant, Need } from '@/lib/collection';
14
14
  import type { PreparedQuery } from '@/lib/explore';
15
- import type { ViewTool } from '@/lib/shared-context';
15
+ import { dayOf } from '@/lib/format-date';
16
+ import { viewAt, type ViewTool } from '@/lib/shared-context';
16
17
  import { viewTools } from '@/views/tools';
17
18
 
18
19
  /** The panel sends at most the last 100 messages; a larger body than 2 MB is refused before it is parsed. */
@@ -62,14 +63,15 @@ export async function POST(request: Request): Promise<Response> {
62
63
  // they may use it. It reads through `aggregate()` and `read()`, which ask the person's grant again when a call runs;
63
64
  // its schema offers only what they may read: the collections handed above, without each one's hidden fields.
64
65
  const path = pathOf(String(page?.path ?? ''));
65
- const view = views.find((v) => onPage(v.page, path));
66
+ const at = viewAt(viewTools, path);
67
+ const view = at && views.includes(at) ? at : undefined;
66
68
  const names = new Set(readable.map((c) => c.name));
67
69
  const reads = new Set(view ? Object.values(view.explore.sources).map((s) => s.collection) : []);
68
70
  const explore: HandedExplore | undefined = view && [...reads].some((name) => names.has(name))
69
71
  ? {
70
72
  view,
71
73
  rights: { readable: names, hidden: (name) => (readable.find((c) => c.name === name)?.hidden ?? []) as string[] },
72
- io: { aggregate, read, today: clock().toISOString().slice(0, 10) },
74
+ io: { aggregate, read, today: dayOf(clock()) },
73
75
  page: path,
74
76
  }
75
77
  : undefined;
@@ -145,12 +147,6 @@ function pathOf(address: string): string {
145
147
  return (path.length > 1 ? path.replace(/\/+$/, '') : path) || '/';
146
148
  }
147
149
 
148
- /** Whether `path` is the page `route` renders: each segment equal, a `[name]` segment matching any one segment. */
149
- function onPage(route: string, path: string): boolean {
150
- const want = route.split('/'), got = path.split('/');
151
- return want.length === got.length && want.every((w, i) => (/^\[[^\]/]+\]$/.test(w) ? got[i] !== '' : w === got[i]));
152
- }
153
-
154
150
  /** The grants a need names: the one, every one of an `allOf`, and none for `public`. */
155
151
  const grantsOf = (need: Need): readonly Grant[] => (need === 'public' ? [] : typeof need === 'string' ? [need] : need.allOf);
156
152
 
@@ -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
  );
@@ -83,7 +83,7 @@ The answer is text of at most 3,000 characters, in a fixed order: the window as
83
83
 
84
84
  ## The console's own assistant
85
85
 
86
- The second place is inside the product. The assistant panel reads the page the person is on (its address with the query, the view's shared context, then the visible text), reads further through every `view_<name>` and the page's own prepared query, and proposes changes as Proposals in its thread. It is handed that page's `explore_<view>` and no other page's: the route finds the view whose `page`, the console route of the page it renders, is the address the panel sends, and hands its prepared query only where the person may use that view. There is no tool that reads a collection directly. Its write tools are those of the collections that prepared query reads, among the product's collections (`src/data/collections.ts`), so the members' writes are handed on Members and the key writes on API keys; a change takes the ids the query's drilled rows give, and asked for a change another page makes, the assistant gives that page's address where the person may open it; a page they may not open is never offered as where they make a change. A rail page's `page` is its `href` in `nav`; a detail page's has its segment (`/customers/[id]`) and matches any one segment there. It follows the same rules: it reads what the person may read, changes nothing without approval, and leaves its mark (the Agent mark and the "Assistant" caption). It is off until a model is configured. `docs/assistant.md` says how to switch it on and point it at your data, and how the same tools can later be offered from an MCP server.
86
+ The second place is inside the product. The assistant panel reads the page the person is on (its address with the query, the view's shared context, then the visible text), reads further through every `view_<name>` and the page's own prepared query, and proposes changes as Proposals in its thread. It is handed that page's `explore_<view>` and no other page's: the route finds the view whose `page`, the console route of the page it renders, is the address the panel sends, and hands its prepared query only where the person may use that view. There is no tool that reads a collection directly. Its write tools are those of the collections that prepared query reads, among the product's collections (`src/data/collections.ts`), so the members' writes are handed on Members and the key writes on API keys; a change takes the ids the query's drilled rows give, and asked for a change another page makes, the assistant gives that page's address where the person may open it; a page they may not open is never offered as where they make a change. A rail page's `page` is its `href` in `nav`; a detail page's has its segment (`/customers/[id]`) and matches any one segment there, and a catch-all matches as Next.js matches it: `/docs/[...path]` any depth under `/docs/`, `/docs/[[...path]]` `/docs` as well. Where two views match one address, the one Next.js renders is chosen (`viewAt` in `src/lib/shared-context.ts`). It follows the same rules: it reads what the person may read, changes nothing without approval, and leaves its mark (the Agent mark and the "Assistant" caption). It is off until a model is configured. `docs/assistant.md` says how to switch it on and point it at your data, and how the same tools can later be offered from an MCP server.
87
87
 
88
88
  The page text it reads is the scroll region's visible text, less every node marked `data-assistant="omit"`. A value shown once, such as a newly issued key's secret or a one-time sign-up link, is drawn so marked, or outside the scroll region (a `Dialog` portals to the document's body), so asking while it is on screen never sends it to the model; the sample's API keys page marks a new key's Copy field so. `scripts/ask.ts` leaves out the same nodes from the page it sends. The thread is the signed-in person's for the tenant they act for: it is stored with that scope, a change of either starts an empty thread and removes the earlier one, and each request names the scope, so a session that changed hands since (a sign-in from another tab) is refused with 409 and the panel clears the thread instead of continuing it, then refreshes the route, so the next question is asked as whoever is signed in now. A tab writes and removes only a thread of its own scope.
89
89
 
@@ -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
 
@@ -8,6 +8,9 @@ const config: NextConfig = {
8
8
  cacheComponents: true,
9
9
  partialPrefetching: true,
10
10
  devIndicators: false,
11
+ // `pnpm verify` sets MERIDIAN_TYPES_CHECKED for its one build, only after its own gate has type-checked this tree with
12
+ // the same tsconfig, so the build does not check it twice. Every other `next build` checks the types itself.
13
+ typescript: { ignoreBuildErrors: process.env.MERIDIAN_TYPES_CHECKED === '1' },
11
14
  // Card specifications (README.md next to each component) are read at build time by the Design Atlas.
12
15
  outputFileTracingIncludes: { '/system/**': ['./src/**/*.md', './app/**/*.md', './docs/**/*.md', './decisions/**/*.md', './*.md', './tokens/**/*.json'], '/icon': ['./tokens/**/*.json'], '/api/assistant': ['./docs/brief.md'] },
13
16
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
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.",
@@ -317,11 +317,16 @@ function report(view: View, address: string, persona: string, base: string, page
317
317
  return `${out.join('\n')}\n`;
318
318
  }
319
319
 
320
- /** The address a page's questions are asked on: the page itself, or for a detail page the first record its listing page links. */
320
+ /**
321
+ * The address a page's questions are asked on: the page itself, or for a detail page the first record its listing page
322
+ * links. An optional catch-all (`/docs/[[...path]]`) renders with none of its segments too, so it is asked there; a
323
+ * catch-all takes a link of any depth.
324
+ */
321
325
  async function addressOf(base: string, cookie: string, page: string): Promise<string | null> {
322
326
  if (!/\[[^\]/]+\]/.test(page)) return page;
327
+ if (/\/\[\[\.\.\.[^\]/]+\]\]$/.test(page)) return addressOf(base, cookie, page.replace(/\/\[\[\.\.\.[^\]/]+\]\]$/, '') || '/');
323
328
  const listing = page.split('/[')[0] || '/';
324
- const form = page.split('/').map((s) => (/^\[[^\]/]+\]$/.test(s) ? `[^/"'?#\\s<>]+` : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/');
329
+ const form = page.split('/').map((s) => (/^\[\.\.\.[^\]/]+\]$/.test(s) ? `[^"'?#\\s<>]+` : /^\[[^\]/]+\]$/.test(s) ? `[^/"'?#\\s<>]+` : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/');
325
330
  const html = await (await fetch(`${base}${listing}`, { headers: { cookie }, signal: AbortSignal.timeout(60_000) })).text();
326
331
  return new RegExp(`href="(${form})(?:[?#][^"]*)?"`).exec(html)?.[1] ?? null;
327
332
  }