@nebutra/tokens 0.1.0 → 0.1.2
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/.turbo/turbo-build.log +8 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/AGENTS.md +5 -2
- package/CHANGELOG.md +33 -0
- package/DESIGN.md +30 -6
- package/LICENSE +21 -676
- package/README.md +41 -8
- package/brands/gsap/brand.json +180 -0
- package/brands/linear/brand.json +202 -0
- package/brands/notion/brand.json +189 -0
- package/brands/raycast/brand.json +167 -0
- package/brands/stripe/brand.json +183 -0
- package/brands/vanta/brand.json +198 -0
- package/brands/vercel/brand.json +207 -0
- package/package.json +18 -9
- package/recipe.css +114 -0
- package/scripts/compile-brand-run.ts +79 -0
- package/scripts/compile-brand.mjs +25 -0
- package/scripts/emit-skins-run.ts +62 -0
- package/scripts/emit-skins.mjs +31 -0
- package/scripts/sync-styles.mjs +29 -0
- package/skins/README.md +151 -0
- package/skins/gsap.css +247 -0
- package/skins/linear.css +282 -0
- package/skins/notion.css +248 -0
- package/skins/raycast.css +272 -0
- package/skins/stripe.css +248 -0
- package/skins/vanta.css +255 -0
- package/skins/vercel.css +283 -0
- package/src/brand-package/__tests__/compile-refero.test.ts +427 -0
- package/src/brand-package/__tests__/emit-css.test.ts +130 -0
- package/src/brand-package/apply-brand.ts +92 -0
- package/src/brand-package/compile-helpers.ts +170 -0
- package/src/brand-package/compile-refero.ts +69 -0
- package/src/brand-package/emit-css.ts +468 -0
- package/src/brand-package/hex-to-hsl.ts +100 -0
- package/src/brand-package/index.ts +71 -0
- package/src/brand-package/infer-recipe.ts +198 -0
- package/src/brand-package/normalize.ts +295 -0
- package/src/brand-package/presets/context.ts +17 -0
- package/src/brand-package/presets/generic.ts +214 -0
- package/src/brand-package/presets/gsap.ts +146 -0
- package/src/brand-package/presets/index.ts +31 -0
- package/src/brand-package/presets/linear.ts +183 -0
- package/src/brand-package/presets/notion.ts +208 -0
- package/src/brand-package/presets/raycast.ts +138 -0
- package/src/brand-package/presets/recipe.ts +61 -0
- package/src/brand-package/presets/stripe.ts +207 -0
- package/src/brand-package/presets/vanta.ts +220 -0
- package/src/brand-package/presets/vercel.ts +187 -0
- package/src/brand-package/types.ts +285 -0
- package/src/brand-package/use-brand.ts +207 -0
- package/src/brand-package/validate.ts +108 -0
- package/src/index.ts +31 -1
- package/src/theme-provider.tsx +21 -6
- package/styles.css +157 -79
- package/turbo.json +15 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit carrier CSS from a normalized Brand Package.
|
|
3
|
+
* Components bind: --primary (= action), --brand-mark, --elevation-*, --radius-*.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isDualModeBrand, normalizeBrandPackage } from "./normalize";
|
|
7
|
+
import type {
|
|
8
|
+
BrandColorRoles,
|
|
9
|
+
BrandFontFace,
|
|
10
|
+
BrandPackage,
|
|
11
|
+
BrandRecipe,
|
|
12
|
+
BrandSemanticColors,
|
|
13
|
+
BrandTypeStep,
|
|
14
|
+
BrandZones,
|
|
15
|
+
BrandZoneTypography,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
/** Biome CSS formatter prefers double-quoted font families over single quotes. */
|
|
19
|
+
function cssFontStack(stack: string): string {
|
|
20
|
+
return stack.replace(/'/g, '"');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function recipeVars(recipe: BrandRecipe): string[] {
|
|
24
|
+
const radii = recipe.radii;
|
|
25
|
+
const elev = recipe.elevationTokens;
|
|
26
|
+
|
|
27
|
+
const lines: string[] = [
|
|
28
|
+
` /* Shape slots */`,
|
|
29
|
+
` --btn-default-radius: ${radii.button};`,
|
|
30
|
+
` --radius-button: ${radii.button};`,
|
|
31
|
+
` --radius-buttons: ${radii.button};`,
|
|
32
|
+
` --radius-md: ${radii.button};`,
|
|
33
|
+
` --radius-card: ${radii.card};`,
|
|
34
|
+
` --radius-lg: ${radii.card};`,
|
|
35
|
+
` --radius-badge: ${radii.badge ?? "9999px"};`,
|
|
36
|
+
` --badge-default-radius: ${radii.badge ?? "9999px"};`,
|
|
37
|
+
` --radius-inputs: ${radii.input ?? radii.button};`,
|
|
38
|
+
` --input-radius: ${radii.input ?? radii.button};`,
|
|
39
|
+
` --radius-pill: ${radii.pill ?? "9999px"};`,
|
|
40
|
+
``,
|
|
41
|
+
` /* Free elevation (carrier-provided CSS shadows) */`,
|
|
42
|
+
` --elevation-card: ${elev.card};`,
|
|
43
|
+
` --elevation-control: ${elev.control ?? "0 0 #0000"};`,
|
|
44
|
+
` --elevation-raised: ${elev.raised ?? elev.card};`,
|
|
45
|
+
// Kill Tailwind drop defaults when brand uses custom elev stacks
|
|
46
|
+
` --shadow-xs: ${elev.control ?? "0 0 #0000"};`,
|
|
47
|
+
` --shadow-sm: ${elev.card};`,
|
|
48
|
+
` --shadow-md: ${elev.raised ?? elev.card};`,
|
|
49
|
+
` --shadow-lg: ${elev.raised ?? elev.card};`,
|
|
50
|
+
` --btn-default-shadow: 0 0 #0000;`,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
switch (recipe.buttonDefault) {
|
|
54
|
+
case "outline": {
|
|
55
|
+
const edge = recipe.outlineBorder ?? "hsl(var(--foreground))";
|
|
56
|
+
lines.push(" --btn-default-bg: transparent;");
|
|
57
|
+
lines.push(" --btn-default-fg: hsl(var(--foreground));");
|
|
58
|
+
lines.push(" --btn-default-border-width: 1px;");
|
|
59
|
+
lines.push(" --btn-default-border: transparent;");
|
|
60
|
+
lines.push(` --btn-default-stroke-gradient: linear-gradient(${edge}, ${edge});`);
|
|
61
|
+
lines.push(" --btn-default-hover-bg: hsl(var(--foreground) / 0.06);");
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "gradient-stroke": {
|
|
65
|
+
const grad =
|
|
66
|
+
recipe.primaryStrokeGradient ??
|
|
67
|
+
"linear-gradient(135deg, hsl(var(--primary)), color-mix(in srgb, hsl(var(--primary)) 55%, white))";
|
|
68
|
+
lines.push(" --btn-default-bg: transparent;");
|
|
69
|
+
lines.push(" --btn-default-fg: hsl(var(--foreground));");
|
|
70
|
+
lines.push(" --btn-default-border-width: 1.5px;");
|
|
71
|
+
lines.push(" --btn-default-border: transparent;");
|
|
72
|
+
lines.push(` --btn-default-stroke-gradient: ${grad};`);
|
|
73
|
+
lines.push(" --btn-default-hover-bg: hsl(var(--primary) / 0.08);");
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
default:
|
|
77
|
+
lines.push(" --btn-default-bg: hsl(var(--primary));");
|
|
78
|
+
lines.push(" --btn-default-fg: hsl(var(--primary-foreground));");
|
|
79
|
+
lines.push(" --btn-default-border-width: 0px;");
|
|
80
|
+
lines.push(" --btn-default-border: transparent;");
|
|
81
|
+
lines.push(" --btn-default-stroke-gradient: transparent;");
|
|
82
|
+
lines.push(
|
|
83
|
+
" --btn-default-hover-bg: color-mix(in srgb, hsl(var(--primary)) 90%, transparent);",
|
|
84
|
+
);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const badgeMode = recipe.badgeDefault ?? "match-action";
|
|
89
|
+
if (badgeMode === "outline") {
|
|
90
|
+
const edge = recipe.outlineBorder ?? "hsl(var(--border))";
|
|
91
|
+
lines.push(" --badge-default-bg: transparent;");
|
|
92
|
+
lines.push(" --badge-default-fg: hsl(var(--foreground));");
|
|
93
|
+
lines.push(` --badge-default-border: ${edge};`);
|
|
94
|
+
lines.push(" --badge-default-hover-bg: hsl(var(--foreground) / 0.06);");
|
|
95
|
+
} else if (badgeMode === "muted") {
|
|
96
|
+
lines.push(" --badge-default-bg: hsl(var(--secondary));");
|
|
97
|
+
lines.push(" --badge-default-fg: hsl(var(--secondary-foreground));");
|
|
98
|
+
lines.push(" --badge-default-border: transparent;");
|
|
99
|
+
lines.push(" --badge-default-hover-bg: color-mix(in srgb, hsl(var(--secondary)) 90%, white);");
|
|
100
|
+
} else if (badgeMode === "brand") {
|
|
101
|
+
lines.push(" --badge-default-bg: hsl(var(--brand-mark, var(--accent)));");
|
|
102
|
+
lines.push(
|
|
103
|
+
" --badge-default-fg: hsl(var(--brand-mark-foreground, var(--accent-foreground)));",
|
|
104
|
+
);
|
|
105
|
+
lines.push(" --badge-default-border: transparent;");
|
|
106
|
+
lines.push(
|
|
107
|
+
" --badge-default-hover-bg: color-mix(in srgb, hsl(var(--brand-mark, var(--accent))) 85%, transparent);",
|
|
108
|
+
);
|
|
109
|
+
} else if (recipe.buttonDefault === "outline" || recipe.buttonDefault === "gradient-stroke") {
|
|
110
|
+
const edge = recipe.outlineBorder ?? "hsl(var(--foreground))";
|
|
111
|
+
lines.push(" --badge-default-bg: transparent;");
|
|
112
|
+
lines.push(" --badge-default-fg: hsl(var(--foreground));");
|
|
113
|
+
lines.push(` --badge-default-border: ${edge};`);
|
|
114
|
+
lines.push(" --badge-default-hover-bg: hsl(var(--foreground) / 0.06);");
|
|
115
|
+
} else {
|
|
116
|
+
// match-action
|
|
117
|
+
lines.push(" --badge-default-bg: hsl(var(--primary));");
|
|
118
|
+
lines.push(" --badge-default-fg: hsl(var(--primary-foreground));");
|
|
119
|
+
lines.push(" --badge-default-border: transparent;");
|
|
120
|
+
lines.push(
|
|
121
|
+
" --badge-default-hover-bg: color-mix(in srgb, hsl(var(--primary)) 80%, transparent);",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (recipe.density === "compact") {
|
|
126
|
+
lines.push(" --btn-default-padding-y: 0.5rem;");
|
|
127
|
+
lines.push(" --btn-default-padding-x: 0.875rem;");
|
|
128
|
+
lines.push(" --control-height-tiny: 1.25rem;");
|
|
129
|
+
lines.push(" --control-height-sm: 1.75rem;");
|
|
130
|
+
lines.push(" --control-height-md: 2rem;");
|
|
131
|
+
lines.push(" --control-height-lg: 2.5rem;");
|
|
132
|
+
lines.push(" --control-font-size-md: 0.8125rem;");
|
|
133
|
+
} else if (recipe.density === "spacious") {
|
|
134
|
+
lines.push(" --btn-default-padding-y: 0.875rem;");
|
|
135
|
+
lines.push(" --btn-default-padding-x: 1.5rem;");
|
|
136
|
+
lines.push(" --control-height-tiny: 1.75rem;");
|
|
137
|
+
lines.push(" --control-height-sm: 2.25rem;");
|
|
138
|
+
lines.push(" --control-height-md: 2.75rem;");
|
|
139
|
+
lines.push(" --control-height-lg: 3.25rem;");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function emitFontFaces(faces: BrandFontFace[] | undefined): string[] {
|
|
146
|
+
if (!faces?.length) return [];
|
|
147
|
+
const out: string[] = ["/* Brand font faces */"];
|
|
148
|
+
for (const face of faces) {
|
|
149
|
+
const src = face.src
|
|
150
|
+
.map((s) => {
|
|
151
|
+
const fmt = s.format ? ` format("${s.format}")` : "";
|
|
152
|
+
return `url("${s.url}")${fmt}`;
|
|
153
|
+
})
|
|
154
|
+
.join(", ");
|
|
155
|
+
out.push("@font-face {");
|
|
156
|
+
out.push(` font-family: "${face.family}";`);
|
|
157
|
+
out.push(` src: ${src};`);
|
|
158
|
+
if (face.weight != null) out.push(` font-weight: ${face.weight};`);
|
|
159
|
+
if (face.style) out.push(` font-style: ${face.style};`);
|
|
160
|
+
out.push(` font-display: ${face.display ?? "swap"};`);
|
|
161
|
+
if (face.unicodeRange) out.push(` unicode-range: ${face.unicodeRange};`);
|
|
162
|
+
out.push("}");
|
|
163
|
+
out.push("");
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stepVars(prefix: string, step: BrandTypeStep | undefined, role: string): string[] {
|
|
169
|
+
if (!step) return [];
|
|
170
|
+
const lines = [` --${prefix}-${role}-size: ${step.fontSize};`];
|
|
171
|
+
if (step.lineHeight != null) lines.push(` --${prefix}-${role}-leading: ${step.lineHeight};`);
|
|
172
|
+
if (step.letterSpacing != null) {
|
|
173
|
+
lines.push(` --${prefix}-${role}-tracking: ${step.letterSpacing};`);
|
|
174
|
+
}
|
|
175
|
+
if (step.fontWeight != null) lines.push(` --${prefix}-${role}-weight: ${step.fontWeight};`);
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function zoneScaleVars(
|
|
180
|
+
zone: "product" | "marketing",
|
|
181
|
+
scale: BrandZoneTypography | undefined,
|
|
182
|
+
): string[] {
|
|
183
|
+
if (!scale) return [];
|
|
184
|
+
const p = `zone-${zone}`;
|
|
185
|
+
return [
|
|
186
|
+
...stepVars(p, scale.caption, "caption"),
|
|
187
|
+
...stepVars(p, scale.bodySm, "body-sm"),
|
|
188
|
+
...stepVars(p, scale.body, "body"),
|
|
189
|
+
...stepVars(p, scale.bodyLg, "body-lg"),
|
|
190
|
+
...stepVars(p, scale.subheading, "subheading"),
|
|
191
|
+
...stepVars(p, scale.headingSm, "heading-sm"),
|
|
192
|
+
...stepVars(p, scale.heading, "heading"),
|
|
193
|
+
...stepVars(p, scale.headingLg, "heading-lg"),
|
|
194
|
+
...stepVars(p, scale.display, "display"),
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function zoneConsumerBlock(
|
|
199
|
+
zone: "product" | "marketing",
|
|
200
|
+
scale: BrandZoneTypography | undefined,
|
|
201
|
+
): string[] {
|
|
202
|
+
if (!scale) return [];
|
|
203
|
+
const p = `zone-${zone}`;
|
|
204
|
+
const body = scale.body;
|
|
205
|
+
const lines = [
|
|
206
|
+
``,
|
|
207
|
+
`/* Zone: ${zone} — product never inherits marketing display */`,
|
|
208
|
+
// Multi-line selector list matches Biome CSS formatter output (CI governance).
|
|
209
|
+
`[data-zone="${zone}"],`,
|
|
210
|
+
`.zone-${zone} {`,
|
|
211
|
+
];
|
|
212
|
+
if (body?.fontSize) lines.push(` font-size: var(--${p}-body-size, ${body.fontSize});`);
|
|
213
|
+
if (body?.lineHeight != null) {
|
|
214
|
+
lines.push(` line-height: var(--${p}-body-leading, ${body.lineHeight});`);
|
|
215
|
+
}
|
|
216
|
+
if (body?.fontWeight != null) {
|
|
217
|
+
lines.push(` font-weight: var(--${p}-body-weight, ${body.fontWeight});`);
|
|
218
|
+
}
|
|
219
|
+
if (body?.letterSpacing != null) {
|
|
220
|
+
lines.push(` letter-spacing: var(--${p}-body-tracking, ${body.letterSpacing});`);
|
|
221
|
+
}
|
|
222
|
+
const roles = [
|
|
223
|
+
"caption",
|
|
224
|
+
"body-sm",
|
|
225
|
+
"body",
|
|
226
|
+
"body-lg",
|
|
227
|
+
"subheading",
|
|
228
|
+
"heading-sm",
|
|
229
|
+
"heading",
|
|
230
|
+
"heading-lg",
|
|
231
|
+
"display",
|
|
232
|
+
] as const;
|
|
233
|
+
for (const role of roles) {
|
|
234
|
+
// Product zone hard-cap: display falls back to heading if product display is unset.
|
|
235
|
+
// Emit once — a second --text-display trips biome noDuplicateCustomProperties.
|
|
236
|
+
if (zone === "product" && role === "display") {
|
|
237
|
+
lines.push(` --text-display: var(--${p}-display-size, var(--${p}-heading-size, inherit));`);
|
|
238
|
+
} else {
|
|
239
|
+
lines.push(` --text-${role}: var(--${p}-${role}-size, inherit);`);
|
|
240
|
+
}
|
|
241
|
+
lines.push(` --leading-${role}: var(--${p}-${role}-leading, inherit);`);
|
|
242
|
+
lines.push(` --tracking-${role}: var(--${p}-${role}-tracking, inherit);`);
|
|
243
|
+
}
|
|
244
|
+
lines.push(`}`);
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function emitZones(zones: BrandZones | undefined): string[] {
|
|
249
|
+
if (!zones) return [];
|
|
250
|
+
return [
|
|
251
|
+
``,
|
|
252
|
+
` /* Zone type scales */`,
|
|
253
|
+
...zoneScaleVars("product", zones.product),
|
|
254
|
+
...zoneScaleVars("marketing", zones.marketing),
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type EmitBrandCssMode =
|
|
259
|
+
/** Single-skin import / Create Center inject — also binds :root (global swap) */
|
|
260
|
+
| "global"
|
|
261
|
+
/** Multi-language catalog — only activates under html[data-brand] */
|
|
262
|
+
| "scoped";
|
|
263
|
+
|
|
264
|
+
export interface EmitBrandCssOptions {
|
|
265
|
+
/**
|
|
266
|
+
* `global` (default): `:root` + `html[data-brand]` — one import recolors the app.
|
|
267
|
+
* Single-mode dark packs also include `.dark`.
|
|
268
|
+
* Dual-mode packs (`modes.light` + `modes.dark`) emit separate light/dark color blocks.
|
|
269
|
+
* `scoped`: only `html[data-brand]` (+ `html.dark[data-brand]` when dual).
|
|
270
|
+
*/
|
|
271
|
+
mode?: EmitBrandCssMode;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Global single-skin selector list — single-mode packs only. */
|
|
275
|
+
export function emitGlobalSkinSelector(brandId: string, darkDefault: boolean): string {
|
|
276
|
+
if (darkDefault) {
|
|
277
|
+
return `:root,\n.dark,\nhtml[data-brand="${brandId}"] {`;
|
|
278
|
+
}
|
|
279
|
+
return `:root,\nhtml[data-brand="${brandId}"] {`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Light mode selector (dual-mode). */
|
|
283
|
+
export function emitLightModeSelector(brandId: string, mode: EmitBrandCssMode): string {
|
|
284
|
+
if (mode === "scoped") return `html[data-brand="${brandId}"] {`;
|
|
285
|
+
return `:root,\nhtml[data-brand="${brandId}"] {`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Dark mode selector (dual-mode) — never paints light colors under .dark. */
|
|
289
|
+
export function emitDarkModeSelector(brandId: string, mode: EmitBrandCssMode): string {
|
|
290
|
+
if (mode === "scoped") return `html.dark[data-brand="${brandId}"] {`;
|
|
291
|
+
return `.dark,\nhtml.dark[data-brand="${brandId}"] {`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function emitColorVars(s: BrandSemanticColors, r: BrandColorRoles | undefined): string[] {
|
|
295
|
+
const roleLines: string[] = [
|
|
296
|
+
` /* ── Color roles (carrier) ── */`,
|
|
297
|
+
` --role-canvas: ${r?.canvas ?? s.background};`,
|
|
298
|
+
` --role-canvas-fg: ${r?.canvasForeground ?? s.foreground};`,
|
|
299
|
+
` --role-surface: ${r?.surface ?? s.card};`,
|
|
300
|
+
` --role-surface-fg: ${r?.surfaceForeground ?? s.cardForeground};`,
|
|
301
|
+
` --role-action: ${r?.action ?? s.primary};`,
|
|
302
|
+
` --role-action-fg: ${r?.actionForeground ?? s.primaryForeground};`,
|
|
303
|
+
` --role-quiet: ${r?.quiet ?? s.secondary};`,
|
|
304
|
+
` --role-quiet-fg: ${r?.quietForeground ?? s.secondaryForeground};`,
|
|
305
|
+
` --role-muted: ${r?.muted ?? s.muted};`,
|
|
306
|
+
` --role-muted-fg: ${r?.mutedForeground ?? s.mutedForeground};`,
|
|
307
|
+
` --role-border: ${r?.border ?? s.border};`,
|
|
308
|
+
` --role-input: ${r?.input ?? s.input};`,
|
|
309
|
+
` --role-ring: ${r?.ring ?? s.ring};`,
|
|
310
|
+
];
|
|
311
|
+
if (r?.brand) {
|
|
312
|
+
roleLines.push(` --role-brand: ${r.brand};`);
|
|
313
|
+
roleLines.push(
|
|
314
|
+
` --role-brand-fg: ${r.brandForeground ?? r.actionForeground ?? s.primaryForeground};`,
|
|
315
|
+
);
|
|
316
|
+
roleLines.push(` --brand-mark: ${r.brand};`);
|
|
317
|
+
roleLines.push(
|
|
318
|
+
` --brand-mark-foreground: ${r.brandForeground ?? r.actionForeground ?? s.primaryForeground};`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const semantic = [
|
|
323
|
+
``,
|
|
324
|
+
` /* ── shadcn bridge (primary = action CTA) ── */`,
|
|
325
|
+
` --background: ${s.background};`,
|
|
326
|
+
` --foreground: ${s.foreground};`,
|
|
327
|
+
` --card: ${s.card};`,
|
|
328
|
+
` --card-foreground: ${s.cardForeground};`,
|
|
329
|
+
` --popover: ${s.popover};`,
|
|
330
|
+
` --popover-foreground: ${s.popoverForeground};`,
|
|
331
|
+
` --primary: ${s.primary};`,
|
|
332
|
+
` --primary-foreground: ${s.primaryForeground};`,
|
|
333
|
+
` --secondary: ${s.secondary};`,
|
|
334
|
+
` --secondary-foreground: ${s.secondaryForeground};`,
|
|
335
|
+
` --muted: ${s.muted};`,
|
|
336
|
+
` --muted-foreground: ${s.mutedForeground};`,
|
|
337
|
+
` --accent: ${s.accent};`,
|
|
338
|
+
` --accent-foreground: ${s.accentForeground};`,
|
|
339
|
+
` --destructive: ${s.destructive};`,
|
|
340
|
+
` --destructive-foreground: ${s.destructiveForeground};`,
|
|
341
|
+
` --border: ${s.border};`,
|
|
342
|
+
` --input: ${s.input};`,
|
|
343
|
+
` --ring: ${s.ring};`,
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
if (s.success) semantic.push(` --success: ${s.success};`);
|
|
347
|
+
if (s.successForeground) semantic.push(` --success-foreground: ${s.successForeground};`);
|
|
348
|
+
if (s.warning) semantic.push(` --warning: ${s.warning};`);
|
|
349
|
+
if (s.warningForeground) semantic.push(` --warning-foreground: ${s.warningForeground};`);
|
|
350
|
+
if (s.info) semantic.push(` --info: ${s.info};`);
|
|
351
|
+
if (s.infoForeground) semantic.push(` --info-foreground: ${s.infoForeground};`);
|
|
352
|
+
|
|
353
|
+
semantic.push(
|
|
354
|
+
` --sidebar: ${s.card};`,
|
|
355
|
+
` --sidebar-foreground: ${s.foreground};`,
|
|
356
|
+
` --sidebar-primary: ${s.primary};`,
|
|
357
|
+
` --sidebar-primary-foreground: ${s.primaryForeground};`,
|
|
358
|
+
` --sidebar-accent: ${s.accent};`,
|
|
359
|
+
` --sidebar-accent-foreground: ${s.accentForeground};`,
|
|
360
|
+
` --sidebar-border: ${s.border};`,
|
|
361
|
+
` --sidebar-ring: ${s.ring};`,
|
|
362
|
+
` --brand-gradient: hsl(var(--primary));`,
|
|
363
|
+
` --brand-gradient-reverse: hsl(var(--primary));`,
|
|
364
|
+
` --brand-gradient-vertical: hsl(var(--primary));`,
|
|
365
|
+
` --brand-gradient-radial: hsl(var(--primary));`,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return [...roleLines, ...semantic];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Emit a single opt-in skin CSS file from a Brand Package.
|
|
373
|
+
*/
|
|
374
|
+
export function emitBrandCss(brand: BrandPackage, options: EmitBrandCssOptions = {}): string {
|
|
375
|
+
const mode = options.mode ?? "global";
|
|
376
|
+
const b = normalizeBrandPackage(brand);
|
|
377
|
+
const t = b.typography;
|
|
378
|
+
const dual = isDualModeBrand(b);
|
|
379
|
+
|
|
380
|
+
const parts: string[] = [
|
|
381
|
+
`/**`,
|
|
382
|
+
` * Brand carrier skin: ${b.name} (${b.id}) v${b.version}`,
|
|
383
|
+
` * darkDefault=${b.darkDefault} dualMode=${dual} button=${b.recipe.buttonDefault}`,
|
|
384
|
+
` * fonts=${t.faces?.length ?? 0} zones=${b.zones ? "yes" : "no"} mode=${mode}`,
|
|
385
|
+
` * Contract: roles.action → --primary; roles.brand → --brand-mark (never default CTA)`,
|
|
386
|
+
` */`,
|
|
387
|
+
``,
|
|
388
|
+
...emitFontFaces(t.faces),
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
// Biome CSS formatter prefers double-quoted font families.
|
|
392
|
+
const fontSans = cssFontStack(t.fontSans);
|
|
393
|
+
const fontDisplay = cssFontStack(t.fontDisplay ?? t.fontSans);
|
|
394
|
+
const typeLines = [
|
|
395
|
+
` --font-sans: ${fontSans};`,
|
|
396
|
+
` --font-heading: ${fontDisplay};`,
|
|
397
|
+
` --font-display: ${fontDisplay};`,
|
|
398
|
+
];
|
|
399
|
+
if (t.fontMono) typeLines.push(` --font-mono: ${cssFontStack(t.fontMono)};`);
|
|
400
|
+
if (t.headingWeight != null) {
|
|
401
|
+
typeLines.push(` --font-weight-heading: ${t.headingWeight};`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const cats = b.extensions?.categories;
|
|
405
|
+
const decorative = b.extensions?.decorative;
|
|
406
|
+
const extLines: string[] = [];
|
|
407
|
+
if (cats) {
|
|
408
|
+
for (const [key, value] of Object.entries(cats)) {
|
|
409
|
+
const safe = key.replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
|
|
410
|
+
extLines.push(` --brand-category-${safe}: ${value};`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (decorative) {
|
|
414
|
+
for (const [key, value] of Object.entries(decorative)) {
|
|
415
|
+
const safe = key.replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
|
|
416
|
+
extLines.push(` --brand-decorative-${safe}: ${value};`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const sharedChrome = [
|
|
421
|
+
``,
|
|
422
|
+
` /* Recipe (action language + free elev/radii) */`,
|
|
423
|
+
...recipeVars(b.recipe),
|
|
424
|
+
``,
|
|
425
|
+
` /* Typography */`,
|
|
426
|
+
...typeLines,
|
|
427
|
+
...emitZones(b.zones),
|
|
428
|
+
...(extLines.length
|
|
429
|
+
? ["", " /* Taxonomy / decorative (not product CTA) */", ...extLines]
|
|
430
|
+
: []),
|
|
431
|
+
];
|
|
432
|
+
|
|
433
|
+
if (dual && b.modes?.light?.semantic && b.modes?.dark?.semantic) {
|
|
434
|
+
// Light block: colors + shared chrome
|
|
435
|
+
parts.push(emitLightModeSelector(b.id, mode));
|
|
436
|
+
parts.push(
|
|
437
|
+
...emitColorVars(b.modes.light.semantic, b.modes.light.roles),
|
|
438
|
+
...sharedChrome,
|
|
439
|
+
`}`,
|
|
440
|
+
``,
|
|
441
|
+
);
|
|
442
|
+
// Dark block: colors only (chrome inherits)
|
|
443
|
+
parts.push(emitDarkModeSelector(b.id, mode));
|
|
444
|
+
parts.push(...emitColorVars(b.modes.dark.semantic, b.modes.dark.roles), `}`, ``);
|
|
445
|
+
} else {
|
|
446
|
+
// Single-mode: one selector + full block
|
|
447
|
+
const selector =
|
|
448
|
+
mode === "scoped"
|
|
449
|
+
? `html[data-brand="${b.id}"] {`
|
|
450
|
+
: emitGlobalSkinSelector(b.id, b.darkDefault);
|
|
451
|
+
parts.push(selector);
|
|
452
|
+
parts.push(...emitColorVars(b.semantic, b.roles), ...sharedChrome, `}`, ``);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
parts.push(
|
|
456
|
+
...zoneConsumerBlock("product", b.zones?.product),
|
|
457
|
+
...zoneConsumerBlock("marketing", b.zones?.marketing),
|
|
458
|
+
``,
|
|
459
|
+
`/* Product chrome must not use decorative gradients as action fills */`,
|
|
460
|
+
`[data-zone="product"] .btn-brand-default,`,
|
|
461
|
+
`.zone-product .btn-brand-default {`,
|
|
462
|
+
` /* intentional no-op: documents contract; action uses --primary only */`,
|
|
463
|
+
`}`,
|
|
464
|
+
``,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
return parts.join("\n");
|
|
468
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Convert #rgb / #rrggbb to HSL channel triple "H S% L%" for shadcn-style vars. */
|
|
2
|
+
export function hexToHslChannels(hex: string): string {
|
|
3
|
+
let h = hex.trim().replace(/^#/, "");
|
|
4
|
+
if (h.length === 3) {
|
|
5
|
+
h = h
|
|
6
|
+
.split("")
|
|
7
|
+
.map((c) => c + c)
|
|
8
|
+
.join("");
|
|
9
|
+
}
|
|
10
|
+
if (h.length !== 6 || !/^[0-9a-fA-F]+$/.test(h)) {
|
|
11
|
+
throw new Error(`Invalid hex color: ${hex}`);
|
|
12
|
+
}
|
|
13
|
+
const r = Number.parseInt(h.slice(0, 2), 16) / 255;
|
|
14
|
+
const g = Number.parseInt(h.slice(2, 4), 16) / 255;
|
|
15
|
+
const b = Number.parseInt(h.slice(4, 6), 16) / 255;
|
|
16
|
+
return srgbToHslChannels(r, g, b);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function srgbToHslChannels(r: number, g: number, b: number): string {
|
|
20
|
+
const max = Math.max(r, g, b);
|
|
21
|
+
const min = Math.min(r, g, b);
|
|
22
|
+
const l = (max + min) / 2;
|
|
23
|
+
let s = 0;
|
|
24
|
+
let hue = 0;
|
|
25
|
+
if (max !== min) {
|
|
26
|
+
const d = max - min;
|
|
27
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
28
|
+
switch (max) {
|
|
29
|
+
case r:
|
|
30
|
+
hue = (g - b) / d + (g < b ? 6 : 0);
|
|
31
|
+
break;
|
|
32
|
+
case g:
|
|
33
|
+
hue = (b - r) / d + 2;
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
hue = (r - g) / d + 4;
|
|
37
|
+
}
|
|
38
|
+
hue /= 6;
|
|
39
|
+
}
|
|
40
|
+
const H = Math.round(hue * 360);
|
|
41
|
+
const S = Math.round(s * 100);
|
|
42
|
+
const L = Math.round(l * 100);
|
|
43
|
+
return `${H} ${S}% ${L}%`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Already a shadcn-style channel triple? e.g. "228 85% 56%" */
|
|
47
|
+
const HSL_CHANNELS_RE = /^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize any common color input to HSL channel triple "H S% L%".
|
|
51
|
+
* Accepts: #hex, "H S% L%", hsl()/hsla(), rgb()/rgba().
|
|
52
|
+
*/
|
|
53
|
+
export function colorToHslChannels(color: string): string {
|
|
54
|
+
const t = color.trim();
|
|
55
|
+
if (!t) throw new Error("Empty color");
|
|
56
|
+
|
|
57
|
+
const channels = t.match(HSL_CHANNELS_RE);
|
|
58
|
+
if (channels) {
|
|
59
|
+
return `${Math.round(Number(channels[1]))} ${Math.round(Number(channels[2]))}% ${Math.round(Number(channels[3]))}%`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Parse hsl()/rgb() without open-ended \s* quantifiers (ReDoS-safe).
|
|
63
|
+
const hslPrefix = t.match(/^hsla?\(/i);
|
|
64
|
+
if (hslPrefix) {
|
|
65
|
+
const inner = t.slice(hslPrefix[0].length).split(")")[0] ?? "";
|
|
66
|
+
const nums = inner.match(/[\d.]+/g) ?? [];
|
|
67
|
+
if (nums.length >= 3) {
|
|
68
|
+
return `${Math.round(Number(nums[0]))} ${Math.round(Number(nums[1]))}% ${Math.round(Number(nums[2]))}%`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rgbPrefix = t.match(/^rgba?\(/i);
|
|
73
|
+
if (rgbPrefix) {
|
|
74
|
+
const inner = t.slice(rgbPrefix[0].length).split(")")[0] ?? "";
|
|
75
|
+
const nums = inner.match(/[\d.]+/g) ?? [];
|
|
76
|
+
if (nums.length >= 3) {
|
|
77
|
+
return srgbToHslChannels(Number(nums[0]) / 255, Number(nums[1]) / 255, Number(nums[2]) / 255);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (t.startsWith("#") || /^[0-9a-fA-F]{3,8}$/.test(t)) {
|
|
82
|
+
return hexToHslChannels(t.startsWith("#") ? t : `#${t}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(`Unsupported color: ${color}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Prefer {@link colorToHslChannels}; hex-only name kept for call sites. */
|
|
89
|
+
export function tryHexToHsl(hex: string | undefined, fallback: string): string {
|
|
90
|
+
return tryColorToHsl(hex, fallback);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function tryColorToHsl(color: string | undefined, fallback: string): string {
|
|
94
|
+
if (!color) return fallback;
|
|
95
|
+
try {
|
|
96
|
+
return colorToHslChannels(color);
|
|
97
|
+
} catch {
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ApplyBrandOptions,
|
|
3
|
+
applyBrandCss,
|
|
4
|
+
applyBrandPackage,
|
|
5
|
+
BRAND_STORAGE_KEY,
|
|
6
|
+
BRAND_STYLE_ELEMENT_ID,
|
|
7
|
+
clearBrand,
|
|
8
|
+
getActiveBrandId,
|
|
9
|
+
restorePersistedBrand,
|
|
10
|
+
} from "./apply-brand";
|
|
11
|
+
export { compileReferoTokens } from "./compile-refero";
|
|
12
|
+
export {
|
|
13
|
+
type EmitBrandCssMode,
|
|
14
|
+
type EmitBrandCssOptions,
|
|
15
|
+
emitBrandCss,
|
|
16
|
+
emitDarkModeSelector,
|
|
17
|
+
emitGlobalSkinSelector,
|
|
18
|
+
emitLightModeSelector,
|
|
19
|
+
} from "./emit-css";
|
|
20
|
+
export {
|
|
21
|
+
colorToHslChannels,
|
|
22
|
+
hexToHslChannels,
|
|
23
|
+
tryColorToHsl,
|
|
24
|
+
tryHexToHsl,
|
|
25
|
+
} from "./hex-to-hsl";
|
|
26
|
+
export { type InferredRecipeHints, inferRecipeFromDesignMd } from "./infer-recipe";
|
|
27
|
+
export {
|
|
28
|
+
elevationPresetToTokens,
|
|
29
|
+
isDualModeBrand,
|
|
30
|
+
normalizeBrandPackage,
|
|
31
|
+
normalizeModePalette,
|
|
32
|
+
rolesFromSemantic,
|
|
33
|
+
semanticFromRoles,
|
|
34
|
+
} from "./normalize";
|
|
35
|
+
export type {
|
|
36
|
+
BadgeDefaultStyle,
|
|
37
|
+
BrandColorRoles,
|
|
38
|
+
BrandElevationTokens,
|
|
39
|
+
BrandExtensions,
|
|
40
|
+
BrandFontFace,
|
|
41
|
+
BrandFontSource,
|
|
42
|
+
BrandModePalette,
|
|
43
|
+
BrandModes,
|
|
44
|
+
BrandPackage,
|
|
45
|
+
BrandPackageInput,
|
|
46
|
+
BrandRadii,
|
|
47
|
+
BrandRecipe,
|
|
48
|
+
BrandRecipeInput,
|
|
49
|
+
BrandSemanticColors,
|
|
50
|
+
BrandTypeStep,
|
|
51
|
+
BrandTypography,
|
|
52
|
+
BrandZoneId,
|
|
53
|
+
BrandZones,
|
|
54
|
+
BrandZoneTypography,
|
|
55
|
+
ButtonDefaultStyle,
|
|
56
|
+
CompileResult,
|
|
57
|
+
CssColor,
|
|
58
|
+
Density,
|
|
59
|
+
ElevationStyle,
|
|
60
|
+
HslChannels,
|
|
61
|
+
} from "./types";
|
|
62
|
+
export {
|
|
63
|
+
applyBrandToIframe,
|
|
64
|
+
type BrandIframePreviewOptions,
|
|
65
|
+
type UseBrandIframePreviewResult,
|
|
66
|
+
type UseBrandOptions,
|
|
67
|
+
type UseBrandResult,
|
|
68
|
+
useBrand,
|
|
69
|
+
useBrandIframePreview,
|
|
70
|
+
} from "./use-brand";
|
|
71
|
+
export { type ValidationResult, validateBrandPackage } from "./validate";
|