@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.
- package/README.md +2 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
- package/claude/skills/backlog-triage/SKILL.md +19 -6
- package/claude/skills/canvas/REQUIREMENT.md +49 -0
- package/claude/skills/canvas/SKILL.md +106 -0
- 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-extract/REQUIREMENT.md +1 -1
- 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-doc/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
- package/claude/skills/draft-figure/SKILL.md +24 -51
- package/claude/skills/draft-figure/references/architecture-set.md +35 -0
- package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
- package/claude/skills/draft-figure/references/mermaid.md +55 -0
- 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-followup/SKILL.md +17 -6
- package/claude/skills/git-issue/references/issue.md +10 -0
- package/claude/skills/git-pr/references/evidence.md +12 -2
- package/claude/skills/git-pr/references/labels.md +12 -0
- package/claude/skills/git-split/SKILL.md +19 -8
- package/claude/skills/index-lookup/SKILL.md +2 -2
- 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-address/SKILL.md +6 -13
- package/claude/skills/review-address/references/rest-reads.md +43 -0
- package/claude/skills/review-craft/references/ui.md +7 -1
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +9 -3
- package/claude/skills/review-pr/references/close-out.md +2 -2
- package/claude/skills/review-pr/references/post.md +17 -5
- package/claude/skills/review-pr/references/scope.md +10 -8
- package/claude/skills/review-ui/SKILL.md +12 -4
- package/claude/skills/review-ui/references/post.md +4 -2
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +38 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
- package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
- package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
- package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
- 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 +4 -2
- package/docs/agents/capture.md +1 -1
- package/docs/agents/commands.md +47 -47
- package/docs/agents/context-audit-checks.md +2 -2
- package/docs/agents/index.md +2 -3
- package/docs/agents/key-changes.md +3 -3
- package/docs/agents/pr-evidence.md +11 -2
- package/docs/agents/pr-reads.md +5 -4
- 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/target/projects.md +4 -4
- package/docs/workflow/ai-workflow.md +1 -2
- 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 +3 -36
- package/docs/workflow/visual-design-workflow.md +1 -0
- package/governance/renames.toml +0 -4
- package/governance/rules/standards/markdown.md +1 -0
- package/package.json +1 -2
- package/scripts/core/install-check.sh +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/audits/catalog.ts +2 -2
- package/src/canvas/capture.ts +1 -9
- package/src/canvas/client/inspector/color-field.tsx +368 -0
- package/src/canvas/client/inspector/field.tsx +200 -0
- package/src/canvas/client/inspector/fields.css +292 -0
- package/src/canvas/client/inspector/section.tsx +27 -0
- package/src/canvas/client/inspector/values.ts +183 -0
- package/src/canvas/client/inspector.tsx +176 -160
- package/src/canvas/client/shell.css +0 -101
- 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/misc.ts +5 -0
- package/src/claude/cases/workflow.ts +6 -1
- package/src/claude/seeds.ts +1 -2
- 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/design.ts +2 -64
- package/src/commands/feedback.ts +3 -3
- package/src/commands/pr.ts +219 -202
- 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 +8 -7
- 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/help.ts +2 -2
- package/src/markdown/ceiling.ts +1 -1
- package/src/migrate/skill-names.ts +7 -1
- package/src/pr/evidence.ts +25 -5
- package/src/pr/rest.ts +137 -0
- 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 +112 -0
- package/standards/context.md +2 -1
- package/standards/figures.md +0 -1
- package/standards/index.md +1 -1
- package/standards/markdown.md +1 -0
- package/standards/mermaid.md +1 -7
- package/standards/requirements.md +2 -0
- package/standards/skill-practice.md +2 -1
- package/standards/skill.md +1 -0
- package/tooling/claude/reference.md +0 -5
- package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
- package/claude/skills/draft-diagram/SKILL.md +0 -177
- package/docs/agents/design-board.md +0 -33
- package/governance/rules/standards/diagrams.md +0 -14
- package/governance/rules/standards/mermaid.md +0 -15
- package/scripts/core/check-gallery-exclusion.sh +0 -50
- package/scripts/manage-sandbox.sh +0 -472
- package/src/design/board.ts +0 -576
- 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/standards/diagrams.md +0 -132
- package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
- /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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { useRef } from 'preact/hooks'
|
|
4
|
+
import { scrubStep } from '@/canvas/client/inspector/values'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One drag of a number field's glyph. Each call takes the value as the field
|
|
8
|
+
* shows it. Only `commit` writes, so a drag posts one edit at most.
|
|
9
|
+
*/
|
|
10
|
+
export interface ScrubSession {
|
|
11
|
+
readonly preview: (value: string) => void
|
|
12
|
+
readonly restore: () => void
|
|
13
|
+
readonly commit: (value: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opens a session at press, so what `restore` returns to is read before any
|
|
18
|
+
* preview, however often the panel renders during the drag.
|
|
19
|
+
*/
|
|
20
|
+
export interface Scrub {
|
|
21
|
+
readonly begin: () => ScrubSession
|
|
22
|
+
/** Holds a scrubbed number inside what the property accepts. */
|
|
23
|
+
readonly clamp: (value: number) => number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FieldProps {
|
|
27
|
+
/** The full word, which names the input for assistive technology. */
|
|
28
|
+
readonly label: string
|
|
29
|
+
/** The short mark drawn inside the field, hidden from assistive technology. */
|
|
30
|
+
readonly glyph: string
|
|
31
|
+
readonly initial: string
|
|
32
|
+
readonly isBusy: boolean
|
|
33
|
+
readonly isWide?: boolean
|
|
34
|
+
readonly placeholder?: string
|
|
35
|
+
readonly onCommit: (value: string) => void
|
|
36
|
+
/** Makes the glyph a drag handle. Typing stays the keyboard path. */
|
|
37
|
+
readonly scrub?: Scrub
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NUMBER = /^-?\d*\.?\d+$/
|
|
41
|
+
|
|
42
|
+
interface Drag {
|
|
43
|
+
readonly pointerId: number
|
|
44
|
+
/** What the input held at press, which a cancel puts back. */
|
|
45
|
+
readonly typed: string
|
|
46
|
+
/** The number the drag started from, an empty field's placeholder included. */
|
|
47
|
+
readonly start: string
|
|
48
|
+
readonly session: ScrubSession
|
|
49
|
+
lastX: number
|
|
50
|
+
value: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fieldClass(isWide: boolean | undefined, modifier?: string): string {
|
|
54
|
+
return ['glyph-field', isWide ? 'is-wide' : '', modifier ?? '']
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join(' ')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Commits on change, which a text input fires on Enter or on leaving it, and
|
|
61
|
+
* sends nothing when the value is what it started at. Escape puts it back.
|
|
62
|
+
*/
|
|
63
|
+
export function Field({
|
|
64
|
+
label,
|
|
65
|
+
glyph,
|
|
66
|
+
initial,
|
|
67
|
+
isBusy,
|
|
68
|
+
isWide,
|
|
69
|
+
placeholder,
|
|
70
|
+
onCommit,
|
|
71
|
+
scrub,
|
|
72
|
+
}: FieldProps): JSX.Element {
|
|
73
|
+
const input = useRef<HTMLInputElement>(null)
|
|
74
|
+
const handle = useRef<HTMLSpanElement>(null)
|
|
75
|
+
const drag = useRef<Drag | null>(null)
|
|
76
|
+
|
|
77
|
+
const end = (pointerId: number): Drag | undefined => {
|
|
78
|
+
const held = drag.current
|
|
79
|
+
if (!held || held.pointerId !== pointerId) return undefined
|
|
80
|
+
drag.current = null
|
|
81
|
+
return held
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* An empty field scrubs from the number its placeholder shows. */
|
|
85
|
+
const scrubFrom = (value: string): string =>
|
|
86
|
+
value.trim() || placeholder?.trim() || ''
|
|
87
|
+
const isScrubbable = scrub !== undefined && NUMBER.test(scrubFrom(initial))
|
|
88
|
+
|
|
89
|
+
const handlers = isScrubbable
|
|
90
|
+
? {
|
|
91
|
+
onPointerDown: (event: PointerEvent) => {
|
|
92
|
+
const typed = input.current?.value ?? ''
|
|
93
|
+
const start = scrubFrom(typed)
|
|
94
|
+
if (isBusy || !NUMBER.test(start)) return
|
|
95
|
+
event.preventDefault()
|
|
96
|
+
/*
|
|
97
|
+
* Capture keeps the release coming here when the pointer leaves the
|
|
98
|
+
* glyph, the panel, or crosses into a frame's own document.
|
|
99
|
+
*/
|
|
100
|
+
try {
|
|
101
|
+
handle.current?.setPointerCapture(event.pointerId)
|
|
102
|
+
} catch {
|
|
103
|
+
/* A pointer the browser no longer tracks ends on cancel instead. */
|
|
104
|
+
}
|
|
105
|
+
drag.current = {
|
|
106
|
+
pointerId: event.pointerId,
|
|
107
|
+
typed,
|
|
108
|
+
start,
|
|
109
|
+
session: scrub.begin(),
|
|
110
|
+
lastX: event.clientX,
|
|
111
|
+
value: Number(start),
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
onPointerMove: (event: PointerEvent) => {
|
|
115
|
+
const held = drag.current
|
|
116
|
+
if (!held || held.pointerId !== event.pointerId) return
|
|
117
|
+
const moved = event.clientX - held.lastX
|
|
118
|
+
held.lastX = event.clientX
|
|
119
|
+
held.value = scrub.clamp(
|
|
120
|
+
Math.round((held.value + moved * scrubStep(event.shiftKey)) * 10) /
|
|
121
|
+
10,
|
|
122
|
+
)
|
|
123
|
+
const shown = String(held.value)
|
|
124
|
+
if (input.current) input.current.value = shown
|
|
125
|
+
held.session.preview(shown)
|
|
126
|
+
},
|
|
127
|
+
onPointerUp: (event: PointerEvent) => {
|
|
128
|
+
const held = end(event.pointerId)
|
|
129
|
+
if (!held) return
|
|
130
|
+
const shown = String(held.value)
|
|
131
|
+
if (shown === held.start) held.session.restore()
|
|
132
|
+
else held.session.commit(shown)
|
|
133
|
+
},
|
|
134
|
+
onPointerCancel: (event: PointerEvent) => {
|
|
135
|
+
const held = end(event.pointerId)
|
|
136
|
+
if (!held) return
|
|
137
|
+
if (input.current) input.current.value = held.typed
|
|
138
|
+
held.session.restore()
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
: {}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div class={fieldClass(isWide)}>
|
|
145
|
+
<span
|
|
146
|
+
ref={handle}
|
|
147
|
+
class={isScrubbable ? 'glyph is-scrub' : 'glyph'}
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
title={label}
|
|
150
|
+
{...handlers}
|
|
151
|
+
>
|
|
152
|
+
{glyph}
|
|
153
|
+
</span>
|
|
154
|
+
<input
|
|
155
|
+
ref={input}
|
|
156
|
+
class="glyph-field-input"
|
|
157
|
+
type="text"
|
|
158
|
+
aria-label={label}
|
|
159
|
+
title={initial}
|
|
160
|
+
defaultValue={initial}
|
|
161
|
+
placeholder={placeholder}
|
|
162
|
+
disabled={isBusy}
|
|
163
|
+
spellcheck={false}
|
|
164
|
+
onChange={(event) => {
|
|
165
|
+
const value = event.currentTarget.value.trim()
|
|
166
|
+
if (value !== initial.trim()) onCommit(value)
|
|
167
|
+
}}
|
|
168
|
+
onKeyDown={(event) => {
|
|
169
|
+
if (event.key === 'Escape') event.currentTarget.value = initial
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface ReadOnlyFieldProps {
|
|
177
|
+
readonly label: string
|
|
178
|
+
readonly glyph: string
|
|
179
|
+
readonly value: string
|
|
180
|
+
readonly isWide?: boolean
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A value the panel shows and nothing here writes, drawn like a field. */
|
|
184
|
+
export function ReadOnlyField({
|
|
185
|
+
label,
|
|
186
|
+
glyph,
|
|
187
|
+
value,
|
|
188
|
+
isWide,
|
|
189
|
+
}: ReadOnlyFieldProps): JSX.Element {
|
|
190
|
+
return (
|
|
191
|
+
<div class={fieldClass(isWide, 'is-readonly')}>
|
|
192
|
+
<span class="glyph" aria-hidden="true" title={label}>
|
|
193
|
+
{glyph}
|
|
194
|
+
</span>
|
|
195
|
+
<output class="glyph-field-input" aria-label={label} title={value}>
|
|
196
|
+
{value}
|
|
197
|
+
</output>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|