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
431 lines
18 KiB
Markdown
431 lines
18 KiB
Markdown
# 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.
|