zudo-css-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

Design Token Lint

デザイントークンシステムをビルド時に強制する ―― 生のカラーリテラル、生の z-index 整数、ゾーン認識違反を検出し、エスケープハッチも明文化する。

問題

メソドロジー記事は どんなトークンを定義するか は語りますが、ビルド時にどう強制するか には踏み込みません。強制がなければトークンシステムは腐っていきます。

  • 生の #0066ff リテラルがコンポーネント CSS に紛れ込み、コードレビューをすり抜けます。

  • z-index: 99 のショートカットが tier system を回避します。一度すり抜けたら、次の貢献者は 99 を見て 100 を書きます。

  • コードベースが大きくなるにつれて、ドキュメントと CSS の間でトークン名がずれていきます。デザインシステムページに記載された --shadow-modal は、実際の 3 つのコンポーネントで使われている --modal-shadow ともはや一致しません。

  • 「コンポーネントで生の oklch を使わない約束」がコードレビューで保たれるのは半年程度。その後は二人の頭の中だけにしか生き残らず、その二人とも今日の PR をレビューしてはいません。

強制されないトークンシステムは、コードレビューの祈りであって保証ではありません。AI 補助によるリファクタリングは回転をさらに早めます ―― トークンの移動は速く、貢献者の入れ替わりは多く、「みんな知っている」ルールは新しいエージェントと貢献者ごとに再学習されます。

解決方法

pre-push と CI のタイミングで マルチパス linter を走らせ、コンポーネントコードと CSS をスキャンして既知のアンチパターンを検出します。各パスは異なるルールを強制します。デザインシステムが成長すれば新しいルールを追加できます。

最小構成のパターンは 3 パスです。

