@erclx/canon 5.25.0 → 5.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/README.md +4 -3
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +14 -1
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  20. package/claude/skills/context-fold/SKILL.md +9 -21
  21. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  22. package/claude/skills/context-fold/references/completion-report.md +26 -0
  23. package/claude/skills/create-rule/SKILL.md +2 -0
  24. package/claude/skills/create-skill/SKILL.md +2 -0
  25. package/claude/skills/create-standard/SKILL.md +2 -0
  26. package/claude/skills/decision-escalate/SKILL.md +2 -0
  27. package/claude/skills/deploy-app/SKILL.md +2 -0
  28. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  29. package/claude/skills/design-extract/SKILL.md +2 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  31. package/claude/skills/design-taste/SKILL.md +6 -2
  32. package/claude/skills/design-taste/references/adopted.md +40 -0
  33. package/claude/skills/design-taste/references/kinds.md +1 -1
  34. package/claude/skills/design-taste/references/systems.md +1 -7
  35. package/claude/skills/design-taste/references/tells.md +2 -24
  36. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  37. package/claude/skills/docs-sync/SKILL.md +2 -0
  38. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  39. package/claude/skills/document-health/SKILL.md +23 -0
  40. package/claude/skills/document-health/references/axes.md +12 -0
  41. package/claude/skills/draft-and-pick/SKILL.md +3 -1
  42. package/claude/skills/draft-doc/SKILL.md +2 -0
  43. package/claude/skills/draft-doc/references/wiki.md +1 -1
  44. package/claude/skills/draft-figure/SKILL.md +2 -0
  45. package/claude/skills/draft-identity/SKILL.md +3 -1
  46. package/claude/skills/draft-ready/SKILL.md +2 -0
  47. package/claude/skills/draft-screencast/SKILL.md +11 -7
  48. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  49. package/claude/skills/draft-slides/SKILL.md +33 -20
  50. package/claude/skills/git-branch/SKILL.md +2 -0
  51. package/claude/skills/git-commit/SKILL.md +2 -0
  52. package/claude/skills/git-followup/SKILL.md +2 -0
  53. package/claude/skills/git-issue/SKILL.md +2 -0
  54. package/claude/skills/git-issue/references/issue.md +10 -0
  55. package/claude/skills/git-pr/SKILL.md +2 -0
  56. package/claude/skills/git-pr/references/evidence.md +14 -6
  57. package/claude/skills/git-pr/references/labels.md +12 -0
  58. package/claude/skills/git-ship/SKILL.md +2 -0
  59. package/claude/skills/git-split/SKILL.md +21 -8
  60. package/claude/skills/git-stage/SKILL.md +2 -0
  61. package/claude/skills/git-worktree/SKILL.md +2 -0
  62. package/claude/skills/index-lookup/SKILL.md +2 -0
  63. package/claude/skills/markdown-propose/SKILL.md +2 -0
  64. package/claude/skills/markdown-propose/references/format.md +9 -0
  65. package/claude/skills/memory-capture/SKILL.md +2 -0
  66. package/claude/skills/memory-review/SKILL.md +2 -0
  67. package/claude/skills/memory-review/references/later-phases.md +7 -0
  68. package/claude/skills/plan-feature/SKILL.md +2 -0
  69. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  70. package/claude/skills/plan-intake/SKILL.md +2 -0
  71. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  72. package/claude/skills/project-commands/SKILL.md +2 -0
  73. package/claude/skills/read-frames/SKILL.md +2 -0
  74. package/claude/skills/record-screencast/SKILL.md +2 -0
  75. package/claude/skills/repo-metadata/SKILL.md +2 -0
  76. package/claude/skills/restate-plainly/SKILL.md +2 -0
  77. package/claude/skills/review-address/SKILL.md +3 -7
  78. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  79. package/claude/skills/review-branch/SKILL.md +2 -0
  80. package/claude/skills/review-craft/SKILL.md +2 -0
  81. package/claude/skills/review-craft/references/ui.md +2 -2
  82. package/claude/skills/review-pr/SKILL.md +4 -8
  83. package/claude/skills/review-pr/references/body-variants.md +52 -0
  84. package/claude/skills/review-pr/references/key-changes.md +16 -0
  85. package/claude/skills/review-pr/references/post.md +15 -43
  86. package/claude/skills/review-ui/SKILL.md +3 -1
  87. package/claude/skills/review-ui/references/post.md +4 -2
  88. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  89. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  91. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  92. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  93. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  94. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  95. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  96. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  97. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  98. package/claude/skills/role-planner/SKILL.md +2 -0
  99. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  100. package/claude/skills/role-reviewer/SKILL.md +2 -0
  101. package/claude/skills/role-worker/SKILL.md +2 -0
  102. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  103. package/claude/skills/search-craft/SKILL.md +5 -1
  104. package/claude/skills/search-craft/references/adopted.md +30 -0
  105. package/claude/skills/seed-sync/SKILL.md +2 -0
  106. package/claude/skills/session-compact/SKILL.md +2 -0
  107. package/claude/skills/session-map/SKILL.md +2 -0
  108. package/claude/skills/session-relay/SKILL.md +2 -0
  109. package/claude/skills/session-resume/SKILL.md +2 -0
  110. package/claude/skills/session-worktree/SKILL.md +3 -11
  111. package/claude/skills/session-worktree/references/install-command.md +20 -0
  112. package/claude/skills/sketch-design/SKILL.md +2 -0
  113. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  114. package/claude/skills/target-check/SKILL.md +2 -0
  115. package/claude/skills/target-setup/SKILL.md +2 -0
  116. package/claude/skills/target-setup/references/detect.md +8 -0
  117. package/claude/skills/target-setup/references/indexes.md +13 -0
  118. package/claude/skills/target-setup/references/verify.md +8 -0
  119. package/claude/skills/task-board/SKILL.md +2 -0
  120. package/claude/skills/teach-workspace/SKILL.md +5 -15
  121. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  122. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  123. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  124. package/claude/skills/test-craft/SKILL.md +2 -0
  125. package/claude/skills/test-first/SKILL.md +4 -0
  126. package/claude/skills/test-first/references/adopted.md +36 -0
  127. package/claude/skills/ui-checklist/SKILL.md +2 -0
  128. package/claude/skills/ux-audit/SKILL.md +2 -0
  129. package/claude/skills/ux-measure/SKILL.md +2 -0
  130. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  131. package/claude/skills/video-craft/SKILL.md +2 -0
  132. package/claude/skills/write-human/SKILL.md +3 -1
  133. package/claude/skills/write-human/references/machine-tells.md +13 -0
  134. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  135. package/docs/agents/canvas.md +24 -6
  136. package/docs/agents/capture.md +6 -6
  137. package/docs/agents/commands.md +5 -5
  138. package/docs/agents/index.md +5 -5
  139. package/docs/agents/install-and-sync.md +7 -0
  140. package/docs/agents/key-changes.md +1 -1
  141. package/docs/agents/pr-evidence.md +38 -15
  142. package/docs/agents/pr-local.md +19 -13
  143. package/docs/agents/pr-preview.md +44 -21
  144. package/docs/agents/records-stale.md +22 -9
  145. package/docs/agents/records.md +1 -1
  146. package/docs/agents/sandbox.md +29 -5
  147. package/docs/agents/scripting.md +8 -1
  148. package/docs/agents/skills-audit.md +11 -3
  149. package/docs/agents/slides.md +15 -3
  150. package/docs/workflow/ai-workflow.md +1 -1
  151. package/docs/workflow/index.md +1 -0
  152. package/docs/workflow/operating-model.md +5 -6
  153. package/docs/workflow/parallel-features.md +23 -2
  154. package/docs/workflow/skill-map-on-request.md +42 -0
  155. package/docs/workflow/skill-map.md +3 -34
  156. package/docs/workflow/zshrc-aliases.md +1 -1
  157. package/package.json +1 -1
  158. package/scripts/core/regen-hero.ts +1 -1
  159. package/scripts/lib/sandbox-git.sh +1 -9
  160. package/scripts/lib/sandbox-path.sh +6 -162
  161. package/scripts/sandbox-hook.sh +99 -0
  162. package/src/audits/run.ts +1 -1
  163. package/src/canvas/address.ts +12 -3
  164. package/src/canvas/capture.ts +1 -9
  165. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  166. package/src/canvas/client/inspector/color-field.tsx +398 -0
  167. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  168. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  169. package/src/canvas/client/inspector/color-space.ts +240 -0
  170. package/src/canvas/client/inspector/field.tsx +5 -1
  171. package/src/canvas/client/inspector/fields.css +547 -17
  172. package/src/canvas/client/inspector/size-field.tsx +69 -0
  173. package/src/canvas/client/inspector/values.ts +166 -1
  174. package/src/canvas/client/inspector.tsx +236 -73
  175. package/src/canvas/client/selection.tsx +197 -0
  176. package/src/canvas/client/shell.css +104 -12
  177. package/src/canvas/client/state.ts +69 -9
  178. package/src/canvas/client/surface.tsx +147 -22
  179. package/src/canvas/content.ts +50 -1
  180. package/src/canvas/edit.ts +37 -4
  181. package/src/canvas/editing.ts +203 -0
  182. package/src/canvas/global.d.ts +15 -0
  183. package/src/canvas/server.ts +149 -12
  184. package/src/canvas/tokens.ts +59 -17
  185. package/src/capture/render.ts +45 -7
  186. package/src/capture/sources.ts +43 -0
  187. package/src/census/count.ts +2 -2
  188. package/src/claude/cases/workflow.ts +5 -0
  189. package/src/claude/seeds.ts +2 -2
  190. package/src/claude/skills-audit.ts +96 -14
  191. package/src/claude/skills-drift.ts +1 -1
  192. package/src/claude/skills-families.ts +36 -0
  193. package/src/claude/skills-list.ts +76 -15
  194. package/src/commands/audits.ts +2 -2
  195. package/src/commands/canvas.ts +179 -2
  196. package/src/commands/claude.ts +94 -11
  197. package/src/commands/design.ts +142 -2
  198. package/src/commands/feedback.ts +3 -3
  199. package/src/commands/gov.ts +1 -1
  200. package/src/commands/hooks.ts +1 -1
  201. package/src/commands/indexes.ts +1 -1
  202. package/src/commands/init.ts +1 -1
  203. package/src/commands/intake.ts +1 -1
  204. package/src/commands/migrate.ts +1 -1
  205. package/src/commands/pr.ts +105 -58
  206. package/src/commands/records.ts +89 -7
  207. package/src/commands/repo.ts +1 -1
  208. package/src/commands/sandbox.ts +103 -11
  209. package/src/commands/sessions.ts +1 -1
  210. package/src/commands/slides.ts +31 -77
  211. package/src/commands/sync.ts +1 -1
  212. package/src/commands/tasks.ts +2 -2
  213. package/src/commands/teach.ts +1 -1
  214. package/src/commands/transcripts.ts +1 -1
  215. package/src/commands/worktrees.ts +1 -1
  216. package/src/comments/scan.ts +1 -1
  217. package/src/comments/trend.ts +1 -1
  218. package/src/context/audit.ts +1 -1
  219. package/src/context/citations.ts +1 -1
  220. package/src/context/classify/extract.ts +2 -2
  221. package/src/design/project-fonts.ts +392 -0
  222. package/src/design/tokens.ts +2 -18
  223. package/src/docs/read.ts +1 -1
  224. package/src/{github.ts → feedback/github.ts} +2 -2
  225. package/src/{client-commands.ts → gate/client-commands.ts} +1 -1
  226. package/src/gate/measures.ts +53 -13
  227. package/src/gate/sequencer.ts +1 -1
  228. package/src/gate/stages.ts +9 -0
  229. package/src/{git-files.ts → git/files.ts} +2 -2
  230. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  231. package/src/{worktree.ts → git/worktree.ts} +1 -1
  232. package/src/gov/citations.ts +2 -2
  233. package/src/gov/install.ts +1 -1
  234. package/src/gov/payload.ts +1 -1
  235. package/src/gov/superseded.ts +3 -3
  236. package/src/gov/test-order.ts +2 -2
  237. package/src/help.ts +1 -1
  238. package/src/hooks/post-merge.ts +1 -1
  239. package/src/indexes/walk.ts +1 -1
  240. package/src/labels/audit.ts +1 -1
  241. package/src/markdown/ceiling.ts +1 -1
  242. package/src/markdown/files.ts +1 -1
  243. package/src/migrate/apply.ts +1 -1
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +96 -43
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/record-root.ts +1 -0
  249. package/src/records/backup.ts +2 -1
  250. package/src/records/push-guard.ts +1 -1
  251. package/src/records/stale-canonical.ts +146 -0
  252. package/src/records/stale.ts +1 -1
  253. package/src/sandbox/headless/dispatch.ts +264 -0
  254. package/src/sandbox/headless/run.ts +653 -0
  255. package/src/sandbox/headless/watch.ts +145 -0
  256. package/src/sandbox/hooks.ts +128 -0
  257. package/src/sandbox/provision.ts +607 -0
  258. package/src/sandbox/tree.ts +99 -4
  259. package/src/secrets/marker.ts +2 -2
  260. package/src/secrets/scan.ts +2 -2
  261. package/src/sessions/claim.ts +1 -1
  262. package/src/sessions/resolve.ts +1 -1
  263. package/src/sessions/transfer/bundle.ts +1 -1
  264. package/src/shipped/references.ts +2 -2
  265. package/src/slides/convert/export.ts +2 -16
  266. package/src/slides/decks.ts +110 -0
  267. package/src/slides/package/fonts.ts +6 -2
  268. package/src/standards/audit.ts +2 -2
  269. package/src/standards/read.ts +1 -1
  270. package/src/sync/check.ts +2 -2
  271. package/src/sync/engine.ts +2 -2
  272. package/src/sync/git.ts +1 -1
  273. package/src/sync/history.ts +1 -1
  274. package/src/sync/reverse.ts +1 -1
  275. package/src/sync/seeds-report.ts +1 -1
  276. package/src/sync/target.ts +1 -1
  277. package/src/sync/workflow.ts +1 -1
  278. package/src/targets/pulls.ts +2 -2
  279. package/src/targets/sweep.ts +2 -2
  280. package/src/tasks/answers.ts +1 -1
  281. package/src/tasks/archive.ts +1 -1
  282. package/src/tasks/reach.ts +1 -1
  283. package/src/tasks/trunk.ts +1 -1
  284. package/src/teach/nav.ts +1 -1
  285. package/src/tooling/inject.ts +2 -2
  286. package/src/tooling/subfolder.ts +1 -1
  287. package/src/web/readme-citations.ts +2 -2
  288. package/src/worktrees/bare-flag.ts +1 -1
  289. package/src/worktrees/reclaim.ts +2 -2
  290. package/src/worktrees/remove.ts +1 -1
  291. package/standards/architecture.md +1 -0
  292. package/standards/canvas.md +3 -2
  293. package/standards/requirements.md +2 -0
  294. package/standards/skill-practice.md +2 -1
  295. package/standards/skill.md +2 -0
  296. package/standards/wiki.md +12 -3
  297. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  298. package/scripts/lib/sandbox-dispatch.sh +0 -190
  299. package/scripts/manage-sandbox.sh +0 -472
  300. package/src/slides/layouts.ts +0 -478
  301. package/src/slides/parse.ts +0 -84
  302. package/src/slides/render.ts +0 -110
  303. package/src/slides/styles.ts +0 -96
  304. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  305. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  306. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  307. /package/src/{git-base.ts → git/base.ts} +0 -0
  308. /package/src/{git-env.ts → git/env.ts} +0 -0
  309. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  310. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  311. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  312. /package/src/{copy.ts → sync/copy.ts} +0 -0
  313. /package/src/{target.ts → targets/validate.ts} +0 -0
  314. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  315. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -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
