zz-meridian 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/reconcile.js +6 -3
  2. package/package.json +1 -1
  3. package/payload/CHANGELOG.md +17 -0
  4. package/payload/app/(dashboard)/keys/README.md +3 -2
  5. package/payload/app/(dashboard)/keys/actions.ts +11 -5
  6. package/payload/app/(dashboard)/members/README.md +1 -1
  7. package/payload/app/(dashboard)/requests/[id]/README.md +5 -3
  8. package/payload/app/(dashboard)/requests/[id]/actions.ts +34 -0
  9. package/payload/app/(dashboard)/requests/[id]/page.tsx +2 -1
  10. package/payload/app/system/(atlas)/page.tsx +1 -1
  11. package/payload/decisions/0010-the-adopter-contract.md +1 -1
  12. package/payload/docs/assistant.md +1 -1
  13. package/payload/docs/benchmark.md +1 -1
  14. package/payload/docs/distribution.md +5 -4
  15. package/payload/package.json +1 -1
  16. package/payload/skills/zz-meridian/references/cache.md +1 -1
  17. package/payload/src/components/charts/timeline/README.md +1 -1
  18. package/payload/src/components/charts/timeline/index.tsx +1 -1
  19. package/payload/src/components/patterns/data-table/README.md +2 -2
  20. package/payload/src/components/patterns/data-table/index.tsx +1 -1
  21. package/payload/src/components/patterns/filter-bar/index.tsx +1 -1
  22. package/payload/src/components/patterns/prose/README.md +1 -1
  23. package/payload/src/components/patterns/shell-tools/index.tsx +1 -1
  24. package/payload/src/components/ui/breadcrumb/README.md +2 -2
  25. package/payload/src/components/ui/card/preview.tsx +1 -1
  26. package/payload/src/components/ui/copy-field/README.md +1 -0
  27. package/payload/src/components/ui/toast/README.md +1 -1
  28. package/payload/src/components/ui/toast/index.tsx +1 -1
  29. package/payload/src/data/access.ts +1 -1
  30. package/payload/src/data/collections.ts +8 -3
  31. package/payload/src/lib/collection.ts +4 -1
  32. package/payload/src/styles/base.css +10 -14
  33. package/payload/src/system/fixtures/sample-records.ts +11 -6
  34. package/payload/src/system/fixtures/sample.ts +2 -0
  35. package/payload/src/views/keys.tsx +22 -12
  36. package/payload/src/views/members.tsx +13 -7
  37. package/payload/src/views/not-found-address.tsx +1 -1
  38. package/payload/src/views/overview.tsx +2 -2
  39. package/payload/src/views/request.tsx +13 -4
  40. package/payload/tests/collection.test.ts +12 -0
  41. package/payload/tests/data-table.test.tsx +9 -1
  42. package/payload/tests/keys-actions.test.ts +15 -1
  43. package/payload/tests/keys-scopes-view.test.tsx +1 -1
  44. package/payload/tests/keys-secret-view.test.tsx +34 -0
  45. package/payload/tests/members-optimistic.test.tsx +9 -4
  46. package/payload/tests/request-replay.test.ts +35 -0
  47. package/payload/tests/request-view-replay.test.tsx +36 -0
package/dist/reconcile.js CHANGED
@@ -6,10 +6,13 @@ import { upsertManagedBlock } from './context.js';
6
6
  export const TOOLCHAIN = ['typescript', '@types/node', '@types/react', '@types/react-dom', 'tailwindcss', '@tailwindcss/postcss', 'eslint', 'eslint-config-next', 'vitest', '@vitejs/plugin-react', 'jsdom', '@testing-library/react', '@testing-library/jest-dom'];
7
7
  export const MERIDIAN_SCRIPTS = ['typecheck', 'test', 'tokens', 'check', 'contrast', 'gate', 'audit', 'verify', 'brand', 'shot', 'interactions', 'keyboard', 'vitals'];
8
8
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
9
- /** The minimum version of `x.y.z`, `^x.y.z`, `~x.y.z` or `>=x.y.z`; null for anything else. */
9
+ /**
10
+ * The minimum version of `x.y.z`, `^x.y.z`, `~x.y.z` or `>=x.y.z`, where a missing minor or patch is 0 (`^24` is
11
+ * 24.0.0, `~5.9` is 5.9.0); null for anything else.
12
+ */
10
13
  function minimum(spec) {
11
- const m = typeof spec === 'string' ? /^(?:\^|~|>=)?(\d+)\.(\d+)\.(\d+)$/.exec(spec.trim()) : null;
12
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
14
+ const m = typeof spec === 'string' ? /^(?:\^|~|>=)?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/.exec(spec.trim()) : null;
15
+ return m ? [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)] : null;
13
16
  }
14
17
  const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
15
18
  const file = (id, summary, instructions, path) => ({ id, summary, paths: [path], instructions, checks: [] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,23 @@
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.6.0] · 2026-10-06
6
+
7
+ ### Changed
8
+
9
+ - **An API key is shown in full once, when it is created.** The keys collection stores each key's `hint` (its prefix and last four characters, `zzm_live_…f601`) and `secretHash` (the secret's SHA-256), never the secret. `createKey` returns the new key with its `secret` for the creation banner, and the table lists every key by its hint, with no Reveal or Copy. Breaking: `ApiKey` has `hint` and `secretHash` in place of `secret`, and `KeysView`'s `createKey` returns `ApiKey & { secret: string }`. A created project's own keys page and fixtures keep the old shape until the team takes the release's versions.
10
+ - **Links are never underlined.** Hover is a colour change and focus is the focus ring every control has. `.link` (accent ink in a sentence) turns `ink` on hover; `.row-link` (a destination in a list) takes `accent-ink`; a Data table's linked row takes the `fill-hover` tint and its title keeps its colour. Before, both classes drew a 1px line across the link's box, which under a link holding a second line read as a rule across its whole width.
11
+ - **Replay sends a failed request again.** On a 5xx or 429 request's page, Replay calls `replayRequest` (`app/(dashboard)/requests/[id]/actions.ts`): it authorizes the write, creates a new request with the same method, route, customer, region and model and a `replayOf` naming the original, and refreshes the request reads. A toast names the new request with Open, and its page links back. It used to toast "Replay queued" and send nothing. The requests collection allows `create` as a page-only operation, so the assistant cannot replay. `arrayCollection` takes `derive`, which works out a row's `derived` fields on every create and change. The request page no longer shows a fixed "API key" it did not know. Breaking: `RequestView` takes `replay`.
12
+ - **A form in an open sheet says why it failed inside the sheet.** A refused invitation reopens the Members sheet with what was typed and a critical Banner, "Invitation not sent", with the reason. A refused key reads "Key not created" at the top of the Create a key sheet. Neither sends a toast over the sheet's own buttons. Refused changes made from a table row still toast.
13
+
14
+ ### Fixed
15
+
16
+ - **`update` reads a dependency written as `^24` (or `~5.9`) as a version.** It used to call any range without a minor and patch "a specification Meridian cannot compare". Every created project carries `"@types/node": "^24"`, so each update asked the team to replace `^24` with "at least ^24" before it could complete. A missing minor or patch now counts as 0.
17
+
18
+ ### Release
19
+
20
+ - **The release's timed default verify allows 180 s on the shared GitHub 4-CPU runner.** The same commit measured 111 s on one runner CPU and 152 s on another, so 120 s stopped a release on the draw of the machine. The default verify must still pass in full; 120 s remains the target on an adopter's own machine. The stakeholder's decision.
21
+
5
22
  ## [0.5.0] · 2026-10-06
6
23
 
7
24
  ### Added
@@ -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 (masked Copy field, from 768px), Scopes (two and "+N", from 1280px), Created by (from 1024px), Created, Last used, and Revoke | Columns | Cards: name and environment, "Created …" and "Used …", Revoke |
13
+ | 2 | Data table: Name with its environment, Key (its hint, `zzm_live_…f601`, in mono, from 768px), Scopes (two and "+N", from 1280px), Created by (from 1024px), 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
 
@@ -22,13 +22,14 @@ Status: beta
22
22
  | Default | The collection's keys, in its order |
23
23
  | Just created | The accent Banner with the only full view of the secret; a toast "Key created" |
24
24
  | Name missing | The Name field's error: "Name the key after what uses it" |
25
+ | Not created | A critical Banner at the top of the open sheet, "Key not created", with the server's reason; the sheet keeps what was typed |
25
26
  | Revoked | The row leaves; a toast "Key revoked" |
26
27
  | No keys | The first-run empty state with Create key |
27
28
  | Never used | "Never" in `ink-3` |
28
29
 
29
30
  ## Data
30
31
 
31
- The keys come from `collections.keys` (`src/data/collections.ts`), read on every request. Create and Revoke are server actions (`actions.ts`) that call `keys.create` and `keys.remove`, then the view refreshes the route; a rejected action shows a critical toast and changes nothing. The secret is generated on the server and shown once, in the banner. A secret is always masked in the table; Copy copies the real value without revealing it.
32
+ The keys come from `collections.keys` (`src/data/collections.ts`), read on every request. Create and Revoke are server actions (`actions.ts`) that call `keys.create` and `keys.remove`, then the view refreshes the route. A rejected create is said inside the sheet; a rejected revoke shows a critical toast and puts the row back. The secret is generated on the server and returned once, to the banner, which is the only place it is ever shown. The collection stores the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret, so the table shows the hint and nothing in the browser or the cache can reveal or copy a listed key. To replace a key, create a new one and revoke the old.
32
33
 
33
34
  ## Embed view
34
35
 
@@ -1,6 +1,6 @@
1
1
  'use server';
2
2
 
3
- import { randomBytes } from 'node:crypto';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
4
  import { updateTag } from 'next/cache';
5
5
  import { z } from 'zod';
6
6
  import { app } from '@/app.config';
@@ -16,14 +16,20 @@ const draft = z.object({
16
16
  scopes: z.array(z.enum(SCOPES), { error: 'Choose only the scopes a key can carry.' }).min(1, 'Give the key at least one scope.'),
17
17
  });
