@erclx/canon 5.26.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 (242) 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 +2 -0
  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 +6 -6
  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/SKILL.md +3 -7
  63. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  64. package/claude/skills/review-branch/SKILL.md +2 -0
  65. package/claude/skills/review-craft/SKILL.md +2 -0
  66. package/claude/skills/review-craft/references/ui.md +2 -2
  67. package/claude/skills/review-pr/SKILL.md +4 -8
  68. package/claude/skills/review-pr/references/body-variants.md +52 -0
  69. package/claude/skills/review-pr/references/key-changes.md +16 -0
  70. package/claude/skills/review-pr/references/post.md +15 -43
  71. package/claude/skills/review-ui/SKILL.md +3 -1
  72. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  73. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  74. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  75. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  76. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  77. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  78. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  80. package/claude/skills/role-planner/SKILL.md +2 -0
  81. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  82. package/claude/skills/role-reviewer/SKILL.md +2 -0
  83. package/claude/skills/role-worker/SKILL.md +2 -0
  84. package/claude/skills/search-craft/SKILL.md +2 -0
  85. package/claude/skills/seed-sync/SKILL.md +2 -0
  86. package/claude/skills/session-compact/SKILL.md +2 -0
  87. package/claude/skills/session-map/SKILL.md +2 -0
  88. package/claude/skills/session-relay/SKILL.md +2 -0
  89. package/claude/skills/session-resume/SKILL.md +2 -0
  90. package/claude/skills/session-worktree/SKILL.md +3 -11
  91. package/claude/skills/session-worktree/references/install-command.md +20 -0
  92. package/claude/skills/sketch-design/SKILL.md +2 -0
  93. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  94. package/claude/skills/target-check/SKILL.md +2 -0
  95. package/claude/skills/target-setup/SKILL.md +2 -0
  96. package/claude/skills/task-board/SKILL.md +2 -0
  97. package/claude/skills/teach-workspace/SKILL.md +5 -15
  98. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  99. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  100. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  101. package/claude/skills/test-craft/SKILL.md +2 -0
  102. package/claude/skills/test-first/SKILL.md +2 -0
  103. package/claude/skills/ui-checklist/SKILL.md +2 -0
  104. package/claude/skills/ux-audit/SKILL.md +2 -0
  105. package/claude/skills/ux-measure/SKILL.md +2 -0
  106. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  107. package/claude/skills/video-craft/SKILL.md +2 -0
  108. package/claude/skills/write-human/SKILL.md +2 -0
  109. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  110. package/docs/agents/canvas.md +23 -7
  111. package/docs/agents/capture.md +5 -5
  112. package/docs/agents/commands.md +2 -2
  113. package/docs/agents/index.md +3 -3
  114. package/docs/agents/install-and-sync.md +7 -0
  115. package/docs/agents/key-changes.md +1 -1
  116. package/docs/agents/pr-evidence.md +38 -15
  117. package/docs/agents/pr-local.md +19 -13
  118. package/docs/agents/pr-preview.md +44 -21
  119. package/docs/agents/sandbox.md +28 -4
  120. package/docs/agents/scripting.md +8 -1
  121. package/docs/agents/skills-audit.md +2 -1
  122. package/docs/workflow/operating-model.md +5 -6
  123. package/docs/workflow/parallel-features.md +22 -1
  124. package/docs/workflow/skill-map.md +3 -1
  125. package/docs/workflow/zshrc-aliases.md +1 -1
  126. package/package.json +1 -1
  127. package/scripts/core/regen-hero.ts +1 -1
  128. package/scripts/lib/sandbox-path.sh +6 -162
  129. package/src/audits/run.ts +1 -1
  130. package/src/canvas/address.ts +12 -3
  131. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  132. package/src/canvas/client/inspector/color-field.tsx +201 -171
  133. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  134. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  135. package/src/canvas/client/inspector/color-space.ts +240 -0
  136. package/src/canvas/client/inspector/field.tsx +5 -1
  137. package/src/canvas/client/inspector/fields.css +395 -6
  138. package/src/canvas/client/inspector/size-field.tsx +69 -0
  139. package/src/canvas/client/inspector/values.ts +43 -1
  140. package/src/canvas/client/inspector.tsx +190 -4
  141. package/src/canvas/client/selection.tsx +197 -0
  142. package/src/canvas/client/shell.css +104 -12
  143. package/src/canvas/client/state.ts +69 -9
  144. package/src/canvas/client/surface.tsx +147 -22
  145. package/src/canvas/content.ts +50 -1
  146. package/src/canvas/edit.ts +37 -4
  147. package/src/canvas/editing.ts +203 -0
  148. package/src/canvas/global.d.ts +15 -0
  149. package/src/canvas/server.ts +149 -12
  150. package/src/canvas/tokens.ts +59 -17
  151. package/src/census/count.ts +1 -1
  152. package/src/claude/seeds.ts +1 -1
  153. package/src/claude/skills-audit.ts +28 -0
  154. package/src/claude/skills-drift.ts +1 -1
  155. package/src/claude/skills-families.ts +36 -0
  156. package/src/claude/skills-list.ts +76 -15
  157. package/src/commands/audits.ts +2 -2
  158. package/src/commands/canvas.ts +179 -2
  159. package/src/commands/claude.ts +67 -9
  160. package/src/commands/design.ts +142 -2
  161. package/src/commands/gov.ts +1 -1
  162. package/src/commands/hooks.ts +1 -1
  163. package/src/commands/indexes.ts +1 -1
  164. package/src/commands/init.ts +1 -1
  165. package/src/commands/intake.ts +1 -1
  166. package/src/commands/migrate.ts +1 -1
  167. package/src/commands/pr.ts +105 -58
  168. package/src/commands/records.ts +1 -1
  169. package/src/commands/repo.ts +1 -1
  170. package/src/commands/sandbox.ts +54 -2
  171. package/src/commands/sessions.ts +1 -1
  172. package/src/commands/sync.ts +1 -1
  173. package/src/commands/tasks.ts +2 -2
  174. package/src/commands/teach.ts +1 -1
  175. package/src/commands/transcripts.ts +1 -1
  176. package/src/commands/worktrees.ts +1 -1
  177. package/src/comments/scan.ts +1 -1
  178. package/src/comments/trend.ts +1 -1
  179. package/src/context/citations.ts +1 -1
  180. package/src/context/classify/extract.ts +2 -2
  181. package/src/design/project-fonts.ts +392 -0
  182. package/src/feedback/github.ts +1 -1
  183. package/src/gate/measures.ts +47 -9
  184. package/src/gate/sequencer.ts +1 -1
  185. package/src/gate/stages.ts +9 -0
  186. package/src/{git-files.ts → git/files.ts} +2 -2
  187. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  188. package/src/{worktree.ts → git/worktree.ts} +1 -1
  189. package/src/gov/citations.ts +1 -1
  190. package/src/gov/install.ts +1 -1
  191. package/src/gov/superseded.ts +1 -1
  192. package/src/gov/test-order.ts +2 -2
  193. package/src/help.ts +1 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/indexes/walk.ts +1 -1
  196. package/src/labels/audit.ts +1 -1
  197. package/src/markdown/files.ts +1 -1
  198. package/src/migrate/apply.ts +1 -1
  199. package/src/pr/bijection.ts +1 -1
  200. package/src/pr/evidence.ts +96 -43
  201. package/src/pr/local.ts +16 -10
  202. package/src/pr/preview.ts +78 -2
  203. package/src/records/backup.ts +1 -1
  204. package/src/records/stale-canonical.ts +1 -1
  205. package/src/sandbox/headless/dispatch.ts +264 -0
  206. package/src/sandbox/headless/run.ts +653 -0
  207. package/src/sandbox/headless/watch.ts +145 -0
  208. package/src/sandbox/provision.ts +12 -1
  209. package/src/sandbox/tree.ts +4 -8
  210. package/src/secrets/scan.ts +1 -1
  211. package/src/sessions/claim.ts +1 -1
  212. package/src/sessions/resolve.ts +1 -1
  213. package/src/sessions/transfer/bundle.ts +1 -1
  214. package/src/slides/package/fonts.ts +6 -2
  215. package/src/standards/audit.ts +2 -2
  216. package/src/sync/check.ts +2 -2
  217. package/src/sync/engine.ts +2 -2
  218. package/src/sync/git.ts +1 -1
  219. package/src/sync/history.ts +1 -1
  220. package/src/sync/reverse.ts +1 -1
  221. package/src/sync/target.ts +1 -1
  222. package/src/sync/workflow.ts +1 -1
  223. package/src/targets/pulls.ts +2 -2
  224. package/src/targets/sweep.ts +2 -2
  225. package/src/tasks/reach.ts +1 -1
  226. package/src/tasks/trunk.ts +1 -1
  227. package/src/tooling/inject.ts +1 -1
  228. package/src/tooling/subfolder.ts +1 -1
  229. package/src/web/readme-citations.ts +1 -1
  230. package/src/worktrees/bare-flag.ts +1 -1
  231. package/src/worktrees/reclaim.ts +2 -2
  232. package/src/worktrees/remove.ts +1 -1
  233. package/standards/canvas.md +2 -1
  234. package/standards/skill.md +1 -0
  235. package/standards/wiki.md +12 -3
  236. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  237. package/scripts/lib/sandbox-dispatch.sh +0 -190
  238. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  239. /package/src/{git-base.ts → git/base.ts} +0 -0
  240. /package/src/{git-env.ts → git/env.ts} +0 -0
  241. /package/src/{copy.ts → sync/copy.ts} +0 -0
  242. /package/src/{target.ts → targets/validate.ts} +0 -0
