@erclx/canon 5.53.0 → 5.54.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/CHANGELOG.md +10 -0
- package/README.md +10 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +5 -5
- package/claude/skills/design-identity/REQUIREMENT.md +2 -2
- package/claude/skills/design-identity/SKILL.md +6 -6
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +4 -4
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/SKILL.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
- package/claude/skills/pick-by-eye/SKILL.md +34 -41
- package/claude/skills/pick-by-eye/references/board.md +42 -0
- package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
- package/claude/skills/teach-craft/SKILL.md +32 -1
- package/claude/skills/teach-craft/references/adopted.md +113 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
- package/claude/skills/teach-craft/references/pedagogy.md +29 -2
- package/docs/agents/install-and-sync.md +0 -8
- package/docs/target/migrations.md +8 -3
- package/docs/target/projects.md +1 -1
- package/docs/workflow/skill-map.md +0 -1
- package/docs/workflow/visual-design-workflow.md +1 -4
- package/governance/detect.toml +1 -6
- package/governance/renames.toml +0 -1
- package/package.json +2 -5
- package/src/claude/cases/misc.ts +0 -5
- package/src/claude/skills-audit.ts +1 -0
- package/src/cli/help.ts +0 -3
- package/src/cli.ts +0 -2
- package/src/commands/design.ts +16 -315
- package/src/commands/sync.ts +4 -5
- package/src/design/regen.ts +5 -32
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +3 -2
- package/src/gate/stages.ts +4 -10
- package/src/gov/audit.ts +1 -6
- package/src/markdown/scope.ts +2 -4
- package/src/shipped/references.ts +1 -2
- package/src/sync/check.ts +4 -29
- package/src/sync/stamp.ts +6 -6
- package/src/sync/target.ts +1 -4
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/canon/DESIGN.md +1 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
- package/claude/skills/canvas-board/SKILL.md +0 -143
- package/claude/skills/canvas-board/references/canvas.md +0 -170
- package/governance/rules/ui/canvas.md +0 -13
- package/src/canvas/address.ts +0 -227
- package/src/canvas/arrange.ts +0 -66
- package/src/canvas/capture.ts +0 -391
- package/src/canvas/client/app.tsx +0 -188
- package/src/canvas/client/inspector/align-grid.tsx +0 -162
- package/src/canvas/client/inspector/color-field.tsx +0 -398
- package/src/canvas/client/inspector/color-inputs.tsx +0 -139
- package/src/canvas/client/inspector/color-picker.tsx +0 -668
- package/src/canvas/client/inspector/color-space.ts +0 -240
- package/src/canvas/client/inspector/field.tsx +0 -263
- package/src/canvas/client/inspector/fields.css +0 -763
- package/src/canvas/client/inspector/section.tsx +0 -27
- package/src/canvas/client/inspector/size-field.tsx +0 -69
- package/src/canvas/client/inspector/token-select.tsx +0 -109
- package/src/canvas/client/inspector/values.ts +0 -297
- package/src/canvas/client/inspector.tsx +0 -692
- package/src/canvas/client/keys.ts +0 -71
- package/src/canvas/client/layers.tsx +0 -118
- package/src/canvas/client/main.tsx +0 -71
- package/src/canvas/client/pages-panel.tsx +0 -212
- package/src/canvas/client/panel-handle.tsx +0 -90
- package/src/canvas/client/selection.tsx +0 -199
- package/src/canvas/client/shell.css +0 -762
- package/src/canvas/client/state.ts +0 -792
- package/src/canvas/client/surface.tsx +0 -803
- package/src/canvas/client/theme-panel.tsx +0 -55
- package/src/canvas/client/tools.tsx +0 -153
- package/src/canvas/content.ts +0 -781
- package/src/canvas/edit.ts +0 -575
- package/src/canvas/editing.ts +0 -203
- package/src/canvas/fit.ts +0 -238
- package/src/canvas/global.d.ts +0 -15
- package/src/canvas/history.ts +0 -116
- package/src/canvas/index.html +0 -20
- package/src/canvas/server.ts +0 -766
- package/src/canvas/tokens.ts +0 -221
- package/src/commands/canvas.ts +0 -1097
- package/src/design/adapter.ts +0 -59
- package/src/design/base.css +0 -203
- package/src/design/brand-favicon.ts +0 -18
- package/src/design/neutral.ts +0 -127
- package/src/design/project-fonts.ts +0 -392
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource preact */
|
|
2
|
-
import { type ComponentChildren, type JSX, toChildArray } from 'preact'
|
|
3
|
-
|
|
4
|
-
interface SectionProps {
|
|
5
|
-
readonly title: string
|
|
6
|
-
/** Sits in a fixed lane right of the title, so adding one never moves it. */
|
|
7
|
-
readonly action?: ComponentChildren
|
|
8
|
-
readonly children?: ComponentChildren
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** A titled group of fields. With no fields it renders its header alone. */
|
|
12
|
-
export function Section({
|
|
13
|
-
title,
|
|
14
|
-
action,
|
|
15
|
-
children,
|
|
16
|
-
}: SectionProps): JSX.Element {
|
|
17
|
-
const fields = toChildArray(children)
|
|
18
|
-
return (
|
|
19
|
-
<section class="inspector-section">
|
|
20
|
-
<header class="inspector-section-head">
|
|
21
|
-
<h3 class="inspector-section-title">{title}</h3>
|
|
22
|
-
<span class="inspector-section-action">{action}</span>
|
|
23
|
-
</header>
|
|
24
|
-
{fields.length > 0 ? <div class="field-grid">{fields}</div> : null}
|
|
25
|
-
</section>
|
|
26
|
-
)
|
|
27
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource preact */
|
|
2
|
-
import type { JSX } from 'preact'
|
|
3
|
-
import { Field, type Scrub } from '@/canvas/client/inspector/field'
|
|
4
|
-
|
|
5
|
-
/** The modes a size takes besides a number, by the name the field shows. */
|
|
6
|
-
const MODES = ['Fill', 'Fit'] as const
|
|
7
|
-
|
|
8
|
-
interface SizeFieldProps {
|
|
9
|
-
readonly label: string
|
|
10
|
-
readonly glyph: string
|
|
11
|
-
/** The value as the panel shows it, a mode name or a number. */
|
|
12
|
-
readonly initial: string
|
|
13
|
-
readonly isBusy: boolean
|
|
14
|
-
/** Takes a typed number or a picked mode name, each as shown. */
|
|
15
|
-
readonly onCommit: (value: string) => void
|
|
16
|
-
readonly scrub?: Scrub
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* A size that takes a number or a mode. The chevron is a native select, so
|
|
21
|
-
* its list opens above the panel's clipping and answers the keyboard as the
|
|
22
|
-
* platform does, and typing a number replaces the mode.
|
|
23
|
-
*/
|
|
24
|
-
export function SizeField({
|
|
25
|
-
label,
|
|
26
|
-
glyph,
|
|
27
|
-
initial,
|
|
28
|
-
isBusy,
|
|
29
|
-
onCommit,
|
|
30
|
-
scrub,
|
|
31
|
-
}: SizeFieldProps): JSX.Element {
|
|
32
|
-
const mode = (MODES as readonly string[]).includes(initial) ? initial : ''
|
|
33
|
-
return (
|
|
34
|
-
<Field
|
|
35
|
-
label={label}
|
|
36
|
-
glyph={glyph}
|
|
37
|
-
initial={initial}
|
|
38
|
-
isBusy={isBusy}
|
|
39
|
-
onCommit={onCommit}
|
|
40
|
-
scrub={scrub}
|
|
41
|
-
trailing={
|
|
42
|
-
<span class="size-mode">
|
|
43
|
-
<select
|
|
44
|
-
class="size-mode-select"
|
|
45
|
-
aria-label={`${label} mode`}
|
|
46
|
-
value={mode}
|
|
47
|
-
disabled={isBusy}
|
|
48
|
-
onChange={(event) => {
|
|
49
|
-
const picked = event.currentTarget.value
|
|
50
|
-
if (picked && picked !== mode) onCommit(picked)
|
|
51
|
-
}}
|
|
52
|
-
>
|
|
53
|
-
<option value="" disabled hidden>
|
|
54
|
-
Fixed
|
|
55
|
-
</option>
|
|
56
|
-
{MODES.map((name) => (
|
|
57
|
-
<option key={name} value={name}>
|
|
58
|
-
{name}
|
|
59
|
-
</option>
|
|
60
|
-
))}
|
|
61
|
-
</select>
|
|
62
|
-
<svg class="size-mode-chevron" viewBox="0 0 16 16" aria-hidden="true">
|
|
63
|
-
<path d="M4.5 6.5 8 10l3.5-3.5" />
|
|
64
|
-
</svg>
|
|
65
|
-
</span>
|
|
66
|
-
}
|
|
67
|
-
/>
|
|
68
|
-
)
|
|
69
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource preact */
|
|
2
|
-
import type { JSX } from 'preact'
|
|
3
|
-
import { Field, type Scrub } from '@/canvas/client/inspector/field'
|
|
4
|
-
|
|
5
|
-
/** The menu entry that hands the value back to the field for typing. */
|
|
6
|
-
const RAW = ''
|
|
7
|
-
|
|
8
|
-
interface TokenSelectProps {
|
|
9
|
-
readonly label: string
|
|
10
|
-
readonly glyph: string
|
|
11
|
-
/** The value as the panel shows it, a token's name where one stands. */
|
|
12
|
-
readonly initial: string
|
|
13
|
-
/** One token group's names, in the order the token sheet sorts them. */
|
|
14
|
-
readonly tokens: readonly string[]
|
|
15
|
-
/** The token the element names, where it names one. */
|
|
16
|
-
readonly token?: string
|
|
17
|
-
readonly isRaw: boolean
|
|
18
|
-
readonly isBusy: boolean
|
|
19
|
-
readonly isWide?: boolean
|
|
20
|
-
readonly placeholder?: string
|
|
21
|
-
/** Takes a typed value as typed. */
|
|
22
|
-
readonly onCommit: (value: string) => void
|
|
23
|
-
/** Takes the name of a token picked from the menu or typed whole. */
|
|
24
|
-
readonly onPick: (name: string) => void
|
|
25
|
-
readonly scrub?: Scrub
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* A value that takes a token or a typed literal. The menu lists the group's
|
|
30
|
-
* tokens first and a raw value last, which returns focus to the field, so a
|
|
31
|
-
* token is one pick away and a literal is still one keystroke. A group with no
|
|
32
|
-
* tokens leaves the field alone, with no menu and no raw marker.
|
|
33
|
-
*/
|
|
34
|
-
export function TokenSelect({
|
|
35
|
-
label,
|
|
36
|
-
glyph,
|
|
37
|
-
initial,
|
|
38
|
-
tokens,
|
|
39
|
-
token,
|
|
40
|
-
isRaw,
|
|
41
|
-
isBusy,
|
|
42
|
-
isWide,
|
|
43
|
-
placeholder,
|
|
44
|
-
onCommit,
|
|
45
|
-
onPick,
|
|
46
|
-
scrub,
|
|
47
|
-
}: TokenSelectProps): JSX.Element {
|
|
48
|
-
const hasTokens = tokens.length > 0
|
|
49
|
-
return (
|
|
50
|
-
<Field
|
|
51
|
-
label={label}
|
|
52
|
-
glyph={glyph}
|
|
53
|
-
initial={initial}
|
|
54
|
-
isBusy={isBusy}
|
|
55
|
-
isWide={isWide}
|
|
56
|
-
placeholder={placeholder}
|
|
57
|
-
onCommit={(typed) => {
|
|
58
|
-
if (tokens.includes(typed)) onPick(typed)
|
|
59
|
-
else onCommit(typed)
|
|
60
|
-
}}
|
|
61
|
-
scrub={scrub}
|
|
62
|
-
trailing={
|
|
63
|
-
hasTokens ? (
|
|
64
|
-
<>
|
|
65
|
-
{isRaw ? (
|
|
66
|
-
<span
|
|
67
|
-
class="raw"
|
|
68
|
-
title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
|
|
69
|
-
>
|
|
70
|
-
raw
|
|
71
|
-
</span>
|
|
72
|
-
) : null}
|
|
73
|
-
<span class="size-mode">
|
|
74
|
-
<select
|
|
75
|
-
class="size-mode-select"
|
|
76
|
-
aria-label={`${label} tokens`}
|
|
77
|
-
value={token ?? RAW}
|
|
78
|
-
disabled={isBusy}
|
|
79
|
-
onChange={(event) => {
|
|
80
|
-
const picked = event.currentTarget.value
|
|
81
|
-
if (picked === RAW) {
|
|
82
|
-
event.currentTarget
|
|
83
|
-
.closest('.glyph-field')
|
|
84
|
-
?.querySelector<HTMLInputElement>('.glyph-field-input')
|
|
85
|
-
?.focus()
|
|
86
|
-
} else if (picked !== token) onPick(picked)
|
|
87
|
-
}}
|
|
88
|
-
>
|
|
89
|
-
{tokens.map((name) => (
|
|
90
|
-
<option key={name} value={name}>
|
|
91
|
-
{name}
|
|
92
|
-
</option>
|
|
93
|
-
))}
|
|
94
|
-
<option value={RAW}>Raw value</option>
|
|
95
|
-
</select>
|
|
96
|
-
<svg
|
|
97
|
-
class="size-mode-chevron"
|
|
98
|
-
viewBox="0 0 16 16"
|
|
99
|
-
aria-hidden="true"
|
|
100
|
-
>
|
|
101
|
-
<path d="M4.5 6.5 8 10l3.5-3.5" />
|
|
102
|
-
</svg>
|
|
103
|
-
</span>
|
|
104
|
-
</>
|
|
105
|
-
) : null
|
|
106
|
-
}
|
|
107
|
-
/>
|
|
108
|
-
)
|
|
109
|
-
}
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How the inspector reads a computed value and writes a typed one back. A
|
|
3
|
-
* field shows the formatted form, and only a value the operator changed goes
|
|
4
|
-
* back through `toCssValue`, so rounding never writes a value nobody typed.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/** A length that runs below zero, so a bare number is pixels and unclamped. */
|
|
8
|
-
const SIGNED_LENGTHS = new Set(['letter-spacing'])
|
|
9
|
-
|
|
10
|
-
/** Properties whose bare numbers are pixel lengths. */
|
|
11
|
-
const LENGTHS = new Set([
|
|
12
|
-
'width',
|
|
13
|
-
'height',
|
|
14
|
-
'padding',
|
|
15
|
-
'gap',
|
|
16
|
-
'font-size',
|
|
17
|
-
'border-radius',
|
|
18
|
-
'border-top-left-radius',
|
|
19
|
-
'border-top-right-radius',
|
|
20
|
-
'border-bottom-right-radius',
|
|
21
|
-
'border-bottom-left-radius',
|
|
22
|
-
])
|
|
23
|
-
|
|
24
|
-
/** Properties a size mode writes, each mode by the name the field shows. */
|
|
25
|
-
const SIZES = new Set(['width', 'height'])
|
|
26
|
-
const SIZE_MODES: Readonly<Record<string, string>> = {
|
|
27
|
-
Fill: '100%',
|
|
28
|
-
Fit: 'fit-content',
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const PX = /^(-?\d*\.?\d+)px$/
|
|
32
|
-
const EM = /^(-?\d*\.?\d+)em$/
|
|
33
|
-
const NUMBER = /^-?\d*\.?\d+$/
|
|
34
|
-
|
|
35
|
-
function formatPx(token: string): string {
|
|
36
|
-
const match = token.match(PX)
|
|
37
|
-
if (!match) return token
|
|
38
|
-
const value = Number(match[1])
|
|
39
|
-
return String(
|
|
40
|
-
Math.abs(value) < 10 ? Math.round(value * 10) / 10 : Math.round(value),
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A color that paints nothing, which the panel reads as no fill. */
|
|
45
|
-
export function isNoFill(value: string): boolean {
|
|
46
|
-
const normalized = value.trim().toLowerCase().replace(/\s+/g, '')
|
|
47
|
-
return normalized === 'transparent' || normalized === 'rgba(0,0,0,0)'
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function displayValue(property: string, raw: string): string {
|
|
51
|
-
const value = raw.trim()
|
|
52
|
-
if (property === 'gap' && (value === '' || value === 'normal')) return ''
|
|
53
|
-
if (property === 'letter-spacing') {
|
|
54
|
-
if (value === 'normal') return ''
|
|
55
|
-
const em = value.match(EM)
|
|
56
|
-
if (em) return `${Math.round(Number(em[1]) * 1000) / 1000}em`
|
|
57
|
-
}
|
|
58
|
-
if (property === 'background-color' && isNoFill(value)) return ''
|
|
59
|
-
if (property === 'opacity' && value !== '')
|
|
60
|
-
return `${Math.round(Number(value) * 100)}%`
|
|
61
|
-
if (SIZES.has(property)) {
|
|
62
|
-
const mode = Object.keys(SIZE_MODES).find(
|
|
63
|
-
(name) => SIZE_MODES[name] === value,
|
|
64
|
-
)
|
|
65
|
-
if (mode) return mode
|
|
66
|
-
}
|
|
67
|
-
if (value === 'auto') return 'Auto'
|
|
68
|
-
if (value === 'normal') return 'Normal'
|
|
69
|
-
return value.split(/\s+/).map(formatPx).join(' ')
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function toCssValue(property: string, typed: string): string {
|
|
73
|
-
const value = typed.trim()
|
|
74
|
-
/* A line height has no auto keyword, so the field's Auto is its normal. */
|
|
75
|
-
if (property === 'line-height' && value.toLowerCase() === 'auto') {
|
|
76
|
-
return 'normal'
|
|
77
|
-
}
|
|
78
|
-
if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
|
|
79
|
-
if (SIZES.has(property) && SIZE_MODES[value]) return SIZE_MODES[value]
|
|
80
|
-
if (!LENGTHS.has(property) && !SIGNED_LENGTHS.has(property)) return value
|
|
81
|
-
return value
|
|
82
|
-
.split(/\s+/)
|
|
83
|
-
.map((token) => (/^-?\d*\.?\d+$/.test(token) ? `${token}px` : token))
|
|
84
|
-
.join(' ')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** A typed opacity percent as the fraction CSS takes, refused outside 0 to 100. */
|
|
88
|
-
export function opacityToCss(typed: string): string | undefined {
|
|
89
|
-
const percent = readOpacity(typed)
|
|
90
|
-
return percent === undefined ? undefined : String(percent / 100)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The radius all four corners share, read in the order the longhands run,
|
|
95
|
-
* or undefined where any corner differs and the panel shows each one.
|
|
96
|
-
*/
|
|
97
|
-
export function sharedRadius(corners: readonly string[]): string | undefined {
|
|
98
|
-
const [first] = corners
|
|
99
|
-
return corners.every((corner) => corner === first) ? first : undefined
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Holds a scrubbed number inside what the property accepts. The writer takes
|
|
104
|
-
* any declaration that does not break the rule, so a negative length or a
|
|
105
|
-
* weight past 1000 would reach the file and the browser would drop it there.
|
|
106
|
-
*/
|
|
107
|
-
export function clampScrub(property: string, value: number): number {
|
|
108
|
-
if (property === 'font-weight') return Math.min(1000, Math.max(1, value))
|
|
109
|
-
if (LENGTHS.has(property) || property === 'line-height') {
|
|
110
|
-
return Math.max(0, value)
|
|
111
|
-
}
|
|
112
|
-
return value
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* A line height as the panel shows it: a ratio of the font size, the way a
|
|
117
|
-
* unitless value writes it. A browser computes a unitless value to pixels, so
|
|
118
|
-
* a pixel value reads back as its ratio when the font size is in pixels too.
|
|
119
|
-
*/
|
|
120
|
-
export function lineHeightValue(raw: string, fontSize: string): string {
|
|
121
|
-
const value = raw.trim()
|
|
122
|
-
if (value === 'normal') return 'Auto'
|
|
123
|
-
const ratio = (number: number): string =>
|
|
124
|
-
String(Math.round(number * 100) / 100)
|
|
125
|
-
if (NUMBER.test(value)) return ratio(Number(value))
|
|
126
|
-
const height = value.match(PX)
|
|
127
|
-
const size = fontSize.trim().match(PX)
|
|
128
|
-
if (height && size && Number(size[1]) > 0) {
|
|
129
|
-
return ratio(Number(height[1]) / Number(size[1]))
|
|
130
|
-
}
|
|
131
|
-
return value
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The first family of a stack, unquoted, which is the one the field names. */
|
|
135
|
-
export function firstFamily(stack: string): string {
|
|
136
|
-
const value = stack.trim()
|
|
137
|
-
if (value.startsWith('var(')) return value
|
|
138
|
-
const [first = ''] = value.split(',')
|
|
139
|
-
return first.trim().replace(/^(["'])(.*)\1$/, '$2')
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Text alignment by the side it lands on, so the group presses the side the
|
|
144
|
-
* text paints on. The logical keywords turn on the element's direction.
|
|
145
|
-
*/
|
|
146
|
-
export function textAlignOf(computed: string, direction: string): string {
|
|
147
|
-
const value = computed.trim()
|
|
148
|
-
const isRtl = direction.trim() === 'rtl'
|
|
149
|
-
if (value === 'start') return isRtl ? 'right' : 'left'
|
|
150
|
-
if (value === 'end') return isRtl ? 'left' : 'right'
|
|
151
|
-
return value
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* A value as the writer takes it. A whole declaration pasted from a
|
|
156
|
-
* stylesheet keeps its value alone, since the writer refuses the semicolon.
|
|
157
|
-
*/
|
|
158
|
-
export function declarationValue(property: string, typed: string): string {
|
|
159
|
-
const value = typed.trim().replace(/;\s*$/, '')
|
|
160
|
-
const prefix = `${property}:`
|
|
161
|
-
return value.toLowerCase().startsWith(prefix)
|
|
162
|
-
? value.slice(prefix.length).trim()
|
|
163
|
-
: value
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** The token a value names when it is a bare `var()` and nothing else. */
|
|
167
|
-
export function tokenOf(value: string): string | undefined {
|
|
168
|
-
return value.trim().match(VAR)?.[1]
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Units a scrub moves for each pixel the pointer travels. */
|
|
172
|
-
export function scrubStep(isCoarse: boolean): number {
|
|
173
|
-
return isCoarse ? 10 : 1
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** A color the picker can write: a token or a hex, each with an opacity. */
|
|
177
|
-
export type WrittenColor =
|
|
178
|
-
| {
|
|
179
|
-
readonly kind: 'token'
|
|
180
|
-
/** The custom property name as the token sheet gives it. */
|
|
181
|
-
readonly name: string
|
|
182
|
-
readonly opacity: number
|
|
183
|
-
}
|
|
184
|
-
| { readonly kind: 'hex'; readonly hex: string; readonly opacity: number }
|
|
185
|
-
|
|
186
|
-
export type ParsedColor = WrittenColor | { readonly kind: 'none' }
|
|
187
|
-
|
|
188
|
-
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
|
|
189
|
-
const RGB =
|
|
190
|
-
/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i
|
|
191
|
-
/** What a browser computes `color-mix()` in srgb to, channels 0 to 1. */
|
|
192
|
-
const SRGB =
|
|
193
|
-
/^color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i
|
|
194
|
-
const VAR = /^var\(\s*(--[\w-]+)\s*\)$/i
|
|
195
|
-
const MIX =
|
|
196
|
-
/^color-mix\(\s*in\s+srgb\s*,\s*var\(\s*(--[\w-]+)\s*\)\s+([\d.]+)%\s*,\s*transparent\s*\)$/i
|
|
197
|
-
|
|
198
|
-
function channel(value: number): string {
|
|
199
|
-
return Math.round(Math.min(255, Math.max(0, value)))
|
|
200
|
-
.toString(16)
|
|
201
|
-
.padStart(2, '0')
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* An alpha as a percent. A value past 1 clamps to full rather than reading as
|
|
206
|
-
* a 0 to 255 scale, since CSS clamps it the same way when it paints.
|
|
207
|
-
*/
|
|
208
|
-
function alphaPercent(alpha: string | undefined): number {
|
|
209
|
-
if (alpha === undefined) return 100
|
|
210
|
-
const fraction = alpha.endsWith('%')
|
|
211
|
-
? Number(alpha.slice(0, -1)) / 100
|
|
212
|
-
: Number(alpha)
|
|
213
|
-
return Math.round(Math.min(1, Math.max(0, fraction)) * 100)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** A typed hex in any of its lengths, normalized to six digits. */
|
|
217
|
-
export function readHex(
|
|
218
|
-
typed: string,
|
|
219
|
-
): { readonly hex: string; readonly opacity?: number } | undefined {
|
|
220
|
-
const match = typed.trim().match(HEX)
|
|
221
|
-
if (!match) return undefined
|
|
222
|
-
const digits = match[1].toLowerCase()
|
|
223
|
-
const full =
|
|
224
|
-
digits.length <= 4
|
|
225
|
-
? [...digits].map((digit) => digit + digit).join('')
|
|
226
|
-
: digits
|
|
227
|
-
const hex = full.slice(0, 6)
|
|
228
|
-
if (full.length === 6) return { hex }
|
|
229
|
-
return {
|
|
230
|
-
hex,
|
|
231
|
-
opacity: Math.round((parseInt(full.slice(6), 16) / 255) * 100),
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** A typed opacity percent, refused outside 0 to 100. */
|
|
236
|
-
export function readOpacity(typed: string): number | undefined {
|
|
237
|
-
const value = typed.trim().replace(/%$/, '')
|
|
238
|
-
if (!/^-?\d*\.?\d+$/.test(value)) return undefined
|
|
239
|
-
const percent = Number(value)
|
|
240
|
-
return percent >= 0 && percent <= 100 ? Math.round(percent) : undefined
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Reads a computed or inline color into what the picker shows. A form it does
|
|
245
|
-
* not know, such as a named color, comes back undefined.
|
|
246
|
-
*/
|
|
247
|
-
export function parseColor(value: string): ParsedColor | undefined {
|
|
248
|
-
const text = value.trim()
|
|
249
|
-
if (isNoFill(text)) return { kind: 'none' }
|
|
250
|
-
const token = text.match(VAR)
|
|
251
|
-
if (token) return { kind: 'token', name: token[1], opacity: 100 }
|
|
252
|
-
const mix = text.match(MIX)
|
|
253
|
-
if (mix) {
|
|
254
|
-
return {
|
|
255
|
-
kind: 'token',
|
|
256
|
-
name: mix[1],
|
|
257
|
-
opacity: Math.round(Math.min(100, Number(mix[2]))),
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
const hex = text.startsWith('#') ? readHex(text) : undefined
|
|
261
|
-
if (hex) return painted(hex.hex, hex.opacity ?? 100)
|
|
262
|
-
const rgb = text.match(RGB)
|
|
263
|
-
if (rgb) {
|
|
264
|
-
return painted(
|
|
265
|
-
[rgb[1], rgb[2], rgb[3]].map((part) => channel(Number(part))).join(''),
|
|
266
|
-
alphaPercent(rgb[4]),
|
|
267
|
-
)
|
|
268
|
-
}
|
|
269
|
-
const srgb = text.match(SRGB)
|
|
270
|
-
if (!srgb) return undefined
|
|
271
|
-
return painted(
|
|
272
|
-
[srgb[1], srgb[2], srgb[3]]
|
|
273
|
-
.map((part) => channel(Number(part) * 255))
|
|
274
|
-
.join(''),
|
|
275
|
-
alphaPercent(srgb[4]),
|
|
276
|
-
)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** A color at zero opacity paints nothing, whichever form wrote it. */
|
|
280
|
-
function painted(hex: string, opacity: number): ParsedColor {
|
|
281
|
-
return opacity === 0 ? { kind: 'none' } : { kind: 'hex', hex, opacity }
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* The value a pick writes. A token below full opacity mixes with transparent
|
|
286
|
-
* rather than resolving to a hex, so it keeps following the theme.
|
|
287
|
-
*/
|
|
288
|
-
export function composeColor(color: WrittenColor): string {
|
|
289
|
-
if (color.kind === 'token') {
|
|
290
|
-
return color.opacity >= 100
|
|
291
|
-
? `var(${color.name})`
|
|
292
|
-
: `color-mix(in srgb, var(${color.name}) ${color.opacity}%, transparent)`
|
|
293
|
-
}
|
|
294
|
-
return color.opacity >= 100
|
|
295
|
-
? `#${color.hex}`
|
|
296
|
-
: `#${color.hex}${channel((color.opacity / 100) * 255)}`
|
|
297
|
-
}
|