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.
Files changed (40) hide show
  1. package/dist/imports.js +32 -0
  2. package/dist/migrations.js +17 -0
  3. package/dist/session.js +12 -1
  4. package/package.json +1 -1
  5. package/payload/CHANGELOG.md +28 -0
  6. package/payload/CONTRIBUTING.md +2 -1
  7. package/payload/app/(dashboard)/(overview)/README.md +1 -1
  8. package/payload/app/(dashboard)/(overview)/page.tsx +1 -1
  9. package/payload/app/(dashboard)/analytics/page.tsx +1 -1
  10. package/payload/app/(dashboard)/requests/README.md +1 -1
  11. package/payload/app/embed/health/view.tsx +4 -2
  12. package/payload/app/system/preview/[section]/[card]/keys.ts +0 -1
  13. package/payload/app/system/preview/[section]/[card]/stage.tsx +1 -1
  14. package/payload/docs/surfaces.md +1 -1
  15. package/payload/package.json +1 -1
  16. package/payload/scripts/audit.ts +23 -6
  17. package/payload/scripts/check.ts +18 -0
  18. package/payload/scripts/interactions.ts +3 -0
  19. package/payload/scripts/keyboard.ts +5 -3
  20. package/payload/scripts/lib/chrome.ts +6 -0
  21. package/payload/scripts/verify.ts +3 -2
  22. package/payload/skills/zz-meridian/references/customize.md +10 -2
  23. package/payload/skills/zz-meridian/references/existing-project.md +21 -0
  24. package/payload/skills/zz-meridian/references/update.md +2 -1
  25. package/payload/src/components/base/surface/README.md +1 -1
  26. package/payload/src/components/base/use-share-view.ts +9 -0
  27. package/payload/src/components/patterns/data-table/README.md +3 -3
  28. package/payload/src/components/patterns/data-table/index.tsx +19 -13
  29. package/payload/src/components/patterns/filter-bar/index.tsx +1 -1
  30. package/payload/src/data/collections.ts +3 -1
  31. package/payload/src/system/atlas-shell.tsx +1 -1
  32. package/payload/src/system/card-stage.tsx +2 -1
  33. package/payload/src/system/fixtures/sample.ts +4 -0
  34. package/payload/src/system/preview-loaders.ts +0 -1
  35. package/payload/src/system/registry.ts +0 -1
  36. package/payload/src/system/sample-cells.tsx +1 -1
  37. package/payload/src/views/health.tsx +3 -9
  38. package/payload/src/components/charts/timeline/README.md +0 -74
  39. package/payload/src/components/charts/timeline/index.tsx +0 -148
  40. package/payload/src/components/charts/timeline/preview.tsx +0 -27
@@ -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
+ }
@@ -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
- const { disposition, action } = classify({ recorded: recorded, target: targetHash, disk: disk.side.hash, managed, kept: keptReason.has(p) });
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.9.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",
@@ -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
@@ -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, and every embed view a document offers is a route | Gate |
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 and the period select stays visible; Export is not offered on a phone (`max-sm:hidden`), and the command palette carries destinations and appearance, not the page's actions.
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" className="max-sm:hidden" />
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" className="max-sm:hidden" />
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 (from 768px), Region (from 1024px), Size (from 1280px), Received | Card list: route and status, then latency, customer and when |
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 { HealthBody, ShareHealth } from '@/views/health';
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
- <ShareHealth data={p} />
27
+ <ShareContext context={healthContext(p)} />
26
28
  {p.current ? (
27
29
  <Banner
28
30
  tone="warning"
@@ -70,7 +70,6 @@ export const PREVIEW_KEYS: string[] = [
70
70
  'patterns/heatmap',
71
71
  'patterns/meridian',
72
72
  'patterns/sparkline',
73
- 'patterns/timeline',
74
73
  'patterns/trend-chart',
75
74
  'patterns/uptime-bars',
76
75
  ];
@@ -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
  }
@@ -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 |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.9.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.",
@@ -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
- if (/(auto|scroll)/.test(c.overflowY) && el.scrollHeight > el.clientHeight + 1 && el.clientHeight > 120) out.scrollers.push(label(el));
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
- const levels = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter((h) => !h.closest('[aria-hidden="true"]')).map((h) => +h.tagName[1]);
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);
@@ -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
- * 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
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)) if (k.startsWith('ASSISTANT_')) clean[k] = '';
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`. Both
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 768px): the same table's rows lay out as cards — the title and the status on one line, two or three facts under it in `text-xs` `ink-3`, separated by dots. It is the SAME tree: the row becomes a six-column grid and each cell says which part of the card it is (`data-mobile`), so nothing is built twice and no device downloads a tree it cannot show.
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 768px each row becomes a card, 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`.
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
- /** What a row becomes below 768px: a six-column grid, a card's padding, a hairline between cards. */
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-md:[&_tbody_tr]:grid max-md:[&_tbody_tr]:grid-cols-6 max-md:[&_tbody_tr]:items-center max-md:[&_tbody_tr]:gap-x-3 max-md:[&_tbody_tr]:gap-y-1',
79
- 'max-md:[&_tbody_tr]:px-(--card-pad) max-md:[&_tbody_tr]:py-3.5 max-md:[&_tbody_td]:border-0 max-md:[&_tbody_tr]:border-b max-md:[&_tbody_tr]:border-line max-md:[&_tbody_tr:last-child]:border-b-0',
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-md:[&_td:not([data-mobile=hidden])]:!h-auto max-md:[&_td:not([data-mobile=hidden])]:!max-w-none max-md:[&_td:not([data-mobile=hidden])]:!p-0 max-md:[&_td:not([data-mobile=hidden])]:min-w-0 max-md:[&_td:not([data-mobile=hidden])]:!block max-md:[&_[data-sep]]:inline';
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-md:[&_td[data-mobile=check]]:col-span-1 max-md:[&_td[data-mobile=check]]:row-start-1',
93
- 'max-md:[&_td[data-mobile=title]]:row-start-1 max-md:[&_td[data-mobile=title]]:font-medium',
94
- 'max-md:[&_td[data-mobile=status]]:row-start-1 max-md:[&_td[data-mobile=status]]:justify-self-end',
95
- 'max-md:[&_td[data-mobile=fact]]:row-start-2 max-md:[&_td[data-mobile=fact]]:col-span-2 max-md:[&_td[data-mobile=fact]]:text-xs max-md:[&_td[data-mobile=fact]]:text-ink-3',
96
- 'max-md:[&_td[data-mobile=hidden]]:hidden',
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-md:[&_td[data-mobile=title]]:col-span-4', selectable: 'max-md:[&_td[data-mobile=title]]:col-span-3' };
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-md:block max-md:[&>thead]:hidden max-md:[&>tbody]:block', CELLS, PHONE_ROWS, PHONE_CELLS, PHONE_ROLES, PHONE_TITLE_SPAN[selectable ? 'selectable' : 'plain'])}>
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-md:hidden">{content}</span>
305
- <span className="hidden max-md:contents">{c.mobileCell(r)}</span>
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="text-xs text-ink-3">{f.label}</span>}
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-3 uppercase">{it.status}</span> : null}
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
- <div className={cn('mx-auto', width === 'phone' ? 'max-w-[390px]' : 'max-w-none')}><Suspense><P /></Suspense></div>
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: 'md', cell: (r) => <span className="whitespace-nowrap">{r.customer}</span>, sortValue: (r) => r.customer },
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 { useShareView } from '@/components/base/use-share-view';
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
- <ShareHealth data={data} />
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
- <ShareHealth data={data} />
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
- }