mcpField
D
design-system-structure

design-system-structure

github.com/zakariaf设计与 UI63176 次安装5 周前更新
代码仓库

关于 design-system-structure

面向 Flutter 开发者的设计系统结构技能,说明如何组织令牌、主题、组件和形状,并规范其使用方式。重点是将审美值集中管理,通过 ThemeExtension 提供语义令牌,并检查原始样式值和字体打包。

主要功能

  1. 01划分基础与语义令牌层级
  2. 02通过 ThemeExtension 读取主题令牌
  3. 03手动构建浅色与深色 ColorScheme
  4. 04将形状与组件主题分层组织
  5. 05为减少动画设置零时长
  6. 06脚本检查原始样式值和字体打包

适用场景

  1. 01为 Flutter 应用建立主题目录结构
  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

设计系统是无法容纳任何随意主张的代码:所有美学值都存放在一个目录中,每个组件都通过具名槽位读取这些值。本技能负责规范令牌、主题、组件和形状的组织方式与使用方式,而不是决定选择哪些颜色或圆角(这些应由设计单一事实来源决定)。正确的结构能让换肤变成对 lib/theme/ 的一次 diff,并让功能代码中意外出现的 Color(0xFF…) 导致构建失败,而不是仅仅成为一个样式问题。

请根据当前任务阅读相应参考资料:

  • references/token-tiers-and-themeextension.md — 两层令牌命名、ThemeExtension 机制、会触发断言的 of()、手工编写的 ColorScheme 与 fromSeed、可靠的 lerp。
  • references/motion-and-reduced-motion.md — Material 默认添加的三种动画、为什么 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. 令牌分两层;组件只能读取语义层。 第 1 层 = 按 测量值 命名的原语(neutral12 = OKLCH 亮度 ×100),绝不按等级(grey700)、外观(darkGrey)或品牌(brandPrimary)命名。第 2 层 = 按用途命名的语义槽位(surface、onSurface、hairline、accent)。组件若直接读取第 1 层原语,就等于硬编码了一个主题。原因:等级色阶没有插入空间,且在深色模式下会产生误导;外观名称会造成灾难性的反转;品牌名称会随品牌消亡。
  3. 通过 ThemeExtension 和带断言的 of(context) 读取令牌。 绝不使用静态 const 类,也绝不通过 InheritedWidget 另辟通道。缺少扩展时,of() 会触发断言——不会使用 ?? fallback。原因:MaterialApp 负责浅色/深色切换和确保变更能正确触发失效;回退值会悄悄发布未经任何测试验证的主题,而在调试模式下明确报错,胜过在实际环境中用错主题。
  4. 手动编写 ColorScheme;绝不使用 ColorScheme.fromSeed(或 dynamic_color)定义主题特征。 fromSeed 对各角色的覆盖值 不会传递——覆盖 surface 后,surfaceContainerHigh 仍会由种子色派生。明确设置每个实际使用的角色,保留 M3 角色名称,以便 Material 组件自行应用主题;未明确设置的角色应使用 ColorScheme 自身的默认值,而非种子色的配置。原因:“种子色加少量覆盖”意味着你不仅要负责所选的约 9 个角色,还要负责未选的约 40 个角色之间的所有衔接。
  5. 分层构建系统:令牌 → 主题 → 修饰器/组件 → 形状。 手动定义浅色 和 深色主题(深色主题绝不是自动翻转得来),两者都必须各自通过 AA,并将每个扩展附加到 两个 ThemeData 上。在组合根中恰好注入一次主题。原因:为应用设置主题只应涉及一个目录;只切换调色板的深色主题,会把浅色主题的对比度比例带到深色主题中。
  6. 如实实现 lerp,或明确采用跳变;copyWith 是必需的。 对颜色和双精度值进行插值;对不可插值的值(字体族、ShapeBorder),在 t < 0.5 时跳变。如果应用禁止主题动画且所有值都采用跳变,应添加注释——仅有一个直接跳变的 lerp 看起来像是尚未完成,后续读者还会试图“修复”它。原因:新增字段被悄悄漏在 lerp 之外,是设计系统腐化的典型问题。
  7. 颜色是最后计算的派生令牌,绝不能成为状态的唯一通道。 颜色应是规范值对象(枚举/小型类)的纯函数,并在最后通过语义槽位解析;状态绝不能从颜色中读取,辅助颜色必须读取槽位,而非使用字面量。每种有状态含义至少需要 3 个非颜色信号(图形符号、标签、粗细/位置),以落实“绝不能只靠颜色”的底线;这由 accessibility-as-code 负责。本技能只要求强化这些信号的颜色必须来自派生槽位。原因:如果颜色不是从值对象派生出来的,就会与其失去同步;而只依赖颜色的组件在灰度模式下会失效。
  8. 减少动态效果时应将其归零,而不是“柔和一些”。 读取 MediaQuery.disableAnimationsOf(context),并解析为 Duration.zero/向最终状态交叉淡化——绝不缩短动画时长或使用更柔和的曲线。动态效果绝不能成为状态变化的唯一信号。原因:用户要求操作系统停止动画,就是要求停止动画。
  9. 首帧绘制前恢复已持久化的主题。 在 runApp 之前,从应用其他部分使用的同一版本化设置中加载已保存的主题模式/变体(不要异步读取 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(本技能下方提供的辅助函数)。至于从 MediaQuery 而非过时的应用状态副本中读取其他平台无障碍标记(boldTextOf、highContrastOf、textScaler),则由 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。

手动编写 ColorScheme,保留 M3 角色名称

// 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,而 MaterialApp 会在 kThemeAnimationDuration 内插值 ThemeData,除非传入 themeAnimationStyle: AnimationStyle.noAnimation。有关全部三个开关,请参阅 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)会晚一帧,导致错误主题闪现。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,
      };
}

