zz-meridian 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/migrations.js +8 -0
  2. package/package.json +1 -1
  3. package/payload/CHANGELOG.md +28 -0
  4. package/payload/README.md +2 -2
  5. package/payload/app/(dashboard)/requests/README.md +1 -1
  6. package/payload/app/(dashboard)/requests/loading.tsx +4 -1
  7. package/payload/app/(dashboard)/requests/page.tsx +4 -2
  8. package/payload/app/layout.tsx +5 -4
  9. package/payload/docs/atlas-scenario-coverage.md +2 -2
  10. package/payload/docs/distribution.md +2 -2
  11. package/payload/journeys/workspaces.ts +107 -8
  12. package/payload/package.json +1 -1
  13. package/payload/scripts/check.ts +10 -0
  14. package/payload/scripts/lib/nav-under.ts +35 -0
  15. package/payload/skills/zz-meridian/references/customize.md +17 -0
  16. package/payload/skills/zz-meridian/references/update.md +1 -0
  17. package/payload/src/app.config.ts +10 -2
  18. package/payload/src/components/base/shell/index.tsx +2 -2
  19. package/payload/src/components/charts/bar-list/README.md +2 -1
  20. package/payload/src/components/charts/bar-list/index.tsx +11 -7
  21. package/payload/src/components/charts/column-chart/README.md +2 -2
  22. package/payload/src/components/charts/column-chart/grouped.tsx +1 -1
  23. package/payload/src/components/charts/composition-bar/README.md +3 -2
  24. package/payload/src/components/charts/composition-bar/index.tsx +14 -6
  25. package/payload/src/components/patterns/command-palette/README.md +2 -2
  26. package/payload/src/components/patterns/command-palette/index.tsx +2 -1
  27. package/payload/src/components/patterns/rail/README.md +30 -5
  28. package/payload/src/components/patterns/rail/index.tsx +104 -42
  29. package/payload/src/components/patterns/rail/preview.tsx +42 -10
  30. package/payload/src/components/patterns/rail/trail.ts +27 -0
  31. package/payload/src/logic/derivations.ts +4 -3
  32. package/payload/src/styles/fonts/Geist-Variable.woff2 +0 -0
  33. package/payload/src/styles/fonts/GeistMono-Variable.woff2 +0 -0
  34. package/payload/src/styles/fonts/LICENSE.txt +92 -0
  35. package/payload/tests/chart-semantics.test.tsx +14 -1
  36. package/payload/tests/check-nav-under.test.ts +59 -0
  37. package/payload/tests/column-chart-grouped.test.tsx +2 -0
  38. package/payload/tests/composition-bar.test.tsx +20 -0
  39. package/payload/tests/derivations.test.ts +2 -0
  40. package/payload/tests/page-frame-title.test.tsx +3 -3
  41. package/payload/tests/palette-hint.test.tsx +57 -0
  42. package/payload/tests/rail-branch-feel.test.tsx +106 -0
  43. package/payload/tests/rail-branch.test.tsx +258 -0
  44. package/payload/tests/rail-trail.test.ts +39 -0
  45. package/payload/tests/review-held-scales.test.tsx +50 -0
  46. package/payload/tests/workspaces.test.ts +5 -2
@@ -572,6 +572,14 @@ export const RELEASE_MIGRATIONS = [
572
572
  instructions: 'The audit, the presses, the keyboard walk, the vitals and `scripts/shot.ts` now read what a route answered: one that answered an error status or draws the not-found screen (`[data-missing-page]`) was measured as that screen and not as the route, so it fails. In `scripts/verify.config.ts`, which is yours, move every entry of `detailRoutes` that names a record that does not exist (the sample\'s `/requests/req_missing` and `/customers/missing`) into a new `missingRoutes` list; each must be the not-found screen, and one that renders a page fails. A not-found screen of your own carries `data-missing-page` on its root, as `MissingPage` does: inside a streamed shell a missing record has already answered 200.',
573
573
  checks: ['verify'],
574
574
  },
575
+ {
576
+ id: 'self-hosted-fonts',
577
+ since: '0.34.0',
578
+ applies: (root) => matching(root, (rel, src) => /^app\//.test(rel) && SOURCE.test(rel) && /from\s+['"]next\/font\/google['"]/.test(src) && /--font-face-(?:sans|mono)/.test(src)),
579
+ summary: "Meridian's faces ship with the release under `src/styles/fonts/`: a build loads them from there and fetches nothing from Google Fonts.",
580
+ instructions: "Your `app/layout.tsx` is yours, so an update does not touch it, and yours hands `--font-face-sans` and `--font-face-mono` their faces through `next/font/google`, which `next build` fetches from fonts.googleapis.com: with that host unreachable and no build cache, the build fails (`Build error occurred`). The update has delivered the two faces, `src/styles/fonts/Geist-Variable.woff2` and `src/styles/fonts/GeistMono-Variable.woff2`, with their licence. In the layout, replace the `next/font/google` import with `import localFont from 'next/font/local';` and each face's call with `localFont({ src: '../src/styles/fonts/Geist-Variable.woff2', weight: '100 900', variable: '--font-face-sans', display: 'swap' })` and `localFont({ src: '../src/styles/fonts/GeistMono-Variable.woff2', weight: '100 900', variable: '--font-face-mono', display: 'swap' })`, keeping the class names on `<html>` as they are. A layout that gives the tokens faces of its own from Google Fonts is the same case: ship the files and load them with `next/font/local`.",
581
+ checks: GATE_AND_BUILD,
582
+ },
575
583
  ];
576
584
  /** 0.32.0: a verify configuration that lists detail routes and declares no routes meant to be missing. */
