brand-intent 0.18.0 → 0.20.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 +1 -1
- package/parser/brandParser.ts +281 -27
- package/parser/colorSystem.ts +60 -6
- package/parser/index.ts +7 -0
- package/parser/types.ts +26 -0
- package/vscode-extension/syntaxes/brand.tmLanguage.json +73 -7
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,
|
|
@@ -35,6 +38,9 @@ import {
|
|
|
35
38
|
resolveTextColor,
|
|
36
39
|
checkFamilyPair,
|
|
37
40
|
reachCoversAll,
|
|
41
|
+
cycleMembers,
|
|
42
|
+
LEVEL_SLOTS,
|
|
43
|
+
levelRuleViolations,
|
|
38
44
|
} from './colorSystem';
|
|
39
45
|
import { parseIconSource, isIconId } from './icons';
|
|
40
46
|
import { parseDateWindowRanges } from './dateWindows';
|
|
@@ -88,6 +94,10 @@ export interface ParsedBrand {
|
|
|
88
94
|
colorAdherenceOverrides?: AdherenceTolerances;
|
|
89
95
|
/** Contrast + tone rules (`color-rules` block). Absent when not declared. */
|
|
90
96
|
colorRules?: ColorRules;
|
|
97
|
+
/** Declared tone ladders (`tones` block). Referenced as `$name.step`. */
|
|
98
|
+
tones?: Tones;
|
|
99
|
+
/** Resolved `levels`: every family × level with all slots. Requires `color-families`. */
|
|
100
|
+
levels?: Levels;
|
|
91
101
|
/** Color families (`color-families` block): ordered sets with roles, reach config and checked pairs. */
|
|
92
102
|
colorFamilies?: ColorFamilies;
|
|
93
103
|
/** Ground / figure / text for every pair theme (authored `ground:`/`figure:` themes and emitted family pairs). */
|
|
@@ -318,10 +328,24 @@ function hexToRgb(hex6: string): [number, number, number] {
|
|
|
318
328
|
];
|
|
319
329
|
}
|
|
320
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Look up a color reference name (without `$`): a brand color, or `name.step` for a declared tone.
|
|
333
|
+
* Returns the raw stored value, undefined when unknown.
|
|
334
|
+
*/
|
|
335
|
+
function lookupColorRef(
|
|
336
|
+
refName: string,
|
|
337
|
+
brandColors: Record<string, string>,
|
|
338
|
+
tones?: Record<string, Record<number, string>>,
|
|
339
|
+
): string | undefined {
|
|
340
|
+
if (brandColors[refName]) return brandColors[refName];
|
|
341
|
+
const m = refName.match(/^(.+)\.(\d+)$/);
|
|
342
|
+
return m ? tones?.[m[1]]?.[Number(m[2])] : undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
321
345
|
/**
|
|
322
346
|
* Resolve a theme-slot value string to a final CSS color.
|
|
323
347
|
*
|
|
324
|
-
* Supports: `$ref`, `$ref NN%`, `#HEX`, `#HEX NN%`, raw rgba(...) pass-through.
|
|
348
|
+
* Supports: `$ref`, `$ref.step`, `$ref NN%`, `#HEX`, `#HEX NN%`, raw rgba(...) pass-through.
|
|
325
349
|
* Throws on malformed alpha or unresolved `$ref`.
|
|
326
350
|
*/
|
|
327
351
|
function resolveThemeSlotValue(
|
|
@@ -329,6 +353,7 @@ function resolveThemeSlotValue(
|
|
|
329
353
|
brandColors: Record<string, string>,
|
|
330
354
|
fileName: string,
|
|
331
355
|
slot: string,
|
|
356
|
+
tones?: Record<string, Record<number, string>>,
|
|
332
357
|
): string {
|
|
333
358
|
const value = raw.trim();
|
|
334
359
|
|
|
@@ -358,7 +383,7 @@ function resolveThemeSlotValue(
|
|
|
358
383
|
let hex: string | null = null;
|
|
359
384
|
if (base.startsWith('$')) {
|
|
360
385
|
const refName = base.slice(1);
|
|
361
|
-
const resolved = brandColors
|
|
386
|
+
const resolved = lookupColorRef(refName, brandColors, tones);
|
|
362
387
|
if (!resolved) {
|
|
363
388
|
throw new Error(
|
|
364
389
|
`${fileName}: theme slot "${slot}" references unknown brand color "$${refName}"`,
|
|
@@ -402,12 +427,24 @@ function resolveThemeSlotValue(
|
|
|
402
427
|
export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
403
428
|
const lines = content.split('\n');
|
|
404
429
|
|
|
430
|
+
/** `slot: value` inside a level (defaults or family override); slot must be a level slot. */
|
|
431
|
+
const parseSlotLine = (trimmed: string, lineNum: number, where: string) => {
|
|
432
|
+
const colonIdx = trimmed.indexOf(':');
|
|
433
|
+
const slot = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : '';
|
|
434
|
+
if (!(LEVEL_SLOTS as readonly string[]).includes(slot)) {
|
|
435
|
+
throw new Error(`${fileName}:${lineNum}: ${where}: unknown slot "${slot || trimmed}" (slots: ${LEVEL_SLOTS.join(', ')})`);
|
|
436
|
+
}
|
|
437
|
+
const value = trimmed.slice(colonIdx + 1).replace(/\s+#\s.*$/, '').trim();
|
|
438
|
+
if (!value) throw new Error(`${fileName}:${lineNum}: ${where}: slot "${slot}" has no value`);
|
|
439
|
+
return { slot, value, line: lineNum };
|
|
440
|
+
};
|
|
441
|
+
|
|
405
442
|
const result: Partial<ParsedBrand> = {
|
|
406
443
|
tokensFile: 'tokens.json',
|
|
407
444
|
tokensSource: 'figma',
|
|
408
445
|
};
|
|
409
446
|
|
|
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';
|
|
447
|
+
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
448
|
let activeBlock: ActiveBlock = 'none';
|
|
412
449
|
let currentThemeName = '';
|
|
413
450
|
let currentDividerName = '';
|
|
@@ -430,7 +467,18 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
430
467
|
const adherenceOverridesRaw: Record<string, string> = {};
|
|
431
468
|
const colorRulesRaw: Record<string, string> = {};
|
|
432
469
|
let colorRulesDeclared = false;
|
|
433
|
-
|
|
470
|
+
let tonesDeclared = false;
|
|
471
|
+
let tonesStepsRaw: { value: string; line: number } | null = null;
|
|
472
|
+
const tonesRaw: Array<{ name: string; values: string[]; line: number }> = [];
|
|
473
|
+
type RawSlot = { slot: string; value: string; line: number };
|
|
474
|
+
type RawLevel = { name: string; line: number; slots: RawSlot[] };
|
|
475
|
+
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[] };
|
|
476
|
+
const levelsRaw: RawLevel[] = [];
|
|
477
|
+
let levelsDeclared = false;
|
|
478
|
+
let currentLevel: RawLevel | null = null;
|
|
479
|
+
/** Level override inside a family (`level Strong` under `family Dusk`) and its indent. */
|
|
480
|
+
let currentFamilyLevel: RawLevel | null = null;
|
|
481
|
+
let familyLevelIndent = 0;
|
|
434
482
|
const familiesProps: Record<string, string> = {};
|
|
435
483
|
const familiesRaw: RawFamily[] = [];
|
|
436
484
|
let familiesDeclared = false;
|
|
@@ -657,6 +705,39 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
657
705
|
}
|
|
658
706
|
continue;
|
|
659
707
|
}
|
|
708
|
+
if (activeBlock === 'tones') {
|
|
709
|
+
// Hex values start with `#`, so only ` # ` (hash between spaces) starts a comment here.
|
|
710
|
+
const trimmed = raw.trim().replace(/\s+#\s.*$/, '');
|
|
711
|
+
if (trimmed === '' || trimmed.startsWith('# ')) continue;
|
|
712
|
+
const colonIdx = trimmed.indexOf(':');
|
|
713
|
+
const key = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : '';
|
|
714
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
715
|
+
if (key === 'steps') {
|
|
716
|
+
if (tonesStepsRaw) throw new Error(`${fileName}:${lineNum}: tones declares steps: twice`);
|
|
717
|
+
tonesStepsRaw = { value, line: lineNum };
|
|
718
|
+
} else if (key.startsWith('$') && key.length > 1) {
|
|
719
|
+
const name = key.slice(1);
|
|
720
|
+
if (tonesRaw.some(t => t.name === name)) throw new Error(`${fileName}:${lineNum}: tones declares "${key}" twice`);
|
|
721
|
+
tonesRaw.push({ name, values: value.split(/\s+/).filter(Boolean), line: lineNum });
|
|
722
|
+
} else {
|
|
723
|
+
throw new Error(`${fileName}:${lineNum}: expected "steps: …" or "$color: #HEX …" in tones, got "${trimmed}"`);
|
|
724
|
+
}
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (activeBlock === 'levels') {
|
|
728
|
+
const trimmed = raw.trim();
|
|
729
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
730
|
+
const levelMatch = trimmed.match(/^level\s+(\S+)$/);
|
|
731
|
+
if (levelMatch) {
|
|
732
|
+
if (levelsRaw.some(l => l.name === levelMatch[1])) throw new Error(`${fileName}:${lineNum}: duplicate level "${levelMatch[1]}"`);
|
|
733
|
+
currentLevel = { name: levelMatch[1], line: lineNum, slots: [] };
|
|
734
|
+
levelsRaw.push(currentLevel);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (!currentLevel) throw new Error(`${fileName}:${lineNum}: expected "level NAME" in levels, got "${trimmed}"`);
|
|
738
|
+
currentLevel.slots.push(parseSlotLine(trimmed, lineNum, `level ${currentLevel.name}`));
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
660
741
|
if (activeBlock === 'color-families') {
|
|
661
742
|
const trimmed = raw.trim();
|
|
662
743
|
if (trimmed === '') continue;
|
|
@@ -665,7 +746,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
665
746
|
if (familiesRaw.some(f => f.name === familyMatch[1])) {
|
|
666
747
|
throw new Error(`${fileName}:${lineNum}: duplicate family "${familyMatch[1]}"`);
|
|
667
748
|
}
|
|
668
|
-
currentFamily = { name: familyMatch[1], line: lineNum, colors: [], pairs: [] };
|
|
749
|
+
currentFamily = { name: familyMatch[1], line: lineNum, colors: [], pairs: [], levels: [] };
|
|
750
|
+
currentFamilyLevel = null;
|
|
669
751
|
familiesRaw.push(currentFamily);
|
|
670
752
|
inPairs = false;
|
|
671
753
|
continue;
|
|
@@ -678,6 +760,23 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
678
760
|
familiesProps[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
|
|
679
761
|
continue;
|
|
680
762
|
}
|
|
763
|
+
const indent = raw.length - raw.trimStart().length;
|
|
764
|
+
if (currentFamilyLevel && indent > familyLevelIndent) {
|
|
765
|
+
currentFamilyLevel.slots.push(parseSlotLine(trimmed, lineNum, `family "${currentFamily.name}" level ${currentFamilyLevel.name}`));
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
currentFamilyLevel = null;
|
|
769
|
+
const familyLevelMatch = trimmed.match(/^level\s+(\S+)$/);
|
|
770
|
+
if (familyLevelMatch) {
|
|
771
|
+
if (currentFamily.levels.some(l => l.name === familyLevelMatch[1])) {
|
|
772
|
+
throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" declares level ${familyLevelMatch[1]} twice`);
|
|
773
|
+
}
|
|
774
|
+
currentFamilyLevel = { name: familyLevelMatch[1], line: lineNum, slots: [] };
|
|
775
|
+
familyLevelIndent = indent;
|
|
776
|
+
currentFamily.levels.push(currentFamilyLevel);
|
|
777
|
+
inPairs = false;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
681
780
|
if (trimmed === 'pairs') {
|
|
682
781
|
inPairs = true;
|
|
683
782
|
continue;
|
|
@@ -700,8 +799,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
700
799
|
currentFamily.from = value;
|
|
701
800
|
continue;
|
|
702
801
|
}
|
|
802
|
+
if (key === 'signal' && colonIdx !== -1) {
|
|
803
|
+
if (currentFamily.signal) throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" declares signal: twice`);
|
|
804
|
+
currentFamily.signal = { value: trimmed.slice(colonIdx + 1).trim(), line: lineNum };
|
|
805
|
+
inPairs = false;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
703
808
|
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}"`);
|
|
809
|
+
throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected from / core / accent / contrast / signal / level / pairs, got "${trimmed}"`);
|
|
705
810
|
}
|
|
706
811
|
inPairs = false;
|
|
707
812
|
currentFamily.colors.push({ role: key, value: trimmed.slice(colonIdx + 1).trim(), line: lineNum });
|
|
@@ -915,6 +1020,23 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
915
1020
|
continue;
|
|
916
1021
|
}
|
|
917
1022
|
|
|
1023
|
+
// tones — declared tone ladders per color (optional trailing comment)
|
|
1024
|
+
if (/^tones(\s+#.*)?$/.test(raw.trim())) {
|
|
1025
|
+
if (tonesDeclared) throw new Error(`${fileName}:${lineNum}: duplicate tones block`);
|
|
1026
|
+
activeBlock = 'tones';
|
|
1027
|
+
tonesDeclared = true;
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// levels — slot defaults per level (families override)
|
|
1032
|
+
if (raw.trim() === 'levels') {
|
|
1033
|
+
if (levelsDeclared) throw new Error(`${fileName}:${lineNum}: duplicate levels block`);
|
|
1034
|
+
activeBlock = 'levels';
|
|
1035
|
+
levelsDeclared = true;
|
|
1036
|
+
currentLevel = null;
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
918
1040
|
// color-families — ordered color sets with roles, reach and pairs
|
|
919
1041
|
if (raw.trim() === 'color-families') {
|
|
920
1042
|
if (familiesDeclared) throw new Error(`${fileName}:${lineNum}: duplicate color-families block`);
|
|
@@ -1191,6 +1313,30 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1191
1313
|
result.colorRules = rules;
|
|
1192
1314
|
}
|
|
1193
1315
|
|
|
1316
|
+
// ── Finalise tones: ladders now, names + anchors after the families joined brandColors ──
|
|
1317
|
+
let tones: Tones | undefined;
|
|
1318
|
+
if (tonesDeclared) {
|
|
1319
|
+
if (!tonesStepsRaw) throw new Error(`${fileName}: tones needs a "steps:" line`);
|
|
1320
|
+
const stepsLine = tonesStepsRaw.line;
|
|
1321
|
+
const steps = tonesStepsRaw.value.split(',').map(s => s.trim()).map(s => /^\d+$/.test(s) ? Number(s) : NaN);
|
|
1322
|
+
if (steps.length === 0 || steps.some(isNaN) || steps.some((s, i) => i > 0 && s <= steps[i - 1])) {
|
|
1323
|
+
throw new Error(`${fileName}:${stepsLine}: tones steps must be ascending whole numbers, e.g. "10, 20, 30", got "${tonesStepsRaw.value}"`);
|
|
1324
|
+
}
|
|
1325
|
+
tones = { steps, colors: {} };
|
|
1326
|
+
for (const t of tonesRaw) {
|
|
1327
|
+
if (t.values.length !== steps.length) {
|
|
1328
|
+
throw new Error(`${fileName}:${t.line}: tones "$${t.name}" has ${t.values.length} values, steps declares ${steps.length}`);
|
|
1329
|
+
}
|
|
1330
|
+
const ladder: Record<number, string> = {};
|
|
1331
|
+
t.values.forEach((v, i) => {
|
|
1332
|
+
const hex = normalizeHex(v);
|
|
1333
|
+
if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${t.line}: tones "$${t.name}" value "${v}" is not a 6-digit hex`);
|
|
1334
|
+
ladder[steps[i]] = hex;
|
|
1335
|
+
});
|
|
1336
|
+
tones.colors[t.name] = ladder;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1194
1340
|
// ── Finalise color-families: colors + flat projection (pairs later, after themes) ──
|
|
1195
1341
|
let colorFamilies: ColorFamilies | undefined;
|
|
1196
1342
|
if (familiesDeclared) {
|
|
@@ -1202,26 +1348,42 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1202
1348
|
if (!Number.isInteger(distance) || distance < 1) throw new Error(`${fileName}: color-families distance must be a whole number ≥ 1, got "${familiesProps.distance}"`);
|
|
1203
1349
|
if (familiesRaw.length === 0) throw new Error(`${fileName}: color-families declares no family`);
|
|
1204
1350
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1351
|
+
/** Comma-separated `$refs` to brand-colors (`shared:`, `photo-fallback:`). */
|
|
1352
|
+
const colorRefs = (key: string): Array<{ name: string; hex: string }> =>
|
|
1353
|
+
(familiesProps[key] ?? '').split(',').map(r => r.trim()).filter(Boolean).map(ref => {
|
|
1354
|
+
if (!ref.startsWith('$')) throw new Error(`${fileName}: color-families ${key} entries must be $refs to brand-colors, got "${ref}"`);
|
|
1355
|
+
const found = lookupColorRef(ref.slice(1), brandColors, tones?.colors);
|
|
1356
|
+
const hex = found ? normalizeHex(found) : null;
|
|
1357
|
+
if (!hex || !isHex6(hex)) throw new Error(`${fileName}: color-families ${key} "${ref}" is not a known 6-digit brand color`);
|
|
1358
|
+
return { name: ref.slice(1), hex };
|
|
1359
|
+
});
|
|
1360
|
+
const shared = colorRefs('shared');
|
|
1361
|
+
const photoFallback = colorRefs('photo-fallback');
|
|
1212
1362
|
|
|
1213
1363
|
const families: ColorFamily[] = [];
|
|
1214
1364
|
for (const raw of familiesRaw) {
|
|
1215
1365
|
const counts: Record<string, number> = {};
|
|
1216
1366
|
for (const c of raw.colors) counts[c.role] = (counts[c.role] ?? 0) + 1;
|
|
1217
1367
|
if (counts.core !== 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" needs exactly one core color`);
|
|
1218
|
-
if (counts.contrast
|
|
1368
|
+
if ((counts.contrast ?? 0) > 1) throw new Error(`${fileName}:${raw.line}: family "${raw.name}" declares more than one contrast color`);
|
|
1219
1369
|
const seen: Record<string, number> = {};
|
|
1370
|
+
const refs = new Set<FamilyColor>();
|
|
1220
1371
|
const colors: FamilyColor[] = raw.colors.map(c => {
|
|
1372
|
+
if (c.value.startsWith('$')) {
|
|
1373
|
+
// A reference to a color declared once elsewhere (shared between families); it doesn't join brandColors again.
|
|
1374
|
+
const ref = c.value.slice(1);
|
|
1375
|
+
const found = lookupColorRef(ref, brandColors, tones?.colors);
|
|
1376
|
+
const hex = found ? normalizeHex(found) : null;
|
|
1377
|
+
if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} "${c.value}" is not a known 6-digit color`);
|
|
1378
|
+
seen[c.role] = (seen[c.role] ?? 0) + 1;
|
|
1379
|
+
const color = { role: c.role as FamilyRole, hex, name: ref, key: ref };
|
|
1380
|
+
refs.add(color);
|
|
1381
|
+
return color;
|
|
1382
|
+
}
|
|
1221
1383
|
const m = c.value.match(/^(#[0-9a-fA-F]{3,6})(?:\s+(.+))?$/);
|
|
1222
1384
|
const hex = m ? normalizeHex(m[1]) : null;
|
|
1223
1385
|
if (!m || !hex || !isHex6(hex)) {
|
|
1224
|
-
throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} must be "#HEX"
|
|
1386
|
+
throw new Error(`${fileName}:${c.line}: family "${raw.name}" ${c.role} must be "#HEX", "#HEX Name" or "$ref", got "${c.value}"`);
|
|
1225
1387
|
}
|
|
1226
1388
|
seen[c.role] = (seen[c.role] ?? 0) + 1;
|
|
1227
1389
|
const generated = `${raw.name.toLowerCase()}-${c.role}${counts[c.role] > 1 ? `-${seen[c.role]}` : ''}`;
|
|
@@ -1229,6 +1391,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1229
1391
|
return { role: c.role as FamilyRole, hex, name, key: colorKey(name) };
|
|
1230
1392
|
});
|
|
1231
1393
|
for (const c of colors) {
|
|
1394
|
+
if (refs.has(c)) continue;
|
|
1232
1395
|
if (brandColors[c.key]) {
|
|
1233
1396
|
throw new Error(`${fileName}: family "${raw.name}" color name "${c.name}" collides with an existing brand color`);
|
|
1234
1397
|
}
|
|
@@ -1236,22 +1399,111 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1236
1399
|
}
|
|
1237
1400
|
families.push({ name: raw.name, ...(raw.from ? { from: raw.from } : {}), colors, pairs: [] });
|
|
1238
1401
|
}
|
|
1402
|
+
// Undated families next to dated ones are hand-picked contexts: off the cycle, never in season.
|
|
1239
1403
|
const dated = familiesRaw.filter(f => f.from !== undefined);
|
|
1240
|
-
if (dated.length > 0 && dated.length < familiesRaw.length) {
|
|
1241
|
-
const missing = familiesRaw.filter(f => f.from === undefined).map(f => f.name).join(', ');
|
|
1242
|
-
throw new Error(`${fileName}: color-families: from: must be declared on every family or on none (missing: ${missing})`);
|
|
1243
|
-
}
|
|
1244
1404
|
for (const f of dated) {
|
|
1245
1405
|
const twin = dated.find(o => o !== f && o.from === f.from);
|
|
1246
1406
|
if (twin) throw new Error(`${fileName}:${f.line}: families "${twin.name}" and "${f.name}" share from: ${f.from}`);
|
|
1247
1407
|
}
|
|
1248
|
-
colorFamilies = { order, borrow, distance, shared, families };
|
|
1249
|
-
if (borrow !== 'none' && reachCoversAll(families.length, { order, borrow, distance })) {
|
|
1408
|
+
colorFamilies = { order, borrow, distance, shared, ...(photoFallback.length > 0 ? { photoFallback } : {}), families };
|
|
1409
|
+
if (borrow !== 'none' && reachCoversAll(cycleMembers(families).length, { order, borrow, distance })) {
|
|
1250
1410
|
colorWarnings.push(`color-families: borrow "${borrow}" with distance ${distance} reaches every family — the borrow rule has no effect`);
|
|
1251
1411
|
}
|
|
1252
1412
|
result.colorFamilies = colorFamilies;
|
|
1253
1413
|
}
|
|
1254
1414
|
|
|
1415
|
+
// Tones belong to a declared color, and that color's own hex sits on its ladder (the anchor).
|
|
1416
|
+
if (tones) {
|
|
1417
|
+
for (const t of tonesRaw) {
|
|
1418
|
+
const own = brandColors[t.name] ? normalizeHex(brandColors[t.name]) : null;
|
|
1419
|
+
if (!own) throw new Error(`${fileName}:${t.line}: tones "$${t.name}" is not a brand color or family color`);
|
|
1420
|
+
if (!Object.values(tones.colors[t.name]).includes(own)) {
|
|
1421
|
+
throw new Error(`${fileName}:${t.line}: tones "$${t.name}" must contain its own hex ${own} on one of its steps`);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
result.tones = tones;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ── Finalise levels: every family × level resolves all slots ──────────────
|
|
1428
|
+
if (levelsDeclared) {
|
|
1429
|
+
if (!colorFamilies) throw new Error(`${fileName}: levels needs a color-families block`);
|
|
1430
|
+
if (levelsRaw.length === 0) throw new Error(`${fileName}: levels declares no level`);
|
|
1431
|
+
const names = levelsRaw.map(l => l.name);
|
|
1432
|
+
const levelByKey = new Map(levelsRaw.map(l => [l.name.toLowerCase(), l]));
|
|
1433
|
+
for (const l of levelsRaw) {
|
|
1434
|
+
if (/^(p|a1|a2)$/i.test(l.name)) throw new Error(`${fileName}:${l.line}: level name "${l.name}" is reserved (family shorthand)`);
|
|
1435
|
+
}
|
|
1436
|
+
const cells: Levels['cells'] = {};
|
|
1437
|
+
colorFamilies.families.forEach((family, index) => {
|
|
1438
|
+
const raw = familiesRaw[index];
|
|
1439
|
+
for (const o of raw.levels) {
|
|
1440
|
+
if (!levelByKey.has(o.name.toLowerCase())) throw new Error(`${fileName}:${o.line}: family "${family.name}" overrides unknown level "${o.name}"`);
|
|
1441
|
+
}
|
|
1442
|
+
let signal: Partial<Record<LevelSlot, RawSlot>> = {};
|
|
1443
|
+
if (raw.signal) {
|
|
1444
|
+
const parts = raw.signal.value.split('/').map(p => p.trim());
|
|
1445
|
+
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}"`);
|
|
1446
|
+
signal = { 'signal': { slot: 'signal', value: parts[0], line: raw.signal.line }, 'on-signal': { slot: 'on-signal', value: parts[1], line: raw.signal.line } };
|
|
1447
|
+
}
|
|
1448
|
+
/** Authored value for a cell: family level override, then family signal, then level default. */
|
|
1449
|
+
const authored = (level: RawLevel, slot: LevelSlot): RawSlot | undefined =>
|
|
1450
|
+
raw.levels.find(o => o.name.toLowerCase() === level.name.toLowerCase())?.slots.find(s => s.slot === slot)
|
|
1451
|
+
?? signal[slot]
|
|
1452
|
+
?? level.slots.find(s => s.slot === slot);
|
|
1453
|
+
const role = (shorthand: string): FamilyColor => {
|
|
1454
|
+
const core = family.colors.find(c => c.role === 'core')!;
|
|
1455
|
+
const accents = family.colors.filter(c => c.role === 'accent');
|
|
1456
|
+
return shorthand === 'p' ? core : shorthand === 'a1' ? accents[0] ?? core : accents[1] ?? core;
|
|
1457
|
+
};
|
|
1458
|
+
const memo = new Map<string, string>();
|
|
1459
|
+
const resolve = (level: RawLevel, slot: LevelSlot, stack: string[]): string => {
|
|
1460
|
+
const id = `${level.name.toLowerCase()}.${slot}`;
|
|
1461
|
+
const cached = memo.get(id);
|
|
1462
|
+
if (cached) return cached;
|
|
1463
|
+
if (stack.includes(id)) throw new Error(`${fileName}: family "${family.name}": circular cell reference ${[...stack, id].join(' → ')}`);
|
|
1464
|
+
const a = authored(level, slot);
|
|
1465
|
+
const where = `family "${family.name}" level ${level.name} ${slot}`;
|
|
1466
|
+
if (!a) throw new Error(`${fileName}: ${where} is not set (level default or family override)`);
|
|
1467
|
+
if (a.value === 'required') throw new Error(`${fileName}:${a.line}: ${where} is required: set it in family "${family.name}" (level ${level.name})`);
|
|
1468
|
+
const at = `${fileName}:${a.line}: ${where}`;
|
|
1469
|
+
let hex: string | null = null;
|
|
1470
|
+
const short = a.value.match(/^(p|a1|a2)(?:\.(\d+))?$/);
|
|
1471
|
+
const cellRef = a.value.match(/^([^$#.\s]+)\.(\S+)$/);
|
|
1472
|
+
if (short) {
|
|
1473
|
+
const color = role(short[1]);
|
|
1474
|
+
if (short[2] === undefined) hex = color.hex;
|
|
1475
|
+
else {
|
|
1476
|
+
hex = tones?.colors[color.key]?.[Number(short[2])] ?? null;
|
|
1477
|
+
if (!hex) throw new Error(`${at}: "${a.value}" — ${color.name} has no tone ${short[2]}`);
|
|
1478
|
+
}
|
|
1479
|
+
} else if (a.value.startsWith('$')) {
|
|
1480
|
+
const found = lookupColorRef(a.value.slice(1), brandColors, tones?.colors);
|
|
1481
|
+
hex = found ? normalizeHex(found) : null;
|
|
1482
|
+
if (!hex) throw new Error(`${at}: unknown color "${a.value}"`);
|
|
1483
|
+
} else if (a.value.startsWith('#')) {
|
|
1484
|
+
hex = normalizeHex(a.value);
|
|
1485
|
+
} else if (cellRef) {
|
|
1486
|
+
const target = levelByKey.get(cellRef[1].toLowerCase());
|
|
1487
|
+
if (!target || !(LEVEL_SLOTS as readonly string[]).includes(cellRef[2])) throw new Error(`${at}: "${a.value}" is not a cell (level.slot)`);
|
|
1488
|
+
hex = resolve(target, cellRef[2] as LevelSlot, [...stack, id]);
|
|
1489
|
+
}
|
|
1490
|
+
if (!hex || !isHex6(hex)) throw new Error(`${at}: value "${a.value}" must be p / a1 / a2 [.step], $ref [.step], #HEX or level.slot`);
|
|
1491
|
+
memo.set(id, hex);
|
|
1492
|
+
return hex;
|
|
1493
|
+
};
|
|
1494
|
+
cells[family.name] = {};
|
|
1495
|
+
for (const level of levelsRaw) {
|
|
1496
|
+
const cell = {} as Record<LevelSlot, string>;
|
|
1497
|
+
for (const slot of LEVEL_SLOTS) cell[slot] = resolve(level, slot, []);
|
|
1498
|
+
cells[family.name][level.name] = cell;
|
|
1499
|
+
if (colorRulesDeclared) colorWarnings.push(...levelRuleViolations(family.name, level.name, cell, rules));
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
result.levels = { names, cells };
|
|
1503
|
+
} else if (familiesRaw.some(f => f.signal || f.levels.length > 0)) {
|
|
1504
|
+
throw new Error(`${fileName}: family signal: / level overrides need a levels block`);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1255
1507
|
// Store brand colors (flat projection)
|
|
1256
1508
|
if (Object.keys(brandColors).length > 0) {
|
|
1257
1509
|
result.brandColors = brandColors;
|
|
@@ -1344,14 +1596,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1344
1596
|
if (theme[key] !== undefined) { pairRaw[key] = theme[key]; delete theme[key]; }
|
|
1345
1597
|
}
|
|
1346
1598
|
for (const [slot, value] of Object.entries(theme)) {
|
|
1347
|
-
theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot);
|
|
1599
|
+
theme[slot] = resolveThemeSlotValue(value, brandColors, fileName, slot, tones?.colors);
|
|
1348
1600
|
}
|
|
1349
1601
|
if (Object.keys(pairRaw).length === 0) continue;
|
|
1350
1602
|
if (!pairRaw.ground || !pairRaw.figure) {
|
|
1351
1603
|
throw new Error(`${fileName}: theme "${name}" needs both "ground:" and "figure:" (pair theme), or neither`);
|
|
1352
1604
|
}
|
|
1353
1605
|
const solid = (key: string, value: string) => {
|
|
1354
|
-
const hex = resolveThemeSlotValue(value, brandColors, fileName, key);
|
|
1606
|
+
const hex = resolveThemeSlotValue(value, brandColors, fileName, key, tones?.colors);
|
|
1355
1607
|
if (!isHex6(hex)) throw new Error(`${fileName}: theme "${name}" ${key} must be a solid 6-digit color, got "${value}"`);
|
|
1356
1608
|
return hex;
|
|
1357
1609
|
};
|
|
@@ -1378,7 +1630,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1378
1630
|
const raw = familiesRaw[index];
|
|
1379
1631
|
raw.pairs.forEach((p, n) => {
|
|
1380
1632
|
const resolve = (value: string) => {
|
|
1381
|
-
const
|
|
1633
|
+
const found = value.startsWith('$') ? lookupColorRef(value.slice(1), brandColors, tones?.colors) : value;
|
|
1634
|
+
const hex = found ? normalizeHex(found) : null;
|
|
1382
1635
|
if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${p.line}: pair color "${value}" is not a known 6-digit color`);
|
|
1383
1636
|
return hex;
|
|
1384
1637
|
};
|
|
@@ -1423,7 +1676,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1423
1676
|
}
|
|
1424
1677
|
|
|
1425
1678
|
// color-mode: brand starts pages from a declared theme, so it needs at least one.
|
|
1426
|
-
if (result.colorMode === 'brand' && Object.keys(themes).length === 0) {
|
|
1679
|
+
if (result.colorMode === 'brand' && Object.keys(themes).length === 0 && !result.levels) {
|
|
1427
1680
|
throw new Error(`${fileName}: color-mode: brand requires at least one theme (a theme block or a valid color-families pair)`);
|
|
1428
1681
|
}
|
|
1429
1682
|
|
|
@@ -1705,10 +1958,11 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1705
1958
|
const refs = duo.value.split(/\s+/);
|
|
1706
1959
|
if (refs.length !== 2) throw new Error(`${fileName}:${duo.line}: photo-look duotone must be "SHADOW HIGHLIGHT", got "${duo.value}"`);
|
|
1707
1960
|
const [shadow, highlight] = refs.map(ref => {
|
|
1708
|
-
|
|
1961
|
+
const found = ref.startsWith('$') ? lookupColorRef(ref.slice(1), brandColors, tones?.colors) : ref;
|
|
1962
|
+
if (!found) {
|
|
1709
1963
|
throw new Error(`${fileName}:${duo.line}: photo-look duotone references unknown brand color "${ref}"`);
|
|
1710
1964
|
}
|
|
1711
|
-
const hex = normalizeHex(
|
|
1965
|
+
const hex = normalizeHex(found);
|
|
1712
1966
|
if (!hex) throw new Error(`${fileName}:${duo.line}: photo-look duotone colors must be $ref or #HEX, got "${ref}"`);
|
|
1713
1967
|
return hex;
|
|
1714
1968
|
});
|
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';
|
|
@@ -186,7 +205,7 @@ export function colorOrigin(
|
|
|
186
205
|
const families = system.families;
|
|
187
206
|
if (families[index]?.colors.some(c => c.hex === hex)) return 'own';
|
|
188
207
|
if (system.shared.some(s => s.hex === hex)) return 'shared';
|
|
189
|
-
const reach = reachableFamilies(families.length, index, config);
|
|
208
|
+
const reach = reachableFamilies(families.length, index, config, cycleMembers(families));
|
|
190
209
|
for (const [j, dir] of reach) {
|
|
191
210
|
if (families[j].colors.some(c => c.hex === hex)) return dir;
|
|
192
211
|
}
|
|
@@ -196,14 +215,14 @@ export function colorOrigin(
|
|
|
196
215
|
/**
|
|
197
216
|
* Family in season on `date`: the one with the latest `from:` on or before that
|
|
198
217
|
* month/day, wrapping to the year's last start before the first one. Uses the
|
|
199
|
-
* date's local calendar day. -1 when
|
|
218
|
+
* date's local calendar day. Undated families never win. -1 when no family declares a date.
|
|
200
219
|
*/
|
|
201
220
|
export function familyIndexForDate(families: Pick<ColorFamily, 'from'>[], date: Date): number {
|
|
202
|
-
if (families.length === 0 || families.some(f => !f.from)) return -1;
|
|
203
221
|
const today = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
204
222
|
let best = -1;
|
|
205
223
|
let latest = -1;
|
|
206
224
|
families.forEach((f, i) => {
|
|
225
|
+
if (!f.from) return;
|
|
207
226
|
if (f.from! <= today && (best === -1 || f.from! > families[best].from!)) best = i;
|
|
208
227
|
if (latest === -1 || f.from! > families[latest].from!) latest = i;
|
|
209
228
|
});
|
|
@@ -273,7 +292,7 @@ export function enumerateFamilyPairs(
|
|
|
273
292
|
const pool = new Set<string>();
|
|
274
293
|
for (const c of system.families[index].colors) pool.add(c.hex);
|
|
275
294
|
for (const s of system.shared) pool.add(s.hex);
|
|
276
|
-
for (const j of reachableFamilies(system.families.length, index, config).keys()) {
|
|
295
|
+
for (const j of reachableFamilies(system.families.length, index, config, cycleMembers(system.families)).keys()) {
|
|
277
296
|
for (const c of system.families[j].colors) pool.add(c.hex);
|
|
278
297
|
}
|
|
279
298
|
const colors = [...pool];
|
|
@@ -286,3 +305,38 @@ export function enumerateFamilyPairs(
|
|
|
286
305
|
}
|
|
287
306
|
return out;
|
|
288
307
|
}
|
|
308
|
+
|
|
309
|
+
// ── Levels ──────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/** The fixed level slots, in four groups: ground, contrast, inset, signal. */
|
|
312
|
+
export const LEVEL_SLOTS = [
|
|
313
|
+
'ground', 'on-ground/headline', 'on-ground/text', 'on-ground/accent', 'on-ground/line', 'on-ground/divider',
|
|
314
|
+
'contrast', 'on-contrast',
|
|
315
|
+
'inset', 'on-inset/text', 'on-inset/accent',
|
|
316
|
+
'signal', 'on-signal',
|
|
317
|
+
] as const;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Pairs `color-rules` checks in every level cell: [figure slot, ground slot, rule].
|
|
321
|
+
* Dividers are decorative and not checked.
|
|
322
|
+
*/
|
|
323
|
+
export const LEVEL_PAIRS: Array<[LevelSlot, LevelSlot, 'text' | 'figure']> = [
|
|
324
|
+
['on-ground/headline', 'ground', 'text'],
|
|
325
|
+
['on-ground/text', 'ground', 'text'],
|
|
326
|
+
['on-ground/accent', 'ground', 'figure'],
|
|
327
|
+
['on-ground/line', 'ground', 'figure'],
|
|
328
|
+
['on-contrast', 'contrast', 'text'],
|
|
329
|
+
['on-inset/text', 'inset', 'text'],
|
|
330
|
+
['on-inset/accent', 'inset', 'figure'],
|
|
331
|
+
['on-signal', 'signal', 'text'],
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
/** Rule warnings for one resolved level cell, e.g. `Dusk / Light / on-ground/accent on ground 2.1:1 < 3`. */
|
|
335
|
+
export function levelRuleViolations(family: string, level: string, cell: Record<LevelSlot, string>, rules: ColorRules): string[] {
|
|
336
|
+
const out: string[] = [];
|
|
337
|
+
for (const [fig, ground, rule] of LEVEL_PAIRS) {
|
|
338
|
+
const ratio = contrastRatio(cell[fig], cell[ground]);
|
|
339
|
+
if (ratio < rules[rule]) out.push(`${family} / ${level} / ${fig} on ${ground} ${ratio.toFixed(1)}:1 < ${rules[rule]}`);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
package/parser/index.ts
CHANGED
|
@@ -15,6 +15,10 @@ 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,
|
|
20
24
|
familyTextCandidates,
|
|
@@ -55,6 +59,9 @@ export type {
|
|
|
55
59
|
ColorFamily,
|
|
56
60
|
ColorFamilies,
|
|
57
61
|
ThemePair,
|
|
62
|
+
Tones,
|
|
63
|
+
Levels,
|
|
64
|
+
LevelSlot,
|
|
58
65
|
} from './types';
|
|
59
66
|
|
|
60
67
|
export { parseFormatFile } from './formatParser';
|
package/parser/types.ts
CHANGED
|
@@ -168,9 +168,35 @@ export interface ColorFamilies {
|
|
|
168
168
|
distance: number;
|
|
169
169
|
/** Colors allowed in every family (resolved from `shared:` refs). */
|
|
170
170
|
shared: Array<{ name: string; hex: string }>;
|
|
171
|
+
/** Text and figure colors for photos no pair reads on, in order of preference (`photo-fallback:` refs).
|
|
172
|
+
* Used only over photos, never in pairs or guided generation. Absent when not declared. */
|
|
173
|
+
photoFallback?: Array<{ name: string; hex: string }>;
|
|
171
174
|
families: ColorFamily[];
|
|
172
175
|
}
|
|
173
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
|
+
|
|
174
200
|
/** Ground / figure / text of a pair theme (declared or emitted from a family pair). */
|
|
175
201
|
export interface ThemePair {
|
|
176
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*)(.+)$",
|
|
@@ -120,8 +185,8 @@
|
|
|
120
185
|
}
|
|
121
186
|
},
|
|
122
187
|
"color-families-property": {
|
|
123
|
-
"comment": "Block keys under color-families: order/borrow/distance/shared",
|
|
124
|
-
"match": "^( {2})(order|borrow|distance|shared)(:\\s*)(.+)$",
|
|
188
|
+
"comment": "Block keys under color-families: order/borrow/distance/shared/photo-fallback",
|
|
189
|
+
"match": "^( {2})(order|borrow|distance|shared|photo-fallback)(:\\s*)(.+)$",
|
|
125
190
|
"captures": {
|
|
126
191
|
"2": { "name": "support.type.property-name.brand-intent-brand" },
|
|
127
192
|
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
@@ -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" },
|