@erclx/canon 5.51.0 → 5.53.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 (142) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -4
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +0 -3
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-taste/references/kinds.md +0 -1
  7. package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
  8. package/claude/skills/draw-figure/SKILL.md +4 -5
  9. package/claude/skills/draw-figure/references/figures.md +0 -1
  10. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  11. package/claude/skills/git-pr/references/pr.md +1 -1
  12. package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
  13. package/claude/skills/pick-by-eye/SKILL.md +1 -1
  14. package/claude/skills/review-address/SKILL.md +1 -1
  15. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  16. package/claude/skills/review-pr/references/post.md +2 -2
  17. package/claude/skills/target-check/SKILL.md +2 -0
  18. package/claude/skills/target-setup/SKILL.md +1 -1
  19. package/claude/skills/target-update/REQUIREMENT.md +33 -0
  20. package/claude/skills/target-update/SKILL.md +136 -0
  21. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  22. package/claude/skills/teach-craft/SKILL.md +39 -0
  23. package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
  24. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
  25. package/docs/agents/audits.md +0 -1
  26. package/docs/agents/migrate.md +2 -1
  27. package/docs/target/migrations.md +12 -1
  28. package/docs/target/projects.md +1 -1
  29. package/docs/target/sync.md +1 -1
  30. package/docs/workflow/parallel-features.md +1 -1
  31. package/docs/workflow/skill-map-on-request.md +10 -22
  32. package/docs/workflow/skill-map.md +13 -14
  33. package/package.json +1 -4
  34. package/scripts/core/regen-web-previews.ts +2 -67
  35. package/src/audits/catalog.ts +0 -1
  36. package/src/browser/engine.ts +3 -6
  37. package/src/capture/render.ts +1 -1
  38. package/src/claude/cases/authoring.ts +0 -12
  39. package/src/claude/cases/misc.ts +0 -17
  40. package/src/claude/cases/setup.ts +7 -0
  41. package/src/claude/cases/workflow.ts +2 -38
  42. package/src/claude/skills-audit.ts +0 -1
  43. package/src/cli/help.ts +0 -7
  44. package/src/cli.ts +0 -6
  45. package/src/commands/labels.ts +9 -7
  46. package/src/commands/records.ts +0 -1
  47. package/src/commands/serve.ts +2 -2
  48. package/src/design/components.ts +8 -1514
  49. package/src/design/css.ts +5 -6
  50. package/src/design/fonts.ts +3 -5
  51. package/src/driver/drive.ts +3 -4
  52. package/src/driver/steps.ts +2 -5
  53. package/src/inventory/walk.ts +1 -1
  54. package/src/markdown/scope.ts +4 -31
  55. package/src/migrate/status.ts +5 -1
  56. package/src/records/validate.ts +4 -176
  57. package/src/sandbox/equivalence.ts +0 -5
  58. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
  59. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  60. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  61. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  62. package/claude/skills/draft-doc/SKILL.md +0 -11
  63. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  64. package/claude/skills/draft-figure/SKILL.md +0 -11
  65. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  66. package/claude/skills/draft-identity/SKILL.md +0 -11
  67. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  68. package/claude/skills/draft-ready/SKILL.md +0 -11
  69. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  70. package/claude/skills/draft-screencast/SKILL.md +0 -11
  71. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  72. package/claude/skills/draft-slides/SKILL.md +0 -11
  73. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  74. package/claude/skills/make-deck/SKILL.md +0 -80
  75. package/claude/skills/make-deck/references/deck.md +0 -183
  76. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  77. package/claude/skills/read-frames/SKILL.md +0 -55
  78. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  79. package/claude/skills/record-screencast/SKILL.md +0 -99
  80. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  81. package/claude/skills/script-screencast/SKILL.md +0 -70
  82. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  83. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  84. package/claude/skills/teach-workspace/SKILL.md +0 -196
  85. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  86. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  87. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  88. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  89. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  90. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  91. package/claude/skills/teach-workspace/references/teach.md +0 -211
  92. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  93. package/claude/skills/video-craft/SKILL.md +0 -96
  94. package/claude/skills/video-craft/references/adopted.md +0 -110
  95. package/src/commands/demo.ts +0 -542
  96. package/src/commands/slides.ts +0 -184
  97. package/src/commands/teach.ts +0 -1401
  98. package/src/demo/beats.ts +0 -135
  99. package/src/demo/compile.ts +0 -350
  100. package/src/demo/container.ts +0 -219
  101. package/src/demo/cursors.ts +0 -55
  102. package/src/demo/drive.ts +0 -442
  103. package/src/demo/pointer.ts +0 -178
  104. package/src/demo/theme.ts +0 -112
  105. package/src/demo/timeline.ts +0 -64
  106. package/src/slides/convert/chart.ts +0 -165
  107. package/src/slides/convert/deck.ts +0 -244
  108. package/src/slides/convert/export.ts +0 -567
  109. package/src/slides/convert/master.ts +0 -198
  110. package/src/slides/convert/shapes.ts +0 -804
  111. package/src/slides/convert/svg.ts +0 -38
  112. package/src/slides/convert/walk.ts +0 -671
  113. package/src/slides/decks.ts +0 -110
  114. package/src/slides/open.ts +0 -18
  115. package/src/slides/package/fonts.ts +0 -301
  116. package/src/slides/package/motion.ts +0 -316
  117. package/src/slides/package/test-face.ts +0 -113
  118. package/src/teach/browser/compile.ts +0 -44
  119. package/src/teach/browser/dismiss.ts +0 -76
  120. package/src/teach/browser/focus-line.ts +0 -19
  121. package/src/teach/browser/glossary-filter.ts +0 -44
  122. package/src/teach/browser/head.ts +0 -30
  123. package/src/teach/browser/quiz.ts +0 -19
  124. package/src/teach/browser/reload.ts +0 -9
  125. package/src/teach/browser/sidebar.ts +0 -233
  126. package/src/teach/browser/theme.ts +0 -18
  127. package/src/teach/components/code.tsx +0 -24
  128. package/src/teach/components/heading.tsx +0 -11
  129. package/src/teach/components/list.tsx +0 -17
  130. package/src/teach/components/paragraph.tsx +0 -29
  131. package/src/teach/components/refs.tsx +0 -28
  132. package/src/teach/cross-references.ts +0 -78
  133. package/src/teach/fonts.ts +0 -16
  134. package/src/teach/highlight.ts +0 -71
  135. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  136. package/src/teach/html/jsx-runtime.ts +0 -113
  137. package/src/teach/html/raw.ts +0 -28
  138. package/src/teach/lesson.ts +0 -178
  139. package/src/teach/nav.ts +0 -1337
  140. package/src/teach/render.ts +0 -240
  141. package/src/teach/workspace.ts +0 -1164
  142. package/src/teach/write-if-changed.ts +0 -17
