zz-meridian 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/CHANGELOG.md +22 -0
- package/payload/CONTRIBUTING.md +1 -0
- package/payload/README.md +1 -1
- package/payload/app/(dashboard)/_loading.tsx +5 -4
- package/payload/app/(dashboard)/customers/README.md +1 -1
- package/payload/app/(dashboard)/keys/README.md +1 -1
- package/payload/app/(dashboard)/members/README.md +1 -1
- package/payload/app/(dashboard)/settings/README.md +2 -2
- package/payload/app/(dashboard)/settings/loading.tsx +1 -1
- package/payload/app/(dashboard)/settings/page.tsx +1 -0
- package/payload/app/not-found/README.md +1 -1
- package/payload/decisions/0013-a-form-page-has-a-measure.md +49 -0
- package/payload/docs/benchmark.md +2 -2
- package/payload/docs/start-a-dashboard.md +1 -1
- package/payload/docs/surfaces.md +3 -3
- package/payload/package.json +1 -1
- package/payload/scripts/assistant.ts +18 -3
- package/payload/scripts/audit.ts +22 -5
- package/payload/scripts/check.ts +1 -1
- package/payload/scripts/verify.config.ts +6 -1
- package/payload/skills/zz-meridian/references/customize.md +5 -1
- package/payload/src/components/base/motion/README.md +7 -6
- package/payload/src/components/base/motion/preview.tsx +8 -1
- package/payload/src/components/base/shell/README.md +6 -2
- package/payload/src/components/base/shell/index.tsx +4 -3
- package/payload/src/components/base/shell/preview.tsx +4 -1
- package/payload/src/components/base/surface/README.md +1 -1
- package/payload/src/components/charts/trend-chart/preview.tsx +1 -1
- package/payload/src/components/patterns/data-table/README.md +1 -1
- package/payload/src/components/patterns/form-section/README.md +3 -3
- package/payload/src/components/patterns/form-section/index.tsx +1 -1
- package/payload/src/components/patterns/freshness/README.md +1 -1
- package/payload/src/components/patterns/rail/README.md +2 -2
- package/payload/src/components/patterns/rail/index.tsx +18 -15
- package/payload/src/components/patterns/rail/preview.tsx +31 -12
- package/payload/src/components/ui/button/README.md +2 -2
- package/payload/src/components/ui/button/index.tsx +7 -3
- package/payload/src/components/ui/button/preview.tsx +3 -2
- package/payload/src/components/ui/field/README.md +1 -1
- package/payload/src/components/ui/input/preview.tsx +1 -1
- package/payload/src/components/ui/menu/README.md +1 -1
- package/payload/src/components/ui/popover/README.md +1 -1
- package/payload/src/components/ui/search-input/README.md +1 -1
- package/payload/src/components/ui/select/README.md +2 -2
- package/payload/src/components/ui/spinner/README.md +5 -1
- package/payload/src/components/ui/spinner/index.tsx +8 -7
- package/payload/src/components/ui/spinner/preview.tsx +2 -2
- package/payload/src/components/ui/status-dot/README.md +1 -1
- package/payload/src/components/ui/tooltip/README.md +1 -1
- package/payload/src/data/member-mutations.ts +22 -6
- package/payload/src/styles/motion.css +2 -2
- package/payload/src/styles/tokens.css +2 -0
- package/payload/src/system/content.ts +3 -2
- package/payload/src/system/token-view.tsx +84 -2
- package/payload/src/views/members.tsx +1 -1
- package/payload/tokens/core.tokens.json +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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.14.0] · 2026-10-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **A form page has a width of its own, and the token layer documents the layout.** `tokens/core.tokens.json` gains `field-width` (64rem: the widest a form's card may be, in rem so the measure grows with the reader's text size) and `form-width` (a form page: its head column, the column gap, a `field-width` card and the page's gutter — written as `calc()` over the parts, so it holds at any root size). `PageFrame` gains `width="form"` and Settings is the page that uses it: the page fills the canvas until the canvas is wider than a form needs, then centres, so the remainder splits evenly instead of leaving the canvas's right side empty — visible on a window wider than about 1470px (with the card capped at 896px, the empty band beside it was about 430px at 1920px and about 1050px at 2560px, measured from the card's right edge to the page's content edge), invisible at 1440px and below. The card's own measure is unchanged (`FormSection`'s `64rem` is now `var(--field-width)`), and a field still never stretches across a wide screen. `LoadingPage` takes the same `width`, and Settings' `loading.tsx` passes it, so the skeleton stands where the page will be instead of moving it when the page arrives. `reading-width`'s description said it was for "forms, documents and settings", which nothing did; it now says what it is — one long document — and the Atlas has a seventh token view, **Layout**, drawing every width role to scale in one place. Additive apart from that description: a product that passes no `width` renders exactly as before.
|
|
10
|
+
|
|
11
|
+
- **The token layer documents the groups it never showed, and the specs stop contradicting it.** Three of the eight core groups had no view in the Atlas at all, so a reader looking for how high a dialog sits or what the rail's glass does found nothing: `Elevation` now carries **Stacking** (the six `layer-*` tokens in order, drawn) and **Glass** (both blurs, over something moving), and the token layer's own prose is corrected — `motion`'s opening said "four durations" where the file has six plus three loops, `float-in` said it leaves over 120ms where the CSS uses `dur-exit` 140ms (the same claim stood in Popover, Menu, Select and Tooltip), and three cards said the system allows two loops where a Spinner's ring is a third. Nothing a product renders changes: every one of these is a description, a spec or an Atlas view.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`audit.ts --atlas` also measures the Atlas's other entries.** The token views, the page specs and the guides live under `[section]/[id]`, so `discover()` skips them as dynamic segments and `--atlas` read only the card registry: a defect in a token view or a guide failed no suite at all. It now reads the entries through the module the Atlas renders from, so a new one is measured without being listed. Measured: 0 issues across the seven entries checked (the token views, two page specs, a guide and the changelog) at five widths in both themes.
|
|
16
|
+
- **The Members table fits its frame again, and the assistant walk-through passes every step.** `Joined` dropped a column too late (the table's own 672px, not 896): under it the table measured 759px in a 713px frame, so the audit failed `/members` at two widths in both themes — a table wider than its card is clipped, not scrolled — and with the assistant panel open the actions column sat outside the card, where a real click cannot reach it and the walk-through's row-menu step timed out. Found by `pnpm verify --full` (four audit issues) and by the walk-through, one cause behind both.
|
|
17
|
+
- **`scripts/assistant.ts` says when a control has no box.** A control hidden by a container query answers `getBoundingClientRect()` with 0x0, so the harness clicked the page's top-left corner and reported a timeout somewhere else; it now fails with "the control is in the DOM but has no box at 0x0 — hidden at this width?", which is what another session needed twenty minutes to work out.
|
|
18
|
+
- **An agent's approved change to a member no longer fails in a built app.** `src/data/member-mutations.ts` invalidated its tags with `updateTag`, which Next allows **only in a Server Action** — so the assistant's route handler and an adopter's MCP server, the two callers the module's own doc names, threw `updateTag can only be called from within a Server Action` the moment a change was applied: the Proposal was approved, the rows never moved, and the app answered with an error. The boundary now invalidates with `updateTag` where it is legal and `revalidateTag` where it is not, so a page action keeps its read-your-writes and an agent's write reaches the page. Found by the assistant walk-through, which timed out waiting for the rows to read Suspended — a suite that had not run since the member boundary landed (`585349a`, after the last full run).
|
|
19
|
+
- **A busy button is drawn as working, at the size of its own icon.** It was drawn at the same 45% opacity as one nobody may press, so it read as unavailable while it was working — the spec said only a disabled button greys out, and the code did not agree. It keeps its colour and its shadow now, with the spinner as the answer. The spinner was also always 14px, so a `lg` or `md` button that carries an icon moved by 4px or 2px the moment it went busy — the one case `busy` exists to make steady (Replay on a failed request, for instance). The Button now composes `Spinner` instead of drawing a second ring of its own, and the Spinner's third step is 18px rather than 20px, so its three steps are the icon scale's (14, 16, 18) and one stands in for the other exactly. `Spinner` gains a decorative form (`label={null}`) for the case where the control announces itself — a busy button's accessible name is unchanged, which a check confirmed — and the spec says plainly that a button with no icon gains the slot and widens by it.
|
|
20
|
+
|
|
21
|
+
- **Specimens and specs were sizing controls from the retired scale, and three page specs quoted the window's breakpoints for a table rule.** Five files still drew controls at 30/36/44 and 26/30/36 where the tokens are 32/38/46 and 28/32/38, and Members, Customers and API keys said their columns drop at 768/1024/1280 — the window's breakpoints — where `hideBelow` reads the table's own 672/896/1152; the four thresholds are now stated once, in the Table card. A suspended persona in the rail's specimen used the disabled ink without saying it was disabled, which the audit measured at 1.38:1 in dark and 2.31:1 in light; it is now a row with `aria-disabled` and the words "Not active", as the product draws one.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **With one workspace or none, the rail's row is the account menu, not a switcher.** A product that passes no `scopes` still opened a menu headed `Workspace` holding a single disabled, checked row — a chevron that promises a choice, opening a list with no choice in it (#20). The row now says `account menu` to a screen reader and carries a plain `ChevronDown` unless more than one scope is passed, and the menu renders no `Show` group and no checked row of its own unless there is something to switch between. A product passing several scopes is unchanged: the `Show` group, the radio items and the switcher glyph are as they were. The card's preview draws both states, and `README.md` states the rule.
|
|
26
|
+
|
|
5
27
|
## [0.13.0] · 2026-10-11
|
|
6
28
|
|
|
7
29
|
### Added
|
package/payload/CONTRIBUTING.md
CHANGED
|
@@ -85,6 +85,7 @@ Write the status on the line under the title: `Status: beta`.
|
|
|
85
85
|
| `pnpm tokens` | Generates the token CSS and the Tailwind bridge from `tokens/` (`--check` only compares) | After any token change |
|
|
86
86
|
| `node scripts/contrast.ts [--all]` | Every specified foreground on its background, in every theme and accent, and the chart palette's colour-vision checks | Gate |
|
|
87
87
|
| `node scripts/registry.ts` | Regenerates the Atlas registry from the card folders (`--check` only compares) | After adding or renaming a card |
|
|
88
|
+
| `rm -rf .next` before the next build, after editing a card's `README.md` | A production build in a working `.next` can serve the prerendered copy of the old text while the client hydrates the new one, which React reports as error #418 on that Atlas page. `pnpm dev` has no such copy, and a fresh clone (CI, a deployment) has none either | After changing what a card's page says |
|
|
88
89
|
| `node scripts/check.ts` | Every card has its README and preview, every README follows the anatomy, no literal colour in components, every token a spec names exists and is written with its own value, every embed view a document offers is a route, and an element that claims a control's role has a key handler | Gate |
|
|
89
90
|
| `pnpm typecheck`, `pnpm test` | TypeScript and the behaviour tests | Gate |
|
|
90
91
|
| `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, a control with no accessible name, more than one page scroller, rendered text under its WCAG minimum, 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 |
|
package/payload/README.md
CHANGED
|
@@ -16,7 +16,7 @@ These govern every layer. When two choices both fit the specs, pick the one that
|
|
|
16
16
|
|
|
17
17
|
**Numbers are exact and honest.** A missing value reads as a dash, never as zero. Every figure says its period and its unit; every chart has a table behind it; a stale feed says it is stale. Large figures are set tight, with the unit and the fraction stepped down so the number reads first.
|
|
18
18
|
|
|
19
|
-
**Calm, then precise.** The ground is quiet and lit; motion explains a change and stops. Data arrives once, in reading order; nothing loops but a
|
|
19
|
+
**Calm, then precise.** The ground is quiet and lit; motion explains a change and stops. Data arrives once, in reading order; nothing loops but a spinner, a skeleton and a live dot; reduced motion shows the final state.
|
|
20
20
|
|
|
21
21
|
**Every surface, every operator.** A view holds on the console, on a phone and as an MCP App in a host. It is addressable, legible to a model, and safe to share with an agent: the agent reads anything, changes nothing without a person, and leaves its name on what it did.
|
|
22
22
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { PageFrame, Row, Stack } from '@/components/base/shell';
|
|
2
|
+
import { PageFrame, Row, Stack, type PageWidth } from '@/components/base/shell';
|
|
3
3
|
import { Card } from '@/components/ui/card';
|
|
4
4
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
5
5
|
|
|
@@ -7,11 +7,12 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|
|
7
7
|
* The frame every console route's loading state shares: one masthead skeleton, then the page's own body, announced once
|
|
8
8
|
* as busy under the page's name. Each route's `loading.tsx` composes its body from these rows, in the shape of its page.
|
|
9
9
|
* The title is the page's own heading, so the shell is acknowledged the moment this state commits; a detail page whose
|
|
10
|
-
* title depends on the record leaves it out and keeps a skeleton.
|
|
10
|
+
* title depends on the record leaves it out and keeps a skeleton. `width` follows the page it stands in for: a
|
|
11
|
+
* skeleton at another width would move the content when the page arrives.
|
|
11
12
|
*/
|
|
12
|
-
export function LoadingPage({ name, title, children }: { name: string; title?: string; children: ReactNode }) {
|
|
13
|
+
export function LoadingPage({ name, title, width, children }: { name: string; title?: string; width?: PageWidth; children: ReactNode }) {
|
|
13
14
|
return (
|
|
14
|
-
<PageFrame kicker={<Skeleton className="h-2.5 w-32" />} title={title ?? <Skeleton className="mt-3 h-10 w-64 rounded-md" />} description={<Skeleton className="mt-2 h-3.5 w-96 max-w-full" />}>
|
|
15
|
+
<PageFrame width={width} kicker={<Skeleton className="h-2.5 w-32" />} title={title ?? <Skeleton className="mt-3 h-10 w-64 rounded-md" />} description={<Skeleton className="mt-2 h-3.5 w-96 max-w-full" />}>
|
|
15
16
|
<Busy name={name}>{children}</Busy>
|
|
16
17
|
</PageFrame>
|
|
17
18
|
);
|
|
@@ -10,7 +10,7 @@ Status: beta
|
|
|
10
10
|
|---|---|---|---|
|
|
11
11
|
| Masthead | PageFrame: kicker, "Customers", one sentence, Invite customer (primary) | One band | Stacked |
|
|
12
12
|
| 1 | Row `tiles`: Customers (with combined requests over 14 days), Spend, last 30 days (emphasis, with its 14-day shape), Past due | Three across | One column |
|
|
13
|
-
| 2 | Data table: search and Status in the Filter bar, the plan as a Segmented view; columns Customer (avatar and name, grow), Plan, Status, Requests, Last 14 days (sparkline, from
|
|
13
|
+
| 2 | Data table: search and Status in the Filter bar, the plan as a Segmented view; columns Customer (avatar and name, grow), Plan, Status, Requests, Last 14 days (sparkline, from 896px), Error rate (from 1152px), Spend, Customer since (from 1152px) | Columns | Cards: name and status, then plan, requests and spend |
|
|
14
14
|
|
|
15
15
|
## States
|
|
16
16
|
|
|
@@ -10,7 +10,7 @@ Status: beta
|
|
|
10
10
|
|---|---|---|---|
|
|
11
11
|
| Masthead | PageFrame: kicker, "API keys", one sentence, Create key (primary) | One band | Stacked |
|
|
12
12
|
| 1 | Banner (accent), only right after a key is created: "Copy {name} now", the full key in a Copy field, dismissable | Full width | Full width |
|
|
13
|
-
| 2 | Data table: Name with its environment, Key (its hint, `zzm_live_…f601`, in mono, from
|
|
13
|
+
| 2 | Data table: Name with its environment, Key (its hint, `zzm_live_…f601`, in mono, from 672px), Scopes (two and "+N", from 1152px), Created by (from 896px), Created, Last used, and Revoke | Columns | Cards: name and environment, "Created …" and "Used …", Revoke |
|
|
14
14
|
| Sheet | Create a key: Name (required), Environment (Live or Test), Scopes (checkboxes); Cancel and Create key | From the right, 440px | From the bottom |
|
|
15
15
|
| Dialog | Revoke: "Revoke {name}?", what fails and that it cannot be undone, when it was last used; Cancel and Revoke key (danger) | Centred | Bottom sheet |
|
|
16
16
|
|
|
@@ -9,7 +9,7 @@ Status: beta
|
|
|
9
9
|
| Row | Pattern | Console | Mobile |
|
|
10
10
|
|---|---|---|---|
|
|
11
11
|
| Masthead | PageFrame: kicker, "Members", one sentence, Invite member (primary), shown only to a person who may invite (`invitableRoles()` is not empty) | One band | Stacked |
|
|
12
|
-
| 1 | Data table: Name (avatar and name, grow), Email (from 896px), Role as a person reads it, add-ons and all (as a column from 512px, and on the card at every width below), Team (from
|
|
12
|
+
| 1 | Data table: Name (avatar and name, grow), Email (from 896px), Role as a person reads it, add-ons and all (as a column from 512px, and on the card at every width below), Team (from 1152px), Status, Joined (from 896px), Last active, and a row action menu | Columns | Cards: name and the menu, then the role with its add-ons, status and last active |
|
|
13
13
|
| 2 | Roles & access matrix: a card whose table ticks each role, main roles first and add-ons after, against every non-public feature and then every action; a legend under it says that an add-on adds to a main role and that the member rules still apply. Generated from `GRANTS`, `FEATURES` and `ACTIONS`, never written by hand; at a phone's width the table scrolls inside its own frame | Columns, scrolling inside the card | The same table, scrolling inside the card |
|
|
14
14
|
| Sheet | Invite a member: Name and Email (required), Role, Team; Cancel and Send invitation | From the right, 440px | From the bottom |
|
|
15
15
|
| Sheet | Change {name}'s role: Role and Add-ons, offering only the choices the row's boundary allowed; a refusal reopens it with the reason in a critical Banner; Cancel and Save role | From the right, 440px | From the bottom |
|
|
@@ -6,7 +6,7 @@ Status: beta
|
|
|
6
6
|
|
|
7
7
|
## Structure
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
On a form page's own measure (`form-width`), which fills the canvas until it is wider than the form needs and then centres, sections 56px apart, each a Form section (its card stops at `field-width`):
|
|
10
10
|
|
|
11
11
|
| Section | Saves | Controls |
|
|
12
12
|
|---|---|---|
|
|
@@ -20,7 +20,7 @@ At the data width like every console page, sections 56px apart, each a Form sect
|
|
|
20
20
|
|
|
21
21
|
| State | What shows |
|
|
22
22
|
|---|---|
|
|
23
|
-
| Loading | `loading.tsx`: a stack of skeleton sections |
|
|
23
|
+
| Loading | `loading.tsx`: a stack of skeleton sections, at the page's own width (`form`), so nothing moves when the page arrives |
|
|
24
24
|
| Assistant not resolved yet | The Assistant section keeps its place as a skeleton; once the request says the assistant is configured it becomes the section, and when it is not the place closes |
|
|
25
25
|
| Editing Workspace | The save bar; Save confirms with a toast "Workspace saved" |
|
|
26
26
|
| Empty name | The field's error; Save does nothing until it is fixed |
|
|
@@ -4,7 +4,7 @@ import { LoadingPage } from '../_loading';
|
|
|
4
4
|
/** Settings on its way: a stack of sections, each a title, a sentence and its controls. */
|
|
5
5
|
export default function SettingsLoading() {
|
|
6
6
|
return (
|
|
7
|
-
<LoadingPage name="settings" title="Settings">
|
|
7
|
+
<LoadingPage name="settings" title="Settings" width="form">
|
|
8
8
|
<div className="flex flex-col gap-14">
|
|
9
9
|
{[3, 4, 3, 2].map((n, i) => (
|
|
10
10
|
<div key={i} className="flex flex-col gap-4">
|
|
@@ -52,6 +52,7 @@ export default async function SettingsPage() {
|
|
|
52
52
|
const workspace = permissions.workspaceRead ? await workspaceRecord() : null;
|
|
53
53
|
return (
|
|
54
54
|
<PageFrame
|
|
55
|
+
width="form"
|
|
55
56
|
kicker={<>{app.name} · {app.workspace}</>}
|
|
56
57
|
title="Settings"
|
|
57
58
|
description={`The workspace, what you hear about, how ${app.name} looks, and what assistants may do.`}
|
|
@@ -17,7 +17,7 @@ For an address outside the console, a standalone screen on the lit ground, from
|
|
|
17
17
|
| Address | A sunk field, body-size mono: the part that exists as a link to that page, the part that does not under a dashed critical rule, and one line saying which is which |
|
|
18
18
|
| Actions | Back to the nearest page (primary, `lg`), then a link to the home page when that is somewhere else (`nav`'s label for `/`) |
|
|
19
19
|
|
|
20
|
-
Inside the console the screen is `MissingPage` (`src/views/missing-page.tsx`): a record page renders it itself when its ID is missing (`/requests/<id>`), and `app/(dashboard)/not-found.tsx` renders it for any other `notFound()`. A record page renders rather than throws because a `notFound()` thrown inside the dashboard's loading boundary shows only once the JavaScript arrives, which on a slow phone put the largest paint past 2.5s; the page keeps `noindex`. The rail stays; the sentence becomes the page title over the same lead at the data width, on the same left edge as every page, and one card holds the address at `text-xl` as the protagonist and the same actions (`md`). A miss at the root there offers Search pages (the command palette) as the second action.
|
|
20
|
+
Inside the console the screen is `MissingPage` (`src/views/missing-page.tsx`): a record page renders it itself when its ID is missing (`/requests/<id>`), and `app/(dashboard)/not-found.tsx` renders it for any other `notFound()`. A record page renders rather than throws because a `notFound()` thrown inside the dashboard's loading boundary shows only once the JavaScript arrives, which on a slow phone put the largest paint past 2.5s; the page keeps `noindex`. The rail stays; the sentence becomes the page title over the same lead at the data width, on the same left edge as every data page, and one card holds the address at `text-xl` as the protagonist and the same actions (`md`). A miss at the root there offers Search pages (the command palette) as the second action.
|
|
21
21
|
|
|
22
22
|
The words come from `src/views/not-found.tsx`; the address and the actions from `src/views/not-found-address.tsx`, once. The home page's own name is read from `nav` (`homeLabel`), not written here: a product whose front page is a ranked list, not "Overview", names it once in `src/app.config.ts` and both buttons follow.
|
|
23
23
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# 0013 · A form page has a measure of its own
|
|
2
|
+
|
|
3
|
+
Date: 2026-10-11 · Status: accepted
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Every console page took the data width — 100% of the canvas — and one card, the Form section's, capped itself at
|
|
8
|
+
`64rem` so a field never stretched across a wide screen. On a 1440px window the two met and nothing showed. On a
|
|
9
|
+
wider one they did not: Settings ran its masthead and its sections out to the canvas's right edge while the cards
|
|
10
|
+
inside stopped at 64rem, so the page ended in a strip — 280px at 1920px, 1108px at 2560px. The owner asked why
|
|
11
|
+
Settings alone was not full width, and the honest answer was that nothing in the repository said what a form page
|
|
12
|
+
should do; the token layer's own `reading-width` described itself as "forms, documents and settings" while no page,
|
|
13
|
+
Settings included, used it.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
- **A form page's measure is a token, derived from its parts.** `form-width` is `calc(15rem + 2.5rem +
|
|
18
|
+
var(--field-width) + 2 * var(--gutter))` — the head column, the column gap, a card at its cap, and the page's own
|
|
19
|
+
gutter — and `field-width` (64rem, in rem so it grows with the reader's text size) is the card's cap. The card's
|
|
20
|
+
measure is unchanged from before; only its name is new. *Why:* the number was a literal in a component and a
|
|
21
|
+
paragraph in a token file, which is how the two came to disagree in the first place.
|
|
22
|
+
- **A page names one width, and a form page names this one.** `PageFrame` gains `width="form"`; `data` stays the
|
|
23
|
+
default and the right answer for an analytical page, where a wider screen should show more. *Why:* the strip was
|
|
24
|
+
not caused by a page being narrow; it was caused by nobody having decided.
|
|
25
|
+
- **It fills the canvas until the canvas is wider than a form needs, then centres.** Below about 1470px of window
|
|
26
|
+
nothing changes at all; above it the remainder splits evenly instead of collecting at one side. *Why:* a form's
|
|
27
|
+
parts have a maximum useful width, and centring is what makes the rest read as a margin rather than as a mistake.
|
|
28
|
+
- **`reading-width` says what it is.** One long document, 832px, centred. It never described forms, and nothing
|
|
29
|
+
used it.
|
|
30
|
+
- **The Atlas names every width role in one place.** A seventh token view, **Layout**, draws them to scale
|
|
31
|
+
(`rail-width`, `assistant-width`, `data-width`, `stage-width`, `reading-width`, `field-width`, `form-width`,
|
|
32
|
+
`gutter`), so the question this record answers has one place to be looked up.
|
|
33
|
+
|
|
34
|
+
**Rejected.** Making the card fill the canvas: it would have made Settings "full width" and every field 1500px
|
|
35
|
+
wide, which is the outcome `64rem` exists to prevent. Filling the freed space with a sticky section index on the
|
|
36
|
+
page's left edge: it is a genuine option — the Atlas's own articles carry such an index — but it adds an element
|
|
37
|
+
nobody asked for and a scroll-spy to keep it honest, and it changes the page more than the defect warranted. It
|
|
38
|
+
stays available: the measure and the token do not depend on which of the two a product chooses.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
- A form page's title sits on the canvas's centre axis rather than its left edge above ~1470px, so it no longer
|
|
43
|
+
lines up with an analytical page's title on a very wide screen. That is the cost of the choice, and it is why
|
|
44
|
+
it is written down.
|
|
45
|
+
- `LoadingPage` takes the same `width`, and a page's `loading.tsx` passes it: a skeleton at another width moves the
|
|
46
|
+
content when the page arrives.
|
|
47
|
+
- `FormSection`'s card is capped at `var(--field-width)`; a form page's band is sized so the card reaches that cap
|
|
48
|
+
exactly, at any root size and gutter.
|
|
49
|
+
- The field measure still holds: a field is never wider than 64rem, on any window.
|
|
@@ -7,10 +7,10 @@ Meridian held to the bar of award-winning sites (Awwwards, Webby, FWA) and of th
|
|
|
7
7
|
| Criterion | Verdict | Evidence |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| Typography | Strong | One family (Geist) with a mono for kickers and identifiers; 52px page titles, 76px hero figures, 40px metric figures set at −0.035em with the unit and fraction stepped down to half size in `ink-3`. Eleven sizes in the scale; a page uses 5 to 8 of them; three weights everywhere on the product |
|
|
10
|
-
| Whitespace | Strong | A 4px scale; 20px between cards, 24–28px inside them; 32px between table columns with the card's padding on the outer edges; every console page on one left edge,
|
|
10
|
+
| Whitespace | Strong | A 4px scale; 20px between cards, 24–28px inside them; 32px between table columns with the card's padding on the outer edges; every analytical console page on one left edge, a form page centred on its own measure once the canvas is wider than a form needs, and a reading width of 832px for long documents |
|
|
11
11
|
| Visual hierarchy | Strong | One protagonist per page. Hierarchy ratio (largest text over median) on analytical pages: Overview 6×, Health 6.5×, Requests 4.6×, Analytics 4.6×; standalone screens 7.2–7.8× |
|
|
12
12
|
| Colour | Strong | A night-sky neutral leaning indigo; one accent that is two numbers (hue, chroma), four presets; status trio reserved; six categorical chart slots validated for colour-vision deficiency (worst adjacent ΔE 10.2 light, 8.7 dark). 328 contrast pairs pass in every theme and accent |
|
|
13
|
-
| Motion | Good | Three jobs (arrive, answer, float),
|
|
13
|
+
| Motion | Good | Three jobs (arrive, answer, float), a duration for each, three curves; data arrives once (a line draws, an area reveals, bars grow); nothing loops but a spinner, a skeleton and a live dot; reduced motion shows the final state at once |
|
|
14
14
|
| Micro-interaction | Good | The rail's pill springs between items; the Segmented thumb slides; a lit edge fades in under the pointer; a linked row takes a hover fill and a link changes colour, never underlined; the Meridian cursor answers pointer, touch and arrow keys with a readout on every chart and tile. Every control on every page is pressed by `scripts/interactions.ts`, with taps at 390px; none does nothing |
|
|
15
15
|
| Responsive | Strong | Zero sideways scroll, zero clipped text and no table wider than its card at 390–1440px on every page (the last was missed until 2026-10-03: see below); tables become card lists under 768px; phone tiles put the sparkline beside the figure; dialogs become bottom sheets |
|
|
16
16
|
| Originality | Strong | The Meridian (one time cursor shared by every chart, tile and the model); three surfaces including MCP Apps with a working host simulator; two operators with consent, provenance and handoff built into components |
|
|
@@ -63,7 +63,7 @@ The rail and the command palette draw only the destinations the signed-in person
|
|
|
63
63
|
|
|
64
64
|
## 6. Arrange your pages
|
|
65
65
|
|
|
66
|
-
Every page is a `PageFrame` holding a `Stack` of `Row`s. Start from the closest preset and change what it shows, not how it is laid out
|
|
66
|
+
Every page is a `PageFrame` holding a `Stack` of `Row`s. Start from the closest preset and change what it shows, not how it is laid out. A dashboard page leaves its width alone — `data` fills the canvas; a page that is mostly a form takes `width="form"`, whose band centres once the canvas is wider than a form needs; a page that is one long document takes `width="reading"`:
|
|
67
67
|
|
|
68
68
|
| You need | Start from |
|
|
69
69
|
|---|---|
|
package/payload/docs/surfaces.md
CHANGED
|
@@ -14,7 +14,7 @@ The surface is a fact about the container, not the device: a console on a 900px
|
|
|
14
14
|
|
|
15
15
|
### Console
|
|
16
16
|
|
|
17
|
-
The default. The rail sits on the `frame`; each page is a `PageFrame` on the canvas: the masthead (title, one-sentence head-note, freshness, actions) above the one scroll region. Rows of cards follow the four splits.
|
|
17
|
+
The default. The rail sits on the `frame`; each page is a `PageFrame` on the canvas: the masthead (title, one-sentence head-note, freshness, actions) above the one scroll region. Rows of cards follow the four splits. A page names its own width: `data` fills the canvas (an analytical page), `form` is a form page's measure, which fills the canvas until the canvas is wider than a form needs and then centres, and `reading` is one long document. Settings is the page that takes `form`.
|
|
18
18
|
|
|
19
19
|
### Mobile
|
|
20
20
|
|
|
@@ -99,10 +99,10 @@ What exists, by layer, and how it changes per surface. "Same" means the card nee
|
|
|
99
99
|
|---|---|---|---|
|
|
100
100
|
| Planes | Frame, canvas and surface: three planes of depth instead of borders | Frame hidden; canvas full-bleed | Frame and canvas transparent |
|
|
101
101
|
| Text roles | Display, page, section and card titles; figure; lead, body, small, caption, eyebrow, mono | Page title 28px holds; display steps down by clamp | Same; host font when given |
|
|
102
|
-
| Shell | AppShell, PageFrame, Stack, Row, the
|
|
102
|
+
| Shell | AppShell, PageFrame, Stack, Row, the three page widths | Drawer, stacked rows | Replaced by EmbedFrame |
|
|
103
103
|
| Icons | Lucide at 1.75 stroke: 14, 16 and 18px | Same | Same |
|
|
104
104
|
| Motion | Arrive, answer, float; reduced motion collapses all | Same | Arrive only on first render, so a re-render in the thread never replays |
|
|
105
|
-
| App mark | The product mark at 20, 24 and 32px | Same | 20px in the embed head |
|
|
105
|
+
| App mark | The product mark at 20, 24, 28 and 32px | Same | 20px in the embed head |
|
|
106
106
|
|
|
107
107
|
### Layer 2 · Components
|
|
108
108
|
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. DTCG tokens, five layers of React components and a Design Atlas, in a light and a dark theme.",
|
|
@@ -89,6 +89,10 @@ async function press(find: string) {
|
|
|
89
89
|
at = next;
|
|
90
90
|
}
|
|
91
91
|
if (!at) return fail(`nothing to press: ${find}`);
|
|
92
|
+
// A control the layout has hidden at this width still answers a rect of 0x0, so the click below would land at the
|
|
93
|
+
// page's top-left corner and the step would fail somewhere else entirely (another session lost twenty minutes to
|
|
94
|
+
// exactly that on a pager hidden by a container query). Say which it is.
|
|
95
|
+
if (at.x === 0 && at.y === 0) return fail(`the control is in the DOM but has no box at 0x0 — hidden at this width?: ${find}`);
|
|
92
96
|
for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased'] as const) await page.send('Input.dispatchMouseEvent', { type, x: at.x, y: at.y, button: 'left', clickCount: 1 });
|
|
93
97
|
}
|
|
94
98
|
/** Press the button named `label` in the open Proposal card. */
|
|
@@ -241,12 +245,23 @@ try {
|
|
|
241
245
|
if (!kept) fail('the dismissed removal took Alice Moreno out of the table');
|
|
242
246
|
ok('the dismissed removal left Alice Moreno in the table');
|
|
243
247
|
|
|
244
|
-
|
|
248
|
+
// Which member, read from the first page rather than written here: the roster is ordered by last active, so a
|
|
249
|
+
// name in this line moves to page 2 the moment the sample's activity changes, and the row menu is then simply
|
|
250
|
+
// absent (found by a full run: "Actions for Amara Okafor" is on the second page).
|
|
251
|
+
const who = await until('a first-page row that offers its menu', () => page.eval<string | null>(`(() => {
|
|
252
|
+
for (const tr of deepQueryAll('tbody tr')) {
|
|
253
|
+
if (!/Active/.test(deepText(tr))) continue;
|
|
254
|
+
const b = deepQueryAll('button[aria-label^="Actions for "]', tr)[0];
|
|
255
|
+
if (b && !b.disabled) return (b.getAttribute('aria-label') || '').slice('Actions for '.length);
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
})()`), (v) => v !== null);
|
|
259
|
+
await press(`deepQueryAll('button[aria-label="Actions for ' + ${JSON.stringify(who)} + '"]')[0]`);
|
|
245
260
|
await until('the row menu', () => page.eval<boolean>(`!!deepQuery('[role="menu"]')`), Boolean);
|
|
246
261
|
await press(`deepQueryAll('[role="menuitem"]').find((i) => deepText(i).trim() === 'Suspend')`);
|
|
247
|
-
await until(
|
|
262
|
+
await until(`${who} to read Suspended`, () => row(who as string), (t) => !!t?.includes('Suspended'));
|
|
248
263
|
await ask('Who is suspended?');
|
|
249
|
-
const found = await until(
|
|
264
|
+
const found = await until(`the answer to name ${who}`, said, (t) => t.includes(who as string));
|
|
250
265
|
ok(`a member suspended from the row menu is found by the assistant's next query ("${found}")`);
|
|
251
266
|
|
|
252
267
|
// The page context: the title and path, and the page's text, capped.
|
package/payload/scripts/audit.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Routes are discovered from app/ (every static page; embeds under /embed), with the detail pages in
|
|
7
7
|
* scripts/verify.config.ts beside them; --routes replaces the pages for one run.
|
|
8
8
|
*
|
|
9
|
-
* --atlas audits the Design Atlas instead: every card's page and its bare preview,
|
|
9
|
+
* --atlas audits the Design Atlas instead: every card's page and its bare preview, and every other entry the Atlas
|
|
10
|
+
* serves — the token views, the page specs and the guides — at 1440 and 390px in both themes.
|
|
10
11
|
* A card's preview is the one place it is drawn alone, so a defect in its own box shows there and nowhere else. It is
|
|
11
12
|
* not part of `pnpm verify` (the default stays fast); run it when the question is the component surface. It takes
|
|
12
13
|
* about ten minutes, and a project without the Atlas has nothing for it to read.
|
|
@@ -21,6 +22,8 @@
|
|
|
21
22
|
* an analytical page.
|
|
22
23
|
*/
|
|
23
24
|
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { pathToFileURL } from 'node:url';
|
|
24
27
|
import { launch } from './lib/chrome.ts';
|
|
25
28
|
import { DEEP_SOURCE } from './lib/deep.ts';
|
|
26
29
|
import { discover } from './lib/routes.ts';
|
|
@@ -38,18 +41,32 @@ if (as && !cookie) { console.error(`audit: --as ${as} is not a declared persona.
|
|
|
38
41
|
const EMBEDS_ONLY = args.includes('--embeds-only');
|
|
39
42
|
const ATLAS = args.includes('--atlas');
|
|
40
43
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Every Atlas page: each card's page and its bare preview (from the keys scripts/registry.ts generates), plus every
|
|
46
|
+
* other entry the Atlas serves — the token views, the page specs and the guides. Those live under `[section]/[id]`, so
|
|
47
|
+
* `discover()` skips them as dynamic segments and nothing measured them: a defect in a token view, a page spec or a
|
|
48
|
+
* guide failed no suite. They are read through the module the Atlas itself renders from, so a new entry is audited
|
|
49
|
+
* without being listed. Only the template has an Atlas.
|
|
50
|
+
*/
|
|
51
|
+
async function atlasRoutes(): Promise<string[]> {
|
|
43
52
|
const file = 'app/system/preview/[section]/[card]/keys.ts';
|
|
44
53
|
if (!fs.existsSync(file)) { console.error('audit --atlas: this project has no Design Atlas to audit'); process.exit(1); }
|
|
45
54
|
const keys = [...fs.readFileSync(file, 'utf8').matchAll(/'([a-z]+\/[a-z0-9-]+)'/g)].map((m) => m[1]);
|
|
46
|
-
|
|
55
|
+
const cards = keys.flatMap((k) => [`/system/${k}`, `/system/preview/${k}`]);
|
|
56
|
+
let entries: string[] = [];
|
|
57
|
+
try {
|
|
58
|
+
const mod = (await import(pathToFileURL(path.resolve('src/system/content.ts')).href)) as { entries?: () => { href: string }[] };
|
|
59
|
+
entries = (mod.entries?.() ?? []).map((e) => e.href);
|
|
60
|
+
} catch {
|
|
61
|
+
// A content module that plain node cannot read: the card routes alone, as before, rather than no audit at all.
|
|
62
|
+
}
|
|
63
|
+
return [...new Set([...cards, ...entries])];
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
const found = discover();
|
|
50
67
|
// verify.config.ts is the team's, so read the one field this needs through its own type.
|
|
51
68
|
const detailRoutes = (config as { detailRoutes?: string[] }).detailRoutes ?? [];
|
|
52
|
-
const ROUTES = EMBEDS_ONLY ? [] : ATLAS ? atlasRoutes() : (opt('--routes', '') ? opt('--routes', '').split(',') : [...found.filter((r) => !r.startsWith('/embed')), ...detailRoutes]);
|
|
69
|
+
const ROUTES = EMBEDS_ONLY ? [] : ATLAS ? await atlasRoutes() : (opt('--routes', '') ? opt('--routes', '').split(',') : [...found.filter((r) => !r.startsWith('/embed')), ...detailRoutes]);
|
|
53
70
|
const EMBEDS = ATLAS ? [] : found.filter((r) => r.startsWith('/embed/'));
|
|
54
71
|
const WIDTHS = ATLAS ? [1440, 390] : [2560, 1440, 1024, 768, 390];
|
|
55
72
|
const THEMES = ['dark', 'light'];
|
package/payload/scripts/check.ts
CHANGED
|
@@ -78,7 +78,7 @@ for (const p of PAGE_SPECS) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// ── Token names in specifications ────────────────────────────────────────────────────────────────────
|
|
81
|
-
const TOKEN_LIKE = /`((?:ground|frame|surface|line|fill|ink|accent|on-accent|on-critical|positive|warning|critical|series|chart|shadow|highlight|glow|edge|text|weight|leading|tracking|space|radius|control|row-height|card-pad|stack-gap|rail|data-width|reading-width|gutter|layer|dur|stagger|ease|stretch)(?:-[a-z0-9-]+)?)`/g;
|
|
81
|
+
const TOKEN_LIKE = /`((?:ground|frame|surface|line|fill|ink|accent|on-accent|on-critical|positive|warning|critical|series|chart|shadow|highlight|glow|edge|text|weight|leading|tracking|space|radius|control|row-height|card-pad|stack-gap|rail|assistant-width|data-width|stage-width|reading-width|form-width|field-width|gutter|layer|dur|stagger|ease|stretch)(?:-[a-z0-9-]+)?)`/g;
|
|
82
82
|
const NOT_TOKENS = new Set(['leading', 'trailing', 'fill', 'text-wrap', 'text-pretty', 'text-balance', 'line-clamp', 'ease-out', 'ease-in-out', 'ease-spring', 'layer-1', 'text-left', 'text-right', 'text-center', 'surface-sunk/60', 'fill-hover', 'glow', 'edge', 'chart', 'series', 'shadow', 'surface', 'line', 'ink', 'accent', 'frame', 'ground']);
|
|
83
83
|
const SPECS = [...cards().map((c) => `${c.dir}/README.md`), ...PAGE_SPECS, ...walk('docs', /\.md$/), 'README.md', 'CONTRIBUTING.md'].filter((f) => fs.existsSync(path.join(ROOT, f)));
|
|
84
84
|
for (const f of SPECS) {
|
|
@@ -125,7 +125,12 @@ const config: VerifyConfig = {
|
|
|
125
125
|
'/requests/req_missing', // no such request: the not-found screen in the shell
|
|
126
126
|
],
|
|
127
127
|
smokeRoutes: ['/', '/requests', '/settings'],
|
|
128
|
-
|
|
128
|
+
// Sizes to hold. /health is the longest streamed document of the analytical pages: ninety days of bars, six
|
|
129
|
+
// services each with their own strip, an incident timeline, and a screen-reader table behind every chart. It
|
|
130
|
+
// measured 99-100 KiB when this cap was set, so the cap sat exactly on the measurement and the next legitimate
|
|
131
|
+
// byte failed: the access work that gave every page a gate added 0.6% (101 KiB, 103,025 bytes), which is markup
|
|
132
|
+
// the page needs rather than fat to trim. Held at 104 — above the measurement, so the next few KiB still fail.
|
|
133
|
+
budgets: { htmlKb: { '/health': 104, '/requests': 150 } },
|
|
129
134
|
navigationChecks: [
|
|
130
135
|
{ path: '/', title: 'Overview', readySelector: 'main table tbody tr', probe: 'toggle', controlSelector: 'main [role="radio"][aria-checked="false"]', resultSelector: 'main [role="radio"]' },
|
|
131
136
|
{ path: '/requests', title: 'Requests', readySelector: 'main table tbody tr', probe: 'filter', controlSelector: 'main input[type="search"]', resultSelector: 'main table tbody tr' },
|
|
@@ -83,7 +83,11 @@ its change compares with in `compare` ("vs the 3 days before") and takes a `note
|
|
|
83
83
|
Rules that make every page look like it belongs:
|
|
84
84
|
|
|
85
85
|
- **`PageFrame`** with `kicker` (where it sits: "Product · Workspace" or the parent record), `title`, one-sentence
|
|
86
|
-
`description`, then `meta` (Freshness) and `actions` (one primary at most).
|
|
86
|
+
`description`, then `meta` (Freshness) and `actions` (one primary at most). A dashboard page leaves `width` alone:
|
|
87
|
+
`data` fills the canvas, so a wider screen shows more. A page that is mostly a form takes `width="form"`, whose band
|
|
88
|
+
fills the canvas until the canvas is wider than a form needs and then centres — a field never stretches across a wide
|
|
89
|
+
screen, and the remainder splits evenly instead of leaving a strip at one side. A page that is one long document takes
|
|
90
|
+
`width="reading"`. A `loading.tsx` passes the same `width` its page does, or the skeleton moves the page when it arrives.
|
|
87
91
|
- **`Stack` of `Row`s.** Splits are `full`, `1/2`, `2/3`, `1/3` and `tiles`. Cards in a row are the same height; one card
|
|
88
92
|
per cell (a column of tiles is fine in a 1/3 cell).
|
|
89
93
|
- **One protagonist.** At most one `FeaturedMetric` per page; everything else is a `MetricTile` or a `Card`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Motion
|
|
2
2
|
|
|
3
|
-
Motion has three jobs and no others: data arriving, a control answering the pointer, and things floating above the page, with
|
|
3
|
+
Motion has three jobs and no others: data arriving, a control answering the pointer, and things floating above the page, with a duration for each job, three curves, three things that loop while they wait, and nothing at all
|
|
4
|
+
under reduced motion.
|
|
4
5
|
|
|
5
6
|
Status: beta
|
|
6
7
|
|
|
@@ -14,9 +15,9 @@ Status: beta
|
|
|
14
15
|
| Answer | A control gives 0.5px and 98.5% | `press` | `dur-press` 110ms · `ease-out` |
|
|
15
16
|
| Answer | Colour and outline change | transitions | `dur-hover` 160ms |
|
|
16
17
|
| Answer | An indicator slides (the rail marker, a segmented thumb) | transitions | `dur-enter` · `ease-spring` |
|
|
17
|
-
| Float | Menus and popovers rise 4px from 97% | `float-in` | `dur-enter` in,
|
|
18
|
-
| Float | Dialogs rise 10px from 98% | `dialog-in` | `dur-enter` in,
|
|
19
|
-
| Float | The drawer slides in from the left | `sheet-in` | `dur-enter` in,
|
|
18
|
+
| Float | Menus and popovers rise 4px from 97% | `float-in` | `dur-enter` in, `dur-exit` out |
|
|
19
|
+
| Float | Dialogs rise 10px from 98% | `dialog-in` | `dur-enter` in, `dur-exit` out |
|
|
20
|
+
| Float | The drawer slides in from the left | `sheet-in` | `dur-enter` in, `dur-exit-sheet` out |
|
|
20
21
|
|
|
21
22
|
## Curves
|
|
22
23
|
|
|
@@ -30,12 +31,12 @@ Status: beta
|
|
|
30
31
|
|
|
31
32
|
- An animation plays when its data lands, once. Nothing moves while it is being read.
|
|
32
33
|
- A page arrives with motion only on the first load of a visit. A page reached by a click in the app, or one the router kept and shows again, is already there: the shell marks its main area `data-still` when the path changes, every arrival inside takes no time and ends in its final state, and the mark lifts after `dur-grow` + `dur-enter` so a change made on the page afterwards (a new period) still animates. A change of query alone is not a navigation.
|
|
33
|
-
-
|
|
34
|
+
- Three things loop, and only three, each while something waits: a Spinner's ring, a Skeleton's shimmer, and a fresh Freshness dot's pulse.
|
|
34
35
|
- Every keyframe lives in `src/styles/motion.css`.
|
|
35
36
|
|
|
36
37
|
## States
|
|
37
38
|
|
|
38
|
-
Under `prefers-reduced-motion: reduce` every duration and delay collapses, and arrivals (`arrive`, `rise`, `grow-*`, `draw`, `reveal-x`) show their final state outright;
|
|
39
|
+
Under `prefers-reduced-motion: reduce` every duration and delay collapses, and arrivals (`arrive`, `rise`, `grow-*`, `draw`, `reveal-x`) show their final state outright; all three loops stop.
|
|
39
40
|
|
|
40
41
|
## Surfaces
|
|
41
42
|
|
|
@@ -12,7 +12,9 @@ const CURVES = [
|
|
|
12
12
|
];
|
|
13
13
|
const DURS = [
|
|
14
14
|
{ name: 'dur-press', ms: 110, use: 'A control giving under the pointer' },
|
|
15
|
+
{ name: 'dur-exit', ms: 140, use: 'A menu, a dialog or a scrim leaving' },
|
|
15
16
|
{ name: 'dur-hover', ms: 160, use: 'Colour and outline changes' },
|
|
17
|
+
{ name: 'dur-exit-sheet', ms: 200, use: 'The phone drawer leaving' },
|
|
16
18
|
{ name: 'dur-enter', ms: 320, use: 'Overlays, rows, sliding indicators' },
|
|
17
19
|
{ name: 'dur-grow', ms: 820, use: 'Bars growing, lines drawing' },
|
|
18
20
|
];
|
|
@@ -71,7 +73,12 @@ export default function MotionPreview() {
|
|
|
71
73
|
<State key={c.name} label={`${c.name} · ${c.use}`}><Curve v={c.v} /></State>
|
|
72
74
|
))}
|
|
73
75
|
</Specimen>
|
|
74
|
-
<Specimen label="
|
|
76
|
+
<Specimen label="Loops" note="Three things loop, and only three, each while something waits: a spinner's ring, a skeleton's shimmer and a live dot's pulse.">
|
|
77
|
+
<State label="spin · a ring while it waits"><span className="spin block size-4 rounded-full border-2 border-current border-r-transparent" /></State>
|
|
78
|
+
<State label="shimmer · a skeleton"><span className="shimmer block h-3 w-32 rounded-sm" /></State>
|
|
79
|
+
<State label="pulse · a fresh dot"><span className="pulse block size-2 rounded-full bg-positive" /></State>
|
|
80
|
+
</Specimen>
|
|
81
|
+
<Specimen label="Reduced motion" note="Every duration and delay collapses; arrivals show their final state outright; all three loops stop.">
|
|
75
82
|
<p className="t-small max-w-[60ch] text-ink-2">Nothing in Meridian moves to decorate. If motion does not explain a change, it is cut.</p>
|
|
76
83
|
</Specimen>
|
|
77
84
|
</>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Shell
|
|
2
2
|
|
|
3
|
-
The shell is the layout contract every console page shares: a fixed frame where only one region scrolls, a rail, a top bar, a masthead that scrolls away, and rows of cards in four splits and
|
|
3
|
+
The shell is the layout contract every console page shares: a fixed frame where only one region scrolls, a rail, a top bar, a masthead that scrolls away, and rows of cards in four splits and a width per kind of page.
|
|
4
4
|
|
|
5
5
|
Status: beta
|
|
6
6
|
|
|
@@ -21,7 +21,7 @@ Status: beta
|
|
|
21
21
|
1. **One scroller.** PageFrame is the only element on a page that scrolls, and only vertically. No card, table or list scrolls on its own.
|
|
22
22
|
2. **Cards are their content's height.** A list that can pass ten rows pages; a "top N" list stays at N.
|
|
23
23
|
3. **Four splits.** Full, `1/2`, `2/3`, `1/3`; and `tiles`, whose columns follow how many tiles it holds and its own width (four are four or two, three are three or one, never a hole). Cards in a row are the same height; every card is a direct child.
|
|
24
|
-
4. **
|
|
24
|
+
4. **Three widths, one per kind of page.** `data` fills the canvas at every size (`data-width` is 100%), so a wider screen shows more, not a centred strip: every analytical page uses it, and their titles sit on the same left edge. `form` is a form page's own measure (`form-width`: a 15rem head column, the column gap, a `field-width` card and the page's gutter). It fills the canvas until the canvas is wider than a form needs, then centres — the remainder splits evenly instead of leaving a strip at one side. Settings is the page that uses it. `reading` (`reading-width` 832px, centred) is for a page that is one long document, such as an article rendered with Prose. The top bar always takes the canvas width, so the tools never move; past a page's own width the light fills the margins.
|
|
25
25
|
|
|
26
26
|
## Sizes
|
|
27
27
|
|
|
@@ -29,6 +29,10 @@ Status: beta
|
|
|
29
29
|
|---|---|
|
|
30
30
|
| Rail | `rail-width` 260px |
|
|
31
31
|
| Assistant | `assistant-width` 400px (from 1024px) |
|
|
32
|
+
| Data width | `data-width` 100%: the canvas, for every analytical page |
|
|
33
|
+
| Form width | `form-width` 1253px at the template's own root: a `field-width` card and its head column; a form page centres on it past that |
|
|
34
|
+
| Reading width | `reading-width` 832px: one long document |
|
|
35
|
+
| Stage width | `stage-width` 1560px: a standalone screen (sign in, not found) |
|
|
32
36
|
| Gutter | `gutter`: 16px on phones to 40px on wide screens (fluid) |
|
|
33
37
|
| Top bar | 56px |
|
|
34
38
|
| Masthead | 16px under the top bar (24px from 1024px), 32px (40px) under the title block |
|
|
@@ -30,8 +30,9 @@ const AssistantLauncher = dynamic(() => import('@/components/patterns/assistant/
|
|
|
30
30
|
* └─ assistant optional, and the person can switch it off: a third column from 1024px, a sheet below; not in the page while closed
|
|
31
31
|
* whether it exists arrives as a promise: the layout never waits for it, and its launcher and column resolve it behind their own boundaries
|
|
32
32
|
*
|
|
33
|
-
* Four rules: one scroller; cards are their content's height; four splits;
|
|
34
|
-
*
|
|
33
|
+
* Four rules: one scroller; cards are their content's height; four splits; three widths, one per kind of page (data, the
|
|
34
|
+
* whole canvas, for an analytical page; form, a form page's own measure, centred once the canvas is wider than it needs;
|
|
35
|
+
* reading, 832px and centred, for one long document).
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
38
|
const ShellCtx = createContext<{ openNav: () => void; tools: ReactNode; assistant: Promise<boolean> }>({ openNav: () => {}, tools: null, assistant: Promise.resolve(false) });
|
|
@@ -198,7 +199,7 @@ export function NavTrigger({ className }: { className?: string }) {
|
|
|
198
199
|
);
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
export const WIDTH = { data: 'max-w-(--data-width)', reading: 'max-w-(--reading-width)' } as const;
|
|
202
|
+
export const WIDTH = { data: 'max-w-(--data-width)', reading: 'max-w-(--reading-width)', form: 'max-w-(--form-width)' } as const;
|
|
202
203
|
export type PageWidth = keyof typeof WIDTH;
|
|
203
204
|
|
|
204
205
|
/**
|
|
@@ -47,10 +47,13 @@ export default function ShellPreview() {
|
|
|
47
47
|
<Row split="tiles"><Cell label="Tile" /><Cell label="Tile" /><Cell label="Tile" /><Cell label="Tile" /></Row>
|
|
48
48
|
</div>
|
|
49
49
|
</Specimen>
|
|
50
|
-
<Specimen label="
|
|
50
|
+
<Specimen label="Three widths" note="A page names one. `data` is the canvas; `form` and `reading` are narrower measures that fill it until the canvas is wider than the page needs, then centre. The bar is each width against the 1560px the layout is designed at.">
|
|
51
51
|
<State label="data · fills the canvas at every width" className="w-full">
|
|
52
52
|
<div className="h-8 w-full rounded-md border border-dashed border-line-strong bg-fill-hover" />
|
|
53
53
|
</State>
|
|
54
|
+
<State label="form · a form page's measure: its head column, a `field-width` card and the gutter" className="w-full">
|
|
55
|
+
<div className="mx-auto h-8 w-[80%] rounded-md border border-dashed border-line-strong bg-fill-hover" />
|
|
56
|
+
</State>
|
|
54
57
|
<State label="reading · 832px, centred: one long document, such as an article" className="w-full">
|
|
55
58
|
<div className="mx-auto h-8 w-[53%] rounded-md border border-dashed border-line-strong bg-fill-hover" />
|
|
56
59
|
</State>
|
|
@@ -10,7 +10,7 @@ Status: beta
|
|
|
10
10
|
2. **`EmbedSurface`**: wraps every route under `app/embed/`. It starts the host bridge, applies the host's theme (`data-theme`), style variables and safe-area insets, marks the root `data-surface="embed"`, and reports the body's height.
|
|
11
11
|
3. **When the host refuses.** A refused `ui/update-model-context` is sent once more a second later; refused again, every Ask from the view carries the context's first line (the view, its scope and its address), so the question keeps its referent. A refused `ui/message` tells the person in a toast, with the question to ask in the chat themselves. A refused fullscreen opens the console page.
|
|
12
12
|
4. **`useShareView(context)`**: keeps both agents told what is on screen, with one shared context (decision 0011). In an MCP host it is re-sent as `ui/update-model-context` 250ms after it settles; on the console `share` publishes it for the assistant, which reads it with the next question (`sharedContextOn`). `SurfaceOverride` shares with nobody unless given a `share`, so an Atlas specimen never speaks for the page. `<ShareContext context={…} />` is the same hook as a component, for a server page with no client view to call it from.
|
|
13
|
-
|
|
13
|
+
5. **`SurfaceOverride`**: puts a subtree on a given surface without a host, for the Atlas and tests.
|
|
14
14
|
|
|
15
15
|
## The host bridge
|
|
16
16
|
|
|
@@ -65,7 +65,7 @@ export default function TrendChartPreview() {
|
|
|
65
65
|
/>
|
|
66
66
|
</Plane>
|
|
67
67
|
</Specimen>
|
|
68
|
-
<Specimen label="Short" note="
|
|
68
|
+
<Specimen label="Short" note="Under 200px the chart draws three gridlines instead of four, and three or four date labels; drawn here at 150px. A chart told to `fill` takes the height its card gives it, never under 220px.">
|
|
69
69
|
<Plane on="surface" className="max-w-md">
|
|
70
70
|
<TrendChart label="Spend per day" dates={dates} height={150} format="cost" series={[{ key: 's', label: 'Spend', values: current.map((d) => d.spend), kind: 'line', color: 'accent' }]} />
|
|
71
71
|
</Plane>
|