@erclx/canon 4.75.1 → 4.77.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.
@@ -0,0 +1,475 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { join, relative, sep } from 'node:path'
11
+ import { DESIGN_DOCUMENT } from '@/design/regen'
12
+ import { renderDesignDoc } from '@/design/render'
13
+ import { colorValue } from '@/design/tokens'
14
+ import { recordDir } from '@/record-root'
15
+
16
+ /**
17
+ * The one named site for this repository's wireframe corpus. `standards/`
18
+ * moves it to `canon/wireframes/` under the answered
19
+ * `.canon/intake/88-surface-roots-and-corpus-debt/` item 1, and that move
20
+ * retargets this constant alone rather than a literal repeated per panel.
21
+ */
22
+ export const WIREFRAME_DIR = join('.claude', 'wireframes')
23
+
24
+ /** Landing page for the built site the surfaces panel iframes when present. */
25
+ const WEB_DIST = join('web', 'dist')
26
+ const WEB_DIST_ENTRY = 'index.html'
27
+
28
+ /**
29
+ * The gallery build, generated by `bun run web:gallery` through
30
+ * `web/gallery.config.mjs`. That config's own `srcDir` is `web/gallery-src`,
31
+ * which `web:build`'s config never reads, so this output never reaches
32
+ * `web/dist`.
33
+ */
34
+ const WEB_GALLERY_DIST = join('web', 'gallery-dist')
35
+ const WEB_GALLERY_DIST_ENTRY = 'index.html'
36
+
37
+ interface WireframeEntry {
38
+ readonly path: string
39
+ readonly describes: string
40
+ }
41
+
42
+ /**
43
+ * The six files the wireframes panel renders, each beside the surface it
44
+ * describes. `index.md` at either level is a catalog rather than a wireframe
45
+ * and is excluded, which is why this list holds six rather than the eight
46
+ * files the corpus carries today.
47
+ */
48
+ const WIREFRAMES: readonly WireframeEntry[] = [
49
+ { path: 'landing-page.md', describes: 'The canon.erclx.dev landing page' },
50
+ { path: 'slides.md', describes: 'The SLIDES.md render' },
51
+ { path: 'teach/root.md', describes: 'A teach workspace root listing' },
52
+ { path: 'teach/contents.md', describes: 'A workspace contents page' },
53
+ { path: 'teach/lesson.md', describes: 'A lesson page and quiz stepper' },
54
+ { path: 'teach/chrome.md', describes: 'The shared teach chrome' },
55
+ ]
56
+
57
+ const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
58
+
59
+ export interface BoardPanel {
60
+ readonly id: string
61
+ readonly title: string
62
+ /** Relative to the board's own output directory. */
63
+ readonly path: string
64
+ }
65
+
66
+ export interface BoardResult {
67
+ readonly ok: true
68
+ readonly outDir: string
69
+ readonly indexPath: string
70
+ readonly panels: readonly BoardPanel[]
71
+ }
72
+
73
+ export interface BoardRefused {
74
+ readonly ok: false
75
+ readonly reason: 'unsafe-out'
76
+ readonly detail: string
77
+ }
78
+
79
+ export type BoardOutcome = BoardResult | BoardRefused
80
+
81
+ /**
82
+ * Whether clearing `outDir` would take a protected directory down with it:
83
+ * the directory equals one of them, or contains one.
84
+ *
85
+ * `generateBoard` clears its output directory on every run, and `--out` is
86
+ * resolved against the caller's cwd while the panels read from `PROJECT_ROOT`.
87
+ * Those agree in the ordinary case and diverge in exactly one: a second
88
+ * checkout, where a global `canon` resolves `PROJECT_ROOT` to a different
89
+ * tree than the one the caller stands in. Guarding `PROJECT_ROOT` alone misses
90
+ * that case, since `--out .` then resolves under the caller's own cwd, which
91
+ * shares no containment with the unrelated root the guard compared it to.
92
+ */
93
+ function wouldDeleteRoot(protect: readonly string[], outDir: string): boolean {
94
+ return protect.some((dir) => dir === outDir || dir.startsWith(outDir + sep))
95
+ }
96
+
97
+ function escapeHtml(value: string): string {
98
+ return value
99
+ .replace(/&/g, '&')
100
+ .replace(/</g, '&lt;')
101
+ .replace(/>/g, '&gt;')
102
+ .replace(/"/g, '&quot;')
103
+ }
104
+
105
+ const THEME_TOGGLE_BUTTON = `<button class="theme-toggle" type="button" aria-label="Switch between light and dark"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>`
106
+
107
+ const THEME_TOGGLE_SCRIPT = [
108
+ '<script>(function(){',
109
+ 'var r=document.documentElement;',
110
+ 'try{var s=localStorage.getItem("board-theme");if(s)r.dataset.theme=s;}catch(e){}',
111
+ 'document.addEventListener("click",function(e){',
112
+ 'var b=e.target.closest(".theme-toggle");if(!b)return;',
113
+ 'var dark=r.dataset.theme==="dark"||(!r.dataset.theme&&matchMedia("(prefers-color-scheme: dark)").matches);',
114
+ 'r.dataset.theme=dark?"light":"dark";',
115
+ 'try{localStorage.setItem("board-theme",r.dataset.theme);}catch(e){}',
116
+ '});',
117
+ '})();</script>',
118
+ ].join('')
119
+
120
+ /**
121
+ * Highlights the nav link for whichever section fills the most of the
122
+ * viewport, following the same largest-intersection-ratio rule the landing
123
+ * page's own floating pill uses, so a reader scrolling through the four
124
+ * panels sees the pill track their position rather than sitting static.
125
+ */
126
+ const SCROLLSPY_SCRIPT = [
127
+ '<script>(function(){',
128
+ 'var links={};',
129
+ 'document.querySelectorAll("nav a[href^=\\"#\\"]").forEach(function(a){links[a.getAttribute("href").slice(1)]=a;});',
130
+ 'var ratios={};',
131
+ 'var current="";',
132
+ 'var observer=new IntersectionObserver(function(entries){',
133
+ 'entries.forEach(function(entry){ratios[entry.target.id]=entry.isIntersecting?entry.intersectionRatio:0;});',
134
+ 'var bestId="";',
135
+ 'var bestRatio=0;',
136
+ 'Object.keys(ratios).forEach(function(id){if(ratios[id]>bestRatio){bestRatio=ratios[id];bestId=id;}});',
137
+ 'if(bestId&&bestId!==current){',
138
+ 'if(links[current])links[current].classList.remove("active");',
139
+ 'current=bestId;',
140
+ 'if(links[current])links[current].classList.add("active");',
141
+ '}',
142
+ '},{threshold:[0,0.25,0.5,0.75,1]});',
143
+ 'Object.keys(links).forEach(function(id){',
144
+ 'var section=document.getElementById(id);',
145
+ 'if(section)observer.observe(section);',
146
+ '});',
147
+ '})();</script>',
148
+ ].join('')
149
+
150
+ /**
151
+ * The same brand mark `src/design/render.ts` embeds in its own preview,
152
+ * colored with the dark accent since the board's default chrome is dark
153
+ * where that preview's is light. Duplicated here rather than imported,
154
+ * following that module's own precedent of three independently-colored
155
+ * copies rather than a shared icon helper for the first second caller.
156
+ */
157
+ function faviconLink(): string {
158
+ const color = colorValue('accent') ?? '#e0724b'
159
+ const href = `data:image/svg+xml,${encodeURIComponent(
160
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80"><path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="${color}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${color}" /></svg>`,
161
+ )}`
162
+ return `<link rel="icon" href="${href}">`
163
+ }
164
+
165
+ /** The board shell's own chrome, read off the toolkit's design source. */
166
+ function shellChrome(): string {
167
+ const roles: ReadonlyArray<readonly [string, string]> = [
168
+ ['background', 'background'],
169
+ ['surface', 'surface'],
170
+ ['text', 'text'],
171
+ ['muted', 'muted'],
172
+ ['border', 'border'],
173
+ ['accent', 'accent'],
174
+ ]
175
+ const dark = roles
176
+ .map(([name, role]) => {
177
+ const value = colorValue(role)
178
+ return value === undefined ? '' : ` --board-${name}: ${value};`
179
+ })
180
+ .filter((line) => line !== '')
181
+ const light = roles
182
+ .map(([name, role]) => {
183
+ const value = colorValue(`light-${role}`)
184
+ return value === undefined ? '' : ` --board-${name}: ${value};`
185
+ })
186
+ .filter((line) => line !== '')
187
+
188
+ return `:root {\n${dark.join('\n')}\n}\n[data-theme='light'] {\n${light.join('\n')}\n}`
189
+ }
190
+
191
+ function shellHtml(panels: readonly BoardPanel[]): string {
192
+ const navLinks = panels
193
+ .map((panel) => `<a href="#${panel.id}">${escapeHtml(panel.title)}</a>`)
194
+ .join('')
195
+
196
+ const sections = panels
197
+ .map(
198
+ (panel, index) =>
199
+ `<section id="${panel.id}">\n<div class="eyebrow">Panel 0${index + 1}</div>\n<h2>${escapeHtml(panel.title)}</h2>\n<iframe src="${panel.path}" loading="lazy"></iframe>\n</section>`,
200
+ )
201
+ .join('\n')
202
+
203
+ return `<!doctype html>
204
+ <html lang="en">
205
+ <head>
206
+ <meta charset="utf-8">
207
+ ${faviconLink()}
208
+ <title>Design board</title>
209
+ <style>
210
+ ${shellChrome()}
211
+ * { box-sizing: border-box; }
212
+ body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; color: var(--board-text); background: var(--board-background); }
213
+ header { padding: 2.5rem 3rem 1.5rem; }
214
+ h1 { margin: 0 0 0.4rem; font-size: 1.6rem; font-weight: 600; letter-spacing: -0.01em; }
215
+ .meta { color: var(--board-muted); font-size: 0.85rem; margin: 0; }
216
+ nav { position: fixed; top: 1.25rem; left: 50%; transform: translateX(-50%); z-index: 1; display: flex; align-items: center; gap: 1.25rem; padding: 0.5rem 0.6rem 0.5rem 1.1rem; background: var(--board-surface); border: 1px solid var(--board-border); border-radius: 999px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); white-space: nowrap; }
217
+ nav a { color: var(--board-muted); text-decoration: none; font-size: 0.8rem; }
218
+ nav a:hover { color: var(--board-accent); }
219
+ nav a.active { color: var(--board-accent); }
220
+ .theme-toggle { display: flex; align-items: center; justify-content: center; width: 1.8rem; height: 1.8rem; background: var(--board-background); color: var(--board-text); border: 1px solid var(--board-border); border-radius: 50%; padding: 0; cursor: pointer; }
221
+ .theme-toggle .sun { display: none; }
222
+ .theme-toggle .moon { display: block; }
223
+ [data-theme='light'] .theme-toggle .sun { display: block; }
224
+ [data-theme='light'] .theme-toggle .moon { display: none; }
225
+ .theme-toggle svg { width: 0.95rem; height: 0.95rem; }
226
+ main { padding: 0 3rem 4.5rem; }
227
+ section { margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--board-border); scroll-margin-top: 4.5rem; }
228
+ .eyebrow { color: var(--board-accent); font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.35rem; }
229
+ section h2 { margin: 0 0 0.75rem; font-size: 1.1rem; font-weight: 600; }
230
+ iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); border-radius: 4px; background: var(--board-surface); }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <header>
235
+ <h1>Design board</h1>
236
+ <p class="meta">Generated by <code>canon design board</code>. Repository-local, never installed into a target.</p>
237
+ </header>
238
+ <nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
239
+ <main>
240
+ ${sections}
241
+ </main>
242
+ ${THEME_TOGGLE_SCRIPT}
243
+ ${SCROLLSPY_SCRIPT}
244
+ </body>
245
+ </html>
246
+ `
247
+ }
248
+
249
+ function panelPage(title: string, body: string): string {
250
+ const accent = colorValue('light-accent') ?? '#a4471c'
251
+ const surface = colorValue('light-surface') ?? '#f4efe6'
252
+ const border = colorValue('light-border') ?? '#e4dcd0'
253
+ const muted = colorValue('light-muted') ?? '#726b62'
254
+ const text = colorValue('light-text') ?? '#1a1815'
255
+ const background = colorValue('light-background') ?? '#faf7f2'
256
+
257
+ return `<!doctype html>
258
+ <html lang="en">
259
+ <head>
260
+ <meta charset="utf-8">
261
+ ${faviconLink()}
262
+ <title>${escapeHtml(title)}</title>
263
+ <style>
264
+ * { box-sizing: border-box; }
265
+ body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; padding: 1.75rem 2.25rem; color: ${text}; background: ${background}; }
266
+ h2 { margin: 2rem 0 0.6rem; padding-top: 1.1rem; border-top: 1px solid ${border}; font-size: 1rem; font-weight: 600; }
267
+ h2:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
268
+ p { color: ${muted}; font-size: 0.85rem; }
269
+ table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
270
+ td, th { padding: 0.4rem 0.6rem; border-bottom: 1px solid ${border}; text-align: left; }
271
+ pre { white-space: pre-wrap; background: ${surface}; border-radius: 4px; padding: 1rem; font-size: 0.8rem; }
272
+ img { border-radius: 4px; border: 1px solid ${border}; margin-top: 0.5rem; }
273
+ a { color: ${accent}; }
274
+ .empty { color: ${muted}; font-style: italic; background: ${surface}; border-radius: 4px; padding: 0.9rem 1rem; }
275
+ </style>
276
+ </head>
277
+ <body>
278
+ ${body}
279
+ </body>
280
+ </html>
281
+ `
282
+ }
283
+
284
+ function writeTokensPanel(root: string, outDir: string): void {
285
+ const sourcePath = join(root, DESIGN_DOCUMENT)
286
+ const dir = join(outDir, 'tokens')
287
+
288
+ if (!existsSync(sourcePath)) {
289
+ mkdirSync(dir, { recursive: true })
290
+ writeFileSync(
291
+ join(dir, 'index.html'),
292
+ panelPage(
293
+ 'Tokens',
294
+ `<p class="empty">No ${DESIGN_DOCUMENT} at the repository root.</p>`,
295
+ ),
296
+ )
297
+ return
298
+ }
299
+
300
+ renderDesignDoc(sourcePath, dir)
301
+ }
302
+
303
+ function writeWireframesPanel(root: string, outDir: string): void {
304
+ const dir = join(outDir, 'wireframes')
305
+ mkdirSync(dir, { recursive: true })
306
+
307
+ const sections = WIREFRAMES.map((entry) => {
308
+ const sourcePath = join(root, WIREFRAME_DIR, entry.path)
309
+ if (!existsSync(sourcePath)) {
310
+ return `<h2>${escapeHtml(entry.path)}</h2>\n<p class="empty">Missing from ${WIREFRAME_DIR}/.</p>`
311
+ }
312
+ const text = readFileSync(sourcePath, 'utf8')
313
+ return `<h2>${escapeHtml(entry.path)}</h2>\n<p>${escapeHtml(entry.describes)}</p>\n<pre>${escapeHtml(text)}</pre>`
314
+ }).join('\n')
315
+
316
+ writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
317
+ }
318
+
319
+ /** Copies a built directory whole, filtering nothing, into the board's own tree. */
320
+ function copyBuilt(source: string, dest: string): void {
321
+ rmSync(dest, { recursive: true, force: true })
322
+ cpSync(source, dest, { recursive: true })
323
+ }
324
+
325
+ function writeSurfacesPanel(root: string, outDir: string): void {
326
+ const dir = join(outDir, 'surfaces')
327
+ mkdirSync(dir, { recursive: true })
328
+
329
+ const distSource = join(root, WEB_DIST)
330
+ const landingBody = existsSync(join(distSource, WEB_DIST_ENTRY))
331
+ ? (copyBuilt(distSource, join(dir, 'landing')),
332
+ '<iframe src="landing/index.html" loading="lazy"></iframe>')
333
+ : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
334
+
335
+ const teachSource = recordDir(root, 'teach')
336
+ const teachBody = existsSync(join(teachSource, 'index.html'))
337
+ ? (copyBuilt(teachSource, join(dir, 'teach')),
338
+ '<iframe src="teach/index.html" loading="lazy"></iframe>')
339
+ : `<p class="empty">${relative(root, teachSource)} is gitignored and machine-local, so this panel renders empty in a fresh clone and on CI.</p>`
340
+
341
+ writeFileSync(
342
+ join(dir, 'index.html'),
343
+ panelPage(
344
+ 'Surfaces',
345
+ `<h2>Landing page</h2>\n${landingBody}\n<h2>Teach workspaces</h2>\n${teachBody}`,
346
+ ),
347
+ )
348
+ }
349
+
350
+ function writeComponentsPanel(root: string, outDir: string): void {
351
+ const dir = join(outDir, 'components')
352
+ mkdirSync(dir, { recursive: true })
353
+
354
+ const gallerySource = join(root, WEB_GALLERY_DIST)
355
+ const body = existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
356
+ ? (copyBuilt(gallerySource, join(dir, 'gallery')),
357
+ '<iframe src="gallery/index.html" loading="lazy"></iframe>')
358
+ : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
359
+
360
+ writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
361
+ }
362
+
363
+ function isImage(name: string): boolean {
364
+ return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
365
+ }
366
+
367
+ /** Every image file directly inside an evidence arm folder, one level deep. */
368
+ function imagesIn(dir: string): string[] {
369
+ return readdirSync(dir, { withFileTypes: true })
370
+ .filter((entry) => entry.isFile() && isImage(entry.name))
371
+ .map((entry) => entry.name)
372
+ .sort()
373
+ }
374
+
375
+ function writeCandidatesPanel(root: string, outDir: string): void {
376
+ const dir = join(outDir, 'candidates')
377
+ mkdirSync(dir, { recursive: true })
378
+
379
+ const evidenceDir = recordDir(root, 'review', 'evidence')
380
+ if (!existsSync(evidenceDir)) {
381
+ writeFileSync(
382
+ join(dir, 'index.html'),
383
+ panelPage(
384
+ 'Past candidates',
385
+ `<p class="empty">No ${relative(root, evidenceDir)} folder yet.</p>`,
386
+ ),
387
+ )
388
+ return
389
+ }
390
+
391
+ const folders = readdirSync(evidenceDir, { withFileTypes: true })
392
+ .filter((entry) => entry.isDirectory())
393
+ .map((entry) => entry.name)
394
+ .sort()
395
+
396
+ const found: Array<{ folder: string; images: string[] }> = []
397
+ for (const folder of folders) {
398
+ const images = imagesIn(join(evidenceDir, folder))
399
+ if (images.length > 0) {
400
+ cpSync(join(evidenceDir, folder), join(dir, folder), { recursive: true })
401
+ found.push({ folder, images })
402
+ }
403
+ }
404
+
405
+ if (found.length === 0) {
406
+ writeFileSync(
407
+ join(dir, 'index.html'),
408
+ panelPage(
409
+ 'Past candidates',
410
+ `<p class="empty">${folders.length} folders under ${relative(root, evidenceDir)}/ and none carries a draft-and-pick arm capture. The archival capture step has not run since it shipped.</p>`,
411
+ ),
412
+ )
413
+ return
414
+ }
415
+
416
+ const sections = found
417
+ .map(
418
+ ({ folder, images }) =>
419
+ `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${folder}/${image}" alt="${escapeHtml(image)}">`).join('\n')}`,
420
+ )
421
+ .join('\n')
422
+
423
+ writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
424
+ }
425
+
426
+ /**
427
+ * Generates the board's page set into `outDir`, clearing whatever was there.
428
+ *
429
+ * This function is the directory's only writer, per the constraint every
430
+ * caller shares it under: `canon serve` and a future `canon capture` pass
431
+ * both read the result and neither may assume it exists ahead of a run.
432
+ *
433
+ * `cwd` is the caller's own working directory, resolved and passed in
434
+ * explicitly rather than read here, so a test can exercise the checkout-
435
+ * mismatch case without touching the process's real cwd.
436
+ */
437
+ export function generateBoard(
438
+ root: string,
439
+ outDir: string,
440
+ cwd: string,
441
+ ): BoardOutcome {
442
+ if (wouldDeleteRoot([root, cwd], outDir)) {
443
+ return {
444
+ ok: false,
445
+ reason: 'unsafe-out',
446
+ detail: `${outDir} is or contains ${root} or ${cwd}. Refusing to clear it.`,
447
+ }
448
+ }
449
+
450
+ rmSync(outDir, { recursive: true, force: true })
451
+ mkdirSync(outDir, { recursive: true })
452
+
453
+ const panels: readonly BoardPanel[] = [
454
+ { id: 'tokens', title: 'Tokens', path: 'tokens/index.html' },
455
+ { id: 'surfaces', title: 'Surfaces', path: 'surfaces/index.html' },
456
+ { id: 'wireframes', title: 'Wireframes', path: 'wireframes/index.html' },
457
+ {
458
+ id: 'candidates',
459
+ title: 'Past candidates',
460
+ path: 'candidates/index.html',
461
+ },
462
+ { id: 'components', title: 'Components', path: 'components/index.html' },
463
+ ]
464
+
465
+ writeTokensPanel(root, outDir)
466
+ writeSurfacesPanel(root, outDir)
467
+ writeWireframesPanel(root, outDir)
468
+ writeCandidatesPanel(root, outDir)
469
+ writeComponentsPanel(root, outDir)
470
+
471
+ const indexPath = join(outDir, 'index.html')
472
+ writeFileSync(indexPath, shellHtml(panels))
473
+
474
+ return { ok: true, outDir, indexPath, panels }
475
+ }
@@ -523,6 +523,8 @@ function describeShippedReference(reference: ShippedReference): string {
523
523
  return 'a bare standards/ path that has nothing to expand it in an installed plugin cache'
524
524
  case 'phase-label':
525
525
  return 'a phase label that names a board no target holds'
526
+ case 'rule-path':
527
+ return 'a numbered rule path that reaches a target only through a separate canon gov sync, and only where governance was installed at all'
526
528
  }
