Skip to content

Day 3: The design system and Widgetbook

Today you read how Footrace gets its look: a file of named constants, a type scale, a theme extension that every widget reads through context.fr, and the Fr* components built on top of them. Then you run Widgetbook, the component catalog, and change a component while watching it update. This is the vocabulary every screen from Day 4 onwards is written in; the leaderboard yesterday was already using it.

Day 2 covered build(), StatelessWidget versus StatefulWidget, layout with Column/Row/Expanded, and Theme.of(context) as a lookup up the tree. You also saw footraceTheme() in app/lib/app/theme.dart set extensions: const [FrTheme.light] at line 51 without explaining it. Today explains it.

The reference page for this layer is Design system. The rules behind the tokens (which Stitch mockup wins when exports disagree) are in app/docs/design/stitch/README.md.

Open app/lib/design/tokens.dart. Everything visual starts here.

abstract final class FrColors {
/// Primary buttons, eyebrows, active navigation, "you" on the race track.
static const accent = Color(0xFFFF5500);
static const accentPressed = Color(0xFFE04A00);
// ...
/// Text, dark buttons, the opponent marker, the dark segmented thumb.
static const ink = Color(0xFF111315);
// ...
}

(app/lib/design/tokens.dart:9-41, trimmed.)

abstract final class with only static const members is Dart’s namespace. abstract means it cannot be instantiated; final (a Dart 3 class modifier) means nothing outside the file can extend or implement it. It plays the role of a Swift caseless enum used as a namespace, or a Kotlin object holding constants. You write FrColors.accent, never FrColors().

Color(0xFFFF5500) is ARGB packed into one int: FF alpha, then FF5500. The orange is the accent, and the comment says where it is used. The names are roles (accent, ink, canvas, hairline), not hues, so a screen never says “orange”. The header comment at lines 3-7 says so.

The other namespaces:

  • FrSpacing (lines 44-58): a 4 px grid, xs 4 through xxxl 32, plus three named roles, gutter (20, screen edge to content), cardPadding (16), listGap (12). You saw gutter and listGap in the leaderboard yesterday.
  • FrRadii (lines 60-72): plain doubles for the radii, and ready-made const BorderRadius values (mdAll, lgAll, pillAll). Constructors like BorderRadius.all(...) can be const, so even these are compile-time constants.
  • FrShadows (lines 74-101): lists of BoxShadow. card is the resting shadow, floating is for panels lifted above content, accentGlow is reserved for the slide-to-confirm.
  • FrFonts (lines 103-109): two strings, 'PlusJakartaSans' and 'SpaceGrotesk'. They must match the family: names in app/pubspec.yaml:56 and app/pubspec.yaml:61, which bundle the font files from assets/fonts. A typo here gives you the platform default font with no error.

If you have used a CSS design-token file or an iOS asset catalog of named colours, this is the same thing in code.

Open app/lib/design/typography.dart. It has two halves.

frTextTheme() (lines 8-131) returns Material’s TextTheme: fifteen fixed slots from displayLarge down to labelSmall, each a TextStyle. Headings are Plus Jakarta Sans at weight 800 with negative letter spacing; body text is weight 500. theme.dart installs it (textTheme: text at app/lib/app/theme.dart:46), so Theme.of(context).textTheme.headlineSmall anywhere in the app is this file’s 18 px bold.

Material has no “eyebrow” (the small uppercase tracked label above a title), no “stat number that ticks during a race”. Flutter’s answer is a ThemeExtension: your own object, attached to ThemeData, found the same way Theme.of finds everything else.

