From 6415a4e517b7b42b420ac66050ad4b6d7b760ccb Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 21 Jun 2026 21:15:09 +0200 Subject: [PATCH] design: updated design docs --- design/README.md | 663 ++---------- design/launcher/SPEC.md | 618 +++++++++++ .../design_reference/SoftLAN Launcher.html | 0 .../design_reference/components.jsx | 0 .../{ => launcher}/design_reference/data.jsx | 0 .../design_reference/design-canvas.jsx | 966 ++++++++++++++++++ .../design_reference/launcher.jsx | 0 .../design_reference/styles.css | 0 .../design_reference/tweaks-panel.jsx | 568 ++++++++++ .../logo/INTEGRATION.md | 75 +- .../logo/assets/favicon.svg | 0 design/logo/assets/softlan-lockup-ink.svg | 43 + design/logo/assets/softlan-lockup.svg | 43 + .../logo/assets/softlan-mark-blue.svg | 0 .../logo/assets/softlan-mark.svg | 0 .../logo/assets/softlan-tile.svg | 0 design/{design_reference => }/logo/demo.html | 33 + .../logo/pixel-live.jsx | 48 +- 18 files changed, 2452 insertions(+), 605 deletions(-) create mode 100644 design/launcher/SPEC.md rename design/{ => launcher}/design_reference/SoftLAN Launcher.html (100%) rename design/{ => launcher}/design_reference/components.jsx (100%) rename design/{ => launcher}/design_reference/data.jsx (100%) create mode 100644 design/launcher/design_reference/design-canvas.jsx rename design/{ => launcher}/design_reference/launcher.jsx (100%) rename design/{ => launcher}/design_reference/styles.css (100%) create mode 100644 design/launcher/design_reference/tweaks-panel.jsx rename design/{design_reference => }/logo/INTEGRATION.md (62%) rename design/{design_reference => }/logo/assets/favicon.svg (100%) create mode 100644 design/logo/assets/softlan-lockup-ink.svg create mode 100644 design/logo/assets/softlan-lockup.svg rename design/{design_reference => }/logo/assets/softlan-mark-blue.svg (100%) rename design/{design_reference => }/logo/assets/softlan-mark.svg (100%) rename design/{design_reference => }/logo/assets/softlan-tile.svg (100%) rename design/{design_reference => }/logo/demo.html (83%) rename design/{design_reference => }/logo/pixel-live.jsx (73%) diff --git a/design/README.md b/design/README.md index 90d6dbf..a8452f4 100644 --- a/design/README.md +++ b/design/README.md @@ -1,618 +1,85 @@ -# Handoff: SoftLAN Launcher redesign +# SoftLAN Launcher — Design Handoff -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. +**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 — +recreate them inside the codebase using its own patterns. They are not meant to +be shipped as-is. --- -## About the design files +## What's inside -The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**. - -The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for: - -- Exact pixel/spacing/color/typography values -- Component composition and interactions -- Copy and microcopy -- Animation easings/durations - -But: - -- Don't ship the Babel-in-browser setup or import the .jsx files as-is -- Don't keep the `` / design-canvas wrapping — that's only for presenting variants -- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below) -- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.) - -## Fidelity - -**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical). - -## Layout variants - -The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only. +``` +design_handoff_softlan_launcher/ +├── README.md ← you are here — start here +│ +├── launcher/ ← THE APP +│ ├── SPEC.md full spec: every screen, component, token, interaction +│ └── design_reference/ the working prototype the spec describes +│ ├── SoftLAN Launcher.html open this to see all screens/variants +│ ├── styles.css +│ ├── data.jsx mock catalog + format helpers +│ ├── components.jsx Icon set, cards, modals, Settings dialog +│ └── launcher.jsx composes chrome + grid + modals +│ +└── logo/ ← THE BRAND MARK + ├── INTEGRATION.md how to ship the logo (component + assets + lockup) + ├── pixel-live.jsx the live animated "S" + Wordmark + Lockup components + ├── demo.html open to see the mark animate + the lockup + └── assets/ + ├── softlan-mark.svg static S (currentColor) + ├── softlan-mark-blue.svg static S (fixed blue) + ├── softlan-tile.svg app-icon tile (gradient + white S) + ├── softlan-lockup.svg full lockup (icon + wordmark), on dark + ├── softlan-lockup-ink.svg full lockup, on light + └── favicon.svg = tile, drop-in favicon +``` --- -## Changes since v3 +## Two pieces, one product -- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B). -- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below. -- **Persisted setting renamed.** `gameFolderSet: boolean` → `gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans). - -## Changes since v2 - -- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows): - - **Left:** brand mark + wordmark. - - **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right. - - **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes). - - Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet. - - See "Top bar (variant A)" below for the full spec and rationale. - -## Changes since v1 - -- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys. -- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec. -- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay. - ---- - -## Screens / views - -### 1. Main library (variant A — primary) - -The default screen. A grid of game cards over a dark, gradient-tinted background. - -**Layout (top-to-bottom):** - -1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other. - - - **Left zone (col 1, flex space-between):** - - **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`. - - **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons: - - `All Games` · count chip - - `Local` · count chip - - `Installed` · count chip - - Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`. - - `Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network. - - The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder. - - - **Center zone (col 2, search alone):** - - **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search. - - - **Right zone (col 3, flex space-between with two sub-groups):** - - **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: ` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search. - - **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings. - - **Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet. - -2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between: - - Left: `Showing N of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`). - - Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: ` 78 GB installed 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`. - -3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically. - - - Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20. - -**Game card** (see "Game card" below for full anatomy). - ---- - -### 2. Game detail overlay - -Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card). - -**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered. - -**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`. - -**Modal structure (top-to-bottom):** - -1. **Hero banner** — `aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads. - - **State chip** in the top-left of the hero (same chip style as on cards — see Game Card). - - **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon. - - **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`: - - Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`) - - **Title** as `

` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art. - -2. **Body** — 22px top, 26px bottom, 28px horizontal: - - **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded). - - **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`. - - **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right: - 1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state). - 2. **Start Server** — *only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub). - 3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. - 4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling). - 5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling). - 6. Spacer (`flex: 1`). - 7. Ghost-button **View files** (neutral) — opens system file manager at the game folder. - -#### Start Server button - -A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention. - -- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`. -- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`. -- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`. -- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`). -- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn. - -The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards. - ---- - -### 3. Settings dialog - -Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences. - -**Structure:** - -``` -┌─────────────────────────────────────────┐ -│ Settings [×] │ ← head: 22 28 18, 1px bottom border -├─────────────────────────────────────────┤ -│ │ -│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3 -│ │ -│ Username │ ← row label: 14px / 600 / --t-1 -│ Shown to other players on the LAN │ ← row hint: 12px / --t-3 -│ [ Enter a username ] │ ← text input (220×36) -│ │ -│ Language │ -│ Interface language │ -│ [English│Deutsch] │ ← segmented radio (new) -│ │ -│ APPEARANCE │ -│ │ -│ Accent color │ -│ Used for primary actions and highlights │ -│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned -│ │ -│ Background │ -│ Backdrop behind the library │ -│ [Flat │Gradient│Animat]│ ← segmented radio -│ │ -│ LIBRARY │ -│ │ -│ Game folder │ -│ Parent directory where games are │ -│ downloaded and installed │ -│ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36) -│ │ -│ Grid density │ -│ How tightly cards are packed │ -│ [Compact│Normal│Large]│ -│ │ -│ Cover aspect │ -│ Shape of the cover art on each card │ -│ [Box-art│Square│Banner│ -│ │ -├─────────────────────────────────────────┤ -│ [Done] │ ← foot: 14 22 18, 1px top border -└─────────────────────────────────────────┘ -``` - -**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap): - -- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`) -- Right (`settings-row-control`): the control - -**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings. - -- **Username** — `` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`. -- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios. - -**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**. - -- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference. - -**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px ` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`. - -Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`. - -**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`. - -**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog. - -Persisted settings (write through to local storage / Tauri config): -- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does. -- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`. -- `accent`: one of the six hex values above. Default `#3b82f6`. -- `bg`: `flat` | `gradient` | `animated`. Default `gradient`. -- `density`: `compact` | `normal` | `large`. Default `normal`. -- `aspect`: `box` | `square` | `banner`. Default `box`. -- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below. - ---- - -## Game-folder field - -A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live). - -**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs. - -Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory: - -| State | Trigger | Path display | Border | Button label | -|---|---|---|---|---| -| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) | -| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) | - -"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple. - -**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right: - -1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state). -2. **Path display** — `flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead. -3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library. - -**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps). - -**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated. - -**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring. - -**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing. - -**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel. - ---- - -## Game card - -The unit element of the library grid. - -**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer. - -**Hover/focus state:** -- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`) -- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))` -- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)` -- Cover inner image scales to 1.03 (350ms cubic-bezier) -- Focus-visible: same lift + 2px solid accent outline - -### Anatomy (top to bottom) - -1. **Cover wrap** — `width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`. - -2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`): - - **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata. - - **Radial accent blob** — `radial-gradient(ellipse at % %, 38, transparent 55%)`. x/y also hashed from id. - - **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7. - - **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash. - - **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px , 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants). - - **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2). - - **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px `. - - **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `2–32`). Always visible — every LAN game is multiplayer. - -3. **Card body** — `padding: 11px 12px 12px`, flex column, 8px gap: - - **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow. - - **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity. - - **Action button** (full width) — primary action depending on state, see below. - -### Action button - -A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance. - -``` -state label button style -───────────── ────────── ──────────────────────────────────────────── -not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1) -local Install bg var(--accent), text white, inset top hl -installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl -downloading — progress see "Download progress" below — the button slot is replaced with a live progress component -``` - -Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12). - -Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`. - -**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons). - ---- - -## Download progress (state === 'downloading') - -When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `` child. Two layouts share the same primitive: - -### Shared visuals - -- Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below). -- **Progress fill** (`.dl-fill`): absolutely positioned, `width: %`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 38–26%, transparent)`. Right edge gets a 1px accent rule + accent glow. -- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting. -- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active. -- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over. - -### Card layout (`.dl-md`, replaces the 32px action button) - -A single row. Two values, separated by `justify-content: space-between`: - -- **Left:** ` %` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`. -- **Right:** `` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale). - -Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`). - -**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo: - -```css -@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } } -@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } } -``` - -At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly. - -The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar. - -### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button) - -Fixed 56px height. CSS-grid with three columns and two rows: - -``` -grid-template-columns: minmax(0, 1fr) auto auto; -grid-template-areas: - "primary pct cancel" - "secondary pct cancel"; -``` - -- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component. -- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity): - 1. `11.4 GB / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest) - 2. `47.6 MB/s` (`var(--t-1)`) - 3. `[users-icon] 5` — `.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip. - 4. `8 min left` (`var(--t-2)`) -- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity. -- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior. - -**Graceful degradation in narrow modals:** - -```css -@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } } -@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } } -``` - -ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses. - -### Number formatting - -All helpers live in `data.jsx`: - -- `fmtSpeed(mbps)` — `49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`. -- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact. -- `fmtBytes(gb)` — `<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`). -- `fmtEta(seconds)` — `< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`. - -Keep these formats; they're tuned so the secondary row never wraps at normal modal width. - -### Data shape - -The `Game` type gains a `downloading` state plus two transient fields: - -```ts -type Game = { - // … existing fields … - state: 'installed' | 'local' | 'downloading' | 'none'; - progress?: number; // 0–1, only when state === 'downloading' - speed?: number; // current throughput in MB/s - peers?: number; // number of LAN peers currently seeding -}; -``` - -In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else. - -Filter changes: -- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them). -- Sort by `state` orders `installed < local < downloading < none`. - -### State chip - -Add a fourth entry to `STATE_META`: - -```js -downloading: { label: 'Downloading', dot: 'var(--accent)' } -``` - -Dot uses the live accent so it visually ties to the progress fill. - ---- - -## Filter controls — variant B (not used, kept for reference) - -The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs: - -- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active. -- Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`. -- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier). - -Implement only if you decide variant A doesn't work after building. - ---- - -## Interactions & behavior - -- **Click game card** (anywhere except the action button) → open detail overlay. -- **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button. -- **Press / (slash)** → focus the search input. -- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring). -- **Click filter tab / segmented pill** → change filter. -- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it. -- **Hover game card** → lift + accent border glow + cover image scale 1.03. -- **Click "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. - ---- - -## State management - -Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations. - -**Library state** (rebuilt on `refresh`): -```ts -type Game = { - id: string; - title: string; - size: number; // GB - version: string; // "YYYY.MM.DD" - desc: string; - state: 'installed' | 'local' | '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. - -**UI state:** -```ts -type LauncherUI = { - filter: 'all' | 'local' | 'installed'; - sort: 'az' | 'size' | 'recent' | 'state'; - query: string; - openGameId: string | null; - settingsOpen: boolean; -}; -``` - -**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 -}; -``` - -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. - ---- - -## Design tokens - -### Color - -| token | value | usage | +| | **launcher/** | **logo/** | |---|---|---| -| `--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 | +| 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 | -### Typography - -- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif` -- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif` -- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay) - -Sizing reference: -- Brand wordmark: 15 / 700 -- Modal title: 32 / 700 / -0.015em -- Card title: 13.5 / 600 -- Filter pill label: 12.5 / 600 -- Settings row label: 14 / 600 -- Section title (settings): 10.5 / 700 / 0.12em uppercase -- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3` -- Hint text: 12 / `--t-3` - -### Spacing & radii - -- Card radius: 10px -- Modal radius: 14px -- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button) -- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28 -- Card body padding: 11 12 12 - -### Shadows - -- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)` -- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)` -- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)` -- Action button (filled): `0 6px 16px -8px , inset 0 1px 0 rgba(255,255,255,0.22)` +The two share one design language. The **accent** color is a single token +(`--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 +placeholder for it. --- -## Assets +## Recommended reading order -Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design. - -In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`. - -The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button. - -Fonts to load: -```html - - - -``` +1. **This file** — the map. +2. **`launcher/SPEC.md`** — the bulk of the work. Screens, components, data + shapes, design tokens, interactions, and what's out of scope. Start at + "Screens / views" and keep the "Design tokens" section open alongside. +3. **`logo/INTEGRATION.md`** — drop the live mark and the wordmark lockup into + the chrome. Short; mostly a component API + static-asset reference. +4. Open both preview files in a browser to see the real thing in motion. --- -## File reference +## Status -``` -design_reference/ -├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts -├── styles.css ← all visual styles (CSS custom props + components) -├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock -├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard, -│ SegmentedFilters, UnderlineFilters, SearchField, -│ SortMenu, StorageMeter, KebabMenu, -│ GameDetailModal, SettingsDialog (incl. GameFolderField) -└── launcher.jsx ← component composing chrome + grid + modals -``` +- **Launcher:** high-fidelity, decisions locked. Variant **A** is the chosen + chrome. Open questions (empty/error states, logs viewer, keyboard grid nav, + German strings, "server running" state) are listed at the end of `SPEC.md`. +- **Logo:** final. Live component + static assets + horizontal lockup (dark and + light) all included. -To preview the design in a browser: -1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder). -2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen. - - **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** - - **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. - ---- - -## 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. -- **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. +Do **not** ship the Babel-in-browser setup, the design-canvas wrappers, or the +Tweaks panel from the prototypes — they're presentation scaffolding. The Tweaks +panel is superseded by the in-app **Settings dialog** (spec'd in `SPEC.md`). diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md new file mode 100644 index 0000000..90d6dbf --- /dev/null +++ b/design/launcher/SPEC.md @@ -0,0 +1,618 @@ +# Handoff: SoftLAN Launcher redesign + +A modern, gamer-friendly redesign of the SoftLAN local-network game launcher, replacing the current basic UI with a Steam-inspired dark layout that keeps high usability while adding cover art, state-coded actions, a game-detail overlay, and an in-app Settings dialog. + +--- + +## About the design files + +The files in `design_reference/` are **design references created in HTML/React via Babel-in-the-browser** — prototypes built to communicate the intended look, layout, and behavior. They are **not production code to copy directly**. + +The target codebase is a **Tauri + React** desktop app. The task is to **recreate these designs inside that codebase**, using its existing patterns (component conventions, state management, routing, IPC to Rust for filesystem / process work). Use the design files for: + +- Exact pixel/spacing/color/typography values +- Component composition and interactions +- Copy and microcopy +- Animation easings/durations + +But: + +- Don't ship the Babel-in-browser setup or import the .jsx files as-is +- Don't keep the `` / design-canvas wrapping — that's only for presenting variants +- Don't ship the Tweaks panel — it's superseded by the in-app **Settings dialog** (see "Screens" below) +- Re-implement using whatever the codebase uses (Vite + plain JSX, CSS modules / styled-components / tailwind, etc.) + +## Fidelity + +**High-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-fidelity to the mock is the goal — recreate exactly, using the codebase's libraries/patterns. Only deviate where the codebase has its own dictate (e.g. an existing button primitive that's near-identical). + +## Layout variants + +The HTML mock includes two chrome variants — **A (single-row)** and **B (two-row)** — to choose from. **The user selected A as the primary direction.** Implement A. Variant B is left in the reference for context only. + +--- + +## Changes since v3 + +- **Game-folder button removed from the top bar.** Setting the games directory is a one-time action — it doesn't deserve permanent real estate in the chrome. The button is gone from both top-bar variants, freeing the right zone for the kebab menu alone (variant A) / the storage meter + kebab pair (variant B). +- **Game folder moved into Settings → Library.** Now a row inside the Settings dialog, styled like the other Library rows. Two visual states (set / not-set) carry over from the old button — see "Settings dialog → Library → Game folder" below. +- **Persisted setting renamed.** `gameFolderSet: boolean` → `gameFolder: string | null`. The actual path is now persisted, not just a "is it configured?" flag. Default is `null` (unset on first run; user must pick a folder before the library scans). + +## Changes since v2 + +- **Top bar layout reorganized.** The single-row top bar is now structured as three visual zones (still one row on wide windows): + - **Left:** brand mark + wordmark. + - **Center (semantically the "search cluster"):** segmented filter pills · search field · sort menu. The **search field is positioned at the geometric center of the window** — filter pills sit immediately to its left, sort menu immediately to its right. + - **Right:** kebab menu (game-folder configuration has moved into Settings — see v3 changes). + - Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet. + - See "Top bar (variant A)" below for the full spec and rationale. + +## Changes since v1 + +- **Settings → Profile section** added at the top of the dialog with two new persisted preferences: **Username** (text input) and **Language** (segmented `English` / `Deutsch`). See "Settings dialog" below for shape + persistence keys. +- **Start Server** action added to the **game detail overlay**, next to **Play**, for installed games that support a dedicated server. Driven by a new `canHostServer: true` flag on the game record. See "Detail overlay → Actions row" and "Game data shape" for the full spec. +- Grid cards are **unchanged** — Start Server only ever appears in the detail overlay. + +--- + +## Screens / views + +### 1. Main library (variant A — primary) + +The default screen. A grid of game cards over a dark, gradient-tinted background. + +**Layout (top-to-bottom):** + +1. **Top bar** — single row, sticky, full width, 64px tall, semi-transparent dark with backdrop-blur. Background `rgba(10,14,19,0.65)` + `backdrop-filter: blur(20px) saturate(140%)`. Border-bottom `1px solid rgba(255,255,255,0.06)`. Padding `14px 24px`. **Layout:** a 3-column CSS grid — `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` with `column-gap: 16px` — putting the search field in the middle (auto-sized) column so it sits at the **geometric center of the window** regardless of how wide the side groups are. The side columns are each `display: flex; justify-content: space-between` so their contents pin to the outer edge on one end and hug the search on the other. + + - **Left zone (col 1, flex space-between):** + - **Brand** (pinned far-left) — 28×28 px rounded square in `--accent` (default `#3b82f6`) with the letter "S" in Bebas Neue 20 px white. Next to it, the wordmark "SoftLAN" in 15 px / 700 weight `--t-1` `#e6edf3`. + - **Segmented filter pills** (pinned right, hugging the search field) — pill-shaped container (`background var(--bg-2) #131b25`, `1px solid rgba(255,255,255,0.06)`, `border-radius: 999px`, `padding: 4px`). Three buttons: + - `All Games` · count chip + - `Local` · count chip + - `Installed` · count chip + + Active button has an animated pill thumb (background `var(--accent)`, transitions `left` and `width` with `cubic-bezier(.4,1.2,.5,1)` over 220 ms), text becomes white, count-chip background goes `rgba(0,0,0,0.25)`. Inactive: text `var(--t-2) #9aa6b4`, count-chip background `rgba(255,255,255,0.08)`. + + `Local` = installed *or* downloaded-but-not-yet-installed. `Installed` = installed only. `All Games` = everything available on the network. + + The filter is grouped semantically with the search — it scopes what the user is searching, so it belongs at the search field's left shoulder. + + - **Center zone (col 2, search alone):** + - **Search field** — 36 px tall, `flex: 0 1 360px` (caps at 360 px wide so it can't elbow into the side zones). `background var(--bg-2)`, `1px solid var(--bd-1)`, `border-radius: 8px`, padding `0 12px`. Leading magnifying-glass icon (14×14, `currentColor`) and a trailing "/" kbd hint (`background rgba(255,255,255,0.06)`, `border-radius: 4px`, font `11px ui-monospace`). On focus: border `color-mix(in srgb, var(--accent) 60%, var(--bd-2))`, background `var(--bg-1)`, ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent)`. The `/` key shortcut should focus the search. + + - **Right zone (col 3, flex space-between with two sub-groups):** + - **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: ` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (A–Z)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search. + - **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings. + + **Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet. + +2. **Results bar** — 18px top padding inside the scroll wrapper, 24px horizontal. Flex row with space-between: + - Left: `Showing N of M games` in 12.5px `var(--t-2)` (strong is `var(--t-1)`). + - Right: compact **storage meter** — 200px min-width, 4px-tall horizontal bar with two stacked segments (`installed` and `local`), plus a 11px text row underneath: ` 78 GB installed 41 GB local 384 GB free`. Squares are 8×8px rounded 2px, colored `var(--accent)` and `color-mix(var(--accent), 55%)`. + +3. **Grid** — CSS grid with `repeat(auto-fill, minmax(188px, 1fr))` at default density, 16px gap, 24px horizontal padding, 32px bottom padding. Scrolls vertically. + + - Density: `compact` → min 148, gap 12. `normal` → min 188, gap 16. `large` → min 244, gap 20. + +**Game card** (see "Game card" below for full anatomy). + +--- + +### 2. Game detail overlay + +Opens when the user **clicks anywhere on a game card except the action button**. Modal over a scrim. Closes on scrim click, Esc key, or the close button. Should also work via keyboard nav (Enter on focused card). + +**Scrim:** absolutely positioned over the launcher, `inset: 0`, `z-index: 100`, `background: rgba(4,7,11,0.7)`, `backdrop-filter: blur(8px)`, fade-in 180ms. Padding 32px, content centered. + +**Modal panel:** `min(880px, 100%)` wide, `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-2)`, `border-radius: 14px`, drop shadow `0 30px 80px -10px rgba(0,0,0,0.7)`. Scales in from 0.96 with 250ms `cubic-bezier(.3,1.3,.4,1)`. + +**Modal structure (top-to-bottom):** + +1. **Hero banner** — `aspect-ratio: 16/7`. Full-bleed cover art rendered as a banner (same gradient + accent treatment as the small cards, scaled up). Bottom-fade gradient `linear-gradient(180deg, transparent 40%, var(--bg-2) 100%)` so text reads. + - **State chip** in the top-left of the hero (same chip style as on cards — see Game Card). + - **Close button** top-right: 32×32 square, `background rgba(8,12,16,0.7)`, `1px solid var(--bd-2)`, `border-radius: 8px`, `backdrop-filter: blur(8px)`, X icon. + - **Title overlay** in bottom-left at `left: 28px, right: 28px, bottom: 22px`: + - Tags row — small uppercase pills (`background rgba(8,12,16,0.6)`, `1px solid var(--bd-2)`, `border-radius: 4px`, `padding: 3px 8px`, `font 11px / 600 / 0.04em letter-spacing`) + - **Title** as `

