brand-intent 0.14.0 → 0.16.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.16.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';
@@ -229,6 +250,14 @@ const VALID_STRATEGIES = new Set<PaletteStrategy>(['monochrome', 'duotone', 'tri
229
250
  const VALID_ADHERENCES = new Set<Adherence>(['strict', 'on-brand', 'flexible']);
230
251
  const TONE_CLASSES = new Set<ToneClass>(['light', 'mid', 'dark']);
231
252
  const FAMILY_ROLES = new Set<FamilyRole>(['core', 'accent', 'contrast']);
253
+
254
+ const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
255
+ function isMonthDay(value: string): boolean {
256
+ const m = value.match(/^(\d{2})-(\d{2})$/);
257
+ if (!m) return false;
258
+ const month = Number(m[1]), day = Number(m[2]);
259
+ return month >= 1 && month <= 12 && day >= 1 && day <= DAYS_IN_MONTH[month - 1];
260
+ }
232
261
  const FAMILY_ORDERS = new Set<FamilyOrder>(['cycle', 'sequence']);
233
262
  const FAMILY_BORROWS = new Set<FamilyBorrow>(['previous', 'next', 'both', 'none']);
234
263
  const PAIR_KEYS = ['ground', 'figure', 'text'] as const;
@@ -351,7 +380,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
351
380
  tokensSource: 'figma',
352
381
  };
353
382
 
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';
383
+ 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
384
  let activeBlock: ActiveBlock = 'none';
356
385
  let currentThemeName = '';
357
386
  let currentDividerName = '';
@@ -374,7 +403,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
374
403
  const adherenceOverridesRaw: Record<string, string> = {};
375
404
  const colorRulesRaw: Record<string, string> = {};
376
405
  let colorRulesDeclared = false;
377
- type RawFamily = { name: string; line: number; colors: Array<{ role: string; value: string; line: number }>; pairs: Array<{ ground: string; figure: string; line: number }> };
406
+ type RawFamily = { name: string; line: number; from?: string; colors: Array<{ role: string; value: string; line: number }>; pairs: Array<{ ground: string; figure: string; line: number }> };
378
407
  const familiesProps: Record<string, string> = {};
379
408
  const familiesRaw: RawFamily[] = [];
380
409
  let familiesDeclared = false;
@@ -398,6 +427,9 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
398
427
  let iconsDeclared = false;
399
428
  const counterRaw: Record<string, { value: string; line: number }> = {};
400
429
  let counterDeclared = false;
430
+ const cameraRaw: Record<string, { value: string; line: number }> = {};
431
+ let cameraDeclared = false;
432
+ const framingsRaw: Array<{ id: string; line: number; props: Record<string, { value: string; line: number }> }> = [];
401
433
 
402
434
  for (let i = 0; i < lines.length; i++) {
403
435
  const raw = lines[i];
@@ -629,8 +661,16 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
629
661
  }
630
662
  const colonIdx = trimmed.indexOf(':');
631
663
  const key = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : trimmed;
664
+ if (key === 'from' && colonIdx !== -1) {
665
+ const value = trimmed.slice(colonIdx + 1).trim();
666
+ if (currentFamily.from !== undefined) throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" declares from: twice`);
667
+ if (!isMonthDay(value)) throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" from: must be "MM-DD" (e.g. 12-01), got "${value}"`);
668
+ inPairs = false;
669
+ currentFamily.from = value;
670
+ continue;
671
+ }
632
672
  if (colonIdx === -1 || !FAMILY_ROLES.has(key as FamilyRole)) {
633
- throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected core / accent / contrast / pairs, got "${trimmed}"`);
673
+ throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected from / core / accent / contrast / pairs, got "${trimmed}"`);
634
674
  }
635
675
  inPairs = false;
636
676
  currentFamily.colors.push({ role: key, value: trimmed.slice(colonIdx + 1).trim(), line: lineNum });
@@ -767,6 +807,24 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
767
807
  counterRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
768
808
  continue;
769
809
  }
