brand-intent 0.1.8 → 0.3.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/README.md CHANGED
@@ -43,7 +43,6 @@ npx brand-intent init
43
43
  ## Documentation
44
44
 
45
45
  - [Specification](SPEC.md): formal grammar for all five file types
46
- - [Design Decisions](DESIGN.md): reasoning behind the framework
47
46
  - [intent.stoll.studio](https://intent.stoll.studio): project landing page
48
47
 
49
48
  ## License
package/cli/index.mjs CHANGED
@@ -60,26 +60,9 @@ function copyDirRecursive(src, dest) {
60
60
  }
61
61
 
62
62
  function scaffoldExample(targetDir) {
63
- const brandSrc = join(PKG_ROOT, 'brands', 'krume');
64
- const exampleDest = join(targetDir, 'examples', 'krume');
65
-
66
- copyDirRecursive(brandSrc, exampleDest);
67
-
68
- const layers = [
69
- { layer: 'formats', file: 'instagram-4-5-feed-portrait.format' },
70
- { layer: 'purposes', file: 'daily-bread.purpose' },
71
- { layer: 'compositions', file: 'editorial.composition' },
72
- ];
73
-
74
- for (const { layer, file } of layers) {
75
- const src = join(PKG_ROOT, layer, file);
76
- if (existsSync(src)) {
77
- const dest = join(exampleDest, layer, file);
78
- mkdirSync(dirname(dest), { recursive: true });
79
- copyFileSync(src, dest);
80
- }
81
- }
82
-
63
+ const src = join(PKG_ROOT, 'examples', 'krume');
64
+ const dest = join(targetDir, 'examples', 'krume');
65
+ copyDirRecursive(src, dest);
83
66
  console.log(' \u2713 examples/krume/ \u2014 reference implementation (identity, brand, format, purpose, composition)');
84
67
  }
85
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.1.8",
3
+ "version": "0.3.0",
4
4
  "description": "An open file format specification for making brand intent machine-readable.",
5
5
  "author": "Christophe Stoll <christophe@stoll.studio>",
6
6
  "license": "MIT",
@@ -12,6 +12,18 @@
12
12
  "bin": {
13
13
  "brand-intent": "./cli/index.mjs"
14
14
  },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./parser/index.ts",
18
+ "default": "./parser/index.ts"
19
+ },
20
+ "./parser": {
21
+ "types": "./parser/index.ts",
22
+ "default": "./parser/index.ts"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "types": "./parser/index.ts",
15
27
  "keywords": [
16
28
  "brand",
17
29
  "design-tokens",
@@ -25,10 +37,8 @@
25
37
  },
26
38
  "files": [
27
39
  "cli/",
40
+ "parser/",
28
41
  "skills/",
29
- "brands/",
30
- "formats/",
31
- "purposes/",
32
- "compositions/"
42
+ "examples/"
33
43
  ]
34
44
  }
