Files
Garage-SDKs/docs/garage-ui-style-guide.md
T
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

29 KiB
Raw Blame History

Garage UI style guide

This is not the widget API reference (read the source for that — every file in lib/ is short and commented). This is the other half: which widget, which variant, which colour token, in which situation. Getting a Garage app to use Button and Panel is easy. Getting it to actually look like a Garage app — right variant for the right emphasis, right token for the right surface, right gap for the right kind of space — is the part that doesn't fall out of the API. That's what this document is for.

It's written from observed usage in Arcs & Angles (metro_map_maker) and Music Maker, the two real apps built on this package. Every rule below is backed by a real call site, not a guess at what "should" be idiomatic. Where a pattern only has 1-2 examples, that's said explicitly — treat it as a lead, not a law.

Where this comes from

Two influences, and they are not the same kind of influence — conflating them is the usual misreading:

  • shadcn (via shadcn_flutter) — the API surface. Variant names (.primary / .secondary / .outline / .ghost / .destructive), the ColourScheme slot vocabulary, the dot-constructor shape. This is why a shadcn snippet usually compiles.
  • Blender — the rendering. Flat chrome, bordered panels, dense controls, no elevation, a properties pane with a hard split down it. This is why the same snippet doesn't look like shadcn once it runs.

So: not a pixel-for-pixel restyle of shadcn. The names carried over; the geometry, the density and the entire chrome layer did not. shadcn_flutter was dropped as a dependency once the port finished — nothing here defers to it, and where the two disagree on how something should look, this package wins. Expect ported code to compile and then need its spacing and emphasis re-picked against the tables below.

Mental model

A Garage app has three layers, outside-in:

  1. Chrome — the app's own furniture: headers, footers, the menu bar, the shell that docks everything else. Flat, dark, no elevation. Reads as part of the window, not as content sitting on the window.
  2. Panels — bordered, rounded, elevated-feeling regions that hold actual tool content (explorer trees, property inspectors, docked windows). This is where most of the UI actually lives.
  3. Controls — buttons, fields, selects, menus. Live inside panels or chrome, never bare against the app background.

