@hadoku/themes 0.0.0-stage → 5.12.15

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-lucide +43 -0
  3. package/README.md +433 -2
  4. package/THEME_USAGE_GUIDE.md +308 -0
  5. package/dist/HadokuThemeRoot.d.ts +53 -0
  6. package/dist/HadokuThemeRoot.js +66 -0
  7. package/dist/ThemeIcons.d.ts +31 -0
  8. package/dist/ThemeIcons.js +91 -0
  9. package/dist/effects.d.ts +25 -0
  10. package/dist/effects.js +25 -0
  11. package/dist/icons/Icon.d.ts +36 -0
  12. package/dist/icons/Icon.js +89 -0
  13. package/dist/icons/index.d.ts +61 -0
  14. package/dist/icons/index.js +87 -0
  15. package/dist/icons/registry.generated.d.ts +158 -0
  16. package/dist/icons/registry.generated.js +446 -0
  17. package/dist/index.d.ts +82 -0
  18. package/dist/index.js +169 -0
  19. package/dist/metadata.d.ts +35 -0
  20. package/dist/metadata.js +108 -0
  21. package/dist/platform/index.d.ts +94 -0
  22. package/dist/platform/index.js +149 -0
  23. package/dist/platform/usePlatform.d.ts +29 -0
  24. package/dist/platform/usePlatform.js +65 -0
  25. package/dist/prefsApiBase.d.ts +37 -0
  26. package/dist/prefsApiBase.js +40 -0
  27. package/dist/theme-types.d.ts +34 -0
  28. package/dist/theme-types.js +1 -0
  29. package/dist/themeContext.d.ts +62 -0
  30. package/dist/themeContext.js +70 -0
  31. package/dist/themePrefs.d.ts +29 -0
  32. package/dist/themePrefs.js +48 -0
  33. package/dist/useRefElement.d.ts +19 -0
  34. package/dist/useRefElement.js +52 -0
  35. package/dist/useTheme.d.ts +46 -0
  36. package/dist/useTheme.js +216 -0
  37. package/package.json +83 -4
  38. package/scripts/audit-tokens.mjs +126 -0
  39. package/scripts/check-contrast.mjs +24 -0
  40. package/scripts/check-docs.mjs +53 -0
  41. package/scripts/check-icons.mjs +473 -0
  42. package/scripts/check-usage.mjs +297 -0
  43. package/scripts/codemod-icons.mjs +396 -0
  44. package/scripts/generate-icons.mjs +199 -0
  45. package/scripts/generate-preview.mjs +374 -0
  46. package/scripts/generate-tailwind-colors.mjs +109 -0
  47. package/scripts/lib/color.mjs +184 -0
  48. package/scripts/lib/contract.d.mts +58 -0
  49. package/scripts/lib/contract.mjs +348 -0
  50. package/scripts/lib/derive.mjs +324 -0
  51. package/scripts/lib/icon-registry.mjs +92 -0
  52. package/scripts/lib/parse-themes.mjs +19 -0
  53. package/scripts/lib/tokens.mjs +188 -0
  54. package/scripts/normalize-tokens.mjs +161 -0
  55. package/src/advanced.css +218 -0
  56. package/src/icons/Icon.tsx +151 -0
  57. package/src/icons/emoji-map.json +202 -0
  58. package/src/icons/index.ts +151 -0
  59. package/src/icons/registry.generated.ts +455 -0
  60. package/src/icons/sources.json +150 -0
  61. package/src/icons.css +145 -0
  62. package/src/style.css +1421 -0
  63. package/src/tailwind-colors.css +88 -0
  64. package/src/tailwind-integration.css +49 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Color math for theme token validation.
