@erclx/canon 4.112.0 → 4.114.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 (52) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-feedback/SKILL.md +3 -3
  3. package/claude/skills/codebase-layout/SKILL.md +1 -1
  4. package/claude/skills/target-setup/SKILL.md +2 -0
  5. package/claude/skills/target-setup/references/detect.md +2 -2
  6. package/claude/skills/target-setup/references/verify.md +26 -10
  7. package/claude/skills/test-craft/SKILL.md +1 -1
  8. package/docs/agents/commands.md +1 -1
  9. package/docs/agents/counts.md +3 -3
  10. package/docs/agents/install-and-sync.md +2 -0
  11. package/docs/agents/records.md +22 -15
  12. package/docs/agents/teach.md +8 -4
  13. package/docs/target-projects.md +9 -2
  14. package/docs/workflow/ai-workflow.md +1 -1
  15. package/governance/rules/lang/130-go.md +40 -0
  16. package/governance/rules/lang/140-php.md +31 -0
  17. package/governance/rules/lib/335-testing-go.md +35 -0
  18. package/governance/rules/lib/336-testing-php.md +30 -0
  19. package/governance/rules/lib/360-security-server.md +2 -0
  20. package/governance/rules/lib/370-database.md +2 -0
  21. package/governance/rules/ui/410-a11y.md +1 -0
  22. package/governance/rules/ui/440-surface-capture.md +2 -0
  23. package/governance/stacks/nextjs.toml +2 -0
  24. package/governance/stacks/react.toml +1 -1
  25. package/package.json +3 -1
  26. package/src/claude/skills-audit.ts +2 -19
  27. package/src/commands/feedback-format.ts +24 -0
  28. package/src/commands/feedback.ts +2 -1
  29. package/src/commands/records.ts +18 -1
  30. package/src/commands/teach.ts +41 -1
  31. package/src/commands/tooling.ts +25 -2
  32. package/src/design/components.ts +17 -0
  33. package/src/design/tokens.ts +1 -1
  34. package/src/gate/measures.ts +13 -2
  35. package/src/indexes/frontmatter.ts +10 -1
  36. package/src/pr/evidence.ts +3 -1
  37. package/src/records/backup.ts +197 -27
  38. package/src/records/push-guard.ts +71 -0
  39. package/src/teach/nav.ts +75 -25
  40. package/src/teach/workspace.ts +14 -6
  41. package/src/tooling/inject.ts +43 -4
  42. package/src/tooling/scan.ts +35 -0
  43. package/src/tooling/subfolder.ts +110 -0
  44. package/tooling/base/configs/.husky/post-merge +5 -1
  45. package/tooling/base/seeds/cspell.json +1 -1
  46. package/tooling/claude/seeds/.claude/hooks/silent-turn.sh +7 -8
  47. package/tooling/nextjs/configs/vitest.config.ts +1 -1
  48. package/tooling/python/configs/scripts/verify.sh +32 -15
  49. package/tooling/python/manifest.toml +1 -1
  50. package/tooling/python/reference.md +3 -4
  51. package/tooling/vite-react/configs/vite.config.ts +1 -1
  52. package/tooling/web/configs/scripts/verify.sh +32 -15
