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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
21c380595f
commit
572c09039f
+387
-460
@@ -1,503 +1,430 @@
|
|||||||
# Garage UI style guide
|
# garage_ui style guide
|
||||||
|
|
||||||
This is not the widget API reference (read the source for that — every file in
|
How to build a screen out of garage_ui so it actually looks like a Garage app.
|
||||||
`lib/` is short and commented). This is the *other* half: which widget, which
|
The API is in the source. This is the other half: which widget, which variant,
|
||||||
variant, which colour token, in which situation. Getting a Garage app to use
|
where — and, mostly, what to leave alone.
|
||||||
`Button` and `Panel` is easy. Getting it to actually look like a Garage app —
|
|
||||||
right variant for the right emphasis, right token for the right surface, right
|
|
||||||
gap for the right kind of space — is the part that doesn't fall out of the
|
|
||||||
API. That's what this document is for.
|
|
||||||
|
|
||||||
It's written from **observed usage** in Arcs & Angles (metro_map_maker) and
|
It's written from the two apps that are the reference:
|
||||||
Music Maker, the two real apps built on this package. Every rule below is
|
|
||||||
backed by a real call site, not a guess at what "should" be idiomatic. Where
|
|
||||||
a pattern only has 1-2 examples, that's said explicitly — treat it as a lead,
|
|
||||||
not a law.
|
|
||||||
|
|
||||||
## Where this comes from
|
- **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.
|
||||||
|
|
||||||
Two influences, and they are not the same kind of influence — conflating them
|
Where those two do something the guide says not to, it's listed at the end
|
||||||
is the usual misreading:
|
under [Dont copy these](#dont-copy-these). An existing call site is not
|
||||||
|
precedent.
|
||||||
|
|
||||||
- **shadcn (via `shadcn_flutter`) — the API surface.** Variant names
|
|
||||||
(`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`), the
|
|
||||||
`ColourScheme` slot vocabulary, the dot-constructor shape. This is why a
|
|
||||||
shadcn snippet usually *compiles*.
|
|
||||||
- **Blender — the rendering.** Flat chrome, bordered panels, dense controls,
|
|
||||||
no elevation, a properties pane with a hard split down it. This is why the
|
|
||||||
same snippet doesn't *look* like shadcn once it runs.
|
|
||||||
|
|
||||||
So: **not a pixel-for-pixel restyle of shadcn.** The names carried over; the
|
## The one rule: let the theme speak
|
||||||
geometry, the density and the entire chrome layer did not. `shadcn_flutter`
|
|
||||||
was dropped as a dependency once the port finished — nothing here defers to
|
|
||||||
it, and where the two disagree on how something should look, this package
|
|
||||||
wins. Expect ported code to compile and then need its spacing and emphasis
|
|
||||||
re-picked against the tables below.
|
|
||||||
|
|
||||||
## Mental model
|
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.
|
||||||
|
|
||||||
A Garage app has three layers, outside-in:
|
So, inside or around a garage_ui component, don't:
|
||||||
|
|
||||||
1. **Chrome** — the app's own furniture: headers, footers, the menu bar, the
|
- pass a `TextStyle` with a `fontSize`, `fontWeight` or `color`
|
||||||
shell that docks everything else. Flat, dark, no elevation. Reads as part
|
- give an `Icon` a `size:` or `color:`
|
||||||
of the window, not as content sitting on the window.
|
- write a literal `EdgeInsets`, `SizedBox`, `Gap(8)` or `BorderRadius.circular(8)`
|
||||||
2. **Panels** — bordered, rounded, elevated-feeling regions that hold actual
|
- pass `alignment:` to a button that doesnt need it
|
||||||
tool content (explorer trees, property inspectors, docked windows). This
|
- force `density: ControlDensity.compact` on a control to make it "fit"
|
||||||
is where most of the UI actually lives.
|
- wrap a dialog's content in a `ConstrainedBox(maxWidth: 360)`
|
||||||
3. **Controls** — buttons, fields, selects, menus. Live inside panels or
|
- build a `Container` with a fill, border and radius to make a badge, a pill, a
|
||||||
chrome, never bare against the app background.
|
warning box or a popover
|
||||||
|
|
||||||
Nothing in this system uses Flutter's Material widgets. There is no
|
Every one of those is a number or a colour that was chosen once, per call site,
|
||||||
`ThemeData.dark()`, no `Colors.black`, no `Scaffold`. Every colour comes from
|
and stops agreeing with the rest of the app the moment the density, scheme or
|
||||||
`ColourScheme`, every size comes from `Density`, every font comes from
|
accent changes. The components are built as families — outline buttons share
|
||||||
`Typography`. If a widget you're building reaches for `package:flutter/material.dart`
|
text fields' fill, stroke and radius on purpose so they "read as one family
|
||||||
or a literal `Color(0x...)` outside `theme/*.dart`, that's the tell you've
|
instead of each getting styled by hand per call site". One override breaks the
|
||||||
stepped outside the system.
|
family.
|
||||||
|
|
||||||
## Getting the theme
|
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
|
```dart
|
||||||
final theme = GarageTheme.of(context);
|
final theme = ThemeData(
|
||||||
final cs = theme.colorScheme;
|
colorScheme: GarageSchemes.carbon, // or your own, see below
|
||||||
|
density: const Density.product(), // or .compact() / .normal()
|
||||||
|
);
|
||||||
|
|
||||||
|
GarageApp.router(routerConfig: router, theme: theme);
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything hangs off `theme`: `theme.colorScheme`, `theme.density`,
|
`GarageApp` is built on `WidgetsApp`, not `MaterialApp`, and installs
|
||||||
`theme.typography`, `theme.iconTheme`, `theme.radiusMd` / `.borderRadiusMd`
|
`GarageTheme` plus the scroll behaviour and scrollbar. Nothing in garage_ui
|
||||||
(and `Sm`/`Lg`/`Xl`/`Xxl`), `theme.panelRadius`, `theme.panelGap`.
|
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
|
||||||
| Token | What it's for |
|
complete scheme from four:
|
||||||
|---|---|
|
|
||||||
| `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." |
|
|
||||||
| `muted` / `mutedForeground` | De-emphasised text/backgrounds — labels, hints, disabled-adjacent copy. `.muted()` text extension reads `mutedForeground`. |
|
|
||||||
| `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. |
|
|
||||||
| `explorerRowEven` / `explorerRowOdd` / `explorerRowHovered` / `explorerRowText` | Zebra-striped tree/list rows (explorer panel, layer lists). |
|
|
||||||
| `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.
|
|
||||||
- `controlPaddingY = (controlHeight - lineBox) / 2` — padding is *derived
|
|
||||||
from* the height target, never the other way round.
|
|
||||||
|
|
||||||
**Never hand-type a control height or a vertical padding.** If a number needs
|
|
||||||
tuning, it belongs in `Density`, not at the call site — that's exactly the
|
|
||||||
mistake the class doc says produced four different control heights and a
|
|
||||||
stray `Transform.translate` before this system existed.
|
|
||||||
|
|
||||||
Two densities are in active use: `ButtonDensity.compact` (54 call sites) is
|
|
||||||
the default for tool chrome — toolbars, menus, panels. `ButtonDensity.normal`
|
|
||||||
(5 call sites) shows up for things meant to feel less cramped — e.g. the
|
|
||||||
`Button.primary` "confirm this row" pattern in `blender_properties_section.dart`
|
|
||||||
via `ButtonDensity.fromTheme(theme)`. Default to compact unless you have a
|
|
||||||
specific reason not to.
|
|
||||||
|
|
||||||
Two spacing scales, don't mix them up:
|
|
||||||
- `controlGap` — INSIDE a control (icon-to-label gap). Tighter, on purpose.
|
|
||||||
It's also the base unit the gap scale below is derived from.
|
|
||||||
- `containerGap` / `containerPadding` — between controls in a panel/popover/
|
|
||||||
dialog, and the padding inside one.
|
|
||||||
|
|
||||||
### The gap scale
|
|
||||||
|
|
||||||
Layout spacing between widgets comes off `theme.density`, not a literal:
|
|
||||||
|
|
||||||
| Step | compact | normal | Use it for |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `gapXxs` | 2 | 3 | hairline — a label sat directly above its value |
|
|
||||||
| `gapXs` | 4 | 5 | tight — icon-adjacent, or items reading as one unit |
|
|
||||||
| `gapSm` | 6 | 8 | snug |
|
|
||||||
| `gapMd` | 8 | 10 | **the default** — related but distinct |
|
|
||||||
| `gapLg` | 12 | 15 | section-level, within a panel or form |
|
|
||||||
| `gapXl` | 16 | 20 | between major blocks |
|
|
||||||
| `gapXxl` | 24 | 30 | page-level |
|
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
const Gap.md(), // <- this, not Gap(8)
|
ColourScheme.derive(
|
||||||
```
|
brightness: Brightness.dark,
|
||||||
|
background: ...,
|
||||||
`Gap` has a named constructor per step. They resolve their extent from the
|
foreground: ...,
|
||||||
theme at build time, so a call site stays `const` and still tracks the density
|
primary: ...,
|
||||||
- no `GarageTheme.of(context)` needed in a build method just because it
|
|
||||||
contains a gap. `Gap(n)` with a literal still works and still wins, for the
|
|
||||||
rare thing that genuinely isn't on the scale.
|
|
||||||
|
|
||||||
When in doubt, reach for `gapMd`. It's the same 8px `Gap(8)` was, so the old
|
|
||||||
advice hasn't changed — it just has a name now, and it moves when the density
|
|
||||||
does instead of staying 8 forever.
|
|
||||||
|
|
||||||
`gapMd` equals `containerGap` and `gapXl` equals `containerPadding` at both
|
|
||||||
densities. That's not arranged, it's what those two were already set to, which
|
|
||||||
is the evidence `controlGap` is the right base unit —
|
|
||||||
`test/density_gap_scale_test.dart` holds it to that.
|
|
||||||
|
|
||||||
**Reaching for a literal is now the exception, not the rule.** Before the
|
|
||||||
scale existed the two Garage web frontends had drifted to sixteen distinct gap
|
|
||||||
values between them, including a 3, a 5 and ten 14s. If a step doesn't fit,
|
|
||||||
that's worth a conversation about the scale rather than a one-off number.
|
|
||||||
|
|
||||||
## Buttons — variant semantics
|
|
||||||
|
|
||||||
`Button` and `IconButton` both expose five named constructors:
|
|
||||||
`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`. Real usage
|
|
||||||
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):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
active
|
|
||||||
? IconButton.primary(
|
|
||||||
density: ButtonDensity.compact,
|
|
||||||
icon: Icon(LucideIcons.some_icon).iconSmall,
|
|
||||||
onPressed: onToggle,
|
|
||||||
)
|
|
||||||
: IconButton.outline(
|
|
||||||
density: ButtonDensity.compact,
|
|
||||||
icon: Icon(LucideIcons.some_icon).iconSmall,
|
|
||||||
onPressed: onToggle,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**The dialog action-row recipe** (`export_dialog_widgets.dart`, verbatim shape):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
Button.outline(onPressed: onCancel, child: const Text("Cancel")),
|
|
||||||
const Gap(8),
|
|
||||||
PrimaryButton(onPressed: enabled ? onConfirm : null, child: Text(confirmLabel)),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
There are also bare `PrimaryButton` / `SecondaryButton` / `OutlineButton` /
|
Every other slot is derived on a perceptual lightness ladder — surfaces step
|
||||||
`GhostButton` / `DestructiveButton` widgets (no `.constructor` dot-syntax) —
|
up or down from the background, strokes are measured off the surface they
|
||||||
lighter-weight wrappers around the same variants. `PrimaryButton` specifically
|
outline, text is a fraction of the background-to-foreground span. Any derived
|
||||||
is the idiomatic way to write a dialog's confirm button, over `Button.primary`.
|
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
|
## Icons
|
||||||
|
|
||||||
- Source: `flutter_lucide`, re-exported through `garage_ui.dart` as
|
- Lucide only: `LucideIcons.x`.
|
||||||
`LucideIcons`. **Names are snake_case** (`LucideIcons.file_plus`,
|
- Inside a control's `leading` / `trailing` / `icon` slot: a bare
|
||||||
`LucideIcons.chevron_right`) — this is `flutter_lucide`'s native spelling,
|
`const Icon(LucideIcons.x)`. The control sizes and colours it — the variant's
|
||||||
used directly. There is no camelCase shim in this package.
|
icon theme is merged over everything inside the button.
|
||||||
- Three tiers: `.iconSmall` (`density.iconSize`), `.iconMedium` (20px),
|
- Outside a control, `.iconSmall` / `.iconMedium` / `.iconLarge` set size only
|
||||||
`.iconLarge` (24px). Set size via the extension, never a hardcoded `size:`.
|
(never colour). `iconSmall` equals `fontSize`: 10 compact, 12 product.
|
||||||
- **`Button` and `IconButton` already apply `small` to everything inside
|
- No literal `size: 13`.
|
||||||
them** — `leading`, `trailing` and the child — so a bare `Icon` in a button
|
|
||||||
is correctly sized and needs no extension. Reach for one only to *override*
|
|
||||||
that. Outside a button the ambient default is `medium` (20px), which is
|
|
||||||
usually too big for a control-adjacent icon; that's where a bare `Icon` does
|
|
||||||
go wrong.
|
|
||||||
- `small` is `1.1 × fontSize` — 13px at product, 11px at compact/normal — not
|
|
||||||
the line box. It was the line box, which is tidy for layout (an icon-only
|
|
||||||
control matches a text control's height for free) and wrong for the eye:
|
|
||||||
lucide glyphs fill their box nearly edge to edge while a 12px font caps out
|
|
||||||
around 8.5px, so a line-box icon read ~70% taller than the text next to it.
|
|
||||||
Control height is unaffected either way — that comes from `controlHeight`.
|
|
||||||
- Muted/de-emphasised icon: chain `.muted()` after the size extension —
|
|
||||||
`Icon(LucideIcons.chevron_left).iconSmall.muted()`.
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
`theme.typography` exposes `normal` / `medium` (weight-only styles) and
|
## Surfaces and layout
|
||||||
`small` (the shared control font — size and line-height come from `Density`,
|
|
||||||
never set this by hand). Two font-family helpers:
|
|
||||||
|
|
||||||
- `theme.typography.sansStyle(style)` — Geist, the UI's default face.
|
Both apps are the same shape: a `chrome` ground, `Panel`s standing on it, and
|
||||||
- `theme.typography.monoStyle(style)` — Geist Mono. Reach for this
|
`panelGap` gutters between them.
|
||||||
specifically for **numeric readouts**: perf stats, transport time,
|
|
||||||
coordinates — anywhere the content is a number that benefits from
|
|
||||||
fixed-width digits. Not for general UI text.
|
|
||||||
|
|
||||||
Text-widget shorthands (`Widget` extensions, merge over ambient
|
```
|
||||||
`DefaultTextStyle`): `.xSmall()` / `.small()` / `.large()`, `.medium()` /
|
ColoredBox(chrome)
|
||||||
`.semiBold()` / `.bold()`, `.muted()`.
|
Padding(panelGap)
|
||||||
|
Row[ Panel(rail), SizedBox(width: panelGap), Expanded(Panel(workspace)) ]
|
||||||
### The text ladder
|
|
||||||
|
|
||||||
The three size shorthands come off `density`, not literals:
|
|
||||||
|
|
||||||
| Shorthand | Token | compact / normal | Use it for |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `.xSmall()` | `textXs` | 11 | dialog copy, headings, hints — and the ambient body size |
|
|
||||||
| `.small()` | `textSm` | 14 | body copy a step above the controls |
|
|
||||||
| `.large()` | `textLg` | 18 | headings |
|
|
||||||
|
|
||||||
`textXs` sits one step above `density.fontSize` — 11 against a 10px control
|
|
||||||
font — and is also what `GarageTheme` sets as the ambient body size, so page
|
|
||||||
copy and dialog copy are the same size by construction.
|
|
||||||
|
|
||||||
It was `x1.2` (12) for a while, which put every dialog title and page heading a
|
|
||||||
fifth above the controls beneath them. That read as oversized rather than as
|
|
||||||
hierarchy — weight and colour carry the emphasis, not size.
|
|
||||||
|
|
||||||
What was wrong before is that the two were *unlinked*: these were hardcoded
|
|
||||||
`12`/`14`/`18` multiplied by `scaling`, while the control font comes off
|
|
||||||
`Density` and is deliberately not scaled. The intended 12-vs-10 held at scaling
|
|
||||||
1.0 and drifted to 14.4-vs-10 at 1.2 — the ratio moved with scaling. Deriving
|
|
||||||
them pins it, and makes `density.fontSize` the single knob that moves every
|
|
||||||
piece of app text together.
|
|
||||||
|
|
||||||
`scaling` is deliberately **not** applied to these. It still moves the
|
|
||||||
medium/large icon tiers, border widths and `GarageTheme`'s own
|
|
||||||
`DefaultTextStyle` — but text that has to line up with a control cannot be on
|
|
||||||
a different axis from the control.
|
|
||||||
|
|
||||||
## Structural widgets
|
|
||||||
|
|
||||||
- **`Panel`** — the bordered, rounded surface for docked tool content. Two
|
|
||||||
modes: standalone (tracks its own hover) or controlled (`active: bool`,
|
|
||||||
parent drives it — used when multiple panels need to be mutually exclusive,
|
|
||||||
e.g. only one lit at a time in a Blender-style three-pane layout). Border
|
|
||||||
goes from `panelBorder` to `panelBorderHighlighted` when active/hovered.
|
|
||||||
- **`ChromeBar`** — the shared treatment for a header/footer strip: fixed
|
|
||||||
height, `chrome` background, padding. Both the header and footer in an
|
|
||||||
editor should be built from this rather than a raw `Container`, so they
|
|
||||||
stay pixel-identical in height (`kChromeBarHeight`, shared constant).
|
|
||||||
- **`EditorShell`** — header / center / optional left+right docks / footer,
|
|
||||||
stacked as one frame. The outermost layout of a whole editor screen.
|
|
||||||
- **`GarageShell`** — main content + two stacked sidebar panes with a
|
|
||||||
draggable resize handle between main and sidebar, plus centralised hover
|
|
||||||
(only one of the three panes lit at once). This is the Blender-style
|
|
||||||
three-pane editor shell, factored out so it isn't hand-rolled per app.
|
|
||||||
- **`ButtonGroup`** — lays controls out in a row (or column) and zeroes the
|
|
||||||
corners where they touch, so they read as one connected control rather than
|
|
||||||
two things that happen to be adjacent. A field with a button welded to its
|
|
||||||
end (password + edit, path + browse, input + unit) is a `ButtonGroup`, not a
|
|
||||||
`Row` with a `Gap` in it. Nested groups merge rather than shadow — an inner
|
|
||||||
group can be told to drop both its top and its start edge, which is how a
|
|
||||||
stacked field/eye/button cluster avoids a doubled stroke down the seam.
|
|
||||||
|
|
||||||
Watch `expands`. It defaults to `false`, which wraps the flex in an
|
|
||||||
`IntrinsicHeight`; the group stretches its children on the cross axis, so
|
|
||||||
without that wrapper it needs a bounded height from its parent and throws
|
|
||||||
`BoxConstraints forces an infinite height` when it doesn't get one. Only
|
|
||||||
pass `expands: true` when the parent already gives it a height.
|
|
||||||
|
|
||||||
- **`Card`** vs **`SurfaceCard`** — `Card` always draws its own
|
|
||||||
`OutlinedContainer` (border + fill). `SurfaceCard` additionally understands
|
|
||||||
sheet-overlay context: inside a sheet it collapses to just padding, because
|
|
||||||
the sheet is already the surface and a nested card would double up the
|
|
||||||
border. Default to `Card` unless the content might end up inside a sheet.
|
|
||||||
- **`OutlinedContainer`** — the base primitive `Card`/`Panel` build on:
|
|
||||||
border + radius + optional shadow. Reach for it directly for one-off
|
|
||||||
floating chrome that isn't quite a card — e.g. a mobile slide-in panel:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
OutlinedContainer(
|
|
||||||
borderColor: GarageTheme.of(context).colorScheme.border,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(color: const Color(0xff000000).withValues(alpha: 0.15), blurRadius: 4, spreadRadius: 2),
|
|
||||||
],
|
|
||||||
child: ...,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
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()),
|
|
||||||
const Gap.md(),
|
|
||||||
Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
// RIGHT - the slot that already exists for it
|
|
||||||
PropertyRow(
|
|
||||||
label: "Delete account",
|
|
||||||
scheme: scheme,
|
|
||||||
description: "Permanently remove this account, all sign-in methods, and "
|
|
||||||
"any OAuth grants. This cannot be undone.",
|
|
||||||
child: Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A row that is *only* explanation and has no control at all is still a
|
A&A gets this from `GarageShell` (header, footer, main, a sidebar split top and
|
||||||
`PropertyRow` — give it the `description` and pass `const SizedBox.shrink()`
|
bottom). The hub builds the same shape by hand because it needs a nav rail.
|
||||||
as the child.
|
Only one panel is lit at a time (`panelBorderHighlighted`).
|
||||||
|
|
||||||
### Fields inside a property row
|
- **`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.
|
||||||
|
|
||||||
`TextField` takes a `variant`: `TextFieldVariant.outline` (default) or
|
|
||||||
`.secondary`. Inside a properties pane, prefer `.secondary` — it matches the
|
|
||||||
filled treatment the surrounding controls use, and it pairs with
|
|
||||||
`ButtonStyle.secondary()` when a button is welded to the field in a
|
|
||||||
`ButtonGroup`. An `outline` field next to a `secondary` button (or the
|
|
||||||
reverse) reads as two controls from different screens.
|
|
||||||
|
|
||||||
A value the user is not allowed to edit — a password, a verified email — is
|
## Buttons
|
||||||
still a field: `readOnly: true, enabled: false` with a stand-in value, not a
|
|
||||||
bare `Text` floating in the column. It keeps the row's geometry and tells the
|
|
||||||
reader "this is a value that lives here" rather than "this is a caption."
|
|
||||||
|
|
||||||
## The menu system — one model, two renderers
|
### Variants mean something
|
||||||
|
|
||||||
This is the pattern from the `AppMenuGroup` model in `app_menu.dart`, and
|
| Variant | Means |
|
||||||
it's worth calling out on its own because it's easy to accidentally
|
|---|---|
|
||||||
reinvent per-app (it has been, twice):
|
| `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 |
|
||||||
|
|
||||||
Define the menu once as data — `List<AppMenuGroup>` (`AppMenuGroup` →
|
`Button.primary(...)`, `Button.ghostDestructive(...)` etc. take a `style:`;
|
||||||
`AppMenuAction` / `AppMenuCheck` / `AppMenuSeparator`) — not as widgets. Then:
|
the `PrimaryButton` / `OutlineButton` / … wrappers take `density:` directly.
|
||||||
|
|
||||||
1. **In-app render**: walk the model into `Menubar`/`MenuButton`/
|
### By context
|
||||||
`MenuCheckbox`/`MenuDivider` widgets (see `_toMenuItem`/`_menuItemsFor` in
|
|
||||||
either app's menu code for the translation).
|
|
||||||
2. **Native macOS bar**: `AppMenuNativeRenderer.build(menus, appName: ...)`
|
|
||||||
pushed into an `AppMenuNotifier` sitting above the app's `Router`, via
|
|
||||||
`PlatformMenuHost`.
|
|
||||||
|
|
||||||
Both renderers must consume the **same** computed `menus` value from the
|
| Where | What |
|
||||||
**same** build pass — not two independent calls to whatever builds the model.
|
|---|---|
|
||||||
Two separate computations drift: they'll watch slightly different state,
|
| Icon only, anywhere | `IconButton.<variant>`, never a `Button` with just an icon in it |
|
||||||
recompute at different times, and eventually disagree about what's checked or
|
| Panel header actions | `IconButton.ghost`, each in a `Tooltip` |
|
||||||
what a shortcut is (this exact bug shipped and had to be fixed). Compute
|
| Nav rail item | `ButtonStyle.ghost`, `secondary` when selected (never primary), `alignment: centerLeft`, `leading: Icon` |
|
||||||
once, hand the value to both. Gate the native push on
|
| Toolbar / header toggle | `IconButton.primary` when on, `outline` when off (`secondary` off for a master switch whose off state matters) |
|
||||||
`AppMenuNativeRenderer.signature(menus)`, not the list itself — building a
|
| Segmented choice | `ButtonGroup.horizontal`, `Expanded` children, chosen one `primary`, rest `secondary`, `alignment: center` |
|
||||||
fresh `AppMenuGroup` tree (and fresh `SingleActivator`s inside it) on every
|
| Button welded to a field | `ButtonGroup.horizontal[field, IconButton.secondary]` |
|
||||||
build is normal and fine, but pushing to the native bar on every build is not
|
| Action inside a PropertyRow | `ButtonStyle.secondary` (the row doesnt force buttons, you pick it) |
|
||||||
— the signature is what turns "recomputed every frame" into "pushed only
|
| Action in a SettingsRow | ghost |
|
||||||
when it actually changed."
|
| 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` |
|
||||||
|
|
||||||
## Anti-patterns
|
### Alignment
|
||||||
|
|
||||||
- **No `package:flutter/material.dart`.** Not even for `Colors.black`. Use
|
Leave `alignment:` off unless the button is stretched wider than its label.
|
||||||
`Color(0xff000000)` / `Color(0xffffffff)` — literal, not `Colors.*`. The UI
|
Then:
|
||||||
kit is deliberately `WidgetsApp`-based, not `MaterialApp`-based, so an app
|
|
||||||
built on it shouldn't reach for Material either.
|
- `Alignment.center` for full-width buttons and segments.
|
||||||
- **Don't hand-type a control height, icon size, or vertical padding.** It
|
- `Alignment.centerLeft` only for nav and list rows, and only with a
|
||||||
belongs in `Density` as a named field with everything else derived from it.
|
**leading** icon.
|
||||||
- **Don't build `chrome`/`panel` treatment from raw `Container` + hardcoded
|
|
||||||
colour.** Use `ChromeBar`/`Panel`/`EditorShell`/`GarageShell` — they read
|
Never `centerLeft` with a trailing-only icon. When alignment is set and exactly
|
||||||
the right token (and in chrome's case, the right *unaccented* scheme) for
|
one of leading / trailing is present, `Button` puts an invisible spacer on the
|
||||||
you.
|
empty side so the label lands on the true centre — with `centerLeft` that just
|
||||||
- **Don't hand-roll a settings row.** `PropertiesSection` + `PropertyRow`
|
indents the label behind a phantom icon.
|
||||||
exist, and a `Column` of `Text` + control + divider will silently lose the
|
|
||||||
split alignment, the collapse state and the actions band. If you find
|
### Toggles
|
||||||
yourself writing `Row(children: [Expanded(Text(...)), Gap, Button])` inside
|
|
||||||
a settings pane, you want `description:` instead.
|
`Toggle` is the package's on/off button: ghost while off, secondary while on.
|
||||||
- **Don't glue a button to a field with a `Gap`.** That's a `ButtonGroup` —
|
Editor toolbars use the primary-on pattern above instead. Pick one per surface.
|
||||||
it merges the touching corners so the pair reads as one control.
|
|
||||||
- **Don't build a menu as widgets directly.** Model it as `AppMenuGroup` data
|
|
||||||
first (see above), even if there's currently only one renderer consuming
|
## Inputs
|
||||||
it — a native menu bar tends to get added later, and retrofitting a model
|
|
||||||
under existing widget-only menu code is exactly the refactor that
|
### Text fields
|
||||||
motivated this document.
|
|
||||||
- **Don't reach for `destructive` for "this matters."** It means
|
- **Default (`outline`)** for free text: names, search, a composer, a form.
|
||||||
delete/discard/record — genuinely dangerous, genuinely irreversible.
|
- **`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.
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ class ButtonVariance implements AbstractButtonStyle {
|
|||||||
margin: _buttonZeroMargin,
|
margin: _buttonZeroMargin,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ghost, but for the remove/revoke/delete kind of action that doesnt deserve
|
||||||
|
// a filled red slab. before this existed every call site hand coloured a
|
||||||
|
// ghost button's text + icon, which is exactly the override we dont want
|
||||||
|
static const AbstractButtonStyle ghostDestructive = ButtonVariance(
|
||||||
|
decoration: _buttonGhostDestructiveDecoration,
|
||||||
|
mouseCursor: _buttonMouseCursor,
|
||||||
|
textStyle: _buttonGhostDestructiveTextStyle,
|
||||||
|
iconTheme: _buttonGhostDestructiveIconTheme,
|
||||||
|
margin: _buttonZeroMargin,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final ButtonStateProperty<Decoration> decoration;
|
final ButtonStateProperty<Decoration> decoration;
|
||||||
@override
|
@override
|
||||||
@@ -307,6 +318,14 @@ class ButtonStyle implements AbstractButtonStyle {
|
|||||||
this.paddingOverride,
|
this.paddingOverride,
|
||||||
}) : variance = ButtonVariance.destructive;
|
}) : variance = ButtonVariance.destructive;
|
||||||
|
|
||||||
|
const ButtonStyle.ghostDestructive({
|
||||||
|
this.size = ButtonSize.normal,
|
||||||
|
this.density,
|
||||||
|
this.shape = ButtonShape.rectangle,
|
||||||
|
this.squarePadding = false,
|
||||||
|
this.paddingOverride,
|
||||||
|
}) : variance = ButtonVariance.ghostDestructive;
|
||||||
|
|
||||||
// icon flavours — same as above but default to icon density (square padding)
|
// icon flavours — same as above but default to icon density (square padding)
|
||||||
const ButtonStyle.primaryIcon({
|
const ButtonStyle.primaryIcon({
|
||||||
this.size = ButtonSize.normal,
|
this.size = ButtonSize.normal,
|
||||||
@@ -348,6 +367,14 @@ class ButtonStyle implements AbstractButtonStyle {
|
|||||||
this.paddingOverride,
|
this.paddingOverride,
|
||||||
}) : variance = ButtonVariance.destructive;
|
}) : variance = ButtonVariance.destructive;
|
||||||
|
|
||||||
|
const ButtonStyle.ghostDestructiveIcon({
|
||||||
|
this.size = ButtonSize.normal,
|
||||||
|
this.density,
|
||||||
|
this.shape = ButtonShape.rectangle,
|
||||||
|
this.squarePadding = true,
|
||||||
|
this.paddingOverride,
|
||||||
|
}) : variance = ButtonVariance.ghostDestructive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ButtonStateProperty<Decoration> get decoration {
|
ButtonStateProperty<Decoration> get decoration {
|
||||||
if (shape == ButtonShape.circle) {
|
if (shape == ButtonShape.circle) {
|
||||||
@@ -923,6 +950,56 @@ IconThemeData _buttonDestructiveIconTheme(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GHOST DESTRUCTIVE
|
||||||
|
/// Ghost with the destructive colour on the label and icon. Same wash as ghost
|
||||||
|
/// on hover, just tinted with `destructive` instead of the foreground so the
|
||||||
|
/// highlight agrees with the text.
|
||||||
|
Decoration _buttonGhostDestructiveDecoration(
|
||||||
|
BuildContext context,
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
final themeData = GarageTheme.of(context);
|
||||||
|
final radius = BorderRadius.circular(themeData.radiusMd);
|
||||||
|
final tint = themeData.colorScheme.destructive;
|
||||||
|
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return BoxDecoration(color: const Color(0x00000000), borderRadius: radius);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.pressed)) {
|
||||||
|
return BoxDecoration(color: tint.withValues(alpha: 0.2), borderRadius: radius);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.hovered)) {
|
||||||
|
return BoxDecoration(color: tint.withValues(alpha: 0.12), borderRadius: radius);
|
||||||
|
}
|
||||||
|
return BoxDecoration(color: const Color(0x00000000), borderRadius: radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TextStyle _buttonGhostDestructiveTextStyle(
|
||||||
|
BuildContext context,
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
var themeData = GarageTheme.of(context);
|
||||||
|
return themeData.typography.small.copyWith(
|
||||||
|
color: states.contains(WidgetState.disabled)
|
||||||
|
? themeData.colorScheme.mutedForeground
|
||||||
|
: themeData.colorScheme.destructive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconThemeData _buttonGhostDestructiveIconTheme(
|
||||||
|
BuildContext context,
|
||||||
|
Set<WidgetState> states,
|
||||||
|
) {
|
||||||
|
var themeData = GarageTheme.of(context);
|
||||||
|
return IconThemeData(
|
||||||
|
color: states.contains(WidgetState.disabled)
|
||||||
|
? themeData.colorScheme.mutedForeground
|
||||||
|
: themeData.colorScheme.destructive,
|
||||||
|
size: themeData.iconTheme.small.size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// button group border merging
|
// button group border merging
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1327,6 +1404,46 @@ class Button extends StatefulWidget {
|
|||||||
this.disableFocusOutline = false,
|
this.disableFocusOutline = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const Button.ghostDestructive({
|
||||||
|
super.key,
|
||||||
|
this.statesController,
|
||||||
|
this.leading,
|
||||||
|
this.trailing,
|
||||||
|
this.leadingGap,
|
||||||
|
this.trailingGap,
|
||||||
|
required this.child,
|
||||||
|
this.onPressed,
|
||||||
|
this.focusNode,
|
||||||
|
this.alignment,
|
||||||
|
this.enabled,
|
||||||
|
this.style = const ButtonStyle.ghostDestructive(),
|
||||||
|
this.disableTransition = false,
|
||||||
|
this.onFocus,
|
||||||
|
this.onHover,
|
||||||
|
this.disableHoverEffect = false,
|
||||||
|
this.enableFeedback,
|
||||||
|
this.onTapDown,
|
||||||
|
this.onTapUp,
|
||||||
|
this.onTapCancel,
|
||||||
|
this.onSecondaryTapDown,
|
||||||
|
this.onSecondaryTapUp,
|
||||||
|
this.onSecondaryTapCancel,
|
||||||
|
this.onTertiaryTapDown,
|
||||||
|
this.onTertiaryTapUp,
|
||||||
|
this.onTertiaryTapCancel,
|
||||||
|
this.onLongPressStart,
|
||||||
|
this.onLongPressUp,
|
||||||
|
this.onLongPressMoveUpdate,
|
||||||
|
this.onLongPressEnd,
|
||||||
|
this.onSecondaryLongPress,
|
||||||
|
this.onTertiaryLongPress,
|
||||||
|
this.semanticLabel,
|
||||||
|
this.semanticToggled,
|
||||||
|
this.semanticSelected,
|
||||||
|
this.marginAlignment,
|
||||||
|
this.disableFocusOutline = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<Button> createState() => _ButtonState();
|
State<Button> createState() => _ButtonState();
|
||||||
}
|
}
|
||||||
@@ -2185,6 +2302,26 @@ class IconButton extends StatelessWidget {
|
|||||||
this.shape = ButtonShape.rectangle,
|
this.shape = ButtonShape.rectangle,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const IconButton.ghostDestructive({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
this.onPressed,
|
||||||
|
this.enabled,
|
||||||
|
this.leading,
|
||||||
|
this.trailing,
|
||||||
|
this.alignment,
|
||||||
|
this.size = ButtonSize.normal,
|
||||||
|
this.focusNode,
|
||||||
|
this.disableTransition = false,
|
||||||
|
this.onHover,
|
||||||
|
this.onFocus,
|
||||||
|
this.enableFeedback,
|
||||||
|
this.semanticLabel,
|
||||||
|
this.variance = ButtonVariance.ghostDestructive,
|
||||||
|
this.density,
|
||||||
|
this.shape = ButtonShape.rectangle,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Button(
|
return Button(
|
||||||
|
|||||||
Reference in New Issue
Block a user