brand-intent 0.5.1 → 0.6.0
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/examples/krume/krume.brand +0 -2
- package/package.json +1 -1
- package/parser/brandParser.ts +129 -22
- package/parser/purposeParser.ts +1 -1
package/package.json
CHANGED
package/parser/brandParser.ts
CHANGED
|
@@ -123,19 +123,117 @@ export interface ParsedDividerDef {
|
|
|
123
123
|
thickness: number;
|
|
124
124
|
/** Line width — percentage string (e.g. '15%') or '100%' */
|
|
125
125
|
width: string;
|
|
126
|
-
/**
|
|
127
|
-
* Resolved color value. Supports:
|
|
128
|
-
* - Brand color names: 'accent1', 'accent2', 'primary', 'secondary', etc. (resolved to hex at parse time)
|
|
129
|
-
* - Raw hex: '#E32D39'
|
|
130
|
-
* - Theme slot names: 'logo' | 'cta' | 'sticker' | 'background' | 'text-primary' | … (resolved at render time via resolveDividerColor)
|
|
131
|
-
*/
|
|
132
|
-
color: string;
|
|
133
126
|
/** Spacing token name applied above and below the divider (xs | s | m | l | xl) */
|
|
134
127
|
spacing: string;
|
|
135
128
|
/** Horizontal alignment of a partial-width divider (default: 'left') */
|
|
136
129
|
align: 'left' | 'center' | 'right';
|
|
137
130
|
}
|
|
138
131
|
|
|
132
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Expand a 3-digit hex to 6-digit. Pass-through for 6 or 8-digit.
|
|
136
|
+
* Returns null for invalid input.
|
|
137
|
+
*/
|
|
138
|
+
function normalizeHex(hex: string): string | null {
|
|
139
|
+
const m = hex.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/);
|
|
140
|
+
if (!m) return null;
|
|
141
|
+
const body = m[1];
|
|
142
|
+
if (body.length === 3) {
|
|
143
|
+
return '#' + body.split('').map(c => c + c).join('').toUpperCase();
|
|
144
|
+
}
|
|
145
|
+
return '#' + body.toUpperCase();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Parse a 6-digit hex into [r, g, b]. Input must be normalised to 6 digits. */
|
|
149
|
+
function hexToRgb(hex6: string): [number, number, number] {
|
|
150
|
+
const body = hex6.slice(1);
|
|
151
|
+
return [
|
|
152
|
+
parseInt(body.slice(0, 2), 16),
|
|
153
|
+
parseInt(body.slice(2, 4), 16),
|
|
154
|
+
parseInt(body.slice(4, 6), 16),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a theme-slot value string to a final CSS color.
|
|
160
|
+
*
|
|
161
|
+
* Supports: `$ref`, `$ref NN%`, `#HEX`, `#HEX NN%`, raw rgba(...) pass-through.
|
|
162
|
+
* Throws on malformed alpha or unresolved `$ref`.
|
|
163
|
+
*/
|
|
164
|
+
function resolveThemeSlotValue(
|
|
165
|
+
raw: string,
|
|
166
|
+
brandColors: Record<string, string>,
|
|
167
|
+
fileName: string,
|
|
168
|
+
slot: string,
|
|
169
|
+
): string {
|
|
170
|
+
const value = raw.trim();
|
|
171
|
+
|
|
172
|
+
// Legacy rgba(...) literal — pass through unchanged.
|
|
173
|
+
if (/^rgba?\s*\(/i.test(value)) return value;
|
|
174
|
+
|
|
175
|
+
// Detect an alpha suffix: "<base> NN%" (one or more spaces, numeric, trailing %).
|
|
176
|
+
// If the line contains a % but the shape doesn't match, it's malformed.
|
|
177
|
+
let base = value;
|
|
178
|
+
let alpha: number | null = null;
|
|
179
|
+
const alphaMatch = value.match(/^(\S+)\s+([0-9]+(?:\.[0-9]+)?)%$/);
|
|
180
|
+
if (alphaMatch) {
|
|
181
|
+
base = alphaMatch[1];
|
|
182
|
+
alpha = parseFloat(alphaMatch[2]);
|
|
183
|
+
if (alpha < 0 || alpha > 100) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`${fileName}: theme slot "${slot}" alpha must be between 0 and 100, got ${alpha}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
} else if (value.includes('%')) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`${fileName}: theme slot "${slot}" alpha syntax is "<value> NN%" with a single space, got "${value}"`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Resolve base to a hex string.
|
|
195
|
+
let hex: string | null = null;
|
|
196
|
+
if (base.startsWith('$')) {
|
|
197
|
+
const refName = base.slice(1);
|
|
198
|
+
const resolved = brandColors[refName];
|
|
199
|
+
if (!resolved) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`${fileName}: theme slot "${slot}" references unknown brand color "$${refName}"`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
hex = normalizeHex(resolved);
|
|
205
|
+
if (!hex) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`${fileName}: theme slot "${slot}" base "$${refName}" resolves to "${resolved}", which is not a valid hex color`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
hex = normalizeHex(base);
|
|
212
|
+
if (!hex) {
|
|
213
|
+
// Not a hex, not a $ref — but it might be a bare keyword like "currentColor" or
|
|
214
|
+
// a semantic slot name used for dividers historically. Pass through only when
|
|
215
|
+
// no alpha was requested; with alpha, the base must be concrete.
|
|
216
|
+
if (alpha !== null) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`${fileName}: theme slot "${slot}" base "${base}" must be "$ref" or "#HEX" when using alpha`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// No alpha → solid hex.
|
|
226
|
+
if (alpha === null) return hex;
|
|
227
|
+
|
|
228
|
+
// 100% alpha → emit solid hex (keep output clean).
|
|
229
|
+
if (alpha === 100) return hex;
|
|
230
|
+
|
|
231
|
+
const [r, g, b] = hexToRgb(hex);
|
|
232
|
+
// Drop trailing zeros for cleanliness: 0.7 not 0.70, 0.5 not 0.50.
|
|
233
|
+
const a = Number((alpha / 100).toFixed(4)).toString();
|
|
234
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
235
|
+
}
|
|
236
|
+
|
|
139
237
|
// ── Parser ─────────────────────────────────────────────────────────────────────
|
|
140
238
|
|
|
141
239
|
export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
@@ -191,6 +289,12 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
191
289
|
const value = trimmed.slice(colonIdx + 1).trim();
|
|
192
290
|
if (indentLen <= 2) {
|
|
193
291
|
// Top-level color line: " primary: #0324B1"
|
|
292
|
+
// Brand colors are solid primitives only — alpha belongs in theme slots.
|
|
293
|
+
if (/%/.test(value) || /rgba\s*\(/i.test(value)) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`${fileName}:${lineNum}: brand color "${key}" must be a solid hex — alpha syntax (e.g. "$ref 75%" or rgba()) is only allowed in theme slots`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
194
298
|
brandColors[key] = value;
|
|
195
299
|
currentBrandColorName = key;
|
|
196
300
|
} else if (currentBrandColorName) {
|
|
@@ -236,6 +340,11 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
236
340
|
if (colonIdx !== -1) {
|
|
237
341
|
const key = raw.slice(0, colonIdx).trim();
|
|
238
342
|
const value = raw.slice(colonIdx + 1).trim();
|
|
343
|
+
if (key === 'color') {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`${fileName}:${lineNum}: divider "${currentDividerName}": color is no longer configurable. Remove the "color:" line — divider color is sourced from the theme's "divider" slot.`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
239
348
|
if (!dividersRaw[currentDividerName]) dividersRaw[currentDividerName] = {};
|
|
240
349
|
dividersRaw[currentDividerName][key] = value;
|
|
241
350
|
}
|
|
@@ -399,19 +508,21 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
399
508
|
if (Object.keys(brandColorPrint).length > 0) result.brandColorPrint = brandColorPrint;
|
|
400
509
|
}
|
|
401
510
|
|
|
402
|
-
// Finalise inline tokens — resolve $ref values against brandColors
|
|
511
|
+
// Finalise inline tokens — resolve $ref values against brandColors, apply optional alpha.
|
|
512
|
+
//
|
|
513
|
+
// Theme slot grammar:
|
|
514
|
+
// slot: $ref → resolve ref to hex
|
|
515
|
+
// slot: $ref 75% → resolve ref, then apply alpha (rgba)
|
|
516
|
+
// slot: #HEX → pass through
|
|
517
|
+
// slot: #HEX 30% → apply alpha (rgba)
|
|
518
|
+
// slot: rgba(...) → pass through (legacy, discouraged)
|
|
519
|
+
//
|
|
520
|
+
// Alpha is expressed as `<number>%`, 0–100, separated from the base value by exactly one space.
|
|
521
|
+
// 100% emits solid hex (not rgba) to keep output clean.
|
|
403
522
|
if (Object.keys(themes).length > 0) {
|
|
404
523
|
for (const theme of Object.values(themes)) {
|
|
405
524
|
for (const [slot, value] of Object.entries(theme)) {
|
|
406
|
-
|
|
407
|
-
const refName = value.slice(1);
|
|
408
|
-
const resolved = brandColors[refName];
|
|
409
|
-
if (resolved) {
|
|
410
|
-
theme[slot] = resolved;
|
|
411
|
-
} else {
|
|
412
|
-
console.warn(`${fileName}: unresolved brand color reference "$${refName}" in theme slot "${slot}"`);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
525
|
+
theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot);
|
|
415
526
|
}
|
|
416
527
|
}
|
|
417
528
|
result.themes = themes;
|
|
@@ -428,12 +539,9 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
428
539
|
};
|
|
429
540
|
}
|
|
430
541
|
|
|
431
|
-
// Finalise dividers
|
|
542
|
+
// Finalise dividers — color is always sourced from the active theme's "divider" slot.
|
|
432
543
|
if (Object.keys(dividersRaw).length > 0) {
|
|
433
544
|
const dividers: Record<string, ParsedDividerDef> = {};
|
|
434
|
-
// Resolve a color value: brand color name (accent1, accent2, …) → hex,
|
|
435
|
-
// semantic slot names → kept as-is for resolveDividerColor(), raw hex → pass through.
|
|
436
|
-
const resolveColor = (c: string): string => brandColors[c] ?? c;
|
|
437
545
|
for (const [name, raw] of Object.entries(dividersRaw)) {
|
|
438
546
|
const alignRaw = raw['align'] ?? 'left';
|
|
439
547
|
const align: ParsedDividerDef['align'] =
|
|
@@ -441,7 +549,6 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
441
549
|
dividers[name] = {
|
|
442
550
|
thickness: parseFloat(raw['thickness'] ?? '0.2'),
|
|
443
551
|
width: raw['width'] ?? '100%',
|
|
444
|
-
color: resolveColor(raw['color'] ?? 'text-tertiary'),
|
|
445
552
|
spacing: raw['spacing'] ?? 'xs',
|
|
446
553
|
align,
|
|
447
554
|
};
|
package/parser/purposeParser.ts
CHANGED
|
@@ -67,7 +67,7 @@ export interface ParsedPurposeSlot {
|
|
|
67
67
|
itemGap?: number;
|
|
68
68
|
/** List layout direction: 'vertical' (default) or 'horizontal'. */
|
|
69
69
|
listDirection?: 'vertical' | 'horizontal';
|
|
70
|
-
/** When
|
|
70
|
+
/** When false, this slot is locked — users cannot edit it in any mode. Default: true (editable). */
|
|
71
71
|
editable?: boolean;
|
|
72
72
|
typography: {
|
|
73
73
|
fontFamily: string;
|