brand-intent 0.9.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.9.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/identityParser.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 */
@@ -97,8 +123,13 @@ export interface ParsedAssetDef {
97
123
  generator?: string;
98
124
  /** Default parameters for the procedural generator, keyed by param name. Declared via the indented `generator-params` sub-block. */
99
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 }>>;
100
129
  }
101
130
 
131
+ export type LogoAlign = 'left' | 'center' | 'right';
132
+
102
133
  export interface ParsedBadgeStyle {
103
134
  /** Padding in cqh: [top, right, bottom, left] */
104
135
  padding: [number, number, number, number];
@@ -172,6 +203,16 @@ const STATE_KEYS = new Set<string>(['problem', 'warning', 'success', 'info', 'in
172
203
  const VALID_COLOR_MODES = new Set<ColorMode>(['static', 'dynamic', 'guided']);
173
204
  const VALID_STRATEGIES = new Set<PaletteStrategy>(['monochrome', 'duotone', 'tricolor', 'spectrum']);
174
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
+ }
175
216
 
176
217
  /**
177
218
  * Expand a 3-digit hex to 6-digit. Pass-through for 6 or 8-digit.
@@ -286,7 +327,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
286
327
  tokensSource: 'figma',
287
328
  };
288
329
 
289
- 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';
290
331
  let activeBlock: ActiveBlock = 'none';
291
332
  let currentThemeName = '';
292
333
  let currentDividerName = '';
@@ -307,6 +348,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
307
348
  const stateColorsRaw: Record<string, string> = {};
308
349
  const colorBoundsRaw: Record<string, string> = {};
309
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;
310
359
  const themes: Record<string, Record<string, string>> = {};
311
360
  const themeDescriptions: Record<string, string> = {};
312
361
  const spacingRaw: Record<string, string> = {};
@@ -509,6 +558,56 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
509
558
  }
510
559
  continue;
511
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
+ }
512
611
  if (activeBlock === 'theme' && currentThemeName) {
513
612
  // slot: #HEX or $ref # optional comment
514
613
  const colonIdx = raw.indexOf(':');
@@ -600,9 +699,9 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
600
699
  const last = arr[arr.length - 1];
601
700
  if (last && last.id === currentAssetId) last.props[trimmed] = 'true';
602
701
  currentSubBlock = null;
603
- } else if (trimmed === 'generator-params') {
702
+ } else if (trimmed === 'generator-params' || (activeBlock === 'logo' && /^align-(left|center|right)$/.test(trimmed))) {
604
703
  // Open a nested sub-block — subsequent deeper-indented key: value lines attach here.
605
- currentSubBlock = 'generator-params';
704
+ currentSubBlock = trimmed;
606
705
  }
607
706
  }
608
707
  continue;
@@ -648,6 +747,23 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
648
747
  continue;
649
748
  }
650
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
+
651
767
  // theme ThemeName — starts a theme block
652
768
  const themeMatch = raw.match(/^theme\s+(\S+)/);
653
769
  if (themeMatch) {
@@ -831,6 +947,91 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
831
947
  }
832
948
  }
833
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
+
834
1035
  // Store brand colors (flat projection)
835
1036
  if (Object.keys(brandColors).length > 0) {
836
1037
  result.brandColors = brandColors;
@@ -873,10 +1074,10 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
873
1074
  }
874
1075
 
875
1076
  // ── Minimum-structure validation for color-mode: guided ───────────────────
876
- if (result.colorMode === 'guided') {
1077
+ if (result.colorMode === 'guided' && !(colorFamilies && colorFamilies.families.length >= 2)) {
877
1078
  if (colorGroups.length === 0) {
878
1079
  throw new Error(
879
- `${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.`,
880
1081
  );
881
1082
  }
882
1083
  const primary = colorGroups.find(g => g.key === 'primary');
@@ -910,15 +1111,97 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
910
1111
  //
911
1112
  // Alpha is expressed as `<number>%`, 0–100, separated from the base value by exactly one space.
912
1113
  // 100% emits solid hex (not rgba) to keep output clean.
913
- if (Object.keys(themes).length > 0) {
914
- for (const theme of Object.values(themes)) {
915
- for (const [slot, value] of Object.entries(theme)) {
916
- 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)`);
1148
+ }
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`);
917
1195
  }
918
1196
  }
1197
+ }
1198
+
1199
+ if (Object.keys(themes).length > 0) {
919
1200
  result.themes = themes;
920
1201
  result.themeDescriptions = themeDescriptions;
921
1202
  }
1203
+ if (Object.keys(themePairs).length > 0) result.themePairs = themePairs;
1204
+ if (colorWarnings.length > 0) result.colorWarnings = colorWarnings;
922
1205
  if (Object.keys(spacingRaw).length > 0) {
923
1206
  result.spacing = {
924
1207
  unit: spacingRaw['unit'] ?? 'cqmin',
@@ -1019,6 +1302,13 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1019
1302
  }
1020
1303
  }
1021
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
+ }
1022
1312
  return def;
1023
1313
  };
1024
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
+ }
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
+ }