@immutable
class FrTheme extends ThemeExtension<FrTheme> {
const FrTheme({
required this.eyebrow,
required this.sectionHeader,
required this.raceLabel,
required this.stat,
// ...
required this.accent,
// ...
});

(app/lib/design/typography.dart:134-153, trimmed.) The fields are six text styles and ten colours. The single instance is static const light (lines 184-244). The race numbers are the interesting style:

stat: TextStyle(
fontFamily: FrFonts.mono,
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1,
color: FrColors.ink,
fontFeatures: [FontFeature.tabularFigures()],
),

(lines 209-217). Space Grotesk with tabular figures, so every digit is the same width and a pace going from 5:11 to 5:10 does not make the line jitter. The same idea as monospacedDigitSystemFont in UIKit or font-variant-numeric: tabular-nums in CSS.

ThemeExtension requires two methods, copyWith (lines 246-283) and lerp (lines 285-306). copyWith is the usual immutable-update method; Dart has no data class or struct copy for free, so it is written out by hand. lerp interpolates between two themes, field by field, so an animated theme change could fade between them. With one light theme it never runs in practice, but ThemeExtension will not compile without it, and app/test/design/theme_test.dart:49-54 checks it behaves.

@immutable (line 134) is an annotation the analyzer enforces: every field must be final.

The last five lines of the file are the reason screens can write context.fr.accent:

extension FrThemeContext on BuildContext {
/// The Footrace theme extension. Falls back to the light values so a
/// widget under a bare `MaterialApp` in a test still renders.
FrTheme get fr => Theme.of(this).extension<FrTheme>() ?? FrTheme.light;
}

(app/lib/design/typography.dart:309-313.) An extension adds members to an existing type without subclassing it, exactly like a Kotlin extension property or a Swift extension. BuildContext gets a getter fr, which does the Theme.of lookup from Day 2 and asks for the extension registered at app/lib/app/theme.dart:51. If none is registered, as in a test that pumps a bare MaterialApp, it falls back to FrTheme.light. app/test/design/theme_test.dart:28-47 tests both paths.

So there are two ways to get the orange: FrColors.accent (a constant) and context.fr.accent (through the theme). With one theme they are the same value. The components mostly go through context.fr, which is what would let a dark theme swap them later. They still use a few constants directly where no theme field exists, such as FrColors.onAccent in fr_button.dart:87. CLAUDE.md puts the rule as “never a literal colour or size”, which both forms satisfy.

Open app/lib/design/components/components.dart.

/// The Footrace design-system components. Every widget here is built from
/// the tokens in `../tokens.dart` and the theme in `../typography.dart` and
/// has a use-case in `widgetbook/`.
library;
export 'fr_avatar.dart';
export 'fr_button.dart';
export 'fr_card.dart';
// ...
export 'race/race.dart';

(app/lib/design/components/components.dart:1-20, trimmed.) A barrel file: one import, ../../design/components/components.dart, gives a screen every widget from the fourteen component files plus the race ones, like an index.ts. library; on its own names nothing; it is there so the doc comment above it attaches to the library rather than to the first export.

Open app/lib/design/components/fr_button.dart. It is the most instructive component because it uses nearly every Dart and Flutter feature you have met so far.

Enums with doc comments (lines 6-24): FrButtonVariant { primary, dark, row } and FrButtonSize { regular, compact }.

Two constructors. The default one (lines 29-38) takes named parameters with defaults. The named constructor FrButton.row (lines 41-49) fixes some fields through an initializer list:

const FrButton.row({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
}) : variant = FrButtonVariant.row,
size = FrButtonSize.regular,
icon = null,
uppercase = false;

Callers write FrButton.row(label: ..., onPressed: ...). Think of a Swift convenience initializer with a label, or a Kotlin factory on the companion.

Why stateful. FrButton is a StatefulWidget (line 28) because it tracks one thing itself: whether a finger is down. _FrButtonState has bool _pressed = false; (line 71), and the gesture handlers flip it with setState (lines 175-177). setState marks the element dirty so build runs again on the next frame. That is the whole mechanism; there is no diffing of the state object.

A record and a switch expression (lines 83-91):

final (fill, pressedFill, foreground) = switch (widget.variant) {
FrButtonVariant.primary => (
fr.accent,
fr.accentPressed,
FrColors.onAccent
),
FrButtonVariant.dark => (fr.ink, fr.inkMuted, FrColors.onInk),
FrButtonVariant.row => (fr.surfaceMuted, fr.hairline, fr.ink),
};

The switch returns a record, (Color, Color, Color), and the final (a, b, c) = ... pattern destructures it. Records are Dart 3’s anonymous tuples, like Swift tuples or Elixir’s {a, b, c} = .... The switch must cover every enum value or it does not compile. widget.variant is how a State reads its widget’s fields: widget is always the latest configuration.

Building the content (lines 104-133) uses a Row with collection if and the spread operator: if (row) ...[dot, gap] else if (widget.icon != null) ...[icon, gap] inserts two children or none. Flexible around the label (line 120) lets the text shrink and ellipsize instead of overflowing, the softer cousin of yesterday’s Expanded.

Implicit animation. AnimatedContainer (lines 135-165) is a Container that animates to its new values over 120 ms whenever they change. Flip _pressed, rebuild with a different fill colour, and the colour tweens. AnimatedScale (line 179) does the same for the 0.98 press shrink. It is the effect of wrapping a property change in UIView.animate, except you never call anything: you rebuild with new values and the widget animates to them.

Accessibility and touch target. Semantics (lines 167-172) is what VoiceOver and TalkBack see: a button, its label, whether it is enabled. That is accessibilityLabel and contentDescription. The ConstrainedBox at lines 184-188 keeps a 48 px minimum tap target around the 40 px compact visual.

Disabled is onPressed: null. _enabled (line 73) is just widget.onPressed != null && !widget.loading. The whole Flutter widget set follows this convention.

Open app/lib/design/components/fr_card.dart. It has the same shape as the button: a variant enum (lines 7-16), a StatefulWidget for the pressed state, AnimatedContainer for the box (lines 95-109). Two new bits:

