@erclx/canon 5.17.0 → 5.19.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 (51) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  4. package/claude/skills/deploy-app/SKILL.md +45 -0
  5. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  6. package/claude/skills/deploy-app/references/vercel.md +72 -0
  7. package/docs/agents/architecture-record.md +12 -4
  8. package/docs/agents/canvas.md +13 -1
  9. package/docs/agents/commands.md +3 -2
  10. package/docs/agents/context-audit-checks.md +1 -1
  11. package/docs/agents/context-audit.md +4 -4
  12. package/docs/agents/index.md +2 -2
  13. package/docs/agents/teach.md +7 -1
  14. package/docs/workflow/skill-map.md +9 -9
  15. package/package.json +2 -1
  16. package/src/audits/catalog.ts +46 -1
  17. package/src/canvas/address.ts +73 -39
  18. package/src/canvas/client/inspector.tsx +241 -33
  19. package/src/canvas/client/pages-panel.tsx +51 -0
  20. package/src/canvas/client/shell.css +126 -1
  21. package/src/canvas/client/state.ts +120 -4
  22. package/src/canvas/client/theme-panel.tsx +55 -0
  23. package/src/canvas/content.ts +14 -3
  24. package/src/canvas/edit.ts +360 -0
  25. package/src/canvas/server.ts +50 -5
  26. package/src/canvas/tokens.ts +82 -0
  27. package/src/claude/cases/misc.ts +5 -1
  28. package/src/commands/canvas.ts +95 -1
  29. package/src/commands/context.ts +85 -5
  30. package/src/commands/slides.ts +73 -4
  31. package/src/commands/teach.ts +112 -4
  32. package/src/context/architecture.ts +98 -18
  33. package/src/context/gate.ts +30 -3
  34. package/src/context/requirements.ts +77 -0
  35. package/src/gate/measures.ts +69 -28
  36. package/src/gate/stages.ts +5 -5
  37. package/src/serve/live.ts +113 -0
  38. package/src/serve/static.ts +60 -2
  39. package/src/slides/convert/export.ts +323 -0
  40. package/src/slides/convert/shapes.ts +764 -0
  41. package/src/slides/convert/svg.ts +38 -0
  42. package/src/slides/convert/walk.ts +444 -0
  43. package/src/teach/browser/reload.ts +9 -0
  44. package/src/teach/nav.ts +7 -6
  45. package/src/teach/workspace.ts +2 -1
  46. package/src/teach/write-if-changed.ts +17 -0
  47. package/standards/architecture.md +13 -7
  48. package/standards/requirements.md +4 -0
  49. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  50. package/tooling/cloudflare/reference.md +1 -1
  51. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -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
+ }
@@ -73,18 +73,24 @@ A decision says why it won and rarely says when that reason stops holding, so an
73
73
  - Draft it from the harm the decision prevents, never from the mechanism it uses. A mechanism can keep working long after the reason for choosing it is gone, so re-checking it confirms the wrong fact.
74
74
  - Place the sentence after the reasoning and before any verification anchor, so the anchor stays the entry's last sentence.
75
75
  - Give a heading holding several principles one sentence, for the premise they share, rather than one per principle.
76
- - Adopt the requirement by stating the clause `Every decision closes with a revisit sentence.` in the record itself. The clause is the record's own, like the cap, so a checker reads it from the file and gates only a record that states it. A record stating none is reported and never gated.
76
+ - Adopt the requirement by stating the clause `Every decision closes with a revisit sentence.` in the record itself. The clause is the record's own, like the caps, so a checker reads it from the file and gates only a record that states it. A record stating none is reported and never gated.
77
77
  - A checker reads presence alone. A vacuous sentence passes it, so the sentence is only as useful as the finding it names.
78
78
 
79
- ## Entry cap
79
+ ## Caps
80
80
 
81
- Every session pays for this file before any work starts, so a heavy read is a real cost. A file that reads heavy is carrying too many decisions, not decisions written too long, so the bound is a count of decisions rather than of words.
81
+ Every session pays for this file before any work starts, so a heavy read is a real cost. Weight comes from two places, too many decisions and decisions written too long, so the record caps both, and caps the open risks beside them.
82
82
 
