brand-intent 0.13.0 → 0.15.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.13.0",
3
+ "version": "0.15.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/icons.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"
@@ -112,6 +112,27 @@ export interface ParsedBrand {
112
112
  icons?: ParsedIconDef[];
113
113
  /** List counter component (`counter` block). */
114
114
  counter?: ParsedCounterDef;
115
+ /** Photo framing modules (`framing NAME` blocks), in declaration order. Absent → no camera guides. */
116
+ framings?: ParsedFramingDef[];
117
+ /** Camera defaults (`camera` block). Absent → rear camera, no grid. */
118
+ camera?: ParsedCameraDef;
119
+ }
120
+
121
+ export interface ParsedCameraDef {
122
+ /** Which camera opens first. */
123
+ facing: 'front' | 'rear';
124
+ /** Viewfinder grid as rows × columns of cells. Absent → no grid. */
125
+ grid?: { rows: number; cols: number };
126
+ }
127
+
128
+ export interface ParsedFramingDef {
129
+ id: string;
130
+ /** Display name. Defaults to the id. */
131
+ label: string;
132
+ /** Horizon position as % of image height, measured from the top. null → no horizon (close-ups). */
133
+ horizon: number | null;
134
+ /** The photographic task, one line. */
135
+ description?: string;
115
136
  }
116
137
 
117
138
  export type CounterFormat = '1' | '01' | '1.' | '(1)' | 'i';
@@ -351,7 +372,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
351
372
  tokensSource: 'figma',
352
373
  };
353
374
 
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';
375
+ 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';
355
376
  let activeBlock: ActiveBlock = 'none';
356
377
  let currentThemeName = '';
357
378
  let currentDividerName = '';
@@ -398,6 +419,9 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
398
419
  let iconsDeclared = false;
399
420
  const counterRaw: Record<string, { value: string; line: number }> = {};
400
421
  let counterDeclared = false;
422
+ const cameraRaw: Record<string, { value: string; line: number }> = {};
423
+ let cameraDeclared = false;
424
+ const framingsRaw: Array<{ id: string; line: number; props: Record<string, { value: string; line: number }> }> = [];
401
425
 
402
426
  for (let i = 0; i < lines.length; i++) {
403
427
  const raw = lines[i];
@@ -767,6 +791,24 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
767
791
  counterRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
768
792
  continue;
769
793
  }
794
+ if (activeBlock === 'camera') {
795
+ const trimmed = raw.trim();
796
+ if (trimmed.startsWith('#')) continue;
797
+ const commentIdx = trimmed.search(/\s+#/);
798
+ const entry = commentIdx !== -1 ? trimmed.slice(0, commentIdx) : trimmed;
799
+ const colonIdx = entry.indexOf(':');
800
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: camera property must be "KEY: VALUE", got "${trimmed}"`);
801
+ cameraRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
802
+ continue;
803
+ }
804
+ if (activeBlock === 'framing') {
805
+ const trimmed = raw.trim();
806
+ if (trimmed.startsWith('#')) continue;
807
+ const colonIdx = trimmed.indexOf(':');
808
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: framing property must be "KEY: VALUE", got "${trimmed}"`);
809
+ framingsRaw[framingsRaw.length - 1].props[trimmed.slice(0, colonIdx).trim()] = { value: trimmed.slice(colonIdx + 1).trim(), line: lineNum };
810
+ continue;
811
+ }
770
812
  // Indented line with no active block — ignore
771
813
  continue;
772
814
  }
@@ -831,6 +873,24 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
831
873
  continue;
832
874
  }
833
875
 
876
+ // camera — camera defaults (facing, viewfinder grid)
877
+ if (raw.trim() === 'camera') {
878
+ if (cameraDeclared) throw new Error(`${fileName}:${lineNum}: duplicate camera block`);
879
+ cameraDeclared = true;
880
+ activeBlock = 'camera';
881
+ continue;
882
+ }
883
+
884
+ // framing <id> — photo framing module (horizon position)
885
+ const framingMatch = raw.match(/^framing\s+(\S+)\s*$/);
886
+ if (framingMatch) {
887
+ const id = framingMatch[1];
888
+ if (framingsRaw.some(f => f.id === id)) throw new Error(`${fileName}:${lineNum}: duplicate framing "${id}"`);
889
+ framingsRaw.push({ id, line: lineNum, props: {} });
890
+ activeBlock = 'framing';
891
+ continue;
892
+ }
893
+
834
894
  // theme ThemeName — starts a theme block
