feat(tauri): implement Steam-style launcher redesign per design handoff

Replace the previous monolithic 900-line `App.tsx` launcher UI with the
Steam-inspired dark redesign specified in `design/README.md` (handoff
committed in the previous commit). The new UI is split across small,
single-responsibility React modules instead of one file.

What changes from the user's perspective
----------------------------------------

- Dark, gradient-tinted background with sticky 64px top bar (glass blur
  + saturate). Single-row chrome (handoff variant A).
- Pill-style filter toggle (`All Games` / `Local` / `Installed`) with an
  animated thumb that slides between options.
- Search field with magnifying-glass icon and a `/` keyboard shortcut to
  focus it from anywhere outside an input.
- Sort menu (Name A–Z / Size / Status) as a dropdown.
- Game directory button shows the current path with leading-ellipsis
  truncation; clicking it opens the native folder picker.
- Kebab menu hosts Settings, Refresh library, and Unpack logs (existing
  companion window). The standalone Unpack-Logs button is removed from
  the chrome.
- Game grid uses CSS `auto-fill` minmax with three density presets
  (compact / normal / large) and three cover aspects (box / square /
  banner), persisted via the Settings dialog.
- Game cards render with the real thumbnail when the backend has one
  (via `get_game_thumbnail`) and fall back to a procedurally-generated
  gradient + accent-blob placeholder with a Bebas Neue title burned in.
  Each card carries a color-coded state chip (Installed = green,
  Downloaded = amber, busy = pulsing accent), a peers chip when at
  least one peer holds the game, the title, size · genre meta line, a
  status line (errors in red), and a single color-coded primary action
  button: Play (green gradient), Update / Install (accent), Download
  (neutral), animated "busy" spinner during in-flight operations, or a
  disabled "Unavailable" state when no peer has the game.
- Clicking anywhere on a card except the action button opens a detail
  modal: 16:7 hero (uses the thumbnail), state chip, tag pills derived
  from genre/publisher/release_year, large title, 4-cell meta grid
  (size, players from `max_players`, version from `local_version` or
  `eti_game_version` formatted YYYY.MM.DD, status), description, and an
  action row with the primary action plus an Uninstall ghost-danger
  button when the game is installed. Esc, scrim click, and the close
  button all dismiss the modal.
- Settings dialog (opened from the kebab menu) lets the user change the
  accent color (six swatches), background style (flat / gradient /
  animated), grid density, and cover aspect. Changes apply live and
  persist immediately to the Tauri store under `launcher-settings.json`
  (key `ui-settings`); the existing `game-directory` key in the same
  file is unchanged.
- Empty state when no directory is chosen offers a centered prompt with
  a single CTA. Empty state when filters/search match nothing shows a
  distinct "Nothing matches" message.

Why this approach
-----------------

The handoff selected variant A (single-row chrome) explicitly, so only
that variant is implemented; variant B underlined tabs and the
storage-meter widget are intentionally omitted (no free-space data
available from the backend yet).

Real cover art from `get_game_thumbnail` is preferred over the
placeholder generator. When a thumbnail is present, the Bebas Neue
title overlay is suppressed because shipped cover art already has its
own title. When the thumbnail is absent, the placeholder gradient (with
per-id stable hue/blob/angle) plus the burned-in title takes over —
this is the same procedural look as the design reference.

Architecture / file layout
--------------------------

The previous single-file design is decomposed top-down:

