スマホ 1 台を回して 3〜8 人で遊ぶパーティーゲームのサイト (mawashite.com) を 1 人で作っています。ゲームは執筆時点で 13 本あり、ゲームごとに配色のテーマが変わります——きもちバレ (いまの気持ちを当て合うゲーム) は夕暮れのラベンダー、くるしいわけ (言い訳をでっち上げるゲーム) は温かいオレンジ、という具合です。この配色の切り替えは CSS 変数の上書き 1 層だけで実装していて、テーマのために増えたコンポーネントは 1 つもありません。
ところが、この記事を書くために「色はどこで定義されているか」を数え直したら、定義場所は 1 層ではなく 3 か所に増えていて、13 本中 1 本は色が実際にずれていました。ここから言えるのは、デザイントークンの一元管理が保証するのは、トークンの定義を読める層の中だけだということです。境界の外では色は手で運ばれ、手で運んだ色はいつかずれる。しかも境界がどこにあるかは、思い込みではなく定義場所の列挙でしか確かめられませんでした。
以下、テーマ機構の設計、数え直しで分かったこと、境界の見極め方の順で書きます。扱うのはゲーム (ブランド) 単位で配色セットを丸ごと入れ替えるパターンで、ライト/ダーク切替はこのサイトでは実装していません。ただし属性でトークンを上書きする機構自体は、ダークモードでも同じ形で使えるはずです。
設計: 変えるのは色の値だけ、切り替えは属性 1 つ
最初に決めたのは「何を変えないか」です。角丸の大きさ、影の付き方、モーションの速さはサイト全体の手触りなので、ゲームごとに変えません。ゲームごとに変えるのは色の値だけです。
実装はこの方針の書き下ろしです。まず、ベースのトークンを @theme (Tailwind CSS v4 でデザイントークンを定義するブロック) に書きます。
@theme {
--color-ground: #ddedf0; /* 地 */
--color-ink: #253352; /* 文字・線 */
--color-card: #fffdf7; /* カード面 */
--color-coral: #e8523f; /* アクセント 1 */
--color-denim: #3d63cc; /* アクセント 2 */
--color-mustard: #f2c94c; /* アクセント 3 */
--color-leaf: #3fa47e; /* アクセント 4 */
}
各ゲームのプレイページがビルド時に <body> へ埋め込む属性 (data-game) で、同じ名前のトークンを上書きします。
[data-game='kurushiiwake'] {
--color-ground: #fbe5cd;
--color-ink: #4a2c1a;
--color-card: #fffaf2;
/* 以下、アクセント 4 色 */
}
コンポーネント側は最初から bg-ground text-ink のようにトークン名で書いてあるので、触る必要がありません (Tailwind は @theme のトークンからユーティリティクラスを自動生成するので、--color-ground を定義すれば bg-ground がそのまま使えます)。ゲームを 1 本足したときに増えるのは色の定義 9 行だけで、テーマ用の props も条件分岐もクラス名の切り替えも増えません。
抜け道も 1 本塞いであります。@theme の冒頭に --color-*: initial; を書くと、Tailwind が持つ既定パレット (赤やスレートなど何十色) が全部消え、bg-red-500 のような「トークンにない色を名前で呼ぶ」ユーティリティクラスが効かなくなります。ただし塞げるのは名前つきのクラスだけで、bg-[#e8523f] のような直接指定や、props で hex を渡す経路は素通りします。この「素通りする経路」が、あとで効いてきます。
結果: 色の定義場所は 1 か所ではなく 3 か所あった
「1 層に閉じている」という説明が正しいか、色の定義場所をリポジトリ全体から数え直しました。定義場所は 3 か所ありました。
1 か所目は上の CSS で、ここは設計どおりです。2 か所目は各ゲームの紹介動画でした。紹介ページに置くループ動画を Remotion (React で動画をレンダリングするライブラリ) で生成しており、React 側は CSS を読めないため、同じ色が TypeScript のオブジェクトとして転記してあります。ここには一応の検問があって、ゲームを足したのに転記を忘れるとビルドが起動時に落ちます。ただし見ているのはエントリの有無だけで、色の値が一致しているかは誰も確かめていません。
3 か所目は結果画面の QR コードです。ゲームを囲んで観ていた人がその場で自分のスマホからゲームのページを開けるように、最終結果の画面に小さな QR を置いています。この QR の描画は色を引数で受け取る API なので、各ゲームの文字色が hex の直値でコンポーネントに焼き込まれていました。
冒頭に書いた「1 本のずれ」は、この 3 か所目で起きていました。知ったかグルメ (実在しない料理を知ったかぶりで解説するゲーム) は、姉妹作の 知ったかリレー (実在しないお題をリレーで解説するゲーム) と結果画面の実装を共有しています。
QR の色は共有元に書かれた既定テーマの紺 (#253352) のままで、このゲームのテーマの紺 (#1f3a4d) ではありませんでした。ずれていても QR は問題なく読めるので、指摘してくれる人は現れません。
考察: 境界は「外に出た場所」ではなく「外に出たと思い込んだ場所」で破れる
ずれの原因を追うと、QR は Canvas ではなく DOM に挿さる SVG でした。つまり最初から CSS 変数を読めます。fill に var(--color-ink) と書けば 1 層に収まったのに、「色は引数で渡すもの」という API の形に引きずられて、13 本中 11 本で直値を書いていた。あとから作った 2 本では実行時に getComputedStyle で CSS 変数を読み取って引数に渡していましたが、これも「引数には解決済みの hex を渡すものだ」という同じ思い込みの産物で、遠回りしただけです。
DOM の中にいるのだから var() をそのまま書けばよい、という発想は 13 本を通じて一度も出ませんでした。境界の外に出たから手で運んでいたのではなく、外に出たと思い込んで手で運んでいたわけです。本当に外なのは、CSS を読む手段がない動画だけでした。
ここから引ける一般則は 2 つあると考えています。1 つは、一元管理の保証範囲を決めるのは技術的な境界そのものではなく、書く人がどこを境界だと信じているかだ、ということです。
信念は検査できないので、機構を信じる代わりに定義場所を列挙して確かめます。やることは git grep -niE '#[0-9a-f]{6}' のように hex の直値をリポジトリ全体から拾い、出てきた場所ごとに「トークンを読めるか」「読めないなら値の一致を何が保証するか」を答えるだけで、13 ゲームぶんでも 30 分かかりません。この記事の 3 か所と 1 本のずれは、まさにその列挙で見つかりました。
もう 1 つは、本当に境界の外にある転記 (うちでは動画) には、エントリの有無ではなく値の一致を検査させることです。うちの検問は「転記を忘れていないか」しか見ておらず、「転記が正しいか」を見ていませんでした。手動の grep 監査は発見には十分でも再発は防がないので、恒久策は直値 hex を lint で禁止することですが、これもまだ入れていません。QR の var() 化ともども宿題です。
まとめ
トークン + 属性上書きの 1 層構成は、「ゲームごとに変わるのは色だけ」という制約を受け入れられるなら、増やしても壊れない良い構造です。
ただし「1 層に閉じている」という説明は、定義を読める層の中でだけ正しい。テーマ機構を持つリポジトリなら、保証の棚卸しを一度やってみてください。13 ゲームぶんで 30 分かからず、うちの答えは「定義場所 3 か所・検問 1 つ・ずれ 1 本」でした。実際の配色は mawashite.com のゲームを開くたびに変わるので、現物はそちらで確かめられます。