` — system sans 32px / 700 / -0.015em, white, text-shadow `0 4px 24px rgba(0,0,0,0.6)`. **Not Bebas Neue** here — this is normal UI typography, not stylized cover art. + +2. **Body** — 22px top, 26px bottom, 28px horizontal: + - **Meta grid** — 4-column CSS grid, 12px gap. Each cell: `padding 10px 12px`, `background rgba(255,255,255,0.025)`, `1px solid var(--bd-1)`, `border-radius: 8px`. Cells (in order): `Size` (e.g. 8.2 GB), `Players` (icon + range), `Version` (mono, e.g. 2018.04.12), `Status` (Installed / Local / Not downloaded). + - **Description** — 14px / 1.55 line-height, `var(--t-2)`, `text-wrap: pretty`, `max-width: 64ch`. + - **Actions row** — flex row, 10px gap, 4px top padding. Order, left → right: + 1. **Primary action button** (44px tall, see "Action button" below — Play / Install / Download depending on state). + 2. **Start Server** — *only* when `game.canHostServer === true` **and** `state === 'installed'`. Same 44px height as Play, but visually a peer secondary action (see "Start Server button" below). Triggers a Tauri command that spawns the game's dedicated-server executable in headless mode against the local LAN (port + server config out of scope here — leave a `startServer(gameId)` IPC stub). + 3. If `state === 'installed'`: ghost-button **Uninstall** — 44px, `background rgba(255,255,255,0.04)`, `1px solid var(--bd-2)`, `border-radius: 8px`, text `#f87171`, trash icon. On hover: bg `rgba(239,68,68,0.10)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. + 4. If `state === 'local'`: ghost-button **Delete from disk** (same danger styling). + 5. If `state === 'downloading'`: ghost-button **Cancel** (same danger styling). + 6. Spacer (`flex: 1`). + 7. Ghost-button **View files** (neutral) — opens system file manager at the game folder. + +#### Start Server button + +A secondary-but-equal action that sits next to **Play**. The intent is to read as a host-action ("I want to put this game on the LAN") without competing with the green Play button for the player's primary attention. + +- Same shape and height as Play: 44px tall, `border-radius: 8px`, `font 14px / 600`, 8px gap between icon and label, padding `0 22px`. +- Surface: `background: color-mix(in srgb, var(--accent) 14%, rgba(255,255,255,0.04))`, `border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent)`, `box-shadow: inset 0 1px 0 rgba(255,255,255,0.06)`. Text in `--t-1`. +- **Icon** in `--accent`: a small server-rack glyph (two stacked rounded rectangles each with an LED dot and a hint of wiring). 13×13. SVG in `components.jsx → Icon.server`. +- Hover: `background: color-mix(in srgb, var(--accent) 22%, ...)`, border darkens to `color-mix(... 75%, transparent)`. Active: `transform: scale(0.98)` (shared with `.act-btn`). +- A future *running* state (live indicator dot + "Server running" label + click-to-stop) is **not** in this round — flag as a follow-up when wiring the real spawn. + +The button is purposefully **not** present on game cards in the grid — hosting a server is intentional and benefits from the context of the detail overlay (player count, version, etc.). Don't add it to cards. + +--- + +### 3. Settings dialog + +Opens when the user clicks **Settings** from the kebab menu. Same modal-scrim treatment as the game-detail modal, but the panel is narrower (`min(640px, 100%)`) and styled as a list of preferences. + +**Structure:** + +``` +┌─────────────────────────────────────────┐ +│ Settings [×] │ ← head: 22 28 18, 1px bottom border +├─────────────────────────────────────────┤ +│ │ +│ PROFILE │ ← section title (new): 10.5px / 700 / 0.12em / uppercase / --t-3 +│ │ +│ Username │ ← row label: 14px / 600 / --t-1 +│ Shown to other players on the LAN │ ← row hint: 12px / --t-3 +│ [ Enter a username ] │ ← text input (220×36) +│ │ +│ Language │ +│ Interface language │ +│ [English│Deutsch] │ ← segmented radio (new) +│ │ +│ APPEARANCE │ +│ │ +│ Accent color │ +│ Used for primary actions and highlights │ +│ ⬤⬤⬤⬤⬤⬤ │ ← 6 swatches, right-aligned +│ │ +│ Background │ +│ Backdrop behind the library │ +│ [Flat │Gradient│Animat]│ ← segmented radio +│ │ +│ LIBRARY │ +│ │ +│ Game folder │ +│ Parent directory where games are │ +│ downloaded and installed │ +│ [📁 /home/pfs/…/eti_games [Change…]] │ ← folder field (340×36) +│ │ +│ Grid density │ +│ How tightly cards are packed │ +│ [Compact│Normal│Large]│ +│ │ +│ Cover aspect │ +│ Shape of the cover art on each card │ +│ [Box-art│Square│Banner│ +│ │ +├─────────────────────────────────────────┤ +│ [Done] │ ← foot: 14 22 18, 1px top border +└─────────────────────────────────────────┘ +``` + +**Sections** are separated by 26px gap (column flex). Rows within a section: 14px gap. Each **row** is flex row with space-between (24px gap): + +- Left (`settings-row-info`): label (14px / 600 / `--t-1`) + hint (3px-top, 12px / `--t-3`) +- Right (`settings-row-control`): the control + +**Profile section** (new in this round). Two rows, rendered **above** Appearance — it's the most personal/identity-shaped setting so it's the first thing the user sees in Settings. + +- **Username** — `` wrapped in a styled container: 220px wide, 36px tall, `background var(--bg-3)`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 0 12px`. Input itself is transparent/borderless, `font 13.5px / 600`, color `--t-1`, placeholder `"Enter a username"` in `--t-3` / 500. `maxLength={24}`, `spellCheck={false}`. On focus the container gets `background var(--bg-2)`, border `var(--accent)`, and an accent focus ring `box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent)`. +- **Language** — same segmented-radio control as Background / Density / Cover aspect, with two options: `English` (value `'en'`) and `Deutsch` (value `'de'`). Active option gets the accent fill, same as the other segmented radios. + +**Library section.** Three rows: **Game folder** (new in v3 — moved out of the top bar), **Grid density**, **Cover aspect**. + +- **Game folder** — see "Game-folder field" below. The first row in the section because it's the only setting users *must* configure for the launcher to work; density and aspect are pure preference. + +**Color swatch picker:** flex row of 8px-gapped buttons. Each swatch is 32×32, `border-radius: 9px`, no border. Inside, a 100% × 100% rounded-8 colored dot with inset shadow `0 0 0 1px rgba(255,255,255,0.08)`. Hover: dot scales 1.06. **Active**: dot has ring `box-shadow: 0 0 0 2px var(--bg-2), 0 0 0 4px ` and shows a centered white check icon with drop-shadow `0 1px 2px rgba(0,0,0,0.5)`. + +Six accent options: Blue `#3b82f6`, Cyan `#22d3ee`, Violet `#a855f7`, Green `#22c55e`, Amber `#f59e0b`, Red `#ef4444`. + +**Segmented radio:** inline-flex with `background var(--bg-3) #1a2330`, `1px solid var(--bd-1)`, `border-radius: 8px`, `padding: 3px`. Each button: 30px tall, `padding: 0 14px`, `border-radius: 6px`, `font 12.5px / 600`. Inactive: `color var(--t-2)`. Active: `background var(--accent)`, `color white`, inset top shadow `0 1px 0 rgba(255,255,255,0.18)`. + +**Done button:** filled button in `--accent`, 36px tall, 13.5px / 600. Closes the dialog. + +Persisted settings (write through to local storage / Tauri config): +- `username`: string, max 24 chars. Default `"Commander"` (placeholder — feel free to default to the OS username on first run). Used as the network identity for LAN sessions; the hint copy *"Shown to other players on the LAN"* tells the user what it does. +- `language`: `'en'` | `'de'`. Default `'en'`. Drives an i18n layer (introduce one if it doesn't exist yet — `react-i18next` or similar). Initial copy is English-only in the mock; German translations need to be added as part of implementation. Recommend detecting the OS locale on first run and defaulting to `'de'` if the system language starts with `de`. +- `accent`: one of the six hex values above. Default `#3b82f6`. +- `bg`: `flat` | `gradient` | `animated`. Default `gradient`. +- `density`: `compact` | `normal` | `large`. Default `normal`. +- `aspect`: `box` | `square` | `banner`. Default `box`. +- `gameFolder`: `string | null`. Absolute path to the parent directory where games are downloaded and installed. Default `null` (unset on first run). See "Game-folder field" below. + +--- + +## Game-folder field + +A settings row inside the **Library** section of the Settings dialog. Exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live). + +**Why it lives in Settings now:** users set this once at install time and basically never touch it again. A permanent top-bar button burned high-attention chrome on a control nobody used after day one. Settings is where one-time configuration belongs. + +Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory: + +| State | Trigger | Path display | Border | Button label | +|---|---|---|---|---| +| **Set & valid** | path is configured and exists on disk | full path in mono, truncated head-first | default `--bd-1` | `Change…` (neutral pill) | +| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Not set` in red | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) + faint red bg tint | `Choose…` (accent-filled pill) | + +"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple. + +**Anatomy:** `inline-flex`, `width: 340px`, `height: 36px`, `padding: 0 4px 0 12px`, `gap: 8px`. `background: var(--bg-3)`, `border-radius: 8px`. Children, left to right: + +1. **Folder icon** — `Icon.folder` from `components.jsx`, 14×14, `var(--t-3)` (set state) or `#f87171` (unset state). +2. **Path display** — `flex: 1`, mono `12px / ui-monospace`, `--t-1`, single line, `overflow: hidden; text-overflow: ellipsis`. **`direction: rtl` + `unicode-bidi: plaintext`** so truncation happens from the head and the leaf folder (the part the user actually cares about) stays visible. When unset: shows the word `Not set` in 12.5 px / 600 / `#f87171` instead. +3. **Action button** — 28 px tall pill, `border-radius: 6px`, `padding: 0 12px`, `font 12.5px / 600`. Set state: neutral `rgba(255,255,255,0.06)` bg, label `Change…`. Unset state: `var(--accent)` fill at 85% alpha, white text, label `Choose…` (so the call-to-action reads stronger when the path needs picking). Click → native folder picker via Tauri; on selection, write through to `settings.gameFolder` and rescan library. + +**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` (unset state). The inner button has its own hover (background opacity bumps). + +**Accessibility:** the path itself is selectable text inside the field; the action button carries `aria-label="Change game folder"` / `"Choose game folder"`. The full path is also exposed via `title` on the path-display element so it's reachable on hover when truncated. + +**Why no inline path on the previous top-bar button anymore?** Original design squeezed the full path into a top-bar button as truncated mono. It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. In the new home (Settings), the field has all the width it needs to show a useful prefix of the path while still keeping the leaf visible — and it's only on screen when the user is actively reconfiguring. + +**Data:** the component takes `value: string | null` and an `onChange(next: string)` callback. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. The `onChange` callback should fire only on successful picker confirmation (not on cancel). In production, derive `value` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing. + +**Dev preview:** the prototype's Tweaks panel exposes a `Game folder` **text field** (under the *Library* section) that writes directly to `t.gameFolder`. Type any string to simulate the set state; clear it to simulate the unset state. This is dev-only — in the real app the value comes from the settings store via the picker, **not** from a free-form text input. Don't ship the Tweaks panel. + +--- + +## Game card + +The unit element of the library grid. + +**Container:** flex column. `background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%)`, `1px solid var(--bd-1)`, `border-radius: 10px`, `overflow: hidden`. Cursor pointer. + +**Hover/focus state:** +- `transform: translateY(-2px)` (180ms `cubic-bezier(.4,1.2,.5,1)`) +- `border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2))` +- Box-shadow `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)` +- Cover inner image scales to 1.03 (350ms cubic-bezier) +- Focus-visible: same lift + 2px solid accent outline + +### Anatomy (top to bottom) + +1. **Cover wrap** — `width: 100%`, `aspect-ratio: 2/3` (box) / `1/1` (square) / `16/9` (banner). `position: relative`, `overflow: hidden`, fallback bg `var(--bg-3)`. + +2. **Cover** (inside cover-wrap, `position: absolute; inset: 0`): + - **Base gradient** — diagonal (`linear-gradient(<110-170deg>, c1, c2)` — angle hashed from game id for variety). Per-game color pair from the game's `cover` metadata. + - **Radial accent blob** — `radial-gradient(ellipse at % %, 38, transparent 55%)`. x/y also hashed from id. + - **Grain / scanline** — two `repeating-linear-gradient` overlays at 1px intervals, `mix-blend-mode: overlay`, opacity 0.7. + - **Decorative SVG mark** — preserveAspectRatio bottom-right, draws a triangle and dot in the accent color at 12% opacity. Variation via id hash. + - **Title** absolutely positioned at bottom-left, padding `14px`. Font `Bebas Neue` (free Google Font, fallback `Oswald, Impact, "Arial Narrow Bold", sans-serif`), 400 weight, uppercase, `letter-spacing: 0.018em`, `line-height: 1.02`, white, text-shadow `0 4px 16px , 0 1px 0 rgba(0,0,0,0.3)`. Size scales by title length: 26px for ≤14 chars, 21px for ≤20, 17px for ≤26, 15px for longer (box aspect; see `components.jsx → GameCover` for square/banner variants). + - **Vignette** — `linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.62) 100%)` over the whole cover, painted *after* the title (so the dark gradient is behind the title visually — title is z-index 2). + - **State chip** in top-right: pill with backdrop-blur, `background rgba(8,12,16,0.78)`, `1px solid rgba(255,255,255,0.08)`, `border-radius: 999px`, `padding: 4px 9px`, font `10.5px / 600`. A 6×6 colored dot (green `#22c55e` for installed, amber `#f59e0b` for local; hidden for "not downloaded") + label. Dot has glow `box-shadow: 0 0 8px `. + - **Multiplayer badge** in top-left: same pill style but slightly lighter background (`rgba(8,12,16,0.65)`). Tiny "users" icon + player range (e.g. `2–32`). Always visible — every LAN game is multiplayer. + +3. **Card body** — `padding: 11px 12px 12px`, flex column, 8px gap: + - **Title** — game's full (mixed-case) title in 13.5px / 600 / `--t-1`, single line, ellipsis on overflow. + - **Meta line** — 11.5px tabular-nums, `--t-3`: size · genre. Dot separator at 50% opacity. + - **Action button** (full width) — primary action depending on state, see below. + +### Action button + +A single button per card with the *primary action for the current state*. Color-coded as the main affordance for state at a glance. + +``` +state label button style +───────────── ────────── ──────────────────────────────────────────── +not downloaded Download neutral: bg rgba(255,255,255,0.08), 1px var(--bd-2), text var(--t-1) +local Install bg var(--accent), text white, inset top hl +installed Play bg linear-gradient(180deg, #2bd07f 0%, #1aa460 100%), text white, inset top hl +downloading — progress see "Download progress" below — the button slot is replaced with a live progress component +``` + +Common sizing: 32px tall (card) or 44px tall (modal). `border-radius: 7px` (card) / 8px (modal). `font 12.5px / 600` (card) / `14px / 600` (modal). 6px gap between icon and label. Icons: filled play triangle, download arrow, install arrow-onto-line (all 12×12). + +Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`. + +**Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons). + +--- + +## Download progress (state === 'downloading') + +When a game is actively downloading, the **action-button slot is replaced** by an inline progress component. The component is its own visual primitive (`DownloadProgress` in `components.jsx`); it is NOT a button with a `` child. Two layouts share the same primitive: + +### Shared visuals + +- Container: `border-radius: 7px` (card) / `9px` (modal), `1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2))`, faint accent halo via `box-shadow`. `container-type: inline-size` (we use container queries for graceful fallback, see below). +- **Progress fill** (`.dl-fill`): absolutely positioned, `width: %`, animated via `transition: width 480ms cubic-bezier(.4,0,.2,1)`. Background is a vertical gradient of `color-mix(in srgb, var(--accent) 38–26%, transparent)`. Right edge gets a 1px accent rule + accent glow. +- **Live shimmer** on top of the fill: `repeating-linear-gradient(115deg, transparent 0 14px, rgba(255,255,255,0.05) 14px 22px)` panned via `animation: dl-stripe 1.4s linear infinite`, `mix-blend-mode: screen`. Subtle — it reads as "live" without being distracting. +- **Pulse dot** (`.dl-pulse`): 7px accent dot with an outward-pulsing `box-shadow` ring (1.4s ease-out infinite). Visual cue that the network transfer is active. +- **Tabular numerics** on all values (`font-variant-numeric: tabular-nums`) so the percentage and speed don't jitter as digits roll over. + +### Card layout (`.dl-md`, replaces the 32px action button) + +A single row. Two values, separated by `justify-content: space-between`: + +- **Left:** ` %` — 12px / 600, `var(--t-1)`. `%` glyph at 0.55 opacity. e.g. `• 32%`. +- **Right:** `` — 11px / 500, `var(--t-2)`. Short format: `49 MB/s` (no decimals at card scale). + +Heights match the action button per density: 30px compact / 32px normal / 34px large. Padding `0 10px` (9 compact / 12 large). Font sizes scale similarly (see `styles.css`). + +**Container-query graceful degradation** — this is the important part, it has to fit every aspect/density combo: + +```css +@container (max-width: 132px) { .dl-md .dl-speed { display: none; } .dl-md-row { justify-content: center; gap: 6px; } } +@container (max-width: 96px) { .dl-md .dl-pulse { display: none; } } +``` + +At 132 px and below, the speed disappears and the percentage centres. At 96 px and below, the pulse dot also drops, leaving just the percentage. This is what guarantees `compact` density + `box` aspect (the narrowest combination) still reads cleanly. + +The state chip in the cover corner still says "Downloading" — we are deliberately NOT repeating that label inside the progress bar. + +### Detail-overlay layout (`.dl-lg`, replaces the 44px modal action button) + +Fixed 56px height. CSS-grid with three columns and two rows: + +``` +grid-template-columns: minmax(0, 1fr) auto auto; +grid-template-areas: + "primary pct cancel" + "secondary pct cancel"; +``` + +- **Primary row** (`.dl-lg-primary`, top-left) — pulse dot + the uppercase live label `DOWNLOADING` in `color-mix(in srgb, var(--accent) 80%, white)`, 13px / 600, `letter-spacing: 0.02em`. This is the only place the word "Downloading" appears in the component. +- **Secondary row** (`.dl-lg-secondary`, bottom-left) — the live stats. 12px, four groups separated by `·` (0.45 opacity): + 1. `11.4 GB / 35 GB` (`var(--t-1)` strong + `var(--t-2)` rest) + 2. `47.6 MB/s` (`var(--t-1)`) + 3. `[users-icon] 5` — `.dl-peers`, inline-flex with 4px gap, icon at 0.7 opacity, count in `var(--t-1)` 600 tabular-nums. Hidden entirely when `game.peers` is falsy. Communicates this is a LAN swarm transfer; the full sentence lives in the `title` tooltip. + 4. `8 min left` (`var(--t-2)`) +- **pct column** — large percentage, 20px / 700, `letter-spacing: -0.01em`, `var(--t-1)`. `%` glyph at 12px / 600 / 0.55 opacity. +- **cancel column** — 28×28 square, `1px solid var(--bd-2)`, `border-radius: 6px`, X icon. Hover: bg `rgba(239,68,68,0.12)`, border `rgba(239,68,68,0.40)`, text `#fca5a5`. Cancelling reverts the game to its prior state (`local` if any data was kept, `none` otherwise) — dev decides the underlying behavior. + +**Graceful degradation in narrow modals:** + +```css +@container (max-width: 320px) { .dl-lg-secondary .dl-eta, .dl-lg-secondary .dl-sep-eta { display: none; } } +@container (max-width: 240px) { .dl-lg-secondary .dl-peers, .dl-lg-secondary .dl-sep-peers { display: none; } } +``` + +ETA drops first, then peers; bytes + speed always stay (they're the actionable numbers). The pct/cancel column never collapses. + +### Number formatting + +All helpers live in `data.jsx`: + +- `fmtSpeed(mbps)` — `49.4 MB/s` below 100, `MM MB/s` (rounded) at/above 100. Used in `.dl-lg`. +- `fmtSpeedShort(mbps)` — always rounded: `49 MB/s`. Used in `.dl-md` so the card stays compact. +- `fmtBytes(gb)` — `<1 GB → MB rounded`, `<10 GB → up to 2 decimals` (trailing zeros stripped: `2.35 GB`, `2.3 GB`, `2 GB`), `≥10 GB → 1 decimal max` (`11.4 GB`, `35 GB`). +- `fmtEta(seconds)` — `< 60s → "N s"`, `< 60min → "N min"`, else `"H h M min"`. + +Keep these formats; they're tuned so the secondary row never wraps at normal modal width. + +### Data shape + +The `Game` type gains a `downloading` state plus two transient fields: + +```ts +type Game = { + // … existing fields … + state: 'installed' | 'local' | 'downloading' | 'none'; + progress?: number; // 0–1, only when state === 'downloading' + speed?: number; // current throughput in MB/s + peers?: number; // number of LAN peers currently seeding +}; +``` + +In the real app, `progress`, `speed`, and `peers` come from the download worker (Tauri command emitting events). The mock's `useLiveDownload(game)` hook (in `components.jsx`) is just a placeholder — 600ms `setInterval` advancing `progress` proportional to `speed`, with `speed` smoothed via a low-pass filter and small random drift so the number doesn't look fake. `peers` is read straight off the game object (static in the mock); in production, push updates as peers join/leave the swarm — the `.dl-peers` chip re-renders silently. Replace the hook with a `useEffect` that subscribes to your real progress events; the rendering layer needs nothing else. + +Filter changes: +- `Local` filter includes `installed` + `local` + `downloading` (in-flight downloads belong on the Local tab — you're managing them). +- Sort by `state` orders `installed < local < downloading < none`. + +### State chip + +Add a fourth entry to `STATE_META`: + +```js +downloading: { label: 'Downloading', dot: 'var(--accent)' } +``` + +Dot uses the live accent so it visually ties to the progress fill. + +--- + +## Filter controls — variant B (not used, kept for reference) + +The two-row chrome has a different filter style — **underlined tabs with counts**, like browser tabs: + +- Buttons: no background, `padding: 10px 14px 12px`, font `13.5px / 600`. Color `--t-2` inactive, `--t-1` active. +- Count chip after label: 11.5px / 600, `padding 1px 7px`, rounded pill. Inactive bg `rgba(255,255,255,0.06)`, text `--t-3`. Active bg `rgba(255,255,255,0.10)`, text `--t-1`. +- Active tab has a 2px underline at the bottom (`left: 12px, right: 12px`) in `--accent`, animated in via opacity + scaleX (220ms cubic-bezier). + +Implement only if you decide variant A doesn't work after building. + +--- + +## Interactions & behavior + +- **Click game card** (anywhere except the action button) → open detail overlay. +- **Click action button on card** → trigger the state-appropriate action without opening the overlay. `e.stopPropagation()` on the button. +- **Press / (slash)** → focus the search input. +- **Type in search** → live-filter the visible grid by title or tag (case-insensitive substring). +- **Click filter tab / segmented pill** → change filter. +- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it. +- **Hover game card** → lift + accent border glow + cover image scale 1.03. +- **Click "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. + +--- + +## State management + +Recommend Zustand or a single React context for global launcher state; Tauri commands for filesystem and process operations. + +**Library state** (rebuilt on `refresh`): +```ts +type Game = { + id: string; + title: string; + size: number; // GB + version: string; // "YYYY.MM.DD" + desc: string; + state: 'installed' | 'local' | '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. + +**UI state:** +```ts +type LauncherUI = { + filter: 'all' | 'local' | 'installed'; + sort: 'az' | 'size' | 'recent' | 'state'; + query: string; + openGameId: string | null; + settingsOpen: boolean; +}; +``` + +**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 +}; +``` + +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. + +--- + +## Design tokens + +### Color + +| token | value | usage | +|---|---|---| +| `--bg-0` | `#0a0e13` | launcher background | +| `--bg-1` | `#0f151c` | card bottom gradient stop | +| `--bg-2` | `#131b25` | top bar / card top / search bg | +| `--bg-3` | `#1a2330` | settings segmented bg / cover fallback | +| `--bg-4` | `#232f3e` | (reserved) | +| `--bd-1` | `rgba(255,255,255,0.06)` | subtle border | +| `--bd-2` | `rgba(255,255,255,0.10)` | stronger border | +| `--bd-3` | `rgba(255,255,255,0.16)` | scrollbar thumb | +| `--t-1` | `#e6edf3` | primary text | +| `--t-2` | `#9aa6b4` | secondary text | +| `--t-3` | `#6b7785` | muted text / metadata | +| `--t-4` | `#4a5663` | (reserved) | +| `--ok` | `#22c55e` | "installed" dot | +| `--warn` | `#f59e0b` | "local" dot | +| `--danger` | `#ef4444` | destructive actions | +| `--accent` | user-selected, default `#3b82f6` | primary actions, focus rings, brand mark | + +### Typography + +- **UI font** — system sans stack: `-apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif` +- **Cover-art display font** — `"Bebas Neue"` (Google Fonts, weight 400) with fallback `"Oswald", Impact, "Arial Narrow Bold", sans-serif` +- **Monospace** — `ui-monospace, "SF Mono", Menlo, Consolas, monospace` (used for: directory path, version field in detail overlay) + +Sizing reference: +- Brand wordmark: 15 / 700 +- Modal title: 32 / 700 / -0.015em +- Card title: 13.5 / 600 +- Filter pill label: 12.5 / 600 +- Settings row label: 14 / 600 +- Section title (settings): 10.5 / 700 / 0.12em uppercase +- Meta (size · genre, etc.): 11.5 / tabular-nums / `--t-3` +- Hint text: 12 / `--t-3` + +### Spacing & radii + +- Card radius: 10px +- Modal radius: 14px +- Pill/control radius: 8px (search, sort, dir button), 999px (filter segmented), 7px (action button) +- Common gaps: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 28 +- Card body padding: 11 12 12 + +### Shadows + +- Card hover: `0 14px 30px -16px color-mix(var(--accent), 50%, black), 0 0 0 1px color-mix(var(--accent), 30%, transparent)` +- Modal: `0 30px 80px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.04)` +- Brand mark: `0 6px 20px -6px color-mix(var(--accent), 60%, black), inset 0 1px 0 rgba(255,255,255,0.22)` +- Action button (filled): `0 6px 16px -8px , inset 0 1px 0 rgba(255,255,255,0.22)` + +--- + +## Assets + +Cover art in the design files is **stylized placeholder art** — generated entirely from the game's metadata (color pair + accent color + id hash for angle/blob position) plus the title typeset in Bebas Neue. There are no real game cover image assets in this design. + +In the production app, the launcher should ideally use real cover-art when available (fetch from IGDB / Steam / local game folder) and fall back to the placeholder generator for games without art. The placeholder generator is in `design_reference/components.jsx → GameCover`. + +The icon set (search, play, **server**, install, download, folder, kebab, sort, users, close, check, chevron, trash) is in `design_reference/components.jsx → Icon`. They are 12-14px inline SVGs using `currentColor`. Reuse as-is or substitute with the codebase's existing icon library at the same visual weight. The `server` glyph is new in this round — two stacked rounded rectangles with LED dots, used only on the Start Server button. + +Fonts to load: +```html + + + +``` + +--- + +## File reference + +``` +design_reference/ +├── SoftLAN Launcher.html ← entry; wires React + Babel, mounts +├── styles.css ← all visual styles (CSS custom props + components) +├── data.jsx ← mock GAMES array + filter/sort helpers + STORAGE mock +├── components.jsx ← Icon, GameCover, StateChip, ActionButton, GameCard, +│ SegmentedFilters, UnderlineFilters, SearchField, +│ SortMenu, StorageMeter, KebabMenu, +│ GameDetailModal, SettingsDialog (incl. GameFolderField) +└── launcher.jsx ← component composing chrome + grid + modals +``` + +To preview the design in a browser: +1. Open `SoftLAN Launcher.html` in a static-server (e.g. `python -m http.server` from the folder). +2. You'll see a design canvas with all variants (A, B, C, D, E, F) side-by-side. Click an artboard's expand button to view it full-screen. + - **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** + - **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. + +--- + +## 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. +- **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. diff --git a/design/design_reference/SoftLAN Launcher.html b/design/launcher/design_reference/SoftLAN Launcher.html similarity index 100% rename from design/design_reference/SoftLAN Launcher.html rename to design/launcher/design_reference/SoftLAN Launcher.html diff --git a/design/design_reference/components.jsx b/design/launcher/design_reference/components.jsx similarity index 100% rename from design/design_reference/components.jsx rename to design/launcher/design_reference/components.jsx diff --git a/design/design_reference/data.jsx b/design/launcher/design_reference/data.jsx similarity index 100% rename from design/design_reference/data.jsx rename to design/launcher/design_reference/data.jsx diff --git a/design/launcher/design_reference/design-canvas.jsx b/design/launcher/design_reference/design-canvas.jsx new file mode 100644 index 0000000..f7c553a --- /dev/null +++ b/design/launcher/design_reference/design-canvas.jsx @@ -0,0 +1,966 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// Recursively unwrap React.Fragment so <>… grouping doesn't hide +// DCSection/DCArtboard children from the type-based walks below. +function dcFlatten(children) { + const out = []; + React.Children.forEach(children, (c) => { + if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children)); + else out.push(c); + }); + return out; +} + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Fragments are flattened; wrapping in other + // elements still opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + dcFlatten(children).forEach((sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + dcFlatten(sec.props.children).forEach((ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's + // marginBottom) reflow on every scale change, vertically shifting the + // world layout — so a world point mathematically pinned under the cursor + // drifts as you zoom (content creeps up on zoom-in, down on zoom-out). + // Anchor the DOM element under the cursor instead: record its screen Y, + // apply the transform + --dc-inv-zoom, then cancel whatever vertical + // drift the reflow introduced so it stays put on screen. + let marker = null, markerY0 = 0; + if (k !== 1) { + const hit = document.elementFromPoint(cx, cy); + marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null; + if (marker) markerY0 = marker.getBoundingClientRect().top; + } + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + if (marker) { + // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any + // departure after the --dc-inv-zoom reflow is the layout drift. + const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k); + if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); } + } + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(dcFlatten(children)); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/design/design_reference/launcher.jsx b/design/launcher/design_reference/launcher.jsx similarity index 100% rename from design/design_reference/launcher.jsx rename to design/launcher/design_reference/launcher.jsx diff --git a/design/design_reference/styles.css b/design/launcher/design_reference/styles.css similarity index 100% rename from design/design_reference/styles.css rename to design/launcher/design_reference/styles.css diff --git a/design/launcher/design_reference/tweaks-panel.jsx b/design/launcher/design_reference/tweaks-panel.jsx new file mode 100644 index 0000000..79ccfe9 --- /dev/null +++ b/design/launcher/design_reference/tweaks-panel.jsx @@ -0,0 +1,568 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "palette": ["#D97757", "#29261b", "#f6f4ef"], +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('palette', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} + + .twk-chips{display:flex;gap:6px} + .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px; + padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default; + box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06); + transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s} + .twk-chip:hover{transform:translateY(-1px); + box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)} + .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85), + 0 2px 6px rgba(0,0,0,.15)} + .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%; + display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)} + .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)} + .twk-chip>span>i:first-child{box-shadow:none} + .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px; + filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + // Same-window signal so in-page listeners (deck-stage rail thumbnails) + // can react — the parent message only reaches the host, not peers. + window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits })); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + // Auto-inject a rail toggle when a is on the page. The + // toggle drives the deck's per-viewer _railVisible via window message; + // state is mirrored from the same localStorage key the deck reads so + // the control reflects reality across reloads. The mechanism is the + // message — authors who want custom placement can post it directly + // and pass noDeckControls to suppress this one. + const hasDeckStage = React.useMemo( + () => typeof document !== 'undefined' && !!document.querySelector('deck-stage'), + [], + ); + // deck-stage enables its rail in connectedCallback, but this panel can + // mount before that element has upgraded. The initial read catches the + // common case; the listener covers mounting first. (Older deck-stage.js + // copies still wait for the host's __omelette_rail_enabled postMessage — + // same listener handles those.) + const [railEnabled, setRailEnabled] = React.useState( + () => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled, + ); + React.useEffect(() => { + if (!hasDeckStage || railEnabled) return undefined; + const onMsg = (e) => { + if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true); + }; + window.addEventListener('message', onMsg); + return () => window.removeEventListener('message', onMsg); + }, [hasDeckStage, railEnabled]); + const [railVisible, setRailVisible] = React.useState(() => { + try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; } + }); + const toggleRail = (on) => { + setRailVisible(on); + window.postMessage({ type: '__deck_rail_visible', on }, '*'); + }; + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
+ {children} + {hasDeckStage && railEnabled && !noDeckControls && ( + + + + )} +
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + // Segments wrap mid-word once per-segment width runs out. The track is + // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px + // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2 + // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall + // back to a dropdown rather than wrap. + const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length; + const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0); + const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0); + if (!fitsAsSegments) { + // onChange(e.target.value)}> + {options.map((o) => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return ; + })} + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +// Relative-luminance contrast pick — checkmarks drawn over a swatch need to +// read on both #111 and #fafafa without per-option configuration. Hex input +// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light". +function __twkIsLight(hex) { + const h = String(hex).replace('#', ''); + const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0'); + const n = parseInt(x.slice(0, 6), 16); + if (Number.isNaN(n)) return true; + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return r * 299 + g * 587 + b * 114 > 148000; +} + +const __TwkCheck = ({ light }) => ( + +); + +// TweakColor — curated color/palette picker. Each option is either a single +// hex string or an array of 1-5 hex strings; the card adapts — a lone color +// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the +// rest stacked in a sharp column on the right. onChange emits the +// option in the shape it was passed (string stays string, array stays array). +// Without options it falls back to the native color input for back-compat. +function TweakColor({ label, value, options, onChange }) { + if (!options || !options.length) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); + } + // Native emits lowercase hex per the HTML spec, so + // compare case-insensitively. String() guards JSON.stringify(undefined), + // which returns the primitive undefined (no .toLowerCase). + const key = (o) => String(JSON.stringify(o)).toLowerCase(); + const cur = key(value); + return ( + +
+ {options.map((o, i) => { + const colors = Array.isArray(o) ? o : [o]; + const [hero, ...rest] = colors; + const sup = rest.slice(0, 4); + const on = key(o) === cur; + return ( + + ); + })} +
+
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/design/design_reference/logo/INTEGRATION.md b/design/logo/INTEGRATION.md similarity index 62% rename from design/design_reference/logo/INTEGRATION.md rename to design/logo/INTEGRATION.md index 09b9a06..8caf9ee 100644 --- a/design/design_reference/logo/INTEGRATION.md +++ b/design/logo/INTEGRATION.md @@ -13,10 +13,12 @@ logo_handoff/ ├── demo.html ← open in a browser to see it in motion + in context ├── INTEGRATION.md ← this file └── assets/ - ├── softlan-mark.svg static S, fill="currentColor" (inline use) - ├── softlan-mark-blue.svg static S, fixed #3b82f6 (img/favicon use) - ├── softlan-tile.svg rounded app-icon tile, gradient + white S - └── favicon.svg = tile (drop-in favicon) + ├── softlan-mark.svg static S, fill="currentColor" (inline use) + ├── softlan-mark-blue.svg static S, fixed #3b82f6 (img/favicon use) + ├── softlan-tile.svg rounded app-icon tile, gradient + white S + ├── softlan-lockup.svg full lockup (tile + SoftLAN + LAUNCHER), on dark + ├── softlan-lockup-ink.svg full lockup for light backgrounds + └── favicon.svg = tile (drop-in favicon) ``` --- @@ -127,7 +129,66 @@ rsvg-convert -w 32 -h 32 assets/favicon.svg > favicon-32.png --- -## 4. Accessibility & motion +## 4. The logo lockup (icon + wordmark) + +The full logo is the **gradient app-tile + the “SoftLAN” wordmark**, with a +tracked-out “LAUNCHER” beneath. This is the canonical lockup — use it for the +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. + +### Drop-in React (from `pixel-live.jsx`) +```jsx +import { Lockup, Wordmark } from './pixel-live'; + +// full lockup, mark stays alive inside the tile: + // on dark (default) + // on light bg + // compact, no "LAUNCHER" + // static S in the tile + +// just the lettering (e.g. next to the bare LiveLogo in a slim top bar): + +``` +`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 +SoftLAN + +``` + +### 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). + +Both are self-contained, baked to brand blue `#3b82f6`. To re-tint, edit the +three `linearGradient` stops and the “LAN” `fill`. + +--- + +## 5. Accessibility & motion - The static SVGs carry `role="img"` + `aria-label="SoftLAN"`. Give the live component an accessible label in its host (e.g. wrap with `aria-label`), since @@ -142,8 +203,10 @@ rsvg-convert -w 32 -h 32 assets/favicon.svg > favicon-32.png --- -## 5. Quick check +## 6. Quick check 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. diff --git a/design/design_reference/logo/assets/favicon.svg b/design/logo/assets/favicon.svg similarity index 100% rename from design/design_reference/logo/assets/favicon.svg rename to design/logo/assets/favicon.svg diff --git a/design/logo/assets/softlan-lockup-ink.svg b/design/logo/assets/softlan-lockup-ink.svg new file mode 100644 index 0000000..0a04360 --- /dev/null +++ b/design/logo/assets/softlan-lockup-ink.svg @@ -0,0 +1,43 @@ + + SoftLAN Launcher + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SoftLAN + + LAUNCHER + \ No newline at end of file diff --git a/design/logo/assets/softlan-lockup.svg b/design/logo/assets/softlan-lockup.svg new file mode 100644 index 0000000..77854c7 --- /dev/null +++ b/design/logo/assets/softlan-lockup.svg @@ -0,0 +1,43 @@ + + SoftLAN Launcher + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SoftLAN + + LAUNCHER + \ No newline at end of file diff --git a/design/design_reference/logo/assets/softlan-mark-blue.svg b/design/logo/assets/softlan-mark-blue.svg similarity index 100% rename from design/design_reference/logo/assets/softlan-mark-blue.svg rename to design/logo/assets/softlan-mark-blue.svg diff --git a/design/design_reference/logo/assets/softlan-mark.svg b/design/logo/assets/softlan-mark.svg similarity index 100% rename from design/design_reference/logo/assets/softlan-mark.svg rename to design/logo/assets/softlan-mark.svg diff --git a/design/design_reference/logo/assets/softlan-tile.svg b/design/logo/assets/softlan-tile.svg similarity index 100% rename from design/design_reference/logo/assets/softlan-tile.svg rename to design/logo/assets/softlan-tile.svg diff --git a/design/design_reference/logo/demo.html b/design/logo/demo.html similarity index 83% rename from design/design_reference/logo/demo.html rename to design/logo/demo.html index cfb6567..5a7282b 100644 --- a/design/design_reference/logo/demo.html +++ b/design/logo/demo.html @@ -83,6 +83,19 @@ .swatches { display: flex; gap: 8px; align-items: center; margin-top: 2px; } .swatches span { font-size: 12px; color: #6b7785; margin-right: 4px; } .swatches button { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.18); cursor: pointer; padding: 0; } + + /* lockup card */ + .lockcard { + width: min(960px, 100%); border-radius: 20px; + background: linear-gradient(180deg, #0e141b 0%, #0a0e13 100%); + border: 1px solid rgba(255,255,255,0.07); + padding: 40px 36px; display: flex; flex-direction: column; gap: 28px; + } + .lockcard h2 { margin: 0; font-family: 'Bebas Neue', sans-serif; font-weight: 400; font-size: 26px; letter-spacing: 0.03em; } + .lockrow { display: flex; align-items: center; gap: 40px; flex-wrap: wrap; } + .lockrow.light { background: #f0eee9; border-radius: 14px; padding: 24px 28px; } + .lab { font-size: 11px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #5b6675; margin-bottom: 14px; } + .lab.dark { color: #94908a; } @@ -121,6 +134,26 @@ function App() { 3 PEERS ONLINE
+ {/* the canonical logo lockup */} +
+

