brand-intent 0.8.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -34,7 +34,7 @@
34
34
  ],
35
35
  "scripts": {
36
36
  "release": "./scripts/release.sh",
37
- "test": "tsx tests/brandParser.test.ts && tsx tests/realBrands.test.ts"
37
+ "test": "tsx tests/brandParser.test.ts && tsx tests/identityParser.test.ts && tsx tests/colorSystem.test.ts && tsx tests/realBrands.test.ts"
38
38
  },
39
39
  "devDependencies": {
40
40
  "tsx": "^4.19.0"
@@ -17,7 +17,25 @@ import type {
17
17
  StateColors,
18
18
  GroupKey,
19
19
  NeutralRole,
20
+ ColorRules,
21
+ ColorFamilies,
22
+ ColorFamily,
23
+ FamilyColor,
24
+ FamilyRole,
25
+ FamilyOrder,
26
+ FamilyBorrow,
27
+ ToneClass,
28
+ ThemePair,
20
29
  } from './types';
30
+ import {
31
+ DEFAULT_COLOR_RULES,
32
+ isHex6,
33
+ contrastRatio,
34
+ derivePairSlots,
35
+ resolveTextColor,
36
+ checkFamilyPair,
37
+ reachCoversAll,
38
+ } from './colorSystem';
21
39
 
22
40
  // ── Parsed type ────────────────────────────────────────────────────────────────
23
41
 
@@ -64,6 +82,14 @@ export interface ParsedBrand {
64
82
  colorAdherence?: Adherence;
65
83
  /** Per-brand tolerance overrides for the declared adherence level. */
66
84
  colorAdherenceOverrides?: AdherenceTolerances;
85
+ /** Contrast + tone rules (`color-rules` block). Absent when not declared. */
86
+ colorRules?: ColorRules;
87
+ /** Color families (`color-families` block): ordered sets with roles, reach config and checked pairs. */
88
+ colorFamilies?: ColorFamilies;
89
+ /** Ground / figure / text for every pair theme (authored `ground:`/`figure:` themes and emitted family pairs). */
90
+ themePairs?: Record<string, ThemePair>;
91
+ /** Non-fatal color findings: rule violations, rejected family pairs, reach config hints. */
92
+ colorWarnings?: string[];
67
93
  /** Inline themes: theme name → slot name → resolved hex color (only when tokensSource === 'inline') */
68
94
  themes?: Record<string, Record<string, string>>;
69
95
  /** Print annotations from inline # comments: slot name → annotation text */
@@ -91,8 +117,19 @@ export interface ParsedAssetDef {
91
117
  lottieSrc?: string;
92
118
  animated?: boolean;
93
119
  loop?: boolean;
120
+ /** Path to a glTF/GLB 3D model. When present, the asset renders as a live 3D object; `src` remains the 2D fallback. */
121
+ gltfSrc?: string;
122
+ /** Procedural generator id (e.g. "mobius", "spiral", "ribbon"). Renders as live geometry; `src` is the 2D fallback. */
123
+ generator?: string;
124
+ /** Default parameters for the procedural generator, keyed by param name. Declared via the indented `generator-params` sub-block. */
125
+ generatorParams?: Record<string, number>;
126
+ /** Logos only: artwork per horizontal placement, declared via `align-left` / `align-center` / `align-right`
127
+ * sub-blocks. Consumers render the variant matching where the logo sits, else the base `src`. */
128
+ alignVariants?: Partial<Record<LogoAlign, { src: string; aspectRatio?: number }>>;
94
129
  }
95
130
 
131
+ export type LogoAlign = 'left' | 'center' | 'right';
132
+
96
133
  export interface ParsedBadgeStyle {
97
134
  /** Padding in cqh: [top, right, bottom, left] */
98
135
  padding: [number, number, number, number];
@@ -166,6 +203,16 @@ const STATE_KEYS = new Set<string>(['problem', 'warning', 'success', 'info', 'in
166
203
  const VALID_COLOR_MODES = new Set<ColorMode>(['static', 'dynamic', 'guided']);
167
204
  const VALID_STRATEGIES = new Set<PaletteStrategy>(['monochrome', 'duotone', 'tricolor', 'spectrum']);
168
205
  const VALID_ADHERENCES = new Set<Adherence>(['strict', 'on-brand', 'flexible']);
206
+ const TONE_CLASSES = new Set<ToneClass>(['light', 'mid', 'dark']);
207
+ const FAMILY_ROLES = new Set<FamilyRole>(['core', 'accent', 'contrast']);
208
+ const FAMILY_ORDERS = new Set<FamilyOrder>(['cycle', 'sequence']);
209
+ const FAMILY_BORROWS = new Set<FamilyBorrow>(['previous', 'next', 'both', 'none']);
210
+ const PAIR_KEYS = ['ground', 'figure', 'text'] as const;
211
+
212
+ /** Flat-projection key for a display name: lowercased, spaces → hyphens. */
213
+ function colorKey(name: string): string {
214
+ return name.toLowerCase().replace(/\s+/g, '-');
215
+ }
169
216
 
170
217
  /**
171
218
  * Expand a 3-digit hex to 6-digit. Pass-through for 6 or 8-digit.
@@ -280,7 +327,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
280
327
  tokensSource: 'figma',
281
328
  };
282
329
 
283
- type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta';
330
+ type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'color-rules' | 'color-families' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta';
284
331
  let activeBlock: ActiveBlock = 'none';
285
332
  let currentThemeName = '';
286
333
  let currentDividerName = '';
@@ -301,6 +348,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
301
348
  const stateColorsRaw: Record<string, string> = {};
302
349
  const colorBoundsRaw: Record<string, string> = {};
303
350
  const adherenceOverridesRaw: Record<string, string> = {};
351
+ const colorRulesRaw: Record<string, string> = {};
352
+ let colorRulesDeclared = false;
353
+ type RawFamily = { name: string; line: number; colors: Array<{ role: string; value: string; line: number }>; pairs: Array<{ ground: string; figure: string; line: number }> };
354
+ const familiesProps: Record<string, string> = {};
355
+ const familiesRaw: RawFamily[] = [];
356
+ let familiesDeclared = false;
357
+ let currentFamily: RawFamily | null = null;
358
+ let inPairs = false;
304
359
  const themes: Record<string, Record<string, string>> = {};
305
360
  const themeDescriptions: Record<string, string> = {};
306
361
  const spacingRaw: Record<string, string> = {};
@@ -312,6 +367,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
312
367
  const videosRaw: Array<{ id: string; props: Record<string, string> }> = [];
313
368
  const illustrationsRaw: Array<{ id: string; props: Record<string, string> }> = [];
314
369
  let currentAssetId = '';
370
+ let currentSubBlock: string | null = null;
315
371
  const labelRaw: Record<string, string> = {};
316
372
  const ctaRaw: Record<string, string> = {};
317
373
 
@@ -502,6 +558,56 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
502
558
  }
503
559
  continue;
504
560
  }
561
+ if (activeBlock === 'color-rules') {
562
+ const trimmed = raw.trim();
563
+ const colonIdx = trimmed.indexOf(':');
564
+ if (colonIdx !== -1) {
565
+ colorRulesRaw[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
566
+ }
567
+ continue;
568
+ }
569
+ if (activeBlock === 'color-families') {
570
+ const trimmed = raw.trim();
571
+ if (trimmed === '') continue;
572
+ const familyMatch = trimmed.match(/^family\s+(\S+)$/);
573
+ if (familyMatch) {
574
+ if (familiesRaw.some(f => f.name === familyMatch[1])) {
575
+ throw new Error(`${fileName}:${lineNum}: duplicate family "${familyMatch[1]}"`);
576
+ }
577
+ currentFamily = { name: familyMatch[1], line: lineNum, colors: [], pairs: [] };
578
+ familiesRaw.push(currentFamily);
579
+ inPairs = false;
580
+ continue;
581
+ }
582
+ if (!currentFamily) {
583
+ const colonIdx = trimmed.indexOf(':');
584
+ if (colonIdx === -1) {
585
+ throw new Error(`${fileName}:${lineNum}: expected "key: value" or "family NAME" in color-families, got "${trimmed}"`);
586
+ }
587
+ familiesProps[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
588
+ continue;
589
+ }
590
+ if (trimmed === 'pairs') {
591
+ inPairs = true;
592
+ continue;
593
+ }
594
+ if (inPairs && trimmed.includes('/')) {
595
+ const parts = trimmed.split('/').map(p => p.trim());
596
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
597
+ throw new Error(`${fileName}:${lineNum}: pair must be "ground / figure", got "${trimmed}"`);
598
+ }
599
+ currentFamily.pairs.push({ ground: parts[0], figure: parts[1], line: lineNum });
600
+ continue;
601
+ }
602
+ const colonIdx = trimmed.indexOf(':');
603
+ const key = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : trimmed;
604
+ if (colonIdx === -1 || !FAMILY_ROLES.has(key as FamilyRole)) {
605
+ throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected core / accent / contrast / pairs, got "${trimmed}"`);
606
+ }
607
+ inPairs = false;
608
+ currentFamily.colors.push({ role: key, value: trimmed.slice(colonIdx + 1).trim(), line: lineNum });
609
+ continue;
610
+ }
505
611
  if (activeBlock === 'theme' && currentThemeName) {
506
612
  // slot: #HEX or $ref # optional comment
507
613
  const colonIdx = raw.indexOf(':');
@@ -569,19 +675,33 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
569
675
  continue;
570
676
  }
571
677
  if ((activeBlock === 'logo' || activeBlock === 'photo' || activeBlock === 'video' || activeBlock === 'illustration') && currentAssetId) {
678
+ const indent = raw.match(/^(\s*)/)?.[1].length ?? 0;
572
679
  const colonIdx = raw.indexOf(':');
573
680
  if (colonIdx !== -1) {
574
681
  const key = raw.slice(0, colonIdx).trim();
575
682
  const value = raw.slice(colonIdx + 1).trim();
576
683
  const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : activeBlock === 'video' ? videosRaw : illustrationsRaw;
577
684
  const last = arr[arr.length - 1];
578
- if (last && last.id === currentAssetId) last.props[key] = value;
685
+ if (last && last.id === currentAssetId) {
686
+ // 4+ space indent after a bare sub-block keyword (e.g. generator-params) → nested param.
687
+ // Store under "subBlock.key" so toAssetDef can split it out.
688
+ if (indent >= 4 && currentSubBlock) {
689
+ last.props[`${currentSubBlock}.${key}`] = value;
690
+ } else {
691
+ last.props[key] = value;
692
+ currentSubBlock = null;
693
+ }
694
+ }
579
695
  } else {
580
696
  const trimmed = raw.trim();
581
697
  if (trimmed === 'animated' || trimmed === 'loop') {
582
698
  const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : activeBlock === 'video' ? videosRaw : illustrationsRaw;
583
699
  const last = arr[arr.length - 1];
584
700
  if (last && last.id === currentAssetId) last.props[trimmed] = 'true';
701
+ currentSubBlock = null;
702
+ } else if (trimmed === 'generator-params' || (activeBlock === 'logo' && /^align-(left|center|right)$/.test(trimmed))) {
703
+ // Open a nested sub-block — subsequent deeper-indented key: value lines attach here.
704
+ currentSubBlock = trimmed;
585
705
  }
586
706
  }
587
707
  continue;
@@ -627,6 +747,23 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
627
747
  continue;
628
748
  }
629
749
 
750
+ // color-rules — contrast minimums and tone rules
751
+ if (raw.trim() === 'color-rules') {
752
+ activeBlock = 'color-rules';
753
+ colorRulesDeclared = true;
754
+ continue;
755
+ }
756
+
757
+ // color-families — ordered color sets with roles, reach and pairs
758
+ if (raw.trim() === 'color-families') {
759
+ if (familiesDeclared) throw new Error(`${fileName}:${lineNum}: duplicate color-families block`);
760
+ activeBlock = 'color-families';
761
+ familiesDeclared = true;
762
+ currentFamily = null;
763
+ inPairs = false;
764
+ continue;
765
+ }
766
+
630
767
  // theme ThemeName — starts a theme block
631
768
  const themeMatch = raw.match(/^theme\s+(\S+)/);
632
769
  if (themeMatch) {
@@ -810,6 +947,91 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
810
947
  }
811
948
  }
812
949
 
950
+ const colorWarnings: string[] = [];
951
+
952
+ // ── Finalise color-rules ──────────────────────────────────────────────────
953
+ const rules: ColorRules = { ...DEFAULT_COLOR_RULES, avoid: [], tones: [...DEFAULT_COLOR_RULES.tones] as [number, number] };
954
+ if (colorRulesDeclared) {
955
+ const num = (key: string) => {
956
+ const n = parseFloat(colorRulesRaw[key]);
957
+ if (isNaN(n) || n < 1 || n > 21) {
958
+ throw new Error(`${fileName}: color-rules "${key}" must be a contrast ratio between 1 and 21, got "${colorRulesRaw[key]}"`);
959
+ }
960
+ return n;
961
+ };
962
+ if (colorRulesRaw.text !== undefined) rules.text = num('text');
963
+ if (colorRulesRaw.figure !== undefined) rules.figure = num('figure');
964
+ if (colorRulesRaw.avoid !== undefined) {
965
+ for (const combo of colorRulesRaw.avoid.split(',').map(c => c.trim()).filter(Boolean)) {
966
+ const parts = combo.split('+').map(c => c.trim());
967
+ if (parts.length !== 2 || !parts.every(t => TONE_CLASSES.has(t as ToneClass))) {
968
+ throw new Error(`${fileName}: color-rules avoid "${combo}" must be two of light / mid / dark joined by "+"`);
969
+ }
970
+ rules.avoid.push([parts[0] as ToneClass, parts[1] as ToneClass]);
971
+ }
972
+ }
973
+ if (colorRulesRaw.tones !== undefined) {
974
+ const t = colorRulesRaw.tones.split(',').map(v => parseFloat(v.trim()));
975
+ if (t.length !== 2 || t.some(isNaN) || t[0] >= t[1]) {
976
+ throw new Error(`${fileName}: color-rules tones must be two ascending contrast values, e.g. "1.8, 4.5"`);
977
+ }
978
+ rules.tones = [t[0], t[1]];
979
+ }
980
+ result.colorRules = rules;
981
+ }
982
+
983
+ // ── Finalise color-families: colors + flat projection (pairs later, after themes) ──
984
+ let colorFamilies: ColorFamilies | undefined;
985
+ if (familiesDeclared) {
986
+ const order = (familiesProps.order ?? 'cycle') as FamilyOrder;
987
+ if (!FAMILY_ORDERS.has(order)) throw new Error(`${fileName}: color-families order must be "cycle" or "sequence", got "${order}"`);
988
+ const borrow = (familiesProps.borrow ?? 'both') as FamilyBorrow;
989
+ if (!FAMILY_BORROWS.has(borrow)) throw new Error(`${fileName}: color-families borrow must be previous / next / both / none, got "${borrow}"`);
990
+ const distance = familiesProps.distance !== undefined ? Number(familiesProps.distance) : 1;
991
+ if (!Number.isInteger(distance) || distance < 1) throw new Error(`${fileName}: color-families distance must be a whole number ≥ 1, got "${familiesProps.distance}"`);
992
+ if (familiesRaw.length === 0) throw new Error(`${fileName}: color-families declares no family`);
993
+
994
+ const shared: Array<{ name: string; hex: string }> = [];
995
+ for (const ref of (familiesProps.shared ?? '').split(',').map(r => r.trim()).filter(Boolean)) {
996
+ if (!ref.startsWith('$')) throw new Error(`${fileName}: color-families shared entries must be $refs to brand-colors, got "${ref}"`);
997
+ const hex = brandColors[ref.slice(1)] ? normalizeHex(brandColors[ref.slice(1)]) : null;
998
+ if (!hex || !isHex6(hex)) throw new Error(`${fileName}: color-families shared "${ref}" is not a known 6-digit brand color`);
999
+ shared.push({ name: ref.slice(1), hex });
1000
+ }
1001
+
1002
+ const families: ColorFamily[] = [];
1003
+ for (const raw of familiesRaw) {
1004
+ const counts: Record<string, number> = {};
1005
+ for (const c of raw.colors) counts[c.role] = (counts[c.role] ?? 0) + 1;
1006
+ if (counts.core !== 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" needs exactly one core color`);
1007
+ if (counts.contrast !== 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" needs exactly one contrast color`);
1008
+ const seen: Record<string, number> = {};
1009
+ const colors: FamilyColor[] = raw.colors.map(c => {
1010
+ const m = c.value.match(/^(#[0-9a-fA-F]{3,6})(?:\s+(.+))?$/);
1011
+ const hex = m ? normalizeHex(m[1]) : null;
1012
+ if (!m || !hex || !isHex6(hex)) {
1013
+ throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} must be "#HEX" or "#HEX Name", got "${c.value}"`);
1014
+ }
1015
+ seen[c.role] = (seen[c.role] ?? 0) + 1;
1016
+ const generated = `${raw.name.toLowerCase()}-${c.role}${counts[c.role] > 1 ? `-${seen[c.role]}` : ''}`;
1017
+ const name = m[2]?.trim() || generated;
1018
+ return { role: c.role as FamilyRole, hex, name, key: colorKey(name) };
1019
+ });
1020
+ for (const c of colors) {
1021
+ if (brandColors[c.key]) {
1022
+ throw new Error(`${fileName}: family "${raw.name}" color name "${c.name}" collides with an existing brand color`);
1023
+ }
1024
+ brandColors[c.key] = c.hex;
1025
+ }
1026
+ families.push({ name: raw.name, colors, pairs: [] });
1027
+ }
1028
+ colorFamilies = { order, borrow, distance, shared, families };
1029
+ if (borrow !== 'none' && reachCoversAll(families.length, { order, borrow, distance })) {
1030
+ colorWarnings.push(`color-families: borrow "${borrow}" with distance ${distance} reaches every family — the borrow rule has no effect`);
1031
+ }
1032
+ result.colorFamilies = colorFamilies;
1033
+ }
1034
+
813
1035
  // Store brand colors (flat projection)
814
1036
  if (Object.keys(brandColors).length > 0) {
815
1037
  result.brandColors = brandColors;
@@ -852,10 +1074,10 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
852
1074
  }
853
1075
 
854
1076
  // ── Minimum-structure validation for color-mode: guided ───────────────────
855
- if (result.colorMode === 'guided') {
1077
+ if (result.colorMode === 'guided' && !(colorFamilies && colorFamilies.families.length >= 2)) {
856
1078
  if (colorGroups.length === 0) {
857
1079
  throw new Error(
858
- `${fileName}: color-mode: guided requires grouped brand-colors syntax — flat syntax is not sufficient. See the Minimum Structure section in the guided-colors spec.`,
1080
+ `${fileName}: color-mode: guided requires grouped brand-colors syntax or a color-families block with at least two families — flat syntax alone is not sufficient.`,
859
1081
  );
860
1082
  }
861
1083
  const primary = colorGroups.find(g => g.key === 'primary');
@@ -889,15 +1111,97 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
889
1111
  //
890
1112
  // Alpha is expressed as `<number>%`, 0–100, separated from the base value by exactly one space.
891
1113
  // 100% emits solid hex (not rgba) to keep output clean.
892
- if (Object.keys(themes).length > 0) {
893
- for (const theme of Object.values(themes)) {
894
- for (const [slot, value] of Object.entries(theme)) {
895
- theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot);
1114
+ //
1115
+ // Pair themes: `ground:` + `figure:` (+ optional `text:`) derive every slot not written explicitly.
1116
+ const themePairs: Record<string, ThemePair> = {};
1117
+ const neutral = colorGroups.find(g => g.key === 'neutral');
1118
+ const neutralText = ['dark', 'light']
1119
+ .map(role => neutral?.entries.find(e => e.role === role)?.hex)
1120
+ .filter((h): h is string => !!h);
1121
+ for (const [name, theme] of Object.entries(themes)) {
1122
+ const pairRaw: Partial<Record<typeof PAIR_KEYS[number], string>> = {};
1123
+ for (const key of PAIR_KEYS) {
1124
+ if (theme[key] !== undefined) { pairRaw[key] = theme[key]; delete theme[key]; }
1125
+ }
1126
+ for (const [slot, value] of Object.entries(theme)) {
1127
+ theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot);
1128
+ }
1129
+ if (Object.keys(pairRaw).length === 0) continue;
1130
+ if (!pairRaw.ground || !pairRaw.figure) {
1131
+ throw new Error(`${fileName}: theme "${name}" needs both "ground:" and "figure:" (pair theme), or neither`);
1132
+ }
1133
+ const solid = (key: string, value: string) => {
1134
+ const hex = resolveThemeSlotValue(value, brandColors, fileName, key);
1135
+ if (!isHex6(hex)) throw new Error(`${fileName}: theme "${name}" ${key} must be a solid 6-digit color, got "${value}"`);
1136
+ return hex;
1137
+ };
1138
+ const ground = solid('ground', pairRaw.ground);
1139
+ const figure = solid('figure', pairRaw.figure);
1140
+ let text: string;
1141
+ if (pairRaw.text) {
1142
+ text = solid('text', pairRaw.text);
1143
+ } else {
1144
+ const resolved = resolveTextColor(ground, neutralText, rules);
1145
+ text = resolved.text;
1146
+ if (!resolved.passes) {
1147
+ colorWarnings.push(`theme "${name}": no text color reaches ${rules.text}:1 on ${ground} — using ${text} (${resolved.ratio.toFixed(1)}:1)`);
896
1148
  }
897
1149
  }
1150
+ themes[name] = { ...derivePairSlots(ground, figure, text), ...theme };
1151
+ themePairs[name] = { ground, figure, text };
1152
+ }
1153
+
1154
+ // Family pairs → checked; valid ones become themes named `{Family}-{n}` (n = declared position).
1155
+ if (colorFamilies) {
1156
+ const config = { order: colorFamilies.order, borrow: colorFamilies.borrow, distance: colorFamilies.distance };
1157
+ colorFamilies.families.forEach((family, index) => {
1158
+ const raw = familiesRaw[index];
1159
+ raw.pairs.forEach((p, n) => {
1160
+ const resolve = (value: string) => {
1161
+ const hex = value.startsWith('$') ? (brandColors[value.slice(1)] ? normalizeHex(brandColors[value.slice(1)]) : null) : normalizeHex(value);
1162
+ if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${p.line}: pair color "${value}" is not a known 6-digit color`);
1163
+ return hex;
1164
+ };
1165
+ const check = checkFamilyPair(resolve(p.ground), resolve(p.figure), index, colorFamilies!, config, rules, neutralText);
1166
+ const themeName = `${family.name}-${n + 1}`;
1167
+ if (check.valid) {
1168
+ if (themes[themeName]) throw new Error(`${fileName}: family pair theme "${themeName}" collides with an authored theme`);
1169
+ themes[themeName] = derivePairSlots(check.ground, check.figure, check.text);
1170
+ themePairs[themeName] = { ground: check.ground, figure: check.figure, text: check.text, family: family.name };
1171
+ family.pairs.push({ ...check, theme: themeName });
1172
+ if (!check.textPasses) {
1173
+ colorWarnings.push(`${themeName}: no text color reaches ${rules.text}:1 on ${check.ground} — using ${check.text} (${check.textRatio.toFixed(1)}:1)`);
1174
+ }
1175
+ } else {
1176
+ family.pairs.push(check);
1177
+ colorWarnings.push(`${themeName} (${check.ground} / ${check.figure}) rejected: ${check.reasons.join('; ')}`);
1178
+ }
1179
+ });
1180
+ });
1181
+ }
1182
+
1183
+ // Rule checks on every theme (only when the brand declares color-rules).
1184
+ if (colorRulesDeclared) {
1185
+ for (const [name, theme] of Object.entries(themes)) {
1186
+ const bg = theme['background'];
1187
+ if (!bg || !isHex6(bg)) continue;
1188
+ const txt = theme['text-primary'];
1189
+ if (txt && isHex6(txt) && contrastRatio(bg, txt) < rules.text && !themePairs[name]) {
1190
+ colorWarnings.push(`theme "${name}": text-primary ${txt} on ${bg} is ${contrastRatio(bg, txt).toFixed(1)}:1, below ${rules.text}:1`);
1191
+ }
1192
+ const fig = theme['logo'];
1193
+ if (fig && isHex6(fig) && contrastRatio(bg, fig) < rules.figure && !themePairs[name]?.family) {
1194
+ colorWarnings.push(`theme "${name}": logo ${fig} on ${bg} is ${contrastRatio(bg, fig).toFixed(1)}:1, below ${rules.figure}:1`);
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ if (Object.keys(themes).length > 0) {
898
1200
  result.themes = themes;
899
1201
  result.themeDescriptions = themeDescriptions;
900
1202
  }
1203
+ if (Object.keys(themePairs).length > 0) result.themePairs = themePairs;
1204
+ if (colorWarnings.length > 0) result.colorWarnings = colorWarnings;
901
1205
  if (Object.keys(spacingRaw).length > 0) {
902
1206
  result.spacing = {
903
1207
  unit: spacingRaw['unit'] ?? 'cqmin',
@@ -986,6 +1290,25 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
986
1290
  if (raw.props['lottie']) def.lottieSrc = raw.props['lottie'];
987
1291
  if (raw.props['animated'] === 'true') def.animated = true;
988
1292
  if (raw.props['loop'] === 'true') def.loop = true;
1293
+ if (raw.props['gltf']) def.gltfSrc = raw.props['gltf'];
1294
+ if (raw.props['generator']) def.generator = raw.props['generator'];
1295
+ // generator-params are stored as "generator-params.KEY" by the sub-block parser.
1296
+ const paramEntries: [string, number][] = [];
1297
+ for (const [k, v] of Object.entries(raw.props)) {
1298
+ if (k.startsWith('generator-params.')) {
1299
+ const paramKey = k.slice('generator-params.'.length);
1300
+ const n = parseFloat(v);
1301
+ if (!Number.isNaN(n)) paramEntries.push([paramKey, n]);
1302
+ }
1303
+ }
1304
+ if (paramEntries.length > 0) def.generatorParams = Object.fromEntries(paramEntries);
1305
+ // align-* sub-blocks are stored as "align-left.src" / "align-left.aspect".
1306
+ for (const align of ['left', 'center', 'right'] as const) {
1307
+ const src = raw.props[`align-${align}.src`];
1308
+ if (!src) continue;
1309
+ const aspect = raw.props[`align-${align}.aspect`];
1310
+ def.alignVariants = { ...def.alignVariants, [align]: { src, ...(aspect ? { aspectRatio: parseFloat(aspect) } : {}) } };
1311
+ }
989
1312
  return def;
990
1313
  };
991
1314
  if (logosRaw.length > 0 || photosRaw.length > 0 || videosRaw.length > 0 || illustrationsRaw.length > 0) {
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Color system helpers — pair themes, color rules, color families.
3
+ *
4
+ * Pure functions shared by the parser (validation at parse time) and by
5
+ * consumers (guided generation, test views with live config overrides).
6
+ * Contrast math follows WCAG 2.x relative luminance.
7
+ */
8
+
9
+ import type {
10
+ ColorRules,
11
+ ColorFamilies,
12
+ ColorFamily,
13
+ FamilyBorrow,
14
+ FamilyOrder,
15
+ ToneClass,
16
+ PairOrigin,
17
+ PairCheck,
18
+ } from './types';
19
+
20
+ // ── Contrast ────────────────────────────────────────────────────────────────
21
+
22
+ const HEX6 = /^#[0-9a-fA-F]{6}$/;
23
+
24
+ /** True for a 6-digit `#RRGGBB` string (the only form the contrast math accepts). */
25
+ export function isHex6(value: string): boolean {
26
+ return HEX6.test(value);
27
+ }
28
+
29
+ /** WCAG relative luminance of a 6-digit hex. */
30
+ export function relativeLuminance(hex: string): number {
31
+ const channel = (i: number) => {
32
+ const v = parseInt(hex.slice(i, i + 2), 16) / 255;
33
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
34
+ };
35
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
36
+ }
37
+
38
+ /** WCAG contrast ratio between two 6-digit hex colors (1–21). */
39
+ export function contrastRatio(a: string, b: string): number {
40
+ const la = relativeLuminance(a);
41
+ const lb = relativeLuminance(b);
42
+ const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
43
+ return (hi + 0.05) / (lo + 0.05);
44
+ }
45
+
46
+ // ── Rules ───────────────────────────────────────────────────────────────────
47
+
48
+ export const DEFAULT_COLOR_RULES: ColorRules = {
49
+ text: 4.5,
50
+ figure: 3,
51
+ avoid: [],
52
+ tones: [1.8, 4.5],
53
+ };
54
+
55
+ /** Tone class from contrast against white: below tones[0] light, at/above tones[1] dark. */
56
+ export function toneClass(hex: string, tones: [number, number] = DEFAULT_COLOR_RULES.tones): ToneClass {
57
+ const vsWhite = contrastRatio(hex, '#FFFFFF');
58
+ if (vsWhite < tones[0]) return 'light';
59
+ if (vsWhite >= tones[1]) return 'dark';
60
+ return 'mid';
61
+ }
62
+
63
+ /** Rule violations for a ground/figure pair (empty = passes). Reach is checked separately. */
64
+ export function pairRuleViolations(ground: string, figure: string, rules: ColorRules): string[] {
65
+ const reasons: string[] = [];
66
+ const ratio = contrastRatio(ground, figure);
67
+ if (ratio < rules.figure) {
68
+ reasons.push(`contrast ${ratio.toFixed(1)}:1 below ${rules.figure}:1`);
69
+ }
70
+ const tg = toneClass(ground, rules.tones);
71
+ const tf = toneClass(figure, rules.tones);
72
+ for (const [a, b] of rules.avoid) {
73
+ if ((tg === a && tf === b) || (tg === b && tf === a)) {
74
+ reasons.push(`${tg}+${tf} combination avoided`);
75
+ break;
76
+ }
77
+ }
78
+ return reasons;
79
+ }
80
+
81
+ // ── Pair themes ─────────────────────────────────────────────────────────────
82
+
83
+ /** Alpha stops for the derived text tiers below text-primary. */
84
+ const TEXT_TIERS: Array<[string, number]> = [
85
+ ['text-secondary', 0.85],
86
+ ['text-tertiary', 0.7],
87
+ ['text-subtle', 0.55],
88
+ ['text-muted', 0.4],
89
+ ];
90
+
91
+ function withAlpha(hex: string, alpha: number): string {
92
+ const r = parseInt(hex.slice(1, 3), 16);
93
+ const g = parseInt(hex.slice(3, 5), 16);
94
+ const b = parseInt(hex.slice(5, 7), 16);
95
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
96
+ }
97
+
98
+ /** Full theme slot map derived from a ground / figure / text triple. */
99
+ export function derivePairSlots(ground: string, figure: string, text: string): Record<string, string> {
100
+ const slots: Record<string, string> = {
101
+ 'background': ground,
102
+ 'text-primary': text,
103
+ };
104
+ for (const [slot, alpha] of TEXT_TIERS) slots[slot] = withAlpha(text, alpha);
105
+ for (const slot of ['logo', 'cta', 'divider', 'icon', 'illustration', 'sticker-bg', 'label-bg']) {
106
+ slots[slot] = figure;
107
+ }
108
+ slots['sticker-fg'] = ground;
109
+ slots['label-fg'] = ground;
110
+ return slots;
111
+ }
112
+
113
+ /**
114
+ * Resolve the text color for a ground.
115
+ * `preferred` candidates are tried in order; the first reaching `rules.text` wins.
116
+ * When none passes, the highest-contrast preferred candidate is used (`passes: false`).
117
+ * Black/white are only considered when no preferred candidates exist.
118
+ */
119
+ export function resolveTextColor(
120
+ ground: string,
121
+ preferred: string[],
122
+ rules: ColorRules,
123
+ ): { text: string; ratio: number; passes: boolean } {
124
+ const candidates = preferred.filter(isHex6);
125
+ const pool = candidates.length > 0 ? candidates : ['#000000', '#FFFFFF'];
126
+ for (const c of pool) {
127
+ const ratio = contrastRatio(ground, c);
128
+ if (ratio >= rules.text) return { text: c, ratio, passes: true };
129
+ }
130
+ let best = pool[0];
131
+ let bestRatio = contrastRatio(ground, best);
132
+ for (const c of pool.slice(1)) {
133
+ const ratio = contrastRatio(ground, c);
134
+ if (ratio > bestRatio) { best = c; bestRatio = ratio; }
135
+ }
136
+ return { text: best, ratio: bestRatio, passes: false };
137
+ }
138
+
139
+ // ── Families ────────────────────────────────────────────────────────────────
140
+
141
+ export interface ReachConfig {
142
+ order: FamilyOrder;
143
+ borrow: FamilyBorrow;
144
+ distance: number;
145
+ }
146
+
147
+ /** Families reachable from `index` (excluding itself), labelled by direction. */
148
+ export function reachableFamilies(count: number, index: number, config: ReachConfig): Map<number, 'prev' | 'next'> {
149
+ const reach = new Map<number, 'prev' | 'next'>();
150
+ if (config.borrow === 'none') return reach;
151
+ const wantPrev = config.borrow === 'previous' || config.borrow === 'both';
152
+ const wantNext = config.borrow === 'next' || config.borrow === 'both';
153
+ for (let d = 1; d <= config.distance; d++) {
154
+ if (wantPrev) {
155
+ const j = index - d;
156
+ if (j >= 0) reach.set(j, 'prev');
157
+ else if (config.order === 'cycle') reach.set(((j % count) + count) % count, 'prev');
158
+ }
159
+ if (wantNext) {
160
+ const j = index + d;
161
+ if (j < count) { if (!reach.has(j)) reach.set(j, 'next'); }
162
+ else if (config.order === 'cycle') { const w = j % count; if (!reach.has(w)) reach.set(w, 'next'); }
163
+ }
164
+ }
165
+ reach.delete(index);
166
+ return reach;
167
+ }
168
+
169
+ /** True when the reach config lets every family borrow from every other one. */
170
+ export function reachCoversAll(count: number, config: ReachConfig): boolean {
171
+ if (count < 3) return false;
172
+ for (let i = 0; i < count; i++) {
173
+ if (reachableFamilies(count, i, config).size < count - 1) return false;
174
+ }
175
+ return true;
176
+ }
177
+
178
+ /** Where a color comes from, seen from family `index`. Null = not usable there. */
179
+ export function colorOrigin(
180
+ hex: string,
181
+ index: number,
182
+ system: Pick<ColorFamilies, 'families' | 'shared'>,
183
+ config: ReachConfig,
184
+ ): PairOrigin | null {
185
+ const families = system.families;
186
+ if (families[index]?.colors.some(c => c.hex === hex)) return 'own';
187
+ if (system.shared.some(s => s.hex === hex)) return 'shared';
188
+ const reach = reachableFamilies(families.length, index, config);
189
+ for (const [j, dir] of reach) {
190
+ if (families[j].colors.some(c => c.hex === hex)) return dir;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /** Name of the family that declares `hex` (first match), for error messages. */
196
+ export function owningFamily(hex: string, families: ColorFamily[]): string | undefined {
197
+ return families.find(f => f.colors.some(c => c.hex === hex))?.name;
198
+ }
199
+
200
+ /** Text candidates for a family ground: its contrast color(s), then shared colors, then extras. */
201
+ export function familyTextCandidates(family: ColorFamily, shared: Array<{ hex: string }>, extra: string[] = []): string[] {
202
+ const out: string[] = [];
203
+ for (const c of family.colors) if (c.role === 'contrast') out.push(c.hex);
204
+ for (const s of shared) out.push(s.hex);
205
+ for (const e of extra) out.push(e);
206
+ return [...new Set(out)];
207
+ }
208
+
209
+ /** Validate one ground/figure pair for family `index` against reach + rules. */
210
+ export function checkFamilyPair(
211
+ ground: string,
212
+ figure: string,
213
+ index: number,
214
+ system: ColorFamilies,
215
+ config: ReachConfig,
216
+ rules: ColorRules,
217
+ extraTextCandidates: string[] = [],
218
+ ): PairCheck {
219
+ const reasons: string[] = [];
220
+ const origins: [PairOrigin | null, PairOrigin | null] = [
221
+ colorOrigin(ground, index, system, config),
222
+ colorOrigin(figure, index, system, config),
223
+ ];
224
+ for (const [hex, origin] of [[ground, origins[0]], [figure, origins[1]]] as const) {
225
+ if (origin === null) {
226
+ const owner = owningFamily(hex, system.families);
227
+ reasons.push(owner ? `${hex} from ${owner} is out of reach` : `${hex} is not in any family or shared`);
228
+ }
229
+ }
230
+ reasons.push(...pairRuleViolations(ground, figure, rules));
231
+ const family = system.families[index];
232
+ const text = resolveTextColor(ground, familyTextCandidates(family, system.shared, extraTextCandidates), rules);
233
+ return {
234
+ ground,
235
+ figure,
236
+ text: text.text,
237
+ figureRatio: contrastRatio(ground, figure),
238
+ textRatio: text.ratio,
239
+ textPasses: text.passes,
240
+ tones: [toneClass(ground, rules.tones), toneClass(figure, rules.tones)],
241
+ origins: [origins[0] ?? 'outside', origins[1] ?? 'outside'],
242
+ valid: reasons.length === 0,
243
+ reasons,
244
+ };
245
+ }
246
+
247
+ /** Every ordered ground/figure pair available to family `index`, checked. */
248
+ export function enumerateFamilyPairs(
249
+ index: number,
250
+ system: ColorFamilies,
251
+ config: ReachConfig,
252
+ rules: ColorRules,
253
+ extraTextCandidates: string[] = [],
254
+ ): PairCheck[] {
255
+ const pool = new Set<string>();
256
+ for (const c of system.families[index].colors) pool.add(c.hex);
257
+ for (const s of system.shared) pool.add(s.hex);
258
+ for (const j of reachableFamilies(system.families.length, index, config).keys()) {
259
+ for (const c of system.families[j].colors) pool.add(c.hex);
260
+ }
261
+ const colors = [...pool];
262
+ const out: PairCheck[] = [];
263
+ for (const g of colors) {
264
+ for (const f of colors) {
265
+ if (g === f) continue;
266
+ out.push(checkFamilyPair(g, f, index, system, config, rules, extraTextCandidates));
267
+ }
268
+ }
269
+ return out;
270
+ }
@@ -26,6 +26,8 @@ export interface ParsedIdentity {
26
26
  tagline?: string;
27
27
  /** Competitive positioning — what makes this brand the only one of its kind */
28
28
  positioning?: string;
29
+ /** Brand purpose — why the brand exists, the inner motivation (distinct from promise, mission, vision) */
30
+ purpose?: string;
29
31
  /** Organizational mission — what the brand does, for whom */
30
32
  mission?: string;
31
33
  /** Aspirational future state */
@@ -109,6 +111,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
109
111
  let promise = '';
110
112
  let tagline = '';
111
113
  let positioning = '';
114
+ let purpose = '';
112
115
  let mission = '';
113
116
  let vision = '';
114
117
  let archetype = '';
@@ -195,6 +198,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
195
198
  if (continuationKey === 'essence') essence += ' ' + continuation;
196
199
  else if (continuationKey === 'promise') promise += ' ' + continuation;
197
200
  else if (continuationKey === 'positioning') positioning += ' ' + continuation;
201
+ else if (continuationKey === 'purpose') purpose += ' ' + continuation;
198
202
  else if (continuationKey === 'mission') mission += ' ' + continuation;
199
203
  else if (continuationKey === 'vision') vision += ' ' + continuation;
200
204
  continue;
@@ -300,6 +304,10 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
300
304
  positioning = val;
301
305
  continuationKey = 'positioning';
302
306
  break;
307
+ case 'purpose':
308
+ purpose = val;
309
+ continuationKey = 'purpose';
310
+ break;
303
311
  case 'mission':
304
312
  mission = val;
305
313
  continuationKey = 'mission';
@@ -425,6 +433,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
425
433
  // Optional fields — only include if present
426
434
  if (tagline) result.tagline = tagline;
427
435
  if (positioning) result.positioning = positioning;
436
+ if (purpose) result.purpose = purpose;
428
437
  if (mission) result.mission = mission;
429
438
  if (vision) result.vision = vision;
430
439
  if (archetype) result.archetype = archetype;
package/parser/index.ts CHANGED
@@ -6,7 +6,24 @@ export { parseIdentityFile } from './identityParser';
6
6
  export type { ParsedIdentity, ParsedVoice, ParsedPillars, ParsedAudience, ParsedAntiAudience, ParsedValue } from './identityParser';
7
7
 
8
8
  export { parseBrandFile } from './brandParser';
9
- export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle } from './brandParser';
9
+ export {
10
+ contrastRatio,
11
+ relativeLuminance,
12
+ toneClass,
13
+ isHex6,
14
+ pairRuleViolations,
15
+ derivePairSlots,
16
+ resolveTextColor,
17
+ reachableFamilies,
18
+ reachCoversAll,
19
+ colorOrigin,
20
+ familyTextCandidates,
21
+ checkFamilyPair,
22
+ enumerateFamilyPairs,
23
+ DEFAULT_COLOR_RULES,
24
+ } from './colorSystem';
25
+ export type { ReachConfig } from './colorSystem';
26
+ export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign } from './brandParser';
10
27
  export type {
11
28
  ColorMode,
12
29
  PaletteStrategy,
@@ -19,6 +36,18 @@ export type {
19
36
  NeutralRole,
20
37
  BrandColorEntry,
21
38
  BrandColorGroup,
39
+ ToneClass,
40
+ ColorRules,
41
+ FamilyOrder,
42
+ FamilyBorrow,
43
+ FamilyRole,
44
+ FamilyColor,
45
+ PairOrigin,
46
+ PairCheck,
47
+ FamilyPair,
48
+ ColorFamily,
49
+ ColorFamilies,
50
+ ThemePair,
22
51
  } from './types';
23
52
 
24
53
  export { parseFormatFile } from './formatParser';
package/parser/types.ts CHANGED
@@ -92,3 +92,88 @@ export interface BrandColorGroup {
92
92
  entries: BrandColorEntry[];
93
93
  }
94
94
 
95
+
96
+ // ── Color rules, pair themes, color families (v0.10.0) ────────────────────────
97
+
98
+ /** Tone class of a color, from its contrast against white. */
99
+ export type ToneClass = 'light' | 'mid' | 'dark';
100
+
101
+ /** Brand-wide contrast and tone rules. */
102
+ export interface ColorRules {
103
+ /** Min contrast for text on ground. */
104
+ text: number;
105
+ /** Min contrast between ground and figure. */
106
+ figure: number;
107
+ /** Tone combinations a pair must not form (unordered). */
108
+ avoid: Array<[ToneClass, ToneClass]>;
109
+ /** Tone thresholds as contrast vs white: [light below, dark at/above]. */
110
+ tones: [number, number];
111
+ }
112
+
113
+ /** How families are arranged. */
114
+ export type FamilyOrder = 'cycle' | 'sequence';
115
+ /** Which neighbours a family may borrow from. */
116
+ export type FamilyBorrow = 'previous' | 'next' | 'both' | 'none';
117
+ /** Fixed role vocabulary inside a family. */
118
+ export type FamilyRole = 'core' | 'accent' | 'contrast';
119
+
120
+ /** One color declared inside a family. */
121
+ export interface FamilyColor {
122
+ role: FamilyRole;
123
+ /** 6-digit uppercase hex. */
124
+ hex: string;
125
+ /** Display name (authored, or generated as `{family}-{role}[-n]`). */
126
+ name: string;
127
+ /** Key in the flat `brandColors` projection. */
128
+ key: string;
129
+ }
130
+
131
+ /** Where a pair color comes from, seen from the pair's family. */
132
+ export type PairOrigin = 'own' | 'prev' | 'next' | 'shared' | 'outside';
133
+
134
+ /** A checked ground/figure pair. */
135
+ export interface PairCheck {
136
+ ground: string;
137
+ figure: string;
138
+ /** Resolved text color for this ground. */
139
+ text: string;
140
+ figureRatio: number;
141
+ textRatio: number;
142
+ /** False when no text candidate reaches the text rule (best one is used). */
143
+ textPasses: boolean;
144
+ tones: [ToneClass, ToneClass];
145
+ origins: [PairOrigin, PairOrigin];
146
+ valid: boolean;
147
+ /** Why the pair fails (empty when valid). */
148
+ reasons: string[];
149
+ }
150
+
151
+ /** A curated pair as declared in a family's `pairs` block. */
152
+ export interface FamilyPair extends PairCheck {
153
+ /** Theme name the pair was emitted as (only when valid), e.g. `Winter-3`. */
154
+ theme?: string;
155
+ }
156
+
157
+ export interface ColorFamily {
158
+ name: string;
159
+ colors: FamilyColor[];
160
+ pairs: FamilyPair[];
161
+ }
162
+
163
+ export interface ColorFamilies {
164
+ order: FamilyOrder;
165
+ borrow: FamilyBorrow;
166
+ distance: number;
167
+ /** Colors allowed in every family (resolved from `shared:` refs). */
168
+ shared: Array<{ name: string; hex: string }>;
169
+ families: ColorFamily[];
170
+ }
171
+
172
+ /** Ground / figure / text of a pair theme (declared or emitted from a family pair). */
173
+ export interface ThemePair {
174
+ ground: string;
175
+ figure: string;
176
+ text: string;
177
+ /** Family the theme was emitted from, when it came from `color-families`. */
178
+ family?: string;
179
+ }