@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.
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +3 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/skill-map.md +9 -9
- package/package.json +2 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +13 -7
- package/standards/requirements.md +4 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- 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
|
|
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
|
|
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
|
|
1238
|
+
await writeIfChanged(rootPath, renderRootPage(listed.workspaces))
|
|
1238
1239
|
|
|
1239
|
-
await
|
|
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
|
|
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
|
|
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
|
)
|
package/src/teach/workspace.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
##
|
|
79
|
+
## Caps
|
|
80
80
|
|
|
81
|
-
Every session pays for this file before any work starts, so a heavy read is a real cost.
|
|
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
|
|
84
|
-
-
|
|
85
|
-
-
|
|
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
|
-
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|