Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
07c4587
Pull DESIGN.md and AGENTS.md guidance from main
SunkenInTime Sep 12, 2026
c9d95b2
Add isolated Hive store launch flag
SunkenInTime Sep 4, 2026
f37be8f
Fix duplicate Windows instance shutdown
SunkenInTime Sep 4, 2026
d24b6c2
Fold cloud sync state into the editor save button and move the app to…
SunkenInTime Sep 12, 2026
20616b0
Finish the Lucide sweep in the color picker, expansion tile, demo tag…
SunkenInTime Sep 12, 2026
b911182
Document the editor toolbar and icon rules in DESIGN.md
SunkenInTime Sep 12, 2026
2c63d3e
Focus the rename and folder dialogs on open and submit on Enter
SunkenInTime Sep 12, 2026
946ee3b
Format the sync button test
SunkenInTime Sep 12, 2026
1acfe8a
Rest toolbar glyphs on full foreground and size the cloud glyph to it…
SunkenInTime Sep 12, 2026
1a05db6
Draw editor toolbar glyphs at the 300 stroke weight
SunkenInTime Sep 12, 2026
35a0a2a
Give the editor the library's 40px strip and stop moving the traffic …
SunkenInTime Sep 12, 2026
16e210c
Keep an 8px gap between the editor strip and the floating panels
SunkenInTime Sep 12, 2026
32ad099
Use the redesigned folder card for cloud folders too
SunkenInTime Sep 12, 2026
4144062
Put the library breadcrumb on a card
SunkenInTime Sep 12, 2026
c01b3cc
Summarise cloud folders on the server so their cards show maps, agent…
SunkenInTime Sep 12, 2026
2c3f86c
Space the library tabs so their backgrounds never touch
SunkenInTime Sep 12, 2026
b3bc45b
Tighten the library tabs' inner padding and icon gap
SunkenInTime Sep 12, 2026
2767ac8
Drop the seam line under the window strip
SunkenInTime Sep 12, 2026
c6eab17
Run the editor canvas up to the strip so no bare band shows under it
SunkenInTime Sep 12, 2026
e3706e7
Keep the strip seam and hang the delete area off it like the map card
SunkenInTime Sep 12, 2026
4fb9b01
Show the Icarus wordmark in the strip on every desktop platform
SunkenInTime Sep 12, 2026
546aa39
Add an inset shadow decoration and the raised surface recipe
SunkenInTime Sep 13, 2026
bb61e5d
Quiet the editor toolbar and square up the map card
SunkenInTime Sep 13, 2026
017a2ec
Raise every selected state and rebuild the menus that sat outside the…
SunkenInTime Sep 13, 2026
a737cc9
Clear the analyzer so CI's analyze step passes
SunkenInTime Sep 13, 2026
ba8bcc6
Let the sync popover's actions wrap
SunkenInTime Sep 13, 2026
f36e02b
Run the library strip semantics tests at a desktop width
SunkenInTime Sep 13, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ local mode - the shipped behavior: signed out, library on disk, exactly what mai
cloud mode - signed in, library synced to Convex.
op - one queued change to cloud data. an op lands when the server accepts it.

The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md defines how the app must look and how we build UI, read it before touching UI. PRODUCT.md holds who this is for and how it must feel.
The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md holds the rules for how we build UI (the values themselves live in `lib/const/settings.dart`), read it before touching UI. PRODUCT.md holds who this is for and how it must feel.

Here's the philosophy we work by:

