markdown formatting
This commit is contained in:
+15
-14
@@ -1,9 +1,10 @@
|
||||
# SoftLAN Launcher — Design Handoff
|
||||
|
||||
**This folder is the complete, current state of design for the SoftLAN Launcher.**
|
||||
Everything an implementor needs to build the product is in here — and nothing
|
||||
that isn't. (The exploration mockups, logo concept boards, and variant studies
|
||||
live back in the project workspace; they're history, not handoff.)
|
||||
**This folder is the complete, current state of design for the SoftLAN
|
||||
Launcher.** Everything an implementor needs to build the product is in here —
|
||||
and nothing that isn't. (The exploration mockups, logo concept boards, and
|
||||
variant studies live back in the project workspace; they're history, not
|
||||
handoff.)
|
||||
|
||||
Target codebase: **Tauri + React** desktop app. The references here are
|
||||
HTML/React prototypes that communicate the intended look, layout, and behavior —
|
||||
@@ -14,7 +15,7 @@ be shipped as-is.
|
||||
|
||||
## What's inside
|
||||
|
||||
```
|
||||
```text
|
||||
design_handoff_softlan_launcher/
|
||||
├── README.md ← you are here — start here
|
||||
│
|
||||
@@ -46,15 +47,15 @@ design_handoff_softlan_launcher/
|
||||
|
||||
## Two pieces, one product
|
||||
|
||||
| | **launcher/** | **logo/** |
|
||||
|---|---|---|
|
||||
| What | The full launcher UI redesign | The brand mark + wordmark lockup |
|
||||
| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` |
|
||||
| Preview | open `launcher/design_reference/SoftLAN Launcher.html` | open `logo/demo.html` |
|
||||
| Fidelity | High — final colors/type/spacing/interactions | Final — recolors live via the `accent` token |
|
||||
| | **launcher/** | **logo/** |
|
||||
| ---------- | ------------------------------------------------------ | -------------------------------------------- |
|
||||
| What | The full launcher UI redesign | The brand mark + wordmark lockup |
|
||||
| Read first | `launcher/SPEC.md` | `logo/INTEGRATION.md` |
|
||||
| Preview | open `launcher/design_reference/SoftLAN Launcher.html` | open `logo/demo.html` |
|
||||
| Fidelity | High — final colors/type/spacing/interactions | Final — recolors live via the `accent` token |
|
||||
|
||||
The two share one design language. The **accent** color is a single token
|
||||
(`--accent`, default `#3b82f6`) that drives the launcher's primary actions *and*
|
||||
(`--accent`, default `#3b82f6`) that drives the launcher's primary actions _and_
|
||||
the logo — wire it once and both follow. The brand mark in the launcher's top
|
||||
bar (`launcher/SPEC.md` → "Top bar → Brand") **is** the logo component from
|
||||
`logo/pixel-live.jsx` at `size={28}`; the static 28px "S" in the mock is a
|
||||
@@ -80,8 +81,8 @@ placeholder for it.
|
||||
chrome. Includes **Call to Play** (rally the LAN around a game + time — live
|
||||
or scheduled, with RSVP, check-in window, and per-call chat). Open questions
|
||||
(empty/error states, logs viewer, keyboard grid nav, German strings, "server
|
||||
running" state, real-time transport for Call to Play) are listed at the end
|
||||
of `SPEC.md`.
|
||||
running" state, real-time transport for Call to Play) are listed at the end of
|
||||
`SPEC.md`.
|
||||
- **Logo:** final. Live component + static assets + horizontal lockup (dark and
|
||||
light) all included.
|
||||
|
||||
|
||||
+732
-275
@@ -1,14 +1,22 @@
|
||||
# Handoff: SoftLAN Launcher redesign
|
||||
|
||||
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog.
|
||||
A modern, gamer-friendly redesign of the SoftLAN local-network game launcher,
|
||||
replacing the current basic UI with a Steam-inspired dark layout that keeps high
|
||||
usability while adding cover art, state-coded actions, a game-detail overlay,
|
||||
and an in-app Settings dialog.
|
||||
|
||||
---
|
||||
|
||||
## About the design files
|
||||
|
||||
The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**.
|
||||
The files in `design_reference/` are **design references created in HTML/React
|
||||
via Babel-in-the-browser** — prototypes built to communicate the intended look,
|
||||
layout, and behavior. They are **not production code to copy directly**.
|
||||
|
||||
The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for:
|
||||
The target codebase is a **Tauri + React** desktop app. The task is to
|
||||
**recreate these designs inside that codebase**, using its existing patterns
|
||||
(component conventions, state management, routing, IPC to Rust for filesystem /
|
||||
process work). Use the design files for:
|
||||
|
||||
- Exact pixel/spacing/color/typography values
|
||||
- Component composition and interactions
|
||||
@@ -18,17 +26,25 @@ The target codebase is a **Tauri + React** desktop app. The task is to **recreat
|
||||
But:
|
||||
|
||||
- Don't ship the Babel-in-browser setup or import the .jsx files as-is
|
||||
- Don't keep the `<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.)
|
||||
- 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).
|
||||
**High-fidelity.** Final colors, typography, spacing, and interactions are
|
||||
decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the
|
||||
codebase's libraries/patterns. Only deviate where the codebase has its own
|
||||
dictate (e.g. an existing button primitive that's near-identical).
|
||||
|
||||
## Layout variants
|
||||
|
||||
The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only.
|
||||
The HTML mock includes two chrome variants — **A (single-row)** and **B
|
||||
(two-row)** — to choose from. **The user selected A as the primary direction.**
|
||||
Implement A. Variant B is left in the reference for context only.
|
||||
|
||||
---
|
||||
|
||||
@@ -41,31 +57,55 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
|
||||
call carries a small group **chat**. Surfaced in three places: a **Call to
|
||||
Play button** in the top bar (with an active-call badge), a persistent stack
|
||||
of **quick bars** above the grid, and a full **overlay** with per-call cards
|
||||
and a create form. Full spec in the new **"Call to Play"** section below.
|
||||
New source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN
|
||||
**peer roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`,
|
||||
`clock`, `caretUp`, `caretDown`.
|
||||
and a create form. Full spec in the new **"Call to Play"** section below. New
|
||||
source files: `calltoplay.jsx`, `ctp-chat.jsx`. New data: a mock LAN **peer
|
||||
roster** (`PEERS`) in `data.jsx`. New icons: `flag`, `chat`, `send`, `clock`,
|
||||
`caretUp`, `caretDown`.
|
||||
|
||||
## Changes since v3
|
||||
|
||||
- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
|
||||
- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below.
|
||||
- **Persisted setting renamed.** `gameFolderSet: boolean` → `gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans).
|
||||
- **Game-folder button removed from the top bar.** Setting the games directory
|
||||
is a one-time action — it doesn't deserve permanent real estate in the chrome.
|
||||
The button is gone from both top-bar variants, freeing the right zone for the
|
||||
kebab menu alone (variant A) / the storage meter + kebab pair (variant B).
|
||||
- **Game folder moved into Settings → Library.** Now a row inside the Settings
|
||||
dialog, styled like the other Library rows. Two visual states (set / not-set)
|
||||
carry over from the old button — see "Settings dialog → Library → Game folder"
|
||||
below.
|
||||
- **Persisted setting renamed.** `gameFolderSet: boolean` →
|
||||
`gameFolder: string | null`. The actual path is now persisted, not just a "is
|
||||
it configured?" flag. Default is `null` (unset on first run; user must pick a
|
||||
folder before the library scans).
|
||||
|
||||
## Changes since v2
|
||||
|
||||
- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows):
|
||||
- **Top bar layout reorganized.** The single-row top bar is now structured as
|
||||
three visual zones (still one row on wide windows):
|
||||
- **Left:** brand mark + wordmark.
|
||||
- **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right.
|
||||
- **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes).
|
||||
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
|
||||
- **Center (semantically the "search cluster"):** segmented filter pills ·
|
||||
search field · sort menu. The **search field is positioned at the geometric
|
||||
center of the window** — filter pills sit immediately to its left, sort menu
|
||||
immediately to its right.
|
||||
- **Right:** kebab menu (game-folder configuration has moved into Settings —
|
||||
see v3 changes).
|
||||
- Below ~1100 px of launcher width (container query), the three zones collapse
|
||||
into a single left-to-right flowing row (no wrap, no centering). Implement
|
||||
via container query on the launcher root; viewport media query is acceptable
|
||||
if your codebase doesn't use container queries yet.
|
||||
- See "Top bar (variant A)" below for the full spec and rationale.
|
||||
|
||||
## Changes since v1
|
||||
|
||||
- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys.
|
||||
- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec.
|
||||
- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay.
|
||||
- **Settings → Profile section** added at the top of the dialog with two new
|
||||
persisted preferences: **Username** (text input) and **Language** (segmented
|
||||
`English` / `Deutsch`). See "Settings dialog" below for shape + persistence
|
||||
keys.
|
||||
- **Start Server** action added to the **game detail overlay**, next to
|
||||
**Play**, for installed games that support a dedicated server. Driven by a new
|
||||
`canHostServer: true` flag on the game record. See "Detail overlay → Actions
|
||||
row" and "Game data shape" for the full spec.
|
||||
- Grid cards are **unchanged** — Start Server only ever appears in the detail
|
||||
overlay.
|
||||
|
||||
---
|
||||
|
||||
@@ -73,42 +113,102 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
|
||||
|
||||
### 1. Main library (variant A — primary)
|
||||
|
||||
The default screen. A grid of game cards over a dark, gradient-tinted background.
|
||||
The default screen. A grid of game cards over a dark, gradient-tinted
|
||||
background.
|
||||
|
||||
**Layout (top-to-bottom):**
|
||||
|
||||
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other.
|
||||
|
||||
1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent
|
||||
dark with backdrop-blur. Background `rgba(10,14,19,0.65)` +
|
||||
`backdrop-filter: blur(20px) saturate(140%)`. Border-bottom
|
||||
`1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a
|
||||
3-column CSS grid —
|
||||
`grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with
|
||||
`column-gap: 16px` — putting the search field in the middle (auto-sized)
|
||||
column so it sits at the **geometric center of the window** regardless of how
|
||||
wide the side groups are. The side columns are each
|
||||
`display: flex; justify-content: space-between` so their contents pin to the
|
||||
outer edge on one end and hug the search on the other.
|
||||
- **Left zone (col 1, flex space-between):**
|
||||
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
|
||||
- **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons:
|
||||
- **Brand** (pinned far-left) — 28×28 px rounded square in `--accent`
|
||||
(default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next
|
||||
to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`.
|
||||
- **Segmented filter pills** (pinned right, hugging the search field) —
|
||||
pill-shaped container (`background var(--bg-2) #131b25`,
|
||||
`1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`,
|
||||
`padding: 4px`). Three buttons:
|
||||
- `All Games` · count chip
|
||||
- `Local` · count chip
|
||||
- `Installed` · count chip
|
||||
|
||||
Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`.
|
||||
Active button has an animated pill thumb (background `var(--accent)`,
|
||||
transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220
|
||||
ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`.
|
||||
Inactive: text `var(--t-2) #9aa6b4`, count-chip background
|
||||
`rgba(255,255,255,0.08)`.
|
||||
|
||||
`Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network.
|
||||
`Local` = installed _or_ downloaded-but-not-yet-installed. `Installed` =
|
||||
installed only. `All Games` = everything available on the network.
|
||||
|
||||
The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder.
|
||||
The filter is grouped semantically with the search — it scopes what the
|
||||
user is searching, so it belongs at the search field's left shoulder.
|
||||
|
||||
- **Center zone (col 2, search alone):**
|
||||
- **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search.
|
||||
- **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so
|
||||
it can't elbow into the side zones). `background var(--bg-2)`,
|
||||
`1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading
|
||||
magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint
|
||||
(`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font
|
||||
`11px ui-monospace`). On focus: border
|
||||
`color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background
|
||||
`var(--bg-1)`, ring
|
||||
`box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`.
|
||||
The `/` key shortcut should focus the search.
|
||||
|
||||
- **Right zone (col 3, flex space-between with two sub-groups):**
|
||||
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
|
||||
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
|
||||
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active, non-terminal calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
|
||||
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface
|
||||
style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and
|
||||
11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`,
|
||||
`Size (largest)`, `Recently Played`, `Status`. This is the only thing on
|
||||
the _left_ side of the right zone — it's part of the search cluster, so
|
||||
it hugs the search.
|
||||
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface
|
||||
as search. Menu items: `Settings` (opens Settings dialog),
|
||||
`Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the
|
||||
only "app-level" control left in the top bar; the game-folder picker has
|
||||
moved into Settings.
|
||||
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the
|
||||
far-right sub-group) — 36 px pill, flag icon + `Call to Play` label.
|
||||
Carries an accent-filled **badge** with the count of active, non-terminal
|
||||
calls. Opens the Call to Play overlay. See the **"Call to Play"** section
|
||||
for the full feature. In variant B it lives in row 1's right group,
|
||||
between the storage meter and the kebab.
|
||||
|
||||
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
|
||||
**Narrow-window fallback** (container width < 1100 px): the grid is replaced
|
||||
by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items
|
||||
align left-to-right in source order (brand → filter → search → sort → kebab).
|
||||
The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The
|
||||
geometric centering is abandoned at narrow widths because there isn't enough
|
||||
horizontal slack for it to read cleanly. Implement via container query
|
||||
(`@container launcher (max-width: 1100px)`) on the launcher root; a viewport
|
||||
media query is an acceptable fallback if you're not using container queries
|
||||
yet.
|
||||
|
||||
2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between:
|
||||
- Left: `Showing <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%)`.
|
||||
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.
|
||||
3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default
|
||||
density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls
|
||||
vertically.
|
||||
- Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` →
|
||||
min 244, gap 20.
|
||||
|
||||
**Game card** (see "Game card" below for full anatomy).
|
||||
|
||||
@@ -116,54 +216,106 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
|
||||
|
||||
### 2. Game detail overlay
|
||||
|
||||
Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card).
|
||||
Opens when the user **clicks anywhere on a game card except the action button**.
|
||||
Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should
|
||||
also work via keyboard nav (Enter on focused card).
|
||||
|
||||
**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered.
|
||||
**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`,
|
||||
`background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms.
|
||||
Padding 32px, content centered.
|
||||
|
||||
**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`.
|
||||
**Modal panel:** `min(880px, 100%)` wide,
|
||||
`background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`,
|
||||
`1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow
|
||||
`0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms
|
||||
`cubic-bezier(.3,1.3,.4,1)`.
|
||||
|
||||
**Modal structure (top-to-bottom):**
|
||||
|
||||
1. **Hero banner** — `aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads.
|
||||
- **State chip** in the top-left of the hero (same chip style as on cards — see Game Card).
|
||||
- **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon.
|
||||
- **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`:
|
||||
- Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`)
|
||||
- **Title** as `<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.
|
||||
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 `<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`.
|
||||
- **Meta grid** — 4-column CSS grid, 12px gap. Each cell:
|
||||
`padding 10px 12px`, `background rgba(255,255,255,0.025)`,
|
||||
`1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size`
|
||||
(e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12),
|
||||
`Status` (Installed / Local / Not downloaded).
|
||||
- **Description** — 14px / 1.55 line-height, `var(--t-2)`,
|
||||
`text-wrap: pretty`, `max-width: 64ch`.
|
||||
- **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right:
|
||||
1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state).
|
||||
2. **Start Server** — *only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub).
|
||||
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
|
||||
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling).
|
||||
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling).
|
||||
1. **Primary action button** (44px tall, see "Action button" below — Play /
|
||||
Install / Download depending on state).
|
||||
2. **Start Server** — _only_ when `game.canHostServer === true` **and**
|
||||
`state === 'installed'`. Same 44px height as Play, but visually a peer
|
||||
secondary action (see "Start Server button" below). Triggers a Tauri
|
||||
command that spawns the game's dedicated-server executable in headless
|
||||
mode against the local LAN (port + server config out of scope here —
|
||||
leave a `startServer(gameId)` IPC stub).
|
||||
3. If `state === 'installed'`: ghost-button **Uninstall** — 44px,
|
||||
`background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`,
|
||||
`border-radius: 8px`, text `#f87171`, trash icon. On hover: bg
|
||||
`rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`.
|
||||
4. If `state === 'local'`: ghost-button **Delete from disk** (same danger
|
||||
styling).
|
||||
5. If `state === 'downloading'`: ghost-button **Cancel** (same danger
|
||||
styling).
|
||||
6. Spacer (`flex: 1`).
|
||||
7. Ghost-button **View files** (neutral) — opens system file manager at the game folder.
|
||||
7. Ghost-button **View files** (neutral) — opens system file manager at the
|
||||
game folder.
|
||||
|
||||
#### Start Server button
|
||||
|
||||
A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention.
|
||||
A secondary-but-equal action that sits next to **Play**. The intent is to read
|
||||
as a host-action ("I want to put this game on the LAN") without competing with
|
||||
the green Play button for the player's primary attention.
|
||||
|
||||
- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
|
||||
- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
|
||||
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`.
|
||||
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`).
|
||||
- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn.
|
||||
- Same shape and height as Play: 44px tall, `border-radius: 8px`,
|
||||
`font 14px / 600`, 8px gap between icon and label, padding `0 22px`.
|
||||
- Surface:
|
||||
`background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`,
|
||||
`border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`,
|
||||
`box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`.
|
||||
- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded
|
||||
rectangles each with an LED dot and a hint of wiring). 13×13. SVG in
|
||||
`components.jsx → Icon.server`.
|
||||
- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border
|
||||
darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)`
|
||||
(shared with `.act-btn`).
|
||||
- A future _running_ state (live indicator dot + "Server running" label +
|
||||
click-to-stop) is **not** in this round — flag as a follow-up when wiring the
|
||||
real spawn.
|
||||
|
||||
The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards.
|
||||
The button is purposefully **not** present on game cards in the grid — hosting a
|
||||
server is intentional and benefits from the context of the detail overlay
|
||||
(player count, version, etc.). Don't add it to cards.
|
||||
|
||||
---
|
||||
|
||||
### 3. Settings dialog
|
||||
|
||||
Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences.
|
||||
Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim
|
||||
treatment as the game-detail modal, but the panel is narrower
|
||||
(`min(640px, 100%)`) and styled as a list of preferences.
|
||||
|
||||
**Structure:**
|
||||
|
||||
```
|
||||
```text
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Settings [×] │ ← head: 22 28 18, 1px bottom border
|
||||
├─────────────────────────────────────────┤
|
||||
@@ -208,69 +360,151 @@ Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim tr
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap):
|
||||
**Sections** are separated by 26px gap (column flex). Rows within a section:
|
||||
14px gap. Each **row** is flex row with space-between (24px gap):
|
||||
|
||||
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`)
|
||||
- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px
|
||||
/ `--t-3`)
|
||||
- Right (`settings-row-control`): the control
|
||||
|
||||
**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings.
|
||||
**Profile section** (new in this round). Two rows, rendered **above** Appearance
|
||||
— it's the most personal/identity-shaped setting so it's the first thing the
|
||||
user sees in Settings.
|
||||
|
||||
- **Username** — `<input type="text">` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
|
||||
- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios.
|
||||
- **Username** — `<input type="text">` wrapped in a styled container: 220px
|
||||
wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`,
|
||||
`border-radius: 8px`, `padding: 0 12px`. Input itself is
|
||||
transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder
|
||||
`"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`.
|
||||
On focus the container gets `background var(--bg-2)`, border `var(--accent)`,
|
||||
and an accent focus ring
|
||||
`box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`.
|
||||
- **Language** — same segmented-radio control as Background / Density / Cover
|
||||
aspect, with two options: `English` (value `'en'`) and `Deutsch` (value
|
||||
`'de'`). Active option gets the accent fill, same as the other segmented
|
||||
radios.
|
||||
|
||||
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**.
|
||||
**Library section.** Three rows: **Game folder** (new in v3 — moved out of the
|
||||
top bar), **Grid density**, **Cover aspect**.
|
||||
|
||||
- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference.
|
||||
- **Game folder** — see "Game-folder field" below. The first row in the section
|
||||
because it's the only setting users _must_ configure for the launcher to work;
|
||||
density and aspect are pure preference.
|
||||
|
||||
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <swatch-color>` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`.
|
||||
**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32,
|
||||
`border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot
|
||||
with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06.
|
||||
**Active**: dot has ring
|
||||
`box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px <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`.
|
||||
Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green
|
||||
`#22c55e`, Amber `#f59e0b`, Red `#ef4444`.
|
||||
|
||||
**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`.
|
||||
**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`,
|
||||
`1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px
|
||||
tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive:
|
||||
`color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top
|
||||
shadow `0 1px 0 rgba(255,255,255,0.18)`.
|
||||
|
||||
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog.
|
||||
**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes
|
||||
the dialog.
|
||||
|
||||
Persisted settings (write through to local storage / Tauri config):
|
||||
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does.
|
||||
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`.
|
||||
|
||||
- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel
|
||||
free to default to the OS username on first run). Used as the network identity
|
||||
for LAN sessions; the hint copy _"Shown to other players on the LAN"_ tells
|
||||
the user what it does.
|
||||
- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce
|
||||
one if it doesn't exist yet — `react-i18next` or similar). Initial copy is
|
||||
English-only in the mock; German translations need to be added as part of
|
||||
implementation. Recommend detecting the OS locale on first run and defaulting
|
||||
to `'de'` if the system language starts with `de`.
|
||||
- `accent`: one of the six hex values above. Default `#3b82f6`.
|
||||
- `bg`: `flat` | `gradient` | `animated`. Default `gradient`.
|
||||
- `density`: `compact` | `normal` | `large`. Default `normal`.
|
||||
- `aspect`: `box` | `square` | `banner`. Default `box`.
|
||||
- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below.
|
||||
- `gameFolder`: `string | null`. Absolute path to the parent directory where
|
||||
games are downloaded and installed. Default `null` (unset on first run). See
|
||||
"Game-folder field" below.
|
||||
|
||||
---
|
||||
|
||||
## Game-folder field
|
||||
|
||||
A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live).
|
||||
A settings row inside the **Library** section of the Settings dialog. Exposes
|
||||
the user's currently-configured game folder (the parent directory under which
|
||||
all per-game subfolders live).
|
||||
|
||||
**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs.
|
||||
**Why it lives in Settings now:** users set this once at install time and
|
||||
basically never touch it again. A permanent top-bar button burned high-attention
|
||||
chrome on a control nobody used after day one. Settings is where one-time
|
||||
configuration belongs.
|
||||
|
||||
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
|
||||
Two visual states, driven by whether `settings.gameFolder` resolves to an
|
||||
accessible directory:
|
||||
|
||||
| State | Trigger | Path display | Border | Button label |
|
||||
|---|---|---|---|---|
|
||||
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
|
||||
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
|
||||
| State | Trigger | Path display | Border | Button label |
|
||||
| --------------------- | ----------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) |
|
||||
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) |
|
||||
|
||||
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
|
||||
"Invalid" is intentionally collapsed into the same visual state as "not set" —
|
||||
the user's job is identical (open the picker and pick a folder), so we don't
|
||||
differentiate. If we later need a distinct "missing" state (e.g. to show the
|
||||
_last known_ path so the user can re-attach an external drive), introduce a
|
||||
third state then; for now, keep it simple.
|
||||
|
||||
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right:
|
||||
**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`,
|
||||
`padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`,
|
||||
`border-radius: 8px`. Children, left to right:
|
||||
|
||||
1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state).
|
||||
2. **Path display** — `flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
|
||||
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library.
|
||||
1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)`
|
||||
(set state) or `#f87171` (unset state).
|
||||
2. **Path display** — `flex: 1`, mono `12px / ui-monospace`, `--t-1`, single
|
||||
line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` +
|
||||
`unicode-bidi: plaintext`** so truncation happens from the head and the leaf
|
||||
folder (the part the user actually cares about) stays visible. When unset:
|
||||
shows the word `Not set` in 12.5 px / 600 / `#f87171` instead.
|
||||
3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`,
|
||||
`font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label
|
||||
`Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label
|
||||
`Choose…` (so the call-to-action reads stronger when the path needs picking).
|
||||
Click → native folder picker via Tauri; on selection, write through to
|
||||
`settings.gameFolder` and rescan library.
|
||||
|
||||
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps).
|
||||
**Hover:** border darkens to `--bd-2` (set state) or to
|
||||
`color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner
|
||||
button has its own hover (background opacity bumps).
|
||||
|
||||
**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated.
|
||||
**Accessibility:** the path itself is selectable text inside the field; the
|
||||
action button carries `aria-label="Change game folder"` /
|
||||
`"Choose game folder"`. The full path is also exposed via `title` on the
|
||||
path-display element so it's reachable on hover when truncated.
|
||||
|
||||
**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring.
|
||||
**Why no inline path on the previous top-bar button anymore?** Original design
|
||||
squeezed the full path into a top-bar button as truncated mono. It rarely showed
|
||||
the meaningful part of the path on real-world configurations, ate horizontal
|
||||
space, and competed with the actual primary controls (filter / search / sort)
|
||||
for the top bar's attention budget. In the new home (Settings), the field has
|
||||
all the width it needs to show a useful prefix of the path while still keeping
|
||||
the leaf visible — and it's only on screen when the user is actively
|
||||
reconfiguring.
|
||||
|
||||
**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
|
||||
**Data:** the component takes `value: string | null` and an
|
||||
`onChange(next: string)` callback. `null` (or empty/whitespace string) renders
|
||||
the unset state; any non-empty string renders the set state. The `onChange`
|
||||
callback should fire only on successful picker confirmation (not on cancel). In
|
||||
production, derive `value` from your settings store; if you want to additionally
|
||||
validate existence, do the `fs.metadata` check in the store / a hook and pass
|
||||
`null` when the directory is missing.
|
||||
|
||||
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
|
||||
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text
|
||||
field** (under the _Library_ section) that writes directly to `t.gameFolder`.
|
||||
Type any string to simulate the set state; clear it to simulate the unset state.
|
||||
This is dev-only — in the real app the value comes from the settings store via
|
||||
the picker, **not** from a free-form text input. Don't ship the Tweaks panel.
|
||||
|
||||
---
|
||||
|
||||
@@ -278,39 +512,72 @@ Two visual states, driven by whether `settings.gameFolder` resolves to an access
|
||||
|
||||
The unit element of the library grid.
|
||||
|
||||
**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer.
|
||||
**Container:** flex column.
|
||||
`background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`,
|
||||
`1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor
|
||||
pointer.
|
||||
|
||||
**Hover/focus state:**
|
||||
|
||||
- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`)
|
||||
- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))`
|
||||
- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
|
||||
- Box-shadow
|
||||
`0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)`
|
||||
- Cover inner image scales to 1.03 (350ms cubic-bezier)
|
||||
- Focus-visible: same lift + 2px solid accent outline
|
||||
|
||||
### Anatomy (top to bottom)
|
||||
|
||||
1. **Cover wrap** — `width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`.
|
||||
1. **Cover wrap** — `width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) /
|
||||
`16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg
|
||||
`var(--bg-3)`.
|
||||
|
||||
2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`):
|
||||
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata.
|
||||
- **Radial accent blob** — `radial-gradient(ellipse at <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).
|
||||
- **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 <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. `2–32`). Always visible — every LAN game is multiplayer.
|
||||
- **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` —
|
||||
angle hashed from game id for variety). Per-game color pair from the game's
|
||||
`cover` metadata.
|
||||
- **Radial accent blob** —
|
||||
`radial-gradient(ellipse at <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).
|
||||
- **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 <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.
|
||||
`2–32`). Always visible — every LAN game is multiplayer.
|
||||
|
||||
3. **Card body** — `padding: 11px 12px 12px`, flex column, 8px gap:
|
||||
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow.
|
||||
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity.
|
||||
- **Action button** (full width) — primary action depending on state, see below.
|
||||
- **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`,
|
||||
single line, ellipsis on overflow.
|
||||
- **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator
|
||||
at 50% opacity.
|
||||
- **Action button** (full width) — primary action depending on state, see
|
||||
below.
|
||||
|
||||
### Action button
|
||||
|
||||
A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance.
|
||||
A single button per card with the _primary action for the current state_.
|
||||
Color-coded as the main affordance for state at a glance.
|
||||
|
||||
```
|
||||
```text
|
||||
state label button style
|
||||
───────────── ────────── ────────────────────────────────────────────
|
||||
not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1)
|
||||
@@ -319,85 +586,156 @@ installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%),
|
||||
downloading — progress see "Download progress" below — the button slot is replaced with a live progress component
|
||||
```
|
||||
|
||||
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12).
|
||||
Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px`
|
||||
(card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap
|
||||
between icon and label. Icons: filled play triangle, download arrow, install
|
||||
arrow-onto-line (all 12×12).
|
||||
|
||||
Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`.
|
||||
|
||||
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons).
|
||||
**Uninstall / Delete-from-disk** are NOT on the card — only in the detail
|
||||
overlay (as ghost-danger buttons).
|
||||
|
||||
---
|
||||
|
||||
## Download progress (state === 'downloading')
|
||||
|
||||
When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `<progress>` child. Two layouts share the same primitive:
|
||||
When a game is actively downloading, the **action-button slot is replaced** by
|
||||
an inline progress component. The component is its own visual primitive
|
||||
(`DownloadProgress` in `components.jsx`); it is NOT a button with a `<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) 38–26%, transparent)`. Right edge gets a 1px accent rule + accent glow.
|
||||
- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting.
|
||||
- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active.
|
||||
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over.
|
||||
- Container: `border-radius: 7px` (card) / `9px` (modal),
|
||||
`1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent
|
||||
halo via `box-shadow`. `container-type: inline-size` (we use container queries
|
||||
for graceful fallback, see below).
|
||||
- **Progress fill** (`.dl-fill`): absolutely positioned, `width: <pct>%`,
|
||||
animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is
|
||||
a vertical gradient of
|
||||
`color-mix(in srgb, var(--accent) 38–26%, transparent)`. Right edge gets a 1px
|
||||
accent rule + accent glow.
|
||||
- **Live shimmer** on top of the fill:
|
||||
`repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)`
|
||||
panned via `animation: dl-stripe 1.4s linear infinite`,
|
||||
`mix-blend-mode: screen`. Subtle — it reads as "live" without being
|
||||
distracting.
|
||||
- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing
|
||||
`box-shadow` ring (1.4s ease-out infinite). Visual cue that the network
|
||||
transfer is active.
|
||||
- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so
|
||||
the percentage and speed don't jitter as digits roll over.
|
||||
|
||||
### Card layout (`.dl-md`, replaces the 32px action button)
|
||||
|
||||
A single row. Two values, separated by `justify-content: space-between`:
|
||||
|
||||
- **Left:** `<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).
|
||||
- **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`).
|
||||
Heights match the action button per density: 30px compact / 32px normal / 34px
|
||||
large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see
|
||||
`styles.css`).
|
||||
|
||||
**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo:
|
||||
**Container-query graceful degradation** — this is the important part, it has to
|
||||
fit every aspect/density combo:
|
||||
|
||||
```css
|
||||
@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } }
|
||||
@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } }
|
||||
@container (max-width: 132px) {
|
||||
.dl-md .dl-speed {
|
||||
display: none;
|
||||
}
|
||||
.dl-md-row {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@container (max-width: 96px) {
|
||||
.dl-md .dl-pulse {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly.
|
||||
At 132 px and below, the speed disappears and the percentage centres. At 96 px
|
||||
and below, the pulse dot also drops, leaving just the percentage. This is what
|
||||
guarantees `compact` density + `box` aspect (the narrowest combination) still
|
||||
reads cleanly.
|
||||
|
||||
The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar.
|
||||
The state chip in the cover corner still says "Downloading" — we are
|
||||
deliberately NOT repeating that label inside the progress bar.
|
||||
|
||||
### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button)
|
||||
|
||||
Fixed 56px height. CSS-grid with three columns and two rows:
|
||||
|
||||
```
|
||||
```text
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
"primary pct cancel"
|
||||
"secondary pct cancel";
|
||||
```
|
||||
|
||||
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component.
|
||||
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity):
|
||||
1. `<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] 5` — `.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip.
|
||||
4. `8 min left` (`var(--t-2)`)
|
||||
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
|
||||
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior.
|
||||
- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live
|
||||
label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px /
|
||||
600, `letter-spacing: 0.02em`. This is the only place the word "Downloading"
|
||||
appears in the component.
|
||||
- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px,
|
||||
four groups separated by `·` (0.45 opacity):
|
||||
1. `<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] 5` — `.dl-peers`, inline-flex with 4px gap, icon at 0.7
|
||||
opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when
|
||||
`game.peers` is falsy. Communicates this is a LAN swarm transfer; the full
|
||||
sentence lives in the `title` tooltip.
|
||||
4. `8 min left` (`var(--t-2)`)
|
||||
- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`,
|
||||
`var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity.
|
||||
- **cancel column** — 28×28 square, `1px solid var(--bd-2)`,
|
||||
`border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border
|
||||
`rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its
|
||||
prior state (`local` if any data was kept, `none` otherwise) — dev decides the
|
||||
underlying behavior.
|
||||
|
||||
**Graceful degradation in narrow modals:**
|
||||
|
||||
```css
|
||||
@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } }
|
||||
@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } }
|
||||
@container (max-width: 320px) {
|
||||
.dl-lg-secondary .dl-eta,
|
||||
.dl-lg-secondary .dl-sep-eta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@container (max-width: 240px) {
|
||||
.dl-lg-secondary .dl-peers,
|
||||
.dl-lg-secondary .dl-sep-peers {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses.
|
||||
ETA drops first, then peers; bytes + speed always stay (they're the actionable
|
||||
numbers). The pct/cancel column never collapses.
|
||||
|
||||
### Number formatting
|
||||
|
||||
All helpers live in `data.jsx`:
|
||||
|
||||
- `fmtSpeed(mbps)` — `49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`.
|
||||
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact.
|
||||
- `fmtBytes(gb)` — `<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`).
|
||||
- `fmtSpeed(mbps)` — `49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100.
|
||||
Used in `.dl-lg`.
|
||||
- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the
|
||||
card stays compact.
|
||||
- `fmtBytes(gb)` — `<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing
|
||||
zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max`
|
||||
(`11.4 GB`, `35 GB`).
|
||||
- `fmtEta(seconds)` — `< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`.
|
||||
|
||||
Keep these formats; they're tuned so the secondary row never wraps at normal modal width.
|
||||
Keep these formats; they're tuned so the secondary row never wraps at normal
|
||||
modal width.
|
||||
|
||||
### Data shape
|
||||
|
||||
@@ -406,17 +744,27 @@ The `Game` type gains a `downloading` state plus two transient fields:
|
||||
```ts
|
||||
type Game = {
|
||||
// … existing fields …
|
||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
||||
progress?: number; // 0–1, only when state === 'downloading'
|
||||
speed?: number; // current throughput in MB/s
|
||||
peers?: number; // number of LAN peers currently seeding
|
||||
state: "installed" | "local" | "downloading" | "none";
|
||||
progress?: number; // 0–1, only when state === 'downloading'
|
||||
speed?: number; // current throughput in MB/s
|
||||
peers?: number; // number of LAN peers currently seeding
|
||||
};
|
||||
```
|
||||
|
||||
In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else.
|
||||
In the real app, `progress`, `speed`, and `peers` come from the download worker
|
||||
(Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in
|
||||
`components.jsx`) is just a placeholder — 600ms `setInterval` advancing
|
||||
`progress` proportional to `speed`, with `speed` smoothed via a low-pass filter
|
||||
and small random drift so the number doesn't look fake. `peers` is read straight
|
||||
off the game object (static in the mock); in production, push updates as peers
|
||||
join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the
|
||||
hook with a `useEffect` that subscribes to your real progress events; the
|
||||
rendering layer needs nothing else.
|
||||
|
||||
Filter changes:
|
||||
- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them).
|
||||
|
||||
- `Local` filter includes `installed` + `local` + `downloading` (in-flight
|
||||
downloads belong on the Local tab — you're managing them).
|
||||
- Sort by `state` orders `installed < local < downloading < none`.
|
||||
|
||||
### State chip
|
||||
@@ -444,12 +792,12 @@ Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared
|
||||
|
||||
### Two flavors of call
|
||||
|
||||
| | **Play now** | **Scheduled** |
|
||||
|---|---|---|
|
||||
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
|
||||
| Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later |
|
||||
| Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window |
|
||||
| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in |
|
||||
| | **Play now** | **Scheduled** |
|
||||
| -------------- | ----------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
|
||||
| Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later |
|
||||
| Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window |
|
||||
| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in |
|
||||
|
||||
**Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the
|
||||
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then
|
||||
@@ -465,8 +813,8 @@ the call and its history expire as a unit.
|
||||
|
||||
### Three surfaces
|
||||
|
||||
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
|
||||
an accent badge counting active, non-terminal calls. Opens the overlay.
|
||||
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label + an
|
||||
accent badge counting active, non-terminal calls. Opens the overlay.
|
||||
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
|
||||
stack rendered at the top of the grid area, **one row per active call**.
|
||||
Sorted **ready → starting-soon → the rest**, ties broken by whichever
|
||||
@@ -474,8 +822,8 @@ the call and its history expire as a unit.
|
||||
then by `deadline`). Clicking a row opens the overlay focused on that call.
|
||||
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
|
||||
the other dialogs) with a header, a **Call a new match** button, the create
|
||||
form, and a list of **nomination cards** (Running and Cancelled calls sink
|
||||
to the bottom).
|
||||
form, and a list of **nomination cards** (Running and Cancelled calls sink to
|
||||
the bottom).
|
||||
|
||||
### Status model
|
||||
|
||||
@@ -497,19 +845,34 @@ not contribute to the top-bar badge.
|
||||
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
|
||||
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
|
||||
yet) · `pending` (checked in with a `+N minutes` buffer, counting down). Shown
|
||||
as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials +
|
||||
green ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and
|
||||
as larger `AvatarChip`s in the nomination card roster.
|
||||
as compact **ready-bubbles** in the quick bars (`MiniBubbles`: initials + green
|
||||
ring/check when ready, `+Nm` tag when pending, dimmed when just "in") and as
|
||||
larger `AvatarChip`s in the nomination card roster.
|
||||
|
||||
### Nomination card (`NominationCard`)
|
||||
|
||||
Top to bottom:
|
||||
|
||||
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or `Cancelled`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
|
||||
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
|
||||
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
|
||||
4. **Roster** — `readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
|
||||
5. **Actions** — context-dependent on your role (**creator** / **participant** / **outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons, `I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a status note.
|
||||
1. **Header** — square game cover + title + a sub-line:
|
||||
`Called by <creator> · N/M peers have it installed` (or
|
||||
`Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the
|
||||
right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in
|
||||
N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or
|
||||
`Cancelled`. If catalog data is temporarily unavailable, the card still
|
||||
renders the caller, game ID, roster, chat, and coordination actions with a
|
||||
clear `Game unavailable here` label. Countdown urgency (`data-urgency`
|
||||
high/mid/low) tints it as time runs low.
|
||||
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting
|
||||
soon — check-in is open" (or a personalized nudge if you RSVP'd).
|
||||
3. **Progress bar** — time remaining as a fill (accent, → green when done);
|
||||
hidden while a call is still in the far-out `scheduled` phase.
|
||||
4. **Roster** — `readyCount/maxPlayers ready` (scheduled shows
|
||||
`N in · up to M players`; check-in adds `· K not checked in yet`), then
|
||||
avatar chips for each participant plus empty slots up to `maxPlayers`.
|
||||
5. **Actions** — context-dependent on your role (**creator** / **participant** /
|
||||
**outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons,
|
||||
`I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved),
|
||||
`Leave` / `Can't make it`, or a status note.
|
||||
6. **Chat** — the collapsible per-call chat panel.
|
||||
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
|
||||
|
||||
@@ -522,10 +885,10 @@ cancel actions are disabled.
|
||||
Game search (typeahead over the catalog) → on pick, max-players defaults to the
|
||||
game's parsed player cap (`parseMaxPlayers`). **When** toggles `Now` vs
|
||||
`Schedule`. `Now` reveals the **Give people** duration chips. `Schedule` reveals
|
||||
a **24-hour time picker** (hour/minute steppers **and** a "Type a time" free-text
|
||||
field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today + next two
|
||||
days). The confirm button reads `Call it — <game>` or `Schedule it — <game> ·
|
||||
<day> <time>`.
|
||||
a **24-hour time picker** (hour/minute steppers **and** a "Type a time"
|
||||
free-text field accepting `20:00` / `2000` / `9:30`) plus **day chips** (Today +
|
||||
next two days). The confirm button reads `Call it — <game>` or
|
||||
`Schedule it — <game> · <day> <time>`.
|
||||
|
||||
### Per-call chat (`CtpChat`, `ctp-chat.jsx`)
|
||||
|
||||
@@ -540,20 +903,30 @@ card. Usernames are colored deterministically by a hash of the name.
|
||||
type Nomination = {
|
||||
id: string;
|
||||
gameId: string;
|
||||
creatorId: string; // stable peer ID of the caller
|
||||
creator: string; // display name of the caller
|
||||
creatorId: string; // stable peer ID of the caller
|
||||
creator: string; // display name of the caller
|
||||
maxPlayers: number;
|
||||
createdAt: number; // ms epoch
|
||||
scheduledFor: number | null; // ms epoch clock time; null = play-now
|
||||
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
|
||||
participants: Record<string, { // keyed by stable peer ID
|
||||
name: string; // current display name
|
||||
status: 'ready' | 'in' | 'pending';
|
||||
joinedAt: number;
|
||||
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
||||
}>;
|
||||
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
|
||||
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||
createdAt: number; // ms epoch
|
||||
scheduledFor: number | null; // ms epoch clock time; null = play-now
|
||||
deadline: number; // play-now: createdAt + durationMin; scheduled: === scheduledFor
|
||||
participants: Record<
|
||||
string,
|
||||
{
|
||||
// keyed by stable peer ID
|
||||
name: string; // current display name
|
||||
status: "ready" | "in" | "pending";
|
||||
joinedAt: number;
|
||||
readyAt?: number; // ms epoch a 'pending' buffer elapses
|
||||
}
|
||||
>;
|
||||
messages: {
|
||||
id: string;
|
||||
fromId: string;
|
||||
from: string;
|
||||
text: string;
|
||||
at: number;
|
||||
}[];
|
||||
state: "open" | "done" | "running" | "cancelled";
|
||||
terminalAt: number | null;
|
||||
};
|
||||
```
|
||||
@@ -566,11 +939,11 @@ The `useNominations({ username, seed })` hook owns the list and exposes
|
||||
|
||||
The mock **simulates other people** with a 1-second `setInterval`
|
||||
(`tickNomination`): bots RSVP to scheduled calls, ready-up during check-in, walk
|
||||
in late, and occasionally post a chat line — purely so the demo resolves
|
||||
visibly (bots use second-scale buffers; real people use the minute-scale ones).
|
||||
The mock also seeds a few representative calls on mount (a live call with chat,
|
||||
a fresh call you started, a scheduled call whose check-in window just opened,
|
||||
and one scheduled for later collecting RSVPs).
|
||||
in late, and occasionally post a chat line — purely so the demo resolves visibly
|
||||
(bots use second-scale buffers; real people use the minute-scale ones). The mock
|
||||
also seeds a few representative calls on mount (a live call with chat, a fresh
|
||||
call you started, a scheduled call whose check-in window just opened, and one
|
||||
scheduled for later collecting RSVPs).
|
||||
|
||||
The production launcher uses the peer's existing QUIC control channel. Each
|
||||
create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
|
||||
@@ -584,19 +957,23 @@ removed after the five-minute post-deadline recovery period. The frontend
|
||||
reducer turns that event history into the `Nomination` state above, derives
|
||||
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
|
||||
identify actors and enforce creator controls; `settings.username` is only the
|
||||
display name. These deadlines use event wall-clock timestamps, so LAN clocks
|
||||
are assumed to be reasonably close; no clock-synchronization protocol is
|
||||
attempted.
|
||||
display name. These deadlines use event wall-clock timestamps, so LAN clocks are
|
||||
assumed to be reasonably close; no clock-synchronization protocol is attempted.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
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).
|
||||
- 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.
|
||||
|
||||
@@ -605,58 +982,84 @@ 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.
|
||||
- **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).
|
||||
- **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.
|
||||
- **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 "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
|
||||
- **Click "Change…" / "Choose…" in the Settings → Library → Game folder row** → open native folder picker via Tauri; on selection, write to `settings.gameFolder` and rescan library. The field indicates whether a valid folder is currently configured (mono path + neutral `Change…`) or not (red `Not set` + accent-filled `Choose…`) — see "Game-folder field" above.
|
||||
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
|
||||
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and
|
||||
persist immediately (no Apply button — Done just closes).
|
||||
- **Click "Change…" / "Choose…" in the Settings → Library → Game folder row** →
|
||||
open native folder picker via Tauri; on selection, write to
|
||||
`settings.gameFolder` and rescan library. The field indicates whether a valid
|
||||
folder is currently configured (mono path + neutral `Change…`) or not (red
|
||||
`Not set` + accent-filled `Choose…`) — see "Game-folder field" above.
|
||||
- **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.
|
||||
- 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.
|
||||
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"
|
||||
size: number; // GB
|
||||
version: string; // "YYYY.MM.DD"
|
||||
desc: string;
|
||||
state: 'installed' | 'local' | 'downloading' | 'none';
|
||||
progress?: number; // 0–1 — present only when state === 'downloading'
|
||||
speed?: number; // MB/s — present only when state === 'downloading'
|
||||
peers?: number; // LAN peers currently seeding
|
||||
players: string; // e.g. "2–32"
|
||||
state: "installed" | "local" | "downloading" | "none";
|
||||
progress?: number; // 0–1 — present only when state === 'downloading'
|
||||
speed?: number; // MB/s — present only when state === 'downloading'
|
||||
peers?: number; // LAN peers currently seeding
|
||||
players: string; // e.g. "2–32"
|
||||
tags: string[];
|
||||
cover: { c1: string; c2: string; accent: string; mood?: string };
|
||||
canHostServer?: boolean; // true if the game ships with a dedicated-server binary
|
||||
};
|
||||
```
|
||||
|
||||
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942, BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2, Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games (AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP, 8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the flag should come from the same per-game manifest that drives titles / sizes / cover art. Wire each entry to whatever launch command the dedicated server uses (`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like `startServer(gameId)` returning a handle or process id.
|
||||
**Server-capable games** in the mock catalog (`canHostServer: true`): BF1942,
|
||||
BF2, CoD2, CoD4, CoD:UO, CS 1.6, CS:Source, Cube 2/Sauerbraten, Doom 3, L4D2,
|
||||
Minecraft, Quake III, TF2, UT2004. RTS / social-deduction / co-op-only-P2P games
|
||||
(AoE II HD, RA3, Generals ZH, Among Us, Portal 2, StarCraft, Warcraft III, AvP,
|
||||
8-Bit Armies, BlazeRush) are not flagged — they host in-game. In production the
|
||||
flag should come from the same per-game manifest that drives titles / sizes /
|
||||
cover art. Wire each entry to whatever launch command the dedicated server uses
|
||||
(`hldsexec`, `srcds`, `minecraft_server.jar`, etc.); the IPC stub looks like
|
||||
`startServer(gameId)` returning a handle or process id.
|
||||
|
||||
**UI state:**
|
||||
|
||||
```ts
|
||||
type LauncherUI = {
|
||||
filter: 'all' | 'local' | 'installed';
|
||||
sort: 'az' | 'size' | 'recent' | 'state';
|
||||
filter: "all" | "local" | "installed";
|
||||
sort: "az" | "size" | "recent" | "state";
|
||||
query: string;
|
||||
openGameId: string | null;
|
||||
settingsOpen: boolean;
|
||||
@@ -664,21 +1067,24 @@ type LauncherUI = {
|
||||
```
|
||||
|
||||
**Persisted settings** (mirror of Settings dialog state):
|
||||
|
||||
```ts
|
||||
type LauncherSettings = {
|
||||
username: string;
|
||||
language: 'en' | 'de';
|
||||
accent: string; // hex from the curated 6-color palette
|
||||
bg: 'flat' | 'gradient' | 'animated';
|
||||
density: 'compact' | 'normal' | 'large';
|
||||
aspect: 'box' | 'square' | 'banner';
|
||||
gameFolder: string | null; // v3: moved out of top bar, persists actual path
|
||||
language: "en" | "de";
|
||||
accent: string; // hex from the curated 6-color palette
|
||||
bg: "flat" | "gradient" | "animated";
|
||||
density: "compact" | "normal" | "large";
|
||||
aspect: "box" | "square" | "banner";
|
||||
gameFolder: string | null; // v3: moved out of top bar, persists actual path
|
||||
};
|
||||
```
|
||||
|
||||
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes from the Settings dialog should write through immediately (no Apply button).
|
||||
Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes
|
||||
from the Settings dialog should write through immediately (no Apply button).
|
||||
|
||||
**Storage figures:** computed by summing game sizes per state, plus free-space query via Tauri.
|
||||
**Storage figures:** computed by summing game sizes per state, plus free-space
|
||||
query via Tauri.
|
||||
|
||||
---
|
||||
|
||||
@@ -686,32 +1092,36 @@ Persist via Tauri's plugin-store or a local JSON file in app data dir. Changes f
|
||||
|
||||
### 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 |
|
||||
| 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)
|
||||
- **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
|
||||
@@ -725,39 +1135,59 @@ Sizing reference:
|
||||
|
||||
- Card radius: 10px
|
||||
- Modal radius: 14px
|
||||
- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button)
|
||||
- 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)`
|
||||
- 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)`
|
||||
- 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.
|
||||
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`.
|
||||
In the production app, the launcher should ideally use real cover-art when
|
||||
available (fetch from IGDB / Steam / local game folder) and fall back to the
|
||||
placeholder generator for games without art. The placeholder generator is in
|
||||
`design_reference/components.jsx → GameCover`.
|
||||
|
||||
The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash, **flag**, **clock**, **chat**, **send**, **caretUp**, **caretDown**) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph drives the Start Server button; `flag` / `clock` / `chat` / `send` / `caretUp` / `caretDown` are used by Call to Play.
|
||||
The icon set (search, play, **server**, install, download, folder, kebab, sort,
|
||||
users, close, check, chevron, trash, **flag**, **clock**, **chat**, **send**,
|
||||
**caretUp**, **caretDown**) is in `design_reference/components.jsx → Icon`. They
|
||||
are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the
|
||||
codebase's existing icon library at the same visual weight. The `server` glyph
|
||||
drives the Start Server button; `flag` / `clock` / `chat` / `send` / `caretUp` /
|
||||
`caretDown` are used by Call to Play.
|
||||
|
||||
Fonts to load:
|
||||
|
||||
```html
|
||||
<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">
|
||||
<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
|
||||
|
||||
```
|
||||
```text
|
||||
design_reference/
|
||||
├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts <App>
|
||||
├── styles.css ← all visual styles (CSS custom props + components)
|
||||
@@ -774,25 +1204,52 @@ design_reference/
|
||||
```
|
||||
|
||||
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 side-by-side. Click an artboard's expand button to view it full-screen.
|
||||
- **Call to Play** (top section) — the quick-bar main view, and the same with the overlay open (live call + chat, your call, a check-in nudge, and a scheduled RSVP). Click anything to interact live — create a call, ready up, RSVP, chat.
|
||||
|
||||
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 side-by-side. Click an
|
||||
artboard's expand button to view it full-screen.
|
||||
- **Call to Play** (top section) — the quick-bar main view, and the same with
|
||||
the overlay open (live call + chat, your call, a check-in nudge, and a
|
||||
scheduled RSVP). Click anything to interact live — create a call, ready up,
|
||||
RSVP, chat.
|
||||
- **A / B** — chrome variants (A is the chosen direction)
|
||||
- **C** — detail overlay for an installed, server-capable game (Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
|
||||
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) → shows **Install + Delete from disk**
|
||||
- **E** — detail overlay for a downloading game (AvP) → shows the live progress component + **Cancel**
|
||||
- **C** — detail overlay for an installed, server-capable game
|
||||
(Counter-Strike 1.6) → shows **Play + Start Server + Uninstall**
|
||||
- **D** — detail overlay for a downloaded-but-not-installed game (CoD 4) →
|
||||
shows **Install + Delete from disk**
|
||||
- **E** — detail overlay for a downloading game (AvP) → shows the live
|
||||
progress component + **Cancel**
|
||||
- **F** — Settings dialog open, with the new **Profile** section at the top
|
||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you live-change every persisted setting (username / language / accent / background / density / aspect / game folder). In the production app these all live in the Settings dialog.
|
||||
3. The "Tweaks" floating panel in the bottom-right is dev-only — it lets you
|
||||
live-change every persisted setting (username / language / accent /
|
||||
background / density / aspect / game folder). In the production app these all
|
||||
live in the Settings dialog.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope / open questions for the developer
|
||||
|
||||
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface it as a separate window or a slide-in panel, dev's choice.
|
||||
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet). Show a centered message with a CTA to install the first game.
|
||||
- **Error state on action** — if a Download / Install fails, show inline error on the affected card (red border + retry button), and a toast.
|
||||
- **Progress state** — designed. See "Download progress" section above. The action-button slot is swapped for a live `DownloadProgress` component (card + modal variants with container-query fallback for narrow tiles). Wire it to your real progress events; the rendering layer is dev-ready.
|
||||
- **Keyboard arrow nav** — arrow keys should move focus between cards in the grid; not implemented in the mock but mentioned as a goal.
|
||||
- **"Server running" state** — once Start Server actually spawns a process, the button should switch to a *running* state (live indicator dot + "Server running" label + click-to-stop). Not designed this round — flag for follow-up alongside whatever server-status panel the app grows.
|
||||
- **Call to Play notifications** — real-time LAN transport is implemented. OS / tray notifications when a call you're in enters its check-in window remain a follow-up.
|
||||
- **German translations** — the language toggle is wired in Settings, but the catalog of translated UI strings hasn't been compiled. Stand up `react-i18next` (or equivalent) and seed `en.json` from the existing copy; `de.json` is a translation task for whoever owns localization.
|
||||
- **Unpack logs viewer** — referenced from kebab menu but not designed. Surface
|
||||
it as a separate window or a slide-in panel, dev's choice.
|
||||
- **Empty state** — when filter returns 0 games (e.g. nothing installed yet).
|
||||
Show a centered message with a CTA to install the first game.
|
||||
- **Error state on action** — if a Download / Install fails, show inline error
|
||||
on the affected card (red border + retry button), and a toast.
|
||||
- **Progress state** — designed. See "Download progress" section above. The
|
||||
action-button slot is swapped for a live `DownloadProgress` component (card +
|
||||
modal variants with container-query fallback for narrow tiles). Wire it to
|
||||
your real progress events; the rendering layer is dev-ready.
|
||||
- **Keyboard arrow nav** — arrow keys should move focus between cards in the
|
||||
grid; not implemented in the mock but mentioned as a goal.
|
||||
- **"Server running" state** — once Start Server actually spawns a process, the
|
||||
button should switch to a _running_ state (live indicator dot + "Server
|
||||
running" label + click-to-stop). Not designed this round — flag for follow-up
|
||||
alongside whatever server-status panel the app grows.
|
||||
- **Call to Play notifications** — real-time LAN transport is implemented. OS /
|
||||
tray notifications when a call you're in enters its check-in window remain a
|
||||
follow-up.
|
||||
- **German translations** — the language toggle is wired in Settings, but the
|
||||
catalog of translated UI strings hasn't been compiled. Stand up
|
||||
`react-i18next` (or equivalent) and seed `en.json` from the existing copy;
|
||||
`de.json` is a translation task for whoever owns localization.
|
||||
|
||||
+71
-42
@@ -2,12 +2,12 @@
|
||||
|
||||
The SoftLAN mark is a pixelated **“S”** (5×5 grid) that, at rest, is a static
|
||||
icon — but periodically and on hover it **comes alive**: it dissolves into a
|
||||
single segment that slithers across the board like the game *Snake*, then
|
||||
single segment that slithers across the board like the game _Snake_, then
|
||||
re-lays itself back into the S. Two shorter “glitch” flickers add variety.
|
||||
|
||||
This folder is everything an engineer/agent needs to ship it.
|
||||
|
||||
```
|
||||
```text
|
||||
logo_handoff/
|
||||
├── pixel-live.jsx ← the live React component (the deliverable)
|
||||
├── demo.html ← open in a browser to see it in motion + in context
|
||||
@@ -31,34 +31,39 @@ normal React/TS toolchain and it compiles as-is.
|
||||
|
||||
### Props
|
||||
|
||||
| prop | type | default | notes |
|
||||
|-------------|----------|-------------|-------|
|
||||
| `accent` | string | `#3b82f6` | the S color (any CSS color). Wire this to your theme accent. |
|
||||
| `size` | number | `140` | rendered width/height in px (it’s a square SVG). |
|
||||
| `idleAuto` | boolean | `true` | when true, plays a random trick by itself every `idleMin`–`idleMax` ms. |
|
||||
| `idleMin` | number | `5000` | min ms between idle auto-plays. |
|
||||
| `idleMax` | number | `11000` | max ms between idle auto-plays. |
|
||||
| prop | type | default | notes |
|
||||
| ---------- | ------- | --------- | ----------------------------------------------------------------------- |
|
||||
| `accent` | string | `#3b82f6` | the S color (any CSS color). Wire this to your theme accent. |
|
||||
| `size` | number | `140` | rendered width/height in px (it’s a square SVG). |
|
||||
| `idleAuto` | boolean | `true` | when true, plays a random trick by itself every `idleMin`–`idleMax` ms. |
|
||||
| `idleMin` | number | `5000` | min ms between idle auto-plays. |
|
||||
| `idleMax` | number | `11000` | max ms between idle auto-plays. |
|
||||
|
||||
### Behavior (built in)
|
||||
|
||||
- **Rest:** renders the static pixel S in `accent`.
|
||||
- **Hover:** plays a random trick (snake-weighted).
|
||||
- **Click:** plays the snake trick.
|
||||
- **Idle:** if `idleAuto`, fires a random trick on a 5–11s jitter — but only
|
||||
while the tab is visible (`document.hidden` guard), so background tabs stay quiet.
|
||||
while the tab is visible (`document.hidden` guard), so background tabs stay
|
||||
quiet.
|
||||
- It never overlaps plays (a `playing` guard ignores triggers mid-animation).
|
||||
|
||||
### Imperative API (ref)
|
||||
|
||||
```jsx
|
||||
const logo = useRef(null);
|
||||
// ...
|
||||
<LiveLogo ref={logo} accent="#3b82f6" size={32} />
|
||||
<LiveLogo ref={logo} accent="#3b82f6" size={32} />;
|
||||
// trigger a specific trick on demand:
|
||||
logo.current.play('snake'); // 'snake' | 'rgb' | 'glitch'
|
||||
logo.current.isPlaying(); // boolean
|
||||
logo.current.play("snake"); // 'snake' | 'rgb' | 'glitch'
|
||||
logo.current.isPlaying(); // boolean
|
||||
```
|
||||
|
||||
### Tricks
|
||||
- `snake` (~2.6s) — the headline animation. S → slither across the 5×5 board → S.
|
||||
|
||||
- `snake` (~2.6s) — the headline animation. S → slither across the 5×5 board →
|
||||
S.
|
||||
- `rgb` (~1.35s) — chromatic-aberration split that settles back to clean.
|
||||
- `glitch` (~1.1s) — rows tear/kick sideways, then snap back.
|
||||
|
||||
@@ -73,7 +78,9 @@ The current brand mark in `launcher.jsx` is a placeholder div:
|
||||
|
||||
```jsx
|
||||
// BEFORE — both the 'single' and 'two' topbar variants:
|
||||
<div className="brand-mark" style={{ background: accent }}>S</div>
|
||||
<div className="brand-mark" style={{ background: accent }}>
|
||||
S
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace each with the live component:
|
||||
@@ -91,19 +98,21 @@ Replace each with the live component:
|
||||
event, share one `ref` and call `.play()`.
|
||||
|
||||
Import at the top of the file (or via your bundler):
|
||||
|
||||
```jsx
|
||||
import { LiveLogo } from './pixel-live'; // if you convert exports to ES modules
|
||||
import { LiveLogo } from "./pixel-live"; // if you convert exports to ES modules
|
||||
```
|
||||
The file currently attaches `LiveLogo` to `window` for the no-build demo —
|
||||
swap the final `Object.assign(window, …)` line for `export { LiveLogo }` in a
|
||||
module build.
|
||||
|
||||
The file currently attaches `LiveLogo` to `window` for the no-build demo — swap
|
||||
the final `Object.assign(window, …)` line for `export { LiveLogo }` in a module
|
||||
build.
|
||||
|
||||
---
|
||||
|
||||
## 3. Static assets (`assets/`)
|
||||
|
||||
For places that must be static — favicons, OS app icons, store listings,
|
||||
loading splash, OG images, anywhere JS isn’t running:
|
||||
For places that must be static — favicons, OS app icons, store listings, loading
|
||||
splash, OG images, anywhere JS isn’t running:
|
||||
|
||||
- **`softlan-tile.svg`** — the rounded app-icon tile (gradient + white S). Use
|
||||
this for the favicon, dock/taskbar icon, and installer art. `favicon.svg` is
|
||||
@@ -120,6 +129,7 @@ Geometry note: all static marks use the exact same grid as the live component
|
||||
and animated S are pixel-identical — no jump when the live one mounts.
|
||||
|
||||
### Generating raster PNGs (if your pipeline needs them)
|
||||
|
||||
```bash
|
||||
# requires librsvg (rsvg-convert) or Inkscape
|
||||
rsvg-convert -w 512 -h 512 assets/softlan-tile.svg > icon-512.png
|
||||
@@ -136,22 +146,24 @@ tracked-out “LAUNCHER” beneath. This is the canonical lockup — use it for
|
||||
top bar, About screen, installer, store header, splash, etc.
|
||||
|
||||
### Exact spec
|
||||
| part | value |
|
||||
|------|-------|
|
||||
| tile | rounded square, `radius = round(size·0.225)`, the accent gradient (`linear-gradient(155deg, mix(accent,white 22%) → accent 52% → mix(accent,black 28%))`), white pixel S at `round(size·0.62)` |
|
||||
| gap tile → text | `size·0.32` |
|
||||
| wordmark | system font (`-apple-system, "Segoe UI", system-ui`), **700**, `font-size ≈ tile·0.568` (25px at a 44px tile), `letter-spacing -0.01em` |
|
||||
| “Soft” color | `#e6edf3` on dark UI · `#0a0e13` on light UI |
|
||||
| “LAN” color | the accent (`#3b82f6`) — always |
|
||||
| “LAUNCHER” | system font, **700**, `font-size = wordmark·0.42`, `letter-spacing 0.34em`, UPPERCASE, `#6b7785` (dark) / `#8b97a6` (light) |
|
||||
|
||||
> The wordmark is set in the **system UI sans** on purpose (it sits inline in the
|
||||
> chrome). If you need a fixed, OS-independent render — store art, OG images,
|
||||
> anywhere the system font isn’t guaranteed — use the SVG assets below, which
|
||||
> carry the same metrics. For pixel-perfect raster, set the wordmark in your
|
||||
> design tool and export, since `system-ui` varies by platform.
|
||||
| part | value |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| tile | rounded square, `radius = round(size·0.225)`, the accent gradient (`linear-gradient(155deg, mix(accent,white 22%) → accent 52% → mix(accent,black 28%))`), white pixel S at `round(size·0.62)` |
|
||||
| gap tile → text | `size·0.32` |
|
||||
| wordmark | system font (`-apple-system, "Segoe UI", system-ui`), **700**, `font-size ≈ tile·0.568` (25px at a 44px tile), `letter-spacing -0.01em` |
|
||||
| “Soft” color | `#e6edf3` on dark UI · `#0a0e13` on light UI |
|
||||
| “LAN” color | the accent (`#3b82f6`) — always |
|
||||
| “LAUNCHER” | system font, **700**, `font-size = wordmark·0.42`, `letter-spacing 0.34em`, UPPERCASE, `#6b7785` (dark) / `#8b97a6` (light) |
|
||||
|
||||
> The wordmark is set in the **system UI sans** on purpose (it sits inline in
|
||||
> the chrome). If you need a fixed, OS-independent render — store art, OG
|
||||
> images, anywhere the system font isn’t guaranteed — use the SVG assets below,
|
||||
> which carry the same metrics. For pixel-perfect raster, set the wordmark in
|
||||
> your design tool and export, since `system-ui` varies by platform.
|
||||
|
||||
### Drop-in React (from `pixel-live.jsx`)
|
||||
|
||||
```jsx
|
||||
import { Lockup, Wordmark } from './pixel-live';
|
||||
|
||||
@@ -164,21 +176,34 @@ import { Lockup, Wordmark } from './pixel-live';
|
||||
// just the lettering (e.g. next to the bare LiveLogo in a slim top bar):
|
||||
<Wordmark accent="#3b82f6" size={19} />
|
||||
```
|
||||
|
||||
`Lockup` props: `accent`, `tile` (px), `light` (true=dark UI), `sub` (show
|
||||
“LAUNCHER”), `live` (animate the mark). `Wordmark` props: `accent`, `size`,
|
||||
`light`, `sub`.
|
||||
|
||||
### Plain CSS/HTML (no React)
|
||||
|
||||
```html
|
||||
<span class="sl-wm">Soft<b>LAN</b></span>
|
||||
<style>
|
||||
.sl-wm { font: 700 25px/1 -apple-system, "Segoe UI", system-ui, sans-serif;
|
||||
letter-spacing: -0.01em; color: #e6edf3; } /* #0a0e13 on light */
|
||||
.sl-wm b { color: #3b82f6; font-weight: 700; } /* the accent */
|
||||
.sl-wm {
|
||||
font:
|
||||
700 25px/1 -apple-system,
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
color: #e6edf3;
|
||||
} /* #0a0e13 on light */
|
||||
.sl-wm b {
|
||||
color: #3b82f6;
|
||||
font-weight: 700;
|
||||
} /* the accent */
|
||||
</style>
|
||||
```
|
||||
|
||||
### Static SVG
|
||||
|
||||
- **`assets/softlan-lockup.svg`** — full lockup for **dark** backgrounds.
|
||||
- **`assets/softlan-lockup-ink.svg`** — same lockup for **light** backgrounds
|
||||
(“Soft” goes ink-dark; “LAN” stays accent).
|
||||
@@ -195,10 +220,14 @@ three `linearGradient` stops and the “LAN” `fill`.
|
||||
it’s decorative motion over a brand mark.
|
||||
- The idle auto-play already pauses in hidden tabs. If you want to fully respect
|
||||
`prefers-reduced-motion`, gate the triggers:
|
||||
|
||||
```jsx
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
<LiveLogo idleAuto={!reduce} /* and skip the hover/click play when reduce */ />
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
<LiveLogo
|
||||
idleAuto={!reduce} /* and skip the hover/click play when reduce */
|
||||
/>;
|
||||
```
|
||||
|
||||
At rest it’s a clean static S, so reduced-motion users simply get the icon.
|
||||
|
||||
---
|
||||
@@ -207,6 +236,6 @@ three `linearGradient` stops and the “LAN” `fill`.
|
||||
|
||||
Open `demo.html` in any browser: hover the big mark (or wait), use the trick
|
||||
buttons, and try the accent swatches. The small mark in the mock top bar is the
|
||||
same component at `size={30}` — that’s exactly how it looks in the launcher.
|
||||
The **logo lockup** card shows the full icon-plus-wordmark on dark and light,
|
||||
at several sizes, and recolors live with the accent swatches.
|
||||
same component at `size={30}` — that’s exactly how it looks in the launcher. The
|
||||
**logo lockup** card shows the full icon-plus-wordmark on dark and light, at
|
||||
several sizes, and recolors live with the accent swatches.
|
||||
|
||||
Reference in New Issue
Block a user