mcpField
D
design-system-structure

design-system-structure

github.com/zakariafデザイン・UI63176 回インストール5 週間前に更新
リポジトリ

design-system-structure について

Flutter 開発者向けに、トークン、テーマ、コンポーネント、シェイプの構成と利用方法を示すデザインシステムのスキルです。美的な値を ThemeExtension のセマンティックなスロットに集約し、直書き値やフォントの同梱を検査します。

主な機能

  1. 01プリミティブとセマンティックのトークン階層
  2. 02ThemeExtension 経由でテーマトークンを参照
  3. 03ライトとダークの ColorScheme を手動定義
  4. 04テーマ・コンポーネント・シェイプを階層化
  5. 05視差効果の低減時はアニメーションをゼロに設定
  6. 06直書き値とフォント同梱をスクリプトで検査

活用シーン

  1. 01Flutter アプリのテーマディレクトリを構成
  2. 02ライト・ダークのテーマトークンを一元管理
  3. 03視差効果の設定をアニメーションに反映
  4. 04プルリクエスト前にスタイル値の直書きを検査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/zakariaf/flutter-skills --skill design-system-structure

インストール後、Claude Code で/design-system-structureを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

examples/
app_theme.dart
reduced_motion.dart
status_encoding.dart
references/
contrast-and-redundant-encoding.md
motion-and-reduced-motion.md
token-tiers-and-themeextension.md
typography-and-fonts.md
scripts/
check_font_bundling.sh
check_raw_values.sh

詳細

design-system-structure

デザインシステムとは、場違いな独自見解を持ち込めないコードです。すべてのデザイン上の値は一つのディレクトリに集約され、すべてのウィジェットは名前付きのスロットを通して値を参照します。このスキルが扱うのは、トークン、テーマ、コンポーネント、シェイプの構造と利用方法であり、どの色や角丸を選ぶかではありません(それはデザインの信頼できる情報源が担います)。構造を正しく整えれば、スキンの変更は diff lib/theme/ で済み、機能コード内に紛れ込んだ Color(0xFF…) はスタイル上の小さな指摘ではなく、ビルドエラーになります。

作業内容に応じて、以下のリファレンスを確認してください。

  • references/token-tiers-and-themeextension.md — 2 層のトークン命名、ThemeExtension の仕組み、アサーションを行う of()、手作業で定義する ColorScheme と fromSeed、誠実な lerp。
  • references/motion-and-reduced-motion.md — Material がデフォルトでマウントする 3 つのアニメーション、NoSplash だけでは不十分な理由、「動きを減らす」とはゼロにすること、テストにおける pumpAndSettle の禁止。
  • references/typography-and-fonts.md — フォントのバンドル、LicenseRegistry、FontWeight が wght を制御する仕組み、何も起きない FontVariation、インスタンス化せずに行うサブセット化、スクリプトごとのフォールバックカスケード。
  • references/contrast-and-redundant-encoding.md — AA をユニットテストとして扱うこと、各意味に対してグレースケールでも判別できるシグナルを 3 つ以上設けること、MediaQuery からアクセシビリティフラグを読み取る方法。

PR の前に scripts/check_raw_values.sh と scripts/check_font_bundling.sh を実行してください。

