Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ 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.

**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.

Expand Down
82 changes: 75 additions & 7 deletions lib/const/settings.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<InsetShadow> 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,
Expand All @@ -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: [
Expand Down
14 changes: 14 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,20 @@ class _MyAppState extends ConsumerState<MyApp> {
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: const [Settings.raisedDropShadow],
border: const ShadBorder(
radius: BorderRadius.all(Radius.circular(6)),
top: ShadBorderSide(color: Settings.raisedTopLight, width: 1),
),
),
),
),
home: const MyHomePage(),
routes: {
Expand Down
11 changes: 4 additions & 7 deletions lib/services/unsaved_strategy_guard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,11 @@ Future<UnsavedStrategyDecision> 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);
},
Expand All @@ -35,7 +32,7 @@ Future<UnsavedStrategyDecision> showUnsavedStrategyDialog(
onPressed: () {
Navigator.of(context).pop(UnsavedStrategyDecision.dontSave);
},
child: const Text("Don't Save"),
child: const Text("Don't save"),
),
ShadButton(
onPressed: () {
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/color_picker_button.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ColorPickerButton extends ConsumerStatefulWidget {
const ColorPickerButton({
Expand Down
17 changes: 7 additions & 10 deletions lib/widgets/custom_segmented_tabs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -207,10 +208,7 @@ class _CustomSegmentedTabsState<T> extends State<CustomSegmentedTabs<T>> {
width: _segmentWidths[selectedIndex],
bottom: 0,
child: DecoratedBox(
decoration: BoxDecoration(
color: Settings.tacticalVioletTheme.primary,
borderRadius: BorderRadius.circular(_segmentRadius),
),
decoration: Settings.raisedPrimary(_segmentRadius),
),
),
Row(
Expand Down Expand Up @@ -296,12 +294,11 @@ class _TabButton<T> 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,
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/delete_area.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,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});
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/dialogs/strategy/rename_strategy_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ class _RenameStrategyDialogState extends ConsumerState<RenameStrategyDialog> {
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) {
Expand Down
29 changes: 11 additions & 18 deletions lib/widgets/editor_toolbar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,13 +67,13 @@ class _EditorToolbarState extends ConsumerState<EditorToolbar> {
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,
Expand All @@ -93,7 +90,7 @@ class _EditorToolbarState extends ConsumerState<EditorToolbar> {
),
),
)
: const Icon(LucideIcons.camera300),
: const Icon(LucideIcons.camera200),
),
const EditorToolbarDivider(),
EditorToolbarButton(
Expand All @@ -105,7 +102,7 @@ class _EditorToolbarState extends ConsumerState<EditorToolbar> {
builder: (context) => const SettingsTab(),
);
},
icon: const Icon(LucideIcons.settings300),
icon: const Icon(LucideIcons.settings200),
),
],
),
Expand Down Expand Up @@ -157,10 +154,9 @@ class _EditorToolbarState extends ConsumerState<EditorToolbar> {

await ref.read(strategyProvider.notifier).forceSaveNow(id);

final newStrat = Hive.box<StrategyData>(HiveBoxNames.strategiesBox)
.values
.where((StrategyData strategy) => strategy.id == id)
.firstOrNull;
final newStrat = Hive.box<StrategyData>(
HiveBoxNames.strategiesBox,
).values.where((StrategyData strategy) => strategy.id == id).firstOrNull;

if (newStrat == null) {
if (mounted) setState(() => _isCapturingScreenshot = false);
Expand Down Expand Up @@ -206,10 +202,7 @@ class _EditorToolbarState extends ConsumerState<EditorToolbar> {
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(
Expand Down Expand Up @@ -237,7 +230,7 @@ class _EditorToolbarState extends ConsumerState<EditorToolbar> {
}
}

/// 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.
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading