Files
Garage-SDKs/docs/garage-ui-style-guide.md
T
ImBenjiandClaude Opus 5.5 572c09039f Rewrite the garage_ui style guide, add a ghost destructive button
The old guide had drifted a long way from the package. The new one is
built from blender mode in Arcs & Angles and the Garage hub, and leads with
letting the theme, density and colour scheme do the talking.

ghostDestructive is for remove/revoke/delete in a row, so apps stop hand
colouring ghost buttons red.

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

18 KiB
Raw Permalink Blame History

garage_ui style guide

How to build a screen out of garage_ui so it actually looks like a Garage app. The API is in the source. This is the other half: which widget, which variant, where — and, mostly, what to leave alone.

It's written from the two apps that are the reference:

  • Arcs & Angles, blender mode (metro_map_maker, lib/pages/blender/) — a dense desktop editor on the compact density.
  • The Garage hub (garage_dashboard_portal) — a web product on the product density.

Where those two do something the guide says not to, it's listed at the end under Dont copy these. An existing call site is not precedent.

The one rule: let the theme speak

garage_ui already knows what everything should look like. The colour scheme, the density and the variant between them decide every colour, font size, weight, icon size, padding, radius and gap. Your job is to pick the widget and the variant. Then stop.

So, inside or around a garage_ui component, don't:

  • pass a TextStyle with a fontSize, fontWeight or color
  • give an Icon a size: or color:
  • write a literal EdgeInsets, SizedBox, Gap(8) or BorderRadius.circular(8)
  • pass alignment: to a button that doesnt need it
  • force density: ControlDensity.compact on a control to make it "fit"
  • wrap a dialog's content in a ConstrainedBox(maxWidth: 360)
  • build a Container with a fill, border and radius to make a badge, a pill, a warning box or a popover

Every one of those is a number or a colour that was chosen once, per call site, and stops agreeing with the rest of the app the moment the density, scheme or accent changes. The components are built as families — outline buttons share text fields' fill, stroke and radius on purpose so they "read as one family instead of each getting styled by hand per call site". One override breaks the family.

When something looks wrong, the fix is a different variant, a different widget, or a change in garage_ui so every app gets it. Not a local override. That's how ButtonStyle.ghostDestructive came about: apps kept hand-colouring ghost buttons red, so it became a variant.

What you can reach for, when a component doesnt cover it:

  • Gap.xs() … Gap.xxl() and density.gapXs … for space between things
  • density.textXxs … density.textLg, or the text extensions, for text size
  • theme.typography.medium / .semiBold for weight
  • scheme.<slot> for colour, when you're drawing something garage_ui doesnt have a widget for

These are tokens — they move with the theme. A literal never does.

Setting up

final theme = ThemeData(
  colorScheme: GarageSchemes.carbon,     // or your own, see below
  density: const Density.product(),      // or .compact() / .normal()
);

GarageApp.router(routerConfig: router, theme: theme);

GarageApp is built on WidgetsApp, not MaterialApp, and installs GarageTheme plus the scroll behaviour and scrollbar. Nothing in garage_ui touches Material. If a file reaches for package:flutter/material.dart, it has left the system.

Leave the rest of ThemeData on its defaults — scaling 1.0, radius 0.5 (so radiusSm 4, radiusMd 6), panelRadius 10, panelGap 5. Both reference apps do.

Read the theme with GarageTheme.of(context): .colorScheme, .density, .typography, .iconTheme, .radiusMd and friends.

Colour schemes

Don't hand-author 37 colours. GarageSchemes ships dark, light and carbon (the one the Garage apps run), and ColourScheme.derive builds a complete scheme from four:

ColourScheme.derive(
  brightness: Brightness.dark,
  background: ...,
  foreground: ...,
  primary: ...,
)

Every other slot is derived on a perceptual lightness ladder — surfaces step up or down from the background, strokes are measured off the surface they outline, text is a fraction of the background-to-foreground span. Any derived slot can be pinned by passing it. schemes.dart is the worked example.

A user-picked accent goes through scheme.withAccent(colour), which swaps primary, primaryHovered, ring and a contrasting primaryForeground, and nothing else.

