@erclx/canon 5.28.0 → 5.30.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 (81) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/review-ui/references/post.md +7 -5
  3. package/docs/agents/canvas.md +8 -0
  4. package/docs/agents/pr-evidence.md +7 -7
  5. package/package.json +1 -1
  6. package/src/canvas/client/app.tsx +105 -4
  7. package/src/canvas/client/inspector.tsx +13 -4
  8. package/src/canvas/client/keys.ts +23 -6
  9. package/src/canvas/client/main.tsx +9 -0
  10. package/src/canvas/client/pages-panel.tsx +5 -1
  11. package/src/canvas/client/panel-handle.tsx +90 -0
  12. package/src/canvas/client/shell.css +86 -2
  13. package/src/canvas/client/state.ts +159 -6
  14. package/src/canvas/client/surface.tsx +37 -1
  15. package/src/canvas/client/tools.tsx +91 -2
  16. package/src/canvas/content.ts +65 -0
  17. package/src/canvas/edit.ts +195 -23
  18. package/src/canvas/history.ts +116 -0
  19. package/src/canvas/server.ts +126 -13
  20. package/src/{exec.ts → cli/exec.ts} +1 -1
  21. package/src/{help.ts → cli/help.ts} +1 -1
  22. package/src/cli.ts +1 -1
  23. package/src/commands/audits.ts +9 -1
  24. package/src/commands/autoship.ts +1 -1
  25. package/src/commands/canvas.ts +1 -1
  26. package/src/commands/capture.ts +1 -1
  27. package/src/commands/census.ts +1 -1
  28. package/src/commands/claude.ts +2 -2
  29. package/src/commands/comments.ts +1 -1
  30. package/src/commands/context.ts +1 -1
  31. package/src/commands/demo.ts +9 -1
  32. package/src/commands/deps.ts +1 -1
  33. package/src/commands/design.ts +9 -1
  34. package/src/commands/docs.ts +1 -1
  35. package/src/commands/driver.ts +9 -1
  36. package/src/commands/feedback.ts +1 -1
  37. package/src/commands/gate.ts +1 -1
  38. package/src/commands/gov.ts +1 -1
  39. package/src/commands/indexes.ts +1 -1
  40. package/src/commands/init.ts +10 -2
  41. package/src/commands/intake.ts +1 -1
  42. package/src/commands/inventory.ts +9 -1
  43. package/src/commands/labels.ts +1 -1
  44. package/src/commands/markdown.ts +1 -1
  45. package/src/commands/migrate.ts +1 -1
  46. package/src/commands/pr.ts +141 -2
  47. package/src/commands/records.ts +1 -1
  48. package/src/commands/repo.ts +1 -1
  49. package/src/commands/sandbox.ts +1 -1
  50. package/src/commands/secrets.ts +9 -1
  51. package/src/commands/sessions.ts +1 -1
  52. package/src/commands/slides.ts +1 -1
  53. package/src/commands/standards.ts +1 -1
  54. package/src/commands/sync.ts +2 -2
  55. package/src/commands/targets.ts +1 -1
  56. package/src/commands/tasks.ts +1 -1
  57. package/src/commands/teach.ts +1 -1
  58. package/src/commands/tooling.ts +1 -1
  59. package/src/commands/transcripts.ts +1 -1
  60. package/src/commands/upgrade.ts +1 -1
  61. package/src/commands/wiki.ts +1 -1
  62. package/src/commands/worktrees.ts +1 -1
  63. package/src/design/brand-favicon.ts +18 -0
  64. package/src/gate/measures.ts +1 -1
  65. package/src/init/run.ts +1 -1
  66. package/src/migrate/rename.ts +1 -1
  67. package/src/pr/evidence.ts +41 -7
  68. package/src/roots/project.ts +1 -1
  69. package/src/sandbox/headless/run.ts +1 -1
  70. package/src/sandbox/provision.ts +1 -1
  71. package/src/sandbox/scenario.ts +1 -1
  72. package/src/serve/report.ts +1 -1
  73. package/src/sync/engine.ts +1 -1
  74. package/src/sync/git.ts +1 -1
  75. package/src/sync/workflow.ts +1 -1
  76. package/src/targets/validate.ts +1 -1
  77. package/src/teach/nav.ts +5 -12
  78. package/src/tooling/inject.ts +1 -1
  79. package/src/tooling/verify-stack.ts +1 -1
  80. /package/src/{cli-run.ts → cli/run.ts} +0 -0
  81. /package/src/{ui.ts → cli/ui.ts} +0 -0