527
529
  }
528
530
 
@@ -579,8 +581,8 @@ export const shippedReferences: Measure = async (ctx) => {
579
581
  ),
580
582
  failure:
581
583
  found.length === 1
582
- ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
583
- : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
584
+ ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
585
+ : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
584
586
  }
585
587
  }
586
588
 
@@ -203,6 +203,22 @@ const STANDARDS_PATH = /(?<![\w./-])standards\/[^\s`)\]]*\.md\b/g
203
203
  */
204
204
  const PHASE_LABEL = /\bv\d+\.\d+(?!\.\d)\b/g
205
205
 
206
+ /**
207
+ * A `.claude/rules/<segments>/<nnn>-<slug>.md` citation from a body under
208
+ * `claude/skills/`, the installed-path spelling `598-authoring-layout.md`
209
+ * bans a shipped skill body from citing as authority for its own behavior.
210
+ *
211
+ * Anchored on the trailing `\d{3}-[\w-]+\.md` rather than on the bare
212
+ * `.claude/rules/` prefix, which is what keeps a folder mention carrying no
213
+ * number, such as `create-rule`, `memory-review`, and `setup-gov` already
214
+ * write correctly, from matching. The segment group between `rules/` and the
215
+ * numbered file admits both a governance-namespace path
216
+ * (`canon/core/055-scratch.md`) and a project-namespace one
217
+ * (`project/<subdir>/<n>-<slug>.md`) without distinguishing them, since
218
+ * either shape is the same broken citation.
219
+ */
220
+ const RULE_PATH = /(?<![\w./-])\.claude\/rules\/[^\s`)\]]*\/\d{3}-[\w-]+\.md\b/g
221
+
206
222
  export interface ShippedReference {
207
223
  readonly file: string
208
224
  /** One-based, matching the `file:line` form a reader clicks. */
@@ -213,6 +229,7 @@ export interface ShippedReference {
213
229
  | 'docs-path'
214
230
  | 'standards-path'
215
231
  | 'phase-label'
232
+ | 'rule-path'
216
233
  /** The reference as written, so a report names the token to qualify. */
217
234
  readonly text: string
218
235
  /**
@@ -259,14 +276,19 @@ function isDocsPathReportable(
259
276
  }
260
277
 
261
278
  /**
262
- * Whether `file` sits in the one corpus `STANDARDS_PATH` gates.
279
+ * Whether `file` sits in the one corpus `STANDARDS_PATH` and `RULE_PATH`
280
+ * both gate: a shipped skill body, minus its own `REQUIREMENT.md`.
263
281
  *
264
- * `REQUIREMENT.md` is excluded for the reason `598-authoring-layout.md`
265
- * leaves it alone: a maintainer or an audit command reads that file rather
266
- * than a session loading it, so the resolver rule this pattern enforces
267
- * never applies there.
282
+ * Both patterns share this scope because both bans live in
283
+ * `598-authoring-layout.md`, stated for the same reader: a session loading
284
+ * the `SKILL.md` body a target actually receives. `REQUIREMENT.md` is
285
+ * excluded for the reason that file states there, since a maintainer or an
286
+ * audit command reads it rather than a session loading it, so neither
287
+ * resolver rule this pair enforces ever applies to it. One predicate serves
288
+ * both call sites rather than two copies drifting apart with nothing
289
+ * comparing them.
268
290
  */
269
- function isStandardsPathScope(file: string): boolean {
291
+ function isSkillBodyScope(file: string): boolean {
270
292
  return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
271
293
  }
272
294
 
@@ -357,7 +379,7 @@ export function referencesIn(
357
379
  })
358
380
  }
359
381
 
360
- if (isStandardsPathScope(file)) {
382
+ if (isSkillBodyScope(file)) {
361
383
  for (const match of line.matchAll(STANDARDS_PATH)) {
362
384
  if (!isStandardsPathReportable(match[0])) continue
363
385
  references.push({
@@ -377,6 +399,18 @@ export function referencesIn(
377
399
  text: match[0],
378
400
  })
379
401
  }
402
+
403
+ if (isSkillBodyScope(file)) {
404
+ for (const match of line.matchAll(RULE_PATH)) {
405
+ if (isPlaceholderPath(match[0])) continue
406
+ references.push({
407
+ file,
408
+ line: index + 1,
409
+ kind: 'rule-path',
410
+ text: match[0],
411
+ })
412
+ }
413
+ }
380
414
  }
381
415
 
382
416
  return references
@@ -0,0 +1,11 @@
1
+ /** @jsxImportSource ../html */
2
+ import type { Child, Element } from '@/teach/html/jsx-runtime'
3
+
4
+ export interface HeadingProps {
5
+ readonly level: 1 | 2
6
+ readonly children: Child
7
+ }
8
+
9
+ export function Heading({ level, children }: HeadingProps): Element {
10
+ return level === 1 ? <h1>{children}</h1> : <h2>{children}</h2>
11
+ }
@@ -0,0 +1,17 @@
1
+ /** @jsxImportSource ../html */
2
+ import type { Child, Element } from '@/teach/html/jsx-runtime'
3
+
4
+ export interface ListProps {
5
+ readonly ordered?: boolean
6
+ readonly items: readonly Child[]
7
+ }
8
+
9
+ /**
10
+ * Composes an item per entry rather than taking pre-built `<li>` children, so
11
+ * every list in a lesson escapes the same way regardless of what the caller
12
+ * hands in.
13
+ */
14
+ export function List({ ordered, items }: ListProps): Element {
15
+ const rendered = items.map((item) => <li>{item}</li>)
16
+ return ordered ? <ol>{rendered}</ol> : <ul>{rendered}</ul>
17
+ }
@@ -0,0 +1,12 @@
1
+ /** @jsxImportSource ../html */
2
+ import type { Child, Element } from '@/teach/html/jsx-runtime'
3
+
4
+ export interface ParagraphProps {
5
+ /** The lesson's dek, read back by `extractLessonMeta` in `@/teach/nav`. */
6
+ readonly lede?: boolean
7
+ readonly children: Child
8
+ }
9
+
10
+ export function Paragraph({ lede, children }: ParagraphProps): Element {
11
+ return lede ? <p class="lede">{children}</p> : <p>{children}</p>
12
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Bun resolves this module rather than `jsx-runtime` at run time (spike 1,
3
+ * `.canon/groundwork/56-teach-render-layer/08-spikes.md`), so every export a
4
+ * caller might reach through either entry point has to exist here too.
5
+ */
6
+ export {
7
+ Fragment,
8
+ jsx,
9
+ jsx as jsxDEV,
10
+ jsxs,
11
+ render,
12
+ type JSX,
13
+ } from './jsx-runtime'