The logo lockup

+
+
On dark — primary
+
+ + + +
+
+
+
On light — ink variant
+
+ + +
+
+
+ {/* hero stage */}

The mark, alive

diff --git a/design/design_reference/logo/pixel-live.jsx b/design/logo/pixel-live.jsx similarity index 73% rename from design/design_reference/logo/pixel-live.jsx rename to design/logo/pixel-live.jsx index 3e3a810..e3a0138 100644 --- a/design/design_reference/logo/pixel-live.jsx +++ b/design/logo/pixel-live.jsx @@ -176,4 +176,50 @@ const LiveLogo = forwardRef(function LiveLogo( ); }); -Object.assign(window, { LiveLogo, renderSnake, renderGlitch, renderRGB, renderStatic }); +// ── Wordmark: "SoftLAN" + "LAUNCHER", the exact brand lettering ── +// `light` controls the "Soft" ink: true → #e6edf3 (dark UI), false → #0a0e13 (light UI). +// "LAN" is always the accent. Scales off `size` (the cap-height-ish wordmark px). +function Wordmark({ accent = '#3b82f6', size = 25, light = true, sub = true }) { + return ( +
+
+ Soft + LAN +
+ {sub && ( +
Launcher
+ )} +
+ ); +} + +// ── Lockup: the gradient app-tile (with the live or static S) + the Wordmark ── +// This is the canonical horizontal logo lockup. `tile` is the tile px size; the +// wordmark scales with it. `live` keeps the mark animated inside the tile. +function Lockup({ accent = '#3b82f6', tile = 44, light = true, sub = true, live = true }) { + const rad = Math.round(tile * 0.225); + const markSize = Math.round(tile * 0.62); + return ( +
+
+ {live + ? + : {renderStatic('#ffffff')}} +
+ +
+ ); +} + +Object.assign(window, { LiveLogo, Wordmark, Lockup, renderSnake, renderGlitch, renderRGB, renderStatic });