design-system-structure

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

design-system-structure

design-system-structure

A design system is code that is incapable of holding a stray opinion: every aesthetic value lives in one directory, and every widget reads it back through a named slot. This skill owns how tokens, themes, components, and shapes are structured and consumed — not which colors or radii to pick (that belongs to your design source of truth). Getting the structure right makes a reskin a
diff lib/theme/
and turns a stray
Color(0xFF…)
in feature code into a build failure instead of a style nit.
Read the reference for the task at hand:
  • references/token-tiers-and-themeextension.md
    — two-tier token naming, ThemeExtension mechanics, the asserting
    of()
    , hand-authored
    ColorScheme
    vs
    fromSeed
    , honest
    lerp
    .
  • references/motion-and-reduced-motion.md
    — the three animations Material mounts by default, why
    NoSplash
    is not enough, reduced-motion-means-zero,
    pumpAndSettle
    bans in tests.
  • references/typography-and-fonts.md
    — bundling fonts,
    LicenseRegistry
    ,
    FontWeight
    drives
    wght
    , silent
    FontVariation
    no-ops, subsetting without instancing, per-script fallback cascades.
  • references/contrast-and-redundant-encoding.md
    — AA as a unit test, ≥3 grayscale-legible signals per meaning, reading a11y flags from
    MediaQuery
    .
Run
scripts/check_raw_values.sh
and
scripts/check_font_bundling.sh
before a PR.
设计系统是一套“不允许混入游离主观意见”的代码:每个审美值都集中在同一个目录中,每个 Widget 都通过具名 slot 读回。这个 skill 负责 token、theme、component 和 shape 的结构和消费方式,而不负责决定选哪些颜色或圆角(后者属于你的设计事实来源)。结构正确后,重新换肤只需
diff lib/theme/
,功能代码中游离的
Color(0xFF…)
也会直接导致构建失败,而不只是留下样式瑕疵。
针对当前任务阅读参考资料:
  • references/token-tiers-and-themeextension.md
    ——两级 token 命名、ThemeExtension 机制、带断言的
    of()
    、手写
    ColorScheme
    与
    fromSeed
    、可靠的
    lerp
    。
  • references/motion-and-reduced-motion.md
    ——Material 默认挂载的三种动画、为何
    NoSplash
    仍不够、reduced motion 必须归零,以及测试中禁用
    pumpAndSettle
    。
  • references/typography-and-fonts.md
    ——字体打包、
    LicenseRegistry
    、
    FontWeight
    驱动
    wght
    、静默失效的
    FontVariation
    、无需实例化即可完成字体子集化,以及按文字系统回退的级联。
  • references/contrast-and-redundant-encoding.md
    ——将 AA 作为单元测试、每种含义至少提供 3 种灰度可辨识信号,以及从
    MediaQuery
    读取 a11y 标志。
提交 PR 前运行
scripts/check_raw_values.sh
和
scripts/check_font_bundling.sh
。

Non-negotiable rules

