zudo-css-wisdom

Type to search...

to open search from anywhere

Design Token Lint

作成2026年4月25日更新2026年4月25日Takeshi Takatsudo

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

The Problem

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

  • 生の #0066ff リテラルがコンポーネント CSS に紛れ込み、コードレビューをすり抜けます。
  • z-index: 99 のショートカットが tier system を回避します。一度すり抜けたら、次の貢献者は 99 を見て 100 を書きます。
  • コードベースが大きくなるにつれて、ドキュメントと CSS の間でトークン名がずれていきます。デザインシステムページに記載された --shadow-modal は、実際の 3 つのコンポーネントで使われている --modal-shadow ともはや一致しません。
  • 「コンポーネントで生の oklch を使わない約束」がコードレビューで保たれるのは半年程度。その後は二人の頭の中だけにしか生き残らず、その二人とも今日の PR をレビューしてはいません。

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

The Solution

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

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 フィールドもサポートしています。安定的な例外にはこれらを使い、行レベルのコメントは個別の理由を運ぶ役割に使います。

リファレンス実装

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

  • 数値スペーシングユーティリティ(p-4m-8gap-6mt-16space-x-4inset-2 …)を禁止
  • Tailwind デフォルト色(bg-gray-500text-blue-600border-red-300ring-indigo-500 …)を禁止
  • セマンティックトークン(bg-surfacetext-fgp-hgap-sm)、任意値(w-[28px])、ゼロ値(p-0)、デフォルトでない色名はすべて許可
  • 静的解析ベース: className=class=cn(...)clsx(...)classNames(...)twMerge(...)、Astro の class:list 式をスキャン

同じパッケージは zudo-pattern-gen <code>packages/design-token-lint</code> でも 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 クリーンなコードだけを置きます。

When to Use

向いているケース

  • 小規模を超えてコンポーネントが増えたトークンシステムを持つプロジェクト全般。 システムには強制境界が必要であり、lint パスがその境界です。
  • 複数貢献者のチーム。 コードレビューは初犯を捕まえます。linter は 2 件目と 3 件目を捕まえます。これがなければ 3 件目が出荷されます。
  • AI 補助コードベース。 AI エージェントは隣接ファイルからコピーします。隣のひとつに生 oklch() があれば、以後の生成は同じものを繰り返す傾向があります。linter はコピーしない最初の読み手です。
  • 長寿命のデザインシステム。 トークンは進化します。linter は現時点の契約を機械可読な形で文書化し、リファクタリングを跨いで生き残らせます。

不要なケース

  • プロトタイプや 1 ページもののサイト。 トークンシステムがまだ安定していません。いまルールをエンコードするのは早すぎます。
  • トークンシステムを持たない単独開発者プロジェクト。 強制すべき対象がそもそも存在しません。

References

Revision History