577
585
  function detailRoutesWithoutMissing(root) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,34 @@
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.35.0] · 2026-10-29
6
+
7
+ A third level in the rail: a navigation item can have sub pages, drawn in a branch under it, and the sample shows one (Traffic, Usage, Requests).
8
+
9
+ ### Breaking
10
+
11
+ - **`PageFrame` draws its kicker in a `div`, not a `p`.** A kicker can now hold block content such as a `Breadcrumb`, which inside a `p` was invalid HTML and a hydration mismatch. The `t-kicker` class and the look are unchanged. A test of your own that asserts the kicker's tag name expects `DIV`, and a selector written as `p.t-kicker` is written `.t-kicker`.
12
+
13
+ ### Added
14
+
15
+ - **A navigation item can have sub pages, drawn as a third level in the rail.** Set `under` on a `NavItem` to the `href` of another item of the same group, and the rail draws the item in that item's branch, on a guide line and without an icon. The parent stays a link to its own page, with a button beside it named `<label> pages` that opens and closes the branch. A branch is open on arrival when the current page is its parent or one of its sub pages, and a closed branch draws none of its links. A closed branch that holds the current page puts the pill on its parent, so the rail always shows where the person is. A sub page the person may not open is not drawn, and one whose parent they may not open is drawn as a plain item. No migration is needed: `under` is optional, and a navigation without it draws as before.
16
+ - **The gate refuses a sub page the rail cannot draw.** `node scripts/check.ts` refuses an `under` that names no item of the same group, the item itself, or an item that is itself under another (three levels, never four). It also refuses a sub page in another workspace than its parent, and more than 7 sub pages under one parent. Each refusal names the item's `href`.
17
+ - **The palette and the masthead say where a sub page sits.** The command palette's hint for a sub page is its parent's label, so typing a parent's label finds its sub pages. `navTrail(href, nav)` in `src/components/patterns/rail/trail.ts` derives a page's crumbs from the navigation: for a sub page, its parent as a link, then the page. The sample's Requests page draws it as its kicker, Usage then Requests.
18
+ - **The sample shows all three levels.** Business is now two labelled groups: Accounts (Revenue, Customers) and Traffic (Usage, with Requests under it). The Rail preview draws every branch state.
19
+
20
+ ## [0.34.0] · 2026-10-29
21
+
22
+ ### Changed
23
+
24
+ - **A build fetches nothing: the typefaces ship with the template.** Geist and Geist Mono came from Google Fonts through `next/font/google`, which `next build` fetches at build time, so with fonts.googleapis.com unreachable and no build cache the build failed (`Build error occurred`, exit 1), as the first 0.33.0 release run's update of a created project did (issue #100). The two variable faces are under `src/styles/fonts/` (from `geist@1.8.2`, with their SIL Open Font License), managed, and `app/layout.tsx` loads them with `next/font/local`. Each face is now one whole file, where Google served its Latin glyphs in a subset of their own.
25
+ - **What a team does.** `app/layout.tsx` is yours, so an update does not change it: run `update`, which delivers the faces and reports `migration:self-hosted-fonts` for a layout that still hands `--font-face-sans` and `--font-face-mono` their faces from Google Fonts, and load each with `next/font/local` as the release's layout does.
26
+
27
+ ### Fixed
28
+
29
+ - **A Bar list's axis draws behind its rows.** Its gridlines, positioned before the rows, painted over every label that is not a link, the meta and each figure, and, under reduced motion, over the bars: the words and the bars now stand over them, and the words over the bars, so a linked label's target under a finger still takes a tap on its bar.
30
+ - **A row or a part below zero draws nothing, never the whole track.** A Bar list row below zero set a negative width, which a browser drops, so its bar filled the track; it draws an empty bar, its figure the value it holds and its share of `total` `0.0%`. A Composition bar summed its signed parts into its whole, so a negative part turned every other share negative; the whole is the sum of the parts above zero (`compositionTotal`), and a part below zero draws as a part of zero, its share `0%`, and adds nothing to Other, as a Bar list row below zero adds nothing to its "N others". A share above none too small for one decimal reads `<0.1%`, never `0.0%`. `tests/chart-semantics.test.tsx` now catches a size below zero where React sets it, which jsdom, like a browser, dropped before any check could read it.
31
+ - **A grouped Column chart's group with no data keeps its cut word in its title.** "No data" cut to a 36 to 45px column was held whole nowhere; the group's title holds it, as a plain column's does. The Column chart's README names the grouped form's ring and readout where it named the stacked form's alone.
32
+
5
33
  ## [0.33.0] · 2026-10-28
6
34
 
7
35
  The charts and parts a monitoring page needs (issue #99): bars in the order given, on a held and labelled scale, two values a row, signed rows about zero, straight and step lines with a dot per reading, grouped columns, a hatch for an estimate, a section that opens in place and a dialog wide enough for a table.
package/payload/README.md CHANGED
@@ -158,8 +158,8 @@ brief defines — which is also how you tell a wrong rendering from a wrong mode
158
158
  Meridian takes nothing from you. The package, the template and the skill have no telemetry, no analytics and no
159
159
  account, and the template turns off Next.js's own anonymous telemetry (`next.config.ts`; Meridian's scripts turn it off
160
160
  for every `next` they run). What reaches the network is what you would expect: npm, when you run `npx` and when `update`
161
- reads releases from it; Google Fonts, which `next/font` downloads the typefaces from at build time; and whatever you
162
- configure yourself, such as the assistant's model provider.
161
+ reads releases from it, and whatever you configure yourself, such as the assistant's model provider. The typefaces ship
162
+ with the template (`src/styles/fonts/`), so a build fetches nothing.
163
163
 