Density: pick the tier, don't tune it

compact normal product
For dense desktop tools the same, roomier web / product apps
Used by Arcs & Angles A&A "Comfortable" the hub
fontSize 10 10 12
controlHeight 23 27 33
chromeBarHeight 31 37 43

Use the named constructor. Density() is the same as Density.compact(), which is a trap for a product app.

Density's fields are the only hand-set pixel values in the system; padding, icon size and line boxes are derived from them. If a tier feels wrong, that's a conversation about the tier, not a reason to set sizes at call sites.

Controls follow the theme's tier. A per-control density: override exists for exactly one job — a header bar that has to match a 23px menu bar — and even then it mixes tiers (compact geometry, the theme's font). Don't use it to make something smaller.

Colour: what the slots are for

You mostly wont touch these — the components read them. When you do draw something yourself, pick the slot by what the thing is.

Slot What it is
background / foreground the page ground and default text. Also a Panel's fill
chrome header and footer bars, and the gutter between panels
surfaceSunken one step below the ground — side rails, panel bodies, list backgrounds
card raised surface — cards, properties sections, dialogs
muted / mutedForeground de-emphasised fill / secondary text, hints, units
rowText / rowHovered a row's resting label colour / hovered row, in lists and menus
popover / popoverBorder menus, select popups, anything floating
tooltipBackground / tooltipBorder tooltips, which sit above everything
primary (+Hovered, Foreground) the accent. The one thing that's on or chosen
secondary (+Hovered, Foreground) a neutral filled control
destructive delete, revoke, discard
controlFill (+Hovered, Focused), controlBorder the shared fill and stroke of every control — fields, selects, outline buttons, checkboxes
switchTrackInactive a Switch while off
border / divider general outline / a seam between things
panelBorder / panelBorderHighlighted a Panel's edge, resting / the lit one
propertiesSectionBorder a properties section's edge
ring keyboard focus
popoverItemHovered hover inside select and date popups
chart1–chart5 data visualisation

