On brand check
@brandkit/on-brand-check
Review a page, stylesheet, email, deck outline, or post against your brand guide and get a short fix list: off-palette colors (with the nearest brand color), wrong fonts, sizes and spacing off the scale, banned words, logo misuse, and voice. Reports only; changes nothing.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Mechanical brand checks for CSS, HTML, JSX, Markdown, and text files.
- 3// Usage: node scripts/check-brand.mjs <brand.md> <file>... [--json out/brand-check.json]
- 4// Finds: colors not in the palette (with the nearest brand color), fonts not in the brand
- 5// stacks, words the voice guide avoids, and font sizes or spacing off the scale.
- 6// Always exits 0 unless the inputs are missing; it reports, it does not judge.
- 7import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 8import { dirname } from "node:path";
- 9import { flatten, readTokens } from "./tokens-lib.mjs";
- 11const args = process.argv.slice(2);
- 12function flag(name, fallback) {
- 13 const i = args.indexOf(name);
- 14 if (i === -1) return fallback;
- 15 const value = args[i + 1] ?? "";
- 16 args.splice(i, 2);
- 17 return value;
- 18}
- 19const jsonOut = flag("--json", "");
- 20const [guide, ...files] = args;
- 22function fail(msg) {
- 23 console.error(`check-brand: ${msg}`);
- 24 process.exit(1);
- 25}
- 26if (!guide || files.length === 0) fail("usage: node scripts/check-brand.mjs brand.md <file>... [--json out/brand-check.json]");
- 27if (!existsSync(guide)) fail(`${guide} not found`);
- 29let tokens;
- 30try {
- 31 tokens = readTokens(readFileSync(guide, "utf8"));
- 32} catch (err) {
- 33 fail(`${guide}: ${err.message}`);
- 34}
- 36// ---- palette -------------------------------------------------------------
- 37function toRgb(value) {
- 38 const v = String(value).trim().toLowerCase();
- 39 let m = /^#([0-9a-f]{3,8})$/.exec(v);
- 40 if (m) {
- 41 let h = m[1];
- 42 if (h.length === 3 || h.length === 4) h = [...h.slice(0, 3)].map((c) => c + c).join("");
- 43 if (h.length !== 6 && h.length !== 8) return null;
- 44 return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
- 45 }
- 46 m = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/.exec(v);
- 47 if (m) return [m[1], m[2], m[3]].map(Number);
- 48 return null;
- 49}
- 50function hexOf(rgb) {
- 51 return `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
- 52}
- 53// Weighted RGB distance ("redmean"), good enough to say "this is basically the brand green".
- 54function distance(a, b) {
- 55 const r = (a[0] + b[0]) / 2;
- 56 const [dr, dg, db] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
- 57 return Math.sqrt((2 + r / 256) * dr * dr + 4 * dg * dg + (2 + (255 - r) / 256) * db * db);
- 58}
- 59const palette = flatten(tokens.color)
- 60 .map(([name, value]) => ({ name, rgb: toRgb(value) }))
- 61 .filter((p) => p.rgb);
- 62// Neutral extremes are always allowed: pure white, pure black, and transparent.
- 63const ALWAYS_OK = new Set(["#FFFFFF", "#000000"]);
- 65// Lowercased name -> name as written in the guide.
- 66const fontFamilies = new Map(
- 67 flatten(tokens.font)
- 68 .flatMap(([, v]) => v.split(","))
- 69 .map((f) => f.trim().replace(/^["']|["']$/g, ""))
- 70 .filter(Boolean)
- 71 .map((f) => [f.toLowerCase(), f]),
- 72);
- 73const GENERIC = new Set(["serif", "sans-serif", "monospace", "system-ui", "ui-sans-serif", "ui-serif", "ui-monospace", "inherit", "initial", "unset", "cursive", "-apple-system"]);
- 75const avoid = (() => {
- 76 const v = tokens.voice?.avoid;
- 77 const list = Array.isArray(v) ? v : v ? String(v).split(",") : [];
- 78 return list.map((w) => String(w).trim()).filter(Boolean);
- 79})();
- 81const sizeScale = new Set(flatten(tokens.text).map(([, v]) => v.trim().toLowerCase()));
- 82const spaceScale = new Set(flatten(tokens.space).map(([, v]) => v.trim().toLowerCase()));
- 84// ---- scan ----------------------------------------------------------------
- 85const findings = [];
- 86const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g;
- 87const FONT_RE = /font-family\s*:\s*([^;}\n]+)|fontFamily\s*:\s*["'`]([^"'`]+)["'`]/g;
- 88const SIZE_RE = /font-size\s*:\s*(\d+(?:\.\d+)?px)/g;
- 89const SPACE_RE = /\b(?:margin|padding|gap|row-gap|column-gap)(?:-(?:top|right|bottom|left|inline|block))?\s*:\s*([^;}\n]+)/g;
- 91for (const file of files) {
- 92 if (!existsSync(file)) {
- 93 findings.push({ file, line: 0, kind: "missing-file", found: file, suggestion: "file not found" });
- 94 continue;
- 95 }
- 96 const isCode = /\.(css|scss|sass|less|html?|jsx?|tsx?|vue|svelte|svg)$/i.test(file);
- 97 const lines = readFileSync(file, "utf8").split(/\r?\n/);
- 98 lines.forEach((text, i) => {
- 99 const line = i + 1;
- 100 if (isCode) {
- 101 for (const m of text.matchAll(COLOR_RE)) {
- 102 // Skip things like "#section" anchors and ids: real hex colors only.
- 103 if (m[0].startsWith("#") && !/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(m[0])) continue;
- 104 const rgb = toRgb(m[0]);
- 105 if (!rgb || palette.length === 0) continue;
- 106 const hex = hexOf(rgb);
- 107 if (ALWAYS_OK.has(hex)) continue;
- 108 let best = null;
- 109 for (const p of palette) {
- 110 const d = distance(rgb, p.rgb);
- 111 if (!best || d < best.d) best = { ...p, d };
- 112 }
- 113 if (best.d < 1) continue;
- 114 findings.push({
- 115 file,
- 116 line,
- 117 kind: best.d <= 40 ? "near-brand-color" : "off-palette-color",
- 118 found: m[0],
- 119 suggestion: best.d <= 40 ? `use color.${best.name} (${hexOf(best.rgb)}); this is almost it` : `nearest brand color is color.${best.name} (${hexOf(best.rgb)})`,
- 120 });
- 121 }
- 122 for (const m of text.matchAll(FONT_RE)) {
- 123 const first = (m[1] ?? m[2]).split(",")[0].trim().replace(/^["']|["']$/g, "");
- 124 const key = first.toLowerCase();
- 125 if (!key || key.startsWith("var(") || GENERIC.has(key) || fontFamilies.has(key)) continue;
- 126 if (fontFamilies.size === 0) continue;
- 127 findings.push({ file, line, kind: "off-brand-font", found: first, suggestion: `use a brand font: ${[...fontFamilies].filter(([k]) => !GENERIC.has(k)).map(([, v]) => v).join(", ")}` });
- 128 }
- 129 if (sizeScale.size > 0) {
- 130 for (const m of text.matchAll(SIZE_RE)) {
- 131 if (!sizeScale.has(m[1].toLowerCase()))
- 132 findings.push({ file, line, kind: "off-scale-size", found: m[1], suggestion: `type scale: ${[...sizeScale].join(", ")}` });
- 133 }
- 134 }
- 135 if (spaceScale.size > 0) {
- 136 for (const m of text.matchAll(SPACE_RE)) {
- 137 const off = m[1]
- 138 .trim()
- 139 .split(/\s+/)
- 140 .filter((v) => /^\d+(?:\.\d+)?px$/.test(v) && v !== "0px" && !spaceScale.has(v.toLowerCase()));
- 141 for (const v of off)
- 142 findings.push({ file, line, kind: "off-scale-spacing", found: v, suggestion: `spacing scale: ${[...spaceScale].join(", ")}` });
- 143 }
- 144 }
- 145 }
- 146 for (const word of avoid) {
- 147 const re = new RegExp(`(^|[^\\p{L}])(${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})(?=[^\\p{L}]|$)`, "giu");
- 148 for (const m of text.matchAll(re)) {
- 149 findings.push({ file, line, kind: "avoided-word", found: m[2], suggestion: "the voice guide avoids this word; say it plainly" });
- 150 }
- 151 }
- 152 });
- 153}
- 155const counts = {};
- 156for (const f of findings) counts[f.kind] = (counts[f.kind] ?? 0) + 1;
- 157const report = {
- 158 guide,
- 159 files,
- 160 checked: {
- 161 palette: palette.length,
- 162 fonts: fontFamilies.size,
- 163 avoided_words: avoid.length,
- 164 type_scale: sizeScale.size,
- 165 space_scale: spaceScale.size,
- 166 },
- 167 total: findings.length,
- 168 counts,
- 169 findings,
- 170};
- 172if (jsonOut) {
- 173 mkdirSync(dirname(jsonOut), { recursive: true });
- 174 writeFileSync(jsonOut, `${JSON.stringify(report, null, 2)}\n`);
- 175}
- 177console.log(`# Mechanical brand check: ${findings.length} finding(s)\n`);
- 178console.log(`Checked ${files.length} file(s) against ${guide}: ${palette.length} colors, ${fontFamilies.size} font names, ${avoid.length} avoided words.\n`);
- 179if (findings.length > 0) {
- 180 console.log("| File | Line | Kind | Found | Suggestion |");
- 181 console.log("| --- | --- | --- | --- | --- |");
- 182 for (const f of findings) console.log(`| ${f.file} | ${f.line} | ${f.kind} | \`${f.found}\` | ${f.suggestion} |`);
- 183}
- 184if (jsonOut) console.log(`\nJSON: ${jsonOut}`);
