@hadoku/themes 0.0.0-stage → 5.12.16
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,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Rewrites themes/src/style.css to the canonical symmetric token set.
|
|
4
|
+
*
|
|
5
|
+
* Every theme block currently opens with exactly 38 contiguous `--color-*`
|
|
6
|
+
* declarations followed by shadow/spacing/advanced tokens. This replaces that
|
|
7
|
+
* leading span with the generated canonical set and leaves everything after
|
|
8
|
+
* it byte-identical.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent: running it twice produces the same file.
|
|
11
|
+
*
|
|
12
|
+
* node themes/scripts/normalize-tokens.mjs [--dry]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import {
|
|
17
|
+
STYLE_PATH,
|
|
18
|
+
readBlocks,
|
|
19
|
+
themesForSelector,
|
|
20
|
+
readDeclarations,
|
|
21
|
+
FAMILIES
|
|
22
|
+
} from './lib/parse-themes.mjs'
|
|
23
|
+
import { deriveTheme } from './lib/derive.mjs'
|
|
24
|
+
|
|
25
|
+
const dry = process.argv.includes('--dry')
|
|
26
|
+
const css = readFileSync(STYLE_PATH, 'utf8')
|
|
27
|
+
const eol = css.includes('\r\n') ? '\r\n' : '\n'
|
|
28
|
+
|
|
29
|
+
const FAMILY_LABEL = {
|
|
30
|
+
primary: 'Primary',
|
|
31
|
+
success: 'Success',
|
|
32
|
+
warning: 'Warning',
|
|
33
|
+
danger: 'Danger',
|
|
34
|
+
neutral: 'Neutral'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Render the canonical color declarations for one theme, with section comments. */
|
|
38
|
+
function renderColors(tokens, indent) {
|
|
39
|
+
const lines = []
|
|
40
|
+
const push = s => lines.push(s ? indent + s : '')
|
|
41
|
+
|
|
42
|
+
for (const f of FAMILIES) {
|
|
43
|
+
push(`/* ${FAMILY_LABEL[f]} */`)
|
|
44
|
+
push(`--color-${f}: ${tokens[`--color-${f}`]};`)
|
|
45
|
+
push(`--color-${f}-dark: ${tokens[`--color-${f}-dark`]};`)
|
|
46
|
+
push(`--color-${f}-bg: ${tokens[`--color-${f}-bg`]};`)
|
|
47
|
+
push(`--color-${f}-hover: ${tokens[`--color-${f}-hover`]};`)
|
|
48
|
+
push(`--color-on-${f}: ${tokens[`--color-on-${f}`]};`)
|
|
49
|
+
push(`--color-on-${f}-bg: ${tokens[`--color-on-${f}-bg`]};`)
|
|
50
|
+
push('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
push('/* Text */')
|
|
54
|
+
for (const n of [
|
|
55
|
+
'--color-text',
|
|
56
|
+
'--color-text-secondary',
|
|
57
|
+
'--color-text-tertiary',
|
|
58
|
+
'--color-text-muted'
|
|
59
|
+
]) {
|
|
60
|
+
push(`${n}: ${tokens[n]};`)
|
|
61
|
+
}
|
|
62
|
+
push('')
|
|
63
|
+
push('/* Borders */')
|
|
64
|
+
for (const n of ['--color-border', '--color-border-light']) push(`${n}: ${tokens[n]};`)
|
|
65
|
+
push('')
|
|
66
|
+
push('/* Backgrounds */')
|
|
67
|
+
for (const n of [
|
|
68
|
+
'--color-bg',
|
|
69
|
+
'--color-bg-card',
|
|
70
|
+
'--color-bg-alt',
|
|
71
|
+
'--color-bg-hover',
|
|
72
|
+
'--color-bg-overlay'
|
|
73
|
+
]) {
|
|
74
|
+
push(`${n}: ${tokens[n]};`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return lines.join(eol)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Collect edits first, apply back-to-front so earlier offsets stay valid. */
|
|
81
|
+
const edits = []
|
|
82
|
+
/* Values that changed for a reason other than a verbatim rename. */
|
|
83
|
+
const changed = []
|
|
84
|
+
|
|
85
|
+
for (const block of readBlocks(css)) {
|
|
86
|
+
const names = themesForSelector(block.selector)
|
|
87
|
+
if (!names.length) continue
|
|
88
|
+
|
|
89
|
+
const decls = readDeclarations(block.body)
|
|
90
|
+
const colorDecls = decls.filter(d => d.name.startsWith('--color-'))
|
|
91
|
+
if (!colorDecls.length) continue
|
|
92
|
+
|
|
93
|
+
const tokens = Object.fromEntries(decls.map(d => [d.name, d.value]))
|
|
94
|
+
const notes = []
|
|
95
|
+
const canonical = deriveTheme(tokens, notes)
|
|
96
|
+
for (const n of notes) changed.push({ theme: names[0], ...n })
|
|
97
|
+
|
|
98
|
+
// The color span covers whole lines: from the line holding the first
|
|
99
|
+
// `--color-` declaration through the line closing the last one.
|
|
100
|
+
const body = block.body
|
|
101
|
+
const firstAt = body.indexOf('--color-')
|
|
102
|
+
const lastName = colorDecls[colorDecls.length - 1].name
|
|
103
|
+
const endInBody = body.indexOf(';', body.lastIndexOf(lastName)) + 1
|
|
104
|
+
|
|
105
|
+
const lines = body.split(/\r?\n/)
|
|
106
|
+
const offsetOfLine = []
|
|
107
|
+
for (let i = 0, at = 0; i < lines.length; i += 1) {
|
|
108
|
+
offsetOfLine.push(at)
|
|
109
|
+
at += lines[i].length + eol.length
|
|
110
|
+
}
|
|
111
|
+
const lineOf = offset => {
|
|
112
|
+
let n = 0
|
|
113
|
+
while (n + 1 < offsetOfLine.length && offsetOfLine[n + 1] <= offset) n += 1
|
|
114
|
+
return n
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let firstLine = lineOf(firstAt)
|
|
118
|
+
const lastLine = lineOf(endInBody - 1)
|
|
119
|
+
|
|
120
|
+
// Section comments inside the span get regenerated, but the first one sits
|
|
121
|
+
// on the line above it — pull it in so it isn't left orphaned.
|
|
122
|
+
if (firstLine > 0 && /^\s*\/\*[^*]*\*\/\s*$/.test(lines[firstLine - 1])) firstLine -= 1
|
|
123
|
+
|
|
124
|
+
const indent = lines[lineOf(firstAt)].match(/^[ \t]*/)[0]
|
|
125
|
+
|
|
126
|
+
edits.push({
|
|
127
|
+
start: block.start + offsetOfLine[firstLine],
|
|
128
|
+
end: block.start + endInBody,
|
|
129
|
+
text: renderColors(canonical, indent),
|
|
130
|
+
theme: names.join('+')
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let out = css
|
|
135
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) {
|
|
136
|
+
out = out.slice(0, e.start) + e.text + out.slice(e.end)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(`Rewrote ${edits.length} theme blocks.`)
|
|
140
|
+
|
|
141
|
+
if (changed.length) {
|
|
142
|
+
console.log(
|
|
143
|
+
`\n${changed.length} value(s) adjusted for contrast (everything else carried over verbatim):`
|
|
144
|
+
)
|
|
145
|
+
for (const c of changed) {
|
|
146
|
+
const why =
|
|
147
|
+
c.reason === 'contrast'
|
|
148
|
+
? 'authored value broke the on-<f> contract'
|
|
149
|
+
: c.reason === 'separation'
|
|
150
|
+
? 'surface step under its floor'
|
|
151
|
+
: `dead-zone nudge, ΔL -${c.shift.toFixed(3)}`
|
|
152
|
+
console.log(` ${c.theme.padEnd(17)} ${c.token.padEnd(24)} ${c.from} → ${c.to} (${why})`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (dry) {
|
|
157
|
+
console.log('--dry: not writing.')
|
|
158
|
+
} else {
|
|
159
|
+
writeFileSync(STYLE_PATH, out)
|
|
160
|
+
console.log(`Wrote ${STYLE_PATH}`)
|
|
161
|
+
}
|
package/src/advanced.css
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hadoku Themes — Advanced Visual Kit
|
|
3
|
+
*
|
|
4
|
+
* Class hooks for themes that ship an advanced visual contract
|
|
5
|
+
* (gradient + animated effects). Per-theme tokens live inside that
|
|
6
|
+
* theme's [data-theme='X'] block in style.css; this file is the
|
|
7
|
+
* shared rendering kit.
|
|
8
|
+
*
|
|
9
|
+
* Mode is controlled by data-theme-mode='simple' | 'advanced':
|
|
10
|
+
* - 'simple' (default): flatten to the theme's solid colors
|
|
11
|
+
* - 'advanced': render the gradient + effects (opt-in)
|
|
12
|
+
*
|
|
13
|
+
* Class hooks (consumer applies these in JSX):
|
|
14
|
+
* .hdk-advanced-page — viewport-fixed gradient page background
|
|
15
|
+
* .hdk-advanced-surface — "window into the gradient" element
|
|
16
|
+
* .hdk-advanced-surface--shift — gradient displacement on hover
|
|
17
|
+
* .hdk-advanced-surface--cascade-rich — multi-wave shimmer
|
|
18
|
+
* .hdk-advanced-surface--sat-rich — saturation + cascade
|
|
19
|
+
*
|
|
20
|
+
* When --advanced-gradient is undefined (theme has no advanced
|
|
21
|
+
* contract), the surfaces fall back to their flat backgrounds so the
|
|
22
|
+
* classes are safe to apply unconditionally.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/* ---------- Page surface ---------- */
|
|
26
|
+
|
|
27
|
+
.hdk-advanced-page {
|
|
28
|
+
background: var(--advanced-gradient, var(--color-bg));
|
|
29
|
+
background-attachment: fixed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ---------- Card surface ---------- */
|
|
33
|
+
/* Each surface is its own stacking context so effect ::after layers
|
|
34
|
+
sit above the bg but below content without forcing children to set
|
|
35
|
+
z-index. */
|
|
36
|
+
|
|
37
|
+
.hdk-advanced-surface {
|
|
38
|
+
position: relative;
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
background: var(--advanced-gradient, var(--color-bg-card));
|
|
41
|
+
background-attachment: fixed;
|
|
42
|
+
isolation: isolate;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ---------- Effect: SHIFT — gradient displacement on hover ---------- */
|
|
46
|
+
|
|
47
|
+
.hdk-advanced-surface--shift::after {
|
|
48
|
+
content: '';
|
|
49
|
+
position: absolute;
|
|
50
|
+
inset: 0;
|
|
51
|
+
border-radius: inherit;
|
|
52
|
+
background: var(--advanced-gradient);
|
|
53
|
+
background-attachment: scroll;
|
|
54
|
+
background-size: 130% 130%;
|
|
55
|
+
background-position: 30% 30%;
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transition:
|
|
58
|
+
opacity 0.4s ease,
|
|
59
|
+
background-position 0.8s ease-out;
|
|
60
|
+
pointer-events: none;
|
|
61
|
+
z-index: -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.hdk-advanced-surface--shift:hover::after {
|
|
65
|
+
opacity: 1;
|
|
66
|
+
background-position: 70% 70%;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------- Effect: CASCADE-RICH — multi-wave, varied speeds ---------- */
|
|
70
|
+
|
|
71
|
+
.hdk-advanced-surface--cascade-rich::before {
|
|
72
|
+
content: '';
|
|
73
|
+
position: absolute;
|
|
74
|
+
inset: -80%;
|
|
75
|
+
background: linear-gradient(
|
|
76
|
+
135deg,
|
|
77
|
+
transparent 38%,
|
|
78
|
+
rgba(255, 255, 255, 0.03) 44%,
|
|
79
|
+
rgba(255, 255, 255, 0.03) 48%,
|
|
80
|
+
transparent 54%
|
|
81
|
+
);
|
|
82
|
+
transform: translateX(-50%) translateY(-50%);
|
|
83
|
+
opacity: 0;
|
|
84
|
+
transition:
|
|
85
|
+
transform 1.2s ease-out,
|
|
86
|
+
opacity 0.2s ease;
|
|
87
|
+
pointer-events: none;
|
|
88
|
+
z-index: -1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.hdk-advanced-surface--cascade-rich::after {
|
|
92
|
+
content: '';
|
|
93
|
+
position: absolute;
|
|
94
|
+
inset: -150%;
|
|
95
|
+
background: linear-gradient(
|
|
96
|
+
135deg,
|
|
97
|
+
transparent 0%,
|
|
98
|
+
transparent 22%,
|
|
99
|
+
rgba(255, 255, 255, 0.05) 26%,
|
|
100
|
+
transparent 30%,
|
|
101
|
+
transparent 40%,
|
|
102
|
+
rgba(255, 255, 255, 0.12) 45%,
|
|
103
|
+
rgba(255, 255, 255, 0.18) 48%,
|
|
104
|
+
rgba(255, 255, 255, 0.12) 51%,
|
|
105
|
+
transparent 56%,
|
|
106
|
+
transparent 66%,
|
|
107
|
+
rgba(255, 255, 255, 0.06) 70%,
|
|
108
|
+
transparent 74%,
|
|
109
|
+
transparent 100%
|
|
110
|
+
);
|
|
111
|
+
transform: translateX(-45%) translateY(-45%);
|
|
112
|
+
opacity: 0;
|
|
113
|
+
transition:
|
|
114
|
+
transform 4s cubic-bezier(0.22, 0.61, 0.36, 1) 0.1s,
|
|
115
|
+
opacity 0.5s ease 0.1s;
|
|
116
|
+
pointer-events: none;
|
|
117
|
+
z-index: -1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.hdk-advanced-surface--cascade-rich:hover::before {
|
|
121
|
+
transform: translateX(50%) translateY(50%);
|
|
122
|
+
opacity: 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.hdk-advanced-surface--cascade-rich:hover::after {
|
|
126
|
+
transform: translateX(45%) translateY(45%);
|
|
127
|
+
opacity: 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---------- Effect: SAT-RICH — saturation boost + 4-band cascade ---------- */
|
|
131
|
+
|
|
132
|
+
.hdk-advanced-surface--sat-rich {
|
|
133
|
+
filter: saturate(1) brightness(1);
|
|
134
|
+
transition:
|
|
135
|
+
filter 0.6s ease-out,
|
|
136
|
+
box-shadow 0.6s ease-out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.hdk-advanced-surface--sat-rich::after {
|
|
140
|
+
content: '';
|
|
141
|
+
position: absolute;
|
|
142
|
+
inset: -100%;
|
|
143
|
+
background: linear-gradient(
|
|
144
|
+
135deg,
|
|
145
|
+
transparent 20%,
|
|
146
|
+
rgba(255, 255, 255, 0.03) 30%,
|
|
147
|
+
transparent 38%,
|
|
148
|
+
transparent 44%,
|
|
149
|
+
rgba(255, 255, 255, 0.1) 50%,
|
|
150
|
+
rgba(255, 255, 255, 0.16) 52%,
|
|
151
|
+
rgba(255, 255, 255, 0.1) 54%,
|
|
152
|
+
transparent 60%,
|
|
153
|
+
transparent 70%,
|
|
154
|
+
rgba(255, 255, 255, 0.04) 78%,
|
|
155
|
+
transparent 86%
|
|
156
|
+
);
|
|
157
|
+
transform: translateX(-50%) translateY(-50%);
|
|
158
|
+
opacity: 0;
|
|
159
|
+
transition:
|
|
160
|
+
transform 3s ease-out,
|
|
161
|
+
opacity 0.6s ease-out;
|
|
162
|
+
pointer-events: none;
|
|
163
|
+
z-index: -1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.hdk-advanced-surface--sat-rich:hover {
|
|
167
|
+
filter: saturate(1.15) brightness(1.05);
|
|
168
|
+
box-shadow:
|
|
169
|
+
0 4px 16px rgba(0, 0, 0, 0.1),
|
|
170
|
+
inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.hdk-advanced-surface--sat-rich:hover::after {
|
|
174
|
+
transform: translateX(50%) translateY(50%);
|
|
175
|
+
opacity: 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---------- Simple mode override ---------- */
|
|
179
|
+
/* Forces flat colors regardless of theme. The selectors target
|
|
180
|
+
common scope roots so the override applies whether the attribute
|
|
181
|
+
is set on documentElement, body, or a microfrontend container. */
|
|
182
|
+
|
|
183
|
+
[data-theme-mode='simple'] .hdk-advanced-page,
|
|
184
|
+
[data-theme-mode='simple'].hdk-advanced-page {
|
|
185
|
+
background: var(--color-bg);
|
|
186
|
+
background-attachment: scroll;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
[data-theme-mode='simple'] .hdk-advanced-surface,
|
|
190
|
+
[data-theme-mode='simple'].hdk-advanced-surface {
|
|
191
|
+
background: var(--color-bg-card);
|
|
192
|
+
background-attachment: scroll;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
[data-theme-mode='simple'] .hdk-advanced-surface::before,
|
|
196
|
+
[data-theme-mode='simple'] .hdk-advanced-surface::after,
|
|
197
|
+
[data-theme-mode='simple'].hdk-advanced-surface::before,
|
|
198
|
+
[data-theme-mode='simple'].hdk-advanced-surface::after {
|
|
199
|
+
display: none;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
[data-theme-mode='simple'] .hdk-advanced-surface--sat-rich:hover,
|
|
203
|
+
[data-theme-mode='simple'].hdk-advanced-surface--sat-rich:hover {
|
|
204
|
+
filter: none;
|
|
205
|
+
box-shadow: none;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ---------- Reduced motion ---------- */
|
|
209
|
+
|
|
210
|
+
@media (prefers-reduced-motion: reduce) {
|
|
211
|
+
.hdk-advanced-surface--shift::after,
|
|
212
|
+
.hdk-advanced-surface--cascade-rich::before,
|
|
213
|
+
.hdk-advanced-surface--cascade-rich::after,
|
|
214
|
+
.hdk-advanced-surface--sat-rich,
|
|
215
|
+
.hdk-advanced-surface--sat-rich::after {
|
|
216
|
+
transition: none;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React `<Icon>` — a thin wrapper over the string registry.
|
|
3
|
+
*
|
|
4
|
+
* The glyph is emitted as real JSX children rather than dangerouslySetInnerHTML: the
|
|
5
|
+
* markup is ours and build-time-generated, but React apps run under the same strict
|
|
6
|
+
* CSP as the frontpage, and parsing 78 vendored strings at runtime buys nothing when
|
|
7
|
+
* the shapes can be elements. So the paths are parsed once, lazily, per icon actually
|
|
8
|
+
* rendered — an app that uses six icons never touches the other seventy-two.
|
|
9
|
+
*/
|
|
10
|
+
import React from 'react'
|
|
11
|
+
import { ICON_MARKUP } from './registry.generated'
|
|
12
|
+
import type { IconName } from './registry.generated'
|
|
13
|
+
import type { IconFamily, IconVariant } from './index'
|
|
14
|
+
|
|
15
|
+
export interface IconProps {
|
|
16
|
+
name: IconName
|
|
17
|
+
/** CSS length. Defaults to `1em` so the glyph scales with surrounding text. */
|
|
18
|
+
size?: string | number
|
|
19
|
+
/**
|
|
20
|
+
* Which family token colours this icon. Applies to `accent` (the glyph itself)
|
|
21
|
+
* and to `tint`/`filled` (the tile). Ignored for `bare`, which inherits
|
|
22
|
+
* `currentColor` by design.
|
|
23
|
+
*/
|
|
24
|
+
family?: IconFamily
|
|
25
|
+
/** `bare` (default), `accent`, `tint`, or `filled` — see IconVariant. */
|
|
26
|
+
variant?: IconVariant
|
|
27
|
+
/** Round the accent tile. No effect when `variant` is `bare`. */
|
|
28
|
+
round?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Accessible name. Omit for decorative icons — those render `aria-hidden` so a
|
|
31
|
+
* screen reader does not announce a glyph that repeats an adjacent text label.
|
|
32
|
+
*/
|
|
33
|
+
title?: string
|
|
34
|
+
className?: string
|
|
35
|
+
/** Stroke width in the 24x24 viewBox. Lucide's default is 2. */
|
|
36
|
+
strokeWidth?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `<path d="..." />` etc. -> React elements. Parsed once per icon, then cached. */
|
|
40
|
+
const parsed = new Map<IconName, React.ReactNode[]>()
|
|
41
|
+
|
|
42
|
+
const SELF_CLOSING = /<([a-z]+)\s+([^>]*?)\/>/g
|
|
43
|
+
const ATTR = /([a-z-]+)="([^"]*)"/g
|
|
44
|
+
|
|
45
|
+
/** SVG presentation attributes we emit, mapped to their React prop names. */
|
|
46
|
+
const PROP_NAMES: Record<string, string> = {
|
|
47
|
+
d: 'd',
|
|
48
|
+
cx: 'cx',
|
|
49
|
+
cy: 'cy',
|
|
50
|
+
r: 'r',
|
|
51
|
+
x: 'x',
|
|
52
|
+
y: 'y',
|
|
53
|
+
x1: 'x1',
|
|
54
|
+
x2: 'x2',
|
|
55
|
+
y1: 'y1',
|
|
56
|
+
y2: 'y2',
|
|
57
|
+
rx: 'rx',
|
|
58
|
+
ry: 'ry',
|
|
59
|
+
width: 'width',
|
|
60
|
+
height: 'height',
|
|
61
|
+
points: 'points',
|
|
62
|
+
transform: 'transform',
|
|
63
|
+
fill: 'fill'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseMarkup(name: IconName): React.ReactNode[] {
|
|
67
|
+
const cached = parsed.get(name)
|
|
68
|
+
if (cached) return cached
|
|
69
|
+
|
|
70
|
+
const nodes: React.ReactNode[] = []
|
|
71
|
+
const markup = ICON_MARKUP[name]
|
|
72
|
+
let match: RegExpExecArray | null
|
|
73
|
+
SELF_CLOSING.lastIndex = 0
|
|
74
|
+
let i = 0
|
|
75
|
+
while ((match = SELF_CLOSING.exec(markup)) !== null) {
|
|
76
|
+
const [, tag, attrString] = match
|
|
77
|
+
const props: Record<string, string> = { key: String(i++) }
|
|
78
|
+
let attr: RegExpExecArray | null
|
|
79
|
+
ATTR.lastIndex = 0
|
|
80
|
+
while ((attr = ATTR.exec(attrString)) !== null) {
|
|
81
|
+
const prop = PROP_NAMES[attr[1]]
|
|
82
|
+
if (prop) props[prop] = attr[2]
|
|
83
|
+
}
|
|
84
|
+
nodes.push(React.createElement(tag, props))
|
|
85
|
+
}
|
|
86
|
+
parsed.set(name, nodes)
|
|
87
|
+
return nodes
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const Icon: React.FC<IconProps> = ({
|
|
91
|
+
name,
|
|
92
|
+
size = '1em',
|
|
93
|
+
family = 'warning',
|
|
94
|
+
variant = 'bare',
|
|
95
|
+
round = false,
|
|
96
|
+
title,
|
|
97
|
+
className,
|
|
98
|
+
strokeWidth = 2
|
|
99
|
+
}) => {
|
|
100
|
+
if (ICON_MARKUP[name] === undefined) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`<Icon>: "${String(name)}" is not in the icon registry. ` +
|
|
103
|
+
`Add it to themes/src/icons/sources.json and run \`pnpm run generate:icons\`.`
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const dim = typeof size === 'number' ? `${size}px` : size
|
|
108
|
+
const a11y = title
|
|
109
|
+
? ({ role: 'img', 'aria-label': title } as const)
|
|
110
|
+
: ({ 'aria-hidden': true, focusable: 'false' } as const)
|
|
111
|
+
|
|
112
|
+
// `bare` and `accent` render the <svg> as the outermost element, so the caller's
|
|
113
|
+
// className belongs on it. `tint`/`filled` wrap it in a tile, and the className
|
|
114
|
+
// goes there instead — in both cases it lands on the element you get back.
|
|
115
|
+
const isTile = variant === 'tint' || variant === 'filled'
|
|
116
|
+
const svgClass = [
|
|
117
|
+
'hdk-icon',
|
|
118
|
+
variant === 'accent' ? `hdk-icon--${family}` : '',
|
|
119
|
+
!isTile && className ? className : ''
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join(' ')
|
|
123
|
+
|
|
124
|
+
const svg = (
|
|
125
|
+
<svg
|
|
126
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
127
|
+
className={svgClass}
|
|
128
|
+
width={dim}
|
|
129
|
+
height={dim}
|
|
130
|
+
viewBox="0 0 24 24"
|
|
131
|
+
fill="none"
|
|
132
|
+
stroke="currentColor"
|
|
133
|
+
strokeWidth={strokeWidth}
|
|
134
|
+
strokeLinecap="round"
|
|
135
|
+
strokeLinejoin="round"
|
|
136
|
+
{...a11y}
|
|
137
|
+
>
|
|
138
|
+
{title ? <title>{title}</title> : null}
|
|
139
|
+
{parseMarkup(name)}
|
|
140
|
+
</svg>
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if (!isTile) return svg
|
|
144
|
+
|
|
145
|
+
const tile =
|
|
146
|
+
`hdk-icon-tile hdk-icon-tile--${variant} hdk-icon-tile--${family}` +
|
|
147
|
+
(round ? ' hdk-icon-tile--round' : '') +
|
|
148
|
+
(className ? ` ${className}` : '')
|
|
149
|
+
|
|
150
|
+
return <span className={tile}>{svg}</span>
|
|
151
|
+
}
|