@erclx/canon 5.26.0 → 5.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +13 -0
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/SKILL.md +3 -17
  20. package/claude/skills/context-fold/references/completion-report.md +26 -0
  21. package/claude/skills/create-rule/SKILL.md +2 -0
  22. package/claude/skills/create-skill/SKILL.md +2 -0
  23. package/claude/skills/create-standard/SKILL.md +2 -0
  24. package/claude/skills/decision-escalate/SKILL.md +2 -0
  25. package/claude/skills/deploy-app/SKILL.md +2 -0
  26. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  27. package/claude/skills/design-extract/SKILL.md +2 -0
  28. package/claude/skills/design-taste/SKILL.md +2 -0
  29. package/claude/skills/docs-sync/SKILL.md +2 -0
  30. package/claude/skills/document-health/SKILL.md +2 -0
  31. package/claude/skills/draft-and-pick/SKILL.md +2 -0
  32. package/claude/skills/draft-doc/SKILL.md +2 -0
  33. package/claude/skills/draft-doc/references/wiki.md +1 -1
  34. package/claude/skills/draft-figure/SKILL.md +2 -0
  35. package/claude/skills/draft-identity/SKILL.md +2 -0
  36. package/claude/skills/draft-ready/SKILL.md +5 -1
  37. package/claude/skills/draft-screencast/SKILL.md +2 -0
  38. package/claude/skills/draft-slides/SKILL.md +2 -0
  39. package/claude/skills/git-branch/SKILL.md +2 -0
  40. package/claude/skills/git-commit/SKILL.md +2 -0
  41. package/claude/skills/git-followup/SKILL.md +2 -0
  42. package/claude/skills/git-issue/SKILL.md +2 -0
  43. package/claude/skills/git-pr/SKILL.md +2 -0
  44. package/claude/skills/git-pr/references/evidence.md +7 -7
  45. package/claude/skills/git-ship/SKILL.md +2 -0
  46. package/claude/skills/git-split/SKILL.md +2 -0
  47. package/claude/skills/git-stage/SKILL.md +2 -0
  48. package/claude/skills/git-worktree/SKILL.md +2 -0
  49. package/claude/skills/index-lookup/SKILL.md +2 -0
  50. package/claude/skills/markdown-propose/SKILL.md +2 -0
  51. package/claude/skills/memory-capture/SKILL.md +2 -0
  52. package/claude/skills/memory-review/SKILL.md +2 -0
  53. package/claude/skills/plan-feature/SKILL.md +2 -0
  54. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  55. package/claude/skills/plan-intake/SKILL.md +2 -0
  56. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  57. package/claude/skills/project-commands/SKILL.md +2 -0
  58. package/claude/skills/read-frames/SKILL.md +2 -0
  59. package/claude/skills/record-screencast/SKILL.md +2 -0
  60. package/claude/skills/repo-metadata/SKILL.md +2 -0
  61. package/claude/skills/restate-plainly/SKILL.md +2 -0
  62. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  63. package/claude/skills/review-address/SKILL.md +19 -14
  64. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  65. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  66. package/claude/skills/review-branch/SKILL.md +2 -0
  67. package/claude/skills/review-craft/SKILL.md +2 -0
  68. package/claude/skills/review-craft/references/ui.md +6 -6
  69. package/claude/skills/review-pr/SKILL.md +4 -8
  70. package/claude/skills/review-pr/references/body-variants.md +52 -0
  71. package/claude/skills/review-pr/references/key-changes.md +16 -0
  72. package/claude/skills/review-pr/references/post.md +15 -43
  73. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  74. package/claude/skills/review-ui/SKILL.md +16 -12
  75. package/claude/skills/review-ui/references/driving.md +8 -8
  76. package/claude/skills/review-ui/references/post.md +13 -3
  77. package/claude/skills/review-ui/references/tick.md +22 -0
  78. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  79. package/claude/skills/role-orchestrator/SKILL.md +31 -42
  80. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  81. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
  82. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  83. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
  84. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  85. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  86. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
  87. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  88. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  89. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  91. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  92. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  93. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  94. package/claude/skills/role-planner/SKILL.md +4 -2
  95. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  96. package/claude/skills/role-reviewer/SKILL.md +3 -1
  97. package/claude/skills/role-worker/SKILL.md +5 -2
  98. package/claude/skills/search-craft/SKILL.md +2 -0
  99. package/claude/skills/seed-sync/SKILL.md +2 -0
  100. package/claude/skills/session-compact/SKILL.md +2 -0
  101. package/claude/skills/session-map/SKILL.md +2 -0
  102. package/claude/skills/session-relay/SKILL.md +2 -0
  103. package/claude/skills/session-resume/SKILL.md +2 -0
  104. package/claude/skills/session-worktree/SKILL.md +3 -11
  105. package/claude/skills/session-worktree/references/install-command.md +20 -0
  106. package/claude/skills/sketch-design/SKILL.md +2 -0
  107. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  108. package/claude/skills/target-check/SKILL.md +2 -0
  109. package/claude/skills/target-setup/SKILL.md +2 -0
  110. package/claude/skills/task-board/SKILL.md +2 -0
  111. package/claude/skills/teach-workspace/SKILL.md +5 -15
  112. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  113. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  114. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  115. package/claude/skills/test-craft/SKILL.md +2 -0
  116. package/claude/skills/test-first/SKILL.md +2 -0
  117. package/claude/skills/ui-checklist/SKILL.md +3 -1
  118. package/claude/skills/ux-audit/SKILL.md +2 -0
  119. package/claude/skills/ux-measure/SKILL.md +2 -0
  120. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  121. package/claude/skills/video-craft/SKILL.md +2 -0
  122. package/claude/skills/write-human/SKILL.md +2 -0
  123. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  124. package/docs/agents/canvas.md +24 -8
  125. package/docs/agents/capture.md +5 -5
  126. package/docs/agents/commands.md +2 -2
  127. package/docs/agents/gate.md +3 -1
  128. package/docs/agents/index.md +5 -4
  129. package/docs/agents/install-and-sync.md +7 -0
  130. package/docs/agents/key-changes.md +1 -1
  131. package/docs/agents/pr-evidence.md +47 -23
  132. package/docs/agents/pr-local.md +19 -13
  133. package/docs/agents/pr-preview.md +44 -21
  134. package/docs/agents/pr-tick.md +72 -0
  135. package/docs/agents/sandbox.md +30 -6
  136. package/docs/agents/scripting.md +8 -1
  137. package/docs/agents/skills-audit.md +2 -1
  138. package/docs/agents/tasks-plans.md +1 -1
  139. package/docs/agents/tasks-validate.md +18 -18
  140. package/docs/agents/tasks.md +1 -1
  141. package/docs/workflow/operating-model.md +15 -21
  142. package/docs/workflow/parallel-features.md +24 -3
  143. package/docs/workflow/skill-map.md +3 -1
  144. package/docs/workflow/zshrc-aliases.md +1 -1
  145. package/package.json +1 -1
  146. package/scripts/core/regen-hero.ts +1 -1
  147. package/scripts/lib/sandbox-path.sh +6 -162
  148. package/src/audits/baseline.ts +1 -1
  149. package/src/audits/run.ts +2 -2
  150. package/src/canvas/address.ts +12 -3
  151. package/src/canvas/capture.ts +1 -1
  152. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  153. package/src/canvas/client/inspector/color-field.tsx +201 -171
  154. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  155. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  156. package/src/canvas/client/inspector/color-space.ts +240 -0
  157. package/src/canvas/client/inspector/field.tsx +66 -3
  158. package/src/canvas/client/inspector/fields.css +473 -6
  159. package/src/canvas/client/inspector/size-field.tsx +69 -0
  160. package/src/canvas/client/inspector/token-select.tsx +109 -0
  161. package/src/canvas/client/inspector/values.ts +117 -3
  162. package/src/canvas/client/inspector.tsx +311 -15
  163. package/src/canvas/client/keys.ts +54 -0
  164. package/src/canvas/client/selection.tsx +199 -0
  165. package/src/canvas/client/shell.css +210 -12
  166. package/src/canvas/client/state.ts +165 -14
  167. package/src/canvas/client/surface.tsx +341 -39
  168. package/src/canvas/client/tools.tsx +64 -0
  169. package/src/canvas/content.ts +51 -2
  170. package/src/canvas/edit.ts +49 -6
  171. package/src/canvas/editing.ts +203 -0
  172. package/src/canvas/global.d.ts +15 -0
  173. package/src/canvas/server.ts +149 -12
  174. package/src/canvas/tokens.ts +60 -18
  175. package/src/census/count.ts +1 -1
  176. package/src/claude/seeds.ts +3 -3
  177. package/src/claude/skills-audit.ts +28 -0
  178. package/src/claude/skills-drift.ts +1 -1
  179. package/src/claude/skills-families.ts +36 -0
  180. package/src/claude/skills-list.ts +76 -15
  181. package/src/claude/skills-reach.ts +1 -1
  182. package/src/commands/audits.ts +2 -2
  183. package/src/commands/canvas.ts +180 -3
  184. package/src/commands/claude.ts +70 -12
  185. package/src/commands/design.ts +145 -5
  186. package/src/commands/docs.ts +1 -1
  187. package/src/commands/feedback.ts +2 -2
  188. package/src/commands/gate.ts +9 -2
  189. package/src/commands/gov.ts +3 -3
  190. package/src/commands/hooks.ts +1 -1
  191. package/src/commands/indexes.ts +1 -1
  192. package/src/commands/init.ts +2 -2
  193. package/src/commands/intake.ts +1 -1
  194. package/src/commands/migrate.ts +2 -2
  195. package/src/commands/pr.ts +288 -65
  196. package/src/commands/records.ts +1 -1
  197. package/src/commands/repo.ts +1 -1
  198. package/src/commands/sandbox.ts +64 -15
  199. package/src/commands/sessions.ts +1 -1
  200. package/src/commands/slides.ts +1 -1
  201. package/src/commands/sync.ts +2 -2
  202. package/src/commands/tasks.ts +21 -4
  203. package/src/commands/teach.ts +1 -1
  204. package/src/commands/tooling.ts +1 -1
  205. package/src/commands/transcripts.ts +2 -2
  206. package/src/commands/upgrade.ts +1 -1
  207. package/src/commands/wiki.ts +1 -1
  208. package/src/commands/worktrees.ts +1 -1
  209. package/src/comments/scan.ts +1 -1
  210. package/src/comments/trend.ts +1 -1
  211. package/src/context/architecture.ts +1 -1
  212. package/src/context/citations.ts +3 -3
  213. package/src/context/classify/extract.ts +3 -3
  214. package/src/context/folders.ts +2 -2
  215. package/src/context/requirements.ts +1 -1
  216. package/src/design/project-fonts.ts +392 -0
  217. package/src/docs/read.ts +1 -1
  218. package/src/exec.ts +1 -1
  219. package/src/feedback/github.ts +2 -2
  220. package/src/gate/measures.ts +48 -10
  221. package/src/gate/sequencer.ts +56 -4
  222. package/src/gate/stages.ts +17 -0
  223. package/src/gate/suite-lock.ts +214 -0
  224. package/src/{git-files.ts → git/files.ts} +2 -2
  225. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  226. package/src/{worktree.ts → git/worktree.ts} +1 -1
  227. package/src/gov/citations.ts +1 -1
  228. package/src/gov/install.ts +1 -1
  229. package/src/gov/superseded.ts +1 -1
  230. package/src/gov/test-order.ts +2 -2
  231. package/src/help.ts +1 -1
  232. package/src/hooks/post-merge.ts +1 -1
  233. package/src/indexes/walk.ts +1 -1
  234. package/src/intake/folder.ts +1 -1
  235. package/src/labels/audit.ts +1 -1
  236. package/src/labels/map.ts +1 -1
  237. package/src/labels/phase.ts +3 -3
  238. package/src/markdown/files.ts +1 -1
  239. package/src/migrate/apply.ts +1 -1
  240. package/src/migrate/record-layout.ts +1 -1
  241. package/src/migrate/records.ts +11 -11
  242. package/src/migrate/scratch-evidence.ts +1 -1
  243. package/src/migrate/surface-roots.ts +6 -6
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +250 -59
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/pr/tick.ts +56 -0
  249. package/src/process/harness.ts +1 -1
  250. package/src/records/backup.ts +2 -2
  251. package/src/records/ordinal.ts +1 -1
  252. package/src/records/prune.ts +1 -1
  253. package/src/records/size.ts +1 -1
  254. package/src/records/stale-canonical.ts +2 -2
  255. package/src/records/stale.ts +1 -1
  256. package/src/records/validate.ts +2 -2
  257. package/src/{project-root.ts → roots/project.ts} +1 -1
  258. package/src/{record-root.ts → roots/record.ts} +1 -1
  259. package/src/sandbox/coverage.ts +11 -12
  260. package/src/sandbox/equivalence.ts +57 -26
  261. package/src/sandbox/headless/dispatch.ts +264 -0
  262. package/src/sandbox/headless/run.ts +653 -0
  263. package/src/sandbox/headless/watch.ts +145 -0
  264. package/src/sandbox/hooks.ts +1 -1
  265. package/src/sandbox/provision.ts +90 -29
  266. package/src/sandbox/scenario.ts +588 -0
  267. package/src/sandbox/tree.ts +4 -8
  268. package/src/secrets/scan.ts +1 -1
  269. package/src/sessions/claim.ts +1 -1
  270. package/src/sessions/live.ts +4 -1
  271. package/src/sessions/resolve.ts +1 -1
  272. package/src/sessions/transfer/bundle.ts +2 -2
  273. package/src/slides/decks.ts +1 -1
  274. package/src/slides/package/fonts.ts +6 -2
  275. package/src/standards/audit.ts +2 -2
  276. package/src/standards/read.ts +1 -1
  277. package/src/sync/check.ts +2 -2
  278. package/src/sync/engine.ts +3 -3
  279. package/src/sync/git.ts +1 -1
  280. package/src/sync/history.ts +1 -1
  281. package/src/sync/layout.ts +2 -2
  282. package/src/sync/reverse.ts +1 -1
  283. package/src/sync/stamp.ts +1 -1
  284. package/src/sync/target.ts +1 -1
  285. package/src/sync/workflow.ts +1 -1
  286. package/src/targets/pulls.ts +2 -2
  287. package/src/targets/sweep.ts +2 -2
  288. package/src/{target.ts → targets/validate.ts} +1 -1
  289. package/src/tasks/answers.ts +1 -1
  290. package/src/tasks/archive.ts +1 -1
  291. package/src/tasks/label.ts +1 -1
  292. package/src/tasks/reach.ts +2 -2
  293. package/src/tasks/trunk.ts +1 -1
  294. package/src/tasks/validate.ts +38 -14
  295. package/src/teach/nav.ts +1 -1
  296. package/src/teach/workspace.ts +1 -1
  297. package/src/tooling/inject.ts +2 -2
  298. package/src/tooling/read.ts +1 -1
  299. package/src/tooling/scan.ts +1 -1
  300. package/src/tooling/subfolder.ts +1 -1
  301. package/src/version/installed.ts +1 -1
  302. package/src/version/skew.ts +1 -1
  303. package/src/web/readme-citations.ts +1 -1
  304. package/src/worktrees/bare-flag.ts +1 -1
  305. package/src/worktrees/reclaim.ts +2 -2
  306. package/src/worktrees/remove.ts +1 -1
  307. package/standards/board.md +3 -3
  308. package/standards/canvas.md +2 -1
  309. package/standards/plan.md +13 -7
  310. package/standards/skill.md +1 -0
  311. package/standards/wiki.md +12 -3
  312. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  313. package/tsconfig.json +1 -1
  314. package/scripts/lib/sandbox-dispatch.sh +0 -190
  315. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  316. /package/src/{git-base.ts → git/base.ts} +0 -0
  317. /package/src/{git-env.ts → git/env.ts} +0 -0
  318. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  319. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
  320. /package/src/{copy.ts → sync/copy.ts} +0 -0
