@erclx/canon 4.110.0 → 4.111.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.110.0",
4
+ "version": "4.111.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -26,8 +26,8 @@ The last failure is a section no route reaches. `## Route` maps an intent or a d
26
26
  - Resolve every stack, rule, snippet, and standard name from a catalog at runtime
27
27
  - Run the CLI non-interactively and report the command run, what changed, and the full path of anything written
28
28
  - Read the overwrite contract from the body before any install or sync, and name each surface it lists as overwritten before running one
29
- - Read the reverse walk beside the forward sections, so a folder the toolkit stopped shipping and a migration with a live case both reach the user
30
- - Hand off a named migration to the skill the report names, and name an unmigrated domain's root and install path for the user to move themselves when no skill covers it
29
+ - Read the reverse walk beside the forward sections, so a folder the toolkit stopped shipping reaches the user
30
+ - Name an unmigrated domain's root and install path for the user to move themselves, since no skill covers the move
31
31
  - Route a measurement intent to the audit offers from the route table itself, rather than leaving the section reachable only by reading past the table
32
32
  - Rank a lifecycle row against the audit offers where the route table itself states it, so a session acting on the lifecycle row reads the ranking without opening the section being ranked
33
33
  - Offer every audit whose surface the target carries, and offer none whose surface it lacks
@@ -25,10 +25,10 @@ Run `canon sync --check . --json` before routing. It reports what a target is be
25
25
  Read seven fields off the report and carry each to `## Route`:
26
26
 
27
27
  - `unmigrated`: a domain sitting at the root layout with nothing under `.claude/`. The most urgent finding, because that domain reports no drift of its own while being entirely behind
28
- - `superseded`: a file a newer seed folder replaced. No command moves it, since the content is the project's own, so this routes to the skill that proposes the split rather than ending at the report
28
+ - `superseded`: a file a newer seed folder replaced. No command moves it, since the content is the project's own, so this routes to `canon:target-check` for the full per-domain reading rather than ending at the report
29
29
  - `seeds`: entries are `matching`, `stale`, `drifted`, or `missing`. Anything but `matching` needs the seed handoff
30
30
  - `domains[].entries`: per-file `stale`, `customized`, `stranded`, and `orphaned` as before
31
- - `reverse`: what the target holds that no live catalog claims. `unclaimed` lists folders at roots the toolkit stopped shipping, each with an `attribution` of `dropped`, `unattributed`, or `project`. Act on the first two and leave `project` alone, which history proved the project owns. `migrations` names a proposal-only skill with a live case here and the `reason` it was measured from
31
+ - `reverse`: what the target holds that no live catalog claims. `unclaimed` lists folders at roots the toolkit stopped shipping, each with an `attribution` of `dropped`, `unattributed`, or `project`. Act on the first two and leave `project` alone, which history proved the project owns
32
32
  - `historyUnavailable` on a domain, on `seeds`, or on `reverse`: nothing could be dated, so treat every difference as unverified and say so rather than reporting a file as untouched. It is set on `reverse` when the toolkit itself ships without history, which is the registry-install case, so the walk found nothing rather than finding a clean target
33
33
  - `tooling`: read `measured` first. Every count under it is zero when it is false, which is an absence of measurement rather than a measured zero. Past that, `chain` names the stacks the install resolved, nearest first, and `counts.gitignore` counts the managed ignore entries the target is missing.
34
34
 
@@ -43,8 +43,7 @@ Map the stated intent, or what `## Diagnose` found, to one lifecycle phase, then
43
43
  The two can name different rows, and a reply answers both rather than picking one. Run or hand off what the finding names, then answer the intent in the same reply. A measurement intent therefore reaches the audit offers below even on a target whose diagnostic found scaffold work, which is the case a session leaving from the scaffold row would otherwise never read.
44
44
 
45
45
  - A domain in `unmigrated`: name it, the root path, and the install path. No command moves it, so the user runs the relocation themselves
46
- - An entry in `migrations`: hand off to the skill its `skill` field names, spelled as the report spells it. Both of them propose without writing, so the handoff is where this stops
47
- - Anything in `superseded`: name which files and what replaced them, then hand off to `migration-superseded`. Both skills propose, so neither moves nor deletes the file
46
+ - Anything in `superseded`: name which files and what replaced them, then hand off to `canon:target-check`, which reports and never moves or deletes the file
48
47
  - A folder in `unclaimed`: name it and the attribution it carries. No command moves it and the content may be the project's own, so the decision is the user's
