关于 design-system-structure
面向 Flutter 开发者的设计系统结构技能,说明如何组织令牌、主题、组件和形状,并规范其使用方式。重点是将审美值集中管理,通过 ThemeExtension 提供语义令牌,并检查原始样式值和字体打包。
主要功能
- 01划分基础与语义令牌层级
- 02通过 ThemeExtension 读取主题令牌
- 03手动构建浅色与深色 ColorScheme
- 04将形状与组件主题分层组织
- 05为减少动画设置零时长
- 06脚本检查原始样式值和字体打包
适用场景
- 01为 Flutter 应用建立主题目录结构
- 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
设计系统是无法容纳任何随意主张的代码:所有美学值都存放在一个目录中,每个组件都通过具名槽位读取这些值。本技能负责规范令牌、主题、组件和形状的组织方式与使用方式,而不是决定选择哪些颜色或圆角(这些应由设计单一事实来源决定)。正确的结构能让换肤变成对 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。
不可妥协的规则
lib/theme/**之外不允许出现原始美学值——检查门槛就是铁律。 在功能或共享 UI 代码中使用Color(0x…)/Colors.*(transparent除外)/Curves.*/Duration(milliseconds:|seconds:(Duration.zero除外)/ 字面量BorderRadius.circular(n)/fontSize: n,都会导致scripts/check_raw_values.sh检查失败。确有合理的新需求时,应新增一个 令牌槽位,绝不能使用// ignore——重点就在于能集中查看所有改动。- 令牌分两层;组件只能读取语义层。 第 1 层 = 按 测量值 命名的原语(
neutral12= OKLCH 亮度 ×100),绝不按等级(grey700)、外观(darkGrey)或品牌(brandPrimary)命名。第 2 层 = 按用途命名的语义槽位(surface、onSurface、hairline、accent)。组件若直接读取第 1 层原语,就等于硬编码了一个主题。原因:等级色阶没有插入空间,且在深色模式下会产生误导;外观名称会造成灾难性的反转;品牌名称会随品牌消亡。 - 通过
ThemeExtension和带断言的of(context)读取令牌。 绝不使用静态const类,也绝不通过InheritedWidget另辟通道。缺少扩展时,of()会触发断言——不会使用?? fallback。原因:MaterialApp负责浅色/深色切换和确保变更能正确触发失效;回退值会悄悄发布未经任何测试验证的主题,而在调试模式下明确报错,胜过在实际环境中用错主题。 - 手动编写
ColorScheme;绝不使用ColorScheme.fromSeed(或dynamic_color)定义主题特征。fromSeed对各角色的覆盖值 不会传递——覆盖surface后,surfaceContainerHigh仍会由种子色派生。明确设置每个实际使用的角色,保留 M3 角色名称,以便 Material 组件自行应用主题;未明确设置的角色应使用ColorScheme自身的默认值,而非种子色的配置。原因:“种子色加少量覆盖”意味着你不仅要负责所选的约 9 个角色,还要负责未选的约 40 个角色之间的所有衔接。 - 分层构建系统:令牌 → 主题 → 修饰器/组件 → 形状。 手动定义浅色 和 深色主题(深色主题绝不是自动翻转得来),两者都必须各自通过 AA,并将每个扩展附加到 两个
ThemeData上。在组合根中恰好注入一次主题。原因:为应用设置主题只应涉及一个目录;只切换调色板的深色主题,会把浅色主题的对比度比例带到深色主题中。 - 如实实现
lerp,或明确采用跳变;copyWith是必需的。 对颜色和双精度值进行插值;对不可插值的值(字体族、ShapeBorder),在t < 0.5时跳变。如果应用禁止主题动画且所有值都采用跳变,应添加注释——仅有一个直接跳变的lerp看起来像是尚未完成,后续读者还会试图“修复”它。原因:新增字段被悄悄漏在lerp之外,是设计系统腐化的典型问题。 - 颜色是最后计算的派生令牌,绝不能成为状态的唯一通道。 颜色应是规范值对象(枚举/小型类)的纯函数,并在最后通过语义槽位解析;状态绝不能从颜色中读取,辅助颜色必须读取槽位,而非使用字面量。每种有状态含义至少需要 3 个非颜色信号(图形符号、标签、粗细/位置),以落实“绝不能只靠颜色”的底线;这由
accessibility-as-code负责。本技能只要求强化这些信号的颜色必须来自派生槽位。原因:如果颜色不是从值对象派生出来的,就会与其失去同步;而只依赖颜色的组件在灰度模式下会失效。 - 减少动态效果时应将其归零,而不是“柔和一些”。 读取
MediaQuery.disableAnimationsOf(context),并解析为Duration.zero/向最终状态交叉淡化——绝不缩短动画时长或使用更柔和的曲线。动态效果绝不能成为状态变化的唯一信号。原因:用户要求操作系统停止动画,就是要求停止动画。 - 首帧绘制前恢复已持久化的主题。 在
runApp之前,从应用其他部分使用的同一版本化设置中加载已保存的主题模式/变体(不要异步读取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(本技能下方提供的辅助函数)。至于从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/Dimensconst类,或返回?? 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。
参考资料
- 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