譲れないルール

  1. lib/theme/** の外で、見た目に関する値を直書きしないでください。ゲートの判定がすべてです。 機能コードまたは共有 UI コードにある Color(0x…)/Colors.*(transparent を除く)/Curves.*/Duration(milliseconds:|seconds:(Duration.zero を除く)/リテラルの BorderRadius.circular(n)/fontSize: n は、scripts/check_raw_values.sh で失敗します。正当な新しい要件には、新しいトークンスロットを追加してください。// ignore は使わないでください。差分を確認する場所を一か所にすることが重要です。
  2. トークンは2つの階層に分け、ウィジェットはセマンティック階層だけを参照します。 階層1は、測定値(neutral12 = OKLCH の明度 ×100)で命名するプリミティブです。順位(grey700)、見た目(darkGrey)、ブランド(brandPrimary)で命名しないでください。階層2は、役割(surface、onSurface、hairline、accent)で命名するセマンティックスロットです。階層1のプリミティブを参照するウィジェットは、1つのテーマに値をハードコードしたことになります。理由:順位のスケールには挿入の余地がなく、ダークモードでは実態と異なる値になります。見た目による命名は致命的な形で逆転し、ブランドによる命名はブランドとともに使えなくなります。
  3. トークンは ThemeExtension と、アサーションを行う of(context) を通じて読み取ります。 静的な const クラスも、InheritedWidget を介した別経路も使わないでください。拡張がない場合、of() はアサートします。?? fallback は使いません。理由:MaterialApp がライト/ダークの切り替えと、構造上正しい無効化を担います。フォールバックを使うと、テストで検証されていないテーマが気付かれないままリリースされます。デバッグ時に明確なエラーを出すほうが、実環境で誤った状態になるより優れています。
  4. ColorScheme は手作業で定義し、アイデンティティには ColorScheme.fromSeed(または dynamic_color)を使わないでください。 fromSeed のロールごとのオーバーライドは他のロールには反映されません。surface をオーバーライドしても、surfaceContainerHigh はシード由来のままです。使用する各ロールを明示し、Material ウィジェットが自らのテーマを設定できるよう M3 のロール名を維持してください。明示していないロールには、シードの値ではなく ColorScheme 自身のデフォルトを使います。理由:「シードにいくつかのオーバーライドを加える」方法では、選んだ約9個のロールだけでなく、選ばなかった約40個のロールすべてのつなぎ目も管理することになります。
  5. システムを、トークン → テーマ → モディファイア/コンポーネント → シェイプの順に構成します。 ライトとダークの両方を手作業で定義します(ダークは自動反転ではありません)。それぞれが個別に AA を満たすようにし、両方の ThemeData にすべての拡張を設定します。テーマの注入はコンポジションルートで一度だけ行います。理由:アプリのテーマ設定の変更は、1つのディレクトリだけで済むべきです。パレットだけを変えたダークテーマでは、ライトテーマの比率がそのままダークテーマにも適用されます。
  6. lerp は正しく補間するか、意図的に切り替えます。copyWith は必須です。 色と倍精度値は補間し、補間できない値(フォントファミリー、ShapeBorder)は t < 0.5 で切り替えます。アプリでテーマのアニメーションを禁止しており、すべての値を切り替える場合はコメントを残してください。コメントのない段階的な lerp は未完成に見え、次の担当者が「修正」してしまいます。理由:新しいフィールドが lerp に追加されないまま放置されることは、デザインシステムが劣化する典型例です。
  7. 色は派生トークンで、最後に計算します。状態を示す唯一の手段にしてはいけません。 色は正規の値オブジェクト(列挙型または小さなクラス)の純粋関数とし、最後にセマンティックスロットを通じて解決します。状態を色から読み取ってはいけません。また、補助となる色もリテラルではなく、スロットから読み取ります。「色だけに頼らない」ための最低基準、つまり状態を持つ各意味に対して色以外のシグナル(グリフ、ラベル、太さ/位置)を3つ以上用意することは、accessibility-as-code が担います。このスキルでは、それらを補強する色が派生スロットであることだけを徹底します。理由:値オブジェクトから派生していない色は状態との同期がずれ、色だけに頼るウィジェットはグレースケールで機能しなくなります。
  8. モーション低減時は「控えめに」するのではなく、ゼロにします。 MediaQuery.disableAnimationsOf(context) を読み取り、Duration.zero または最終状態へのクロスフェードに解決します。時間を短くしたり、より緩やかなカーブにしたりしてはいけません。モーションを状態変化の唯一のシグナルにしてはいけません。理由:OS のアニメーション停止を求めたユーザーは、停止を求めています。
  9. 初回描画の前に、保存済みのテーマを復元します。 保存されたテーマモード/バリアントは、runApp の前に、アプリの他の部分と同じバージョン管理された設定から読み込みます(1フレーム遅れて反映される非同期の shared_preferences 読み込みは使いません)。不明または破損した保存値は、null のままにせず、明示的かつユーザーに分かる形でフォールバックします。デバッガーのないデバイスで of() のアサーションが発生する状態を避けてください。理由:誤ったテーマが一瞬表示されることは、ユーザーが引き起こしていない突然の輝度変化になります。
  10. フォントはバンドルし、実行時に google_fonts を使わないでください。 pubspec.yaml でフォントを宣言し、ライセンステキストを同梱して LicenseRegistry に登録します。太さは FontWeight(wght 軸を制御します)で指定し、冗長な FontVariation('wght', …) は使いません。スクリプトごとの fontFamilyFallback をカスケードで宣言し、既知の正常な書体で終わるようにします。理由:google_fonts はデフォルトで HTTP を使うコードパスを含みます。フォントに軸がない場合、FontVariation('opsz'|'ital', …) は何もせずに無視されるため、「動作する」イタリック体もレビューでは見えません。
  11. ペインターはトークンのスナップショットを受け取り、paint() から BuildContext を読み取りません。 ウィジェット層で Theme.of(context).extension<T>() を一度だけ解決し、その値を CustomPainter/Scene のフィールドに渡します。理由:paint() のアロケーションをなくし、MaterialApp なしでもテスト可能にします。custom-canvas-and-gestures を参照してください。
  12. モーション低減フラグは MediaQuery(disableAnimationsOf)から読み取り、アプリの状態からは読み取りません。このフラグは resolveMotion(このスキルのヘルパー。以下を参照)に渡されます。その他のプラットフォームのアクセシビリティフラグ(boldTextOf、highContrastOf、textScaler)を、古いアプリ状態のコピーではなく MediaQuery から読み取ることは、accessibility-as-code が担います。

トークンのレイヤー構造

tokens → theme → modifiers → shapes。プリミティブは値を保持し、ThemeExtension はセマンティックなスロットに名前を付け、ThemeData ビルダーはスロットを Material の ColorScheme とコンポーネントテーマに集約します。シェイプのファクトリーは独立した拡張に配置し、コンポーネントが半径ではなくシルエットを要求するようにします。

// lib/theme/primitives.dart — TIER 1. The ONLY file allowed a raw color literal.
// Named by MEASURED value; values are placeholders owned by your design authority.
abstract final class Primitives {
  static const neutral08 = Color(0xFF141414); // L .08 — ink
  static const neutral96 = Color(0xFFF4F4F4); // L .96 — paper
  static const neutral40 = Color(0xFF636363);
  static const accent45  = Color(0xFF3A6FF0);
}

// lib/theme/app_colors.dart — TIER 2. Widgets read THESE, never a primitive.
@immutable
class AppColors extends ThemeExtension<AppColors> {
  const AppColors({required this.surface, required this.onSurface, required this.accent});
  final Color surface, onSurface, accent;

  static AppColors of(BuildContext context) {
    final ext = Theme.of(context).extension<AppColors>();
    assert(ext != null, 'AppColors missing. Build ThemeData via buildAppTheme().');
    return ext!; // assert, never `?? fallback` — a fallback ships an unverified theme.
  }
  // copyWith + lerp omitted here — see examples/app_theme.dart for the full class.
}

完全な実装例(プリミティブ、cardShape ファクトリーを持つシェイプ拡張、手動定義の ColorScheme、適切な lerp、および一度だけの注入):examples/app_theme.dart。

M3 のロール名を維持して ColorScheme を手動定義する

// WRONG — surfaceContainerHigh, surfaceDim, surfaceBright etc. stay seed-derived;
// your `surface` override does not propagate to them.
ColorScheme.fromSeed(seedColor: Primitives.accent45, surface: c.surface);

// RIGHT — state every role you read. Unstated roles get ColorScheme's defaults,
// not a seed's opinion, and the M3 names mean Material widgets theme themselves.
ColorScheme(
  brightness: brightness,
  primary: c.accent, onPrimary: c.surface,
  surface: c.surface, onSurface: c.onSurface,
  surfaceContainerHighest: c.surface, onSurfaceVariant: c.onSurfaceMuted,
  outline: c.hairline, error: c.danger, onError: c.surface,
);

Material の TextField は surfaceContainerHighest、onSurfaceVariant、outline、error を独自に読み取ります。ロールに正しい名前を付ければ、テーマ変更のたびに同期がずれていくような、ウィジェットごとの InputDecoration の修正を行わなくてもテーマが適用されます。

モーションを減らす:フラグを読み取り、ゼロにする

// The one place a widget asks "should I animate?" — collapses under the OS flag.
Duration resolveMotion(BuildContext context, Duration full) =>
    MediaQuery.disableAnimationsOf(context) ? Duration.zero : full;

AnimatedContainer(
  duration: resolveMotion(context, AppMotion.of(context).medium), // token, not 300ms
  curve: AppMotion.of(context).enter,                              // token, not Curves.easeOut
  color: AppColors.of(context).surface,
  child: child,
);

NoSplash.splashFactory だけでは不十分です。押下時のハイライトのフェードは別の InkHighlight であり、themeAnimationStyle: AnimationStyle.noAnimation を渡さない限り、MaterialApp は ThemeData を kThemeAnimationDuration にわたって補間します。3 つの切り替え方法すべてについては、references/motion-and-reduced-motion.md を参照してください。

最初の描画前にテーマを復元する

// bootstrap: settings are loaded BEFORE runApp, so the first frame is correct.
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final settings = await Settings.load();               // synchronous by the time runApp runs
  final mode = ThemeVariant.tryParse(settings.themeName) // unknown/corrupt -> explicit default
      ?? ThemeVariant.system;
  runApp(App(initialVariant: mode));
}

