@erclx/canon 5.25.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 (123) hide show
  1. package/README.md +2 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canvas/SKILL.md +1 -1
  4. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  5. package/claude/skills/context-fold/SKILL.md +8 -6
  6. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  7. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  8. package/claude/skills/design-taste/SKILL.md +4 -2
  9. package/claude/skills/design-taste/references/adopted.md +40 -0
  10. package/claude/skills/design-taste/references/kinds.md +1 -1
  11. package/claude/skills/design-taste/references/systems.md +1 -7
  12. package/claude/skills/design-taste/references/tells.md +2 -24
  13. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  14. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  15. package/claude/skills/document-health/SKILL.md +21 -0
  16. package/claude/skills/document-health/references/axes.md +12 -0
  17. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  18. package/claude/skills/draft-identity/SKILL.md +1 -1
  19. package/claude/skills/draft-screencast/SKILL.md +9 -7
  20. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  21. package/claude/skills/draft-slides/SKILL.md +31 -20
  22. package/claude/skills/git-issue/references/issue.md +10 -0
  23. package/claude/skills/git-pr/references/evidence.md +8 -0
  24. package/claude/skills/git-pr/references/labels.md +12 -0
  25. package/claude/skills/git-split/SKILL.md +19 -8
  26. package/claude/skills/markdown-propose/references/format.md +9 -0
  27. package/claude/skills/memory-review/references/later-phases.md +7 -0
  28. package/claude/skills/review-ui/references/post.md +4 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  30. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  31. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  32. package/claude/skills/search-craft/SKILL.md +3 -1
  33. package/claude/skills/search-craft/references/adopted.md +30 -0
  34. package/claude/skills/target-setup/references/detect.md +8 -0
  35. package/claude/skills/target-setup/references/indexes.md +13 -0
  36. package/claude/skills/target-setup/references/verify.md +8 -0
  37. package/claude/skills/test-first/SKILL.md +2 -0
  38. package/claude/skills/test-first/references/adopted.md +36 -0
  39. package/claude/skills/write-human/SKILL.md +1 -1
  40. package/claude/skills/write-human/references/machine-tells.md +13 -0
  41. package/docs/agents/canvas.md +3 -1
  42. package/docs/agents/capture.md +1 -1
  43. package/docs/agents/commands.md +3 -3
  44. package/docs/agents/index.md +2 -2
  45. package/docs/agents/records-stale.md +22 -9
  46. package/docs/agents/records.md +1 -1
  47. package/docs/agents/sandbox.md +1 -1
  48. package/docs/agents/skills-audit.md +10 -3
  49. package/docs/agents/slides.md +15 -3
  50. package/docs/workflow/ai-workflow.md +1 -1
  51. package/docs/workflow/index.md +1 -0
  52. package/docs/workflow/parallel-features.md +1 -1
  53. package/docs/workflow/skill-map-on-request.md +42 -0
  54. package/docs/workflow/skill-map.md +1 -34
  55. package/package.json +1 -1
  56. package/scripts/lib/sandbox-dispatch.sh +1 -1
  57. package/scripts/lib/sandbox-git.sh +1 -9
  58. package/scripts/lib/sandbox-path.sh +1 -1
  59. package/scripts/sandbox-hook.sh +99 -0
  60. package/src/canvas/capture.ts +1 -9
  61. package/src/canvas/client/inspector/color-field.tsx +368 -0
  62. package/src/canvas/client/inspector/fields.css +163 -22
  63. package/src/canvas/client/inspector/values.ts +123 -0
  64. package/src/canvas/client/inspector.tsx +46 -69
  65. package/src/capture/render.ts +45 -7
  66. package/src/capture/sources.ts +43 -0
  67. package/src/census/count.ts +1 -1
  68. package/src/claude/cases/workflow.ts +5 -0
  69. package/src/claude/seeds.ts +1 -1
  70. package/src/claude/skills-audit.ts +68 -14
  71. package/src/client-commands.ts +1 -1
  72. package/src/commands/claude.ts +27 -2
  73. package/src/commands/feedback.ts +3 -3
  74. package/src/commands/records.ts +88 -6
  75. package/src/commands/sandbox.ts +50 -10
  76. package/src/commands/slides.ts +31 -77
  77. package/src/context/audit.ts +1 -1
  78. package/src/design/tokens.ts +2 -18
  79. package/src/docs/read.ts +1 -1
  80. package/src/{github.ts → feedback/github.ts} +1 -1
  81. package/src/gate/measures.ts +6 -4
  82. package/src/gov/citations.ts +1 -1
  83. package/src/gov/payload.ts +1 -1
  84. package/src/gov/superseded.ts +2 -2
  85. package/src/markdown/ceiling.ts +1 -1
  86. package/src/record-root.ts +1 -0
  87. package/src/records/backup.ts +1 -0
  88. package/src/records/push-guard.ts +1 -1
  89. package/src/records/stale-canonical.ts +146 -0
  90. package/src/records/stale.ts +1 -1
  91. package/src/sandbox/hooks.ts +128 -0
  92. package/src/sandbox/provision.ts +596 -0
  93. package/src/sandbox/tree.ts +103 -4
  94. package/src/secrets/marker.ts +2 -2
  95. package/src/secrets/scan.ts +1 -1
  96. package/src/shipped/references.ts +2 -2
  97. package/src/slides/convert/export.ts +2 -16
  98. package/src/slides/decks.ts +110 -0
  99. package/src/standards/read.ts +1 -1
  100. package/src/sync/seeds-report.ts +1 -1
  101. package/src/tasks/answers.ts +1 -1
  102. package/src/tasks/archive.ts +1 -1
  103. package/src/teach/nav.ts +1 -1
  104. package/src/tooling/inject.ts +1 -1
  105. package/src/web/readme-citations.ts +1 -1
  106. package/standards/architecture.md +1 -0
  107. package/standards/canvas.md +1 -1
  108. package/standards/requirements.md +2 -0
  109. package/standards/skill-practice.md +2 -1
  110. package/standards/skill.md +1 -0
  111. package/scripts/manage-sandbox.sh +0 -472
  112. package/src/slides/layouts.ts +0 -478
  113. package/src/slides/parse.ts +0 -84
  114. package/src/slides/render.ts +0 -110
  115. package/src/slides/styles.ts +0 -96
  116. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  117. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  118. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  119. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  120. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  121. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  122. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  123. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -0,0 +1,368 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks'
