garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
504 lines
29 KiB
Markdown
504 lines
29 KiB
Markdown
# Garage UI style guide
|
||
|
||
This is not the widget API reference (read the source for that — every file in
|
||
`lib/` is short and commented). This is the *other* half: which widget, which
|
||
variant, which colour token, in which situation. Getting a Garage app to use
|
||
`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
|
||
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
|
||
|
||
Two influences, and they are not the same kind of influence — conflating them
|
||
is the usual misreading:
|
||
|
||
- **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
|
||
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
|
||
|
||
A Garage app has three layers, outside-in:
|
||
|
||
1. **Chrome** — the app's own furniture: headers, footers, the menu bar, the
|
||
shell that docks everything else. Flat, dark, no elevation. Reads as part
|
||
of the window, not as content sitting on the window.
|
||
2. **Panels** — bordered, rounded, elevated-feeling regions that hold actual
|
||
tool content (explorer trees, property inspectors, docked windows). This
|
||
is where most of the UI actually lives.
|
||
3. **Controls** — buttons, fields, selects, menus. Live inside panels or
|
||
chrome, never bare against the app background.
|
||
|
||
Nothing in this system uses Flutter's Material widgets. There is no
|
||
`ThemeData.dark()`, no `Colors.black`, no `Scaffold`. Every colour comes from
|
||
`ColourScheme`, every size comes from `Density`, every font comes from
|
||
`Typography`. If a widget you're building reaches for `package:flutter/material.dart`
|
||
or a literal `Color(0x...)` outside `theme/*.dart`, that's the tell you've
|
||
stepped outside the system.
|
||
|
||
## Getting the theme
|
||
|
||
```dart
|
||
final theme = GarageTheme.of(context);
|
||
final cs = theme.colorScheme;
|
||
```
|
||
|
||
Everything hangs off `theme`: `theme.colorScheme`, `theme.density`,
|
||
`theme.typography`, `theme.iconTheme`, `theme.radiusMd` / `.borderRadiusMd`
|
||
(and `Sm`/`Lg`/`Xl`/`Xxl`), `theme.panelRadius`, `theme.panelGap`.
|
||
|
||
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.
|
||
|
||
## Colour tokens
|
||
|
||
`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.
|
||
|
||
### 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." |
|
||
| `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
|
||
const Gap.md(), // <- this, not Gap(8)
|
||
```
|
||
|
||
`Gap` has a named constructor per step. They resolve their extent from the
|
||
theme at build time, so a call site stays `const` and still tracks the density
|
||
- 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` /
|
||
`GhostButton` / `DestructiveButton` widgets (no `.constructor` dot-syntax) —
|
||
lighter-weight wrappers around the same variants. `PrimaryButton` specifically
|
||
is the idiomatic way to write a dialog's confirm button, over `Button.primary`.
|
||
|
||
## Icons
|
||
|
||
- Source: `flutter_lucide`, re-exported through `garage_ui.dart` as
|
||
`LucideIcons`. **Names are snake_case** (`LucideIcons.file_plus`,
|
||
`LucideIcons.chevron_right`) — this is `flutter_lucide`'s native spelling,
|
||
used directly. There is no camelCase shim in this package.
|
||
- Three tiers: `.iconSmall` (`density.iconSize`), `.iconMedium` (20px),
|
||
`.iconLarge` (24px). Set size via the extension, never a hardcoded `size:`.
|
||
- **`Button` and `IconButton` already apply `small` to everything inside
|
||
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
|
||
`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.
|
||
- `theme.typography.monoStyle(style)` — Geist Mono. Reach for this
|
||
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()` /
|
||
`.semiBold()` / `.bold()`, `.muted()`.
|
||
|
||
### 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
|
||
`PropertyRow` — give it the `description` and pass `const SizedBox.shrink()`
|
||
as the child.
|
||
|
||
### Fields inside a property row
|
||
|
||
`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
|
||
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
|
||
|
||
This is the pattern from the `AppMenuGroup` model in `app_menu.dart`, and
|
||
it's worth calling out on its own because it's easy to accidentally
|
||
reinvent per-app (it has been, twice):
|
||
|
||
Define the menu once as data — `List<AppMenuGroup>` (`AppMenuGroup` →
|
||
`AppMenuAction` / `AppMenuCheck` / `AppMenuSeparator`) — not as widgets. Then:
|
||
|
||
1. **In-app render**: walk the model into `Menubar`/`MenuButton`/
|
||
`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
|
||
**same** build pass — not two independent calls to whatever builds the model.
|
||
Two separate computations drift: they'll watch slightly different state,
|
||
recompute at different times, and eventually disagree about what's checked or
|
||
what a shortcut is (this exact bug shipped and had to be fixed). Compute
|
||
once, hand the value to both. Gate the native push on
|
||
`AppMenuNativeRenderer.signature(menus)`, not the list itself — building a
|
||
fresh `AppMenuGroup` tree (and fresh `SingleActivator`s inside it) on every
|
||
build is normal and fine, but pushing to the native bar on every build is not
|
||
— the signature is what turns "recomputed every frame" into "pushed only
|
||
when it actually changed."
|
||
|
||
## Anti-patterns
|
||
|
||
- **No `package:flutter/material.dart`.** Not even for `Colors.black`. Use
|
||
`Color(0xff000000)` / `Color(0xffffffff)` — literal, not `Colors.*`. The UI
|
||
kit is deliberately `WidgetsApp`-based, not `MaterialApp`-based, so an app
|
||
built on it shouldn't reach for Material either.
|
||
- **Don't hand-type a control height, icon size, or vertical padding.** It
|
||
belongs in `Density` as a named field with everything else derived from it.
|
||
- **Don't build `chrome`/`panel` treatment from raw `Container` + hardcoded
|
||
colour.** Use `ChromeBar`/`Panel`/`EditorShell`/`GarageShell` — they read
|
||
the right token (and in chrome's case, the right *unaccented* scheme) for
|
||
you.
|
||
- **Don't hand-roll a settings row.** `PropertiesSection` + `PropertyRow`
|
||
exist, and a `Column` of `Text` + control + divider will silently lose the
|
||
split alignment, the collapse state and the actions band. If you find
|
||
yourself writing `Row(children: [Expanded(Text(...)), Gap, Button])` inside
|
||
a settings pane, you want `description:` instead.
|
||
- **Don't glue a button to a field with a `Gap`.** That's a `ButtonGroup` —
|
||
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
|
||
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
|
||
motivated this document.
|
||
- **Don't reach for `destructive` for "this matters."** It means
|
||
delete/discard/record — genuinely dangerous, genuinely irreversible.
|