@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +57 -0
- package/claude/skills/search-craft/SKILL.md +83 -0
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/canvas.md +36 -0
- package/docs/agents/commands.md +3 -3
- package/docs/agents/demo.md +3 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -1
- package/scripts/lib/sandbox-git.sh +2 -2
- package/src/canvas/address.ts +184 -0
- package/src/canvas/capture.ts +172 -0
- package/src/canvas/client/app.tsx +2 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/pages-panel.tsx +47 -13
- package/src/canvas/client/shell.css +131 -1
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +272 -20
- package/src/canvas/content.ts +307 -16
- package/src/canvas/server.ts +182 -13
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/canvas.ts +200 -3
- package/src/commands/demo.ts +2 -0
- package/src/commands/migrate.ts +1 -1
- package/src/commands/teach.ts +3 -1
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/migrate/rename.ts +5 -4
- package/src/teach/browser/compile.ts +44 -0
- package/src/teach/browser/dismiss.ts +76 -0
- package/src/teach/browser/focus-line.ts +19 -0
- package/src/teach/browser/glossary-filter.ts +44 -0
- package/src/teach/browser/head.ts +30 -0
- package/src/teach/browser/quiz.ts +19 -0
- package/src/teach/browser/sidebar.ts +233 -0
- package/src/teach/browser/theme.ts +18 -0
- package/src/teach/nav.ts +19 -374
- package/src/teach/workspace.ts +61 -6
- package/standards/figures.md +1 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX, RefObject } from 'preact'
|
|
3
|
-
import { useEffect, useRef } from 'preact/hooks'
|
|
3
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
4
|
+
import { addressOf, documentElements, elementAt } from '@/canvas/address'
|
|
4
5
|
import type { Frame } from '@/canvas/content'
|
|
5
6
|
import {
|
|
6
7
|
currentPage,
|
|
8
|
+
draggingFrame,
|
|
7
9
|
fitView,
|
|
10
|
+
type FrameRef,
|
|
11
|
+
frameDocuments,
|
|
8
12
|
frameKey,
|
|
9
13
|
frameTheme,
|
|
10
14
|
frameVersions,
|
|
15
|
+
hoveredElement,
|
|
16
|
+
moveFrameTo,
|
|
11
17
|
panBy,
|
|
18
|
+
previewMove,
|
|
19
|
+
registerFrameDocument,
|
|
20
|
+
selectElement,
|
|
21
|
+
selectFrame,
|
|
22
|
+
selection,
|
|
12
23
|
type Theme,
|
|
13
24
|
toggleFrameTheme,
|
|
14
25
|
view,
|
|
@@ -18,10 +29,76 @@ import {
|
|
|
18
29
|
/** One wheel notch or one button press. */
|
|
19
30
|
const ZOOM_STEP = 1.2
|
|
20
31
|
|
|
32
|
+
/** Screen pixels a press travels before it reads as a drag and not a click. */
|
|
33
|
+
const DRAG_THRESHOLD = 3
|
|
34
|
+
|
|
35
|
+
/** Surface units one arrow press moves a frame, and one with Shift held. */
|
|
36
|
+
const NUDGE = 10
|
|
37
|
+
const NUDGE_LARGE = 50
|
|
38
|
+
|
|
39
|
+
const ARROWS: Readonly<Record<string, { x: number; y: number } | undefined>> = {
|
|
40
|
+
ArrowLeft: { x: -1, y: 0 },
|
|
41
|
+
ArrowRight: { x: 1, y: 0 },
|
|
42
|
+
ArrowUp: { x: 0, y: -1 },
|
|
43
|
+
ArrowDown: { x: 0, y: 1 },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Gesture {
|
|
47
|
+
readonly startX: number
|
|
48
|
+
readonly startY: number
|
|
49
|
+
readonly originX: number
|
|
50
|
+
readonly originY: number
|
|
51
|
+
x: number
|
|
52
|
+
y: number
|
|
53
|
+
moved: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
21
56
|
export interface SurfaceProps {
|
|
22
57
|
readonly viewportRef: RefObject<HTMLDivElement | null>
|
|
23
58
|
}
|
|
24
59
|
|
|
60
|
+
/** A wheel turn in shell coordinates, wherever the pointer sat. */
|
|
61
|
+
interface WheelTurn {
|
|
62
|
+
readonly deltaX: number
|
|
63
|
+
readonly deltaY: number
|
|
64
|
+
readonly isZoom: boolean
|
|
65
|
+
readonly clientX: number
|
|
66
|
+
readonly clientY: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The frame's own document is a separate window, so an element in it fails an
|
|
71
|
+
* `instanceof Element` against the shell's window. The node type is the test
|
|
72
|
+
* that holds across both.
|
|
73
|
+
*/
|
|
74
|
+
function asElement(target: EventTarget | null): Element | undefined {
|
|
75
|
+
const node = target as Node | null
|
|
76
|
+
return node?.nodeType === 1 ? (node as Element) : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ElementOutline({
|
|
80
|
+
element,
|
|
81
|
+
kind,
|
|
82
|
+
}: {
|
|
83
|
+
readonly element: Element
|
|
84
|
+
readonly kind: 'hover' | 'selected'
|
|
85
|
+
}): JSX.Element {
|
|
86
|
+
const rect = element.getBoundingClientRect()
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
class="element-outline"
|
|
90
|
+
data-outline={kind}
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
style={{
|
|
93
|
+
left: `${rect.left}px`,
|
|
94
|
+
top: `${rect.top}px`,
|
|
95
|
+
width: `${rect.width}px`,
|
|
96
|
+
height: `${rect.height}px`,
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
25
102
|
export function frameSrc(page: string, frame: Frame, version: number): string {
|
|
26
103
|
const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
27
104
|
return version === 0 ? path : `${path}?v=${version}`
|
|
@@ -41,31 +118,179 @@ function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
|
|
|
41
118
|
function FrameView({
|
|
42
119
|
page,
|
|
43
120
|
frame,
|
|
121
|
+
onWheelTurn,
|
|
44
122
|
}: {
|
|
45
123
|
readonly page: string
|
|
46
124
|
readonly frame: Frame
|
|
125
|
+
readonly onWheelTurn: (turn: WheelTurn) => void
|
|
47
126
|
}): JSX.Element {
|
|
48
127
|
const key = frameKey(page, frame)
|
|
49
128
|
const version = frameVersions.value.get(key) ?? 0
|
|
50
129
|
const value = frameTheme(key)
|
|
51
130
|
const iframe = useRef<HTMLIFrameElement>(null)
|
|
131
|
+
const gesture = useRef<Gesture | undefined>(undefined)
|
|
132
|
+
const ref: FrameRef = { page, frame: frame.name }
|
|
133
|
+
const isSelected =
|
|
134
|
+
selection.value?.page === page && selection.value.frame === frame.name
|
|
135
|
+
/* Bumped when the frame document scrolls, so the outlines follow it. */
|
|
136
|
+
const [, setScrolled] = useState(0)
|
|
137
|
+
const doc = frameDocuments.value.get(key)
|
|
52
138
|
|
|
53
139
|
useEffect(() => {
|
|
54
140
|
applyFrameTheme(iframe.current, value)
|
|
55
141
|
}, [value])
|
|
56
142
|
|
|
143
|
+
/*
|
|
144
|
+
* The frame shares the shell's origin, so its document takes listeners
|
|
145
|
+
* directly. A click selects the element under it and goes no further, so a
|
|
146
|
+
* link or a submit button in a draft never navigates the frame away.
|
|
147
|
+
*/
|
|
148
|
+
const handleLoad = () => {
|
|
149
|
+
const element = iframe.current
|
|
150
|
+
const loaded = element?.contentDocument
|
|
151
|
+
applyFrameTheme(element, frameTheme(key))
|
|
152
|
+
if (!element || !loaded) return
|
|
153
|
+
registerFrameDocument(key, loaded)
|
|
154
|
+
|
|
155
|
+
loaded.addEventListener('click', (event) => {
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
event.stopPropagation()
|
|
158
|
+
const target = asElement(event.target)
|
|
159
|
+
const address = target && addressOf(loaded, target)
|
|
160
|
+
if (address) void selectElement(ref, key, address)
|
|
161
|
+
})
|
|
162
|
+
loaded.addEventListener('submit', (event) => event.preventDefault())
|
|
163
|
+
loaded.addEventListener('mouseover', (event) => {
|
|
164
|
+
const target = asElement(event.target)
|
|
165
|
+
const index = target ? documentElements(loaded).indexOf(target) : -1
|
|
166
|
+
hoveredElement.value = index === -1 ? undefined : { key, index }
|
|
167
|
+
})
|
|
168
|
+
loaded.addEventListener('mouseleave', () => {
|
|
169
|
+
if (hoveredElement.value?.key === key) hoveredElement.value = undefined
|
|
170
|
+
})
|
|
171
|
+
loaded.addEventListener('scroll', () => setScrolled((n) => n + 1))
|
|
172
|
+
loaded.addEventListener(
|
|
173
|
+
'wheel',
|
|
174
|
+
(event) => {
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
const rect = element.getBoundingClientRect()
|
|
177
|
+
const { zoom } = view.value
|
|
178
|
+
onWheelTurn({
|
|
179
|
+
deltaX: event.deltaX,
|
|
180
|
+
deltaY: event.deltaY,
|
|
181
|
+
isZoom: event.ctrlKey || event.metaKey,
|
|
182
|
+
clientX: rect.left + event.clientX * zoom,
|
|
183
|
+
clientY: rect.top + event.clientY * zoom,
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
{ passive: false },
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const picked =
|
|
191
|
+
isSelected && doc && selection.value?.element
|
|
192
|
+
? selection.value.element
|
|
193
|
+
: undefined
|
|
194
|
+
const selectedNode =
|
|
195
|
+
doc && picked && !picked.stale ? elementAt(doc, picked) : undefined
|
|
196
|
+
const hovered = hoveredElement.value
|
|
197
|
+
const hoveredNode =
|
|
198
|
+
doc && hovered?.key === key
|
|
199
|
+
? documentElements(doc)[hovered.index]
|
|
200
|
+
: undefined
|
|
201
|
+
|
|
202
|
+
/* The label means the frame itself, so it also drops a picked element. */
|
|
203
|
+
const select = () => {
|
|
204
|
+
if (!isSelected || selection.value?.element) void selectFrame(ref)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
208
|
+
if (event.button !== 0) return
|
|
209
|
+
if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
|
|
210
|
+
gesture.current = {
|
|
211
|
+
startX: event.clientX,
|
|
212
|
+
startY: event.clientY,
|
|
213
|
+
originX: frame.x,
|
|
214
|
+
originY: frame.y,
|
|
215
|
+
x: frame.x,
|
|
216
|
+
y: frame.y,
|
|
217
|
+
moved: false,
|
|
218
|
+
}
|
|
219
|
+
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
|
|
220
|
+
select()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
224
|
+
const current = gesture.current
|
|
225
|
+
if (!current) return
|
|
226
|
+
const dx = event.clientX - current.startX
|
|
227
|
+
const dy = event.clientY - current.startY
|
|
228
|
+
if (!current.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
|
|
229
|
+
const { zoom } = view.value
|
|
230
|
+
current.moved = true
|
|
231
|
+
current.x = Math.round(current.originX + dx / zoom)
|
|
232
|
+
current.y = Math.round(current.originY + dy / zoom)
|
|
233
|
+
draggingFrame.value = frame.name
|
|
234
|
+
previewMove(ref, current.x, current.y)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const handlePointerUp = () => {
|
|
238
|
+
const current = gesture.current
|
|
239
|
+
gesture.current = undefined
|
|
240
|
+
draggingFrame.value = undefined
|
|
241
|
+
if (current?.moved) void moveFrameTo(ref, current.x, current.y)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const handlePointerCancel = () => {
|
|
245
|
+
const current = gesture.current
|
|
246
|
+
gesture.current = undefined
|
|
247
|
+
draggingFrame.value = undefined
|
|
248
|
+
if (current?.moved) previewMove(ref, current.originX, current.originY)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/*
|
|
252
|
+
* The keyboard path for a drag, since a pointer gesture alone leaves a
|
|
253
|
+
* frame unmovable without a mouse.
|
|
254
|
+
*/
|
|
255
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
256
|
+
if (event.target !== event.currentTarget) return
|
|
257
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
258
|
+
event.preventDefault()
|
|
259
|
+
select()
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const step = event.shiftKey ? NUDGE_LARGE : NUDGE
|
|
263
|
+
const delta = ARROWS[event.key]
|
|
264
|
+
if (!delta || !isSelected) return
|
|
265
|
+
event.preventDefault()
|
|
266
|
+
void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
|
|
267
|
+
}
|
|
268
|
+
|
|
57
269
|
const next = value === 'dark' ? 'light' : 'dark'
|
|
58
270
|
return (
|
|
59
271
|
<figure
|
|
60
272
|
class="frame"
|
|
61
273
|
data-frame={frame.name}
|
|
274
|
+
data-selected={isSelected ? 'true' : undefined}
|
|
275
|
+
data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
|
|
276
|
+
tabIndex={0}
|
|
277
|
+
aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
|
|
278
|
+
aria-current={isSelected ? 'true' : undefined}
|
|
279
|
+
onKeyDown={handleKeyDown}
|
|
62
280
|
style={{
|
|
63
281
|
left: `${frame.x}px`,
|
|
64
282
|
top: `${frame.y}px`,
|
|
65
283
|
width: `${frame.width}px`,
|
|
66
284
|
}}
|
|
67
285
|
>
|
|
68
|
-
|
|
286
|
+
{/* The frame's handle, since a press inside the frame picks an element. */}
|
|
287
|
+
<figcaption
|
|
288
|
+
class="frame-label"
|
|
289
|
+
onPointerDown={handlePointerDown}
|
|
290
|
+
onPointerMove={handlePointerMove}
|
|
291
|
+
onPointerUp={handlePointerUp}
|
|
292
|
+
onPointerCancel={handlePointerCancel}
|
|
293
|
+
>
|
|
69
294
|
<span class="frame-name" title={frame.name}>
|
|
70
295
|
{frame.name}
|
|
71
296
|
</span>
|
|
@@ -82,15 +307,23 @@ function FrameView({
|
|
|
82
307
|
{value === 'dark' ? 'Dark' : 'Light'}
|
|
83
308
|
</button>
|
|
84
309
|
</figcaption>
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
310
|
+
<div class="frame-body">
|
|
311
|
+
<iframe
|
|
312
|
+
ref={iframe}
|
|
313
|
+
title={frame.name}
|
|
314
|
+
src={frameSrc(page, frame, version)}
|
|
315
|
+
width={frame.width}
|
|
316
|
+
height={frame.height}
|
|
317
|
+
style={{ height: `${frame.height}px` }}
|
|
318
|
+
onLoad={handleLoad}
|
|
319
|
+
/>
|
|
320
|
+
{hoveredNode && hoveredNode !== selectedNode ? (
|
|
321
|
+
<ElementOutline element={hoveredNode} kind="hover" />
|
|
322
|
+
) : null}
|
|
323
|
+
{selectedNode ? (
|
|
324
|
+
<ElementOutline element={selectedNode} kind="selected" />
|
|
325
|
+
) : null}
|
|
326
|
+
</div>
|
|
94
327
|
</figure>
|
|
95
328
|
)
|
|
96
329
|
}
|
|
@@ -153,19 +386,29 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
153
386
|
fitView(width, height)
|
|
154
387
|
}, [page?.name])
|
|
155
388
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
if (event.ctrlKey || event.metaKey) {
|
|
389
|
+
const handleWheelTurn = (turn: WheelTurn) => {
|
|
390
|
+
if (turn.isZoom) {
|
|
159
391
|
const rect = viewportRef.current?.getBoundingClientRect()
|
|
160
|
-
const factor =
|
|
392
|
+
const factor = turn.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
|
|
161
393
|
zoomAt(
|
|
162
394
|
factor,
|
|
163
|
-
|
|
164
|
-
|
|
395
|
+
turn.clientX - (rect?.left ?? 0),
|
|
396
|
+
turn.clientY - (rect?.top ?? 0),
|
|
165
397
|
)
|
|
166
398
|
return
|
|
167
399
|
}
|
|
168
|
-
panBy(-
|
|
400
|
+
panBy(-turn.deltaX, -turn.deltaY)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const handleWheel = (event: WheelEvent) => {
|
|
404
|
+
event.preventDefault()
|
|
405
|
+
handleWheelTurn({
|
|
406
|
+
deltaX: event.deltaX,
|
|
407
|
+
deltaY: event.deltaY,
|
|
408
|
+
isZoom: event.ctrlKey || event.metaKey,
|
|
409
|
+
clientX: event.clientX,
|
|
410
|
+
clientY: event.clientY,
|
|
411
|
+
})
|
|
169
412
|
}
|
|
170
413
|
|
|
171
414
|
const handlePointerDown = (event: PointerEvent) => {
|
|
@@ -200,10 +443,19 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
200
443
|
>
|
|
201
444
|
<div
|
|
202
445
|
class="plane"
|
|
203
|
-
style={{
|
|
446
|
+
style={{
|
|
447
|
+
transform: `translate(${x}px, ${y}px) scale(${zoom})`,
|
|
448
|
+
/* Lets an outline inside the plane hold its screen weight at any zoom. */
|
|
449
|
+
'--outline-scale': String(1 / zoom),
|
|
450
|
+
}}
|
|
204
451
|
>
|
|
205
452
|
{page?.frames.map((frame) => (
|
|
206
|
-
<FrameView
|
|
453
|
+
<FrameView
|
|
454
|
+
key={frame.name}
|
|
455
|
+
page={page.name}
|
|
456
|
+
frame={frame}
|
|
457
|
+
onWheelTurn={handleWheelTurn}
|
|
458
|
+
/>
|
|
207
459
|
))}
|
|
208
460
|
</div>
|
|
209
461
|
</div>
|