3
+ *
4
+ * Handles the three value formats that appear in style.css: #rrggbb,
5
+ * rgba(r, g, b, a), and the CSS keywords black/white. Alpha matters — the
6
+ * `-bg` and `-hover` tokens are translucent, so contrast has to be computed
7
+ * against the composited result over a real surface, not the raw value.
8
+ */
9
+
10
+ const NAMED = {
11
+ black: [0, 0, 0, 1],
12
+ white: [255, 255, 255, 1],
13
+ transparent: [0, 0, 0, 0]
14
+ }
15
+
16
+ /**
17
+ * Parse a CSS color into [r, g, b, a]. Throws on anything unrecognized.
18
+ * Already-parsed [r,g,b,a] tuples pass straight through, so the output of
19
+ * flatten() can be fed back in as a backdrop without a round-trip to string.
20
+ */
21
+ export function parseColor(input) {
22
+ if (Array.isArray(input)) {
23
+ const [r, g, b, a = 1] = input
24
+ return [r, g, b, a]
25
+ }
26
+ const value = String(input).trim().toLowerCase()
27
+
28
+ if (NAMED[value]) return [...NAMED[value]]
29
+
30
+ const hex = value.match(/^#([0-9a-f]{3,8})$/)
31
+ if (hex) {
32
+ let h = hex[1]
33
+ if (h.length === 3 || h.length === 4) {
34
+ h = h
35
+ .split('')
36
+ .map(c => c + c)
37
+ .join('')
38
+ }
39
+ if (h.length !== 6 && h.length !== 8) {
40
+ throw new Error(`Unsupported hex color: ${input}`)
41
+ }
42
+ return [
43
+ parseInt(h.slice(0, 2), 16),
44
+ parseInt(h.slice(2, 4), 16),
45
+ parseInt(h.slice(4, 6), 16),
46
+ h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1
47
+ ]
48
+ }
49
+
50
+ const rgb = value.match(/^rgba?\(([^)]+)\)$/)
51
+ if (rgb) {
52
+ const parts = rgb[1].split(/[,\s/]+/).filter(Boolean)
53
+ if (parts.length < 3) throw new Error(`Unsupported rgb color: ${input}`)
54
+ const channel = p => (p.endsWith('%') ? (parseFloat(p) / 100) * 255 : parseFloat(p))
55
+ const alpha =
56
+ parts[3] === undefined
57
+ ? 1
58
+ : parts[3].endsWith('%')
59
+ ? parseFloat(parts[3]) / 100
60
+ : parseFloat(parts[3])
61
+ return [channel(parts[0]), channel(parts[1]), channel(parts[2]), alpha]
62
+ }
63
+
64
+ throw new Error(`Cannot parse color: ${input}`)
65
+ }
66
+
67
+ /** Composite a possibly-translucent foreground over an opaque backdrop. */
68
+ export function flatten(color, backdrop) {
69
+ const [r, g, b, a] = parseColor(color)
70
+ if (a >= 1) return [r, g, b, 1]
71
+ const [br, bg, bb] = parseColor(backdrop)
72
+ return [r * a + br * (1 - a), g * a + bg * (1 - a), b * a + bb * (1 - a), 1]
73
+ }
74
+
75
+ /** WCAG 2.2 relative luminance. */
76
+ export function luminance(rgb) {
77
+ const [r, g, b] = rgb.slice(0, 3).map(v => {
78
+ const c = v / 255
79
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
80
+ })
81
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
82
+ }
83
+
84
+ /**
85
+ * WCAG 2.2 contrast ratio between two colors, each flattened over `backdrop`
86
+ * first so translucent tokens are measured as they actually render.
87
+ */
88
+ export function contrast(fg, bg, backdrop = '#ffffff') {
89
+ const f = luminance(flatten(fg, flatten(bg, backdrop)))
90
+ const b = luminance(flatten(bg, backdrop))
91
+ const [hi, lo] = f > b ? [f, b] : [b, f]
92
+ return (hi + 0.05) / (lo + 0.05)
93
+ }
94
+
95
+ /** Round a ratio the way the docs report it. */
96
+ export const ratio = n => Math.round(n * 100) / 100
97
+
98
+ export function toHex([r, g, b]) {
99
+ const c = v =>
100
+ Math.round(Math.min(255, Math.max(0, v)))
101
+ .toString(16)
102
+ .padStart(2, '0')
103
+ return `#${c(r)}${c(g)}${c(b)}`
104
+ }
105
+
106
+ /* ---------- OKLCH, for perceptually-even shade derivation ---------- */
107
+ /* THEME_SYSTEM_RULES.md §8: derive variants in OKLCH, not HSL — an HSL
108
+ lightness shift that is fine for blue is catastrophic for yellow. */
109
+
110
+ const srgbToLinear = c => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
111
+ const linearToSrgb = c => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)
112
+
113
+ export function rgbToOklch([r, g, b]) {
114
+ const lr = srgbToLinear(r / 255)
115
+ const lg = srgbToLinear(g / 255)
116
+ const lb = srgbToLinear(b / 255)
117
+
118
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb)
119
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb)
120
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb)
121
+
122
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
123
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
124
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
125
+
126
+ const C = Math.sqrt(a * a + bb * bb)
127
+ let H = (Math.atan2(bb, a) * 180) / Math.PI
128
+ if (H < 0) H += 360
129
+ return { L, C, H }
130
+ }
131
+
132
+ export function oklchToRgb({ L, C, H }) {
133
+ const h = (H * Math.PI) / 180
134
+ const a = C * Math.cos(h)
135
+ const b = C * Math.sin(h)
136
+
137
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
138
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
139
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
140
+
141
+ const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
142
+ const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
143
+ const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
144
+
145
+ return [linearToSrgb(lr) * 255, linearToSrgb(lg) * 255, linearToSrgb(lb) * 255].map(v =>
146
+ Math.min(255, Math.max(0, v))
147
+ )
148
+ }
149
+
150
+ /**
151
+ * OKLCH → sRGB with the chroma pulled in until the result is inside the sRGB
152
+ * gamut. Lightness and hue are kept exactly; only chroma gives way. This is
153
+ * what lets an editor slider move L or H freely without producing a color the
154
+ * screen cannot show.
155
+ */
156
+ export function oklchToRgbInGamut({ L, C, H }) {
157
+ const target = Math.min(1, Math.max(0, L))
158
+ // Binary-search chroma down until the result round-trips inside sRGB.
159
+ let lo = 0
160
+ let hi = Math.max(0, C)
161
+ let best = oklchToRgb({ L: target, C: 0, H })
162
+ for (let i = 0; i < 24; i += 1) {
163
+ const mid = (lo + hi) / 2
164
+ const candidate = oklchToRgb({ L: target, C: mid, H })
165
+ const inGamut = candidate.every(v => v >= -0.5 && v <= 255.5)
166
+ if (inGamut) {
167
+ best = candidate
168
+ lo = mid
169
+ } else {
170
+ hi = mid
171
+ }
172
+ }
173
+ return best
174
+ }
175
+
176
+ /**
177
+ * Shift a color's OKLCH lightness by `delta` (positive = lighter), clamping
178
+ * chroma so the result stays in sRGB gamut.
179
+ */
180
+ export function shiftLightness(color, delta) {
181
+ const [r, g, b, alpha] = parseColor(color)
182
+ const { L, C, H } = rgbToOklch([r, g, b])
183
+ return { rgb: oklchToRgbInGamut({ L: L + delta, C, H }), alpha }
184
+ }
@@ -0,0 +1,58 @@
1
+ /** Types for contract.mjs — see that file for the behaviour. */
2
+
3
+ export type RGBA = [number, number, number, number]
4
+ export type ThemeTokens = Record<string, string>
5
+ export type ThemeTokenMap = Record<string, ThemeTokens>
6
+ /** `{ [theme]: { [--color-token]: value } }` */
7
+ export type TokenEdits = Record<string, Record<string, string>>
8
+
9
+ export interface TokenChange {
10
+ theme: string
11
+ token: string
12
+ from: string
13
+ to: string
14
+ }
15
+
16
+ export interface ComfortMetric {
17
+ id: string
18
+ label: string
19
+ value: number
20
+ ok: boolean
21
+ target: string
22
+ display: string
23
+ }
24
+
25
+ export const THEMES: readonly string[]
26
+ export const FAMILIES: readonly string[]
27
+ export const STRUCTURAL: readonly string[]
28
+ export const AA_TEXT: number
29
+ export const WCAG_PAIR_COUNT: number
30
+
31
+ export function isDarkTheme(theme: string): boolean
32
+ export function parseThemes(css: string): ThemeTokenMap
33
+ export function checkTheme(theme: string, tokens: ThemeTokens): string[]
34
+ export function checkThemes(themes: ThemeTokenMap): string[]
35
+ export function comfortMetrics(theme: string, tokens: ThemeTokens): ComfortMetric[]
36
+ export function formatColor(rgba: number[]): string
37
+ export function normalizeColor(input: unknown): string
38
+ export function applyTokenEdits(
39
+ css: string,
40
+ edits: TokenEdits
41
+ ): { css: string; changes: TokenChange[] }
42
+
43
+ export function parseColor(input: string | number[]): RGBA
44
+ export function flatten(color: string | number[], backdrop: string | number[]): RGBA
45
+ export function contrast(
46
+ fg: string | number[],
47
+ bg: string | number[],
48
+ backdrop?: string | number[]
49
+ ): number
50
+ export function luminance(rgb: number[]): number
51
+ export function ratio(n: number): number
52
+ export function toHex(rgb: number[]): string
53
+ export function rgbToOklch(rgb: number[]): { L: number; C: number; H: number }
54
+ export function oklchToRgbInGamut(lch: {
55
+ L: number
56
+ C: number
57
+ H: number
58
+ }): [number, number, number]
@@ -0,0 +1,348 @@
1
+ /**
2
+ * The theme contract, as pure functions over style.css TEXT.
3
+ *
4
+ * One implementation, three callers:
5
+ * - check-contrast.mjs (the CLI gate lint:css runs)
6
+ * - the hosted theme editor (@wolffm/task/editor), live as sliders move
7
+ * - the task worker's save route, which re-checks before it commits to main
8
+ *
9
+ * The editor's verdict is a preview; the worker's is the one that counts. They
10
+ * agree because they are the same code, not because they were kept in sync.
11
+ *
12
+ * No node imports — this module is bundled into a browser page and a
13
+ * Cloudflare Worker. Exposed as `@wolffm/themes/contract`.
14
+ */
15
+
16
+ import { parseColor, flatten, contrast, luminance, ratio, toHex, rgbToOklch } from './color.mjs'
17
+ import { AA_TEXT, SURFACE_STEP, BORDER_STEP, BORDER_LIGHT_STEP } from './derive.mjs'
18
+ import {
19
+ THEMES,
20
+ FAMILIES,
21
+ STRUCTURAL,
22
+ isDarkTheme,
23
+ parseThemes,
24
+ readBlocks,
25
+ blankComments,
26
+ themesForSelector
27
+ } from './tokens.mjs'
28
+
29
+ export { THEMES, FAMILIES, STRUCTURAL, isDarkTheme, parseThemes, AA_TEXT }
30
+ export {
31
+ parseColor,
32
+ flatten,
33
+ contrast,
34
+ luminance,
35
+ ratio,
36
+ toHex,
37
+ rgbToOklch,
38
+ oklchToRgbInGamut
39
+ } from './color.mjs'
40
+
41
+ const VARIANTS = ['', '-dark', '-bg', '-hover']
42
+
43
+ /** Every app surface a component (and therefore a translucent tint) can sit on. */
44
+ const SURFACES = ['bg', 'bg-card', 'bg-alt']
45
+
46
+ /**
47
+ * Every structural and WCAG problem in one theme's tokens, as human-readable
48
+ * strings. Empty = the theme passes the gate.
49
+ */
50
+ export function checkTheme(theme, tk) {
51
+ const problems = []
52
+ const surface =
53
+ tk['--color-bg-card'] || tk['--color-bg'] || (isDarkTheme(theme) ? '#000' : '#fff')
54
+
55
+ // 1. Structural symmetry — every family carries an identical token set.
56
+ for (const f of FAMILIES) {
57
+ for (const v of VARIANTS) {
58
+ if (!tk[`--color-${f}${v}`]) problems.push(`${theme}: missing --color-${f}${v}`)
59
+ }
60
+ for (const on of [`--color-on-${f}`, `--color-on-${f}-bg`]) {
61
+ if (!tk[on]) problems.push(`${theme}: missing ${on}`)
62
+ }
63
+ }
64
+ for (const s of STRUCTURAL) {
65
+ if (!tk[`--color-${s}`]) problems.push(`${theme}: missing --color-${s}`)
66
+ }
67
+
68
+ // 2. Contrast contracts. The `-bg` tints are translucent, so the pair is
69
+ // checked against every surface the badge could be placed on, not just
70
+ // the card — the same badge on `bg-alt` is a different composite.
71
+ for (const f of FAMILIES) {
72
+ const opaquePairs = [
73
+ [`on-${f}`, tk[`--color-on-${f}`], f, tk[`--color-${f}`], 'filled button'],
74
+ [
75
+ `on-${f}`,
76
+ tk[`--color-on-${f}`],
77
+ `${f}-dark`,
78
+ tk[`--color-${f}-dark`],
79
+ 'button gradient bottom'
80
+ ]
81
+ ]
82
+ for (const [fgName, fg, bgName, bg, what] of opaquePairs) {
83
+ if (!fg || !bg) continue
84
+ const r = contrast(fg, bg, surface)
85
+ if (r < AA_TEXT) {
86
+ problems.push(
87
+ `${theme}: ${what} — ${fgName} on ${bgName} is ${ratio(r)}:1 (needs ${AA_TEXT}:1)`
88
+ )
89
+ }
90
+ }
91
+
92
+ const fg = tk[`--color-on-${f}-bg`]
93
+ const tint = tk[`--color-${f}-bg`]
94
+ if (fg && tint) {
95
+ for (const s of SURFACES) {
96
+ const r = contrast(fg, tint, tk[`--color-${s}`])
97
+ if (r < AA_TEXT) {
98
+ problems.push(
99
+ `${theme}: tint badge on ${s} — on-${f}-bg on ${f}-bg is ${ratio(r)}:1 (needs ${AA_TEXT}:1)`
100
+ )
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // 3. Text must be readable on every surface it can land on. `-tertiary` and
107
+ // `-muted` are deliberately excluded: they are decorative-only and do not
108
+ // clear AA in every theme (documented in THEME_USAGE_GUIDE.md).
109
+ for (const token of ['text', 'text-secondary']) {
110
+ for (const s of SURFACES) {
111
+ const r = contrast(tk[`--color-${token}`], tk[`--color-${s}`], surface)
112
+ if (r < AA_TEXT) {
113
+ problems.push(`${theme}: ${token} on ${s} is ${ratio(r)}:1 (needs ${AA_TEXT}:1)`)
114
+ }
115
+ }
116
+ }
117
+
118
+ // 4. Surface separation. WCAG says nothing about page vs card, which is how
119
+ // the light themes drifted to white-on-white with nobody noticing: every
120
+ // pair above passed while the cards were invisible. Floors and their
121
+ // measurement are documented beside SURFACE_STEP in derive.mjs.
122
+ const L = c => rgbToOklch(flatten(c, surface)).L
123
+ const mode = isDarkTheme(theme) ? 'dark' : 'light'
124
+ const steps = [
125
+ ['bg-card', 'bg', SURFACE_STEP],
126
+ ['border', 'bg-card', BORDER_STEP[mode]],
127
+ ['border-light', 'bg-card', BORDER_LIGHT_STEP[mode]]
128
+ ]
129
+ for (const [a, b, floor] of steps) {
130
+ const fa = tk[`--color-${a}`]
131
+ const fb = tk[`--color-${b}`]
132
+ if (!fa || !fb) continue
133
+ const d = Math.abs(L(fa) - L(fb))
134
+ if (d < floor) {
135
+ problems.push(`${theme}: ${a} vs ${b} is ΔL ${d.toFixed(3)} (needs ${floor} OKLCH)`)
136
+ }
137
+ }
138
+ return problems
139
+ }
140
+
141
+ /** checkTheme over every shipped theme. */
142
+ export function checkThemes(themes) {
143
+ return THEMES.flatMap(theme => checkTheme(theme, themes[theme] ?? {}))
144
+ }
145
+
146
+ /** Number of WCAG pairs checkTheme evaluates across all themes, for reporting. */
147
+ export const WCAG_PAIR_COUNT =
148
+ THEMES.length * (FAMILIES.length * (2 + SURFACES.length) + 2 * SURFACES.length)
149
+
150
+ /**
151
+ * Where each semantic family's hue is EXPECTED to sit, in OKLCH degrees. A
152
+ * `success` that renders orange is not a contrast failure, but a reader who has
153
+ * learned "green = done" will misread it — which is a comfort problem the AA
154
+ * gate cannot see. Ranges are wide on purpose: they catch a family drifting to
155
+ * another meaning, not a theme choosing its own shade.
156
+ */
157
+ const SEMANTIC_HUES = {
158
+ success: [100, 180],
159
+ warning: [45, 110],
160
+ danger: [0, 50, 330, 360]
161
+ }
162
+
163
+ function hueInRange(h, range) {
164
+ for (let i = 0; i < range.length; i += 2) {
165
+ if (h >= range[i] && h <= range[i + 1]) return true
166
+ }
167
+ return false
168
+ }
169
+
170
+ /**
171
+ * Comfort metrics: the things that make a theme tiring to look at while every
172
+ * AA pair still passes. ADVISORY — they guide the editor's meters and never
173
+ * block a save; the contract above is the only gate.
174
+ *
175
+ * Measured 2026-10-23 when light themes were reported "painful": card-vs-page
176
+ * separation was 1.03–1.07:1 in 8 of 9 light themes (cards invisible), borders
177
+ * 1.25–1.55:1, and body text up to 17:1 on near-white.
178
+ */
179
+ export function comfortMetrics(theme, tk) {
180
+ const dark = isDarkTheme(theme)
181
+ const bg = tk['--color-bg']
182
+ const card = tk['--color-bg-card']
183
+ const metrics = []
184
+ const push = (id, label, value, ok, target, display) =>
185
+ metrics.push({ id, label, value, ok, target, display: display ?? `${ratio(value)}:1` })
186
+
187
+ try {
188
+ const sep = contrast(card, bg, bg)
189
+ push('surface-separation', 'Card vs page', sep, sep >= 1.1, '≥ 1.10:1')
190
+
191
+ const border = contrast(tk['--color-border'], card, bg)
192
+ push('border-visibility', 'Border vs card', border, border >= 1.6, '≥ 1.60:1')
193
+
194
+ const text = contrast(tk['--color-text'], bg, bg)
195
+ push('text-harshness', 'Text vs page', text, text >= 7 && text <= 15, '7–15:1')
196
+
197
+ const lum = luminance(flatten(bg, dark ? '#000' : '#fff'))
198
+ if (dark) {
199
+ push('page-depth', 'Page darkness', lum, lum <= 0.02, '≤ 0.020', lum.toFixed(3))
200
+ } else {
201
+ push('page-glare', 'Page glare', lum, lum <= 0.9, '≤ 0.900', lum.toFixed(3))
202
+ }
203
+
204
+ for (const [family, range] of Object.entries(SEMANTIC_HUES)) {
205
+ const [r, g, b] = parseColor(tk[`--color-${family}`])
206
+ const { H, C } = rgbToOklch([r, g, b])
207
+ // A near-grey has no meaningful hue; don't flag what nobody can see.
208
+ const ok = C < 0.03 || hueInRange(H, range)
209
+ push(
210
+ `${family}-hue`,
211
+ `${family} reads as ${family}`,
212
+ H,
213
+ ok,
214
+ `hue ${range.slice(0, 2).join('–')}°`,
215
+ `${Math.round(H)}°`
216
+ )
217
+ }
218
+ } catch (e) {
219
+ metrics.push({
220
+ id: 'unparseable',
221
+ label: 'Unparseable token',
222
+ value: 0,
223
+ ok: false,
224
+ target: '',
225
+ display: e instanceof Error ? e.message : String(e)
226
+ })
227
+ }
228
+ return metrics
229
+ }
230
+
231
+ /**
232
+ * Serialize [r,g,b,a] the way style.css writes colors: lowercase #rrggbb when
233
+ * opaque, `rgba(r, g, b, a)` when translucent.
234
+ */
235
+ export function formatColor([r, g, b, a = 1]) {
236
+ if (a >= 1) return toHex([r, g, b])
237
+ const c = v => Math.round(Math.min(255, Math.max(0, v)))
238
+ const alpha = Math.round(Math.min(1, Math.max(0, a)) * 1000) / 1000
239
+ return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${alpha})`
240
+ }
241
+
242
+ const NUM = String.raw`\d+(?:\.\d+)?%?|\.\d+%?`
243
+ /** #hex, white/black, or rgb()/rgba() of plain numbers — nothing parseFloat could half-read. */
244
+ const STRICT_COLOR = new RegExp(
245
+ String.raw`^(?:white|black|#[0-9a-f]{3,8}|rgba?\(\s*(?:${NUM})(?:\s*[,\s/]\s*(?:${NUM})){2,3}\s*\))$`,
246
+ 'i'
247
+ )
248
+
249
+ /**
250
+ * Parse an untrusted color and re-serialize it. Throws unless every channel
251
+ * is a finite number in range — parseColor alone does not guarantee that
252
+ * (`rgba(1;},2,3)` parses to NaN rather than throwing).
253
+ *
254
+ * This is what makes a save safe: the text spliced into style.css is always
255
+ * produced HERE, never copied from the request.
256
+ */
257
+ export function normalizeColor(input) {
258
+ if (typeof input !== 'string' || input.length > 64 || !STRICT_COLOR.test(input.trim())) {
259
+ throw new Error(`Not a color: ${String(input).slice(0, 64)}`)
260
+ }
261
+ const rgba = parseColor(input)
262
+ const ok =
263
+ rgba.slice(0, 3).every(v => Number.isFinite(v) && v >= 0 && v <= 255) &&
264
+ Number.isFinite(rgba[3]) &&
265
+ rgba[3] >= 0 &&
266
+ rgba[3] <= 1
267
+ if (!ok) throw new Error(`Color out of range: ${input}`)
268
+ return formatColor(rgba)
269
+ }
270
+
271
+ /**
272
+ * Locate `name`'s value inside a block body. Returns offsets relative to the
273
+ * body, or null. Scans the comment-blanked body so a commented-out
274
+ * declaration never matches, while offsets stay valid for the original text.
275
+ */
276
+ function findValue(body, name) {
277
+ const scan = blankComments(body)
278
+ const re = new RegExp(`(^|[\\s;{])${name.replace(/[-]/g, '\\-')}\\s*:`, 'g')
279
+ let match
280
+ let last = null
281
+ while ((match = re.exec(scan)) !== null) {
282
+ let i = re.lastIndex
283
+ let depth = 0
284
+ while (i < scan.length) {
285
+ const c = scan[i]
286
+ if (c === '(') depth += 1
287
+ else if (c === ')') depth -= 1
288
+ else if (c === ';' && depth === 0) break
289
+ i += 1
290
+ }
291
+ let start = re.lastIndex
292
+ let end = i
293
+ while (start < end && /\s/.test(scan[start])) start += 1
294
+ while (end > start && /\s/.test(scan[end - 1])) end -= 1
295
+ // Last declaration wins, mirroring the cascade.
296
+ last = { start, end }
297
+ }
298
+ return last
299
+ }
300
+
301
+ /**
302
+ * Apply `{ [theme]: { [--color-token]: value } }` to style.css text.
303
+ *
304
+ * Returns `{ css, changes }`, where `changes` lists only the tokens whose value
305
+ * actually moved. Throws on an unknown theme or token, a theme whose block is
306
+ * shared with another theme (an edit would leak), or an invalid color.
307
+ * Every byte outside the edited values — comments, CRLF line endings,
308
+ * ordering — is preserved.
309
+ */
310
+ export function applyTokenEdits(css, edits) {
311
+ const blocks = readBlocks(css)
312
+ const splices = []
313
+ const changes = []
314
+
315
+ for (const [theme, tokens] of Object.entries(edits)) {
316
+ if (!THEMES.includes(theme)) throw new Error(`Unknown theme: ${theme}`)
317
+ for (const [name, raw] of Object.entries(tokens)) {
318
+ if (!/^--color-[a-z0-9-]+$/.test(name)) throw new Error(`Not a color token: ${name}`)
319
+ const value = normalizeColor(raw)
320
+
321
+ let target = null
322
+ for (const block of blocks) {
323
+ const names = themesForSelector(block.selector)
324
+ if (!names.includes(theme)) continue
325
+ const at = findValue(block.body, name)
326
+ if (at) target = { block, at, names }
327
+ }
328
+ if (!target) throw new Error(`${theme} does not declare ${name}`)
329
+ if (target.names.length > 1) {
330
+ throw new Error(
331
+ `${theme}'s ${name} lives in a block shared with ${target.names.join(', ')} — refusing to edit it from one theme`
332
+ )
333
+ }
334
+ const start = target.block.start + target.at.start
335
+ const end = target.block.start + target.at.end
336
+ const from = css.slice(start, end)
337
+ if (from === value) continue
338
+ splices.push({ start, end, value })
339
+ changes.push({ theme, token: name, from, to: value })
340
+ }
341
+ }
342
+
343
+ let out = css
344
+ for (const s of splices.sort((a, b) => b.start - a.start)) {
345
+ out = out.slice(0, s.start) + s.value + out.slice(s.end)
346
+ }
347
+ return { css: out, changes }
348
+ }