164
164
  The one way to tell us anything is a [GitHub issue](https://github.com/zhixuan312/zz-meridian/issues/new/choose): a
165
165
  **Bug** (something in Meridian did the wrong thing) or a **Feature request** (something it should do, or do more
@@ -8,7 +8,7 @@ Status: beta
8
8
 
9
9
  | Row | Pattern | Console (1024px and wider) | Mobile |
10
10
  |---|---|---|---|
11
- | Masthead | PageFrame: kicker "ZZ Meridian · Production", title "Requests", one sentence, Freshness, Export CSV | One band | Title, sentence, then Freshness and Export on their own row |
11
+ | Masthead | PageFrame: kicker its trail in the navigation (Usage, then Requests), from `navTrail`, title "Requests", one sentence, Freshness, Export CSV | One band | Title, sentence, then Freshness and Export on their own row |
12
12
  | 1 | Row `tiles`: three Metric tiles for what the filters let through: requests, errors and limits, latency p95, each with its 5-minute shape over the last hour and the last half hour against the one before | Three across | One column |
13
13
  | 2 | Data table with a Filter bar in its toolbar: search, Status, Method, Region, the result count | Columns: Request (grow), Status, Latency, Customer and Region (once the table is 896px wide), Size (1152px), Received; a table under 640px wide is the card list. A row opens its request; its Customer opens the customer's page | Card list: route and status, then latency, customer and when |
14
14
 
@@ -1,3 +1,6 @@
1
+ import { nav } from '@/app.config';
2
+ import { navTrail } from '@/components/patterns/rail/trail';
3
+ import { Breadcrumb } from '@/components/ui/breadcrumb';
1
4
  import { LoadingPage } from '@/components/patterns/page-loading';
2
5
  import { gatedLoading } from '@/data/refusal';
3
6
  import { RecordsSkeleton } from '@/components/templates/records';
@@ -5,7 +8,7 @@ import { RecordsSkeleton } from '@/components/templates/records';
5
8
  /** Requests on its way: three tiles over a few table rows; the page's own fallback is the same, so a navigation does not jump. */
6
9
  export default function RequestsLoading() {
7
10
  return (
8
- <LoadingPage {...gatedLoading({ name: 'requests', title: 'Requests' })}>
11
+ <LoadingPage {...gatedLoading({ name: 'requests', title: 'Requests', kicker: <Breadcrumb items={navTrail('/requests', nav)} /> })}>
9
12
  <RecordsSkeleton tiles={3} rows={3} filters={false} />
10
13
  </LoadingPage>
11
14
  );
@@ -1,6 +1,8 @@
1
1
  import { Suspense, cache } from 'react';
2
- import { app } from '@/app.config';
2
+ import { nav } from '@/app.config';
3
3
  import { PageFrame } from '@/components/base/shell';
4
+ import { navTrail } from '@/components/patterns/rail/trail';
5
+ import { Breadcrumb } from '@/components/ui/breadcrumb';
4
6
  import { ExportButton } from '@/components/patterns/export-button';
5
7
  import { Freshness } from '@/components/patterns/freshness';
6
8
  import { gate, gatedMetadata } from '@/data/access';
@@ -26,7 +28,7 @@ export default async function RequestsPage({ searchParams }: { searchParams: Sea
26
28
  if (denied) return RefusalScreen(denied);
27
29
  return (
28
30
  <PageFrame
29
- kicker={<>{app.name} · {app.workspace}</>}
31
+ kicker={<Breadcrumb items={navTrail('/requests', nav)} />}
30
32
  title="Requests"
31
33
  description="The most recent calls that reached the gateway, newest first. Open one to see where its time went."
32
34
  meta={<Suspense fallback={<span className="inline-block h-4 w-28" />}><RequestsFreshness searchParams={searchParams} /></Suspense>}
@@ -1,13 +1,14 @@
1
1
  import type { Metadata, Viewport } from 'next';
2
- import { Geist, Geist_Mono } from 'next/font/google';
2
+ import localFont from 'next/font/local';
3
3
  import { app } from '@/app.config';
4
4
  import { PREPAINT } from '@/lib/preferences';
5
5
  import { Providers } from '@/components/base/providers';
6
6
  import './globals.css';
7
7
 
8
- /* The faces are self-hosted by next/font and handed to the tokens as --font-face-sans and --font-face-mono. */
9
- const sans = Geist({ subsets: ['latin'], variable: '--font-face-sans', display: 'swap' });
10
- const mono = Geist_Mono({ subsets: ['latin'], weight: ['400', '500'], variable: '--font-face-mono', display: 'swap' });
8
+ /* The faces ship with the template (src/styles/fonts, Geist and Geist Mono from geist@1.8.2, under their licence there),
9
+ so a build fetches nothing; next/font hands them to the tokens as --font-face-sans and --font-face-mono. */
10
+ const sans = localFont({ src: '../src/styles/fonts/Geist-Variable.woff2', weight: '100 900', variable: '--font-face-sans', display: 'swap' });
11
+ const mono = localFont({ src: '../src/styles/fonts/GeistMono-Variable.woff2', weight: '100 900', variable: '--font-face-mono', display: 'swap' });
11
12
 
12
13
  export const metadata: Metadata = {
13
14
  title: { default: app.name, template: `%s · ${app.name}` },
@@ -78,7 +78,7 @@ This is the inventory of the whole Meridian sample: every job the product is mea
78
78
  | S11 | Pattern: Rail | console navigation — `src/components/patterns/rail/index.tsx` | L3 | `src/components/patterns/rail` | compose | Used by the console chrome and holds the appearance menu. It takes the optional navigation and the workspace scopes that let the product supply one workspace at a time. Its preview draws the real rail with one workspace and with several, and no copy of its items. | `src/components/patterns/rail` | `/system/patterns/rail` | all console pages | normal | `tests/workspace-chrome.test.tsx`, `tests/console-chrome.test.tsx`, `tests/atlas-template-contracts.test.tsx`, `scripts/rail.ts` |
79
79
  | shared | Pattern: Shell tools | search pill and alerts — `src/components/patterns/shell-tools/index.tsx` | L3 | `src/components/patterns/shell-tools` | keep | Used by the dashboard layout and the states layout. The alerts panel lists what needs a person across the product and links to where each is handled; the layout hands it alerts worked out from the sample's records, and the card's own preview keeps its fixture. It is the closest thing to A7. | `src/components/patterns/shell-tools` | `/system/patterns/shell-tools` | all console pages | normal, empty | `tests/clock.test.tsx`, `tests/shell-alerts.test.ts` |
80
80
  | S5 | Pattern: Status list | every service, its 90-day history and, when asked, how long it has been operational — `src/components/patterns/status-list/index.tsx` | L3 | `src/components/patterns/status-list` | keep | Used by Now, by Services and by the Now embed. With `run` each row says how long the service has been operational, and that the run is its longest when no run among the days kept is longer; Now and Services ask for it. It is also the nearest composition for release lifecycle (S8) and run verdicts (S7). | `src/components/patterns/status-list` | `/system/patterns/status-list` | Now, Services, Now embed | normal, empty | `tests/status-list.test.tsx`, `tests/plural.test.ts` |
81
- | shared | Chart: Bar list | rank items by one measure — `src/components/charts/bar-list/index.tsx` | L3 | `src/components/charts/bar-list` | keep | Used by Usage, Revenue, One customer and Now. | `src/components/charts/bar-list` | `/system/patterns/bar-list` | Usage, Revenue, One customer, Now | normal, empty | `scripts/audit.ts` |
81
+ | shared | Chart: Bar list | rank items by one measure — `src/components/charts/bar-list/index.tsx` | L3 | `src/components/charts/bar-list` | keep | Used by Usage, Revenue, One customer and Now. | `src/components/charts/bar-list` | `/system/patterns/bar-list` | Usage, Revenue, One customer, Now | normal, empty | `tests/bar-list-links.test.tsx`, `tests/bar-list-forms.test.tsx`, `tests/bar-list-axis-words.test.tsx`, `tests/feel-bar-list.test.tsx`, `scripts/audit.ts` |
82
82
  | shared | Chart: Column chart | compare counts side by side, or read each period as a whole split into its parts — `src/components/charts/column-chart/index.tsx` | L3 | `src/components/charts/column-chart` | keep | Used by Usage for the hours of the day, which are sums over the heatmap's rows, with the busiest hour highlighted. On that chart the card writes a value above each column while a column is at least 36px wide, is one Tab stop whose arrow keys move the cursor the pointer moves, and reads the column pointed at to a screen reader. The card also draws each column as its parts, in one hue or in tones when the parts are outcomes, with a legend, a fold into Other past four parts, a readout and a table of every part and total; marks a last column that is a period still running and says so far; stands two or three identities side by side as a grouped form, with no Total, loaded only on a page that draws one; hatches an estimated column in every form and says estimate; and takes its card's height when asked. Home draws requests and revenue a period with the latest highlighted, Revenue draws its periods as parts in one hue, and Now its verification runs as parts in tones with the day in progress running; no page takes its card's height yet, and the card's preview does. | `src/components/charts/column-chart` | `/system/patterns/column-chart` | Main (`/`), Usage, Revenue, Now | normal, empty, partial | `tests/column-chart.test.tsx`, `tests/column-chart-stacked.test.tsx`, `tests/column-chart-grouped.test.tsx`, `scripts/audit.ts` |
83
83
  | shared | Chart: Composition bar | split one whole into parts — `src/components/charts/composition-bar/index.tsx` | L3 | `src/components/charts/composition-bar` | keep | Used by Usage for how the last 30 complete days were answered, by status class in status colours, and by Home and Revenue for what is sold and who pays. Single use is not a reason to remove a card the Atlas teaches. | `src/components/charts/composition-bar` | `/system/patterns/composition-bar` | Main (`/`), Revenue, Usage | normal | `scripts/audit.ts` |
84
84
  | shared | Chart: Diverging bars | signed rows either side of one zero line — `src/components/charts/diverging-bars/index.tsx` | L3 | `src/components/charts/diverging-bars` | keep | Added for issue #99: a signed change by category on a scale symmetric about zero, held at a domain or widened past it in words, the neutral population with one accent row, linked labels and hatched estimates. No page of the sample draws it yet; the Atlas teaches it, and its preview draws every state from the sample's customers against the company. | `src/components/charts/diverging-bars` | `/system/patterns/diverging-bars` | none: no page draws it yet; the Atlas preview does | normal, empty, partial | `tests/diverging-bars.test.tsx`, `tests/chart-semantics.test.tsx`, `scripts/audit.ts` |
@@ -91,7 +91,7 @@ This is the inventory of the whole Meridian sample: every job the product is mea
91
91
  | A2 | Return to a filtered view without rebuilding it | none | L3 | `src/components/patterns/filter-bar`, `app/(dashboard)/requests` | defer | Nothing was built. The filtered Requests view is already a link, because the filters live in the address, and a bookmark meets the job today. A saved, named view needs per-person storage the sample does not have; it comes back when a record for a person's views exists. | none | none: nothing was built, so there is no entry to specify | none | none | none |
92
92
  | A3 | See tickets by state as columns | none | L4 | `src/components/templates/records` | defer | Nothing was built. The tickets queue (S6) is a table with a state column and a state change. A board needs drag between columns and ordering rules that the sample's mutation model does not define; it comes back if the queue shows that state grouping is wanted and the ordering is defined. | none | none: nothing was built, so there is no entry to specify | none | none | none |
93
93
  | A4 | Compare two things side by side (two releases, two customers) | none | L3 | `src/components/patterns/data-table`, `src/components/ui/key-value` | defer | Nothing was built. Key-value facts and tables show one thing per row; a comparison needs a second selected subject and a diff rule. No scenario in the product asks for it, and Releases already marks the live version beside each candidate (S8); it comes back with a scenario that names two subjects. | none | none: nothing was built, so there is no entry to specify | none | none | none |
94
- | A5 | Read data a basic trend cannot show | none | L3 | `src/components/charts/trend-chart`, `src/components/charts/heatmap` | defer | Nothing was built. The sample already has trend, column, heatmap, composition, bar list and uptime charts. A new chart type needs a question the existing ones cannot answer, and none was found in the inventory; it comes back when one is. | none | none: nothing was built, so there is no entry to specify | none | none | none |
94
+ | A5 | Read data a basic trend cannot show | none | L3 | `src/components/charts/trend-chart`, `src/components/charts/heatmap` | defer | No page was built for it. The sample has trend, column, heatmap, composition, bar list and uptime charts, and Diverging bars (issue #99) answers one question they could not, a signed change by category; it is a card, not a page. A further chart type needs a question none of them answers; it comes back when one is. | none | none: nothing was built, so there is no entry to specify | none | none | none |
95
95
  | A6 | Review and decide on a list of agent proposals | none | L4 | `src/components/patterns/proposal`, `app/embed/proposal` | defer | Nothing was built. The Proposal card and its embed handle one change at a time, with Approve and Dismiss. A queue page needs stored proposals and an owner per proposal, which the sample does not hold; it comes back, records-shaped, if proposals are stored. | none | none: nothing was built, so there is no entry to specify | none | none | none |
96
96
  | A7 | Read what needs attention across the product | the alerts panel — `src/components/patterns/shell-tools/index.tsx` | L3 | `src/components/patterns/shell-tools` | compose | The top bar has an alerts popover with a new-count dot and a link per alert. The alerts are worked out when the page is read from unresolved incidents, customers past due, failing verifications and high-priority tickets nobody owns, and each opens the page where it is handled: customers past due, failing verifications and unowned high-priority tickets open their list filtered to those records (a lone customer past due opens its own page), and an incident opens Services. The panel keeps no record of what a person has read: its dot marks an incident that is still open. Home's lines worth knowing across the company serve the larger job; sending notifications is not invented. A separate page is deferred in favour of S1. | `src/logic/alerts.ts`, `src/views/shell-alerts.tsx` | `/system/patterns/shell-tools` | Main (`/`), top bar | normal, empty | `tests/shell-alerts.test.ts` |
97
97
  | A8 | Finish first-run setup in a known order | none | L3 | `src/components/ui/empty-state`, `src/components/ui/progress` | defer | Nothing was built. The pages have first-run empty states that point to the next action (create a key, invite a member), and a Progress component exists. A checklist needs a sequence the product defines and no such sequence exists; it comes back when the product defines one. | none | none: nothing was built, so there is no entry to specify | none | none | none |
@@ -151,10 +151,10 @@ Two environment variables exist for the updater's own tests and are used only wh
151
151
  reports as `migration:<id>` because it cannot make them. An entry applies when the project's recorded version is older
152
152
  than its `since` and the target is not, and its `applies(root)` returns the paths that still have the old shape, or
153
153
  null. It only reads, and only team-owned files (the ownership rule's managed paths are skipped); a file it cannot read is
154
- absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark` and `table-grid`, `gate` alone), and its
154
+ absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark`, `table-grid`, `direct-query-escape`, `app-description` and `live-stream-shutdown`, `gate` alone; `missing-routes`, `verify`), and its
155
155
  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.
156
156
 
157
- Eighteen arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), `verify-tiers` (0.17.0), `no-data-mark` (0.19.0), `table-grid` (0.26.0) and `four-layers` (0.28.0), which moves thirty-eight modules into `src/logic/`. The ten of 0.5.0 are:
157
+ Twenty-three arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), `verify-tiers` (0.17.0), `no-data-mark` (0.19.0), `table-grid` (0.26.0), `four-layers` (0.28.0), which moves thirty-eight modules into `src/logic/`, `direct-query-escape` and `app-description` (0.30.0), `live-stream-shutdown` (0.31.1), `missing-routes` (0.32.0) and `self-hosted-fonts` (0.34.0). The ten of 0.5.0 are:
158
158
 
159
159
  | Id | Reported when a team-owned file still has | Completion step |
160
160
  |----|-------------------------------------------|-----------------|
@@ -128,8 +128,11 @@ async function press(page: Page, spec: Spec) {
128
128
  await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: at.x, y: at.y, button: 'left', clickCount: 1 });
129
129
  }
130
130
 
131
- const key = (page: Page, k: string, code: string, vk: number, text?: string) => page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, ...(text ? { text, unmodifiedText: text } : {}) })
132
- .then(() => page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk }));
131
+ const key = (page: Page, k: string, code: string, vk: number, text?: string, modifiers?: number) => {
132
+ const mods = modifiers ? { modifiers } : {};
133
+ return page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk, ...mods, ...(text ? { text, unmodifiedText: text } : {}) })
134
+ .then(() => page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk, ...mods }));
135
+ };
133
136
 