渲染全部三种信号的完整芯片组件: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) 读取。
  • 令牌分为两层:基础令牌按测量值命名,语义槽位按用途命名;组件不得读取基础令牌。
  • 两种亮度的 ColorScheme 均手动定义(用于确立视觉身份时绝不使用 fromSeed);保留 M3 角色名称;每个扩展都附加到两种 ThemeData。
  • 两种主题分别通过 AA 标准,并通过针对已声明前景色/背景色对的 computeLuminance() 单元测试验证(references/contrast-and-redundant-encoding.md)。
  • 已实现 copyWith;lerp 会插值颜色/双精度值,并对不可插值项进行切换(或注明这是有意为之的阶跃变化)。
  • 每种有状态含义至少具有 3 种在灰度下清晰可辨的信号;灰度黄金图仍能回答「这是什么状态?」。
  • 每个动画都读取动态效果令牌,并在 MediaQuery.disableAnimationsOf 下将动画时长缩为零。
  • 首次绘制前恢复已持久化的主题;未知值明确回退,绝不回退到 null。
  • 字体已打包并注册许可证;已声明各文字脚本对应的 fontFamilyFallback;不在运行时获取字体。
  • 绘制器接收令牌快照;paint() 中不得使用 Theme.of(context)。

相关技能

  • 关于从 MediaQuery 读取无障碍状态、颜色不可作为唯一信号的规则、目标尺寸,以及不得限制 textScaler,请参阅 accessibility-as-code。
  • 关于使用令牌快照的绘制器,请参阅 custom-canvas-and-gestures。
  • 关于 const 子树、.select 重建范围控制和 RepaintBoundary,请参阅 flutter-performance。
  • 关于使用读取这些槽位的小型 const 组件构建视图,请参阅 widget-composition。
  • 关于 themeModeProvider 和组合根注入,请参阅 state-management-riverpod。
  • 关于在 runApp 之前恢复主题的 main() 调用顺序,请参阅 app-startup-and-bootstrap。
  • 关于各脚本对应的字体、方向几何和 RTL 黄金图,请参阅 i18n-rtl-l10n。
  • 关于将禁止原始值的 grep 检查提升为 CI 门禁,请参阅 lint-and-style-config。
  • 关于动态效果令牌的用途,请参阅 motion-and-haptics:时刻目录、触觉事件映射,以及每个时刻声明的降级方式。
  • 关于每个应用进行一次截图检查以评估组装后的系统,请参阅 design-review-workflow。

参考资料

评论

支持 Markdown