zz-meridian 0.2.0 → 0.3.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 (45) hide show
  1. package/package.json +1 -1
  2. package/payload/.env.example +2 -0
  3. package/payload/CHANGELOG.md +33 -0
  4. package/payload/app/(dashboard)/README.md +1 -1
  5. package/payload/app/(dashboard)/error.tsx +10 -2
  6. package/payload/app/not-found/README.md +4 -4
  7. package/payload/docs/assistant.md +1 -1
  8. package/payload/docs/distribution.md +8 -6
  9. package/payload/package.json +1 -1
  10. package/payload/scripts/assistant.ts +17 -3
  11. package/payload/scripts/check.ts +10 -1
  12. package/payload/scripts/fake-llm.ts +15 -0
  13. package/payload/scripts/keyboard.ts +10 -0
  14. package/payload/scripts/verify.config.ts +13 -0
  15. package/payload/scripts/verify.ts +65 -0
  16. package/payload/skills/zz-meridian/references/customize.md +8 -0
  17. package/payload/skills/zz-meridian/references/existing-project.md +11 -0
  18. package/payload/skills/zz-meridian/references/validation.md +4 -0
  19. package/payload/src/components/base/shell/README.md +1 -0
  20. package/payload/src/components/base/shell/index.tsx +4 -1
  21. package/payload/src/components/charts/timeline/README.md +3 -1
  22. package/payload/src/components/charts/timeline/index.tsx +9 -1
  23. package/payload/src/components/patterns/assistant/README.md +2 -2
  24. package/payload/src/components/patterns/assistant/index.tsx +23 -3
  25. package/payload/src/components/patterns/assistant/preview.tsx +2 -1
  26. package/payload/src/components/patterns/assistant/text.tsx +25 -0
  27. package/payload/src/components/patterns/command-palette/README.md +1 -1
  28. package/payload/src/components/patterns/filter-bar/README.md +3 -3
  29. package/payload/src/components/patterns/filter-bar/index.tsx +11 -7
  30. package/payload/src/components/patterns/form-section/README.md +5 -1
  31. package/payload/src/components/patterns/form-section/index.tsx +71 -36
  32. package/payload/src/components/patterns/form-section/preview.tsx +17 -0
  33. package/payload/src/components/patterns/period-select/index.tsx +4 -3
  34. package/payload/src/components/patterns/rail/README.md +2 -0
  35. package/payload/src/components/ui/card/README.md +2 -2
  36. package/payload/src/components/ui/card/index.tsx +28 -2
  37. package/payload/src/components/ui/card/preview.tsx +25 -0
  38. package/payload/src/components/ui/segmented/README.md +1 -1
  39. package/payload/src/components/ui/segmented/index.tsx +4 -1
  40. package/payload/src/lib/collection.ts +29 -5
  41. package/payload/src/lib/period.ts +11 -0
  42. package/payload/src/views/not-found-address.tsx +4 -4
  43. package/payload/src/views/not-found.tsx +10 -2
  44. package/payload/tests/assistant-text.test.tsx +40 -0
  45. package/payload/tests/collection.test.ts +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -8,6 +8,8 @@
8
8
  # ASSISTANT_API_KEY=
9
9
 
10
10
  # The model's id, as the provider names it. It must be able to call tools.
11
+ # A gateway (LiteLLM, OpenRouter and the like) often names models "<provider>.<model>": copy the id
12
+ # from the gateway's GET /models, and take it exactly as it comes — the prefix is part of the name.
11
13
  # ASSISTANT_MODEL=
12
14
 
13
15
  # The provider's address. Required for "openai-compatible"; optional for "anthropic".
