brandrail 0.1.0 → 0.3.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 CHANGED
@@ -4,7 +4,7 @@ Keep AI-generated UI on-brand. `brandrail` checks code and live pages against yo
4
4
  (Brand Kit), and writes your rules into the files AI coding tools read.
5
5
 
6
6
  ```bash
7
- npx brandrail init # brandrail.config.json + AGENTS.md, CLAUDE.md, Cursor/Copilot/Windsurf/Gemini rules
7
+ npx brandrail init # brandrail.config.json, src/brandrail.tokens.css + AGENTS.md, CLAUDE.md, Cursor/Copilot/Windsurf/Gemini rules
8
8
  npx brandrail check # violations + brand score for this repo
9
9
  npx brandrail scan <url> # check a running page in a real browser
10
10
  ```
@@ -23,7 +23,7 @@ npx brandrail check --changed --upload # only changed lines, results in the d
23
23
  | --- | --- |
24
24
  | `check [paths]` | `--format pretty\|json\|markdown\|github`, `--fail-below 80`, `--changed [base]`, `--upload` |
25
25
  | `scan <url>` | `--pages 3`, `--storage-state auth.json` (pages behind a login), `--wait 2000`, `--upload`. Needs `npm i -D playwright && npx playwright install chromium` |
26
- | `init` | `--from-api`, `--mcp`, `--no-rules`, `--force` |
26
+ | `init` | `--from-api`, `--mcp`, `--tokens-css <file>`, `--no-rules`, `--force`. Writes the Brand Kit as CSS variables; import that file once at your app entry |
27
27
  | `import <tokens.json>` | DTCG, Tokens Studio or Figma variables JSON into the config. `--replace`, `--primitives` |
28
28
  | `rules` | List the rules |
29
29
 
@@ -175,10 +175,10 @@ function resolveConfig(config) {
175
175
  if (!l.type && !l.path.startsWith("color")) continue;
176
176
  const rgb = parseColor(l.value);
177
177
  if (!rgb) continue;
178
- const cssVar = `--${l.path.replace(/\./g, "-")}`;
179
- colors.push({ path: l.path, cssVar, hex: toHex(rgb), lab: rgbToLab(rgb), alpha: rgb.a });
178
+ const cssVar2 = `--${l.path.replace(/\./g, "-")}`;
179
+ colors.push({ path: l.path, cssVar: cssVar2, hex: toHex(rgb), lab: rgbToLab(rgb), alpha: rgb.a });
180
180
  const dark = typeof l.dark === "string" ? parseColor(l.dark) : null;
181
- if (dark) colors.push({ path: l.path, cssVar, hex: toHex(dark), lab: rgbToLab(dark), alpha: dark.a, mode: "dark" });
181
+ if (dark) colors.push({ path: l.path, cssVar: cssVar2, hex: toHex(dark), lab: rgbToLab(dark), alpha: dark.a, mode: "dark" });
182
182
  }
183
183
  const spaceLeaves = all.filter((l) => /^(space|spacing)\./.test(l.path));
184
184
  const spacing = [0, 1, 2];
@@ -604,7 +604,7 @@ function makeIgnore(globs) {
604
604
  }
605
605
 
606
606
  // ../engine/src/check.ts
607
- var ENGINE_VERSION = "0.1.0";
607
+ var ENGINE_VERSION = "0.3.0";
608
608
  var WEIGHT = { error: 4, warning: 1.5, info: 0.25 };
