@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.
- package/README.md +2 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canvas/SKILL.md +1 -1
- package/claude/skills/context-fold/REQUIREMENT.md +6 -2
- package/claude/skills/context-fold/SKILL.md +8 -6
- package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/design-taste/SKILL.md +4 -2
- package/claude/skills/design-taste/references/adopted.md +40 -0
- package/claude/skills/design-taste/references/kinds.md +1 -1
- package/claude/skills/design-taste/references/systems.md +1 -7
- package/claude/skills/design-taste/references/tells.md +2 -24
- package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
- package/claude/skills/document-health/REQUIREMENT.md +2 -0
- package/claude/skills/document-health/SKILL.md +21 -0
- package/claude/skills/document-health/references/axes.md +12 -0
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-identity/SKILL.md +1 -1
- package/claude/skills/draft-screencast/SKILL.md +9 -7
- package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
- package/claude/skills/draft-slides/SKILL.md +31 -20
- package/claude/skills/git-issue/references/issue.md +10 -0
- package/claude/skills/git-pr/references/evidence.md +8 -0
- package/claude/skills/git-pr/references/labels.md +12 -0
- package/claude/skills/git-split/SKILL.md +19 -8
- package/claude/skills/markdown-propose/references/format.md +9 -0
- package/claude/skills/memory-review/references/later-phases.md +7 -0
- package/claude/skills/review-ui/references/post.md +4 -2
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
- package/claude/skills/search-craft/REQUIREMENT.md +1 -1
- package/claude/skills/search-craft/SKILL.md +3 -1
- package/claude/skills/search-craft/references/adopted.md +30 -0
- package/claude/skills/target-setup/references/detect.md +8 -0
- package/claude/skills/target-setup/references/indexes.md +13 -0
- package/claude/skills/target-setup/references/verify.md +8 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/test-first/references/adopted.md +36 -0
- package/claude/skills/write-human/SKILL.md +1 -1
- package/claude/skills/write-human/references/machine-tells.md +13 -0
- package/docs/agents/canvas.md +3 -1
- package/docs/agents/capture.md +1 -1
- package/docs/agents/commands.md +3 -3
- package/docs/agents/index.md +2 -2
- package/docs/agents/records-stale.md +22 -9
- package/docs/agents/records.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/skills-audit.md +10 -3
- package/docs/agents/slides.md +15 -3
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/index.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +42 -0
- package/docs/workflow/skill-map.md +1 -34
- package/package.json +1 -1
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-git.sh +1 -9
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/sandbox-hook.sh +99 -0
- package/src/canvas/capture.ts +1 -9
- package/src/canvas/client/inspector/color-field.tsx +368 -0
- package/src/canvas/client/inspector/fields.css +163 -22
- package/src/canvas/client/inspector/values.ts +123 -0
- package/src/canvas/client/inspector.tsx +46 -69
- package/src/capture/render.ts +45 -7
- package/src/capture/sources.ts +43 -0
- package/src/census/count.ts +1 -1
- package/src/claude/cases/workflow.ts +5 -0
- package/src/claude/seeds.ts +1 -1
- package/src/claude/skills-audit.ts +68 -14
- package/src/client-commands.ts +1 -1
- package/src/commands/claude.ts +27 -2
- package/src/commands/feedback.ts +3 -3
- package/src/commands/records.ts +88 -6
- package/src/commands/sandbox.ts +50 -10
- package/src/commands/slides.ts +31 -77
- package/src/context/audit.ts +1 -1
- package/src/design/tokens.ts +2 -18
- package/src/docs/read.ts +1 -1
- package/src/{github.ts → feedback/github.ts} +1 -1
- package/src/gate/measures.ts +6 -4
- package/src/gov/citations.ts +1 -1
- package/src/gov/payload.ts +1 -1
- package/src/gov/superseded.ts +2 -2
- package/src/markdown/ceiling.ts +1 -1
- package/src/record-root.ts +1 -0
- package/src/records/backup.ts +1 -0
- package/src/records/push-guard.ts +1 -1
- package/src/records/stale-canonical.ts +146 -0
- package/src/records/stale.ts +1 -1
- package/src/sandbox/hooks.ts +128 -0
- package/src/sandbox/provision.ts +596 -0
- package/src/sandbox/tree.ts +103 -4
- package/src/secrets/marker.ts +2 -2
- package/src/secrets/scan.ts +1 -1
- package/src/shipped/references.ts +2 -2
- package/src/slides/convert/export.ts +2 -16
- package/src/slides/decks.ts +110 -0
- package/src/standards/read.ts +1 -1
- package/src/sync/seeds-report.ts +1 -1
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/teach/nav.ts +1 -1
- package/src/tooling/inject.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/standards/architecture.md +1 -0
- package/standards/canvas.md +1 -1
- package/standards/requirements.md +2 -0
- package/standards/skill-practice.md +2 -1
- package/standards/skill.md +1 -0
- package/scripts/manage-sandbox.sh +0 -472
- package/src/slides/layouts.ts +0 -478
- package/src/slides/parse.ts +0 -84
- package/src/slides/render.ts +0 -110
- package/src/slides/styles.ts +0 -96
- /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
- /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
- /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
- /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
- /package/src/{binary.ts → sweep/binary.ts} +0 -0
- /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
- /package/src/{paths.ts → tasks/paths.ts} +0 -0
- /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
|
+
}
|