Flutter DuskMoon UI — Design Principles & Usage Rules
Architecture Overview
Package Dependency Graph (import direction →)
duskmoon-dev/design (YAML → codegen)
→ duskmoon_theme
├ DmDesignTokens (generated const data)
├ DmTheme (InheritedWidget)
├ DmPlatformStyle { material, cupertino, fluent }
└ toMaterial() / toCupertino() / toFluent()
duskmoon_theme
→ duskmoon_widgets
├ DuskmoonApp (root shell)
├ DmAdaptiveWidget (base class)
└ Dm* widgets (Button, TextField, Switch, etc.)
duskmoon_widgets
→ duskmoon_settings
→ duskmoon_feedback
→ duskmoon_ui (umbrella re-export)
Rule: Never import downstream. must never import from
.
must never import from
.
Widget Tree (Runtime)
DuskmoonApp(tokens: .sunshine, darkTokens: .moonlight, platformStyle: .cupertino)
└→ DmTheme (InheritedWidget — .of(context) available everywhere below)
└→ CupertinoApp(theme: tokens.toCupertino())
└→ user's widget tree
└→ DmButton(label: "Save") // dispatches to CupertinoButton
Rule 1: Theme Setup
Correct App Root
dart
// ✅ ALWAYS — use DuskmoonApp as the root widget
void main() {
runApp(
DuskmoonApp(
tokens: DmDesignTokens.sunshine,
darkTokens: DmDesignTokens.moonlight,
themeMode: ThemeMode.system,
platformStyle: DmPlatformStyle.material,
home: const MyHomePage(),
),
);
}
❌ NEVER — bypass DuskmoonApp
dart
// ❌ NEVER wrap MaterialApp/CupertinoApp directly
void main() {
runApp(MaterialApp(
theme: DmDesignTokens.sunshine.toMaterial(), // wrong — skips DmTheme
home: MyHomePage(),
));
}
Why: injects
above the platform app. Without it,
returns null and all Dm* widgets fail to resolve tokens.
Rule 2: Accessing Design Tokens
Always use
dart
// ✅ Read tokens from the widget tree
Widget build(BuildContext context) {
final tokens = DmTheme.of(context).tokens;
return Container(
color: tokens.primaryContainer,
child: Text('Hello', style: TextStyle(color: tokens.onPrimaryContainer)),
);
}
❌ NEVER reference generated constants directly in widget builds
dart
// ❌ This ignores dark mode, theme overrides, and subtree overrides
Widget build(BuildContext context) {
return Container(color: DmDesignTokens.sunshine.primaryContainer);
}
Why: The resolved tokens depend on
, platform brightness, and possible
ancestors. Only
returns the correct resolved set.
Exception — static adapter methods
itself must convert tokens to platform
before
exists in the tree. For this case only, use the static adapters:
dart
// Inside DuskmoonApp.build() — acceptable
MaterialApp(theme: DmTheme.staticToMaterial(resolvedTokens));
Rule 3: Color System
Token Structure (61 color tokens per theme)
| Group | Tokens | Usage |
|---|
| Primary | , , , | Main brand actions, primary CTAs |
| Secondary | , , , | Supporting actions, alternative CTAs |
| Tertiary | , , , | Accent highlights, badges, special UI |
| Error | , , , | Error states, destructive actions |
| Surface | , , , , →, , | Backgrounds, cards, elevation |
| Outline | , | Borders, dividers |
| Inverse | , , | Snackbars, contrast overlays |
| Scrim/Shadow | (with alpha), | Modal overlays, elevation shadows |
| Semantic | , , (+ content variants) | Status indicators |
Color Format: OKLCH
All colors are defined in OKLCH in the source CSS. The Dart codegen converts to
objects via inline OKLCH→sRGB math (zero external deps).
❌ NEVER hardcode color values
dart
// ❌ Hardcoded hex
Container(color: Color(0xFF60A5FA))
// ❌ Hardcoded Material color
Container(color: Colors.blue)
// ✅ Use design tokens
Container(color: DmTheme.of(context).tokens.primary)
Semantic color pairing rule
Every background token has a corresponding foreground token. Always pair them:
dart
// ✅ Correct pairing
Container(
color: tokens.primaryContainer,
child: Text('Label', style: TextStyle(color: tokens.onPrimaryContainer)),
)
// ❌ Mismatched — onSurface on primaryContainer may fail contrast
Container(
color: tokens.primaryContainer,
child: Text('Label', style: TextStyle(color: tokens.onSurface)),
)
Surface elevation hierarchy
Use surface container tokens for visual depth, not opacity or shadows alone:
surfaceContainerLowest → bottom layer (behind everything)
surfaceContainerLow → low-elevation cards
surfaceContainer → standard cards/containers
surfaceContainerHigh → elevated cards, menus
surfaceContainerHighest → dialogs, tooltips, top layer
Rule 4: Available Themes
5 themes defined in
, codegen'd to Dart:
| Theme | Mode | Primary Character |
|---|
| light | Warm amber/gold |
| dark | Cool blue/lavender |
| dark | Deep blue/teal |
| light | Natural green/earth |
| light | Warm orange/rose |
Rule 5: Platform Adaptive Widgets
Resolution Stack (highest priority first)
L1: Per-widget `platformOverride` parameter
L2: Nearest DmPlatformOverride ancestor (subtree override)
L3: DmTheme.of(context).platformStyle (from DuskmoonApp)
L4: defaultTargetPlatform (auto-detect)
Writing an adaptive widget
All adaptive widgets extend
:
dart
class DmButton extends DmAdaptiveWidget {
const DmButton({super.key, required this.label, super.platformOverride});
final String label;
@override
Widget buildMaterial(BuildContext context, DmDesignTokens tokens) {
return FilledButton(onPressed: () {}, child: Text(label));
}
@override
Widget buildCupertino(BuildContext context, DmDesignTokens tokens) {
return CupertinoButton.filled(onPressed: () {}, child: Text(label));
}
// buildFluent defaults to buildMaterial unless overridden
}
❌ NEVER check platform manually
dart
// ❌ Manual platform switching
if (Platform.isIOS) {
return CupertinoButton(...);
} else {
return ElevatedButton(...);
}
// ✅ Use DmAdaptiveWidget dispatch or DmPlatformStyle resolution
class MyWidget extends DmAdaptiveWidget { ... }
File structure for adaptive widgets
dm_button/
├── dm_button.dart # Public API, extends DmAdaptiveWidget
├── dm_button_material.dart # buildMaterial implementation
├── dm_button_cupertino.dart # buildCupertino implementation
└── dm_button_fluent.dart # buildFluent (optional, falls through to material)
Rule 6: Shared Design Enums
All Dm* widgets share these semantic enums. Use them consistently — never invent ad-hoc parameters.
dart
enum DmColorRole { primary, secondary, tertiary, error, neutral }
enum DmSize { xs, sm, md, lg, xl }
enum DmButtonVariant { filled, outlined, ghost, tonal }
enum DmInputVariant { outlined, filled, underlined }
Color resolution from DmColorRole
Every widget that takes
resolves tokens identically:
| DmColorRole | Background | Foreground | Container | On Container |
|---|
| | | | tokens.onPrimaryContainer
|
| | | tokens.secondaryContainer
| tokens.onSecondaryContainer
|
| | | | tokens.onTertiaryContainer
|
| | | | |
| | | tokens.surfaceContainerHigh
| |
Size scale
| DmSize | Horizontal padding | Vertical padding | Font scale |
|---|
| 8 | 4 | 0.75rem (12) |
| 12 | 6 | 0.875rem (14) |
| 16 | 8 | 0.875rem (14) |
| 24 | 12 | 1rem (16) |
| 32 | 16 | 1.125rem (18) |
Rule 7: Component Design — Actions
DmButton
| Variant | Background | Foreground | Border | Use case |
|---|
| role color | onRole | none | Primary CTAs, main actions |
| transparent | role color | role color | Secondary actions, cancel |
| transparent | role color | none | Tertiary/inline actions, links |
| roleContainer | onRoleContainer | none | Soft emphasis, toggles |
Color role assignment convention:
| Action type | Color role | Example |
|---|
| Main CTA, save, submit, confirm | | "Save Changes" |
| Alternative action, secondary flow | | "Export", "Share" |
| Accent action, special highlight | | "Watch Demo", "Premium" |
| Destructive, delete, remove | | "Delete Account" |
| Neutral, dismiss, low emphasis | | "Cancel", "Skip" |
dart
// ✅ Typical action group
Row(children: [
DmButton(variant: .ghost, color: .neutral, child: Text('Cancel')),
DmButton(variant: .outlined, color: .secondary, child: Text('Save Draft')),
DmButton(variant: .filled, color: .primary, child: Text('Publish')),
])
DmIconButton
Same color/size system as DmButton.
Must always have .
dart
DmIconButton(
icon: Icons.delete,
color: DmColorRole.error,
semanticLabel: 'Delete item',
onPressed: () {},
)
DmFab (Floating Action Button)
- Default color: — the single most important action on the screen
- Surface: background, icon
- Rule: Maximum one FAB per screen. If you need multiple actions, use .
dart
DmFab(
onPressed: () {},
icon: Icons.add,
// FAB always uses primaryContainer/onPrimaryContainer — no color param
)
DmActionList
Adapts rendering to available space:
| Breakpoint | Rendering |
|---|
| Small (< 600) | Popup menu (overflow) |
| Medium (600–1200) | Icon buttons in row |
| Large (> 1200) | Text buttons with icons |
dart
DmActionList(
actions: [
DmAction(icon: Icons.edit, label: 'Edit', onPressed: ...),
DmAction(icon: Icons.share, label: 'Share', onPressed: ...),
DmAction(icon: Icons.delete, label: 'Delete', color: DmColorRole.error, onPressed: ...),
],
)
Rule 8: Component Design — Navigation
DmAppBar
Default token mapping:
| Element | Token | Rationale |
|---|
| Background | | Brand presence, top-level identity |
| Title text | | Contrast on primary |
| Icon buttons | | Consistent with primary surface |
| Bottom border | none (primary fills) | Clean branded bar |
Scrolled/elevated state: Background transitions to
, text to
.
dart
DmAppBar(
title: Text('Settings'),
leading: DmIconButton(icon: Icons.arrow_back, semanticLabel: 'Back'),
actions: [
DmIconButton(icon: Icons.search, semanticLabel: 'Search'),
DmIconButton(icon: Icons.more_vert, semanticLabel: 'More options'),
],
)
Neutral variant: For screens where the app bar should not compete with content (e.g., content-heavy reading views), pass
color: DmColorRole.neutral
to fall back to
/
.
DmBottomNav
| Element | Token |
|---|
| Background | |
| Selected icon/label | |
| Unselected icon/label | at 70% opacity |
| Selected indicator | (pill behind icon) |
| Top border | none (primary fills) |
Rule: 3–5 destinations maximum. Labels always visible (not icon-only).
DmTabBar
| Element | Token |
|---|
| Background | |
| Selected tab | (indicator + text) |
| Unselected tab | |
| Indicator | (bottom line in Material, pill in Cupertino) |
DmDrawer
| Element | Token |
|---|
| Background | |
| Header area | |
| Selected item bg | at 15% opacity |
| Selected item text | |
| Unselected text | at 70% opacity |
| Dividers | at 20% opacity |
| Scrim (overlay behind drawer) | with alpha |
Side menus and drawers use the secondary color family to visually distinguish navigation chrome from the primary-branded top bar.
DmBreadcrumbs
| Element | Token |
|---|
| Active (current) | (no link) |
| Ancestors (links) | |
| Separator | |
Rule 9: Component Design — Layout & Cards
DmCard
Elevation hierarchy via surface tokens:
| Card style | Background token | Use case |
|---|
| Flat | | Inline content, no separation |
| Outlined | + border | List items, settings rows |
| Elevated | | Standard cards |
| Filled | | Emphasized/grouped content |
Interior layout convention:
┌─────────────────────────────────┐
│ [optional media/image] │
├─────────────────────────────────┤
│ Title (onSurface) │
│ Subtitle (onSurfaceVariant)│
│ │
│ Body text (onSurface) │
│ │
│ ┌─────────────────────────────┐ │
│ │ Actions: ghost/outlined btns│ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
dart
DmCard(
style: DmCardStyle.elevated,
child: Column(children: [
Image(...),
Padding(
padding: EdgeInsets.all(16),
child: Column(children: [
Text('Title', style: TextStyle(color: tokens.onSurface)),
Text('Subtitle', style: TextStyle(color: tokens.onSurfaceVariant)),
Row(children: [
DmButton(variant: .ghost, child: Text('Cancel')),
DmButton(variant: .filled, child: Text('Confirm')),
]),
]),
),
]),
)
❌ NEVER put a
primary card background with
text for regular content cards. Primary/secondary/tertiary containers are for
interactive highlights (selected state, feature callout), not default card backgrounds.
DmDivider
| Variant | Token | Use case |
|---|
| Default | | Section separation |
| Strong | | Major section breaks |
DmScaffold
Responsive layout dispatch:
| Breakpoint | Navigation style |
|---|
| Compact (< 600) | |
| Medium (600–1200) | (collapsed) |
| Expanded (> 1200) | (expanded with labels) |
Page body background:
. Rail/side nav background:
.
Rule 10: Component Design — Data Display (Bricks)
DmBadge
Small status/count indicator. Takes
.
| Variant | Background | Foreground | Use case |
|---|
| Filled | role color | onRole | Notification count, status dot |
| Tonal | roleContainer | onRoleContainer | Soft label, category tag |
Default:
(notification convention),
dart
DmBadge(count: 3) // red notification dot
DmBadge(label: 'New', color: .tertiary, variant: .tonal) // soft accent tag
DmBadge(label: 'Draft', color: .neutral, variant: .tonal) // muted status
DmChip
Selectable/filterable labels. Takes
.
| State | Background | Foreground | Border |
|---|
| Unselected | | | |
| Selected | | | none |
| Disabled | at 38% opacity | at 38% | at 12% |
Default selection color:
— secondary containers are for selection states.
DmAvatar
| Variant | Background | Foreground |
|---|
| With image | — | — |
| Initials (default) | | |
| Initials (group variety) | Cycle through // containers | Matching |
Sizes follow
enum. Default:
(40dp diameter).
DmStat (Data Brick)
Statistics display block:
┌───────────────┐
│ 1,234 │ ← value: onSurface, large/bold
│ Active Users │ ← label: onSurfaceVariant, small
│ ▲ 12.5% │ ← trend: success or error token
└───────────────┘
| Element | Token |
|---|
| Value | |
| Label | |
| Positive trend | (or ) |
| Negative trend | |
| Card background | (when in card) |
DmTable / Data Grid
| Element | Token |
|---|
| Header row bg | |
| Header text | (bold) |
| Body row bg (even) | |
| Body row bg (odd) | |
| Body text | |
| Row hover | |
| Selected row | |
| Border/grid lines | |
| Sort indicator | |
Rule 11: Component Design — Feedback
DmAlert
| Semantic | Background | Foreground | Icon color |
|---|
| Info | (or + info icon) | | |
| Success | | | |
| Warning | | | |
| Error | | | |
Convention: Alerts use semantic container tokens with full-width layout. For inline indicators, use
.
DmDialog
| Element | Token |
|---|
| Scrim (backdrop) | with alpha |
| Dialog surface | |
| Title | |
| Body | |
| Confirm button | DmButton(variant: .filled, color: .primary)
|
| Cancel button | DmButton(variant: .ghost, color: .neutral)
|
| Destructive confirm | DmButton(variant: .filled, color: .error)
|
DmSnackbar
Uses inverse tokens for contrast against current theme:
| Element | Token |
|---|
| Background | |
| Text | |
| Action button | |
DmProgress
Linear and circular variants. Default color:
.
| Variant | Track | Indicator |
|---|
| Default | | |
| With color role | (at low opacity) | role color |
DmSkeleton
Loading placeholder. Uses
with shimmer animation toward
.
Rule 12: Component Design — Inputs
DmTextField
| Variant | Idle | Focused | Error |
|---|
| border | border (2px) | border |
| bg | bottom indicator | indicator |
| bottom line | bottom line (2px) | line |
| Element | Token |
|---|
| Input text | |
| Placeholder/hint | |
| Label (floating) | → when focused |
| Helper text | |
| Error text | |
| Prefix/suffix icon | |
DmCheckbox / DmSwitch / DmSlider
| State | Token |
|---|
| Unchecked/off | (border), (fill) |
| Checked/on | (fill), (checkmark) |
| Track (switch off) | |
| Track (switch on) | at 50% → |
| Thumb | (off) → (on, over primary track) |
| Slider active track | |
| Slider inactive track | |
| Slider thumb | |
| Disabled | All at 38% opacity |
Rule 13: Visual Design Principles
Hierarchy through token roles, not through ad-hoc colors
Primary → THE action (one per screen section)
Secondary → supporting actions, selection states
Tertiary → accents, highlights, special callouts
Surface → everything else (backgrounds, text, structure)
If you need emphasis, promote the token role — don't invent a color.
Density and spacing
DuskMoon follows MD3 density: default padding 16dp, compact 12dp, comfortable 24dp. Widget padding follows the
scale.
Elevation = surface tokens, not shadows
Use
→
for visual hierarchy. Shadows (
token) are supplementary, not the primary depth cue.
dart
// ✅ Surface-token elevation
Container(color: tokens.surfaceContainerHigh) // elevated
Container(color: tokens.surface) // base level
// ❌ Shadow-only elevation
Container(
decoration: BoxDecoration(
color: tokens.surface,
boxShadow: [BoxShadow(blurRadius: 8)], // shadow without surface distinction
),
)
Dark mode is not "invert everything"
Each theme has its own curated token set. The codegen produces distinct values per theme. Never compute dark colors by inverting or dimming light colors at runtime.
dart
// ❌ Never compute dark variants
final darkBg = Color.lerp(tokens.surface, Colors.black, 0.3);
// ✅ Use the dark theme's own tokens
DuskmoonApp(tokens: .sunshine, darkTokens: .moonlight) // moonlight has its own curated values
Rule 14: Package Boundaries
(Architecture rules — same as above, renumbered for continuity)
What goes where
| Package | Contains | Does NOT contain |
|---|
| , , , / / adapters | Any widgets, any -dependent rendering |
| , , all widgets | Token definitions, theme adapters |
| , for runtime theme switching | Widget implementations |
| Settings UI widgets built on adaptive dispatch | Theme internals |
| Feedback/bug-report widgets | Theme internals |
| Umbrella — re-exports all above | No unique code |
❌ NEVER add duskmoon_widgets as dependency of duskmoon_theme
This creates a circular dependency. If
needs to reference a widget concept, use an abstract interface or callback, not a concrete widget import.
Rule 15: Code Engine Integration
has
zero dependency on
. The theme adapter lives as an extension method in
:
dart
// In duskmoon_theme — NOT in duskmoon_code_engine
extension DmCodeEngineTheme on DmDesignTokens {
CodeEditorTheme toCodeEditorTheme() => CodeEditorTheme(
background: surface,
foreground: onSurface,
// ...
);
}
Rule 16: Codegen Pipeline
duskmoon-dev/design YAML
→ Bun/TypeScript emitter
→ CSS (duskmoonui consumption)
→ TypeScript (duskmoonui/duskmoon-elements)
→ Dart (flutter_duskmoon_ui — committed, CI never needs Bun)
→ JSON (documentation/tooling)
Generated Dart files are committed to git. CI must never require Bun or Node to build the Flutter packages.
❌ NEVER hand-edit generated files
Files in
packages/duskmoon_theme/lib/src/generated/
are produced by codegen. Edit the YAML source in
and re-run the pipeline.
Rule 17: Accessibility
- All color pairings must meet WCAG 2.1 AA contrast (4.5:1 normal text, 3:1 large text)
- Every interactive Dm* widget must support keyboard navigation
- Semantic labels required on all icon-only buttons
- Focus indicators must be visible on all themes
Rule 18: Testing Patterns
Widget tests must verify all three platforms
dart
for (final style in DmPlatformStyle.values) {
testWidgets('DmButton renders on $style', (tester) async {
await tester.pumpWidget(
DuskmoonApp(
tokens: DmDesignTokens.sunshine,
platformStyle: style,
home: const DmButton(label: 'Test'),
),
);
expect(find.text('Test'), findsOneWidget);
});
}
Theme tests must verify token resolution
dart
testWidgets('DmTheme.of resolves correct tokens', (tester) async {
late DmDesignTokens resolved;
await tester.pumpWidget(
DuskmoonApp(
tokens: DmDesignTokens.sunshine,
home: Builder(builder: (context) {
resolved = DmTheme.of(context).tokens;
return const SizedBox();
}),
),
);
expect(resolved.primary, equals(DmDesignTokens.sunshine.primary));
});
Quick Reference: Anti-Patterns
| ❌ Don't | ✅ Do |
|---|
MaterialApp(theme: tokens.toMaterial())
as root | as root |
DmDesignTokens.sunshine.primary
in widget build | DmTheme.of(context).tokens.primary
|
| or | / |
| for widget dispatch | Extend |
| Hand-edit Dart files | Edit YAML source, re-run codegen |
| Import from | Keep dependency direction strict |
| Put theme adapter in | Extension method in |
| bg + text | Pair + |
| AppBar background = | AppBar background = (DuskMoon convention) |
| Drawer/side menu bg = | Drawer/side menu bg = (navigation chrome distinction) |
| Card default bg = | Card bg = / + outline |
| Shadows as primary depth cue | Surface container tokens for elevation hierarchy |
Color.lerp(x, Colors.black, 0.3)
for dark mode | Use the dark theme's own curated tokens |
| Multiple FABs on one screen | One FAB max; use for multiple |
| Icon button without | Always provide on |
| Selection highlight with | Selection states use |
| Inventing colors outside the token system | Promote token role (primary→secondary→tertiary) |
Checklist for Code Review
When reviewing Flutter code that uses DuskMoon UI, verify:
Architecture:
Color & Tokens:
Component Design:
Accessibility: