brand-intent 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "An open file format specification for making brand intent machine-readable.",
5
5
  "author": "Christophe Stoll <christophe@stoll.studio>",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "release": "./scripts/release.sh",
38
- "test": "tsx tests/brandParser.test.ts && tsx tests/identityParser.test.ts && tsx tests/colorSystem.test.ts && tsx tests/realBrands.test.ts"
38
+ "test": "tsx tests/brandParser.test.ts && tsx tests/identityParser.test.ts && tsx tests/colorSystem.test.ts && tsx tests/icons.test.ts && tsx tests/purposeParser.test.ts && tsx tests/realBrands.test.ts"
39
39
  },
40
40
  "devDependencies": {
41
41
  "tsx": "^4.19.0"
@@ -36,6 +36,8 @@ import {
36
36
  checkFamilyPair,
37
37
  reachCoversAll,
38
38
  } from './colorSystem';
39
+ import { parseIconSource, isIconId } from './icons';
40
+ import type { ParsedIconDef } from './icons';
39
41
 
40
42
  // ── Parsed type ────────────────────────────────────────────────────────────────
41
43
 
@@ -106,6 +108,26 @@ export interface ParsedBrand {
106
108
  label?: ParsedBadgeStyle;
107
109
  /** CTA pill style — padding (top/right/bottom/left in cqh) and corner radius (cqh, or 999 for full pill) */
108
110
  cta?: ParsedBadgeStyle;
111
+ /** Brand icon set (`icons` block), in declaration order. Absent → the brand has no set. */
112
+ icons?: ParsedIconDef[];
113
+ /** List counter component (`counter` block). */
114
+ counter?: ParsedCounterDef;
115
+ }
116
+
117
+ export type CounterFormat = '1' | '01' | '1.' | '(1)' | 'i';
118
+ export type CounterShape = 'none' | 'circle' | 'square' | 'pill';
119
+
120
+ export interface ParsedCounterDef {
121
+ /** Number format: 1 | 01 | 1. | (1) | i (lowercase roman). */
122
+ format: CounterFormat;
123
+ /** Typography style for the digits. Absent → the list item's typography. */
124
+ typography?: string;
125
+ /** Background shape, coloured with the theme's counter-bg / counter-fg. */
126
+ shape: CounterShape;
127
+ /** Counter height in em, relative to the list item text. */
128
+ size: number;
129
+ /** Inner padding in em. */
130
+ padding: number;
109
131
  }
110
132
 
111
133
  export interface ParsedAssetDef {
@@ -329,7 +351,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
329
351
  tokensSource: 'figma',
330
352
  };
331
353
 
332
- type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'color-rules' | 'color-families' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta';
354
+ 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';
333
355
  let activeBlock: ActiveBlock = 'none';
334
356
  let currentThemeName = '';
335
357
  let currentDividerName = '';
@@ -372,6 +394,10 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
372
394
  let currentSubBlock: string | null = null;
373
395
  const labelRaw: Record<string, string> = {};
374
396
  const ctaRaw: Record<string, string> = {};
397
+ const icons: ParsedIconDef[] = [];
398
+ let iconsDeclared = false;
399
+ const counterRaw: Record<string, { value: string; line: number }> = {};
400
+ let counterDeclared = false;
375
401
 
376
402
  for (let i = 0; i < lines.length; i++) {
377
403
  const raw = lines[i];
@@ -718,6 +744,29 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
718
744
  }
719
745
  continue;
720
746
  }
747
+ if (activeBlock === 'icons') {
748
+ const trimmed = raw.trim();
749
+ if (trimmed.startsWith('#')) continue;
750
+ const commentIdx = trimmed.search(/\s+#/);
751
+ const entry = commentIdx !== -1 ? trimmed.slice(0, commentIdx) : trimmed;
752
+ const colonIdx = entry.indexOf(':');
753
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: icons entry must be "ID: SOURCE", got "${trimmed}"`);
754
+ const id = entry.slice(0, colonIdx).trim();
755
+ if (!isIconId(id)) throw new Error(`${fileName}:${lineNum}: invalid icon id "${id}" — use lowercase kebab-case`);
756
+ if (icons.some(ic => ic.id === id)) throw new Error(`${fileName}:${lineNum}: duplicate icon "${id}"`);
757
+ icons.push({ id, source: parseIconSource(entry.slice(colonIdx + 1), `${fileName}:${lineNum}`) });
758
+ continue;
759
+ }
760
+ if (activeBlock === 'counter') {
761
+ const trimmed = raw.trim();
762
+ if (trimmed.startsWith('#')) continue;
763
+ const commentIdx = trimmed.search(/\s+#/);
764
+ const entry = commentIdx !== -1 ? trimmed.slice(0, commentIdx) : trimmed;
765
+ const colonIdx = entry.indexOf(':');
766
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: counter property must be "KEY: VALUE", got "${trimmed}"`);
767
+ counterRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
768
+ continue;
769
+ }
721
770
  // Indented line with no active block — ignore
