@erclx/canon 5.25.0 → 5.27.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 (315) hide show
  1. package/README.md +4 -3
  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 +14 -1
  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/REQUIREMENT.md +6 -2
  20. package/claude/skills/context-fold/SKILL.md +9 -21
  21. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  22. package/claude/skills/context-fold/references/completion-report.md +26 -0
  23. package/claude/skills/create-rule/SKILL.md +2 -0
  24. package/claude/skills/create-skill/SKILL.md +2 -0
  25. package/claude/skills/create-standard/SKILL.md +2 -0
  26. package/claude/skills/decision-escalate/SKILL.md +2 -0
  27. package/claude/skills/deploy-app/SKILL.md +2 -0
  28. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  29. package/claude/skills/design-extract/SKILL.md +2 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  31. package/claude/skills/design-taste/SKILL.md +6 -2
  32. package/claude/skills/design-taste/references/adopted.md +40 -0
  33. package/claude/skills/design-taste/references/kinds.md +1 -1
  34. package/claude/skills/design-taste/references/systems.md +1 -7
  35. package/claude/skills/design-taste/references/tells.md +2 -24
  36. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  37. package/claude/skills/docs-sync/SKILL.md +2 -0
  38. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  39. package/claude/skills/document-health/SKILL.md +23 -0
  40. package/claude/skills/document-health/references/axes.md +12 -0
  41. package/claude/skills/draft-and-pick/SKILL.md +3 -1
  42. package/claude/skills/draft-doc/SKILL.md +2 -0
  43. package/claude/skills/draft-doc/references/wiki.md +1 -1
  44. package/claude/skills/draft-figure/SKILL.md +2 -0
  45. package/claude/skills/draft-identity/SKILL.md +3 -1
  46. package/claude/skills/draft-ready/SKILL.md +2 -0
  47. package/claude/skills/draft-screencast/SKILL.md +11 -7
  48. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  49. package/claude/skills/draft-slides/SKILL.md +33 -20
  50. package/claude/skills/git-branch/SKILL.md +2 -0
  51. package/claude/skills/git-commit/SKILL.md +2 -0
  52. package/claude/skills/git-followup/SKILL.md +2 -0
  53. package/claude/skills/git-issue/SKILL.md +2 -0
  54. package/claude/skills/git-issue/references/issue.md +10 -0
  55. package/claude/skills/git-pr/SKILL.md +2 -0
  56. package/claude/skills/git-pr/references/evidence.md +14 -6
  57. package/claude/skills/git-pr/references/labels.md +12 -0
  58. package/claude/skills/git-ship/SKILL.md +2 -0
  59. package/claude/skills/git-split/SKILL.md +21 -8
  60. package/claude/skills/git-stage/SKILL.md +2 -0
  61. package/claude/skills/git-worktree/SKILL.md +2 -0
  62. package/claude/skills/index-lookup/SKILL.md +2 -0
  63. package/claude/skills/markdown-propose/SKILL.md +2 -0
  64. package/claude/skills/markdown-propose/references/format.md +9 -0
  65. package/claude/skills/memory-capture/SKILL.md +2 -0
  66. package/claude/skills/memory-review/SKILL.md +2 -0
  67. package/claude/skills/memory-review/references/later-phases.md +7 -0
  68. package/claude/skills/plan-feature/SKILL.md +2 -0
  69. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  70. package/claude/skills/plan-intake/SKILL.md +2 -0
  71. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  72. package/claude/skills/project-commands/SKILL.md +2 -0
  73. package/claude/skills/read-frames/SKILL.md +2 -0
  74. package/claude/skills/record-screencast/SKILL.md +2 -0
  75. package/claude/skills/repo-metadata/SKILL.md +2 -0
  76. package/claude/skills/restate-plainly/SKILL.md +2 -0
  77. package/claude/skills/review-address/SKILL.md +3 -7
  78. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  79. package/claude/skills/review-branch/SKILL.md +2 -0
  80. package/claude/skills/review-craft/SKILL.md +2 -0
  81. package/claude/skills/review-craft/references/ui.md +2 -2
  82. package/claude/skills/review-pr/SKILL.md +4 -8
  83. package/claude/skills/review-pr/references/body-variants.md +52 -0
  84. package/claude/skills/review-pr/references/key-changes.md +16 -0
  85. package/claude/skills/review-pr/references/post.md +15 -43
  86. package/claude/skills/review-ui/SKILL.md +3 -1
  87. package/claude/skills/review-ui/references/post.md +4 -2
  88. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  89. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  91. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  92. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  93. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  94. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  95. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  96. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  97. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  98. package/claude/skills/role-planner/SKILL.md +2 -0
  99. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  100. package/claude/skills/role-reviewer/SKILL.md +2 -0
  101. package/claude/skills/role-worker/SKILL.md +2 -0
  102. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  103. package/claude/skills/search-craft/SKILL.md +5 -1
  104. package/claude/skills/search-craft/references/adopted.md +30 -0
  105. package/claude/skills/seed-sync/SKILL.md +2 -0
  106. package/claude/skills/session-compact/SKILL.md +2 -0
  107. package/claude/skills/session-map/SKILL.md +2 -0
  108. package/claude/skills/session-relay/SKILL.md +2 -0
  109. package/claude/skills/session-resume/SKILL.md +2 -0
  110. package/claude/skills/session-worktree/SKILL.md +3 -11
  111. package/claude/skills/session-worktree/references/install-command.md +20 -0
  112. package/claude/skills/sketch-design/SKILL.md +2 -0
  113. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  114. package/claude/skills/target-check/SKILL.md +2 -0
  115. package/claude/skills/target-setup/SKILL.md +2 -0
  116. package/claude/skills/target-setup/references/detect.md +8 -0
  117. package/claude/skills/target-setup/references/indexes.md +13 -0
  118. package/claude/skills/target-setup/references/verify.md +8 -0
  119. package/claude/skills/task-board/SKILL.md +2 -0
  120. package/claude/skills/teach-workspace/SKILL.md +5 -15
  121. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  122. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  123. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  124. package/claude/skills/test-craft/SKILL.md +2 -0
  125. package/claude/skills/test-first/SKILL.md +4 -0
  126. package/claude/skills/test-first/references/adopted.md +36 -0
  127. package/claude/skills/ui-checklist/SKILL.md +2 -0
  128. package/claude/skills/ux-audit/SKILL.md +2 -0
  129. package/claude/skills/ux-measure/SKILL.md +2 -0
  130. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  131. package/claude/skills/video-craft/SKILL.md +2 -0
  132. package/claude/skills/write-human/SKILL.md +3 -1
  133. package/claude/skills/write-human/references/machine-tells.md +13 -0
  134. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  135. package/docs/agents/canvas.md +24 -6
  136. package/docs/agents/capture.md +6 -6
  137. package/docs/agents/commands.md +5 -5
  138. package/docs/agents/index.md +5 -5
  139. package/docs/agents/install-and-sync.md +7 -0
  140. package/docs/agents/key-changes.md +1 -1
  141. package/docs/agents/pr-evidence.md +38 -15
  142. package/docs/agents/pr-local.md +19 -13
  143. package/docs/agents/pr-preview.md +44 -21
  144. package/docs/agents/records-stale.md +22 -9
  145. package/docs/agents/records.md +1 -1
  146. package/docs/agents/sandbox.md +29 -5
  147. package/docs/agents/scripting.md +8 -1
  148. package/docs/agents/skills-audit.md +11 -3
  149. package/docs/agents/slides.md +15 -3
  150. package/docs/workflow/ai-workflow.md +1 -1
  151. package/docs/workflow/index.md +1 -0
  152. package/docs/workflow/operating-model.md +5 -6
  153. package/docs/workflow/parallel-features.md +23 -2
  154. package/docs/workflow/skill-map-on-request.md +42 -0
  155. package/docs/workflow/skill-map.md +3 -34
  156. package/docs/workflow/zshrc-aliases.md +1 -1
  157. package/package.json +1 -1
  158. package/scripts/core/regen-hero.ts +1 -1
  159. package/scripts/lib/sandbox-git.sh +1 -9
  160. package/scripts/lib/sandbox-path.sh +6 -162
  161. package/scripts/sandbox-hook.sh +99 -0
  162. package/src/audits/run.ts +1 -1
  163. package/src/canvas/address.ts +12 -3
  164. package/src/canvas/capture.ts +1 -9
  165. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  166. package/src/canvas/client/inspector/color-field.tsx +398 -0
  167. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  168. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  169. package/src/canvas/client/inspector/color-space.ts +240 -0
  170. package/src/canvas/client/inspector/field.tsx +5 -1
  171. package/src/canvas/client/inspector/fields.css +547 -17
  172. package/src/canvas/client/inspector/size-field.tsx +69 -0
  173. package/src/canvas/client/inspector/values.ts +166 -1
  174. package/src/canvas/client/inspector.tsx +236 -73
  175. package/src/canvas/client/selection.tsx +197 -0
  176. package/src/canvas/client/shell.css +104 -12
  177. package/src/canvas/client/state.ts +69 -9
  178. package/src/canvas/client/surface.tsx +147 -22
  179. package/src/canvas/content.ts +50 -1
  180. package/src/canvas/edit.ts +37 -4
  181. package/src/canvas/editing.ts +203 -0
  182. package/src/canvas/global.d.ts +15 -0
  183. package/src/canvas/server.ts +149 -12
  184. package/src/canvas/tokens.ts +59 -17
  185. package/src/capture/render.ts +45 -7
  186. package/src/capture/sources.ts +43 -0
  187. package/src/census/count.ts +2 -2
  188. package/src/claude/cases/workflow.ts +5 -0
  189. package/src/claude/seeds.ts +2 -2
  190. package/src/claude/skills-audit.ts +96 -14
  191. package/src/claude/skills-drift.ts +1 -1
  192. package/src/claude/skills-families.ts +36 -0
  193. package/src/claude/skills-list.ts +76 -15
  194. package/src/commands/audits.ts +2 -2
  195. package/src/commands/canvas.ts +179 -2
  196. package/src/commands/claude.ts +94 -11
  197. package/src/commands/design.ts +142 -2
  198. package/src/commands/feedback.ts +3 -3
  199. package/src/commands/gov.ts +1 -1
  200. package/src/commands/hooks.ts +1 -1
  201. package/src/commands/indexes.ts +1 -1
  202. package/src/commands/init.ts +1 -1
  203. package/src/commands/intake.ts +1 -1
  204. package/src/commands/migrate.ts +1 -1
  205. package/src/commands/pr.ts +105 -58
  206. package/src/commands/records.ts +89 -7
  207. package/src/commands/repo.ts +1 -1
  208. package/src/commands/sandbox.ts +103 -11
  209. package/src/commands/sessions.ts +1 -1
  210. package/src/commands/slides.ts +31 -77
  211. package/src/commands/sync.ts +1 -1
  212. package/src/commands/tasks.ts +2 -2
  213. package/src/commands/teach.ts +1 -1
  214. package/src/commands/transcripts.ts +1 -1
  215. package/src/commands/worktrees.ts +1 -1
  216. package/src/comments/scan.ts +1 -1
  217. package/src/comments/trend.ts +1 -1
  218. package/src/context/audit.ts +1 -1
  219. package/src/context/citations.ts +1 -1
  220. package/src/context/classify/extract.ts +2 -2
  221. package/src/design/project-fonts.ts +392 -0
  222. package/src/design/tokens.ts +2 -18
  223. package/src/docs/read.ts +1 -1
  224. package/src/{github.ts → feedback/github.ts} +2 -2
  225. package/src/{client-commands.ts → gate/client-commands.ts} +1 -1
  226. package/src/gate/measures.ts +53 -13
  227. package/src/gate/sequencer.ts +1 -1
  228. package/src/gate/stages.ts +9 -0
  229. package/src/{git-files.ts → git/files.ts} +2 -2
  230. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  231. package/src/{worktree.ts → git/worktree.ts} +1 -1
  232. package/src/gov/citations.ts +2 -2
  233. package/src/gov/install.ts +1 -1
  234. package/src/gov/payload.ts +1 -1
  235. package/src/gov/superseded.ts +3 -3
  236. package/src/gov/test-order.ts +2 -2
  237. package/src/help.ts +1 -1
  238. package/src/hooks/post-merge.ts +1 -1
  239. package/src/indexes/walk.ts +1 -1
  240. package/src/labels/audit.ts +1 -1
  241. package/src/markdown/ceiling.ts +1 -1
  242. package/src/markdown/files.ts +1 -1
  243. package/src/migrate/apply.ts +1 -1
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +96 -43
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/record-root.ts +1 -0
  249. package/src/records/backup.ts +2 -1
  250. package/src/records/push-guard.ts +1 -1
  251. package/src/records/stale-canonical.ts +146 -0
  252. package/src/records/stale.ts +1 -1
  253. package/src/sandbox/headless/dispatch.ts +264 -0
  254. package/src/sandbox/headless/run.ts +653 -0
  255. package/src/sandbox/headless/watch.ts +145 -0
  256. package/src/sandbox/hooks.ts +128 -0
  257. package/src/sandbox/provision.ts +607 -0
  258. package/src/sandbox/tree.ts +99 -4
  259. package/src/secrets/marker.ts +2 -2
  260. package/src/secrets/scan.ts +2 -2
  261. package/src/sessions/claim.ts +1 -1
  262. package/src/sessions/resolve.ts +1 -1
  263. package/src/sessions/transfer/bundle.ts +1 -1
  264. package/src/shipped/references.ts +2 -2
  265. package/src/slides/convert/export.ts +2 -16
  266. package/src/slides/decks.ts +110 -0
  267. package/src/slides/package/fonts.ts +6 -2
  268. package/src/standards/audit.ts +2 -2
  269. package/src/standards/read.ts +1 -1
  270. package/src/sync/check.ts +2 -2
  271. package/src/sync/engine.ts +2 -2
  272. package/src/sync/git.ts +1 -1
  273. package/src/sync/history.ts +1 -1
  274. package/src/sync/reverse.ts +1 -1
  275. package/src/sync/seeds-report.ts +1 -1
  276. package/src/sync/target.ts +1 -1
  277. package/src/sync/workflow.ts +1 -1
  278. package/src/targets/pulls.ts +2 -2
  279. package/src/targets/sweep.ts +2 -2
  280. package/src/tasks/answers.ts +1 -1
  281. package/src/tasks/archive.ts +1 -1
  282. package/src/tasks/reach.ts +1 -1
  283. package/src/tasks/trunk.ts +1 -1
  284. package/src/teach/nav.ts +1 -1
  285. package/src/tooling/inject.ts +2 -2
  286. package/src/tooling/subfolder.ts +1 -1
  287. package/src/web/readme-citations.ts +2 -2
  288. package/src/worktrees/bare-flag.ts +1 -1
  289. package/src/worktrees/reclaim.ts +2 -2
  290. package/src/worktrees/remove.ts +1 -1
  291. package/standards/architecture.md +1 -0
  292. package/standards/canvas.md +3 -2
  293. package/standards/requirements.md +2 -0
  294. package/standards/skill-practice.md +2 -1
  295. package/standards/skill.md +2 -0
  296. package/standards/wiki.md +12 -3
  297. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  298. package/scripts/lib/sandbox-dispatch.sh +0 -190
  299. package/scripts/manage-sandbox.sh +0 -472
  300. package/src/slides/layouts.ts +0 -478
  301. package/src/slides/parse.ts +0 -84
  302. package/src/slides/render.ts +0 -110
  303. package/src/slides/styles.ts +0 -96
  304. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  305. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  306. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  307. /package/src/{git-base.ts → git/base.ts} +0 -0
  308. /package/src/{git-env.ts → git/env.ts} +0 -0
  309. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  310. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  311. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  312. /package/src/{copy.ts → sync/copy.ts} +0 -0
  313. /package/src/{target.ts → targets/validate.ts} +0 -0
  314. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  315. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -0,0 +1,197 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useRef, useState } from 'preact/hooks'
