/** * 樣式把關:顏色與字級只能來自 tokens.css。 * * 會擋下的事: * 1. css 寫死 font-size 數值(em 例外:那是刻意的相對縮放) * 2. tsx 的 inline style 寫死 fontSize 或色碼 * 3. css 出現任何 css 檔都沒定義的 --hb-*(--hb-warn 這種拼錯永遠吃 fallback,很難發現) * 4. css 出現寫死的顏色 * 5. 把兩個有彩度的 token 互相 color-mix(藍配橘是補色,混出來一定是濁褐) * * 第 4 條放過兩種情形:純黑純白(只用於陰影與高光的 color-mix,本身不是主題色), * 以及 global.css 主題預覽色塊那 12 個值 —— 那裡必須同時顯示淺色與深色, * 不能吃會隨主題切換的 var()。 */ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; const STYLE_DIR = "src/styles"; const NEUTRAL = /^#(fff|000|ffffff|000000)$/i; /** * 有彩度的 token。這些彼此不能混:主色在 H≈187、暖色在 H≈32,是補色關係, * 任何比例混出來都是濁褐。要調淡就跟 surface/line/transparent 混,要同一顏色 * 的深淺兩階就用 X 與 X-deep。 */ const CHROMATIC = /^--hb-(brand|brand-hover|brand-deep|brand-dim|magic|accent-warm|warning|warning-deep|danger|danger-deep|success|gold|gold-bright)$/; const MIX = /color-mix\(in srgb,\s*var\((--hb-[a-z0-9-]+)\)[^,]*,\s*var\((--hb-[a-z0-9-]+)\)\s*\)/g; /** 主題預覽色塊:見 global.css 的 .hb-theme-picker__swatch--* */ const HEX_BUDGET = { "global.css": 12, "layout.css": 0, "ui.css": 0 }; const errors = []; function walk(dir) { const out = []; for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) out.push(...walk(path)); else out.push(path); } return out; } const cssFiles = readdirSync(STYLE_DIR).filter((f) => f.endsWith(".css")); // 除了 tokens.css 的全域 token,元件內宣告的區域變數(如 --hb-top-ctrl)也算已定義。 const defined = new Set( cssFiles.flatMap((f) => [ ...readFileSync(join(STYLE_DIR, f), "utf8").matchAll(/^\s*(--hb-[a-z0-9-]+)\s*:/gm), ].map((m) => m[1])), ); for (const file of readdirSync(STYLE_DIR)) { if (!file.endsWith(".css") || file === "tokens.css") continue; const path = join(STYLE_DIR, file); const text = readFileSync(path, "utf8"); text.split("\n").forEach((line, i) => { const at = `${path}:${i + 1}`; if (/font-size:\s*[0-9.]+(rem|px|pt)/.test(line)) { errors.push(`${at} 寫死 font-size,請改用 var(--hb-text-*):${line.trim()}`); } for (const [, name] of line.matchAll(/var\((--hb-[a-z0-9-]+)/g)) { if (!defined.has(name)) { errors.push(`${at} 用了 tokens.css 沒有的 ${name}(拼錯?)`); } } for (const [, a, b] of line.matchAll(MIX)) { if (CHROMATIC.test(a) && CHROMATIC.test(b)) { errors.push( `${at} 混了兩個有彩度的顏色(${a} + ${b}),會變濁。` + `要調淡請跟 surface/line/transparent 混,要深一階請用 -deep。`, ); } } }); const hex = (text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).filter((h) => !NEUTRAL.test(h)); const budget = HEX_BUDGET[file] ?? 0; if (hex.length > budget) { errors.push( `${path} 寫死顏色 ${hex.length} 處(上限 ${budget}):${[...new Set(hex)].join(" ")}。` + `顏色請走 tokens.css,否則深淺模式會有一半畫面不跟著切。`, ); } } for (const path of walk("src")) { if (!path.endsWith(".tsx")) continue; const text = readFileSync(path, "utf8"); text.split("\n").forEach((line, i) => { const at = `${path}:${i + 1}`; if (/fontSize:\s*"[0-9.]/.test(line)) { errors.push(`${at} inline fontSize 寫死,請改用 var(--hb-text-*)`); } if (/(color|background|borderColor):\s*"#[0-9a-fA-F]{3,8}"/.test(line)) { errors.push(`${at} inline 色碼寫死,請改用 var(--hb-*)`); } }); } if (errors.length) { console.error(`樣式把關失敗(${errors.length}):`); for (const e of errors) console.error(" " + e); process.exit(1); } console.log("樣式把關通過:顏色與字級都來自 tokens.css");