@erclx/canon 5.14.1 → 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/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +34 -7
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/sandbox.md +25 -0
- 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 +5 -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 +108 -0
- 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/equivalence.ts +847 -0
- package/src/serve/static.ts +25 -11
- package/src/surface-root.ts +0 -1
- 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/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,123 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import type { Frame } from '@/canvas/content'
|
|
4
|
+
import {
|
|
5
|
+
currentPage,
|
|
6
|
+
isLoaded,
|
|
7
|
+
loadError,
|
|
8
|
+
pages,
|
|
9
|
+
selectedPage,
|
|
10
|
+
theme,
|
|
11
|
+
toggleTheme,
|
|
12
|
+
} from '@/canvas/client/state'
|
|
13
|
+
|
|
14
|
+
export interface PagesPanelProps {
|
|
15
|
+
/** Brings a frame into view on the surface. */
|
|
16
|
+
readonly onFocusFrame: (frame: Frame) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ThemeToggle(): JSX.Element {
|
|
20
|
+
const next = theme.value === 'dark' ? 'light' : 'dark'
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
class="icon-button"
|
|
25
|
+
aria-label={`Switch to ${next} theme`}
|
|
26
|
+
title={`Switch to ${next} theme`}
|
|
27
|
+
onClick={toggleTheme}
|
|
28
|
+
>
|
|
29
|
+
<span aria-hidden="true">{theme.value === 'dark' ? '☾' : '☀'}</span>
|
|
30
|
+
</button>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function PageList(): JSX.Element {
|
|
35
|
+
if (pages.value.length === 0) {
|
|
36
|
+
return (
|
|
37
|
+
<p class="empty">
|
|
38
|
+
No pages yet. Run <code>canon canvas page add <name></code> to add
|
|
39
|
+
one.
|
|
40
|
+
</p>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
const current = currentPage.value?.name
|
|
44
|
+
return (
|
|
45
|
+
<ul class="list" aria-label="Pages">
|
|
46
|
+
{pages.value.map((page) => (
|
|
47
|
+
<li key={page.name}>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
class="row"
|
|
51
|
+
aria-current={page.name === current ? 'page' : undefined}
|
|
52
|
+
title={page.name}
|
|
53
|
+
onClick={() => {
|
|
54
|
+
selectedPage.value = page.name
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<span class="row-label">{page.name}</span>
|
|
58
|
+
<span class="row-meta">{page.frames.length}</span>
|
|
59
|
+
</button>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function FrameList({ onFocusFrame }: PagesPanelProps): JSX.Element | null {
|
|
67
|
+
const page = currentPage.value
|
|
68
|
+
if (!page) return null
|
|
69
|
+
if (page.frames.length === 0) {
|
|
70
|
+
return (
|
|
71
|
+
<p class="empty">
|
|
72
|
+
No frames on this page. Run{' '}
|
|
73
|
+
<code>canon canvas frame add {page.name} <name></code> to add one.
|
|
74
|
+
</p>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return (
|
|
78
|
+
<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
|
+
))}
|
|
92
|
+
</ul>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function PagesPanel(props: PagesPanelProps): JSX.Element {
|
|
97
|
+
return (
|
|
98
|
+
<nav class="panel panel-left" aria-label="Pages and frames">
|
|
99
|
+
<header class="panel-head">
|
|
100
|
+
<span class="brand">Canvas</span>
|
|
101
|
+
<ThemeToggle />
|
|
102
|
+
</header>
|
|
103
|
+
{loadError.value ? (
|
|
104
|
+
<p class="empty" role="alert">
|
|
105
|
+
{loadError.value}
|
|
106
|
+
</p>
|
|
107
|
+
) : !isLoaded.value ? (
|
|
108
|
+
<p class="empty">Loading</p>
|
|
109
|
+
) : (
|
|
110
|
+
<>
|
|
111
|
+
<h2 class="section-label">Pages</h2>
|
|
112
|
+
<PageList />
|
|
113
|
+
{currentPage.value ? (
|
|
114
|
+
<>
|
|
115
|
+
<h2 class="section-label">Frames</h2>
|
|
116
|
+
<FrameList {...props} />
|
|
117
|
+
</>
|
|
118
|
+
) : null}
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
</nav>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The canvas chrome. Every value reads a custom property from this toolkit's
|
|
3
|
+
* own token module, served at /api/chrome.css, which maps dark onto :root and
|
|
4
|
+
* light onto [data-theme='light']. A frame reads the project's tokens inside
|
|
5
|
+
* its own iframe, so the two sets of names never meet.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
html,
|
|
13
|
+
body {
|
|
14
|
+
height: 100%;
|
|
15
|
+
margin: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
background: var(--color-background);
|
|
20
|
+
color: var(--color-text);
|
|
21
|
+
font-family: var(--type-body-family);
|
|
22
|
+
font-size: var(--type-label-size);
|
|
23
|
+
line-height: var(--type-label-lh);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#app {
|
|
27
|
+
height: 100%;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
:focus-visible {
|
|
31
|
+
outline: 2px solid var(--color-accent);
|
|
32
|
+
outline-offset: 2px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
code {
|
|
36
|
+
font-family: var(--type-code-family);
|
|
37
|
+
font-size: var(--type-code-size);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.shell {
|
|
41
|
+
display: grid;
|
|
42
|
+
grid-template-columns: 15rem minmax(0, 1fr) 13rem;
|
|
43
|
+
height: 100%;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.panel {
|
|
47
|
+
overflow-y: auto;
|
|
48
|
+
padding: var(--space-md);
|
|
49
|
+
background: var(--color-chrome);
|
|
50
|
+
min-width: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.panel-left {
|
|
54
|
+
border-right: 1px solid var(--color-border);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.panel-right {
|
|
58
|
+
border-left: 1px solid var(--color-border);
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: var(--space-lg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.panel-head {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
justify-content: space-between;
|
|
68
|
+
margin-bottom: var(--space-md);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.brand {
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
color: var(--color-text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.section-label {
|
|
77
|
+
margin: var(--space-md) 0 var(--space-xs);
|
|
78
|
+
font-size: var(--type-label-size);
|
|
79
|
+
font-weight: 500;
|
|
80
|
+
color: var(--color-text-secondary);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.panel-right .section-label {
|
|
84
|
+
margin-top: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.list {
|
|
88
|
+
list-style: none;
|
|
89
|
+
margin: 0;
|
|
90
|
+
padding: 2px;
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: 2px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.row {
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: space-between;
|
|
100
|
+
gap: var(--space-sm);
|
|
101
|
+
width: 100%;
|
|
102
|
+
min-height: 28px;
|
|
103
|
+
padding: var(--space-xs) var(--space-sm);
|
|
104
|
+
border: 0;
|
|
105
|
+
border-radius: var(--radius-action);
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: var(--color-text-body);
|
|
108
|
+
font: inherit;
|
|
109
|
+
text-align: left;
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.row:hover {
|
|
114
|
+
background: var(--color-surface);
|
|
115
|
+
color: var(--color-text);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.row:active {
|
|
119
|
+
background: var(--color-border);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.row[aria-current='page'] {
|
|
123
|
+
background: var(--color-surface);
|
|
124
|
+
color: var(--color-text);
|
|
125
|
+
box-shadow: inset 2px 0 0 var(--color-accent);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.row-label {
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
text-overflow: ellipsis;
|
|
131
|
+
white-space: nowrap;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.row-meta {
|
|
135
|
+
flex-shrink: 0;
|
|
136
|
+
color: var(--color-muted);
|
|
137
|
+
font-variant-numeric: tabular-nums;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.empty,
|
|
141
|
+
.notice {
|
|
142
|
+
margin: var(--space-xs) 0;
|
|
143
|
+
color: var(--color-text-secondary);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.notice {
|
|
147
|
+
border-left: 2px solid var(--color-accent);
|
|
148
|
+
padding-left: var(--space-sm);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.detail {
|
|
152
|
+
margin: 0;
|
|
153
|
+
color: var(--color-text);
|
|
154
|
+
overflow: hidden;
|
|
155
|
+
text-overflow: ellipsis;
|
|
156
|
+
white-space: nowrap;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.detail-meta {
|
|
160
|
+
margin: 0;
|
|
161
|
+
color: var(--color-muted);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.files {
|
|
165
|
+
list-style: none;
|
|
166
|
+
margin: var(--space-xs) 0 0;
|
|
167
|
+
padding: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.files li {
|
|
171
|
+
overflow: hidden;
|
|
172
|
+
text-overflow: ellipsis;
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
color: var(--color-text-body);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.icon-button,
|
|
178
|
+
.text-button {
|
|
179
|
+
min-width: 28px;
|
|
180
|
+
min-height: 28px;
|
|
181
|
+
padding: 0 var(--space-sm);
|
|
182
|
+
border: 1px solid var(--color-border);
|
|
183
|
+
border-radius: var(--radius-action);
|
|
184
|
+
background: var(--color-surface);
|
|
185
|
+
color: var(--color-text);
|
|
186
|
+
font: inherit;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.icon-button:hover,
|
|
191
|
+
.text-button:hover {
|
|
192
|
+
border-color: var(--color-text-secondary);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.icon-button:active,
|
|
196
|
+
.text-button:active {
|
|
197
|
+
background: var(--color-border);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.surface {
|
|
201
|
+
position: relative;
|
|
202
|
+
min-width: 0;
|
|
203
|
+
overflow: hidden;
|
|
204
|
+
background: var(--color-background);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.viewport {
|
|
208
|
+
position: absolute;
|
|
209
|
+
inset: 0;
|
|
210
|
+
overflow: hidden;
|
|
211
|
+
cursor: grab;
|
|
212
|
+
touch-action: none;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.viewport:active {
|
|
216
|
+
cursor: grabbing;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.plane {
|
|
220
|
+
position: absolute;
|
|
221
|
+
top: 0;
|
|
222
|
+
left: 0;
|
|
223
|
+
transform-origin: 0 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.frame {
|
|
227
|
+
position: absolute;
|
|
228
|
+
margin: 0;
|
|
229
|
+
cursor: default;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.frame-label {
|
|
233
|
+
position: absolute;
|
|
234
|
+
bottom: 100%;
|
|
235
|
+
left: 0;
|
|
236
|
+
right: 0;
|
|
237
|
+
display: flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
gap: var(--space-sm);
|
|
240
|
+
padding-bottom: var(--space-sm);
|
|
241
|
+
font-size: 1.5rem;
|
|
242
|
+
color: var(--color-text-secondary);
|
|
243
|
+
white-space: nowrap;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.frame-name {
|
|
247
|
+
overflow: hidden;
|
|
248
|
+
text-overflow: ellipsis;
|
|
249
|
+
color: var(--color-text);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.frame-size {
|
|
253
|
+
color: var(--color-muted);
|
|
254
|
+
font-variant-numeric: tabular-nums;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.frame-theme {
|
|
258
|
+
margin-left: auto;
|
|
259
|
+
padding: 2px var(--space-sm);
|
|
260
|
+
border: 1px solid var(--color-border);
|
|
261
|
+
border-radius: var(--radius-pill);
|
|
262
|
+
background: var(--color-surface);
|
|
263
|
+
color: var(--color-text-secondary);
|
|
264
|
+
font: inherit;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.frame-theme:hover {
|
|
269
|
+
color: var(--color-text);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.frame iframe {
|
|
273
|
+
display: block;
|
|
274
|
+
border: 1px solid var(--color-border);
|
|
275
|
+
border-radius: 2px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.toolbar {
|
|
279
|
+
position: absolute;
|
|
280
|
+
right: var(--space-md);
|
|
281
|
+
bottom: var(--space-md);
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
gap: var(--space-xs);
|
|
285
|
+
padding: var(--space-xs);
|
|
286
|
+
border: 1px solid var(--color-border);
|
|
287
|
+
border-radius: var(--radius-panel);
|
|
288
|
+
background: var(--color-chrome);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.zoom {
|
|
292
|
+
min-width: 3.5rem;
|
|
293
|
+
text-align: center;
|
|
294
|
+
font-variant-numeric: tabular-nums;
|
|
295
|
+
color: var(--color-text-secondary);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@media (max-width: 900px) {
|
|
299
|
+
.shell {
|
|
300
|
+
grid-template-columns: 12rem minmax(0, 1fr);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.panel-right {
|
|
304
|
+
display: none;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@media (max-width: 600px) {
|
|
309
|
+
.shell {
|
|
310
|
+
grid-template-columns: minmax(0, 1fr);
|
|
311
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.panel-left {
|
|
315
|
+
max-height: 40vh;
|
|
316
|
+
border-right: 0;
|
|
317
|
+
border-bottom: 1px solid var(--color-border);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -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
|
+
}
|