From ad8004e93414692f19b9b8cfda91a78984a8a4ea Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:51:32 -0400 Subject: [PATCH 1/7] Add an inset shadow decoration and the raised surface recipe Flutter has no inner shadow, so InsetShadowDecoration paints them the way Chromium does: clip to the box, fill the plane minus the offset-and-shrunk box, blur the ring. It also takes a gradient fill, outer drop shadows, and a border, and it tweens from a plain rounded BoxDecoration so animated containers can fade a selection in. Settings.raised(color, radius) builds the lit surface every selected or primary state now shares: a fill lighter at the top, a 1px light inside the top edge, a 1px shade inside the bottom, a 1px shadow beneath, bare sides. Two lightness numbers set the lift for every base color. DESIGN.md records the rule under Tonal First. Pixel tests cover the edges, the gradient, the drop shadow, and the tween. Co-Authored-By: Claude Fable 5.1 --- DESIGN.md | 2 +- lib/const/settings.dart | 82 ++++++- lib/widgets/inset_shadow_decoration.dart | 232 ++++++++++++++++++ .../widgets/inset_shadow_decoration_test.dart | 155 ++++++++++++ 4 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 lib/widgets/inset_shadow_decoration.dart create mode 100644 test/widgets/inset_shadow_decoration_test.dart diff --git a/DESIGN.md b/DESIGN.md index 3a4f89f1..a1c97731 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -25,7 +25,7 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with **The Tactical Semantics** Ally green, enemy red, defender blue, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis. -**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). +**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). A selected or primary state is never a flat fill: it is a raised surface, lit from above. The fill runs lighter at the top, a 1px light sits inside the top edge, a 1px shade inside the bottom, and a 1px shadow drops beneath; the sides stay bare. `Settings.raised(color, radius)` builds it for any base color (`raisedPrimary` and `raisedSurface` are the violet and zinc shortcuts), painted by `InsetShadowDecoration` (`lib/widgets/inset_shadow_decoration.dart`), which also tweens in animated containers. Primary buttons get it from the Shad theme. Hover stays flat. **Every control earns its position.** If you can't say why a control sits where it sits, it isn't done. Never fill spare space with a feature. diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 2b8b9122..e42253c8 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/color_option.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; @@ -226,6 +227,79 @@ class Settings { static const Color settingsDiscordAccent = Color(0xff5865f2); // brand blurple static const Color settingsMapAccent = Color(0xffb27c40); // map layers + // Resting glyph color for toolbar controls: a step under foreground so the + // strip of icons stays quiet, but above mutedForeground, which vanishes at + // the light stroke weights. Hover still comes up to foreground. + static const Color toolbarGlyph = Color(0xffd4d4d8); // zinc-300 + + // Raised surfaces (a selected tab, a checked tool, a primary command) are + // lit from above: the fill runs lighter at the top, a bright 1px edge sits + // inside the top, a dark 1px edge inside the bottom, and a 1px shadow drops + // beneath. The sides stay bare. Everything paints inside or 1px under the + // box, so the footprint never changes. Hover stays flat. + static const Color raisedTopLight = Color(0x24ffffff); // white 14% + static const Color raisedBottomShade = Color(0x4d000000); // black 30% + static const List raisedRim = [ + InsetShadow(color: raisedTopLight, offset: Offset(0, 1)), + InsetShadow(color: raisedBottomShade, offset: Offset(0, -1)), + ]; + static const BoxShadow raisedDropShadow = BoxShadow( + color: Color(0x73000000), // black 45% + offset: Offset(0, 1), + ); + // How far the fill's top and bottom move from the base color, in HSL + // lightness. These two numbers set the lift for every raised surface. + static const double raisedTopLift = 0.06; + static const double raisedBottomDrop = 0.03; + + /// The lit fill for any base color: lighter at the top, darker at the + /// bottom, so one recipe serves violet, zinc, red, and the rest. + static LinearGradient raisedGradient(Color base) { + final hsl = HSLColor.fromColor(base); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + hsl + .withLightness((hsl.lightness + raisedTopLift).clamp(0, 1)) + .toColor(), + hsl + .withLightness((hsl.lightness - raisedBottomDrop).clamp(0, 1)) + .toColor(), + ], + ); + } + + /// A raised surface of [base] color at [radius]. Use this wherever a + /// selected or primary state would otherwise be a flat fill. + static InsetShadowDecoration raised(Color base, double radius) => + InsetShadowDecoration( + gradient: raisedGradient(base), + borderRadius: BorderRadius.circular(radius), + boxShadows: const [raisedDropShadow], + shadows: raisedRim, + ); + + /// The raised neutral surface: a selected tab or chip. + static InsetShadowDecoration raisedSurface(double radius) => + raised(tacticalVioletTheme.secondary, radius); + + /// The raised command surface: a checked tool, the active segment, the + /// active page, anything that would otherwise be a flat `primary` fill. + static InsetShadowDecoration raisedPrimary(double radius) => + raised(tacticalVioletTheme.primary, radius); + + /// The primary fill alone, for the Shad theme and animated fills. + static final LinearGradient raisedPrimaryFill = + raisedGradient(tacticalVioletTheme.primary); + + // The shadow a floating menu earns (DESIGN.md: 0 8px 24px rgba(0,0,0,0.28)). + static const BoxShadow floatingMenuShadow = BoxShadow( + color: Color(0x47000000), + blurRadius: 24, + offset: Offset(0, 8), + ); + static const cardForegroundBackdrop = BoxShadow( color: Colors.black54, // High opacity because the background is dark blurRadius: 12, @@ -249,13 +323,7 @@ class Settings { return Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - ), + decoration: Settings.raised(backgroundColor, 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/inset_shadow_decoration.dart b/lib/widgets/inset_shadow_decoration.dart new file mode 100644 index 00000000..6707228d --- /dev/null +++ b/lib/widgets/inset_shadow_decoration.dart @@ -0,0 +1,232 @@ +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; + +/// A shadow cast inward from the edge of a box, like CSS `box-shadow: inset`. +/// +/// With [blurRadius] zero this is a crisp inner edge: a 1px [spreadRadius] +/// draws a rim all the way round, a 1px [offset] draws a lit or shaded edge on +/// one side. Flutter's [BoxShadow] cannot do either, so this pairs with +/// [InsetShadowDecoration]. +class InsetShadow { + const InsetShadow({ + required this.color, + this.offset = Offset.zero, + this.blurRadius = 0, + this.spreadRadius = 0, + }); + + final Color color; + final Offset offset; + final double blurRadius; + final double spreadRadius; + + InsetShadow _faded() => InsetShadow( + color: color.withValues(alpha: 0), + offset: offset, + blurRadius: blurRadius, + spreadRadius: spreadRadius, + ); + + static InsetShadow lerp(InsetShadow a, InsetShadow b, double t) { + return InsetShadow( + color: Color.lerp(a.color, b.color, t)!, + offset: Offset.lerp(a.offset, b.offset, t)!, + blurRadius: ui.lerpDouble(a.blurRadius, b.blurRadius, t)!, + spreadRadius: ui.lerpDouble(a.spreadRadius, b.spreadRadius, t)!, + ); + } + + /// Lerps two lists, fading in or out whatever one side lacks. + static List lerpList( + List a, + List b, + double t, + ) { + final length = a.length > b.length ? a.length : b.length; + return [ + for (var i = 0; i < length; i++) + lerp( + i < a.length ? a[i] : b[i]._faded(), + i < b.length ? b[i] : a[i]._faded(), + t, + ), + ]; + } +} + +/// A rounded box with a fill (flat or [gradient]), optional border, outer +/// [boxShadows], and any number of [InsetShadow]s painted inside its edge. +/// +/// Each shadow uses the technique Chromium uses for inset shadows: clip to +/// the box, fill the plane minus a copy of the box that has been moved by the +/// shadow's offset and shrunk by its spread, then blur that ring. The border +/// paints last so it stays crisp over the shadows. +class InsetShadowDecoration extends Decoration { + const InsetShadowDecoration({ + this.color, + this.gradient, + this.borderRadius = BorderRadius.zero, + this.border, + this.boxShadows = const [], + this.shadows = const [], + }); + + final Color? color; + + /// Painted over [color] when set, so a gradient can sit on a base fill. + final Gradient? gradient; + final BorderRadius borderRadius; + final BoxBorder? border; + + /// Ordinary drop shadows, painted outside the box before the fill. + final List boxShadows; + final List shadows; + + @override + EdgeInsetsGeometry get padding => border?.dimensions ?? EdgeInsets.zero; + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + return Path()..addRRect(borderRadius.toRRect(rect)); + } + + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) { + return borderRadius.toRRect(Offset.zero & size).contains(position); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return _InsetShadowPainter(this, onChanged); + } + + // Animated containers can tween to and from this decoration, including + // from a plain rounded [BoxDecoration], so a selected state can fade in. + @override + Decoration? lerpFrom(Decoration? a, double t) { + final from = _coerce(a); + return from == null ? null : _lerp(from, this, t); + } + + @override + Decoration? lerpTo(Decoration? b, double t) { + final to = _coerce(b); + return to == null ? null : _lerp(this, to, t); + } + + static InsetShadowDecoration? _coerce(Decoration? other) { + if (other == null) return const InsetShadowDecoration(); + if (other is InsetShadowDecoration) return other; + if (other is BoxDecoration && + other.shape == BoxShape.rectangle && + other.image == null && + other.backgroundBlendMode == null && + (other.borderRadius == null || other.borderRadius is BorderRadius) && + (other.border == null || other.border is Border)) { + return InsetShadowDecoration( + color: other.color, + gradient: other.gradient, + borderRadius: + (other.borderRadius as BorderRadius?) ?? BorderRadius.zero, + border: other.border, + boxShadows: other.boxShadow ?? const [], + ); + } + return null; + } + + static InsetShadowDecoration _lerp( + InsetShadowDecoration a, + InsetShadowDecoration b, + double t, + ) { + return InsetShadowDecoration( + color: Color.lerp(a.color, b.color, t), + gradient: Gradient.lerp(a.gradient, b.gradient, t), + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t)!, + border: BoxBorder.lerp(a.border, b.border, t), + boxShadows: BoxShadow.lerpList(a.boxShadows, b.boxShadows, t) ?? const [], + shadows: InsetShadow.lerpList(a.shadows, b.shadows, t), + ); + } +} + +class _InsetShadowPainter extends BoxPainter { + _InsetShadowPainter(this.decoration, super.onChanged); + + final InsetShadowDecoration decoration; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + if (size == null) { + return; + } + final rect = offset & size; + final rrect = decoration.borderRadius.toRRect(rect); + + for (final shadow in decoration.boxShadows) { + canvas.drawRRect( + rrect.shift(shadow.offset).inflate(shadow.spreadRadius), + shadow.toPaint(), + ); + } + + final color = decoration.color; + if (color != null) { + canvas.drawRRect(rrect, Paint()..color = color); + } + final gradient = decoration.gradient; + if (gradient != null) { + canvas.drawRRect( + rrect, + Paint() + ..shader = gradient.createShader( + rect, + textDirection: configuration.textDirection, + ), + ); + } + + for (final shadow in decoration.shadows) { + _paintShadow(canvas, rect, rrect, shadow); + } + + decoration.border?.paint( + canvas, + rect, + textDirection: configuration.textDirection, + borderRadius: decoration.borderRadius, + ); + } + + void _paintShadow(Canvas canvas, Rect rect, RRect rrect, InsetShadow shadow) { + // The hole is the box itself, moved by the offset and shrunk by the + // spread; whatever it no longer covers inside the clip is the shadow. + final hole = rrect.shift(shadow.offset).deflate(shadow.spreadRadius); + // Extend the filled area past the clip so the blur never fades at the + // box edge, only at the hole edge. + final reach = shadow.blurRadius + shadow.spreadRadius; + final outer = rect.inflate(reach + shadow.offset.distance + 1); + + final ring = Path() + ..fillType = PathFillType.evenOdd + ..addRect(outer) + ..addRRect(hole); + + final paint = Paint()..color = shadow.color; + if (shadow.blurRadius > 0) { + paint.maskFilter = ui.MaskFilter.blur( + ui.BlurStyle.normal, + Shadow.convertRadiusToSigma(shadow.blurRadius), + ); + } + + canvas + ..save() + ..clipRRect(rrect) + ..drawPath(ring, paint) + ..restore(); + } +} diff --git a/test/widgets/inset_shadow_decoration_test.dart b/test/widgets/inset_shadow_decoration_test.dart new file mode 100644 index 00000000..b606a33a --- /dev/null +++ b/test/widgets/inset_shadow_decoration_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; + +const _size = Size(120, 40); +const _fill = Color(0xff27272a); + +Future<_Pixels> _render(WidgetTester tester, Decoration decoration) async { + final key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: _size.width, + height: _size.height + 1, + child: Align( + alignment: Alignment.topCenter, + child: Container( + width: _size.width, + height: _size.height, + decoration: decoration, + ), + ), + ), + ), + ), + ), + ); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + // Engine futures only resolve inside runAsync; the fake-async test zone + // never completes them. + late _Pixels pixels; + await tester.runAsync(() async { + final image = await boundary.toImage(); + final dump = Platform.environment['INSET_SHADOW_DUMP']; + if (dump != null) { + final png = await image.toByteData(format: ui.ImageByteFormat.png); + File(dump).writeAsBytesSync(png!.buffer.asUint8List()); + } + pixels = _Pixels(image.width, (await image.toByteData())!); + }); + return pixels; +} + +class _Pixels { + const _Pixels(this.width, this.bytes); + + final int width; + final ByteData bytes; + + Color at(int x, int y) { + final i = (y * width + x) * 4; + return Color.fromARGB( + bytes.getUint8(i + 3), + bytes.getUint8(i), + bytes.getUint8(i + 1), + bytes.getUint8(i + 2), + ); + } +} + +void main() { + testWidgets('raised rim lightens the edges and the top edge most', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + shadows: Settings.raisedRim, + ), + ); + final centre = image.at(60, 20); + final top = image.at(60, 0); + final bottom = image.at(60, 39); + final left = image.at(0, 20); + final inside = image.at(60, 2); + + expect(centre, _fill, reason: 'the face keeps the plain fill'); + expect(inside, _fill, reason: 'the edges are one pixel deep'); + expect(left, _fill, reason: 'the sides stay bare'); + expect(top.r, greaterThan(centre.r), reason: 'top edge is lit'); + expect(bottom.r, lessThan(centre.r), reason: 'bottom edge is shaded'); + }); + + testWidgets('gradient paints over the fill and drop shadows fall outside', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + gradient: Settings.raisedGradient(_fill), + borderRadius: BorderRadius.circular(6), + boxShadows: const [Settings.raisedDropShadow], + ), + ); + expect(image.at(60, 4).r, greaterThan(image.at(60, 35).r), + reason: 'the fill runs lighter at the top'); + expect(image.at(60, 40).a, greaterThan(0), + reason: 'the shadow lands one pixel under the box'); + }); + + testWidgets('a blurred inset shadow darkens toward the edge', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration( + color: Color(0xffffffff), + shadows: [ + InsetShadow(color: Color(0xff000000), blurRadius: 8), + ], + ), + ); + final centre = image.at(60, 20); + final nearEdge = image.at(60, 1); + final midway = image.at(60, 4); + expect(centre, const Color(0xffffffff)); + expect(nearEdge.r, lessThan(midway.r)); + expect(midway.r, lessThan(centre.r)); + }); + + testWidgets('without shadows nothing paints outside the fill', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration(color: _fill), + ); + expect(image.at(0, 0), _fill); + expect(image.at(119, 39), _fill); + }); + + test('tweens from a plain rounded BoxDecoration', () { + final from = BoxDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + ); + final to = Settings.raisedPrimary(6); + final mid = Decoration.lerp(from, to, 0.5); + expect(mid, isA()); + final raised = mid! as InsetShadowDecoration; + expect(raised.shadows, hasLength(2)); + expect(raised.shadows.first.color.a, closeTo(0.07, 0.01), + reason: 'the top light fades in halfway'); + expect(Decoration.lerp(to, from, 0.75), isA()); + }); +} From 198bba7d064d639984fc90e4fd9786d020ad690d Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:51:32 -0400 Subject: [PATCH 2/7] Quiet the editor toolbar and square up the map card Toolbar glyphs drop from the 300 to the 200 Lucide weight and rest on zinc-300 instead of full white, coming up to foreground on hover. The sync popover pins its text to the start (the editor's centered alignment leaked in), loses its bold title, and puts its actions in one right-aligned row. The map card was 262pt wide with 260pt of contents, so the slack landed on the right of the side toggle. Its width is derived from the tile, gap, and toggle now, so both sides get the same 4pt. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/editor_toolbar.dart | 29 ++++------ lib/widgets/map_selector.dart | 67 ++++++++++++++-------- lib/widgets/map_tile.dart | 22 ++++--- lib/widgets/strategy_save_icon_button.dart | 38 ++++++------ 4 files changed, 86 insertions(+), 70 deletions(-) diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index f60959c8..7f984be6 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -23,10 +23,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; /// Geometry shared by every control in the editor's floating toolbar, so the /// save button matches its neighbours exactly. class EditorToolbarButtonStyle { - const EditorToolbarButtonStyle({ - this.size = 32, - this.iconSize = 18, - }); + const EditorToolbarButtonStyle({this.size = 32, this.iconSize = 18}); final double size; final double iconSize; @@ -70,13 +67,13 @@ class _EditorToolbarState extends ConsumerState { style: style, tooltip: 'Export .ica', onPressed: _exportStrategy, - icon: const Icon(LucideIcons.upload300), + icon: const Icon(LucideIcons.upload200), ), EditorToolbarButton( style: style, tooltip: 'Export video', onPressed: _exportVideo, - icon: const Icon(LucideIcons.clapperboard300), + icon: const Icon(LucideIcons.clapperboard200), ), EditorToolbarButton( style: style, @@ -93,7 +90,7 @@ class _EditorToolbarState extends ConsumerState { ), ), ) - : const Icon(LucideIcons.camera300), + : const Icon(LucideIcons.camera200), ), const EditorToolbarDivider(), EditorToolbarButton( @@ -105,7 +102,7 @@ class _EditorToolbarState extends ConsumerState { builder: (context) => const SettingsTab(), ); }, - icon: const Icon(LucideIcons.settings300), + icon: const Icon(LucideIcons.settings200), ), ], ), @@ -157,10 +154,9 @@ class _EditorToolbarState extends ConsumerState { await ref.read(strategyProvider.notifier).forceSaveNow(id); - final newStrat = Hive.box(HiveBoxNames.strategiesBox) - .values - .where((StrategyData strategy) => strategy.id == id) - .firstOrNull; + final newStrat = Hive.box( + HiveBoxNames.strategiesBox, + ).values.where((StrategyData strategy) => strategy.id == id).firstOrNull; if (newStrat == null) { if (mounted) setState(() => _isCapturingScreenshot = false); @@ -206,10 +202,7 @@ class _EditorToolbarState extends ConsumerState { screenshotView.hydrateProviders(screenshotContainer); final image = await newController.captureFromWidget( targetSize: CoordinateSystem.screenShotSize, - wrapForOffscreenCapture( - screenshotView, - container: screenshotContainer, - ), + wrapForOffscreenCapture(screenshotView, container: screenshotContainer), ); if (mounted) setState(() => _isCapturingScreenshot = false); String? outputFile = await FilePicker.platform.saveFile( @@ -237,7 +230,7 @@ class _EditorToolbarState extends ConsumerState { } } -/// One control in the editor toolbar. Glyphs are the 300 stroke weight: the +/// One control in the editor toolbar. Glyphs are the 200 stroke weight: the /// default 2px Lucide stroke reads heavy in white at 18px, and muted grey /// vanishes against the card, so the weight carries the quietness instead. [icon] is any 18px glyph, so buttons can swap /// in a spinner without changing size. @@ -266,7 +259,7 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final resting = foregroundColor ?? theme.foreground; + final resting = foregroundColor ?? Settings.toolbarGlyph; return Semantics( label: semanticsLabel ?? tooltip, button: true, diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index fe179a82..237e19ca 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -16,12 +16,20 @@ class MapSelector extends ConsumerStatefulWidget { } class _MapSelectorState extends ConsumerState { - static const double _cardWidth = 262; static const double _cardHeight = 65; static const double _outerRadius = 12; static const double _innerGap = 4; static const double _innerRadius = _outerRadius - _innerGap; + static const double _borderWidth = 1; static const double _sideToggleWidth = 66; + // Sized from the contents so the gap on the right of the side toggle equals + // the gap on the left of the map tile. + static const double _cardWidth = + 2 * _borderWidth + + 2 * _innerGap + + MapTile.width + + _innerGap + + _sideToggleWidth; final OverlayPortalController _controller = OverlayPortalController(); final _link = LayerLink(); @@ -56,19 +64,20 @@ class _MapSelectorState extends ConsumerState { @override Widget build(BuildContext context) { final MapValue currentMap = ref.watch(mapProvider).currentMap; - final List availableMaps = Maps.mapNames.keys - .where((mapValue) => Maps.availableMaps.contains(mapValue)) - .toList() - ..sort( - (a, b) => Maps.mapNames[a]! - .toLowerCase() - .compareTo(Maps.mapNames[b]!.toLowerCase()), - ); + final List availableMaps = + Maps.mapNames.keys + .where((mapValue) => Maps.availableMaps.contains(mapValue)) + .toList() + ..sort( + (a, b) => Maps.mapNames[a]!.toLowerCase().compareTo( + Maps.mapNames[b]!.toLowerCase(), + ), + ); final List outOfRotationMaps = Maps.outofplayMaps.toList() ..sort( - (a, b) => Maps.mapNames[a]! - .toLowerCase() - .compareTo(Maps.mapNames[b]!.toLowerCase()), + (a, b) => Maps.mapNames[a]!.toLowerCase().compareTo( + Maps.mapNames[b]!.toLowerCase(), + ), ); return CompositedTransformTarget( @@ -77,7 +86,10 @@ class _MapSelectorState extends ConsumerState { decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, borderRadius: const BorderRadius.all(Radius.circular(_outerRadius)), - border: Border.all(color: Settings.tacticalVioletTheme.border), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: _borderWidth, + ), ), width: _cardWidth, height: _cardHeight, @@ -101,8 +113,9 @@ class _MapSelectorState extends ConsumerState { width: 260, decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: - const BorderRadius.all(Radius.circular(10)), + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), border: Border.all( color: Settings.tacticalVioletTheme.border, width: 2, @@ -110,15 +123,17 @@ class _MapSelectorState extends ConsumerState { ), child: ClipRRect( clipBehavior: Clip.antiAlias, - borderRadius: - const BorderRadius.all(Radius.circular(10)), + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: ListView.separated( padding: const EdgeInsets.all(_innerGap), - itemCount: availableMaps.length + + itemCount: + availableMaps.length + outOfRotationMaps.length + 1, separatorBuilder: (_, __) => @@ -154,15 +169,21 @@ class _MapSelectorState extends ConsumerState { style: TextStyle( fontWeight: FontWeight.w500, color: Color.fromARGB( - 255, 160, 160, 160), + 255, + 160, + 160, + 160, + ), ), textAlign: TextAlign.center, ), ); } - final mapValue = outOfRotationMaps[ - index - availableMaps.length - 1]; + final mapValue = + outOfRotationMaps[index - + availableMaps.length - + 1]; final mapName = Maps.mapNames[mapValue]!; return MapTile( name: mapName, @@ -235,12 +256,12 @@ class _MapSelectorState extends ConsumerState { fontWeight: FontWeight.w500, color: Colors.white, ), - ) + ), ], ), ), ), - ) + ), ], ), ), diff --git a/lib/widgets/map_tile.dart b/lib/widgets/map_tile.dart index fc1d9e83..61d2851a 100644 --- a/lib/widgets/map_tile.dart +++ b/lib/widgets/map_tile.dart @@ -18,6 +18,10 @@ class MapTile extends ConsumerStatefulWidget { final bool isActive; final double borderRadius; + /// The tile's fixed footprint; the map card sizes itself from this. + static const double width = 180; + static const double height = 65; + @override ConsumerState createState() => _MapTileState(); } @@ -28,18 +32,20 @@ class _MapTileState extends ConsumerState { @override Widget build(BuildContext context) { return MouseRegion( - onEnter: - widget.isPreview ? null : (_) => setState(() => _isHovered = true), - onExit: - widget.isPreview ? null : (_) => setState(() => _isHovered = false), + onEnter: widget.isPreview + ? null + : (_) => setState(() => _isHovered = true), + onExit: widget.isPreview + ? null + : (_) => setState(() => _isHovered = false), child: ClipRRect( borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)), child: InkWell( mouseCursor: SystemMouseCursors.click, onTap: widget.onTap, child: SizedBox( - width: 180, - height: 65, + width: MapTile.width, + height: MapTile.height, child: Stack( children: [ Positioned.fill( @@ -77,11 +83,11 @@ class _MapTileState extends ConsumerState { color: Colors.black, blurRadius: 2, offset: Offset(0, 2), - ) + ), ], ), ), - ) + ), ], ), ), diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 63a6d3c2..78c634eb 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -31,10 +31,9 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { ), child: Text( 'Save Complete', - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Settings.tacticalVioletTheme.foreground), + style: ShadTheme.of(context).textTheme.small.copyWith( + color: Settings.tacticalVioletTheme.foreground, + ), ), ); }, @@ -44,10 +43,7 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { /// The save button of the open strategy. Shows a spinner while an auto-save /// runs and a check when it lands, then rests on the save glyph. class AutoSaveButton extends ConsumerStatefulWidget { - const AutoSaveButton({ - super.key, - this.style = kEditorToolbarButtonStyle, - }); + const AutoSaveButton({super.key, this.style = kEditorToolbarButtonStyle}); final EditorToolbarButtonStyle style; @@ -100,23 +96,23 @@ class _AutoSaveButtonState extends ConsumerState { final size = widget.style.iconSize; final Widget icon = switch (_phase) { - _Phase.idle => const Icon(LucideIcons.save300, key: ValueKey('idle')), + _Phase.idle => const Icon(LucideIcons.save200, key: ValueKey('idle')), _Phase.loading => SizedBox( - key: const ValueKey('loading'), - width: size - 2, - height: size - 2, - child: CircularProgressIndicator( - strokeWidth: 1.8, - valueColor: AlwaysStoppedAnimation( - Settings.tacticalVioletTheme.mutedForeground, - ), + key: const ValueKey('loading'), + width: size - 2, + height: size - 2, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, ), ), + ), _Phase.success => const Icon( - LucideIcons.check300, - key: ValueKey('success'), - color: Settings.allyBGColor, - ), + LucideIcons.check200, + key: ValueKey('success'), + color: Settings.allyBGColor, + ), }; return EditorToolbarButton( From 02b0860094c92ea0710890055253e8341df62fed Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:32:30 -0400 Subject: [PATCH 3/7] Finish the Lucide sweep in the color picker, expansion tile, demo tag, and delete area Co-Authored-By: Claude Fable 5.1 --- lib/widgets/color_picker_button.dart | 1 + lib/widgets/delete_area.dart | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/widgets/color_picker_button.dart b/lib/widgets/color_picker_button.dart index c1111167..ad02a817 100644 --- a/lib/widgets/color_picker_button.dart +++ b/lib/widgets/color_picker_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; diff --git a/lib/widgets/delete_area.dart b/lib/widgets/delete_area.dart index f1e3cc3a..efb59d70 100644 --- a/lib/widgets/delete_area.dart +++ b/lib/widgets/delete_area.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/placed_classes.dart'; From 13e3adde00815744b57afe2cc2343bb329bede68 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:34:25 -0400 Subject: [PATCH 4/7] Document the editor toolbar and icon rules in DESIGN.md Co-Authored-By: Claude Fable 5.1 --- DESIGN.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index a1c97731..9fa8aa8e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -19,6 +19,11 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with - The library strip holds the three tabs on the left and only search, sort, and New on the right (there is no account yet). Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. - The editor's document actions (save, export, video, screenshot, settings) sit in one card at the top-left of the canvas (`lib/widgets/editor_toolbar.dart`). No status chips or labels in the editor. +## Icons + +- Lucide is the icon family (`LucideIcons.*`, re-exported by shadcn_ui). Material `Icons.*` only survive in the persisted folder-icon registry and cursor glyphs. Toolbar and tool glyphs are 18-22px, menu and inline glyphs 16px. +- Icons rest in muted foreground and come up to foreground on hover. Violet on an icon means it is the selected or checked item, nothing else. + ## Things I would like to remain consistent **The One Command Color** Violet marks current action, selection, focus, and primary commands, and nothing else. If violet appears somewhere that isn't actionable or active, it's wrong. From cdb3efa8b7766baba1688818a6d445859accddb5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:36:50 -0400 Subject: [PATCH 5/7] Focus the rename and folder dialogs on open and submit on Enter Co-Authored-By: Claude Fable 5.1 --- lib/widgets/dialogs/strategy/rename_strategy_dialog.dart | 2 +- lib/widgets/folder_edit_dialog.dart | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index a4aaa73c..061b5a49 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -71,9 +71,9 @@ class _RenameStrategyDialogState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(8.0), child: CustomTextField( - // onEnterPressed: (intent) {}, hintText: widget.currentName, controller: _textController, + autofocus: true, textAlign: TextAlign.start, onSubmitted: (value) async { if (value.isNotEmpty) { diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index ed17f34e..daefeba3 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -68,6 +68,10 @@ class _FolderEditDialogState extends ConsumerState { child: CustomTextField( hintText: "Folder Name", controller: _folderNameController, + autofocus: true, + onSubmitted: (_) { + if (!_isSubmitting) _submit(); + }, ), ), // const SizedBox(width: 30), From 1528fda9e5ec881dc21970a4b3f440a1f89d50ee Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:51:32 -0400 Subject: [PATCH 6/7] Raise every selected state and rebuild the menus that sat outside the system Selected and primary states are raised surfaces now instead of flat violet or zinc fills: the library tab, checked sidebar tools, the thickness and agent-filter pills, the active page row and its transition sweep, the strategy card's hover ring, primary buttons through the Shad theme, the placement banner, and toasts. The page row is rebuilt as fixed layers so switching states no longer shifts its content by the resting border's width. Active items keep their siblings' weight; the surface marks them. The strategy quick switcher becomes a proper menu: popover grey, hairline border, 12px radius, the floating-menu shadow, flat ghost rows with a thumbnail. Its bar clips both segments so hover fills stop spilling past the border. The cloud-exit dialog lays its actions out in one wrapping row with sentence-case labels. The cloud outbox banner stops pushing the library grid down and floats in the corner as a status card instead. Co-Authored-By: Claude Fable 5.1 --- lib/main.dart | 14 + lib/services/unsaved_strategy_guard.dart | 11 +- lib/widgets/custom_segmented_tabs.dart | 17 +- lib/widgets/library_title_strip.dart | 34 ++- lib/widgets/line_up_placer.dart | 9 +- lib/widgets/pages_bar.dart | 62 ++-- lib/widgets/selectable_icon_button.dart | 22 +- lib/widgets/strategy_quick_switcher.dart | 291 +++++++++---------- lib/widgets/strategy_tile/strategy_tile.dart | 75 ++--- test/unsaved_strategy_guard_test.dart | 2 +- 10 files changed, 287 insertions(+), 250 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2ab5d91c..3ce08e11 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -367,6 +367,20 @@ class _MyAppState extends ConsumerState { ghostButtonTheme: ShadButtonTheme( foregroundColor: Settings.tacticalVioletTheme.foreground, ), + // Primary commands are raised like the selected tab: a lighter top + // of the fill, a bright 1px edge inside the top, a 1px shadow + // beneath. A rounded Border must be one color, so the theme paints + // the top light only and the gradient carries the bottom shade. + primaryButtonTheme: ShadButtonTheme( + decoration: ShadDecoration( + gradient: Settings.raisedPrimaryFill, + shadows: [Settings.raisedDropShadow], + border: const ShadBorder( + radius: BorderRadius.all(Radius.circular(6)), + top: ShadBorderSide(color: Settings.raisedTopLight, width: 1), + ), + ), + ), ), home: const MyHomePage(), routes: { diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 374a9ca1..0b93e7a3 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -18,14 +18,11 @@ Future showUnsavedStrategyDialog( builder: (context) { return ShadDialog.alert( title: const Text('Save changes?'), - description: const Padding( - padding: EdgeInsets.all(8), - child: Text( - 'This strategy has unsaved changes. Do you want to save before leaving?', - ), + description: const Text( + 'This strategy has unsaved changes. Do you want to save before leaving?', ), actions: [ - ShadButton.secondary( + ShadButton.ghost( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.cancel); }, @@ -35,7 +32,7 @@ Future showUnsavedStrategyDialog( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.dontSave); }, - child: const Text("Don't Save"), + child: const Text("Don't save"), ), ShadButton( onPressed: () { diff --git a/lib/widgets/custom_segmented_tabs.dart b/lib/widgets/custom_segmented_tabs.dart index db0d7d67..ad506724 100644 --- a/lib/widgets/custom_segmented_tabs.dart +++ b/lib/widgets/custom_segmented_tabs.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; enum SegmentedIndicatorBehavior { slidingPill, @@ -207,10 +208,7 @@ class _CustomSegmentedTabsState extends State> { width: _segmentWidths[selectedIndex], bottom: 0, child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(_segmentRadius), - ), + decoration: Settings.raisedPrimary(_segmentRadius), ), ), Row( @@ -296,12 +294,11 @@ class _TabButton extends StatelessWidget { horizontal: horizontalPadding, vertical: verticalPadding, ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: showStaticFill && isSelected - ? Settings.tacticalVioletTheme.primary - : Colors.transparent, - ), + decoration: showStaticFill && isSelected + ? Settings.raisedPrimary(borderRadius) + : InsetShadowDecoration( + borderRadius: BorderRadius.circular(borderRadius), + ), child: DefaultTextStyle.merge( style: TextStyle( color: textColor, diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 7be4003f..31d01367 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -12,6 +12,8 @@ import 'package:shadcn_ui/shadcn_ui.dart'; const double _controlHeight = 28; // Tabs sit apart so a selected and a hovered background never touch. const double _tabGap = 4; +// Matches the ghost button's own radius so the rim hugs its hover shape. +const double _tabRadius = 6; // Action menus hug their labels. const double _sortMenuWidth = 168; const double _newMenuWidth = 140; @@ -280,19 +282,25 @@ class _TabButton extends StatelessWidget { onTap: onTap, child: Opacity( opacity: dimmed ? 0.45 : 1, - child: ShadButton.ghost( - height: _controlHeight, - padding: const EdgeInsets.symmetric(horizontal: 8), - gap: 6, - cursor: onTap == null ? SystemMouseCursors.basic : null, - backgroundColor: selected ? theme.secondary : null, - foregroundColor: foreground, - hoverForegroundColor: theme.foreground, - onPressed: onTap, - leading: Icon(icon, size: 15), - child: Text( - label, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + child: DecoratedBox( + decoration: selected + ? Settings.raisedSurface(_tabRadius) + : const BoxDecoration(), + child: ShadButton.ghost( + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + gap: 6, + cursor: onTap == null ? SystemMouseCursors.basic : null, + backgroundColor: selected ? Colors.transparent : null, + hoverBackgroundColor: selected ? Colors.transparent : null, + foregroundColor: foreground, + hoverForegroundColor: theme.foreground, + onPressed: onTap, + leading: Icon(icon, size: 15), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), ), ), ), diff --git a/lib/widgets/line_up_placer.dart b/lib/widgets/line_up_placer.dart index 6257e6d1..e4a272a4 100644 --- a/lib/widgets/line_up_placer.dart +++ b/lib/widgets/line_up_placer.dart @@ -164,14 +164,7 @@ class _LineupPositionWidgetState extends ConsumerState { horizontal: 12, vertical: 8, ), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - boxShadow: const [Settings.cardForegroundBackdrop], - ), + decoration: Settings.raisedPrimary(8), child: Text( lineUpPlacementStatus(placement), style: ShadTheme.of(context) diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index f5e561cc..078ce29d 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -411,7 +411,7 @@ class _CollapsedPill extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontSize: 14), ), ), @@ -786,9 +786,7 @@ class _PageRowState extends State<_PageRow> { final fillProgress = widget.transitionProgress?.clamp(0.0, 1.0); final showActions = _hovered || widget.active || widget.transitionProgress != null; - final bg = widget.active && fillProgress == null - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.card; + final isRaised = widget.active && fillProgress == null; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), @@ -803,34 +801,48 @@ class _PageRowState extends State<_PageRow> { onTap: () => widget.onSelect(widget.page.id), child: ClipRRect( borderRadius: BorderRadius.circular(_PageRow._rowRadius), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(_PageRow._rowRadius), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Settings.tacticalVioletTheme.card - .withValues(alpha: 0.2), - blurRadius: 12, - offset: const Offset(0, 4)) - ], - color: bg, - ), + // Every layer is a Positioned.fill over one fixed-size box, so + // the surface, the transition sweep, and the content share the + // same rect in every state. (A Container would inset its child + // by the resting border's width and shift everything 1px when + // the row turns active, which has no border.) + child: SizedBox( height: _PageRow._rowHeight, child: Stack( children: [ + Positioned.fill( + child: DecoratedBox( + decoration: isRaised + ? Settings.raisedPrimary(_PageRow._rowRadius) + : BoxDecoration( + borderRadius: + BorderRadius.circular(_PageRow._rowRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Settings.tacticalVioletTheme.card + .withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + color: Settings.tacticalVioletTheme.card, + ), + ), + ), if (fillProgress != null) Positioned.fill( child: FractionallySizedBox( widthFactor: fillProgress, alignment: Alignment.centerLeft, + // The same surface as the active row, so the + // sweep's corners match at every width and it + // lands exactly on the active state. child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - ), + decoration: + Settings.raisedPrimary(_PageRow._rowRadius), ), ), ), @@ -845,9 +857,7 @@ class _PageRowState extends State<_PageRow> { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: widget.active - ? FontWeight.w600 - : FontWeight.w500, + fontWeight: FontWeight.w500, fontSize: 14, ), ), diff --git a/lib/widgets/selectable_icon_button.dart b/lib/widgets/selectable_icon_button.dart index b76ad8c3..e320018b 100644 --- a/lib/widgets/selectable_icon_button.dart +++ b/lib/widgets/selectable_icon_button.dart @@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +// The secondary icon button's own corner radius (the theme default). +const double _radius = 6; + class SelectableIconButton extends ConsumerWidget { const SelectableIconButton({ super.key, @@ -24,17 +27,24 @@ class SelectableIconButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasShortcutLabel = shortcutLabel != null && shortcutLabel!.isNotEmpty; + // A checked tool is a raised command surface. A caller-supplied color + // (the delete tools' red) stays a flat fill. + final raised = isSelected && hoverBackgroundColor == null; + final flatColor = + isSelected ? hoverBackgroundColor ?? Colors.transparent : null; Widget button = ShadIconButton.secondary( padding: EdgeInsets.zero, icon: icon, - backgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, - hoverBackgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, + backgroundColor: flatColor, + hoverBackgroundColor: flatColor, onPressed: onPressed, ); + if (raised) { + button = DecoratedBox( + decoration: Settings.raisedPrimary(_radius), + child: button, + ); + } // No tooltip text means no ShadTooltip wrapper; an empty tooltip bubble // would still pop up on hover otherwise. diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index ffba409c..049a5907 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -6,8 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; -import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -28,6 +28,12 @@ class StrategyQuickSwitcher extends ConsumerStatefulWidget { class _StrategyQuickSwitcherState extends ConsumerState { static const double _barWidth = 280; static const double _barHeight = 30; + static const double _barRadius = 8; + static const double _chevronWidth = 38; + // Menu geometry, matching the library strip's popovers. + static const double _menuRadius = 12; + static const double _menuInset = 6; + static const double _rowInset = 6; static const EdgeInsets _displayMargin = EdgeInsets.symmetric(horizontal: 16); final OverlayPortalController _controller = OverlayPortalController(); final LayerLink _layerLink = LayerLink(); @@ -249,6 +255,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { @override Widget build(BuildContext context) { final currentStrategy = ref.watch(strategyProvider); + final currentStrategyId = currentStrategy.id; final strategyName = currentStrategy.stratName ?? 'Untitled Strategy'; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); @@ -261,7 +268,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { builder: (context, box, _) { final recents = _recentStrategies( box: box, - currentStrategyId: currentStrategy.id, + currentStrategyId: currentStrategyId, ); return OverlayPortal.overlayChildLayoutBuilder( @@ -293,73 +300,81 @@ class _StrategyQuickSwitcherState extends ConsumerState { left: left, top: top, width: _barWidth, - child: Material( - color: Colors.transparent, - child: Container( - constraints: BoxConstraints(maxHeight: maxHeight), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.background, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), + // A floating menu like the library strip's: popover + // grey, hairline border, panel radius, one shadow. The + // rows inside are flat. + child: Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.popover, + borderRadius: BorderRadius.circular(_menuRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, ), - child: recents.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Text( - 'No recent strategies', - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white70), + boxShadow: const [Settings.floatingMenuShadow], + ), + child: recents.isEmpty + ? Padding( + padding: const EdgeInsets.fromLTRB( + _menuInset + _rowInset, + _menuInset + 6, + _menuInset + _rowInset, + _menuInset + 6, + ), + child: Text( + 'No recent strategies', + style: TextStyle( + fontSize: 12, + color: Settings + .tacticalVioletTheme.mutedForeground, ), - ) - : ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.all(8), - itemCount: recents.length, - separatorBuilder: (_, __) => - const SizedBox(height: 8), - itemBuilder: (context, index) { - final strategy = recents[index]; - final attackLabel = _attackLabel(strategy); - final mapName = _mapName(strategy); - final thumbnail = - 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; - return _StrategyQuickSwitchItem( - strategyName: strategy.name, - mapName: mapName, - attackLabel: attackLabel, - attackColor: _attackColor(attackLabel), - lastEdited: _timeAgo(strategy.lastEdited), - thumbnailPath: thumbnail, - onTap: _isSwitching || _isEditingName - ? null - : () => _switchStrategy(strategy.id), - ); - }, ), - ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.all(_menuInset), + itemCount: recents.length, + separatorBuilder: (_, __) => + const SizedBox(height: 2), + itemBuilder: (context, index) { + final strategy = recents[index]; + final attackLabel = _attackLabel(strategy); + final mapName = _mapName(strategy); + final thumbnail = + 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; + return _StrategyQuickSwitchItem( + strategyName: strategy.name, + mapName: mapName, + attackLabel: attackLabel, + attackColor: _attackColor(attackLabel), + lastEdited: _timeAgo(strategy.lastEdited), + thumbnailPath: thumbnail, + onTap: _isSwitching || _isEditingName + ? null + : () => _switchStrategy(strategy.id), + ); + }, + ), ), ), ], ); }, + // A two-segment control. The children are clipped to the + // bar's inner rounded rect, so each segment's hover fill runs + // edge to edge and the bar's own corners round it off. child: Container( key: const ValueKey('strategy-quick-switcher-control'), width: _barWidth, height: _barHeight, decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_barRadius), border: Border.all( color: Settings.tacticalVioletTheme.border, ), ), + clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( @@ -441,7 +456,8 @@ class _StrategyQuickSwitcherState extends ConsumerState { mouseCursor: currentStrategy.stratName == null ? SystemMouseCursors.basic : SystemMouseCursors.click, - borderRadius: BorderRadius.circular(8), + hoverColor: + Settings.tacticalVioletTheme.accent, child: Center( child: Padding( padding: const EdgeInsets.symmetric( @@ -465,12 +481,17 @@ class _StrategyQuickSwitcherState extends ConsumerState { ), Container( width: 1, - height: 30, color: Settings.tacticalVioletTheme.border, ), SizedBox( - width: 38, + width: _chevronWidth, child: ShadIconButton.ghost( + width: _chevronWidth, + height: double.infinity, + padding: EdgeInsets.zero, + decoration: const ShadDecoration( + border: ShadBorder(radius: BorderRadius.zero), + ), onPressed: _isSwitching || _isEditingName ? null : () => _isOpen ? _closePortal() : _openPortal(), @@ -526,106 +547,84 @@ class _StrategyQuickSwitchItem extends StatefulWidget { } class _StrategyQuickSwitchItemState extends State<_StrategyQuickSwitchItem> { - bool _isHovered = false; + static const double _rowHeight = 40; + static const double _thumbnail = 28; @override Widget build(BuildContext context) { - final isEnabled = widget.onTap != null; - final borderColor = _isHovered - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.border; - final backgroundColor = _isHovered - ? Settings.tacticalVioletTheme.card.withValues(alpha: 0.85) - : Settings.tacticalVioletTheme.card; - - return MouseRegion( - cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: borderColor), - boxShadow: const [Settings.cardForegroundBackdrop], + const theme = Settings.tacticalVioletTheme; + // A flat menu row: thumbnail, name over map, side over time. Hover is + // the ghost button's grey fill; nothing here is bordered or shadowed. + return ShadButton.ghost( + height: _rowHeight, + expands: true, + mainAxisAlignment: MainAxisAlignment.start, + padding: const EdgeInsets.symmetric( + horizontal: _StrategyQuickSwitcherState._rowInset, + ), + gap: 10, + onPressed: widget.onTap, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + widget.thumbnailPath, + width: _thumbnail, + height: _thumbnail, + fit: BoxFit.cover, ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.onTap, - borderRadius: BorderRadius.circular(10), - mouseCursor: - isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - hoverColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.12), - splashColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.2), - child: Padding( - padding: const EdgeInsets.all(6), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - widget.thumbnailPath, - width: 46, - height: 46, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - OverflowTooltipText( - widget.strategyName, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - widget.mapName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white70, - ), - ), - ], - ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + OverflowTooltipText( + widget.strategyName, + style: TextStyle( + fontSize: 13, + height: 1.2, + color: theme.foreground, ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.attackLabel, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: widget.attackColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - widget.lastEdited, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white54, - ), - ), - ], + ), + Text( + widget.mapName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, ), - ], - ), + ), + ], ), ), - ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.attackLabel, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: widget.attackColor, + ), + ), + Text( + widget.lastEdited, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, + ), + ), + ], + ), + ], ), ); } diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 965baeea..9f667f04 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -33,8 +33,10 @@ class StrategyTile extends ConsumerStatefulWidget { ConsumerState createState() => _StrategyTileState(); } +const double _ringWidth = 2; + class _StrategyTileState extends ConsumerState { - Color _highlightColor = Settings.tacticalVioletTheme.border; + bool _isHovered = false; bool _isLoading = false; bool _menuButtonWasOpenOnPointerDown = false; DropInsertionSide? _pinnedDropSide; @@ -153,10 +155,8 @@ class _StrategyTileState extends ConsumerState { ), child: MouseRegion( cursor: SystemMouseCursors.click, - onEnter: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.ring), - onExit: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.border), + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), child: AbsorbPointer( absorbing: _isLoading, child: ShadContextMenuRegion( @@ -177,38 +177,47 @@ class _StrategyTileState extends ConsumerState { return Stack( clipBehavior: Clip.none, children: [ + // The ring is the outer box showing through a + // 2px inset: flat zinc at rest, and on hover the + // lit violet gradient, since a Border cannot + // take a gradient. AnimatedContainer( - duration: const Duration(milliseconds: 100), - decoration: BoxDecoration( - color: ShadTheme.of(context).colorScheme.card, - borderRadius: BorderRadius.circular( - strategyTileOuterRadius), - border: Border.all( - color: isPinDropTarget - ? Settings.tacticalVioletTheme.border - : _highlightColor, - width: 2, + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.border, + gradient: !isPinDropTarget && _isHovered + ? Settings.raisedPrimaryFill + : null, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius), ), - ), - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Expanded( - child: StrategyTileThumbnail( - assetPath: viewData.thumbnailAsset, - borderRadius: strategyTileInnerRadius, - ), + padding: const EdgeInsets.all(_ringWidth), + child: Container( + decoration: BoxDecoration( + color: + ShadTheme.of(context).colorScheme.card, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius - _ringWidth), ), - const SizedBox(height: 10), - Expanded( - child: StrategyTileDetails( - data: viewData, - borderRadius: strategyTileInnerRadius, - ), + padding: const EdgeInsets.all(8 - _ringWidth), + child: Column( + children: [ + Expanded( + child: StrategyTileThumbnail( + assetPath: viewData.thumbnailAsset, + borderRadius: strategyTileInnerRadius, + ), + ), + const SizedBox(height: 10), + Expanded( + child: StrategyTileDetails( + data: viewData, + borderRadius: strategyTileInnerRadius, + ), + ), + ], ), - ], - ), - ), + )), if (isPinned) Align( alignment: Alignment.topLeft, diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index 10dbdc7d..5cf242dc 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -411,7 +411,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Save changes?'), findsOneWidget); - await tester.tap(find.text("Don't Save")); + await tester.tap(find.text("Don't save")); await tester.pumpAndSettle(); final result = await guardFuture; From 97ca04c9e9a062a2424bc0fec87c794963c3787b Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:58:50 -0400 Subject: [PATCH 7/7] Adapt the ported UI pass to main The folder dialog on main had no shared submit path, so Enter and Done now both go through one guarded _submit. Two imports the Lucide sweep had already added on main are deduplicated, and the analyzer's mechanical infos are cleared. Co-Authored-By: Claude Fable 5.1 --- lib/main.dart | 2 +- lib/widgets/color_picker_button.dart | 1 - lib/widgets/delete_area.dart | 1 - lib/widgets/folder_edit_dialog.dart | 60 ++++++++++++++++----------- test/sunset_scale_migration_test.dart | 5 ++- 5 files changed, 39 insertions(+), 30 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 3ce08e11..7e919518 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -374,7 +374,7 @@ class _MyAppState extends ConsumerState { primaryButtonTheme: ShadButtonTheme( decoration: ShadDecoration( gradient: Settings.raisedPrimaryFill, - shadows: [Settings.raisedDropShadow], + shadows: const [Settings.raisedDropShadow], border: const ShadBorder( radius: BorderRadius.all(Radius.circular(6)), top: ShadBorderSide(color: Settings.raisedTopLight, width: 1), diff --git a/lib/widgets/color_picker_button.dart b/lib/widgets/color_picker_button.dart index ad02a817..38d695a1 100644 --- a/lib/widgets/color_picker_button.dart +++ b/lib/widgets/color_picker_button.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class ColorPickerButton extends ConsumerStatefulWidget { const ColorPickerButton({ diff --git a/lib/widgets/delete_area.dart b/lib/widgets/delete_area.dart index efb59d70..e121611a 100644 --- a/lib/widgets/delete_area.dart +++ b/lib/widgets/delete_area.dart @@ -11,7 +11,6 @@ import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/widgets/delete_helpers.dart'; import 'package:icarus/widgets/sidebar_widgets/delete_options.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class DeleteArea extends ConsumerStatefulWidget { const DeleteArea({super.key}); diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index daefeba3..0b9ae7a4 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -58,6 +58,40 @@ class _FolderEditDialogState extends ConsumerState { }); } + bool _isSubmitting = false; + + /// Saves the folder and closes the dialog; Enter in the name field and the + /// Done button both land here, and a second submit while one is in flight + /// is ignored. + Future _submit() async { + if (_isSubmitting) return; + setState(() => _isSubmitting = true); + final name = _folderNameController.text.isEmpty + ? "New Folder" + : _folderNameController.text; + try { + if (widget.folder != null) { + ref.read(folderProvider.notifier).editFolder( + folder: widget.folder!, + newName: name, + newIconId: _selectedIconId, + newColor: _selectedColor, + newCustomColor: _customColor, + ); + } else { + await ref.read(folderProvider.notifier).createFolder( + name: name, + iconId: _selectedIconId, + color: _selectedColor, + customColor: _customColor, + ); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + if (mounted) Navigator.of(context).pop(); + } + @override Widget build(BuildContext context) { return ShadDialog( @@ -79,31 +113,7 @@ class _FolderEditDialogState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: ShadButton( leading: const Icon(LucideIcons.check), - onPressed: () async { - if (widget.folder != null) { - ref.read(folderProvider.notifier).editFolder( - folder: widget.folder!, - newName: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - newIconId: _selectedIconId, - newColor: _selectedColor, - newCustomColor: _customColor, - ); - if (context.mounted) Navigator.of(context).pop(); - return; - } - await ref.read(folderProvider.notifier).createFolder( - name: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - iconId: _selectedIconId, - color: _selectedColor, - customColor: _customColor, - ); - - if (context.mounted) Navigator.of(context).pop(); - }, + onPressed: _isSubmitting ? null : _submit, child: const Text("Done"), ), ) diff --git a/test/sunset_scale_migration_test.dart b/test/sunset_scale_migration_test.dart index 84123fb2..6c41890c 100644 --- a/test/sunset_scale_migration_test.dart +++ b/test/sunset_scale_migration_test.dart @@ -227,7 +227,7 @@ void main() { map: MapValue.sunset, ); const shift = 30 * (5.5 * 1.048 - 5.78 * _oldScale); - _expectPoint(migrated.position, Offset(shift, shift)); + _expectPoint(migrated.position, const Offset(shift, shift)); final source = _page(true); final oldCircle = source.utilityData.singleWhere( (u) => u.type == UtilityType.customCircle, @@ -240,7 +240,8 @@ void main() { (u) => u.type == UtilityType.customCircle, ); const inset = (40 - 14) * 5.78 * _oldScale; - _expectPoint(circle.position, oldCircle.position - Offset(inset, inset)); + _expectPoint( + circle.position, oldCircle.position - const Offset(inset, inset)); }); for (final version in [16, 38, 39, 44, 45, 96]) {