Token 契約:模組 × 風格為什麼能自由組合
整套系統只靠一條規則成立 —— 模組只能讀 --ds-* 變數,風格只能寫 --ds-* 變數。這份文件是完整的變數清單與使用規則。
一句話
模組不知道自己被套上了哪一個風格。
模組只讀 --ds-* 變數,風格檔只寫 --ds-* 變數,兩邊都不認識對方。所以「33 個模組 × 6 種風格 = 198 種合法組合」不需要任何額外工作,它是架構的必然結果,不是我們一個一個試出來的。
三條規則
- 模組可以讀
--ds-*,永遠不可以寫死顏色、字體、陰影、圓角、間距。 - 風格檔只可以寫
--ds-*,永遠不可以出現針對模組內部的選擇器。 - 站台本身的外框用
--ui-*,跟--ds-*完全隔離 —— 否則你會分不清哪裡是預覽、哪裡是網站。
違反第 1 條,換風格時那個地方會留在原地。違反第 2 條,風格就不再是一份資料,而是一坨要維護的 CSS。
檔案順序
contract.css 變數定義 + 預設值 + reset + 版面 primitives
components.css 元件層(按鈕、輸入框、卡片…),只用 contract 的變數
tokens.css 某一個風格的實際數值 ← 換風格只換這個檔
三個檔案照這個順序 import。
一個必須知道的陷阱:specificity
contract.css 的預設值寫成這樣:
:where(:root, .ds-scope) {
--ds-accent: #4f46e5;
/* … */
}
:where() 不是裝飾,是關鍵。它讓整段預設值的權重歸零,所以 [data-style='neon-ink'] 一定贏,跟瀏覽器先載入哪個檔案無關。
如果寫成 :root, .ds-scope { ... },權重是 (0,1,0),跟 [data-style='x'] 打平,接著比載入順序 —— 於是每個風格都靜靜地失效了。這是可切換主題的 CSS 最常見的 bug,而且很難察覺,因為畫面「看起來有東西」。
同樣的道理:[data-radius='soft'](預設值)不應該寫任何東西。預設代表「不要干涉」,不是「設成中間值」。寫了的話,Brutal Mono 的 0 圓角和 Warm Clay 的 1.5 倍圓角都會被壓回 1。
變數清單
表面 Surfaces
| 變數 | 用途 |
|---|---|
--ds-canvas |
頁面底色 |
--ds-canvas-2 |
交替區塊的底色 |
--ds-surface |
卡片、面板 |
--ds-surface-2 |
卡片內的次層、輸入框底 |
--ds-surface-inverse |
深色面板(淺色主題)/淺色面板(深色主題) |
--ds-overlay |
彈窗的遮罩 |
文字 Ink
| 變數 | 用途 |
|---|---|
--ds-ink |
主要文字 |
--ds-ink-2 |
說明、次要文字 |
--ds-ink-3 |
停用、佔位、非必要資訊 |
--ds-ink-inverse |
深色面板上的文字 |
強調 Accent(最容易做錯的一組)
| 變數 | 用途 |
|---|---|
--ds-accent |
底色、圖形、進度條。不可以當文字色 |
--ds-accent-2 |
hover / pressed |
--ds-accent-soft |
淡色底(badge、選取列) |
--ds-accent-ink |
accent 當文字時的唯一合法形式 |
--ds-on-accent |
放在 accent 底色上面的文字 |
為什麼要分 accent 和 accent-ink:#39FF14 放在紙色底上的對比是 1.3:1,完全讀不到。所以 Neon Ink 的 --ds-accent-ink 是 #1E7A0C。任何品牌色只要夠亮,都會有這個問題。
在任何風格的設計系統頁上都有一張對比表,其中一行是「強調色當文字」,故意留著讓你看見它不合格。
語意 Semantic
--ds-success / --ds-warning / --ds-danger / --ds-info,各自另有:
-soft:淡色底--ds-on-*:放在實心底色上的文字色
第三個最常被忘記。深色主題的語意色通常是亮的(#34D399、#FBBF24),白字放上去完全看不見 —— 所以它必須是 token,不能是元件裡寫死的 #fff。
另外還有 --ds-danger-on-inverse:深色面板上的小型錯誤標籤用,因為實心的 danger 色在深底上通常太暗。
Tooltip、捲軸、游標
--ds-tooltip-bg|ink|radius|shadow|size|gap|delay|arrow
--ds-scrollbar-size|track|thumb|thumb-hover|radius|border
--ds-cursor-default|pointer
游標只開放這兩個,其餘(text、not-allowed、progress…)一律用系統原生 —— 那是少數使用者真的依賴辨識的東西。詳見 micro-details。
線條與形狀
--ds-line(髮絲線)、--ds-line-2(明顯分隔)、--ds-border-width
--ds-radius-xs|sm|md|lg|xl|pill,全部乘上 --ds-radius-scale
字體
--ds-font-display / --ds-font-body / --ds-font-mono
三個都必須把 --ds-font-cjk 接在後面,否則中文會 fallback 到隨機的襯線字。
字級是 --ds-step--1 到 --ds-step-6,全部用 clamp(),所以不需要 media query。
間距
--ds-space-1 到 --ds-space-12,全部乘上 --ds-density。
--ds-container、--ds-container-narrow、--ds-section-y、--ds-gutter。
動態
--ds-dur-1|2|3、--ds-ease、--ds-ease-out、--ds-ease-spring。
系統設定「減少動態效果」時全部歸零,這一段寫在 contract.css,不需要每個元件自己處理。
特徵層 Signature
這一組讓風格能有個性,而模組不需要 if:
--ds-texture(紙紋、光暈)、--ds-panel-bg、--ds-panel-dots、--ds-glass-bg、--ds-glass-backdrop、--ds-glass-shadow
沒有紋理的風格就把 --ds-texture 留成 none,畫出來是平的,模組完全不用知道。
元件掛鉤 Component hooks
風格想改按鈕形狀,不是去寫 .ds-btn { },而是設 --ds-btn-radius、--ds-btn-shadow、--ds-btn-transform⋯。這樣「風格只寫變數」這條規則才守得住。
Brutal Mono 就是這樣得到硬陰影的:
--ds-btn-shadow: 3px 3px 0 0 #000000;
--ds-btn-shadow-hover: 5px 5px 0 0 #000000;
--ds-btn-translate-hover: 2px;
--ds-btn-transform: uppercase;
一行元件程式碼都沒有改。
全域旋鈕
設在 <html>(整站)或任何 .ds-scope(單一預覽)上:
<html data-style="neon-ink" data-density="cozy" data-radius="soft" data-motion="full">
data-density: compact / cozy / spaciousdata-radius: sharp / soft / rounddata-motion: none / calm / full
怎麼檢查有沒有守規則
# 模組裡不該有十六進位色碼
grep -rn "#[0-9a-fA-F]\{3,8\}" src/lib/modules/
# 也不該有裸的 px 間距(除了 1px 邊框與小數點微調)
grep -rnE "(margin|padding|gap): *[0-9]+px" src/lib/modules/
有輸出就是有人破壞了契約。把這兩行放進 CI,比任何 code review 都可靠。