brandrail 0.0.0-stage → 0.1.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.
@@ -0,0 +1,2139 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
5
+ import { dirname as dirname2, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
6
+
7
+ // ../engine/src/color.ts
8
+ var clamp = (n, lo = 0, hi = 255) => Math.min(hi, Math.max(lo, n));
9
+ function parseHex(hex) {
10
+ let h = hex.replace(/^#/, "").toLowerCase();
11
+ if (!/^[0-9a-f]+$/.test(h)) return null;
12
+ if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
13
+ if (h.length !== 6 && h.length !== 8) return null;
14
+ const n = (i) => parseInt(h.slice(i, i + 2), 16);
15
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) / 255 : 1 };
16
+ }
17
+ function hslToRgb(h, s, l) {
18
+ h = (h % 360 + 360) % 360;
19
+ s /= 100;
20
+ l /= 100;
21
+ const k = (n) => (n + h / 30) % 12;
22
+ const a = s * Math.min(l, 1 - l);
23
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
24
+ return [f(0) * 255, f(8) * 255, f(4) * 255];
25
+ }
26
+ function parseColor(input) {
27
+ const s = input.trim().toLowerCase();
28
+ if (s.startsWith("#")) return parseHex(s);
29
+ const m = s.match(/^(rgba?|hsla?)\(\s*([^)]*)\)$/);
30
+ if (!m) return null;
31
+ const parts = m[2].split(/[\s,/]+/).filter(Boolean);
32
+ if (parts.length < 3) return null;
33
+ const num = (p, max) => p.endsWith("%") ? parseFloat(p) / 100 * max : parseFloat(p);
34
+ const alpha = parts[3] !== void 0 ? num(parts[3], 1) : 1;
35
+ if (m[1].startsWith("rgb")) {
36
+ const [r2, g2, b2] = parts.slice(0, 3).map((p) => num(p, 255));
37
+ if ([r2, g2, b2].some((v) => Number.isNaN(v))) return null;
38
+ return { r: clamp(r2), g: clamp(g2), b: clamp(b2), a: alpha };
39
+ }
40
+ const [h, sat, l] = [parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])];
41
+ if ([h, sat, l].some((v) => Number.isNaN(v))) return null;
42
+ const [r, g, b] = hslToRgb(h, sat, l);
43
+ return { r: clamp(r), g: clamp(g), b: clamp(b), a: alpha };
44
+ }
45
+ function toHex({ r, g, b }) {
46
+ return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
47
+ }
48
+ function rgbToLab({ r, g, b }) {
49
+ const lin = (c) => {
50
+ const v = c / 255;
51
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
52
+ };
53
+ const [R, G, B] = [lin(r), lin(g), lin(b)];
54
+ const x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
55
+ const y = R * 0.2126 + G * 0.7152 + B * 0.0722;
56
+ const z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
57
+ const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
58
+ return { l: 116 * f(y) - 16, a: 500 * (f(x) - f(y)), b: 200 * (f(y) - f(z)) };
59
+ }
60
+ function deltaE(a, b) {
61
+ return Math.sqrt((a.l - b.l) ** 2 + (a.a - b.a) ** 2 + (a.b - b.b) ** 2);
62
+ }
63
+ function contrastRatio(a, b) {
64
+ const lum = ({ r, g, b: bl }) => {
65
+ const c = [r, g, bl].map((v) => {
66
+ const s = v / 255;
67
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
68
+ });
69
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
70
+ };
71
+ const [l1, l2] = [lum(a), lum(b)].sort((x, y) => y - x);
72
+ return (l1 + 0.05) / (l2 + 0.05);
73
+ }
74
+
75
+ // ../engine/src/config.ts
76
+ var DEFAULT_IGNORE = [
77
+ "**/node_modules/**",
78
+ "**/.git/**",
79
+ "**/dist/**",
80
+ "**/build/**",
81
+ "**/out/**",
82
+ "**/.next/**",
83
+ "**/.nuxt/**",
84
+ "**/.turbo/**",
85
+ "**/coverage/**",
86
+ "**/*.min.*",
87
+ "**/*.map",
88
+ "**/*.d.ts",
89
+ "**/tailwind.config.*",
90
+ "**/*.test.*",
91
+ "**/*.spec.*",
92
+ "**/*.stories.*",
93
+ "**/__tests__/**",
94
+ "**/__mocks__/**"
95
+ ];
96
+ var DEFAULT_FORBIDDEN_IMPORTS = [
97
+ "@mui/material",
98
+ "@mui/joy",
99
+ "@material-ui/core",
100
+ "antd",
101
+ "@chakra-ui/react",
102
+ "react-bootstrap",
103
+ "bootstrap",
104
+ "@mantine/core",
105
+ "semantic-ui-react",
106
+ "primereact"
107
+ ];
108
+ var NAME_TO_TAGS = {
109
+ button: ["button"],
110
+ input: ["input"],
111
+ textinput: ["input"],
112
+ inputtext: ["input"],
113
+ textfield: ["input"],
114
+ select: ["select"],
115
+ dropdown: ["select"],
116
+ textarea: ["textarea"],
117
+ inputtextarea: ["textarea"],
118
+ link: ["a"],
119
+ table: ["table"],
120
+ checkbox: ["input[type=checkbox]"],
121
+ radio: ["input[type=radio]"]
122
+ };
123
+ var GENERIC_FONTS = /* @__PURE__ */ new Set([
124
+ "inherit",
125
+ "initial",
126
+ "unset",
127
+ "system-ui",
128
+ "-apple-system",
129
+ "blinkmacsystemfont",
130
+ "sans-serif",
131
+ "serif",
132
+ "monospace",
133
+ "ui-sans-serif",
134
+ "ui-serif",
135
+ "ui-monospace",
136
+ "cursive",
137
+ "fantasy"
138
+ ]);
139
+ function leaves(doc, prefix = "", out = []) {
140
+ if (!doc || typeof doc !== "object") return out;
141
+ const obj = doc;
142
+ if ("$value" in obj) {
143
+ const ext = obj.$extensions?.["dev.brandrail"];
144
+ out.push({ path: prefix, type: obj.$type, value: obj.$value, dark: ext?.modes?.dark });
145
+ return out;
146
+ }
147
+ for (const [k, v] of Object.entries(obj)) {
148
+ if (k.startsWith("$")) continue;
149
+ leaves(v, prefix ? `${prefix}.${k}` : k, out);
150
+ }
151
+ return out;
152
+ }
153
+ var px = (v) => {
154
+ if (typeof v === "number") return v;
155
+ if (typeof v !== "string") return null;
156
+ const m = v.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/);
157
+ if (!m) return null;
158
+ const n = parseFloat(m[1]);
159
+ return m[2] === "rem" ? n * 16 : n;
160
+ };
161
+ function normalizeFontFamily(value) {
162
+ const first = value.split(",")[0] ?? "";
163
+ return first.trim().replace(/^['"]|['"]$/g, "").toLowerCase();
164
+ }
165
+ function isGenericFont(family) {
166
+ return GENERIC_FONTS.has(family) || family.startsWith("var(");
167
+ }
168
+ var uniqSorted = (xs) => [...new Set(xs.map((x) => Math.round(x * 100) / 100))].sort((a, b) => a - b);
169
+ function resolveConfig(config) {
170
+ const all = leaves(config.tokens);
171
+ const colors = [];
172
+ for (const l of all) {
173
+ if (typeof l.value !== "string") continue;
174
+ if (l.type && l.type !== "color") continue;
175
+ if (!l.type && !l.path.startsWith("color")) continue;
176
+ const rgb = parseColor(l.value);
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 });
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" });
182
+ }
183
+ const spaceLeaves = all.filter((l) => /^(space|spacing)\./.test(l.path));
184
+ const spacing = [0, 1, 2];
185
+ const unit = px(spaceLeaves.find((l) => /\.unit$/.test(l.path))?.value);
186
+ for (const l of spaceLeaves) {
187
+ const v = px(l.value);
188
+ if (v !== null && !/\.unit$/.test(l.path)) spacing.push(v);
189
+ }
190
+ if (unit) for (const m of [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24]) spacing.push(unit * m);
191
+ if (spacing.length === 3) for (const v of [4, 8, 12, 16, 20, 24, 32, 40, 48, 64]) spacing.push(v);
192
+ const radiusVals = all.filter((l) => /^(radius|radii|border-?radius)\./i.test(l.path)).map((l) => px(l.value)).filter((v) => v !== null);
193
+ const radii = [0, 9999, ...radiusVals];
194
+ if (radiusVals.length === 1) radii.push(Math.round(radiusVals[0] / 2), radiusVals[0] * 2);
195
+ if (radiusVals.length === 0) radii.push(2, 4, 6, 8, 12, 16);
196
+ const fontLeaves = all.filter((l) => (l.type === "fontFamily" || /^font\.family\./.test(l.path)) && typeof l.value === "string");
197
+ const fonts = fontLeaves.map((l) => normalizeFontFamily(l.value));
198
+ const fontVars = fontLeaves.map((l) => `--${l.path.replace(/\./g, "-")}`);
199
+ const components = config.components ?? [];
200
+ const replaces = /* @__PURE__ */ new Map();
201
+ for (const c of components) {
202
+ const tags = c.replaces ?? NAME_TO_TAGS[c.name.toLowerCase()] ?? [];
203
+ for (const t of tags) {
204
+ const key = t.toLowerCase().replace(/\s+/g, "").replace(/["']/g, "");
205
+ if (!replaces.has(key)) replaces.set(key, c);
206
+ }
207
+ }
208
+ return {
209
+ tokenVersion: config.tokenVersion,
210
+ colors,
211
+ spacing: uniqSorted(spacing),
212
+ radii: uniqSorted(radii),
213
+ fonts: [...new Set(fonts)],
214
+ fontVars: fontVars.length ? fontVars : ["--font-family-sans"],
215
+ fontSizes: uniqSorted(all.filter((l) => /^font\.size\./.test(l.path)).map((l) => px(l.value)).filter((v) => v !== null)),
216
+ components,
217
+ replaces,
218
+ rules: config.rules ?? {},
219
+ ignore: [...DEFAULT_IGNORE, ...config.ignore ?? []],
220
+ forbiddenImports: config.forbiddenImports ?? DEFAULT_FORBIDDEN_IMPORTS,
221
+ colorTolerance: config.colorTolerance ?? 2.5
222
+ };
223
+ }
224
+
225
+ // ../engine/src/rules.ts
226
+ var MARKUP = ["jsx", "vue", "svelte", "html"];
227
+ var ALL = ["jsx", "vue", "svelte", "html", "css"];
228
+ function* matches(re, text) {
229
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
230
+ let m;
231
+ while (m = g.exec(text)) {
232
+ yield m;
233
+ if (m[0].length === 0) g.lastIndex++;
234
+ }
235
+ }
236
+ var lineBefore = (code, offset, max = 80) => {
237
+ const start = Math.max(code.lastIndexOf("\n", offset - 1) + 1, offset - max);
238
+ return code.slice(start, offset);
239
+ };
240
+ var lineOf = (code, offset) => {
241
+ const start = code.lastIndexOf("\n", offset - 1) + 1;
242
+ const end = code.indexOf("\n", offset);
243
+ return code.slice(start, end === -1 ? void 0 : end);
244
+ };
245
+ var fmt = (n) => `${Math.round(n * 100) / 100}px`;
246
+ function neighbours(scale, v) {
247
+ const lower = [...scale].reverse().find((x) => x <= v);
248
+ const upper = scale.find((x) => x >= v);
249
+ const opts = [...new Set([lower, upper].filter((x) => x !== void 0))];
250
+ return opts.map(fmt).join(" or ");
251
+ }
252
+ function pxValues(value) {
253
+ const out = [];
254
+ for (const m of matches(/(-?\d*\.?\d+)(px|rem)\b/g, value)) {
255
+ const n = Math.abs(parseFloat(m[1])) * (m[2] === "rem" ? 16 : 1);
256
+ out.push({ n, index: m.index });
257
+ }
258
+ return out;
259
+ }
260
+ var noHardcodedColor = {
261
+ id: "no-hardcoded-color",
262
+ title: "Hard-coded colour",
263
+ description: "Colours must come from Brand Kit tokens, not hex / rgb / hsl literals.",
264
+ defaultSeverity: "error",
265
+ kinds: ALL,
266
+ check({ code, kind, config }) {
267
+ if (config.colors.length === 0) return [];
268
+ const out = [];
269
+ const re = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-zA-Z_-])|(?<![\w-])(?:rgba?|hsla?)\(\s*[^()]*\)/g;
270
+ for (const m of matches(re, code)) {
271
+ const raw = m[0];
272
+ const before = lineBefore(code, m.index);
273
+ const line = lineOf(code, m.index);
274
+ if (raw.startsWith("#")) {
275
+ const prev = code[m.index - 1] ?? "";
276
+ if (/[A-Za-z0-9_&/]/.test(prev)) continue;
277
+ if (/(?:href|to|id|xlink:href|for|aria-[a-z]+)\s*=\s*["'{`]?\s*$/i.test(before)) continue;
278
+ if (/url\(\s*["']?$/.test(before)) continue;
279
+ }
280
+ if (/--[\w-]+\s*:\s*[^;{}]*$/.test(before)) continue;
281
+ if (kind !== "css" && /^\s*(import|export)\s/.test(line) && !/style|color/i.test(line)) continue;
282
+ const rgb = parseColor(raw);
283
+ if (!rgb) continue;
284
+ const lab = rgbToLab(rgb);
285
+ let best = config.colors[0];
286
+ let bestD = Infinity;
287
+ for (const c of config.colors) {
288
+ const d = deltaE(lab, c.lab) + Math.abs(rgb.a - (c.alpha ?? 1)) * 100;
289
+ if (d < bestD) {
290
+ bestD = d;
291
+ best = c;
292
+ }
293
+ }
294
+ if (bestD <= config.colorTolerance) {
295
+ out.push({
296
+ offset: m.index,
297
+ severity: "warning",
298
+ message: `Hard-coded ${raw} matches the token ${best.path}`,
299
+ suggestion: `Use var(${best.cssVar}) so it follows Brand Kit updates`
300
+ });
301
+ } else {
302
+ out.push({
303
+ offset: m.index,
304
+ message: `${raw} is not in the Brand Kit`,
305
+ suggestion: `Closest token is ${best.path} (${best.hex}, \u0394E ${bestD.toFixed(1)}): use var(${best.cssVar})`
306
+ });
307
+ }
308
+ }
309
+ return out;
310
+ }
311
+ };
312
+ var PALETTE = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
313
+ var UTIL = "bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|fill|stroke|from|via|to|outline|divide|placeholder|accent|caret|decoration|shadow";
314
+ var noRawPaletteClass = {
315
+ id: "no-raw-palette-class",
316
+ title: "Raw Tailwind palette class",
317
+ description: "Use semantic token classes (bg-primary, text-muted) instead of Tailwind's default palette.",
318
+ defaultSeverity: "warning",
319
+ kinds: MARKUP,
320
+ check({ code }) {
321
+ const out = [];
322
+ const re = new RegExp(`(?<![\\w-])((?:[a-z0-9-]+:)*)(${UTIL})-(${PALETTE})-(50|[1-9]00|950)(?:/\\d{1,3})?(?![\\w-])`, "g");
323
+ for (const m of matches(re, code)) {
324
+ out.push({
325
+ offset: m.index + m[1].length,
326
+ message: `Tailwind palette class ${m[0].slice(m[1].length)} bypasses the Brand Kit`,
327
+ suggestion: `Use a semantic class mapped to a token, e.g. ${m[2]}-primary or ${m[2]}-muted`
328
+ });
329
+ }
330
+ return out;
331
+ }
332
+ };
333
+ var spacingScale = {
334
+ id: "spacing-scale",
335
+ title: "Off-scale spacing",
336
+ description: "Padding, margin and gap must use the spacing scale.",
337
+ defaultSeverity: "warning",
338
+ kinds: ALL,
339
+ check({ code, kind, config }) {
340
+ const out = [];
341
+ const scale = config.spacing;
342
+ const ok = (n) => scale.some((s) => Math.abs(s - n) < 0.01);
343
+ for (const m of matches(/(?<![\w-])(padding|margin|gap|row-gap|column-gap)(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?\s*:\s*([^;{}\n]+)/g, code)) {
344
+ const valueStart = m.index + m[0].length - m[2].length;
345
+ for (const v of pxValues(m[2])) {
346
+ if (!ok(v.n)) out.push({ offset: valueStart + v.index, message: `${m[1]} ${fmt(v.n)} is off the spacing scale`, suggestion: `Use ${neighbours(scale, v.n)}` });
347
+ }
348
+ }
349
+ if (kind !== "css") {
350
+ for (const m of matches(/(?<![\w-])(padding|margin|gap|rowGap|columnGap)(?:Top|Right|Bottom|Left|Inline|Block|X|Y)?\s*:\s*(['"`]?)(-?\d*\.?\d+)\2(?=\s*[,}\n])/g, code)) {
351
+ const n = Math.abs(parseFloat(m[3]));
352
+ if (!ok(n)) out.push({ offset: m.index, message: `${m[1]} ${fmt(n)} is off the spacing scale`, suggestion: `Use ${neighbours(scale, n)}` });
353
+ }
354
+ for (const m of matches(/(?<![\w-])-?(p[xytrbl]?|m[xytrbl]?|gap(?:-[xy])?|space-[xy])-\[(-?\d*\.?\d+)(px|rem)\]/g, code)) {
355
+ const n = Math.abs(parseFloat(m[2])) * (m[3] === "rem" ? 16 : 1);
356
+ if (!ok(n)) out.push({ offset: m.index, message: `${m[0]} is off the spacing scale`, suggestion: `Use ${neighbours(scale, n)}` });
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+ };
362
+ var radiusScale = {
363
+ id: "radius-scale",
364
+ title: "Off-scale corner radius",
365
+ description: "Border radius must use the radius tokens.",
366
+ defaultSeverity: "info",
367
+ kinds: ALL,
368
+ check({ code, kind, config }) {
369
+ const out = [];
370
+ const ok = (n) => n >= 999 || config.radii.some((r) => Math.abs(r - n) < 0.01);
371
+ for (const m of matches(/(?<![\w-])border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius\s*:\s*([^;{}\n]+)/g, code)) {
372
+ const valueStart = m.index + m[0].length - m[1].length;
373
+ for (const v of pxValues(m[1])) {
374
+ if (!ok(v.n)) out.push({ offset: valueStart + v.index, message: `border-radius ${fmt(v.n)} is not a radius token`, suggestion: `Use ${neighbours(config.radii.filter((r) => r < 999), v.n)}` });
375
+ }
376
+ }
377
+ if (kind !== "css") {
378
+ for (const m of matches(/(?<![\w-])borderRadius\s*:\s*(['"`]?)(\d*\.?\d+)\1(?=\s*[,}\n])/g, code)) {
379
+ const n = parseFloat(m[2]);
380
+ if (!ok(n)) out.push({ offset: m.index, message: `borderRadius ${fmt(n)} is not a radius token`, suggestion: `Use ${neighbours(config.radii.filter((r) => r < 999), n)}` });
381
+ }
382
+ for (const m of matches(/(?<![\w-])rounded(?:-[a-z]{1,2})?-\[(\d*\.?\d+)px\]/g, code)) {
383
+ const n = parseFloat(m[1]);
384
+ if (!ok(n)) out.push({ offset: m.index, message: `${m[0]} is not a radius token`, suggestion: `Use ${neighbours(config.radii.filter((r) => r < 999), n)}` });
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+ };
390
+ var fontFamily = {
391
+ id: "font-family",
392
+ title: "Unapproved font",
393
+ description: "Only the Brand Kit's font families may be used.",
394
+ defaultSeverity: "warning",
395
+ kinds: ALL,
396
+ check({ code, kind, config }) {
397
+ if (config.fonts.length === 0) return [];
398
+ const out = [];
399
+ const allowed = config.fonts;
400
+ const test = (value, offset) => {
401
+ const fam = normalizeFontFamily(value);
402
+ if (!fam || isGenericFont(fam) || allowed.includes(fam)) return;
403
+ out.push({
404
+ offset,
405
+ message: `Font "${fam}" is not in the Brand Kit`,
406
+ suggestion: `Use var(--font-family-sans) (${allowed[0]})`
407
+ });
408
+ };
409
+ for (const m of matches(/(?<![\w-])font-family\s*:\s*([^;{}\n]+)/g, code)) test(m[1], m.index);
410
+ if (kind !== "css") {
411
+ for (const m of matches(/(?<![\w-])fontFamily\s*:\s*(['"`])([^'"`]+)\1/g, code)) test(m[2], m.index);
412
+ for (const m of matches(/(?<![\w-])font-\[([^\]]+)\]/g, code)) test(m[1].replace(/_/g, " "), m.index);
413
+ }
414
+ return out;
415
+ }
416
+ };
417
+ var TEXT_INPUT_TYPES = /* @__PURE__ */ new Set(["", "text", "email", "password", "number", "search", "tel", "url"]);
418
+ var preferOfficialComponent = {
419
+ id: "prefer-official-component",
420
+ title: "Re-invented component",
421
+ description: "Use the design system component instead of a raw HTML element.",
422
+ defaultSeverity: "error",
423
+ kinds: MARKUP,
424
+ check({ code, config }) {
425
+ if (config.replaces.size === 0) return [];
426
+ const out = [];
427
+ const baseTags = [...new Set([...config.replaces.keys()].map((k) => k.replace(/\[.*$/, "").replace(/[^a-z0-9-]/g, "")))].filter(Boolean);
428
+ for (const m of matches(new RegExp(`<(${baseTags.join("|")})(?=[\\s>/])([^>]*)>`, "g"), code)) {
429
+ const tag = m[1];
430
+ const attrs = m[2];
431
+ let comp = config.replaces.get(tag);
432
+ if (tag === "input" || tag === "button") {
433
+ const type = (attrs.match(/\btype\s*=\s*\{?\s*["'`]([\w-]+)["'`]/)?.[1] ?? "").toLowerCase();
434
+ const specific = config.replaces.get(`${tag}[type=${type}]`);
435
+ if (specific) comp = specific;
436
+ else if (tag === "input" && !TEXT_INPUT_TYPES.has(type)) comp = type === "submit" || type === "button" ? config.replaces.get("button") : void 0;
437
+ }
438
+ if (!comp) continue;
439
+ if (new RegExp(`(?:function|const|class)\\s+${comp.name}\\b`).test(code)) continue;
440
+ out.push({
441
+ offset: m.index,
442
+ message: `Raw <${tag}${tag === "input" && /type=/.test(attrs) ? ` type="${attrs.match(/type\s*=\s*\{?\s*["'`]([\w-]+)/)?.[1]}"` : ""}> re-implements ${comp.name}`,
443
+ suggestion: `import { ${comp.name} } from "${comp.importPath}"`
444
+ });
445
+ }
446
+ return out;
447
+ }
448
+ };
449
+ var forbiddenImport = {
450
+ id: "forbidden-import",
451
+ title: "Other UI library",
452
+ description: "Only the design system's component library may be imported.",
453
+ defaultSeverity: "error",
454
+ kinds: ALL,
455
+ check({ code, config }) {
456
+ const out = [];
457
+ const lib = config.components[0]?.importPath;
458
+ const re = /(?:\bimport\s[^'";]*?from\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\(\s*|@import\s+(?:url\(\s*)?)(['"])([^'"]+)\1/g;
459
+ for (const m of matches(re, code)) {
460
+ const spec = m[2];
461
+ const bad = config.forbiddenImports.find((p) => spec === p || spec.startsWith(`${p}/`));
462
+ if (!bad) continue;
463
+ out.push({
464
+ offset: m.index + m[0].indexOf(spec),
465
+ message: `Imports ${bad}, which isn't part of the design system`,
466
+ suggestion: lib ? `Use components from "${lib}"` : "Use the design system's components"
467
+ });
468
+ }
469
+ return out;
470
+ }
471
+ };
472
+ var imgAlt = {
473
+ id: "img-alt",
474
+ title: "Image without alt text",
475
+ description: "Every <img> needs an alt attribute (empty for decorative images).",
476
+ defaultSeverity: "warning",
477
+ kinds: MARKUP,
478
+ check({ code }) {
479
+ const out = [];
480
+ for (const m of matches(/<img(?=[\s/>])([^>]*)>/g, code)) {
481
+ if (/(?<![\w-])alt\s*=/.test(m[1]) || /\{\s*\.\.\./.test(m[1])) continue;
482
+ out.push({ offset: m.index, message: "<img> has no alt text", suggestion: 'Add alt="\u2026" describing the image, or alt="" if decorative' });
483
+ }
484
+ return out;
485
+ }
486
+ };
487
+ var RULES = [
488
+ noHardcodedColor,
489
+ noRawPaletteClass,
490
+ preferOfficialComponent,
491
+ forbiddenImport,
492
+ spacingScale,
493
+ fontFamily,
494
+ radiusScale,
495
+ imgAlt
496
+ ];
497
+ var RULE_IDS = RULES.map((r) => r.id);
498
+
499
+ // ../engine/src/source.ts
500
+ function fileKind(path) {
501
+ const ext = path.toLowerCase().split(".").pop() ?? "";
502
+ if (["tsx", "jsx", "ts", "js", "mjs", "cjs", "mdx"].includes(ext)) return "jsx";
503
+ if (ext === "vue") return "vue";
504
+ if (ext === "svelte") return "svelte";
505
+ if (["html", "htm", "astro"].includes(ext)) return "html";
506
+ if (["css", "scss", "sass", "less", "pcss"].includes(ext)) return "css";
507
+ return "other";
508
+ }
509
+ var SCANNABLE = /* @__PURE__ */ new Set(["jsx", "vue", "svelte", "html", "css"]);
510
+ var blank = (s) => s.replace(/[^\n]/g, " ");
511
+ function stripComments(code, kind) {
512
+ let out = code;
513
+ if (kind === "html" || kind === "vue" || kind === "svelte") {
514
+ out = out.replace(/<!--[\s\S]*?-->/g, blank);
515
+ }
516
+ if (kind === "css") {
517
+ return out.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + blank(m.slice(p1.length)));
518
+ }
519
+ const chars = out.split("");
520
+ let i = 0;
521
+ let quote = null;
522
+ while (i < chars.length) {
523
+ const c = chars[i];
524
+ const n = chars[i + 1];
525
+ if (quote) {
526
+ if (c === "\\") i += 2;
527
+ else {
528
+ if (c === quote) quote = null;
529
+ i++;
530
+ }
531
+ continue;
532
+ }
533
+ if (c === '"' || c === "'" || c === "`") {
534
+ quote = c;
535
+ i++;
536
+ continue;
537
+ }
538
+ if (c === "/" && n === "*") {
539
+ const end = out.indexOf("*/", i + 2);
540
+ const stop = end === -1 ? chars.length : end + 2;
541
+ for (let j = i; j < stop; j++) if (chars[j] !== "\n") chars[j] = " ";
542
+ i = stop;
543
+ continue;
544
+ }
545
+ if (c === "/" && n === "/" && chars[i - 1] !== ":" && kind === "jsx") {
546
+ while (i < chars.length && chars[i] !== "\n") chars[i++] = " ";
547
+ continue;
548
+ }
549
+ i++;
550
+ }
551
+ return chars.join("");
552
+ }
553
+ var LineIndex = class {
554
+ starts = [0];
555
+ text;
556
+ constructor(text) {
557
+ this.text = text;
558
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) this.starts.push(i + 1);
559
+ }
560
+ position(offset) {
561
+ let lo = 0;
562
+ let hi = this.starts.length - 1;
563
+ while (lo < hi) {
564
+ const mid = lo + hi + 1 >> 1;
565
+ if (this.starts[mid] <= offset) lo = mid;
566
+ else hi = mid - 1;
567
+ }
568
+ return { line: lo + 1, column: offset - this.starts[lo] + 1 };
569
+ }
570
+ lineText(line) {
571
+ const start = this.starts[line - 1] ?? 0;
572
+ const end = this.starts[line] ?? this.text.length + 1;
573
+ return this.text.slice(start, end - 1);
574
+ }
575
+ get lineCount() {
576
+ return this.starts.length;
577
+ }
578
+ };
579
+ function globToRegExp(glob) {
580
+ let re = "";
581
+ for (let i = 0; i < glob.length; i++) {
582
+ const c = glob[i];
583
+ if (c === "*") {
584
+ if (glob[i + 1] === "*") {
585
+ const slash = glob[i + 2] === "/";
586
+ re += slash ? "(?:.*/)?" : ".*";
587
+ i += slash ? 2 : 1;
588
+ } else re += "[^/]*";
589
+ } else if (c === "?") re += "[^/]";
590
+ else if (c === "{") {
591
+ const end = glob.indexOf("}", i);
592
+ re += `(?:${glob.slice(i + 1, end).split(",").map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|")})`;
593
+ i = end;
594
+ } else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
595
+ }
596
+ return new RegExp(`^${re}$`);
597
+ }
598
+ function makeIgnore(globs) {
599
+ const res = globs.map(globToRegExp);
600
+ return (path) => {
601
+ const p = path.replace(/\\/g, "/").replace(/^\.\//, "");
602
+ return res.some((r) => r.test(p) || r.test(`/${p}`));
603
+ };
604
+ }
605
+
606
+ // ../engine/src/check.ts
607
+ var ENGINE_VERSION = "0.1.0";
608
+ var WEIGHT = { error: 4, warning: 1.5, info: 0.25 };
609
+ function computeScore(counts, lines) {
610
+ const penalty = counts.error * WEIGHT.error + counts.warning * WEIGHT.warning + counts.info * WEIGHT.info;
611
+ if (penalty === 0) return 100;
612
+ const perK = penalty / Math.max(1, lines / 1e3);
613
+ return Math.max(0, Math.min(99, Math.round(100 / (1 + perK / 25))));
614
+ }
615
+ function check(files, configInput, opts = {}) {
616
+ const t0 = Date.now();
617
+ const config = "replaces" in configInput ? configInput : resolveConfig(configInput);
618
+ const ignored = makeIgnore(config.ignore);
619
+ const violations = [];
620
+ let filesScanned = 0;
621
+ let linesScanned = 0;
622
+ for (const file of files) {
623
+ const path = file.path.replace(/\\/g, "/").replace(/^\.\//, "");
624
+ const kind = fileKind(path);
625
+ if (!SCANNABLE.has(kind) || ignored(path)) continue;
626
+ const only = opts.onlyLines?.get(path);
627
+ if (opts.onlyLines && (!only || only.size === 0)) continue;
628
+ filesScanned++;
629
+ const idx = new LineIndex(file.content);
630
+ linesScanned += only ? only.size : file.content.split("\n").filter((l) => l.trim()).length;
631
+ const code = stripComments(file.content, kind);
632
+ const seen = /* @__PURE__ */ new Set();
633
+ for (const rule of RULES) {
634
+ const setting = config.rules[rule.id];
635
+ if (setting === "off" || !rule.kinds.includes(kind)) continue;
636
+ for (const f of rule.check({ path, kind, code, config })) {
637
+ const { line, column } = idx.position(f.offset);
638
+ if (only && !only.has(line)) continue;
639
+ const key = `${rule.id}:${line}:${column}`;
640
+ if (seen.has(key)) continue;
641
+ seen.add(key);
642
+ violations.push({
643
+ ruleId: rule.id,
644
+ severity: setting ?? f.severity ?? rule.defaultSeverity,
645
+ file: path,
646
+ line,
647
+ column,
648
+ message: f.message,
649
+ suggestion: f.suggestion,
650
+ snippet: opts.snippets === false ? void 0 : idx.lineText(line).trim().slice(0, 160)
651
+ });
652
+ }
653
+ }
654
+ }
655
+ violations.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
656
+ const counts = { error: 0, warning: 0, info: 0 };
657
+ const perRule = /* @__PURE__ */ new Map();
658
+ for (const v of violations) {
659
+ counts[v.severity]++;
660
+ perRule.set(v.ruleId, (perRule.get(v.ruleId) ?? 0) + 1);
661
+ }
662
+ return {
663
+ engineVersion: ENGINE_VERSION,
664
+ tokenVersion: config.tokenVersion,
665
+ score: computeScore(counts, linesScanned),
666
+ counts,
667
+ filesScanned,
668
+ linesScanned,
669
+ violations,
670
+ byRule: [...perRule].map(([ruleId, count]) => ({ ruleId, count })).sort((a, b) => b.count - a.count),
671
+ durationMs: Date.now() - t0
672
+ };
673
+ }
674
+
675
+ // ../engine/src/report.ts
676
+ var RENDERED_ONLY = { "text-contrast": "Low text contrast", "font-size-scale": "Off-scale font size" };
677
+ var title = (id) => RULES.find((r) => r.id === id)?.title ?? RENDERED_ONLY[id] ?? id;
678
+ var ansi = (on) => {
679
+ const w = (code) => (s) => on ? `\x1B[${code}m${s}\x1B[0m` : s;
680
+ return { red: w("31"), yellow: w("33"), cyan: w("36"), green: w("32"), dim: w("2"), bold: w("1") };
681
+ };
682
+ var rendered = (r) => r.violations.length > 0 && r.violations.every((v) => v.line === 0);
683
+ function formatPretty(report, { color = true, max = 200 } = {}) {
684
+ const c = ansi(color);
685
+ const sev = { error: c.red, warning: c.yellow, info: c.cyan };
686
+ const lines = [];
687
+ let lastFile = "";
688
+ for (const v of report.violations.slice(0, max)) {
689
+ if (v.file !== lastFile) {
690
+ lines.push("", c.bold(v.file));
691
+ lastFile = v.file;
692
+ }
693
+ const rendered2 = v.line === 0;
694
+ lines.push(` ${c.dim(rendered2 ? "".padEnd(8) : `${v.line}:${v.column}`.padEnd(8))} ${sev[v.severity](v.severity.padEnd(7))} ${v.message} ${c.dim(v.ruleId)}`);
695
+ if (v.suggestion) lines.push(` ${" ".repeat(16)} ${c.green("\u2192")} ${v.suggestion}`);
696
+ if (rendered2 && v.snippet) lines.push(` ${" ".repeat(16)} ${c.dim(`on ${v.snippet}`)}`);
697
+ }
698
+ if (report.violations.length > max) lines.push("", c.dim(`\u2026 and ${report.violations.length - max} more`));
699
+ const s = report.score;
700
+ const scoreStr = s >= 80 ? c.green(String(s)) : s >= 65 ? c.yellow(String(s)) : c.red(String(s));
701
+ lines.push(
702
+ "",
703
+ `${c.bold("Brand score")} ${scoreStr}/100 ${c.dim("\xB7")} ${c.red(`${report.counts.error} errors`)} ${c.dim("\xB7")} ${c.yellow(`${report.counts.warning} warnings`)} ${c.dim("\xB7")} ${c.cyan(`${report.counts.info} info`)}`,
704
+ c.dim(`${report.filesScanned} ${rendered(report) ? "pages" : "files"}, ${report.linesScanned} ${rendered(report) ? "elements" : "lines"} in ${report.durationMs}ms${report.tokenVersion ? ` \xB7 Brand Kit v${report.tokenVersion}` : ""}`)
705
+ );
706
+ return lines.join("\n");
707
+ }
708
+ var esc = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
709
+ var escProp = (s) => esc(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
710
+ function formatGithubAnnotations(report, max = 50) {
711
+ const level = { error: "error", warning: "warning", info: "notice" };
712
+ return report.violations.slice(0, max).map(
713
+ (v) => `::${level[v.severity]} file=${escProp(v.file)},line=${v.line},col=${v.column},title=${escProp(`Brandrail: ${title(v.ruleId)}`)}::${esc(
714
+ v.suggestion ? `${v.message}. ${v.suggestion}` : v.message
715
+ )}`
716
+ ).join("\n");
717
+ }
718
+ var COMMENT_MARKER = "<!-- brandrail-report -->";
719
+ function formatMarkdown(report, opts = {}) {
720
+ const { threshold, maxRows = 30 } = opts;
721
+ const pass = threshold === void 0 || report.score >= threshold;
722
+ const badge = report.violations.length === 0 ? "\u{1F7E2}" : pass ? "\u{1F7E1}" : "\u{1F534}";
723
+ const out = [COMMENT_MARKER];
724
+ out.push(`## ${badge} Brand score: **${report.score}**/100${threshold !== void 0 ? ` (threshold ${threshold})` : ""}`);
725
+ out.push("");
726
+ out.push(
727
+ `**${report.counts.error}** errors \xB7 **${report.counts.warning}** warnings \xB7 **${report.counts.info}** info \u2014 ${report.filesScanned} files scanned${report.tokenVersion ? ` against Brand Kit v${report.tokenVersion}` : ""}`
728
+ );
729
+ if (report.violations.length === 0) {
730
+ out.push("", "Everything uses approved tokens and components. \u2728");
731
+ } else {
732
+ if (report.byRule.length) {
733
+ out.push("", "| Rule | Count |", "| --- | ---: |");
734
+ for (const r of report.byRule) out.push(`| ${title(r.ruleId)} \`${r.ruleId}\` | ${r.count} |`);
735
+ }
736
+ out.push("", "<details><summary>Violations</summary>", "", "| | Location | Issue | Fix |", "| --- | --- | --- | --- |");
737
+ const icon = { error: "\u{1F534}", warning: "\u{1F7E1}", info: "\u{1F535}" };
738
+ for (const v of report.violations.slice(0, maxRows)) {
739
+ const loc = v.line === 0 ? `${v.file} ${v.snippet ?? ""}`.trim() : `${v.file}:${v.line}`;
740
+ const url = opts.fileUrl?.(v);
741
+ const cell = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
742
+ out.push(`| ${icon[v.severity]} | ${url ? `[\`${loc}\`](${url})` : `\`${loc}\``} | ${cell(v.message)} | ${cell(v.suggestion ?? "")} |`);
743
+ }
744
+ if (report.violations.length > maxRows) out.push("", `\u2026and ${report.violations.length - maxRows} more.`);
745
+ out.push("", "</details>");
746
+ }
747
+ out.push("", `<sub>Brandrail engine ${report.engineVersion}${opts.dashboardUrl ? ` \xB7 [Open in Brandrail](${opts.dashboardUrl})` : ""}</sub>`);
748
+ return out.join("\n");
749
+ }
750
+
751
+ // ../engine/src/defaults.ts
752
+ var starterConfig = {
753
+ $schema: "https://brandrail.dev/schema/config.json",
754
+ tokenVersion: "0.0.0-local",
755
+ tokens: {
756
+ color: {
757
+ action: { primary: { $type: "color", $value: "#1f883d", $description: "One primary action per view" } },
758
+ accent: { default: { $type: "color", $value: "#0969da", $description: "Links, focus, selection" } },
759
+ fg: {
760
+ default: { $type: "color", $value: "#1f2328", $description: "Body text" },
761
+ muted: { $type: "color", $value: "#59636e", $description: "Secondary text" }
762
+ },
763
+ canvas: {
764
+ default: { $type: "color", $value: "#ffffff", $description: "Page background" },
765
+ subtle: { $type: "color", $value: "#f6f8fa", $description: "Headers, wells" }
766
+ },
767
+ border: { default: { $type: "color", $value: "#d1d9e0", $description: "Borders and dividers" } },
768
+ danger: { default: { $type: "color", $value: "#cf222e", $description: "Destructive actions, errors" } }
769
+ },
770
+ font: {
771
+ family: { sans: { $type: "fontFamily", $value: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif" } },
772
+ size: { base: { $type: "dimension", $value: "14px" } }
773
+ },
774
+ radius: { md: { $type: "dimension", $value: "6px" } },
775
+ space: { unit: { $type: "dimension", $value: "8px" } }
776
+ },
777
+ components: [
778
+ { name: "Button", importPath: "@acme/ui" },
779
+ { name: "TextInput", importPath: "@acme/ui" },
780
+ { name: "Select", importPath: "@acme/ui" },
781
+ { name: "Textarea", importPath: "@acme/ui" }
782
+ ],
783
+ rules: {},
784
+ ignore: []
785
+ };
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
+ // ../engine/src/tokens.ts
911
+ var EXT = "dev.brandrail";
912
+ function slug(s) {
913
+ return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/([A-Za-z])(\d)/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
914
+ }
915
+ var segs = (name) => name.split(/[/$.]+/).map(slug).filter(Boolean);
916
+ var stripPrefix = (s, ...prefixes) => {
917
+ for (const p of prefixes) if (s === p) return "";
918
+ for (const p of prefixes) if (s.startsWith(`${p}-`)) return s.slice(p.length + 1);
919
+ return s;
920
+ };
921
+ var WEIGHTS = {
922
+ thin: "100",
923
+ hairline: "100",
924
+ "extra-light": "200",
925
+ "ultra-light": "200",
926
+ light: "300",
927
+ regular: "400",
928
+ regulare: "400",
929
+ normal: "400",
930
+ book: "400",
931
+ medium: "500",
932
+ "semi-bold": "600",
933
+ semibold: "600",
934
+ "demi-bold": "600",
935
+ bold: "700",
936
+ "extra-bold": "800",
937
+ "ultra-bold": "800",
938
+ black: "900",
939
+ heavy: "900"
940
+ };
941
+ function fontWeight(v) {
942
+ if (typeof v === "number") return String(v);
943
+ return WEIGHTS[slug(v)] ?? (/^\d{3}$/.test(v.trim()) ? v.trim() : v);
944
+ }
945
+ function colorValue(v) {
946
+ if (v && typeof v === "object" && "r" in v) {
947
+ const c = v;
948
+ const hex2 = toHex({ r: c.r * 255, g: c.g * 255, b: c.b * 255, a: 1 });
949
+ const a = c.a ?? 1;
950
+ return a < 1 ? `${hex2}${Math.round(a * 255).toString(16).padStart(2, "0")}` : hex2;
951
+ }
952
+ if (typeof v !== "string") return null;
953
+ const s = v.trim();
954
+ if (/^#[0-9a-f]{3,8}$/i.test(s)) {
955
+ const h = s.slice(1).toLowerCase();
956
+ return h.length === 3 || h.length === 4 ? `#${[...h].map((c) => c + c).join("")}` : `#${h}`;
957
+ }
958
+ const rgb = parseColor(s);
959
+ if (!rgb) return null;
960
+ const hex = toHex(rgb);
961
+ return rgb.a < 1 ? `${hex}${Math.round(rgb.a * 255).toString(16).padStart(2, "0")}` : hex;
962
+ }
963
+ var dim = (v) => typeof v === "number" ? `${Math.round(v * 100) / 100}px` : String(v).trim();
964
+ function classify(name, kind, value, opts = {}) {
965
+ const parts = segs(name);
966
+ const hay = slug(`${opts.context ?? ""} ${name}`);
967
+ const scopes = opts.scopes ?? [];
968
+ const last = parts[parts.length - 1] ?? "";
969
+ const rest = parts.slice(0, -1);
970
+ if (kind === "boolean") return null;
971
+ if (kind === "color") {
972
+ const v2 = colorValue(value);
973
+ if (!v2) return null;
974
+ let p = parts[0] === "color" || parts[0] === "colors" ? parts.slice(1) : parts;
975
+ if (p.length === 1) p = [stripPrefix(p[0], "colors", "color") || p[0]];
976
+ const tail = p[p.length - 1];
977
+ const m = tail.match(/^(.*[a-z])-(\d+)$/);
978
+ if (m && p.length <= 2) p = [...p.slice(0, -1), m[1], m[2]];
979
+ return { path: ["color", ...p].join("."), type: "color", value: v2 };
980
+ }
981
+ if (kind === "string") {
982
+ const s = String(value);
983
+ if (scopes.includes("FONT_FAMILY") || /font-family|typeface|font-name/.test(hay) || last === "font") {
984
+ const n = stripPrefix(last, "font-family", "font", "family") || "sans";
985
+ return { path: `font.family.${/mono|code/.test(s.toLowerCase()) && n === "sans" ? "mono" : n}`, type: "fontFamily", value: s };
986
+ }
987
+ if (scopes.includes("FONT_STYLE") || scopes.includes("FONT_WEIGHT") || /font-weight|weight/.test(hay)) {
988
+ return { path: `font.weight.${stripPrefix(last, "font-weight", "weight") || last}`, type: "fontWeight", value: fontWeight(s) };
989
+ }
990
+ const c = colorValue(s);
991
+ if (c && /^#|^rgb|^hsl/i.test(s.trim())) return classify(name, "color", s, opts);
992
+ return null;
993
+ }
994
+ const v = dim(value);
995
+ const has = (...scope) => scope.some((x) => scopes.includes(x));
996
+ const nm = (...prefixes) => stripPrefix(last, ...prefixes) || last;
997
+ if (has("CORNER_RADIUS") || /radius|(^|-)rad(-|$)|corner|rounded/.test(hay)) return { path: `radius.${nm("radius", "rad", "corner", "rounded")}`, type: "dimension", value: v };
998
+ if (has("FONT_SIZE") || /font-size|text-size/.test(hay)) return { path: `font.size.${nm("font-size", "text-size", "size")}`, type: "dimension", value: v };
999
+ if (has("LINE_HEIGHT") || /line-height|leading/.test(hay)) return { path: `font.line-height.${nm("line-height", "leading")}`, type: "dimension", value: v };
1000
+ if (has("LETTER_SPACING") || /letter-spacing|tracking/.test(hay)) return { path: `font.letter-spacing.${nm("letter-spacing", "tracking")}`, type: "dimension", value: v };
1001
+ if (has("FONT_WEIGHT") || /font-weight/.test(hay)) return { path: `font.weight.${nm("font-weight", "weight")}`, type: "fontWeight", value: String(value) };
1002
+ if (/icon/.test(last)) return { path: `size.icon.${nm("icon")}`, type: "dimension", value: v };
1003
+ if (has("GAP") || /spac|gap|padding|margin|inset|(^|-)space(-|$)/.test(hay)) return { path: `space.${nm("spacing", "space", "gap")}`, type: "dimension", value: v };
1004
+ if (has("STROKE_FLOAT") || /stroke|border-width/.test(hay)) return { path: `border.width.${nm("border-width", "stroke")}`, type: "dimension", value: v };
1005
+ if (has("WIDTH_HEIGHT") || /size|width|height/.test(hay)) return { path: ["size", ...rest.filter((x) => x !== "size"), last].join("."), type: "dimension", value: v };
1006
+ return { path: ["number", ...parts].join("."), type: "number", value: String(value) };
1007
+ }
1008
+ function rawLeaves(node, prefix, out, studio, inherited) {
1009
+ if (!node || typeof node !== "object" || Array.isArray(node)) return;
1010
+ const o = node;
1011
+ const groupType = o.$type ?? inherited;
1012
+ const isLeaf = "$value" in o || studio && "value" in o && (typeof o.value !== "object" || o.value === null || "type" in o);
1013
+ if (isLeaf) {
1014
+ const ext = o.$extensions?.[EXT];
1015
+ out.push({
1016
+ path: prefix,
1017
+ type: o.$type ?? o.type ?? groupType,
1018
+ value: "$value" in o ? o.$value : o.value,
1019
+ description: o.$description ?? o.description,
1020
+ dark: ext?.modes?.dark
1021
+ });
1022
+ return;
1023
+ }
1024
+ for (const [k, v] of Object.entries(o)) if (!k.startsWith("$")) rawLeaves(v, prefix ? `${prefix}.${k}` : k, out, studio, groupType);
1025
+ }
1026
+ var REF = /^\{([^}]+)\}$/;
1027
+ function resolveRefs(raws) {
1028
+ const byPath = new Map(raws.map((r) => [r.path, r]));
1029
+ const deref = (v, mode, depth = 0) => {
1030
+ if (typeof v !== "string" || depth > 10) return v;
1031
+ const m = v.trim().match(REF);
1032
+ if (!m) return v.replace(/\{([^}]+)\}/g, (all, p) => String(byPath.get(p)?.value ?? all));
1033
+ const target = byPath.get(m[1]);
1034
+ if (!target) return v;
1035
+ return deref(mode === "dark" && target.dark !== void 0 ? target.dark : target.value, mode, depth + 1);
1036
+ };
1037
+ return raws.map((r) => ({ ...r, value: deref(r.value, "light"), dark: r.dark !== void 0 ? deref(r.dark, "dark") : void 0 }));
1038
+ }
1039
+ var TYPE_KIND = {
1040
+ color: "color",
1041
+ dimension: "number",
1042
+ spacing: "number",
1043
+ sizing: "number",
1044
+ borderradius: "number",
1045
+ fontsizes: "number",
1046
+ fontsize: "number",
1047
+ lineheights: "number",
1048
+ lineheight: "number",
1049
+ letterspacing: "number",
1050
+ number: "number",
1051
+ fontfamilies: "string",
1052
+ fontfamily: "string",
1053
+ fontweights: "string",
1054
+ fontweight: "string"
1055
+ };
1056
+ function flattenTokens(doc) {
1057
+ const raws = resolveRefs(collectRaw(doc).raws);
1058
+ const out = [];
1059
+ for (const r of raws) {
1060
+ const t = (r.type ?? "").toLowerCase().replace(/[^a-z]/g, "");
1061
+ if (t === "color" || !t && r.path.startsWith("color")) {
1062
+ const v = colorValue(r.value);
1063
+ if (!v) continue;
1064
+ const d = r.dark !== void 0 ? colorValue(r.dark) : null;
1065
+ out.push({ path: r.path, type: "color", value: v, ...d ? { dark: d } : {}, ...r.description ? { description: r.description } : {} });
1066
+ } else if (typeof r.value === "string" || typeof r.value === "number") {
1067
+ const type = t === "fontfamily" || t === "fontfamilies" ? "fontFamily" : t === "fontweight" || t === "fontweights" ? "fontWeight" : TYPE_KIND[t] === "number" ? "dimension" : t === "number" ? "number" : /^font\.family\./.test(r.path) ? "fontFamily" : typeof r.value === "number" ? "number" : /px$|rem$/.test(String(r.value)) ? "dimension" : "string";
1068
+ out.push({ path: r.path, type, value: type === "dimension" ? dim(r.value) : String(r.value), ...r.description ? { description: r.description } : {} });
1069
+ }
1070
+ }
1071
+ return out;
1072
+ }
1073
+ function collectRaw(doc) {
1074
+ const o = doc ?? {};
1075
+ const studio = !hasDollarValue(o) && hasStudioValue(o);
1076
+ const order = o.$metadata?.tokenSetOrder;
1077
+ if (!studio) {
1078
+ const raws = [];
1079
+ rawLeaves(o, "", raws, false);
1080
+ return { raws, studio };
1081
+ }
1082
+ const setNames = (order ?? Object.keys(o).filter((k) => !k.startsWith("$"))).filter((k) => o[k] && typeof o[k] === "object");
1083
+ const looksLikeSets = setNames.length > 0 && setNames.every((k) => !hasLeafAtTop(o[k]));
1084
+ if (!looksLikeSets) {
1085
+ const raws = [];
1086
+ rawLeaves(o, "", raws, true);
1087
+ return { raws, studio };
1088
+ }
1089
+ const merged = /* @__PURE__ */ new Map();
1090
+ const darkSets = setNames.filter((n) => /dark/i.test(n));
1091
+ for (const set of setNames.filter((n) => !/dark/i.test(n))) {
1092
+ const raws = [];
1093
+ rawLeaves(o[set], "", raws, true);
1094
+ for (const r of raws) merged.set(r.path, r);
1095
+ }
1096
+ for (const set of darkSets) {
1097
+ const raws = [];
1098
+ rawLeaves(o[set], "", raws, true);
1099
+ for (const r of raws) {
1100
+ const base = merged.get(r.path);
1101
+ if (base) base.dark = r.value;
1102
+ else merged.set(r.path, r);
1103
+ }
1104
+ }
1105
+ return { raws: [...merged.values()], studio };
1106
+ }
1107
+ function hasDollarValue(node, depth = 0) {
1108
+ if (!node || typeof node !== "object" || depth > 12) return false;
1109
+ if ("$value" in node) return true;
1110
+ return Object.values(node).some((v) => hasDollarValue(v, depth + 1));
1111
+ }
1112
+ function hasStudioValue(node, depth = 0) {
1113
+ if (!node || typeof node !== "object" || depth > 12) return false;
1114
+ const o = node;
1115
+ if ("value" in o && "type" in o) return true;
1116
+ return Object.values(o).some((v) => hasStudioValue(v, depth + 1));
1117
+ }
1118
+ function hasLeafAtTop(node) {
1119
+ return !!node && typeof node === "object" && ("value" in node || "$value" in node);
1120
+ }
1121
+ function buildDtcg(tokens) {
1122
+ const tree = {};
1123
+ for (const t of tokens) {
1124
+ const parts = t.path.split(".");
1125
+ let node = tree;
1126
+ parts.forEach((p, i) => {
1127
+ if (i === parts.length - 1) {
1128
+ node[p] = {
1129
+ $type: t.type,
1130
+ $value: t.value,
1131
+ ...t.description ? { $description: t.description } : {},
1132
+ ...t.dark && t.dark !== t.value ? { $extensions: { [EXT]: { modes: { dark: t.dark } } } } : {}
1133
+ };
1134
+ } else {
1135
+ const next = node[p];
1136
+ if (next && typeof next === "object" && "$value" in next) node[p] = { default: next };
1137
+ node = node[p] ??= {};
1138
+ }
1139
+ });
1140
+ }
1141
+ return tree;
1142
+ }
1143
+ var isAlias = (v) => !!v && typeof v === "object" && v.type === "VARIABLE_ALIAS";
1144
+ var DARK = /dark|night/i;
1145
+ var LIGHT = /light|default|day|base/i;
1146
+ function finish(format, all, notes, opts) {
1147
+ const PALETTE2 = /^(color\.[a-z-]+)\.\d{1,3}$/;
1148
+ const usedFamilies = new Set(all.filter((t) => t.referenced).map((t) => t.path.match(PALETTE2)?.[1]).filter(Boolean));
1149
+ if (usedFamilies.size) {
1150
+ const steps = /* @__PURE__ */ new Map();
1151
+ for (const t of all) {
1152
+ const f = t.path.match(PALETTE2)?.[1];
1153
+ if (f) steps.set(f, (steps.get(f) ?? 0) + 1);
1154
+ }
1155
+ for (const t of all) {
1156
+ const f = t.path.match(PALETTE2)?.[1];
1157
+ if (f && (usedFamilies.has(f) || (steps.get(f) ?? 0) >= 3)) t.referenced = true;
1158
+ }
1159
+ }
1160
+ const hasSemantic = all.some((t) => !t.referenced) && all.some((t) => t.referenced);
1161
+ const keep = opts.includePrimitives || !hasSemantic ? all : all.filter((t) => !t.referenced);
1162
+ const primitives = hasSemantic ? all.filter((t) => t.referenced).length : 0;
1163
+ const seen = /* @__PURE__ */ new Set();
1164
+ const tokens = [];
1165
+ for (const { referenced: _r, ...t } of keep) {
1166
+ if (seen.has(t.path)) continue;
1167
+ seen.add(t.path);
1168
+ tokens.push(t);
1169
+ }
1170
+ tokens.sort((a, b) => a.path.localeCompare(b.path, "en", { numeric: true }));
1171
+ return { format, tokens: buildDtcg(tokens), count: tokens.length, primitives, notes };
1172
+ }
1173
+ function fromFigmaVariables(meta, opts = {}) {
1174
+ const vars = meta.variables ?? {};
1175
+ const cols = meta.variableCollections ?? {};
1176
+ const notes = [];
1177
+ const referenced = /* @__PURE__ */ new Set();
1178
+ const modeFor = (col, want, likeName) => {
1179
+ if (likeName) {
1180
+ const same = col.modes.find((m) => m.name.toLowerCase() === likeName.toLowerCase());
1181
+ if (same) return same.modeId;
1182
+ }
1183
+ if (want === "dark") return col.modes.find((m) => DARK.test(m.name))?.modeId;
1184
+ return col.defaultModeId ?? col.modes[0]?.modeId;
1185
+ };
1186
+ const resolve4 = (v, want, modeName, depth = 0) => {
1187
+ const col = cols[v.variableCollectionId];
1188
+ if (!col) return void 0;
1189
+ const modeId = modeFor(col, want, modeName) ?? col.defaultModeId;
1190
+ const raw = v.valuesByMode[modeId] ?? Object.values(v.valuesByMode)[0];
1191
+ if (!isAlias(raw)) return raw;
1192
+ referenced.add(raw.id);
1193
+ const target = vars[raw.id];
1194
+ if (!target || depth > 10) return void 0;
1195
+ const name = col.modes.find((m) => m.modeId === modeId)?.name;
1196
+ return resolve4(target, want, name, depth + 1);
1197
+ };
1198
+ for (const v of Object.values(vars)) for (const val of Object.values(v.valuesByMode)) if (isAlias(val)) referenced.add(val.id);
1199
+ for (const col of Object.values(cols)) {
1200
+ if (col.remote) continue;
1201
+ const dark = col.modes.find((m) => DARK.test(m.name));
1202
+ const others = col.modes.filter((m) => m.modeId !== col.defaultModeId && m !== dark);
1203
+ if (others.length) {
1204
+ const def = col.modes.find((m) => m.modeId === col.defaultModeId)?.name;
1205
+ notes.push(`\u201C${col.name}\u201D has modes ${col.modes.map((m) => m.name).join(", ")}; used \u201C${def}\u201D${dark ? ` (and \u201C${dark.name}\u201D as dark)` : ""}.`);
1206
+ }
1207
+ }
1208
+ const out = [];
1209
+ let hidden = 0;
1210
+ for (const v of Object.values(vars)) {
1211
+ const col = cols[v.variableCollectionId];
1212
+ if (!col || v.remote || col.remote) continue;
1213
+ if (v.hiddenFromPublishing) {
1214
+ hidden++;
1215
+ continue;
1216
+ }
1217
+ const kind = v.resolvedType === "COLOR" ? "color" : v.resolvedType === "FLOAT" ? "number" : v.resolvedType === "STRING" ? "string" : "boolean";
1218
+ const value = resolve4(v, "default");
1219
+ if (value === void 0) continue;
1220
+ const c = classify(v.name, kind, value, { scopes: v.scopes?.filter((s) => s !== "ALL_SCOPES"), context: col.name });
1221
+ if (!c) continue;
1222
+ let darkValue;
1223
+ if (c.type === "color") {
1224
+ const hasDarkSomewhere = Object.values(cols).some((x) => x.modes.some((m) => DARK.test(m.name)));
1225
+ if (hasDarkSomewhere) {
1226
+ const d = colorValue(resolve4(v, "dark"));
1227
+ if (d && d !== c.value) darkValue = d;
1228
+ }
1229
+ }
1230
+ out.push({ ...c, ...darkValue ? { dark: darkValue } : {}, ...v.description ? { description: v.description } : {}, referenced: referenced.has(v.id) });
1231
+ }
1232
+ if (hidden) notes.push(`${hidden} variable${hidden === 1 ? "" : "s"} hidden from publishing were skipped.`);
1233
+ return finish("figma-variables", out, notes, opts);
1234
+ }
1235
+ function fromFigmaStyles(styles, opts = {}) {
1236
+ const out = [];
1237
+ const families = /* @__PURE__ */ new Map();
1238
+ let skipped = 0;
1239
+ for (const s of styles) {
1240
+ if (s.styleType === "FILL") {
1241
+ const fill = s.node?.fills?.find((f) => f.type === "SOLID" && f.visible !== false && f.color);
1242
+ if (!fill?.color) {
1243
+ skipped++;
1244
+ continue;
1245
+ }
1246
+ const c = classify(s.name, "color", { ...fill.color, a: (fill.color.a ?? 1) * (fill.opacity ?? 1) });
1247
+ if (c) out.push({ ...c, ...s.description ? { description: s.description } : {} });
1248
+ } else if (s.styleType === "TEXT" && s.node?.style) {
1249
+ const st = s.node.style;
1250
+ const name = segs(s.name).join("-");
1251
+ if (st.fontSize) out.push({ path: `font.size.${name}`, type: "dimension", value: dim(st.fontSize) });
1252
+ if (st.lineHeightPx) out.push({ path: `font.line-height.${name}`, type: "dimension", value: dim(Math.round(st.lineHeightPx)) });
1253
+ if (st.fontWeight) out.push({ path: `font.weight.${name}`, type: "fontWeight", value: String(st.fontWeight) });
1254
+ if (st.fontFamily) families.set(st.fontFamily, (families.get(st.fontFamily) ?? 0) + 1);
1255
+ }
1256
+ }
1257
+ const fams = [...families.entries()].sort((a, b) => b[1] - a[1]);
1258
+ fams.forEach(([f], i) => out.push({ path: `font.family.${i === 0 ? "sans" : /mono|code/i.test(f) ? "mono" : slug(f)}`, type: "fontFamily", value: f }));
1259
+ const notes = skipped ? [`${skipped} fill style${skipped === 1 ? "" : "s"} (gradients or images) were skipped.`] : [];
1260
+ return finish("figma-styles", out, notes, opts);
1261
+ }
1262
+ function plainLeaves(node, prefix, out) {
1263
+ if (node === null || node === void 0) return;
1264
+ if (typeof node !== "object" || typeof node === "object" && "r" in node && "g" in node) {
1265
+ out.push({ key: prefix, value: node });
1266
+ return;
1267
+ }
1268
+ for (const [k, v] of Object.entries(node)) plainLeaves(v, [...prefix, k], out);
1269
+ }
1270
+ function fromModeObject(obj, opts = {}) {
1271
+ const notes = [];
1272
+ const modes = Object.entries(obj).filter(([, v]) => v && typeof v === "object");
1273
+ const leafSets = modes.map(([name, v]) => {
1274
+ const l = [];
1275
+ plainLeaves(v, [], l);
1276
+ return { name, leaves: l, keys: new Set(l.map((x) => x.key.join("/"))) };
1277
+ });
1278
+ const groups = [];
1279
+ for (const m of leafSets) {
1280
+ const g = groups.find((grp) => {
1281
+ const a = grp[0].keys;
1282
+ let same = 0;
1283
+ for (const k of m.keys) if (a.has(k)) same++;
1284
+ return same / Math.max(a.size, m.keys.size) >= 0.8;
1285
+ });
1286
+ if (g) g.push(m);
1287
+ else groups.push([m]);
1288
+ }
1289
+ const byLeaf = /* @__PURE__ */ new Map();
1290
+ groups.forEach((grp, gi) => {
1291
+ for (const m of grp) for (const l of m.leaves) {
1292
+ const k = l.key[l.key.length - 1];
1293
+ const e = byLeaf.get(k) ?? { group: gi, values: /* @__PURE__ */ new Map() };
1294
+ e.values.set(m.name, l.value);
1295
+ byLeaf.set(k, e);
1296
+ }
1297
+ });
1298
+ const pick = (grp, want) => want === "dark" ? grp.find((m) => DARK.test(m.name)) : grp.find((m) => LIGHT.test(m.name) && !DARK.test(m.name)) ?? grp.find((m) => !DARK.test(m.name)) ?? grp[0];
1299
+ const referenced = /* @__PURE__ */ new Set();
1300
+ for (const m of leafSets) for (const l of m.leaves) if (typeof l.value === "string" && byLeaf.has(l.value)) referenced.add(l.value);
1301
+ const deref = (v, want, depth = 0) => {
1302
+ if (typeof v !== "string" || depth > 10) return v;
1303
+ const ref = byLeaf.get(v) ?? byLeaf.get(v.replace(REF, "$1"));
1304
+ if (!ref) return v;
1305
+ referenced.add(v);
1306
+ const grp = groups[ref.group];
1307
+ const mode = pick(grp, want) ?? pick(grp, "default");
1308
+ return deref(ref.values.get(mode.name) ?? [...ref.values.values()][0], want, depth + 1);
1309
+ };
1310
+ const out = [];
1311
+ const pending = [];
1312
+ for (const grp of groups) {
1313
+ const def = pick(grp, "default");
1314
+ const dark = pick(grp, "dark");
1315
+ if (grp.length > 1) notes.push(`${grp.map((m) => m.name).join(" / ")} look like modes of one collection; used \u201C${def.name}\u201D${dark && dark !== def ? ` (and \u201C${dark.name}\u201D as dark)` : ""}.`);
1316
+ for (const l of def.leaves) {
1317
+ const key = l.key[l.key.length - 1];
1318
+ const container = l.key.length > 1 ? l.key[l.key.length - 2].toLowerCase() : "";
1319
+ const value = deref(l.value, "default");
1320
+ const kind = container === "color" || colorValue(value) && /^#|^rgb|^hsl/i.test(String(value)) ? "color" : container === "string" || typeof value === "string" && !/^-?[\d.]+(px|rem|%)?$/.test(value) ? "string" : "number";
1321
+ const c = classify(key, kind, kind === "number" && typeof value === "string" && /px$/.test(value) ? parseFloat(value) : value, { context: def.name });
1322
+ if (!c) continue;
1323
+ let d;
1324
+ if (c.type === "color") {
1325
+ const darkLeaf = dark && dark !== def ? dark.leaves.find((x) => x.key.join("/") === l.key.join("/")) : void 0;
1326
+ const dv = colorValue(deref(darkLeaf ? darkLeaf.value : l.value, "dark"));
1327
+ if (dv && dv !== c.value) d = dv;
1328
+ }
1329
+ pending.push({ key, t: { ...c, ...d ? { dark: d } : {} } });
1330
+ }
1331
+ }
1332
+ for (const p of pending) out.push({ ...p.t, referenced: referenced.has(p.key) });
1333
+ return finish("mode-object", out, notes, opts);
1334
+ }
1335
+ function importTokens(input, opts = {}) {
1336
+ if (!input || typeof input !== "object") throw new Error("Expected a JSON object.");
1337
+ const o = input;
1338
+ const meta = o.meta ?? o;
1339
+ if (meta.variables && meta.variableCollections) return fromFigmaVariables(meta, opts);
1340
+ if (Array.isArray(o.styles) && o.styles.every((s) => s && "styleType" in s)) return fromFigmaStyles(o.styles, opts);
1341
+ if (hasDollarValue(o) || hasStudioValue(o)) {
1342
+ const studio = !hasDollarValue(o);
1343
+ const flat = flattenTokens(o);
1344
+ if (!flat.length) throw new Error("No tokens found in that file.");
1345
+ const out = flat.map((t) => {
1346
+ if (t.type === "color" && !t.path.startsWith("color.")) return { ...t, path: `color.${t.path}` };
1347
+ return t;
1348
+ });
1349
+ const notes = studio ? [] : [];
1350
+ const res = finish(studio ? "tokens-studio" : "dtcg", out, notes, opts);
1351
+ return res;
1352
+ }
1353
+ const leaves2 = [];
1354
+ plainLeaves(o, [], leaves2);
1355
+ if (leaves2.length) return fromModeObject(o, opts);
1356
+ throw new Error("Couldn't find any tokens. Supported: DTCG, Tokens Studio, Figma variables JSON.");
1357
+ }
1358
+ function diffTokens(before, after) {
1359
+ const a = new Map(flattenTokens(before).map((t) => [t.path, t]));
1360
+ const b = new Map(flattenTokens(after).map((t) => [t.path, t]));
1361
+ const diff = { added: [], removed: [], changed: [], unchanged: 0 };
1362
+ for (const [p, t] of b) {
1363
+ const old = a.get(p);
1364
+ if (!old) diff.added.push(t);
1365
+ else if (old.value.toLowerCase() !== t.value.toLowerCase() || (old.dark ?? "").toLowerCase() !== (t.dark ?? "").toLowerCase()) diff.changed.push({ path: p, before: old, after: t });
1366
+ else diff.unchanged++;
1367
+ }
1368
+ for (const [p, t] of a) if (!b.has(p)) diff.removed.push(t);
1369
+ return diff;
1370
+ }
1371
+ function mergeTokens(base, incoming) {
1372
+ const m = new Map(flattenTokens(base).map((t) => [t.path, t]));
1373
+ for (const t of flattenTokens(incoming)) m.set(t.path, { ...m.get(t.path), ...t, description: t.description ?? m.get(t.path)?.description });
1374
+ return buildDtcg([...m.values()]);
1375
+ }
1376
+
1377
+ // ../engine/src/rendered.ts
1378
+ function collectRenderedNodes(maxNodes = 3e3) {
1379
+ const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "HEAD", "META", "LINK", "BR", "WBR", "SOURCE", "TRACK"]);
1380
+ const px2 = (v) => {
1381
+ const n = parseFloat(v);
1382
+ return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
1383
+ };
1384
+ const alphaOf = (c) => {
1385
+ const m = c.match(/rgba?\(([^)]+)\)/);
1386
+ if (!m) return 1;
1387
+ const parts = m[1].split(/[\s,/]+/).filter(Boolean);
1388
+ return parts[3] !== void 0 ? parseFloat(parts[3]) : 1;
1389
+ };
1390
+ const cssEsc = (s) => window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/[^\w-]/g, "\\$&");
1391
+ const part = (el) => {
1392
+ if (el.id && el.id.length < 40) return `${el.tagName.toLowerCase()}#${cssEsc(el.id)}`;
1393
+ const cls = Array.from(el.classList).filter((c) => c.length < 30).slice(0, 2).map((c) => `.${cssEsc(c)}`).join("");
1394
+ return el.tagName.toLowerCase() + cls;
1395
+ };
1396
+ const selectorOf = (el) => {
1397
+ const parts = [];
1398
+ let cur = el;
1399
+ while (cur && cur !== document.body && parts.length < 3) {
1400
+ parts.unshift(part(cur));
1401
+ if (cur.id) break;
1402
+ cur = cur.parentElement;
1403
+ }
1404
+ return parts.join(" > ");
1405
+ };
1406
+ const bgCache = /* @__PURE__ */ new Map();
1407
+ const effectiveBg = (el) => {
1408
+ if (!el) return "rgb(255, 255, 255)";
1409
+ if (bgCache.has(el)) return bgCache.get(el);
1410
+ const cs = getComputedStyle(el);
1411
+ let out;
1412
+ if (cs.backgroundImage && cs.backgroundImage !== "none") out = null;
1413
+ else if (alphaOf(cs.backgroundColor) >= 0.95) out = cs.backgroundColor;
1414
+ else out = effectiveBg(el.parentElement);
1415
+ bgCache.set(el, out);
1416
+ return out;
1417
+ };
1418
+ const nodes = [];
1419
+ const all = document.body ? document.body.querySelectorAll("*") : [];
1420
+ for (const el of Array.from(all)) {
1421
+ if (nodes.length >= maxNodes) break;
1422
+ if (SKIP.has(el.tagName) || el.closest("svg") !== null && el.tagName.toLowerCase() !== "svg") continue;
1423
+ const cs = getComputedStyle(el);
1424
+ if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) === 0) continue;
1425
+ const r = el.getBoundingClientRect();
1426
+ if (r.width < 1 || r.height < 1) continue;
1427
+ 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;
1430
+ nodes.push({
1431
+ selector: selectorOf(el),
1432
+ tag: el.tagName.toLowerCase(),
1433
+ text: text ? text.slice(0, 60) : void 0,
1434
+ color: cs.color,
1435
+ backgroundColor: cs.backgroundColor,
1436
+ borderColor: borderW > 0 && cs.borderTopStyle !== "none" ? borderSide : void 0,
1437
+ effectiveBackground: effectiveBg(el),
1438
+ fontFamily: cs.fontFamily,
1439
+ fontSize: px2(cs.fontSize),
1440
+ fontWeight: parseInt(cs.fontWeight, 10) || 400,
1441
+ radius: Math.max(px2(cs.borderTopLeftRadius), px2(cs.borderTopRightRadius), px2(cs.borderBottomLeftRadius), px2(cs.borderBottomRightRadius)),
1442
+ 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)] : []
1445
+ });
1446
+ }
1447
+ const links = Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((h) => {
1448
+ try {
1449
+ const u = new URL(h);
1450
+ return u.origin === location.origin && !/\.(pdf|zip|png|jpe?g|svg|gif|webp|mp4)$/i.test(u.pathname);
1451
+ } catch {
1452
+ return false;
1453
+ }
1454
+ });
1455
+ return { title: document.title, nodes, links: Array.from(new Set(links.map((h) => h.split("#")[0]))).slice(0, 50) };
1456
+ }
1457
+ var near = (scale, v, tol = 0.5) => scale.some((s) => Math.abs(s - v) <= tol);
1458
+ var neighbours2 = (scale, v) => {
1459
+ const lo = [...scale].reverse().find((x) => x <= v);
1460
+ const hi = scale.find((x) => x >= v);
1461
+ return [...new Set([lo, hi].filter((x) => x !== void 0))].map((x) => `${x}px`).join(" or ");
1462
+ };
1463
+ function closestToken(config, rgb) {
1464
+ const lab = rgbToLab(rgb);
1465
+ let best = config.colors[0];
1466
+ let bestD = Infinity;
1467
+ for (const c of config.colors) {
1468
+ const d = deltaE(lab, c.lab) + Math.abs(rgb.a - (c.alpha ?? 1)) * 100;
1469
+ if (d < bestD) {
1470
+ bestD = d;
1471
+ best = c;
1472
+ }
1473
+ }
1474
+ return { token: best, distance: bestD };
1475
+ }
1476
+ function colorLabel(rgb) {
1477
+ const hex = toHex(rgb);
1478
+ return rgb.a < 1 ? `${hex} at ${Math.round(rgb.a * 100)}%` : hex;
1479
+ }
1480
+ function checkRendered(pages, configInput, opts = {}) {
1481
+ const t0 = Date.now();
1482
+ const config = "replaces" in configInput ? configInput : resolveConfig(configInput);
1483
+ const examples = opts.examples ?? 3;
1484
+ const fontSizes = config.fontSizes;
1485
+ const violations = [];
1486
+ let nodesChecked = 0;
1487
+ const spacing = config.spacing;
1488
+ const radii = config.radii.filter((r) => r < 999);
1489
+ for (const page of pages) {
1490
+ const groups = /* @__PURE__ */ new Map();
1491
+ const add = (key, g, selector) => {
1492
+ const sev = config.rules[g.ruleId];
1493
+ if (sev === "off") return;
1494
+ const cur = groups.get(key) ?? { ...g, severity: sev ?? g.severity, selectors: [], count: 0 };
1495
+ cur.count++;
1496
+ if (cur.selectors.length < examples && !cur.selectors.includes(selector)) cur.selectors.push(selector);
1497
+ groups.set(key, cur);
1498
+ };
1499
+ const colorCheck = (raw, where, selector) => {
1500
+ if (!raw || !config.colors.length) return;
1501
+ const rgb = parseColor(raw);
1502
+ if (!rgb || rgb.a < 0.05) return;
1503
+ const { token, distance } = closestToken(config, rgb);
1504
+ if (distance <= config.colorTolerance) return;
1505
+ const label = colorLabel(rgb);
1506
+ add(
1507
+ `color:${label}`,
1508
+ {
1509
+ ruleId: "no-hardcoded-color",
1510
+ severity: "error",
1511
+ message: (n) => `${label} isn't in the Brand Kit (${where} on ${n} element${n === 1 ? "" : "s"})`,
1512
+ suggestion: token ? `Closest token is ${token.path} (${token.hex}, \u0394E ${distance.toFixed(1)}): use var(${token.cssVar})` : void 0
1513
+ },
1514
+ selector
1515
+ );
1516
+ };
1517
+ for (const n of page.nodes) {
1518
+ nodesChecked++;
1519
+ if (n.text) colorCheck(n.color, "text", n.selector);
1520
+ colorCheck(n.backgroundColor, "background", n.selector);
1521
+ colorCheck(n.borderColor, "border", n.selector);
1522
+ if (n.text) {
1523
+ const fg = parseColor(n.color);
1524
+ const bg = n.effectiveBackground ? parseColor(n.effectiveBackground) : null;
1525
+ if (fg && bg && fg.a > 0.05) {
1526
+ const blended = fg.a < 1 ? { r: fg.r * fg.a + bg.r * (1 - fg.a), g: fg.g * fg.a + bg.g * (1 - fg.a), b: fg.b * fg.a + bg.b * (1 - fg.a), a: 1 } : fg;
1527
+ const ratio = contrastRatio(blended, bg);
1528
+ const large = n.fontSize >= 24 || n.fontSize >= 18.66 && n.fontWeight >= 700;
1529
+ const min = large ? 3 : 4.5;
1530
+ if (ratio < min) {
1531
+ const pair = `${colorLabel(fg)} on ${toHex(bg)}`;
1532
+ add(
1533
+ `contrast:${pair}:${large}`,
1534
+ {
1535
+ ruleId: "text-contrast",
1536
+ severity: "error",
1537
+ message: (c) => `${pair} is ${ratio.toFixed(2)}:1, below ${min}:1${large ? " for large text" : ""} (${c} element${c === 1 ? "" : "s"})`,
1538
+ suggestion: "Use the Brand Kit's text colours on its background colours, e.g. fg.default on canvas.default",
1539
+ sample: n.text
1540
+ },
1541
+ n.selector
1542
+ );
1543
+ }
1544
+ }
1545
+ if (config.fonts.length) {
1546
+ const fam = normalizeFontFamily(n.fontFamily);
1547
+ if (fam && !isGenericFont(fam) && !config.fonts.includes(fam) && !/^(pre|code|kbd|samp)$/.test(n.tag)) {
1548
+ add(
1549
+ `font:${fam}`,
1550
+ {
1551
+ ruleId: "font-family",
1552
+ severity: "warning",
1553
+ message: (c) => `Font \u201C${n.fontFamily.split(",")[0].replace(/["']/g, "").trim()}\u201D isn't a Brand Kit font (${c} element${c === 1 ? "" : "s"})`,
1554
+ suggestion: `Use ${config.fontVars.map((v) => `var(${v})`).join(" or ")}`
1555
+ },
1556
+ n.selector
1557
+ );
1558
+ }
1559
+ }
1560
+ if (fontSizes.length >= 3 && !near(fontSizes, n.fontSize)) {
1561
+ add(
1562
+ `size:${n.fontSize}`,
1563
+ {
1564
+ ruleId: "font-size-scale",
1565
+ severity: "warning",
1566
+ message: (c) => `Font size ${n.fontSize}px is off the type scale (${c} element${c === 1 ? "" : "s"})`,
1567
+ suggestion: `Use ${neighbours2(fontSizes, n.fontSize)}`
1568
+ },
1569
+ n.selector
1570
+ );
1571
+ }
1572
+ }
1573
+ for (const v of [.../* @__PURE__ */ new Set([...n.padding, ...n.gap])]) {
1574
+ if (v <= 2 || near(spacing, v)) continue;
1575
+ add(
1576
+ `space:${v}`,
1577
+ { ruleId: "spacing-scale", severity: "warning", message: (c) => `Spacing ${v}px is off the scale (${c} element${c === 1 ? "" : "s"})`, suggestion: `Use ${neighbours2(spacing, v)}` },
1578
+ n.selector
1579
+ );
1580
+ }
1581
+ if (n.radius > 0 && n.radius < 999 && n.radius < n.minSide / 2 - 0.5 && radii.length && !near(radii, n.radius)) {
1582
+ add(
1583
+ `radius:${n.radius}`,
1584
+ { ruleId: "radius-scale", severity: "warning", message: (c) => `Corner radius ${n.radius}px is off the scale (${c} element${c === 1 ? "" : "s"})`, suggestion: `Use ${neighbours2(radii, n.radius)}` },
1585
+ n.selector
1586
+ );
1587
+ }
1588
+ }
1589
+ const path = pagePath(page.url);
1590
+ for (const g of groups.values()) {
1591
+ violations.push({
1592
+ ruleId: g.ruleId,
1593
+ severity: g.severity,
1594
+ file: path,
1595
+ line: 0,
1596
+ column: 0,
1597
+ message: g.message(g.count),
1598
+ suggestion: g.suggestion,
1599
+ snippet: g.selectors.join(", ") + (g.sample ? ` \u201C${g.sample}\u201D` : "")
1600
+ });
1601
+ }
1602
+ }
1603
+ const order = { error: 0, warning: 1, info: 2 };
1604
+ violations.sort((a, b) => a.file.localeCompare(b.file) || order[a.severity] - order[b.severity] || countOf(b) - countOf(a));
1605
+ const counts = { error: 0, warning: 0, info: 0 };
1606
+ const perRule = /* @__PURE__ */ new Map();
1607
+ for (const v of violations) {
1608
+ counts[v.severity]++;
1609
+ perRule.set(v.ruleId, (perRule.get(v.ruleId) ?? 0) + 1);
1610
+ }
1611
+ return {
1612
+ engineVersion: ENGINE_VERSION,
1613
+ tokenVersion: config.tokenVersion,
1614
+ // Findings are grouped per distinct value, so each one weighs more than a single code line.
1615
+ score: computeScore(counts, Math.max(2e3, nodesChecked * 3)),
1616
+ counts,
1617
+ filesScanned: pages.length,
1618
+ linesScanned: nodesChecked,
1619
+ violations,
1620
+ byRule: [...perRule].map(([ruleId, count]) => ({ ruleId, count })).sort((a, b) => b.count - a.count),
1621
+ durationMs: Date.now() - t0
1622
+ };
1623
+ }
1624
+ var countOf = (v) => Number(v.message.match(/\((?:\w+ on )?(\d+) element/)?.[1] ?? 0);
1625
+ function pagePath(url) {
1626
+ try {
1627
+ const u = new URL(url);
1628
+ return `${u.host}${u.pathname}${u.search}`;
1629
+ } catch {
1630
+ return url;
1631
+ }
1632
+ }
1633
+
1634
+ // src/api.ts
1635
+ var DEFAULT_API = "https://web-phi-nine-77.vercel.app";
1636
+ function apiBase() {
1637
+ return (process.env.BRANDRAIL_API_URL ?? DEFAULT_API).replace(/\/$/, "");
1638
+ }
1639
+ async function call(path, init) {
1640
+ const res = await fetch(`${apiBase()}${path}`, {
1641
+ ...init,
1642
+ headers: { "content-type": "application/json", authorization: `Bearer ${init.key}`, ...init.headers ?? {} }
1643
+ });
1644
+ const text = await res.text();
1645
+ let body = null;
1646
+ try {
1647
+ body = text ? JSON.parse(text) : null;
1648
+ } catch {
1649
+ }
1650
+ if (!res.ok) {
1651
+ const msg = body?.error ?? `${res.status} ${res.statusText}`;
1652
+ throw new Error(`Brandrail API ${path}: ${msg}`);
1653
+ }
1654
+ return body;
1655
+ }
1656
+ function fetchBrandKit(key) {
1657
+ return call("/api/v1/brand-kit", { method: "GET", key });
1658
+ }
1659
+ function uploadReport(key, payload) {
1660
+ const report = payload.uploadSnippets ? payload.report : { ...payload.report, violations: payload.report.violations.map(({ snippet: _snippet, ...v }) => v) };
1661
+ return call("/api/v1/scans", { method: "POST", key, body: JSON.stringify({ ...payload, report }) });
1662
+ }
1663
+
1664
+ // src/files.ts
1665
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1666
+ import { dirname, join, relative, resolve, sep } from "node:path";
1667
+ var CONFIG_FILE = "brandrail.config.json";
1668
+ var MAX_FILE_BYTES = 512 * 1024;
1669
+ function collectFiles(root, paths, ignoreGlobs) {
1670
+ const ignored = makeIgnore(ignoreGlobs);
1671
+ const out = [];
1672
+ const rel = (p) => relative(root, p).split(sep).join("/");
1673
+ const visit = (abs) => {
1674
+ const r = rel(abs);
1675
+ let st;
1676
+ try {
1677
+ st = statSync(abs);
1678
+ } catch {
1679
+ return;
1680
+ }
1681
+ if (st.isDirectory()) {
1682
+ const name = abs.split(sep).pop() ?? "";
1683
+ if (r && (ignored(`${r}/`) || ignored(`${r}/x`) || name.startsWith(".") && name !== "." && name !== ".github")) return;
1684
+ for (const entry of readdirSync(abs)) visit(join(abs, entry));
1685
+ return;
1686
+ }
1687
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return;
1688
+ if (fileKind(r) === "other" || ignored(r)) return;
1689
+ out.push({ path: r, content: readFileSync(abs, "utf8") });
1690
+ };
1691
+ for (const p of paths.length ? paths : ["."]) visit(resolve(root, p));
1692
+ return out;
1693
+ }
1694
+ function findConfig(start) {
1695
+ let dir = resolve(start);
1696
+ for (; ; ) {
1697
+ const candidate = join(dir, CONFIG_FILE);
1698
+ if (existsSync(candidate)) return candidate;
1699
+ const parent = dirname(dir);
1700
+ if (parent === dir) return null;
1701
+ dir = parent;
1702
+ }
1703
+ }
1704
+ function readConfig(path) {
1705
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1706
+ if (!parsed || typeof parsed !== "object" || !parsed.tokens) {
1707
+ throw new Error(`${path} has no "tokens". Run \`brandrail init\` to create one.`);
1708
+ }
1709
+ return parsed;
1710
+ }
1711
+
1712
+ // src/git.ts
1713
+ import { execFileSync } from "node:child_process";
1714
+ var git = (args, cwd) => {
1715
+ try {
1716
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1717
+ } catch {
1718
+ return null;
1719
+ }
1720
+ };
1721
+ function repoInfo(cwd) {
1722
+ const remote = git(["remote", "get-url", "origin"], cwd);
1723
+ const m = remote?.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
1724
+ const folder = cwd.split(/[\\/]/).pop() ?? "repo";
1725
+ return {
1726
+ slug: m?.[1] ?? remote ?? folder,
1727
+ branch: git(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? void 0,
1728
+ sha: git(["rev-parse", "HEAD"], cwd) ?? void 0
1729
+ };
1730
+ }
1731
+ function parseChangedLines(diff) {
1732
+ const out = /* @__PURE__ */ new Map();
1733
+ let file = null;
1734
+ for (const line of diff.split("\n")) {
1735
+ if (line.startsWith("+++ ")) {
1736
+ const p = line.slice(4).trim();
1737
+ file = p === "/dev/null" ? null : p.replace(/^b\//, "");
1738
+ if (file && !out.has(file)) out.set(file, /* @__PURE__ */ new Set());
1739
+ continue;
1740
+ }
1741
+ const h = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
1742
+ if (h && file) {
1743
+ const start = Number(h[1]);
1744
+ const count = h[2] === void 0 ? 1 : Number(h[2]);
1745
+ const set = out.get(file);
1746
+ for (let i = 0; i < count; i++) set.add(start + i);
1747
+ }
1748
+ }
1749
+ return out;
1750
+ }
1751
+ function changedLines(cwd, base) {
1752
+ const mergeBase = git(["merge-base", base, "HEAD"], cwd) ?? base;
1753
+ const diff = git(["diff", "-U0", "--no-color", "--diff-filter=AMR", mergeBase], cwd);
1754
+ return diff === null ? null : parseChangedLines(diff);
1755
+ }
1756
+
1757
+ // src/scan.ts
1758
+ import { existsSync as existsSync2 } from "node:fs";
1759
+ import { createRequire } from "node:module";
1760
+ import { join as join2, resolve as resolve2 } from "node:path";
1761
+ function loadPlaywright(cwd) {
1762
+ for (const base of [join2(cwd, "package.json"), import.meta.url]) {
1763
+ for (const name of ["playwright", "playwright-core"]) {
1764
+ try {
1765
+ return createRequire(base)(name);
1766
+ } catch {
1767
+ }
1768
+ }
1769
+ }
1770
+ throw new Error("`brandrail scan` needs Playwright: npm i -D playwright && npx playwright install chromium");
1771
+ }
1772
+ async function scanPages(cwd, url, config, opts, log2) {
1773
+ const pw = loadPlaywright(cwd);
1774
+ const start = /^https?:\/\//i.test(url) ? url : `http://${url}`;
1775
+ const state = opts.storageState ? resolve2(cwd, opts.storageState) : void 0;
1776
+ if (state && !existsSync2(state)) throw new Error(`--storage-state file not found: ${state}`);
1777
+ const browser = await pw.chromium.launch({ headless: true });
1778
+ try {
1779
+ const context = await browser.newContext({ viewport: { width: opts.width, height: 900 }, storageState: state });
1780
+ const queue = [start];
1781
+ const seen = /* @__PURE__ */ new Set();
1782
+ const pages = [];
1783
+ while (queue.length && pages.length < opts.pages) {
1784
+ const next = queue.shift();
1785
+ const key = next.replace(/\/$/, "");
1786
+ if (seen.has(key)) continue;
1787
+ seen.add(key);
1788
+ const page = await context.newPage();
1789
+ try {
1790
+ const res = await page.goto(next, { waitUntil: "domcontentloaded", timeout: 3e4 });
1791
+ if (res && res.status() >= 400) {
1792
+ log2(` skipped ${next} (${res.status()})`);
1793
+ if (pages.length === 0) throw new Error(`${next} answered ${res.status()}`);
1794
+ continue;
1795
+ }
1796
+ await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1797
+ });
1798
+ if (opts.waitMs) await page.waitForTimeout(opts.waitMs);
1799
+ const data = await page.evaluate(collectRenderedNodes, 4e3);
1800
+ pages.push({ url: page.url(), title: data.title, nodes: data.nodes });
1801
+ log2(` \u2714 ${page.url()} (${data.nodes.length} elements)`);
1802
+ if (pages.length === 1) queue.push(...data.links.filter((l) => l.startsWith(new URL(page.url()).origin)).sort((a, b) => a.length - b.length));
1803
+ } finally {
1804
+ await page.close().catch(() => {
1805
+ });
1806
+ }
1807
+ }
1808
+ return { report: checkRendered(pages, config), pages };
1809
+ } finally {
1810
+ await browser.close();
1811
+ }
1812
+ }
1813
+
1814
+ // src/index.ts
1815
+ var HELP = `brandrail ${ENGINE_VERSION} \u2014 check that UI follows your design system
1816
+
1817
+ Usage
1818
+ brandrail check [paths...] Scan files and print violations
1819
+ brandrail init Create brandrail.config.json and AI rule files
1820
+ brandrail scan <url> Check a running page (localhost, staging, behind a login) in a real browser
1821
+ brandrail import <file> Load tokens (DTCG, Tokens Studio, Figma variables JSON) into the config
1822
+ brandrail rules List the rules
1823
+
1824
+ Options for check
1825
+ --config <file> Config file (default: nearest brandrail.config.json)
1826
+ --format <fmt> pretty | json | markdown | github (default: pretty)
1827
+ --fail-below <score> Exit 1 when the brand score is lower (default: off)
1828
+ --changed [base] Only report lines changed vs base branch (default: origin/main)
1829
+ --upload Send the result to Brandrail (needs BRANDRAIL_KEY)
1830
+ --no-color Plain output
1831
+
1832
+ Options for init
1833
+ --from-api Pull the published Brand Kit (needs BRANDRAIL_KEY)
1834
+ --no-rules Don't write AGENTS.md (Codex), CLAUDE.md, Cursor, Copilot, Windsurf, Gemini rule files
1835
+ --mcp Also add the Brandrail MCP server to .mcp.json, .cursor/mcp.json, .vscode/mcp.json
1836
+ and print the Codex / Windsurf config
1837
+ --force Overwrite an existing brandrail.config.json
1838
+
1839
+ Options for scan
1840
+ --pages <n> Also scan pages it links to, up to n in total (default 1, max 20)
1841
+ --storage-state <file> Playwright storage state with your login cookies
1842
+ --wait <ms> Extra wait after load, for client-rendered apps
1843
+ --width <px> Viewport width (default 1280)
1844
+ --format, --fail-below, --upload As for check
1845
+ Needs Playwright: npm i -D playwright && npx playwright install chromium
1846
+
1847
+ Options for import
1848
+ --replace Replace the config's tokens instead of merging
1849
+ --primitives Keep raw palette ramps (gray.100\u2026900) that semantic tokens point at
1850
+
1851
+ Environment
1852
+ BRANDRAIL_KEY API key from Dashboard \u2192 Connect
1853
+ BRANDRAIL_API_URL Brandrail URL (default ${apiBase()})
1854
+ `;
1855
+ function parseArgs(argv) {
1856
+ const [cmd = "help", ...rest] = argv;
1857
+ const positional = [];
1858
+ const flags = {};
1859
+ for (let i = 0; i < rest.length; i++) {
1860
+ const a = rest[i];
1861
+ if (a.startsWith("--no-")) flags[a.slice(5)] = false;
1862
+ else if (a.startsWith("--")) {
1863
+ const [k, v] = a.slice(2).split("=", 2);
1864
+ const next = rest[i + 1];
1865
+ 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)) {
1867
+ flags[k] = next;
1868
+ i++;
1869
+ } else flags[k] = true;
1870
+ } else positional.push(a);
1871
+ }
1872
+ return { cmd, positional, flags };
1873
+ }
1874
+ var log = (s = "") => process.stderr.write(`${s}
1875
+ `);
1876
+ async function loadConfig(cwd, flags) {
1877
+ const explicit = typeof flags.config === "string" ? resolve3(cwd, flags.config) : null;
1878
+ const path = explicit ?? findConfig(cwd);
1879
+ if (path) return { config: readConfig(path), source: relative2(cwd, path) || CONFIG_FILE };
1880
+ const key = process.env.BRANDRAIL_KEY;
1881
+ if (key) {
1882
+ const { config } = await fetchBrandKit(key);
1883
+ return { config, source: `${apiBase()} (published Brand Kit)` };
1884
+ }
1885
+ throw new Error(`No ${CONFIG_FILE} found. Run \`npx brandrail init\` first, or set BRANDRAIL_KEY to use your published Brand Kit.`);
1886
+ }
1887
+ async function cmdCheck(cwd, positional, flags) {
1888
+ const { config, source } = await loadConfig(cwd, flags);
1889
+ const resolved = resolveConfig(config);
1890
+ const root = dirname2(typeof flags.config === "string" ? resolve3(cwd, flags.config) : findConfig(cwd) ?? join3(cwd, CONFIG_FILE));
1891
+ const files = collectFiles(root, positional.map((p) => relative2(root, resolve3(cwd, p))), resolved.ignore);
1892
+ let onlyLines;
1893
+ if (flags.changed) {
1894
+ const base = typeof flags.changed === "string" ? flags.changed : "origin/main";
1895
+ onlyLines = changedLines(root, base) ?? void 0;
1896
+ if (!onlyLines) log(`warning: couldn't read git diff against ${base}; checking everything`);
1897
+ }
1898
+ const report = check(files, resolved, { onlyLines });
1899
+ const format = typeof flags.format === "string" ? flags.format : "pretty";
1900
+ const threshold = flags["fail-below"] !== void 0 ? Number(flags["fail-below"]) : void 0;
1901
+ if (format === "json") process.stdout.write(`${JSON.stringify(report, null, 2)}
1902
+ `);
1903
+ else if (format === "markdown") process.stdout.write(`${formatMarkdown(report, { threshold })}
1904
+ `);
1905
+ else if (format === "github") process.stdout.write(`${formatGithubAnnotations(report)}
1906
+ `);
1907
+ else {
1908
+ const color = flags.color !== false && process.stdout.isTTY !== false && !process.env.NO_COLOR;
1909
+ const kit = `Brand Kit: ${source}${config.tokenVersion ? ` v${config.tokenVersion}` : ""}`;
1910
+ log(color ? `\x1B[2m${kit}\x1B[0m` : kit);
1911
+ process.stdout.write(`${formatPretty(report, { color })}
1912
+ `);
1913
+ }
1914
+ if (flags.upload) {
1915
+ const key = process.env.BRANDRAIL_KEY;
1916
+ if (!key) log("warning: --upload needs BRANDRAIL_KEY; skipped");
1917
+ else {
1918
+ const repo = repoInfo(root);
1919
+ const res = await uploadReport(key, {
1920
+ source: process.env.CI ? "ci" : "cli",
1921
+ target: { kind: "repo", name: repo.slug.split("/").pop() ?? repo.slug, location: repo.slug },
1922
+ ref: repo.branch ?? repo.sha?.slice(0, 7),
1923
+ title: flags.changed ? `Changed lines on ${repo.branch ?? "HEAD"}` : `Local check on ${repo.branch ?? "HEAD"}`,
1924
+ threshold,
1925
+ report
1926
+ });
1927
+ log(`Uploaded \u2192 ${res.url ?? res.scanId}`);
1928
+ }
1929
+ }
1930
+ if (threshold !== void 0 && report.score < threshold) {
1931
+ log(`
1932
+ Brand score ${report.score} is below ${threshold}.`);
1933
+ return 1;
1934
+ }
1935
+ return 0;
1936
+ }
1937
+ function writeRuleFiles(root, config, org) {
1938
+ const md = generateInstructions(config, { org });
1939
+ const written = [];
1940
+ const write = (rel, content) => {
1941
+ const abs = join3(root, rel);
1942
+ mkdirSync(dirname2(abs), { recursive: true });
1943
+ const before = existsSync3(abs) ? readFileSync2(abs, "utf8") : "";
1944
+ writeFileSync(abs, content(before));
1945
+ written.push(rel);
1946
+ };
1947
+ for (const { file, readBy } of RULE_FILES) {
1948
+ if (file.endsWith(".mdc")) {
1949
+ write(file, () => `---
1950
+ description: Design system rules from Brandrail
1951
+ globs: ["**/*.{tsx,jsx,vue,svelte,html,css,scss}"]
1952
+ alwaysApply: true
1953
+ ---
1954
+
1955
+ ${md}
1956
+ `);
1957
+ } else if (file.startsWith(".windsurf/")) {
1958
+ write(file, () => `---
1959
+ trigger: always_on
1960
+ ---
1961
+
1962
+ ${md}
1963
+ `);
1964
+ } else {
1965
+ write(file, (before) => upsertSection(before, md));
1966
+ }
1967
+ written[written.length - 1] = `${file} (${readBy})`;
1968
+ }
1969
+ return written;
1970
+ }
1971
+ function writeMcpConfigs(root) {
1972
+ const url = `${apiBase()}/api/mcp`;
1973
+ for (const c of mcpClientConfigs(url)) {
1974
+ if (!c.projectFile) continue;
1975
+ const abs = join3(root, c.file);
1976
+ mkdirSync(dirname2(abs), { recursive: true });
1977
+ const ours = JSON.parse(c.content);
1978
+ let merged = ours;
1979
+ if (existsSync3(abs)) {
1980
+ try {
1981
+ const existing = JSON.parse(readFileSync2(abs, "utf8"));
1982
+ merged = { ...existing };
1983
+ for (const [k, v] of Object.entries(ours)) {
1984
+ if (Array.isArray(v)) {
1985
+ const prev = Array.isArray(existing[k]) ? existing[k] : [];
1986
+ merged[k] = [...prev.filter((x) => !v.some((y) => y.id === x.id)), ...v];
1987
+ } else merged[k] = { ...existing[k] ?? {}, ...v };
1988
+ }
1989
+ } catch {
1990
+ log(`warning: ${c.file} isn't valid JSON; left unchanged`);
1991
+ continue;
1992
+ }
1993
+ }
1994
+ writeFileSync(abs, `${JSON.stringify(merged, null, 2)}
1995
+ `);
1996
+ log(`\u2714 added brandrail to ${c.file} (${c.name})`);
1997
+ }
1998
+ for (const c of mcpClientConfigs(url).filter((x) => !x.projectFile)) {
1999
+ log(`
2000
+ ${c.name}: ${c.command ? `run
2001
+ ${c.command}
2002
+ or add` : "add"} to ${c.file}:
2003
+ ${c.content.replace(/^/gm, " ")}`);
2004
+ }
2005
+ log("\nSet BRANDRAIL_KEY in your shell (an API key from Dashboard \u2192 Connect) before starting the AI tool.");
2006
+ }
2007
+ async function cmdInit(cwd, flags) {
2008
+ const target = join3(cwd, CONFIG_FILE);
2009
+ let config = starterConfig;
2010
+ let org;
2011
+ if (flags["from-api"]) {
2012
+ const key = process.env.BRANDRAIL_KEY;
2013
+ if (!key) throw new Error("--from-api needs BRANDRAIL_KEY (Dashboard \u2192 Connect \u2192 API keys).");
2014
+ const res = await fetchBrandKit(key);
2015
+ config = { $schema: starterConfig.$schema, ...res.config };
2016
+ org = res.org;
2017
+ }
2018
+ if (existsSync3(target) && !flags.force && !flags["from-api"]) {
2019
+ log(`${CONFIG_FILE} already exists (use --force to overwrite). Updating rule files from it.`);
2020
+ config = readConfig(target);
2021
+ } else {
2022
+ writeFileSync(target, `${JSON.stringify(config, null, 2)}
2023
+ `);
2024
+ log(`\u2714 wrote ${CONFIG_FILE}${org ? ` from ${org}'s Brand Kit` : " (starter tokens \u2014 edit them or use --from-api)"}`);
2025
+ }
2026
+ if (flags.rules !== false) for (const f of writeRuleFiles(cwd, config, org)) log(`\u2714 updated ${f}`);
2027
+ if (flags.mcp) writeMcpConfigs(cwd);
2028
+ log("\nNext: npx brandrail check");
2029
+ return 0;
2030
+ }
2031
+ async function cmdScan(cwd, positional, flags) {
2032
+ const url = positional[0];
2033
+ if (!url) throw new Error("Usage: brandrail scan <url> [--pages 3] [--storage-state auth.json] [--upload]");
2034
+ const { config, source } = await loadConfig(cwd, flags);
2035
+ const num = (k, d, max) => typeof flags[k] === "string" ? Math.min(Math.max(Number(flags[k]) || d, 0), max) : d;
2036
+ log(`Scanning ${url} \u2026`);
2037
+ const { report, pages } = await scanPages(
2038
+ cwd,
2039
+ url,
2040
+ config,
2041
+ {
2042
+ pages: Math.max(1, num("pages", 1, 20)),
2043
+ storageState: typeof flags["storage-state"] === "string" ? flags["storage-state"] : void 0,
2044
+ waitMs: num("wait", 0, 3e4),
2045
+ width: num("width", 1280, 3840) || 1280
2046
+ },
2047
+ log
2048
+ );
2049
+ const format = typeof flags.format === "string" ? flags.format : "pretty";
2050
+ const threshold = flags["fail-below"] !== void 0 ? Number(flags["fail-below"]) : void 0;
2051
+ if (format === "json") process.stdout.write(`${JSON.stringify(report, null, 2)}
2052
+ `);
2053
+ else if (format === "markdown") process.stdout.write(`${formatMarkdown(report, { threshold })}
2054
+ `);
2055
+ else {
2056
+ const color = flags.color !== false && process.stdout.isTTY !== false && !process.env.NO_COLOR;
2057
+ log(color ? `\x1B[2mBrand Kit: ${source}\x1B[0m` : `Brand Kit: ${source}`);
2058
+ process.stdout.write(`${formatPretty(report, { color }).replace(/:0:0\b/g, "")}
2059
+ `);
2060
+ }
2061
+ if (flags.upload) {
2062
+ const key = process.env.BRANDRAIL_KEY;
2063
+ if (!key) log("warning: --upload needs BRANDRAIL_KEY; skipped");
2064
+ else {
2065
+ const first = new URL(pages[0].url);
2066
+ const res = await uploadReport(key, {
2067
+ source: process.env.CI ? "ci" : "cli",
2068
+ target: { kind: "url", name: first.host, location: `${first.origin}${first.pathname === "/" ? "" : first.pathname}` },
2069
+ ref: first.toString(),
2070
+ title: pages.length > 1 ? `${first.host} \xB7 ${pages.length} pages` : `${first.host}${first.pathname === "/" ? "" : first.pathname}`,
2071
+ threshold,
2072
+ report
2073
+ });
2074
+ log(`Uploaded \u2192 ${res.url ?? res.scanId}`);
2075
+ }
2076
+ }
2077
+ if (threshold !== void 0 && report.score < threshold) {
2078
+ log(`
2079
+ Brand score ${report.score} is below ${threshold}.`);
2080
+ return 1;
2081
+ }
2082
+ return 0;
2083
+ }
2084
+ function cmdImport(cwd, positional, flags) {
2085
+ const file = positional[0];
2086
+ if (!file) throw new Error("Usage: brandrail import <tokens.json> [--replace] [--primitives]");
2087
+ const raw = JSON.parse(readFileSync2(resolve3(cwd, file), "utf8"));
2088
+ const res = importTokens(raw, { includePrimitives: !!flags.primitives });
2089
+ const path = (typeof flags.config === "string" ? resolve3(cwd, flags.config) : findConfig(cwd)) ?? join3(cwd, CONFIG_FILE);
2090
+ const config = existsSync3(path) ? readConfig(path) : { ...starterConfig, tokens: {} };
2091
+ const next = flags.replace ? res.tokens : mergeTokens(config.tokens, res.tokens);
2092
+ const d = diffTokens(config.tokens, next);
2093
+ writeFileSync(path, `${JSON.stringify({ ...config, tokens: next }, null, 2)}
2094
+ `);
2095
+ log(`\u2714 ${res.count} tokens from ${file} (${res.format})${res.primitives ? `, ${res.primitives} palette primitives left out (--primitives to keep)` : ""}`);
2096
+ for (const n of res.notes) log(` \xB7 ${n}`);
2097
+ log(`\u2714 ${relative2(cwd, path) || CONFIG_FILE}: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed`);
2098
+ log("\nNext: npx brandrail init (to refresh AI rule files), then npx brandrail check");
2099
+ return 0;
2100
+ }
2101
+ function cmdRules() {
2102
+ for (const r of RULES) process.stdout.write(`${r.id.padEnd(28)} ${r.defaultSeverity.padEnd(8)} ${r.description}
2103
+ `);
2104
+ return 0;
2105
+ }
2106
+ async function main(argv = process.argv.slice(2)) {
2107
+ const { cmd, positional, flags } = parseArgs(argv);
2108
+ const cwd = process.cwd();
2109
+ if (flags.version || cmd === "--version" || cmd === "-v") {
2110
+ process.stdout.write(`${ENGINE_VERSION}
2111
+ `);
2112
+ return 0;
2113
+ }
2114
+ switch (cmd) {
2115
+ case "check":
2116
+ return cmdCheck(cwd, positional, flags);
2117
+ case "init":
2118
+ return cmdInit(cwd, flags);
2119
+ case "scan":
2120
+ return cmdScan(cwd, positional, flags);
2121
+ case "import":
2122
+ return cmdImport(cwd, positional, flags);
2123
+ case "rules":
2124
+ return cmdRules();
2125
+ default:
2126
+ process.stdout.write(HELP);
2127
+ return cmd === "help" || cmd === "--help" || cmd === "-h" ? 0 : 1;
2128
+ }
2129
+ }
2130
+ main().then(
2131
+ (code) => process.exit(code),
2132
+ (err) => {
2133
+ log(`brandrail: ${err.message}`);
2134
+ process.exit(2);
2135
+ }
2136
+ );
2137
+ export {
2138
+ main
2139
+ };