49
48
 
50
49
  - First-time scaffold of a fresh project: hand off to `target-setup`
@@ -25,7 +25,7 @@ Read three fields off the same report, then two verbs:
25
25
 
26
26
  - `unmigrated`: a domain still at the root layout with nothing under `.claude/`. The most urgent finding, because such a domain reports no drift of its own while being entirely behind.
27
27
  - `superseded`: a file a newer seed folder replaced. No command moves it, since the content is the project's own.
28
- - `reverse`: `unclaimed` names folders at roots the toolkit stopped shipping, each with an `attribution` of `dropped`, `unattributed`, or `project`. `migrations` names a proposal-only skill with a live case here and the `reason` behind it.
28
+ - `reverse`: `unclaimed` names folders at roots the toolkit stopped shipping, each with an `attribution` of `dropped`, `unattributed`, or `project`.
29
29
  - `canon context audit --json`: required sections, entry length, citations, and index drift across the context folder.
30
30
  - `canon records validate <kind> --json`, once per record folder the target carries.
31
31
 
@@ -33,7 +33,7 @@ Read three fields off the same report, then two verbs:
33
33
  - Behind: any of those carrying an entry. Name the entry and its attribution.
34
34
  - Unread: `historyUnavailable` set on `reverse`, a `reverse` key the report does not carry at all, or either verb refusing. A missing `reverse` key is a binary predating the field rather than a target with nothing unclaimed.
35
35
 
36
- Routes by finding. `unmigrated` routes to a relocation the user runs, since no command moves it. `superseded` routes to `migration-superseded`. A `migrations` entry routes to the skill its `skill` field names, spelled as the report spells it. An `unclaimed` folder carrying `project` attribution routes nowhere, since history proved the project owns it.
36
+ Routes by finding. `unmigrated` routes to a relocation the user runs, since no command moves it. `superseded` routes to a split the user makes, since no command moves the content and no skill proposes it. An `unclaimed` folder carrying `project` attribution routes nowhere, since history proved the project owns it.
37
37
 
38
38
  ## 3. Governance rules
39
39
 
@@ -256,11 +256,9 @@ seed tree rather than from a fixed set of filenames, so a folder added later is
256
256
  covered without a code change. Only an exact stem matches, which leaves a
257
257
  suffixed variant such as `TASKS-ARCHIVE.md` unreported.
258
258
 
259
- Route it to `migration-superseded`. That skill resolves the standard governing
260
- `replacedBy` from the `appliesTo` the standards catalog declares, reads the
261
- destination shape with `canon standards <name>`, and proposes the split without
262
- writing. Where a folder has no governing standard, the entry earns a named
263
- refusal rather than a shape nobody stated.
259
+ No skill splits the file. The standard governing `replacedBy` is the one the
260
+ `appliesTo` the standards catalog declares, and `canon standards <name>` reads
261
+ its destination shape, so the split is the project's own to make.
264
262
 
265
263
  `unmigrated` names a domain sitting at the root layout an older toolkit installed
266
264
  to, with nothing at the path the current one reads. It carries `rootPath`,
@@ -350,7 +348,7 @@ check.
350
348
  `reverse` is the one section built by walking the target rather than the
351
349
  catalog. Every other surface enumerates toolkit-owned keys and asks whether the
352
350
  target matches, so a folder the toolkit deleted appears in none of them. It
353
- carries `unclaimed`, `migrations`, and `historyUnavailable`.
351
+ carries `unclaimed` and `historyUnavailable`.
354
352
 
355
353
  `unclaimed` names a folder the target holds at a top-level path the toolkit once
356
354
  shipped and has since deleted. The candidate roots come from the toolkit's own
@@ -381,13 +379,6 @@ colliding on a retired name costs the walk no reads. The cost is that a file the
381
379
  toolkit shipped and the target renamed goes unmatched, the same limit the
382
380
  `unmigrated` count carries.
383
381
 
384
- `migrations` names a proposal-only skill with a live case in this target. It
385
- fires on a `CLAUDE.md` past 250 lines for `migration-claude-md`, and on a `docs/` folder
386
- holding markdown with no populated `canon/context/` for `migration-context`.
387
- Each entry carries the skill name and the measurement behind it, so a consumer
388
- can check the proposal before running it. Without the field both skills are
389
- documented and unreachable from any report.
390
-
391
382
  #### What counts toward the gate
