refactor(frontend): migrate dashboard tabbar to createTabStrip + drop overflow (#1464 step 1)

The dashboard's tab strip now runs on the shared createTabStrip
(@hive/shared/tabs.js) — the third and final consumer of #1464 step 1
(after logs + the agent window selector).

- activateTab is reduced to the per-tab side-effects only
  (body.dataset.activeTab, the selection-bar gate, the lazy-loads);
  createTabStrip owns the active tab/pane toggle + aria-selected + hash
  routing, calling activateTab via onShow. Panes switch via the `hidden`
  attribute (was a `.tab-pane-active` class) — the markup carries `hidden`
  so there's no flash before the script runs.
- the responsive overflow menu is DROPPED per the design call (move panes
  to their own pages rather than hide them in a dropdown). Removes ~150
  lines: updateTabbarOverflow, closeOverflowMenu, the click/keydown
  handlers, the ResizeObserver + MutationObserver, and syncTabFromHash.
  S3TT1NGS is a regular strip tab now (no data-overflow).
- CSS: .tab.active becomes .tab.hive-tab--active; pane visibility is now
  .tab-pane[hidden] { display: none }; the overflow dropdown styles are
  gone. Count pills (setTabCount/refreshTabCounts) are unchanged.

Behaviour-preserving except the deliberate overflow removal; aria-selected
is now standardised on the tabs.
This commit is contained in:
iris 2026-06-08 22:47:12 +02:00 committed by mara
commit 2ec06f07ed
3 changed files with 37 additions and 320 deletions

View file

@ -85,7 +85,7 @@ body.dashboard-shell {
color: var(--purple);
background: color-mix(in srgb, var(--purple) 6%, transparent);
}
.tabbar .tab.active {
.tabbar .tab.hive-tab--active {
color: var(--purple);
border-color: var(--purple-dim);
background: var(--bg);
@ -112,109 +112,11 @@ body.dashboard-shell {
color: var(--red);
}
/* Overflowed tabs are hidden from the bar (moved to the ⋮ dropdown). */
.tabbar .tab[data-overflow="default"],
.tabbar .tab.tab-overflowed {
display: none;
}
/* ── overflow ⋮ button + dropdown ────────────────────────── */
.tabbar-overflow {
position: relative;
flex-shrink: 0;
margin-left: auto; /* push to the far right */
align-self: stretch;
display: flex;
align-items: center;
}
.tabbar-overflow-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid transparent;
border-radius: 4px 4px 0 0;
color: var(--muted);
font-size: 1.2em;
line-height: 1;
padding: 0.3em 0.5em;
cursor: pointer;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.tabbar-overflow-btn:hover {
color: var(--purple);
background: color-mix(in srgb, var(--purple) 6%, transparent);
}
.tabbar-overflow-btn.tabbar-overflow-active {
color: var(--purple);
border-color: var(--purple-dim);
}
.tabbar-overflow-dropdown {
position: absolute;
right: 0;
top: calc(100% + 2px);
z-index: 100;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
list-style: none;
margin: 0;
padding: 0.3em 0;
min-width: 12em;
white-space: nowrap;
}
.tabbar-overflow-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6em;
width: 100%;
background: none;
border: none;
color: var(--fg);
font-family: inherit;
font-size: 0.88em;
letter-spacing: 0.05em;
text-align: left;
text-decoration: none;
padding: 0.45em 1em;
cursor: pointer;
}
.tabbar-overflow-item:hover {
background: var(--border);
color: var(--purple);
}
.tabbar-overflow-item-active {
color: var(--purple);
}
.tabbar-overflow-badge {
display: inline-block;
background: var(--purple-dim);
color: var(--purple);
border-radius: 999px;
padding: 0 0.5em;
min-width: 1.4em;
text-align: center;
font-size: 0.82em;
font-variant-numeric: tabular-nums;
font-weight: bold;
}
.tabbar-overflow-badge.tab-count-attn {
background: color-mix(in srgb, var(--red) 22%, transparent);
color: var(--red);
}
/* 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; }
/* Tab pane visibility createTabStrip (@hive/shared/tabs.js) sets the
`hidden` attribute on every inactive pane (resolved from the URL hash;
default no hash is SW4RM). Only the active pane stays visible. */
.tab-pane[hidden] { display: none; }
/* FL0W is a separate page (`/flow.html`) its full-viewport
styling lives in flow.css. */

View file

@ -78,39 +78,29 @@
into the log viewer (an agent's logs, a build entry's log)
still live inside the relevant dashboard content. -->
<!-- S3TT1NGS: default-overflow — always lives in the ⋮ dropdown,
never in the main strip. `data-overflow="default"` marks it so
JS keeps it out of the bar regardless of available width. -->
<!-- S3TT1NGS: lock-bump + matrix-gui config. A regular tab in the
strip now (the responsive overflow ⋮ menu was dropped — if the
strip ever runs out of room we move panes to their own pages
rather than hide them). -->
<a class="tab" id="tab-settings" href="#settings" role="tab"
aria-controls="tab-pane-settings"
data-tab="settings"
data-overflow="default">
data-tab="settings">
<span class="tab-label">◆ S3TT1NGS ◆</span>
</a>
<!-- Overflow ⋮ button + dropdown. JS populates the dropdown with
tab clones and keeps the button hidden when the dropdown would
be empty. Gets `.tabbar-overflow-active` when the active tab
is inside. -->
<div class="tabbar-overflow" id="tabbar-overflow">
<button type="button" class="tabbar-overflow-btn" id="tabbar-overflow-btn"
aria-haspopup="menu" aria-expanded="false"
title="more tabs" hidden>⋮</button>
<ul class="tabbar-overflow-dropdown" id="tabbar-overflow-dropdown"
role="menu" hidden></ul>
</div>
</nav>
</header>
<!-- Tab panes. Exactly one is `.tab-pane-active` at a time;
JS toggles based on the URL hash + `hashchange` events. -->
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before
the script runs. -->
<main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation
that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label SW4RM already says it. -->
<section class="tab-pane" id="tab-pane-swarm"
<section class="tab-pane" id="tab-pane-swarm" data-tab-pane="swarm" hidden
role="tabpanel" aria-labelledby="tab-swarm">
<!-- Swarm / hive identity headline. Populated by refreshState from
hive_name + swarm_name; stays hidden when neither is set. -->
@ -133,7 +123,7 @@
questions read as the same concept ("something is waiting on
you"); both surface their full bodies inline so the operator
can decide without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call"
<section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden
role="tabpanel" aria-labelledby="tab-call">
<!-- 1NB0X: unread agent→operator messages (#1469). Fetched on
tab activation + cold load, appended live from the broker
@ -162,7 +152,7 @@
bumps), rebuild queue (watch only), kept state from previous
tombstoned agents. Per-section content auto-compresses to a
one-line summary when empty (JS toggle). -->
<section class="tab-pane" id="tab-pane-system"
<section class="tab-pane" id="tab-pane-system" data-tab-pane="system" hidden
role="tabpanel" aria-labelledby="tab-system">
<h2>◆ M3T4 1NPUTS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
@ -201,7 +191,7 @@
(GET /api/capabilities, GET /api/tool-groups) so new entries
require no UI change. Saving POSTs to the respective
/{agent} endpoint and queues a rebuild. -->
<section class="tab-pane" id="tab-pane-permissions"
<section class="tab-pane" id="tab-pane-permissions" data-tab-pane="permissions" hidden
role="tabpanel" aria-labelledby="tab-permissions">
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
@ -233,7 +223,7 @@
`schedules_changed` SSE; tab activation re-fetches as a
safety net for disconnect windows.
See docs/web-ui.md::SCH3DUL3S tab. -->
<section class="tab-pane" id="tab-pane-schedules"
<section class="tab-pane" id="tab-pane-schedules" data-tab-pane="schedules" hidden
role="tabpanel" aria-labelledby="tab-schedules">
<h2>◆ SCH3DUL3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
@ -262,7 +252,7 @@
/api/stats-hive?window=, fetched on tab activation + on
window change. Per-agent trend charts stay on each agent's
own /stats page. -->
<section class="tab-pane" id="tab-pane-stats"
<section class="tab-pane" id="tab-pane-stats" data-tab-pane="stats" hidden
role="tabpanel" aria-labelledby="tab-stats">
<h2>◆ ST4TS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
@ -297,7 +287,7 @@
The same IDs that used to live in the tab strip migrated
here unchanged so `NOTIF.bind()` in common.js wires them
without any JS change. -->
<section class="tab-pane" id="tab-pane-settings"
<section class="tab-pane" id="tab-pane-settings" data-tab-pane="settings" hidden
role="tabpanel" aria-labelledby="tab-settings">
<h2>◆ S3TT1NGS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>

View file

@ -18,6 +18,7 @@ import {
makePathLink, appendText, appendLinkified,
openStream, renderServerWarnings,
} from './common.js';
import { createTabStrip } from '@hive/shared/tabs.js';
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
@ -4057,28 +4058,20 @@ window.marked = marked;
// (`/flow.html`) reached via the tab-strip link. Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page
// itself, where these elements don't exist and the loop no-ops).
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'stats', 'settings'];
function activateTab(name) {
const target = TABS.includes(name) ? name : TABS[0];
for (const t of TABS) {
const tab = $('tab-' + t);
const pane = $('tab-pane-' + t);
if (tab) tab.classList.toggle('active', t === target);
if (pane) pane.classList.toggle('tab-pane-active', t === target);
}
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
// per-tab side-effects via the strip's onShow. Constructed below (after
// the lazy-load fns it calls are defined). The responsive overflow ⋮
// menu was dropped — if the strip ever runs out of room we move panes
// to their own pages rather than hide them in a dropdown.
function activateTab(target) {
// Track active tab on the body so renderSelectionBar can gate
// visibility (bar only belongs on SW4RM where agent cards live).
// Re-render the bar so the toggle takes effect immediately
// on hashchange without waiting for the next SSE update.
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// Keep overflow button active state in sync after tab change.
updateTabbarOverflow();
// Re-fetch schedules on activation as a safety net (SSE covers
// live mutations but re-sync ensures consistency after disconnect
// windows or approval-path inserts that don't yet emit). Also
// re-fetch reminders on SCH3DUL3S activation since both sections
// live on the same tab.
// Re-fetch on activation as a safety net: SSE covers live mutations,
// re-sync covers disconnect windows / approval-path inserts that
// don't yet emit. SCH3DUL3S also re-fetches reminders (same tab).
if (target === 'schedules') { refreshSchedules(); refreshReminders(); }
// Permissions tables: SSE covers worker-applied changes
// (capabilities_changed / tool_groups_changed); re-fetch on
@ -4095,25 +4088,6 @@ window.marked = marked;
// open (cpu needs a short two-sample read each refresh).
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
}
// ─── tabbar overflow menu ────────────────────────────────────────────────
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
// the ⋮ dropdown. When the bar is too narrow to show all remaining tabs,
// rightmost tabs spill into the dropdown too (right-to-left).
//
// Implementation: all tabs stay in the DOM. The ⋮ wrapper sits at the
// flex end. `tab-overflowed` hides a tab from the bar (display:none).
// The dropdown is rebuilt from scratch on every call — it holds cloned
// <a>/<button> items, not the originals.
//
// IMPORTANT: these must be declared before syncTabFromHash() is called
// below — activateTab() calls updateTabbarOverflow() which closes over
// these variables, and const/let are not accessible before their
// declaration (TDZ).
const overflowWrap = $('tabbar-overflow');
const overflowBtn = $('tabbar-overflow-btn');
const overflowDrop = $('tabbar-overflow-dropdown');
let overflowOpen = false;
// ─── ST4TS: hive-wide turn-stats rollup ──────────────────────────────────
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and
@ -4372,158 +4346,12 @@ window.marked = marked;
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
}
function syncTabFromHash() {
const h = (window.location.hash || '#swarm').replace(/^#/, '');
activateTab(h);
}
window.addEventListener('hashchange', () => {
syncTabFromHash();
updateTabbarOverflow();
});
syncTabFromHash();
// Wire the shared tab strip now that activateTab + the lazy-load fns it
// calls are defined. The strip resolves the active tab from the hash
// (default SW4RM), toggles the active tab/pane + aria-selected, and
// fires activateTab for the per-tab side-effects on every change.
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
function closeOverflowMenu() {
if (!overflowOpen) return;
overflowOpen = false;
if (overflowDrop) overflowDrop.hidden = true;
if (overflowBtn) overflowBtn.setAttribute('aria-expanded', 'false');
}
if (overflowBtn) {
overflowBtn.addEventListener('click', (e) => {
e.stopPropagation();
overflowOpen = !overflowOpen;
overflowDrop.hidden = !overflowOpen;
overflowBtn.setAttribute('aria-expanded', String(overflowOpen));
});
}
document.addEventListener('click', (e) => {
if (overflowOpen && !overflowWrap?.contains(e.target)) closeOverflowMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overflowOpen) { closeOverflowMenu(); e.stopImmediatePropagation(); }
}, true);
function updateTabbarOverflow() {
const tabbar = $('tabbar');
if (!tabbar || !overflowBtn || !overflowDrop) return;
// Skip dropdown rebuild while it is open — the 1s badge tick would
// replace DOM nodes and cause a flicker mid-interaction.
if (overflowOpen) return;
// Collect all tabs that are not JS-hidden (P33RS/M4TR1X may be hidden
// by feature-gating) and not already removed from the DOM.
const allTabs = [...tabbar.querySelectorAll('.tab')];
// Separate default-overflow tabs from dynamic ones.
const defaultOverflow = allTabs.filter(t => t.dataset.overflow === 'default');
const dynamic = allTabs.filter(t => t.dataset.overflow !== 'default' && !t.hidden);
// Step 1: un-overflow all dynamic tabs so we can measure natural widths.
dynamic.forEach(t => t.classList.remove('tab-overflowed'));
// Step 2: compute the right boundary where visible tabs must end.
// getBoundingClientRect is used instead of clientWidth because
// clientWidth includes the tabbar's horizontal padding (~2em total),
// causing an over-allocation of ~2em; flex gap between tabs is also
// not captured by offsetWidth accumulation. Together these pushed
// the ⋮ button off the right edge of the screen.
const tabbarRect = tabbar.getBoundingClientRect();
const padR = parseFloat(getComputedStyle(tabbar).paddingRight) || 0;
// Right edge of the flex content area (inside right padding).
// Fall back to clientWidth-based estimate when the rect is zero
// (element not in layout, e.g. display:none ancestor).
const contentRight = tabbarRect.width > 0
? (tabbarRect.right - padR)
: (tabbar.clientWidth - padR);
// Space to reserve for the overflow wrapper. Use its actual offsetWidth
// when available; fall back to 40px on the first call (button hidden).
const btnReserve = (overflowWrap.offsetWidth || 40) + 4;
const cutoffRight = contentRight - btnReserve;
// Step 3: any tab whose right edge exceeds the cutoff is overflowed,
// along with all subsequent tabs (keeps the visible set contiguous
// and left-anchored). Once the first offending tab is found, all
// following tabs are also overflowed without re-measuring.
const dynamicOverflow = [];
for (const tab of dynamic) {
if (dynamicOverflow.length > 0 || tab.getBoundingClientRect().right > cutoffRight) {
dynamicOverflow.push(tab);
}
}
dynamicOverflow.forEach(t => t.classList.add('tab-overflowed'));
// Step 4: rebuild the dropdown from the two overflow sets.
const overflowedTabs = [...dynamicOverflow, ...defaultOverflow];
overflowDrop.replaceChildren();
for (const tab of overflowedTabs) {
const li = document.createElement('li');
li.setAttribute('role', 'presentation');
// Build a menu item that mirrors the tab's link/button behaviour.
const href = tab.getAttribute('href');
const item = href
? document.createElement('a')
: document.createElement('button');
if (href) {
item.href = href;
} else {
item.type = 'button';
item.addEventListener('click', () => {
closeOverflowMenu();
window.location.hash = tab.dataset.tab || '';
});
}
item.className = 'tabbar-overflow-item';
item.setAttribute('role', 'menuitem');
// Copy the tab label text.
const labelEl = tab.querySelector('.tab-label');
item.textContent = labelEl ? labelEl.textContent : (tab.textContent || '').trim();
// Active state: hash tabs match current route.
if (tab.dataset.tab && tab.classList.contains('active')) {
item.classList.add('tabbar-overflow-item-active');
}
// Count badge: copy from the tab's count pill if non-zero.
const countEl = tab.querySelector('.tab-count');
if (countEl && !countEl.hidden && countEl.textContent) {
const badge = document.createElement('span');
badge.className = 'tabbar-overflow-badge' + (countEl.classList.contains('tab-count-attn') ? ' tab-count-attn' : '');
badge.textContent = countEl.textContent;
item.append(badge);
}
if (href) {
item.addEventListener('click', closeOverflowMenu);
}
li.append(item);
overflowDrop.append(li);
}
// Step 5: show/hide the overflow button.
const hasItems = overflowedTabs.length > 0;
overflowBtn.hidden = !hasItems;
if (!hasItems) closeOverflowMenu();
// Step 6: mark the button active when the current tab is overflowed.
const activeTab = document.body.dataset.activeTab;
const activeIsOverflowed = overflowedTabs.some(t => t.dataset.tab === activeTab);
overflowBtn.classList.toggle('tabbar-overflow-active', activeIsOverflowed);
}
// Call on init and whenever the bar width changes.
updateTabbarOverflow();
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => updateTabbarOverflow());
const tabbar = $('tabbar');
if (tabbar) ro.observe(tabbar);
}
// Also refresh after P33RS/M4TR1X visibility changes (they're toggled by
// refreshState). A MutationObserver on the tabbar catches attribute changes
// (hidden attr) on child tabs without coupling to specific render paths.
const tabbarEl = $('tabbar');
if (tabbarEl && typeof MutationObserver !== 'undefined') {
new MutationObserver(() => updateTabbarOverflow()).observe(tabbarEl, {
attributes: true, subtree: true, attributeFilter: ['hidden'],
});
}
// Tab count pills — pure derived data from the existing state
// stores so SSE-driven updates flow through without extra plumbing.
@ -4638,9 +4466,6 @@ window.marked = marked;
// target (whole-schedule cancellation or all-targets-cancelled
// means "not waiting on the worker"; those don't pull attention).
setTabCount('schedules', activeScheduleCount());
// Re-render overflow dropdown so badges stay in sync.
updateTabbarOverflow();
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE