@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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +8 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/AGENTS.md +5 -2
  4. package/CHANGELOG.md +33 -0
  5. package/DESIGN.md +30 -6
  6. package/LICENSE +21 -676
  7. package/README.md +41 -8
  8. package/brands/gsap/brand.json +180 -0
  9. package/brands/linear/brand.json +202 -0
  10. package/brands/notion/brand.json +189 -0
  11. package/brands/raycast/brand.json +167 -0
  12. package/brands/stripe/brand.json +183 -0
  13. package/brands/vanta/brand.json +198 -0
  14. package/brands/vercel/brand.json +207 -0
  15. package/package.json +18 -9
  16. package/recipe.css +114 -0
  17. package/scripts/compile-brand-run.ts +79 -0
  18. package/scripts/compile-brand.mjs +25 -0
  19. package/scripts/emit-skins-run.ts +62 -0
  20. package/scripts/emit-skins.mjs +31 -0
  21. package/scripts/sync-styles.mjs +29 -0
  22. package/skins/README.md +151 -0
  23. package/skins/gsap.css +247 -0
  24. package/skins/linear.css +282 -0
  25. package/skins/notion.css +248 -0
  26. package/skins/raycast.css +272 -0
  27. package/skins/stripe.css +248 -0
  28. package/skins/vanta.css +255 -0
  29. package/skins/vercel.css +283 -0
  30. package/src/brand-package/__tests__/compile-refero.test.ts +427 -0
  31. package/src/brand-package/__tests__/emit-css.test.ts +130 -0
  32. package/src/brand-package/apply-brand.ts +92 -0
  33. package/src/brand-package/compile-helpers.ts +170 -0
  34. package/src/brand-package/compile-refero.ts +69 -0
  35. package/src/brand-package/emit-css.ts +468 -0
  36. package/src/brand-package/hex-to-hsl.ts +100 -0
  37. package/src/brand-package/index.ts +71 -0
  38. package/src/brand-package/infer-recipe.ts +198 -0
  39. package/src/brand-package/normalize.ts +295 -0
  40. package/src/brand-package/presets/context.ts +17 -0
  41. package/src/brand-package/presets/generic.ts +214 -0
  42. package/src/brand-package/presets/gsap.ts +146 -0
  43. package/src/brand-package/presets/index.ts +31 -0
  44. package/src/brand-package/presets/linear.ts +183 -0
  45. package/src/brand-package/presets/notion.ts +208 -0
  46. package/src/brand-package/presets/raycast.ts +138 -0
  47. package/src/brand-package/presets/recipe.ts +61 -0
  48. package/src/brand-package/presets/stripe.ts +207 -0
  49. package/src/brand-package/presets/vanta.ts +220 -0
  50. package/src/brand-package/presets/vercel.ts +187 -0
  51. package/src/brand-package/types.ts +285 -0
  52. package/src/brand-package/use-brand.ts +207 -0
  53. package/src/brand-package/validate.ts +108 -0
  54. package/src/index.ts +31 -1
  55. package/src/theme-provider.tsx +21 -6
  56. package/styles.css +157 -79
  57. package/turbo.json +15 -0
