Skip to content

Design system

LibreBash's look is loud, rounded, and friendly — it should read as a party game the moment the picker opens. The system is a thin layer of tokens plus a few shared widgets; games compose them rather than styling ad hoc.

Tokens

All raw layout values live in app_spacing.dart in game_mechanics — the one token file shared by the app and every game package. UI code must not hardcode raw numbers — use the tokens:

  • AppSpacing — xxs through xxxl paddings and gaps.
  • AppRadius — corner radii for cards, dialogs, chips.
  • AppSizes — icon sizes (iconSm…iconXl), minTouchTarget (48), the focus-ring metrics (focusRingWidth, focusRingGap), the TV overscan insets (tvOverscanHorizontal/tvOverscanVertical), and other recurring dimensions (avatar sizes, card/dialog extents, the vote scoreboard's indent).

If a value is missing, add a token — don't inline a literal. The tokens live in game_mechanics rather than the app because packages can't see app code: this way lib/ and packages/ share the same scale.

Theme

lib/core/theming/app_theme.dart exposes exactly two builders: AppTheme.light() and AppTheme.dark(). Both derive a Material 3 ColorScheme from one seed color, so the whole palette stays coherent. The dark scheme warms every surface level with an 8% blend of primary — the stock near-black neutral reads as a void. The user picks light, dark, or match-device in Settings (persisted theme_mode); the web client has no settings screen but reads the same key — it follows the system until the browser-chrome night-mode toggle writes a pick, so a TV's choice persists on that origin (and the host toolbox can flip it remotely while a display is attached).

Per-game GameMetadata.accentColor tints picker cards and in-game highlights — it never replaces the app theme. Sanctioned exception: a game's setup and session screens sit on gameBackgroundFor(accent, theme) — the accent blended over the scaffold color (30% dark, 14% light) — so each game's screens are visibly its own color. SessionScaffold applies it (scaffold + app bar, surface tint off, no scroll-under), and the setup screen paints it under its transparent scaffold.

Typography

AppTypography applies the bundled Fredoka variable font (assets/fonts/Fredoka-Variable.ttf, OFL-licensed) with a wght FontVariation per text style. Fredoka is bundled so the app stays fully offline — never fetch fonts at runtime. Use theme.textTheme.*; don't build TextStyles from scratch.

Fredoka has no emoji glyphs, so every themed style also carries emojiFontFallback (game_mechanics emoji_fonts.dart) — the shared color-emoji chain: Apple/Segoe/Noto first (the platform's own face wins wherever one exists), then Twemoji Mozilla, then plain Noto Emoji. Two render paths never see the theme and carry the chain on their raw TextStyles: the EmojiBackdrop glyph painters and the CelebrationBurst particles — both in game_mechanics. A raw TextPainter anywhere else needs the same line.

On the browser client the whole question is sharper: CanvasKit can't see OS fonts at all, so Twemoji Mozilla — bundled as a web-root file (web/fonts/TwemojiMozilla.ttf, loaded by lib/web_client/emoji_font_loader.dart before runApp) — is the first chain entry that actually resolves there. It ships only in the staged web bundle; native builds keep their system emoji and never download the font.

Shared widgets

  • AppScaffold — the standard screen shell (safe area, consistent padding, optional header row). bottomBar pins a widget below the scroll view — the setup screen's rules + begin buttons live there so the primary action stays reachable no matter how long the page gets.
  • AppIconButton — the only icon-button. It enforces the accessibility contract: a required tooltip and a minimum 48x48 touch target. Icon-only buttons are the default for toolbars; text buttons are for when an icon alone is ambiguous.
  • AppSection — a titled content section (header + body) used to break screens into scannable chunks.
  • AboutTile — icon + name + version/build + license block, shown in the home-screen about dialog.
  • PlayerAvatar / playerPalette (game_mechanics) — player identity is name + palette color; reuse the palette everywhere a player is shown.
  • SessionScope (game_mechanics) — the per-session provider boundary every game wraps itself in; seeds the shared session providers from the GameSession.
  • SessionScaffold (game_mechanics) — every session screen's app bar: game title plus the close button. Routes close and system back through onExitRequested when a game arms its exit gate.
  • showExitGate (game_mechanics) — the themed mid-session "are you sure" dialog: stay/leave plus an optional standings action once a round has been scored; a barrier tap reads as stay.
  • SessionTurnHeader (game_mechanics) — the writing-phase "whose turn" row: avatar, label, optional TurnTimer, progress counter.
  • CountStepper (game_mechanics) — the labeled −/+ picker; the shared control for CountRoundOptions on the setup screen.
  • PassDeviceGate (game_mechanics) — the standard pass-and-play handoff screen. Inside a GameBackdropScope its scaffold is transparent and its body is a FloodTransition, so the layers run: SessionScaffold's game-colored surface + motif wallpaper underneath, the recipient's color flood on top, then the content — the handoff reads game color → player color. With no scope the gate is plain as before.
  • TurnTimer (game_mechanics) — optional per-turn countdown.
  • ShareActions (game_mechanics) — the copy/share button pair for reveal and results screens; sharing falls back to the clipboard.
  • SpeakQueueButton (game_mechanics) — read-aloud: speaks a queue of utterances (one voice each), hides itself without a speech engine, icon-button variant for prompts.
  • SessionResultsView (game_mechanics) — the scoring-agnostic results shell: headline, winner callouts, a scrollable score area on a rounded surfaceContainerHigh panel, and the closing actions; VoteResultsView layers the vote round on top. When callout crowns a side and winnerColor is set, a FloodTransition covers the screen at calloutDelay — the header switches to onWinnerColor, and winnerEmoji wallpapers the flood. Ties pass nothing and stay on the game's tint.
  • PlayerScoreboard (game_mechanics) — the ranked points list for games that score without a ballot (guessers, speed games).
  • PlayerChipRow (game_mechanics) — the tap-a-player chip row for "pick a player" moments (crediting guessers, picking a starter). Multi-select: every tap toggles that player in or out of the selectedIds set.
  • VoteResultsView (game_mechanics) — the post-vote results screen: title, winner callouts over VoteScoreboard, closing actions. When callout is set it derives the crowned human itself (top pointsFor total, exactly one) and feeds the winner flood — a tie stays unflooded.

Reveal choreography (game_mechanics reveal_fx.dart)

The shared entrance language for reveal and results screens — sequenced motion, not decoration. All of it renders instantly under MediaQuery.disableAnimations, so pumpAndSettle settles it in tests.

  • CascadeIn — a delayed fade-and-rise entrance; index is the item's position in the reveal order, hold adds a dramatic pause. Scoreboard rows, debrief cards, and gallery entries all enter this way. onArrived fires when the entrance actually starts (a tracked timer, so it also fires under reduced motion) — scoreboard rows hook their landing sound cue there.
  • PopIn — a springy scale-in for markers, trophies, and punchline moments; delay sequences multiple pops, onArrived hooks haptics.
  • CountUpText — a point total that ticks up from zero; delay holds it until the row has landed. Wired into PlayerScoreboard, TeamScoreboard, and VoteScoreboard totals.
  • CelebrationBurst — a one-shot, screen-filling emoji + confetti eruption; SessionResultsView fires it with celebrationEmoji when callout is set.
  • Leaderboard pacing — leaderboardDelay(rank, count) / leaderboardSettled(count) define the shared ranked reveal: rows land last-place-first, a drumroll beat holds before second and again before first, and the winner's row fully lands before calloutDelay lets the crowning (trophy pop + burst) fire. Games with a scoreboard results screen pass leaderboardSettled(rowCount) as calloutDelay — VoteResultsView already does.
  • Leaderboard camera — leaderboardFollowRow(context) is the other half of the bottom-up reveal: each scoreboard row calls it from its onArrived, and it animates the enclosing Scrollable just far enough to put the landing row on screen. A board taller than the viewport climbs from last place to the winner instead of letting the early landings happen below the fold; a board that fits scrolls not at all. It is a silent no-op under reduced motion (the static board reads top-down) and when no Scrollable/RenderAbstractViewport wraps the row (bare scoreboards in tests).

Party motion (game_mechanics party_fx.dart)

Two classes of motion, and the distinction is a contract:

  • One-shot motion — entrances and reactions that land and stop (CascadeIn, PopIn, StampIn, BounceInText, Squish, the pass-gate color flood, the countdown). Always settles under pumpAndSettle; under MediaQuery.disableAnimations it renders the final state immediately.
  • Ambient motion — loops that could run forever (EmojiBackdrop drift, Wiggle, the home tagline cycle). These may only tick while an ancestor AmbientMotion scope permits and reduced motion is off. AmbientMotion.of(context) returns false with no ancestor, so plain widget tests never hang on a loop. The app root and the web client root install the scope from ambientMotionEnabledProvider (runtime-only, default on — full-app tests override it to false). Off means a static frame, not a missing widget.

The toolkit:

  • Squish — wraps an interactive child; scales to ~0.94 while pressed and springs back on release. It listens beside the child, so the child's own tap recognizer still wins — picker cards and the setup begin button wear it.
  • StampIn — a rubber-stamp landing: scale ~1.6 → 1 with a slight rotation settle and fade; onArrived fires at the slam (ballot locks hook PartyHaptics.confirm there).
  • BounceInText — letter-by-letter staggered bounce; letterColors cycles per letter (the home wordmark runs the games' accent colors through it). The whole string is one semantics label; letters are excluded individually.
  • Wiggle — ambient: a ±8° wobble (~600 ms) with a ~2 s rest between shakes. The home header's 🎉 and the pass-gate phone icon use it.
  • EmojiBackdrop — a CustomPainter field of tint-colored emoji silhouettes in a staggered grid, ~16% opacity (22% on the pass-gate flood), drifting two tiles diagonally on a ~60 s seamless loop (two tiles keeps the band and row parity identical at the wrap). Painted, ignored by pointers, excluded from semantics. tints takes a per-glyph color list parallel to emoji — the home wallpaper mixes every game's motif in that game's own accent. Because the field repaints every vsync, each unique glyph+tint silhouette is baked once into a ui.Image atlas (at native pixel ratio) and each frame replays the grid as a single drawAtlas batch — without it, every frame costs one full-screen saveLayer per tint plus hundreds of text draws. Until the async bake lands the painter draws the direct saveLayer-per-tint path as a fallback. On tinted or dark surfaces, feed the tint through backdropTintFor, which raises the accent's lightness for dark themes and lowers it for light so the silhouettes stay readable; the pass gate uses onAccent instead since it sits on a full-color flood. Home mixes every game's motif — the shared MotifBackdrop widget — and the standard screens reach the same field through AppScaffold(wallpaper: true) (chooser, settings, stats), which goes transparent over it. SessionScaffold paints the scope's motif behind the session body; setup wallpapers its screen with the game's motif. The browser join surfaces wear the same field too: the web client's join form layers MotifBackdrop behind it, and the room server's code-entry gate — static HTML rendered before the bundle exists — tiles a frozen SVG copy of it (_gateMotifTile in packages/session_link), light- and dark-scheme aware. Every wallpaper stack pins StackFit.expand — a loose stack lets a shrink-wrapping scroll view collapse the whole stack (and the Positioned.fill backdrop inside it) to the content's height, leaving bare scaffold under short phases.
  • ColorFlood — the one-shot circle reveal: color expands from origin (alignment) to cover the farthest corner in ~450 ms after delay, all folded into one controller.
  • FloodTransition — the shared "a player takes over the screen" moment: a ColorFlood plus an optional single-emoji wallpaper (emoji, tinted onColor, 22% opacity) that fades in as the circle lands, under child. It paints no base color — the game-colored surface always comes from the SessionScaffold underneath. Use it for any future takeover. The pass-device gate floods the recipient's palette color; SessionResultsView floods the winner's color at the crowning beat (centered Alignment(0, -0.6) on the trophy) while the header switches to onWinnerColor and winnerEmoji wallpapers the flood.
  • gameBackgroundFor / backdropTintFor — the two color helpers behind the game-colored screens above; see the Theme section.
  • PartyHaptics — fire-and-forget taps: tap() (selection), tick() (selection — countdown beats and the timer's last five seconds), confirm() (medium — ballot locks, gate confirms), celebrate() (heavy — the countdown's "Go!").
  • GameBackdropScope — an InheritedWidget carrying emoji + accentColor; wrapGameSession installs it per game so SessionScaffold can wallpaper the session.

Sound cues (game_mechanics sound_scope.dart)

One-shot cues are vocabulary, not decoration — they land beside the haptics on the same moments:

  • context.playSound(SoundCue.x) is the fire line — fire-and-forget, silent when no SoundScope is installed, never throws. The host's scope plays the full vocabulary; remote seats (native + browser) sit under a filtered scope that lets tap, turnPing, timerTick, and timeUp through — "phones small": your own taps, the "your turn" ping, and your own countdown (the last-five tick is the seat's call to act), not the host's scoreboard soundtrack.
  • Cues are not motion. They ignore MediaQuery.disableAnimations; CascadeIn/PopIn/StampIn expose onArrived as a tracked timer — not an animation listener — precisely so a still scoreboard still lands its ticks.
  • The vocabulary, in party order: tap (cards, forward actions), passWhoosh + confirm (the handoff gate), turnPing (a remote seat's turn), countdownBeep/countdownGo (the 3-2-1 splash), timerTick (the timer's final five seconds), timeUp (expiry), stamp (ballot lock), leaderboardRow per landing row plus rimshot for the lowest zero-point row, revealSting (results mount), drumroll (timed to end on the winner's row), winnerFanfare (a crowned winner only — a tie gets nothing).
  • Settings — a Sound section on the settings screen carries the effects toggle and the effects-volume slider; both persist and write straight through to the live player.

Motifs and branding

  • GameMetadata.motifEmoji — each game declares a small emoji set (🍽️🥒⭐📸, 🍾🌊🏝️📜, …). It is decorative only — backdrop tiles and the sticker's corner — and never persisted. An empty list means no backdrop.
  • GameSticker (lib/core/widgets/game_sticker.dart) — the game's rounded-square badge: light container, rotated ~-8°, accent icon, motif emoji on the corner. Home cards and the setup header share it inside a Hero tagged game-sticker-<gameId> so the card's sticker flies into the setup screen.
  • CountdownSplash (lib/core/widgets/countdown_splash.dart) — showCountdownSplash(context, accentColor: …) runs the 3-2-1-"Go!" interstitial (~2.5 s, accent flood, tick haptics per beat, celebrate + CelebrationBurst on "Go!"). Tap anywhere skips; under reduce motion it resolves without showing. Both /play paths in setup await it.
  • Page transitions — every route goes through partyPage in lib/core/routing/app_router.dart: a CustomTransitionPage, ~300 ms easeOutCubic, slide-up-and-fade by default; /play and /multiplayer/game zoom from ~0.92 instead. Reduce motion returns the child directly, and heroes keep working.

Conventions

  • Every icon-only control has a tooltip — enforced by AppIconButton.
  • Focused controls wear the focus ring — FocusRingOverlay (lib/core/controller/) paints a colorScheme.primary rounded stroke (AppSizes.focusRingWidth, offset AppSizes.focusRingGap, radius AppRadius.md, colorScheme.surface halo) around the focused widget while the highlight mode is traditional (controller, remote, keyboard). It reads MediaQuery-free geometry — don't draw per-widget focus borders on top of it.
  • Wrap TextFields that a controller user must pass through in VerticalFocusEscape (lib/core/controller/) — without it a single-line field swallows arrow up/down for the caret and a remote or gamepad can't leave. Never wrap multiline fields, where the arrows are a caret.
  • Host-path controls are focusable stock widgets — the whole lobby / setup / session / banner path must drive by D-pad: FilledButton, OutlinedButton, IconButton/AppIconButton, SwitchListTile, ListTile, SegmentedButton. Never a bare GestureDetector for an action — a tap region is invisible to focus traversal. If a card or row needs a tap, give it InkWell/Card+InkWell (focusable) or a real button; the first actionable control on a screen carries autofocus so a controller lands on something useful. On a remote view's host face, the phase's advance control carries autofocus: spectating — the presenting (unseated) host lands on the button that paces the game, while a seated host's own input fields keep their focus.
  • Unfilled buttons carry a translucent fill — OutlinedButton paints surface at 55% (25% when disabled) so secondary actions stay readable on the game-colored backgrounds and winner floods. TextButton (dialog actions) stays bare.
  • Buttons that can lose a game (end session, restart) confirm first.
  • Keep screens single-column and thumb-reachable. The device is usually being passed around; the important action sits low on the screen.
  • Games theme through the app. Game packages take colors and text styles from Theme.of(context); GameMetadata.accentColor is the sanctioned per-game flourish.