4
+ import { view } from '@/canvas/client/state'
5
+
6
+ /** Screen pixels a press travels before it reads as a drag and not a click. */
7
+ export const DRAG_THRESHOLD = 3
8
+
9
+ /** The smallest box a handle drag leaves, in surface units. */
10
+ const MIN_SIZE = 1
11
+
12
+ export type Corner = 'nw' | 'ne' | 'sw' | 'se'
13
+
14
+ const CORNERS: readonly Corner[] = ['nw', 'ne', 'sw', 'se']
15
+
16
+ export interface OverlayBox {
17
+ readonly x: number
18
+ readonly y: number
19
+ readonly width: number
20
+ readonly height: number
21
+ }
22
+
23
+ /**
24
+ * The box a corner drag leaves, with the delta already in surface units. The
25
+ * corner opposite the one dragged stays put, so a west or north handle moves
26
+ * the box as it resizes it.
27
+ */
28
+ export function resizedBox(
29
+ origin: OverlayBox,
30
+ corner: Corner,
31
+ dx: number,
32
+ dy: number,
33
+ ): OverlayBox {
34
+ const isWest = corner === 'nw' || corner === 'sw'
35
+ const isNorth = corner === 'nw' || corner === 'ne'
36
+ const width = Math.max(MIN_SIZE, origin.width + (isWest ? -dx : dx))
37
+ const height = Math.max(MIN_SIZE, origin.height + (isNorth ? -dy : dy))
38
+ return {
39
+ x: isWest ? origin.x + origin.width - width : origin.x,
40
+ y: isNorth ? origin.y + origin.height - height : origin.y,
41
+ width,
42
+ height,
43
+ }
44
+ }
45
+
46
+ export function sizeLabel(box: Pick<OverlayBox, 'width' | 'height'>): string {
47
+ return `${Math.round(box.width)} × ${Math.round(box.height)}`
48
+ }
49
+
50
+ interface HandleGesture {
51
+ readonly corner: Corner
52
+ readonly startX: number
53
+ readonly startY: number
54
+ readonly origin: OverlayBox
55
+ box: OverlayBox
56
+ moved: boolean
57
+ }
58
+
59
+ export interface SelectionOverlayProps {
60
+ readonly kind: 'frame' | 'element'
61
+ /** Read on every render, so a box the drag previewed is what shows after. */
62
+ readonly measure: () => OverlayBox
63
+ /** Subtracted from the box to place the overlay inside its container. */
64
+ readonly offset?: { readonly x: number; readonly y: number }
65
+ readonly hasChip?: boolean
66
+ /**
67
+ * Draws the target's own measured box during a drag rather than the dragged
68
+ * one. An element in normal flow grows from its own top left whichever corner
69
+ * is dragged, so only its measured box shows where it actually sits.
70
+ */
71
+ readonly isMeasuredWhileDragging?: boolean
72
+ readonly onStart?: () => void
73
+ readonly onPreview?: (box: OverlayBox, origin: OverlayBox) => void
74
+ readonly onCommit: (box: OverlayBox, origin: OverlayBox) => void
75
+ readonly onCancel?: (origin: OverlayBox) => void
76
+ }
77
+
78
+ /**
79
+ * The selected frame's or element's outline, its four corner handles, and an
80
+ * element's size chip. Every part sizes against `--outline-scale`, so it keeps
81
+ * one screen weight at any zoom.
82
+ */
83
+ export function SelectionOverlay({
84
+ kind,
85
+ measure,
86
+ offset = { x: 0, y: 0 },
87
+ hasChip = false,
88
+ isMeasuredWhileDragging = false,
89
+ onStart,
90
+ onPreview,
91
+ onCommit,
92
+ onCancel,
93
+ }: SelectionOverlayProps): JSX.Element {
94
+ const gesture = useRef<HandleGesture | undefined>(undefined)
95
+ const [dragged, setDragged] = useState<OverlayBox | undefined>(undefined)
96
+ const box = dragged ?? measure()
97
+
98
+ const handlePointerDown = (corner: Corner) => (event: PointerEvent) => {
99
+ if (event.button !== 0) return
100
+ event.preventDefault()
101
+ event.stopPropagation()
102
+ const origin = measure()
103
+ gesture.current = {
104
+ corner,
105
+ startX: event.clientX,
106
+ startY: event.clientY,
107
+ origin,
108
+ box: origin,
109
+ moved: false,
110
+ }
111
+ ;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
112
+ }
113
+
114
+ const handlePointerMove = (event: PointerEvent) => {
115
+ const current = gesture.current
116
+ if (!current) return
117
+ const dx = event.clientX - current.startX
118
+ const dy = event.clientY - current.startY
119
+ if (!current.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
120
+ if (!current.moved) onStart?.()
121
+ current.moved = true
122
+ /* The delta is in screen pixels and the box in surface units. */
123
+ const { zoom } = view.value
124
+ current.box = resizedBox(
125
+ current.origin,
126
+ current.corner,
127
+ Math.round(dx / zoom),
128
+ Math.round(dy / zoom),
129
+ )
130
+ onPreview?.(current.box, current.origin)
131
+ setDragged(isMeasuredWhileDragging ? measure() : current.box)
132
+ }
133
+
134
+ const handlePointerUp = () => {
135
+ const current = gesture.current
136
+ gesture.current = undefined
137
+ if (current?.moved) onCommit(current.box, current.origin)
138
+ setDragged(undefined)
139
+ }
140
+
141
+ const handlePointerCancel = () => {
142
+ const current = gesture.current
143
+ gesture.current = undefined
144
+ if (current?.moved) onCancel?.(current.origin)
145
+ setDragged(undefined)
146
+ }
147
+
148
+ return (
149
+ <div
150
+ class="selection"
151
+ data-selection={kind}
152
+ data-resizing={dragged ? 'true' : undefined}
153
+ style={{
154
+ left: `${box.x - offset.x}px`,
155
+ top: `${box.y - offset.y}px`,
156
+ width: `${box.width}px`,
157
+ height: `${box.height}px`,
158
+ }}
159
+ >
160
+ {CORNERS.map((corner) => (
161
+ <span
162
+ key={corner}
163
+ class="selection-handle"
164
+ data-handle={corner}
165
+ aria-hidden="true"
166
+ onPointerDown={handlePointerDown(corner)}
167
+ onPointerMove={handlePointerMove}
168
+ onPointerUp={handlePointerUp}
169
+ onPointerCancel={handlePointerCancel}
170
+ />
171
+ ))}
172
+ {hasChip ? (
173
+ <span class="selection-chip" aria-hidden="true">
174
+ {sizeLabel(box)}
175
+ </span>
176
+ ) : null}
177
+ </div>
178
+ )
179
+ }
180
+
181
+ /** The element under the pointer, drawn without handles. */
182
+ export function HoverOutline({ element }: { readonly element: Element }) {
183
+ const rect = element.getBoundingClientRect()
184
+ return (
185
+ <div
186
+ class="element-outline"
187
+ data-outline="hover"
188
+ aria-hidden="true"
189
+ style={{
190
+ left: `${rect.left}px`,
191
+ top: `${rect.top}px`,
192
+ width: `${rect.width}px`,
193
+ height: `${rect.height}px`,
194
+ }}
195
+ />
196
+ )
197
+ }
@@ -237,14 +237,32 @@ code {
237
237
  right: 0;
238
238
  display: flex;
239
239
  align-items: center;
240
- gap: var(--space-sm);
241
- padding-bottom: var(--space-sm);
242
- font-size: 1.5rem;
240
+ /*
241
+ * Sized against the zoom, so the label reads at one screen size at any zoom.
242
+ * A row too short for all three parts wraps the theme switch and then the
243
+ * size onto lines the clip hides, so the name keeps the row longest.
244
+ */
245
+ flex-wrap: wrap;
246
+ align-content: flex-start;
247
+ column-gap: calc(6px * var(--outline-scale, 1));
248
+ row-gap: calc(40px * var(--outline-scale, 1));
249
+ height: calc(25px * var(--outline-scale, 1));
250
+ overflow: clip;
251
+ overflow-clip-margin: calc(4px * var(--outline-scale, 1));
252
+ padding-bottom: calc(6px * var(--outline-scale, 1));
253
+ font-size: calc(11px * var(--outline-scale, 1));
254
+ line-height: 1.3;
243
255
  color: var(--color-text-secondary);
244
256
  white-space: nowrap;
245
257
  }
246
258
 
259
+ /* A theme switch focused on a hidden line shows rather than staying hidden. */
260
+ .frame-label:focus-within {
261
+ overflow: visible;
262
+ }
263
+
247
264
  .frame-name {
265
+ min-width: 0;
248
266
  overflow: hidden;
249
267
  text-overflow: ellipsis;
250
268
  color: var(--color-text);
@@ -257,8 +275,9 @@ code {
257
275
 
258
276
  .frame-theme {
259
277
  margin-left: auto;
260
- padding: 2px var(--space-sm);
261
- border: 1px solid var(--color-border);
278
+ padding: calc(1px * var(--outline-scale, 1))
279
+ calc(6px * var(--outline-scale, 1));
280
+ border: calc(1px * var(--outline-scale, 1)) solid var(--color-border);
262
281
  border-radius: var(--radius-pill);
263
282
  background: var(--color-surface);
264
283
  color: var(--color-text-secondary);
@@ -301,18 +320,91 @@ code {
301
320
  outline: calc(1px * var(--outline-scale, 1)) dashed var(--color-accent);
302
321
  }
303
322
 
304
- .element-outline[data-outline='selected'] {
305
- outline: calc(2px * var(--outline-scale, 1)) solid var(--color-accent);
306
- }
307
-
308
323
  /* Above its neighbors, so a frame dragged over another stays whole. */
309
324
  .frame[data-selected='true'] {
310
325
  z-index: 1;
311
326
  }
312
327
 
313
- .frame[data-selected='true'] iframe {
314
- outline: 2px solid var(--color-accent);
315
- outline-offset: 2px;
328
+ /* A selected frame names itself in the accent, and a picked element does not. */
329
+ .frame[data-selected='true']:not([data-picked]) .frame-name {
330
+ color: var(--color-accent);
331
+ font-weight: 600;
332
+ }
333
+
334
+ /*
335
+ * The selected frame's or element's outline, handles, and size chip. Every
336
+ * length multiplies by the outline scale, so each holds one screen size.
337
+ */
338
+ .selection {
339
+ --handle: calc(8px * var(--outline-scale, 1));
340
+ --handle-hit: calc(24px * var(--outline-scale, 1));
341
+ position: absolute;
342
+ pointer-events: none;
343
+ outline: calc(1px * var(--outline-scale, 1)) solid var(--color-accent);
344
+ }
345
+
346
+ /* Inset by the iframe border, so the box lines up with the element it marks. */
347
+ .selection[data-selection='element'] {
348
+ translate: 1px 1px;
349
+ }
350
+
351
+ /* A 24 pixel hit area around an 8 pixel square, per the target size minimum. */
352
+ .selection-handle {
353
+ position: absolute;
354
+ width: var(--handle-hit);
355
+ height: var(--handle-hit);
356
+ margin: calc(var(--handle-hit) / -2);
357
+ pointer-events: auto;
358
+ touch-action: none;
359
+ }
360
+
361
+ .selection-handle::after {
362
+ content: '';
363
+ position: absolute;
364
+ inset: calc((var(--handle-hit) - var(--handle)) / 2);
365
+ border: calc(1px * var(--outline-scale, 1)) solid var(--color-accent);
366
+ background: var(--color-background);
367
+ }
368
+
369
+ .selection-handle[data-handle='nw'] {
370
+ top: 0;
371
+ left: 0;
372
+ cursor: nwse-resize;
373
+ }
374
+
375
+ .selection-handle[data-handle='ne'] {
376
+ top: 0;
377
+ left: 100%;
378
+ cursor: nesw-resize;
379
+ }
380
+
381
+ .selection-handle[data-handle='sw'] {
382
+ top: 100%;
383
+ left: 0;
384
+ cursor: nesw-resize;
385
+ }
386
+
387
+ .selection-handle[data-handle='se'] {
388
+ top: 100%;
389
+ left: 100%;
390
+ cursor: nwse-resize;
391
+ }
392
+
393
+ .selection-chip {
394
+ position: absolute;
395
+ top: calc(100% + 8px * var(--outline-scale, 1));
396
+ left: 50%;
397
+ translate: -50% 0;
398
+ padding: calc(2px * var(--outline-scale, 1))
399
+ calc(5px * var(--outline-scale, 1));
400
+ border-radius: calc(3px * var(--outline-scale, 1));
401
+ background: var(--color-accent);
402
+ color: var(--color-background);
403
+ font-family: var(--type-code-family);
404
+ font-size: calc(11px * var(--outline-scale, 1));
405
+ line-height: 1.3;
406
+ font-variant-numeric: tabular-nums;
407
+ white-space: nowrap;
316
408
  }
317
409
 
318
410
  .frame:focus-visible {
@@ -1,6 +1,6 @@
1
1
  import { computed, signal } from '@preact/signals'
2
2
  import type { ElementAddress } from '@/canvas/address'
3
- import type { Frame, Page } from '@/canvas/content'
3
+ import type { Box, Frame, Page } from '@/canvas/content'
4
4
  import type { TokenGroup } from '@/canvas/tokens'
5
5
 
6
6
  /**
@@ -320,7 +320,8 @@ const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
320
320
  /**
321
321
  * Posts one property change to the element the address names. A refusal
322
322
  * says why and reloads the frame, so a pending value never sits over a file
323
- * that moved under it.
323
+ * that moved under it. Resolves to what the write answered, or undefined when
324
+ * nothing was written.
324
325
  */
325
326
  export async function editElement(
326
327
  ref: FrameRef,
@@ -329,7 +330,7 @@ export async function editElement(
329
330
  property: string,
330
331
  value: string,
331
332
  fetchImpl: typeof fetch = fetch,
332
- ): Promise<void> {
333
+ ): Promise<{ readonly hash?: string } | undefined> {
333
334
  const hash = editedHashes.value.get(key) ?? address.hash
334
335
  pendingEdit.value = { key, index: address.index, property }
335
336
  try {
@@ -358,7 +359,7 @@ export async function editElement(
358
359
  : `Could not save (status ${response.status}). Check canon canvas serve is still running.`)
359
360
  reloadFrame(key)
360
361
  await loadPages(fetchImpl)
361
- return
362
+ return undefined
362
363
  }
363
364
  editRefusal.value = undefined
364
365
  const saved = pendingEdit.value
@@ -366,18 +367,51 @@ export async function editElement(
366
367
  setTimeout(() => {
367
368
  if (savedEdit.value === saved) savedEdit.value = undefined
368
369
  }, SAVED_MS)
369
- if (typeof record.hash === 'string') {
370
- const hashes = new Map(editedHashes.value)
371
- hashes.set(key, record.hash)
372
- editedHashes.value = hashes
373
- }
370
+ if (typeof record.hash !== 'string') return {}
371
+ const hashes = new Map(editedHashes.value)
372
+ hashes.set(key, record.hash)
373
+ editedHashes.value = hashes
374
+ return { hash: record.hash }
374
375
  } catch (error) {
375
376
  editRefusal.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
377
+ return undefined
376
378
  } finally {
377
379
  pendingEdit.value = undefined
378
380
  }
379
381
  }
380
382
 
383
+ /**
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.
388
+ */
389
+ export async function resizeElement(
390
+ ref: FrameRef,
391
+ key: string,
392
+ address: ElementAddress,
393
+ width: number | undefined,
394
+ height: number | undefined,
395
+ fetchImpl: typeof fetch = fetch,
396
+ ): Promise<void> {
397
+ let current = address
398
+ if (width !== undefined) {
399
+ const written = await editElement(
400
+ ref,
401
+ key,
402
+ current,
403
+ 'width',
404
+ `${width}px`,
405
+ fetchImpl,
406
+ )
407
+ if (!written) return
408
+ if (written.hash) current = { ...current, hash: written.hash }
409
+ }
410
+ if (height !== undefined) {
411
+ await editElement(ref, key, current, 'height', `${height}px`, fetchImpl)
412
+ }
413
+ }
414
+
381
415
  /**
382
416
  * Moves a frame on the surface without writing, which a drag calls on every
383
417
  * pointer move. The next reread replaces it with what the layout holds.
@@ -406,6 +440,32 @@ export async function moveFrameTo(
406
440
  await write('/api/frames/move', { ...ref, x, y }, fetchImpl)
407
441
  }
408
442
 
443
+ /** Resizes a frame on the surface without writing, as a handle drag does. */
444
+ export function previewResize(ref: FrameRef, box: Box): void {
445
+ pages.value = pages.value.map((candidate) =>
446
+ candidate.name === ref.page
447
+ ? {
448
+ ...candidate,
449
+ frames: candidate.frames.map((frame) =>
450
+ frame.name === ref.frame
451
+ ? { ...frame, ...box, placed: true }
452
+ : frame,
453
+ ),
454
+ }
455
+ : candidate,
456
+ )
457
+ }
458
+
459
+ /** Shows the new box and writes it through the server's shared layout writer. */
460
+ export async function resizeFrameTo(
461
+ ref: FrameRef,
462
+ box: Box,
463
+ fetchImpl: typeof fetch = fetch,
464
+ ): Promise<void> {
465
+ previewResize(ref, box)
466
+ await write('/api/frames/resize', { ...ref, ...box }, fetchImpl)
467
+ }
468
+
409
469
  export function clampZoom(zoom: number): number {
410
470
  return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
411
471
  }
@@ -16,7 +16,10 @@ import {
16
16
  moveFrameTo,
17
17
  panBy,
18
18
  previewMove,
19
+ previewResize,
19
20
  registerFrameDocument,
21
+ resizeElement,
22
+ resizeFrameTo,
20
23
  selectElement,
21
24
  selectFrame,
22
25
  selection,
@@ -25,13 +28,16 @@ import {
25
28
  view,
26
29
  zoomAt,
27
30
  } from '@/canvas/client/state'
31
+ import {
32
+ DRAG_THRESHOLD,
33
+ HoverOutline,
34
+ type OverlayBox,
35
+ SelectionOverlay,
36
+ } from '@/canvas/client/selection'
28
37
 
29
38
  /** One wheel notch or one button press. */
30
39
  const ZOOM_STEP = 1.2
31
40
 
32
- /** Screen pixels a press travels before it reads as a drag and not a click. */
33
- const DRAG_THRESHOLD = 3
34
-
35
41
  /** Surface units one arrow press moves a frame, and one with Shift held. */
36
42
  const NUDGE = 10
37
43
  const NUDGE_LARGE = 50
@@ -76,25 +82,111 @@ function asElement(target: EventTarget | null): Element | undefined {
76
82
  return node?.nodeType === 1 ? (node as Element) : undefined
77
83
  }
78
84
 
79
- function ElementOutline({
80
- element,
81
- kind,
85
+ /** A computed length in pixels, or the fallback for one that is not. */
86
+ function pixels(value: string, fallback: number): number {
87
+ const parsed = Number.parseFloat(value)
88
+ return value.endsWith('px') && Number.isFinite(parsed) ? parsed : fallback
89
+ }
90
+
91
+ /**
92
+ * The selected element's overlay. A handle drag previews the size as inline
93
+ * style on the element and writes it once on release, the way the inspector's
94
+ * scrub does. The drag measures the border box while `width` and `height` set
95
+ * whatever box the element sizes by, so the delta is added to the computed
96
+ * value rather than written as the box itself.
97
+ */
98
+ function ElementSelection({
99
+ node,
100
+ doc,
101
+ frameRef,
102
+ docKey,
82
103
  }: {
83
- readonly element: Element
84
- readonly kind: 'hover' | 'selected'
104
+ readonly node: Element
105
+ readonly doc: Document
106
+ readonly frameRef: FrameRef
107
+ readonly docKey: string
85
108
  }): JSX.Element {
86
- const rect = element.getBoundingClientRect()
109
+ const start = useRef<
110
+ | {
111
+ readonly width: number
112
+ readonly height: number
113
+ readonly inlineWidth: string
114
+ readonly inlineHeight: string
115
+ }
116
+ | undefined
117
+ >(undefined)
118
+ const style = (node as HTMLElement).style
119
+
120
+ const measure = () => {
121
+ const rect = node.getBoundingClientRect()
122
+ return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
123
+ }
124
+
125
+ const cssSize = (box: OverlayBox, origin: OverlayBox) => {
126
+ const from = start.current
127
+ return {
128
+ width: Math.round(
129
+ (from?.width ?? origin.width) + box.width - origin.width,
130
+ ),
131
+ height: Math.round(
132
+ (from?.height ?? origin.height) + box.height - origin.height,
133
+ ),
134
+ }
135
+ }
136
+
137
+ const handleStart = () => {
138
+ const computed = doc.defaultView?.getComputedStyle(node)
139
+ const rect = node.getBoundingClientRect()
140
+ start.current = {
141
+ width: pixels(computed?.width ?? '', rect.width),
142
+ height: pixels(computed?.height ?? '', rect.height),
143
+ inlineWidth: style.getPropertyValue('width'),
144
+ inlineHeight: style.getPropertyValue('height'),
145
+ }
146
+ }
147
+
148
+ const handlePreview = (box: OverlayBox, from: OverlayBox) => {
149
+ const size = cssSize(box, from)
150
+ style.setProperty('width', `${size.width}px`)
151
+ style.setProperty('height', `${size.height}px`)
152
+ }
153
+
154
+ const restore = () => {
155
+ const from = start.current
156
+ if (!from) return
157
+ style.setProperty('width', from.inlineWidth)
158
+ style.setProperty('height', from.inlineHeight)
159
+ }
160
+
161
+ const handleCommit = (box: OverlayBox, from: OverlayBox) => {
162
+ const size = cssSize(box, from)
163
+ const address = addressOf(doc, node)
164
+ const isUnchanged = box.width === from.width && box.height === from.height
165
+ if (!address || isUnchanged) {
166
+ restore()
167
+ return
168
+ }
169
+ void resizeElement(
170
+ frameRef,
171
+ docKey,
172
+ address,
173
+ box.width === from.width ? undefined : size.width,
174
+ box.height === from.height ? undefined : size.height,
175
+ )
176
+ }
177
+
178
+ const handleCancel = restore
179
+
87
180
  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
- }}
181
+ <SelectionOverlay
182
+ kind="element"
183
+ measure={measure}
184
+ hasChip
185
+ isMeasuredWhileDragging
186
+ onStart={handleStart}
187
+ onPreview={handlePreview}
188
+ onCommit={handleCommit}
189
+ onCancel={handleCancel}
98
190
  />
99
191
  )
100
192
  }
@@ -263,15 +355,33 @@ function FrameView({
263
355
  const delta = ARROWS[event.key]
264
356
  if (!delta || !isSelected) return
265
357
  event.preventDefault()
358
+ /* The keyboard path for a handle drag: right and down grow the frame. */
359
+ if (event.ctrlKey || event.metaKey) {
360
+ void resizeFrameTo(ref, {
361
+ x: frame.x,
362
+ y: frame.y,
363
+ width: Math.max(1, frame.width + delta.x * step),
364
+ height: Math.max(1, frame.height + delta.y * step),
365
+ })
366
+ return
367
+ }
266
368
  void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
267
369
  }
268
370
 
371
+ const frameBox = (): OverlayBox => ({
372
+ x: frame.x,
373
+ y: frame.y,
374
+ width: frame.width,
375
+ height: frame.height,
376
+ })
377
+
269
378
  const next = value === 'dark' ? 'light' : 'dark'
270
379
  return (
271
380
  <figure
272
381
  class="frame"
273
382
  data-frame={frame.name}
274
383
  data-selected={isSelected ? 'true' : undefined}
384
+ data-picked={picked ? 'true' : undefined}
275
385
  data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
276
386
  tabIndex={0}
277
387
  aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
@@ -318,10 +428,25 @@ function FrameView({
318
428
  onLoad={handleLoad}
319
429
  />
320
430
  {hoveredNode && hoveredNode !== selectedNode ? (
321
- <ElementOutline element={hoveredNode} kind="hover" />
431
+ <HoverOutline element={hoveredNode} />
432
+ ) : null}
433
+ {isSelected && !picked ? (
434
+ <SelectionOverlay
435
+ kind="frame"
436
+ measure={frameBox}
437
+ offset={{ x: frame.x, y: frame.y }}
438
+ onPreview={(box) => previewResize(ref, box)}
439
+ onCommit={(box) => void resizeFrameTo(ref, box)}
440
+ onCancel={(origin) => previewResize(ref, origin)}
441
+ />
322
442
  ) : null}
323
- {selectedNode ? (
324
- <ElementOutline element={selectedNode} kind="selected" />
443
+ {doc && selectedNode ? (
444
+ <ElementSelection
445
+ node={selectedNode}
446
+ doc={doc}
447
+ frameRef={ref}
448
+ docKey={key}
449
+ />
325
450
  ) : null}
326
451
  </div>
327
452
  </figure>