dashboard: tab-bar restructure + extract FL0W to /flow.html (#369)

Operator: 'option A (tabs)' (#369#issuecomment-3434) +
'yes terminal can be a separate page' (#369#issuecomment-3437).

## Tab framework

`index.html` becomes a 3-tab dashboard with a sticky chrome header:

- `◆ SW4RM ◆`    — containers list (the central thing)
- `◆ Y3R C4LL ◆` — pending approvals + operator-targeted questions
- `◆ SYST3M ◆`   — meta inputs + rebuild queue + reminders + tombstones

Hash routing: `#swarm` / `#call` / `#system` (empty → SW4RM).
F5-reloadable + back-button-aware without a router framework.

SSE stays alive across tab switches — count pills on inactive tabs
update live so the operator never loses pulse on what's happening
elsewhere:

  - SW4RM:  containers with needs_update
  - Y3R C4LL: approvals.pending + questions.pending (attn-coloured pill)
  - SYST3M: rebuild_queue entries in Queued|Running

Pills hidden when count is zero. setInterval(1s) polls the existing
state stores (cheap, no per-renderer hookup needed).

## FL0W as its own page

The all-agents chat moves to /flow.html — full-viewport vibec0re
layout mirroring the per-agent live page (#362):

- Fixed-overlay frosted-glass header at top (back link + title +
  notif controls), backdrop-filter blur shows the scrolled chat
  text behind.
- Full-viewport terminal, scroll-padded for the floating chrome so
  first/last rows stay reachable.
- Fixed-overlay frosted composer at the bottom.
- Operator inbox surfaces via a pill (📬 inbox · N) in the upper
  right — click opens the side-panel flyout with the message list.

In the dashboard tab strip, FL0W is the right-most entry but
renders as a `<a class="tab tab-link" href="/flow.html">` — clicking
navigates to the page rather than swapping a pane. Same pattern
back from flow.html via the `← d4shb04rd` link.

## Implementation notes

- New `/flow.html` page rendered by the same bundled `app.js` — the
  flow page just doesn't have the dashboard-chrome DOM, so the
  matching renderers no-op silently (each `if (!el) return`).
  Avoids splitting the bundle for v1; can extract later if size
  becomes a concern.
- `Panel` module gains `openNamed(name, …)` + `refresh(name, …)` —
  the legacy untyped `open(title, content)` calls clear the owner,
  so file-preview / diff / log drill-ins behave unchanged. `refresh`
  is no-op when a different view owns the panel, so live message
  events re-render the inbox flyout only when it's actually open.
- `renderInbox` updates BOTH the dashboard's inline `#inbox-section`
  (now living on the flow page) AND the flow page's pill count +
  side-panel refresh. The dashboard's empty FL0W tab is removed —
  inbox + message flow + compose box only exist in flow.html.
- Banner shrinks to a thin Catppuccin gradient strip at the top of
  the dashboard chrome (dropped the multi-line ASCII art —
  affectionate but pure chrome budget in a tabbed layout).
- `build.mjs` copies both `index.html` + `flow.html` into dist.

## Validation

`npm run build` clean. Dashboard bundle deltas:
  app.js  150kb → 152kb  (tab routing + count pills + named-Panel)
  dashboard.css 33kb → 38kb (tab chrome + flow page layout)
  + dist/flow.html  4.4kb

Browser smoke test isn't possible from inside iris's container
(no JS engine) — drafting as a PR for operator visual review on
next deploy. Worth eyeballing:
  - Tab switching feels right; counts update live across SSE events
  - FL0W page reads like the agent live page (frosted header + composer)
  - Inbox pill opens flyout; live message arrivals refresh it
  - Back link from flow → dashboard returns to last tab via the
    URL hash (browser remembers the hash across page nav)

Closes #369.
This commit is contained in:
iris 2026-05-24 12:15:16 +02:00 committed by Mara
parent 8c7bc850f3
commit 9666cb8c3f
5 changed files with 627 additions and 86 deletions

View file

@ -3,11 +3,116 @@
@import "@hive/shared/base.css";
@import "@hive/shared/terminal.css";
body {
max-width: 70em;
margin: 1.5em auto;
padding: 0 1.5em;
/* tabbed dashboard chrome (#369)
Top-of-page sticky header with banner + tab strip. Tab routing is
hash-based; tab panes are show/hide via the `.tab-pane-active`
class. SSE stays alive across tab switches so count pills update
live on inactive tabs without losing pulse on what's happening
elsewhere. */
body.dashboard-shell {
/* Width is generous so the container tree + agent cards aren't
boxed too narrow agent state pills want room. */
max-width: 90em;
margin: 0 auto;
padding: 0 1.5em 1.5em;
}
.dashboard-chrome {
position: sticky;
top: 0;
z-index: 25;
background: rgba(30, 30, 46, 0.86);
-webkit-backdrop-filter: blur(8px) saturate(120%);
backdrop-filter: blur(8px) saturate(120%);
border-bottom: 1px solid var(--purple-dim);
padding: 0.4em 0 0;
margin: 0 -1.5em 1em;
}
.banner-thin {
text-align: center;
margin: 0;
padding: 0 1em;
font-size: 0.78em;
color: var(--purple-dim);
letter-spacing: 0.15em;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.tabbar {
display: flex;
align-items: center;
gap: 0.2em;
padding: 0.5em 1em 0;
border-bottom: 1px solid var(--purple-dim);
}
.tabbar .tab {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.55em 1em 0.45em;
margin-bottom: -1px; /* overlap the tabbar bottom border */
color: var(--muted);
font-family: inherit;
font-size: 0.92em;
letter-spacing: 0.08em;
text-decoration: none;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 4px 4px 0 0;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.tabbar .tab:hover {
color: var(--purple);
background: rgba(203, 166, 247, 0.06);
}
.tabbar .tab.active {
color: var(--purple);
border-color: var(--purple-dim);
background: var(--bg);
/* Lift the active tab visually the bottom border of the tabbar
yields under it via the -1px margin above. */
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4);
}
.tab-label { font-weight: bold; }
.tab-count {
display: inline-block;
background: var(--purple-dim);
color: var(--purple);
border-radius: 999px;
padding: 0 0.55em;
min-width: 1.6em;
text-align: center;
font-size: 0.82em;
font-variant-numeric: tabular-nums;
font-weight: bold;
}
.tab-count-attn {
/* Y3R C4LL — operator-action-required pill colour, harder to miss. */
background: rgba(243, 139, 168, 0.22);
color: var(--red);
}
/* Notification controls cohabit with the tabs (always-on chrome). */
.tabbar #notif-row {
margin-left: auto;
display: flex;
gap: 0.5em;
align-items: center;
padding-right: 0.5em;
}
/* Tab pane visibility show only the active one. The .tab-pane-active
class is set by app.js based on the URL hash; default (no hash)
resolves to SW4RM. */
.tab-pane { display: none; }
.tab-pane.tab-pane-active { display: block; }
/* FL0W is a separate page (`/flow.html`) its full-viewport vibec0re
styling lives further down under `body.flow-shell`. */
.banner {
text-align: center;
margin: 0 0 1em 0;
@ -1174,3 +1279,181 @@ footer a { color: var(--purple); }
border: 1px solid var(--border);
padding: 0.2em 0.5em;
}
/* /flow.html full-page chat (#369)
The all-agents chat surface lives on its own page so it can claim
full-viewport vibec0re styling (operator @ #369#issuecomment-3437).
Same shape as the per-agent live page (#362): frosted-glass header
at top, frosted composer docked at bottom, terminal scrolls
behind both. Operator inbox lives behind a header pill that opens
the side-panel flyout preserves the "inbox + chat in one view"
ergonomics without stealing terminal real estate. */
:root {
--flow-header-h: 4.2em;
--flow-composer-h: 3.6em;
--flow-frost-bg: rgba(30, 30, 46, 0.74);
--flow-frost-blur: blur(12px) saturate(140%);
}
body.flow-shell {
/* Override the dashboard's centred-max-width layout flow owns
the whole viewport. */
max-width: none;
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 50% 0%,
rgba(203, 166, 247, 0.06) 0%,
transparent 60%),
var(--bg);
}
.flow-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
min-height: var(--flow-header-h);
display: flex;
align-items: center;
gap: 1em;
padding: 0.55em 1em;
background: var(--flow-frost-bg);
-webkit-backdrop-filter: var(--flow-frost-blur);
backdrop-filter: var(--flow-frost-blur);
border-bottom: 1px solid var(--purple-dim);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
flex-wrap: wrap;
}
.flow-back {
color: var(--cyan);
text-decoration: none;
font-size: 0.85em;
letter-spacing: 0.1em;
padding: 0.25em 0.6em;
border: 1px solid var(--purple-dim);
border-radius: 4px;
flex: 0 0 auto;
transition: border-color 0.15s ease, color 0.15s ease;
}
.flow-back:hover {
border-color: var(--cyan);
text-shadow: 0 0 8px rgba(137, 220, 235, 0.6);
}
.flow-title {
margin: 0;
font-size: 1.2em;
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
}
.flow-hint {
margin: 0;
color: var(--muted);
font-size: 0.82em;
flex: 1 1 auto;
min-width: 0;
}
.flow-header .notif-row {
margin-left: auto;
display: flex;
gap: 0.4em;
}
/* Inbox pill operator inbox flyout trigger. Sits right under the
header so it stays in the operator's gaze without crowding the
chat. Same shape as the agent page's pills (#362). */
.flow-pill {
position: fixed;
top: calc(var(--flow-header-h) + 0.8em);
right: 1em;
z-index: 25;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
color: var(--fg);
font-family: inherit;
font-size: 0.85em;
letter-spacing: 0.04em;
border-radius: 999px;
padding: 0.3em 0.8em;
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
}
.flow-pill:hover {
border-color: var(--purple);
color: var(--purple);
box-shadow: 0 0 12px -2px var(--purple);
}
.flow-pill-icon { font-size: 1.05em; line-height: 1; }
.flow-pill-label { color: var(--muted); }
.flow-pill-count {
background: rgba(250, 179, 135, 0.18);
color: var(--amber);
border-radius: 999px;
padding: 0 0.5em;
min-width: 1.6em;
text-align: center;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.flow-main {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.flow-main .terminal-wrap {
position: absolute;
inset: 0;
border: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
margin: 0;
padding: 0;
}
.flow-main .live {
position: absolute;
inset: 0;
height: auto;
max-height: none;
padding-top: calc(var(--flow-header-h) + 0.8em);
padding-bottom: calc(var(--flow-composer-h) + 0.8em);
scroll-padding-top: calc(var(--flow-header-h) + 0.8em);
scroll-padding-bottom: calc(var(--flow-composer-h) + 0.8em);
overflow: auto;
}
.flow-main .tail-pill {
bottom: calc(var(--flow-composer-h) + 0.6em);
}
.flow-composer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 30;
min-height: var(--flow-composer-h);
background: var(--flow-frost-bg);
-webkit-backdrop-filter: var(--flow-frost-blur);
backdrop-filter: var(--flow-frost-blur);
border-top: 1px solid var(--purple-dim);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
padding: 0.4em 1em;
}
/* Hidden inbox section on the flow page the renderInbox path
wants `#inbox-section` in the DOM (legacy contract), but we
surface the messages via the pill/flyout instead. */
.flow-inbox-headless { display: none !important; }