@erclx/canon 5.27.0 → 5.29.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 (177) 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 +19 -7
  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 +12 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +15 -14
  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/app.tsx +105 -4
  46. package/src/canvas/client/inspector/field.tsx +61 -2
  47. package/src/canvas/client/inspector/fields.css +78 -0
  48. package/src/canvas/client/inspector/token-select.tsx +109 -0
  49. package/src/canvas/client/inspector/values.ts +74 -2
  50. package/src/canvas/client/inspector.tsx +134 -15
  51. package/src/canvas/client/keys.ts +71 -0
  52. package/src/canvas/client/pages-panel.tsx +5 -1
  53. package/src/canvas/client/panel-handle.tsx +90 -0
  54. package/src/canvas/client/selection.tsx +3 -1
  55. package/src/canvas/client/shell.css +194 -4
  56. package/src/canvas/client/state.ts +255 -11
  57. package/src/canvas/client/surface.tsx +231 -18
  58. package/src/canvas/client/tools.tsx +153 -0
  59. package/src/canvas/content.ts +66 -1
  60. package/src/canvas/edit.ts +207 -25
  61. package/src/canvas/history.ts +116 -0
  62. package/src/canvas/server.ts +119 -13
  63. package/src/canvas/tokens.ts +1 -1
  64. package/src/claude/seeds.ts +2 -2
  65. package/src/claude/skills-reach.ts +1 -1
  66. package/src/{exec.ts → cli/exec.ts} +2 -2
  67. package/src/{help.ts → cli/help.ts} +1 -1
  68. package/src/cli.ts +1 -1
  69. package/src/commands/audits.ts +9 -1
  70. package/src/commands/autoship.ts +1 -1
  71. package/src/commands/canvas.ts +2 -2
  72. package/src/commands/capture.ts +1 -1
  73. package/src/commands/census.ts +1 -1
  74. package/src/commands/claude.ts +5 -5
  75. package/src/commands/comments.ts +1 -1
  76. package/src/commands/context.ts +1 -1
  77. package/src/commands/demo.ts +9 -1
  78. package/src/commands/deps.ts +1 -1
  79. package/src/commands/design.ts +12 -4
  80. package/src/commands/docs.ts +2 -2
  81. package/src/commands/driver.ts +9 -1
  82. package/src/commands/feedback.ts +3 -3
  83. package/src/commands/gate.ts +10 -3
  84. package/src/commands/gov.ts +3 -3
  85. package/src/commands/indexes.ts +1 -1
  86. package/src/commands/init.ts +11 -3
  87. package/src/commands/intake.ts +1 -1
  88. package/src/commands/inventory.ts +9 -1
  89. package/src/commands/labels.ts +1 -1
  90. package/src/commands/markdown.ts +1 -1
  91. package/src/commands/migrate.ts +2 -2
  92. package/src/commands/pr.ts +324 -9
  93. package/src/commands/records.ts +1 -1
  94. package/src/commands/repo.ts +1 -1
  95. package/src/commands/sandbox.ts +11 -14
  96. package/src/commands/secrets.ts +9 -1
  97. package/src/commands/sessions.ts +1 -1
  98. package/src/commands/slides.ts +2 -2
  99. package/src/commands/standards.ts +1 -1
  100. package/src/commands/sync.ts +3 -3
  101. package/src/commands/targets.ts +1 -1
  102. package/src/commands/tasks.ts +20 -3
  103. package/src/commands/teach.ts +1 -1
  104. package/src/commands/tooling.ts +2 -2
  105. package/src/commands/transcripts.ts +2 -2
  106. package/src/commands/upgrade.ts +2 -2
  107. package/src/commands/wiki.ts +2 -2
  108. package/src/commands/worktrees.ts +1 -1
  109. package/src/context/architecture.ts +1 -1
  110. package/src/context/citations.ts +2 -2
  111. package/src/context/classify/extract.ts +1 -1
  112. package/src/context/folders.ts +2 -2
  113. package/src/context/requirements.ts +1 -1
  114. package/src/docs/read.ts +1 -1
  115. package/src/feedback/github.ts +1 -1
  116. package/src/gate/measures.ts +2 -2
  117. package/src/gate/sequencer.ts +55 -3
  118. package/src/gate/stages.ts +8 -0
  119. package/src/gate/suite-lock.ts +214 -0
  120. package/src/init/run.ts +1 -1
  121. package/src/intake/folder.ts +1 -1
  122. package/src/labels/map.ts +1 -1
  123. package/src/labels/phase.ts +3 -3
  124. package/src/migrate/record-layout.ts +1 -1
  125. package/src/migrate/records.ts +11 -11
  126. package/src/migrate/rename.ts +1 -1
  127. package/src/migrate/scratch-evidence.ts +1 -1
  128. package/src/migrate/surface-roots.ts +6 -6
  129. package/src/pr/evidence.ts +188 -16
  130. package/src/pr/tick.ts +56 -0
  131. package/src/process/harness.ts +1 -1
  132. package/src/records/backup.ts +1 -1
  133. package/src/records/ordinal.ts +1 -1
  134. package/src/records/prune.ts +1 -1
  135. package/src/records/size.ts +1 -1
  136. package/src/records/stale-canonical.ts +1 -1
  137. package/src/records/stale.ts +1 -1
  138. package/src/records/validate.ts +2 -2
  139. package/src/{project-root.ts → roots/project.ts} +2 -2
  140. package/src/{record-root.ts → roots/record.ts} +1 -1
  141. package/src/sandbox/coverage.ts +11 -12
  142. package/src/sandbox/equivalence.ts +57 -26
  143. package/src/sandbox/headless/run.ts +1 -1
  144. package/src/sandbox/hooks.ts +1 -1
  145. package/src/sandbox/provision.ts +79 -29
  146. package/src/sandbox/scenario.ts +588 -0
  147. package/src/serve/report.ts +1 -1
  148. package/src/sessions/live.ts +4 -1
  149. package/src/sessions/transfer/bundle.ts +1 -1
  150. package/src/slides/decks.ts +1 -1
  151. package/src/standards/read.ts +1 -1
  152. package/src/sync/engine.ts +2 -2
  153. package/src/sync/git.ts +1 -1
  154. package/src/sync/layout.ts +2 -2
  155. package/src/sync/stamp.ts +1 -1
  156. package/src/sync/workflow.ts +1 -1
  157. package/src/targets/validate.ts +2 -2
  158. package/src/tasks/answers.ts +1 -1
  159. package/src/tasks/archive.ts +1 -1
  160. package/src/tasks/label.ts +1 -1
  161. package/src/tasks/reach.ts +1 -1
  162. package/src/tasks/validate.ts +38 -14
  163. package/src/teach/nav.ts +1 -1
  164. package/src/teach/workspace.ts +1 -1
  165. package/src/tooling/inject.ts +2 -2
  166. package/src/tooling/read.ts +1 -1
  167. package/src/tooling/scan.ts +1 -1
  168. package/src/tooling/verify-stack.ts +1 -1
  169. package/src/version/installed.ts +1 -1
  170. package/src/version/skew.ts +1 -1
  171. package/standards/board.md +3 -3
  172. package/standards/plan.md +13 -7
  173. package/tsconfig.json +1 -1
  174. /package/src/{cli-run.ts → cli/run.ts} +0 -0
  175. /package/src/{ui.ts → cli/ui.ts} +0 -0
  176. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  177. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