835
895
  const themeMatch = raw.match(/^theme\s+(\S+)/);
836
896
  if (themeMatch) {
@@ -1473,6 +1533,52 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1473
1533
  result.counter = counter;
1474
1534
  }
1475
1535
 
1536
+ // Finalise camera defaults
1537
+ if (cameraDeclared) {
1538
+ const known = new Set(['facing', 'grid']);
1539
+ for (const [key, { line }] of Object.entries(cameraRaw)) {
1540
+ if (!known.has(key)) throw new Error(`${fileName}:${line}: unknown camera property "${key}"`);
1541
+ }
1542
+ const facing = cameraRaw['facing']?.value ?? 'rear';
1543
+ if (facing !== 'front' && facing !== 'rear') {
1544
+ throw new Error(`${fileName}:${cameraRaw['facing'].line}: camera facing must be front | rear, got "${facing}"`);
1545
+ }
1546
+ const camera: ParsedCameraDef = { facing };
1547
+ const g = cameraRaw['grid'];
1548
+ if (g) {
1549
+ // "3" → 3 × 3, "3 4" → 3 rows × 4 columns
1550
+ const nums = g.value.split(/\s+/).map(Number);
1551
+ const [rows, cols = rows] = nums;
1552
+ if (nums.length > 2 || ![rows, cols].every(n => Number.isInteger(n) && n >= 2 && n <= 12)) {
1553
+ throw new Error(`${fileName}:${g.line}: camera grid must be "N" or "ROWS COLS" (2–12), got "${g.value}"`);
1554
+ }
1555
+ camera.grid = { rows, cols };
1556
+ }
1557
+ result.camera = camera;
1558
+ }
1559
+
1560
+ // Finalise framing modules
1561
+ if (framingsRaw.length > 0) {
1562
+ const known = new Set(['label', 'horizon', 'description']);
1563
+ result.framings = framingsRaw.map(({ id, line, props }) => {
1564
+ for (const [key, { line: l }] of Object.entries(props)) {
1565
+ if (!known.has(key)) throw new Error(`${fileName}:${l}: unknown framing property "${key}"`);
1566
+ }
1567
+ const h = props['horizon'];
1568
+ if (!h) throw new Error(`${fileName}:${line}: framing "${id}" needs "horizon: N%" or "horizon: none"`);
1569
+ let horizon: number | null = null;
1570
+ if (h.value !== 'none') {
1571
+ const m = h.value.match(/^(\d+(?:\.\d+)?)%$/);
1572
+ const n = m ? parseFloat(m[1]) : NaN;
1573
+ if (!(n > 0 && n < 100)) throw new Error(`${fileName}:${h.line}: framing horizon must be a percentage between 0% and 100% or "none", got "${h.value}"`);
1574
+ horizon = n;
1575
+ }
1576
+ const def: ParsedFramingDef = { id, label: props['label']?.value || id, horizon };
1577
+ if (props['description']?.value) def.description = props['description'].value;
1578
+ return def;
1579
+ });
1580
+ }
1581
+
1476
1582
  // Validate required fields
1477
1583
  const required: (keyof ParsedBrand)[] = ['id', 'name', 'language', 'locale'];
1478
1584
  for (const field of required) {
package/parser/index.ts CHANGED
@@ -23,7 +23,7 @@ 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, ParsedCounterDef, CounterFormat, CounterShape } from './brandParser';
26
+ export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape, ParsedFramingDef, ParsedCameraDef } from './brandParser';
27
27
 
28
28
  export { parseIconRef, parseIconSource, resolveIconRef, parseListItem, phosphorName, PHOSPHOR_WEIGHTS } from './icons';
29
29
  export type { IconRef, IconSource, ParsedIconDef, ResolvedIcon, PhosphorWeight } from './icons';
