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
18 KiB
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 thecompactdensity. - The Garage hub (
garage_dashboard_portal) — a web product on theproductdensity.
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
TextStylewith afontSize,fontWeightorcolor - give an
Iconasize:orcolor: - write a literal
EdgeInsets,SizedBox,Gap(8)orBorderRadius.circular(8) - pass
alignment:to a button that doesnt need it - force
density: ControlDensity.compacton a control to make it "fit" - wrap a dialog's content in a
ConstrainedBox(maxWidth: 360) - build a
Containerwith 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()anddensity.gapXs… for space between thingsdensity.textXxs…density.textLg, or the text extensions, for text sizetheme.typography.medium/.semiBoldfor weightscheme.<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
fontSizeat w300. PlainText("...")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; readdensity.textXxswhen 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/iconslot: a bareconst 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/.iconLargeset size only (never colour).iconSmallequalsfontSize: 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).
ChromeBarfor a header or footer — it'schromeBarHeighttall onchrome.Panelfor a docked region. Flat,backgroundfill, 1.15 border.PanelHeader(icon, title, scheme, trailing:)at the top of a panel body. A&A's panel pages are allColoredBox(surfaceSunken)→PanelHeader(bottomPadding: 0)→ScrollEdgeFade(ListView(...)).Card/SurfaceCardfor a raised block (cardfill,radiusXl).OutlinedContainerfor 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.centerfor full-width buttons and segments.Alignment.centerLeftonly 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. secondaryfor numeric property values and read-only values.- Inside a
PropertyRowyou don't choose — the row wraps its child in aPropertySlotScopeandTextField,SelectandDateInputare forced to secondary. An explicitvariant:loses. (Buttons aren't forced.) - Numeric editor fields (A&A): secondary,
textAlign: center,InputFeature.scrub(...), a unit as a trailing mutedText. Put trailing features beforescrub— list order is render order. - A read-only value is a
TextField(readOnly: true, enabled: false), never a bareText(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 secondaryTextField(readOnly: true, enabled: false)— the hub wraps that asValueField. 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
subtitleis 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.descriptionis longer copy about a setting.
Dialogs, sheets, panes, overlays
- Confirm / small dialog:
showDialog→AlertDialog(title, content, actions: [cancel, confirm]). It'scard-filled, capped atkDialogMaxWidth(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(...)withSheetRows. - 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;showContextMenufor right-click; app menus asAppMenuItemdata.
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 bareIcon.- Tooltip text forced to
foregroundorbackground, customTooltipContainerpadding. - Literal font sizes in the agent panel and the download modal.
- The hand-rolled snap popover,
PaneDialogtitle bar and properties tab strip. - Plain
Textvalues in settings and downloadPropertyRows. - Literal
SizedBox/EdgeInsetsspacing outside the settings modal.
The hub
- Ghost and link buttons hand-coloured
destructive("Remove", "Revoke", "Refund", "Delete?") — useButtonStyle.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 insideSettingsLists. - Record-card and detail-page subtitles carrying data (sku · price, buyer · date, counts, emails).
- About 37 hand-written copies of
ValueField, and a bareTextvalue in two role rows. - Hand-built toasts (
showToast+SurfaceCard+ literal padding) instead ofshowAppToast. ControlDensity.compacton ordinary buttons outside a header.