4
+ import {
5
+ composeColor,
6
+ type ParsedColor,
7
+ readHex,
8
+ readOpacity,
9
+ } from '@/canvas/client/inspector/values'
10
+
11
+ interface ColorFieldProps {
12
+ /** The full word, which names the hex input for assistive technology. */
13
+ readonly label: string
14
+ /** What the element holds, or undefined for a form the picker cannot read. */
15
+ readonly color: ParsedColor | undefined
16
+ /** The value as written, shown when `color` is undefined. */
17
+ readonly text: string
18
+ /** The color the browser paints, which fills the swatch. */
19
+ readonly painted: string
20
+ /** The color tokens in the order the token sheet sorts them. */
21
+ readonly tokens: readonly string[]
22
+ /** The color a token paints in the frame, read when the picker opens. */
23
+ readonly paint: (name: string) => string
24
+ readonly isRaw: boolean
25
+ readonly isBusy: boolean
26
+ readonly onCommit: (value: string) => void
27
+ }
28
+
29
+ function shownHex(color: ParsedColor | undefined, text: string): string {
30
+ if (!color) return text
31
+ if (color.kind === 'none') return ''
32
+ return color.kind === 'token' ? color.name : color.hex
33
+ }
34
+
35
+ function shownOpacity(color: ParsedColor | undefined): string {
36
+ return color && color.kind !== 'none' ? String(color.opacity) : ''
37
+ }
38
+
39
+ function currentOpacity(color: ParsedColor | undefined): number {
40
+ return color && color.kind !== 'none' ? color.opacity : 100
41
+ }
42
+
43
+ /**
44
+ * What a typed hex writes. A token name typed whole picks the token, and any
45
+ * other text goes through as written, which the panel then marks raw.
46
+ */
47
+ function fromTyped(typed: string, opacity: number): string {
48
+ const hex = readHex(typed)
49
+ if (hex) {
50
+ return composeColor({
51
+ kind: 'hex',
52
+ hex: hex.hex,
53
+ opacity: hex.opacity ?? opacity,
54
+ })
55
+ }
56
+ if (/^--[\w-]+$/.test(typed)) {
57
+ return composeColor({ kind: 'token', name: typed, opacity })
58
+ }
59
+ return typed
60
+ }
61
+
62
+ interface ColorInputsProps {
63
+ readonly hexLabel: string
64
+ readonly opacityLabel: string
65
+ readonly color: ParsedColor | undefined
66
+ readonly text: string
67
+ readonly isBusy: boolean
68
+ readonly onCommit: (value: string) => void
69
+ }
70
+
71
+ /**
72
+ * The hex and the opacity. Each commits on change and posts nothing when the
73
+ * value is what it started at, or an opacity outside 0 to 100.
74
+ */
75
+ function ColorInputs({
76
+ hexLabel,
77
+ opacityLabel,
78
+ color,
79
+ text,
80
+ isBusy,
81
+ onCommit,
82
+ }: ColorInputsProps): JSX.Element {
83
+ const hex = shownHex(color, text)
84
+ const opacity = shownOpacity(color)
85
+ return (
86
+ <>
87
+ <input
88
+ class={
89
+ color === undefined
90
+ ? 'glyph-field-input is-text'
91
+ : color.kind === 'token'
92
+ ? 'glyph-field-input is-token'
93
+ : 'glyph-field-input'
94
+ }
95
+ type="text"
96
+ aria-label={hexLabel}
97
+ title={hex}
98
+ defaultValue={hex}
99
+ placeholder="None"
100
+ disabled={isBusy}
101
+ spellcheck={false}
102
+ onChange={(event) => {
103
+ const typed = event.currentTarget.value.trim()
104
+ if (typed === '' || typed === hex) return
105
+ onCommit(fromTyped(typed, currentOpacity(color)))
106
+ }}
107
+ onKeyDown={(event) => {
108
+ if (event.key === 'Escape') event.currentTarget.value = hex
109
+ }}
110
+ />
111
+ <input
112
+ class="color-opacity"
113
+ type="text"
114
+ inputMode="numeric"
115
+ aria-label={opacityLabel}
116
+ defaultValue={opacity}
117
+ disabled={isBusy || !color || color.kind === 'none'}
118
+ spellcheck={false}
119
+ onChange={(event) => {
120
+ const percent = readOpacity(event.currentTarget.value)
121
+ if (
122
+ percent === undefined ||
123
+ !color ||
124
+ color.kind === 'none' ||
125
+ percent === color.opacity
126
+ ) {
127
+ event.currentTarget.value = opacity
128
+ return
129
+ }
130
+ onCommit(composeColor({ ...color, opacity: percent }))
131
+ }}
132
+ onKeyDown={(event) => {
133
+ if (event.key === 'Escape') event.currentTarget.value = opacity
134
+ }}
135
+ />
136
+ {opacity ? (
137
+ <span class="color-percent" aria-hidden="true">
138
+ %
139
+ </span>
140
+ ) : null}
141
+ </>
142
+ )
143
+ }
144
+
145
+ /**
146
+ * The swatch whose picker last wrote a value, so it takes focus back each time
147
+ * the frame reloads. A saved edit remounts the inspector, once for the edit and
148
+ * again when the file watcher reports the write, and each remount replaces the
149
+ * swatch that held focus and drops it to the page. It holds until focus lands
150
+ * anywhere else.
151
+ */
152
+ let refocus: string | undefined
153
+
154
+ function swatchLabel(label: string): string {
155
+ return `${label} picker`
156
+ }
157
+
158
+ if (typeof document !== 'undefined') {
159
+ document.addEventListener('focusin', (event) => {
160
+ if (refocus === undefined) return
161
+ const target = event.target
162
+ const name =
163
+ target instanceof Element ? target.getAttribute('aria-label') : null
164
+ if (name !== swatchLabel(refocus)) refocus = undefined
165
+ })
166
+ }
167
+
168
+ /**
169
+ * One color property: a swatch opening the picker, the hex, and the opacity.
170
+ * The picker lists the project's color tokens ahead of any raw value, since a
171
+ * token keeps following the theme and a literal does not.
172
+ */
173
+ export function ColorField({
174
+ label,
175
+ color,
176
+ text,
177
+ painted,
178
+ tokens,
179
+ paint,
180
+ isRaw,
181
+ isBusy,
182
+ onCommit,
183
+ }: ColorFieldProps): JSX.Element {
184
+ const [isOpen, setOpen] = useState(false)
185
+ const swatch = useRef<HTMLButtonElement>(null)
186
+ const list = useRef<HTMLUListElement>(null)
187
+ const dialog = useRef<HTMLDivElement>(null)
188
+ const picked = color?.kind === 'token' ? tokens.indexOf(color.name) : -1
189
+ const [active, setActive] = useState(Math.max(0, picked))
190
+ const isNone = color?.kind === 'none'
191
+
192
+ /*
193
+ * Focus moves in the commit rather than after paint, so a key typed straight
194
+ * after the one that opened the picker reaches the list, not the swatch.
195
+ */
196
+ useLayoutEffect(() => {
197
+ if (!isOpen) return
198
+ const target =
199
+ list.current ??
200
+ dialog.current?.querySelector<HTMLInputElement>('input') ??
201
+ null
202
+ target?.focus()
203
+ }, [isOpen])
204
+
205
+ useLayoutEffect(() => {
206
+ if (refocus === label) swatch.current?.focus()
207
+ }, [label])
208
+
209
+ const close = () => {
210
+ setOpen(false)
211
+ swatch.current?.focus()
212
+ }
213
+
214
+ /* Writes from inside the picker, whose swatch keeps focus across the reload. */
215
+ const commitFromPicker = (value: string) => {
216
+ close()
217
+ refocus = label
218
+ onCommit(value)
219
+ }
220
+
221
+ const pick = (name: string) => {
222
+ if (color?.kind === 'token' && color.name === name) {
223
+ close()
224
+ return
225
+ }
226
+ commitFromPicker(
227
+ composeColor({ kind: 'token', name, opacity: currentOpacity(color) }),
228
+ )
229
+ }
230
+
231
+ const id = `color-${label.replace(/\W+/g, '-')}`
232
+
233
+ return (
234
+ <div
235
+ class="color-field is-wide"
236
+ onFocusOut={(event) => {
237
+ const next = event.relatedTarget
238
+ if (!(next instanceof Node) || !event.currentTarget.contains(next)) {
239
+ setOpen(false)
240
+ }
241
+ }}
242
+ >
243
+ <div class="fill-row">
244
+ <div class="glyph-field">
245
+ <button
246
+ ref={swatch}
247
+ class={isNone ? 'swatch is-none' : 'swatch'}
248
+ type="button"
249
+ aria-label={swatchLabel(label)}
250
+ aria-haspopup="dialog"
251
+ aria-expanded={isOpen}
252
+ title={label}
253
+ disabled={isBusy}
254
+ onClick={() => {
255
+ setActive(Math.max(0, picked))
256
+ setOpen(!isOpen)
257
+ }}
258
+ >
259
+ <span
260
+ class="swatch-paint"
261
+ style={isNone ? undefined : { '--paint': painted }}
262
+ />
263
+ </button>
264
+ <ColorInputs
265
+ hexLabel={label}
266
+ opacityLabel={`${label} opacity`}
267
+ color={color}
268
+ text={text}
269
+ isBusy={isBusy}
270
+ onCommit={onCommit}
271
+ />
272
+ </div>
273
+ {isRaw ? (
274
+ <span
275
+ class="raw"
276
+ title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
277
+ >
278
+ raw
279
+ </span>
280
+ ) : null}
281
+ </div>
282
+ {isOpen ? (
283
+ <div
284
+ ref={dialog}
285
+ class="color-picker"
286
+ role="dialog"
287
+ aria-label={`${label} colors`}
288
+ tabIndex={-1}
289
+ onKeyDown={(event) => {
290
+ if (event.key !== 'Escape') return
291
+ event.preventDefault()
292
+ /* A blur would commit what was typed, so put it back first. */
293
+ if (event.target instanceof HTMLInputElement) {
294
+ event.target.value = event.target.defaultValue
295
+ }
296
+ close()
297
+ }}
298
+ >
299
+ {tokens.length > 0 ? (
300
+ <ul
301
+ ref={list}
302
+ class="token-list"
303
+ role="listbox"
304
+ aria-label="Tokens"
305
+ tabIndex={0}
306
+ aria-activedescendant={`${id}-${active}`}
307
+ onKeyDown={(event) => {
308
+ const last = tokens.length - 1
309
+ const moves: Record<string, number> = {
310
+ ArrowDown: Math.min(last, active + 1),
311
+ ArrowUp: Math.max(0, active - 1),
312
+ Home: 0,
313
+ End: last,
314
+ }
315
+ if (event.key in moves) {
316
+ event.preventDefault()
317
+ setActive(moves[event.key] ?? active)
318
+ } else if (event.key === 'Enter' || event.key === ' ') {
319
+ event.preventDefault()
320
+ const name = tokens[active]
321
+ if (name) pick(name)
322
+ }
323
+ }}
324
+ >
325
+ {tokens.map((name, index) => (
326
+ <li
327
+ key={name}
328
+ id={`${id}-${index}`}
329
+ class={
330
+ index === active ? 'token-option is-active' : 'token-option'
331
+ }
332
+ role="option"
333
+ aria-selected={index === picked}
334
+ title={name}
335
+ onClick={() => pick(name)}
336
+ >
337
+ <span
338
+ class="swatch-paint"
339
+ aria-hidden="true"
340
+ style={{ '--paint': paint(name) }}
341
+ />
342
+ <span class="token-name">{name}</span>
343
+ </li>
344
+ ))}
345
+ </ul>
346
+ ) : (
347
+ <p class="empty">
348
+ No color tokens resolve. Add them to the token sheet
349
+ </p>
350
+ )}
351
+ <div class="glyph-field">
352
+ <span class="glyph" aria-hidden="true">
353
+ #
354
+ </span>
355
+ <ColorInputs
356
+ hexLabel="hex"
357
+ opacityLabel="opacity"
358
+ color={color}
359
+ text={text}
360
+ isBusy={isBusy}
361
+ onCommit={commitFromPicker}
362
+ />
363
+ </div>
364
+ </div>
365
+ ) : null}
366
+ </div>
367
+ )
368
+ }
@@ -108,28 +108,6 @@
108
108
  flex: 1;
109
109
  }
110
110
 
111
- .token-picker {
112
- flex: none;
113
- width: 4.5rem;
114
- min-height: 24px;
115
- padding: 2px var(--space-xs);
116
- border: 1px solid var(--color-border);
117
- border-radius: var(--radius-action);
118
- background: var(--color-surface);
119
- color: var(--color-text);
120
- font: inherit;
121
- cursor: pointer;
122
- }
123
-
124
- .token-picker:hover {
125
- border-color: var(--color-muted);
126
- }
127
-
128
- .token-picker:disabled {
129
- opacity: 0.5;
130
- cursor: not-allowed;
131
- }
132
-
133
111
  .raw {
134
112
  flex: none;
135
113
  color: var(--color-text-secondary);
@@ -149,3 +127,166 @@
149
127
  color: var(--color-success);
150
128
  font-weight: 400;
151
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
+ }
@@ -58,3 +58,126 @@ export function clampScrub(property: string, value: number): number {
58
58
  export function scrubStep(isCoarse: boolean): number {
59
59
  return isCoarse ? 10 : 1
60
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
+ }