@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,199 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Vendor the icon artwork: src/icons/sources.json -> src/icons/registry.generated.ts
4
+ *
5
+ * We copy lucide's path data in rather than depending on `lucide-react`, for three
6
+ * reasons that all matter here:
7
+ *
8
+ * 1. @wolffm/themes has ZERO runtime dependencies and must keep it that way — React
9
+ * is an optional peer, and the Astro/Qwik consumers on the frontpage have no React
10
+ * at all. A registry of plain strings serves every framework; a React icon library
11
+ * serves one.
12
+ * 2. The set has to be ENFORCED. A dependency on lucide lets any consumer import any
13
+ * of its 2007 icons; a generated union of the names in sources.json does not.
14
+ * 3. Strict CSP on the frontpage rules out fetching artwork at runtime, so it has to
15
+ * be in the bundle either way.
16
+ *
17
+ * Only the inner markup is stored. The wrapping <svg> and its attributes are applied by
18
+ * getIconSvg()/<Icon>, so all 78 glyphs are guaranteed to share one set of attributes
19
+ * and one sizing rule instead of drifting per-icon.
20
+ *
21
+ * node scripts/generate-icons.mjs [--check]
22
+ *
23
+ * --check verifies the committed file is current (used by `pnpm validate` and CI) and
24
+ * exits non-zero on drift, so a hand-edit of the generated file cannot survive.
25
+ */
26
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
27
+ import { resolve, dirname } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { createRequire } from 'node:module'
30
+
31
+ const here = dirname(fileURLToPath(import.meta.url))
32
+ const SOURCES = resolve(here, '../src/icons/sources.json')
33
+ const OUT = resolve(here, '../src/icons/registry.generated.ts')
34
+ const check = process.argv.includes('--check')
35
+
36
+ const require = createRequire(import.meta.url)
37
+ let lucideDir
38
+ let lucideVersion
39
+ try {
40
+ const pkgPath = require.resolve('lucide-static/package.json')
41
+ lucideDir = resolve(dirname(pkgPath), 'icons')
42
+ lucideVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version
43
+ } catch {
44
+ console.error(
45
+ 'generate-icons: lucide-static is not installed. It is a devDependency of this\n' +
46
+ 'package — run `pnpm install` in themes/ before regenerating.'
47
+ )
48
+ process.exit(1)
49
+ }
50
+
51
+ const raw = JSON.parse(readFileSync(SOURCES, 'utf8'))
52
+ // `$`-prefixed keys are the documentation blocks inside sources.json, not icons.
53
+ const entries = Object.entries(raw).filter(([k]) => !k.startsWith('$'))
54
+
55
+ if (!entries.length) {
56
+ console.error('generate-icons: sources.json declares no icons')
57
+ process.exit(1)
58
+ }
59
+
60
+ /** Pull the drawable children out of a lucide SVG, dropping the wrapper and its attrs. */
61
+ function innerMarkup(svg) {
62
+ const open = svg.indexOf('>', svg.indexOf('<svg'))
63
+ const close = svg.lastIndexOf('</svg>')
64
+ return svg
65
+ .slice(open + 1, close)
66
+ .replace(/<!--[\s\S]*?-->/g, '')
67
+ .split('\n')
68
+ .map(l => l.trim())
69
+ .filter(Boolean)
70
+ .join('')
71
+ }
72
+
73
+ const missing = []
74
+ const icons = []
75
+ for (const [name, slug] of entries) {
76
+ if (typeof slug !== 'string') {
77
+ missing.push(`${name}: source must be a lucide slug string`)
78
+ continue
79
+ }
80
+ if (!/^[a-z0-9-]+$/.test(name)) {
81
+ missing.push(`${name}: icon names must be kebab-case`)
82
+ continue
83
+ }
84
+ const file = resolve(lucideDir, `${slug}.svg`)
85
+ if (!existsSync(file)) {
86
+ missing.push(`${name} -> "${slug}" does not exist in lucide-static@${lucideVersion}`)
87
+ continue
88
+ }
89
+ const body = innerMarkup(readFileSync(file, 'utf8'))
90
+ if (!body) {
91
+ missing.push(`${name} -> "${slug}" produced empty markup`)
92
+ continue
93
+ }
94
+ icons.push({ name, slug, body })
95
+ }
96
+
97
+ if (missing.length) {
98
+ console.error(`generate-icons: FAILED — ${missing.length} bad entr${missing.length === 1 ? 'y' : 'ies'}:`)
99
+ for (const m of missing) console.error(` ${m}`)
100
+ process.exit(1)
101
+ }
102
+
103
+ icons.sort((a, b) => a.name.localeCompare(b.name))
104
+
105
+ // The canonical emoji -> name map is compiled INTO the generated module rather
106
+ // than imported as JSON at runtime: an ESM `import ... from './x.json'` needs an
107
+ // import attribute, and bundler support for those is uneven. One source
108
+ // (emoji-map.json), two consumers (this file and check-icons.mjs), no runtime
109
+ // JSON loading anywhere.
110
+ const MAP_PATH = resolve(here, '../src/icons/emoji-map.json')
111
+ const emojiPairs = []
112
+ // `$ambiguous` has to be emitted too, not just skipped with the other `$` keys.
113
+ // A consumer install has no src/ tree, so check-icons and the codemod read the
114
+ // map back out of THIS file — and without the caveats they silently resolve `▶`
115
+ // to `play` inside a mirrored pair again, which is the bug they exist to stop.
116
+ const ambiguousPairs = []
117
+ if (existsSync(MAP_PATH)) {
118
+ const rawMap = JSON.parse(readFileSync(MAP_PATH, 'utf8'))
119
+ const names = new Set(icons.map(i => i.name))
120
+ const bad = []
121
+ for (const [emoji, note] of Object.entries(rawMap.$ambiguous ?? {})) {
122
+ if (typeof note === 'string') ambiguousPairs.push([emoji, note])
123
+ }
124
+ for (const [emoji, name] of Object.entries(rawMap)) {
125
+ if (emoji.startsWith('$')) continue
126
+ if (typeof name !== 'string') continue
127
+ if (!names.has(name)) bad.push(`${emoji} -> "${name}"`)
128
+ else emojiPairs.push([emoji, name])
129
+ }
130
+ if (bad.length) {
131
+ console.error(`generate-icons: FAILED — emoji-map.json points at ${bad.length} icon(s) that do not exist:`)
132
+ for (const b of bad) console.error(` ${b}`)
133
+ process.exit(1)
134
+ }
135
+ }
136
+
137
+ const body = icons
138
+ .map(i => ` ${/^[a-z][a-zA-Z0-9]*$/.test(i.name) ? i.name : `'${i.name}'`}: ${JSON.stringify(i.body)}`)
139
+ .join(',\n')
140
+
141
+ const out = `/**
142
+ * GENERATED FILE — do not edit by hand.
143
+ *
144
+ * Source: src/icons/sources.json + lucide-static@${lucideVersion} (ISC, see LICENSE-lucide).
145
+ * Regenerate with \`pnpm run generate:icons\`; \`pnpm run check:icons\` fails on drift.
146
+ *
147
+ * Values are the INNER markup of each glyph. The wrapping <svg> element and its
148
+ * attributes come from getIconSvg()/<Icon> so every icon shares one set.
149
+ */
150
+
151
+ export const ICON_MARKUP = {
152
+ ${body}
153
+ } as const
154
+
155
+ /** Every icon name in the enforced set. An icon outside this union does not exist. */
156
+ export type IconName = keyof typeof ICON_MARKUP
157
+
158
+ export const ICON_NAMES = Object.keys(ICON_MARKUP) as IconName[]
159
+
160
+ /** The lucide slug each glyph was vendored from — provenance for regeneration. */
161
+ export const ICON_SOURCE_SLUGS: Record<IconName, string> = {
162
+ ${icons.map(i => ` ${/^[a-z][a-zA-Z0-9]*$/.test(i.name) ? i.name : `'${i.name}'`}: '${i.slug}'`).join(',\n')}
163
+ }
164
+
165
+ export const LUCIDE_VERSION = '${lucideVersion}'
166
+
167
+ /**
168
+ * Canonical raw-emoji -> icon name. Source: src/icons/emoji-map.json.
169
+ * Keys are stored exactly as written there; look them up through
170
+ * emojiToIconName(), which strips variation selectors and skin tones first.
171
+ */
172
+ export const EMOJI_TO_ICON: Record<string, IconName> = {
173
+ ${emojiPairs.map(([e, n]) => ` ${JSON.stringify(e)}: ${JSON.stringify(n)}`).join(',\n')}
174
+ }
175
+
176
+ /**
177
+ * Emoji whose EMOJI_TO_ICON entry is only right in one context, and the rule for
178
+ * each. Source: the \`$ambiguous\` block in src/icons/emoji-map.json. check-icons
179
+ * prints these beside its suggestion; codemod-icons refuses to rewrite them.
180
+ */
181
+ export const AMBIGUOUS_EMOJI: Record<string, string> = {
182
+ ${ambiguousPairs.map(([e, n]) => ` ${JSON.stringify(e)}: ${JSON.stringify(n)}`).join(',\n')}
183
+ }
184
+ `
185
+
186
+ if (check) {
187
+ const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : ''
188
+ if (current !== out) {
189
+ console.error(
190
+ 'check:icons: FAILED — registry.generated.ts is stale or hand-edited.\n' +
191
+ 'Run `pnpm run generate:icons` in themes/ and commit the result.'
192
+ )
193
+ process.exit(1)
194
+ }
195
+ console.log(`check:icons: OK — ${icons.length} icons match sources.json (lucide ${lucideVersion})`)
196
+ } else {
197
+ writeFileSync(OUT, out)
198
+ console.log(`generate-icons: wrote ${icons.length} icons -> src/icons/registry.generated.ts (lucide ${lucideVersion})`)
199
+ }
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generates a standalone preview of the whole theme system.
4
+ *
5
+ * The page is built entirely out of the tokens it is previewing - switching
6
+ * theme restyles the preview itself, so it doubles as proof that the token set
7
+ * is complete. Nothing is hardcoded; every value comes from style.css.
8
+ *
9
+ * All output is ASCII (HTML entities / CSS escapes) so the page cannot depend
10
+ * on the host sending a charset.
11
+ *
12
+ * node themes/scripts/generate-preview.mjs [-o out.html]
13
+ *
14
+ * Themes are scoped to a wrapper element rather than :root because the host
15
+ * page may already own the `data-theme` attribute on <html>.
16
+ */
17
+
18
+ import { readFileSync, writeFileSync } from 'node:fs'
19
+ import { resolve, dirname } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { parseThemes, THEMES, FAMILIES, STYLE_PATH, isDarkTheme } from './lib/parse-themes.mjs'
22
+ import { contrast, ratio } from './lib/color.mjs'
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url))
25
+ const outFlag = process.argv.indexOf('-o')
26
+ const OUT = outFlag > -1 ? process.argv[outFlag + 1] : resolve(here, '../dev/preview.html')
27
+
28
+ const themes = parseThemes()
29
+ const styleCss = readFileSync(STYLE_PATH, 'utf8')
30
+
31
+ const VARIANTS = [
32
+ ['', 'fill'],
33
+ ['-dark', 'gradient'],
34
+ ['-bg', 'tint'],
35
+ ['-hover', 'hover']
36
+ ]
37
+ const STRUCTURAL_GROUPS = [
38
+ ['Text', ['text', 'text-secondary', 'text-tertiary', 'text-muted']],
39
+ ['Borders', ['border', 'border-light']],
40
+ ['Surfaces', ['bg', 'bg-card', 'bg-alt', 'bg-hover', 'bg-overlay']]
41
+ ]
42
+
43
+ /* ---------- contrast data, computed once and embedded ---------- */
44
+ const contrastData = {}
45
+ for (const theme of THEMES) {
46
+ const tk = themes[theme]
47
+ const surface = tk['--color-bg-card']
48
+ contrastData[theme] = FAMILIES.map(f => ({
49
+ family: f,
50
+ fill: ratio(contrast(tk[`--color-on-${f}`], tk[`--color-${f}`], surface)),
51
+ gradient: ratio(contrast(tk[`--color-on-${f}`], tk[`--color-${f}-dark`], surface)),
52
+ tint: ratio(contrast(tk[`--color-on-${f}-bg`], tk[`--color-${f}-bg`], surface))
53
+ }))
54
+ }
55
+ const totalPairs = THEMES.length * FAMILIES.length * 3
56
+ const worst = Math.min(
57
+ ...Object.values(contrastData).flatMap(rows => rows.flatMap(r => [r.fill, r.gradient, r.tint]))
58
+ )
59
+
60
+ /* ---------- markup fragments ---------- */
61
+ const themeLabel = t => t.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
62
+
63
+ const themeRail = THEMES.map(
64
+ t => ` <button class="chip" data-pick="${t}" type="button" aria-pressed="${t === 'light'}">
65
+ <span class="chip__swatches" data-theme="${t}" aria-hidden="true">
66
+ <i style="background: var(--color-primary)"></i><i style="background: var(--color-success)"></i><i style="background: var(--color-danger)"></i>
67
+ </span>
68
+ <span class="chip__name">${themeLabel(t)}</span>
69
+ </button>`
70
+ ).join('\n')
71
+
72
+ const familyRows = FAMILIES.map(
73
+ f => ` <div class="rect__row">
74
+ <div class="rect__label">${f}</div>
75
+ ${VARIANTS.map(
76
+ ([v, role]) => ` <div class="rect__cell">
77
+ <div class="rect__chip" style="background: var(--color-${f}${v})"></div>
78
+ <code>--color-${f}${v || ''}</code><span class="rect__role">${role}</span>
79
+ </div>`
80
+ ).join('\n')}
81
+ <div class="rect__cell">
82
+ <div class="rect__chip rect__chip--text" style="background: var(--color-${f}); color: var(--color-on-${f})">Aa</div>
83
+ <code>--color-on-${f}</code><span class="rect__role">on fill</span>
84
+ </div>
85
+ <div class="rect__cell">
86
+ <div class="rect__chip rect__chip--text" style="background: var(--color-${f}-bg); color: var(--color-on-${f}-bg)">Aa</div>
87
+ <code>--color-on-${f}-bg</code><span class="rect__role">on tint</span>
88
+ </div>
89
+ </div>`
90
+ ).join('\n')
91
+
92
+ const structuralRows = STRUCTURAL_GROUPS.map(
93
+ ([label, names]) => ` <div class="struct__group">
94
+ <div class="struct__label">${label}</div>
95
+ <div class="struct__items">
96
+ ${names
97
+ .map(
98
+ n => ` <div class="struct__item">
99
+ <div class="struct__chip" style="background: var(--color-${n})"></div>
100
+ <code>--color-${n}</code>
101
+ </div>`
102
+ )
103
+ .join('\n')}
104
+ </div>
105
+ </div>`
106
+ ).join('\n')
107
+
108
+ const buttons = FAMILIES.map(
109
+ f => ` <button class="btn btn--${f}" type="button">${f}</button>`
110
+ ).join('\n')
111
+ const badges = FAMILIES.map(f => ` <span class="badge badge--${f}">${f}</span>`).join('\n')
112
+ const familyCss = FAMILIES.map(
113
+ f => `.btn--${f} { background: linear-gradient(180deg, var(--color-${f}) 0%, var(--color-${f}-dark) 100%); color: var(--color-on-${f}); }
114
+ .btn--${f}:hover { background: var(--color-${f}-dark); }
115
+ .badge--${f} { background: var(--color-${f}-bg); color: var(--color-on-${f}-bg); }`
116
+ ).join('\n')
117
+
118
+ const html = `<title>Hadoku Theme System &mdash; 18 themes, 41 tokens</title>
119
+ <style>
120
+ ${styleCss}
121
+
122
+ /* ============================================================
123
+ Preview chrome. Deliberately built from the tokens it shows:
124
+ change the theme and this page restyles itself, which is the
125
+ proof that the set is complete.
126
+ ============================================================ */
127
+ *, *::before, *::after { box-sizing: border-box; }
128
+ body { margin: 0; }
129
+
130
+ .stage {
131
+ min-height: 100vh;
132
+ background: var(--color-bg);
133
+ color: var(--color-text);
134
+ font-family: var(--font-family);
135
+ line-height: 1.55;
136
+ transition: background 0.25s ease, color 0.25s ease;
137
+ }
138
+
139
+ .wrap { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 0; max-width: 1280px; margin: 0 auto; }
140
+ @media (max-width: 860px) { .wrap { grid-template-columns: 1fr; } }
141
+
142
+ /* ---- theme rail ---- */
143
+ .rail {
144
+ position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto;
145
+ padding: 28px 18px 40px; border-right: 1px solid var(--color-border);
146
+ display: flex; flex-direction: column; gap: 3px;
147
+ }
148
+ @media (max-width: 860px) {
149
+ .rail { position: static; max-height: none; border-right: 0; border-bottom: 1px solid var(--color-border);
150
+ flex-direction: row; flex-wrap: wrap; gap: 6px; }
151
+ }
152
+ .rail__eyebrow {
153
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.11em; font-weight: 700;
154
+ color: var(--color-text-secondary); margin-bottom: 12px; width: 100%;
155
+ }
156
+ .chip {
157
+ display: flex; align-items: center; gap: 10px; width: 100%;
158
+ padding: 7px 9px; border: 1px solid transparent; border-radius: var(--hdk-radius);
159
+ background: transparent; color: var(--color-text-secondary);
160
+ font: inherit; font-size: 12.5px; text-align: left; cursor: pointer;
161
+ transition: background 0.15s ease, color 0.15s ease;
162
+ }
163
+ .chip:hover { background: var(--color-bg-hover); color: var(--color-text); }
164
+ .chip[aria-pressed='true'] { background: var(--color-primary-bg); color: var(--color-on-primary-bg); border-color: var(--color-primary); font-weight: 600; }
165
+ .chip:focus-visible { outline: none; box-shadow: var(--hdk-shadow-focus-sm); }
166
+ .chip__swatches { display: flex; flex-shrink: 0; border-radius: 3px; overflow: hidden; }
167
+ .chip__swatches i { display: block; width: 9px; height: 18px; }
168
+ .chip__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
169
+
170
+ /* ---- main ---- */
171
+ main { padding: 32px 34px 80px; min-width: 0; }
172
+ @media (max-width: 640px) { main { padding: 24px 18px 60px; } }
173
+
174
+ .head { border-bottom: 1px solid var(--color-border); padding-bottom: 22px; margin-bottom: 26px; }
175
+ h1 { font-size: 26px; line-height: 1.2; margin: 0 0 6px; font-weight: 700; text-wrap: balance; }
176
+ .head p { margin: 0; color: var(--color-text-secondary); max-width: 62ch; font-size: 14px; }
177
+
178
+ .stats { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
179
+ .stat {
180
+ flex: 1 1 130px; padding: 12px 14px; border: 1px solid var(--color-border-light);
181
+ border-radius: var(--hdk-radius); background: var(--color-bg-card);
182
+ }
183
+ .stat__n { font-size: 21px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
184
+ .stat__l { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-text-secondary); margin-top: 3px; }
185
+ .stat--ok .stat__n { color: var(--color-success); }
186
+
187
+ section { margin-top: 40px; }
188
+ h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.11em; font-weight: 700;
189
+ color: var(--color-text-secondary); margin: 0 0 4px; }
190
+ .sub { margin: 0 0 16px; font-size: 13px; color: var(--color-text-secondary); max-width: 62ch; }
191
+
192
+ .panel { background: var(--color-bg-card); border: 1px solid var(--color-border-light);
193
+ border-radius: var(--hdk-radius-lg); padding: 20px; }
194
+
195
+ /* ---- components ---- */
196
+ .row { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; }
197
+ .row + .row { margin-top: 16px; }
198
+ .btn { padding: 8px 15px; border: none; border-radius: var(--hdk-radius); font: inherit; font-size: 13px;
199
+ font-weight: 600; cursor: pointer; text-transform: capitalize; box-shadow: var(--hdk-shadow-sm); }
200
+ .badge { padding: 3px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 600; text-transform: capitalize; }
201
+ ${familyCss}
202
+ .field { padding: 8px 11px; border: 1px solid var(--color-border); border-radius: var(--hdk-radius);
203
+ background: var(--color-bg); color: var(--color-text); font: inherit; font-size: 13px; min-width: 190px; }
204
+ .field:focus { outline: none; border-color: var(--color-primary); box-shadow: var(--hdk-shadow-focus-sm); }
205
+ .inset { background: var(--color-bg-alt); border: 1px solid var(--color-border-light);
206
+ border-radius: var(--hdk-radius); padding: 13px 15px; margin-top: 16px; }
207
+ .type-scale > * { margin: 0 0 5px; }
208
+ .t2 { color: var(--color-text-secondary); font-size: 13.5px; }
209
+ .t3 { color: var(--color-text-tertiary); font-size: 13px; }
210
+ .t4 { color: var(--color-text-muted); font-size: 12.5px; }
211
+
212
+ /* ---- the rectangle ---- */
213
+ .rect { display: flex; flex-direction: column; gap: 7px; overflow-x: auto; }
214
+ .rect__head, .rect__row { display: grid; grid-template-columns: 68px repeat(6, minmax(104px, 1fr)); gap: 7px; align-items: start; }
215
+ .rect__head > div { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.07em;
216
+ color: var(--color-text-secondary); font-weight: 700; padding-bottom: 2px; }
217
+ .rect__label { font-size: 12px; font-weight: 700; text-transform: capitalize; padding-top: 9px; }
218
+ .rect__cell { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
219
+ .rect__chip { height: 34px; border-radius: 5px; border: 1px solid var(--color-border-light); }
220
+ .rect__chip--text { display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; }
221
+ .rect__cell code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9.5px;
222
+ color: var(--color-text-secondary); overflow-wrap: anywhere; line-height: 1.35; }
223
+ .rect__role { font-size: 9px; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
224
+
225
+ /* ---- structural ---- */
226
+ .struct { display: flex; flex-direction: column; gap: 16px; }
227
+ .struct__label { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em;
228
+ color: var(--color-text-secondary); font-weight: 700; margin-bottom: 7px; }
229
+ .struct__items { display: flex; flex-wrap: wrap; gap: 8px; }
230
+ .struct__item { display: flex; flex-direction: column; gap: 3px; width: 132px; }
231
+ .struct__chip { height: 30px; border-radius: 5px; border: 1px solid var(--color-border); }
232
+ .struct__item code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9.5px;
233
+ color: var(--color-text-secondary); overflow-wrap: anywhere; }
234
+
235
+ /* ---- contrast matrix ---- */
236
+ /* color: inherit is load-bearing. In quirks mode (a host that serves this
237
+ fragment without a doctype) tables do not inherit colour from their
238
+ ancestors, so the text falls back to black and vanishes on dark themes. */
239
+ .matrix { width: 100%; border-collapse: collapse; font-size: 12.5px; color: inherit; }
240
+ .matrix th, .matrix td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--color-border-light); color: inherit; }
241
+ .matrix th { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--color-text-secondary); font-weight: 700; }
242
+ .matrix td:first-child { font-weight: 600; text-transform: capitalize; }
243
+ .matrix tr:last-child td { border-bottom: none; }
244
+ .ratio { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
245
+ .ratio::after { content: ' \\2713'; color: var(--color-success); font-weight: 700; }
246
+ .ratio--fail { color: var(--color-danger); font-weight: 700; }
247
+ .ratio--fail::after { content: ' \\2715'; color: var(--color-danger); }
248
+
249
+ .note { margin-top: 12px; font-size: 12px; color: var(--color-text-secondary); }
250
+ .note code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
251
+
252
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
253
+ </style>
254
+
255
+ <div class="stage" id="stage" data-theme="light">
256
+ <div class="wrap">
257
+ <nav class="rail" aria-label="Theme">
258
+ <div class="rail__eyebrow">${THEMES.length} Themes</div>
259
+ ${themeRail}
260
+ </nav>
261
+
262
+ <main>
263
+ <header class="head">
264
+ <h1>Hadoku Theme System</h1>
265
+ <p>Every semantic family carries an identical set of six tokens. This page is built from
266
+ those tokens &mdash; switching theme restyles the page itself, so what you see is the system
267
+ proving it is complete.</p>
268
+ <div class="stats">
269
+ <div class="stat"><div class="stat__n">${THEMES.length}</div><div class="stat__l">Themes</div></div>
270
+ <div class="stat"><div class="stat__n">41</div><div class="stat__l">Color tokens</div></div>
271
+ <div class="stat"><div class="stat__n">5 &times; 6</div><div class="stat__l">Family rectangle</div></div>
272
+ <div class="stat stat--ok"><div class="stat__n" id="stat-pairs">${totalPairs}</div><div class="stat__l">WCAG pairs pass</div></div>
273
+ <div class="stat stat--ok"><div class="stat__n">${ratio(worst)}:1</div><div class="stat__l">Worst pair</div></div>
274
+ </div>
275
+ </header>
276
+
277
+ <section>
278
+ <h2>Components</h2>
279
+ <p class="sub">Rendered live in <strong id="theme-name">Light</strong>. Buttons use the fill&rarr;gradient ramp with
280
+ <code>on-&lt;f&gt;</code>; badges use the tint with <code>on-&lt;f&gt;-bg</code>.</p>
281
+ <div class="panel">
282
+ <div class="row">
283
+ ${buttons}
284
+ </div>
285
+ <div class="row">
286
+ ${badges}
287
+ </div>
288
+ <div class="row">
289
+ <input class="field" value="Input field" aria-label="Sample input" />
290
+ <button class="btn btn--neutral" type="button">Cancel</button>
291
+ <button class="btn btn--primary" type="button">Save</button>
292
+ </div>
293
+ <div class="inset type-scale">
294
+ <p>Body text &mdash; <code>--color-text</code></p>
295
+ <p class="t2">Secondary text &mdash; <code>--color-text-secondary</code></p>
296
+ <p class="t3">Tertiary text &mdash; <code>--color-text-tertiary</code></p>
297
+ <p class="t4">Muted text &mdash; <code>--color-text-muted</code></p>
298
+ </div>
299
+ </div>
300
+ </section>
301
+
302
+ <section>
303
+ <h2>The family rectangle</h2>
304
+ <p class="sub">Five families down, six tokens across, no gaps. The symmetry is the point &mdash; it means the
305
+ obvious guess is always a real token.</p>
306
+ <div class="panel rect">
307
+ <div class="rect__head">
308
+ <div></div><div>Fill</div><div>Gradient</div><div>Tint</div><div>Hover</div><div>On fill</div><div>On tint</div>
309
+ </div>
310
+ ${familyRows}
311
+ </div>
312
+ </section>
313
+
314
+ <section>
315
+ <h2>Structural tokens</h2>
316
+ <p class="sub">Text hierarchy, borders and surfaces &mdash; shared across all families.</p>
317
+ <div class="panel struct">
318
+ ${structuralRows}
319
+ </div>
320
+ </section>
321
+
322
+ <section>
323
+ <h2>Contrast &mdash; measured, not asserted</h2>
324
+ <p class="sub">Every pair must clear WCAG AA 4.5:1. Translucent tints are composited over
325
+ <code>--color-bg-card</code> before measuring, so these are the ratios that actually render.</p>
326
+ <div class="panel">
327
+ <table class="matrix">
328
+ <thead><tr><th>Family</th><th>on-&lt;f&gt; on fill</th><th>on-&lt;f&gt; on gradient</th><th>on-&lt;f&gt;-bg on tint</th></tr></thead>
329
+ <tbody id="matrix-body"></tbody>
330
+ </table>
331
+ <p class="note">Before this release the tint column used <code>--color-&lt;f&gt;</code> as its text
332
+ colour and failed in 62 of 90 theme&times;family combinations.</p>
333
+ </div>
334
+ </section>
335
+ </main>
336
+ </div>
337
+ </div>
338
+
339
+ <script>
340
+ const CONTRAST = ${JSON.stringify(contrastData)};
341
+ const DARK = ${JSON.stringify(THEMES.filter(isDarkTheme))};
342
+ const stage = document.getElementById('stage');
343
+ const body = document.getElementById('matrix-body');
344
+ const label = (t) => t.replace(/-/g, ' ').replace(/\\b\\w/g, (c) => c.toUpperCase());
345
+
346
+ function cell(v) {
347
+ const cls = v >= 4.5 ? 'ratio' : 'ratio ratio--fail';
348
+ return '<td><span class="' + cls + '">' + v.toFixed(2) + ':1</span></td>';
349
+ }
350
+
351
+ function apply(theme) {
352
+ stage.dataset.theme = theme;
353
+ document.getElementById('theme-name').textContent = label(theme);
354
+ document.querySelectorAll('[data-pick]').forEach((b) => {
355
+ b.setAttribute('aria-pressed', String(b.dataset.pick === theme));
356
+ });
357
+ body.innerHTML = CONTRAST[theme]
358
+ .map((r) => '<tr><td>' + r.family + '</td>' + cell(r.fill) + cell(r.gradient) + cell(r.tint) + '</tr>')
359
+ .join('');
360
+ }
361
+
362
+ document.querySelectorAll('[data-pick]').forEach((b) => {
363
+ b.addEventListener('click', () => apply(b.dataset.pick));
364
+ });
365
+
366
+ // Open on a theme that matches the viewer's own light/dark preference.
367
+ apply(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
368
+ </script>
369
+ `
370
+
371
+ writeFileSync(OUT, html)
372
+ console.log(
373
+ `Wrote ${OUT} - ${THEMES.length} themes, ${totalPairs} contrast pairs, worst ${ratio(worst)}:1`
374
+ )
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generates themes/src/tailwind-colors.css from style.css.
4
+ *
5
+ * Root cause #2 of the recurring colour bugs was that every consumer
6
+ * hand-maintained its own `@theme` colour subset, which drifted from the
7
+ * source. Generating the mapping means it cannot drift: `--check` re-runs this
8
+ * and fails if the committed file disagrees with the tokens.
9
+ *
10
+ * node themes/scripts/generate-tailwind-colors.mjs [--check]
11
+ */
12
+
13
+ import { readFileSync, writeFileSync } from 'node:fs'
14
+ import { resolve, dirname } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { parseThemes, allTokenNames, THEMES } from './lib/parse-themes.mjs'
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url))
19
+ const OUT_PATH = resolve(here, '../src/tailwind-colors.css')
20
+
21
+ const themes = parseThemes()
22
+ const names = allTokenNames(themes)
23
+
24
+ // Only map tokens every theme defines — a partially-defined token would
25
+ // generate a utility that works in some themes and silently dies in others.
26
+ const complete = names.filter(n => THEMES.every(t => themes[t][n]))
27
+ const partial = names.filter(n => !complete.includes(n))
28
+ if (partial.length) {
29
+ console.error(`Refusing to map partially-defined tokens: ${partial.join(', ')}`)
30
+ process.exit(1)
31
+ }
32
+
33
+ const GROUPS = [
34
+ ['Primary', n => n.includes('primary')],
35
+ ['Success', n => n.includes('success')],
36
+ ['Warning', n => n.includes('warning')],
37
+ ['Danger', n => n.includes('danger')],
38
+ ['Neutral', n => n.includes('neutral')],
39
+ ['Text', n => n === '--color-text' || n.startsWith('--color-text-')],
40
+ ['Borders', n => n === '--color-border' || n.startsWith('--color-border-')],
41
+ ['Surfaces', n => n === '--color-bg' || n.startsWith('--color-bg-')]
42
+ ]
43
+
44
+ const lines = []
45
+ const seen = new Set()
46
+ for (const [label, match] of GROUPS) {
47
+ const group = complete.filter(n => !seen.has(n) && match(n))
48
+ if (!group.length) continue
49
+ group.forEach(n => seen.add(n))
50
+ lines.push(` /* ${label} */`)
51
+ for (const n of group) lines.push(` ${n}: var(${n});`)
52
+ lines.push('')
53
+ }
54
+ const leftover = complete.filter(n => !seen.has(n))
55
+ if (leftover.length) {
56
+ lines.push(' /* Other */')
57
+ for (const n of leftover) lines.push(` ${n}: var(${n});`)
58
+ lines.push('')
59
+ }
60
+ while (lines.at(-1) === '') lines.pop()
61
+
62
+ const output = `/**
63
+ * Tailwind v4 colour mapping for @wolffm/themes — GENERATED, DO NOT EDIT.
64
+ *
65
+ * Regenerate with: node themes/scripts/generate-tailwind-colors.mjs
66
+ * Source of truth: themes/src/style.css
67
+ *
68
+ * Mapping every theme token into Tailwind's \`--color-*\` namespace makes the
69
+ * whole token set available as utilities — \`bg-primary\`, \`text-on-primary\`,
70
+ * \`bg-success-bg\`, \`text-on-success-bg\`, \`border-border\`, \`bg-bg-card\` — with
71
+ * no hand-written \`@theme\` block in any consumer.
72
+ *
73
+ * USAGE — import order matters:
74
+ *
75
+ * @import "@wolffm/themes/style.css"; // 1. token values (UNLAYERED)
76
+ * @import "tailwindcss"; // 2. Tailwind
77
+ * @import "@wolffm/themes/tailwind-colors.css"; // 3. this mapping
78
+ *
79
+ * WHY style.css MUST STAY UNLAYERED
80
+ * =================================
81
+ * Tailwind emits every \`@theme\` entry into \`@layer theme\` as \`:root { --x: var(--x) }\`
82
+ * — a self-reference that is invalid on its own. It resolves correctly only
83
+ * because style.css declares the same properties OUTSIDE any cascade layer,
84
+ * and unlayered declarations beat layered ones. Importing style.css with
85
+ * \`layer(...)\` puts both declarations in layers, the self-reference can win,
86
+ * and EVERY colour silently resolves to nothing.
87
+ *
88
+ * So: never \`@import "@wolffm/themes/style.css" layer(base)\`.
89
+ * \`themes/scripts/check-usage.mjs\` enforces this.
90
+ */
91
+
92
+ @theme {
93
+ ${lines.join('\n')}
94
+ }
95
+ `
96
+
97
+ if (process.argv.includes('--check')) {
98
+ const current = readFileSync(OUT_PATH, 'utf8')
99
+ if (current.replace(/\r\n/g, '\n') !== output) {
100
+ console.error(
101
+ `${OUT_PATH} is out of date — run: node themes/scripts/generate-tailwind-colors.mjs`
102
+ )
103
+ process.exit(1)
104
+ }
105
+ console.log(`tailwind-colors.css is in sync (${complete.length} tokens mapped).`)
106
+ } else {
107
+ writeFileSync(OUT_PATH, output)
108
+ console.log(`Wrote ${OUT_PATH} — ${complete.length} tokens mapped.`)
109
+ }