@erclx/canon 5.15.0 → 5.17.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 (55) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
  3. package/claude/skills/draft-figure/SKILL.md +19 -4
  4. package/claude/skills/draft-wiki/SKILL.md +1 -1
  5. package/claude/skills/draft-wireframes/SKILL.md +1 -1
  6. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  7. package/claude/skills/search-craft/REQUIREMENT.md +57 -0
  8. package/claude/skills/search-craft/SKILL.md +83 -0
  9. package/claude/skills/teach-workspace/SKILL.md +1 -1
  10. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  11. package/docs/agents/canvas.md +36 -0
  12. package/docs/agents/commands.md +3 -3
  13. package/docs/agents/demo.md +3 -1
  14. package/docs/agents/index.md +1 -0
  15. package/docs/agents/teach.md +2 -2
  16. package/docs/workflow/skill-map.md +1 -0
  17. package/docs/workflow/visual-design-workflow.md +8 -15
  18. package/governance/rules/claude/566-output.md +3 -5
  19. package/governance/rules/claude/575-hooks.md +1 -1
  20. package/package.json +1 -1
  21. package/scripts/lib/sandbox-git.sh +2 -2
  22. package/src/canvas/address.ts +184 -0
  23. package/src/canvas/capture.ts +172 -0
  24. package/src/canvas/client/app.tsx +2 -0
  25. package/src/canvas/client/inspector.tsx +121 -0
  26. package/src/canvas/client/layers.tsx +118 -0
  27. package/src/canvas/client/pages-panel.tsx +47 -13
  28. package/src/canvas/client/shell.css +131 -1
  29. package/src/canvas/client/state.ts +186 -0
  30. package/src/canvas/client/surface.tsx +272 -20
  31. package/src/canvas/content.ts +307 -16
  32. package/src/canvas/server.ts +182 -13
  33. package/src/claude/cases/misc.ts +5 -0
  34. package/src/commands/canvas.ts +200 -3
  35. package/src/commands/demo.ts +2 -0
  36. package/src/commands/migrate.ts +1 -1
  37. package/src/commands/teach.ts +3 -1
  38. package/src/demo/drive.ts +36 -7
  39. package/src/demo/timeline.ts +64 -0
  40. package/src/migrate/rename.ts +5 -4
  41. package/src/teach/browser/compile.ts +44 -0
  42. package/src/teach/browser/dismiss.ts +76 -0
  43. package/src/teach/browser/focus-line.ts +19 -0
  44. package/src/teach/browser/glossary-filter.ts +44 -0
  45. package/src/teach/browser/head.ts +30 -0
  46. package/src/teach/browser/quiz.ts +19 -0
  47. package/src/teach/browser/sidebar.ts +233 -0
  48. package/src/teach/browser/theme.ts +18 -0
  49. package/src/teach/nav.ts +19 -374
  50. package/src/teach/workspace.ts +61 -6
  51. package/standards/figures.md +1 -0
  52. package/tooling/claude/reference.md +1 -1
  53. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
  54. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
  55. package/tooling/claude/seeds/.claude/settings.json +9 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The element address: a frame plus the element's index in document order.