@@ -1,30 +1,41 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX, RefObject } from 'preact'
3
- import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import { useEffect, useLayoutEffect, 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,
12
15
  frameKey,
13
16
  frameTheme,
14
17
  frameVersions,
18
+ historyNotice,
15
19
  hoveredElement,
16
20
  moveFrameTo,
17
21
  panBy,
22
+ panelsHidden,
23
+ panelWidths,
18
24
  previewMove,
19
25
  previewResize,
26
+ redo,
20
27
  registerFrameDocument,
21
28
  resizeElement,
22
29
  resizeFrameTo,
23
30
  selectElement,
24
31
  selectFrame,
25
32
  selection,
33
+ shownTool,
34
+ spacePan,
26
35
  type Theme,
27
36
  toggleFrameTheme,
37
+ togglePanels,
38
+ undo,
28
39
  view,
29
40
  zoomAt,
30
41
  } from '@/canvas/client/state'
@@ -34,6 +45,7 @@ import {
34
45
  type OverlayBox,
35
46
  SelectionOverlay,
36
47
  } from '@/canvas/client/selection'
48
+ import { ToolStrip } from '@/canvas/client/tools'
37
49
 
38
50
  /** One wheel notch or one button press. */
39
51
  const ZOOM_STEP = 1.2
@@ -82,6 +94,37 @@ function asElement(target: EventTarget | null): Element | undefined {
82
94
  return node?.nodeType === 1 ? (node as Element) : undefined
83
95
  }