83
- - State the cap in the record itself as a clause of the form `This record holds at most <n> decisions.` The cap is the record's own, so a checker reads it from the file, and a record stating none is measured and never gated.
84
- - Set the cap near one decision per slot plus a small margin.
85
- - At the cap, merge two decisions or retire one before adding another, and name which in the change that does it. Never compress a decision's prose to fit, and never pack two decisions under one heading, which the count cannot see.
83
+ - State each cap in the record itself. A checker reads only these spellings, so a clause worded another way is reported and never gated:
84
+ - `This record holds at most <n> decisions.`
85
+ - `at most <n> words a decision`, counting a decision's prose below its heading, fenced lines left out
86
+ - `at most <n> risk bullets`, counting every list item under `## Risks / open questions`, nested ones included
87
+ - The three can share one sentence, such as `This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets.` Each cap is the record's own, so a record stating none is measured and never gated, and the template states the entry cap alone so the other two are adopted on purpose.
88
+ - Set the decision cap near one decision per slot plus a small margin, the word cap near what one trade takes to state, about 150, and the risk cap at what a reader holds in mind at once, about 6.
89
+ - At the decision cap, merge two decisions or retire one before adding another, and name which in the change that does it. Never pack two decisions under one heading, which the count cannot see.
90
+ - At the word cap, cut a decision to the choice, the alternative that lost, the reason, and the revisit sentence. What it sheds is a mechanism or a measurement, which goes to the domain context entry it constrains, never under a second heading.
91
+ - At the risk cap, drop what is no longer open before adding a risk.
86
92
  - Retire a decision by moving it to the domain context entry it constrains, not by deleting its reasoning.
87
- - A word count, for the file and for each decision, is read alongside the judgment when one is available, and it never gates.
93
+ - The whole file's word count stays a report, read alongside the judgment, and never gates.
88
94
  - Yield to the paragraph weight checkpoint in `markdown.md`. A paragraph past the checkpoint is a defect no length guideline licenses.
89
95
 
90
96
  ## Template
@@ -36,6 +36,10 @@ Does not govern:
36
36
 
37
37
  Use `## Problem`, `## Goals`, `## Non-goals`, `## MVP features`, and `## Constraints`. Add `## Distribution` when the rule below applies. Add `## Worldview` when the project holds beliefs that shape every decision, and keep a belief there only when it changes a decision, since a padded worldview turns into a second goals list. Add `## Premise` when the project exists to answer a question, stated as the question and what would count as an answer, never as the answer measured so far. Drop a section rather than pad it with filler.
38
38
 
39
+ ## Length
40
+
41
+ This file loads into every session, so its weight is paid before any work starts. Cap it by stating the clause `This record holds at most <n> words.` in the record itself, where a checker reads it and counts every word below the frontmatter. The cap is the record's own, so a record stating none is measured and never gated. Set it near 600, and at the cap cut a goal or a constraint that no longer changes a decision before adding one.
42
+
39
43
  ## Lifecycle
40
44
 
41
45
  Once every entry in the MVP list, or in a later scope section, has shipped, delete the section. Git keeps the old text, and the document states the project as it stands. Do not annotate an entry as shipped and leave it in place. While any entry is unshipped, do not renumber the list or append to it.
@@ -75,7 +75,7 @@ jobs:
75
75
 
76
76
  - name: Skip notice
77
77
  if: steps.secrets-check.outputs.skip == 'true'
78
- run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-cloudflare skill to finish setup."
78
+ run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-app skill to finish setup."
79
79
 
80
80
  # This job builds nothing and still needs a toolchain. The checkout
81
81
  # below brings bun.lock, wrangler-action reads it, picks bun on the
@@ -110,7 +110,7 @@ jobs:
110
110
  accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
111
111
  wranglerVersion: '3.90.0'
112
112
  # Fill in this project's Pages project name, set when the
113
- # deploy-cloudflare skill runs wrangler pages project create.
113
+ # deploy-app skill runs wrangler pages project create.
114
114
  command: pages deploy dist --project-name=REPLACE_WITH_PROJECT_NAME --branch=${{ github.ref_name }}
115
115
 
116
116
  # `canon pr preview` reads this line out of the run log, so the marker is
@@ -20,7 +20,7 @@ Three values in the seeded file have no generic default and are set once, right
20
20
 
21
21
  - The `paths:` filter under the `push` trigger. The shipped default is a single `src/**` glob. Widen it to cover every path the build reads when the build draws on catalogs or generated content living outside `src/`.
22
22
  - The `concurrency.group` name. The shipped default is `deploy-${{ github.ref_name }}`, which is enough for a single-deploy project. Give the `deploy` prefix a project-specific name only if the project runs more than one deploy workflow, and keep the ref suffix.
23
- - The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-cloudflare` skill creates the Pages project under.
23
+ - The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-app` skill creates the Pages project under.
24
24
 
25
25
  ## Re-sync
26
26