@erclx/canon 5.18.0 → 5.20.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 (116) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-skill/SKILL.md +1 -0
  4. package/claude/skills/plan-feature/SKILL.md +1 -1
  5. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  6. package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
  7. package/claude/skills/record-screencast/SKILL.md +2 -2
  8. package/claude/skills/role-worker/REQUIREMENT.md +3 -1
  9. package/claude/skills/role-worker/SKILL.md +5 -3
  10. package/claude/skills/session-resume/SKILL.md +1 -1
  11. package/claude/skills/task-board/REQUIREMENT.md +1 -0
  12. package/claude/skills/task-board/SKILL.md +6 -0
  13. package/claude/skills/task-board/references/decline.md +2 -0
  14. package/claude/skills/video-craft/REQUIREMENT.md +48 -0
  15. package/claude/skills/video-craft/SKILL.md +80 -0
  16. package/claude/skills/video-craft/references/adopted.md +66 -0
  17. package/docs/agents/canvas.md +4 -0
  18. package/docs/agents/commands.md +1 -0
  19. package/docs/agents/demo.md +1 -1
  20. package/docs/agents/install-and-sync.md +17 -3
  21. package/docs/agents/rule-citations.md +4 -4
  22. package/docs/agents/sandbox.md +8 -7
  23. package/docs/agents/teach.md +7 -1
  24. package/docs/workflow/operating-model.md +9 -9
  25. package/docs/workflow/parallel-features.md +1 -1
  26. package/docs/workflow/skill-map.md +1 -0
  27. package/governance/renames.toml +24 -0
  28. package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
  29. package/governance/rules/standards/context.md +15 -0
  30. package/governance/rules/standards/design.md +13 -0
  31. package/governance/rules/standards/diagrams.md +17 -0
  32. package/governance/rules/standards/groundwork.md +13 -0
  33. package/governance/rules/standards/intake.md +12 -0
  34. package/governance/rules/standards/markdown.md +15 -0
  35. package/governance/rules/standards/memory.md +12 -0
  36. package/governance/rules/standards/mermaid.md +15 -0
  37. package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
  38. package/governance/rules/standards/readme.md +12 -0
  39. package/governance/rules/standards/ready.md +13 -0
  40. package/governance/rules/standards/requirements.md +13 -0
  41. package/governance/rules/standards/rule.md +13 -0
  42. package/governance/rules/standards/session.md +13 -0
  43. package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
  44. package/governance/rules/standards/standard.md +13 -0
  45. package/governance/rules/standards/tasks.md +15 -0
  46. package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
  47. package/governance/rules/standards/wiki.md +13 -0
  48. package/governance/rules/standards/wireframes.md +14 -0
  49. package/governance/rules/writing/800-prose.md +1 -1
  50. package/governance/stacks/base.toml +4 -2
  51. package/package.json +2 -1
  52. package/src/canvas/capture.ts +228 -15
  53. package/src/claude/cases/misc.ts +5 -0
  54. package/src/claude/routing.ts +1 -1
  55. package/src/commands/canvas.ts +76 -2
  56. package/src/commands/gov.ts +19 -4
  57. package/src/commands/sandbox.ts +7 -0
  58. package/src/commands/slides.ts +73 -4
  59. package/src/commands/teach.ts +112 -4
  60. package/src/gate/stages.ts +7 -1
  61. package/src/gov/citations.ts +1 -1
  62. package/src/gov/standard-rules.ts +179 -0
  63. package/src/sandbox/equivalence.ts +43 -14
  64. package/src/sandbox/stub-remote.ts +140 -0
  65. package/src/serve/live.ts +113 -0
  66. package/src/serve/static.ts +60 -2
  67. package/src/slides/convert/export.ts +323 -0
  68. package/src/slides/convert/shapes.ts +764 -0
  69. package/src/slides/convert/svg.ts +38 -0
  70. package/src/slides/convert/walk.ts +444 -0
  71. package/src/teach/browser/reload.ts +9 -0
  72. package/src/teach/nav.ts +7 -6
  73. package/src/teach/workspace.ts +2 -1
  74. package/src/teach/write-if-changed.ts +17 -0
  75. package/standards/architecture.md +5 -0
  76. package/standards/context.md +6 -0
  77. package/standards/design.md +3 -0
  78. package/standards/diagrams.md +8 -0
  79. package/standards/groundwork-spikes.md +66 -0
  80. package/standards/groundwork.md +5 -25
  81. package/standards/index.md +6 -2
  82. package/standards/intake.md +2 -0
  83. package/standards/markdown.md +6 -0
  84. package/standards/memory.md +2 -0
  85. package/standards/mermaid.md +6 -0
  86. package/standards/plan-lifecycle.md +36 -0
  87. package/standards/plan.md +13 -11
  88. package/standards/readme.md +2 -0
  89. package/standards/ready.md +4 -0
  90. package/standards/requirements.md +3 -0
  91. package/standards/rule.md +6 -1
  92. package/standards/session.md +4 -0
  93. package/standards/skill-paths.md +50 -0
  94. package/standards/skill.md +15 -23
  95. package/standards/standard.md +7 -1
  96. package/standards/tasks-decline.md +38 -0
  97. package/standards/tasks.md +8 -13
  98. package/standards/teach.md +5 -0
  99. package/standards/wiki.md +4 -0
  100. package/standards/wireframes.md +5 -0
  101. package/governance/rules/canon/610-context.md +0 -17
  102. package/governance/rules/canon/620-wireframes.md +0 -13
  103. package/governance/rules/canon/630-requirements.md +0 -12
  104. package/governance/rules/canon/650-design.md +0 -12
  105. package/governance/rules/canon/655-tasks.md +0 -13
  106. package/governance/rules/canon/656-groundwork.md +0 -11
  107. package/governance/rules/canon/657-intake.md +0 -11
  108. package/governance/rules/canon/659-memory.md +0 -11
  109. package/governance/rules/canon/660-diagrams.md +0 -19
  110. package/governance/rules/canon/662-session.md +0 -15
  111. package/governance/rules/canon/663-ready.md +0 -15
  112. package/governance/rules/claude/590-rule-authoring.md +0 -12
  113. package/governance/rules/writing/801-markdown.md +0 -14
  114. package/governance/rules/writing/802-mermaid.md +0 -14
  115. package/governance/rules/writing/880-readme.md +0 -11
  116. package/governance/rules/writing/891-standard-authoring.md +0 -12
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The colors an inline SVG inherited from the page it was drawn in. Inside a
3
+ * slide nothing supplies `color` or a custom property, so a vector handed over
4
+ * as written renders every `currentColor` and `var(--*)` paint as black.
5
+ */
6
+ export interface SvgColors {
7
+ /** The element's computed `color`, which is what `currentColor` reads. */
8
+ readonly color: string
9
+ /** Every custom property the element computed, keyed with its leading `--`. */
10
+ readonly vars: Readonly<Record<string, string>>
11
+ }
12
+
13
+ const NAMESPACE = 'http://www.w3.org/2000/svg'
14
+
15
+ /**
16
+ * Rewrites the paints that depend on the page into the values the browser
17
+ * computed for them. A custom property the page never set takes its own
18
+ * fallback where it carries one, and stays as written otherwise, since the
19
+ * browser drew that paint as invalid too.
20
+ */
21
+ export function resolveSvgColors(markup: string, colors: SvgColors): string {
22
+ const withVars = markup.replace(
23
+ /var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\))?[^()]*))?\)/g,
24
+ (match, name: string, fallback: string | undefined) =>
25
+ colors.vars[name] ?? fallback?.trim() ?? match,
26
+ )
27
+ const withColor = withVars.replace(/\bcurrentColor\b/gi, colors.color)
28
+ return withNamespace(withColor)
29
+ }
30
+
31
+ /**
32
+ * An SVG serialized out of an HTML document carries no namespace, and a
33
+ * standalone file without one is not read as SVG at all.
34
+ */
35
+ function withNamespace(markup: string): string {
36
+ if (/^<svg\b[^>]*\sxmlns=/.test(markup)) return markup
37
+ return markup.replace(/^<svg\b/, `<svg xmlns="${NAMESPACE}"`)
38
+ }
@@ -0,0 +1,444 @@
1
+ import type {
2
+ BoxStyle,
3
+ ElementRecord,
4
+ Rect,
5
+ Rgba,
6
+ ShadowRecord,
7
+ TableCellRecord,
8
+ TextContent,
9
+ TextRun,
10
+ } from '@/slides/convert/shapes'
11
+
12
+ /** What one laid-out slide hands back across `page.evaluate`. */
13
+ export interface WalkedSlide {
14
+ readonly background: Rgba
15
+ readonly records: ElementRecord[]
16
+ }
17
+
18
+ /** Set on every walked element, so a screenshot finds the one a record names. */
19
+ export const ID_ATTRIBUTE = 'data-canon-id'
20
+
21
+ /**
22
+ * Runs inside the laid-out page and returns one record per drawable element in
23
+ * document order. Playwright serializes this function to source, so every
24
+ * helper it calls is declared inside its own body: a reference to anything at
25
+ * module scope typechecks and then throws in the page.
26
+ *
27
+ * Text gathers along the inline flow. A block holding text takes every run its
28
+ * inline descendants carry, so `<strong>` inside `<p>` is one shape with two
29
+ * runs, while a block-level child is walked on its own. Images, SVGs, and
30
+ * tables consume their whole subtree.
31
+ *
32
+ * Each walked element is tagged with `idAttribute` set to its record id.
33
+ */
34
+ export function walkSlide(idAttribute: string): WalkedSlide {
35
+ const canvas = document.createElement('canvas')
36
+ canvas.width = 1
37
+ canvas.height = 1
38
+ const context = canvas.getContext('2d', { willReadFrequently: true })
39
+
40
+ const rgba = (value: string): Rgba => {
41
+ if (!context || !value || value === 'transparent') {
42
+ return { hex: '000000', alpha: 0 }
43
+ }
44
+ context.clearRect(0, 0, 1, 1)
45
+ context.fillStyle = '#000000'
46
+ context.fillStyle = value
47
+ context.fillRect(0, 0, 1, 1)
48
+ const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data
49
+ const hex = [r, g, b]
50
+ .map((channel) => channel.toString(16).padStart(2, '0'))
51
+ .join('')
52
+ .toUpperCase()
53
+ return { hex, alpha: Math.round((a / 255) * 100) / 100 }
54
+ }
55
+
56
+ const px = (value: string): number => Number.parseFloat(value) || 0
57
+
58
+ const splitTopLevel = (value: string): string[] => {
59
+ const parts: string[] = []
60
+ let depth = 0
61
+ let current = ''
62
+ for (const char of value) {
63
+ if (char === '(') depth += 1
64
+ if (char === ')') depth -= 1
65
+ if (char === ',' && depth === 0) {
66
+ parts.push(current.trim())
67
+ current = ''
68
+ } else {
69
+ current += char
70
+ }
71
+ }
72
+ if (current.trim()) parts.push(current.trim())
73
+ return parts
74
+ }
75
+
76
+ const shadows = (value: string): ShadowRecord[] => {
77
+ if (!value || value === 'none') return []
78
+ return splitTopLevel(value).map((part) => {
79
+ const colorMatch = /[a-z-]+\([^)]*\)|#[0-9a-f]+/i.exec(part)
80
+ const rest = colorMatch ? part.replace(colorMatch[0], ' ') : part
81
+ const [x = 0, y = 0, blur = 0] = (rest.match(/-?[\d.]+px/g) ?? []).map(px)
82
+ return {
83
+ x,
84
+ y,
85
+ blur,
86
+ color: rgba(colorMatch?.[0] ?? 'rgb(0, 0, 0)'),
87
+ inset: /\binset\b/.test(rest),
88
+ }
89
+ })
90
+ }
91
+
92
+ const rectOf = (domRect: DOMRect): Rect => ({
93
+ x: domRect.left,
94
+ y: domRect.top,
95
+ w: domRect.width,
96
+ h: domRect.height,
97
+ })
98
+
99
+ const boxStyle = (element: Element, bounds: Rect): BoxStyle => {
100
+ const style = getComputedStyle(element)
101
+ const radius = style.borderTopLeftRadius.endsWith('%')
102
+ ? (px(style.borderTopLeftRadius) / 100) * Math.min(bounds.w, bounds.h)
103
+ : px(style.borderTopLeftRadius)
104
+ return {
105
+ background: rgba(style.backgroundColor),
106
+ borderWidth: {
107
+ top: px(style.borderTopWidth),
108
+ right: px(style.borderRightWidth),
109
+ bottom: px(style.borderBottomWidth),
110
+ left: px(style.borderLeftWidth),
111
+ },
112
+ borderColor: rgba(style.borderTopColor),
113
+ borderStyle: style.borderTopStyle,
114
+ radius,
115
+ padding: {
116
+ top: px(style.paddingTop),
117
+ right: px(style.paddingRight),
118
+ bottom: px(style.paddingBottom),
119
+ left: px(style.paddingLeft),
120
+ },
121
+ shadows: shadows(style.boxShadow),
122
+ raw: {
123
+ backgroundImage: style.backgroundImage,
124
+ transform: style.transform,
125
+ filter: style.filter,
126
+ clipPath: style.clipPath,
127
+ mask: style.maskImage || 'none',
128
+ mixBlendMode: style.mixBlendMode,
129
+ backdropFilter: style.backdropFilter || 'none',
130
+ borderWidth: style.borderWidth,
131
+ borderColor: style.borderColor,
132
+ borderStyle: style.borderStyle,
133
+ borderRadius: style.borderRadius,
134
+ },
135
+ }
136
+ }
137
+
138
+ const describe = (element: Element): string => {
139
+ const tag = element.tagName.toLowerCase()
140
+ if (element.id) return `${tag}#${element.id}`
141
+ const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
142
+ return className ? `${tag}.${className}` : tag
143
+ }
144
+
145
+ const isInlineFlow = (element: Element): boolean => {
146
+ const tag = element.tagName.toLowerCase()
147
+ if (tag === 'img' || tag === 'svg' || tag === 'table') return false
148
+ return getComputedStyle(element).display === 'inline'
149
+ }
150
+
151
+ const transformText = (text: string, transform: string): string => {
152
+ if (transform === 'uppercase') return text.toUpperCase()
153
+ if (transform === 'lowercase') return text.toLowerCase()
154
+ if (transform === 'capitalize') {
155
+ return text.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase())
156
+ }
157
+ return text
158
+ }
159
+
160
+ /**
161
+ * Collects the runs of `root`'s inline flow, adding every inline element it
162
+ * passed through to `consumed`. `deep` follows block children too, which a
163
+ * table cell needs since it is drawn as one cell however it nests.
164
+ */
165
+ const gatherRuns = (
166
+ root: Element,
167
+ consumed: Set<Element>,
168
+ deep: boolean,
169
+ ): TextRun[] => {
170
+ const runs: TextRun[] = []
171
+ let breakPending = false
172
+ const visit = (node: Node): void => {
173
+ for (const child of Array.from(node.childNodes)) {
174
+ if (child.nodeType === Node.TEXT_NODE) {
175
+ const parent = child.parentElement
176
+ if (!parent) continue
177
+ const style = getComputedStyle(parent)
178
+ const text = (child.textContent ?? '').replace(/\s+/g, ' ')
179
+ if (!text.trim() && !runs.length) continue
180
+ if (!text) continue
181
+ const spacing = style.letterSpacing
182
+ const isUnderlined = (inline: Element): boolean => {
183
+ for (let at: Element | null = inline; at; at = at.parentElement) {
184
+ if (
185
+ getComputedStyle(at).textDecorationLine.includes('underline')
186
+ ) {
187
+ return true
188
+ }
189
+ if (at === root) break
190
+ }
191
+ return false
192
+ }
193
+ const highlightOf = (inline: Element): Rgba | undefined => {
194
+ if (inline === root) return undefined
195
+ const ground = rgba(getComputedStyle(inline).backgroundColor)
196
+ return ground.alpha > 0 ? ground : undefined
197
+ }
198
+ runs.push({
199
+ text: transformText(text, style.textTransform),
200
+ color: rgba(style.color),
201
+ fontSize: px(style.fontSize),
202
+ fontWeight: Number(style.fontWeight) || 400,
203
+ italic: style.fontStyle === 'italic',
204
+ underline: isUnderlined(parent),
205
+ fontFamily: style.fontFamily,
206
+ letterSpacing: spacing === 'normal' ? 0 : px(spacing),
207
+ highlight: highlightOf(parent),
208
+ href: parent.closest('a[href]')?.getAttribute('href') ?? undefined,
209
+ breakBefore: breakPending || undefined,
210
+ })
211
+ breakPending = false
212
+ } else if (child instanceof Element) {
213
+ if (child.tagName.toLowerCase() === 'br') {
214
+ breakPending = true
215
+ consumed.add(child)
216
+ } else if (deep || isInlineFlow(child)) {
217
+ consumed.add(child)
218
+ visit(child)
219
+ }
220
+ }
221
+ }
222
+ }
223
+ visit(root)
224
+ return tidyRuns(runs)
225
+ }
226
+
227
+ /** Collapses the spaces the browser would, across run boundaries included. */
228
+ const tidyRuns = (runs: TextRun[]): TextRun[] => {
229
+ const tidied: TextRun[] = []
230
+ for (const run of runs) {
231
+ const previous = tidied.at(-1)
232
+ let text = run.text
233
+ if (!previous || run.breakBefore || previous.text.endsWith(' ')) {
234
+ text = text.replace(/^ /, '')
235
+ }
236
+ if (text) tidied.push({ ...run, text })
237
+ }
238
+ const last = tidied.at(-1)
239
+ if (last) tidied[tidied.length - 1] = { ...last, text: last.text.trimEnd() }
240
+ return tidied.filter((run) => run.text.length > 0)
241
+ }
242
+
243
+ const textContent = (element: Element, runs: TextRun[]): TextContent => {
244
+ const style = getComputedStyle(element)
245
+ const fontSize = px(style.fontSize)
246
+ return {
247
+ runs,
248
+ align: style.textAlign,
249
+ lineHeight:
250
+ style.lineHeight === 'normal' ? fontSize * 1.2 : px(style.lineHeight),
251
+ fontSize,
252
+ }
253
+ }
254
+
255
+ const renderedText = (element: Element): string => {
256
+ const text =
257
+ element instanceof HTMLElement ? element.innerText : element.textContent
258
+ return (text ?? '').replace(/\s+/g, ' ').trim()
259
+ }
260
+
261
+ const layoutBox = (element: Element, bounds: Rect): Rect => {
262
+ if (getComputedStyle(element).transform === 'none') return bounds
263
+ if (!(element instanceof HTMLElement)) return bounds
264
+ const w = element.offsetWidth
265
+ const h = element.offsetHeight
266
+ return {
267
+ x: bounds.x + (bounds.w - w) / 2,
268
+ y: bounds.y + (bounds.h - h) / 2,
269
+ w,
270
+ h,
271
+ }
272
+ }
273
+
274
+ const records: ElementRecord[] = []
275
+ const ids = new Map<Element, number>()
276
+ const consumed = new Set<Element>()
277
+
278
+ const parentId = (element: Element): number | null => {
279
+ let ancestor = element.parentElement
280
+ while (ancestor) {
281
+ const id = ids.get(ancestor)
282
+ if (id !== undefined) return id
283
+ ancestor = ancestor.parentElement
284
+ }
285
+ return null
286
+ }
287
+
288
+ const consumeSubtree = (element: Element): void => {
289
+ for (const descendant of Array.from(element.querySelectorAll('*'))) {
290
+ consumed.add(descendant)
291
+ }
292
+ }
293
+
294
+ const SKIPPED = new Set(['script', 'style', 'template', 'noscript', 'link'])
295
+
296
+ for (const element of Array.from(document.body.querySelectorAll('*'))) {
297
+ if (consumed.has(element)) continue
298
+ const tag = element.tagName.toLowerCase()
299
+ if (SKIPPED.has(tag)) continue
300
+ if (!element.checkVisibility({ visibilityProperty: true })) continue
301
+
302
+ const id = records.length + 1
303
+ const bounds = rectOf(element.getBoundingClientRect())
304
+ const base = {
305
+ id,
306
+ parent: parentId(element),
307
+ selector: describe(element),
308
+ box: layoutBox(element, bounds),
309
+ bounds,
310
+ style: boxStyle(element, bounds),
311
+ text: renderedText(element),
312
+ }
313
+ ids.set(element, id)
314
+ element.setAttribute(idAttribute, String(id))
315
+
316
+ if (element instanceof HTMLImageElement) {
317
+ records.push({
318
+ ...base,
319
+ kind: 'image',
320
+ src: element.currentSrc || element.src,
321
+ alt: element.alt,
322
+ })
323
+ continue
324
+ }
325
+
326
+ if (element instanceof SVGSVGElement) {
327
+ consumeSubtree(element)
328
+ const style = getComputedStyle(element)
329
+ const vars: Record<string, string> = {}
330
+ for (const [, name] of element.outerHTML.matchAll(
331
+ /var\(\s*(--[\w-]+)/g,
332
+ )) {
333
+ if (!name) continue
334
+ const value = style.getPropertyValue(name).trim()
335
+ if (value) vars[name] = value
336
+ }
337
+ records.push({
338
+ ...base,
339
+ kind: 'svg',
340
+ markup: element.outerHTML,
341
+ colors: { color: style.color, vars },
342
+ alt:
343
+ element.getAttribute('aria-label') ??
344
+ element.querySelector('title')?.textContent ??
345
+ '',
346
+ })
347
+ continue
348
+ }
349
+
350
+ if (element instanceof HTMLTableElement) {
351
+ consumeSubtree(element)
352
+ const rows = Array.from(element.rows).map((row) =>
353
+ Array.from(row.cells).map((cell): TableCellRecord => {
354
+ const cellBounds = rectOf(cell.getBoundingClientRect())
355
+ return {
356
+ ...textContent(cell, gatherRuns(cell, new Set(), true)),
357
+ box: cellBounds,
358
+ colspan: cell.colSpan,
359
+ rowspan: cell.rowSpan,
360
+ style: boxStyle(cell, cellBounds),
361
+ verticalAlign: getComputedStyle(cell).verticalAlign,
362
+ }
363
+ }),
364
+ )
365
+ records.push({ ...base, kind: 'table', rows })
366
+ continue
367
+ }
368
+
369
+ const gathered = new Set<Element>()
370
+ const runs = gatherRuns(element, gathered, false)
371
+ for (const inline of gathered) consumed.add(inline)
372
+ if (runs.length === 0) {
373
+ records.push({ ...base, kind: 'box' })
374
+ continue
375
+ }
376
+
377
+ const blockChild = Array.from(element.children).find(
378
+ (child) =>
379
+ !consumed.has(child) &&
380
+ !SKIPPED.has(child.tagName.toLowerCase()) &&
381
+ child.checkVisibility() &&
382
+ !getComputedStyle(child).display.startsWith('inline'),
383
+ )
384
+ const box = blockChild
385
+ ? {
386
+ ...base.box,
387
+ h: Math.max(0, blockChild.getBoundingClientRect().top - base.box.y),
388
+ }
389
+ : base.box
390
+
391
+ const style = getComputedStyle(element)
392
+ const isListItem =
393
+ style.display === 'list-item' && style.listStyleType !== 'none'
394
+ const list = isListItem
395
+ ? {
396
+ style: style.listStyleType,
397
+ depth: (() => {
398
+ let depth = 0
399
+ let ancestor = element.parentElement
400
+ while (ancestor) {
401
+ if (ancestor.tagName === 'UL' || ancestor.tagName === 'OL') {
402
+ depth += 1
403
+ }
404
+ ancestor = ancestor.parentElement
405
+ }
406
+ return depth
407
+ })(),
408
+ ordinal:
409
+ (element.parentElement instanceof HTMLOListElement
410
+ ? element.parentElement.start
411
+ : 1) +
412
+ Array.from(element.parentElement?.children ?? [])
413
+ .filter((sibling) => sibling.tagName === 'LI')
414
+ .indexOf(element),
415
+ }
416
+ : undefined
417
+
418
+ records.push({
419
+ ...base,
420
+ box,
421
+ kind: 'text',
422
+ content: textContent(element, runs),
423
+ list,
424
+ inlineStyles: Array.from(gathered)
425
+ .filter((inline) => inline.tagName !== 'BR')
426
+ .map((inline) =>
427
+ boxStyle(inline, rectOf(inline.getBoundingClientRect())),
428
+ ),
429
+ })
430
+ }
431
+
432
+ const ground = (element: Element): Rgba =>
433
+ rgba(getComputedStyle(element).backgroundColor)
434
+ const bodyGround = ground(document.body)
435
+ const htmlGround = ground(document.documentElement)
436
+ const background =
437
+ bodyGround.alpha > 0
438
+ ? bodyGround
439
+ : htmlGround.alpha > 0
440
+ ? htmlGround
441
+ : { hex: 'FFFFFF', alpha: 1 }
442
+
443
+ return { background, records }
444
+ }
@@ -0,0 +1,9 @@
1
+ /* Spliced in by `canon teach up` at serve time and never written to a page, so
2
+ the server sends a message only once a refresh has landed on disk. */
3
+ export function reload(path: string): void {
4
+ var source = new EventSource(path)
5
+ source.onmessage = function () {
6
+ source.close()
7
+ location.reload()
8
+ }
9
+ }
package/src/teach/nav.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
- import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { readdir, readFile, rm } from 'node:fs/promises'
3
3
  import { join, posix, relative } from 'node:path'