810
+ if (activeBlock === 'camera') {
811
+ const trimmed = raw.trim();
812
+ if (trimmed.startsWith('#')) continue;
813
+ const commentIdx = trimmed.search(/\s+#/);
814
+ const entry = commentIdx !== -1 ? trimmed.slice(0, commentIdx) : trimmed;
815
+ const colonIdx = entry.indexOf(':');
816
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: camera property must be "KEY: VALUE", got "${trimmed}"`);
817
+ cameraRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
818
+ continue;
819
+ }
820
+ if (activeBlock === 'framing') {
821
+ const trimmed = raw.trim();
822
+ if (trimmed.startsWith('#')) continue;
823
+ const colonIdx = trimmed.indexOf(':');
824
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: framing property must be "KEY: VALUE", got "${trimmed}"`);
825
+ framingsRaw[framingsRaw.length - 1].props[trimmed.slice(0, colonIdx).trim()] = { value: trimmed.slice(colonIdx + 1).trim(), line: lineNum };
826
+ continue;
827
+ }
770
828
  // Indented line with no active block — ignore
771
829
  continue;
772
830
  }
@@ -831,6 +889,24 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
831
889
  continue;
832
890
  }
833
891
 
892
+ // camera — camera defaults (facing, viewfinder grid)
893
+ if (raw.trim() === 'camera') {
894
+ if (cameraDeclared) throw new Error(`${fileName}:${lineNum}: duplicate camera block`);
895
+ cameraDeclared = true;
896
+ activeBlock = 'camera';
897
+ continue;
898
+ }
899
+
900
+ // framing <id> — photo framing module (horizon position)
901
+ const framingMatch = raw.match(/^framing\s+(\S+)\s*$/);
902
+ if (framingMatch) {
903
+ const id = framingMatch[1];
904
+ if (framingsRaw.some(f => f.id === id)) throw new Error(`${fileName}:${lineNum}: duplicate framing "${id}"`);
905
+ framingsRaw.push({ id, line: lineNum, props: {} });
906
+ activeBlock = 'framing';
907
+ continue;
908
+ }
909
+
834
910
  // theme ThemeName — starts a theme block
835
911
  const themeMatch = raw.match(/^theme\s+(\S+)/);
836
912
  if (themeMatch) {
@@ -1090,7 +1166,16 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1090
1166
  }
1091
1167
  brandColors[c.key] = c.hex;
1092
1168
  }
1093
- families.push({ name: raw.name, colors, pairs: [] });
1169
+ families.push({ name: raw.name, ...(raw.from ? { from: raw.from } : {}), colors, pairs: [] });
1170
+ }
1171
+ const dated = familiesRaw.filter(f => f.from !== undefined);
1172
+ if (dated.length > 0 && dated.length < familiesRaw.length) {
1173
+ const missing = familiesRaw.filter(f => f.from === undefined).map(f => f.name).join(', ');
1174
+ throw new Error(`${fileName}: color-families: from: must be declared on every family or on none (missing: ${missing})`);
1175
+ }
1176
+ for (const f of dated) {
1177
+ const twin = dated.find(o => o !== f && o.from === f.from);
1178
+ if (twin) throw new Error(`${fileName}:${f.line}: families "${twin.name}" and "${f.name}" share from: ${f.from}`);
1094
1179
  }
1095
1180
  colorFamilies = { order, borrow, distance, shared, families };
1096
1181
  if (borrow !== 'none' && reachCoversAll(families.length, { order, borrow, distance })) {
@@ -1473,6 +1558,52 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1473
1558
  result.counter = counter;
1474
1559
  }
1475
1560
 
