Files
Garage-SDKs/docs/garage-ui-style-guide.md
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

431 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#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
```dart
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:
```dart
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, `Panel`s 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 `SheetRow`s.
- **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 `PropertyRow`s.
- 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
`IconButton`s, `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 `PropertiesSection`s;
secondary fields inside `SettingsList`s.
- 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.