選択を永続化しても、非同期で読み戻す(単独の shared_preferences 読み取り)と、1 フレーム遅れて適用され、誤ったテーマが一瞬表示されます。state-management-riverpod では、解決済みのバリアントを themeModeProvider 経由で渡す方法を説明し、app-startup-and-bootstrap では main() の実行順序を説明しています。

冗長な表現:最後に色を導出する

3 つ以上の非カラーシグナルを設ける基準は accessibility-as-code が担います。ここで示すデザインシステムの役割はより限定的です。補助色は状態の情報源として読み取るのではなく、セマンティックスロットを通じて正規の値オブジェクトから最後に導出します。

enum OrderStatus { pending, active, blocked }

extension OrderStatusView on OrderStatus {
  IconData get icon => switch (this) {          // signal 1: shape differs per status
        OrderStatus.pending => Icons.schedule,
        OrderStatus.active  => Icons.play_arrow,
        OrderStatus.blocked => Icons.error_outline,
      };
  String label(AppLocalizations l) => switch (this) { … }; // signal 2: the word
  double get emphasis => this == OrderStatus.blocked ? 2 : 1; // signal 3: monotonic weight
  Color color(AppColors c) => switch (this) {   // color is DECORATION, derived LAST
        OrderStatus.pending => c.onSurfaceMuted,
        OrderStatus.active  => c.accent,
        OrderStatus.blocked => c.danger,
      };
}

