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
+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(