Skip to content

Queen Console Design System

Short contract for agents that write Queen Console UI. Targets the Svelte 5 + Tailwind v4 + DaisyUI 5 stack from Spec 035. There is no bespoke design: consistency and predictability over originality. Read this before writing any .svelte file.

Principles

  1. Use ready-made DaisyUI primitives and Tailwind utilities. Never invent a new visual style when a DaisyUI class already covers it.
  2. Colors, spacing, and typography come from DaisyUI themes and Tailwind — never from raw CSS literals in components.
  3. A page is a composition: shared shell + components from lib/components/. New screens add composition, not new CSS.

Theming

  • Curated themes only (registered in the main stylesheet through DaisyUI's CSS-first plugin): catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, light, dark. Do not add more — bundle size and predictability are the reason.
  • Theme is applied via data-theme on <html>, driven by themeStore (Svelte store), persisted to localStorage.
  • Dark mode is a theme switch, not @media (prefers-color-scheme).
  • Semantic color classes only: base-100/200/300, primary, secondary, accent, neutral, info, success, warning, error, *-content. No hex/RGB literals in Svelte files.
  • Icons come from lucide-svelte and render on currentColor, so they inherit the semantic classes above; a hand-rolled SVG is not an accepted substitute. StatusIcon is the wrapper for status glyphs — it owns the status→glyph table and the tone — and a component that renders a status goes through it. A non-status affordance (a copy button, a chevron) imports the lucide component directly and applies its own sizing class.

Layout shell

  • +layout.svelte renders the navigation drawer: div.drawer.md:drawer-open holding input.drawer-toggle, a div.drawer-content (the trigger, <Header />, <main>), and a div.drawer-side holding <SideMenu />. The menu is therefore always on screen above 768px as a column, and an off-canvas sheet below it — not a bottom sheet. md:drawer-open and not lg:drawer-open: the contract's breakpoint is 768px, and a drawer that opened at lg would show the menu to a window promised a collapsed one.
  • The drawer has one flag with two meanings, and it is the whole state model. Checked, it is the expanded menu above 768px and the open sheet below it; unchecked, it is the icon rail and the closed sheet. SideMenu renders the width (w-56 / w-16) and the labels from sideMenuStore.expanded rather than from daisyUI's is-drawer-open: / is-drawer-close: variants, which do not exist in the pinned 5.7 — and because the flag is also a preference, a store has to own it anyway. The preference is the desktop state, and only the desktop writes it: a stored expanded is not restored below 768px, and closing the sheet on a phone writes nothing, so a phone visit cannot turn the rail off on the operator's laptop. A fresh console shows the labels — user story #3 promises icons and labels, and the rail is what an operator opts into.
  • The menu is w-56, and that is a topbar decision as much as a menu one. The column sits beside the topbar row, which is shrink-0 all the way down and scrolls horizontally when it cannot fit. At w-64 the row's threshold was ~1300px of window; at 224px it is ~1240px, and FHD at 100%, 125% and 150% scaling all fit. The menu width is set against the topbar's budget, not the longest route name — the longest label is 72px and nothing clips.
  • The panel is h-full and its list scrolls, so the drawer side never does. min-h-full let the side scroll and reserve a 15px scrollbar gutter beside the panel — width taken from the topbar's budget, for a menu that scrolls in all but the worst case. The list is already grow overflow-y-auto, so the scrollbar belongs inside the panel.
  • Two controls, because two states answer different questions. The head's button (PanelLeftClose / PanelLeftOpen) is the rail: it is inside the panel, so it is reachable in both states, and it is the only control at 768px where the panel is always visible. Below 768px that same button is the sheet's close (X) instead — the sheet covers the topbar's own trigger, so the panel has to be able to dismiss itself — and it drops aria-pressed rather than claiming a state it cannot change. The two are worded apart (Close menu against the trigger's Close navigation) because one accessible name on two controls is a name nobody can act on. The top-left trigger is md:hidden and is the sheet's open. The panel's head carries the app mark — /favicon.svg, served from the console's static root, the same asset the legacy console declares — beside the word Navigate, which names the panel and not the app: the topbar's identity block already says which console this is, and two headings for one thing read as a mistake.
  • The stack is the topbar, then the trigger, then the sheet. The topbar is sticky z-20 (it only has to stay above the page content scrolling under it), the trigger z-30 (fixed over the topbar, and it must vanish under the panel it opened), and the drawer side z-40 — over daisyUI's z-10, because its example navbar carries no z-index and ours does.
  • The sheet shows the labels; the rail is a desktop state only. labelled is expanded || narrow, so below 768px the panel is w-56 with every name and its g <key> hint: a screen of unlabelled icons with no second control to collapse it would be the one place the menu cannot be read at all.
  • An icon-only route link is still a named link. Every entry carries aria-label, plus title in the rail where the label is gone, following the MetaList copy button's precedent. The name is what display: none on a label span would take away, and what a w-16 rail leaves the operator to guess; the g <key> chord is the second thing the rail hides, so it is the title's business to keep the route's name on screen.
  • The panel's foot carries the build stamp, and the stamp is the link. versionStamp(build, labelled) returns the server's composed line in the full list and the bare commit in the w-16 rail, which is all that width holds — a truncated version is worse than a commit the operator recognises. href is build.commitUrl ?? build.repository, both server-supplied and never derived from the module path, because a console built from a fork must not offer a link into upstream. It is one control rather than a stamp plus a button beside it: the route list is the only part of the panel that scrolls, and every row the foot claims is a route it hides. Its accessible name is ${stamp} — open on GitHub so the visible text survives into the name in both the full list and the rail, and its title (versionTooltip) carries the unabbreviated sha, the commit date, and whether this is a release — a bare 0.6.0+67730c4 reads as a name rather than as something to click. A stamp that has not arrived renders nothing, not a dash: this is where an operator confirms which build they are reading, and a placeholder reads as an answer. The legacy link stays the last stop, so the sheet's trap wraps on real ends. versionStore is app-scoped and read once (+layout.svelte calls start()), because a build cannot change under an open page and a poller would re-read a constant. The System route deliberately does not repeat it — that page is about the box, and one place per fact is the rule.
  • A menu glyph is a name, not a component. consoleRoutes carries glyph, RouteGlyph is derived from that table, and lib/route-icons.ts is the single name → lucide map — the split StatusIcon and runtimeActionGlyph already use. A route asking for a glyph with no icon is a type error, not a blank cell. There is no bee in lucide 1.0, so the roster takes Hexagon (a comb cell) and a headless run takes Zap against a session's Terminal.
  • The sheet is the only modal state, and the only one that traps focus. Above 768px the panel is a column with the page beside it: Escape must still walk a trail detail back to its list, and a Tab trap would make the page unreachable. So the menu's key handler is gated on narrow && expanded, and the shell's Escape yields to the sheet through navigationOpen(narrow) — asked of the DOM, like dialogOpen(), with the narrow argument the whole subtlety: checked alone is true on a desktop showing its labels, and an Escape claimed on that reading would strand a detail route.
  • A route link closes the sheet, and only the sheet. onNavigate writes the flag only below 768px; a link that collapsed the rail would make the column disappear on every navigation.
  • Routes: /next/dashboard, /next/traces, /next/timeline, /next/tasks, /next/reviews, /next/sessions, /next/bees, /next/worktrees, /next/runs, /next/git, /next/topology, /next/system, /next/settings. The menu groups them as Work, Colony, Diagnostics, and Configuration, and each g <key> chord is unique. Last route, theme, and the menu's own state persist in localStorage.
  • Two keys are not chords and live in the shell rather than in navigation.ts. g <key> is a destination, and these are not. Escape returns to the list the current path belongs to, found by running isRouteActive backwards (owningListPath) — the same test the side menu uses to light an entry, so all five detail families and /reviews/:traceId/:taskId/preview are covered with no per-route registration, and a detail route added later is covered without being registered anywhere. / focuses the list's filter and selects what is in it, because arriving at a filter you meant to replace is the normal reason to press it. A path that is a list has nowhere for Escape to go, so it does nothing rather than reloading itself.
  • Escape yields three times, and every yield is the point. A dialog keeps it, because Modal closes itself on Escape and a second, invisible way to dismiss one is how a form loses what was typed. The navigation sheet keeps it, for the dialog's reason — it stands between the operator and the page, and the menu's own handler closes it. An editable target keeps it — the same rule that stops a chord firing mid-word — so a half-written review note is never thrown away by a reflex. That rule also settles the case xterm looked like an exception to: a terminal focuses an off-screen <textarea>, so its Escape never reaches the window to be claimed, and no terminal-specific check was needed.
  • A shell affordance finds its target by asking the document, not by a registry. dialogOpen() looks for [role="dialog"] — which Modal renders inside {#if open} and which Drawer is — and listFilter() looks for [data-list-filter], an attribute rather than an id because the element's identity is "the list's filter" and not one particular list's. The same rule answers the two ends of the navigation: navigationOpen(narrow) looks for [data-navigation-toggle]:checked, navigationTrigger() and firstNavigationLink() for [data-navigation-trigger] and [data-navigation-link], so the trigger in the shell and the panel in the menu can hand focus back and forth without importing each other to agree on a name. Each costs a selector and none can drift from what is on screen, which a set of open flags maintained by hand can.
  • Header is one row: the identity block (console name, colony slug, StatusIcon for the NATS transport), attention badges that render only when reviews or invites are pending, then four bordered panels — Hive runtime, Live bees, Host, Git. Panel header rows are justify-between: label left, status right. The /api/chrome/stream event stream supplies runtime, agents, host, git, and attention; a dashboard poll supplies NATS. The active trail count is not in the top panel.
  • The Hive runtime panel owns one icon control, not two buttons. The icon is the action, never the status: runtimeAction() returns start (stopped, stale), stop (running), busy (in-flight or stopping, disabled), or choose (anything unclear — opens a Modal with both actions). With a glyph override the icon drops the status tone and inherits the button colour, so only one colour drives it; aria-label spells the action out. stopping stays blocked because Start mid-shutdown would spawn a second runtime, and the result is reported with a Toast. Stop always opens a Modal confirmation.
  • Every truncating line is wrapped in Hint: the row scrolls horizontally, so an in-flow tooltip would be clipped; the popover therefore lives in body. Panel header rows are min-h-8 so a 32px icon button and a 24px badge share one label baseline, and the four panels sit in a ml-auto wrapper so they align to the right edge.
  • The status is the second line: runtimeStateNote() is the raw status word plus a hint for the unusual values (starting · coming up, stopping · shutting down, stale · registry entry, start respawns, degraded · hive reported an error), so any status the server invents stays readable verbatim. runtimeDetail() (pid · started · heartbeat) is the third line, rendered only when it has content — the same three-line shape as the other panels.
  • The Git plaque's badge is about origin, and the working tree is a line of the panel. gitSyncLabel answers the one question the badge can act on — in sync, ↑3, ↓2, no origin, fetch where there is nothing to compare — and gitNeedsAttention asks the same question, so the tone and the word cannot disagree; a green badge can never read dirty. dirty is in neither, because a dirty root is close to a designed state rather than an accident: a worktree: false bee publishing code.proposal.root stages its edits and is told not to commit, and a root proposal never merges on approve, so a tone that followed the tree would be lit for most of every session — and would read as the reviews and invites badges further along the row, which are the tones an operator must not learn to ignore. The fact is a line instead: gitDetail leads with dirty and then the fetch age, and it dropped the short HEAD sha, the one item on this panel that was identity rather than a measurement (pid · started · heartbeat, bee counts, load 1.25) and which the Git page already carries as a copyable row with the full sha behind a Hint. The line is omitted entirely when the tree is clean, nothing has been fetched and no read failed, rather than padded.
  • Below 768px the row scrolls horizontally; panels keep their fixed widths so nothing reflows mid-scroll. Above 768px it could scroll too, and the cause was the menu column plus one odd panel. Everything in the row is shrink-0, so its width is the identity block plus the four panels and it overflowed on a window narrower than ~1283px — FHD at 150% scaling. Both were fixed by the menu's w-56 and by giving the runtime panel the same w-48 as its three neighbours: it had been w-56 because it is the only panel carrying a line that needs 201px (stale · registry entry, start respawns), which is a reason to keep the text and its Hint, not a reason for one panel to be a different shape. running, stopped, starting and stopping read whole at w-48; the two explanatory tails that now clip (degraded, stale) are on the Hint that already carries the same string. All four panels are one width, and a test says so — a row of bordered panels reads as a set, and one odd width reads as a mistake. A pending Reviews or Invites badge adds ~120px on top of this budget, so the row is tightest when the queue is not.
  • The redesigned app is mounted at /next/; the legacy console remains the default at / until explicit cutover.

API and store boundaries

  • lib/api/client.ts is the only place that fetches. Paths are root-relative /api/* — the /next prefix belongs to frontend routes and assets, never to the API. ApiError carries the status and the server's plain-text reason (Go's http.Error body), because domain errors like honey reserve not configured are the whole point of a 400 or a 503; it falls back to request failed: <status> when the body is empty or unreadable.
  • consoleStatusStore owns one dashboard poll (default 15s) and keeps the whole DashboardSummary in store.dashboard. The topbar derives NATS from it, and the Dashboard route reads the same object — a second poller for the same endpoint is a bug.
  • A route-scoped store owns a different endpoint and starts on mount / stops on unmount, so an idle console keeps no poll running. tracesStore polls /api/traces (15s); traceStore polls /api/traces/:id (10s) for the detail route; gitStore polls /api/git (15s), which is the slowest read in the console because every one of its polls shells out to git several times; systemStore polls /api/system (5s), the busiest, because it walks every pid. The rule is one poller per endpoint, not one poller per app — and a poller whose cadence the operator has not armed is a poller that does not exist, which is how timelineStore stays off the clock: the feed is cursor-paginated history, so it is offered a timer rather than given one.
  • A mount effect is untracked when its store's start reads the poll setting. Otherwise choosing a cadence invalidates the effect, whose cleanup stops the store and whose body starts it again — so the one control that only meant to set a timer spends a full reset read. The effect's job is mount and unmount; re-arming belongs to the setter, which owns the timer the way the store owns every other one.
  • A store must not assign a payload before checking that it is still the answer the operator is waiting for. Capture the id, await, and only then compare — otherwise a slow read for a trail the operator already left overwrites the current one.
  • A table's filter and page are the URL's, and the URL is written with replaceState. ?q=&page= is the half of user story #3 a list can keep by itself: Back from a trail detail lands on the page the operator left, and a narrowed list is a link they can send. pushState would make Back walk an operator backwards through the letters of a word — and it is also why a table never has to watch the history: nothing it does creates an entry to move between. The seed is read once, untracked, and from the address bar rather than from $app/state, because a table that re-seeded on every URL write would feed its own write-back into itself. Three rules make the query safe to publish: it merges, so a param the table does not own (?trace=, a deep link) survives a keystroke; it keeps the hash, since an anchored note is a position somebody can be sent to; and it spends nothing on the default view, so the common case is a clean URL. The page written is currentPage rather than the requested one, so a bookmark a poll has invalidated corrects itself in the address bar instead of sitting there disagreeing with the table under it — and a ?page= the URL cannot be trusted for is read strictly, because parseInt('1.5') is 1 and a number somebody got wrong is not a page to round in their favour. stateKey namespaces both params (?runs.q=&runs.page=) for a route with a second table; absent everywhere today, and deliberately not derived from label, which is prose written for a human and would put ?Review queue.q= on the wire. A pageSize={0} table writes the filter and drops the page, because an unpaged table has no page to name — Traces is the one route where ?q= is the whole shareable view.
  • A table with no rows publishes nothing, because it has no page. The first effect pass runs before the rows land — a cold list route mounts with its store empty — and the page under the seed is then derived from nothing, so it is 0 whatever the URL asked for. Publishing that wrote page one over the page the operator arrived on and reached replaceState before the router could accept it, which throws: Cannot call replaceState(...) before router is initialized, an uncaught rejection that takes the write effect down with it. It only ever happened on a ?page= link, because spends nothing on the default view is the guard that hid it — a default arrival wrote an identical query, returned early, and never called replaceState. The loading prop is not the signal, since eight of the ten tables do not pass it; the row count is, because every table has it. Waiting costs nothing, since the pass that follows the rows derives a real page and republishes it, so the seeded page is honoured the moment it is knowable and a bookmark the rows cannot fill is still corrected.
  • A read failure keeps the last good payload. systemStore holds the snapshot it had and reports the failure beside it, because the numbers an operator is reading are still the last real ones and blanking them is worse than showing them as stale. A page distinguishes the two kinds of bad news: a failed read is an alert-error, while the server's own partial-failure string arrives on a 200 and is an alert-warning about missing rows.
  • A page may read another store it does not own. /next/system marks live adapter processes from consoleStatusStore.agents.items, the chrome stream the topbar already keeps, so the join costs no extra request and cannot disagree with the Live bees plaque. This is a read, not a second poller.
  • One mutation in flight per store. gitStore.run() refuses a second call with a busy outcome rather than queueing it, because a push during a prune would race on the same refs. That guard is UI-level: it stops two clicks, not two browser tabs, and it is not the lock against live bees (see Backlog). A mutation that fails re-reads nothing — the view keeps the state before it, and the reason is reported.
  • A getter is for a derived value only (natsStatus maps a raw report to a status word, activeTraceCount counts in-flight trails). Pass-through fields (activeSessions, activeWorktrees, taskCounts) are read straight off store.dashboard instead — a getter that only forwards one field is noise.
  • store.refresh() forces an out-of-band poll; it is how a route updates itself after a mutation, in place of a manual Refresh button.
  • A truncating value needs min-w-0 as well as truncate. A flex item defaults to min-width: auto, so a long path refuses to shrink and overruns its own label; and Hint's wrapper is min-w-0 flex-1 rather than w-full, because it is sometimes a flex item beside a copy button and claiming the whole row makes the pair overflow leftwards.
  • lib/clipboard.ts owns copyText(). navigator.clipboard only exists in a secure context and the documented homelab setup is plain http on a Tailscale IP, so the selection path is a real fallback, not defensive padding — and the scratch field is always removed, including when the copy is refused.
  • Formatters live in lib/format.ts and take plain values, not components, so they stay unit-testable. Take the narrowest structural type that covers what you read (Pick<ArtifactView, 'announced'>), so a formatter can be called with a literal in a test. Row timestamps use formatTimestamp (local, 24-hour, YYYY-MM-DD HH:MM:SS); formatClock stays for the topbar's time-of-day lines.

Dashboard route (/next/dashboard)

  • No duplicate status. The hive runtime, live bees, host, git, and the NATS transport live in the topbar — NATS is a StatusIcon beside the colony identity, so the Dashboard repeats none of it. The stat grid carries only what the topbar does not: active traces, active sessions, active worktrees, and task counts. A tile never restates a topbar panel.
  • Stat grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 — three narrow StatTiles (active traces, sessions, worktrees) plus a task-counts tile on lg:col-span-2. Five columns rather than four so all four tiles share one row; a tile that spans the full row always lands on a second one, which is what the legacy stat-wide did and what this layout deliberately drops. Active traces is the trail count the topbar deliberately does not carry.
  • Task counts ride in the grid instead of their own card: the status is quiet text, the count is a StatusBadge taking the status tone, so ready 1 pops green and blocked 1 pops red while the words stay quiet. Sorted by status (taskCountEntries) so the tile reads the same on every poll. Empty reads None, not a sentence — a full clause in a tile looks like an error.
  • Recent traces are the front-and-centre TraceRow list, then Failed runs as a DataTable, then Recent insights as SignalCards — the order the legacy console used. Every trace row links to /next/traces/<id>; "All traces" is the only route-wide link.
  • Run cue is a header quick-action button opening CueRunModal (user story #16). It is the page's only header control, so it is full-size (btn, not btn-sm) and reads as the primary action. There is no manual Refresh: the poll and the chrome stream drive the view.
  • States: five skeletons before the first dashboard payload — one per grid column, so the grid does not reflow when the tiles arrive — then empty copy for each of the three lists and None in the task tile. store.lastError renders as an alert-error above the grid.

Trace routes (/next/traces, /next/traces/:id)

  • The list is a DataTable, the detail is its own route. Not a drawer: a trail carries a summary, a honey reserve, a comb, tasks, runs, a worktree, and an event tail, and a drawer cannot hold that without becoming a page anyway. A deep link to one trail must work, and /next/traces/:id is a child of the Traces menu entry, so isRouteActive keeps that entry lit on the detail route.
  • Traces pages once, and the page is the server's. The cursor behind Load older trails is the only depth this list has, and the table is unpaged (pageSize={0}) so there is no second one to contradict it. The loaded window is session state in tracesStore; the URL carries only the filter. An earlier shape layered a 15-row client page over the loaded window and kept it in ?page=, which produced three controls for one job: pressing Load older trails appended fifty rows that landed behind the pager, so the list on screen did not change and the button looked broken; and 1–15 of 50 counted the window while reading as the size of the history. Both are gone, and pageSize={0} is how a table says so — no pager, no range label, and no ?page= written. A table whose depth the server owns must not page itself, or the operator pages a window that was already paged.
  • pageSize={0} is a contract, not a missing value. It means every row is on screen, so the table writes no page and prints no count, and its skeleton stands in three rows rather than a third of nothing. A count here would be over the rows the table holds, which is not the number of rows the server has — there is no honest total to print without counting the whole history, which is the scan the list exists to avoid.
  • A cursor, not a rank, because this list grows while it is read. The store pages from the cursor on the last trail it holds, so a trail arriving mid-read pushes nothing out from under the boundary; a rank is only correct until the colony produces something. That is also why the store needs no correction logic — it reads the boundary off a row rather than counting rows, so a poll that prepends fifty trails changes nothing about where the next page starts. cursorPageScanLimit is gone, and it was never a cost: there is no index over .paseka/runs, so scanAllTraces walks everything on every read whatever the page asks for, and the old cap of limit * 10 was a filter applied to rows the walk had already read and then thrown away. It did not save work, it made the walk stop at ten pages and report that the history had ended there.
  • The state column speaks only when it has news. A trail that is neither active nor failed leaves the cell empty, because 4 runs would repeat the Runs column. The same rule governs the detail header badges: standing always, the state word only when there is one. A scan surface earns its keep by the rows that pop.
  • Cells never wrap. A wrapped date or bee list doubles the row height. The one grow column takes the leftover width and truncates, so a long title can never push the table sideways at 390px.
  • text is the cell, searchText is the rest. The id behind a titled row, a standing flag, and the trail summary are searchable but not shown; without searchText the filter would be blind to them.
  • Paging is server-side, filtering is not. tracesStore keeps the pages it has pulled in and exposes loadMore(); a poll merges the fresh first page by id so it never drops history the operator loaded, and the next page still builds from the same last trail because a poll does not move it.
  • A store that pages asks for one row past its page. tracesStore requests pageSize + 1 and drops the extra, so hasMore is answered by the response rather than guessed from a full page. The guess was wrong exactly once per history — a total that was a whole multiple of the page size ended on a full page, so the button promised a page that came back empty. The probe row is never part of the window and never becomes the next cursor, or the boundary would sit one trail past what the operator can see.
  • Detail order is priority order: header (back, title, badges, timeline link, summary) → Trail + Honey reserve → Trail artifacts → Tasks → Runs → Worktree → LLM usage → Recent events. Trail and Honey reserve share one lg:grid-cols-3 row, Trail on lg:col-span-2 — they answer "what is this trail" together, stacking two short blocks wastes a screen, and the identity grid needs the extra width because the bee list truncates without it. Worktree, usage, and events are collapsible and start closed; they are introspection, not the reason the operator opened the trail.
  • A copy button marks a value the operator pastes elsewhere, so the trail detail carries three: the Trail id into a CLI, and the worktree Path and Base SHA into a shell. It is a judgement about the value, not about the page, so a count, a timestamp, or a — never gets one — a worktree with no base SHA offers no button rather than one that copies an em dash. A row that truncates and copies needs hint too, or the value is unreadable at the width the button itself took.
  • A 404 on this route is not always "no such trail". Publishing a cue mints a trace id and puts a SIGNAL on the bus, and nothing is written to disk until a bee picks that signal up — so a trail the operator just created is a 404 for as long as the colony takes to answer. Reporting that as a failure would be telling them their own cue broke, so traceStore reads it as awaitingTrail, keeps its ten-second poll, and the page says the trail will appear. It is not skeletons: nothing failed and there is no payload to stand in for, so a role="status" notice replaces the body. The wait is bounded (awaitingTrailMs, 30s), because a wait with no end is how a mistyped id becomes a spinner that never resolves and how a cue nobody picked up — a real problem — hides behind the same silence; the two dead ends converge, so the message names both. The clock starts at the first 404 and is reset when the operator switches trails, or a freshly cued trail would report itself dead on arrival. A 404 for a trail already on screen is still a failure, because a deleted trail is gone and "not started yet" would be a lie the operator watches forever.
  • The comb's 404 is settled by the trail's verdict. Both endpoints fail for the same reason while a cue is in flight, and the two reads race, so load clears artifactsError once awaitingTrail is set — left alone it would still be on screen the moment the trail landed and the artifacts section became renderable.
  • The comb is a separate endpoint, so it fails separately. A trail with an unreadable comb still inspects its work: store.lastError and store.artifactsError are two different alerts, and only the second one replaces the comb list.
  • A cue never navigates. The Dashboard's publish toast carries Open trail rather than going there: a standing cue continues a trail the operator may already be watching, so the new one is not necessarily what they want to see, and the fear that started the entry — a background cue yanking somebody off a page they never acted from — is obsolete anyway, because CueRunModal is on the Dashboard and a publish is always something the operator just did. A link the operator presses cannot do that by accident.
  • Artifact bodies open in a Modal, never inline. The body is fetched when the operator asks for it, and the modal owns a requested ref so a late answer for an artifact they already left is dropped. Markdown arrives as server-rendered HTML and is styled with Tailwind descendant utilities ([&_h1]:…) rather than a new dependency; anything else renders in a <pre>. The server's omitted reason is shown as-is — there is no partial body to page — and so is the file's size, which is the fact that makes the next decision possible. MaxInlinePreviewBytes and MaxInlineExportBytes are separate constants, equal today: they answer different questions — what may be read on one screen, and what may be shipped — and an operator who hits "too large for inline preview" and raises the one constant they can find has raised the size of every export archive. Bytes costs nothing: it is the stat ItemFromFile already performs, and MergeAnnounced carries it because a field added to one and not the other is dropped on every announced file.
  • A trail is a place work can start, so its header carries a Run bee button beside Open timeline. It opens the shared BeeRunDrawer with the trail prefilled, which is the whole point of having it in lib/components: the same launch is on the Bees page with no trail and here with one, and two forms for one verb is how they drift. The prefilled field is editable, not locked — clearing it starts a fresh trail, which is a legitimate thing to want from a trail page — and the hint says which trail the run joins rather than repeating the id the header already shows.
  • The toast here has no Open trail, because the operator is already on it. That action belongs on the Dashboard and the Bees page, where the trail is somewhere else; offering it on the trail page would be a link to the page underneath. This one names the bee that was started, and the trail page's own poll brings the new run in without a manual read — so beeRan reads nothing, for the same reason the Bees roster is left alone: a refresh in the same second would report a run that has not been written yet.
  • Top-ups patch the reserve from the response, so a top-up never refetches the trail. The steps are disabled while one is in flight, and the outcome goes through a Toast rather than a blocking alert.
  • A row shows state, and links only where there is somewhere to go. A row that promises a destination and dead-ends is worse than one that shows only state, so href returns null for a row with nowhere to open and the cell falls back to plain text. Task and run rows on the trail detail started inert for exactly this reason and now link to /next/tasks/:traceId/:taskId and /next/runs/:traceId/:agentId, both of which exist.

Git route (/next/git)

  • The three sync actions are one compact group in the page header, not a stretched row of equal-width buttons. Fetch, Push, and Pull are btn btn-sm with a gap-2; the "Run git hooks on push" flag is a toggle toggle-xs beside them. Push takes btn-primary only while there are commits to publish, so a button that reads as the primary action is one that has work to do. There is no manual Refresh — gitStore polls and every mutation re-reads the clone.
  • A merged branch the sweep skips has its own Delete, in its own row. leftover is the sweep's name filter — a feature/ branch is not one — so a branch git would delete on request had no verb at all: the operator filtered down to it and found nothing to press. The row's action is offered only where gitBranchDeletable says yes, which mirrors the four guards gitroot.DeleteBranch holds (not the default branch, not HEAD, not held by a live worktree, must be merged), so the table says which branches are deletable instead of offering a refusal on every row. The server still refuses, and with the reason; the button only decides whether to offer.
  • The row action and the sweep share one dialog. Same verb, same guards, so confirming is the sweep or a named branch, the title says which, and the refusal appears in the same place either way — a second modal would be a second place for the failure to land. Closing it forgets the name, or the next sweep dialog would open already armed for a row the operator is no longer looking at. gitStore.deleteBranch(name) and run('delete') share one guard and one pending verb, because a push during a delete would race on the same refs.
  • Sync is direct, destruction confirms. Fetch/Push/Pull fire on one click; the server already guards them (push never --force, pull is fast-forward-only and refuses while live bees hold the colony root). Delete N leftovers destroys local state, so it opens a Modal first, and that dialog lists every branch name it is about to remove. A dialog stays open on failure and shows the reason in place; a header action has no such surface, so its failure goes to a Toast.
  • The worktree table is not here. It and the orphan prune live on /next/worktrees, which owns the inventory and the verb that reconciles it. This page keeps a count with a link where its copy was, and the section is absent when the colony holds none — a heading over an empty sentence says nothing, and the Dashboard's active-worktrees tile is the count's real home. A branch row still carries the worktree path and the trail behind a branch in its searchText, because a row here is a branch and a worktree is only one thing that can hold one.
  • The clone splits into two lists, not one wide grid: Colony root (branch, HEAD, default branch, working tree) beside Origin (sync, origin, last fetch, note). A single two-column MetaList pairs a row with an arbitrary neighbour, which reads as a jumble; two one-column lists answer two different questions. A row appears only when it says something, so no origin, no divergence, and no note leave the block quiet rather than padded with —. The divergence numbers live inside the sync word (↑3 ↓1) and are not repeated as rows.
  • The server's Note is a row, not a footnote. Without a remote-tracking ref the server cannot compare against origin and omits the unpublished list, so an empty publish list looks like there is nothing to push. The Note is the only thing that says why.
  • One word per row, in the order the operator acts. A branch's State badge names the fact that decides the next step — current (you cannot delete where you stand) → leftover (the sweep target) → merged — and the rest (default, worktree) stay quiet flags. A row with none of the three leaves the cell empty rather than reading plain.
  • A derived column is not a column. The worktree path is <colony root>/.paseka/worktrees/<trace id>, so it is derivable from the row; it lives in searchText instead of taking width that crowds out the columns an operator scans.
  • Prose is the grow column. A commit subject takes the leftover width; the branch name does not. Given grow to the identity column instead, a wrapping-free subject is starved down to its longest word and the table looks empty on the left.
  • A page-level action reports through a Toast, and a partial success is a warning. A batch branch delete is only as good as its worst name, so ok: false with a per-name reason reads as warning, never as success.

Worktrees route (/next/worktrees)

  • The page is the inventory, and prune is the verb that resolves it. The worktree list is a reconciliation — the server builds it from the home registry, git worktree list --porcelain, and the .paseka/worktrees/ directory — while everything on the Git page is a single-source read about one clone against one origin. That is the whole reason the table and the prune left /next/git: a destructive verb sharing a header with Push, one click away, is the thing to move, not to keep.
  • The headline is a sentence, not a count. worktreesHeadline has three forms, and the empty one is its own sentence: a colony with no worktree has not isolated anything yet, which is not the same claim as having isolated none of several.
  • One header, no section heading. A page whose only section is a table does not also want a <h2> saying what its <h1> already said, so the prune button sits in the page header and the DataTable follows.
  • The columns are the Git table's, unchanged. Branch (mono, grow, the path in searchText), Trail, State, Pull request (secondary), Base SHA (secondary). Moving a table between routes is not a redesign of it; the only label that changed is Trace → Trail, because a row is now keyed by one. The row that cannot show a state is the interesting limitation: the server's snapshot drops any row that is no longer a checkout root, so a orphaned badge would be the client inventing a state it was never sent.
  • No per-row action here. The header prune stays this page's only verb. DataTable gained a declarative action for the Git branch row, and dropping one worktree is deliberately not it: a worktree holds uncommitted work, so the row is the state and the page's one confirmed sweep is the verb. A second destructive row action would also be the point where action stops being one control and starts being a list.
  • One store, two routes, one poller. The worktree list is part of GET /api/git and the two routes are never mounted at once, so both use the same route-scoped store rather than a second poller on one URL.
  • The prune result is the reconciliation report. git worktree prune prints nothing when it had nothing to do, so the server joins the parts it has into one message and the console toasts it verbatim — no leading space, or the toast would open with one.

Bees route (/next/bees)

  • A picker and a roster are different questions, so the endpoint answers both. GET /api/bees defaults to the launch picker — only bees whose effective adapter supports an interactive session — because that is what the launch and create drawers consume and what a client that never learns a parameter can safely be handed. ?scope=colony is the roster: every bee, script adapters included, each carrying interactive so the page can say which rows cannot be started as a session. The flag is absent on the picker, where every row is interactive by construction, and beeIsInteractive reads an absent flag as interactive rather than as unknown.
  • A last run is server-derived, because a client cannot know one. The colony scope fills lastRun from runs.ScanRuns — every run directory, newest first, first per role wins — and the scan is uncapped where ScanRecentRuns is capped, since a colony with more runs than the cap holds would otherwise report a quiet bee as never-run. That is the same rule the Worktrees page follows: never infer a state the server did not send. A bee that has never run has no lastRun, and the cell is left empty.
  • The page does not poll. A bee's identity, adapter, sector, and intent vocabulary come from committed bee YAML, so they change when a commit lands, not on a clock — the topology route's reasoning. Liveness is the one fact that moves, and it is joined from the agents frame the topbar already keeps, the /next/system precedent, so the column costs no request and cannot disagree with the Live bees plaque. Refresh is honest rather than a gap: a run landing is the only thing here that changes without a commit.
  • The headline is a sentence, not a count, like the Worktrees one: a colony with no bee has been offered no work at all, which is not the same claim as a roster of several with none of them live.
  • A row is a DataTable row, so BeeCard is out of the inventory. A bee is one line of facts, which is the shape DataTable was built for. Columns: Bee (mono), Adapter, Intents (secondary, grow), Sector (secondary), Workspace, Live, Last run.
  • A multi-valued cell is one joined string, never a badge per value. DataColumn.text is (row) => string and always feeds the filter box, so beeIntentsLabel comma-joins the vocabulary and every intent becomes individually searchable with nothing duplicated into searchText by hand. A list of badges would want the row to grow, which whitespace-nowrap forbids. The separator is , because the values are a set of identifiers — a trail's bees, a task's dependencies — and · is reserved for a phrase built from parts of one thing.
  • A multi-valued cell dashes what is absent. A blank cell is indistinguishable from a row still loading, which is why beeIntentsLabel returns — for a bee with no templates, exactly as traceBees does for a trail that has not picked a worker. The Live column is the deliberate exception: it is a badge, and a null badge is the empty cell, because "no live process" is the normal state for most of a colony and a word in every idle row would be noise.
  • Workspace is plain text, not a badge. It is committed config rather than a state that moves, and the row that most needs saying out loud is worktree: false — a bee that writes to the colony root — which a badge column would leave empty.
  • The launch is a header button, not a row action. Run bee sits beside Refresh the way Launch session sits beside it on Sessions, and this page has no DataColumn.action — the second row action in the console, on a table that has nothing per-row to guard. Which bee to run is a question the form answers, and the roster behind it is a list an operator reads rather than a menu they choose from. Zap is the glyph, the same one the Runs route takes: the two run things and only one has a person in it.
  • A run is launched, not finished, so the form answers with a trail and a toast. POST /api/bees/:role/run returns the trace id and nothing else — no agent id, no output — because a run lasts as long as its agent and the browser does not. The toast therefore offers Open trail rather than navigating, the same rule the cue publish follows, and the roster is deliberately not refreshed: a refresh in the same second would report a Last run that has not been written yet, and the run's own page is where it is watched.
  • The drawer's picker is the roster, not the launch picker. BeeRunDrawer takes the page's own bees, so it asks no question the page has already answered — no fetch of its own, and nothing to go stale between reading the roster and opening the form. It must not be handed GET /api/bees without a scope: that picker holds the interactive bees only, and this form is the one control that can start a script bee, so a drawer built on the picker would drop the row it exists for. Its submit button needs no task for a script bee (beeNeedsTask), which is the server's own rule: the adapter decides whether a prompt is read, not the console.

Settings route (/next/settings)

  • Every value carries the source that decides it. This is the whole design, and it is the same rule the Bees and Worktrees pages follow — never infer a state the server did not send — applied to provenance. NATSConfig.EffectiveURL prefers PASEKA_NATS_URL over the home config, BotToken prefers PASEKA_TELEGRAM_BOT_TOKEN over the file, and the selected profile prefers a --profile flag, then the environment, then the home config's sticky profile:. A page showing values alone would report a URL the process does not use, and an operator editing config.yaml would watch nothing change with no explanation. ConfigValue is therefore { value, source }, where source is config.yaml, colony.yaml, default, unset, env:NAME, or flag.
  • A default is annotated in the cell; the home config is not. configValueText appends the source in brackets for everything but config.yaml and colony.yaml — those two are what an operator editing the file expects, so announcing them would be noise. A value nobody wrote must not hide behind a Hint hover, and a Hint is for text CSS has truncated, not for the most important word on the page. The one place a source gets a full explanation is the env-override warning, because there the file is not consulted at all.
  • The page is read-only about the colony's configuration, and says so in its own copy. The endpoints that would let the console write machine-local config do not exist, so a control that looked editable would be a promise the server cannot keep. The theme picker is the one control, and it is the one setting the console genuinely keeps itself. The test asserts there is no editable field on the page but the theme select, so the claim cannot rot quietly.
  • A credential is a reference, never a value. Adapters store api_key_env, a variable name, and read the key from the environment at call time, so the table shows the name plus whether it resolves, and the gate reports botTokenSet rather than the token. A Go test greps the raw response body for both a CURSOR_API_KEY value and a bot_token, because a test that decoded into a struct would not catch a field added later.
  • A declared value and a loader default are different, and only the file can tell them apart. Every adapter loader fills a default for an absent key, so cursor.yaml holding only binary still yields api_key_env: CURSOR_API_KEY; attributing that to the file would send an operator to a line of their YAML that does not exist. colony.AdapterConfigDeclared reports which top-level keys the file actually sets, and each value is marked config.yaml or default from that. A file that does not parse counts as absent — the loader already fails on it in the request path, and this must not become a second, quieter error.
  • The gate is reportable while broken, so it does not go through telegram.Load. Load treats a missing telegram.yaml and enabled: false as errors, so reading it that way would render "the gate is switched off" as a failed read — the answer an operator most needs. telegram.Inspect parses and applies the same defaults without validating and reports presence separately from enabled. The legacy waiting_review key is folded into the two review categories on read and never appears as a category of its own, so the page shows the seven the gate acts on.
  • The page does not poll. Configuration is a file a human edits, so a timer would re-read identical values forever — the Bees route's reasoning one step further out. The one fact that moves under a running process, whether NATS is connected, arrives on the chrome stream the topbar already keeps, so GET /api/config reports configured values and their provenance and no liveness; a second reader of the dashboard poll is the duplicate poller the store contract forbids. A failed read keeps the last good snapshot.
  • No new component. Section, MetaList, DataTable, DetailRow, and the ThemeSelect the inventory listed against this route since the shell landed cover it entirely — the fourth page in a row built entirely from what was already there. Push modes are DetailRows with a StatusBadge, because a category is one line of facts with a value beside it, and the adapter credentials are the eighth DataTable.
  • A Section note is a count, a caveat, or a provenance word — never a sentence. Notes render in caps, so a whole sentence in a note is a different kind of shout. The gate's "running" or "switched off" line is body copy; the note carries 7 categories or absent.

System route (/next/system)

  • Lead with the metrics, because that is why the page is opened. CPU, Memory, Available, Load 1/5/15, and Colony disk are StatTiles above the identity block. The topbar's Host plaque already shows CPU and memory, so the tiles earn their place by carrying what the plaque cannot: available memory, load 5 and 15, and disk. The "no duplicate status" rule is about the Dashboard stat grid restating a topbar panel — a detail page restating its own subject at a readable size is the detail, not the duplication.
  • A metric tile appears only when the server measured it. A statfs that fails must not leave a tile reading —, which reads as a fault the operator cannot act on. The tile is simply absent and the partial-snapshot warning says why. CPU is the exception: its percent is a delta between two /proc/stat samples, so the first poll of a fresh console cannot have one. That gap is expected, so the tile stays, shows —, and carries the reason in a Hint.
  • Process memory is formatted to the process, not the machine. The formatBytes GiB formatter the tiles use would print 0.00 GiB down the whole RSS column and make the rows incomparable, so the column has its own formatter whose unit follows the value — and which never climbs back to GiB, because a column that changes unit halfway down is the thing it exists to prevent.
  • A live adapter is a badge, not a row class. The legacy console marked a bee's row with a CSS class. The column declares a badge that returns live for a pid the Live bees panel reports and null otherwise, so an ordinary process keeps an empty cell and the tone table owns the colour.
  • Say when two numbers on one page have different denominators. The CPU tile is whole-machine (100 * (1 - idle/total), so it caps at 100%) while a process row is per-core (100 * ticks * cpus / total), so a busy process reads 172%. Without a line saying so, the table looks broken next to a tile reading 15%.
  • The process table starts folded, with the count in the summary line: 25 rows is the biggest thing on the page, and the metrics above are what an operator opens System for. Section opens by default, so a folded one says open={false} explicitly.
  • The Agent CLIs block starts folded, and its note is a caveat plus the roster size. Its read execs four external binaries, and an operator who never opens the block should not pay for it. Before the first answer the summary line reads not probed, 4 adapters: the count is the colony's roster (agentCLIAdapterNames, mirroring adapterCLINames in internal/console/adapters.go), a fact about which adapters exist rather than a guess about this box, and only the found flag in a payload is about the box. After a probe the note is the count — 2 of 4 found — which is the one thing that tells the operator whether the click was worth making.
  • Opening the block is the request, and the visit pays for it once. Section grows an ontoggle(open) callback, and the block calls adapters.load() on the transition to open. The store keeps the payload, so folding and re-opening is not a second read; a failed read leaves probed false, so the next open retries rather than leaving a folded block holding a dead error alert.
  • A Refresh here invalidates a server-side cache, so it is not duplicating a timer. The probe is expensive enough that the server answers from a process cache with no TTL — installing a CLI mid-visit is a deliberate act, not a fact a 5s poll should catch — and the metric tiles above are served by a poll this control does not touch. The button is in the block's actions slot rather than the page header, because the cache it drops is the block's own.
  • Not installed is a state, not a fault, and it has no reason. A found: false row gets the not found badge with an empty path and an empty version, and no error: there is nothing that went wrong. A probe that ran and failed — timeout, non-zero exit, unreadable version — is the opposite case and keeps its reason in the row, because a CLI installed and broken is a fact the operator can act on. Both answer 200 with every adapter present, so "which CLIs can this box run" never reads as a broken endpoint.
  • The row reports the binary the colony resolved, not the loader's default. A binary: in ~/.config/paseka/<slug>/adapters/<name>.yaml is the binary a bee would actually launch, so it is the one worth probing and reporting; the body copy says so rather than leaving an operator to wonder why the path is not under $PATH.
  • script is absent on purpose, and the body says why. Its binary is whatever each bee's command: names, so there is no single entry to probe or to report — listing it would put a row in the table that can only ever read not found.
  • The path column is secondary, so the table narrows instead of scrolling. Five columns, and the path is the reference behind the identity, which makes it the first thing to go below 768px.
  • The plaque label is the link, not the whole panel. Host, Live bees, and Git are each a real <a>, and a stretched overlay over the panel would swallow the Hint popover underneath it — the legacy console's invisible click target on a <div> is not worth restoring. Live bees moves its href with the bees, because a bee is not addressable. The order is the legacy one — AFK first, then sessions, then Runs — so a colony running both lands on Runs, an interactive-only colony lands on Sessions, and an idle one still lands where the next bee will appear. /next/bees is deliberately not the target: it is a roster of committed bee YAML with a live count per role, while the plaque counts live adapter processes, and Runs and Sessions are the pages that list those processes as rows. A href that changes under the cursor is the price of that contract, and liveBeesPath in navigation.ts is the one place the choice is made so the topbar cannot pick differently from the legacy order.
  • No Refresh button for the metrics. The page has no mutations, only reads, so the poll is the refresh; a control that duplicates a 5s timer teaches the operator to distrust the number. The Agent CLIs block's Refresh is the exception, and it is scoped to that block's own cache rather than to the page.

Timeline route (/next/timeline)

  • The filter panel is folded until something is in it. Six filter fields are a wall of chrome above a feed that is the page's reason to exist. open={applied > 0} is the whole rule: arrive with nothing applied and the feed owns the screen; arrive from a trail's Open timeline with a ?trace= scope and the panel is open, because that operator needs to see the scope they were sent with and be able to drop it. The collapsed summary line names the active filters, so a narrowed feed never looks like a colony-wide one.
  • Filters apply on submit, not per keystroke. The form is a form: six fields, one request. A filter that fires on every character would send the operator's typo to the server eight times.
  • The feed does not move unless the operator armed it. It is history, and it is paginated, so a timer that prepends to a list somebody is reading, or silently replaces the page they scrolled to, is worse than a Refresh button. The header therefore carries a Manual | 5s | 10s | 15s | 60s selector beside Refresh, defaulting to Manual: choosing a cadence is a statement that this operator is watching a run, and a feed that moved on its own would be a second reader deciding when this one looks. The four steps are the console's existing cadences (5s, 10s, 15s) plus a minute, and they are read as a ladder rather than as words — a beekeeper picking a cadence is comparing numbers.
  • A tick is the reset read Refresh already performs. The list is replaced, cursor and hasMore recompute, nothing is prepended. That is the whole reason a timer can be offered at all: opting in costs no scroll position, no row identity, and no boundary event, so the feed's one read-in-flight guard drops a tick landing inside an Apply rather than needing a dedupe. Refresh returns the selector to Manual, because a deliberate read is the operator taking the cadence back and a timer resuming behind the click would leave them unsure whether the button did anything. The cadence is component state and does not survive the visit; a feed that remembered its timer would re-arm it for whoever opened the page next.
  • The poll pauses on visibilitychange, the way the chrome stream does. A hidden tab is not somebody waiting on a feed, and each tick reads up to fifty trail directories. Returning re-reads at once rather than waiting out the rest of the interval, and keeps the operator's cadence, so the first thing on screen is never the state they left. The handler is also called once on mount: a tab opened in the background never fires the event, and it is exactly the case the pause exists for.
  • A button's busy state rides its icon, not its label, when the button sits in a header row. Refresh → Refreshing… is four characters wider, so a header carrying that button reflows while the read is in flight — harmless on a click, a visible flinch on a five-second tick. The word stays, the RefreshCw spins, and aria-busy carries the state to a reader who cannot see the motion. An action button is the other case and keeps naming its own verb while it waits (Fetch → Fetching…, Prune orphans → Pruning…): the operator started that by hand, is looking straight at it, and a verb is information rather than reflow.
  • A busy indicator has a floor of about a second, measured from when the work started. A read of a few dozen milliseconds gives one or two frames of motion, which reads as a glitch rather than as feedback. So the indicator is held past the end of the work — while the control's disabled still follows the real read, because the floor is on what the operator is shown, not on what the server is asked, and a button that stays greyed out for a second after it works again is its own lie. A second read landing inside another's floor extends the indicator rather than restarting it, which is what makes a five-second cadence read as a heartbeat rather than as five flickers. The arrival of the page is exempt: the skeletons already said it was loading, and a spinning Refresh on a feed nobody armed is the icon claiming a cadence that does not exist.
  • A disabled binding needs a reactive value behind it. inFlight was a plain let read through a busy getter, so Svelte had nothing to re-evaluate on and the binding was computed once at mount — it only ever refreshed by accident, when something else in the same subtree happened to change. The tell is a control that stays disabled after its work finished; the fix is in the store (let inFlight = $state(false)), not in the template.
  • One read in flight at a time. A second Apply against one cursor would append two pages at the same boundary and could repeat or skip an event. The overlap is refused, not queued — and a tick is the same overlap, so it is refused by the same guard rather than by a second one.
  • A failed load-more keeps its cursor and hasMore, so the button is a retry rather than a dead end. A failed tick keeps the feed on screen and shows the same alert, because a read that failed is not a reason to empty a list somebody is reading.
  • The raw envelope is a per-row disclosure, not a global checkbox and not a modal. The raw protocol.Event already ships on every feed row, so revealing it costs no request — the legacy's Show raw JSON per event checkbox put a <pre> inside every card, which is why it was worth replacing. A modal would mean opening and closing one per event. The disclosure is borderless on purpose: a closed box on every card is noise.
  • Navigation belongs on the identifier, not the prose. On a colony-wide feed the row's trace id links to the trail; the summary stays plain text, because a feed of underlined headlines reads as a wall of links. A feed already scoped to one trail drops both the id and the link — the open filter panel says which trail it is, and repeating it on every row says nothing new.
  • A feed row is not a table row. SignalCard clamps the summary to two lines and lets the meta line wrap; the "cells never wrap" rule exists so table rows keep a uniform height, and a feed is not a scan surface. SignalCard gained two optional props for this: an expand snippet under the meta line, and a traceHref for the id. The Dashboard and the trail detail pass neither, so all three surfaces stay one component.

Topology route (/next/topology)

  • This route owns the console's one imperative component. The colony graph is drawn by cytoscape, which is not a Svelte component and does not know about themes, tokens, or this contract. Everything else on every other route is declarative. Two rules keep that exception contained:
  • The graph takes its colours from the theme, not from the source. topology-theme.ts reads the active DaisyUI custom properties and maps them onto cytoscape's own properties, so a Svelte file contains token names and no colour literal. A theme switch re-reads and restyles the live instance rather than rebuilding it, because rebuilding would throw away the operator's in-progress drag.
  • The layout maths is pure and lives outside the library. lib/topology.ts builds the element set, orders the two sides by barycentre, and computes positions as plain functions, so the graph's behaviour is unit-tested without a canvas. The component only owns the instance lifecycle.
  • cytoscape is imported dynamically. It is the largest dependency in the console and only this route draws a graph, so it is code-split into this route's chunk rather than charged to the initial bundle every route pays for.
  • The bee side is one column and the event side wraps. Bees stay in a single left column — that is what makes the graph read as bees versus events — while event kinds wrap into a block whose width-to-height roughly matches the viewport. A single row of fourteen event ids is a line several thousand pixels long, and the fit that framed it shrank every label to nothing.
  • A dragged shape is remembered per colony. Positions persist to localStorage under a key namespaced by the colony slug, so two colonies on one browser do not fight over one layout, and Reset layout clears the stored shape so it cannot come back on the next read.
  • The graph is an image, so the Mermaid is the text path. A canvas cannot be read by a screen reader or pasted into a PR, so the container's aria-label carries the size and points at the Mermaid block, which is the same graph in the form paseka colony topology prints.
  • The projection does not poll. It is derived from committed bee YAML and colony auto_invites, so it changes when a commit lands, not on a clock; Refresh is the honest control.
  • All three actions stay visible, and each sits next to what it acts on. The audit wanted Copy Mermaid and Reset layout hidden behind a menu, which is right for a page that is mostly a picture — but all three are things an operator reaches for while looking at the page, and a menu would put them one click further from the thing they act on. Refresh and Reset layout are page-level, so they take the header's action slot on the right, as on the Git route; copy acts on the Mermaid, so it rides that block's header rather than competing with them. Reset is btn-error btn-outline, because it throws away a shape the operator spent time arranging — the one destructive thing on a route with no mutations.
  • The Mermaid starts folded, and it is a text shadow of a picture above it. open={false} is the whole rule: the graph is why an operator is on this route, and the block is the same data in the form paseka colony topology prints — wanted when pasting into a PR or reading aloud, which is not the moment of arriving. Folding it also keeps the copy button honest, since a button in the page header for content further down the page points at something off-screen.

Reviews routes (/next/reviews, /next/reviews/:traceId/:taskId, …/preview)

  • Three routes, because the diff deserves its own screen. Queue, proposal, and a full-screen merge preview. The legacy hid one <main> and showed another while keeping the same URL, so a preview could not be linked to and Back left it in a state the tab did not describe. Here it is a child of the proposal, so the menu entry stays lit and the browser agrees with the page.
  • A queue is a DataTable and a proposal is a detail page, unlike the legacy's queue-beside-detail split — a proposal has its own URL, so it can be shared, bookmarked, and reached from the task page and the topbar badge.
  • A final gate is not the same decision as a mid-trail review, so the queue badges it differently and names what approving will do: a local_merge merges the branch, a pull_request opens or updates a PR. A review that is not a final gate has no delivery at all, and says so rather than implying one.
  • The diff is parsed by this console, not by diff2html. The legacy vendored diff2html and then scraped its generated DOM for line numbers — td.d2h-ins .line-num2 — to anchor a review comment. That couples comment anchors to a third party's private markup, so a diff2html upgrade would have been a review-page change. lib/diff.ts parses a patch into rows that carry their own old/new line numbers, which makes anchoring a note a field read rather than a selector query, renders as Svelte text nodes instead of a library's innerHTML, and keeps the console's one third-party styling exception (cytoscape) the only one.
  • A patch splits on the diff --git header, never on +++ b/. A context line can begin with the same three characters, so +++ is not a safe delimiter. The path is the b/ side — the name a reviewer recognises after a rename — with the a/ side kept beside it so a rename shows both. The added/removed split comes from the --stat bar, because the patch itself carries no totals.
  • The file list filters; the body does not. Hiding a file's section would make every line number an anchored comment points at move, so the list is a navigation aid and the body stays whole. Paths wrap rather than truncate: config.go and config_test.go both truncated to internal/gate/telegram/con…, which is the one thing a diff file list must not do.
  • File navigation is real buttons, not j/k on a focusable <div>. The legacy walked its list with j/k from a tabindex="0" div — a <div> that swallowed Tab — and marked the <ul> as a listbox while leaving every <li> without a role, so no option was ever announced. Buttons are reachable and activatable on their own and have no such gap.
  • A moved head holds the comment packet; it does not void it. A note's line numbers are meaningless against a different commit, so when the diff's headSha changes the drafts are kept, the send is disabled, and the panel says which commit they were written against and which the diff now shows — with a button that says what it does. Silently re-aiming them would aim the rework at the wrong lines; silently dropping them takes the reviewer's work with them. Acknowledging re-pins the packet to the head the reviewer has just read, so what is sent is what they checked.
  • A re-read must not blank the diff it is re-reading. The comments panel is mounted beside the diff, so clearing the diff to show a loading skeleton would unmount the panel and destroy every draft — a review would lose its notes because the agent happened to push. A same-trail re-read therefore leaves the old patch on screen under a "Re-reading" hint, and only a genuinely different trail gets a blank.
  • One parser, two layouts. The unified view is a patch as the server sent it; the split view is the same rows paired one-for-one, with a filler where one side runs out. A rewritten block reads better with both versions in view, and the pairing is a property of the patch rather than of the renderer, so the two layouts cannot disagree about a line number. Split is also the only way to put a note on the old line of an unchanged line — which is usually the point of the note.
  • The split table sizes itself in colgroup, not on its cells. In a fixed-layout table the first row sets the columns, and the first row of every patch is a colspan banner, which cannot. Widths on the cells are therefore ignored and the browser falls back to sizing by content, which left one half of the diff twice as wide as the other.
  • Reject and Request changes are different actions and say so. Reject publishes feedback only. Request changes starts a rework task and carries the annotated packet, which is why it lives beside the diff where the lines are, and why it is disabled while a rework from an earlier rejection is still in flight.
  • Approve names the verb it will perform: Approve, Open PR, or Update PR, with the PR fields shown only for a final gate delivered as a pull request. pull_request is the value — an earlier version compared against pr, which no delivery equals, so those fields silently never appeared.
  • The proposal patches sit beside the live gate preview, never instead of it. The Proposal patches block lists every archived proposal attempt for the trail — one row per run, with its bee, mode, kind, publication state, capture time, file count, and size — and opens a row's body on demand from /api/traces/:traceId/proposal-patches?agentId=. It answers what each run proposed, which is a different question from the merge gate's what is staged now, so it is a distinct block with its own endpoint and a trail whose worktree is already gone still has it. The listing reads sidecars and file metadata only; a body is a bounded, escaped preview, and an omitted or truncated one says so rather than passing itself off as the original. Publication (published / unconfirmed / unpublished) is stated as what the broker confirmed at capture time, never as approval: the block carries no review outcome and makes no claim about the code a Guard approved.

Sessions routes (/next/sessions, /next/sessions/:sessionId)

  • A session is a link, not a row you click. The identity of a session is its id, so the row is an <a> into its own route and the detail is its own page. The legacy's <li> with a click handler had no href, no tabindex, and no role: unreachable by keyboard, unlinkable, and un-deep-linkable.
  • A live terminal gets a mode, not a route. Reviews' merge preview became a child route because a diff is shareable. A terminal is nobody else's to link to — the bytes belong to whoever is watching — so full screen is a state on the detail page: the terminal's box changes and nothing else does, the list is never hidden, the URL keeps naming the session, and Escape leaves. The legacy's Widen button collapsed the page and hid the launch form and the session list, with itself as the only way back.
  • One terminal instance, always. Focus mode resizes the existing SessionTerminal rather than mounting a second one in an overlay. A second instance would open a second relay subscription to the same PTY, so the agent would have two terminals answering for one and every keystroke would arrive twice.
  • The relay's lifecycle is a prop, not a call into the child. enabled={false} is Detach. A parent reaching through bind:this to close a child's socket is the same coupling as the legacy's global SessionTerminal singleton, and a prop is testable without standing up an emulator.
  • The terminal's size goes out after the socket opens. Sending it at attach time sends it into a socket that is not open yet, so it is dropped and the PTY keeps its default size — which is how an agent's TUI ends up wrapped into unreadable columns on a wide window.
  • A relay refusal is translated, not printed. The console cannot know in advance whether it owns a session's PTY — one started by paseka bee chat in another terminal is active with a live PID — so the relay is asked and the server's reason becomes a sentence with the CLI alternative in it. An unrecognised reason is passed through: the operator can read it and report it, where a generic message would hide the actual failure.
  • Stop confirms, and then reports which stop it was. A session in this process is Process.Kill; one in another gets a SIGTERM. Those are different acts with different consequences, so the confirmation says which one is coming and the result says which one happened.
  • The transcript follows the reader, not the clock. It follows the newest line only while the reader is at the bottom; reaching for history is a choice the poll is not allowed to overrule, and a Latest button appears the moment it is. The log is bounded to a tail and says how much it dropped, and it does not wrap: a session's transcript is terminal output, and a two-column menu folded into a staircase is unreadable.
  • A connecting effect is idempotent, and returns no cleanup. A store that polls replaces its selected item with a fresh projection on every tick, which re-runs any effect downstream of it. A terminal effect that returned a close cleanup therefore tore the relay down and opened another socket several times a second, left the old ones open, and flashed its "relay closed" notice each time. So the effect decides what should be connected now and does nothing when it already is, and teardown happens only where a change is observed: a different session, a detach, or unmount. Being re-run is then harmless by construction rather than by hoping nothing re-runs it.
  • A $derived over a polled object is not a signal, it is an identity. attachable = $derived(session?.active === true) reads as a boolean and behaves like a fresh object on every tick, so an effect downstream of it re-ran on every poll. Anything a connection depends on is held as $state that moves only when the answer moves.
  • A nullable list is read in one place, never inline. beeIntents is the only reader of Bee.intents, because the server sends null for a bee with no prompt templates and two drawers had the same .length on it inline — one of which was a live crash. The shape differs by endpoint on purpose: console.BeeView.Intents has no omitempty and is null, while colony.Bee.Intents has omitempty and is genuinely absent, so the topology label keeps its own optional read.
  • xterm is the console's second third-party styling exception, and it is the right one. The diff parser exists to avoid a dependency for a patch; a PTY is a terminal emulator, and the bytes an agent's TUI draws are the bytes xterm was built to interpret. There is no version of this problem to write instead, so the trade is the styling exception rather than a hand-rolled escape-sequence interpreter.

Tasks routes (/next/tasks, /next/tasks/:traceId/:taskId)

  • The board is a grid of status columns that wrap, not one wide scroller. Seven lifecycle statuses side by side would put the last two off the right edge of a laptop and entirely out of reach on a phone. Wrapping keeps every column reachable, and the server's group order — ready, running, waiting_review, planned, blocked, failed, completed — is the pipeline read left to right, so the page does not re-sort what it was handed.
  • Each column scrolls inside its own bounded box, because a colony is mostly history. This one holds 29 completed tasks beside a single ready one; an unbounded column turns the board into a three-thousand-pixel ribbon with the work buried at the top. The count in the header stays visible, so a column that scrolls still says how much it holds — and every card stays reachable, which a "show 10 more" fold would not.
  • A card links to its own page, like every other row in the console. A task is addressed by both ids, and the board is colony-wide, so the card key is traceId/taskId: _review is the default id for a rework task and five trails each have one, and keying on the task id alone throws each_key_duplicate and renders nothing.
  • A column header is a topbar panel header. The status names the column in the same text-xs font-semibold tracking-wide uppercase the Hive runtime and Live bees panels use, and the count is the badge on the right. The console already has one pattern for naming a small panel of state, and a second one on a board would read as two different ideas about the same thing. The count badge is a StatusBadge toned by the column's status, so moving the status word out of a badge and into the title costs the colour nothing, and the count shown is the server's taskCounts figure rather than the number of cards that happened to render.
  • The card says in badges what a table would say in columns. Startable, retryable, the review policy, the dependencies. Dependencies are a badge and not part of the second line: a dependency printed twice on one card is noise, not emphasis. The id line is dropped when the title already is the id.
  • waiting_review is badged "waiting review" — the one status an operator cannot read as a single word, and the one the board exists to make visible. A gated task also carries its policy as a badge, because a task that stopped for a human should say so on the card and not only on its own page.
  • The badge reads the polled board row, not the once-read detail. The detail is fetched once and the board is polled every five seconds, so a task approved in another tab would keep announcing itself as waiting for review for as long as the page stayed open — the badge would disagree with the board it links back to. canStart, canRetry, and canApprove come from the row for the same reason: they are the ledger's eligibility answer, and a stale one offers a start that would be refused a moment later.
  • Start and Retry appear only when the server says the task is eligible, and sit above the detail rather than inside it — they are the two things an operator does to a task that is sitting still.
  • A list field from the API may be null, and the page normalises it once. Go marshals a nil slice or map as null, and the task views carry their lists without omitempty, so a task no run has touched answers with runs: null and a colony with no tasks answers with taskCounts: null. TraceDetail already declares its own lists nullable and every page reads them through ?? []; TaskDetail and TaskBoard say the same, and the store is where the board's counts are normalised because the store is the boundary that hands a page a list rather than whatever the wire had. Normalise at the read, not in each of the three places that ask a list for its length — the first version of this page did exactly that and a real task with no runs took the whole render down, because null.length throws during render and there is no error boundary to catch it.
  • Approve, reject, and discard live on the task page, not only on the review queue. This is where a task is read, and a gated task that could not be answered from the page it is read on would be a step back from the legacy console. The PR fields are collapsed under Approve and shown only when the task delivers as a pull request — a task delivered as a merge commit has nothing to title. Reject stays one box: a line of feedback, which becomes the rework task's body. Discard is the third, terminal close — "not doing this" — and wears a btn-warning outline, reads as a one-question confirmation (an optional reason, and a submit that says what stays behind), and never sends feedback or starts rework. The toggles read Approve…, Reject…, and Discard…, because the verb opens something and because a toggle and its submit with the same accessible name is a screen-reader trap.
  • A gated task links to its own review, because the diff is not on this page. Approve and Reject without a route to the change they decide on is a decision offered without its subject, and the review queue is the only other way onto the patch — one trail's row among the others, found by eye. The link sits in the Review section beside the buttons it belongs with, under the same gate that renders them, since canApprove, canReject, and canDiscard are the server's one answer (hiveview.reviewActionsForTask returns all or neither) and there is no half-gated task to differ from. It lands on the proposal, not on the merge preview below it: the proposal is what says there is no branch on this machine or nothing to merge, and its header carries the way back here — a shortcut that skipped a level left a task page one step from a preview whose only exits are Back to review and All reviews.
  • A link that leaves the page is a link, and wears the glyph for that. The control reads Open review with a ChevronRight after it — the same chevron the run detail uses for the run beside this one — because a glyph and a label that both say the diff is right here promise a patch that is one route away, and an operator who reads the icon first has been misled by the console rather than by their own miss. The destination is named, the thing behind it is said in the plain hint beside it, and what differs by gate lives only in that hint: a final gate merges and has a patch to read and notes to leave on it, a mid-trail review merges nothing and says so. The label is the navigation; the hint is the promise, and only one of them can be wrong about which page it is.
  • The create form is a Drawer, not a full column and not a centered modal. Drawer is Modal with placement="right": same focus trap, same ESC, same focus restoration. It is a placement, never a second focus implementation. A form with nine fields wants the full height, and the operator checking it against the board should not lose the board.
  • A task's intents come from the bee's own prompt templates, read from GET /api/bees, so a bee that adds an intent needs no console change. A task with no title is still valid — the server accepts a title or a body, and the form's hint says so rather than lying about a required field. An unset optional field is left out of the request rather than sent blank, because a present-but-empty trace id is not the same as an absent one, where absent means "generate one".

Runs routes (/next/runs, /next/runs/:traceId/:agentId)

  • The list is compact: five columns, and the adapter and the model are searchable rather than shown. Which adapter ran and which model answered are both real questions, but the detail page answers them and a sixth column of identifiers is what pushed the run's own link off the right edge. Both ride the Bee column's searchText beside it. The trail column is grow, so it absorbs the leftover width and truncates instead of shoving the last column out of the table.
  • The model is in the headline, not only in the identity rows. {bee} on {adapter} · {model} is what an operator reads first, because "which model answered this" is the first question about a run and the bee's config cannot answer it after the fact. A command or script bee has no launch model, so the segment and the Model row are both absent rather than naming the adapter's default.
  • A wide table scrolls; it does not clip. The table wrapper is overflow-x-auto, not hidden. A phone cannot fit six columns, and clipping the last one puts a link somewhere with no way to reveal it. The region scrolls and the page around it does not.
  • The run's own state is the row's headline. State carries the badge and the run id links to the detail; the trail id links to the trail. Both targets exist, so both are links, and neither repeats the other's words.
  • Prev/next are real links, not buttons that swap a store. A run has its own URL, so stepping to a sibling has to put that URL in the address bar — otherwise a run in a trail cannot be shared and Back does the wrong thing. isRouteActive treats the detail as a child of /runs, and the route change drives the store's select, which is the same shape the trail detail uses.
  • Neighbours are by start time within the trail, not by list position. A fresh run shifts list positions; "the previous run" means the one that started before this one, which is how an operator reads a trail. A run outside the recent window reports no position rather than a confident 1 of 1.
  • Events are projected, not dumped. The run's events endpoint hands back raw protocol.Event envelopes, so runEventSummary derives the handful of fields a human reads — contract, payload kind, severity, summary — and the row is the same SignalCard the Dashboard and the Timeline feed already use. A payload with no summary still says something: payloadDigest lists the payload's readable fields, and a list of records contributes its first element, because artifact.written announces {"artifacts": [...]} and has no top-level scalar at all. The raw envelope stays one folded disclosure away, on the row, costing no request.
  • The run summary is literal text, not prose. Whatever the adapter wrote, terminal escapes included, stays in a scroll box rather than being reflowed or treated as markup. The task body is folded by default: reading a run is about what it did, not what it was handed.
  • This route polls, unlike the feed and the topology. A run's state moves from queued to running to completed while an operator watches, which is the one thing on these pages a timer is for.

Component inventory (lib/components/)

Use these. If a page needs a missing element, add the component here and extend this table — do not duplicate one-off markup across pages.

Component Use for Notes
StatusBadge Any status or state label badge + one semantic class; see mapping below
StatusIcon Status where the word is redundant (a runtime already labelled "Hive runtime") Inline SVG on currentColor, role="img" + aria-label + title = raw status, sr-only text; sm 12px, md 16px, lg 20px
DataTable Tabular lists: traces, bees, runs, worktrees, branches Generic over the row type; every column supplies a text projection (the cell plus the filter box) and may add searchText for terms that are searchable but not shown; href renders a link and may return null for a row with nowhere to go, which falls back to plain text; badge renders a StatusBadge (or nothing when it returns null, and a column that declares badge and gets null renders an empty cell rather than falling back to text); action renders one control in that cell, beside its text, and returns null for a row it does not apply to — a table that shows a control on some rows is telling the operator which rows it is for. grow gives one column the leftover width so it truncates, and the cell's text truncates with it or it overflows its own max-w-0 cell; mono sets a monospace face for refs, SHAs, and paths; secondary columns hide below 768px; loading skeletons rows, emptyMessage fills the body. The filter and the page live in the URL — ?q=&page=, read once on mount and written back through replaceState; stateKey namespaces both for a route with a second table. Typing resets the page. pageSize={0} is an unpaged table — every row on screen, no pager, no range label, and no ?page= written, for a list whose depth the server owns There is no cell snippet — a snippet cannot be built inside <script>, so cell intents are declarative
StatTile One metric in a stat grid label + big value (text-xl), optional hint (one line per entry, for a truncating value line or a value that needs explaining), optional href for a related route, optional id as a test/deep-link hook, optional class for a grid span, and a children snippet that replaces the value line (a StatusBadge, for instance)
Section A titled block on a detail page title + optional quiet note in the header, optional actions snippet, optional class for a grid span, optional collapsible (a <details> with an arrow) and open, which defaults to true, so a block that should start folded says open={false}; id is a deep-link and test hook. actions render inside the <summary> too, with click propagation stopped so a control does not toggle the block. ontoggle(open) fires on the fold transition and is how a folded block defers its first read — the System route's Agent CLIs probe is the reason it exists
MetaList Label/value pairs: trail identity, worktree, token usage Stacked dt over dd in a sm:grid-cols-2 grid; rows are MetaRows (label, value, mono, hint, href, copy); label is the accessible name since a dl has no heading. copy adds an icon-only Copy button that swaps to a green Check for 1.5s, with aria-label and title naming the action — and a row that both truncates and copies needs hint too, or the id becomes unreadable at narrow widths
DetailRow A compact list row: task, run, comb file title + optional monospace meta + optional quiet detail, a side snippet for the row's badges, and an optional href; renders an <li>, so it belongs in a <ul>
EnergyMeter The honey reserve of one trail energy (the HoneyReserve fields), pending, error, and ontopup(amount); renders the bar, a low badge, the reserve's provenance, and the fixed +1/+5/+12 steps
ArtifactViewModal Reading one comb file Takes open, traceId, artifact, onclose; fetches the body on open and renders markdown HTML, a <pre>, the server's omitted reason, or an error alert
Modal Short, focused forms and destructive confirmations Focus trap, ESC closes, restores focus to trigger; size is md for a form or lg for a document, the body scrolls, and the footer stays pinned outside it
Drawer Wide or multi-step forms (task create, session launch, bee run) Built: Modal with placement="right", so the focus rules are the same ones. Not daisyUI's drawer: the shell's navigation uses that primitive, so the word means two things here — this row is the wide form, and the shell component stays SideMenu
DiffViewer A worktree diff as lines, a file list, a path filter, and a unified/split toggle Both layouts come from one parser; the split halves are paired, not pre-rendered
CommentThreads Line-anchored review notes and their submission Drafts are local until sent; a moved head holds the send rather than eating the notes
ReviewActions Approve / Open PR / Update PR, Reject, and Discard Shared by the task page and the review page, so the review forms have one copy
SessionTerminal A live PTY as a terminal, with detach and a size that is pushed on open Owns xterm and the socket; its lifecycle is a prop, never a bind:this into the child
SessionTranscript A finished session's log, bounded and follow-the-tail Tail-only, with the dropped count said out loud; never wraps, because a terminal does not
Toast Transient notifications For action results, not persistent state; mounted once in the root layout. An optional action (label + onselect) renders a button, which is the one thing a toast can offer besides telling you — and a toast that has one lives longer (toastActionTimeoutMs, 12s, against 4s for a plain one), because a report with something to press is a question rather than a receipt, and four seconds is not long enough to read an id and decide. Selecting it dismisses first, so the trail it navigated to does not arrive with the notice still on screen. A caller may still pass its own timeoutMs, and 0 pins it
SignalCard SIGNAL / INSIGHT / MUTATION / VERIFICATION presentation Takes any SignalSummary — an insight highlight or an event feed row. The severity becomes a StatusBadge and the event type, payload kind, bee, and time share one source line, deduped when the type and the kind are the same word, so nothing is stated twice; showTrace={false} drops the trace id inside a trail, where the header already names it; traceHref turns that id into the trail link while the headline stays prose; an expand snippet renders under the meta line, which is how the Timeline reveals one event's raw envelope
CodeBlock A payload shown verbatim: a raw event envelope code, already serialized by the caller — a raw event is data, not a file — plus an optional label, which is the accessible name because the text inside a <pre> of punctuation no longer names itself, and an optional class for a folded view's max-h-80. Six roles go onto six different theme slots via lib/tokenize.ts, and info is not one of them: it carries the same value as primary in all four Catppuccin themes, so a role mapped there would look distinct in the table and render identically on four of the six themes the console ships. The lexer never validates and never throws — the concatenation of its tokens is the source byte for byte, so a payload that is not JSON renders as itself, which is the property that lets a <pre> this decorative sit on author-supplied evidence. The {#each} over the tokens is deliberately one line: Svelte preserves whitespace inside a <pre>, so an indented template would inject its own newlines into the payload
TraceRow Trail rows on the Dashboard Title (falling back to the trace id), runCount · taskCount · last activity line, optional standing badge, active/failures/run-count state badge; the id repeats only when a title is shown, and href points at /next/traces/:id
CueRunModal Publishing a cue (SIGNAL ingress) from any route Fetches /api/cues on open, requires a cue plus non-empty text (aria-invalid + described hint), reports the published trace through onran so the caller toasts and refreshes
BeeRunDrawer One headless run of one bee, from /next/bees or a trail bees when the route already holds the roster, otherwise it reads the colony scope itself on open — never the launch picker, whose rows are the interactive bees and which would drop the script rows this control exists for. A bee <select> (preselected when there is one) whose choice rebuilds the intent list; intent + task, an Advanced raw-prompt override that replaces the task, and traceId prefilled when the route was opened from inside a trail. beeNeedsTask decides whether an empty task is a run — a script bee runs its own command. Submits POST /api/bees/:role/run and reports the trail through onran; the reset-on-open effect reads its props untracked, so a page re-reading its roster cannot wipe a half-written form
ThemeSelect Theme picker The Settings route, and the only control there
PagePlaceholder Routes awaiting feature migration Shared empty state; never a hand-rolled per-route card
Hint Full text of a line that CSS truncates Portaled to body (a scrolling ancestor would clip it), placed below the anchor and flipped above when the viewport has no room, aria-hidden because the visible line already holds the same text; hover and focus open it

Status → semantic colors

Map domain status to a DaisyUI semantic badge. Do not pick colors per context.

Domain status Semantic class
running, interacting, connected, live, active, queued badge-info
success, approved, merged, ready, completed, announced, open, clean badge-success
waiting_review, pending, reconnecting, low, dirty, leftover badge-warning
failed, rejected, killed, blocked, cancelled, disconnected, error badge-error
idle, stopped, stopping, unavailable, archived, planned, standing, staged, unknown badge-neutral

Task statuses are planned, ready, running, waiting_review, completed, failed, blocked, cancelled; run states are queued, running, completed, failed, cancelled. A comb file is announced or staged, a trail is standing or not, and a reserve is low or not — all of them have a row here so a trail surface never falls through to grey by accident. The Git route adds the checkout's dirty/clean and the branch's leftover; a branch's current travels as active with current as the label, so it needs no row of its own. The Settings route adds the Telegram push modes, read as "will this interrupt me": sound is the only mode that alerts and takes the attention tone, silent delivers without noise and takes the in-flight one, and off is the absence of a push and takes the idle one.

StatusIcon reuses the same tone via statusToneTextClasses (text-info, text-success, text-warning, text-error, text-neutral) beside statusToneClasses. Glyph by status (data-glyph exposes it for tests): play for running/live/active/queued, stop for stopped, pending for starting/stopping, link for connected, broken for disconnected, alert for failed/error/killed, unknown for anything else including idle. Icons are lucide components: play = Play, stop = Square, pending = LoaderCircle, link = Link, broken = Unlink, alert = CircleAlert, unknown = CircleDashed. Pass label when the bare status is ambiguous — the topbar renders NATS connected, not connected. Pass glyph to force an icon regardless of status; the component then drops the status tone and the accessible name, because the surrounding control owns both.

A status missing from the table falls through to neutral — that is a deliberate "nothing is wrong" default, not an oversight, so a new domain status gets a row here rather than a local class in the component that shows it. Keep the tone table and the glyph list in step: a status that reads as in flight belongs to the badge-info / play pair.

Hard rules (agent contract)

  1. Create/edit forms open in <Modal> or <Drawer> triggered by a button — never a full-column form replacing the list view.
  2. Every status is a StatusBadge with a semantic class from the mapping above.
  3. No inline styles, no raw color literals, no custom fonts. Tailwind + DaisyUI only. Two exceptions, both narrow: the Hint popover, which sets top/left from getBoundingClientRect() — that is geometry, not styling; it must never carry a color or a font — and the cytoscape topology graph, which is not a Svelte component and resolves its colors from the active theme's DaisyUI custom properties at runtime. The exception is for reading a token, never for writing a literal; a hard-coded hex in the graph is a bug the same as one in a page.
  4. Tabular lists are DataTables (filter, paginate) — not hand-rolled <table> markup per page. A short list of related rows (a trail's tasks, runs, comb files) is a <ul> of DetailRows, not a table.
  5. Icon-only buttons must carry an aria-label, and so must an icon-only link — the rail's route entries are the case that forced the rule outward, since their label span is hidden rather than truncated; add a title where a sighted operator would otherwise be guessing. Rely on DaisyUI/Tailwind focus-visible outlines.
  6. Render loading (skeleton), empty, and error states — not just the happy path.
  7. Stay responsive to 768px: the side menu becomes an off-canvas sheet with a md:hidden trigger, and the page never scrolls sideways on mobile. A wide DataTable scrolls inside its own bordered region instead — a table that clips its last column puts a link somewhere with no way to reveal it, which is worse than a scroll the operator can see. A table of more than four columns must mark at least one secondary, and the scroll is the floor rather than the answer: two columns are never on screen together, so a sideways pan loses more than a hidden column does, while a hidden column loses only what was hidden — and nothing, because text feeds the filter whatever the cell is doing. Mark the columns an operator scans last — the reference behind the identity, the path, the timestamp — and keep the identity, the state, and the link. A card layout below 768px stays out of scope: the columns already carry the label and text a card would need, but a card puts the table and the cards both in the DOM, and bees.test.ts resolves a cell with screen.getByText(text).closest('tr'), which throws on two copies. secondary predates this rule, so when it was written four of the ten tables already did the right thing and Settings was the only one with none — which is the argument for writing it down at all: nothing in the contract required it, so a table added without it was a table nobody had checked.
  8. Keyboard shortcuts across routes (e.g. g d dashboard, g t traces).
  9. On NATS disconnect show a reconnecting banner (badge-warning) and queue mutations locally; do not lose operator input. Never fire two runtime mutations at once.
  10. Consume typed payloads generated from the Go event contracts; no any-typed event handling.
  11. A collapsed Section must state what it holds in its summary line (a count, a caveat) so the operator can tell whether opening it is worth the click. A control rendered inside a <summary> stops click propagation, or it toggles the block as a side effect.

Agent workflow

  1. Read this doc + Spec 035 before writing UI code. Check UI migration backlog for a decision already deferred — a component listed in the inventory below may not exist, and a row may be deliberately inert.
  2. Compose from the shell and lib/components/; only add a component if the inventory genuinely lacks it (and extend the table).
  3. Verify with Vitest component tests and Playwright visual regression on Dashboard, Traces, Settings.
  4. Theme store and route persistence must be covered by unit tests (hydration, reload persistence).

Scope

This contract covers the new Svelte-based console. The current vanilla-js console under internal/console/static/ is being replaced per Spec 035 and is out of scope for new features. Operator-facing behavior of the console lives in docs/guide/queen-console.md.