@@ -58,3 +100,126 @@ export function clampScrub(property: string, value: number): number {
58
100
  export function scrubStep(isCoarse: boolean): number {
59
101
  return isCoarse ? 10 : 1
60
102
  }
103
+
104
+ /** A color the picker can write: a token or a hex, each with an opacity. */
105
+ export type WrittenColor =
106
+ | {
107
+ readonly kind: 'token'
108
+ /** The custom property name as the token sheet gives it. */
109
+ readonly name: string
110
+ readonly opacity: number
111
+ }
112
+ | { readonly kind: 'hex'; readonly hex: string; readonly opacity: number }
113
+
114
+ export type ParsedColor = WrittenColor | { readonly kind: 'none' }
115
+
116
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
117
+ const RGB =
118
+ /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i
119
+ /** What a browser computes `color-mix()` in srgb to, channels 0 to 1. */
120
+ const SRGB =
121
+ /^color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i
122
+ const VAR = /^var\(\s*(--[\w-]+)\s*\)$/i
123
+ const MIX =
124
+ /^color-mix\(\s*in\s+srgb\s*,\s*var\(\s*(--[\w-]+)\s*\)\s+([\d.]+)%\s*,\s*transparent\s*\)$/i
125
+
126
+ function channel(value: number): string {
127
+ return Math.round(Math.min(255, Math.max(0, value)))
128
+ .toString(16)
129
+ .padStart(2, '0')
130
+ }
131
+
132
+ /**
133
+ * An alpha as a percent. A value past 1 clamps to full rather than reading as
134
+ * a 0 to 255 scale, since CSS clamps it the same way when it paints.
135
+ */
136
+ function alphaPercent(alpha: string | undefined): number {
137
+ if (alpha === undefined) return 100
138
+ const fraction = alpha.endsWith('%')
139
+ ? Number(alpha.slice(0, -1)) / 100
140
+ : Number(alpha)
141
+ return Math.round(Math.min(1, Math.max(0, fraction)) * 100)
142
+ }
143
+
144
+ /** A typed hex in any of its lengths, normalized to six digits. */
145
+ export function readHex(
146
+ typed: string,
147
+ ): { readonly hex: string; readonly opacity?: number } | undefined {
148
+ const match = typed.trim().match(HEX)
149
+ if (!match) return undefined
150
+ const digits = match[1].toLowerCase()
151
+ const full =
152
+ digits.length <= 4
153
+ ? [...digits].map((digit) => digit + digit).join('')
154
+ : digits
155
+ const hex = full.slice(0, 6)
156
+ if (full.length === 6) return { hex }
157
+ return {
158
+ hex,
159
+ opacity: Math.round((parseInt(full.slice(6), 16) / 255) * 100),
160
+ }
161
+ }
162
+
163
+ /** A typed opacity percent, refused outside 0 to 100. */
164
+ export function readOpacity(typed: string): number | undefined {
165
+ const value = typed.trim().replace(/%$/, '')
166
+ if (!/^-?\d*\.?\d+$/.test(value)) return undefined
167
+ const percent = Number(value)
168
+ return percent >= 0 && percent <= 100 ? Math.round(percent) : undefined
169
+ }
170
+
171
+ /**
172
+ * Reads a computed or inline color into what the picker shows. A form it does
173
+ * not know, such as a named color, comes back undefined.
174
+ */
175
+ export function parseColor(value: string): ParsedColor | undefined {
176
+ const text = value.trim()
177
+ if (isNoFill(text)) return { kind: 'none' }
178
+ const token = text.match(VAR)
179
+ if (token) return { kind: 'token', name: token[1], opacity: 100 }
180
+ const mix = text.match(MIX)
181
+ if (mix) {
182
+ return {
183
+ kind: 'token',
184
+ name: mix[1],
185
+ opacity: Math.round(Math.min(100, Number(mix[2]))),
186
+ }
187
+ }
188
+ const hex = text.startsWith('#') ? readHex(text) : undefined
189
+ if (hex) return painted(hex.hex, hex.opacity ?? 100)
190
+ const rgb = text.match(RGB)
191
+ if (rgb) {
192
+ return painted(
193
+ [rgb[1], rgb[2], rgb[3]].map((part) => channel(Number(part))).join(''),
194
+ alphaPercent(rgb[4]),
195
+ )
196
+ }
197
+ const srgb = text.match(SRGB)
198
+ if (!srgb) return undefined
199
+ return painted(
200
+ [srgb[1], srgb[2], srgb[3]]
201
+ .map((part) => channel(Number(part) * 255))
202
+ .join(''),
203
+ alphaPercent(srgb[4]),
204
+ )
205
+ }
206
+
207
+ /** A color at zero opacity paints nothing, whichever form wrote it. */
208
+ function painted(hex: string, opacity: number): ParsedColor {
209
+ return opacity === 0 ? { kind: 'none' } : { kind: 'hex', hex, opacity }
210
+ }
211
+
212
+ /**
213
+ * The value a pick writes. A token below full opacity mixes with transparent
214
+ * rather than resolving to a hex, so it keeps following the theme.
215
+ */
216
+ export function composeColor(color: WrittenColor): string {
217
+ if (color.kind === 'token') {
218
+ return color.opacity >= 100
219
+ ? `var(${color.name})`
220
+ : `color-mix(in srgb, var(${color.name}) ${color.opacity}%, transparent)`
221
+ }
222
+ return color.opacity >= 100
223
+ ? `#${color.hex}`
224
+ : `#${color.hex}${channel((color.opacity / 100) * 255)}`
225
+ }
@@ -1,15 +1,22 @@
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'
6
+ import { ColorField } from '@/canvas/client/inspector/color-field'
4
7
  import {
5
8
  Field,
6
9
  ReadOnlyField,
7
10
  type Scrub,
8
11
  } from '@/canvas/client/inspector/field'
9
12
  import { Section } from '@/canvas/client/inspector/section'
13
+ import { SizeField } from '@/canvas/client/inspector/size-field'
10
14
  import {
11
15
  clampScrub,
12
16
  displayValue,
17
+ opacityToCss,
18
+ parseColor,
19
+ sharedRadius,
13
20
  toCssValue,
14
21
  } from '@/canvas/client/inspector/values'
15
22
  import {
@@ -64,26 +71,56 @@ const SIZE_FIELDS: readonly StyleField[] = [
64
71
  { label: 'height', glyph: 'H', property: 'height' },
65
72
  ]
66
73
 
74
+ const DIRECTION_FIELD: StyleField = {
75
+ label: 'direction',
76
+ glyph: 'Dir',
77
+ property: 'flex-direction',
78
+ }
79
+
67
80
  const FLEX_FIELDS: readonly StyleField[] = [
68
- { label: 'direction', glyph: 'Dir', property: 'flex-direction' },
69
81
  { label: 'gap', glyph: 'Gap', property: 'gap', placeholder: '0' },
70
- { label: 'padding', glyph: 'Pad', property: 'padding', isWide: true },
71
82
  ]
72
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
+
73
115
  const TYPE_FIELDS: readonly StyleField[] = [
74
116
  { label: 'size', glyph: 'Size', property: 'font-size' },
75
117
  { label: 'weight', glyph: 'Wt', property: 'font-weight' },
76
118
  ]
77
119
 
78
- const FILL_FIELDS: readonly StyleField[] = [
79
- { label: 'color', glyph: 'Fg', property: 'color' },
80
- {
81
- label: 'background',
82
- glyph: 'Bg',
83
- property: 'background-color',
84
- placeholder: 'None',
85
- },
86
- ]
120
+ const FILL_FIELDS = [
121
+ { label: 'color', property: 'color' },
122
+ { label: 'background', property: 'background-color' },
123
+ ] as const
87
124
 
88
125
  function inlineValue(element: Element, property: string): string {
89
126
  return 'style' in element
@@ -127,8 +164,10 @@ function isRawInline(element: Element, property: string): boolean {
127
164
  * an edit starts from what the operator sees.
128
165
  */
129
166
  function currentValue(element: Element, property: string): string {
130
- const inline = inlineValue(element, property)
131
- if (inline) return inline
167
+ return inlineValue(element, property) || computedValue(element, property)
168
+ }
169
+
170
+ function computedValue(element: Element, property: string): string {
132
171
  return (
133
172
  element.ownerDocument.defaultView
134
173
  ?.getComputedStyle(element)
@@ -136,6 +175,19 @@ function currentValue(element: Element, property: string): string {
136
175
  )
137
176
  }
138
177
 
178
+ /**
179
+ * The color a token paints in the frame, read off a probe rather than the
180
+ * token's text, since a token's value may itself be a `var()` or a mix.
181
+ */
182
+ function tokenPaint(doc: Document, name: string): string {
183
+ const probe = doc.createElement('span')
184
+ probe.style.setProperty('color', `var(${name})`)
185
+ ;(doc.body ?? doc.documentElement).append(probe)
186
+ const painted = computedValue(probe, 'color')
187
+ probe.remove()
188
+ return painted
189
+ }
190
+
139
191
  function holdsTextAlone(element: Element): boolean {
140
192
  const tag = element.tagName.toLowerCase()
141
193
  return element.children.length === 0 && !VOID.has(tag) && !isRawText(tag)
@@ -146,46 +198,6 @@ function elementName(element: Element): string {
146
198
  return [tag, ...element.classList].join('.')
147
199
  }
148
200
 
149
- interface TokenPickerProps {
150
- readonly label: string
151
- readonly initial: string
152
- readonly isBusy: boolean
153
- readonly onCommit: (value: string) => void
154
- }
155
-
156
- function TokenPicker({
157
- label,
158
- initial,
159
- isBusy,
160
- onCommit,
161
- }: TokenPickerProps): JSX.Element | null {
162
- const colors =
163
- tokens.value?.groups?.find((group) => group.kind === 'color')?.tokens ?? []
164
- if (colors.length === 0) return null
165
- return (
166
- <select
167
- class="token-picker"
168
- aria-label={`${label} token`}
169
- title="Pick a project token"
170
- value={
171
- colors.some((token) => initial === `var(${token.name})`) ? initial : ''
172
- }
173
- disabled={isBusy}
174
- onChange={(event) => {
175
- const { value } = event.currentTarget
176
- if (value !== '' && value !== initial) onCommit(value)
177
- }}
178
- >
179
- <option value="">Token</option>
180
- {colors.map((token) => (
181
- <option key={token.name} value={`var(${token.name})`}>
182
- {token.name}
183
- </option>
184
- ))}
185
- </select>
186
- )
187
- }
188
-
189
201
  function ElementFields({
190
202
  node,
191
203
  doc,
@@ -203,9 +215,38 @@ function ElementFields({
203
215
  const commit = (property: string) => (value: string) => {
204
216
  if (address) void editElement(frameRef, key, address, property, value)
205
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'
206
243
  const rect = node.getBoundingClientRect()
207
244
  const font = currentValue(node, 'font-family')
208
245
  const text = node.textContent ?? ''
246
+ const colorTokens =
247
+ tokens.value?.groups
248
+ ?.find((group) => group.kind === 'color')
249
+ ?.tokens.map((token) => token.name) ?? []
209
250
 
210
251
  /**
211
252
  * Previews into the frame's own inline style and writes through the same
@@ -261,9 +302,125 @@ function ElementFields({
261
302
  glyph="Y"
262
303
  value={String(Math.round(rect.top))}
263
304
  />
264
- {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
+ )}
265
423
  </Section>
266
- <Section title="Flex">{FLEX_FIELDS.map(styleField)}</Section>
267
424
  <Section title="Typography">
268
425
  {TYPE_FIELDS.map(styleField)}
269
426
  {font ? (
@@ -272,25 +429,31 @@ function ElementFields({
272
429
  </Section>
273
430
  <Section title="Fill">
274
431
  {FILL_FIELDS.map((field) => {
275
- const initial = currentValue(node, field.property)
432
+ const painted = computedValue(node, field.property)
433
+ const inline = inlineValue(node, field.property)
434
+ /*
435
+ * An inline value the picker cannot read stays as written. Reading
436
+ * the computed color in its place would show a token-following
437
+ * value as a literal, and an opacity edit would freeze it.
438
+ */
276
439
  return (
277
- <div key={field.property} class="fill-row is-wide">
278
- {styleField(field)}
279
- {isRawInline(node, field.property) ? (
280
- <span
281
- class="raw"
282
- title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
283
- >
284
- raw
285
- </span>
286
- ) : null}
287
- <TokenPicker
288
- label={field.label}
289
- initial={initial}
290
- isBusy={isBusy}
291
- onCommit={commit(field.property)}
292
- />
293
- </div>
440
+ <ColorField
441
+ key={field.property}
442
+ label={field.label}
443
+ color={parseColor(inline || painted)}
444
+ text={inline || painted}
445
+ inline={inline}
446
+ painted={painted}
447
+ owner={`${key}:${address?.index ?? -1}:${field.property}`}
448
+ tokens={colorTokens}
449
+ paint={(name) => tokenPaint(doc, name)}
450
+ isRaw={isRawInline(node, field.property)}
451
+ isBusy={isBusy}
452
+ preview={
453
+ 'style' in node ? scrubOf(field.property).begin : undefined
454
+ }
455
+ onCommit={commit(field.property)}
456
+ />
294
457
  )
295
458
  })}
296
459
  </Section>