diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css
index f2019b52..0f9b4dee 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -45,6 +45,7 @@ body.dashboard-shell {
.tabbar {
display: flex;
+ flex-wrap: nowrap;
align-items: center;
gap: 0.2em;
padding: 0.5em 1em 0;
@@ -98,6 +99,104 @@ 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: rgba(203, 166, 247, 0.06);
+}
+.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(--surface0);
+ border: 1px solid var(--surface2);
+ 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(--text);
+ 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(--surface1);
+ 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: rgba(243, 139, 168, 0.22);
+ 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. */
diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html
index fcac8f41..45a68b38 100644
--- a/frontend/packages/dashboard/src/index.html
+++ b/frontend/packages/dashboard/src/index.html
@@ -71,14 +71,6 @@
◆ M4TR1X ◆ →
-
-
- ◆ L0GS ◆ →
-
-
+
+
+ ◆ L0GS ◆ →
+
+ data-tab="settings"
+ data-overflow="default">
◆ S3TT1NGS ◆
+
+
+
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index 05cb5a9c..9a295b32 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -3382,6 +3382,8 @@ window.marked = marked;
// 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();
// Schedules pane has no SSE channel for mutations, so re-fetch
// on activation so the operator never lands on stale data.
if (target === 'schedules') refreshSchedules();
@@ -3390,9 +3392,152 @@ window.marked = marked;
const h = (window.location.hash || '#swarm').replace(/^#/, '');
activateTab(h);
}
- window.addEventListener('hashchange', syncTabFromHash);
+ window.addEventListener('hashchange', () => {
+ syncTabFromHash();
+ updateTabbarOverflow();
+ });
syncTabFromHash();
+ // ─── 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
+ // / items, not the originals.
+
+ const overflowWrap = $('tabbar-overflow');
+ const overflowBtn = $('tabbar-overflow-btn');
+ const overflowDrop = $('tabbar-overflow-dropdown');
+ let overflowOpen = false;
+
+ 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;
+
+ // 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: measure available width (bar width minus overflow-btn width).
+ // We must read layout AFTER restoring all dynamic tabs.
+ const availWidth = tabbar.clientWidth - overflowBtn.offsetWidth - 8;
+
+ // Step 3: walk dynamic tabs left-to-right, accumulating widths.
+ // Any that go over the available width get overflowed.
+ let cumWidth = 0;
+ const dynamicOverflow = [];
+ for (const tab of dynamic) {
+ cumWidth += tab.offsetWidth;
+ if (cumWidth > availWidth) {
+ 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.innerHTML = '';
+ 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.
// Set `hidden` when the count is zero so the pill doesn't draw
@@ -3437,6 +3582,9 @@ window.marked = marked;
// stays hidden by default; future plumbing could broadcast the
// count via localStorage / BroadcastChannel if both pages are
// open.
+
+ // 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