Pass対象禁止許可
Pass 1コンポーネントの class リスト / CSS 値生のカラーリテラル(#rrggbboklch(...)rgb(...)、Tailwind デフォルト色)セマンティックトークン(bg-surfacetext-fg)、エスケープハッチ内の任意値
Pass 2ゾーン定義ブロック(:root@theme[data-theme="..."]リテラルを直接埋め込んだセマンティック層トークンvar() / color-mix() 経由でパレットトークンを参照するセマンティック層トークン
Pass 3コンポーネント CSS生の z-index: <integer>var(--z-*)calc(... var(--z-*) ...)、キーワード(autoinheritinitial

Pass 1 が最も一般的です ―― @takazudo/zudo-design-token-lint が今日 Tailwind class 名向けに出荷しているのもこれです。Pass 2 と Pass 3 は同じパターンから自然に派生する概念的拡張です。

Pass 3 の状況

Pass 3(生 z-index 整数の禁止)は 計画段階 であり、まだ出荷されていません。Z-Index Strategy のティアシステムと一緒に投入される予定です。アップストリームの状況は zudo-pattern-gen#942 で追跡してください。契約を明確にするためルールはいま文書化し、実装はアップストリームの準備が整ったタイミングで投入します。

Pass 1: コンポーネントコードでの生値の禁止

最もシンプルなパスです。ユーザーに見える UI を生むすべてのファイルをスキャンし、本来トークンであるべきリテラルを検出します。

/* component.css — caught by Pass 1 */
.alert {
  background: #ffe4e4;          /* raw hex literal */
  color: oklch(45% 0.18 27);    /* raw oklch literal */
  padding: 12px 16px;           /* raw spacing literal */
}
/* component.css — passes Pass 1 */
.alert {
  background: var(--color-alert-bg);
  color: var(--color-alert-fg);
  padding: var(--space-sm) var(--space-md);
}

Tailwind class ベースのプロジェクトでは、同じルールが数値ユーティリティとデフォルト色にも適用されます。

// Caught by Pass 1
<div className="p-4 bg-gray-500 text-blue-600">
// Passes Pass 1
<div className="p-hgap-sm bg-surface text-fg">

要点は構文そのものではありません ―― コンポーネントコード内のあらゆる生リテラルは既定で違反である、という点です。違反はエスケープハッチで免除できますが、無言で済ませることはできません。

Before / After: Raw Literal vs Token

Tailwind ユーティリティにおける許可/禁止の正確な境界線

「裸の数値ユーティリティ禁止」というルールは、過剰に解釈されがちです。あるプロジェクトでは、この lint ポリシーを実装した AI エージェントがルールを「数字を含むものはすべて禁止」と読み取ってしまい、ルールが本来捕まえようとしていた自由度の高い数値スケールのユーティリティと一緒に、Tailwind の フラクション ユーティリティ(w-1/2w-1/3)までフラグしてしまいました ―― 人間がミスに気づくまで、コードベース内のすべての比例幅レイアウトが壊れていました。フラクションは比率を表すものであり、このルールが対象とする自由度の高い数値スケールには含まれません。

デフォルトの数値 スケール とデフォルトのカラー パレット を禁止し、明示的なもの(トークン、任意値 [...]、フラクション、キーワード)はすべて許可します。

カテゴリ判定
数値スペーシングスケール禁止p-4, m-8, gap-6, inset-2, ...
数値サイジングスケール禁止w-24, h-3, size-2, min-w-4, max-w-8, ...
デフォルトカラーパレット禁止bg-gray-500, text-blue-600, border-red-400, ...
セマンティックトークン許可px-hsp-sm, py-vsp-md, bg-surface, text-ink
任意値(単発かつコンポーネント固有の値のためのエスケープハッチ)許可w-[30px], p-[6px], grid-cols-[120px_1fr], bg-[hsl(...)]
フラクション(比例的 ―― 自由度の高い数値スケールではない)許可w-1/2, w-1/3, basis-2/3
キーワード許可w-full, h-screen, max-w-prose, min-w-0
ゼロ許可p-0, w-0

数値サイジングスケールはポリシー上禁止されていますが、現在のリファレンス実装ではそのどれも安全に強制できません ―― 詳細は後述の「リファレンス実装」セクションのツール面のギャップに関する注記を参照してください。

Pass 2: セマンティックトークンのゾーン認識

Pass 1 だけでは大雑把すぎます。ある種のファイルは 必ず 生リテラルを含む必要があるからです ―― それこそが :root ブロックの本来の目的です。

:root {
  /* Palette: literal oklch is correct here. */
  --p0: oklch(98% 0 0);
  --p15: oklch(15% 0 0);
}

Pass 1 が :root 内のあらゆるリテラルをフラグするなら、パレット層は存在できなくなります。

解決策は ゾーン認識 です。:root@theme[data-theme="..."]トークン定義ゾーン として扱います。ゾーン内では、パレット層トークン(--p0--p15)はリテラルを埋め込めます。同じゾーンで定義されるセマンティック層トークンは、依然として var(...) / color-mix(...) を介してパレットトークンを参照する必要があります。リテラルを直接埋め込むことはできません。

:root {
  /* Pass 1 allows literals in this zone. */
  --p0:  oklch(98% 0 0);
  --p15: oklch(15% 0 0);

  /* Pass 2 catches this. The token name --shadow-modal */
  /* is semantic, but it embeds a raw oklch literal.    */
  --shadow-modal: 0 12px 32px oklch(15% 0 0 / 0.4);
}
:root {
  --p0:  oklch(98% 0 0);
  --p15: oklch(15% 0 0);

  /* Passes Pass 2 — the semantic shadow references the palette. */
  --shadow-modal: 0 12px 32px color-mix(in oklch, var(--p15), transparent 60%);
}

この区別こそが、定義しているのがセマンティックトークンであるとき「:root 内の生 oklch」を依然として lint 失敗にする理由です。Pass 2 がなければ :root ブロックは抜け道になります ―― あらゆるリテラルが、あらゆる名前の裏に隠れることができてしまいます。

linter はどうやって違いを知るのか。命名規約によってです。パレットトークンは予約済みのプレフィックスを使います(--p0--p15--p-blue-500、プロジェクトが選んだもの)。トークンゾーン内で定義された他のものはセマンティックとして扱われ、パレットを参照しなければなりません。規約は明示的かつ文書化されている必要があります ―― linter は推測しません。

Convention before enforcement

Pass 2 はパレットトークンに対する明確な命名規約があるときにのみ機能します。デザインシステムページで一度文書化し、その後 linter の設定にエンコードします。規約が曖昧なら lint ルールも曖昧になります。

Pass 3: 生 z-index 整数の禁止

コンポーネント CSS の生 z-index: <integer> は、Z-Index Strategy のティアシステムを掘り崩します。Pass 3 はそれを禁止します。

/* component.css — caught by Pass 3 */
.modal {
  position: fixed;
  z-index: 100;
}

.toast {
  position: fixed;
  z-index: 9999;
}
/* component.css — passes Pass 3 */
.modal {
  position: fixed;
  z-index: var(--z-modal);
}

.toast {
  position: fixed;
  z-index: var(--z-toast);
}

許可される形式:

  • z-index: var(--z-modal); ―― トークンの直接参照

  • z-index: calc(var(--z-modal) + 1); ―― トークンから派生する calc

  • z-index: auto; / inherit; / initial; ―― キーワード値

  • 文書化されたエスケープハッチを伴う生整数(次節)

禁止される形式: 裸の整数。例外なし、エスケープハッチなしの z-index: 100; はコンポーネント CSS で Pass 3 失敗です。

姉妹記事 Z-Index Strategy は Pass 3 が強制するティアトークンシステムを文書化しています。Pass 3 の状況: zudo-pattern-gen#942 で計画中。

エスケープハッチ

正当な違反というのも存在します ―― 単発のサードパーティウィジェット統合、デバッグ色、実験的レイヤーなど。既定でブロックしつつ、永久にブロックしないこと。エスケープハッチを文書化し、コメントを必須にします。

@takazudo/zudo-design-token-lint が公開している正典の構文は次の通りです。

{/* design-token-lint-ignore */}
<div className="p-4 bg-gray-500">
/* design-token-lint-ignore */
.legacy-widget {
  background: #0066ff;
}
// design-token-lint-ignore
const className = `p-4 bg-${shade}-500`;

エスケープハッチは 行レベル のコメントが 1 つで、その次のコード行の違反を抑制します。プロジェクトが遭遇する 3 つのコメント構文(JSX、CSS、JS/TS の行コメント)に対応した 3 形態が存在します。これらは同じルールのエイリアスです。

注意すべきアンチパターンが 2 つあります。

  • コメントなしのエスケープハッチ。 /* design-token-lint-ignore */ 単体は不吉な兆候です。ルールは「既定でブロック、文書化された理由とともに許可」です。理由がなければ次の読み手は例外がまだ妥当かどうかを判断できません。

  • ファイルレベルのエスケープハッチ。 ファイル全体の ignore はほぼ常に間違いです ―― そのファイルに今後追加されるあらゆる生リテラルを暗黙に免除してしまうからです。本当にファイル全体をオプトアウトする必要があるなら、.design-token-lint.jsonignore glob のほうが正直です。プロジェクトの lint 設定に現れるので、CSS コメント内に隠れません。

/* Legacy: this colour matches the old brand asset PNG that finance still uses. */
/* Tracked at #1234 — remove once the asset is regenerated.                     */
/* design-token-lint-ignore */
.invoice-banner {
  background: #0066ff;
}

lint 設定はプロジェクト全体のルールに対して allowedignoreprohibited フィールドもサポートしています。安定的な例外にはこれらを使い、行レベルのコメントは個別の理由を運ぶ役割に使います。

ラチェット許可リストによる移行パターン

ここまでの内容は、すでにクリーンな、あるいはそれに近いコードベースを前提としています。成熟したコードベース ―― 実際の採用のほとんどはここから始まります ―― で厳格なルールを有効にしようとすると、素朴な 2 つの形はどちらも失敗します。「まず全部直す」は永遠に着地せず、「警告のみ」は永遠に無視されます。うまくいくパターンは 領域ごとのラチェット です。

  1. ルールと現状の違反すべてのベースラインを同時に投入し、初日からグリーンにする。 既存の違反はすべて、ファイル → 正確なクラス文字列をキーとする許可リストに入れます。CI は最初のコミットから「新規の違反ゼロ」を強制し、その裏でバックログは少しずつ返済していきます。

  2. ロールアウト領域ごとに許可リストファイルを 1 つ。中央集約の 1 ファイルにはしません。 各領域の移行は自分のファイルだけを縮めるので、並行するブランチが同じ行を書くことはありません。中央集約の許可リストはマージコンフリクトの磁石であり、本来並行できる移行を直列化させます。

  3. 正しい道はエントリ単位ではなくパターンで自動承認する。 ある抜け道の形が 常に 正当なら、それはルール自体にエンコードします。var(--display-scale)var(--spacing-*) を含むブラケット値 ―― 加えて env()/vh/vw/% のビューポート計算と 1px の構造的ヘアライン、それらを min()/max() で包んだものも含めて ―― は許可リストの行なしで通過します。正しい抜け道のすべてにエントリが必要だとしたら、許可リストはコードベースが改善するのとまったく同じ速度で成長してしまいます。

  4. 統合して固定する。 ロールアウトが終わったら、わずかな生き残りを文書化された恒久例外ファイル 1 つにまとめ、ルールをハードエラーに切り替えます。

これは、行レベルのコメントと設定の glob に並ぶ 3 つ目のエスケープハッチの形 ―― パターンによる受け入れ ―― を導入します。行コメントは例外のためのもの、パターンは契約のためのものです。正当と認められた形なのに使用箇所ごとに注釈を要求されるなら、それは契約ではありません ―― 人々に ignore コメントへ手を伸ばすことを教える摩擦です。

およそ 3,000 箇所のスペーシング使用を対象としたある移行での実測: 5 つの領域ファイルにまたがる 31 件のベースラインエントリ → 恒久的な生き残りは 2 件(どちらも mx-[0.25em]、原理的に例外となる em ベースのインライン微調整)、1,042 ファイルで lint はクリーン、6 つの並行 worktree にまたがって許可リストのマージコンフリクトはゼロ。周辺のアーキテクチャについては 高密度なデスクトップクラス Web アプリのスペーシングアーキテクチャ を参照してください。

リファレンス実装

@takazudo/zudo-design-token-lint が動作するリファレンス実装です。現時点では Tailwind class 名向けの Pass 1 を出荷しています。

  • 数値スペーシングユーティリティ(p-4m-8gap-6mt-16space-x-4inset-2 ...)を禁止 ―― ただし、同梱の inset のカバレッジは後述のフラクション衝突の影響を受けるため(inset-1/2 は正当なフラクションです)、v1.0.0 の設定では inset-{n} を裸の禁止リストから外しておくべきです(Warning を参照)

  • 数値サイジングユーティリティ(w-{n}h-{n}size-{n}min-w-{n}max-w-{n}min-h-{n}max-h-{n}basis-{n})はまだ一切禁止していない ―― 理由は後述のツール面のギャップに関する注記を参照してください

  • Tailwind デフォルト色(bg-gray-500text-blue-600border-red-300ring-indigo-500 ...)を禁止

  • セマンティックトークン(bg-surfacetext-fgp-hgap-sm)、任意値(w-[28px])、ゼロ値(p-0)、フラクション(w-1/2)、デフォルトでない色名はすべて許可

  • 静的解析ベース: className=class=cn(...)clsx(...)classNames(...)twMerge(...)、Astro の class:list 式をスキャン

フラクションと不透明度修飾子の衝突(v1.0.0)

@takazudo/zudo-design-token-lint v1.0.0 は、class を禁止ルールと照合する前に / 以降をすべて Tailwind の不透明度修飾子として取り除きます。この正規化によって w-1/2w-1 になり、w-1w-{n} の禁止ルールにマッチしてしまいます ―― 正当なフラクションが、まるで自由度の高い数値スケールのユーティリティ w-1 であるかのようにフラグされるのです。これは、上で紹介した事例の失敗パターンが、ツールのレベルでそのまま再現されたものです。

Tailwind の不透明度修飾子(bg-black/50)は カラー ユーティリティにのみ適用されます。サイジングユーティリティには存在しません ―― whsizemin-wmax-wmin-hmax-hbasis の末尾の /N は常にフラクションであり、不透明度の値になることはありません。Tailwind v4 ではこれらすべてのプレフィックスがフラクション構文を受け付けます ―― size-1/2min-w-1/3max-h-2/3 はすべて実在する正当なユーティリティであり、w-1/2 / h-1/3 / basis-2/3 だけの話ではありません。裸の {prefix}-{n} 禁止ルールは、w / h / basis だけでなく、これらすべてで同じ誤検知のリスクを抱えています。修正の方向性としては、ベースのユーティリティがカラーユーティリティである場合にのみ、linter が末尾の /N を不透明度修飾子として取り除くようにすることです。サイジングユーティリティであれば 1/2 が裸の数値パターンにマッチすることはなくなり、あらゆるフラクションが正しく通過するようになります。

それがアップストリームに反映されるまでは、数値サイジングスケールはこれらのどのプレフィックスに対しても裸の {prefix}-{n} ルールで安全に禁止することはできません ―― w-{n}h-{n}size-{n}min-w-{n}max-w-{n}min-h-{n}max-h-{n}basis-{n} のいずれを禁止しても、現行の v1.0.0 のマッチャーではそのプレフィックス上の正当なフラクションが誤検知されてしまいます。コアの数値スペーシングスケール(p-{n}m-{n}gap-{n}space-x-{n} ...)には Tailwind にフラクション構文が存在しないため、今日でも安全に禁止できます ―― ただし 1 つ例外があります。inset-{n} はスペーシングスケール上にありますがフラクションを受け付けるため(inset-1/2 は正当なポジショニングユーティリティです)、同じ誤検知のリスクを抱えており、サイジングプレフィックスと同様に裸の {prefix}-{n} 禁止リストから外しておく必要があります。

数字始まりのトークン名は素朴な数値禁止ルールを壊す

トークン名が数字で始まるスケール ―― 2xs2xl3xl4xl5xl ―― は、値のパターンを 純粋な数字のみ にアンカーしない限り、{prefix}-{value} の数値禁止ルールと衝突します。

^-?\d+(\.\d+)?$

このアンカーがあれば、p-2 はフラグされ(自由度の高い数値スケールのユーティリティ)、p-2xsgap-3xl は通過します(末尾に文字が続くなら名前付きトークンです)。これがなければ、先頭の数字を見るだけのルールがコードベース中のあらゆる 2xl をフラグしてしまいます。

これはヒューリスティックではなく必須の正規表現条件です ―― 数字始まりの名前を使うスケールに対してルールを走らせた初日に必ず踏みます。xs/xl より先のステップでは、これが Tailwind の一般的な命名規約だからです。

同じパッケージは zudo-pattern-gen packages/design-token-lint でも vendor されています ―― pgen プロジェクトはこれをワークスペースパッケージとして消費しています。これは正当な 3 つの採用戦略のうちの 1 つです。

  1. npm からインストール ―― pnpm add -D @takazudo/zudo-design-token-lint。最もシンプルで、アップストリームのリリースに追従します。

  2. ワークスペースパッケージとして vendor ―― pgen がやっていることです。プロジェクト固有のルールがまだアップストリームに上がっていない場合に便利です。

  3. リファレンスデザインとして使う ―― マルチパスパターンをプロジェクト固有のルールセットと CLI で再実装します。プロジェクトのトークンルールが乖離していて、設定よりフォークのほうがクリーンな場合に便利です。

Pass 2(ゾーン認識セマンティックトークン)と Pass 3(生 z-index 整数)は概念的拡張です。アップストリームがそこまで成長することもあれば、vendor 済みコピーが先に実装することもあります。どちらの経路でもマルチパスの形は同じです ―― スキャンし、分類し、報告し、違反があれば非ゼロで終了します。

エンドツーエンドの実例

違反 2 つと正当なエスケープを 1 つ含むコンポーネント CSS ファイルです。

/* src/components/legacy-toast.css */

.legacy-toast {
  position: fixed;
  z-index: 9999;                  /* Pass 3: raw z-index integer */
  background: #ffaa00;            /* Pass 1: raw hex literal */
  color: var(--color-toast-fg);
}

/* Brand-mandated exact colour for the legacy yellow toast — tracked at #5678. */
/* design-token-lint-ignore */
.legacy-toast--brand {
  background: #ffaa00;
}

linter を実行(出力フォーマットは説明用です ―― 正確なフォーマットは実装依存)。

$ pnpm design-token-lint

src/components/legacy-toast.css
  3:11  error  Raw z-index integer "9999" — use a --z-* token
  4:15  error  Raw color literal "#ffaa00" — use a semantic color token

✖ 2 errors

Suggestions:
  - Replace "z-index: 9999" with "z-index: var(--z-toast)" (see Z-Index Strategy)
  - Replace "#ffaa00" with a semantic token from the design system

エスケープされた .legacy-toast--brand ブロックは出力に現れません ―― 次のルールでは Pass 1 が抑制され、上のコメントが理由を文書化しています。

プロジェクトの pre-push フックに組み込んで、違反が origin に到達できないようにします。

# scripts/run-b4push.sh
set -euo pipefail

step "Type check"
pnpm check

step "Build"
pnpm build

step "Design token lint"
pnpm design-token-lint

CI も同じスクリプトを実行します。lint が失敗すれば push が失敗し、開発者が別のシェルから push したとしても PR チェックがそれを捕まえます。冗長性は意図的です ―― ローカルフックは高速フィードバックのため、CI ゲートが実際の契約です。

アンチパターン

  • linter を b4push / CI に組み込まずに追加する。 誰かが思い出したときにしか走らない linter は何も強制しません。組み込みこそが強制です。

  • デザイントークンルールだけを走らせ、コンポーネント CSS で参照される無関係なリテラルをスキップする。 透過グラデーション、currentColor チェーン、単発の SVG fill も漂流します。同じマルチパスインフラがあれば他のルールも安価に同居できます。

  • コメントなしのエスケープハッチ。 既定でブロック、文書化された理由とともに許可。裸の /* design-token-lint-ignore */ は数か月で支柱級の技術的負債になります。

  • Pass 1 をパターンの全てとみなす。 Pass 1 は明らかな生値を捕まえます。Pass 2 はもっと微妙な「:root 内でリテラルを抱えたトークン名」の腐食を捕まえます。Pass 2 がなければ、デザインシステムは生リテラルを少しずつトークン定義ゾーンに移し替えていきます。

  • <CssPreview> デモの中に lint 失敗例を置く。 css={...} ペイロード中の生 #ff0000 はプロジェクト自身のデモ規約に違反します(CSS 値は「バグを示す」リテラルではなく、すでに有効な hsl()/oklch() であるべきです)。lint 失敗例は fenced code block に留め、<CssPreview> には出荷可能な lint クリーンなコードだけを置きます。

この lint に見えないもの

グリーンなデザイントークン lint が証明するのは値であって、レイアウトではありません。2 つの欠陥クラスがそのまま素通りします。

近接と比率の失敗 ―― どの値も正当で、関係が読み取れない。linter は値を単独で検査するので、カード間のギャップがカード内のリズムと等しいカードリストは、あらゆるルールを通過しながら 1 つに融合した塊として読まれます(スペーシングの設計思想 を参照)。

ズームからオプトアウトする固定 px のサイジング任意値 ―― ディスプレイスケールを持つアプリでは、w-[28px]2層サイズ戦略 の契約上許可されており、その寸法をあらゆるズームレベルで固まらせます。監査したあるプロジェクトの lint は 1,042 ファイルでグリーンでしたが、その裏でスケーリングしない閉じるボタンのリテラルが 41 件出荷されていました。

lint ゲートには、computed style の検証条件と、複数のディスプレイスケールでの目を細めるテストを組み合わせましょう。lint は床であって天井ではありません ―― それが証明するのは誰も不正な値を書いていないことであり、「UI が正しい」よりはるかに弱い主張です。

使い分け

向いているケース

  • 小規模を超えてコンポーネントが増えたトークンシステムを持つプロジェクト全般。 システムには強制境界が必要であり、lint パスがその境界です。

  • 複数貢献者のチーム。 コードレビューは初犯を捕まえます。linter は 2 件目と 3 件目を捕まえます。これがなければ 3 件目が出荷されます。

  • AI 補助コードベース。 AI エージェントは隣接ファイルからコピーします。隣のひとつに生 oklch() があれば、以後の生成は同じものを繰り返す傾向があります。linter はコピーしない最初の読み手です。

  • 長寿命のデザインシステム。 トークンは進化します。linter は現時点の契約を機械可読な形で文書化し、リファクタリングを跨いで生き残らせます。

不要なケース

  • プロトタイプや 1 ページもののサイト。 トークンシステムがまだ安定していません。いまルールをエンコードするのは早すぎます。

  • トークンシステムを持たない単独開発者プロジェクト。 強制すべき対象がそもそも存在しません。

参考リンク

Revision History

作成更新