Skip to content
SlashClone
Made by@brandkit

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.

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