Files
lanspread/crates
ddidderr 640214ec38 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/
2026-05-19 20:12:57 +02:00
..