# 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.