@erclx/canon 5.26.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 (320) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +13 -0
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/SKILL.md +3 -17
  20. package/claude/skills/context-fold/references/completion-report.md +26 -0
  21. package/claude/skills/create-rule/SKILL.md +2 -0
  22. package/claude/skills/create-skill/SKILL.md +2 -0
  23. package/claude/skills/create-standard/SKILL.md +2 -0
  24. package/claude/skills/decision-escalate/SKILL.md +2 -0
  25. package/claude/skills/deploy-app/SKILL.md +2 -0
  26. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  27. package/claude/skills/design-extract/SKILL.md +2 -0
  28. package/claude/skills/design-taste/SKILL.md +2 -0
  29. package/claude/skills/docs-sync/SKILL.md +2 -0
  30. package/claude/skills/document-health/SKILL.md +2 -0
  31. package/claude/skills/draft-and-pick/SKILL.md +2 -0
  32. package/claude/skills/draft-doc/SKILL.md +2 -0
  33. package/claude/skills/draft-doc/references/wiki.md +1 -1
  34. package/claude/skills/draft-figure/SKILL.md +2 -0
  35. package/claude/skills/draft-identity/SKILL.md +2 -0
  36. package/claude/skills/draft-ready/SKILL.md +5 -1
  37. package/claude/skills/draft-screencast/SKILL.md +2 -0
  38. package/claude/skills/draft-slides/SKILL.md +2 -0
  39. package/claude/skills/git-branch/SKILL.md +2 -0
  40. package/claude/skills/git-commit/SKILL.md +2 -0
  41. package/claude/skills/git-followup/SKILL.md +2 -0
  42. package/claude/skills/git-issue/SKILL.md +2 -0
  43. package/claude/skills/git-pr/SKILL.md +2 -0
  44. package/claude/skills/git-pr/references/evidence.md +7 -7
  45. package/claude/skills/git-ship/SKILL.md +2 -0
  46. package/claude/skills/git-split/SKILL.md +2 -0
  47. package/claude/skills/git-stage/SKILL.md +2 -0
  48. package/claude/skills/git-worktree/SKILL.md +2 -0
  49. package/claude/skills/index-lookup/SKILL.md +2 -0
  50. package/claude/skills/markdown-propose/SKILL.md +2 -0
  51. package/claude/skills/memory-capture/SKILL.md +2 -0
  52. package/claude/skills/memory-review/SKILL.md +2 -0
  53. package/claude/skills/plan-feature/SKILL.md +2 -0
  54. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  55. package/claude/skills/plan-intake/SKILL.md +2 -0
  56. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  57. package/claude/skills/project-commands/SKILL.md +2 -0
  58. package/claude/skills/read-frames/SKILL.md +2 -0
  59. package/claude/skills/record-screencast/SKILL.md +2 -0
  60. package/claude/skills/repo-metadata/SKILL.md +2 -0
  61. package/claude/skills/restate-plainly/SKILL.md +2 -0
  62. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  63. package/claude/skills/review-address/SKILL.md +19 -14
  64. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  65. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  66. package/claude/skills/review-branch/SKILL.md +2 -0
  67. package/claude/skills/review-craft/SKILL.md +2 -0
  68. package/claude/skills/review-craft/references/ui.md +6 -6
  69. package/claude/skills/review-pr/SKILL.md +4 -8
  70. package/claude/skills/review-pr/references/body-variants.md +52 -0
  71. package/claude/skills/review-pr/references/key-changes.md +16 -0
  72. package/claude/skills/review-pr/references/post.md +15 -43
  73. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  74. package/claude/skills/review-ui/SKILL.md +16 -12
  75. package/claude/skills/review-ui/references/driving.md +8 -8
  76. package/claude/skills/review-ui/references/post.md +13 -3
  77. package/claude/skills/review-ui/references/tick.md +22 -0
  78. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  79. package/claude/skills/role-orchestrator/SKILL.md +31 -42
  80. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  81. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
  82. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  83. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
  84. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  85. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  86. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
  87. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  88. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  89. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  91. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  92. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  93. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  94. package/claude/skills/role-planner/SKILL.md +4 -2
  95. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  96. package/claude/skills/role-reviewer/SKILL.md +3 -1
  97. package/claude/skills/role-worker/SKILL.md +5 -2
  98. package/claude/skills/search-craft/SKILL.md +2 -0
  99. package/claude/skills/seed-sync/SKILL.md +2 -0
  100. package/claude/skills/session-compact/SKILL.md +2 -0
  101. package/claude/skills/session-map/SKILL.md +2 -0
  102. package/claude/skills/session-relay/SKILL.md +2 -0
  103. package/claude/skills/session-resume/SKILL.md +2 -0
  104. package/claude/skills/session-worktree/SKILL.md +3 -11
  105. package/claude/skills/session-worktree/references/install-command.md +20 -0
  106. package/claude/skills/sketch-design/SKILL.md +2 -0
  107. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  108. package/claude/skills/target-check/SKILL.md +2 -0
  109. package/claude/skills/target-setup/SKILL.md +2 -0
  110. package/claude/skills/task-board/SKILL.md +2 -0
  111. package/claude/skills/teach-workspace/SKILL.md +5 -15
  112. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  113. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  114. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  115. package/claude/skills/test-craft/SKILL.md +2 -0
  116. package/claude/skills/test-first/SKILL.md +2 -0
  117. package/claude/skills/ui-checklist/SKILL.md +3 -1
  118. package/claude/skills/ux-audit/SKILL.md +2 -0
  119. package/claude/skills/ux-measure/SKILL.md +2 -0
  120. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  121. package/claude/skills/video-craft/SKILL.md +2 -0
  122. package/claude/skills/write-human/SKILL.md +2 -0
  123. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  124. package/docs/agents/canvas.md +24 -8
  125. package/docs/agents/capture.md +5 -5
  126. package/docs/agents/commands.md +2 -2
  127. package/docs/agents/gate.md +3 -1
  128. package/docs/agents/index.md +5 -4
  129. package/docs/agents/install-and-sync.md +7 -0
  130. package/docs/agents/key-changes.md +1 -1
  131. package/docs/agents/pr-evidence.md +47 -23
  132. package/docs/agents/pr-local.md +19 -13
  133. package/docs/agents/pr-preview.md +44 -21
  134. package/docs/agents/pr-tick.md +72 -0
  135. package/docs/agents/sandbox.md +30 -6
  136. package/docs/agents/scripting.md +8 -1
  137. package/docs/agents/skills-audit.md +2 -1
  138. package/docs/agents/tasks-plans.md +1 -1
  139. package/docs/agents/tasks-validate.md +18 -18
  140. package/docs/agents/tasks.md +1 -1
  141. package/docs/workflow/operating-model.md +15 -21
  142. package/docs/workflow/parallel-features.md +24 -3
  143. package/docs/workflow/skill-map.md +3 -1
  144. package/docs/workflow/zshrc-aliases.md +1 -1
  145. package/package.json +1 -1
  146. package/scripts/core/regen-hero.ts +1 -1
  147. package/scripts/lib/sandbox-path.sh +6 -162
  148. package/src/audits/baseline.ts +1 -1
  149. package/src/audits/run.ts +2 -2
  150. package/src/canvas/address.ts +12 -3
  151. package/src/canvas/capture.ts +1 -1
  152. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  153. package/src/canvas/client/inspector/color-field.tsx +201 -171
  154. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  155. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  156. package/src/canvas/client/inspector/color-space.ts +240 -0
  157. package/src/canvas/client/inspector/field.tsx +66 -3
  158. package/src/canvas/client/inspector/fields.css +473 -6
  159. package/src/canvas/client/inspector/size-field.tsx +69 -0
  160. package/src/canvas/client/inspector/token-select.tsx +109 -0
  161. package/src/canvas/client/inspector/values.ts +117 -3
  162. package/src/canvas/client/inspector.tsx +311 -15
  163. package/src/canvas/client/keys.ts +54 -0
  164. package/src/canvas/client/selection.tsx +199 -0
  165. package/src/canvas/client/shell.css +210 -12
  166. package/src/canvas/client/state.ts +165 -14
  167. package/src/canvas/client/surface.tsx +341 -39
  168. package/src/canvas/client/tools.tsx +64 -0
  169. package/src/canvas/content.ts +51 -2
  170. package/src/canvas/edit.ts +49 -6
  171. package/src/canvas/editing.ts +203 -0
  172. package/src/canvas/global.d.ts +15 -0
  173. package/src/canvas/server.ts +149 -12
  174. package/src/canvas/tokens.ts +60 -18
  175. package/src/census/count.ts +1 -1
  176. package/src/claude/seeds.ts +3 -3
  177. package/src/claude/skills-audit.ts +28 -0
  178. package/src/claude/skills-drift.ts +1 -1
  179. package/src/claude/skills-families.ts +36 -0
  180. package/src/claude/skills-list.ts +76 -15
  181. package/src/claude/skills-reach.ts +1 -1
  182. package/src/commands/audits.ts +2 -2
  183. package/src/commands/canvas.ts +180 -3
  184. package/src/commands/claude.ts +70 -12
  185. package/src/commands/design.ts +145 -5
  186. package/src/commands/docs.ts +1 -1
  187. package/src/commands/feedback.ts +2 -2
  188. package/src/commands/gate.ts +9 -2
  189. package/src/commands/gov.ts +3 -3
  190. package/src/commands/hooks.ts +1 -1
  191. package/src/commands/indexes.ts +1 -1
  192. package/src/commands/init.ts +2 -2
  193. package/src/commands/intake.ts +1 -1
  194. package/src/commands/migrate.ts +2 -2
  195. package/src/commands/pr.ts +288 -65
  196. package/src/commands/records.ts +1 -1
  197. package/src/commands/repo.ts +1 -1
  198. package/src/commands/sandbox.ts +64 -15
  199. package/src/commands/sessions.ts +1 -1
  200. package/src/commands/slides.ts +1 -1
  201. package/src/commands/sync.ts +2 -2
  202. package/src/commands/tasks.ts +21 -4
  203. package/src/commands/teach.ts +1 -1
  204. package/src/commands/tooling.ts +1 -1
  205. package/src/commands/transcripts.ts +2 -2
  206. package/src/commands/upgrade.ts +1 -1
  207. package/src/commands/wiki.ts +1 -1
  208. package/src/commands/worktrees.ts +1 -1
  209. package/src/comments/scan.ts +1 -1
  210. package/src/comments/trend.ts +1 -1
  211. package/src/context/architecture.ts +1 -1
  212. package/src/context/citations.ts +3 -3
  213. package/src/context/classify/extract.ts +3 -3
  214. package/src/context/folders.ts +2 -2
  215. package/src/context/requirements.ts +1 -1
  216. package/src/design/project-fonts.ts +392 -0
  217. package/src/docs/read.ts +1 -1
  218. package/src/exec.ts +1 -1
  219. package/src/feedback/github.ts +2 -2
  220. package/src/gate/measures.ts +48 -10
  221. package/src/gate/sequencer.ts +56 -4
  222. package/src/gate/stages.ts +17 -0
  223. package/src/gate/suite-lock.ts +214 -0
  224. package/src/{git-files.ts → git/files.ts} +2 -2
  225. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  226. package/src/{worktree.ts → git/worktree.ts} +1 -1
  227. package/src/gov/citations.ts +1 -1
  228. package/src/gov/install.ts +1 -1
  229. package/src/gov/superseded.ts +1 -1
  230. package/src/gov/test-order.ts +2 -2
  231. package/src/help.ts +1 -1
  232. package/src/hooks/post-merge.ts +1 -1
  233. package/src/indexes/walk.ts +1 -1
  234. package/src/intake/folder.ts +1 -1
  235. package/src/labels/audit.ts +1 -1
  236. package/src/labels/map.ts +1 -1
  237. package/src/labels/phase.ts +3 -3
  238. package/src/markdown/files.ts +1 -1
  239. package/src/migrate/apply.ts +1 -1
  240. package/src/migrate/record-layout.ts +1 -1
  241. package/src/migrate/records.ts +11 -11
  242. package/src/migrate/scratch-evidence.ts +1 -1
  243. package/src/migrate/surface-roots.ts +6 -6
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +250 -59
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/pr/tick.ts +56 -0
  249. package/src/process/harness.ts +1 -1
  250. package/src/records/backup.ts +2 -2
  251. package/src/records/ordinal.ts +1 -1
  252. package/src/records/prune.ts +1 -1
  253. package/src/records/size.ts +1 -1
  254. package/src/records/stale-canonical.ts +2 -2
  255. package/src/records/stale.ts +1 -1
  256. package/src/records/validate.ts +2 -2
  257. package/src/{project-root.ts → roots/project.ts} +1 -1
  258. package/src/{record-root.ts → roots/record.ts} +1 -1
  259. package/src/sandbox/coverage.ts +11 -12
  260. package/src/sandbox/equivalence.ts +57 -26
  261. package/src/sandbox/headless/dispatch.ts +264 -0
  262. package/src/sandbox/headless/run.ts +653 -0
  263. package/src/sandbox/headless/watch.ts +145 -0
  264. package/src/sandbox/hooks.ts +1 -1
  265. package/src/sandbox/provision.ts +90 -29
  266. package/src/sandbox/scenario.ts +588 -0
  267. package/src/sandbox/tree.ts +4 -8
  268. package/src/secrets/scan.ts +1 -1
  269. package/src/sessions/claim.ts +1 -1
  270. package/src/sessions/live.ts +4 -1
  271. package/src/sessions/resolve.ts +1 -1
  272. package/src/sessions/transfer/bundle.ts +2 -2
  273. package/src/slides/decks.ts +1 -1
  274. package/src/slides/package/fonts.ts +6 -2
  275. package/src/standards/audit.ts +2 -2
  276. package/src/standards/read.ts +1 -1
  277. package/src/sync/check.ts +2 -2
  278. package/src/sync/engine.ts +3 -3
  279. package/src/sync/git.ts +1 -1
  280. package/src/sync/history.ts +1 -1
  281. package/src/sync/layout.ts +2 -2
  282. package/src/sync/reverse.ts +1 -1
  283. package/src/sync/stamp.ts +1 -1
  284. package/src/sync/target.ts +1 -1
  285. package/src/sync/workflow.ts +1 -1
  286. package/src/targets/pulls.ts +2 -2
  287. package/src/targets/sweep.ts +2 -2
  288. package/src/{target.ts → targets/validate.ts} +1 -1
  289. package/src/tasks/answers.ts +1 -1
  290. package/src/tasks/archive.ts +1 -1
  291. package/src/tasks/label.ts +1 -1
  292. package/src/tasks/reach.ts +2 -2
  293. package/src/tasks/trunk.ts +1 -1
  294. package/src/tasks/validate.ts +38 -14
  295. package/src/teach/nav.ts +1 -1
  296. package/src/teach/workspace.ts +1 -1
  297. package/src/tooling/inject.ts +2 -2
  298. package/src/tooling/read.ts +1 -1
  299. package/src/tooling/scan.ts +1 -1
  300. package/src/tooling/subfolder.ts +1 -1
  301. package/src/version/installed.ts +1 -1
  302. package/src/version/skew.ts +1 -1
  303. package/src/web/readme-citations.ts +1 -1
  304. package/src/worktrees/bare-flag.ts +1 -1
  305. package/src/worktrees/reclaim.ts +2 -2
  306. package/src/worktrees/remove.ts +1 -1
  307. package/standards/board.md +3 -3
  308. package/standards/canvas.md +2 -1
  309. package/standards/plan.md +13 -7
  310. package/standards/skill.md +1 -0
  311. package/standards/wiki.md +12 -3
  312. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  313. package/tsconfig.json +1 -1
  314. package/scripts/lib/sandbox-dispatch.sh +0 -190
  315. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  316. /package/src/{git-base.ts → git/base.ts} +0 -0
  317. /package/src/{git-env.ts → git/env.ts} +0 -0
  318. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  319. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
  320. /package/src/{copy.ts → sync/copy.ts} +0 -0
