Files
lanspread/design/README.md
T
ddidderr f1e915c379 docs: document download peer count chip
Update the launcher design reference so active downloads show how many LAN
peers are currently seeding the transfer. The reference now places the peer
chip between speed and ETA, describes the singular/plural copy, and records
how the ETA and peer count collapse in narrow modal layouts.

Test Plan:
- git diff --cached --check
2026-05-21 00:30:57 +02:00

32 KiB
Raw Blame History

Handoff: SoftLAN Launcher redesign

A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog.


About the design files

The files in design_reference/ are design references created in HTML/React via Babel-in-the-browser — prototypes built to communicate the intended look, layout, and behavior. They are not production code to copy directly.

The target codebase is a Tauri + React desktop app. The task is to recreate these designs inside that codebase, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for:

  • Exact pixel/spacing/color/typography values
  • Component composition and interactions
  • Copy and microcopy
  • Animation easings/durations

But:

  • Don't ship the Babel-in-browser setup or import the .jsx files as-is
  • Don't keep the <deck> / design-canvas wrapping — that's only for presenting variants
  • Don't ship the Tweaks panel — it's superseded by the in-app Settings dialog (see "Screens" below)
  • Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.)

Fidelity

High-fidelity. Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical).

Layout variants

The HTML mock includes two chrome variants — A (single-row) and B (two-row) — to choose from. The user selected A as the primary direction. Implement A. Variant B is left in the reference for context only.


Screens / views

1. Main library (variant A — primary)

The default screen. A grid of game cards over a dark, gradient-tinted background.

Layout (top-to-bottom):

  1. Top bar — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background rgba(10,14,19,0.65) + backdrop-filter: blur(20px) saturate(140%). Border-bottom 1px solid rgba(255,255,255,0.06). Contents, left-to-right with 18px gap and 24px horizontal padding:

    • Brand — 28×28px rounded square in --accent (default #3b82f6) with the letter "S" in Bebas Neue 20px white. Next to it, the wordmark "SoftLAN" in 15px / 700 weight --t-1 #e6edf3.

    • Segmented filter pills — 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 220ms), 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.

    • Search field — 36px tall, min-width 320px (flex 0 1 380px). background var(--bg-2), 1px solid var(--bd-1), border-radius: 8px, padding 0 12px. Has a 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 becomes 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.

    • Sort menu — 36px button, same surface style as search. Label Sort: <bold value> plus 13px sort-bars icon and 11px chevron. Click reveals dropdown menu below. Options: Name (AZ), Size (largest), Recently Played, Status.

    • Game directory button — 36px button, max-width 360px. Folder icon, "Game directory" label (600 weight --t-1), then the current path in ui-monospace 11.5px --t-3 truncated with leading ellipsis when long (e.g. …s/Desktop/eti_games_AFTER_LAN_2025).

    • Kebab menu () — 36×36 button with same surface. Menu items: Settings (opens Settings dialog — see below), Refresh library, separator, Unpack logs, About SoftLAN.

  2. Results bar — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:

    • Left: Showing <strong>N</strong> 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: <sq> 78 GB installed <sq> 41 GB local 384 GB free. Squares are 8×8px rounded 2px, colored var(--accent) and color-mix(var(--accent), 55%).
  3. Grid — CSS grid with repeat(auto-fill, minmax(188px, 1fr)) at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically.

    • Density: compact → min 148, gap 12. normal → min 188, gap 16. large → min 244, gap 20.

Game card (see "Game card" below for full anatomy).


2. Game detail overlay

Opens when the user clicks anywhere on a game card except the action button. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card).

Scrim: absolutely positioned over the launcher, inset: 0, z-index: 100, background: rgba(4,7,11,0.7), backdrop-filter: blur(8px), fade-in 180ms. Padding 32px, content centered.

Modal panel: min(880px, 100%) wide, background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%), 1px solid var(--bd-2), border-radius: 14px, drop shadow 0 30px 80px -10px rgba(0,0,0,0.7). Scales in from 0.96 with 250ms cubic-bezier(.3,1.3,.4,1).

