brand-intent 0.15.0 → 0.17.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.15.0",
3
+ "version": "0.17.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",
@@ -116,6 +116,27 @@ export interface ParsedBrand {
116
116
  framings?: ParsedFramingDef[];
117
117
  /** Camera defaults (`camera` block). Absent → rear camera, no grid. */
118
118
  camera?: ParsedCameraDef;
119
+ /** Photo treatment (`photo-look` block). Absent → photos stay as taken. */
120
+ photoLook?: ParsedPhotoLookDef;
121
+ }
122
+
123
+ export type PhotoLookKey = 'saturation' | 'contrast' | 'warmth' | 'fade' | 'mono' | 'duotone';
124
+
125
+ export interface ParsedPhotoLookDef {
126
+ /** -100..100, 0 = unchanged. */
127
+ saturation: number;
128
+ /** -100..100, 0 = unchanged. */
129
+ contrast: number;
130
+ /** -100 (cool) .. 100 (warm), 0 = unchanged. */
131
+ warmth: number;
132
+ /** Lifted black point, 0..100. */
133
+ fade: number;
134
+ /** Black-and-white. */
135
+ mono: boolean;
136
+ /** Map luminance onto two colors (resolved hex), shadows → highlights. Excludes `mono`. */
137
+ duotone?: { shadow: string; highlight: string };
138
+ /** Keys the person cannot change. All others are starting values. */
139
+ fixed: PhotoLookKey[];
119
140
  }
120
141
 