@@ -0,0 +1,198 @@
1
+ import type { ButtonDefaultStyle, Density, ElevationStyle } from "./types";
2
+
3
+ export interface InferredRecipeHints {
4
+ buttonDefault?: ButtonDefaultStyle;
5
+ elevationPreset?: ElevationStyle;
6
+ density?: Density;
7
+ /** Free radii slots from DESIGN.md tables / free-text */
8
+ radii?: { button?: string; card?: string };
9
+ notes: string[];
10
+ }
11
+
12
+ /**
13
+ * Infer control recipe from DESIGN.md / agent prompt text.
14
+ * Used for generic brands and to refine known fixtures.
15
+ */
16
+ export function inferRecipeFromDesignMd(designMd: string): InferredRecipeHints {
17
+ const t = designMd.toLowerCase();
18
+ const notes: string[] = [];
19
+ const hints: InferredRecipeHints = { notes };
20
+
21
+ const outlineFirst =
22
+ t.includes("outlined-only") ||
23
+ t.includes("outline-only") ||
24
+ t.includes("ghost pill") ||
25
+ t.includes("ghost-pill") ||
26
+ t.includes("no filled") ||
27
+ t.includes("don't add filled") ||
28
+ t.includes("do not add filled") ||
29
+ t.includes("never fill") ||
30
+ (t.includes("outlined") && t.includes("button") && !t.includes("filled cta"));
31
+
32
+ // Require explicit stroke CTA language — do NOT match hero artwork "gradient"
33
+ const gradientStroke =
34
+ t.includes("gradient-stroked") ||
35
+ t.includes("gradient stroke") ||
36
+ t.includes("gradient-stroked cta") ||
37
+ t.includes("border-image") ||
38
+ (t.includes("gradient border") && (t.includes("cta") || t.includes("button")));
39
+
40
+ // Raycast-style neutral CTA — do NOT match mere color-token name "mist" + "filled button"
41
+ const neutralFilled =
42
+ t.includes("no chromatic") ||
43
+ t.includes("deliberately neutral") ||
44
+ t.includes("neutral rather than chromatic") ||
45
+ t.includes("no chromatic action") ||
46
+ t.includes("don't use chromatic action") ||
47
+ t.includes("do not use chromatic action") ||
48
+ (t.includes("mist") && t.includes("filled") && (t.includes("iron") || t.includes("neutral")));
49
+
50
+ const solidCta =
51
+ neutralFilled ||
52
+ (t.includes("filled") &&
53
+ (t.includes("primary") ||
54
+ t.includes("cta") ||
55
+ t.includes("download") ||
56
+ t.includes("action button") ||
57
+ t.includes("primary action") ||
58
+ t.includes("filled button")));
59
+
60
+ if (gradientStroke) {
61
+ hints.buttonDefault = "gradient-stroke";
62
+ notes.push("DESIGN.md: gradient-stroke CTA");
63
+ } else if (outlineFirst && !solidCta) {
64
+ hints.buttonDefault = "outline";
65
+ notes.push("DESIGN.md: outline-first controls");
66
+ } else if (solidCta) {
67
+ hints.buttonDefault = "solid";
68
+ if (neutralFilled) notes.push("DESIGN.md: neutral filled CTA (not chromatic)");
69
+ }
70
+
71
+ // Explicit ban on all elevation shadows (Stripe/Vanta ledger systems)
72
+ const forbidsAnyShadow =
73
+ t.includes("avoids shadows") ||
74
+ t.includes("avoid shadows") ||
75
+ t.includes("avoids shadows entirely") ||
76
+ t.includes("no card has a box-shadow") ||
77
+ t.includes("no button has a shadow") ||
78
+ t.includes("never from box-shadow") ||
79
+ t.includes("never from elevation") ||
80
+ t.includes("depth comes from background tint") ||
81
+ t.includes("depth comes from background") ||
82
+ t.includes("no shadows, blurs") ||
83
+ t.includes("do not use shadows") ||
84
+ t.includes("don't use shadows") ||
85
+ t.includes("rejects drop shadows") ||
86
+ t.includes("reject drop shadows") ||
87
+ t.includes("don't drop shadows") ||
88
+ t.includes("do not drop shadows") ||
89
+ t.includes("don't apply drop shadows") ||
90
+ t.includes("do not apply drop shadows") ||
91
+ t.includes("don't add drop-shadows") ||
92
+ t.includes("do not add drop-shadows") ||
93
+ t.includes("never use drop-shadows") ||
94
+ t.includes("no drop shadow") ||
95
+ t.includes("no box-shadow") ||
96
+ t.includes("never via box-shadow") ||
97
+ t.includes("border is the elevation") ||
98
+ t.includes("no shadow — the border") ||
99
+ t.includes("no shadow - the border") ||
100
+ t.includes("depth is communicated only") ||
101
+ (t.includes("no shadow") && t.includes("border")) ||
102
+ (t.includes("flat") && t.includes("1px") && t.includes("border"));
103
+
104
+ const keyElev =
105
+ t.includes("keyboard key") ||
106
+ t.includes("key shadow") ||
107
+ t.includes("key cap") ||
108
+ (t.includes("inset top") && t.includes("highlight") && t.includes("shadow"));
109
+
110
+ // Vercel-style: affirmative *use* of stacked box-shadow rings (not "never box-shadow")
111
+ const hairlineRingElev =
112
+ !forbidsAnyShadow &&
113
+ (t.includes("stacked box-shadow") ||
114
+ t.includes("double-ring") ||
115
+ t.includes("build depth with hairline") ||
116
+ (t.includes("hairline") &&
117
+ t.includes("box-shadow") &&
118
+ (t.includes("never with drop-shadow") ||
119
+ t.includes("never use drop-shadow") ||
120
+ t.includes("not with drop-shadow"))));
121
+
122
+ if (keyElev) {
123
+ hints.elevationPreset = "key";
124
+ notes.push("DESIGN.md: key/inset elevation");
125
+ } else if (forbidsAnyShadow) {
126
+ hints.elevationPreset = "none";
127
+ notes.push("DESIGN.md: elevation=none (no box-shadow / tint+border depth)");
128
+ } else if (hairlineRingElev) {
129
+ hints.elevationPreset = "hairline";
130
+ notes.push("DESIGN.md: hairline ring elevation");
131
+ }
132
+
133
+ // Density: explicit catalog line beats incidental "compact pill padding"
134
+ if (
135
+ /\*\*density:\*\*\s*comfortable|density:\s*comfortable|\bdensity\b[^\n]{0,20}comfortable/i.test(
136
+ designMd,
137
+ )
138
+ ) {
139
+ hints.density = "comfortable";
140
+ } else if (/\*\*density:\*\*\s*spacious|density:\s*spacious/i.test(designMd)) {
141
+ hints.density = "spacious";
142
+ } else if (/\*\*density:\*\*\s*compact|density:\s*compact/i.test(designMd)) {
143
+ hints.density = "compact";
144
+ } else if (
145
+ (t.includes("compact density") || t.includes("8–12px") || t.includes("8-12px")) &&
146
+ !t.includes("comfortable")
147
+ ) {
148
+ hints.density = "compact";
149
+ } else if (t.includes("comfortable") || t.includes("spacious")) {
150
+ hints.density = t.includes("spacious") ? "spacious" : "comfortable";
151
+ }
152
+
153
+ // Radius: **token table rows win** over free-text / agent-prompt noise.
154
+ // e.g. Notion "| buttons | 8px |" must beat incidental "border-radius 4px" on a tertiary control.
155
+ const tableButton = designMd.match(/\|\s*buttons\s*\|\s*(\d+px|9999?px)\s*\|/i);
156
+ const tableCards = designMd.match(/\|\s*cards\s*\|\s*(\d+px|9999?px)\s*\|/i);
157
+ const tableButtonRadius = tableButton?.[1];
158
+ const tableCardsRadius = tableCards?.[1];
159
+ const radii: { button?: string; card?: string } = {};
160
+ if (tableButtonRadius) {
161
+ radii.button = tableButtonRadius;
162
+ notes.push(`DESIGN.md: table buttons radius ${tableButtonRadius}`);
163
+ } else if (
164
+ t.includes("4px border-radius on all buttons") ||
165
+ t.includes("use 4px border-radius on all") ||
166
+ (t.includes("never pill") && t.includes("4px") && t.includes("button"))
167
+ ) {
168
+ radii.button = "4px";
169
+ notes.push("DESIGN.md: 4px control radius (not pill)");
170
+ } else if (
171
+ ((t.includes("999px") || t.includes("9999px")) &&
172
+ (t.includes("button") || t.includes("pill-shaped")) &&
173
+ !t.includes("never pill") &&
174
+ !t.includes("not pill") &&
175
+ !t.includes("pills only")) ||
176
+ (t.includes("pill-shaped") && t.includes("button"))
177
+ ) {
178
+ radii.button = t.includes("9999px") ? "9999px" : "999px";
179
+ notes.push("DESIGN.md: full pill control radius");
180
+ } else if (/\bbuttons?\b[^\n.|]{0,48}\b8px\b/.test(t) || t.includes("8px for buttons")) {
181
+ radii.button = "8px";
182
+ } else if (/\bbuttons?\b[^\n.|]{0,48}\b6px\b/.test(t) || t.includes("button radius to 6px")) {
183
+ radii.button = "6px";
184
+ } else if (t.includes("100px") && (t.includes("button") || t.includes("pill button"))) {
185
+ radii.button = "100px";
186
+ }
187
+
188
+ if (tableCardsRadius) {
189
+ radii.card = tableCardsRadius;
190
+ notes.push(`DESIGN.md: table cards radius ${tableCardsRadius}`);
191
+ }
192
+
193
+ if (radii.button != null || radii.card != null) {
194
+ hints.radii = radii;
195
+ }
196
+
197
+ return hints;
198
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Normalize Brand Packages into the carrier contract.
3
+ * Accepts legacy recipe fields and missing roles; always outputs full roles + free elev/radii.
4
+ */
5
+
6
+ import { tryHexToHsl } from "./hex-to-hsl";
7
+ import type {
8
+ BrandColorRoles,
9
+ BrandElevationTokens,
10
+ BrandModePalette,
11
+ BrandModes,
12
+ BrandPackage,
13
+ BrandPackageInput,
14
+ BrandRadii,
15
+ BrandRecipe,
16
+ BrandRecipeInput,
17
+ BrandSemanticColors,
18
+ ElevationStyle,
19
+ } from "./types";
20
+
21
+ const NONE = "0 0 #0000";
22
+
23
+ const KEY_SHADOW =
24
+ "rgba(255, 255, 255, 0.05) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px 0px 0px 1px, rgba(0, 0, 0, 0.2) 0px -1px 0px 0px inset";
25
+
26
+ const HAIRLINE_SHADOW = "rgba(0, 0, 0, 0.08) 0px 0px 0px 1px, rgb(250, 250, 250) 0px 0px 0px 1px";
27
+
28
+ const SOFT_CARD = "var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05))";
29
+ const SOFT_CONTROL = "var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.04))";
30
+ const SOFT_RAISED = "var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1))";
31
+
32
+ /** Ensure role color is HSL channels (accept hex for convenience). */
33
+ function asChannels(value: string | undefined): string | undefined {
34
+ if (value == null || value === "") return undefined;
35
+ const v = value.trim();
36
+ if (v.startsWith("#")) return tryHexToHsl(v, "0 0% 50%");
37
+ return v;
38
+ }
39
+
40
+ /** Expand elevation preset → free CSS tokens. */
41
+ export function elevationPresetToTokens(
42
+ preset: ElevationStyle | undefined,
43
+ cardShadow?: string,
44
+ ): BrandElevationTokens {
45
+ switch (preset) {
46
+ case "none":
47
+ return { card: NONE, control: NONE, raised: NONE };
48
+ case "key":
49
+ return {
50
+ card: cardShadow ?? KEY_SHADOW,
51
+ control: NONE,
52
+ raised: cardShadow ?? KEY_SHADOW,
53
+ };
54
+ case "hairline":
55
+ return {
56
+ card: cardShadow ?? HAIRLINE_SHADOW,
57
+ control: NONE,
58
+ raised: cardShadow ?? HAIRLINE_SHADOW,
59
+ };
60
+ case "raised":
61
+ return { card: SOFT_RAISED, control: SOFT_CONTROL, raised: SOFT_RAISED };
62
+ default:
63
+ return { card: SOFT_CARD, control: SOFT_CONTROL, raised: SOFT_RAISED };
64
+ }
65
+ }
66
+
67
+ export function rolesFromSemantic(
68
+ s: BrandSemanticColors,
69
+ brandMark?: { brand?: string; brandForeground?: string },
70
+ ): BrandColorRoles {
71
+ const roles: BrandColorRoles = {
72
+ canvas: s.background,
73
+ canvasForeground: s.foreground,
74
+ surface: s.card,
75
+ surfaceForeground: s.cardForeground,
76
+ action: s.primary,
77
+ actionForeground: s.primaryForeground,
78
+ quiet: s.secondary,
79
+ quietForeground: s.secondaryForeground,
80
+ muted: s.muted,
81
+ mutedForeground: s.mutedForeground,
82
+ border: s.border,
83
+ input: s.input,
84
+ ring: s.ring,
85
+ destructive: s.destructive,
86
+ destructiveForeground: s.destructiveForeground,
87
+ };
88
+ const brand = asChannels(brandMark?.brand);
89
+ if (brand) roles.brand = brand;
90
+ const brandFg = asChannels(brandMark?.brandForeground);
91
+ if (brandFg) roles.brandForeground = brandFg;
92
+ if (s.success) roles.success = s.success;
93
+ if (s.successForeground) roles.successForeground = s.successForeground;
94
+ if (s.warning) roles.warning = s.warning;
95
+ if (s.warningForeground) roles.warningForeground = s.warningForeground;
96
+ if (s.info) roles.info = s.info;
97
+ if (s.infoForeground) roles.infoForeground = s.infoForeground;
98
+ return roles;
99
+ }
100
+
101
+ /** roles → shadcn semantic: primary ALWAYS tracks action (CTA), never brand mark. */
102
+ export function semanticFromRoles(r: BrandColorRoles): BrandSemanticColors {
103
+ const accent = r.brand ?? r.quiet;
104
+ const accentFg = r.brandForeground ?? r.quietForeground;
105
+ const semantic: BrandSemanticColors = {
106
+ background: r.canvas,
107
+ foreground: r.canvasForeground,
108
+ card: r.surface,
109
+ cardForeground: r.surfaceForeground,
110
+ popover: r.surface,
111
+ popoverForeground: r.surfaceForeground,
112
+ primary: r.action,
113
+ primaryForeground: r.actionForeground,
114
+ secondary: r.quiet,
115
+ secondaryForeground: r.quietForeground,
116
+ muted: r.muted,
117
+ mutedForeground: r.mutedForeground,
118
+ accent,
119
+ accentForeground: accentFg,
120
+ destructive: r.destructive,
121
+ destructiveForeground: r.destructiveForeground,
122
+ border: r.border,
123
+ input: r.input,
124
+ ring: r.ring,
125
+ };
126
+ if (r.success) semantic.success = r.success;
127
+ if (r.successForeground) semantic.successForeground = r.successForeground;
128
+ if (r.warning) semantic.warning = r.warning;
129
+ if (r.warningForeground) semantic.warningForeground = r.warningForeground;
130
+ if (r.info) semantic.info = r.info;
131
+ if (r.infoForeground) semantic.infoForeground = r.infoForeground;
132
+ return semantic;
133
+ }
134
+
135
+ function normalizeRadii(recipe: BrandRecipeInput): BrandRadii {
136
+ const button = recipe.radii?.button ?? recipe.buttonRadius ?? "0.375rem";
137
+ const card = recipe.radii?.card ?? recipe.cardRadius ?? "0.75rem";
138
+ const radii: BrandRadii = {
139
+ button,
140
+ card,
141
+ badge: recipe.radii?.badge ?? recipe.badgeRadius ?? "9999px",
142
+ input: recipe.radii?.input ?? recipe.inputRadius ?? button,
143
+ pill: recipe.radii?.pill ?? "9999px",
144
+ };
145
+ return radii;
146
+ }
147
+
148
+ function normalizeElevation(recipe: BrandRecipeInput): BrandElevationTokens {
149
+ if (recipe.elevationTokens?.card) {
150
+ const elev: BrandElevationTokens = { card: recipe.elevationTokens.card };
151
+ elev.control = recipe.elevationTokens.control ?? NONE;
152
+ elev.raised = recipe.elevationTokens.raised ?? recipe.elevationTokens.card;
153
+ return elev;
154
+ }
155
+ return elevationPresetToTokens(recipe.elevation, recipe.cardShadow);
156
+ }
157
+
158
+ function normalizeBadgeDefault(
159
+ badge: BrandRecipeInput["badgeDefault"],
160
+ ): NonNullable<BrandRecipe["badgeDefault"]> {
161
+ if (!badge || badge === "match-primary") return "match-action";
162
+ return badge;
163
+ }
164
+
165
+ function categoryBrandMark(brand: BrandPackage | BrandPackageInput): string | undefined {
166
+ return typeof brand.extensions?.categories?.brand === "string"
167
+ ? brand.extensions.categories.brand
168
+ : undefined;
169
+ }
170
+
171
+ /** Canonicalize one mode palette → full roles + semantic. */
172
+ export function normalizeModePalette(
173
+ palette: BrandModePalette,
174
+ categoryBrand?: string,
175
+ ): { roles: BrandColorRoles; semantic: BrandSemanticColors } {
176
+ let baseRoles: BrandColorRoles;
177
+ if (palette.roles) {
178
+ baseRoles = { ...palette.roles };
179
+ } else if (palette.semantic) {
180
+ const mark: { brand?: string; brandForeground?: string } = {
181
+ brandForeground: palette.semantic.primaryForeground,
182
+ };
183
+ if (categoryBrand) mark.brand = categoryBrand;
184
+ baseRoles = rolesFromSemantic(palette.semantic, mark);
185
+ } else {
186
+ throw new Error("BrandModePalette requires roles or semantic");
187
+ }
188
+
189
+ const roles: BrandColorRoles = {
190
+ canvas: baseRoles.canvas,
191
+ canvasForeground: baseRoles.canvasForeground,
192
+ surface: baseRoles.surface,
193
+ surfaceForeground: baseRoles.surfaceForeground,
194
+ action: baseRoles.action,
195
+ actionForeground: baseRoles.actionForeground,
196
+ quiet: baseRoles.quiet,
197
+ quietForeground: baseRoles.quietForeground,
198
+ muted: baseRoles.muted,
199
+ mutedForeground: baseRoles.mutedForeground,
200
+ border: baseRoles.border,
201
+ input: baseRoles.input,
202
+ ring: baseRoles.ring,
203
+ destructive: baseRoles.destructive,
204
+ destructiveForeground: baseRoles.destructiveForeground,
205
+ };
206
+ const brandCh = asChannels(baseRoles.brand);
207
+ if (brandCh) roles.brand = brandCh;
208
+ const brandFg = asChannels(baseRoles.brandForeground) ?? baseRoles.actionForeground;
209
+ if (brandCh) roles.brandForeground = brandFg;
210
+ if (baseRoles.success) roles.success = baseRoles.success;
211
+ if (baseRoles.successForeground) roles.successForeground = baseRoles.successForeground;
212
+ if (baseRoles.warning) roles.warning = baseRoles.warning;
213
+ if (baseRoles.warningForeground) roles.warningForeground = baseRoles.warningForeground;
214
+ if (baseRoles.info) roles.info = baseRoles.info;
215
+ if (baseRoles.infoForeground) roles.infoForeground = baseRoles.infoForeground;
216
+
217
+ return { roles, semantic: semanticFromRoles(roles) };
218
+ }
219
+
220
+ function normalizeModes(
221
+ brand: BrandPackage | BrandPackageInput,
222
+ categoryBrand?: string,
223
+ ): BrandModes | undefined {
224
+ const raw = brand.modes;
225
+ if (!raw?.light && !raw?.dark) return undefined;
226
+
227
+ const modes: BrandModes = {};
228
+ if (raw.light) {
229
+ const n = normalizeModePalette(raw.light, categoryBrand);
230
+ modes.light = { roles: n.roles, semantic: n.semantic };
231
+ }
232
+ if (raw.dark) {
233
+ const n = normalizeModePalette(raw.dark, categoryBrand);
234
+ modes.dark = { roles: n.roles, semantic: n.semantic };
235
+ }
236
+ // Dual-mode complete: both present
237
+ if (modes.light && modes.dark) return modes;
238
+ // Partial modes still useful (e.g. only dark override)
239
+ return modes;
240
+ }
241
+
242
+ /** Ensure package has roles + free radii/elev; strip legacy recipe aliases from output. */
243
+ export function normalizeBrandPackage(brand: BrandPackage | BrandPackageInput): BrandPackage {
244
+ const categoryBrand = categoryBrandMark(brand);
245
+ const modes = normalizeModes(brand, categoryBrand);
246
+
247
+ // Default mode palette: dual-mode prefers modes[darkDefault], else top-level semantic/roles
248
+ const defaultModeKey = brand.darkDefault ? "dark" : "light";
249
+ const defaultMode = modes?.[defaultModeKey] ?? modes?.light ?? modes?.dark;
250
+
251
+ let primaryPalette: BrandModePalette;
252
+ if (defaultMode?.roles || defaultMode?.semantic) {
253
+ primaryPalette = {};
254
+ if (defaultMode.roles) primaryPalette.roles = defaultMode.roles;
255
+ if (defaultMode.semantic) primaryPalette.semantic = defaultMode.semantic;
256
+ } else {
257
+ primaryPalette = { semantic: brand.semantic };
258
+ if (brand.roles) primaryPalette.roles = brand.roles;
259
+ }
260
+
261
+ const { roles, semantic } = normalizeModePalette(primaryPalette, categoryBrand);
262
+
263
+ const looseRecipe = brand.recipe as BrandRecipeInput;
264
+ const radii = normalizeRadii(looseRecipe);
265
+ const elevationTokens = normalizeElevation(looseRecipe);
266
+
267
+ const recipe: BrandRecipe = {
268
+ buttonDefault: looseRecipe.buttonDefault,
269
+ density: looseRecipe.density ?? "comfortable",
270
+ badgeDefault: normalizeBadgeDefault(looseRecipe.badgeDefault),
271
+ radii,
272
+ elevationTokens,
273
+ };
274
+ if (looseRecipe.primaryStrokeGradient) {
275
+ recipe.primaryStrokeGradient = looseRecipe.primaryStrokeGradient;
276
+ }
277
+ if (looseRecipe.outlineBorder) {
278
+ recipe.outlineBorder = looseRecipe.outlineBorder;
279
+ }
280
+
281
+ const out: BrandPackage = {
282
+ ...brand,
283
+ roles,
284
+ semantic,
285
+ recipe,
286
+ };
287
+ if (modes) out.modes = modes;
288
+ else delete (out as { modes?: BrandModes }).modes;
289
+ return out;
290
+ }
291
+
292
+ /** True when package has full dual light+dark palettes. */
293
+ export function isDualModeBrand(brand: BrandPackage): boolean {
294
+ return Boolean(brand.modes?.light?.semantic && brand.modes?.dark?.semantic);
295
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared context for named compile presets + generic fallback.
3
+ */
4
+
5
+ import type { Json } from "../compile-helpers";
6
+ import type { InferredRecipeHints } from "../infer-recipe";
7
+
8
+ export interface CompileContext {
9
+ colors: Record<string, string>;
10
+ font: Json;
11
+ radius: Json;
12
+ refero: Json;
13
+ recipeHints: InferredRecipeHints;
14
+ warnings: string[];
15
+ id: string;
16
+ siteName: string;
17
+ }