@erclx/canon 5.14.0 → 5.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/draft-screencast/SKILL.md +14 -9
- package/claude/skills/read-frames/SKILL.md +3 -2
- package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
- package/claude/skills/record-screencast/SKILL.md +28 -5
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/close-out.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
- package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
- package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
- package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/pr-reads.md +1 -1
- package/docs/agents/sandbox.md +28 -3
- package/docs/agents/state-scoped-risk.md +2 -2
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +2 -3
- package/docs/workflow/skill-map.md +1 -1
- package/governance/renames.toml +7 -5
- package/governance/rules/canon/630-requirements.md +1 -1
- package/package.json +8 -6
- package/scripts/core/check-color-source.sh +11 -1
- package/scripts/core/install-check.sh +13 -4
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-fixtures.sh +2 -2
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/manage-sandbox.sh +2 -2
- package/src/canvas/client/app.tsx +85 -0
- package/src/canvas/client/main.tsx +62 -0
- package/src/canvas/client/pages-panel.tsx +123 -0
- package/src/canvas/client/shell.css +319 -0
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +213 -0
- package/src/canvas/content.ts +326 -0
- package/src/canvas/index.html +14 -0
- package/src/canvas/server.ts +302 -0
- package/src/canvas/tokens.ts +71 -0
- package/src/claude/seeds.ts +0 -1
- package/src/cli.ts +2 -0
- package/src/commands/canvas.ts +325 -0
- package/src/commands/claude.ts +9 -14
- package/src/commands/demo.ts +6 -6
- package/src/commands/sandbox.ts +110 -2
- package/src/context/citations.ts +1 -1
- package/src/context/classify/prompts.ts +1 -1
- package/src/context/folders.ts +0 -1
- package/src/demo/compile.ts +8 -4
- package/src/demo/container.ts +1 -1
- package/src/gov/renames.ts +3 -2
- package/src/help.ts +1 -0
- package/src/record-root.ts +1 -0
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +1 -1
- package/src/sandbox/equivalence.ts +847 -0
- package/src/sandbox/expect.ts +0 -1
- package/src/serve/static.ts +25 -11
- package/src/shipped/references.ts +8 -10
- package/src/surface-root.ts +0 -1
- package/src/targets/pulls.ts +2 -2
- package/standards/architecture.md +1 -1
- package/standards/context.md +1 -1
- package/standards/design.md +1 -1
- package/standards/index.md +0 -1
- package/standards/requirements.md +4 -6
- package/standards/wireframes.md +2 -2
- package/tooling/claude/reference.md +0 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
- package/tsconfig.json +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
- package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
- package/governance/rules/canon/645-decisions.md +0 -12
- package/standards/decisions.md +0 -105
- package/tooling/claude/seeds/canon/decisions/index.md +0 -8
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { computed, signal } from '@preact/signals'
|
|
2
|
+
import type { Frame, Page } from '@/canvas/content'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Everything the panels read. Type imports only from outside `client/`, since
|
|
6
|
+
* this runs in the browser and a Node or Bun module would not bundle there.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Theme = 'light' | 'dark'
|
|
10
|
+
|
|
11
|
+
export interface TokenSource {
|
|
12
|
+
readonly source: 'toolkit' | 'installed' | 'none'
|
|
13
|
+
readonly files?: readonly string[]
|
|
14
|
+
readonly notice?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PagesRecord {
|
|
18
|
+
readonly pages: readonly Page[]
|
|
19
|
+
readonly tokens: TokenSource
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ChangeEvent {
|
|
23
|
+
readonly page: string
|
|
24
|
+
readonly file: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface View {
|
|
28
|
+
readonly x: number
|
|
29
|
+
readonly y: number
|
|
30
|
+
readonly zoom: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const MIN_ZOOM = 0.05
|
|
34
|
+
export const MAX_ZOOM = 4
|
|
35
|
+
|
|
36
|
+
export const pages = signal<readonly Page[]>([])
|
|
37
|
+
export const tokens = signal<TokenSource | undefined>(undefined)
|
|
38
|
+
export const isLoaded = signal(false)
|
|
39
|
+
export const loadError = signal<string | undefined>(undefined)
|
|
40
|
+
export const selectedPage = signal<string | undefined>(undefined)
|
|
41
|
+
|
|
42
|
+
/** Undefined follows the system, which `systemTheme` reports. */
|
|
43
|
+
export const themeChoice = signal<Theme | undefined>(undefined)
|
|
44
|
+
export const systemTheme = signal<Theme>('dark')
|
|
45
|
+
export const theme = computed<Theme>(
|
|
46
|
+
() => themeChoice.value ?? systemTheme.value,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
/** A frame's own theme where the operator switched it, else the chrome's. */
|
|
50
|
+
export const frameThemes = signal<ReadonlyMap<string, Theme>>(new Map())
|
|
51
|
+
|
|
52
|
+
/** Bumped per frame on a change, so only that frame's document reloads. */
|
|
53
|
+
export const frameVersions = signal<ReadonlyMap<string, number>>(new Map())
|
|
54
|
+
|
|
55
|
+
export const view = signal<View>({ x: 0, y: 0, zoom: 0.5 })
|
|
56
|
+
|
|
57
|
+
export const currentPage = computed<Page | undefined>(() => {
|
|
58
|
+
const all = pages.value
|
|
59
|
+
return all.find((page) => page.name === selectedPage.value) ?? all[0]
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export function frameKey(page: string, frame: Pick<Frame, 'file'>): string {
|
|
63
|
+
return `${page}/${frame.file}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function frameTheme(key: string): Theme {
|
|
67
|
+
return frameThemes.value.get(key) ?? theme.value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function toggleTheme(): void {
|
|
71
|
+
themeChoice.value = theme.value === 'dark' ? 'light' : 'dark'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function toggleFrameTheme(key: string): void {
|
|
75
|
+
const next = new Map(frameThemes.value)
|
|
76
|
+
next.set(key, frameTheme(key) === 'dark' ? 'light' : 'dark')
|
|
77
|
+
frameThemes.value = next
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function applyRecord(record: PagesRecord): void {
|
|
81
|
+
pages.value = record.pages
|
|
82
|
+
tokens.value = record.tokens
|
|
83
|
+
loadError.value = undefined
|
|
84
|
+
isLoaded.value = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function loadPages(
|
|
88
|
+
fetchImpl: typeof fetch = fetch,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetchImpl('/api/pages')
|
|
92
|
+
if (!response.ok) throw new Error(`status ${response.status}`)
|
|
93
|
+
applyRecord((await response.json()) as PagesRecord)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
loadError.value = `Could not read the canvas (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
|
|
96
|
+
isLoaded.value = true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reloads the one frame whose file changed and rereads the page list, which
|
|
102
|
+
* picks up an added frame or a moved box. The view is separate state, so
|
|
103
|
+
* neither resets pan or zoom.
|
|
104
|
+
*/
|
|
105
|
+
export async function applyChange(
|
|
106
|
+
change: ChangeEvent,
|
|
107
|
+
fetchImpl: typeof fetch = fetch,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
if (change.file.endsWith('.html')) {
|
|
110
|
+
const key = `${change.page}/${change.file}`
|
|
111
|
+
const next = new Map(frameVersions.value)
|
|
112
|
+
next.set(key, (next.get(key) ?? 0) + 1)
|
|
113
|
+
frameVersions.value = next
|
|
114
|
+
}
|
|
115
|
+
await loadPages(fetchImpl)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function clampZoom(zoom: number): number {
|
|
119
|
+
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Zooms about a point in surface coordinates, keeping that point still. */
|
|
123
|
+
export function zoomAt(factor: number, originX: number, originY: number): void {
|
|
124
|
+
const current = view.value
|
|
125
|
+
const zoom = clampZoom(current.zoom * factor)
|
|
126
|
+
const scale = zoom / current.zoom
|
|
127
|
+
view.value = {
|
|
128
|
+
zoom,
|
|
129
|
+
x: originX - (originX - current.x) * scale,
|
|
130
|
+
y: originY - (originY - current.y) * scale,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function panBy(dx: number, dy: number): void {
|
|
135
|
+
const current = view.value
|
|
136
|
+
view.value = { ...current, x: current.x + dx, y: current.y + dy }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Fits every frame on the current page into a viewport of the given size. */
|
|
140
|
+
export function fitView(width: number, height: number, padding = 48): void {
|
|
141
|
+
const frames = currentPage.value?.frames ?? []
|
|
142
|
+
if (frames.length === 0 || width <= 0 || height <= 0) {
|
|
143
|
+
view.value = { x: padding, y: padding, zoom: 0.5 }
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const left = Math.min(...frames.map((frame) => frame.x))
|
|
147
|
+
const top = Math.min(...frames.map((frame) => frame.y))
|
|
148
|
+
const right = Math.max(...frames.map((frame) => frame.x + frame.width))
|
|
149
|
+
const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
|
|
150
|
+
const zoom = clampZoom(
|
|
151
|
+
Math.min(
|
|
152
|
+
(width - padding * 2) / (right - left),
|
|
153
|
+
(height - padding * 2) / (bottom - top),
|
|
154
|
+
1,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
view.value = {
|
|
158
|
+
zoom,
|
|
159
|
+
x: padding - left * zoom,
|
|
160
|
+
y: padding - top * zoom,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Centers one frame in a viewport of the given size at the current zoom. */
|
|
165
|
+
export function focusFrame(frame: Frame, width: number, height: number): void {
|
|
166
|
+
const { zoom } = view.value
|
|
167
|
+
view.value = {
|
|
168
|
+
zoom,
|
|
169
|
+
x: width / 2 - (frame.x + frame.width / 2) * zoom,
|
|
170
|
+
y: height / 2 - (frame.y + frame.height / 2) * zoom,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Puts every signal back to its first value. Tests call it between cases. */
|
|
175
|
+
export function resetState(): void {
|
|
176
|
+
pages.value = []
|
|
177
|
+
tokens.value = undefined
|
|
178
|
+
isLoaded.value = false
|
|
179
|
+
loadError.value = undefined
|
|
180
|
+
selectedPage.value = undefined
|
|
181
|
+
themeChoice.value = undefined
|
|
182
|
+
systemTheme.value = 'dark'
|
|
183
|
+
frameThemes.value = new Map()
|
|
184
|
+
frameVersions.value = new Map()
|
|
185
|
+
view.value = { x: 0, y: 0, zoom: 0.5 }
|
|
186
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX, RefObject } from 'preact'
|
|
3
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
4
|
+
import type { Frame } from '@/canvas/content'
|
|
5
|
+
import {
|
|
6
|
+
currentPage,
|
|
7
|
+
fitView,
|
|
8
|
+
frameKey,
|
|
9
|
+
frameTheme,
|
|
10
|
+
frameVersions,
|
|
11
|
+
panBy,
|
|
12
|
+
type Theme,
|
|
13
|
+
toggleFrameTheme,
|
|
14
|
+
view,
|
|
15
|
+
zoomAt,
|
|
16
|
+
} from '@/canvas/client/state'
|
|
17
|
+
|
|
18
|
+
/** One wheel notch or one button press. */
|
|
19
|
+
const ZOOM_STEP = 1.2
|
|
20
|
+
|
|
21
|
+
export interface SurfaceProps {
|
|
22
|
+
readonly viewportRef: RefObject<HTMLDivElement | null>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function frameSrc(page: string, frame: Frame, version: number): string {
|
|
26
|
+
const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
27
|
+
return version === 0 ? path : `${path}?v=${version}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sets the frame document's theme the way the token stylesheet reads it. The
|
|
32
|
+
* frame shares the shell's origin, so its document is reachable once loaded.
|
|
33
|
+
*/
|
|
34
|
+
function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
|
|
35
|
+
const root = iframe?.contentDocument?.documentElement
|
|
36
|
+
if (!root) return
|
|
37
|
+
root.dataset.theme = value
|
|
38
|
+
root.style.colorScheme = value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FrameView({
|
|
42
|
+
page,
|
|
43
|
+
frame,
|
|
44
|
+
}: {
|
|
45
|
+
readonly page: string
|
|
46
|
+
readonly frame: Frame
|
|
47
|
+
}): JSX.Element {
|
|
48
|
+
const key = frameKey(page, frame)
|
|
49
|
+
const version = frameVersions.value.get(key) ?? 0
|
|
50
|
+
const value = frameTheme(key)
|
|
51
|
+
const iframe = useRef<HTMLIFrameElement>(null)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
applyFrameTheme(iframe.current, value)
|
|
55
|
+
}, [value])
|
|
56
|
+
|
|
57
|
+
const next = value === 'dark' ? 'light' : 'dark'
|
|
58
|
+
return (
|
|
59
|
+
<figure
|
|
60
|
+
class="frame"
|
|
61
|
+
data-frame={frame.name}
|
|
62
|
+
style={{
|
|
63
|
+
left: `${frame.x}px`,
|
|
64
|
+
top: `${frame.y}px`,
|
|
65
|
+
width: `${frame.width}px`,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<figcaption class="frame-label">
|
|
69
|
+
<span class="frame-name" title={frame.name}>
|
|
70
|
+
{frame.name}
|
|
71
|
+
</span>
|
|
72
|
+
<span class="frame-size">
|
|
73
|
+
{frame.width} × {frame.height}
|
|
74
|
+
</span>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class="frame-theme"
|
|
78
|
+
aria-label={`Show ${frame.name} in ${next} theme`}
|
|
79
|
+
title={`Show in ${next} theme`}
|
|
80
|
+
onClick={() => toggleFrameTheme(key)}
|
|
81
|
+
>
|
|
82
|
+
{value === 'dark' ? 'Dark' : 'Light'}
|
|
83
|
+
</button>
|
|
84
|
+
</figcaption>
|
|
85
|
+
<iframe
|
|
86
|
+
ref={iframe}
|
|
87
|
+
title={frame.name}
|
|
88
|
+
src={frameSrc(page, frame, version)}
|
|
89
|
+
width={frame.width}
|
|
90
|
+
height={frame.height}
|
|
91
|
+
style={{ height: `${frame.height}px` }}
|
|
92
|
+
onLoad={() => applyFrameTheme(iframe.current, frameTheme(key))}
|
|
93
|
+
/>
|
|
94
|
+
</figure>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
|
|
99
|
+
width: number
|
|
100
|
+
height: number
|
|
101
|
+
} {
|
|
102
|
+
const rect = viewport.current?.getBoundingClientRect()
|
|
103
|
+
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
107
|
+
const zoomCentered = (factor: number) => {
|
|
108
|
+
const { width, height } = viewportSize(viewportRef)
|
|
109
|
+
zoomAt(factor, width / 2, height / 2)
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
<div class="toolbar" role="toolbar" aria-label="Zoom">
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
class="icon-button"
|
|
116
|
+
aria-label="Zoom out"
|
|
117
|
+
onClick={() => zoomCentered(1 / ZOOM_STEP)}
|
|
118
|
+
>
|
|
119
|
+
−
|
|
120
|
+
</button>
|
|
121
|
+
<output class="zoom" aria-live="polite">
|
|
122
|
+
{Math.round(view.value.zoom * 100)}%
|
|
123
|
+
</output>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
class="icon-button"
|
|
127
|
+
aria-label="Zoom in"
|
|
128
|
+
onClick={() => zoomCentered(ZOOM_STEP)}
|
|
129
|
+
>
|
|
130
|
+
+
|
|
131
|
+
</button>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
class="text-button"
|
|
135
|
+
onClick={() => {
|
|
136
|
+
const { width, height } = viewportSize(viewportRef)
|
|
137
|
+
fitView(width, height)
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
Fit
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
147
|
+
const page = currentPage.value
|
|
148
|
+
const drag = useRef<{ x: number; y: number } | undefined>(undefined)
|
|
149
|
+
|
|
150
|
+
/* Fits a page once when it opens, never on a reload of the same page. */
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const { width, height } = viewportSize(viewportRef)
|
|
153
|
+
fitView(width, height)
|
|
154
|
+
}, [page?.name])
|
|
155
|
+
|
|
156
|
+
const handleWheel = (event: WheelEvent) => {
|
|
157
|
+
event.preventDefault()
|
|
158
|
+
if (event.ctrlKey || event.metaKey) {
|
|
159
|
+
const rect = viewportRef.current?.getBoundingClientRect()
|
|
160
|
+
const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
|
|
161
|
+
zoomAt(
|
|
162
|
+
factor,
|
|
163
|
+
event.clientX - (rect?.left ?? 0),
|
|
164
|
+
event.clientY - (rect?.top ?? 0),
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
panBy(-event.deltaX, -event.deltaY)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
172
|
+
const target = event.target as HTMLElement | null
|
|
173
|
+
if (target?.closest('.frame, .toolbar')) return
|
|
174
|
+
drag.current = { x: event.clientX, y: event.clientY }
|
|
175
|
+
viewportRef.current?.setPointerCapture?.(event.pointerId)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
179
|
+
const start = drag.current
|
|
180
|
+
if (!start) return
|
|
181
|
+
panBy(event.clientX - start.x, event.clientY - start.y)
|
|
182
|
+
drag.current = { x: event.clientX, y: event.clientY }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handlePointerUp = () => {
|
|
186
|
+
drag.current = undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { x, y, zoom } = view.value
|
|
190
|
+
return (
|
|
191
|
+
<main class="surface" aria-label="Canvas">
|
|
192
|
+
<div
|
|
193
|
+
ref={viewportRef}
|
|
194
|
+
class="viewport"
|
|
195
|
+
onWheel={handleWheel}
|
|
196
|
+
onPointerDown={handlePointerDown}
|
|
197
|
+
onPointerMove={handlePointerMove}
|
|
198
|
+
onPointerUp={handlePointerUp}
|
|
199
|
+
onPointerCancel={handlePointerUp}
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
class="plane"
|
|
203
|
+
style={{ transform: `translate(${x}px, ${y}px) scale(${zoom})` }}
|
|
204
|
+
>
|
|
205
|
+
{page?.frames.map((frame) => (
|
|
206
|
+
<FrameView key={frame.name} page={page.name} frame={frame} />
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
<Toolbar viewportRef={viewportRef} />
|
|
211
|
+
</main>
|
|
212
|
+
)
|
|
213
|
+
}
|