722
771
  continue;
723
772
  }
@@ -766,6 +815,22 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
766
815
  continue;
767
816
  }
768
817
 
818
+ // icons — the brand icon set
819
+ if (raw.trim() === 'icons') {
820
+ if (iconsDeclared) throw new Error(`${fileName}:${lineNum}: duplicate icons block`);
821
+ iconsDeclared = true;
822
+ activeBlock = 'icons';
823
+ continue;
824
+ }
825
+
826
+ // counter — list counter component
827
+ if (raw.trim() === 'counter') {
828
+ if (counterDeclared) throw new Error(`${fileName}:${lineNum}: duplicate counter block`);
829
+ counterDeclared = true;
830
+ activeBlock = 'counter';
831
+ continue;
832
+ }
833
+
769
834
  // theme ThemeName — starts a theme block
770
835
  const themeMatch = raw.match(/^theme\s+(\S+)/);
771
836
  if (themeMatch) {
@@ -1182,6 +1247,12 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1182
1247
  });
1183
1248
  }
1184
1249
 
1250
+ // Counter colours default to the label badge's.
1251
+ for (const theme of Object.values(themes)) {
1252
+ if (theme['counter-bg'] === undefined && theme['label-bg'] !== undefined) theme['counter-bg'] = theme['label-bg'];
1253
+ if (theme['counter-fg'] === undefined && theme['label-fg'] !== undefined) theme['counter-fg'] = theme['label-fg'];
1254
+ }
1255
+
1185
1256
  // Rule checks on every theme (only when the brand declares color-rules).
