brand-intent 0.21.0 → 0.22.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.
@@ -65,6 +65,14 @@ voice
65
65
  - Superlative ohne Substanz (bestes Brot, einzigartiger Geschmack)
66
66
  - Wellness- oder Gesundheitsversprechen (gut für dich, clean eating)
67
67
 
68
+ # instead: the phrase to avoid and what Krume writes instead.
69
+ # A pair teaches the rewrite, not just the ban.
70
+
71
+ instead:
72
+ - "beste Zutaten" -> Tipo 1 und Roggen 1150 aus der Mühle in Dachau
73
+ - "mit Liebe gebacken" -> achtzehn Stunden Teigruhe
74
+ - "frisch aus dem Ofen" -> seit 6 Uhr im Regal
75
+
68
76
  # ── Values ──
69
77
  # Not keywords - behavior statements. Each value describes
70
78
  # how it manifests in what we say and do.
@@ -74,11 +82,15 @@ values
74
82
  Wir benennen den Prozess, nicht das Ergebnis.
75
83
  Backzeit in Stunden, Mehlsorte beim Namen.
76
84
  Kein Brot ohne nachvollziehbaren Weg vom Korn zum Laib.
85
+ say: Der Roggen steht seit gestern Abend.
86
+ not: Mit Liebe handgemacht.
77
87
 
78
88
  Ehrlichkeit
79
89
  Kein Brot ohne Zutatenliste. Keine Versprechen
80
90
  die der Teig nicht halten kann. Wenn etwas
81
91
  nicht gelungen ist, sagen wir das.
92
+ say: Heute kein Dinkel. Der Teig wollte nicht.
93
+ not: Leider ausverkauft, schau morgen wieder vorbei!
82
94
 
83
95
  Kiez
84
96
  Der Laden gehört zur Weserstraße wie der Späti
@@ -125,3 +137,61 @@ anti-audience discount-hunters
125
137
  Supermarkt-Toastbrot vergleicht, ist hier falsch.
126
138
  Wir erklären gerne warum es kostet was es kostet -
127
139
  aber wir rechtfertigen uns nicht.
140
+
141
+ # ── Imagery ──
142
+ # The image counterpart of voice: yes/no rules for photos and film.
143
+ # Framing and colour treatment live in krume.brand.
144
+
145
+ imagery
146
+ always:
147
+ - echte Arbeitsschritte in der Backstube, nichts nachgestellt
148
+ - Mehl, Teig und Werkzeug mit Gebrauchsspuren
149
+ - Tageslicht oder das Licht der Backstube
150
+ never:
151
+ - gestellte Lächeln in die Kamera
152
+ - Brot als Deko auf Leinen und Holzbrett
153
+ - KI-generierte oder KI-veränderte Bildinhalte
154
+
155
+ image-theme Vier Uhr morgens
156
+ Die Backstube vor Ladenöffnung: Hände im Teig,
157
+ Mehl auf der Arbeitsfläche, der Ofen offen.
158
+
159
+ image-theme Über den Tresen
160
+ Stammkunden und Laufkundschaft am Tresen,
161
+ Brot wechselt die Hand, die Weserstraße im Hintergrund.
162
+
163
+ # ── Goals ──
164
+ # What a post is for, independent of its format.
165
+ # Shares: monthly target for Instagram.
166
+
167
+ goal craft
168
+ label: Handwerk zeigen
169
+ share: 60%
170
+ question: Wie entsteht das Brot?
171
+ always:
172
+ - einen konkreten Arbeitsschritt, eine Mehlsorte oder eine Zeit nennen
173
+
174
+ goal kiez
175
+ label: Kiez
176
+ share: 30%
177
+ question: Wer kommt hierher und warum?
178
+ always:
179
+ - eine Person mit Vornamen und ihrem Grund zu kommen
180
+
181
+ goal service
182
+ label: Service
183
+ share: 10%
184
+ question: Was gibt es heute, wann ist offen?
185
+ always:
186
+ - Tag, Uhrzeit und Sorte konkret nennen
187
+
188
+ # ── Review ──
189
+ # Asked before anything goes out. Each one is a yes/no question.
190
+
191
+ review
192
+ copy:
193
+ - Nennt der Text Sorte, Mehl, Gewicht oder Backzeit?
194
+ - Klingt er wie die Bäckerin hinterm Tresen?
195
+ image:
196
+ - Zeigt das Bild einen echten Arbeitsschritt oder echte Kundschaft?
197
+ - Ist das Brot Lebensmittel, nicht Dekoration?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.21.0",
3
+ "version": "0.22.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",
@@ -169,6 +169,10 @@ export interface ParsedFramingDef {
169
169
  horizon: number | null;
170
170
  /** The photographic task, one line. */
171
171
  description?: string;
172
+ /** What a text adds to a photo in this framing, one line. */
173
+ caption?: string;
174
+ /** Identity goal ids this framing serves. */
175
+ goals?: string[];
172
176
  }
173
177
 
174
178
  export type CounterFormat = '1' | '01' | '1.' | '(1)' | 'i';
@@ -209,6 +213,14 @@ export interface ParsedAssetDef {
209
213
  group?: string;
210
214
  /** Photos only: name of the date window the photo fits (`when:`). Absent → all year. */
211
215
  when?: string;
216
+ /** Photos only: the framing module the photo was taken in. */
217
+ framing?: string;
218
+ /** Photos only: where the photo was taken. */
219
+ place?: string;
220
+ /** Photos only: who is shown, with name and role. */
221
+ person?: string;
222
+ /** Photos only: date taken, YYYY-MM-DD. */
223
+ taken?: string;
212
224
  }
213
225
 
214
226
  export type LogoAlign = 'left' | 'center' | 'right';
