Skip to content
SlashClone

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/.

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

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.