@@ -4,10 +4,33 @@
4
4
  * back through `toCssValue`, so rounding never writes a value nobody typed.
5
5
  */
6
6
 
7
+ /** A length that runs below zero, so a bare number is pixels and unclamped. */
8
+ const SIGNED_LENGTHS = new Set(['letter-spacing'])
9
+
7
10
  /** Properties whose bare numbers are pixel lengths. */
8
- const LENGTHS = new Set(['width', 'height', 'padding', 'gap', 'font-size'])
11
+ const LENGTHS = new Set([
12
+ 'width',
13
+ 'height',
14
+ 'padding',
15
+ 'gap',
16
+ 'font-size',
17
+ 'border-radius',
18
+ 'border-top-left-radius',
19
+ 'border-top-right-radius',
20
+ 'border-bottom-right-radius',
21
+ 'border-bottom-left-radius',
22
+ ])
23
+
24
+ /** Properties a size mode writes, each mode by the name the field shows. */
25
+ const SIZES = new Set(['width', 'height'])
26
+ const SIZE_MODES: Readonly<Record<string, string>> = {
27
+ Fill: '100%',
28
+ Fit: 'fit-content',
29
+ }
9
30
 
10
31
  const PX = /^(-?\d*\.?\d+)px$/
32
+ const EM = /^(-?\d*\.?\d+)em$/
33
+ const NUMBER = /^-?\d*\.?\d+$/
11
34
 
