跳转至

dashboard-shell

The desktop dashboard's root shell + shared substrate — the App.jsx root/router, the data.js polled-board layer, and the global styles.css — that every dashboard feature renders within.

raw source

The dashboard's feature nodes (node-graph, keyboard-nav, the session views…) all mount inside one root component, poll through one data layer, and style against one global stylesheet. That substrate has no single feature owner, so co-owning it fanned every shell/style edit across all of them. Give it a foundation node; features REFERENCE what they touch via related: instead of co-owning it.

expanded spec

dashboard-shell owns the cross-cutting dashboard files: App.jsx (the entry — it boots the one shared data layer, owns the fail-loud boot below, picks the face by viewport width, and is the one writer of the tab head: document.title and the favicon are written only from resolved route-selected identity — while the catalog/board probes are still pending the static boot document stands untouched, because a placeholder default in the head poisons the browser's per-URL favicon memory, side-nav), Dashboard.jsx (the desktop root — it mounts the side-nav rail and swaps the routed page through one shared page-pane boundary: every page gets the same pane and the same loading fallback, and warm pages — the graph, the session board — declare warmth to stay mounted and display-toggle across switches), data.js (the shared polled board data every view reads), and styles.css (the global stylesheet). The project scope is a shell concern (projects-hub): project.js reads the served pathname once (/p/<id>/ vs the root) and every /api URL in the data layer — fetch, SSE, the terminal WebSocket — routes through its one prefixing seam, so a scoped page talks to /p/<id>/api/* while an unscoped serve stays byte-identical to before; the entry's face pick extends the same way (a scoped 401 raises the shared credential gate instead of the error panel, and the root address with no board but a live /projects surface boots the hub face instead of the classic dashboard). Route params that belong to a feature (#/graph/<node>, #/issues/<id>, #/evals/<node>/<scenario>) pass through this shell unchanged; the destination feature owns their meaning. The shell holds no app-resident Issues/Evals row collection beside the board: both faces mount the same routed review pages, and those pages request only their current server slice through paged-review. The board itself stays graph-lean summary data, never a back door that preloads either review list. The shell applies an incoming routed selection before it echoes a page's local selection back into the hash, so an external door to #/graph/<node>, #/sessions/<id>, or another detail route is never overwritten by the previously-selected tab during the page switch. Likewise, feature-level shared widgets may add compact global style vocabulary here when the rule is genuinely reused across shell surfaces. Each face is its own lazy chunk, and the desktop root lazy-loads its heavy leaves (the session console with xterm, the evals/issues pages with the annotator) the same way — so the phone face (mobile-ui) never downloads the graph or terminal libraries, and the first graph paint doesn't wait on them either; the split moves bytes only, never behaviour. The split's one failure mode is owned here too: after a dist rebuild a still-open page asks for OLD hashed chunks the server no longer has (the gateway answers 404, never HTML — public-mode), so the shell catches the failed chunk load (vite:preloadError) and reloads once onto the fresh index.html — a deploy under a live tab costs one automatic reload, never a blanked app; a failure that persists right after that reload surfaces as the normal error instead of a reload loop. The board focus survives a reload or a mobile↔desktop breakpoint remount within its tab (session-scoped, so a fresh tab still opens on the root). A feature node lists whichever of these it touches under related:, so editing the shell or the stylesheet attributes its drift and eval staleness here rather than to every feature (see governed-related). This is the dashboard twin of sessions-core: one owner for the substrate, references everywhere else.

One palette, many themes. The whole app — the spec-node board, the react-flow canvas, AND the session console — draws its colours from one set of CSS custom properties (--paper --panel --panel2 --line --ink --ink2 --muted, the accents --blue/--green/--red/--yellow/--orange/--magenta/--cyan, --term-bg). Because every rule reads through those vars, a theme is nothing but another definition of them. Every theme is a community preset — design tokens ported from MIT-licensed themes in the official Obsidian community catalog (Minimal, Things, Tokyo Night, Catppuccin Mocha, Everforest, Gruvbox, Rosé Pine Dawn, Dracula; palette values only, never upstream CSS rules or per-component branches — every upstream license is independently verified at porting time and the notices are preserved in spec-dashboard/THEME-CREDITS.md). Minimal is the default and lives as the bare :root var set, so even an unthemed <html> paints Minimal; each other preset is one :root[data-theme=<code>] row over the same vars. Flipping the one data-theme attribute on <html> re-skins board and console together, with no per-component theme logic. The theme identity stays ONE flat code — no family × light/dark axes, and no base light/dark pair: the legacy light/dark themes are retired. The embedded terminal stays dark in every theme (the Claude TUI is dark-designed), so --term-bg is a neutral near-black under light palettes and each dark preset's own deepest surface. Even the scrollbars read through the palette: styles.css themes them globally (a thin, rounded thumb — --line at rest, --muted on hover, over a transparent track) via ::-webkit-scrollbar* for Blink/WebKit and scrollbar-color/scrollbar-width for Firefox, so every scrollable pane matches the app in every theme with no per-surface rule and no raw-OS default. The terminal is styled only at its edge; xterm keeps its viewport geometry so scrollback and TUI wheel paths stay truthful.

theme.js owns the pick: getTheme() returns an explicit saved choice (localStorage spexcode.theme, validated against the THEMES list) and resolves anything else — absent, garbage, or a legacy light/dark value from before those themes were retired — to the Minimal default; there is no system prefers-color-scheme axis. applyTheme(t) sets the data-theme attribute and persists. To avoid a wrong-palette flash before the module boots, index.html runs a tiny inline script in <head> that applies the same choice (same fallback to Minimal) to <html data-theme> before first paint — its inline code list mirrors THEMES and must move with it. The settings page carries the live picker; preset labels are proper nouns and deliberately untranslated.

One document scrollport. The shell's page pane defines the available viewport, while page-scroll is the one overflow owner used by document-shaped pages. Pages contribute content width and sticky children, never another full-page scrollbar. Graph camera geometry, session panes, terminal scrollback, and bounded overlays keep their own non-document contracts.

One type system. Dashboard chrome reads font size, line height, weight, and letter spacing from one small semantic scale in styles.css: caption/meta, control, body, subtitle, title, heading, and display roles, plus shared leading and weight roles. A component chooses the role its text performs; it never invents a nearby pixel value to make one label fit. The scale keeps ordinary UI text readable, reserves the smallest role for genuinely secondary metadata, and gives the graph, sessions, evals, issues, settings, overlays, and phone face the same hierarchy. Compactness comes from layout and spacing rather than shrinking copy below the scale. Responsive display copy may own a fluid scale token, but the formula still lives with the shared tokens rather than at its callsite. Letter spacing is neutral across the app; hierarchy comes from size, weight, colour, and case, not scattered tracking values. The embedded terminal uses the same family and a shared terminal-size token at its xterm adapter boundary, so its numeric API does not become a second typography source.

Fail-loud boot. A board that never arrives (backend down, proxy dead) shows an error + retry panel, never an eternal spinner — the pre-first-board window is the only reader; once a board has landed, a failed refetch keeps the last good board and the stream/poll below keep retrying on their own. The catalog projection keeps last-good the same way: it is identity-bearing, so a blipped poll (an absent answer after a proven catalog — a gateway restart mid-poll) never regresses a resolved identity to the anonymous default; a fresh ok or denied always applies — denied is an answer, a mid-session lock must re-gate.

Push-first board — freshest-issued wins. The shell keeps the board fresh through three paths. The primary is the delta subscription (graph-stream/graph-delta): whole boards arrive over the push channel — a full on connect, then patches the data layer applies to its unit-map mirror — straight into state, no refetch per change; a patch whose chain tag mismatches reopens the stream and re-anchors on the fresh full. Second, an on-demand reload() (/api/graph): a session close/rename calls it so every surface reflects the change at once, and an old backend that only speaks bare board-changed downgrades the subscription to exactly this refetch path. Third, a slow fallback poll that always runs as the final belt. Between them a heartbeat dead-man switch holds the stream to its contract: the server pings on a fixed cadence, so silence past 2.5× that window means the stream is DEAD (half-open tunnel, sleep-resume, frozen tab), not merely quiet. The cadence primitive, the derived dead window, and the switch itself live in ONE shared client heartbeat module (heartbeat.js) that the terminal socket (reconnect) reads too — one constant for the whole client, held equal to the server's two ping cadences by test, never a per-channel copy. Detection is event-driven, not a polling loop: every stream event (pings included) re-arms one one-shot timer, so on a healthy link liveness costs zero wakeups and nothing ever fires. On a breach it reopens (board-full re-anchors and repaints), re-arms to keep watching the replacement, and kicks the ETag refetch, so catch-up is instant; a frozen tab runs no timers, so its overdue one-shot fires on resume and converges likewise. The poll's cost is zeroed by conditional requests: loadGraph sends If-None-Match, an unchanged board answers a bodyless 304 and the shell skips the repaint, so no failure mode is staler than the poll period. That guarantee holds only while the conditional key is the identity of the board actually DISPLAYED: the ETag latches when its body paints (never from a response a fresher board superseded), and a pushed board clears it — the display's identity is then a delta-chain tag the HTTP lane can't express, so the next poll goes unconditional once and re-earns its 304s from a painted response. A key that outlives its paint would let the poll 304 forever against a board nobody sees, turning push-delivered staleness permanent. Because pushed boards and in-flight fetches can interleave, the shell stamps every application with a monotonic sequence — a pushed board is freshest by channel order, so it bumps the sequence and invalidates any older fetch still in flight; a superseded response is dropped, never painted. Without that guard a just-closed session resurrects: the post-close reload paints the row gone, then a stale in-flight snapshot lands late and flickers it back. The guard makes a removal stick the moment its own reload lands.

That same envelope sequences session eval summaries (session-eval): within one backend epoch, a session projection is accepted only when its generation is at least the last one displayed; an authoritative full snapshot may rebase the epoch, while a chained delta may not regress it. Stream ping proves transport liveness only. An error or dead-man breach marks resident summaries last-known without clearing their values; only the next authoritative graph-full certifies them current again. This is client state over the existing graph subscription, not a summary-specific EventSource, WebSocket, REST poll, or timer.