121
142
  export interface ParsedCameraDef {
@@ -250,6 +271,14 @@ const VALID_STRATEGIES = new Set<PaletteStrategy>(['monochrome', 'duotone', 'tri
250
271
  const VALID_ADHERENCES = new Set<Adherence>(['strict', 'on-brand', 'flexible']);
251
272
  const TONE_CLASSES = new Set<ToneClass>(['light', 'mid', 'dark']);
252
273
  const FAMILY_ROLES = new Set<FamilyRole>(['core', 'accent', 'contrast']);
274
+
275
+ const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
276
+ function isMonthDay(value: string): boolean {
277
+ const m = value.match(/^(\d{2})-(\d{2})$/);
278
+ if (!m) return false;
279
+ const month = Number(m[1]), day = Number(m[2]);
280
+ return month >= 1 && month <= 12 && day >= 1 && day <= DAYS_IN_MONTH[month - 1];
281
+ }
253
282
  const FAMILY_ORDERS = new Set<FamilyOrder>(['cycle', 'sequence']);
254
283
  const FAMILY_BORROWS = new Set<FamilyBorrow>(['previous', 'next', 'both', 'none']);
255
284
  const PAIR_KEYS = ['ground', 'figure', 'text'] as const;
@@ -372,7 +401,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
372
401
  tokensSource: 'figma',
373
402
  };
374
403
 
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';
404
+ 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';
376
405
  let activeBlock: ActiveBlock = 'none';
377
406
  let currentThemeName = '';
378
407
  let currentDividerName = '';
@@ -395,7 +424,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
395
424
  const adherenceOverridesRaw: Record<string, string> = {};
396
425
  const colorRulesRaw: Record<string, string> = {};
397
426
  let colorRulesDeclared = false;
398
- type RawFamily = { name: string; line: number; colors: Array<{ role: string; value: string; line: number }>; pairs: Array<{ ground: string; figure: string; line: number }> };
427
+ type RawFamily = { name: string; line: number; from?: string; colors: Array<{ role: string; value: string; line: number }>; pairs: Array<{ ground: string; figure: string; line: number }> };
399
428
  const familiesProps: Record<string, string> = {};
400
429
  const familiesRaw: RawFamily[] = [];
401
430
  let familiesDeclared = false;
@@ -421,6 +450,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
421
450
  let counterDeclared = false;
422
451
  const cameraRaw: Record<string, { value: string; line: number }> = {};
423
452
  let cameraDeclared = false;
453
+ const photoLookRaw: Record<string, { value: string; line: number }> = {};
454
+ let photoLookDeclared = false;
424
455
  const framingsRaw: Array<{ id: string; line: number; props: Record<string, { value: string; line: number }> }> = [];
425
456
 
426
457
  for (let i = 0; i < lines.length; i++) {
@@ -653,8 +684,16 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
653
684
  }
654
685
  const colonIdx = trimmed.indexOf(':');
655
686
  const key = colonIdx !== -1 ? trimmed.slice(0, colonIdx).trim() : trimmed;
687
+ if (key === 'from' && colonIdx !== -1) {
688
+ const value = trimmed.slice(colonIdx + 1).trim();
689
+ if (currentFamily.from !== undefined) throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" declares from: twice`);
690
+ if (!isMonthDay(value)) throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}" from: must be "MM-DD" (e.g. 12-01), got "${value}"`);
691
+ inPairs = false;
692
+ currentFamily.from = value;
693
+ continue;
694
+ }
656
695
  if (colonIdx === -1 || !FAMILY_ROLES.has(key as FamilyRole)) {
657
- throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected core / accent / contrast / pairs, got "${trimmed}"`);
696
+ throw new Error(`${fileName}:${lineNum}: family "${currentFamily.name}": expected from / core / accent / contrast / pairs, got "${trimmed}"`);
658
697
  }
659
698
  inPairs = false;
660
699
  currentFamily.colors.push({ role: key, value: trimmed.slice(colonIdx + 1).trim(), line: lineNum });
@@ -801,6 +840,18 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
801
840
  cameraRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
802
841
  continue;
803
842
  }
843
+ if (activeBlock === 'photo-look') {
844
+ const trimmed = raw.trim();
845
+ if (trimmed.startsWith('#')) continue;
846
+ // `# …` is a comment, but `#RGB` / `#RRGGBB` is a duotone color
847
+ const commentIdx = trimmed.search(/\s+#(?![0-9a-f]{3}(?:[0-9a-f]{3}(?:[0-9a-f]{2})?)?(?:\s|$))/i);
848
+ const entry = commentIdx !== -1 ? trimmed.slice(0, commentIdx) : trimmed;
849
+ const colonIdx = entry.indexOf(':');
850
+ // Bare flag (`mono`) or KEY: VALUE
851
+ if (colonIdx === -1) photoLookRaw[entry.trim()] = { value: '', line: lineNum };
852
+ else photoLookRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
853
+ continue;
854
+ }
804
855
  if (activeBlock === 'framing') {
805
856
  const trimmed = raw.trim();
806
857
  if (trimmed.startsWith('#')) continue;
@@ -881,6 +932,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
881
932
  continue;
882
933
  }
883
934
 
935
+ // photo-look — photo treatment (saturation)
936
+ if (raw.trim() === 'photo-look') {
937
+ if (photoLookDeclared) throw new Error(`${fileName}:${lineNum}: duplicate photo-look block`);
938
+ photoLookDeclared = true;
939
+ activeBlock = 'photo-look';
940
+ continue;
941
+ }
942
+
884
943
  // framing <id> — photo framing module (horizon position)
885
944
  const framingMatch = raw.match(/^framing\s+(\S+)\s*$/);
886
945
  if (framingMatch) {
@@ -1150,7 +1209,16 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1150
1209
  }
1151
1210
  brandColors[c.key] = c.hex;
1152
1211
  }
1153
- families.push({ name: raw.name, colors, pairs: [] });
1212
+ families.push({ name: raw.name, ...(raw.from ? { from: raw.from } : {}), colors, pairs: [] });
1213
+ }
1214
+ const dated = familiesRaw.filter(f => f.from !== undefined);
1215
+ if (dated.length > 0 && dated.length < familiesRaw.length) {
1216
+ const missing = familiesRaw.filter(f => f.from === undefined).map(f => f.name).join(', ');
1217
+ throw new Error(`${fileName}: color-families: from: must be declared on every family or on none (missing: ${missing})`);
1218
+ }
1219
+ for (const f of dated) {
1220
+ const twin = dated.find(o => o !== f && o.from === f.from);
1221
+ if (twin) throw new Error(`${fileName}:${f.line}: families "${twin.name}" and "${f.name}" share from: ${f.from}`);
1154
1222
  }
1155
1223
  colorFamilies = { order, borrow, distance, shared, families };
1156
1224
  if (borrow !== 'none' && reachCoversAll(families.length, { order, borrow, distance })) {
@@ -1557,6 +1625,60 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1557
1625
  result.camera = camera;
1558
1626
  }
1559
1627
 
