漂移防護
問題
設計系統的規則寫在文件裡,三個月後就沒有人記得。
不是因為大家不專業,是因為趕時間的時候,違反規則永遠是比較快的選項:
硬編一個顏色比查 token 快、複製一段 <table> 比讀 DataTable 的 API 快。
三道防線,依「防漏成本」排序
同一條規則可以擋在三個不同的地方,越前面越便宜:
| 防線 | 手段 | 擋掉什麼 |
|---|---|---|
| ① 編譯層 | 清空 Tailwind 預設色盤 | bg-red-500 根本產不出樣式,不必靠人抓 |
| ② lint | ESLint 規則 | 繞過編譯層的逃逸路徑:bg-[#fff]、inline style 硬編色、深入內部路徑 |
| ③ 測試 | 守衛測試 | 結構性規則:相依邊界、token 成對、產物同步 |
只做測試守衛的話,規範 repo 很乾淨,但取用端照樣可以硬編顏色—— 而跨系統一致正是設計系統存在的理由。三道要一起上。
① 編譯層:讓違規寫不出來
@dooping/tokens 的 Tailwind preset 覆蓋(而非 extend)theme.colors,
所以套用 preset 之後,Tailwind 的預設色盤整個消失:
module.exports = {
presets: [require("@dooping/tokens/tailwind-preset")],
content: ["./src/**/*.{ts,tsx}"],
};
<div className="bg-red-500" /> {/* ❌ 產不出樣式,畫面上直接看得出來 */}
<div className="bg-danger/10" /> {/* ✅ */}
保留 transparent/current/inherit/white/black——它們不承載品牌語意。
真的需要額外色階,在自己的 config 用 theme.extend.colors 加回去:那是明示的例外,會出現在 diff 裡,
比默默用預設色盤好。
② lint:堵住繞路
編譯層擋不住的是繞路——bg-[#ff0000]、style={{ color: '#f00' }}、
直接 import 上游內部路徑。這些用 ESLint 擋:
# 複製到你的專案(跟元件一樣走複製,不是 npm 套件)
curl -O https://raw.githubusercontent.com/kielchang/dooping-design-book/main/templates/eslint.dooping.cjs
module.exports = { extends: ["./eslint.dooping.cjs"] };
規則本身很短,值得讀過再用——尤其 overrides 那段要依你的專案結構調整。
③ 把結構性規則寫成測試
以下八支都可以直接抄,但不是每一支都在這個 repo 裡跑——分清楚比較有用:
| 守衛 | 在本 repo | |
|---|---|---|
| 1–4 | 邊界/barrel/token 一致性/詞彙 | ✅ 在跑 |
| 5 | 禁止原始調色盤字面色 | 建議,本 repo 目前只做了防線 ①(清空預設色盤) |
| 6 | Token 副本同步 | 不適用——本 repo 直接 alias 到產物,沒有副本 |
| 7 | 文件掛鉤 | ✅ 在跑(tests/doc-hooks.test.ts) |
| 8 | 雙站部署的路徑過濾 | 部分——本 repo 兩支 workflow 共用 concurrency group,但沒有分站 |
第 6 與第 8 支是寫給有獨立文件站的宿主專案的,本 repo 的架構剛好不需要。 「不適用」不等於「不重要」——它們擋的漂移在那種架構下一樣真實。
下面這些守衛不是各自獨立的規則,是同一個承諾的幾個面向: 「這個元件庫可以被原樣搬到別的專案。」 barrel 說「公開範圍到哪裡」、邊界守衛說「邊界有沒有被穿透」、版號說「你抄的是哪一版」。 少任何一件,另外兩件都會慢慢失效——分散的規則各自都很容易被「這次先例外一下」繞過, 講成一組之後,繞過任何一條都會明顯地違反同一個承諾。
1. 邊界守衛:元件不得依賴應用層
const ALLOWED_EXTERNAL = ["react", "react-dom", "@radix-ui/", "lucide-react",
"clsx", "tailwind-merge", "class-variance-authority", "@dooping/tokens"];
const FORBIDDEN = [
{ re: /from\s+["']zustand/, why: "狀態管理屬於應用層" },
{ re: /from\s+["']react-router/, why: "路由屬於應用層" },
{ re: /from\s+["']@\//, why: "@/ 別名指向宿主專案" },
];
for (const file of shippedFiles) {
for (const spec of imports(file)) {
if (spec.startsWith(".")) continue;
expect(ALLOWED_EXTERNAL.some((p) => spec.startsWith(p))).toBe(true);
}
}
這支測試就是「元件庫能被任何專案拿去用」的自動化保證。 任何應用層概念滲進來,它會在變成技術債之前先紅。
2. Barrel 覆蓋率:新元件不會被漏掉
const missing = shippedFiles
.map(toModuleName)
.filter((m) => !barrelSource.includes(`"./${m}"`));
expect(missing).toEqual([]);
沒有這條,新元件會安靜地不出現在公開 API 裡,然後有人繞過 barrel 直接深層 import。
Barrel 有兩個方向的意義,而守衛只擋住了其中一個:
- 漏掉(守衛已擋):新元件沒進 barrel,會被人繞過 barrel 深層 import
- 多出來(守衛擋不住):不該公開的東西進了 barrel,就再也拿不掉了
第二種比第一種難救。一旦某個內部輔助元件被匯出,就會有人開始用它, 之後任何調整都是 breaking change——而它從來不是你打算支援的東西。
判準:這個東西有沒有做出視覺或互動的決策?
| 進 barrel | 不進 barrel |
|---|---|
| 有視覺/互動決策、任何專案都能用 | 綁定特定業務語彙(欄位名、狀態機、流程) |
| 只依賴 token 與彼此 | 需要應用層的狀態、路由、資料來源 |
| 有 story、有文件頁 | 只是把幾個元件拼在一起的私有便利函式 |
拿不定主意就先不匯出。加進 barrel 是 minor,拿掉是 major。
不要為了「反正遲早會用到」預先匯出。預先匯出的東西沒有文件、 沒有 story,卻已經是契約了。
3. Token 一致性:淺深成對、CSS 與來源同步
// 最容易發生的漂移:加了淺色 token 忘了配深色
const light = Object.keys(semanticColors("light"));
const dark = new Set(Object.keys(semanticColors("dark")));
expect(light.filter((k) => !dark.has(k))).toEqual([]);
// CSS 產物與來源同步(防止有人手改 dist)
expect(missingInCss).toEqual([]);
深色缺一個 token,通常要等到有人切到深色才會發現——可能是三個月後的使用者回報。
4. 詞彙守衛:防止領域語意回流
const FORBIDDEN_TERMS = ["…產業術語…", "…角色流程詞…"];
// 掃描 packages/、book/docs、docs/adr、registry
expect(hits).toEqual([]);
這支是本 repo 特有的:工具箱萃取自一套特定產業的系統, 守衛確保「示範資料與文件裡一個領域詞都不留」。
你的專案未必需要這一支,但值得想一想:你的設計系統有沒有偷偷綁定某個業務假設?
5. 禁止原始調色盤字面色
// 元件庫不得出現原始調色盤字面色,狀態色一律走語意 token
const FORBIDDEN_PALETTE =
/\b(?:border|bg|text|ring|from|to|via|fill|stroke)-(?:amber|emerald|rose|sky|orange|yellow)-\d{2,3}\b/g;
for (const file of shippedFiles) {
const hits = [...readFileSync(file, "utf8").matchAll(FORBIDDEN_PALETTE)];
expect(hits, `${file} 請改用語意 token(如 text-success / border-edit)`).toEqual([]);
}
這支和防線 ① 不重複,它補的正是 ① 的盲點。 清空預設色盤之後,
bg-amber-500 只是靜默產不出樣式——不報錯、不紅燈,元件看起來「有點不對」而已。
更要緊的是取用端:他們不一定套我們的 preset(token 是唯一硬相依),
那時 bg-amber-500 會真的渲染成琥珀色,而且完全不跟主題走。
token 守衛也擋不到:它檢查的是「token 本身完不完整、淺深有沒有成對」,
檢查不到「有人根本沒用 token」——直接寫 bg-amber-100 的那一行,在它眼裡不存在。
而這正是最容易發生的漂移:趕時間的時候,bg-amber-100 比「先去查該用哪個語意 token」快,
而且當下看起來一模一樣。等到有人換主題或切深色,才發現有十幾處顏色不會跟著變。
特別要盯琥珀:--edit 是保留色(ADR-0002)。一旦有人用原始色階寫了一個
「順便也是琥珀色」的高亮,「已改動未送出」這個訊號就被稀釋了,而稀釋是不可逆的——
使用者一旦學到「琥珀有時候沒有意義」,就再也不會相信它。
6. Token 副本同步
理想情況是文件站直接吃 token 產物(@import 或 alias),這樣根本沒有副本——
本 repo 自己就是這樣做的(見下一節)。但很多宿主專案做不到:
文件站是另一個框架,有自己的 CSS 管線與樣式重置,直接匯入會與站台主題打架,
或是產物裡的深色選擇器與文件站的深色開關對不上。
這時務實的做法是留一份副本,並且立刻替它配一支守衛:
it("token 副本涵蓋樣式設定引用的全部變數", () => {
const config = read("tailwind.config.js");
const copy = read("docs-site/src/css/tokens.css");
const wanted = new Set(
[...config.matchAll(/var\((--[a-z0-9-]+)\)/g)].map((m) => m[1])
// 執行期由第三方元件注入的變數不是設計 token,不需入副本
.filter((v) => !v.startsWith("--radix"))
);
expect(wanted.size).toBeGreaterThan(10); // 守衛本身沒有空轉
for (const v of wanted) expect(copy).toContain(`${v}:`);
});
| 重點 | 為什麼 |
|---|---|
| 以樣式設定實際引用的變數為準,不是以來源檔的變數清單為準 | 來源檔會有還沒被用到的 token,強迫副本全帶只會製造噪音 |
| 排除執行期注入的變數 | 第三方元件會在 runtime 注入 --* 變數,它們不是設計 token |
| 斷言「想要的集合不是空的」 | 正則寫錯時 wanted 會是空集合,迴圈一次都不跑,守衛變成永遠綠燈 |
副本檔開頭寫清楚它是副本、來源在哪、由哪支守衛盯著。
不要以為「反正加 token 的時候會記得同步」。加 token 的人在改的是元件, 他的腦子裡沒有文件站。症狀會是文件站的活範例顏色慢慢跑掉—— 那是最不容易被回報的一種壞掉,因為它看起來只是「有點怪」。
7. 文件掛鉤:引用的東西必須存在
文件一旦開始嵌入真元件(story、示意流程、範例畫面), 它就對元件庫產生了以字串為鍵的相依,而字串沒有型別檢查,改名不會有人告訴你。
// story id 由 stories 檔的 meta.title + export 名推導(沿用 Storybook 的 id 規則)
const validIds = collectStoryIds("packages/react/src/**/*.stories.tsx");
for (const f of docFiles)
for (const [, id] of read(f).matchAll(/<StoryFrame[^>]*id="([^"]+)"/g))
expect(validIds.has(id), `${f} 引用不存在的 story:${id}`).toBe(true);
失敗訊息要指出是哪個檔案引用了哪個不存在的鍵。 這支守衛紅的時候, 動手的人通常正在改元件、對文件毫無概念——訊息要能讓他不必理解文件站就知道該改哪一行。
順帶做兩件事:禁止回歸(把已經廢除的做法寫成斷言——「不要再用截圖」寫在規範裡會被遺忘, 寫成測試不會)、正向下限(斷言文件至少嵌了 N 個視覺輔助,確保守衛不是在對空集合微笑)。
本 repo 這一支是 tests/doc-hooks.test.ts(隨
零截圖文件示意
的第一個嵌入一起加的)。實作時有兩件事是量出來才知道的:
一、id 的推導必須對著真值驗一次。 這支守衛的核心是「用 meta.title + export 名
重算 Storybook 的 id」,而重算就是第二份真相——推導規則與 Storybook 稍有出入,
守衛就會開始說謊,而且兩個方向都很糟:推少了會誤判正確的引用壞掉,
推多了會放過真的壞引用。所以要拿 build-storybook 產物的 index.json
逐一對照過(本 repo 是 33 個 id 雙向全中)。只跑「測試綠了」不算驗過。
二、行內程式碼要排除,圍籬程式碼要照驗。 散文需要能討論這個標籤本身
(「掃出所有 <StoryFrame id="…">」),把它當引用會誤判——這一條是實際踩到的,
本頁那一節的說明文字自己先觸發了一次假警報。
但圍籬裡的範例要驗:那是讀者會複製走的東西,範例裡的 id 死掉一樣是壞的,
只是晚一步才被發現。
8. 雙站部署的路徑過濾與它的例外
應用程式與文件站各自獨立部署,用路徑過濾避免互相牽動:
# 應用程式:文件變更不重佈
paths-ignore:
- "docs-site/**"
# 文件站:文件變更才重佈……
paths:
- "docs-site/**"
# ……但元件變更也必須重佈(例外,見下)
- "packages/react/**"
- ".storybook/**"
前半是效率問題(改一個錯字不必重建整個應用程式),後半才是重點:
容易漏掉的例外
文件站內嵌元件與 story,所以元件變更必須觸發文件站重佈。
只設 paths: ["docs-site/**"] 的話,元件改版後文件站不會重建,
裡面嵌的仍是舊版元件——文件與現行介面當場脫鉤,
而這件事沒有任何測試看得見,因為兩邊各自都是綠的。
這就是為什麼「零截圖」的承諾(元件改版,示意圖自動跟上)必須由 CI 設定來兌現。 承諾寫在文件裡,兌現寫在 workflow 裡;只有前者沒有後者,承諾是假的。
還有一個小陷阱:兩個部署寫入的是同一個發佈分支的不同子目錄。 它們要共用同一個 concurrency group,否則同時觸發時會互相覆蓋—— 症狀是「明明部署成功了,但站上還是舊的」。
加分:讓文件站直接吃元件原始碼
resolve: { alias: { "@dooping/react": path.resolve("../packages/react/src") } }
文件裡的範例是真元件,不是複製品、不是截圖。 元件改了、範例當場跟著改——文件不可能過期,因為它根本沒有自己的副本。
判準:什麼時候需要一支新守衛
上面這些看起來各不相干,但判準只有一條:
判準
同一份事實只要存在於兩個地方,就需要一支守衛。
| 兩個地方 | 守衛 |
|---|---|
| 淺色 token ↔ 深色 token | 成對檢查(第 3 支) |
| 元件檔 ↔ barrel | 覆蓋率(第 2 支) |
| token 來源 ↔ 文件站副本 | 副本涵蓋率(第 6 支) |
| 元件庫 ↔ 文件引用的 story id | 掛鉤存在性(第 7 支) |
| 元件庫 ↔ 已部署的文件站 | CI 路徑過濾(第 8 支) |
而最好的守衛,是讓那份事實只存在一個地方—— 能 alias 就不要複製,能自動推導就不要手寫清單。 守衛是次佳解,用在「消不掉的第二份」上。
不要只靠 code review
能寫成測試的規則就寫成測試。人只 review 寫不成測試的部分(命名、文案、取捨)。
不要把「請記得用 token」寫在 wiki 然後期待大家自律。 三個月後那頁 wiki 沒有人打開過。