Commit Graph

21 Commits

Author SHA1 Message Date
ddidderr e06a887da1 fix(ui): strip literal br tags from game descriptions
Some ETI game descriptions include the literal string <br> in metadata.
React renders descriptions as text, so the marker appears to users instead
of being treated as a line break.

Strip only the exact <br> token at the detail modal boundary. This keeps
the fix UI-only and avoids treating descriptions as HTML or normalizing any
other markup-like text.

Test Plan:
- just frontend-test
- git diff --check

Refs: none
2026-05-21 21:56:42 +02:00
ddidderr 2e7a0cff2f feat(ui): move game folder picker into settings
The design update moved game-folder configuration out of launcher chrome and
into Settings > Library. Follow that contract in the runtime UI without
changing the existing storage or Tauri directory commands.

The top bar now leaves its right edge for the kebab menu. Settings owns a new
Game folder row that shows a valid selected path with a neutral Change button,
or the red Not set state with a stronger Choose button when no accessible
directory is configured. Both the empty-library state and the Settings row
still use the existing native directory picker, so existing saved paths and
rescans keep their current behavior.

Keep useGameDirectory as the directory-state owner and expose the shared
hasGameDirectory boolean from that hook so the grid and Settings field agree on
what counts as configured.

Test Plan:
- git diff --cached --check
- just frontend-test
- just build

Refs: 62b409f4bfc4995c25461776107d28f52b24f30e
2026-05-21 21:32:29 +02:00
ddidderr a7d99261cf fix(ui): reserve search clear button space
Typing the first character into the launcher search field used to insert the
clear button into the flex row, which widened the field after focus. The clear
button now stays mounted in the row so its slot is part of the empty field's
layout, while the empty state hides and disables it.

Users now get the wider steady-state search width immediately, and the control
does not jump when the clear affordance becomes visible.

Test Plan:
- git diff --check
- just frontend-test
- just build

Refs: user-reported search field jump
2026-05-21 21:32:28 +02:00
ddidderr debb1c0c49 feat(ui): focus search with Ctrl+F
Ctrl+F is a common search shortcut and should open the launcher search just
like the existing slash shortcut. Handle it in the same SearchField keydown
listener so the behavior stays scoped to the topbar search component.

The shortcut is ignored while the user is already typing in another input or
textarea, matching the existing slash behavior. When handled, it prevents the
webview's browser find UI and focuses the app search field instead.

Test Plan:
- `just frontend-test`

Refs: none
2026-05-21 21:32:28 +02:00
ddidderr 0151d7a16c style(ui): match game-folder topbar design
Follow the latest top-bar spec for the Game Folder control. The button stays
as only the folder icon and short label, with the full path in tooltip and aria
text, and now sits in the app-level control group with the kebab menu on the
far-right edge of the wide top bar.

This keeps Sort as the right-zone lead control next to the centered search
cluster while treating Game Folder and the kebab menu as a tight trailing pair.
The narrow fallback still flows in source order: sort, game folder, kebab.

Test Plan:
- git diff --check
- rg -n "dirbtn-status-dot|status dot|green status|red status" crates/lanspread-tauri-deno-ts/src
- just frontend-test
- just build
2026-05-21 21:32:28 +02:00
ddidderr 31ace174e3 fix(ui): treat missing game folders as unset
Validate the persisted game directory before sending it to the backend or
showing library content for it. When the saved path no longer exists, the
launcher keeps the top bar visible but shows the folder picker empty state
and labels the Game Folder button as an unset folder.

This keeps stale local data from being presented as the active library when
an old path is deleted or disconnected.

