From 6349e91e0f11b4d94152569fc34c510052333682 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 10 Mar 2026 15:29:37 +0300 Subject: [PATCH] Sticky header with centered tabs, scroll-spy, and full-width layout - Move tab bar into header (centered between title and toolbar) - Make entire header sticky with border-bottom separator - Remove container max-width for full-width layout - Add scroll-spy: tree sidebar tracks visible section on scroll - Remember scroll position per tab when switching - Remove sticky section headers, use scroll-margin-top instead - Update sticky offsets to use --sticky-top CSS variable Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/static/css/base.css | 2 - .../src/wled_controller/static/css/layout.css | 5 +- .../wled_controller/static/css/streams.css | 6 +- .../wled_controller/static/css/tree-nav.css | 4 +- server/src/wled_controller/static/js/app.js | 8 +- .../static/js/core/tree-nav.js | 77 +++++++++++++++++++ .../static/js/features/streams.js | 8 ++ .../static/js/features/tabs.js | 7 ++ .../static/js/features/targets.js | 1 + .../src/wled_controller/templates/index.html | 14 ++-- 10 files changed, 108 insertions(+), 24 deletions(-) diff --git a/server/src/wled_controller/static/css/base.css b/server/src/wled_controller/static/css/base.css index e94b70e..43e936f 100644 --- a/server/src/wled_controller/static/css/base.css +++ b/server/src/wled_controller/static/css/base.css @@ -84,8 +84,6 @@ body.modal-open { } .container { - max-width: 1200px; - margin: 0 auto; padding: 20px; } diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index 5f1079a..0056386 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -2,11 +2,12 @@ header { display: flex; justify-content: space-between; align-items: center; - padding: 8px 0 12px; + padding: 8px 0 8px; position: sticky; top: 0; z-index: 100; background: var(--bg-color); + border-bottom: 2px solid var(--border-color); } .header-title { @@ -148,8 +149,6 @@ h2 { display: flex; align-items: center; gap: 4px; - border-bottom: 2px solid var(--border-color); - margin-bottom: 16px; } .tab-btn { diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index cad4b34..6f90ac1 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -700,7 +700,7 @@ body.pp-filter-dragging .pp-filter-drag-handle { /* Sub-tab content sections */ .subtab-section { margin-bottom: 24px; - scroll-margin-top: var(--header-height, 48px); + scroll-margin-top: var(--sticky-top, 90px); } .subtab-section:last-child { @@ -728,10 +728,6 @@ body.pp-filter-dragging .pp-filter-drag-handle { gap: 8px; cursor: pointer; user-select: none; - position: sticky; - top: var(--header-height, 0px); - z-index: 10; - background: var(--bg-color); padding: 8px 0; } diff --git a/server/src/wled_controller/static/css/tree-nav.css b/server/src/wled_controller/static/css/tree-nav.css index a2a9725..f18b170 100644 --- a/server/src/wled_controller/static/css/tree-nav.css +++ b/server/src/wled_controller/static/css/tree-nav.css @@ -13,8 +13,8 @@ min-width: 210px; flex-shrink: 0; position: sticky; - top: calc(var(--header-height, 48px) + 52px); - max-height: calc(100vh - var(--header-height, 48px) - 80px); + top: calc(var(--sticky-top, 90px) + 8px); + max-height: calc(100vh - var(--sticky-top, 90px) - 24px); overflow-y: auto; padding: 4px 0; } diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 8e46ac7..5422505 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -524,13 +524,13 @@ document.addEventListener('DOMContentLoaded', async () => { // Show content now that translations are loaded and tabs are set document.body.style.visibility = 'visible'; - // Set CSS variable for sticky header height so section headers stack below it + // Set CSS variable for sticky header height (header now includes tab bar) const headerEl = document.querySelector('header'); if (headerEl) { const updateHeaderHeight = () => { - document.documentElement.style.setProperty( - '--header-height', headerEl.offsetHeight + 'px' - ); + const hh = headerEl.offsetHeight; + document.documentElement.style.setProperty('--header-height', hh + 'px'); + document.documentElement.style.setProperty('--sticky-top', hh + 'px'); }; updateHeaderHeight(); window.addEventListener('resize', updateHeaderHeight); diff --git a/server/src/wled_controller/static/js/core/tree-nav.js b/server/src/wled_controller/static/js/core/tree-nav.js index 1751374..5ec7dcc 100644 --- a/server/src/wled_controller/static/js/core/tree-nav.js +++ b/server/src/wled_controller/static/js/core/tree-nav.js @@ -38,6 +38,14 @@ export class TreeNav { this._leafMap = new Map(); // key → leaf config this._activeLeaf = null; this._extraHtml = ''; + this._observerSuppressed = false; + } + + /** Temporarily suppress scroll-spy (e.g. during programmatic scroll). */ + suppressObserver(ms = 800) { + this._observerSuppressed = true; + clearTimeout(this._suppressTimer); + this._suppressTimer = setTimeout(() => { this._observerSuppressed = false; }, ms); } /** @@ -170,6 +178,74 @@ export class TreeNav { `; } + /** + * Start observing card-section elements so the active tree leaf + * follows whichever section is currently visible on screen. + * @param {string} contentId - ID of the scrollable content container + * @param {Object} [sectionMap] - optional { data-card-section → leafKey } override + */ + observeSections(contentId, sectionMap) { + this.stopObserving(); + const content = document.getElementById(contentId); + if (!content) return; + + // Build sectionKey → leafKey mapping + const sectionToLeaf = new Map(); + if (sectionMap) { + for (const [sk, lk] of Object.entries(sectionMap)) sectionToLeaf.set(sk, lk); + } else { + for (const [key, leaf] of this._leafMap) { + sectionToLeaf.set(leaf.sectionKey || key, key); + } + } + + const stickyTop = parseInt(getComputedStyle(document.documentElement) + .getPropertyValue('--sticky-top')) || 90; + + // Track which sections are currently intersecting + const visible = new Set(); + + this._observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target); + else visible.delete(entry.target); + } + if (this._observerSuppressed) return; + // Read fresh rects to find the topmost visible section + let bestEl = null; + let bestTop = Infinity; + for (const el of visible) { + const top = el.getBoundingClientRect().top; + if (top < bestTop) { bestTop = top; bestEl = el; } + } + if (bestEl) { + const sectionKey = bestEl.dataset.cardSection; + const leafKey = sectionToLeaf.get(sectionKey); + if (leafKey && leafKey !== this._activeLeaf) { + this._activeLeaf = leafKey; + this.setActive(leafKey); + } + } + }, { + rootMargin: `-${stickyTop}px 0px -40% 0px`, + threshold: 0 + }); + + content.querySelectorAll('[data-card-section]').forEach(section => { + if (sectionToLeaf.has(section.dataset.cardSection)) { + this._observer.observe(section); + } + }); + } + + /** Stop the scroll-spy observer. */ + stopObserving() { + if (this._observer) { + this._observer.disconnect(); + this._observer = null; + } + } + _bindEvents(container) { // Group header toggle container.querySelectorAll('.tree-group-header').forEach(header => { @@ -191,6 +267,7 @@ export class TreeNav { if (el.closest('.tree-group-header')) return; const key = el.dataset.treeLeaf; this.setActive(key); + this.suppressObserver(); if (this.onSelect) this.onSelect(key, this._leafMap.get(key)); }); }); diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 7f8e7c3..3058fe2 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1538,6 +1538,14 @@ function renderPictureSourcesList(streams) { // Render tree sidebar with expand/collapse buttons _streamsTree.setExtraHtml(``); _streamsTree.update(treeGroups, activeTab); + _streamsTree.observeSections('streams-list', { + 'raw-streams': 'raw', 'raw-templates': 'raw', + 'static-streams': 'static_image', + 'proc-streams': 'processed', 'proc-templates': 'processed', + 'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio', + 'value-sources': 'value', + 'sync-clocks': 'sync', + }); } } diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js index 733b39e..17c8f4c 100644 --- a/server/src/wled_controller/static/js/features/tabs.js +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -21,8 +21,12 @@ function _setHash(tab, subTab) { let _suppressHashUpdate = false; let _activeTab = null; +const _tabScrollPositions = {}; + export function switchTab(name, { updateHash = true, skipLoad = false } = {}) { if (_activeTab === name) return; + // Save scroll position of the tab we're leaving + if (_activeTab) _tabScrollPositions[_activeTab] = window.scrollY; _activeTab = name; document.querySelectorAll('.tab-btn').forEach(btn => { @@ -33,6 +37,9 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) { document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); localStorage.setItem('activeTab', name); + // Restore scroll position for this tab + requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0)); + if (updateHash && !_suppressHashUpdate) { const subTab = name === 'targets' ? (localStorage.getItem('activeTargetSubTab') || 'led') diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index edbb07f..559d2d3 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -723,6 +723,7 @@ export async function loadTargetsTab() { // Render tree sidebar with expand/collapse buttons _targetsTree.setExtraHtml(``); _targetsTree.update(treeGroups, activeLeaf); + _targetsTree.observeSections('targets-panel-content'); } // Show/hide stop-all buttons based on running state diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 8e6bd41..ca41c0e 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -36,6 +36,12 @@

LED Grab

+
+ + + + +
API @@ -88,15 +94,7 @@
-
-
- - - - -
-