zz-meridian 0.16.0 → 0.16.1

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 (91) hide show
  1. package/README.md +3 -1
  2. package/dist/adopt.js +39 -15
  3. package/dist/cli.js +7 -0
  4. package/dist/command-flags.js +38 -0
  5. package/dist/files.js +17 -4
  6. package/dist/migrations.js +38 -6
  7. package/dist/rebrand.js +13 -2
  8. package/dist/session.js +4 -1
  9. package/package.json +1 -1
  10. package/payload/CHANGELOG.md +36 -1
  11. package/payload/app/(dashboard)/keys/page.tsx +2 -2
  12. package/payload/app/(dashboard)/members/page.tsx +2 -2
  13. package/payload/app/(dashboard)/operations-tickets/page.tsx +3 -3
  14. package/payload/decisions/0006-quiet-light.md +1 -1
  15. package/payload/docs/distribution.md +2 -1
  16. package/payload/docs/surfaces.md +5 -1
  17. package/payload/package.json +1 -1
  18. package/payload/scripts/assistant.ts +1 -1
  19. package/payload/scripts/audit.ts +24 -11
  20. package/payload/scripts/check.ts +6 -2
  21. package/payload/scripts/keyboard.ts +1 -1
  22. package/payload/scripts/lib/data-url.ts +86 -0
  23. package/payload/scripts/verify.config.ts +2 -2
  24. package/payload/scripts/verify.ts +6 -18
  25. package/payload/skills/zz-meridian/references/agents.md +2 -2
  26. package/payload/skills/zz-meridian/references/cache.md +7 -5
  27. package/payload/skills/zz-meridian/references/existing-project.md +2 -1
  28. package/payload/skills/zz-meridian/references/standard.md +1 -1
  29. package/payload/src/components/charts/column-chart/preview.tsx +5 -6
  30. package/payload/src/components/patterns/metric-tile/README.md +1 -1
  31. package/payload/src/components/patterns/metric-tile/index.tsx +4 -3
  32. package/payload/src/components/ui/copy-field/index.tsx +13 -1
  33. package/payload/src/components/ui/dialog/README.md +1 -2
  34. package/payload/src/components/ui/pagination/README.md +1 -1
  35. package/payload/src/components/ui/select/index.tsx +5 -3
  36. package/payload/src/components/ui/skeleton/README.md +1 -1
  37. package/payload/src/components/ui/table/preview.tsx +2 -2
  38. package/payload/src/data/metrics.ts +6 -4
  39. package/payload/src/data/read.ts +7 -5
  40. package/payload/src/data/requests.ts +12 -6
  41. package/payload/src/lib/assistant/respond.ts +5 -0
  42. package/payload/src/lib/explore.ts +22 -4
  43. package/payload/src/lib/format-date.ts +9 -0
  44. package/payload/src/lib/format.ts +6 -4
  45. package/payload/src/system/atlas-shell.tsx +5 -1
  46. package/payload/src/views/home-context.ts +17 -5
  47. package/payload/src/views/home.tsx +3 -4
  48. package/payload/src/views/now-context.ts +3 -3
  49. package/payload/src/views/now-parts.ts +3 -1
  50. package/payload/src/views/now.tsx +2 -3
  51. package/payload/src/views/requests.tsx +2 -2
  52. package/payload/src/views/revenue-context.ts +2 -2
  53. package/payload/src/views/revenue.tsx +2 -3
  54. package/payload/src/views/tools.ts +2 -2
  55. package/payload/src/views/usage-context.ts +2 -3
  56. package/payload/src/views/usage.tsx +3 -3
  57. package/payload/tests/assistant-access.test.ts +12 -2
  58. package/payload/tests/assistant-respond.test.ts +31 -0
  59. package/payload/tests/assistant-route.test.ts +4 -0
  60. package/payload/tests/assistant-thread-scope.test.tsx +2 -1
  61. package/payload/tests/atlas-template-contracts.test.tsx +2 -2
  62. package/payload/tests/copy-field.test.tsx +48 -0
  63. package/payload/tests/customer-figures.test.ts +5 -2
  64. package/payload/tests/engineering-workflows.test.ts +2 -13
  65. package/payload/tests/explore-answer-length.test.ts +83 -0
  66. package/payload/tests/explore-runner.test.ts +1 -1
  67. package/payload/tests/feature-definitions.test.ts +2 -4
  68. package/payload/tests/featured-metric.test.tsx +1 -1
  69. package/payload/tests/format.test.ts +5 -0
  70. package/payload/tests/forms.test.tsx +20 -0
  71. package/payload/tests/key-value.test.tsx +7 -1
  72. package/payload/tests/keys-gate.test.tsx +7 -1
  73. package/payload/tests/member-mutations.test.ts +6 -2
  74. package/payload/tests/member-roster-boundary.test.ts +73 -0
  75. package/payload/tests/navigate-menu-start.test.ts +6 -4
  76. package/payload/tests/no-access.test.tsx +12 -1
  77. package/payload/tests/page-gates.test.tsx +7 -1
  78. package/payload/tests/pages-read-every-row.test.tsx +91 -0
  79. package/payload/tests/prose-memo.test.tsx +16 -4
  80. package/payload/tests/read-all-query.test.ts +43 -0
  81. package/payload/tests/read-every-row.test.ts +66 -0
  82. package/payload/tests/requests-export.test.ts +13 -0
  83. package/payload/tests/requests-query.test.ts +15 -0
  84. package/payload/tests/sample.test.ts +1 -1
  85. package/payload/tests/settings-access.test.tsx +3 -0
  86. package/payload/tests/shared-context.test.tsx +15 -1
  87. package/payload/tests/titles.test.ts +7 -0
  88. package/payload/tests/uptime-bars-end.test.tsx +6 -1
  89. package/payload/tests/usage-summary.test.tsx +3 -1
  90. package/payload/tests/verify-data-url.test.ts +122 -0
  91. package/payload/tests/verify-personas.test.ts +1 -1
