Create brand guidelines for your product
@slashclone/brand-book
Create brand guidelines for your product: one shareable HTML brand book covering logo, colors, type, icons, share images, and voice. It follows your real design tokens in light and dark themes, and a checker confirms contrast in both themes, icon and image sizes, and banned copy such as em dashes. Writes only to brand/ and public/brand/.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Checks a brand kit folder against its brand.json, with Node's standard library only.
- 3//
- 4// node scripts/check-brand.mjs --dir brand [--out brand/check.json] [--report-only]
- 5//
- 6// What it checks:
- 7// - tokens: light and dark have the same names, and every value is a #RRGGBB hex.
- 8// - contrast: every pair in "pairs" meets its minimum WCAG ratio in both themes.
- 9// - assets: every listed file exists; PNG width and height match when given;
- 10// an SVG has a viewBox.
- 11// - copy: no file in "copy" contains a banned string from voice.banned.
- 12// Exits 1 when anything fails, unless --report-only is passed.
- 13import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 14import { dirname, join } from "node:path";
- 15import { parseArgs } from "node:util";
- 17const { values } = parseArgs({
- 18 options: {
- 19 dir: { type: "string", default: "brand" },
- 20 out: { type: "string" },
- 21 "report-only": { type: "boolean", default: false },
- 22 },
- 23});
- 25const dir = values.dir;
- 26const specPath = join(dir, "brand.json");
- 27if (!existsSync(specPath)) {
- 28 console.error(`No ${specPath}. Copy examples/brand.json there and fill it in.`);
- 29 process.exit(1);
- 30}
- 31const spec = JSON.parse(readFileSync(specPath, "utf8"));
- 32const failures = [];
- 33const fail = (area, message) => failures.push({ area, message });
- 35// Tokens
- 36const HEX = /^#[0-9a-fA-F]{6}$/;
- 37const light = spec.tokens?.light ?? {};
- 38const dark = spec.tokens?.dark ?? {};
- 39const themes = { light, dark };
- 40for (const name of Object.keys(light)) {
- 41 if (!(name in dark)) fail("tokens", `${name} is in light but not in dark`);
- 42}
- 43for (const name of Object.keys(dark)) {
- 44 if (!(name in light)) fail("tokens", `${name} is in dark but not in light`);
- 45}
- 46for (const [theme, set] of Object.entries(themes)) {
- 47 for (const [name, value] of Object.entries(set)) {
- 48 if (typeof value !== "string" || !HEX.test(value)) {
- 49 fail("tokens", `${theme}.${name} is not a #RRGGBB hex`);
- 50 }
- 51 }
- 52}
- 54// Contrast
- 55const channel = (v) => {
- 56 const s = v / 255;
- 57 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
- 58};
- 59const luminance = (hex) => {
- 60 const [r, g, b] = [1, 3, 5].map((i) => channel(parseInt(hex.slice(i, i + 2), 16)));
- 61 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
- 62};
- 63const ratio = (a, b) => {
- 64 const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
- 65 return (hi + 0.05) / (lo + 0.05);
- 66};
- 67const contrast = [];
- 68for (const pair of spec.pairs ?? []) {
- 69 for (const [theme, set] of Object.entries(themes)) {
- 70 const fg = set[pair.fg];
- 71 const bg = set[pair.bg];
- 72 if (!HEX.test(fg ?? "") || !HEX.test(bg ?? "")) {
- 73 fail("contrast", `${theme}: ${pair.fg} on ${pair.bg} names a missing token`);
- 74 continue;
- 75 }
- 76 const r = Math.round(ratio(fg, bg) * 100) / 100;
- 77 const ok = r >= pair.min;
- 78 contrast.push({ theme, fg: pair.fg, bg: pair.bg, ratio: r, min: pair.min, ok });
- 79 if (!ok) fail("contrast", `${theme}: ${pair.fg} on ${pair.bg} is ${r}, needs ${pair.min}`);
- 80 }
- 81}
- 83// Assets
- 84const pngSize = (buf) => {
- 85 const sig = "89504e470d0a1a0a";
- 86 if (buf.length < 24 || buf.subarray(0, 8).toString("hex") !== sig) return undefined;
- 87 return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
- 88};
- 89let assetsFound = 0;
- 90for (const asset of spec.assets ?? []) {
- 91 const file = join(dir, asset.path);
- 92 if (!existsSync(file)) {
- 93 fail("assets", `missing ${asset.path}`);
- 94 continue;
- 95 }
- 96 assetsFound++;
- 97 const buf = readFileSync(file);
- 98 if (asset.path.endsWith(".png")) {
- 99 const size = pngSize(buf);
- 100 if (!size) fail("assets", `${asset.path} is not a PNG`);
- 101 else if (
- 102 (asset.width && size.width !== asset.width) ||
- 103 (asset.height && size.height !== asset.height)
- 104 ) {
- 105 fail(
- 106 "assets",
- 107 `${asset.path} is ${size.width}x${size.height}, expected ${asset.width}x${asset.height}`,
- 108 );
- 109 }
- 110 } else if (asset.path.endsWith(".svg") && !buf.toString("utf8").includes("viewBox")) {
- 111 fail("assets", `${asset.path} has no viewBox, so it will not scale`);
- 112 }
- 113}
- 115// Copy
- 116const banned = spec.voice?.banned ?? ["\u2014"];
- 117const label = (s) => (s === "\u2014" ? "an em dash" : `"${s}"`);
- 118let copyChecked = 0;
- 119for (const rel of spec.copy ?? []) {
- 120 const file = join(dir, rel);
- 121 if (!existsSync(file)) {
- 122 fail("copy", `missing ${rel}`);
- 123 continue;
- 124 }
- 125 copyChecked++;
- 126 const lines = readFileSync(file, "utf8").split("\n");
- 127 lines.forEach((line, i) => {
- 128 for (const word of banned) {
- 129 const hit = /^[a-z]+$/i.test(word)
- 130 ? line.toLowerCase().includes(word.toLowerCase())
- 131 : line.includes(word);
- 132 if (hit) fail("copy", `${rel}:${i + 1} uses ${label(word)}`);
- 133 }
- 134 });
- 135}
- 137const report = {
- 138 name: spec.name ?? "",
- 139 edition: spec.edition ?? 0,
- 140 tokens: Object.keys(light).length,
- 141 contrast,
- 142 assets: { listed: (spec.assets ?? []).length, found: assetsFound },
- 143 copyChecked,
- 144 failing: failures.length,
- 145 failures,
- 146};
- 147if (values.out) {
- 148 mkdirSync(dirname(values.out), { recursive: true });
- 149 writeFileSync(values.out, `${JSON.stringify(report, null, 2)}\n`);
- 150}
- 151for (const row of contrast) {
- 152 console.log(
- 153 `${row.ok ? "pass" : "FAIL"} ${row.theme.padEnd(5)} ${row.fg} on ${row.bg} ${row.ratio} (min ${row.min})`,
- 154 );
- 155}
- 156for (const f of failures) console.log(`FAIL ${f.area}: ${f.message}`);
- 157console.log(
- 158 `${failures.length} failing, ${assetsFound} of ${report.assets.listed} assets, ${copyChecked} copy files checked`,
- 159);
- 160process.exit(failures.length && !values["report-only"] ? 1 : 0);