1186
1257
  if (colorRulesDeclared) {
1187
1258
  for (const [name, theme] of Object.entries(themes)) {
@@ -1349,6 +1420,59 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1349
1420
  result.cta = parseBadgeStyle(ctaRaw, { padding: [0.8, 2.5, 0.8, 2.5], radius: 999 });
1350
1421
  }
1351
1422
 
1423
+ // Finalise icon set — font glyphs need a declared font (bare `font U+…` → first font).
1424
+ if (iconsDeclared) {
1425
+ if (icons.length === 0) throw new Error(`${fileName}: icons block is empty`);
1426
+ const fontNames = Object.keys(result.fonts ?? {});
1427
+ for (const icon of icons) {
1428
+ if (icon.source.kind !== 'font') continue;
1429
+ if (!icon.source.font) {
1430
+ if (fontNames.length === 0) throw new Error(`${fileName}: icon "${icon.id}" uses a font glyph, but no font block is declared`);
1431
+ icon.source.font = fontNames[0];
1432
+ } else if (!fontNames.includes(icon.source.font)) {
1433
+ throw new Error(`${fileName}: icon "${icon.id}" references unknown font "$${icon.source.font}"`);
1434
+ }
1435
+ }
1436
+ result.icons = icons;
1437
+ }
1438
+
1439
+ // Finalise counter component
1440
+ if (counterDeclared) {
1441
+ const known = new Set(['format', 'typography', 'shape', 'size', 'padding']);
1442
+ for (const [key, { line }] of Object.entries(counterRaw)) {
1443
+ if (!known.has(key)) throw new Error(`${fileName}:${line}: unknown counter property "${key}"`);
1444
+ }
1445
+ const formats: CounterFormat[] = ['1', '01', '1.', '(1)', 'i'];
1446
+ const shapes: CounterShape[] = ['none', 'circle', 'square', 'pill'];
1447
+ const em = (key: string, fallback: number): number => {
1448
+ const entry = counterRaw[key];
1449
+ if (!entry) return fallback;
1450
+ const m = entry.value.match(/^(\d+(?:\.\d+)?)(?:em)?$/);
1451
+ if (!m) throw new Error(`${fileName}:${entry.line}: counter ${key} must be a number in em (e.g. "1.2em"), got "${entry.value}"`);
1452
+ return parseFloat(m[1]);
1453
+ };
1454
+ const format = counterRaw['format']?.value ?? '1';
1455
+ if (!formats.includes(format as CounterFormat)) {
1456
+ throw new Error(`${fileName}:${counterRaw['format'].line}: counter format must be one of ${formats.join(' | ')}, got "${format}"`);
1457
+ }
1458
+ const shape = counterRaw['shape']?.value ?? 'circle';
1459
+ if (!shapes.includes(shape as CounterShape)) {
1460
+ throw new Error(`${fileName}:${counterRaw['shape'].line}: counter shape must be one of ${shapes.join(' | ')}, got "${shape}"`);
1461
+ }
1462
+ const counter: ParsedCounterDef = {
1463
+ format: format as CounterFormat,
1464
+ shape: shape as CounterShape,
1465
+ size: em('size', 1),
1466
+ padding: em('padding', 0.2),
1467
+ };
1468
+ const typo = counterRaw['typography'];
1469
+ if (typo) {
1470
+ if (!result.typographies?.[typo.value]) throw new Error(`${fileName}:${typo.line}: counter typography "${typo.value}" is not a declared typography`);
1471
+ counter.typography = typo.value;
1472
+ }
1473
+ result.counter = counter;
1474
+ }
1475
+
1352
1476
  // Validate required fields
1353
1477
  const required: (keyof ParsedBrand)[] = ['id', 'name', 'language', 'locale'];
1354
1478
  for (const field of required) {
@@ -102,11 +102,12 @@ export function derivePairSlots(ground: string, figure: string, text: string): R
102
102
  'text-primary': text,
103
103
  };
104
104
  for (const [slot, alpha] of TEXT_TIERS) slots[slot] = withAlpha(text, alpha);
105
- for (const slot of ['logo', 'cta', 'divider', 'icon', 'illustration', 'sticker-bg', 'label-bg']) {
105
+ for (const slot of ['logo', 'cta', 'divider', 'icon', 'illustration', 'sticker-bg', 'label-bg', 'counter-bg']) {
106
106
  slots[slot] = figure;
107
107
  }
108
108
  slots['sticker-fg'] = ground;
109
109
  slots['label-fg'] = ground;
110
+ slots['counter-fg'] = ground;
110
111
  return slots;
111
112
  }
112
113
 
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Icons: brand icon sets (`icons` block in .brand) and icon references
3
+ * (`defaultIcon:` and `[id]` list-sample prefixes in .purpose).
4
+ *
5
+ * A reference resolves in the brand set first, then as a Phosphor name.
6
+ * `phosphor:NAME` skips the brand set.
7
+ */
8
+
9
+ export const PHOSPHOR_WEIGHTS = ['thin', 'light', 'regular', 'bold', 'fill', 'duotone'] as const;
10
+ export type PhosphorWeight = typeof PHOSPHOR_WEIGHTS[number];
11
+
12
+ /** Where a brand icon comes from. */
13
+ export type IconSource =
14
+ /** Glyph in a brand font. `font` is the font block name; exactly one of codepoint / glyph is set. */
15
+ | { kind: 'font'; font: string; codepoint?: number; glyph?: string }
16
+ /** SVG file, relative to the brand folder. */
17
+ | { kind: 'svg'; src: string }
18
+ /** Phosphor icon adopted into the set. `name` is PascalCase (e.g. "MapPin"). */
19
+ | { kind: 'phosphor'; name: string; weight?: PhosphorWeight };
20
+
21
+ export interface ParsedIconDef {
22
+ /** Semantic id, e.g. "quote", "pin". */
23
+ id: string;
24
+ source: IconSource;
25
+ }
26
+
27
+ /** Icon reference from a .purpose file. */
28
+ export interface IconRef {
29
+ /** Icon id as written, kebab-case (e.g. "quote", "map-pin"). */
30
+ id: string;
31
+ /** Set when written as `phosphor:NAME` — skips the brand set. */
32
+ library?: 'phosphor';
33
+ /** Phosphor weight. Only meaningful when the reference resolves to Phosphor. */
34
+ weight?: PhosphorWeight;
35
+ }
36
+
37
+ export type ResolvedIcon =
38
+ | { kind: 'brand'; icon: ParsedIconDef }
39
+ | { kind: 'phosphor'; name: string; weight?: PhosphorWeight };
40
+
41
+ const ICON_ID = /^[a-z][a-z0-9-]*$/;
42
+
43
+ /** "map-pin" → "MapPin" */
44
+ export function phosphorName(id: string): string {
45
+ return id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
46
+ }
47
+
48
+ function parseWeight(value: string, where: string): PhosphorWeight {
49
+ if (!(PHOSPHOR_WEIGHTS as readonly string[]).includes(value)) {
50
+ throw new Error(`${where}: invalid icon weight "${value}" — expected ${PHOSPHOR_WEIGHTS.join(', ')}`);
51
+ }
52
+ return value as PhosphorWeight;
53
+ }
54
+
55
+ /** Parse `quote`, `map-pin bold`, `phosphor:quotes duotone`. */
56
+ export function parseIconRef(value: string, where: string): IconRef {
57
+ const parts = value.trim().split(/\s+/);
58
+ if (parts.length === 0 || parts.length > 2 || !parts[0]) {
59
+ throw new Error(`${where}: invalid icon reference "${value}" — expected "ID [WEIGHT]" or "phosphor:ID [WEIGHT]"`);
60
+ }
61
+ let id = parts[0];
62
+ let library: IconRef['library'];
63
+ if (id.startsWith('phosphor:')) {
64
+ library = 'phosphor';
65
+ id = id.slice(9);
66
+ }
67
+ if (!ICON_ID.test(id)) {
68
+ throw new Error(`${where}: invalid icon id "${id}" — use lowercase kebab-case (e.g. "map-pin")`);
69
+ }
70
+ const ref: IconRef = { id };
71
+ if (library) ref.library = library;
72
+ if (parts[1]) ref.weight = parseWeight(parts[1], where);
73
+ return ref;
74
+ }
75
+
76
+ /**
77
+ * Parse one entry of the `icons` block (the part after `ID:`):
78
+ * font $display U+201C glyph by codepoint
79
+ * font $display icon.pin glyph by name
80
+ * font U+201C glyph in the default (first) font
81
+ * icons/map.svg brand SVG
82
+ * phosphor wave light Phosphor icon adopted into the set
83
+ * Font names are checked later, once all font blocks are known.
84
+ */
85
+ export function parseIconSource(value: string, where: string): IconSource {
86
+ const parts = value.trim().split(/\s+/);
87
+ if (parts[0] === 'font') {
88
+ const rest = parts.slice(1);
89
+ if (rest.length === 0 || rest.length > 2) {
90
+ throw new Error(`${where}: font icon must be "font [$FONT] U+XXXX" or "font [$FONT] GLYPHNAME"`);
91
+ }
92
+ let font = '';
93
+ if (rest.length === 2) {
94
+ if (!rest[0].startsWith('$')) throw new Error(`${where}: font reference must start with "$" (e.g. "$display"), got "${rest[0]}"`);
95
+ font = rest[0].slice(1);
96
+ }
97
+ const glyphRef = rest[rest.length - 1];
98
+ const cp = glyphRef.match(/^U\+([0-9A-Fa-f]{4,6})$/);
99
+ if (cp) return { kind: 'font', font, codepoint: parseInt(cp[1], 16) };
100
+ if (/^U\+/i.test(glyphRef)) throw new Error(`${where}: invalid codepoint "${glyphRef}" — expected U+ and 4–6 hex digits`);
101
+ return { kind: 'font', font, glyph: glyphRef };
102
+ }
103
+ if (parts[0] === 'phosphor') {
104
+ if (parts.length < 2 || parts.length > 3) throw new Error(`${where}: phosphor icon must be "phosphor NAME [WEIGHT]"`);
105
+ if (!ICON_ID.test(parts[1])) throw new Error(`${where}: invalid Phosphor name "${parts[1]}" — use kebab-case (e.g. "map-pin")`);
106
+ const source: IconSource = { kind: 'phosphor', name: phosphorName(parts[1]) };
107
+ if (parts[2]) source.weight = parseWeight(parts[2], where);
108
+ return source;
109
+ }
110
+ if (parts.length === 1 && parts[0].toLowerCase().endsWith('.svg')) {
111
+ return { kind: 'svg', src: parts[0] };
112
+ }
113
+ throw new Error(`${where}: invalid icon source "${value}" — expected "font …", "phosphor …" or a path to an .svg file`);
114
+ }
115
+
116
+ /** Parse the icon id at the start of an `icons` block line. */
117
+ export function isIconId(id: string): boolean {
118
+ return ICON_ID.test(id);
119
+ }
120
+
121
+ /**
122
+ * Resolve a reference against a brand's icon set.
123
+ * Brand set first, then Phosphor. Whether the Phosphor name exists is up to the consumer.
124
+ */
125
+ export function resolveIconRef(ref: IconRef, icons: ParsedIconDef[] | undefined): ResolvedIcon {
126
+ if (!ref.library) {
127
+ const icon = icons?.find(i => i.id === ref.id);
128
+ if (icon) return { kind: 'brand', icon };
129
+ }
130
+ const resolved: ResolvedIcon = { kind: 'phosphor', name: phosphorName(ref.id) };
131
+ if (ref.weight) resolved.weight = ref.weight;
132
+ return resolved;
133
+ }
134
+
135
+ /** Split an optional `[id]` prefix off a list item: "[pin] Hafen Burgstaaken". */
136
+ export function parseListItem(item: string): { icon?: IconRef; text: string } {
137
+ const m = item.match(/^\[([^\]]+)\]\s*(.*)$/s);
138
+ if (!m) return { text: item };
139
+ return { icon: parseIconRef(m[1], `list item "${item}"`), text: m[2] };
140
+ }
package/parser/index.ts CHANGED
@@ -23,7 +23,10 @@ export {
23
23
  DEFAULT_COLOR_RULES,
24
24
  } from './colorSystem';
