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:
ImBenji
2026-09-23 19:51:45 +01:00
co-authored by Claude Opus 5.5
parent 21c380595f
commit 572c09039f
2 changed files with 524 additions and 460 deletions
+387 -460
View File
@@ -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
`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.
How to build a screen out of garage_ui so it actually looks like a Garage app.
The API is in the source. This is the other half: which widget, which variant,
where — and, mostly, what to leave alone.
It's written from **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.
It's written from the two apps that are the reference:
## 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
is the usual misreading:
Where those two do something the guide says not to, it's listed at the end
under [Dont copy these](#dont-copy-these). An existing call site is not
precedent.
- **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.
## The one rule: let the theme speak
## 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
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.
- pass a `TextStyle` with a `fontSize`, `fontWeight` or `color`
- give an `Icon` a `size:` or `color:`
- write a literal `EdgeInsets`, `SizedBox`, `Gap(8)` or `BorderRadius.circular(8)`
- pass `alignment:` to a button that doesnt need it
- force `density: ControlDensity.compact` on a control to make it "fit"
- wrap a dialog's content in a `ConstrainedBox(maxWidth: 360)`
- build a `Container` with a fill, border and radius to make a badge, a pill, a
warning box or a popover
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.
Every one of those is a number or a colour that was chosen once, per call site,
and stops agreeing with the rest of the app the moment the density, scheme or
accent changes. The components are built as families — outline buttons share
text fields' fill, stroke and radius on purpose so they "read as one family
instead of each getting styled by hand per call site". One override breaks the
family.
## 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
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final theme = ThemeData(
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`,
`theme.typography`, `theme.iconTheme`, `theme.radiusMd` / `.borderRadiusMd`
(and `Sm`/`Lg`/`Xl`/`Xxl`), `theme.panelRadius`, `theme.panelGap`.
`GarageApp` is built on `WidgetsApp`, not `MaterialApp`, and installs
`GarageTheme` plus the scroll behaviour and scrollbar. Nothing in garage_ui
touches Material. If a file reaches for `package:flutter/material.dart`, it has
left the system.
One exception: an app's *unaccented* scheme — the one nobody's accent-colour
override has touched — is read through the app's settings provider, not
`GarageTheme.of(context)`. In Arcs & Angles that's
`context.watch<SettingsProvider>().anaScheme`. Reach for this specifically
when you need `chrome` on something that must stay neutral even if the user
picked a wild accent colour (this is rare — most code never needs to do this,
because `ChromeBar`/`Panel`/`GarageShell` already read `chrome`/`panel`
internally). See "Colour tokens" below for why `chrome` gets this treatment.
Leave the rest of `ThemeData` on its defaults — `scaling 1.0`, `radius 0.5`
(so `radiusSm 4`, `radiusMd 6`), `panelRadius 10`, `panelGap 5`. Both
reference apps do.
## Colour tokens
Read the theme with `GarageTheme.of(context)`: `.colorScheme`, `.density`,
`.typography`, `.iconTheme`, `.radiusMd` and friends.
`ColourScheme` (in `theme/colour_scheme.dart`) is one flat list of named
colours — no light/dark split, no derived roles computed at paint time. Every
field is authored by hand per scheme (Arcs & Angles ships eleven: zinc,
crimson, slate, forest, stone, teal, indigo, amber, carbon, fuchsia, plus each
one's light twin). When you add a new UI surface, you're choosing which of
these *existing* tokens it belongs to — you're not inventing a new colour.
### Colour schemes
### Base semantic slots (shadcn-shaped, still the backbone)
| Token | What it's for |
|---|---|
| `background` / `foreground` | The app's base surface and default text colour. |
| `card` / `cardForeground` | `Card`/`SurfaceCard` fill and the text colour merged inside them. |
| `popover` / `popoverForeground` / `popoverBorder` | Dropdowns, select popups, context menus — anything that floats over content in an `OverlayPortal`. |
| `primary` / `primaryHovered` / `primaryForeground` | The accent. Active/engaged control state, the one CTA in a dialog. Swappable per-user via `withAccent()`. |
| `secondary` / `secondaryHovered` / `secondaryForeground` | Neutral filled control — a resting toggle, an attached-to-a-field icon button. Not an accent colour, just "has a fill." |
| `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 |
Don't hand-author 37 colours. `GarageSchemes` ships `dark`, `light` and
`carbon` (the one the Garage apps run), and `ColourScheme.derive` builds a
complete scheme from four:
```dart
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)),
],
ColourScheme.derive(
brightness: Brightness.dark,
background: ...,
foreground: ...,
primary: ...,
)
```
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`.
Every other slot is derived on a perceptual lightness ladder — surfaces step
up or down from the background, strokes are measured off the surface they
outline, text is a fraction of the background-to-foreground span. Any derived
slot can be pinned by passing it. `schemes.dart` is the worked example.
A user-picked accent goes through `scheme.withAccent(colour)`, which swaps
`primary`, `primaryHovered`, `ring` and a contrasting `primaryForeground`, and
nothing else.
### Density: pick the tier, don't tune it
| | `compact` | `normal` | `product` |
|---|---|---|---|
| For | dense desktop tools | the same, roomier | web / product apps |
| Used by | Arcs & Angles | A&A "Comfortable" | the hub |
| `fontSize` | 10 | 10 | 12 |
| `controlHeight` | 23 | 27 | 33 |
| `chromeBarHeight` | 31 | 37 | 43 |
Use the named constructor. `Density()` is the same as `Density.compact()`,
which is a trap for a product app.
`Density`'s fields are the only hand-set pixel values in the system; padding,
icon size and line boxes are derived from them. If a tier feels wrong, that's
a conversation about the tier, not a reason to set sizes at call sites.
Controls follow the theme's tier. A per-control `density:` override exists for
exactly one job — a header bar that has to match a 23px menu bar — and even
then it mixes tiers (compact geometry, the theme's font). Don't use it to make
something smaller.
## Colour: what the slots are for
You mostly wont touch these — the components read them. When you do draw
something yourself, pick the slot by what the thing *is*.
| Slot | What it is |
|---|---|
| `background` / `foreground` | the page ground and default text. Also a `Panel`'s fill |
| `chrome` | header and footer bars, and the gutter between panels |
| `surfaceSunken` | one step below the ground — side rails, panel bodies, list backgrounds |
| `card` | raised surface — cards, properties sections, dialogs |
| `muted` / `mutedForeground` | de-emphasised fill / secondary text, hints, units |
| `rowText` / `rowHovered` | a row's resting label colour / hovered row, in lists and menus |
| `popover` / `popoverBorder` | menus, select popups, anything floating |
| `tooltipBackground` / `tooltipBorder` | tooltips, which sit above everything |
| `primary` (+`Hovered`, `Foreground`) | the accent. The one thing that's on or chosen |
| `secondary` (+`Hovered`, `Foreground`) | a neutral filled control |
| `destructive` | delete, revoke, discard |
| `controlFill` (+`Hovered`, `Focused`), `controlBorder` | the shared fill and stroke of every control — fields, selects, outline buttons, checkboxes |
| `switchTrackInactive` | a Switch while off |
| `border` / `divider` | general outline / a seam between things |
| `panelBorder` / `panelBorderHighlighted` | a Panel's edge, resting / the lit one |
| `propertiesSectionBorder` | a properties section's edge |
| `ring` | keyboard focus |
| `popoverItemHovered` | hover inside select and date popups |
| `chart1`–`chart5` | data visualisation |
There is no `panel`, `input*`, `explorerRow*`, `menuItem*` or `canvas*` slot any
more. App-specific colours (A&A's canvas) belong in the app, derived with
`shiftLstar` so they sit on the same ladder.
## Space
- Between siblings: `Gap.xxs()` … `Gap.xxl()`. They resolve against the
density at build time.
- In padding: `density.gapXs` … `density.gapXxl`, `containerGap`,
`containerPadding`.
- Never a literal. `Gap(8)` is 8 in compact and wrong everywhere else.
| | `xxs` | `xs` | `sm` | `md` | `lg` | `xl` | `xxl` |
|---|---|---|---|---|---|---|---|
| compact | 2 | 4 | 6 | 8 | 12 | 16 | 24 |
| normal, product | 3 | 5 | 8 | 10 | 15 | 20 | 30 |
A lot of the spacing is already in the components. `PropertiesSection` carries
its own margin (`gapXs` either side, `gapLg` below), so a stack of sections
needs no gaps between them. `SettingsList` insets its own rows. Don't add
padding around things that already have it.
## Type
- The ambient text is `fontSize` at w300. Plain `Text("...")` is already right
for body copy.
- Controls render their own labels at w400; primary buttons at w600 as optical
compensation. Don't bold a button label.
- Size steps are the extensions: `.xSmall()` (`textXs`), `.small()` (`textSm`),
`.large()` (`textLg`). Weight: `.medium()`, `.semiBold()`, `.bold()`.
Secondary text: `.muted()`.
- The step below the control font, `textXxs`, has no extension; read
`density.textXxs` when you need it (group captions).
- Numbers and readouts: `theme.typography.monoStyle(...)`.
| Job | How |
|---|---|
| Page title | `Text(title).large()` |
| Page / section subtitle | muted text under it |
| Section heading inside a page | `.small().semiBold()` |
| Nav group caption | `density.textXxs`, w600, `.muted()` |
| Hint, unit, empty-state line | `.muted()` (hub list text: `.xSmall().muted()`) |
| Footer stats, perf readouts | `monoStyle`, muted |
`.muted()` colours **text** only. It does nothing to an `Icon`.
## Icons
- 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()`.
- Lucide only: `LucideIcons.x`.
- Inside a control's `leading` / `trailing` / `icon` slot: a bare
`const Icon(LucideIcons.x)`. The control sizes and colours it — the variant's
icon theme is merged over everything inside the button.
- Outside a control, `.iconSmall` / `.iconMedium` / `.iconLarge` set size only
(never colour). `iconSmall` equals `fontSize`: 10 compact, 12 product.
- No literal `size: 13`.
## 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:
## Surfaces and layout
- `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.
Both apps are the same shape: a `chrome` ground, `Panel`s standing on it, and
`panelGap` gutters between them.
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…")),
)
```
ColoredBox(chrome)
Padding(panelGap)
Row[ Panel(rail), SizedBox(width: panelGap), Expanded(Panel(workspace)) ]
```
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.
A&A gets this from `GarageShell` (header, footer, main, a sidebar split top and
bottom). The hub builds the same shape by hand because it needs a nav rail.
Only one panel is lit at a time (`panelBorderHighlighted`).
### 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
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."
## Buttons
## The menu system — one model, two renderers
### Variants mean something
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):
| Variant | Means |
|---|---|
| `primary` | the one action, or the thing that's on / chosen. One per view, ideally |
| `secondary` | a neutral filled control — a resting toggle, a button welded to a field, the selected item in a nav |
| `outline` | a normal action that isn't the main one — Cancel, Retry, Previous / Next |
| `ghost` | chrome, toolbars, nav items, row actions — anything that shouldn't compete |
| `destructive` | the confirm button of a destructive dialog |
| `ghostDestructive` | a destructive *trigger* that isnt a filled slab — Remove, Revoke, Delete in a row |
| `link` / `text` | inline, in running copy |
Define the menu once as data — `List<AppMenuGroup>` (`AppMenuGroup` →
`AppMenuAction` / `AppMenuCheck` / `AppMenuSeparator`) — not as widgets. Then:
`Button.primary(...)`, `Button.ghostDestructive(...)` etc. take a `style:`;
the `PrimaryButton` / `OutlineButton` / … wrappers take `density:` directly.
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`.
### By context
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."
| Where | What |
|---|---|
| Icon only, anywhere | `IconButton.<variant>`, never a `Button` with just an icon in it |
| Panel header actions | `IconButton.ghost`, each in a `Tooltip` |
| Nav rail item | `ButtonStyle.ghost`, `secondary` when selected (never primary), `alignment: centerLeft`, `leading: Icon` |
| Toolbar / header toggle | `IconButton.primary` when on, `outline` when off (`secondary` off for a master switch whose off state matters) |
| Segmented choice | `ButtonGroup.horizontal`, `Expanded` children, chosen one `primary`, rest `secondary`, `alignment: center` |
| Button welded to a field | `ButtonGroup.horizontal[field, IconButton.secondary]` |
| Action inside a PropertyRow | `ButtonStyle.secondary` (the row doesnt force buttons, you pick it) |
| Action in a SettingsRow | ghost |
| Dialog | Cancel `outline`, confirm `Button.primary` or `Button.destructive` |
| Retry after an error | `outline` |
| Empty-state call to action | `outline`, centred |
| Full-width stacked flow buttons (auth) | primary last, every one `alignment: Alignment.center` |
## Anti-patterns
### Alignment
- **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.
Leave `alignment:` off unless the button is stretched wider than its label.
Then:
- `Alignment.center` for full-width buttons and segments.
- `Alignment.centerLeft` only for nav and list rows, and only with a
**leading** icon.
Never `centerLeft` with a trailing-only icon. When alignment is set and exactly
one of leading / trailing is present, `Button` puts an invisible spacer on the
empty side so the label lands on the true centre — with `centerLeft` that just
indents the label behind a phantom icon.
### Toggles
`Toggle` is the package's on/off button: ghost while off, secondary while on.
Editor toolbars use the primary-on pattern above instead. Pick one per surface.
## Inputs
### Text fields
- **Default (`outline`)** for free text: names, search, a composer, a form.
- **`secondary`** for numeric property values and read-only values.
- Inside a `PropertyRow` you don't choose — the row wraps its child in a
`PropertySlotScope` and `TextField`, `Select` and `DateInput` are forced to
secondary. An explicit `variant:` loses. (Buttons aren't forced.)
- Numeric editor fields (A&A): secondary, `textAlign: center`,
`InputFeature.scrub(...)`, a unit as a trailing muted `Text`. Put trailing
features before `scrub` — list order is render order.
- A read-only value is a `TextField(readOnly: true, enabled: false)`, never a
bare `Text` (see below).
### Select
`SelectVariant.secondary` in property rows (forced anyway), `ghost` in a
`SettingsRow` or where the select reports a value rather than offering a
control, default `outline` elsewhere.
### Booleans
A&A uses `Checkbox` throughout. The hub uses `Switch` in rows. Either is
fine; don't mix them within one surface.
### Errors
Use the row's `error:` slot (`PropertyRow` and `SettingsRow` both have one).
It reddens the outline, never the fill. Don't hand-roll a red `Text` under a
field. "Required" goes in the row's `action:` slot.
## Properties and settings
The two list shapes, and the hub's two page archetypes.
**`PropertiesSection` + `PropertyRow`** — a boxed card of label/value rows. For
inspectors, account and security pages, and lists of records (one collapsible
section per record, then the hub's `Pager`).
- The value slot is **always a field or a control**, never bare `Text`. A
read-only value is a secondary `TextField(readOnly: true, enabled: false)` —
the hub wraps that as `ValueField`. Beside real fields, bare text reads as a
caption and the column goes ragged.
- A button in the value slot goes in `Align(centerLeft)` so it doesn't stretch.
- Controls here are secondary.
- Editor rows carry `PropertyActions` (right-click reset / copy / paste).
- A status badge or count goes in the section's `trailing`, which stays
visible when collapsed.
- Actions go in the section's `actions:` band.
**`SettingsList` + `SettingsRow`** — unboxed rows ruled between entries, on
the page ground. For forms: products, coupons, clients.
- There's no card, so filled controls would float. Selects, buttons and icon
buttons are **ghost**; text fields are the default **outline** — read-only
ones too.
Converting one to the other means changing the variants, not just the
container.
### Subtitle vs description
- `subtitle` is a short **static** line saying what the thing is. Never live
data: no counts, dates, names, emails, statuses or "3 of 5". This holds
everywhere — page headings, section headings, rows, and record cards in a
list. A record's identifying data goes in its rows.
- `description` is longer copy about a setting.
## Dialogs, sheets, panes, overlays
- **Confirm / small dialog:** `showDialog` → `AlertDialog(title, content,
actions: [cancel, confirm])`. It's `card`-filled, capped at
`kDialogMaxWidth` (350) and renders the actions in its own band. Don't
constrain its width yourself, and don't build the action row by hand.
- **Bottom sheet:** `showSheet(...)` with `SheetRow`s.
- **Big pane** (settings, export — A&A): `showPaneOverlay`, blurred scrim,
inside the same chrome → Panel → gutter shape as the editor.
- **Toasts:** `showAppToast(context: context, title: ..., subtitle: ..., severity: ...)`.
- **Tooltips:** `Tooltip(tooltip: (_) => TooltipContainer(child: Text(msg)))`,
unstyled.
- **Menus:** `MenuButton`, `MenuDivider`, `MenuLabel`; `showContextMenu` for
right-click; app menus as `AppMenuItem` data.
## Loading, empty, error
- **Loading:** a `CircularProgressIndicator`. No skeletons, no shimmer.
- **Empty:** one muted line.
- **Error:** the message muted, and an outline Retry.
Plainest thing that works. If a page wants more, it's asking for a new garage_ui
component, not a one-off.
## Dont copy these
Known overrides in the reference apps. They're debt, not patterns.
**Arcs & Angles**
- `IconButton.ghost(icon: Icon(x, size: 13))` in panel headers, the tab strip
and the agent composer — should be a bare `Icon`.
- Tooltip text forced to `foreground` or `background`, custom
`TooltipContainer` padding.
- Literal font sizes in the agent panel and the download modal.
- The hand-rolled snap popover, `PaneDialog` title bar and properties tab
strip.
- Plain `Text` values in settings and download `PropertyRow`s.
- Literal `SizedBox` / `EdgeInsets` spacing outside the settings modal.
**The hub**
- Ghost and link buttons hand-coloured `destructive` ("Remove", "Revoke",
"Refund", "Delete?") — use `ButtonStyle.ghostDestructive`.
- `TextStyle(fontWeight: w600)` on primary labels in the older auth steps.
- Four competing badges (disabled compact buttons, disabled coloured
`IconButton`s, `SandboxTag`, `Pill`), and hand-built warning boxes
(`FlowError`, the delete-account box, `_Banner`).
- `ConstrainedBox(maxWidth: 360/380)` around dialog content.
- Literal widths on toolbar selects.
- Outline text fields and ghost selects inside `PropertiesSection`s;
secondary fields inside `SettingsList`s.
- Record-card and detail-page subtitles carrying data (sku · price, buyer ·
date, counts, emails).
- About 37 hand-written copies of `ValueField`, and a bare `Text` value in two
role rows.
- Hand-built toasts (`showToast` + `SurfaceCard` + literal padding) instead of
`showAppToast`.
- `ControlDensity.compact` on ordinary buttons outside a header.
+137
View File
@@ -186,6 +186,17 @@ class ButtonVariance implements AbstractButtonStyle {
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
final ButtonStateProperty<Decoration> decoration;
@override
@@ -307,6 +318,14 @@ class ButtonStyle implements AbstractButtonStyle {
this.paddingOverride,
}) : 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)
const ButtonStyle.primaryIcon({
this.size = ButtonSize.normal,
@@ -348,6 +367,14 @@ class ButtonStyle implements AbstractButtonStyle {
this.paddingOverride,
}) : 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
ButtonStateProperty<Decoration> get decoration {
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
// ---------------------------------------------------------------------------
@@ -1327,6 +1404,46 @@ class Button extends StatefulWidget {
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
State<Button> createState() => _ButtonState();
}
@@ -2185,6 +2302,26 @@ class IconButton extends StatelessWidget {
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
Widget build(BuildContext context) {
return Button(