```
src/
  main.tsx                    entry; loads tokens + launcher CSS
  App.tsx                     thin router (main vs. unpack-logs view)
  styles/
    tokens.css                CSS custom props + body reset
    launcher.css              port of the design reference styles.css
                              (single-row chrome only)
  windows/
    MainWindow.tsx            composition root: top bar + grid + modals
  lib/
    types.ts                  Game, InstallStatus, GameAvailability,
                              ActiveOperationKind, GameFilter / GameSort,
                              DerivedState
    gameState.ts              derive() + isUnavailable + needsUpdate +
                              primaryActionFor + actionLabel +
                              mergeGameUpdate (event reconciliation) +
                              countByFilter + applyFilterAndSort
    format.ts                 formatBytes, formatEtiVersion (YYYYMMDD),
                              truncatePath, formatPlayers
    cover.ts                  coverColorsFor(id) — stable palette pick +
                              gradient angle + blob position from id
                              hash; titleFontSize
    store.ts                  file + key constants for plugin-store
  hooks/
    useSettings.ts            UISettings + accent/bg/density/aspect/
                              sort/filter, persisted via plugin-store
    useGameDirectory.ts       loads + persists the chosen directory and
                              pushes it to update_game_directory
    useGames.ts               owns the games list; listens to every
                              backend event (games-list-updated,
                              game-download-begin/finished/failed/
                              peers-gone, game-no-peers, game-install-
                              begin/finished/failed, game-uninstall-
                              begin/finished/failed, peer-count-updated);
                              exposes markChecking with a 5s fallback to
                              clear "Checking peers…" when nothing comes
                              back from the backend
    useGameActions.ts         play / install / update / uninstall
                              wrappers around the corresponding invoke
                              commands
    useThumbnails.ts          lazy per-id cache for get_game_thumbnail
  components/
    Icon.tsx                  inline SVG icon set (currentColor)
    Brand.tsx                 brand mark + name + peer-count chip
    Modal.tsx                 generic scrim + panel + Esc handler
    StateChip.tsx             corner pill with state-coded dot
    ActionButton.tsx          color-coded primary action; disabled when
                              unavailable; spinner when busy
    SegmentedRadio.tsx        generic 3-way segmented control
    ColorSwatchPicker.tsx     6-swatch picker with check overlay
    topbar/
      TopBar.tsx              chrome composition
      SegmentedFilters.tsx    All / Local / Installed with sliding thumb
      SearchField.tsx         input + `/` shortcut
      SortMenu.tsx            dropdown sort selector
      DirectoryButton.tsx     folder picker trigger
      KebabMenu.tsx           generic dropdown menu
    grid/
      ResultsBar.tsx          "Showing N of M games"
      GameGrid.tsx            CSS-grid wrapper
      GameCard.tsx            full card composition
      GameCover.tsx           thumbnail OR placeholder cover art
    modals/
      GameDetailModal.tsx     hero + meta grid + actions
      SettingsDialog.tsx      appearance + library preferences
    empty/
      NoDirectoryState.tsx    onboarding CTA
      EmptyResultsState.tsx   "scanning" / "nothing matches"
```

`UnpackLogsWindow.tsx` and its CSS are untouched — the unpack-logs
companion window is rendered as before via the existing `?view=unpack-
logs` route in `App.tsx`.

The previous `App.css` is removed entirely (its styles are superseded
by `styles/launcher.css`).

Bebas Neue is loaded via Google Fonts in `index.html` (preconnect +
swap), used for the brand mark and the placeholder cover-art titles.

Tradeoffs and intentional omissions
-----------------------------------

- Storage meter: omitted. The handoff specifies installed/local/free
  bytes, but no Tauri command currently provides free-space data.
- Variant B (two-row chrome with underline tabs): omitted; the handoff
  picked variant A.
- "View files" action in the detail modal: omitted. The backend doesn't
  expose per-game install paths and `shell.open` of the user-chosen
  root directory would be misleading.
- "Delete from disk" ghost-danger action for `local` games: omitted.
  No backend command currently distinguishes "delete downloaded
  archive" from `uninstall_game`. Only installed games get an Uninstall
  button.
- "Recently Played" sort: omitted (no play-time tracking yet). The sort
  menu offers Name / Size / Status instead.
- Keyboard arrow grid navigation: not yet implemented (out of scope per
  the handoff).
- Per-game progress bar during downloads/installs: not implemented; the
  action button shows a spinner + "Downloading…" / "Installing…" label
  instead, matching the existing event-driven status text.

Persistence
-----------

UI preferences (accent, bg, density, aspect, sort, filter) live in
`launcher-settings.json` under a new `ui-settings` key. The existing
`game-directory` key in the same file is preserved untouched, so users
keep their previously selected directory.

Test plan
---------