@@ -2,6 +2,39 @@
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.3.0] · 2026-10-04
6
+
7
+ Three field reports from products built on Meridian (issues #3, #4 and #5), taken as proposed where the proposal held and differently where it did not. Everything here is a fix to what the template ships, or a hole an adopting product could not fill itself.
8
+
9
+ ### Added
10
+
11
+ - **The assistant renders a reply as markdown.** A real model answers in markdown, and the panel was showing it literally — `**High risk:**`, `- ` bullets and `|---|` table rules on screen. Each text part goes through `Prose` at `sm` (`src/components/patterns/assistant/text.tsx`), the same reader the rest of the product uses, so raw HTML stays text and every URL passes `safeMarkdownUrl`. The reply block carries `data-assistant-text`, a stable handle for a check; the person's own message is still plain text, as typed. `scripts/fake-llm.ts` gained one scripted markdown reply and `scripts/assistant.ts` asserts the list, the bold and the table are elements with the markup characters gone.
12
+ - **`CardHeader` `wrap`**, for a title that is the point of the card — an objective, a record's name — rather than a label in a list where one line and an ellipsis is right.
13
+ - **`FormSection as="div"` and `flush`.** A settings page that holds a table had nowhere to put it: `FormSection` always wrapped its card in a `<form>` with a save bar, and its body was a padded `fieldset`, so it could hold neither a table that runs edge to edge nor a form of its own (forms cannot nest). `as="div"` is the same head and card with no `<form>`, and `flush` drops the body's padding for the table. `SettingRow` sits outside `FormSection` unchanged, for a switch that applies at once.
14
+ - **Collections: `derived`, and the write shapes a real data layer can honour.** `create` and `update` took `Omit<T, K>`, which demands every field of the record — including ones a write never takes (a worked-out score, a band, joined data) — so a database-backed product had to cast around the type. They now take a plain record validated by `fields`, which is what the store always did at runtime. `derived` names the read-only fields: a page reads them, the assistant may filter on them, and `patchOf` keeps them out of every change.
15
+ - **`verify` refuses to run against a data URL that is not on this machine.** It presses every control, Delete included, and an adopted app's `DATABASE_URL` comes from `.env` — so on a first outage, or any day, those presses landed on whatever that URL names. Name extra variables in `dataUrls`, and set `allowRemoteData: true` only when you know what the presses reach. verify also prints an estimate and each phase as it goes, and names `--quick` and `--no-vitals` up front.
16
+ - **`PERIOD_SHORT`** in `src/lib/period.ts`, beside `PERIOD_LABEL`: a product that adds its own period (a 24-hour one) edits that one file, and `PeriodSelect` follows — it no longer carries a `Record<Period, string>` of its own that fails to type check the moment the vocabulary moves.
17
+ - **A timeline bar that continues past a fixed window is squared off** on the side that continues. Work that started before `from` or runs past `to` used to read as work that began at the window's edge.
18
+
19
+ ### Changed
20
+
21
+ - **`FilterBar` reads its own width, not the window's** (a container query, `@max-[52rem]`). With the assistant's column open at 1440px the bar sat in about 900px and still laid out for a wide window: the search shrank to a few characters while every filter stayed. This is the rule the table already followed.
22
+ - **`Segmented` scrolls sideways with the edge fade** when its labels are wider than the track, rather than running off the card. Six options with counts in their labels ("All 37 · Idea 3 · Scored 25") no longer clip at 390px.
23
+ - **`CardBody flush` clips a `Table` as its first child and drops the header row's top border.** Directly under a card's own edge there was a second line, and the header's sunk fill squared off the card's rounded top corners — most visible in dark. A second table in the same body keeps its border.
24
+ - **The not-found screen and the error view read the home page's name from `nav`**, never "Overview". A product whose front page is a ranked list names it once in `src/app.config.ts`, and the buttons follow. The error view's second way out is `Check Health` where the product has that page and the home page where it does not.
25
+ - **`check.ts` treats `src/lib/format.ts` and `src/lib/color.ts` as the toolkit they are.** A product that re-syncs `src/lib` and removes the samples was failing Meridian's own gate on Meridian's own files (`formatCost`, `oklchToRgb`: "nothing a product keeps imports"), and had to strip the export keywords to get through. Every file under `src/lib` that Meridian ships now passes in a product, unchanged.
26
+
27
+ ### Fixed
28
+
29
+ - The assistant labelled a question with the masthead `h1`, which on a detail page is the record's name with its status badge run into it ("REC-1042high risk · 0.64"). The label now comes from the route's own `document.title` ("Record REC-1042"), with the masthead as the fallback.
30
+ - A keyboard focus that scrolled into view could land under the 56px sticky top bar. The scroll region now carries `scroll-pt-16`, so a focused control comes to rest clear of it (WCAG 2.4.11).
31
+ - `docs/assistant.md` and `.env.example` say that a gateway names models `<provider>.<model>` and to copy the id from its `GET /models` — the prefix is part of the name.
32
+
33
+ ### Breaking
34
+
35
+ - `Collection.create` and `Collection.update` (`src/lib/collection.ts`) take `Record<string, unknown>` instead of `Omit<T, K>` and `Partial<Omit<T, K>>`. A caller that passed a fully typed record still compiles; a data layer that had to cast now does not. `derived` is new and optional.
36
+ - `FormSection` gained `as` and `flush`; both default to today's behaviour, so nothing that does not pass them changes.
37
+
5
38
  ## [0.2.0] · 2026-10-04
6
39
 
7
40
  The first release on npm. A team brings Meridian into its own dashboard with one sentence to its coding agent:
@@ -27,7 +27,7 @@ Below 1024px every row stacks: the featured card first, then the tiles (two acro
27
27
  | Empty (no traffic yet) | The featured card says "No requests yet" with a link to API keys; tiles show dashes, never zeros |
28
28
  | Partial (a series missing) | That tile shows a dash and "Not measured"; the chart breaks its line over missing days |
29
29
  | Stale | Freshness turns to warning: "Stale · updated 47 min ago" |
30
- | Error | `error.tsx`, inside the shell at the data width, like every page: the title says the view did not load and nothing was changed; one card says retrying usually works, with Retry (primary), Check Health, and the reference |
30
+ | Error | `error.tsx`, inside the shell at the data width, like every page: the title says the view did not load and nothing was changed; one card says retrying usually works, with Retry (primary), a second way out read from `nav` (Check Health where the product has one, the home page where it does not), and the reference |
31
31
 
32
32
  ## Data
33
33
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import Link from 'next/link';
4
4
  import { RotateCw } from 'lucide-react';
5
+ import { nav } from '@/app.config';
5
6
  import { PageFrame } from '@/components/base/shell';
6
7
  import { Button } from '@/components/ui/button';
7
8
  import { Card } from '@/components/ui/card';
@@ -10,8 +11,15 @@ import { EmptyState } from '@/components/ui/empty-state';
10
11
  /**
11
12
  * A page that failed to load: in place, inside the shell, so the rail and the way out stay where they were. The title
12
13
  * says what happened once; the card says what to do about it, with the reference support will ask for.
14
+ *
15
+ * The second way out is read from `nav`, never written here: not every product has a Health page, and one that does not
16
+ * would otherwise offer a button to an address that leads nowhere while the reader is already having a bad day.
13
17
  */
14
18
  export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
19
+ const items = nav.flatMap((g) => g.items);
20
+ const health = items.find((i) => i.href === '/health');
21
+ const home = items.find((i) => i.href === '/');
22
+ const elsewhere = health ?? home;
15
23
  return (
16
24
  <PageFrame kicker="Something failed" title="This view did not load" description="The data behind it did not arrive. Nothing was changed.">
17
25
  <Card className="arrive">
@@ -21,11 +29,11 @@ export default function DashboardError({ error, reset }: { error: Error & { dige
21
29
  action={
22
30
  <>
23
31
  <Button variant="primary" icon={<RotateCw />} onClick={reset}>Retry</Button>
24
- <Button asChild><Link href="/health">Check Health</Link></Button>
32
+ {elsewhere ? <Button asChild><Link href={elsewhere.href}>{health ? `Check ${health.label}` : `Go to ${elsewhere.label}`}</Link></Button> : null}
25
33
  </>
26
34
  }
27
35
  >
28
- If it fails again, Health says whether an incident is under way. Reference <span className="font-mono text-ink">{error.digest ?? 'client'}</span>
36
+ {health ? `If it fails again, ${health.label} says whether an incident is under way.` : 'If it fails again, it is likely to stay that way until someone looks at the data behind it.'} Reference <span className="font-mono text-ink">{error.digest ?? 'client'}</span>
29
37
  </EmptyState>
30
38
  </Card>
31
39
  </PageFrame>
@@ -15,19 +15,19 @@ For an address outside the console, a standalone screen on the lit ground, from
15
15
  | Sentence | "This page isn't here." at `text-display`: the protagonist |
16
16
  | Lead | "The link may be mistyped or out of date, or what it pointed to has been removed." |
17
17
  | Address | A sunk field, body-size mono: the part that exists as a link to that page, the part that does not under a dashed critical rule, and one line saying which is which |
18
- | Actions | Back to the nearest page (primary, `lg`), then Go to Overview when that is somewhere else |
18
+ | Actions | Back to the nearest page (primary, `lg`), then a link to the home page when that is somewhere else (`nav`'s label for `/`) |
19
19
 
20
20
  Inside the console the screen is `MissingPage` (`src/views/missing-page.tsx`): a record page renders it itself when its ID is missing (`/requests/<id>`), and `app/(dashboard)/not-found.tsx` renders it for any other `notFound()`. A record page renders rather than throws because a `notFound()` thrown inside the dashboard's loading boundary shows only once the JavaScript arrives, which on a slow phone put the largest paint past 2.5s; the page keeps `noindex`. The rail stays; the sentence becomes the page title over the same lead at the data width, on the same left edge as every page, and one card holds the address at `text-xl` as the protagonist and the same actions (`md`). A miss at the root there offers Search pages (the command palette) as the second action.
21
21
 
22
- The words come from `src/views/not-found.tsx`; the address and the actions from `src/views/not-found-address.tsx`, once.
22
+ The words come from `src/views/not-found.tsx`; the address and the actions from `src/views/not-found-address.tsx`, once. The home page's own name is read from `nav` (`homeLabel`), not written here: a product whose front page is a ranked list, not "Overview", names it once in `src/app.config.ts` and both buttons follow.
23
23
 
24
24
  ## States
25
25
 
26
26
  | State | What shows |
27
27
  |---|---|
28
- | A missing record (`/requests/req_9x7k`) | `/requests` links to Requests; `/req_9x7k` is marked; "Requests is still here. Nothing in it answers to req_9x7k."; Back to Requests, Go to Overview |
28
+ | A missing record (`/requests/req_9x7k`) | `/requests` links to Requests; `/req_9x7k` is marked; "Requests is still here. Nothing in it answers to req_9x7k."; Back to Requests, Go to the home page (`nav`'s label: Overview here) |
29
29
  | A deeper miss (`/settings/billing/invoices/2026`) | `/settings` links to Settings; the rest is marked; "Settings is still here; the rest of the address leads nowhere." |
30
- | No part exists (`/this-page-does-not-exist`) | `/` links home; the rest is marked; "No page in ZZ Meridian lives at this address."; Go to Overview (and, in the console, Search pages) |
30
+ | No part exists (`/this-page-does-not-exist`) | `/` links home; the rest is marked; "No page in ZZ Meridian lives at this address."; Go to the home page (and, in the console, Search pages) |
31
31
  | A long ID (60 characters or more) | The address breaks anywhere rather than overflowing; the sentence says "the address above" instead of repeating an ID over 32 characters |
32
32
  | Percent-escapes (`/requests/a%20b`) | Decoded for reading where they decode, shown as typed where they do not |
33
33
  | A trailing slash | Ignored: `/requests/` is Requests itself, not a miss |
@@ -14,7 +14,7 @@ Four variables, read from the environment on every request, so one build serves
14
14
  |---|---|
15
15
  | `ASSISTANT_PROVIDER` | `anthropic`, or `openai-compatible` for any service that speaks the OpenAI chat-completions format |
16
16
  | `ASSISTANT_API_KEY` | The provider's key. Required: the approval secret is derived from it. |
17
- | `ASSISTANT_MODEL` | The model's id, as the provider names it |
17
+ | `ASSISTANT_MODEL` | The model's id, as the provider names it. A gateway such as LiteLLM names models `<provider>.<model>` — copy the id exactly as its `GET /models` lists it, and take it as it comes. |
18
18
  | `ASSISTANT_BASE_URL` | The provider's address. Required for `openai-compatible`; optional for `anthropic`. |
19
19
 
20
20
  If the key or the model is missing, or the provider is anything else, or `openai-compatible` has no address, the assistant is off. `.env.example` lists the four, commented, with no values.
@@ -137,12 +137,14 @@ writes the version into `cli/package.json` and checks the root agrees.
137
137
 
138
138
  ## One-time setup (the maintainer, once)
139
139
 
140
- npm configures a trusted publisher only on a package that exists, so 0.2.0 was published from a laptop under the
141
- maintainer's npm account, with 2FA: the exact tarball the dry run tested, downloaded from its run. Then npmjs.com →
142
- `zz-meridian` → Settings → Trusted publisher: GitHub Actions, `zhixuan312` / `zz-meridian` / `release.yml`, and
143
- Publishing access set to require 2FA and disallow tokens. The real dispatch of 0.2.0 then found the version on the
144
- registry, skipped the publish, and finished the consumer check, the tag and the Release. From 0.2.1 on, CI publishes
145
- with provenance.
140
+ npm configures a trusted publisher only on a package that exists. The maintainer created `zz-meridian` with a
141
+ `0.0.0-stage` placeholder, then on npmjs.com → `zz-meridian` → Settings set the trusted publisher (GitHub Actions,
142
+ `zhixuan312` / `zz-meridian` / `release.yml`, environment `npm`, "Allow npm publish" on) and Publishing access to
143
+ require 2FA and disallow tokens. 0.2.0 itself was published from the maintainer's laptop, with 2FA, before the
144
+ publisher was set: the exact tarball the dry run had tested (its sha512 matches), so it carries no provenance. The
145
+ release run then found it on the registry, skipped the publish, and finished the consumer check, the tag and the
146
+ Release. From 0.2.1 on, only the workflow, from `master` through the `npm` environment, can publish, and every
147
+ version carries provenance. The placeholder can be deprecated.
146
148
 
147
149
  ## Versioning
148
150
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.2.0",
3
+ "version": "0.3.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.",
@@ -49,8 +49,10 @@ async function ask(text: string) {
49
49
  await until('Send to enable', () => page.eval<boolean>(`!document.querySelector('${SEND}').disabled`), Boolean);
50
50
  await page.eval(`document.querySelector('${SEND}').click()`);
51
51
  }
52
- /** What the assistant's latest text says (a Proposal card's own text is not the assistant speaking). */
53
- const said = () => page.eval<string>(`[...document.querySelectorAll('aside[data-assistant] [data-role=assistant] > p')].at(-1)?.textContent.trim() ?? ''`);
52
+ /** What the assistant's latest text says (a Proposal card's own text is not the assistant speaking).
53
+ * `data-assistant-text` is the reply block's own handle: a reply is markdown, so its text may be in a paragraph, a
54
+ * list item or a table cell, and a selector that assumed a paragraph would break the day a model answers with a list. */
55
+ const said = () => page.eval<string>(`[...document.querySelectorAll('aside[data-assistant] [data-assistant-text]')].at(-1)?.textContent.trim() ?? ''`);
54
56
  /** The titles of the Proposal cards in the panel. */
55
57
  const proposals = () => page.eval<string[]>(`[...document.querySelectorAll('${PROPOSALS}')].map((a) => a.getAttribute('aria-label'))`);
56
58
  /** A table row's text: the member's name, role, team, status and activity. */
@@ -96,13 +98,25 @@ try {
96
98
  await page.send('Input.insertText', { text: 'What is this page?' });
97
99
  await until('Send to enable', () => page.eval<boolean>(`!document.querySelector('aside[data-assistant] button[aria-label="Send"]').disabled`), Boolean);
98
100
  await page.eval(`document.querySelector('aside[data-assistant] button[aria-label="Send"]').click()`);
99
- const reply = await until('the reply', () => page.eval<string>(`[...document.querySelectorAll('aside[data-assistant] [data-role=assistant] p')].map((e) => e.textContent.trim()).join('|')`), (t) => t === 'This is the Overview page.');
101
+ const reply = await until('the reply', said, (t) => t === 'This is the Overview page.');
100
102
  ok(`the streamed reply reads "${reply}"`);
101
103
 
102
104
  const requests = (await (await fetch(`${llm}/requests`)).json()) as unknown[];
103
105
  if (!JSON.stringify(requests).includes('Page: Overview (/)')) fail('no request to the model carried "Page: Overview (/)"');
104
106
  ok('the model request carried "Page: Overview (/)"');
105
107
 
108
+ // A reply is rendered as markdown, never as the model's own markup: the list, the bold and the table are
109
+ // ELEMENTS, and the characters that made them are gone from the panel's text.
110
+ await ask('Show me a markdown summary');
111
+ const md = await until('the markdown reply', () => page.eval<{ list: number; bold: number; table: number; text: string }>(
112
+ `(() => { const b = [...document.querySelectorAll('aside[data-assistant] [data-assistant-text]')].at(-1);
113
+ return b ? { list: b.querySelectorAll('li').length, bold: b.querySelectorAll('strong').length,
114
+ table: b.querySelectorAll('table').length, text: b.textContent } : { list: 0, bold: 0, table: 0, text: '' }; })()`),
115
+ (v) => v.table > 0);
116
+ if (!md.list || !md.bold) fail(`the markdown reply rendered ${md.list} list items and ${md.bold} bold runs`);
117
+ if (md.text.includes('**') || md.text.includes('|---') || md.text.includes('| ---')) fail(`the markdown reply still shows its markup: "${md.text}"`);
118
+ ok(`the reply is rendered markdown: ${md.list} list items, ${md.bold} bold runs and a table, with no markup characters left`);
119
+
106
120
  // The collections: ask, approve, dismiss, and a change made on the page.
107
121
  await page.open(`${base}/members`, { width: 1440 });
108
122
  await page.eval(`window.__walk = true`);
@@ -159,6 +159,15 @@ const hasAtlas = fs.existsSync(path.join(ROOT, 'src/system')) && fs.existsSync(p
159
159
  const atlasOnly = hasAtlas ? [...read('scripts/brand.ts').matchAll(/const atlasOnly = \[([^\]]*)\]/g)].flatMap((m) => [...m[1].matchAll(/'([^']+)'/g)].map((x) => `src/system/${x[1]}`)) : [];
160
160
  if (hasAtlas && !atlasOnly.length) problems.push('scripts/brand.ts: no atlasOnly list found, so the dormant-code rule cannot tell what a product keeps');
161
161
  const SWEPT = /^src\/(lib|data)\//;
162
+ /**
163
+ * The modules Meridian ships as a TOOLKIT, whose exports are a product's to use or not.
164
+ *
165
+ * A product re-syncs `src/lib` verbatim and removes the samples, or never had them, so a formatter or a colour helper
166
+ * whose only callers were sample views reads as a dormant export and fails this very check — in the product, on a file
167
+ * Meridian gave it. Naming them here is the fix: "this module is a library a product may use in part" is a decision
168
+ * about the module, so it is declared rather than inferred, and the list is short on purpose.
169
+ */
170
+ const TOOLKIT = /^src\/lib\/(format|color)\.ts$/;
162
171
  // In a project that adopted Meridian (zz-meridian adopt), Meridian's own modules are a library it uses in part; only
163
172
  // the project's own src/lib and src/data are swept. A created dashboard is swept whole, as the template is.
164
173
  const manifestPath = path.join(ROOT, '.meridian/manifest.json');
@@ -194,7 +203,7 @@ for (const f of kept) {
194
203
  for (const m of src.matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g)) record(resolveSpec(f, m[1]), '*');
195
204
  }
196
205
  const EXPORT_DECL = /^export\s+(?:declare\s+)?(?:async\s+)?(?:const|let|var|function\*?|class|abstract\s+class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/gm;
197
- for (const f of walk('src', /\.tsx?$/).filter((x) => SWEPT.test(x) && !/(^|\/)preview\.tsx$/.test(x) && !adopted.has(x))) {
206
+ for (const f of walk('src', /\.tsx?$/).filter((x) => SWEPT.test(x) && !TOOLKIT.test(x) && !/(^|\/)preview\.tsx$/.test(x) && !adopted.has(x))) {
198
207
  const src = read(f);
199
208
  const names = new Set([...src.matchAll(EXPORT_DECL)].map((m) => m[1]));
200
209
  if (/^export\s+default\b/m.test(src)) names.add('default');
@@ -55,6 +55,21 @@ function script(body: { messages?: Message[] }): Reply {
55
55
 
56
56
  if (last?.role === 'user') {
57
57
  const said = textOf(last.content).trim();
58
+ // One reply in markdown, for the walk-through that proves the panel RENDERS it rather than printing the model's
59
+ // own `**`, `- ` and `|---|`. Every model this template has been pointed at answers this way, and a fake model
60
+ // that only ever answers one plain sentence cannot tell the two apart.
61
+ if (/markdown/i.test(said)) {
62
+ return { text: [
63
+ '**Traffic** this week, in short:',
64
+ '',
65
+ '- requests are up **12%**',
66
+ '- errors are flat',
67
+ '',
68
+ '| Metric | Value |',
69
+ '| --- | --- |',
70
+ '| Requests | `1204` |',
71
+ ].join('\n') };
72
+ }
58
73
  const today = /Today: (\d{4}-\d{2}-\d{2})/.exec(system)?.[1];
59
74
  if (today && /support/i.test(said) && /60 days/i.test(said)) {
60
75
  const where = [{ field: 'team', op: 'eq', value: 'Support' }, { field: 'role', op: 'eq', value: 'Viewer' }, { field: 'lastActive', op: 'lt', value: daysBefore(today, 60) }];
@@ -7,6 +7,16 @@
7
7
  * ring drawn as a box shadow, on the control or the frame around it), when a focused control is hidden under
8
8
  * something else such as the sticky top bar (WCAG 2.4.11), or when a visible control is never reached at all.
9
9
  * The audit (scripts/audit.ts) checks the first eighteen stops of every page at every width; this walks all of them.
10
+ *
11
+ * KNOWN GAP: this proves every control is REACHABLE and ringed; it never ACTIVATES one with the keyboard. A control
12
+ * that answers a click but not Enter or Space passes both this and scripts/interactions.ts (which presses with the
13
+ * mouse and with touch). The gap is deliberate until a check can be trusted not to cry wolf: what a key should do
14
+ * depends on the role — Enter activates a button and a menu item, Space toggles a checkbox and a switch, and an arrow
15
+ * key moves within a radio group — so a blanket "something must change on Enter" fails honest controls and teaches
16
+ * people to ignore the check. Two things a future check needs: send the key as
17
+ * `Input.dispatchKeyEvent({ type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' })`
18
+ * — a CDP keyDown WITHOUT `text` does not activate a button at all, which reads as a dead control — and press by role,
19
+ * not one key for everything.
10
20
  */
11
21
  import { launch } from './lib/chrome.ts';
12
22
  import { discover } from './lib/routes.ts';
@@ -21,6 +21,19 @@ export type VerifyConfig = {
21
21
  * repository (local files, fixtures). Never set it to make verify run against an API.
22
22
  */
23
23
  noLiveApi?: true;
24
+ /**
25
+ * Environment variables naming a data store, besides `DATABASE_URL`, which verify always reads.
26
+ *
27
+ * verify refuses to start when one of them — from the environment, or from an `.env` file Next would load — resolves
28
+ * to a host that is not this machine. It presses every control it finds, Delete included, and a `next build` and
29
+ * `next start` in this folder read the project's own `.env`: an adopted app whose `DATABASE_URL` points at
30
+ * production would otherwise have those presses land on production.
31
+ */
32
+ dataUrls?: string[];
33
+ /**
34
+ * Run verify against a remote data URL anyway. Set it only when you know what the presses reach.
35
+ */
36
+ allowRemoteData?: true;
24
37
  /**
25
38
  * The product's own browser checks, beside Meridian's audit, presses and keyboard walk: scripts verify runs with
26
39
  * `--base <url>` against the built app, failing when one exits non-zero.
@@ -11,6 +11,10 @@
11
11
  * walk-through (scripts/assistant.ts: asks, approves and dismisses on /members, then the thread, the switch, the
12
12
  * layout, a refused key and the key's absence from the browser).
13
13
  *
14
+ * It refuses to start when a data URL (`DATABASE_URL`, or any `verify.config.ts` names) resolves to a host that is not
15
+ * this machine: the presses and the walk-through change data, and an adopted app's `.env` usually points at production.
16
+ * Restore the data after a run: the walk-through flags rows and a press can delete.
17
+ *
14
18
  * pnpm verify [--quick] [--no-vitals] [--extra /requests/req_1,/customers/acme]
15
19
  *
16
20
  * The detail pages in scripts/verify.config.ts are checked by default; --extra replaces them for one run. --no-vitals
@@ -53,6 +57,63 @@ const finish = (code: number) => {
53
57
  process.exit(code);
54
58
  };
55
59
 
60
+ /** Can this hostname only be this machine — or a name that can only resolve inside it? */
61
+ function isLocalHost(host: string): boolean {
62
+ const h = host.toLowerCase().replace(/^\[|\]$/g, '');
63
+ if (h === 'localhost' || h === '::1' || h === '0.0.0.0') return true;
64
+ if (/^127\./.test(h)) return true;
65
+ // A name with no dot cannot be a public address: a compose service (`postgres`), a container name, a bare hostname.
66
+ return !h.includes('.');
67
+ }
68
+
69
+ /** The `.env` files Next loads into the build and the server, in its own order: a later file wins over an earlier one. */
70
+ function envFiles(): Record<string, string> {
71
+ const out: Record<string, string> = {};
72
+ for (const f of ['.env', '.env.production', '.env.local', '.env.production.local']) {
73
+ const p = path.join(ROOT, f);
74
+ if (!fs.existsSync(p)) continue;
75
+ for (const raw of fs.readFileSync(p, 'utf8').split('\n')) {
76
+ const line = raw.trim();
77
+ if (!line || line.startsWith('#')) continue;
78
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
79
+ if (!m) continue;
80
+ const v = m[2].trim();
81
+ out[m[1]] = (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ? v.slice(1, -1) : v;
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // The presses reach whatever database the app is pointed at. `noLiveApi`/`fakeApi` cover an HTTP backend; a direct
88
+ // database connection is the same hazard through a different door, and it is the one an adopted app has: its
89
+ // DATABASE_URL comes from .env, and verify builds and starts it in this folder. Refused before anything runs, because
90
+ // by the time the presses land the damage is done — and they leave changes behind even when everything passes.
91
+ {
92
+ const fromFiles = envFiles();
93
+ const offenders = ['DATABASE_URL', ...(config.dataUrls ?? [])].flatMap((name) => {
94
+ const value = process.env[name] ?? fromFiles[name];
95
+ if (!value) return [];
96
+ let host: string;
97
+ // Not a URL is not something this can judge; the app will fail on it long before a press.
98
+ try { host = new URL(value).hostname; } catch { return []; }
99
+ return isLocalHost(host) ? [] : [`${name} → ${host}`];
100
+ });
101
+ if (offenders.length && !config.allowRemoteData) {
102
+ console.error(`verify presses every control, Delete included, and this project's data URL is not on this machine:
103
+
104
+ ${offenders.join('\n ')}
105
+
106
+ Point it at a local copy for the run, or set \`allowRemoteData: true\` in scripts/verify.config.ts when you know what
107
+ those presses reach. The walk-through and the presses leave changes behind: restore the data afterwards.`);
108
+ process.exit(1);
109
+ }
110
+ }
111
+
112
+ log(`verify: gate, build, then the browser checks on every page at 1440 and 390px`
113
+ + `${hasAssistant ? ', the assistant walk-through' : ''}${noVitals ? '' : ', and Web Vitals on a mid-range phone'}`
114
+ + '.\n A full run takes roughly fifteen to twenty minutes, and the presses are the long part.'
115
+ + ' `--quick` presses at the desktop width only; `--no-vitals` leaves Web Vitals out.');
116
+
56
117
  const freePort = () => new Promise<number>((res) => { const s = net.createServer(); s.listen(0, () => { const p = (s.address() as net.AddressInfo).port; s.close(() => res(p)); }); });
57
118
 
58
119
  // The assistant is off unless configured: the build and the first start must not see the caller's own variables.
@@ -60,6 +121,8 @@ const clean: NodeJS.ProcessEnv = { ...process.env };
60
121
  for (const k of Object.keys(clean)) if (k.startsWith('ASSISTANT_')) delete clean[k];
61
122
 
62
123
  function step(name: string, cmd: string, args: string[], env = clean) {
124
+ // Announced before it runs, not only after: a run people wait on should say which phase it is in.
125
+ log(`… ${name}`);
63
126
  const t = Date.now();
64
127
  const r = spawnSync(cmd, args, { cwd: ROOT, env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
65
128
  const ok = r.status === 0;
@@ -151,6 +214,7 @@ if (hasAssistant) {
151
214
  const t = Date.now();
152
215
  const base = ['--base', `http://127.0.0.1:${port}`, ...pass];
153
216
  const own = config.browserChecks ?? [];
217
+ log(`… the browser checks, side by side: the audit, every control and link, the whole keyboard path${own.length ? `, and the project's own` : ''}`);
154
218
  const [audit, presses, keys, ...extras] = await Promise.all([
155
219
  run('scripts/audit.ts', base),
156
220
  run('scripts/interactions.ts', base),
@@ -170,6 +234,7 @@ extras.forEach((r, i) => {
170
234
  log(`(browser checks ${((Date.now() - t) / 60_000).toFixed(1)} min)`);
171
235
 
172
236
  // Web Vitals on a mid-range phone, alone: CPU throttling measures the machine too, so nothing else runs beside it.
237
+ if (!noVitals) log('… Web Vitals on a mid-range phone, alone');
173
238
  const vitals = noVitals ? { status: 0, out: '' } : await run('scripts/vitals.ts', base);
174
239
  log(noVitals ? 'skip Web Vitals (--no-vitals)' : vitals.status === 0 ? 'ok LCP, INP and CLS on a mid-range phone' : 'FAIL Web Vitals on a mid-range phone');
175
240
  log(vitals.out.split('\n').slice(-30).join('\n'));
@@ -126,6 +126,14 @@ grep -rn "/health\|/requests\|/customers\|/keys\|/settings\|fixtures/sample\|sam
126
126
 
127
127
  Then `node scripts/registry.ts` and `pnpm verify`.
128
128
 
129
+ ## Renaming a thing
130
+
131
+ Routes, API paths, tool names (`query_enhancements` → `query_initiatives`), seeds and tables are yours to rename. One
132
+ thing is scripted against them: the walk-through in `scripts/assistant.ts` types the questions and matches the replies
133
+ its route and page titles produce, and `scripts/fake-llm.ts` decides what to answer from the same text ("Page: Members
134
+ (/members)"). Rename a page or a route and both must move with it, or `pnpm verify` fails in the assistant walk-through
135
+ with a reply it did not expect. `scripts/verify.config.ts`'s `detailRoutes` name ids from the sample, so they move too.
136
+
129
137
  ## Things that render nothing (and fail the gate)
130
138
 
131
139
  Meridian resets Tailwind's own scales, so only its names exist: `font-regular|medium|semibold`, `rounded-xs…xl|full`,
@@ -30,6 +30,11 @@ Migrate in place, page by page, keeping their data layer.
30
30
  client module (`src/views/console-chrome.tsx`), since each destination carries its icon component; if what a person
31
31
  may see depends on their role or scope, filter `nav` there from their session, and pass `workspace` and `scopes`
32
32
  to the Rail for a scope switcher.
33
+ **Catch the shell's own data in the layout.** A page's error boundary only catches what the page throws: if the
34
+ layout awaits something the shell needs — the alert list, the signed-in person — one failed query takes the whole
35
+ layout down, and the framework's bare error page shows instead of the designed one inside the shell. Read those
36
+ defensively (`const alerts = await recentAlerts().catch(() => [])`), so the shell stands and the page's own
37
+ `error.tsx` shows "This view did not load" with Retry.
33
38
  4. Rebuild each page on `PageFrame`, `Stack` and `Row` with Meridian components (imported as
34
39
  `@meridian/components/…`), keeping their data fetching and business logic untouched. Their old Tailwind utilities
35
40
  render nothing under Meridian's scales, and the gate names each one. Do the busiest page first; when the person is
@@ -44,6 +49,12 @@ Migrate in place, page by page, keeping their data layer.
44
49
  audit alone (`node scripts/audit.ts --base <url>`), which reads and never presses. In an adopted project verify
45
50
  refuses to start until `verify.config.ts` names a `fakeApi`, or says `noLiveApi: true` because the pages read and
46
51
  write nothing outside the repository.
52
+ **A direct database connection is the same hazard through another door.** If their pages read `DATABASE_URL` from
53
+ `.env` (or any other data URL: name it in `dataUrls`), a `next build` and `next start` in their folder carry it, so
54
+ Approve and Delete land on whatever it names. verify refuses to start when a data URL resolves to a host that is not
55
+ this machine; point it at a local copy for the run, and set `allowRemoteData: true` only when you know what the
56
+ presses reach. Restore the data afterwards — the walk-through flags rows and a press can delete, even on a run where
57
+ every check passes.
47
58
  6. List their detail pages worth seeing (a normal record, a failed one, a missing one) in `detailRoutes` in
48
59
  `scripts/verify.config.ts`, with ids from the fake API's fixtures, then run `pnpm verify` until it passes.
49
60
 
@@ -17,6 +17,10 @@ check runs and the report lists each failure:
17
17
  390px. A button that changes nothing, a control something else covers, and a link that answers 4xx all fail.
18
18
  **This presses Approve, Revoke and Delete too.** Pages that call a live API must be built against a fake one
19
19
  (`fakeApi` in `scripts/verify.config.ts`; see `references/existing-project.md`, step 5), or verify changes real data.
20
+ The same hazard through another door: pages that read a database directly. verify reads `DATABASE_URL` — and any
21
+ name in `dataUrls` — from the environment and from the `.env` files Next would load, and refuses to start when one
22
+ resolves to a host that is not this machine. Point it at a local copy for the run, and restore the data afterwards:
23
+ the walk-through flags rows and a press can delete.
20
24
  6. **The whole keyboard path** (`scripts/keyboard.ts`), beside the audit and the presses: Tab through each page until
21
25
  focus comes back round. The first stop in the shell is "Skip to content", every stop shows a focus ring and is not
22
26
  hidden under something such as the sticky top bar, and every visible control is reached. With them run the
@@ -59,6 +59,7 @@ Every console page keeps its state in its address (period, filters, the selected
59
59
  ## Do and do not
60
60
 
61
61
  - Do build every page from PageFrame, Stack and Row; a page has no layout of its own.
62
+ - Do put `*:min-w-0` on any grid you write yourself, and start it from one column. A grid item's default is `min-width: auto`, so a child that truncates never gets a width to truncate at: the column grows to its longest word and the page scrolls sideways — on a 390px phone, while `Row` beside it looks right. `Row` carries `*:min-w-0` for exactly this.
62
63
  - Do not nest a scroller in a card; page the list instead.
63
64
 
64
65
  ## Implementation
@@ -167,7 +167,10 @@ export function PageFrame({
167
167
  return () => io.disconnect();
168
168
  }, []);
169
169
  return (
170
- <div data-scroll-region className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
170
+ <div data-scroll-region className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [scrollbar-gutter:stable] scroll-pt-16">
171
+ {/* `scroll-pt-16` (64px) is for the keyboard: a control focused far down the page scrolls into view, and without
172
+ it the browser aligns it to the very top — under the 56px stuck masthead. Focus must land where it can be
173
+ seen, or a person tabbing cannot tell what they are on. */}
171
174
  <div
172
175
  data-stuck={stuck || undefined}
173
176
  className="sticky top-0 z-(--layer-sticky) border-b border-transparent transition-[background-color,border-color,backdrop-filter] duration-(--dur-enter) data-stuck:border-line data-stuck:bg-ground/72 data-stuck:backdrop-blur-xl data-stuck:backdrop-saturate-150"
@@ -26,13 +26,15 @@ Status: beta
26
26
 
27
27
  The plot is in percent of the span from `from` to `to`, so it fits its container at any width and never scrolls sideways. The label column is 7rem under 512px of its own width and 11rem from there.
28
28
 
29
+ Leave `from` and `to` out and the span is the items' own extent. A roadmap usually wants the other thing: a fixed planning window — this half-year and the next — so the same rows sit in the same place from week to week, and an item that starts before the window or runs past it is clipped and squared off (see Outside the span, below).
30
+
29
31
  ## States
30
32
 
31
33
  | State | Spec |
32
34
  |---|---|
33
35
  | Rest | as above |
34
36
  | Linked | an item with `href` makes its label a link (`row-link`: an underline draws in on hover) |
35
- | Outside the span | a bar is clipped to the span; Today is drawn only when it falls inside |
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 |
36
38
 
37
39
  ## Behaviour
38
40
 
@@ -102,7 +102,15 @@ export function Timeline({
102
102
  ) : null}
103
103
  {items.filter((it) => (it.group ?? '') === g).map((it) => {
104
104
  const a = day(it.start), b = day(it.end) + DAY;
105
- const bar = <span className={cn('absolute top-1/2 h-2.5 -translate-y-1/2 rounded-full', TONE[it.tone ?? 'neutral'])} style={{ left: pct(a), width: `calc(${pct(lo + (Math.min(b, hi) - Math.max(a, lo)))} - 2px)`, minWidth: 6 }} />;
105
+ // A bar that reaches past a fixed window keeps its rounded end only where the work really ends. Squaring
106
+ // the side that continues is what says "this started before, or runs past" — without it the plan reads as
107
+ // if nothing was under way before the window opened, which is the opposite of what a roadmap is for.
108
+ const bar = (
109
+ <span
110
+ className={cn('absolute top-1/2 h-2.5 -translate-y-1/2 rounded-full', TONE[it.tone ?? 'neutral'], a < lo && 'rounded-l-none', b > hi && 'rounded-r-none')}
111
+ style={{ left: pct(a), width: `calc(${pct(lo + (Math.min(b, hi) - Math.max(a, lo)))} - 2px)`, minWidth: 6 }}
112
+ />
113
+ );
106
114
  return (
107
115
  <Fragment key={it.id}>
108
116
  {/* The label is the item's one link: visible, focusable, and read with the table's dates. */}
@@ -14,11 +14,11 @@ Status: draft
14
14
 
15
15
  ## Composition
16
16
 
17
- Agent mark, Icon button, Textarea, Button, Banner, Proposal. The conversation and the open state live in the shell, so the thread survives navigation.
17
+ Agent mark, Icon button, Textarea, Button, Banner, Proposal, Prose. The conversation and the open state live in the shell, so the thread survives navigation.
18
18
 
19
19
  ## Variants
20
20
 
21
- One. Width `assistant-width` (400px). Messages: a person's in a `fill-hover` bubble, `ink`, right-aligned; the assistant's plain, `ink-2`, under an "Assistant" caption (`t-caption`). All type is `t-small`.
21
+ One. Width `assistant-width` (400px). Messages: a person's in a `fill-hover` bubble, `ink`, right-aligned; the assistant's is markdown read by `Prose` at `sm`, `ink-2`, under an "Assistant" caption (`t-caption`), in a block marked `data-assistant-text` — a reply may be a paragraph, a list, a table or a code block, so a check reads that handle rather than guessing which element the text landed in. The person's own message is plain text, as typed. All type is `t-small`.
22
22
 
23
23
  ## Sizes
24
24
 
@@ -6,6 +6,7 @@ import { useChat } from '@ai-sdk/react';
6
6
  import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses, type UIMessage } from 'ai';
7
7
  import { ArrowUp, Eraser, X } from 'lucide-react';
8
8
  import { cn } from '@/lib/cn';
9
+ import { app } from '@/app.config';
9
10
  import { AgentMark } from '@/components/ui/agent-mark';
10
11
  import { Banner } from '@/components/ui/banner';
11
12
  import { Button } from '@/components/ui/button';
@@ -13,12 +14,27 @@ import { IconButton } from '@/components/ui/icon-button';
13
14
  import { Proposal, type ProposalChange, type ProposalState } from '@/components/patterns/proposal';
14
15
  import { Textarea } from '@/components/ui/textarea';
15
16
  import type { PageContext } from '@/lib/assistant/prompt';
17
+ import { AssistantText } from './text';
16
18
  import { REASONS, clearThread, closeOpenApprovals, loadThread, recent, saveThread } from './thread';
17
19
 
18
- /** Reads the page from the scroll region: the masthead title and the visible text. */
20
+ /** The suffix the root layout's title template puts after every route's own title. */
21
+ const TITLE_SUFFIX = ` · ${app.name}`;
22
+
23
+ /**
24
+ * Reads the page from the scroll region: the page's own title and its visible text.
25
+ *
26
+ * The title comes from `document.title`, never the masthead `h1`. On a detail page that `h1` is the Detail head — the
27
+ * record's name with its status badge run into it — so a question asked there was labelled "REC-1042high risk · 0.64".
28
+ * The route's declared title is the clean one ("Record REC-1042"). The masthead is the fallback for a route that
29
+ * declares none, where `document.title` is only the product name.
30
+ */
19
31
  function readPage(path: string): PageContext {
20
32
  const region = document.querySelector<HTMLElement>('[data-scroll-region]');
21
- return { path, title: region?.querySelector('h1')?.textContent ?? '', text: region?.innerText ?? '' };
33
+ const h1 = region?.querySelector('h1')?.textContent?.trim() ?? '';
34
+ const declared = document.title.endsWith(TITLE_SUFFIX)
35
+ ? document.title.slice(0, -TITLE_SUFFIX.length).trim()
36
+ : document.title.trim();
37
+ return { path, title: declared && declared !== app.name ? declared : h1, text: region?.innerText ?? '' };
22
38
  }
23
39
 
24
40
  type Preview = { title: string; tone: 'neutral' | 'critical'; changes: ProposalChange[] };
@@ -166,7 +182,11 @@ export function AssistantPanel({
166
182
  ) : (
167
183
  m.parts.map((p, i) =>
168
184
  p.type === 'text' ? (
169
- p.text ? <p key={i} className="t-small max-w-full whitespace-pre-wrap text-ink-2 [overflow-wrap:anywhere]">{p.text}</p> : null
185
+ // Markdown, not the model's text with its characters showing: every model this template has been
186
+ // pointed at answers in markdown, and a plain paragraph renders `**bold**`, `- ` bullets and
187
+ // `|---|` table rules literally. See `AssistantText`. The person's OWN message stays plain text,
188
+ // above: what they typed is what they meant to type.
189
+ p.text ? <AssistantText key={i} text={p.text} /> : null
170
190
  ) : isMutation(p) ? (
171
191
  <ProposalOf key={i} message={m} part={p as unknown as ToolPart} onDecide={onDecide} />
172
192
  ) : null,
@@ -8,9 +8,10 @@ const say = (id: string, role: 'user' | 'assistant', text: string): UIMessage =>
8
8
 
9
9
  const asked = (id: string, text: string, title: string): UIMessage => ({ ...say(id, 'user', text), metadata: { page: { path: '/', title } } });
10
10
 
11
+ /** A reply in the shape a real model sends: markdown, not a plain sentence. */
11
12
  const thread = [
12
13
  asked('1', 'Why did p95 latency rise on Tuesday?', 'Overview'),
13
- say('2', 'assistant', 'Latency rose from 212 ms to 340 ms between 08:00 and 09:30 UTC, while Parallax AI ran its batch job. Error rate stayed at 0.2%.'),
14
+ say('2', 'assistant', 'Latency rose from **212 ms to 340 ms** between 08:00 and 09:30 UTC:\n\n- Parallax AI ran its batch job\n- error rate stayed at `0.2%`'),
14
15
  ];
15
16
 
16
17
  const noop = () => {};