From e9d22ef2d3bd460fee42cfaf4a666d3d6e30494d Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 1 Jun 2026 19:39:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(#994):=20tabbar=20overflow=20menu=20?= =?UTF-8?q?=E2=80=94=20settings=20and=20logs=20in=20=E2=8B=AE=20by=20defau?= =?UTF-8?q?lt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a ⋮ overflow button at the right end of the dashboard tab strip. SETTINGS and LOGS always live in the overflow dropdown (marked data-overflow="default" in index.html — never rendered in the main bar). Dynamic overflow: when the bar is too narrow to fit all remaining tabs, rightmost tabs spill into the dropdown right-to-left. Implemented via JS measurement + ResizeObserver; no CSS-only relayout trick needed. The ⋮ button: - hidden when the dropdown is empty (wide screens with only the default tabs overflowed, which are always there anyway → button always shown) - gets .tabbar-overflow-active when the current hash tab is inside - dropdown items clone the tab label + count badge from the original - closes on outside click / Escape MutationObserver on the tabbar catches P33RS/M4TR1X hidden-attribute changes so the overflow recalculates when those tabs are shown/hidden. --- frontend/packages/dashboard/src/dashboard.css | 99 ++++++++++++ frontend/packages/dashboard/src/index.html | 36 +++-- frontend/packages/dashboard/src/tabs.js | 150 +++++++++++++++++- 3 files changed, 271 insertions(+), 14 deletions(-) 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 + // /