@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.
Files changed (54) hide show
  1. package/README.md +3 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
  4. package/claude/skills/draft-screencast/SKILL.md +14 -9
  5. package/claude/skills/read-frames/SKILL.md +3 -2
  6. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  7. package/claude/skills/record-screencast/SKILL.md +28 -5
  8. package/docs/agents/commands.md +3 -0
  9. package/docs/agents/demo.md +34 -7
  10. package/docs/agents/migrate.md +1 -1
  11. package/docs/agents/sandbox.md +25 -0
  12. package/docs/target/migrations.md +1 -1
  13. package/docs/workflow/ai-workflow.md +2 -3
  14. package/docs/workflow/skill-map.md +1 -1
  15. package/governance/renames.toml +7 -5
  16. package/governance/rules/canon/630-requirements.md +1 -1
  17. package/package.json +5 -2
  18. package/src/canvas/client/app.tsx +85 -0
  19. package/src/canvas/client/main.tsx +62 -0
  20. package/src/canvas/client/pages-panel.tsx +123 -0
  21. package/src/canvas/client/shell.css +319 -0
  22. package/src/canvas/client/state.ts +186 -0
  23. package/src/canvas/client/surface.tsx +213 -0
  24. package/src/canvas/content.ts +326 -0
  25. package/src/canvas/index.html +14 -0
  26. package/src/canvas/server.ts +302 -0
  27. package/src/canvas/tokens.ts +71 -0
  28. package/src/claude/seeds.ts +0 -1
  29. package/src/cli.ts +2 -0
  30. package/src/commands/canvas.ts +325 -0
  31. package/src/commands/claude.ts +9 -14
  32. package/src/commands/demo.ts +6 -6
  33. package/src/commands/sandbox.ts +108 -0
  34. package/src/context/classify/prompts.ts +1 -1
  35. package/src/context/folders.ts +0 -1
  36. package/src/demo/compile.ts +8 -4
  37. package/src/demo/container.ts +1 -1
  38. package/src/gov/renames.ts +3 -2
  39. package/src/help.ts +1 -0
  40. package/src/record-root.ts +1 -0
  41. package/src/sandbox/equivalence.ts +847 -0
  42. package/src/serve/static.ts +25 -11
  43. package/src/surface-root.ts +0 -1
  44. package/standards/architecture.md +1 -1
  45. package/standards/context.md +1 -1
  46. package/standards/design.md +1 -1
  47. package/standards/index.md +0 -1
  48. package/standards/requirements.md +4 -6
  49. package/standards/wireframes.md +2 -2
  50. package/tooling/claude/reference.md +0 -1
  51. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  52. package/governance/rules/canon/645-decisions.md +0 -12
  53. package/standards/decisions.md +0 -105
  54. 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 &lt;name&gt;</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} &lt;name&gt;</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
+ }