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¶
- Use ready-made DaisyUI primitives and Tailwind utilities. Never invent a new visual style when a DaisyUI class already covers it.
- Colors, spacing, and typography come from DaisyUI themes and Tailwind — never from raw CSS literals in components.
- 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-themeon<html>, driven bythemeStore(Svelte store), persisted tolocalStorage. - 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-svelteand render oncurrentColor, so they inherit the semantic classes above; a hand-rolled SVG is not an accepted substitute.StatusIconis 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.svelterenders the navigation drawer:div.drawer.md:drawer-openholdinginput.drawer-toggle, adiv.drawer-content(the trigger,<Header />,<main>), and adiv.drawer-sideholding<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-openand notlg:drawer-open: the contract's breakpoint is 768px, and a drawer that opened atlgwould 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.
SideMenurenders the width (w-56/w-16) and the labels fromsideMenuStore.expandedrather than from daisyUI'sis-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 storedexpandedis 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 isshrink-0all the way down and scrolls horizontally when it cannot fit. Atw-64the 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-fulland its list scrolls, so the drawer side never does.min-h-fulllet 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 alreadygrow 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 dropsaria-pressedrather than claiming a state it cannot change. The two are worded apart (Close menuagainst the trigger'sClose navigation) because one accessible name on two controls is a name nobody can act on. The top-left trigger ismd:hiddenand 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 triggerz-30(fixed over the topbar, and it must vanish under the panel it opened), and the drawer sidez-40— over daisyUI'sz-10, because its example navbar carries no z-index and ours does. - The sheet shows the labels; the rail is a desktop state only.
labelledisexpanded || narrow, so below 768px the panel isw-56with every name and itsg <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, plustitlein the rail where the label is gone, following theMetaListcopy button's precedent. The name is whatdisplay: noneon a label span would take away, and what aw-16rail leaves the operator to guess; theg <key>chord is the second thing the rail hides, so it is thetitle'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 thew-16rail, which is all that width holds — a truncated version is worse than a commit the operator recognises.hrefisbuild.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 GitHubso the visible text survives into the name in both the full list and the rail, and itstitle(versionTooltip) carries the unabbreviated sha, the commit date, and whether this is a release — a bare0.6.0+67730c4reads 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.versionStoreis app-scoped and read once (+layout.sveltecallsstart()), 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.
consoleRoutescarriesglyph,RouteGlyphis derived from that table, andlib/route-icons.tsis the single name → lucide map — the splitStatusIconandruntimeActionGlyphalready use. A route asking for a glyph with no icon is a type error, not a blank cell. There is nobeein lucide 1.0, so the roster takesHexagon(a comb cell) and a headless run takesZapagainst a session'sTerminal. - 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:
Escapemust still walk a trail detail back to its list, and aTabtrap would make the page unreachable. So the menu's key handler is gated onnarrow && expanded, and the shell'sEscapeyields to the sheet throughnavigationOpen(narrow)— asked of the DOM, likedialogOpen(), with thenarrowargument the whole subtlety:checkedalone is true on a desktop showing its labels, and anEscapeclaimed on that reading would strand a detail route. - A route link closes the sheet, and only the sheet.
onNavigatewrites 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 eachg <key>chord is unique. Last route, theme, and the menu's own state persist inlocalStorage. - Two keys are not chords and live in the shell rather than in
navigation.ts.g <key>is a destination, and these are not.Escapereturns to the list the current path belongs to, found by runningisRouteActivebackwards (owningListPath) — the same test the side menu uses to light an entry, so all five detail families and/reviews/:traceId/:taskId/previeware 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 forEscapeto go, so it does nothing rather than reloading itself. Escapeyields three times, and every yield is the point. A dialog keeps it, becauseModalcloses itself onEscapeand 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 itsEscapenever 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"]— whichModalrenders inside{#if open}and whichDraweris — andlistFilter()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()andfirstNavigationLink()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,
StatusIconfor 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 arejustify-between: label left, status right. The/api/chrome/streamevent 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()returnsstart(stopped,stale),stop(running),busy(in-flight orstopping, disabled), orchoose(anything unclear — opens aModalwith both actions). With aglyphoverride the icon drops the status tone and inherits the button colour, so only one colour drives it;aria-labelspells the action out.stoppingstays blocked becauseStartmid-shutdown would spawn a second runtime, and the result is reported with aToast. Stop always opens aModalconfirmation. - Every truncating line is wrapped in
Hint: the row scrolls horizontally, so an in-flow tooltip would be clipped; the popover therefore lives inbody. Panel header rows aremin-h-8so a 32px icon button and a 24px badge share one label baseline, and the four panels sit in aml-autowrapper 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.
gitSyncLabelanswers the one question the badge can act on —in sync,↑3,↓2,no origin,fetchwhere there is nothing to compare — andgitNeedsAttentionasks the same question, so the tone and the word cannot disagree; a green badge can never readdirty.dirtyis in neither, because a dirty root is close to a designed state rather than an accident: aworktree: falsebee publishingcode.proposal.rootstages 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:gitDetailleads withdirtyand 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 aHint. 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'sw-56and by giving the runtime panel the samew-48as its three neighbours: it had beenw-56because 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 itsHint, not a reason for one panel to be a different shape.running,stopped,startingandstoppingread whole atw-48; the two explanatory tails that now clip (degraded,stale) are on theHintthat 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.tsis the only place that fetches. Paths are root-relative/api/*— the/nextprefix belongs to frontend routes and assets, never to the API.ApiErrorcarries the status and the server's plain-text reason (Go'shttp.Errorbody), because domain errors likehoney reserve not configuredare the whole point of a 400 or a 503; it falls back torequest failed: <status>when the body is empty or unreadable.consoleStatusStoreowns one dashboard poll (default 15s) and keeps the wholeDashboardSummaryinstore.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.
tracesStorepolls/api/traces(15s);traceStorepolls/api/traces/:id(10s) for the detail route;gitStorepolls/api/git(15s), which is the slowest read in the console because every one of its polls shells out togitseveral times;systemStorepolls/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 howtimelineStorestays 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'sstartreads 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.pushStatewould 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 iscurrentPagerather 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, becauseparseInt('1.5')is1and a number somebody got wrong is not a page to round in their favour.stateKeynamespaces both params (?runs.q=&runs.page=) for a route with a second table; absent everywhere today, and deliberately not derived fromlabel, which is prose written for a human and would put?Review queue.q=on the wire. ApageSize={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
replaceStatebefore 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 calledreplaceState. Theloadingprop 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.
systemStoreholds 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 analert-error, while the server's own partial-failure string arrives on a 200 and is analert-warningabout missing rows. - A page may read another store it does not own.
/next/systemmarks live adapter processes fromconsoleStatusStore.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 abusyoutcome 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 (
natsStatusmaps a raw report to a status word,activeTraceCountcounts in-flight trails). Pass-through fields (activeSessions,activeWorktrees,taskCounts) are read straight offstore.dashboardinstead — 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-0as well astruncate. A flex item defaults tomin-width: auto, so a long path refuses to shrink and overruns its own label; andHint's wrapper ismin-w-0 flex-1rather thanw-full, because it is sometimes a flex item beside a copy button and claiming the whole row makes the pair overflow leftwards. lib/clipboard.tsownscopyText().navigator.clipboardonly 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.tsand 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 useformatTimestamp(local, 24-hour,YYYY-MM-DD HH:MM:SS);formatClockstays 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
StatusIconbeside 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 narrowStatTiles (active traces, sessions, worktrees) plus a task-counts tile onlg: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 legacystat-widedid 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
StatusBadgetaking the status tone, soready 1pops green andblocked 1pops red while the words stay quiet. Sorted by status (taskCountEntries) so the tile reads the same on every poll. Empty readsNone, not a sentence — a full clause in a tile looks like an error. - Recent traces are the front-and-centre
TraceRowlist, then Failed runs as aDataTable, then Recent insights asSignalCards — 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, notbtn-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
Nonein the task tile.store.lastErrorrenders as analert-errorabove 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/:idis a child of the Traces menu entry, soisRouteActivekeeps 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 intracesStore; 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; and1–15 of 50counted the window while reading as the size of the history. Both are gone, andpageSize={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.
cursorPageScanLimitis gone, and it was never a cost: there is no index over.paseka/runs, soscanAllTraceswalks everything on every read whatever the page asks for, and the old cap oflimit * 10was 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 runswould repeat the Runs column. The same rule governs the detail header badges:standingalways, 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
growcolumn takes the leftover width and truncates, so a long title can never push the table sideways at 390px. textis the cell,searchTextis the rest. The id behind a titled row, a standing flag, and the trail summary are searchable but not shown; withoutsearchTextthe filter would be blind to them.- Paging is server-side, filtering is not.
tracesStorekeeps the pages it has pulled in and exposesloadMore(); 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.
tracesStorerequestspageSize + 1and drops the extra, sohasMoreis 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-3row, Trail onlg: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 arecollapsibleand 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 needshinttoo, 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
traceStorereads it asawaitingTrail, 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 arole="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
loadclearsartifactsErroronceawaitingTrailis 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.lastErrorandstore.artifactsErrorare 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
CueRunModalis 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 arequestedref 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'somittedreason 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.MaxInlinePreviewBytesandMaxInlineExportBytesare 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.Bytescosts nothing: it is the statItemFromFilealready performs, andMergeAnnouncedcarries 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 beebutton beside Open timeline. It opens the sharedBeeRunDrawerwith the trail prefilled, which is the whole point of having it inlib/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
beeRanreads 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
Toastrather 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
hrefreturnsnullfor 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/:taskIdand/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, andPullarebtn btn-smwith agap-2; the "Run git hooks on push" flag is atoggle toggle-xsbeside them. Push takesbtn-primaryonly 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 —gitStorepolls and every mutation re-reads the clone. - A merged branch the sweep skips has its own Delete, in its own row.
leftoveris the sweep's name filter — afeature/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 wheregitBranchDeletablesays yes, which mirrors the four guardsgitroot.DeleteBranchholds (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
confirmingis 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)andrun('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 aModalfirst, 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 aToast. - 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 itssearchText, 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
MetaListpairs 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
Noteis 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. TheNoteis the only thing that says why. - One word per row, in the order the operator acts. A branch's
Statebadge 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 readingplain. - 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 insearchTextinstead of taking width that crowds out the columns an operator scans. - Prose is the
growcolumn. A commit subject takes the leftover width; the branch name does not. Givengrowto 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, sook: falsewith a per-name reason reads aswarning, 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.
worktreesHeadlinehas 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 theDataTablefollows. - The columns are the Git table's, unchanged. Branch (
mono,grow, the path insearchText), Trail, State, Pull request (secondary), Base SHA (secondary). Moving a table between routes is not a redesign of it; the only label that changed isTrace→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 aorphanedbadge 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.
DataTablegained a declarativeactionfor 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 whereactionstops being one control and starts being a list. - One store, two routes, one poller. The worktree list is part of
GET /api/gitand 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 pruneprints 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/beesdefaults 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=colonyis the roster: every bee,scriptadapters included, each carryinginteractiveso 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, andbeeIsInteractivereads 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
lastRunfromruns.ScanRuns— every run directory, newest first, first per role wins — and the scan is uncapped whereScanRecentRunsis 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 nolastRun, 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/systemprecedent, 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
DataTablerow, soBeeCardis out of the inventory. A bee is one line of facts, which is the shapeDataTablewas 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.textis(row) => stringand always feeds the filter box, sobeeIntentsLabelcomma-joins the vocabulary and every intent becomes individually searchable with nothing duplicated intosearchTextby hand. A list of badges would want the row to grow, whichwhitespace-nowrapforbids. 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
beeIntentsLabelreturns—for a bee with no templates, exactly astraceBeesdoes 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 beesits besideRefreshthe wayLaunch sessionsits beside it on Sessions, and this page has noDataColumn.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.Zapis 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/runreturns 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 offersOpen trailrather than navigating, the same rule the cue publish follows, and the roster is deliberately not refreshed: a refresh in the same second would report aLast runthat 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.
BeeRunDrawertakes the page's ownbees, 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 handedGET /api/beeswithout a scope: that picker holds the interactive bees only, and this form is the one control that can start ascriptbee, so a drawer built on the picker would drop the row it exists for. Its submit button needs no task for ascriptbee (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.EffectiveURLprefersPASEKA_NATS_URLover the home config,BotTokenprefersPASEKA_TELEGRAM_BOT_TOKENover the file, and the selected profile prefers a--profileflag, then the environment, then the home config's stickyprofile:. A page showing values alone would report a URL the process does not use, and an operator editingconfig.yamlwould watch nothing change with no explanation.ConfigValueis therefore{ value, source }, wheresourceisconfig.yaml,colony.yaml,default,unset,env:NAME, orflag. - A default is annotated in the cell; the home config is not.
configValueTextappends the source in brackets for everything butconfig.yamlandcolony.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 aHinthover, and aHintis 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 reportsbotTokenSetrather than the token. A Go test greps the raw response body for both aCURSOR_API_KEYvalue and abot_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.yamlholding onlybinarystill yieldsapi_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.AdapterConfigDeclaredreports which top-level keys the file actually sets, and each value is markedconfig.yamlordefaultfrom 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.Loadtreats a missingtelegram.yamlandenabled: falseas errors, so reading it that way would render "the gate is switched off" as a failed read — the answer an operator most needs.telegram.Inspectparses and applies the same defaults without validating and reports presence separately from enabled. The legacywaiting_reviewkey 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/configreports 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 theThemeSelectthe 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 areDetailRows with aStatusBadge, because a category is one line of facts with a value beside it, and the adapter credentials are the eighthDataTable. - A
Sectionnoteis 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 carries7 categoriesorabsent.
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
statfsthat 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/statsamples, 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 aHint. - Process memory is formatted to the process, not the machine. The
formatBytesGiB formatter the tiles use would print0.00 GiBdown 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
badgethat returnslivefor a pid the Live bees panel reports andnullotherwise, 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.
Sectionopens by default, so a folded one saysopen={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, mirroringadapterCLINamesininternal/console/adapters.go), a fact about which adapters exist rather than a guess about this box, and only thefoundflag 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.
Sectiongrows anontoggle(open)callback, and the block callsadapters.load()on the transition to open. The store keeps the payload, so folding and re-opening is not a second read; a failed read leavesprobedfalse, 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
actionsslot 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: falserow gets thenot foundbadge with an empty path and an empty version, and noerror: 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>.yamlis 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. scriptis absent on purpose, and the body says why. Its binary is whatever each bee'scommand: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 readnot 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 theHintpopover underneath it — the legacy console's invisible click target on a<div>is not worth restoring. Live bees moves itshrefwith 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/beesis 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. Ahrefthat changes under the cursor is the price of that contract, andliveBeesPathinnavigation.tsis 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 | 60sselector 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,
cursorandhasMorerecompute, 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 anApplyrather 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, theRefreshCwspins, andaria-busycarries 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
disabledstill 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
disabledbinding needs a reactive value behind it.inFlightwas a plainletread through abusygetter, 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.Eventalready 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.
SignalCardclamps 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.SignalCardgained two optional props for this: anexpandsnippet under the meta line, and atraceHreffor 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.tsreads 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.tsbuilds 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
fitthat framed it shrank every label to nothing. - A dragged shape is remembered per colony. Positions persist to
localStorageunder 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-labelcarries the size and points at the Mermaid block, which is the same graph in the formpaseka colony topologyprints. - 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 formpaseka colony topologyprints — 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
DataTableand 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_mergemerges the branch, apull_requestopens 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.tsparses 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'sinnerHTML, and keeps the console's one third-party styling exception (cytoscape) the only one. - A patch splits on the
diff --githeader, never on+++ b/. A context line can begin with the same three characters, so+++is not a safe delimiter. The path is theb/side — the name a reviewer recognises after a rename — with thea/side kept beside it so a rename shows both. The added/removed split comes from the--statbar, 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.goandconfig_test.goboth truncated tointernal/gate/telegram/con…, which is the one thing a diff file list must not do. - File navigation is real buttons, not
j/kon a focusable<div>. The legacy walked its list withj/kfrom atabindex="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
headShachanges 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 acolspanbanner, 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, orUpdate PR, with the PR fields shown only for a final gate delivered as a pull request.pull_requestis the value — an earlier version compared againstpr, 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 patchesblock 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 nohref, notabindex, 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
SessionTerminalrather 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 throughbind:thisto close a child's socket is the same coupling as the legacy's globalSessionTerminalsingleton, 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 chatin another terminal isactivewith 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 aSIGTERM. 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
closecleanup 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
$derivedover 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$statethat moves only when the answer moves. - A nullable list is read in one place, never inline.
beeIntentsis the only reader ofBee.intents, because the server sendsnullfor a bee with no prompt templates and two drawers had the same.lengthon it inline — one of which was a live crash. The shape differs by endpoint on purpose:console.BeeView.Intentshas noomitemptyand isnull, whilecolony.Bee.Intentshasomitemptyand 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:_reviewis the default id for a rework task and five trails each have one, and keying on the task id alone throwseach_key_duplicateand renders nothing. - A column header is a topbar panel header. The status names the column in the same
text-xs font-semibold tracking-wide uppercasethe 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 aStatusBadgetoned 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'staskCountsfigure 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_reviewis 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, andcanApprovecome 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 asnull, and the task views carry their lists withoutomitempty, so a task no run has touched answers withruns: nulland a colony with no tasks answers withtaskCounts: null.TraceDetailalready declares its own lists nullable and every page reads them through?? [];TaskDetailandTaskBoardsay 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, becausenull.lengththrows 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-warningoutline, 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 readApprove…,Reject…, andDiscard…, 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, andcanDiscardare the server's one answer (hiveview.reviewActionsForTaskreturns 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 areBack to reviewandAll reviews. - A link that leaves the page is a link, and wears the glyph for that. The control reads Open review with a
ChevronRightafter 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.DrawerisModalwithplacement="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
searchTextbeside it. The trail column isgrow, 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. Acommandorscriptbee has no launch model, so the segment and theModelrow 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, nothidden. 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.
Statecarries 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.
isRouteActivetreats 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.Eventenvelopes, sorunEventSummaryderives the handful of fields a human reads — contract, payload kind, severity, summary — and the row is the sameSignalCardthe Dashboard and the Timeline feed already use. A payload with nosummarystill says something:payloadDigestlists the payload's readable fields, and a list of records contributes its first element, becauseartifact.writtenannounces{"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)¶
- Create/edit forms open in
<Modal>or<Drawer>triggered by a button — never a full-column form replacing the list view. - Every status is a
StatusBadgewith a semantic class from the mapping above. - No inline styles, no raw color literals, no custom fonts. Tailwind + DaisyUI only. Two exceptions, both narrow: the
Hintpopover, which setstop/leftfromgetBoundingClientRect()— 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. - 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>ofDetailRows, not a table. - 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 atitlewhere a sighted operator would otherwise be guessing. Rely on DaisyUI/Tailwind focus-visible outlines. - Render loading (skeleton), empty, and error states — not just the happy path.
- Stay responsive to 768px: the side menu becomes an off-canvas sheet with a
md:hiddentrigger, and the page never scrolls sideways on mobile. A wideDataTablescrolls 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 onesecondary, 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, becausetextfeeds 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 thelabelandtexta card would need, but a card puts the table and the cards both in the DOM, andbees.test.tsresolves a cell withscreen.getByText(text).closest('tr'), which throws on two copies.secondarypredates 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. - Keyboard shortcuts across routes (e.g.
g ddashboard,g ttraces). - 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. - Consume typed payloads generated from the Go event contracts; no
any-typed event handling. - A collapsed
Sectionmust 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¶
- 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.
- Compose from the shell and
lib/components/; only add a component if the inventory genuinely lacks it (and extend the table). - Verify with Vitest component tests and Playwright visual regression on Dashboard, Traces, Settings.
- 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.