zz-meridian 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/imports.js +32 -0
- package/dist/migrations.js +17 -0
- package/dist/session.js +12 -1
- package/package.json +1 -1
- package/payload/CHANGELOG.md +28 -0
- package/payload/CONTRIBUTING.md +2 -1
- package/payload/app/(dashboard)/(overview)/README.md +1 -1
- package/payload/app/(dashboard)/(overview)/page.tsx +1 -1
- package/payload/app/(dashboard)/analytics/page.tsx +1 -1
- package/payload/app/(dashboard)/requests/README.md +1 -1
- package/payload/app/embed/health/view.tsx +4 -2
- package/payload/app/system/preview/[section]/[card]/keys.ts +0 -1
- package/payload/app/system/preview/[section]/[card]/stage.tsx +1 -1
- package/payload/docs/surfaces.md +1 -1
- package/payload/package.json +1 -1
- package/payload/scripts/audit.ts +23 -6
- package/payload/scripts/check.ts +18 -0
- package/payload/scripts/interactions.ts +3 -0
- package/payload/scripts/keyboard.ts +5 -3
- package/payload/scripts/lib/chrome.ts +6 -0
- package/payload/scripts/verify.ts +3 -2
- package/payload/skills/zz-meridian/references/customize.md +10 -2
- package/payload/skills/zz-meridian/references/existing-project.md +21 -0
- package/payload/skills/zz-meridian/references/update.md +2 -1
- package/payload/src/components/base/surface/README.md +1 -1
- package/payload/src/components/base/use-share-view.ts +9 -0
- package/payload/src/components/patterns/data-table/README.md +3 -3
- package/payload/src/components/patterns/data-table/index.tsx +19 -13
- package/payload/src/components/patterns/filter-bar/index.tsx +1 -1
- package/payload/src/data/collections.ts +3 -1
- package/payload/src/system/atlas-shell.tsx +1 -1
- package/payload/src/system/card-stage.tsx +2 -1
- package/payload/src/system/fixtures/sample.ts +4 -0
- package/payload/src/system/preview-loaders.ts +0 -1
- package/payload/src/system/registry.ts +0 -1
- package/payload/src/system/sample-cells.tsx +1 -1
- package/payload/src/views/health.tsx +3 -9
- package/payload/src/components/charts/timeline/README.md +0 -74
- package/payload/src/components/charts/timeline/index.tsx +0 -148
- package/payload/src/components/charts/timeline/preview.tsx +0 -27
package/dist/imports.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import specifiers in a common form, so two copies of a module that differ only in how they name the same modules
|
|
3
|
+
* (`'../../ui/badge/index'` beside `'@/components/ui/badge'`) compare equal. `update` uses it to tell a file the team
|
|
4
|
+
* only re-styled from one it changed (issue #16). Pure: the caller passes the aliases it read.
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
/** The project's `@/*`-style aliases from its tsconfig's `paths` (best effort; a file it cannot read gives none). */
|
|
8
|
+
export function aliasesOf(tsconfig) {
|
|
9
|
+
const out = new Map([['@meridian/', 'src/']]);
|
|
10
|
+
if (!tsconfig)
|
|
11
|
+
return out;
|
|
12
|
+
// `"@/*": ["./src/*"]` maps `@/` to `src/`; `"@/*": ["./*"]` maps it to the project root.
|
|
13
|
+
for (const m of tsconfig.matchAll(/"(@[\w-]*)\/\*"\s*:\s*\[\s*"([^"*]*)\*"/g))
|
|
14
|
+
out.set(`${m[1]}/`, m[2].replace(/^\.\//, ''));
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
/** `text` with every literal module specifier rewritten as the project path it names, extension and `/index` dropped. */
|
|
18
|
+
export function canonicalImports(rel, text, aliases) {
|
|
19
|
+
const canonical = (spec) => {
|
|
20
|
+
let to = null;
|
|
21
|
+
if (spec.startsWith('.'))
|
|
22
|
+
to = path.posix.normalize(path.posix.join(path.posix.dirname(rel), spec));
|
|
23
|
+
else
|
|
24
|
+
for (const [alias, dir] of aliases)
|
|
25
|
+
if (spec.startsWith(alias)) {
|
|
26
|
+
to = path.posix.normalize(dir + spec.slice(alias.length));
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
return to === null ? spec : to.replace(/\.(tsx?|jsx?|mjs)$/, '').replace(/\/index$/, '');
|
|
30
|
+
};
|
|
31
|
+
return text.replace(/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])([^'"\n]+)\2/g, (_m, lead, q, spec) => `${lead}${q}${canonical(spec)}${q}`);
|
|
32
|
+
}
|
package/dist/migrations.js
CHANGED
|
@@ -213,6 +213,23 @@ export const RELEASE_MIGRATIONS = [
|
|
|
213
213
|
instructions: 'Read "Legible" in references/agents.md and "A shared context" in references/customize.md. Build the view\'s context with the fields of `SharedContext` from `src/lib/shared-context.ts` (scope, freshness, each figure with its unit, change and definition, what code finds, what the data cannot say) in a pure function beside the view, and pass it alone: `useShareView(context)`. Both agents read it: an MCP host and the console\'s assistant.',
|
|
214
214
|
checks: GATE_AND_BUILD,
|
|
215
215
|
},
|
|
216
|
+
{
|
|
217
|
+
id: 'assistant-view-tools',
|
|
218
|
+
since: '0.10.0',
|
|
219
|
+
applies: (root) => {
|
|
220
|
+
const route = text(root, 'app/api/assistant/route.ts');
|
|
221
|
+
const tools = text(root, 'src/lib/assistant/tools.ts');
|
|
222
|
+
const respond = text(root, 'src/lib/assistant/respond.ts');
|
|
223
|
+
if (route === null || tools === null || respond === null)
|
|
224
|
+
return null;
|
|
225
|
+
// The 0.9.0 assistant takes the views and tells the model its limits; one kept from before does neither.
|
|
226
|
+
const stale = [...(/\bViewTool\b/.test(tools) ? [] : ['src/lib/assistant/tools.ts']), ...(/\blimitsOf\b/.test(respond) ? [] : ['src/lib/assistant/respond.ts']), ...(/\bviews\s*:/.test(route) ? [] : ['app/api/assistant/route.ts'])];
|
|
227
|
+
return stale.length > 0 ? stale : null;
|
|
228
|
+
},
|
|
229
|
+
summary: "The assistant's route-side code predates view tools and its limits.",
|
|
230
|
+
instructions: 'Read "Adding an MCP server later" in example:docs/assistant.md. Bring `src/lib/assistant/tools.ts` and `src/lib/assistant/respond.ts` over from the release: `assistantTools(collections, writer, guard, views)` offers every view in `src/views/tools.ts` as a read-only `view_<name>` tool, `respond` lists what the assistant cannot do or see (`limitsOf`) and passes the views on, and the guard may take a `record`. Then pass `views` from the assistant route to `respond`: the template\'s `viewTools`, an adopted project\'s own view tools (`ViewTool` in `src/lib/shared-context.ts`), or none.',
|
|
231
|
+
checks: GATE_AND_BUILD,
|
|
232
|
+
},
|
|
216
233
|
{
|
|
217
234
|
id: 'agent-reads-section',
|
|
218
235
|
since: '0.9.0',
|
package/dist/session.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { managedBlock } from './context.js';
|
|
10
10
|
import { inside, packageManager, run as spawn, sha256 } from './files.js';
|
|
11
|
+
import { aliasesOf, canonicalImports } from './imports.js';
|
|
11
12
|
import { changedPaths, takeInventory } from './inventory.js';
|
|
12
13
|
import { renderMergeReport } from './merge-report.js';
|
|
13
14
|
import { releaseMigrations } from './migrations.js';
|
|
@@ -145,6 +146,7 @@ function plan(ctx, manifest, startedAt) {
|
|
|
145
146
|
if (!isSafePath(p) || FORBIDDEN.test(p))
|
|
146
147
|
throw new Refusal(`unsafe path ${JSON.stringify(p)}; nothing was written`);
|
|
147
148
|
const sourcePayload = new Set(source.payload);
|
|
149
|
+
const aliases = aliasesOf(readText(root, 'tsconfig.json'));
|
|
148
150
|
const operations = [];
|
|
149
151
|
const retired = [];
|
|
150
152
|
const targetFiles = {};
|
|
@@ -159,7 +161,16 @@ function plan(ctx, manifest, startedAt) {
|
|
|
159
161
|
const disk = probe(root, p);
|
|
160
162
|
if (disk.unsafe && (managed || keptReason.has(p)))
|
|
161
163
|
throw new Refusal(`${p} is a symbolic link or not a regular file; nothing was written`);
|
|
162
|
-
|
|
164
|
+
// A managed module the team only re-styled (its imports named another way, nothing else changed) is untouched: the
|
|
165
|
+
// release's copy replaces it instead of staging a merge nobody needs (issue #16).
|
|
166
|
+
let diskHash = disk.side.hash;
|
|
167
|
+
if (managed && recorded && diskHash && diskHash !== recorded && /\.(?:tsx?|jsx?|mjs)$/.test(p) && fs.existsSync(inside(source.tree, p))) {
|
|
168
|
+
const ours = readText(root, p);
|
|
169
|
+
const base = fs.readFileSync(inside(source.tree, p), 'utf8');
|
|
170
|
+
if (ours !== null && canonicalImports(p, ours, aliases) === canonicalImports(p, base, aliases))
|
|
171
|
+
diskHash = recorded;
|
|
172
|
+
}
|
|
173
|
+
const { disposition, action } = classify({ recorded: recorded, target: targetHash, disk: diskHash, managed, kept: keptReason.has(p) });
|
|
163
174
|
operations.push({ path: p, disposition, action, base: sideOf(recorded), ours: disk.side, target: sideOf(targetHash), applied: false, appliedHash: null });
|
|
164
175
|
if (disposition === 'retired-kept')
|
|
165
176
|
retired.push({ path: p, baselineHash: recorded, reason: keptReason.get(p) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
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.10.0] · 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`audit --atlas`.** The browser audit over the Design Atlas: every card's page and its bare preview, at 1440 and 390px in both themes, about ten minutes. Not part of `pnpm verify`, which stays fast (issue #9).
|
|
10
|
+
- **`ShareContext`.** `useShareView` as a component that renders nothing, for a server page with no client view (issue #17).
|
|
11
|
+
- **A database guide.** `references/existing-project.md` covers a pool that survives a dropped connection, a transaction pooler, schema changes on start, and a role that cannot create (issues #16, #17).
|
|
12
|
+
- **`assistant-view-tools` migration.** `update` reports an assistant whose route-side code predates view tools and its limits (issue #17).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Export is offered on a phone.** The Overview's and Analytics' Export were hidden below 640px; a page's actions are offered on every width (issue #13).
|
|
17
|
+
- **`update` sees past import style.** A managed module whose only difference from its release copy is how its imports are written (`@/…` for `../../…`) is untouched, not a merge (issue #16).
|
|
18
|
+
- **A specimen is not the page.** The audit keeps a card preview's own headings and scroll regions out of the page's outline and its one scroller (issue #8).
|
|
19
|
+
- **A control's role needs its keys.** `scripts/check.ts` fails an element that claims a control's role (`button`, `tab`, `switch` and the rest) with no `onKeyDown` (issue #11).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **A narrow table becomes cards on any screen.** `DataTable`'s card list follows the table's own width (under 640px), as `hideBelow` does, so a table in a half-width row or beside the assistant's column no longer runs past its frame. Found by the first `audit --atlas`, with the two below.
|
|
24
|
+
- **An active filter's label reads.** A filter that is on keeps its name in `ink-2` on the accent tint; `ink-3` fell to 4.04:1 in light.
|
|
25
|
+
- **A selected row's facts read on a phone.** On the accent wash, a card's facts step up to `ink-2`; `ink-3` fell to 4.43:1 in light.
|
|
26
|
+
- **The presses count a file chooser.** A file picker's button opens the system's chooser, which changes nothing in the DOM; the presses now intercept it and count it as an answer (issue #16).
|
|
27
|
+
- **verify's assistant-off run ignores `.env.local`.** It blanks every name the assistant reads, set in the shell or not (issue #16).
|
|
28
|
+
|
|
29
|
+
### Breaking
|
|
30
|
+
|
|
31
|
+
- **`Timeline` is removed.** No page used it, and a card exists because a page needs it (`CONTRIBUTING.md`). Import nothing from `src/components/charts/timeline`.
|
|
32
|
+
|
|
5
33
|
## [0.9.0] · 2026-10-07
|
|
6
34
|
|
|
7
35
|
### Added
|
package/payload/CONTRIBUTING.md
CHANGED
|
@@ -85,9 +85,10 @@ Write the status on the line under the title: `Status: beta`.
|
|
|
85
85
|
| `pnpm tokens` | Generates the token CSS and the Tailwind bridge from `tokens/` (`--check` only compares) | After any token change |
|
|
86
86
|
| `node scripts/contrast.ts [--all]` | Every specified foreground on its background, in every theme and accent, and the chart palette's colour-vision checks | Gate |
|
|
87
87
|
| `node scripts/registry.ts` | Regenerates the Atlas registry from the card folders (`--check` only compares) | After adding or renaming a card |
|
|
88
|
-
| `node scripts/check.ts` | Every card has its README and preview, every README follows the anatomy, no literal colour in components, every token a spec names exists and is written with its own value,
|
|
88
|
+
| `node scripts/check.ts` | Every card has its README and preview, every README follows the anatomy, no literal colour in components, every token a spec names exists and is written with its own value, every embed view a document offers is a route, and an element that claims a control's role has a key handler | Gate |
|
|
89
89
|
| `pnpm typecheck`, `pnpm test` | TypeScript and the behaviour tests | Gate |
|
|
90
90
|
| `node scripts/audit.ts` | With the app running: every page and embed view at 2560, 1440, 1024, 768 and 390px in both themes (embeds at 720 and 420 on a simulated host ground). Fails on sideways scroll, text clipped without an ellipsis, a control with no accessible name, more than one page scroller, rendered text under its WCAG minimum, a Tab stop with no visible focus ring (it presses Tab through the page), and any uncaught exception or console error. Prints the design metrics: type sizes, weights, radii and the hierarchy ratio | Before release |
|
|
91
|
+
| `node scripts/audit.ts --atlas` | With the app running: the same audit over the Design Atlas, every card's page and its bare preview, at 1440 and 390px in both themes, about ten minutes. Not part of `pnpm verify`, which stays fast: a card's preview is where a defect in its own box shows, so run it after changing a component | After changing a component |
|
|
91
92
|
| `node scripts/interactions.ts` | With the app running: presses every control on every page (mouse at 1440px, taps at 390px) and follows every link. Fails on a control that changes nothing, one something covers, and a link that answers 4xx | Before release |
|
|
92
93
|
| `pnpm verify` | The gate once, one production build, the route policy, the size checks and a smoke of up to three routes on desktop and the phone drawer; ends with a coverage line, and the report is in `out/verify.txt` | Before finishing any change |
|
|
93
94
|
| `pnpm verify --full` | The navigation of every rail route, then every exhaustive suite: the audit, every control pressed and link followed, the keyboard walk, the assistant, the live data and Web Vitals | After a change to shared components, the shell or data, and before release |
|
|
@@ -17,7 +17,7 @@ Status: beta
|
|
|
17
17
|
|
|
18
18
|
Everything in rows 1 and 2 shares one Meridian: point at a day in either chart and the featured figure, the tiles and both charts read that day.
|
|
19
19
|
|
|
20
|
-
Below 1024px every row stacks: the featured card first, then the tiles (two across from 34rem, one below), then the cards. At 390px the masthead's actions wrap under the title
|
|
20
|
+
Below 1024px every row stacks: the featured card first, then the tiles (two across from 34rem, one below), then the cards. At 390px the masthead's actions wrap under the title: the period select, then Export. A page's actions are offered on every width, as the request log's export is: a person on a phone may want the CSV too (issue #13).
|
|
21
21
|
|
|
22
22
|
## States
|
|
23
23
|
|
|
@@ -47,7 +47,7 @@ async function Actions({ searchParams }: { searchParams: SearchParams }) {
|
|
|
47
47
|
return (
|
|
48
48
|
<>
|
|
49
49
|
<Suspense><PeriodSelect value={period} /></Suspense>
|
|
50
|
-
<ExportButton rows={data.series} filename={`overview-${period}.csv`} noun="days"
|
|
50
|
+
<ExportButton rows={data.series} filename={`overview-${period}.csv`} noun="days" />
|
|
51
51
|
</>
|
|
52
52
|
);
|
|
53
53
|
}
|
|
@@ -47,7 +47,7 @@ async function Actions({ searchParams }: { searchParams: SearchParams }) {
|
|
|
47
47
|
return (
|
|
48
48
|
<>
|
|
49
49
|
<Suspense><PeriodSelect value={period} /></Suspense>
|
|
50
|
-
<ExportButton rows={data.series} filename={`analytics-${period}.csv`} noun="days"
|
|
50
|
+
<ExportButton rows={data.series} filename={`analytics-${period}.csv`} noun="days" />
|
|
51
51
|
</>
|
|
52
52
|
);
|
|
53
53
|
}
|
|
@@ -10,7 +10,7 @@ Status: beta
|
|
|
10
10
|
|---|---|---|---|
|
|
11
11
|
| Masthead | PageFrame: kicker "ZZ Meridian · Production", title "Requests", one sentence, Freshness, Export CSV | One band | Title, sentence, then Freshness and Export on their own row |
|
|
12
12
|
| 1 | Row `tiles`: three Metric tiles for what the filters let through: requests (emphasis), errors and limits, latency p95, each with its 5-minute shape over the last hour and the last half hour against the one before | Three across | One column |
|
|
13
|
-
| 2 | Data table with a Filter bar in its toolbar: search, Status, Method, Region, the result count | Columns: Request (grow), Status, Latency, Customer
|
|
13
|
+
| 2 | Data table with a Filter bar in its toolbar: search, Status, Method, Region, the result count | Columns: Request (grow), Status, Latency, Customer and Region (once the table is 896px wide), Size (1152px), Received; a table under 640px wide is the card list | Card list: route and status, then latency, customer and when |
|
|
14
14
|
|
|
15
15
|
## States
|
|
16
16
|
|
|
@@ -7,7 +7,9 @@ import { EmbedFrame } from '@/components/patterns/embed-frame';
|
|
|
7
7
|
import { Freshness } from '@/components/patterns/freshness';
|
|
8
8
|
import { AskAbout } from '@/components/patterns/ask-about';
|
|
9
9
|
import { StatusList } from '@/components/patterns/status-list';
|
|
10
|
-
import {
|
|
10
|
+
import { ShareContext } from '@/components/base/use-share-view';
|
|
11
|
+
import { HealthBody } from '@/views/health';
|
|
12
|
+
import { healthContext } from '@/views/health-context';
|
|
11
13
|
import { formatRelative } from '@/lib/format-date';
|
|
12
14
|
import type { Incident } from '@/components/patterns/incident-card';
|
|
13
15
|
import type { Service } from '@/components/patterns/status-list';
|
|
@@ -22,7 +24,7 @@ export function EmbedHealth(p: Props) {
|
|
|
22
24
|
<HealthBody {...p} />
|
|
23
25
|
) : (
|
|
24
26
|
<div className="flex flex-col gap-3">
|
|
25
|
-
<
|
|
27
|
+
<ShareContext context={healthContext(p)} />
|
|
26
28
|
{p.current ? (
|
|
27
29
|
<Banner
|
|
28
30
|
tone="warning"
|
|
@@ -17,7 +17,7 @@ function Inner({ id }: { id: string }) {
|
|
|
17
17
|
data-density={q.get('density') ?? undefined}
|
|
18
18
|
className="min-h-dvh bg-ground p-8 text-ink max-sm:p-4"
|
|
19
19
|
>
|
|
20
|
-
<div className="mx-auto max-w-5xl"><Suspense><P /></Suspense></div>
|
|
20
|
+
<div data-specimen className="mx-auto max-w-5xl"><Suspense><P /></Suspense></div>
|
|
21
21
|
</div>
|
|
22
22
|
);
|
|
23
23
|
}
|
package/payload/docs/surfaces.md
CHANGED
|
@@ -25,7 +25,7 @@ Below 1024px the rail becomes a drawer (the same node, so nothing is defined twi
|
|
|
25
25
|
| Navigation | Rail | Drawer from the masthead's menu button; closes on Escape, on the scrim and on navigation | AppShell |
|
|
26
26
|
| Masthead | Title, head-note, actions on one band | Title row with the menu button; head-note under it; actions wrap to their own row, the primary last | PageFrame |
|
|
27
27
|
| Metric tiles | Four across | Two across from 34rem of row width, one below | Row `tiles` |
|
|
28
|
-
| Tables | Columns, sortable heads | A list of cards, one per row: the primary column as the title, two or three facts under it, the status at the end; low-value columns drop first (`hideBelow`) | DataTable |
|
|
28
|
+
| Tables | Columns, sortable heads | A list of cards, one per row: the primary column as the title, two or three facts under it, the status at the end; low-value columns drop first (`hideBelow`). Both read the table's own width, so a table in a narrow frame on a wide screen becomes cards too | DataTable |
|
|
29
29
|
| Dialogs | Centred, 520px | A bottom sheet, full width, safe-area padding | Dialog |
|
|
30
30
|
| Menus and selects | Popover | Popover, at least 44px rows on touch | Menu, Select |
|
|
31
31
|
| Charts | Full height, 6 to 8 date labels | Shorter (180px), 3 to 4 date labels; the Meridian follows the finger and the tooltip pins to the top edge | TrendChart |
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.",
|
package/payload/scripts/audit.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The browser audit: every page and embed view, at every width, in both themes, measured.
|
|
3
3
|
*
|
|
4
|
-
* node scripts/audit.ts [--base http://localhost:3100] [--routes /,/requests] [--embeds-only]
|
|
4
|
+
* node scripts/audit.ts [--base http://localhost:3100] [--routes /,/requests] [--embeds-only] [--atlas]
|
|
5
5
|
*
|
|
6
6
|
* Routes are discovered from app/ (every static page; embeds under /embed), with the detail pages in
|
|
7
7
|
* scripts/verify.config.ts beside them; --routes replaces the pages for one run.
|
|
8
8
|
*
|
|
9
|
+
* --atlas audits the Design Atlas instead: every card's page and its bare preview, at 1440 and 390px in both themes.
|
|
10
|
+
* A card's preview is the one place it is drawn alone, so a defect in its own box shows there and nowhere else. It is
|
|
11
|
+
* not part of `pnpm verify` (the default stays fast); run it when the question is the component surface. It takes
|
|
12
|
+
* about ten minutes, and a project without the Atlas has nothing for it to read.
|
|
13
|
+
*
|
|
9
14
|
* Fails (exit 1) on: sideways scroll, text clipped without an ellipsis, a control with no accessible name, more than
|
|
10
15
|
* one page scroller, and rendered text under its WCAG minimum. Prints the design metrics per page: distinct type
|
|
11
16
|
* sizes, weights and radii, and the hierarchy ratio (largest text over the median), which should be 3 or more on
|
|
12
17
|
* an analytical page.
|
|
13
18
|
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
14
20
|
import { launch } from './lib/chrome.ts';
|
|
15
21
|
import { discover } from './lib/routes.ts';
|
|
16
22
|
import config from './verify.config.ts';
|
|
@@ -19,13 +25,22 @@ const args = process.argv.slice(2);
|
|
|
19
25
|
const opt = (k: string, d: string) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
|
|
20
26
|
const base = opt('--base', process.env.BASE ?? 'http://localhost:3100');
|
|
21
27
|
const EMBEDS_ONLY = args.includes('--embeds-only');
|
|
28
|
+
const ATLAS = args.includes('--atlas');
|
|
29
|
+
|
|
30
|
+
/** Every card's Atlas page and bare preview, from the keys scripts/registry.ts generates; only the template has them. */
|
|
31
|
+
function atlasRoutes(): string[] {
|
|
32
|
+
const file = 'app/system/preview/[section]/[card]/keys.ts';
|
|
33
|
+
if (!fs.existsSync(file)) { console.error('audit --atlas: this project has no Design Atlas to audit'); process.exit(1); }
|
|
34
|
+
const keys = [...fs.readFileSync(file, 'utf8').matchAll(/'([a-z]+\/[a-z0-9-]+)'/g)].map((m) => m[1]);
|
|
35
|
+
return keys.flatMap((k) => [`/system/${k}`, `/system/preview/${k}`]);
|
|
36
|
+
}
|
|
22
37
|
|
|
23
38
|
const found = discover();
|
|
24
39
|
// verify.config.ts is the team's, so read the one field this needs through its own type.
|
|
25
40
|
const detailRoutes = (config as { detailRoutes?: string[] }).detailRoutes ?? [];
|
|
26
|
-
const ROUTES = EMBEDS_ONLY ? [] : (opt('--routes', '') ? opt('--routes', '').split(',') : [...found.filter((r) => !r.startsWith('/embed')), ...detailRoutes]);
|
|
27
|
-
const EMBEDS = found.filter((r) => r.startsWith('/embed/'));
|
|
28
|
-
const WIDTHS = [2560, 1440, 1024, 768, 390];
|
|
41
|
+
const ROUTES = EMBEDS_ONLY ? [] : ATLAS ? atlasRoutes() : (opt('--routes', '') ? opt('--routes', '').split(',') : [...found.filter((r) => !r.startsWith('/embed')), ...detailRoutes]);
|
|
42
|
+
const EMBEDS = ATLAS ? [] : found.filter((r) => r.startsWith('/embed/'));
|
|
43
|
+
const WIDTHS = ATLAS ? [1440, 390] : [2560, 1440, 1024, 768, 390];
|
|
29
44
|
const THEMES = ['dark', 'light'];
|
|
30
45
|
|
|
31
46
|
type Report = {
|
|
@@ -89,7 +104,8 @@ const MEASURE = `(() => {
|
|
|
89
104
|
}
|
|
90
105
|
const rad = parseFloat(c.borderTopLeftRadius);
|
|
91
106
|
if (rad > 0 && rad < 999) out.radii.push(Math.round(rad));
|
|
92
|
-
|
|
107
|
+
// A card drawn as a specimen in the Atlas keeps its own scroll regions; only the page's count against the one scroller.
|
|
108
|
+
if (/(auto|scroll)/.test(c.overflowY) && el.scrollHeight > el.clientHeight + 1 && el.clientHeight > 120 && !el.closest('[data-specimen]')) out.scrollers.push(label(el));
|
|
93
109
|
if (el.matches('button,a[href],[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],input:not([type=hidden]),select,textarea')) {
|
|
94
110
|
const name = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title') || el.textContent.trim() || el.querySelector('img[alt]')?.getAttribute('alt') || (el.id && document.querySelector('label[for="' + el.id + '"]')?.textContent.trim()) || el.closest('label')?.textContent.trim() || el.getAttribute('placeholder') || '';
|
|
95
111
|
if (!name) out.unnamed.push(label(el));
|
|
@@ -109,7 +125,8 @@ const MEASURE = `(() => {
|
|
|
109
125
|
}
|
|
110
126
|
}
|
|
111
127
|
// Headings descend one level at a time, so a screen reader's outline has no holes.
|
|
112
|
-
|
|
128
|
+
// A specimen's headings are the card's own, at the level the card uses where it is placed, not the documentation page's.
|
|
129
|
+
const levels = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter((h) => !h.closest('[aria-hidden="true"]') && !h.closest('[data-specimen]')).map((h) => +h.tagName[1]);
|
|
113
130
|
levels.forEach((l, i) => { if (i && l > levels[i - 1] + 1) out.headings.push('h' + levels[i - 1] + ' then h' + l); });
|
|
114
131
|
out.focusless = [];
|
|
115
132
|
const sorted = texts.sort((a, b) => a - b);
|
package/payload/scripts/check.ts
CHANGED
|
@@ -229,6 +229,24 @@ if (fs.existsSync(path.join(ROOT, COLLECTIONS))) {
|
|
|
229
229
|
for (const f of LAYERS) if (/\brehype-?raw\b/i.test(code(read(f)))) problems.push(`${f}: reaches for rehype-raw; render markdown with Prose, where raw HTML stays text`);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// ── A control a keyboard can use ─────────────────────────────────────────────────────────────────────
|
|
233
|
+
// A native button, link or field answers Enter and Space by itself, and Radix's primitives carry their own keys. An
|
|
234
|
+
// element that only claims a control's role does not: it needs its own key handler, or a keyboard user can reach it,
|
|
235
|
+
// see its ring and still not use it, past every browser check (issue #11). An option is left out: its listbox's owner,
|
|
236
|
+
// the combobox that holds focus, moves through the options and picks one (aria-activedescendant).
|
|
237
|
+
{
|
|
238
|
+
const ROLES = /\brole=["'](button|link|menuitem|menuitemcheckbox|menuitemradio|tab|checkbox|switch|radio)["']/;
|
|
239
|
+
const NATIVE = new Set(['button', 'a', 'input', 'select', 'textarea', 'summary']);
|
|
240
|
+
for (const f of [APP_DIR, 'src'].flatMap((d) => walk(d, /\.tsx$/))) {
|
|
241
|
+
const src = read(f);
|
|
242
|
+
for (const m of src.matchAll(/<([a-z][a-z0-9]*)\b((?:=>|[^>])*)>/g)) {
|
|
243
|
+
const [, tag, attrs] = m;
|
|
244
|
+
const role = ROLES.exec(attrs)?.[1];
|
|
245
|
+
if (role && !NATIVE.has(tag) && !/\bonKeyDown\s*=/.test(attrs)) problems.push(`${f}: <${tag} role="${role}"> has no onKeyDown; use a <button>, or handle Enter and Space`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
232
250
|
// ── The update session, the keep register and the logo ────────────────────────────────────────────────
|
|
233
251
|
// A session under .meridian/update/ must be resolved before the gate passes. With one ready, its candidate manifest
|
|
234
252
|
// stands in for the original: the files it will own are what keep, retirements and the dormant-code sweep read.
|
|
@@ -88,9 +88,12 @@ for (const width of WIDTHS) {
|
|
|
88
88
|
if (!armed) continue;
|
|
89
89
|
if (armed.covered) { failures.push(`${route} @${width}: "${controls[i]}" is covered by ${armed.covered}`); continue; }
|
|
90
90
|
await sleep(100);
|
|
91
|
+
const choosers = page.fileChoosers();
|
|
91
92
|
await press(page, armed.x, armed.y, touch);
|
|
92
93
|
await sleep(600);
|
|
93
94
|
pressed++;
|
|
95
|
+
// A file picker answers by opening the system's file chooser, which changes nothing on the page (issue #16).
|
|
96
|
+
if (page.fileChoosers() > choosers) continue;
|
|
94
97
|
const tip = touch || /^About /.test(controls[i]);
|
|
95
98
|
const r = await page.eval<{ changes: number; moved: boolean }>(`({ changes: window.__changes + (${tip} ? document.querySelectorAll('[role=tooltip]').length : 0), moved: location.href !== window.__href })`);
|
|
96
99
|
if (!r.changes && !r.moved) failures.push(`${route} @${width}: "${controls[i]}" does nothing when ${touch ? 'tapped' : 'pressed'}`);
|
|
@@ -8,9 +8,11 @@
|
|
|
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
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* This proves every control is REACHABLE and ringed; it never ACTIVATES one with the keyboard. The activation is held
|
|
12
|
+
* where it can be held without guessing: a native button, link or field answers Enter and Space by itself, Radix's
|
|
13
|
+
* primitives carry their own keys, and scripts/check.ts fails any element that only claims a control's role
|
|
14
|
+
* (role="button", "tab", "switch" and the rest) with no onKeyDown (issue #11). A pass that presses keys here is left
|
|
15
|
+
* out on purpose, so it cannot cry wolf: what a key should do
|
|
14
16
|
* depends on the role — Enter activates a button and a menu item, Space toggles a checkbox and a switch, and an arrow
|
|
15
17
|
* key moves within a radio group — so a blanket "something must change on Enter" fails honest controls and teaches
|
|
16
18
|
* people to ignore the check. Two things a future check needs: send the key as
|
|
@@ -13,6 +13,8 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
13
13
|
export type Page = {
|
|
14
14
|
/** Uncaught exceptions and console errors since the last open(). */
|
|
15
15
|
errors: string[];
|
|
16
|
+
/** How many native file choosers the page has opened: intercepted, so none is shown, and counted, since opening one changes nothing in the DOM. */
|
|
17
|
+
fileChoosers: () => number;
|
|
16
18
|
send: (method: string, params?: Record<string, unknown>) => Promise<any>;
|
|
17
19
|
eval: <T = unknown>(expression: string) => Promise<T>;
|
|
18
20
|
open: (url: string, opts: { width: number; height?: number; theme?: 'light' | 'dark'; reduced?: boolean; wait?: number }) => Promise<void>;
|
|
@@ -59,8 +61,10 @@ async function attach(proc: ChildProcess, dir: string, handle: { proc: ChildProc
|
|
|
59
61
|
let id = 0;
|
|
60
62
|
const pending = new Map<number, (m: any) => void>();
|
|
61
63
|
const errors: string[] = [];
|
|
64
|
+
let choosers = 0;
|
|
62
65
|
ws.addEventListener('message', (e) => {
|
|
63
66
|
const m = JSON.parse(String(e.data));
|
|
67
|
+
if (m.method === 'Page.fileChooserOpened') choosers++;
|
|
64
68
|
if (m.id && pending.has(m.id)) { pending.get(m.id)!(m); pending.delete(m.id); return; }
|
|
65
69
|
if (m.method === 'Runtime.exceptionThrown') errors.push('exception: ' + (m.params.exceptionDetails?.exception?.description ?? m.params.exceptionDetails?.text ?? '').split('\n')[0]);
|
|
66
70
|
if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') errors.push('console.error: ' + m.params.args.map((a: any) => a.value ?? a.description ?? '').join(' ').slice(0, 200));
|
|
@@ -75,8 +79,10 @@ async function attach(proc: ChildProcess, dir: string, handle: { proc: ChildProc
|
|
|
75
79
|
});
|
|
76
80
|
await send('Page.enable');
|
|
77
81
|
await send('Runtime.enable');
|
|
82
|
+
await send('Page.setInterceptFileChooserDialog', { enabled: true });
|
|
78
83
|
const page: Page = {
|
|
79
84
|
errors,
|
|
85
|
+
fileChoosers: () => choosers,
|
|
80
86
|
send,
|
|
81
87
|
async eval(expression) {
|
|
82
88
|
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
|
@@ -266,9 +266,10 @@ const freePort = () => new Promise<number>((res) => { const s = net.createServer
|
|
|
266
266
|
// BLANKED, not deleted: `next start` loads `.env.local` itself, where a person keeps their own model, and Next does not
|
|
267
267
|
// overwrite a variable that is already set — so a deleted `ASSISTANT_*` came straight back from that file and the
|
|
268
268
|
// "assistant off" start failed with "the page has 1 assistant element(s)" while nothing was wrong (`assistantConfig`
|
|
269
|
-
// treats a blank value as unset). Reported in issue #7 against `e4a6225`.
|
|
269
|
+
// treats a blank value as unset). Reported in issue #7 against `e4a6225`. Every name the assistant reads is blanked,
|
|
270
|
+
// set in the shell or not: a variable kept only in `.env.local` is absent here and would come back from it (issue #16).
|
|
270
271
|
const clean: NodeJS.ProcessEnv = { ...process.env };
|
|
271
|
-
for (const k of Object.keys(clean))
|
|
272
|
+
for (const k of ['ASSISTANT_PROVIDER', 'ASSISTANT_API_KEY', 'ASSISTANT_MODEL', 'ASSISTANT_BASE_URL', ...Object.keys(clean).filter((k) => k.startsWith('ASSISTANT_'))]) clean[k] = '';
|
|
272
273
|
|
|
273
274
|
function step(name: string, cmd: string, args: string[], env = clean, last = false) {
|
|
274
275
|
current = name;
|
|
@@ -67,12 +67,16 @@ Rules that make every page look like it belongs:
|
|
|
67
67
|
open their record. Below 768px a data table becomes a card list by itself.
|
|
68
68
|
- **Every state**: loading (`loading.tsx` with skeletons shaped like the page), empty (Empty state with the one action),
|
|
69
69
|
error (`error.tsx`, what failed and Retry).
|
|
70
|
+
- **A file picker** is a visible `Button` that calls `input.click()` on an `<input type="file">` with `tabIndex={-1}`
|
|
71
|
+
and `aria-hidden`, so the keyboard reaches the button and never the hidden input; give the press a state of its own
|
|
72
|
+
("Choosing the files…") until the change event arrives. The presses count a file chooser that opens as an answer.
|
|
70
73
|
- **A page spec** next to each route (`README.md`: summary, Structure, States, Surfaces, Agents), so the next person, or
|
|
71
74
|
agent, knows what the page is for. `node scripts/check.ts` requires Structure and States, and under `## Agents` a
|
|
72
75
|
`### What the agent reads` part for every page in `optional:app/(dashboard)/` and `optional:app/embed/`.
|
|
73
76
|
- **A shared context** for each page (decision 0011): a pure function beside the view builds it from the page's data
|
|
74
77
|
(copy `optional:src/views/overview-context.ts`: scope, freshness, each figure with its unit, change and definition,
|
|
75
|
-
what `optional:src/lib/insight.ts` finds, what the data cannot say), and the view passes it to `useShareView
|
|
78
|
+
what `optional:src/lib/insight.ts` finds, what the data cannot say), and the view passes it to `useShareView`; a
|
|
79
|
+
server page with no client view places `<ShareContext context={…} />` among its cards instead. Both
|
|
76
80
|
agents read it: an MCP host and the console's assistant. Add the page's tool to `optional:src/views/tools.ts` so the
|
|
77
81
|
assistant can open it at an address.
|
|
78
82
|
|
|
@@ -92,7 +96,11 @@ The layout hands the shell whether it is on as a promise it does not await, `ass
|
|
|
92
96
|
records from several places (the layout's shell tools, a page strip, the page and its freshness stamp), and `read()`
|
|
93
97
|
caches per caller's scope and refreshes exactly the tenant a write touched, so do not wrap the read in a second cache;
|
|
94
98
|
writes call `updateTag` after the commit (`cache.md`). Raise the pool's idle timeout too: `pg` drops an idle connection after 10s, and a remote Postgres with TLS
|
|
95
|
-
costs hundreds of milliseconds to reconnect. Issue #7 has the numbers.
|
|
99
|
+
costs hundreds of milliseconds to reconnect. Issue #7 has the numbers. A pool that keeps connections idle must also
|
|
100
|
+
listen for their loss: `pool.on('error', (e) => console.error('db: an idle connection closed', e))`. Without it, the
|
|
101
|
+
database or its pooler closing an idle connection is an uncaught `error` event and the server exits (issue #16); the
|
|
102
|
+
pool has already dropped the broken client, so logging is enough. Set `connectionTimeoutMillis` too, so a database
|
|
103
|
+
that does not answer fails a read instead of hanging it.
|
|
96
104
|
- **Sign-in check** goes in three places, the same check: `optional:app/(dashboard)/layout.tsx`, `optional:app/api/assistant/route.ts` and every server action (each `actions.ts`), because a server action is a public endpoint the layout does not guard
|
|
97
105
|
(before the model is reached).
|
|
98
106
|
- **The switch** ("Show the assistant") is in `optional:src/views/settings.tsx`. A product that deletes Settings moves it, or the
|
|
@@ -83,6 +83,27 @@ cache; writes call `updateTag` after the commit (`cache.md`, "Authorized, scoped
|
|
|
83
83
|
invalidate"). And raise the connection pool's idle timeout past `pg`'s 10-second default, since a cache miss is a round
|
|
84
84
|
trip each. Issue #7 has the numbers.
|
|
85
85
|
|
|
86
|
+
## The database behind the collections
|
|
87
|
+
|
|
88
|
+
Meridian's sample serves fixtures; a product's collections read a database. What adopters met, and what held (issues #16 and #17):
|
|
89
|
+
|
|
90
|
+
- **The pool survives a dropped connection.** Keep idle connections for minutes (`idleTimeoutMillis`, `customize.md`), and
|
|
91
|
+
listen for their loss: `pool.on('error', (e) => console.error('db: an idle connection closed', e))`. Without the
|
|
92
|
+
listener, the database or its pooler closing an idle connection is an uncaught error and the server exits. Set
|
|
93
|
+
`connectionTimeoutMillis` so a database that does not answer fails a read rather than hanging it.
|
|
94
|
+
- **Behind a transaction pooler** (PgBouncer, Neon's pooled endpoint, Supabase's): pass no session setting to the pool. A
|
|
95
|
+
`statement_timeout` or any startup option is refused at connect ("unsupported startup parameter") and every connection
|
|
96
|
+
fails; set limits with `SET LOCAL` inside each transaction instead. A session advisory lock does not hold across
|
|
97
|
+
transaction pooling: take a start-up lock with `pg_try_advisory_lock`, try it once, and carry on without it. Connect
|
|
98
|
+
migrations, and anything that needs a session, to the direct endpoint.
|
|
99
|
+
- **Schema changes run on start, never in the build.** Apply the product's own migrations from `instrumentation.ts`
|
|
100
|
+
(`register`, on the Node.js runtime) under that non-blocking lock, so one instance migrates and the rest serve; keep the
|
|
101
|
+
migrator's bookkeeping in a schema the role may write (`public`); never stop the server on a failure, and report the
|
|
102
|
+
outcome at a health route so a deploy can read it. A build has no database to migrate and must not need one.
|
|
103
|
+
- **A role that may read and write but not create.** Deployed roles often lack `CREATE`, and a migrator that starts with
|
|
104
|
+
`CREATE SCHEMA` is refused even where tables may be created. Ship the schema as a one-off SQL file a person with the
|
|
105
|
+
owner's role runs once, and let the application role only migrate within what it may do.
|
|
106
|
+
|
|
86
107
|
## Route A2: a static HTML page (data fetched as JSON)
|
|
87
108
|
|
|
88
109
|
The simplest case. Create a new Meridian project (SKILL.md steps 3 and 4, with `--product`) and point a module in
|
|
@@ -161,12 +161,13 @@ update (`2` pending, `0` finalized, `1` failure). `--abort`: `0` aborted, `1` re
|
|
|
161
161
|
|
|
162
162
|
## Resolving 0.9.0's migrations
|
|
163
163
|
|
|
164
|
-
Moving a project to 0.9.0 can report two more `migration:<id>` items, resolved the same way as those below:
|
|
164
|
+
Moving a project to 0.9.0 can report two more `migration:<id>` items, and to 0.10.0 a third for an assistant kept from before, resolved the same way as those below:
|
|
165
165
|
|
|
166
166
|
| Id | The old shape it found | The change |
|
|
167
167
|
|----|------------------------|------------|
|
|
168
168
|
| `share-view-context` | `useShareView(text, structured)`, a sentence and an object | one shared context per view, `useShareView(context)`: `agents.md`, "Legible", and `customize.md`, "A shared context" |
|
|
169
169
|
| `agent-reads-section` | a page README under `optional:app/(dashboard)/` or `optional:app/embed/` with no `### What the agent reads` | add the part under `## Agents`: `customize.md`, "A page spec"; checked by the gate |
|
|
170
|
+
| `assistant-view-tools` (0.10.0) | an assistant route, `optional:src/lib/assistant/tools.ts` or `optional:src/lib/assistant/respond.ts` kept from before view tools | bring the two files over from the release and pass `views` to `respond` (the template's `viewTools`, your own, or none): `example:docs/assistant.md`, "Adding an MCP server later" |
|
|
170
171
|
|
|
171
172
|
A created project that never changed the template's pages brings the release's version of each affected file over, with
|
|
172
173
|
the files that arrived with them: the view contexts `optional:src/views/overview-context.ts` and its siblings, the view
|
|
@@ -9,7 +9,7 @@ Status: beta
|
|
|
9
9
|
1. **`useSurface()`**: `kind` (`console` | `embed`), `connected`, `mode` (`inline` | `fullscreen` | `pip`), the host context, and four actions: `expand`, `ask` (absent when no agent listens), `share` and `openLink`.
|
|
10
10
|
2. **`EmbedSurface`**: wraps every route under `app/embed/`. It starts the host bridge, applies the host's theme (`data-theme`), style variables and safe-area insets, marks the root `data-surface="embed"`, and reports the body's height.
|
|
11
11
|
3. **When the host refuses.** A refused `ui/update-model-context` is sent once more a second later; refused again, every Ask from the view carries the context's first line (the view, its scope and its address), so the question keeps its referent. A refused `ui/message` tells the person in a toast, with the question to ask in the chat themselves. A refused fullscreen opens the console page.
|
|
12
|
-
4. **`useShareView(context)`**: keeps both agents told what is on screen, with one shared context (decision 0011). In an MCP host it is re-sent as `ui/update-model-context` 250ms after it settles; on the console `share` publishes it for the assistant, which reads it with the next question (`sharedContextOn`). `SurfaceOverride` shares with nobody unless given a `share`, so an Atlas specimen never speaks for the page.
|
|
12
|
+
4. **`useShareView(context)`**: keeps both agents told what is on screen, with one shared context (decision 0011). In an MCP host it is re-sent as `ui/update-model-context` 250ms after it settles; on the console `share` publishes it for the assistant, which reads it with the next question (`sharedContextOn`). `SurfaceOverride` shares with nobody unless given a `share`, so an Atlas specimen never speaks for the page. `<ShareContext context={…} />` is the same hook as a component, for a server page with no client view to call it from.
|
|
13
13
|
4. **`SurfaceOverride`**: puts a subtree on a given surface without a host, for the Atlas and tests.
|
|
14
14
|
|
|
15
15
|
## The host bridge
|
|
@@ -21,3 +21,12 @@ export function useShareView(context: SharedContext) {
|
|
|
21
21
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
22
22
|
}, [key, kind, connected, share]);
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The same, as a component that renders nothing: a server page that awaits its record and renders cards has no client
|
|
27
|
+
* view to call the hook from, so it places `<ShareContext context={…} />` among them (issue #17).
|
|
28
|
+
*/
|
|
29
|
+
export function ShareContext({ context }: { context: SharedContext }) {
|
|
30
|
+
useShareView(context);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Data table
|
|
2
2
|
|
|
3
|
-
A list of records in one card: a toolbar, the table, and pagination, with its loading, empty and error states built in. On phones the same rows become cards, in CSS, so nothing ever scrolls sideways.
|
|
3
|
+
A list of records in one card: a toolbar, the table, and pagination, with its loading, empty and error states built in. On phones, and in any frame too narrow for its columns, the same rows become cards, in CSS, so nothing ever scrolls sideways.
|
|
4
4
|
|
|
5
5
|
Status: beta
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@ Status: beta
|
|
|
13
13
|
5. **Rows**: `row-height` 52px (38px compact), a `line` divider between rows. The title column (`mobile: 'title'`, or the first) is the row's link when the row opens a record. The whole row takes the `fill-hover` tint on hover; the title keeps its colour and is never underlined.
|
|
14
14
|
6. **Selection column** (optional): 40px with a Checkbox; the head's checkbox selects the page and shows indeterminate when part of it is chosen.
|
|
15
15
|
7. **Footer**: Pagination, under a `line` divider, shown only when the rows do not fit on one page.
|
|
16
|
-
8. **Cards, not a second list** (under
|
|
16
|
+
8. **Cards, not a second list** (when the table is under 640px wide, a phone or a half-width row on a wide screen): the same table's rows lay out as cards — the title and the status on one line, two or three facts under it in `text-xs` `ink-3`, separated by dots. It is the SAME tree: the row becomes a six-column grid and each cell says which part of the card it is (`data-mobile`), so nothing is built twice and no device downloads a tree it cannot show.
|
|
17
17
|
|
|
18
18
|
## Variants
|
|
19
19
|
|
|
@@ -55,7 +55,7 @@ Arrival: rows rise with the page (`arrive`); under reduced motion they are simpl
|
|
|
55
55
|
## Surfaces
|
|
56
56
|
|
|
57
57
|
- **Console**: as specified; low-value columns drop with `hideBelow` as the window narrows.
|
|
58
|
-
- **Mobile**: under
|
|
58
|
+
- **Mobile, and any narrow frame**: once the table itself is under 640px wide each row becomes a card (a container query, as `hideBelow` reads the table's width, so a table in a split row or beside the assistant's column never runs past its frame), and the columns not wanted there are hidden. Facts that need their header to make sense use `mobileCell` ("Used 1 min ago", not "1 min ago"); the phone reads a smaller size in `ink-3`.
|
|
59
59
|
- **Embed**: inline views show at most five rows as a compact list and offer Expand; fullscreen shows the full table.
|
|
60
60
|
|
|
61
61
|
## Agents
|
|
@@ -73,14 +73,18 @@ const CELLS = [
|
|
|
73
73
|
'[&_tbody_tr]:transition-colors [&_tbody_tr]:duration-(--dur-hover) [&_tbody_td]:border-b [&_tbody_td]:border-line [&_tbody_tr:last-child_td]:border-0',
|
|
74
74
|
].join(' ');
|
|
75
75
|
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* What a row becomes when the TABLE is under 640px wide: a six-column grid, a card's padding, a hairline between cards.
|
|
78
|
+
* The table's own width, not the window's, as `hideBelow` reads it: a table in a half-width row on a wide screen, or
|
|
79
|
+
* beside the assistant's column, becomes cards instead of running past its frame (issue #9's Atlas audit).
|
|
80
|
+
*/
|
|
77
81
|
const PHONE_ROWS = [
|
|
78
|
-
'max-
|
|
79
|
-
'max-
|
|
82
|
+
'@max-[640px]:[&_tbody_tr]:grid @max-[640px]:[&_tbody_tr]:grid-cols-6 @max-[640px]:[&_tbody_tr]:items-center @max-[640px]:[&_tbody_tr]:gap-x-3 @max-[640px]:[&_tbody_tr]:gap-y-1',
|
|
83
|
+
'@max-[640px]:[&_tbody_tr]:px-(--card-pad) @max-[640px]:[&_tbody_tr]:py-3.5 @max-[640px]:[&_tbody_td]:border-0 @max-[640px]:[&_tbody_tr]:border-b @max-[640px]:[&_tbody_tr]:border-line @max-[640px]:[&_tbody_tr:last-child]:border-b-0',
|
|
80
84
|
].join(' ');
|
|
81
85
|
|
|
82
86
|
/** The resets a cell needs once the row is a grid: not a row height, not a padding box, free to shrink and to clip. */
|
|
83
|
-
const PHONE_CELLS = 'max-
|
|
87
|
+
const PHONE_CELLS = '@max-[640px]:[&_td:not([data-mobile=hidden])]:!h-auto @max-[640px]:[&_td:not([data-mobile=hidden])]:!max-w-none @max-[640px]:[&_td:not([data-mobile=hidden])]:!p-0 @max-[640px]:[&_td:not([data-mobile=hidden])]:min-w-0 @max-[640px]:[&_td:not([data-mobile=hidden])]:!block @max-[640px]:[&_[data-sep]]:inline';
|
|
84
88
|
|
|
85
89
|
/** Where a role sits in the card, and how it reads there.
|
|
86
90
|
*
|
|
@@ -89,13 +93,15 @@ const PHONE_CELLS = 'max-md:[&_td:not([data-mobile=hidden])]:!h-auto max-md:[&_t
|
|
|
89
93
|
* card's own rule is the role, not the width. The title takes one column fewer when a checkbox leads the row: six
|
|
90
94
|
* columns, and the status keeps the last two. */
|
|
91
95
|
const PHONE_ROLES = [
|
|
92
|
-
'max-
|
|
93
|
-
'max-
|
|
94
|
-
'max-
|
|
95
|
-
'max-
|
|
96
|
-
'max-
|
|
96
|
+
'@max-[640px]:[&_td[data-mobile=check]]:col-span-1 @max-[640px]:[&_td[data-mobile=check]]:row-start-1',
|
|
97
|
+
'@max-[640px]:[&_td[data-mobile=title]]:row-start-1 @max-[640px]:[&_td[data-mobile=title]]:font-medium',
|
|
98
|
+
'@max-[640px]:[&_td[data-mobile=status]]:row-start-1 @max-[640px]:[&_td[data-mobile=status]]:justify-self-end',
|
|
99
|
+
'@max-[640px]:[&_td[data-mobile=fact]]:row-start-2 @max-[640px]:[&_td[data-mobile=fact]]:col-span-2 @max-[640px]:[&_td[data-mobile=fact]]:text-xs @max-[640px]:[&_td[data-mobile=fact]]:text-ink-3',
|
|
100
|
+
'@max-[640px]:[&_td[data-mobile=hidden]]:hidden',
|
|
101
|
+
// On the accent wash of a selected row, a fact steps up to ink-2: ink-3 on it falls under 4.5:1 in light.
|
|
102
|
+
'@max-[640px]:[&_tr[aria-selected]_td[data-mobile=fact]]:text-ink-2',
|
|
97
103
|
].join(' ');
|
|
98
|
-
const PHONE_TITLE_SPAN = { plain: 'max-
|
|
104
|
+
const PHONE_TITLE_SPAN = { plain: '@max-[640px]:[&_td[data-mobile=title]]:col-span-4', selectable: '@max-[640px]:[&_td[data-mobile=title]]:col-span-3' };
|
|
99
105
|
|
|
100
106
|
/* Literal strings, as in the Table: Tailwind finds classes by reading the source. */
|
|
101
107
|
const HIDE: Record<Breakpoint, string> = { sm: '@max-[512px]:hidden', md: '@max-[672px]:hidden', lg: '@max-[896px]:hidden', xl: '@max-[1152px]:hidden' };
|
|
@@ -253,7 +259,7 @@ export function DataTable<R>({
|
|
|
253
259
|
} else {
|
|
254
260
|
const roleOf = (c: Column<R>): Mobile => (c === titleColumn ? 'title' : c === status ? 'status' : facts.includes(c) ? 'fact' : 'hidden');
|
|
255
261
|
body = (
|
|
256
|
-
<Table caption={caption} aria-busy={loading || undefined} className={cn('max-
|
|
262
|
+
<Table caption={caption} aria-busy={loading || undefined} className={cn('@max-[640px]:block @max-[640px]:[&>thead]:hidden @max-[640px]:[&>tbody]:block', CELLS, PHONE_ROWS, PHONE_CELLS, PHONE_ROLES, PHONE_TITLE_SPAN[selectable ? 'selectable' : 'plain'])}>
|
|
257
263
|
<TableHead>
|
|
258
264
|
<tr>
|
|
259
265
|
{selectable ? (
|
|
@@ -301,8 +307,8 @@ export function DataTable<R>({
|
|
|
301
307
|
both are rendered and one is hidden: a few words each, never a second copy of the row. */}
|
|
302
308
|
{c.mobileCell ? (
|
|
303
309
|
<>
|
|
304
|
-
<span className="max-
|
|
305
|
-
<span className="hidden max-
|
|
310
|
+
<span className="@max-[640px]:hidden">{content}</span>
|
|
311
|
+
<span className="hidden @max-[640px]:contents">{c.mobileCell(r)}</span>
|
|
306
312
|
</>
|
|
307
313
|
) : content}
|
|
308
314
|
{/* The dot that separates two facts, on a phone only — the table has a column for each of
|
|
@@ -66,7 +66,7 @@ export function FilterBar({
|
|
|
66
66
|
value={f.value}
|
|
67
67
|
onValueChange={f.onChange}
|
|
68
68
|
options={f.options}
|
|
69
|
-
leading={<span className=
|
|
69
|
+
leading={<span className={cn('text-xs', on ? 'text-ink-2' : 'text-ink-3')}>{f.label}</span>}
|
|
70
70
|
className={cn(!block && 'w-auto min-w-0', on && !block && 'border-accent-line bg-accent-tint text-ink')}
|
|
71
71
|
/>
|
|
72
72
|
);
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* directly, so a validation never sees an earlier answer. That product's three heaviest pages answered 44-46% sooner.
|
|
12
12
|
* 2. Raise the connection pool's idle timeout. `pg` drops an idle connection after 10 seconds, and against a remote
|
|
13
13
|
* Postgres with TLS a new one cost that product 330 ms to 1.5 s — paid again on the next click by anybody who reads
|
|
14
|
-
* a page for a moment. `idleTimeoutMillis: 300_000` is what they settled on.
|
|
14
|
+
* a page for a moment. `idleTimeoutMillis: 300_000` is what they settled on. A pool that keeps connections idle must
|
|
15
|
+
* also listen for their loss, `pool.on('error', (e) => console.error('db: an idle connection closed', e))`, or the
|
|
16
|
+
* database closing one is an uncaught error and the server exits (issue #16); add `connectionTimeoutMillis` too.
|
|
15
17
|
*/
|
|
16
18
|
import { z } from 'zod';
|
|
17
19
|
import { arrayCollection, type AnyCollection, type Collection } from '@/lib/collection';
|
|
@@ -123,7 +123,7 @@ function Shelf({ nav }: { nav: AtlasNav }) {
|
|
|
123
123
|
<Link href={it.href} aria-current={on ? 'page' : undefined} className={cn('relative flex h-8 items-center gap-2 rounded-sm px-2.5 text-sm', on ? 'bg-accent-tint font-medium text-ink' : 'text-ink-2 hover:bg-fill-hover hover:text-ink')}>
|
|
124
124
|
{on ? <span aria-hidden className="absolute top-1.5 bottom-1.5 -left-[9px] w-0.5 rounded-full bg-accent" /> : null}
|
|
125
125
|
<span className="flex-1 truncate">{it.title}</span>
|
|
126
|
-
{/* Only the exception is marked: most cards are beta, so a mark on every row says nothing. */}{it.status === 'draft' ? <span className="font-mono text-2xs tracking-[0.08em] text-ink-
|
|
126
|
+
{/* Only the exception is marked: most cards are beta, so a mark on every row says nothing. */}{it.status === 'draft' ? <span className="font-mono text-2xs tracking-[0.08em] text-ink-2 uppercase">{it.status}</span> : null}
|
|
127
127
|
</Link>
|
|
128
128
|
</li>
|
|
129
129
|
);
|
|
@@ -58,7 +58,8 @@ export function CardStage({ id }: { id: string }) {
|
|
|
58
58
|
const pane = (t: 'dark' | 'light') => (
|
|
59
59
|
<Scope key={t} theme={t} accent={accent} density={density} className="px-5 py-8 sm:px-10">
|
|
60
60
|
{theme === 'both' ? <p className="t-kicker mb-6">{t}</p> : null}
|
|
61
|
-
|
|
61
|
+
{/* A specimen, not the page: its own headings and scroll regions are the card's (a Detail head is an h1 where it is used), so the audit keeps them out of the page's outline and its one scroller (issue #8). */}
|
|
62
|
+
<div data-specimen className={cn('mx-auto', width === 'phone' ? 'max-w-[390px]' : 'max-w-none')}><Suspense><P /></Suspense></div>
|
|
62
63
|
</Scope>
|
|
63
64
|
);
|
|
64
65
|
return (
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* The sample product: this template presented as a fictional API platform under the name in app.config, so the template's pages, the Design Atlas and every card preview
|
|
3
3
|
* run with no backend and look like a real product.
|
|
4
4
|
*
|
|
5
|
+
* Every customer, person, region and incident in it is invented: Northwind Labs, Parallax AI, Maya Chen and the rest
|
|
6
|
+
* name nobody this work came from, and a name that matches a real company does so by chance. Plausible names keep the
|
|
7
|
+
* examples readable, as a placeholder like "Customer 1" would not (issue #14).
|
|
8
|
+
*
|
|
5
9
|
* It is not your data seam. A product built on the template reads its own module in src/data/ and leaves this one in
|
|
6
10
|
* place for the Atlas and the previews.
|
|
7
11
|
*
|
|
@@ -74,7 +74,6 @@ export const PREVIEW_LOADERS: Record<string, () => Promise<{ default: ComponentT
|
|
|
74
74
|
'patterns/heatmap': () => import('@/components/charts/heatmap/preview'),
|
|
75
75
|
'patterns/meridian': () => import('@/components/charts/meridian/preview'),
|
|
76
76
|
'patterns/sparkline': () => import('@/components/charts/sparkline/preview'),
|
|
77
|
-
'patterns/timeline': () => import('@/components/charts/timeline/preview'),
|
|
78
77
|
'patterns/trend-chart': () => import('@/components/charts/trend-chart/preview'),
|
|
79
78
|
'patterns/uptime-bars': () => import('@/components/charts/uptime-bars/preview'),
|
|
80
79
|
};
|
|
@@ -74,7 +74,6 @@ export const CARDS: Card[] = [
|
|
|
74
74
|
{ id: 'heatmap', layer: 3, section: 'patterns', dir: 'src/components/charts/heatmap', Preview: dynamic(() => import('@/components/charts/heatmap/preview')) },
|
|
75
75
|
{ id: 'meridian', layer: 3, section: 'patterns', dir: 'src/components/charts/meridian', Preview: dynamic(() => import('@/components/charts/meridian/preview')) },
|
|
76
76
|
{ id: 'sparkline', layer: 3, section: 'patterns', dir: 'src/components/charts/sparkline', Preview: dynamic(() => import('@/components/charts/sparkline/preview')) },
|
|
77
|
-
{ id: 'timeline', layer: 3, section: 'patterns', dir: 'src/components/charts/timeline', Preview: dynamic(() => import('@/components/charts/timeline/preview')) },
|
|
78
77
|
{ id: 'trend-chart', layer: 3, section: 'patterns', dir: 'src/components/charts/trend-chart', Preview: dynamic(() => import('@/components/charts/trend-chart/preview')) },
|
|
79
78
|
{ id: 'uptime-bars', layer: 3, section: 'patterns', dir: 'src/components/charts/uptime-bars', Preview: dynamic(() => import('@/components/charts/uptime-bars/preview')) },
|
|
80
79
|
];
|
|
@@ -60,7 +60,7 @@ export const requestColumns: Column<RequestRow>[] = [
|
|
|
60
60
|
key: 'latency', header: 'Latency', numeric: true, mobile: 'fact', sortValue: (r) => r.latency,
|
|
61
61
|
cell: (r) => <span className={r.latency > 1000 ? 'font-medium text-warning-ink' : undefined}>{formatDuration(r.latency)}</span>,
|
|
62
62
|
},
|
|
63
|
-
{ key: 'customer', header: 'Customer', muted: true, mobile: 'fact', hideBelow: '
|
|
63
|
+
{ key: 'customer', header: 'Customer', muted: true, mobile: 'fact', hideBelow: 'lg', cell: (r) => <span className="whitespace-nowrap">{r.customer}</span>, sortValue: (r) => r.customer },
|
|
64
64
|
{ key: 'region', header: 'Region', muted: true, hideBelow: 'lg', cell: (r) => <span className="font-mono text-xs whitespace-nowrap">{r.region}</span> },
|
|
65
65
|
{ key: 'size', header: 'Size', numeric: true, muted: true, hideBelow: 'xl', cell: (r) => formatBytes(r.bytes), sortValue: (r) => r.bytes },
|
|
66
66
|
{
|
|
@@ -18,15 +18,9 @@ import { StatusDot } from '@/components/ui/status-dot';
|
|
|
18
18
|
import type { Tone } from '@/components/ui/badge';
|
|
19
19
|
import { cn } from '@/lib/cn';
|
|
20
20
|
import { formatDuration } from '@/lib/format';
|
|
21
|
-
import {
|
|
21
|
+
import { ShareContext } from '@/components/base/use-share-view';
|
|
22
22
|
import { healthContext, type HealthData } from './health-context';
|
|
23
23
|
|
|
24
|
-
/** Tells both agents what Health shows (decision 0011). */
|
|
25
|
-
export function ShareHealth({ data }: { data: HealthData }) {
|
|
26
|
-
useShareView(healthContext(data));
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
24
|
const RANK: Record<DayState, number> = { operational: 0, none: 0, degraded: 1, outage: 2 };
|
|
31
25
|
const TONE: Record<DayState, Tone> = { operational: 'positive', none: 'neutral', degraded: 'warning', outage: 'critical' };
|
|
32
26
|
|
|
@@ -37,7 +31,7 @@ export function HealthBody(data: HealthData) {
|
|
|
37
31
|
if (services.length === 0) {
|
|
38
32
|
return (
|
|
39
33
|
<Card>
|
|
40
|
-
<
|
|
34
|
+
<ShareContext context={healthContext(data)} />
|
|
41
35
|
<EmptyState title="No services yet" className="py-16">Every service you monitor appears here with its state now and its last 90 days.</EmptyState>
|
|
42
36
|
</Card>
|
|
43
37
|
);
|
|
@@ -50,7 +44,7 @@ export function HealthBody(data: HealthData) {
|
|
|
50
44
|
const affected = services.filter((x) => x.status !== 'operational').map((x) => x.name);
|
|
51
45
|
return (
|
|
52
46
|
<Stack>
|
|
53
|
-
<
|
|
47
|
+
<ShareContext context={healthContext(data)} />
|
|
54
48
|
<Row split={current ? '2/3' : 'full'}>
|
|
55
49
|
<FeaturedMetric
|
|
56
50
|
kicker="Uptime · all services · 90 days"
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# Timeline
|
|
2
|
-
|
|
3
|
-
The timeline shows work over months: one bar per item from its start day to its end day, grouped by workstream, on month hairlines with a line for today.
|
|
4
|
-
|
|
5
|
-
Status: beta
|
|
6
|
-
|
|
7
|
-
## Anatomy
|
|
8
|
-
|
|
9
|
-
1. **Month scale**: a 44px row: month names in `text-2xs` `ink-3` along its foot (the year, short, on January and on the first month shown: "Jul ’26"), the Today chip above them.
|
|
10
|
-
2. **Today**: a 1px `accent` line through every row, labelled "Today" on the scale in a small accent chip.
|
|
11
|
-
3. **Heat row** (optional): one cell per month, the accent at 12% to 100% by the month's value, with its label on the left.
|
|
12
|
-
4. **Group head**: the workstream in `t-eyebrow`, 16px above its first row.
|
|
13
|
-
5. **Rows**: a label column (7rem, 11rem from 512px) and a 36px plot row with a 1px `line` hairline under it.
|
|
14
|
-
6. **Bar**: 10px tall, `radius-full`, at least 6px wide, from the start day to the end of the end day.
|
|
15
|
-
7. **Screen-reader table**: every item with its group, start and end dates.
|
|
16
|
-
|
|
17
|
-
## Variants
|
|
18
|
-
|
|
19
|
-
| Tone | Fill | Use |
|
|
20
|
-
|---|---|---|
|
|
21
|
-
| `neutral` (default) | `chart-neutral-strong` | Work in the plan |
|
|
22
|
-
| `accent` | `accent` | The item in focus, at most one or two |
|
|
23
|
-
| `positive`, `warning`, `critical` | status fills | Done, at risk, late, with the word in the label or the record |
|
|
24
|
-
|
|
25
|
-
## Sizes
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
## States
|
|
32
|
-
|
|
33
|
-
| State | Spec |
|
|
34
|
-
|---|---|
|
|
35
|
-
| Rest | as above |
|
|
36
|
-
| Linked | an item with `href` makes its label a link (`row-link`: it takes the accent on hover, with no underline) |
|
|
37
|
-
| Outside the span | a bar is clipped to the span and the side that continues is squared off (`rounded-l-none` when it started before `from`, `rounded-r-none` when it ends after `to`), so work that runs past the window does not read as work that began at its edge; Today is drawn only when it falls inside |
|
|
38
|
-
|
|
39
|
-
## Behaviour
|
|
40
|
-
|
|
41
|
-
Static: no cursor, no zoom. The label is the item's link; the bar is drawn for the eye and carries its dates in a title.
|
|
42
|
-
|
|
43
|
-
## Surfaces
|
|
44
|
-
|
|
45
|
-
- **Console**: a full-width card, or a 2/3 cell on a wide screen.
|
|
46
|
-
- **Mobile**: the label column narrows and month names stay short; a phone reads a quarter or two well.
|
|
47
|
-
- **Embed**: same, inline at the host's width; keep the span to a quarter so bars stay wide enough to read.
|
|
48
|
-
|
|
49
|
-
## Agents
|
|
50
|
-
|
|
51
|
-
An agent may read the items and the span; a change of dates is a Proposal ("Move Gateway v5 to start 16 Nov"), never a drag.
|
|
52
|
-
|
|
53
|
-
## Accessibility
|
|
54
|
-
|
|
55
|
-
- The plot is decorative (`aria-hidden`); the screen-reader table lists every item with its group and dates, and each linked label is a real link in the tab order.
|
|
56
|
-
- Contrast: bars are `chart-neutral-strong`, the accent or a status fill, each at least 3:1 on `surface` in both themes; month labels are `ink-3` text.
|
|
57
|
-
|
|
58
|
-
## Content
|
|
59
|
-
|
|
60
|
-
- Labels name the work, not the ticket: "Regional failover", not "PLAT-1432". Months are "Oct", "Jan 2027".
|
|
61
|
-
|
|
62
|
-
## Do and do not
|
|
63
|
-
|
|
64
|
-
- Do keep one or two accent bars: the accent is the focus, not a category.
|
|
65
|
-
- Do not encode a status by colour alone: say it in the label or the linked record.
|
|
66
|
-
- Do not show a span longer than a year in one card; split it, or zoom to quarters.
|
|
67
|
-
|
|
68
|
-
## Implementation
|
|
69
|
-
|
|
70
|
-
```tsx
|
|
71
|
-
import { Timeline } from '@/components/charts/timeline';
|
|
72
|
-
|
|
73
|
-
<Timeline items={roadmap} from="2026-07-01" to="2027-01-31" today={today} label="Roadmap, July to January" />
|
|
74
|
-
```
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import Link from 'next/link';
|
|
2
|
-
import { Fragment } from 'react';
|
|
3
|
-
import { cn } from '@/lib/cn';
|
|
4
|
-
|
|
5
|
-
/** One piece of work from a start day to an end day (both inclusive, ISO dates), in a group such as a workstream. */
|
|
6
|
-
export type TimelineItem = {
|
|
7
|
-
id: string;
|
|
8
|
-
label: string;
|
|
9
|
-
group?: string;
|
|
10
|
-
start: string;
|
|
11
|
-
end: string;
|
|
12
|
-
/** What the bar means: the accent for work in focus, a status for a judgement, neutral for the rest. */
|
|
13
|
-
tone?: 'accent' | 'neutral' | 'positive' | 'warning' | 'critical';
|
|
14
|
-
href?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const DAY = 86_400_000;
|
|
18
|
-
const day = (iso: string) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
|
|
19
|
-
const MONTH = new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' });
|
|
20
|
-
const LONG = new Intl.DateTimeFormat('en-US', { day: '2-digit', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
|
21
|
-
const TONE = { accent: 'bg-accent', neutral: 'bg-chart-neutral-strong', positive: 'bg-positive', warning: 'bg-warning', critical: 'bg-critical' } as const;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Work over months: one bar per item from its start to its end, grouped by workstream, on month hairlines with a
|
|
25
|
-
* line for today. The label column never scrolls; the plot is in percent, so it fits any width without scrolling
|
|
26
|
-
* sideways. An optional heat row shades each month by a value (load, spend, incidents).
|
|
27
|
-
*/
|
|
28
|
-
export function Timeline({
|
|
29
|
-
items,
|
|
30
|
-
from,
|
|
31
|
-
to,
|
|
32
|
-
today,
|
|
33
|
-
heat,
|
|
34
|
-
heatLabel = 'Load',
|
|
35
|
-
label,
|
|
36
|
-
className,
|
|
37
|
-
}: {
|
|
38
|
-
items: TimelineItem[];
|
|
39
|
-
/** The first and last day shown (ISO dates); default to the earliest start and the latest end. */
|
|
40
|
-
from?: string;
|
|
41
|
-
to?: string;
|
|
42
|
-
/** Today's date (ISO), from the data's clock: the line that says where the plan stands. */
|
|
43
|
-
today?: string;
|
|
44
|
-
/** One value per month, keyed "YYYY-MM", for the heat row above the bars. */
|
|
45
|
-
heat?: Record<string, number>;
|
|
46
|
-
heatLabel?: string;
|
|
47
|
-
label: string;
|
|
48
|
-
className?: string;
|
|
49
|
-
}) {
|
|
50
|
-
const lo = day(from ?? items.reduce((m, it) => (it.start < m ? it.start : m), items[0]?.start ?? '2026-01-01'));
|
|
51
|
-
const hi = day(to ?? items.reduce((m, it) => (it.end > m ? it.end : m), items[0]?.end ?? '2026-12-31')) + DAY;
|
|
52
|
-
const pct = (t: number) => `${Math.max(0, Math.min(100, ((t - lo) / (hi - lo)) * 100))}%`;
|
|
53
|
-
|
|
54
|
-
// Month starts inside the span, for hairlines, labels and the heat row.
|
|
55
|
-
const months: { key: string; t: number; next: number; label: string }[] = [];
|
|
56
|
-
for (let d = new Date(lo); d.getTime() < hi; d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))) {
|
|
57
|
-
const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
|
|
58
|
-
const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1);
|
|
59
|
-
const m = d.getUTCMonth();
|
|
60
|
-
months.push({ key: `${d.getUTCFullYear()}-${String(m + 1).padStart(2, '0')}`, t: Math.max(start, lo), next: Math.min(next, hi), label: m === 0 || months.length === 0 ? `${MONTH.format(d)} \u2019${String(d.getUTCFullYear()).slice(2)}` : MONTH.format(d) });
|
|
61
|
-
}
|
|
62
|
-
const groups = [...new Set(items.map((it) => it.group ?? ''))];
|
|
63
|
-
const heatMax = heat ? Math.max(1, ...Object.values(heat)) : 1;
|
|
64
|
-
const now = today ? day(today) : null;
|
|
65
|
-
|
|
66
|
-
const Guides = () => (
|
|
67
|
-
<>
|
|
68
|
-
{months.slice(1).map((m) => <span key={m.key} aria-hidden className="absolute inset-y-0 w-px bg-chart-grid" style={{ left: pct(m.t) }} />)}
|
|
69
|
-
{now !== null && now >= lo && now < hi ? <span aria-hidden className="absolute inset-y-0 z-10 w-px bg-accent" style={{ left: pct(now) }} /> : null}
|
|
70
|
-
</>
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<figure aria-label={label} className={cn('@container w-full min-w-0', className)}>
|
|
75
|
-
<div className="grid grid-cols-[7rem_minmax(0,1fr)] text-sm @lg:grid-cols-[11rem_minmax(0,1fr)]">
|
|
76
|
-
{/* Month scale, and Today above its line */}
|
|
77
|
-
<span aria-hidden />
|
|
78
|
-
<div aria-hidden className="relative h-11 text-2xs text-ink-3">
|
|
79
|
-
{months.map((m) => (
|
|
80
|
-
<span key={m.key} className="absolute bottom-1 truncate pl-1.5" style={{ left: pct(m.t), width: pct(lo + (m.next - m.t)) }}>{m.label}</span>
|
|
81
|
-
))}
|
|
82
|
-
{now !== null && now >= lo && now < hi ? <span className="absolute top-0 z-10 -translate-x-1/2 rounded-xs bg-accent px-1 text-on-accent" style={{ left: pct(now) }}>Today</span> : null}
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
{heat ? (
|
|
86
|
-
<>
|
|
87
|
-
<span aria-hidden className="t-caption self-center truncate pr-3">{heatLabel}</span>
|
|
88
|
-
<div aria-hidden className="relative flex h-4 gap-px overflow-hidden rounded-xs">
|
|
89
|
-
{months.map((m) => (
|
|
90
|
-
<span key={m.key} className="h-full bg-accent" style={{ width: pct(lo + (m.next - m.t)), opacity: 0.12 + 0.88 * ((heat[m.key] ?? 0) / heatMax) }} title={`${m.label}: ${heat[m.key] ?? 0}`} />
|
|
91
|
-
))}
|
|
92
|
-
</div>
|
|
93
|
-
</>
|
|
94
|
-
) : null}
|
|
95
|
-
|
|
96
|
-
{groups.map((g) => (
|
|
97
|
-
<Fragment key={g || 'all'}>
|
|
98
|
-
{g ? (
|
|
99
|
-
<>
|
|
100
|
-
<span aria-hidden className="t-eyebrow col-span-2 pt-4 pb-1.5">{g}</span>
|
|
101
|
-
</>
|
|
102
|
-
) : null}
|
|
103
|
-
{items.filter((it) => (it.group ?? '') === g).map((it) => {
|
|
104
|
-
const a = day(it.start), b = day(it.end) + DAY;
|
|
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
|
-
);
|
|
114
|
-
return (
|
|
115
|
-
<Fragment key={it.id}>
|
|
116
|
-
{/* The label is the item's one link: visible, focusable, and read with the table's dates. */}
|
|
117
|
-
{it.href ? (
|
|
118
|
-
<Link href={it.href} title={it.label} className="row-link self-center truncate py-2 pr-3 text-ink-2 hover:text-accent-ink">{it.label}</Link>
|
|
119
|
-
) : (
|
|
120
|
-
<span aria-hidden className="self-center truncate py-2 pr-3 text-ink-2" title={it.label}>{it.label}</span>
|
|
121
|
-
)}
|
|
122
|
-
<div aria-hidden className="relative h-9 border-b border-line" title={`${it.label}: ${LONG.format(a)} to ${LONG.format(b - DAY)}`}>
|
|
123
|
-
<Guides />
|
|
124
|
-
{bar}
|
|
125
|
-
</div>
|
|
126
|
-
</Fragment>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
129
|
-
</Fragment>
|
|
130
|
-
))}
|
|
131
|
-
</div>
|
|
132
|
-
<table className="sr-only">
|
|
133
|
-
<caption>{label}</caption>
|
|
134
|
-
<thead><tr><th>Item</th><th>Group</th><th>Start</th><th>End</th></tr></thead>
|
|
135
|
-
<tbody>
|
|
136
|
-
{items.map((it) => (
|
|
137
|
-
<tr key={it.id}>
|
|
138
|
-
<td>{it.label}</td>
|
|
139
|
-
<td>{it.group ?? ''}</td>
|
|
140
|
-
<td>{LONG.format(day(it.start))}</td>
|
|
141
|
-
<td>{LONG.format(day(it.end))}</td>
|
|
142
|
-
</tr>
|
|
143
|
-
))}
|
|
144
|
-
</tbody>
|
|
145
|
-
</table>
|
|
146
|
-
</figure>
|
|
147
|
-
);
|
|
148
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Specimen, State } from '@/system/specimen';
|
|
4
|
-
import { Timeline, type TimelineItem } from '.';
|
|
5
|
-
|
|
6
|
-
const ROADMAP: TimelineItem[] = [
|
|
7
|
-
{ id: 'r1', group: 'Platform', label: 'Regional failover', start: '2026-08-04', end: '2026-10-16', tone: 'accent' },
|
|
8
|
-
{ id: 'r2', group: 'Platform', label: 'Rate limits per key', start: '2026-09-14', end: '2026-11-27' },
|
|
9
|
-
{ id: 'r3', group: 'Platform', label: 'Gateway v5', start: '2026-11-02', end: '2027-01-29' },
|
|
10
|
-
{ id: 'r4', group: 'Console', label: 'Alerts and on-call', start: '2026-07-20', end: '2026-09-04', tone: 'positive' },
|
|
11
|
-
{ id: 'r5', group: 'Console', label: 'Usage-based billing', start: '2026-09-28', end: '2026-12-18', tone: 'warning' },
|
|
12
|
-
{ id: 'r6', group: 'Assistants', label: 'MCP views for every page', start: '2026-10-05', end: '2027-01-15' },
|
|
13
|
-
];
|
|
14
|
-
const HEAT = { '2026-07': 2, '2026-08': 3, '2026-09': 5, '2026-10': 6, '2026-11': 5, '2026-12': 4, '2027-01': 2 };
|
|
15
|
-
|
|
16
|
-
export default function TimelinePreview() {
|
|
17
|
-
return (
|
|
18
|
-
<>
|
|
19
|
-
<Specimen label="Roadmap" note="Bars run from start to end, grouped by workstream, on month hairlines; the accent line is today. The label column stays put; the plot is in percent, so it never scrolls sideways." stack>
|
|
20
|
-
<State label="With a heat row (projects in flight)" className="w-full"><Timeline items={ROADMAP} from="2026-07-01" to="2027-01-31" today="2026-10-03" heat={HEAT} heatLabel="In flight" label="Roadmap, July 2026 to January 2027" /></State>
|
|
21
|
-
</Specimen>
|
|
22
|
-
<Specimen label="Narrow" note="Under 512px the label column narrows to 7rem and month labels shorten; nothing scrolls.">
|
|
23
|
-
<State label="Phone width" className="w-90 max-w-full"><Timeline items={ROADMAP.slice(0, 3)} from="2026-08-01" to="2026-12-31" today="2026-10-03" label="Platform roadmap" /></State>
|
|
24
|
-
</Specimen>
|
|
25
|
-
</>
|
|
26
|
-
);
|
|
27
|
-
}
|