# Handoff: SoftLAN Launcher redesign A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog. --- ## About the design files The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**. The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for: - Exact pixel/spacing/color/typography values - Component composition and interactions - Copy and microcopy - Animation easings/durations But: - Don't ship the Babel-in-browser setup or import the .jsx files as-is - Don't keep the `` / design-canvas wrapping — that's only for presenting variants - Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below) - Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.) ## Fidelity **High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical). ## Layout variants The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only. --- ## Changes since v2 - **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows): - **Left:** brand mark + wordmark. - **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right. - **Right:** game-folder button + kebab menu. - Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet. - See "Top bar (variant A)" below for the full spec and rationale. - **Game-directory button redesigned.** No more inline path display. The button is now an icon + short label + status dot, with the full path moved to the tooltip. Two states: - **Set & valid:** label `Game folder`, green status dot, tooltip = full configured path. - **Not set / invalid path:** label `Set game folder`, red status dot, subtle red border, slightly red-tinted hover, tooltip = `Please select a game folder`. "Invalid" (a path is stored but doesn't exist on disk) is treated identically to "not set" — no separate warning state for now. - Click behavior unchanged — opens the native folder picker via Tauri. - See "Game-folder button" below for the full spec. ## Changes since v1 - **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys. - **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec. - Grid cards are **unchanged** — Start Server only ever appears in the detail overlay. --- ## Screens / views ### 1. Main library (variant A — primary) The default screen. A grid of game cards over a dark, gradient-tinted background. **Layout (top-to-bottom):** 1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other. - **Left zone (col 1, flex space-between):** - **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`. - **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons: - `All Games` · count chip - `Local` · count chip - `Installed` · count chip Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`. `Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network. The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder. - **Center zone (col 2, search alone):** - **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search. - **Right zone (col 3, flex space-between):** - **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: ` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. - **Game-folder button** (pinned right of itself, before the kebab) — see "Game-folder button" below. - **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. **Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → game-folder → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet. 2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between: - Left: `Showing N of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`). - Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: ` 78 GB installed 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`. 3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically. - Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20. **Game card** (see "Game card" below for full anatomy). --- ### 2. Game detail overlay Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card). **Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered. **Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`. **Modal structure (top-to-bottom):** 1. **Hero banner** — `aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads. - **State chip** in the top-left of the hero (same chip style as on cards — see Game Card). - **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon. - **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`: - Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`) - **Title** as `

` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art. 2. **Body** — 22px top, 26px bottom, 28px horizontal: - **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded). - **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`. - **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right: 1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state). 2. **Start Server** — *only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub). 3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. 4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling). 5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling). 6. Spacer (`flex: 1`). 7. Ghost-button **View files** (neutral) — opens system file manager at the game folder. #### Start Server button A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention. - Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`. - Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`. - **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`. - Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`). - A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn. The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards. --- ### 3. Settings dialog Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences. **Structure:** ``` ┌─────────────────────────────────────────┐ │ Settings [×] │ ← head: 22 28 18, 1px bottom border ├─────────────────────────────────────────┤ │ │ │ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3 │ │ │ Username │ ← row label: 14px / 600 / --t-1 │ Shown to other players on the LAN │ ← row hint: 12px / --t-3 │ [ Enter a username ] │ ← text input (220×36) │ │ │ Language │ │ Interface language │ │ [English│Deutsch] │ ← segmented radio (new) │ │ │ APPEARANCE │ │ │ │ Accent color │ │ Used for primary actions and highlights │ │ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned │ │ │ Background │ │ Backdrop behind the library │ │ [Flat │Gradient│Animat]│ ← segmented radio │ │ │ LIBRARY │ │ │ │ Grid density │ │ How tightly cards are packed │ │ [Compact│Normal│Large]│ │ │ │ Cover aspect │ │ Shape of the cover art on each card │ │ [Box-art│Square│Banner│ │ │ ├─────────────────────────────────────────┤ │ [Done] │ ← foot: 14 22 18, 1px top border └─────────────────────────────────────────┘ ``` **Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap): - Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`) - Right (`settings-row-control`): the control **Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings. - **Username** — `` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`. - **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios. **Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px ` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`. Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`. **Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`. **Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog. Persisted settings (write through to local storage / Tauri config): - `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does. - `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`. - `accent`: one of the six hex values above. Default `#3b82f6`. - `bg`: `flat` | `gradient` | `animated`. Default `gradient`. - `density`: `compact` | `normal` | `large`. Default `normal`. - `aspect`: `box` | `square` | `banner`. Default `box`. --- ## Game-folder button The top-bar control that exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live). Sits in the right zone of the top bar, between the sort menu and the kebab menu. Click anywhere on it → native folder picker via Tauri; on selection, rescan library. Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory: | State | Trigger | Label | Status dot | Border | Tooltip | |---|---|---|---|---|---| | **Set & valid** | path is configured and exists on disk | `Game folder` | green (`--ok` `#22c55e`) | default `--bd-1` | full configured path | | **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Set game folder` | red (`--danger` `#ef4444`) | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) | `Please select a game folder` | "Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple. **Anatomy:** `inline-flex`, `height: 36px`, `padding: 0 14px 0 12px`, `gap: 8px`. `background: var(--bg-2)`, `border-radius: 8px`. Children, left to right: 1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `currentColor`. 2. **Label** — 12.5 px / 600, `var(--t-1)`, `white-space: nowrap`, `line-height: 1`. Text content depends on state (see table above). 3. **Status dot** — `8×8` px circle, `border-radius: 999px`, `margin-left: 2px`. Background `var(--ok)` (set) or `var(--danger)` (unset). Glow via `box-shadow: 0 0 6px color-mix(in srgb, 70%, transparent)`. The dot is **inline** (a flex sibling next to the label), not corner-pinned over the icon — because the button is now wider than tall, a corner badge would feel misplaced. **Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` with a faint `color-mix(in srgb, var(--danger) 8%, var(--bg-2))` background tint (unset state) so the bad-state hover reads as "this is the thing you need to fix". **Accessibility:** the `aria-label` carries the full state in words — `"Game folder: "` when set, `"Set game folder"` when unset — so screen readers don't have to interpret the colored dot. **Why no inline path anymore?** The previous design squeezed the full path into the button as truncated monospace (`…/eti_games_AFTER_LAN_2025`). It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. The information is still one mouseover away in the tooltip; on the surface, the dot + short label communicate the *state* of the configuration, which is what users actually need to glance at. **Data:** the component takes a `path: string | null` prop. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. In production, derive `path` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing. **Dev preview:** the prototype's Tweaks panel exposes a `Game folder set` toggle (under the *Library* section) that flips the `gameFolderSet` flag wired into the Launcher. This is dev-only — in the real app the state comes from the settings store, **not** from a user-facing toggle. Don't ship it. --- ## Game card The unit element of the library grid. **Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer. **Hover/focus state:** - `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`) - `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))` - Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)` - Cover inner image scales to 1.03 (350ms cubic-bezier) - Focus-visible: same lift + 2px solid accent outline ### Anatomy (top to bottom) 1. **Cover wrap** — `width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`. 2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`): - **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata. - **Radial accent blob** — `radial-gradient(ellipse at % %, 38, transparent 55%)`. x/y also hashed from id. - **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7. - **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash. - **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px , 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants). - **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2). - **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px `. - **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `2–32`). Always visible — every LAN game is multiplayer. 3. **Card body** — `padding: 11px 12px 12px`, flex column, 8px gap: - **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow. - **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity. - **Action button** (full width) — primary action depending on state, see below. ### Action button A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance. ``` state label button style ───────────── ────────── ──────────────────────────────────────────── not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1) local Install bg var(--accent), text white, inset top hl installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl downloading — progress see "Download progress" below — the button slot is replaced with a live progress component ``` Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12). Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`. **Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons). --- ## Download progress (state === 'downloading') When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `` child. Two layouts share the same primitive: ### Shared visuals - Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below). - **Progress fill** (`.dl-fill`): absolutely positioned, `width: %`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 38–26%, transparent)`. Right edge gets a 1px accent rule + accent glow. - **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting. - **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active. - **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over. ### Card layout (`.dl-md`, replaces the 32px action button) A single row. Two values, separated by `justify-content: space-between`: - **Left:** ` %` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`. - **Right:** `` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale). Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`). **Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo: ```css @container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } } @container (max-width: 96px) { .dl-md .dl-pulse { display: none; } } ``` At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly. The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar. ### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button) Fixed 56px height. CSS-grid with three columns and two rows: ``` grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "primary pct cancel" "secondary pct cancel"; ``` - **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component. - **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity): 1. `11.4 GB / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest) 2. `47.6 MB/s` (`var(--t-1)`) 3. `[users-icon] 5` — `.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip. 4. `8 min left` (`var(--t-2)`) - **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity. - **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior. **Graceful degradation in narrow modals:** ```css @container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } } @container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } } ``` ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses. ### Number formatting All helpers live in `data.jsx`: - `fmtSpeed(mbps)` — `49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`. - `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact. - `fmtBytes(gb)` — `<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`). - `fmtEta(seconds)` — `< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`. Keep these formats; they're tuned so the secondary row never wraps at normal modal width. ### Data shape The `Game` type gains a `downloading` state plus two transient fields: ```ts type Game = { // … existing fields … state: 'installed' | 'local' | 'downloading' | 'none'; progress?: number; // 0–1, only when state === 'downloading' speed?: number; // current throughput in MB/s peers?: number; // number of LAN peers currently seeding }; ``` In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else. Filter changes: - `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them). - Sort by `state` orders `installed < local < downloading < none`. ### State chip Add a fourth entry to `STATE_META`: ```js downloading: { label: 'Downloading', dot: 'var(--accent)' } ``` Dot uses the live accent so it visually ties to the progress fill. --- ## Filter controls — variant B (not used, kept for reference) The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs: - Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active. - Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`. - Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier). Implement only if you decide variant A doesn't work after building. --- ## Interactions & behavior - **Click game card** (anywhere except the action button) → open detail overlay. - **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button. - **Press / (slash)** → focus the search input. - **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring). - **Click filter tab / segmented pill** → change filter. - **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it. - **Hover game card** → lift + accent border glow + cover image scale 1.03. - **Click "Game folder" button** → open native folder picker via Tauri; on selection, rescan library. The button itself indicates whether a valid folder is currently configured (green dot + `Game folder`) or not (red dot + `Set game folder`) — see "Game-folder button" above. - **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes). - **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design). - **Click "Refresh library"** in kebab → re-runs the library scan. - **Esc** → closes any open modal (detail overlay, Settings). ### Transitions / animations - Card hover: `180ms cubic-bezier(.4,1.2,.5,1)` on transform/border, `350ms cubic-bezier(.4,1.2,.5,1)` on cover scale. - Modal fade-in: scrim `opacity 0 → 1` over 180ms ease; modal `transform: scale(.96) translateY(8px) → scale(1) translateY(0)` and opacity over 250ms `cubic-bezier(.3,1.3,.4,1)`. - Segmented filter thumb: `220ms cubic-bezier(.4,1.2,.5,1)` on `left` and `width`. - Underline tab indicator (variant B): `200ms` on opacity, `250ms cubic-bezier(.4,1.2,.5,1)` on `transform: scaleX`. - Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients. --- ## State management Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations. **Library state** (rebuilt on `refresh`): ```ts type Game = { id: string; title: string; size: number; // GB version: string; // "YYYY.MM.DD" desc: string; state: 'installed' | 'local' | 'downloading' | 'none'; progress?: number; // 0–1 — present only when state === 'downloading' speed?: number; // MB/s — present only when state === 'downloading' peers?: number; // LAN peers currently seeding players: string; // e.g. "2–32" tags: string[]; cover: { c1: string; c2: string; accent: string; mood?: string }; canHostServer?: boolean; // true if the game ships with a dedicated-server binary }; ``` **Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id. **UI state:** ```ts type LauncherUI = { filter: 'all' | 'local' | 'installed'; sort: 'az' | 'size' | 'recent' | 'state'; query: string; openGameId: string | null; settingsOpen: boolean; }; ``` **Persisted settings** (mirror of Settings dialog state): ```ts type LauncherSettings = { username: string; // new language: 'en' | 'de'; // new accent: string; // hex from the curated 6-color palette bg: 'flat' | 'gradient' | 'animated'; density: 'compact' | 'normal' | 'large'; aspect: 'box' | 'square' | 'banner'; }; ``` Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button). **Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri. --- ## Design tokens ### Color | token | value | usage | |---|---|---| | `--bg-0` | `#0a0e13` | launcher background | | `--bg-1` | `#0f151c` | card bottom gradient stop | | `--bg-2` | `#131b25` | top bar / card top / search bg | | `--bg-3` | `#1a2330` | settings segmented bg / cover fallback | | `--bg-4` | `#232f3e` | (reserved) | | `--bd-1` | `rgba(255,255,255,0.06)` | subtle border | | `--bd-2` | `rgba(255,255,255,0.10)` | stronger border | | `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb | | `--t-1` | `#e6edf3` | primary text | | `--t-2` | `#9aa6b4` | secondary text | | `--t-3` | `#6b7785` | muted text / metadata | | `--t-4` | `#4a5663` | (reserved) | | `--ok` | `#22c55e` | "installed" dot | | `--warn` | `#f59e0b` | "local" dot | | `--danger` | `#ef4444` | destructive actions | | `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark | ### Typography - **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif` - **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif` - **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay) Sizing reference: - Brand wordmark: 15 / 700 - Modal title: 32 / 700 / -0.015em - Card title: 13.5 / 600 - Filter pill label: 12.5 / 600 - Settings row label: 14 / 600 - Section title (settings): 10.5 / 700 / 0.12em uppercase - Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3` - Hint text: 12 / `--t-3` ### Spacing & radii - Card radius: 10px - Modal radius: 14px - Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button) - Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28 - Card body padding: 11 12 12 ### Shadows - Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)` - Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)` - Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)` - Action button (filled): `0 6px 16px -8px , inset 0 1px 0 rgba(255,255,255,0.22)` --- ## Assets Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design. In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`. The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button. Fonts to load: ```html ``` --- ## File reference ``` design_reference/ ├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts ├── styles.css ← all visual styles (CSS custom props + components) ├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock ├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard, │ SegmentedFilters, UnderlineFilters, SearchField, │ SortMenu, StorageMeter, DirectoryButton, KebabMenu, │ GameDetailModal, SettingsDialog └── launcher.jsx ← component composing chrome + grid + modals ``` To preview the design in a browser: 1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder). 2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen. - **A / B** — chrome variants (A is the chosen direction) - **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall** - **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk** - **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel** - **F** — Settings dialog open, with the new **Profile** section at the top 3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect). In the production app these live in the Settings dialog. --- ## Out of scope / open questions for the developer - **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice. - **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game. - **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast. - **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready. - **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal. - **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows. - **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.