@@ -1784,6 +1796,9 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1784
1796
  if (raw.props['generator']) def.generator = raw.props['generator'];
1785
1797
  if (raw.props['group']) def.group = raw.props['group'];
1786
1798
  if (raw.props['when']) def.when = raw.props['when'];
1799
+ for (const key of ['framing', 'place', 'person', 'taken'] as const) {
1800
+ if (raw.props[key]) def[key] = raw.props[key];
1801
+ }
1787
1802
  // generator-params are stored as "generator-params.KEY" by the sub-block parser.
1788
1803
  const paramEntries: [string, number][] = [];
1789
1804
  for (const [k, v] of Object.entries(raw.props)) {
@@ -1814,13 +1829,20 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1814
1829
  }
1815
1830
  result.windows = windows;
1816
1831
  }
1832
+ const photoOnly = ['when', 'framing', 'place', 'person', 'taken'];
1817
1833
  for (const [kind, arr] of [['logo', logosRaw], ['video', videosRaw], ['illustration', illustrationsRaw]] as const) {
1818
- const a = arr.find(r => r.props['when']);
1819
- if (a) throw new Error(`${fileName}: ${kind} "${a.id}": when: is for photos only`);
1834
+ for (const key of photoOnly) {
1835
+ const a = arr.find(r => r.props[key]);
1836
+ if (a) throw new Error(`${fileName}: ${kind} "${a.id}": ${key}: is for photos only`);
1837
+ }
1820
1838
  }
1821
1839
  for (const p of photosRaw) {
1822
1840
  const when = p.props['when'];
1823
1841
  if (when && !result.windows?.[when]) throw new Error(`${fileName}: photo "${p.id}" references unknown window "${when}"`);
1842
+ const taken = p.props['taken'];
1843
+ if (taken && !/^\d{4}-\d{2}-\d{2}$/.test(taken)) throw new Error(`${fileName}: photo "${p.id}": taken: must be YYYY-MM-DD, got "${taken}"`);
1844
+ const framing = p.props['framing'];
1845
+ if (framing && !framingsRaw.some(f => f.id === framing)) throw new Error(`${fileName}: photo "${p.id}" references unknown framing "${framing}"`);
1824
1846
  }
1825
1847
 