609
609
  function computeScore(counts, lines) {
610
610
  const penalty = counts.error * WEIGHT.error + counts.warning * WEIGHT.warning + counts.info * WEIGHT.info;
@@ -673,7 +673,7 @@ function check(files, configInput, opts = {}) {
673
673
  }
674
674
 
675
675
  // ../engine/src/report.ts
676
- var RENDERED_ONLY = { "text-contrast": "Low text contrast", "font-size-scale": "Off-scale font size" };
676
+ var RENDERED_ONLY = { "text-contrast": "Low text contrast", "font-size-scale": "Off-scale font size", "page-rendered": "Blank page" };
677
677
  var title = (id) => RULES.find((r) => r.id === id)?.title ?? RENDERED_ONLY[id] ?? id;
678
678
  var ansi = (on) => {
679
679
  const w = (code) => (s) => on ? `\x1B[${code}m${s}\x1B[0m` : s;
@@ -784,129 +784,6 @@ var starterConfig = {
784
784
  ignore: []
785
785
  };
786
786
 
787
- // ../engine/src/instructions.ts
788
- var RULES_START = "<!-- brandrail:start -->";
789
- var RULES_END = "<!-- brandrail:end -->";
790
- function generateInstructions(config, opts = {}) {
791
- const r = resolveConfig(config);
792
- const lines = [];
793
- lines.push(`# Design system rules${opts.org ? ` \u2014 ${opts.org}` : ""}`);
794
- lines.push("");
795
- lines.push(
796
- `All UI in this repo must follow the Brand Kit${r.tokenVersion ? ` (v${r.tokenVersion})` : ""}. These rules are checked on every pull request by Brandrail.`
797
- );
798
- lines.push("");
799
- if (r.components.length) {
800
- lines.push("## Components");
801
- lines.push("Always use these components. Never re-create them with raw HTML elements:");
802
- for (const c of r.components) {
803
- const tags = [...r.replaces].filter(([, v]) => v === c).map(([t]) => `<${t}>`);
804
- lines.push(`- \`${c.name}\` from \`${c.importPath}\`${tags.length ? ` (instead of ${tags.join(", ")})` : ""}`);
805
- }
806
- lines.push("");
807
- }
808
- if (r.colors.length) {
809
- lines.push("## Colours");
810
- lines.push("Use CSS variables only. Never write hex, rgb() or hsl() values, and never use Tailwind palette classes like `bg-blue-500`.");
811
- for (const c of r.colors) {
812
- if (c.mode) continue;
813
- const dark = r.colors.find((d) => d.mode === "dark" && d.path === c.path);
814
- lines.push(`- \`var(${c.cssVar})\` \u2014 ${c.path} (${c.hex}${dark ? `, dark ${dark.hex}` : ""})`);
815
- }
816
- lines.push("");
817
- }
818
- lines.push("## Spacing, radius and type");
819
- lines.push(`- Spacing (padding, margin, gap) must be one of: ${r.spacing.filter((s) => s > 2).map((s) => `${s}px`).join(", ")}.`);
820
- lines.push(`- Corner radius must be one of: ${r.radii.filter((x) => x > 0 && x < 999).map((x) => `${x}px`).join(", ")} (or fully rounded).`);
821
- if (r.fonts.length) lines.push(`- Fonts: use ${r.fontVars.map((v) => `\`var(${v})\``).join(" or ")}; never set another font-family.`);
822
- lines.push("");
823
- lines.push("## Before you finish");
824
- lines.push("- If the Brandrail MCP server is connected, call `validate_snippet` on the UI you wrote and fix every violation.");
825
- lines.push("- Otherwise run `npx brandrail check` and fix errors before committing.");
826
- lines.push("");
827
- lines.push("<details><summary>Checked rules</summary>");
828
- lines.push("");
829
- for (const rule of RULES) {
830
- if (r.rules[rule.id] === "off") continue;
831
- lines.push(`- **${rule.title}** (\`${rule.id}\`): ${rule.description}`);
832
- }
833
- lines.push("");
834
- lines.push("</details>");
835
- return lines.join("\n");
836
- }
837
- function upsertSection(existing, section) {
838
- const block = `${RULES_START}
839
- ${section}
840
- ${RULES_END}`;
841
- const start = existing.indexOf(RULES_START);
842
- const end = existing.indexOf(RULES_END);
843
- if (start !== -1 && end > start) return existing.slice(0, start) + block + existing.slice(end + RULES_END.length);
844
- return existing.trim() ? `${existing.trimEnd()}
845
-
846
- ${block}
847
- ` : `${block}
848
- `;
849
- }
850
-
851
- // ../engine/src/mcp-config.ts
852
- var json = (o) => `${JSON.stringify(o, null, 2)}
853
- `;
854
- function mcpClientConfigs(url) {
855
- return [
856
- {
857
- id: "claude-code",
858
- name: "Claude Code",
859
- file: ".mcp.json",
860
- projectFile: true,
861
- content: json({ mcpServers: { brandrail: { type: "http", url, headers: { Authorization: "Bearer ${BRANDRAIL_KEY}" } } } }),
862
- command: `claude mcp add --transport http brandrail ${url} --header "Authorization: Bearer $BRANDRAIL_KEY"`
863
- },
864
- {
865
- id: "codex",
866
- name: "Codex",
867
- file: "~/.codex/config.toml",
868
- projectFile: false,
869
- content: `[mcp_servers.brandrail]
870
- url = "${url}"
871
- bearer_token_env_var = "BRANDRAIL_KEY"
872
- `,
873
- command: `codex mcp add brandrail --url ${url} --bearer-token-env-var BRANDRAIL_KEY`
874
- },
875
- {
876
- id: "cursor",
877
- name: "Cursor",
878
- file: ".cursor/mcp.json",
879
- projectFile: true,
880
- content: json({ mcpServers: { brandrail: { url, headers: { Authorization: "Bearer ${env:BRANDRAIL_KEY}" } } } })
881
- },
882
- {
883
- id: "vscode",
884
- name: "GitHub Copilot (VS Code)",
885
- file: ".vscode/mcp.json",
886
- projectFile: true,
887
- content: json({
888
- inputs: [{ type: "promptString", id: "brandrail-key", description: "Brandrail API key", password: true }],
889
- servers: { brandrail: { type: "http", url, headers: { Authorization: "Bearer ${input:brandrail-key}" } } }
890
- })
891
- },
892
- {
893
- id: "windsurf",
894
- name: "Windsurf",
895
- file: "~/.codeium/windsurf/mcp_config.json",
896
- projectFile: false,
897
- content: json({ mcpServers: { brandrail: { serverUrl: url, headers: { Authorization: "Bearer ${env:BRANDRAIL_KEY}" } } } })
898
- }
899
- ];
900
- }
901
- var RULE_FILES = [
902
- { file: "AGENTS.md", readBy: "Codex, Jules, Amp, Cursor and other agents" },
903
- { file: "CLAUDE.md", readBy: "Claude Code" },
904
- { file: ".cursor/rules/brandrail.mdc", readBy: "Cursor" },
905
- { file: ".github/copilot-instructions.md", readBy: "GitHub Copilot" },
906
- { file: ".windsurf/rules/brandrail.md", readBy: "Windsurf" },
907
- { file: "GEMINI.md", readBy: "Gemini CLI" }
908
- ];
909
-
910
787
  // ../engine/src/tokens.ts
911
788
  var EXT = "dev.brandrail";
