設計系統建構工具 是做什麼的?
這不是一份泛泛的最佳實踐清單。以下每一條規則之所以存在,都是因為跳過它曾導致真實的、已上線的錯誤——而且大多數是無聲錯誤(沒有錯誤訊息、建置未失敗,只是在瀏覽器中顯示錯誤)。references/bug-catalog.md 詳細記錄了每一條規則對應的具體事件。在開始任何規模夠大的系統之前,請先閱讀該文件一次。
核心主線:一條僅存在於註解或文件中的規則,最終一定會被意外違反,而且違反時不會產生錯誤。 每個階段結束時,都必須問「這要如何自動執行?」——如果答案是「有人會記得」,那這個階段就還沒完成。
兩個進入點:
- 建立新系統 → 依序執行 Phase 1 到 7。
- 稽核或演進既有系統 → 從 Phase 8 開始,再依需求參考 Phase 1-7。
Phase 1 — 探索
在動手前先提問;在此階段猜測會產出一個沒人想要的系統,等到問題顯現時,要回頭修正已成本高昂。
- 參考資料要提供具體範例,而非形容詞。 「現代且乾淨」沒有任何具體意涵;三張螢幕截圖才有。
- 顏色是否承載意義,還是純粹美學? 如果調色板具有分類值(元素、狀態、品牌線),請預先決定每一個值是否同時也是一種行為(動態、形狀、重量),還是純粹視覺。事後為一個只為了好看而選的顏色賦予意義,遠比最初就決定好要困難得多。
- 內容是放在什麼表面上? 不要假設頁面背景和面板背景「差不多夠了」。在確定之前,先測量候選組合——請參考 Phase 2.3。
- 範圍:新建、精煉既有系統,或是與先前的/同系列的系統比較?如果不是全新的建置,則轉向 Phase 8。
- 分發方式:單一專案,還是跨多個專案重複使用?這個問題的答案直接決定 Phase 6(封裝)是否適用。答錯的話,你不是過度設計一個一次性專案,就是低度設計一個即將散佈到三個儲存庫的系統。
Phase 2 — 作者記號
按優先順序列出——搞錯這些,所有建構在其上的東西都會繼承錯誤的基礎。
2.1 — 顏色只撰寫一次,作為原始色通道三原色,絕不使用純粹的十六進位碼。
--fire-rgb: 170 0 0; /* 唯一撰寫的值 */
--fire: rgb(var(--fire-rgb)); /* 衍生 */
--fire-soft: rgb(var(--fire-rgb) / 0.18); /* 衍生 */
這不是風格問題。Tailwind(以及大多數工具系統的)透明度修飾符(bg-fire/40)無法將 Alpha 通道合成到不透明的 var(--x) 十六進位值上——這個類別會無聲地解析為無效,沒有錯誤,沒有警告。這個確切的錯誤正是這個模式所來自的強化過程的起點。每種顏色只撰寫一個數值,也表示永遠只有一個地方需要修改它。
2.2 — 每一種可以作為文字顏色的顏色都必須有 -text 變體,且字形使用此變體,而非基礎顏色。
透過計算 WCAG 相對亮度(公式在 assets/check-tokens.template.mjs 中)來驗證對比度,而非用肉眼看。一種在某個表面上通過 4.5:1 的色調,可能在另一個表面上失敗——這正是 2.3 重要的原因。
2.3 — 如果存在多個表面層級(頁面/面板/凹陷層),請將其定義為真實的高度縮放,並在確定之前測量各層級之間的分隔程度。 「稍微亮一點」不是一個決定,而是一個猜測。一個真實的系統在發佈後,反轉了整個高度模型(墨水當作頁面 → 虛空當作頁面),只因為有人終於計算了實際的合成對比度:一種方向是 1.04:1,另一種是 1.19:1。在基於這個選擇進行建構之前,就先做好這個計算,而不是等到利害關係人說「卡片看起來很平」之後。
一旦有了縮放,每個文字安全的記號都需要與它可能出現的每個層級進行對比度檢查,而不僅僅是它為其設計的那個層級。
2.4 — 動態:一個緩動曲線,一組小的固定持續時間。
如果任何 JS 動畫(framer-motion、GSAP、手動的 requestAnimationFrame)會用到這些值,請將曲線和持續時間作為 JS 常數與 CSS 常數一同匯出——CSS 變數對 JavaScript 是不可見的,而手動輸入的貝茲曲線或持續時間會立即且無聲地偏移。保持 CSS 和 JS 版本互相檢查(Phase 4)。
2.5 — 任何類似發光/漸層效果的數值上限必須是真實數字,並根據實際發送的值進行檢查——包括漸層停止點,而不僅僅是單一純色透明度。「微妙的發光」如果沒有強制上限,就會導致全頁環境光線悄悄回來,一個元件一個元件地,每個單獨看起來都還算合理。
2.6 — 每個記號都必須有一行註解說明何時應該使用它,而不僅僅是它渲染成什麼樣子。
Phase 3 — 建構基礎元件
- 元件只參考記號。不能使用十六進位碼、任意值或
white/10這類字面值——這些正是 Phase 4 的守衛會檢查的,現在寫它們只是為了之後通不過檢查。 - 任何使用透明度修飾符(
bg-x/10)的操作都必須針對根據 2.1 規則可組合 Alpha 的記號——這是可檢查的,所以請檢查它,不要用肉眼看。 - 如果一個屬性打算覆蓋它,就不要將變體硬編碼到元件的基礎類別中。
className="shadow-lg"內建在每個卡片中,同時有一個屬性打算換成shadow-root-earth,會產生兩個具有相同特異性的 box-shadow 工具——哪個生效由產生的樣式表中的來源順序決定,而且是無聲的。請將變體製作成一個以屬性為鍵的查詢表(Record<Variant, string>),而不是基礎類別加上預期的覆蓋。 - 樣式屬性並不能替代真實的互動元素。 一個卡片透過
interactive屬性看起來可點擊(cursor-pointer、懸浮提起),但它是一個<div>,這給了滑鼠使用者一個鍵盤使用者永遠無法獲得的提示——沒有焦點環、沒有角色、沒有啟動。如果某個東西應該被點擊,請渲染一個<a>或<button>(一個CardLink/CardButton雙胞胎),而不是在被動版本上加上樣式切換。 - 對於真正複雜的互動元件(模態框、分頁、提示訊息),請依賴平台。 原生
<dialog>內建了焦點陷阱和按 Escape 關閉的功能;手動實作通常會搞錯其中一個。分頁的 Roving tabindex 是手動分頁實作最常搞錯的地方(每個未啟動的分頁仍然是一個 Tab 停靠點)——請明確驗證它(tabindex 在方向鍵導航時從[0,-1,-1,-1]移動到[-1,0,-1,-1]),不要假設。 - 如果系統需要自訂圖示/標記資產,請預先確定繪製規則(網格、筆畫粗細、端點樣式),並像顏色規則一樣強制執行——這也是 Phase 4 的工作。
Phase 4 — 守衛它
撰寫一個小的、最好是零依賴的檢查腳本。這是整個流程中槓桿率最高的產出物——references/bug-catalog.md 中的每個錯誤,都是一條只以文字形式存在的規則,直到有人將它編碼成腳本。
最低覆蓋範圍(改編 assets/check-tokens.template.mjs):
- 元件或預設集參考的每個記號都確實存在。
- 元件原始碼中沒有手動輸入的十六進位碼或字面透明度顏色。
- 每個文字安全的記號與它可能出現的每個表面層級都符合 WCAG 對比度(2.3)。
- 文件記錄的數值上限,作為數字在實際發送的值上強制執行——包括漸層最亮的部分,而不僅僅是簡單的顏色(2.5)。
- JS 動態常數與其 CSS 記號對應項相符(2.4)。
- 結構性規則,這些規則很容易被意外違反——例如,「沒有全頁背景發光」:檢查發光工具是否從未應用於頁面層級的元素,而不僅僅是記號的 Alpha 值有上限。
- 任何可能與一般文字(例如關於「第二個緩動曲線」的規則)相符的規則,都必須限定在實際程式碼上下文中——類別屬性、CSS 值——而不是任意文字。一個標記你自己文件的 linter 會被禁用,這就失去了意義。
檢查最終必須在消費端專案上執行,而不僅僅是系統本身的儲存庫。 這是這裡可以學到的最大教訓:在一個真實的系統中,所有曾經發生過的規則違反——11 個小於 24px 的字型大小、一個網站上三個不同的 <h2> 大小、五個競爭的字距值、一個第二個緩動曲線、發明的持續時間——都發生在消費該系統的專案中,而內部檢查從來沒有看過那裡。很長一段時間,它只是在守衛從未違反過規則的程式碼。請將檢查器設計成可以對任意來源執行(npx your-check ./src),並為真正的例外提供一個有範圍的逃生門(一個 your-check-allow: <rule> — <reason> 註解慣例),而不是讓人們禁用整個檢查。
在信任任何檢查之前,先進行負面測試。 在一個暫存副本中故意違反每條規則,並確認腳本實際上會失敗。一個從未被觀察到失敗的檢查是未經驗證的——你不知道它是否有效,還是默默地無效。
Phase 5 — 文件
三個檔案,按對象劃分——抵制添加第四個的衝動。第四個文件(一個聚合的「design.md」、第二個作為文件的樣式指南)是相同值偏離的第二個地方;三個各自擁有不同問題(為什麼/做什麼/決定了什麼)的文件就足夠了。
philosophy.md— 為什麼、身份、選擇背後的意義。讀一次,很少重讀。foundations.md— 操作規則。某人在建構新東西之前檢查的內容。應該大致與 Phase 4 的守衛強制執行的內容 1:1 對應——如果一個規則在一個檔案中而不在另一個檔案中,請找出缺少的是哪個並修正它。decisions.md— 一個執行日誌,最新的條目在最前面,記錄具體的判斷及其原因。永遠不要刪除一個被取代的條目。 將其標記為已取代,並解釋改變了什麼以及為什麼。在沒有紙本記錄的情況下推翻一個真實的決定(一個高度模型、一個強調層級),會迫使下一個人——通常是未來的你——從頭重新推導推理過程,或者更糟,無聲地重新破壞原始決定修正的問題。- 一個即時展示,渲染真實的記號和元件——而不是對它們的描述。如果它不能實際上
import套件(例如一個靜態的單一檔案 HTML 文件),那麼它就攜帶了每個值的第二個內置副本,而那個副本將會偏離:一個真實系統的展示在不知不覺中落後於其自己的記號兩個版本,直到有人將其重建為一個實際上匯入實際套件的東西。如果暫時無法避免靜態展示,最快的修正方法是讓 Phase 4 守衛內置副本與真實來源——但計劃在系統穩定到有實際消費者時立即退休。 - 如果展示可以部署,請部署到真實的 URL,並在真實的窄視口中檢視它。 一個只在特定區段比其內容窄時才會出現的版面錯誤(例如一個故意溢出的裝飾元素使整個文件變寬),對所有靜態檢查都是不可見的——不是記號違反、不是對比度問題、不是 lint 錯誤。它只存在於實際瀏覽器和實際寬度中。一個這樣的錯誤曾經發佈並存活了三個版本,因為該系統唯一被檢視的地點是本地檔案。
- 當文件超過一份時,寫下一個版本控制政策以及閱讀文件的順序。只需要一個段落,就能省去某人重新推導它的時間。
Phase 6 — 封裝與分發
僅在 Phase 1 表示「跨專案重複使用」時適用。
- 作為可安裝的套件提供(
package.json包含exports映射)。絕不能用「將這些檔案複製到你的專案中」——複製沒有路徑讓下游修正回流到上游,這正是如何使系統自己的真相來源悄悄地成為兩個副本中較為損壞的那個。這是整個系列中第一個錯誤的根本原因。 - 如果分發 Tailwind 預設集:預設集的
content陣列不會被 Tailwind 合併。 消費者的content陣列會直接取代預設集的。這是目錄中第二昂貴的錯誤,而「明顯」的修正——在預設集中宣告 glob 並假設它們會合併——是行不通的;建置結果會逐位元組相同,這就是徵兆。預設集必須匯出其 glob,而每個入門文件(README、如果存在的 scaffold 命令)都必須明確展示消費者如何將其展開:content: [...preset.content, ...yourGlobs]。使用打包工具的 config-resolution API 驗證這一點,不要假設——一個在「修正」後不變的建置產物是一個錯誤訊號,而不是巧合。 - 使用標籤而非分支進行版本控制。 當來源儲存庫的分支前進時,分支鎖點會在消費者端無聲地移動——這正是消費者如何在沒有人決定這樣做之下,發佈一個未經測試的版本。如果消費者需要移動到一個新標籤,請記住鎖定檔案可以將解析的提交固定在
package.json中的標籤所說的內容之外——強制重新解析,並在信任重新建置之前,驗證node_modules實際報告了新版本。 - 如果套件為新消費者提供 scaffold/init 命令,請讓它從第一天起就發出正確的、展開的內容配置——不要依賴每個消費者閱讀 README 的自律性來處理一個嚴重到足以有自己決策日誌的陷阱。
Phase 7 — 驗證
持續應用,而不是只在最後做一次。
- 針對全新的安裝或複製進行測試,而不是工作目錄。 工作目錄會隱藏遺失的匯出、未提交的修正以及陳舊的快取,這些都是真實消費者在第一天就會遇到的。
- 一個在「修正」後不變的建置產物,是修正無效的訊號,而不是巧合。 在相信任何改變之前,請比較實際的輸出。
- 型別檢查不是驗證。 這個目錄中最嚴重的錯誤——一個字型以完全錯誤的字體渲染——通過了所有可用的靜態檢查,只有在實際消費者中渲染元件並觀察它時才被發現。在完成之前,至少將每個東西在真實環境中渲染一次。
- 對看似方便的驗證聲明平台 API 保持懷疑。
document.fonts.check()看起來像是「這個字型是否實際上包含這個字形」的正確工具,但它會說謊(它報告可用的字型,即使你要求的變體不存在),所以不要相信它來驗證字型本身。唯一的可靠方法是實際渲染文字並比較 glyphmetric(例如使用 canvasmeasureText),或者使用更粗的粒度:確認載入了正確的檔案(使用@font-face src檢查或開發者工具網路請求)。
Phase 8 — 稽核與演進
從這裡開始,而不是 Phase 1,如果系統已經存在。
- 不要一開始就重新製作任何東西。 既有系統有慣性——團隊習慣、文件、現有程式碼。改變的門檻高於建立新的。
- 從訓練資料開始: 收集實際消費者的輸出、已發佈的版本、收到的錯誤報告。那裡的模式決定了為什麼這個系統感覺需要演進,而不僅僅是我們希望它不同。
- Phase 2-6 的應有狀態是目標,而不是起點。 你將透過以下方式到達那裡:
- 反向工程既有記號成爲符合 Phase 2 規範的原始值(最可能:從十六進位碼→rgb→用 Phase 4 檢查的衍生記號)。
- 尋找每個記號被實際上用在哪裡,而不僅僅是設計文件說它在哪裡。在一個真實的系統中,設計記號從未被使用過,但
--primary被使用了七次,因為沒有人移除它——而一個 linter 從未檢查過消費端。 - 在消費端專案上執行 Phase 4 的檢查器以找到未強制執行的規則,並嘗試分級:哪些違反是純粹風格(「第二個緩動曲線」)、哪些是錯誤(「文字在頁面背景和卡片背景上讀取」)。
- 輸出不是「我們做了 Phase 2-6」。 輸出是明確的決定:保留、淘汰、重構、或封存。每個決定都需要支援證據(來自 Phase 8 步驟 1 訓練資料的錯誤報告計數、審查時發現的實例、Phase 4 檢查器產生的違反計數)。
- 如果 Phase 8 導致 Phase 2-6 的短期工作,請將那些工作導回正確的 Phase——它們在那裡被追蹤,而不是作為稽核階段的子集。稽核是找到差距;演進是執行 Phase 1-7 的循環,以特定的、受限的範圍。
- 最後,寫下一個關於什麼標準會觸發再次稽核的政策。無條件、定期稽核會被忽略;有條件稽核(「當 bug 報告超過 X 時」、「當新專案加入時」)會被執行,因為條件是可明確觀察的,而不是關於時間的。