brand-intent 0.19.0 → 0.20.1
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 +1 -1
- package/parser/brandParser.ts +277 -21
- package/parser/colorSystem.ts +97 -16
- package/parser/index.ts +9 -1
- package/parser/types.ts +23 -0
- package/vscode-extension/syntaxes/brand.tmLanguage.json +71 -5
package/package.json
CHANGED
package/parser/brandParser.ts
CHANGED
|
@@ -26,6 +26,9 @@ import type {
|
|
|
26
26
|
FamilyBorrow,
|
|
27
27
|
ToneClass,
|
|
28
28
|
ThemePair,
|
|
29
|
+
Tones,
|
|
30
|
+
Levels,
|
|
31
|
+
LevelSlot,
|
|
29
32
|
} from './types';
|
|
30
33
|
import {
|
|
31
34
|
DEFAULT_COLOR_RULES,
|
|
@@ -34,7 +37,11 @@ import {
|
|
|
34
37
|
derivePairSlots,
|
|
35
38
|
resolveTextColor,
|
|
36
39
|
checkFamilyPair,
|
|
40
|
+
toneAnchorsFrom,
|
|
37
41
|
reachCoversAll,
|
|
42
|
+
cycleMembers,
|
|
43
|
+
LEVEL_SLOTS,
|
|
44
|
+
levelRuleViolations,
|
|
38
45
|
} from './colorSystem';
|
|
39
46
|
import { parseIconSource, isIconId } from './icons';
|
|
40
47
|
import { parseDateWindowRanges } from './dateWindows';
|
|
@@ -88,6 +95,10 @@ export interface ParsedBrand {
|
|
|
88
95
|
colorAdherenceOverrides?: AdherenceTolerances;
|
|
89
96
|
/** Contrast + tone rules (`color-rules` block). Absent when not declared. */
|
|
90
97
|
colorRules?: ColorRules;
|
|
98
|
+
/** Declared tone ladders (`tones` block). Referenced as `$name.step`. */
|
|
99
|
+
tones?: Tones;
|
|
100
|
+
/** Resolved `levels`: every family × level with all slots. Requires `color-families`. */
|
|
101
|
+
levels?: Levels;
|
|
91
102
|
/** Color families (`color-families` block): ordered sets with roles, reach config and checked pairs. */
|
|
92
103
|
colorFamilies?: ColorFamilies;
|
|
93
104
|
/** Ground / figure / text for every pair theme (authored `ground:`/`figure:` themes and emitted family pairs). */
|
|
@@ -318,10 +329,24 @@ function hexToRgb(hex6: string): [number, number, number] {
|
|
|
318
329
|
];
|
|
319
330
|
}
|
|
320
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Look up a color reference name (without `$`): a brand color, or `name.step` for a declared tone.
|
|
334
|
+
* Returns the raw stored value, undefined when unknown.
|
|
335
|
+
*/
|
|
336
|
+
function lookupColorRef(
|
|
337
|
+
refName: string,
|
|
338
|
+
brandColors: Record<string, string>,
|
|
339
|
+
tones?: Record<string, Record<number, string>>,
|
|
340
|
+
): string | undefined {
|
|
341
|
+
if (brandColors[refName]) return brandColors[refName];
|
|
342
|
+
const m = refName.match(/^(.+)\.(\d+)$/);
|
|
343
|
+
return m ? tones?.[m[1]]?.[Number(m[2])] : undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
321
346
|
/**
|
|
322
347
|
* Resolve a theme-slot value string to a final CSS color.
|
|
323
348
|
*
|
|
324
|
-
* Supports: `$ref`, `$ref NN%`, `#HEX`, `#HEX NN%`, raw rgba(...) pass-through.
|
|
349
|
+
* Supports: `$ref`, `$ref.step`, `$ref NN%`, `#HEX`, `#HEX NN%`, raw rgba(...) pass-through.
|
|
325
350
|
* Throws on malformed alpha or unresolved `$ref`.
|
|
326
351
|
*/
|
|
327
352
|
function resolveThemeSlotValue(
|
|
@@ -329,6 +354,7 @@ function resolveThemeSlotValue(
|
|
|
329
354
|
brandColors: Record<string, string>,
|
|
330
355
|
fileName: string,
|
|
331
356
|
slot: string,
|
|
357
|
+
tones?: Record<string, Record<number, string>>,
|
|
332
358
|
): string {
|
|
333
359
|
const value = raw.trim();
|
|
334
360
|
|
|
@@ -358,7 +384,7 @@ function resolveThemeSlotValue(
|
|
|
358
384
|
let hex: string | null = null;
|
|
359
385
|
if (base.startsWith('$')) {
|
|
360
386
|
const refName = base.slice(1);
|
|
361
|
-
const resolved = brandColors
|
|
387
|
+
const resolved = lookupColorRef(refName, brandColors, tones);
|
|
362
388
|
if (!resolved) {
|
|
363
389
|
throw new Error(
|
|
364
390
|
`${fileName}: theme slot "${slot}" references unknown brand color "$${refName}"`,
|
|
@@ -402,12 +428,24 @@ function resolveThemeSlotValue(
|
|
|
402
428
|
export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
403
429
|
const lines = content.split('\n');
|
|
404
430
|
|
|
431
|
+
/** `slot: value` inside a level (defaults or family override); slot must be a level slot. */
|
|
432
|
+
const parseSlotLine = (trimmed: string, lineNum: number, where: string) => {
|
|
433
|
+
const colonIdx = trimmed.indexOf(':');
|
|
434
|
+
const slot = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : '';
|
|
435
|
+
if (!(LEVEL_SLOTS as readonly string[]).includes(slot)) {
|
|
436
|
+
throw new Error(`${fileName}:${lineNum}: ${where}: unknown slot "${slot || trimmed}" (slots: ${LEVEL_SLOTS.join(', ')})`);
|
|
437
|
+
}
|
|
438
|
+
const value = trimmed.slice(colonIdx + 1).replace(/\s+#\s.*$/, '').trim();
|
|
439
|
+
if (!value) throw new Error(`${fileName}:${lineNum}: ${where}: slot "${slot}" has no value`);
|
|
440
|
+
return { slot, value, line: lineNum };
|
|
441
|
+
};
|
|
442
|
+
|
|
405
443
|
const result: Partial<ParsedBrand> = {
|
|
406
444
|
tokensFile: 'tokens.json',
|
|
407
445
|
tokensSource: 'figma',
|
|
408
446
|
};
|
|
409
447
|
|
|
410
|
-
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' | 'icons' | 'counter' | 'framing' | 'camera' | 'photo-look' | 'windows';
|
|
448
|
+
type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'color-rules' | 'tones' | 'levels' | 'color-families' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta' | 'icons' | 'counter' | 'framing' | 'camera' | 'photo-look' | 'windows';
|
|
411
449
|
let activeBlock: ActiveBlock = 'none';
|
|
412
450
|
let currentThemeName = '';
|
|
413
451
|
let currentDividerName = '';
|
|
@@ -430,7 +468,18 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
430
468
|
const adherenceOverridesRaw: Record<string, string> = {};
|
|
431
469
|
const colorRulesRaw: Record<string, string> = {};
|
|
432
470
|
let colorRulesDeclared = false;
|
|
433
|
-
|
|
471
|
+
let tonesDeclared = false;
|
|
472
|
+
let tonesStepsRaw: { value: string; line: number } | null = null;
|
|
473
|
+
const tonesRaw: Array<{ name: string; values: string[]; line: number }> = [];
|
|
474
|
+
type RawSlot = { slot: string; value: string; line: number };
|
|
475
|
+
type RawLevel = { name: string; line: number; slots: RawSlot[] };
|
|
476
|
+
type RawFamily = { name: string; line: number; from?: string; colors: Array<{ role: string; value: string; line: number }>; pairs: Array<{ ground: string; figure: string; line: number }>; signal?: { value: string; line: number }; levels: RawLevel[] };
|
|
477
|
+
const levelsRaw: RawLevel[] = [];
|
|
478
|
+
let levelsDeclared = false;
|
|
479
|
+
let currentLevel: RawLevel | null = null;
|
|
480
|
+
/** Level override inside a family (`level Strong` under `family Dusk`) and its indent. */
|
|
481
|
+
let currentFamilyLevel: RawLevel | null = null;
|
|
482
|
+
let familyLevelIndent = 0;
|
|
434
483
|
const familiesProps: Record<string, string> = {};
|
|
435
484
|
const familiesRaw: RawFamily[] = [];
|
|
436
485
|
let familiesDeclared = false;
|
|
@@ -657,6 +706,39 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
657
706
|
}
|
|
658
707
|
continue;
|
|
659
708
|
}
|
|
709
|
+
if (activeBlock === 'tones') {
|
|
710
|
+
// Hex values start with `#`, so only ` # ` (hash between spaces) starts a comment here.
|
|
711
|
+
const trimmed = raw.trim().replace(/\s+#\s.*$/, '');
|
|
712
|
+
if (trimmed === '' || trimmed.startsWith('# ')) continue;
|
|
713
|
+
const colonIdx = trimmed.indexOf(':');
|
|
714
|
+
const key = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : '';
|
|
715
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
716
|
+
if (key === 'steps') {
|
|
717
|
+
if (tonesStepsRaw) throw new Error(`${fileName}:${lineNum}: tones declares steps: twice`);
|
|
718
|
+
tonesStepsRaw = { value, line: lineNum };
|
|
719
|
+
} else if (key.startsWith('$') && key.length > 1) {
|
|
720
|
+
const name = key.slice(1);
|
|
721
|
+
if (tonesRaw.some(t => t.name === name)) throw new Error(`${fileName}:${lineNum}: tones declares "${key}" twice`);
|
|
722
|
+
tonesRaw.push({ name, values: value.split(/\s+/).filter(Boolean), line: lineNum });
|
|
723
|
+
} else {
|
|
724
|
+
throw new Error(`${fileName}:${lineNum}: expected "steps: …" or "$color: #HEX …" in tones, got "${trimmed}"`);
|
|
725
|
+
}
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (activeBlock === 'levels') {
|
|
729
|
+
const trimmed = raw.trim();
|
|
730
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
731
|
+
const levelMatch = trimmed.match(/^level\s+(\S+)$/);
|
|
732
|
+
if (levelMatch) {
|
|
733
|
+
if (levelsRaw.some(l => l.name === levelMatch[1])) throw new Error(`${fileName}:${lineNum}: duplicate level "${levelMatch[1]}"`);
|
|
734
|
+
currentLevel = { name: levelMatch[1], line: lineNum, slots: [] };
|
|
735
|
+
levelsRaw.push(currentLevel);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (!currentLevel) throw new Error(`${fileName}:${lineNum}: expected "level NAME" in levels, got "${trimmed}"`);
|
|
739
|
+
currentLevel.slots.push(parseSlotLine(trimmed, lineNum, `level ${currentLevel.name}`));
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
660
742
|
if (activeBlock === 'color-families') {
|
|
661
743
|
const trimmed = raw.trim();
|
|
662
744
|
if (trimmed === '') continue;
|
|
@@ -665,7 +747,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
665
747
|
if (familiesRaw.some(f => f.name === familyMatch[1])) {
|
|
666
748
|
throw new Error(`${fileName}:${lineNum}: duplicate family "${familyMatch[1]}"`);
|
|
667
749
|
}
|
|
668
|
-
currentFamily = { name: familyMatch[1], line: lineNum, colors: [], pairs: [] };
|
|
750
|
+
currentFamily = { name: familyMatch[1], line: lineNum, colors: [], pairs: [], levels: [] };
|
|
751
|
+
currentFamilyLevel = null;
|
|
669
752
|
familiesRaw.push(currentFamily);
|
|
670
753
|
inPairs = false;
|
|
671
754
|
continue;
|
|
@@ -678,6 +761,23 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
678
761
|
familiesProps[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
|
|
679
762
|
continue;
|
|
680
763
|
}
|
|
764
|
+
const indent = raw.length - raw.trimStart().length;
|
|
765
|
+
if (currentFamilyLevel && indent > familyLevelIndent) {
|
|
766
|
+
currentFamilyLevel.slots.push(parseSlotLine(trimmed, lineNum, `family "${currentFamily.name}" level ${currentFamilyLevel.name}`));
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
currentFamilyLevel = null;
|
|
770
|
+
const familyLevelMatch = trimmed.match(/^level\s+(\S+)$/);
|
|
771
|
+
if (familyLevelMatch) {
|
|
772
|
+
if (currentFamily.levels.some(l => l.name === familyLevelMatch[1])) {
|
|
773
|
+
throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" declares level ${familyLevelMatch[1]} twice`);
|
|
774
|
+
}
|
|
775
|
+
currentFamilyLevel = { name: familyLevelMatch[1], line: lineNum, slots: [] };
|
|
776
|
+
familyLevelIndent = indent;
|
|
777
|
+
currentFamily.levels.push(currentFamilyLevel);
|
|
778
|
+
inPairs = false;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
681
781
|
if (trimmed === 'pairs') {
|
|
682
782
|
inPairs = true;
|
|
683
783
|
continue;
|
|
@@ -700,8 +800,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
700
800
|
currentFamily.from = value;
|
|
701
801
|
continue;
|
|
702
802
|
}
|
|
803
|
+
if (key === 'signal' && colonIdx !== -1) {
|
|
804
|
+
if (currentFamily.signal) throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" declares signal: twice`);
|
|
805
|
+
currentFamily.signal = { value: trimmed.slice(colonIdx + 1).trim(), line: lineNum };
|
|
806
|
+
inPairs = false;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
703
809
|
if (colonIdx === -1 || !FAMILY_ROLES.has(key as FamilyRole)) {
|
|
704
|
-
throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected from / core / accent / contrast / pairs, got "${trimmed}"`);
|
|
810
|
+
throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected from / core / accent / contrast / signal / level / pairs, got "${trimmed}"`);
|
|
705
811
|
}
|
|
706
812
|
inPairs = false;
|
|
707
813
|
currentFamily.colors.push({ role: key, value: trimmed.slice(colonIdx + 1).trim(), line: lineNum });
|
|
@@ -915,6 +1021,23 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
915
1021
|
continue;
|
|
916
1022
|
}
|
|
917
1023
|
|
|
1024
|
+
// tones — declared tone ladders per color (optional trailing comment)
|
|
1025
|
+
if (/^tones(\s+#.*)?$/.test(raw.trim())) {
|
|
1026
|
+
if (tonesDeclared) throw new Error(`${fileName}:${lineNum}: duplicate tones block`);
|
|
1027
|
+
activeBlock = 'tones';
|
|
1028
|
+
tonesDeclared = true;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// levels — slot defaults per level (families override)
|
|
1033
|
+
if (raw.trim() === 'levels') {
|
|
1034
|
+
if (levelsDeclared) throw new Error(`${fileName}:${lineNum}: duplicate levels block`);
|
|
1035
|
+
activeBlock = 'levels';
|
|
1036
|
+
levelsDeclared = true;
|
|
1037
|
+
currentLevel = null;
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
918
1041
|
// color-families — ordered color sets with roles, reach and pairs
|
|
919
1042
|
if (raw.trim() === 'color-families') {
|
|
920
1043
|
if (familiesDeclared) throw new Error(`${fileName}:${lineNum}: duplicate color-families block`);
|
|
@@ -1191,6 +1314,30 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1191
1314
|
result.colorRules = rules;
|
|
1192
1315
|
}
|
|
1193
1316
|
|
|
1317
|
+
// ── Finalise tones: ladders now, names + anchors after the families joined brandColors ──
|
|
1318
|
+
let tones: Tones | undefined;
|
|
1319
|
+
if (tonesDeclared) {
|
|
1320
|
+
if (!tonesStepsRaw) throw new Error(`${fileName}: tones needs a "steps:" line`);
|
|
1321
|
+
const stepsLine = tonesStepsRaw.line;
|
|
1322
|
+
const steps = tonesStepsRaw.value.split(',').map(s => s.trim()).map(s => /^\d+$/.test(s) ? Number(s) : NaN);
|
|
1323
|
+
if (steps.length === 0 || steps.some(isNaN) || steps.some((s, i) => i > 0 && s <= steps[i - 1])) {
|
|
1324
|
+
throw new Error(`${fileName}:${stepsLine}: tones steps must be ascending whole numbers, e.g. "10, 20, 30", got "${tonesStepsRaw.value}"`);
|
|
1325
|
+
}
|
|
1326
|
+
tones = { steps, colors: {} };
|
|
1327
|
+
for (const t of tonesRaw) {
|
|
1328
|
+
if (t.values.length !== steps.length) {
|
|
1329
|
+
throw new Error(`${fileName}:${t.line}: tones "$${t.name}" has ${t.values.length} values, steps declares ${steps.length}`);
|
|
1330
|
+
}
|
|
1331
|
+
const ladder: Record<number, string> = {};
|
|
1332
|
+
t.values.forEach((v, i) => {
|
|
1333
|
+
const hex = normalizeHex(v);
|
|
1334
|
+
if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${t.line}: tones "$${t.name}" value "${v}" is not a 6-digit hex`);
|
|
1335
|
+
ladder[steps[i]] = hex;
|
|
1336
|
+
});
|
|
1337
|
+
tones.colors[t.name] = ladder;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1194
1341
|
// ── Finalise color-families: colors + flat projection (pairs later, after themes) ──
|
|
1195
1342
|
let colorFamilies: ColorFamilies | undefined;
|
|
1196
1343
|
if (familiesDeclared) {
|
|
@@ -1206,7 +1353,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1206
1353
|
const colorRefs = (key: string): Array<{ name: string; hex: string }> =>
|
|
1207
1354
|
(familiesProps[key] ?? '').split(',').map(r => r.trim()).filter(Boolean).map(ref => {
|
|
1208
1355
|
if (!ref.startsWith('$')) throw new Error(`${fileName}: color-families ${key} entries must be $refs to brand-colors, got "${ref}"`);
|
|
1209
|
-
const
|
|
1356
|
+
const found = lookupColorRef(ref.slice(1), brandColors, tones?.colors);
|
|
1357
|
+
const hex = found ? normalizeHex(found) : null;
|
|
1210
1358
|
if (!hex || !isHex6(hex)) throw new Error(`${fileName}: color-families ${key} "${ref}" is not a known 6-digit brand color`);
|
|
1211
1359
|
return { name: ref.slice(1), hex };
|
|
1212
1360
|
});
|
|
@@ -1218,13 +1366,25 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1218
1366
|
const counts: Record<string, number> = {};
|
|
1219
1367
|
for (const c of raw.colors) counts[c.role] = (counts[c.role] ?? 0) + 1;
|
|
1220
1368
|
if (counts.core !== 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" needs exactly one core color`);
|
|
1221
|
-
if (counts.contrast
|
|
1369
|
+
if ((counts.contrast ?? 0) > 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" declares more than one contrast color`);
|
|
1222
1370
|
const seen: Record<string, number> = {};
|
|
1371
|
+
const refs = new Set<FamilyColor>();
|
|
1223
1372
|
const colors: FamilyColor[] = raw.colors.map(c => {
|
|
1373
|
+
if (c.value.startsWith('$')) {
|
|
1374
|
+
// A reference to a color declared once elsewhere (shared between families); it doesn't join brandColors again.
|
|
1375
|
+
const ref = c.value.slice(1);
|
|
1376
|
+
const found = lookupColorRef(ref, brandColors, tones?.colors);
|
|
1377
|
+
const hex = found ? normalizeHex(found) : null;
|
|
1378
|
+
if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} "${c.value}" is not a known 6-digit color`);
|
|
1379
|
+
seen[c.role] = (seen[c.role] ?? 0) + 1;
|
|
1380
|
+
const color = { role: c.role as FamilyRole, hex, name: ref, key: ref };
|
|
1381
|
+
refs.add(color);
|
|
1382
|
+
return color;
|
|
1383
|
+
}
|
|
1224
1384
|
const m = c.value.match(/^(#[0-9a-fA-F]{3,6})(?:\s+(.+))?$/);
|
|
1225
1385
|
const hex = m ? normalizeHex(m[1]) : null;
|
|
1226
1386
|
if (!m || !hex || !isHex6(hex)) {
|
|
1227
|
-
throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} must be "#HEX"
|
|
1387
|
+
throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} must be "#HEX", "#HEX Name" or "$ref", got "${c.value}"`);
|
|
1228
1388
|
}
|
|
1229
1389
|
seen[c.role] = (seen[c.role] ?? 0) + 1;
|
|
1230
1390
|
const generated = `${raw.name.toLowerCase()}-${c.role}${counts[c.role] > 1 ? `-${seen[c.role]}` : ''}`;
|
|
@@ -1232,6 +1392,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1232
1392
|
return { role: c.role as FamilyRole, hex, name, key: colorKey(name) };
|
|
1233
1393
|
});
|
|
1234
1394
|
for (const c of colors) {
|
|
1395
|
+
if (refs.has(c)) continue;
|
|
1235
1396
|
if (brandColors[c.key]) {
|
|
1236
1397
|
throw new Error(`${fileName}: family "${raw.name}" color name "${c.name}" collides with an existing brand color`);
|
|
1237
1398
|
}
|
|
@@ -1239,22 +1400,111 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1239
1400
|
}
|
|
1240
1401
|
families.push({ name: raw.name, ...(raw.from ? { from: raw.from } : {}), colors, pairs: [] });
|
|
1241
1402
|
}
|
|
1403
|
+
// Undated families next to dated ones are hand-picked contexts: off the cycle, never in season.
|
|
1242
1404
|
const dated = familiesRaw.filter(f => f.from !== undefined);
|
|
1243
|
-
if (dated.length > 0 && dated.length < familiesRaw.length) {
|
|
1244
|
-
const missing = familiesRaw.filter(f => f.from === undefined).map(f => f.name).join(', ');
|
|
1245
|
-
throw new Error(`${fileName}: color-families: from: must be declared on every family or on none (missing: ${missing})`);
|
|
1246
|
-
}
|
|
1247
1405
|
for (const f of dated) {
|
|
1248
1406
|
const twin = dated.find(o => o !== f && o.from === f.from);
|
|
1249
1407
|
if (twin) throw new Error(`${fileName}:${f.line}: families "${twin.name}" and "${f.name}" share from: ${f.from}`);
|
|
1250
1408
|
}
|
|
1251
1409
|
colorFamilies = { order, borrow, distance, shared, ...(photoFallback.length > 0 ? { photoFallback } : {}), families };
|
|
1252
|
-
if (borrow !== 'none' && reachCoversAll(families.length, { order, borrow, distance })) {
|
|
1410
|
+
if (borrow !== 'none' && reachCoversAll(cycleMembers(families).length, { order, borrow, distance })) {
|
|
1253
1411
|
colorWarnings.push(`color-families: borrow "${borrow}" with distance ${distance} reaches every family — the borrow rule has no effect`);
|
|
1254
1412
|
}
|
|
1255
1413
|
result.colorFamilies = colorFamilies;
|
|
1256
1414
|
}
|
|
1257
1415
|
|
|
1416
|
+
// Tones belong to a declared color, and that color's own hex sits on its ladder (the anchor).
|
|
1417
|
+
if (tones) {
|
|
1418
|
+
for (const t of tonesRaw) {
|
|
1419
|
+
const own = brandColors[t.name] ? normalizeHex(brandColors[t.name]) : null;
|
|
1420
|
+
if (!own) throw new Error(`${fileName}:${t.line}: tones "$${t.name}" is not a brand color or family color`);
|
|
1421
|
+
if (!Object.values(tones.colors[t.name]).includes(own)) {
|
|
1422
|
+
throw new Error(`${fileName}:${t.line}: tones "$${t.name}" must contain its own hex ${own} on one of its steps`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
result.tones = tones;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// ── Finalise levels: every family × level resolves all slots ──────────────
|
|
1429
|
+
if (levelsDeclared) {
|
|
1430
|
+
if (!colorFamilies) throw new Error(`${fileName}: levels needs a color-families block`);
|
|
1431
|
+
if (levelsRaw.length === 0) throw new Error(`${fileName}: levels declares no level`);
|
|
1432
|
+
const names = levelsRaw.map(l => l.name);
|
|
1433
|
+
const levelByKey = new Map(levelsRaw.map(l => [l.name.toLowerCase(), l]));
|
|
1434
|
+
for (const l of levelsRaw) {
|
|
1435
|
+
if (/^(p|a1|a2)$/i.test(l.name)) throw new Error(`${fileName}:${l.line}: level name "${l.name}" is reserved (family shorthand)`);
|
|
1436
|
+
}
|
|
1437
|
+
const cells: Levels['cells'] = {};
|
|
1438
|
+
colorFamilies.families.forEach((family, index) => {
|
|
1439
|
+
const raw = familiesRaw[index];
|
|
1440
|
+
for (const o of raw.levels) {
|
|
1441
|
+
if (!levelByKey.has(o.name.toLowerCase())) throw new Error(`${fileName}:${o.line}: family "${family.name}" overrides unknown level "${o.name}"`);
|
|
1442
|
+
}
|
|
1443
|
+
let signal: Partial<Record<LevelSlot, RawSlot>> = {};
|
|
1444
|
+
if (raw.signal) {
|
|
1445
|
+
const parts = raw.signal.value.split('/').map(p => p.trim());
|
|
1446
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error(`${fileName}:${raw.signal.line}: family "${family.name}" signal must be "signal / on-signal", got "${raw.signal.value}"`);
|
|
1447
|
+
signal = { 'signal': { slot: 'signal', value: parts[0], line: raw.signal.line }, 'on-signal': { slot: 'on-signal', value: parts[1], line: raw.signal.line } };
|
|
1448
|
+
}
|
|
1449
|
+
/** Authored value for a cell: family level override, then family signal, then level default. */
|
|
1450
|
+
const authored = (level: RawLevel, slot: LevelSlot): RawSlot | undefined =>
|
|
1451
|
+
raw.levels.find(o => o.name.toLowerCase() === level.name.toLowerCase())?.slots.find(s => s.slot === slot)
|
|
1452
|
+
?? signal[slot]
|
|
1453
|
+
?? level.slots.find(s => s.slot === slot);
|
|
1454
|
+
const role = (shorthand: string): FamilyColor => {
|
|
1455
|
+
const core = family.colors.find(c => c.role === 'core')!;
|
|
1456
|
+
const accents = family.colors.filter(c => c.role === 'accent');
|
|
1457
|
+
return shorthand === 'p' ? core : shorthand === 'a1' ? accents[0] ?? core : accents[1] ?? core;
|
|
1458
|
+
};
|
|
1459
|
+
const memo = new Map<string, string>();
|
|
1460
|
+
const resolve = (level: RawLevel, slot: LevelSlot, stack: string[]): string => {
|
|
1461
|
+
const id = `${level.name.toLowerCase()}.${slot}`;
|
|
1462
|
+
const cached = memo.get(id);
|
|
1463
|
+
if (cached) return cached;
|
|
1464
|
+
if (stack.includes(id)) throw new Error(`${fileName}: family "${family.name}": circular cell reference ${[...stack, id].join(' → ')}`);
|
|
1465
|
+
const a = authored(level, slot);
|
|
1466
|
+
const where = `family "${family.name}" level ${level.name} ${slot}`;
|
|
1467
|
+
if (!a) throw new Error(`${fileName}: ${where} is not set (level default or family override)`);
|
|
1468
|
+
if (a.value === 'required') throw new Error(`${fileName}:${a.line}: ${where} is required: set it in family "${family.name}" (level ${level.name})`);
|
|
1469
|
+
const at = `${fileName}:${a.line}: ${where}`;
|
|
1470
|
+
let hex: string | null = null;
|
|
1471
|
+
const short = a.value.match(/^(p|a1|a2)(?:\.(\d+))?$/);
|
|
1472
|
+
const cellRef = a.value.match(/^([^$#.\s]+)\.(\S+)$/);
|
|
1473
|
+
if (short) {
|
|
1474
|
+
const color = role(short[1]);
|
|
1475
|
+
if (short[2] === undefined) hex = color.hex;
|
|
1476
|
+
else {
|
|
1477
|
+
hex = tones?.colors[color.key]?.[Number(short[2])] ?? null;
|
|
1478
|
+
if (!hex) throw new Error(`${at}: "${a.value}" — ${color.name} has no tone ${short[2]}`);
|
|
1479
|
+
}
|
|
1480
|
+
} else if (a.value.startsWith('$')) {
|
|
1481
|
+
const found = lookupColorRef(a.value.slice(1), brandColors, tones?.colors);
|
|
1482
|
+
hex = found ? normalizeHex(found) : null;
|
|
1483
|
+
if (!hex) throw new Error(`${at}: unknown color "${a.value}"`);
|
|
1484
|
+
} else if (a.value.startsWith('#')) {
|
|
1485
|
+
hex = normalizeHex(a.value);
|
|
1486
|
+
} else if (cellRef) {
|
|
1487
|
+
const target = levelByKey.get(cellRef[1].toLowerCase());
|
|
1488
|
+
if (!target || !(LEVEL_SLOTS as readonly string[]).includes(cellRef[2])) throw new Error(`${at}: "${a.value}" is not a cell (level.slot)`);
|
|
1489
|
+
hex = resolve(target, cellRef[2] as LevelSlot, [...stack, id]);
|
|
1490
|
+
}
|
|
1491
|
+
if (!hex || !isHex6(hex)) throw new Error(`${at}: value "${a.value}" must be p / a1 / a2 [.step], $ref [.step], #HEX or level.slot`);
|
|
1492
|
+
memo.set(id, hex);
|
|
1493
|
+
return hex;
|
|
1494
|
+
};
|
|
1495
|
+
cells[family.name] = {};
|
|
1496
|
+
for (const level of levelsRaw) {
|
|
1497
|
+
const cell = {} as Record<LevelSlot, string>;
|
|
1498
|
+
for (const slot of LEVEL_SLOTS) cell[slot] = resolve(level, slot, []);
|
|
1499
|
+
cells[family.name][level.name] = cell;
|
|
1500
|
+
if (colorRulesDeclared) colorWarnings.push(...levelRuleViolations(family.name, level.name, cell, rules));
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
result.levels = { names, cells };
|
|
1504
|
+
} else if (familiesRaw.some(f => f.signal || f.levels.length > 0)) {
|
|
1505
|
+
throw new Error(`${fileName}: family signal: / level overrides need a levels block`);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1258
1508
|
// Store brand colors (flat projection)
|
|
1259
1509
|
if (Object.keys(brandColors).length > 0) {
|
|
1260
1510
|
result.brandColors = brandColors;
|
|
@@ -1347,14 +1597,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1347
1597
|
if (theme[key] !== undefined) { pairRaw[key] = theme[key]; delete theme[key]; }
|
|
1348
1598
|
}
|
|
1349
1599
|
for (const [slot, value] of Object.entries(theme)) {
|
|
1350
|
-
theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot);
|
|
1600
|
+
theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot, tones?.colors);
|
|
1351
1601
|
}
|
|
1352
1602
|
if (Object.keys(pairRaw).length === 0) continue;
|
|
1353
1603
|
if (!pairRaw.ground || !pairRaw.figure) {
|
|
1354
1604
|
throw new Error(`${fileName}: theme "${name}" needs both "ground:" and "figure:" (pair theme), or neither`);
|
|
1355
1605
|
}
|
|
1356
1606
|
const solid = (key: string, value: string) => {
|
|
1357
|
-
const hex = resolveThemeSlotValue(value, brandColors, fileName, key);
|
|
1607
|
+
const hex = resolveThemeSlotValue(value, brandColors, fileName, key, tones?.colors);
|
|
1358
1608
|
if (!isHex6(hex)) throw new Error(`${fileName}: theme "${name}" ${key} must be a solid 6-digit color, got "${value}"`);
|
|
1359
1609
|
return hex;
|
|
1360
1610
|
};
|
|
@@ -1377,15 +1627,20 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1377
1627
|
// Family pairs → checked; valid ones become themes named `{Family}-{n}` (n = declared position).
|
|
1378
1628
|
if (colorFamilies) {
|
|
1379
1629
|
const config = { order: colorFamilies.order, borrow: colorFamilies.borrow, distance: colorFamilies.distance };
|
|
1630
|
+
const toneAnchors = toneAnchorsFrom(tones?.colors, name => {
|
|
1631
|
+
const raw = brandColors[name];
|
|
1632
|
+
return raw ? normalizeHex(raw) ?? undefined : undefined;
|
|
1633
|
+
});
|
|
1380
1634
|
colorFamilies.families.forEach((family, index) => {
|
|
1381
1635
|
const raw = familiesRaw[index];
|
|
1382
1636
|
raw.pairs.forEach((p, n) => {
|
|
1383
1637
|
const resolve = (value: string) => {
|
|
1384
|
-
const
|
|
1638
|
+
const found = value.startsWith('$') ? lookupColorRef(value.slice(1), brandColors, tones?.colors) : value;
|
|
1639
|
+
const hex = found ? normalizeHex(found) : null;
|
|
1385
1640
|
if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${p.line}: pair color "${value}" is not a known 6-digit color`);
|
|
1386
1641
|
return hex;
|
|
1387
1642
|
};
|
|
1388
|
-
const check = checkFamilyPair(resolve(p.ground), resolve(p.figure), index, colorFamilies!, config, rules, neutralText);
|
|
1643
|
+
const check = checkFamilyPair(resolve(p.ground), resolve(p.figure), index, colorFamilies!, config, rules, neutralText, toneAnchors);
|
|
1389
1644
|
const themeName = `${family.name}-${n + 1}`;
|
|
1390
1645
|
if (check.valid) {
|
|
1391
1646
|
if (themes[themeName]) throw new Error(`${fileName}: family pair theme "${themeName}" collides with an authored theme`);
|
|
@@ -1426,7 +1681,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1426
1681
|
}
|
|
1427
1682
|
|
|
1428
1683
|
// color-mode: brand starts pages from a declared theme, so it needs at least one.
|
|
1429
|
-
if (result.colorMode === 'brand' && Object.keys(themes).length === 0) {
|
|
1684
|
+
if (result.colorMode === 'brand' && Object.keys(themes).length === 0 && !result.levels) {
|
|
1430
1685
|
throw new Error(`${fileName}: color-mode: brand requires at least one theme (a theme block or a valid color-families pair)`);
|
|
1431
1686
|
}
|
|
1432
1687
|
|
|
@@ -1708,10 +1963,11 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1708
1963
|
const refs = duo.value.split(/\s+/);
|
|
1709
1964
|
if (refs.length !== 2) throw new Error(`${fileName}:${duo.line}: photo-look duotone must be "SHADOW HIGHLIGHT", got "${duo.value}"`);
|
|
1710
1965
|
const [shadow, highlight] = refs.map(ref => {
|
|
1711
|
-
|
|
1966
|
+
const found = ref.startsWith('$') ? lookupColorRef(ref.slice(1), brandColors, tones?.colors) : ref;
|
|
1967
|
+
if (!found) {
|
|
1712
1968
|
throw new Error(`${fileName}:${duo.line}: photo-look duotone references unknown brand color "${ref}"`);
|
|
1713
1969
|
}
|
|
1714
|
-
const hex = normalizeHex(
|
|
1970
|
+
const hex = normalizeHex(found);
|
|
1715
1971
|
if (!hex) throw new Error(`${fileName}:${duo.line}: photo-look duotone colors must be $ref or #HEX, got "${ref}"`);
|
|
1716
1972
|
return hex;
|
|
1717
1973
|
});
|
package/parser/colorSystem.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ColorRules,
|
|
11
11
|
ColorFamilies,
|
|
12
12
|
ColorFamily,
|
|
13
|
+
LevelSlot,
|
|
13
14
|
FamilyBorrow,
|
|
14
15
|
FamilyOrder,
|
|
15
16
|
ToneClass,
|
|
@@ -145,8 +146,26 @@ export interface ReachConfig {
|
|
|
145
146
|
distance: number;
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Indices of the families on the cycle: the dated ones when any family declares `from:`,
|
|
151
|
+
* else all. Undated families next to dated ones are hand-picked contexts (e.g. a plain context) and never borrow.
|
|
152
|
+
*/
|
|
153
|
+
export function cycleMembers(families: Pick<ColorFamily, 'from'>[]): number[] {
|
|
154
|
+
const dated = families.flatMap((f, i) => (f.from ? [i] : []));
|
|
155
|
+
return dated.length > 0 ? dated : families.map((_, i) => i);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Families reachable from `index` (excluding itself), labelled by direction.
|
|
160
|
+
* `members` limits the cycle to these family indices (see `cycleMembers`); default all `count`.
|
|
161
|
+
*/
|
|
162
|
+
export function reachableFamilies(count: number, index: number, config: ReachConfig, members?: number[]): Map<number, 'prev' | 'next'> {
|
|
163
|
+
if (members) {
|
|
164
|
+
const pos = members.indexOf(index);
|
|
165
|
+
if (pos === -1) return new Map();
|
|
166
|
+
const inner = reachableFamilies(members.length, pos, config);
|
|
167
|
+
return new Map([...inner].map(([j, dir]) => [members[j], dir]));
|
|
168
|
+
}
|
|
150
169
|
const reach = new Map<number, 'prev' | 'next'>();
|
|
151
170
|
if (config.borrow === 'none') return reach;
|
|
152
171
|
const wantPrev = config.borrow === 'previous' || config.borrow === 'both';
|
|
@@ -176,43 +195,69 @@ export function reachCoversAll(count: number, config: ReachConfig): boolean {
|
|
|
176
195
|
return true;
|
|
177
196
|
}
|
|
178
197
|
|
|
179
|
-
/** Where a color comes from, seen from family `index`. Null = not usable there.
|
|
198
|
+
/** Where a color comes from, seen from family `index`. Null = not usable there.
|
|
199
|
+
* `toneAnchors` maps a tone's hex to the hex of the color(s) whose ladder holds it, so a tone
|
|
200
|
+
* counts as its color: a family color's tone is own, a shared color's tone shared, a neighbour's by reach. */
|
|
180
201
|
export function colorOrigin(
|
|
181
202
|
hex: string,
|
|
182
203
|
index: number,
|
|
183
204
|
system: Pick<ColorFamilies, 'families' | 'shared'>,
|
|
184
205
|
config: ReachConfig,
|
|
206
|
+
toneAnchors: ToneAnchors = {},
|
|
185
207
|
): PairOrigin | null {
|
|
186
208
|
const families = system.families;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
209
|
+
const candidates = [hex, ...(toneAnchors[hex] ?? [])];
|
|
210
|
+
const holds = (colors: Array<{ hex: string }>) => colors.some(c => candidates.includes(c.hex));
|
|
211
|
+
if (families[index] && holds(families[index].colors)) return 'own';
|
|
212
|
+
if (holds(system.shared)) return 'shared';
|
|
213
|
+
const reach = reachableFamilies(families.length, index, config, cycleMembers(families));
|
|
190
214
|
for (const [j, dir] of reach) {
|
|
191
|
-
if (families[j].colors
|
|
215
|
+
if (holds(families[j].colors)) return dir;
|
|
192
216
|
}
|
|
193
217
|
return null;
|
|
194
218
|
}
|
|
195
219
|
|
|
220
|
+
/** Tone hex → hex of every color whose declared ladder holds it (`tones` block). Empty without tones. */
|
|
221
|
+
export type ToneAnchors = Record<string, string[]>;
|
|
222
|
+
|
|
223
|
+
/** Build `ToneAnchors` from ladders (color name → step → hex) and the colors' own hexes. */
|
|
224
|
+
export function toneAnchorsFrom(
|
|
225
|
+
ladders: Record<string, Record<number, string>> | undefined,
|
|
226
|
+
colorHex: (name: string) => string | undefined,
|
|
227
|
+
): ToneAnchors {
|
|
228
|
+
const out: ToneAnchors = {};
|
|
229
|
+
for (const [name, ladder] of Object.entries(ladders ?? {})) {
|
|
230
|
+
const anchor = colorHex(name);
|
|
231
|
+
if (!anchor) continue;
|
|
232
|
+
for (const hex of Object.values(ladder)) {
|
|
233
|
+
if (hex === anchor) continue;
|
|
234
|
+
(out[hex] ??= []).includes(anchor) || out[hex].push(anchor);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
196
240
|
/**
|
|
197
241
|
* Family in season on `date`: the one with the latest `from:` on or before that
|
|
198
242
|
* month/day, wrapping to the year's last start before the first one. Uses the
|
|
199
|
-
* date's local calendar day. -1 when
|
|
243
|
+
* date's local calendar day. Undated families never win. -1 when no family declares a date.
|
|
200
244
|
*/
|
|
201
245
|
export function familyIndexForDate(families: Pick<ColorFamily, 'from'>[], date: Date): number {
|
|
202
|
-
if (families.length === 0 || families.some(f => !f.from)) return -1;
|
|
203
246
|
const today = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
204
247
|
let best = -1;
|
|
205
248
|
let latest = -1;
|
|
206
249
|
families.forEach((f, i) => {
|
|
250
|
+
if (!f.from) return;
|
|
207
251
|
if (f.from! <= today && (best === -1 || f.from! > families[best].from!)) best = i;
|
|
208
252
|
if (latest === -1 || f.from! > families[latest].from!) latest = i;
|
|
209
253
|
});
|
|
210
254
|
return best !== -1 ? best : latest;
|
|
211
255
|
}
|
|
212
256
|
|
|
213
|
-
/** Name of the family that declares `hex` (first match), for error messages. */
|
|
214
|
-
export function owningFamily(hex: string, families: ColorFamily[]): string | undefined {
|
|
215
|
-
|
|
257
|
+
/** Name of the family that declares `hex` or the color it is a tone of (first match), for error messages. */
|
|
258
|
+
export function owningFamily(hex: string, families: ColorFamily[], toneAnchors: ToneAnchors = {}): string | undefined {
|
|
259
|
+
const candidates = [hex, ...(toneAnchors[hex] ?? [])];
|
|
260
|
+
return families.find(f => f.colors.some(c => candidates.includes(c.hex)))?.name;
|
|
216
261
|
}
|
|
217
262
|
|
|
218
263
|
/** Text candidates for a family ground: its contrast color(s), then shared colors, then extras. */
|
|
@@ -233,15 +278,16 @@ export function checkFamilyPair(
|
|
|
233
278
|
config: ReachConfig,
|
|
234
279
|
rules: ColorRules,
|
|
235
280
|
extraTextCandidates: string[] = [],
|
|
281
|
+
toneAnchors: ToneAnchors = {},
|
|
236
282
|
): PairCheck {
|
|
237
283
|
const reasons: string[] = [];
|
|
238
284
|
const origins: [PairOrigin | null, PairOrigin | null] = [
|
|
239
|
-
colorOrigin(ground, index, system, config),
|
|
240
|
-
colorOrigin(figure, index, system, config),
|
|
285
|
+
colorOrigin(ground, index, system, config, toneAnchors),
|
|
286
|
+
colorOrigin(figure, index, system, config, toneAnchors),
|
|
241
287
|
];
|
|
242
288
|
for (const [hex, origin] of [[ground, origins[0]], [figure, origins[1]]] as const) {
|
|
243
289
|
if (origin === null) {
|
|
244
|
-
const owner = owningFamily(hex, system.families);
|
|
290
|
+
const owner = owningFamily(hex, system.families, toneAnchors);
|
|
245
291
|
reasons.push(owner ? `${hex} from ${owner} is out of reach` : `${hex} is not in any family or shared`);
|
|
246
292
|
}
|
|
247
293
|
}
|
|
@@ -273,7 +319,7 @@ export function enumerateFamilyPairs(
|
|
|
273
319
|
const pool = new Set<string>();
|
|
274
320
|
for (const c of system.families[index].colors) pool.add(c.hex);
|
|
275
321
|
for (const s of system.shared) pool.add(s.hex);
|
|
276
|
-
for (const j of reachableFamilies(system.families.length, index, config).keys()) {
|
|
322
|
+
for (const j of reachableFamilies(system.families.length, index, config, cycleMembers(system.families)).keys()) {
|
|
277
323
|
for (const c of system.families[j].colors) pool.add(c.hex);
|
|
278
324
|
}
|
|
279
325
|
const colors = [...pool];
|
|
@@ -286,3 +332,38 @@ export function enumerateFamilyPairs(
|
|
|
286
332
|
}
|
|
287
333
|
return out;
|
|
288
334
|
}
|
|
335
|
+
|
|
336
|
+
// ── Levels ──────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/** The fixed level slots, in four groups: ground, contrast, inset, signal. */
|
|
339
|
+
export const LEVEL_SLOTS = [
|
|
340
|
+
'ground', 'on-ground/headline', 'on-ground/text', 'on-ground/accent', 'on-ground/line', 'on-ground/divider',
|
|
341
|
+
'contrast', 'on-contrast',
|
|
342
|
+
'inset', 'on-inset/text', 'on-inset/accent',
|
|
343
|
+
'signal', 'on-signal',
|
|
344
|
+
] as const;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Pairs `color-rules` checks in every level cell: [figure slot, ground slot, rule].
|
|
348
|
+
* Dividers are decorative and not checked.
|
|
349
|
+
*/
|
|
350
|
+
export const LEVEL_PAIRS: Array<[LevelSlot, LevelSlot, 'text' | 'figure']> = [
|
|
351
|
+
['on-ground/headline', 'ground', 'text'],
|
|
352
|
+
['on-ground/text', 'ground', 'text'],
|
|
353
|
+
['on-ground/accent', 'ground', 'figure'],
|
|
354
|
+
['on-ground/line', 'ground', 'figure'],
|
|
355
|
+
['on-contrast', 'contrast', 'text'],
|
|
356
|
+
['on-inset/text', 'inset', 'text'],
|
|
357
|
+
['on-inset/accent', 'inset', 'figure'],
|
|
358
|
+
['on-signal', 'signal', 'text'],
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
/** Rule warnings for one resolved level cell, e.g. `Dusk / Light / on-ground/accent on ground 2.1:1 < 3`. */
|
|
362
|
+
export function levelRuleViolations(family: string, level: string, cell: Record<LevelSlot, string>, rules: ColorRules): string[] {
|
|
363
|
+
const out: string[] = [];
|
|
364
|
+
for (const [fig, ground, rule] of LEVEL_PAIRS) {
|
|
365
|
+
const ratio = contrastRatio(cell[fig], cell[ground]);
|
|
366
|
+
if (ratio < rules[rule]) out.push(`${family} / ${level} / ${fig} on ${ground} ${ratio.toFixed(1)}:1 < ${rules[rule]}`);
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
package/parser/index.ts
CHANGED
|
@@ -15,15 +15,20 @@ export {
|
|
|
15
15
|
derivePairSlots,
|
|
16
16
|
resolveTextColor,
|
|
17
17
|
reachableFamilies,
|
|
18
|
+
cycleMembers,
|
|
19
|
+
LEVEL_SLOTS,
|
|
20
|
+
LEVEL_PAIRS,
|
|
21
|
+
levelRuleViolations,
|
|
18
22
|
reachCoversAll,
|
|
19
23
|
colorOrigin,
|
|
24
|
+
toneAnchorsFrom,
|
|
20
25
|
familyTextCandidates,
|
|
21
26
|
checkFamilyPair,
|
|
22
27
|
enumerateFamilyPairs,
|
|
23
28
|
familyIndexForDate,
|
|
24
29
|
DEFAULT_COLOR_RULES,
|
|
25
30
|
} from './colorSystem';
|
|
26
|
-
export type { ReachConfig } from './colorSystem';
|
|
31
|
+
export type { ReachConfig, ToneAnchors } from './colorSystem';
|
|
27
32
|
export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape, ParsedFramingDef, ParsedCameraDef, ParsedPhotoLookDef, PhotoLookKey } from './brandParser';
|
|
28
33
|
|
|
29
34
|
export { parseDateWindowRanges, isInDateWindow, SAMPLE_WINDOW_MARKER } from './dateWindows';
|
|
@@ -55,6 +60,9 @@ export type {
|
|
|
55
60
|
ColorFamily,
|
|
56
61
|
ColorFamilies,
|
|
57
62
|
ThemePair,
|
|
63
|
+
Tones,
|
|
64
|
+
Levels,
|
|
65
|
+
LevelSlot,
|
|
58
66
|
} from './types';
|
|
59
67
|
|
|
60
68
|
export { parseFormatFile } from './formatParser';
|
package/parser/types.ts
CHANGED
|
@@ -174,6 +174,29 @@ export interface ColorFamilies {
|
|
|
174
174
|
families: ColorFamily[];
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/** Declared tone ladders (`tones` block): every listed color on the same steps. */
|
|
178
|
+
export interface Tones {
|
|
179
|
+
/** Ascending steps, e.g. 10 … 98. */
|
|
180
|
+
steps: number[];
|
|
181
|
+
/** Brand color name → step → 6-digit uppercase hex. The color's own hex sits on one of its steps. */
|
|
182
|
+
colors: Record<string, Record<number, string>>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One of the fixed level slots (`LEVEL_SLOTS` in colorSystem). */
|
|
186
|
+
export type LevelSlot =
|
|
187
|
+
| 'ground' | 'on-ground/headline' | 'on-ground/text' | 'on-ground/accent' | 'on-ground/line' | 'on-ground/divider'
|
|
188
|
+
| 'contrast' | 'on-contrast'
|
|
189
|
+
| 'inset' | 'on-inset/text' | 'on-inset/accent'
|
|
190
|
+
| 'signal' | 'on-signal';
|
|
191
|
+
|
|
192
|
+
/** Resolved `levels`: every family × level fills all slots. */
|
|
193
|
+
export interface Levels {
|
|
194
|
+
/** Level names in declaration order (weakest first, e.g. Neutral, Light, Medium, Strong). */
|
|
195
|
+
names: string[];
|
|
196
|
+
/** family name → level name → slot → 6-digit uppercase hex. */
|
|
197
|
+
cells: Record<string, Record<string, Record<LevelSlot, string>>>;
|
|
198
|
+
}
|
|
199
|
+
|
|
177
200
|
/** Ground / figure / text of a pair theme (declared or emitted from a family pair). */
|
|
178
201
|
export interface ThemePair {
|
|
179
202
|
ground: string;
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
{ "include": "#color-bounds-header" },
|
|
13
13
|
{ "include": "#color-rules-header" },
|
|
14
14
|
{ "include": "#color-families-header" },
|
|
15
|
+
{ "include": "#tones-header" },
|
|
16
|
+
{ "include": "#levels-header" },
|
|
17
|
+
{ "include": "#level-header" },
|
|
18
|
+
{ "include": "#level-slot" },
|
|
19
|
+
{ "include": "#family-signal" },
|
|
20
|
+
{ "include": "#tones-steps" },
|
|
21
|
+
{ "include": "#tones-entry" },
|
|
15
22
|
{ "include": "#color-rules-entry" },
|
|
16
23
|
{ "include": "#color-families-property" },
|
|
17
24
|
{ "include": "#family-header" },
|
|
@@ -110,6 +117,64 @@
|
|
|
110
117
|
"1": { "name": "keyword.control.brand-intent-brand" }
|
|
111
118
|
}
|
|
112
119
|
},
|
|
120
|
+
"levels-header": {
|
|
121
|
+
"match": "^(levels)\\s*$",
|
|
122
|
+
"captures": { "1": { "name": "keyword.control.brand-intent-brand" } }
|
|
123
|
+
},
|
|
124
|
+
"level-header": {
|
|
125
|
+
"comment": "level NAME under levels or inside a family",
|
|
126
|
+
"match": "^(\\s{2,})(level)\\s+(\\S+)\\s*$",
|
|
127
|
+
"captures": {
|
|
128
|
+
"2": { "name": "keyword.control.brand-intent-brand" },
|
|
129
|
+
"3": { "name": "entity.name.type.brand-intent-brand" }
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"level-slot": {
|
|
133
|
+
"comment": "Level slot: ground / on-ground/text / … → p|a1|a2[.step], $ref[.step], #HEX, level.slot, required",
|
|
134
|
+
"match": "^(\\s{4,})(ground|on-ground/(?:headline|text|accent|line|divider)|contrast|on-contrast|inset|on-inset/(?:text|accent)|signal|on-signal)(:\\s*)(required|#[0-9A-Fa-f]{6}|\\$[\\w-]+(?:\\.\\d+)?|(?:p|a1|a2)(?:\\.\\d+)?|[A-Za-z][\\w-]*\\.[\\w/-]+)\\s*(#\\s.*)?$",
|
|
135
|
+
"captures": {
|
|
136
|
+
"2": { "name": "support.type.property-name.brand-intent-brand" },
|
|
137
|
+
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
138
|
+
"4": { "name": "variable.other.brand-intent-brand" },
|
|
139
|
+
"5": { "name": "comment.line.number-sign.brand-intent-brand" }
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
"family-signal": {
|
|
143
|
+
"comment": "Family signal pair: signal: VALUE / VALUE",
|
|
144
|
+
"match": "^(\\s{4,})(signal)(:\\s*)(\\S+)(\\s*/\\s*)(\\S+)\\s*$",
|
|
145
|
+
"captures": {
|
|
146
|
+
"2": { "name": "support.type.property-name.brand-intent-brand" },
|
|
147
|
+
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
148
|
+
"4": { "name": "variable.other.brand-intent-brand" },
|
|
149
|
+
"6": { "name": "variable.other.brand-intent-brand" }
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"tones-header": {
|
|
153
|
+
"match": "^(tones)(\\s+#.*)?$",
|
|
154
|
+
"captures": {
|
|
155
|
+
"1": { "name": "keyword.control.brand-intent-brand" },
|
|
156
|
+
"2": { "name": "comment.line.number-sign.brand-intent-brand" }
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
"tones-steps": {
|
|
160
|
+
"comment": "steps: 10, 20, … under tones",
|
|
161
|
+
"match": "^( {2})(steps)(:\\s*)([\\d,\\s]+)$",
|
|
162
|
+
"captures": {
|
|
163
|
+
"2": { "name": "support.type.property-name.brand-intent-brand" },
|
|
164
|
+
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
165
|
+
"4": { "name": "constant.numeric.brand-intent-brand" }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"tones-entry": {
|
|
169
|
+
"comment": "Tone ladder: $color: #HEX #HEX … (one hex per step)",
|
|
170
|
+
"match": "^( {2})(\\$[\\w-]+)(:)((?:\\s+#[0-9A-Fa-f]{6})+)\\s*(#.*)?$",
|
|
171
|
+
"captures": {
|
|
172
|
+
"2": { "name": "variable.other.brand-intent-brand" },
|
|
173
|
+
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
174
|
+
"4": { "name": "constant.other.color.rgb-value.brand-intent-brand" },
|
|
175
|
+
"5": { "name": "comment.line.number-sign.brand-intent-brand" }
|
|
176
|
+
}
|
|
177
|
+
},
|
|
113
178
|
"color-rules-entry": {
|
|
114
179
|
"comment": "Entries under color-rules: text/figure (contrast), avoid (tone combos), tones (thresholds)",
|
|
115
180
|
"match": "^(\\s{2,})(text|figure|avoid|tones)(:\\s*)(.+)$",
|
|
@@ -136,13 +201,14 @@
|
|
|
136
201
|
}
|
|
137
202
|
},
|
|
138
203
|
"family-role": {
|
|
139
|
-
"comment": "Family color: core/accent/contrast → #HEX [Name]",
|
|
140
|
-
"match": "^(\\s{4,})(core|accent|contrast)(:\\s*)(#[0-9A-Fa-f]{3,6})(?:\\s+(.+))
|
|
204
|
+
"comment": "Family color: core/accent/contrast → #HEX [Name] or $ref[.step]",
|
|
205
|
+
"match": "^(\\s{4,})(core|accent|contrast)(:\\s*)(?:(#[0-9A-Fa-f]{3,6})(?:\\s+(.+))?|(\\$[\\w-]+(?:\\.\\d+)?))\\s*$",
|
|
141
206
|
"captures": {
|
|
142
207
|
"2": { "name": "support.type.property-name.brand-intent-brand" },
|
|
143
208
|
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
144
209
|
"4": { "name": "constant.other.color.rgb-value.brand-intent-brand" },
|
|
145
|
-
"5": { "name": "string.unquoted.brand-intent-brand" }
|
|
210
|
+
"5": { "name": "string.unquoted.brand-intent-brand" },
|
|
211
|
+
"6": { "name": "variable.other.brand-intent-brand" }
|
|
146
212
|
}
|
|
147
213
|
},
|
|
148
214
|
"family-from": {
|
|
@@ -162,7 +228,7 @@
|
|
|
162
228
|
},
|
|
163
229
|
"family-pair": {
|
|
164
230
|
"comment": "Pair line: ground / figure (hex or $ref). Listed before #comment so indented hex is not read as a comment.",
|
|
165
|
-
"match": "^(\\s{4,})(#[0-9A-Fa-f]{3,6}|\\$[\\w-]+)(\\s*/\\s*)(#[0-9A-Fa-f]{3,6}|\\$[\\w-]+)\\s*$",
|
|
231
|
+
"match": "^(\\s{4,})(#[0-9A-Fa-f]{3,6}|\\$[\\w-]+(?:\\.\\d+)?)(\\s*/\\s*)(#[0-9A-Fa-f]{3,6}|\\$[\\w-]+(?:\\.\\d+)?)\\s*$",
|
|
166
232
|
"captures": {
|
|
167
233
|
"2": { "name": "constant.other.color.rgb-value.brand-intent-brand" },
|
|
168
234
|
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
@@ -287,7 +353,7 @@
|
|
|
287
353
|
},
|
|
288
354
|
"token-slot-ref": {
|
|
289
355
|
"comment": "Indented token slot with $ref value, optional alpha suffix",
|
|
290
|
-
"match": "^(\\s{2,})([-\\w]+)(:\\s*)(\\$[\\w-]+)(?:\\s+([0-9]+(?:\\.[0-9]+)?%))?",
|
|
356
|
+
"match": "^(\\s{2,})([-\\w]+)(:\\s*)(\\$[\\w-]+(?:\\.\\d+)?)(?:\\s+([0-9]+(?:\\.[0-9]+)?%))?",
|
|
291
357
|
"captures": {
|
|
292
358
|
"2": { "name": "support.type.property-name.brand-intent-brand" },
|
|
293
359
|
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|