912
789
  function slug(s) {
@@ -1374,10 +1251,150 @@ function mergeTokens(base, incoming) {
1374
1251
  return buildDtcg([...m.values()]);
1375
1252
  }
1376
1253
 
1254
+ // ../engine/src/instructions.ts
1255
+ var KIT_PACKAGE = "@brandrail/kit";
1256
+ var RULES_START = "<!-- brandrail:start -->";
1257
+ var RULES_END = "<!-- brandrail:end -->";
1258
+ function generateInstructions(config, opts = {}) {
1259
+ const r = resolveConfig(config);
1260
+ const lines = [];
1261
+ lines.push(`# Design system rules${opts.org ? ` \u2014 ${opts.org}` : ""}`);
1262
+ lines.push("");
1263
+ lines.push(
1264
+ `All UI in this repo must follow the Brand Kit${r.tokenVersion ? ` (v${r.tokenVersion})` : ""}. These rules are checked on every pull request by Brandrail.`
1265
+ );
1266
+ lines.push("");
1267
+ const usesKit = r.components.some((c) => c.importPath === KIT_PACKAGE);
1268
+ if (opts.tokensCss || usesKit) {
1269
+ lines.push("## Setup");
1270
+ if (opts.tokensCss) {
1271
+ lines.push(`- \`${opts.tokensCss}\` defines every CSS variable below (light and dark). Import it once at the app entry, before other styles. Don't edit it: it's generated from the Brand Kit.`);
1272
+ }
1273
+ if (usesKit) {
1274
+ lines.push(`- Import \`${KIT_PACKAGE}/styles.css\` once at the app entry. Pass \`theme="brandrail"\` to kit components, and \`colorMode={ColorMode.DARK}\` (from \`${KIT_PACKAGE}\`) for dark mode. The Brand Kit styles them through the CSS variables; don't override their colours, spacing or fonts with your own CSS.`);
1275
+ }
1276
+ lines.push("");
1277
+ }
1278
+ if (r.components.length) {
1279
+ lines.push("## Components");
1280
+ lines.push("Always use these components. Never re-create them with raw HTML elements:");
1281
+ for (const c of r.components) {
1282
+ const tags = [...r.replaces].filter(([, v]) => v === c).map(([t]) => `<${t}>`);
1283
+ lines.push(`- \`${c.name}\` from \`${c.importPath}\`${tags.length ? ` (instead of ${tags.join(", ")})` : ""}`);
1284
+ }
1285
+ lines.push("");
1286
+ }
1287
+ if (r.colors.length) {
1288
+ lines.push("## Colours");
1289
+ lines.push("Use CSS variables only. Never write hex, rgb() or hsl() values, and never use Tailwind palette classes like `bg-blue-500`.");
1290
+ for (const c of r.colors) {
1291
+ if (c.mode) continue;
1292
+ const dark = r.colors.find((d) => d.mode === "dark" && d.path === c.path);
1293
+ lines.push(`- \`var(${c.cssVar})\` \u2014 ${c.path} (${c.hex}${dark ? `, dark ${dark.hex}` : ""})`);
1294
+ }
1295
+ lines.push("");
1296
+ }
1297
+ lines.push("## Spacing, radius and type");
1298
+ const named = (prefix) => flattenTokens(config.tokens).filter((t) => prefix.test(t.path)).map((t) => `\`var(--${t.path.replace(/\./g, "-")})\` ${t.value}`);
1299
+ const spaceVars = named(/^(space|spacing)\./);
1300
+ const radiusVars = named(/^radius\./);
1301
+ const sizeVars = named(/^font\.size\./);
1302
+ lines.push(`- Spacing (padding, margin, gap) must be one of: ${r.spacing.filter((s) => s > 2).map((s) => `${s}px`).join(", ")}.${spaceVars.length ? ` Prefer the variables: ${spaceVars.join(", ")}.` : ""}`);
1303
+ lines.push(`- Corner radius must be one of: ${r.radii.filter((x) => x > 0 && x < 999).map((x) => `${x}px`).join(", ")} (or fully rounded).${radiusVars.length ? ` Variables: ${radiusVars.join(", ")}.` : ""}`);
1304
+ if (sizeVars.length) lines.push(`- Font sizes come from the type scale: ${sizeVars.join(", ")}. Matching line heights are \`--font-line-height-*\`.`);
1305
+ if (r.fonts.length) lines.push(`- Fonts: use ${r.fontVars.map((v) => `\`var(${v})\``).join(" or ")}; never set another font-family.`);
1306
+ lines.push("");
1307
+ lines.push("## Before you finish");
1308
+ lines.push("- If the Brandrail MCP server is connected, call `validate_snippet` on the UI you wrote and fix every violation.");
1309
+ lines.push("- Otherwise run `npx brandrail check` and fix errors before committing.");
1310
+ lines.push("");
1311
+ lines.push("<details><summary>Checked rules</summary>");
1312
+ lines.push("");
1313
+ for (const rule of RULES) {
1314
+ if (r.rules[rule.id] === "off") continue;
1315
+ lines.push(`- **${rule.title}** (\`${rule.id}\`): ${rule.description}`);
1316
+ }
1317
+ lines.push("");
1318
+ lines.push("</details>");
1319
+ return lines.join("\n");
1320
+ }
1321
+ function upsertSection(existing, section) {
1322
+ const block = `${RULES_START}
1323
+ ${section}
1324
+ ${RULES_END}`;
1325
+ const start = existing.indexOf(RULES_START);
1326
+ const end = existing.indexOf(RULES_END);
1327
+ if (start !== -1 && end > start) return existing.slice(0, start) + block + existing.slice(end + RULES_END.length);
1328
+ return existing.trim() ? `${existing.trimEnd()}
1329
+
1330
+ ${block}
1331
+ ` : `${block}
1332
+ `;
1333
+ }
1334
+
1335
+ // ../engine/src/mcp-config.ts
1336
+ var json = (o) => `${JSON.stringify(o, null, 2)}
1337
+ `;
1338
+ function mcpClientConfigs(url) {
1339
+ return [
1340
+ {
1341
+ id: "claude-code",
1342
+ name: "Claude Code",
1343
+ file: ".mcp.json",
1344
+ projectFile: true,
1345
+ content: json({ mcpServers: { brandrail: { type: "http", url, headers: { Authorization: "Bearer ${BRANDRAIL_KEY}" } } } }),
1346
+ command: `claude mcp add --transport http brandrail ${url} --header "Authorization: Bearer $BRANDRAIL_KEY"`
1347
+ },
1348
+ {
1349
+ id: "codex",
1350
+ name: "Codex",
1351
+ file: "~/.codex/config.toml",
1352
+ projectFile: false,
1353
+ content: `[mcp_servers.brandrail]
1354
+ url = "${url}"
1355
+ bearer_token_env_var = "BRANDRAIL_KEY"
1356
+ `,
1357
+ command: `codex mcp add brandrail --url ${url} --bearer-token-env-var BRANDRAIL_KEY`
1358
+ },
1359
+ {
1360
+ id: "cursor",
1361
+ name: "Cursor",
1362
+ file: ".cursor/mcp.json",
1363
+ projectFile: true,
1364
+ content: json({ mcpServers: { brandrail: { url, headers: { Authorization: "Bearer ${env:BRANDRAIL_KEY}" } } } })
1365
+ },
1366
+ {
1367
+ id: "vscode",
1368
+ name: "GitHub Copilot (VS Code)",
1369
+ file: ".vscode/mcp.json",
1370
+ projectFile: true,
1371
+ content: json({
1372
+ inputs: [{ type: "promptString", id: "brandrail-key", description: "Brandrail API key", password: true }],
1373
+ servers: { brandrail: { type: "http", url, headers: { Authorization: "Bearer ${input:brandrail-key}" } } }
1374
+ })
1375
+ },
1376
+ {
1377
+ id: "windsurf",
1378
+ name: "Windsurf",
1379
+ file: "~/.codeium/windsurf/mcp_config.json",
1380
+ projectFile: false,
1381
+ content: json({ mcpServers: { brandrail: { serverUrl: url, headers: { Authorization: "Bearer ${env:BRANDRAIL_KEY}" } } } })
1382
+ }
1383
+ ];
1384
+ }
1385
+ var RULE_FILES = [
1386
+ { file: "AGENTS.md", readBy: "Codex, Jules, Amp, Cursor and other agents" },
1387
+ { file: "CLAUDE.md", readBy: "Claude Code" },
1388
+ { file: ".cursor/rules/brandrail.mdc", readBy: "Cursor" },
1389
+ { file: ".github/copilot-instructions.md", readBy: "GitHub Copilot" },
1390
+ { file: ".windsurf/rules/brandrail.md", readBy: "Windsurf" },
1391
+ { file: "GEMINI.md", readBy: "Gemini CLI" }
1392
+ ];
1393
+
1377
1394
  // ../engine/src/rendered.ts