3
+ * The shell counts it over the frame's parsed DOM and the server counts it over
4
+ * the file through `HTMLRewriter`, so the two have to count the same elements.
5
+ * This module imports nothing, which keeps it bundling into the shell, and the
6
+ * server half reaches `HTMLRewriter` as a Bun global only when called.
7
+ */
8
+
9
+ /** Marks the token stylesheet the server injects, which the file never holds. */
10
+ export const TOKENS_ATTRIBUTE = 'data-canvas-tokens'
11
+
12
+ /** On that same element, the hash of the file the frame was served from. */
13
+ export const HASH_ATTRIBUTE = 'data-canvas-hash'
14
+
15
+ const EXCERPT_LENGTH = 80
16
+
17
+ /** Elements whose content is raw text, which a browser never parses as markup. */
18
+ const RAW_TEXT = new Set(['script', 'style', 'template', 'textarea', 'title'])
19
+
20
+ export interface ElementAddress {
21
+ readonly index: number
22
+ readonly tag: string
23
+ /**
24
+ * Every element the shell counted. A browser implies elements the file never
25
+ * states, such as a `tbody` or the document wrapper, and each one shifts the
26
+ * count, so a total that differs from the server's is what exposes it.
27
+ */
28
+ readonly count: number
29
+ /**
30
+ * The served file's hash, so the server can refuse a pick made against a
31
+ * version of the frame the file has since moved past.
32
+ */
33
+ readonly hash?: string
34
+ }
35
+
36
+ export interface SourceElement {
37
+ readonly tag: string
38
+ readonly classes: readonly string[]
39
+ readonly text: string
40
+ }
41
+
42
+ export type AddressCheck =
43
+ | { readonly ok: true; readonly element: SourceElement }
44
+ | {
45
+ readonly ok: false
46
+ readonly reason: 'address-mismatch'
47
+ readonly detail: string
48
+ }
49
+
50
+ export function excerpt(text: string): string {
51
+ return text.replace(/\s+/g, ' ').trim().slice(0, EXCERPT_LENGTH)
52
+ }
53
+
54
+ export function documentElements(doc: Document): Element[] {
55
+ return [...doc.querySelectorAll('*')].filter(
56
+ (element) => !element.hasAttribute(TOKENS_ATTRIBUTE),
57
+ )
58
+ }
59
+
60
+ export function addressOf(
61
+ doc: Document,
62
+ element: Element,
63
+ ): ElementAddress | undefined {
64
+ const all = documentElements(doc)
65
+ const index = all.indexOf(element)
66
+ if (index === -1) return undefined
67
+ const address = {
68
+ index,
69
+ tag: element.tagName.toLowerCase(),
70
+ count: all.length,
71
+ }
72
+ const hash = doc
73
+ .querySelector(`[${TOKENS_ATTRIBUTE}]`)
74
+ ?.getAttribute(HASH_ATTRIBUTE)
75
+ return hash ? { ...address, hash } : address
76
+ }
77
+
78
+ /** The element an address names, or nothing once the tag there differs. */
79
+ export function elementAt(
80
+ doc: Document,
81
+ address: Pick<ElementAddress, 'index' | 'tag'>,
82
+ ): Element | undefined {
83
+ const element = documentElements(doc)[address.index]
84
+ return element?.tagName.toLowerCase() === address.tag ? element : undefined
85
+ }
86
+
87
+ interface Collected {
88
+ readonly tag: string
89
+ readonly classes: readonly string[]
90
+ text: string
91
+ }
92
+
93
+ /**
94
+ * Every element the file states, in document order. Text is gathered into
95
+ * each open element, which an element closed only by implication keeps open to
96
+ * the end, so its excerpt can run past where a browser would end it.
97
+ */
98
+ export function sourceElements(html: string): SourceElement[] {
99
+ const elements: Collected[] = []
100
+ const open: number[] = []
101
+ let rawDepth = 0
102
+ /* A template's content is a fragment `querySelectorAll` never reaches. */
103
+ let templateDepth = 0
104
+
105
+ const appendText = (text: string) => {
106
+ for (const index of open) {
107
+ const element = elements[index]
108
+ if (element && element.text.length < EXCERPT_LENGTH * 4) {
109
+ element.text += text
110
+ }
111
+ }
112
+ }
113
+
114
+ new HTMLRewriter()
115
+ .on('*', {
116
+ element(element) {
117
+ const tag = element.tagName.toLowerCase()
118
+ if (templateDepth > 0) {
119
+ if (tag === 'template') {
120
+ templateDepth += 1
121
+ element.onEndTag(() => {
122
+ templateDepth -= 1
123
+ })
124
+ }
125
+ return
126
+ }
127
+ const index = elements.length
128
+ /* A tag boundary separates words the way a rendered block would. */
129
+ appendText(' ')
130
+ elements.push({
131
+ tag,
132
+ classes: (element.getAttribute('class') ?? '')
133
+ .split(/\s+/)
134
+ .filter(Boolean),
135
+ text: '',
136
+ })
137
+ if (!element.canHaveContent || element.selfClosing) return
138
+ const isRaw = RAW_TEXT.has(tag)
139
+ const isTemplate = tag === 'template'
140
+ open.push(index)
141
+ if (isRaw) rawDepth += 1
142
+ if (isTemplate) templateDepth += 1
143
+ element.onEndTag(() => {
144
+ const at = open.lastIndexOf(index)
145
+ if (at !== -1) open.splice(at, 1)
146
+ if (isRaw) rawDepth -= 1
147
+ if (isTemplate) templateDepth -= 1
148
+ appendText(' ')
149
+ })
150
+ },
151
+ })
152
+ .onDocument({
153
+ text(chunk) {
154
+ if (rawDepth === 0) appendText(chunk.text)
155
+ },
156
+ })
157
+ .transform(html)
158
+
159
+ return elements.map(({ tag, classes, text }) => ({
160
+ tag,
161
+ classes,
162
+ text: excerpt(text),
163
+ }))
164
+ }
165
+
166
+ /**
167
+ * Finds the element the shell addressed in the file, and refuses when the two
168
+ * counts disagree rather than naming whatever sits at that index on disk.
169
+ */
170
+ export function resolveAddress(
171
+ html: string,
172
+ address: ElementAddress,
173
+ ): AddressCheck {
174
+ const elements = sourceElements(html)
175
+ const element = elements[address.index]
176
+ if (elements.length !== address.count || element?.tag !== address.tag) {
177
+ return {
178
+ ok: false,
179
+ reason: 'address-mismatch',
180
+ detail: `the browser counted ${address.count} elements and the file states ${elements.length}, so element ${address.index} cannot be matched`,
181
+ }
182
+ }
183
+ return { ok: true, element }
184
+ }
@@ -0,0 +1,172 @@
1
+ import { join } from 'node:path'
2
+ import type { CaptureResult } from '@/capture/render'
3
+ import { type ContentRefused, type Frame, readPage } from '@/canvas/content'
4
+ import {
5
+ type CanvasOptions,
6
+ type CanvasOutcome,
7
+ FRAMES_PREFIX,
8
+ startCanvas,
9
+ } from '@/canvas/server'
10
+ import { recordDir } from '@/record-root'
11
+
12
+ /**
13
+ * The element a frame is captured by. The document rather than its body, so a
14
+ * frame that paints its background on the root is not cropped to the body's
15
+ * box.
16
+ */
17
+ export const CAPTURE_SELECTOR = 'html'
18
+
19
+ export interface CaptureTarget {
20
+ readonly page: string
21
+ readonly frame: string
22
+ /** The served address, which is what carries the injected tokens. */
23
+ readonly url: string
24
+ /** The viewport width, so a media query resolves at the frame's own width. */
25
+ readonly width: number
26
+ readonly pngPath: string
27
+ }
28
+
29
+ export type TargetsOutcome =
30
+ | { readonly ok: true; readonly targets: readonly CaptureTarget[] }
31
+ | ContentRefused
32
+
33
+ function refuse(
34
+ reason: ContentRefused['reason'],
35
+ detail: string,
36
+ ): ContentRefused {
37
+ return { ok: false, reason, detail }
38
+ }
39
+
40
+ function frameUrl(base: string, page: string, frame: Frame): string {
41
+ return `${base}${FRAMES_PREFIX.slice(1)}${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
42
+ }
43
+
44
+ /**
45
+ * Turns `<page>` or `<page>/<frame>` into what to capture. Every target is a
46
+ * served address, never the file on disk, so a capture can only show what the
47
+ * canvas shows. The width comes from the layout and not from the window, so a
48
+ * frame narrower than its own breakpoint renders its narrow layout.
49
+ *
50
+ * `out` is the PNG for a frame and a folder for a page, the way a URL source
51
+ * reads `--out` in `canon capture`.
52
+ */
53
+ export function resolveCaptureTargets(
54
+ root: string,
55
+ spec: string,
56
+ base: string,
57
+ out?: string,
58
+ ): TargetsOutcome {
59
+ const [pageName, frameName, ...extra] = spec.split('/')
60
+ if (!pageName || extra.length > 0 || frameName === '') {
61
+ return refuse('invalid-name', `${spec} is not <page> or <page>/<frame>`)
62
+ }
63
+
64
+ const page = readPage(root, pageName)
65
+ if (!page) return refuse('no-page', `page ${pageName} does not exist`)
66
+
67
+ const frames =
68
+ frameName === undefined
69
+ ? page.frames
70
+ : page.frames.filter((frame) => frame.name === frameName)
71
+ if (frames.length === 0) {
72
+ return frameName === undefined
73
+ ? refuse('no-frame', `page ${pageName} has no frames`)
74
+ : refuse('no-frame', `frame ${frameName} does not exist on ${pageName}`)
75
+ }
76
+
77
+ const scratch = recordDir(root, 'tmp', 'canvas-capture', pageName)
78
+ return {
79
+ ok: true,
80
+ targets: frames.map((frame) => ({
81
+ page: pageName,
82
+ frame: frame.name,
83
+ url: frameUrl(base, pageName, frame),
84
+ width: frame.width,
85
+ pngPath:
86
+ frameName !== undefined && out !== undefined
87
+ ? out
88
+ : join(out ?? scratch, `${frame.name}.png`),
89
+ })),
90
+ }
91
+ }
92
+
93
+ export type CaptureOutcome =
94
+ | {
95
+ readonly ok: true
96
+ readonly captures: readonly {
97
+ readonly target: CaptureTarget
98
+ readonly result: CaptureResult
99
+ }[]
100
+ }
101
+ | ContentRefused
102
+ | {
103
+ readonly ok: false
104
+ readonly reason: 'no-server' | 'capture-failed'
105
+ readonly detail: string
106
+ }
107
+
108
+ type CaptureEngine = (typeof import('@/capture/render'))['captureSources']
109
+
110
+ /** What a test stands in for, which the command never passes. */
111
+ export interface CaptureDeps {
112
+ readonly start?: (root: string, options: CanvasOptions) => CanvasOutcome
113
+ readonly capture?: CaptureEngine
114
+ }
115
+
116
+ /**
117
+ * Serves the canvas for the length of the call and captures through it, so the
118
+ * run needs no `canon canvas serve` and still never reads the raw file. The
119
+ * port is whatever is free, which keeps it clear of a canvas already running.
120
+ * An error the engine throws, such as a browser that will not launch, comes
121
+ * back as `capture-failed` rather than escaping, so a caller reading the record
122
+ * can tell it from a root that is wrong.
123
+ */
124
+ export async function captureCanvas(
125
+ root: string,
126
+ spec: string,
127
+ out?: string,
128
+ deps: CaptureDeps = {},
129
+ ): Promise<CaptureOutcome> {
130
+ /*
131
+ * Refused before the server starts, since starting it creates the canvas
132
+ * folder and a refused capture should leave the project as it found it.
133
+ */
134
+ const early = resolveCaptureTargets(root, spec, '')
135
+ if (!early.ok) return early
136
+
137
+ const start = deps.start ?? startCanvas
138
+ const server = start(root, { port: 0, shell: new Response('') })
139
+ if (!server.ok) {
140
+ return {
141
+ ok: false,
142
+ reason: 'no-server',
143
+ detail: `${server.detail}, so run canon canvas serve and capture again`,
144
+ }
145
+ }
146
+
147
+ try {
148
+ const resolved = resolveCaptureTargets(root, spec, server.url, out)
149
+ if (!resolved.ok) return resolved
150
+
151
+ const capture =
152
+ deps.capture ?? (await import('@/capture/render')).captureSources
153
+ const captures: { target: CaptureTarget; result: CaptureResult }[] = []
154
+ for (const target of resolved.targets) {
155
+ const [result] = await capture(target.url, {
156
+ selector: CAPTURE_SELECTOR,
157
+ width: target.width,
158
+ outDir: target.pngPath,
159
+ })
160
+ if (result) captures.push({ target, result })
161
+ }
162
+ return { ok: true, captures }
163
+ } catch (error) {
164
+ return {
165
+ ok: false,
166
+ reason: 'capture-failed',
167
+ detail: error instanceof Error ? error.message : String(error),
168
+ }
169
+ } finally {
170
+ await server.stop()
171
+ }
172
+ }
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
3
  import { useRef } from 'preact/hooks'
4
+ import { Inspector } from '@/canvas/client/inspector'
4
5
  import { PagesPanel } from '@/canvas/client/pages-panel'
5
6
  import { currentPage, focusFrame, tokens } from '@/canvas/client/state'
6
7
  import { Surface } from '@/canvas/client/surface'
@@ -77,6 +78,7 @@ export function App(): JSX.Element {
77
78
  />
78
79
  <Surface viewportRef={viewportRef} />
79
80
  <aside class="panel panel-right" aria-label="Details">
81
+ <Inspector />
80
82
  <PageDetails />
81
83
  <TokenDetails />
82
84
  </aside>
@@ -0,0 +1,121 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { elementAt } from '@/canvas/address'
4
+ import {
5
+ currentPage,
6
+ frameDocuments,
7
+ frameKey,
8
+ selectedElement,
9
+ selectedFrame,
10
+ writeError,
11
+ } from '@/canvas/client/state'
12
+
13
+ const FIELDS = ['x', 'y', 'width', 'height'] as const
14
+
15
+ interface Row {
16
+ readonly name: string
17
+ readonly value: string
18
+ }
19
+
20
+ /**
21
+ * The computed values the browser settled on, which is what the operator sees
22
+ * rather than what any one stylesheet states.
23
+ */
24
+ function styleRows(element: Element): Row[] {
25
+ const rect = element.getBoundingClientRect()
26
+ const view = element.ownerDocument.defaultView
27
+ const style = view?.getComputedStyle(element)
28
+ const rows: Row[] = [
29
+ { name: 'x', value: String(Math.round(rect.left)) },
30
+ { name: 'y', value: String(Math.round(rect.top)) },
31
+ { name: 'width', value: String(Math.round(rect.width)) },
32
+ { name: 'height', value: String(Math.round(rect.height)) },
33
+ ]
34
+ if (!style) return rows
35
+ return [
36
+ ...rows,
37
+ { name: 'color', value: style.color },
38
+ { name: 'background', value: style.backgroundColor },
39
+ { name: 'font', value: style.fontFamily },
40
+ { name: 'size', value: style.fontSize },
41
+ { name: 'weight', value: style.fontWeight },
42
+ ]
43
+ }
44
+
45
+ function elementName(element: Element): string {
46
+ const tag = element.tagName.toLowerCase()
47
+ return [tag, ...element.classList].join('.')
48
+ }
49
+
50
+ /** Read-only in this slice, since editing an element is the edit slice's work. */
51
+ function ElementDetails(): JSX.Element | null {
52
+ const picked = selectedElement.value
53
+ const page = currentPage.value
54
+ if (!picked || !page) return null
55
+ const { frame, element } = picked
56
+ const doc = frameDocuments.value.get(frameKey(page.name, frame))
57
+ const node = doc && !element.stale ? elementAt(doc, element) : undefined
58
+
59
+ return (
60
+ <section aria-label="Element">
61
+ <h2 class="section-label">Element</h2>
62
+ {element.stale ? (
63
+ <p class="notice">
64
+ The frame changed since this element was picked, so its index may name
65
+ another element now. Pick it again.
66
+ </p>
67
+ ) : node ? (
68
+ <>
69
+ <p class="detail" title={elementName(node)}>
70
+ {elementName(node)}
71
+ </p>
72
+ <dl class="box styles">
73
+ {styleRows(node).map((row) => (
74
+ <div key={row.name} class="box-row">
75
+ <dt>{row.name}</dt>
76
+ <dd title={row.value}>{row.value}</dd>
77
+ </div>
78
+ ))}
79
+ </dl>
80
+ </>
81
+ ) : (
82
+ <p class="empty">Loading the frame to read this element</p>
83
+ )}
84
+ </section>
85
+ )
86
+ }
87
+
88
+ /** Read-only in this slice, since editing a box is the edit slice's work. */
89
+ export function Inspector(): JSX.Element {
90
+ const frame = selectedFrame.value
91
+ return (
92
+ <>
93
+ <section aria-label="Frame">
94
+ <h2 class="section-label">Frame</h2>
95
+ {frame ? (
96
+ <>
97
+ <p class="detail" title={frame.name}>
98
+ {frame.name}
99
+ </p>
100
+ <dl class="box">
101
+ {FIELDS.map((field) => (
102
+ <div key={field} class="box-row">
103
+ <dt>{field}</dt>
104
+ <dd>{frame[field]}</dd>
105
+ </div>
106
+ ))}
107
+ </dl>
108
+ </>
109
+ ) : (
110
+ <p class="empty">Select a frame to see its box</p>
111
+ )}
112
+ {writeError.value ? (
113
+ <p class="notice" role="alert">
114
+ {writeError.value}
115
+ </p>
116
+ ) : null}
117
+ </section>
118
+ <ElementDetails />
119
+ </>
120
+ )
121
+ }
@@ -0,0 +1,118 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { addressOf, documentElements, excerpt } from '@/canvas/address'
4
+ import {
5
+ type FrameRef,
6
+ frameDocuments,
7
+ hoveredElement,
8
+ selectElement,
9
+ selection,
10
+ } from '@/canvas/client/state'
11
+
12
+ /** Elements that never render, so a layer for one points at nothing. */
13
+ const HIDDEN = new Set(['script', 'style', 'template', 'noscript'])
14
+
15
+ const LABEL_TEXT = 24
16
+
17
+ export interface LayersProps {
18
+ readonly frame: FrameRef
19
+ /** The frame's key, which its loaded document is registered under. */
20
+ readonly frameKey: string
21
+ }
22
+
23
+ /** `tag.first-class`, then a short excerpt of a leaf's own text. */
24
+ function layerLabel(element: Element): { name: string; text: string } {
25
+ const tag = element.tagName.toLowerCase()
26
+ const first = element.classList[0]
27
+ const text =
28
+ element.children.length === 0
29
+ ? excerpt(element.textContent ?? '').slice(0, LABEL_TEXT)
30
+ : ''
31
+ return { name: first ? `${tag}.${first}` : tag, text }
32
+ }
33
+
34
+ function LayerNode({
35
+ doc,
36
+ element,
37
+ indexes,
38
+ props,
39
+ }: {
40
+ readonly doc: Document
41
+ readonly element: Element
42
+ readonly indexes: ReadonlyMap<Element, number>
43
+ readonly props: LayersProps
44
+ }): JSX.Element {
45
+ const index = indexes.get(element) ?? -1
46
+ const chosen = selection.value
47
+ const isCurrent =
48
+ chosen?.page === props.frame.page &&
49
+ chosen.frame === props.frame.frame &&
50
+ chosen.element?.index === index &&
51
+ !chosen.element.stale
52
+ const { name, text } = layerLabel(element)
53
+ const children = [...element.children].filter(
54
+ (child) => !HIDDEN.has(child.tagName.toLowerCase()),
55
+ )
56
+
57
+ return (
58
+ <li>
59
+ <button
60
+ type="button"
61
+ class="row layer"
62
+ title={text ? `${name} ${text}` : name}
63
+ aria-current={isCurrent ? 'true' : undefined}
64
+ onClick={() => {
65
+ const address = addressOf(doc, element)
66
+ if (address) void selectElement(props.frame, props.frameKey, address)
67
+ }}
68
+ onMouseEnter={() => {
69
+ hoveredElement.value = { key: props.frameKey, index }
70
+ }}
71
+ onMouseLeave={() => {
72
+ hoveredElement.value = undefined
73
+ }}
74
+ >
75
+ <span class="row-label">{name}</span>
76
+ {text ? <span class="row-meta layer-text">{text}</span> : null}
77
+ </button>
78
+ {children.length > 0 ? (
79
+ <ul class="layers">
80
+ {children.map((child, at) => (
81
+ <LayerNode
82
+ key={at}
83
+ doc={doc}
84
+ element={child}
85
+ indexes={indexes}
86
+ props={props}
87
+ />
88
+ ))}
89
+ </ul>
90
+ ) : null}
91
+ </li>
92
+ )
93
+ }
94
+
95
+ /**
96
+ * The element tree of one frame, read from its loaded document, so it shows
97
+ * what the browser built rather than what the file states. Each row selects by
98
+ * the same address a click on the surface records.
99
+ */
100
+ export function Layers(props: LayersProps): JSX.Element {
101
+ const doc = frameDocuments.value.get(props.frameKey)
102
+ const label = `Layers of ${props.frame.frame}`
103
+ if (!doc?.body) {
104
+ return (
105
+ <p class="empty" aria-label={label}>
106
+ Loading layers
107
+ </p>
108
+ )
109
+ }
110
+ const indexes = new Map(
111
+ documentElements(doc).map((element, index) => [element, index]),
112
+ )
113
+ return (
114
+ <ul class="layers layers-root" aria-label={label}>
115
+ <LayerNode doc={doc} element={doc.body} indexes={indexes} props={props} />
116
+ </ul>
117
+ )
118
+ }
@@ -1,13 +1,19 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
+ import { Layers } from '@/canvas/client/layers'
3
4
  import type { Frame } from '@/canvas/content'
4
5
  import {
5
6
  currentPage,
7
+ expandedFrames,
8
+ frameKey,
6
9
  isLoaded,
7
10
  loadError,
8
11
  pages,
12
+ selectedFrame,
9
13
  selectedPage,
14
+ selectFrame,
10
15
  theme,
16
+ toggleLayers,
11
17
  toggleTheme,
12
18
  } from '@/canvas/client/state'
13
19
 
@@ -76,19 +82,47 @@ function FrameList({ onFocusFrame }: PagesPanelProps): JSX.Element | null {
76
82
  }
77
83
  return (
78
84
  <ul class="list" aria-label="Frames">
79
- {page.frames.map((frame) => (
80
- <li key={frame.name}>
81
- <button
82
- type="button"
83
- class="row"
84
- title={frame.name}
85
- onClick={() => onFocusFrame(frame)}
86
- >
87
- <span class="row-label">{frame.name}</span>
88
- <span class="row-meta">{frame.width}</span>
89
- </button>
90
- </li>
91
- ))}
85
+ {page.frames.map((frame) => {
86
+ const key = frameKey(page.name, frame)
87
+ const isOpen = expandedFrames.value.has(key)
88
+ return (
89
+ <li key={frame.name}>
90
+ <div class="frame-row">
91
+ <button
92
+ type="button"
93
+ class="disclosure"
94
+ aria-expanded={isOpen}
95
+ aria-label={`${isOpen ? 'Hide' : 'Show'} layers of ${frame.name}`}
96
+ title={`${isOpen ? 'Hide' : 'Show'} layers`}
97
+ onClick={() => toggleLayers(key)}
98
+ >
99
+ <span aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
100
+ </button>
101
+ <button
102
+ type="button"
103
+ class="row"
104
+ title={frame.name}
105
+ aria-current={
106
+ frame.name === selectedFrame.value?.name ? 'true' : undefined
107
+ }
108
+ onClick={() => {
109
+ onFocusFrame(frame)
110
+ void selectFrame({ page: page.name, frame: frame.name })
111
+ }}
112
+ >
113
+ <span class="row-label">{frame.name}</span>
114
+ <span class="row-meta">{frame.width}</span>
115
+ </button>
116
+ </div>
117
+ {isOpen ? (
118
+ <Layers
119
+ frame={{ page: page.name, frame: frame.name }}
120
+ frameKey={key}
121
+ />
122
+ ) : null}
123
+ </li>
124
+ )
125
+ })}
92
126
  </ul>
93
127
  )
94
128
  }