@@ -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>
@@ -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
@@ -31,11 +31,38 @@ export const STYLE_PROPERTIES = [
31
31
  'height',
32
32
  'padding',
33
33
  'gap',
34
+ 'display',
34
35
  'flex-direction',
36
+ 'justify-content',
37
+ 'align-items',
38
+ 'flex-wrap',
39
+ 'opacity',
40
+ 'border-radius',
41
+ 'border-top-left-radius',
42
+ 'border-top-right-radius',
43
+ 'border-bottom-right-radius',
44
+ 'border-bottom-left-radius',
35
45
  ] as const
36
46
 
37
47
  export type StyleProperty = (typeof STYLE_PROPERTIES)[number]
38
48
 
49
+ /**
50
+ * The longhands a shorthand write drops, so a later longhand in the attribute
51
+ * does not keep overriding the value just set. A longhand write leaves its
52
+ * shorthand alone, since the longhand comes later and already wins.
53
+ */
54
+ const SHORTHAND_LONGHANDS: Readonly<
55
+ Partial<Record<StyleProperty, readonly string[]>>
56
+ > = {
57
+ 'border-radius': [
58
+ 'border-top-left-radius',
59
+ 'border-top-right-radius',
60
+ 'border-bottom-right-radius',
61
+ 'border-bottom-left-radius',
62
+ ],
63
+ padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
64
+ }
65
+
39
66
  export type EditProperty = StyleProperty | 'text'
