@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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -4
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +0 -3
- package/claude/skills/context-fold/REQUIREMENT.md +0 -1
- package/claude/skills/design-taste/references/kinds.md +0 -1
- package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
- package/claude/skills/draw-figure/SKILL.md +4 -5
- package/claude/skills/draw-figure/references/figures.md +0 -1
- package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
- package/claude/skills/pick-by-eye/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-update/REQUIREMENT.md +33 -0
- package/claude/skills/target-update/SKILL.md +136 -0
- package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
- package/claude/skills/teach-craft/SKILL.md +39 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
- package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
- package/docs/agents/audits.md +0 -1
- package/docs/agents/migrate.md +2 -1
- package/docs/target/migrations.md +12 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +10 -22
- package/docs/workflow/skill-map.md +13 -14
- package/package.json +1 -4
- package/scripts/core/regen-web-previews.ts +2 -67
- package/src/audits/catalog.ts +0 -1
- package/src/browser/engine.ts +3 -6
- package/src/capture/render.ts +1 -1
- package/src/claude/cases/authoring.ts +0 -12
- package/src/claude/cases/misc.ts +0 -17
- package/src/claude/cases/setup.ts +7 -0
- package/src/claude/cases/workflow.ts +2 -38
- package/src/claude/skills-audit.ts +0 -1
- package/src/cli/help.ts +0 -7
- package/src/cli.ts +0 -6
- package/src/commands/labels.ts +9 -7
- package/src/commands/records.ts +0 -1
- package/src/commands/serve.ts +2 -2
- package/src/design/components.ts +8 -1514
- package/src/design/css.ts +5 -6
- package/src/design/fonts.ts +3 -5
- package/src/driver/drive.ts +3 -4
- package/src/driver/steps.ts +2 -5
- package/src/inventory/walk.ts +1 -1
- package/src/markdown/scope.ts +4 -31
- package/src/migrate/status.ts +5 -1
- package/src/records/validate.ts +4 -176
- package/src/sandbox/equivalence.ts +0 -5
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
- package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
- package/claude/skills/draft-and-pick/SKILL.md +0 -11
- package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
- package/claude/skills/draft-doc/SKILL.md +0 -11
- package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
- package/claude/skills/draft-figure/SKILL.md +0 -11
- package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
- package/claude/skills/draft-identity/SKILL.md +0 -11
- package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
- package/claude/skills/draft-ready/SKILL.md +0 -11
- package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
- package/claude/skills/draft-screencast/SKILL.md +0 -11
- package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
- package/claude/skills/draft-slides/SKILL.md +0 -11
- package/claude/skills/make-deck/REQUIREMENT.md +0 -36
- package/claude/skills/make-deck/SKILL.md +0 -80
- package/claude/skills/make-deck/references/deck.md +0 -183
- package/claude/skills/read-frames/REQUIREMENT.md +0 -30
- package/claude/skills/read-frames/SKILL.md +0 -55
- package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
- package/claude/skills/record-screencast/SKILL.md +0 -99
- package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
- package/claude/skills/script-screencast/SKILL.md +0 -70
- package/claude/skills/script-screencast/references/beats-template.md +0 -119
- package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
- package/claude/skills/teach-workspace/SKILL.md +0 -196
- package/claude/skills/teach-workspace/references/glossary.md +0 -90
- package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
- package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
- package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
- package/claude/skills/teach-workspace/references/promotion.md +0 -116
- package/claude/skills/teach-workspace/references/revisit.md +0 -28
- package/claude/skills/teach-workspace/references/teach.md +0 -211
- package/claude/skills/video-craft/REQUIREMENT.md +0 -35
- package/claude/skills/video-craft/SKILL.md +0 -96
- package/claude/skills/video-craft/references/adopted.md +0 -110
- package/src/commands/demo.ts +0 -542
- package/src/commands/slides.ts +0 -184
- package/src/commands/teach.ts +0 -1401
- package/src/demo/beats.ts +0 -135
- package/src/demo/compile.ts +0 -350
- package/src/demo/container.ts +0 -219
- package/src/demo/cursors.ts +0 -55
- package/src/demo/drive.ts +0 -442
- package/src/demo/pointer.ts +0 -178
- package/src/demo/theme.ts +0 -112
- package/src/demo/timeline.ts +0 -64
- package/src/slides/convert/chart.ts +0 -165
- package/src/slides/convert/deck.ts +0 -244
- package/src/slides/convert/export.ts +0 -567
- package/src/slides/convert/master.ts +0 -198
- package/src/slides/convert/shapes.ts +0 -804
- package/src/slides/convert/svg.ts +0 -38
- package/src/slides/convert/walk.ts +0 -671
- package/src/slides/decks.ts +0 -110
- package/src/slides/open.ts +0 -18
- package/src/slides/package/fonts.ts +0 -301
- package/src/slides/package/motion.ts +0 -316
- package/src/slides/package/test-face.ts +0 -113
- package/src/teach/browser/compile.ts +0 -44
- package/src/teach/browser/dismiss.ts +0 -76
- package/src/teach/browser/focus-line.ts +0 -19
- package/src/teach/browser/glossary-filter.ts +0 -44
- package/src/teach/browser/head.ts +0 -30
- package/src/teach/browser/quiz.ts +0 -19
- package/src/teach/browser/reload.ts +0 -9
- package/src/teach/browser/sidebar.ts +0 -233
- package/src/teach/browser/theme.ts +0 -18
- package/src/teach/components/code.tsx +0 -24
- package/src/teach/components/heading.tsx +0 -11
- package/src/teach/components/list.tsx +0 -17
- package/src/teach/components/paragraph.tsx +0 -29
- package/src/teach/components/refs.tsx +0 -28
- package/src/teach/cross-references.ts +0 -78
- package/src/teach/fonts.ts +0 -16
- package/src/teach/highlight.ts +0 -71
- package/src/teach/html/jsx-dev-runtime.ts +0 -13
- package/src/teach/html/jsx-runtime.ts +0 -113
- package/src/teach/html/raw.ts +0 -28
- package/src/teach/lesson.ts +0 -178
- package/src/teach/nav.ts +0 -1337
- package/src/teach/render.ts +0 -240
- package/src/teach/workspace.ts +0 -1164
- 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
|
-
}
|