@@ -63,6 +63,13 @@ export interface PagesRecord {
63
63
  readonly selection?: SelectionRef | null
64
64
  /** The live marks, which a server older than the mark leaves out. */
65
65
  readonly editing?: readonly EditingRef[]
66
+ /** Whether the server holds an edit to undo or redo, absent before undo shipped. */
67
+ readonly history?: HistoryFlags
68
+ }
69
+
70
+ export interface HistoryFlags {
71
+ readonly canUndo: boolean
72
+ readonly canRedo: boolean
66
73
  }
67
74
 
68
75
  export interface ChangeEvent {
@@ -128,6 +135,60 @@ export const writeError = signal<string | undefined>(undefined)
128
135
 
129
136
  export const leftTab = signal<LeftTab>('pages')
130
137
 
138
+ /** Whether the operator hid both side panels to see the whole surface. */
139
+ export const panelsHidden = signal(false)
140
+
141
+ export function togglePanels(): void {
142
+ panelsHidden.value = !panelsHidden.value
143
+ }
144
+
145
+ export type PanelSide = 'left' | 'right'
146
+
147
+ export interface PanelWidths {
148
+ readonly left: number
149
+ readonly right: number
150
+ }
151
+
152
+ /** The 15rem and 16rem columns the shell opened at before panels resized. */
153
+ export const DEFAULT_PANEL_WIDTHS: PanelWidths = { left: 240, right: 256 }
154
+ export const MIN_PANEL_WIDTH = 180
155
+ export const MAX_PANEL_WIDTH = 480
156
+ /** What a panel never squeezes the surface below, the reflow floor. */
157
+ export const MIN_SURFACE_WIDTH = 320
158
+
159
+ export const panelWidths = signal<PanelWidths>(DEFAULT_PANEL_WIDTHS)
160
+
161
+ /**
162
+ * Holds one panel between the floor and the ceiling, and under whatever keeps
163
+ * the surface at its own floor beside the other panel. The floor wins on a
164
+ * window too narrow for both.
165
+ */
166
+ export function clampPanelWidth(
167
+ side: PanelSide,
168
+ width: number,
169
+ windowWidth: number,
170
+ widths: PanelWidths = panelWidths.value,
171
+ ): number {
172
+ const other = side === 'left' ? widths.right : widths.left
173
+ const ceiling = Math.min(
174
+ MAX_PANEL_WIDTH,
175
+ windowWidth - other - MIN_SURFACE_WIDTH,
176
+ )
177
+ const fallback = DEFAULT_PANEL_WIDTHS[side]
178
+ const wanted = Number.isFinite(width) ? width : fallback
179
+ return Math.round(Math.max(MIN_PANEL_WIDTH, Math.min(ceiling, wanted)))
180
+ }
181
+
182
+ export function setPanelWidth(
183
+ side: PanelSide,
184
+ width: number,
185
+ windowWidth: number,
186
+ ): void {
187
+ const next = clampPanelWidth(side, width, windowWidth)
188
+ if (panelWidths.value[side] === next) return
189
+ panelWidths.value = { ...panelWidths.value, [side]: next }
190
+ }
191
+
131
192
  /** The tool the strip has lit, which a Space pan overrides while held. */
132
193
  export const activeTool = signal<Tool>('move')
133
194
 
@@ -149,6 +210,14 @@ const SAVED_MS = 2500
149
210
  /** Why the last edit wrote nothing, until the next one lands. */
150
211
  export const editRefusal = signal<string | undefined>(undefined)
151
212
 
213
+ const NO_HISTORY: HistoryFlags = { canUndo: false, canRedo: false }
214
+
215
+ /** What the server's history holds, as the last page record reported it. */
216
+ export const history = signal<HistoryFlags>(NO_HISTORY)
217
+
218
+ /** Why the last undo or redo wrote nothing, until the next one runs. */
219
+ export const historyNotice = signal<string | undefined>(undefined)
220
+
152
221
  /**
153
222
  * The hash an edit answered by frame key, until that frame reloads. A second
154
223
  * edit sent before the reload is made from the file the first one wrote, not
@@ -275,6 +344,7 @@ export function applyRecord(record: PagesRecord): void {
275
344
  tokens.value = record.tokens
276
345
  selection.value = record.selection ?? undefined
277
346
  applyEditing(record.pages, record.editing ?? [])
347
+ history.value = record.history ?? NO_HISTORY
278
348
  loadError.value = undefined
279
349
  isLoaded.value = true
280
350
  }
@@ -395,11 +465,22 @@ const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
395
465
  'Could not save the text, since this element holds other elements.',
396
466
  }
397
467
 
468
+ let stepCount = 0
469
+
470
+ /**
471
+ * A token the edits of one gesture share, so the server merges them into one
472
+ * undo. The time keeps it apart from a token an earlier shell load sent.
473
+ */
474
+ export function newStep(): string {
475
+ stepCount += 1
476
+ return `${Date.now().toString(36)}-${stepCount}`
477
+ }
478
+
398
479
  /**
399
480
  * Posts one property change to the element the address names. A refusal
400
481
  * says why and reloads the frame, so a pending value never sits over a file
401
482
  * that moved under it. Resolves to what the write answered, or undefined when
402
- * nothing was written.
483
+ * nothing was written. Edits carrying one step undo as one.
403
484
  */
404
485
  export async function editElement(
405
486
  ref: FrameRef,
@@ -407,6 +488,7 @@ export async function editElement(
407
488
  address: ElementAddress,
408
489
  property: string,
409
490
  value: string,
491
+ step?: string,
410
492
  fetchImpl: typeof fetch = fetch,
411
493
  ): Promise<{ readonly hash?: string } | undefined> {
412
494
  const hash = editedHashes.value.get(key) ?? address.hash
@@ -420,6 +502,7 @@ export async function editElement(
420
502
  element: { ...address, hash },
421
503
  property,
422
504
  value,
505
+ ...(step !== undefined && { step }),
423
506
  }),
424
507
  })
