# 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 v4 - **Call to Play added.** A whole new coordination layer on top of the library: rally the LAN around a game and a time. Two flavors — **Play now** (give people N minutes to ready up) and **Scheduled** ("Among Us at 20:00", collect "I'm in" RSVPs, then a **check-in window** opens 15 min before start). Every call carries a small group **chat**. Surfaced in three places: a **Call to Play button** in the top bar (with an active-call badge), a persistent stack of **quick bars** above the grid, and a full **overlay** with per-call cards and a create form. Full spec in the new **"Call to Play"** section below. New source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN **peer roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`, `clock`, `caretUp`, `caretDown`. ## Changes since v3 - **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B). - **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below. - **Persisted setting renamed.** `gameFolderSet: boolean` → `gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans). ## 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:** kebab menu (game-folder configuration has moved into Settings — see v3 changes). - 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. ## 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 with two sub-groups):** - **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`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search. - **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings. - **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab. **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 → 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 │ │ │ │ Game folder │ │ Parent directory where games are │ │ downloaded and installed │ │ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36) │ │ │ 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. **Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**. - **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference. **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`. - `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below. --- ## Game-folder field A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live). **Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs. Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory: | State | Trigger | Path display | Border | Button label | |---|---|---|---|---| | **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) | | **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) | "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`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right: 1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state). 2. **Path display** — `flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead. 3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library. **Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps). **Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated. **Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. 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. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring. **Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` 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` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel. --- ## 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. --- ## Call to Play A coordination layer that sits on top of the library: **rally the people on the LAN around a specific game at a specific time.** It answers the actual social question at a LAN party ("what are we all playing, and when?") that a plain library grid can't. Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared `ctpAvatarColor` / `ctpFmtClock` helpers). Styles are the `ctp-*` classes in `styles.css`. Backed by the mock LAN **peer roster** `PEERS` in `data.jsx`. ### Two flavors of call | | **Play now** | **Scheduled** | |---|---|---| | Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) | | Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later | | Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window | | Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in | **Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the `scheduled` phase collecting RSVPs until 15 minutes before its start time, then flips into the `checkin` phase: it lights up everywhere and everyone who said "I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as a play-now call. A play-now call is effectively "always in its check-in window." **Every call carries a small group chat** (see "Per-call chat" below). ### Three surfaces 1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label + an accent badge counting active (non-`started`) calls. Opens the overlay. 2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent stack rendered at the top of the grid area, **one row per active call**. Sorted **ready → starting-soon → the rest**, ties broken by whichever resolves/starts soonest (`TICKER_RANK` = ready 0, soon 1, call/scheduled 2; then by `deadline`). Clicking a row opens the overlay focused on that call. 3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as the other dialogs) with a header, a **Call a new match** button, the create form, and a list of **nomination cards** (started calls sink to the bottom). ### Status model Two derived values drive everything (`calltoplay.jsx`): - `phaseOf(call)` → `'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) · `'checkin'` (within the 15-min lead). - `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'` (`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call). Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via `.ctp-ticker-dot[data-status]`. **Per-participant ready state:** `ready` (explicitly readied, or their `readyAt` countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in yet) · `pending` (checked in with a `+N minutes` buffer, counting down). Shown as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials + green ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and as larger `AvatarChip`s in the nomination card roster. ### Nomination card (`NominationCard`) Top to bottom: 1. **Header** — square game cover + title + a sub-line: `Called by · N/M peers have it installed` (or `Scheduled by · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, or `Launching…`. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low. 2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd). 3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase. 4. **Roster** — `readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`. 5. **Actions** — context-dependent on your role (**creator** / **participant** / **outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons, `I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a status note. 6. **Chat** — the collapsible per-call chat panel. 7. **Cancel** — creators get a `Cancel this call` link with an inline confirm. ### Create form (`CreateNominationForm`) Game search (typeahead over the catalog) → on pick, max-players defaults to the game's parsed player cap (`parseMaxPlayers`). **When** toggles `Now` vs `Schedule`. `Now` reveals the **Give people** duration chips. `Schedule` reveals a **24-hour time picker** (hour/minute steppers **and** a "Type a time" free-text field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today + next two days). The confirm button reads `Call it — ` or `Schedule it — ·