134
137
  /** Type into the focused field, one key at a time. */
135
138
  async function type(page: Page, s: string) {
@@ -169,7 +172,54 @@ const ownerOf = (path: string): string | null => {
169
172
 
170
173
  /** A person's routes: the ones the verification configuration says they may open, written there by hand. */
171
174
  const routesOf = (name: string) => [...persona(name).expectedRoutes];
172
- const railHrefs = (ws: string, routes: string[]) => items.filter((i) => i.workspace === ws && routes.includes(i.href)).map((i) => i.href);
175
+
176
+ /** The held items of one navigation group in a workspace, for a person who may open `routes`. */
177
+ const heldIn = (g: (typeof nav)[number], ws: string, routes: string[]) => g.items.filter((i) => i.workspace === ws && routes.includes(i.href));
178
+ /**
179
+ * The parent a held item hangs under, as the rail reads it: the held item of the same group that `under` names, when that
180
+ * is not itself a sub page; otherwise null and the item stands at the top level.
181
+ */
182
+ const parentIn = (held: (typeof items)[number][], it: (typeof items)[number]): string | null => {
183
+ const under = 'under' in it && typeof it.under === 'string' ? it.under : undefined;
184
+ const parent = under === undefined ? undefined : held.find((p) => p.href === under && p !== it);
185
+ if (!parent) return null;
186
+ const grand = 'under' in parent && typeof parent.under === 'string' ? parent.under : undefined;
187
+ return grand !== undefined && held.some((x) => x.href === grand && x !== parent) ? null : parent.href;
188
+ };
189
+ /** The parent a destination hangs under for a person who may open `routes`; null when it stands at the top level. */
190
+ const parentOfHref = (href: string, routes: string[]) => {
191
+ const g = nav.find((x) => x.items.some((i) => i.href === href));
192
+ const it = g?.items.find((i) => i.href === href);
193
+ if (!g || !it?.workspace) return null;
194
+ return parentIn(heldIn(g, it.workspace, routes), it);
195
+ };
196
+
197
+ /**
198
+ * The parents whose branch each page has open, as the rail keeps its own: every full load empties it, arriving at a page
199
+ * opens the branch that holds its current entry, pressing a branch's button toggles it, and a client navigation leaves it.
200
+ */
201
+ const branches = new WeakMap<Page, Set<string>>();
202
+ const openIn = (page: Page) => { let s = branches.get(page); if (!s) branches.set(page, (s = new Set())); return s; };
203
+ const loaded = (page: Page) => openIn(page).clear();
204
+
205
+ /**
206
+ * What the rail draws for a workspace, in the order of the page: each group's top-level destinations, and after a parent
207
+ * its sub pages when its branch is open. A sub page whose parent is not held stands at the top level.
208
+ */
209
+ const railHrefs = (ws: string, routes: string[], open: Set<string>) =>
210
+ nav.flatMap((g) => {
211
+ const held = heldIn(g, ws, routes);
212
+ return held.filter((i) => !parentIn(held, i)).flatMap((i) => [i.href, ...(open.has(i.href) ? held.filter((s) => parentIn(held, s) === i.href).map((s) => s.href) : [])]);
213
+ });
214
+ /** The branch that holds the address's current entry: the parent whose own page it is, or whose sub page it is. */
215
+ const branchOf = (ws: string, routes: string[], path: string) => {
216
+ const all = nav.flatMap((g) => heldIn(g, ws, routes));
217
+ const cur = currentIn(path, all.map((i) => i.href));
218
+ if (cur === null) return null;
219
+ const parent = parentOfHref(cur, routes);
220
+ if (parent) return parent;
221
+ return nav.some((g) => { const held = heldIn(g, ws, routes); return held.some((s) => parentIn(held, s) === cur); }) ? cur : null;
222
+ };
173
223
  const currentIn = (path: string, hrefs: string[]) => hrefs.filter((h) => (h === '/' ? path === '/' : path === h || path.startsWith(`${h}/`))).sort((a, b) => b.length - a.length)[0] ?? null;
174
224
 
175
225
  /**
@@ -179,19 +229,25 @@ const currentIn = (path: string, hrefs: string[]) => hrefs.filter((h) => (h ===
179
229
  */
180
230
  async function settle(page: Page, who: string, path: string | RegExp, ws?: string) {
181
231
  const routes = routesOf(who);
232
+ const open = openIn(page);
182
233
  let found = 'nothing read';
183
234
  let expected = '';
184
235
  try {
185
236
  await until('', async () => {
186
237
  const r = await railOf(page);
187
238
  const owner = ws ?? ownerOf(r.path) ?? 'main';
188
- const hrefs = railHrefs(owner, routes);
189
- const cur = currentIn(r.path, hrefs);
239
+ const at = path instanceof RegExp ? path.test(r.path) : r.path === path;
240
+ // Arriving at a page opens the branch that holds its entry.
241
+ const arrival = at ? branchOf(owner, routes, r.path) : null;
242
+ if (arrival) open.add(arrival);
243
+ const hrefs = railHrefs(owner, routes, open);
244
+ // The current entry is the longest held one; when its branch is closed it is not drawn, and the rail marks nothing as the page.
245
+ const entry = currentIn(r.path, nav.flatMap((g) => heldIn(g, owner, routes).map((i) => i.href)));
246
+ const cur = entry !== null && hrefs.includes(entry) ? entry : null;
190
247
  expected = `${path} with the rail on "${W[owner].label}" showing ${hrefs.join(' ')} (current ${cur})`;
191
248
  const seen = r.links?.map((l) => l.href) ?? null;
192
249
  const now = r.links?.find((l) => l.current)?.href ?? null;
193
250
  found = `${r.path}${r.search} with the rail on "${railWorkspace(r)}" showing ${seen?.join(' ') ?? 'no links'} (current ${now})`;
194
- const at = path instanceof RegExp ? path.test(r.path) : r.path === path;
195
251
  return at && railWorkspace(r) === W[owner].label && seen?.join(' ') === hrefs.join(' ') && now === cur;
196
252
  });
197
253
  } catch {
@@ -200,6 +256,21 @@ async function settle(page: Page, who: string, path: string | RegExp, ws?: strin
200
256
  return (await railOf(page)).path;
201
257
  }
202
258
 
259
+ /**
260
+ * Press a destination's link in the rail. A sub page's link is not drawn while its parent's branch is closed, so a person
261
+ * first presses the parent's `<label> pages` button, and the branch is then open.
262
+ */
263
+ async function pressRail(page: Page, who: string, href: string) {
264
+ const link: Spec = { role: 'link', name: labelOf(href), within: 'nav[aria-label="Main"]' };
265
+ const parent = parentOfHref(href, routesOf(who));
266
+ if (parent && !(await ask<boolean>(page, `return find(${JSON.stringify(link)}).length > 0;`))) {
267
+ await press(page, { role: 'button', name: `${labelOf(parent)} pages`, within: 'nav[aria-label="Main"]' });
268
+ const open = openIn(page);
269
+ if (!open.delete(parent)) open.add(parent);
270
+ }
271
+ await press(page, link);
272
+ }
273
+
203
274
  /** Choose a workspace in the rail's menu, as a person does: open the menu, press the row. */
204
275
  async function choose(page: Page, id: string) {
205
276
  await press(page, { role: 'button', re: `${MENU_END}$` });
@@ -216,6 +287,7 @@ const opened: Page[] = [];
216
287
  const browser = async () => { const p = await launch(); opened.push(p); return p; };
217
288
  const go = async (page: Page, who: string, path: string) => {
218
289
  quiet(page, 'the page before');
290
+ loaded(page);
219
291
  await page.open(base + path, { width: 1440, height: 900, theme: 'dark', wait: 300, cookie: persona(who).cookie });
220
292
  };
221
293
 
@@ -336,8 +408,34 @@ if (import.meta.main) {
336
408
  // Home's Business card opens the Business home; the rail then names Business and offers Requests.
337
409
  await press(a, { role: 'link', hrefRe: '^/business$', within: '[role="region"][aria-label="Business"]' });
338
410
  await settle(a, 'member', '/business', 'business');
339
- await press(a, { role: 'link', name: labelOf(REQUESTS), within: 'nav[aria-label="Main"]' });
411
+ await pressRail(a, 'member', REQUESTS);
340
412
  await settle(a, 'member', REQUESTS, 'business');
413
+ // The masthead's trail names the parent as a link, then the page as the current one; the palette hints the parent.
414
+ const parentHref = parentOfHref(REQUESTS, routesOf('member'));
415
+ const parentLabel = parentHref === null ? null : labelOf(parentHref);
416
+ const groupLabel = nav.find((g) => g.items.some((i) => i.href === REQUESTS))?.label ?? '';
417
+ const label = labelOf(REQUESTS);
418
+ const crumbs = await ask<{ links: { href: string; label: string }[]; current: string | null }>(a, `
419
+ const bc = document.querySelector('nav[aria-label="Breadcrumb"]');
420
+ if (!bc) return { links: [], current: null };
421
+ const cur = bc.querySelector('[aria-current="page"]');
422
+ return { links: [...bc.querySelectorAll('a[href]')].map((l) => ({ href: l.getAttribute('href'), label: text(l) })), current: cur ? text(cur) : null };`);
423
+ const wantLinks = parentHref === null ? [] : [{ href: parentHref, label: parentLabel }];
424
+ if (JSON.stringify(crumbs.links) !== JSON.stringify(wantLinks) || crumbs.current !== label) {
425
+ throw new Error(`expected the Breadcrumb to hold ${JSON.stringify(wantLinks)} and then "${label}" as the current page; found links ${JSON.stringify(crumbs.links)} and current ${JSON.stringify(crumbs.current)}`);
426
+ }
427
+ // The palette listens on window for Ctrl or Command with K; a button would be ambiguous, the not-found view has a second one.
428
+ await key(a, 'k', 'KeyK', 75, undefined, 2);
429
+ await until('the palette search field', () => centre(a, { role: 'combobox', name: 'Search pages and commands' }));
430
+ await type(a, label);
431
+ const hint = await until(`the palette option "${label}"`, () => ask<{ hint: string | null } | null>(a, `
432
+ const row = [...document.querySelectorAll('[role="option"]')].find((o) => [...o.querySelectorAll('span[title]')].some((t) => t.getAttribute('title') === ${JSON.stringify(label)}));
433
+ if (!row) return null;
434
+ const h = row.querySelector('span.text-xs');
435
+ return { hint: h ? text(h) : null };`));
436
+ const wantHint = parentLabel ?? groupLabel;
437
+ if (hint.hint !== wantHint) throw new Error(`expected the palette option "${label}" to carry the hint "${wantHint}"; found ${JSON.stringify(hint.hint)}`);
438
+ await key(a, 'Escape', 'Escape', 27);
341
439
  await press(a, { role: 'link', hrefRe: '^/requests/[^/]+$', within: 'main table tbody' });
342
440
  const at = await settle(a, 'member', /^\/requests\/[^/]+$/, 'business');
343
441
  return `(${at})`;
@@ -400,13 +498,14 @@ if (import.meta.main) {
400
498
  await settle(a, 'member', HOME, 'main');
401
499
  await choose(a, 'business');
402
500
  await settle(a, 'member', W.business.homeHref, 'business');
403
- await press(a, { role: 'link', name: labelOf(REQUESTS), within: 'nav[aria-label="Main"]' });
501
+ await pressRail(a, 'member', REQUESTS);
404
502
  await settle(a, 'member', REQUESTS, 'business');
405
503
  await press(a, { role: 'link', hrefRe: '^/requests/[^/]+$', within: 'main table tbody' });
406
504
  const request = await settle(a, 'member', /^\/requests\/[^/]+$/, 'business');
407
505
  // A refresh: the page is marked first, so that only the new document can satisfy the wait.
408
506
  await a.eval('window.__before = true');
409
507
  await a.send('Page.reload');
508
+ loaded(a);
410
509
  await until('the page to reload', () => a.eval<boolean>('window.__before === undefined && document.readyState !== "loading"'));
411
510
  await settle(a, 'member', request, 'business');
412
511
  for (const path of trail) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -23,12 +23,15 @@
23
23
  * `drift:` line, a warning the gate carries: nothing else says so between releases.
24
24
  * - Every nav item's `needs` has the shape the rail can read: 'public', a grant `object:read|create|update|remove`, or an
25
25
  * `{ allOf: [...] }` of them.
26
+ * - Every nav item's `under` names an item of its own group that is not itself under another, in the same workspace, and
27
+ * no item has more than seven sub pages: the rail draws three levels and nothing it cannot draw.
26
28
  */
27
29
  import { createHash } from 'node:crypto';
28
30
  import fs from 'node:fs';
29
31
  import path from 'node:path';
30
32
  import { importTarget, projectAliases } from './lib/aliases.ts';
31
33
  import { checkBrief, scanReferences } from './lib/context-check.ts';
34
+ import { navUnderProblems } from './lib/nav-under.ts';
32
35
  import { blankedText, noDirectQuery, presentationDoesNotRead, resolveSpec as resolveProjectSpec, valueImports } from './lib/layer-rules.ts';
33
36
  import { APP_DIR, agentPageSpec, agentSection, specRoute, viewToolPages } from './lib/routes.ts';
34
37
  import { keepProblems, unresolved, parseJournal, parseKeep, parseResolutions, verifiedRetirements, type Hash, type Retirement } from './lib/update-session.ts';
@@ -360,6 +363,13 @@ for (const item of nav.flatMap((g) => g.items)) {
360
363
  if (why) problems.push(`src/app.config.ts: nav item ${item.href} ${why}`);
361
364
  }
362
365
 
366
+ // ── A sub page names a parent the rail can draw ───────────────────────────────────────────────────────
367
+ // A nav item may name its parent with `under`, and the rail draws it as one list nested under that parent. An `under`
368
+ // that names nothing of its group, the item itself, an item that is already a sub page (a fourth level), or an item of
369
+ // another workspace has no place in that list, so the page would vanish from the rail or hang in the wrong one. A parent
370
+ // with more than seven sub pages is a group of its own. The rule is pure and lives in scripts/lib/nav-under.ts.
371
+ problems.push(...navUnderProblems(nav));
372
+
363
373
  // ── The update session, the keep register and the logo ────────────────────────────────────────────────
364
374
  // A session under .meridian/update/ must be resolved before the gate passes. With one ready, its candidate manifest
365
375
  // stands in for the original: the files it will own are what keep, retirements and the dormant-code sweep read.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The gate's rule for a sub page: a nav item that names its parent with `under`. The rail draws one list nested under
3
+ * the parent, so a navigation it cannot draw is refused here. Pure, and imports nothing from the project, so a test can
4
+ * feed it navigations of its own.
5
+ */
6
+ type Item = { href: string; workspace?: string; under?: string };
7
+ type Group = { label?: string; items: Item[] };
8
+
9
+ const MAX_SUB_PAGES = 7;
10
+
11
+ /** The `under` an item names, or undefined: a project whose `NavItem` has no such field passes. */
12
+ const underOf = (it: Item): string | undefined => ('under' in it && typeof it.under === 'string' ? it.under : undefined);
13
+
14
+ /** One line per refused item in item order, then per group the parents with too many sub pages. */
15
+ export function navUnderProblems(groups: Group[]): string[] {
16
+ const out: string[] = [];
17
+ const at = (href: string) => `src/app.config.ts: nav item ${href}`;
18
+ for (const g of groups) {
19
+ const byHref = new Map(g.items.map((it) => [it.href, it]));
20
+ for (const it of g.items) {
21
+ const under = underOf(it);
22
+ if (under === undefined) continue;
23
+ const parent = byHref.get(under);
24
+ if (under === it.href) out.push(`${at(it.href)} is under itself`);
25
+ else if (!parent) out.push(`${at(it.href)} is under ${under}, which is no item of its group`);
26
+ else if (underOf(parent) !== undefined) out.push(`${at(it.href)} is under ${under}, which is itself under ${underOf(parent)}; the rail has three levels, never four`);
27
+ else if (it.workspace !== parent.workspace) out.push(`${at(it.href)} is in workspace ${it.workspace ?? 'none'} under ${under}, which is in workspace ${parent.workspace ?? 'none'}`);
28
+ }
29
+ for (const it of g.items) {
30
+ const n = g.items.filter((s) => underOf(s) === it.href).length;
31
+ if (n > MAX_SUB_PAGES) out.push(`${at(it.href)} has ${n} sub pages; at most ${MAX_SUB_PAGES}, past that the item is a group of its own`);
32
+ }
33
+ }
34
+ return out;
35
+ }
@@ -67,6 +67,23 @@ timezone, currency and the signed-in user the rail shows (all but the descriptio
67
67
  palette: groups of `{ href, label, icon, needs }`, plus a `workspace` while the workspaces stay (icons from `lucide-react`, 1.75 stroke is applied by the rail). A badge
68
68
  is a short string, used only for something that needs attention ("1").
69
69
 
70
+ **Sub pages.** An item may be a sub page of another item of the same group: give it `under`, the `href` of its parent.
71
+ The rail draws the parent as its link plus a button named `<label> pages`, and the sub pages in a list under it; the
72
+ branch is open on arrival when the current page is the parent or one of its sub pages. Nothing else reads a hierarchy:
73
+ every module that reads `nav` still sees a flat list of items, each with its own `needs` and `workspace`, and a
74
+ navigation with no `under` draws as before. `node scripts/check.ts` refuses, naming the item's `href`:
75
+
76
+ - an `under` that names no item of the same group;
77
+ - an `under` that names the item itself;
78
+ - an `under` that names an item which itself has an `under` (three levels, never four);
79
+ - a sub page whose `workspace` differs from its parent's, where a declared workspace and an undeclared one count as differing;
80
+ - more than 7 sub pages under one parent (past that, the item is a group of its own).
81
+
82
+ Use `under` for destinations: pages with their own address, access and place in the palette. Use in-page tabs (`Tabs`)
83
+ instead for the views of one record, under one header. For a sub page's masthead to show its path, pass
84
+ `<Breadcrumb items={navTrail(href, nav)} />` (from `@/components/patterns/rail/trail`) as the kicker; the crumbs are
85
+ derived from `nav`, never written out.
86
+
70
87
  `needs` is required, and in a created project it is a feature's own need **by reference** — `needs: FEATURES.home.needs`,
71
88
  from `optional:src/data/features.ts` — never a grant written a second time: the rail and the palette draw only the
72
89
  destinations the signed-in person satisfies, and a test fails an item that restates a need. That filtering is
@@ -193,6 +193,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
193
193
  | `four-layers` (0.28.0) | a file of the team's importing a module that has left `optional:src/views/` or `optional:src/data/` for `src/logic/` — the migration names the file and, in its instruction, every path the module left — or importing `afterStart` from `src/components/patterns/incident-card` or `summarise` from `src/components/patterns/status-list`, which no longer export them | thirty-eight modules are in `src/logic/` since 0.28.0, thirty-six of them from `optional:src/views/`, and each import is rewritten to the path the module has: `afterStart` is `src/logic/after-start.ts` and `summarise` is `src/logic/summarise.ts`, and those two must move because the cards are Meridian's and the update replaces them, while every path under `optional:src/views/` and `optional:src/data/` is the team's and stays as it is; a created project brings the release's `src/logic/` over with the pages that import it; the standard the layers come from is `data-layers.md`; checked by the gate and a build |
194
194
  | `live-stream-shutdown` (0.31.1) | `optional:src/data/live-stream.ts` that never ends its streams when the server is told to stop | a stopped `next start` closes its port and waits for its open connections, so the old process kept every tab's stream while the new one served the port and no tab heard a hint again until its next poll: bring the block that begins `Every stream still open` over from `live.md`, delete the stream from the open set first in its `close`, and register it after it listens for its abort; checked by the gate |
195
195
  | `missing-routes` (0.32.0) | `scripts/verify.config.ts` that lists `detailRoutes` and no `missingRoutes` | every browser suite now fails a route that answered an error or draws the not-found screen (`[data-missing-page]`), since it measured that screen and not the route: move each detail route that names a record that does not exist into `missingRoutes`, and give a not-found screen of your own `data-missing-page` on its root; checked by verify |
196
+ | `self-hosted-fonts` (0.34.0) | a file under `app/` that imports `next/font/google` and names `--font-face-sans` or `--font-face-mono` | the faces ship under `src/styles/fonts/` and an update delivered them: load each with `next/font/local` (`src: '../src/styles/fonts/Geist-Variable.woff2'` and `'../src/styles/fonts/GeistMono-Variable.woff2'`, `weight: '100 900'`, the same `variable`), so `next build` fetches nothing from Google Fonts and cannot fail when that host is unreachable; checked by the gate and the build |
196
197
 
197
198
  A created project that never changed the template's pages brings the release's version of each affected file over, with
198
199
  the files that arrived with them: the view contexts `optional:src/logic/home-context.ts` and its siblings, the view
@@ -52,8 +52,10 @@ export type WorkspaceId = keyof typeof workspaces;
52
52
  * `prefetch` asks for the item's page to be prefetched in full, its URL data with it, where Next's default prefetches the
53
53
  * route's App Shell only. It is for a destination whose content is its URL data (a document page under a dynamic
54
54
  * segment), and every visible such link makes a request of its own, so it is for one or two items.
55
+ *
56
+ * `under` is the href of an item in the same group that this item is a sub page of.
55
57
  */
56
- export type NavItem = { href: string; label: string; icon: LucideIcon; badge?: string; workspace?: WorkspaceId; needs: Need; feature?: string; prefetch?: true };
58
+ export type NavItem = { href: string; label: string; icon: LucideIcon; badge?: string; workspace?: WorkspaceId; needs: Need; feature?: string; prefetch?: true; under?: string };
57
59
  export type NavGroup = { label?: string; items: NavItem[] };
58
60
 
59
61
  export const nav: NavGroup[] = [
@@ -67,11 +69,17 @@ export const nav: NavGroup[] = [
67
69
  ],
68
70
  },
69
71
  {
72
+ label: 'Accounts',
70
73
  items: [
71
74
  { href: '/business', label: 'Revenue', icon: CircleDollarSign, workspace: 'business', needs: FEATURES.revenue.needs },
72
75
  { href: '/customers', label: 'Customers', icon: Users, workspace: 'business', needs: FEATURES.customers.needs },
76
+ ],
77
+ },
78
+ {
79
+ label: 'Traffic',
80
+ items: [
73
81
  { href: '/usage', label: 'Usage', icon: BarChart3, workspace: 'business', needs: FEATURES.usage.needs },
74
- { href: '/requests', label: 'Requests', icon: Activity, workspace: 'business', needs: FEATURES.requests.needs },
82
+ { href: '/requests', label: 'Requests', icon: Activity, workspace: 'business', needs: FEATURES.requests.needs, under: '/usage' },
75
83
  ],
76
84
  },
77
85
  {
@@ -490,7 +490,7 @@ export function PageFrame({
490
490
  {name === undefined ? (
491
491
  <div className="flex flex-wrap items-end gap-x-8 gap-y-5">
492
492
  <div className="min-w-0 flex-1 basis-[28rem]">
493
- {kicker ? <p className="t-kicker mb-4">{kicker}</p> : null}
493
+ {kicker ? <div className="t-kicker mb-4">{kicker}</div> : null}
494
494
  <PageTitle ref={head} title={title} />
495
495
  {description ? <p className="t-lead mt-3 max-w-[60ch]">{description}</p> : null}
496
496
  </div>
@@ -503,7 +503,7 @@ export function PageFrame({
503
503
  state has none, so the block kept two lines while the page loaded and three once it had, and
504
504
  everything under it dropped. Now its width follows the viewport, the rail and the assistant alone. */}
505
505
  <div>
506
- {kicker ? <p className="t-kicker mb-4">{kicker}</p> : null}
506
+ {kicker ? <div className="t-kicker mb-4">{kicker}</div> : null}
507
507
  <PageTitle ref={head} name={name} title={title} speaksName={speaksName} />
508
508
  </div>
509
509
  {/* The description grows, so the meta and the actions end the row on its last line; with no description
@@ -9,7 +9,7 @@ Status: beta
9
9
  1. **Row**: label on the left (`text-sm`, `ink-2`; the highlighted row `ink` at `weight-medium`), an optional meta line, the value on the right (`ink`, tabular, `weight-medium`).
10
10
  2. **Track**: 6px tall, `fill-track`, `radius-full`.
11
11
  3. **Bar**: the value against the scale's top (the largest row, `total`, `max` or the axis's last level), `chart-neutral`; the one highlighted row in `accent`, and its row's title adds `highlightLabel`, what makes it the one ("The busiest endpoint"), so the accent is never a colour left to guess.
12
- 4. **Others**: past `limit`, the remaining rows fold into "N others", summed, in `ink-3`; a single extra row shows as itself, since "1 others" would hide a name to save no space. It is never a link, whatever the rows it holds.
12
+ 4. **Others**: past `limit`, the remaining rows fold into "N others", the sum of those above zero, in `ink-3`; a single extra row shows as itself, since "1 others" would hide a name to save no space. It is never a link, whatever the rows it holds.
13
13
  5. **Linked row**: a row with an `href` names a record that has a page, and its label is the one link to it, named by the label. The bar, the meta line and the figure stay text: they say what the label already opens, so they are never a second way there. At rest the row looks as an unlinked one does.
14
14
  6. **Compare** (under `pair`): 2px under the value bar, the row's `compare` on the same scale, a 1px `chart-neutral-strong` outline with no fill; none for a missing compare. The figure reads `{value} · {compare}`, the compare in `ink-3`, `—` for one with no data.
15
15
  7. **Legend** (under `pair`): above the rows, a filled and an outlined swatch named `pair.label` and `pair.compareLabel`, at `text-xs` in `ink-2`.
@@ -44,6 +44,7 @@ Rows are 14px apart; label to bar 6px. The list takes its container's width.
44
44
  | Hover (a linked row's label) | The label takes `accent-ink` over `dur-hover`, and the bar steps up as on any row. |
45
45
  | Focus (a linked row) | The label's link shows the 2px `accent` ring. |
46
46
  | Empty | Render the card's Empty state. |
47
+ | Below zero | A row below zero draws an empty bar (and an empty outline under `pair`), its figure the value it holds and its share of `total` `0.0%`: a Bar list measures from zero, and signed rows are Diverging bars. |
47
48
 
48
49
  ## Behaviour
49
50