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,55 @@
import { useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { UseGamesResult } from './useGames';
export interface GameActions {
play: (id: string) => Promise<void>;
install: (id: string) => Promise<void>;
update: (id: string) => Promise<void>;
uninstall: (id: string) => Promise<void>;
}
/**
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
* / `uninstall_game` commands. For install + update we mark the game as
* "checking peers" up-front through the games hook so the UI doesn't have to
* wait for the first backend event.
*/
export const useGameActions = (games: UseGamesResult): GameActions => {
const play = useCallback(async (id: string) => {
try {
await invoke('run_game', { id });
} catch (err) {
console.error('run_game failed:', err);
}
}, []);
const install = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('install_game', { id });
if (success) games.markChecking(id);
} catch (err) {
console.error('install_game failed:', err);
}
}, [games]);
const update = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('update_game', { id });
if (success) games.markChecking(id);
} catch (err) {
console.error('update_game failed:', err);
}
}, [games]);
const uninstall = useCallback(async (id: string) => {
try {
await invoke('uninstall_game', { id });
} catch (err) {
console.error('uninstall_game failed:', err);
}
}, []);
return { play, install, update, uninstall };
};
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { load } from '@tauri-apps/plugin-store';
import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store';
/**
* Owns the user's selected game directory. Hydrates from the persistent store
* on mount, writes back on every change, and pushes the value to the Tauri
* backend so it can scan/rescan.
*/
export const useGameDirectory = () => {
const [gameDir, setGameDir] = useState('');
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
const saved = await store.get<string>(GAME_DIR_KEY);
if (saved && !cancelled) setGameDir(saved);
} catch (err) {
console.error('Failed to load game directory:', err);
}
};
void hydrate();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!gameDir) return;
const sync = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
await store.set(GAME_DIR_KEY, gameDir);
} catch (err) {
console.error('Failed to persist game directory:', err);
}
};
void sync();
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to push game directory to backend:', err),
);
}, [gameDir]);
const rescan = useCallback(() => {
if (!gameDir) return;
invoke('update_game_directory', { path: gameDir }).catch(err =>
console.error('Failed to rescan game directory:', err),
);
}, [gameDir]);
return { gameDir, setGameDir, rescan };
};
@@ -0,0 +1,252 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import {
Game,
GamesListPayload,
InstallStatus,
StatusLevel,
} from '../lib/types';
import {
activeStatusById,
mergeGameUpdate,
normalizeGamesListPayload,
} from '../lib/gameState';
const CHECKING_PEERS_TIMEOUT_MS = 5000;
interface PendingPatch {
install_status?: InstallStatus;
installed?: boolean;
status_message?: string;
status_level?: StatusLevel | undefined;
clearStatus?: boolean;
}
const applyPatch = (game: Game, patch: PendingPatch): Game => {
let next: Game = { ...game };
if (patch.install_status !== undefined) next.install_status = patch.install_status;
if (patch.installed !== undefined) next.installed = patch.installed;
if (patch.clearStatus) {
next.status_message = undefined;
next.status_level = undefined;
}
if (patch.status_message !== undefined) {
next.status_message = patch.status_message;
next.status_level = patch.status_level;
}
return next;
};
/**
* Owns the games list and reflects every backend event (download/install/
* uninstall lifecycle, peer count) into local React state. Returns a
* fire-and-forget `markChecking` helper so action calls can immediately show a
* "Checking peers…" state with an automatic fall-back if the backend never
* emits a follow-up event.
*/
export interface UseGamesResult {
games: Game[];
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
totalPeerCount: number;
requestGames: () => Promise<void>;
markChecking: (id: string) => void;
cancelChecking: (id: string) => void;
}
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
const [games, setGames] = useState<Game[]>([]);
const [totalPeerCount, setTotalPeerCount] = useState(0);
const checkingTimeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const rescanRef = useRef(rescanGameDir);
rescanRef.current = rescanGameDir;
const cancelChecking = useCallback((id: string) => {
const t = checkingTimeouts.current[id];
if (t !== undefined) {
clearTimeout(t);
delete checkingTimeouts.current[id];
}
}, []);
const markChecking = useCallback((id: string) => {
cancelChecking(id);
setGames(prev => prev.map(item =>
item.id === id
? { ...item, install_status: InstallStatus.CheckingPeers }
: item,
));
checkingTimeouts.current[id] = setTimeout(() => {
setGames(prev => prev.map(item => {
if (item.id !== id || item.install_status !== InstallStatus.CheckingPeers) {
return item;
}
return {
...item,
install_status: item.installed
? InstallStatus.Installed
: InstallStatus.NotInstalled,
status_message: 'No peers currently have this game.',
status_level: 'error',
};
}));
delete checkingTimeouts.current[id];
}, CHECKING_PEERS_TIMEOUT_MS);
}, [cancelChecking]);
const requestGames = useCallback(async () => {
try {
await invoke('request_games');
} catch (err) {
console.error('request_games failed:', err);
}
}, []);
useEffect(() => {
const unlisteners: UnlistenFn[] = [];
let cancelled = false;
const updateById = (id: string, patch: PendingPatch) => {
setGames(prev => prev.map(item => item.id === id ? applyPatch(item, patch) : item));
};
const handleErrorEvent = (
id: string,
message: string,
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
) => {
cancelChecking(id);
setGames(prev => prev.map(item => item.id === id
? {
...item,
install_status: item.installed
? InstallStatus.Installed
: InstallStatus.NotInstalled,
status_message: message,
status_level: 'error',
}
: item));
if (triggerRescan) rescanRef.current();
};
const register = async () => {
try {
unlisteners.push(await listen('games-list-updated', (event) => {
const payload = normalizeGamesListPayload(
event.payload as GamesListPayload | Game[],
);
const activeStatuses = activeStatusById(payload.active_operations);
const hasAuthoritative = payload.active_operations !== undefined;
setGames(prev => {
const previousById = new Map(prev.map(item => [item.id, item]));
return payload.games.map(game => mergeGameUpdate(
game,
previousById.get(game.id),
activeStatuses.get(game.id),
hasAuthoritative,
));
});
}));
unlisteners.push(await listen('game-download-begin', (e) => {
const id = e.payload as string;
cancelChecking(id);
updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true });
}));
unlisteners.push(await listen('game-download-finished', (e) => {
const id = e.payload as string;
cancelChecking(id);
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
}));
unlisteners.push(await listen('game-download-failed', (e) => {
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
triggerRescan: true,
});
}));
unlisteners.push(await listen('game-download-peers-gone', (e) => {
handleErrorEvent(e.payload as string, 'Failed: all peers gone.', {
triggerRescan: true,
});
}));
unlisteners.push(await listen('game-no-peers', (e) => {
handleErrorEvent(e.payload as string, 'No peers currently have this game.');
}));
unlisteners.push(await listen('game-install-begin', (e) => {
const id = e.payload as string;
cancelChecking(id);
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
}));
unlisteners.push(await listen('game-install-finished', (e) => {
const id = e.payload as string;
cancelChecking(id);
updateById(id, {
install_status: InstallStatus.Installed,
installed: true,
clearStatus: true,
});
rescanRef.current();
}));
unlisteners.push(await listen('game-install-failed', (e) => {
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
}));
unlisteners.push(await listen('game-uninstall-begin', (e) => {
updateById(e.payload as string, {
install_status: InstallStatus.Uninstalling,
clearStatus: true,
});
}));
unlisteners.push(await listen('game-uninstall-finished', (e) => {
updateById(e.payload as string, {
install_status: InstallStatus.NotInstalled,
installed: false,
clearStatus: true,
});
}));
unlisteners.push(await listen('game-uninstall-failed', (e) => {
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
}));
unlisteners.push(await listen('peer-count-updated', (e) => {
setTotalPeerCount(e.payload as number);
}));
if (!cancelled) {
await invoke('request_games').catch(err =>
console.error('request_games failed:', err),
);
}
} catch (err) {
console.error('Failed to register game listeners:', err);
}
};
void register();
return () => {
cancelled = true;
unlisteners.forEach(fn => fn());
Object.values(checkingTimeouts.current).forEach(clearTimeout);
checkingTimeouts.current = {};
};
}, [cancelChecking]);
return {
games,
setGames,
totalPeerCount,
requestGames,
markChecking,
cancelChecking,
};
};
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from 'react';
import { load } from '@tauri-apps/plugin-store';
import { GameFilter, GameSort } from '../lib/types';
import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store';
export type Density = 'compact' | 'normal' | 'large';
export type CoverAspect = 'box' | 'square' | 'banner';
export type BackgroundStyle = 'flat' | 'gradient' | 'animated';
export interface UISettings {
accent: string;
bg: BackgroundStyle;
density: Density;
aspect: CoverAspect;
sort: GameSort;
filter: GameFilter;
}
export const ACCENT_OPTIONS = [
{ value: '#3b82f6', label: 'Blue' },
{ value: '#22d3ee', label: 'Cyan' },
{ value: '#a855f7', label: 'Violet' },
{ value: '#22c55e', label: 'Green' },
{ value: '#f59e0b', label: 'Amber' },
{ value: '#ef4444', label: 'Red' },
] as const;
export const BG_OPTIONS: ReadonlyArray<{ value: BackgroundStyle; label: string }> = [
{ value: 'flat', label: 'Flat' },
{ value: 'gradient', label: 'Gradient' },
{ value: 'animated', label: 'Animated' },
];
export const DENSITY_OPTIONS: ReadonlyArray<{ value: Density; label: string }> = [
{ value: 'compact', label: 'Compact' },
{ value: 'normal', label: 'Normal' },
{ value: 'large', label: 'Large' },
];
export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }> = [
{ value: 'box', label: 'Box-art' },
{ value: 'square', label: 'Square' },
{ value: 'banner', label: 'Banner' },
];
export const DEFAULT_SETTINGS: UISettings = {
accent: '#3b82f6',
bg: 'gradient',
density: 'normal',
aspect: 'box',
sort: 'status',
filter: 'local',
};
const sanitize = (raw: Partial<UISettings> | undefined): UISettings => ({
accent: raw?.accent ?? DEFAULT_SETTINGS.accent,
bg: raw?.bg ?? DEFAULT_SETTINGS.bg,
density: raw?.density ?? DEFAULT_SETTINGS.density,
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
sort: raw?.sort ?? DEFAULT_SETTINGS.sort,
filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
});
export interface UseSettings {
settings: UISettings;
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
ready: boolean;
}
/**
* Loads UI preferences from the Tauri persistent store once on mount and
* writes every change back through it. Components only see a synchronous
* `settings` snapshot; persistence is fire-and-forget.
*/
export const useSettings = (): UseSettings => {
const [settings, setSettings] = useState<UISettings>(DEFAULT_SETTINGS);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const init = async () => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
const saved = await store.get<Partial<UISettings>>(UI_SETTINGS_KEY);
if (!cancelled) {
setSettings(sanitize(saved ?? undefined));
}
} catch (err) {
console.error('Failed to load UI settings:', err);
} finally {
if (!cancelled) setReady(true);
}
};
void init();
return () => {
cancelled = true;
};
}, []);
const set = useCallback(<K extends keyof UISettings>(key: K, value: UISettings[K]) => {
setSettings(prev => {
const next = { ...prev, [key]: value };
void persist(next);
return next;
});
}, []);
return { settings, set, ready };
};
const persist = async (settings: UISettings): Promise<void> => {
try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
await store.set(UI_SETTINGS_KEY, settings);
} catch (err) {
console.error('Failed to persist UI settings:', err);
}
};
@@ -0,0 +1,30 @@
import { useCallback, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
/**
* Lazy, per-id cache for cover thumbnails. Returns `null` until the value is
* known; returns the empty string when the backend has nothing for the id, so
* callers can fall back to the placeholder cover art.
*/
export const useThumbnails = () => {
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
const pending = useRef<Set<string>>(new Set());
const get = useCallback((id: string): string | null => {
if (thumbnails.has(id)) return thumbnails.get(id) ?? '';
if (pending.current.has(id)) return null;
pending.current.add(id);
invoke<string>('get_game_thumbnail', { gameId: id })
.then(url => {
pending.current.delete(id);
setThumbnails(prev => new Map(prev).set(id, url));
})
.catch(() => {
pending.current.delete(id);
setThumbnails(prev => new Map(prev).set(id, ''));
});
return null;
}, [thumbnails]);
return { get };
};