@@ -38,6 +38,10 @@ export interface ParsedPurpose {
38
38
  defaultVideo?: string;
39
39
  /** When false, hides Take Photo / Upload buttons for this purpose. Default: true. */
40
40
  camera?: boolean;
41
+ /** `camera: front | rear` — overrides the brand's camera facing for this purpose. */
42
+ cameraFacing?: 'front' | 'rear';
43
+ /** `framing: <id>` — the brand framing module photos for this purpose are shot in. */
44
+ framing?: string;
41
45
  /** When false, photo/video analysis skips this purpose (no AI text generation). Default: true. */
42
46
  analyze?: boolean;
43
47
  /** Default icon for the icon slot. Resolves in the brand set first, then Phosphor (see resolveIconRef). */
@@ -96,7 +100,8 @@ export interface ParsedPurposeSlot {
96
100
  autofitMin: number;
97
101
  autofitMax: number;
98
102
  textAlign: 'left' | 'center' | 'right' | 'justify';
99
- hyphenate: boolean;
103
+ /** true = hyphenate freely (body text); 'fit' = only when a word doesn't fit at autofit-min. */
104
+ hyphenate: boolean | 'fit';
100
105
  }>;
101
106
  }
102
107
 
@@ -131,6 +136,8 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
131
136
  let defaultPhoto: string | undefined;
132
137
  let defaultVideo: string | undefined;
133
138
  let camera: boolean | undefined;
139
+ let cameraFacing: 'front' | 'rear' | undefined;
140
+ let framing: string | undefined;
134
141
  let analyze: boolean | undefined;
135
142
  let defaultIcon: IconRef | undefined;
136
143
  let bgPrompt: string | undefined;
@@ -314,7 +321,15 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
314
321
 
315
322
  // Camera control
316
323
  if (line.startsWith('camera:')) {
317
- camera = line.slice(7).trim() !== 'false';
324
+ const value = line.slice(7).trim();
325
+ camera = value !== 'false';
326
+ if (value === 'front' || value === 'rear') cameraFacing = value;
327
+ continue;
328
+ }
329
+
330
+ // Framing module: one of the brand's `framing NAME` blocks
331
+ if (line.startsWith('framing:')) {
332
+ framing = line.slice(8).trim() || undefined;
318
333
  continue;
319
334
  }
320
335
 
@@ -419,6 +434,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
419
434
  if (prop === 'uppercase: false') { currentSlot.typographyOverrides.uppercase = false; continue; }
420
435
  if (prop === 'hyphenate' || prop === 'hyphenate: true') { currentSlot.typographyOverrides.hyphenate = true; continue; }
421
436
  if (prop === 'hyphenate: false') { currentSlot.typographyOverrides.hyphenate = false; continue; }
437
+ if (prop === 'hyphenate: fit') { currentSlot.typographyOverrides.hyphenate = 'fit'; continue; }
422
438
  if (prop === 'autofit: none' || prop === 'autofit:none') {
423
439
  currentSlot.typographyOverrides.autofitMin = -1;
424
440
  currentSlot.typographyOverrides.autofitMax = -1;
@@ -650,5 +666,5 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
650
666
  context = contextLines.join('\n').trim();
651
667
  }
652
668
 
653
- return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, analyze, defaultIcon, bgPrompt };
669
+ return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, cameraFacing, framing, analyze, defaultIcon, bgPrompt };
654
670
  }
@@ -31,6 +31,7 @@
31
31
  { "include": "#icons-counter-header" },
32
32
  { "include": "#icon-entry" },
33
33
  { "include": "#counter-value" },
34
+ { "include": "#framing-horizon" },
34
35
  { "include": "#theme-header" },
35
36
  { "include": "#brand-color-entry" },
36
37
  { "include": "#brand-color-print-sub" },
@@ -366,7 +367,7 @@
366
367
  }
367
368
  },
368
369
  "asset-header": {
369
- "match": "^(logo|photo|video|illustration)\\s+(\\S+)",
370
+ "match": "^(logo|photo|video|illustration|framing)\\s+(\\S+)",
370
371
  "captures": {
371
372
  "1": { "name": "keyword.control.brand-intent-brand" },
372
373
  "2": { "name": "entity.name.type.brand-intent-brand" }
@@ -410,6 +411,16 @@
410
411
  "11": { "name": "comment.line.number-sign.brand-intent-brand" }
411
412
  }
412
413
  },