Modal structure (top-to-bottom):

  1. Hero banneraspect-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 <h2> — system sans 32px / 700 / -0.015em, white, text-shadow 0 4px 24px rgba(0,0,0,0.6). Not Bebas Neue here — this is normal UI typography, not stylized cover art.
  2. Body — 22px top, 26px bottom, 28px horizontal:

    • Meta grid — 4-column CSS grid, 12px gap. Each cell: padding 10px 12px, background rgba(255,255,255,0.025), 1px solid var(--bd-1), border-radius: 8px. Cells (in order): Size (e.g. 8.2 GB), Players (icon + range), Version (mono, e.g. 2018.04.12), Status (Installed / Local / Not downloaded).
    • Description — 14px / 1.55 line-height, var(--t-2), text-wrap: pretty, max-width: 64ch.
    • Actions row — flex row, 10px gap, 4px top padding:
      • Primary action button (44px tall, see "Action button" below — Play / Install / Download depending on state)
      • 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.
      • If state === 'local': ghost-button Delete from disk (same danger styling).
      • Spacer (flex: 1).
      • Ghost-button View files (neutral) — opens system file manager at the game folder.

3. Settings dialog

Opens when the user clicks Settings from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (min(640px, 100%)) and styled as a list of preferences.

Structure:

┌─────────────────────────────────────────┐
│ Settings                            [×] │  ← head: 22 28 18, 1px bottom border
├─────────────────────────────────────────┤
│                                         │
│ APPEARANCE                              │  ← section title: 10.5px / 700 / 0.12em / uppercase / --t-3
│                                         │
│ Accent color                            │  ← row label: 14px / 600 / --t-1
│ Used for primary actions and highlights │  ← row hint: 12px / --t-3
│                       ⬤⬤⬤⬤⬤⬤            │  ← 6 swatches, right-aligned
│                                         │
│ Background                              │
│ Backdrop behind the library             │
│                  [Flat │Gradient│Animat]│  ← segmented radio
│                                         │
│ LIBRARY                                 │
│                                         │
│ Grid density                            │
│ How tightly cards are packed            │
│                   [Compact│Normal│Large]│
│                                         │
│ Cover aspect                            │
│ Shape of the cover art on each card     │
│                   [Box-art│Square│Banner│
│                                         │
├─────────────────────────────────────────┤
│                                  [Done] │  ← foot: 14 22 18, 1px top border
└─────────────────────────────────────────┘

Sections are separated by 26px gap (column flex). Rows within a section: 14px gap. Each row is flex row with space-between (24px gap):

  • Left (settings-row-info): label (14px / 600 / --t-1) + hint (3px-top, 12px / --t-3)
  • Right (settings-row-control): the control

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 <swatch-color> and shows a centered white check icon with drop-shadow 0 1px 2px rgba(0,0,0,0.5).

Six accent options: Blue #3b82f6, Cyan #22d3ee, Violet #a855f7, Green #22c55e, Amber #f59e0b, Red #ef4444.

Segmented radio: inline-flex with background var(--bg-3) #1a2330, 1px solid var(--bd-1), border-radius: 8px, padding: 3px. Each button: 30px tall, padding: 0 14px, border-radius: 6px, font 12.5px / 600. Inactive: color var(--t-2). Active: background var(--accent), color white, inset top shadow 0 1px 0 rgba(255,255,255,0.18).

Done button: filled button in --accent, 36px tall, 13.5px / 600. Closes the dialog.

Persisted settings (write through to local storage / Tauri config):

  • accent: one of the six hex values above. Default #3b82f6.
  • bg: flat | gradient | animated. Default gradient.
  • density: compact | normal | large. Default normal.
  • aspect: box | square | banner. Default box.

Game card

The unit element of the library grid.

Container: flex column. background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%), 1px solid var(--bd-1), border-radius: 10px, overflow: hidden. Cursor pointer.

Hover/focus state:

  • transform: translateY(-2px) (180ms cubic-bezier(.4,1.2,.5,1))
  • border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))
  • Box-shadow 0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)
  • Cover inner image scales to 1.03 (350ms cubic-bezier)
  • Focus-visible: same lift + 2px solid accent outline

