跳转至

side-nav

The modern-app skeleton — a left icon rail with one entry per top-level page (graph · sessions · evals · issues · settings), each page at its own URL (#/…, with list-page filter state in the hash's query string) so it can be bookmarked, reloaded, and history-walked; list→detail navigation is a history PUSH, browser Back the return path.

raw source

The dashboard grew top-level surfaces — the spec graph, the session board, the evals feed, the issues page, settings — but they were organized as one page with overlays: the board a full-screen modal over the graph, the review surfaces tabs inside that modal, settings a popup. A user couldn't bookmark the session board, reload the issues page, or see where they were. The standard modern-app skeleton answers all of it at once: a left sidebar naming the pages, and a URL per page. The review surfaces are two peers, each a GitHub-style LIST page + DETAIL page pair (evals-view / issues-view) — the human's directive, verified against GitHub's live product: state in the URL, rows as links, click = push, Back restores the list.

expanded spec

  • One rail, five pages — every entry a real anchor. A slim always-visible icon rail on the app's left edge is the page switch: Spec Node Graph, Session Board, Evals, Issues, and Settings pinned at the bottom. Evals and Issues are distinct rail entries, each with its own glyph and i18n label — Evals above Issues (evals lead: the current measured loss is what review attends to first). The active page wears the accent; labels live in tooltips/aria (i18n'd), so the rail stays slim and the pages keep their space. Each entry is an <a> carrying its page's address (href="#/…"): a click is a native hash navigation — the same transaction the address bar, a bookmark, ⌥digit, or any in-page door produces — so middle-click/new-tab/copy-address come free and no click handler re-implements routing. The rail is chrome, not a page — it never scrolls away and never overlays content. And it is inert chrome for pointer focus (focus-return's acquisition-side guard): a press on a rail entry or the project chip acts — the link navigates, the chip menu opens — without moving DOM focus, so the rail never becomes the focus-return ticket and closing an overlay can never land focus on the top-left chip (where, as a menu trigger, it would swallow the node popup's keys). Keyboard Tab still reaches every entry, and a Tab-focused control keeps its native Enter/Space activation.
  • The URL is the page state — query string included. Routes are hash paths — #/graph (home; any unknown hash lands here) or #/graph/<node> (a shareable spec-node focus), #/sessions (+ #/sessions/<sel> deep-linking a tab), #/evals (+ #/evals/<node>/<scenario>, the canonical eval DETAIL address — each segment encoded on its own so the path shape survives), #/issues (+ #/issues/<id>), #/settings. A LIST page's filter state rides a query string INSIDE the hash — for the review lists, ONE ?q=<raw token text> param (review-query; the default view stays bare) — so a filtered list is a copyable, Back-restorable address. Hash, deliberately not the History API: the dashboard ships as a static dist behind plain gateways with no index.html fallback, and a hash route needs nothing from any server. route.js is the whole route layer (parse — path + query, hash construction, navigate, one hashchange hook, legacy normalization); the object-level address vocabulary over it is address-routing.
  • Pages push; list→detail pushes; filter changes push; automatic echoes replace. Switching pages pushes a history entry. Opening a DETAIL page from its list is ALSO a push — measured on GitHub: history grows by one and browser Back restores the previous list URL, filters intact; the detail is a real place, not an echo. A HUMAN's list-filter change pushes too (GitHub's semantics — Back walks filter history), and a list re-derives its whole state from the URL on every hashchange, so Back replays it exactly. What REPLACES is automatic state-naming — a normalization or the session board's selected-tab echo. There is no fake in-app Back button anywhere; the browser's history is the return path.
  • Legacy review addresses normalize. #/sessions/<id>/eval[/<node>/<scenario>] was the un-merged worktree evals' old home; its canonical form is now the evals-view family (#/evals?q=is:eval scope:<id> / #/evals/<node>/<scenario>?q=scope:<id>). Likewise the review lists' old STRUCTURED filter params (state/concluded/store/author/node/filer/verdict/ freshness/kind/live/ok/session) replay into the one ?q= token text (review-query). The route layer rewrites each old shape with replace on arrival — old links keep working, no old shape is ever re-minted. The retired scoped #/projects admin route crosses a pathname boundary instead: arrival at /p/<id>/#/projects performs one full-page redirect to the canonical global /projects surface.
  • Pages are peers behind one boundary, not layers. Navigation swaps which page fills the main area beside the rail; nothing dims or floats. Every routed page renders inside the same shell-owned pane with the same loading fallback — a page whose lazy chunk is still arriving shows that shared loading state in place, never a blank main area — and no lazy/loading intermediate ever touches the document head or unmounts the shell. Surfaces that must stay warm across switches (the graph's camera, the session board's live terminals) declare warmth: their pane stays mounted and display-toggles instead of unmounting — a property any page may claim, never a session-board special case — so a route change may never cost a terminal its socket, and a warm page's focus/scroll context survives Back into it. True transient overlays (help, search, the node popup) remain modals within a page and close when the page changes.
  • Only resolved identity reaches the tab. document.title and the favicon belong to the shared shell alone (dashboard-shell holds the one writer; project identity resolves the value) — no page, chunk, or loading state writes them. The shell writes the head only once the route-selected identity has actually resolved (a catalog row, or the board's own answer); while it is pending, the static boot document stands — the default mark and the raw project id are never written as placeholders. The browser remembers a favicon per page URL and re-resolves it on every same-document navigation, so a placeholder default written during one boot keeps flashing back on later navigations — foremost on the session board's freshly-minted per-tab addresses. A placeholder in the head is poisoning, not cosmetics.
  • Catalog-gated project switching, never project management. Under the multi-project gateway (projects-hub) a /p/<id>/ rail keeps the persistent current-project chip pinned above the five project-owned page entries. Its mark and label come from the route-matched project identity, never an initial derived from whichever board loaded last. Its 31px mark sits 2.5px inside the 36px rail hit target, halving the former 5px mark-to-border gap. It keeps the rail's neutral, borderless chrome at rest; the standard rail background appears only on hover or while its menu is open. A SUCCESSFUL catalog probe gives that chip a menu for same-tab project switching plus an "All projects" door to the global /projects hub. Every menu row leads with the catalog identity mark in one aligned slot — project marks for project rows, gateway mark for the global row — while its accessible name and current check remain intact. Online (and legacy/unknown) project rows remain native same-tab links; an explicitly offline row is a disabled status item with no project URL, so selecting a stopped backend cannot send the shell into a dead scope. It never adds a Projects rail page or mounts project management inside the scoped shell. When the catalog is denied the chip still names the current project but carries no menu, so a direct-project guest never sees the fleet or any global admin affordance: the gate is absence of data, not a hidden element.
  • One global ⌥ vocabulary; Esc never switches pages. Page switching is the ⌥ command family, window-global on every page: ⌥1..⌥5 jump straight to a page in rail order (graph · sessions · evals · issues · settings — the rail tooltips carry the hints), ⌥N to the New Session composer, ⌥F to the Evals list (the leading loss surface, so the letter door and the bare f agree) — matched by physical key (e.code, the mac ⌥-dead-key rule), ⌥-only so ⌘/⌃ chords stay the browser's. The family is reserved even over the console's raw-key nav mode (the same standing as its ⌥+I toggle — a TUI never sees M-1 or M-f). Graph-scoped doors stay: Enter → the session board, bare f → the Evals list, , → settings (and , toggles back). Issues has no bare-key board door — the rail, ⌥4, or history. Esc routes nothing — pages are peers, not layers, so Esc only closes transient overlays within a page (search, the node popup, a console menu); leaving a page is navigation: the rail, ⌥digit, or history. One vocabulary for mouse (rail), keyboard, and address bar.