crabidy/architecture/web-client.md

193 lines
8.3 KiB
Markdown

# Web client (cbd-web)
A browser client with the same functionality as the TUI, served by
`crabidy-server` itself so that "open `http://server:50051`" is the
whole install story. Leptos, pure modern CSS, crab orange-red accent,
light and dark themes.
## Context and problem statement
The TUI covers the owner's desk. Phones, tablets, and guests (see
`architecture/roles-auth.md` — queue-owner / queue-appender roles
exist precisely for them) need a client without a terminal. It must
not be a second, drifting implementation of the protocol surface: the
web client should speak the same gRPC contract as the TUI, feature for
feature: library browsing, search terms, queue manipulation, playback
control, bookmarks (`w`), captures (`W`, with progress and confirmed
deletion), and the live update stream.
## Assumptions (confirmed or decided)
- "Exactly the same functionality as the TUI" means the same *actions
and information*, not a terminal emulation: every TUI binding has a
clickable equivalent, and the familiar keyboard bindings (j/k/h/l,
Tab, %, e, d, w, W, …) also work on desktop browsers.
- "Local first" is interpreted for what this app *is* — a remote
control for live server state (one audio output, one queue). There
is no offline-editing story to sync: a CRDT layer (as in the
`web_client_example_workspace` template, automerge et al.) would
model conflicts that cannot occur and add a heavy dependency wall.
Local-first here means: **client-side rendered, all assets local
(no CDN), session state and credentials in the browser, library
listings cached in memory like the TUI's, optimistic UI where
safe, and graceful reconnect/backoff when the server disappears.**
This is a deliberate, documented deviation from the example
template.
- The example workspace informs the toolchain (leptos 0.8, trunk,
wasm32 target in devenv, workspace layout, lint posture) — not the
runtime architecture (SSR/hydration + WebSocket sync). We build a
pure CSR app: the server side must stay tonic, not become a leptos
SSR host.
- One port for everything: gRPC (TUI), gRPC-web (browser), and static
assets are all served on `LISTEN_ADDR` (50051). No second listener,
no CORS story needed (same origin).
## Options considered
### Browser transport
1. **gRPC-web with the existing proto** — server wraps the existing
tonic service in `tonic-web` (0.14.6, matches our tonic); the
browser uses the *same generated clients* from `crabidy-core` over
`tonic-web-wasm-client` (0.9.1, tonic ^0.14). Server streaming
(GetUpdateStream) is supported. The 24-RPC surface and all types
are shared — parity is structural, not aspirational. The auth
layer keeps working unchanged: gRPC-web POSTs to the same
`/crabidy.v1.CrabidyService/…` paths, `minimum_role` sees them
identically, and the browser can set the `authorization` header.
2. REST + WebSocket bridge — a second API surface to hand-write,
secure, and keep in sync. Rejected.
**Decision: gRPC-web (1).**
### Serving the app
1. **Embed the built assets in the server binary** (`include_dir` of
`cbd-web/dist`) behind a cargo feature `web-ui`, **default on**
(the request), compiled into `crabidy-server` and thus `cbd`. The
single binary stays self-contained.
2. Serve from a directory on disk at runtime. Flexible but breaks the
single-binary story and invites path confusion. Rejected (can be
added later as an override).
**Decision: embed (1).** tonic 0.14's router is axum-based:
`Routes::into_axum_router()` lets us add plain axum routes for `/`,
`/pkg/…` and friends next to the gRPC paths. Static assets are served
without authentication (the app shell is public; every RPC behind it
stays gated) — same posture as any login page.
### Building the wasm app
`cbd-web` is a workspace member built by **trunk** into
`cbd-web/dist`. Embedding happens through a small
`crabidy-server/build.rs` that copies `cbd-web/dist` into `OUT_DIR`
when present and otherwise generates a **placeholder page** ("web UI
not built — run `devenv shell -- trunk build --release` in
`cbd-web/`") so that:
- plain `cargo build` never fails and never needs wasm tooling
(default-on feature stays harmless),
- `build.rs` never invokes cargo-in-cargo (trunk runs cargo; nesting
it inside a build script risks target-dir lock deadlocks),
- rebuilding after a trunk run re-embeds automatically
(`rerun-if-changed=cbd-web/dist`).
devenv gains `trunk`, `wasm-bindgen-cli`, `binaryen` and the
`wasm32-unknown-unknown` rustup target, so the documented build is
two commands. The dev loop (`trunk serve` with a proxy to a running
server) is documented in `cbd-web/README.md`.
### crabidy-core on wasm
The generated gRPC client must compile to `wasm32-unknown-unknown`.
`crabidy-core` trims its tonic dependency to
`default-features = false, features = ["codegen"]` (no transport, no
router); native crates keep the full tonic via their own dependency
edges, and cargo's per-target feature unification does the rest.
Native-only pieces of crabidy-core that do not build on wasm (config
loading via `dirs`, clap plumbing) move behind a
`cfg(not(target_arch = "wasm32"))` gate / target-specific
dependencies. The proto types, paths helpers, and client stubs are
the wasm surface.
## Structure
```d2
direction: right
browser: Browser {
app: "cbd-web (leptos CSR wasm)"
store: "localStorage:\ncredentials, theme"
app -> store
}
server: "crabidy-server :50051" {
axum: axum router
static: "embedded cbd-web/dist\n(feature web-ui, default on)"
grpcweb: "tonic-web layer"
auth: AuthLayer
rpc: CrabidyService
axum -> static: "GET /, /pkg/…"
axum -> grpcweb: "POST /crabidy.v1.…"
grpcweb -> auth -> rpc
}
tui: cbd-tui
browser.app -> server.axum: "gRPC-web (fetch,\nauthorization header)"
tui -> server.axum: gRPC (HTTP/2)
```
```d2
direction: right
title: cbd-web internals {near: top-center}
rpc: "rpc.rs\ntonic-web-wasm-client,\nsame crabidy-core stubs"
state: "state.rs\nsignals: queue, play\nstate, volume, library\n+ cache, captures"
stream: "stream task\nGetUpdateStream →\nsignals, reconnect backoff"
ui: "components\nLibrary, Queue, NowPlaying,\ndialogs (name, y/N, login)"
keys: "keymap\nTUI-compatible bindings"
rpc -> stream -> state
ui -> rpc: actions
state -> ui: render
keys -> ui
```
## Functional parity map (TUI → web)
| TUI | Web |
| --- | --- |
| library j/k/h/l, Tab, Enter | list + click/keys, back button, panes |
| `%` create search term | "+" affordance & `%` key → name dialog |
| `e` rename, `d` delete (+capture y/N) | item actions & keys → dialogs |
| `w`/`W` bookmark/capture + progress | keys/actions → dialog, progress |
| marks (`*`), queue/append/replace/insert | multi-select & keys |
| queue ops, x remove, C/c clear, s save | buttons & keys |
| all playback + volume/mute/shuffle/repeat | transport bar & keys |
| skipped tracks red | same, via `Track.is_skipped` |
| update stream reconnect | same, backoff + disconnect banner |
| auth via config file | login form (proactive + forced), localStorage |
| `?` help modal | `?` help overlay listing keys |
## Styling
Pure hand-written CSS (one `style.css`, no framework, no CDN):
custom properties for the palette, `color-scheme: light dark` +
`light-dark()`/`prefers-color-scheme` with a manual override toggle
(persisted), CSS nesting, `color-mix()` for derived tones, grid/flex
layout, `@media` breakpoints for phone layout (library and queue as
switchable panes, like Tab in the TUI). Accent color "crab
orange-red": `--accent: oklch(0.62 0.19 35)` (≈ #e14b2a) with hover /
active derivations via `color-mix`. Focus rings and selection bars
reuse the accent; skipped tracks and destructive confirms use the
existing red semantics.
## Risks and open questions
- `tonic-web-wasm-client` is a third-party crate; if it ever lags a
tonic bump, the pinned pair (tonic 0.14 / 0.9.1) keeps building —
upgrade both in lockstep.
- gRPC-web server streaming holds one HTTP connection per browser
tab; fine at household scale.
- The browser cannot play the audio (output is the server's
speakers); a later "play in browser" feature would need a separate
audio streaming endpoint — explicitly out of scope.
- Leptos component logic is hard to unit-test headlessly; logic that
matters (path/selection state machines, formatting) lives in plain
modules with native `#[test]`s, components stay thin.