392
383
 
393
384
  `unmigrated` counts toward `--exit-code`, since running the relocation closes it.
@@ -261,8 +261,6 @@ Each entry carries a verdict, since a dropped folder and one the project wrote a
261
261
 
262
262
  Nothing acts on any of them, and the verdict is what makes the list safe to read.
263
263
 
264
- The same field names a proposal-only skill with a live case here under `migrations`, which is how `canon:migration-claude-md` and `canon:migration-context` become reachable. Each entry carries the measurement behind the proposal rather than the proposal alone.
265
-
266
264
  Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code, and neither do superseded artifacts, seed drift, tooling, or anything the reverse walk reports, since each names content the project is expected to edit or place itself. An unmigrated domain does count, because running the relocation closes it.
267
265
 
268
266
  Tooling reports under a section of its own, and `measured` there says whether the target ever recorded a chain. One that never ran a tooling sync reports unmeasured rather than clean, which is what separates tooling nobody has looked at from tooling that is current. A workspace root records nothing either way, since each package resolves its own chain.
@@ -285,17 +285,13 @@ This section is the corpus the coverage claim is measured against: every name `c
285
285
 
286
286
  ### Keep the project current with the toolkit
287
287
 
288
- | Skill | When to use |
289
- | -------------------------------- | -------------------------------------------------------------------------------------- |
290
- | `canon:target-check` | In a target project, to report per domain what it holds against what the toolkit ships |
291
- | `canon:seed-sync` | After a toolkit update, to reconcile installed seeds without losing customizations |
292
- | `canon:migration-claude-md` | When `CLAUDE.md` grew past what always-load context should carry |
293
- | `canon:migration-context` | When `docs/` holds agent-flavored files belonging in `canon/context/` |
294
- | `canon:migration-superseded` | When a drift report names a `.claude/` file a folder has replaced |
295
- | `canon:migration-standards-drop` | When the project still holds an installed `.claude/standards/` tree |
296
- | `canon:canon-feedback` | When something in the toolkit is broken, missing, or off, to open an issue on it |
297
- | `canon:canon-feedback-triage` | In the toolkit repo, to work through the open feedback issues |
298
- | `canon:canon-rollout` | In the toolkit repo, to take one change out to every consuming project at once |
288
+ | Skill | When to use |
289
+ | ----------------------------- | -------------------------------------------------------------------------------------- |
290
+ | `canon:target-check` | In a target project, to report per domain what it holds against what the toolkit ships |
291
+ | `canon:seed-sync` | After a toolkit update, to reconcile installed seeds without losing customizations |
292
+ | `canon:canon-feedback` | When something in the toolkit is broken, missing, or off, to open an issue on it |
293
+ | `canon:canon-feedback-triage` | In the toolkit repo, to work through the open feedback issues |
294
+ | `canon:canon-rollout` | In the toolkit repo, to take one change out to every consuming project at once |
299
295
 
300
296
  ### Generate an artifact on demand
301
297
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.110.0",
4
+ "version": "4.111.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # Builds the landing page, captures it, and stages the frames over
3
+ # assets/evidence/home, the baseline `canon pr evidence` compares against.
4
+ #
5
+ # Two workflows call it. pr-visual-checks.yml stages the frames into the runner
6
+ # tree to diff them against the baseline, and refresh-capture-frames.yml stages
7
+ # them to commit as the new baseline. Both need the same frames, so one script
8
+ # keeps the two from drifting apart.
9
+ #
10
+ # `tooling/web/configs/e2e/screenshot.ts` is reused as is. This checkout has no
11
+ # web/package.json, so the preview server and the cases run against the
12
+ # root-level scripts rather than through screenshot.sh, which assumes a
13
+ # per-project `bun run build`/`bun run preview` pair.
14
+ set -e
15
+ set -o pipefail
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
19
+
20
+ PORT=4321
21
+ BASE_URL="http://localhost:$PORT"
22
+ BASELINE="$PROJECT_ROOT/assets/evidence/home"
23
+
24
+ cd "$PROJECT_ROOT"
25
+
26
+ bun run web:build
27
+
28
+ bun run web:preview &
29
+ PREVIEW_PID=$!
30
+ trap 'kill "$PREVIEW_PID" 2>/dev/null || true' EXIT
31
+
32
+ for _ in $(seq 1 40); do
33
+ curl -sSf "$BASE_URL/" >/dev/null 2>&1 && break
34
+ sleep 0.25
35
+ done
36
+ curl -sSf "$BASE_URL/" >/dev/null 2>&1 || {
37
+ echo "Preview server not responding on port $PORT after 10s." >&2
38
+ exit 1
39
+ }
40
+
41
+ (cd web && SCREENSHOT_BASE_URL="$BASE_URL" bun ../tooling/web/configs/e2e/screenshot.ts)
42
+
43
+ # The committed tree is cleared first rather than copied over. A copy only adds
44
+ # and overwrites, so a section dropped from the case list would leave its
45
+ # frames in place and a diff would see no change. Clearing first shows that
46
+ # section as a deletion.
47
+ rm -rf "$BASELINE"
48
+ mkdir -p "$BASELINE"
49
+ cp -R web/screenshots/localhost/home/. "$BASELINE/"
50
+
51
+ # The sweep holds all four widths and this repository commits two, 320 and
52
+ # 1280. The case list is the place to change if that set moves.
53
+ find "$BASELINE" \( -name '768--*.png' -o -name '1536--*.png' \) -delete
54
+
55
+ # The shared golden config names the case that emulates nothing `default`, and
56
+ # this repository's baseline names that frame `light`.
57
+ find "$BASELINE" -name '*--default.png' -execdir sh -c 'mv "$1" "${1%--default.png}--light.png"' _ {} \;
58
+
59
+ echo "capture-home: staged frames in assets/evidence/home"
@@ -1,9 +1,8 @@
1
1
  import type { SkillCase } from '@/claude/skills-rank'
2
2
 
3
3
  /**
4
- * `target-setup`, `migration-*`, `canon-*`, and `create-rule`: scaffolding,
5
- * proposal-only migrations, and the toolkit's own reference and feedback
6
- * surfaces.
4
+ * `target-setup`, `canon-*`, and `create-rule`: scaffolding and the
5
+ * toolkit's own reference and feedback surfaces.
7
6
  *
8
7
  * The five `target-setup` cases are one skill read through five entry phrasings
9
8
  * rather than five rows that collapsed to a duplicate. Each names a different
@@ -36,26 +35,6 @@ export const SETUP_CASES: readonly SkillCase[] = [
36
35
  'Check that the dev server actually starts and the end-to-end suite passes against the scaffold.',
37
36
  expect: 'target-setup',
38
37
  },
39
- {
40
- prompt:
41
- 'This CLAUDE.md file has grown huge, break it apart into the tiered context model.',
42
- expect: 'migration-claude-md',
43
- },
44
- {
45
- prompt:
46
- 'Move the agent-flavored docs out of the docs folder and into context.',
47
- expect: 'migration-context',
48
- },
49
- {
50
- prompt:
51
- 'This file was replaced by a folder, help me split its content into it.',
52
- expect: 'migration-superseded',
53
- },
54
- {
55
- prompt:
56
- 'This project still carries a copy of the standards folder, get rid of it and point everything at the CLI.',
57
- expect: 'migration-standards-drop',
58
- },
59
38
  {
60
39
  prompt:
61
40
  "Before I run this sync, tell me exactly what it's going to overwrite.",
@@ -189,7 +189,6 @@ function renderCheck(report: CheckReport): void {
189
189
  }
190
190
 
191
191
  renderUnclaimed(report)
192
- renderMigrations(report)
193
192
 
194
193
  outro()
195
194
  // Scanned domains only. Tooling renders a section on every managed target, so
@@ -334,21 +333,6 @@ function describeUnclaimed(entry: UnclaimedFolder): string {
334
333
  return `${counted}, content published at ${entry.since.slice(0, 7)}`
335
334
  }
336
335
 
337
- /**
338
- * The proposal-only skills no other field reaches.
339
- */
340
- function renderMigrations(report: CheckReport): void {
341
- const { migrations } = report.reverse
342
- if (migrations.length === 0) return
343
-
344
- logStep('Migrations with a case here')
345
-
346
- for (const candidate of migrations) {
347
- logWarn(candidate.reason)
348
- logInfo(`Run /canon:${candidate.skill} for a proposal.`)
349
- }
350
- }
351
-
352
336
  async function runSync(target: string): Promise<number> {
353
337
  intro('canon sync')
354
338
 
@@ -223,6 +223,7 @@ const TEACH_CHROME: Component = {
223
223
  reads: [
224
224
  '--color-background',
225
225
  '--color-surface',
226
+ '--color-chrome',
226
227
  '--color-border',
227
228
  '--color-text',
228
229
  '--color-text-body',
@@ -232,7 +233,6 @@ const TEACH_CHROME: Component = {
232
233
  '--teach-sans',
233
234
  '--teach-mono',
234
235
  '--teach-measure',
235
- '--teach-chrome',
236
236
  '--teach-shadow',
237
237
  '--color-teach-accent-bg',
238
238
  '--type-body-family',
@@ -247,8 +247,7 @@ const TEACH_CHROME: Component = {
247
247
  --teach-hand: 'Virgil', 'Excalifont', cursive;
248
248
  --teach-mono: var(--type-code-family);
249
249
  --teach-measure: 52rem;
250
- --teach-chrome: 52rem;
251
- --teach-mast-h: 4.4rem;
250
+ --teach-mast-h: 3.5rem;
252
251
  --teach-shadow: 0 1px 2px rgba(20, 20, 20, 0.04);
253
252
  --color-teach-accent-bg: color-mix(in srgb, var(--color-accent) 14%, var(--color-background));
254
253
 
@@ -328,6 +327,7 @@ html.sb-shut .pane { border-left: 0; }
328
327
  background: var(--color-background);
329
328
  border-bottom: 1px solid var(--color-border);
330
329
  padding: 0 1.5rem;
330
+ cursor: default;
331
331
  }
332
332
 
333
333
  /* The reading bar rides the masthead's own bottom edge rather than arriving as
@@ -345,37 +345,26 @@ html.sb-shut .pane { border-left: 0; }
345
345
  }
346
346
 
347
347
  /* The height is the token rather than the sum of two paddings, so the sidebar's
348
- own top row lands on the same seam the bar's bottom border draws. */
348
+ own top row lands on the same seam the bar's bottom border draws. The row
349
+ spans the window rather than the reading measure: capped, the toggle and the
350
+ theme control floated in from the edges by an amount that changed per page. */
349
351
  .mast {
350
- max-width: var(--teach-chrome);
351
- margin: 0 auto;
352
+ max-width: none;
353
+ margin: 0;
352
354
  min-height: var(--teach-mast-h);
353
- padding: 0;
355
+ padding: 0 0.9rem;
354
356
  display: flex;
355
357
  justify-content: space-between;
356
358
  align-items: center;
357
- gap: 1rem;
359
+ gap: 0.55rem;
358
360
  font-size: var(--t5);
359
361
  color: var(--color-muted);
362
+ cursor: default;
360
363
  }
361
364
 
362
365
  .mast a { color: var(--color-accent); text-decoration: none; font-weight: 600; }
363
366
  .mast a:hover { text-decoration: underline; }
364
367
 
365
- .theme {
366
- font-family: var(--teach-sans);
367
- font-size: 0.75rem;
368
- font-weight: 600;
369
- color: var(--color-muted);
370
- background: transparent;
371
- border: 1px solid var(--color-border);
372
- border-radius: 99px;
373
- padding: 0.25rem 0.7rem;
374
- cursor: pointer;
375
- }
376
-
377
- .theme:hover { border-color: var(--color-accent); color: var(--color-accent); }
378
-
379
368
  .nav {
380
369
  width: 100%;
381
370
  margin: 0;
@@ -422,7 +411,7 @@ html.sb-shut .pane { border-left: 0; }
422
411
  restates either. */
423
412
  /* A breadcrumb rather than a row of links. The gap tightens because the
424
413
  separators now carry the spacing the gap used to. */
425
- .mast-left { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
414
+ .mast-left { display: flex; align-items: center; gap: 0.3rem; min-width: 0; flex-wrap: wrap; cursor: default; }
426
415
 
427
416
  /* \`--rule\` measured 1.27:1 here. It is a border token, and a border is a
428
417
  shape a reader infers rather than a glyph they resolve, so it is the wrong
@@ -430,6 +419,7 @@ html.sb-shut .pane { border-left: 0; }
430
419
  .crumb-sep {
431
420
  color: var(--color-muted);
432
421
  font-size: var(--t4);
422
+ margin: 0 0.1rem;
433
423
  user-select: none;
434
424
  }
435
425
 
@@ -442,27 +432,55 @@ html.sb-shut .pane { border-left: 0; }
442
432
 
443
433
  .crumb { display: inline-flex; align-items: center; }
444
434
 
445
- /* The label and its caret are one segment visually and two controls in fact:
446
- the label navigates, the caret opens the menu. */
447
- .crumb-item { display: inline-flex; align-items: center; gap: 0.1rem; }
435
+ /* The label and its caret are one component with two zones, like a split
436
+ button: the label navigates, the caret opens the menu, and one surface fills
437
+ for both so the pair reads as a single chip rather than two loose pieces.
438
+ The pointer covers the gap between them, which is interactive on both sides. */
439
+ .crumb-item {
440
+ display: inline-flex;
441
+ align-items: center;
442
+ gap: 0;
443
+ border-radius: 7px;
444
+ padding: 0.1rem 0.15rem 0.1rem 0.35rem;
445
+ cursor: pointer;
446
+ }
447
+
448
+ .crumb-item:has(details.jump):hover,
449
+ .crumb-item:has(details.jump[open]) { background: var(--color-chrome); }
450
+
451
+ /* Accent reports where you are. A crumb is navigation you can take, so it is
452
+ neutral, and \`.mast a\` keeps the accent for any other bar link. */
453
+ .mast a.crumb { color: var(--color-text-secondary); padding: 0.1rem 0.15rem 0.1rem 0; }
454
+ .mast a.crumb:hover { text-decoration: none; }
455
+ .crumb-item:hover a.crumb { color: var(--color-text); }
456
+
457
+ .crumb-here { color: var(--color-text); font-weight: 650; }
448
458
 
449
- .crumb-here { color: var(--color-text); font-weight: 600; }
459
+ /* \`align-items: center\` centres boxes, and a label's line box carries
460
+ half-leading above the cap and descender room below, so its capitals sit
461
+ above its own centre by an amount that scales with the font size. Trimming
462
+ the label to its cap band fixes the box rather than nudging the mark. It
463
+ lands on a span holding the text alone, since the link's own \`inline-flex\`
464
+ ignores it. Firefox ships no \`text-box\` and keeps the caret a pixel low. */
465
+ .crumb-t, .crumb-here, .sb-ws .ws-name {
466
+ display: block;
467
+ text-box: trim-both cap alphabetic;
468
+ }
450
469
 
451
- /* The caret is now the whole control, so it carries the 24 pixel minimum on
452
- its own rather than inheriting a label's width. */
470
+ /* The caret is the whole control, so it carries the 24 pixel minimum on its
471
+ own rather than inheriting a label's width. It fills nothing itself, since
472
+ the chip around it does. */
453
473
  .crumb-item > .jump > summary {
454
474
  min-width: 1.5rem;
455
475
  min-height: 1.5rem;
456
476
  display: inline-flex;
457
477
  align-items: center;
458
478
  justify-content: center;
459
- padding: 0;
479
+ padding: 0.15rem 0.3rem 0.15rem 0;
460
480
  border: 0;
461
481
  background: transparent;
462
482
  }
463
483
 
464
- .crumb-item > .jump > summary:hover { background: var(--color-surface); border-radius: 5px; }
465
-
466
484
  /* A bar link is its own tap target rather than a line of text, so it carries
467
485
  the 24 pixel minimum the a11y rule sets. */
468
486
  .mast a,
@@ -478,23 +496,25 @@ html.sb-shut .pane { border-left: 0; }
478
496
  color: var(--color-muted);
479
497
  }
480
498
 
481
- .mast-right { display: flex; align-items: center; gap: 0.85rem; }
499
+ .mast-right { display: flex; align-items: center; gap: 0.2rem; margin-left: auto; }
482
500
 
501
+ /* No border, at the fold control's size and radius, so every control on the
502
+ bar is drawn one way. */
483
503
  .theme {
484
- width: 1.9rem;
485
- height: 1.9rem;
504
+ width: 1.75rem;
505
+ height: 1.75rem;
486
506
  display: inline-flex;
487
507
  align-items: center;
488
508
  justify-content: center;
489
509
  padding: 0;
490
- border: 1px solid var(--color-border);
491
- border-radius: 99px;
510
+ border: 0;
511
+ border-radius: 6px;
492
512
  background: transparent;
493
513
  color: var(--color-muted);
494
514
  cursor: pointer;
495
515
  }
496
516
 
497
- .theme:hover { border-color: var(--color-accent); color: var(--color-accent); }
517
+ .theme:hover { color: var(--color-text); background: var(--color-chrome); }
498
518
  .theme svg { width: 0.95rem; height: 0.95rem; display: block; }
499
519
  .theme .moon { display: none; }
500
520
  :root[data-theme="dark"] .theme .sun { display: none; }
@@ -528,7 +548,7 @@ html.sb-shut .pane { border-left: 0; }
528
548
  cursor: pointer;
529
549
  }
530
550
 
531
- .sb-fold:hover { color: var(--color-text); background: var(--color-surface); }
551
+ .sb-fold:hover { color: var(--color-text); background: var(--color-chrome); }
532
552
 
533
553
  .sb {
534
554
  flex: 0 0 var(--sb-w, 16rem);
@@ -564,7 +584,7 @@ html.sb-shut .sb { flex-basis: 0; width: 0; overflow: hidden; }
564
584
  display: inline-flex;
565
585
  align-items: center;
566
586
  gap: 0.35rem;
567
- padding: 0.2rem 0.4rem;
587
+ padding: 0.25rem 0.3rem;
568
588
  border-radius: 6px;
569
589
  font-size: var(--t4);
570
590
  font-weight: 650;
@@ -572,7 +592,7 @@ html.sb-shut .sb { flex-basis: 0; width: 0; overflow: hidden; }
572
592
  }
573
593
 
574
594
  .sb-ws > summary::-webkit-details-marker { display: none; }
575
- .sb-ws > summary:hover, .sb-ws[open] > summary { background: var(--color-surface); color: var(--color-text); }
595
+ .sb-ws > summary:hover, .sb-ws[open] > summary { background: var(--color-chrome); color: var(--color-text); }
576
596
  .sb-ws .car { color: var(--color-muted); display: inline-flex; align-items: center; }
577
597
 
578
598
  .sb-wl {
@@ -585,7 +605,7 @@ html.sb-shut .sb { flex-basis: 0; width: 0; overflow: hidden; }
585
605
  border: 1px solid var(--color-border);
586
606
  border-radius: 9px;
587
607
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
588
- padding: 0.25rem;
608
+ padding: 0.3rem;
589
609
  }
590
610
 
591
611
  :root[data-theme="dark"] .sb-wl { box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); }
@@ -644,7 +664,7 @@ html.sb-shut .sb { flex-basis: 0; width: 0; overflow: hidden; }
644
664
  line-height: 1.45;
645
665
  }
646
666
 
647
- .sb-l:hover { background: var(--color-surface); color: var(--color-text); }
667
+ .sb-l:hover { background: var(--color-chrome); color: var(--color-text); }
648
668
 
649
669
  .sb-n {
650
670
  flex: none;
@@ -764,46 +784,47 @@ html.sb-shut .sb-grip { display: none; }
764
784
  }
765
785
 
766
786
  .jump summary::-webkit-details-marker { display: none; }
767
- .jump summary:hover { border-color: var(--color-accent); color: var(--color-accent); }
768
- /* The caret says "this is a menu" and nothing else. It used to flip to say
769
- "the menu is open", which the open panel underneath already says, so the
770
- motion carried no information a reader could not already see. */
771
- .jump summary .caret { opacity: 0.55; }
772
- .jump summary:hover .caret { opacity: 1; }
773
- .jump[open] summary { border-color: var(--color-accent); color: var(--color-accent); }
774
- .jump[open] summary .caret { opacity: 1; }
787
+ /* The caret says "this is a menu" and nothing else, so it is drawn at full
788
+ strength in every state and stays muted. A reveal on hover hid the only
789
+ route into the menu from a reader who cannot hover, and an affordance is not
790
+ a state, so it takes no accent on hover or while open. */
791
+ .jump summary .caret { color: var(--color-muted); }
775
792
 
776
793
  .jump-list {
777
794
  position: absolute;
778
795
  top: calc(100% + 0.4rem);
779
796
  left: 0;
780
797
  z-index: 20;
781
- min-width: 21rem;
782
798
  background: var(--color-background);
783
799
  border: 1px solid var(--color-border);
784
800
  border-radius: 9px;
785
801
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
786
- padding: 0.35rem;
802
+ padding: 0.25rem;
787
803
  list-style: none;
788
804
  margin: 0;
789
805
  }
790
806
 
791
807
  :root[data-theme="dark"] .jump-list { box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); }
792
808
 
809
+ /* One row recipe wherever the menu is mounted, on the steps the lesson list
810
+ beside it already reads: the label at \`--t5\` and the numeral at \`--t6\` mono.
811
+ The weight is restated because \`.mast a\` sets 600 for the bar's own links. */
793
812
  .jump-list a {
794
813
  display: grid;
795
814
  grid-template-columns: 1.65rem 1fr auto;
796
- gap: 0.7rem;
815
+ gap: 0.55rem;
797
816
  align-items: center;
798
- padding: 0.45rem 0.55rem;
817
+ padding: 0.38rem 0.55rem;
799
818
  border-radius: 6px;
800
819
  text-decoration: none;
801
820
  color: var(--color-text-body);
802
- font-size: 0.875rem;
821
+ font-size: var(--t5);
822
+ font-weight: 400;
823
+ line-height: 1.45;
803
824
  }
804
825
 
805
- .jump-list a:hover { background: var(--color-surface); }
806
- .jump-list .n { font-family: var(--teach-mono); font-size: 0.75rem; color: var(--color-muted); }
826
+ .jump-list a:hover { background: var(--color-chrome); }
827
+ .jump-list .n { font-family: var(--teach-mono); font-size: var(--t6); color: var(--color-muted); }
807
828
  /* The third column used to hold a status dot that read the same on every row,
808
829
  so it reported nothing. It is a per-entry trailing slot now: the workspace
809
830
  menu puts a lesson count there and the lesson menu leaves it empty. */
@@ -825,11 +846,11 @@ html.sb-shut .sb-grip { display: none; }
825
846
  .jump-list li.all a { color: var(--color-accent); }
826
847
  .jump-list li.all .n { color: var(--color-accent); }
827
848
 
828
- /* The menu is \`min-width: 21rem\` and anchored to its summary, which puts its
829
- right edge 35 pixels past a 390 pixel viewport. Clamping the width and
830
- letting a long row wrap keeps it on screen without a breakpoint. */
849
+ /* The menu fits its content above an 11rem floor rather than padding three
850
+ short titles out to a wide panel. It is anchored to its summary, so the
851
+ clamp keeps a long row wrapping on screen without a breakpoint. */
831
852
  .jump-list {
832
- min-width: min(21rem, calc(100vw - 2rem));
853
+ min-width: min(11rem, calc(100vw - 2rem));
833
854
  max-width: calc(100vw - 2rem);
834
855
  }
835
856
 
@@ -850,8 +871,7 @@ html.sb-shut .sb-grip { display: none; }
850
871
  @media (max-width: 640px) {
851
872
  .bar { padding: 0 1rem; }
852
873
  main { padding-left: 1rem; padding-right: 1rem; }
853
- .mast { font-size: 0.75rem; gap: 0.5rem; }
854
- .mast-left { gap: 0.5rem; row-gap: 0.35rem; }
874
+ .mast { font-size: 0.75rem; }
855
875
  }
856
876
 
857
877
  @media (max-width: 640px) {
@@ -933,7 +953,7 @@ html.sb-shut .sb-grip { display: none; }
933
953
  cursor: pointer;
934
954
  }
935
955
 
936
- .sb-close:hover { color: var(--color-text); background: var(--color-surface); }
956
+ .sb-close:hover { color: var(--color-text); background: var(--color-chrome); }
937
957
 
938
958
  .sb-scrim {
939
959
  display: block;
@@ -172,8 +172,7 @@ const DOCS_PATH = /(?<![\w./-])docs\/[^\s`)\]]*\.md\b/g
172
172
  * deliberate placeholder rather than a real citation. A body illustrating
173
173
  * the shape a project's own `standards/<name>.md` takes writes a bracketed
174
174
  * token this pattern also matches, such as `standards/<slug>.md` in
175
- * `create-standard/SKILL.md` or `standards/<name>.md` in
176
- * `migration-standards-drop/SKILL.md`, and neither names a real file. A bare,
175
+ * `create-standard/SKILL.md`, which names no real file. A bare,
177
176
  * non-bracketed match now reports whether or not the file exists, unlike
178
177
  * `DOCS_PATH`, which still gates on existence: a same-repository citation
179
178
  * under this corpus names no target project's own tree the way a `docs/`