1561
+ // Finalise camera defaults
1562
+ if (cameraDeclared) {
1563
+ const known = new Set(['facing', 'grid']);
1564
+ for (const [key, { line }] of Object.entries(cameraRaw)) {
1565
+ if (!known.has(key)) throw new Error(`${fileName}:${line}: unknown camera property "${key}"`);
1566
+ }
1567
+ const facing = cameraRaw['facing']?.value ?? 'rear';
1568
+ if (facing !== 'front' && facing !== 'rear') {
1569
+ throw new Error(`${fileName}:${cameraRaw['facing'].line}: camera facing must be front | rear, got "${facing}"`);
1570
+ }
1571
+ const camera: ParsedCameraDef = { facing };
1572
+ const g = cameraRaw['grid'];
1573
+ if (g) {
1574
+ // "3" → 3 × 3, "3 4" → 3 rows × 4 columns
1575
+ const nums = g.value.split(/\s+/).map(Number);
1576
+ const [rows, cols = rows] = nums;
1577
+ if (nums.length > 2 || ![rows, cols].every(n => Number.isInteger(n) && n >= 2 && n <= 12)) {
1578
+ throw new Error(`${fileName}:${g.line}: camera grid must be "N" or "ROWS COLS" (2–12), got "${g.value}"`);
1579
+ }
1580
+ camera.grid = { rows, cols };
1581
+ }
1582
+ result.camera = camera;
1583
+ }
1584
+
1585
+ // Finalise framing modules
1586
+ if (framingsRaw.length > 0) {
1587
+ const known = new Set(['label', 'horizon', 'description']);
1588
+ result.framings = framingsRaw.map(({ id, line, props }) => {
1589
+ for (const [key, { line: l }] of Object.entries(props)) {
1590
+ if (!known.has(key)) throw new Error(`${fileName}:${l}: unknown framing property "${key}"`);
1591
+ }
1592
+ const h = props['horizon'];
1593
+ if (!h) throw new Error(`${fileName}:${line}: framing "${id}" needs "horizon: N%" or "horizon: none"`);
1594
+ let horizon: number | null = null;
1595
+ if (h.value !== 'none') {
1596
+ const m = h.value.match(/^(\d+(?:\.\d+)?)%$/);
1597
+ const n = m ? parseFloat(m[1]) : NaN;
1598
+ 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}"`);
1599
+ horizon = n;
1600
+ }
1601
+ const def: ParsedFramingDef = { id, label: props['label']?.value || id, horizon };
1602
+ if (props['description']?.value) def.description = props['description'].value;
1603
+ return def;
1604
+ });
1605
+ }
1606
+
1476
1607
  // Validate required fields
1477
1608
  const required: (keyof ParsedBrand)[] = ['id', 'name', 'language', 'locale'];
1478
1609
  for (const field of required) {
@@ -193,6 +193,23 @@ export function colorOrigin(
193
193
  return null;
194
194
  }
195
195
 
196
+ /**
197
+ * Family in season on `date`: the one with the latest `from:` on or before that
198
+ * month/day, wrapping to the year's last start before the first one. Uses the
199
+ * date's local calendar day. -1 when the families declare no dates.
200
+ */
201
+ export function familyIndexForDate(families: Pick<ColorFamily, 'from'>[], date: Date): number {
202
+ if (families.length === 0 || families.some(f => !f.from)) return -1;
203
+ const today = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
204
+ let best = -1;
205
+ let latest = -1;
206
+ families.forEach((f, i) => {
207
+ if (f.from! <= today && (best === -1 || f.from! > families[best].from!)) best = i;
208
+ if (latest === -1 || f.from! > families[latest].from!) latest = i;
209
+ });
210
+ return best !== -1 ? best : latest;
211
+ }
212
+
196
213
  /** Name of the family that declares `hex` (first match), for error messages. */
197
214
  export function owningFamily(hex: string, families: ColorFamily[]): string | undefined {
198
215
  return families.find(f => f.colors.some(c => c.hex === hex))?.name;
package/parser/index.ts CHANGED
@@ -20,10 +20,11 @@ export {
20
20
  familyTextCandidates,
21
21
  checkFamilyPair,
22
22
  enumerateFamilyPairs,
23
+ familyIndexForDate,
23
24
  DEFAULT_COLOR_RULES,
24
25
  } from './colorSystem';
25
26
  export type { ReachConfig } from './colorSystem';
26
- export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape } from './brandParser';
27
+ export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape, ParsedFramingDef, ParsedCameraDef } from './brandParser';
27
28
 
