design-system-structure について
Flutter 開発者向けに、トークン、テーマ、コンポーネント、シェイプの構成と利用方法を示すデザインシステムのスキルです。美的な値を ThemeExtension のセマンティックなスロットに集約し、直書き値やフォントの同梱を検査します。
主な機能
- 01プリミティブとセマンティックのトークン階層
- 02ThemeExtension 経由でテーマトークンを参照
- 03ライトとダークの ColorScheme を手動定義
- 04テーマ・コンポーネント・シェイプを階層化
- 05視差効果の低減時はアニメーションをゼロに設定
- 06直書き値とフォント同梱をスクリプトで検査
活用シーン
- 01Flutter アプリのテーマディレクトリを構成
- 02ライト・ダークのテーマトークンを一元管理
- 03視差効果の設定をアニメーションに反映
- 04プルリクエスト前にスタイル値の直書きを検査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill design-system-structureインストール後、Claude Code で/design-system-structureを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/app_theme.dartreduced_motion.dartstatus_encoding.dartreferences/contrast-and-redundant-encoding.mdmotion-and-reduced-motion.mdtoken-tiers-and-themeextension.mdtypography-and-fonts.mdscripts/check_font_bundling.shcheck_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 を実行してください。
譲れないルール
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つの階層に分け、ウィジェットはセマンティック階層だけを参照します。 階層1は、測定値(
neutral12= OKLCH の明度 ×100)で命名するプリミティブです。順位(grey700)、見た目(darkGrey)、ブランド(brandPrimary)で命名しないでください。階層2は、役割(surface、onSurface、hairline、accent)で命名するセマンティックスロットです。階層1のプリミティブを参照するウィジェットは、1つのテーマに値をハードコードしたことになります。理由:順位のスケールには挿入の余地がなく、ダークモードでは実態と異なる値になります。見た目による命名は致命的な形で逆転し、ブランドによる命名はブランドとともに使えなくなります。 - トークンは
ThemeExtensionと、アサーションを行うof(context)を通じて読み取ります。 静的なconstクラスも、InheritedWidgetを介した別経路も使わないでください。拡張がない場合、of()はアサートします。?? fallbackは使いません。理由:MaterialAppがライト/ダークの切り替えと、構造上正しい無効化を担います。フォールバックを使うと、テストで検証されていないテーマが気付かれないままリリースされます。デバッグ時に明確なエラーを出すほうが、実環境で誤った状態になるより優れています。 ColorSchemeは手作業で定義し、アイデンティティにはColorScheme.fromSeed(またはdynamic_color)を使わないでください。fromSeedのロールごとのオーバーライドは他のロールには反映されません。surfaceをオーバーライドしても、surfaceContainerHighはシード由来のままです。使用する各ロールを明示し、Material ウィジェットが自らのテーマを設定できるよう M3 のロール名を維持してください。明示していないロールには、シードの値ではなくColorScheme自身のデフォルトを使います。理由:「シードにいくつかのオーバーライドを加える」方法では、選んだ約9個のロールだけでなく、選ばなかった約40個のロールすべてのつなぎ目も管理することになります。- システムを、トークン → テーマ → モディファイア/コンポーネント → シェイプの順に構成します。 ライトとダークの両方を手作業で定義します(ダークは自動反転ではありません)。それぞれが個別に AA を満たすようにし、両方の
ThemeDataにすべての拡張を設定します。テーマの注入はコンポジションルートで一度だけ行います。理由:アプリのテーマ設定の変更は、1つのディレクトリだけで済むべきです。パレットだけを変えたダークテーマでは、ライトテーマの比率がそのままダークテーマにも適用されます。 lerpは正しく補間するか、意図的に切り替えます。copyWithは必須です。 色と倍精度値は補間し、補間できない値(フォントファミリー、ShapeBorder)はt < 0.5で切り替えます。アプリでテーマのアニメーションを禁止しており、すべての値を切り替える場合はコメントを残してください。コメントのない段階的なlerpは未完成に見え、次の担当者が「修正」してしまいます。理由:新しいフィールドがlerpに追加されないまま放置されることは、デザインシステムが劣化する典型例です。- 色は派生トークンで、最後に計算します。状態を示す唯一の手段にしてはいけません。 色は正規の値オブジェクト(列挙型または小さなクラス)の純粋関数とし、最後にセマンティックスロットを通じて解決します。状態を色から読み取ってはいけません。また、補助となる色もリテラルではなく、スロットから読み取ります。「色だけに頼らない」ための最低基準、つまり状態を持つ各意味に対して色以外のシグナル(グリフ、ラベル、太さ/位置)を3つ以上用意することは、
accessibility-as-codeが担います。このスキルでは、それらを補強する色が派生スロットであることだけを徹底します。理由:値オブジェクトから派生していない色は状態との同期がずれ、色だけに頼るウィジェットはグレースケールで機能しなくなります。 - モーション低減時は「控えめに」するのではなく、ゼロにします。
MediaQuery.disableAnimationsOf(context)を読み取り、Duration.zeroまたは最終状態へのクロスフェードに解決します。時間を短くしたり、より緩やかなカーブにしたりしてはいけません。モーションを状態変化の唯一のシグナルにしてはいけません。理由:OS のアニメーション停止を求めたユーザーは、停止を求めています。 - 初回描画の前に、保存済みのテーマを復元します。 保存されたテーマモード/バリアントは、
runAppの前に、アプリの他の部分と同じバージョン管理された設定から読み込みます(1フレーム遅れて反映される非同期のshared_preferences読み込みは使いません)。不明または破損した保存値は、nullのままにせず、明示的かつユーザーに分かる形でフォールバックします。デバッガーのないデバイスでof()のアサーションが発生する状態を避けてください。理由:誤ったテーマが一瞬表示されることは、ユーザーが引き起こしていない突然の輝度変化になります。 - フォントはバンドルし、実行時に
google_fontsを使わないでください。pubspec.yamlでフォントを宣言し、ライセンステキストを同梱してLicenseRegistryに登録します。太さはFontWeight(wght軸を制御します)で指定し、冗長なFontVariation('wght', …)は使いません。スクリプトごとのfontFamilyFallbackをカスケードで宣言し、既知の正常な書体で終わるようにします。理由:google_fontsはデフォルトで HTTP を使うコードパスを含みます。フォントに軸がない場合、FontVariation('opsz'|'ital', …)は何もせずに無視されるため、「動作する」イタリック体もレビューでは見えません。 - ペインターはトークンのスナップショットを受け取り、
paint()からBuildContextを読み取りません。 ウィジェット層でTheme.of(context).extension<T>()を一度だけ解決し、その値をCustomPainter/Sceneのフィールドに渡します。理由:paint()のアロケーションをなくし、MaterialAppなしでもテスト可能にします。custom-canvas-and-gesturesを参照してください。 - モーション低減フラグは
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では、アプリ全体で一度行うスクリーンショットの確認により、組み上げたシステムを評価する方法について説明しています。
参考資料
- Flutter API —
ThemeExtension:https://api.flutter.dev/flutter/material/ThemeExtension-class.html - Flutter API —
ThemeData(extensions、コンポーネントテーマ、colorScheme):https://api.flutter.dev/flutter/material/ThemeData-class.html - Flutter API —
ColorScheme(ロール、fromSeedに関する注意点):https://api.flutter.dev/flutter/material/ColorScheme-class.html - Flutter クックブック — テーマを使って色とフォントスタイルを共有する:https://docs.flutter.dev/cookbook/design/themes
- Flutter クックブック — カスタムフォントを使用する(バンドル、
fontFamilyFallback):https://docs.flutter.dev/cookbook/design/fonts - Flutter API —
MediaQueryData.disableAnimations/textScaler:https://api.flutter.dev/flutter/widgets/MediaQueryData-class.html - Flutter API —
Color.computeLuminance(WCAG の相対輝度):https://api.flutter.dev/flutter/dart-ui/Color/computeLuminance.html - Flutter API —
LicenseRegistry:https://api.flutter.dev/flutter/foundation/LicenseRegistry-class.html - Material Design 3 — デザイントークン:https://m3.material.io/foundations/design-tokens/overview
- W3C WAI — WCAG 2.2 SC 1.4.3 コントラスト(最低限):https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html
- W3C WAI — WCAG 2.2 SC 1.4.1 色の使用:https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html