1826
1848
  if (logosRaw.length > 0 || photosRaw.length > 0 || videosRaw.length > 0 || illustrationsRaw.length > 0) {
@@ -1987,7 +2009,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1987
2009
 
1988
2010
  // Finalise framing modules
1989
2011
  if (framingsRaw.length > 0) {
1990
- const known = new Set(['label', 'horizon', 'description']);
2012
+ const known = new Set(['label', 'horizon', 'description', 'caption', 'goals']);
1991
2013
  result.framings = framingsRaw.map(({ id, line, props }) => {
1992
2014
  for (const [key, { line: l }] of Object.entries(props)) {
1993
2015
  if (!known.has(key)) throw new Error(`${fileName}:${l}: unknown framing property "${key}"`);
@@ -2003,6 +2025,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
2003
2025
  }
2004
2026
  const def: ParsedFramingDef = { id, label: props['label']?.value || id, horizon };
2005
2027
  if (props['description']?.value) def.description = props['description'].value;
2028
+ if (props['caption']?.value) def.caption = props['caption'].value;
2029
+ if (props['goals']?.value) def.goals = props['goals'].value.split(',').map(g => g.trim()).filter(Boolean);
2006
2030
  return def;
2007
2031
  });
2008
2032
  }
@@ -2,7 +2,8 @@
2
2
  * Parser for .identity brand identity files.
3
3
  *
4
4
  * Line-based format with indentation-based blocks.
5
- * Contains brand strategy: essence, promise, voice, pillars, audiences, values.
5
+ * Contains brand strategy: essence, promise, voice, pillars, audiences, values,
6
+ * imagery, goals and review checks.
6
7
  * No visual properties of any kind.
7
8
  */
8
9
 
@@ -40,6 +41,14 @@ export interface ParsedIdentity {
40
41
  values?: ParsedValue[];
41
42
  /** Explicitly not-our-audience segments keyed by ID */
42
43
  antiAudiences?: Record<string, ParsedAntiAudience>;
44
+ /** Rules for photos and film: what the imagery always shows and never shows */
45
+ imagery?: ParsedImagery;
46
+ /** Named image themes, in declaration order */
47
+ imageThemes?: ParsedImageTheme[];
48
+ /** Communication goals keyed by ID (e.g. the shares of a content mix), in declaration order */
49
+ goals?: Record<string, ParsedGoal>;
50
+ /** Questions to check before publishing */
51
+ review?: ParsedReview;
43
52
  }
44
53
 
45
54
  export interface ParsedVoice {
@@ -53,6 +62,15 @@ export interface ParsedVoice {
53
62
  always: string[];
54
63
  /** Concrete rules the brand never breaks */
55
64
  never: string[];
65
+ /** Phrases to replace, each with the brand's own wording */
66
+ instead?: ParsedReplacement[];
67
+ }
68
+
69
+ export interface ParsedReplacement {
70
+ /** The phrase to avoid */
71
+ avoid: string;
72
+ /** What the brand writes instead */
73
+ use: string;
56
74
  }
57
75
 
58
76
  export interface ParsedPillars {
@@ -96,13 +114,56 @@ export interface ParsedValue {
96
114
  name: string;
97
115
  /** Behavior statement — what this value means in practice */
98
116
  behavior: string;
117
+ /** Example lines that carry this value */
118
+ say?: string[];
119
+ /** Counter-examples: lines that miss it */
120
+ not?: string[];
121
+ }
122
+
123
+ export interface ParsedImagery {
124
+ /** What every photo and film shows */
125
+ always: string[];
126
+ /** What the imagery never shows */
127
+ never: string[];
128
+ }
129
+
130
+ export interface ParsedImageTheme {
131
+ /** Theme title (free-form) */
132
+ name: string;
133
+ /** Name of the narrative this theme belongs to */
134
+ narrative?: string;
135
+ /** What the images show */
136
+ text: string;
137
+ }
138
+
139
+ export interface ParsedGoal {
140
+ /** Human-readable goal name */
141
+ label: string;
142
+ /** Target share of the content mix, 0–100 */
143
+ share?: number;
144
+ /** The question content for this goal answers */
145
+ question?: string;
146
+ /** Narratives this goal draws on */
147
+ narratives?: string[];
148
+ /** Rules content for this goal follows */
149
+ always?: string[];
150
+ /** Rules content for this goal never breaks */
151
+ never?: string[];
152
+ }
153
+
154
+ export interface ParsedReview {
155
+ /** Questions for texts */
156
+ copy?: string[];
157
+ /** Questions for photos and film */
158
+ image?: string[];
99
159
  }
100
160
 
101
161
  // ── Parser ────────────────────────────────────────────────────────────────────
102
162
 
103
- type Block = 'top' | 'voice' | 'pillars' | 'audience' | 'anti-audience' | 'values' | 'narrative';
163
+ type Block = 'top' | 'voice' | 'pillars' | 'audience' | 'anti-audience' | 'values' | 'narrative' | 'imagery' | 'image-theme' | 'goal' | 'review';
104
164
  type NarrativeState = { name: string; primary: boolean; text: string };
105
- type ListTarget = 'always' | 'never' | 'primary' | 'secondary' | 'avoid';
165
+ type ListTarget = 'always' | 'never' | 'instead' | 'primary' | 'secondary' | 'avoid'
166
+ | 'imagery-always' | 'imagery-never' | 'goal-always' | 'goal-never' | 'review-copy' | 'review-image';
106
167
 
107
168
  export function parseIdentityFile(content: string, fileName: string): ParsedIdentity {
108
169
  const lines = content.split('\n');
@@ -122,12 +183,22 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
122
183
  const audiences: Record<string, Partial<ParsedAudience>> = {};
123
184
  const antiAudiences: Record<string, Partial<ParsedAntiAudience>> = {};
124
185
  const values: ParsedValue[] = [];
186
+ const imagery: ParsedImagery = { always: [], never: [] };
187
+ let imageryDeclared = false;
188
+ const imageThemes: ParsedImageTheme[] = [];
189
+ let currentImageTheme: ParsedImageTheme | null = null;
190
+ const goals: Record<string, Partial<ParsedGoal> & { line: number }> = {};
191
+ let currentGoal = '';
192
+ const review: ParsedReview = {};
193
+ let reviewDeclared = false;
125
194
 
126
195
  let block: Block = 'top';
127
196
  let currentAudience = '';
128
197
  let currentAntiAudience = '';
129
198
  let currentValueName = '';
130
199
  let currentValueBehavior = '';
200
+ let currentValueSay: string[] = [];
201
+ let currentValueNot: string[] = [];
131
202
  let listTarget: ListTarget | null = null;
132
203
  let continuationKey: string | null = null;
133
204
 
@@ -144,10 +215,29 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
144
215
 
145
216
  function flushValue() {
146
217
  if (currentValueName && currentValueBehavior) {
147
- values.push({ name: currentValueName, behavior: currentValueBehavior.trim() });
218
+ const value: ParsedValue = { name: currentValueName, behavior: currentValueBehavior.trim() };
219
+ if (currentValueSay.length > 0) value.say = currentValueSay;
220
+ if (currentValueNot.length > 0) value.not = currentValueNot;
221
+ values.push(value);
148
222
  }
149
223
  currentValueName = '';
150
224
  currentValueBehavior = '';
225
+ currentValueSay = [];
226
+ currentValueNot = [];
227
+ }
228
+
229
+ function flushImageTheme() {
230
+ if (currentImageTheme && currentImageTheme.text) {
231
+ imageThemes.push({ ...currentImageTheme, text: currentImageTheme.text.trim() });
232
+ }
233
+ currentImageTheme = null;
234
+ }
235
+
236
+ /** Close the free-text blocks before a new block or top-level key starts. */
237
+ function flushBlock() {
238
+ if (block === 'values') flushValue();
239
+ if (block === 'narrative') flushNarrative();
240
+ if (block === 'image-theme') flushImageTheme();
151
241
  }
152
242
 
153
243
  for (let i = 0; i < lines.length; i++) {
@@ -167,10 +257,13 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
167
257
  if (block === 'values' && /^\s{2,}/.test(raw) && !raw.trim().startsWith('#')) {
168
258
  const indent = raw.match(/^(\s*)/)?.[1].length ?? 0;
169
259
  const text = raw.trim();
260
+ const example = text.match(/^(say|not):\s*(.+)$/);
170
261
  if (indent <= 2 && !text.includes(':')) {
171
262
  // New value name (2-space indent)
172
263
  flushValue();
173
264
  currentValueName = text;
265
+ } else if (currentValueName && example) {
266
+ (example[1] === 'say' ? currentValueSay : currentValueNot).push(example[2].trim());
174
267
  } else if (currentValueName) {
175
268
  // Behavior text (4+ space indent) or continuation
176
269
  currentValueBehavior += (currentValueBehavior ? ' ' : '') + text;
@@ -189,6 +282,15 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
189
282
  continue;
190
283
  }
191
284
 
285
+ // ── Image theme block: optional narrative reference, then text lines ──
286
+ if (block === 'image-theme' && currentImageTheme && /^\s{2,}/.test(raw) && !raw.trim().startsWith('#')) {
287
+ const trimmed = raw.trim();
288
+ const ref = trimmed.match(/^narrative:\s*(.+)$/);
289
+ if (ref) currentImageTheme.narrative = ref[1].trim();
290
+ else currentImageTheme.text += (currentImageTheme.text ? '\n' : '') + trimmed;
291
+ continue;
292
+ }
293
+
192
294
  // ── Indented continuation lines ──
193
295
  if (/^\s{2,}/.test(raw) && !raw.trim().startsWith('-') && !raw.trim().includes(':')) {
194
296
  const continuation = raw.trim();
@@ -215,6 +317,19 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
215
317
  case 'primary': pillars.primary!.push(value); break;
216
318
  case 'secondary': (pillars.secondary ??= []).push(value); break;
217
319
  case 'avoid': pillars.avoid!.push(value); break;
320
+ case 'instead': {
321
+ const arrow = value.indexOf('->');
322
+ if (arrow === -1) throw new Error(`${fileName}:${lineNum}: voice.instead item must be "PHRASE -> REPLACEMENT", got "${value}"`);
323
+ const unquote = (s: string) => s.trim().replace(/^["„“](.*)["“”]$/, '$1');
324
+ (voice.instead ??= []).push({ avoid: unquote(value.slice(0, arrow)), use: unquote(value.slice(arrow + 2)) });
325
+ break;
326
+ }
327
+ case 'imagery-always': imagery.always.push(value); break;
328
+ case 'imagery-never': imagery.never.push(value); break;
329
+ case 'goal-always': (goals[currentGoal].always ??= []).push(value); break;
330
+ case 'goal-never': (goals[currentGoal].never ??= []).push(value); break;
331
+ case 'review-copy': (review.copy ??= []).push(value); break;
332
+ case 'review-image': (review.image ??= []).push(value); break;
218
333
  }
219
334
  continuationKey = null;
220
335
  continue;
@@ -235,6 +350,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
235
350
  case 'rhythm': voice.rhythm = val; break;
236
351
  case 'always': listTarget = 'always'; break;
237
352
  case 'never': listTarget = 'never'; break;
353
+ case 'instead': listTarget = 'instead'; break;
238
354
  default:
239
355
  throw new Error(`${fileName}:${lineNum}: unknown voice key "${key}"`);
240
356
  }
@@ -273,6 +389,38 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
273
389
  default:
274
390
  throw new Error(`${fileName}:${lineNum}: unknown anti-audience key "${key}"`);
275
391
  }
392
+ } else if (block === 'imagery') {
393
+ switch (key) {
394
+ case 'always': listTarget = 'imagery-always'; break;
395
+ case 'never': listTarget = 'imagery-never'; break;
396
+ default:
397
+ throw new Error(`${fileName}:${lineNum}: unknown imagery key "${key}"`);
398
+ }
399
+ } else if (block === 'goal' && currentGoal) {
400
+ const goal = goals[currentGoal];
401
+ switch (key) {
402
+ case 'label': goal.label = val; break;
403
+ case 'question': goal.question = val; break;
404
+ case 'share': {
405
+ const m = val.match(/^(\d+(?:\.\d+)?)%$/);
406
+ const n = m ? parseFloat(m[1]) : NaN;
407
+ if (!(n >= 0 && n <= 100)) throw new Error(`${fileName}:${lineNum}: goal share must be a percentage between 0% and 100%, got "${val}"`);
408
+ goal.share = n;
409
+ break;
410
+ }
411
+ case 'narratives': goal.narratives = val.split(',').map(s => s.trim()).filter(Boolean); break;
412
+ case 'always': listTarget = 'goal-always'; break;
413
+ case 'never': listTarget = 'goal-never'; break;
414
+ default:
415
+ throw new Error(`${fileName}:${lineNum}: unknown goal key "${key}"`);
416
+ }
417
+ } else if (block === 'review') {
418
+ switch (key) {
419
+ case 'copy': listTarget = 'review-copy'; break;
420
+ case 'image': listTarget = 'review-image'; break;
421
+ default:
422
+ throw new Error(`${fileName}:${lineNum}: unknown review key "${key}"`);
423
+ }
276
424
  }
277
425
  continue;
278
426
  }
@@ -282,8 +430,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
282
430
  if (topMatch) {
283
431
  const [, key, value] = topMatch;
284
432
  const val = value.trim();
285
- if (block === 'values') flushValue();
286
- if (block === 'narrative') flushNarrative();
433
+ flushBlock();
287
434
  block = 'top';
288
435
  listTarget = null;
289
436
  continuationKey = null;
@@ -327,8 +474,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
327
474
 
328
475
  // ── Block headers ──
329
476
  if (raw.match(/^voice\s*$/)) {
330
- if (block === 'values') flushValue();
331
- if (block === 'narrative') flushNarrative();
477
+ flushBlock();
332
478
  block = 'voice';
333
479
  listTarget = null;
334
480
  continuationKey = null;
@@ -336,8 +482,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
336
482
  }
337
483
 
338
484
  if (raw.match(/^pillars\s*$/)) {
339
- if (block === 'values') flushValue();
340
- if (block === 'narrative') flushNarrative();
485
+ flushBlock();
341
486
  block = 'pillars';
342
487
  listTarget = null;
343
488
  continuationKey = null;
@@ -345,8 +490,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
345
490
  }
346
491
 
347
492
  if (raw.match(/^values\s*$/)) {
348
- if (block === 'values') flushValue();
349
- if (block === 'narrative') flushNarrative();
493
+ flushBlock();
350
494
  block = 'values';
351
495
  listTarget = null;
352
496
  continuationKey = null;
@@ -355,8 +499,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
355
499
 
356
500
  const narrativeMatch = raw.match(/^narrative\s+(.+?)\s*$/);
357
501
  if (narrativeMatch) {
358
- if (block === 'values') flushValue();
359
- if (block === 'narrative') flushNarrative();
502
+ flushBlock();
360
503
  block = 'narrative';
361
504
  currentNarrative = { name: narrativeMatch[1].trim(), primary: false, text: '' };
362
505
  listTarget = null;
@@ -364,10 +507,49 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
364
507
  continue;
365
508
  }
366
509
 
510
+ if (raw.match(/^imagery\s*$/)) {
511
+ flushBlock();
512
+ block = 'imagery';
513
+ imageryDeclared = true;
514
+ listTarget = null;
515
+ continuationKey = null;
516
+ continue;
517
+ }
518
+
519
+ if (raw.match(/^review\s*$/)) {
520
+ flushBlock();
521
+ block = 'review';
522
+ reviewDeclared = true;
523
+ listTarget = null;
524
+ continuationKey = null;
525
+ continue;
526
+ }
527
+
528
+ const imageThemeMatch = raw.match(/^image-theme\s+(.+?)\s*$/);
529
+ if (imageThemeMatch) {
530
+ flushBlock();
531
+ block = 'image-theme';
532
+ currentImageTheme = { name: imageThemeMatch[1].trim(), text: '' };
533
+ listTarget = null;
534
+ continuationKey = null;
535
+ continue;
536
+ }
537
+
538
+ const goalMatch = raw.match(/^goal\s+(\S+)\s*$/);
539
+ if (goalMatch) {
540
+ flushBlock();
541
+ block = 'goal';
542
+ currentGoal = goalMatch[1];
543
+ if (goals[currentGoal]) throw new Error(`${fileName}:${lineNum}: duplicate goal "${currentGoal}"`);
544
+ goals[currentGoal] = { line: lineNum };
545
+ listTarget = null;
546
+ continuationKey = null;
547
+ continue;
548
+ }
549
+
367
550
  const audienceMatch = raw.match(/^audience\s+(\S+)\s*$/);
368
551
  if (audienceMatch) {
369
- if (block === 'values') flushValue();
370
- if (block === 'narrative') flushNarrative();
552
+ flushBlock();
371
553
  block = 'audience';
372
554
  currentAudience = audienceMatch[1];
373
555
  audiences[currentAudience] ??= {};
@@ -378,8 +560,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
378
560
 
379
561
  const antiAudienceMatch = raw.match(/^anti-audience\s+(\S+)\s*$/);
380
562
  if (antiAudienceMatch) {
381
- if (block === 'values') flushValue();
382
- if (block === 'narrative') flushNarrative();
563
+ flushBlock();
383
564
  block = 'anti-audience';
384
565
  currentAntiAudience = antiAudienceMatch[1];
385
566
  antiAudiences[currentAntiAudience] ??= {};
@@ -389,9 +570,8 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
389
570
  }
390
571
  }
391
572
 
392
- // Flush last value/narrative if in those blocks
393
- if (block === 'values') flushValue();
394
- if (block === 'narrative') flushNarrative();
573
+ // Flush the last free-text block
574
+ flushBlock();
395
575
 
396
576
  // ── Validation ──────────────────────────────────────────────────────────────
397
577
 
@@ -416,6 +596,23 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
416
596
  if (!aa.description) throw new Error(`${fileName}: anti-audience "${id}" missing required field "description:"`);
417
597
  }
418
598
 
599
+ const narrativeNames = new Set(narratives.map(n => n.name));
600
+ for (const theme of imageThemes) {
601
+ if (theme.narrative && !narrativeNames.has(theme.narrative)) {
602
+ throw new Error(`${fileName}: image-theme "${theme.name}" references unknown narrative "${theme.narrative}"`);
603
+ }
604
+ }
605
+
606
+ let shareTotal = 0;
607
+ for (const [id, goal] of Object.entries(goals)) {
608
+ if (!goal.label) throw new Error(`${fileName}:${goal.line}: goal "${id}" missing required field "label:"`);
609
+ for (const name of goal.narratives ?? []) {
610
+ if (!narrativeNames.has(name)) throw new Error(`${fileName}:${goal.line}: goal "${id}" references unknown narrative "${name}"`);
611
+ }
612
+ shareTotal += goal.share ?? 0;
613
+ }
614
+ if (shareTotal > 100) throw new Error(`${fileName}: goal shares add up to ${shareTotal}%, more than 100%`);
615
+
419
616
  // ── Hex value exclusion rule ──
420
617
  const hexPattern = /#[0-9A-Fa-f]{3,8}\b/;
421
618
  if (hexPattern.test(content.replace(/#.*$/gm, ''))) {
@@ -442,6 +639,14 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
442
639
  if (Object.keys(antiAudiences).length > 0) {
443
640
  result.antiAudiences = antiAudiences as Record<string, ParsedAntiAudience>;
444
641
  }
642
+ if (imageryDeclared) result.imagery = imagery;
643
+ if (imageThemes.length > 0) result.imageThemes = imageThemes;
644
+ if (Object.keys(goals).length > 0) {
645
+ result.goals = Object.fromEntries(
646
+ Object.entries(goals).map(([id, { line: _line, ...goal }]) => [id, goal as ParsedGoal]),
647
+ );
648
+ }
649
+ if (reviewDeclared) result.review = review;
445
650
 
446
651
  return result;
447
652
  }
package/parser/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // they take a string (file content) and filename, return a parsed object.
4
4
 
5
5
  export { parseIdentityFile } from './identityParser';
6
- export type { ParsedIdentity, ParsedVoice, ParsedPillars, ParsedAudience, ParsedAntiAudience, ParsedValue } from './identityParser';
6
+ export type { ParsedIdentity, ParsedVoice, ParsedReplacement, ParsedPillars, ParsedAudience, ParsedAntiAudience, ParsedValue, ParsedImagery, ParsedImageTheme, ParsedGoal, ParsedReview } from './identityParser';
7
7
 
8
8
  export { parseBrandFile } from './brandParser';
9
9
  export {
@@ -69,7 +69,7 @@ export { parseFormatFile } from './formatParser';
69
69
  export type { ParsedFormat } from './formatParser';
70
70
 
71
71
  export { parsePurposeFile } from './purposeParser';
72
- export type { ParsedPurpose, ParsedPurposeSlot, Density, ListMarker } from './purposeParser';
72
+ export type { ParsedPurpose, ParsedPurposeSlot, CountRange, Density, ListMarker } from './purposeParser';
73
73
 
74
74
  export { parseCompositionFile } from './compositionParser';
75
75
  export type {
@@ -43,6 +43,8 @@ export interface ParsedPurpose {
43
43
  cameraFacing?: 'front' | 'rear';
44
44
  /** `framing: <id>` — the brand framing module photos for this purpose are shot in. */
45
45
  framing?: string;
46
+ /** `goal: <id>` — the identity goal this purpose serves by default. */
47
+ goal?: string;
46
48
  /** When false, photo/video analysis skips this purpose (no AI text generation). Default: true. */
47
49
  analyze?: boolean;
48
50
  /** Default icon for the icon slot. Resolves in the brand set first, then Phosphor (see resolveIconRef). */
@@ -62,6 +64,10 @@ export interface ParsedPurposeSlot {
62
64
  /** Fixed value — when set, this slot is never AI-generated or shuffled. Mutually exclusive with samples. */
63
65
  value?: string;
64
66
  maxLength: number;
67
+ /** Editorial word range (`words: 3..8`). Guidance on top of the hard `maxLength`. */
68
+ words?: CountRange;
69
+ /** Editorial sentence range (`sentences: 1..2`). Guidance on top of the hard `maxLength`. */
70
+ sentences?: CountRange;
65
71
  /** 'text' (default) or 'list'. */
66
72
  slotType?: 'text' | 'list';
67
73
  /** Max number of items (list slots only). */
@@ -109,6 +115,12 @@ export interface ParsedPurposeSlot {
109
115
  }>;
110
116
  }
111
117
 
118
+ /** Inclusive count range; `words: 3` is { min: 3, max: 3 }. */
119
+ export interface CountRange {
120
+ min: number;
121
+ max: number;
122
+ }
123
+
112
124
  // ── Constants ─────────────────────────────────────────────────────────────────
113
125
 
114
126
  const VALID_SLOTS = new Set<string>(['primary', 'secondary', 'detail', 'meta', 'cta', 'label']);
@@ -142,6 +154,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
142
154
  let camera: boolean | undefined;
143
155
  let cameraFacing: 'front' | 'rear' | undefined;
144
156
  let framing: string | undefined;
157
+ let goal: string | undefined;
145
158
  let analyze: boolean | undefined;
146
159
  let defaultIcon: IconRef | undefined;
147
160
  let bgPrompt: string | undefined;
@@ -349,6 +362,12 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
349
362
  continue;
350
363
  }
351
364
 
365
+ // Goal: one of the identity's `goal NAME` blocks
366
+ if (line.startsWith('goal:') && !raw.startsWith(' ')) {
367
+ goal = line.slice(5).trim() || undefined;
368
+ continue;
369
+ }
370
+
352
371
  // Photo/video analysis control
353
372
  if (line.startsWith('analyze:')) {
354
373
  analyze = line.slice(8).trim() !== 'false';
@@ -597,6 +616,15 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
597
616
  currentSlot.maxLength = n;
598
617
  continue;
599
618
  }
619
+ if (prop.startsWith('words:') || prop.startsWith('sentences:')) {
620
+ const key = prop.startsWith('words:') ? 'words' : 'sentences';
621
+ const v = prop.slice(key.length + 1).trim();
622
+ const m = v.match(/^(\d+)(?:\.\.(\d+))?$/);
623
+ const range = m ? { min: parseInt(m[1], 10), max: parseInt(m[2] ?? m[1], 10) } : null;
624
+ if (!range || range.min <= 0 || range.max < range.min) throw new Error(`${fileName}:${lineNum}: invalid ${key} "${v}" — expected N or MIN..MAX`);
625
+ currentSlot[key] = range;
626
+ continue;
627
+ }
600
628
  if (prop.startsWith('typography:')) {
601
629
  currentSlot.typographyRef = prop.slice(11).trim();
602
630
  continue;
@@ -682,5 +710,5 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
682
710
  context = contextLines.join('\n').trim();
683
711
  }
684
712
 
685
- return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, cameraFacing, framing, analyze, defaultIcon, bgPrompt };
713
+ return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, cameraFacing, framing, goal, analyze, defaultIcon, bgPrompt };
686
714
  }
@@ -38,11 +38,19 @@ Identity → Brand → Format → Purpose → Composition
38
38
 
39
39
  **Required:** essence, promise, voice (register, persona, rhythm, always/never), pillars, audience segments.
40
40
 
41
- **Optional:** tagline, positioning, mission, vision, archetype, narrative, values (with behavior statements), anti-audience segments.
41
+ **Optional:** tagline, positioning, mission, vision, archetype, narrative, values (with behavior statements and `say:`/`not:` examples), anti-audience segments, imagery, image-theme, goal, review.
42
42
 
43
43
  **The `always`/`never` construct is your primary guardrail.** These are concrete, testable rules. When generating or reviewing content, check every item in `never:` and confirm every item in `always:`. They are not suggestions; they are constraints.
44
44
 
45
- **`values`** are not just keywords; each has a behavior statement that describes how it manifests. Use these to inform decisions when voice rules don't cover a situation.
45
+ **`voice.instead`** pairs a phrase to avoid with the brand's own wording. When a draft contains the phrase, use the replacement as the model for the rewrite.
46
+
47
+ **`values`** are not just keywords; each has a behavior statement that describes how it manifests. Use these to inform decisions when voice rules don't cover a situation. `say:` lines are examples to match in tone, `not:` lines are the tone to avoid.
48
+
49
+ **`imagery`** is the image counterpart of `voice`: `always`/`never` rules for photos and film. Apply them when reviewing a photo or writing an image brief or prompt. **`image-theme`** blocks name subjects for photos and film, some tied to a narrative.
50
+
51
+ **`goal`** says what a piece of content is for, independent of its format. A purpose names a default goal with `goal:`. Follow the goal's `always`/`never` rules and answer its `question`. `share:` is a planning target, not a rule for a single piece.
52
+
53
+ **`review`** lists yes/no questions to check before publishing: `copy` for texts, `image` for photos and film.
46
54
 
47
55
  **`anti-audience`** segments define who the brand is explicitly *not* for. Never generate content that sounds like it's addressing an anti-audience. This is as important as `voice.never`.
48
56
 
@@ -92,11 +100,11 @@ When generating content for a specific purpose, read all layers silently (do not
92
100
 
93
101
  ### Reading layers (internal, not shown to user):
94
102
 
95
- 1. **Read `.identity`** — voice.always/never (hard constraints), persona, register, pillars.avoid, anti-audience, values, positioning
103
+ 1. **Read `.identity`** — voice.always/never/instead (hard constraints), persona, register, pillars.avoid, anti-audience, values and their examples, positioning; the goal named by the purpose
96
104
  2. **Read `.brand`** — voice-constraints: sentence-max, headline-pattern, number-format, register
97
105
  3. **Apply `scope` from `.purpose`** — audience and pillar filters narrow the upstream context
98
106
  4. **Apply `context` from `.purpose`** — slot-specific instructions, follow literally
99
- 5. **Generate per-slot content** — respect maxLength, use samples as structural reference (match pattern, not words), follow context instructions
107
+ 5. **Generate per-slot content** — respect maxLength and any `words:`/`sentences:` range, use samples as structural reference (match pattern, not words), follow context instructions
100
108
 
101
109
  ### Output format:
102
110
 
@@ -117,7 +125,13 @@ After generating or reviewing content, validate against these rules in order:
117
125
  6. **`sentence-max`:** Count words in every sentence. Does any exceed the limit? If yes, split.
118
126
  7. **`headline-pattern`:** Does the headline follow the specified pattern? If not, restructure.
119
127
  8. **`maxLength`:** Does each slot's content fit within its character limit? If not, shorten.
120
- 9. **`register`:** Does the formality level match? (e.g., if `informal, Du`, no formal address)
128
+ 9. **`words:` / `sentences:`:** Is each slot within its editorial range, where one is set? If not, adjust.
129
+ 10. **`register`:** Does the formality level match? (e.g., if `informal, Du`, no formal address)
130
+ 11. **`voice.instead`:** Does the text contain a phrase from the instead list? If yes, rewrite along the replacement.
131
+ 12. **`goal`:** Where a goal applies, does the text follow its rules and answer its question?
132
+ 13. **`review.copy`:** Answer each question with yes or no. Any no is a failure.
133
+
134
+ When reviewing a photo, check `imagery.always`/`never`, the brand's framing (horizon position) and each `review.image` question.
121
135
 
122
136
  ## What NOT to Do
123
137
 
package/skills/brief.md CHANGED
@@ -39,7 +39,9 @@ The brief is a snapshot. Regenerate it after any change to `.identity`, `.brand`
39
39
  6. **Auto-derive the Guardrails section** from `.identity`:
40
40
  - every item in `voice.never`
41
41
  - every item in `voice.always`
42
+ - every `voice.instead` pair
42
43
  - `pillars.avoid` list
44
+ - every item in `imagery.never` and `imagery.always`
43
45
  - every `anti-audience` block (label + description)
44
46
 
45
47
  The Guardrails *section in the brief* is derived. Never add a `guardrails:` block to the DSL — guardrails live inside `.identity` already.
@@ -93,6 +95,26 @@ _Role: how this brand sounds._
93
95
  _Role: behavior statements, not keywords._
94
96
 
95
97
  - **{name}** — {behavior}
98
+ - Say: {each say: line}
99
+ - Not: {each not: line}
100
+
101
+ ## Imagery (optional)
102
+ _Role: what photos and film show. Only emitted if `imagery` or `image-theme` is present._
103
+
104
+ - **Image themes:** for each — **{name}** ({narrative, if set}) — {text}
105
+
106
+ ## Goals (optional)
107
+ _Role: what a piece of content is for, independent of its format. Only emitted if `goal` blocks are present._
108
+
109
+ - **{label}** ({share, if set}) — {question}
110
+ - Narratives: {narratives}
111
+ - Always: {each always item} · Never: {each never item}
112
+
113
+ ## Review (optional)
114
+ _Role: yes/no questions before publishing. Only emitted if `review` is present._
115
+
116
+ - **Copy:** {each review.copy question}
117
+ - **Image:** {each review.image question}
96
118
 
97
119
  ## Content Pillars
98
120
  _Role: what the brand talks about, and what it avoids._
@@ -132,6 +154,12 @@ _Role: hard rules any generator, agent, or reviewer must enforce._
132
154
  **Always:**
133
155
  - {each voice.always item}
134
156
 
157
+ **Instead:**
158
+ - "{avoid}" → {use}
159
+
160
+ **Imagery never / always:**
161
+ - {each imagery.never item} / {each imagery.always item}
162
+
135
163
  **Avoid topics:**
136
164
  - {each pillars.avoid item}
137
165
 
@@ -151,7 +179,8 @@ For each purpose, emit **glossed** tags — not verbatim DSL values:
151
179
  - **Logo:** gloss — `text` = "wordmark (text logo)"; `mark` = "graphic mark"; other values pass through verbatim.
152
180
  - Scope: {scope audience + pillars}
153
181
  - Context: {context prose per slot}
154
- - Slots: for each slot — id, typography, maxLength, samples. Omit `color:` references (theme-internal detail). Override hints (`hyphenate`, `textAlign`) pass through as-is.
182
+ - Goal: {goal label}, if set
183
+ - Slots: for each slot — id, typography, maxLength, words/sentences range if set, samples. Omit `color:` references (theme-internal detail). Override hints (`hyphenate`, `textAlign`) pass through as-is.
155
184
 
156
185
  ## Formats (optional)
157
186
  _Role: where content appears. Only emitted if any `.format` files are present._
@@ -451,7 +451,7 @@
451
451
  }
452
452
  },
453
453
  "asset-value": {
454
- "match": "^(\\s{2,})(label|src|lottie|gltf|generator|group)(:\\s*)(.*)|^(\\s{2,})(aspect|scale)(:\\s*)(\\S+)|^(\\s{2,})(animated|generator-params|align-left|align-center|align-right)\\s*$|^(\\s{2,})(loop)(?:(:\\s*)(true|false))?\\s*$",
454
+ "match": "^(\\s{2,})(label|src|lottie|gltf|generator|group|when|framing|place|person|taken|description|caption|goals)(:\\s*)(.*)|^(\\s{2,})(aspect|scale)(:\\s*)(\\S+)|^(\\s{2,})(animated|generator-params|align-left|align-center|align-right)\\s*$|^(\\s{2,})(loop)(?:(:\\s*)(true|false))?\\s*$",
455
455
  "captures": {
456
456
  "2": { "name": "support.type.property-name.brand-intent-brand" },
457
457
  "3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
@@ -25,16 +25,16 @@
25
25
  }
26
26
  },
27
27
  "named-block": {
28
- "comment": "Block headers: voice, pillars, values, narrative NAME, audience NAME, anti-audience NAME",
28
+ "comment": "Block headers: voice, pillars, values, imagery, review, narrative NAME, audience NAME, anti-audience NAME, image-theme NAME, goal NAME",
29
29
  "patterns": [
30
30
  {
31
- "match": "^(voice|pillars|values)\\s*$",
31
+ "match": "^(voice|pillars|values|imagery|review)\\s*$",
32
32
  "captures": {
33
33
  "1": { "name": "keyword.control.brand-intent-identity" }
34
34
  }
35
35
  },
36
36
  {
37
- "match": "^(narrative|audience|anti-audience)\\s+(\\S.*)",
37
+ "match": "^(narrative|audience|anti-audience|image-theme|goal)\\s+(\\S.*)",
38
38
  "captures": {
39
39
  "1": { "name": "keyword.control.brand-intent-identity" },
40
40
  "2": { "name": "entity.name.type.brand-intent-identity" }
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "indented-property": {
46
- "match": "^(\\s{2,})(register|persona|rhythm|always|never|primary|secondary|avoid|label|profile|motivation|language|description)(:)\\s+(.*)",
46
+ "match": "^(\\s{2,})(register|persona|rhythm|always|never|instead|primary|secondary|avoid|label|profile|motivation|language|description|say|not|narrative|share|question|narratives|copy|image)(:)\\s+(.*)",
47
47
  "captures": {
48
48
  "2": { "name": "support.type.property-name.brand-intent-identity" },
49
49
  "3": { "name": "punctuation.separator.key-value.brand-intent-identity" },
@@ -123,7 +123,7 @@
123
123
  }
124
124
  },
125
125
  "framing-field": {
126
- "match": "^(framing:)\\s+([\\w-]+)",
126
+ "match": "^(framing:|goal:)\\s+([\\w-]+)",
127
127
  "captures": {
128
128
  "1": { "name": "keyword.control.brand-intent-purpose" },
129
129
  "2": { "name": "entity.name.type.brand-intent-purpose" }
@@ -216,7 +216,7 @@
216
216
  ]
217
217
  },
218
218
  "property": {
219
- "match": "^\\s+(label|samples|value|maxLength|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|editable)(:)\\s+(.*)",
219
+ "match": "^\\s+(label|samples|value|maxLength|words|sentences|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|editable)(:)\\s+(.*)",
220
220
  "captures": {
221
221
  "1": { "name": "support.type.property-name.brand-intent-purpose" },
222
222
  "2": { "name": "punctuation.separator.brand-intent-purpose" },
@@ -258,13 +258,13 @@
258
258
  "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
259
259
  "patterns": [
260
260
  {
261
- "match": "^(id:|name:|compositions:|density:|defaultIcon:|scope|voice|palette:|logo:|photo:|video:|camera:|framing:|analyze:|bg-prompt:|context|slot)(?=\\s|$)",
261
+ "match": "^(id:|name:|compositions:|density:|defaultIcon:|scope|voice|palette:|logo:|photo:|video:|camera:|framing:|goal:|analyze:|bg-prompt:|context|slot)(?=\\s|$)",
262
262
  "captures": {
263
263
  "1": { "name": "keyword.control.brand-intent-purpose" }
264
264
  }
265
265
  },
266
266
  {
267
- "match": "^\\s+(label|samples|value|maxLength|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|override|audience|pillars|editable)(?=:|\\s|$)",
267
+ "match": "^\\s+(label|samples|value|maxLength|words|sentences|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|override|audience|pillars|editable)(?=:|\\s|$)",
268
268
  "captures": {
269
269
  "1": { "name": "support.type.property-name.brand-intent-purpose" }
270
270
  }