package/README.md CHANGED
@@ -39,7 +39,9 @@ Brand flags: `--name "Acme Ops"`, `--hex '#2E6BE4'` (or `--accent indigo|cobalt|
39
39
  in a page's kicker ("Production"); it does not touch the navigation workspaces.
40
40
 
41
41
  **adopt** copies Meridian's tokens, styles, components, gates and scripts into `src/`, `tokens/` and `scripts/`;
42
- merges the dependencies and scripts it needs into your `package.json`; replaces your global stylesheet (yours is kept
42
+ merges the dependencies and scripts it needs into your `package.json`; adds the `@meridian/*` alias to your
43
+ `tsconfig.json` (yours is kept as `tsconfig.before.json` when it carries comments or trailing commas, which JSON
44
+ cannot hold); replaces your global stylesheet (yours is kept
43
45
  beside it as `*.before.css`); brands it; adds Meridian's managed block to your `AGENTS.md` between two markers, keeping your own text there byte
44
46
  for byte; writes an empty `docs/brief.md` (your product, users, data, decisions and glossary) if you have none; installs the skill into
45
47
  `.agents/skills/` (Codex) and `.claude/skills/` (Claude Code); records every Meridian file it copied in `.meridian/manifest.json`;
package/dist/adopt.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { VERSION, brandArgs, effectiveBrand, inside, installSkill, packageManager, payloadBytes, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
11
+ import { VERSION, brandArgs, effectiveBrand, inside, installSkill, jsoncExtras, packageManager, payloadBytes, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
12
12
  import { BRIEF_TEMPLATE, managedBlock, upsertManagedBlock } from './context.js';
13
13
  import { FIXED, adoptSetOf, managedPaths } from './ownership.js';
14
14
  /** The template's dependencies a dashboard built from these files needs. */
@@ -123,6 +123,39 @@ export default defineConfig({
123
123
  });
124
124
  `;
125
125
  }
126
+ /**
127
+ * What `adopt` writes for a team's `tsconfig.json`, and what it must say about it: the file Meridian's code needs (its
128
+ * scripts import with `.ts` extensions, its code is ES2022, `@meridian/*` reaches its files), the team's own kept beside
129
+ * it when it carries what `JSON.stringify` cannot hold, and what their `@/*` alias stands for. A function rather than a
130
+ * block inside `adopt` so a test can hold it to those three: a test that ran the whole `adopt` would need the template
131
+ * the payload carries, which a checkout has only after a build, and it would be testing the copy, not this decision.
132
+ */
133
+ export function tsconfigWrites(tsPath) {
134
+ const writes = new Map();
135
+ const notes = [];
136
+ const ts = readJsonc(tsPath);
137
+ ts.compilerOptions ??= {};
138
+ ts.compilerOptions.allowImportingTsExtensions = true;
139
+ ts.compilerOptions.noEmit = true;
140
+ if (!/^es20(2[2-9]|[3-9]\d)|^esnext$/i.test(ts.compilerOptions.target ?? '')) {
141
+ notes.push(`raised tsconfig target from ${ts.compilerOptions.target ?? 'its default'} to ES2022`);
142
+ ts.compilerOptions.target = 'ES2022';
143
+ }
144
+ ts.compilerOptions.paths ??= {};
145
+ ts.compilerOptions.paths['@meridian/*'] = ['./src/*'];
146
+ const at = ts.compilerOptions.paths['@/*']?.[0];
147
+ const atAlias = typeof at === 'string' ? at.replace(/\/\*$/, '').replace(/^\.\//, '') || '.' : null;
148
+ // A tsconfig may carry comments and trailing commas, which `next dev` keeps (`comment-json`) and `JSON.stringify`
149
+ // cannot hold. What the team wrote is kept beside the rewritten file, as the stylesheet and the postcss config are,
150
+ // rather than dropped in silence — the note says so, and there is no backup to explain when there was nothing to lose.
151
+ const was = fs.readFileSync(tsPath, 'utf8');
152
+ writes.set('tsconfig.json', JSON.stringify(ts, null, 2) + '\n');
153
+ if (jsoncExtras(was)) {
154
+ writes.set('tsconfig.before.json', was);
155
+ notes.push('tsconfig.json carried comments or trailing commas, which the rewritten file cannot: yours is tsconfig.before.json');
156
+ }
157
+ return { writes, notes, atAlias };
158
+ }
126
159
  export function adopt(o) {
127
160
  const { root } = o;
128
161
  const say = (s) => console.log(s);
@@ -230,20 +263,11 @@ Rename or move them, then run adopt again.`);
230
263
  const tsPath = path.join(root, 'tsconfig.json');
231
264
  let atAlias = null;
232
265
  if (fs.existsSync(tsPath)) {
233
- const ts = readJsonc(tsPath);
234
- ts.compilerOptions ??= {};
235
- ts.compilerOptions.allowImportingTsExtensions = true;
236
- ts.compilerOptions.noEmit = true;
237
- if (!/^es20(2[2-9]|[3-9]\d)|^esnext$/i.test(ts.compilerOptions.target ?? '')) {
238
- notes.push(`raised tsconfig target from ${ts.compilerOptions.target ?? 'its default'} to ES2022`);
239
- ts.compilerOptions.target = 'ES2022';
240
- }
241
- ts.compilerOptions.paths ??= {};
242
- ts.compilerOptions.paths['@meridian/*'] = ['./src/*'];
243
- const at = ts.compilerOptions.paths['@/*']?.[0];
244
- if (typeof at === 'string')
245
- atAlias = at.replace(/\/\*$/, '').replace(/^\.\//, '') || '.';
246
- writes.set('tsconfig.json', JSON.stringify(ts, null, 2) + '\n');
266
+ const ts = tsconfigWrites(tsPath);
267
+ for (const [f, text] of ts.writes)
268
+ writes.set(f, text);
269
+ notes.push(...ts.notes);
270
+ atAlias = ts.atAlias;
247
271
  }
248
272
  else {
249
273
  return fail('no tsconfig.json: Meridian is TypeScript. Add one (next dev writes it), then run adopt again.');
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { parseArgs } from 'node:util';
9
9
  import { adopt } from './adopt.js';
10
+ import { misapplied } from './command-flags.js';
10
11
  import { create } from './create.js';
11
12
  import { BRAND_FLAGS, VERSION, installSkill } from './files.js';
12
13
  import { brandCommand } from './rebrand.js';
@@ -75,6 +76,12 @@ function main() {
75
76
  console.log(HELP);
76
77
  return command || values.help ? 0 : 1;
77
78
  }
79
+ // A flag a command does not read is refused, never ignored: `adopt --dry-run` wrote the project rather than previewing it.
80
+ const wrong = misapplied(command, flags);
81
+ if (wrong) {
82
+ console.error(`zz-meridian ${command}: --${wrong.flag} belongs to ${wrong.belongsTo}`);
83
+ return 1;
84
+ }
78
85
  if (command === 'adopt')
79
86
  return adopt({ root: process.cwd(), brand, allowDirty: Boolean(values['allow-dirty']), install });
80
87
  if (command === 'create') {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Which flags each command reads. A flag that names a mode or a value belongs to the command that acts on it: anywhere
3
+ * else it is refused by name rather than accepted and ignored, so `adopt --dry-run` never modifies a project while the
4
+ * person waits for a preview.
5
+ */
6
+ import { BRAND_FLAGS } from './files.js';
7
+ /** Every command reads these. */
8
+ const EVERYWHERE = ['help', 'version'];
9
+ /** The brand flags, read by the commands that write a brand. */
10
+ const BRAND = BRAND_FLAGS;
11
+ /** What each command reads, beyond the two above. */
12
+ const ACCEPTS = {
13
+ adopt: [...BRAND, 'allow-dirty', 'no-install'],
14
+ create: [...BRAND, 'no-install'],
15
+ update: ['allow-dirty', 'no-install', 'dry-run', 'resume', 'finalize', 'abort', 'verify', 'verbose'],
16
+ brand: [...BRAND, 'allow-dirty'],
17
+ skill: ['global'],
18
+ };
19
+ /** The commands that read one flag, in the CLI's own order, for the sentence that sends the person to the right one. */
20
+ function commandsReading(flag) {
21
+ return Object.keys(ACCEPTS).filter((c) => ACCEPTS[c].includes(flag));
22
+ }
23
+ /**
24
+ * The first flag a command does not read, with the command it belongs to; null when every flag is the command's own.
25
+ * `update` is the one command with no spelling of its own, so a flag only it reads is named as its own.
26
+ */
27
+ export function misapplied(command, flags) {
28
+ const accepted = ACCEPTS[command];
29
+ if (!accepted)
30
+ return null;
31
+ for (const [flag, value] of Object.entries(flags)) {
32
+ if (value === undefined || EVERYWHERE.includes(flag) || accepted.includes(flag))
33
+ continue;
34
+ const others = commandsReading(flag);
35
+ return { flag, belongsTo: others.length ? `zz-meridian ${others.join(' or ')}` : 'no command' };
36
+ }
37
+ return null;
38
+ }
package/dist/files.js CHANGED
@@ -82,10 +82,10 @@ export function runTool(root, entry, args, quiet = true) {
82
82
  return spawnSync(process.execPath, [path.join(root, 'node_modules', entry), ...args], { cwd: root, encoding: 'utf8', stdio: quiet ? 'pipe' : 'inherit' });
83
83
  }
84
84
  export const hasCommand = (cmd) => spawnSync(cmd, ['--version'], { stdio: 'ignore', shell: process.platform === 'win32' }).status === 0;
85
- /** JSON with the comments and trailing commas a tsconfig may carry. */
86
- export function readJsonc(file) {
87
- const text = fs.readFileSync(file, 'utf8');
85
+ /** A JSONC document's text with the comments dropped, and whether anything had to be dropped to read it. */
86
+ function strippedJsonc(text) {
88
87
  let out = '';
88
+ let dropped = false;
89
89
  for (let i = 0, str = false; i < text.length; i++) {
90
90
  const c = text[i];
91
91
  if (str) {
@@ -102,12 +102,14 @@ export function readJsonc(file) {
102
102
  continue;
103
103
  }
104
104
  if (c === '/' && text[i + 1] === '/') {
105
+ dropped = true;
105
106
  while (i < text.length && text[i] !== '\n')
106
107
  i++;
107
108
  out += '\n';
108
109
  continue;
109
110
  }
110
111
  if (c === '/' && text[i + 1] === '*') {
112
+ dropped = true;
111
113
  i += 2;
112
114
  while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
113
115
  i++;
@@ -116,8 +118,19 @@ export function readJsonc(file) {
116
118
  }
117
119
  out += c;
118
120
  }
119
- return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
121
+ const json = out.replace(/,(\s*[}\]])/g, '$1');
122
+ return { json, dropped: dropped || json.length !== out.length };
120
123
  }
124
+ /** JSON with the comments and trailing commas a tsconfig may carry. */
125
+ export function readJsonc(file) {
126
+ return JSON.parse(strippedJsonc(fs.readFileSync(file, 'utf8')).json);
127
+ }
128
+ /**
129
+ * Whether a project's file carries what plain JSON cannot hold: a comment, or a trailing comma. `next dev` writes a
130
+ * tsconfig through `comment-json`, which keeps both, so a tool that re-serialises one drops what the team's own
131
+ * toolchain preserves — and this is the question to ask before doing it.
132
+ */
133
+ export const jsoncExtras = (text) => strippedJsonc(text).dropped;
121
134
  /**
122
135
  * Meridian's own `@/` imports, made relative to the file. An adopting project may map `@/` somewhere else, or already
123
136
  * have a `components/ui/button`; a relative import can never resolve to the team's file instead of Meridian's.
@@ -511,6 +511,37 @@ function refusalUndeclared(root) {
511
511
  return null;
512
512
  return /^export const refusal\b/m.test(body) ? null : [rel];
513
513
  }
514
+ /** The nav item objects of a nav array, by brace: an object naming an `href` of its own, however many lines it is over. Braces inside a quoted value are not braces. */
515
+ function navItemObjects(nav) {
516
+ const found = [];
517
+ const open = [];
518
+ let quote = null;
519
+ for (let i = 0; i < nav.length; i++) {
520
+ const ch = nav[i];
521
+ if (quote) {
522
+ if (ch === '\\')
523
+ i++;
524
+ else if (ch === quote)
525
+ quote = null;
526
+ continue;
527
+ }
528
+ if (ch === "'" || ch === '"' || ch === '`')
529
+ quote = ch;
530
+ else if (ch === '{')
531
+ open.push(i);
532
+ else if (ch === '}') {
533
+ const start = open.pop();
534
+ if (start === undefined)
535
+ continue;
536
+ const inner = nav.slice(start + 1, i);
537
+ // What is left once the objects inside it are taken out: a group's `href`s are all in its items, so a group has
538
+ // none of its own left and is not an item.
539
+ if (/\bhref\s*:/.test(inner.replace(/\{[^{}]*\}/g, '')))
540
+ found.push(inner);
541
+ }
542
+ }
543
+ return found;
544
+ }
514
545
  /** The project's own nav, with any item still missing the `needs` that 0.13.0 requires of it. */
515
546
  function navItemsWithoutNeeds(root) {
516
547
  const rel = 'src/app.config.ts';
@@ -520,16 +551,17 @@ function navItemsWithoutNeeds(root) {
520
551
  const nav = /export const nav\b[^=]*=\s*\[([\s\S]*?)\n\];/.exec(body)?.[1];
521
552
  if (nav === undefined)
522
553
  return null;
523
- // One item per line, as `adopt` writes them and as a hand-kept nav keeps them, so the item is the line naming its
524
- // `href`: a group's opening line — every project groups its nav — names no `href` and carries no `needs` of its own,
525
- // so counting it as an item would report a change no edit can settle. Anything the file does beyond that is the
526
- // team's own shape, and none of it is read.
527
- const items = nav.split('\n').filter((l) => /\bhref\s*:/.test(l));
554
+ // The item is the object naming its `href`, read by its braces rather than by its line: a team that spreads an item
555
+ // over its keys put the `needs` two lines below the `href`, and a line-based read reported the migration for a file
556
+ // with none of the old shape left in it. A group is not a destination and carries no `needs`: its own `href`s belong
557
+ // to the items inside it, so an object whose braces hold only other objects is not read. Anything the file does
558
+ // beyond that is the team's own shape, and none of it is read.
559
+ const items = navItemObjects(nav);
528
560
  // A project of 0.12.3's vintage also declares `NavItem` itself, without the property: an item that names a `needs` its
529
561
  // own type does not have is the type error the gate prints next, so the change is not done until the type carries it.
530
562
  const declaredType = /export type NavItem[^;]*\};/.test(body);
531
563
  const typeHasNeeds = /export type NavItem[^;]*\bneeds\s*:/.test(body);
532
- return items.some((l) => !/\bneeds\s*:/.test(l)) || (declaredType && !typeHasNeeds) ? [rel] : null;
564
+ return items.some((item) => !/\bneeds\s*:/.test(item)) || (declaredType && !typeHasNeeds) ? [rel] : null;
533
565
  }
534
566
  /**
535
567
  * The page specifications under a route group of the project's own, at the top of its app folder, that lack the part the
package/dist/rebrand.js CHANGED
@@ -165,8 +165,19 @@ function attempt(ctx, change, allowDirty) {
165
165
  }
166
166
  /** The command: the real context, which replays the running release and runs its branding script in scratch. */
167
167
  export function brandCommand({ root, flags, allowDirty }) {
168
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'zz-meridian-brand-'));
169
168
  const log = (line) => (line.startsWith('zz-meridian brand:') ? console.error(line) : console.log(line));
