@erclx/canon 5.24.0 → 5.26.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 (185) hide show
  1. package/README.md +2 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  4. package/claude/skills/backlog-triage/SKILL.md +19 -6
  5. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  6. package/claude/skills/canvas/SKILL.md +106 -0
  7. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  8. package/claude/skills/context-fold/SKILL.md +8 -6
  9. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  10. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/SKILL.md +4 -2
  13. package/claude/skills/design-taste/references/adopted.md +40 -0
  14. package/claude/skills/design-taste/references/kinds.md +1 -1
  15. package/claude/skills/design-taste/references/systems.md +1 -7
  16. package/claude/skills/design-taste/references/tells.md +2 -24
  17. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  18. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  19. package/claude/skills/document-health/SKILL.md +21 -0
  20. package/claude/skills/document-health/references/axes.md +12 -0
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/draft-doc/REQUIREMENT.md +1 -1
  23. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  24. package/claude/skills/draft-figure/SKILL.md +24 -51
  25. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  26. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  27. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  28. package/claude/skills/draft-identity/SKILL.md +1 -1
  29. package/claude/skills/draft-screencast/SKILL.md +9 -7
  30. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  31. package/claude/skills/draft-slides/SKILL.md +31 -20
  32. package/claude/skills/git-followup/SKILL.md +17 -6
  33. package/claude/skills/git-issue/references/issue.md +10 -0
  34. package/claude/skills/git-pr/references/evidence.md +12 -2
  35. package/claude/skills/git-pr/references/labels.md +12 -0
  36. package/claude/skills/git-split/SKILL.md +19 -8
  37. package/claude/skills/index-lookup/SKILL.md +2 -2
  38. package/claude/skills/markdown-propose/references/format.md +9 -0
  39. package/claude/skills/memory-review/references/later-phases.md +7 -0
  40. package/claude/skills/review-address/SKILL.md +6 -13
  41. package/claude/skills/review-address/references/rest-reads.md +43 -0
  42. package/claude/skills/review-craft/references/ui.md +7 -1
  43. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  44. package/claude/skills/review-pr/SKILL.md +9 -3
  45. package/claude/skills/review-pr/references/close-out.md +2 -2
  46. package/claude/skills/review-pr/references/post.md +17 -5
  47. package/claude/skills/review-pr/references/scope.md +10 -8
  48. package/claude/skills/review-ui/SKILL.md +12 -4
  49. package/claude/skills/review-ui/references/post.md +4 -2
  50. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +38 -1
  51. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  52. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  53. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  54. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  55. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  56. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  57. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  58. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  59. package/claude/skills/search-craft/SKILL.md +3 -1
  60. package/claude/skills/search-craft/references/adopted.md +30 -0
  61. package/claude/skills/target-setup/references/detect.md +8 -0
  62. package/claude/skills/target-setup/references/indexes.md +13 -0
  63. package/claude/skills/target-setup/references/verify.md +8 -0
  64. package/claude/skills/test-first/SKILL.md +2 -0
  65. package/claude/skills/test-first/references/adopted.md +36 -0
  66. package/claude/skills/write-human/SKILL.md +1 -1
  67. package/claude/skills/write-human/references/machine-tells.md +13 -0
  68. package/docs/agents/canvas.md +4 -2
  69. package/docs/agents/capture.md +1 -1
  70. package/docs/agents/commands.md +47 -47
  71. package/docs/agents/context-audit-checks.md +2 -2
  72. package/docs/agents/index.md +2 -3
  73. package/docs/agents/key-changes.md +3 -3
  74. package/docs/agents/pr-evidence.md +11 -2
  75. package/docs/agents/pr-reads.md +5 -4
  76. package/docs/agents/records-stale.md +22 -9
  77. package/docs/agents/records.md +1 -1
  78. package/docs/agents/sandbox.md +1 -1
  79. package/docs/agents/skills-audit.md +10 -3
  80. package/docs/agents/slides.md +15 -3
  81. package/docs/target/projects.md +4 -4
  82. package/docs/workflow/ai-workflow.md +1 -2
  83. package/docs/workflow/index.md +1 -0
  84. package/docs/workflow/parallel-features.md +1 -1
  85. package/docs/workflow/skill-map-on-request.md +42 -0
  86. package/docs/workflow/skill-map.md +3 -36
  87. package/docs/workflow/visual-design-workflow.md +1 -0
  88. package/governance/renames.toml +0 -4
  89. package/governance/rules/standards/markdown.md +1 -0
  90. package/package.json +1 -2
  91. package/scripts/core/install-check.sh +1 -1
  92. package/scripts/lib/sandbox-dispatch.sh +1 -1
  93. package/scripts/lib/sandbox-git.sh +1 -9
  94. package/scripts/lib/sandbox-path.sh +1 -1
  95. package/scripts/sandbox-hook.sh +99 -0
  96. package/src/audits/catalog.ts +2 -2
  97. package/src/canvas/capture.ts +1 -9
  98. package/src/canvas/client/inspector/color-field.tsx +368 -0
  99. package/src/canvas/client/inspector/field.tsx +200 -0
  100. package/src/canvas/client/inspector/fields.css +292 -0
  101. package/src/canvas/client/inspector/section.tsx +27 -0
  102. package/src/canvas/client/inspector/values.ts +183 -0
  103. package/src/canvas/client/inspector.tsx +176 -160
  104. package/src/canvas/client/shell.css +0 -101
  105. package/src/capture/render.ts +45 -7
  106. package/src/capture/sources.ts +43 -0
  107. package/src/census/count.ts +1 -1
  108. package/src/claude/cases/misc.ts +5 -0
  109. package/src/claude/cases/workflow.ts +6 -1
  110. package/src/claude/seeds.ts +1 -2
  111. package/src/claude/skills-audit.ts +68 -14
  112. package/src/client-commands.ts +1 -1
  113. package/src/commands/claude.ts +27 -2
  114. package/src/commands/design.ts +2 -64
  115. package/src/commands/feedback.ts +3 -3
  116. package/src/commands/pr.ts +219 -202
  117. package/src/commands/records.ts +88 -6
  118. package/src/commands/sandbox.ts +50 -10
  119. package/src/commands/slides.ts +31 -77
  120. package/src/context/audit.ts +8 -7
  121. package/src/design/tokens.ts +2 -18
  122. package/src/docs/read.ts +1 -1
  123. package/src/{github.ts → feedback/github.ts} +1 -1
  124. package/src/gate/measures.ts +6 -4
  125. package/src/gov/citations.ts +1 -1
  126. package/src/gov/payload.ts +1 -1
  127. package/src/gov/superseded.ts +2 -2
  128. package/src/help.ts +2 -2
  129. package/src/markdown/ceiling.ts +1 -1
  130. package/src/migrate/skill-names.ts +7 -1
  131. package/src/pr/evidence.ts +25 -5
  132. package/src/pr/rest.ts +137 -0
  133. package/src/record-root.ts +1 -0
  134. package/src/records/backup.ts +1 -0
  135. package/src/records/push-guard.ts +1 -1
  136. package/src/records/stale-canonical.ts +146 -0
  137. package/src/records/stale.ts +1 -1
  138. package/src/sandbox/hooks.ts +128 -0
  139. package/src/sandbox/provision.ts +596 -0
  140. package/src/sandbox/tree.ts +103 -4
  141. package/src/secrets/marker.ts +2 -2
  142. package/src/secrets/scan.ts +1 -1
  143. package/src/shipped/references.ts +2 -2
  144. package/src/slides/convert/export.ts +2 -16
  145. package/src/slides/decks.ts +110 -0
  146. package/src/standards/read.ts +1 -1
  147. package/src/sync/seeds-report.ts +1 -1
  148. package/src/tasks/answers.ts +1 -1
  149. package/src/tasks/archive.ts +1 -1
  150. package/src/teach/nav.ts +1 -1
  151. package/src/tooling/inject.ts +1 -1
  152. package/src/web/readme-citations.ts +1 -1
  153. package/standards/architecture.md +1 -0
  154. package/standards/canvas.md +112 -0
  155. package/standards/context.md +2 -1
  156. package/standards/figures.md +0 -1
  157. package/standards/index.md +1 -1
  158. package/standards/markdown.md +1 -0
  159. package/standards/mermaid.md +1 -7
  160. package/standards/requirements.md +2 -0
  161. package/standards/skill-practice.md +2 -1
  162. package/standards/skill.md +1 -0
  163. package/tooling/claude/reference.md +0 -5
  164. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  165. package/claude/skills/draft-diagram/SKILL.md +0 -177
  166. package/docs/agents/design-board.md +0 -33
  167. package/governance/rules/standards/diagrams.md +0 -14
  168. package/governance/rules/standards/mermaid.md +0 -15
  169. package/scripts/core/check-gallery-exclusion.sh +0 -50
  170. package/scripts/manage-sandbox.sh +0 -472
  171. package/src/design/board.ts +0 -576
  172. package/src/slides/layouts.ts +0 -478
  173. package/src/slides/parse.ts +0 -84
  174. package/src/slides/render.ts +0 -110
  175. package/src/slides/styles.ts +0 -96
  176. package/standards/diagrams.md +0 -132
  177. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
  178. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  179. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  180. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  181. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  182. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  183. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  184. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  185. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -0,0 +1,292 @@