There is no panel, input*, explorerRow*, menuItem* or canvas* slot any more. App-specific colours (A&A's canvas) belong in the app, derived with shiftLstar so they sit on the same ladder.

Space

  • Between siblings: Gap.xxs() … Gap.xxl(). They resolve against the density at build time.
  • In padding: density.gapXs … density.gapXxl, containerGap, containerPadding.
  • Never a literal. Gap(8) is 8 in compact and wrong everywhere else.
xxs xs sm md lg xl xxl
compact 2 4 6 8 12 16 24
normal, product 3 5 8 10 15 20 30

A lot of the spacing is already in the components. PropertiesSection carries its own margin (gapXs either side, gapLg below), so a stack of sections needs no gaps between them. SettingsList insets its own rows. Don't add padding around things that already have it.

Type

  • The ambient text is fontSize at w300. Plain Text("...") is already right for body copy.
  • Controls render their own labels at w400; primary buttons at w600 as optical compensation. Don't bold a button label.
  • Size steps are the extensions: .xSmall() (textXs), .small() (textSm), .large() (textLg). Weight: .medium(), .semiBold(), .bold(). Secondary text: .muted().
  • The step below the control font, textXxs, has no extension; read density.textXxs when you need it (group captions).
  • Numbers and readouts: theme.typography.monoStyle(...).
Job How
Page title Text(title).large()
Page / section subtitle muted text under it
Section heading inside a page .small().semiBold()
Nav group caption density.textXxs, w600, .muted()
Hint, unit, empty-state line .muted() (hub list text: .xSmall().muted())
Footer stats, perf readouts monoStyle, muted

.muted() colours text only. It does nothing to an Icon.

Icons

  • Lucide only: LucideIcons.x.
  • Inside a control's leading / trailing / icon slot: a bare const Icon(LucideIcons.x). The control sizes and colours it — the variant's icon theme is merged over everything inside the button.
  • Outside a control, .iconSmall / .iconMedium / .iconLarge set size only (never colour). iconSmall equals fontSize: 10 compact, 12 product.
  • No literal size: 13.

Surfaces and layout

Both apps are the same shape: a chrome ground, Panels standing on it, and panelGap gutters between them.

ColoredBox(chrome)
  Padding(panelGap)
    Row[ Panel(rail), SizedBox(width: panelGap), Expanded(Panel(workspace)) ]

A&A gets this from GarageShell (header, footer, main, a sidebar split top and bottom). The hub builds the same shape by hand because it needs a nav rail. Only one panel is lit at a time (panelBorderHighlighted).

  • ChromeBar for a header or footer — it's chromeBarHeight tall on chrome.
  • Panel for a docked region. Flat, background fill, 1.15 border.
  • PanelHeader(icon, title, scheme, trailing:) at the top of a panel body. A&A's panel pages are all ColoredBox(surfaceSunken) → PanelHeader(bottomPadding: 0) → ScrollEdgeFade(ListView(...)).
  • Card / SurfaceCard for a raised block (card fill, radiusXl).
  • OutlinedContainer for a bordered box that isn't a card.
  • Flat. No shadows on anything — the border and the surface step are what separate things. The package's sheet, menu and popup surfaces are all shadowless.

Buttons

Variants mean something

Variant Means
primary the one action, or the thing that's on / chosen. One per view, ideally
secondary a neutral filled control — a resting toggle, a button welded to a field, the selected item in a nav
outline a normal action that isn't the main one — Cancel, Retry, Previous / Next
ghost chrome, toolbars, nav items, row actions — anything that shouldn't compete
destructive the confirm button of a destructive dialog
ghostDestructive a destructive trigger that isnt a filled slab — Remove, Revoke, Delete in a row
link / text inline, in running copy

Button.primary(...), Button.ghostDestructive(...) etc. take a style:; the PrimaryButton / OutlineButton / … wrappers take density: directly.

By context

Where What
Icon only, anywhere IconButton.<variant>, never a Button with just an icon in it
Panel header actions IconButton.ghost, each in a Tooltip
Nav rail item ButtonStyle.ghost, secondary when selected (never primary), alignment: centerLeft, leading: Icon
Toolbar / header toggle IconButton.primary when on, outline when off (secondary off for a master switch whose off state matters)
Segmented choice ButtonGroup.horizontal, Expanded children, chosen one primary, rest secondary, alignment: center
Button welded to a field ButtonGroup.horizontal[field, IconButton.secondary]
Action inside a PropertyRow ButtonStyle.secondary (the row doesnt force buttons, you pick it)
Action in a SettingsRow ghost
Dialog Cancel outline, confirm Button.primary or Button.destructive
Retry after an error outline
Empty-state call to action outline, centred
Full-width stacked flow buttons (auth) primary last, every one alignment: Alignment.center

Alignment

Leave alignment: off unless the button is stretched wider than its label. Then:

  • Alignment.center for full-width buttons and segments.
  • Alignment.centerLeft only for nav and list rows, and only with a leading icon.

Never centerLeft with a trailing-only icon. When alignment is set and exactly one of leading / trailing is present, Button puts an invisible spacer on the empty side so the label lands on the true centre — with centerLeft that just indents the label behind a phantom icon.

Toggles

Toggle is the package's on/off button: ghost while off, secondary while on. Editor toolbars use the primary-on pattern above instead. Pick one per surface.

Inputs

Text fields

  • Default (outline) for free text: names, search, a composer, a form.
  • secondary for numeric property values and read-only values.
  • Inside a PropertyRow you don't choose — the row wraps its child in a PropertySlotScope and TextField, Select and DateInput are forced to secondary. An explicit variant: loses. (Buttons aren't forced.)
  • Numeric editor fields (A&A): secondary, textAlign: center, InputFeature.scrub(...), a unit as a trailing muted Text. Put trailing features before scrub — list order is render order.
  • A read-only value is a TextField(readOnly: true, enabled: false), never a bare Text (see below).

Select

SelectVariant.secondary in property rows (forced anyway), ghost in a SettingsRow or where the select reports a value rather than offering a control, default outline elsewhere.

Booleans

A&A uses Checkbox throughout. The hub uses Switch in rows. Either is fine; don't mix them within one surface.

Errors

