From 18dd3b7e073c36678f55d07191f9c6bfc18b0b11 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 9 Aug 2026 16:16:45 +0200 Subject: [PATCH] markdown formatting --- CLAUDE.md | 30 +- crates/lanspread-peer-cli/README.md | 13 +- crates/lanspread-peer/ARCHITECTURE.md | 69 +- crates/lanspread-peer/README.md | 64 +- design/README.md | 29 +- design/launcher/SPEC.md | 1007 ++++++++++++----- design/logo/INTEGRATION.md | 113 +- justfile | 2 + organize/decision-tracking/IMPL_DECISIONS.md | 7 +- organize/planning/PEER_AUTH_PLAN.md | 298 +++-- organize/testing/PEER_CLI_SCENARIOS.md | 297 ++--- organize/unsorted/BACKLOG.md | 26 +- organize/unsorted/CALL_TO_PLAY_FIXES_PLAN.md | 145 ++- .../CALL_TO_PLAY_REVIEW_FABLE_5_XHIGH.md | 118 +- .../CALL_TO_PLAY_REVIEW_GEMINI_3.6_FLASH.md | 100 +- .../unsorted/CALL_TO_PLAY_REVIEW_KIMI_K3.md | 111 +- organize/unsorted/CLEAN_CODE.md | 7 +- organize/unsorted/CLEAN_CODE_PLAN_1.md | 8 +- organize/unsorted/FABLE_5_FINDINGS.md | 18 +- organize/unsorted/FINDINGS.md | 29 +- organize/unsorted/FINDINGS_AUSWERTUNG_SOL.md | 32 +- 21 files changed, 1630 insertions(+), 893 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87bd422..12c47db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,21 +1,27 @@ # lanspread -Peer-to-peer game library sharing for LAN parties. Peers discover each other on the local network via mDNS, exchange library metadata over QUIC, and let users browse and download games from each other. Ships as a Tauri desktop app. +Peer-to-peer game library sharing for LAN parties. Peers discover each other on +the local network via mDNS, exchange library metadata over QUIC, and let users +browse and download games from each other. Ships as a Tauri desktop app. ## Workspace layout Cargo workspace under `crates/`: -- `lanspread-peer` — core peer: networking, library, downloads, services. Start here for behavior changes. See `crates/lanspread-peer/ARCHITECTURE.md`. +- `lanspread-peer` — core peer: networking, library, downloads, services. Start + here for behavior changes. See `crates/lanspread-peer/ARCHITECTURE.md`. - `lanspread-proto` — wire protocol types shared across peers. - `lanspread-mdns` — mDNS-SD discovery wrapper. - `lanspread-db` — database/schema types (sqlx + sqlite). - `lanspread-compat` — compatibility/migration glue between db and other crates. - `lanspread-utils` — small shared helpers. -- `lanspread-peer-cli` — JSONL peer harness for scripted and containerized tests. -- `lanspread-tauri-deno-ts/` — frontend (Vite + Deno + TS in `src/`) and Tauri shell (`src-tauri/`). This is the GUI client. +- `lanspread-peer-cli` — JSONL peer harness for scripted and containerized + tests. +- `lanspread-tauri-deno-ts/` — frontend (Vite + Deno + TS in `src/`) and Tauri + shell (`src-tauri/`). This is the GUI client. -Top-level `Cargo.toml` pins workspace dependency versions; per-crate `Cargo.toml`s set lints (pedantic clippy, `unsafe_code = forbid` on most). +Top-level `Cargo.toml` pins workspace dependency versions; per-crate +`Cargo.toml`s set lints (pedantic clippy, `unsafe_code = forbid` on most). ## Commands (justfile) @@ -31,16 +37,22 @@ Never use normal cargo ... commands, use the just ... commands instead. - `just clean` — wipe the build cache. - `just peer-cli-build` — build the scripted peer harness. - `just peer-cli-image` — build the peer harness Docker image. -- `just peer-cli-run NAME` — run one named harness container with persistent state under `.lanspread-peer-cli/NAME/`. +- `just peer-cli-run NAME` — run one named harness container with persistent + state under `.lanspread-peer-cli/NAME/`. ## Protocol policy -There is only one wire version — the current one. No legacy peers, no compatibility shims, no fallback paths for older builds. Anyone who wants to interop must run the current build; everyone else is out. Do not add backward-compat code, `#[serde(other)]` escape hatches, or "what if an old peer sends X" defenses. +There is only one wire version — the current one. No legacy peers, no +compatibility shims, no fallback paths for older builds. Anyone who wants to +interop must run the current build; everyone else is out. Do not add +backward-compat code, `#[serde(other)]` escape hatches, or "what if an old peer +sends X" defenses. ## Manual CLI testing (docker container) -Start `just peer-cli-alpha`, `just peer-cli-bravo` and `just peer-cli-charlie` each in its own terminal. You then have 3 peers that you can interact with via stdin/stdout (JSONL). -Use this setup to manually test peer functionality. +Start `just peer-cli-alpha`, `just peer-cli-bravo` and `just peer-cli-charlie` +each in its own terminal. You then have 3 peers that you can interact with via +stdin/stdout (JSONL). Use this setup to manually test peer functionality. ## General info diff --git a/crates/lanspread-peer-cli/README.md b/crates/lanspread-peer-cli/README.md index f67e0f2..e7de385 100644 --- a/crates/lanspread-peer-cli/README.md +++ b/crates/lanspread-peer-cli/README.md @@ -17,13 +17,14 @@ Useful flags: - `--games-dir PATH` stores local archives and installs. - `--state-dir PATH` stores the generated peer identity. -- `--fixture GAME_ID` seeds a tiny archive that the fixture unpacker can install. +- `--fixture GAME_ID` seeds a tiny archive that the fixture unpacker can + install. ## Fixture Game Directories `fixtures/fixture-alpha`, `fixtures/fixture-bravo`, and -`fixtures/fixture-charlie` are ready-to-use game directories for local CLI -smoke tests. Point `--games-dir` at one of them to start a peer with several +`fixtures/fixture-charlie` are ready-to-use game directories for local CLI smoke +tests. Point `--games-dir` at one of them to start a peer with several catalog-backed fake games. Each game includes `version.ini` and a real RAR archive renamed to `.eti`; `fixture-alpha` and `fixture-bravo` share `ggoo`, while `fixture-bravo` and `fixture-charlie` share `cnc4`. @@ -44,6 +45,6 @@ echoed back on the result or error line. {"id":"q1","cmd":"shutdown"} ``` -The `status` result includes receiver-side `active_operations` and -sender-side `active_outbound_transfers` counts by game ID, which the scenario -runner uses to verify transfer lifecycle cleanup. +The `status` result includes receiver-side `active_operations` and sender-side +`active_outbound_transfers` counts by game ID, which the scenario runner uses to +verify transfer lifecycle cleanup. diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index c8debf5..0d03fb9 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -1,8 +1,8 @@ # lanspread-peer proposed protocol and architecture -This document proposes a tighter, more fault-tolerant protocol while keeping -the current idea: mDNS discovery, QUIC transport, on-demand metadata, and -chunked file transfers. +This document proposes a tighter, more fault-tolerant protocol while keeping the +current idea: mDNS discovery, QUIC transport, on-demand metadata, and chunked +file transfers. ## Goals (unchanged) @@ -26,14 +26,15 @@ chunked file transfers. When a peer is discovered: -1. Connect and send `Hello { peer_id, proto_ver, listen_addr, library_rev, - library_digest, features }`. `listen_addr` is mandatory; the QUIC source port - is only a temporary transport port and must not be recorded as the peer's - listener. -2. Receive `HelloAck { peer_id, proto_ver, listen_addr, library_rev, - library_digest, features }`. +1. Connect and send + `Hello { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`. + `listen_addr` is mandatory; the QUIC source port is only a temporary + transport port and must not be recorded as the peer's listener. +2. Receive + `HelloAck { peer_id, proto_ver, listen_addr, library_rev, library_digest, features }`. 3. If the remote `peer_id` is already known but the address changed, update it. -4. If protocol versions are incompatible, drop the peer (and keep mDNS watching). +4. If protocol versions are incompatible, drop the peer (and keep mDNS + watching). 5. If library digests match, do nothing else. 6. If digests differ: - If we have a known `library_rev` for that peer, request `LibraryDelta`. @@ -50,16 +51,16 @@ When a peer is discovered: Call to Play is transient peer-session state rather than database state. The peer keeps a bounded event history, deduplicated by event ID. Every event and -chat message remains in the snapshot for the full lifetime of an active call, -so a peer joining mid-call receives the complete context. A creator's `Start` -or `Cancel` makes the call terminal and read-only, but its complete roster and -chat history remain in snapshots for 15 minutes so late joiners can see the -outcome. After that display window the history compacts to the Start or Cancel -tombstone for the rest of the peer session. Active and recently terminal calls -are never partially trimmed. If genuinely active history reaches the bound, -local publishes return an error to the caller instead of appearing to succeed; -terminal histories and tombstones do not consume that active-history capacity. -A call whose deadline elapses remains available for five minutes so the creator +chat message remains in the snapshot for the full lifetime of an active call, so +a peer joining mid-call receives the complete context. A creator's `Start` or +`Cancel` makes the call terminal and read-only, but its complete roster and chat +history remain in snapshots for 15 minutes so late joiners can see the outcome. +After that display window the history compacts to the Start or Cancel tombstone +for the rest of the peer session. Active and recently terminal calls are never +partially trimmed. If genuinely active history reaches the bound, local +publishes return an error to the caller instead of appearing to succeed; +terminal histories and tombstones do not consume that active-history capacity. A +call whose deadline elapses remains available for five minutes so the creator can start or extend it, then its unresolved history is evicted as a unit. A local action is applied to that history, sent to the UI, and broadcast to every currently known peer. An incoming live event is applied once and sent to the UI @@ -89,8 +90,8 @@ deterministically and derives deadlines and check-in phases from timestamps. Those phases compare creator-supplied wall-clock timestamps with each viewer's local wall clock, so LAN machines are assumed to be synchronized closely enough for human-scale minute countdowns; clock skew shifts the displayed boundary by -the same amount. -There is deliberately no compatibility path for older protocol versions. +the same amount. There is deliberately no compatibility path for older protocol +versions. ### 4) Shutdown @@ -137,8 +138,8 @@ There is deliberately no compatibility path for older protocol versions. 1. Maintain a persistent on-disk index (per game): - `manifest_hash`, total size, file list (optional), and a fingerprint - (root-level `version.ini` mtime, root-level `.eti` mtime/size, and - `local/` directory presence). + (root-level `version.ini` mtime, root-level `.eti` mtime/size, and `local/` + directory presence). 2. Use filesystem watchers to update only changed games. 3. Keep a 300-second fallback scan to recover from missed events. @@ -164,8 +165,8 @@ Downloaded and installed are independent predicates: `local/` are user-owned and are skipped by manifests, fingerprints, and file serving. - Install and update transactions unpack into staging, then overwrite the first - discovered game-provided `account_name.txt` and `language.txt` files under - the staged tree from launcher settings before promoting it to `local/`. + discovered game-provided `account_name.txt` and `language.txt` files under the + staged tree from launcher settings before promoting it to `local/`. Reserved per-game paths: @@ -239,8 +240,8 @@ Most scans become O(number of game dirs), with full recursion only when needed. 1. Protocol updates in `lanspread-proto`: - Define `Hello`, `HelloAck`, `LibrarySummary`, `LibrarySnapshot`, `LibraryDelta`, and optional `Goodbye` messages. - - Thread `peer_id`, `library_rev`, and `manifest_hash` through all - library and manifest-bearing types. + - Thread `peer_id`, `library_rev`, and `manifest_hash` through all library + and manifest-bearing types. - Make `Hello` and `HelloAck` carry the sender's `listen_addr`, `library_rev`, and `library_digest` so both sides can record stable listener addresses and immediately select `LibraryDelta` vs @@ -248,11 +249,11 @@ Most scans become O(number of game dirs), with full recursion only when needed. 2. Peer identity: - Persist a stable `peer_id` (UUID) in the peer config and inject it into `PeerInfo` and `PeerGameDB` at startup. - - Track `peer_id -> SocketAddr` in the discovery table and update the - address on any incoming handshake or mDNS refresh. + - Track `peer_id -> SocketAddr` in the discovery table and update the address + on any incoming handshake or mDNS refresh. 3. Discovery handshake: - - Publish `peer_id` and `library_rev` in mDNS TXT records to avoid - immediate TCP/QUIC roundtrips when nothing changed. + - Publish `peer_id` and `library_rev` in mDNS TXT records to avoid immediate + TCP/QUIC roundtrips when nothing changed. - Add a lightweight handshake in `run_peer_discovery` that exchanges `Hello`/`HelloAck` before any library sync. - Ignore peers that do not advertise the current protocol version. @@ -261,8 +262,8 @@ Most scans become O(number of game dirs), with full recursion only when needed. successful index refresh completes. - Apply `LibraryDelta` when `library_rev` matches; reject stale or future revisions and request `LibrarySnapshot` instead. - - Cache the last accepted `manifest_hash` per peer to short-circuit - manifest requests when unchanged. + - Cache the last accepted `manifest_hash` per peer to short-circuit manifest + requests when unchanged. 5. Local index + scan optimizations: - Use the cached `local_library/index.json` file in the configured state directory to store per-root fingerprints and computed manifests. diff --git a/crates/lanspread-peer/README.md b/crates/lanspread-peer/README.md index 55a409b..44d60c4 100644 --- a/crates/lanspread-peer/README.md +++ b/crates/lanspread-peer/README.md @@ -23,19 +23,20 @@ It is designed to run headless – other crates (most notably `Game` definitions, tracks the latest ETI version per title, and keeps the last seen list of `GameFileDescription` entries for each peer. -Internally the peer runtime owns four long-lived tasks that run for the -lifetime of the process: +Internally the peer runtime owns four long-lived tasks that run for the lifetime +of the process: 1. **Server component** (`run_server_component`) – listens for QUIC connections, advertises via mDNS, and serves `Request::ListGames`, `Request::GetGame`, `Request::GetGameFileData`, `Request::GetGameFileChunk`, and `Request::StreamInstall` by reading from the local game directory. -2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns` - helper to discover other peers. The blocking mDNS work is executed on a - dedicated thread via `tokio::task::spawn_blocking` so that the Tokio runtime - remains responsive. -3. **Ping service** (`run_ping_service`) – periodically issues QUIC ping requests - to keep peer liveness up to date and prunes stale entries from `PeerGameDB`. +2. **Discovery loop** (`run_peer_discovery`) – uses the `lanspread-mdns` helper + to discover other peers. The blocking mDNS work is executed on a dedicated + thread via `tokio::task::spawn_blocking` so that the Tokio runtime remains + responsive. +3. **Ping service** (`run_ping_service`) – periodically issues QUIC ping + requests to keep peer liveness up to date and prunes stale entries from + `PeerGameDB`. 4. **Local game monitor** (`run_local_game_monitor`) – watches the configured game directory and each game root non-recursively, gates per-ID rescans while operations are active, emits local-library changes separately from active @@ -61,8 +62,9 @@ When the UI asks to download a game: 1. The UI first issues `PeerCommand::GetGame` for a new download, or `PeerCommand::FetchLatestFromPeers` for an update that must bypass local - archives. The selected peers are queried via `request_game_details_from_peer`, - and their file manifests are merged inside `PeerGameDB`. + archives. The selected peers are queried via + `request_game_details_from_peer`, and their file manifests are merged inside + `PeerGameDB`. 2. Once the UI receives `PeerEvent::GotGameFiles`, it forwards the selected file list back with `PeerCommand::DownloadGameFiles`. 3. `download_game_files` starts a version-sentinel transaction, parks any old @@ -84,8 +86,8 @@ When the UI asks to download a game: sweep `.version.ini.tmp` and `.version.ini.discarded` without restoring the previous sentinel. Cancelled downloads also discard the peer-owned download payload while preserving `local/` and install transaction metadata. -7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` - is emitted and the peer auto-runs the install transaction. +7. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is + emitted and the peer auto-runs the install transaction. ### Streamed Install Pipeline @@ -108,25 +110,24 @@ renamed to `local/`, post-promote intent or launch-settings cleanup failures are logged for startup recovery rather than reported as a failed install. `PeerCommand::CancelDownload` cancels the tracked download token for an active -transfer. The transfer task remains responsible for clearing `active_operations`, -discarding partial payload files, and refreshing the settled local snapshot, so -the UI continues to treat active-operation snapshots as the single source of -truth for whether a download is still running. +transfer. The transfer task remains responsible for clearing +`active_operations`, discarding partial payload files, and refreshing the +settled local snapshot, so the UI continues to treat active-operation snapshots +as the single source of truth for whether a download is still running. ### Install Transactions Install, update, uninstall, downloaded-file removal, and startup recovery live -under `src/install/`. -Install-side operation intent is stored atomically under the configured peer -state directory, at `games//install_intent.json`. Game roots still use -Lanspread-owned `.local.installing/` and `.local.backup/` directories marked by -`.lanspread_owned`. Startup recovery combines the recorded intent with the -observed filesystem state and only deletes reserved directories when intent or -marker ownership proves they belong to Lanspread. -Downloaded-file removal is deliberately separate from uninstall: it only accepts -catalog IDs that are direct children of the configured game directory, refuses -installed or in-flight roots, and deletes the whole game root only after finding -a regular root-level `version.ini` sentinel. +under `src/install/`. Install-side operation intent is stored atomically under +the configured peer state directory, at `games//install_intent.json`. +Game roots still use Lanspread-owned `.local.installing/` and `.local.backup/` +directories marked by `.lanspread_owned`. Startup recovery combines the recorded +intent with the observed filesystem state and only deletes reserved directories +when intent or marker ownership proves they belong to Lanspread. Downloaded-file +removal is deliberately separate from uninstall: it only accepts catalog IDs +that are direct children of the configured game directory, refuses installed or +in-flight roots, and deletes the whole game root only after finding a regular +root-level `version.ini` sentinel. Legacy launcher-owned files in game directories are migrated by a dedicated pre-start phase. Normal install, recovery, scan, and transfer paths use only the @@ -142,11 +143,10 @@ The Tauri application embeds this crate in game directory. - The Tauri commands (`request_games`, `install_game`, `update_game`, `remove_downloaded_game`, and `update_game_directory`) translate UI actions - into `PeerCommand`s. In - particular, `update_game_directory` validates the filesystem path before - storing it, loads the bundled catalog on first use, kicks off the peer runtime - on demand, and mirrors the installed/uninstalled state into the UI-facing - database. + into `PeerCommand`s. In particular, `update_game_directory` validates the + filesystem path before storing it, loads the bundled catalog on first use, + kicks off the peer runtime on demand, and mirrors the installed/uninstalled + state into the UI-facing database. - A background task consumes `PeerEvent`s and fans them out to the front-end via Tauri publish/subscribe events (`games-list-updated`, `game-download-*`, `game-install-*`, `game-uninstall-*`, `peer-*`). The Tauri crate now only diff --git a/design/README.md b/design/README.md index aa955a5..092196a 100644 --- a/design/README.md +++ b/design/README.md @@ -1,9 +1,10 @@ # SoftLAN Launcher — Design Handoff -**This folder is the complete, current state of design for the SoftLAN Launcher.** -Everything an implementor needs to build the product is in here — and nothing -that isn't. (The exploration mockups, logo concept boards, and variant studies -live back in the project workspace; they're history, not handoff.) +**This folder is the complete, current state of design for the SoftLAN +Launcher.** Everything an implementor needs to build the product is in here — +and nothing that isn't. (The exploration mockups, logo concept boards, and +variant studies live back in the project workspace; they're history, not +handoff.) Target codebase: **Tauri + React** desktop app. The references here are HTML/React prototypes that communicate the intended look, layout, and behavior — @@ -14,7 +15,7 @@ be shipped as-is. ## What's inside -``` +```text design_handoff_softlan_launcher/ ├── README.md ← you are here — start here │ @@ -46,15 +47,15 @@ design_handoff_softlan_launcher/ ## Two pieces, one product -| | **launcher/** | **logo/** | -|---|---|---| -| What | The full launcher UI redesign | The brand mark + wordmark lockup | -| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` | -| Preview | open `launcher/design_reference/SoftLAN Launcher.html` | open `logo/demo.html` | -| Fidelity | High — final colors/type/spacing/interactions | Final — recolors live via the `accent` token | +| | **launcher/** | **logo/** | +| ---------- | ------------------------------------------------------ | -------------------------------------------- | +| What | The full launcher UI redesign | The brand mark + wordmark lockup | +| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` | +| Preview | open `launcher/design_reference/SoftLAN Launcher.html` | open `logo/demo.html` | +| Fidelity | High — final colors/type/spacing/interactions | Final — recolors live via the `accent` token | The two share one design language. The **accent** color is a single token -(`--accent`, default `#3b82f6`) that drives the launcher's primary actions *and* +(`--accent`, default `#3b82f6`) that drives the launcher's primary actions _and_ the logo — wire it once and both follow. The brand mark in the launcher's top bar (`launcher/SPEC.md` → "Top bar → Brand") **is** the logo component from `logo/pixel-live.jsx` at `size={28}`; the static 28px "S" in the mock is a @@ -80,8 +81,8 @@ placeholder for it. chrome. Includes **Call to Play** (rally the LAN around a game + time — live or scheduled, with RSVP, check-in window, and per-call chat). Open questions (empty/error states, logs viewer, keyboard grid nav, German strings, "server - running" state, real-time transport for Call to Play) are listed at the end - of `SPEC.md`. + running" state, real-time transport for Call to Play) are listed at the end of + `SPEC.md`. - **Logo:** final. Live component + static assets + horizontal lockup (dark and light) all included. diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md index be6e876..be537f1 100644 --- a/design/launcher/SPEC.md +++ b/design/launcher/SPEC.md @@ -1,14 +1,22 @@ # 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. +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 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: +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 @@ -18,17 +26,25 @@ The target codebase is a **Tauri + React** desktop app. The task is to **recreat 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.) +- 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). +**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. +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. --- @@ -41,31 +57,55 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r 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`. + 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). +- **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): +- **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. + - **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. +- **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. --- @@ -73,42 +113,102 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r ### 1. Main library (variant A — primary) -The default screen. A grid of game cards over a dark, gradient-tinted background. +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. - +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: + - **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)`. + 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. + `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. + 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. + - **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-terminal 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. + - **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-terminal + 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. + **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%)`. +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. +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). @@ -116,54 +216,106 @@ The default screen. A grid of game cards over a dark, gradient-tinted background ### 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). +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. +**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 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. +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`. + - **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). + 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. + 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. +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. +- 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. +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. +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:** -``` +```text ┌─────────────────────────────────────────┐ │ Settings [×] │ ← head: 22 28 18, 1px bottom border ├─────────────────────────────────────────┤ @@ -208,69 +360,151 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr └─────────────────────────────────────────┘ ``` -**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap): +**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`) +- 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. +**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. +- **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**. +**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. +- **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)`. +**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`. +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)`. +**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. +**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`. + +- `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. +- `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). +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. +**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: +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) | +| 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. +"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: +**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. +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). +**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. +**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. +**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. +**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. +**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. --- @@ -278,39 +512,72 @@ Two visual states, driven by whether `settings.gameFolder` resolves to an access 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. +**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)` +- 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)`. +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. + - **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. + - **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. +A single button per card with the _primary action for the current state_. +Color-coded as the main affordance for state at a glance. -``` +```text state label button style ───────────── ────────── ──────────────────────────────────────────── not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1) @@ -319,85 +586,156 @@ installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), 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). +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). +**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: +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. +- 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). +- **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`). +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: +**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; } } +@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. +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. +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: -``` +```text 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. +- **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; } } +@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. +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`). +- `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. +Keep these formats; they're tuned so the secondary row never wraps at normal +modal width. ### Data shape @@ -406,17 +744,27 @@ 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 + 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. +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). + +- `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 @@ -444,12 +792,12 @@ Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared ### 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 | +| | **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 @@ -465,8 +813,8 @@ the call and its history expire as a unit. ### Three surfaces -1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label + - an accent badge counting active, non-terminal calls. Opens the overlay. +1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label + an + accent badge counting active, non-terminal 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 @@ -474,8 +822,8 @@ the call and its history expire as a unit. 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** (Running and Cancelled calls sink - to the bottom). + form, and a list of **nomination cards** (Running and Cancelled calls sink to + the bottom). ### Status model @@ -497,19 +845,34 @@ not contribute to the top-bar badge. **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. +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`, `Time's up`, `Running`, or `Cancelled`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. 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. +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`, `Time's up`, `Running`, or + `Cancelled`. If catalog data is temporarily unavailable, the card still + renders the caller, game ID, roster, chat, and coordination actions with a + clear `Game unavailable here` label. 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. @@ -522,10 +885,10 @@ cancel actions are disabled. 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 — · -