多語言(i18n)跟深色模式加自訂主題色,這兩件表面功夫都不難,但我各做錯過一次。
這個產品有一條規則我從第一天就定下來:內容永遠不翻譯。
你的知識庫裡是中文就是中文,是英文就是英文,混著也可以。系統不碰它,一個字都不動。這聽起來理所當然,但如果不先講清楚,很容易在某個地方手滑,例如把使用者的標題丟去正規化,或者在搜尋時做語言判斷。
需要語言的只有三個地方:
第三點是我後來想清楚的。agent 用什麼語言寫頁面,由這座知識庫的設定決定,跟介面語言無關。你可能用英文介面但想要中文的知識庫。而且如果使用者在規則頁裡寫「一律用英文撰寫」,那應該蓋過設定。
錯誤訊息也要雙語。我的做法:錯誤物件本身帶兩種語言,到 API 或 MCP 那一層才依當下的語言選一個:
throw new NoteError('FORBIDDEN', {
'zh-TW': `raw/ 為唯讀來源層,不可更新:${path}`,
en: `raw/ is the read-only source layer and cannot be updated: ${path}`,
});
這樣「決定講哪種語言」只發生在最外層一個地方,中間所有函式都不需要知道語言是什麼。
這是我做錯過的地方。使用者的主題偏好有三種:
第三種是預設值,也是最多人的狀態。我第一版只用一個 dark class 切換,跟隨系統的使用者就永遠拿到淺色。
正確的組合是這樣:
:root { --ink: #22313A; --paper: #FDFEFD; /* 淺色的完整定義 */ }
/* 跟隨系統且沒有明確選淺色時,用深色 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --ink: #E4ECE8; --paper: #1D2422; }
}
/* 明確選了深色,蓋過系統 */
:root[data-theme="dark"] { --ink: #E4ECE8; --paper: #1D2422; }
節錄並簡化自 web/src/styles.css:6-37,實際一組是十幾個變數。
三段缺一不可。第一段是完整的淺色定義,後面兩段只重新定義變數。所有顏色都必須先在第一段出現過,只寫在媒體查詢裡的顏色,在「未指定」狀態下會是空的。
主題還要在 HTML 解析的最前面就套用,用一小段內嵌腳本從 localStorage 讀出來塞進 data-theme。不這樣做的話,使用者會先看到一閃的白色再變深色。
做完深色模式之後,加自訂主題色就簡單了,因為顏色已經全部走變數。
我用 OKLCH,它的三個分量是亮度、彩度、色相。好處是改色相不會改變亮度,所以對比度自動維持,換成靛藍或赭紅,文字跟背景的對比不會突然變糟。
:root[data-accent] {
--celadon: oklch(52% 0.085 var(--accent-h));
--celadon-deep: oklch(42% 0.09 var(--accent-h));
--celadon-mist: oklch(94% 0.025 var(--accent-h));
/* 圖表用的六階也從同一個色相重算 */
--ramp-1: oklch(94% 0.025 var(--accent-h)); /* … */ --ramp-6: oklch(42% 0.09 var(--accent-h));
}
我第一次做就漏了這個:三種狀態會在每一層重複出現一次。自訂色相不是只寫上面這一段就好,深色那一組也要重算,所以實際上有三塊:[data-accent]、[data-accent][data-theme="dark"],以及媒體查詢裡的 [data-accent]:not([data-theme="light"])。
而且深色那一組只換色相還不夠,六階的亮度要倒過來。淺色的 --ramp-1 是 94%(最淡),深色的 --ramp-1 是 28%(最深):淺底上要淡就得亮,深底上要淡就得暗。這件事沒有自動化的捷徑,只能兩組各寫一次。
設定頁給六個預設色加一條 0 到 359 的色相拉桿,存在 localStorage。圖譜的 canvas 跟 Mermaid 圖不吃 CSS 變數,所以它們在每次繪製時去讀 computed style,並且監聽主題變更事件重畫。
如果用 HSL 做同一件事,換色相會讓亮度亂跑,藍色跟黃色在同樣的 L 值下看起來差很多,對比度就毀了。這就是選 OKLCH 的理由。
語言跟色相是同一種東西:一個只在最外層決定一次、中間全部不必知道的值。錯誤訊息帶著兩種語言一路走到 API 那一層才選一個,六階顏色全部從一個色相算出來,所以加一種語言或一組配色,都不必回頭改中間任何一支函式。