From 27c71978d2fb55cd4603f9ba089c4fa50f4f2f3a Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 19 May 2026 19:59:36 +0200 Subject: [PATCH] docs(design): add SoftLAN launcher redesign handoff and references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `design/` directory containing the design handoff document and HTML/React reference prototypes for the planned Steam-inspired redesign of the launcher UI. Contents: - `design/README.md` — handoff spec. Defines screens (main library, game detail overlay, in-app Settings dialog), the game card anatomy, interaction behavior, transitions, state shape, design tokens (colors, typography, spacing, shadows) and out-of-scope items. Selects layout variant A (single-row top bar) as the primary direction. High-fidelity: colors / typography / spacing / animations are decided, pixel-fidelity to the mock is the goal. - `design/design_reference/` — Babel-in-browser React prototypes built to communicate intended look and behavior. Includes: * `SoftLAN Launcher.html` — entry that wires React + Babel and mounts the design canvas with all variants side-by-side. * `styles.css` — full visual spec as CSS custom properties + named component classes (`.topbar`, `.seg`, `.card`, `.modal`, etc.). * `data.jsx` — mock game catalog plus filter/sort helpers and a mock STORAGE record used by the storage meter. * `components.jsx` — reusable building blocks (Icon set, GameCover placeholder generator, StateChip, ActionButton, GameCard, SegmentedFilters, UnderlineFilters, SearchField, SortMenu, StorageMeter, DirectoryButton, KebabMenu, GameDetailModal, SettingsDialog). * `launcher.jsx` — composes top bar + grid + modals into a complete launcher screen, in both `single`-row and `two`-row chrome variants. These files are reference material, not production code. They are not imported by the Vite/Tauri build and ship outside the frontend crate (`crates/lanspread-tauri-deno-ts/`). They are committed so the design intent is reviewable in-repo and surviving across implementations. The accompanying production implementation against this spec is in follow-up commits. Trailer ------- Refs: design/README.md (canonical handoff) --- design/README.md | 382 +++++++ design/design_reference/SoftLAN Launcher.html | 110 +++ design/design_reference/components.jsx | 472 +++++++++ design/design_reference/data.jsx | 192 ++++ design/design_reference/launcher.jsx | 112 +++ design/design_reference/styles.css | 931 ++++++++++++++++++ 6 files changed, 2199 insertions(+) create mode 100644 design/README.md create mode 100644 design/design_reference/SoftLAN Launcher.html create mode 100644 design/design_reference/components.jsx create mode 100644 design/design_reference/data.jsx create mode 100644 design/design_reference/launcher.jsx create mode 100644 design/design_reference/styles.css diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..2716c6a --- /dev/null +++ b/design/README.md @@ -0,0 +1,382 @@ +# Handoff: SoftLAN Launcher redesign + +A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog. + +--- + +## About the design files + +The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**. + +The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for: + +- Exact pixel/spacing/color/typography values +- Component composition and interactions +- Copy and microcopy +- Animation easings/durations + +But: + +- Don't ship the Babel-in-browser setup or import the .jsx files as-is +- Don't keep the `` / design-canvas wrapping — that's only for presenting variants +- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below) +- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.) + +## Fidelity + +**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical). + +## Layout variants + +The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only. + +--- + +## 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: ` plus 13px sort-bars icon and 11px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `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 N of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`). + - Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: ` 78 GB installed 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`. + +3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically. + + - Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20. + +**Game card** (see "Game card" below for full anatomy). + +--- + +### 2. Game detail overlay + +Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card). + +**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered. + +**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`. + +**Modal structure (top-to-bottom):** + +1. **Hero banner** — `aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads. + - **State chip** in the top-left of the hero (same chip style as on cards — see Game Card). + - **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon. + - **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`: + - Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`) + - **Title** as `

` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art. + +2. **Body** — 22px top, 26px bottom, 28px horizontal: + - **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded). + - **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`. + - **Actions row** — flex row, 10px gap, 4px top padding: + - 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 ` 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 wrap** — `width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`. + +2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`): + - **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata. + - **Radial accent blob** — `radial-gradient(ellipse at % %, 38, transparent 55%)`. x/y also hashed from id. + - **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7. + - **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash. + - **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px , 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants). + - **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2). + - **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px `. + - **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `2–32`). Always visible — every LAN game is multiplayer. + +3. **Card body** — `padding: 11px 12px 12px`, flex column, 8px gap: + - **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow. + - **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity. + - **Action button** (full width) — primary action depending on state, see below. + +### Action button + +A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance. + +``` +state label button style +───────────── ────────── ──────────────────────────────────────────── +not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1) +local Install bg var(--accent), text white, inset top hl +installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl +``` + +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). + +--- + +## 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`): +```ts +type Game = { + id: string; + title: string; + size: number; // GB + version: string; // "YYYY.MM.DD" + desc: string; + state: 'installed' | 'local' | 'none'; + players: string; // e.g. "2–32" + tags: string[]; + cover: { c1: string; c2: string; accent: string; mood?: string }; +}; +``` + +**UI state:** +```ts +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` +- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay) + +Sizing reference: +- Brand wordmark: 15 / 700 +- Modal title: 32 / 700 / -0.015em +- Card title: 13.5 / 600 +- Filter pill label: 12.5 / 600 +- Settings row label: 14 / 600 +- Section title (settings): 10.5 / 700 / 0.12em uppercase +- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3` +- Hint text: 12 / `--t-3` + +### Spacing & radii + +- Card radius: 10px +- Modal radius: 14px +- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button) +- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28 +- Card body padding: 11 12 12 + +### Shadows + +- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)` +- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)` +- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)` +- Action button (filled): `0 6px 16px -8px , inset 0 1px 0 rgba(255,255,255,0.22)` + +--- + +## Assets + +Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design. + +In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`. + +The icon set (search, play, 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: +```html + + + +``` + +--- + +## File reference + +``` +design_reference/ +├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts +├── styles.css ← all visual styles (CSS custom props + components) +├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock +├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard, +│ SegmentedFilters, UnderlineFilters, SearchField, +│ SortMenu, StorageMeter, DirectoryButton, KebabMenu, +│ GameDetailModal, SettingsDialog +└── launcher.jsx ← component composing chrome + grid + modals +``` + +To preview the design in a browser: +1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder). +2. You'll see a design canvas with all variants (A, B, C, D, E) 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** — when a game is actively downloading or installing, the action button should become a progress bar with a cancel affordance. Not designed; recommend: replace the button with a progress bar of the same dimensions, percentage text on the left, cancel "×" on the right. +- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal. diff --git a/design/design_reference/SoftLAN Launcher.html b/design/design_reference/SoftLAN Launcher.html new file mode 100644 index 0000000..ccab554 --- /dev/null +++ b/design/design_reference/SoftLAN Launcher.html @@ -0,0 +1,110 @@ + + + + +SoftLAN Launcher — Redesign + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/design/design_reference/components.jsx b/design/design_reference/components.jsx new file mode 100644 index 0000000..2c52534 --- /dev/null +++ b/design/design_reference/components.jsx @@ -0,0 +1,472 @@ +// components.jsx — UI building blocks for the SoftLAN launcher +// Loaded after data.jsx; relies on GAMES/STATE_META/ACTION_FOR_STATE/etc. on window. + +const { useState, useMemo, useRef, useEffect } = React; + +// ──────────────────────────────────────────────────────────────────── +// Iconography (tiny inline SVGs; no emoji) +// ──────────────────────────────────────────────────────────────────── +const Icon = { + search: (p) => , + play: (p) => , + install:(p) => , + download:(p)=> , + folder: (p) => , + kebab: (p) => , + sort: (p) => , + users: (p) => , + close: (p) => , + check: (p) => , + chevron:(p) => , + trash: (p) => , +}; + +// ──────────────────────────────────────────────────────────────────── +// Cover art — stylized box-art placeholder +// ──────────────────────────────────────────────────────────────────── +function GameCover({ game, aspect = 'box', size = 'normal' }) { + const { c1, c2, accent } = game.cover; + // Pick title sizing — shrink for longer names; line-clamp:2 handles wrap + const title = game.title; + const len = title.length; + const fontPx = aspect === 'banner' ? (len > 22 ? 18 : len > 14 ? 22 : 28) + : aspect === 'square' ? (len > 22 ? 18 : len > 14 ? 22 : 28) + : (len > 26 ? 15 : len > 20 ? 17 : len > 14 ? 21 : 26); + + // Stable but varied per-game accent shape (id hash → angle / size) + const h = [...game.id].reduce((a, c) => a + c.charCodeAt(0), 0); + const angle = 110 + (h % 60); // 110-170 + const blobX = 60 + (h % 30); + const blobY = 10 + ((h * 7) % 30); + + return ( +
+ {/* base gradient */} +
+ {/* radial accent blob */} +
+ {/* scanline / grain */} +
+ {/* faint geometric mark */} + + {/* title */} +
+
+ {title} +
+
+ {/* bottom darkening */} +
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// State chip (corner of cover) +// ──────────────────────────────────────────────────────────────────── +function StateChip({ state }) { + const meta = STATE_META[state]; + if (!meta || !meta.label) return null; + return ( +
+ + {meta.label} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Action button — Play / Install / Download +// ──────────────────────────────────────────────────────────────────── +function ActionButton({ state, accent, size = 'md', onClick, full = false }) { + const action = ACTION_FOR_STATE[state]; + const cls = `act-btn act-${action.kind} ${size === 'lg' ? 'act-lg' : ''} ${full ? 'act-full' : ''}`; + const icon = action.kind === 'play' ? + : action.kind === 'install' ? + : ; + return ( + + ); +} + +// ──────────────────────────────────────────────────────────────────── +// Game card +// ──────────────────────────────────────────────────────────────────── +function GameCard({ game, accent, aspect, onOpen }) { + return ( +
onOpen && onOpen(game)} tabIndex={0}> +
+ + +
+ {game.players} +
+
+
+
{game.title}
+
+ {fmtSize(game.size)} + · + {game.tags[0]} +
+ +
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Filter controls +// ──────────────────────────────────────────────────────────────────── +const FILTER_TABS = [ + { key: 'all', label: 'All Games' }, + { key: 'local', label: 'Local' }, + { key: 'installed', label: 'Installed' }, +]; + +function SegmentedFilters({ value, onChange, counts, accent }) { + const ref = useRef(null); + const [thumb, setThumb] = useState({ left: 0, width: 0 }); + useEffect(() => { + if (!ref.current) return; + const el = ref.current.querySelector(`[data-key="${value}"]`); + if (el) setThumb({ left: el.offsetLeft, width: el.offsetWidth }); + }, [value]); + return ( +
+
+ {FILTER_TABS.map(t => ( + + ))} +
+ ); +} + +function UnderlineFilters({ value, onChange, counts, accent }) { + return ( +
+ {FILTER_TABS.map(t => ( + + ))} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Search input +// ──────────────────────────────────────────────────────────────────── +function SearchField({ value, onChange, accent, wide = false }) { + return ( +
+ + onChange(e.target.value)}/> + / +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Sort menu (simple dropdown) +// ──────────────────────────────────────────────────────────────────── +const SORTS = [ + { key: 'az', label: 'Name (A–Z)' }, + { key: 'size', label: 'Size (largest)' }, + { key: 'recent', label: 'Recently Played' }, + { key: 'state', label: 'Status' }, +]; + +function SortMenu({ value, onChange, accent }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [open]); + const current = SORTS.find(s => s.key === value) || SORTS[0]; + return ( +
+ + {open && ( +
+ {SORTS.map(s => ( + + ))} +
+ )} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Storage meter +// ──────────────────────────────────────────────────────────────────── +function StorageMeter({ accent, compact = false }) { + const { installed, local, total } = STORAGE; + const pctI = (installed / total) * 100; + const pctL = (local / total) * 100; + return ( +
+
+
+
+
+
+ {installed.toFixed(0)} GB installed + {local.toFixed(0)} GB local + {STORAGE.free.toFixed(0)} GB free +
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Directory button (shows path) +// ──────────────────────────────────────────────────────────────────── +function DirectoryButton({ path }) { + const short = path.length > 36 ? '…' + path.slice(-34) : path; + return ( + + ); +} + +// ──────────────────────────────────────────────────────────────────── +// Menu (kebab) +// ──────────────────────────────────────────────────────────────────── +function KebabMenu({ items }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [open]); + return ( +
+ + {open && ( +
+ {items.map((it, i) => it === '-' ?
: ( + + ))} +
+ )} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Detail Modal +// ──────────────────────────────────────────────────────────────────── +function GameDetailModal({ game, accent, onClose }) { + if (!game) return null; + const action = ACTION_FOR_STATE[game.state]; + return ( +
+
e.stopPropagation()}> + +
+ +
+
+
+ {game.tags.map(t => {t})} +
+

{game.title}

+
+
+ +
+
+
+
+
+
Size
+
{fmtSize(game.size)}
+
+
+
Players
+
{game.players}
+
+
+
Version
+
{game.version}
+
+
+
Status
+
{STATE_META[game.state].label || 'Not downloaded'}
+
+
+

{game.desc}

