#!/usr/bin/env node // check-style.mjs — design-system §06 anti-pattern guard for apps/kimi-web. // // Scans src/** for the rules in the design system (§06 of the DesignSystemView spec): // no-gradient-text, no-glassmorphism (.frost exempt), no-color-glow, // icon-from-registry (hand-written ; Icon/Spinner/MoonSpinner + the // 32x22 brand mark exempt), no-emoji-icon (moon in MoonSpinner exempt), // no-hardcoded-hex (DiffView/DiffLines/Terminal domain colors + var() // fallbacks exempt), no-hardcoded-font (token definitions exempt), // radius-from-scale, z-from-scale, weight-from-scale. // // Default mode: report a baseline and exit 0 (warnings only). Pass --strict // to exit 1 when any finding exists (flipped on in P3 enforcement). import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const SRC = path.join(ROOT, 'src'); const STRICT = process.argv.includes('--strict'); const DOMAIN_HEX_EXEMPT = new Set([ 'chat/DiffView.vue', 'chat/DiffLines.vue', 'Terminal.vue', ]); // Files that legitimately render their own : the icon primitive itself, // bespoke data-viz / colored illustrations, the spinner, and brand marks // (the Kimi wordmark on the loading screen). Everything else should use // lib/icons.ts via /iconSvg(). The 32x22 Kimi eye logo is also exempted // inline (matched by viewBox). const ICON_EXEMPT = new Set([ 'components/ui/Icon.vue', 'components/ui/Spinner.vue', 'components/ui/MoonSpinner.vue', 'components/ui/ContextRing.vue', 'components/ui/AuthStateIcon.vue', 'components/GlobalLoading.vue', ]); // Files entirely exempt from the §06 scan. The design-system showcase view is // documentation/demo CSS (forced-dark previews, syntax-highlighting palettes, // illustrative mockups) rather than product UI, so the anti-pattern rules do not // apply to it. const FILE_EXEMPT = new Set(['views/DesignSystemView.vue']); const RADIUS_SCALE = new Set([4, 6, 8, 12, 16, 20, 999]); const WEIGHT_OK = new Set([ '400', '500', 'normal', 'bolder', 'lighter', 'inherit', 'initial', 'unset', 'revert', ]); const Z_OK = new Set(['0', '1', '-1', 'auto']); /** @type {{ rule: string, file: string, line: number, detail: string }[]} */ const findings = []; function walk(dir, out = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === 'dist') continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full, out); else if (/\.(vue|css)$/.test(entry.name)) out.push(full); } return out; } function rel(abs) { return path.relative(SRC, abs).replaceAll(path.sep, '/'); } function lineOf(text, index) { let n = 1; for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++; return n; } function add(rule, file, line, detail) { findings.push({ rule, file, line, detail }); } function stripVarSpans(line) { // Remove var(...) substrings so var() fallbacks don't trip hex checks. return line.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ''); } function extractStyleBlocks(content) { // For .vue: return [{text, baseLine}] for each