1
+ /*
2
+ * The inspector's own rules. Every value reads a chrome token, the same set
3
+ * shell.css reads. Each field's glyph sits inside its box, so two fields share
4
+ * a row without a label column eating the width.
5
+ */
6
+
7
+ .inspector-section {
8
+ margin-top: var(--space-sm);
9
+ }
10
+
11
+ .inspector-section-head {
12
+ display: flex;
13
+ align-items: center;
14
+ min-height: 24px;
15
+ }
16
+
17
+ .inspector-section-title {
18
+ flex: 1;
19
+ min-width: 0;
20
+ margin: 0;
21
+ font-size: var(--type-label-size);
22
+ font-weight: 500;
23
+ color: var(--color-text);
24
+ }
25
+
26
+ .inspector-section-action {
27
+ flex: none;
28
+ display: flex;
29
+ justify-content: flex-end;
30
+ min-width: 24px;
31
+ }
32
+
33
+ .field-grid {
34
+ display: grid;
35
+ grid-template-columns: repeat(2, minmax(0, 1fr));
36
+ gap: var(--space-xs);
37
+ margin-top: var(--space-xs);
38
+ }
39
+
40
+ .glyph-field {
41
+ display: flex;
42
+ align-items: center;
43
+ min-width: 0;
44
+ min-height: 24px;
45
+ border: 1px solid var(--color-border);
46
+ border-radius: var(--radius-action);
47
+ background: var(--color-surface);
48
+ }
49
+
50
+ .glyph-field:hover {
51
+ border-color: var(--color-muted);
52
+ }
53
+
54
+ .glyph-field.is-readonly {
55
+ background: transparent;
56
+ }
57
+
58
+ .glyph-field.is-readonly:hover {
59
+ border-color: var(--color-border);
60
+ }
61
+
62
+ .is-wide {
63
+ grid-column: 1 / -1;
64
+ }
65
+
66
+ .glyph {
67
+ flex: none;
68
+ min-width: 1.75rem;
69
+ padding: 0 var(--space-xs);
70
+ color: var(--color-muted);
71
+ text-align: center;
72
+ user-select: none;
73
+ }
74
+
75
+ /* A number field's glyph scrubs its value, so a drag must not pan or scroll. */
76
+ .glyph.is-scrub {
77
+ cursor: ew-resize;
78
+ touch-action: none;
79
+ }
80
+
81
+ .glyph-field-input {
82
+ flex: 1;
83
+ min-width: 0;
84
+ padding: 2px var(--space-xs) 2px 0;
85
+ border: 0;
86
+ background: transparent;
87
+ color: var(--color-text);
88
+ font: inherit;
89
+ font-variant-numeric: tabular-nums;
90
+ overflow: hidden;
91
+ text-overflow: ellipsis;
92
+ white-space: nowrap;
93
+ }
94
+
95
+ .glyph-field-input:disabled {
96
+ opacity: 0.5;
97
+ cursor: not-allowed;
98
+ }
99
+
100
+ .fill-row {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: var(--space-xs);
104
+ min-width: 0;
105
+ }
106
+
107
+ .fill-row .glyph-field {
108
+ flex: 1;
109
+ }
110
+
111
+ .raw {
112
+ flex: none;
113
+ color: var(--color-text-secondary);
114
+ cursor: help;
115
+ }
116
+
117
+ .text-excerpt {
118
+ margin: var(--space-xs) 0 0;
119
+ color: var(--color-text-body);
120
+ overflow: hidden;
121
+ text-overflow: ellipsis;
122
+ white-space: nowrap;
123
+ }
124
+
125
+ .saved {
126
+ margin-left: var(--space-sm);
127
+ color: var(--color-success);
128
+ font-weight: 400;
129
+ }
130
+
131
+ /*
132
+ * A color is one field: a swatch leading in the glyph's lane, the hex, and the
133
+ * opacity right-aligned. The picker opens in the flow under it, so it can never
134
+ * reach past the panel's edge or scroll it sideways.
135
+ */
136
+ .color-field {
137
+ min-width: 0;
138
+ }
139
+
140
+ .swatch {
141
+ flex: none;
142
+ display: grid;
143
+ place-items: center;
144
+ width: 1.75rem;
145
+ min-height: 22px;
146
+ padding: 0;
147
+ border: 0;
148
+ background: transparent;
149
+ cursor: pointer;
150
+ }
151
+
152
+ .swatch:disabled {
153
+ opacity: 0.5;
154
+ cursor: not-allowed;
155
+ }
156
+
157
+ /*
158
+ * The paint is the top layer over a checker, so an opacity below full reads
159
+ * as one. `--paint` carries the color, since a background color sits under
160
+ * every image layer and the checker would cover it.
161
+ */
162
+ .swatch-paint {
163
+ --paint: transparent;
164
+ flex: none;
165
+ width: 12px;
166
+ height: 12px;
167
+ border: 1px solid var(--color-border);
168
+ border-radius: 2px;
169
+ background-image:
170
+ linear-gradient(var(--paint), var(--paint)),
171
+ linear-gradient(45deg, var(--color-border) 25%, transparent 25%),
172
+ linear-gradient(-45deg, var(--color-border) 25%, transparent 25%),
173
+ linear-gradient(45deg, transparent 75%, var(--color-border) 75%),
174
+ linear-gradient(-45deg, transparent 75%, var(--color-border) 75%);
175
+ background-size:
176
+ auto,
177
+ 6px 6px,
178
+ 6px 6px,
179
+ 6px 6px,
180
+ 6px 6px;
181
+ background-position:
182
+ 0 0,
183
+ 0 0,
184
+ 0 3px,
185
+ 3px -3px,
186
+ -3px 0;
187
+ }
188
+
189
+ .swatch:hover .swatch-paint {
190
+ border-color: var(--color-muted);
191
+ }
192
+
193
+ /* No fill draws a slash rather than a color. */
194
+ .swatch.is-none .swatch-paint {
195
+ background-image: linear-gradient(
196
+ to top right,
197
+ transparent calc(50% - 1px),
198
+ var(--color-muted) calc(50% - 1px),
199
+ var(--color-muted) calc(50% + 1px),
200
+ transparent calc(50% + 1px)
201
+ );
202
+ background-size: auto;
203
+ }
204
+
205
+ .color-field .glyph-field-input {
206
+ text-transform: uppercase;
207
+ }
208
+
209
+ /* Only a hex reads in capitals. A value the picker cannot read stays as written. */
210
+ .color-field .glyph-field-input::placeholder,
211
+ .color-field .glyph-field-input.is-text {
212
+ text-transform: none;
213
+ }
214
+
215
+ /* A token name is a name, so it keeps its own case. */
216
+ .color-field .glyph-field-input.is-token {
217
+ flex: 0 1 auto;
218
+ margin-right: auto;
219
+ padding: 0 var(--space-xs);
220
+ border-radius: var(--radius-action);
221
+ background: var(--color-background);
222
+ text-transform: none;
223
+ }
224
+
225
+ .color-opacity {
226
+ flex: none;
227
+ width: 2.25rem;
228
+ padding: 2px 0;
229
+ border: 0;
230
+ background: transparent;
231
+ color: var(--color-text);
232
+ font: inherit;
233
+ font-variant-numeric: tabular-nums;
234
+ text-align: right;
235
+ }
236
+
237
+ .color-opacity:disabled {
238
+ color: var(--color-muted);
239
+ cursor: not-allowed;
240
+ }
241
+
242
+ .color-percent {
243
+ flex: none;
244
+ padding-right: var(--space-xs);
245
+ color: var(--color-muted);
246
+ user-select: none;
247
+ }
248
+
249
+ .color-picker {
250
+ display: flex;
251
+ flex-direction: column;
252
+ gap: var(--space-xs);
253
+ margin-top: var(--space-xs);
254
+ padding: var(--space-xs);
255
+ border: 1px solid var(--color-border);
256
+ border-radius: var(--radius-action);
257
+ background: var(--color-chrome);
258
+ }
259
+
260
+ .token-list {
261
+ max-height: 12rem;
262
+ margin: 0;
263
+ padding: 0;
264
+ overflow-y: auto;
265
+ list-style: none;
266
+ }
267
+
268
+ .token-option {
269
+ display: flex;
270
+ align-items: center;
271
+ gap: var(--space-xs);
272
+ min-height: 24px;
273
+ padding: 0 var(--space-xs);
274
+ border-radius: var(--radius-action);
275
+ cursor: pointer;
276
+ }
277
+
278
+ .token-option:hover,
279
+ .token-option.is-active {
280
+ background: var(--color-surface);
281
+ }
282
+
283
+ .token-option[aria-selected='true'] .token-name {
284
+ color: var(--color-accent);
285
+ }
286
+
287
+ .token-name {
288
+ min-width: 0;
289
+ overflow: hidden;
290
+ text-overflow: ellipsis;
291
+ white-space: nowrap;
292
+ }
@@ -0,0 +1,27 @@
1
+ /** @jsxImportSource preact */
2
+ import { type ComponentChildren, type JSX, toChildArray } from 'preact'
3
+
4
+ interface SectionProps {
5
+ readonly title: string
6
+ /** Sits in a fixed lane right of the title, so adding one never moves it. */
7
+ readonly action?: ComponentChildren
8
+ readonly children?: ComponentChildren
9
+ }
10
+
11
+ /** A titled group of fields. With no fields it renders its header alone. */
12
+ export function Section({
13
+ title,
14
+ action,
15
+ children,
16
+ }: SectionProps): JSX.Element {
17
+ const fields = toChildArray(children)
18
+ return (
19
+ <section class="inspector-section">
20
+ <header class="inspector-section-head">
21
+ <h3 class="inspector-section-title">{title}</h3>
22
+ <span class="inspector-section-action">{action}</span>
23
+ </header>
24
+ {fields.length > 0 ? <div class="field-grid">{fields}</div> : null}
25
+ </section>
26
+ )
27
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * How the inspector reads a computed value and writes a typed one back. A
3
+ * field shows the formatted form, and only a value the operator changed goes
4
+ * back through `toCssValue`, so rounding never writes a value nobody typed.
5
+ */
6
+
7
+ /** Properties whose bare numbers are pixel lengths. */
8
+ const LENGTHS = new Set(['width', 'height', 'padding', 'gap', 'font-size'])
9
+
10
+ const PX = /^(-?\d*\.?\d+)px$/
11
+
12
+ function formatPx(token: string): string {
13
+ const match = token.match(PX)
14
+ if (!match) return token
15
+ const value = Number(match[1])
16
+ return String(
17
+ Math.abs(value) < 10 ? Math.round(value * 10) / 10 : Math.round(value),
18
+ )
19
+ }
20
+
21
+ /** A color that paints nothing, which the panel reads as no fill. */
22
+ export function isNoFill(value: string): boolean {
23
+ const normalized = value.trim().toLowerCase().replace(/\s+/g, '')
24
+ return normalized === 'transparent' || normalized === 'rgba(0,0,0,0)'
25
+ }
26
+
27
+ export function displayValue(property: string, raw: string): string {
28
+ const value = raw.trim()
29
+ if (property === 'gap' && (value === '' || value === 'normal')) return ''
30
+ if (property === 'background-color' && isNoFill(value)) return ''
31
+ if (value === 'auto') return 'Auto'
32
+ if (value === 'normal') return 'Normal'
33
+ return value.split(/\s+/).map(formatPx).join(' ')
34
+ }
35
+
36
+ export function toCssValue(property: string, typed: string): string {
37
+ const value = typed.trim()
38
+ if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
39
+ if (!LENGTHS.has(property)) return value
40
+ return value
41
+ .split(/\s+/)
42
+ .map((token) => (/^-?\d*\.?\d+$/.test(token) ? `${token}px` : token))
43
+ .join(' ')
44
+ }
45
+
46
+ /**
47
+ * Holds a scrubbed number inside what the property accepts. The writer takes
48
+ * any declaration that does not break the rule, so a negative length or a
49
+ * weight past 1000 would reach the file and the browser would drop it there.
50
+ */
51
+ export function clampScrub(property: string, value: number): number {
52
+ if (property === 'font-weight') return Math.min(1000, Math.max(1, value))
53
+ if (LENGTHS.has(property)) return Math.max(0, value)
54
+ return value
55
+ }
56
+
57
+ /** Units a scrub moves for each pixel the pointer travels. */
58
+ export function scrubStep(isCoarse: boolean): number {
59
+ return isCoarse ? 10 : 1
60
+ }
61
+
62
+ /** A color the picker can write: a token or a hex, each with an opacity. */
63
+ export type WrittenColor =
64
+ | {
65
+ readonly kind: 'token'
66
+ /** The custom property name as the token sheet gives it. */
67
+ readonly name: string
68
+ readonly opacity: number
69
+ }
70
+ | { readonly kind: 'hex'; readonly hex: string; readonly opacity: number }
71
+
72
+ export type ParsedColor = WrittenColor | { readonly kind: 'none' }
73
+
74
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
75
+ const RGB =
76
+ /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i
77
+ /** What a browser computes `color-mix()` in srgb to, channels 0 to 1. */
78
+ const SRGB =
79
+ /^color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i
80
+ const VAR = /^var\(\s*(--[\w-]+)\s*\)$/i
81
+ const MIX =
82
+ /^color-mix\(\s*in\s+srgb\s*,\s*var\(\s*(--[\w-]+)\s*\)\s+([\d.]+)%\s*,\s*transparent\s*\)$/i
83
+
84
+ function channel(value: number): string {
85
+ return Math.round(Math.min(255, Math.max(0, value)))
86
+ .toString(16)
87
+ .padStart(2, '0')
88
+ }
89
+
90
+ /**
91
+ * An alpha as a percent. A value past 1 clamps to full rather than reading as
92
+ * a 0 to 255 scale, since CSS clamps it the same way when it paints.
93
+ */
94
+ function alphaPercent(alpha: string | undefined): number {
95
+ if (alpha === undefined) return 100
96
+ const fraction = alpha.endsWith('%')
97
+ ? Number(alpha.slice(0, -1)) / 100
98
+ : Number(alpha)
99
+ return Math.round(Math.min(1, Math.max(0, fraction)) * 100)
100
+ }
101
+
102
+ /** A typed hex in any of its lengths, normalized to six digits. */
103
+ export function readHex(
104
+ typed: string,
105
+ ): { readonly hex: string; readonly opacity?: number } | undefined {
106
+ const match = typed.trim().match(HEX)
107
+ if (!match) return undefined
108
+ const digits = match[1].toLowerCase()
109
+ const full =
110
+ digits.length <= 4
111
+ ? [...digits].map((digit) => digit + digit).join('')
112
+ : digits
113
+ const hex = full.slice(0, 6)
114
+ if (full.length === 6) return { hex }
115
+ return {
116
+ hex,
117
+ opacity: Math.round((parseInt(full.slice(6), 16) / 255) * 100),
118
+ }
119
+ }
120
+
121
+ /** A typed opacity percent, refused outside 0 to 100. */
122
+ export function readOpacity(typed: string): number | undefined {
123
+ const value = typed.trim().replace(/%$/, '')
124
+ if (!/^-?\d*\.?\d+$/.test(value)) return undefined
125
+ const percent = Number(value)
126
+ return percent >= 0 && percent <= 100 ? Math.round(percent) : undefined
127
+ }
128
+
129
+ /**
130
+ * Reads a computed or inline color into what the picker shows. A form it does
131
+ * not know, such as a named color, comes back undefined.
132
+ */
133
+ export function parseColor(value: string): ParsedColor | undefined {
134
+ const text = value.trim()
135
+ if (isNoFill(text)) return { kind: 'none' }
136
+ const token = text.match(VAR)
137
+ if (token) return { kind: 'token', name: token[1], opacity: 100 }
138
+ const mix = text.match(MIX)
139
+ if (mix) {
140
+ return {
141
+ kind: 'token',
142
+ name: mix[1],
143
+ opacity: Math.round(Math.min(100, Number(mix[2]))),
144
+ }
145
+ }
146
+ const hex = text.startsWith('#') ? readHex(text) : undefined
147
+ if (hex) return painted(hex.hex, hex.opacity ?? 100)
148
+ const rgb = text.match(RGB)
149
+ if (rgb) {
150
+ return painted(
151
+ [rgb[1], rgb[2], rgb[3]].map((part) => channel(Number(part))).join(''),
152
+ alphaPercent(rgb[4]),
153
+ )
154
+ }
155
+ const srgb = text.match(SRGB)
156
+ if (!srgb) return undefined
157
+ return painted(
158
+ [srgb[1], srgb[2], srgb[3]]
159
+ .map((part) => channel(Number(part) * 255))
160
+ .join(''),
161
+ alphaPercent(srgb[4]),
162
+ )
163
+ }
164
+
165
+ /** A color at zero opacity paints nothing, whichever form wrote it. */
166
+ function painted(hex: string, opacity: number): ParsedColor {
167
+ return opacity === 0 ? { kind: 'none' } : { kind: 'hex', hex, opacity }
168
+ }
169
+
170
+ /**
171
+ * The value a pick writes. A token below full opacity mixes with transparent
172
+ * rather than resolving to a hex, so it keeps following the theme.
173
+ */
174
+ export function composeColor(color: WrittenColor): string {
175
+ if (color.kind === 'token') {
176
+ return color.opacity >= 100
177
+ ? `var(${color.name})`
178
+ : `color-mix(in srgb, var(${color.name}) ${color.opacity}%, transparent)`
179
+ }
180
+ return color.opacity >= 100
181
+ ? `#${color.hex}`
182
+ : `#${color.hex}${channel((color.opacity / 100) * 255)}`
183
+ }