From 572c09039f5c321f945c6c71f711a687027b433e Mon Sep 17 00:00:00 2001 From: ImBenji Date: Wed, 23 Sep 2026 19:51:45 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7 --- docs/garage-ui-style-guide.md | 847 ++++++++++++++++------------------ garage_ui/lib/button.dart | 137 ++++++ 2 files changed, 524 insertions(+), 460 deletions(-) diff --git a/docs/garage-ui-style-guide.md b/docs/garage-ui-style-guide.md index 1bd4c74..db2c3c0 100644 --- a/docs/garage-ui-style-guide.md +++ b/docs/garage-ui-style-guide.md @@ -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.` 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().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` → -`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.`, 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. diff --git a/garage_ui/lib/button.dart b/garage_ui/lib/button.dart index d2e1908..f0ae767 100644 --- a/garage_ui/lib/button.dart +++ b/garage_ui/lib/button.dart @@ -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; @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 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 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 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 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