// scripts/ci/check-design-tokens.mjs // // Design-token SSOT guard. Fails when a surface hardcodes a color, uses a // numeric literal for spacing/radius/control that a token already owns, or // re-introduces a bold weight that design.md v3 retired. // // The point is not zero-hex everywhere: a token *definition* file legitimately // holds raw values. Everything else must consume --d3-* / d3ro* tokens. // // Usage: // node scripts/ci/check-design-tokens.mjs # check, exit 1 on violation // node scripts/ci/check-design-tokens.mjs --json # machine-readable report // node scripts/ci/check-design-tokens.mjs --self-test // // Allowlisted paths are the ONLY places raw color literals may live. import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const HERE = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(HERE, '..', '..') // Directories scanned for consumer code (not token definitions). const TARGETS = [ 'apps/desktop/src', 'apps/web/src', 'apps/admin/src', 'apps/mobile-rn/src', 'packages/ui/src', 'packages/ui-native/src', 'site/src', 'site/public', ] // Token *definition* layers. Raw values are the point here, not a violation. const ALLOWLIST = new Set([ 'packages/ui/src/theme.ts', 'packages/ui/src/theme-vars.ts', 'packages/ui-native/src/theme.ts', 'apps/mobile-rn/src/theme/mobile-theme.ts', 'apps/admin/src/lib/console-theme.ts', // Canvas cannot resolve CSS custom properties; these are SSR fallbacks that // mirror --d3-gradient-wave1..4 and are never painted on the client. 'packages/ui/src/components/ds/GradientWave.tsx', 'packages/ui/src/components/ds/AudioVisualizerBar.tsx', 'apps/desktop/src/main/services/MeetingModeService.ts', 'apps/desktop/src/main/services/CloudSyncService.ts', 'apps/desktop/src/main/windows/WindowManager.ts', 'site/src/tokens.ts', 'site/src/index.css', 'site/tailwind.config.js', 'site/public/accept-invite.css', 'site/public/legal.css', 'apps/desktop/src/renderer/styles/global.css', ]) const IGNORED_DIRS = new Set([ 'node_modules', '.next', 'dist', 'build', 'out', 'coverage', '.turbo', 'android', 'ios', '__snapshots__', ]) const SCAN_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html']) // A token definition file may define local `:root` fallbacks for popups. const isPopupStyle = (rel) => /apps\/desktop\/src\/renderer\/popups\/.*\/style\.css$/.test(rel) const isTestFile = (rel) => /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/.test(rel) const HEX = /#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])/g const FUNC_COLOR = /\b(?:rgba?|hsla?)\([^)]*\)/g const BOLD_WEIGHT = /(font-?weight\s*[:=]\s*['"]?([7-9]\d0)\b|fontWeight\s*:\s*([7-9]\d0)\b)/g function walk(dir, files) { let entries try { entries = readdirSync(dir) } catch { return } for (const name of entries) { if (IGNORED_DIRS.has(name)) continue const full = join(dir, name) const st = statSync(full) if (st.isDirectory()) walk(full, files) else if (SCAN_EXT.has(name.slice(name.lastIndexOf('.')))) files.push(full) } } function isAllowlisted(rel) { if (ALLOWLIST.has(rel)) return true if (isPopupStyle(rel)) return true if (isTestFile(rel)) return true return false } function hexLooksLikeColor(match, line, index) { const before = line[index - 1] // URL fragment / selector boundary: #features, #root, url(#clip) if (before && /[A-Za-z0-9_\-/)&(]/.test(before)) return false // HTML attribute value: href="#download", id='#x' if ((before === '"' || before === "'") && line[index - 2] === '=') return false return true } // A mask gradient uses white as an opacity stencil, not a painted color. const isMaskIdiom = (line) => /#fff 0 0/.test(line) // Comment lines describe identifiers like #access_token; they are not colors. const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|