@@ -1,38 +0,0 @@
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
- }
@@ -1,671 +0,0 @@
1
- import type { ChartRecord } from '@/slides/convert/chart'
2
- import type { ThemeReading } from '@/slides/convert/master'
3
- import type { TransitionSpec } from '@/slides/package/motion'
4
- import type {
5
- BoxStyle,
6
- ElementRecord,
7
- Rect,
8
- Rgba,
9
- ShadowRecord,
10
- TableCellRecord,
11
- TextContent,
12
- TextRun,
13
- } from '@/slides/convert/shapes'
14
-
15
- /** How one slide treats a band the deck master draws. */
16
- export type BandOverride =
17
- | { readonly kind: 'master' }
18
- | { readonly kind: 'off' }
19
- | {
20
- readonly kind: 'override'
21
- readonly left?: string
22
- readonly center?: string
23
- readonly right?: string
24
- }
25
-
26
- /** What a slide declares about itself on `<body>` and in its notes. */
27
- export interface SlideMeta {
28
- readonly notes?: string
29
- readonly section?: string
30
- readonly isHidden: boolean
31
- readonly header: BandOverride
32
- readonly footer: BandOverride
33
- /** The `--color-text` the slide's `<body>` computes, as hex. */
34
- readonly textToken?: string
35
- /** `data-transition` and `data-transition-duration` on `<body>`. */
36
- readonly transition?: TransitionSpec
37
- }
38
-
39
- /**
40
- * An element's `data-enter`, with the record it was walked as, or null when it
41
- * was folded into another record's text or never drawn.
42
- */
43
- export interface EntranceRecord {
44
- readonly record: number | null
45
- readonly selector: string
46
- readonly effect: string
47
- readonly order?: string
48
- readonly duration?: string
49
- }
50
-
51
- /** What one laid-out slide hands back across `page.evaluate`. */
52
- export interface WalkedSlide {
53
- readonly background: Rgba
54
- readonly records: ElementRecord[]
55
- readonly charts: ChartRecord[]
56
- readonly entrances: EntranceRecord[]
57
- readonly meta: SlideMeta
58
- }
59
-
60
- /** Set on every walked element, so a screenshot finds the one a record names. */
61
- export const ID_ATTRIBUTE = 'data-canon-id'
62
-
63
- /**
64
- * Runs inside the laid-out page and returns one record per drawable element in
65
- * document order. Playwright serializes this function to source, so every
66
- * helper it calls is declared inside its own body: a reference to anything at
67
- * module scope typechecks and then throws in the page.
68
- *
69
- * Text gathers along the inline flow. A block holding text takes every run its
70
- * inline descendants carry, so `<strong>` inside `<p>` is one shape with two
71
- * runs, while a block-level child is walked on its own. Images, SVGs, and
72
- * tables consume their whole subtree. Speaker notes in `<aside class="notes">`
73
- * and a chart's `<figure data-chart>` are read whole and never drawn as shapes.
74
- *
75
- * Each walked element is tagged with `idAttribute` set to its record id.
76
- */
77
- export function walkSlide(idAttribute: string): WalkedSlide {
78
- const canvas = document.createElement('canvas')
79
- canvas.width = 1
80
- canvas.height = 1
81
- const context = canvas.getContext('2d', { willReadFrequently: true })
82
-
83
- const rgba = (value: string): Rgba => {
84
- if (!context || !value || value === 'transparent') {
85
- return { hex: '000000', alpha: 0 }
86
- }
87
- context.clearRect(0, 0, 1, 1)
88
- context.fillStyle = '#000000'
89
- context.fillStyle = value
90
- context.fillRect(0, 0, 1, 1)
91
- const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data
92
- const hex = [r, g, b]
93
- .map((channel) => channel.toString(16).padStart(2, '0'))
94
- .join('')
95
- .toUpperCase()
96
- return { hex, alpha: Math.round((a / 255) * 100) / 100 }
97
- }
98
-
99
- const px = (value: string): number => Number.parseFloat(value) || 0
100
-
101
- const splitTopLevel = (value: string): string[] => {
102
- const parts: string[] = []
103
- let depth = 0
104
- let current = ''
105
- for (const char of value) {
106
- if (char === '(') depth += 1
107
- if (char === ')') depth -= 1
108
- if (char === ',' && depth === 0) {
109
- parts.push(current.trim())
110
- current = ''
111
- } else {
112
- current += char
113
- }
114
- }
115
- if (current.trim()) parts.push(current.trim())
116
- return parts
117
- }
118
-
119
- const shadows = (value: string): ShadowRecord[] => {
120
- if (!value || value === 'none') return []
121
- return splitTopLevel(value).map((part) => {
122
- const colorMatch = /[a-z-]+\([^)]*\)|#[0-9a-f]+/i.exec(part)
123
- const rest = colorMatch ? part.replace(colorMatch[0], ' ') : part
124
- const [x = 0, y = 0, blur = 0] = (rest.match(/-?[\d.]+px/g) ?? []).map(px)
125
- return {
126
- x,
127
- y,
128
- blur,
129
- color: rgba(colorMatch?.[0] ?? 'rgb(0, 0, 0)'),
130
- inset: /\binset\b/.test(rest),
131
- }
132
- })
133
- }
134
-
135
- const rectOf = (domRect: DOMRect): Rect => ({
136
- x: domRect.left,
137
- y: domRect.top,
138
- w: domRect.width,
139
- h: domRect.height,
140
- })
141
-
142
- const boxStyle = (element: Element, bounds: Rect): BoxStyle => {
143
- const style = getComputedStyle(element)
144
- const radius = style.borderTopLeftRadius.endsWith('%')
145
- ? (px(style.borderTopLeftRadius) / 100) * Math.min(bounds.w, bounds.h)
146
- : px(style.borderTopLeftRadius)
147
- return {
148
- background: rgba(style.backgroundColor),
149
- borderWidth: {
150
- top: px(style.borderTopWidth),
151
- right: px(style.borderRightWidth),
152
- bottom: px(style.borderBottomWidth),
153
- left: px(style.borderLeftWidth),
154
- },
155
- borderColor: rgba(style.borderTopColor),
156
- borderStyle: style.borderTopStyle,
157
- radius,
158
- padding: {
159
- top: px(style.paddingTop),
160
- right: px(style.paddingRight),
161
- bottom: px(style.paddingBottom),
162
- left: px(style.paddingLeft),
163
- },
164
- shadows: shadows(style.boxShadow),
165
- raw: {
166
- backgroundImage: style.backgroundImage,
167
- transform: style.transform,
168
- filter: style.filter,
169
- clipPath: style.clipPath,
170
- mask: style.maskImage || 'none',
171
- mixBlendMode: style.mixBlendMode,
172
- backdropFilter: style.backdropFilter || 'none',
173
- borderWidth: style.borderWidth,
174
- borderColor: style.borderColor,
175
- borderStyle: style.borderStyle,
176
- borderRadius: style.borderRadius,
177
- },
178
- }
179
- }
180
-
181
- const describe = (element: Element): string => {
182
- const tag = element.tagName.toLowerCase()
183
- if (element.id) return `${tag}#${element.id}`
184
- const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
185
- return className ? `${tag}.${className}` : tag
186
- }
187
-
188
- const isInlineFlow = (element: Element): boolean => {
189
- const tag = element.tagName.toLowerCase()
190
- if (tag === 'img' || tag === 'svg' || tag === 'table') return false
191
- return getComputedStyle(element).display === 'inline'
192
- }
193
-
194
- const transformText = (text: string, transform: string): string => {
195
- if (transform === 'uppercase') return text.toUpperCase()
196
- if (transform === 'lowercase') return text.toLowerCase()
197
- if (transform === 'capitalize') {
198
- return text.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase())
199
- }
200
- return text
201
- }
202
-
203
- /**
204
- * Collects the runs of `root`'s inline flow, adding every inline element it
205
- * passed through to `consumed`. `deep` follows block children too, which a
206
- * table cell needs since it is drawn as one cell however it nests.
207
- */
208
- const gatherRuns = (
209
- root: Element,
210
- consumed: Set<Element>,
211
- deep: boolean,
212
- ): TextRun[] => {
213
- const runs: TextRun[] = []
214
- let breakPending = false
215
- const visit = (node: Node): void => {
216
- for (const child of Array.from(node.childNodes)) {
217
- if (child.nodeType === Node.TEXT_NODE) {
218
- const parent = child.parentElement
219
- if (!parent) continue
220
- const style = getComputedStyle(parent)
221
- const text = (child.textContent ?? '').replace(/\s+/g, ' ')
222
- if (!text.trim() && !runs.length) continue
223
- if (!text) continue
224
- const spacing = style.letterSpacing
225
- const isUnderlined = (inline: Element): boolean => {
226
- for (let at: Element | null = inline; at; at = at.parentElement) {
227
- if (
228
- getComputedStyle(at).textDecorationLine.includes('underline')
229
- ) {
230
- return true
231
- }
232
- if (at === root) break
233
- }
234
- return false
235
- }
236
- const highlightOf = (inline: Element): Rgba | undefined => {
237
- if (inline === root) return undefined
238
- const ground = rgba(getComputedStyle(inline).backgroundColor)
239
- return ground.alpha > 0 ? ground : undefined
240
- }
241
- runs.push({
242
- text: transformText(text, style.textTransform),
243
- color: rgba(style.color),
244
- fontSize: px(style.fontSize),
245
- fontWeight: Number(style.fontWeight) || 400,
246
- italic: style.fontStyle === 'italic',
247
- underline: isUnderlined(parent),
248
- fontFamily: style.fontFamily,
249
- letterSpacing: spacing === 'normal' ? 0 : px(spacing),
250
- highlight: highlightOf(parent),
251
- href: parent.closest('a[href]')?.getAttribute('href') ?? undefined,
252
- breakBefore: breakPending || undefined,
253
- })
254
- breakPending = false
255
- } else if (child instanceof Element) {
256
- if (child.tagName.toLowerCase() === 'br') {
257
- breakPending = true
258
- consumed.add(child)
259
- } else if (deep || isInlineFlow(child)) {
260
- consumed.add(child)
261
- visit(child)
262
- }
263
- }
264
- }
265
- }
266
- visit(root)
267
- return tidyRuns(runs)
268
- }
269
-
270
- /** Collapses the spaces the browser would, across run boundaries included. */
271
- const tidyRuns = (runs: TextRun[]): TextRun[] => {
272
- const tidied: TextRun[] = []
273
- for (const run of runs) {
274
- const previous = tidied.at(-1)
275
- let text = run.text
276
- if (!previous || run.breakBefore || previous.text.endsWith(' ')) {
277
- text = text.replace(/^ /, '')
278
- }
279
- if (text) tidied.push({ ...run, text })
280
- }
281
- const last = tidied.at(-1)
282
- if (last) tidied[tidied.length - 1] = { ...last, text: last.text.trimEnd() }
283
- return tidied.filter((run) => run.text.length > 0)
284
- }
285
-
286
- const textContent = (element: Element, runs: TextRun[]): TextContent => {
287
- const style = getComputedStyle(element)
288
- const fontSize = px(style.fontSize)
289
- return {
290
- runs,
291
- align: style.textAlign,
292
- lineHeight:
293
- style.lineHeight === 'normal' ? fontSize * 1.2 : px(style.lineHeight),
294
- fontSize,
295
- }
296
- }
297
-
298
- const renderedText = (element: Element): string => {
299
- const text =
300
- element instanceof HTMLElement ? element.innerText : element.textContent
301
- return (text ?? '').replace(/\s+/g, ' ').trim()
302
- }
303
-
304
- const layoutBox = (element: Element, bounds: Rect): Rect => {
305
- if (getComputedStyle(element).transform === 'none') return bounds
306
- if (!(element instanceof HTMLElement)) return bounds
307
- const w = element.offsetWidth
308
- const h = element.offsetHeight
309
- return {
310
- x: bounds.x + (bounds.w - w) / 2,
311
- y: bounds.y + (bounds.h - h) / 2,
312
- w,
313
- h,
314
- }
315
- }
316
-
317
- const records: ElementRecord[] = []
318
- const ids = new Map<Element, number>()
319
- const consumed = new Set<Element>()
320
-
321
- const parentId = (element: Element): number | null => {
322
- let ancestor = element.parentElement
323
- while (ancestor) {
324
- const id = ids.get(ancestor)
325
- if (id !== undefined) return id
326
- ancestor = ancestor.parentElement
327
- }
328
- return null
329
- }
330
-
331
- const consumeSubtree = (element: Element): void => {
332
- for (const descendant of Array.from(element.querySelectorAll('*'))) {
333
- consumed.add(descendant)
334
- }
335
- }
336
-
337
- const SKIPPED = new Set(['script', 'style', 'template', 'noscript', 'link'])
338
-
339
- const cellText = (element: Element): string =>
340
- (element.textContent ?? '').replace(/\s+/g, ' ').trim()
341
-
342
- /** One line per block, so a paragraph break survives and no tag does. */
343
- const notesText = (element: Element): string => {
344
- const clone = element.cloneNode(true)
345
- if (!(clone instanceof Element)) return ''
346
- for (const br of Array.from(clone.querySelectorAll('br'))) {
347
- br.replaceWith('\n')
348
- }
349
- for (const block of Array.from(
350
- clone.querySelectorAll('p, li, div, blockquote, h1, h2, h3, h4, h5, h6'),
351
- )) {
352
- block.append('\n')
353
- }
354
- return (clone.textContent ?? '')
355
- .split('\n')
356
- .map((line) => line.replace(/\s+/g, ' ').trim())
357
- .filter(Boolean)
358
- .join('\n')
359
- }
360
-
361
- const notes: string[] = []
362
- const charts: ChartRecord[] = []
363
-
364
- for (const element of Array.from(document.body.querySelectorAll('*'))) {
365
- if (consumed.has(element)) continue
366
- const tag = element.tagName.toLowerCase()
367
- if (SKIPPED.has(tag)) continue
368
- if (tag === 'aside' && element.classList.contains('notes')) {
369
- consumeSubtree(element)
370
- const text = notesText(element)
371
- if (text) notes.push(text)
372
- continue
373
- }
374
- if (!element.checkVisibility({ visibilityProperty: true })) continue
375
-
376
- const id = records.length + 1
377
- const bounds = rectOf(element.getBoundingClientRect())
378
- const base = {
379
- id,
380
- parent: parentId(element),
381
- selector: describe(element),
382
- box: layoutBox(element, bounds),
383
- bounds,
384
- style: boxStyle(element, bounds),
385
- text: renderedText(element),
386
- }
387
- ids.set(element, id)
388
- element.setAttribute(idAttribute, String(id))
389
-
390
- if (tag === 'figure' && element.hasAttribute('data-chart')) {
391
- consumeSubtree(element)
392
- records.push({ ...base, kind: 'box' })
393
- const table = element.querySelector('table')
394
- const caption = element.querySelector('figcaption')
395
- const title = caption ? cellText(caption) : ''
396
- charts.push({
397
- selector: base.selector,
398
- box: bounds,
399
- type: element.getAttribute('data-chart') ?? '',
400
- hasLabels: element.hasAttribute('data-labels'),
401
- ...(title ? { title } : {}),
402
- rows: table
403
- ? Array.from(table.rows).map((row) =>
404
- Array.from(row.cells).map(cellText),
405
- )
406
- : [],
407
- })
408
- continue
409
- }
410
-
411
- if (element instanceof HTMLImageElement) {
412
- records.push({
413
- ...base,
414
- kind: 'image',
415
- src: element.currentSrc || element.src,
416
- alt: element.alt,
417
- })
418
- continue
419
- }
420
-
421
- if (element instanceof SVGSVGElement) {
422
- consumeSubtree(element)
423
- const style = getComputedStyle(element)
424
- const vars: Record<string, string> = {}
425
- for (const [, name] of element.outerHTML.matchAll(
426
- /var\(\s*(--[\w-]+)/g,
427
- )) {
428
- if (!name) continue
429
- const value = style.getPropertyValue(name).trim()
430
- if (value) vars[name] = value
431
- }
432
- records.push({
433
- ...base,
434
- kind: 'svg',
435
- markup: element.outerHTML,
436
- colors: { color: style.color, vars },
437
- alt:
438
- element.getAttribute('aria-label') ??
439
- element.querySelector('title')?.textContent ??
440
- '',
441
- })
442
- continue
443
- }
444
-
445
- if (element instanceof HTMLTableElement) {
446
- consumeSubtree(element)
447
- const rows = Array.from(element.rows).map((row) =>
448
- Array.from(row.cells).map((cell): TableCellRecord => {
449
- const cellBounds = rectOf(cell.getBoundingClientRect())
450
- return {
451
- ...textContent(cell, gatherRuns(cell, new Set(), true)),
452
- box: cellBounds,
453
- colspan: cell.colSpan,
454
- rowspan: cell.rowSpan,
455
- style: boxStyle(cell, cellBounds),
456
- verticalAlign: getComputedStyle(cell).verticalAlign,
457
- }
458
- }),
459
- )
460
- records.push({ ...base, kind: 'table', rows })
461
- continue
462
- }
463
-
464
- const gathered = new Set<Element>()
465
- const runs = gatherRuns(element, gathered, false)
466
- for (const inline of gathered) consumed.add(inline)
467
- if (runs.length === 0) {
468
- records.push({ ...base, kind: 'box' })
469
- continue
470
- }
471
-
472
- const blockChild = Array.from(element.children).find(
473
- (child) =>
474
- !consumed.has(child) &&
475
- !SKIPPED.has(child.tagName.toLowerCase()) &&
476
- child.checkVisibility() &&
477
- !getComputedStyle(child).display.startsWith('inline'),
478
- )
479
- const box = blockChild
480
- ? {
481
- ...base.box,
482
- h: Math.max(0, blockChild.getBoundingClientRect().top - base.box.y),
483
- }
484
- : base.box
485
-
486
- const style = getComputedStyle(element)
487
- const isListItem =
488
- style.display === 'list-item' && style.listStyleType !== 'none'
489
- const list = isListItem
490
- ? {
491
- style: style.listStyleType,
492
- depth: (() => {
493
- let depth = 0
494
- let ancestor = element.parentElement
495
- while (ancestor) {
496
- if (ancestor.tagName === 'UL' || ancestor.tagName === 'OL') {
497
- depth += 1
498
- }
499
- ancestor = ancestor.parentElement
500
- }
501
- return depth
502
- })(),
503
- ordinal:
504
- (element.parentElement instanceof HTMLOListElement
505
- ? element.parentElement.start
506
- : 1) +
507
- Array.from(element.parentElement?.children ?? [])
508
- .filter((sibling) => sibling.tagName === 'LI')
509
- .indexOf(element),
510
- }
511
- : undefined
512
-
513
- records.push({
514
- ...base,
515
- box,
516
- kind: 'text',
517
- content: textContent(element, runs),
518
- list,
519
- inlineStyles: Array.from(gathered)
520
- .filter((inline) => inline.tagName !== 'BR')
521
- .map((inline) =>
522
- boxStyle(inline, rectOf(inline.getBoundingClientRect())),
523
- ),
524
- })
525
- }
526
-
527
- const ground = (element: Element): Rgba =>
528
- rgba(getComputedStyle(element).backgroundColor)
529
- const bodyGround = ground(document.body)
530
- const htmlGround = ground(document.documentElement)
531
- const background =
532
- bodyGround.alpha > 0
533
- ? bodyGround
534
- : htmlGround.alpha > 0
535
- ? htmlGround
536
- : { hex: 'FFFFFF', alpha: 1 }
537
-
538
- const body = document.body
539
- const band = (kind: 'header' | 'footer'): BandOverride => {
540
- if (body.getAttribute(`data-${kind}`) === 'off') return { kind: 'off' }
541
- const slots: { left?: string; center?: string; right?: string } = {}
542
- for (const slot of ['left', 'center', 'right'] as const) {
543
- const value = body.getAttribute(`data-${kind}-${slot}`)
544
- if (value !== null) slots[slot] = value
545
- }
546
- return Object.keys(slots).length > 0
547
- ? { kind: 'override', ...slots }
548
- : { kind: 'master' }
549
- }
550
- const section = body.getAttribute('data-section')?.trim()
551
- const attribute = (element: Element, name: string): string | undefined =>
552
- element.getAttribute(name)?.trim() || undefined
553
- const transitionEffect = attribute(body, 'data-transition')
554
- const transitionDuration = attribute(body, 'data-transition-duration')
555
- const textValue = getComputedStyle(body)
556
- .getPropertyValue('--color-text')
557
- .trim()
558
- const meta: SlideMeta = {
559
- ...(textValue ? { textToken: rgba(textValue).hex } : {}),
560
- ...(notes.length > 0 ? { notes: notes.join('\n\n') } : {}),
561
- ...(section ? { section } : {}),
562
- isHidden:
563
- body.hasAttribute('data-hidden') &&
564
- body.getAttribute('data-hidden') !== 'false',
565
- header: band('header'),
566
- footer: band('footer'),
567
- ...(transitionEffect
568
- ? {
569
- transition: {
570
- effect: transitionEffect,
571
- ...(transitionDuration ? { duration: transitionDuration } : {}),
572
- },
573
- }
574
- : {}),
575
- }
576
-
577
- const entrances: EntranceRecord[] = Array.from(
578
- body.querySelectorAll('[data-enter]'),
579
- ).map((element) => {
580
- const order = attribute(element, 'data-enter-order')
581
- const duration = attribute(element, 'data-enter-duration')
582
- return {
583
- record: ids.get(element) ?? null,
584
- selector: describe(element),
585
- effect: attribute(element, 'data-enter') ?? '',
586
- ...(order ? { order } : {}),
587
- ...(duration ? { duration } : {}),
588
- }
589
- })
590
-
591
- return { background, records, charts, entrances, meta }
592
- }
593
-
594
- /**
595
- * Runs inside the laid-out page and reads the colors and face the deck master
596
- * takes. Tokens come from `<html>`'s computed style, so a slide that switches
597
- * theme on its own `<body>`, such as a dark cover, leaves the master on the
598
- * project's root theme. The body supplies the fallback colors and the face.
599
- * Serialized to source like `walkSlide`, so its helpers sit in its own body.
600
- */
601
- export function readTheme(): ThemeReading {
602
- const canvas = document.createElement('canvas')
603
- canvas.width = 1
604
- canvas.height = 1
605
- const context = canvas.getContext('2d', { willReadFrequently: true })
606
- const hex = (value: string): string | undefined => {
607
- if (!context || !value || value === 'transparent') return undefined
608
- context.clearRect(0, 0, 1, 1)
609
- context.fillStyle = '#000000'
610
- context.fillStyle = value
611
- context.fillRect(0, 0, 1, 1)
612
- const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data
613
- if (a === 0) return undefined
614
- return [r, g, b]
615
- .map((channel) => channel.toString(16).padStart(2, '0'))
616
- .join('')
617
- .toUpperCase()
618
- }
619
-
620
- const style = getComputedStyle(document.body)
621
- const rootStyle = getComputedStyle(document.documentElement)
622
- const token = (name: string): string | undefined => {
623
- const value = rootStyle.getPropertyValue(name).trim()
624
- return value ? hex(value) : undefined
625
- }
626
-
627
- const names: string[] = []
628
- for (const sheet of Array.from(document.styleSheets)) {
629
- let rules: CSSRuleList
630
- try {
631
- rules = sheet.cssRules
632
- } catch {
633
- continue
634
- }
635
- for (const rule of Array.from(rules)) {
636
- if (!(rule instanceof CSSStyleRule)) continue
637
- for (const name of Array.from(rule.style)) {
638
- if (name.startsWith('--color-') && !names.includes(name)) {
639
- names.push(name)
640
- }
641
- }
642
- }
643
- }
644
-
645
- const tokens: {
646
- background?: string
647
- text?: string
648
- muted?: string
649
- accent?: string
650
- } = {}
651
- for (const role of ['background', 'text', 'muted', 'accent'] as const) {
652
- const value = token(`--color-${role}`)
653
- if (value) tokens[role] = value
654
- }
655
-
656
- return {
657
- tokens,
658
- body: {
659
- background:
660
- hex(style.backgroundColor) ??
661
- hex(getComputedStyle(document.documentElement).backgroundColor) ??
662
- 'FFFFFF',
663
- color: hex(style.color) ?? '000000',
664
- },
665
- fontFamily: style.fontFamily,
666
- roles: names.flatMap((name) => {
667
- const value = token(name)
668
- return value ? [{ name, hex: value }] : []
669
- }),
670
- }
671
- }