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.
- package/package.json +1 -1
- package/payload/.env.example +2 -0
- package/payload/CHANGELOG.md +33 -0
- package/payload/app/(dashboard)/README.md +1 -1
- package/payload/app/(dashboard)/error.tsx +10 -2
- package/payload/app/not-found/README.md +4 -4
- package/payload/docs/assistant.md +1 -1
- package/payload/docs/distribution.md +8 -6
- package/payload/package.json +1 -1
- package/payload/scripts/assistant.ts +17 -3
- package/payload/scripts/check.ts +10 -1
- package/payload/scripts/fake-llm.ts +15 -0
- package/payload/scripts/keyboard.ts +10 -0
- package/payload/scripts/verify.config.ts +13 -0
- package/payload/scripts/verify.ts +65 -0
- package/payload/skills/zz-meridian/references/customize.md +8 -0
- package/payload/skills/zz-meridian/references/existing-project.md +11 -0
- package/payload/skills/zz-meridian/references/validation.md +4 -0
- package/payload/src/components/base/shell/README.md +1 -0
- package/payload/src/components/base/shell/index.tsx +4 -1
- package/payload/src/components/charts/timeline/README.md +3 -1
- package/payload/src/components/charts/timeline/index.tsx +9 -1
- package/payload/src/components/patterns/assistant/README.md +2 -2
- package/payload/src/components/patterns/assistant/index.tsx +23 -3
- package/payload/src/components/patterns/assistant/preview.tsx +2 -1
- package/payload/src/components/patterns/assistant/text.tsx +25 -0
- package/payload/src/components/patterns/command-palette/README.md +1 -1
- package/payload/src/components/patterns/filter-bar/README.md +3 -3
- package/payload/src/components/patterns/filter-bar/index.tsx +11 -7
- package/payload/src/components/patterns/form-section/README.md +5 -1
- package/payload/src/components/patterns/form-section/index.tsx +71 -36
- package/payload/src/components/patterns/form-section/preview.tsx +17 -0
- package/payload/src/components/patterns/period-select/index.tsx +4 -3
- package/payload/src/components/patterns/rail/README.md +2 -0
- package/payload/src/components/ui/card/README.md +2 -2
- package/payload/src/components/ui/card/index.tsx +28 -2
- package/payload/src/components/ui/card/preview.tsx +25 -0
- package/payload/src/components/ui/segmented/README.md +1 -1
- package/payload/src/components/ui/segmented/index.tsx +4 -1
- package/payload/src/lib/collection.ts +29 -5
- package/payload/src/lib/period.ts +11 -0
- package/payload/src/views/not-found-address.tsx +4 -4
- package/payload/src/views/not-found.tsx +10 -2
- package/payload/tests/assistant-text.test.tsx +40 -0
- 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.
|
|
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",
|
package/payload/.env.example
CHANGED
|
@@ -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".
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -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=
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
141
|
-
|
|
142
|
-
`
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "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
|
-
|
|
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',
|
|
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`);
|
package/payload/scripts/check.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = () => {};
|