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,93 @@
import { JSX, SVGProps } from 'react';
type Props = SVGProps<SVGSVGElement>;
const baseStroke: Partial<Props> = {
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
export const Icon = {
search: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.6} {...baseStroke} {...p}>
<circle cx="7" cy="7" r="5" />
<path d="m13.5 13.5-3-3" />
</svg>
),
play: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}>
<path d="M4 2.5v11l10-5.5z" />
</svg>
),
install: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="M8 2v8" />
<path d="m4.5 7 3.5 3.5L11.5 7" />
<path d="M2.5 12.5h11" />
</svg>
),
download: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="M8 2v8" />
<path d="m4.5 7 3.5 3.5L11.5 7" />
<path d="M2.5 13.5h11" />
</svg>
),
folder: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z" />
</svg>
),
kebab: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}>
<circle cx="8" cy="3.2" r="1.4" />
<circle cx="8" cy="8" r="1.4" />
<circle cx="8" cy="12.8" r="1.4" />
</svg>
),
sort: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
<path d="M3 4h10" />
<path d="M4.5 8h7" />
<path d="M6 12h4" />
</svg>
),
users: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={1.5} {...baseStroke} {...p}>
<circle cx="6" cy="6" r="2.4" />
<path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13" />
<circle cx="11.2" cy="5.4" r="1.8" />
<path d="M10.4 9.8c1.7 0 3 1 3.6 2.6" />
</svg>
),
close: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.7} {...baseStroke} {...p}>
<path d="m4 4 8 8M12 4l-8 8" />
</svg>
),
check: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={2} {...baseStroke} {...p}>
<path d="m3 8 3.5 3.5L13 5" />
</svg>
),
chevron: (p: Props) => (
<svg viewBox="0 0 16 16" width="11" height="11" strokeWidth={1.6} {...baseStroke} {...p}>
<path d="m4 6 4 4 4-4" />
</svg>
),
trash: (p: Props) => (
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
<path d="M3 4.5h10" />
<path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" />
<path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5" />
</svg>
),
games: (p: Props) => (
<svg viewBox="0 0 16 16" width="22" height="22" strokeWidth={1.4} {...baseStroke} {...p}>
<rect x="2" y="5" width="12" height="8" rx="2" />
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
</svg>
),
} satisfies Record<string, (p: Props) => JSX.Element>;