@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.
- package/LICENSE +21 -0
- package/LICENSE-lucide +43 -0
- package/README.md +433 -2
- package/THEME_USAGE_GUIDE.md +308 -0
- package/dist/HadokuThemeRoot.d.ts +53 -0
- package/dist/HadokuThemeRoot.js +66 -0
- package/dist/ThemeIcons.d.ts +31 -0
- package/dist/ThemeIcons.js +91 -0
- package/dist/effects.d.ts +25 -0
- package/dist/effects.js +25 -0
- package/dist/icons/Icon.d.ts +36 -0
- package/dist/icons/Icon.js +89 -0
- package/dist/icons/index.d.ts +61 -0
- package/dist/icons/index.js +87 -0
- package/dist/icons/registry.generated.d.ts +158 -0
- package/dist/icons/registry.generated.js +446 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +169 -0
- package/dist/metadata.d.ts +35 -0
- package/dist/metadata.js +108 -0
- package/dist/platform/index.d.ts +94 -0
- package/dist/platform/index.js +149 -0
- package/dist/platform/usePlatform.d.ts +29 -0
- package/dist/platform/usePlatform.js +65 -0
- package/dist/prefsApiBase.d.ts +37 -0
- package/dist/prefsApiBase.js +40 -0
- package/dist/theme-types.d.ts +34 -0
- package/dist/theme-types.js +1 -0
- package/dist/themeContext.d.ts +62 -0
- package/dist/themeContext.js +70 -0
- package/dist/themePrefs.d.ts +29 -0
- package/dist/themePrefs.js +48 -0
- package/dist/useRefElement.d.ts +19 -0
- package/dist/useRefElement.js +52 -0
- package/dist/useTheme.d.ts +46 -0
- package/dist/useTheme.js +216 -0
- package/package.json +83 -4
- package/scripts/audit-tokens.mjs +126 -0
- package/scripts/check-contrast.mjs +24 -0
- package/scripts/check-docs.mjs +53 -0
- package/scripts/check-icons.mjs +473 -0
- package/scripts/check-usage.mjs +297 -0
- package/scripts/codemod-icons.mjs +396 -0
- package/scripts/generate-icons.mjs +199 -0
- package/scripts/generate-preview.mjs +374 -0
- package/scripts/generate-tailwind-colors.mjs +109 -0
- package/scripts/lib/color.mjs +184 -0
- package/scripts/lib/contract.d.mts +58 -0
- package/scripts/lib/contract.mjs +348 -0
- package/scripts/lib/derive.mjs +324 -0
- package/scripts/lib/icon-registry.mjs +92 -0
- package/scripts/lib/parse-themes.mjs +19 -0
- package/scripts/lib/tokens.mjs +188 -0
- package/scripts/normalize-tokens.mjs +161 -0
- package/src/advanced.css +218 -0
- package/src/icons/Icon.tsx +151 -0
- package/src/icons/emoji-map.json +202 -0
- package/src/icons/index.ts +151 -0
- package/src/icons/registry.generated.ts +455 -0
- package/src/icons/sources.json +150 -0
- package/src/icons.css +145 -0
- package/src/style.css +1421 -0
- package/src/tailwind-colors.css +88 -0
- 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
|
+
}
|