25
25
  export type { ReachConfig } from './colorSystem';
26
- export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign } from './brandParser';
26
+ export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape } from './brandParser';
27
+
28
+ export { parseIconRef, parseIconSource, resolveIconRef, parseListItem, phosphorName, PHOSPHOR_WEIGHTS } from './icons';
29
+ export type { IconRef, IconSource, ParsedIconDef, ResolvedIcon, PhosphorWeight } from './icons';
27
30
  export type {
28
31
  ColorMode,
29
32
  PaletteStrategy,
@@ -54,7 +57,7 @@ export { parseFormatFile } from './formatParser';
54
57
  export type { ParsedFormat } from './formatParser';
55
58
 
56
59
  export { parsePurposeFile } from './purposeParser';
57
- export type { ParsedPurpose, ParsedPurposeSlot, Density } from './purposeParser';
60
+ export type { ParsedPurpose, ParsedPurposeSlot, Density, ListMarker } from './purposeParser';
58
61
 
59
62
  export { parseCompositionFile } from './compositionParser';
60
63
  export type {
@@ -6,10 +6,13 @@
6
6
  */
7
7
 
8
8
  import type { SlotId } from './types';
9
+ import { parseIconRef, parseListItem } from './icons';
10
+ import type { IconRef } from './icons';
9
11
 
10
12
  // ── Parsed types ──────────────────────────────────────────────────────────────
11
13
 
12
14
  export type Density = 'light' | 'medium' | 'full';
15
+ export type ListMarker = 'none' | 'counter' | 'icon';
13
16
 
14
17
  export interface ParsedPurpose {
15
18
  id: string;
@@ -37,10 +40,8 @@ export interface ParsedPurpose {
37
40
  camera?: boolean;
38
41
  /** When false, photo/video analysis skips this purpose (no AI text generation). Default: true. */
39
42
  analyze?: boolean;
40
- /** Default icon (Phosphor PascalCase name, e.g. "ArrowRight"). */
41
- defaultIcon?: string;
42
- /** Default icon weight. */
43
- defaultIconWeight?: string;
43
+ /** Default icon for the icon slot. Resolves in the brand set first, then Phosphor (see resolveIconRef). */
44
+ defaultIcon?: IconRef;
44
45
  /** Optional prompt text shown in the empty background placeholder. Default: "Select a background". */
45
46
  bgPrompt?: string;
46
47
  }
@@ -57,11 +58,13 @@ export interface ParsedPurposeSlot {
57
58
  slotType?: 'text' | 'list';
58
59
  /** Max number of items (list slots only). */
59
60
  maxItems?: number;
60
- /** Whether per-item icons are supported (list slots only). */
61
- icons?: boolean;
62
- /** Icon-only list — icons fill available space, text is suppressed. */
61
+ /** List item marker (list slots only). Default: 'none'. */
62
+ marker?: ListMarker;
63
+ /** Default icon for `marker: icon` items without an `[id]` prefix (list slots only). */
64
+ defaultIcon?: IconRef;
65
+ /** Icon-only list — icons fill available space, text is suppressed. Implies `marker: icon`. */
63
66
  iconOnly?: boolean;
64
- /** Space between icon and text, in em (list slots only). Default: 0.4 */
67
+ /** Space between marker and text, in em (list slots only). Default: 0.4 */
65
68
  iconGap?: number;
66
69
  /** Gap between list items in em. Default: 0.5 */
67
70
  itemGap?: number;
@@ -93,7 +96,8 @@ export interface ParsedPurposeSlot {
93
96
  autofitMin: number;
94
97
  autofitMax: number;
95
98
  textAlign: 'left' | 'center' | 'right' | 'justify';
96
- hyphenate: boolean;
99
+ /** true = hyphenate freely (body text); 'fit' = only when a word doesn't fit at autofit-min. */
100
+ hyphenate: boolean | 'fit';
97
101
  }>;
98
102
  }
99
103
 
@@ -129,8 +133,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
129
133
  let defaultVideo: string | undefined;
130
134
  let camera: boolean | undefined;
131
135
  let analyze: boolean | undefined;
132
- let defaultIcon: string | undefined;
133
- let defaultIconWeight: string | undefined;
136
+ let defaultIcon: IconRef | undefined;
134
137
  let bgPrompt: string | undefined;
135
138
  const slots: ParsedPurposeSlot[] = [];
136
139
  // For backward compat: track first # line text for fallback id
@@ -152,6 +155,19 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
152
155
  if (!s.label) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing label`);
153
156
  if ((!s.samples || s.samples.length === 0) && !s.iconOnly && !s.value) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing samples or value`);
154
157
  if (!s.maxLength) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing maxLength`);
158
+ if (s.iconOnly) {
159
+ if (s.marker && s.marker !== 'icon') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": iconOnly needs "marker: icon", got "${s.marker}"`);
160
+ s.marker = 'icon';
161
+ }
162
+ if (s.defaultIcon && s.marker !== 'icon') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": defaultIcon needs "marker: icon"`);
163
+ if (s.slotType === 'list') {
164
+ for (const sample of s.samples ?? []) {
165
+ for (const item of sample.split('\n')) {
166
+ const { icon } = parseListItem(item);
167
+ if (icon && s.marker !== 'icon') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": sample item "${item}" names an icon, but the list has no "marker: icon"`);
168
+ }
169
+ }
170
+ }
155
171
  const t = s.typography as ParsedPurposeSlot['typography'];