1628
+ // Finalise photo treatment
1629
+ if (photoLookDeclared) {
1630
+ const keys: PhotoLookKey[] = ['saturation', 'contrast', 'warmth', 'fade', 'mono', 'duotone'];
1631
+ for (const [key, { line }] of Object.entries(photoLookRaw)) {
1632
+ if (key !== 'fixed' && !keys.includes(key as PhotoLookKey)) throw new Error(`${fileName}:${line}: unknown photo-look property "${key}"`);
1633
+ }
1634
+ const int = (key: string, min: number, max: number): number => {
1635
+ const p = photoLookRaw[key];
1636
+ if (!p) return 0;
1637
+ const n = Number(p.value);
1638
+ if (!/^[+-]?\d+$/.test(p.value) || n < min || n > max) {
1639
+ throw new Error(`${fileName}:${p.line}: photo-look ${key} must be an integer from ${min} to ${max}, got "${p.value}"`);
1640
+ }
1641
+ return n;
1642
+ };
1643
+ const look: ParsedPhotoLookDef = {
1644
+ saturation: int('saturation', -100, 100),
1645
+ contrast: int('contrast', -100, 100),
1646
+ warmth: int('warmth', -100, 100),
1647
+ fade: int('fade', 0, 100),
1648
+ mono: false,
1649
+ fixed: [],
1650
+ };
1651
+ const mono = photoLookRaw['mono'];
1652
+ if (mono) {
1653
+ if (mono.value !== '') throw new Error(`${fileName}:${mono.line}: photo-look mono is a flag and takes no value`);
1654
+ look.mono = true;
1655
+ }
1656
+ const duo = photoLookRaw['duotone'];
1657
+ if (duo) {
1658
+ if (mono) throw new Error(`${fileName}:${duo.line}: photo-look cannot be both mono and duotone`);
1659
+ const refs = duo.value.split(/\s+/);
1660
+ if (refs.length !== 2) throw new Error(`${fileName}:${duo.line}: photo-look duotone must be "SHADOW HIGHLIGHT", got "${duo.value}"`);
1661
+ const [shadow, highlight] = refs.map(ref => {
1662
+ if (ref.startsWith('$') && !brandColors[ref.slice(1)]) {
1663
+ throw new Error(`${fileName}:${duo.line}: photo-look duotone references unknown brand color "${ref}"`);
1664
+ }
1665
+ const hex = normalizeHex(ref.startsWith('$') ? brandColors[ref.slice(1)] : ref);
1666
+ if (!hex) throw new Error(`${fileName}:${duo.line}: photo-look duotone colors must be $ref or #HEX, got "${ref}"`);
1667
+ return hex;
1668
+ });
1669
+ look.duotone = { shadow, highlight };
1670
+ }
1671
+ const fixed = photoLookRaw['fixed'];
1672
+ if (fixed) {
1673
+ for (const key of fixed.value.split(/\s+/)) {
1674
+ if (!keys.includes(key as PhotoLookKey)) throw new Error(`${fileName}:${fixed.line}: photo-look fixed lists unknown key "${key}"`);
1675
+ if (!photoLookRaw[key]) throw new Error(`${fileName}:${fixed.line}: photo-look fixed lists "${key}", which is not set`);
1676
+ look.fixed.push(key as PhotoLookKey);
1677
+ }
1678
+ }
1679
+ result.photoLook = look;
1680
+ }
1681
+
1560
1682
  // Finalise framing modules
1561
1683
  if (framingsRaw.length > 0) {
1562
1684
  const known = new Set(['label', 'horizon', 'description']);
@@ -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, ParsedFramingDef, ParsedCameraDef } from './brandParser';
27
+ export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape, ParsedFramingDef, ParsedCameraDef, ParsedPhotoLookDef, PhotoLookKey } 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';
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" },
@@ -143,6 +144,15 @@
143
144
  "5": { "name": "string.unquoted.brand-intent-brand" }
144
145
  }
145
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
+ },
146
156
  "family-pairs-keyword": {
147
157
  "match": "^(\\s{4,})(pairs)\\s*$",
148
158
  "captures": {
@@ -389,8 +399,8 @@
389
399
  }
390
400
  },
391
401
  "icons-counter-header": {
392
- "comment": "Bare `icons` / `counter` block headers.",
393
- "match": "^(icons|counter)\\s*$",
402
+ "comment": "Bare `icons` / `counter` / `camera` / `photo-look` block headers.",
403
+ "match": "^(icons|counter|camera|photo-look)\\s*$",
394
404
  "captures": {
395
405
  "1": { "name": "keyword.control.brand-intent-brand" }
396
406
  }