@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
@@ -0,0 +1,69 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { Field, type Scrub } from '@/canvas/client/inspector/field'
4
+
5
+ /** The modes a size takes besides a number, by the name the field shows. */
6
+ const MODES = ['Fill', 'Fit'] as const
7
+
8
+ interface SizeFieldProps {
9
+ readonly label: string
10
+ readonly glyph: string
11
+ /** The value as the panel shows it, a mode name or a number. */
12
+ readonly initial: string
13
+ readonly isBusy: boolean
14
+ /** Takes a typed number or a picked mode name, each as shown. */
15
+ readonly onCommit: (value: string) => void
16
+ readonly scrub?: Scrub
17
+ }
18
+
19
+ /**
20
+ * A size that takes a number or a mode. The chevron is a native select, so
21
+ * its list opens above the panel's clipping and answers the keyboard as the
22
+ * platform does, and typing a number replaces the mode.
23
+ */
24
+ export function SizeField({
25
+ label,
26
+ glyph,
27
+ initial,
28
+ isBusy,
29
+ onCommit,
30
+ scrub,
31
+ }: SizeFieldProps): JSX.Element {
32
+ const mode = (MODES as readonly string[]).includes(initial) ? initial : ''
33
+ return (
34
+ <Field
35
+ label={label}
36
+ glyph={glyph}
37
+ initial={initial}
38
+ isBusy={isBusy}
39
+ onCommit={onCommit}
40
+ scrub={scrub}
41
+ trailing={
42
+ <span class="size-mode">
43
+ <select
44
+ class="size-mode-select"
45
+ aria-label={`${label} mode`}
46
+ value={mode}
47
+ disabled={isBusy}
48
+ onChange={(event) => {
49
+ const picked = event.currentTarget.value
50
+ if (picked && picked !== mode) onCommit(picked)
51
+ }}
52
+ >
53
+ <option value="" disabled hidden>
54
+ Fixed
55
+ </option>
56
+ {MODES.map((name) => (
57
+ <option key={name} value={name}>
58
+ {name}
59
+ </option>
60
+ ))}
61
+ </select>
62
+ <svg class="size-mode-chevron" viewBox="0 0 16 16" aria-hidden="true">
63
+ <path d="M4.5 6.5 8 10l3.5-3.5" />
64
+ </svg>
65
+ </span>
66
+ }
67
+ />
68
+ )
69
+ }
@@ -5,7 +5,25 @@
5
5
  */
6
6
 
7
7
  /** Properties whose bare numbers are pixel lengths. */
8
- const LENGTHS = new Set(['width', 'height', 'padding', 'gap', 'font-size'])
8
+ const LENGTHS = new Set([
9
+ 'width',
10
+ 'height',
11
+ 'padding',
12
+ 'gap',
13
+ 'font-size',
14
+ 'border-radius',
15
+ 'border-top-left-radius',
16
+ 'border-top-right-radius',
17
+ 'border-bottom-right-radius',
18
+ 'border-bottom-left-radius',
19
+ ])
20
+
21
+ /** Properties a size mode writes, each mode by the name the field shows. */
22
+ const SIZES = new Set(['width', 'height'])
23
+ const SIZE_MODES: Readonly<Record<string, string>> = {
24
+ Fill: '100%',
25
+ Fit: 'fit-content',
26
+ }
9
27
 
10
28
  const PX = /^(-?\d*\.?\d+)px$/
11
29
 
