brand-intent 0.9.0 → 0.11.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,16 +1,16 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/studiostoll/brand-intent.git"
9
+ "url": "git+https://github.com/studiostoll/brand-intent.git"
10
10
  },
11
11
  "homepage": "https://intent.stoll.studio",
12
12
  "bin": {
13
- "brand-intent": "./cli/index.mjs"
13
+ "brand-intent": "cli/index.mjs"
14
14
  },
15
15
  "exports": {
16
16
  ".": {
@@ -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,15 @@ 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 }>>;
129
+ /** Library group (e.g. "experimental"). Consumers list each group as its own section after the ungrouped assets. */
130
+ group?: string;
100
131
  }
101
132
 
133
+ export type LogoAlign = 'left' | 'center' | 'right';
134
+
102
135
  export interface ParsedBadgeStyle {
103
136
  /** Padding in cqh: [top, right, bottom, left] */
104
137
  padding: [number, number, number, number];
@@ -172,6 +205,16 @@ const STATE_KEYS = new Set<string>(['problem', 'warning', 'success', 'info', 'in
172
205
  const VALID_COLOR_MODES = new Set<ColorMode>(['static', 'dynamic', 'guided']);
173
206
  const VALID_STRATEGIES = new Set<PaletteStrategy>(['monochrome', 'duotone', 'tricolor', 'spectrum']);
174
207
  const VALID_ADHERENCES = new Set<Adherence>(['strict', 'on-brand', 'flexible']);
208
+ const TONE_CLASSES = new Set<ToneClass>(['light', 'mid', 'dark']);
209
+ const FAMILY_ROLES = new Set<FamilyRole>(['core', 'accent', 'contrast']);
210
+ const FAMILY_ORDERS = new Set<FamilyOrder>(['cycle', 'sequence']);
211
+ const FAMILY_BORROWS = new Set<FamilyBorrow>(['previous', 'next', 'both', 'none']);
212
+ const PAIR_KEYS = ['ground', 'figure', 'text'] as const;
213
+
214
+ /** Flat-projection key for a display name: lowercased, spaces → hyphens. */
215
+ function colorKey(name: string): string {
216
+ return name.toLowerCase().replace(/\s+/g, '-');
217
+ }
175
218
 
176
219
  /**
177
220
  * Expand a 3-digit hex to 6-digit. Pass-through for 6 or 8-digit.
@@ -286,7 +329,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
286
329
  tokensSource: 'figma',
287
330
  };
288
331
 
289
- type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta';
332
+ 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
333
  let activeBlock: ActiveBlock = 'none';
291
334
  let currentThemeName = '';
292
335
  let currentDividerName = '';
@@ -307,6 +350,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
307
350
  const stateColorsRaw: Record<string, string> = {};
308
351
  const colorBoundsRaw: Record<string, string> = {};
309
352
  const adherenceOverridesRaw: Record<string, string> = {};
353
+ const colorRulesRaw: Record<string, string> = {};
354
+ let colorRulesDeclared = false;
355
+ type RawFamily = { name: string; line: number; colors: Array<{ role: string; value: string; line: number }>; pairs: Array<{ ground: string; figure: string; line: number }> };
356
+ const familiesProps: Record<string, string> = {};
357
+ const familiesRaw: RawFamily[] = [];
358
+ let familiesDeclared = false;
359
+ let currentFamily: RawFamily | null = null;
360
+ let inPairs = false;
310
361
  const themes: Record<string, Record<string, string>> = {};
311
362
  const themeDescriptions: Record<string, string> = {};
312
363
  const spacingRaw: Record<string, string> = {};
@@ -509,6 +560,56 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
509
560
  }
510
561
  continue;
511
562
  }
563
+ if (activeBlock === 'color-rules') {
564
+ const trimmed = raw.trim();
565
+ const colonIdx = trimmed.indexOf(':');
566
+ if (colonIdx !== -1) {
567
+ colorRulesRaw[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
568
+ }
569
+ continue;
570
+ }
571
+ if (activeBlock === 'color-families') {
572
+ const trimmed = raw.trim();
573
+ if (trimmed === '') continue;
574
+ const familyMatch = trimmed.match(/^family\s+(\S+)$/);
575
+ if (familyMatch) {
576
+ if (familiesRaw.some(f => f.name === familyMatch[1])) {
577
+ throw new Error(`${fileName}:${lineNum}: duplicate family "${familyMatch[1]}"`);
578
+ }
579
+ currentFamily = { name: familyMatch[1], line: lineNum, colors: [], pairs: [] };
580
+ familiesRaw.push(currentFamily);
581
+ inPairs = false;
582
+ continue;
583
+ }
584
+ if (!currentFamily) {
585
+ const colonIdx = trimmed.indexOf(':');
586
+ if (colonIdx === -1) {
587
+ throw new Error(`${fileName}:${lineNum}: expected "key: value" or "family NAME" in color-families, got "${trimmed}"`);
588
+ }
589
+ familiesProps[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
590
+ continue;
591
+ }
592
+ if (trimmed === 'pairs') {
593
+ inPairs = true;
594
+ continue;
595
+ }
596
+ if (inPairs && trimmed.includes('/')) {
597
+ const parts = trimmed.split('/').map(p => p.trim());
598
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
599
+ throw new Error(`${fileName}:${lineNum}: pair must be "ground / figure", got "${trimmed}"`);
600
+ }
601
+ currentFamily.pairs.push({ ground: parts[0], figure: parts[1], line: lineNum });
602
+ continue;
603
+ }
604
+ const colonIdx = trimmed.indexOf(':');
605
+ const key = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : trimmed;
606
+ if (colonIdx === -1 || !FAMILY_ROLES.has(key as FamilyRole)) {
607
+ throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected core / accent / contrast / pairs, got "${trimmed}"`);
608
+ }
609
+ inPairs = false;
610
+ currentFamily.colors.push({ role: key, value: trimmed.slice(colonIdx + 1).trim(), line: lineNum });
611
+ continue;
612
+ }
512
613
  if (activeBlock === 'theme' && currentThemeName) {
513
614
  // slot: #HEX or $ref # optional comment
514
615
  const colonIdx = raw.indexOf(':');
@@ -600,9 +701,9 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
600
701
  const last = arr[arr.length - 1];
601
702
  if (last && last.id === currentAssetId) last.props[trimmed] = 'true';
602
703
  currentSubBlock = null;
603
- } else if (trimmed === 'generator-params') {
704
+ } else if (trimmed === 'generator-params' || (activeBlock === 'logo' && /^align-(left|center|right)$/.test(trimmed))) {
604
705
  // Open a nested sub-block — subsequent deeper-indented key: value lines attach here.
605
- currentSubBlock = 'generator-params';
706
+ currentSubBlock = trimmed;
606
707
  }
607
708
  }
608
709
  continue;
@@ -648,6 +749,23 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
648
749
  continue;
649
750
  }
650
751
 
752
+ // color-rules — contrast minimums and tone rules
753
+ if (raw.trim() === 'color-rules') {
754
+ activeBlock = 'color-rules';
755
+ colorRulesDeclared = true;
756
+ continue;
757
+ }
758
+
759
+ // color-families — ordered color sets with roles, reach and pairs
760
+ if (raw.trim() === 'color-families') {
761
+ if (familiesDeclared) throw new Error(`${fileName}:${lineNum}: duplicate color-families block`);
762
+ activeBlock = 'color-families';
763
+ familiesDeclared = true;
764
+ currentFamily = null;
765
+ inPairs = false;
766
+ continue;
767
+ }
768
+
651
769
  // theme ThemeName — starts a theme block
652
770
  const themeMatch = raw.match(/^theme\s+(\S+)/);
653
771
  if (themeMatch) {
@@ -831,6 +949,91 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
831
949
  }
832
950
  }
833
951
 
952
+ const colorWarnings: string[] = [];
953
+
954
+ // ── Finalise color-rules ──────────────────────────────────────────────────
955
+ const rules: ColorRules = { ...DEFAULT_COLOR_RULES, avoid: [], tones: [...DEFAULT_COLOR_RULES.tones] as [number, number] };
956
+ if (colorRulesDeclared) {
957
+ const num = (key: string) => {
958
+ const n = parseFloat(colorRulesRaw[key]);
959
+ if (isNaN(n) || n < 1 || n > 21) {
960
+ throw new Error(`${fileName}: color-rules "${key}" must be a contrast ratio between 1 and 21, got "${colorRulesRaw[key]}"`);
961
+ }
962
+ return n;
963
+ };
964
+ if (colorRulesRaw.text !== undefined) rules.text = num('text');
965
+ if (colorRulesRaw.figure !== undefined) rules.figure = num('figure');
966
+ if (colorRulesRaw.avoid !== undefined) {
967
+ for (const combo of colorRulesRaw.avoid.split(',').map(c => c.trim()).filter(Boolean)) {
968
+ const parts = combo.split('+').map(c => c.trim());
969
+ if (parts.length !== 2 || !parts.every(t => TONE_CLASSES.has(t as ToneClass))) {
970
+ throw new Error(`${fileName}: color-rules avoid "${combo}" must be two of light / mid / dark joined by "+"`);
971
+ }
972
+ rules.avoid.push([parts[0] as ToneClass, parts[1] as ToneClass]);
973
+ }
974
+ }
975
+ if (colorRulesRaw.tones !== undefined) {
976
+ const t = colorRulesRaw.tones.split(',').map(v => parseFloat(v.trim()));
977
+ if (t.length !== 2 || t.some(isNaN) || t[0] >= t[1]) {
978
+ throw new Error(`${fileName}: color-rules tones must be two ascending contrast values, e.g. "1.8, 4.5"`);
979
+ }
980
+ rules.tones = [t[0], t[1]];
981
+ }
982
+ result.colorRules = rules;
983
+ }
984
+
985
+ // ── Finalise color-families: colors + flat projection (pairs later, after themes) ──
986
+ let colorFamilies: ColorFamilies | undefined;
987
+ if (familiesDeclared) {
988
+ const order = (familiesProps.order ?? 'cycle') as FamilyOrder;
989
+ if (!FAMILY_ORDERS.has(order)) throw new Error(`${fileName}: color-families order must be "cycle" or "sequence", got "${order}"`);
990
+ const borrow = (familiesProps.borrow ?? 'both') as FamilyBorrow;
991
+ if (!FAMILY_BORROWS.has(borrow)) throw new Error(`${fileName}: color-families borrow must be previous / next / both / none, got "${borrow}"`);
992
+ const distance = familiesProps.distance !== undefined ? Number(familiesProps.distance) : 1;
993
+ if (!Number.isInteger(distance) || distance < 1) throw new Error(`${fileName}: color-families distance must be a whole number ≥ 1, got "${familiesProps.distance}"`);
994
+ if (familiesRaw.length === 0) throw new Error(`${fileName}: color-families declares no family`);
995
+
996
+ const shared: Array<{ name: string; hex: string }> = [];
997
+ for (const ref of (familiesProps.shared ?? '').split(',').map(r => r.trim()).filter(Boolean)) {
998
+ if (!ref.startsWith('$')) throw new Error(`${fileName}: color-families shared entries must be $refs to brand-colors, got "${ref}"`);
999
+ const hex = brandColors[ref.slice(1)] ? normalizeHex(brandColors[ref.slice(1)]) : null;
1000
+ if (!hex || !isHex6(hex)) throw new Error(`${fileName}: color-families shared "${ref}" is not a known 6-digit brand color`);
1001
+ shared.push({ name: ref.slice(1), hex });
1002
+ }
1003
+
1004
+ const families: ColorFamily[] = [];
1005
+ for (const raw of familiesRaw) {
1006
+ const counts: Record<string, number> = {};
1007
+ for (const c of raw.colors) counts[c.role] = (counts[c.role] ?? 0) + 1;
1008
+ if (counts.core !== 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" needs exactly one core color`);
1009
+ if (counts.contrast !== 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" needs exactly one contrast color`);
1010
+ const seen: Record<string, number> = {};
1011
+ const colors: FamilyColor[] = raw.colors.map(c => {
1012
+ const m = c.value.match(/^(#[0-9a-fA-F]{3,6})(?:\s+(.+))?$/);
1013
+ const hex = m ? normalizeHex(m[1]) : null;
1014
+ if (!m || !hex || !isHex6(hex)) {
1015
+ throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} must be "#HEX" or "#HEX Name", got "${c.value}"`);
1016
+ }
1017
+ seen[c.role] = (seen[c.role] ?? 0) + 1;
1018
+ const generated = `${raw.name.toLowerCase()}-${c.role}${counts[c.role] > 1 ? `-${seen[c.role]}` : ''}`;
1019
+ const name = m[2]?.trim() || generated;
1020
+ return { role: c.role as FamilyRole, hex, name, key: colorKey(name) };
1021
+ });
1022
+ for (const c of colors) {
1023
+ if (brandColors[c.key]) {
1024
+ throw new Error(`${fileName}: family "${raw.name}" color name "${c.name}" collides with an existing brand color`);
1025
+ }
1026
+ brandColors[c.key] = c.hex;
1027
+ }
1028
+ families.push({ name: raw.name, colors, pairs: [] });
1029
+ }
1030
+ colorFamilies = { order, borrow, distance, shared, families };
1031
+ if (borrow !== 'none' && reachCoversAll(families.length, { order, borrow, distance })) {
1032
+ colorWarnings.push(`color-families: borrow "${borrow}" with distance ${distance} reaches every family — the borrow rule has no effect`);
1033
+ }
1034
+ result.colorFamilies = colorFamilies;
1035
+ }
1036
+
834
1037
  // Store brand colors (flat projection)
835
1038
  if (Object.keys(brandColors).length > 0) {
836
1039
  result.brandColors = brandColors;
@@ -873,10 +1076,10 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
873
1076
  }
874
1077
 
875
1078
  // ── Minimum-structure validation for color-mode: guided ───────────────────
876
- if (result.colorMode === 'guided') {
1079
+ if (result.colorMode === 'guided' && !(colorFamilies && colorFamilies.families.length >= 2)) {
877
1080
  if (colorGroups.length === 0) {
878
1081
  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.`,
1082
+ `${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
1083
  );
881
1084
  }
882
1085
  const primary = colorGroups.find(g => g.key === 'primary');
@@ -910,15 +1113,97 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
910
1113
  //
911
1114
  // Alpha is expressed as `<number>%`, 0–100, separated from the base value by exactly one space.
912
1115
  // 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);
1116
+ //
1117
+ // Pair themes: `ground:` + `figure:` (+ optional `text:`) derive every slot not written explicitly.
1118
+ const themePairs: Record<string, ThemePair> = {};
1119
+ const neutral = colorGroups.find(g => g.key === 'neutral');
1120
+ const neutralText = ['dark', 'light']
1121
+ .map(role => neutral?.entries.find(e => e.role === role)?.hex)
1122
+ .filter((h): h is string => !!h);
1123
+ for (const [name, theme] of Object.entries(themes)) {
1124
+ const pairRaw: Partial<Record<typeof PAIR_KEYS[number], string>> = {};
1125
+ for (const key of PAIR_KEYS) {
1126
+ if (theme[key] !== undefined) { pairRaw[key] = theme[key]; delete theme[key]; }
1127
+ }
1128
+ for (const [slot, value] of Object.entries(theme)) {
1129
+ theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot);
1130
+ }
1131
+ if (Object.keys(pairRaw).length === 0) continue;
1132
+ if (!pairRaw.ground || !pairRaw.figure) {
1133
+ throw new Error(`${fileName}: theme "${name}" needs both "ground:" and "figure:" (pair theme), or neither`);
1134
+ }
1135
+ const solid = (key: string, value: string) => {
1136
+ const hex = resolveThemeSlotValue(value, brandColors, fileName, key);
1137
+ if (!isHex6(hex)) throw new Error(`${fileName}: theme "${name}" ${key} must be a solid 6-digit color, got "${value}"`);
1138
+ return hex;
1139
+ };
1140
+ const ground = solid('ground', pairRaw.ground);
1141
+ const figure = solid('figure', pairRaw.figure);
1142
+ let text: string;
1143
+ if (pairRaw.text) {
1144
+ text = solid('text', pairRaw.text);
1145
+ } else {
1146
+ const resolved = resolveTextColor(ground, neutralText, rules);
1147
+ text = resolved.text;
1148
+ if (!resolved.passes) {
1149
+ colorWarnings.push(`theme "${name}": no text color reaches ${rules.text}:1 on ${ground} — using ${text} (${resolved.ratio.toFixed(1)}:1)`);
1150
+ }
1151
+ }
1152
+ themes[name] = { ...derivePairSlots(ground, figure, text), ...theme };
1153
+ themePairs[name] = { ground, figure, text };
1154
+ }
1155
+
1156
+ // Family pairs → checked; valid ones become themes named `{Family}-{n}` (n = declared position).
1157
+ if (colorFamilies) {
1158
+ const config = { order: colorFamilies.order, borrow: colorFamilies.borrow, distance: colorFamilies.distance };
1159
+ colorFamilies.families.forEach((family, index) => {
1160
+ const raw = familiesRaw[index];
1161
+ raw.pairs.forEach((p, n) => {
1162
+ const resolve = (value: string) => {
1163
+ const hex = value.startsWith('$') ? (brandColors[value.slice(1)] ? normalizeHex(brandColors[value.slice(1)]) : null) : normalizeHex(value);
1164
+ if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${p.line}: pair color "${value}" is not a known 6-digit color`);
1165
+ return hex;
1166
+ };
1167
+ const check = checkFamilyPair(resolve(p.ground), resolve(p.figure), index, colorFamilies!, config, rules, neutralText);
1168
+ const themeName = `${family.name}-${n + 1}`;
1169
+ if (check.valid) {
1170
+ if (themes[themeName]) throw new Error(`${fileName}: family pair theme "${themeName}" collides with an authored theme`);
1171
+ themes[themeName] = derivePairSlots(check.ground, check.figure, check.text);
1172
+ themePairs[themeName] = { ground: check.ground, figure: check.figure, text: check.text, family: family.name };
1173
+ family.pairs.push({ ...check, theme: themeName });
1174
+ if (!check.textPasses) {
1175
+ colorWarnings.push(`${themeName}: no text color reaches ${rules.text}:1 on ${check.ground} — using ${check.text} (${check.textRatio.toFixed(1)}:1)`);
1176
+ }
1177
+ } else {
1178
+ family.pairs.push(check);
1179
+ colorWarnings.push(`${themeName} (${check.ground} / ${check.figure}) rejected: ${check.reasons.join('; ')}`);
1180
+ }
1181
+ });
1182
+ });
1183
+ }
1184
+
1185
+ // Rule checks on every theme (only when the brand declares color-rules).
1186
+ if (colorRulesDeclared) {
1187
+ for (const [name, theme] of Object.entries(themes)) {
1188
+ const bg = theme['background'];
1189
+ if (!bg || !isHex6(bg)) continue;
1190
+ const txt = theme['text-primary'];
1191
+ if (txt && isHex6(txt) && contrastRatio(bg, txt) < rules.text && !themePairs[name]) {
1192
+ colorWarnings.push(`theme "${name}": text-primary ${txt} on ${bg} is ${contrastRatio(bg, txt).toFixed(1)}:1, below ${rules.text}:1`);
1193
+ }
1194
+ const fig = theme['logo'];
1195
+ if (fig && isHex6(fig) && contrastRatio(bg, fig) < rules.figure && !themePairs[name]?.family) {
1196
+ colorWarnings.push(`theme "${name}": logo ${fig} on ${bg} is ${contrastRatio(bg, fig).toFixed(1)}:1, below ${rules.figure}:1`);
917
1197
  }
918
1198
  }
1199
+ }
1200
+
1201
+ if (Object.keys(themes).length > 0) {
919
1202
  result.themes = themes;
920
1203
  result.themeDescriptions = themeDescriptions;
921
1204
  }
1205
+ if (Object.keys(themePairs).length > 0) result.themePairs = themePairs;
1206
+ if (colorWarnings.length > 0) result.colorWarnings = colorWarnings;
922
1207
  if (Object.keys(spacingRaw).length > 0) {
923
1208
  result.spacing = {
924
1209
  unit: spacingRaw['unit'] ?? 'cqmin',
@@ -1009,6 +1294,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1009
1294
  if (raw.props['loop'] === 'true') def.loop = true;
1010
1295
  if (raw.props['gltf']) def.gltfSrc = raw.props['gltf'];
1011
1296
  if (raw.props['generator']) def.generator = raw.props['generator'];
1297
+ if (raw.props['group']) def.group = raw.props['group'];
1012
1298
  // generator-params are stored as "generator-params.KEY" by the sub-block parser.
1013
1299
  const paramEntries: [string, number][] = [];
1014
1300
  for (const [k, v] of Object.entries(raw.props)) {
@@ -1019,6 +1305,13 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1019
1305
  }
1020
1306
  }
1021
1307
  if (paramEntries.length > 0) def.generatorParams = Object.fromEntries(paramEntries);
1308
+ // align-* sub-blocks are stored as "align-left.src" / "align-left.aspect".
1309
+ for (const align of ['left', 'center', 'right'] as const) {
1310
+ const src = raw.props[`align-${align}.src`];
1311
+ if (!src) continue;
1312
+ const aspect = raw.props[`align-${align}.aspect`];
1313
+ def.alignVariants = { ...def.alignVariants, [align]: { src, ...(aspect ? { aspectRatio: parseFloat(aspect) } : {}) } };
1314
+ }
1022
1315
  return def;
1023
1316
  };
1024
1317
  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
+ }