brandrail 0.2.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.
Files changed (2) hide show
  1. package/dist/brandrail.mjs +182 -13
  2. package/package.json +8 -9
@@ -604,7 +604,7 @@ function makeIgnore(globs) {
604
604
  }
605
605
 
606
606
  // ../engine/src/check.ts
607
- var ENGINE_VERSION = "0.2.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;
@@ -1252,6 +1252,7 @@ function mergeTokens(base, incoming) {
1252
1252
  }
1253
1253
 
1254
1254
  // ../engine/src/instructions.ts
1255
+ var KIT_PACKAGE = "@brandrail/kit";
1255
1256
  var RULES_START = "<!-- brandrail:start -->";
1256
1257
  var RULES_END = "<!-- brandrail:end -->";
1257
1258
  function generateInstructions(config, opts = {}) {
@@ -1263,9 +1264,15 @@ function generateInstructions(config, opts = {}) {
1263
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.`
1264
1265
  );
1265
1266
  lines.push("");
1266
- if (opts.tokensCss) {
1267
+ const usesKit = r.components.some((c) => c.importPath === KIT_PACKAGE);
1268
+ if (opts.tokensCss || usesKit) {
1267
1269
  lines.push("## Setup");
1268
- 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.`);
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
+ }
1269
1276
  lines.push("");
1270
1277
  }
1271
1278
  if (r.components.length) {
@@ -1387,7 +1394,7 @@ var RULE_FILES = [
1387
1394
  // ../engine/src/rendered.ts
1388
1395
  function collectRenderedNodes(maxNodes = 3e3) {
1389
1396
  const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "HEAD", "META", "LINK", "BR", "WBR", "SOURCE", "TRACK"]);
1390
- const px2 = (v) => {
1397
+ const px3 = (v) => {
1391
1398
  const n = parseFloat(v);
1392
1399
  return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
1393
1400
  };
@@ -1435,8 +1442,8 @@ function collectRenderedNodes(maxNodes = 3e3) {
1435
1442
  const r = el.getBoundingClientRect();
1436
1443
  if (r.width < 1 || r.height < 1) continue;
1437
1444
  const text = Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
1438
- const borderW = Math.max(px2(cs.borderTopWidth), px2(cs.borderRightWidth), px2(cs.borderBottomWidth), px2(cs.borderLeftWidth));
1439
- 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;
1440
1447
  nodes.push({
1441
1448
  selector: selectorOf(el),
1442
1449
  tag: el.tagName.toLowerCase(),
@@ -1446,12 +1453,12 @@ function collectRenderedNodes(maxNodes = 3e3) {
1446
1453
  borderColor: borderW > 0 && cs.borderTopStyle !== "none" ? borderSide : void 0,
1447
1454
  effectiveBackground: effectiveBg(el),
1448
1455
  fontFamily: cs.fontFamily,
1449
- fontSize: px2(cs.fontSize),
1456
+ fontSize: px3(cs.fontSize),
1450
1457
  fontWeight: parseInt(cs.fontWeight, 10) || 400,
1451
- 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)),
1452
1459
  minSide: Math.round(Math.min(r.width, r.height)),
1453
- padding: [px2(cs.paddingTop), px2(cs.paddingRight), px2(cs.paddingBottom), px2(cs.paddingLeft)],
1454
- 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)] : []
1455
1462
  });
1456
1463
  }
1457
1464
  const links = Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((h) => {
@@ -1667,6 +1674,161 @@ function generateTokensCss(tokens, opts = {}) {
1667
1674
  `;
1668
1675
  }
1669
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
+
1670
1832
  // src/api.ts
1671
1833
  var DEFAULT_API = "https://web-phi-nine-77.vercel.app";
1672
1834
  function apiBase() {
@@ -1979,7 +2141,14 @@ function tokensCssPath(root, config, flags) {
1979
2141
  function writeTokensCss(root, rel, config, org) {
1980
2142
  const abs = join3(root, rel);
1981
2143
  mkdirSync(dirname2(abs), { recursive: true });
1982
- writeFileSync(abs, generateTokensCss(config.tokens, { version: config.tokenVersion, org }));
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;
1983
2152
  }
1984
2153
  function writeRuleFiles(root, config, org, tokensCss) {
1985
2154
  const md = generateInstructions(config, { org, tokensCss });
@@ -2077,8 +2246,8 @@ async function cmdInit(cwd, flags) {
2077
2246
  }
2078
2247
  const css = flags["tokens-css"] === false ? void 0 : tokensCssPath(cwd, config, flags);
2079
2248
  if (css) {
2080
- writeTokensCss(cwd, css, config, org);
2081
- log(`\u2714 wrote ${css} (the Brand Kit as CSS variables \u2014 import it once at your app entry)`);
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)`);
2082
2251
  }
2083
2252
  if (flags.rules !== false) for (const f of writeRuleFiles(cwd, config, org, css)) log(`\u2714 updated ${f}`);
2084
2253
  if (flags.mcp) writeMcpConfigs(cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brandrail",
3
- "version": "0.2.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",
@@ -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' && chmod +x dist/brandrail.mjs",
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
+ }