  • The feature variant (lines 72-93) puts a 160 px accent-soft circle behind the content with a Stack, a Positioned at top: -48, right: -48, and clipBehavior: Clip.none on the stack. The card’s own AnimatedContainer clips with Clip.antiAlias (line 98), so the circle is cut off at the card’s rounded corner. Stack and Positioned are absolute positioning inside a parent, position: absolute in CSS.
  • if (!_tappable) return box; (line 111): a card without onTap returns before any gesture or semantics wrapping. Building different trees from the same widget depending on inputs is normal; there is no “hidden” flag.

MessageState, which the leaderboard showed on error, is an FrCard with radius: FrRadii.xl (app/lib/features/shared/message_state.dart:41-43).

Open app/lib/design/components/race/fr_live_pill.dart. The race components live in their own folder with their own barrel, race/race.dart, which components.dart re-exports.

FrLivePill (lines 13-69) is the floating pill at the top of the race screen: a dot and an uppercase mono title. It is a StatelessWidget because it has no state of its own: the phase comes in as a parameter (FrRacePhase { countdown, live, finished }, line 8). It reads context.fr once (line 25) and uses fr.raceLabel for the text, the Space Grotesk style from FrTheme. Only the live phase pulses, and the pulsing is delegated to FrLiveDot, a stateful widget from fr_section_header.dart:92 that owns the animation. That split is typical: the stateless outer widget decides what, a small stateful inner one owns the animation controller.

The race screen uses it in a private _Header widget: child: Center(child: FrLivePill(title: title, phase: phase)) (app/lib/features/race/race_screen.dart:176). Day 11 reads the rest of that screen.

widgetbook/ is a second Flutter app in the same repo, never shipped. Its pubspec.yaml depends on the main app by path:

dependencies:
footrace_flutter:
path: ../
widgetbook: ^3.25.0

so it imports package:footrace_flutter/design/components/components.dart like any other package. Think Storybook, written in Dart, with the tree of stories written by hand.

Open app/widgetbook/lib/main.dart:

@override
Widget build(BuildContext context) => Widgetbook.material(
addons: [
ViewportAddon([/* four phones */]),
// Runners read these mid-stride with the system text size turned up.
TextScaleAddon(min: 1.0, max: 2.0, divisions: 4, initialScale: 1.0),
MaterialThemeAddon(
themes: [WidgetbookTheme(name: 'Footrace', data: footraceTheme())],
),
InspectorAddon(),
],
directories: [foundations, components],
);

(app/widgetbook/lib/main.dart:20-37, trimmed.) The addons are the toolbar: phone frames, text scale up to 2x, the real footraceTheme() (so context.fr works), and a widget inspector. directories is the navigation tree. foundations (app/widgetbook/lib/foundations.dart:8) renders the tokens: colours, type scale, spacing, shadows. components (app/widgetbook/lib/components.dart:15-18) holds the design system folder (app/widgetbook/lib/design_components.dart:14-19) and the app’s own composites.

A use case, from app/widgetbook/lib/design_controls.dart:10-52:

WidgetbookComponent(
name: 'FrButton',
useCases: [
WidgetbookUseCase(
name: 'Variants',
builder: (context) {
final variant = context.knobs.object.dropdown(
label: 'Variant',
options: const [FrButtonVariant.primary, FrButtonVariant.dark],
// ...
);
// ... size, label, icon, loading, enabled knobs
return _Canvas(
child: FrButton(
label: label,
variant: variant,
size: size,
icon: icon ? Icons.play_arrow : null,
loading: loading,
onPressed: enabled ? () {} : null,
),
);
},
),

Each knob becomes a control in the side panel, and the builder reruns when one changes: build() again. The FrLivePill use case is at app/widgetbook/lib/design_race.dart:62-86, with a dropdown over FrRacePhase.values and a string knob for the title.

The house rule from CLAUDE.md: every new component under lib/design/components/ gets a use case in the same PR, with a knob for every public parameter.

The make targets (app/Makefile:36-63):

Target Does
make storybook-dev flutter run -d web-server --web-port 8790 inside widgetbook/: hot reload, localhost only
make storybook release web build, served with Python on port 8790 on every interface (reachable over the tailnet)
make storybook-phone debug APK of the catalog installed over adb
make storybook-check flutter analyze --fatal-infos on widgetbook/, what CI runs

1. A component test, no device. From footrace-flutter:

Terminal window
flutter test test/design/components/fr_button_test.dart

The first test (app/test/design/components/fr_button_test.dart:9-31) pumps an FrButton inside the real theme on a 360 px canvas (the helper is app/test/design/components/pump.dart:7-31) and checks the label is upper-cased, the button is 360 wide and 52 tall, and a tap fires. Now change the regular height in app/lib/design/components/fr_button.dart:138 from compact ? 40 : 52 to compact ? 40 : 56 and run it again. Expect the failure at fr_button_test.dart:22. Note that 52 also appears in the theme (app/lib/app/theme.dart:83) for Material’s own FilledButton: the two are kept in step by hand. Revert.

2. Widgetbook with hot reload (browser, no phone). From footrace-flutter:

Terminal window
make storybook-dev

The first build takes a minute or two. When it says it is serving, open http://localhost:8790 in a browser. Go to Components, Design system, the buttons folder, FrButton, Variants. Play with the knobs: Loading keeps the width and hides the label, Enabled off greys it out, Text scale at 2.0 shows whether the label still fits.

Now, with the server still running, change the press shrink in fr_button.dart:180 from _pressed ? 0.98 : 1 to _pressed ? 0.9 : 1, save, and press r in the terminal. Click and hold the button in the browser: it shrinks visibly. Then find FrLivePill under the race folder and switch the phase dropdown between countdown, live and finished to see which one pulses. Revert the change and stop the server with q.

If make storybook-dev loses its connection (the Makefile comment at app/Makefile:36-42 warns it does when the browser is on another machine), use make storybook instead; it has no hot reload but nothing to drop.

  1. A screen needs the card border colour. Name two ways to get it, and say which one the components prefer and why.
  2. What makes context.fr exist on every BuildContext, and what does it return in a test that pumps a plain MaterialApp with no theme?
  3. Why is FrButton a StatefulWidget while FrLivePill is a StatelessWidget?
  4. What does final (fill, pressedFill, foreground) = switch (...) { ... }; do, and what happens if someone adds a fourth FrButtonVariant?
  5. Where does Widgetbook get its theme, and why does that matter for components that call context.fr?
Answers
  1. FrColors.hairline (the constant) or context.fr.hairline (through the theme). Components use context.fr, so a future theme could change it without touching them.
  2. A Dart extension FrThemeContext on BuildContext at typography.dart:309-313. In a bare MaterialApp the extension is not registered, so it falls back to FrTheme.light.
  3. FrButton tracks whether it is pressed (_pressed) and rebuilds on touch with setState. FrLivePill gets everything from its parameters; the animated dot is its own stateful child, FrLiveDot.
  4. The switch expression returns a three-colour record for the variant and the pattern destructures it into three locals. A new variant makes the switch non-exhaustive, which is a compile error until a case is added.
  5. MaterialThemeAddon with footraceTheme() in app/widgetbook/lib/main.dart:31-33. That theme registers FrTheme.light, so context.fr returns the real values (it would fall back to the same values anyway, but text styles and Material component themes would be missing).

Day 4: go_router, routes, redirect, HomeShell reads how screens are reached and how the app decides you must log in first.