@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.
Files changed (139) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-ready/SKILL.md +3 -1
  3. package/claude/skills/git-pr/references/evidence.md +1 -1
  4. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  5. package/claude/skills/review-address/SKILL.md +16 -7
  6. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  7. package/claude/skills/review-craft/references/ui.md +4 -4
  8. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  9. package/claude/skills/review-ui/SKILL.md +13 -11
  10. package/claude/skills/review-ui/references/driving.md +8 -8
  11. package/claude/skills/review-ui/references/post.md +13 -3
  12. package/claude/skills/review-ui/references/tick.md +22 -0
  13. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  14. package/claude/skills/role-orchestrator/SKILL.md +24 -26
  15. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  16. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
  17. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  20. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
  22. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  23. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  24. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  25. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  26. package/claude/skills/role-planner/SKILL.md +2 -2
  27. package/claude/skills/role-reviewer/SKILL.md +1 -1
  28. package/claude/skills/role-worker/SKILL.md +3 -2
  29. package/claude/skills/ui-checklist/SKILL.md +1 -1
  30. package/docs/agents/canvas.md +4 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +9 -8
  34. package/docs/agents/pr-tick.md +72 -0
  35. package/docs/agents/sandbox.md +2 -2
  36. package/docs/agents/tasks-plans.md +1 -1
  37. package/docs/agents/tasks-validate.md +18 -18
  38. package/docs/agents/tasks.md +1 -1
  39. package/docs/workflow/operating-model.md +10 -15
  40. package/docs/workflow/parallel-features.md +7 -7
  41. package/package.json +1 -1
  42. package/src/audits/baseline.ts +1 -1
  43. package/src/audits/run.ts +1 -1
  44. package/src/canvas/capture.ts +1 -1
  45. package/src/canvas/client/inspector/field.tsx +61 -2
  46. package/src/canvas/client/inspector/fields.css +78 -0
  47. package/src/canvas/client/inspector/token-select.tsx +109 -0
  48. package/src/canvas/client/inspector/values.ts +74 -2
  49. package/src/canvas/client/inspector.tsx +121 -11
  50. package/src/canvas/client/keys.ts +54 -0
  51. package/src/canvas/client/selection.tsx +3 -1
  52. package/src/canvas/client/shell.css +108 -2
  53. package/src/canvas/client/state.ts +96 -5
  54. package/src/canvas/client/surface.tsx +194 -17
  55. package/src/canvas/client/tools.tsx +64 -0
  56. package/src/canvas/content.ts +1 -1
  57. package/src/canvas/edit.ts +12 -2
  58. package/src/canvas/tokens.ts +1 -1
  59. package/src/claude/seeds.ts +2 -2
  60. package/src/claude/skills-reach.ts +1 -1
  61. package/src/commands/canvas.ts +1 -1
  62. package/src/commands/claude.ts +3 -3
  63. package/src/commands/design.ts +3 -3
  64. package/src/commands/docs.ts +1 -1
  65. package/src/commands/feedback.ts +2 -2
  66. package/src/commands/gate.ts +9 -2
  67. package/src/commands/gov.ts +2 -2
  68. package/src/commands/init.ts +1 -1
  69. package/src/commands/migrate.ts +1 -1
  70. package/src/commands/pr.ts +183 -7
  71. package/src/commands/sandbox.ts +10 -13
  72. package/src/commands/slides.ts +1 -1
  73. package/src/commands/sync.ts +1 -1
  74. package/src/commands/tasks.ts +19 -2
  75. package/src/commands/tooling.ts +1 -1
  76. package/src/commands/transcripts.ts +1 -1
  77. package/src/commands/upgrade.ts +1 -1
  78. package/src/commands/wiki.ts +1 -1
  79. package/src/context/architecture.ts +1 -1
  80. package/src/context/citations.ts +2 -2
  81. package/src/context/classify/extract.ts +1 -1
  82. package/src/context/folders.ts +2 -2
  83. package/src/context/requirements.ts +1 -1
  84. package/src/docs/read.ts +1 -1
  85. package/src/exec.ts +1 -1
  86. package/src/feedback/github.ts +1 -1
  87. package/src/gate/measures.ts +1 -1
  88. package/src/gate/sequencer.ts +55 -3
  89. package/src/gate/stages.ts +8 -0
  90. package/src/gate/suite-lock.ts +214 -0
  91. package/src/intake/folder.ts +1 -1
  92. package/src/labels/map.ts +1 -1
  93. package/src/labels/phase.ts +3 -3
  94. package/src/migrate/record-layout.ts +1 -1
  95. package/src/migrate/records.ts +11 -11
  96. package/src/migrate/scratch-evidence.ts +1 -1
  97. package/src/migrate/surface-roots.ts +6 -6
  98. package/src/pr/evidence.ts +154 -16
  99. package/src/pr/tick.ts +56 -0
  100. package/src/process/harness.ts +1 -1
  101. package/src/records/backup.ts +1 -1
  102. package/src/records/ordinal.ts +1 -1
  103. package/src/records/prune.ts +1 -1
  104. package/src/records/size.ts +1 -1
  105. package/src/records/stale-canonical.ts +1 -1
  106. package/src/records/stale.ts +1 -1
  107. package/src/records/validate.ts +2 -2
  108. package/src/{project-root.ts → roots/project.ts} +1 -1
  109. package/src/{record-root.ts → roots/record.ts} +1 -1
  110. package/src/sandbox/coverage.ts +11 -12
  111. package/src/sandbox/equivalence.ts +57 -26
  112. package/src/sandbox/hooks.ts +1 -1
  113. package/src/sandbox/provision.ts +78 -28
  114. package/src/sandbox/scenario.ts +588 -0
  115. package/src/sessions/live.ts +4 -1
  116. package/src/sessions/transfer/bundle.ts +1 -1
  117. package/src/slides/decks.ts +1 -1
  118. package/src/standards/read.ts +1 -1
  119. package/src/sync/engine.ts +1 -1
  120. package/src/sync/layout.ts +2 -2
  121. package/src/sync/stamp.ts +1 -1
  122. package/src/targets/validate.ts +1 -1
  123. package/src/tasks/answers.ts +1 -1
  124. package/src/tasks/archive.ts +1 -1
  125. package/src/tasks/label.ts +1 -1
  126. package/src/tasks/reach.ts +1 -1
  127. package/src/tasks/validate.ts +38 -14
  128. package/src/teach/nav.ts +1 -1
  129. package/src/teach/workspace.ts +1 -1
  130. package/src/tooling/inject.ts +1 -1
  131. package/src/tooling/read.ts +1 -1
  132. package/src/tooling/scan.ts +1 -1
  133. package/src/version/installed.ts +1 -1
  134. package/src/version/skew.ts +1 -1
  135. package/standards/board.md +3 -3
  136. package/standards/plan.md +13 -7
  137. package/tsconfig.json +1 -1
  138. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  139. /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
