hyperhive/frontend/packages/dashboard/src/dashboard.css
iris dc8c71c687 dashboard: fix rebuild spinner shape (#804)
Mara reported the rebuild spinner rendered as a rotating L-corner
('_|' / '⌐') instead of a smooth orbiting arc. Two issues:

1. The previous version coloured TWO adjacent border sides amber
   (border-top + border-right). With the rest of the border
   transparent, this paints a sharp L-shape at the icon corner —
   which when rotated reads as a spinning border-corner, not a
   loading spinner.

2. @keyframes had only `to` defined. Safer to include explicit
   `from { transform: rotate(0deg) }` so any browser that doesn't
   default cleanly still picks up the rotation.

Fix is the classic CSS spinner shape: faint amber ring around the
full icon perimeter (`border: 2px solid rgba(250, 179, 135, 0.2)`)
with one brighter top arc (`border-top-color: var(--amber)`) that
rotates. Reads unambiguously as a loading indicator.

Closes #804.
2026-05-31 15:36:38 +02:00

2114 lines
62 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Shared Catppuccin palette + body typography + terminal pane styles.
Bundled in front of the dashboard-only rules below via esbuild. */
@import "@hive/shared/base.css";
@import "@hive/shared/terminal.css";
/* ─── tabbed dashboard chrome ──────────────────────────────────────
Top-of-page sticky header with banner + tab strip. SSE stays
alive across tab switches so count pills update live on inactive
tabs. See docs/web-ui.md::Chrome header + Tab strip for the
routing model. */
body.dashboard-shell {
/* Full-width layout — no max-width cap so wide screens don't
waste real estate on empty side margins. `padding: 0 1.5em
1.5em` keeps a small gutter on the left/right so cards don't
kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em
... }` still pulls the chrome bar edge-to-edge through that
gutter. */
margin: 0;
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 tabs.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;
font-size: 0.95em;
overflow-x: auto;
background: linear-gradient(
90deg,
var(--purple-dim) 0%,
var(--purple) 50%,
var(--purple-dim) 100%
);
background-size: 200% 100%;
background-position: 50% 0;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
}
.banner.active {
animation: banner-shimmer 1.8s linear infinite;
}
@keyframes banner-shimmer {
from { background-position: 200% 0; }
to { background-position: -100% 0; }
}
h1, h2 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
margin-top: 2em;
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
}
.divider {
color: var(--purple-dim);
overflow: hidden;
white-space: nowrap;
margin-bottom: 0.5em;
}
ul { list-style: none; padding-left: 0; }
li { padding: 0.5em 0; }
.glyph { color: var(--purple); margin-right: 0.5em; }
a {
color: var(--cyan);
text-decoration: none;
font-weight: bold;
text-shadow: 0 0 4px rgba(137, 220, 235, 0.5);
}
a:hover {
color: var(--fg);
text-shadow: 0 0 12px rgba(137, 220, 235, 0.9);
}
.role {
display: inline-block;
margin-left: 0.4em;
padding: 0.05em 0.5em;
border: 1px solid;
border-radius: 2px;
font-size: 0.8em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(245, 194, 231, 0.08); }
.role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(250, 179, 135, 0.08); }
/* Container rows: a full-height square agent icon on the left, the
identity / actions / drill-in lines stacked in the card body on the
right. Pending rows dim everything except the pending indicator. */
.containers { display: flex; flex-direction: column; gap: 0.4em; }
.container-row {
padding: 0.6em 0.8em;
border: 1px solid var(--border);
border-radius: 4px;
background: rgba(24, 24, 37, 0.55);
transition: opacity 200ms ease, border-color 200ms ease;
}
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
+ lane geometry paragraph) for the 1.8em-per-depth-level
rationale + CSS-attr()-not-yet-portable caveat. */
.container-row[data-depth] { position: relative; }
.container-row[data-depth="1"] { margin-left: 1.8em; }
.container-row[data-depth="2"] { margin-left: 3.6em; }
.container-row[data-depth="3"] { margin-left: 5.4em; }
.container-row[data-depth="4"] { margin-left: 7.2em; }
.container-row[data-depth="5"] { margin-left: 9em; }
.container-row[data-depth="6"] { margin-left: 10.8em; }
/* Tree prefix lanes — DOM-painted, not text-glyph-painted. See
docs/web-ui.md::Topology tree for the full-row-height vertical
bar rationale + icon-midline joint alignment. */
.container-row .tree-prefix {
position: absolute;
/* Extend into the `.containers { gap: 0.4em }` below so vertical
bars connect to the next sibling's prefix without a visual gap. */
top: 0;
bottom: -0.4em;
display: flex;
flex-direction: row;
pointer-events: none;
user-select: none;
color: var(--purple-dim);
}
/* Prefix-left-edge ladder. Each depth step is 1.8em (matches the
row indent ladder above) so the prefix's right edge meets the
icon and its leftmost lane lines up with top-level rows' icons
at x = 0. */
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
.container-row[data-depth="4"] .tree-prefix { left: -7.2em; }
.container-row[data-depth="5"] .tree-prefix { left: -9em; }
.container-row[data-depth="6"] .tree-prefix { left: -10.8em; }
.tree-prefix .tree-lane {
flex: 0 0 1.8em;
position: relative;
}
/* Continuation bar — full row + gap below (so two adjacent
ancestor-line lanes from sibling rows visually merge into one
unbroken vertical line). Drawn at lane center (0.6em from left)
to keep it visually centered in the 1.8em column. */
.tree-prefix .lane-line::before,
.tree-prefix .lane-joint::before {
content: '';
position: absolute;
left: 0.6em;
top: 0;
bottom: 0;
border-left: 1px solid currentColor;
}
/* Last-child joint (└): vertical bar stops at the icon midline (no
continuation below this row). 3.1em ≈ row padding-top 0.6em +
icon half-height 2.5em, matching the horizontal stub's y below. */
.tree-prefix .lane-joint-last::before {
bottom: auto;
height: 3.1em;
}
/* Horizontal stub from the joint's vertical bar across the lane
and through the row's padding-left to the icon left edge. Lands
at the icon midline so the ├/└ meets the icon cleanly.
Width = lane-right (1.2em from lane center) + row padding-left
(0.8em) = 2em. */
.tree-prefix .lane-joint::after {
content: '';
position: absolute;
left: 0.6em;
top: 3.1em;
width: 2em;
border-top: 1px solid currentColor;
}
/* Live cards get the icon-left / body-right split; tombstone rows
keep the plain stacked block layout. The icon's fixed 5em width
+ aspect-ratio-derived height avoid the align-self: stretch
feedback loop that used to make different-content cards show
different-sized icons. See docs/web-ui.md::Container row (Icon
layout + load strategy) for the load-state reflow rationale. */
.container-row:not(.tombstone) {
display: flex;
align-items: flex-start;
gap: 0.7em;
}
.container-row:not(.tombstone) > .container-icon {
position: relative;
overflow: hidden;
flex: none;
width: 5em;
aspect-ratio: 1;
border-radius: 6px;
background-color: rgba(17, 17, 27, 0.6);
/* Icon doubles as the selection toggle — cursor + hover ring
make that affordable without a chrome change. The
:focus-visible ring covers keyboard activation (Enter / Space).
See docs/web-ui.md::Selection bar for the toggle semantics. */
cursor: pointer;
transition: box-shadow 120ms ease, transform 120ms ease;
}
.container-row:not(.tombstone) > .container-icon:hover {
box-shadow: 0 0 0 2px var(--purple);
}
.container-row:not(.tombstone) > .container-icon:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
/* Selected state — mauve ring on the icon + a matching outline on the
whole row so a glance at the list shows what's in the active set. */
.container-row.selected {
border-color: var(--purple);
box-shadow: inset 0 0 0 1px var(--purple);
background: rgba(203, 166, 247, 0.06);
}
.container-row.selected > .container-icon {
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
}
/* Icon image — absolutely positioned, fills the square wrapper.
See docs/web-ui.md::Container row (Icon layout + load strategy)
for the load-state-can't-reflow-row rationale. */
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
/* When the <img> fails to load it falls back to the dimmed
hyperhive mark — see docs/web-ui.md::Container row for the
fire-and-forget load + favicon fallback chain. */
.container-row:not(.tombstone) > .container-icon.icon-unreachable {
filter: grayscale(1);
opacity: 0.4;
}
.container-row .card-body {
flex: 1;
min-width: 0;
}
/* Pending state splits queued vs running — queued ops show only
the pending-state badge (no row tint), running ops keep the
amber row tint AND get a rotating amber ring on the icon. See
docs/web-ui.md::Container row (Pending-state derivation) for
the priority order. */
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
.container-row.pending-running {
border-color: var(--amber);
background: rgba(250, 179, 135, 0.05);
}
/* The spinner is a faint amber ring with one brighter arc on top
that rotates around the icon — classic CSS spinner shape. The
previous version coloured two adjacent border sides amber, which
rendered as a rotating L-corner ("_|") rather than a smooth
orbiting arc (mara on #804). Faint-ring + bright-arc reads
unambiguously as a loading spinner.
Override the icon's overflow:hidden so the ring can sit just
outside the square; it composes naturally with the mauve
selected-ring (selected + running shows both). */
@keyframes container-icon-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.container-row.pending-running > .container-icon {
overflow: visible;
}
.container-row.pending-running > .container-icon::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 10px;
border: 2px solid rgba(250, 179, 135, 0.2);
border-top-color: var(--amber);
animation: container-icon-spin 1s linear infinite;
pointer-events: none;
}
.container-row .head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: 0.4em;
}
.container-row .head .name {
font-size: 1.05em;
font-weight: bold;
}
.container-row .head .meta { margin-left: auto; }
/* Icon-only nav strip in the head row — backend-supplied per-
container link list. Inline-flex + gap so a longer list (e.g.
with `dashboardLinks` extras) doesn't cram. Each link gets a
comfortable hit target with a subtle hover so the icons read
as interactive rather than decorative. See
docs/web-ui.md::Container row Line 1 for the link-list source
of truth. */
.container-row .head .nav-strip {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35em;
}
.nav-link {
color: var(--muted);
font-size: 0.95em;
line-height: 1;
padding: 0.15em 0.35em;
border-radius: 3px;
text-decoration: none;
transition: background 0.12s ease, color 0.12s ease;
}
.nav-link:hover {
background: rgba(203, 166, 247, 0.12);
color: var(--cyan);
text-shadow: 0 0 6px rgba(137, 220, 235, 0.5);
}
.container-row .actions {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.container-row .actions form.inline { display: inline-block; margin: 0; }
.badge {
display: inline-block;
padding: 0.05em 0.5em;
border: 1px solid;
border-radius: 2px;
font-size: 0.75em;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.badge-warn {
color: var(--amber); border-color: var(--amber);
text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
}
.badge-rate-limited {
color: var(--red); border-color: var(--red);
text-shadow: 0 0 6px rgba(243, 139, 168, 0.5);
}
.badge-muted {
color: var(--muted); border-color: var(--purple-dim);
background: rgba(127, 132, 156, 0.08);
}
.badge-reminder {
color: var(--cyan); border-color: var(--cyan);
text-shadow: 0 0 6px rgba(137, 220, 235, 0.4);
}
/* Context-window usage badges on dashboard container rows. See
docs/web-ui.md::Container row Line 1 for the per-container
threshold derivation (yellow ≥ 50% / red ≥ 75% of the model's
context window) and fallback values. */
.badge-ctx-ok {
color: var(--green); border-color: var(--green);
opacity: 0.85;
}
.badge-ctx-caution {
color: var(--amber); border-color: var(--amber);
text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
}
.badge-ctx-warn {
color: var(--red); border-color: var(--red);
text-shadow: 0 0 6px rgba(243, 139, 168, 0.5);
}
.agent-status {
font-size: 0.82em;
color: var(--subtext0, #a6adc8);
padding: 0.1em 0.3em 0.25em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-status .status-icon { opacity: 0.65; }
.agent-status .status-age { opacity: 0.5; font-size: 0.9em; margin-left: 0.2em; }
.container-row.tombstone {
border-style: dashed;
background: rgba(24, 24, 37, 0.35);
opacity: 0.85;
}
.container-row.tombstone .name { color: var(--muted); }
/* Per-container journald viewer + applied-config viewer. Both open
in the side panel and lazy-fetch on open; output is monospace
inside a bordered <pre>, controls (unit select + refresh) above.
The panel-body wrapper is a column flex container that fills the
side-panel-body so the <pre> can flex-grow into a single tall
scrollable surface instead of a short box at the top with the
rest of the panel empty. */
.journal-body {
display: flex;
flex-direction: column;
height: 100%;
gap: 0.4em;
}
.journal-controls {
flex: 0 0 auto;
display: flex;
gap: 0.5em;
align-items: center;
}
.journal-unit {
font-family: inherit;
font-size: 0.9em;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.2em 0.4em;
}
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.journal-output {
margin: 0;
background: #11111b;
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.5em 0.7em;
/* Take all leftover panel height + scroll inside the pre so long
log fetches don't push the controls off-screen. `min-height: 0`
is the canonical "let me actually flex-shrink for overflow"
escape hatch on flex children. */
flex: 1 1 0;
min-height: 0;
overflow: auto;
font-size: 0.85em;
line-height: 1.4;
white-space: pre;
word-break: normal;
}
/* Notification controls — sit between the banner and the
containers section. Hidden by JS when notifications are
unsupported, denied, or already in the right state. */
/* Port-collision banner: appears above the containers list when
two sub-agents hash to the same web UI port. Critical — without
resolution, one of the harnesses will restart-loop on
AddrInUse. */
.port-conflict {
background: rgba(243, 139, 168, 0.08);
border: 1px solid var(--red);
color: var(--red);
padding: 0.5em 0.8em;
margin-bottom: 0.6em;
border-radius: 4px;
text-shadow: 0 0 6px rgba(243, 139, 168, 0.4);
animation: questions-pulse 2.4s ease-in-out infinite;
}
.port-conflict strong { color: var(--red); }
.notif-row {
display: flex;
gap: 0.5em;
align-items: center;
margin: 0.5em 0;
font-size: 0.85em;
}
.btn-notif {
font-family: inherit;
font-size: 0.85em;
background: transparent;
color: var(--cyan);
border: 1px solid var(--cyan);
padding: 0.2em 0.7em;
border-radius: 999px;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
}
.btn-notif:hover {
background: rgba(137, 220, 235, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
.pending-state {
color: var(--amber);
font-size: 0.85em;
letter-spacing: 0.08em;
text-transform: uppercase;
text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
animation: badge-pulse 1.6s ease-in-out infinite;
}
@keyframes badge-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
.empty { color: var(--muted); font-style: italic; }
code {
color: var(--amber);
background: var(--bg-elev);
padding: 0.1em 0.4em;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 0.9em;
}
/* Pending approval: a card with three stacked sections — identity
header, what-changed body, decision actions. */
.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; }
.approval-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-left: 3px solid var(--purple);
border-radius: 4px;
padding: 0.6em 0.8em;
margin-bottom: 0.6em;
}
.approval-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.3em;
}
/* Approval requested-at chip — right-aligned, goes amber after
1h. See docs/web-ui.md::Approval card. */
.approval-ts {
margin-left: auto;
color: var(--muted);
font-size: 0.85em;
}
.approval-ts.stale {
color: var(--amber);
text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
}
.approval-body {
margin: 0.45em 0;
padding-left: 1.3em;
}
.approval-description {
font-size: 0.9em;
color: var(--fg);
white-space: pre-wrap;
margin-bottom: 0.35em;
}
.approval-actions {
display: flex;
gap: 0.5em;
padding-top: 0.45em;
border-top: 1px solid var(--border);
}
.approval-actions form.inline { display: inline; }
/* Inline drill-in triggers (logs / config repo / view diff). */
.drill-ins {
display: flex;
flex-wrap: wrap;
gap: 0.15em 1.1em;
margin-top: 0.4em;
}
.drill-ins .panel-trigger { margin-top: 0; }
/* Diff side-panel: base-toggle tabs above the diff host. */
.diff-panel { display: flex; flex-direction: column; gap: 0.6em; }
.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; }
.diff-base-tab {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font: inherit;
font-size: 0.85em;
padding: 0.2em 0.7em;
cursor: pointer;
}
.diff-base-tab:hover { color: var(--fg); }
.diff-base-tab.active {
color: var(--purple);
border-color: var(--purple);
background: rgba(203, 166, 247, 0.08);
}
/* Image / tabbed file preview — see docs/web-ui.md::Side panel
for the type-aware preview shapes. */
.preview-host { margin-top: 0.5em; }
.img-preview {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
border: 1px solid var(--border);
border-radius: 4px;
/* checkerboard so transparent regions of the image read clearly */
background: repeating-conic-gradient(#313244 0% 25%, #1e1e2e 0% 50%) 50% / 18px 18px;
}
.approval-tabs {
display: flex;
gap: 0.4em;
margin: 0.6em 0 0.4em;
}
.approval-tab {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font: inherit;
font-size: 0.85em;
letter-spacing: 0.08em;
padding: 0.25em 0.9em;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.approval-tab:hover { color: var(--fg); }
.approval-tab.active {
color: var(--purple);
border-color: var(--purple);
background: rgba(203, 166, 247, 0.08);
text-shadow: 0 0 4px currentColor;
}
.approvals-history .status { font-size: 0.85em; padding: 0 0.5em; }
.status-approved { color: var(--green); }
.status-denied { color: var(--red); }
.status-failed { color: var(--amber); }
.status-cancelled { color: var(--muted); }
.glyph-approved { color: var(--green); }
.glyph-denied { color: var(--red); }
.glyph-failed { color: var(--amber); }
.glyph-cancelled { color: var(--muted); }
.meta-inputs {
list-style: none;
padding: 0;
margin: 0 0 0.8em;
display: grid;
gap: 0.2em;
}
.meta-inputs li {
padding: 0.25em 0.6em;
border: 1px solid var(--border);
background: rgba(24, 24, 37, 0.6);
}
.meta-inputs label {
display: flex;
align-items: baseline;
gap: 0.5em;
cursor: pointer;
font-size: 0.9em;
}
.meta-input-name { color: var(--amber); font-weight: bold; }
.meta-input-rev { color: var(--muted); }
.meta-input-ts { color: var(--muted); font-size: 0.85em; }
.meta-input-url {
color: var(--muted);
font-size: 0.85em;
margin-left: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Bulk select-all / -none control above the meta-inputs tree. */
.meta-inputs-bulk {
margin: 0 0 0.5em;
font-size: 0.8em;
color: var(--muted);
}
.meta-bulk-btn {
font: inherit;
font-size: 1em;
background: transparent;
border: 1px solid var(--purple-dim);
color: var(--cyan);
padding: 0.1em 0.6em;
margin-right: 0.2em;
cursor: pointer;
}
.meta-bulk-btn:hover {
border-color: var(--cyan);
text-shadow: 0 0 6px currentColor;
}
/* Tree twig glyph prefixing a nested (sub-)input row. */
.meta-input-twig {
color: var(--purple-dim);
margin-right: 0.1em;
}
.btn-meta-update {
background: rgba(203, 166, 247, 0.12);
border: 1px solid var(--purple);
color: var(--purple);
text-shadow: 0 0 4px currentColor;
padding: 0.3em 1em;
font: inherit;
font-size: 0.85em;
letter-spacing: 0.08em;
cursor: pointer;
transition: box-shadow 0.15s ease, background 0.15s ease;
}
.btn-meta-update:hover:not([disabled]) {
background: rgba(203, 166, 247, 0.22);
box-shadow: 0 0 10px -2px currentColor;
}
.btn-meta-update[disabled] {
opacity: 0.35;
cursor: not-allowed;
}
/* In-progress banner for the META INPUTS panel: shown while a
dashboard-triggered meta-update runs in the background. */
.meta-update-running {
margin: 0 0 0.7em;
padding: 0.4em 0.7em;
border: 1px solid var(--purple);
background: rgba(203, 166, 247, 0.12);
color: var(--purple);
font-size: 0.85em;
animation: badge-pulse 1.6s ease-in-out infinite;
}
/* ─── rebuild queue panel ──────────────────────────────────────────────── */
.rebuild-queue {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.2em;
}
.rebuild-queue-entry {
padding: 0.3em 0.6em;
border: 1px solid var(--border);
background: rgba(24, 24, 37, 0.6);
font-size: 0.9em;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4em;
}
.rebuild-queue-entry.rqe-child { margin-left: 1.6em; border-color: var(--purple-dim); }
.rebuild-queue-entry.rqe-running {
border-color: var(--purple);
background: rgba(203, 166, 247, 0.12);
animation: badge-pulse 1.6s ease-in-out infinite;
}
.rebuild-queue-entry.rqe-failed { border-color: var(--red); color: var(--red); }
.rebuild-queue-entry.rqe-cancelled { opacity: 0.6; }
.rebuild-queue-entry.rqe-done { opacity: 0.7; color: var(--green); }
.rqe-state { font-weight: bold; min-width: 1.2em; text-align: center; }
.rqe-kind { color: var(--cyan); }
.rqe-agent { color: var(--amber); font-weight: bold; }
.rqe-source {
font-size: 0.75em;
padding: 0.05em 0.45em;
border-radius: 0.7em;
border: 1px solid var(--border);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rqe-source-manual { color: var(--cyan); border-color: var(--cyan); }
.rqe-source-meta_update { color: var(--purple); border-color: var(--purple); }
.rqe-source-auto_update { color: var(--muted); }
.rqe-source-crash_recover { color: var(--amber); border-color: var(--amber); }
.rqe-source-approval { color: var(--green); border-color: var(--green); }
.rqe-when { color: var(--muted); font-size: 0.85em; }
.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
/* In-flight step indicator on running queue entries — cyan sub-
line below the main row, wraps to its own line via flex-basis:
100%. See docs/web-ui.md::R3BU1LD QU3U3 for the step-annotation
semantics. */
.rqe-step {
flex-basis: 100%;
margin: 0.1em 0 0 1.8em;
color: var(--cyan);
font-size: 0.85em;
}
.rqe-error {
flex-basis: 100%;
margin: 0.3em 0 0;
padding: 0.3em 0.5em;
background: rgba(243, 139, 168, 0.1);
border-left: 2px solid var(--red);
color: var(--red);
font-size: 0.8em;
white-space: pre-wrap;
}
/* Cancel-X for queued rebuild_queue entries — sits at far right
via margin-left: auto, lights red on hover. Mirrors the side-
panel-close glyph shape (✗) so it stays unobtrusive in a list
row. See docs/web-ui.md::R3BU1LD QU3U3 for the queued-only
gating. */
.rqe-cancel {
margin-left: auto;
}
.rqe-cancel-btn {
background: transparent;
border: 1px solid var(--purple-dim);
color: var(--muted);
border-radius: 999px;
width: 1.6em;
height: 1.6em;
font-size: 0.85em;
line-height: 1;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.rqe-cancel-btn:hover:not(:disabled),
.rqe-cancel-btn:focus-visible {
color: var(--red);
border-color: var(--red);
box-shadow: 0 0 8px -2px var(--red);
outline: none;
}
.rqe-cancel-btn:disabled {
opacity: 0.5;
cursor: default;
}
.history-note {
margin-left: 1.8em;
margin-top: 0.2em;
color: var(--muted);
font-size: 0.85em;
white-space: pre-wrap;
word-break: break-word;
}
ul form.inline { display: inline-block; }
.btn {
font-family: inherit;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.1em;
background: transparent;
border: 1px solid;
padding: 0.25em 0.8em;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
box-shadow: 0 0 0 0 currentColor;
transition: box-shadow 0.15s ease;
}
.btn:hover {
background: rgba(205, 214, 244, 0.06);
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn:disabled,
.btn[disabled] {
opacity: 0.32;
cursor: not-allowed;
text-shadow: none;
box-shadow: none;
}
.btn:disabled:hover,
.btn[disabled]:hover {
background: transparent;
text-shadow: none;
box-shadow: none;
}
.btn-approve { color: var(--green); border-color: var(--green); }
.btn-deny { color: var(--red); border-color: var(--red); }
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
/* M0V3 affordance (selection bar) — mauve reads as "structural
change" rather than the destructive red / amber chrome of
destroy / rebuild. See docs/web-ui.md::Selection bar. */
.btn-move { color: var(--mauve); border-color: var(--mauve); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
.btn-fire-now { color: var(--mauve, #cba6f7); border-color: var(--mauve, #cba6f7); }
/* Post-fire flash: report renders directly on the button for ~1.5s
so the operator sees ok/failed/missing/consumed counts inline
without a modal. Green when at least one ok; muted otherwise. */
.btn-fire-now-flashed {
color: var(--green);
border-color: var(--green);
text-shadow: 0 0 6px currentColor;
box-shadow: 0 0 8px -2px currentColor;
}
/* Inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but
isn't deletion). */
.btn-edit-schedule { color: var(--yellow, #f9e2af); border-color: var(--yellow, #f9e2af); }
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
.spawnform input {
font-family: inherit;
font-size: 1em;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4em 0.6em;
flex: 1;
}
.spawnform input::placeholder { color: var(--muted); }
.spawnform input:focus { outline: 1px solid var(--purple); }
.role-pending { color: var(--amber); border-color: var(--amber); }
.btn-inline {
font-family: inherit;
background: transparent;
cursor: pointer;
margin-left: 0.4em;
}
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
.kind {
display: inline-block;
margin-left: 0.4em;
padding: 0.05em 0.5em;
border: 1px solid var(--purple-dim);
color: var(--purple-dim);
border-radius: 2px;
font-size: 0.75em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.kind-spawn { color: var(--amber); border-color: var(--amber); }
.spinner {
display: inline-block;
animation: spin 1s linear infinite;
color: var(--amber);
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.talkform {
display: flex;
gap: 0.6em;
align-items: stretch;
margin-top: 0.5em;
}
.talkform select, .talkform input {
font-family: inherit;
font-size: 1em;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4em 0.6em;
}
.talkform select { color: var(--amber); }
.talkform input { flex: 1; }
.talkform input::placeholder { color: var(--muted); }
.talkform input:focus, .talkform select:focus { outline: 1px solid var(--purple); }
details { margin-top: 0.5em; }
summary {
cursor: pointer;
color: var(--muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.1em;
}
summary:hover { color: var(--purple); }
.diff {
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.8em;
margin-top: 0.4em;
overflow-x: auto;
font-size: 0.85em;
line-height: 1.4;
color: var(--muted);
white-space: pre;
}
.diff span { display: block; }
.diff .diff-add { color: var(--green); }
.diff .diff-del { color: var(--red); }
.diff .diff-hunk { color: var(--cyan); }
.diff .diff-file { color: var(--purple); font-weight: bold; }
.diff .diff-ctx { color: var(--fg); }
.questions {
background: var(--bg-elev);
border: 1px solid var(--amber);
box-shadow: 0 0 12px -4px var(--amber);
padding: 0.6em 0.9em;
animation: questions-pulse 2.4s ease-in-out infinite;
}
@keyframes questions-pulse {
0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
}
/* Reminders list — rendered from /api/reminders, separate from the
main /api/state snapshot. Each row stacks identity, head meta,
body, and a small cancel form. */
.reminders {
list-style: none;
padding: 0;
margin: 0;
}
.reminder-row {
padding: 0.4em 0;
border-bottom: 1px solid var(--border);
}
.reminder-row:last-child { border-bottom: 0; }
.reminder-head { font-size: 0.9em; }
.reminder-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
margin: 0.3em 0;
}
.reminder-row.reminder-failed {
border-left: 2px solid var(--red, #f38ba8);
padding-left: 0.5em;
}
.reminder-error {
color: var(--red, #f38ba8);
background: rgba(243, 139, 168, 0.06);
border: 1px solid rgba(243, 139, 168, 0.25);
padding: 0.3em 0.5em;
font-size: 0.85em;
white-space: pre-wrap;
word-break: break-word;
margin: 0.2em 0;
}
.reminder-actions {
display: flex;
gap: 0.4em;
margin-top: 0.3em;
}
/* Path linkification — agents drop pointer strings into messages
constantly; clicking the anchor opens the file in the side panel,
lazy-loaded from /api/state-file. */
.path-link {
color: var(--blue, #89b4fa);
text-decoration: underline dotted;
cursor: pointer;
}
.path-link:hover { color: var(--amber); }
/* File-preview body — rendered inside the side panel. */
.path-preview-body {
background: var(--bg);
border: 1px solid var(--border);
padding: 0.5em 0.7em;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85em;
color: var(--fg);
}
/* Filter chip row above the questions list. The active chip lights
up amber to match the rest of the dashboard's selection accents. */
.questions-filters {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
margin-bottom: 0.5em;
}
.q-filter-chip {
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.15em 0.7em;
font: inherit;
font-size: 0.85em;
cursor: pointer;
}
.q-filter-chip:hover { color: var(--fg); }
.q-filter-chip.active {
color: var(--amber);
border-color: var(--amber);
}
/* Peer (agent-to-agent) question rows get a left rule + dim
target-name styling so they read distinctly from operator-bound
threads at a glance. */
.questions li.question-peer {
border-left: 2px solid var(--mauve, #cba6f7);
padding-left: 0.6em;
}
.questions .msg-to-peer { color: var(--mauve, #cba6f7); }
/* The override button on peer threads picks up a non-default colour
so the operator notices they're answering on someone's behalf. */
.btn-override { background: var(--mauve, #cba6f7) !important; color: var(--bg) !important; }
.questions li.question {
padding: 0.4em 0;
border-bottom: 1px solid var(--border);
}
.questions li.question:last-child { border-bottom: 0; }
.questions .q-head { font-size: 0.9em; }
.questions .q-ttl {
color: var(--amber);
margin-left: 0.4em;
font-size: 0.95em;
letter-spacing: 0.05em;
}
.questions .q-body {
color: var(--fg);
margin: 0.3em 0;
white-space: pre-wrap;
word-break: break-word;
}
.qform {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-top: 0.4em;
}
.qform .q-options {
display: flex;
flex-direction: column;
gap: 0.25em;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.4em 0.6em;
}
.qform .q-option label { cursor: pointer; user-select: none; }
.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); }
.qform .q-free { display: flex; }
.qform .q-free textarea {
flex: 1;
font-family: inherit;
font-size: 1em;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4em 0.6em;
resize: vertical;
line-height: 1.4;
}
.qform .q-free textarea::placeholder { color: var(--muted); }
.qform .q-free textarea:focus { outline: 1px solid var(--amber); }
.qform button { align-self: flex-start; }
.qform-cancel { margin-top: 0.3em; }
.q-history {
margin-top: 0.8em;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.4em 0.7em;
}
.q-history summary { cursor: pointer; color: var(--muted); font-size: 0.9em; user-select: none; }
.questions-answered {
border: none;
box-shadow: none;
animation: none;
padding: 0;
margin-top: 0.5em;
}
.question-answered { opacity: 0.7; }
.question-answered .q-body { color: var(--muted); margin-bottom: 0.15em; }
.q-answer { font-size: 0.9em; color: var(--green, #a6e3a1); padding: 0.1em 0 0.4em 0; }
.q-answer-text { font-style: italic; }
.inbox {
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.5em 0.8em;
/* No max-height cap — let the inbox grow to fill the
side-panel-body which already scrolls. */
}
.inbox li {
padding: 0.25em 0;
border-bottom: 1px solid var(--border);
display: grid;
grid-template-columns: auto auto auto 1fr;
gap: 0.5em;
align-items: baseline;
}
.inbox li:last-child { border-bottom: 0; }
.inbox .msg-ts { color: var(--muted); font-size: 0.85em; }
.inbox .msg-from { color: var(--amber); }
.inbox .msg-sep { color: var(--muted); }
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap` (see
hive-fr0nt::TERMINAL_CSS). The msgrow / msg-* rules below are
dashboard-specific: each broker event becomes a grid of timestamp +
arrow + from/sep/to + body inside the `.row` shell. */
/* Flex (not grid): the row carries the header chips (ts / arrow /
from / → / to / body) inline. Flex collapses whitespace-only text
nodes between items and gives `body` the remaining width via
`flex: 1`. Path references inside `body` are inline anchors that
open the side panel — no full-width sibling rows. */
.live .msgrow {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5em;
padding: 0.1em 0;
/* Override the per-agent-terminal's hanging-indent metrics from
TERMINAL_CSS — the dashboard's broker rows are flex grids, not
glyph-prefixed text, and don't want the prefix column. */
text-indent: 0;
}
.live .msgrow .msg-body {
/* Body takes a full flex line of its own beneath the metadata
chips. See docs/web-ui.md::FL0W page (MESS4GE FL0W) for the
flex-basis: 100% + min-width: 0 rationale. */
flex: 1 1 100%;
min-width: 0;
}
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
.live .msgrow.delivered .msg-arrow { color: var(--green); }
/* Reply-thread rendering: indented border-left + muted reply tag. */
.live .msgrow.msg-reply {
padding-left: 1.2em;
border-left: 2px solid var(--border);
margin-left: 0.6em;
}
.msg-reply-tag {
color: var(--muted);
font-size: 0.8em;
white-space: nowrap;
order: -1; /* prepend before other flex items */
}
.msg-reply-tag a {
color: var(--muted);
text-shadow: none;
font-weight: normal;
}
.msg-reply-tag a:hover { color: var(--fg); }
/* Flash highlight when scrolled to from a reply link. */
@keyframes msg-highlight-fade {
from { background: rgba(203, 166, 247, 0.18); }
to { background: transparent; }
}
.msg-highlight { animation: msg-highlight-fade 1.5s ease-out forwards; }
.msg-ts { color: var(--muted); font-size: 0.85em; }
.msg-arrow { font-weight: bold; }
.msg-from { color: var(--amber); }
.msg-sep { color: var(--muted); }
.msg-to { color: var(--pink); }
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
/* Compose box sits inside `.terminal-wrap`, below the `.live` log. The
dashed separator mirrors the agent terminal's prompt divider. */
.op-compose {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.6em;
padding: 0.55em 0.8em;
border-top: 1px dashed var(--purple-dim);
}
.op-compose-prompt {
color: var(--purple);
text-shadow: 0 0 4px currentColor;
font-weight: bold;
white-space: nowrap;
user-select: none;
padding-top: 0.15em;
}
.op-compose-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--fg);
font: inherit;
font-size: 0.85em;
line-height: 1.5;
resize: none;
overflow: hidden;
min-height: 1.5em;
caret-color: var(--purple);
}
.op-compose-input::placeholder { color: var(--muted); }
.op-compose-suggest {
position: absolute;
bottom: 100%;
left: 0.8em;
margin-bottom: 0.2em;
background: rgba(24, 24, 37, 0.95);
border: 1px solid var(--border);
font-size: 0.85em;
min-width: 12em;
max-height: 12em;
overflow-y: auto;
z-index: 10;
}
.op-compose-suggest .item {
padding: 0.2em 0.8em;
cursor: pointer;
color: var(--fg);
}
.op-compose-suggest .item.active,
.op-compose-suggest .item:hover {
background: rgba(203, 166, 247, 0.18);
color: var(--purple);
}
footer {
margin-top: 4em;
text-align: center;
color: var(--muted);
font-size: 0.9em;
}
footer a { color: var(--purple); }
/* Slug banner lives at the page footer — slim top margin so it
doesn't crash into the prior content, bottom margin separating
from the divider/link line. */
footer .banner-thin {
margin-bottom: 0.8em;
}
/* ─── side panel ─────────────────────────────────────────────────
Long content (file previews, diffs, journald, applied config)
opens in a drawer that swipes in from the right instead of
expanding inline. `.panel-trigger` is the inline affordance that
opens it. */
.panel-trigger {
background: none;
border: none;
color: var(--muted);
font-family: inherit;
font-size: 0.85em;
letter-spacing: 0.05em;
cursor: pointer;
padding: 0;
margin-top: 0.5em;
display: inline-block;
text-align: left;
text-decoration: none;
}
.panel-trigger:hover { color: var(--cyan); }
.side-panel {
position: fixed;
inset: 0;
z-index: 50;
/* Closed: the wrapper ignores pointer events so the dashboard
underneath stays interactive; `.open` flips it back on. */
pointer-events: none;
}
.side-panel-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 0.2s ease;
}
.side-panel-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
/* Width is a CSS variable so the drag handle can update it live
(JS sets `--side-panel-w` on the drawer) and localStorage-
persisted widths apply on first paint. Default min(760px, 94vw)
covers operators who never drag. See docs/web-ui.md::Side panel
for the drag-to-resize mechanism + localStorage key. */
width: var(--side-panel-w, min(760px, 94vw));
/* Clamp so a stored width can never push the drawer off-screen
or shrink it past readability. min content width matches the
panel-head's natural width with the close button visible. */
min-width: 320px;
max-width: 96vw;
display: flex;
flex-direction: column;
background: var(--bg-elev);
border-left: 2px solid var(--purple);
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45);
transform: translateX(100%);
transition: transform 0.25s ease;
}
.side-panel.open { pointer-events: auto; }
.side-panel.open .side-panel-backdrop { opacity: 1; }
.side-panel.open .side-panel-drawer { transform: translateX(0); }
/* Drag-to-resize handle on the drawer's left edge. Invisible until
hover/drag so it doesn't compete with the 2px mauve border-left
for the visual boundary. Pointer-cursor tells the operator the
edge is grabbable; the brighter glow during drag
(body.side-panel-resizing) is the affordance the eye tracks. */
.side-panel-resize {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 6px;
margin-left: -3px;
cursor: ew-resize;
z-index: 1;
background: transparent;
transition: background 120ms ease;
}
.side-panel-resize:hover,
body.side-panel-resizing .side-panel-resize {
background: rgba(203, 166, 247, 0.55);
}
/* While dragging: kill text selection + force the resize cursor
page-wide so the cursor doesn't flip back to default the moment it
leaves the 6px handle band. */
body.side-panel-resizing {
cursor: ew-resize !important;
user-select: none;
}
body.side-panel-resizing * { cursor: ew-resize !important; }
.side-panel-head {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
padding: 0.7em 1em;
border-bottom: 1px solid var(--border);
}
.side-panel-title {
color: var(--purple);
font-weight: bold;
letter-spacing: 0.05em;
word-break: break-all;
}
.side-panel-close {
flex: 0 0 auto;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
font-family: inherit;
font-size: 1em;
line-height: 1;
padding: 0.25em 0.55em;
cursor: pointer;
}
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
.side-panel-body {
flex: 1 1 auto;
overflow: auto;
padding: 1em;
}
/* Markdown file previews rendered by `marked`. TERMINAL_CSS scopes
its own `.md` rules to `.live .row`, so the panel needs its own. */
.side-panel-body .md { color: var(--fg); line-height: 1.5; }
.side-panel-body .md > :first-child { margin-top: 0; }
.side-panel-body .md > :last-child { margin-bottom: 0; }
.side-panel-body .md p { margin: 0.5em 0; }
.side-panel-body .md h1,
.side-panel-body .md h2,
.side-panel-body .md h3,
.side-panel-body .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
.side-panel-body .md code {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.05em 0.3em;
font-size: 0.9em;
}
.side-panel-body .md pre {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.6em 0.8em;
overflow-x: auto;
}
.side-panel-body .md pre code { background: none; border: none; padding: 0; }
.side-panel-body .md a { color: var(--cyan); }
.side-panel-body .md ul,
.side-panel-body .md ol { margin: 0.4em 0; padding-left: 1.5em; }
.side-panel-body .md blockquote {
margin: 0.5em 0;
padding-left: 0.8em;
border-left: 2px solid var(--border);
color: var(--muted);
}
.side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; }
.side-panel-body .md th,
.side-panel-body .md td {
border: 1px solid var(--border);
padding: 0.2em 0.5em;
}
/* ─── /flow.html — full-page chat ─────────────────────────────────
See docs/web-ui.md::FL0W page for the page-vs-pane rationale
and the inbox-flyout-as-pill ergonomics. Shape mirrors the
per-agent live page (frosted-glass header + composer, full-
viewport terminal). */
:root {
/* Approximate height of the flow chrome (tabbar + dashboard-chrome
padding). The slug banner lives at the page footer on /, omitted
on /flow.html since the full-viewport terminal has no normal-flow
footer position. */
--flow-header-h: 3.6em;
--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 chrome reuses the dashboard's `.dashboard-chrome` + tabbar
so the operator can switch tabs from the flow page without
navigating back first. The chrome must be fixed-position (vs
sticky on the dashboard) since flow-shell has `overflow: hidden`
on body and the main area absolute-positions the terminal. */
body.flow-shell .dashboard-chrome.flow-chrome {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
margin: 0;
padding: 0.4em 0 0;
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);
}
/* Active tab on the flow page is FL0W. Its `.tab-link` styling (the
tab strip's right-most entry on the dashboard, where it represents
the *link out* to /flow.html) needs to read as the active tab here.
`aria-current="page"` flags it semantically. */
body.flow-shell .tabbar .tab.active.tab-link {
color: var(--purple);
background: var(--bg);
border-color: var(--purple-dim);
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4);
}
/* `.notif-row` styling lives under the shared `.tabbar #notif-row`
selector earlier in the file — the flow page reuses the dashboard
tab strip rather than carrying its own title/hint/back chrome. */
/* 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 header pills. */
.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;
}
/* Tail pill (↓ N new): bottom offset clears the floating composer.
Pill is anchored on .flow-main (not .terminal-wrap) so the
backdrop-filter stacking context doesn't trap its z-index — see
docs/web-ui.md::Per-agent page (Terminal-wrap) for the same
gotcha on the agent page. */
.flow-main .tail-pill {
bottom: calc(var(--flow-composer-h) + 0.6em);
z-index: 35;
}
.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; }
/* ─── scheduled prompts tab ────────────────────────────────────────
Creation form at the top, list of queued schedule cards below.
Cards show: id + source + due-in + cancel-all in the header,
the prompt body, then a targets table with per-row cancel. See
docs/web-ui.md::SCH3DUL3S tab. */
.schedule-edit-form {
display: flex;
flex-direction: column;
gap: 0.6em;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.8em 1em;
margin-bottom: 0.5em;
}
/* Inline edit form opens directly under the schedule row's
actions strip, indented slightly so it visually nests under
the row it edits. */
.schedule-edit-form-wrapper {
margin-top: 0.5em;
padding-left: 0.5em;
border-left: 2px solid var(--yellow, #f9e2af);
}
.schedule-edit-targets-note {
color: var(--muted);
font-style: italic;
font-size: 0.85em;
margin: 0.2em 0 0 0;
}
.schedule-field {
display: flex;
flex-direction: column;
gap: 0.2em;
}
.schedule-field-label {
color: var(--muted);
font-size: 0.85em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.schedule-field input,
.schedule-field textarea {
font-family: inherit;
font-size: inherit;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.35em 0.5em;
}
.schedule-field textarea { resize: vertical; min-height: 4em; }
.schedule-targets {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.schedule-target-chip {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.2em 0.6em;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.85em;
cursor: pointer;
background: rgba(24, 24, 37, 0.4);
}
.schedule-target-chip:hover { border-color: var(--purple-dim); }
.schedule-target-chip:has(input:checked) {
border-color: var(--purple);
color: var(--purple);
background: rgba(203, 166, 247, 0.08);
}
.schedule-target-chip input { margin: 0; }
.schedule-actions {
display: flex;
gap: 0.5em;
justify-content: flex-end;
}
/* Interval composer — preset chip row + d/h/m/s inputs + live
preview, so the operator never has to multiply seconds by hand. */
.schedule-interval-presets {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
margin-top: 0.1em;
}
.btn-interval-preset {
color: var(--cyan);
border-color: var(--cyan);
font-size: 0.75em;
padding: 0.15em 0.55em;
letter-spacing: 0.05em;
}
.btn-interval-oneshot {
color: var(--muted);
border-color: var(--muted);
}
.schedule-interval-parts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35em;
margin-top: 0.4em;
}
.schedule-interval-num {
width: 4em;
text-align: right;
font-variant-numeric: tabular-nums;
}
.schedule-interval-unit {
color: var(--muted);
font-size: 0.85em;
margin-right: 0.2em;
}
.schedule-interval-preview {
margin-top: 0.4em;
color: var(--cyan);
font-size: 0.9em;
font-variant-numeric: tabular-nums;
}
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; }
/* Schedules-as-table — one row per schedule, attribute columns
on the left, one ✓/✕ column per agent in the middle, actions
column on the right. See docs/web-ui.md::SCH3DUL3S tab. */
.schedules-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.schedules-table thead th {
vertical-align: bottom;
padding: 0.3em 0.5em;
text-align: left;
color: var(--muted);
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.8em;
border-bottom: 1px solid var(--border);
}
.schedules-table-id { width: 3em; }
.schedules-table-body-th { min-width: 12em; }
.schedules-table-actions-th { width: 7em; }
/* Tilted agent column headers — narrow (~28px) and tall (~90px);
the inner <div> rotates -45° about its bottom-left corner, with
a translate to slide the text up alongside the cell border. The
inner <span> carries the actual baseline so the underline
(border-bottom on the cell) aligns with the rotated text. */
.schedules-table-agent-th {
width: 28px;
min-width: 28px;
height: 95px;
padding: 0 !important;
vertical-align: bottom;
white-space: nowrap;
text-transform: none !important;
letter-spacing: 0 !important;
position: relative;
}
.schedules-table-agent-th > div {
position: absolute;
bottom: 4px;
left: 14px;
transform-origin: left bottom;
transform: rotate(-45deg);
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-size: 0.9em;
color: var(--fg);
}
.schedules-table-agent-th > div > span {
display: inline-block;
padding: 0 4px 1px;
}
.schedules-table tbody td {
padding: 0.3em 0.5em;
border-top: 1px solid var(--border);
vertical-align: middle;
}
.schedules-table-row-cancelled td { opacity: 0.55; }
.schedules-table-body-cell {
max-width: 30em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-size: 0.9em;
}
.schedules-table-check {
width: 28px;
text-align: center;
padding: 0.3em 0 !important;
}
.schedules-table-check-btn {
background: transparent;
border: 0;
color: var(--green);
cursor: pointer;
padding: 0.1em 0.3em;
font-family: inherit;
font-size: 1em;
}
.schedules-table-check-btn:hover:not(:disabled) {
color: var(--red);
}
.schedules-table-check-btn:disabled {
cursor: default;
}
.schedules-table-check-cancelled {
color: var(--muted);
font-size: 0.9em;
}
.schedules-table-actions {
text-align: right;
white-space: nowrap;
}
.schedules-table-actions .btn-inline-small {
margin-left: 0.3em;
}
/* Edit form expands inline into a colspan'd row underneath the
schedule it edits. Wrapper drops the form's normal background so it
reads as "an extension of the row above" instead of a free-floating
card. */
.schedules-table-edit-row > td {
padding: 0.5em 0.8em 0.8em !important;
background: rgba(24, 24, 37, 0.4);
}
.btn-inline-small {
font-size: 0.7em;
padding: 0.1em 0.45em;
}
/* Inline create row at the bottom of the schedules table. Cells
host inputs directly so the operator can fill + click to
queue a new schedule without leaving the table view. Slightly
different background tone so it reads as "this isn't a schedule
yet, it's the create form". */
.schedules-table-create-row td {
background: rgba(38, 32, 60, 0.25);
border-top: 2px solid var(--border);
vertical-align: top;
padding: 0.4em 0.5em;
}
.schedules-table-create-cell {
padding: 0.35em 0.4em;
}
.schedules-table-inline-input,
.schedules-table-inline-textarea,
.schedules-table-inline-num {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-size: 0.85em;
padding: 0.2em 0.4em;
border-radius: 2px;
}
.schedules-table-inline-input:focus,
.schedules-table-inline-textarea:focus,
.schedules-table-inline-num:focus {
border-color: var(--purple);
outline: none;
}
.schedules-table-inline-textarea {
display: block;
width: 100%;
min-height: 1.6em;
resize: vertical;
/* Single-line by default; bumps to 4em on focus so a multi-line
prompt has room without the row taking permanent vertical space.
:focus-within so resizing-up sticks while focus stays in the row. */
}
.schedules-table-create-body:focus-within .schedules-table-inline-textarea {
min-height: 4em;
}
.schedules-table-inline-desc {
display: block;
width: 100%;
margin-top: 0.25em;
}
.schedules-table-inline-datetime {
width: 100%;
font-size: 0.78em;
}
.schedules-table-inline-interval {
display: flex;
align-items: center;
gap: 0.1em;
white-space: nowrap;
}
.schedules-table-inline-num {
width: 2.4em;
text-align: right;
font-variant-numeric: tabular-nums;
/* hide native number spinners — they steal too much width at the
2.4em column size and the d/h/m/s buttons aren't useful with no
min/max range hint anyway. */
-moz-appearance: textfield;
appearance: textfield;
}
.schedules-table-inline-num::-webkit-outer-spin-button,
.schedules-table-inline-num::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.schedules-table-inline-unit {
color: var(--muted);
font-size: 0.7em;
margin-right: 0.15em;
}
.schedules-table-inline-check {
width: 16px;
height: 16px;
cursor: pointer;
margin: 0;
}
.schedules-table-inline-check-lbl {
display: inline-flex;
cursor: pointer;
padding: 0.4em 0.6em;
/* Pad out the label so the entire cell is a click target. */
}
.schedules-table-create-submit {
font-size: 1em;
padding: 0.15em 0.55em;
color: var(--green);
border-color: var(--green);
}
.schedules-table-create-reset {
font-size: 0.85em;
padding: 0.15em 0.45em;
color: var(--muted);
border-color: var(--border);
}
/* Selection bar — sticky-bottom strip that surfaces bulk actions
when ≥1 agent is selected (click the icon). Visually echoes the
flow composer's frosted-mauve treatment so the chrome reads as
part of the same vibecore family. Hidden when empty. See
docs/web-ui.md::Selection bar for the bulk-action semantics. */
.selection-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6em;
padding: 0.55em 1em;
background: var(--flow-frost-bg);
-webkit-backdrop-filter: var(--flow-frost-blur);
backdrop-filter: var(--flow-frost-blur);
border-top: 1px solid var(--purple);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.4);
}
.selection-bar[hidden] { display: none; }
.selection-count {
color: var(--purple);
font-weight: bold;
letter-spacing: 0.05em;
}
.selection-names {
color: var(--muted);
font-size: 0.85em;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selection-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 0.4em;
align-items: center;
}
.selection-actions .btn {
font-size: 0.75em;
padding: 0.2em 0.7em;
}
.selection-clear {
color: var(--muted);
border-color: var(--purple-dim);
font-size: 0.75em;
padding: 0.2em 0.7em;
}
/* Pad the dashboard body so the sticky bar doesn't cover the
bottom of the agent list. ~3.4em covers the bar's vertical
footprint with breathing room; only applies when the bar is
visible (`body.has-selection`, toggled by tabs.js when the
selection set is non-empty). */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
/* M0V3 → <pick> picker — inline `<select>` + button pair sitting
alongside the bulk action buttons in the selection bar. The
select inherits the terminal-y monospace look so it doesn't read
as system-chrome popping out of the swarm aesthetic. See
docs/web-ui.md::Selection bar for the picker semantics. */
.move-picker {
display: inline-flex;
align-items: center;
gap: 0.3em;
margin-left: 0.6em;
}
.move-picker-select {
font-family: inherit;
font-size: 0.75em;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--mauve);
border-radius: 2px;
padding: 0.1em 0.3em;
max-width: 16em;
}
.move-picker-select:disabled {
opacity: 0.5;
cursor: default;
}
.move-picker .btn-move {
margin-left: 0;
}