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