156
172
  if (!s.typographyRef) {
157
173
  // Legacy direct font: path — all fields required
@@ -315,23 +331,16 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
315
331
  continue;
316
332
  }
317
333
 
318
- // Default icon: "defaultIcon: warning-circle" or "defaultIcon: warning-circle duotone"
319
- // Accepts kebab-case (natural for file format) and converts to PascalCase for Phosphor lookup.
320
- if (line.startsWith('defaultIcon:')) {
321
- const parts = line.slice(12).trim().split(/\s+/);
322
- // Convert kebab-case to PascalCase: "warning-circle" → "WarningCircle"
323
- defaultIcon = parts[0].split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
324
- if (parts[1]) defaultIconWeight = parts[1];
334
+ // Default icon: "defaultIcon: quote", "defaultIcon: phosphor:warning-circle duotone"
335
+ if (line.startsWith('defaultIcon:') && !raw.startsWith(' ')) {
336
+ defaultIcon = parseIconRef(line.slice(12), `${fileName}:${lineNum}`);
325
337
  continue;
326
338
  }
327
339
 
328
340
  // Backward compat: "icon: warning-circle" (deprecated — use defaultIcon:)
329
- if (line.startsWith('icon:')) {
341
+ if (line.startsWith('icon:') && !raw.startsWith(' ')) {
330
342
  console.warn(`${fileName}:${lineNum}: deprecated "icon:" — use "defaultIcon:" instead`);
331
- const parts = line.slice(5).trim().split(/\s+/);
332
- // Convert kebab-case to PascalCase: "warning-circle" → "WarningCircle"
333
- defaultIcon = parts[0].split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
334
- if (parts[1]) defaultIconWeight = parts[1];
343
+ defaultIcon = parseIconRef(line.slice(5), `${fileName}:${lineNum}`);
335
344
  continue;
336
345
  }
337
346
 
@@ -411,6 +420,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
411
420
  if (prop === 'uppercase: false') { currentSlot.typographyOverrides.uppercase = false; continue; }
412
421
  if (prop === 'hyphenate' || prop === 'hyphenate: true') { currentSlot.typographyOverrides.hyphenate = true; continue; }
413
422
  if (prop === 'hyphenate: false') { currentSlot.typographyOverrides.hyphenate = false; continue; }
423
+ if (prop === 'hyphenate: fit') { currentSlot.typographyOverrides.hyphenate = 'fit'; continue; }
414
424
  if (prop === 'autofit: none' || prop === 'autofit:none') {
415
425
  currentSlot.typographyOverrides.autofitMin = -1;
416
426
  currentSlot.typographyOverrides.autofitMax = -1;
@@ -462,9 +472,27 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
462
472
  currentSlot.samples = raw.split('|').map(s => s.trim().replace(/\\n/g, '\n'));
463
473
  continue;
464
474
  }
465
- if (prop === 'icons: true' || prop === 'icons:true') { currentSlot.icons = true; continue; }
466
- if (prop === 'icons: false' || prop === 'icons:false') { currentSlot.icons = false; continue; }
467
- if (prop === 'iconOnly: true' || prop === 'iconOnly:true') { currentSlot.iconOnly = true; currentSlot.icons = true; continue; }
475
+ if (prop.startsWith('marker:')) {
476
+ const m = prop.slice(7).trim();
477
+ if (m !== 'none' && m !== 'counter' && m !== 'icon') throw new Error(`${fileName}:${lineNum}: invalid marker "${m}" — must be "none", "counter" or "icon"`);
478
+ currentSlot.marker = m;
479
+ continue;
480
+ }
481
+ if (prop.startsWith('defaultIcon:')) {
482
+ currentSlot.defaultIcon = parseIconRef(prop.slice(12), `${fileName}:${lineNum}`);
483
+ continue;
484
+ }
485
+ if (prop === 'icons: true' || prop === 'icons:true') {
486
+ console.warn(`${fileName}:${lineNum}: deprecated "icons: true" — use "marker: counter" or "marker: icon"`);
487
+ currentSlot.marker = currentSlot.marker ?? 'counter';
488
+ continue;
489
+ }
490
+ if (prop === 'icons: false' || prop === 'icons:false') {
491
+ console.warn(`${fileName}:${lineNum}: deprecated "icons: false" — use "marker: none"`);
492
+ currentSlot.marker = currentSlot.marker ?? 'none';
493
+ continue;
494
+ }
495
+ if (prop === 'iconOnly: true' || prop === 'iconOnly:true') { currentSlot.iconOnly = true; continue; }
468
496
  if (prop.startsWith('iconGap:')) {
469
497
  const n = parseFloat(prop.slice(8).trim());
470
498
  if (isNaN(n) || n < 0) throw new Error(`${fileName}:${lineNum}: invalid iconGap "${prop.slice(8).trim()}"`);
@@ -624,5 +652,5 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
624
652
  context = contextLines.join('\n').trim();
625
653
  }
626
654
 
627
- return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, analyze, defaultIcon, defaultIconWeight, bgPrompt };
655
+ return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, analyze, defaultIcon, bgPrompt };
628
656
  }
