brand-intent 0.7.0 → 0.9.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/README.md +3 -3
- package/package.json +6 -2
- package/parser/brandParser.ts +410 -7
- package/parser/identityParser.ts +9 -0
- package/parser/index.ts +13 -0
- package/parser/types.ts +77 -0
- package/skills/brief.md +2 -2
package/README.md
CHANGED
|
@@ -44,10 +44,10 @@ npx brand-intent init
|
|
|
44
44
|
|
|
45
45
|
Two skills are now installed in your AI agent.
|
|
46
46
|
|
|
47
|
-
- **`/brand-intent`** authors, validates, and generates on-brand content from your
|
|
48
|
-
- **`/brief`** exports a portable `{brand-id}-brief.md`. Use it to hand the brand to a tool that doesn't read Brand Intent directly, like a Claude
|
|
47
|
+
- **`/brand-intent`** authors, validates, and generates on-brand content from your Brand Intent files.
|
|
48
|
+
- **`/brief`** exports a portable `{brand-id}-brief.md`. Use it to hand the brand to a tool that doesn't read Brand Intent directly, like a Claude Design project, another LLM, or a collaborator.
|
|
49
49
|
|
|
50
|
-
Edit your
|
|
50
|
+
Edit your Brand Intent files whenever strategy or expression evolves. Regenerate briefs after any change.
|
|
51
51
|
|
|
52
52
|
## Documentation
|
|
53
53
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brand-intent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -33,7 +33,11 @@
|
|
|
33
33
|
"design-system"
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
|
-
"release": "./scripts/release.sh"
|
|
36
|
+
"release": "./scripts/release.sh",
|
|
37
|
+
"test": "tsx tests/brandParser.test.ts && tsx tests/identityParser.test.ts && tsx tests/realBrands.test.ts"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"tsx": "^4.19.0"
|
|
37
41
|
},
|
|
38
42
|
"files": [
|
|
39
43
|
"cli/",
|
package/parser/brandParser.ts
CHANGED
|
@@ -6,6 +6,19 @@
|
|
|
6
6
|
* handles expression-layer primitives only (colors, typography, dividers, assets, badge styles).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type {
|
|
10
|
+
BrandColorGroup,
|
|
11
|
+
BrandColorEntry,
|
|
12
|
+
ColorMode,
|
|
13
|
+
PaletteStrategy,
|
|
14
|
+
Adherence,
|
|
15
|
+
AdherenceTolerances,
|
|
16
|
+
ColorBounds,
|
|
17
|
+
StateColors,
|
|
18
|
+
GroupKey,
|
|
19
|
+
NeutralRole,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
9
22
|
// ── Parsed type ────────────────────────────────────────────────────────────────
|
|
10
23
|
|
|
11
24
|
export interface ParsedBrand {
|
|
@@ -31,10 +44,26 @@ export interface ParsedBrand {
|
|
|
31
44
|
favicon?: string;
|
|
32
45
|
/** Path to OG image asset (relative to /public), used for social sharing previews */
|
|
33
46
|
ogImage?: string;
|
|
34
|
-
/** Core brand color primitives: name → hex (e.g. primary, secondary, accent1, accent2, white)
|
|
47
|
+
/** Core brand color primitives: name → hex (e.g. primary, secondary, accent1, accent2, white).
|
|
48
|
+
* Always present when any colors are declared — grouped-syntax brands get a flat projection
|
|
49
|
+
* generated from all entries, so legacy consumers keep working unchanged. */
|
|
35
50
|
brandColors?: Record<string, string>;
|
|
36
51
|
/** Structured print specs per brand color: name → {pantone?, hks?, cmyk?} */
|
|
37
52
|
brandColorPrint?: Record<string, BrandColorPrintSpec>;
|
|
53
|
+
/** Grouped-syntax representation (v0.8.0+). Present only when the `brand-colors` block uses the grouped form. */
|
|
54
|
+
colorGroups?: BrandColorGroup[];
|
|
55
|
+
/** Per-brand fixed state colors (problem/warning/success/info/inactive). */
|
|
56
|
+
stateColors?: StateColors;
|
|
57
|
+
/** Bounds for guided derivation (luminance + saturation floor). */
|
|
58
|
+
colorBounds?: ColorBounds;
|
|
59
|
+
/** Color resolution mode. Defaults behave as `static` when absent. */
|
|
60
|
+
colorMode?: ColorMode;
|
|
61
|
+
/** Palette strategy default for Path A (guided shuffle). Optional. */
|
|
62
|
+
colorStrategy?: PaletteStrategy;
|
|
63
|
+
/** Adherence level for guided derivation. Optional — engine-side default per level. */
|
|
64
|
+
colorAdherence?: Adherence;
|
|
65
|
+
/** Per-brand tolerance overrides for the declared adherence level. */
|
|
66
|
+
colorAdherenceOverrides?: AdherenceTolerances;
|
|
38
67
|
/** Inline themes: theme name → slot name → resolved hex color (only when tokensSource === 'inline') */
|
|
39
68
|
themes?: Record<string, Record<string, string>>;
|
|
40
69
|
/** Print annotations from inline # comments: slot name → annotation text */
|
|
@@ -62,6 +91,12 @@ export interface ParsedAssetDef {
|
|
|
62
91
|
lottieSrc?: string;
|
|
63
92
|
animated?: boolean;
|
|
64
93
|
loop?: boolean;
|
|
94
|
+
/** Path to a glTF/GLB 3D model. When present, the asset renders as a live 3D object; `src` remains the 2D fallback. */
|
|
95
|
+
gltfSrc?: string;
|
|
96
|
+
/** Procedural generator id (e.g. "mobius", "spiral", "ribbon"). Renders as live geometry; `src` is the 2D fallback. */
|
|
97
|
+
generator?: string;
|
|
98
|
+
/** Default parameters for the procedural generator, keyed by param name. Declared via the indented `generator-params` sub-block. */
|
|
99
|
+
generatorParams?: Record<string, number>;
|
|
65
100
|
}
|
|
66
101
|
|
|
67
102
|
export interface ParsedBadgeStyle {
|
|
@@ -131,6 +166,13 @@ export interface ParsedDividerDef {
|
|
|
131
166
|
|
|
132
167
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
133
168
|
|
|
169
|
+
const GROUP_KEYWORDS = new Set<string>(['primary', 'secondary', 'accent', 'neutral']);
|
|
170
|
+
const NEUTRAL_ROLES = new Set<NeutralRole>(['light', 'subtle', 'mid', 'muted', 'dark']);
|
|
171
|
+
const STATE_KEYS = new Set<string>(['problem', 'warning', 'success', 'info', 'inactive']);
|
|
172
|
+
const VALID_COLOR_MODES = new Set<ColorMode>(['static', 'dynamic', 'guided']);
|
|
173
|
+
const VALID_STRATEGIES = new Set<PaletteStrategy>(['monochrome', 'duotone', 'tricolor', 'spectrum']);
|
|
174
|
+
const VALID_ADHERENCES = new Set<Adherence>(['strict', 'on-brand', 'flexible']);
|
|
175
|
+
|
|
134
176
|
/**
|
|
135
177
|
* Expand a 3-digit hex to 6-digit. Pass-through for 6 or 8-digit.
|
|
136
178
|
* Returns null for invalid input.
|
|
@@ -244,7 +286,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
244
286
|
tokensSource: 'figma',
|
|
245
287
|
};
|
|
246
288
|
|
|
247
|
-
type ActiveBlock = 'none' | 'brand-colors' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta';
|
|
289
|
+
type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta';
|
|
248
290
|
let activeBlock: ActiveBlock = 'none';
|
|
249
291
|
let currentThemeName = '';
|
|
250
292
|
let currentDividerName = '';
|
|
@@ -253,6 +295,18 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
253
295
|
const brandColors: Record<string, string> = {};
|
|
254
296
|
const brandColorPrint: Record<string, BrandColorPrintSpec> = {};
|
|
255
297
|
let currentBrandColorName = '';
|
|
298
|
+
|
|
299
|
+
// Grouped brand-colors state (v0.8.0+).
|
|
300
|
+
// Shape is detected lazily: when the first non-blank indented line inside `brand-colors`
|
|
301
|
+
// is a bare group keyword, the block switches to grouped mode. Otherwise it stays flat.
|
|
302
|
+
type GroupedShape = 'unknown' | 'flat' | 'grouped';
|
|
303
|
+
let brandColorsShape: GroupedShape = 'unknown';
|
|
304
|
+
const colorGroups: BrandColorGroup[] = [];
|
|
305
|
+
let currentGroup: BrandColorGroup | null = null;
|
|
306
|
+
let currentGroupedEntry: BrandColorEntry | null = null;
|
|
307
|
+
const stateColorsRaw: Record<string, string> = {};
|
|
308
|
+
const colorBoundsRaw: Record<string, string> = {};
|
|
309
|
+
const adherenceOverridesRaw: Record<string, string> = {};
|
|
256
310
|
const themes: Record<string, Record<string, string>> = {};
|
|
257
311
|
const themeDescriptions: Record<string, string> = {};
|
|
258
312
|
const spacingRaw: Record<string, string> = {};
|
|
@@ -264,6 +318,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
264
318
|
const videosRaw: Array<{ id: string; props: Record<string, string> }> = [];
|
|
265
319
|
const illustrationsRaw: Array<{ id: string; props: Record<string, string> }> = [];
|
|
266
320
|
let currentAssetId = '';
|
|
321
|
+
let currentSubBlock: string | null = null;
|
|
267
322
|
const labelRaw: Record<string, string> = {};
|
|
268
323
|
const ctaRaw: Record<string, string> = {};
|
|
269
324
|
|
|
@@ -282,7 +337,123 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
282
337
|
if (isIndented) {
|
|
283
338
|
if (activeBlock === 'brand-colors') {
|
|
284
339
|
const trimmed = raw.trimStart();
|
|
340
|
+
if (trimmed === '') continue;
|
|
285
341
|
const indentLen = raw.length - trimmed.length;
|
|
342
|
+
|
|
343
|
+
// Shape detection: first non-blank indented line decides.
|
|
344
|
+
// Grouped = bare group keyword (`primary`/`secondary`/`accent`/`neutral`) at 2-space indent.
|
|
345
|
+
// Flat = `key: value` at 2-space indent.
|
|
346
|
+
if (brandColorsShape === 'unknown') {
|
|
347
|
+
if (GROUP_KEYWORDS.has(trimmed) && indentLen <= 2) {
|
|
348
|
+
brandColorsShape = 'grouped';
|
|
349
|
+
} else {
|
|
350
|
+
brandColorsShape = 'flat';
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (brandColorsShape === 'grouped') {
|
|
355
|
+
// ── Grouped shape ────────────────────────────────────────────────
|
|
356
|
+
const groupKwMatch = GROUP_KEYWORDS.has(trimmed) && indentLen <= 2;
|
|
357
|
+
if (groupKwMatch) {
|
|
358
|
+
const key = trimmed as GroupKey;
|
|
359
|
+
if (colorGroups.some(g => g.key === key)) {
|
|
360
|
+
throw new Error(`${fileName}:${lineNum}: duplicate color group "${key}"`);
|
|
361
|
+
}
|
|
362
|
+
currentGroup = { key, entries: [] };
|
|
363
|
+
colorGroups.push(currentGroup);
|
|
364
|
+
currentGroupedEntry = null;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!currentGroup) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`${fileName}:${lineNum}: grouped brand-colors entries must appear inside a group block (primary/secondary/accent/neutral)`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
// Group-level properties at 4-space indent OR color-entry header / sub-properties.
|
|
373
|
+
// We distinguish by: entry header = starts with `#` or `$`.
|
|
374
|
+
if (trimmed.startsWith('#') || trimmed.startsWith('$')) {
|
|
375
|
+
// New entry. Header forms:
|
|
376
|
+
// "#HEX Display Name"
|
|
377
|
+
// "$group.role"
|
|
378
|
+
if (trimmed.startsWith('#')) {
|
|
379
|
+
// Split header: "#HEX Name" — hex then whitespace then name
|
|
380
|
+
const headerMatch = trimmed.match(/^(#[0-9a-fA-F]{3,8})(?:\s+(.+))?$/);
|
|
381
|
+
if (!headerMatch) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`${fileName}:${lineNum}: expected "#HEX Name" entry header, got "${trimmed}"`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const hex = normalizeHex(headerMatch[1]);
|
|
387
|
+
if (!hex) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`${fileName}:${lineNum}: invalid hex "${headerMatch[1]}"`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const name = (headerMatch[2] ?? '').trim();
|
|
393
|
+
if (!name) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`${fileName}:${lineNum}: color entry needs a display name after the hex`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
currentGroupedEntry = { hex, name };
|
|
399
|
+
currentGroup.entries.push(currentGroupedEntry);
|
|
400
|
+
} else {
|
|
401
|
+
// $group.role reference — resolved in finalisation pass.
|
|
402
|
+
const refMatch = trimmed.match(/^\$([a-z]+)\.([a-z-]+)$/i);
|
|
403
|
+
if (!refMatch) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`${fileName}:${lineNum}: expected "$group.role" reference, got "${trimmed}"`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
currentGroupedEntry = {
|
|
409
|
+
hex: '',
|
|
410
|
+
name: '',
|
|
411
|
+
referencedFrom: `${refMatch[1]}.${refMatch[2]}`,
|
|
412
|
+
};
|
|
413
|
+
currentGroup.entries.push(currentGroupedEntry);
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
// `key: value` line — either group property (indent ≤ 4) or entry sub-property.
|
|
418
|
+
const colonIdx = trimmed.indexOf(':');
|
|
419
|
+
if (colonIdx === -1) continue;
|
|
420
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
421
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
422
|
+
// Group property lives at indent 4 and only appears before the first entry.
|
|
423
|
+
if (indentLen <= 4 && !currentGroupedEntry) {
|
|
424
|
+
if (key === 'meaning') {
|
|
425
|
+
currentGroup.meaning = value;
|
|
426
|
+
} else if (key === 'distribution') {
|
|
427
|
+
if (currentGroup.key === 'neutral') {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`${fileName}:${lineNum}: neutral group cannot declare distribution — it's the scale, not a selection target`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const pct = parseFloat(value.replace(/%$/, ''));
|
|
433
|
+
if (isNaN(pct)) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`${fileName}:${lineNum}: distribution "${value}" is not a number`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
currentGroup.distribution = pct;
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
// Entry sub-property.
|
|
443
|
+
if (!currentGroupedEntry) continue;
|
|
444
|
+
if (key === 'role') {
|
|
445
|
+
currentGroupedEntry.role = value;
|
|
446
|
+
if (value === 'default') currentGroupedEntry.isDefault = true;
|
|
447
|
+
} else if (key === 'meaning') {
|
|
448
|
+
currentGroupedEntry.meaning = value;
|
|
449
|
+
} else if (key === 'pantone' || key === 'hks' || key === 'cmyk') {
|
|
450
|
+
if (!currentGroupedEntry.print) currentGroupedEntry.print = {};
|
|
451
|
+
currentGroupedEntry.print[key] = value;
|
|
452
|
+
}
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Flat shape (legacy) ────────────────────────────────────────────
|
|
286
457
|
const colonIdx = trimmed.indexOf(':');
|
|
287
458
|
if (colonIdx !== -1) {
|
|
288
459
|
const key = trimmed.slice(0, colonIdx).trim();
|
|
@@ -308,6 +479,36 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
308
479
|
}
|
|
309
480
|
continue;
|
|
310
481
|
}
|
|
482
|
+
if (activeBlock === 'state-colors') {
|
|
483
|
+
const trimmed = raw.trimStart();
|
|
484
|
+
const colonIdx = trimmed.indexOf(':');
|
|
485
|
+
if (colonIdx !== -1) {
|
|
486
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
487
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
488
|
+
stateColorsRaw[key] = value;
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (activeBlock === 'color-bounds') {
|
|
493
|
+
const trimmed = raw.trimStart();
|
|
494
|
+
const colonIdx = trimmed.indexOf(':');
|
|
495
|
+
if (colonIdx !== -1) {
|
|
496
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
497
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
498
|
+
colorBoundsRaw[key] = value;
|
|
499
|
+
}
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (activeBlock === 'color-adherence') {
|
|
503
|
+
const trimmed = raw.trimStart();
|
|
504
|
+
const colonIdx = trimmed.indexOf(':');
|
|
505
|
+
if (colonIdx !== -1) {
|
|
506
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
507
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
508
|
+
adherenceOverridesRaw[key] = value;
|
|
509
|
+
}
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
311
512
|
if (activeBlock === 'theme' && currentThemeName) {
|
|
312
513
|
// slot: #HEX or $ref # optional comment
|
|
313
514
|
const colonIdx = raw.indexOf(':');
|
|
@@ -375,19 +576,33 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
375
576
|
continue;
|
|
376
577
|
}
|
|
377
578
|
if ((activeBlock === 'logo' || activeBlock === 'photo' || activeBlock === 'video' || activeBlock === 'illustration') && currentAssetId) {
|
|
579
|
+
const indent = raw.match(/^(\s*)/)?.[1].length ?? 0;
|
|
378
580
|
const colonIdx = raw.indexOf(':');
|
|
379
581
|
if (colonIdx !== -1) {
|
|
380
582
|
const key = raw.slice(0, colonIdx).trim();
|
|
381
583
|
const value = raw.slice(colonIdx + 1).trim();
|
|
382
584
|
const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : activeBlock === 'video' ? videosRaw : illustrationsRaw;
|
|
383
585
|
const last = arr[arr.length - 1];
|
|
384
|
-
if (last && last.id === currentAssetId)
|
|
586
|
+
if (last && last.id === currentAssetId) {
|
|
587
|
+
// 4+ space indent after a bare sub-block keyword (e.g. generator-params) → nested param.
|
|
588
|
+
// Store under "subBlock.key" so toAssetDef can split it out.
|
|
589
|
+
if (indent >= 4 && currentSubBlock) {
|
|
590
|
+
last.props[`${currentSubBlock}.${key}`] = value;
|
|
591
|
+
} else {
|
|
592
|
+
last.props[key] = value;
|
|
593
|
+
currentSubBlock = null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
385
596
|
} else {
|
|
386
597
|
const trimmed = raw.trim();
|
|
387
598
|
if (trimmed === 'animated' || trimmed === 'loop') {
|
|
388
599
|
const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : activeBlock === 'video' ? videosRaw : illustrationsRaw;
|
|
389
600
|
const last = arr[arr.length - 1];
|
|
390
601
|
if (last && last.id === currentAssetId) last.props[trimmed] = 'true';
|
|
602
|
+
currentSubBlock = null;
|
|
603
|
+
} else if (trimmed === 'generator-params') {
|
|
604
|
+
// Open a nested sub-block — subsequent deeper-indented key: value lines attach here.
|
|
605
|
+
currentSubBlock = 'generator-params';
|
|
391
606
|
}
|
|
392
607
|
}
|
|
393
608
|
continue;
|
|
@@ -406,14 +621,30 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
406
621
|
continue;
|
|
407
622
|
}
|
|
408
623
|
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
624
|
+
// Blank lines are ignored — they don't close active blocks (grouped brand-colors
|
|
625
|
+
// relies on blank lines between groups for readability).
|
|
412
626
|
if (raw.trim() === '') continue;
|
|
413
627
|
|
|
628
|
+
// Non-indented non-blank line ends any active block
|
|
629
|
+
activeBlock = 'none';
|
|
630
|
+
|
|
414
631
|
// brand-colors — starts the brand color primitives block
|
|
415
632
|
if (raw.trim() === 'brand-colors') {
|
|
416
633
|
activeBlock = 'brand-colors';
|
|
634
|
+
currentGroup = null;
|
|
635
|
+
currentGroupedEntry = null;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// state-colors — flat block of problem/warning/success/info/inactive
|
|
640
|
+
if (raw.trim() === 'state-colors') {
|
|
641
|
+
activeBlock = 'state-colors';
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// color-bounds — luminance + saturation floor for guided derivation
|
|
646
|
+
if (raw.trim() === 'color-bounds') {
|
|
647
|
+
activeBlock = 'color-bounds';
|
|
417
648
|
continue;
|
|
418
649
|
}
|
|
419
650
|
|
|
@@ -496,18 +727,178 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
496
727
|
case 'cta':
|
|
497
728
|
activeBlock = 'cta';
|
|
498
729
|
break;
|
|
730
|
+
case 'color-mode':
|
|
731
|
+
if (!VALID_COLOR_MODES.has(value as ColorMode)) {
|
|
732
|
+
throw new Error(`${fileName}:${lineNum}: color-mode must be "static", "dynamic", or "guided", got "${value}"`);
|
|
733
|
+
}
|
|
734
|
+
result.colorMode = value as ColorMode;
|
|
735
|
+
break;
|
|
736
|
+
case 'color-strategy':
|
|
737
|
+
if (!VALID_STRATEGIES.has(value as PaletteStrategy)) {
|
|
738
|
+
throw new Error(`${fileName}:${lineNum}: color-strategy must be one of monochrome/duotone/tricolor/spectrum, got "${value}"`);
|
|
739
|
+
}
|
|
740
|
+
result.colorStrategy = value as PaletteStrategy;
|
|
741
|
+
break;
|
|
742
|
+
case 'color-adherence':
|
|
743
|
+
if (!VALID_ADHERENCES.has(value as Adherence)) {
|
|
744
|
+
throw new Error(`${fileName}:${lineNum}: color-adherence must be "strict", "on-brand", or "flexible", got "${value}"`);
|
|
745
|
+
}
|
|
746
|
+
result.colorAdherence = value as Adherence;
|
|
747
|
+
// Optional indented overrides follow.
|
|
748
|
+
activeBlock = 'color-adherence';
|
|
749
|
+
break;
|
|
499
750
|
default:
|
|
500
751
|
// Unknown keys silently ignored for forward compatibility
|
|
501
752
|
break;
|
|
502
753
|
}
|
|
503
754
|
}
|
|
504
755
|
|
|
505
|
-
//
|
|
756
|
+
// ── Finalise grouped brand-colors (v0.8.0+) ────────────────────────────────
|
|
757
|
+
if (colorGroups.length > 0) {
|
|
758
|
+
// Resolve $group.role references in declaration order — a reference may only point
|
|
759
|
+
// to a group earlier in the list than its containing group.
|
|
760
|
+
const resolvedNames = new Set<string>();
|
|
761
|
+
for (let gi = 0; gi < colorGroups.length; gi++) {
|
|
762
|
+
const group = colorGroups[gi];
|
|
763
|
+
for (const entry of group.entries) {
|
|
764
|
+
if (!entry.referencedFrom) {
|
|
765
|
+
// Normal entry — ensure its name doesn't collide (important for flat projection).
|
|
766
|
+
if (resolvedNames.has(entry.name)) {
|
|
767
|
+
throw new Error(`${fileName}: duplicate color name "${entry.name}" in brand-colors`);
|
|
768
|
+
}
|
|
769
|
+
resolvedNames.add(entry.name);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
// Reference shape: "<group>.<role>"
|
|
773
|
+
const [refGroupKey, refRole] = entry.referencedFrom.split('.');
|
|
774
|
+
const refGroupIdx = colorGroups.findIndex(g => g.key === refGroupKey);
|
|
775
|
+
if (refGroupIdx === -1) {
|
|
776
|
+
throw new Error(`${fileName}: reference "$${entry.referencedFrom}" targets unknown group "${refGroupKey}"`);
|
|
777
|
+
}
|
|
778
|
+
if (refGroupIdx >= gi) {
|
|
779
|
+
throw new Error(`${fileName}: reference "$${entry.referencedFrom}" must point to a group declared earlier (forward/self references forbidden)`);
|
|
780
|
+
}
|
|
781
|
+
const refGroup = colorGroups[refGroupIdx];
|
|
782
|
+
// Find the target entry. `default` matches explicit or first-declared default.
|
|
783
|
+
let target: BrandColorEntry | undefined;
|
|
784
|
+
if (refRole === 'default') {
|
|
785
|
+
target = refGroup.entries.find(e => e.isDefault) ?? refGroup.entries[0];
|
|
786
|
+
} else {
|
|
787
|
+
target = refGroup.entries.find(e => e.role === refRole);
|
|
788
|
+
}
|
|
789
|
+
if (!target) {
|
|
790
|
+
throw new Error(`${fileName}: reference "$${entry.referencedFrom}" — no entry with role "${refRole}" in group "${refGroupKey}"`);
|
|
791
|
+
}
|
|
792
|
+
entry.hex = target.hex;
|
|
793
|
+
entry.name = target.name;
|
|
794
|
+
// Don't add referenced name to resolvedNames — it's already registered under the target.
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Apply default selection rule for identity groups.
|
|
798
|
+
if (group.key !== 'neutral' && group.entries.length > 0) {
|
|
799
|
+
const explicit = group.entries.find(e => e.isDefault);
|
|
800
|
+
if (!explicit) {
|
|
801
|
+
// First-declared wins implicitly.
|
|
802
|
+
group.entries[0].isDefault = true;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Neutral-group role validation.
|
|
807
|
+
if (group.key === 'neutral') {
|
|
808
|
+
for (const entry of group.entries) {
|
|
809
|
+
if (entry.role && !NEUTRAL_ROLES.has(entry.role as NeutralRole)) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`${fileName}: neutral entry "${entry.name}" role "${entry.role}" is not in the fixed vocabulary (light | subtle | mid | muted | dark)`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
result.colorGroups = colorGroups;
|
|
819
|
+
|
|
820
|
+
// Build flat projection so legacy consumers keep working unchanged.
|
|
821
|
+
// Key convention: the entry's display name lowercased, spaces → hyphens.
|
|
822
|
+
// Name collisions already rejected above, so the projection is deterministic.
|
|
823
|
+
for (const group of colorGroups) {
|
|
824
|
+
for (const entry of group.entries) {
|
|
825
|
+
const key = entry.name.toLowerCase().replace(/\s+/g, '-');
|
|
826
|
+
if (!brandColors[key]) {
|
|
827
|
+
brandColors[key] = entry.hex;
|
|
828
|
+
if (entry.print) brandColorPrint[key] = entry.print;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Store brand colors (flat projection)
|
|
506
835
|
if (Object.keys(brandColors).length > 0) {
|
|
507
836
|
result.brandColors = brandColors;
|
|
508
837
|
if (Object.keys(brandColorPrint).length > 0) result.brandColorPrint = brandColorPrint;
|
|
509
838
|
}
|
|
510
839
|
|
|
840
|
+
// ── Finalise state-colors / color-bounds / adherence overrides ────────────
|
|
841
|
+
if (Object.keys(stateColorsRaw).length > 0) {
|
|
842
|
+
const state: StateColors = {};
|
|
843
|
+
for (const [key, value] of Object.entries(stateColorsRaw)) {
|
|
844
|
+
if (!STATE_KEYS.has(key)) continue; // forward-compat: ignore unknown
|
|
845
|
+
const hex = normalizeHex(value);
|
|
846
|
+
if (!hex) {
|
|
847
|
+
throw new Error(`${fileName}: state-colors "${key}" value "${value}" is not a valid hex`);
|
|
848
|
+
}
|
|
849
|
+
(state as Record<string, string>)[key] = hex;
|
|
850
|
+
}
|
|
851
|
+
if (Object.keys(state).length > 0) result.stateColors = state;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (Object.keys(colorBoundsRaw).length > 0) {
|
|
855
|
+
const bounds: ColorBounds = {};
|
|
856
|
+
if (colorBoundsRaw.light !== undefined) bounds.light = parseFloat(colorBoundsRaw.light);
|
|
857
|
+
if (colorBoundsRaw.dark !== undefined) bounds.dark = parseFloat(colorBoundsRaw.dark);
|
|
858
|
+
if (colorBoundsRaw['saturation-floor'] !== undefined) {
|
|
859
|
+
bounds.saturationFloor = parseFloat(colorBoundsRaw['saturation-floor']);
|
|
860
|
+
}
|
|
861
|
+
result.colorBounds = bounds;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (Object.keys(adherenceOverridesRaw).length > 0) {
|
|
865
|
+
const overrides: AdherenceTolerances = {};
|
|
866
|
+
if (adherenceOverridesRaw['hue-tolerance'] !== undefined) {
|
|
867
|
+
overrides.hueTolerance = parseFloat(adherenceOverridesRaw['hue-tolerance']);
|
|
868
|
+
}
|
|
869
|
+
if (adherenceOverridesRaw['sat-tolerance'] !== undefined) {
|
|
870
|
+
overrides.satTolerance = parseFloat(adherenceOverridesRaw['sat-tolerance']);
|
|
871
|
+
}
|
|
872
|
+
if (Object.keys(overrides).length > 0) result.colorAdherenceOverrides = overrides;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── Minimum-structure validation for color-mode: guided ───────────────────
|
|
876
|
+
if (result.colorMode === 'guided') {
|
|
877
|
+
if (colorGroups.length === 0) {
|
|
878
|
+
throw new Error(
|
|
879
|
+
`${fileName}: color-mode: guided requires grouped brand-colors syntax — flat syntax is not sufficient. See the Minimum Structure section in the guided-colors spec.`,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
const primary = colorGroups.find(g => g.key === 'primary');
|
|
883
|
+
const neutral = colorGroups.find(g => g.key === 'neutral');
|
|
884
|
+
if (!primary) {
|
|
885
|
+
throw new Error(`${fileName}: color-mode: guided requires a "primary" group`);
|
|
886
|
+
}
|
|
887
|
+
if (!neutral) {
|
|
888
|
+
throw new Error(`${fileName}: color-mode: guided requires a "neutral" group`);
|
|
889
|
+
}
|
|
890
|
+
const neutralRoles = new Set(neutral.entries.map(e => e.role).filter(Boolean));
|
|
891
|
+
if (!neutralRoles.has('light')) {
|
|
892
|
+
throw new Error(`${fileName}: color-mode: guided requires neutral group to declare at least role "light"`);
|
|
893
|
+
}
|
|
894
|
+
if (!neutralRoles.has('dark')) {
|
|
895
|
+
throw new Error(`${fileName}: color-mode: guided requires neutral group to declare at least role "dark"`);
|
|
896
|
+
}
|
|
897
|
+
if (primary.entries.length === 0) {
|
|
898
|
+
throw new Error(`${fileName}: color-mode: guided requires primary group to have at least one color`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
511
902
|
// Finalise inline tokens — resolve $ref values against brandColors, apply optional alpha.
|
|
512
903
|
//
|
|
513
904
|
// Theme slot grammar:
|
|
@@ -616,6 +1007,18 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
616
1007
|
if (raw.props['lottie']) def.lottieSrc = raw.props['lottie'];
|
|
617
1008
|
if (raw.props['animated'] === 'true') def.animated = true;
|
|
618
1009
|
if (raw.props['loop'] === 'true') def.loop = true;
|
|
1010
|
+
if (raw.props['gltf']) def.gltfSrc = raw.props['gltf'];
|
|
1011
|
+
if (raw.props['generator']) def.generator = raw.props['generator'];
|
|
1012
|
+
// generator-params are stored as "generator-params.KEY" by the sub-block parser.
|
|
1013
|
+
const paramEntries: [string, number][] = [];
|
|
1014
|
+
for (const [k, v] of Object.entries(raw.props)) {
|
|
1015
|
+
if (k.startsWith('generator-params.')) {
|
|
1016
|
+
const paramKey = k.slice('generator-params.'.length);
|
|
1017
|
+
const n = parseFloat(v);
|
|
1018
|
+
if (!Number.isNaN(n)) paramEntries.push([paramKey, n]);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (paramEntries.length > 0) def.generatorParams = Object.fromEntries(paramEntries);
|
|
619
1022
|
return def;
|
|
620
1023
|
};
|
|
621
1024
|
if (logosRaw.length > 0 || photosRaw.length > 0 || videosRaw.length > 0 || illustrationsRaw.length > 0) {
|
package/parser/identityParser.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface ParsedIdentity {
|
|
|
26
26
|
tagline?: string;
|
|
27
27
|
/** Competitive positioning — what makes this brand the only one of its kind */
|
|
28
28
|
positioning?: string;
|
|
29
|
+
/** Brand purpose — why the brand exists, the inner motivation (distinct from promise, mission, vision) */
|
|
30
|
+
purpose?: string;
|
|
29
31
|
/** Organizational mission — what the brand does, for whom */
|
|
30
32
|
mission?: string;
|
|
31
33
|
/** Aspirational future state */
|
|
@@ -109,6 +111,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
109
111
|
let promise = '';
|
|
110
112
|
let tagline = '';
|
|
111
113
|
let positioning = '';
|
|
114
|
+
let purpose = '';
|
|
112
115
|
let mission = '';
|
|
113
116
|
let vision = '';
|
|
114
117
|
let archetype = '';
|
|
@@ -195,6 +198,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
195
198
|
if (continuationKey === 'essence') essence += ' ' + continuation;
|
|
196
199
|
else if (continuationKey === 'promise') promise += ' ' + continuation;
|
|
197
200
|
else if (continuationKey === 'positioning') positioning += ' ' + continuation;
|
|
201
|
+
else if (continuationKey === 'purpose') purpose += ' ' + continuation;
|
|
198
202
|
else if (continuationKey === 'mission') mission += ' ' + continuation;
|
|
199
203
|
else if (continuationKey === 'vision') vision += ' ' + continuation;
|
|
200
204
|
continue;
|
|
@@ -300,6 +304,10 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
300
304
|
positioning = val;
|
|
301
305
|
continuationKey = 'positioning';
|
|
302
306
|
break;
|
|
307
|
+
case 'purpose':
|
|
308
|
+
purpose = val;
|
|
309
|
+
continuationKey = 'purpose';
|
|
310
|
+
break;
|
|
303
311
|
case 'mission':
|
|
304
312
|
mission = val;
|
|
305
313
|
continuationKey = 'mission';
|
|
@@ -425,6 +433,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
425
433
|
// Optional fields — only include if present
|
|
426
434
|
if (tagline) result.tagline = tagline;
|
|
427
435
|
if (positioning) result.positioning = positioning;
|
|
436
|
+
if (purpose) result.purpose = purpose;
|
|
428
437
|
if (mission) result.mission = mission;
|
|
429
438
|
if (vision) result.vision = vision;
|
|
430
439
|
if (archetype) result.archetype = archetype;
|
package/parser/index.ts
CHANGED
|
@@ -7,6 +7,19 @@ export type { ParsedIdentity, ParsedVoice, ParsedPillars, ParsedAudience, Parsed
|
|
|
7
7
|
|
|
8
8
|
export { parseBrandFile } from './brandParser';
|
|
9
9
|
export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle } from './brandParser';
|
|
10
|
+
export type {
|
|
11
|
+
ColorMode,
|
|
12
|
+
PaletteStrategy,
|
|
13
|
+
Adherence,
|
|
14
|
+
AdherenceTolerances,
|
|
15
|
+
ColorBounds,
|
|
16
|
+
StateColors,
|
|
17
|
+
GroupKey,
|
|
18
|
+
IdentityGroupKey,
|
|
19
|
+
NeutralRole,
|
|
20
|
+
BrandColorEntry,
|
|
21
|
+
BrandColorGroup,
|
|
22
|
+
} from './types';
|
|
10
23
|
|
|
11
24
|
export { parseFormatFile } from './formatParser';
|
|
12
25
|
export type { ParsedFormat } from './formatParser';
|
package/parser/types.ts
CHANGED
|
@@ -15,3 +15,80 @@ export interface ZoneInsets {
|
|
|
15
15
|
|
|
16
16
|
/** Text alignment options. */
|
|
17
17
|
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
|
|
18
|
+
|
|
19
|
+
// ── Guided colors (v0.8.0) ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Color resolution mode declared at the brand level. */
|
|
22
|
+
export type ColorMode = 'static' | 'dynamic' | 'guided';
|
|
23
|
+
|
|
24
|
+
/** Palette construction strategy for guided shuffle (Path A only). */
|
|
25
|
+
export type PaletteStrategy = 'monochrome' | 'duotone' | 'tricolor' | 'spectrum';
|
|
26
|
+
|
|
27
|
+
/** Adherence level — how tightly guided derivation follows brand groups. */
|
|
28
|
+
export type Adherence = 'strict' | 'on-brand' | 'flexible';
|
|
29
|
+
|
|
30
|
+
/** Optional per-brand tolerance overrides for the declared adherence level. */
|
|
31
|
+
export interface AdherenceTolerances {
|
|
32
|
+
/** Hue drift tolerance in degrees. */
|
|
33
|
+
hueTolerance?: number;
|
|
34
|
+
/** Saturation variance tolerance in percentage points. */
|
|
35
|
+
satTolerance?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bounds for all guided derivation — brand scales, state variants, neutral interpolation. */
|
|
39
|
+
export interface ColorBounds {
|
|
40
|
+
/** Max luminance for derived tints (0–1). Never brighter than this. */
|
|
41
|
+
light?: number;
|
|
42
|
+
/** Min luminance for derived shades (0–1). Never darker than this. */
|
|
43
|
+
dark?: number;
|
|
44
|
+
/** Min saturation (0–100) for derived colors — prevents washed-out grays. */
|
|
45
|
+
saturationFloor?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Fixed per-brand state (problem / success / warning / info / inactive) colors. */
|
|
49
|
+
export interface StateColors {
|
|
50
|
+
problem?: string;
|
|
51
|
+
warning?: string;
|
|
52
|
+
success?: string;
|
|
53
|
+
info?: string;
|
|
54
|
+
inactive?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Identity group keys (neutral is structurally distinct). */
|
|
58
|
+
export type IdentityGroupKey = 'primary' | 'secondary' | 'accent';
|
|
59
|
+
/** All four group keys. */
|
|
60
|
+
export type GroupKey = IdentityGroupKey | 'neutral';
|
|
61
|
+
|
|
62
|
+
/** Fixed neutral-role vocabulary — ordered light → dark. */
|
|
63
|
+
export type NeutralRole = 'light' | 'subtle' | 'mid' | 'muted' | 'dark';
|
|
64
|
+
|
|
65
|
+
/** One entry within a brand color group. */
|
|
66
|
+
export interface BrandColorEntry {
|
|
67
|
+
/** Resolved 6-digit hex (uppercase). */
|
|
68
|
+
hex: string;
|
|
69
|
+
/** Display name as authored (`#HEX Name` → "Name"). Reference entries inherit the target's name. */
|
|
70
|
+
name: string;
|
|
71
|
+
/** Authored role (identity groups) or fixed neutral-role vocabulary (neutral group). */
|
|
72
|
+
role?: string;
|
|
73
|
+
/** Marks this entry as the group default. Only meaningful in identity groups. */
|
|
74
|
+
isDefault?: boolean;
|
|
75
|
+
/** Authored meaning string — human-facing only, engine ignores. */
|
|
76
|
+
meaning?: string;
|
|
77
|
+
/** When the entry was declared via a `$group.role` reference, the reference path (informational only). */
|
|
78
|
+
referencedFrom?: string;
|
|
79
|
+
/** Print specs authored on this entry (Pantone, HKS, CMYK). */
|
|
80
|
+
print?: { pantone?: string; hks?: string; cmyk?: string };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One color group within the `brand-colors` block. */
|
|
84
|
+
export interface BrandColorGroup {
|
|
85
|
+
/** Group key — fixed vocabulary. */
|
|
86
|
+
key: GroupKey;
|
|
87
|
+
/** Authored meaning — human-facing only. */
|
|
88
|
+
meaning?: string;
|
|
89
|
+
/** Selection-probability weight (0–100). Invalid on neutral. */
|
|
90
|
+
distribution?: number;
|
|
91
|
+
/** Ordered entries as declared. */
|
|
92
|
+
entries: BrandColorEntry[];
|
|
93
|
+
}
|
|
94
|
+
|
package/skills/brief.md
CHANGED
|
@@ -10,7 +10,7 @@ description: Generate a portable markdown brand brief from Brand Intent DSL file
|
|
|
10
10
|
> If `$ARGUMENTS` names a brand id (e.g. "krume", "studio-stoll"): locate that brand folder under the current working directory and generate its brief.
|
|
11
11
|
> If no brand is found: tell the user to run `/brand-intent onboard` (or `npx brand-intent init` to scaffold the Krume example) and stop.
|
|
12
12
|
|
|
13
|
-
You are generating a **portable brand brief** — a single markdown file, `{brand-id}-brief.md`, written at the brand root. The brief is format-agnostic: it can be dropped into any tool that doesn't read Brand Intent
|
|
13
|
+
You are generating a **portable brand brief** — a single markdown file, `{brand-id}-brief.md`, written at the brand root. The brief is format-agnostic: it can be dropped into any tool that doesn't read Brand Intent files directly — a Claude Design project, another LLM, a human collaborator.
|
|
14
14
|
|
|
15
15
|
The brief is **derived**, not authored. Every value comes verbatim from the DSL files. You never rephrase, interpret, or invent.
|
|
16
16
|
|
|
@@ -194,4 +194,4 @@ Generated by `/brief` from brand-intent v{version}. To change anything in this b
|
|
|
194
194
|
- **Omit absent optional sections entirely.** No empty headers, no "none" placeholders.
|
|
195
195
|
- **Never add `guardrails:` to the DSL.** The Guardrails section in the brief is always derived from existing `.identity` fields.
|
|
196
196
|
- **Never invent.** If `voice.always` is empty, the Always list is empty. Don't fill gaps.
|
|
197
|
-
- **Claude
|
|
197
|
+
- **Claude Design is an example, not a dependency.** This brief works anywhere. Don't phrase it as Claude-specific.
|