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 +2 -2
- package/parser/brandParser.ts +331 -8
- package/parser/colorSystem.ts +270 -0
- package/parser/identityParser.ts +9 -0
- package/parser/index.ts +30 -1
- package/parser/types.ts +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brand-intent",
|
|
3
|
-
"version": "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"
|
package/parser/brandParser.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
+
}
|
package/parser/identityParser.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|