@@ -28,6 +46,14 @@ export function displayValue(property: string, raw: string): string {
28
46
  const value = raw.trim()
29
47
  if (property === 'gap' && (value === '' || value === 'normal')) return ''
30
48
  if (property === 'background-color' && isNoFill(value)) return ''
49
+ if (property === 'opacity' && value !== '')
50
+ return `${Math.round(Number(value) * 100)}%`
51
+ if (SIZES.has(property)) {
52
+ const mode = Object.keys(SIZE_MODES).find(
53
+ (name) => SIZE_MODES[name] === value,
54
+ )
55
+ if (mode) return mode
56
+ }
31
57
  if (value === 'auto') return 'Auto'
32
58
  if (value === 'normal') return 'Normal'
33
59
  return value.split(/\s+/).map(formatPx).join(' ')
@@ -36,6 +62,7 @@ export function displayValue(property: string, raw: string): string {
36
62
  export function toCssValue(property: string, typed: string): string {
37
63
  const value = typed.trim()
38
64
  if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
65
+ if (SIZES.has(property) && SIZE_MODES[value]) return SIZE_MODES[value]
39
66
  if (!LENGTHS.has(property)) return value
40
67
  return value
41
68
  .split(/\s+/)
@@ -43,6 +70,21 @@ export function toCssValue(property: string, typed: string): string {
43
70
  .join(' ')
44
71
  }
45
72
 
73
+ /** A typed opacity percent as the fraction CSS takes, refused outside 0 to 100. */
74
+ export function opacityToCss(typed: string): string | undefined {
75
+ const percent = readOpacity(typed)
76
+ return percent === undefined ? undefined : String(percent / 100)
77
+ }
78
+
79
+ /**
80
+ * The radius all four corners share, read in the order the longhands run,
81
+ * or undefined where any corner differs and the panel shows each one.
82
+ */
83
+ export function sharedRadius(corners: readonly string[]): string | undefined {
84
+ const [first] = corners
85
+ return corners.every((corner) => corner === first) ? first : undefined
86
+ }
87
+
46
88
  /**
47
89
  * Holds a scrubbed number inside what the property accepts. The writer takes
48
90
  * any declaration that does not break the rule, so a negative length or a
@@ -1,6 +1,8 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
+ import { useState } from 'preact/hooks'
3
4
  import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
5
+ import { AlignGrid } from '@/canvas/client/inspector/align-grid'
4
6
  import { ColorField } from '@/canvas/client/inspector/color-field'
5
7
  import {
6
8
  Field,
@@ -8,10 +10,13 @@ import {
8
10
  type Scrub,
9
11
  } from '@/canvas/client/inspector/field'
10
12
  import { Section } from '@/canvas/client/inspector/section'
13
+ import { SizeField } from '@/canvas/client/inspector/size-field'
11
14
  import {
12
15
  clampScrub,
13
16
  displayValue,
17
+ opacityToCss,
14
18
  parseColor,
19
+ sharedRadius,
15
20
  toCssValue,
16
21
  } from '@/canvas/client/inspector/values'
17
22
  import {
@@ -66,12 +71,47 @@ const SIZE_FIELDS: readonly StyleField[] = [
66
71
  { label: 'height', glyph: 'H', property: 'height' },
67
72
  ]
68
73
 
74
+ const DIRECTION_FIELD: StyleField = {
75
+ label: 'direction',
76
+ glyph: 'Dir',
77
+ property: 'flex-direction',
78
+ }
79
+
69
80
  const FLEX_FIELDS: readonly StyleField[] = [
70
- { label: 'direction', glyph: 'Dir', property: 'flex-direction' },
71
81
  { label: 'gap', glyph: 'Gap', property: 'gap', placeholder: '0' },
72
- { label: 'padding', glyph: 'Pad', property: 'padding', isWide: true },
73
82
  ]
74
83
 
84
+ /** Padding applies to any box, so it sits in Layout rather than Flex. */
85
+ const PADDING_FIELD: StyleField = {
86
+ label: 'padding',
87
+ glyph: 'Pad',
88
+ property: 'padding',
89
+ isWide: true,
90
+ }
91
+
92
+ /** The corners in the order the radius shorthand lists them. */
93
+ const CORNER_FIELDS: readonly StyleField[] = [
94
+ { label: 'top left radius', glyph: 'TL', property: 'border-top-left-radius' },
95
+ {
96
+ label: 'top right radius',
97
+ glyph: 'TR',
98
+ property: 'border-top-right-radius',
99
+ },
100
+ {
101
+ label: 'bottom right radius',
102
+ glyph: 'BR',
103
+ property: 'border-bottom-right-radius',
104
+ },
105
+ {
106
+ label: 'bottom left radius',
107
+ glyph: 'BL',
108
+ property: 'border-bottom-left-radius',
109
+ },
110
+ ]
111
+
112
+ const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
113
+ const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
114
+
75
115
  const TYPE_FIELDS: readonly StyleField[] = [
76
116
  { label: 'size', glyph: 'Size', property: 'font-size' },
77
117
  { label: 'weight', glyph: 'Wt', property: 'font-weight' },
@@ -175,6 +215,31 @@ function ElementFields({
175
215
  const commit = (property: string) => (value: string) => {
176
216
  if (address) void editElement(frameRef, key, address, property, value)
177
217
  }
218
+ /*
219
+ * The writer takes one property an edit, so a control setting two posts
220
+ * them in turn and stops at the first refusal. Each carries the hash the
221
+ * last answered with in its own address, since a frame reloading between
222
+ * the two clears the stored one.
223
+ */
224
+ const commitAll = async (
225
+ changes: readonly (readonly [string, string])[],
226
+ ): Promise<void> => {
227
+ let current = address
228
+ for (const [property, value] of changes) {
229
+ if (!current) return
230
+ const written = await editElement(frameRef, key, current, property, value)
231
+ if (!written) return
232
+ if (written.hash) current = { ...current, hash: written.hash }
233
+ }
234
+ }
235
+ const corners = CORNER_FIELDS.map((field) =>
236
+ currentValue(node, field.property).trim(),
237
+ )
238
+ const radius = sharedRadius(corners)
239
+ const [isPerCorner, setPerCorner] = useState(radius === undefined)
240
+ const display = computedValue(node, 'display').trim()
241
+ const isFlex = FLEX_DISPLAYS.has(display)
242
+ const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
178
243
  const rect = node.getBoundingClientRect()
179
244
  const font = currentValue(node, 'font-family')
180
245
  const text = node.textContent ?? ''
@@ -237,9 +302,125 @@ function ElementFields({
237
302
  glyph="Y"
238
303
  value={String(Math.round(rect.top))}
239
304
  />
240
- {SIZE_FIELDS.map(styleField)}
305
+ {SIZE_FIELDS.map((field) => (
306
+ <SizeField
307
+ key={field.property}
308
+ label={field.label}
309
+ glyph={field.glyph}
310
+ initial={displayValue(
311
+ field.property,
312
+ currentValue(node, field.property),
313
+ )}
314
+ isBusy={isBusy}
315
+ onCommit={(typed) =>
316
+ commit(field.property)(toCssValue(field.property, typed))
317
+ }
318
+ scrub={'style' in node ? scrubOf(field.property) : undefined}
319
+ />
320
+ ))}
321
+ {styleField(PADDING_FIELD)}
322
+ </Section>
323
+ <Section
324
+ title="Flex"
325
+ action={
326
+ isFlex || GRID_DISPLAYS.has(display) ? null : (
327
+ <button
328
+ type="button"
329
+ class="section-icon"
330
+ aria-label="Add flex layout"
331
+ title="Add flex layout"
332
+ disabled={isBusy}
333
+ onClick={() => commit('display')('flex')}
334
+ >
335
+ <svg viewBox="0 0 16 16" aria-hidden="true">
336
+ <path d="M8 3.5v9M3.5 8h9" />
337
+ </svg>
338
+ </button>
339
+ )
340
+ }
341
+ >
342
+ {isFlex ? (
343
+ <>
344
+ <div class="flex-layout is-wide">
345
+ <AlignGrid
346
+ direction={computedValue(node, 'flex-direction').trim()}
347
+ justifyContent={computedValue(node, 'justify-content')}
348
+ alignItems={computedValue(node, 'align-items')}
349
+ isBusy={isBusy}
350
+ onPick={(alignment) =>
351
+ void commitAll([
352
+ ['justify-content', alignment.justifyContent],
353
+ ['align-items', alignment.alignItems],
354
+ ])
355
+ }
356
+ />
357
+ <div class="flex-layout-side">
358
+ {styleField(DIRECTION_FIELD)}
359
+ <button
360
+ type="button"
361
+ class="section-icon"
362
+ aria-label="Wrap"
363
+ title="Wrap"
364
+ aria-pressed={isWrapped}
365
+ disabled={isBusy}
366
+ onClick={() =>
367
+ commit('flex-wrap')(isWrapped ? 'nowrap' : 'wrap')
368
+ }
369
+ >
370
+ <svg viewBox="0 0 16 16" aria-hidden="true">
371
+ <path d="M2.5 4.5h9a2.5 2.5 0 0 1 0 5h-6M7.5 7.5l-2 2 2 2M2.5 12.5h1" />
372
+ </svg>
373
+ </button>
374
+ </div>
375
+ </div>
376
+ {FLEX_FIELDS.map(styleField)}
377
+ </>
378
+ ) : null}
379
+ </Section>
380
+ <Section
381
+ title="Appearance"
382
+ action={
383
+ <button
384
+ type="button"
385
+ class="section-icon"
386
+ aria-label="Per-corner radius"
387
+ title="Per-corner radius"
388
+ aria-pressed={isPerCorner}
389
+ onClick={() => setPerCorner(!isPerCorner)}
390
+ >
391
+ <svg viewBox="0 0 16 16" aria-hidden="true">
392
+ <path d="M3 6.5V5a2 2 0 0 1 2-2h1.5M9.5 3H11a2 2 0 0 1 2 2v1.5M13 9.5V11a2 2 0 0 1-2 2H9.5M6.5 13H5a2 2 0 0 1-2-2V9.5" />
393
+ </svg>
394
+ </button>
395
+ }
396
+ >
397
+ <Field
398
+ label="opacity"
399
+ glyph="Op"
400
+ initial={displayValue('opacity', computedValue(node, 'opacity'))}
401
+ isBusy={isBusy}
402
+ isWide={isPerCorner}
403
+ onCommit={(typed) => {
404
+ const value = opacityToCss(typed)
405
+ if (value !== undefined) commit('opacity')(value)
406
+ }}
407
+ />
408
+ {isPerCorner ? (
409
+ CORNER_FIELDS.map(styleField)
410
+ ) : (
411
+ <Field
412
+ label="radius"
413
+ glyph="R"
414
+ initial={displayValue('border-radius', radius ?? '')}
415
+ isBusy={isBusy}
416
+ placeholder="0"
417
+ onCommit={(typed) =>
418
+ commit('border-radius')(toCssValue('border-radius', typed))
419
+ }
420
+ scrub={'style' in node ? scrubOf('border-radius') : undefined}
421
+ />
422
+ )}
241
423
  </Section>
242
- <Section title="Flex">{FLEX_FIELDS.map(styleField)}</Section>
243
424
  <Section title="Typography">
244
425
  {TYPE_FIELDS.map(styleField)}
245
426
  {font ? (
@@ -261,11 +442,16 @@ function ElementFields({
261
442
  label={field.label}
262
443
  color={parseColor(inline || painted)}
263
444
  text={inline || painted}
445
+ inline={inline}
264
446
  painted={painted}
447
+ owner={`${key}:${address?.index ?? -1}:${field.property}`}
265
448
  tokens={colorTokens}
266
449
  paint={(name) => tokenPaint(doc, name)}
267
450
  isRaw={isRawInline(node, field.property)}
268
451
  isBusy={isBusy}
452
+ preview={
453
+ 'style' in node ? scrubOf(field.property).begin : undefined
454
+ }
269
455
  onCommit={commit(field.property)}
270
456
  />
271
457
  )
@@ -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
+ }