@@ -28,6 +28,9 @@
28
28
  { "include": "#color-group-entry" },
29
29
  { "include": "#color-group-reference" },
30
30
  { "include": "#color-group-entry-property" },
31
+ { "include": "#icons-counter-header" },
32
+ { "include": "#icon-entry" },
33
+ { "include": "#counter-value" },
31
34
  { "include": "#theme-header" },
32
35
  { "include": "#brand-color-entry" },
33
36
  { "include": "#brand-color-print-sub" },
@@ -384,6 +387,39 @@
384
387
  "14": { "name": "constant.language.boolean.brand-intent-brand" }
385
388
  }
386
389
  },
390
+ "icons-counter-header": {
391
+ "comment": "Bare `icons` / `counter` block headers.",
392
+ "match": "^(icons|counter)\\s*$",
393
+ "captures": {
394
+ "1": { "name": "keyword.control.brand-intent-brand" }
395
+ }
396
+ },
397
+ "icon-entry": {
398
+ "comment": "icons entry: ID: font [$FONT] U+XXXX|GLYPH / ID: phosphor NAME [WEIGHT] / ID: PATH.svg",
399
+ "match": "^(\\s{2,})(?!(?:src|lottie|gltf):)([a-z][a-z0-9-]*)(:\\s*)(?:(font)(?:\\s+(\\$[\\w-]+))?\\s+(\\S+)|(phosphor)\\s+([a-z][a-z0-9-]*)(?:\\s+(thin|light|regular|bold|fill|duotone))?|(\\S+\\.svg))\\s*(#.*)?$",
400
+ "captures": {
401
+ "2": { "name": "entity.name.function.brand-intent-brand" },
402
+ "3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
403
+ "4": { "name": "keyword.other.brand-intent-brand" },
404
+ "5": { "name": "variable.other.brand-intent-brand" },
405
+ "6": { "name": "constant.character.brand-intent-brand" },
406
+ "7": { "name": "keyword.other.brand-intent-brand" },
407
+ "8": { "name": "string.unquoted.brand-intent-brand" },
408
+ "9": { "name": "variable.parameter.brand-intent-brand" },
409
+ "10": { "name": "string.unquoted.brand-intent-brand" },
410
+ "11": { "name": "comment.line.number-sign.brand-intent-brand" }
411
+ }
412
+ },
413
+ "counter-value": {
414
+ "comment": "counter block properties (typography: is covered by typography-value)",
415
+ "match": "^(\\s{2,})(format|shape|size|padding)(:\\s*)(\\S+)\\s*(#.*)?$",
416
+ "captures": {
417
+ "2": { "name": "support.type.property-name.brand-intent-brand" },
418
+ "3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
419
+ "4": { "name": "constant.numeric.brand-intent-brand" },
420
+ "5": { "name": "comment.line.number-sign.brand-intent-brand" }
421
+ }
422
+ },
387
423
  "badge-header": {
388
424
  "comment": "Bare `label:` or `cta:` line that introduces a badge style block.",
389
425
  "match": "^(label|cta):\\s*$",
@@ -409,7 +445,7 @@
409
445
  },