Test Plan:
- git diff --check
- just frontend-test
- just build
2026-05-21 21:32:28 +02:00
ddidderr 059c1e7720 feat(ui): redesign game-folder button as icon + label + dot
Implements the v2 design's game-folder button in the Tauri launcher.
The previous control squeezed the full game-directory path into the
top-bar button as truncated monospace (e.g.
`…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading
ellipsis rarely showed the meaningful part of the path, ate
horizontal space the new 3-zone top bar needs for its primary
controls, and competed with the filter / search / sort cluster for
attention.

The button now communicates the *state* of the configuration at a
glance — an icon + short label + colored status dot — while the full
path moves into the native tooltip and `aria-label`, where it stays
one mouseover (and screen-reader friendly) away.

Two visual states, both 36 px tall and sharing the surface of the
other top-bar controls:

- **Set & valid** (`.dirbtn-set`) — label `Game folder`, green dot
  (`--ok`) with a soft glow, default border, tooltip = full path.
- **Not set / invalid** (`.dirbtn-unset`) — label `Set game folder`,
  red dot (`--danger`) with a soft glow, a red-tinted border, and a
  faint red wash on hover so the bad state reads as "this is what
  you need to fix". Tooltip = `Please select a game folder`.

`DirectoryButton` now takes `path: string | null` and picks the
state from `!!(path && path.trim())`. The `aria-label` carries the
full state in words (`Game folder: <path>` / `Set game folder`) so
screen readers don't have to interpret the colored dot.

Note: in the current `MainWindow`, `gameDir` is gated upstream — if
no directory is selected, `NoDirectoryState` is shown instead of the
top bar, so the unset state will only surface here if we later
validate disk existence and clear `gameDir`. The button accepts a
nullable path anyway, so it's ready when that check lands.

`truncatePath` in `lib/format.ts` was the only caller-less helper
left behind and is removed.

Test Plan

- `npx tsc --noEmit` from the frontend crate — clean.
- `just frontend-test` — passes.
- Manual: `just run`, pick a valid game directory, and confirm the
  top-bar button reads `Game folder` with a green dot; hovering it
  reveals the full path in the OS tooltip. Inspect the DOM and
  confirm `aria-label` reads `Game folder: <path>`. (The unset
  variant currently isn't surfaced by `MainWindow`; eyeball it via
  DevTools by toggling the `dirbtn-set` class to `dirbtn-unset`.)
2026-05-21 21:32:28 +02:00
ddidderr 095bc9b9ff feat(ui): center search in a 3-zone top bar
Implements the v2 design's top-bar reorganization in the Tauri
launcher. The bar was previously a flat flex row that let the search
field drift left or right depending on filter / sort widths; now it's
a 3-column CSS grid with the search field pinned to the geometric
center of the window.

- `.topbar` becomes `display: grid` with
  `grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)` and a
  16 px column gap. The middle (auto) column holds only the search,
  capped at `flex: 0 1 360px` so it cannot push into the side columns.
- The left zone is `flex; justify-content: space-between`: brand
  pins far-left, filter pills hug the search. The filter pills are
  now grouped with the search semantically (they scope it) instead of
  floating next to the brand.
- The right zone mirrors that: sort hugs the search, kebab pins
  far-right, with the directory button between them.
- A `@container launcher (max-width: 1100px)` rule collapses the
  layout back to a single nowrap flex row at narrow widths — the
  geometric centering doesn't read at small widths and would force
  awkward truncation, so we abandon it rather than fight it. The
  launcher root opts into container queries via
  `container-type: inline-size; container-name: launcher`.

`TopBar.tsx` now wraps the existing children in `.topbar-left`,
`.topbar-center`, `.topbar-right` (plus `.topbar-left-trail` /
`.topbar-right-lead` for the inner space-between alignment), but each
control component is otherwise untouched.

Test Plan

- `just frontend-test` — passes.
- `npx tsc --noEmit` from the frontend crate — clean.
- Manual: run `just run`, confirm the search input's horizontal center
  matches the window's horizontal center across the standard launcher
  width. Shrink the window below 1100 px and confirm the row collapses
  to a single left-to-right strip with no overlap or wrapping.
2026-05-21 21:32:28 +02:00
ddidderr 8d96d99160 fix(ui): keep file viewer visible while installing
The detail view already exposes View Files while a game is downloaded,
installed, or actively downloading. During the download-to-install handoff,
the backend can report Installing before the local downloaded/installed flags
have settled in the next snapshot, which briefly hid the file viewer button.

Treat Installing as another state where the game root should remain reachable.
This keeps the detail view stable during the handoff without changing backend
file-opening behavior.

Test Plan:
- just frontend-test
2026-05-21 21:32:28 +02:00
ddidderr a913e4c776 feat(settings): show automatic build number
The launcher settings dialog now shows a Build-Nr value in its footer so users
can identify the exact application build without manually updating a number
before release.

The value is generated by Vite when the frontend bundle is built and injected
as an import.meta.env constant. Using the current millisecond timestamp keeps
the number numeric and monotonic for normal builds without a checked-in counter
file or any extra build-state mutation. The tradeoff is that the value follows
the build machine clock rather than a central sequence service.

The footer layout now keeps the build number on the lower left and the Done
button on the lower right.

Test Plan:
- just frontend-test
- just build
- git diff --cached --check

Refs: local user request
2026-05-21 21:32:28 +02:00
ddidderr 4f34c4a249 feat: pass profile settings to launch scripts
Add launcher profile settings for username and language, then thread those
values into the Windows script launch path. The game setup, game start, and
server start scripts now share the same argument shape:

- game path: local
- game id
- language: en or de
- player name

Expose a local can_host_server flag in the games payload by checking for
server_start.cmd in an installed game's root directory. The detail modal uses
that flag to show Start Server only for installed games with the script, and
the new start_server command invokes server_start.cmd with the same sanitized
settings used by game_setup.cmd and game_start.cmd.

Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check

Refs: design/README.md
2026-05-21 21:32:28 +02:00
ddidderr 12a0d7abe9 feat(ui): align peer count chip with design reference
Mirror the design-doc update in the actual download progress component
so the GUI matches the trimmed chip. Previously the large progress
panel rendered `[icon] from N peer(s)` inline; now it renders just
`[icon] N`, with the full "Downloading from N peers on the LAN"
sentence retained as the `title` tooltip for discoverability.

Changes:
- `DownloadProgress.tsx` (lg variant): drop the "from" / unit text from
  the inline span, keeping only the count. `peerUnit` stays in scope
  because the tooltip still needs singular/plural.
- `launcher.css`: collapse `.dl-peers` and `.dl-peers strong` into a
  single rule that puts the t-1 colour, 600 weight and tabular-nums
  directly on the chip (the inner `<strong>` no longer exists). Gap
  drops from 5px to 4px to match the tighter icon+number layout.
- Container queries: peers drops at <=240px and ETA drops at <=320px,
  matching the new thresholds in the design reference. The narrower
  chip simply fits in smaller modals, so the old 300/380 cutoffs were
  hiding stats that would have rendered fine.

Test Plan
- `just frontend-test` (passes)
- `just run`, start a download, confirm the chip reads `[icon] N`,
  hover shows the tooltip, and narrowing the window collapses ETA
  before peers at the new breakpoints.
2026-05-21 00:41:10 +02:00
ddidderr 8acb6dc246 feat: show active download peer count
Render the active peer count already carried by download progress events in the
large download progress control. The peer chip appears between speed and ETA,
uses singular/plural copy, and hides after the ETA when the detail modal gets
very narrow.

This keeps the UI aligned with the design reference without changing backend
state ownership or download progress plumbing.

Test Plan:
- git diff --check
- git diff --cached --check
- just frontend-test
- just build
2026-05-21 00:32:57 +02:00
ddidderr 47e2bbd454 feat(ui): add download progress controls
Replace the downloading action button with a dedicated progress component in
both card and detail views. The card now shows percent plus current speed, while
the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance
using the same backend progress payload.

Expose download cancellation as a peer command that cancels the tracked transfer
token and lets the running operation clear the authoritative active-operation
snapshot. Add a View Files action that resolves the game root safely and opens it
with the platform file viewer through Tauri's shell plugin.

Test Plan:
- just fmt
- just frontend-test
- just test
- just build
- just clippy
- git diff --cached --check

Refs: design reference e308009a08
2026-05-20 23:20:53 +02:00
ddidderr 01712f248b feat(ui): show download progress and speed in the action button
Previously the action button only said "Downloading…" with no indication of
how far along the transfer was or how fast it was going. With multi-gigabyte
game payloads on a LAN this gave the user no signal whether the download had
stalled, was hitting the wire fast, or was about to finish.

Wire a sampled byte-level progress channel from the download pipeline up to
the action button:

- New `DownloadProgressTracker` in `crates/lanspread-peer/src/download/progress.rs`
  holds the total expected bytes plus two atomic counters: `downloaded_bytes`
  (deduplicated per `(relative_path, offset)` chunk key, used for the bar) and
  `transferred_bytes` (raw cumulative, used for the speed sample). The dedup
  prevents a retried chunk from double-counting toward completion while still
  letting speed reflect actual wire activity including retry waste, which is
  the more useful metric for "is the link doing anything right now?".
- `sample_download_progress` wraps the transfer future, emits an initial 0 B/s
  snapshot, then samples on a 500 ms interval (`MissedTickBehavior::Skip` so a
  stalled downloader does not generate a thundering herd of catch-up ticks)
  and emits one final snapshot when the future resolves, so the UI sees the
  closing state before `DownloadGameFilesFinished` arrives.
- New `PeerEvent::DownloadGameFilesProgress(DownloadProgress)` variant carries
  `{ id, downloaded_bytes, total_bytes, bytes_per_second }`. The Tauri shell
  forwards it as `game-download-progress`; the JSONL harness emits it as
  `download-progress`.
- Orchestrator and retry paths refactored to thread a single shared
  `Arc<DownloadProgressTracker>` through both the initial transfer and any
  retry attempts. New `TransferContext`, `RetryContext`, and `ChunkPlanContext`
  structs absorb the parameter-list growth that came with adding the tracker.

Frontend rendering honors the snapshot-is-authoritative decision from commit
`5df82aa` ("fix(ui): derive operation status from snapshots"):

- `Game.download_progress` is an ephemeral overlay carried alongside the card,
  not a status field. `mergeGameUpdate` preserves it only while
  `install_status === Downloading` and otherwise clears it on the next
  snapshot, so the games-list snapshot remains the single authority for when
  the bar should disappear.
- The `game-download-progress` listener writes ONLY `download_progress` — it
  does not touch `install_status`, `status_message`, or `status_level`. This
  preserves the rule that lifecycle events never mutate card status.
- No `game-download-finished` listener; snapshot reconciliation clears the
  overlay automatically when status leaves Downloading.
- `ActionButton` renders a percentage fill behind the icon/label via a
  `--download-progress` CSS custom property; the existing `.act-busy` spinner
  is layered above the fill with `z-index: 1`. `act-downloading` widens the
  button to avoid label jitter as the speed number changes (tabular-nums).
- `actionLabel` for the Downloading status now appends a formatted speed
  ("Downloading… 12.5 MB/s") via the new `formatBytesPerSecond` helper.

Test Plan:
- `just test` — Rust workspace tests including new progress tracker unit tests
  (`tracker_counts_only_new_bytes_for_a_retried_chunk`,
  `tracker_clamps_reported_bytes_to_total`).
- `just frontend-test` — Deno tests including
  `download progress is preserved only while actively downloading` and
  `downloading action label includes current speed`.
- `just clippy` — clean.
- Manual: download a multi-GB game from a peer and watch the action button
  fill, speed update on the half-second, and reset cleanly on completion.

Refs: download progress visibility, snapshot-authoritative UI architecture
2026-05-20 22:11:09 +02:00
ddidderr ebeee2d90a fix(settings): name descending size sort explicitly
The library sort setting used `size` for largest-first sorting while the
ascending option used `sizeAsc`. That made the pair asymmetric and left the
current settings model carrying a legacy-looking key.

Rename the current descending key to `sizeDesc` in the type, menu, and sort
logic. Stored `size` values are normalized to `sizeDesc` on read, so existing
users keep the same largest-first behavior while new writes use the explicit
key.

Test Plan:
- deno task build
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build
- git diff --check

Refs: local review feedback
2026-05-19 21:28:40 +02:00
ddidderr 62ceb063ac feat(peer): remove downloaded game files safely
Downloaded but uninstalled games can still occupy significant disk space. Add a
separate removal path for that state instead of overloading uninstall, which is
reserved for deleting only `local/` installs.

The peer runtime now exposes `RemoveDownloadedGame` with matching lifecycle
and active-operation events. The filesystem delete is intentionally strict: the
id must be a catalog game and a single path component, the target must be a
direct child of the configured game directory, the root must not be a symlink,
it must have a regular root-level `version.ini`, and it must not contain
`local/`, `.local.installing/`, or `.local.backup/`. Only then do we recursively
remove the game root.

The Tauri bridge exposes this as `remove_downloaded_game`, the frontend shows a
matching danger action only for downloaded-but-uninstalled games, and a
confirmation dialog warns that re-downloading can take a long time.

Test Plan:
- git diff --check
- just fmt
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build

Refs: user redesign nitpick about removing downloaded uninstalled games
2026-05-19 21:00:44 +02:00
ddidderr 50698f9a7d feat(ui): add search clear button
Search now exposes a small icon-only clear button whenever a query is present.
Clicking it clears the term in one step and returns focus to the input so users
can immediately type a replacement.

The button uses the existing topbar styling language and a compact circled-x
icon alongside the keyboard hint.

Test Plan:
- git diff --check

Refs: user redesign nitpick about one-click search clearing
2026-05-19 20:48:46 +02:00
ddidderr a6130fc687 fix(ui): handle enter and escape in search
The search field should behave like a transient launcher search control. Enter
now blurs the input while preserving the current term, and Escape clears the
term before blurring the input.

Test Plan:
- git diff --check

Refs: user redesign nitpick about search keyboard behavior
2026-05-19 20:48:12 +02:00
ddidderr 25f92c9b0b feat(ui): add smallest-first size sort
The redesign only offered a largest-first size sort. Keep the existing `size`
preference value as largest for compatibility with saved settings and add a new
ascending size key for users who want to find small downloads first.

The sort menu now exposes both size directions and the sorter handles the new
smallest-first option directly.

Test Plan:
- git diff --check

Refs: user redesign nitpick about Size (smallest) sort
2026-05-19 20:47:01 +02:00
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