Expand Down
163 changes: 18 additions & 145 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,165 +1,38 @@
---
name: Icarus
description: A refined tactical desktop workspace for Valorant strategy planning.
colors:
tactical-background: "#09090b"
tactical-sidebar: "#141114"
tactical-card: "#18181b"
tactical-panel: "#1b1b1b"
tactical-raised: "#27272a"
tactical-border: "#27272a"
tactical-scrollbar: "#353435"
tactical-foreground: "#fafafa"
tactical-muted: "#a1a1aa"
tactical-primary: "#7c3aed"
tactical-primary-deep: "#4c1d95"
tactical-primary-foreground: "#f9fafb"
tactical-danger: "#ef4444"
tactical-favorite: "#ff9800"
tactical-favorite-danger: "#e53935"
tactical-ally: "#3a7e5d"
tactical-ally-outline: "#69f0af6a"
tactical-enemy: "#772727"
tactical-enemy-outline: "#ff52528b"
map-base: "#271406"
map-detail: "#b27c40"
map-highlight: "#f08234"
typography:
headline:
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "20px"
fontWeight: 500
lineHeight: 1.2
letterSpacing: "normal"
title:
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "16px"
fontWeight: 600
lineHeight: 1.25
letterSpacing: "normal"
body:
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "14px"
fontWeight: 400
lineHeight: 1.35
letterSpacing: "normal"
label:
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "12px"
fontWeight: 600
lineHeight: 1.2
letterSpacing: "0.3px"
micro:
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
fontSize: "10px"
fontWeight: 600
lineHeight: 1.2
letterSpacing: "0.5px"
rounded:
xs: "3px"
sm: "4px"
md: "6px"
lg: "8px"
xl: "10px"
panel: "12px"
card: "16px"
dialog: "22px"
pill: "22px"
spacing:
xxs: "2px"
xs: "4px"
sm: "6px"
md: "8px"
lg: "10px"
xl: "12px"
section: "16px"
panel: "24px"
grid-gap: "20px"
components:
button-primary:
backgroundColor: "{colors.tactical-primary}"
textColor: "{colors.tactical-primary-foreground}"
rounded: "{rounded.lg}"
padding: "8px 14px"
button-secondary:
backgroundColor: "{colors.tactical-raised}"
textColor: "{colors.tactical-foreground}"
rounded: "{rounded.lg}"
padding: "8px 14px"
icon-button:
backgroundColor: "{colors.tactical-raised}"
textColor: "{colors.tactical-foreground}"
rounded: "{rounded.lg}"
size: "40px"
tool-button-selected:
backgroundColor: "{colors.tactical-primary}"
textColor: "{colors.tactical-primary-foreground}"
rounded: "{rounded.lg}"
size: "57.8px"
search-field:
backgroundColor: "{colors.tactical-card}"
textColor: "{colors.tactical-foreground}"
rounded: "{rounded.lg}"
height: "40px"
segmented-tabs:
backgroundColor: "{colors.tactical-raised}"
textColor: "{colors.tactical-muted}"
rounded: "{rounded.md}"
padding: "2px"
strategy-card:
backgroundColor: "{colors.tactical-card}"
textColor: "{colors.tactical-foreground}"
rounded: "{rounded.card}"
padding: "8px"
sidebar-panel:
backgroundColor: "{colors.tactical-card}"
textColor: "{colors.tactical-foreground}"
rounded: "{rounded.panel}"
width: "345px"
title-strip:
backgroundColor: "{colors.tactical-card}"
textColor: "{colors.tactical-foreground}"
height: "40px"
controlHeight: "28px"
folder-card:
backgroundColor: "{colors.tactical-card}"
textColor: "{colors.tactical-foreground}"
rounded: "{rounded.panel}"
size: "232px x 64px"
---

# Design: Icarus

Icarus is a tactical workbench: dark, dense, map-first. The canvas and the tactical objects on it stay visually dominant; everything else is hardware around the bench. Polish comes from order, not ornament.

The palette, theme, and sizing constants live in `lib/const/settings.dart`, with `Settings.tacticalVioletTheme` as the ShadColorScheme. That file is the only source of truth for values. This file holds the rules for using them.

## Building UI