12
35
  function formatPx(token: string): string {
13
36
  const match = token.match(PX)
@@ -27,7 +50,20 @@ export function isNoFill(value: string): boolean {
27
50
  export function displayValue(property: string, raw: string): string {
28
51
  const value = raw.trim()
29
52
  if (property === 'gap' && (value === '' || value === 'normal')) return ''
53
+ if (property === 'letter-spacing') {
54
+ if (value === 'normal') return ''
55
+ const em = value.match(EM)
56
+ if (em) return `${Math.round(Number(em[1]) * 1000) / 1000}em`
57
+ }
30
58
  if (property === 'background-color' && isNoFill(value)) return ''
59
+ if (property === 'opacity' && value !== '')
60
+ return `${Math.round(Number(value) * 100)}%`
61
+ if (SIZES.has(property)) {
62
+ const mode = Object.keys(SIZE_MODES).find(
63
+ (name) => SIZE_MODES[name] === value,
64
+ )
65
+ if (mode) return mode
66
+ }
31
67
  if (value === 'auto') return 'Auto'
32
68
  if (value === 'normal') return 'Normal'
33
69
  return value.split(/\s+/).map(formatPx).join(' ')
@@ -35,14 +71,34 @@ export function displayValue(property: string, raw: string): string {
35
71
 
36
72
  export function toCssValue(property: string, typed: string): string {
37
73
  const value = typed.trim()
74
+ /* A line height has no auto keyword, so the field's Auto is its normal. */
75
+ if (property === 'line-height' && value.toLowerCase() === 'auto') {
76
+ return 'normal'
77
+ }
38
78
  if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
39
- if (!LENGTHS.has(property)) return value
79
+ if (SIZES.has(property) && SIZE_MODES[value]) return SIZE_MODES[value]
80
+ if (!LENGTHS.has(property) && !SIGNED_LENGTHS.has(property)) return value
40
81
  return value
41
82
  .split(/\s+/)
42
83
  .map((token) => (/^-?\d*\.?\d+$/.test(token) ? `${token}px` : token))
43
84
  .join(' ')
44
85
  }
45
86
 
87
+ /** A typed opacity percent as the fraction CSS takes, refused outside 0 to 100. */
88
+ export function opacityToCss(typed: string): string | undefined {
89
+ const percent = readOpacity(typed)
90
+ return percent === undefined ? undefined : String(percent / 100)
91
+ }
92
+
93
+ /**
94
+ * The radius all four corners share, read in the order the longhands run,
95
+ * or undefined where any corner differs and the panel shows each one.
96
+ */
97
+ export function sharedRadius(corners: readonly string[]): string | undefined {
98
+ const [first] = corners
99
+ return corners.every((corner) => corner === first) ? first : undefined
100
+ }
101
+
46
102
  /**
47
103
  * Holds a scrubbed number inside what the property accepts. The writer takes
48
104
  * any declaration that does not break the rule, so a negative length or a
@@ -50,10 +106,68 @@ export function toCssValue(property: string, typed: string): string {
50
106
  */
51
107
  export function clampScrub(property: string, value: number): number {
52
108
  if (property === 'font-weight') return Math.min(1000, Math.max(1, value))
53
- if (LENGTHS.has(property)) return Math.max(0, value)
109
+ if (LENGTHS.has(property) || property === 'line-height') {
110
+ return Math.max(0, value)
111
+ }
112
+ return value
113
+ }
114
+
115
+ /**
116
+ * A line height as the panel shows it: a ratio of the font size, the way a
117
+ * unitless value writes it. A browser computes a unitless value to pixels, so
118
+ * a pixel value reads back as its ratio when the font size is in pixels too.
119
+ */
120
+ export function lineHeightValue(raw: string, fontSize: string): string {
121
+ const value = raw.trim()
122
+ if (value === 'normal') return 'Auto'
123
+ const ratio = (number: number): string =>
124
+ String(Math.round(number * 100) / 100)
125
+ if (NUMBER.test(value)) return ratio(Number(value))
126
+ const height = value.match(PX)
127
+ const size = fontSize.trim().match(PX)
128
+ if (height && size && Number(size[1]) > 0) {
129
+ return ratio(Number(height[1]) / Number(size[1]))
130
+ }
54
131
  return value
55
132
  }
56
133
 
134
+ /** The first family of a stack, unquoted, which is the one the field names. */
135
+ export function firstFamily(stack: string): string {
136
+ const value = stack.trim()
137
+ if (value.startsWith('var(')) return value
138
+ const [first = ''] = value.split(',')
139
+ return first.trim().replace(/^(["'])(.*)\1$/, '$2')
140
+ }
141
+
142
+ /**
143
+ * Text alignment by the side it lands on, so the group presses the side the
144
+ * text paints on. The logical keywords turn on the element's direction.
145
+ */
146
+ export function textAlignOf(computed: string, direction: string): string {
147
+ const value = computed.trim()
148
+ const isRtl = direction.trim() === 'rtl'
149
+ if (value === 'start') return isRtl ? 'right' : 'left'
150
+ if (value === 'end') return isRtl ? 'left' : 'right'
151
+ return value
152
+ }
153
+
154
+ /**
155
+ * A value as the writer takes it. A whole declaration pasted from a
156
+ * stylesheet keeps its value alone, since the writer refuses the semicolon.
157
+ */
158
+ export function declarationValue(property: string, typed: string): string {
159
+ const value = typed.trim().replace(/;\s*$/, '')
160
+ const prefix = `${property}:`
161
+ return value.toLowerCase().startsWith(prefix)
162
+ ? value.slice(prefix.length).trim()
163
+ : value
164
+ }
165
+
166
+ /** The token a value names when it is a bare `var()` and nothing else. */
167
+ export function tokenOf(value: string): string | undefined {
168
+ return value.trim().match(VAR)?.[1]
169
+ }
170
+
57
171
  /** Units a scrub moves for each pixel the pointer travels. */
58
172
  export function scrubStep(isCoarse: boolean): number {
59
173
  return isCoarse ? 10 : 1
@@ -1,18 +1,30 @@
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,
7
9
  ReadOnlyField,
8
10
  type Scrub,
11
+ SelectField,
9
12
  } from '@/canvas/client/inspector/field'
10
13
  import { Section } from '@/canvas/client/inspector/section'
14
+ import { SizeField } from '@/canvas/client/inspector/size-field'
15
+ import { TokenSelect } from '@/canvas/client/inspector/token-select'
11
16
  import {
12
17
  clampScrub,
18
+ declarationValue,
13
19
  displayValue,
20
+ firstFamily,
21
+ lineHeightValue,
22
+ opacityToCss,
14
23
  parseColor,
24
+ sharedRadius,
25
+ textAlignOf,
15
26
  toCssValue,
27
+ tokenOf,
16
28
  } from '@/canvas/client/inspector/values'
17
29
  import {
18
30
  currentPage,
@@ -29,6 +41,7 @@ import {
29
41
  tokens,
30
42
  writeError,
31
43
  } from '@/canvas/client/state'
44
+ import type { TokenKind } from '@/canvas/tokens'
32
45
  import '@/canvas/client/inspector/fields.css'
33
46
 
34
47
  /** Elements the parser never gives content, so they hold no text to edit. */
@@ -66,17 +79,67 @@ const SIZE_FIELDS: readonly StyleField[] = [
66
79
  { label: 'height', glyph: 'H', property: 'height' },
67
80
  ]
68
81
 
82
+ const DIRECTION_FIELD: StyleField = {
83
+ label: 'direction',
84
+ glyph: 'Dir',
85
+ property: 'flex-direction',
86
+ }
87
+
69
88
  const FLEX_FIELDS: readonly StyleField[] = [
70
- { label: 'direction', glyph: 'Dir', property: 'flex-direction' },
71
89
  { label: 'gap', glyph: 'Gap', property: 'gap', placeholder: '0' },
72
- { label: 'padding', glyph: 'Pad', property: 'padding', isWide: true },
73
90
  ]
74
91
 
75
- const TYPE_FIELDS: readonly StyleField[] = [
76
- { label: 'size', glyph: 'Size', property: 'font-size' },
77
- { label: 'weight', glyph: 'Wt', property: 'font-weight' },
92
+ /** Padding applies to any box, so it sits in Layout rather than Flex. */
93
+ const PADDING_FIELD: StyleField = {
94
+ label: 'padding',
95
+ glyph: 'Pad',
96
+ property: 'padding',
97
+ isWide: true,
98
+ }
99
+
100
+ /** The corners in the order the radius shorthand lists them. */
101
+ const CORNER_FIELDS: readonly StyleField[] = [
102
+ { label: 'top left radius', glyph: 'TL', property: 'border-top-left-radius' },
103
+ {
104
+ label: 'top right radius',
105
+ glyph: 'TR',
106
+ property: 'border-top-right-radius',
107
+ },
108
+ {
109
+ label: 'bottom right radius',
110
+ glyph: 'BR',
111
+ property: 'border-bottom-right-radius',
112
+ },
113
+ {
114
+ label: 'bottom left radius',
115
+ glyph: 'BL',
116
+ property: 'border-bottom-left-radius',
117
+ },
118
+ ]
119
+
120
+ const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
121
+ const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
122
+
123
+ const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900']
124
+
125
+ /** Line height and letter spacing move in tenths, since their range is small. */
126
+ const SPACING_FIELDS: readonly StyleField[] = [
127
+ { label: 'line height', glyph: 'LH', property: 'line-height' },
128
+ {
129
+ label: 'letter spacing',
130
+ glyph: 'LS',
131
+ property: 'letter-spacing',
132
+ placeholder: '0',
133
+ },
78
134
  ]
79
135
 
136
+ const TEXT_ALIGNS = [
137
+ { value: 'left', path: 'M2.5 4h11M2.5 8h7M2.5 12h9' },
138
+ { value: 'center', path: 'M2.5 4h11M4.5 8h7M3.5 12h9' },
139
+ { value: 'right', path: 'M2.5 4h11M6.5 8h7M4.5 12h9' },
140
+ { value: 'justify', path: 'M2.5 4h11M2.5 8h11M2.5 12h11' },
141
+ ] as const
142
+
80
143
  const FILL_FIELDS = [
81
144
  { label: 'color', property: 'color' },
82
145
  { label: 'background', property: 'background-color' },
@@ -175,21 +238,55 @@ function ElementFields({
175
238
  const commit = (property: string) => (value: string) => {
176
239
  if (address) void editElement(frameRef, key, address, property, value)
177
240
  }
241
+ /*
242
+ * The writer takes one property an edit, so a control setting two posts
243
+ * them in turn and stops at the first refusal. Each carries the hash the
244
+ * last answered with in its own address, since a frame reloading between
245
+ * the two clears the stored one.
246
+ */
247
+ const commitAll = async (
248
+ changes: readonly (readonly [string, string])[],
249
+ ): Promise<void> => {
250
+ let current = address
251
+ for (const [property, value] of changes) {
252
+ if (!current) return
253
+ const written = await editElement(frameRef, key, current, property, value)
254
+ if (!written) return
255
+ if (written.hash) current = { ...current, hash: written.hash }
256
+ }
257
+ }
258
+ const corners = CORNER_FIELDS.map((field) =>
259
+ currentValue(node, field.property).trim(),
260
+ )
261
+ const radius = sharedRadius(corners)
262
+ const [isPerCorner, setPerCorner] = useState(radius === undefined)
263
+ const display = computedValue(node, 'display').trim()
264
+ const isFlex = FLEX_DISPLAYS.has(display)
265
+ const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
178
266
  const rect = node.getBoundingClientRect()
179
- const font = currentValue(node, 'font-family')
180
267
  const text = node.textContent ?? ''
181
- const colorTokens =
268
+ const groupTokens = (kind: TokenKind): readonly string[] =>
182
269
  tokens.value?.groups
183
- ?.find((group) => group.kind === 'color')
270
+ ?.find((group) => group.kind === kind)
184
271
  ?.tokens.map((token) => token.name) ?? []
272
+ const colorTokens = groupTokens('color')
273
+ const family = currentValue(node, 'font-family')
274
+ const familyToken = tokenOf(family)
275
+ const size = currentValue(node, 'font-size')
276
+ const sizeToken = tokenOf(size)
277
+ const textAlign = textAlignOf(
278
+ computedValue(node, 'text-align'),
279
+ computedValue(node, 'direction'),
280
+ )
185
281
 
186
282
  /**
187
283
  * Previews into the frame's own inline style and writes through the same
188
284
  * edit as typing. A reload mid-drag replaces the document, so a release
189
285
  * against one no longer on screen posts nothing.
190
286
  */
191
- const scrubOf = (property: string): Scrub => ({
287
+ const scrubOf = (property: string, step?: number): Scrub => ({
192
288
  clamp: (value) => clampScrub(property, value),
289
+ step,
193
290
  begin: () => {
194
291
  const original = inlineValue(node, property)
195
292
  const style = (node as HTMLElement).style
@@ -237,15 +334,209 @@ function ElementFields({
237
334
  glyph="Y"
238
335
  value={String(Math.round(rect.top))}
239
336
  />
240
- {SIZE_FIELDS.map(styleField)}
337
+ {SIZE_FIELDS.map((field) => (
338
+ <SizeField
339
+ key={field.property}
340
+ label={field.label}
341
+ glyph={field.glyph}
342
+ initial={displayValue(
343
+ field.property,
344
+ currentValue(node, field.property),
345
+ )}
346
+ isBusy={isBusy}
347
+ onCommit={(typed) =>
348
+ commit(field.property)(toCssValue(field.property, typed))
349
+ }
350
+ scrub={'style' in node ? scrubOf(field.property) : undefined}
351
+ />
352
+ ))}
353
+ {styleField(PADDING_FIELD)}
241
354
  </Section>
242
- <Section title="Flex">{FLEX_FIELDS.map(styleField)}</Section>
243
- <Section title="Typography">
244
- {TYPE_FIELDS.map(styleField)}
245
- {font ? (
246
- <ReadOnlyField label="font" glyph="Font" value={font} isWide />
355
+ <Section
356
+ title="Flex"
357
+ action={
358
+ isFlex || GRID_DISPLAYS.has(display) ? null : (
359
+ <button
360
+ type="button"
361
+ class="section-icon"
362
+ aria-label="Add flex layout"
363
+ title="Add flex layout"
364
+ disabled={isBusy}
365
+ onClick={() => commit('display')('flex')}
366
+ >
367
+ <svg viewBox="0 0 16 16" aria-hidden="true">
368
+ <path d="M8 3.5v9M3.5 8h9" />
369
+ </svg>
370
+ </button>
371
+ )
372
+ }
373
+ >
374
+ {isFlex ? (
375
+ <>
376
+ <div class="flex-layout is-wide">
377
+ <AlignGrid
378
+ direction={computedValue(node, 'flex-direction').trim()}
379
+ justifyContent={computedValue(node, 'justify-content')}
380
+ alignItems={computedValue(node, 'align-items')}
381
+ isBusy={isBusy}
382
+ onPick={(alignment) =>
383
+ void commitAll([
384
+ ['justify-content', alignment.justifyContent],
385
+ ['align-items', alignment.alignItems],
386
+ ])
387
+ }
388
+ />
389
+ <div class="flex-layout-side">
390
+ {styleField(DIRECTION_FIELD)}
391
+ <button
392
+ type="button"
393
+ class="section-icon"
394
+ aria-label="Wrap"
395
+ title="Wrap"
396
+ aria-pressed={isWrapped}
397
+ disabled={isBusy}
398
+ onClick={() =>
399
+ commit('flex-wrap')(isWrapped ? 'nowrap' : 'wrap')
400
+ }
401
+ >
402
+ <svg viewBox="0 0 16 16" aria-hidden="true">
403
+ <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" />
404
+ </svg>
405
+ </button>
406
+ </div>
407
+ </div>
408
+ {FLEX_FIELDS.map(styleField)}
409
+ </>
247
410
  ) : null}
248
411
  </Section>
412
+ <Section
413
+ title="Appearance"
414
+ action={
415
+ <button
416
+ type="button"
417
+ class="section-icon"
418
+ aria-label="Per-corner radius"
419
+ title="Per-corner radius"
420
+ aria-pressed={isPerCorner}
421
+ onClick={() => setPerCorner(!isPerCorner)}
422
+ >
423
+ <svg viewBox="0 0 16 16" aria-hidden="true">
424
+ <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" />
425
+ </svg>
426
+ </button>
427
+ }
428
+ >
429
+ <Field
430
+ label="opacity"
431
+ glyph="Op"
432
+ initial={displayValue('opacity', computedValue(node, 'opacity'))}
433
+ isBusy={isBusy}
434
+ isWide={isPerCorner}
435
+ onCommit={(typed) => {
436
+ const value = opacityToCss(typed)
437
+ if (value !== undefined) commit('opacity')(value)
438
+ }}
439
+ />
440
+ {isPerCorner ? (
441
+ CORNER_FIELDS.map(styleField)
442
+ ) : (
443
+ <Field
444
+ label="radius"
445
+ glyph="R"
446
+ initial={displayValue('border-radius', radius ?? '')}
447
+ isBusy={isBusy}
448
+ placeholder="0"
449
+ onCommit={(typed) =>
450
+ commit('border-radius')(toCssValue('border-radius', typed))
451
+ }
452
+ scrub={'style' in node ? scrubOf('border-radius') : undefined}
453
+ />
454
+ )}
455
+ </Section>
456
+ <Section title="Typography">
457
+ <TokenSelect
458
+ label="family"
459
+ glyph="Aa"
460
+ initial={familyToken ?? firstFamily(family)}
461
+ tokens={groupTokens('font-family')}
462
+ token={familyToken}
463
+ isRaw={isRawInline(node, 'font-family')}
464
+ isBusy={isBusy}
465
+ isWide
466
+ onCommit={(typed) =>
467
+ commit('font-family')(declarationValue('font-family', typed))
468
+ }
469
+ onPick={(name) => commit('font-family')(`var(${name})`)}
470
+ />
471
+ <TokenSelect
472
+ label="size"
473
+ glyph="Size"
474
+ initial={sizeToken ?? displayValue('font-size', size)}
475
+ tokens={groupTokens('font-size')}
476
+ token={sizeToken}
477
+ isRaw={isRawInline(node, 'font-size')}
478
+ isBusy={isBusy}
479
+ onCommit={(typed) =>
480
+ commit('font-size')(toCssValue('font-size', typed))
481
+ }
482
+ onPick={(name) => commit('font-size')(`var(${name})`)}
483
+ scrub={'style' in node ? scrubOf('font-size') : undefined}
484
+ />
485
+ <SelectField
486
+ label="weight"
487
+ glyph="Wt"
488
+ options={WEIGHTS}
489
+ value={currentValue(node, 'font-weight').trim()}
490
+ isBusy={isBusy}
491
+ onCommit={commit('font-weight')}
492
+ />
493
+ {SPACING_FIELDS.map((field) => (
494
+ <Field
495
+ key={field.property}
496
+ label={field.label}
497
+ glyph={field.glyph}
498
+ initial={
499
+ field.property === 'line-height'
500
+ ? lineHeightValue(
501
+ currentValue(node, 'line-height'),
502
+ computedValue(node, 'font-size'),
503
+ )
504
+ : displayValue(
505
+ field.property,
506
+ currentValue(node, field.property),
507
+ )
508
+ }
509
+ isBusy={isBusy}
510
+ placeholder={field.placeholder}
511
+ onCommit={(typed) =>
512
+ commit(field.property)(toCssValue(field.property, typed))
513
+ }
514
+ scrub={'style' in node ? scrubOf(field.property, 0.1) : undefined}
515
+ />
516
+ ))}
517
+ <div class="segmented is-wide" role="group" aria-label="text alignment">
518
+ {TEXT_ALIGNS.map((align) => (
519
+ <button
520
+ key={align.value}
521
+ type="button"
522
+ class="segment"
523
+ aria-label={`Align ${align.value}`}
524
+ title={`Align ${align.value}`}
525
+ aria-pressed={textAlign === align.value}
526
+ disabled={isBusy}
527
+ onClick={() => {
528
+ if (textAlign !== align.value) {
529
+ commit('text-align')(align.value)
530
+ }
531
+ }}
532
+ >
533
+ <svg viewBox="0 0 16 16" aria-hidden="true">
534
+ <path d={align.path} />
535
+ </svg>
536
+ </button>
537
+ ))}
538
+ </div>
539
+ </Section>
249
540
  <Section title="Fill">
250
541
  {FILL_FIELDS.map((field) => {
251
542
  const painted = computedValue(node, field.property)
@@ -261,11 +552,16 @@ function ElementFields({
261
552
  label={field.label}
262
553
  color={parseColor(inline || painted)}
263
554
  text={inline || painted}
555
+ inline={inline}
264
556
  painted={painted}
557
+ owner={`${key}:${address?.index ?? -1}:${field.property}`}
265
558
  tokens={colorTokens}
266
559
  paint={(name) => tokenPaint(doc, name)}
267
560
  isRaw={isRawInline(node, field.property)}
268
561
  isBusy={isBusy}
562
+ preview={
563
+ 'style' in node ? scrubOf(field.property).begin : undefined
564
+ }
269
565
  onCommit={commit(field.property)}
270
566
  />
271
567
  )
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The canvas key layer: one map from a key event to a surface action, which
3
+ * the surface and every loaded frame document call. It sees only what the
4
+ * surface forwards, so a key typed into a panel field never reaches it.
5
+ */
6
+
7
+ export type SurfaceAction =
8
+ | 'tool-move'
9
+ | 'tool-pan'
10
+ | 'zoom-in'
11
+ | 'zoom-out'
12
+ | 'zoom-fit'
13
+ | 'pan-hold'
14
+ | 'pan-release'
15
+
16
+ /** The fields of a `KeyboardEvent` the map reads, so a test needs no DOM. */
17
+ export interface KeyInput {
18
+ readonly type: string
19
+ readonly key: string
20
+ readonly code: string
21
+ readonly shiftKey: boolean
22
+ readonly ctrlKey: boolean
23
+ readonly metaKey: boolean
24
+ readonly altKey: boolean
25
+ }
26
+
27
+ const PLAIN_KEYS: Readonly<Record<string, SurfaceAction | undefined>> = {
28
+ v: 'tool-move',
29
+ h: 'tool-pan',
30
+ '=': 'zoom-in',
31
+ '+': 'zoom-in',
32
+ '-': 'zoom-out',
33
+ ' ': 'pan-hold',
34
+ }
35
+
36
+ /**
37
+ * Cmd, Ctrl, and Alt combinations are the browser's or the system's, so any
38
+ * of them held on a press returns nothing. Space coming up releases whatever
39
+ * is held with it, or a modifier pressed mid-pan would leave the pan stuck.
40
+ * Shift and the 1 key fits by `code`, since the character Shift gives on that
41
+ * key differs by layout.
42
+ */
43
+ export function surfaceAction(event: KeyInput): SurfaceAction | undefined {
44
+ if (event.type === 'keyup') {
45
+ return event.key === ' ' ? 'pan-release' : undefined
46
+ }
47
+ if (event.type !== 'keydown') return undefined
48
+ if (event.ctrlKey || event.metaKey || event.altKey) return undefined
49
+ if (event.shiftKey) {
50
+ if (event.code === 'Digit1') return 'zoom-fit'
51
+ return event.key === '+' ? 'zoom-in' : undefined
52
+ }
53
+ return PLAIN_KEYS[event.key.toLowerCase()]
54
+ }