410
446
  "keyword-partial": {
411
447
  "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
412
- "match": "^(id:|name:|language:|locale:|domain:|app-icon:|favicon:|og-image:|tokens:|figma-file-key:|figma-collection:|tokens-file:|spacing:|label:|cta:|color-mode:|color-strategy:|color-adherence:|brand-colors|state-colors|color-bounds|color-rules|color-families|theme|divider|font|typography|logo|photo|video|illustration)(?=\\s|$)",
448
+ "match": "^(id:|name:|language:|locale:|domain:|app-icon:|favicon:|og-image:|tokens:|figma-file-key:|figma-collection:|tokens-file:|spacing:|label:|cta:|color-mode:|color-strategy:|color-adherence:|brand-colors|state-colors|color-bounds|color-rules|color-families|theme|divider|font|typography|logo|photo|video|illustration|icons|counter)(?=\\s|$)",
413
449
  "captures": {
414
450
  "1": { "name": "keyword.control.brand-intent-brand" }
415
451
  }
@@ -22,6 +22,7 @@
22
22
  { "include": "#override-value" },
23
23
  { "include": "#list-property" },
24
24
  { "include": "#sample-item" },
25
+ { "include": "#sample-icon-continuation" },
25
26
  { "include": "#property" },
26
27
  { "include": "#keyword-partial" }
27
28
  ],