84
96
 
97
+ /**
98
+ * Whether a key belongs to the element it was typed in rather than to the
99
+ * canvas: any key in a field, and Space on a control it activates. A frame's
100
+ * draft can hold its own fields, and their keys arrive through the forwarding.
101
+ */
102
+ function isOwnedByTarget(event: KeyboardEvent, action: SurfaceAction): boolean {
103
+ const target = asElement(event.target)
104
+ if (!target) return false
105
+ if ((target as HTMLElement).isContentEditable) return true
106
+ if (target.closest('input, textarea, select')) return true
107
+ return (
108
+ action === 'pan-hold' && target.closest('button, a[href], summary') !== null
109
+ )
110
+ }
111
+
112
+ /**
113
+ * A press read as a second click selects a word or a whole frame even while
114
+ * panning, so no selection may start in the shell or a frame then.
115
+ */
116
+ function blockSelectionWhilePanning(event: Event): void {
117
+ if (shownTool.value === 'pan') event.preventDefault()
118
+ }
119
+
120
+ /** Drops any selection in the shell and in every loaded frame document. */
121
+ function clearSelections(): void {
122
+ document.getSelection()?.removeAllRanges()
123
+ for (const doc of frameDocuments.value.values()) {
124
+ doc.getSelection()?.removeAllRanges()
125
+ }
126
+ }
127
+
85
128
  /** A computed length in pixels, or the fallback for one that is not. */
