@stacklist-app/brandkit 1.1.1 → 1.2.1

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/lib/export.js ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Agent-native exports — project a render-shaped config.json into machine-first
3
+ * views an AI agent or design-token tool can consume without scraping the HTML.
4
+ *
5
+ * buildBrandJson(config) → normalized, semantic brand (roles, usage, contrast, asset paths)
6
+ * buildTokensJson(config) → W3C Design Tokens (DTCG) format for token tooling
7
+ * buildBrandMarkdown(config) → an LLM brief ("how to be on-brand") a.k.a. llms.txt
8
+ *
9
+ * Pure functions, Node builtins only. Contrast is computed via generate-helpers.
10
+ */
11
+ var fs = require('fs');
12
+ var path = require('path');
13
+ var helpers = require('./generate-helpers');
14
+
15
+ var HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
16
+
17
+ // Map format aliases to the canonical set.
18
+ function normalizeFormats(formats) {
19
+ if (!formats || formats === 'all') return ['json', 'dtcg', 'md'];
20
+ var list = Array.isArray(formats) ? formats : [formats];
21
+ var canon = [];
22
+ list.forEach(function (f) {
23
+ f = String(f).toLowerCase();
24
+ if (f === 'all') { canon = ['json', 'dtcg', 'md']; }
25
+ else if (f === 'json' || f === 'brand') { if (canon.indexOf('json') === -1) canon.push('json'); }
26
+ else if (f === 'dtcg' || f === 'tokens') { if (canon.indexOf('dtcg') === -1) canon.push('dtcg'); }
27
+ else if (f === 'md' || f === 'markdown' || f === 'llms') { if (canon.indexOf('md') === -1) canon.push('md'); }
28
+ });
29
+ // Return canon as-is (possibly empty) so an unknown format surfaces as
30
+ // "nothing written" rather than silently writing every file.
31
+ return canon;
32
+ }
33
+
34
+ /**
35
+ * Write the requested export files into targetDir. Returns the written
36
+ * filenames and the brand.json object (so callers can also embed it).
37
+ */
38
+ function writeExports(config, targetDir, formats) {
39
+ var want = normalizeFormats(formats);
40
+ var written = [];
41
+ var brandJson = null;
42
+ if (want.indexOf('json') !== -1) {
43
+ brandJson = buildBrandJson(config);
44
+ fs.writeFileSync(path.join(targetDir, 'brand.json'), JSON.stringify(brandJson, null, 2) + '\n');
45
+ written.push('brand.json');
46
+ }
47
+ if (want.indexOf('dtcg') !== -1) {
48
+ fs.writeFileSync(path.join(targetDir, 'tokens.json'), JSON.stringify(buildTokensJson(config), null, 2) + '\n');
49
+ written.push('tokens.json');
50
+ }
51
+ if (want.indexOf('md') !== -1) {
52
+ fs.writeFileSync(path.join(targetDir, 'brand.md'), buildBrandMarkdown(config));
53
+ written.push('brand.md');
54
+ }
55
+ return { written: written, brandJson: brandJson };
56
+ }
57
+
58
+ function isHex(v) {
59
+ return typeof v === 'string' && HEX_RE.test(v);
60
+ }
61
+
62
+ // "#4338CA on #FFFFFF" → { ratio: "7.9:1", rating: "AAA" }
63
+ function contrast(fg, bg) {
64
+ if (!isHex(fg) || !isHex(bg)) return null;
65
+ var ratio = helpers.contrastRatio(fg, bg);
66
+ return { ratio: ratio, rating: helpers.rateContrast(ratio) };
67
+ }
68
+
69
+ function themeVal(config, key) {
70
+ return config.theme && config.theme[key];
71
+ }
72
+
73
+ /**
74
+ * Normalized, agent-first brand. Encodes roles + usage + contrast, not layout.
75
+ */
76
+ function buildBrandJson(config) {
77
+ config = config || {};
78
+ var brand = config.brand || {};
79
+ var theme = config.theme || {};
80
+ var fonts = config.fonts || {};
81
+ var colors = config.colors || {};
82
+
83
+ var accentFill = theme['--accent'];
84
+ var accentOnFill = theme['--accent-foreground'];
85
+ var accentText = theme['--accent-text'];
86
+
87
+ var out = {
88
+ name: brand.name || null,
89
+ displayName: brand.displayName || brand.name || null,
90
+ tagline: brand.tagline || null,
91
+ description: brand.description || null,
92
+ url: brand.url || null,
93
+ version: brand.version || null,
94
+ generatedBy: 'brandkit',
95
+ voice: config.voice ? {
96
+ summary: config.voice.description || null,
97
+ do: config.voice.do || [],
98
+ dont: config.voice.dont || []
99
+ } : null,
100
+ color: {
101
+ accent: accentFill ? {
102
+ fill: accentFill,
103
+ onFill: accentOnFill || null,
104
+ asText: accentText || null,
105
+ contrast: {
106
+ onFill: accentOnFill ? contrast(accentOnFill, accentFill) : null,
107
+ asTextOnWhite: accentText ? contrast(accentText, theme['--white'] || '#FFFFFF') : null
108
+ }
109
+ } : null,
110
+ // Only surfaced when a brand separates its primary action from the accent.
111
+ primary: theme['--primary'] ? {
112
+ fill: theme['--primary'],
113
+ onFill: theme['--primary-foreground'] || null,
114
+ contrast: {
115
+ onFill: theme['--primary-foreground'] ? contrast(theme['--primary-foreground'], theme['--primary']) : null
116
+ }
117
+ } : null,
118
+ brand: projectColorGroup(colors.brand),
119
+ neutral: projectColorGroup(colors.neutrals),
120
+ semantic: projectColorGroup(colors.semantic)
121
+ },
122
+ gradient: (theme['--gradient-brand'] || config.gradientUsage) ? {
123
+ brand: theme['--gradient-brand'] || null,
124
+ subtle: theme['--gradient-brand-subtle'] || null,
125
+ usage: config.gradientUsage ? {
126
+ do: config.gradientUsage.do || [],
127
+ dont: config.gradientUsage.dont || []
128
+ } : null
129
+ } : null,
130
+ type: {
131
+ display: projectFont(fonts.display),
132
+ body: projectFont(fonts.body),
133
+ scale: (config.typography || []).map(function (t) {
134
+ return { name: t.name, size: t.size, weight: t.weight, font: t.font };
135
+ })
136
+ },
137
+ spacing: (config.spacing || []).map(function (s) {
138
+ return { token: s.token, px: s.px };
139
+ }),
140
+ logos: (config.logos || []).map(function (l) {
141
+ var variants = l.variants || {};
142
+ var formats = Object.keys(variants);
143
+ return {
144
+ name: l.name,
145
+ description: l.description || null,
146
+ use: backgroundToUse(l.background),
147
+ formats: formats,
148
+ src: variants.svg || variants.png || variants.jpg || (formats.length ? variants[formats[0]] : null),
149
+ variants: variants
150
+ };
151
+ }),
152
+ accessibility: (config.accessibility || []).map(function (a) {
153
+ return {
154
+ pair: (a.fgName || a.fg || '') + ' on ' + (a.bgName || a.bg || ''),
155
+ fg: a.fg, bg: a.bg, ratio: a.ratio, rating: a.rating
156
+ };
157
+ })
158
+ };
159
+
160
+ return prune(out);
161
+ }
162
+
163
+ function projectColorGroup(group) {
164
+ if (!group) return [];
165
+ var items = group.items || group;
166
+ if (!Array.isArray(items)) return [];
167
+ return items.map(function (c) {
168
+ return prune({
169
+ name: c.name || c.label || null,
170
+ hex: c.hex || null,
171
+ oklch: c.oklch || null,
172
+ role: c.role || c.usage || null,
173
+ token: c.cssVar || c.token || null
174
+ });
175
+ });
176
+ }
177
+
178
+ function projectFont(f) {
179
+ if (!f) return null;
180
+ return prune({ family: f.family || null, googleImport: f.googleImport || null, description: f.description || null });
181
+ }
182
+
183
+ // "Space Grotesk — Space Grotesk — a geometric sans" → "Space Grotesk — a geometric sans"
184
+ function fontLine(f) {
185
+ var family = f.family || '';
186
+ var desc = f.description || '';
187
+ if (desc) {
188
+ var stripped = desc.replace(new RegExp('^' + family.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*[—:-]\\s*'), '');
189
+ return family + (stripped ? ' — ' + stripped : '');
190
+ }
191
+ return family;
192
+ }
193
+
194
+ function backgroundToUse(bg) {
195
+ if (bg === 'light') return 'light backgrounds';
196
+ if (bg === 'dark') return 'dark backgrounds';
197
+ if (bg === 'gradient') return 'the brand gradient';
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * W3C Design Tokens (DTCG) — color / fontFamily / dimension groups.
203
+ * Strict-ish so Style Dictionary, Tokens Studio, and Figma can read it.
204
+ */
205
+ function buildTokensJson(config) {
206
+ config = config || {};
207
+ var theme = config.theme || {};
208
+ var fonts = config.fonts || {};
209
+ var brand = config.brand || {};
210
+
211
+ var color = {};
212
+ Object.keys(theme).forEach(function (key) {
213
+ var val = theme[key];
214
+ if (isHex(val)) {
215
+ color[key.replace(/^--/, '')] = { $type: 'color', $value: val };
216
+ }
217
+ });
218
+
219
+ var fontFamily = {};
220
+ if (fonts.display && fonts.display.family) fontFamily.display = { $type: 'fontFamily', $value: fonts.display.family };
221
+ if (fonts.body && fonts.body.family) fontFamily.body = { $type: 'fontFamily', $value: fonts.body.family };
222
+
223
+ var dimension = {};
224
+ (config.spacing || []).forEach(function (s) {
225
+ if (s.token && (s.px || s.px === 0)) dimension[s.token] = { $type: 'dimension', $value: s.px + 'px' };
226
+ });
227
+
228
+ var out = {
229
+ $description: 'Design tokens for ' + (brand.displayName || brand.name || 'brand') + ' — W3C DTCG format, generated by brandkit'
230
+ };
231
+ if (Object.keys(color).length) out.color = color;
232
+ if (Object.keys(fontFamily).length) out.fontFamily = fontFamily;
233
+ if (Object.keys(dimension).length) out.dimension = dimension;
234
+ return out;
235
+ }
236
+
237
+ /**
238
+ * LLM brief — drop into an agent's context to write/design on-brand.
239
+ */
240
+ function buildBrandMarkdown(config) {
241
+ config = config || {};
242
+ var brand = config.brand || {};
243
+ var theme = config.theme || {};
244
+ var fonts = config.fonts || {};
245
+ var name = brand.displayName || brand.name || 'Brand';
246
+ var L = [];
247
+
248
+ L.push('# ' + name + ' — Brand Brief');
249
+ L.push('');
250
+ L.push('> Machine-readable brand guidance generated by brandkit from `config.json`.');
251
+ L.push('> Companion files: `brand.json` (full structured brand), `tokens.json` (W3C design tokens).');
252
+ if (brand.description) { L.push(''); L.push(brand.description); }
253
+
254
+ if (config.voice) {
255
+ L.push(''); L.push('## Voice & Tone');
256
+ if (config.voice.description) L.push(config.voice.description);
257
+ if (config.voice.do && config.voice.do.length) {
258
+ L.push(''); L.push('**Do:**');
259
+ config.voice.do.forEach(function (d) { L.push('- ' + d); });
260
+ }
261
+ if (config.voice.dont && config.voice.dont.length) {
262
+ L.push(''); L.push("**Don't:**");
263
+ config.voice.dont.forEach(function (d) { L.push('- ' + d); });
264
+ }
265
+ }
266
+
267
+ L.push(''); L.push('## Color');
268
+ var accent = theme['--accent'];
269
+ if (accent) {
270
+ var onFill = theme['--accent-foreground'];
271
+ var asText = theme['--accent-text'];
272
+ var c1 = onFill ? contrast(onFill, accent) : null;
273
+ var c2 = asText ? contrast(asText, theme['--white'] || '#FFFFFF') : null;
274
+ L.push('- **Accent (fill):** `' + accent + '`' + (onFill ? ' — put `' + onFill + '` text/icons on it' + (c1 ? ' (' + c1.ratio + ' ' + c1.rating + ')' : '') : '') + '.');
275
+ if (asText) L.push('- **Accent as text/links on light:** `' + asText + '`' + (c2 ? ' (' + c2.ratio + ' ' + c2.rating + ' on white)' : '') + '.');
276
+ }
277
+ var primary = theme['--primary'];
278
+ if (primary && primary !== accent) {
279
+ var pOn = theme['--primary-foreground'];
280
+ var pc = pOn ? contrast(pOn, primary) : null;
281
+ L.push('- **Primary action (CTA fill):** `' + primary + '`' + (pOn ? ' with `' + pOn + '` text' + (pc ? ' (' + pc.ratio + ' ' + pc.rating + ')' : '') : '') + '.');
282
+ }
283
+ var sem = [];
284
+ ['--success', '--warning', '--error'].forEach(function (k) {
285
+ if (theme[k]) sem.push(k.replace('--', '') + ' `' + theme[k] + '`');
286
+ });
287
+ if (sem.length) L.push('- **Semantic:** ' + sem.join(', ') + '.');
288
+ var neutralBits = [];
289
+ if (theme['--ink']) neutralBits.push('Ink `' + theme['--ink'] + '` (primary text)');
290
+ if (theme['--slate']) neutralBits.push('Slate `' + theme['--slate'] + '` (muted)');
291
+ if (theme['--cloud']) neutralBits.push('Cloud `' + theme['--cloud'] + '` (page bg)');
292
+ if (neutralBits.length) L.push('- **Neutrals:** ' + neutralBits.join(', ') + '.');
293
+
294
+ // Surface any low-contrast pairs as explicit warnings
295
+ var warns = (config.accessibility || []).filter(function (a) {
296
+ return a.rating && a.rating !== 'AAA' && a.rating !== 'AA';
297
+ });
298
+ if (warns.length) {
299
+ L.push(''); L.push('**Contrast cautions:**');
300
+ warns.forEach(function (a) {
301
+ var note = a.rating === 'AA Large' ? ' — large text only.' : ' — fails AA; do not use this pair for text.';
302
+ L.push('- ' + (a.fgName || a.fg) + ' on ' + (a.bgName || a.bg) + ' is ' + a.ratio + ' (' + a.rating + ')' + note);
303
+ });
304
+ }
305
+
306
+ if (fonts.display || fonts.body) {
307
+ L.push(''); L.push('## Typography');
308
+ if (fonts.display) L.push('- **Display:** ' + fontLine(fonts.display));
309
+ if (fonts.body) L.push('- **Body:** ' + fontLine(fonts.body));
310
+ }
311
+
312
+ if (config.logos && config.logos.length) {
313
+ L.push(''); L.push('## Logos');
314
+ config.logos.forEach(function (l) {
315
+ var use = backgroundToUse(l.background);
316
+ var variants = l.variants || {};
317
+ var src = variants.svg || variants.png || variants.jpg || '';
318
+ L.push('- **' + l.name + '**' + (use ? ' — use on ' + use : '') + (src ? ' — `' + src + '`' : ''));
319
+ });
320
+ }
321
+
322
+ if (theme['--gradient-brand']) {
323
+ L.push(''); L.push('## Gradient');
324
+ L.push('- **Brand gradient:** `' + theme['--gradient-brand'] + '`');
325
+ if (config.gradientUsage) {
326
+ if (config.gradientUsage.do && config.gradientUsage.do.length) L.push('- Use for: ' + config.gradientUsage.do.join('; ') + '.');
327
+ if (config.gradientUsage.dont && config.gradientUsage.dont.length) L.push("- Don't use for: " + config.gradientUsage.dont.join('; ') + '.');
328
+ }
329
+ }
330
+
331
+ L.push('');
332
+ return L.join('\n');
333
+ }
334
+
335
+ // Recursively drop null / empty-array / empty-object keys for a clean export.
336
+ function prune(obj) {
337
+ if (Array.isArray(obj)) return obj.map(prune);
338
+ if (obj === null || typeof obj !== 'object') return obj;
339
+ var out = {};
340
+ Object.keys(obj).forEach(function (k) {
341
+ var v = prune(obj[k]);
342
+ if (v === null || v === undefined) return;
343
+ if (Array.isArray(v) && v.length === 0) return;
344
+ if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) return;
345
+ out[k] = v;
346
+ });
347
+ return out;
348
+ }
349
+
350
+ module.exports = {
351
+ buildBrandJson: buildBrandJson,
352
+ buildTokensJson: buildTokensJson,
353
+ buildBrandMarkdown: buildBrandMarkdown,
354
+ writeExports: writeExports,
355
+ normalizeFormats: normalizeFormats
356
+ };
@@ -94,7 +94,7 @@ function mapToTheme(cssVars) {
94
94
  var mapping = {
95
95
  '--background': '--cloud',
96
96
  '--foreground': '--ink',
97
- '--primary': '--purple',
97
+ '--primary': '--accent',
98
98
  '--secondary': '--mist',
99
99
  '--muted': '--mist',
100
100
  '--muted-foreground': '--slate',
@@ -226,7 +226,7 @@ function buildHierarchyFromTheme(theme) {
226
226
  var fg = resolve('--ink', ['--foreground'], '#111827');
227
227
  var secondary = resolve('--graphite', ['--slate', '--muted-foreground'], '#374151');
228
228
  var tertiary = resolve('--slate', ['--haze', '--muted-foreground'], '#6B7280');
229
- var accent = resolve('--purple', ['--coral', '--primary'], '#6366F1');
229
+ var accent = resolve('--accent-text', ['--accent', '--purple', '--coral', '--primary'], '#6366F1');
230
230
 
231
231
  function keyToName(key) {
232
232
  return key.replace(/^--/, '').replace(/-/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
package/lib/template.js CHANGED
@@ -1,5 +1,6 @@
1
1
  var fs = require('fs');
2
2
  var path = require('path');
3
+ var exporter = require('./export');
3
4
 
4
5
  /**
5
6
  * Generate a :root CSS block from config.theme + config.fonts.
@@ -46,21 +47,27 @@ function generateFontsLink(config) {
46
47
  * Writes index.html and styles.css into the target directory.
47
48
  */
48
49
  function build(distDir, targetDir, config) {
49
- // Build styles.css: prepend :root block
50
+ // Build styles.css: append the config :root AFTER the stylesheet's default
51
+ // tokens so brand overrides win the cascade (styles.css ships a baseline
52
+ // :root; an earlier block would be overridden by it).
50
53
  var cssTemplate = fs.readFileSync(path.join(distDir, 'styles.css'), 'utf8');
51
54
  var rootCSS = generateRootCSS(config);
52
- var builtCSS = '/* Generated by brandkit build */\n' + rootCSS + '\n\n' + cssTemplate;
55
+ var builtCSS = cssTemplate + '\n\n/* Generated by brandkit build — brand overrides */\n' + rootCSS + '\n';
53
56
  fs.writeFileSync(path.join(targetDir, 'styles.css'), builtCSS);
54
57
 
55
- // Build index.html: inject fonts link + title
58
+ // Agent-native exports: brand.json (semantic), tokens.json (W3C DTCG),
59
+ // brand.md (LLM brief). brandJson is reused to embed the data in the page.
60
+ var exported = exporter.writeExports(config, targetDir, 'all');
61
+
62
+ // Build index.html: inject fonts link + title + agent-discoverable brand data
56
63
  var htmlTemplate = fs.readFileSync(path.join(distDir, 'index.html'), 'utf8');
57
64
  var fontsLink = generateFontsLink(config);
58
65
  var brandName = (config.brand && config.brand.displayName) || 'Brand';
59
66
 
60
- // Inject fonts link before </head>
61
- if (fontsLink) {
62
- htmlTemplate = htmlTemplate.replace('</head>', fontsLink + '\n</head>');
63
- }
67
+ // Inject fonts link + a discovery <link> for the structured brand data
68
+ var headInject = (fontsLink ? fontsLink + '\n' : '') +
69
+ '<link rel="alternate" type="application/json" href="brand.json" title="Brand data">\n';
70
+ htmlTemplate = htmlTemplate.replace('</head>', headInject + '</head>');
64
71
 
65
72
  // Set title
66
73
  htmlTemplate = htmlTemplate.replace(
@@ -68,6 +75,16 @@ function build(distDir, targetDir, config) {
68
75
  '<title>' + brandName + ' \u2014 Brand Guide</title>'
69
76
  );
70
77
 
78
+ // Embed brand.json inline so an agent that fetches the page gets structured
79
+ // brand data with zero extra requests or scraping. Every "<" is escaped to
80
+ // its JSON unicode form (<, which JSON.parse restores to "<"), so no
81
+ // config value can produce a "</script" sequence and break out of the block.
82
+ if (exported.brandJson) {
83
+ var embedded = JSON.stringify(exported.brandJson).replace(/</g, '\\u003C');
84
+ var embed = '<script type="application/json" id="brandkit-brand">' + embedded + '</script>\n';
85
+ htmlTemplate = htmlTemplate.replace('</body>', embed + '</body>');
86
+ }
87
+
71
88
  fs.writeFileSync(path.join(targetDir, 'index.html'), htmlTemplate);
72
89
 
73
90
  // Copy engine.js as-is
@@ -75,6 +92,8 @@ function build(distDir, targetDir, config) {
75
92
  path.join(distDir, 'engine.js'),
76
93
  path.join(targetDir, 'engine.js')
77
94
  );
95
+
96
+ return exported;
78
97
  }
79
98
 
80
99
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stacklist-app/brandkit",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Config-driven brand guide that bolts onto any website. Zero dependencies.",
5
5
  "main": "lib/resolve.js",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "cli/",
12
12
  "lib/",
13
13
  "dist/",
14
- "integrations/"
14
+ "integrations/",
15
+ "config.schema.json"
15
16
  ],
16
17
  "scripts": {
17
18
  "dev": "node bin/brandkit.js dev example",