Frontend build verified locally:

  cd crates/lanspread-tauri-deno-ts && deno task build
  → `tsc && vite build` completes with no diagnostics; bundle ~228 kB.

Manual verification (recommended once the app boots end-to-end):

- [ ] Launch with no directory set: only the "Pick a game directory"
      empty state is visible; clicking the button opens the native
      folder picker.
- [ ] Pick a directory: top bar appears, grid populates as games arrive.
- [ ] Click the All / Local / Installed pills: the thumb slides; the
      count chips reflect the right subset.
- [ ] Press `/`: focus moves to the search input; type a substring and
      confirm the grid filters live.
- [ ] Open the Sort menu, switch between sorts; the grid reorders.
- [ ] Open the Settings dialog from the kebab: change accent → the
      thumb, brand mark, search-focus ring, and Install button all
      switch color live. Change density → grid card size changes.
      Change cover aspect → cards re-shape (2/3, 1/1, 16/9). Close and
      reopen: choices are remembered.
- [ ] Click anywhere on a card except the action button → detail modal
      opens with the right metadata; Esc / scrim click / close button
      all dismiss it.
- [ ] Click the action button on an `installed` card → game launches.
- [ ] Click the action button on a `local` card → install starts;
      button shows the spinner + "Installing…".
- [ ] Click on a `none` card with peer_count > 0 → download starts; the
      lifecycle events update the button label correctly.
- [ ] Card for a game with peer_count == 0 and not downloaded → button
      reads "Unavailable" and is disabled.
- [ ] Trigger a `game-download-failed` from the backend: the error
      status line appears under the card title in red.
- [ ] Open Unpack Logs from the kebab: the companion window opens
      exactly as before.