4
4
  import {
5
5
  TEACH_SIDEBAR_BREAKPOINT,
@@ -37,6 +37,7 @@ import {
37
37
  type WorkspaceSummary,
38
38
  writeStylesheet,
39
39
  } from '@/teach/workspace'
40
+ import { writeIfChanged } from '@/teach/write-if-changed'
40
41
 
41
42
  const BRAND_MARK = 'assets/brand/mark.svg'
42
43
 
@@ -1162,7 +1163,7 @@ async function rewriteLesson(
1162
1163
  }
1163
1164
 
1164
1165
  const linked = linkAuthoredRegion(wrapMain(html), targets, file)
1165
- await writeFile(path, linked.html)
1166
+ await writeIfChanged(path, linked.html)
1166
1167
  return { ok: true, file, unresolved: linked.unresolved }
1167
1168
  }
1168
1169
 
@@ -1234,9 +1235,9 @@ export async function generateNav(
1234
1235
 
1235
1236
  const dir = teachDir(root)
1236
1237
  const rootPath = join(dir, 'index.html')
1237
- await writeFile(rootPath, renderRootPage(listed.workspaces))
1238
+ await writeIfChanged(rootPath, renderRootPage(listed.workspaces))
1238
1239
 
1239
- await writeFile(
1240
+ await writeIfChanged(
1240
1241
  join(dir, TEACH_STYLESHEET),
1241
1242
  buildDesignCss(undefined, {
1242
1243
  embedFonts: TEACH_FONT_FACES,
@@ -1273,7 +1274,7 @@ export async function generateNav(
1273
1274
  }
1274
1275
 
1275
1276
  const contentsPath = join(root, detail.path, 'index.html')
1276
- await writeFile(
1277
+ await writeIfChanged(
1277
1278
  contentsPath,
1278
1279
  await renderContentsPage(
1279
1280
  root,
@@ -1292,7 +1293,7 @@ export async function generateNav(
1292
1293
  continue
1293
1294
  }
1294
1295
 
1295
- await writeFile(
1296
+ await writeIfChanged(
1296
1297
  join(root, detail.path, TEACH_REFERENCE, renderedReferenceName(file)),
1297
1298
  await renderReferencePage(root, listed.workspaces, detail, metas, file),
1298
1299
  )
@@ -7,6 +7,7 @@ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
7
7
  import { type BodyLine, bodyLines } from '@/markdown/scan'
8
8
  import { recordDir } from '@/record-root'
9
9
  import { TEACH_FONT_FACES } from '@/teach/fonts'
10
+ import { writeIfChanged } from '@/teach/write-if-changed'
10
11
 
11
12
  export const TEACH_REFUSALS = [
12
13
  'no-teach',
@@ -1132,7 +1133,7 @@ export async function writeStylesheet(
1132
1133
  const path = join(root, rel)
1133
1134
 
1134
1135
  await mkdir(join(root, assetsDir), { recursive: true })
1135
- await writeFile(
1136
+ await writeIfChanged(
1136
1137
  basePath,
1137
1138
  buildDesignCss(undefined, {
1138
1139
  banner: STYLESHEET_BASE_BANNER,
@@ -0,0 +1,17 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+
3
+ /**
4
+ * Writes `text` to `path` only when the file does not already hold it, and
5
+ * reports whether it wrote. `canon teach up` watches the folder `nav` writes
6
+ * into, so a pass that rewrites unchanged bytes raises the events that start
7
+ * the next pass, and an unchanged mtime is what lets the loop settle.
8
+ */
9
+ export async function writeIfChanged(
10
+ path: string,
11
+ text: string,
12
+ ): Promise<boolean> {
13
+ const current = await readFile(path, 'utf8').catch(() => undefined)
14
+ if (current === text) return false
15
+ await writeFile(path, text)
16
+ return true
17
+ }
@@ -1,6 +1,11 @@
1
1
  ---
2
2
  title: Architecture reference
3
3
  description: Shape and content rules for canon/ARCHITECTURE.md
4
+ paths:
5
+ - 'canon/ARCHITECTURE.md'
6
+ - '.claude/ARCHITECTURE.md'
7
+ rule:
8
+ - "Add a decision here only when it fills one of the standard's slots, and write any other decision into the context entry for the domain it constrains. At the record's stated cap, merge or retire an entry before adding one."
4
9
  ---
5
10
 
6
11
  # Architecture reference
@@ -1,6 +1,12 @@
1
1
  ---
2
2
  title: Context entry reference
3
3
  description: Shape and content rules for canon/context/<domain>.md entries
4
+ paths:
5
+ - 'canon/context/**'
6
+ - '.claude/context/**'
7
+ rule:
8
+ - 'Before non-trivial work in a domain, read its `canon/context/<domain>.md` entry. Use `canon/context/index.md` to find it.'
9
+ - 'Leave the entry conforming when work in its domain changes what it describes.'
4
10
  ---
5
11
 
6
12
  # Context entry reference
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  title: Design reference
3
3
  description: Shape and content rules for canon/DESIGN.md
4
+ paths:
5
+ - 'canon/DESIGN.md'
6
+ - '.claude/DESIGN.md'
4
7
  ---
5
8
 
6
9
  # Design reference
@@ -1,6 +1,14 @@
1
1
  ---
2
2
  title: Diagram reference
3
3
  description: Shape and content rules for .canon/diagrams/<kind>.md files
4
+ paths:
5
+ - '.canon/diagrams/**'
6
+ - '.claude/DIAGRAMS.md'
7
+ rule:
8
+ - 'Layout, budgets, accessibility, labels, and render verification inside the fence are a separate topic. The mermaid standard routes them.'
9
+ - 'A diagram entry carries structure and flow, not implementation. Read the standard before adding or revising a kind.'
10
+ - 'Write a new diagram to `.canon/diagrams/<kind>.md`, never to `.claude/DIAGRAMS.md`'
11
+ - 'Convert a `.claude/DIAGRAMS.md` left by an older install into per-kind entries before editing it'
4
12
  ---
5
13
 
6
14
  # Diagram reference