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.
Before you start
Section titled “Before you start”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.
Tokens
Section titled “Tokens”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,xs4 throughxxxl32, plus three named roles,gutter(20, screen edge to content),cardPadding(16),listGap(12). You sawgutterandlistGapin the leaderboard yesterday.FrRadii(lines 60-72): plaindoubles for the radii, and ready-madeconst BorderRadiusvalues (mdAll,lgAll,pillAll). Constructors likeBorderRadius.all(...)can beconst, so even these are compile-time constants.FrShadows(lines 74-101): lists ofBoxShadow.cardis the resting shadow,floatingis for panels lifted above content,accentGlowis reserved for the slide-to-confirm.FrFonts(lines 103-109): two strings,'PlusJakartaSans'and'SpaceGrotesk'. They must match thefamily:names inapp/pubspec.yaml:56andapp/pubspec.yaml:61, which bundle the font files fromassets/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.
Typography and the FrTheme extension
Section titled “Typography and the FrTheme extension”Open app/lib/design/typography.dart. It has two halves.
The Material type scale
Section titled “The Material type scale”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.
Roles Material has no slot for
Section titled “Roles Material has no slot for”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.
@immutableclass 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.
context.fr
Section titled “context.fr”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.
Components
Section titled “Components”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.
FrButton
Section titled “FrButton”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.
FrCard
Section titled “FrCard”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
featurevariant (lines 72-93) puts a 160 px accent-soft circle behind the content with aStack, aPositionedattop: -48, right: -48, andclipBehavior: Clip.noneon the stack. The card’s ownAnimatedContainerclips withClip.antiAlias(line 98), so the circle is cut off at the card’s rounded corner.StackandPositionedare absolute positioning inside a parent,position: absolutein CSS. if (!_tappable) return box;(line 111): a card withoutonTapreturns 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).
A race component: FrLivePill
Section titled “A race component: FrLivePill”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
Section titled “Widgetbook”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.0so 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:
@overrideWidget 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 |
Try it
Section titled “Try it”1. A component test, no device. From footrace-flutter:
flutter test test/design/components/fr_button_test.dartThe 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:
make storybook-devThe 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.
Check yourself
Section titled “Check yourself”- A screen needs the card border colour. Name two ways to get it, and say which one the components prefer and why.
- What makes
context.frexist on everyBuildContext, and what does it return in a test that pumps a plainMaterialAppwith no theme? - Why is
FrButtonaStatefulWidgetwhileFrLivePillis aStatelessWidget? - What does
final (fill, pressedFill, foreground) = switch (...) { ... };do, and what happens if someone adds a fourthFrButtonVariant? - Where does Widgetbook get its theme, and why does that matter for components that call
context.fr?
Answers
FrColors.hairline(the constant) orcontext.fr.hairline(through the theme). Components usecontext.fr, so a future theme could change it without touching them.- A Dart
extension FrThemeContext on BuildContextattypography.dart:309-313. In a bareMaterialAppthe extension is not registered, so it falls back toFrTheme.light. FrButtontracks whether it is pressed (_pressed) and rebuilds on touch withsetState.FrLivePillgets everything from its parameters; the animated dot is its own stateful child,FrLiveDot.- The
switchexpression 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. MaterialThemeAddonwithfootraceTheme()inapp/widgetbook/lib/main.dart:31-33. That theme registersFrTheme.light, socontext.frreturns 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.