はい、英語でテーマを記述し、動作するデザインシステムを得ることはできる。ただし、その英語の記述はプロンプトではない。ソースファイルなのだ。そしてあらゆるソースファイルと同様、コンパイラ、型システム、テストが必要だ。

チャットボットの問い合わせのように扱えば、火曜日には月曜日とは異なる配色を得ることになる。限界付きコンテキストと厳格なスキーマを持つDSLとして扱えば、デザインシステムが消費できる再現性のあるトークンが得られる。

デザイントークンは創造的課題ではなく、同期問題だ

多くのチームは色を選ぶことに苦労していない。Figma、CSS変数、コンポーネントライブラリの間で色を一貫させることに苦労しているのだ。典型的なワークフローはこうだ。デザイナーがスタイルガイドのhexコードを更新し、開発者がそれをJSONファイルにコピーし、別の開発者がReactコンポーネントで間違ったキーを参照し、3カ月後にはある箇所では#1a1a2eになり、別の箇所では#1b1b2fになっている。

本当の苦痛はハンドオフだ。Figmaはコードではない。JSONはデザインツールではない。英語はその中間に位置する。英語はデザイナーも開発者も訓練なしに読める唯一の形式だ。

問題は、LLMが英語をhexコードに変換できるかどうかではない。そのプロセスをCIで実行するに足るほど信頼性の高いものにできるかどうかだ。

theme-to-codeは実際にどう動くのか

アーキテクチャは単純明快だ。平易な英語で短いテーママニフェストを書く。スクリプトがそれを制約付き出力形式でLLMに渡す。LLMが構造化されたトークンを返す。それらのトークンをディスクに書き込み、型チェックし、コミットする。

パイプラインは以下の通りだ。

  1. マニフェスト: テーマを散文で記述した.txtまたは.mdファイル。
  2. スキーマ: LLMが返さなければならないZod(またはJSON Schema)定義。
  3. コンパイラ: マニフェストを読み込み、構造化出力でLLMを呼び出し、tokens.jsonを書き出すスクリプト。
  4. ゲート: 生成されたトークンがスナップショットから逸脱したり制約に違反したりした場合に失敗するテスト。

鍵はステップ3だ。モデルに「ダークテーマを生成せよ」と頼むのではない。スキーマを埋めさせるのだ。これにより創作タスクが構造化データ抽出タスクに変わり、LLMはこの分野で格段に得意だ。

TypeScriptで動作するコンパイラ

以下は最小限だが完全な実装だ。テーマ記述を読み込み、ZodスキーマでOpenAIを呼び出し、結果をJSONファイルに書き出す。

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import OpenAI from "openai";

