@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
@@ -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,26 @@ 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[]
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
53
73
  }
54
74
 
55
75
  export interface ChangeEvent {
@@ -101,6 +121,9 @@ export const hoveredElement = signal<
101
121
  { readonly key: string; readonly index: number } | undefined
102
122
  >(undefined)
103
123
 
124
+ /** Each marked frame's mark by frame key, dropped at its `until`. */
125
+ export const editingFrames = signal<ReadonlyMap<string, EditingRef>>(new Map())
126
+
104
127
  /** Frame keys whose layers are open in the pages panel. */
105
128
  export const expandedFrames = signal<ReadonlySet<string>>(new Set())
106
129
 
@@ -112,6 +135,70 @@ export const writeError = signal<string | undefined>(undefined)
112
135
 
113
136
  export const leftTab = signal<LeftTab>('pages')
114
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
+
192
+ /** The tool the strip has lit, which a Space pan overrides while held. */
193
+ export const activeTool = signal<Tool>('move')
194
+
195
+ export const spacePan = signal<SpacePan | undefined>(undefined)
196
+
197
+ /** The tool in effect, so Space shows Pan lit and releasing it restores it. */
198
+ export const shownTool = computed<Tool>(() =>
199
+ spacePan.value ? 'pan' : activeTool.value,
200
+ )
201
+
115
202
  /** The edit in flight, which holds its field until the server answers. */
116
203
  export const pendingEdit = signal<PendingEdit | undefined>(undefined)
117
204
 
@@ -123,6 +210,14 @@ const SAVED_MS = 2500
123
210
  /** Why the last edit wrote nothing, until the next one lands. */
124
211
  export const editRefusal = signal<string | undefined>(undefined)
125
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
+
126
221
  /**
127
222
  * The hash an edit answered by frame key, until that frame reloads. A second
128
223
  * edit sent before the reload is made from the file the first one wrote, not
@@ -193,10 +288,63 @@ export function toggleLayers(key: string): void {
193
288
  expandedFrames.value = next
194
289
  }
195
290
 
291
+ /*
292
+ * An expiry writes no file and raises no change event, so nothing rereads the
293
+ * page list when a mark lapses. One timer, set for the earliest `until`, drops
294
+ * it instead, and every record resets it.
295
+ */
296
+ let editingTimer: ReturnType<typeof setTimeout> | undefined
297
+
298
+ /*
299
+ * The longest delay a timer holds. A longer one fires at once, so an `until`
300
+ * far out would reschedule on every tick rather than wait.
301
+ */
302
+ const MAX_TIMER_MS = 2 ** 31 - 1
303
+
304
+ function liveMarks(
305
+ marks: Iterable<readonly [string, EditingRef]>,
306
+ now: number,
307
+ ): ReadonlyMap<string, EditingRef> {
308
+ return new Map([...marks].filter(([, mark]) => Date.parse(mark.until) > now))
309
+ }
310
+
311
+ function scheduleExpiry(): void {
312
+ clearTimeout(editingTimer)
313
+ editingTimer = undefined
314
+ const next = Math.min(
315
+ ...[...editingFrames.value.values()].map((mark) => Date.parse(mark.until)),
316
+ )
317
+ if (!Number.isFinite(next)) return
318
+ editingTimer = setTimeout(
319
+ () => {
320
+ editingFrames.value = liveMarks(editingFrames.value, Date.now())
321
+ scheduleExpiry()
322
+ },
323
+ Math.min(MAX_TIMER_MS, Math.max(0, next - Date.now())),
324
+ )
325
+ }
326
+
327
+ /** Keys each mark by the file of the frame it names, as the surface reads. */
328
+ function applyEditing(
329
+ all: readonly Page[],
330
+ marks: readonly EditingRef[],
331
+ ): void {
332
+ const keyed = marks.flatMap((mark) => {
333
+ const frame = all
334
+ .find((candidate) => candidate.name === mark.page)
335
+ ?.frames.find((candidate) => candidate.name === mark.frame)
336
+ return frame ? [[frameKey(mark.page, frame), mark] as const] : []
337
+ })
338
+ editingFrames.value = liveMarks(keyed, Date.now())
339
+ scheduleExpiry()
340
+ }
341
+
196
342
  export function applyRecord(record: PagesRecord): void {
197
343
  pages.value = record.pages
198
344
  tokens.value = record.tokens
199
345
  selection.value = record.selection ?? undefined
346
+ applyEditing(record.pages, record.editing ?? [])
347
+ history.value = record.history ?? NO_HISTORY
200
348
  loadError.value = undefined
201
349
  isLoaded.value = true
202
350
  }
@@ -317,11 +465,22 @@ const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
317
465
  'Could not save the text, since this element holds other elements.',
318
466
  }
319
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
+
320
479
  /**
321
480
  * Posts one property change to the element the address names. A refusal
322
481
  * says why and reloads the frame, so a pending value never sits over a file
323
482
  * that moved under it. Resolves to what the write answered, or undefined when
324
- * nothing was written.
483
+ * nothing was written. Edits carrying one step undo as one.
325
484
  */
