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 +2 -2
- package/parser/brandParser.ts +299 -9
- package/parser/colorSystem.ts +270 -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/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"
|
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 */
|
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
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
|
+
}
|