@erclx/canon 5.25.0 → 5.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/README.md +4 -3
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +14 -1
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  20. package/claude/skills/context-fold/SKILL.md +9 -21
  21. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  22. package/claude/skills/context-fold/references/completion-report.md +26 -0
  23. package/claude/skills/create-rule/SKILL.md +2 -0
  24. package/claude/skills/create-skill/SKILL.md +2 -0
  25. package/claude/skills/create-standard/SKILL.md +2 -0
  26. package/claude/skills/decision-escalate/SKILL.md +2 -0
  27. package/claude/skills/deploy-app/SKILL.md +2 -0
  28. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  29. package/claude/skills/design-extract/SKILL.md +2 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  31. package/claude/skills/design-taste/SKILL.md +6 -2
  32. package/claude/skills/design-taste/references/adopted.md +40 -0
  33. package/claude/skills/design-taste/references/kinds.md +1 -1
  34. package/claude/skills/design-taste/references/systems.md +1 -7
  35. package/claude/skills/design-taste/references/tells.md +2 -24
  36. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  37. package/claude/skills/docs-sync/SKILL.md +2 -0
  38. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  39. package/claude/skills/document-health/SKILL.md +23 -0
  40. package/claude/skills/document-health/references/axes.md +12 -0
  41. package/claude/skills/draft-and-pick/SKILL.md +3 -1
  42. package/claude/skills/draft-doc/SKILL.md +2 -0
  43. package/claude/skills/draft-doc/references/wiki.md +1 -1
  44. package/claude/skills/draft-figure/SKILL.md +2 -0
  45. package/claude/skills/draft-identity/SKILL.md +3 -1
  46. package/claude/skills/draft-ready/SKILL.md +2 -0
  47. package/claude/skills/draft-screencast/SKILL.md +11 -7
  48. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  49. package/claude/skills/draft-slides/SKILL.md +33 -20
  50. package/claude/skills/git-branch/SKILL.md +2 -0
  51. package/claude/skills/git-commit/SKILL.md +2 -0
  52. package/claude/skills/git-followup/SKILL.md +2 -0
  53. package/claude/skills/git-issue/SKILL.md +2 -0
  54. package/claude/skills/git-issue/references/issue.md +10 -0
  55. package/claude/skills/git-pr/SKILL.md +2 -0
  56. package/claude/skills/git-pr/references/evidence.md +14 -6
  57. package/claude/skills/git-pr/references/labels.md +12 -0
  58. package/claude/skills/git-ship/SKILL.md +2 -0
  59. package/claude/skills/git-split/SKILL.md +21 -8
  60. package/claude/skills/git-stage/SKILL.md +2 -0
  61. package/claude/skills/git-worktree/SKILL.md +2 -0
  62. package/claude/skills/index-lookup/SKILL.md +2 -0
  63. package/claude/skills/markdown-propose/SKILL.md +2 -0
  64. package/claude/skills/markdown-propose/references/format.md +9 -0
  65. package/claude/skills/memory-capture/SKILL.md +2 -0
  66. package/claude/skills/memory-review/SKILL.md +2 -0
  67. package/claude/skills/memory-review/references/later-phases.md +7 -0
  68. package/claude/skills/plan-feature/SKILL.md +2 -0
  69. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  70. package/claude/skills/plan-intake/SKILL.md +2 -0
  71. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  72. package/claude/skills/project-commands/SKILL.md +2 -0
  73. package/claude/skills/read-frames/SKILL.md +2 -0
  74. package/claude/skills/record-screencast/SKILL.md +2 -0
  75. package/claude/skills/repo-metadata/SKILL.md +2 -0
  76. package/claude/skills/restate-plainly/SKILL.md +2 -0
  77. package/claude/skills/review-address/SKILL.md +3 -7
  78. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  79. package/claude/skills/review-branch/SKILL.md +2 -0
  80. package/claude/skills/review-craft/SKILL.md +2 -0
  81. package/claude/skills/review-craft/references/ui.md +2 -2
  82. package/claude/skills/review-pr/SKILL.md +4 -8
  83. package/claude/skills/review-pr/references/body-variants.md +52 -0
  84. package/claude/skills/review-pr/references/key-changes.md +16 -0
  85. package/claude/skills/review-pr/references/post.md +15 -43
  86. package/claude/skills/review-ui/SKILL.md +3 -1
  87. package/claude/skills/review-ui/references/post.md +4 -2
  88. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  89. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  91. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  92. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  93. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  94. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  95. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  96. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  97. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  98. package/claude/skills/role-planner/SKILL.md +2 -0
  99. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  100. package/claude/skills/role-reviewer/SKILL.md +2 -0
  101. package/claude/skills/role-worker/SKILL.md +2 -0
  102. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  103. package/claude/skills/search-craft/SKILL.md +5 -1
  104. package/claude/skills/search-craft/references/adopted.md +30 -0
  105. package/claude/skills/seed-sync/SKILL.md +2 -0
  106. package/claude/skills/session-compact/SKILL.md +2 -0
  107. package/claude/skills/session-map/SKILL.md +2 -0
  108. package/claude/skills/session-relay/SKILL.md +2 -0
  109. package/claude/skills/session-resume/SKILL.md +2 -0
  110. package/claude/skills/session-worktree/SKILL.md +3 -11
  111. package/claude/skills/session-worktree/references/install-command.md +20 -0
  112. package/claude/skills/sketch-design/SKILL.md +2 -0
  113. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  114. package/claude/skills/target-check/SKILL.md +2 -0
  115. package/claude/skills/target-setup/SKILL.md +2 -0
  116. package/claude/skills/target-setup/references/detect.md +8 -0
  117. package/claude/skills/target-setup/references/indexes.md +13 -0
  118. package/claude/skills/target-setup/references/verify.md +8 -0
  119. package/claude/skills/task-board/SKILL.md +2 -0
  120. package/claude/skills/teach-workspace/SKILL.md +5 -15
  121. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  122. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  123. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  124. package/claude/skills/test-craft/SKILL.md +2 -0
  125. package/claude/skills/test-first/SKILL.md +4 -0
  126. package/claude/skills/test-first/references/adopted.md +36 -0
  127. package/claude/skills/ui-checklist/SKILL.md +2 -0
  128. package/claude/skills/ux-audit/SKILL.md +2 -0
  129. package/claude/skills/ux-measure/SKILL.md +2 -0
  130. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  131. package/claude/skills/video-craft/SKILL.md +2 -0
  132. package/claude/skills/write-human/SKILL.md +3 -1
  133. package/claude/skills/write-human/references/machine-tells.md +13 -0
  134. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  135. package/docs/agents/canvas.md +24 -6
  136. package/docs/agents/capture.md +6 -6
  137. package/docs/agents/commands.md +5 -5
  138. package/docs/agents/index.md +5 -5
  139. package/docs/agents/install-and-sync.md +7 -0
  140. package/docs/agents/key-changes.md +1 -1
  141. package/docs/agents/pr-evidence.md +38 -15
  142. package/docs/agents/pr-local.md +19 -13
  143. package/docs/agents/pr-preview.md +44 -21
  144. package/docs/agents/records-stale.md +22 -9
  145. package/docs/agents/records.md +1 -1
  146. package/docs/agents/sandbox.md +29 -5
  147. package/docs/agents/scripting.md +8 -1
  148. package/docs/agents/skills-audit.md +11 -3
  149. package/docs/agents/slides.md +15 -3
  150. package/docs/workflow/ai-workflow.md +1 -1
  151. package/docs/workflow/index.md +1 -0
  152. package/docs/workflow/operating-model.md +5 -6
  153. package/docs/workflow/parallel-features.md +23 -2
  154. package/docs/workflow/skill-map-on-request.md +42 -0
  155. package/docs/workflow/skill-map.md +3 -34
  156. package/docs/workflow/zshrc-aliases.md +1 -1
  157. package/package.json +1 -1
  158. package/scripts/core/regen-hero.ts +1 -1
  159. package/scripts/lib/sandbox-git.sh +1 -9
  160. package/scripts/lib/sandbox-path.sh +6 -162
  161. package/scripts/sandbox-hook.sh +99 -0
  162. package/src/audits/run.ts +1 -1
  163. package/src/canvas/address.ts +12 -3
  164. package/src/canvas/capture.ts +1 -9
  165. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  166. package/src/canvas/client/inspector/color-field.tsx +398 -0
  167. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  168. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  169. package/src/canvas/client/inspector/color-space.ts +240 -0
  170. package/src/canvas/client/inspector/field.tsx +5 -1
  171. package/src/canvas/client/inspector/fields.css +547 -17
  172. package/src/canvas/client/inspector/size-field.tsx +69 -0
  173. package/src/canvas/client/inspector/values.ts +166 -1
  174. package/src/canvas/client/inspector.tsx +236 -73
  175. package/src/canvas/client/selection.tsx +197 -0
  176. package/src/canvas/client/shell.css +104 -12
  177. package/src/canvas/client/state.ts +69 -9
  178. package/src/canvas/client/surface.tsx +147 -22
  179. package/src/canvas/content.ts +50 -1
  180. package/src/canvas/edit.ts +37 -4
  181. package/src/canvas/editing.ts +203 -0
  182. package/src/canvas/global.d.ts +15 -0
  183. package/src/canvas/server.ts +149 -12
  184. package/src/canvas/tokens.ts +59 -17
  185. package/src/capture/render.ts +45 -7
  186. package/src/capture/sources.ts +43 -0
  187. package/src/census/count.ts +2 -2
  188. package/src/claude/cases/workflow.ts +5 -0
  189. package/src/claude/seeds.ts +2 -2
  190. package/src/claude/skills-audit.ts +96 -14
  191. package/src/claude/skills-drift.ts +1 -1
  192. package/src/claude/skills-families.ts +36 -0
  193. package/src/claude/skills-list.ts +76 -15
  194. package/src/commands/audits.ts +2 -2
  195. package/src/commands/canvas.ts +179 -2
  196. package/src/commands/claude.ts +94 -11
  197. package/src/commands/design.ts +142 -2
  198. package/src/commands/feedback.ts +3 -3
  199. package/src/commands/gov.ts +1 -1
  200. package/src/commands/hooks.ts +1 -1
  201. package/src/commands/indexes.ts +1 -1
  202. package/src/commands/init.ts +1 -1
  203. package/src/commands/intake.ts +1 -1
  204. package/src/commands/migrate.ts +1 -1
  205. package/src/commands/pr.ts +105 -58
  206. package/src/commands/records.ts +89 -7
  207. package/src/commands/repo.ts +1 -1
  208. package/src/commands/sandbox.ts +103 -11
  209. package/src/commands/sessions.ts +1 -1
  210. package/src/commands/slides.ts +31 -77
  211. package/src/commands/sync.ts +1 -1
  212. package/src/commands/tasks.ts +2 -2
  213. package/src/commands/teach.ts +1 -1
  214. package/src/commands/transcripts.ts +1 -1
  215. package/src/commands/worktrees.ts +1 -1
  216. package/src/comments/scan.ts +1 -1
  217. package/src/comments/trend.ts +1 -1
  218. package/src/context/audit.ts +1 -1
  219. package/src/context/citations.ts +1 -1
  220. package/src/context/classify/extract.ts +2 -2
  221. package/src/design/project-fonts.ts +392 -0
  222. package/src/design/tokens.ts +2 -18
  223. package/src/docs/read.ts +1 -1
  224. package/src/{github.ts → feedback/github.ts} +2 -2
  225. package/src/{client-commands.ts → gate/client-commands.ts} +1 -1
  226. package/src/gate/measures.ts +53 -13
  227. package/src/gate/sequencer.ts +1 -1
  228. package/src/gate/stages.ts +9 -0
  229. package/src/{git-files.ts → git/files.ts} +2 -2
  230. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  231. package/src/{worktree.ts → git/worktree.ts} +1 -1
  232. package/src/gov/citations.ts +2 -2
  233. package/src/gov/install.ts +1 -1
  234. package/src/gov/payload.ts +1 -1
  235. package/src/gov/superseded.ts +3 -3
  236. package/src/gov/test-order.ts +2 -2
  237. package/src/help.ts +1 -1
  238. package/src/hooks/post-merge.ts +1 -1
  239. package/src/indexes/walk.ts +1 -1
  240. package/src/labels/audit.ts +1 -1
  241. package/src/markdown/ceiling.ts +1 -1
  242. package/src/markdown/files.ts +1 -1
  243. package/src/migrate/apply.ts +1 -1
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +96 -43
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/record-root.ts +1 -0
  249. package/src/records/backup.ts +2 -1
  250. package/src/records/push-guard.ts +1 -1
  251. package/src/records/stale-canonical.ts +146 -0
  252. package/src/records/stale.ts +1 -1
  253. package/src/sandbox/headless/dispatch.ts +264 -0
  254. package/src/sandbox/headless/run.ts +653 -0
  255. package/src/sandbox/headless/watch.ts +145 -0
  256. package/src/sandbox/hooks.ts +128 -0
  257. package/src/sandbox/provision.ts +607 -0
  258. package/src/sandbox/tree.ts +99 -4
  259. package/src/secrets/marker.ts +2 -2
  260. package/src/secrets/scan.ts +2 -2
  261. package/src/sessions/claim.ts +1 -1
  262. package/src/sessions/resolve.ts +1 -1
  263. package/src/sessions/transfer/bundle.ts +1 -1
  264. package/src/shipped/references.ts +2 -2
  265. package/src/slides/convert/export.ts +2 -16
  266. package/src/slides/decks.ts +110 -0
  267. package/src/slides/package/fonts.ts +6 -2
  268. package/src/standards/audit.ts +2 -2
  269. package/src/standards/read.ts +1 -1
  270. package/src/sync/check.ts +2 -2
  271. package/src/sync/engine.ts +2 -2
  272. package/src/sync/git.ts +1 -1
  273. package/src/sync/history.ts +1 -1
  274. package/src/sync/reverse.ts +1 -1
  275. package/src/sync/seeds-report.ts +1 -1
  276. package/src/sync/target.ts +1 -1
  277. package/src/sync/workflow.ts +1 -1
  278. package/src/targets/pulls.ts +2 -2
  279. package/src/targets/sweep.ts +2 -2
  280. package/src/tasks/answers.ts +1 -1
  281. package/src/tasks/archive.ts +1 -1
  282. package/src/tasks/reach.ts +1 -1
  283. package/src/tasks/trunk.ts +1 -1
  284. package/src/teach/nav.ts +1 -1
  285. package/src/tooling/inject.ts +2 -2
  286. package/src/tooling/subfolder.ts +1 -1
  287. package/src/web/readme-citations.ts +2 -2
  288. package/src/worktrees/bare-flag.ts +1 -1
  289. package/src/worktrees/reclaim.ts +2 -2
  290. package/src/worktrees/remove.ts +1 -1
  291. package/standards/architecture.md +1 -0
  292. package/standards/canvas.md +3 -2
  293. package/standards/requirements.md +2 -0
  294. package/standards/skill-practice.md +2 -1
  295. package/standards/skill.md +2 -0
  296. package/standards/wiki.md +12 -3
  297. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  298. package/scripts/lib/sandbox-dispatch.sh +0 -190
  299. package/scripts/manage-sandbox.sh +0 -472
  300. package/src/slides/layouts.ts +0 -478
  301. package/src/slides/parse.ts +0 -84
  302. package/src/slides/render.ts +0 -110
  303. package/src/slides/styles.ts +0 -96
  304. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  305. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  306. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  307. /package/src/{git-base.ts → git/base.ts} +0 -0
  308. /package/src/{git-env.ts → git/env.ts} +0 -0
  309. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  310. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  311. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  312. /package/src/{copy.ts → sync/copy.ts} +0 -0
  313. /package/src/{target.ts → targets/validate.ts} +0 -0
  314. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  315. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -0,0 +1,668 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks'