326
485
  export async function editElement(
327
486
  ref: FrameRef,
@@ -329,6 +488,7 @@ export async function editElement(
329
488
  address: ElementAddress,
330
489
  property: string,
331
490
  value: string,
491
+ step?: string,
332
492
  fetchImpl: typeof fetch = fetch,
333
493
  ): Promise<{ readonly hash?: string } | undefined> {
334
494
  const hash = editedHashes.value.get(key) ?? address.hash
@@ -342,6 +502,7 @@ export async function editElement(
342
502
  element: { ...address, hash },
343
503
  property,
344
504
  value,
505
+ ...(step !== undefined && { step }),
345
506
  }),
346
507
  })
347
508
  const body: unknown = await response.json().catch(() => undefined)
@@ -362,6 +523,7 @@ export async function editElement(
362
523
  return undefined
363
524
  }
364
525
  editRefusal.value = undefined
526
+ historyNotice.value = undefined
365
527
  const saved = pendingEdit.value
366
528
  savedEdit.value = saved
367
529
  setTimeout(() => {
@@ -381,10 +543,11 @@ export async function editElement(
381
543
  }
382
544
 
383
545
  /**
384
- * Writes an element's new size as one edit per changed axis, width first. The
385
- * second carries the hash the first answered, so a frame reloading between the
386
- * two cannot leave it holding the hash of a file already rewritten. A refused
387
- * 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.
388
551
  */
389
552
  export async function resizeElement(
390
553
  ref: FrameRef,
@@ -394,6 +557,7 @@ export async function resizeElement(
394
557
  height: number | undefined,
395
558
  fetchImpl: typeof fetch = fetch,
396
559
  ): Promise<void> {
560
+ const step = newStep()
397
561
  let current = address
398
562
  if (width !== undefined) {
399
563
  const written = await editElement(
@@ -402,16 +566,79 @@ export async function resizeElement(
402
566
  current,
403
567
  'width',
404
568
  `${width}px`,
569
+ step,
405
570
  fetchImpl,
406
571
  )
407
572
  if (!written) return
408
573
  if (written.hash) current = { ...current, hash: written.hash }
409
574
  }
410
575
  if (height !== undefined) {
411
- 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
+ )
412
585
  }
413
586
  }
414
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
+
415
642
  /**
416
643
  * Moves a frame on the surface without writing, which a drag calls on every
417
644
  * pointer move. The next reread replaces it with what the layout holds.
@@ -487,11 +714,19 @@ export function panBy(dx: number, dy: number): void {
487
714
  view.value = { ...current, x: current.x + dx, y: current.y + dy }
488
715
  }
489
716
 
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 {
717
+ /**
718
+ * Fits every frame on the current page into a viewport of the given size,
719
+ * clear of `insetLeft` pixels of chrome floating over its left edge.
720
+ */
721
+ export function fitView(
722
+ width: number,
723
+ height: number,
724
+ padding = 48,
725
+ insetLeft = 0,
726
+ ): void {
492
727
  const frames = currentPage.value?.frames ?? []
493
728
  if (frames.length === 0 || width <= 0 || height <= 0) {
494
- view.value = { x: padding, y: padding, zoom: 0.5 }
729
+ view.value = { x: insetLeft + padding, y: padding, zoom: 0.5 }
495
730
  return
496
731
  }
497
732
  const left = Math.min(...frames.map((frame) => frame.x))
@@ -500,14 +735,14 @@ export function fitView(width: number, height: number, padding = 48): void {
500
735
  const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
501
736
  const zoom = clampZoom(
502
737
  Math.min(
503
- (width - padding * 2) / (right - left),
738
+ (width - insetLeft - padding * 2) / (right - left),
504
739
  (height - padding * 2) / (bottom - top),
505
740
  1,
506
741
  ),
507
742
  )
508
743
  view.value = {
509
744
  zoom,
510
- x: padding - left * zoom,
745
+ x: insetLeft + padding - left * zoom,
511
746
  y: padding - top * zoom,
512
747
  }
513
748
  }
@@ -537,12 +772,21 @@ export function resetState(): void {
537
772
  selection.value = undefined
538
773
  frameDocuments.value = new Map()
539
774
  hoveredElement.value = undefined
775
+ editingFrames.value = new Map()
776
+ clearTimeout(editingTimer)
777
+ editingTimer = undefined
540
778
  expandedFrames.value = new Set()
541
779
  draggingFrame.value = undefined
542
780
  writeError.value = undefined
543
781
  leftTab.value = 'pages'
782
+ activeTool.value = 'move'
783
+ spacePan.value = undefined
544
784
  pendingEdit.value = undefined
545
785
  savedEdit.value = undefined
546
786
  editRefusal.value = undefined
547
787
  editedHashes.value = new Map()
788
+ history.value = NO_HISTORY
789
+ historyNotice.value = undefined
790
+ panelsHidden.value = false
791
+ panelWidths.value = DEFAULT_PANEL_WIDTHS
548
792
  }