Anatomy (top to bottom)

  1. Cover wrapwidth: 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 blobradial-gradient(ellipse at <x>% <y>%, <accent>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 <c2 + alpha>, 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).
    • Vignettelinear-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 <color>.
    • 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. 232). Always visible — every LAN game is multiplayer.
  3. Card bodypadding: 11px 12px 12px, flex column, 8px gap:

    • Title — game's full (mixed-case) title in 13.5px / 600 / --t-1, single line, ellipsis on overflow.
    • Meta line — 11.5px tabular-nums, --t-3: size · genre. Dot separator at 50% opacity.
    • Action button (full width) — primary action depending on state, see below.

Action button

A single button per card with the primary action for the current state. Color-coded as the main affordance for state at a glance.

state          label       button style
─────────────  ──────────  ────────────────────────────────────────────
not downloaded Download    neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
local          Install     bg var(--accent), text white, inset top hl
installed      Play        bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl
downloading    — progress  see "Download progress" below — the button slot is replaced with a live progress component

Common sizing: 32px tall (card) or 44px tall (modal). border-radius: 7px (card) / 8px (modal). font 12.5px / 600 (card) / 14px / 600 (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).

Hover: filter: brightness(1.12). Active: transform: scale(0.98).

Uninstall / Delete-from-disk are NOT on the card — only in the detail overlay (as ghost-danger buttons).


Download progress (state === 'downloading')