- shadcn_ui is the component library. Reach for `Shad*` widgets first — `ShadDialog`, `ShadButton`, `ShadIconButton`, `ShadInput`, `ShadSelect`, `ShadTooltip`, `ShadContextMenu*`, `ShadPopover` — before Material equivalents or custom widgets. Read theme values through `ShadTheme.of(context)`.
- `ShadDialog` does not provide a `Material` ancestor. Material-dependent children (`TextField`, `InkWell`, `LinearProgressIndicator`, `Slider`) throw "No Material widget found" inside one. Wrap the dialog's `child` in `Material(color: Colors.transparent, child: ...)` — see `lib/widgets/dialogs/export_video_dialog.dart` for the idiom.
- Color, theme, and sizing constants live in `lib/const/settings.dart` (including the `tacticalVioletTheme` ShadColorScheme). Use them; never hardcode a hex that already has a name. The frontmatter above mirrors these values for reference.
- Spacing uses the 8/10/12/16/24px steps from the frontmatter. Radii: 8px controls, 12px panels, 16px cards, 22px dialogs.
- shadcn_ui is the component library. Reach for `Shad*` widgets first (`ShadDialog`, `ShadButton`, `ShadIconButton`, `ShadInput`, `ShadSelect`, `ShadTooltip`, `ShadContextMenu*`, `ShadPopover`) before Material equivalents or custom widgets. Read theme values through `ShadTheme.of(context)`.
- `ShadDialog` does not provide a `Material` ancestor. Material-dependent children (`TextField`, `InkWell`, `LinearProgressIndicator`, `Slider`) throw "No Material widget found" inside one. Wrap the dialog's `child` in `Material(color: Colors.transparent, child: ...)`; see `lib/widgets/dialogs/export_video_dialog.dart` for the idiom.
- Never hardcode a hex that already has a name in `lib/const/settings.dart`. If a color is new, name it there first.
- Spacing steps are 8/10/12/16/24px. Radii: 8px controls, 12px panels, 16px cards, 22px dialogs.
- Type roles, all in the system sans stack: headline 20px/500, title 16px/600, body 14px/400, label 12px/600, micro 10px/600. Hierarchy comes from these five roles, not from display fonts or hero-scale type.
- Transitions run 150-250ms and must communicate a state change (hover, selection, reveal, loading). No motion for its own sake.

## Window chrome

- Desktop builds hide the native title bar. Each top-level screen draws its own 40px strip (`lib/widgets/window_chrome.dart`): macOS keeps its traffic lights, so the strip leaves a 78px inset on the left; Windows and Linux get app-drawn caption buttons on the right; the strip is the drag handle. Web renders the same strip with no inset and no buttons.
- The library strip holds the three tabs on the left and only search, sort, New, and the account on the right. 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`). For a cloud strategy the save button's glyph is the sync state; the words live in its tooltip and a popover opens only when sync needs attention. No status chips or labels in the editor.

## Named rules
## Icons

**The One Command Color Rule.** Violet marks current action, selection, focus, and primary commands — nothing else. If violet appears somewhere that isn't actionable or active, it's wrong.
- 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.

**The Tactical Semantics Rule.** Ally green, enemy red, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis.
## Things I would like to remain consistent

**The Tonal First Rule.** 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 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 Native Tool Rule.** System sans stack for everything. Hierarchy comes from the five frontmatter type roles (headline/title/body/label/micro), not from display fonts or hero-scale type.
**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.

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

## Don't
**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.

- No gradients, glow, glassmorphism, or decorative effects — the anti-reference is the generic gamer overlay.
- No marketing-page composition inside the product: no hero typography, no decorative dashboards.
- No colored side-stripe borders, gradient text, or nested cards.
- No custom affordance where a standard Shad or desktop pattern already communicates the action.
## Some general rules
These steer us in the right direction. They are not hard-set, but default to following them; if you think one should be ignored, be very loud about it and get approval from us first.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,31 @@ This rewrites the current user's `icarus://` handler to the debug executable.
After testing OAuth, launch the installed Icarus app once to restore the handler
back to the installed build.

### Isolated Hive store

Desktop builds accept an absolute `--hive-store-dir` path. It moves every
Hive box, including the library, preferences, and anonymous analytics, without
touching the normal Hive files.

Pass the option through Flutter with one Dart entrypoint argument:

```bash
fvm flutter run -d macos -a "--hive-store-dir=/Users/your-name/Library/Containers/xyz.icarus-strats/Data/Library/Application Support/xyz.icarus-strats-demo"
```

The macOS app sandbox limits this path to Icarus's container unless the user
selects another directory through a native picker. The container lives at
`~/Library/Containers/xyz.icarus-strats/Data`.

Or pass it directly to a built executable:

```bash
./icarus --hive-store-dir "/absolute/path/to/icarus-demo-hive"
```