@@ -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,
@@ -16,22 +19,31 @@ import {
16
19
  moveFrameTo,
17
20
  panBy,
18
21
  previewMove,
22
+ previewResize,
19
23
  registerFrameDocument,
24
+ resizeElement,
25
+ resizeFrameTo,
20
26
  selectElement,
21
27
  selectFrame,
22
28
  selection,
29
+ shownTool,
30
+ spacePan,
23
31
  type Theme,
24
32
  toggleFrameTheme,
25
33
  view,
26
34
  zoomAt,
27
35
  } from '@/canvas/client/state'
36
+ import {
37
+ DRAG_THRESHOLD,
38
+ HoverOutline,
39
+ type OverlayBox,
40
+ SelectionOverlay,
41
+ } from '@/canvas/client/selection'
42
+ import { ToolStrip } from '@/canvas/client/tools'
28
43
 
29
44
  /** One wheel notch or one button press. */
30
45
  const ZOOM_STEP = 1.2
31
46
 
32
- /** Screen pixels a press travels before it reads as a drag and not a click. */
33
- const DRAG_THRESHOLD = 3
34
-
35
47
  /** Surface units one arrow press moves a frame, and one with Shift held. */
36
48
  const NUDGE = 10
37
49
  const NUDGE_LARGE = 50
@@ -76,25 +88,142 @@ function asElement(target: EventTarget | null): Element | undefined {
76
88
  return node?.nodeType === 1 ? (node as Element) : undefined
77
89
  }
78
90
 
79
- function ElementOutline({
80
- element,
81
- kind,
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
+
122
+ /** A computed length in pixels, or the fallback for one that is not. */
123
+ function pixels(value: string, fallback: number): number {
124
+ const parsed = Number.parseFloat(value)
125
+ return value.endsWith('px') && Number.isFinite(parsed) ? parsed : fallback
126
+ }
127
+
128
+ /**
129
+ * The selected element's overlay. A handle drag previews the size as inline
130
+ * style on the element and writes it once on release, the way the inspector's
131
+ * scrub does. The drag measures the border box while `width` and `height` set
132
+ * whatever box the element sizes by, so the delta is added to the computed
133
+ * value rather than written as the box itself.
134
+ */
135
+ function ElementSelection({
136
+ node,
137
+ doc,
138
+ frameRef,
139
+ docKey,
82
140
  }: {
83
- readonly element: Element
84
- readonly kind: 'hover' | 'selected'
141
+ readonly node: Element
142
+ readonly doc: Document
143
+ readonly frameRef: FrameRef
144
+ readonly docKey: string
85
145
  }): JSX.Element {
86
- const rect = element.getBoundingClientRect()
146
+ const start = useRef<
147
+ | {
148
+ readonly width: number
149
+ readonly height: number
150
+ readonly inlineWidth: string
151
+ readonly inlineHeight: string
152
+ }
153
+ | undefined
154
+ >(undefined)
155
+ const style = (node as HTMLElement).style
156
+
157
+ const measure = () => {
158
+ const rect = node.getBoundingClientRect()
159
+ return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
160
+ }
161
+
162
+ const cssSize = (box: OverlayBox, origin: OverlayBox) => {
163
+ const from = start.current
164
+ return {
165
+ width: Math.round(
166
+ (from?.width ?? origin.width) + box.width - origin.width,
167
+ ),
168
+ height: Math.round(
169
+ (from?.height ?? origin.height) + box.height - origin.height,
170
+ ),
171
+ }
172
+ }
173
+
174
+ const handleStart = () => {
175
+ const computed = doc.defaultView?.getComputedStyle(node)
176
+ const rect = node.getBoundingClientRect()
177
+ start.current = {
178
+ width: pixels(computed?.width ?? '', rect.width),
179
+ height: pixels(computed?.height ?? '', rect.height),
180
+ inlineWidth: style.getPropertyValue('width'),
181
+ inlineHeight: style.getPropertyValue('height'),
182
+ }
183
+ }
184
+
185
+ const handlePreview = (box: OverlayBox, from: OverlayBox) => {
186
+ const size = cssSize(box, from)
187
+ style.setProperty('width', `${size.width}px`)
188
+ style.setProperty('height', `${size.height}px`)
189
+ }
190
+
191
+ const restore = () => {
192
+ const from = start.current
193
+ if (!from) return
194
+ style.setProperty('width', from.inlineWidth)
195
+ style.setProperty('height', from.inlineHeight)
196
+ }
197
+
198
+ const handleCommit = (box: OverlayBox, from: OverlayBox) => {
199
+ const size = cssSize(box, from)
200
+ const address = addressOf(doc, node)
201
+ const isUnchanged = box.width === from.width && box.height === from.height
202
+ if (!address || isUnchanged) {
203
+ restore()
204
+ return
205
+ }
206
+ void resizeElement(
207
+ frameRef,
208
+ docKey,
209
+ address,
210
+ box.width === from.width ? undefined : size.width,
211
+ box.height === from.height ? undefined : size.height,
212
+ )
213
+ }
214
+
215
+ const handleCancel = restore
216
+
87
217
  return (
88
- <div
89
- class="element-outline"
90
- data-outline={kind}
91
- aria-hidden="true"
92
- style={{
93
- left: `${rect.left}px`,
94
- top: `${rect.top}px`,
95
- width: `${rect.width}px`,
96
- height: `${rect.height}px`,
97
- }}
218
+ <SelectionOverlay
219
+ kind="element"
220
+ measure={measure}
221
+ hasChip
222
+ isMeasuredWhileDragging
223
+ onStart={handleStart}
224
+ onPreview={handlePreview}
225
+ onCommit={handleCommit}
226
+ onCancel={handleCancel}
98
227
  />
99
228
  )
100
229
  }
@@ -119,10 +248,12 @@ function FrameView({
119
248
  page,
120
249
  frame,
121
250
  onWheelTurn,
251
+ onKey,
122
252
  }: {
123
253
  readonly page: string
124
254
  readonly frame: Frame
125
255
  readonly onWheelTurn: (turn: WheelTurn) => void
256
+ readonly onKey: (event: KeyboardEvent) => void
126
257
  }): JSX.Element {
127
258
  const key = frameKey(page, frame)
128
259
  const version = frameVersions.value.get(key) ?? 0
@@ -135,6 +266,7 @@ function FrameView({
135
266
  /* Bumped when the frame document scrolls, so the outlines follow it. */
136
267
  const [, setScrolled] = useState(0)
137
268
  const doc = frameDocuments.value.get(key)
269
+ const editing = editingFrames.value.get(key)
138
270
 
139
271
  useEffect(() => {
140
272
  applyFrameTheme(iframe.current, value)
@@ -185,6 +317,16 @@ function FrameView({
185
317
  },
186
318
  { passive: false },
187
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
+ })
188
330
  }
189
331
 
190
332
  const picked =
@@ -206,6 +348,8 @@ function FrameView({
206
348
 
207
349
  const handlePointerDown = (event: PointerEvent) => {
208
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
209
353
  if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
210
354
  gesture.current = {
211
355
  startX: event.clientX,
@@ -254,7 +398,7 @@ function FrameView({
254
398
  */
255
399
  const handleKeyDown = (event: KeyboardEvent) => {
256
400
  if (event.target !== event.currentTarget) return
257
- if (event.key === 'Enter' || event.key === ' ') {
401
+ if (event.key === 'Enter') {
258
402
  event.preventDefault()
259
403
  select()
260
404
  return
@@ -263,20 +407,50 @@ function FrameView({
263
407
  const delta = ARROWS[event.key]
264
408
  if (!delta || !isSelected) return
265
409
  event.preventDefault()
410
+ /* The keyboard path for a handle drag: right and down grow the frame. */
411
+ if (event.ctrlKey || event.metaKey) {
412
+ void resizeFrameTo(ref, {
413
+ x: frame.x,
414
+ y: frame.y,
415
+ width: Math.max(1, frame.width + delta.x * step),
416
+ height: Math.max(1, frame.height + delta.y * step),
417
+ })
418
+ return
419
+ }
266
420
  void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
267
421
  }
268
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
+
433
+ const frameBox = (): OverlayBox => ({
434
+ x: frame.x,
435
+ y: frame.y,
436
+ width: frame.width,
437
+ height: frame.height,
438
+ })
439
+
269
440
  const next = value === 'dark' ? 'light' : 'dark'
270
441
  return (
271
442
  <figure
272
443
  class="frame"
273
444
  data-frame={frame.name}
274
445
  data-selected={isSelected ? 'true' : undefined}
446
+ data-picked={picked ? 'true' : undefined}
275
447
  data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
448
+ data-editing={editing ? 'true' : undefined}
276
449
  tabIndex={0}
277
- aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
450
+ aria-label={`${frame.name}, ${frame.width} by ${frame.height}${editing ? `, ${editing.by} editing` : ''}`}
278
451
  aria-current={isSelected ? 'true' : undefined}
279
452
  onKeyDown={handleKeyDown}
453
+ onKeyUp={handleKeyUp}
280
454
  style={{
281
455
  left: `${frame.x}px`,
282
456
  top: `${frame.y}px`,
@@ -297,6 +471,10 @@ function FrameView({
297
471
  <span class="frame-size">
298
472
  {frame.width} × {frame.height}
299
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>
300
478
  <button
301
479
  type="button"
302
480
  class="frame-theme"
@@ -318,10 +496,25 @@ function FrameView({
318
496
  onLoad={handleLoad}
319
497
  />
320
498
  {hoveredNode && hoveredNode !== selectedNode ? (
321
- <ElementOutline element={hoveredNode} kind="hover" />
499
+ <HoverOutline element={hoveredNode} />
500
+ ) : null}
501
+ {isSelected && !picked ? (
502
+ <SelectionOverlay
503
+ kind="frame"
504
+ measure={frameBox}
505
+ offset={{ x: frame.x, y: frame.y }}
506
+ onPreview={(box) => previewResize(ref, box)}
507
+ onCommit={(box) => void resizeFrameTo(ref, box)}
508
+ onCancel={(origin) => previewResize(ref, origin)}
509
+ />
322
510
  ) : null}
323
- {selectedNode ? (
324
- <ElementOutline element={selectedNode} kind="selected" />
511
+ {doc && selectedNode ? (
512
+ <ElementSelection
513
+ node={selectedNode}
514
+ doc={doc}
515
+ frameRef={ref}
516
+ docKey={key}
517
+ />
325
518
  ) : null}
326
519
  </div>
327
520
  </figure>
@@ -336,18 +529,35 @@ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
336
529
  return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
337
530
  }
338
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
+
339
551
  function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
340
- const zoomCentered = (factor: number) => {
341
- const { width, height } = viewportSize(viewportRef)
342
- zoomAt(factor, width / 2, height / 2)
343
- }
344
552
  return (
345
553
  <div class="toolbar" role="toolbar" aria-label="Zoom">
346
554
  <button
347
555
  type="button"
348
556
  class="icon-button"
349
557
  aria-label="Zoom out"
350
- onClick={() => zoomCentered(1 / ZOOM_STEP)}
558
+ aria-keyshortcuts="-"
559
+ title="Zoom out (-)"
560
+ onClick={() => zoomCentered(viewportRef, 1 / ZOOM_STEP)}
351
561
  >
352
562
  −
353
563
  </button>
@@ -358,17 +568,18 @@ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
358
568
  type="button"
359
569
  class="icon-button"
360
570
  aria-label="Zoom in"
361
- onClick={() => zoomCentered(ZOOM_STEP)}
571
+ aria-keyshortcuts="+"
572
+ title="Zoom in (+)"
573
+ onClick={() => zoomCentered(viewportRef, ZOOM_STEP)}
362
574
  >
363
575
  +
364
576
  </button>
365
577
  <button
366
578
  type="button"
367
579
  class="text-button"
368
- onClick={() => {
369
- const { width, height } = viewportSize(viewportRef)
370
- fitView(width, height)
371
- }}
580
+ aria-keyshortcuts="Shift+1"
581
+ title="Fit (Shift+1)"
582
+ onClick={() => fitViewport(viewportRef)}
372
583
  >
373
584
  Fit
374
585
  </button>
@@ -382,8 +593,7 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
382
593
 
383
594
  /* Fits a page once when it opens, never on a reload of the same page. */
384
595
  useEffect(() => {
385
- const { width, height } = viewportSize(viewportRef)
386
- fitView(width, height)
596
+ fitViewport(viewportRef)
387
597
  }, [page?.name])
388
598
 
389
599
  const handleWheelTurn = (turn: WheelTurn) => {
@@ -411,9 +621,68 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
411
621
  })
412
622
  }
413
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
+
414
681
  const handlePointerDown = (event: PointerEvent) => {
415
682
  const target = event.target as HTMLElement | null
416
- 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()
417
686
  drag.current = { x: event.clientX, y: event.clientY }
418
687
  viewportRef.current?.setPointerCapture?.(event.pointerId)
419
688
  }
@@ -421,22 +690,53 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
421
690
  const handlePointerMove = (event: PointerEvent) => {
422
691
  const start = drag.current
423
692
  if (!start) return
424
- 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)
425
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
+ }
426
717
  }
427
718
 
428
719
  const handlePointerUp = () => {
720
+ if (drag.current && shownTool.value === 'pan') clearSelections()
429
721
  drag.current = undefined
430
722
  }
431
723
 
432
724
  const { x, y, zoom } = view.value
433
725
  return (
434
- <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
+ >
435
734
  <div
436
735
  ref={viewportRef}
437
736
  class="viewport"
438
737
  onWheel={handleWheel}
439
738
  onPointerDown={handlePointerDown}
739
+ onMouseDown={handleMouseDown}
440
740
  onPointerMove={handlePointerMove}
441
741
  onPointerUp={handlePointerUp}
442
742
  onPointerCancel={handlePointerUp}
@@ -455,10 +755,12 @@ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
455
755
  page={page.name}
456
756
  frame={frame}
457
757
  onWheelTurn={handleWheelTurn}
758
+ onKey={handleKey}
458
759
  />
459
760
  ))}
460
761
  </div>
461
762
  </div>
763
+ <ToolStrip />
462
764
  <Toolbar viewportRef={viewportRef} />
463
765
  </main>
464
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
@@ -129,7 +129,7 @@ function refuse(reason: ContentRefusal, detail: string): ContentRefused {
129
129
  return { ok: false, reason, detail }
130
130
  }
131
131
 
132
- function isValidName(name: string): boolean {
132
+ export function isValidName(name: string): boolean {
133
133
  return NAME_PATTERN.test(name) && !name.includes('..')
134
134
  }
135
135
 
@@ -464,6 +464,55 @@ export function moveFrame(
464
464
  })
465
465
  }
466
466
 
467
+ /**
468
+ * Writes one frame's whole box. A handle on the top or left edge moves the
469
+ * frame as it resizes it, so position and size land in one locked write rather
470
+ * than a move and a resize another writer could land between.
471
+ */
472
+ export function resizeFrame(
473
+ root: string,
474
+ page: string,
475
+ frame: string,
476
+ box: Box,
477
+ ): FrameOutcome {
478
+ if (!isValidName(page)) {
479
+ return refuse('invalid-name', `${page} is not a valid page name`)
480
+ }
481
+ if (!isValidName(frame)) {
482
+ return refuse('invalid-name', `${frame} is not a valid frame name`)
483
+ }
484
+ if (!Number.isFinite(box.x) || !Number.isFinite(box.y)) {
485
+ return refuse('invalid-position', 'x and y must be numbers')
486
+ }
487
+ const isPositive = (value: number) => Number.isFinite(value) && value > 0
488
+ if (!isPositive(box.width) || !isPositive(box.height)) {
489
+ return refuse('invalid-size', 'width and height must be positive numbers')
490
+ }
491
+
492
+ const dir = pagePath(root, page)
493
+ if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
494
+ const file = `${frame}${FRAME_EXTENSION}`
495
+ if (!existsSync(join(dir, file))) {
496
+ return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
497
+ }
498
+
499
+ return withLayoutLock(dir, () => {
500
+ const { boxes, malformed } = readLayout(dir)
501
+ if (malformed) {
502
+ return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
503
+ }
504
+ const written: Box = {
505
+ x: box.x,
506
+ y: box.y,
507
+ width: box.width,
508
+ height: box.height,
509
+ }
510
+
511
+ writeLayout(dir, new Map([...boxes, [frame, written]]))
512
+ return { ok: true, page, frame, file, box: written }
513
+ })
514
+ }
515
+
467
516
  export interface Selection {
468
517
  readonly page: string
469
518
  readonly frame: string