169
+ // The flags are read before anything is copied, and a value the branding script refuses is refused here with the
170
+ // command's own sentence: `adopt` and `create` read the same flags through `effectiveBrand` and answer its Error
171
+ // (`adopt.ts:153`, `create.ts:19`), where `brand` let it out as a stack trace.
172
+ let brand;
173
+ try {
174
+ brand = effectiveBrand(flags);
175
+ }
176
+ catch (e) {
177
+ log(`zz-meridian brand: ${e.message}`);
178
+ return 1;
179
+ }
180
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'zz-meridian-brand-'));
170
181
  const replays = new Map();
171
182
  let n = 0;
172
183
  try {
@@ -204,7 +215,7 @@ export function brandCommand({ root, flags, allowDirty }) {
204
215
  return fs.readFileSync(path.join(copy, APP_CONFIG), 'utf8');
205
216
  },
206
217
  };
207
- return rebrand(ctx, effectiveBrand(flags), { allowDirty });
218
+ return rebrand(ctx, brand, { allowDirty });
208
219
  }
209
220
  finally {
210
221
  fs.rmSync(scratch, { recursive: true, force: true });
package/dist/session.js CHANGED
@@ -265,8 +265,11 @@ function install(ctx, session, j, pm) {
265
265
  const { root } = ctx;
266
266
  const lock = lockfileFor(root, pm);
267
267
  const prior = j.operations.find((o) => o.path === lock && o.disposition === 'team-preserved' && o.applied);
268
- const before = prior ? prior.base : probe(root, lock).side;
269
268
  const backup = path.join(session, 'backup', lock);
269
+ // The preimage is what the lockfile was before this update's install, and the first run wrote it here before running
270
+ // one. A resumed run that probed the project instead takes whatever a killed install left behind as "ours", and an
271
+ // abort would then put a half-written lockfile back in place of the team's.
272
+ const before = prior ? prior.base : fs.existsSync(backup) ? sideOf(sha(fs.readFileSync(backup))) : probe(root, lock).side;
270
273
  if (before.exists && !fs.existsSync(backup))
271
274
  writeAtomic(backup, fs.readFileSync(inside(root, lock)));
272
275
  const t0 = performance.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
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,42 @@
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.16.1] · 2026-10-16
6
+
7
+ ### Breaking
8
+
9
+ - **The literal-colour rule reads more forms.** `scripts/check.ts` matched `#rrggbb`, `#rgb`, `rgb(`/`rgba(` and `hsl(`/`hsla(`. It now reads a hex of four or eight digits too — `#0a5F`, `#0a58caFF` — and `oklch()`, `oklab()`, `lab()`, `lch()` and `color()`. A gate that passed can fail on a line that writes one of them: use a role or a token (`text-ink`, `border-line`, `var(--…)`), or mark the line `allow-literal-colour` where the colour is genuinely a literal of its own, as an embed's simulated host and a third party's brand are. A colour composed from tokens is not a literal and is not reported, so `color-mix(in oklch, var(--ink) 40%, transparent)` stays as it is.
10
+ - **A flag a command does not read is refused, not ignored.** `zz-meridian adopt --dry-run` — and any other flag named outside the command that reads it — exits 1, naming the command it belongs to. A CI step that passed one did nothing quietly; it fails now, which is what it should have done.
11
+ - **A figure nobody measured is `null`, not zero.** `RequestSummary.p95` and `RequestSummary.errorShare` (`src/data/requests.ts`) are `null` when the set is empty, and `MetricTile.points` is `(number | null)[]`; `NowData`'s per-model `p95` and `errorRate` are `null` for a model with no usage row. A page, a fixture or a test of yours that builds one of them adds the `null` case; a tile whose point is `null` draws a gap where it drew a fall to zero, and the request log says "not measured" where it said `0ms` and `0.0%`.
12
+ - **The audit's two truncated-value rules mean what the card contract says.** `scripts/audit.ts` accepted any `title` anywhere near a cut value without comparing it with the text, and read `aria-labelledby` as the attribute's own value instead of resolving the ids it names. Both compare now, so a `verify --full` that passed can fail: put the whole of a cut value in a `title` that *holds* it (a title of something else keeps none of it), and name a real element with `aria-labelledby` — an id that is nowhere is a control with no name. `CONTRIBUTING.md` promised both all along.
13
+ - **The backend gate refuses a data URL it cannot read the host of.** `unsafeBackend` (`scripts/verify.ts`) skipped a value it could not read the host of, which is how a DSN written without a scheme (`user:pass@db.prod.example.com:5432/app`), a password holding a `/` or a `#`, or a libpq line (`host=… user=…`) reached the presses. The authority is read with its credentials dropped, a value whose host still cannot be read is refused rather than skipped, and a host named in the query string is read the way libpq reads it — `postgres:///app?host=db.prod.example.com` reaches that host, since libpq takes the host from the query and lets it override the authority, dials `hostaddr` when both are named, keeps the last value of a keyword it is given twice, and treats `/…` as a Unix socket, which stays local. A file scheme that names a host is another machine's file (`file://db.prod.example.com/share/app.db`) or a database over the network (`libsql://…`) rather than a file of this machine's. A refusal names the way out: point the value at a local copy for the run, or set `allowRemoteData: true` in `scripts/verify.config.ts`.
14
+
15
+ ### New
16
+
17
+ - **`readAll(name, q)`** (`src/data/read.ts`) answers with every row a collection holds, asking the same question of every page: a list that counts, totals or claims "every" starts there, and a `limit` passed to it is ignored, because its answer means every row. `readEndpoints`, `readServices` and the incident read that joins each ticket go through it, and so do the Members and the Keys view tools and the three pages that list them: the endpoint list, the incidents the bell and Services count, every member, every key and every service are now all of them, where each was the first hundred. A page of yours that read one page of a collection and called it every row reads through `readAll` too.
18
+
19
+ ### Changed
20
+
21
+ - **Home says no service is monitored yet.** A services read that succeeded and returned no rows had the page's title and its line say "every service is operational" while the page's own fact said "0 of 0 operational". Both say the empty state now, in the words the Services and Now pages already use.
22
+ - **`adopt` keeps a commented tsconfig beside the rewritten one.** `adopt` rewrites `tsconfig.json` to raise the target and add the `@meridian/*` path, and `JSON.stringify` cannot hold a comment or a trailing comma. When the team's file carries either, it is kept as `tsconfig.before.json` and the notes say so; a plain tsconfig gets no backup.
23
+ - **`brand` reports a flag it cannot use.** `brand --theme purple` printed an uncaught `Error` where `adopt` and `create` answer the same value with a sentence. All three report it the same way now, before anything is copied.
24
+ - **The standard names three motion loops.** The token layer defines three (`--dur-loop-spin`, `--dur-loop-shimmer`, `--dur-loop-pulse`) and the Motion floor and the Skeleton card named two. All three are named everywhere now, and `tests/motion-loops.test.ts` holds the count to the tokens.
25
+
26
+ ### Fixed
27
+
28
+ - **A held-back answer agrees with itself.** The line an answer ends with when the 3,000-character cap holds part of it back read "1 more line are not shown."; it reads "1 more line is not shown.", and the row messages follow.
29
+ - **A resumed update keeps the lockfile preimage a killed install left behind.** `--resume` probed the project for the team's lockfile, so a lockfile a killed install had rewritten stood in as "ours" and an abort would have put a half-written file back in place of the team's. It comes from the session's backup now.
30
+ - **The prepared query's 3,000-character cap bounds the whole answer.** It bounded the drilled rows, the one part it could shorten, so a summary over budget by itself came back over.
31
+ - **`nav-needs` reads a nav item by its braces.** An item a team had spread over its keys was reported as missing a `needs` that was two lines below its `href`.
32
+ - **Copy says "Copied" only when the copy happened.** A refused clipboard write left the button saying it had copied.
33
+ - **The short date is one function.** `formatShortDate` (`01 Oct`) and `formatDay` (`3 Oct`) in `src/lib/format-date.ts`; six copies of the same expression in five views and a chart preview are gone, and four sentences that wrote the day in two different ways agree.
34
+ - **A project created in another timezone passes its own gate.** A shipped test pinned the template's timezone; it states the project's own now, so `create --timezone Europe/London` no longer fails the gate it ships with.
35
+ - **A credit reads as a credit.** `formatCost` wrote `$-0.0050` for an amount under a cent; the sign goes before the currency, as `-$0.0050`.
36
+ - **Home's focus line states no ratio against a median of zero**, where it printed `NaN×`.
37
+ - **A required control inside a `Field` is announced as required.** A `Select` given `required`, or inside `<Field required>`, carries `aria-required` on its trigger, where nothing announced it.
38
+ - **Three checks in `scripts/` say what they measured.** A failed performance run shows in the coverage line rather than only in the phase; the keyboard walk prints "and the same in reverse" only when it compared the reverse walk; and `audit --atlas` reads its key list from this repository rather than from the folder it was run in. The pagination card's README no longer claims a page size in the address that the table does not keep.
39
+ - **The 0.16.0 section below was corrected in place.** Revenue's own needs were stated twice and one of them wrongly; nothing a product renders changed.
40
+
5
41
  ## [0.16.0] · 2026-10-15
6
42
 
7
43
  ### Breaking
@@ -34,7 +70,6 @@ Every release of ZZ Meridian, newest first. Versions follow semver: a removed or
34
70
  - **A request is priced at its model's price.** `usageOf()` prices a request from the `models` records and from no list of its own: its tokens at its model's prices a million, the price of a call when it reached no model, nothing when it was refused. The list of $3 and $15 a million tokens and $0.10 a thousand calls is gone, so every cost on a request's page moved.
35
71
  - **The ticket boundary's refusal for a fixed field has new words.** It reads "A ticket's id, its times and its incident are set by the system and cannot be changed." A test of yours that matches the sentence matches the new one.
36
72
  - **Home, Usage and Revenue hand their pages a `head`.** `HomeData`, `UsageData` and `RevenueData` each hold `head`, what the page states at its head: `{ title, facts, unknown? }` (`PageHead` in `src/views/fact-insight.ts`), worked out on the server by the view tool's read. Home's `head.facts` are the facts of a year, and `HomeData` holds `line`, the line under its title, where it held `unwell`. A fixture or a test of yours that builds one of the three adds them: `head: { title: null, facts: [] }` draws a page headed by its name, and `unwell: null` says nothing of the services.
37
- - **Revenue asks for the days.** `FEATURES.revenue.needs` is `{ allOf: ['customers:read', 'days:read'] }`, because its title reads them. Every role of the sample that reads customers reads the days too. A role of yours that holds `customers:read` alone no longer opens `/business`: grant it `days:read`.
38
73
  - **Now and Services hand their pages a `head`.** `NowData` and `ServicesData` each hold `head`, the page's title and the days inside the band (`PageHead` in `src/views/fact-insight.ts`), worked out on the server by the view tool's read. A fixture or a test of yours that builds one of them adds it: `head: { title: null, facts: [] }` draws a page headed by its name.
39
74
  - **Now and Services ask for more.** `FEATURES.now.needs` is `{ allOf: ['services:read', 'incidents:read', 'releases:read', 'tests:read', 'days:read', 'usage:read', 'models:read', 'compute:read', 'endpoints:read', 'tickets:read', 'activity:read'] }` and `FEATURES.services.needs` is `{ allOf: ['services:read', 'incidents:read', 'days:read'] }`: Now's title counts the releases in testing, which their verification runs decide, and both contexts count the days inside the band. Every role of the sample that reads the services holds them. A role of yours that does not no longer opens `/engineering` or `/services`: grant it what is missing. Now's eleven are what its read states beside the services, the releases and the days: each model, compute in use, the slowest endpoints, the work and the latest changes.
40
75
  - **Now's data states what engineering's parts draw.** `NowData` keeps `services`, `head`, `updatedAt` and `now`, loses `current` and `alsoOpen` (`incidents.open` holds every open incident, the most severe first), and adds `line`, `incidents`, `band`, `models`, `compute`, `shipping`, `work`, `endpoints`, `month`, `runs`, `activity` and `unread`, a part being `null` when its read failed. Build it with `nowData(reads)` from `src/views/now-parts.ts`, as `nowTool` does: a fixture or a test of yours that builds a `NowData` by hand builds it there instead. Now's body and its embed take it packed: `PackedNowBody` in `src/views/now.tsx` and `EmbedNow` in `app/embed/now/view.tsx` take `packNow(data)` from `src/views/now-context.ts`, which sends each strip to the browser one character a day; a page or a test of yours that renders either passes its `NowData` through `packNow` first. Now's context no longer lists every service with its latency: it states the services as their summary and each part's figures.
@@ -1,6 +1,6 @@
1
1
  import { gate, gatedMetadata, may } from '@/data/access';
2
2
  import { ACTIONS } from '@/data/features';
3
- import { read } from '@/data/read';
3
+ import { readAll } from '@/data/read';
4
4
  import { KeysView } from '@/views/keys';
5
5
  import type { ApiKey } from '@/data/sample';
6
6
  import { createKey, revokeKey } from './actions';
@@ -22,7 +22,7 @@ export function generateMetadata() {
22
22
  export default async function KeysPage() {
23
23
  const denied = await gate('keys');
24
24
  if (denied) return denied;
25
- const { rows, observedAt } = await read('keys');
25
+ const { rows, observedAt } = await readAll('keys');
26
26
  const mayCreate = await may(ACTIONS['create-key'].needs);
27
27
  const mayRevoke = await may(ACTIONS['revoke-key'].needs);
28
28
  return (
@@ -1,6 +1,6 @@
1
1
  import { gate, gatedMetadata, may } from '@/data/access';
2
2
  import { invitableRoles, memberAccess, type MemberRowAccess } from '@/data/member-mutations';
3
- import { read } from '@/data/read';
3
+ import { read, readAll } from '@/data/read';
4
4
  import { assignRole, inviteMember, removeMember, setMemberStatus } from './actions';
5
5
  import { MembersView } from '@/views/members';
6
6
  import type { Member } from '@/data/sample';
@@ -34,7 +34,7 @@ async function workspaceName(): Promise<string | undefined> {
34
34
  export default async function MembersPage() {
35
35
  const denied = await gate('members');
36
36
  if (denied) return denied;
37
- const { rows, observedAt } = await read('members', { sort: { field: 'joined', dir: 'desc' } });
37
+ const { rows, observedAt } = await readAll('members', { sort: { field: 'joined', dir: 'desc' } });
38
38
  const members = rows as Member[];
39
39
  const [access, roles, workspace] = await Promise.all([
40
40
  Promise.all(members.map(async (m): Promise<readonly [string, MemberRowAccess]> => [m.id, await memberAccess(m.id)])).then((entries) => Object.fromEntries(entries)),
@@ -1,6 +1,6 @@
1
1
  import { gate, gatedMetadata, may } from '@/data/access';
2
2
  import { ACTIONS } from '@/data/features';
3
- import { read } from '@/data/read';
3
+ import { readAll } from '@/data/read';
4
4
  import { readTickets } from '@/data/tickets';
5
5
  import { TicketsView } from '@/views/tickets';
6
6
  import { createTicket, updateTicket } from './actions';
@@ -27,9 +27,9 @@ export default async function TicketsPage() {
27
27
  may(ACTIONS['create-ticket'].needs),
28
28
  may(ACTIONS['update-ticket'].needs),
29
29
  may('members:read'),
30
- read('services'),
30
+ readAll('services'),
31
31
  ]);
32
- const people = mayReadMembers ? (await read('members', { limit: 500 })).rows : [];
32
+ const people = mayReadMembers ? (await readAll('members')).rows : [];
33
33
  return (
34
34
  <TicketsView
35
35
  rows={rows}
@@ -18,4 +18,4 @@ After 0002, the owner asked for the overall feeling to be more refined and less
18
18
  ## Consequences
19
19
 
20
20
  - Every card picks up the ground's light; contrast is still computed by compositing the surface over the ground (`node scripts/contrast.ts`).
21
- - Nothing in the interface loops except a live dot and a skeleton.
21
+ - Nothing in the interface loops except a live dot, a skeleton and a spinner's ring. (Amended 2026-10-16: this line named a dot and a skeleton; the spinner's ring is the third loop the token layer defines, and the standard's floor names all three.)
@@ -67,7 +67,8 @@ Then, in this order:
67
67
  fixture test (below) is what keeps it complete. A file of the same name that differs is never overwritten: `adopt`
68
68
  lists the conflicts and stops before writing anything.
69
69
  2. Merges dependencies and scripts into their `package.json`, keeping their versions where newer and compatible; adds
70
- the `@meridian/* → ./src/*` alias to `tsconfig.json` when theirs differs (their own `@/*` is left as it is).
70
+ the `@meridian/* → ./src/*` alias to `tsconfig.json` when theirs differs (their own `@/*` is left as it is), keeping
71
+ theirs as `tsconfig.before.json` when it carries comments or trailing commas, which JSON cannot hold.
71
72
  3. Replaces their global stylesheet with the template's `app/globals.css`, keeping theirs as `app/globals.before.css`
72
73
  for the agent to port from.
73
74
  4. Runs `scripts/brand.ts --existing` with the brand arguments given (or the name from their `package.json`): the name,
@@ -104,6 +104,7 @@ What exists, by layer, and how it changes per surface. "Same" means the card nee
104
104
  | Icons | Lucide at 1.75 stroke: 14, 16 and 18px | Same | Same |
105
105
  | Motion | Arrive, answer, float; reduced motion collapses all | Same | Arrive only on first render, so a re-render in the thread never replays |
106
106
  | App mark | The product mark at 20, 24, 28 and 32px | Same | 20px in the embed head |
107
+ | Surface | Where a view is being read, the console or an MCP host, and what it may ask of that host: the bridge, the host's theme and style variables, and the one shared context both agents are told | The console, with no host: `expand` opens the console page | Started by EmbedFrame on every route under `app/embed/` |
107
108
 
108
109
  ### Layer 2 · Components
109
110
 
@@ -111,7 +112,7 @@ What exists, by layer, and how it changes per surface. "Same" means the card nee
111
112
  |---|---|
112
113
  | Actions | Button, Icon button, Menu |
113
114
  | Input | Field, Input, Textarea, Select, Checkbox, Radio group, Switch, Segmented, Search input |
114
- | Display | Card, Badge, Status dot, Delta, Avatar, Tooltip, Progress, Key value, Copy field, Kbd |
115
+ | Display | Card, Badge, Status dot, Delta, Avatar, Agent mark, Tooltip, Progress, Key value, Copy field, Kbd, Popover |
115
116
  | Navigation | Tabs, Breadcrumb, Pagination |
116
117
  | Feedback | Banner, Toast, Dialog, Sheet, Empty state, Skeleton, Spinner |
117
118
  | Data | Table |
@@ -123,6 +124,8 @@ All components are surface-agnostic by construction: they size from control toke
123
124
  | Pattern | Job | Console | Mobile | Embed |
124
125
  |---|---|---|---|---|
125
126
  | Rail | Navigation, workspace, account, appearance | Fixed on the frame | In the drawer | None |
127
+ | Shell tools | Search and commands (⌘K) and alerts, in the top bar of every console page | The top bar, one press away while the page scrolls | In the top bar, the same | None |
128
+ | Assistant | The panel beside the page where a person asks about what they are looking at | A third column at `assistant-width` 400px from 1024px, a sheet over the page under it | The panel as a sheet | None: the host has its own conversation, and Ask posts into it |
126
129
  | Command palette | Every destination and global action behind ⌘K | Centred, 600px | Centred, the screen's width less 24px | None |
127
130
  | Appearance menu | Theme, accent, density | In the rail's foot | In the drawer's foot | None: the host decides the theme |
128
131
  | Period select | The reporting period, in the URL or held by the page | Masthead actions | Masthead actions row | None: the period is the tool's argument, named in the embed's title |
@@ -149,6 +152,7 @@ All components are surface-agnostic by construction: they size from control toke
149
152
  | Ask about | Hands a card to the agent as a question | On each page's protagonist (its Featured metric, its list or its record's card), when the assistant is on: opens the panel | Same, the panel as a sheet | On cards, when a host is connected |
150
153
  | Proposal | An agent's write, waiting for a person's Approve | In the assistant's thread | Same | The tool result's card |
151
154
  | Prose | Markdown a person or a model wrote, rendered safely | Reading width, `base` | One column; tables wrap | `sm`, host font |
155
+ | Export button | The rows behind a view as a CSV, with the period and filters already applied, so the file matches the screen | A masthead action, after the period select; the browser reports the download when it has an `href` | Same | None: the console's export is the page's |
152
156
 
153
157
  ### Layer 4 · Pages
154
158
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -210,7 +210,7 @@ try {
210
210
  const lines: [string, RegExp][] = [
211
211
  ['revenue with its definition', new RegExp(String.raw`^- Revenue: [^\d\s]*[\d,]+\.\d{2}${CHANGE}\. \(Billed for metered usage, before credits\.\)$`, 'm')],
212
212
  ['the gross margin an Owner reads', new RegExp(String.raw`^- Gross margin: [\d.]+%${POINTS}\. \(Revenue less compute cost, over revenue\.\)$`, 'm')],
213
- ['the day in progress, counted in no total', /^- \d{2} [A-Z][a-z]{2} is in progress and counted in no total\.$/m],
213
+ ['the day in progress, counted in no total', /^- \d{1,2} [A-Z][a-z]{2} is in progress and counted in no total\.$/m],
214
214
  ];
215
215
  for (const [what, shape] of lines) if (!shape.test(viewContext)) fail(`Home's view context does not state ${what} (${shape})`);
216
216
  // It leads with the sentence the page is headed by.
@@ -17,14 +17,17 @@
17
17
  * own content is measured like any other. A defined custom element that shows a box but exposes no open root and no
18
18
  * light-DOM content, and one that is still undefined after the settle wait, are reported as `unmeasured:` instead of passed.
19
19
  *
20
- * Fails (exit 1) on: sideways scroll, text clipped without an ellipsis, text cut with an ellipsis that keeps the whole
21
- * of it nowhere, two pieces of text in the same place or a line drawn through one, a control with no accessible name,
22
- * more than one page scroller, and rendered text under its WCAG minimum, an SVG's text included, read by its `fill`
23
- * against whatever the SVG paints behind it. Prints the design metrics per page: distinct type
24
- * sizes, weights and radii, and the hierarchy ratio (largest text over the median), which should be 3 or more on
25
- * an analytical page.
20
+ * Fails (exit 1) on: a route that served another page or dropped a parameter it was asked for, a page whose content
21
+ * region rendered nothing or that threw or logged a failure while it rendered, sideways scroll, text clipped without an
22
+ * ellipsis, text cut with an ellipsis that keeps the whole of it nowhere, two pieces of text in the same place or a line
23
+ * drawn through one, a control with no accessible name or one under 44px on touch, a heading two levels below the one
24
+ * before it, more than one page scroller, and rendered text under its WCAG minimum, an SVG's text included, read by its
25
+ * `fill` against whatever the SVG paints behind it. Prints the design metrics per page: distinct type sizes, weights and
26
+ * radii, and the hierarchy ratio (largest text over the median), which should be 3 or more on an analytical page.
27
+ * Every rule above has a page in `scripts/fixtures/deep/` that makes it fire.
26
28
  */
27
29
  import fs from 'node:fs';
30
+ import path from 'node:path';
28
31
  import { atlasCoverage, atlasEntries } from './lib/atlas.ts';
29
32
  import { launch } from './lib/chrome.ts';
30
33
  import { DEEP_SOURCE } from './lib/deep.ts';
@@ -53,7 +56,9 @@ const ATLAS = args.includes('--atlas');
53
56
  */
54
57
  let atlasLine = '';
55
58
  async function atlasRoutes(): Promise<string[]> {
56
- const file = 'app/system/preview/[section]/[card]/keys.ts';
59
+ // Resolved from this file, like every other path a check reads: run by hand from another folder, a bare relative path
60
+ // told a repository with an Atlas that it had none.
61
+ const file = path.resolve(import.meta.dirname, '../app/system/preview/[section]/[card]/keys.ts');
57
62
  if (!fs.existsSync(file)) { console.error('audit --atlas: this project has no Design Atlas to audit'); process.exit(1); }
58
63
  const keys = [...fs.readFileSync(file, 'utf8').matchAll(/'([a-z]+\/[a-z0-9-]+)'/g)].map((m) => m[1]);
59
64
  const cards = keys.flatMap((k) => [`/system/${k}`, `/system/preview/${k}`]);
@@ -137,9 +142,13 @@ const MEASURE = `(() => {
137
142
  }
138
143
  return b;
139
144
  };
140
- // Text cut with an ellipsis has to keep the whole of itself where a pointer can reach it: a title on the element or
141
- // on what holds it (a cell, a row, a link), or on the one thing inside it that the pointer is over.
142
- const titled = (el) => { for (let n = el, i = 0; n && i < 4; n = deepParent(n), i++) if ((n.getAttribute('title') || '').trim()) return true; return [...el.querySelectorAll('[title]')].some((n) => n.getAttribute('title').trim()); };
145
+ // Text cut with an ellipsis has to keep the whole of itself where a pointer can reach it: a title attribute on the element or
146
+ // on what holds it (a cell, a row, a link), or on the one thing inside it that the pointer is over. The title has to
147
+ // *hold* the text, compared as a whole — a title on any ancestor or descendant used to count whatever it said, so a
148
+ // cell titled "Orders" over a cut account id passed as keeping it.
149
+ const whole = (s) => (s || '').replace(/\\s+/g, ' ').trim();
150
+ const keeps = (el, title) => { const t = whole(title), own = whole(el.textContent); return t.length > 0 && own.length > 0 && t.includes(own); };
151
+ const titled = (el) => { for (let n = el, i = 0; n && i < 4; n = deepParent(n), i++) if (keeps(el, n.getAttribute('title'))) return true; return [...el.querySelectorAll('[title]')].some((n) => keeps(el, n.getAttribute('title'))); };
143
152
  const inked = [];
144
153
  const ringSig = (c) => [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow, c.borderTopStyle, c.borderTopWidth, c.borderTopColor, c.borderBottomColor, c.borderLeftColor, c.borderRightColor].join('|');
145
154
  const texts = [];
@@ -181,7 +190,11 @@ const MEASURE = `(() => {
181
190
  // A card drawn as a specimen in the Atlas keeps its own scroll regions; only the page's count against the one scroller.
182
191
  if (/(auto|scroll)/.test(c.overflowY) && el.scrollHeight > el.clientHeight + 1 && el.clientHeight > 120 && !deepClosest(el, '[data-specimen]')) out.scrollers.push(label(el));
183
192
  if (el.matches('button,a[href],[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],input:not([type=hidden]),select,textarea')) {
184
- const name = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title') || deepText(el).trim() || el.querySelector('img[alt]')?.getAttribute('alt') || (el.id && el.getRootNode().querySelector('label[for="' + el.id + '"]')?.textContent.trim()) || el.closest('label')?.textContent.trim() || el.getAttribute('placeholder') || '';
193
+ // A name is what the control is announced as. aria-labelledby names elements that hold it, and is worth
194
+ // nothing until they are looked up — the same lookup a label-for gets two terms later — so an id that names
195
+ // nothing leaves the control unnamed rather than passing on the attribute's own text.
196
+ const byIds = (attr) => (el.getAttribute(attr) || '').split(/\\s+/).filter(Boolean).map((id) => { const t = el.getRootNode().querySelector('#' + CSS.escape(id)); return t ? whole(t.textContent) : ''; }).join(' ').trim();
197
+ const name = el.getAttribute('aria-label') || byIds('aria-labelledby') || el.getAttribute('title') || deepText(el).trim() || el.querySelector('img[alt]')?.getAttribute('alt') || (el.id && el.getRootNode().querySelector('label[for="' + el.id + '"]')?.textContent.trim()) || el.closest('label')?.textContent.trim() || el.getAttribute('placeholder') || '';
185
198
  if (!name) out.unnamed.push(label(el));
186
199
  // On touch (a phone is emulated as one) every control answers at least 44px: its own box, its field's frame, or
187
200
  // the .hit square around it. A link inside running text is exempt, as WCAG exempts it.
@@ -7,7 +7,8 @@
7
7
  * - Every page specification exists and follows the page anatomy, and a page with a view tool says what the agent does
8
8
  * there: its protagonist and at least three questions its prepared query answers.
9
9
  * - Every token a specification names in backticks, and every var(--x) or (--x) a component uses, exists.
10
- * - No literal colour (hex, rgb, hsl) and no Tailwind default palette in the layers: colours come from roles.
10
+ * - No literal colour (hex of 3, 4, 6 or 8 digits; rgb, hsl, oklch, oklab, lab, lch, color) and no Tailwind default
11
+ * palette in the layers: colours come from roles.
11
12
  * - One implementation: the fixtures a collection serves are read through src/data/collections.ts, not imported again.
12
13
  * - In the template, `connection()` is called only where the request is meant to be read: the console layout and the
13
14
  * not-found page; pages and views reach the sample through src/data, never src/system/fixtures; and a writer path
@@ -192,7 +193,10 @@ for (const f of LAYERS) {
192
193
  if (/allow-literal-colour/.test(line)) return;
193
194
  if (f === 'src/system/page-stage.tsx' && /HOST_STYLES|--color-|--border-radius|#2A2A30|#EDEDF0/.test(line)) return; // a simulated foreign host's own colours
194
195
  const at = `${f}:${i + 1}`;
195
- const lit = line.match(/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])|\brgba?\(\s*\d|\bhsla?\(/);
196
+ // Literal colour, in every form a browser takes: hex of 3, 4, 6 or 8 digits, and the colour functions. A function
197
+ // is matched by its own name and its parenthesis, and not after a dot — so `color-mix(in oklch, var(--ink) 40%,
198
+ // transparent)`, which is how the layers compose a tint out of tokens, is not a literal and is not reported.
199
+ const lit = line.match(/#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{4}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])|\brgba?\(\s*\d|\bhsla?\(|(?<![\w.$])(?:oklch|oklab|lab|lch|color)\(/);
196
200
  if (lit && !/^\s*(\*|\/\/|\/\*)/.test(line)) problems.push(`${at}: literal colour ${lit[0]} (use a role)`);
197
201
  // Meridian resets Tailwind's scales; a utility outside them emits no CSS and fails silently.
198
202
  const dead = line.match(/\b(?:font-(?:normal|light|bold|extrabold|black|thin)|rounded-(?:2xl|3xl)|shadow-(?:sm|md|lg|xl|2xl)|text-(?:3xl|4xl|5xl))\b/);