@erclx/canon 5.52.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.
Files changed (201) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +11 -13
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -8
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  7. package/claude/skills/design-identity/SKILL.md +6 -6
  8. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  9. package/claude/skills/design-record/SKILL.md +4 -4
  10. package/claude/skills/design-record/references/folder.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +0 -1
  13. package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
  14. package/claude/skills/draw-figure/SKILL.md +5 -6
  15. package/claude/skills/draw-figure/references/figures.md +0 -1
  16. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  17. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
  18. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  19. package/claude/skills/pick-by-eye/references/board.md +42 -0
  20. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  21. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  22. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  23. package/claude/skills/teach-craft/SKILL.md +70 -0
  24. package/claude/skills/teach-craft/references/adopted.md +113 -0
  25. package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
  26. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
  27. package/docs/agents/audits.md +0 -1
  28. package/docs/agents/install-and-sync.md +0 -8
  29. package/docs/target/migrations.md +16 -2
  30. package/docs/target/projects.md +1 -1
  31. package/docs/workflow/parallel-features.md +1 -1
  32. package/docs/workflow/skill-map-on-request.md +10 -22
  33. package/docs/workflow/skill-map.md +11 -14
  34. package/docs/workflow/visual-design-workflow.md +1 -4
  35. package/governance/detect.toml +1 -6
  36. package/governance/renames.toml +0 -1
  37. package/package.json +2 -8
  38. package/scripts/core/regen-web-previews.ts +2 -67
  39. package/src/audits/catalog.ts +0 -1
  40. package/src/browser/engine.ts +3 -6
  41. package/src/capture/render.ts +1 -1
  42. package/src/claude/cases/authoring.ts +0 -12
  43. package/src/claude/cases/misc.ts +0 -22
  44. package/src/claude/cases/workflow.ts +2 -38
  45. package/src/claude/skills-audit.ts +1 -1
  46. package/src/cli/help.ts +0 -10
  47. package/src/cli.ts +0 -8
  48. package/src/commands/design.ts +16 -315
  49. package/src/commands/records.ts +0 -1
  50. package/src/commands/serve.ts +2 -2
  51. package/src/commands/sync.ts +4 -5
  52. package/src/design/components.ts +8 -1514
  53. package/src/design/css.ts +5 -6
  54. package/src/design/fonts.ts +3 -5
  55. package/src/design/regen.ts +5 -32
  56. package/src/design/render.ts +1 -1
  57. package/src/design/tokens.ts +3 -2
  58. package/src/driver/drive.ts +3 -4
  59. package/src/driver/steps.ts +2 -5
  60. package/src/gate/stages.ts +4 -10
  61. package/src/gov/audit.ts +1 -6
  62. package/src/inventory/walk.ts +1 -1
  63. package/src/markdown/scope.ts +6 -35
  64. package/src/records/validate.ts +4 -176
  65. package/src/sandbox/equivalence.ts +0 -5
  66. package/src/shipped/references.ts +1 -2
  67. package/src/sync/check.ts +4 -29
  68. package/src/sync/stamp.ts +6 -6
  69. package/src/sync/target.ts +1 -4
  70. package/tooling/claude/reference.md +1 -1
  71. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  72. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  73. package/claude/skills/canvas-board/SKILL.md +0 -143
  74. package/claude/skills/canvas-board/references/canvas.md +0 -170
  75. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  76. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  77. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  78. package/claude/skills/draft-doc/SKILL.md +0 -11
  79. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  80. package/claude/skills/draft-figure/SKILL.md +0 -11
  81. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  82. package/claude/skills/draft-identity/SKILL.md +0 -11
  83. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  84. package/claude/skills/draft-ready/SKILL.md +0 -11
  85. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  86. package/claude/skills/draft-screencast/SKILL.md +0 -11
  87. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  88. package/claude/skills/draft-slides/SKILL.md +0 -11
  89. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  90. package/claude/skills/make-deck/SKILL.md +0 -80
  91. package/claude/skills/make-deck/references/deck.md +0 -183
  92. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  93. package/claude/skills/read-frames/SKILL.md +0 -55
  94. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  95. package/claude/skills/record-screencast/SKILL.md +0 -99
  96. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  97. package/claude/skills/script-screencast/SKILL.md +0 -70
  98. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  99. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  100. package/claude/skills/teach-workspace/SKILL.md +0 -196
  101. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  102. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  104. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  105. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  106. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  107. package/claude/skills/teach-workspace/references/teach.md +0 -211
  108. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  109. package/claude/skills/video-craft/SKILL.md +0 -96
  110. package/claude/skills/video-craft/references/adopted.md +0 -110
  111. package/governance/rules/ui/canvas.md +0 -13
  112. package/src/canvas/address.ts +0 -227
  113. package/src/canvas/arrange.ts +0 -66
  114. package/src/canvas/capture.ts +0 -391
  115. package/src/canvas/client/app.tsx +0 -188
  116. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  117. package/src/canvas/client/inspector/color-field.tsx +0 -398
  118. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  119. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  120. package/src/canvas/client/inspector/color-space.ts +0 -240
  121. package/src/canvas/client/inspector/field.tsx +0 -263
  122. package/src/canvas/client/inspector/fields.css +0 -763
  123. package/src/canvas/client/inspector/section.tsx +0 -27
  124. package/src/canvas/client/inspector/size-field.tsx +0 -69
  125. package/src/canvas/client/inspector/token-select.tsx +0 -109
  126. package/src/canvas/client/inspector/values.ts +0 -297
  127. package/src/canvas/client/inspector.tsx +0 -692
  128. package/src/canvas/client/keys.ts +0 -71
  129. package/src/canvas/client/layers.tsx +0 -118
  130. package/src/canvas/client/main.tsx +0 -71
  131. package/src/canvas/client/pages-panel.tsx +0 -212
  132. package/src/canvas/client/panel-handle.tsx +0 -90
  133. package/src/canvas/client/selection.tsx +0 -199
  134. package/src/canvas/client/shell.css +0 -762
  135. package/src/canvas/client/state.ts +0 -792
  136. package/src/canvas/client/surface.tsx +0 -803
  137. package/src/canvas/client/theme-panel.tsx +0 -55
  138. package/src/canvas/client/tools.tsx +0 -153
  139. package/src/canvas/content.ts +0 -781
  140. package/src/canvas/edit.ts +0 -575
  141. package/src/canvas/editing.ts +0 -203
  142. package/src/canvas/fit.ts +0 -238
  143. package/src/canvas/global.d.ts +0 -15
  144. package/src/canvas/history.ts +0 -116
  145. package/src/canvas/index.html +0 -20
  146. package/src/canvas/server.ts +0 -766
  147. package/src/canvas/tokens.ts +0 -221
  148. package/src/commands/canvas.ts +0 -1097
  149. package/src/commands/demo.ts +0 -542
  150. package/src/commands/slides.ts +0 -184
  151. package/src/commands/teach.ts +0 -1401
  152. package/src/demo/beats.ts +0 -135
  153. package/src/demo/compile.ts +0 -350
  154. package/src/demo/container.ts +0 -219
  155. package/src/demo/cursors.ts +0 -55
  156. package/src/demo/drive.ts +0 -442
  157. package/src/demo/pointer.ts +0 -178
  158. package/src/demo/theme.ts +0 -112
  159. package/src/demo/timeline.ts +0 -64
  160. package/src/design/adapter.ts +0 -59
  161. package/src/design/base.css +0 -203
  162. package/src/design/brand-favicon.ts +0 -18
  163. package/src/design/neutral.ts +0 -127
  164. package/src/design/project-fonts.ts +0 -392
  165. package/src/slides/convert/chart.ts +0 -165
  166. package/src/slides/convert/deck.ts +0 -244
  167. package/src/slides/convert/export.ts +0 -567
  168. package/src/slides/convert/master.ts +0 -198
  169. package/src/slides/convert/shapes.ts +0 -804
  170. package/src/slides/convert/svg.ts +0 -38
  171. package/src/slides/convert/walk.ts +0 -671
  172. package/src/slides/decks.ts +0 -110
  173. package/src/slides/open.ts +0 -18
  174. package/src/slides/package/fonts.ts +0 -301
  175. package/src/slides/package/motion.ts +0 -316
  176. package/src/slides/package/test-face.ts +0 -113
  177. package/src/teach/browser/compile.ts +0 -44
  178. package/src/teach/browser/dismiss.ts +0 -76
  179. package/src/teach/browser/focus-line.ts +0 -19
  180. package/src/teach/browser/glossary-filter.ts +0 -44
  181. package/src/teach/browser/head.ts +0 -30
  182. package/src/teach/browser/quiz.ts +0 -19
  183. package/src/teach/browser/reload.ts +0 -9
  184. package/src/teach/browser/sidebar.ts +0 -233
  185. package/src/teach/browser/theme.ts +0 -18
  186. package/src/teach/components/code.tsx +0 -24
  187. package/src/teach/components/heading.tsx +0 -11
  188. package/src/teach/components/list.tsx +0 -17
  189. package/src/teach/components/paragraph.tsx +0 -29
  190. package/src/teach/components/refs.tsx +0 -28
  191. package/src/teach/cross-references.ts +0 -78
  192. package/src/teach/fonts.ts +0 -16
  193. package/src/teach/highlight.ts +0 -71
  194. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  195. package/src/teach/html/jsx-runtime.ts +0 -113
  196. package/src/teach/html/raw.ts +0 -28
  197. package/src/teach/lesson.ts +0 -178
  198. package/src/teach/nav.ts +0 -1337
  199. package/src/teach/render.ts +0 -240
  200. package/src/teach/workspace.ts +0 -1164
  201. package/src/teach/write-if-changed.ts +0 -17
@@ -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
- }