@@ -1,8 +1,9 @@
1
- import { existsSync, readdirSync } from 'node:fs'
1
+ import { existsSync, lstatSync, readdirSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import { gitEnv } from '@/git-env'
5
- import { RECORD_ROOTS, recordRoot } from '@/record-root'
5
+ import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record-root'
6
+ import { type BlockedPath, guardPayload } from '@/records/push-guard'
6
7
 
7
8
  /**
8
9
  * The legacy `.claude`-root allowlist, read only when a project has not moved
@@ -81,6 +82,11 @@ const RECORDS_GIT_NAME = '.records.git'
81
82
  * every entry already keeps a pull's `README.md` out, so this is the one name
82
83
  * that still has to be said: without it a listing would stage the history
83
84
  * into itself.
85
+ *
86
+ * The list bounds the records index as well as the disk. `scopedFolders`
87
+ * filters the names the index tracks through `isExcluded`, so a name an older
88
+ * binary committed is removed from the history once rather than re-staged on
89
+ * every push by the `-f` that carries the payload.
84
90
  */
85
91
  export const EXCLUDED_ENTRIES = [
86
92
  'tmp',
@@ -88,6 +94,20 @@ export const EXCLUDED_ENTRIES = [
88
94
  RECORDS_GIT_NAME,
89
95
  ] as const
90
96
 
97
+ /**
98
+ * Whether a top-level name stays out of every push, at either root.
99
+ *
100
+ * `SCRATCH` is the legacy `.tmp` spelling, which a records index opened before
101
+ * the move can still carry and which `EXCLUDED_ENTRIES` names at its `.canon`
102
+ * spelling only.
103
+ */
104
+ function isExcluded(name: string): boolean {
105
+ return (
106
+ name === SCRATCH ||
107
+ EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number])
108
+ )
109
+ }
110
+
91
111
  /**
92
112
  * The record folders sitting at `dir` right now, at either root spelling.
93
113
  *
@@ -112,10 +132,7 @@ function foldersAt(dir: string): string[] {
112
132
  return entries
113
133
  .filter((entry) => entry.isDirectory())
114
134
  .map((entry) => entry.name)
115
- .filter(
116
- (name) =>
117
- !EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number]),
118
- )
135
+ .filter((name) => !isExcluded(name))
119
136
  }
120
137
 
121
138
  /**
@@ -197,6 +214,7 @@ export const BACKUP_REFUSALS = [
197
214
  'no-remote-records',
198
215
  'local-changes',
199
216
  'local-ahead',
217
+ 'unsafe-payload',
200
218
  'git-failed',
201
219
  ] as const
202
220
 
@@ -206,6 +224,8 @@ export interface BackupRefused {
206
224
  readonly ok: false
207
225
  readonly reason: BackupRefusal
208
226
  readonly message: string
227
+ /** Every pending path the payload guard refused, carried on `unsafe-payload` only. */
228
+ readonly blocked?: readonly BlockedPath[]
209
229
  }
210
230
 
