@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,324 @@
1
+ /**
2
+ * Derives the canonical token set for a theme from its existing values.
3
+ *
4
+ * Every derived value is produced in OKLCH (THEME_SYSTEM_RULES §8 — an HSL
5
+ * lightness shift that is safe for blue is catastrophic for yellow) and then
6
+ * verified against the WCAG contracts in §1/§2 rather than trusted.
7
+ */
8
+
9
+ import {
10
+ parseColor,
11
+ flatten,
12
+ contrast,
13
+ toHex,
14
+ rgbToOklch,
15
+ oklchToRgb,
16
+ shiftLightness
17
+ } from './color.mjs'
18
+ import { FAMILIES } from './tokens.mjs'
19
+
20
+ /** Minimum WCAG ratio for text (§1, §2). Derived values aim slightly above. */
21
+ export const AA_TEXT = 4.5
22
+ const TARGET = 4.6 // headroom so rounding can't drop a value under the bar
23
+
24
+ /**
25
+ * Median OKLCH ΔL between the existing `<f>` and `<f>-dark` values across all
26
+ * themes is ≈ -0.07 (measured over primary/success/danger, n=54). New `-dark`
27
+ * values use that so they sit in the same visual family as the hand-authored ones.
28
+ */
29
+ const DARK_SHIFT = -0.07
30
+
31
+ /**
32
+ * `-dark` is overwhelmingly consumed as the bottom stop of a filled button's
33
+ * gradient (`linear-gradient(180deg, var(--color-f), var(--color-f-dark))`),
34
+ * not as a separate hover swap. So `on-<f>` sits on the whole ramp and has to
35
+ * clear 4.5:1 at BOTH ends. These bound how far the ramp may travel: far
36
+ * enough to read as a gradient, never far enough to break the text.
37
+ */
38
+ const MIN_DARK_SHIFT = 0.03
39
+ const MAX_DARK_SHIFT = 0.12
40
+
41
+ const fmt = (rgb, alpha) =>
42
+ alpha >= 1
43
+ ? toHex(rgb)
44
+ : `rgba(${rgb
45
+ .slice(0, 3)
46
+ .map(v => Math.round(v))
47
+ .join(', ')}, ${Math.round(alpha * 100) / 100})`
48
+
49
+ /**
50
+ * Text/icon color ON a filled `<f>` surface.
51
+ *
52
+ * §7 warns that the usual luminance-threshold heuristic flips near its 0.179
53
+ * cutoff, so this picks by measured WCAG ratio instead: whichever of black or
54
+ * white scores higher.
55
+ */
56
+ function deriveOn(base) {
57
+ return contrast('black', base) >= contrast('white', base) ? 'black' : 'white'
58
+ }
59
+
60
+ /**
61
+ * Pull a base colour out of the luminance dead zone.
62
+ *
63
+ * §7: "If neither white nor black achieves 4.5:1, the base color is unsuitable
64
+ * for a filled button." Three of the 90 family bases sit just inside that band
65
+ * — they clear 4.5:1 on their own but leave no room for the gradient's bottom
66
+ * stop. Darkening is the safe direction: it monotonically *increases* white's
67
+ * contrast, so the ramp can only improve from there. The nudge preserves hue
68
+ * and chroma and is 0.015–0.045 L, below the just-noticeable threshold.
69
+ */
70
+ function ensureFillable(base) {
71
+ const headroom = c => Math.max(contrast('black', c), contrast('white', c))
72
+ if (headroom(base) >= TARGET + 0.2) return { color: base, nudged: false }
73
+
74
+ for (let s = 0.005; s <= 0.25; s += 0.005) {
75
+ const candidate = toHex(shiftLightness(base, -s).rgb)
76
+ if (contrast('white', candidate) >= 4.8) {
77
+ return { color: candidate, nudged: true, shift: s }
78
+ }
79
+ }
80
+ return { color: base, nudged: false }
81
+ }
82
+
83
+ /**
84
+ * The gradient's bottom stop: as deep as possible without dropping `on` below
85
+ * the AA floor, bounded so the ramp stays visible but never runaway.
86
+ */
87
+ function deriveDark(base, on = deriveOn(base)) {
88
+ let shift = 0
89
+ for (let s = MIN_DARK_SHIFT; s <= MAX_DARK_SHIFT; s += 0.005) {
90
+ if (contrast(on, toHex(shiftLightness(base, -s).rgb)) >= TARGET) shift = s
91
+ else break
92
+ }
93
+ const { rgb, alpha } = shiftLightness(base, -(shift || MIN_DARK_SHIFT))
94
+ return fmt(rgb, alpha)
95
+ }
96
+
97
+ /**
98
+ * Text color ON the faint `<f>-bg` tint (Material's on-container role).
99
+ *
100
+ * Keeps the family's hue and chroma so a badge still reads as that family,
101
+ * but walks OKLCH lightness away from the tint until the pair clears 4.5:1.
102
+ *
103
+ * The tint is usually translucent, so what it composites over changes the
104
+ * result. A badge can sit on any app surface, so this solves for the WORST of
105
+ * them — deriving against `bg-card` alone left 19 pairs under the bar once the
106
+ * same badge was placed on `bg-alt`.
107
+ */
108
+ function deriveOnBg(base, tint, surfaces) {
109
+ const list = Array.isArray(surfaces) ? surfaces : [surfaces]
110
+ const solidTints = list.map(s => flatten(tint, s))
111
+ const holds = color => solidTints.every(t => contrast(color, toHex(t)) >= TARGET)
112
+
113
+ const { L, C, H } = rgbToOklch(parseColor(base))
114
+ // Move away from the tint's lightness: darker text on a light tint, lighter
115
+ // on a dark one. Use the lightest composite to choose the direction.
116
+ const tintL = Math.max(...solidTints.map(t => rgbToOklch(t).L))
117
+ const direction = tintL > 0.5 ? -1 : 1
118
+
119
+ for (let step = 0; step <= 100; step += 1) {
120
+ const target = L + direction * step * 0.01
121
+ if (target < 0 || target > 1) break
122
+ const candidate = toHex(oklchToRgb({ L: target, C, H }))
123
+ if (holds(candidate)) return candidate
124
+ }
125
+
126
+ // Hue-preserving text can't reach the bar (near-black or near-white tint) —
127
+ // fall back to whichever neutral holds across every surface.
128
+ for (const neutral of ['black', 'white']) {
129
+ if (holds(neutral)) return neutral
130
+ }
131
+ const worst = toHex(solidTints[0])
132
+ return contrast('black', worst) >= contrast('white', worst) ? 'black' : 'white'
133
+ }
134
+
135
+ /**
136
+ * Minimum OKLCH ΔL between the surfaces a boundary has to be seen across.
137
+ *
138
+ * Measured, not picked: every dark theme steps page → card by 0.052–0.098 and
139
+ * reads fine, while the light themes had drifted to 0.008–0.025 — white cards
140
+ * on a near-white page, with the 1px border left to carry the whole edge
141
+ * alone. Two of them (strawberry, lavender) even had the card DARKER than the
142
+ * page. Nothing gated it, because these tokens used to pass through untouched.
143
+ */
144
+ export const SURFACE_STEP = 0.05 // page (`bg`) → raised (`bg-card`), both modes
145
+ /**
146
+ * `border` / `border-light` against `bg-card`. The dark floor sits where the
147
+ * dark themes already were (0.104–0.215); only ocean-dark (0.007) and
148
+ * lavender-dark fell under it. The light floor is set above where the light
149
+ * themes were, because at those values the edges were reported unreadable.
150
+ */
151
+ export const BORDER_STEP = { light: 0.13, dark: 0.1 }
152
+ export const BORDER_LIGHT_STEP = { light: 0.075, dark: 0.05 }
153
+
154
+ /** Aim this far past a floor so hex rounding can't land a value back under it. */
155
+ const STEP_HEADROOM = 0.004
156
+
157
+ /**
158
+ * A page pulled down off white has to carry the theme's hue, or it reads as
159
+ * dirty grey. Measured: the moved pages that looked right (ocean, coffee,
160
+ * nature) all sat at C ≥ 0.012; the ones that went dingy (strawberry,
161
+ * lavender, pink, cyberpunk) were at ≤ 0.005. Below this, a hue is noise.
162
+ */
163
+ const PAGE_MIN_CHROMA = 0.012
164
+ const ACHROMATIC = 0.002
165
+
166
+ const L = c => rgbToOklch(parseColor(c)).L
167
+ const withL = (color, target) => {
168
+ const { rgb, alpha } = shiftLightness(color, target - L(color))
169
+ return fmt(rgb, alpha)
170
+ }
171
+
172
+ /**
173
+ * Pull the surface ladder apart until each step clears its floor.
174
+ *
175
+ * Moves as little as possible and only the values that fail, keeping hue and
176
+ * chroma, so a theme that already holds is left alone: every dark theme but
177
+ * two borders, and izakaya-light but for a one-hex-step border nudge. A card
178
+ * is the lighter surface in both modes (§5: higher = lighter), unless an
179
+ * inverted ladder already clears the step. In a light theme the PAGE moves
180
+ * down rather than the card up — the card is already at or near white, and
181
+ * "too bright" was the complaint. `bg-alt` and `bg-hover` ride along with the
182
+ * page so their offsets from it are preserved, and all three are lifted to
183
+ * PAGE_MIN_CHROMA, in their own hue or, where they have none, the primary's.
184
+ *
185
+ * A darker page lowers the contrast of whatever text sits on it, so the body
186
+ * text tokens are then walked away from the surfaces until they clear AA again.
187
+ */
188
+ function separateSurfaces(tokens, notes) {
189
+ const out = { ...tokens }
190
+ const set = (name, value) => {
191
+ if (value === out[name]) return
192
+ notes.push({ token: name, from: out[name], to: value, reason: 'separation' })
193
+ out[name] = value
194
+ }
195
+
196
+ const light = L(out['--color-bg']) > 0.5
197
+ const step = L(out['--color-bg-card']) - L(out['--color-bg'])
198
+ // An inverted ladder that still clears the step is a deliberate choice —
199
+ // izakaya-light's pink paper card on a cream page — and stays as authored.
200
+ if (Math.abs(step) < SURFACE_STEP) {
201
+ if (light) {
202
+ // Inverted (card darker than page): lift the card to the page first, so
203
+ // the step is measured down from a real top.
204
+ if (step < 0) set('--color-bg-card', withL(out['--color-bg-card'], L(out['--color-bg'])))
205
+ const shift = L(out['--color-bg-card']) - SURFACE_STEP - STEP_HEADROOM - L(out['--color-bg'])
206
+ const hueOf = c => rgbToOklch(parseColor(c))
207
+ const pageHue = [out['--color-bg'], out['--color-primary']]
208
+ .map(hueOf)
209
+ .find(o => o.C > ACHROMATIC).H
210
+ for (const n of ['--color-bg', '--color-bg-alt', '--color-bg-hover']) {
211
+ const own = hueOf(out[n])
212
+ const H = own.C > ACHROMATIC ? own.H : pageHue
213
+ const C = Math.max(own.C, PAGE_MIN_CHROMA)
214
+ set(n, toHex(oklchToRgb({ L: own.L + shift, C, H })))
215
+ }
216
+ } else {
217
+ const target = L(out['--color-bg']) + SURFACE_STEP + STEP_HEADROOM
218
+ set('--color-bg-card', withL(out['--color-bg-card'], target))
219
+ }
220
+ }
221
+
222
+ // Borders step away from the card toward the text: darker in a light
223
+ // theme, lighter in a dark one.
224
+ const card = out['--color-bg-card']
225
+ const away = light ? -1 : 1
226
+ const mode = light ? 'light' : 'dark'
227
+ for (const [name, floor] of [
228
+ ['--color-border', BORDER_STEP[mode]],
229
+ ['--color-border-light', BORDER_LIGHT_STEP[mode]]
230
+ ]) {
231
+ if (Math.abs(rgbToOklch(flatten(out[name], card)).L - L(card)) < floor) {
232
+ set(name, withL(out[name], L(card) + away * (floor + STEP_HEADROOM)))
233
+ }
234
+ }
235
+
236
+ const surfaces = ['--color-bg', '--color-bg-card', '--color-bg-alt'].map(n => out[n])
237
+ for (const name of ['--color-text', '--color-text-secondary']) {
238
+ for (let s = 0; s <= 0.3; s += 0.005) {
239
+ const candidate = s ? withL(out[name], L(out[name]) + away * s) : out[name]
240
+ if (surfaces.every(bg => contrast(candidate, bg, card) >= TARGET)) {
241
+ set(name, candidate)
242
+ break
243
+ }
244
+ }
245
+ }
246
+ return out
247
+ }
248
+
249
+ /**
250
+ * Build the full canonical color token map for one theme from its current one.
251
+ *
252
+ * Renames carry values over verbatim (no visual change); only genuinely new
253
+ * tokens are computed.
254
+ */
255
+ export function deriveTheme(input, notes = []) {
256
+ const out = {}
257
+ // Separate first: every surface-relative value below is solved against these.
258
+ const tokens = separateSurfaces(input, notes)
259
+ // Every surface a translucent tint might composite over.
260
+ const surfaces = ['--color-bg-card', '--color-bg', '--color-bg-alt']
261
+ .map(n => tokens[n])
262
+ .filter(Boolean)
263
+ if (!surfaces.length) surfaces.push('#ffffff')
264
+
265
+ // Renames — the value already exists under a legacy name.
266
+ const RENAMED_BG = {
267
+ danger: '--color-danger-light', // danger had no -bg; its tint was -light
268
+ neutral: '--color-muted-bg' // orphan name that broke the <family>-bg pattern
269
+ }
270
+
271
+ for (const f of FAMILIES) {
272
+ const { color: base, nudged, shift } = ensureFillable(tokens[`--color-${f}`])
273
+ if (nudged) {
274
+ notes.push({ token: `--color-${f}`, from: tokens[`--color-${f}`], to: base, shift })
275
+ }
276
+ const on = deriveOn(base)
277
+
278
+ // Keep a hand-authored `-dark` when it already holds the contract — those
279
+ // are deliberate design choices, and the goal here is structural symmetry,
280
+ // not homogenising every value. Re-derive only the ones that break it.
281
+ const authored = tokens[`--color-${f}-dark`]
282
+ const dark = authored && contrast(on, authored) >= AA_TEXT ? authored : deriveDark(base, on)
283
+ if (authored && dark !== authored) {
284
+ notes.push({ token: `--color-${f}-dark`, from: authored, to: dark, reason: 'contrast' })
285
+ }
286
+
287
+ const tint = tokens[`--color-${f}-bg`] || tokens[RENAMED_BG[f]]
288
+ out[`--color-${f}`] = base
289
+ out[`--color-${f}-dark`] = dark
290
+ out[`--color-${f}-bg`] = tint
291
+ out[`--color-${f}-hover`] = tokens[`--color-${f}-hover`]
292
+ out[`--color-on-${f}`] = on
293
+ out[`--color-on-${f}-bg`] = deriveOnBg(base, tint, surfaces)
294
+ }
295
+
296
+ // Structural tokens pass through untouched.
297
+ for (const name of [
298
+ '--color-text',
299
+ '--color-text-secondary',
300
+ '--color-text-tertiary',
301
+ '--color-text-muted',
302
+ '--color-border',
303
+ '--color-border-light',
304
+ '--color-bg',
305
+ '--color-bg-card',
306
+ '--color-bg-alt',
307
+ '--color-bg-hover',
308
+ '--color-bg-overlay'
309
+ ]) {
310
+ out[name] = tokens[name]
311
+ }
312
+
313
+ return out
314
+ }
315
+
316
+ /** Tokens removed by the consolidation, with what replaces them. */
317
+ export const REMOVED_TOKENS = {
318
+ '--color-primary-light': '--color-primary-bg',
319
+ '--color-danger-light': '--color-danger-bg',
320
+ '--color-danger-darker': '--color-danger-dark',
321
+ '--color-neutral-light': '--color-neutral-bg',
322
+ '--color-neutral-lighter': '--color-neutral-bg',
323
+ '--color-muted-bg': '--color-neutral-bg'
324
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Locate the icon registry, from either of the two trees it can live in.
3
+ *
4
+ * The scripts that need it run in two very different places:
5
+ *
6
+ * - In this workspace, where `themes/src` is authored but `themes/dist` only
7
+ * exists after a build. Parsing the .ts source is what lets `check:icons`
8
+ * run on a clean checkout with no build step.
9
+ * - From a consumer's node_modules, where the shape of the tarball decides
10
+ * what is there at all.
11
+ *
12
+ * Reading only `src/icons/registry.generated.ts` made the second case depend on
13
+ * `src/icons` being listed in package.json `files`. It was not, up to and
14
+ * including 5.6.2, so every consumer that ran the documented command got
15
+ * `registry.generated.ts is missing — run pnpm run generate:icons` — advice that
16
+ * cannot work in a consumer repo, for a file npm had simply not shipped. 5.7.1
17
+ * ships `src/icons` and fixes that, but the gate should not be one `files` edit
18
+ * away from breaking again for every downstream repo at once. So: prefer src,
19
+ * fall back to the built registry, and only fail when neither is present.
20
+ */
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { resolve, dirname } from 'node:path'
23
+ import { fileURLToPath, pathToFileURL } from 'node:url'
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url))
26
+ const SRC_REGISTRY = resolve(here, '../../src/icons/registry.generated.ts')
27
+ const SRC_EMOJI_MAP = resolve(here, '../../src/icons/emoji-map.json')
28
+ const DIST_REGISTRY = resolve(here, '../../dist/icons/registry.generated.js')
29
+
30
+ /**
31
+ * Icon names out of the generated .ts, without needing it compiled.
32
+ *
33
+ * Tolerates any indent — the emitted source is 2-space and the built .js is
34
+ * 4-space, and a check that silently parses zero icons out of the wrong one is
35
+ * worse than no check. `export type IconName` terminates the object in the .ts
36
+ * and is absent from the .js, so its absence means "read to the end".
37
+ */
38
+ function parseRegistrySource(src) {
39
+ const start = src.indexOf('export const ICON_MARKUP')
40
+ if (start === -1) return null
41
+ const end = src.indexOf('export type IconName')
42
+ const body = src.slice(start, end === -1 ? undefined : end)
43
+ const names = [...body.matchAll(/^\s+'?([a-z0-9-]+)'?:/gm)].map(m => m[1])
44
+ return names.length ? new Set(names) : null
45
+ }
46
+
47
+ /**
48
+ * @returns {Promise<{iconNames: Set<string>, emojiMap: Record<string,string>|null, ambiguous: Record<string,string>|null, source: string}>}
49
+ * `emojiMap` is null only when neither tree carries one; `$`-prefixed comment
50
+ * keys are passed through untouched for the caller to skip. `ambiguous` maps an
51
+ * emoji to the reason its single mapping needs a human — see `$ambiguous` in
52
+ * emoji-map.json.
53
+ */
54
+ export async function loadIconRegistry() {
55
+ if (existsSync(SRC_REGISTRY)) {
56
+ const iconNames = parseRegistrySource(readFileSync(SRC_REGISTRY, 'utf8'))
57
+ if (iconNames) {
58
+ const emojiMap = existsSync(SRC_EMOJI_MAP)
59
+ ? JSON.parse(readFileSync(SRC_EMOJI_MAP, 'utf8'))
60
+ : null
61
+ return {
62
+ iconNames,
63
+ emojiMap,
64
+ ambiguous: emojiMap?.$ambiguous ?? null,
65
+ source: 'src/icons/registry.generated.ts',
66
+ }
67
+ }
68
+ }
69
+
70
+ if (existsSync(DIST_REGISTRY)) {
71
+ const mod = await import(pathToFileURL(DIST_REGISTRY).href)
72
+ if (mod.ICON_MARKUP) {
73
+ return {
74
+ iconNames: new Set(Object.keys(mod.ICON_MARKUP)),
75
+ emojiMap: mod.EMOJI_TO_ICON ?? null,
76
+ // Only the generated registry carries these in a consumer install —
77
+ // there is no emoji-map.json outside the source tree to fall back on.
78
+ ambiguous: mod.AMBIGUOUS_EMOJI ?? null,
79
+ source: 'dist/icons/registry.generated.js',
80
+ }
81
+ }
82
+ }
83
+
84
+ return { iconNames: new Set(), emojiMap: null, ambiguous: null, source: null }
85
+ }
86
+
87
+ /** The message every caller should print when {@link loadIconRegistry} comes back empty. */
88
+ export const MISSING_REGISTRY_MESSAGE =
89
+ 'icon registry not found — looked for themes/src/icons/registry.generated.ts and ' +
90
+ 'themes/dist/icons/registry.generated.js.\n' +
91
+ 'In this workspace, run `pnpm run generate:icons`. From a consumer repo, this means the ' +
92
+ 'installed @wolffm/themes shipped neither tree — upgrade to >= 5.7.1.'
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Node wrapper over tokens.mjs: the same parser, defaulting to the package's
3
+ * own style.css on disk. Scripts import from here; anything that must also run
4
+ * in a browser or a worker imports tokens.mjs / contract.mjs instead.
5
+ */
6
+
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { dirname, resolve } from 'node:path'
10
+ import { parseThemes as parseThemeCss } from './tokens.mjs'
11
+
12
+ export * from './tokens.mjs'
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ export const STYLE_PATH = resolve(here, '../../src/style.css')
16
+
17
+ export function parseThemes(css = readFileSync(STYLE_PATH, 'utf8')) {
18
+ return parseThemeCss(css)
19
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Parses themes/src/style.css text into a per-theme token map.
3
+ *
4
+ * Pure — no node imports — so the hosted theme editor and the task worker can
5
+ * run the same parser the CLI gates do. parse-themes.mjs is the node wrapper
6
+ * that reads the file from disk.
7
+ *
8
+ * The file is a flat sequence of `[data-theme='x'] { ... }` blocks (plus the
9
+ * `:root, [data-theme='light']` opener). A theme can appear in more than one
10
+ * block — the advanced-gradient contract is declared separately — so tokens
11
+ * are merged in source order, last write winning, which mirrors the cascade.
12
+ */
13
+
14
+ /** Every theme shipped by the package, in source order. */
15
+ export const THEMES = [
16
+ 'light',
17
+ 'dark',
18
+ 'strawberry-light',
19
+ 'strawberry-dark',
20
+ 'ocean-light',
21
+ 'ocean-dark',
22
+ 'cyberpunk-light',
23
+ 'cyberpunk-dark',
24
+ 'coffee-light',
25
+ 'coffee-dark',
26
+ 'lavender-light',
27
+ 'lavender-dark',
28
+ 'pink-light',
29
+ 'pink-dark',
30
+ 'nature-light',
31
+ 'nature-dark',
32
+ 'izakaya-light',
33
+ 'izakaya-dark'
34
+ ]
35
+
36
+ export const isDarkTheme = name => name.endsWith('dark')
37
+
38
+ /** The five semantic color families that must share an identical token set. */
39
+ export const FAMILIES = ['primary', 'success', 'warning', 'danger', 'neutral']
40
+
41
+ /** Structural (non-family) color tokens. */
42
+ export const STRUCTURAL = [
43
+ 'text',
44
+ 'text-secondary',
45
+ 'text-tertiary',
46
+ 'text-muted',
47
+ 'border',
48
+ 'border-light',
49
+ 'bg',
50
+ 'bg-card',
51
+ 'bg-alt',
52
+ 'bg-hover',
53
+ 'bg-overlay'
54
+ ]
55
+
56
+ /**
57
+ * Replace every comment with same-length spaces. Blanking rather than
58
+ * deleting keeps all offsets valid, so callers can splice the original file
59
+ * using the ranges this module reports.
60
+ */
61
+ export function blankComments(css) {
62
+ return css.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n\r]/g, ' '))
63
+ }
64
+
65
+ /**
66
+ * Split the stylesheet into `{ selector, body, start, end }` records.
67
+ * Depth counting keeps nested at-rules (none today, but @media is likely
68
+ * later) from terminating a block early.
69
+ *
70
+ * Offsets index into the ORIGINAL `css` string, not the blanked copy.
71
+ *
72
+ * A selector is the text between a `}` (or the start of the file) and the
73
+ * next `{`, and may not contain `@` — so an at-rule's own `{` opens no block,
74
+ * while blocks nested inside it after the first still do.
75
+ *
76
+ * Hand-scanned, in one linear pass. This was the regex
77
+ * `(^|\})\s*([^{}@]+?)\s*\{`, which backtracks polynomially over runs of
78
+ * whitespace — and blankComments turns every comment into exactly that. On the
79
+ * 1,400-line style.css it cost 6–12 s of CPU per parse (2026-10-23), paid by
80
+ * every lint:css gate, and it would have frozen the theme editor on each
81
+ * slider tick and blown the worker's CPU budget on save.
82
+ */
83
+ export function readBlocks(css) {
84
+ const blocks = []
85
+ const scan = blankComments(css)
86
+ // Index of the last `{`, `}` or `@` seen; -1 is the start of the file.
87
+ let delim = -1
88
+ let i = 0
89
+
90
+ while (i < scan.length) {
91
+ const c = scan[i]
92
+ if (c === '}' || c === '@') {
93
+ delim = i
94
+ i += 1
95
+ continue
96
+ }
97
+ if (c !== '{') {
98
+ i += 1
99
+ continue
100
+ }
101
+
102
+ const opensBlock = delim === -1 || scan[delim] === '}'
103
+ const selector = opensBlock ? scan.slice(delim + 1, i).trim() : ''
104
+ if (!selector) {
105
+ delim = i
106
+ i += 1
107
+ continue
108
+ }
109
+
110
+ const bodyStart = i + 1
111
+ let depth = 1
112
+ let j = bodyStart
113
+ while (j < scan.length && depth > 0) {
114
+ if (scan[j] === '{') depth += 1
115
+ else if (scan[j] === '}') depth -= 1
116
+ j += 1
117
+ }
118
+ blocks.push({ selector, body: css.slice(bodyStart, j - 1), start: bodyStart, end: j - 1 })
119
+ // Resume AT the closing brace, so it can begin the next selector.
120
+ i = j - 1
121
+ }
122
+ return blocks
123
+ }
124
+
125
+ /** Which theme names a selector applies to. */
126
+ export function themesForSelector(selector) {
127
+ const names = []
128
+ for (const part of selector.split(',')) {
129
+ const s = part.trim()
130
+ if (s === ':root') names.push('light')
131
+ const m = s.match(/^\[data-theme=['"]([^'"]+)['"]\]$/)
132
+ if (m) names.push(m[1])
133
+ }
134
+ return [...new Set(names)]
135
+ }
136
+
137
+ /** Declarations in a block body, in source order, comments stripped. */
138
+ export function readDeclarations(body) {
139
+ const clean = body.replace(/\/\*[\s\S]*?\*\//g, '')
140
+ const decls = []
141
+ // Custom-property values can contain commas/parens, so scan to the
142
+ // top-level semicolon rather than splitting naively.
143
+ const re = /(--[a-z0-9-]+)\s*:/gi
144
+ let m
145
+ while ((m = re.exec(clean)) !== null) {
146
+ let i = re.lastIndex
147
+ let depth = 0
148
+ while (i < clean.length) {
149
+ const c = clean[i]
150
+ if (c === '(') depth += 1
151
+ else if (c === ')') depth -= 1
152
+ else if (c === ';' && depth === 0) break
153
+ i += 1
154
+ }
155
+ decls.push({ name: m[1], value: clean.slice(re.lastIndex, i).trim() })
156
+ re.lastIndex = i
157
+ }
158
+ return decls
159
+ }
160
+
161
+ /**
162
+ * Build `{ [theme]: { [tokenName]: value } }` for every color token.
163
+ * Only `--color-*` is returned; spacing/shadow/advanced tokens are ignored.
164
+ */
165
+ export function parseThemes(css) {
166
+ const out = Object.fromEntries(THEMES.map(t => [t, {}]))
167
+
168
+ for (const block of readBlocks(css)) {
169
+ const names = themesForSelector(block.selector)
170
+ if (!names.length) continue
171
+ for (const { name, value } of readDeclarations(block.body)) {
172
+ if (!name.startsWith('--color-')) continue
173
+ for (const theme of names) {
174
+ if (out[theme]) out[theme][name] = value
175
+ }
176
+ }
177
+ }
178
+ return out
179
+ }
180
+
181
+ /** All distinct `--color-*` names across all themes, sorted. */
182
+ export function allTokenNames(themes) {
183
+ const set = new Set()
184
+ for (const tokens of Object.values(themes)) {
185
+ for (const name of Object.keys(tokens)) set.add(name)
186
+ }
187
+ return [...set].sort()
188
+ }