@stacklist-app/brandkit 1.2.4 → 1.3.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/README.md CHANGED
@@ -32,6 +32,7 @@ npx brandkit build brand # bake into static files for production
32
32
  | `brandkit dev [dir]` | Local dev server on `:4800` with SSE live reload |
33
33
  | `brandkit build [dir]` | Produce static `index.html` + `styles.css` + `engine.js` (theme baked in) **plus** the agent-native exports below |
34
34
  | `brandkit export [dir]` | Emit just the agent-native brand data (`--format json\|dtcg\|md\|all`, `--out <dir>`) |
35
+ | `brandkit changelog "<msg>" [dir]` | Record a revision: prepend a changelog entry and bump `brand.version` (`--lock`, `--major`, `--version X.Y`, `--date`, `--dir`) |
35
36
 
36
37
  ## Agent-readable brand data
37
38
 
@@ -47,6 +48,22 @@ And for a person who wants to hand the guide to their own agent, the sidebar sho
47
48
 
48
49
  A JSON Schema for `config.json` ships at [`config.schema.json`](config.schema.json) — point your editor's `$schema` at it for validation and autocomplete.
49
50
 
51
+ ## Changelog
52
+
53
+ A brand guide evolves, so brandkit keeps a history of its revisions. Record one with:
54
+
55
+ ```bash
56
+ npx brandkit changelog "Swapped the accent to teal, refreshed the logo"
57
+ ```
58
+
59
+ This prepends a `{ version, date, changes }` entry to `config.changelog` and bumps `brand.version`. A fresh guide starts at **0.1** and climbs with each change (`0.1 → 0.2 → …`); finalize the brand at **1.0** with `--lock`:
60
+
61
+ ```bash
62
+ npx brandkit changelog --lock "Brand finalized"
63
+ ```
64
+
65
+ The history renders on a standalone **`changelog.html`** page (linked from the bottom of the sidebar) and is included in `brand.json` / `brand.md` so agents see it too. `init` also scaffolds an **`AGENTS.md`** into the guide directory — a maintenance contract telling an AI agent the source of truth, to read from the exports, and to record changes with `brandkit changelog`.
66
+
50
67
  ## Framework integrations
51
68
 
52
69
  Serve the brand guide at `/brand` in your existing dev server and bundle it on build.