28
29
  export { parseIconRef, parseIconSource, resolveIconRef, parseListItem, phosphorName, PHOSPHOR_WEIGHTS } from './icons';
29
30
  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
  }
package/parser/types.ts CHANGED
@@ -156,6 +156,8 @@ export interface FamilyPair extends PairCheck {
156
156
 
157
157
  export interface ColorFamily {
158
158
  name: string;
159
+ /** Start date `MM-DD` (`from:`). Declared on every family or on none. */
160
+ from?: string;
159
161
  colors: FamilyColor[];
160
162
  pairs: FamilyPair[];
161
163
  }
@@ -15,6 +15,7 @@
15
15
  { "include": "#color-rules-entry" },
16
16
  { "include": "#color-families-property" },
17
17
  { "include": "#family-header" },
18
+ { "include": "#family-from" },
18
19
  { "include": "#family-role" },
19
20
  { "include": "#family-pairs-keyword" },
20
21
  { "include": "#color-mode-enum" },
@@ -31,6 +32,7 @@
31
32
  { "include": "#icons-counter-header" },
32
33
  { "include": "#icon-entry" },
33
34
  { "include": "#counter-value" },
35
+ { "include": "#framing-horizon" },
34
36
  { "include": "#theme-header" },
35
37
  { "include": "#brand-color-entry" },
36
38
  { "include": "#brand-color-print-sub" },
@@ -142,6 +144,15 @@
142
144
  "5": { "name": "string.unquoted.brand-intent-brand" }
143
145
  }
144
146
  },
147
+ "family-from": {
148
+ "comment": "Family start date: from: MM-DD",
149
+ "match": "^(\\s{4,})(from)(:\\s*)(\\d{2}-\\d{2})\\s*$",
150
+ "captures": {
151
+ "2": { "name": "support.type.property-name.brand-intent-brand" },
152
+ "3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
153
+ "4": { "name": "constant.numeric.brand-intent-brand" }
154
+ }
155
+ },
145
156
  "family-pairs-keyword": {
146
157
  "match": "^(\\s{4,})(pairs)\\s*$",
147
158
  "captures": {
@@ -366,7 +377,7 @@
366
377
  }
367
378
  },
368
379
  "asset-header": {
369
- "match": "^(logo|photo|video|illustration)\\s+(\\S+)",
380
+ "match": "^(logo|photo|video|illustration|framing)\\s+(\\S+)",
370
381
  "captures": {
371
382
  "1": { "name": "keyword.control.brand-intent-brand" },
372
383
  "2": { "name": "entity.name.type.brand-intent-brand" }
@@ -410,6 +421,16 @@
410
421
  "11": { "name": "comment.line.number-sign.brand-intent-brand" }
411
422
  }
412
423
  },
424
+ "framing-horizon": {
425
+ "comment": "framing block horizon: N% | none",
426
+ "match": "^(\\s{2,})(horizon)(:\\s*)(\\d+(?:\\.\\d+)?%|none)\\s*(#.*)?$",
427
+ "captures": {
428
+ "2": { "name": "support.type.property-name.brand-intent-brand" },
429
+ "3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
430
+ "4": { "name": "constant.numeric.brand-intent-brand" },
431
+ "5": { "name": "comment.line.number-sign.brand-intent-brand" }
432
+ }
433
+ },
413
434
  "counter-value": {
414
435
  "comment": "counter block properties (typography: is covered by typography-value)",
415
436
  "match": "^(\\s{2,})(format|shape|size|padding)(:\\s*)(\\S+)\\s*(#.*)?$",
@@ -445,7 +466,7 @@
445
466
  },
446
467
  "keyword-partial": {
447
468
  "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|$)",
469
+ "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
470
  "captures": {
450
471
  "1": { "name": "keyword.control.brand-intent-brand" }
451
472
  }
@@ -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
  }