不可协商的规则

  1. No raw aesthetic value outside
    lib/theme/**
    — the gate is the law.
    A
    Color(0x…)
    /
    Colors.*
    (except
    transparent
    ) /
    Curves.*
    /
    Duration(milliseconds:|seconds:
    (except
    Duration.zero
    ) / literal
    BorderRadius.circular(n)
    /
    fontSize: n
    in feature or shared UI code fails
    scripts/check_raw_values.sh
    . A legitimate new need is a new token slot, never a
    // ignore
    — one place to diff is the whole point.
  2. Two token tiers; widgets read only the semantic tier. Tier 1 = primitives named by the measured value (
    neutral12
    = OKLCH lightness ×100), never by rank (
    grey700
    ), appearance (
    darkGrey
    ), or brand (
    brandPrimary
    ). Tier 2 = semantic slots named by role (
    surface
    ,
    onSurface
    ,
    hairline
    ,
    accent
    ). A widget that reaches a Tier-1 primitive has hardcoded one theme. WHY: rank scales have no room to insert and lie in dark mode; appearance names invert catastrophically; brand names die with the brand.
  3. Read tokens through a
    ThemeExtension
    and an asserting
    of(context)
    .
    Never a static
    const
    class, never an
    InheritedWidget
    side channel.
    of()
    asserts on a missing extension — it does not
    ?? fallback
    . WHY:
    MaterialApp
    drives light/dark switching and correct-by-construction invalidation; a fallback silently ships a theme no test verified, and loud-in-debug beats wrong-in-field.
  4. Hand-author
    ColorScheme
    ; never
    ColorScheme.fromSeed
    (or
    dynamic_color
    ) for identity.
    fromSeed
    's per-role overrides do not propagate — override
    surface
    and
    surfaceContainerHigh
    stays seed-derived. State every role you consume, keep the M3 role names so Material widgets theme themselves, and let unstated roles take
    ColorScheme
    's own defaults, not a seed's opinion. WHY: "seed plus a few overrides" means owning the ~9 roles you chose and every seam of the ~40 you didn't.
  5. Layer the system: tokens → theme → modifiers/components → shapes. Author light and dark by hand (dark is never an auto-flip), each passing AA independently, and attach every extension to both
    ThemeData
    s. Inject the theme exactly once, at the composition root. WHY: theming the app must touch one directory; a palette-only dark theme ships light's ratios into the dark.
  6. lerp
    honestly or snap deliberately;
    copyWith
    is mandatory.
    Interpolate colors and doubles; snap non-interpolables (font families,
    ShapeBorder
    ) at
    t < 0.5
    . If your app bans theme animation and you snap everything, leave a comment — a bare step-
    lerp
    reads as unfinished and the next reader will "fix" it. WHY: a new field silently forgotten in
    lerp
    is the classic design-system rot.
  7. Color is a derived token, computed LAST — never a status's only channel. Color is a pure function of a canonical value object (an enum/small class), resolved through a semantic slot at the end; status is never read from color, and the supporting color is a slot read, not a literal. The never-color-alone floor itself — ≥3 non-color signals (glyph, label, weight/position) per stateful meaning — is owned by
    accessibility-as-code
    ; this skill only enforces that the color reinforcing them is a derived slot. WHY: a color not derived from the value object drifts out of sync, and a widget that leans on color alone dies in grayscale.
  8. Reduced motion collapses to zero, not "gentler." Read
    MediaQuery.disableAnimationsOf(context)
    and resolve to
    Duration.zero
    / a cross-fade to the end state — never a shorter duration or a softer curve. Motion is never the only signal for a state change. WHY: a user who asked the OS to stop animations asked for stop.
  9. Restore the persisted theme before first paint. Load the saved theme mode / variant before
    runApp
    , from the same versioned settings the rest of the app uses (not an async
    shared_preferences
    read that lands a frame late). An unknown or corrupt stored value falls back explicitly and visibly, never to a
    null
    that leaves
    of()
    asserting on a device with no debugger. WHY: a flash of the wrong theme is a sudden luminance change the user did not cause.
  10. Bundle fonts; never
    google_fonts
    at runtime.
    Declare fonts in
    pubspec.yaml
    , ship the license text and register it via
    LicenseRegistry
    , and drive weight with
    FontWeight
    (which drives the
    wght
    axis) — not a redundant
    FontVariation('wght', …)
    . Declare a per-script
    fontFamilyFallback
    cascade that ends in a known-good face. WHY:
    google_fonts
    ships an HTTP code path by default;
    FontVariation('opsz'|'ital', …)
    silently no-ops on a font that lacks the axis, so a "working" italic is invisible in review.
  11. Painters snapshot tokens;
    paint()
    never reads
    BuildContext
    .
    Resolve
    Theme.of(context).extension<T>()
    once at the widget layer and pass the values into the
    CustomPainter
    /
    Scene
    fields. WHY: keeps
    paint()
    allocation-free and testable without a
    MaterialApp
    ; see
    custom-canvas-and-gestures
    .
  12. The reduced-motion flag is read from
    MediaQuery
    (
    disableAnimationsOf
    ), never app state
    — it feeds
    resolveMotion
    (this skill's helper, below). Reading the other platform a11y flags (
    boldTextOf
    ,
    highContrastOf
    ,
    textScaler
    ) from
    MediaQuery
    rather than a stale app-state copy is owned by
    accessibility-as-code
    .
  1. lib/theme/**
    之外不得存在原始审美值——门禁就是铁律。
    功能代码或共享 UI 代码中出现
    Color(0x…)
    /
    Colors.*
    (
    transparent
    除外)/
    Curves.*
    /
    Duration(milliseconds:|seconds:
    (
    Duration.zero
    除外)/ 字面量
    BorderRadius.circular(n)
    /
    fontSize: n
    ,都会导致
    scripts/check_raw_values.sh
    失败。合理的新需求应当新增 token slot,绝不能添加
    // ignore
    ——所有变更只需对比一个位置正是其核心价值。
  2. 两级 token;Widget 只能读取语义层。 第 1 层 = 按实测值命名的 primitives(
    neutral12
    = OKLCH lightness ×100),绝不能按等级(
    grey700
    )、外观(
    darkGrey
    )或品牌(
    brandPrimary
    )命名。第 2 层 = 按角色命名的 semantic slots(
    surface
    、
    onSurface
    、
    hairline
    、
    accent
    )。如果 Widget 直接访问第 1 层 primitive,就等于把一个主题写死。原因:等级色阶没有插入空间,而且在暗色模式下会“说谎”;按外观命名的色值在暗色模式下会发生灾难性反转;品牌名则会随品牌消亡。
  3. 通过
    ThemeExtension
    和带断言的
    of(context)
    读取 token。
    绝不能使用静态
    const
    类,也绝不能通过
    InheritedWidget
    旁路读取。
    of()
    在 extension 缺失时触发断言——绝不使用
    ?? fallback
    。原因:
    MaterialApp
    负责浅色/深色切换,并从结构上保证正确地触发失效刷新;fallback 会悄悄交付一份没有任何测试验证过的主题,在调试环境中大声报错胜过在线上环境中静默出错。
  4. 手写
    ColorScheme
    ;具有固定视觉识别时,绝不能使用
    ColorScheme.fromSeed
    (或
    dynamic_color
    )。
    fromSeed
    的逐角色覆盖不会向下传播——覆盖
    surface
    和
    surfaceContainerHigh
    后,其他角色仍由 seed 派生。明确写出你使用的每个角色,保留 M3 role 名称,让 Material Widget 能正确应用主题;未声明的角色采用
    ColorScheme
    自身的默认值,而不是 seed 的主观意见。原因:“seed 加少量覆盖”意味着你既要掌控所选的约 9 个角色,也要掌控未选择的约 40 个角色之间的每一处衔接。
  5. 对系统分层:tokens → theme → modifiers/components → shapes。 手动编写浅色和深色主题(暗色主题绝不能自动翻转),两者各自独立通过 AA,并将每个 extension 同时附加到两个
    ThemeData
    。主题只在 composition root 注入且仅注入一次。原因:应用换肤应只触及一个目录;仅做调色板翻转的暗色主题会把浅色主题的对比度比例带进暗色模式。
  6. 要么如实执行
    lerp
    ,要么有意按步切换;
    copyWith
    是必选项。
    对颜色和 double 进行插值;对不可插值项(字体家族、
    ShapeBorder
    )在
    t < 0.5
    时按步切换。如果应用禁止主题动画并对所有内容执行按步切换,请留下注释——裸露的阶梯式
    lerp
    看起来像尚未完成,下一位读者会把它“修好”。原因:在
    lerp
    中悄悄遗漏新字段,是设计系统腐化的经典问题。
  7. Color 是最后计算的派生 token,绝不能成为状态的唯一通道。 Color 是规范值对象(enum 或小型 class)的纯函数,并在最后通过 semantic slot 解析;绝不能从 Color 反推状态,辅助 Color 也必须读取 slot,而不能是字面量。每个状态含义至少包含 3 种非颜色信号这一下限由
    accessibility-as-code
    负责;本 skill 只强制要求用于强化这些信号的 Color 同样是派生 slot。原因:如果 Color 不是从值对象派生,它就会逐渐失步;只依赖 Color 的 Widget 在灰度下会失效。
  8. Reduced motion 必须归零,而不只是“更温和”。 读取
    MediaQuery.disableAnimationsOf(context)
    ,并解析为
    Duration.zero
    / 淡入淡出至最终状态——绝不能只缩短时长或改用更柔和的曲线。Motion 绝不能成为状态变化的唯一信号。原因:当用户要求操作系统停止动画时,他们要求的就是停止。
  9. 在首次绘制前恢复持久化主题。 在
    runApp
    之前,从应用其余部分所用的同一版本化设置中加载已保存的 theme mode / variant(不能使用晚一帧才完成的异步
    shared_preferences
    读取)。对于未知或损坏的存储值,必须显式且可见地回退,绝不能回退到
    null
    ,否则在无调试器的设备上会导致
    of()
    触发断言。原因:错误主题闪现会造成用户并未主动触发的突发亮度变化。
  10. 打包字体;运行时绝不能使用
    google_fonts
    。
    在
    pubspec.yaml
    中声明字体,随包提供许可证文本并通过
    LicenseRegistry
    注册,使用
    FontWeight
    驱动字重(它会驱动
    wght
    轴),而不是重复使用
    FontVariation('wght', …)
    。声明按文字系统区分的
    fontFamilyFallback
    级联,并以已知可用的字体结尾。原因:
    google_fonts
    默认包含 HTTP 代码路径;
    FontVariation('opsz'|'ital', …)
    在缺少相应轴的字体上会静默失效,因此所谓的“可用斜体”在评审中根本看不出来。
  11. Painter 对 token 做快照;
    paint()
    绝不读取
    BuildContext
    。
    在 Widget 层一次性解析
    Theme.of(context).extension<T>()
    ,再把值传入
    CustomPainter
    /
    Scene
    的字段。原因:这样可以让
    paint()
    无需内存分配,并且无需
    MaterialApp
    也能测试;参见
    custom-canvas-and-gestures
    。
  12. Reduced-motion 标志必须来自
    MediaQuery
    (
    disableAnimationsOf
    ),绝不能来自应用状态。
    它会驱动下文的
    resolveMotion
    helper。其他平台 a11y 标志(
    boldTextOf
    、
    highContrastOf
    、
    textScaler
    )也应从
    MediaQuery
    而不是过期的应用状态副本中读取;这由
    accessibility-as-code
    负责。

The token layering

Token 分层

tokens → theme → modifiers → shapes
. Primitives carry values; a
ThemeExtension
names semantic slots; a
ThemeData
builder folds slots into Material's
ColorScheme
and component themes; shape factories live on their own extension so a component asks for a silhouette, not a radius.
dart
// 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.

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.
}
Full worked file (primitives, a shapes extension with a
cardShape
factory, the hand-authored
ColorScheme
, honest
lerp
, and one-shot injection):
examples/app_theme.dart
.
tokens → theme → modifiers → shapes
。Primitives 承载具体值;
ThemeExtension
为 semantic slots 命名;
ThemeData
builder 将 slots 融入 Material 的
ColorScheme
和 component themes;shape factories 放在自己的 extension 上,让 component 请求的是轮廓,而不是 radius。
dart
// 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.

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.
}
完整示例文件(primitives、带
cardShape
factory 的 shapes extension、手写的
ColorScheme
、可靠的
lerp
,以及一次性注入)见
examples/app_theme.dart
。

Hand-author
ColorScheme
, keep the M3 role names

手写
ColorScheme
,保留 M3 role 名称

dart
// 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,
);
A Material
TextField
reads
surfaceContainerHighest
,
onSurfaceVariant
,
outline
, and
error
on its own — name the roles right and it themes with zero per-widget
InputDecoration
patching that would drift out of sync on the next theme change.
dart
// 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
——只要角色命名正确,它就能自动套用主题,无需对每个 Widget 单独修补
InputDecoration
,也就不会在下次主题变更时发生失步。

Reduced motion: read the flag, collapse to zero

Reduced motion:读取标志并归零

dart
// 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
alone is not enough — the pressed highlight fade is a separate
InkHighlight
, and
MaterialApp
interpolates
ThemeData
over
kThemeAnimationDuration
unless you pass
themeAnimationStyle: AnimationStyle.noAnimation
. See
references/motion-and-reduced-motion.md
for all three switches.
dart
// 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
会在
kThemeAnimationDuration
期间插值
ThemeData
。三个开关的完整说明见
references/motion-and-reduced-motion.md
。

Restore the theme before first paint

在首次绘制前恢复主题

dart
// 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));
}
Persisting the choice but reading it back asynchronously (a bare
shared_preferences
read) lands a frame late and flashes the wrong theme.
state-management-riverpod
covers threading the resolved variant through a
themeModeProvider
;
app-startup-and-bootstrap
covers the
main()
ordering.
dart
// 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
介绍如何把解析后的 variant 通过
themeModeProvider
传递下去;
app-startup-and-bootstrap
介绍
main()
中恢复主题所需的顺序。

Redundant encoding: derive color last

冗余编码:最后派生 Color

The ≥3-non-color-signals floor is owned by
accessibility-as-code
; the design-system contribution shown here is narrower — the supporting color is derived last from a canonical value object through a semantic slot, never read as the source of state:
dart
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,
      };
}
Full chip widget rendering all three signals:
examples/status_encoding.dart
.
每个状态含义至少包含 3 种非颜色信号这一下限由
accessibility-as-code
负责;这里展示的设计系统贡献范围更窄——辅助 Color 必须在最后从规范值对象经 semantic slot 派生出来,绝不能被当作状态的来源:
dart
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,
      };
}
完整 chip Widget 会渲染全部三种信号,见
examples/status_encoding.dart
。

Anti-patterns

反模式

  • Color(0xFF…)
    ,
    Colors.blueGrey
    ,
    Curves.easeOut
    ,
    Duration(milliseconds: 200)
    ,
    BorderRadius.circular(16)
    , or
    fontSize: 14
    in a widget outside
    lib/theme/
    — the gate fails; the fix is a slot read or a new slot, never
    // ignore
    .
  • A static
    AppColors
    /
    Dimens
    const
    class, or
    AppColors.of
    returning
    ?? AppColors.fallback()
    — slots ride a
    ThemeExtension
    , and a fallback ships a palette no test ever verified.
  • ColorScheme.fromSeed(...)
    for a product with an identity, or adding
    dynamic_color
    — the overrides don't propagate and wallpaper-derived palettes are untestable at build time.
  • A palette-only dark theme, or extensions attached to the light
    ThemeData
    only
    — dark silently renders defaults and theme switches snap.
  • A
    lerp
    that returns
    this
    unlabeled, or that forgets a newly added field
    — the theme snaps or a slot goes un-interpolated forever.
  • Color as the only status channel — recoloring is fine; removing the glyph/label/weight it accompanies is a bug that vanishes in grayscale.
  • Choosing a shorter duration or softer curve under reduced motion — collapse to
    Duration.zero
    ; the user asked for stop.
  • A bare
    shared_preferences
    read for theme, corrected a frame after
    runApp
    — a flash of the wrong theme is a luminance jolt; restore before first paint.
  • import 'package:google_fonts/…'
    or
    FontVariation('opsz'|'ital', …)
    — the first ships an HTTP path; the second no-ops silently on a font without the axis.
  • A
    CustomPainter
    calling
    Theme.of(context)
    inside
    paint()
    — snapshot tokens into painter fields at the widget layer.
  • A token pipeline (DTCG JSON, Style Dictionary, Figma sync codegen) for a small single-package app — it buys a
    node_modules
    build-failure mode to solve a designer/engineer handoff that, when it's the same person, does not exist.
  • Color(0xFF…)
    、
    Colors.blueGrey
    、
    Curves.easeOut
    、
    Duration(milliseconds: 200)
    、
    BorderRadius.circular(16)
    或
    fontSize: 14
    出现在
    lib/theme/
    之外的 Widget 中
    ——门禁会失败;修复方式是读取 slot 或新增 slot,绝不能使用
    // ignore
    。
  • 静态
    AppColors
    /
    Dimens
    const
    类,或
    AppColors.of
    返回
    ?? AppColors.fallback()
    ——slot 应依附于
    ThemeExtension
    ;fallback 会交付一份从未经过测试验证的调色板。
  • 对具有固定视觉识别的产品使用
    ColorScheme.fromSeed(...)
    ,或加入
    dynamic_color
    ——覆盖项不会传播,而壁纸生成的调色板无法在构建时进行测试。
  • 只有调色板的暗色主题,或 extension 仅附加到浅色
    ThemeData
    ——暗色模式会悄悄渲染默认值,主题切换也会发生跳变。
  • lerp
    未加说明便返回
    this
    ,或忘记新增字段
    ——主题会跳变,或某个 slot 永远得不到插值。
  • Color 是状态的唯一通道——重新着色没有问题,但移除与其一同出现的 glyph/label/weight 是会在灰度环境中被掩盖的缺陷。
  • 在 reduced motion 下选择更短的时长或更柔和的曲线——应归零为
    Duration.zero
    ;用户要求的是停止。
  • 用裸
    shared_preferences
    读取主题,并在
    runApp
    后一帧才修正
    ——错误主题闪现会造成一次亮度冲击;必须在首次绘制前恢复。
  • import 'package:google_fonts/…'
    或
    FontVariation('opsz'|'ital', …)
    ——前者包含 HTTP 路径;后者在字体缺少相应轴时会静默失效。
  • CustomPainter
    在
    paint()
    内调用
    Theme.of(context)
    ——应在 Widget 层把 token 快照传入 Painter。
  • 为小型单包应用引入 token pipeline(DTCG JSON、Style Dictionary、Figma sync codegen)——它会引入一种
    node_modules
    构建失败模式,去解决一个当设计师与工程师本就是同一个人时根本不存在的交接问题。

Definition of done

完成定义

  • scripts/check_raw_values.sh
    and
    scripts/check_font_bundling.sh
    are clean over
    lib/
    (raw values confined to
    lib/theme/**
    ; no
    google_fonts
    ).
  • Every aesthetic value a widget renders traces to a named semantic slot on a
    ThemeExtension
    , read via an asserting
    of(context)
    .
  • Tokens are two-tier: primitives named by measured value, semantic slots named by role; no widget reads a primitive.
  • ColorScheme
    is hand-authored for both brightnesses (never
    fromSeed
    for identity); M3 role names kept; every extension attached to both
    ThemeData
    s.
  • Both themes pass AA independently, verified as a
    computeLuminance()
    unit test over declared fg/bg pairs (
    references/contrast-and-redundant-encoding.md
    ).
  • copyWith
    implemented;
    lerp
    interpolates colors/doubles and snaps non-interpolables (or is a commented deliberate step).
  • Every stateful meaning carries ≥3 grayscale-legible signals; a greyscale golden still answers "what state is this?".
  • Every animation reads a motion token and collapses to zero under
    MediaQuery.disableAnimationsOf
    .
  • The persisted theme is restored before first paint; unknown values fall back explicitly, never to
    null
    .
  • Fonts are bundled and license-registered; per-script
    fontFamilyFallback
    declared; no runtime font fetch.
  • Painters receive a token snapshot; no
    Theme.of(context)
    in
    paint()
    .
  • scripts/check_raw_values.sh
    和
    scripts/check_font_bundling.sh
    对
    lib/
    检查无报错(原始值仅限
    lib/theme/**
    ;不存在
    google_fonts
    )。
  • Widget 渲染的每个审美值都能追溯到
    ThemeExtension
    上具名的 semantic slot,并通过带断言的
    of(context)
    读取。
  • Token 分为两级:primitives 按实测值命名,semantic slots 按角色命名;任何 Widget 都不读取 primitive。
  • ColorScheme
    为两种 brightness 手动编写(固定视觉识别时绝不使用
    fromSeed
    );保留 M3 role 名称;每个 extension 都同时附加到两个
    ThemeData
    。
  • 两种主题各自独立通过 AA,并通过针对已声明前景色/背景色组合的
    computeLuminance()
    单元测试进行验证(
    references/contrast-and-redundant-encoding.md
    )。
  • copyWith
    已实现;
    lerp
    会插值颜色/double,并对不可插值项执行按步切换(或明确注明这是有意为之的阶梯行为)。
  • 每种状态含义都带有至少 3 种灰度可辨识信号;灰度 golden 仍能回答“当前是什么状态?”
  • 每个动画都读取 motion token,并在
    MediaQuery.disableAnimationsOf
    下归零。
  • 持久化主题在首次绘制前恢复;未知值显式回退,绝不回退到
    null
    。
  • 字体已打包并注册许可证;已声明按文字系统区分的
    fontFamilyFallback
    ;不存在运行时字体获取。
  • Painter 接收 token 快照;
    paint()
    中没有
    Theme.of(context)
    。

Related skills

相关 skill

  • See
    accessibility-as-code
    for reading a11y state from
    MediaQuery
    , the never-color-alone rule, target sizes, and never clamping
    textScaler
    .
  • See
    custom-canvas-and-gestures
    for the painter that consumes the token snapshot.
  • See
    flutter-performance
    for
    const
    subtrees,
    .select
    rebuild scoping, and
    RepaintBoundary
    .
  • See
    widget-composition
    for building views from small
    const
    widgets that read these slots.
  • See
    state-management-riverpod
    for the
    themeModeProvider
    and composition-root injection.
  • See
    app-startup-and-bootstrap
    for the
    main()
    ordering that restores the theme before
    runApp
    .
  • See
    i18n-rtl-l10n
    for per-script fonts, directional geometry, and RTL goldens.
  • See
    lint-and-style-config
    for promoting the no-raw-values grep into a CI gate.
  • See
    motion-and-haptics
    for what the motion tokens are spent on: the moment catalog, the haptic event map, and the degradation each moment declares.
  • See
    design-review-workflow
    for the once-per-app screenshot sweep that judges the assembled system.
  • 参见
    accessibility-as-code
    ,了解如何从
    MediaQuery
    读取 a11y 状态、禁止仅用颜色传达信息、目标尺寸,以及绝不限制
    textScaler
    。
  • 参见
    custom-canvas-and-gestures
    ,了解消费 token 快照的 Painter。
  • 参见
    flutter-performance
    ,了解
    const
    子树、
    .select
    重建范围和
    RepaintBoundary
    。
  • 参见
    widget-composition
    ,了解如何构建读取这些 slot 的小型
    const
    Widget。
  • 参见
    state-management-riverpod
    ,了解
    themeModeProvider
    和 composition-root 注入。
  • 参见
    app-startup-and-bootstrap
    ,了解在
    runApp
    前恢复主题的
    main()
    顺序。
  • 参见
    i18n-rtl-l10n
    ,了解按文字系统区分的字体、方向性几何结构和 RTL golden。
  • 参见
    lint-and-style-config
    ,了解如何把 no-raw-values grep 提升为 CI 门禁。
  • 参见
    motion-and-haptics
    ,了解 motion token 的用途:moment catalog、haptic event map,以及每个 moment 声明的降级行为。
  • 参见
    design-review-workflow
    ,了解针对整个应用执行一次的截图审查流程,该流程用于评判组装后的系统。

References

参考资料