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 (sagavr7kgx.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 (
cwdbasename), 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 inlocalStorage, 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).--canvasresolves 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, sobtn--primarytext 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 0frtominmax(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 takesmin 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.tscommand. 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
DedicatedViewerwithcaplifted. 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_idever surfaces.SessionSummarycarries none; the privaterouteAgentId(the internal handle the per-agent event protocol routes by) lives only inSessionTabStateinside the connection layer. Selection identity is the durablesession_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. (
stoppedisruntime === 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
failedruntime 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-recentmodified_at(most-recently-touched project first). - Within a group, root sessions (
parent_session_id === undefined) are recency-ordered (newest first). Delegated children (parent_session_idset) are collected into a per-parent map and nested under their parent viaSessionCardrecursion. - 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_THRESHOLDof the top andhasOlder && !loadingOlder && !loadError, it dispatchesloadOlder(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 theloadingOldertrue→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_THRESHOLDof 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
stateChangewith{ 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-stripabove 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 sessionRESUME — 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 dynamicaria-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 (respondwith 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'soperationPendingmap 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, orautoresolves 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.tsis 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*.jsonand*.tomlfiles from the XDG-aware<config>/shard/themes/directory (normally~/.config/shard/themes/) throughGET /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-visiblerings 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-currenton the active session;aria-expanded/aria-selectedon tree/tab toggles;.sr-onlykind 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, fallbackindex.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, plusweb-renderervia bun:test.connection.ts/render-descriptor.ts/enrollment-client.ts/Descriptor.sveltecontracts and the puresession-state/session-history/session-rack-groupingprojections 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.