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
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
Leave the rest of `ThemeData` on its defaults — `scaling 1.0`, `radius 0.5`
override has touched — is read through the app's settings provider, not
(so `radiusSm 4`, `radiusMd 6`), `panelRadius 10`, `panelGap 5`. Both
`GarageTheme.of(context)`. In Arcs & Angles that's
reference apps do.
`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.
## 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
### Colour schemes
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.
### Base semantic slots (shadcn-shaped, still the backbone)
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:
| Token | What it's for |
```dart
|---|---|
ColourScheme.derive(
| `background` / `foreground` | The app's base surface and default text colour. |
brightness:Brightness.dark,
| `card` / `cardForeground` | `Card`/`SurfaceCard` fill and the text colour merged inside them. |
background:...,
| `popover` / `popoverForeground` / `popoverBorder` | Dropdowns, select popups, context menus — anything that floats over content in an `OverlayPortal`. |
foreground:...,
| `primary` / `primaryHovered` / `primaryForeground` | The accent. Active/engaged control state, the one CTA in a dialog. Swappable per-user via `withAccent()`. |
primary:...,
| `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)
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.
| Token | What it's for |
A user-picked accent goes through `scheme.withAccent(colour)`, which swaps
|---|---|
`primary`, `primaryHovered`, `ring` and a contrasting `primaryForeground`, and
| `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`). |
nothing else.
| `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
### Density: pick the tier, don't tune it
Look at `settings_state.dart` in the host app, not the package — that's where
| | `compact` | `normal` | `product` |
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.
| `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 |
| Variant | Meaning | Evidence |
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
| **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 |
`shiftLstar` so they sit on the same ladder.
| **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):
- **`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.
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`,
## Buttons
`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
### Variants mean something
Both are muted second lines. They are not interchangeable, and picking the
| Variant | Means |
wrong one is the single most common mistake against this component:
|---|---|
| `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 |
| | Where it renders | What it's for |
`Button.primary(...)`, `Button.ghostDestructive(...)` etc. take a `style:`;
|---|---|---|
the `PrimaryButton` / `OutlineButton` / … wrappers take `density:` directly.
| **`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
### By context
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
| Where | What |
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
| Icon only, anywhere | `IconButton.<variant>`, never a `Button` with just an icon in it |
the right edge, and the section's split alignment stops meaning anything
| Panel header actions | `IconButton.ghost`, each in a`Tooltip` |
because every row's control now starts somewhere different. `child` is for
| Nav rail item | `ButtonStyle.ghost`, `secondary` when selected (never primary), `alignment: centerLeft`, `leading: Icon` |
the control. Copy goes in`description`.
| Toolbar / header toggle | `IconButton.primary` when on, `outline` when off (`secondary` off for a master switch whose off state matters) |
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.