211
231
  export interface PushReport {
@@ -214,6 +234,10 @@ export interface PushReport {
214
234
  readonly folders: readonly string[]
215
235
  /** A folder in scope this push found on disk but the records index had never tracked. */
216
236
  readonly firstSeen: readonly string[]
237
+ /** Every file this push stages that the records history has never carried. */
238
+ readonly added: readonly string[]
239
+ /** An excluded name the records index still tracked, removed from the history going forward. */
240
+ readonly dropped: readonly string[]
217
241
  readonly changed: number
218
242
  readonly commit?: string
219
243
  readonly pushed: boolean
@@ -450,10 +474,14 @@ function refuseSplitRoots(root: string): BackupRefused | undefined {
450
474
  )
451
475
  }
452
476
 
453
- /** A pathspec-safe subset, plus which of it never entered the records index before. */
477
+ /**
478
+ * A pathspec-safe subset, which of it never entered the records index before,
479
+ * and which excluded names the index still tracks.
480
+ */
454
481
  interface FolderScope {
455
482
  readonly scope: readonly string[]
456
483
  readonly firstSeen: readonly string[]
484
+ readonly dropped: readonly string[]
457
485
  }
458
486
 
459
487
  /**
@@ -476,12 +504,19 @@ interface FolderScope {
476
504
  * tracked, which is the read a caller reports rather than acts on: a folder
477
505
  * `.canon` picked up that should have been excluded is visible in the push
478
506
  * report instead of entering the payload silently.
507
+ *
508
+ * The index half passes through `isExcluded` before the union, since the index
509
+ * is the one side an excluded name can still reach the pathspec through. A
510
+ * name it drops is returned as `dropped` rather than silently forgotten, so the
511
+ * push can remove it from the history once and say so.
479
512
  */
480
513
  async function scopedFolders(root: string): Promise<FolderScope> {
481
514
  const tracked = await records(root, ['ls-files'])
482
- const indexed = new Set(
515
+ const everIndexed = new Set(
483
516
  tracked.ok ? tracked.text.split('\n').filter(Boolean).map(topSegment) : [],
484
517
  )
518
+ const indexed = new Set([...everIndexed].filter((name) => !isExcluded(name)))
519
+ const dropped = [...everIndexed].filter(isExcluded).sort()
485
520
 
486
521
  const present = new Set(foldersAt(workTree(root)))
487
522
  const retired = RETIRED_FOLDERS.filter(
@@ -492,7 +527,7 @@ async function scopedFolders(root: string): Promise<FolderScope> {
492
527
  (folder) => present.has(folder) && !indexed.has(folder),
493
528
  )
494
529
 
495
- return { scope, firstSeen }
530
+ return { scope, firstSeen, dropped }
496
531
  }
497
532
 
498
533
  function topSegment(path: string): string {
@@ -531,12 +566,105 @@ function countLines(text: string): number {
531
566
  return text.split('\n').filter(Boolean).length
532
567
  }
533
568
 
569
+ function splitNul(text: string): string[] {
570
+ return text.split('\0').filter(Boolean)
571
+ }
572
+
573
+ /**
574
+ * The files `add -A -f` is about to stage from `scope`, split into those the
575
+ * history has never carried and every pending one.
576
+ *
577
+ * `ls-files -o` without `--exclude-standard` lists ignored files too, which is
578
+ * what `-f` stages. `-m` also lists a deleted file and `-o` lists an embedded
579
+ * repository as a `dir/` entry, so both are filtered down to regular files,
580
+ * which is all a payload check has bytes to read.
581
+ */
582
+ async function pendingFiles(
583
+ root: string,
584
+ scope: readonly string[],
585
+ ): Promise<{ added: string[]; pending: string[] } | BackupRefused> {
586
+ if (scope.length === 0) return { added: [], pending: [] }
587
+
588
+ const [others, modified] = await Promise.all([
589
+ records(root, ['ls-files', '-z', '-o', '--', ...scope]),
590
+ records(root, ['ls-files', '-z', '-m', '--', ...scope]),
591
+ ])
592
+ if (!others.ok) return failed('ls-files', others)
593
+ if (!modified.ok) return failed('ls-files', modified)
594
+
595
+ const tree = workTree(root)
596
+ const isRegularFile = (path: string): boolean => {
597
+ try {
598
+ return lstatSync(join(tree, path)).isFile()
599
+ } catch {
600
+ return false
601
+ }
602
+ }
603
+
604
+ const added = splitNul(others.text).filter(isRegularFile).sort()
605
+ const pending = [
606
+ ...new Set([...added, ...splitNul(modified.text).filter(isRegularFile)]),
607
+ ].sort()
608
+
609
+ return { added, pending }
610
+ }
611
+
612
+ function refuseBlocked(blocked: readonly BlockedPath[]): BackupRefused {
613
+ return {
614
+ ...refuse(
615
+ 'unsafe-payload',
616
+ [
617
+ `${blocked.length} record(s) cannot be backed up, so nothing was staged or pushed:`,
618
+ ...blocked.map(
619
+ (finding) =>
620
+ ` ${finding.path} (${finding.cause}: ${finding.detail})`,
621
+ ),
622
+ 'Move each out of the record folders, such as into .canon/tmp/, or remove the credential, then push again.',
623
+ ].join('\n'),
624
+ ),
625
+ blocked,
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Puts HEAD and the index back where they stood before this run committed.
631
+ *
632
+ * A mixed reset to the recorded HEAD rather than `reset --soft HEAD~1`. A soft
633
+ * reset leaves a dropped name's deletion staged while the next run's index read
634
+ * no longer lists the name, so that run's diff never sees the deletion and it
635
+ * is never committed. The recorded HEAD also covers the root commit, where
636
+ * `HEAD~1` does not exist and only removing the ref restores an unborn branch.
637
+ *
638
+ * Returns an error sentence when the undo itself failed, so the caller appends
639
+ * it rather than reporting a clean history that is not.
640
+ */
641
+ async function undoCommit(
642
+ root: string,
643
+ priorHead: string,
644
+ ): Promise<string | undefined> {
645
+ if (priorHead.length > 0) {
646
+ const reset = await records(root, ['reset', '--quiet', priorHead])
647
+ return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
648
+ }
649
+
650
+ const unref = await records(root, ['update-ref', '-d', 'HEAD'])
651
+ if (!unref.ok) return `Undoing it failed: ${unref.stderr}.`
652
+ const reset = await records(root, ['reset', '--quiet'])
653
+ return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
654
+ }
655
+
534
656
  /**
535
657
  * Stages the backed folders, commits when any of them changed, and pushes.
536
658
  *
537
- * The push runs whether or not this call committed, because a previous run can
538
- * have committed and then failed to reach the network. Skipping it would leave
539
- * that commit on one disk, which is the state the whole verb exists to end.
659
+ * The payload guard reads every pending file ahead of the stage, so a refusal
660
+ * leaves the index and the object store exactly as it found them. It reads what
661
+ * this run would add, never a commit already in the history.
662
+ *
663
+ * The push runs whether or not this call committed, because an older binary
664
+ * can have committed and then failed to reach the network. Skipping it would
665
+ * leave that commit on one disk, which is the state the whole verb exists to
666
+ * end. A commit this call made is undone when the push fails, so a remote that
667
+ * rejects the payload is not handed the same commit again on every later run.
540
668
  */
541
669
  export async function pushRecords(root: string): Promise<PushOutcome> {
542
670
  const split = refuseSplitRoots(root)
@@ -546,27 +674,56 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
546
674
  const remote = await resolveRemote(root, enclosing)
547
675
  if (typeof remote !== 'string') return remote
548
676
 
549
- const { scope, firstSeen } = await scopedFolders(root)
677
+ const { scope, firstSeen, dropped } = await scopedFolders(root)
678
+
679
+ const files = await pendingFiles(root, scope)
680
+ if ('ok' in files) return files
681
+
682
+ const blocked = await guardPayload(workTree(root), files.pending)
683
+ if (blocked.length > 0) return refuseBlocked(blocked)
550
684
 
551
685
  if (scope.length > 0) {
552
686
  // `-f` is what carries the payload: every backed folder is ignored by the
553
687
  // enclosing repository, and the pathspecs are the whole list, so nothing
554
- // outside them can enter the index however the ignore rules read.
688
+ // outside them can enter the index however the ignore rules read. An
689
+ // excluded name never reaches the list, whatever the records index holds.
555
690
  const staged = await records(root, ['add', '-A', '-f', '--', ...scope])
556
691
  if (!staged.ok) return failed('add', staged)
557
692
  }
558
693
 
694
+ if (dropped.length > 0) {
695
+ const removed = await records(root, [
696
+ 'rm',
697
+ '-r',
698
+ '--cached',
699
+ '--quiet',
700
+ '--',
701
+ ...dropped,
702
+ ])
703
+ if (!removed.ok) return failed('rm', removed)
704
+ }
705
+
706
+ // Scope alone would miss the dropped deletions, which sit outside it.
559
707
  const diff = await records(root, [
560
708
  'diff',
561
709
  '--cached',
562
710
  '--name-only',
563
711
  '--',
564
712
  ...scope,
713
+ ...dropped,
565
714
  ])
566
715
  if (!diff.ok) return failed('diff', diff)
567
716
 
568
717
  const changed = countLines(diff.text)
569
718
 
719
+ const prior = await records(root, [
720
+ 'rev-parse',
721
+ '--verify',
722
+ '--quiet',
723
+ 'HEAD',
724
+ ])
725
+ const priorHead = prior.ok ? prior.text : ''
726
+
570
727
  if (changed > 0) {
571
728
  const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16)
572
729
  const commit = await records(root, [
@@ -579,11 +736,17 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
579
736
  if (!commit.ok) return failed('commit', commit)
580
737
  }
581
738
 
582
- const folders = presentFolders(root)
739
+ const report = {
740
+ ok: true,
741
+ root,
742
+ folders: presentFolders(root),
743
+ firstSeen,
744
+ added: files.added,
745
+ dropped,
746
+ changed,
747
+ } as const
583
748
  const head = await records(root, ['rev-parse', '--short', 'HEAD'])
584
- if (!head.ok) {
585
- return { ok: true, root, folders, firstSeen, changed, pushed: false }
586
- }
749
+ if (!head.ok) return { ...report, pushed: false }
587
750
 
588
751
  const branch = await projectBranch(root, enclosing)
589
752
  const pushed = await records(root, [
@@ -591,17 +754,24 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
591
754
  'origin',
592
755
  `HEAD:refs/heads/${branch}`,
593
756
  ])
594
- if (!pushed.ok) return failed('push', pushed)
757
+ if (!pushed.ok) {
758
+ const refused = failed('push', pushed)
759
+ if (changed === 0) return refused
595
760
 
596
- return {
597
- ok: true,
598
- root,
599
- folders,
600
- firstSeen,
601
- changed,
602
- commit: head.text,
603
- pushed: true,
761
+ const undoError = await undoCommit(root, priorHead)
762
+ return refuse(
763
+ 'git-failed',
764
+ [
765
+ refused.message,
766
+ 'The commit this run made was undone, and the records are still on disk.',
767
+ undoError,
768
+ ]
769
+ .filter(Boolean)
770
+ .join(' '),
771
+ )
604
772
  }
773
+
774
+ return { ...report, commit: head.text, pushed: true }
605
775
  }
606
776
 
607
777
  /**
@@ -0,0 +1,71 @@
1
+ import { lstat, readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { isBinary } from '@/binary'
4
+ import { scanText } from '@/secrets/scan'
5
+
6
+ /**
7
+ * The largest single record a push carries, in bytes.
8
+ *
9
+ * A constant rather than a flag. The largest record measured in this
10
+ * repository is a 6.7 MB git pack under a groundwork fixture and GitHub
11
+ * rejects a blob at 100 MB, so this sits well clear of both, and an override
12
+ * is one nobody remembers to pass the first time a backup tarball lands in a
13
+ * record folder.
14
+ */
15
+ export const MAX_RECORD_BYTES = 25 * 1024 * 1024
16
+
17
+ export interface BlockedPath {
18
+ readonly path: string
19
+ readonly cause: 'oversized' | 'credential'
20
+ /** A size or `<label> at line <n>`, never the matched value or a preview of it. */
21
+ readonly detail: string
22
+ }
23
+
24
+ function megabytes(bytes: number): string {
25
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
26
+ }
27
+
28
+ /**
29
+ * Every pending path a push must not carry, in the order given.
30
+ *
31
+ * Reads the size off `lstat` first so an oversized file is never opened, and
32
+ * skips anything that is not a regular file, since `add` stages a symlink as
33
+ * its target path rather than the bytes it points at. The credential half is
34
+ * `scanText` unchanged, so the patterns and the `canon-allow-secret` marker
35
+ * answer here exactly as they do in `canon secrets scan`.
36
+ */
37
+ export async function guardPayload(
38
+ tree: string,
39
+ paths: readonly string[],
40
+ ): Promise<BlockedPath[]> {
41
+ const blocked: BlockedPath[] = []
42
+
43
+ for (const path of paths) {
44
+ const full = join(tree, path)
45
+ const stats = await lstat(full).catch(() => undefined)
46
+ if (!stats?.isFile()) continue
47
+
48
+ if (stats.size > MAX_RECORD_BYTES) {
49
+ blocked.push({
50
+ path,
51
+ cause: 'oversized',
52
+ detail: `${megabytes(stats.size)}, over the ${megabytes(MAX_RECORD_BYTES)} ceiling`,
53
+ })
54
+ continue
55
+ }
56
+
57
+ const text = await readFile(full, 'utf8').catch(() => undefined)
58
+ if (text === undefined || isBinary(text)) continue
59
+
60
+ const [first] = scanText(path, text)
61
+ if (first) {
62
+ blocked.push({
63
+ path,
64
+ cause: 'credential',
65
+ detail: `${first.label} at line ${first.line}`,
66
+ })
67
+ }
68
+ }
69
+
70
+ return blocked
71
+ }
package/src/teach/nav.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  TEACH_MISSION,
19
19
  TEACH_REFERENCE,
20
20
  TEACH_STYLESHEET,
21
+ TEACH_STYLESHEET_BASE,
21
22
  teachDir,
22
23
  type TeachRefused,
23
24
  type WorkspaceDetail,
@@ -35,12 +36,23 @@ const BRAND_MARK = 'assets/brand/mark.svg'
35
36
  */
36
37
  const HAND_WRITTEN_ICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='10 10 80 80'%3E%3Cpath d='M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z' fill='rgb(224,114,75)' /%3E%3Crect x='44' y='15' width='12' height='70' rx='2' fill='rgb(224,114,75)' /%3E%3C/svg%3E" />`
37
38
 
38
- function dropHandWrittenIcon(html: string): string {
39
- return html
40
- .split('\n')
41
- .filter((line) => line.trim() !== HAND_WRITTEN_ICON)
42
- .join('\n')
43
- .replaceAll(HAND_WRITTEN_ICON, '')
39
+ /**
40
+ * The stylesheet link lessons used to carry by hand outside every region. The
41
+ * `style` region now links the base sheet itself, so keeping this one would
42
+ * load the base twice.
43
+ */
44
+ const HAND_WRITTEN_STYLESHEET = `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET}" />`
45
+
46
+ const HAND_WRITTEN_HEAD = [HAND_WRITTEN_ICON, HAND_WRITTEN_STYLESHEET]
47
+
48
+ function dropHandWrittenHead(html: string): string {
49
+ return HAND_WRITTEN_HEAD.reduce(
50
+ (text, link) => text.replaceAll(link, ''),
51
+ html
52
+ .split('\n')
53
+ .filter((line) => !HAND_WRITTEN_HEAD.includes(line.trim()))
54
+ .join('\n'),
55
+ )
44
56
  }
45
57
 
46
58
  /**
@@ -504,6 +516,11 @@ function titleCase(slug: string): string {
504
516
  ].join(' ')
505
517
  }
506
518
 
519
+ /** The mission's own title, falling back to the slug where it names none. */
520
+ function workspaceLabel(workspace: WorkspaceSummary): string {
521
+ return workspace.title ?? titleCase(workspace.topic)
522
+ }
523
+
507
524
  interface JumpEntry {
508
525
  readonly ordinal: string
509
526
  readonly label: string
@@ -629,7 +646,7 @@ function renderSidebar(sidebar: Sidebar): string {
629
646
 
630
647
  return `<aside class="sb">
631
648
  <div class="sb-top">
632
- <details class="sb-ws" name="mast"><summary><span class="ws-name">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
649
+ <details class="sb-ws" name="mast"><summary><span class="ws-name" title="${escapeHtml(sidebar.heading)}">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
633
650
  </div>
634
651
  <div class="sb-scroll">
635
652
  <div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
@@ -724,7 +741,7 @@ function workspaceJumpEntries(
724
741
  ): JumpEntry[] {
725
742
  return workspaces.map((workspace) => ({
726
743
  ordinal: ordinalOf(workspace),
727
- label: titleCase(workspace.topic),
744
+ label: workspaceLabel(workspace),
728
745
  href: hrefForWorkspace(workspace, prefix),
729
746
  at: workspace.slug === currentSlug,
730
747
  trailing: String(workspace.lessons),
@@ -935,7 +952,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
935
952
  meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
936
953
  items: workspaces.map((workspace) => ({
937
954
  ordinal: ordinalOf(workspace),
938
- label: titleCase(workspace.topic),
955
+ label: workspaceLabel(workspace),
939
956
  href: hrefForWorkspace(workspace, '') ?? '#',
940
957
  at: false,
941
958
  })),
@@ -951,7 +968,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
951
968
  ? 'Live'
952
969
  : 'Open'
953
970
  const blurb = `${workspace.lessons} lesson(s) &middot; ${workspace.reference} reference page(s) &middot; ${workspace.terms} term(s)`
954
- const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(titleCase(workspace.topic))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
971
+ const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(workspaceLabel(workspace))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
955
972
 
956
973
  return href === undefined
957
974
  ? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
@@ -1014,7 +1031,7 @@ async function renderContentsPage(
1014
1031
  )
1015
1032
  : undefined
1016
1033
 
1017
- const title = detail.title ?? titleCase(detail.topic)
1034
+ const title = workspaceLabel(detail)
1018
1035
 
1019
1036
  const segments: CrumbSegment[] = [
1020
1037
  {
@@ -1026,7 +1043,7 @@ async function renderContentsPage(
1026
1043
  },
1027
1044
  },
1028
1045
  {
1029
- label: titleCase(detail.topic),
1046
+ label: workspaceLabel(detail),
1030
1047
  jump: {
1031
1048
  ariaLabel: 'Jump to a lesson',
1032
1049
  entries: lessonJumpEntries(metas, '', undefined),
@@ -1035,7 +1052,7 @@ async function renderContentsPage(
1035
1052
  ]
1036
1053
 
1037
1054
  const sidebar: Sidebar = {
1038
- heading: titleCase(detail.topic),
1055
+ heading: workspaceLabel(detail),
1039
1056
  switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
1040
1057
  meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1041
1058
  items: metas.map((meta, index) => ({
@@ -1139,21 +1156,44 @@ interface LessonRefused {
1139
1156
  readonly missing: Region
1140
1157
  }
1141
1158
 
1142
- /**
1143
- * Splices the four chrome regions into one lesson file, in a fixed order so a
1144
- * missing marker is always reported against the same region a session can
1145
- * check first. Nothing is written when any region is missing, which is what
1146
- * keeps a partially-spliced file off disk.
1147
- */
1148
1159
  /**
1149
1160
  * A lesson embeds the workspace stylesheet, and an `@import` resolves against
1150
1161
  * the lesson's own folder there rather than the assets folder, so it never
1151
- * loaded anything. The base sheet reaches the page through the `<link>`.
1162
+ * loaded anything. The base sheet reaches the page through the `<link>` the
1163
+ * `style` region carries ahead of the embedded rules.
1152
1164
  */
1153
1165
  function stripImports(css: string): string {
1154
1166
  return css.replace(/^@import[^;]*;[ \t]*\n?/gm, '')
1155
1167
  }
1156
1168
 
1169
+ const HEADER_CLOSE = '<!-- /canon:teach:header -->'
1170
+ const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
1171
+
1172
+ /**
1173
+ * The authored body between the header and footnav regions, wrapped in
1174
+ * `<main>` when it holds none. The skill has a session write the body bare,
1175
+ * and the column width hangs off `main`. A body already holding one is left
1176
+ * alone, which keeps a second run byte-identical.
1177
+ */
1178
+ function wrapMain(html: string): string {
1179
+ const start = html.indexOf(HEADER_CLOSE)
1180
+ const end = html.indexOf(FOOTNAV_OPEN, start)
1181
+ if (start === -1 || end === -1) return html
1182
+
1183
+ const bodyStart = start + HEADER_CLOSE.length
1184
+ const body = html.slice(bodyStart, end)
1185
+ if (/<main[\s>]/.test(body)) return html
1186
+
1187
+ return `${html.slice(0, bodyStart)}\n<main>\n${body.trim()}\n</main>\n${html.slice(end)}`
1188
+ }
1189
+
1190
+ /**
1191
+ * Splices the four chrome regions into one lesson file, in a fixed order so a
1192
+ * missing marker is always reported against the same region a session can
1193
+ * check first, then wraps the authored body in `<main>` where it holds none.
1194
+ * Nothing is written when any region is missing, which is what keeps a
1195
+ * partially-spliced file off disk.
1196
+ */
1157
1197
  async function rewriteLesson(
1158
1198
  root: string,
1159
1199
  detail: WorkspaceDetail,
@@ -1164,7 +1204,7 @@ async function rewriteLesson(
1164
1204
  ): Promise<LessonRewritten | LessonRefused> {
1165
1205
  const file = metas[index].file
1166
1206
  const path = join(root, detail.path, TEACH_LESSONS, file)
1167
- let html = dropHandWrittenIcon(await readFile(path, 'utf8'))
1207
+ let html = dropHandWrittenHead(await readFile(path, 'utf8'))
1168
1208
 
1169
1209
  const teachPrefix = '../../'
1170
1210
  const workspacePrefix = '../'
@@ -1180,7 +1220,7 @@ async function rewriteLesson(
1180
1220
  },
1181
1221
  },
1182
1222
  {
1183
- label: titleCase(detail.topic),
1223
+ label: workspaceLabel(detail),
1184
1224
  href: `${workspacePrefix}index.html`,
1185
1225
  jump: {
1186
1226
  ariaLabel: 'Jump to a lesson',
@@ -1190,7 +1230,7 @@ async function rewriteLesson(
1190
1230
  { label: `Lesson ${index + 1} of ${metas.length}` },
1191
1231
  ],
1192
1232
  {
1193
- heading: titleCase(detail.topic),
1233
+ heading: workspaceLabel(detail),
1194
1234
  switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1195
1235
  meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1196
1236
  items: metas.map((meta, i) => ({
@@ -1210,6 +1250,16 @@ async function rewriteLesson(
1210
1250
  // the class the script reveals feedback with.
1211
1251
  const legacy = html.includes(LEGACY_OPTION)
1212
1252
 
1253
+ // Linked ahead of the embedded rules so those still win the cascade.
1254
+ // Embedding it instead repeats a sheet carrying its fonts inline in every
1255
+ // lesson. A workspace seeded before the pair has no base sheet to link.
1256
+ const hasBase = existsSync(
1257
+ join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET_BASE),
1258
+ )
1259
+ const baseLink = hasBase
1260
+ ? `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET_BASE}">\n`
1261
+ : ''
1262
+
1213
1263
  const regions: ReadonlyArray<readonly [Region, string]> = [
1214
1264
  // The stepper follows the workspace stylesheet so it wins the cascade at
1215
1265
  // equal specificity, which is what reaches a workspace seeded before it.
@@ -1218,7 +1268,7 @@ async function rewriteLesson(
1218
1268
  // lesson would lack.
1219
1269
  [
1220
1270
  'style',
1221
- `${teachFavicon()}\n<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1271
+ `${teachFavicon()}\n${baseLink}<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1222
1272
  ],
1223
1273
  ['header', header],
1224
1274
  ['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
@@ -1231,7 +1281,7 @@ async function rewriteLesson(
1231
1281
  html = spliced
1232
1282
  }
1233
1283
 
1234
- await writeFile(path, html)
1284
+ await writeFile(path, wrapMain(html))
1235
1285
  return { ok: true, file }
1236
1286
  }
1237
1287
 
@@ -579,8 +579,8 @@ function missionText(
579
579
 
580
580
  return [
581
581
  '---',
582
- `title: ${title}`,
583
- `description: ${request.subject}`,
582
+ `title: ${yamlValue(title)}`,
583
+ `description: ${yamlValue(request.subject)}`,
584
584
  `date: ${date}`,
585
585
  '---',
586
586
  '',
@@ -606,8 +606,8 @@ function missionText(
606
606
  function resourcesText(title: string): string {
607
607
  return [
608
608
  '---',
609
- `title: Sources for ${title}`,
610
- `description: Sources read for ${title}, and leads found but not opened`,
609
+ `title: ${yamlValue(`Sources for ${title}`)}`,
610
+ `description: ${yamlValue(`Sources read for ${title}, and leads found but not opened`)}`,
611
611
  '---',
612
612
  '',
613
613
  `# Sources for ${title}`,
@@ -628,8 +628,8 @@ function resourcesText(title: string): string {
628
628
  function glossaryText(title: string): string {
629
629
  return [
630
630
  '---',
631
- `title: Glossary for ${title}`,
632
- `description: Terms ${title} defines, one entry each`,
631
+ `title: ${yamlValue(`Glossary for ${title}`)}`,
632
+ `description: ${yamlValue(`Terms ${title} defines, one entry each`)}`,
633
633
  '---',
634
634
  '',
635
635
  `# Glossary for ${title}`,
@@ -641,6 +641,14 @@ function glossaryText(title: string): string {
641
641
  ].join('\n')
642
642
  }
643
643
 
644
+ /**
645
+ * Serializes a frontmatter value, quoting only what YAML would otherwise read
646
+ * as something other than the string, so a plain title stays byte-identical.
647
+ */
648
+ function yamlValue(value: string): string {
649
+ return Bun.YAML.stringify(value)
650
+ }
651
+
644
652
  /** Today as `YYYY-MM-DD`, which is the one field the mission dates. */
645
653
  function today(): string {
646
654
  return new Date().toISOString().slice(0, DATE_LENGTH)