# Shard Web Client — Session-First Design Record > Surface contract: the web IS a session control surface, not a chat app. The unit of > attention is a durable **session** (append-only JSONL owned by `shardd`), grouped by > **project** in a catalog tree. Selecting a session opens its **drawer** docked beside the > catalog — the catalog compresses but **stays visible** (persistent attention is a product > invariant). The **runtime is secondary**: it is a state badge and a details pane, never the > navigated object; a stopped session stays fully browsable. Severity lives only on the > **edge tab** and the severity dot. This record supersedes the earlier agent-first > strip-tower topology: the catalog, the drawer, the tab bar, the transcript viewport, and the > runtime model were all rebuilt around the session as the primary object (saga `vr7kgx.2`). ## 1. Intent A multi-session control surface that reads like a project-organized catalog board: - The **session catalog is the home/hero** — a full-viewport tree of every session the daemon owns, grouped by project (`cwd` basename), roots recency-ordered, delegated child sessions (`parent_session_id`) nested under their parent. Every row shows title (with first-user- message preview fallback), recency, project basename, and a runtime state/status indicator. - Selecting a session opens its **drawer** docked beside the catalog. The catalog compresses but **stays visible** while the drawer is open — a permission never quietly waits while you are inside a session. - The transcript is a **session log** laid out along a route spine (inherited visual language, now wrapped by a scroll viewport): user = your message row, tool = tool call with a `↳ ` result footer, assistant = response, system = event/system note. Each kind has a distinct geometry, legible without reading words. - A **rehearsed command** projects its consequence as a **draft overlay** on the session before commit: the annotation tray's typed prompt/steer, and a pending permission's consequence. Confirm sends; dismiss clears with no residue. - The **runtime is secondary.** The drawer header shows the session title + project cwd with a runtime-state badge; full runtime metadata (model/provider/mode/usage) lives in an opt-in details pane. Stopped/failed runtimes read "history available" / "runtime failed" — calm, browsable states, never connection failures. Sending to a stopped session activates-then-sends exactly once; opening a stopped session only replays its newest history page and never wakes the runtime. - **Tabs are navigation, keyed by durable `session_id`.** Open tabs + the active id + per- session viewport anchors are persisted in `localStorage`, so stopped sessions and open positions survive daemon/browser restarts naturally. - **Dark + light** (day scene / night scene) via the strip-world token system; Inter for prose, JetBrains Mono for names/numerics/paths. ## 2. Token system (`src/web/lib/theme/tokens.css`) Strip-world tokens. The shared primitives in `app.css` (`.sev-dot`, `.btn`, diff gutters) consume the same semantic token *names* but their values are strip-world: - **Warm card over cool desk.** `--strip-card` (#f4f1ea light / #191c20 dark) over `--rack` (#e7e9ec / #111417). `--canvas` resolves to the desk so the page background IS the catalog. - **Edge-tab severity.** `--edge-clear` (green, idle/stopped), `--edge-stream` (green a hair brighter, streaming), `--edge-hold` (amber, permission), `--edge-urgent` (red, error). These ARE `--sev-success/info/warn/error` — the severity dot/edge is the only place severity lives. The catalog is mostly green until it isn't. - **One accent — control blue** (`--accent-airspace`): the active row's left rail, focus ring, and the rolling caret only. Never a severity. - **Draft overlay** (`--pencil`, `--pencil-bg`): graphite on warm card for rehearsed messages before they are sent. - **Metal rules** (`--hairline*`): oxidized-steel hairlines on the desk. - **Radii** are tight (`--r-sm: 4px`) — cards are paper, not pills. - **Filled-accent text** (`--accent-on`): white in light, dark ink in dark, so `btn--primary` text passes AA on the accent in both scenes. `theme.ts` is unchanged: `themePref` store (`light | dark | auto`), `cycleTheme()`, `data-theme` on ``. Components consume only `var(--...)` — no hardcoded colors (the only literals — the edge sheen and the primary-button text — were tokenized as `--edge-sheen` and `--accent-on`). Typography: `--font-sans` (Inter via fontsource), `--font-mono` (JetBrains Mono). The locked fontsource set is Inter + JetBrains Mono; JetBrains Mono carries names, numerics, paths, exit codes, and node-ids — "the name IS the identifier." Shared primitives in `app.css`: `.btn` (+ `--primary/--ghost/--danger`), `.sev-dot.*` (the edge tab in miniature, with the calm streaming pulse), `.tracked` (stamped uppercase labels), `.spine`, `.mono`, `.hairline`, `.kbd`, `.sr-only`, and the diff gutter grammar (`.gutter-add` / `.gutter-remove`). ## 3. Component + route architecture ``` routes/+page.svelte orchestrator: tower grid, persisted tabs/active/viewport, keyboard Esc handling, session-first wiring routes/+layout.svelte fonts (Inter + JetBrains Mono), app.css, theme init (unchanged) routes/+layout.ts ssr=false, prerender (unchanged) web/lib/ connection.ts WS state machine + session-first command surface (selectSession / loadOlderSessionPage / refreshSessionCatalog / createSession / inspectSessionRuntime / stopSessionRuntime / killSessionRuntime / activateSession / sendSessionPrompt / closeSessionTab). The private routeAgentId never leaves it. session-state.ts PURE session-first state slice (catalog, selectedSessionId, per-session tabs: transcript/pending/replay/routeAgentId/ runtimeActive). Reducers + generation/selection fencing. session-history.ts PURE history merge ops (replayToTranscript / prependOlderPage / reconcileSyncOverlap / mergeLiveSnapshot / transcriptKey) — pinned by tests. session-rack-grouping.ts PURE catalog projection: project groups + child-by-parent map, recency-ordered (no state, no I/O). viewport-state.ts ViewportState type (anchorEntryId/offset/atBottom/unreadCount). render-descriptor.ts descriptor helpers — UNCHANGED (pinned by tests) enrollment-client.ts enrollment — UNCHANGED (pinned by tests) severity.ts severityForStatus / attentionRank / severityClass / severityDotClass (public signatures UNCHANGED) + ADDITIVE: bayForStatus / BAY_ORDER / edgeToken / towerLabel theme/theme.ts themePref store + cycleTheme (unchanged) components/ TowerBar.svelte the chassis rail: wordmark + connection lamp + desk controls (New session / Recall / Theme). Dispatch contract unchanged from the prior Topbar (home / toggleRecall / toggleTheme) plus newSession. SessionRack.svelte THE HOME — full-viewport project-grouped catalog tree (default-expanded); owns node/group expand-collapse and WAI-ARIA treeview keyboard nav across visible rows. SessionCard.svelte a session row (recurses for delegated children via ): severity dot + title(+preview) + recency + project basename + runtime state/status indicator; open-tab marker. SessionDrawer.svelte the session opened beside the catalog (does NOT hide it); composes the tab bar + transcript viewport + permission stack + annotation tray + runtime/recall/viewer panes. SessionTabBar.svelte open sessions keyed by durable session_id — severity dot + title tabs; WAI-ARIA tabs keyboard pattern. SessionTranscriptViewport.svelte the scroll surface: incremental history (load-older at top with retry), scroll-anchor preservation across prepends, auto-follow near the bottom, unread count + "new output" control, persisted per-session viewport state. FlightPlan.svelte the transcript as a session log along a route spine (visual language inherited; wrapped by the viewport). AnnotationTray.svelte the input: prompt/steer input + draft overlay before commit; live ops read-only when no runtime is attached. ClearanceOverlay.svelte a pending permission/UI request as an amber-edged overlay on the session (unchanged contract). SessionRuntimePanel.svelte runtime-secondary details pane: refresh/stop/kill + Resume for stopped/failed sessions; model/provider/mode/usage. SessionCreatePanel.svelte new-session form (cwd, title, model, provider, mode). Descriptor.svelte render-descriptor grammar (UNCHANGED script — pinned by tests) DedicatedViewer.svelte full (uncapped) descriptor — the escape hatch (lazy/expand) Recall.svelte search/scope/expand over the loaded session Enrollment.svelte setup-token capture (presentational, unchanged) ``` > Vestigial agent-first components from the prior topology (`StripRack`, `StripCard`, > `BayHeader`, `FlightPlanDrawer`, `TabBar`, `FirstStrip`) are no longer wired into the route > and remain only as historical artifacts. They are not part of this surface contract. ### Topology `TowerBar + tower-grid(rack-pane | drawer-pane)`: - The **catalog is the hero** (full-viewport tree). Its empty state guides the first session; "New session" lives on the rail and the rack. - The **drawer is a side panel** that opens beside the catalog when a session is selected. The grid transitions from `1fr 0fr` to `minmax(280px,320px) minmax(0,1fr)`: a narrow catalog rail (280–320px) on the left so the chat drawer expands to fill the rest. A docked hint ("Select a session · Enter / ↑↓") shows over the catalog when no session is selected. - The **session replaces the conversation** as the transcript surface, wrapped by a scroll viewport that owns long-session navigation (see §5/§6). - On narrow viewports (`max-width: 820px`) the grid stacks (`1fr / auto`) and the drawer takes `min 60vh`; the docked hint hides. ### Composition rules - The page owns **tab/active/viewport/create** state and persists tabs + per-session viewports (bounded to open tabs, capped at 50) in `localStorage`. The connection's session-first command surface drives catalog/replay/activate; tab management is purely a page-layer concern. - Events flow via Svelte `createEventDispatcher`: child → page → `connection.ts` command. No child talks to the connection directly. The session components reuse the dispatch contracts of the surfaces they replaced (home/newSession/toggleRecall/toggleTheme · select/closeTab · send/respond/abort/sessionOp · loadOlder/stateChange · refresh/stop/kill/resume) so page wiring is a thin adapter. - The dedicated viewer opens when a Descriptor body exceeds its render cap; the drawer routes it to `DedicatedViewer` with `cap` lifted. Recall and the runtime panel open as sibling panes inside the drawer's right column; only one of {recall, dedicated viewer, runtime} is shown at a time. - **No `agent_id` ever surfaces.** `SessionSummary` carries none; the private `routeAgentId` (the internal handle the per-agent event protocol routes by) lives only in `SessionTabState` inside the connection layer. Selection identity is the durable `session_id`. - Keyboard: Escape closes recall → runtime → create → drawer (and defers to a focused text field so a tray/search Esc clears locally without slamming the drawer). The rack, tab bar, and viewport own their own keyboard models. ## 4. Severity model (`severity.ts`) `Severity = "success" | "streaming" | "permission" | "error" | "compact"` (unchanged). Public functions `severityForStatus`, `attentionRank`, `severityClass`, `severityDotClass` keep their signatures and attention-rank semantics (idle/streaming calm; permission/error to the top). Additive helpers — `bayForStatus`, `BAY_ORDER`, `edgeToken`, `towerLabel` — map the severity model onto the edge-token grammar without touching the pinned seam. **How each state renders in the session-first structure:** - **idle / stopped** → calm green severity dot, no motion. A stopped runtime reads "history available" — a valid calm state, never red. (`stopped` is `runtime === undefined || runtime_state === "stopped"`.) - **streaming** → the catalog row's consistent 16px green Shard Reactor. The session's live response block retains its rolling caret. Still `info`-calm. - **waiting_for_permission / waiting_for_ui** → amber severity dot, ranked up by attention. The pending request renders as an **amber-edged overlay** (`ClearanceOverlay`) projected on the session; confirm sends, deny strikes without residue. - **error / failed** → red severity dot; a `failed` runtime reads "runtime failed" in the error-ink color. No flashing — the color carries urgency alone. State-as-severity, never failure-centric: the catalog is mostly green unless something actually needs you. The additive `bay*` helpers are retained for callers that want status-bay grouping, but the live catalog groups by **project** (not by status bay) — see §5. ## 5. The session catalog (`SessionRack` + `session-rack-grouping`) The catalog is a **project-grouped tree**, not a status-bay board. Grouping is a pure projection in `session-rack-grouping.ts`: - Sessions are grouped by `cwd`; each group's label is the cwd basename, groups are ordered by most-recent `modified_at` (most-recently-touched project first). - Within a group, **root** sessions (`parent_session_id === undefined`) are recency-ordered (newest first). **Delegated children** (`parent_session_id` set) are collected into a per-parent map and nested under their parent via `SessionCard` recursion. - The tree is **default-expanded**. Each parent node has a disclosure toggle (collapses its subtree); each project group header toggles its group. Both collapse states are owned in the rack. `SessionCard` renders one row: a severity dot (state-as-severity; stopped = calm green), the title (with first-user-message preview fallback, then "Untitled session"), an open-tab marker, recency, a status line (`towerLabel(status)` / "history available" / "runtime failed"), and the project basename. A subtitle preview line appears when a title is set. Material live and attention states use the same 16px Shard Reactor in every row, keeping the dense catalog visually consistent. **Keyboard navigation** follows the WAI-ARIA treeview pattern across **visible** rows: ArrowUp/Down move focus, ArrowRight expands-or-descends, ArrowLeft collapses-or-ascends, Home/End jump to the ends, Enter/Space selects. Typing into a field is exempt. The catalog is **presentational only**: it consumes `SessionSummary` (+ the per-session tab map solely to mark open rows) and dispatches `select` / `toggleExpand` / `newSession`. It performs no I/O. ## 6. The session transcript form + viewport `FlightPlan.svelte` renders the transcript as a **session log along a route spine** — a vertical rule (`.spine::before`) connecting every row. Each block kind has a DISTINCT geometry; this is the structural commitment that distinguishes it from a chat scroll: - **User → your message row.** A `▸` name marker on the spine, the callsign in mono, then the message framed as an echo (`"…"`). It reads as a sent message, not a chat bubble. - **Tool → tool call.** A severity-colored rule segment on the spine, the tool identifier as `⚙ ` (mono, togglicble), the call args as the route segment, optional detail body (Descriptor, collapsed by default), and a **`↳ ` result footer** — a one-line summary of the tool's result (ok / exit N / status label / first line of content). - **Assistant → response.** Prose paragraphs; thinking blocks collapse under a "reasoning" summary (dimmed one level deeper). The live streaming response shows the rolling caret at its tail. - **System → event / system note.** A one-line, bracketed, dim row on the spine (`[ event · turn started ]`, `[ compaction · context saved ]`, …), never competing with the session. Error signals render in the error-ink color. The semantic KINDS (user/assistant/tool + system signals between) are unchanged from the pinned block model; only the LAYOUT form is restructured. Each row carries `data-i` and `data-entry-id` so Recall's focus handler can scroll it into view and the viewport can anchor. `SessionTranscriptViewport.svelte` wraps `FlightPlan` and owns the long-session scroll behavior the surface needs: - **Load older at the top.** When scrolled within `TOP_THRESHOLD` of the top and `hasOlder && !loadingOlder && !loadError`, it dispatches `loadOlder` (debounced against an in-flight request). A top sentinel shows "Loading older…" while in flight and a "Couldn't load older — Retry" affordance on error; the trigger re-arms on the `loadingOlder` true→false transition so the retry control stays live even when a page fails. - **Anchor preservation across prepends.** Before a transcript change it captures the topmost visible entry + its pixel offset; after the DOM patch it restores that entry's offset so older-page loads never jump the view. - **Auto-follow near the bottom.** Appends/live output auto-scroll to the end **only** when the viewport is already within `BOTTOM_THRESHOLD` of the bottom. - **Unread tracking when not at the bottom.** When held away from the bottom, tail appends grow an `unreadCount` (stored as a marker, so it survives prepends that shift indices); a calm "↓ N new" control appears, and activating it scrolls to the end and clears unread. - **Persisted viewport state.** It emits `stateChange` with `{ anchorEntryId, offset, atBottom, unreadCount }` so the page can persist it per session, and restores a saved anchor/offset (or saved bottom) once on mount, reconstructing the seen marker from the persisted unread count. Default on a fresh session: pin to the newest. - **Keyboard** inside the scroll region: Home/End/PageUp/PageDown (parity with the keyboard-driven model). Boundary: the viewport is a presentational scroll surface only. It never talks to the connection; the page feeds it `transcript` + replay flags and persists the emitted state. ## 7. Draft-overlay rehearsed command Two surfaces project the consequence of a command *before* commit, as a draft overlay laid on the session: - **AnnotationTray.** Typing a prompt or steer renders a `pencil-strip` above the input — a single-line consequence strip whose focal point is the **verb** (what pressing Enter will do), with the outcome as secondary text: `STEER — inject into the running session` / `SEND — start a new turn` / on a stopped session `RESUME — wake the runtime and send`. The drafted text is **not** echoed — it already lives in the textarea below; the strip carries only what the field cannot: the action and its outcome. The edge carries the consequence's severity (graphite for an ordinary send, green for a live steer, amber for waking a stopped runtime). It settles in `--dur` (190ms) on appear. **Confirm** (Enter / Send) sends it: the message is dispatched and the text clears, so the overlay lifts with no residue. **Dismiss** (Esc / clearing the text) clears the draft; the session is as it was. The consequence auto-switches to steer when the session is mid-turn **and** a runtime is attached. When no runtime is attached, live ops (steer / abort / compact / branch / fork) are read-only, but a prompt still activates-then-sends. The consequence is exposed to assistive tech via the textarea's dynamic `aria-label` (`Annotation tray — : `); the visual strip is sighted reinforcement (`aria-hidden`). - **ClearanceOverlay.** A pending permission (amber edge) or UI request renders as an overlay on the session showing the consequence (tool, details, command) — you read it *on the thing that will change*. **Allow** sends the permission; **Deny** strikes it through and the overlay lifts (settles `--dur`); **Allow always** files the standing permission. UI requests render their native control (input/select/editor/confirm/notify). The mechanism reuses the rehearsed-command contract (`respond` with the exact wire response). Motion is state-only. Routine transitions stay bounded to 120–250ms (`--dur-fast/--dur/--dur-slow`); live work indicators use a calm 1.8s loop while the state is active. Catalog rows consistently use the Shard Reactor. Each transition into streaming or tool-running advances the chat activity strip through a seeded, non-repeating sequence of six wider instruments: route tracer, tool handoff, branch merge, work queue, compile stack, and transcript cursor. Unrelated re-renders do not switch the active instrument. Compacting, waiting, and error retain the Shard Reactor's state-specific collapse/lock/fracture behavior. No radar sweeps, contrails, roundels, or drifting cards. `prefers-reduced-motion` disables the indicator loops, caret, draft-settle, overlay-settle, and grid transitions. ## 8. Runtime-secondary model (`SessionRuntimePanel`) The runtime is a secondary concern, surfaced two ways: - **Drawer header badge.** The session title (with preview fallback) plus a runtime-state line ("history available" / "runtime failed" / the runtime_state), the project cwd, and an edge tab colored by severity. A "Runtime" toggle opens the details pane. - **Details pane** (`SessionRuntimePanel`, an opt-in right-pane sibling of Recall/viewer): session id, working directory, model, provider, mode, turn status, runtime state, created time, and usage (input/output/cache read/cache write, short-formatted). Lifecycle actions: Refresh status; **Resume runtime** (activates a stopped/failed session — the only path back to a live runtime); Stop runtime; Kill runtime; Force kill runtime (a confirm dialog; durable session history is always retained). Each action shows a pending state from the connection's `operationPending` map and surfaces a live status/notice line. Stopped sessions remain fully browsable: the catalog lists every session regardless of runtime state, the viewport loads the newest replay page, and the transcript + Recall work read-only. A prompt sent to a stopped session is the activation trigger — `sendSessionPrompt` activates then sends exactly once through the fresh route handle (the `queuedSend` fencing in `session-state.ts`). The drawer header also carries the compact runtime controls (`SessionRuntimeControls`): capability-gated Model / Mode / Reasoning selectors sourced exclusively from the authoritative `list_models`/`list_modes` catalogs (no hard-coded modes, no free-form field). A stopped/failed runtime makes those selectors inert, so the row leads with a **Relaunch runtime** action that activates the session in place — the same `session.activate` resume the details pane offers, without forcing the user to open the runtime sidebar to pick a model or mode. The action shows a "Relaunching…" pending state from the connection's `operationPending` map and is disabled while an activation is in flight or the connection is down. ## 9. Session tabs (`SessionTabBar`) Tabs are keyed by durable `session_id` (not agent_id), so stopped sessions survive daemon and browser restarts naturally. Each tab shows a severity dot (state-as-severity; stopped = calm green) + the session title (with preview fallback, then `Session `), and a close affordance. The bar is hidden when no tabs are open. Open tabs + the active id are persisted in `localStorage` (`shard.web.session-tabs`); the page restores them on mount and re-selects the active session. Closing a tab selects the next open tab or returns home; closing also drops that session's persisted viewport state. **Keyboard** follows the WAI-ARIA tabs pattern: ArrowLeft/Right move focus across tabs with automatic activation (the focused tab is selected), Home/End jump to the ends, and Delete/Backspace close the focused tab. **Tab / Shift+Tab cycle the open tabs forward / backward (wrapping), like browser tabs** — when focus is on a tab, or on the drawer's non-interactive chrome (the transcript log) with two or more tabs open. Single-tab bars and interactive controls (form fields, buttons, panels) keep the native tab order. The newly active tab is scrolled into view inside the strip. Middle-click also closes. ## 10. Recall — known seam Recall is a first-class client-side interaction over the **loaded** session (search, scope filter, focus-row). It renders inside the drawer as a sibling pane. Full pre-compaction history requires loading older pages via the viewport's `loadOlder` (the incremental scroll-up path in §6); the seam for *automatic* pre-compaction hydration on open is documented here, not a silent gap. ## 11. Theme + a11y - The top-bar theme control opens a compact color-theme panel. Appearance is independent of palette: `light`, `dark`, or `auto` resolves the matching variant from the selected scheme. Shard, Gruvbox, Darcula, and Catppuccin (Latte / Mocha) ship as built-ins; each has a complete light and dark palette. - `theme/schemes.ts` is the public semantic color contract. It covers canvas and layered surfaces, four text levels, rules, control accent, success/info/warning/error triples, diff add/remove/hunk, annotation pencil, caret/pulse, and edge sheen. Components continue to consume CSS variables only; `toCssVariables()` maps a scheme onto that stable seam. - Built-ins can be cloned, then renamed and edited by role in both variants. Custom schemes are strictly validated, persisted in `localStorage`, and can be imported/exported as JSON. The daemon also discovers installable `*.json` and `*.toml` files from the XDG-aware `/shard/themes/` directory (normally `~/.config/shard/themes/`) through `GET /api/themes`. Installed themes use the same `{ id, name, light, dark }` semantic schema, are read-only in the UI, and can be cloned for local editing. A malformed file is isolated rather than taking down theme loading; refreshing the page discovers file additions. The selected scheme, appearance mode, and last resolved CSS-variable snapshot are restored before CSS paints to avoid a light/dark or palette flash. Invalid/corrupt stored schemes are ignored and the Shard palette remains the safe fallback. - Night-scene Shard dark keeps warm cards against the dim desk; alternate schemes may define a different physical color scene while preserving the same component hierarchy and state grammar. - Interactive rows, group toggles, disclosures, tabs, and confirm bars are real `