Strategy media, debug logs, and WebView data still use the normal application
support directory. Use a different absolute Hive directory for each instance.

## Build

```bash
Expand Down
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type * as lib_payloadValidators from "../lib/payloadValidators.js";
import type * as lib_publicValidators from "../lib/publicValidators.js";
import type * as lib_r2 from "../lib/r2.js";
import type * as lib_snapshotSerialization from "../lib/snapshotSerialization.js";
import type * as lib_strategyAgentSummary from "../lib/strategyAgentSummary.js";
import type * as lineups from "../lineups.js";
import type * as maintenance from "../maintenance.js";
import type * as ops from "../ops.js";
Expand Down Expand Up @@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{
"lib/publicValidators": typeof lib_publicValidators;
"lib/r2": typeof lib_r2;
"lib/snapshotSerialization": typeof lib_snapshotSerialization;
"lib/strategyAgentSummary": typeof lib_strategyAgentSummary;
lineups: typeof lineups;
maintenance: typeof maintenance;
ops: typeof ops;
Expand Down
105 changes: 105 additions & 0 deletions convex/folders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,108 @@ test("folder move rejects a descendant parent without changing the tree", async
await expect(parentPublicId(t, "child")).resolves.toBe("root");
await expect(parentPublicId(t, "grandchild")).resolves.toBe("child");
});

const listFolderTree = makeFunctionReference<"query">("folders:listTree");
const createStrategy = makeFunctionReference<"mutation">(
"strategies:createWithInitialPage",
);
const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch");
const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete");

async function seedStrategy(
owner: Harness,
publicId: string,
folderPublicId: string,
mapData: string,
) {
await owner.mutation(createStrategy, {
clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION,
publicId,
name: publicId,
mapData,
folderPublicId,
initialPagePublicId: `${publicId}-page`,
initialPageName: "Page 1",
initialPageIsAttack: true,
initialPageSettings: {
agentSize: 48,
abilitySize: 32,
useNeutralTeamColors: false,
},
});
}

async function placeAgent(
owner: Harness,
strategyPublicId: string,
elementPublicId: string,
agentType: string,
) {
await owner.mutation(applyBatch, {
clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION,
strategyPublicId,
clientId: "client-a",
ops: [
{
opId: `${elementPublicId}-add`,
type: "element.add",
elementPublicId,
pagePublicId: `${strategyPublicId}-page`,
payload: {
kind: "agent",
payloadVersion: 1,
data: { type: agentType, position: { x: 0.5, y: 0.5 } },
},
sortIndex: 0,
},
],
});
}

test("folder tree summarises strategies, maps, and agents across the subtree", async () => {
const { owner } = await createHarness();
await seedFolder(owner, "root");
await seedFolder(owner, "child", "root");
await seedStrategy(owner, "s-root", "root", "ascent");
await seedStrategy(owner, "s-child-1", "child", "haven");
await seedStrategy(owner, "s-child-2", "child", "haven");
await placeAgent(owner, "s-root", "e1", "jett");
await placeAgent(owner, "s-child-1", "e2", "sova");
await placeAgent(owner, "s-child-2", "e3", "sova");

const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array<
Record<string, unknown>
>;
const byId = new Map(tree.map((entry) => [entry.publicId, entry]));
expect(byId.get("child")).toMatchObject({
strategyCount: 2,
mapPeeks: ["haven"],
agentTypes: ["sova"],
});
expect(byId.get("root")).toMatchObject({
strategyCount: 3,
mapPeeks: ["haven", "ascent"],
agentTypes: ["sova", "jett"],
});
});

test("deleting a strategy drops it from the folder summary", async () => {
const { owner } = await createHarness();
await seedFolder(owner, "root");
await seedStrategy(owner, "s-root", "root", "ascent");
await placeAgent(owner, "s-root", "e1", "jett");
await owner.mutation(deleteStrategy, {
clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION,
strategyPublicId: "s-root",
expectedRevision: 0,
});

const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array<
Record<string, unknown>
>;
expect(tree[0]).toMatchObject({
strategyCount: 0,
mapPeeks: [],
agentTypes: [],
});
});
Loading
Loading