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
`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.
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.
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.
## Colour tokens
Read the theme with `GarageTheme.of(context)`: `.colorScheme`, `.density`,
`.typography`, `.iconTheme`, `.radiusMd` and friends.
`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.
### Colour schemes
### 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." |
| `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. |
| `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.
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):
| `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.
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`.
```dart
// 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()),
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.