18
18
 
19
- /** Creates a key and returns it: the only moment its full secret leaves the server for a banner. */
20
- export async function createKey(input: z.input<typeof draft>): Promise<ApiKey> {
19
+ /**
20
+ * Creates a key and returns it with its full secret: the only moment the secret exists outside the caller's own copy.
21
+ * What is stored is the key's hint (its prefix and last four characters) and the secret's SHA-256, never the secret.
22
+ */
23
+ export async function createKey(input: z.input<typeof draft>): Promise<ApiKey & { secret: string }> {
21
24
  const { name, env, scopes } = draft.parse(input);
22
25
  const scope = await resolveAccess();
23
26
  if (!(await can(scope, 'keys', 'create'))) throw new AccessDenied();
24
- const key = await collectionFor(scope, 'keys').create!({ name, env, scopes, owner: app.user.name, created: clock().toISOString(), lastUsed: null, secret: `zzm_${env}_${randomBytes(16).toString('hex')}` });
27
+ const secret = `zzm_${env}_${randomBytes(16).toString('hex')}`;
28
+ const hint = `zzm_${env}_…${secret.slice(-4)}`;
29
+ const secretHash = createHash('sha256').update(secret).digest('hex');
30
+ const key = await collectionFor(scope, 'keys').create!({ name, env, scopes, owner: app.user.name, created: clock().toISOString(), lastUsed: null, hint, secretHash });
25
31
  updateTag(collectionTag(scope.tenantId, 'keys'));
26
- return key;
32
+ return { ...(key as ApiKey), secret };
27
33
  }
28
34
 
29
35
  export async function revokeKey(id: string): Promise<void> {
@@ -25,7 +25,7 @@ The row menu holds Suspend (Reactivate on a suspended member) and Remove.
25
25
  | Suspended | The status Badge in `warning` |
26
26
  | Name or email missing | The field's error under it; nothing is sent |
27
27
  | Changed | The page refreshes from the collection and a toast confirms: "Member suspended", "Member reactivated", "Member removed", "Invitation sent to {email}" |
28
- | Rejected | A critical toast with the reason; the table does not change |
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
29
  | No members | The first-run empty state with Invite member |
30
30
 
31
31
  ## Data
@@ -9,7 +9,7 @@ Status: beta
9
9
  | Row | Pattern | Console | Mobile |
10
10
  |---|---|---|---|
11
11
  | Masthead | Detail head: "← Requests", the ID in mono with its status Badge, facts (method and route, latency, customer, when), More actions, Copy as cURL, and Replay as the primary when the request failed (5xx or 429) | One band | Facts wrap; actions on their own row |
12
- | 1 | Row `2/3`: Request facts (Key value, two columns: received, endpoint, latency, response size, customer linking to their requests, region, API key, cost, and model and tokens only on the routes that call a model; the ID and status are in the head, not repeated) · Trace (one bar per phase on a shared scale: gateway, auth, queue, the route's own work, the response) | Side by side | Stacked |
12
+ | 1 | Row `2/3`: Request facts (Key value, two columns: received, endpoint, latency, response size, customer linking to their requests, region, the request it replays when it is a replay, cost, and model and tokens only on the routes that call a model; the ID and status are in the head, not repeated) · Trace (one bar per phase on a shared scale: gateway, auth, queue, the route's own work, the response) | Side by side | Stacked |
13
13
  | 2 | Request body and Response body side by side, each a card with a code block (wrapping, never scrolling sideways) and Copy | 1/2 + 1/2 | Stacked |
14
14
 
15
15
  ## States
@@ -19,13 +19,15 @@ Status: beta
19
19
  | Loading | `loading.tsx`: the facts beside the trace, then two body cards, as skeletons. The id is read inside that boundary |
20
20
  | Success | Status `neutral`; the route's own phase (Model, Search, Storage or Parse) in the accent; no primary action |
21
21
  | Rate limited, rejected, not found | Status `warning`; the trace ends at the limit, the validation or the lookup phase, in `critical`; tokens and cost read "None, refused"; the response is a short error body |
22
- | No request body (GET, DELETE) | The Request body card says "No body: a GET carries everything it needs in its address." in a dashed frame; Copy as cURL sends no `-d`; Replay says it runs again without "with the same body" |
22
+ | No request body (GET, DELETE) | The Request body card says "No body: a GET carries everything it needs in its address." in a dashed frame; Copy as cURL sends no `-d` |
23
23
  | Failed | Status `critical`; the route's own phase and the error phase in `critical`; Replay is the primary |
24
+ | Replayed | A toast "Replayed: 200 OK" (or 201) naming the new request, with Open; the new request leads the list and its page shows "Replay of" linking back |
25
+ | Replay refused | A critical toast "Not replayed" with the reason; nothing is written |
24
26
  | Unknown ID | The page renders the console's Not found screen itself (`MissingPage`, see `app/not-found/README.md`), with `noindex`, rather than throwing `notFound()`, so the screen is in the first HTML |
25
27
 
26
28
  ## Data
27
29
 
28
- The request from the `requests` collection (`src/data/collections.ts`), queried by id; its trace and payloads from `traceOf()` and `payloadsOf()` in `src/system/fixtures/sample-records.ts`. Phases add up to the request's latency; the axis shows 0, half and the total.
30
+ The request from the `requests` collection (`src/data/collections.ts`), queried by id. Replay is the page's Server Action (`actions.ts`, `replayRequest`): it authorizes, reads the request, and creates a new one with the same method, route, customer, region and model, naming the one it replays, then refreshes the tenant's request reads. A product calls its gateway there with the stored request; the sample's gateway answers a replay of a 5xx or a 429, the only requests that offer Replay, with the route's success. Its its trace and payloads from `traceOf()` and `payloadsOf()` in `src/system/fixtures/sample-records.ts`. Phases add up to the request's latency; the axis shows 0, half and the total.
29
31
 
30
32
  ## Embed view
31
33
 
@@ -0,0 +1,34 @@
1
+ 'use server';
2
+
3
+ import { updateTag } from 'next/cache';
4
+ import { clock } from '@/data/collections';
5
+ import { can, collectionFor, resolveAccess, Unauthenticated } from '@/data/access';
6
+ import { collectionTag } from '@/data/read';
7
+ import type { RequestRow } from '@/data/sample';
8
+
9
+ export type ReplayResult = { ok: true; id: string; status: number } | { ok: false; error: string };
10
+
11
+ /**
12
+ * Sends a logged request again: the same method, route, customer, region and model, as a new request that names the one
13
+ * it replays. A product calls its gateway here with the stored request; the sample's gateway answers a replay of a
14
+ * temporary failure (a 5xx or a 429, the only requests that offer Replay) with the route's success. Authorized like any
15
+ * write, then the tenant's request reads are refreshed.
16
+ */
17
+ export async function replayRequest(id: string): Promise<ReplayResult> {
18
+ try {
19
+ const scope = await resolveAccess();
20
+ if (!(await can(scope, 'requests', 'create'))) return { ok: false, error: 'You do not have permission to replay requests.' };
21
+ const requests = collectionFor(scope, 'requests');
22
+ const original = (await requests.query({ where: [{ field: 'id', op: 'eq', value: id }] })).rows[0] as RequestRow | undefined;
23
+ if (!original) return { ok: false, error: `No request with id ${id}.` };
24
+ const status = original.method === 'POST' ? 201 : 200;
25
+ const replay = await requests.create!({
26
+ at: clock().toISOString(), method: original.method, route: original.route, status, latency: original.latency,
27
+ customer: original.customer, region: original.region, bytes: original.bytes, model: original.model, replayOf: original.id,
28
+ });
29
+ updateTag(collectionTag(scope.tenantId, 'requests'));
30
+ return { ok: true, id: String(replay.id), status };
31
+ } catch (e) {
32
+ return { ok: false, error: e instanceof Unauthenticated ? 'Sign in again to replay this request.' : e instanceof Error ? e.message : 'The request was not replayed.' };
33
+ }
34
+ }
@@ -2,6 +2,7 @@ import { read } from '@/data/read';
2
2
  import { FEATURED_REQUEST_IDS, traceOf, payloadsOf, type RequestRow } from '@/data/sample';
3
3
  import { MissingPage } from '@/views/missing-page';
4
4
  import { RequestView } from '@/views/request';
5
+ import { replayRequest } from './actions';
5
6
 
6
7
  export function generateStaticParams() {
7
8
  return FEATURED_REQUEST_IDS.map((id) => ({ id }));
@@ -22,5 +23,5 @@ export default async function RequestPage({ params }: { params: Promise<{ id: st
22
23
  const { request: r, observedAt } = await byId(id);
23
24
  // Rendered here rather than thrown, so the screen is in the first HTML (see MissingPage).
24
25
  if (!r) return <MissingPage />;
25
- return <RequestView request={r} trace={traceOf(r)} payloads={payloadsOf(r)} now={observedAt} />;
26
+ return <RequestView request={r} trace={traceOf(r)} payloads={payloadsOf(r)} now={observedAt} replay={replayRequest} />;
26
27
  }
@@ -129,7 +129,7 @@ export default function AtlasHome() {
129
129
  <p className="t-kicker">{release.version} · {release.date}</p>
130
130
  <p className="t-small mt-4 max-w-[48ch] text-ink-2">{release.summary}</p>
131
131
  {changelog ? (
132
- <Link href={changelog} className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-accent-ink hover:underline">
132
+ <Link href={changelog} className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-accent-ink hover:text-ink">
133
133
  Full changelog <ArrowRight className="size-3.5" />
134
134
  </Link>
135
135
  ) : null}
@@ -94,7 +94,7 @@ this is the reason they agree.
94
94
  - HTML caps are `/requests` 150 KiB and `/health` 100 KiB. The stakeholder set them after measurement showed no variant
95
95
  kept the page title instant within 130 KiB.
96
96
  - Weekly is weekly, release is release, and nothing runs twice. The release gates, each once, on the default verify
97
- from a clean `.next` on the GitHub-hosted ubuntu-24.04 4-CPU runner (at most 120 s, with its browser smoke; the gate
97
+ from a clean `.next` on the GitHub-hosted ubuntu-24.04 4-CPU runner (at most 120 s, raised to 180 s in 0.6.0 because the runner's CPU varies, with its browser smoke; the gate
98
98
  and its unit tests run inside it) and on the consumer smoke from the tarball: adopt, create with its default verify,
99
99
  and all four published origins updated through finalize. After publishing it checks the registry serves the tested
100
100
  tarball (equal sha256) with provenance, and tags last. A weekly workflow runs `verify --full --perf` (the perf part a
@@ -93,7 +93,7 @@ Add it to `collections`. For a real store, write the same `Collection` yourself:
93
93
 
94
94
  Two fields on a collection keep the assistant in bounds:
95
95
 
96
- - `pageOnly`: operations only a page may perform. The assistant never gets a tool for them. In the sample, creating an API key is `pageOnly`, because the secret is shown once, on the page.
96
+ - `pageOnly`: operations only a page may perform. The assistant never gets a tool for them. In the sample, creating an API key is `pageOnly`, because the secret is shown once, on the page, and so is replaying a request, a person's action on its page.
97
97
  - `hidden`: fields only a page may see. They are left out of every tool's input and of every query result. The sample hides a key's `secret`.
98
98
 
99
99
  Per collection the assistant gets `query_<name>`, and `create_<name>`, `update_<name>` and `remove_<name>` for what is allowed and not `pageOnly`. A query takes `where` conditions (`eq`, `ne`, `gt`, `lt`, `contains`, `in`), a `sort` and a `limit`. A field the collection does not have, or hides, is rejected, and a field with no value matches only `ne`. `arrayCollection` parses what is created or changed with the collection's `fields`, so the schema is where a record's rules live (the sample's members need a name and a valid email).
@@ -11,7 +11,7 @@ Meridian held to the bar of award-winning sites (Awwwards, Webby, FWA) and of th
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
13
  | Motion | Good | Three jobs (arrive, answer, float), four durations, three curves; data arrives once (a line draws, an area reveals, bars grow); nothing loops but a live dot and a skeleton; reduced motion shows the final state at once |
14
- | Micro-interaction | Good | The rail's pill springs between items; the Segmented thumb slides; a lit edge fades in under the pointer; an underline draws in on row links; 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 |
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 |
17
17
 
@@ -150,8 +150,7 @@ For 0.5.0 they are:
150
150
  | `live-provider` | `src/data/collections.ts` without `src/data/live-actions.ts`, or a dashboard layout with neither `LiveProvider` nor `ConsoleLive` | `live.md`, "The live starters" and "Refreshing": add the five files and the provider |
151
151
  | `authorized-endpoints` | `app/api/assistant/route.ts`, or an `actions.ts` Server Action file, without a `resolveAccess` call | `cache.md`, "The assistant route": call `resolveAccess()` first and bring `tools.ts` and `respond.ts` over |
152
152
 
153
- The consumer smoke (`--update`) resolves the nine that touch source on all four published origins, a project adopted and a project created
154
- with 0.3.0 and with 0.4.0, as `references/update.md` tells an agent: a created origin, whose files are the template's,
153
+ The consumer smoke (`--update`) updates a project adopted and a project created with each published release from 0.3.0 on. It resolves the nine migrations that touch source on the 0.3.0 and 0.4.0 origins, as `references/update.md` tells an agent: a created origin, whose files are the template's,
155
154
  takes the release's versions of the affected team-owned files and the files that arrived with them; an adopted origin,
156
155
  whose pages are the team's own, makes the minimal edit the instruction names. It then records the resolutions and
157
156
  finalizes, so the gate and the build run on the result, and checks the team's own files kept their bytes. A 0.4.0
@@ -204,7 +203,8 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
204
203
  `cli/package.json`'s and the tag must be unused.
205
204
  2. **Gates** (ubuntu-24.04, 4 CPUs, the reference profile), each run once: the package's own tests (`tests/cli-*`, which
206
205
  no adopted or created project has); the template's default `verify` from a clean `.next` through
207
- `cli/scripts/release-check.ts`, within 120 s and with its browser smoke (the gate and the template's unit tests run
206
+ `cli/scripts/release-check.ts`, within 180 s on the shared runner (whose CPU varies: one commit measured 111 s on one
207
+ and 152 s on another) and with its browser smoke (the gate and the template's unit tests run
208
208
  inside it); and the consumer smoke from the built tarball (step 4). The full, perf and recovery suites are never
209
209
  run here: they run weekly (below).
210
210
  3. **Pack and assert, before anything is irreversible**: `npm pack` in `cli/`, then on the tarball: the bin has its
@@ -214,7 +214,8 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
214
214
  - `adopt` into `cli/test/fixture-next-app` (a minimal App Router app with one page and its own stylesheet), then
215
215
  install, `tsc --noEmit` and `next build`, all green. This is the test that keeps the Route A list complete.
216
216
  - `create` into a clean folder, then its default `pnpm verify`. The smoke quotes the coverage line.
217
- - `update` from all four published origins (0.3.0 and 0.4.0, adopted and created) through finalize, as above.
217
+ - `update` from every published origin since 0.3.0 (adopted and created) through finalize, as above. A 0.5.0 origin
218
+ reports no migration; a created one with nothing to resolve completes in the update itself.
218
219
  5. **Publish** the tarball with `npm` 11.5.1 or newer through trusted publishing (OIDC), with `--provenance`. `pnpm
219
220
  publish` does not perform the OIDC exchange.
220
221
  6. **The registry's package**: `npx zz-meridian@<version> --version` answers the version, the registry's tarball has the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.5.0",
3
+ "version": "0.6.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.",
@@ -141,7 +141,7 @@ const DEMO: AccessScope = { tenantId: 'demo', subjectId: 'owner', authorizationK
141
141
  const DEMO_COLLECTIONS: Record<string, { collection: AnyCollection; ops: readonly Operation[] }> = {
142
142
  members: { collection: members, ops: ['read', 'create', 'update', 'remove'] },
143
143
  keys: { collection: keys, ops: ['read', 'create', 'remove'] },
144
- requests: { collection: requests, ops: ['read'] },
144
+ requests: { collection: requests, ops: ['read', 'create'] },
145
145
  };
146
146
 
147
147
  export const { resolveAccess, collectionFor, can } = accessFrom({
@@ -33,7 +33,7 @@ Leave `from` and `to` out and the span is the items' own extent. A roadmap usual
33
33
  | State | Spec |
34
34
  |---|---|
35
35
  | Rest | as above |
36
- | Linked | an item with `href` makes its label a link (`row-link`: an underline draws in on hover) |
36
+ | Linked | an item with `href` makes its label a link (`row-link`: it takes the accent on hover, with no underline) |
37
37
  | Outside the span | a bar is clipped to the span and the side that continues is squared off (`rounded-l-none` when it started before `from`, `rounded-r-none` when it ends after `to`), so work that runs past the window does not read as work that began at its edge; Today is drawn only when it falls inside |
38
38
 
39
39
  ## Behaviour
@@ -115,7 +115,7 @@ export function Timeline({
115
115
  <Fragment key={it.id}>
116
116
  {/* The label is the item's one link: visible, focusable, and read with the table's dates. */}
117
117
  {it.href ? (
118
- <Link href={it.href} title={it.label} className="row-link self-center truncate py-2 pr-3 text-ink-2">{it.label}</Link>
118
+ <Link href={it.href} title={it.label} className="row-link self-center truncate py-2 pr-3 text-ink-2 hover:text-accent-ink">{it.label}</Link>
119
119
  ) : (
120
120
  <span aria-hidden className="self-center truncate py-2 pr-3 text-ink-2" title={it.label}>{it.label}</span>
121
121
  )}
@@ -10,7 +10,7 @@ Status: beta
10
10
  2. **Title** (optional): `title`, `description` and `actions` as a Card header, when the table is one card among others on a page; a hairline under it when no toolbar follows.
11
11
  3. **Toolbar** (optional): a band above the table, `card-pad` horizontal and 14px vertical padding, a `line` divider under it from 768px. It holds a Filter bar.
12
12
  4. **Header row**: Table header cells, `surface-sunk`, 36px, `text-xs` 500 `ink-3`. A sortable head is a button with the sort arrow in `accent-ink`.
13
- 5. **Rows**: `row-height` 52px (38px compact), a `line` divider between rows. The title column (`mobile: 'title'`, or the first) is a `.row-link` when the row opens a record.
13
+ 5. **Rows**: `row-height` 52px (38px compact), a `line` divider between rows. The title column (`mobile: 'title'`, or the first) is the row's link when the row opens a record. The whole row takes the `fill-hover` tint on hover; the title keeps its colour and is never underlined.
14
14
  6. **Selection column** (optional): 40px with a Checkbox; the head's checkbox selects the page and shows indeterminate when part of it is chosen.
15
15
  7. **Footer**: Pagination, under a `line` divider, shown only when the rows do not fit on one page.
16
16
  8. **Cards, not a second list** (under 768px): the same table's rows lay out as cards — the title and the status on one line, two or three facts under it in `text-xs` `ink-3`, separated by dots. It is the SAME tree: the row becomes a six-column grid and each cell says which part of the card it is (`data-mobile`), so nothing is built twice and no device downloads a tree it cannot show.
@@ -34,7 +34,7 @@ Row height and card padding come from the density: comfortable 52px and 24px, co
34
34
  | State | What shows |
35
35
  |---|---|
36
36
  | Rest | Rows; numeric columns right-aligned in tabular figures |
37
- | Hover (a linked row) | Row fill `fill-hover` over `dur-hover`; the title's underline draws in |
37
+ | Hover (a linked row) | Row fill `fill-hover` over `dur-hover`; nothing else changes |
38
38
  | Selected | Row fill `accent-tint`; the checkbox in `accent` |
39
39
  | Focus | The link or the checkbox shows the 2px `accent` ring |
40
40
  | Loading | `aria-busy`; up to eight skeleton rows shaped like real ones (a wide bar in the grow column, short bars elsewhere, right-aligned bars in numeric columns) — the same eight rows on a phone, laid out as cards |
@@ -294,7 +294,7 @@ export function DataTable<R>({
294
294
  const role = roleOf(c);
295
295
  // The title is the row's link wherever it is shown, phone included: the whole card takes the
296
296
  // pointer too (the row's own click), but the link is what a keyboard and a screen reader follow.
297
- const content = c === titleColumn && href ? <Link href={href} className="row-link">{c.cell(r)}</Link> : c.cell(r);
297
+ const content = c === titleColumn && href ? <Link href={href}>{c.cell(r)}</Link> : c.cell(r);
298
298
  return (
299
299
  <td key={c.key} data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} className={cellClass(c) || undefined}>
300
300
  {/* A phone may want other words for the same cell — "Used 1 min ago", not "1 min ago" — so
@@ -124,7 +124,7 @@ export function FilterBar({
124
124
  <span>
125
125
  <span className="font-medium text-ink">Set by {setBy}</span> · these filters came from the assistant
126
126
  </span>
127
- {onClear ? <button type="button" onClick={onClear} className="hit font-medium text-accent-ink hover:underline">Clear</button> : null}
127
+ {onClear ? <button type="button" onClick={onClear} className="hit font-medium text-accent-ink hover:text-ink">Clear</button> : null}
128
128
  </p>
129
129
  ) : null}
130
130
  </div>
@@ -12,7 +12,7 @@ Status: beta
12
12
  4. **Code**: inline on `surface-sunk` with a `line` border; a fenced block on `surface-sunk`, `radius-lg`, wrapping instead of scrolling.
13
13
  5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells.
14
14
  6. **Quote**: a 2px `accent` rule on the left, `ink` text.
15
- 7. **Links**: `.link`, in `accent-ink`, underlined on hover; a link out of the product sends no referrer.
15
+ 7. **Links**: `.link`, in `accent-ink`, turning `ink` on hover, never underlined; a link out of the product sends no referrer.
16
16
 
17
17
  ## Composition
18
18
 
@@ -58,7 +58,7 @@ export function AlertsPanel({ alerts, now, read = new Set(), onOpen, onMarkAll }
58
58
  <p className="text-sm font-semibold">Alerts</p>
59
59
  {unread ? <span className="t-caption">{unread} new</span> : null}
60
60
  {unread && onMarkAll ? (
61
- <button type="button" onClick={onMarkAll} className="hit ml-auto rounded-xs text-xs font-medium text-accent-ink hover:underline">
61
+ <button type="button" onClick={onMarkAll} className="hit ml-auto rounded-xs text-xs font-medium text-accent-ink hover:text-ink">
62
62
  Mark all read
63
63
  </button>
64
64
  ) : null}
@@ -6,7 +6,7 @@ Status: beta
6
6
 
7
7
  ## Anatomy
8
8
 
9
- 1. **Ancestor**: a link, `ink-2`; its underline draws in on hover and it turns `ink`.
9
+ 1. **Ancestor**: a link, `ink-2`; it turns `ink` on hover, with no underline.
10
10
  2. **Separator**: a 12px chevron in `ink-3`, decorative.
11
11
  3. **Current page**: `ink`, medium, `aria-current="page"`.
12
12
  4. **Fold** (more than four levels): a 24px ellipsis button that opens a Menu of the hidden levels.
@@ -27,7 +27,7 @@ Status: beta
27
27
  | State | Ancestor |
28
28
  |---|---|
29
29
  | Rest | `ink-2` |
30
- | Hover | `ink`, underline draws over `dur-hover` |
30
+ | Hover | `ink` over `dur-hover` |
31
31
  | Focus | 2px `accent` outline |
32
32
 
33
33
  ## Behaviour
@@ -25,7 +25,7 @@ export default function CardPreview() {
25
25
  <p className="t-caption mt-2">Resets on 1 November</p>
26
26
  </CardBody>
27
27
  <CardFooter>
28
- <a className="row-link inline-flex items-center gap-1 font-medium text-ink" href="#">Billing <ArrowUpRight className="size-3.5" /></a>
28
+ <a className="row-link inline-flex items-center gap-1 font-medium text-ink hover:text-accent-ink" href="#">Billing <ArrowUpRight className="size-3.5" /></a>
29
29
  </CardFooter>
30
30
  </Card>
31
31
  </Plane>
@@ -58,6 +58,7 @@ Label the field with what it is: "API key", "Webhook signing secret".
58
58
  ## Do and do not
59
59
 
60
60
  - Do mask every secret by default.
61
+ - Do not list a stored secret at all. An API key is shown in full once, when it is created; afterwards the page shows its hint (the prefix and the last four characters) as plain text, and the server never sends the secret again.
61
62
  - Do not offer Copy for something that is not meant to be pasted elsewhere.
62
63
 
63
64
  ## Implementation
@@ -63,7 +63,7 @@ When an agent's approved proposal applies, the toast names the agent: "Rate limi
63
63
  ## Do and do not
64
64
 
65
65
  - Do offer Undo instead of a confirmation dialog when an action can be reversed.
66
- - Do not use a toast for an error the person must act on; use a Banner or the field's error.
66
+ - Do not use a toast for an error the person must act on; use a Banner or the field's error. A form in an open sheet or dialog that fails shows its error inside the form, at the top, and keeps what was typed: a toast would land on the form's own actions.
67
67
 
68
68
  ## Implementation
69
69
 
@@ -54,7 +54,7 @@ export function ToastView({
54
54
  {description ? <p className="mt-0.5 text-xs text-ink-2">{description}</p> : null}
55
55
  </div>
56
56
  {action ? (
57
- <button type="button" className="press hit shrink-0 rounded-xs text-sm font-medium text-accent-ink hover:underline" onClick={action.onClick}>
57
+ <button type="button" className="press hit shrink-0 rounded-xs text-sm font-medium text-accent-ink hover:text-ink" onClick={action.onClick}>
58
58
  {action.label}
59
59
  </button>
60
60
  ) : null}
@@ -63,7 +63,7 @@ const DEMO: AccessScope = { tenantId: 'demo', subjectId: 'owner', authorizationK
63
63
  const DEMO_COLLECTIONS: Record<string, { collection: AnyCollection; ops: readonly Operation[] }> = {
64
64
  members: { collection: members, ops: ['read', 'create', 'update', 'remove'] },
65
65
  keys: { collection: keys, ops: ['read', 'create', 'remove'] },
66
- requests: { collection: requests, ops: ['read'] },
66
+ requests: { collection: requests, ops: ['read', 'create'] },
67
67
  };
68
68
 
69
69
  export const { resolveAccess, collectionFor, can } = accessFrom({
@@ -52,7 +52,8 @@ export const keys: Collection<ApiKey, 'id'> = arrayCollection({
52
52
  title: (k) => k.name,
53
53
  fields: z.object({
54
54
  name: z.string(),
55
- secret: z.string(),
55
+ hint: z.string(),
56
+ secretHash: z.string(),
56
57
  owner: z.string(),
57
58
  created: z.string(),
58
59
  lastUsed: z.string().nullable(),
@@ -62,7 +63,7 @@ export const keys: Collection<ApiKey, 'id'> = arrayCollection({
62
63
  rows: API_KEYS,
63
64
  allow: ['create', 'remove'],
64
65
  pageOnly: ['create'],
65
- hidden: ['secret'],
66
+ hidden: ['secretHash'],
66
67
  });
67
68
 
68
69
  /** A request as the collection holds it: the record plus two values worked out for filtering, never written. `search` is the lower-cased id, route and customer; a page does not carry it (`publicRows`). */
@@ -84,10 +85,14 @@ export const requests: Collection<RequestRecord, 'id'> = arrayCollection({
84
85
  region: z.string(),
85
86
  bytes: z.number(),
86
87
  model: z.string().nullable(),
88
+ replayOf: z.string().nullable().default(null),
87
89
  }),
88
90
  derived: ['statusClass', 'search'],
91
+ derive: (r) => ({ statusClass: statusClass(Number(r.status)), search: `${r.id} ${r.route} ${r.customer}`.toLowerCase() }),
89
92
  rows: REQUESTS.map((r) => ({ ...r, statusClass: statusClass(r.status), search: `${r.id} ${r.route} ${r.customer}`.toLowerCase() })),
90
- allow: [],
93
+ // A replay is the one write: a person sending a failed request again from its page, never the assistant.
94
+ allow: ['create'],
95
+ pageOnly: ['create'],
91
96
  });
92
97
 
93
98
  export const collections: AnyCollection[] = [members, keys, requests];
@@ -124,6 +124,8 @@ export function arrayCollection<T extends Record<string, unknown>, K extends key
124
124
  description: string;
125
125
  fields: z.ZodObject;
126
126
  derived?: (keyof T & string)[];
127
+ /** Works out the `derived` fields of a row from the rest; run on every created and changed row, never written by a caller. */
128
+ derive?: (row: Record<string, unknown>) => Partial<T>;
127
129
  key: K;
128
130
  title: (row: T) => string;
129
131
  rows: T[];
@@ -197,6 +199,7 @@ export function arrayCollection<T extends Record<string, unknown>, K extends key
197
199
  if (allow.includes('create')) {
198
200
  c.create = async (input) => {
199
201
  const row = { ...def.fields.parse(input), [key]: `${name}_${store().next++}` } as unknown as T;
202
+ Object.assign(row, def.derive?.(row));
200
203
  rows().push(row);
201
204
  emit();
202
205
  return copy(row);
@@ -206,7 +209,7 @@ export function arrayCollection<T extends Record<string, unknown>, K extends key
206
209
  c.update = async (ids, patch) => {
207
210
  const set = patchOf(def.fields).parse(patch);
208
211
  const hit = pick(ids);
209
- for (const r of hit) Object.assign(r, set);
212
+ for (const r of hit) Object.assign(r, set, def.derive?.({ ...r, ...set }));
210
213
  emit();
211
214
  return copy(hit);
212
215
  };
@@ -138,20 +138,16 @@
138
138
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
139
139
  }
140
140
 
141
- /* A link inside a sentence is accent ink with an underline that draws in from the left. */
142
- .link {
143
- color: var(--accent-ink); text-decoration: none;
144
- background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
145
- transition: background-size var(--dur-hover) var(--ease-out);
146
- }
147
- .link:hover { background-size: 100% 1px; }
148
-
149
- /* A destination in a list stays ink; the underline draws on hover. */
150
- .row-link {
151
- background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
152
- transition: background-size var(--dur-hover) var(--ease-out), color var(--dur-hover);
153
- }
154
- .row-link:hover { background-size: 100% 1px; color: var(--accent-ink); }
141
+ /* Links are never underlined: hover is a colour change, and focus is the focus ring every control has.
142
+ A link inside a sentence is accent ink, and turns ink on hover. */
143
+ .link { color: var(--accent-ink); text-decoration: none; transition: color var(--dur-hover) var(--ease-out); }
144
+ .link:hover { color: var(--ink); }
145
+
146
+ /* A destination in a list stays ink, and takes the accent on hover. A colour utility on the link (`text-ink`) sits in a
147
+ later layer and wins over this hover, so such a link also carries `hover:text-accent-ink`. A table row's title is
148
+ neither: the whole row is the target and takes the hover fill, and its title keeps its colour. */
149
+ .row-link { transition: color var(--dur-hover) var(--ease-out); }
150
+ .row-link:hover { color: var(--accent-ink); }
155
151
 
156
152
  /* A scrolling strip fades out at an edge with more behind it, so a cut-off item reads as "more this way", never as
157
153
  broken. The fade follows the strip's own scroll: none at rest when everything fits, none at the end it has reached.
@@ -95,14 +95,19 @@ export function payloadsOf(r: RequestRow): { request: string | null; response: s
95
95
  return { request: request ? JSON.stringify(request, null, 2) : null, response: JSON.stringify(response, null, 2) };
96
96
  }
97
97
 
98
- export type ApiKey = { id: string; name: string; secret: string; owner: string; created: string; lastUsed: string | null; scopes: string[]; env: 'live' | 'test' };
98
+ /**
99
+ * An API key as it is stored: never the secret itself. `hint` is the non-secret part a person recognises the key by (its
100
+ * prefix and last four characters), and `secretHash` is what a request's key is checked against. The full secret exists
101
+ * once, in what `createKey` returns.
102
+ */
103
+ export type ApiKey = { id: string; name: string; hint: string; secretHash: string; owner: string; created: string; lastUsed: string | null; scopes: string[]; env: 'live' | 'test' };
99
104
  const ago = (days: number, hours = 0) => new Date(DEMO_NOW.getTime() - days * 86_400_000 - hours * 3_600_000).toISOString();
100
105
  export const API_KEYS: ApiKey[] = [
101
- { id: 'key_01', name: 'Production backend', secret: 'zzm_live_4f9a2c71e8b04d6f93a1c5e2d7b8f601', owner: 'Maya Chen', created: ago(212), lastUsed: ago(0, 0.02), scopes: ['messages', 'search', 'embeddings'], env: 'live' },
102
- { id: 'key_02', name: 'Search indexer', secret: 'zzm_live_9b3e1d55a7c24f08b6e2a9d1c4f7e830', owner: 'Jonas Weber', created: ago(141), lastUsed: ago(0, 0.4), scopes: ['embeddings', 'files'], env: 'live' },
103
- { id: 'key_03', name: 'Support assistant', secret: 'zzm_live_2c8d4a96f1e34b7a85d0c3e9b6a2f417', owner: 'Amara Okafor', created: ago(63), lastUsed: ago(0, 3), scopes: ['messages'], env: 'live' },
104
- { id: 'key_04', name: 'Staging', secret: 'zzm_test_7e1f3b29c5d84a60b2f9e8c1d3a7b552', owner: 'Jonas Weber', created: ago(30), lastUsed: ago(2), scopes: ['messages', 'search', 'embeddings', 'files', 'webhooks'], env: 'test' },
105
- { id: 'key_05', name: 'Load test, October', secret: 'zzm_test_5a0c9e17b3f24d8e91c6a2b7f4d0e389', owner: 'Maya Chen', created: ago(1), lastUsed: null, scopes: ['messages'], env: 'test' },
106
+ { id: 'key_01', name: 'Production backend', hint: 'zzm_live_…f601', secretHash: 'a70b9eb5972206dad7965842f9c1a020b6fbd671f95ff4d90e04f31440a0767c', owner: 'Maya Chen', created: ago(212), lastUsed: ago(0, 0.02), scopes: ['messages', 'search', 'embeddings'], env: 'live' },
107
+ { id: 'key_02', name: 'Search indexer', hint: 'zzm_live_…e830', secretHash: 'c5e3a95789cd465edf1a812638689aec44e22c712ddf8aebd143f92db44ec2c9', owner: 'Jonas Weber', created: ago(141), lastUsed: ago(0, 0.4), scopes: ['embeddings', 'files'], env: 'live' },
108
+ { id: 'key_03', name: 'Support assistant', hint: 'zzm_live_…f417', secretHash: '645516307926d8cf8f1a4ffc7706ff525379e0bd50f143b672b537a1b84a605e', owner: 'Amara Okafor', created: ago(63), lastUsed: ago(0, 3), scopes: ['messages'], env: 'live' },
109
+ { id: 'key_04', name: 'Staging', hint: 'zzm_test_…b552', secretHash: 'b7caa2d349c1e2bc72d6d1c80e09c7f20a99e1d86580f5b9c058cdc9aaeb784c', owner: 'Jonas Weber', created: ago(30), lastUsed: ago(2), scopes: ['messages', 'search', 'embeddings', 'files', 'webhooks'], env: 'test' },
110
+ { id: 'key_05', name: 'Load test, October', hint: 'zzm_test_…e389', secretHash: 'cb3e8f7c3409619a25c56b233f885082c1ddfda97d99aa4a60eba6ddca281440', owner: 'Maya Chen', created: ago(1), lastUsed: null, scopes: ['messages'], env: 'test' },
106
111
  ];
107
112
 
108
113
  export type CustomerRecord = (typeof CUSTOMER_ROWS)[number] & { id: string; region: string; since: string; errorRate: number; seats: number };
@@ -128,6 +128,8 @@ const REGION_IDS = REGIONS.map((r) => r.label);
128
128
  export type RequestRow = {
129
129
  id: string; at: string; method: string; route: string; status: number; latency: number;
130
130
  customer: string; region: string; bytes: number;
131
+ /** The request this one replays, when a person sent a failed request again from its page. */
132
+ replayOf?: string | null;
131
133
  /** The model that served it: only /v1/messages and /v1/embeddings call one. */
132
134
  model: string | null;
133
135
  };
@@ -29,7 +29,7 @@ type Draft = { name: string; env: 'live' | 'test'; scopes: Scope[] };
29
29
 
30
30
  /** The server actions arrive as props: the page owns them, the view only calls them and refreshes the route. */
31
31
  /** `now` is the read's observation time, so "last used" is never fresher than the data. */
32
- export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[]; now: string; createKey: (draft: Draft) => Promise<ApiKey>; revokeKey: (id: string) => Promise<void> }) {
32
+ export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[]; now: string; createKey: (draft: Draft) => Promise<ApiKey & { secret: string }>; revokeKey: (id: string) => Promise<void> }) {
33
33
  const router = useRouter();
34
34
  useLive(['keys']);
35
35
  const asOf = new Date(now);
@@ -38,11 +38,13 @@ export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[];
38
38
  const [pending, startTransition] = useTransition();
39
39
  const [creating, setCreating] = useState(false);
40
40
  const [revoking, setRevoking] = useState<ApiKey | null>(null);
41
- const [fresh, setFresh] = useState<ApiKey | null>(null);
41
+ const [fresh, setFresh] = useState<(ApiKey & { secret: string }) | null>(null);
42
42
  const [name, setName] = useState('');
43
43
  const [env, setEnv] = useState<'live' | 'test'>('live');
44
44
  const [scopes, setScopes] = useState<Scope[]>(['messages']);
45
45
  const [error, setError] = useState<string | null>(null);
46
+ /** Why the server did not create the key: said inside the open sheet, never in a toast over its button. */
47
+ const [createError, setCreateError] = useState<string | null>(null);
46
48
 
47
49
  /** Runs an action, refreshes the route on success, and shows a critical toast, changing nothing, when it is rejected. */
48
50
  const run = (action: () => Promise<void>, failed: string) =>
@@ -57,13 +59,19 @@ export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[];
57
59
 
58
60
  const create = () => {
59
61
  if (!name.trim()) return setError('Name the key after what uses it: "Billing worker".');
60
- run(async () => {
61
- const k = await createKey({ name, env, scopes });
62
- setFresh(k);
63
- setCreating(false);
64
- setName(''); setScopes(['messages']); setError(null);
65
- toast({ tone: 'positive', title: 'Key created', description: `${k.name} can call ${k.scopes.join(', ')}.` });
66
- }, 'Could not create the key');
62
+ setCreateError(null);
63
+ startTransition(async () => {
64
+ try {
65
+ const k = await createKey({ name, env, scopes });
66
+ setFresh(k);
67
+ setCreating(false);
68
+ setName(''); setScopes(['messages']); setError(null);
69
+ toast({ tone: 'positive', title: 'Key created', description: `${k.name} can call ${k.scopes.join(', ')}.` });
70
+ router.refresh();
71
+ } catch (e) {
72
+ setCreateError(e instanceof Error ? e.message : 'The key could not be created. Try again.');
73
+ }
74
+ });
67
75
  };
68
76
 
69
77
  const columns: Column<ApiKey>[] = [
@@ -76,7 +84,8 @@ export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[];
76
84
  </span>
77
85
  ),
78
86
  },
79
- { key: 'secret', header: 'Key', hideBelow: 'md', cell: (k) => <CopyField value={k.secret} label={`${k.name} key`} secret className="w-60" /> },
87
+ // The full key is shown once, when it is created; a listed key is known by its hint, which is not a secret.
88
+ { key: 'hint', header: 'Key', hideBelow: 'md', cell: (k) => <code className="font-mono text-xs text-ink-2">{k.hint}</code> },
80
89
  {
81
90
  key: 'scopes', header: 'Scopes', hideBelow: 'xl',
82
91
  cell: (k) => (
@@ -108,7 +117,7 @@ export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[];
108
117
  kicker={<>{app.name} · {app.workspace}</>}
109
118
  title="API keys"
110
119
  description={`Keys let your services call ${app.name}. Each one carries only the scopes it needs.`}
111
- actions={<Button variant="primary" icon={<Plus />} onClick={() => setCreating(true)}>Create key</Button>}
120
+ actions={<Button variant="primary" icon={<Plus />} onClick={() => { setCreateError(null); setCreating(true); }}>Create key</Button>}
112
121
  >
113
122
  <Stack>
114
123
  {fresh ? (
@@ -125,7 +134,7 @@ export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[];
125
134
  rows={shown}
126
135
  columns={columns}
127
136
  rowKey={(k) => k.id}
128
- empty={{ title: 'No keys yet', body: `Create a key for each service that calls ${app.name}.`, action: <Button variant="primary" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>Create key</Button> }}
137
+ empty={{ title: 'No keys yet', body: `Create a key for each service that calls ${app.name}.`, action: <Button variant="primary" size="sm" icon={<Plus />} onClick={() => { setCreateError(null); setCreating(true); }}>Create key</Button> }}
129
138
  />
130
139
  </Tooltip.Provider>
131
140
  <p className="t-caption max-w-[72ch]">Keys never expire. To rotate one, create its replacement, move your services to it, then revoke the old key: requests signed with it fail at once.</p>
@@ -138,6 +147,7 @@ export function KeysView({ rows, now, createKey, revokeKey }: { rows: ApiKey[];
138
147
  footer={<><SheetClose asChild><Button variant="ghost">Cancel</Button></SheetClose><Button variant="primary" onClick={create} disabled={pending || scopes.length === 0}>Create key</Button></>}
139
148
  >
140
149
  <div className="flex flex-col gap-6">
150
+ {createError ? <Banner tone="critical" title="Key not created">{createError}</Banner> : null}
141
151
  <Field label="Name" hint="Name it after what uses it." error={error ?? undefined} required>
142
152
  {(p) => <Input {...p} value={name} onChange={(e) => { setName(e.target.value); setError(null); }} placeholder="Billing worker" />}
143
153
  </Field>
@@ -7,6 +7,7 @@ import { app } from '@/app.config';
7
7
  import { PageFrame, Stack } from '@/components/base/shell';
8
8
  import { Avatar } from '@/components/ui/avatar';
9
9
  import { Badge } from '@/components/ui/badge';
10
+ import { Banner } from '@/components/ui/banner';
10
11
  import { Button } from '@/components/ui/button';
11
12
  import { Dialog, DialogClose, DialogContent } from '@/components/ui/dialog';
12
13
  import { Field } from '@/components/ui/field';
@@ -56,13 +57,16 @@ export function MembersView({ rows, now, actions }: { rows: Member[]; now: strin
56
57
  const [removing, setRemoving] = useState<Member | null>(null);
57
58
  const [invite, setInvite] = useState(BLANK);
58
59
  const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
60
+ /** Why the server did not send the last invitation: said inside the sheet it was sent from, never in a toast over it. */
61
+ const [inviteError, setInviteError] = useState<string | null>(null);
59
62
 
60
63
  /**
61
64
  * Show `optimistic` at once, then run the action for the row `id`. The change stays until the transition ends, which waits for
62
- * the refreshed rows; a refusal or a failed request toasts the reason and the table goes back to the rows it was given. A row with an action
63
- * in flight takes no second one.
65
+ * the refreshed rows; on a refusal or a failed request the table goes back to the rows it was given, and the reason is
66
+ * toasted, or handed to `failed` when the change came from a form that shows its own errors. A row with an action in
67
+ * flight takes no second one.
64
68
  */
65
- const act = (id: string, optimistic: Change, action: () => Promise<Result>, done: { title: string; description?: string }, failed?: () => void) => {
69
+ const act = (id: string, optimistic: Change, action: () => Promise<Result>, done: { title: string; description?: string }, failed?: (reason: string) => void) => {
66
70
  if (inFlight.current.has(id)) return;
67
71
  inFlight.current.add(id);
68
72
  start(async () => {
@@ -71,7 +75,7 @@ export function MembersView({ rows, now, actions }: { rows: Member[]; now: strin
71
75
  // A refusal comes back as a reason; an action that never answered (a dropped connection) throws, and is told the same way.
72
76
  const r = await action().catch((): Result => ({ ok: false, error: 'The change did not reach the server. Try again.' }));
73
77
  if (!r.ok) {
74
- failed?.();
78
+ if (failed) return failed(r.error);
75
79
  return toast({ tone: 'critical', title: 'Change not made', description: r.error });
76
80
  }
77
81
  router.refresh();
@@ -93,7 +97,8 @@ export function MembersView({ rows, now, actions }: { rows: Member[]; now: strin
93
97
  const row: Shown = { id: `pending-${tempId.current++}`, name: draft.name.trim(), email: draft.email.trim(), role: draft.role, team: draft.team, status: 'Invited', joined: now.slice(0, 10), lastActive: null, pending: true };
94
98
  setInviting(false);
95
99
  setInvite(BLANK);
96
- act(row.id, { type: 'add', row }, () => actions.invite(draft), { title: `Invitation sent to ${row.email}`, description: `${row.name} joins as ${draft.role} on ${draft.team} when they accept.` }, () => { setInvite(draft); setInviting(true); });
100
+ setInviteError(null);
101
+ act(row.id, { type: 'add', row }, () => actions.invite(draft), { title: `Invitation sent to ${row.email}`, description: `${row.name} joins as ${draft.role} on ${draft.team} when they accept.` }, (reason) => { setInvite(draft); setInviteError(reason); setInviting(true); });
97
102
  };
98
103
 
99
104
  const columns: Column<Shown>[] = [
@@ -146,7 +151,7 @@ export function MembersView({ rows, now, actions }: { rows: Member[]; now: strin
146
151
  kicker={<>{app.name} · {app.workspace}</>}
147
152
  title="Members"
148
153
  description="Who is in the workspace, what they can do, and when they last used it."
149
- actions={<Button variant="primary" icon={<UserPlus />} onClick={() => setInviting(true)}>Invite member</Button>}
154
+ actions={<Button variant="primary" icon={<UserPlus />} onClick={() => { setInviteError(null); setInviting(true); }}>Invite member</Button>}
150
155
  >
151
156
  <Stack>
152
157
  <DataTable
@@ -155,7 +160,7 @@ export function MembersView({ rows, now, actions }: { rows: Member[]; now: strin
155
160
  rows={shown}
156
161
  columns={columns}
157
162
  rowKey={(m) => m.id}
158
- empty={{ title: 'No members yet', body: `Invite the people who work in ${app.name}.`, action: <Button variant="primary" size="sm" icon={<UserPlus />} onClick={() => setInviting(true)}>Invite member</Button> }}
163
+ empty={{ title: 'No members yet', body: `Invite the people who work in ${app.name}.`, action: <Button variant="primary" size="sm" icon={<UserPlus />} onClick={() => { setInviteError(null); setInviting(true); }}>Invite member</Button> }}
159
164
  />
160
165
  </Stack>
161
166
 
@@ -166,6 +171,7 @@ export function MembersView({ rows, now, actions }: { rows: Member[]; now: strin
166
171
  footer={<><SheetClose asChild><Button variant="ghost">Cancel</Button></SheetClose><Button variant="primary" busy={pending} onClick={send}>Send invitation</Button></>}
167
172
  >
168
173
  <div className="flex flex-col gap-6">
174
+ {inviteError ? <Banner tone="critical" title="Invitation not sent">{inviteError}</Banner> : null}
169
175
  <Field label="Name" error={errors.name} required>
170
176
  {(p) => <Input {...p} value={invite.name} onChange={(e) => { setInvite({ ...invite, name: e.target.value }); setErrors({ ...errors, name: undefined }); }} placeholder="Ana Costa" />}
171
177
  </Field>
@@ -26,7 +26,7 @@ export function MissingAddress({ size = 'display' }: { size?: 'display' | 'quiet
26
26
  return (
27
27
  <div className="min-w-0">
28
28
  <p className={cn('font-mono break-all', size === 'display' ? 'text-xl leading-snug tracking-[-0.01em]' : 'text-sm leading-relaxed')}>
29
- <Link href={found} aria-label={`${found}, ${n.label}`} className="row-link text-ink-2">{found}</Link>
29
+ <Link href={found} aria-label={`${found}, ${n.label}`} className="row-link text-ink-2 hover:text-accent-ink">{found}</Link>
30
30
  <span className="border-b-2 border-dashed border-critical pb-px text-ink">{missing}</span>
31
31
  </p>
32
32
  <p className={cn('text-pretty text-ink-2', size === 'display' ? 't-small mt-4' : 't-caption mt-2.5')}>
@@ -40,7 +40,7 @@ export function OverviewBody({
40
40
  if (series.length === 0) {
41
41
  return (
42
42
  <Card>
43
- <EmptyState title="No requests yet" action={<Link href="/keys" className="row-link inline-flex items-center gap-1 font-medium text-ink">API keys <ArrowRight className="size-3.5" /></Link>} className="py-16">
43
+ <EmptyState title="No requests yet" action={<Link href="/keys" className="row-link inline-flex items-center gap-1 font-medium text-ink hover:text-accent-ink">API keys <ArrowRight className="size-3.5" /></Link>} className="py-16">
44
44
  Traffic, reliability and spend appear here once a key makes its first call.
45
45
  </EmptyState>
46
46
  </Card>
@@ -121,7 +121,7 @@ export function OverviewBody({
121
121
  />
122
122
  </CardBody>
123
123
  <CardFooter>
124
- <Link href="/analytics" className="row-link inline-flex items-center gap-1 font-medium text-ink">
124
+ <Link href="/analytics" className="row-link inline-flex items-center gap-1 font-medium text-ink hover:text-accent-ink">
125
125
  All endpoints <ArrowRight className="size-3.5" />
126
126
  </Link>
127
127
  </CardFooter>
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
+ import { useTransition } from 'react';
4
5
  import { useRouter } from 'next/navigation';
5
6
  import { ArrowRight, Copy, Link2, RotateCw, ShieldBan, Terminal } from 'lucide-react';
6
7
  import { PageFrame, Row, Stack } from '@/components/base/shell';
@@ -68,10 +69,18 @@ function Code({ body, label }: { body: string; label: string }) {
68
69
  );
69
70
  }
70
71
 
71
- export function RequestView({ request: r, trace, payloads, now }: { request: RequestRow; trace: Span[]; payloads: { request: string | null; response: string }; now: string }) {
72
+ /** `replay` is the page's Server Action: it sends the request again and answers with the new request, or why not. */
73
+ export function RequestView({ request: r, trace, payloads, now, replay }: { request: RequestRow; trace: Span[]; payloads: { request: string | null; response: string }; now: string; replay: (id: string) => Promise<{ ok: true; id: string; status: number } | { ok: false; error: string }> }) {
72
74
  const router = useRouter();
73
75
  const usage = usageOf(r);
74
76
  const failed = r.status >= 500 || r.status === 429;
77
+ const [replaying, startReplay] = useTransition();
78
+ const sendAgain = () => startReplay(async () => {
79
+ const result = await replay(r.id);
80
+ if (!result.ok) return toast({ tone: 'critical', title: 'Not replayed', description: result.error });
81
+ router.refresh();
82
+ toast({ tone: 'positive', title: `Replayed: ${result.status} ${STATUS_TEXT[result.status] ?? ''}`.trim(), description: `${r.method} ${r.route} ran again as ${result.id}.`, action: { label: 'Open', onClick: () => router.push(`/requests/${result.id}`) } });
83
+ });
75
84
  return (
76
85
  <PageFrame
77
86
  {...detailHead({
@@ -88,7 +97,7 @@ export function RequestView({ request: r, trace, payloads, now }: { request: Req
88
97
  primary: (
89
98
  <>
90
99
  <Button icon={<Terminal />} onClick={() => { void navigator.clipboard?.writeText(curlOf(r, payloads.request)); toast({ tone: 'positive', title: 'cURL command copied' }); }}>Copy as cURL</Button>
91
- {failed ? <Button variant="primary" icon={<RotateCw />} onClick={() => toast({ tone: 'positive', title: 'Replay queued', description: `${r.method} ${r.route} will run again${payloads.request ? ' with the same body' : ''}.` })}>Replay</Button> : null}
100
+ {failed ? <Button variant="primary" icon={<RotateCw />} busy={replaying} onClick={sendAgain}>Replay</Button> : null}
92
101
  </>
93
102
  ),
94
103
  more: [
@@ -112,7 +121,7 @@ export function RequestView({ request: r, trace, payloads, now }: { request: Req
112
121
  { label: 'Response size', value: formatBytes(r.bytes) },
113
122
  { label: 'Customer', value: <Link href={`/requests?q=${encodeURIComponent(r.customer)}`} className="link">{r.customer}</Link> },
114
123
  { label: 'Region', value: r.region, mono: true },
115
- { label: 'API key', value: 'Production backend' },
124
+ ...(r.replayOf ? [{ label: 'Replay of', value: <Link href={`/requests/${r.replayOf}`} className="link font-mono text-xs">{r.replayOf}</Link> }] : []),
116
125
  ...(r.model
117
126
  ? [
118
127
  { label: 'Model', value: r.model, mono: true },
@@ -124,7 +133,7 @@ export function RequestView({ request: r, trace, payloads, now }: { request: Req
124
133
  />
125
134
  </CardBody>
126
135
  <CardFooter>
127
- <Link href={`/requests?q=${encodeURIComponent(r.customer)}`} className="row-link inline-flex items-center gap-1 font-medium text-ink">
136
+ <Link href={`/requests?q=${encodeURIComponent(r.customer)}`} className="row-link inline-flex items-center gap-1 font-medium text-ink hover:text-accent-ink">
128
137
  More from {r.customer} <ArrowRight className="size-3.5" />
129
138
  </Link>
130
139
  </CardFooter>
@@ -62,6 +62,18 @@ describe('changes', () => {
62
62
  expect((await c.query({})).total).toBe(4);
63
63
  });
64
64
 
65
+ test('a created row carries the fields the collection derives from it', async () => {
66
+ const c = arrayCollection({
67
+ name: `derived${++n}`, label: 'People', description: 'd', key: 'id', title: (r: Person & { initial: string }) => r.name,
68
+ fields: z.object({ name: z.string(), team: z.enum(['Support', 'Sales']), seats: z.number(), lastActive: z.string().nullable() }),
69
+ derived: ['initial'], derive: (r) => ({ initial: String(r.name).slice(0, 1) }),
70
+ rows: [], allow: ['create'],
71
+ });
72
+ const row = await c.create!({ name: 'Jin Park', team: 'Sales', seats: 1, lastActive: null });
73
+ expect(row.initial).toBe('J');
74
+ expect((await c.query({ where: [{ field: 'initial', op: 'eq', value: 'J' }] })).total).toBe(1);
75
+ });
76
+
65
77
  test('update and remove are all-or-nothing', async () => {
66
78
  const c = people();
67
79
  await expect(c.update!(['p_1', 'nope'], { team: 'Sales' })).rejects.toThrow(/nope/);
@@ -1,5 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
- import { describe, expect, it } from 'vitest';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('next/navigation', () => ({ useRouter: () => ({ push() {}, refresh() {}, replace() {} }), usePathname: () => '/' }));
3
5
  import { DataTable, type Column } from '@/components/patterns/data-table';
4
6
 
5
7
  type Row = { id: string; name: string; status: string; calls: number };
@@ -45,4 +47,10 @@ describe('DataTable', () => {
45
47
  expect(screen.getByText('643,000')).toBeInTheDocument();
46
48
  expect(screen.getByText('643000 requests')).toBeInTheDocument();
47
49
  });
50
+ it('makes the title of a row that opens a record its link; the row is the hover target and the title has no hover style of its own', () => {
51
+ render(<DataTable caption="Customers" noun="customers" rows={rows} columns={columns} rowKey={(r) => r.id} rowHref={(r) => `/customers/${r.id}`} />);
52
+ const link = screen.getAllByRole('link', { name: 'Northwind Labs' })[0];
53
+ expect(link.getAttribute('href')).toBe('/customers/a');
54
+ expect(link.className).not.toMatch(/row-link|link|underline/);
55
+ });
48
56
  });
@@ -3,9 +3,11 @@ import { describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  vi.mock('next/cache', () => ({ cacheTag: () => {}, cacheLife: () => {}, updateTag: () => {}, revalidateTag: () => {} }));
5
5
 
6
+ import { createHash } from 'node:crypto';
6
7
  import { createKey } from '../app/(dashboard)/keys/actions';
8
+ import { collectionFor, resolveAccess } from '@/data/access';
7
9
 
8
- const draft = { name: 'Billing worker', env: 'live' as const, scopes: ['messages'] };
10
+ const draft = { name: 'Billing worker', env: 'live' as const, scopes: ['messages' as const] };
9
11
 
10
12
  describe('createKey validates its scopes on the server', () => {
11
13
  it('creates a key with declared scopes', async () => {
@@ -18,3 +20,15 @@ describe('createKey validates its scopes on the server', () => {
18
20
  await expect(createKey({ ...draft, scopes: ['messages', 'admin'] as never })).rejects.toThrow(/scope/i);
19
21
  });
20
22
  });
23
+
24
+ describe('createKey shows the full secret once', () => {
25
+ it('returns it to the caller and stores only its hint and its hash', async () => {
26
+ const created = await createKey(draft);
27
+ expect(created.secret).toMatch(/^zzm_live_[0-9a-f]{32}$/);
28
+ const { rows } = await collectionFor(await resolveAccess(), 'keys').query({ where: [{ field: 'id', op: 'eq', value: created.id }] });
29
+ const stored = rows[0] as Record<string, unknown>;
30
+ expect(JSON.stringify(stored)).not.toContain(created.secret);
31
+ expect(stored.hint).toBe(`zzm_live_…${created.secret.slice(-4)}`);
32
+ expect(stored.secretHash).toBe(createHash('sha256').update(created.secret).digest('hex'));
33
+ });
34
+ });
@@ -9,7 +9,7 @@ import { API_KEYS } from '@/system/fixtures/sample-records';
9
9
 
10
10
  describe('the Create key sheet', () => {
11
11
  it('will not create a key with no scope ticked', async () => {
12
- render(<KeysView rows={API_KEYS} now="2026-10-05T09:00:00.000Z" createKey={async () => API_KEYS[0]} revokeKey={async () => {}} />);
12
+ render(<KeysView rows={API_KEYS} now="2026-10-05T09:00:00.000Z" createKey={async () => ({ ...API_KEYS[0], secret: 'zzm_live_' + '0'.repeat(32) })} revokeKey={async () => {}} />);
13
13
  fireEvent.click(screen.getAllByRole('button', { name: 'Create key' })[0]);
14
14
  fireEvent.click(await screen.findByRole('checkbox', { name: 'Messages' }));
15
15
  const create = screen.getAllByRole('button', { name: 'Create key' }).at(-1) as HTMLButtonElement;
@@ -0,0 +1,34 @@
1
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('next/navigation', () => ({ usePathname: () => '/keys', useRouter: () => ({ refresh() {}, push() {}, replace() {} }) }));
5
+ const toasts = vi.hoisted(() => [] as { tone: string; title: string }[]);
6
+ vi.mock('@/components/ui/toast', () => ({ toast: (t: { tone: string; title: string }) => { toasts.push(t); } }));
7
+ vi.stubGlobal('IntersectionObserver', class { observe() {} unobserve() {} disconnect() {} });
8
+
9
+ import { KeysView } from '@/views/keys';
10
+ import { API_KEYS } from '@/system/fixtures/sample-records';
11
+
12
+ describe('the key list', () => {
13
+ it('shows each key by its hint and never carries or reveals a full secret', () => {
14
+ const { container } = render(<KeysView rows={API_KEYS} now="2026-10-05T09:00:00.000Z" createKey={async () => { throw new Error('unused'); }} revokeKey={async () => {}} />);
15
+ expect(screen.getAllByText('zzm_live_…f601').length).toBeGreaterThan(0);
16
+ expect(container.innerHTML).not.toMatch(/zzm_(live|test)_[0-9a-f]{32}/);
17
+ expect(screen.queryByRole('button', { name: /^Reveal/ })).toBeNull();
18
+ expect(screen.queryByRole('button', { name: /^Copy .* key$/ })).toBeNull();
19
+ });
20
+ });
21
+
22
+ describe('creating a key', () => {
23
+ it('says why it failed inside the open sheet, not in a toast over it', async () => {
24
+ render(<KeysView rows={API_KEYS} now="2026-10-05T09:00:00.000Z" createKey={async () => { throw new Error('You do not have permission to create keys.'); }} revokeKey={async () => {}} />);
25
+ fireEvent.click(screen.getAllByRole('button', { name: 'Create key' })[0]);
26
+ fireEvent.change(await screen.findByLabelText(/^Name/), { target: { value: 'Billing worker' } });
27
+ const before = toasts.length;
28
+ await act(async () => { fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Create key' })); });
29
+ const alert = within(await screen.findByRole('dialog')).getByRole('alert');
30
+ expect(alert.textContent).toContain('Key not created');
31
+ expect(alert.textContent).toContain('You do not have permission to create keys.');
32
+ expect(toasts.length).toBe(before);
33
+ });
34
+ });
@@ -23,14 +23,19 @@ async function invite(name: string) {
23
23
  }
24
24
 
25
25
  describe('members, optimistically', () => {
26
- it('shows an invitation at once and rolls it back with the reason when it fails', async () => {
26
+ it('shows an invitation at once, and on a refusal rolls it back and says why inside the reopened sheet, not in a toast', async () => {
27
27
  const d = deferred();
28
28
  render(<MembersView rows={MEMBERS} now="2026-10-05T09:00:00.000Z" actions={{ invite: () => d.promise, setStatus: async () => ({ ok: true }), remove: async () => ({ ok: true }) }} />);
29
29
  await invite('Ana Ruiz');
30
30
  expect(await screen.findByText('Ana Ruiz')).toBeTruthy();
31
+ const before = toasts.length;
31
32
  await act(async () => { d.resolve({ ok: false, error: 'There is no seat left on this plan.' }); });
32
33
  await waitFor(() => expect(screen.queryByText('Ana Ruiz')).toBeNull());
33
- expect(toasts.at(-1)).toMatchObject({ tone: 'critical', title: 'Change not made', description: 'There is no seat left on this plan.' });
34
+ const alert = within(await screen.findByRole('dialog')).getByRole('alert');
35
+ expect(alert.textContent).toContain('Invitation not sent');
36
+ expect(alert.textContent).toContain('There is no seat left on this plan.');
37
+ expect((screen.getByLabelText(/^Name/) as HTMLInputElement).value).toBe('Ana Ruiz');
38
+ expect(toasts.length).toBe(before);
34
39
  });
35
40
  it('rolls back and says so when the action itself fails, as a dropped connection does', async () => {
36
41
  let fail!: (e: Error) => void;
@@ -40,7 +45,7 @@ describe('members, optimistically', () => {
40
45
  expect(await screen.findByText('Rae Okafor')).toBeTruthy();
41
46
  await act(async () => { fail(new Error('Failed to fetch')); });
42
47
  await waitFor(() => expect(screen.queryByText('Rae Okafor')).toBeNull());
43
- expect(toasts.at(-1)).toMatchObject({ tone: 'critical', title: 'Change not made', description: 'The change did not reach the server. Try again.' });
48
+ expect(within(await screen.findByRole('dialog')).getByRole('alert').textContent).toContain('The change did not reach the server. Try again.');
44
49
  expect((screen.getByLabelText(/^Name/) as HTMLInputElement).value).toBe('Rae Okafor');
45
50
  });
46
51
  it('keeps a successful invitation once the authoritative rows include it', async () => {
@@ -111,7 +116,7 @@ describe('keys, optimistically', () => {
111
116
  let fail!: (e: Error) => void;
112
117
  const revokeKey = () => new Promise<void>((_, reject) => { fail = reject; });
113
118
  const key = API_KEYS[0];
114
- render(<KeysView rows={API_KEYS} now="2026-10-05T09:00:00.000Z" createKey={async () => key} revokeKey={revokeKey} />);
119
+ render(<KeysView rows={API_KEYS} now="2026-10-05T09:00:00.000Z" createKey={async () => ({ ...key, secret: 'zzm_live_' + '0'.repeat(32) })} revokeKey={revokeKey} />);
115
120
  fireEvent.click(screen.getByRole('button', { name: `Revoke ${key.name}` }));
116
121
  fireEvent.click(await screen.findByRole('button', { name: 'Revoke key' }));
117
122
  await waitFor(() => expect(screen.queryByRole('button', { name: `Revoke ${key.name}` })).toBeNull());
@@ -0,0 +1,35 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ const invalidated = vi.hoisted(() => [] as string[]);
5
+ vi.mock('next/cache', () => ({ cacheTag: () => {}, cacheLife: () => {}, updateTag: (t: string) => { invalidated.push(t); }, revalidateTag: () => {} }));
6
+
7
+ import { replayRequest } from '../app/(dashboard)/requests/[id]/actions';
8
+ import { collectionFor, resolveAccess } from '@/data/access';
9
+ import { REQUESTS } from '@/system/fixtures/sample';
10
+
11
+ const failed = REQUESTS.find((r) => r.status >= 500)!;
12
+
13
+ describe('replaying a request', () => {
14
+ it('sends the same request again: a new record, newest, that names the one it replays, and the list is refreshed', async () => {
15
+ const r = await replayRequest(failed.id);
16
+ expect(r.ok).toBe(true);
17
+ if (!r.ok) return;
18
+ expect(r.id).not.toBe(failed.id);
19
+ const c = collectionFor(await resolveAccess(), 'requests');
20
+ const { rows } = await c.query({ where: [{ field: 'id', op: 'eq', value: r.id }] });
21
+ const replay = rows[0] as Record<string, unknown>;
22
+ expect(replay).toMatchObject({ method: failed.method, route: failed.route, customer: failed.customer, region: failed.region, replayOf: failed.id, status: r.status });
23
+ expect(replay.statusClass).toBe('2xx');
24
+ expect(String(replay.search)).toContain(failed.route.toLowerCase());
25
+ const newest = await c.query({ sort: { field: 'at', dir: 'desc' }, limit: 1 });
26
+ expect(newest.rows[0].id).toBe(r.id);
27
+ expect(invalidated.length).toBeGreaterThan(0);
28
+ });
29
+ it('answers a request that does not exist with a reason, and writes nothing', async () => {
30
+ const c = collectionFor(await resolveAccess(), 'requests');
31
+ const before = (await c.query({})).total;
32
+ expect(await replayRequest('req_nope')).toEqual({ ok: false, error: 'No request with id req_nope.' });
33
+ expect((await c.query({})).total).toBe(before);
34
+ });
35
+ });
@@ -0,0 +1,36 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ const push = vi.hoisted(() => vi.fn());
5
+ vi.mock('next/navigation', () => ({ usePathname: () => '/requests/x', useRouter: () => ({ refresh() {}, push, replace() {} }) }));
6
+ const toasts = vi.hoisted(() => [] as { tone: string; title: string; description?: string; action?: { label: string; onClick: () => void } }[]);
7
+ vi.mock('@/components/ui/toast', () => ({ toast: (t: (typeof toasts)[number]) => { toasts.push(t); } }));
8
+
9
+ import { RequestView } from '@/views/request';
10
+ import { REQUESTS } from '@/system/fixtures/sample';
11
+ import { payloadsOf, traceOf } from '@/data/sample';
12
+
13
+ const failed = REQUESTS.find((r) => r.status >= 500)!;
14
+ const view = (replay: (id: string) => Promise<{ ok: true; id: string; status: number } | { ok: false; error: string }>) =>
15
+ render(<RequestView request={failed} trace={traceOf(failed)} payloads={payloadsOf(failed)} now="2026-10-05T09:00:00.000Z" replay={replay} />);
16
+
17
+ describe('Replay on a failed request', () => {
18
+ it('sends it again and offers the new request', async () => {
19
+ const replay = vi.fn(async () => ({ ok: true as const, id: 'requests_999', status: 200 }));
20
+ view(replay);
21
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Replay' })); });
22
+ expect(replay).toHaveBeenCalledWith(failed.id);
23
+ expect(toasts.at(-1)).toMatchObject({ tone: 'positive', title: 'Replayed: 200 OK' });
24
+ toasts.at(-1)!.action!.onClick();
25
+ expect(push).toHaveBeenCalledWith('/requests/requests_999');
26
+ });
27
+ it('says why when the replay is refused', async () => {
28
+ view(async () => ({ ok: false, error: 'You do not have permission to replay requests.' }));
29
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Replay' })); });
30
+ expect(toasts.at(-1)).toMatchObject({ tone: 'critical', title: 'Not replayed', description: 'You do not have permission to replay requests.' });
31
+ });
32
+ it('states no API key it does not know', () => {
33
+ view(async () => ({ ok: false, error: 'unused' }));
34
+ expect(screen.queryByText('Production backend')).toBeNull();
35
+ });
36
+ });