When a game is actively downloading, the action-button slot is replaced by an inline progress component. The component is its own visual primitive (DownloadProgress in components.jsx); it is NOT a button with a <progress> 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: <pct>%, animated via transition: width 480ms cubic-bezier(.4,0,.2,1). Background is a vertical gradient of color-mix(in srgb, var(--accent) 3826%, 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: <pulse> <pct>% — 12px / 600, var(--t-1). % glyph at 0.55 opacity. e.g. • 32%.
  • Right: <speed> — 11px / 500, var(--t-2). Short format: 49 MB/s (no decimals at card scale).

Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding 0 10px (9 compact / 12 large). Font sizes scale similarly (see styles.css).

Container-query graceful degradation — this is the important part, it has to fit every aspect/density combo:

@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
@container (max-width: 96px)  { .dl-md .dl-pulse { display: none; } }

At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees compact density + box aspect (the narrowest combination) still reads cleanly.

The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar.

Detail-overlay layout (.dl-lg, replaces the 44px modal action button)

Fixed 56px height. CSS-grid with three columns and two rows:

grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas:
  "primary   pct cancel"
  "secondary pct cancel";
  • Primary row (.dl-lg-primary, top-left) — pulse dot + the uppercase live label DOWNLOADING in color-mix(in srgb, var(--accent) 80%, white), 13px / 600, letter-spacing: 0.02em. This is the only place the word "Downloading" appears in the component.
  • Secondary row (.dl-lg-secondary, bottom-left) — the live stats. 12px, four groups separated by · (0.45 opacity):
    1. <strong>11.4 GB</strong> / 35 GB (var(--t-1) strong + var(--t-2) rest)
    2. 47.6 MB/s (var(--t-1))
    3. [users-icon] from <strong>5</strong> peers.dl-peers, inline-flex with 5px gap, icon at 0.7 opacity, count bold + tabular-nums in var(--t-1), rest in var(--t-2). Singular/plural switches on peers === 1. Hidden entirely when game.peers is falsy. Communicates that this is a LAN swarm transfer, not a single-source pull.
    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:

@container (max-width: 380px) { .dl-lg-secondary .dl-eta,   .dl-lg-secondary .dl-sep-eta   { display: none; } }
@container (max-width: 300px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }

ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.

Number formatting

All helpers live in data.jsx:

  • fmtSpeed(mbps)49.4 MB/s below 100, MM MB/s (rounded) at/above 100. Used in .dl-lg.
  • fmtSpeedShort(mbps) — always rounded: 49 MB/s. Used in .dl-md so the card stays compact.
  • fmtBytes(gb)<1 GB → MB rounded, <10 GB → up to 2 decimals (trailing zeros stripped: 2.35 GB, 2.3 GB, 2 GB), ≥10 GB → 1 decimal max (11.4 GB, 35 GB).
  • fmtEta(seconds)< 60s → "N s", < 60min → "N min", else "H h M min".

Keep these formats; they're tuned so the secondary row never wraps at normal modal width.

Data shape

The Game type gains a downloading state plus two transient fields:

type Game = {
  // … existing fields …
  state: 'installed' | 'local' | 'downloading' | 'none';
  progress?: number;   // 01, only when state === 'downloading'
  speed?: number;      // current throughput in MB/s
  peers?: number;      // number of LAN peers currently seeding
};

In the real app, progress, speed, and peers come from the download worker (Tauri command emitting events). The mock's useLiveDownload(game) hook (in components.jsx) is just a placeholder — 600ms setInterval advancing progress proportional to speed, with speed smoothed via a low-pass filter and small random drift so the number doesn't look fake. peers is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the .dl-peers chip re-renders silently. Replace the hook with a useEffect that subscribes to your real progress events; the rendering layer needs nothing else.

Filter changes:

  • Local filter includes installed + local + downloading (in-flight downloads belong on the Local tab — you're managing them).
  • Sort by state orders installed < local < downloading < none.

State chip

Add a fourth entry to STATE_META:

downloading: { label: 'Downloading', dot: 'var(--accent)' }

Dot uses the live accent so it visually ties to the progress fill.


Filter controls — variant B (not used, kept for reference)

The two-row chrome has a different filter style — underlined tabs with counts, like browser tabs:

  • Buttons: no background, padding: 10px 14px 12px, font 13.5px / 600. Color --t-2 inactive, --t-1 active.
  • Count chip after label: 11.5px / 600, padding 1px 7px, rounded pill. Inactive bg rgba(255,255,255,0.06), text --t-3. Active bg rgba(255,255,255,0.10), text --t-1.
  • Active tab has a 2px underline at the bottom (left: 12px, right: 12px) in --accent, animated in via opacity + scaleX (220ms cubic-bezier).

Implement only if you decide variant A doesn't work after building.


Interactions & behavior

  • Click game card (anywhere except the action button) → open detail overlay.
  • Click action button on card → trigger the state-appropriate action without opening the overlay. e.stopPropagation() on the button.
  • Press / (slash) → focus the search input.
  • Type in search → live-filter the visible grid by title or tag (case-insensitive substring).
  • Click filter tab / segmented pill → change filter.
  • Click sort button → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
  • Hover game card → lift + accent border glow + cover image scale 1.03.
  • Click "Game directory" button → open native folder picker via Tauri; on selection, rescan library.
  • Click "Settings" in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
  • Click "Unpack logs" in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
  • Click "Refresh library" in kebab → re-runs the library scan.
  • Esc → closes any open modal (detail overlay, Settings).

Transitions / animations

  • Card hover: 180ms cubic-bezier(.4,1.2,.5,1) on transform/border, 350ms cubic-bezier(.4,1.2,.5,1) on cover scale.
  • Modal fade-in: scrim opacity 0 → 1 over 180ms ease; modal transform: scale(.96) translateY(8px) → scale(1) translateY(0) and opacity over 250ms cubic-bezier(.3,1.3,.4,1).
  • Segmented filter thumb: 220ms cubic-bezier(.4,1.2,.5,1) on left and width.
  • Underline tab indicator (variant B): 200ms on opacity, 250ms cubic-bezier(.4,1.2,.5,1) on transform: scaleX.
  • Animated background option: subtle 18s ease-in-out infinite alternate background-position shift on two accent-tinted radial gradients.

State management

Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations.

Library state (rebuilt on refresh):

type Game = {
  id: string;
  title: string;
  size: number;        // GB
  version: string;     // "YYYY.MM.DD"
  desc: string;
  state: 'installed' | 'local' | 'downloading' | 'none';
  progress?: number;   // 01 — present only when state === 'downloading'
  speed?: number;      // MB/s — present only when state === 'downloading'
  players: string;     // e.g. "232"
  tags: string[];
  cover: { c1: string; c2: string; accent: string; mood?: string };
};

UI state:

type LauncherUI = {
  filter: 'all' | 'local' | 'installed';
  sort: 'az' | 'size' | 'recent' | 'state';
  query: string;
  openGameId: string | null;
  settingsOpen: boolean;
};

Persisted settings: see Settings dialog section. Persist via Tauri's plugin-store or a local JSON file in app data dir.

Storage figures: computed by summing game sizes per state, plus free-space query via Tauri.


Design tokens

Color

token value usage
--bg-0 #0a0e13 launcher background
--bg-1 #0f151c card bottom gradient stop
--bg-2 #131b25 top bar / card top / search bg
--bg-3 #1a2330 settings segmented bg / cover fallback
--bg-4 #232f3e (reserved)
--bd-1 rgba(255,255,255,0.06) subtle border
--bd-2 rgba(255,255,255,0.10) stronger border
--bd-3 rgba(255,255,255,0.16) scrollbar thumb
--t-1 #e6edf3 primary text
--t-2 #9aa6b4 secondary text
--t-3 #6b7785 muted text / metadata
--t-4 #4a5663 (reserved)
--ok #22c55e "installed" dot
--warn #f59e0b "local" dot
--danger #ef4444 destructive actions
--accent user-selected, default #3b82f6 primary actions, focus rings, brand mark

Typography

  • UI font — system sans stack: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif
  • Cover-art display font"Bebas Neue" (Google Fonts, weight 400) with fallback "Oswald", Impact, "Arial Narrow Bold", sans-serif
  • Monospaceui-monospace, "SF Mono", Menlo, Consolas, monospace (used for: directory path, version field in detail overlay)

Sizing reference:

  • Brand wordmark: 15 / 700
  • Modal title: 32 / 700 / -0.015em
  • Card title: 13.5 / 600
  • Filter pill label: 12.5 / 600
  • Settings row label: 14 / 600
  • Section title (settings): 10.5 / 700 / 0.12em uppercase
  • Meta (size · genre, etc.): 11.5 / tabular-nums / --t-3
  • Hint text: 12 / --t-3

Spacing & radii

  • Card radius: 10px
  • Modal radius: 14px
  • Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
  • Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28
  • Card body padding: 11 12 12

Shadows

  • Card hover: 0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)
  • Modal: 0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)
  • Brand mark: 0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)
  • Action button (filled): 0 6px 16px -8px <color>, inset 0 1px 0 rgba(255,255,255,0.22)