Nothing in this system uses Flutter's Material widgets. There is no ThemeData.dark(), no Colors.black, no Scaffold. Every colour comes from ColourScheme, every size comes from Density, every font comes from Typography. If a widget you're building reaches for package:flutter/material.dart or a literal Color(0x...) outside theme/*.dart, that's the tell you've stepped outside the system.

Getting the theme

final theme = GarageTheme.of(context);
final cs = theme.colorScheme;

Everything hangs off theme: theme.colorScheme, theme.density, theme.typography, theme.iconTheme, theme.radiusMd / .borderRadiusMd (and Sm/Lg/Xl/Xxl), theme.panelRadius, theme.panelGap.

One exception: an app's unaccented scheme — the one nobody's accent-colour override has touched — is read through the app's settings provider, not GarageTheme.of(context). In Arcs & Angles that's context.watch<SettingsProvider>().anaScheme. Reach for this specifically when you need chrome on something that must stay neutral even if the user picked a wild accent colour (this is rare — most code never needs to do this, because ChromeBar/Panel/GarageShell already read chrome/panel internally). See "Colour tokens" below for why chrome gets this treatment.

Colour tokens

ColourScheme (in theme/colour_scheme.dart) is one flat list of named colours — no light/dark split, no derived roles computed at paint time. Every field is authored by hand per scheme (Arcs & Angles ships eleven: zinc, crimson, slate, forest, stone, teal, indigo, amber, carbon, fuchsia, plus each one's light twin). When you add a new UI surface, you're choosing which of these existing tokens it belongs to — you're not inventing a new colour.

Base semantic slots (shadcn-shaped, still the backbone)

Token What it's for
background / foreground The app's base surface and default text colour.
card / cardForeground Card/SurfaceCard fill and the text colour merged inside them.
popover / popoverForeground / popoverBorder Dropdowns, select popups, context menus — anything that floats over content in an OverlayPortal.
primary / primaryHovered / primaryForeground The accent. Active/engaged control state, the one CTA in a dialog. Swappable per-user via withAccent().
secondary / secondaryHovered / secondaryForeground Neutral filled control — a resting toggle, an attached-to-a-field icon button. Not an accent colour, just "has a fill."
muted / mutedForeground De-emphasised text/backgrounds — labels, hints, disabled-adjacent copy. .muted() text extension reads mutedForeground.
destructive Delete/discard/record actions. Used sparingly — 3 call sites total in Arcs & Angles.
border Generic 1px outline — text field default border colour, general dividing lines.
divider A line between things in a layout (menu separators, section dividers) — conceptually different from border even though schemes often set them equal.
ring Keyboard-focus outline. Not the canvas selection ring — see canvasSelectionRing.
chart1-chart5 Reserved for data visualisation, unused by the UI kit itself.

App-chrome slots (the part shadcn never had)

Token What it's for
chrome Header/footer/menu-bar background. Deliberately nudged off background so it reads as app furniture, not content. Read via the unaccented scheme (see above) — chrome shouldn't shift when the user picks an accent. ChromeBar and GarageShell apply this for you; you'd only reach for it by hand building a header from scratch (see blender_header.dart, scene_stats_hud.dart).
panel / panelBorder / panelBorderHighlighted Panel's fill and border — resting vs. "this is the active/hovered one" (see Panel(active: ...)). This is the surface docked tool content lives in.
input / inputBackground / inputBackgroundHovered / inputBackgroundFocused / inputBorder Text field fill (three interaction states) and border.
explorerRowEven / explorerRowOdd / explorerRowHovered / explorerRowText Zebra-striped tree/list rows (explorer panel, layer lists).
menuItemText / menuItemHovered Dropdown/menu row text and hover fill.
popoverItemHovered Hover fill for popover rows that aren't menu items (kept distinct from menuItemHovered while the two are still being evaluated — may merge later).
tooltipBackground / tooltipBorder Tooltips get their own pair rather than reusing popover* — they sit on top of everything and want more contrast than a panel-level surface.
propertiesSectionBackground / propertiesSectionBorder / propertiesSectionLabel The boxed sub-sections inside an object-properties panel.
canvasBackdrop / canvasPaper / canvasGridMinor / canvasGridMajor / canvasGridMajorDot / canvasBoundary Authored canvas colours — not derived at paint time, these are picked by hand per scheme like everything else. App-specific (Arcs & Angles' map canvas); a non-canvas app can mostly ignore this group.
canvasAlignmentGuide Smart-guide lines while dragging/resizing. Deliberately its own slot, not ring — ring is keyboard focus, a different job.
canvasSelectionRing Selection outline/handles. Tracks primary for most schemes but exists as its own slot so an achromatic scheme (carbon: near-black/near-white primary) can still give selection actual hue. If you add withAccent() support anywhere, remember canvasSelectionRing needs the same brightened-for-visibility treatment _brightenForSelectionRing gives it, or an accent override leaves the selection ring the one thing on screen still showing the old colour.

Authoring a new scheme

Look at settings_state.dart in the host app, not the package — that's where the actual eleven-scheme palette lives (the package only defines the type and the neutral fallback used in tests/demos). Each scheme is grouped under // ── Accounted for ── vs // ── not reviewed yet ── comments — that's a live audit trail, not decoration; keep using it as new tokens get added so it's visible which colours were deliberately chosen vs. still riding an old default.

Density & spacing

Density (theme/theme_data.dart) is the only place pixel heights get hand-set. Everything else — icon size, padding, line-box height — is a derived getter. The class doc lays out the chain in full; the short version:

  • You set controlHeight (23 compact / 27 normal), fontSize (10, both densities), lineHeight (1.1, both densities).
  • lineBox = (fontSize * lineHeight).roundToDouble() falls out of those.
  • iconSize = lineBox — a control icon always matches the text beside it.
  • controlPaddingY = (controlHeight - lineBox) / 2 — padding is derived from the height target, never the other way round.

Never hand-type a control height or a vertical padding. If a number needs tuning, it belongs in Density, not at the call site — that's exactly the mistake the class doc says produced four different control heights and a stray Transform.translate before this system existed.

Two densities are in active use: ButtonDensity.compact (54 call sites) is the default for tool chrome — toolbars, menus, panels. ButtonDensity.normal (5 call sites) shows up for things meant to feel less cramped — e.g. the Button.primary "confirm this row" pattern in blender_properties_section.dart via ButtonDensity.fromTheme(theme). Default to compact unless you have a specific reason not to.

Two spacing scales, don't mix them up:

  • controlGap — INSIDE a control (icon-to-label gap). Tighter, on purpose. It's also the base unit the gap scale below is derived from.
  • containerGap / containerPadding — between controls in a panel/popover/ dialog, and the padding inside one.

The gap scale

Layout spacing between widgets comes off theme.density, not a literal:

Step compact normal Use it for
gapXxs 2 3 hairline — a label sat directly above its value
gapXs 4 5 tight — icon-adjacent, or items reading as one unit
gapSm 6 8 snug
gapMd 8 10 the default — related but distinct
gapLg 12 15 section-level, within a panel or form
gapXl 16 20 between major blocks
gapXxl 24 30 page-level
const Gap.md(),   // <- this, not Gap(8)

Gap has a named constructor per step. They resolve their extent from the theme at build time, so a call site stays const and still tracks the density

  • no GarageTheme.of(context) needed in a build method just because it contains a gap. Gap(n) with a literal still works and still wins, for the rare thing that genuinely isn't on the scale.

When in doubt, reach for gapMd. It's the same 8px Gap(8) was, so the old advice hasn't changed — it just has a name now, and it moves when the density does instead of staying 8 forever.

gapMd equals containerGap and gapXl equals containerPadding at both densities. That's not arranged, it's what those two were already set to, which is the evidence controlGap is the right base unit — test/density_gap_scale_test.dart holds it to that.

Reaching for a literal is now the exception, not the rule. Before the scale existed the two Garage web frontends had drifted to sixteen distinct gap values between them, including a 3, a 5 and ten 14s. If a step doesn't fit, that's worth a conversation about the scale rather than a one-off number.

Buttons — variant semantics

Button and IconButton both expose five named constructors: .primary / .secondary / .outline / .ghost / .destructive. Real usage across both apps settles into a clear pattern — this is the single most useful thing in this document:

Variant Meaning Evidence
ghost Default, lowest-emphasis action. Toolbar icons, dialog close (X) buttons, settings-cog buttons. Most common IconButton variant by a wide margin (15 sites). pane_dialog.dart close button, blender_editor.dart header icons
outline Second most common (Button.outline: 14 sites). The "resting/inactive" half of a toggle pair, AND the standard "Cancel"/dismissive action in a dialog action row. export_dialog_widgets.dart: Button.outline(onPressed: onCancel, child: Text("Cancel"))
secondary Neutral filled control — NOT a toggle's resting state (that's outline), more like "has a job but isn't the emphasised one." An icon button glued onto a text field (browse/file-picker button in a ButtonGroup) — see Properties below; the field goes TextFieldVariant.secondary to match, and reaching for outline here is the usual slip. Also used as a toggle's resting state in a couple of places (Music Maker's transport controls) — outline and secondary are somewhat interchangeable for "not active right now," pick whichever reads better against the surrounding controls. export_dialog_widgets.dart browse button, Music Maker transport
primary The accent colour. Two jobs: (1) the engaged half of a toggle-button pair — condition ? primary : outline is the standard toggle idiom, used repeatedly (_SnapPopoverButton, draw_panel.dart eyedropper, Music Maker play/pause); (2) the single confirm/CTA action in a dialog, almost always via the PrimaryButton shorthand rather than Button.primary directly. _open ? IconButton.primary(...) : IconButton.outline(...)
destructive Reserved for genuinely dangerous/irreversible actions — delete, record. Rare on purpose (3 sites total). Don't reach for it just because something is "important." Music Maker's record toggle: recording ? IconButton.destructive(...) : IconButton.secondary(...)

The toggle-button recipe (this exact shape appears in every editor):

active
    ? IconButton.primary(
        density: ButtonDensity.compact,
        icon: Icon(LucideIcons.some_icon).iconSmall,
        onPressed: onToggle,
      )
    : IconButton.outline(
        density: ButtonDensity.compact,
        icon: Icon(LucideIcons.some_icon).iconSmall,
        onPressed: onToggle,
      )

The dialog action-row recipe (export_dialog_widgets.dart, verbatim shape):

Row(
  children: [
    const Spacer(),
    Button.outline(onPressed: onCancel, child: const Text("Cancel")),
    const Gap(8),
    PrimaryButton(onPressed: enabled ? onConfirm : null, child: Text(confirmLabel)),
  ],
)

There are also bare PrimaryButton / SecondaryButton / OutlineButton / GhostButton / DestructiveButton widgets (no .constructor dot-syntax) — lighter-weight wrappers around the same variants. PrimaryButton specifically is the idiomatic way to write a dialog's confirm button, over Button.primary.

Icons

  • Source: flutter_lucide, re-exported through garage_ui.dart as LucideIcons. Names are snake_case (LucideIcons.file_plus, LucideIcons.chevron_right) — this is flutter_lucide's native spelling, used directly. There is no camelCase shim in this package.
  • Three tiers: .iconSmall (density.iconSize), .iconMedium (20px), .iconLarge (24px). Set size via the extension, never a hardcoded size:.
  • Button and IconButton already apply small to everything inside them — leading, trailing and the child — so a bare Icon in a button is correctly sized and needs no extension. Reach for one only to override that. Outside a button the ambient default is medium (20px), which is usually too big for a control-adjacent icon; that's where a bare Icon does go wrong.
  • small is 1.1 × fontSize — 13px at product, 11px at compact/normal — not the line box. It was the line box, which is tidy for layout (an icon-only control matches a text control's height for free) and wrong for the eye: lucide glyphs fill their box nearly edge to edge while a 12px font caps out around 8.5px, so a line-box icon read ~70% taller than the text next to it. Control height is unaffected either way — that comes from controlHeight.
  • Muted/de-emphasised icon: chain .muted() after the size extension — Icon(LucideIcons.chevron_left).iconSmall.muted().

Typography

theme.typography exposes normal / medium (weight-only styles) and small (the shared control font — size and line-height come from Density, never set this by hand). Two font-family helpers:

  • theme.typography.sansStyle(style) — Geist, the UI's default face.
  • theme.typography.monoStyle(style) — Geist Mono. Reach for this specifically for numeric readouts: perf stats, transport time, coordinates — anywhere the content is a number that benefits from fixed-width digits. Not for general UI text.

Text-widget shorthands (Widget extensions, merge over ambient DefaultTextStyle): .xSmall() / .small() / .large(), .medium() / .semiBold() / .bold(), .muted().

The text ladder

The three size shorthands come off density, not literals:

Shorthand Token compact / normal Use it for
.xSmall() textXs 11 dialog copy, headings, hints — and the ambient body size
.small() textSm 14 body copy a step above the controls
.large() textLg 18 headings

textXs sits one step above density.fontSize — 11 against a 10px control font — and is also what GarageTheme sets as the ambient body size, so page copy and dialog copy are the same size by construction.

It was x1.2 (12) for a while, which put every dialog title and page heading a fifth above the controls beneath them. That read as oversized rather than as hierarchy — weight and colour carry the emphasis, not size.

What was wrong before is that the two were unlinked: these were hardcoded 12/14/18 multiplied by scaling, while the control font comes off Density and is deliberately not scaled. The intended 12-vs-10 held at scaling 1.0 and drifted to 14.4-vs-10 at 1.2 — the ratio moved with scaling. Deriving them pins it, and makes density.fontSize the single knob that moves every piece of app text together.

scaling is deliberately not applied to these. It still moves the medium/large icon tiers, border widths and GarageTheme's own DefaultTextStyle — but text that has to line up with a control cannot be on a different axis from the control.

Structural widgets

  • Panel — the bordered, rounded surface for docked tool content. Two modes: standalone (tracks its own hover) or controlled (active: bool, parent drives it — used when multiple panels need to be mutually exclusive, e.g. only one lit at a time in a Blender-style three-pane layout). Border goes from panelBorder to panelBorderHighlighted when active/hovered.

  • ChromeBar — the shared treatment for a header/footer strip: fixed height, chrome background, padding. Both the header and footer in an editor should be built from this rather than a raw Container, so they stay pixel-identical in height (kChromeBarHeight, shared constant).

  • EditorShell — header / center / optional left+right docks / footer, stacked as one frame. The outermost layout of a whole editor screen.

  • GarageShell — main content + two stacked sidebar panes with a draggable resize handle between main and sidebar, plus centralised hover (only one of the three panes lit at once). This is the Blender-style three-pane editor shell, factored out so it isn't hand-rolled per app.

  • ButtonGroup — lays controls out in a row (or column) and zeroes the corners where they touch, so they read as one connected control rather than two things that happen to be adjacent. A field with a button welded to its end (password + edit, path + browse, input + unit) is a ButtonGroup, not a Row with a Gap in it. Nested groups merge rather than shadow — an inner group can be told to drop both its top and its start edge, which is how a stacked field/eye/button cluster avoids a doubled stroke down the seam.

    Watch expands. It defaults to false, which wraps the flex in an IntrinsicHeight; the group stretches its children on the cross axis, so without that wrapper it needs a bounded height from its parent and throws BoxConstraints forces an infinite height when it doesn't get one. Only pass expands: true when the parent already gives it a height.

  • Card vs SurfaceCard — Card always draws its own OutlinedContainer (border + fill). SurfaceCard additionally understands sheet-overlay context: inside a sheet it collapses to just padding, because the sheet is already the surface and a nested card would double up the border. Default to Card unless the content might end up inside a sheet.

  • OutlinedContainer — the base primitive Card/Panel build on: border + radius + optional shadow. Reach for it directly for one-off floating chrome that isn't quite a card — e.g. a mobile slide-in panel:

    OutlinedContainer(
      borderColor: GarageTheme.of(context).colorScheme.border,
      boxShadow: [
        BoxShadow(color: const Color(0xff000000).withValues(alpha: 0.15), blurRadius: 4, spreadRadius: 2),
      ],
      child: ...,
    )
    

    A bigger, "floating well above the app" shadow (splash/welcome overlay) goes heavier and uses negative spread to keep the blur from reading as a hard edge: blurRadius: 40, spreadRadius: -8, offset: Offset(0, 20), alpha 0.4. Scale shadow weight to how far off the page the thing is meant to read as floating — a docked panel border shadow and a modal-over-everything shadow should not look like the same intensity.

Properties — the settings-row system

Any screen that is a list of things you can change is built from PropertiesSection + PropertyRow. This covers settings panes, inspectors, and account screens. Do not assemble one out of Column + Text + a divider — the split alignment, the collapse behaviour, the actions band and the row minimum height are all in here already.

  • PropertiesSection — a titled, collapsible block: title, subtitle, rows, an optional actions band along the bottom for section-level buttons (Save / Reset / Refresh), and collapsed + onToggle driven by the parent so several sections can be remembered independently.
  • PropertyRow — one setting. label on the left of the split, child (the control) on the right. split is the fraction of the width sitting left of that line, so every row in a section lines its controls up at the same x. labelless: true for a row that has no name of its own.

subtitle vs description — the one that gets got wrong

Both are muted second lines. They are not interchangeable, and picking the wrong one is the single most common mistake against this component:

Where it renders What it's for
subtitle Inside the label column, under the label, holding the same right-alignment against the split Naming the value. "Last used 3d ago", "Never used", "2 permissions · last used 5m ago" — text that says which row this is
description Full width under the whole row, spanning label and control Explaining the setting. Consequences, caveats, what changes when you change it — "Permanently remove this account… This cannot be undone."

The test: does the sentence identify this particular item (subtitle), or does it explain what the control does (description)? A list of five passkeys wants five subtitles, not five full-width paragraphs.

Never put explanatory copy in child. It is the single failure mode this component has. A paragraph in the control column shares a cell with the control, so the copy wraps to three lines, the button gets squeezed against the right edge, and the section's split alignment stops meaning anything because every row's control now starts somewhere different. child is for the control. Copy goes in description.

// WRONG - copy competing with the control for the same column
PropertyRow(
  label: "Delete account",
  scheme: scheme,
  child: Row(children: [
    Expanded(child: Text("Permanently remove this account…").muted()),
    const Gap.md(),
    Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
  ]),
)

// RIGHT - the slot that already exists for it
PropertyRow(
  label: "Delete account",
  scheme: scheme,
  description: "Permanently remove this account, all sign-in methods, and "
      "any OAuth grants. This cannot be undone.",
  child: Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
)

A row that is only explanation and has no control at all is still a PropertyRow — give it the description and pass const SizedBox.shrink() as the child.

Fields inside a property row

TextField takes a variant: TextFieldVariant.outline (default) or .secondary. Inside a properties pane, prefer .secondary — it matches the filled treatment the surrounding controls use, and it pairs with ButtonStyle.secondary() when a button is welded to the field in a ButtonGroup. An outline field next to a secondary button (or the reverse) reads as two controls from different screens.

A value the user is not allowed to edit — a password, a verified email — is still a field: readOnly: true, enabled: false with a stand-in value, not a bare Text floating in the column. It keeps the row's geometry and tells the reader "this is a value that lives here" rather than "this is a caption."

The menu system — one model, two renderers

This is the pattern from the AppMenuGroup model in app_menu.dart, and it's worth calling out on its own because it's easy to accidentally reinvent per-app (it has been, twice):

Define the menu once as data — List<AppMenuGroup> (AppMenuGroup → AppMenuAction / AppMenuCheck / AppMenuSeparator) — not as widgets. Then:

  1. In-app render: walk the model into Menubar/MenuButton/ MenuCheckbox/MenuDivider widgets (see _toMenuItem/_menuItemsFor in either app's menu code for the translation).
  2. Native macOS bar: AppMenuNativeRenderer.build(menus, appName: ...) pushed into an AppMenuNotifier sitting above the app's Router, via PlatformMenuHost.

Both renderers must consume the same computed menus value from the same build pass — not two independent calls to whatever builds the model. Two separate computations drift: they'll watch slightly different state, recompute at different times, and eventually disagree about what's checked or what a shortcut is (this exact bug shipped and had to be fixed). Compute once, hand the value to both. Gate the native push on AppMenuNativeRenderer.signature(menus), not the list itself — building a fresh AppMenuGroup tree (and fresh SingleActivators inside it) on every build is normal and fine, but pushing to the native bar on every build is not — the signature is what turns "recomputed every frame" into "pushed only when it actually changed."

Anti-patterns

  • No package:flutter/material.dart. Not even for Colors.black. Use Color(0xff000000) / Color(0xffffffff) — literal, not Colors.*. The UI kit is deliberately WidgetsApp-based, not MaterialApp-based, so an app built on it shouldn't reach for Material either.
  • Don't hand-type a control height, icon size, or vertical padding. It belongs in Density as a named field with everything else derived from it.
  • Don't build chrome/panel treatment from raw Container + hardcoded colour. Use ChromeBar/Panel/EditorShell/GarageShell — they read the right token (and in chrome's case, the right unaccented scheme) for you.
  • Don't hand-roll a settings row. PropertiesSection + PropertyRow exist, and a Column of Text + control + divider will silently lose the split alignment, the collapse state and the actions band. If you find yourself writing Row(children: [Expanded(Text(...)), Gap, Button]) inside a settings pane, you want description: instead.
  • Don't glue a button to a field with a Gap. That's a ButtonGroup — it merges the touching corners so the pair reads as one control.
  • Don't build a menu as widgets directly. Model it as AppMenuGroup data first (see above), even if there's currently only one renderer consuming it — a native menu bar tends to get added later, and retrofitting a model under existing widget-only menu code is exactly the refactor that motivated this document.
  • Don't reach for destructive for "this matters." It means delete/discard/record — genuinely dangerous, genuinely irreversible.