86
129
  function pixels(value: string, fallback: number): number {
87
130
  const parsed = Number.parseFloat(value)
@@ -211,10 +254,12 @@ function FrameView({
211
254
  page,
212
255
  frame,
213
256
  onWheelTurn,
257
+ onKey,
214
258
  }: {
215
259
  readonly page: string
216
260
  readonly frame: Frame
217
261
  readonly onWheelTurn: (turn: WheelTurn) => void
262
+ readonly onKey: (event: KeyboardEvent) => void
218
263
  }): JSX.Element {
219
264
  const key = frameKey(page, frame)
220
265
  const version = frameVersions.value.get(key) ?? 0
@@ -227,6 +272,7 @@ function FrameView({
227
272
  /* Bumped when the frame document scrolls, so the outlines follow it. */
228
273
  const [, setScrolled] = useState(0)
229
274
  const doc = frameDocuments.value.get(key)
275
+ const editing = editingFrames.value.get(key)
230
276
 
231
277
  useEffect(() => {
232
278
  applyFrameTheme(iframe.current, value)
@@ -277,6 +323,16 @@ function FrameView({
277
323
  },
278
324
  { passive: false },
279
325
  )
326
+ /*
327
+ * A click inside the frame moves focus into its window, so the surface
328
+ * hears no key after the first pick unless the frame passes it on.
329
+ */
330
+ loaded.addEventListener('keydown', onKey)
331
+ loaded.addEventListener('keyup', onKey)
332
+ loaded.addEventListener('selectstart', blockSelectionWhilePanning)
333
+ loaded.defaultView?.addEventListener('blur', () => {
334
+ spacePan.value = undefined
335
+ })
280
336
  }
281
337
 
282
338
  const picked =
@@ -298,6 +354,8 @@ function FrameView({
298
354
 
299
355
  const handlePointerDown = (event: PointerEvent) => {
300
356
  if (event.button !== 0) return
357
+ /* A pan drag starting on the label belongs to the surface behind it. */
358
+ if (shownTool.value === 'pan') return
301
359
  if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
302
360
  gesture.current = {
303
361
  startX: event.clientX,
@@ -346,7 +404,7 @@ function FrameView({
346
404
  */
347
405
  const handleKeyDown = (event: KeyboardEvent) => {
348
406
  if (event.target !== event.currentTarget) return
349
- if (event.key === 'Enter' || event.key === ' ') {
407
+ if (event.key === 'Enter') {
350
408
  event.preventDefault()
351
409
  select()
352
410
  return
@@ -368,6 +426,16 @@ function FrameView({
368
426
  void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
369
427
  }
370
428
 
429
+ /*
430
+ * Space held pans, so it selects on release instead, and only when no drag
431
+ * moved the view while it was down. This runs before the surface's own
432
+ * release clears the pan.
433
+ */
434
+ const handleKeyUp = (event: KeyboardEvent) => {
435
+ if (event.target !== event.currentTarget || event.key !== ' ') return
436
+ if (spacePan.value !== 'dragged') select()
437
+ }
438
+
371
439
  const frameBox = (): OverlayBox => ({
372
440
  x: frame.x,
373
441
  y: frame.y,
@@ -383,10 +451,12 @@ function FrameView({
383
451
  data-selected={isSelected ? 'true' : undefined}
384
452
  data-picked={picked ? 'true' : undefined}
385
453
  data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
454
+ data-editing={editing ? 'true' : undefined}
386
455
  tabIndex={0}
387
- aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
456
+ aria-label={`${frame.name}, ${frame.width} by ${frame.height}${editing ? `, ${editing.by} editing` : ''}`}
388
457
  aria-current={isSelected ? 'true' : undefined}
389
458
  onKeyDown={handleKeyDown}
459
+ onKeyUp={handleKeyUp}
390
460
  style={{
391
461
  left: `${frame.x}px`,
392
462
  top: `${frame.y}px`,
@@ -407,6 +477,10 @@ function FrameView({
407
477
  <span class="frame-size">
408
478
  {frame.width} × {frame.height}
409
479
  </span>
480
+ {/* Rendered empty while unmarked, so a mark landing is announced. */}
481
+ <span class="frame-editing" aria-live="polite">
482
+ {editing ? `${editing.by} editing` : ''}
483
+ </span>
410
484
  <button
411
485
  type="button"
412
486
  class="frame-theme"
@@ -461,18 +535,35 @@ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
461
535
  return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
462
536
  }
463
537
 
538
+ /** The zoom toolbar's actions, which its keys call as well. */
539
+ function zoomCentered(
540
+ viewport: RefObject<HTMLDivElement | null>,
541
+ factor: number,
542
+ ): void {
543
+ const { width, height } = viewportSize(viewport)
544
+ zoomAt(factor, width / 2, height / 2)
545
+ }
546
+
547
+ /** Fits the page clear of the tool strip floating over the viewport's left. */
548
+ function fitViewport(viewport: RefObject<HTMLDivElement | null>): void {
549
+ const rect = viewport.current?.getBoundingClientRect()
550
+ const strip = viewport.current?.parentElement
551
+ ?.querySelector('.tools')
552
+ ?.getBoundingClientRect()
553
+ const inset = rect && strip ? Math.max(0, strip.right - rect.left) : 0
554
+ fitView(rect?.width ?? 0, rect?.height ?? 0, undefined, inset)
555
+ }
556
+
464
557
  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
558
  return (
470
559
  <div class="toolbar" role="toolbar" aria-label="Zoom">
471
560
  <button
472
561
  type="button"
473
562
  class="icon-button"
474
563
  aria-label="Zoom out"
475
- onClick={() => zoomCentered(1 / ZOOM_STEP)}
564
+ aria-keyshortcuts="-"
565
+ title="Zoom out (-)"
566
+ onClick={() => zoomCentered(viewportRef, 1 / ZOOM_STEP)}
476
567
  >
477
568
  −
478
569
  </button>
@@ -483,17 +574,18 @@ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
483
574
  type="button"
484
575
  class="icon-button"
485
576
  aria-label="Zoom in"
486
- onClick={() => zoomCentered(ZOOM_STEP)}
577
+ aria-keyshortcuts="+"
578
+ title="Zoom in (+)"
579
+ onClick={() => zoomCentered(viewportRef, ZOOM_STEP)}
487
580
  >
488
581
  +
489
582
  </button>
490
583
  <button
491
584
  type="button"
492
585
  class="text-button"
493
- onClick={() => {
494
- const { width, height } = viewportSize(viewportRef)
495
- fitView(width, height)
496
- }}
586
+ aria-keyshortcuts="Shift+1"
587
+ title="Fit (Shift+1)"
588
+ onClick={() => fitViewport(viewportRef)}
497
589
  >
498
590
  Fit
499
591
  </button>
@@ -507,10 +599,25 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
507
599
 
508
600
  /* Fits a page once when it opens, never on a reload of the same page. */
509
601
  useEffect(() => {
510
- const { width, height } = viewportSize(viewportRef)
511
- fitView(width, height)
602
+ fitViewport(viewportRef)
512
603
  }, [page?.name])
513
604
 
605
+ /*
606
+ * A panel hiding, showing, or resizing moves the viewport's left edge, so
607
+ * the view shifts by as much the other way and the frames hold still on
608
+ * screen. Reading the flag subscribes this component to it.
609
+ */
610
+ const viewportLeft = useRef<number | undefined>(undefined)
611
+ const isHidden = panelsHidden.value
612
+ const leftWidth = panelWidths.value.left
613
+ useLayoutEffect(() => {
614
+ const left = viewportRef.current?.getBoundingClientRect().left
615
+ if (left === undefined) return
616
+ const before = viewportLeft.current
617
+ viewportLeft.current = left
618
+ if (before !== undefined && before !== left) panBy(before - left, 0)
619
+ }, [isHidden, leftWidth])
620
+
514
621
  const handleWheelTurn = (turn: WheelTurn) => {
515
622
  if (turn.isZoom) {
516
623
  const rect = viewportRef.current?.getBoundingClientRect()
@@ -536,9 +643,77 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
536
643
  })
537
644
  }
538
645
 
646
+ /* A pan held when the window loses focus never hears its Space come up. */
647
+ useEffect(() => {
648
+ const endPan = () => {
649
+ spacePan.value = undefined
650
+ }
651
+ window.addEventListener('blur', endPan)
652
+ document.addEventListener('selectstart', blockSelectionWhilePanning)
653
+ return () => {
654
+ window.removeEventListener('blur', endPan)
655
+ document.removeEventListener('selectstart', blockSelectionWhilePanning)
656
+ }
657
+ }, [])
658
+
659
+ const runAction = (action: SurfaceAction) => {
660
+ switch (action) {
661
+ case 'tool-move':
662
+ activeTool.value = 'move'
663
+ return
664
+ case 'tool-pan':
665
+ activeTool.value = 'pan'
666
+ return
667
+ case 'zoom-in':
668
+ zoomCentered(viewportRef, ZOOM_STEP)
669
+ return
670
+ case 'zoom-out':
671
+ zoomCentered(viewportRef, 1 / ZOOM_STEP)
672
+ return
673
+ case 'zoom-fit':
674
+ fitViewport(viewportRef)
675
+ return
676
+ case 'pan-hold':
677
+ spacePan.value ??= 'held'
678
+ return
679
+ case 'pan-release':
680
+ spacePan.value = undefined
681
+ return
682
+ case 'undo':
683
+ void undo()
684
+ return
685
+ case 'redo':
686
+ void redo()
687
+ return
688
+ case 'panels-toggle':
689
+ togglePanels()
690
+ }
691
+ }
692
+
693
+ /*
694
+ * The surface's one key handler, which every frame document feeds too. The
695
+ * default is prevented only for a key press it takes, so Space stops
696
+ * scrolling a frame it pans and every other key keeps its own meaning. A
697
+ * release always ends the pan and never claims the keyup, since a button
698
+ * fires its Space activation there.
699
+ */
700
+ const handleKey = (event: KeyboardEvent) => {
701
+ const action = surfaceAction(event)
702
+ if (!action) return
703
+ if (action === 'pan-release') {
704
+ runAction(action)
705
+ return
706
+ }
707
+ if (isOwnedByTarget(event, action)) return
708
+ event.preventDefault()
709
+ runAction(action)
710
+ }
711
+
539
712
  const handlePointerDown = (event: PointerEvent) => {
540
713
  const target = event.target as HTMLElement | null
541
- if (target?.closest('.frame, .toolbar')) return
714
+ if (shownTool.value !== 'pan' && target?.closest('.frame')) return
715
+ /* A selection left over would paint over every frame the pan crosses. */
716
+ if (shownTool.value === 'pan') clearSelections()
542
717
  drag.current = { x: event.clientX, y: event.clientY }
543
718
  viewportRef.current?.setPointerCapture?.(event.pointerId)
544
719
  }
@@ -546,22 +721,53 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
546
721
  const handlePointerMove = (event: PointerEvent) => {
547
722
  const start = drag.current
548
723
  if (!start) return
549
- panBy(event.clientX - start.x, event.clientY - start.y)
724
+ const dx = event.clientX - start.x
725
+ const dy = event.clientY - start.y
726
+ panBy(dx, dy)
550
727
  drag.current = { x: event.clientX, y: event.clientY }
728
+ if (spacePan.value === 'held' && (dx !== 0 || dy !== 0)) {
729
+ spacePan.value = 'dragged'
730
+ }
731
+ }
732
+
733
+ /*
734
+ * A press selects text by default, a word or a whole frame when it reads as
735
+ * a second click, and neither `selectstart` nor a clear at pointer down
736
+ * reaches that. Under a held Space, focus stays where it was so the frame
737
+ * still hears the release. Under the Pan tool the press takes focus itself,
738
+ * as the default would have, so a field it leaves commits and the next key
739
+ * reaches the layer.
740
+ */
741
+ const handleMouseDown = (event: MouseEvent) => {
742
+ if (shownTool.value !== 'pan') return
743
+ event.preventDefault()
744
+ if (!spacePan.value) {
745
+ const surface = (event.currentTarget as HTMLElement).closest('main')
746
+ surface?.focus({ preventScroll: true })
747
+ }
551
748
  }
552
749
 
553
750
  const handlePointerUp = () => {
751
+ if (drag.current && shownTool.value === 'pan') clearSelections()
554
752
  drag.current = undefined
555
753
  }
556
754
 
557
755
  const { x, y, zoom } = view.value
558
756
  return (
559
- <main class="surface" aria-label="Canvas">
757
+ <main
758
+ class="surface"
759
+ aria-label="Canvas"
760
+ tabIndex={0}
761
+ data-tool={shownTool.value}
762
+ onKeyDown={handleKey}
763
+ onKeyUp={handleKey}
764
+ >
560
765
  <div
561
766
  ref={viewportRef}
562
767
  class="viewport"
563
768
  onWheel={handleWheel}
564
769
  onPointerDown={handlePointerDown}
770
+ onMouseDown={handleMouseDown}
565
771
  onPointerMove={handlePointerMove}
566
772
  onPointerUp={handlePointerUp}
567
773
  onPointerCancel={handlePointerUp}
@@ -580,11 +786,18 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
580
786
  page={page.name}
581
787
  frame={frame}
582
788
  onWheelTurn={handleWheelTurn}
789
+ onKey={handleKey}
583
790
  />
584
791
  ))}
585
792
  </div>
586
793
  </div>
794
+ <ToolStrip />
587
795
  <Toolbar viewportRef={viewportRef} />
796
+ {historyNotice.value ? (
797
+ <p class="history-notice" role="status">
798
+ {historyNotice.value}
799
+ </p>
800
+ ) : null}
588
801
  </main>
589
802
  )
590
803
  }
@@ -0,0 +1,153 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import {
4
+ activeTool,
5
+ history,
6
+ panelsHidden,
7
+ redo,
8
+ shownTool,
9
+ type Tool,
10
+ togglePanels,
11
+ undo,
12
+ } from '@/canvas/client/state'
13
+
14
+ interface ToolButton {
15
+ readonly tool: Tool
16
+ readonly name: string
17
+ readonly key: string
18
+ readonly icon: JSX.Element
19
+ }
20
+
21
+ const TOOLS: readonly ToolButton[] = [
22
+ {
23
+ tool: 'move',
24
+ name: 'Move',
25
+ key: 'V',
26
+ icon: (
27
+ <svg viewBox="0 0 16 16" aria-hidden="true">
28
+ <path d="M3.5 2.5l9 4.2-3.9 1.2-1.4 3.8z" />
29
+ </svg>
30
+ ),
31
+ },
32
+ {
33
+ tool: 'pan',
34
+ name: 'Pan',
35
+ key: 'H',
36
+ icon: (
37
+ <svg viewBox="0 0 16 16" aria-hidden="true">
38
+ <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" />
39
+ </svg>
40
+ ),
41
+ },
42
+ ]
43
+
44
+ interface HistoryButton {
45
+ readonly name: 'Undo' | 'Redo'
46
+ readonly shortcut: string
47
+ readonly keys: string
48
+ readonly run: () => Promise<void>
49
+ readonly icon: JSX.Element
50
+ }
51
+
52
+ /** The modifier the platform names for undo, Cmd on macOS and Ctrl elsewhere. */
53
+ const MOD = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? '')
54
+ ? 'Cmd'
55
+ : 'Ctrl'
56
+
57
+ const HISTORY_BUTTONS: readonly HistoryButton[] = [
58
+ {
59
+ name: 'Undo',
60
+ shortcut: `${MOD}+Z`,
61
+ keys: 'Control+Z Meta+Z',
62
+ run: () => undo(),
63
+ icon: (
64
+ <svg viewBox="0 0 16 16" aria-hidden="true">
65
+ <path d="M5.5 3.5L2.5 6.5l3 3M2.5 6.5h7a4 4 0 0 1 0 8H7" />
66
+ </svg>
67
+ ),
68
+ },
69
+ {
70
+ name: 'Redo',
71
+ shortcut: `${MOD}+Shift+Z`,
72
+ keys: 'Control+Shift+Z Meta+Shift+Z Control+Y',
73
+ run: () => redo(),
74
+ icon: (
75
+ <svg viewBox="0 0 16 16" aria-hidden="true">
76
+ <path d="M10.5 3.5l3 3-3 3M13.5 6.5h-7a4 4 0 0 0 0 8H9" />
77
+ </svg>
78
+ ),
79
+ },
80
+ ]
81
+
82
+ /**
83
+ * The tool strip floating at the surface's left edge, one button a tool, then
84
+ * undo and redo, each disabled while the server holds nothing to step.
85
+ */
86
+ export function ToolStrip(): JSX.Element {
87
+ const shown = shownTool.value
88
+ const { canUndo, canRedo } = history.value
89
+ return (
90
+ <div
91
+ class="tools"
92
+ role="toolbar"
93
+ aria-label="Tools"
94
+ aria-orientation="vertical"
95
+ >
96
+ {TOOLS.map(({ tool, name, key, icon }) => (
97
+ <button
98
+ key={tool}
99
+ type="button"
100
+ class="tool-button"
101
+ aria-label={`${name} (${key})`}
102
+ aria-keyshortcuts={key}
103
+ aria-pressed={shown === tool ? 'true' : 'false'}
104
+ title={`${name} (${key})`}
105
+ data-tool={tool}
106
+ onClick={() => {
107
+ activeTool.value = tool
108
+ }}
109
+ >
110
+ {icon}
111
+ </button>
112
+ ))}
113
+ <span class="tools-divider" aria-hidden="true" />
114
+ {HISTORY_BUTTONS.map(({ name, shortcut, keys, run, icon }) => (
115
+ <button
116
+ key={name}
117
+ type="button"
118
+ class="tool-button"
119
+ aria-label={`${name} (${shortcut})`}
120
+ aria-keyshortcuts={keys}
121
+ title={`${name} (${shortcut})`}
122
+ disabled={name === 'Undo' ? !canUndo : !canRedo}
123
+ onClick={() => void run()}
124
+ >
125
+ {icon}
126
+ </button>
127
+ ))}
128
+ <span class="tools-divider" aria-hidden="true" />
129
+ <PanelsButton />
130
+ </div>
131
+ )
132
+ }
133
+
134
+ /** Named by what a press does now, so the panels come back by mouse too. */
135
+ function PanelsButton(): JSX.Element {
136
+ const name = panelsHidden.value ? 'Show panels' : 'Hide panels'
137
+ return (
138
+ <button
139
+ type="button"
140
+ class="tool-button"
141
+ aria-label={`${name} (\\)`}
142
+ aria-keyshortcuts="\\"
143
+ title={`${name} (\\)`}
144
+ data-panels={panelsHidden.value ? 'hidden' : 'shown'}
145
+ onClick={togglePanels}
146
+ >
147
+ <svg viewBox="0 0 16 16" aria-hidden="true">
148
+ <rect x="2" y="3" width="12" height="10" rx="1.5" />
149
+ {panelsHidden.value ? null : <path d="M5.5 3v10M10.5 3v10" />}
150
+ </svg>
151
+ </button>
152
+ )
153
+ }
@@ -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
@@ -419,6 +419,12 @@ export interface Position {
419
419
  readonly y: number
420
420
  }
421
421
 
422
+ /**
423
+ * Hears the box a write replaced, undefined when the layout held none, and the
424
+ * box it wrote, apart from the returned record, which the CLI prints.
425
+ */
426
+ export type BoxObserver = (replaced: Box | undefined, written: Box) => void
427
+
422
428
  /**
423
429
  * Moves one frame and keeps its size. A frame the layout never named takes the
424
430
  * default size it was already drawn at, so the move does not resize it.
@@ -428,6 +434,7 @@ export function moveFrame(
428
434
  page: string,
429
435
  frame: string,
430
436
  to: Position,
437
+ observe?: BoxObserver,
431
438
  ): FrameOutcome {
432
439
  if (!isValidName(page)) {
433
440
  return refuse('invalid-name', `${page} is not a valid page name`)
@@ -460,6 +467,7 @@ export function moveFrame(
460
467
  }
461
468
 
462
469
  writeLayout(dir, new Map([...boxes, [frame, box]]))
470
+ observe?.(boxes.get(frame), box)
463
471
  return { ok: true, page, frame, file, box }
464
472
  })
465
473
  }
@@ -474,6 +482,7 @@ export function resizeFrame(
474
482
  page: string,
475
483
  frame: string,
476
484
  box: Box,
485
+ observe?: BoxObserver,
477
486
  ): FrameOutcome {
478
487
  if (!isValidName(page)) {
479
488
  return refuse('invalid-name', `${page} is not a valid page name`)
@@ -509,10 +518,66 @@ export function resizeFrame(
509
518
  }
510
519
 
511
520
  writeLayout(dir, new Map([...boxes, [frame, written]]))
521
+ observe?.(boxes.get(frame), written)
512
522
  return { ok: true, page, frame, file, box: written }
513
523
  })
514
524
  }
515
525
 
526
+ function isSameBox(a: Box | undefined, b: Box | undefined): boolean {
527
+ if (a === undefined || b === undefined) return a === b
528
+ return (
529
+ a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
530
+ )
531
+ }
532
+
533
+ export type BoxRestore =
534
+ | { readonly ok: true }
535
+ | ContentRefused
536
+ | {
537
+ readonly ok: false
538
+ readonly reason: 'changed'
539
+ readonly detail: string
540
+ }
541
+
542
+ /**
543
+ * Writes a recorded box back under the layout lock, only while the layout
544
+ * still holds the box the record expects, where undefined expects none.
545
+ * Restoring to no box drops the frame's entry, so the reader places it in the
546
+ * default row again.
547
+ */
548
+ export function restoreBox(
549
+ root: string,
550
+ page: string,
551
+ frame: string,
552
+ expected: Box | undefined,
553
+ to: Box | undefined,
554
+ ): BoxRestore {
555
+ const dir = pagePath(root, page)
556
+ if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
557
+ if (!existsSync(join(dir, `${frame}${FRAME_EXTENSION}`))) {
558
+ return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
559
+ }
560
+
561
+ return withFileLock(join(dir, LAYOUT_FILE), (): BoxRestore => {
562
+ const { boxes, malformed } = readLayout(dir)
563
+ if (malformed) {
564
+ return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
565
+ }
566
+ if (!isSameBox(boxes.get(frame), expected)) {
567
+ return {
568
+ ok: false,
569
+ reason: 'changed',
570
+ detail: `${page}/${frame} moved since this change, so nothing was written`,
571
+ }
572
+ }
573
+ const next = new Map(boxes)
574
+ if (to === undefined) next.delete(frame)
575
+ else next.set(frame, to)
576
+ writeLayout(dir, next)
577
+ return { ok: true }
578
+ })
579
+ }
580
+
516
581
  export interface Selection {
517
582
  readonly page: string
518
583
  readonly frame: string