4
+ import type { ScrubSession } from '@/canvas/client/inspector/field'
5
+ import { ColorInputs } from '@/canvas/client/inspector/color-inputs'
6
+ import {
7
+ type Hsv,
8
+ hexToRgb,
9
+ hslToRgb,
10
+ hsvToRgb,
11
+ lchToRgb,
12
+ type Rgb,
13
+ rgbToHex,
14
+ rgbToHsl,
15
+ rgbToHsv,
16
+ rgbToLch,
17
+ } from '@/canvas/client/inspector/color-space'
18
+ import {
19
+ composeColor,
20
+ type ParsedColor,
21
+ parseColor,
22
+ } from '@/canvas/client/inspector/values'
23
+
24
+ /**
25
+ * What the picker edits. It holds HSV rather than the hex, so a grey keeps the
26
+ * hue the operator set instead of re-deriving 0 from the hex on every render.
27
+ */
28
+ interface PickerState {
29
+ readonly hsv: Hsv
30
+ readonly alpha: number
31
+ /** Set while the value is a token, which only the alpha keeps. */
32
+ readonly token: string | undefined
33
+ }
34
+
35
+ interface Opened {
36
+ readonly state: PickerState
37
+ /** The inline value the picker opened on, empty where none stood. */
38
+ readonly inline: string
39
+ /** The color the browser painted then, which fills Previous. */
40
+ readonly paint: string
41
+ }
42
+
43
+ /**
44
+ * The picker whose last write reloaded the frame. A saved edit remounts the
45
+ * inspector, so the picker reopens from this rather than from the hex, keeping
46
+ * the hue, what Previous restores, and the control that held focus.
47
+ */
48
+ interface Held {
49
+ /** The frame, element, and property the picker edits. */
50
+ readonly owner: string
51
+ readonly state: PickerState
52
+ readonly opened: Opened
53
+ readonly hasCommitted: boolean
54
+ readonly focus: string | undefined
55
+ }
56
+
57
+ let held: Held | undefined
58
+
59
+ /** Whether the picker of that owner reopens after a reload. */
60
+ export function isPickerHeld(owner: string): boolean {
61
+ return held?.owner === owner
62
+ }
63
+
64
+ /** Drops the held picker, once it closes. */
65
+ export function releasePicker(): void {
66
+ held = undefined
67
+ }
68
+
69
+ function initialState(
70
+ color: ParsedColor | undefined,
71
+ painted: string,
72
+ ): PickerState {
73
+ const paint = parseColor(painted)
74
+ const base =
75
+ color?.kind === 'hex' ? color : paint?.kind === 'hex' ? paint : undefined
76
+ return {
77
+ hsv: base ? rgbToHsv(hexToRgb(base.hex)) : { h: 0, s: 0, v: 100 },
78
+ alpha: color && color.kind !== 'none' ? color.opacity : 100,
79
+ token: color?.kind === 'token' ? color.name : undefined,
80
+ }
81
+ }
82
+
83
+ function hexOf(state: PickerState): string {
84
+ return rgbToHex(hsvToRgb(state.hsv))
85
+ }
86
+
87
+ function valueOf(state: PickerState): string {
88
+ return composeColor(
89
+ state.token
90
+ ? { kind: 'token', name: state.token, opacity: state.alpha }
91
+ : { kind: 'hex', hex: hexOf(state), opacity: state.alpha },
92
+ )
93
+ }
94
+
95
+ function parsedOf(state: PickerState): ParsedColor {
96
+ return state.token
97
+ ? { kind: 'token', name: state.token, opacity: state.alpha }
98
+ : { kind: 'hex', hex: hexOf(state), opacity: state.alpha }
99
+ }
100
+
101
+ function clamp(value: number, low: number, high: number): number {
102
+ return Math.min(high, Math.max(low, value))
103
+ }
104
+
105
+ function nameOf(target: EventTarget | null): string | undefined {
106
+ return target instanceof Element
107
+ ? (target.getAttribute('aria-label') ?? undefined)
108
+ : undefined
109
+ }
110
+
111
+ interface Channel {
112
+ /** The letter drawn under the input. */
113
+ readonly letter: string
114
+ /** The full word, which names the input. */
115
+ readonly name: string
116
+ }
117
+
118
+ interface NumberRowProps {
119
+ readonly space: string
120
+ readonly channels: readonly [Channel, Channel, Channel]
121
+ readonly values: readonly [number, number, number]
122
+ readonly isBusy: boolean
123
+ readonly onCommit: (values: readonly [number, number, number]) => void
124
+ }
125
+
126
+ /**
127
+ * Three numbers for one space. Each commits on change, reading its two
128
+ * neighbors as they stand, and a value that is not a number, or one typed
129
+ * while an edit is in flight, puts all three back.
130
+ */
131
+ function NumberRow({
132
+ space,
133
+ channels,
134
+ values,
135
+ isBusy,
136
+ onCommit,
137
+ }: NumberRowProps): JSX.Element {
138
+ const row = useRef<HTMLDivElement>(null)
139
+ const shown = values.map((value) => String(Math.round(value)))
140
+ const label = (channel: Channel) =>
141
+ space === 'RGB' ? channel.name : `${space} ${channel.name}`
142
+ const putBack = () =>
143
+ row.current?.querySelectorAll('input').forEach((input, at) => {
144
+ input.value = shown[at] ?? ''
145
+ })
146
+ return (
147
+ <div ref={row} class="picker-row">
148
+ {channels.map((channel, index) => (
149
+ <label key={channel.letter} class="picker-channel">
150
+ <input
151
+ class="picker-number"
152
+ type="text"
153
+ inputMode="decimal"
154
+ aria-label={label(channel)}
155
+ defaultValue={shown[index]}
156
+ readOnly={isBusy}
157
+ spellcheck={false}
158
+ onFocus={(event) => event.currentTarget.select()}
159
+ onChange={() => {
160
+ if (isBusy) {
161
+ putBack()
162
+ return
163
+ }
164
+ const inputs = [
165
+ ...(row.current?.querySelectorAll('input') ?? []),
166
+ ].map((input) => Number(input.value.trim()))
167
+ const [first, second, third] = inputs
168
+ if (
169
+ first === undefined ||
170
+ second === undefined ||
171
+ third === undefined ||
172
+ inputs.some((value) => !Number.isFinite(value))
173
+ ) {
174
+ putBack()
175
+ return
176
+ }
177
+ onCommit([first, second, third])
178
+ }}
179
+ />
180
+ <span class="picker-letter" aria-hidden="true">
181
+ {channel.letter}
182
+ </span>
183
+ </label>
184
+ ))}
185
+ </div>
186
+ )
187
+ }
188
+
189
+ const LCH_CHANNELS = [
190
+ { letter: 'L', name: 'lightness' },
191
+ { letter: 'C', name: 'chroma' },
192
+ { letter: 'H', name: 'hue' },
193
+ ] as const
194
+
195
+ const HSL_CHANNELS = [
196
+ { letter: 'H', name: 'hue' },
197
+ { letter: 'S', name: 'saturation' },
198
+ { letter: 'L', name: 'lightness' },
199
+ ] as const
200
+
201
+ const RGB_CHANNELS = [
202
+ { letter: 'R', name: 'red' },
203
+ { letter: 'G', name: 'green' },
204
+ { letter: 'B', name: 'blue' },
205
+ ] as const
206
+
207
+ interface ColorPickerProps {
208
+ /** The frame, element, and property, which a reload keeps. */
209
+ readonly owner: string
210
+ readonly color: ParsedColor | undefined
211
+ readonly text: string
212
+ /** The element's own inline value, which Previous writes back. */
213
+ readonly inline: string
214
+ readonly painted: string
215
+ /** Opens a preview session at press, or undefined where none can preview. */
216
+ readonly begin: (() => ScrubSession) | undefined
217
+ /** Set while an edit is in flight, when a second write would carry a stale hash. */
218
+ readonly isBusy: boolean
219
+ readonly onCommit: (value: string) => void
220
+ }
221
+
222
+ /** A control that previews before it writes. */
223
+ type Control = 'area' | 'alpha' | 'hue'
224
+
225
+ interface Pending {
226
+ readonly session: ScrubSession
227
+ /** The value when the session opened, which a release equal to it restores. */
228
+ readonly from: string
229
+ /**
230
+ * The control moving the color, which alone may end the session. A range
231
+ * fires its first input on a press before focus leaves the area, so the
232
+ * area's blur would otherwise write the range's half-finished drag.
233
+ */
234
+ owner: Control
235
+ }
236
+
237
+ /**
238
+ * The full picker: a saturation and brightness area, vertical alpha and hue
239
+ * sliders, Previous and New, numeric LCH, HSL, and RGB rows, and the hex. The
240
+ * area and the sliders preview into the frame and write once on release, while
241
+ * a numeric row writes on change. Any change but the alpha writes a hex, since
242
+ * a token's color cannot change from here without editing the token.
243
+ *
244
+ * While an edit is in flight every control holds, ignoring what it is handed
245
+ * rather than taking `disabled`, since a disabled control drops focus and the
246
+ * field closes the picker once focus leaves it.
247
+ */
248
+ export function ColorPicker({
249
+ owner,
250
+ color,
251
+ text,
252
+ inline,
253
+ painted,
254
+ begin,
255
+ isBusy,
256
+ onCommit,
257
+ }: ColorPickerProps): JSX.Element {
258
+ const resumed = held?.owner === owner ? held : undefined
259
+ const [opened] = useState<Opened>(
260
+ () =>
261
+ resumed?.opened ?? {
262
+ state: initialState(color, painted),
263
+ inline,
264
+ paint: painted,
265
+ },
266
+ )
267
+ const [state, setState] = useState<PickerState>(
268
+ () => resumed?.state ?? opened.state,
269
+ )
270
+ const latest = useRef(state)
271
+ const hasCommitted = useRef(resumed?.hasCommitted ?? false)
272
+ const pending = useRef<Pending | null>(null)
273
+ const dragging = useRef<number | null>(null)
274
+ const root = useRef<HTMLDivElement>(null)
275
+ const area = useRef<HTMLDivElement>(null)
276
+
277
+ useLayoutEffect(() => {
278
+ /*
279
+ * The field shows the popover in its own effect, which runs after this
280
+ * one, and an element still hidden takes no focus.
281
+ */
282
+ const popover = root.current?.closest<HTMLElement>('[popover]')
283
+ if (
284
+ popover &&
285
+ typeof popover.showPopover === 'function' &&
286
+ !popover.matches(':popover-open')
287
+ ) {
288
+ popover.showPopover()
289
+ }
290
+ const focus = resumed?.focus
291
+ const target = focus
292
+ ? root.current?.querySelector<HTMLElement>(`[aria-label="${focus}"]`)
293
+ : undefined
294
+ ;(target ?? area.current)?.focus()
295
+ }, [])
296
+
297
+ const hold = (next: PickerState, focus: string | undefined) => {
298
+ held = {
299
+ owner,
300
+ state: next,
301
+ opened,
302
+ hasCommitted: hasCommitted.current,
303
+ focus,
304
+ }
305
+ }
306
+
307
+ const show = (next: PickerState) => {
308
+ latest.current = next
309
+ setState(next)
310
+ }
311
+
312
+ /*
313
+ * A control moving the color while another's session is open takes that
314
+ * session over, so its restore point stays the value the frame held before
315
+ * either moved and one release writes both.
316
+ */
317
+ const start = (owner: Control): Pending => {
318
+ if (pending.current) {
319
+ pending.current.owner = owner
320
+ return pending.current
321
+ }
322
+ pending.current = {
323
+ session: begin?.() ?? {
324
+ preview: () => undefined,
325
+ restore: () => undefined,
326
+ commit: onCommit,
327
+ },
328
+ from: valueOf(latest.current),
329
+ owner,
330
+ }
331
+ return pending.current
332
+ }
333
+
334
+ /* Moves the color during a drag or a key step, previewing and posting nothing. */
335
+ const move = (owner: Control, next: PickerState) => {
336
+ if (isBusy) return
337
+ const session = start(owner).session
338
+ show(next)
339
+ session.preview(valueOf(next))
340
+ }
341
+
342
+ /*
343
+ * Ends the session that control owns: writes once when the value moved,
344
+ * else puts it back. A session another control owns is left running.
345
+ */
346
+ const settle = (owner: Control, focus = nameOf(document.activeElement)) => {
347
+ const open = pending.current
348
+ if (!open || open.owner !== owner) return
349
+ pending.current = null
350
+ const value = valueOf(latest.current)
351
+ if (value === open.from || isBusy) {
352
+ open.session.restore()
353
+ return
354
+ }
355
+ hasCommitted.current = true
356
+ hold(latest.current, focus)
357
+ open.session.commit(value)
358
+ }
359
+
360
+ const cancel = () => {
361
+ dragging.current = null
362
+ const open = pending.current
363
+ pending.current = null
364
+ open?.session.restore()
365
+ }
366
+
367
+ /* Writes at once, for a row or the hex, which carry no preview. */
368
+ const write = (next: PickerState, value = valueOf(next)) => {
369
+ if (isBusy) return
370
+ cancel()
371
+ show(next)
372
+ if (value === valueOf(state)) return
373
+ hasCommitted.current = true
374
+ hold(next, nameOf(document.activeElement))
375
+ onCommit(value)
376
+ }
377
+
378
+ const fromRgb = (rgb: Rgb, hue = latest.current.hsv.h): PickerState => ({
379
+ ...latest.current,
380
+ hsv: rgbToHsv(rgb, hue),
381
+ token: undefined,
382
+ })
383
+
384
+ const moveTo = (event: PointerEvent) => {
385
+ const box = area.current?.getBoundingClientRect()
386
+ if (!box || box.width === 0 || box.height === 0) return
387
+ move('area', {
388
+ ...latest.current,
389
+ hsv: {
390
+ h: latest.current.hsv.h,
391
+ s: clamp(((event.clientX - box.left) / box.width) * 100, 0, 100),
392
+ v: clamp(100 - ((event.clientY - box.top) / box.height) * 100, 0, 100),
393
+ },
394
+ token: undefined,
395
+ })
396
+ }
397
+
398
+ const restorePrevious = () => {
399
+ if (isBusy) return
400
+ cancel()
401
+ show(opened.state)
402
+ if (!hasCommitted.current) return
403
+ hasCommitted.current = false
404
+ hold(opened.state, 'previous color')
405
+ onCommit(opened.inline)
406
+ }
407
+
408
+ /* A range moves natively on a key or a press, so it is stopped before it does. */
409
+ const holdWhileBusy = {
410
+ onKeyDown: (event: KeyboardEvent) => {
411
+ if (isBusy && event.key !== 'Tab' && event.key !== 'Escape') {
412
+ event.preventDefault()
413
+ }
414
+ },
415
+ onPointerDown: (event: PointerEvent) => {
416
+ if (isBusy) event.preventDefault()
417
+ },
418
+ }
419
+
420
+ const rgb = hsvToRgb(state.hsv)
421
+ const hsl = rgbToHsl(rgb, state.hsv.h)
422
+ const lch = rgbToLch(rgb, state.hsv.h)
423
+ const hex = hexOf(state)
424
+ const current = parsedOf(state)
425
+ const saturation = Math.round(state.hsv.s)
426
+ const brightness = Math.round(state.hsv.v)
427
+
428
+ return (
429
+ <div
430
+ ref={root}
431
+ class="picker"
432
+ style={{
433
+ '--picker-hue': `hsl(${state.hsv.h} 100% 50%)`,
434
+ '--picker-solid': `#${hex}`,
435
+ }}
436
+ onKeyDown={(event) => {
437
+ if (event.key === 'Escape') cancel()
438
+ }}
439
+ >
440
+ <div
441
+ ref={area}
442
+ class="picker-area"
443
+ role="slider"
444
+ tabIndex={0}
445
+ aria-label="saturation and brightness"
446
+ aria-roledescription="2D slider"
447
+ aria-valuemin={0}
448
+ aria-valuemax={100}
449
+ aria-valuenow={saturation}
450
+ aria-valuetext={`Saturation ${saturation}%, brightness ${brightness}%`}
451
+ aria-disabled={isBusy}
452
+ onPointerDown={(event) => {
453
+ event.preventDefault()
454
+ area.current?.focus()
455
+ if (isBusy) return
456
+ try {
457
+ area.current?.setPointerCapture(event.pointerId)
458
+ } catch {
459
+ /* A pointer the browser no longer tracks ends on cancel instead. */
460
+ }
461
+ dragging.current = event.pointerId
462
+ moveTo(event)
463
+ }}
464
+ onPointerMove={(event) => {
465
+ if (dragging.current === event.pointerId) moveTo(event)
466
+ }}
467
+ onPointerUp={(event) => {
468
+ if (dragging.current !== event.pointerId) return
469
+ dragging.current = null
470
+ settle('area')
471
+ }}
472
+ onPointerCancel={cancel}
473
+ onBlur={(event) => settle('area', nameOf(event.relatedTarget))}
474
+ onKeyDown={(event) => {
475
+ const step = event.shiftKey ? 10 : 1
476
+ const { s, v } = latest.current.hsv
477
+ const moves: Record<string, Partial<Hsv>> = {
478
+ ArrowLeft: { s: clamp(s - step, 0, 100) },
479
+ ArrowRight: { s: clamp(s + step, 0, 100) },
480
+ ArrowUp: { v: clamp(v + step, 0, 100) },
481
+ ArrowDown: { v: clamp(v - step, 0, 100) },
482
+ }
483
+ const change = moves[event.key]
484
+ if (change) {
485
+ event.preventDefault()
486
+ move('area', {
487
+ ...latest.current,
488
+ hsv: { ...latest.current.hsv, ...change },
489
+ token: undefined,
490
+ })
491
+ } else if (event.key === 'Enter') {
492
+ event.preventDefault()
493
+ settle('area')
494
+ }
495
+ }}
496
+ >
497
+ <span
498
+ class="picker-thumb"
499
+ aria-hidden="true"
500
+ style={{ left: `${state.hsv.s}%`, top: `${100 - state.hsv.v}%` }}
501
+ />
502
+ </div>
503
+ <input
504
+ class="picker-slider picker-alpha"
505
+ type="range"
506
+ min={0}
507
+ max={100}
508
+ step={1}
509
+ aria-label="alpha"
510
+ aria-orientation="vertical"
511
+ aria-disabled={isBusy}
512
+ value={state.alpha}
513
+ {...holdWhileBusy}
514
+ onInput={(event) => {
515
+ if (isBusy) {
516
+ event.currentTarget.value = String(latest.current.alpha)
517
+ return
518
+ }
519
+ move('alpha', {
520
+ ...latest.current,
521
+ alpha: Number(event.currentTarget.value),
522
+ })
523
+ }}
524
+ onChange={() => settle('alpha')}
525
+ onBlur={(event) => settle('alpha', nameOf(event.relatedTarget))}
526
+ />
527
+ <input
528
+ class="picker-slider picker-hue"
529
+ type="range"
530
+ min={0}
531
+ max={360}
532
+ step={1}
533
+ aria-label="hue"
534
+ aria-orientation="vertical"
535
+ aria-disabled={isBusy}
536
+ value={Math.round(state.hsv.h)}
537
+ {...holdWhileBusy}
538
+ onInput={(event) => {
539
+ if (isBusy) {
540
+ event.currentTarget.value = String(Math.round(latest.current.hsv.h))
541
+ return
542
+ }
543
+ move('hue', {
544
+ ...latest.current,
545
+ hsv: {
546
+ ...latest.current.hsv,
547
+ h: Number(event.currentTarget.value),
548
+ },
549
+ token: undefined,
550
+ })
551
+ }}
552
+ onChange={() => settle('hue')}
553
+ onBlur={(event) => settle('hue', nameOf(event.relatedTarget))}
554
+ />
555
+ <div class="picker-side">
556
+ <div class="picker-compare">
557
+ <button
558
+ class="picker-previous"
559
+ type="button"
560
+ aria-label="previous color"
561
+ title="Restore the color the picker opened on"
562
+ aria-disabled={isBusy}
563
+ style={{ '--paint': opened.paint }}
564
+ onClick={restorePrevious}
565
+ />
566
+ <span
567
+ class="picker-new"
568
+ aria-hidden="true"
569
+ style={{ '--paint': `#${hex}${alphaHex(state.alpha)}` }}
570
+ />
571
+ </div>
572
+ <div class="picker-compare-labels" aria-hidden="true">
573
+ <span>Previous</span>
574
+ <span>New</span>
575
+ </div>
576
+ {/* Keyed by what each row shows, so a drag refreshes its inputs. */}
577
+ <NumberRow
578
+ key={`lch ${rowKey([lch.l, lch.c, lch.h])}`}
579
+ space="LCH"
580
+ channels={LCH_CHANNELS}
581
+ values={[lch.l, lch.c, lch.h]}
582
+ isBusy={isBusy}
583
+ onCommit={([l, c, h]) => write(fromRgb(lchToRgb({ l, c, h }).rgb))}
584
+ />
585
+ <NumberRow
586
+ key={`hsl ${rowKey([hsl.h, hsl.s, hsl.l])}`}
587
+ space="HSL"
588
+ channels={HSL_CHANNELS}
589
+ values={[hsl.h, hsl.s, hsl.l]}
590
+ isBusy={isBusy}
591
+ onCommit={([h, s, l]) =>
592
+ write(
593
+ fromRgb(
594
+ hslToRgb({ h, s: clamp(s, 0, 100), l: clamp(l, 0, 100) }),
595
+ h,
596
+ ),
597
+ )
598
+ }
599
+ />
600
+ <NumberRow
601
+ key={`rgb ${rowKey([rgb.r, rgb.g, rgb.b])}`}
602
+ space="RGB"
603
+ channels={RGB_CHANNELS}
604
+ values={[rgb.r, rgb.g, rgb.b]}
605
+ isBusy={isBusy}
606
+ onCommit={([r, g, b]) =>
607
+ write(
608
+ fromRgb({
609
+ r: clamp(r, 0, 255),
610
+ g: clamp(g, 0, 255),
611
+ b: clamp(b, 0, 255),
612
+ }),
613
+ )
614
+ }
615
+ />
616
+ <div class="glyph-field" key={valueOf(state)}>
617
+ <span class="glyph" aria-hidden="true">
618
+ #
619
+ </span>
620
+ <ColorInputs
621
+ hexLabel="hex"
622
+ opacityLabel="opacity"
623
+ color={current}
624
+ text={text}
625
+ isBusy={isBusy}
626
+ keepsFocus
627
+ onCommit={(typed) => {
628
+ const parsed = parseColor(typed)
629
+ if (parsed?.kind === 'hex') {
630
+ write(
631
+ {
632
+ ...fromRgb(hexToRgb(parsed.hex)),
633
+ alpha: parsed.opacity,
634
+ },
635
+ typed,
636
+ )
637
+ } else if (parsed?.kind === 'token') {
638
+ write(
639
+ {
640
+ ...latest.current,
641
+ token: parsed.name,
642
+ alpha: parsed.opacity,
643
+ },
644
+ typed,
645
+ )
646
+ } else {
647
+ cancel()
648
+ onCommit(typed)
649
+ }
650
+ }}
651
+ />
652
+ </div>
653
+ </div>
654
+ </div>
655
+ )
656
+ }
657
+
658
+ function rowKey(values: readonly number[]): string {
659
+ return values.map((value) => Math.round(value)).join(' ')
660
+ }
661
+
662
+ function alphaHex(alpha: number): string {
663
+ return alpha >= 100
664
+ ? ''
665
+ : Math.round((alpha / 100) * 255)
666
+ .toString(16)
667
+ .padStart(2, '0')
668
+ }