3 つのシグナルすべてを描画するチップウィジェットの完全な例:examples/status_encoding.dart。

アンチパターン

  • lib/theme/ 外のウィジェットにある Color(0xFF…)、Colors.blueGrey、Curves.easeOut、Duration(milliseconds: 200)、BorderRadius.circular(16)、または fontSize: 14 — チェックに失敗します。修正方法はスロットを読み取るか新しいスロットを追加することであり、// ignore ではありません。
  • 静的な AppColors/Dimens の const クラス、または ?? AppColors.fallback() を返す AppColors.of — スロットは ThemeExtension に配置し、フォールバックでテスト検証されていないパレットを提供しないでください。
  • アイデンティティのある製品での ColorScheme.fromSeed(...)、または dynamic_color の追加 — オーバーライドが反映されず、壁紙由来のパレットはビルド時にテストできません。
  • パレットだけのダークテーマ、またはライト ThemeData のみに追加された拡張 — ダークテーマでは気付かないうちにデフォルトが描画され、テーマ切り替えが瞬時に切り替わります。
  • ラベル付けされていないまま this を返す lerp、または新しく追加したフィールドを忘れる lerp — テーマが瞬時に切り替わるか、スロットが永久に補間されません。
  • 状態を示す唯一の手段として色を使うこと — 色の変更は問題ありませんが、色に添えられたグリフ/ラベル/ウェイトを取り除くと、グレースケールで見えなくなるバグになります。
  • モーションを減らす設定時に、再生時間を短くしたりカーブを穏やかにしたりすること — Duration.zero にします。ユーザーが求めているのは停止です。
  • テーマの取得に単独の shared_preferences 読み取りを使い、runApp の後のフレームで修正すること — 誤ったテーマが一瞬表示されるのは輝度の急変になります。最初の描画前に復元してください。
  • import 'package:google_fonts/…' または FontVariation('opsz'|'ital', …) — 前者は HTTP 経由の処理を導入し、後者は軸のないフォントでは何もせずに無効になります。
  • paint() 内で Theme.of(context) を呼び出す CustomPainter — ウィジェット層でトークンを読み取り、ペインターのフィールドにスナップショットしてください。
  • 小規模な単一パッケージのアプリでのトークンパイプライン(DTCG JSON、Style Dictionary、Figma 同期コード生成) — デザイナーとエンジニアの引き継ぎを解決するために node_modules によるビルド失敗の可能性が加わりますが、同じ人が担当するなら、その引き継ぎ自体が存在しません。