1378
1395
  function collectRenderedNodes(maxNodes = 3e3) {
1379
1396
  const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "HEAD", "META", "LINK", "BR", "WBR", "SOURCE", "TRACK"]);
1380
- const px2 = (v) => {
1397
+ const px3 = (v) => {
1381
1398
  const n = parseFloat(v);
1382
1399
  return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
1383
1400
  };
@@ -1425,8 +1442,8 @@ function collectRenderedNodes(maxNodes = 3e3) {
1425
1442
  const r = el.getBoundingClientRect();
1426
1443
  if (r.width < 1 || r.height < 1) continue;
1427
1444
  const text = Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
1428
- const borderW = Math.max(px2(cs.borderTopWidth), px2(cs.borderRightWidth), px2(cs.borderBottomWidth), px2(cs.borderLeftWidth));
1429
- const borderSide = px2(cs.borderTopWidth) > 0 ? cs.borderTopColor : px2(cs.borderBottomWidth) > 0 ? cs.borderBottomColor : px2(cs.borderLeftWidth) > 0 ? cs.borderLeftColor : cs.borderRightColor;
1445
+ const borderW = Math.max(px3(cs.borderTopWidth), px3(cs.borderRightWidth), px3(cs.borderBottomWidth), px3(cs.borderLeftWidth));
1446
+ const borderSide = px3(cs.borderTopWidth) > 0 ? cs.borderTopColor : px3(cs.borderBottomWidth) > 0 ? cs.borderBottomColor : px3(cs.borderLeftWidth) > 0 ? cs.borderLeftColor : cs.borderRightColor;
1430
1447
  nodes.push({
1431
1448
  selector: selectorOf(el),
1432
1449
  tag: el.tagName.toLowerCase(),
@@ -1436,12 +1453,12 @@ function collectRenderedNodes(maxNodes = 3e3) {
1436
1453
  borderColor: borderW > 0 && cs.borderTopStyle !== "none" ? borderSide : void 0,
1437
1454
  effectiveBackground: effectiveBg(el),
1438
1455
  fontFamily: cs.fontFamily,
1439
- fontSize: px2(cs.fontSize),
1456
+ fontSize: px3(cs.fontSize),
1440
1457
  fontWeight: parseInt(cs.fontWeight, 10) || 400,
1441
- radius: Math.max(px2(cs.borderTopLeftRadius), px2(cs.borderTopRightRadius), px2(cs.borderBottomLeftRadius), px2(cs.borderBottomRightRadius)),
1458
+ radius: Math.max(px3(cs.borderTopLeftRadius), px3(cs.borderTopRightRadius), px3(cs.borderBottomLeftRadius), px3(cs.borderBottomRightRadius)),
1442
1459
  minSide: Math.round(Math.min(r.width, r.height)),
1443
- padding: [px2(cs.paddingTop), px2(cs.paddingRight), px2(cs.paddingBottom), px2(cs.paddingLeft)],
1444
- gap: cs.display.includes("flex") || cs.display.includes("grid") ? [px2(cs.rowGap), px2(cs.columnGap)] : []
1460
+ padding: [px3(cs.paddingTop), px3(cs.paddingRight), px3(cs.paddingBottom), px3(cs.paddingLeft)],
1461
+ gap: cs.display.includes("flex") || cs.display.includes("grid") ? [px3(cs.rowGap), px3(cs.columnGap)] : []
1445
1462
  });
1446
1463
  }
1447
1464
  const links = Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((h) => {
@@ -1587,6 +1604,17 @@ function checkRendered(pages, configInput, opts = {}) {
1587
1604
  }
1588
1605
  }
1589
1606
  const path = pagePath(page.url);
1607
+ if (page.nodes.length < 5) {
1608
+ violations.push({
1609
+ ruleId: "page-rendered",
1610
+ severity: "error",
1611
+ file: path,
1612
+ line: 0,
1613
+ column: 0,
1614
+ message: `The page rendered ${page.nodes.length} visible element${page.nodes.length === 1 ? "" : "s"}, so there was nothing to check`,
1615
+ suggestion: "Open it in a browser: it may have crashed, need a login (use --storage-state), or need longer to load (--wait)."
1616
+ });
1617
+ }
1590
1618
  for (const g of groups.values()) {
1591
1619
  violations.push({
1592
1620
  ruleId: g.ruleId,
@@ -1631,6 +1659,176 @@ function pagePath(url) {
1631
1659
  }
1632
1660
  }
1633
1661
 
1662
+ // ../engine/src/tokens-css.ts
1663
+ var cssVar = (path) => `--${path.replace(/\./g, "-")}`;
1664
+ function generateTokensCss(tokens, opts = {}) {
1665
+ const flat = flattenTokens(tokens);
1666
+ const head = `/* Generated by Brandrail from the ${opts.org ? `${opts.org} ` : ""}Brand Kit${opts.version ? ` v${opts.version}` : ""}. Do not edit: run \`npx brandrail init --from-api\` to update. */`;
1667
+ const light = flat.map((t) => ` ${cssVar(t.path)}: ${t.type === "fontFamily" ? t.value : t.value};`);
1668
+ const dark = flat.filter((t) => t.dark).map((t) => ` ${cssVar(t.path)}: ${t.dark};`);
1669
+ const out = [head, "", ":root {", ...light, "}"];
1670
+ if (dark.length) {
1671
+ out.push("", '[data-theme="dark"] {', ...dark, "}", "", "@media (prefers-color-scheme: dark) {", ' :root:not([data-theme="light"]) {', ...dark.map((l) => ` ${l}`), " }", "}");
1672
+ }
1673
+ return `${out.join("\n")}
1674
+ `;
1675
+ }
1676
+
1677
+ // ../engine/src/kit-bridge.ts
1678
+ var ROLE_PATTERNS = {
1679
+ canvas: [/canvas\.default$/, /^color\.(bg|background)(\.default)?$/, /surface(\.1|\.default)?$/, /canvas$/, /(bg|background)/],
1680
+ subtle: [/canvas\.subtle$/, /(bg|background)\.subtle$/, /surface\.2$/, /fill\.1$/, /subtle$/],
1681
+ fg: [/fg\.default$/, /text\.(default|primary|dark-primary)$/, /^color\.(fg|text|foreground)$/, /fg|text/],
1682
+ muted: [/fg\.muted$/, /text\.(muted|secondary|dark-secondary)$/, /muted|secondary/],
1683
+ border: [/border\.default$/, /^color\.(base\.)?border$/, /border/],
1684
+ primary: [/action\.primary$/, /^color\.(brand\.)?primary$/, /brand(-active)?$/, /primary/, /brand/],
1685
+ accent: [/accent(\.default)?$/, /link/, /info/, /accent/],
1686
+ danger: [/danger(\.default|-active)?$/, /error/, /danger/]
1687
+ };
1688
+ var ROLE_FALLBACK = {
1689
+ canvas: ["#ffffff", "#0d1117"],
1690
+ subtle: ["#f6f8fa", "#151b23"],
1691
+ fg: ["#1f2328", "#f0f6fc"],
1692
+ muted: ["#59636e", "#9198a1"],
1693
+ border: ["#d1d9e0", "#3d444d"],
1694
+ primary: ["#1f883d", "#238636"],
1695
+ accent: ["#0969da", "#4493f8"],
1696
+ danger: ["#cf222e", "#f85149"]
1697
+ };
1698
+ var HEX = /^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i;
1699
+ function pickColorRoles(colors, dark = false) {
1700
+ const out = {};
1701
+ for (const role of Object.keys(ROLE_PATTERNS)) {
1702
+ let hit;
1703
+ for (const re of ROLE_PATTERNS[role]) {
1704
+ hit = colors.find((c) => re.test(c.path) && HEX.test(c.value));
1705
+ if (hit) break;
1706
+ }
1707
+ const fb = ROLE_FALLBACK[role][dark ? 1 : 0];
1708
+ if (!hit) out[role] = { value: fb };
1709
+ else if (!dark) out[role] = { value: hit.value, path: hit.path };
1710
+ else if (hit.dark) out[role] = { value: hit.dark, path: hit.path };
1711
+ else out[role] = role === "primary" || role === "accent" || role === "danger" ? { value: hit.value, path: hit.path } : { value: fb, path: hit.path };
1712
+ }
1713
+ return out;
1714
+ }
1715
+ var KIT_SPACING = { 3: 4, 4: 6, 5: 8, 6: 10, 7: 12, 8: 16, 9: 20, 10: 24, 11: 28, 12: 32, 13: 36, 14: 40, 15: 44, 16: 48, 17: 56, 18: 64 };
1716
+ var KIT_RADIUS = { 1: 2, 2: 4, 3: 6, 4: 8, 5: 10, 6: 12, 7: 16, 8: 22, 9: 28 };
1717
+ var KIT_RADIUS_NAME = { 2: "sm", 3: "md", 6: "lg" };
1718
+ var KIT_TYPE = [
1719
+ ["Xs", 12, 18],
1720
+ ["Sm", 14, 22],
1721
+ ["Base", 16, 24],
1722
+ ["Md", 16, 24],
1723
+ ["Lg", 18, 28],
1724
+ ["Xl", 20, 32],
1725
+ ["2xl", 24, 38],
1726
+ ["3xl", 30, 42],
1727
+ ["4xl", 36, 52]
1728
+ ];
1729
+ var KIT_THEMES = ["brandrail", "themeA", "themeB"];
1730
+ var px2 = (v) => {
1731
+ const m = v.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/);
1732
+ return m ? Number.parseFloat(m[1]) * (m[2] === "rem" ? 16 : 1) : null;
1733
+ };
1734
+ var nearest2 = (values, v) => values.reduce((best, x) => Math.abs(x - v) < Math.abs(best - v) ? x : best, values[0]);
1735
+ function luminance(hex) {
1736
+ const n = hex.replace("#", "").slice(0, 6);
1737
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(n.slice(i, i + 2), 16) / 255).map((c) => c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
1738
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1739
+ }
1740
+ var contrast = (a, b) => {
1741
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
1742
+ return (x + 0.05) / (y + 0.05);
1743
+ };
1744
+ function onColor(bg, candidates) {
1745
+ return candidates.filter((c) => HEX.test(c)).sort((a, b) => contrast(b, bg) - contrast(a, bg))[0] ?? "#ffffff";
1746
+ }
1747
+ function scalesOf(flat) {
1748
+ const leaf = (p) => p.split(".").pop();
1749
+ const spacing = flat.filter((t) => /^(space|spacing)\./.test(t.path) && !/\.unit$/.test(t.path)).map((t) => px2(t.value)).filter((v) => v !== null && v > 0 && v < 999);
1750
+ const unit = flat.find((t) => /^space\.unit$/.test(t.path));
1751
+ if (!spacing.length && unit && px2(unit.value)) for (const m of [0.5, 1, 1.5, 2, 3, 4, 5, 6]) spacing.push(px2(unit.value) * m);
1752
+ const radii = flat.filter((t) => /^radius\./.test(t.path)).map((t) => ({ name: leaf(t.path), value: px2(t.value) ?? -1 })).filter((r) => r.value > 0 && r.value < 999);
1753
+ const lineHeights = new Map(flat.filter((t) => /^font\.line-height\./.test(t.path)).map((t) => [leaf(t.path), px2(t.value) ?? void 0]));
1754
+ const type = flat.filter((t) => /^font\.size\./.test(t.path)).map((t) => ({ name: leaf(t.path), size: px2(t.value) ?? 0, lineHeight: lineHeights.get(leaf(t.path)) })).filter((t) => t.size > 0);
1755
+ const font = flat.find((t) => t.type === "fontFamily" || /^font\.family\./.test(t.path))?.value;
1756
+ return { spacing, radii, type, font };
1757
+ }
1758
+ function scaleVars(s) {
1759
+ const out = [];
1760
+ if (s.spacing.length) for (const [n, v] of Object.entries(KIT_SPACING)) out.push(`--spacing${n}: ${nearest2(s.spacing, v)}px;`);
1761
+ if (s.radii.length) {
1762
+ const values = s.radii.map((r) => r.value);
1763
+ for (const [n, v] of Object.entries(KIT_RADIUS)) {
1764
+ const named = s.radii.find((r) => r.name === KIT_RADIUS_NAME[Number(n)]);
1765
+ out.push(`--defaultRadius-rad${n}: ${named ? named.value : nearest2(values, v)}px;`);
1766
+ }
1767
+ }
1768
+ if (s.type.length) {
1769
+ const sizes = s.type.map((t) => t.size);
1770
+ for (const [name, size, lh] of KIT_TYPE) {
1771
+ const step = s.type.find((t) => t.name.toLowerCase() === name.toLowerCase()) ?? s.type.find((t) => t.size === nearest2(sizes, size));
1772
+ out.push(`--fontA-fontSize${name}: ${step.size}px;`, `--fontA-lineHeight${name}: ${step.lineHeight ?? Math.round(step.size * lh / size)}px;`);
1773
+ }
1774
+ const base = s.type.find((t) => t.name === "base") ?? s.type.find((t) => t.size === nearest2(sizes, 14));
1775
+ out.push(`--fontSizeBase: ${base.size}px;`);
1776
+ }
1777
+ return out;
1778
+ }
1779
+ function colorVars(colors, dark, font) {
1780
+ const r = pickColorRoles(colors, dark);
1781
+ const find = (re) => {
1782
+ const c = colors.find((x) => re.test(x.path));
1783
+ return c ? dark ? c.dark ?? c.value : c.value : void 0;
1784
+ };
1785
+ const success = find(/success/);
1786
+ const warning = find(/warning|attention/);
1787
+ const mix = (a, b, pct) => `color-mix(in srgb, ${a} ${pct}%, ${b})`;
1788
+ const v = {
1789
+ baseSurface1: r.canvas.value,
1790
+ baseSurface2: r.subtle.value,
1791
+ baseSurface3: mix(r.subtle.value, r.border.value, 70),
1792
+ baseFill1: r.subtle.value,
1793
+ baseFill2: mix(r.subtle.value, r.border.value, 50),
1794
+ baseFill3: r.border.value,
1795
+ baseFill4: mix(r.muted.value, r.border.value, 60),
1796
+ baseFill5: r.muted.value,
1797
+ baseBorder: r.border.value,
1798
+ baseDisabled: mix(r.fg.value, "transparent", 28),
1799
+ textDarkPrimary: r.fg.value,
1800
+ textDarkSecondary: r.muted.value,
1801
+ textDarkTertiary: mix(r.muted.value, r.canvas.value, 75),
1802
+ textLightPrimary: onColor(r.primary.value, [pickColorRoles(colors, false).canvas.value, pickColorRoles(colors, false).fg.value]),
1803
+ stateBrandActive: r.primary.value,
1804
+ stateBrandHover: mix(r.primary.value, "black", 88),
1805
+ stateBrandSelected: mix(r.primary.value, "black", 75),
1806
+ stateDangerActive: r.danger.value,
1807
+ stateDangerHover: mix(r.danger.value, "black", 85),
1808
+ stateDangerSelected: mix(r.danger.value, r.canvas.value, 14),
1809
+ stateHighlightActive: r.accent.value,
1810
+ stateHighlightHover: mix(r.accent.value, r.canvas.value, 85),
1811
+ stateHighlightSelected: mix(r.accent.value, r.canvas.value, 14)
1812
+ };
1813
+ if (success) Object.assign(v, { stateSuccessActive: success, stateSuccessHover: mix(success, "black", 88), stateSuccessSelected: mix(success, r.canvas.value, 14) });
1814
+ if (warning) Object.assign(v, { stateWarningActive: warning, stateWarningHover: mix(warning, "black", 88), stateWarningSelected: mix(warning, r.canvas.value, 14) });
1815
+ if (font) Object.assign(v, { "fontA-fontFamily": font, "fontB-fontFamily": font });
1816
+ return Object.entries(v).map(([k, val]) => `--${k}: ${val};`);
1817
+ }
1818
+ function kitBridgeCss(tokens, opts = {}) {
1819
+ const flat = flattenTokens(tokens);
1820
+ const colors = flat.filter((t) => t.type === "color").map((t) => ({ path: t.path, value: t.value, dark: t.dark }));
1821
+ const sc = scalesOf(flat);
1822
+ const scales = scaleVars(sc);
1823
+ const block = (sel2, lines) => lines.length ? `${sel2} {
1824
+ ${lines.join("\n ")}
1825
+ }` : "";
1826
+ const themes = `:is(${KIT_THEMES.map((t) => `.${t}`).join(", ")})`;
1827
+ const s = opts.scope;
1828
+ const sel = s ? { scale: s, light: `${s}, ${s} .light`, dark: `${s} .dark, ${s}.dark` } : { scale: "html:root", light: `html:root, html:root ${themes}.light`, dark: `html:root ${themes}.dark` };
1829
+ return [block(sel.scale, scales), block(sel.light, colorVars(colors, false, sc.font)), block(sel.dark, colorVars(colors, true, sc.font))].filter(Boolean).join("\n");
1830
+ }
1831
+
1634
1832
  // src/api.ts
1635
1833
  var DEFAULT_API = "https://web-phi-nine-77.vercel.app";
1636
1834
  function apiBase() {
@@ -1834,6 +2032,7 @@ Options for init
1834
2032
  --no-rules Don't write AGENTS.md (Codex), CLAUDE.md, Cursor, Copilot, Windsurf, Gemini rule files
1835
2033
  --mcp Also add the Brandrail MCP server to .mcp.json, .cursor/mcp.json, .vscode/mcp.json
1836
2034
  and print the Codex / Windsurf config
2035
+ --tokens-css <file> Where to write the CSS variables (default src/brandrail.tokens.css); --no-tokens-css to skip
1837
2036
  --force Overwrite an existing brandrail.config.json
1838
2037
 
1839
2038
  Options for scan
@@ -1863,7 +2062,7 @@ function parseArgs(argv) {
1863
2062
  const [k, v] = a.slice(2).split("=", 2);
1864
2063
  const next = rest[i + 1];
1865
2064
  if (v !== void 0) flags[k] = v;
1866
- else if (next !== void 0 && !next.startsWith("-") && ["config", "format", "fail-below", "changed", "pages", "storage-state", "wait", "width"].includes(k)) {
2065
+ else if (next !== void 0 && !next.startsWith("-") && ["config", "format", "fail-below", "changed", "pages", "storage-state", "wait", "width", "tokens-css"].includes(k)) {
1867
2066
  flags[k] = next;
1868
2067
  i++;
1869
2068
  } else flags[k] = true;
@@ -1934,8 +2133,25 @@ Brand score ${report.score} is below ${threshold}.`);
1934
2133
  }
1935
2134
  return 0;
1936
2135
  }
1937
- function writeRuleFiles(root, config, org) {
1938
- const md = generateInstructions(config, { org });
2136
+ function tokensCssPath(root, config, flags) {
2137
+ if (typeof flags["tokens-css"] === "string") return flags["tokens-css"];
2138
+ if (config.tokensCss) return config.tokensCss;
2139
+ return existsSync3(join3(root, "src")) ? "src/brandrail.tokens.css" : "brandrail.tokens.css";
2140
+ }
2141
+ function writeTokensCss(root, rel, config, org) {
2142
+ const abs = join3(root, rel);
2143
+ mkdirSync(dirname2(abs), { recursive: true });
2144
+ let css = generateTokensCss(config.tokens, { version: config.tokenVersion, org });
2145
+ const usesKit = (config.components ?? []).some((c) => c.importPath === "@brandrail/kit");
2146
+ if (usesKit) css += `
2147
+ /* @brandrail/kit follows the Brand Kit: colours, font, spacing, radius and type scale. */
2148
+ ${kitBridgeCss(config.tokens)}
2149
+ `;
2150
+ writeFileSync(abs, css);
2151
+ return usesKit;
2152
+ }
2153
+ function writeRuleFiles(root, config, org, tokensCss) {
2154
+ const md = generateInstructions(config, { org, tokensCss });
1939
2155
  const written = [];
1940
2156
  const write = (rel, content) => {
1941
2157
  const abs = join3(root, rel);
@@ -2018,12 +2234,22 @@ async function cmdInit(cwd, flags) {
2018
2234
  if (existsSync3(target) && !flags.force && !flags["from-api"]) {
2019
2235
  log(`${CONFIG_FILE} already exists (use --force to overwrite). Updating rule files from it.`);
2020
2236
  config = readConfig(target);
2237
+ org ??= config.org;
2021
2238
  } else {
2239
+ if (existsSync3(target)) {
2240
+ const prev = readConfig(target);
2241
+ config = { ...prev, ...config, rules: { ...prev.rules, ...config.rules }, ignore: prev.ignore ?? config.ignore, tokensCss: prev.tokensCss };
2242
+ }
2022
2243
  writeFileSync(target, `${JSON.stringify(config, null, 2)}
2023
2244
  `);
2024
2245
  log(`\u2714 wrote ${CONFIG_FILE}${org ? ` from ${org}'s Brand Kit` : " (starter tokens \u2014 edit them or use --from-api)"}`);
2025
2246
  }
2026
- if (flags.rules !== false) for (const f of writeRuleFiles(cwd, config, org)) log(`\u2714 updated ${f}`);
2247
+ const css = flags["tokens-css"] === false ? void 0 : tokensCssPath(cwd, config, flags);
2248
+ if (css) {
2249
+ const kit = writeTokensCss(cwd, css, config, org);
2250
+ log(`\u2714 wrote ${css} (the Brand Kit as CSS variables${kit ? ", and @brandrail/kit themed from it" : ""} \u2014 import it once at your app entry)`);
2251
+ }
2252
+ if (flags.rules !== false) for (const f of writeRuleFiles(cwd, config, org, css)) log(`\u2714 updated ${f}`);
2027
2253
  if (flags.mcp) writeMcpConfigs(cwd);
2028
2254
  log("\nNext: npx brandrail check");
2029
2255
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brandrail",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Keep AI-generated UI on-brand: checks code and live pages against your design system, and wires your Brand Kit into Claude Code, Cursor, Copilot and Codex.",
5
5
  "keywords": [
6
6
  "design-system",
@@ -23,7 +23,7 @@
23
23
  "license": "UNLICENSED",
24
24
  "type": "module",
25
25
  "bin": {
26
- "brandrail": "./dist/brandrail.mjs"
26
+ "brandrail": "dist/brandrail.mjs"
27
27
  },
28
28
  "files": [
29
29
  "dist",
@@ -35,16 +35,15 @@
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  },
38
- "scripts": {
39
- "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/brandrail.mjs --external:playwright --external:playwright-core --banner:js='#!/usr/bin/env node'",
40
- "typecheck": "tsc --noEmit",
41
- "prepublishOnly": "pnpm run build"
42
- },
43
38
  "devDependencies": {
44
- "@brandrail/engine": "workspace:*",
45
39
  "@types/node": "^22",
46
40
  "esbuild": "^0.28.2",
47
41
  "playwright-core": "1.63.0",
48
- "typescript": "5.9.3"
42
+ "typescript": "5.9.3",
43
+ "@brandrail/engine": "0.1.0"
44
+ },
45
+ "scripts": {
46
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/brandrail.mjs --external:playwright --external:playwright-core --banner:js='#!/usr/bin/env node' && chmod +x dist/brandrail.mjs",
47
+ "typecheck": "tsc --noEmit"
49
48
  }
50
- }
49
+ }