#!/usr/bin/env node /* Token contract guard — runs as part of `npm run check`. Bans hardcoded colors/fonts so every visual change goes through src/tokens.css. What it checks: • every CSS file under src/ (except tokens.css): hex colors anywhere, named colors, rgb()/rgba(), hsl()/oklch()/lab()/…, px font sizes — including values split across multiple lines. • every .ts/.tsx file under src/: the same color rules inside inline `style={{ ... }}` objects. • every `var(--x)` reference (CSS + inline styles) must be defined in some CSS file, so a typo'd token fails the check instead of silently rendering nothing. */ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' const ROOTS = process.argv.length > 2 ? process.argv.slice(2) : ['src'] const EXEMPT = new Set(['tokens.css']) // Neutral shine/shadow literals stay allowed (they are surfaces, not palette). const RGB_ALLOW = [/rgba\(\s*0\s*,\s*0\s*,\s*0/, /rgba\(\s*255\s*,\s*255\s*,\s*255/] // CSS named colors (CSS Color 4 list; 'transparent'/'currentColor' are keywords, not colors). const NAMED_COLORS = ( 'aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,' + 'burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,' + 'darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,' + 'darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,' + 'deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,' + 'ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,' + 'lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,' + 'lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,' + 'lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,' + 'mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,' + 'mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,' + 'orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,' + 'pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,' + 'seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,' + 'teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen' ).split(',') const NAMED_COLOR_RE = new RegExp(`\\b(${NAMED_COLORS.join('|')})\\b`, 'gi') // Functions whose *arguments* may legitimately contain colors only via tokens/color-mix. const COLOR_FN_RE = /\b(rgb|rgba|hsl|hsla|hwb|oklch|oklcha|lab|lch|lcha|color|color-mix)\(/i const violations = [] const definedTokens = new Set() const usedTokens = new Map() // token name -> first usage site function* walk(dir) { for (const entry of readdirSync(dir)) { const full = join(dir, entry) if (statSync(full).isDirectory()) { yield* walk(full) } else { yield full } } } function stripComments(text) { return text.replace(/\/\*[\s\S]*?\*\//g, '') } function lineOf(text, index) { let line = 1 for (let i = 0; i < index && i < text.length; i++) { if (text[i] === '\n') line++ } return line } function noteTokens(value, site) { for (const m of value.matchAll(/var\(\s*(--[\w-]+)/g)) { if (!usedTokens.has(m[1])) usedTokens.set(m[1], site) } } /** Colors are only allowed as var() references or color-mix/light-dark over tokens. Strategy: strip every token-driven construct, then flag any color literal left. */ function stripTokenColors(value) { let v = value for (let i = 0; i < 6; i++) { const next = v .replace(/var\([^()]*\)/gi, ' ') .replace(/color-mix\([^()]*\)/gi, ' ') .replace(/light-dark\([^()]*\)/gi, ' ') if (next === v) return v v = next } return v } function isTokenOnlyValue(value) { if (RGB_ALLOW.some(re => re.test(value))) return true const residue = stripTokenColors(value) return !COLOR_FN_RE.test(residue) && !NAMED_COLOR_RE.test(residue) && !/#[0-9a-fA-F]{3,8}\b/.test(residue) } function checkColorValue(value, site, what) { const hex = value.match(/#[0-9a-fA-F]{3,8}\b/) if (hex) { violations.push(`${site} hex color ${hex[0]} in ${what} — add a token in tokens.css instead`) return } if (isTokenOnlyValue(value)) return const fn = value.match(COLOR_FN_RE) if (fn) { violations.push( `${site} ${fn[1]}() color in ${what} — use var(--token) or color-mix(in srgb, var(--token) N%, transparent)`, ) return } const named = value.match(NAMED_COLOR_RE) if (named) { violations.push(`${site} named color "${named[0]}" in ${what} — add a token in tokens.css instead`) } } function checkFontPx(value, site, what) { const px = value.match(/-?\d+(\.\d+)?px/i) if (px) violations.push(`${site} px font size (${px[0]}) in ${what} — use var(--text-*)`) } const COLOR_PROP_RE = /(^|-)(color|background|border|fill|stroke|shadow|outline|caret|accent|text-decoration|column-rule)/i const FONT_PROP_RE = /^font(-?size)?$/i /* ── CSS files ──────────────────────────────────────────────────── */ function checkCssFile(file, css) { const text = stripComments(css) // Token definitions are collected from every CSS file (tokens.css included). for (const m of text.matchAll(/--[\w-]+\s*:/g)) { definedTokens.add(m[0].replace(/\s*:.*$/s, '').trim()) } // tokens.css is the source of truth — collect definitions, skip value checks. if (EXEMPT.has(file.split('/').pop())) return // Hex anywhere (same contract as the original line-based guard, but comment-safe). for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) { violations.push( `${file}:${lineOf(css, m.index ?? 0)} hex color ${m[0]} — add a token in tokens.css instead`, ) } // Declarations may span lines: `prop: value;` up to the next `;` or `}`. // Lookbehind keeps the anchor zero-width so every declaration in a block matches. for (const m of text.matchAll(/(^|(?<=[{;]))\s*([-a-zA-Z]+)\s*:\s*([^;{}]+)[;}]/g)) { const prop = m[2].trim().toLowerCase() const value = m[3].trim() const site = `${file}:${lineOf(css, m.index ?? 0)}` const what = `"${prop}" declaration` if (COLOR_PROP_RE.test(prop)) checkColorValue(value, site, what) if (FONT_PROP_RE.test(prop)) checkFontPx(value, site, what) noteTokens(value, site) } // Named colors can also hide inside longhand gradients/shadows — sweep the whole file, // skipping property-name positions (e.g. `white-space`). for (const m of text.matchAll(NAMED_COLOR_RE)) { const idx = m.index ?? 0 const declStart = Math.max(text.lastIndexOf(';', idx), text.lastIndexOf('{', idx), text.lastIndexOf('}', idx)) + 1 const segment = text.slice(declStart, idx) const colon = segment.lastIndexOf(':') if (colon === -1) continue // match is in a selector, not a value const prop = segment.slice(0, colon).trim().toLowerCase() if (prop.startsWith('--')) continue if (!isTokenOnlyValue(text.slice(idx - 2, idx + m[0].length + 20))) { violations.push( `${file}:${lineOf(css, idx)} named color "${m[0]}" in "${prop}" — add a token in tokens.css instead`, ) } } } /* ── TS/TSX inline styles ───────────────────────────────────────── */ /** Find balanced `style={{ ... }}` object literals; returns their bodies. */ function extractStyleObjects(text) { const objects = [] const re = /style\s*=\s*\{\s*\{/g let m while ((m = re.exec(text))) { let depth = 2 let i = re.lastIndex let inString = null for (; i < text.length && depth > 0; i++) { const ch = text[i] if (inString) { if (ch === '\\') i++ else if (ch === inString) inString = null } else if (ch === "'" || ch === '"' || ch === '`') { inString = ch } else if (ch === '{') { depth++ } else if (ch === '}') { depth-- } } objects.push({ body: text.slice(re.lastIndex, i - 1), index: re.lastIndex }) re.lastIndex = i } return objects } function checkTsxFile(file, source) { const text = stripComments(source) for (const { body, index } of extractStyleObjects(text)) { // Split `key: value` pairs on commas that are followed by another `key:` // (whitespace/newlines allowed between them). for (const m of body.matchAll(/([-a-zA-Z]+)\s*:\s*((?:[^,]|\,(?!\s*[a-zA-Z-]+\s*:))*)/g)) { const prop = m[1].trim().toLowerCase() const value = m[2].trim() if (!value || value === "'{" || prop.startsWith('--')) continue const site = `${file}:${lineOf(source, index)}` const what = `style={{ ${prop} }}` if (COLOR_PROP_RE.test(prop)) checkColorValue(value, site, what) if (FONT_PROP_RE.test(prop)) checkFontPx(value, site, what) noteTokens(value, site) } } } /* ── Run ────────────────────────────────────────────────────────── */ const cssFiles = [] const tsFiles = [] for (const root of ROOTS) { for (const f of walk(root)) { if (f.endsWith('.css')) cssFiles.push(f) else if (f.endsWith('.tsx') || f.endsWith('.ts')) tsFiles.push(f) } } for (const f of cssFiles) checkCssFile(f, readFileSync(f, 'utf8')) for (const f of tsFiles) checkTsxFile(f, readFileSync(f, 'utf8')) // Cross-check: every referenced token must exist (catches typos that silently render nothing). for (const [token, site] of usedTokens) { if (!definedTokens.has(token)) { violations.push(`${site} var(${token}) is never defined — typo, or add it to tokens.css`) } } if (violations.length) { console.error(`\n✗ token contract violations:\n\n${[...new Set(violations)].join('\n')}\n`) process.exit(1) } else { console.log( `✓ token contract OK — ${cssFiles.length} css + ${tsFiles.length} ts/tsx files clean, ` + `${usedTokens.size} var() references all defined`, ) }