zz-meridian 0.12.3 → 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/dist/adopt.js +5 -1
- package/dist/migrations.js +28 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +84 -2
- package/payload/CONTRIBUTING.md +1 -0
- package/payload/README.md +1 -1
- package/payload/app/(dashboard)/(overview)/page.tsx +5 -2
- package/payload/app/(dashboard)/_loading.tsx +5 -4
- package/payload/app/(dashboard)/analytics/page.tsx +5 -2
- package/payload/app/(dashboard)/customers/README.md +1 -1
- package/payload/app/(dashboard)/customers/page.tsx +22 -4
- package/payload/app/(dashboard)/health/page.tsx +4 -1
- package/payload/app/(dashboard)/keys/README.md +1 -1
- package/payload/app/(dashboard)/keys/actions.ts +5 -3
- package/payload/app/(dashboard)/keys/page.tsx +26 -1
- package/payload/app/(dashboard)/layout.tsx +7 -3
- package/payload/app/(dashboard)/members/README.md +19 -12
- package/payload/app/(dashboard)/members/actions.ts +24 -10
- package/payload/app/(dashboard)/members/page.tsx +19 -2
- package/payload/app/(dashboard)/requests/[id]/page.tsx +21 -1
- package/payload/app/(dashboard)/requests/page.tsx +5 -2
- package/payload/app/(dashboard)/settings/README.md +2 -2
- package/payload/app/(dashboard)/settings/actions.ts +41 -0
- package/payload/app/(dashboard)/settings/loading.tsx +1 -1
- package/payload/app/(dashboard)/settings/page.tsx +49 -3
- package/payload/app/api/assistant/route.ts +22 -6
- package/payload/app/not-found/README.md +1 -1
- package/payload/app/sign-in/README.md +13 -8
- package/payload/app/sign-in/actions.ts +30 -2
- package/payload/app/sign-in/gated-panel.tsx +26 -3
- package/payload/app/sign-in/panel.tsx +61 -9
- package/payload/decisions/0012-roles-and-feature-access.md +36 -0
- package/payload/decisions/0013-a-form-page-has-a-measure.md +49 -0
- package/payload/docs/agents.md +3 -3
- package/payload/docs/benchmark.md +2 -2
- package/payload/docs/start-a-dashboard.md +20 -4
- 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 +29 -6
- package/payload/scripts/check.ts +35 -3
- package/payload/scripts/interactions.ts +8 -2
- package/payload/scripts/keyboard.ts +7 -1
- package/payload/scripts/lib/budgets.ts +6 -0
- package/payload/scripts/lib/chrome.ts +26 -6
- package/payload/scripts/live.ts +18 -4
- package/payload/scripts/navigate.ts +10 -3
- package/payload/scripts/rail.ts +53 -0
- package/payload/scripts/shot.ts +43 -11
- package/payload/scripts/verify.baseline.json +22 -22
- package/payload/scripts/verify.config.ts +52 -2
- package/payload/scripts/verify.ts +109 -5
- package/payload/skills/zz-meridian/references/cache.md +178 -14
- package/payload/skills/zz-meridian/references/customize.md +33 -5
- package/payload/skills/zz-meridian/references/existing-project.md +8 -4
- package/payload/skills/zz-meridian/references/standard.md +1 -1
- package/payload/skills/zz-meridian/references/validation.md +5 -1
- package/payload/src/app.config.ts +15 -11
- package/payload/src/components/base/motion/README.md +8 -6
- package/payload/src/components/base/motion/preview.tsx +8 -1
- package/payload/src/components/base/shell/README.md +7 -3
- package/payload/src/components/base/shell/index.tsx +32 -5
- 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/meridian/README.md +1 -1
- package/payload/src/components/charts/trend-chart/README.md +7 -4
- package/payload/src/components/charts/trend-chart/index.tsx +42 -18
- package/payload/src/components/charts/trend-chart/preview.tsx +13 -1
- package/payload/src/components/patterns/data-table/README.md +2 -1
- package/payload/src/components/patterns/data-table/index.tsx +5 -2
- package/payload/src/components/patterns/data-table/use-query-state.ts +15 -4
- 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 +9 -5
- package/payload/src/components/patterns/rail/index.tsx +75 -21
- package/payload/src/components/patterns/rail/preview.tsx +62 -0
- 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/card/README.md +2 -0
- package/payload/src/components/ui/card/index.tsx +3 -6
- package/payload/src/components/ui/empty-state/README.md +9 -3
- package/payload/src/components/ui/empty-state/index.tsx +8 -7
- package/payload/src/components/ui/empty-state/preview.tsx +7 -0
- 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/toast/index.tsx +4 -1
- package/payload/src/components/ui/tooltip/README.md +1 -1
- package/payload/src/data/access.ts +165 -13
- package/payload/src/data/analytics.ts +26 -0
- package/payload/src/data/collections.ts +59 -4
- package/payload/src/data/features.ts +57 -0
- package/payload/src/data/member-mutations.ts +258 -0
- package/payload/src/data/roles.ts +54 -0
- package/payload/src/data/sample.ts +2 -2
- package/payload/src/data/view-as.ts +23 -0
- package/payload/src/lib/assistant/scoped.ts +55 -0
- package/payload/src/lib/assistant/tools.ts +5 -2
- package/payload/src/lib/collection.ts +47 -0
- package/payload/src/lib/format-date.ts +5 -2
- package/payload/src/lib/format.ts +12 -6
- package/payload/src/lib/shared-context.ts +8 -4
- package/payload/src/styles/base.css +20 -0
- package/payload/src/styles/motion.css +8 -2
- package/payload/src/styles/tokens.css +2 -0
- package/payload/src/system/content.ts +3 -2
- package/payload/src/system/fixtures/sample-members.ts +8 -3
- package/payload/src/system/markdown.tsx +1 -1
- package/payload/src/system/token-view.tsx +84 -2
- package/payload/src/views/console-chrome.tsx +74 -10
- package/payload/src/views/keys.tsx +14 -8
- package/payload/src/views/members-context.ts +21 -4
- package/payload/src/views/members.tsx +164 -47
- package/payload/src/views/no-access.tsx +58 -0
- package/payload/src/views/request.tsx +6 -2
- package/payload/src/views/requests.tsx +3 -2
- package/payload/src/views/roles-matrix.tsx +98 -0
- package/payload/src/views/settings.tsx +49 -18
- package/payload/src/views/tools.ts +29 -6
- package/payload/tests/action-needs.test.tsx +45 -0
- package/payload/tests/assistant-access.test.ts +122 -0
- package/payload/tests/assistant-settings.test.tsx +4 -1
- package/payload/tests/assistant-tools.test.ts +18 -1
- package/payload/tests/bypasses.test.ts +70 -0
- package/payload/tests/console-chrome.test.tsx +19 -4
- package/payload/tests/customers-masthead.test.tsx +8 -3
- package/payload/tests/data-access.test.ts +3 -2
- package/payload/tests/data-write.test.ts +14 -1
- package/payload/tests/demo-policy.test.ts +102 -0
- package/payload/tests/feature-access.test.ts +93 -0
- package/payload/tests/feature-definitions.test.ts +57 -0
- package/payload/tests/format.test.ts +9 -0
- package/payload/tests/keys-gate.test.tsx +67 -0
- package/payload/tests/member-mutations.test.ts +140 -0
- package/payload/tests/member-roles.test.ts +37 -0
- package/payload/tests/member-row-access.test.tsx +52 -0
- package/payload/tests/members-optimistic.test.tsx +72 -20
- package/payload/tests/no-access.test.tsx +37 -0
- package/payload/tests/page-gates.test.tsx +79 -0
- package/payload/tests/roles-context.test.ts +38 -0
- package/payload/tests/roles-matrix.test.tsx +48 -0
- package/payload/tests/roles.test.ts +38 -0
- package/payload/tests/settings-access.test.tsx +36 -0
- package/payload/tests/settings-disconnect-undo.test.tsx +4 -1
- package/payload/tests/setup.ts +8 -0
- package/payload/tests/shared-context.test.tsx +3 -2
- package/payload/tests/sign-in-persona.test.ts +38 -0
- package/payload/tests/trend-chart.test.tsx +45 -2
- package/payload/tests/verify-personas.test.ts +45 -0
- package/payload/tests/view-as.test.ts +39 -0
- package/payload/tests/view-tool-needs.test.ts +46 -0
- package/payload/tokens/core.tokens.json +24 -3
package/dist/adopt.js
CHANGED
|
@@ -74,9 +74,13 @@ function routes(root, dir) {
|
|
|
74
74
|
const label = (route) => (route === '/' ? 'Home' : route.split('/').pop().replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase()));
|
|
75
75
|
function appConfig(pages) {
|
|
76
76
|
const template = readPayload('src/app.config.ts');
|
|
77
|
-
const items = pages.map((r) => ` { href: ${JSON.stringify(r)}, label: ${JSON.stringify(label(r))}, icon: LayoutGrid },`).join('\n');
|
|
77
|
+
const items = pages.map((r) => ` { href: ${JSON.stringify(r)}, label: ${JSON.stringify(label(r))}, icon: LayoutGrid, needs: 'public' },`).join('\n');
|
|
78
78
|
return template
|
|
79
79
|
.replace(/^import \{[\s\S]*?\} from 'lucide-react';/m, "import { LayoutGrid, type LucideIcon } from 'lucide-react';")
|
|
80
|
+
// The project's pages have no feature entry yet, so every item is `public` — the honest default — and step 4 of the
|
|
81
|
+
// Start guide points each at its own feature once it has one. The template's own `FEATURES` import would name a file
|
|
82
|
+
// this project's `src/data/` need not have, and a nav item without a `needs` is a type error since 0.13.0.
|
|
83
|
+
.replace(/^import \{ FEATURES \} from '\.\/data\/features\.ts';\n/m, '')
|
|
80
84
|
.replace(/export const nav: NavGroup\[\] = \[[\s\S]*?\n\];/, `/** Written by zz-meridian adopt from the project's pages: give each its own icon and group them. */\nexport const nav: NavGroup[] = [\n {\n items: [\n${items}\n ],\n },\n];`);
|
|
81
85
|
}
|
|
82
86
|
function verifyConfig() {
|
package/dist/migrations.js
CHANGED
|
@@ -238,7 +238,35 @@ export const RELEASE_MIGRATIONS = [
|
|
|
238
238
|
instructions: 'Read "A page spec" in references/customize.md. Under `## Agents` in the page\'s README.md, add `### What the agent reads`: what the page\'s shared context tells each agent, with a real line of it, and what the MCP App and the console\'s assistant each receive. `scripts/check.ts` requires it.',
|
|
239
239
|
checks: ['gate'],
|
|
240
240
|
},
|
|
241
|
+
{
|
|
242
|
+
id: 'nav-needs',
|
|
243
|
+
since: '0.13.0',
|
|
244
|
+
applies: navItemsWithoutNeeds,
|
|
245
|
+
summary: 'Every item in the project\'s `nav` carries a `needs`, so the rail shows only the destinations a person may reach.',
|
|
246
|
+
instructions: 'Read "Roles and access" in the Start guide (docs/start-a-dashboard.md). For each item in `src/app.config.ts`\'s `nav`, add `needs: \'public\'` when everyone may open that page, or point it at its feature (`needs: FEATURES.<id>.needs`); step 4 of that guide has the four steps, and `scripts/check.ts` fails the project\'s gate, naming the href, on an item without one. `adopt` writes `needs: \'public\'` for a project it sets up from now on.',
|
|
247
|
+
checks: ['gate'],
|
|
248
|
+
},
|
|
241
249
|
];
|
|
250
|
+
/** The project's own nav, with any item still missing the `needs` that 0.13.0 requires of it. */
|
|
251
|
+
function navItemsWithoutNeeds(root) {
|
|
252
|
+
const rel = 'src/app.config.ts';
|
|
253
|
+
const body = text(root, rel);
|
|
254
|
+
if (body === null)
|
|
255
|
+
return null;
|
|
256
|
+
const nav = /export const nav\b[^=]*=\s*\[([\s\S]*?)\n\];/.exec(body)?.[1];
|
|
257
|
+
if (nav === undefined)
|
|
258
|
+
return null;
|
|
259
|
+
// One item per line, as `adopt` writes them and as a hand-kept nav keeps them, so the item is the line naming its
|
|
260
|
+
// `href`: a group's opening line — every project groups its nav — names no `href` and carries no `needs` of its own,
|
|
261
|
+
// so counting it as an item would report a change no edit can settle. Anything the file does beyond that is the
|
|
262
|
+
// team's own shape, and none of it is read.
|
|
263
|
+
const items = nav.split('\n').filter((l) => /\bhref\s*:/.test(l));
|
|
264
|
+
// A project of 0.12.3's vintage also declares `NavItem` itself, without the property: an item that names a `needs` its
|
|
265
|
+
// own type does not have is the type error the gate prints next, so the change is not done until the type carries it.
|
|
266
|
+
const declaredType = /export type NavItem[^;]*\};/.test(body);
|
|
267
|
+
const typeHasNeeds = /export type NavItem[^;]*\bneeds\s*:/.test(body);
|
|
268
|
+
return items.some((l) => !/\bneeds\s*:/.test(l)) || (declaredType && !typeHasNeeds) ? [rel] : null;
|
|
269
|
+
}
|
|
242
270
|
/** The page specifications under the console and embed folders that lack the part the 0.9.0 check requires. */
|
|
243
271
|
function pagesWithoutAgentReads(root) {
|
|
244
272
|
const files = [];
|
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,7 +2,89 @@
|
|
|
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
|
-
## [
|
|
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
|
+
|
|
27
|
+
## [0.13.0] · 2026-10-11
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **A navigation check may carry its own budget.** An entry in `scripts/verify.config.ts`'s `navigationChecks` gains an optional `budget` — `{ shellMs: { phone: 300 } }` — which stands in for the shared `budgets.navigation` figure on that one metric and device for that one route; every figure it does not name, and every other route, keeps the shared one, and the shared figure itself is unchanged. The template uses it once: `/system/start/start-a-dashboard`, the longest reading page in the repository, whose phone shell measures 243-282 ms on the 4x-throttled profile against the shared 250. Beside it in the config is what that cost is not — this route's payload is smaller than the commit before the access work (152,388 bytes against 157,080), and `585349a` measures the same with byte-identical HTML — and the measurement lives in the initiative's review Backlog. Additive: a config with no `budget` behaves exactly as before.
|
|
32
|
+
- **The consistency gate fails a nav item whose `needs` is not a need.** `node scripts/check.ts` reads `nav` from `src/app.config.ts` and fails, naming the href, when an item's `needs` is neither `'public'`, nor a grant (`object:read|create|update|remove`), nor `{ allOf: [...] }` of grants — including the empty and the nested `allOf`. It checks the shape of a need and never the role table, so it passes whatever grants a project declares, and it holds in a product as well as in the template because `src/app.config.ts` is every project's own file.
|
|
33
|
+
- **`pnpm verify --as <persona>` signs the browser in as someone else, and is opt-in.** `scripts/verify.config.ts` declares five personas (`owner`, `admin`, `member`, `key-manager`, `viewer`) with the member id each stands for, its hand-written `expectedRoutes` and the protected string each of its denied routes must not leak. The run compares the rail with those routes, presses the role-dependent controls (`/keys`, `/members`, `/settings`) at 1440px, and opens every other rail route expecting NoAccess. An unknown persona exits non-zero naming the declared ones and `--as` cannot combine with `--perf`, both before anything is built. Without `--as`, `pnpm verify` is unchanged. `scripts/lib/chrome.ts`'s `Page.open` gains an optional `cookie` (set before the first request), `scripts/rail.ts` is new (it prints the rail's links as the browser drew them), and `scripts/audit.ts`, `scripts/interactions.ts` and `scripts/keyboard.ts` gain `--as`; `interactions.ts` also gains `--width`. All additive.
|
|
34
|
+
- **The Members page shows a Roles & access matrix, generated from the tables.** `src/views/roles-matrix.tsx` is new: `matrixRows()` and `RolesMatrix()` build a card whose columns are `MAIN_ROLES` then `ADD_ONS` and whose rows are the non-public features (their `title`) then the actions (their `label`), each ticked where `whoCan(need)` says that role's own grants satisfy the whole need, with a legend saying that an add-on adds to a main role and that the member rules still apply. It renders on `/members` inside the page's own stack, so the page's gate is its gate, and the table scrolls inside its own frame at a phone's width. Nothing is listed by hand — editing `src/data/roles.ts` or `src/data/features.ts` edits the section.
|
|
35
|
+
- **Every member write passes one boundary.** `src/data/member-mutations.ts` is new: `membersFor(scope)` binds the members collection so its `create`, `update` and `remove` all pass the boundary; `assignMemberRole({ id, role, addOns })` is the one assignment path; `memberAccess(id)` answers a row's capabilities, its role and add-on choices and its target-only reason; `invitableRoles()` is the invite choices. It re-resolves the caller from the request's own scope, enforces the Owner, self, give-Owner and last-active-Owner rules with the fixed messages, and runs every write in one promise queue per workspace.
|
|
36
|
+
- **The gate is one idiom.** `src/data/access.ts` gains `gate(feature)` and `requireNeed(need)`: a dashboard page opens with the gate — a person who may not open it gets the NoAccess view as a normal 200, and a request with no session is sent to `/sign-in` — and a view tool calls `requireNeed` before it reads. Both ask `may`, and `may` answers from `effective(role, addOns)`, the one union rule, so the policy and the role table cannot drift.
|
|
37
|
+
- **The empty state has a no-access kind.** `EmptyState` gains `kind="no-access"`: a lock mark on the neutral disc (`fill-track`, `ink-3`), `data-no-access` on its root, and no `role="alert"` (only `error` is an alert). `src/views/no-access.tsx` generates its copy from the role table — `noAccessCopy(need, title)` names the feature and the roles whose own grants satisfy the whole need, and says a main role plus an add-on is needed when no single role does.
|
|
38
|
+
- **A member's role and add-ons are data, not a fixture list.** The role table moved into a new `src/data/roles.ts` (`MAIN_ROLES`, `ADD_ONS`, `GRANTS`, `effective(role, addOns)`, `whoCan(need)`), and `ROLES` left the member fixture and `src/data/sample.ts`. A member record now carries `role` (a main role, default `Member`) and `addOns` (default `[]`, duplicates removed), so the members collection refuses an unknown role or add-on and an update that does not name either leaves it as it was. Anything importing `ROLES` from `@/data/sample` takes `MAIN_ROLES` from `@/data/roles` instead.
|
|
39
|
+
- **The demo signs in as whoever the View as cookie names.** `src/data/access.ts` reads the `zz_meridian_view_as` cookie on every request and answers grants from the member's own record: no cookie signs in as Maya Chen (`members_1`), the five frozen personas are `PERSONAS`, and an unknown or non-Active id gives no session — never the Owner. The scope keeps its three fields but its values change: `subjectId` is a member id and `authorizationKey` is `demo:<id>:<role>:<add-ons deduped and sorted>`, so code that hard-coded `subjectId: 'owner'` or `demo:1` must read the scope instead. `src/data/view-as.ts` (`'use server'`) adds `chooseViewAs(id)`, which sets the cookie for an Active persona and throws a readable message otherwise.
|
|
40
|
+
- **The demo sign-in offers the personas.** Without `DEMO_PASSWORD`, the sign-in page lists the five personas above the product's sign-in panel — each with their name and role, a non-Active one disabled — and a click opens the console as that person through the View as cookie; with `DEMO_PASSWORD`, "Open the demo" gains an "Open as" select (default Maya Chen) beside the password and sets both the gate and the View as cookie. A persona outside the list, or one whose record is not Active, sets nothing, and the choice never bypasses the password.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **The Members page offers each row only what that person may do.** A row's menu is drawn from the boundary's answer for that row and that target: withheld controls are not rendered at all, a row that offers nothing says why, and a caller without the member grants sees no row menu, no Invite member control and one line saying who can (`Owners and Admins manage members.`). New: a `Change role` row action opening a sheet whose role select and add-on choices are exactly what that row allows, submitting through `assignRole`; the Activity line is written by the boundary, not the page. The phone card now carries the role with its add-ons (`Member + Key manager`) — and since Meridian's card shows three facts, Joined moved to the table only and the card keeps Last active.
|
|
45
|
+
- **The assistant never exceeds the person it acts for.** `src/lib/collection.ts` gains `pageOnlyFields` (fields an agent reads and never writes) and `writableFields(c)`; the members collection declares `pageOnlyFields: ['role', 'addOns']`, so `create_members` and `update_members` leave them out of their schemas and refuse them if sent anyway. `app/api/assistant/route.ts` registers a `view_<name>` tool only where the person satisfies that view's need, hands `members` in through the boundary, and forces an agent's member create to `role: 'Member'` with no add-ons (FR-12). `src/lib/assistant/scoped.ts` is new: `scopedQuery(collection, scope, name)` re-checks the same tenant and subject and the read grant at the moment a query runs, so a tool listed before a demotion refuses after it. Nothing to do for an adopter unless they declare their own `pageOnlyFields`.
|
|
46
|
+
- **The demo policy binds `members` through the boundary.** `src/data/access.ts`'s `bind(scope, 'members')` returns `membersFor(scope)`, and `app/(dashboard)/members/actions.ts` routes `inviteMember`, `setMemberStatus` and `removeMember` through it, exports `assignRole`, and no longer invalidates the tag itself. A person may no longer suspend or remove themselves, and a change that would leave the workspace with no active Owner is refused — including a status change through `update`, which is new.
|
|
47
|
+
- **The three paths that skipped the permission read go through the data layer.** `src/data/collections.ts` gains `customers` (the twelve sample rows, read-only) and `workspace` (one record: the name, address and time zone the Settings form shows), `/customers` and the customers view tool reach them through `read()`, so `customers:read` is asked on the same path as every other collection, and Settings saves through a new server action (`saveWorkspace`) that asks `ACTIONS['save-workspace'].needs`. The Analytics heatmap, hours and regions come from `analyticsFigures()` in `src/data/analytics.ts`, which refuses a scope without `requests:read`. `CUSTOMERS` is no longer re-exported by `src/data/sample.ts` — take it from `@/system/fixtures/sample-records`, or read the `customers` collection; `SettingsBody` gains optional `workspace` and `save` props.
|
|
48
|
+
- **A general action is shown only where the person may perform it.** `KeysView` takes `may: { create; revoke }` and `line`, and `RequestView` takes `mayReplay` and `replayLine`: each control is hidden — never drawn disabled, never left to fail on submit — when the person may not use it, and the page shows one line saying who can. `ACTIONS` in `src/data/features.ts` gains an optional `line`, and `src/views/no-access.tsx` exports `actionLine(id)`. Both view props are optional, so an existing direct caller of a view is unaffected.
|
|
49
|
+
- **Settings shows each section by what a person may do.** `SettingsBody` takes a required `may: { workspaceRead; workspaceUpdate; workspaceRemove }`, computed on the server page from the grant table. The Workspace section appears only to someone who may read it — read-only, with the line "Only an Owner can change the workspace." and no save bar, when they may not change it — and the Danger zone only to someone who may remove the workspace; a Member or Viewer no longer sees the Workspace section at all. Notifications, Appearance, Assistant and Agents and MCP stay for everyone.
|
|
50
|
+
- **A view tool carries its feature's need and asks it before it reads.** `ViewTool` gains a required `needs: Need`, so an adopter's own view tools must declare one (`needs: FEATURES.<id>.needs`) and call `await requireNeed(FEATURES.<id>.needs)` as the first statement of their `read`. Tool filtering by need is what keeps the assistant's `view_*` tools matching the person it acts for.
|
|
51
|
+
- **Every dashboard page asks its feature before it reads.** Each page opens with its gate, `/keys` drops its hand-written check, and `/requests/[id]`'s `generateMetadata` answers the feature title `Request` for a person who may not open it without reading the record. A page that awaits its gate is an async page, so `/`, `/analytics`, `/customers` and `/requests` stream their masthead with the gate rather than in the static shell, and `OverviewPage`/`AnalyticsPage` accept a defaulted props object.
|
|
52
|
+
- **`NoAccess` takes a feature, not a title, and is async.** `src/views/no-access.tsx` exports `NoAccess({ feature }: { feature: FeatureId })` — awaited — in place of `NoAccess({ title })`; a caller that passed `title` passes a `FeatureId` and awaits it.
|
|
53
|
+
- **A nav item carries its feature's need, so the rail asks one table.** `src/data/features.ts` is new: `FEATURES` (what each page needs), `ACTIONS` (what each control needs), and the `FeatureId` and `ActionId` types. Every item in `src/app.config.ts`'s `nav` now writes `needs: FEATURES.<id>.needs` by reference, and `NavItem` gains a required `needs: Need` — so every adopter's nav item must add one: import `FEATURES` and point each item at its feature, or write a `Need` (`'keys:read'`, `{ allOf: [...] }`, `'public'`) directly. The rail and the palette then show only the destinations the current person may reach: a Viewer sees eight of the ten (`/keys` and `/members` are gone) and every other role sees all ten. This is presentation only — a destination kept out of the rail is still reachable by its address, and every page keeps its own check.
|
|
54
|
+
- **The console's chrome takes the request as a promise.** `ConsoleRail` no longer takes `user`, and neither it nor `ConsolePalette` takes `only`; both take `access?: Promise<ChromeAccess | null>` — `chromeAccess()` from `src/data/access.ts` — which the layout passes without awaiting. Each piece of chrome waits for it in its own `<Suspense>` boundary, so the frame stays in the static shell and the person's own rail streams in the same response: nothing about the identity is drawn and then taken away. A product that passed `only` or `user` passes `access={chromeAccess()}` instead, or its own promise of the same shape — a console with no sign-in passes no `access` at all. `skills/zz-meridian/references/existing-project.md` shows the new call.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **The demo policy binds the customers and the workspace.** `DEMO_COLLECTIONS` in `src/data/access.ts` gains `customers` and `workspace`, so the `customers:read` and `workspace:*` grants the role table freezes are reachable through `read()` — without them `/customers` refused everybody, the Owner included.
|
|
59
|
+
- **`/keys` asks `keys:read` before it reads.** The keys page checks the caller's grant first: a Viewer gets a minimal NoAccess view (a normal 200 holding none of the keys' records) and a request with no session is redirected to `/sign-in`, both before any read. The new `src/views/no-access.tsx` holds that view.
|
|
60
|
+
- **A key records the person who made it.** Creating an API key stored the sample's own person as the owner, so a key Lucas Meyer created said Maya Chen made it. The owner is now the caller the policy names.
|
|
61
|
+
- **A closed browser check leaves no Chrome profile behind**: `scripts/lib/chrome.ts` removed a closed browser's profile from a timer that never ran when the script exited at once, so every `verify`, audit or shot left one in the temp folder. It now removes it as soon as the browser is closed — in the background, never on the caller's path. A suite closes its browser inside a hook, a hook has a ten-second ceiling, and a profile that has loaded many pages runs to tens of megabytes, so removing one synchronously timed a hook out on a runner (`Hook timed out in 10000ms`, `tests/deep-dom.test.ts`'s `afterAll`) after first failing with `ENOTEMPTY: directory not empty` when Chrome was still writing into it. That test now asserts the close does not do that work on the caller's path. A project brought in with `adopt` gets the fix with `update`.
|
|
62
|
+
- **A card is one class**: Card's look moved from a dozen utilities to `.card` in `base.css`, and the Data table's wrapper uses it too, so every page carries less HTML (`/health` from 102,225 to 98,154 bytes, `/requests` from 148,614 to 141,568). A utility on a card still overrides it.
|
|
63
|
+
- **Removing a member is undone, not confirmed.** Remove takes the person off the table at once with a toast offering Undo; the removal is sent when the toast closes (`UNDO_MS`, 8 s, now exported by the Toast) or at once if the page goes away first, and a refusal brings them back with its reason. Revoking an API key keeps its confirmation.
|
|
64
|
+
- **A page reached by a click no longer replays its arrival** (#19). On a client navigation, or when the router shows a page it kept, the shell marks its main area `data-still`: arrivals end at once in their final state, and the mark lifts after `dur-grow` + `dur-enter`, so a later change on the page (a new period) still animates. The first load of a visit arrives as before.
|
|
65
|
+
- **`navigate.ts` times real content** (#18): its `data` time no longer counts a skeleton's rows or the rows of a page the router keeps hidden, so a `loading.tsx` that holds a click for 300 ms or more now shows in `data`, where it read as the skeleton's 30 ms before.
|
|
66
|
+
- **A rail link answers a click before its page arrives** (#18): its icon becomes a Spinner while a page that is not prefetched is on the way (`useLinkStatus`). `cache.md`, `customize.md` and `standard.md` now say what a request-time read costs a navigation (React holds a shown fallback 300 ms or more) and to read through `read()` wherever the data allows.
|
|
67
|
+
- **The rail's marker measures after paint**, so opening the phone drawer costs one render and no forced layout; the current link draws the same pill until it has.
|
|
68
|
+
- **A trend chart's readout stays inside its chart**: it measures its own width, wraps a long label and keeps an extreme value whole; on a phone it pins to the plot's leading edge. Money axis ticks from $10,000 read at a glance (`$25K`, `$5.0B`). `scripts/shot.ts --point "<chart>" --at 0.6` captures a readout.
|
|
69
|
+
- **A sort or a page press is answered before the server is.** `useQueryState` returns a third value, `pending`, and shows the asked-for state at once (`useOptimistic` inside the navigation's transition); the DataTable takes `busy` (rows at 60% opacity, `aria-busy`) until the new rows land. A caller that destructures two values is unaffected.
|
|
70
|
+
- **Dates and figures reuse their Intl formatters**: `formatDate` and the money and count formatters build their `Intl` formatter once instead of on every call (200 dates: 209 ms to 2.5 ms), which shortens the first tap on Alerts or the drawer on a phone.
|
|
71
|
+
|
|
72
|
+
### Breaking
|
|
73
|
+
|
|
74
|
+
Ten changes need a line from anyone updating a project. Each says what to do instead.
|
|
75
|
+
|
|
76
|
+
- **A nav item carries a required `needs`.** Every item in `src/app.config.ts`'s `nav` writes one: import `FEATURES` and point the item at its feature (`needs: FEATURES.<id>.needs`), or write the `Need` yourself (`'keys:read'`, `{ allOf: [...] }`, `'public'`). `node scripts/check.ts` now fails a malformed one, naming the href.
|
|
77
|
+
- **A view tool carries a required `needs`.** An adopter's own view tools declare `needs: FEATURES.<id>.needs` and call `await requireNeed(FEATURES.<id>.needs)` as the first statement of their `read`.
|
|
78
|
+
- **A dashboard page that awaits its gate is an async page.** `/`, `/analytics`, `/customers` and `/requests` stream their masthead with the gate rather than in the static shell, and `OverviewPage`/`AnalyticsPage` accept a defaulted props object. A test that asserted the static shell on one of those routes, or code calling a page component synchronously, updates the assertion or the call.
|
|
79
|
+
- **`NoAccess` takes a feature, not a title, and is awaited.** `NoAccess({ feature }: { feature: FeatureId })` replaces `NoAccess({ title })`: a caller that passed a title passes a `FeatureId` and awaits it.
|
|
80
|
+
- **`SettingsBody` takes a required `may`.** `{ workspaceRead, workspaceUpdate, workspaceRemove }`, computed on the server page from the grant table. A direct caller that passed nothing computes and passes it. A Member or Viewer no longer sees the Workspace section at all.
|
|
81
|
+
- **`ConsoleRail` and `ConsolePalette` take the request as a promise.** Both drop `user` and `only` for `access?: Promise<ChromeAccess | null>` — `chromeAccess()` from `src/data/access.ts` — which the layout passes without awaiting and each piece of chrome waits for in its own `<Suspense>` boundary. Pass `access={chromeAccess()}`, or your own promise of the same shape; a console with no sign-in passes none.
|
|
82
|
+
- **The demo policy's scope values change.** `subjectId` is a member id, not `'owner'`, and `authorizationKey` is `demo:<id>:<role>:<add-ons deduped and sorted>`, not `demo:1`. Code that hard-coded either reads the scope instead.
|
|
83
|
+
- **`ROLES` left `src/data/sample.ts`.** The role table is `src/data/roles.ts` (`MAIN_ROLES`, `ADD_ONS`, `GRANTS`, `whoCan`): an importer takes `MAIN_ROLES` from `@/data/roles`, and a member record now carries `role` and `addOns`.
|
|
84
|
+
- **`CUSTOMERS` is no longer re-exported by `src/data/sample.ts`.** Take the twelve rows from `@/system/fixtures/sample-records`, or read the `customers` collection through `read()`.
|
|
85
|
+
- **A person may no longer suspend or remove themselves, and the workspace always keeps one active Owner.** The member boundary refuses both — including a status change through `update`, which is new — with its frozen messages. A UI that offered either gets the refusal, and `memberAccess(id).reason` answers the row's own reason.
|
|
86
|
+
|
|
87
|
+
**An update to 0.13.0 reports one migration.** `nav-needs`: every item in a project's `nav` needs a `needs`, which the rail asks before it draws a destination. `update` names the change and the file it is in, and `--finalize` runs the project's gate, which fails, naming the href, until the change is made — `needs: 'public'` for a page everyone may open, or the page's own feature (`needs: FEATURES.<id>.needs`), as step 4 of the Start guide has it. A project whose `NavItem` type predates this release has to carry the property too. `adopt` writes `needs: 'public'` into the projects it sets up from now on, so only a project that predates this release makes the change by hand.
|
|
6
88
|
|
|
7
89
|
## [0.12.3] · 2026-10-10
|
|
8
90
|
|
|
@@ -22,7 +104,7 @@ Every release of ZZ Meridian, newest first. Versions follow semver: a removed or
|
|
|
22
104
|
- **The search pill folds by the top bar's own width** (under 40rem), so a canvas narrowed by the assistant folds it too. The top bar is now a container.
|
|
23
105
|
- **A read-only form section keeps its values legible** in `ink-2` instead of `ink-disabled`.
|
|
24
106
|
- **A phone's record card reads its facts as one sentence**: the DataTable card is a wrapping row instead of a six-column grid, so a separator dot never lands alone on a line and a fact is never cut to fit a third.
|
|
25
|
-
- **Members shows the date its order follows**: Joined appears from 768px of table (Role now drops below 1024px and Team below 1152px), so the newest-first order is explained wherever the table has columns; on a phone the card reads "Joined
|
|
107
|
+
- **Members shows the date its order follows**: Joined appears from 768px of table (Role now drops below 1024px and Team below 1152px), so the newest-first order is explained wherever the table has columns; on a phone the card reads "Joined 01 Oct 2026", in the same form as its "Active 07 Jul 2026" and as API keys' "Created 05 Mar 2026": members joined across two years, so a day without its year would be misread.
|
|
26
108
|
- **The Atlas home drops its numbered section labels** (01 to 05), a default Meridian's own register rules out.
|
|
27
109
|
- **A figure is never cut.** In a Metric tile or a Featured metric a number longer than nine characters is set at a glance with `fitFigure` ("9.9B", "$1.2M") instead of being clipped by its card; `splitFigure` keeps a compact amount's decimal in the number ("$1.2M", not "$1" and ".2M").
|
|
28
110
|
- **A day inside a sentence reads "1 Oct"**: `formatDay` in `src/lib/format-date.ts`; tables and axes keep `formatDate`'s "01 Oct".
|
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
|
|
|
@@ -5,6 +5,7 @@ import { Freshness } from '@/components/patterns/freshness';
|
|
|
5
5
|
import { PeriodSelect } from '@/components/patterns/period-select';
|
|
6
6
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
7
7
|
import { OverviewBody } from '@/views/overview';
|
|
8
|
+
import { gate } from '@/data/access';
|
|
8
9
|
import { DEMO_NOW, DEMO_UPDATED_AT } from '@/data/sample';
|
|
9
10
|
import { overviewTool } from '@/views/tools';
|
|
10
11
|
import { cache } from 'react';
|
|
@@ -20,8 +21,10 @@ type SearchParams = Promise<{ period?: string; day?: string }>;
|
|
|
20
21
|
/** The page's one read, through the Overview tool (`src/views/tools.ts`), shared by the export and the body. */
|
|
21
22
|
const readPeriod = cache((period: ReturnType<typeof parsePeriod>, day?: string) => overviewTool.read({ period, day: /^\d{4}-\d{2}-\d{2}$/.test(day ?? '') ? day : undefined }));
|
|
22
23
|
|
|
23
|
-
/** The
|
|
24
|
-
export default function OverviewPage({ searchParams }: { searchParams: SearchParams }) {
|
|
24
|
+
/** The page asks its feature before it renders — the rail hiding `/` is presentation, not this gate — then the masthead renders and the period select, the export and the body read the address inside their own boundaries. Next always passes the address; the default keeps the component callable with none, which a check that renders it bare relies on. */
|
|
25
|
+
export default async function OverviewPage({ searchParams }: { searchParams: SearchParams } = { searchParams: Promise.resolve({}) }) {
|
|
26
|
+
const denied = await gate('overview');
|
|
27
|
+
if (denied) return denied;
|
|
25
28
|
return (
|
|
26
29
|
<PageFrame
|
|
27
30
|
kicker={<>{app.name} · {app.workspace}</>}
|
|
@@ -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
|
);
|
|
@@ -6,6 +6,7 @@ import { Freshness } from '@/components/patterns/freshness';
|
|
|
6
6
|
import { PeriodSelect } from '@/components/patterns/period-select';
|
|
7
7
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
8
8
|
import { AnalyticsBody } from '@/views/analytics';
|
|
9
|
+
import { gate } from '@/data/access';
|
|
9
10
|
import { DEMO_NOW, DEMO_UPDATED_AT } from '@/data/sample';
|
|
10
11
|
import { analyticsTool } from '@/views/tools';
|
|
11
12
|
import { cache } from 'react';
|
|
@@ -17,8 +18,10 @@ export const metadata = { title: 'Analytics' };
|
|
|
17
18
|
|
|
18
19
|
type SearchParams = Promise<{ period?: string }>;
|
|
19
20
|
|
|
20
|
-
/** The
|
|
21
|
-
export default function AnalyticsPage({ searchParams }: { searchParams: SearchParams }) {
|
|
21
|
+
/** The page asks its feature before it renders — the rail hiding `/analytics` is presentation, not this gate — then the masthead renders and the period select, the export and the body read the address inside their own boundaries. Next always passes the address; the default keeps the component callable with none, which a check that renders it bare relies on. */
|
|
22
|
+
export default async function AnalyticsPage({ searchParams }: { searchParams: SearchParams } = { searchParams: Promise.resolve({}) }) {
|
|
23
|
+
const denied = await gate('analytics');
|
|
24
|
+
if (denied) return denied;
|
|
22
25
|
return (
|
|
23
26
|
<PageFrame
|
|
24
27
|
kicker={<>{app.name} · {app.workspace}</>}
|
|
@@ -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
|
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { Suspense } from 'react';
|
|
2
2
|
import { app } from '@/app.config';
|
|
3
3
|
import { PageFrame } from '@/components/base/shell';
|
|
4
|
-
import {
|
|
4
|
+
import { gate } from '@/data/access';
|
|
5
|
+
import { read } from '@/data/read';
|
|
6
|
+
import type { CustomerRecord } from '@/data/sample';
|
|
5
7
|
import { CustomersView, InviteCustomer } from '@/views/customers';
|
|
6
8
|
import { Busy, TableSkeleton, TilesSkeleton } from '../_loading';
|
|
7
9
|
|
|
8
10
|
export const metadata = { title: 'Customers' };
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The page asks its feature before it renders; the rail hiding `/customers` is presentation, never this gate. The
|
|
14
|
+
* masthead then sits outside the boundary that reads the address (the table's filters), and the boundary is where the
|
|
15
|
+
* page reads its records.
|
|
16
|
+
*/
|
|
17
|
+
export default async function CustomersPage() {
|
|
18
|
+
const denied = await gate('customers');
|
|
19
|
+
if (denied) return denied;
|
|
12
20
|
return (
|
|
13
21
|
<PageFrame
|
|
14
22
|
kicker={<>{app.name} · {app.workspace}</>}
|
|
@@ -24,8 +32,18 @@ export default function CustomersPage() {
|
|
|
24
32
|
</Busy>
|
|
25
33
|
}
|
|
26
34
|
>
|
|
27
|
-
<
|
|
35
|
+
<Customers />
|
|
28
36
|
</Suspense>
|
|
29
37
|
</PageFrame>
|
|
30
38
|
);
|
|
31
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The page's one read of the customers, through `read()`: `customers:read` is asked on the same path as every other
|
|
43
|
+
* collection, so a policy that does not bind the collection (or a person who does not hold the grant) refuses here before
|
|
44
|
+
* a row is drawn, exactly as it does for the members, the keys and the request log.
|
|
45
|
+
*/
|
|
46
|
+
async function Customers() {
|
|
47
|
+
const { rows } = await read('customers');
|
|
48
|
+
return <CustomersView rows={rows as CustomerRecord[]} />;
|
|
49
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { app } from '@/app.config';
|
|
2
|
+
import { gate } from '@/data/access';
|
|
2
3
|
import { PageFrame } from '@/components/base/shell';
|
|
3
4
|
import { Freshness } from '@/components/patterns/freshness';
|
|
4
5
|
import { PackedHealthBody, SubscribeButton } from '@/views/health';
|
|
@@ -7,8 +8,10 @@ import { packServices } from '@/components/patterns/status-list/summarise';
|
|
|
7
8
|
|
|
8
9
|
export const metadata = { title: 'Health' };
|
|
9
10
|
|
|
10
|
-
/** Rendered from the Health tool's own read (`src/views/tools.ts`)
|
|
11
|
+
/** Rendered from the Health tool's own read (`src/views/tools.ts`), once the page's feature is held: the rail's hiding is presentation, not this gate. */
|
|
11
12
|
export default async function HealthPage() {
|
|
13
|
+
const denied = await gate('health');
|
|
14
|
+
if (denied) return denied;
|
|
12
15
|
const { data } = await healthTool.read({});
|
|
13
16
|
return (
|
|
14
17
|
<PageFrame
|
|
@@ -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
|
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
4
|
import { updateTag } from 'next/cache';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { app } from '@/app.config';
|
|
7
6
|
import { clock } from '@/data/collections';
|
|
8
|
-
import { AccessDenied, can, collectionFor, resolveAccess } from '@/data/access';
|
|
7
|
+
import { AccessDenied, can, collectionFor, nameOf, resolveAccess } from '@/data/access';
|
|
9
8
|
import { collectionTag } from '@/data/read';
|
|
10
9
|
import { SCOPES } from '@/views/key-scopes';
|
|
11
10
|
import type { ApiKey } from '@/data/sample';
|
|
@@ -19,6 +18,8 @@ const draft = z.object({
|
|
|
19
18
|
/**
|
|
20
19
|
* Creates a key and returns it with its full secret: the only moment the secret exists outside the caller's own copy.
|
|
21
20
|
* What is stored is the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret.
|
|
21
|
+
* The key's owner is the caller — the person the policy names — so a key Lucas makes records Lucas, never the sample's
|
|
22
|
+
* own person; `subjectId` is the honest fallback when the policy names nobody.
|
|
22
23
|
*/
|
|
23
24
|
export async function createKey(input: z.input<typeof draft>): Promise<ApiKey & { secret: string }> {
|
|
24
25
|
const { name, env, scopes } = draft.parse(input);
|
|
@@ -27,7 +28,8 @@ export async function createKey(input: z.input<typeof draft>): Promise<ApiKey &
|
|
|
27
28
|
const secret = `zzm_${env}_${randomBytes(16).toString('hex')}`;
|
|
28
29
|
const hint = `zzm_${env}_…${secret.slice(-4)}`;
|
|
29
30
|
const secretHash = createHash('sha256').update(secret).digest('hex');
|
|
30
|
-
const
|
|
31
|
+
const owner = (await nameOf(scope)) ?? scope.subjectId;
|
|
32
|
+
const key = await collectionFor(scope, 'keys').create!({ name, env, scopes, owner, created: clock().toISOString(), lastUsed: null, hint, secretHash });
|
|
31
33
|
updateTag(collectionTag(scope.tenantId, 'keys'));
|
|
32
34
|
return { ...(key as ApiKey), secret };
|
|
33
35
|
}
|
|
@@ -1,11 +1,36 @@
|
|
|
1
|
+
import { gate, may } from '@/data/access';
|
|
2
|
+
import { ACTIONS } from '@/data/features';
|
|
1
3
|
import { read } from '@/data/read';
|
|
2
4
|
import { KeysView } from '@/views/keys';
|
|
5
|
+
import { actionLine } from '@/views/no-access';
|
|
3
6
|
import type { ApiKey } from '@/data/sample';
|
|
4
7
|
import { createKey, revokeKey } from './actions';
|
|
5
8
|
|
|
6
9
|
export const metadata = { title: 'API keys' };
|
|
7
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The keys page asks its feature first, before it reads anything: a person who may not see the keys gets the NoAccess
|
|
13
|
+
* view and no record of theirs, and a request with no session is sent to sign-in. The rail hides `/keys` from that
|
|
14
|
+
* person too, but hiding is presentation only — this check is the gate, and it runs before the read either way.
|
|
15
|
+
*
|
|
16
|
+
* After the gate it asks the action table what this person may do here, so the view draws Create key and Revoke only
|
|
17
|
+
* where they may be used. A Member opens this page (they may read keys) but may not create or revoke one, and the line
|
|
18
|
+
* says who can. The authority is a snapshot: a control shown here is still refused by the server action if it changed.
|
|
19
|
+
*/
|
|
8
20
|
export default async function KeysPage() {
|
|
21
|
+
const denied = await gate('keys');
|
|
22
|
+
if (denied) return denied;
|
|
9
23
|
const { rows, observedAt } = await read('keys');
|
|
10
|
-
|
|
24
|
+
const mayCreate = await may(ACTIONS['create-key'].needs);
|
|
25
|
+
const mayRevoke = await may(ACTIONS['revoke-key'].needs);
|
|
26
|
+
return (
|
|
27
|
+
<KeysView
|
|
28
|
+
rows={rows as ApiKey[]}
|
|
29
|
+
now={observedAt}
|
|
30
|
+
may={{ create: mayCreate, revoke: mayRevoke }}
|
|
31
|
+
line={mayCreate ? undefined : actionLine('create-key')}
|
|
32
|
+
createKey={createKey}
|
|
33
|
+
revokeKey={revokeKey}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
11
36
|
}
|
|
@@ -4,7 +4,7 @@ import { ConsolePalette, ConsoleRail } from '@/views/console-chrome';
|
|
|
4
4
|
import { ShellTools } from '@/components/patterns/shell-tools';
|
|
5
5
|
import { assistantConfig } from '@/lib/assistant/config';
|
|
6
6
|
import { DEMO_NOW, ALERTS } from '@/data/sample';
|
|
7
|
-
import { resolveAccess } from '@/data/access';
|
|
7
|
+
import { chromeAccess, resolveAccess } from '@/data/access';
|
|
8
8
|
import { ConsoleLive } from '@/views/console-live';
|
|
9
9
|
|
|
10
10
|
/** Every console page: the rail on the frame, the page on the canvas. Put your sign-in gate here. */
|
|
@@ -15,10 +15,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|
|
15
15
|
const assistant = connection().then(() => assistantConfig(process.env) !== null);
|
|
16
16
|
// The scope key reaches the live provider the same way, as a promise it resolves behind its own boundary.
|
|
17
17
|
const scope = resolveAccess().then((s) => `${s.tenantId}/${s.subjectId}`);
|
|
18
|
+
// The rail's destinations, the signed-in person and the View as group, one promise for both pieces of chrome: not
|
|
19
|
+
// awaited, so the frame is still the first thing streamed. An identity nobody can resolve settles on null, which the
|
|
20
|
+
// chrome draws as the placeholder person and no destination — never the sample person.
|
|
21
|
+
const access = chromeAccess().catch(() => null);
|
|
18
22
|
return (
|
|
19
|
-
<AppShell rail={<ConsoleRail />} tools={<ShellTools alerts={ALERTS} now={DEMO_NOW} />} assistant={assistant}>
|
|
23
|
+
<AppShell rail={<ConsoleRail access={access} />} tools={<ShellTools alerts={ALERTS} now={DEMO_NOW} />} assistant={assistant}>
|
|
20
24
|
<ConsoleLive scope={scope}>{children}</ConsoleLive>
|
|
21
|
-
<ConsolePalette />
|
|
25
|
+
<ConsolePalette access={access} />
|
|
22
26
|
</AppShell>
|
|
23
27
|
);
|
|
24
28
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Members page
|
|
2
2
|
|
|
3
|
-
Who is in the workspace, what role and team they hold, and when they last used it: invite someone, suspend or reactivate them, or remove them.
|
|
3
|
+
Who is in the workspace, what role and team they hold, and when they last used it: invite someone, change their role and add-ons, suspend or reactivate them, or remove them.
|
|
4
4
|
|
|
5
5
|
Status: beta
|
|
6
6
|
|
|
@@ -8,12 +8,14 @@ Status: beta
|
|
|
8
8
|
|
|
9
9
|
| Row | Pattern | Console | Mobile |
|
|
10
10
|
|---|---|---|---|
|
|
11
|
-
| Masthead | PageFrame: kicker, "Members", one sentence, Invite member (primary) | One band | Stacked |
|
|
12
|
-
| 1 | Data table: Name (avatar and name, grow), Email (from
|
|
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 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
|
+
| 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 |
|
|
13
14
|
| Sheet | Invite a member: Name and Email (required), Role, Team; Cancel and Send invitation | From the right, 440px | From the bottom |
|
|
14
|
-
|
|
|
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 |
|
|
16
|
+
| Toast | Remove: the person leaves the table at once and a toast says "{name} removed · They lose access when this closes" with Undo. The removal is sent when the toast closes (8 s, `UNDO_MS`), or at once if the page goes away first; Undo brings them back and sends nothing; a refusal brings them back with its reason | Bottom right | Top, full width |
|
|
15
17
|
|
|
16
|
-
The row menu holds Suspend (Reactivate on a suspended member) and Remove.
|
|
18
|
+
A row the caller may not change draws no menu at all: its own reason where the restriction is the target's — the first rule in the boundary's frozen order that hides the action, such as "Only an Owner can change or remove an Owner." on an Owner's row read by an Admin — and nothing where the caller simply lacks the grant, which the page's one line above the table already says ("Owners and Admins manage members."). The row menu holds Change role, Suspend (Reactivate on a suspended member) and Remove, each shown only where the boundary allowed it; an invited row offers none until the server gives it a capability.
|
|
17
19
|
|
|
18
20
|
## States
|
|
19
21
|
|
|
@@ -21,16 +23,19 @@ The row menu holds Suspend (Reactivate on a suspended member) and Remove.
|
|
|
21
23
|
|---|---|
|
|
22
24
|
| Loading | `loading.tsx`: skeleton table rows; the members are read per request, so the prerendered shell holds none |
|
|
23
25
|
| Default | Newest members first, as the Joined column beside them shows |
|
|
24
|
-
| Invited | The status Badge in `accent`; Last active reads "Never" in `ink-3
|
|
26
|
+
| Invited | The status Badge in `accent`; Last active reads "Never" in `ink-3`; no row actions |
|
|
25
27
|
| Suspended | The status Badge in `warning` |
|
|
26
28
|
| Name or email missing | The field's error under it; nothing is sent |
|
|
27
|
-
| Changed | The page refreshes from the collection and a toast confirms: "
|
|
28
|
-
| Rejected | The table goes back to what it was. A refused invitation reopens the sheet with what was typed and a critical Banner, "Invitation not sent", with the reason; a refused status change or removal shows a critical toast with the reason |
|
|
29
|
+
| Changed | The page refreshes from the collection and a toast confirms: "Role changed", "Member suspended", "Member reactivated", "Invitation sent to {email}"; a removal's toast is its Undo, "{name} removed" |
|
|
30
|
+
| Rejected | The table goes back to what it was. A refused invitation reopens the sheet with what was typed and a critical Banner, "Invitation not sent", with the reason; a refused role change reopens the Change role sheet with the draft it was sent from and a critical Banner, "Role not changed", with the reason; a refused status change or removal shows a critical toast with the reason |
|
|
29
31
|
| No members | The first-run empty state with Invite member |
|
|
32
|
+
| May not invite | No Invite member in the masthead and none in the empty state, because the boundary offers no invitable role |
|
|
30
33
|
|
|
31
34
|
## Data
|
|
32
35
|
|
|
33
|
-
The `members` collection in `src/data/collections.ts`, read with `members.query({})` at request time. Server actions in `actions.ts` call `members.create` (status `Invited`, joined today by the data's clock, never active), `members.update` (status)
|
|
36
|
+
The `members` collection in `src/data/collections.ts`, read with `members.query({})` at request time. Server actions in `actions.ts` call `members.create` (status `Invited`, joined today by the data's clock, never active), `members.update` (status), `members.remove` and `assignRole`; the view refreshes the page after each and keeps no copy of the rows. Last active is relative to the data's clock.
|
|
37
|
+
|
|
38
|
+
Every one of those writes passes `src/data/member-mutations.ts`, which re-resolves the caller, checks the operation's grant and refuses a change or removal of an Owner without `workspace:update`, one's own role or add-ons, suspending or removing oneself, and any change that would leave no active Owner. The page reads that boundary per row, once, and is handed only what it allows: `memberAccess(id)` gives the row's booleans, its role choices, its add-on choices and its reason, and `invitableRoles()` gives the roles an invitation may carry. The page decides nothing from a role of its own. A role or add-on change is one `assignRole` write, with its Activity line in the same step; the matrix in row 2 asks `whoCan` over the same `GRANTS` the boundary uses.
|
|
34
39
|
|
|
35
40
|
## Embed view
|
|
36
41
|
|
|
@@ -38,7 +43,7 @@ Not offered. Inviting and removing people are decisions for the console, not a c
|
|
|
38
43
|
|
|
39
44
|
## Surfaces
|
|
40
45
|
|
|
41
|
-
- **Console**: as above. **Mobile**: cards, the
|
|
46
|
+
- **Console**: as above. **Mobile**: cards, the sheets rise from the bottom. **Embed**: not offered.
|
|
42
47
|
|
|
43
48
|
## Agents
|
|
44
49
|
|
|
@@ -46,7 +51,7 @@ The assistant reads and proposes changes here through Proposals: it adds, change
|
|
|
46
51
|
|
|
47
52
|
### What the agent reads
|
|
48
53
|
|
|
49
|
-
Counts by status, role (with what each role may do) and team, everyone with their role, team, status and when they were last seen (25 at most; `query_members` finds the rest), and what code found: active members unseen for 60 days or more, unanswered invitations, and who can change the workspace. It says that "last active" is a console sign-in, not use of the API.
|
|
54
|
+
Counts by status, role (with what each role may do) and team, everyone with their role, add-ons, team, status and when they were last seen (25 at most; `query_members` finds the rest), and what code found: active members unseen for 60 days or more, unanswered invitations, and who can change the workspace. It says that "last active" is a console sign-in, not use of the API.
|
|
50
55
|
|
|
51
56
|
```text
|
|
52
57
|
- 3 active members have not been seen for 60 days or more: Alice Moreno (Viewer, Support, 94 days), Felix Andersson (Viewer, Engineering, 75 days), Ravi Patel (Viewer, Support, 71 days).
|
|
@@ -57,5 +62,7 @@ Counts by status, role (with what each role may do) and team, everyone with thei
|
|
|
57
62
|
|
|
58
63
|
## Content
|
|
59
64
|
|
|
60
|
-
- One verb through each flow: "Remove
|
|
65
|
+
- One verb through each flow: "Remove", then "{name} removed" with Undo. A removal is undone, not confirmed: it is the change a person makes on purpose and regrets by accident. Revoking an API key stays a confirmation, because requests using the key fail the moment it is sent.
|
|
61
66
|
- Statuses are words ("Invited", "Suspended"), never colour alone.
|
|
67
|
+
- A role reads the way a person holds it: "Member + Key manager", never "Member" with the add-on left to be inferred.
|
|
68
|
+
- The matrix names roles, not grants: the grant table stays in `src/data/roles.ts`, and a reader who wants it reads the page's own section instead of a list of permissions here.
|