Design system
Footrace’s look lives in app/lib/design/: one file of tokens, one file of typography and theme extension, and a folder of about twenty Fr* widgets. Screens are assembled from those widgets and read colours and text styles from the theme, never from literals. The visual source is a set of Google Stitch mockups under docs/design/stitch/, and the components are catalogued in a separate Widgetbook app under widgetbook/. This page goes through each layer, bottom up.
flowchart BT
stitch["docs/design/stitch/<br/>mockups + README"] -.->|values settled in| tokens
tokens["lib/design/tokens.dart<br/>FrColors · FrSpacing · FrRadii · FrShadows · FrFonts"]
typo["lib/design/typography.dart<br/>frTextTheme() · FrTheme · context.fr"]
theme["lib/app/theme.dart<br/>footraceTheme()"]
comps["lib/design/components/<br/>Fr* widgets + race/"]
screens["lib/features/*<br/>screens"]
wb["widgetbook/<br/>catalog app"]
tokens --> typo
tokens --> theme
typo --> theme
theme --> comps
tokens --> comps
comps --> screens
theme --> screens
comps --> wb
theme --> wb
Tokens
Section titled “Tokens”app/lib/design/tokens.dart is plain constants grouped into five namespaces. Each is an abstract final class, Dart’s way to make a bag of statics that can be neither instantiated nor subclassed, like a Kotlin object or a caseless Swift enum.
The header comment states the rule for the whole file:
/// Design tokens from the Stitch "Nordic minimalist" mockups. The reconciled/// values live in docs/design/stitch/README.md; that file wins over the/// exported HTML where the three screens disagree.////// Everything is named by role, not hue, so a screen never says "orange".app/lib/design/tokens.dart:3-7.
Colour
Section titled “Colour”| Token | Value | Role |
|---|---|---|
accent |
#FF5500 |
primary buttons, eyebrows, active navigation, “you” on the race track |
accentPressed |
#E04A00 |
pressed accent |
accentSoft |
#FFF3ED |
backgrounds behind accent icons and badges |
ink |
#111315 |
text, dark buttons, the opponent marker |
inkMuted |
#64748B |
secondary text, inactive labels |
inkFaint |
#9CA3AF |
eyebrows over grey, timestamps, inactive navigation |
canvas |
#F8F9FA |
scaffold background |
surface |
#FFFFFF |
cards |
surfaceMuted |
#F3F4F6 |
inset panels, neutral icon tiles, segmented track |
hairline |
#E5E7EB |
card borders, dividers |
onAccent, onInk |
#FFFFFF |
text on accent or ink fills |
error |
#B3261E |
Material’s default red; the mockups have no error colour |
app/lib/design/tokens.dart:9-41. Dart’s Color(0xFFFF5500) is ARGB, alpha first, so 0xFF is fully opaque. That is the reverse of CSS #RRGGBBAA, and it catches people reading shadow colours: Color(0x0D000000) is black at about 5 % opacity.
Spacing, radii, shadows, fonts
Section titled “Spacing, radii, shadows, fonts”| Namespace | Values | Source |
|---|---|---|
FrSpacing |
xs 4, sm 8, md 12, lg 16, xl 20, xxl 24, xxxl 32; gutter 20, cardPadding 16, listGap 12 |
app/lib/design/tokens.dart:43-58 |
FrRadii |
sm 6, md 12, lg 16, xl 24, pill 999, plus ready-made BorderRadius constants smAll to pillAll |
app/lib/design/tokens.dart:60-72 |
FrShadows |
card (1 px, 5 % black), floating (8 px offset, 24 blur), accentGlow (the slide-to-confirm handle only) |
app/lib/design/tokens.dart:74-101 |
FrFonts |
sans = 'PlusJakartaSans', mono = 'SpaceGrotesk' |
app/lib/design/tokens.dart:103-109 |
Spacing is a 4 px grid. Flutter’s units are logical pixels, the same idea as iOS points or Android dp, so FrSpacing.lg is 16 pt on an iPhone and 16 dp on a Pixel.
Both font families are bundled, not fetched. pubspec.yaml registers the variable-weight TTFs under assets/fonts/ (app/pubspec.yaml:55-63); the family name there is what FrFonts refers to. Space Grotesk is labelled mono in the code, but it is not a monospaced font. What makes race numbers not jitter is the tabular-figures font feature applied in the stat styles, described below.
Typography and FrTheme
Section titled “Typography and FrTheme”app/lib/design/typography.dart has two parts.
frTextTheme()
Section titled “frTextTheme()”A full Material TextTheme built from Plus Jakarta Sans: displayLarge (40, w800) down to labelSmall (11, w600), with tight negative tracking on headings and ink or muted colours baked in (app/lib/design/typography.dart:8-131). Material widgets look up their text style by these slot names: an AppBar title uses titleLarge, a TextField uses bodyLarge, and so on. Filling all fifteen slots means stock widgets use the Footrace type without per-widget styling.
Screens read it as Theme.of(context).textTheme.headlineSmall, typically adjusted with copyWith:
Text( body, style: text.bodyMedium?.copyWith(color: fr.inkMuted), textAlign: TextAlign.center, ),app/lib/features/shared/message_state.dart:54-58. The ?. is there because every slot in TextTheme is nullable in Flutter’s API, even though this theme fills them all.
FrTheme, a ThemeExtension
Section titled “FrTheme, a ThemeExtension”Material’s TextTheme has no slot for an eyebrow label, a section header, or a stat number with its unit. FrTheme adds them:
@immutableclass FrTheme extends ThemeExtension<FrTheme> { const FrTheme({ required this.eyebrow, required this.sectionHeader, required this.raceLabel, required this.stat, required this.statLarge, required this.statUnit, required this.accent, // ... the other nine token colours });app/lib/design/typography.dart:134-153. Six text roles plus the ten role colours:
| Field | What it is | Face |
|---|---|---|
eyebrow |
small uppercase tracked label above a title (“LAST RUN”) | sans, 11, w700, inkFaint |
sectionHeader |
uppercase heading over a list section (“LIVE LOBBIES”) | sans, 11, w800, ink |
raceLabel |
uppercase label on the race screen (“CURRENT PACE”) | Space Grotesk, 10, w700 |
stat |
a number that updates during a race | Space Grotesk, 20, w700, tabular |
statLarge |
the hero number in a metric tile | Space Grotesk, 36, w700, tabular |
statUnit |
the unit next to a stat (“/km”, “km”) | sans, 12, w600, inkMuted |
The values are in the single instance FrTheme.light (app/lib/design/typography.dart:184-244). The stat styles carry fontFeatures: [FontFeature.tabularFigures()] (app/lib/design/typography.dart:216, app/lib/design/typography.dart:225), the OpenType tnum feature: every digit gets the same advance width, so 9:59 becoming 10:00 does not shift the text sideways. In CSS this is font-variant-numeric: tabular-nums; in UIKit, UIFontDescriptor.FeatureKey with kNumberSpacingType.
The extension is registered once in the app theme (extensions: const [FrTheme.light], app/lib/app/theme.dart:51) and read through a BuildContext extension:
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. Dart extensions add members to an existing type without subclassing, exactly like Kotlin extension functions or Swift extensions. So context.fr.eyebrow reads as if BuildContext always had an fr property.
ThemeExtension<T> requires two methods. copyWith (app/lib/design/typography.dart:246-283) makes a modified copy. lerp (app/lib/design/typography.dart:285-306) interpolates between two instances, field by field, with TextStyle.lerp and Color.lerp. Flutter calls lerp when the theme animates, for example during a light/dark crossfade. There is only a light theme today, so lerp never sees two different values in the running app, but the framework requires it and test/design/theme_test.dart covers it.
How this maps to what you know:
- Android: custom theme attributes. You declare
<attr name="eyebrowStyle">inattrs.xml, set it in the theme, and read?attr/eyebrowStyle.FrThemeis the same thing, typed, with the theme as an object instead of XML. - Compose: a custom
CompositionLocalexposing extended colours next toMaterialTheme.context.frplays the role ofLocalExtendedColors.current. - Web: CSS custom properties on
:root. The difference is thatFrThemeis resolved by walking up the widget tree to the nearestTheme, so a subtree can override it.
The colour fields duplicate FrColors. The reason to read fr.accent rather than FrColors.accent in a widget is that the former comes from the theme, so a future dark theme only has to supply a second FrTheme. Most components follow that; a few still use FrColors.onAccent and FrColors.onInk directly (app/lib/design/components/fr_button.dart:87).
The app theme
Section titled “The app theme”footraceTheme() in app/lib/app/theme.dart maps the tokens onto Material’s ColorScheme and restyles stock widgets (buttons, inputs, app bar, cards, sheets, snack bars). It is covered on App shell. The short version: it exists so that a plain FilledButton or TextField dropped into a screen looks like Footrace, and it is where FrTheme is plugged in.
Components
Section titled “Components”All components are exported from one barrel file, so a screen needs one import:
import '../../design/components/components.dart';The barrel (app/lib/design/components/components.dart:1-20) re-exports every file, including race/race.dart, which in turn re-exports the three race-specific widgets (app/lib/design/components/race/race.dart:1-7). The barrel’s doc comment states the contract: every widget is built from the tokens and the theme, and has a use case in widgetbook/.
The common traits:
- Each is a
StatelessWidgetunless it animates or tracks a press, in which case it is aStatefulWidgetwith a small private state class (_FrButtonState,_FrCardState). - Each reads
context.frat the top ofbuildand nothing global. - Variants are enums (
FrButtonVariant,FrCardVariant,FrTagTone), switched on with Dart’s exhaustiveswitchexpressions.FrButtonpicks its colours with a switch that returns a record, a tuple-like value destructured on the spot:
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), };app/lib/design/components/fr_button.dart:83-91. Kotlin would write a when returning a Triple; Swift a switch returning a tuple.
- They do not format data.
FrStattakesvalue: '4:32'andunit: '/km'as strings the app already produced, so units and rounding stay inUnitsFormatter(app/lib/core/units.dart), not in the design layer (app/lib/design/components/fr_stat.dart:8-11). - They take care of accessibility themselves: tappable cards announce as buttons, the slide-to-confirm offers a plain tap to screen readers, the pulsing dot stops for reduced motion.
General components
Section titled “General components”| Component | What it is | Key parameters | File |
|---|---|---|---|
FrAvatar |
A runner’s initials in a circle. The API has no avatar images, so this is the avatar. | name, size (44), ring |
app/lib/design/components/fr_avatar.dart:8 |
FrButton |
The app’s button. Disabled when onPressed is null; with loading the label is replaced by a spinner without changing width. FrButton.row is the muted in-card style with an accent dot. |
label, onPressed, variant (primary, dark, row), size (regular 52 px full width, compact 40 px visual and 48 px tap target), icon, loading, uppercase |
app/lib/design/components/fr_button.dart:28 |
FrCard |
The container nearly every screen is built from. With onTap the whole card is a button that scales to 0.98 while pressed. |
child, variant (plain, inset, feature with the accent circle bleeding off the corner), onTap, padding, radius, semanticLabel |
app/lib/design/components/fr_card.dart:22 |
FrEyebrow |
The small uppercase label above a title. | text, tone (accent, muted) |
app/lib/design/components/fr_eyebrow.dart:8 |
FrIconButton |
Round white icon button with a hairline, for header actions. The tooltip doubles as the accessibility label. | icon, tooltip, onPressed, size |
app/lib/design/components/fr_icon_button.dart:8 |
FrIconTile |
Rounded-square icon container, the glyph in a list row. | icon, size (sm 40, md 44, lg 48), tone (accent, neutral, dark) |
app/lib/design/components/fr_icon_tile.dart:12 |
FrListRow |
A list row: leading tile or avatar, bold title, meta parts joined by a muted dot, trailing slot. | leading, title, meta (list of strings), subtitle, trailing, onTap |
app/lib/design/components/fr_list_row.dart:10 |
FrMetricTile / FrMetricGrid |
Race-screen tile: accent icon, uppercase label, big value and unit, optional footer row. The grid lays tiles out two to a row; a tile with span: 2 takes a row alone. |
icon, label, value, unit, footerLabel, footerValue, span |
app/lib/design/components/fr_metric_tile.dart:11, :98 |
FrNavigationBar |
The bottom tab bar: outlined icon idle, filled accent icon active, tiny tracked label, accent dot under the active item, hairline on top. 68 px. | destinations (FrNavDestination), selectedIndex, onDestinationSelected |
app/lib/design/components/fr_navigation_bar.dart:24 |
FrSectionHeader / FrLiveDot |
Uppercase heading over a list section, with an optional pulsing live dot and either a muted note or an action. FrLiveDot is the 8 px breathing dot, also used by the live pill. |
title, live, trailing, actionLabel, onAction |
app/lib/design/components/fr_section_header.dart:9, :92 |
FrSegmentedControl<T> |
A pill of two to four segments with a sliding thumb. Generic over the value type. | segments (FrSegment<T>), value, onChanged, variant (light, dark), height |
app/lib/design/components/fr_segmented_control.dart:23 |
FrStat |
A number with optional label above and unit beside, in the stat styles. | value, label, unit, size (sm 16 to xl 36), align |
app/lib/design/components/fr_stat.dart:12 |
FrStatGroup |
Two to four FrStats divided by hairlines, optionally in the grey inset panel. |
stats, inset |
app/lib/design/components/fr_stat_group.dart:10 |
FrTag |
A small pill label for a state the app has (Won, Lost, Bot), or the gap badge. | label, tone (neutral, accent, dark), dot, icon |
app/lib/design/components/fr_tag.dart:10 |
Race components
Section titled “Race components”app/lib/design/components/race/ holds the three widgets that exist only for the race screen. Their folder doc comment calls them “what makes the race screen feel like a race” (app/lib/design/components/race/race.dart:1-2).
| Component | What it is | Key parameters | File |
|---|---|---|---|
FrHeadToHead |
The “competition gap” card body: a track with you (accent fill ending in a ringed pin) and the opponent (an ink tick), a gap badge, and the runner rows. Positions tween over 600 ms with AnimatedPositioned rather than jump between updates. Takes a list of runners but asserts at least two and is designed for exactly two. |
runners (FrRunner: name, progress 0 to 1, rank, subtitle, isMe), gapLabel (pre-formatted), ahead, title, animate |
app/lib/design/components/race/fr_head_to_head.dart:34 |
FrLivePill |
The floating pill at the top of the race screen: a dot and a mono uppercase title. Only the live phase pulses. |
title, phase (FrRacePhase.countdown, live, finished) |
app/lib/design/components/race/fr_live_pill.dart:13 |
FrSlideToConfirm |
A dark pill you drag an accent handle across to confirm something you must not do by accident. Past threshold (0.85) it confirms with a heavy haptic; let go earlier and it springs back. Screen readers get a plain tap action. |
label, onConfirm (null disables), threshold, height (64) |
app/lib/design/components/race/fr_slide_to_confirm.dart:11 |
The slide-to-confirm is a good small example of a Flutter animation. Its state class mixes in SingleTickerProviderStateMixin and owns an AnimationController for the spring-back (app/lib/design/components/race/fr_slide_to_confirm.dart:33-38). A Ticker is Flutter’s per-frame callback, the counterpart of CADisplayLink on iOS or Choreographer on Android; the mixin supplies one tied to the widget’s lifetime, so the animation stops when the widget leaves the tree. Confirming is short:
void _confirm() { if (!_enabled) return; setState(() { _confirmed = true; _position = 1; }); HapticFeedback.heavyImpact(); widget.onConfirm?.call(); }app/lib/design/components/race/fr_slide_to_confirm.dart:78-86. The same _confirm is wired to the Semantics node’s onTap (app/lib/design/components/race/fr_slide_to_confirm.dart:93-99), which is how VoiceOver and TalkBack users leave a race without dragging.
FrLiveDot is the reduced-motion example. It checks MediaQuery.maybeDisableAnimationsOf(context) in didChangeDependencies and stops its controller when the platform asks for less motion (app/lib/design/components/fr_section_header.dart:112-122). That flag follows iOS “Reduce Motion” and Android “Remove animations”.
How screens consume the system
Section titled “How screens consume the system”Every screen follows the same three habits.
1. Grab the theme once at the top of build.
Widget build(BuildContext context, WidgetRef ref) { final raceTypes = ref.watch(raceTypesProvider); final auth = ref.watch(authProvider).value; final formatter = ref.watch(unitsFormatterProvider); final fr = context.fr; final textTheme = Theme.of(context).textTheme;app/lib/features/race_tab/race_tab.dart:24-29. fr for Footrace roles and colours, textTheme for Material type slots.
2. Compose from components, spacing from tokens. The race screen’s racing body is a fair sample: a FrCard holding a FrHeadToHead, a SizedBox(height: FrSpacing.md), a FrMetricGrid of three FrMetricTiles, then a FrSlideToConfirm padded with FrSpacing.gutter:
FrMetricGrid( tiles: [ FrMetricTile( icon: Icons.speed, label: 'Current pace', value: paceValue, unit: paceUnit, footerLabel: 'Average', footerValue: avgPace, ), // ... distance, then elapsed time with span: 2 ], ),app/lib/features/race/race_screen.dart:347-373. The values (paceValue, avgPace) are already formatted strings from UnitsFormatter.
3. Never a literal colour. grep for Color(0x under lib/features/ finds nothing. A handful of screens do adjust a size on a theme style, which is the accepted escape hatch: the queue countdown is fr.statLarge.copyWith(fontSize: 96, height: 1) (app/lib/features/queue/queue_screen.dart:265) and the race countdown uses 128 (app/lib/features/race/race_screen.dart:231).
Where components are used:
| Component | Used in |
|---|---|
FrCard |
message state, Activity, Profile, Race tab, result, race, queue |
FrButton |
login, register, lobby, message state, race type card, Race tab, result, queue |
FrListRow |
lobby, race type card, opponent profile, leaderboard, Profile, History |
FrStat / FrStatGroup |
Activity, lobby, leaderboard, History, profile widgets, result |
FrSectionHeader |
Activity, lobby, Race tab, profile widgets, History |
FrEyebrow |
lobby, login, Race tab, profile widgets, result, queue |
FrIconTile |
profile widgets, Profile, opponent profile, race type card, result, History |
FrTag |
Activity, result, queue |
FrAvatar |
lobby, profile widgets |
FrIconButton |
Race tab, Profile |
FrSegmentedControl |
Profile (units) |
FrNavigationBar |
HomeShell |
FrMetricTile, FrHeadToHead, FrLivePill, FrSlideToConfirm |
race screen only |
The leaderboard filter is built from its own chips (_Chip, app/lib/features/leaderboard/leaderboard_screen.dart:96) rather than FrSegmentedControl, presumably because there can be more race types than the control’s four-segment limit.
The Stitch reference
Section titled “The Stitch reference”docs/design/stitch/README.md in the app repo is the design brief. Three screens were exported from Google Stitch on 2026-09-25: home, the race-selection lobby and the live race. For each there is a full PNG and the Tailwind HTML Stitch generated (docs/design/stitch/screens/), plus fifteen crops of individual parts (docs/design/stitch/components/), so a component story can point at exactly the piece it implements.
What the README settles:
- It is a direction, not a spec. The HTML is the most precise source for sizes and weights, but the README’s token tables win where the three exports disagree (two canvas greys, two “primary dark” shades, the lobby calling the accent
solar). - Stitch invented features. Stars and rewards, coaches and levels, heart rate, cadence, target pace, the map and its controls, Home and Social tabs, the Splits/Terrain/Rival bar, the bot gallery, race tiers and stages, lobby tags like Fast/Popular/Pro. The rule is to ignore them entirely: no components, no reserved space. You can see this rule in the code comments:
FrLivePillsays “No tier or stage line; Stitch invented those” (app/lib/design/components/race/fr_live_pill.dart:11-12) andFrMetricTilesays it shows pace, distance and elapsed time, “nothing else was specified” (app/lib/design/components/fr_metric_tile.dart:8-10). - A component index maps each crop to the components in it (for example
race-gap-card.pngto the head-to-head track, gap badge and runner rows).
Where the code and the README differ, slightly: the README gives radius xl as 20 to 24 and list gap as 10 to 12; the tokens pick 24 and 12. The README describes the floating style as translucent white with blur; FrShadows.floating is only the shadow, and the blur is not a token. Neither is a contradiction, just the code choosing one end of a range.
Screens that change look are also rendered to PNGs by test/screenshots/ and committed under docs/screenshots/<story>/ to sit next to the Stitch crop in the PR. Those are renders from the test harness, not emulator captures. See Testing.
Widgetbook, the component catalog
Section titled “Widgetbook, the component catalog”widgetbook/ is a second Flutter app: Footrace’s Storybook. It depends on the main app by path (footrace_flutter: path: ../ in widgetbook/pubspec.yaml), renders each component in isolation with knobs, and never ships in the app binary. It is outside the coverage gate.
@override Widget build(BuildContext context) => Widgetbook.material( addons: [ ViewportAddon([ AndroidViewports.samsungGalaxyS20, AndroidViewports.onePlus8Pro, IosViewports.iPhone13, IosViewports.iPhoneSE, ]), // 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. Addons are toolbar controls: device frames, text scale from 1x to 2x, the app theme, and an inspector. The tree is written by hand rather than generated (the comment at app/widgetbook/lib/main.dart:11-12 says this keeps the toolchain the app’s toolchain).
| File | Contents |
|---|---|
widgetbook/lib/foundations.dart |
Colours, Typography, Spacing and radii, Shadows: the tokens rendered as swatches and specimens |
widgetbook/lib/components.dart |
The top-level “Components” category: the design-system folder plus an “Existing” folder for app widgets (RaceTypeCard, RaceTypeStatsRow, FormErrorBanner) |
widgetbook/lib/design_components.dart |
FrCard, FrEyebrow, FrSectionHeader, FrTag, FrIconTile, FrAvatar |
widgetbook/lib/design_controls.dart |
FrButton, FrIconButton, FrSegmentedControl, FrNavigationBar |
widgetbook/lib/design_stats.dart |
FrStat, FrStatGroup, FrMetricTile (with FrMetricGrid), FrListRow |
widgetbook/lib/design_race.dart |
FrHeadToHead, FrLivePill, FrSlideToConfirm |
The house rule, from the app’s CLAUDE.md: every new component under lib/design/components/ gets a WidgetbookUseCase in the same PR, with a knob for each public parameter, showing only data the app really has. A use case reads its knobs from context.knobs, for example context.knobs.boolean(label: 'Metric units', initialValue: true) in the RaceTypeCard story (app/widgetbook/lib/components.dart:28-31).
Make targets
Section titled “Make targets”From the app’s Makefile:
| Target | What it does |
|---|---|
make storybook |
storybook-build, then serves widgetbook/build/web with python3 -m http.server on port 8790 bound to all interfaces, so it is reachable over the tailnet. Prints the Tailscale address. (app/Makefile:45-47) |
make storybook-build |
flutter build web --release in widgetbook/ (app/Makefile:52-53) |
make storybook-dev |
Flutter’s hot-reload web dev server on port 8790. Localhost only: the Makefile comment explains the dev server’s debug channel drops when the browser is on another machine (app/Makefile:36-50) |
make storybook-phone |
Builds a debug APK of the catalog, installs it over adb and launches it as footrace_widgetbook (app/Makefile:55-60). The comment notes the web build does not run everything the app package pulls in, which is why this exists. |
make storybook-check |
flutter analyze --fatal-infos in widgetbook/ (app/Makefile:62-63) |
The app’s CLAUDE.md says storybook-check also does a web build. The Makefile target does not; it only analyzes. The CI “Widgetbook” job does both, running flutter analyze --fatal-infos and flutter build web --release in widgetbook/ (app/.github/workflows/ci.yml:94-98), so a component change that breaks a use case fails CI even if make storybook-check passes locally.
Every component has a widget test under test/design/components/ (and test/design/components/race/ for the three race widgets). They use a shared helper, pumpFr, which wraps the widget in the real app theme on a 360 px wide canvas at a chosen text scale (app/test/design/components/pump.dart:5-30), so each test checks the narrowest phone the app targets. test/design/theme_test.dart checks that the theme is built from the tokens, that the stat style uses tabular Space Grotesk, that context.fr falls back to FrTheme.light without the extension, and that lerp and copyWith work.
Further reading
Section titled “Further reading”- Flutter mental model for
StatelessWidget,StatefulWidgetandbuild. - Day 3: The design system and Widgetbook walks this code with exercises.
- Features for the screens that use these components.