跳到主要內容

Token 契約:模組 × 風格為什麼能自由組合

整套系統只靠一條規則成立 —— 模組只能讀 --ds-* 變數,風格只能寫 --ds-* 變數。這份文件是完整的變數清單與使用規則。

約 7 分鐘 · token-contract.md

一句話

模組不知道自己被套上了哪一個風格。

模組只讀 --ds-* 變數,風格檔只寫 --ds-* 變數,兩邊都不認識對方。所以「33 個模組 × 6 種風格 = 198 種合法組合」不需要任何額外工作,它是架構的必然結果,不是我們一個一個試出來的。

三條規則

  1. 模組可以讀 --ds-*,永遠不可以寫死顏色、字體、陰影、圓角、間距。
  2. 風格檔只可以寫 --ds-*,永遠不可以出現針對模組內部的選擇器。
  3. 站台本身的外框用 --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 底色上面的文字

為什麼要分 accentaccent-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 / spacious
  • data-radius: sharp / soft / round
  • data-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 都可靠。

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言