Use the row's error: slot (PropertyRow and SettingsRow both have one). It reddens the outline, never the fill. Don't hand-roll a red Text under a field. "Required" goes in the row's action: slot.

Properties and settings

The two list shapes, and the hub's two page archetypes.

PropertiesSection + PropertyRow — a boxed card of label/value rows. For inspectors, account and security pages, and lists of records (one collapsible section per record, then the hub's Pager).

  • The value slot is always a field or a control, never bare Text. A read-only value is a secondary TextField(readOnly: true, enabled: false) — the hub wraps that as ValueField. Beside real fields, bare text reads as a caption and the column goes ragged.
  • A button in the value slot goes in Align(centerLeft) so it doesn't stretch.
  • Controls here are secondary.
  • Editor rows carry PropertyActions (right-click reset / copy / paste).
  • A status badge or count goes in the section's trailing, which stays visible when collapsed.
  • Actions go in the section's actions: band.

SettingsList + SettingsRow — unboxed rows ruled between entries, on the page ground. For forms: products, coupons, clients.

  • There's no card, so filled controls would float. Selects, buttons and icon buttons are ghost; text fields are the default outline — read-only ones too.

Converting one to the other means changing the variants, not just the container.

Subtitle vs description

  • subtitle is a short static line saying what the thing is. Never live data: no counts, dates, names, emails, statuses or "3 of 5". This holds everywhere — page headings, section headings, rows, and record cards in a list. A record's identifying data goes in its rows.
  • description is longer copy about a setting.

Dialogs, sheets, panes, overlays

  • Confirm / small dialog: showDialog → AlertDialog(title, content, actions: [cancel, confirm]). It's card-filled, capped at kDialogMaxWidth (350) and renders the actions in its own band. Don't constrain its width yourself, and don't build the action row by hand.
  • Bottom sheet: showSheet(...) with SheetRows.
  • Big pane (settings, export — A&A): showPaneOverlay, blurred scrim, inside the same chrome → Panel → gutter shape as the editor.
  • Toasts: showAppToast(context: context, title: ..., subtitle: ..., severity: ...).
  • Tooltips: Tooltip(tooltip: (_) => TooltipContainer(child: Text(msg))), unstyled.
  • Menus: MenuButton, MenuDivider, MenuLabel; showContextMenu for right-click; app menus as AppMenuItem data.

Loading, empty, error

  • Loading: a CircularProgressIndicator. No skeletons, no shimmer.
  • Empty: one muted line.
  • Error: the message muted, and an outline Retry.

Plainest thing that works. If a page wants more, it's asking for a new garage_ui component, not a one-off.

Dont copy these

Known overrides in the reference apps. They're debt, not patterns.

Arcs & Angles

  • IconButton.ghost(icon: Icon(x, size: 13)) in panel headers, the tab strip and the agent composer — should be a bare Icon.
  • Tooltip text forced to foreground or background, custom TooltipContainer padding.
  • Literal font sizes in the agent panel and the download modal.
  • The hand-rolled snap popover, PaneDialog title bar and properties tab strip.
  • Plain Text values in settings and download PropertyRows.
  • Literal SizedBox / EdgeInsets spacing outside the settings modal.

The hub

  • Ghost and link buttons hand-coloured destructive ("Remove", "Revoke", "Refund", "Delete?") — use ButtonStyle.ghostDestructive.
  • TextStyle(fontWeight: w600) on primary labels in the older auth steps.
  • Four competing badges (disabled compact buttons, disabled coloured IconButtons, SandboxTag, Pill), and hand-built warning boxes (FlowError, the delete-account box, _Banner).
  • ConstrainedBox(maxWidth: 360/380) around dialog content.
  • Literal widths on toolbar selects.
  • Outline text fields and ghost selects inside PropertiesSections; secondary fields inside SettingsLists.
  • Record-card and detail-page subtitles carrying data (sku · price, buyer · date, counts, emails).
  • About 37 hand-written copies of ValueField, and a bare Text value in two role rows.
  • Hand-built toasts (showToast + SurfaceCard + literal padding) instead of showAppToast.
  • ControlDensity.compact on ordinary buttons outside a header.