brand-intent 0.14.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.14.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",
@@ -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). */
@@ -132,6 +136,8 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
132
136
  let defaultPhoto: string | undefined;
133
137
  let defaultVideo: string | undefined;
134
138
  let camera: boolean | undefined;
139
+ let cameraFacing: 'front' | 'rear' | undefined;
140
+ let framing: string | undefined;
135
141
  let analyze: boolean | undefined;
136
142
  let defaultIcon: IconRef | undefined;
137
143
  let bgPrompt: string | undefined;
@@ -315,7 +321,15 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
315
321
 
316
322
  // Camera control
317
323
  if (line.startsWith('camera:')) {
318
- 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;
319
333
  continue;
320
334
  }
321
335
 
@@ -652,5 +666,5 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
652
666
  context = contextLines.join('\n').trim();
653
667
  }
654
668
 
655
- 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 };
656
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": {
@@ -242,7 +250,7 @@
242
250
  "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
243
251
  "patterns": [
244
252
  {
245
- "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|$)",
246
254
  "captures": {
247
255
  "1": { "name": "keyword.control.brand-intent-purpose" }
248
256
  }