diff --git a/frontend/app.js b/frontend/app.js index caab02f..7bbb292 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -368,6 +368,83 @@ function renderSidebar(sessions, currentSession) { }); } +const SIDEBAR_KEY = 'muxplex.sidebarOpen'; +const SIDEBAR_NARROW_THRESHOLD = 960; + +/** + * Initialise sidebar open/closed state on page load. + * Reads muxplex.sidebarOpen from localStorage (JSON.parse with try/catch). + * Defaults to open on wide screens (innerWidth >= 960) when no stored value. + * Applies sidebar--collapsed class accordingly and persists the initial state. + */ +function initSidebar() { + let isOpen; + try { + const stored = localStorage.getItem(SIDEBAR_KEY); + if (stored !== null) { + isOpen = JSON.parse(stored); + } else { + isOpen = window.innerWidth >= SIDEBAR_NARROW_THRESHOLD; + } + } catch (_) { + isOpen = window.innerWidth >= SIDEBAR_NARROW_THRESHOLD; + } + + const sidebar = $('sidebar'); + if (sidebar) { + if (isOpen) { + sidebar.classList.remove('sidebar--collapsed'); + } else { + sidebar.classList.add('sidebar--collapsed'); + } + } + + // Persist initial state + try { + localStorage.setItem(SIDEBAR_KEY, JSON.stringify(isOpen)); + } catch (_) { /* blocked — ok */ } +} + +/** + * Toggle the sidebar open/closed state. + * Reads current state from localStorage, inverts it, persists, applies + * sidebar--collapsed class, and updates the collapse button text. + * Button shows ‹ when open, › when closed. + */ +function toggleSidebar() { + let isOpen; + try { + const stored = localStorage.getItem(SIDEBAR_KEY); + isOpen = stored !== null ? JSON.parse(stored) : true; + } catch (_) { + isOpen = true; + } + + // Invert state + isOpen = !isOpen; + + // Persist + try { + localStorage.setItem(SIDEBAR_KEY, JSON.stringify(isOpen)); + } catch (_) { /* blocked — ok */ } + + // Apply class + const sidebar = $('sidebar'); + if (sidebar) { + if (isOpen) { + sidebar.classList.remove('sidebar--collapsed'); + } else { + sidebar.classList.add('sidebar--collapsed'); + } + } + + // Update collapse button text (‹ when open, › when closed) + const collapseBtn = $('collapse-btn'); + if (collapseBtn) { + collapseBtn.textContent = isOpen ? '\u2039' : '\u203a'; + } +} + /** * Render the session grid. Shows empty state when no sessions exist. * On mobile, sorts sessions by priority before rendering. @@ -967,6 +1044,8 @@ if (typeof module !== 'undefined' && module.exports) { buildTileHTML, buildSidebarHTML, renderSidebar, + initSidebar, + toggleSidebar, renderGrid, requestNotificationPermission, handleBellTransitions, diff --git a/frontend/tests/test_app.mjs b/frontend/tests/test_app.mjs index 81636a0..8b89010 100644 --- a/frontend/tests/test_app.mjs +++ b/frontend/tests/test_app.mjs @@ -1,3 +1,11 @@ +// localStorage stub — must be set before importing app.js +let _localStorageStore = {}; +globalThis.localStorage = { + getItem: (key) => (Object.prototype.hasOwnProperty.call(_localStorageStore, key) ? _localStorageStore[key] : null), + setItem: (key, value) => { _localStorageStore[key] = String(value); }, + removeItem: (key) => { delete _localStorageStore[key]; }, +}; + // Browser global stubs — must be set before importing app.js globalThis.document = { getElementById: () => null, @@ -17,6 +25,7 @@ globalThis.updatePillBell = () => {}; globalThis.window = { addEventListener: () => {}, location: { href: '' }, + innerWidth: 1024, }; globalThis.Notification = { @@ -1766,3 +1775,154 @@ test('renderSidebar does nothing when view is not fullscreen', () => { globalThis.document.getElementById = origGetById; }); + +// ─── initSidebar ───────────────────────────────────────────────────────────── + +test('initSidebar defaults to open (removes sidebar--collapsed) on wide screens when no stored value', () => { + delete _localStorageStore['muxplex.sidebarOpen']; + const origInnerWidth = globalThis.window.innerWidth; + globalThis.window.innerWidth = 1200; + + const removedClasses = []; + const addedClasses = []; + const mockSidebar = { + classList: { remove: (c) => removedClasses.push(c), add: (c) => addedClasses.push(c) }, + }; + const mockCollapseBtn = { textContent: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar') return mockSidebar; + if (id === 'collapse-btn') return mockCollapseBtn; + return null; + }; + + app.initSidebar(); + + assert.ok(removedClasses.includes('sidebar--collapsed'), 'should remove sidebar--collapsed on wide screen'); + assert.ok(!addedClasses.includes('sidebar--collapsed'), 'should not add sidebar--collapsed on wide screen'); + + globalThis.document.getElementById = origGetById; + globalThis.window.innerWidth = origInnerWidth; +}); + +test('initSidebar defaults to closed (adds sidebar--collapsed) on narrow screens when no stored value', () => { + delete _localStorageStore['muxplex.sidebarOpen']; + const origInnerWidth = globalThis.window.innerWidth; + globalThis.window.innerWidth = 600; + + const removedClasses = []; + const addedClasses = []; + const mockSidebar = { + classList: { remove: (c) => removedClasses.push(c), add: (c) => addedClasses.push(c) }, + }; + const mockCollapseBtn = { textContent: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar') return mockSidebar; + if (id === 'collapse-btn') return mockCollapseBtn; + return null; + }; + + app.initSidebar(); + + assert.ok(addedClasses.includes('sidebar--collapsed'), 'should add sidebar--collapsed on narrow screen'); + assert.ok(!removedClasses.includes('sidebar--collapsed'), 'should not remove sidebar--collapsed on narrow screen'); + + globalThis.document.getElementById = origGetById; + globalThis.window.innerWidth = origInnerWidth; +}); + +test('initSidebar respects stored value true regardless of screen width — even at 600px removes collapsed class', () => { + _localStorageStore['muxplex.sidebarOpen'] = 'true'; + const origInnerWidth = globalThis.window.innerWidth; + globalThis.window.innerWidth = 600; + + const removedClasses = []; + const addedClasses = []; + const mockSidebar = { + classList: { remove: (c) => removedClasses.push(c), add: (c) => addedClasses.push(c) }, + }; + const mockCollapseBtn = { textContent: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar') return mockSidebar; + if (id === 'collapse-btn') return mockCollapseBtn; + return null; + }; + + app.initSidebar(); + + assert.ok(removedClasses.includes('sidebar--collapsed'), 'should remove sidebar--collapsed when stored value is true, even at 600px'); + assert.ok(!addedClasses.includes('sidebar--collapsed'), 'should not add sidebar--collapsed when stored value is true'); + + globalThis.document.getElementById = origGetById; + globalThis.window.innerWidth = origInnerWidth; +}); + +// ─── toggleSidebar ─────────────────────────────────────────────────────────── + +test('toggleSidebar persists state to localStorage — from true toggles to false', () => { + _localStorageStore['muxplex.sidebarOpen'] = 'true'; + + const mockSidebar = { + classList: { remove: () => {}, add: () => {} }, + }; + const mockCollapseBtn = { textContent: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar') return mockSidebar; + if (id === 'collapse-btn') return mockCollapseBtn; + return null; + }; + + app.toggleSidebar(); + + assert.strictEqual(_localStorageStore['muxplex.sidebarOpen'], 'false', 'should persist false after toggling from true'); + + globalThis.document.getElementById = origGetById; +}); + +test('toggleSidebar adds sidebar--collapsed class when closing (from open)', () => { + _localStorageStore['muxplex.sidebarOpen'] = 'true'; + + const addedClasses = []; + const mockSidebar = { + classList: { remove: () => {}, add: (c) => addedClasses.push(c) }, + }; + const mockCollapseBtn = { textContent: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar') return mockSidebar; + if (id === 'collapse-btn') return mockCollapseBtn; + return null; + }; + + app.toggleSidebar(); + + assert.ok(addedClasses.includes('sidebar--collapsed'), 'should add sidebar--collapsed class when closing'); + + globalThis.document.getElementById = origGetById; +}); + +test('toggleSidebar removes sidebar--collapsed class when opening (from closed) and sets localStorage to true', () => { + _localStorageStore['muxplex.sidebarOpen'] = 'false'; + + const removedClasses = []; + const mockSidebar = { + classList: { remove: (c) => removedClasses.push(c), add: () => {} }, + }; + const mockCollapseBtn = { textContent: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar') return mockSidebar; + if (id === 'collapse-btn') return mockCollapseBtn; + return null; + }; + + app.toggleSidebar(); + + assert.ok(removedClasses.includes('sidebar--collapsed'), 'should remove sidebar--collapsed class when opening'); + assert.strictEqual(_localStorageStore['muxplex.sidebarOpen'], 'true', 'should persist true after toggling from false'); + + globalThis.document.getElementById = origGetById; +});