// 1. Define the bounded context: only these tokens exist
const TokenSchema = z.object({
  name: z.string(),
  colors: z.object({
    background: z.string().regex(/^#[0-9a-f]{6}$/i),
    surface: z.string().regex(/^#[0-9a-f]{6}$/i),
    primary: z.string().regex(/^#[0-9a-f]{6}$/i),
    text: z.string().regex(/^#[0-9a-f]{6}$/i),
    muted: z.string().regex(/^#[0-9a-f]{6}$/i),
  }),
  spacing: z.object({
    unit: z.number().min(4).max(16),
    scale: z.array(z.number()).length(4),
  }),
  radii: z.object({
    sm: z.number(),
    md: z.number(),
    lg: z.number(),
  }),
});

type TokenSet = z.infer<typeof TokenSchema>;

// 2. The compiler
async function compileTheme(
  description: string,
  apiKey: string
): Promise<TokenSet> {
  const client = new OpenAI({ apiKey });

  const completion = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [
      {
        role: "system",
        content:
          "You are a design-token compiler. " +
          "Convert the user's theme description into the exact JSON schema provided. " +
          "All colors must be valid 6-digit hex. " +
          "The spacing unit must be a multiple of 4. " +
          "The scale array must have exactly 4 values.",
      },
      {
        role: "user",
        content: description,
      },
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "theme_tokens",
        strict: true,
        schema: zodToJsonSchema(TokenSchema),
      },
    },
  });

  const raw = JSON.parse(completion.choices[0].message.content!);
  return TokenSchema.parse(raw);
}

// 3. CLI entrypoint
async function main() {
  const description = await Bun.file("theme.manifest.txt").text();
  const tokens = await compileTheme(description, process.env.OPENAI_API_KEY!);
  await Bun.write("tokens.json", JSON.stringify(tokens, null, 2));
  console.log("Compiled theme:", tokens.name);
}

main();

依存関係としてzodopenaizod-to-json-schemaが必要だ。スクリプトはBunを使用することを前提としているが、Bun.fileBun.writefs.promisesに置き換えるのは容易だ。

無視できないトレードオフ

これは動作するが、タダではない。

決定性。 temperatureをゼロに設定し構造化出力を有効にしても、同じ記述がモデルのバージョン間で若干異なるhexコードを生み出すことがある。OpenAIのアップデート後にコンパイラを再実行すると、スナップショットテストが失敗する可能性がある。生成されたtokens.jsonをコミットし、マニフェストが変更された場合にのみ再コンパイルすべきだ。

レイテンシ。 ビルドステップでLLMを呼び出すと、数秒、時には数十秒追加される。ホットリロードのたびにテーマをコンパイルするな。マニフェストファイルが変更された場合に、pre-commitフックまたはCIでコンパイラを実行するのだ。

スキーマの不動性。 LLMは新しいトークンカテゴリを発明できない。後にデザインシステムがelevation.shadows.xlを必要とした場合、モデルが出力する前にZodスキーマ、システムプロンプト、コンパイラを更新しなければならない。これはバグではなく機能だ。DSLを限界付きに保つためだ。

アクセシビリティ。 LLMは、プライマリカラーが背景に対してWCAGのコントラスト比を満たしているかどうかを知らない。生成されたトークンは別途検証しなければならない。TokenSchema.parse呼び出しの後に以下を追加するのだ。

function contrastRatio(hex1: string, hex2: string): number {
  // WCAG relative luminance calculation
  const lum = (hex: string) => {
    const rgb = parseInt(hex.slice(1), 16);
    const [r, g, b] = [(rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff];
    const f = (c: number) => {
      c /= 255;
      return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    };
    return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
  };
  const l1 = lum(hex1) + 0.05;
  const l2 = lum(hex2) + 0.05;
  return l1 > l2 ? l1 / l2 : l2 / l1;
}

// After parsing tokens
const ratio = contrastRatio(tokens.colors.primary, tokens.colors.background);
if (ratio < 4.5) {
  throw new Error(`Contrast ratio ${ratio.toFixed(2)} fails WCAG AA`);
}

崩壊する場面:相互作用問題

このアプローチは配色、スペーシングスケール、書体ファミリーには見事に機能する。しかし、トークンが散文では記述しにくい形で相互作用する場合、崩壊する。

セマンティックトークンは典型的な罠だ。「リンクとボタンにはプライマリカラーを使え」と書いたとしよう。しかし無効化されたボタンはどうか。すでにプライマリカラーを使っている警告バナー内のリンクはどうか。これらはテーマレベルの決定ではない。コンポーネントレベルの規則であり、マニフェストファイルではなくコンポーネントライブラリに属する。

マニフェストをプリミティブトークンに限定するのだ。セマンティクスはコンポーネントに任せる。

よくある質問

限界付きコンテキストDSLとは何か。 スコープが狭い小さな言語だ。この場合、「言語」とは厳格なスキーマに支配された、デザイントークンのみを記述する英語の散文である。

同じ記述は常に同じトークンを生み出すか。 必ずしもそうではない。LLMの出力はモデルのバージョンやプロバイダー間で変動する。生成されたトークンをコミットし、スナップショットテストでドリフトを検知するのだ。

コンポーネントレベルのスタイルにこれを使えるか。 いや、マニフェストは色やスペーシングのようなプリミティブトークンのみを定義すべきだ。コンポーネントのセマンティクスはコンポーネントライブラリに属する。

OpenAIが必要か、他のモデルでも動作するか。 構造化JSON出力をサポートし、システムプロンプトに従うモデルならどれでも動作する。Llama 3.3のようなローカルモデルも、ビルドパイプライン向けに十分速く実行できるハードウェアがあれば実行可能だ。

10行のマニフェストと厳格なスキーマから始めよう

マーケティングのランディングページや管理ダッシュボードのような、1つの限界付きコンテキストを選ぶ。10行のマニフェストを書き、オプションフィールドのないZodスキーマを定義し、最初のトークンファイルを生成する。出力をコミットする。マニフェストを再コンパイルし、コミット済みのスナップショットと比較するテストを書く。

マニフェストが変更されていないのにスナップショットがドリフトしたら、コンパイラは決定論的ではない。システムプロンプトを修正するか、モデルのバージョンを固定して決定論的になるまで対処するのだ。

あなたのデザインシステムには、もう1つFigmaプラグインは必要ない。デザイナーも開発者も読める信頼できる情報源が必要だ。平易な英語は読める。スキーマがそれを信頼できるものにする。