shard src web DESIGN.md
31 kB · 447 lines
Markdown
at main

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 ↳ <summary> 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 <html>. 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 <svelte:self>):
                             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 ⚙ <name> (mono, togglicble), the call args as the route segment, optional detail body (Descriptor, collapsed by default), and a ↳ <summary> 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 — <verb>: <outcome>); 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 <short-id>), 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 <config>/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 <button>s; nested-button conflicts are resolved by splitting each row into a disclosure button + a select button.
  • :focus-visible rings use the control accent (--focus-ring); the focused catalog row always shows the accent ring even when selected.
  • aria-live="polite" session log (role="log"); aria-current on the active session; aria-expanded/aria-selected on tree/tab toggles; .sr-only kind labels per transcript row (your message / response / tool call / system note) so each block is announced distinctly.
  • No exclamation marks in copy; no hardcoded colors (only var(--...)); mono numerics for names, usage, paths, exit codes, node-ids.

12. Build verification #

  • bun run typecheck — clean (svelte-kit sync + tsc + svelte-check).
  • bun run web:build — succeeds (adapter-static, fallback index.html).
  • bun run test:web — the session-first suite passes: web-session-catalog-ui, web-session-connection, session-state, session-history, web-viewport, web-lifecycle-components, web-descriptor, web-state, plus web-renderer via bun:test. connection.ts / render-descriptor.ts / enrollment-client.ts / Descriptor.svelte contracts and the pure session-state / session-history / session-rack-grouping projections are pinned by tests.

13. Structural-form note — honest self-critique #

The session-first rebuild keeps the locked visual world (warm card / cool desk / edge-tab severity / airspace accent / draft overlay / route-spine transcript) and changes the navigated object from the agent/runtime to the durable session. The strongest structural commitments are the project-grouped catalog tree with nested delegated sessions and the transcript viewport's anchor/unread/load-older behavior — a developer reading the catalog sees projects and their session lineages, and a long session navigates without jumps or lost position. The runtime being demoted to a secondary badge + opt-in pane (with stopped sessions fully browsable) is the load-bearing product correction: the session outlives the runtime.

The weakest commitment is the edge-token / bay-helper residue in severity.ts (bayForStatus / BAY_ORDER): the live catalog groups by project, not by status bay, so those additive helpers are retained-but-unused by the rack. They are kept to avoid touching the pinned seam and remain available to callers that want status grouping; they are not part of the catalog's actual grammar. The drawer's right-pane (Recall / DedicatedViewer / SessionRuntimePanel) is structurally familiar, by design: the dedicated viewer is the pinned Descriptor escape hatch, Recall is the documented seam, and the runtime pane is the secondary surface — all reused to keep the pinned contracts green rather than forked for novelty.