414
+ "framing-horizon": {
415
+ "comment": "framing block horizon: N% | none",
416
+ "match": "^(\\s{2,})(horizon)(:\\s*)(\\d+(?:\\.\\d+)?%|none)\\s*(#.*)?$",
417
+ "captures": {
418
+ "2": { "name": "support.type.property-name.brand-intent-brand" },
419
+ "3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
420
+ "4": { "name": "constant.numeric.brand-intent-brand" },
421
+ "5": { "name": "comment.line.number-sign.brand-intent-brand" }
422
+ }
423
+ },
413
424
  "counter-value": {
414
425
  "comment": "counter block properties (typography: is covered by typography-value)",
415
426
  "match": "^(\\s{2,})(format|shape|size|padding)(:\\s*)(\\S+)\\s*(#.*)?$",
@@ -445,7 +456,7 @@
445
456
  },
446
457
  "keyword-partial": {
447
458
  "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
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|$)",
459
+ "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|framing)(?=\\s|$)",
449
460
  "captures": {
450
461
  "1": { "name": "keyword.control.brand-intent-brand" }
451
462
  }
@@ -14,6 +14,7 @@
14
14
  { "include": "#palette-header" },
15
15
  { "include": "#asset-field" },
16
16
  { "include": "#camera-field" },
17
+ { "include": "#framing-field" },
17
18
  { "include": "#analyze-field" },
18
19
  { "include": "#bg-prompt-field" },
19
20
  { "include": "#context-header" },
@@ -114,12 +115,19 @@
114
115
  }
115
116
  },
116
117
  "camera-field": {
117
- "match": "^(camera:)\\s+(true|false)",
118
+ "match": "^(camera:)\\s+(true|false|front|rear)",
118
119
  "captures": {
119
120
  "1": { "name": "keyword.control.brand-intent-purpose" },
120
121
  "2": { "name": "constant.language.brand-intent-purpose" }
121
122
  }
122
123
  },
124
+ "framing-field": {
125
+ "match": "^(framing:)\\s+([\\w-]+)",
126
+ "captures": {
127
+ "1": { "name": "keyword.control.brand-intent-purpose" },
128
+ "2": { "name": "entity.name.type.brand-intent-purpose" }
129
+ }
130
+ },
123
131
  "analyze-field": {
124
132
  "match": "^(analyze:)\\s+(true|false)",
125
133
  "captures": {
@@ -165,7 +173,15 @@
165
173
  }
166
174
  },
167
175
  {
168
- "match": "^\\s{4,}(italic|uppercase|hyphenate)(?:(:)\\s+(true|false))?\\s*$",
176
+ "match": "^\\s{4,}(italic|uppercase)(?:(:)\\s+(true|false))?\\s*$",
177
+ "captures": {
178
+ "1": { "name": "support.type.property-name.brand-intent-purpose" },
179
+ "2": { "name": "punctuation.separator.brand-intent-purpose" },
180
+ "3": { "name": "constant.language.brand-intent-purpose" }
181
+ }
182
+ },
183
+ {
184
+ "match": "^\\s{4,}(hyphenate)(?:(:)\\s+(true|false|fit))?\\s*$",
169
185
  "captures": {
170
186
  "1": { "name": "support.type.property-name.brand-intent-purpose" },
171
187
  "2": { "name": "punctuation.separator.brand-intent-purpose" },
@@ -234,7 +250,7 @@
234
250
  "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
235
251
  "patterns": [
236
252
  {
237
- "match": "^(id:|name:|compositions:|density:|defaultIcon:|scope|voice|palette:|logo:|photo:|video:|camera:|analyze:|bg-prompt:|context|slot)(?=\\s|$)",
253
+ "match": "^(id:|name:|compositions:|density:|defaultIcon:|scope|voice|palette:|logo:|photo:|video:|camera:|framing:|analyze:|bg-prompt:|context|slot)(?=\\s|$)",
238
254
  "captures": {
239
255
  "1": { "name": "keyword.control.brand-intent-purpose" }
240
256
  }