@@ -0,0 +1,36 @@
1
+ # Brand Intent - Reference Parser
2
+
3
+ TypeScript parsers for all five Brand Intent file types.
4
+
5
+ ## Parsers
6
+
7
+ | Parser | File Type | Function |
8
+ |--------|-----------|----------|
9
+ | `identityParser.ts` | `.identity` | `parseIdentityFile(content, fileName)` |
10
+ | `brandParser.ts` | `.brand` | `parseBrandFile(content, fileName)` |
11
+ | `formatParser.ts` | `.format` | `parseFormatFile(content, fileName)` |
12
+ | `purposeParser.ts` | `.purpose` | `parsePurposeFile(content, fileName)` |
13
+ | `compositionParser.ts` | `.composition` | `parseCompositionFile(content, fileName)` |
14
+
15
+ All parsers are **pure functions**: they take a string (file content) and a filename (for error messages), and return a parsed object. No I/O, no side effects, no dependencies beyond `types.ts`.
16
+
17
+ ## Usage
18
+
19
+ ```typescript
20
+ import { readFileSync } from 'fs';
21
+ import { parseIdentityFile, parseBrandFile } from './parser';
22
+
23
+ const identity = parseIdentityFile(
24
+ readFileSync('examples/krume/krume.identity', 'utf-8'),
25
+ 'krume.identity'
26
+ );
27
+
28
+ const brand = parseBrandFile(
29
+ readFileSync('examples/krume/krume.brand', 'utf-8'),
30
+ 'krume.brand'
31
+ );
32
+ ```
33
+
34
+ ## Notes
35
+
36
+ - These parsers throw on validation errors, with the filename and line number included in the error message.
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Parser for .brand brand definition files.
3
+ *
4
+ * Line-based format, same conventions as .purpose / .composition / .format / .identity.
5
+ * Strategic/narrative content belongs in the sibling `.identity` file — this parser
6
+ * handles expression-layer primitives only (colors, typography, dividers, assets, badge styles).
7
+ */
8
+
9
+ // ── Parsed type ────────────────────────────────────────────────────────────────
10
+
11
+ export interface ParsedBrand {
12
+ id: string;
13
+ name: string;
14
+ language: string;
15
+ locale: string;
16
+ /** Canonical domain for this brand (used for OG meta, favicon paths, share links). */
17
+ domain?: string;
18
+ /** Named font primitives: name → font definition. First entry is the brand default. */
19
+ fonts?: Record<string, ParsedFontDef>;
20
+ /** Token source type: 'figma' | 'inline' */
21
+ tokensSource: 'figma' | 'inline';
22
+ /** Figma file key (only when tokensSource === 'figma') */
23
+ figmaFileKey?: string;
24
+ /** Figma collection name (only when tokensSource === 'figma') */
25
+ figmaCollection?: string;
26
+ /** Path to the runtime tokens file (relative to /src) */
27
+ tokensFile: string;
28
+ /** Path to app icon asset (relative to /public), used for PWA icon */
29
+ appIcon?: string;
30
+ /** Path to favicon asset (relative to /public), used for browser tab icon */
31
+ favicon?: string;
32
+ /** Path to OG image asset (relative to /public), used for social sharing previews */
33
+ ogImage?: string;
34
+ /** Core brand color primitives: name → hex (e.g. primary, secondary, accent1, accent2, white) */
35
+ brandColors?: Record<string, string>;
36
+ /** Structured print specs per brand color: name → {pantone?, hks?, cmyk?} */
37
+ brandColorPrint?: Record<string, BrandColorPrintSpec>;
38
+ /** Inline themes: theme name → slot name → resolved hex color (only when tokensSource === 'inline') */
39
+ themes?: Record<string, Record<string, string>>;
40
+ /** Print annotations from inline # comments: slot name → annotation text */
41
+ themeDescriptions?: Record<string, string>;
42
+ /** Spacing scale (only when tokensSource === 'inline') */
43
+ spacing?: { unit: string; xs: number; s: number; m: number; l: number; xl: number };
44
+ /** Divider style definitions: name → divider config */
45
+ dividers?: Record<string, ParsedDividerDef>;
46
+ /** Named typography styles: name → style definition */
47
+ typographies?: Record<string, ParsedTypographyDef>;
48
+ /** Brand-declared assets: curated logos, photos, videos with labels and metadata */
49
+ assets?: { logos?: ParsedAssetDef[]; photos?: ParsedAssetDef[]; videos?: ParsedAssetDef[] };
50
+ /** Label badge style — padding (top/right/bottom/left in cqh) and corner radius (cqh) */
51
+ label?: ParsedBadgeStyle;
52
+ /** CTA pill style — padding (top/right/bottom/left in cqh) and corner radius (cqh, or 999 for full pill) */
53
+ cta?: ParsedBadgeStyle;
54
+ }
55
+
56
+ export interface ParsedAssetDef {
57
+ id: string;
58
+ label: string;
59
+ src: string;
60
+ aspectRatio?: number;
61
+ scale?: number;
62
+ lottieSrc?: string;
63
+ animated?: boolean;
64
+ loop?: boolean;
65
+ }
66
+
67
+ export interface ParsedBadgeStyle {
68
+ /** Padding in cqh: [top, right, bottom, left] */
69
+ padding: [number, number, number, number];
70
+ /** Corner radius in cqh. Use 999 for a full pill. */
71
+ radius: number;
72
+ }
73
+
74
+ export interface ParsedFontDef {
75
+ /** Display name, e.g. "Matter", "system-ui", "Inter" */
76
+ name: string;
77
+ /** CSS fallback stack, e.g. "sans-serif", "serif" */
78
+ fallback: string;
79
+ /** Font source: 'local' (bundled in public/fonts/), 'system' (no @font-face), 'google' (future) */
80
+ source: 'local' | 'system' | 'google';
81
+ /** Variable font filename, relative to the brand's fonts/ directory. Used by preview where weight interpolation matters. */
82
+ variable?: string;
83
+ /** Variable italic font filename, when the family ships separate Roman/Italic variable files. */
84
+ variableItalic?: string;
85
+ /**
86
+ * Static font instances, keyed by `{weight}` or `{weight}i` (italic).
87
+ * E.g. `{ "400": "Pangea-Regular.ttf", "400i": "Pangea-Italic.ttf", "600": "Pangea-SemiBold.ttf" }`.
88
+ * Required for export pipelines (Chromium PDF, print) that cannot embed variable or WOFF2 fonts.
89
+ * Filenames are relative to the brand's `fonts/` directory and may contain subdirectories.
90
+ */
91
+ static?: Record<string, string>;
92
+ }
93
+
94
+ export interface BrandColorPrintSpec {
95
+ /** PANTONE color name, e.g. "286 C" */
96
+ pantone?: string;
97
+ /** HKS color name, e.g. "44 K" */
98
+ hks?: string;
99
+ /** CMYK values as "C/M/Y/K" string, e.g. "97/80/0/31" */
100
+ cmyk?: string;
101
+ }
102
+
103
+ export interface ParsedTypographyDef {
104
+ /** Font key referencing a named font block (e.g. "mono"). Defaults to the brand primary font. */
105
+ font?: string;
106
+ weight: number;
107
+ size: number;
108
+ opsz?: number;
109
+ lineHeight: number;
110
+ letterSpacing?: number;
111
+ italic?: boolean;
112
+ uppercase?: boolean;
113
+ /** Weight to use for **strong** inline markup. If absent, strong renders as normal. */
114
+ strong?: number;
115
+ /** Autofit minimum font size in cqh. If absent, autofit is disabled (fixed size). */
116
+ autofitMin?: number;
117
+ /** Autofit maximum font size in cqh. If absent, autofit is disabled (fixed size). */
118
+ autofitMax?: number;
119
+ }
120
+
121
+ export interface ParsedDividerDef {
122
+ /** Line thickness in cqmin */
123
+ thickness: number;
124
+ /** Line width — percentage string (e.g. '15%') or '100%' */
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
+ /** Spacing token name applied above and below the divider (xs | s | m | l | xl) */
134
+ spacing: string;
135
+ /** Horizontal alignment of a partial-width divider (default: 'left') */
136
+ align: 'left' | 'center' | 'right';
137
+ }
138
+
139
+ // ── Parser ─────────────────────────────────────────────────────────────────────
140
+
141
+ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
142
+ const lines = content.split('\n');
143
+
144
+ const result: Partial<ParsedBrand> = {
145
+ tokensFile: 'tokens.json',
146
+ tokensSource: 'figma',
147
+ };
148
+
149
+ type ActiveBlock = 'none' | 'brand-colors' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'label' | 'cta';
150
+ let activeBlock: ActiveBlock = 'none';
151
+ let currentThemeName = '';
152
+ let currentDividerName = '';
153
+ let currentTypographyName = '';
154
+ let currentFontName = '';
155
+ const brandColors: Record<string, string> = {};
156
+ const brandColorPrint: Record<string, BrandColorPrintSpec> = {};
157
+ let currentBrandColorName = '';
158
+ const themes: Record<string, Record<string, string>> = {};
159
+ const themeDescriptions: Record<string, string> = {};
160
+ const spacingRaw: Record<string, string> = {};
161
+ const dividersRaw: Record<string, Record<string, string>> = {};
162
+ const typographiesRaw: Record<string, Record<string, string>> = {};
163
+ const fontsRaw: Array<{ name: string; props: Record<string, string> }> = [];
164
+ const logosRaw: Array<{ id: string; props: Record<string, string> }> = [];
165
+ const photosRaw: Array<{ id: string; props: Record<string, string> }> = [];
166
+ const videosRaw: Array<{ id: string; props: Record<string, string> }> = [];
167
+ let currentAssetId = '';
168
+ const labelRaw: Record<string, string> = {};
169
+ const ctaRaw: Record<string, string> = {};
170
+
171
+ for (let i = 0; i < lines.length; i++) {
172
+ const raw = lines[i];
173
+ const lineNum = i + 1;
174
+ const isIndented = raw.startsWith(' ') || raw.startsWith('\t');
175
+
176
+ // Comment — end any active block and skip
177
+ if (raw.startsWith('#')) {
178
+ activeBlock = 'none';
179
+ continue;
180
+ }
181
+
182
+ // Indented continuation line — route to the active block
183
+ if (isIndented) {
184
+ if (activeBlock === 'brand-colors') {
185
+ const trimmed = raw.trimStart();
186
+ const indentLen = raw.length - trimmed.length;
187
+ const colonIdx = trimmed.indexOf(':');
188
+ if (colonIdx !== -1) {
189
+ const key = trimmed.slice(0, colonIdx).trim();
190
+ const value = trimmed.slice(colonIdx + 1).trim();
191
+ if (indentLen <= 2) {
192
+ // Top-level color line: " primary: #0324B1"
193
+ brandColors[key] = value;
194
+ currentBrandColorName = key;
195
+ } else if (currentBrandColorName) {
196
+ // Print sub-key: " pantone: 286 C" / " hks: 44 K" / " cmyk: 97/80/0/31"
197
+ if (!brandColorPrint[currentBrandColorName]) brandColorPrint[currentBrandColorName] = {};
198
+ const spec = brandColorPrint[currentBrandColorName];
199
+ if (key === 'pantone') spec.pantone = value;
200
+ else if (key === 'hks') spec.hks = value;
201
+ else if (key === 'cmyk') spec.cmyk = value;
202
+ }
203
+ }
204
+ continue;
205
+ }
206
+ if (activeBlock === 'theme' && currentThemeName) {
207
+ // slot: #HEX or $ref # optional comment
208
+ const colonIdx = raw.indexOf(':');
209
+ if (colonIdx !== -1) {
210
+ const slot = raw.slice(0, colonIdx).trim();
211
+ const rest = raw.slice(colonIdx + 1).trim();
212
+ // Split on first ' #' to separate value from inline comment
213
+ const commentIdx = rest.search(/\s+#/);
214
+ const value = commentIdx !== -1 ? rest.slice(0, commentIdx).trim() : rest.trim();
215
+ const comment = commentIdx !== -1 ? rest.slice(commentIdx).replace(/^\s+#\s*/, '') : '';
216
+ if (!themes[currentThemeName]) themes[currentThemeName] = {};
217
+ themes[currentThemeName][slot] = value; // may be $ref — resolved after parsing
218
+ if (comment && !themeDescriptions[slot]) {
219
+ themeDescriptions[slot] = comment;
220
+ }
221
+ }
222
+ continue;
223
+ }
224
+ if (activeBlock === 'spacing') {
225
+ const colonIdx = raw.indexOf(':');
226
+ if (colonIdx !== -1) {
227
+ const key = raw.slice(0, colonIdx).trim();
228
+ const value = raw.slice(colonIdx + 1).trim();
229
+ spacingRaw[key] = value;
230
+ }
231
+ continue;
232
+ }
233
+ if (activeBlock === 'divider' && currentDividerName) {
234
+ const colonIdx = raw.indexOf(':');
235
+ if (colonIdx !== -1) {
236
+ const key = raw.slice(0, colonIdx).trim();
237
+ const value = raw.slice(colonIdx + 1).trim();
238
+ if (!dividersRaw[currentDividerName]) dividersRaw[currentDividerName] = {};
239
+ dividersRaw[currentDividerName][key] = value;
240
+ }
241
+ continue;
242
+ }
243
+ if (activeBlock === 'typography' && currentTypographyName) {
244
+ const trimmed = raw.trim();
245
+ if (!typographiesRaw[currentTypographyName]) typographiesRaw[currentTypographyName] = {};
246
+ const colonIdx = trimmed.indexOf(':');
247
+ if (colonIdx !== -1) {
248
+ const key = trimmed.slice(0, colonIdx).trim();
249
+ const value = trimmed.slice(colonIdx + 1).trim();
250
+ typographiesRaw[currentTypographyName][key] = value;
251
+ } else if (trimmed === 'italic' || trimmed === 'uppercase') {
252
+ // Bare flag — presence means true
253
+ typographiesRaw[currentTypographyName][trimmed] = 'true';
254
+ }
255
+ continue;
256
+ }
257
+ if (activeBlock === 'font' && currentFontName) {
258
+ const colonIdx = raw.indexOf(':');
259
+ if (colonIdx !== -1) {
260
+ const key = raw.slice(0, colonIdx).trim();
261
+ const value = raw.slice(colonIdx + 1).trim();
262
+ const last = fontsRaw[fontsRaw.length - 1];
263
+ if (last && last.name === currentFontName) last.props[key] = value;
264
+ }
265
+ continue;
266
+ }
267
+ if ((activeBlock === 'logo' || activeBlock === 'photo' || activeBlock === 'video') && currentAssetId) {
268
+ const colonIdx = raw.indexOf(':');
269
+ if (colonIdx !== -1) {
270
+ const key = raw.slice(0, colonIdx).trim();
271
+ const value = raw.slice(colonIdx + 1).trim();
272
+ const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : videosRaw;
273
+ const last = arr[arr.length - 1];
274
+ if (last && last.id === currentAssetId) last.props[key] = value;
275
+ } else {
276
+ const trimmed = raw.trim();
277
+ if (trimmed === 'animated' || trimmed === 'loop') {
278
+ const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : videosRaw;
279
+ const last = arr[arr.length - 1];
280
+ if (last && last.id === currentAssetId) last.props[trimmed] = 'true';
281
+ }
282
+ }
283
+ continue;
284
+ }
285
+ if (activeBlock === 'label' || activeBlock === 'cta') {
286
+ const colonIdx = raw.indexOf(':');
287
+ if (colonIdx !== -1) {
288
+ const key = raw.slice(0, colonIdx).trim();
289
+ const value = raw.slice(colonIdx + 1).trim();
290
+ const target = activeBlock === 'label' ? labelRaw : ctaRaw;
291
+ target[key] = value;
292
+ }
293
+ continue;
294
+ }
295
+ // Indented line with no active block — ignore
296
+ continue;
297
+ }
298
+
299
+ // Non-indented line ends any active block
300
+ activeBlock = 'none';
301
+
302
+ if (raw.trim() === '') continue;
303
+
304
+ // brand-colors — starts the brand color primitives block
305
+ if (raw.trim() === 'brand-colors') {
306
+ activeBlock = 'brand-colors';
307
+ continue;
308
+ }
309
+
310
+ // theme ThemeName — starts a theme block
311
+ const themeMatch = raw.match(/^theme\s+(\S+)/);
312
+ if (themeMatch) {
313
+ currentThemeName = themeMatch[1];
314
+ activeBlock = 'theme';
315
+ continue;
316
+ }
317
+
318
+ // divider name — starts a divider block
319
+ const dividerMatch = raw.match(/^divider\s+(\S+)/);
320
+ if (dividerMatch) {
321
+ currentDividerName = dividerMatch[1];
322
+ activeBlock = 'divider';
323
+ continue;
324
+ }
325
+
326
+ // typography name — starts a typography block
327
+ const typographyMatch = raw.match(/^typography\s+(\S+)/);
328
+ if (typographyMatch) {
329
+ currentTypographyName = typographyMatch[1];
330
+ activeBlock = 'typography';
331
+ continue;
332
+ }
333
+
334
+ // font <name> or bare "font" — starts a font primitive block
335
+ // Bare "font" (no name) uses "_default" internally; first font is always the brand default.
336
+ const fontBlockMatch = raw.match(/^font(?:\s+(\S+))?$/);
337
+ if (fontBlockMatch) {
338
+ currentFontName = fontBlockMatch[1] ?? '_default';
339
+ fontsRaw.push({ name: currentFontName, props: {} });
340
+ activeBlock = 'font';
341
+ continue;
342
+ }
343
+
344
+ // logo/photo/video <id> — starts an asset block
345
+ const assetMatch = raw.match(/^(logo|photo|video)\s+(\S+)/);
346
+ if (assetMatch) {
347
+ const [, type, id] = assetMatch;
348
+ currentAssetId = id;
349
+ activeBlock = type as 'logo' | 'photo' | 'video';
350
+ const arr = type === 'logo' ? logosRaw : type === 'photo' ? photosRaw : videosRaw;
351
+ arr.push({ id, props: {} });
352
+ continue;
353
+ }
354
+
355
+ const colonIdx = raw.indexOf(':');
356
+ if (colonIdx === -1) continue;
357
+
358
+ const key = raw.slice(0, colonIdx).trim();
359
+ const value = raw.slice(colonIdx + 1).trim();
360
+
361
+ switch (key) {
362
+ case 'id': result.id = value; break;
363
+ case 'name': result.name = value; break;
364
+ case 'language': result.language = value; break;
365
+ case 'locale': result.locale = value; break;
366
+ case 'domain': result.domain = value; break;
367
+ case 'tokens':
368
+ if (value === 'figma' || value === 'inline') {
369
+ result.tokensSource = value;
370
+ } else {
371
+ console.warn(`${fileName}:${lineNum}: unknown tokens source "${value}", defaulting to "figma"`);
372
+ }
373
+ break;
374
+ case 'figma-file-key': result.figmaFileKey = value; break;
375
+ case 'figma-collection': result.figmaCollection = value; break;
376
+ case 'tokens-file': result.tokensFile = value; break;
377
+ case 'app-icon': result.appIcon = value; break;
378
+ case 'favicon': result.favicon = value; break;
379
+ case 'og-image': result.ogImage = value; break;
380
+ case 'spacing':
381
+ activeBlock = 'spacing';
382
+ break;
383
+ case 'label':
384
+ activeBlock = 'label';
385
+ break;
386
+ case 'cta':
387
+ activeBlock = 'cta';
388
+ break;
389
+ default:
390
+ // Unknown keys silently ignored for forward compatibility
391
+ break;
392
+ }
393
+ }
394
+
395
+ // Store brand colors
396
+ if (Object.keys(brandColors).length > 0) {
397
+ result.brandColors = brandColors;
398
+ if (Object.keys(brandColorPrint).length > 0) result.brandColorPrint = brandColorPrint;
399
+ }
400
+
401
+ // Finalise inline tokens — resolve $ref values against brandColors
402
+ if (Object.keys(themes).length > 0) {
403
+ for (const theme of Object.values(themes)) {
404
+ for (const [slot, value] of Object.entries(theme)) {
405
+ if (value.startsWith('$')) {
406
+ const refName = value.slice(1);
407
+ const resolved = brandColors[refName];
408
+ if (resolved) {
409
+ theme[slot] = resolved;
410
+ } else {
411
+ console.warn(`${fileName}: unresolved brand color reference "$${refName}" in theme slot "${slot}"`);
412
+ }
413
+ }
414
+ }
415
+ }
416
+ result.themes = themes;
417
+ result.themeDescriptions = themeDescriptions;
418
+ }
419
+ if (Object.keys(spacingRaw).length > 0) {
420
+ result.spacing = {
421
+ unit: spacingRaw['unit'] ?? 'cqmin',
422
+ xs: parseFloat(spacingRaw['xs'] ?? '1.5'),
423
+ s: parseFloat(spacingRaw['s'] ?? '2'),
424
+ m: parseFloat(spacingRaw['m'] ?? '3'),
425
+ l: parseFloat(spacingRaw['l'] ?? '5'),
426
+ xl: parseFloat(spacingRaw['xl'] ?? '6.5'),
427
+ };
428
+ }
429
+
430
+ // Finalise dividers
431
+ if (Object.keys(dividersRaw).length > 0) {
432
+ const dividers: Record<string, ParsedDividerDef> = {};
433
+ // Resolve a color value: brand color name (accent1, accent2, …) → hex,
434
+ // semantic slot names → kept as-is for resolveDividerColor(), raw hex → pass through.
435
+ const resolveColor = (c: string): string => brandColors[c] ?? c;
436
+ for (const [name, raw] of Object.entries(dividersRaw)) {
437
+ const alignRaw = raw['align'] ?? 'left';
438
+ const align: ParsedDividerDef['align'] =
439
+ alignRaw === 'center' ? 'center' : alignRaw === 'right' ? 'right' : 'left';
440
+ dividers[name] = {
441
+ thickness: parseFloat(raw['thickness'] ?? '0.2'),
442
+ width: raw['width'] ?? '100%',
443
+ color: resolveColor(raw['color'] ?? 'text-tertiary'),
444
+ spacing: raw['spacing'] ?? 'xs',
445
+ align,
446
+ };
447
+ }
448
+ result.dividers = dividers;
449
+ }
450
+
451
+ // Finalise font primitives
452
+ if (fontsRaw.length > 0) {
453
+ const fonts: Record<string, ParsedFontDef> = {};
454
+ for (const { name, props } of fontsRaw) {
455
+ const fontName = props['name'];
456
+ if (!fontName) {
457
+ console.warn(`${fileName}: font block "${name}" missing "name:" property — skipping`);
458
+ continue;
459
+ }
460
+ const rawSource = props['source'];
461
+ const source: ParsedFontDef['source'] =
462
+ rawSource === 'system' ? 'system' : rawSource === 'google' ? 'google' : 'local';
463
+ const fallback = props['fallback'] ?? 'sans-serif';
464
+ const def: ParsedFontDef = { name: fontName, fallback, source };
465
+ if (props['variable']) def.variable = props['variable'];
466
+ if (props['variable-italic']) def.variableItalic = props['variable-italic'];
467
+ const staticInstances: Record<string, string> = {};
468
+ for (const [key, value] of Object.entries(props)) {
469
+ const match = key.match(/^static-(\d{3})(i)?$/);
470
+ if (match) staticInstances[match[1] + (match[2] ?? '')] = value;
471
+ }
472
+ if (Object.keys(staticInstances).length > 0) def.static = staticInstances;
473
+ fonts[name] = def;
474
+ }
475
+ if (Object.keys(fonts).length > 0) result.fonts = fonts;
476
+ }
477
+
478
+ // Finalise typographies
479
+ if (Object.keys(typographiesRaw).length > 0) {
480
+ const typographies: Record<string, ParsedTypographyDef> = {};
481
+ for (const [name, raw] of Object.entries(typographiesRaw)) {
482
+ const weight = parseInt(raw['weight'] ?? '400', 10);
483
+ const size = parseFloat(raw['size'] ?? '3');
484
+ const lineHeight = parseFloat(raw['lineHeight'] ?? '1.4');
485
+ if (isNaN(weight) || isNaN(size) || isNaN(lineHeight)) {
486
+ console.warn(`${fileName}: typography "${name}" has invalid numeric values — skipping`);
487
+ continue;
488
+ }
489
+ const def: ParsedTypographyDef = { weight, size, lineHeight };
490
+ if (raw['font'] !== undefined) def.font = raw['font'].replace(/^\$/, '');
491
+ if (raw['opsz'] !== undefined) def.opsz = parseFloat(raw['opsz']);
492
+ if (raw['letterSpacing'] !== undefined) def.letterSpacing = parseFloat(raw['letterSpacing']);
493
+ if (raw['italic'] === 'true' || raw['italic'] === '') def.italic = true;
494
+ if (raw['uppercase'] === 'true' || raw['uppercase'] === '') def.uppercase = true;
495
+ if (raw['strong'] !== undefined) def.strong = parseInt(raw['strong'], 10);
496
+ if (raw['autofit-min'] !== undefined) def.autofitMin = parseFloat(raw['autofit-min']);
497
+ if (raw['autofit-max'] !== undefined) def.autofitMax = parseFloat(raw['autofit-max']);
498
+ typographies[name] = def;
499
+ }
500
+ result.typographies = typographies;
501
+ }
502
+
503
+ // Finalise brand-declared assets
504
+ const toAssetDef = (raw: { id: string; props: Record<string, string> }): ParsedAssetDef => {
505
+ const def: ParsedAssetDef = { id: raw.id, label: raw.props['label'] ?? raw.id, src: raw.props['src'] ?? '' };
506
+ if (raw.props['aspect']) def.aspectRatio = parseFloat(raw.props['aspect']);
507
+ if (raw.props['scale']) def.scale = parseFloat(raw.props['scale']);
508
+ if (raw.props['lottie']) def.lottieSrc = raw.props['lottie'];
509
+ if (raw.props['animated'] === 'true') def.animated = true;
510
+ if (raw.props['loop'] === 'true') def.loop = true;
511
+ return def;
512
+ };
513
+ if (logosRaw.length > 0 || photosRaw.length > 0 || videosRaw.length > 0) {
514
+ result.assets = {};
515
+ if (logosRaw.length > 0) result.assets.logos = logosRaw.map(toAssetDef);
516
+ if (photosRaw.length > 0) result.assets.photos = photosRaw.map(toAssetDef);
517
+ if (videosRaw.length > 0) result.assets.videos = videosRaw.map(toAssetDef);
518
+ }
519
+
520
+ // Finalise label / cta badge styles
521
+ const parseBadgeStyle = (raw: Record<string, string>, defaults: ParsedBadgeStyle): ParsedBadgeStyle => {
522
+ const padding = (() => {
523
+ const p = raw['padding'];
524
+ if (!p) return defaults.padding;
525
+ const nums = p.split(/\s+/).map(parseFloat).filter(n => !isNaN(n));
526
+ if (nums.length === 1) return [nums[0], nums[0], nums[0], nums[0]] as [number, number, number, number];
527
+ if (nums.length === 2) return [nums[0], nums[1], nums[0], nums[1]] as [number, number, number, number];
528
+ if (nums.length === 3) return [nums[0], nums[1], nums[2], nums[1]] as [number, number, number, number];
529
+ if (nums.length >= 4) return [nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number];
530
+ return defaults.padding;
531
+ })();
532
+ const radius = raw['radius'] !== undefined ? parseFloat(raw['radius']) : defaults.radius;
533
+ return { padding, radius: isNaN(radius) ? defaults.radius : radius };
534
+ };
535
+ if (Object.keys(labelRaw).length > 0) {
536
+ result.label = parseBadgeStyle(labelRaw, { padding: [0.5, 1, 0.7, 1], radius: 0 });
537
+ }
538
+ if (Object.keys(ctaRaw).length > 0) {
539
+ result.cta = parseBadgeStyle(ctaRaw, { padding: [0.8, 2.5, 0.8, 2.5], radius: 999 });
540
+ }
541
+
542
+ // Validate required fields
543
+ const required: (keyof ParsedBrand)[] = ['id', 'name', 'language', 'locale'];
544
+ for (const field of required) {
545
+ if (!result[field]) {
546
+ throw new Error(`${fileName}: missing required field "${field}"`);
547
+ }
548
+ }
549
+
550
+ return result as ParsedBrand;
551
+ }