+
+ + {game.state === 'installed' && ( + + )} + {game.state === 'local' && ( + + )} + {game.state !== 'none' &&
} + +
+
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Settings Dialog — in-app version of the Tweaks panel +// ──────────────────────────────────────────────────────────────────── +const SETTING_OPTIONS = { + accent: [ + { value: '#3b82f6', label: 'Blue' }, + { value: '#22d3ee', label: 'Cyan' }, + { value: '#a855f7', label: 'Violet' }, + { value: '#22c55e', label: 'Green' }, + { value: '#f59e0b', label: 'Amber' }, + { value: '#ef4444', label: 'Red' }, + ], + bg: [{ value: 'flat', label: 'Flat' }, { value: 'gradient', label: 'Gradient' }, { value: 'animated', label: 'Animated' }], + density: [{ value: 'compact', label: 'Compact' }, { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }], + aspect: [{ value: 'box', label: 'Box-art' }, { value: 'square', label: 'Square' }, { value: 'banner', label: 'Banner' }], +}; + +function SettingsRow({ label, hint, children }) { + return ( +
+
+
{label}
+ {hint &&
{hint}
} +
+
{children}
+
+ ); +} + +function SegmentedRadio({ value, options, onChange, accent }) { + return ( +
+ {options.map(o => ( + + ))} +
+ ); +} + +function ColorSwatchPicker({ value, options, onChange }) { + return ( +
+ {options.map(o => ( + + ))} +
+ ); +} + +function SettingsDialog({ settings, onChange, onClose }) { + return ( +
+
e.stopPropagation()}> +
+

Settings

+ +
+
+
+
Appearance
+ + onChange('accent', v)}/> + + + onChange('bg', v)} + accent={settings.accent}/> + +
+
+
Library
+ + onChange('density', v)} + accent={settings.accent}/> + + + onChange('aspect', v)} + accent={settings.accent}/> + +
+
+
+ +
+
+
+ ); +} + +Object.assign(window, { + Icon, GameCover, StateChip, ActionButton, GameCard, + SegmentedFilters, UnderlineFilters, SearchField, SortMenu, + StorageMeter, DirectoryButton, KebabMenu, GameDetailModal, + SettingsDialog, +}); diff --git a/design/design_reference/data.jsx b/design/design_reference/data.jsx new file mode 100644 index 0000000..9c18280 --- /dev/null +++ b/design/design_reference/data.jsx @@ -0,0 +1,192 @@ +// data.jsx — game catalog for the SoftLAN launcher mock +// Each game has: id, title, size (GB), version (date), description, state, players (min-max), tags, cover (color pair + optional accent shape) +// state: 'installed' | 'local' | 'none' (local = downloaded but not installed yet) + +const GAMES = [ + { + id: '8bitarmies', title: '8-Bit Armies', size: 1.9, version: '2016.10.24', + desc: "A fast-paced retro-styled RTS with bright voxel armies, three factions, and zero patience for slow players. Tank-rush, build queues, and a campaign that doesn't waste your time.", + state: 'installed', players: '1–8', tags: ['RTS', 'Multiplayer', 'LAN'], + cover: { c1: '#f59e0b', c2: '#b91c1c', accent: '#fde047', mood: 'arcade' }, + }, + { + id: 'aoe2hd', title: 'Age of Empires II (HD)', size: 8.6, version: '2018.01.31', + desc: "The HD remaster of the strategy classic. Lead one of thirteen civilizations from the dark ages through the imperial age, and finally settle who actually deserved that wonder.", + state: 'local', players: '1–8', tags: ['RTS', 'Historical', 'LAN'], + cover: { c1: '#7c2d12', c2: '#1c1917', accent: '#fbbf24', mood: 'gothic' }, + }, + { + id: 'avp', title: 'Aliens vs. Predator', size: 35.0, version: '2019.10.01', + desc: "Three campaigns, three nightmares. Be the alien stalking the dark, the predator hunting both, or the marine just trying to make it home with a working flashlight.", + state: 'none', players: '2–16', tags: ['FPS', 'Horror', 'Multiplayer'], + cover: { c1: '#064e3b', c2: '#020617', accent: '#34d399', mood: 'dark' }, + }, + { + id: 'amongus', title: 'Among Us', size: 0.3, version: '2021.11.05', + desc: "Crewmates fix the ship. Impostors sabotage it and vent through walls. Friendships are tested. The orange one is always sus.", + state: 'installed', players: '4–15', tags: ['Social Deduction', 'Casual'], + cover: { c1: '#ef4444', c2: '#1e3a8a', accent: '#fef08a', mood: 'playful' }, + }, + { + id: 'bf1942', title: 'Battlefield 1942', size: 7.8, version: '2016.01.30', + desc: "The original Battlefield. WWII on land, sea, and air across 16 maps. The mod scene basically reinvented PC gaming on top of this engine.", + state: 'installed', players: '2–64', tags: ['FPS', 'Vehicles', 'LAN'], + cover: { c1: '#92400e', c2: '#1c1917', accent: '#facc15', mood: 'war' }, + }, + { + id: 'bf2', title: 'Battlefield 2 Complete', size: 8.0, version: '2021.12.27', + desc: "Modern combat with commander mode, squads, and the kind of jet-vs-jet duels you tell stories about for a decade.", + state: 'local', players: '2–64', tags: ['FPS', 'Vehicles', 'Tactical'], + cover: { c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee', mood: 'tactical' }, + }, + { + id: 'blazerush', title: 'BlazeRush', size: 1.3, version: '2021.12.27', + desc: "Top-down arcade racing with no fuel, no health bar, and absolutely no brakes. Ram, boost, win.", + state: 'none', players: '1–8', tags: ['Racing', 'Arcade'], + cover: { c1: '#f97316', c2: '#7c2d12', accent: '#fde047', mood: 'arcade' }, + }, + { + id: 'cod2', title: 'Call of Duty 2', size: 7.0, version: '2016.09.22', + desc: "WWII shooter — Russian, British and American campaigns, plus the multiplayer that defined LAN parties for years.", + state: 'installed', players: '2–32', tags: ['FPS', 'War'], + cover: { c1: '#57534e', c2: '#1c1917', accent: '#fbbf24', mood: 'war' }, + }, + { + id: 'cod4mw', title: 'Call of Duty 4: Modern Warfare', size: 13.0, version: '2016.09.21', + desc: "The shooter that flipped the genre to modern combat and minted a generation of esports careers. All Ghillied Up still holds up.", + state: 'local', players: '2–32', tags: ['FPS', 'Modern'], + cover: { c1: '#525252', c2: '#0a0a0a', accent: '#84cc16', mood: 'tactical' }, + }, + { + id: 'coduo', title: 'Call of Duty: United Offensive', size: 3.8, version: '2018.09.08', + desc: "Expansion to the original CoD. Battle of the Bulge, Sicily, Kursk. Adds tanks, B-17 sequences, and the flamethrower nobody asked for but everybody loved.", + state: 'none', players: '2–32', tags: ['FPS', 'Expansion'], + cover: { c1: '#78716c', c2: '#292524', accent: '#fb923c', mood: 'war' }, + }, + { + id: 'ra3', title: 'C&C: Red Alert 3', size: 8.2, version: '2018.04.12', + desc: "Cold War alt-history RTS. Soviets, Allies, and the Empire of the Rising Sun — every cutscene live-action and absolutely deranged.", + state: 'installed', players: '1–6', tags: ['RTS', 'Co-op'], + cover: { c1: '#991b1b', c2: '#450a0a', accent: '#fde047', mood: 'propaganda' }, + }, + { + id: 'cncgen', title: 'C&C Generals: Zero Hour', size: 2.1, version: '2017.11.15', + desc: "Modern-warfare RTS expansion with generals challenges. USA, China, GLA — pick your asymmetry and rush something.", + state: 'local', players: '1–8', tags: ['RTS', 'Modern'], + cover: { c1: '#a16207', c2: '#422006', accent: '#facc15', mood: 'war' }, + }, + { + id: 'cs', title: 'Counter-Strike 1.6', size: 0.7, version: '2014.01.21', + desc: "The 1.6 build still everyone insists was the peak. Terrorists vs Counter-Terrorists, AWP camping, de_dust2.", + state: 'installed', players: '2–32', tags: ['FPS', 'Competitive', 'LAN'], + cover: { c1: '#1e40af', c2: '#0c1f3a', accent: '#fbbf24', mood: 'tactical' }, + }, + { + id: 'css', title: 'Counter-Strike: Source', size: 4.3, version: '2014.10.23', + desc: "CS reborn on the Source engine. Same maps, same rules, with physics that lets the molotovs work properly.", + state: 'installed', players: '2–32', tags: ['FPS', 'Competitive'], + cover: { c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b', mood: 'tactical' }, + }, + { + id: 'cube2', title: 'Cube 2: Sauerbraten', size: 0.4, version: '2013.09.20', + desc: "Open-source arena FPS with in-game level editing. Fast, free, and one of those things every LAN party always had a copy of.", + state: 'none', players: '2–16', tags: ['FPS', 'Open Source'], + cover: { c1: '#dc2626', c2: '#7f1d1d', accent: '#f1f5f9', mood: 'arcade' }, + }, + { + id: 'doom3', title: 'Doom 3', size: 2.2, version: '2012.01.31', + desc: "Sci-fi horror reboot of the franchise. Mars Research Facility, demonic incursion, the shotgun that started a flashlight debate.", + state: 'local', players: '2–16', tags: ['FPS', 'Horror'], + cover: { c1: '#7f1d1d', c2: '#000000', accent: '#f97316', mood: 'dark' }, + }, + { + id: 'l4d2', title: 'Left 4 Dead 2', size: 6.5, version: '2020.08.15', + desc: "Co-op zombie survival with the AI Director rewriting every campaign run. Bring four friends or four strangers; the chainsaw works the same.", + state: 'installed', players: '1–8', tags: ['Co-op', 'FPS', 'Horror'], + cover: { c1: '#15803d', c2: '#052e16', accent: '#fef08a', mood: 'horror' }, + }, + { + id: 'minecraft', title: 'Minecraft', size: 1.0, version: '2024.03.01', + desc: "Infinite voxel sandbox. Build, mine, survive, get blown up by a creeper. The LAN button is right there.", + state: 'installed', players: '2–100', tags: ['Sandbox', 'Survival', 'LAN'], + cover: { c1: '#15803d', c2: '#7c2d12', accent: '#fde68a', mood: 'sandbox' }, + }, + { + id: 'portal2', title: 'Portal 2', size: 11.0, version: '2014.01.01', + desc: "Puzzle-shooter sequel with a full co-op campaign — two players, two portals each, infinite ways to get GLaDOS to insult you.", + state: 'local', players: '1–2', tags: ['Puzzle', 'Co-op'], + cover: { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee', mood: 'tech' }, + }, + { + id: 'quake3', title: 'Quake III Arena', size: 0.5, version: '2010.08.15', + desc: "Arena FPS in its most distilled form. Rocket jumps, rail-gun duels, and a netcode that's still the benchmark.", + state: 'none', players: '2–16', tags: ['FPS', 'Arena', 'LAN'], + cover: { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#fbbf24', mood: 'dark' }, + }, + { + id: 'starcraft', title: 'StarCraft: Brood War', size: 1.2, version: '2018.04.16', + desc: "Sci-fi RTS — Terran, Zerg, Protoss. Still played at the highest level decades later for a reason.", + state: 'installed', players: '1–8', tags: ['RTS', 'Sci-Fi'], + cover: { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee', mood: 'scifi' }, + }, + { + id: 'tf2', title: 'Team Fortress 2', size: 22.0, version: '2023.06.18', + desc: "Class-based shooter with nine archetypes, absurd hats, and a meta with more history than most actual sports.", + state: 'local', players: '2–32', tags: ['FPS', 'Class-based'], + cover: { c1: '#b45309', c2: '#7c2d12', accent: '#fbbf24', mood: 'cartoon' }, + }, + { + id: 'ut2k4', title: 'Unreal Tournament 2004', size: 5.2, version: '2012.06.01', + desc: "Arena shooter at maximum velocity. Onslaught, Assault, Bombing Run — vehicles, jump boots, the announcer screaming HEADSHOT.", + state: 'none', players: '2–32', tags: ['FPS', 'Arena'], + cover: { c1: '#854d0e', c2: '#422006', accent: '#fde047', mood: 'arena' }, + }, + { + id: 'warcraft3', title: 'Warcraft III: TFT', size: 1.3, version: '2018.03.25', + desc: "Hero-driven RTS whose custom-game scene birthed Dota, tower defense, and at least three other genres that ate the industry.", + state: 'installed', players: '1–12', tags: ['RTS', 'Fantasy'], + cover: { c1: '#a16207', c2: '#422006', accent: '#fbbf24', mood: 'fantasy' }, + }, +]; + +// Helpers +const fmtSize = (gb) => gb < 1 ? `${Math.round(gb * 1024)} MB` : `${gb.toFixed(1)} GB`; +const STATE_META = { + installed: { label: 'Installed', dot: '#22c55e' }, + local: { label: 'Local', dot: '#f59e0b' }, + none: { label: '', dot: 'transparent' }, +}; +const ACTION_FOR_STATE = { + installed: { label: 'Play', kind: 'play' }, + local: { label: 'Install', kind: 'install' }, + none: { label: 'Download', kind: 'download' }, +}; + +const countByFilter = (games) => ({ + all: games.length, + local: games.filter(g => g.state === 'installed' || g.state === 'local').length, + installed: games.filter(g => g.state === 'installed').length, +}); + +const filterGames = (games, key) => { + if (key === 'all') return games; + if (key === 'local') return games.filter(g => g.state === 'installed' || g.state === 'local'); + if (key === 'installed') return games.filter(g => g.state === 'installed'); + return games; +}; + +// Storage figures (mock) +const STORAGE = { + installed: 78.4, // GB + local: 41.2, + free: 384.1, + total: 512, +}; + +window.GAMES = GAMES; +window.STATE_META = STATE_META; +window.ACTION_FOR_STATE = ACTION_FOR_STATE; +window.countByFilter = countByFilter; +window.filterGames = filterGames; +window.fmtSize = fmtSize; +window.STORAGE = STORAGE; diff --git a/design/design_reference/launcher.jsx b/design/design_reference/launcher.jsx new file mode 100644 index 0000000..ff062ea --- /dev/null +++ b/design/design_reference/launcher.jsx @@ -0,0 +1,112 @@ +// launcher.jsx — composes top bar + grid into a complete launcher screen +// Comes in two chrome variants: 'single' (one-row) and 'two' (two-row). + +const DIR_PATH = '/home/pfs/Desktop/eti_games_AFTER_LAN_2025'; + +function applyFilterAndSort(games, filter, sort, query) { + let g = filterGames(games, filter); + if (query.trim()) { + const q = query.toLowerCase(); + g = g.filter(x => x.title.toLowerCase().includes(q) || x.tags.some(t => t.toLowerCase().includes(q))); + } + if (sort === 'az') g = [...g].sort((a, b) => a.title.localeCompare(b.title)); + else if (sort === 'size') g = [...g].sort((a, b) => b.size - a.size); + else if (sort === 'state') { + const order = { installed: 0, local: 1, none: 2 }; + g = [...g].sort((a, b) => order[a.state] - order[b.state] || a.title.localeCompare(b.title)); + } else if (sort === 'recent') { + const order = { installed: 0, local: 1, none: 2 }; + g = [...g].sort((a, b) => order[a.state] - order[b.state] || b.version.localeCompare(a.version)); + } + return g; +} + +function Launcher({ + variant, + tweaks, setTweak, + initialFilter = 'all', initialSort = 'recent', initialQuery = '', + initialOpenGame = null, + initialSettingsOpen = false, +}) { + const { density, aspect, accent, bg } = tweaks; + const [filter, setFilter] = useState(initialFilter); + const [sort, setSort] = useState(initialSort); + const [query, setQuery] = useState(initialQuery); + const [openGame, setOpenGame] = useState(initialOpenGame); + const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen); + + const counts = useMemo(() => countByFilter(GAMES), []); + const list = useMemo(() => applyFilterAndSort(GAMES, filter, sort, query), [filter, sort, query]); + + const menuItems = [ + { label: 'Settings', onClick: () => setSettingsOpen(true) }, + { label: 'Refresh library', onClick: () => {} }, + '-', + { label: 'Unpack logs', onClick: () => {} }, + { label: 'About SoftLAN', onClick: () => {} }, + ]; + + return ( +
+ {variant === 'single' ? ( +
+
+
S
+
SoftLAN
+
+ + + + + +
+ ) : ( +
+
+
+
S
+
SoftLAN Launcher
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ )} + +
+ {variant === 'single' && ( +
+
+ Showing {list.length} of {counts.all} games +
+ +
+ )} +
+ {list.map(g => ( + setOpenGame(game)}/> + ))} +
+
+ + {openGame && setOpenGame(null)}/>} + {settingsOpen && setTweak && ( + setSettingsOpen(false)}/> + )} +
+ ); +} + +window.Launcher = Launcher; diff --git a/design/design_reference/styles.css b/design/design_reference/styles.css new file mode 100644 index 0000000..ed58719 --- /dev/null +++ b/design/design_reference/styles.css @@ -0,0 +1,931 @@ +/* SoftLAN Launcher — styles + Steam-like dark UI, blue accent (configurable). System sans for UI, + Bebas Neue for cover-art display type. +*/ + +@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap'); + +:root { + --accent: #3b82f6; + --bg-0: #0a0e13; + --bg-1: #0f151c; + --bg-2: #131b25; + --bg-3: #1a2330; + --bg-4: #232f3e; + --bd-1: rgba(255,255,255,0.06); + --bd-2: rgba(255,255,255,0.10); + --bd-3: rgba(255,255,255,0.16); + --t-1: #e6edf3; + --t-2: #9aa6b4; + --t-3: #6b7785; + --t-4: #4a5663; + --ok: #22c55e; + --warn: #f59e0b; + --danger: #ef4444; + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", Inter, system-ui, sans-serif; + --font-display: "Bebas Neue", "Oswald", Impact, "Arial Narrow Bold", sans-serif; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; +} + +* { box-sizing: border-box; } + +/* ─── Launcher root ─── */ +.launcher { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-0); + color: var(--t-1); + font-family: var(--font-ui); + font-size: 13px; + line-height: 1.4; + overflow: hidden; + position: relative; + isolation: isolate; +} + +/* Background variants */ +.bg-flat { background: var(--bg-0); } +.bg-gradient { + background: + radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in srgb, var(--accent) 22%, transparent) 0%, transparent 60%), + linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); +} +.bg-animated { + background: + radial-gradient(ellipse 60% 40% at 20% 0%, color-mix(in srgb, var(--accent) 24%, transparent) 0%, transparent 55%), + radial-gradient(ellipse 55% 40% at 85% 8%, color-mix(in srgb, var(--accent) 16%, transparent) 0%, transparent 55%), + linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); + background-size: 100% 100%; + animation: bgshift 18s ease-in-out infinite alternate; +} +@keyframes bgshift { + 0% { background-position: 0% 0%, 0% 0%, 0% 0%; } + 100% { background-position: 10% 4%, -6% 2%, 0% 0%; } +} + +/* ─── Top bar — shared ─── */ +.topbar { + position: relative; + z-index: 10; + background: rgba(10,14,19,0.65); + -webkit-backdrop-filter: blur(20px) saturate(140%); + backdrop-filter: blur(20px) saturate(140%); + border-bottom: 1px solid var(--bd-1); +} + +/* Variant 1: single row */ +.topbar-single { + display: flex; + align-items: center; + gap: 18px; + padding: 14px 24px; + flex-wrap: nowrap; +} + +/* Variant 2: two row */ +.topbar-two .topbar-row { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 24px; +} +.topbar-two .topbar-row1 { + border-bottom: 1px solid var(--bd-1); +} +.topbar-two .topbar-row1-right { margin-left: auto; display: flex; align-items: center; gap: 16px; } +.topbar-two .topbar-row2 { padding-top: 4px; padding-bottom: 4px; } +.topbar-two .topbar-row2-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } + +/* ─── Brand ─── */ +.brand { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} +.brand-mark { + width: 28px; height: 28px; + border-radius: 7px; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 20px; + letter-spacing: 0.02em; + color: white; + box-shadow: 0 6px 20px -6px color-mix(in srgb, var(--accent) 60%, black), inset 0 1px 0 rgba(255,255,255,0.22); +} +.brand-name { + font-weight: 700; + font-size: 15px; + letter-spacing: -0.005em; + color: var(--t-1); +} +.brand-name-soft { color: var(--t-3); font-weight: 500; margin-left: 4px; } + +/* ─── Segmented filters (variant 1) ─── */ +.seg { + position: relative; + display: inline-flex; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 999px; + padding: 4px; + flex-shrink: 0; +} +.seg-thumb { + position: absolute; + top: 4px; bottom: 4px; + border-radius: 999px; + transition: left .22s cubic-bezier(.4,1.2,.5,1), width .22s cubic-bezier(.4,1.2,.5,1), background .15s; + box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, black); +} +.seg-btn { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: transparent; + color: var(--t-2); + border: 0; + border-radius: 999px; + font: inherit; + font-weight: 600; + font-size: 12.5px; + cursor: pointer; + white-space: nowrap; + transition: color .15s; +} +.seg-btn:hover { color: var(--t-1); } +.seg-btn.is-active { color: white; } +.seg-count { + font-size: 11px; + font-weight: 700; + padding: 1px 6px; + border-radius: 999px; + background: rgba(255,255,255,0.08); + color: inherit; + font-variant-numeric: tabular-nums; +} +.seg-btn.is-active .seg-count { background: rgba(0,0,0,0.25); color: white; } + +/* ─── Underline filters (variant 2) ─── */ +.utabs { + display: flex; + align-items: stretch; + gap: 4px; +} +.utab { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px 12px; + background: transparent; + border: 0; + color: var(--t-2); + font: inherit; + font-weight: 600; + font-size: 13.5px; + cursor: pointer; + transition: color .15s; +} +.utab:hover { color: var(--t-1); } +.utab.is-active { color: var(--t-1); } +.utab-count { + font-size: 11.5px; + font-weight: 600; + padding: 1px 7px; + border-radius: 999px; + background: rgba(255,255,255,0.06); + color: var(--t-3); + font-variant-numeric: tabular-nums; +} +.utab.is-active .utab-count { color: var(--t-1); background: rgba(255,255,255,0.10); } +.utab-underline { + position: absolute; + left: 12px; right: 12px; + bottom: 0; + height: 2px; + border-radius: 2px 2px 0 0; + opacity: 0; + transform: scaleX(0.4); + transform-origin: center; + transition: opacity .2s, transform .25s cubic-bezier(.4,1.2,.5,1); +} +.utab.is-active .utab-underline { opacity: 1; transform: scaleX(1); } + +/* ─── Search ─── */ +.search { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + height: 36px; + min-width: 220px; + color: var(--t-3); + transition: border-color .15s, background .15s, box-shadow .15s; +} +.search-wide { min-width: 320px; flex: 0 1 380px; } +.search:focus-within { + border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); + background: var(--bg-1); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); +} +.search:focus-within { color: var(--t-1); } +.search input { + flex: 1; min-width: 0; + background: transparent; border: 0; outline: 0; + color: var(--t-1); + font: inherit; + font-size: 13px; +} +.search input::placeholder { color: var(--t-3); } +.search-kbd { + display: inline-grid; place-items: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 4px; + background: rgba(255,255,255,0.06); + border: 1px solid var(--bd-1); + font-size: 11px; + color: var(--t-3); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +.search:focus-within .search-kbd { opacity: 0.4; } + +/* ─── Sort menu ─── */ +.sort { position: relative; flex-shrink: 0; } +.sort-btn { + display: inline-flex; align-items: center; gap: 8px; + height: 36px; padding: 0 12px; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + color: var(--t-2); + font: inherit; font-size: 12.5px; + cursor: pointer; + transition: border-color .15s, color .15s; +} +.sort-btn:hover { color: var(--t-1); border-color: var(--bd-2); } +.sort-btn strong { color: var(--t-1); font-weight: 600; } +.sort-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 200px; + padding: 4px; + background: var(--bg-3); + border: 1px solid var(--bd-2); + border-radius: 10px; + box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04); +} +.sort-menu button { + display: flex; align-items: center; gap: 8px; + width: 100%; + padding: 8px 10px; + background: transparent; + border: 0; + border-radius: 6px; + color: var(--t-1); + font: inherit; font-size: 12.5px; + text-align: left; + cursor: pointer; +} +.sort-menu button:hover { background: rgba(255,255,255,0.06); } +.sort-check { width: 14px; display: inline-grid; place-items: center; color: var(--accent); } + +/* ─── Directory button ─── */ +.dirbtn { + display: inline-flex; align-items: center; gap: 8px; + height: 36px; padding: 0 12px; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + color: var(--t-2); + font: inherit; font-size: 12.5px; + cursor: pointer; + max-width: 360px; + transition: border-color .15s, color .15s; + flex-shrink: 1; + min-width: 0; +} +.dirbtn:hover { border-color: var(--bd-2); color: var(--t-1); } +.dirbtn-label { color: var(--t-1); font-weight: 600; flex-shrink: 0; } +.dirbtn-path { + color: var(--t-3); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* ─── Kebab menu ─── */ +.kebab { position: relative; } +.kebab-btn { + width: 36px; height: 36px; + display: grid; place-items: center; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + color: var(--t-2); + cursor: pointer; + transition: color .15s, border-color .15s; +} +.kebab-btn:hover { color: var(--t-1); border-color: var(--bd-2); } +.kebab-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 180px; + padding: 4px; + background: var(--bg-3); + border: 1px solid var(--bd-2); + border-radius: 10px; + box-shadow: 0 16px 40px -8px rgba(0,0,0,0.5); +} +.kebab-menu button { + display: block; + width: 100%; + padding: 8px 10px; + background: transparent; + border: 0; + border-radius: 6px; + color: var(--t-1); + font: inherit; font-size: 12.5px; + text-align: left; + cursor: pointer; +} +.kebab-menu button:hover { background: rgba(255,255,255,0.06); } +.kebab-sep { height: 1px; background: var(--bd-1); margin: 4px 0; } + +/* ─── Storage meter ─── */ +.storage { + display: flex; flex-direction: column; + gap: 6px; + min-width: 240px; +} +.storage-compact { min-width: 200px; } +.storage-bar { + position: relative; + height: 6px; + background: rgba(255,255,255,0.06); + border-radius: 3px; + overflow: hidden; +} +.storage-i { position: absolute; top: 0; left: 0; bottom: 0; } +.storage-l { + position: absolute; top: 0; bottom: 0; + left: calc((var(--installed-pct, 15.3)) * 1%); +} +.storage-compact .storage-bar { height: 4px; } +.storage-text { + display: flex; align-items: center; + gap: 10px; + font-size: 11px; + color: var(--t-3); + font-variant-numeric: tabular-nums; +} +.storage-text > span { display: inline-flex; align-items: center; gap: 5px; } +.storage-sq { + width: 8px; height: 8px; + border-radius: 2px; +} +.storage-free { margin-left: auto; color: var(--t-2); } + +/* ─── Grid wrapper / results bar ─── */ +.grid-wrap { + flex: 1; + overflow: auto; + padding: 18px 24px 32px; + scrollbar-width: thin; + scrollbar-color: var(--bd-3) transparent; +} +.grid-wrap::-webkit-scrollbar { width: 10px; } +.grid-wrap::-webkit-scrollbar-thumb { background: var(--bd-3); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; } + +.results-bar { + display: flex; align-items: center; justify-content: space-between; + padding: 4px 4px 16px; + gap: 16px; +} +.results-count { color: var(--t-2); font-size: 12.5px; } +.results-count strong { color: var(--t-1); font-weight: 700; } + +/* ─── Grid ─── */ +.grid { + display: grid; + gap: var(--card-gap, 16px); + grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 188px), 1fr)); +} +.density-compact { --card-min: 148px; --card-gap: 12px; } +.density-normal { --card-min: 188px; --card-gap: 16px; } +.density-large { --card-min: 244px; --card-gap: 20px; } + +/* ─── Card ─── */ +.card { + position: relative; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%); + border: 1px solid var(--bd-1); + border-radius: var(--radius-md); + cursor: pointer; + overflow: hidden; + transition: transform .18s cubic-bezier(.4,1.2,.5,1), border-color .18s, box-shadow .18s; + outline: 0; +} +.card:hover, .card:focus-visible { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2)); + box-shadow: + 0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black), + 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); +} +.card:focus-visible { + box-shadow: + 0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black), + 0 0 0 2px var(--accent); +} + +/* ─── Cover ─── */ +.card-cover-wrap { + position: relative; + width: 100%; + overflow: hidden; + background: var(--bg-3); +} +.card-cover-wrap[data-aspect="box"] { aspect-ratio: 2 / 3; } +.card-cover-wrap[data-aspect="square"] { aspect-ratio: 1 / 1; } +.card-cover-wrap[data-aspect="banner"] { aspect-ratio: 16 / 9; } + +.cover { + position: absolute; inset: 0; + overflow: hidden; + transition: transform .35s cubic-bezier(.4,1.2,.5,1); +} +.card:hover .cover { transform: scale(1.03); } +.cover-base, .cover-blob, .cover-grain, .cover-vignette, .cover-mark { position: absolute; inset: 0; pointer-events: none; } +.cover-grain { + background-image: + repeating-linear-gradient(0deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 3px), + repeating-linear-gradient(90deg, rgba(0,0,0,0.04) 0 1px, transparent 1px 3px); + mix-blend-mode: overlay; + opacity: 0.7; +} +.cover-vignette { + background: linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%); +} +.cover-mark { width: 100%; height: 100%; } +.cover-titlewrap { + position: absolute; + left: 0; right: 0; bottom: 0; + z-index: 2; + padding: 14px 14px 14px; +} +.cover-title { + font-family: var(--font-display); + font-weight: 400; + letter-spacing: 0.018em; + line-height: 1.02; + text-transform: uppercase; + color: white; + overflow-wrap: normal; + word-break: normal; +} +.cover-sub { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + opacity: 0.85; +} + +/* banner mode: title centered + smaller padding */ +.card-cover-wrap[data-aspect="banner"] .cover-titlewrap { + padding: 14px 18px; +} + +/* ─── State chip ─── */ +.state-chip { + position: absolute; + top: 10px; right: 10px; + z-index: 3; + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 9px; + border-radius: 999px; + background: rgba(8,12,16,0.78); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.08); + font-size: 10.5px; + font-weight: 600; + color: var(--t-1); + letter-spacing: 0.01em; +} +.state-dot { width: 6px; height: 6px; border-radius: 999px; box-shadow: 0 0 8px currentColor; } +.state-chip[data-state="installed"] .state-dot { box-shadow: 0 0 8px var(--ok); } +.state-chip[data-state="local"] .state-dot { box-shadow: 0 0 8px var(--warn); } + +/* Multiplayer badge */ +.card-mp { + position: absolute; + top: 10px; left: 10px; + z-index: 3; + display: inline-flex; align-items: center; gap: 4px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(8,12,16,0.65); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.06); + font-size: 10.5px; + font-weight: 600; + color: var(--t-1); + font-variant-numeric: tabular-nums; +} + +/* ─── Card body ─── */ +.card-body { + padding: 11px 12px 12px; + display: flex; flex-direction: column; + gap: 8px; +} +.card-title { + font-weight: 600; + font-size: 13.5px; + color: var(--t-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.005em; +} +.card-meta { + display: flex; align-items: center; gap: 6px; + font-size: 11.5px; + color: var(--t-3); + font-variant-numeric: tabular-nums; +} +.card-meta .card-dot { opacity: 0.5; } + +.density-compact .card-body { padding: 9px 10px 10px; gap: 6px; } +.density-compact .card-title { font-size: 12.5px; } +.density-compact .card-meta { font-size: 11px; } +.density-large .card-body { padding: 14px 14px 14px; gap: 10px; } +.density-large .card-title { font-size: 15px; } + +/* ─── Action buttons ─── */ +.act-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: 7px; + border: 0; + font: inherit; + font-weight: 600; + font-size: 12.5px; + letter-spacing: 0.005em; + cursor: pointer; + transition: transform .12s, filter .12s, background .15s; + white-space: nowrap; +} +.act-btn:hover { filter: brightness(1.12); } +.act-btn:active { transform: scale(0.98); } +.act-full { width: 100%; } +.act-lg { height: 44px; padding: 0 22px; font-size: 14px; gap: 8px; } +.act-lg svg { width: 14px; height: 14px; } + +.act-play { + color: white; + background: linear-gradient(180deg, #2bd07f 0%, #1aa460 100%); + box-shadow: 0 6px 16px -8px #1aa460, inset 0 1px 0 rgba(255,255,255,0.25); +} +.act-install { + color: white; + background: var(--accent); + box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--accent) 80%, black), inset 0 1px 0 rgba(255,255,255,0.22); +} +.act-download { + color: var(--t-1); + background: rgba(255,255,255,0.08); + border: 1px solid var(--bd-2); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} +.act-download:hover { background: rgba(255,255,255,0.12); border-color: var(--bd-3); } + +/* Ghost / secondary */ +.ghost-btn { + display: inline-flex; align-items: center; gap: 7px; + height: 44px; padding: 0 18px; + background: rgba(255,255,255,0.04); + border: 1px solid var(--bd-2); + border-radius: 8px; + color: var(--t-1); + font: inherit; font-size: 13.5px; font-weight: 600; + cursor: pointer; + transition: background .15s, border-color .15s, color .15s; +} +.ghost-btn:hover { background: rgba(255,255,255,0.08); border-color: var(--bd-3); } +.ghost-danger { color: #f87171; } +.ghost-danger:hover { background: rgba(239,68,68,0.10); border-color: rgba(239,68,68,0.40); color: #fca5a5; } + +/* ─── Modal ─── */ +.modal-scrim { + position: absolute; + inset: 0; + z-index: 100; + background: rgba(4,7,11,0.7); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + display: grid; + place-items: center; + padding: 32px; + animation: fadein .18s ease; +} +@keyframes fadein { from { opacity: 0 } to { opacity: 1 } } +.modal { + width: min(880px, 100%); + max-height: 100%; + background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%); + border: 1px solid var(--bd-2); + border-radius: 14px; + overflow: hidden; + position: relative; + box-shadow: 0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04); + display: flex; flex-direction: column; + animation: modalin .25s cubic-bezier(.3,1.3,.4,1); +} +@keyframes modalin { from { transform: scale(.96) translateY(8px); opacity: 0 } to { transform: scale(1) translateY(0); opacity: 1 } } +.modal-close { + position: absolute; + top: 14px; right: 14px; + z-index: 5; + width: 32px; height: 32px; + display: grid; place-items: center; + background: rgba(8,12,16,0.7); + border: 1px solid var(--bd-2); + border-radius: 8px; + color: var(--t-1); + cursor: pointer; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + transition: background .15s, border-color .15s; +} +.modal-close:hover { background: rgba(255,255,255,0.10); border-color: var(--bd-3); } +.modal-hero { + position: relative; + aspect-ratio: 16 / 7; + overflow: hidden; +} +.modal-hero .cover { transform: none !important; } +.modal-hero-fade { + position: absolute; inset: 0; + background: linear-gradient(180deg, transparent 40%, var(--bg-2) 100%); + pointer-events: none; +} +.modal-hero-text { + position: absolute; + left: 28px; right: 28px; bottom: 22px; + z-index: 2; +} +.modal-hero-text .modal-title { + font-family: var(--font-ui); + font-size: 32px; + font-weight: 700; + letter-spacing: -0.015em; + color: white; + margin: 6px 0 0; + text-shadow: 0 4px 24px rgba(0,0,0,0.6); +} +.modal-tags { display: flex; gap: 6px; flex-wrap: wrap; } +.modal-tag { + display: inline-block; + padding: 3px 8px; + background: rgba(8,12,16,0.6); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid var(--bd-2); + border-radius: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--t-1); +} +.modal-state { + position: absolute; + top: 18px; + left: 24px; + z-index: 3; +} +.modal-state .state-chip { + position: static; + font-size: 11.5px; + padding: 5px 11px; +} + +/* Banner cover treatment inside modal: hide the cover's own title (we show our own h2) */ +.modal-hero .cover-titlewrap { opacity: 0.14; } + +.modal-body { + padding: 22px 28px 26px; + display: flex; flex-direction: column; gap: 18px; +} +.modal-meta { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} +.meta-cell { + padding: 10px 12px; + background: rgba(255,255,255,0.025); + border: 1px solid var(--bd-1); + border-radius: 8px; +} +.meta-label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--t-3); +} +.meta-value { + margin-top: 4px; + font-size: 14px; + font-weight: 600; + color: var(--t-1); + display: flex; align-items: center; gap: 6px; +} +.meta-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; } + +.modal-desc { + margin: 0; + font-size: 14px; + line-height: 1.55; + color: var(--t-2); + text-wrap: pretty; + max-width: 64ch; +} +.modal-actions { + display: flex; align-items: center; gap: 10px; + padding-top: 4px; +} +.modal-actions-spacer { flex: 1; } + +/* ─── Settings dialog ─── */ +.settings-modal { + width: min(640px, 100%); + background: var(--bg-2); +} +.settings-head { + position: relative; + padding: 22px 28px 18px; + border-bottom: 1px solid var(--bd-1); +} +.settings-head h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--t-1); +} +.settings-close { + position: absolute; + top: 18px; + right: 18px; + background: transparent; +} +.settings-close:hover { background: rgba(255,255,255,0.06); } +.settings-body { + padding: 22px 28px 26px; + display: flex; flex-direction: column; + gap: 26px; + max-height: 70vh; + overflow: auto; +} +.settings-section { + display: flex; flex-direction: column; + gap: 14px; +} +.settings-section-title { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--t-3); +} +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.settings-row-info { min-width: 0; flex: 1; } +.settings-row-label { + font-size: 14px; + font-weight: 600; + color: var(--t-1); +} +.settings-row-hint { + margin-top: 3px; + font-size: 12px; + color: var(--t-3); +} +.settings-row-control { flex-shrink: 0; } +.settings-foot { + display: flex; + justify-content: flex-end; + padding: 14px 22px 18px; + border-top: 1px solid var(--bd-1); + gap: 10px; +} +.settings-done { + height: 36px; + padding: 0 22px; + font-size: 13.5px; +} +.settings-done:hover { + filter: brightness(1.1); + border-color: transparent !important; +} + +/* ─── Settings: color swatches ─── */ +.swatch-row { + display: inline-flex; + gap: 8px; +} +.swatch { + position: relative; + width: 32px; height: 32px; + padding: 0; + background: transparent; + border: 0; + border-radius: 9px; + cursor: pointer; +} +.swatch-dot { + display: block; + width: 100%; height: 100%; + border-radius: 8px; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); + transition: transform .15s, box-shadow .15s; +} +.swatch:hover .swatch-dot { transform: scale(1.06); } +.swatch.is-active .swatch-dot { + box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px currentColor; +} +.swatch-check { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: white; + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5)); + pointer-events: none; +} + +/* ─── Settings: segmented radio ─── */ +.srad { + display: inline-flex; + background: var(--bg-3); + border: 1px solid var(--bd-1); + border-radius: 8px; + padding: 3px; +} +.srad-btn { + display: inline-flex; align-items: center; + height: 30px; + padding: 0 14px; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--t-2); + font: inherit; + font-weight: 600; + font-size: 12.5px; + cursor: pointer; + transition: color .15s, background .15s; + white-space: nowrap; +} +.srad-btn:hover { color: var(--t-1); } +.srad-btn.is-active { + color: white; + box-shadow: 0 2px 8px -2px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.18); +}