完了の定義

  • scripts/check_raw_values.sh と scripts/check_font_bundling.sh が lib/ に対して問題なく実行されること(生の値は lib/theme/** に限定され、google_fonts を使用していないこと)。
  • ウィジェットが描画するすべての視覚的な値が、ThemeExtension 上の名前付きセマンティックスロットに由来し、アサーションを行う of(context) を介して読み取られること。
  • トークンは2層構造であること。プリミティブは測定値で命名し、セマンティックスロットは役割で命名すること。ウィジェットがプリミティブを読み取らないこと。
  • ColorScheme は両方の明るさ用に手動で定義すること(アイデンティティの定義に fromSeed を使用しないこと)。M3 のロール名を維持し、すべての拡張機能を両方の ThemeData に適用すること。
  • 両方のテーマが、それぞれ個別に AA に適合すること。宣言された前景色/背景色の組み合わせを対象とする computeLuminance() のユニットテストで検証すること(references/contrast-and-redundant-encoding.md)。
  • copyWith を実装すること。lerp では色と倍精度浮動小数点数を補間し、補間できない値は切り替えること(または、意図的な手順であることをコメントに記すこと)。
  • 状態を示す意味には、グレースケールでも判別できるシグナルを3つ以上持たせること。グレースケールのゴールデンテストでも「これはどの状態か?」が分かること。
  • すべてのアニメーションがモーショントークンを参照し、MediaQuery.disableAnimationsOf が有効な場合はゼロに縮退すること。
  • 保存されたテーマを初回描画より前に復元すること。不明な値には明示的なフォールバックを指定し、決して null にしないこと。
  • フォントをバンドルし、ライセンスを登録すること。スクリプトごとに fontFamilyFallback を宣言し、実行時にフォントを取得しないこと。
  • ペインターにはトークンのスナップショットを渡すこと。paint() 内で Theme.of(context) を使用しないこと。

関連スキル

  • accessibility-as-code では、MediaQuery からアクセシビリティの状態を読み取る方法、色だけに頼らないルール、ターゲットサイズ、textScaler を決してクランプしないことについて説明しています。
  • custom-canvas-and-gestures では、トークンのスナップショットを使用するペインターについて説明しています。
  • flutter-performance では、const サブツリー、.select によるリビルド範囲の限定、RepaintBoundary について説明しています。
  • widget-composition では、これらのスロットを読み取る小さな const ウィジェットからビューを構築する方法について説明しています。
  • state-management-riverpod では、themeModeProvider とコンポジションルートへの依存性注入について説明しています。
  • app-startup-and-bootstrap では、runApp より前にテーマを復元する main() の呼び出し順序について説明しています。
  • i18n-rtl-l10n では、スクリプトごとのフォント、方向に応じたジオメトリ、RTL のゴールデンテストについて説明しています。
  • lint-and-style-config では、生の値を検出する grep を CI のゲートに昇格させる方法について説明しています。
  • motion-and-haptics では、モーショントークンの用途について説明しています。モーメントのカタログ、ハプティックイベントのマップ、各モーメントで定義する縮退方法を扱います。
  • design-review-workflow では、アプリ全体で一度行うスクリーンショットの確認により、組み上げたシステムを評価する方法について説明しています。

参考資料

コメント