425
508
  const body: unknown = await response.json().catch(() => undefined)
@@ -440,6 +523,7 @@ export async function editElement(
440
523
  return undefined
441
524
  }
442
525
  editRefusal.value = undefined
526
+ historyNotice.value = undefined
443
527
  const saved = pendingEdit.value
444
528
  savedEdit.value = saved
445
529
  setTimeout(() => {
@@ -459,10 +543,11 @@ export async function editElement(
459
543
  }
460
544
 
461
545
  /**
462
- * Writes an element's new size as one edit per changed axis, width first. The
463
- * second carries the hash the first answered, so a frame reloading between the
464
- * two cannot leave it holding the hash of a file already rewritten. A refused
465
- * first edit sends no second.
546
+ * Writes an element's new size as one edit per changed axis, width first,
547
+ * sharing one step so a single undo restores both. The second carries the
548
+ * hash the first answered, so a frame reloading between the two cannot leave
549
+ * it holding the hash of a file already rewritten. A refused first edit sends
550
+ * no second.
466
551
  */
467
552
  export async function resizeElement(
468
553
  ref: FrameRef,
@@ -472,6 +557,7 @@ export async function resizeElement(
472
557
  height: number | undefined,
473
558
  fetchImpl: typeof fetch = fetch,
474
559
  ): Promise<void> {
560
+ const step = newStep()
475
561
  let current = address
476
562
  if (width !== undefined) {
477
563
  const written = await editElement(
@@ -480,16 +566,79 @@ export async function resizeElement(
480
566
  current,
481
567
  'width',
482
568
  `${width}px`,
569
+ step,
483
570
  fetchImpl,
484
571
  )
485
572
  if (!written) return
486
573
  if (written.hash) current = { ...current, hash: written.hash }
487
574
  }
488
575
  if (height !== undefined) {
489
- await editElement(ref, key, current, 'height', `${height}px`, fetchImpl)
576
+ await editElement(
577
+ ref,
578
+ key,
579
+ current,
580
+ 'height',
581
+ `${height}px`,
582
+ step,
583
+ fetchImpl,
584
+ )
490
585
  }
491
586
  }
492
587
 
588
+ const HISTORY_NOTICES = {
589
+ undo: 'Could not undo, since that element or frame changed after your edit. Nothing was written.',
590
+ redo: 'Could not redo, since that element or frame changed after the undo. Nothing was written.',
591
+ } as const
592
+
593
+ /**
594
+ * Asks the server to step its history and rereads the page list for the new
595
+ * flags. A written step clears every hash an edit answered, since the frame
596
+ * it named was just rewritten under it. The frame itself reloads on the
597
+ * change event the write raises.
598
+ */
599
+ async function stepHistory(
600
+ direction: 'undo' | 'redo',
601
+ fetchImpl: typeof fetch,
602
+ ): Promise<void> {
603
+ try {
604
+ const response = await fetchImpl(`/api/history/${direction}`, {
605
+ method: 'POST',
606
+ headers: { 'content-type': 'application/json' },
607
+ body: '{}',
608
+ })
609
+ const body: unknown = await response.json().catch(() => undefined)
610
+ const outcome =
611
+ typeof body === 'object' && body !== null && 'outcome' in body
612
+ ? body.outcome
613
+ : undefined
614
+ if (!response.ok) {
615
+ historyNotice.value = `Could not ${direction} (status ${response.status}). Try again.`
616
+ } else if (outcome === 'dropped') {
617
+ historyNotice.value = HISTORY_NOTICES[direction]
618
+ } else if (outcome === 'applied') {
619
+ const done = direction === 'undo' ? 'Undone' : 'Redone'
620
+ historyNotice.value = done
621
+ setTimeout(() => {
622
+ if (historyNotice.value === done) historyNotice.value = undefined
623
+ }, SAVED_MS)
624
+ } else {
625
+ historyNotice.value = undefined
626
+ }
627
+ if (outcome === 'applied') editedHashes.value = new Map()
628
+ } catch (error) {
629
+ historyNotice.value = `Could not ${direction} (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
630
+ }
631
+ await loadPages(fetchImpl)
632
+ }
633
+
634
+ export function undo(fetchImpl: typeof fetch = fetch): Promise<void> {
635
+ return stepHistory('undo', fetchImpl)
636
+ }
637
+
638
+ export function redo(fetchImpl: typeof fetch = fetch): Promise<void> {
639
+ return stepHistory('redo', fetchImpl)
640
+ }
641
+
493
642
  /**
494
643
  * Moves a frame on the surface without writing, which a drag calls on every
495
644
  * pointer move. The next reread replaces it with what the layout holds.
@@ -636,4 +785,8 @@ export function resetState(): void {
636
785
  savedEdit.value = undefined
637
786
  editRefusal.value = undefined
638
787
  editedHashes.value = new Map()
788
+ history.value = NO_HISTORY
789
+ historyNotice.value = undefined
790
+ panelsHidden.value = false
791
+ panelWidths.value = DEFAULT_PANEL_WIDTHS
639
792
  }
@@ -1,6 +1,6 @@
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
6
  import { type SurfaceAction, surfaceAction } from '@/canvas/client/keys'
@@ -15,11 +15,15 @@ import {
15
15
  frameKey,
16
16
  frameTheme,
17
17
  frameVersions,
18
+ historyNotice,
18
19
  hoveredElement,
19
20
  moveFrameTo,
20
21
  panBy,
22
+ panelsHidden,
23
+ panelWidths,
21
24
  previewMove,
22
25
  previewResize,
26
+ redo,
23
27
  registerFrameDocument,
24
28
  resizeElement,
25
29
  resizeFrameTo,
@@ -30,6 +34,8 @@ import {
30
34
  spacePan,
31
35
  type Theme,
32
36
  toggleFrameTheme,
37
+ togglePanels,
38
+ undo,
33
39
  view,
34
40
  zoomAt,
35
41
  } from '@/canvas/client/state'
@@ -596,6 +602,22 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
596
602
  fitViewport(viewportRef)
597
603
  }, [page?.name])
598
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
+
599
621
  const handleWheelTurn = (turn: WheelTurn) => {
600
622
  if (turn.isZoom) {
601
623
  const rect = viewportRef.current?.getBoundingClientRect()
@@ -656,6 +678,15 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
656
678
  return
657
679
  case 'pan-release':
658
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()
659
690
  }
660
691
  }
661
692
 
@@ -762,6 +793,11 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
762
793
  </div>
763
794
  <ToolStrip />
764
795
  <Toolbar viewportRef={viewportRef} />
796
+ {historyNotice.value ? (
797
+ <p class="history-notice" role="status">
798
+ {historyNotice.value}
799
+ </p>
800
+ ) : null}
765
801
  </main>
766
802
  )
767
803
  }
@@ -1,6 +1,15 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
- import { activeTool, shownTool, type Tool } from '@/canvas/client/state'
3
+ import {
4
+ activeTool,
5
+ history,
6
+ panelsHidden,
7
+ redo,
8
+ shownTool,
9
+ type Tool,
10
+ togglePanels,
11
+ undo,
12
+ } from '@/canvas/client/state'
4
13
 
5
14
  interface ToolButton {
6
15
  readonly tool: Tool
@@ -32,9 +41,51 @@ const TOOLS: readonly ToolButton[] = [
32
41
  },
33
42
  ]
34
43
 
35
- /** The tool strip floating at the surface's left edge, one button a tool. */
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
+ */
36
86
  export function ToolStrip(): JSX.Element {
37
87
  const shown = shownTool.value
88
+ const { canUndo, canRedo } = history.value
38
89
  return (
39
90
  <div
40
91
  class="tools"
@@ -59,6 +110,44 @@ export function ToolStrip(): JSX.Element {
59
110
  {icon}
60
111
  </button>
61
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 />
62
130
  </div>
63
131
  )
64
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
+ }
@@ -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