package/bin/brandkit.js CHANGED
@@ -19,6 +19,9 @@ switch (command) {
19
19
  case 'export':
20
20
  require('../cli/export')(args.slice(1));
21
21
  break;
22
+ case 'changelog':
23
+ require('../cli/changelog')(args.slice(1));
24
+ break;
22
25
  case 'help':
23
26
  case '--help':
24
27
  case '-h':
@@ -45,6 +48,7 @@ function printHelp() {
45
48
  console.log(' brandkit dev [dir] Start dev server with live reload');
46
49
  console.log(' brandkit build [dir] Build static files for production');
47
50
  console.log(' brandkit export [dir] Emit agent-native brand data (brand.json, tokens.json, brand.md)');
51
+ console.log(' brandkit changelog "<msg>" Record a revision (bumps version, adds a changelog entry)');
48
52
  console.log('');
49
53
  console.log(' Options:');
50
54
  console.log(' --help, -h Show this help message');
package/cli/build.js CHANGED
@@ -28,7 +28,8 @@ module.exports = function build(args) {
28
28
  var brandName = (config.brand && config.brand.displayName) || 'Brand';
29
29
  console.log(' built index.html (' + brandName + ' title + fonts + embedded brand data)');
30
30
  console.log(' built styles.css (:root variables generated)');
31
- console.log(' copied engine.js');
31
+ console.log(' built changelog.html (' + brandName + ' title + fonts)');
32
+ console.log(' copied engine.js, changelog.js');
32
33
  if (exported && exported.written) {
33
34
  exported.written.forEach(function (f) {
34
35
  console.log(' wrote ' + f + ' (agent-native export)');
@@ -0,0 +1,172 @@
1
+ var fs = require('fs');
2
+ var path = require('path');
3
+
4
+ /**
5
+ * brandkit changelog — record a brand-guide revision.
6
+ *
7
+ * Prepends a `{ version, date, changes }` entry to config.changelog (newest
8
+ * first) and bumps brand.version. A fresh guide starts at 0.1; each call
9
+ * minor-bumps (0.1 → 0.2 → …); --lock jumps to 1.0 when the brand is finalized.
10
+ *
11
+ * brandkit changelog "Added gradients" "Tuned contrast"
12
+ * brandkit changelog --lock "Brand locked"
13
+ * brandkit changelog --major "v2 redesign"
14
+ * brandkit changelog --version 0.5 "Set explicitly"
15
+ * brandkit changelog --dir example "Update the demo"
16
+ */
17
+ module.exports = function changelog(args) {
18
+ var opts = parseArgs(args);
19
+
20
+ if (opts.help) {
21
+ printUsage();
22
+ return;
23
+ }
24
+
25
+ if (opts.error) {
26
+ console.error('');
27
+ console.error(' ' + opts.error);
28
+ console.error('');
29
+ printUsage();
30
+ process.exit(1);
31
+ }
32
+
33
+ var targetDir = path.resolve(opts.dir || '.');
34
+ var configPath = path.join(targetDir, 'config.json');
35
+ if (!fs.existsSync(configPath)) {
36
+ console.error('');
37
+ console.error(' No config.json found in ' + targetDir);
38
+ console.error(' Run: brandkit init ' + path.relative(process.cwd(), targetDir));
39
+ console.error('');
40
+ process.exit(1);
41
+ }
42
+
43
+ // Drop blank/whitespace-only messages so a stray empty arg can't create a
44
+ // blank bullet (and silently bump the version).
45
+ opts.messages = opts.messages.filter(function (m) { return String(m).trim() !== ''; });
46
+ if (!opts.messages.length) {
47
+ console.error('');
48
+ console.error(' Nothing to log — pass at least one change message.');
49
+ console.error(' Example: brandkit changelog "Added the gradient system"');
50
+ console.error('');
51
+ process.exit(1);
52
+ }
53
+
54
+ // Reject a malformed explicit --version so junk never lands in brand.version
55
+ // (which build/template.js consume downstream). The changelog scheme is
56
+ // strictly MAJOR.MINOR, so a patch (1.2.3) is rejected too — keeping the
57
+ // regex, the message, and parseVersion's bump logic consistent.
58
+ if (opts.version && !/^\d+\.\d+$/.test(opts.version)) {
59
+ console.error('');
60
+ console.error(' Invalid --version "' + opts.version + '" — expected MAJOR.MINOR (e.g. 0.3 or 1.0).');
61
+ console.error('');
62
+ process.exit(1);
63
+ }
64
+
65
+ var config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
66
+ config.brand = config.brand || {};
67
+ if (!Array.isArray(config.changelog)) config.changelog = [];
68
+
69
+ var prevVersion = config.brand.version || '0.1';
70
+
71
+ // The scheme is MAJOR.MINOR. If the stored version isn't clean (hand-edited,
72
+ // or a 3-part value like the demo's package version), warn rather than
73
+ // silently normalizing — the bump uses only its leading major.minor.
74
+ if (!opts.version && !/^\d+\.\d+$/.test(prevVersion)) {
75
+ console.error(' Note: brand.version "' + prevVersion +
76
+ '" is not MAJOR.MINOR — bumping from its leading major.minor.');
77
+ }
78
+
79
+ // --lock finalizes a pre-1.0 brand at 1.0; refuse to move a >=1.0 brand
80
+ // backward (which would also break the newest-first version ordering).
81
+ if (opts.lock && parseVersion(prevVersion).major >= 1) {
82
+ console.error('');
83
+ console.error(' Already at v' + prevVersion + ' — the brand is locked (>= 1.0).');
84
+ console.error(' Use --major or --version X.Y to bump further.');
85
+ console.error('');
86
+ process.exit(1);
87
+ }
88
+
89
+ var nextVersion = nextVersionFor(prevVersion, opts);
90
+ var date = opts.date || new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
91
+
92
+ var entry = { version: nextVersion, date: date, changes: opts.messages.slice() };
93
+ config.changelog.unshift(entry);
94
+ config.brand.version = nextVersion;
95
+
96
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
97
+
98
+ console.log('');
99
+ console.log(' brandkit changelog');
100
+ console.log('');
101
+ console.log(' v' + prevVersion + ' → v' + nextVersion + ' · ' + opts.messages.length +
102
+ ' change' + (opts.messages.length === 1 ? '' : 's') + ' (' + date + ')');
103
+ opts.messages.forEach(function (m) {
104
+ console.log(' • ' + m);
105
+ });
106
+ console.log('');
107
+ console.log(' Updated ' + path.relative(process.cwd(), configPath));
108
+ console.log(' Run `brandkit build` (or `export`) to refresh the deployed page and agent exports.');
109
+ console.log('');
110
+ };
111
+
112
+ /**
113
+ * Decide the next version string from the previous one + flags.
114
+ * --version X.Y → explicit
115
+ * --lock → 1.0
116
+ * --major → next whole major, minor reset to 0
117
+ * default → minor + 1
118
+ */
119
+ function nextVersionFor(prev, opts) {
120
+ if (opts.version) return opts.version;
121
+ if (opts.lock) return '1.0';
122
+
123
+ var parsed = parseVersion(prev);
124
+ if (opts.major) return (parsed.major + 1) + '.0';
125
+ return parsed.major + '.' + (parsed.minor + 1);
126
+ }
127
+
128
+ // "0.3" → { major: 0, minor: 3 }; tolerant of junk → { 0, 1 }.
129
+ function parseVersion(v) {
130
+ var m = String(v == null ? '' : v).match(/^(\d+)\.(\d+)/);
131
+ if (!m) return { major: 0, minor: 1 };
132
+ return { major: parseInt(m[1], 10), minor: parseInt(m[2], 10) };
133
+ }
134
+
135
+ function parseArgs(args) {
136
+ var opts = { messages: [], dir: '.', lock: false, major: false, version: null, date: null, help: false, error: null };
137
+ var valueFlags = { '--version': 'version', '--date': 'date', '--dir': 'dir' };
138
+ for (var i = 0; i < args.length; i++) {
139
+ var a = args[i];
140
+ if (a === '--help' || a === '-h') opts.help = true;
141
+ else if (a === '--lock') opts.lock = true;
142
+ else if (a === '--major') opts.major = true;
143
+ else if (valueFlags[a]) {
144
+ // A value flag at the end of argv would otherwise read undefined and be
145
+ // silently ignored — error instead.
146
+ if (i + 1 >= args.length) { opts.error = a + ' requires a value.'; break; }
147
+ opts[valueFlags[a]] = args[++i];
148
+ }
149
+ // Reject an unknown --flag (e.g. a "--majro" typo) rather than recording it
150
+ // as a changelog message. Single-dash args pass through so a message can
151
+ // still start with "-" (e.g. "-20% load time").
152
+ else if (a.indexOf('--') === 0) { opts.error = 'Unknown option: ' + a; break; }
153
+ else opts.messages.push(a);
154
+ }
155
+ return opts;
156
+ }
157
+
158
+ function printUsage() {
159
+ console.log('');
160
+ console.log(' brandkit changelog — record a brand-guide revision');
161
+ console.log('');
162
+ console.log(' Usage:');
163
+ console.log(' brandkit changelog "<message>" ["<message>" ...]');
164
+ console.log('');
165
+ console.log(' Options:');
166
+ console.log(' --lock Mark the brand as finalized (version → 1.0)');
167
+ console.log(' --major Bump the major version (e.g. 1.4 → 2.0)');
168
+ console.log(' --version X.Y Set the version explicitly');
169
+ console.log(' --date "<text>" Override the entry date (default: this month)');
170
+ console.log(' --dir <path> Target guide directory (default: .)');
171
+ console.log('');
172
+ }
package/cli/init.js CHANGED
@@ -2,6 +2,7 @@ var fs = require('fs');
2
2
  var path = require('path');
3
3
  var resolve = require('../lib/resolve');
4
4
  var schema = require('../lib/config-schema');
5
+ var agentsDoc = require('../lib/agents-doc').agentsDoc;
5
6
 
6
7
  module.exports = function init(args) {
7
8
  var targetDir = path.resolve(args[0] || 'brand');
@@ -27,7 +28,7 @@ module.exports = function init(args) {
27
28
  var isUpdate = args.indexOf('--update') !== -1;
28
29
 
29
30
  // Copy engine files from dist/
30
- var files = ['engine.js', 'styles.css', 'index.html'];
31
+ var files = ['engine.js', 'styles.css', 'index.html', 'changelog.html', 'changelog.js'];
31
32
  for (var i = 0; i < files.length; i++) {
32
33
  var src = path.join(distDir, files[i]);
33
34
  var dest = path.join(targetDir, files[i]);
@@ -65,6 +66,16 @@ module.exports = function init(args) {
65
66
  console.log(' kept config.json (already exists)');
66
67
  }
67
68
 
69
+ // Maintenance contract for agents/humans working in this dir — written when
70
+ // absent (so older guides pick it up on --update) and never clobbered.
71
+ var agentsPath = path.join(targetDir, 'AGENTS.md');
72
+ if (!fs.existsSync(agentsPath)) {
73
+ fs.writeFileSync(agentsPath, agentsDoc());
74
+ console.log(' created AGENTS.md');
75
+ } else {
76
+ console.log(' kept AGENTS.md (already exists)');
77
+ }
78
+
68
79
  console.log('');
69
80
  if (isUpdate) {
70
81
  console.log(' Updated engine files. Config and logos preserved.');
@@ -251,6 +251,18 @@
251
251
  "cards": { "type": "array" },
252
252
  "stats": { "type": "array" }
253
253
  }
254
+ },
255
+ "changelog": {
256
+ "type": "array",
257
+ "description": "Revision history for the brand guide, newest entry first. Rendered on the standalone changelog.html page and surfaced in the agent exports. Maintain it with `brandkit changelog`. The latest entry's version should match brand.version.",
258
+ "items": {
259
+ "type": "object",
260
+ "properties": {
261
+ "version": { "type": "string", "description": "Revision label, e.g. '0.2'. Starts at 0.1 on a fresh guide; 1.0 = locked/finalized." },
262
+ "date": { "type": "string", "description": "Human-readable date, e.g. 'June 2026'." },
263
+ "changes": { "type": "array", "items": { "type": "string" }, "description": "Bullet list of what changed in this revision." }
264
+ }
265
+ }
254
266
  }
255
267
  }
256
268
  }
@@ -0,0 +1,33 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Changelog</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+
11
+ <div class="changelog-page">
12
+
13
+ <!-- Header -->
14
+ <div class="changelog-header">
15
+ <a class="changelog-back" href="index.html">&larr; Back to guide</a>
16
+ <div class="changelog-brand" id="changelog-brand"></div>
17
+ <h1 class="changelog-title">Changelog</h1>
18
+ <p class="changelog-lead" id="changelog-lead"></p>
19
+ </div>
20
+
21
+ <!-- Entries -->
22
+ <div class="changelog-list" id="changelog-list"></div>
23
+
24
+ <!-- Footer -->
25
+ <div class="changelog-footer" id="changelog-footer"></div>
26
+
27
+ </div>
28
+
29
+ <!-- Render script -->
30
+ <script src="changelog.js"></script>
31
+
32
+ </body>
33
+ </html>
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Brandkit Changelog Page
3
+ * Loads config.json → bootstraps theme → renders the version history.
4
+ *
5
+ * A small standalone companion to engine.js: it reuses the same config, the
6
+ * same theme/font bootstrap, and the same escaping, but renders only the
7
+ * changelog (cfg.changelog) so the guide can ship a dedicated history page.
8
+ */
9
+ (function () {
10
+ // Base path the page is served from. The build injects
11
+ // window.__BRANDKIT_BASE__ when config.basePath is set (e.g. "/brand"), so a
12
+ // page served from a non-trailing-slash URL still resolves config.json.
13
+ var BASE = (typeof window !== 'undefined' && window.__BRANDKIT_BASE__)
14
+ ? (String(window.__BRANDKIT_BASE__).replace(/\/+$/, '') || '.') : '.';
15
+
16
+ fetch(BASE + '/config.json')
17
+ // Guard before parsing: a non-200 response (e.g. a CDN/error page served
18
+ // with a JSON body) is routed to the error state instead of init().
19
+ .then(function (res) { if (!res.ok) throw new Error(res.status); return res.json(); })
20
+ .then(function (config) { init(config); })
21
+ .catch(function () { renderError(); });
22
+
23
+ /* ================================================================
24
+ Helpers (kept in sync with dist/engine.js — no module system here)
25
+ ================================================================ */
26
+ function esc(s) {
27
+ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
29
+ }
30
+
31
+ function fontStack(f) {
32
+ if (!f || !f.family) return 'sans-serif';
33
+ function q(s) { return String(s).replace(/[\u0000-\u001F\u007F]/g, '').replace(/[\\']/g, '\\$&'); }
34
+ var stack = "'" + q(f.family) + "'";
35
+ if (f.fallback) stack += ", '" + q(f.fallback) + "'";
36
+ return stack + ', sans-serif';
37
+ }
38
+
39
+ // Strip the characters a config value would need to break out of the
40
+ // injected <style> block ( < > ), the :root {…} rule ( { } ), or its own
41
+ // declaration to smuggle in another ( ; ). Mirrors cssVal() in
42
+ // dist/engine.js. A real CSS value never contains them.
43
+ function cssVal(v) { return String(v == null ? '' : v).replace(/[<>{};]/g, ''); }
44
+
45
+ /* ================================================================
46
+ Bootstrap — fonts + theme variables, mirroring engine.js
47
+ ================================================================ */
48
+ function bootstrap(cfg) {
49
+ if (cfg.fonts) {
50
+ // Dedupe families: when display and body share a font, request it once.
51
+ var seen = {};
52
+ var families = [];
53
+ function addFamily(f) {
54
+ if (f && f.googleImport && !seen[f.googleImport]) {
55
+ seen[f.googleImport] = 1;
56
+ families.push(f.googleImport);
57
+ }
58
+ }
59
+ addFamily(cfg.fonts.display);
60
+ addFamily(cfg.fonts.body);
61
+ if (families.length) {
62
+ var link = document.createElement('link');
63
+ link.rel = 'stylesheet';
64
+ link.href = 'https://fonts.googleapis.com/css2?family=' + families.join('&family=') + '&display=swap';
65
+ document.head.appendChild(link);
66
+ }
67
+ }
68
+
69
+ if (cfg.theme) {
70
+ var vars = [];
71
+ var keys = Object.keys(cfg.theme);
72
+ for (var i = 0; i < keys.length; i++) {
73
+ vars.push(' ' + cssVal(keys[i]) + ': ' + cssVal(cfg.theme[keys[i]]) + ';');
74
+ }
75
+ if (cfg.fonts) {
76
+ if (cfg.fonts.display) vars.push(' --font-display: ' + fontStack(cfg.fonts.display) + ';');
77
+ if (cfg.fonts.body) vars.push(' --font-body: ' + fontStack(cfg.fonts.body) + ';');
78
+ }
79
+ var style = document.createElement('style');
80
+ style.setAttribute('data-brandkit-theme', '');
81
+ style.textContent = ':root {\n' + vars.join('\n') + '\n}';
82
+ document.head.appendChild(style);
83
+ }
84
+
85
+ var name = cfg.brand && (cfg.brand.displayName || cfg.brand.name);
86
+ document.title = (name ? name + ' — ' : '') + 'Changelog';
87
+ }
88
+
89
+ /* ================================================================
90
+ Render
91
+ ================================================================ */
92
+ function init(cfg) {
93
+ bootstrap(cfg);
94
+ var brand = cfg.brand || {};
95
+
96
+ var brandEl = document.getElementById('changelog-brand');
97
+ if (brandEl) brandEl.textContent = brand.displayName || brand.name || 'Brand';
98
+
99
+ var leadEl = document.getElementById('changelog-lead');
100
+ if (leadEl) {
101
+ leadEl.textContent = 'A history of every revision to this brand guide — newest first.';
102
+ }
103
+
104
+ var list = document.getElementById('changelog-list');
105
+ if (!list) return;
106
+
107
+ var entries = cfg.changelog || [];
108
+ if (!entries.length) {
109
+ list.innerHTML = '<div class="changelog-empty">No changelog entries yet. ' +
110
+ 'Run <code>brandkit changelog "your update"</code> to add one.</div>';
111
+ return;
112
+ }
113
+
114
+ list.innerHTML = entries.map(function (entry) {
115
+ var changes = (entry.changes || []).map(function (c) {
116
+ return '<li>' + esc(c) + '</li>';
117
+ }).join('');
118
+ return (
119
+ '<div class="changelog-entry">' +
120
+ '<div class="changelog-entry-head">' +
121
+ '<span class="changelog-version">v' + esc(entry.version) + '</span>' +
122
+ (entry.date ? '<span class="changelog-date">' + esc(entry.date) + '</span>' : '') +
123
+ '</div>' +
124
+ (changes ? '<ul class="changelog-changes">' + changes + '</ul>' : '') +
125
+ '</div>'
126
+ );
127
+ }).join('');
128
+
129
+ var footer = document.getElementById('changelog-footer');
130
+ if (footer) {
131
+ var label = brand.guideLabel || 'Web Style Guide';
132
+ footer.innerHTML = esc((brand.url || '') + (brand.url && brand.byline ? ' · ' : '') + (brand.byline || '')) +
133
+ '<br>' + esc(label) + (brand.version ? ' v' + esc(brand.version) : '');
134
+ }
135
+ }
136
+
137
+ function renderError() {
138
+ var list = document.getElementById('changelog-list');
139
+ if (list) {
140
+ list.innerHTML = '<div class="changelog-empty">Could not load config.json. ' +
141
+ 'The changelog page needs to be served over HTTP (not opened from a file).</div>';
142
+ }
143
+ }
144
+ })();
package/dist/engine.js CHANGED
@@ -13,6 +13,10 @@
13
13
  var BASE = (typeof window !== 'undefined' && window.__BRANDKIT_BASE__)
14
14
  ? (String(window.__BRANDKIT_BASE__).replace(/\/+$/, '') || '.') : '.';
15
15
  var res = await fetch(BASE + '/config.json');
16
+ // Guard before parsing: a non-200 response (e.g. a CDN/error page served
17
+ // with a JSON body) would otherwise flow straight into init() and render
18
+ // confusing output instead of failing cleanly.
19
+ if (!res.ok) throw new Error('Failed to load config.json: ' + res.status);
16
20
  var config = await res.json();
17
21
  init(config);
18
22
 
@@ -44,15 +48,36 @@
44
48
  return stack + ', sans-serif';
45
49
  }
46
50
 
51
+ /* ==============================================================
52
+ CSS-value sanitizer — strips the characters a config value would
53
+ need to break out of the injected <style> block ( < > ), out of the
54
+ :root {…} rule ( { } ), or out of its own declaration to smuggle in
55
+ another one ( ; ). A legitimate custom-property token/value never
56
+ contains them, so real configs pass through unchanged; an
57
+ adversarial value like "}</style><script>..." or
58
+ "red; background-image:url(x)" is defanged.
59
+ ============================================================== */
60
+ function cssVal(v) { return String(v == null ? '' : v).replace(/[<>{};]/g, ''); }
61
+
47
62
  /* ==============================================================
48
63
  BOOTSTRAP — inject fonts + CSS variables before any rendering
49
64
  ============================================================== */
50
65
  function bootstrap() {
51
66
  // Inject Google Fonts
52
67
  if (cfg.fonts) {
68
+ // Dedupe families: when display and body share a font (e.g. both
69
+ // Inter), requesting it once avoids a redundant URL + duplicate
70
+ // font CSS work.
71
+ var seen = {};
53
72
  var families = [];
54
- if (cfg.fonts.display && cfg.fonts.display.googleImport) families.push(cfg.fonts.display.googleImport);
55
- if (cfg.fonts.body && cfg.fonts.body.googleImport) families.push(cfg.fonts.body.googleImport);
73
+ function addFamily(f) {
74
+ if (f && f.googleImport && !seen[f.googleImport]) {
75
+ seen[f.googleImport] = 1;
76
+ families.push(f.googleImport);
77
+ }
78
+ }
79
+ addFamily(cfg.fonts.display);
80
+ addFamily(cfg.fonts.body);
56
81
  if (families.length) {
57
82
  var link = document.createElement('link');
58
83
  link.rel = 'stylesheet';
@@ -66,7 +91,7 @@
66
91
  var vars = [];
67
92
  var keys = Object.keys(cfg.theme);
68
93
  for (var i = 0; i < keys.length; i++) {
69
- vars.push(' ' + keys[i] + ': ' + cfg.theme[keys[i]] + ';');
94
+ vars.push(' ' + cssVal(keys[i]) + ': ' + cssVal(cfg.theme[keys[i]]) + ';');
70
95
  }
71
96
  // Add font variables from config
72
97
  if (cfg.fonts) {
@@ -183,6 +208,29 @@
183
208
  box.hidden = false;
184
209
  }
185
210
 
211
+ /* ==============================================================
212
+ 2c. Render changelog link — pinned to the bottom of the sidebar when
213
+ a changelog exists, pointing at the standalone changelog.html page.
214
+ ============================================================== */
215
+ function renderChangelogLink() {
216
+ var link = document.getElementById('sidebar-changelog');
217
+ if (!link) return;
218
+ if (!cfg.changelog || !cfg.changelog.length) return;
219
+
220
+ // Resolve against BASE so the link works when the guide is served from a
221
+ // non-trailing-slash base path (e.g. /brand → /brand/index.html).
222
+ var href = (BASE && BASE !== '.') ? BASE + '/changelog.html' : 'changelog.html';
223
+ link.setAttribute('href', href);
224
+ link.innerHTML =
225
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">' +
226
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 2"/>' +
227
+ '<circle cx="12" cy="12" r="9"/>' +
228
+ '</svg>' +
229
+ '<span class="sidebar-changelog-label">Changelog</span>' +
230
+ '<span class="sidebar-changelog-version">v' + esc((cfg.brand && cfg.brand.version) || '') + '</span>';
231
+ link.hidden = false;
232
+ }
233
+
186
234
  /* ==============================================================
187
235
  3. Render colors
188
236
  ============================================================== */
@@ -954,6 +1002,7 @@
954
1002
  renderShell();
955
1003
  renderNav();
956
1004
  renderAgentCallout();
1005
+ renderChangelogLink();
957
1006
  renderSectionIntros();
958
1007
  renderColors();
959
1008
  renderGradients();
package/dist/index.html CHANGED
@@ -15,6 +15,7 @@
15
15
  <div class="sidebar-brand">brandkit</div>
16
16
  <ul class="sidebar-nav" id="nav"></ul>
17
17
  <div class="sidebar-agent" id="agent-callout" hidden></div>
18
+ <a class="sidebar-changelog" id="sidebar-changelog" href="changelog.html" hidden></a>
18
19
  </nav>
19
20
 
20
21
  <!-- Main Content -->
package/dist/styles.css CHANGED
@@ -94,6 +94,9 @@ body {
94
94
  background: var(--white);
95
95
  border-right: 1px solid var(--mist);
96
96
  z-index: 100;
97
+ /* Column layout so the changelog link can pin to the bottom (margin-top:auto) */
98
+ display: flex;
99
+ flex-direction: column;
97
100
  }
98
101
 
99
102
  .sidebar-brand {
@@ -167,6 +170,34 @@ body {
167
170
  .sidebar-agent-btn:active { transform: translateY(1px); }
168
171
  .sidebar-agent-btn:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
169
172
 
173
+ /* Changelog link — pinned to the bottom of the sidebar column */
174
+ /* The author `display:flex` below would override the UA `[hidden]{display:none}`
175
+ (author beats UA), leaving an empty bordered box when JS hasn't un-hidden it
176
+ (no changelog, or before the engine runs) — so restore hidden explicitly. */
177
+ .sidebar-changelog[hidden] { display: none; }
178
+ .sidebar-changelog {
179
+ margin: auto 16px 4px; /* auto top pushes it to the bottom */
180
+ padding-top: 16px;
181
+ border-top: 1px solid var(--mist);
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 8px;
185
+ font-size: 12px;
186
+ font-weight: 500;
187
+ color: var(--slate);
188
+ text-decoration: none;
189
+ transition: color 150ms ease;
190
+ }
191
+ .sidebar-changelog:hover { color: var(--accent-text); }
192
+ .sidebar-changelog svg { width: 14px; height: 14px; flex: none; }
193
+ .sidebar-changelog-label { flex: 1; }
194
+ .sidebar-changelog-version {
195
+ font-size: 11px;
196
+ color: var(--haze);
197
+ font-variant-numeric: tabular-nums;
198
+ }
199
+ .sidebar-changelog:hover .sidebar-changelog-version { color: var(--slate); }
200
+
170
201
  /* ── Sidebar Grouped Navigation ── */
171
202
  .nav-group {
172
203
  padding: 20px 24px 6px;
@@ -1097,6 +1128,150 @@ body {
1097
1128
  justify-content: space-between;
1098
1129
  }
1099
1130
 
1131
+ /* ── Changelog page (standalone changelog.html) ── */
1132
+ .changelog-page {
1133
+ max-width: 760px;
1134
+ margin: 0 auto;
1135
+ padding: 72px 32px 96px;
1136
+ }
1137
+
1138
+ .changelog-header { margin-bottom: 56px; }
1139
+
1140
+ .changelog-back {
1141
+ display: inline-block;
1142
+ font-size: 13px;
1143
+ font-weight: 500;
1144
+ color: var(--slate);
1145
+ text-decoration: none;
1146
+ margin-bottom: 32px;
1147
+ transition: color 150ms ease;
1148
+ }
1149
+
1150
+ .changelog-back:hover { color: var(--accent-text); }
1151
+
1152
+ .changelog-brand {
1153
+ font-family: var(--font-body), sans-serif;
1154
+ font-size: 11px;
1155
+ font-weight: 700;
1156
+ letter-spacing: 0.12em;
1157
+ text-transform: uppercase;
1158
+ color: var(--slate);
1159
+ margin-bottom: 12px;
1160
+ }
1161
+
1162
+ .changelog-title {
1163
+ font-family: var(--font-display), sans-serif;
1164
+ font-size: 44px;
1165
+ font-weight: 700;
1166
+ letter-spacing: -0.02em;
1167
+ line-height: 1.1;
1168
+ color: var(--ink);
1169
+ margin: 0 0 12px;
1170
+ }
1171
+
1172
+ .changelog-lead {
1173
+ font-size: 15px;
1174
+ line-height: 1.7;
1175
+ color: var(--graphite);
1176
+ margin: 0;
1177
+ }
1178
+
1179
+ /* Each release — a vertical timeline with the version "ticked" on the rail */
1180
+ .changelog-list {
1181
+ border-left: 2px solid var(--mist);
1182
+ padding-left: 32px;
1183
+ }
1184
+
1185
+ .changelog-entry {
1186
+ position: relative;
1187
+ padding-bottom: 40px;
1188
+ }
1189
+
1190
+ .changelog-entry:last-child { padding-bottom: 0; }
1191
+
1192
+ .changelog-entry::before {
1193
+ content: "";
1194
+ position: absolute;
1195
+ left: -41px;
1196
+ top: 6px;
1197
+ width: 10px;
1198
+ height: 10px;
1199
+ border-radius: 50%;
1200
+ background: var(--accent);
1201
+ box-shadow: 0 0 0 4px var(--cloud);
1202
+ }
1203
+
1204
+ .changelog-entry-head {
1205
+ display: flex;
1206
+ align-items: baseline;
1207
+ gap: 12px;
1208
+ margin-bottom: 14px;
1209
+ }
1210
+
1211
+ .changelog-version {
1212
+ font-family: var(--font-display), sans-serif;
1213
+ font-size: 20px;
1214
+ font-weight: 700;
1215
+ letter-spacing: -0.01em;
1216
+ color: var(--accent-text);
1217
+ }
1218
+
1219
+ .changelog-date {
1220
+ font-size: 13px;
1221
+ font-weight: 500;
1222
+ color: var(--slate);
1223
+ }
1224
+
1225
+ .changelog-changes {
1226
+ margin: 0;
1227
+ padding: 0;
1228
+ list-style: none;
1229
+ }
1230
+
1231
+ .changelog-changes li {
1232
+ position: relative;
1233
+ padding-left: 20px;
1234
+ margin-bottom: 8px;
1235
+ font-size: 15px;
1236
+ line-height: 1.6;
1237
+ color: var(--graphite);
1238
+ }
1239
+
1240
+ .changelog-changes li::before {
1241
+ content: "";
1242
+ position: absolute;
1243
+ left: 2px;
1244
+ top: 9px;
1245
+ width: 5px;
1246
+ height: 5px;
1247
+ border-radius: 50%;
1248
+ background: var(--haze);
1249
+ }
1250
+
1251
+ .changelog-empty {
1252
+ font-size: 15px;
1253
+ line-height: 1.7;
1254
+ color: var(--slate);
1255
+ }
1256
+
1257
+ .changelog-empty code {
1258
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1259
+ font-size: 13px;
1260
+ background: var(--mist);
1261
+ padding: 2px 6px;
1262
+ border-radius: 6px;
1263
+ color: var(--ink);
1264
+ }
1265
+
1266
+ .changelog-footer {
1267
+ margin-top: 64px;
1268
+ padding-top: 28px;
1269
+ border-top: 1px solid var(--mist);
1270
+ font-size: 12px;
1271
+ line-height: 1.6;
1272
+ color: var(--slate);
1273
+ }
1274
+
1100
1275
  /* ── Print ── */
1101
1276
  @media print {
1102
1277
  .sidebar, .copy-format-bar, .toast-container { display: none !important; }
@@ -1113,4 +1288,6 @@ body {
1113
1288
  .card-demo-grid, .dark-card-demo-grid, .a11y-grid { grid-template-columns: 1fr; }
1114
1289
  .voice-grid, .gradient-usage, .wordmark-display, .logo-grid { grid-template-columns: 1fr; }
1115
1290
  .type-row { grid-template-columns: 60px 1fr 140px; }
1291
+ .changelog-page { padding: 48px 22px 64px; }
1292
+ .changelog-title { font-size: 34px; }
1116
1293
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The AGENTS.md scaffolded into a brand-guide directory by `brandkit init`.
3
+ *
4
+ * It is a maintenance contract for AI agents (and humans) working in the host
5
+ * project's brand directory: where the source of truth is, how to read the
6
+ * brand, and — crucially — to record changes in the changelog. It is brand-
7
+ * agnostic (it points at config.json for specifics), so it never goes stale.
8
+ */
9
+ function agentsDoc() {
10
+ return [
11
+ '# Working on this brand guide (for AI agents)',
12
+ '',
13
+ 'This directory is a **[brandkit](https://github.com/The-Stack-Lab/brandkit) brand guide**.',
14
+ '`config.json` is the single source of truth — it drives the rendered guide (`index.html`) and',
15
+ 'the machine-readable exports below.',
16
+ '',
17
+ '## Read the brand from the exports, not the page',
18
+ '',
19
+ '- `brand.json` — full structured brand: colors (roles + contrast), typography, voice, logos, spacing',
20
+ '- `tokens.json` — W3C design tokens (DTCG `$type`/`$value`) for code and design tooling',
21
+ '- `brand.md` — a brief on how to stay on-brand',
22
+ '',
23
+ 'These are regenerated by `brandkit build` / `brandkit export`. If they look stale, rebuild.',
24
+ '',
25
+ '## When you change the brand, record it in the changelog',
26
+ '',
27
+ 'Whenever you edit the brand — colors, type, logos, voice, spacing, or anything in `config.json` —',
28
+ 'log what changed so the guide keeps an accurate history:',
29
+ '',
30
+ '```bash',
31
+ 'npx brandkit changelog "what you changed" # e.g. "Swapped accent to teal, refreshed the logo"',
32
+ '```',
33
+ '',
34
+ "This prepends an entry to `config.json`'s `changelog` and bumps `brand.version`.",
35
+ '',
36
+ '**Versioning:** a new guide starts at **0.1** and climbs with each change (0.1 → 0.2 → …). When',
37
+ 'the brand is finalized, lock it at **1.0**:',
38
+ '',
39
+ '```bash',
40
+ 'npx brandkit changelog --lock "Brand finalized"',
41
+ '```',
42
+ '',
43
+ 'The changelog renders on its own page (`changelog.html`) and is included in `brand.json` / `brand.md`.',
44
+ '',
45
+ '## Commands',
46
+ '',
47
+ '```bash',
48
+ 'npx brandkit dev . # preview at http://localhost:4800 (live reload)',
49
+ 'npx brandkit build . # build static files + exports for deploy',
50
+ 'npx brandkit export . # regenerate brand.json / tokens.json / brand.md',
51
+ 'npx brandkit changelog "…" # record a brand change (see above)',
52
+ '```',
53
+ '',
54
+ '## Rules',
55
+ '',
56
+ '- Edit `config.json` — do **not** hand-edit `index.html`, `styles.css`, `engine.js`, or',
57
+ ' `changelog.html`. They are universal and overwritten on every rebuild.',
58
+ '- After changing `config.json`: run `brandkit changelog "…"`, then `brandkit build` (or `export`).',
59
+ ''
60
+ ].join('\n');
61
+ }
62
+
63
+ module.exports = { agentsDoc: agentsDoc };
@@ -19,7 +19,10 @@ function starterConfig() {
19
19
  description: 'Brandkit turns a single config.json into a complete, living brand guide — colors, typography, logos, voice, and components, all driven by design tokens. This is the default guide; swap the config and assets to make it your own.',
20
20
  url: 'github.com/The-Stack-Lab/brandkit',
21
21
  byline: 'Open source · MIT',
22
- version: '1.0',
22
+ // A fresh guide starts at 0.1 and climbs as it's built out; lock it at 1.0
23
+ // when the brand is finalized (`brandkit changelog --lock`). The latest
24
+ // changelog entry's version mirrors this.
25
+ version: '0.1',
23
26
  date: new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' }),
24
27
  // Renames the "Web Style Guide" label in the header + footer.
25
28
  guideLabel: 'Web Style Guide',
@@ -230,7 +233,17 @@ function starterConfig() {
230
233
  { value: '0', label: 'Runtime dependencies' },
231
234
  { value: '12', label: 'Sections, rendered from tokens' }
232
235
  ]
233
- }
236
+ },
237
+ // Revision history, newest first — shown on changelog.html and exported for
238
+ // agents. A fresh guide starts with this single 0.1 entry; add more with
239
+ // `brandkit changelog "what changed"` as the brand evolves.
240
+ changelog: [
241
+ {
242
+ version: '0.1',
243
+ date: new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' }),
244
+ changes: ['Initial brand guide scaffolded with brandkit.']
245
+ }
246
+ ]
234
247
  };
235
248
  }
236
249
 
package/lib/export.js CHANGED
@@ -154,6 +154,13 @@ function buildBrandJson(config) {
154
154
  pair: (a.fgName || a.fg || '') + ' on ' + (a.bgName || a.bg || ''),
155
155
  fg: a.fg, bg: a.bg, ratio: a.ratio, rating: a.rating
156
156
  };
157
+ }),
158
+ changelog: (config.changelog || []).map(function (e) {
159
+ return prune({
160
+ version: e.version || null,
161
+ date: e.date || null,
162
+ changes: e.changes || []
163
+ });
157
164
  })
158
165
  };
159
166
 
@@ -328,6 +335,30 @@ function buildBrandMarkdown(config) {
328
335
  }
329
336
  }
330
337
 
338
+ // Changelog — emitted unconditionally so an agent reading this brief always
339
+ // learns the maintenance rule, then the history (if any). brand.md is the
340
+ // file agents are pointed at to "stay on-brand", so the instruction lives here.
341
+ L.push(''); L.push('## Changelog');
342
+ L.push('When you change this brand — colors, type, logos, voice, spacing, or anything in ' +
343
+ '`config.json` — record it so the guide keeps an accurate history: run ' +
344
+ '`brandkit changelog "what changed"`. It prepends an entry and bumps the version (a fresh ' +
345
+ 'guide starts at 0.1 and climbs; add `--lock` to finalize the brand at 1.0).');
346
+ if (config.changelog && config.changelog.length) {
347
+ L.push(''); L.push('Revision history (newest first):'); L.push('');
348
+ config.changelog.forEach(function (e) {
349
+ var head = '- **v' + (e.version || '?') + '**' + (e.date ? ' (' + e.date + ')' : '');
350
+ var changes = (e.changes || []).filter(Boolean);
351
+ if (changes.length <= 1) {
352
+ // Single change reads cleanly inline.
353
+ L.push(head + (changes.length ? ': ' + changes[0] : ''));
354
+ } else {
355
+ // Multiple changes → nested bullets, so each keeps its own punctuation.
356
+ L.push(head + ':');
357
+ changes.forEach(function (c) { L.push(' - ' + c); });
358
+ }
359
+ });
360
+ }
361
+
331
362
  L.push('');
332
363
  return L.join('\n');
333
364
  }
package/lib/template.js CHANGED
@@ -19,6 +19,20 @@ function fontStack(font) {
19
19
  return stack + ', sans-serif';
20
20
  }
21
21
 
22
+ /**
23
+ * Strip the characters a config value would need to break out of the
24
+ * generated :root block ( < > ), out of the :root {…} rule ( { } ), or out
25
+ * of its own declaration to smuggle in another one ( ; ). A legitimate CSS
26
+ * token/value never contains them, so real configs pass through unchanged;
27
+ * an adversarial value like "}</style><script>..." or
28
+ * "red; background-image:url(x)" is defanged. Mirrors cssVal() in
29
+ * dist/engine.js + dist/changelog.js so dev (runtime-injected) and
30
+ * production (build-baked) :root blocks sanitize identically.
31
+ */
32
+ function cssValue(v) {
33
+ return String(v == null ? '' : v).replace(/[<>{};]/g, '');
34
+ }
35
+
22
36
  /**
23
37
  * Generate a :root CSS block from config.theme + config.fonts.
24
38
  */
@@ -27,7 +41,7 @@ function generateRootCSS(config) {
27
41
  if (config.theme) {
28
42
  var keys = Object.keys(config.theme);
29
43
  for (var i = 0; i < keys.length; i++) {
30
- lines.push(' ' + keys[i] + ': ' + config.theme[keys[i]] + ';');
44
+ lines.push(' ' + cssValue(keys[i]) + ': ' + cssValue(config.theme[keys[i]]) + ';');
31
45
  }
32
46
  }
33
47
  if (config.fonts) {
@@ -73,13 +87,19 @@ function normalizeBasePath(bp) {
73
87
  */
74
88
  function generateFontsLink(config) {
75
89
  if (!config.fonts) return '';
90
+ // Dedupe families: when display and body share a font (e.g. both Inter),
91
+ // baking it once avoids a redundant ...&family=Inter:...&family=Inter:...
92
+ // URL and the duplicate font CSS work it triggers.
93
+ var seen = {};
76
94
  var families = [];
77
- if (config.fonts.display && config.fonts.display.googleImport) {
78
- families.push(config.fonts.display.googleImport);
79
- }
80
- if (config.fonts.body && config.fonts.body.googleImport) {
81
- families.push(config.fonts.body.googleImport);
95
+ function addFamily(f) {
96
+ if (f && f.googleImport && !seen[f.googleImport]) {
97
+ seen[f.googleImport] = 1;
98
+ families.push(f.googleImport);
99
+ }
82
100
  }
101
+ addFamily(config.fonts.display);
102
+ addFamily(config.fonts.body);
83
103
  if (!families.length) return '';
84
104
  return '<link href="https://fonts.googleapis.com/css2?family=' +
85
105
  families.join('&family=') + '&display=swap" rel="stylesheet">';
@@ -141,10 +161,12 @@ function build(distDir, targetDir, config) {
141
161
  .replace(/src\s*=\s*["']engine\.js["']/, function () { return 'src="' + basePathAttr + '/engine.js"'; });
142
162
  }
143
163
 
144
- // Set title
164
+ // Set title \u2014 escape the brand name so a hostile displayName can't break out
165
+ // of the <title> element (e.g. "</title><script>\u2026").
166
+ var brandNameHtml = escapeAttr(brandName);
145
167
  htmlTemplate = htmlTemplate.replace(
146
168
  '<title>Brand Guide</title>',
147
- '<title>' + brandName + ' \u2014 Brand Guide</title>'
169
+ '<title>' + brandNameHtml + ' \u2014 Brand Guide</title>'
148
170
  );
149
171
 
150
172
  // Embed brand.json inline so an agent that fetches the page gets structured
@@ -159,11 +181,34 @@ function build(distDir, targetDir, config) {
159
181
 
160
182
  fs.writeFileSync(path.join(targetDir, 'index.html'), htmlTemplate);
161
183
 
162
- // Copy engine.js as-is
184
+ // Build the standalone changelog page the same way: inject the base script +
185
+ // fonts link + title, and (when a basePath is set) prefix its own asset refs
186
+ // so it resolves them from a non-trailing-slash URL. It renders from
187
+ // config.json at runtime (like index.html), so no data is embedded here.
188
+ var changelogTemplate = fs.readFileSync(path.join(distDir, 'changelog.html'), 'utf8');
189
+ var changelogHead = (basePath ? '<script>window.__BRANDKIT_BASE__ = ' + basePathLiteral + ';</script>\n' : '') +
190
+ (fontsLink ? fontsLink + '\n' : '');
191
+ changelogTemplate = changelogTemplate.replace('</head>', function () { return changelogHead + '</head>'; });
192
+ if (basePath) {
193
+ changelogTemplate = changelogTemplate
194
+ .replace(/href\s*=\s*["']styles\.css["']/, function () { return 'href="' + basePathAttr + '/styles.css"'; })
195
+ .replace(/src\s*=\s*["']changelog\.js["']/, function () { return 'src="' + basePathAttr + '/changelog.js"'; });
196
+ }
197
+ changelogTemplate = changelogTemplate.replace(
198
+ '<title>Changelog</title>',
199
+ '<title>' + brandNameHtml + ' — Changelog</title>'
200
+ );
201
+ fs.writeFileSync(path.join(targetDir, 'changelog.html'), changelogTemplate);
202
+
203
+ // Copy engine.js + changelog.js as-is
163
204
  fs.copyFileSync(
164
205
  path.join(distDir, 'engine.js'),
165
206
  path.join(targetDir, 'engine.js')
166
207
  );
208
+ fs.copyFileSync(
209
+ path.join(distDir, 'changelog.js'),
210
+ path.join(targetDir, 'changelog.js')
211
+ );
167
212
 
168
213
  return exported;
169
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stacklist-app/brandkit",
3
- "version": "1.2.4",
3
+ "version": "1.3.1",
4
4
  "description": "Config-driven brand guide that bolts onto any website. Zero dependencies.",
5
5
  "main": "lib/resolve.js",
6
6
  "bin": {