@@ -59,7 +60,7 @@
59
60
  }
60
61
  },
61
62
  "default-icon": {
62
- "match": "^(defaultIcon:)\\s+([\\w-]+)(?:\\s+(thin|light|regular|bold|fill|duotone))?",
63
+ "match": "^\\s*(defaultIcon:)\\s+((?:phosphor:)?[a-z][a-z0-9-]*)(?:\\s+(thin|light|regular|bold|fill|duotone))?",
63
64
  "captures": {
64
65
  "1": { "name": "keyword.control.brand-intent-purpose" },
65
66
  "2": { "name": "entity.name.function.brand-intent-purpose" },
@@ -164,7 +165,15 @@
164
165
  }
165
166
  },
166
167
  {
167
- "match": "^\\s{4,}(italic|uppercase|hyphenate)(?:(:)\\s+(true|false))?\\s*$",
168
+ "match": "^\\s{4,}(italic|uppercase)(?:(:)\\s+(true|false))?\\s*$",
169
+ "captures": {
170
+ "1": { "name": "support.type.property-name.brand-intent-purpose" },
171
+ "2": { "name": "punctuation.separator.brand-intent-purpose" },
172
+ "3": { "name": "constant.language.brand-intent-purpose" }
173
+ }
174
+ },
175
+ {
176
+ "match": "^\\s{4,}(hyphenate)(?:(:)\\s+(true|false|fit))?\\s*$",
168
177
  "captures": {
169
178
  "1": { "name": "support.type.property-name.brand-intent-purpose" },
170
179
  "2": { "name": "punctuation.separator.brand-intent-purpose" },
@@ -207,7 +216,7 @@
207
216
  },
208
217
  "list-property": {
209
218
  "comment": "4-space indented properties under type: list blocks",
210
- "match": "^\\s{4,}(icons|iconOnly|iconGap|itemGap|maxItems|direction)(:)\\s+(.*)",
219
+ "match": "^\\s{4,}(marker|icons|iconOnly|iconGap|itemGap|maxItems|direction)(:)\\s+(.*)",
211
220
  "captures": {
212
221
  "1": { "name": "support.type.property-name.brand-intent-purpose" },
213
222
  "2": { "name": "punctuation.separator.brand-intent-purpose" },
@@ -216,9 +225,17 @@
216
225
  },
217
226
  "sample-item": {
218
227
  "comment": "Multi-line sample entries -- dim only the dash, leave text unstyled",
219
- "match": "^(\\s{4,})(-)(?=\\s)",
228
+ "match": "^(\\s{4,})(-)(?=\\s)(?:\\s+(\\[[^\\]]+\\]))?",
229
+ "captures": {
230
+ "2": { "name": "comment.line.dash.brand-intent-purpose" },
231
+ "3": { "name": "entity.name.function.brand-intent-purpose" }
232
+ }
233
+ },
234
+ "sample-icon-continuation": {
235
+ "comment": "[ID] icon prefix on list item continuation lines",
236
+ "match": "^(\\s{6,})(\\[[^\\]]+\\])",
220
237
  "captures": {
221
- "2": { "name": "comment.line.dash.brand-intent-purpose" }
238
+ "2": { "name": "entity.name.function.brand-intent-purpose" }
222
239
  }
223
240
  },
224
241
  "keyword-partial": {