Assets

Cover art in the design files is stylized placeholder art — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design.

In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in design_reference/components.jsx → GameCover.

The icon set (search, play, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in design_reference/components.jsx → Icon. They are 12-14px inline SVGs using currentColor. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight.

Fonts to load:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap">

File reference

design_reference/
├── SoftLAN Launcher.html   ← entry; wires React + Babel, mounts <App>
├── styles.css              ← all visual styles (CSS custom props + components)
├── data.jsx                ← mock GAMES array + filter/sort helpers + STORAGE mock
├── components.jsx          ← Icon, GameCover, StateChip, ActionButton, GameCard,
│                             SegmentedFilters, UnderlineFilters, SearchField,
│                             SortMenu, StorageMeter, DirectoryButton, KebabMenu,
│                             GameDetailModal, SettingsDialog
└── launcher.jsx            ← <Launcher> component composing chrome + grid + modals

To preview the design in a browser:

  1. Open SoftLAN Launcher.html in a static-server (e.g. python -m http.server from the folder).
  2. You'll see a design canvas with all variants (A, B, C, D, E) side-by-side. Click an artboard's expand button to view it full-screen.
  3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change accent / density / aspect / background. In the production app these live in the Settings dialog (variant E).

Out of scope / open questions for the developer

  • Unpack logs viewer — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
  • Empty state — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
  • Error state on action — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
  • Progress state — designed. See "Download progress" section above. The action-button slot is swapped for a live DownloadProgress component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
  • Keyboard arrow nav — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.