40
67
 
41
68
  export interface EditChange {
@@ -139,15 +166,18 @@ function propertyOf(segment: string): string {
139
166
  /**
140
167
  * Sets one property in a style attribute, or drops it on an empty value. The
141
168
  * first declaration of it is replaced in place and any later one dropped, so
142
- * nothing further in the attribute overrides the edit. Undefined means no
143
- * style is left.
169
+ * nothing further in the attribute overrides the edit. A shorthand also drops
170
+ * its longhands. Undefined means no style is left.
144
171
  */
145
172
  export function setDeclaration(
146
173
  style: string,
147
174
  property: StyleProperty,
148
175
  value: string,
149
176
  ): string | undefined {
150
- const segments = declarations(style)
177
+ const longhands = SHORTHAND_LONGHANDS[property] ?? []
178
+ const segments = declarations(style).filter(
179
+ (segment) => !longhands.includes(propertyOf(segment)),
180
+ )
151
181
  const matches = segments.filter((segment) => propertyOf(segment) === property)
152
182
  const declaration = `${property}: ${value.trim()}`
153
183
 
@@ -164,7 +194,10 @@ export function setDeclaration(
164
194
  return [`${lead}${declaration}`]
165
195
  })
166
196
  } else {
167
- const kept = style.replace(/[\s;]*$/, '')
197
+ const kept = segments
198
+ .join(';')
199
+ .replace(/^[\s;]+/, '')
200
+ .replace(/[\s;]*$/, '')
168
201
  return kept === '' ? declaration : `${kept}; ${declaration}`
169
202
  }
170
203