- /** Fits every frame on the current page into a viewport of the given size. */
491
- export function fitView(width: number, height: number, padding = 48): void {
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' || event.key === ' ') {
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
- onClick={() => zoomCentered(1 / ZOOM_STEP)}
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
- onClick={() => zoomCentered(ZOOM_STEP)}
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
- onClick={() => {
494
- const { width, height } = viewportSize(viewportRef)
495
- fitView(width, height)
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
- const { width, height } = viewportSize(viewportRef)
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, .toolbar')) return
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
- panBy(event.clientX - start.x, event.clientY - start.y)
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 class="surface" aria-label="Canvas">
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
+ }
@@ -18,7 +18,7 @@ import {
18
18
  resolveAddress,
19
19
  type SourceElement,
20
20
  } from '@/canvas/address'
21
- import { recordDir } from '@/record-root'
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
@@ -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 `&quot;`, 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
- if (quote) {
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
@@ -13,7 +13,7 @@ import {
13
13
  namedFamilies,
14
14
  projectSheets,
15
15
  } from '@/design/project-fonts'
16
- import { isOwnCheckout } from '@/project-root'
16
+ import { isOwnCheckout } from '@/roots/project'
17
17
 
18
18
  /**
19
19
  * The stylesheet injected into every frame, so a frame drawn with
@@ -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-root'
5
+ import { creationRel, isRecordEntry } from '@/roots/record'
6
6
  import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
- import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
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-root'
4
+ import { SURFACE_ROOTS } from '@/roots/surface'
5
5
 
6
6
  /**
7
7
  * The authoring roots this repository owns and no install channel delivers.