@erclx/canon 5.27.0 → 5.28.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-ready/SKILL.md +3 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +16 -7
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-craft/references/ui.md +4 -4
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +13 -11
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +13 -3
- package/claude/skills/review-ui/references/tick.md +22 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
- package/claude/skills/role-orchestrator/SKILL.md +24 -26
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +3 -2
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/docs/agents/canvas.md +4 -4
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/pr-evidence.md +9 -8
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +2 -2
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/tasks-validate.md +18 -18
- package/docs/agents/tasks.md +1 -1
- package/docs/workflow/operating-model.md +10 -15
- package/docs/workflow/parallel-features.md +7 -7
- package/package.json +1 -1
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +1 -1
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/inspector/field.tsx +61 -2
- package/src/canvas/client/inspector/fields.css +78 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +74 -2
- package/src/canvas/client/inspector.tsx +121 -11
- package/src/canvas/client/keys.ts +54 -0
- package/src/canvas/client/selection.tsx +3 -1
- package/src/canvas/client/shell.css +108 -2
- package/src/canvas/client/state.ts +96 -5
- package/src/canvas/client/surface.tsx +194 -17
- package/src/canvas/client/tools.tsx +64 -0
- package/src/canvas/content.ts +1 -1
- package/src/canvas/edit.ts +12 -2
- package/src/canvas/tokens.ts +1 -1
- package/src/claude/seeds.ts +2 -2
- package/src/claude/skills-reach.ts +1 -1
- package/src/commands/canvas.ts +1 -1
- package/src/commands/claude.ts +3 -3
- package/src/commands/design.ts +3 -3
- package/src/commands/docs.ts +1 -1
- package/src/commands/feedback.ts +2 -2
- package/src/commands/gate.ts +9 -2
- package/src/commands/gov.ts +2 -2
- package/src/commands/init.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +183 -7
- package/src/commands/sandbox.ts +10 -13
- package/src/commands/slides.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/tasks.ts +19 -2
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +1 -1
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +2 -2
- package/src/context/classify/extract.ts +1 -1
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/docs/read.ts +1 -1
- package/src/exec.ts +1 -1
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +1 -1
- package/src/gate/sequencer.ts +55 -3
- package/src/gate/stages.ts +8 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/intake/folder.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/evidence.ts +154 -16
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +1 -1
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/records/size.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +1 -1
- package/src/{record-root.ts → roots/record.ts} +1 -1
- package/src/sandbox/coverage.ts +11 -12
- package/src/sandbox/equivalence.ts +57 -26
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +78 -28
- package/src/sandbox/scenario.ts +588 -0
- package/src/sessions/live.ts +4 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/decks.ts +1 -1
- package/src/standards/read.ts +1 -1
- package/src/sync/engine.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/stamp.ts +1 -1
- package/src/targets/validate.ts +1 -1
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/tasks/label.ts +1 -1
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/validate.ts +38 -14
- package/src/teach/nav.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/tooling/inject.ts +1 -1
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/plan.md +13 -7
- package/tsconfig.json +1 -1
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
|
@@ -22,6 +22,11 @@ export interface TokenSource {
|
|
|
22
22
|
|
|
23
23
|
export type LeftTab = 'pages' | 'theme'
|
|
24
24
|
|
|
25
|
+
export type Tool = 'move' | 'pan'
|
|
26
|
+
|
|
27
|
+
/** A Space pan held, and whether a drag has moved the view while it was. */
|
|
28
|
+
export type SpacePan = 'held' | 'dragged'
|
|
29
|
+
|
|
25
30
|
/** One property change to the selected element, as the inspector posts it. */
|
|
26
31
|
export interface PendingEdit {
|
|
27
32
|
readonly key: string
|
|
@@ -45,11 +50,19 @@ export interface SelectionRef extends FrameRef {
|
|
|
45
50
|
readonly element?: ElementRef
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** A frame a session says it is editing, until the stamp it expires at. */
|
|
54
|
+
export interface EditingRef extends FrameRef {
|
|
55
|
+
readonly by: string
|
|
56
|
+
readonly until: string
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
export interface PagesRecord {
|
|
49
60
|
readonly pages: readonly Page[]
|
|
50
61
|
readonly tokens: TokenSource
|
|
51
62
|
/** Absent when nothing is selected or the selected frame is gone. */
|
|
52
63
|
readonly selection?: SelectionRef | null
|
|
64
|
+
/** The live marks, which a server older than the mark leaves out. */
|
|
65
|
+
readonly editing?: readonly EditingRef[]
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
export interface ChangeEvent {
|
|
@@ -101,6 +114,9 @@ export const hoveredElement = signal<
|
|
|
101
114
|
{ readonly key: string; readonly index: number } | undefined
|
|
102
115
|
>(undefined)
|
|
103
116
|
|
|
117
|
+
/** Each marked frame's mark by frame key, dropped at its `until`. */
|
|
118
|
+
export const editingFrames = signal<ReadonlyMap<string, EditingRef>>(new Map())
|
|
119
|
+
|
|
104
120
|
/** Frame keys whose layers are open in the pages panel. */
|
|
105
121
|
export const expandedFrames = signal<ReadonlySet<string>>(new Set())
|
|
106
122
|
|
|
@@ -112,6 +128,16 @@ export const writeError = signal<string | undefined>(undefined)
|
|
|
112
128
|
|
|
113
129
|
export const leftTab = signal<LeftTab>('pages')
|
|
114
130
|
|
|
131
|
+
/** The tool the strip has lit, which a Space pan overrides while held. */
|
|
132
|
+
export const activeTool = signal<Tool>('move')
|
|
133
|
+
|
|
134
|
+
export const spacePan = signal<SpacePan | undefined>(undefined)
|
|
135
|
+
|
|
136
|
+
/** The tool in effect, so Space shows Pan lit and releasing it restores it. */
|
|
137
|
+
export const shownTool = computed<Tool>(() =>
|
|
138
|
+
spacePan.value ? 'pan' : activeTool.value,
|
|
139
|
+
)
|
|
140
|
+
|
|
115
141
|
/** The edit in flight, which holds its field until the server answers. */
|
|
116
142
|
export const pendingEdit = signal<PendingEdit | undefined>(undefined)
|
|
117
143
|
|
|
@@ -193,10 +219,62 @@ export function toggleLayers(key: string): void {
|
|
|
193
219
|
expandedFrames.value = next
|
|
194
220
|
}
|
|
195
221
|
|
|
222
|
+
/*
|
|
223
|
+
* An expiry writes no file and raises no change event, so nothing rereads the
|
|
224
|
+
* page list when a mark lapses. One timer, set for the earliest `until`, drops
|
|
225
|
+
* it instead, and every record resets it.
|
|
226
|
+
*/
|
|
227
|
+
let editingTimer: ReturnType<typeof setTimeout> | undefined
|
|
228
|
+
|
|
229
|
+
/*
|
|
230
|
+
* The longest delay a timer holds. A longer one fires at once, so an `until`
|
|
231
|
+
* far out would reschedule on every tick rather than wait.
|
|
232
|
+
*/
|
|
233
|
+
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
234
|
+
|
|
235
|
+
function liveMarks(
|
|
236
|
+
marks: Iterable<readonly [string, EditingRef]>,
|
|
237
|
+
now: number,
|
|
238
|
+
): ReadonlyMap<string, EditingRef> {
|
|
239
|
+
return new Map([...marks].filter(([, mark]) => Date.parse(mark.until) > now))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function scheduleExpiry(): void {
|
|
243
|
+
clearTimeout(editingTimer)
|
|
244
|
+
editingTimer = undefined
|
|
245
|
+
const next = Math.min(
|
|
246
|
+
...[...editingFrames.value.values()].map((mark) => Date.parse(mark.until)),
|
|
247
|
+
)
|
|
248
|
+
if (!Number.isFinite(next)) return
|
|
249
|
+
editingTimer = setTimeout(
|
|
250
|
+
() => {
|
|
251
|
+
editingFrames.value = liveMarks(editingFrames.value, Date.now())
|
|
252
|
+
scheduleExpiry()
|
|
253
|
+
},
|
|
254
|
+
Math.min(MAX_TIMER_MS, Math.max(0, next - Date.now())),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Keys each mark by the file of the frame it names, as the surface reads. */
|
|
259
|
+
function applyEditing(
|
|
260
|
+
all: readonly Page[],
|
|
261
|
+
marks: readonly EditingRef[],
|
|
262
|
+
): void {
|
|
263
|
+
const keyed = marks.flatMap((mark) => {
|
|
264
|
+
const frame = all
|
|
265
|
+
.find((candidate) => candidate.name === mark.page)
|
|
266
|
+
?.frames.find((candidate) => candidate.name === mark.frame)
|
|
267
|
+
return frame ? [[frameKey(mark.page, frame), mark] as const] : []
|
|
268
|
+
})
|
|
269
|
+
editingFrames.value = liveMarks(keyed, Date.now())
|
|
270
|
+
scheduleExpiry()
|
|
271
|
+
}
|
|
272
|
+
|
|
196
273
|
export function applyRecord(record: PagesRecord): void {
|
|
197
274
|
pages.value = record.pages
|
|
198
275
|
tokens.value = record.tokens
|
|
199
276
|
selection.value = record.selection ?? undefined
|
|
277
|
+
applyEditing(record.pages, record.editing ?? [])
|
|
200
278
|
loadError.value = undefined
|
|
201
279
|
isLoaded.value = true
|
|
202
280
|
}
|
|
@@ -487,11 +565,19 @@ export function panBy(dx: number, dy: number): void {
|
|
|
487
565
|
view.value = { ...current, x: current.x + dx, y: current.y + dy }
|
|
488
566
|
}
|
|
489
567
|
|
|
490
|
-
/**
|
|
491
|
-
|
|
568
|
+
/**
|
|
569
|
+
* Fits every frame on the current page into a viewport of the given size,
|
|
570
|
+
* clear of `insetLeft` pixels of chrome floating over its left edge.
|
|
571
|
+
*/
|
|
572
|
+
export function fitView(
|
|
573
|
+
width: number,
|
|
574
|
+
height: number,
|
|
575
|
+
padding = 48,
|
|
576
|
+
insetLeft = 0,
|
|
577
|
+
): void {
|
|
492
578
|
const frames = currentPage.value?.frames ?? []
|
|
493
579
|
if (frames.length === 0 || width <= 0 || height <= 0) {
|
|
494
|
-
view.value = { x: padding, y: padding, zoom: 0.5 }
|
|
580
|
+
view.value = { x: insetLeft + padding, y: padding, zoom: 0.5 }
|
|
495
581
|
return
|
|
496
582
|
}
|
|
497
583
|
const left = Math.min(...frames.map((frame) => frame.x))
|
|
@@ -500,14 +586,14 @@ export function fitView(width: number, height: number, padding = 48): void {
|
|
|
500
586
|
const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
|
|
501
587
|
const zoom = clampZoom(
|
|
502
588
|
Math.min(
|
|
503
|
-
(width - padding * 2) / (right - left),
|
|
589
|
+
(width - insetLeft - padding * 2) / (right - left),
|
|
504
590
|
(height - padding * 2) / (bottom - top),
|
|
505
591
|
1,
|
|
506
592
|
),
|
|
507
593
|
)
|
|
508
594
|
view.value = {
|
|
509
595
|
zoom,
|
|
510
|
-
x: padding - left * zoom,
|
|
596
|
+
x: insetLeft + padding - left * zoom,
|
|
511
597
|
y: padding - top * zoom,
|
|
512
598
|
}
|
|
513
599
|
}
|
|
@@ -537,10 +623,15 @@ export function resetState(): void {
|
|
|
537
623
|
selection.value = undefined
|
|
538
624
|
frameDocuments.value = new Map()
|
|
539
625
|
hoveredElement.value = undefined
|
|
626
|
+
editingFrames.value = new Map()
|
|
627
|
+
clearTimeout(editingTimer)
|
|
628
|
+
editingTimer = undefined
|
|
540
629
|
expandedFrames.value = new Set()
|
|
541
630
|
draggingFrame.value = undefined
|
|
542
631
|
writeError.value = undefined
|
|
543
632
|
leftTab.value = 'pages'
|
|
633
|
+
activeTool.value = 'move'
|
|
634
|
+
spacePan.value = undefined
|
|
544
635
|
pendingEdit.value = undefined
|
|
545
636
|
savedEdit.value = undefined
|
|
546
637
|
editRefusal.value = undefined
|
|
@@ -3,9 +3,12 @@ import type { JSX, RefObject } from 'preact'
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
4
4
|
import { addressOf, documentElements, elementAt } from '@/canvas/address'
|
|
5
5
|
import type { Frame } from '@/canvas/content'
|
|
6
|
+
import { type SurfaceAction, surfaceAction } from '@/canvas/client/keys'
|
|
6
7
|
import {
|
|
8
|
+
activeTool,
|
|
7
9
|
currentPage,
|
|
8
10
|
draggingFrame,
|
|
11
|
+
editingFrames,
|
|
9
12
|
fitView,
|
|
10
13
|
type FrameRef,
|
|
11
14
|
frameDocuments,
|
|
@@ -23,6 +26,8 @@ import {
|
|
|
23
26
|
selectElement,
|
|
24
27
|
selectFrame,
|
|
25
28
|
selection,
|
|
29
|
+
shownTool,
|
|
30
|
+
spacePan,
|
|
26
31
|
type Theme,
|
|
27
32
|
toggleFrameTheme,
|
|
28
33
|
view,
|
|
@@ -34,6 +39,7 @@ import {
|
|
|
34
39
|
type OverlayBox,
|
|
35
40
|
SelectionOverlay,
|
|
36
41
|
} from '@/canvas/client/selection'
|
|
42
|
+
import { ToolStrip } from '@/canvas/client/tools'
|
|
37
43
|
|
|
38
44
|
/** One wheel notch or one button press. */
|
|
39
45
|
const ZOOM_STEP = 1.2
|
|
@@ -82,6 +88,37 @@ function asElement(target: EventTarget | null): Element | undefined {
|
|
|
82
88
|
return node?.nodeType === 1 ? (node as Element) : undefined
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether a key belongs to the element it was typed in rather than to the
|
|
93
|
+
* canvas: any key in a field, and Space on a control it activates. A frame's
|
|
94
|
+
* draft can hold its own fields, and their keys arrive through the forwarding.
|
|
95
|
+
*/
|
|
96
|
+
function isOwnedByTarget(event: KeyboardEvent, action: SurfaceAction): boolean {
|
|
97
|
+
const target = asElement(event.target)
|
|
98
|
+
if (!target) return false
|
|
99
|
+
if ((target as HTMLElement).isContentEditable) return true
|
|
100
|
+
if (target.closest('input, textarea, select')) return true
|
|
101
|
+
return (
|
|
102
|
+
action === 'pan-hold' && target.closest('button, a[href], summary') !== null
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A press read as a second click selects a word or a whole frame even while
|
|
108
|
+
* panning, so no selection may start in the shell or a frame then.
|
|
109
|
+
*/
|
|
110
|
+
function blockSelectionWhilePanning(event: Event): void {
|
|
111
|
+
if (shownTool.value === 'pan') event.preventDefault()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drops any selection in the shell and in every loaded frame document. */
|
|
115
|
+
function clearSelections(): void {
|
|
116
|
+
document.getSelection()?.removeAllRanges()
|
|
117
|
+
for (const doc of frameDocuments.value.values()) {
|
|
118
|
+
doc.getSelection()?.removeAllRanges()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
85
122
|
/** A computed length in pixels, or the fallback for one that is not. */
|
|
86
123
|
function pixels(value: string, fallback: number): number {
|
|
87
124
|
const parsed = Number.parseFloat(value)
|
|
@@ -211,10 +248,12 @@ function FrameView({
|
|
|
211
248
|
page,
|
|
212
249
|
frame,
|
|
213
250
|
onWheelTurn,
|
|
251
|
+
onKey,
|
|
214
252
|
}: {
|
|
215
253
|
readonly page: string
|
|
216
254
|
readonly frame: Frame
|
|
217
255
|
readonly onWheelTurn: (turn: WheelTurn) => void
|
|
256
|
+
readonly onKey: (event: KeyboardEvent) => void
|
|
218
257
|
}): JSX.Element {
|
|
219
258
|
const key = frameKey(page, frame)
|
|
220
259
|
const version = frameVersions.value.get(key) ?? 0
|
|
@@ -227,6 +266,7 @@ function FrameView({
|
|
|
227
266
|
/* Bumped when the frame document scrolls, so the outlines follow it. */
|
|
228
267
|
const [, setScrolled] = useState(0)
|
|
229
268
|
const doc = frameDocuments.value.get(key)
|
|
269
|
+
const editing = editingFrames.value.get(key)
|
|
230
270
|
|
|
231
271
|
useEffect(() => {
|
|
232
272
|
applyFrameTheme(iframe.current, value)
|
|
@@ -277,6 +317,16 @@ function FrameView({
|
|
|
277
317
|
},
|
|
278
318
|
{ passive: false },
|
|
279
319
|
)
|
|
320
|
+
/*
|
|
321
|
+
* A click inside the frame moves focus into its window, so the surface
|
|
322
|
+
* hears no key after the first pick unless the frame passes it on.
|
|
323
|
+
*/
|
|
324
|
+
loaded.addEventListener('keydown', onKey)
|
|
325
|
+
loaded.addEventListener('keyup', onKey)
|
|
326
|
+
loaded.addEventListener('selectstart', blockSelectionWhilePanning)
|
|
327
|
+
loaded.defaultView?.addEventListener('blur', () => {
|
|
328
|
+
spacePan.value = undefined
|
|
329
|
+
})
|
|
280
330
|
}
|
|
281
331
|
|
|
282
332
|
const picked =
|
|
@@ -298,6 +348,8 @@ function FrameView({
|
|
|
298
348
|
|
|
299
349
|
const handlePointerDown = (event: PointerEvent) => {
|
|
300
350
|
if (event.button !== 0) return
|
|
351
|
+
/* A pan drag starting on the label belongs to the surface behind it. */
|
|
352
|
+
if (shownTool.value === 'pan') return
|
|
301
353
|
if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
|
|
302
354
|
gesture.current = {
|
|
303
355
|
startX: event.clientX,
|
|
@@ -346,7 +398,7 @@ function FrameView({
|
|
|
346
398
|
*/
|
|
347
399
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
348
400
|
if (event.target !== event.currentTarget) return
|
|
349
|
-
if (event.key === 'Enter'
|
|
401
|
+
if (event.key === 'Enter') {
|
|
350
402
|
event.preventDefault()
|
|
351
403
|
select()
|
|
352
404
|
return
|
|
@@ -368,6 +420,16 @@ function FrameView({
|
|
|
368
420
|
void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
|
|
369
421
|
}
|
|
370
422
|
|
|
423
|
+
/*
|
|
424
|
+
* Space held pans, so it selects on release instead, and only when no drag
|
|
425
|
+
* moved the view while it was down. This runs before the surface's own
|
|
426
|
+
* release clears the pan.
|
|
427
|
+
*/
|
|
428
|
+
const handleKeyUp = (event: KeyboardEvent) => {
|
|
429
|
+
if (event.target !== event.currentTarget || event.key !== ' ') return
|
|
430
|
+
if (spacePan.value !== 'dragged') select()
|
|
431
|
+
}
|
|
432
|
+
|
|
371
433
|
const frameBox = (): OverlayBox => ({
|
|
372
434
|
x: frame.x,
|
|
373
435
|
y: frame.y,
|
|
@@ -383,10 +445,12 @@ function FrameView({
|
|
|
383
445
|
data-selected={isSelected ? 'true' : undefined}
|
|
384
446
|
data-picked={picked ? 'true' : undefined}
|
|
385
447
|
data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
|
|
448
|
+
data-editing={editing ? 'true' : undefined}
|
|
386
449
|
tabIndex={0}
|
|
387
|
-
aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
|
|
450
|
+
aria-label={`${frame.name}, ${frame.width} by ${frame.height}${editing ? `, ${editing.by} editing` : ''}`}
|
|
388
451
|
aria-current={isSelected ? 'true' : undefined}
|
|
389
452
|
onKeyDown={handleKeyDown}
|
|
453
|
+
onKeyUp={handleKeyUp}
|
|
390
454
|
style={{
|
|
391
455
|
left: `${frame.x}px`,
|
|
392
456
|
top: `${frame.y}px`,
|
|
@@ -407,6 +471,10 @@ function FrameView({
|
|
|
407
471
|
<span class="frame-size">
|
|
408
472
|
{frame.width} × {frame.height}
|
|
409
473
|
</span>
|
|
474
|
+
{/* Rendered empty while unmarked, so a mark landing is announced. */}
|
|
475
|
+
<span class="frame-editing" aria-live="polite">
|
|
476
|
+
{editing ? `${editing.by} editing` : ''}
|
|
477
|
+
</span>
|
|
410
478
|
<button
|
|
411
479
|
type="button"
|
|
412
480
|
class="frame-theme"
|
|
@@ -461,18 +529,35 @@ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
|
|
|
461
529
|
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
|
462
530
|
}
|
|
463
531
|
|
|
532
|
+
/** The zoom toolbar's actions, which its keys call as well. */
|
|
533
|
+
function zoomCentered(
|
|
534
|
+
viewport: RefObject<HTMLDivElement | null>,
|
|
535
|
+
factor: number,
|
|
536
|
+
): void {
|
|
537
|
+
const { width, height } = viewportSize(viewport)
|
|
538
|
+
zoomAt(factor, width / 2, height / 2)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Fits the page clear of the tool strip floating over the viewport's left. */
|
|
542
|
+
function fitViewport(viewport: RefObject<HTMLDivElement | null>): void {
|
|
543
|
+
const rect = viewport.current?.getBoundingClientRect()
|
|
544
|
+
const strip = viewport.current?.parentElement
|
|
545
|
+
?.querySelector('.tools')
|
|
546
|
+
?.getBoundingClientRect()
|
|
547
|
+
const inset = rect && strip ? Math.max(0, strip.right - rect.left) : 0
|
|
548
|
+
fitView(rect?.width ?? 0, rect?.height ?? 0, undefined, inset)
|
|
549
|
+
}
|
|
550
|
+
|
|
464
551
|
function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
465
|
-
const zoomCentered = (factor: number) => {
|
|
466
|
-
const { width, height } = viewportSize(viewportRef)
|
|
467
|
-
zoomAt(factor, width / 2, height / 2)
|
|
468
|
-
}
|
|
469
552
|
return (
|
|
470
553
|
<div class="toolbar" role="toolbar" aria-label="Zoom">
|
|
471
554
|
<button
|
|
472
555
|
type="button"
|
|
473
556
|
class="icon-button"
|
|
474
557
|
aria-label="Zoom out"
|
|
475
|
-
|
|
558
|
+
aria-keyshortcuts="-"
|
|
559
|
+
title="Zoom out (-)"
|
|
560
|
+
onClick={() => zoomCentered(viewportRef, 1 / ZOOM_STEP)}
|
|
476
561
|
>
|
|
477
562
|
−
|
|
478
563
|
</button>
|
|
@@ -483,17 +568,18 @@ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
483
568
|
type="button"
|
|
484
569
|
class="icon-button"
|
|
485
570
|
aria-label="Zoom in"
|
|
486
|
-
|
|
571
|
+
aria-keyshortcuts="+"
|
|
572
|
+
title="Zoom in (+)"
|
|
573
|
+
onClick={() => zoomCentered(viewportRef, ZOOM_STEP)}
|
|
487
574
|
>
|
|
488
575
|
+
|
|
489
576
|
</button>
|
|
490
577
|
<button
|
|
491
578
|
type="button"
|
|
492
579
|
class="text-button"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}}
|
|
580
|
+
aria-keyshortcuts="Shift+1"
|
|
581
|
+
title="Fit (Shift+1)"
|
|
582
|
+
onClick={() => fitViewport(viewportRef)}
|
|
497
583
|
>
|
|
498
584
|
Fit
|
|
499
585
|
</button>
|
|
@@ -507,8 +593,7 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
507
593
|
|
|
508
594
|
/* Fits a page once when it opens, never on a reload of the same page. */
|
|
509
595
|
useEffect(() => {
|
|
510
|
-
|
|
511
|
-
fitView(width, height)
|
|
596
|
+
fitViewport(viewportRef)
|
|
512
597
|
}, [page?.name])
|
|
513
598
|
|
|
514
599
|
const handleWheelTurn = (turn: WheelTurn) => {
|
|
@@ -536,9 +621,68 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
536
621
|
})
|
|
537
622
|
}
|
|
538
623
|
|
|
624
|
+
/* A pan held when the window loses focus never hears its Space come up. */
|
|
625
|
+
useEffect(() => {
|
|
626
|
+
const endPan = () => {
|
|
627
|
+
spacePan.value = undefined
|
|
628
|
+
}
|
|
629
|
+
window.addEventListener('blur', endPan)
|
|
630
|
+
document.addEventListener('selectstart', blockSelectionWhilePanning)
|
|
631
|
+
return () => {
|
|
632
|
+
window.removeEventListener('blur', endPan)
|
|
633
|
+
document.removeEventListener('selectstart', blockSelectionWhilePanning)
|
|
634
|
+
}
|
|
635
|
+
}, [])
|
|
636
|
+
|
|
637
|
+
const runAction = (action: SurfaceAction) => {
|
|
638
|
+
switch (action) {
|
|
639
|
+
case 'tool-move':
|
|
640
|
+
activeTool.value = 'move'
|
|
641
|
+
return
|
|
642
|
+
case 'tool-pan':
|
|
643
|
+
activeTool.value = 'pan'
|
|
644
|
+
return
|
|
645
|
+
case 'zoom-in':
|
|
646
|
+
zoomCentered(viewportRef, ZOOM_STEP)
|
|
647
|
+
return
|
|
648
|
+
case 'zoom-out':
|
|
649
|
+
zoomCentered(viewportRef, 1 / ZOOM_STEP)
|
|
650
|
+
return
|
|
651
|
+
case 'zoom-fit':
|
|
652
|
+
fitViewport(viewportRef)
|
|
653
|
+
return
|
|
654
|
+
case 'pan-hold':
|
|
655
|
+
spacePan.value ??= 'held'
|
|
656
|
+
return
|
|
657
|
+
case 'pan-release':
|
|
658
|
+
spacePan.value = undefined
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/*
|
|
663
|
+
* The surface's one key handler, which every frame document feeds too. The
|
|
664
|
+
* default is prevented only for a key press it takes, so Space stops
|
|
665
|
+
* scrolling a frame it pans and every other key keeps its own meaning. A
|
|
666
|
+
* release always ends the pan and never claims the keyup, since a button
|
|
667
|
+
* fires its Space activation there.
|
|
668
|
+
*/
|
|
669
|
+
const handleKey = (event: KeyboardEvent) => {
|
|
670
|
+
const action = surfaceAction(event)
|
|
671
|
+
if (!action) return
|
|
672
|
+
if (action === 'pan-release') {
|
|
673
|
+
runAction(action)
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
if (isOwnedByTarget(event, action)) return
|
|
677
|
+
event.preventDefault()
|
|
678
|
+
runAction(action)
|
|
679
|
+
}
|
|
680
|
+
|
|
539
681
|
const handlePointerDown = (event: PointerEvent) => {
|
|
540
682
|
const target = event.target as HTMLElement | null
|
|
541
|
-
if (target?.closest('.frame
|
|
683
|
+
if (shownTool.value !== 'pan' && target?.closest('.frame')) return
|
|
684
|
+
/* A selection left over would paint over every frame the pan crosses. */
|
|
685
|
+
if (shownTool.value === 'pan') clearSelections()
|
|
542
686
|
drag.current = { x: event.clientX, y: event.clientY }
|
|
543
687
|
viewportRef.current?.setPointerCapture?.(event.pointerId)
|
|
544
688
|
}
|
|
@@ -546,22 +690,53 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
546
690
|
const handlePointerMove = (event: PointerEvent) => {
|
|
547
691
|
const start = drag.current
|
|
548
692
|
if (!start) return
|
|
549
|
-
|
|
693
|
+
const dx = event.clientX - start.x
|
|
694
|
+
const dy = event.clientY - start.y
|
|
695
|
+
panBy(dx, dy)
|
|
550
696
|
drag.current = { x: event.clientX, y: event.clientY }
|
|
697
|
+
if (spacePan.value === 'held' && (dx !== 0 || dy !== 0)) {
|
|
698
|
+
spacePan.value = 'dragged'
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/*
|
|
703
|
+
* A press selects text by default, a word or a whole frame when it reads as
|
|
704
|
+
* a second click, and neither `selectstart` nor a clear at pointer down
|
|
705
|
+
* reaches that. Under a held Space, focus stays where it was so the frame
|
|
706
|
+
* still hears the release. Under the Pan tool the press takes focus itself,
|
|
707
|
+
* as the default would have, so a field it leaves commits and the next key
|
|
708
|
+
* reaches the layer.
|
|
709
|
+
*/
|
|
710
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
711
|
+
if (shownTool.value !== 'pan') return
|
|
712
|
+
event.preventDefault()
|
|
713
|
+
if (!spacePan.value) {
|
|
714
|
+
const surface = (event.currentTarget as HTMLElement).closest('main')
|
|
715
|
+
surface?.focus({ preventScroll: true })
|
|
716
|
+
}
|
|
551
717
|
}
|
|
552
718
|
|
|
553
719
|
const handlePointerUp = () => {
|
|
720
|
+
if (drag.current && shownTool.value === 'pan') clearSelections()
|
|
554
721
|
drag.current = undefined
|
|
555
722
|
}
|
|
556
723
|
|
|
557
724
|
const { x, y, zoom } = view.value
|
|
558
725
|
return (
|
|
559
|
-
<main
|
|
726
|
+
<main
|
|
727
|
+
class="surface"
|
|
728
|
+
aria-label="Canvas"
|
|
729
|
+
tabIndex={0}
|
|
730
|
+
data-tool={shownTool.value}
|
|
731
|
+
onKeyDown={handleKey}
|
|
732
|
+
onKeyUp={handleKey}
|
|
733
|
+
>
|
|
560
734
|
<div
|
|
561
735
|
ref={viewportRef}
|
|
562
736
|
class="viewport"
|
|
563
737
|
onWheel={handleWheel}
|
|
564
738
|
onPointerDown={handlePointerDown}
|
|
739
|
+
onMouseDown={handleMouseDown}
|
|
565
740
|
onPointerMove={handlePointerMove}
|
|
566
741
|
onPointerUp={handlePointerUp}
|
|
567
742
|
onPointerCancel={handlePointerUp}
|
|
@@ -580,10 +755,12 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
|
|
|
580
755
|
page={page.name}
|
|
581
756
|
frame={frame}
|
|
582
757
|
onWheelTurn={handleWheelTurn}
|
|
758
|
+
onKey={handleKey}
|
|
583
759
|
/>
|
|
584
760
|
))}
|
|
585
761
|
</div>
|
|
586
762
|
</div>
|
|
763
|
+
<ToolStrip />
|
|
587
764
|
<Toolbar viewportRef={viewportRef} />
|
|
588
765
|
</main>
|
|
589
766
|
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { activeTool, shownTool, type Tool } from '@/canvas/client/state'
|
|
4
|
+
|
|
5
|
+
interface ToolButton {
|
|
6
|
+
readonly tool: Tool
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly key: string
|
|
9
|
+
readonly icon: JSX.Element
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TOOLS: readonly ToolButton[] = [
|
|
13
|
+
{
|
|
14
|
+
tool: 'move',
|
|
15
|
+
name: 'Move',
|
|
16
|
+
key: 'V',
|
|
17
|
+
icon: (
|
|
18
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
19
|
+
<path d="M3.5 2.5l9 4.2-3.9 1.2-1.4 3.8z" />
|
|
20
|
+
</svg>
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
tool: 'pan',
|
|
25
|
+
name: 'Pan',
|
|
26
|
+
key: 'H',
|
|
27
|
+
icon: (
|
|
28
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
29
|
+
<path d="M5.5 8.5V4a1 1 0 0 1 2 0v3.5M7.5 7V2.8a1 1 0 0 1 2 0V7M9.5 7V3.6a1 1 0 0 1 2 0V8M11.5 7.5a1 1 0 0 1 2 0v2.2A4.8 4.8 0 0 1 8.7 14.5h-.6a4.3 4.3 0 0 1-3.2-1.4L2.6 10.6a1 1 0 0 1 1.5-1.4l1.4 1.3" />
|
|
30
|
+
</svg>
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/** The tool strip floating at the surface's left edge, one button a tool. */
|
|
36
|
+
export function ToolStrip(): JSX.Element {
|
|
37
|
+
const shown = shownTool.value
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
class="tools"
|
|
41
|
+
role="toolbar"
|
|
42
|
+
aria-label="Tools"
|
|
43
|
+
aria-orientation="vertical"
|
|
44
|
+
>
|
|
45
|
+
{TOOLS.map(({ tool, name, key, icon }) => (
|
|
46
|
+
<button
|
|
47
|
+
key={tool}
|
|
48
|
+
type="button"
|
|
49
|
+
class="tool-button"
|
|
50
|
+
aria-label={`${name} (${key})`}
|
|
51
|
+
aria-keyshortcuts={key}
|
|
52
|
+
aria-pressed={shown === tool ? 'true' : 'false'}
|
|
53
|
+
title={`${name} (${key})`}
|
|
54
|
+
data-tool={tool}
|
|
55
|
+
onClick={() => {
|
|
56
|
+
activeTool.value = tool
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{icon}
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
package/src/canvas/content.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
resolveAddress,
|
|
19
19
|
type SourceElement,
|
|
20
20
|
} from '@/canvas/address'
|
|
21
|
-
import { recordDir } from '@/record
|
|
21
|
+
import { recordDir } from '@/roots/record'
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The content format. A page is a folder under the record root's `canvas/`, a
|
package/src/canvas/edit.ts
CHANGED
|
@@ -27,6 +27,10 @@ export const STYLE_PROPERTIES = [
|
|
|
27
27
|
'background-color',
|
|
28
28
|
'font-size',
|
|
29
29
|
'font-weight',
|
|
30
|
+
'font-family',
|
|
31
|
+
'line-height',
|
|
32
|
+
'letter-spacing',
|
|
33
|
+
'text-align',
|
|
30
34
|
'width',
|
|
31
35
|
'height',
|
|
32
36
|
'padding',
|
|
@@ -133,10 +137,13 @@ function checkChange(change: EditChange): EditRefused | undefined {
|
|
|
133
137
|
return undefined
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
const CHARACTER_REFERENCE = /&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/iy
|
|
141
|
+
|
|
136
142
|
/**
|
|
137
143
|
* Splits a style attribute at each semicolon outside parentheses and quotes,
|
|
138
144
|
* keeping every segment's own spacing so an untouched one is written back as
|
|
139
|
-
* it was.
|
|
145
|
+
* it was. The rewriter hands the attribute over undecoded and writes a quote
|
|
146
|
+
* back as `"`, so a character reference's own semicolon never splits.
|
|
140
147
|
*/
|
|
141
148
|
function declarations(style: string): string[] {
|
|
142
149
|
const segments: string[] = []
|
|
@@ -145,7 +152,10 @@ function declarations(style: string): string[] {
|
|
|
145
152
|
let start = 0
|
|
146
153
|
for (let at = 0; at < style.length; at += 1) {
|
|
147
154
|
const char = style[at]
|
|
148
|
-
|
|
155
|
+
CHARACTER_REFERENCE.lastIndex = at
|
|
156
|
+
if (char === '&' && CHARACTER_REFERENCE.test(style)) {
|
|
157
|
+
at = CHARACTER_REFERENCE.lastIndex - 1
|
|
158
|
+
} else if (quote) {
|
|
149
159
|
if (char === quote) quote = undefined
|
|
150
160
|
} else if (char === '"' || char === "'") quote = char
|
|
151
161
|
else if (char === '(') depth += 1
|
package/src/canvas/tokens.ts
CHANGED
package/src/claude/seeds.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative } from 'node:path'
|
|
4
4
|
import { copyPreservingMode } from '@/sync/copy'
|
|
5
|
-
import { creationRel, isRecordEntry } from '@/record
|
|
5
|
+
import { creationRel, isRecordEntry } from '@/roots/record'
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
|
|
7
|
-
import { SURFACE_ENTRIES, surfaceDir } from '@/surface
|
|
7
|
+
import { SURFACE_ENTRIES, surfaceDir } from '@/roots/surface'
|
|
8
8
|
|
|
9
9
|
const SEEDS_DIR = join('tooling', 'claude', 'seeds')
|
|
10
10
|
const CLAUDE_DIR = '.claude'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { resolveSkillsCorpus } from '@/claude/skills-list'
|
|
4
|
-
import { SURFACE_ROOTS } from '@/surface
|
|
4
|
+
import { SURFACE_ROOTS } from '@/roots/surface'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The authoring roots this repository owns and no install channel delivers.
|