Trailer
-------
Refs: design/README.md (canonical handoff), design/design_reference/
This commit is contained in:
2026-05-19 20:12:57 +02:00
parent 27c71978d2
commit 640214ec38
38 changed files with 3329 additions and 1259 deletions
@@ -0,0 +1,58 @@
/**
* Stable gradient + accent derived from a game id. Used as cover-art fallback
* when the backend has no thumbnail for a game.
*/
export interface CoverColors {
c1: string;
c2: string;
accent: string;
angle: number;
blobX: number;
blobY: number;
}
const PALETTE: Array<Pick<CoverColors, 'c1' | 'c2' | 'accent'>> = [
{ c1: '#7c2d12', c2: '#1c1917', accent: '#fbbf24' },
{ c1: '#1e40af', c2: '#0c1f3a', accent: '#22d3ee' },
{ c1: '#15803d', c2: '#052e16', accent: '#fef08a' },
{ c1: '#7f1d1d', c2: '#0a0a0a', accent: '#f97316' },
{ c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b' },
{ c1: '#a16207', c2: '#422006', accent: '#fde047' },
{ c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee' },
{ c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee' },
{ c1: '#064e3b', c2: '#020617', accent: '#34d399' },
{ c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee' },
];
const hash = (id: string): number => {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return h;
};
export const coverColorsFor = (id: string): CoverColors => {
const h = hash(id);
const base = PALETTE[h % PALETTE.length];
return {
...base,
angle: 110 + (h % 60),
blobX: 60 + (h % 30),
blobY: 10 + ((h * 7) % 30),
};
};
export const titleFontSize = (
title: string,
aspect: 'box' | 'square' | 'banner',
): number => {
const len = title.length;
if (aspect === 'banner' || aspect === 'square') {
if (len > 22) return 18;
if (len > 14) return 22;
return 28;
}
if (len > 26) return 15;
if (len > 20) return 17;
if (len > 14) return 21;
return 26;
};
@@ -0,0 +1,29 @@
const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
export const formatBytes = (bytes: number): string => {
if (bytes >= GB) return `${(bytes / GB).toFixed(1)} GB`;
if (bytes >= MB) return `${(bytes / MB).toFixed(0)} MB`;
return `${bytes} B`;
};
/**
* Format an ETI version stamp (YYYYMMDD) for display. Falls back to the raw
* string when it doesn't fit the expected shape.
*/
export const formatEtiVersion = (raw: string | undefined): string => {
if (!raw) return '—';
if (raw.length === 8 && /^\d{8}$/.test(raw)) {
return `${raw.slice(0, 4)}.${raw.slice(4, 6)}.${raw.slice(6, 8)}`;
}
return raw;
};
/** Truncate a path with a leading ellipsis when it exceeds the limit. */
export const truncatePath = (path: string, max = 36): string =>
path.length > max ? `${path.slice(-(max - 1))}` : path;
export const formatPlayers = (max?: number): string => {
if (!max || max <= 0) return '—';
return max === 1 ? '1' : `1${max}`;
};
@@ -0,0 +1,212 @@
import {
ActiveOperation,
ActiveOperationKind,
DerivedState,
Game,
GameFilter,
GameSort,
GamesListPayload,
InstallStatus,
} from './types';
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.CheckingPeers,
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
]);
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
]);
export const isInProgress = (status: InstallStatus): boolean =>
IN_PROGRESS_INSTALL_STATUSES.has(status);
const isReconciledOperationStatus = (status: InstallStatus): boolean =>
RECONCILED_OPERATION_STATUSES.has(status);
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
switch (op) {
case ActiveOperationKind.Downloading:
return InstallStatus.Downloading;
case ActiveOperationKind.Installing:
case ActiveOperationKind.Updating:
return InstallStatus.Installing;
case ActiveOperationKind.Uninstalling:
return InstallStatus.Uninstalling;
}
};
export const activeStatusById = (ops: ActiveOperation[] = []): Map<string, InstallStatus> =>
new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)]));
export const normalizeGamesListPayload = (
payload: GamesListPayload | Game[],
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
/**
* Reconcile a freshly received backend snapshot of a game with our prior
* locally-tracked install status. Keeps in-progress operations visible across
* snapshots that don't yet reflect the running operation.
*/
export const mergeGameUpdate = (
incoming: Game,
previous?: Game,
activeStatus?: InstallStatus,
hasAuthoritativeSnapshot = false,
): Game => {
let installStatus = InstallStatus.NotInstalled;
if (activeStatus !== undefined) {
installStatus = activeStatus;
} else if (incoming.installed) {
installStatus = InstallStatus.Installed;
} else if (
previous
&& isInProgress(previous.install_status)
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
) {
installStatus = previous.install_status;
}
const localStateChanged = previous !== undefined
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
const activeStateReconciled = hasAuthoritativeSnapshot
&& (activeStatus !== undefined
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status)));
const clearStatus = localStateChanged || activeStateReconciled;
return {
...incoming,
availability: incoming.availability,
install_status: installStatus,
status_message: clearStatus ? undefined : previous?.status_message,
status_level: clearStatus ? undefined : previous?.status_level,
peer_count: incoming.peer_count ?? 0,
};
};
/** Visual card state — used for state chip color and action button styling. */
export const deriveState = (game: Game): DerivedState => {
if (isInProgress(game.install_status)) return 'busy';
if (game.installed) return 'installed';
if (game.downloaded) return 'local';
return 'none';
};
export const isUnavailable = (game: Game): boolean =>
!game.installed
&& !game.downloaded
&& game.peer_count === 0
&& game.install_status === InstallStatus.NotInstalled;
export const needsUpdate = (game: Game): boolean => {
if (!game.installed) return false;
const peer = game.eti_game_version;
const local = game.local_version;
if (!local && peer) return true;
if (local && peer) {
const l = parseInt(local, 10);
const p = parseInt(peer, 10);
if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l;
}
return false;
};
/** What pressing the card's main action button should do, given the state. */
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
export const primaryActionFor = (game: Game): PrimaryAction => {
if (isInProgress(game.install_status)) return 'busy';
if (isUnavailable(game)) return 'disabled';
if (!game.installed) return game.downloaded ? 'install' : 'download';
if (needsUpdate(game)) return 'update';
return 'play';
};
export const inProgressLabel = (status: InstallStatus): string | undefined => {
switch (status) {
case InstallStatus.CheckingPeers:
return 'Checking peers…';
case InstallStatus.Downloading:
return 'Downloading…';
case InstallStatus.Installing:
return 'Installing…';
case InstallStatus.Uninstalling:
return 'Uninstalling…';
default:
return undefined;
}
};
export const actionLabel = (game: Game): string => {
const busy = inProgressLabel(game.install_status);
if (busy) return busy;
if (isUnavailable(game)) return 'Unavailable';
if (!game.installed) return game.downloaded ? 'Install' : 'Download';
if (needsUpdate(game)) return 'Update';
return 'Play';
};
/** Counts shown on filter pills. */
export interface FilterCounts {
all: number;
local: number;
installed: number;
}
export const countByFilter = (games: Game[]): FilterCounts => ({
all: games.length,
local: games.filter(g => g.installed || g.downloaded).length,
installed: games.filter(g => g.installed).length,
});
const matchesFilter = (game: Game, filter: GameFilter): boolean => {
switch (filter) {
case 'local':
return game.installed || game.downloaded;
case 'installed':
return game.installed;
case 'all':
return game.installed || game.downloaded || game.peer_count > 0;
}
};
const STATE_SORT_ORDER: Record<DerivedState, number> = {
busy: 0,
installed: 1,
local: 2,
none: 3,
};
const compareByState = (a: Game, b: Game): number => {
const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)];
return diff !== 0 ? diff : a.name.localeCompare(b.name);
};
export const applyFilterAndSort = (
games: Game[],
filter: GameFilter,
sort: GameSort,
query: string,
): Game[] => {
let list = games.filter(g => matchesFilter(g, filter));
const q = query.trim().toLowerCase();
if (q) {
list = list.filter(g =>
g.name.toLowerCase().includes(q)
|| (g.genre?.toLowerCase().includes(q) ?? false)
|| (g.publisher?.toLowerCase().includes(q) ?? false),
);
}
switch (sort) {
case 'az':
return [...list].sort((a, b) => a.name.localeCompare(b.name));
case 'size':
return [...list].sort((a, b) => b.size - a.size);
case 'status':
return [...list].sort(compareByState);
}
};
@@ -0,0 +1,12 @@
/** File names + keys for the @tauri-apps/plugin-store-backed persistent state. */
export const SETTINGS_FILE = 'launcher-settings.json';
export const GAME_DIR_KEY = 'game-directory';
export const UI_SETTINGS_KEY = 'ui-settings';
export const SETTINGS_FILE_OPTIONS = {
autoSave: true,
defaults: {
[GAME_DIR_KEY]: '',
},
};
@@ -0,0 +1,66 @@
export enum InstallStatus {
NotInstalled = 'NotInstalled',
CheckingPeers = 'CheckingPeers',
Downloading = 'Downloading',
Installing = 'Installing',
Uninstalling = 'Uninstalling',
Installed = 'Installed',
}
export enum GameAvailability {
Ready = 'Ready',
LocalOnly = 'LocalOnly',
}
export enum ActiveOperationKind {
Downloading = 'Downloading',
Installing = 'Installing',
Updating = 'Updating',
Uninstalling = 'Uninstalling',
}
export type StatusLevel = 'info' | 'error';
export interface Game {
id: string;
name: string;
description: string;
/** Bytes. */
size: number;
/** Raw bytes — unused in UI, kept for parity with backend payload. */
thumbnail?: Uint8Array | number[];
downloaded: boolean;
installed: boolean;
availability: GameAvailability;
install_status: InstallStatus;
eti_game_version?: string;
local_version?: string;
/** Optional richer metadata surfaced by the backend. */
release_year?: string;
publisher?: string;
max_players?: number;
version?: string;
genre?: string;
status_message?: string;
status_level?: StatusLevel;
peer_count: number;
}
export interface ActiveOperation {
id: string;
operation: ActiveOperationKind;
}
export interface GamesListPayload {
games: Game[];
active_operations?: ActiveOperation[];
}
/** Library filter chip — what subset of the catalog to show. */
export type GameFilter = 'all' | 'local' | 'installed';
/** Library sort order. */
export type GameSort = 'az' | 'size' | 'status';
/** Visual state of a card. Derived from install/download flags. */
export type DerivedState = 'installed' | 'local' | 'none' | 'busy';