From cee319050ca017b465f7fdddc42cd086b32e6e39 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:48:19 +0100 Subject: [PATCH] refactor(settings): add registry-backed navigation and finder (#6040) * refactor(settings): add modular shell primitives * refactor(settings): wire modular shell * test(settings): exercise real coordinator ESM boundary * refactor(settings): add registry-backed settings finder * fix(settings): harden registry navigation behavior --- static/index.html | 52 +- static/js/settings.js | 233 ++-- static/js/settings/dom.js | 7 + static/js/settings/lifecycle.js | 176 +++ static/js/settings/navigation.js | 57 + static/js/settings/registry.js | 237 ++++ static/js/settings/search.js | 172 +++ static/js/settings/sidebar.js | 238 ++++ static/style.css | 264 +++- tests/helpers/test_settings_shell.js | 775 ++++++++++++ .../test_settings_shell_coordinator.mjs | 1093 +++++++++++++++++ tests/test_settings_shell_js_behavior.py | 117 ++ 12 files changed, 3267 insertions(+), 154 deletions(-) create mode 100644 static/js/settings/dom.js create mode 100644 static/js/settings/lifecycle.js create mode 100644 static/js/settings/navigation.js create mode 100644 static/js/settings/registry.js create mode 100644 static/js/settings/search.js create mode 100644 static/js/settings/sidebar.js create mode 100644 tests/helpers/test_settings_shell.js create mode 100644 tests/helpers/test_settings_shell_coordinator.mjs create mode 100644 tests/test_settings_shell_js_behavior.py diff --git a/static/index.html b/static/index.html index df133010d..5731633c9 100644 --- a/static/index.html +++ b/static/index.html @@ -1421,6 +1421,55 @@
+ + + + +
+
+ + + +
+
diff --git a/static/js/settings.js b/static/js/settings.js index e9100553d..b4bf69ab3 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -3,8 +3,25 @@ import uiModule from './ui.js'; import searchModule from './search.js'; -import { makeWindowDraggable } from './windowDrag.js'; -import { clearDockSide } from './modalSnap.js'; +import { byId } from './settings/dom.js'; +import { + getSettingsRegistryIssues, + isAdminManagedSettingsTab, +} from './settings/registry.js'; +import { bindSettingsSearch } from './settings/search.js'; +import { bindSettingsSidebar } from './settings/sidebar.js'; +import { + activateSettingsPanel, + getActiveSettingsTab, + bindSettingsNavigation, +} from './settings/navigation.js'; +import { + bindSettingsDrag, + bindSettingsClose, + bindOpenPromptModalLink, + showSettingsModal, + hideSettingsModal, +} from './settings/lifecycle.js'; import { sortModelIds } from './modelSort.js'; import { providerLogo } from './providers.js'; import { isAltGrEvent } from './platform.js'; @@ -14,133 +31,29 @@ let initialized = false; let modalEl = null; let _authPolicy = { password_min_length: 8 }; -function el(id) { return document.getElementById(id); } +const el = byId; function esc(s) { return uiModule.esc(s); } function safeRasterDataUrl(raw) { const value = String(raw || '').trim(); return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : ''; } -/* ── Tab switching ── */ -const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']); +/* ── Settings shell coordination ── */ +function onSettingsPanelActivated(tab) { + // Appearance keeps its existing transparent preview behavior. + document.body.classList.toggle('settings-appearance-open', tab === 'appearance'); + syncAppearanceOpacity(tab === 'appearance'); -function initTabs() { - modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => { - btn.addEventListener('click', () => { - const tab = btn.dataset.settingsTab; - // Lazy-init admin when first clicking an admin tab - if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') { - window.adminModule.open(tab); - return; - } - modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab)); - modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab)); - // Mark when the Appearance tab is open so the modal can go - // semi-transparent — lets the user see the rest of the UI react as - // they flip toggles instead of having to close + reopen the modal. - document.body.classList.toggle('settings-appearance-open', tab === 'appearance'); - syncAppearanceOpacity(tab === 'appearance'); - if (tab === 'ai') refreshAiModelEndpoints(); - }); - }); + // AI endpoints are intentionally refreshed only when entering the AI panel. + if (tab === 'ai') refreshAiModelEndpoints(); } -/* ── Dragging ── */ -function initDrag() { - const header = modalEl.querySelector('.modal-header'); - const content = modalEl.querySelector('.settings-modal-content'); - if (!header || !content) return; - // Skip interactive controls in the header (e.g. the opacity slider) so - // grabbing them doesn't start a window-drag. - makeWindowDraggable(modalEl, { - content, - header, - skipSelector: 'button, input, select, .theme-opacity-wrap', - enableDock: true, - }); -} - -function resetWindowPlacement() { - const content = modalEl && modalEl.querySelector('.settings-modal-content'); - if (!content) return; - const hadLeft = modalEl.classList.contains('modal-left-docked'); - const hadRight = modalEl.classList.contains('modal-right-docked'); - modalEl.classList.remove('modal-left-docked', 'modal-right-docked'); - if (hadLeft) clearDockSide('left', modalEl); - if (hadRight) clearDockSide('right', modalEl); - if (content._leftDockNavObs) { - try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {} - try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} - delete content._leftDockNavObs; +function openAdminSettingsTab(tab) { + if (window.adminModule && typeof window.adminModule.open === 'function') { + window.adminModule.open(tab); + return true; } - delete content._preDockSnapshot; - delete content._dockSide; - delete content._dockSuspended; - delete content.dataset._tilePreSnap; - delete content.dataset._tileZone; - [ - 'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform', - 'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition', - ].forEach(prop => content.style.removeProperty(prop)); -} - -/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */ -function initOpenPromptModalLink() { - document.addEventListener('click', async (e) => { - const link = e.target.closest('[data-open-prompt-modal]'); - if (!link) return; - e.preventDefault(); - // Close settings first so the prompt modal isn't stacked on top. - if (modalEl && !modalEl.classList.contains('hidden')) close(); - try { - const m = await import('./presets.js'); - const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal); - if (typeof fn === 'function') fn(); - } catch (_) { - const modal = document.getElementById('custom-preset-modal'); - if (modal) modal.classList.remove('hidden'); - } - // Force the Persona tab (data-chartab="character") since the link's - // whole purpose is editing personas — not landing on Inject by default. - const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]'); - if (personaTab) personaTab.click(); - }); -} - -/* ── Close on backdrop / X ── */ -function initClose() { - modalEl.querySelector('.close-btn').addEventListener('click', close); - modalEl.addEventListener('mousedown', e => { - if (uiModule.isTouchInsideModal()) return; - if (e.target === modalEl) close(); - }); - document.addEventListener('keydown', e => { - if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return; - // Bail when a transient popover inside the modal is open — Esc should - // dismiss just that, not the whole modal. Same-document listeners fire - // in registration order regardless of capture/bubble, so the popover's - // own handler can't pre-empt ours; we have to opt out here. - const popoverOpen = modalEl.querySelector( - '#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]' - ); - if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) { - return; - } - // If an integration edit/add form is open inside the modal, close - // just that — don't dismiss the whole settings modal. (Pressing - // ESC mid-edit and losing the modal was a fast-typing footgun.) - const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form'); - if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) { - e.preventDefault(); - e.stopPropagation(); - innerForm.style.display = 'none'; - innerForm.innerHTML = ''; - return; - } - e.preventDefault(); - e.stopPropagation(); - close(); - }); + return false; } /* ── Appearance-tab opacity slider ── @@ -2238,10 +2151,39 @@ function initAccount() { function initAll() { modalEl = el('settings-modal'); - initTabs(); - initDrag(); - initClose(); - initOpenPromptModalLink(); + + bindSettingsNavigation(modalEl, { + openAdminTab: openAdminSettingsTab, + onPanelActivated: onSettingsPanelActivated, + }); + + bindSettingsSearch(modalEl, { + isAdmin: () => !!window._isAdmin, + openPanel(tab) { + const button = modalEl.querySelector(`[data-settings-tab="${tab}"]`); + if (button) button.click(); + }, + }); + + bindSettingsSidebar(modalEl); + + const registryIssues = getSettingsRegistryIssues(modalEl); + if (registryIssues.length) { + console.warn('Settings registry/DOM mismatch:', registryIssues); + } + + bindSettingsDrag(modalEl); + + bindSettingsClose(modalEl, { + closeSettings: close, + isTouchInsideModal: () => uiModule.isTouchInsideModal(), + }); + + bindOpenPromptModalLink({ + getModal: () => modalEl, + closeSettings: close, + }); + initOpacityToggle(); initialized = true; initDefaultChat(); @@ -5661,44 +5603,35 @@ function syncAdminVisibility() { ═══════════════════════════════════════════ */ export function open(tab) { if (!initialized) initAll(); + syncAppearanceCheckboxes(); - if (modalEl.classList.contains('hidden')) { - resetWindowPlacement(); - } - modalEl.classList.remove('hidden'); + showSettingsModal(modalEl); syncAdminVisibility(); - const content = modalEl.querySelector('.settings-modal-content'); + if (tab) { - modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab)); - modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab)); + activateSettingsPanel(modalEl, tab); } - // Auto-init admin data if showing an admin tab - const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services'; - document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance'); - syncAppearanceOpacity(activeTab === 'appearance'); - if (activeTab === 'ai') refreshAiModelEndpoints(); - if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) { + + // Preserve existing panel-specific side effects when Settings is opened + // directly to a tab as well as when the user navigates there. + const activeTab = tab || getActiveSettingsTab(modalEl); + onSettingsPanelActivated(activeTab); + + // Auto-init admin data if showing an admin tab. + if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) { window.adminModule._initData(); } } export function close() { if (!modalEl) return; - // Always clear the appearance-tab body class so the rest of the app - // doesn't keep its dimmed state if the modal got closed mid-tab. + + // Always clear the Appearance state so the rest of the app does not remain + // dimmed if Settings is closed while that panel is active. document.body.classList.remove('settings-appearance-open'); - syncAppearanceOpacity(false); // clear any opacity-slider fade - const content = modalEl.querySelector('.modal-content, .settings-modal-content'); - if (content && !content.classList.contains('modal-closing')) { - content.classList.add('modal-closing'); - content.addEventListener('animationend', () => { - modalEl.classList.add('hidden'); - content.classList.remove('modal-closing'); - }, { once: true }); - setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250); - } else { - modalEl.classList.add('hidden'); - } + syncAppearanceOpacity(false); + + hideSettingsModal(modalEl); } // Handle redirect back from Google OAuth2 — open settings to integrations and show status. diff --git a/static/js/settings/dom.js b/static/js/settings/dom.js new file mode 100644 index 000000000..ad5bad923 --- /dev/null +++ b/static/js/settings/dom.js @@ -0,0 +1,7 @@ +// Shared DOM helpers for the Settings modules. +// Keep this module intentionally small so panel modules do not grow their own +// element-lookup conventions as Settings is split out of settings.js. + +export function byId(id) { + return document.getElementById(id); +} diff --git a/static/js/settings/lifecycle.js b/static/js/settings/lifecycle.js new file mode 100644 index 000000000..e143c38a8 --- /dev/null +++ b/static/js/settings/lifecycle.js @@ -0,0 +1,176 @@ +// Settings modal lifecycle primitives. +// +// Panel-specific behavior belongs elsewhere. This module owns only the window +// shell: dragging/docking reset, close semantics, visibility animation, and the +// delegated link that leaves Settings for the Persona editor. + +import { makeWindowDraggable } from '../windowDrag.js'; +import { clearDockSide } from '../modalSnap.js'; + +const _dragBound = new WeakSet(); +const _closeBound = new WeakSet(); +let _promptLinkBound = false; + +export function bindSettingsDrag(modalEl) { + if (!modalEl || _dragBound.has(modalEl)) return; + + const header = modalEl.querySelector('.modal-header'); + const content = modalEl.querySelector('.settings-modal-content'); + if (!header || !content) return; + + _dragBound.add(modalEl); + makeWindowDraggable(modalEl, { + content, + header, + skipSelector: 'button, input, select, .theme-opacity-wrap', + enableDock: true, + }); +} + +export function resetSettingsWindowPlacement(modalEl) { + const content = modalEl?.querySelector('.settings-modal-content'); + if (!content) return; + + const hadLeft = modalEl.classList.contains('modal-left-docked'); + const hadRight = modalEl.classList.contains('modal-right-docked'); + modalEl.classList.remove('modal-left-docked', 'modal-right-docked'); + if (hadLeft) clearDockSide('left', modalEl); + if (hadRight) clearDockSide('right', modalEl); + + if (content._leftDockNavObs) { + try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {} + try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} + delete content._leftDockNavObs; + } + + delete content._preDockSnapshot; + delete content._dockSide; + delete content._dockSuspended; + delete content.dataset._tilePreSnap; + delete content.dataset._tileZone; + + [ + 'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform', + 'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition', + ].forEach(property => content.style.removeProperty(property)); +} + +export function bindOpenPromptModalLink({ getModal, closeSettings } = {}) { + if (_promptLinkBound) return; + _promptLinkBound = true; + + document.addEventListener('click', async event => { + const link = event.target?.closest?.('[data-open-prompt-modal]'); + if (!link) return; + event.preventDefault(); + + const settingsModal = typeof getModal === 'function' ? getModal() : null; + if ( + settingsModal + && !settingsModal.classList.contains('hidden') + && typeof closeSettings === 'function' + ) { + closeSettings(); + } + + try { + const module = await import('../presets.js'); + const openPrompt = module.openCustomPresetModal + || (module.default && module.default.openCustomPresetModal); + if (typeof openPrompt === 'function') openPrompt(); + } catch (_) { + const modal = document.getElementById('custom-preset-modal'); + if (modal) modal.classList.remove('hidden'); + } + + const personaTab = document.querySelector( + '#custom-preset-modal .preset-tab[data-chartab="character"]' + ); + if (personaTab) personaTab.click(); + }); +} + +export function bindSettingsClose(modalEl, options = {}) { + if (!modalEl || _closeBound.has(modalEl)) return; + _closeBound.add(modalEl); + + const closeSettings = options.closeSettings; + const isTouchInsideModal = options.isTouchInsideModal; + + const closeButton = modalEl.querySelector('.close-btn'); + closeButton?.addEventListener('click', () => { + if (typeof closeSettings === 'function') closeSettings(); + }); + + modalEl.addEventListener('mousedown', event => { + if (typeof isTouchInsideModal === 'function' && isTouchInsideModal()) return; + if (event.target === modalEl && typeof closeSettings === 'function') { + closeSettings(); + } + }); + + document.addEventListener('keydown', event => { + if (event.key !== 'Escape' || modalEl.classList.contains('hidden')) return; + + // Esc should dismiss transient popovers before the Settings window. + const popoverOpen = modalEl.querySelector( + '#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]' + ); + if ( + popoverOpen + && popoverOpen.style.display !== 'none' + && !popoverOpen.classList.contains('hidden') + ) { + return; + } + + // Integration/account editors are nested flows. Close the editor first so + // an accidental Esc does not discard the entire Settings context. + const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form'); + if ( + innerForm + && innerForm.style.display !== 'none' + && innerForm.children.length > 0 + ) { + event.preventDefault(); + event.stopPropagation(); + innerForm.style.display = 'none'; + innerForm.innerHTML = ''; + return; + } + + event.preventDefault(); + event.stopPropagation(); + if (typeof closeSettings === 'function') closeSettings(); + }); +} + +export function showSettingsModal(modalEl) { + if (!modalEl) return; + if (modalEl.classList.contains('hidden')) { + resetSettingsWindowPlacement(modalEl); + } + modalEl.classList.remove('hidden'); +} + +export function hideSettingsModal(modalEl) { + if (!modalEl) return; + + const content = modalEl.querySelector('.modal-content, .settings-modal-content'); + if (content && !content.classList.contains('modal-closing')) { + content.classList.add('modal-closing'); + content.addEventListener('animationend', () => { + modalEl.classList.add('hidden'); + content.classList.remove('modal-closing'); + }, { once: true }); + setTimeout(() => { + if (!modalEl.classList.contains('hidden')) { + modalEl.classList.add('hidden'); + content.classList.remove('modal-closing'); + } + }, 250); + return; + } + + modalEl.classList.add('hidden'); +} diff --git a/static/js/settings/navigation.js b/static/js/settings/navigation.js new file mode 100644 index 000000000..85b449105 --- /dev/null +++ b/static/js/settings/navigation.js @@ -0,0 +1,57 @@ +// Settings navigation primitives. +// +// This module owns panel activation and sidebar click routing only. Individual +// panels continue to own their data loading and side effects. + +import { DEFAULT_SETTINGS_PANEL_ID, isAdminManagedSettingsTab } from './registry.js'; + +const _boundModals = new WeakSet(); + +export function activateSettingsPanel(modalEl, tab) { + if (!modalEl || !tab) return null; + + modalEl.querySelectorAll('[data-settings-tab]').forEach(button => { + button.classList.toggle('active', button.dataset.settingsTab === tab); + }); + modalEl.querySelectorAll('[data-settings-panel]').forEach(panel => { + panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tab); + }); + return tab; +} + +export function getActiveSettingsTab(modalEl, fallback = DEFAULT_SETTINGS_PANEL_ID) { + if (!modalEl) return fallback; + const active = modalEl.querySelector('[data-settings-tab].active'); + return active?.dataset?.settingsTab || fallback; +} + +export function bindSettingsNavigation(modalEl, options = {}) { + if (!modalEl || _boundModals.has(modalEl)) return; + _boundModals.add(modalEl); + + const openAdminTab = options.openAdminTab; + const onPanelActivated = options.onPanelActivated; + + modalEl.querySelectorAll('[data-settings-tab]').forEach(button => { + button.addEventListener('click', () => { + const tab = button.dataset.settingsTab; + if (!tab) return; + + // Preserve the existing lazy-admin path: when the admin module accepts + // the tab, it owns activation/rendering and the Settings shell does not + // perform a second local switch. + if ( + isAdminManagedSettingsTab(tab) + && typeof openAdminTab === 'function' + && openAdminTab(tab, button) === true + ) { + return; + } + + activateSettingsPanel(modalEl, tab); + if (typeof onPanelActivated === 'function') { + onPanelActivated(tab, button); + } + }); + }); +} diff --git a/static/js/settings/registry.js b/static/js/settings/registry.js new file mode 100644 index 000000000..a0e369049 --- /dev/null +++ b/static/js/settings/registry.js @@ -0,0 +1,237 @@ +// Canonical metadata for the existing Settings information architecture. +// +// This module describes Settings; it does not render the sidebar, load panel +// data, or own panel behavior. Keeping those concerns separate lets the +// current markup remain stable while navigation/search code shares one source +// of truth for panel identity and ownership. + +function defineGroup(definition) { + return Object.freeze({ ...definition }); +} + +function definePanel(definition) { + return Object.freeze({ + controller: 'settings', + adminOnly: false, + ...definition, + keywords: Object.freeze([...(definition.keywords || [])]), + }); +} + +export const SETTINGS_GROUPS = Object.freeze([ + defineGroup({ + id: 'models', + label: 'Models & AI', + }), + defineGroup({ + id: 'communications', + label: 'Communications', + }), + defineGroup({ + id: 'experience', + label: 'Experience', + }), + defineGroup({ + id: 'account', + label: 'Account', + }), + defineGroup({ + id: 'administration', + label: 'Administration', + adminOnly: true, + }), +]); + +// Order intentionally mirrors the existing Settings sidebar. +export const SETTINGS_PANELS = Object.freeze([ + definePanel({ + id: 'services', + label: 'Add Models', + group: 'models', + controller: 'admin', + keywords: ['models', 'provider', 'endpoint'], + }), + definePanel({ + id: 'added-models', + label: 'Added Models', + group: 'models', + controller: 'admin', + keywords: ['models', 'configured', 'provider', 'endpoint'], + }), + definePanel({ + id: 'ai', + label: 'AI Defaults', + group: 'models', + keywords: ['ai', 'defaults', 'model', 'vision', 'image', 'tts', 'stt'], + }), + definePanel({ + id: 'search', + label: 'Search', + group: 'models', + keywords: ['search', 'research', 'provider'], + }), + + definePanel({ + id: 'integrations', + label: 'Integrations', + group: 'communications', + controller: 'admin', + keywords: ['integrations', 'connections', 'services'], + }), + definePanel({ + id: 'email', + label: 'Email', + group: 'communications', + keywords: ['email', 'imap', 'smtp', 'oauth'], + }), + definePanel({ + id: 'reminders', + label: 'Reminders', + group: 'communications', + keywords: ['reminders', 'notifications', 'alerts'], + }), + + definePanel({ + id: 'appearance', + label: 'Appearance', + group: 'experience', + keywords: ['appearance', 'theme', 'font', 'density', 'peek'], + }), + definePanel({ + id: 'shortcuts', + label: 'Shortcuts', + group: 'experience', + keywords: ['shortcuts', 'keyboard', 'hotkeys'], + }), + + definePanel({ + id: 'account', + label: 'Account', + group: 'account', + keywords: ['account', 'password', 'logout'], + }), + + definePanel({ + id: 'tools', + label: 'Agent Tools', + group: 'administration', + controller: 'admin', + adminOnly: true, + keywords: ['agent', 'tools'], + }), + definePanel({ + id: 'users', + label: 'Users', + group: 'administration', + controller: 'admin', + adminOnly: true, + keywords: ['users', 'accounts', 'admin'], + }), + definePanel({ + id: 'system', + label: 'System', + group: 'administration', + controller: 'admin', + adminOnly: true, + keywords: ['system', 'admin', 'server'], + }), +]); + +export const DEFAULT_SETTINGS_PANEL_ID = 'services'; + +const _panelsById = new Map( + SETTINGS_PANELS.map(panel => [panel.id, panel]), +); + +export function getSettingsPanel(id) { + return _panelsById.get(String(id || '')) || null; +} + +export function getSettingsPanelsForGroup(groupId) { + return SETTINGS_PANELS.filter(panel => panel.group === groupId); +} + +export function isAdminManagedSettingsTab(id) { + return getSettingsPanel(id)?.controller === 'admin'; +} + +export function isAdminOnlySettingsTab(id) { + return getSettingsPanel(id)?.adminOnly === true; +} + +export function getSettingsPanelSearchText(panelOrId) { + const panel = typeof panelOrId === 'string' + ? getSettingsPanel(panelOrId) + : panelOrId; + + if (!panel) return ''; + + return [ + panel.label, + ...(panel.keywords || []), + ].join(' ').toLowerCase(); +} + +function normalizeSettingsSearch(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + +export function searchSettingsPanels(query, options = {}) { + const normalized = normalizeSettingsSearch(query); + if (!normalized) return []; + + const terms = normalized.split(' '); + const isAdmin = options.isAdmin === true; + + return SETTINGS_PANELS.filter(panel => { + if (panel.adminOnly && !isAdmin) return false; + + const haystack = getSettingsPanelSearchText(panel); + return terms.every(term => haystack.includes(term)); + }); +} + +export function getSettingsRegistryIssues(modalEl) { + if (!modalEl) return ['Settings modal is unavailable']; + + const tabIds = Array.from( + modalEl.querySelectorAll('[data-settings-tab]'), + element => element.dataset.settingsTab, + ).filter(Boolean); + + const panelIds = Array.from( + modalEl.querySelectorAll('[data-settings-panel]'), + element => element.dataset.settingsPanel, + ).filter(Boolean); + + const registryIds = SETTINGS_PANELS.map(panel => panel.id); + const issues = []; + + const duplicates = ids => ids.filter( + (id, index) => ids.indexOf(id) !== index, + ); + + for (const id of new Set(duplicates(tabIds))) { + issues.push(`Duplicate Settings tab: ${id}`); + } + for (const id of new Set(duplicates(panelIds))) { + issues.push(`Duplicate Settings panel: ${id}`); + } + + for (const id of registryIds) { + if (!tabIds.includes(id)) issues.push(`Registry tab missing from DOM: ${id}`); + if (!panelIds.includes(id)) issues.push(`Registry panel missing from DOM: ${id}`); + } + + for (const id of tabIds) { + if (!registryIds.includes(id)) issues.push(`DOM tab missing from registry: ${id}`); + } + for (const id of panelIds) { + if (!registryIds.includes(id)) issues.push(`DOM panel missing from registry: ${id}`); + } + + return issues; +} diff --git a/static/js/settings/search.js b/static/js/settings/search.js new file mode 100644 index 000000000..0cd151cbe --- /dev/null +++ b/static/js/settings/search.js @@ -0,0 +1,172 @@ +import { + SETTINGS_GROUPS, + getSettingsPanel, + searchSettingsPanels, +} from './registry.js'; + +const _boundModals = new WeakSet(); + +function groupLabelFor(panel) { + const group = SETTINGS_GROUPS.find(candidate => candidate.id === panel.group); + return group?.label || ''; +} + +function clearResults(resultsEl) { + if (!resultsEl) return; + resultsEl.replaceChildren(); + resultsEl.classList.add('hidden'); +} + +function getResultButtons(resultsEl) { + if (!resultsEl) return []; + return Array.from(resultsEl.querySelectorAll('[data-settings-search-result]')); +} + +function setActiveResult(resultsEl, index) { + const buttons = getResultButtons(resultsEl); + if (!buttons.length) return -1; + + let next = index; + if (next < 0) next = buttons.length - 1; + if (next >= buttons.length) next = 0; + + buttons.forEach((button, buttonIndex) => { + const active = buttonIndex === next; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + + if (typeof buttons[next].scrollIntoView === 'function') { + buttons[next].scrollIntoView({ block: 'nearest' }); + } + + return next; +} + +export function bindSettingsSearch(modalEl, options = {}) { + if (!modalEl || _boundModals.has(modalEl)) return; + + const input = modalEl.querySelector('#settings-nav-search'); + const resultsEl = modalEl.querySelector('#settings-nav-search-results'); + + if (!input || !resultsEl) return; + _boundModals.add(modalEl); + + const isAdmin = typeof options.isAdmin === 'function' + ? options.isAdmin + : () => false; + + const openPanel = typeof options.openPanel === 'function' + ? options.openPanel + : () => {}; + + let activeIndex = -1; + + function reset() { + input.value = ''; + activeIndex = -1; + clearResults(resultsEl); + } + + function activateResult(button) { + const panelId = button?.dataset?.settingsSearchResult; + if (!panelId || !getSettingsPanel(panelId)) return; + + openPanel(panelId); + reset(); + } + + function render() { + const query = input.value.trim(); + activeIndex = -1; + resultsEl.replaceChildren(); + + if (!query) { + resultsEl.classList.add('hidden'); + return; + } + + const matches = searchSettingsPanels(query, { + isAdmin: isAdmin(), + }); + + if (!matches.length) { + const empty = document.createElement('div'); + empty.className = 'settings-search-empty'; + empty.textContent = 'No settings found'; + resultsEl.appendChild(empty); + resultsEl.classList.remove('hidden'); + return; + } + + for (const panel of matches) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'settings-search-result'; + button.setAttribute('data-settings-search-result', panel.id); + button.setAttribute('role', 'option'); + button.setAttribute('aria-selected', 'false'); + + const label = document.createElement('span'); + label.className = 'settings-search-result-label'; + label.textContent = panel.label; + + const group = document.createElement('span'); + group.className = 'settings-search-result-group'; + group.textContent = groupLabelFor(panel); + + button.append(label, group); + button.addEventListener('click', () => activateResult(button)); + resultsEl.appendChild(button); + } + + resultsEl.classList.remove('hidden'); + } + + input.addEventListener('input', render); + + input.addEventListener('focus', () => { + if (input.value.trim()) render(); + }); + + input.addEventListener('keydown', event => { + const buttons = getResultButtons(resultsEl); + + if (event.key === 'Escape') { + if (!input.value && resultsEl.classList.contains('hidden')) return; + event.preventDefault(); + event.stopPropagation(); + reset(); + return; + } + + if (!buttons.length) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + activeIndex = setActiveResult(resultsEl, activeIndex + 1); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + activeIndex = setActiveResult(resultsEl, activeIndex - 1); + return; + } + + if (event.key === 'Enter') { + const target = buttons[activeIndex >= 0 ? activeIndex : 0]; + if (!target) return; + event.preventDefault(); + activateResult(target); + } + }); + + modalEl.addEventListener('mousedown', event => { + if (event.target === input || resultsEl.contains(event.target)) return; + clearResults(resultsEl); + activeIndex = -1; + }); + + return { reset, render }; +} diff --git a/static/js/settings/sidebar.js b/static/js/settings/sidebar.js new file mode 100644 index 000000000..7e1a34aeb --- /dev/null +++ b/static/js/settings/sidebar.js @@ -0,0 +1,238 @@ +const STORAGE_WIDTH = 'odysseus-settings-sidebar-width'; +const STORAGE_COLLAPSED = 'odysseus-settings-sidebar-collapsed'; + +export const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 220; +export const SETTINGS_SIDEBAR_MIN_WIDTH = 150; +export const SETTINGS_SIDEBAR_MAX_WIDTH = 340; +export const SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD = 110; + +const _bound = new WeakSet(); + +function clampWidth(value) { + const width = Number(value); + if (!Number.isFinite(width)) return SETTINGS_SIDEBAR_DEFAULT_WIDTH; + return Math.max( + SETTINGS_SIDEBAR_MIN_WIDTH, + Math.min(SETTINGS_SIDEBAR_MAX_WIDTH, width), + ); +} + +function readStoredWidth() { + try { + const stored = localStorage.getItem(STORAGE_WIDTH); + if (stored == null || String(stored).trim() === '') { + return SETTINGS_SIDEBAR_DEFAULT_WIDTH; + } + return clampWidth(stored); + } catch { + return SETTINGS_SIDEBAR_DEFAULT_WIDTH; + } +} + +function readStoredCollapsed() { + try { + return localStorage.getItem(STORAGE_COLLAPSED) === '1'; + } catch { + return false; + } +} + +function storeWidth(width) { + try { + localStorage.setItem(STORAGE_WIDTH, String(Math.round(width))); + } catch {} +} + +function storeCollapsed(collapsed) { + try { + localStorage.setItem(STORAGE_COLLAPSED, collapsed ? '1' : '0'); + } catch {} +} + +function syncResizeHandleAria(modalEl, width = null) { + const handle = modalEl?.querySelector('#settings-sidebar-resize-handle'); + if (!handle) return; + + const sidebar = modalEl.querySelector('.settings-sidebar'); + const collapsed = sidebar?.classList.contains('settings-sidebar-collapsed'); + + const current = collapsed + ? SETTINGS_SIDEBAR_MIN_WIDTH + : clampWidth( + width ?? sidebar?.getBoundingClientRect?.().width + ?? SETTINGS_SIDEBAR_DEFAULT_WIDTH + ); + + handle.setAttribute('aria-valuemin', String(SETTINGS_SIDEBAR_MIN_WIDTH)); + handle.setAttribute('aria-valuemax', String(SETTINGS_SIDEBAR_MAX_WIDTH)); + handle.setAttribute('aria-valuenow', String(Math.round(current))); +} + +function isDesktopSidebarMode(modalEl) { + const content = modalEl?.querySelector('.settings-modal-content'); + if (!content) return false; + + // Mirrors the existing container breakpoint where the sidebar becomes a + // horizontal rail. Resizing/collapse applies only to the vertical desktop + // navigation layout. + return content.getBoundingClientRect().width > 620; +} + +export function setSettingsSidebarCollapsed(modalEl, collapsed, options = {}) { + const sidebar = modalEl?.querySelector('.settings-sidebar'); + if (!sidebar) return false; + + const next = collapsed === true; + sidebar.classList.toggle('settings-sidebar-collapsed', next); + + const toggle = sidebar.querySelector('#settings-sidebar-toggle'); + if (toggle) { + toggle.setAttribute('aria-expanded', next ? 'false' : 'true'); + toggle.setAttribute( + 'aria-label', + next ? 'Expand settings navigation' : 'Collapse settings navigation', + ); + toggle.title = next + ? 'Expand settings navigation' + : 'Collapse settings navigation'; + } + + if (!next) { + const width = clampWidth(options.width ?? readStoredWidth()); + sidebar.style.setProperty('--settings-sidebar-width', `${width}px`); + syncResizeHandleAria(modalEl, width); + } else { + syncResizeHandleAria(modalEl); + } + + if (options.persist !== false) storeCollapsed(next); + return next; +} + +export function setSettingsSidebarWidth(modalEl, width, options = {}) { + const sidebar = modalEl?.querySelector('.settings-sidebar'); + if (!sidebar) return null; + + const next = clampWidth(width); + sidebar.style.setProperty('--settings-sidebar-width', `${next}px`); + syncResizeHandleAria(modalEl, next); + + if (options.persist !== false) storeWidth(next); + return next; +} + +export function bindSettingsSidebar(modalEl) { + if (!modalEl || _bound.has(modalEl)) return; + _bound.add(modalEl); + + const sidebar = modalEl.querySelector('.settings-sidebar'); + const handle = modalEl.querySelector('#settings-sidebar-resize-handle'); + const toggle = modalEl.querySelector('#settings-sidebar-toggle'); + + if (!sidebar || !handle || !toggle) return; + + setSettingsSidebarWidth(modalEl, readStoredWidth(), { persist: false }); + setSettingsSidebarCollapsed( + modalEl, + readStoredCollapsed(), + { persist: false }, + ); + + toggle.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + + if (!isDesktopSidebarMode(modalEl)) return; + + const collapsed = sidebar.classList.contains('settings-sidebar-collapsed'); + setSettingsSidebarCollapsed(modalEl, !collapsed); + }); + + let startX = 0; + let startWidth = 0; + + function stopResize() { + if (!sidebar.classList.contains('settings-sidebar-resizing')) return; + + sidebar.classList.remove('settings-sidebar-resizing'); + document.body.classList.remove('settings-sidebar-resize-active'); + + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', stopResize); + + const width = sidebar.getBoundingClientRect().width; + if (width < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) { + setSettingsSidebarCollapsed(modalEl, true); + return; + } + + setSettingsSidebarCollapsed(modalEl, false, { persist: false }); + setSettingsSidebarWidth(modalEl, width); + storeCollapsed(false); + } + + function onPointerMove(event) { + const rawWidth = startWidth + (event.clientX - startX); + + if (rawWidth < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) { + sidebar.style.setProperty( + '--settings-sidebar-width', + `${Math.max(34, rawWidth)}px`, + ); + return; + } + + setSettingsSidebarCollapsed(modalEl, false, { persist: false }); + setSettingsSidebarWidth(modalEl, rawWidth, { persist: false }); + } + + handle.addEventListener('pointerdown', event => { + if (!isDesktopSidebarMode(modalEl)) return; + + event.preventDefault(); + startX = event.clientX; + startWidth = sidebar.getBoundingClientRect().width; + + sidebar.classList.remove('settings-sidebar-collapsed'); + sidebar.classList.add('settings-sidebar-resizing'); + document.body.classList.add('settings-sidebar-resize-active'); + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', stopResize); + }); + + handle.addEventListener('keydown', event => { + if (!isDesktopSidebarMode(modalEl)) return; + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle.click(); + return; + } + + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + + event.preventDefault(); + + if (sidebar.classList.contains('settings-sidebar-collapsed')) { + setSettingsSidebarCollapsed(modalEl, false); + } + + const current = sidebar.getBoundingClientRect().width; + const delta = event.key === 'ArrowLeft' ? -16 : 16; + + // Once keyboard resizing reaches the declared minimum, another ArrowLeft + // collapses the rail. Without this explicit boundary transition the width + // setter clamps 134px back to 150px forever, making keyboard collapse via + // ArrowLeft unreachable. + if ( + event.key === 'ArrowLeft' + && current <= SETTINGS_SIDEBAR_MIN_WIDTH + ) { + setSettingsSidebarCollapsed(modalEl, true); + return; + } + + setSettingsSidebarWidth(modalEl, current + delta); + }); +} diff --git a/static/style.css b/static/style.css index 283d50f8c..e38b82c0a 100644 --- a/static/style.css +++ b/static/style.css @@ -23605,7 +23605,7 @@ body.gallery-selecting .gallery-dl-btn, /* ===== Settings Modal Layout ===== */ .settings-modal-content { - width: min(720px, 92vw); + width: min(1040px, 94vw); max-height: 85vh; padding: 0; container-type: inline-size; @@ -23659,10 +23659,13 @@ body.gallery-selecting .gallery-dl-btn, } .settings-sidebar { - width: 160px; - flex-shrink: 0; + position: relative; + width: var(--settings-sidebar-width, 220px); + flex: 0 0 var(--settings-sidebar-width, 220px); + min-width: 0; border-right: 1px solid var(--border); padding: 8px; + transition: width 0.16s ease, flex-basis 0.16s ease; display: flex; flex-direction: column; gap: 2px; @@ -41141,3 +41144,258 @@ body.theme-frosted .modal { .compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; } .compare-pane { min-height: 60dvh; } } + +/* ── Settings navigation / finder ────────────────────────────── */ +.settings-sidebar-content { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 2px; + overflow-y: auto; + overflow-x: hidden; +} + +.settings-nav-search-wrap { + position: relative; + margin: 0 0 8px; +} + +.settings-nav-search { + box-sizing: border-box; + width: 100%; + min-width: 0; + height: 32px; + padding: 5px 9px 5px 29px; + border: 1px solid var(--border); + border-radius: 6px; + outline: none; + background: var(--bg); + color: var(--fg); + font: inherit; + font-size: 11px; +} + +.settings-nav-search:focus { + border-color: var(--accent, var(--red)); +} + +.settings-nav-search::placeholder { + color: var(--fg); + opacity: 0.45; +} + +.settings-nav-search-icon { + position: absolute; + z-index: 2; + top: 9px; + left: 9px; + pointer-events: none; + opacity: 0.5; +} + +.settings-nav-search-results { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + left: 0; + right: 0; + min-width: 100%; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; + padding: 4px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--panel); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); +} + +.settings-search-result { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + width: 100%; + align-items: center; + gap: 10px; + padding: 8px 9px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--fg); + font: inherit; + text-align: left; + cursor: pointer; +} + +.settings-search-result:hover, +.settings-search-result.active { + background: color-mix(in srgb, var(--fg) 8%, transparent); +} + +.settings-search-result-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + +.settings-search-result-group { + flex: 0 0 auto; + max-width: 92px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 9px; + opacity: 0.5; +} + +.settings-search-empty { + padding: 8px; + color: var(--fg); + font-size: 10px; + text-align: center; + opacity: 0.55; +} + +.settings-sidebar-resize-handle { + position: absolute; + z-index: 20; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + background: transparent; + touch-action: none; +} + +.settings-sidebar-resize-handle:hover, +.settings-sidebar-resizing .settings-sidebar-resize-handle { + background: var(--accent, var(--red)); + opacity: 0.45; +} + +.settings-sidebar-resizing { + transition: none; + user-select: none; +} + +body.settings-sidebar-resize-active { + cursor: col-resize; + user-select: none; +} + +.settings-sidebar-toggle { + position: absolute; + z-index: 25; + top: 9px; + right: -13px; + display: flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--panel); + color: var(--fg); + cursor: pointer; + opacity: 0; + transition: opacity 0.12s ease, border-color 0.12s ease; +} + +.settings-sidebar:hover .settings-sidebar-toggle, +.settings-sidebar-toggle:focus-visible, +.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle { + opacity: 1; +} + +.settings-sidebar-toggle:hover, +.settings-sidebar-toggle:focus-visible { + border-color: var(--accent, var(--red)); + color: var(--accent, var(--red)); +} + +.settings-sidebar-toggle-expand { + display: none; +} + +.settings-sidebar.settings-sidebar-collapsed { + width: 34px; + flex-basis: 34px; + padding: 0; +} + +.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content { + display: none; +} + +.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle { + display: none; +} + +.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle { + top: 10px; + right: 5px; +} + +.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-collapse { + display: none; +} + +.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-expand { + display: block; +} + +/* The existing narrow/snapped layout is a horizontal tab rail. Sidebar + resizing/collapse applies only to the vertical desktop layout. */ +@container settings-modal (max-width: 620px) { + .settings-sidebar { + width: auto; + flex: 0 0 auto; + transition: none; + } + + .settings-sidebar-content { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + } + + .settings-nav-search-wrap, + .settings-sidebar-resize-handle, + .settings-sidebar-toggle { + display: none; + } + + .settings-sidebar.settings-sidebar-collapsed { + width: auto; + flex-basis: auto; + padding: 6px; + } + + .settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content { + display: flex; + } +} + +@media (max-width: 600px) { + .settings-sidebar { + width: auto; + flex: 0 0 auto; + } + + .settings-sidebar-content { + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + } + + .settings-nav-search-wrap, + .settings-sidebar-resize-handle, + .settings-sidebar-toggle { + display: none; + } +} diff --git a/tests/helpers/test_settings_shell.js b/tests/helpers/test_settings_shell.js new file mode 100644 index 000000000..a4494a9b6 --- /dev/null +++ b/tests/helpers/test_settings_shell.js @@ -0,0 +1,775 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +class ClassList { + constructor() { this.values = new Set(); } + add(...names) { names.filter(Boolean).forEach(name => this.values.add(name)); } + remove(...names) { names.forEach(name => this.values.delete(name)); } + contains(name) { return this.values.has(name); } + toggle(name, force) { + if (force === undefined) force = !this.contains(name); + force ? this.add(name) : this.remove(name); + return force; + } +} + +class Style { + constructor() { this.values = {}; this.display = ''; this.cssText = ''; } + setProperty(name, value) { this.values[name] = value; this[name] = value; } + removeProperty(name) { delete this.values[name]; delete this[name]; } +} + +class Element { + constructor(tagName, documentRef) { + this.tagName = String(tagName || 'div').toUpperCase(); + this.ownerDocument = documentRef; + this.children = []; + this.parentElement = null; + this.attributes = {}; + this.dataset = {}; + this.classList = new ClassList(); + this.style = new Style(); + this._listeners = new Map(); + this._innerHTML = ''; + this._textContent = ''; + } + set id(value) { this.attributes.id = String(value); } + get id() { return this.attributes.id || ''; } + set className(value) { + this.classList.values = new Set(String(value || '').split(/\s+/).filter(Boolean)); + } + get className() { return Array.from(this.classList.values).join(' '); } + set innerHTML(value) { + this._innerHTML = String(value || ''); + if (!this._innerHTML) { + this.children.forEach(child => { child.parentElement = null; }); + this.children = []; + } + } + get innerHTML() { return this._innerHTML; } + set textContent(value) { + this._textContent = String(value ?? ''); + this.children.forEach(child => { child.parentElement = null; }); + this.children = []; + } + get textContent() { + return this._textContent + this.children.map(child => child.textContent).join(''); + } + setAttribute(name, value) { + const text = String(value); + this.attributes[name] = text; + if (name === 'class') this.className = text; + if (name.startsWith('data-')) { + const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + this.dataset[key] = text; + } + } + getAttribute(name) { return this.attributes[name] ?? null; } + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } + append(...children) { children.forEach(child => this.appendChild(child)); } + replaceChildren(...children) { + this.children.forEach(child => { child.parentElement = null; }); + this.children = []; + children.forEach(child => this.appendChild(child)); + } + contains(candidate) { + if (candidate === this) return true; + return descendants(this).includes(candidate); + } + getBoundingClientRect() { + const width = Number.parseFloat( + this.style.values['--settings-sidebar-width'] + || this.style.width + // This is only a desktop-mode fixture (>620px), not a CSS assertion. + || (this.classList.contains('settings-modal-content') ? '800' : '220'), + ); + return { width, height: 600, left: 0, right: width, top: 0, bottom: 600 }; + } + addEventListener(type, handler, options = {}) { + if (!this._listeners.has(type)) this._listeners.set(type, []); + this._listeners.get(type).push({ handler, once: !!options.once }); + } + removeEventListener(type, handler) { + const entries = this._listeners.get(type) || []; + this._listeners.set(type, entries.filter(entry => entry.handler !== handler)); + } + dispatchEvent(event) { + event.target ||= this; + event.currentTarget = this; + const entries = [...(this._listeners.get(event.type) || [])]; + for (const entry of entries) { + entry.handler.call(this, event); + if (entry.once) this.removeEventListener(event.type, entry.handler); + } + } + click() { + this.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} }); + } + matches(selector) { return matchesSelector(this, selector); } + querySelector(selector) { return queryAll(this, selector)[0] || null; } + querySelectorAll(selector) { return queryAll(this, selector); } + closest(selector) { + let node = this; + while (node) { + if (matchesSelector(node, selector)) return node; + node = node.parentElement; + } + return null; + } +} + +function simpleMatch(element, selector) { + const text = selector.trim(); + if (!text) return false; + const id = text.match(/#([\w-]+)/); + if (id && element.id !== id[1]) return false; + for (const match of text.matchAll(/\.([\w-]+)/g)) { + if (!element.classList.contains(match[1])) return false; + } + for (const match of text.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) { + const actual = element.getAttribute(match[1]); + if (actual === null) return false; + if (match[2] !== undefined && actual !== match[2]) return false; + } + return true; +} + +function matchesSelector(element, selector) { + return selector.split(',').some(part => simpleMatch(element, part)); +} + +function descendants(root) { + const result = []; + const visit = node => { + for (const child of node.children || []) { + result.push(child); + visit(child); + } + }; + visit(root); + return result; +} + +function queryAll(root, selector) { + const selectors = selector.split(',').map(item => item.trim()).filter(Boolean); + const nodes = descendants(root); + const result = []; + for (const candidate of nodes) { + if (selectors.some(sel => simpleMatch(candidate, sel))) result.push(candidate); + } + return result; +} + +class DocumentShim { + constructor() { + this.body = new Element('body', this); + this.head = new Element('head', this); + this.listeners = new Map(); + } + createElement(tag) { return new Element(tag, this); } + getElementById(id) { + return [this.body, this.head, ...descendants(this.body), ...descendants(this.head)] + .find(node => node.id === id) || null; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + querySelectorAll(selector) { + return [...queryAll(this.body, selector), ...queryAll(this.head, selector)]; + } + addEventListener(type, handler) { + if (!this.listeners.has(type)) this.listeners.set(type, []); + this.listeners.get(type).push(handler); + } + removeEventListener(type, handler) { + this.listeners.set(type, (this.listeners.get(type) || []).filter(item => item !== handler)); + } + dispatch(type, event) { + for (const handler of [...(this.listeners.get(type) || [])]) handler(event); + } +} + +function buildFixture(document) { + const modal = document.createElement('div'); + modal.id = 'settings-modal'; + document.body.appendChild(modal); + + const header = document.createElement('div'); + header.className = 'modal-header'; + modal.appendChild(header); + + const close = document.createElement('button'); + close.className = 'close-btn'; + header.appendChild(close); + + const content = document.createElement('div'); + content.className = 'settings-modal-content modal-content'; + modal.appendChild(content); + + const nav = document.createElement('div'); + nav.className = 'settings-sidebar'; + content.appendChild(nav); + + const sidebarToggle = document.createElement('button'); + sidebarToggle.id = 'settings-sidebar-toggle'; + nav.appendChild(sidebarToggle); + + const sidebarHandle = document.createElement('div'); + sidebarHandle.id = 'settings-sidebar-resize-handle'; + nav.appendChild(sidebarHandle); + + const sidebarContent = document.createElement('div'); + sidebarContent.className = 'settings-sidebar-content'; + nav.appendChild(sidebarContent); + + const finder = document.createElement('div'); + sidebarContent.appendChild(finder); + + const searchInput = document.createElement('input'); + searchInput.id = 'settings-nav-search'; + finder.appendChild(searchInput); + + const searchResults = document.createElement('div'); + searchResults.id = 'settings-nav-search-results'; + searchResults.classList.add('hidden'); + finder.appendChild(searchResults); + + const panels = document.createElement('div'); + content.appendChild(panels); + + const makeTab = (id, active = false) => { + const button = document.createElement('button'); + button.setAttribute('data-settings-tab', id); + if (active) button.classList.add('active'); + sidebarContent.appendChild(button); + const panel = document.createElement('section'); + panel.setAttribute('data-settings-panel', id); + if (!active) panel.classList.add('hidden'); + panels.appendChild(panel); + return { button, panel }; + }; + + const panelIds = [ + 'services', + 'added-models', + 'ai', + 'search', + 'integrations', + 'email', + 'reminders', + 'appearance', + 'shortcuts', + 'account', + 'tools', + 'users', + 'system', + ]; + + const settingsPanels = Object.fromEntries( + panelIds.map((id, index) => [id, makeTab(id, index === 0)]), + ); + + return { + modal, + header, + close, + content, + services: settingsPanels.services, + appearance: settingsPanels.appearance, + system: settingsPanels.system, + settingsPanels, + searchInput, + searchResults, + sidebar: nav, + sidebarToggle, + sidebarHandle, + }; +} + +function moduleSource(relativePath) { + let source = fs.readFileSync( + path.join(__dirname, '../../static/js/settings', relativePath), + 'utf8', + ); + + // The production files are real ES modules. This lightweight VM harness + // removes imports because dependencies are loaded into the same context, + // but each module still needs its own lexical scope so private const/let + // bindings do not collide across modules. + source = source.replace(/^\s*import[\s\S]*?;\s*$/gm, ''); + + // Preserve exported API on globalThis while keeping all non-exported + // bindings private inside the module block below. + source = source + .replace( + /\bexport\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g, + 'globalThis.$1 = function $1(', + ) + .replace( + /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=/g, + 'globalThis.$1 =', + ) + .replace( + /\bexport\s+let\s+([A-Za-z_$][\w$]*)\s*=/g, + 'globalThis.$1 =', + ) + .replace( + /\bexport\s+var\s+([A-Za-z_$][\w$]*)\s*=/g, + 'globalThis.$1 =', + ); + + return `{\n${source}\n}`; +} + +(function runTests() { + const document = new DocumentShim(); + const fixture = buildFixture(document); + const dragCalls = []; + const dockCalls = []; + const removedWindowListeners = []; + + const storage = new Map(); + const context = { + console, + document, + localStorage: { + getItem(key) { return storage.has(key) ? storage.get(key) : null; }, + setItem(key, value) { storage.set(key, String(value)); }, + removeItem(key) { storage.delete(key); }, + }, + window: { + removeEventListener: (...args) => removedWindowListeners.push(args), + addEventListener() {}, + }, + makeWindowDraggable: (...args) => dragCalls.push(args), + clearDockSide: (...args) => dockCalls.push(args), + setTimeout: callback => { callback(); return 1; }, + WeakSet, + }; + vm.createContext(context); + vm.runInContext(moduleSource('registry.js'), context, { filename: 'registry.js' }); + vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' }); + vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' }); + vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' }); + vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' }); + vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' }); + + const results = []; + const check = (test, pass, detail = '') => results.push({ test, pass: Boolean(pass), detail }); + + const registryPanelIds = vm.runInContext( + 'SETTINGS_PANELS.map(panel => panel.id).join(",")', + context, + ); + const registryGroupIds = vm.runInContext( + 'SETTINGS_GROUPS.map(group => group.id).join(",")', + context, + ); + + check( + 'Settings registry preserves the existing sidebar panel order', + registryPanelIds === [ + 'services', + 'added-models', + 'ai', + 'search', + 'integrations', + 'email', + 'reminders', + 'appearance', + 'shortcuts', + 'account', + 'tools', + 'users', + 'system', + ].join(','), + ); + + check( + 'Settings registry defines the intended information-architecture groups', + registryGroupIds === [ + 'models', + 'communications', + 'experience', + 'account', + 'administration', + ].join(','), + ); + + check( + 'Settings registry keeps services, models, integrations and admin panels on the existing admin controller', + ['services', 'added-models', 'integrations', 'tools', 'users', 'system'] + .every(id => context.isAdminManagedSettingsTab(id)) + && ['ai', 'search', 'email', 'reminders', 'appearance', 'shortcuts', 'account'] + .every(id => !context.isAdminManagedSettingsTab(id)), + ); + + check( + 'Settings registry distinguishes admin-only visibility from admin-controlled routing', + ['tools', 'users', 'system'].every(id => context.isAdminOnlySettingsTab(id)) + && ['services', 'added-models', 'integrations'] + .every(id => !context.isAdminOnlySettingsTab(id)), + ); + + check( + 'Settings registry provides search metadata without owning search UI', + context.getSettingsPanelSearchText('appearance').includes('theme') + && context.getSettingsPanelSearchText('email').includes('smtp') + && context.getSettingsPanelSearchText('missing') === '', + ); + + check( + 'Settings registry exposes group membership in sidebar order', + context.getSettingsPanelsForGroup('communications') + .map(panel => panel.id) + .join(',') === 'integrations,email,reminders', + ); + + check( + 'Settings registry matches every production-style tab and panel in the DOM fixture', + context.getSettingsRegistryIssues(fixture.modal).length === 0, + ); + + check( + 'Settings search resolves metadata terms in registry order', + context.searchSettingsPanels('provider', { isAdmin: true }) + .map(panel => panel.id) + .join(',') === 'services,added-models,search', + ); + + check( + 'Settings search excludes admin-only panels for non-admin users', + context.searchSettingsPanels('agent tools', { isAdmin: false }).length === 0 + && context.searchSettingsPanels('agent tools', { isAdmin: true }) + .map(panel => panel.id) + .join(',') === 'tools', + ); + + check( + 'Settings search requires all query terms', + context.searchSettingsPanels('appearance theme', { isAdmin: false }) + .map(panel => panel.id) + .join(',') === 'appearance', + ); + + let searchedPanel = null; + context.bindSettingsSearch(fixture.modal, { + isAdmin: () => false, + openPanel(tab) { searchedPanel = tab; }, + }); + + fixture.searchInput.value = 'theme'; + fixture.searchInput.dispatchEvent({ + type: 'input', + preventDefault() {}, + stopPropagation() {}, + }); + + check( + 'Settings finder renders matching production registry results', + !fixture.searchResults.classList.contains('hidden') + && fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1 + && fixture.searchResults.querySelector('[data-settings-search-result]').dataset.settingsSearchResult === 'appearance', + ); + + const finderOutsideTarget = document.createElement('div'); + fixture.content.appendChild(finderOutsideTarget); + + fixture.modal.dispatchEvent({ + type: 'mousedown', + target: finderOutsideTarget, + preventDefault() {}, + stopPropagation() {}, + }); + + check( + 'Settings finder click-away hides results while retaining the query', + fixture.searchInput.value === 'theme' + && fixture.searchResults.classList.contains('hidden'), + ); + + fixture.searchInput.dispatchEvent({ + type: 'focus', + preventDefault() {}, + stopPropagation() {}, + }); + + check( + 'Settings finder refocus restores results for an unchanged retained query', + fixture.searchInput.value === 'theme' + && !fixture.searchResults.classList.contains('hidden') + && fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1, + ); + + fixture.searchInput.dispatchEvent({ + type: 'keydown', + key: 'Enter', + preventDefault() {}, + stopPropagation() {}, + }); + + check( + 'Settings finder Enter opens the first result and resets the finder', + searchedPanel === 'appearance' + && fixture.searchInput.value === '' + && fixture.searchResults.classList.contains('hidden'), + ); + + searchedPanel = null; + fixture.searchInput.value = 'agent tools'; + fixture.searchInput.dispatchEvent({ + type: 'input', + preventDefault() {}, + stopPropagation() {}, + }); + + check( + 'Settings finder does not expose admin-only results to non-admin users', + fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 0 + && fixture.searchResults.textContent !== '', + ); + + fixture.searchInput.value = 'theme'; + fixture.searchInput.dispatchEvent({ + type: 'input', + preventDefault() {}, + stopPropagation() {}, + }); + fixture.searchInput.dispatchEvent({ + type: 'keydown', + key: 'Escape', + preventDefault() {}, + stopPropagation() {}, + }); + + check( + 'Settings finder Escape clears results without closing Settings', + fixture.searchInput.value === '' + && fixture.searchResults.classList.contains('hidden'), + ); + + context.setSettingsSidebarWidth(fixture.modal, 280); + check( + 'Settings sidebar width is clamped and applied through the production controller', + fixture.sidebar.style.values['--settings-sidebar-width'] === '280px', + ); + + const widthStorageKey = 'odysseus-settings-sidebar-width'; + + storage.clear(); + const firstRunSidebar = buildFixture(document); + context.bindSettingsSidebar(firstRunSidebar.modal); + check( + 'Settings sidebar first bind uses the declared 220px default when storage is empty', + firstRunSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px', + ); + + storage.clear(); + storage.set(widthStorageKey, '276'); + const storedSidebar = buildFixture(document); + context.bindSettingsSidebar(storedSidebar.modal); + check( + 'Settings sidebar first bind restores a valid stored width', + storedSidebar.sidebar.style.values['--settings-sidebar-width'] === '276px', + ); + + storage.clear(); + storage.set(widthStorageKey, 'not-a-number'); + const malformedSidebar = buildFixture(document); + context.bindSettingsSidebar(malformedSidebar.modal); + check( + 'Settings sidebar first bind falls back to default for malformed storage', + malformedSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px', + ); + + storage.clear(); + storage.set(widthStorageKey, '10'); + const minimumSidebar = buildFixture(document); + context.bindSettingsSidebar(minimumSidebar.modal); + + storage.clear(); + storage.set(widthStorageKey, '999'); + const maximumSidebar = buildFixture(document); + context.bindSettingsSidebar(maximumSidebar.modal); + + check( + 'Settings sidebar first bind clamps stored widths to declared bounds', + minimumSidebar.sidebar.style.values['--settings-sidebar-width'] === '150px' + && maximumSidebar.sidebar.style.values['--settings-sidebar-width'] === '340px', + ); + + context.setSettingsSidebarCollapsed(fixture.modal, true); + check( + 'Settings sidebar collapse leaves the compact navigation rail state active', + fixture.sidebar.classList.contains('settings-sidebar-collapsed') + && fixture.sidebarToggle.getAttribute('aria-label') === 'Expand settings navigation', + ); + + context.setSettingsSidebarCollapsed(fixture.modal, false, { width: 260 }); + check( + 'Settings sidebar expansion restores the requested expanded width', + !fixture.sidebar.classList.contains('settings-sidebar-collapsed') + && fixture.sidebar.style.values['--settings-sidebar-width'] === '260px', + ); + + // Keyboard behavior must be exercised on a fixture that went through the + // real binding path; direct controller calls above intentionally do not bind + // event listeners. + storage.clear(); + const keyboardSidebar = buildFixture(document); + context.bindSettingsSidebar(keyboardSidebar.modal); + + context.setSettingsSidebarWidth( + keyboardSidebar.modal, + 150, + { persist: false }, + ); + + keyboardSidebar.sidebarHandle.dispatchEvent({ + type: 'keydown', + key: 'ArrowLeft', + preventDefault() {}, + stopPropagation() {}, + }); + + check( + 'ArrowLeft at the sidebar minimum collapses the rail instead of getting stuck at 150px', + keyboardSidebar.sidebar.classList.contains('settings-sidebar-collapsed'), + ); + + context.setSettingsSidebarCollapsed( + keyboardSidebar.modal, + false, + { width: 220, persist: false }, + ); + + check( + 'resizable Settings separator exposes its current ARIA range and value', + keyboardSidebar.sidebarHandle.getAttribute('aria-valuemin') === '150' + && keyboardSidebar.sidebarHandle.getAttribute('aria-valuemax') === '340' + && keyboardSidebar.sidebarHandle.getAttribute('aria-valuenow') === '220', + ); + + check('byId resolves elements through the production DOM helper', context.byId('settings-modal') === fixture.modal); + + context.activateSettingsPanel(fixture.modal, 'appearance'); + check( + 'activateSettingsPanel switches sidebar and panel state together', + fixture.appearance.button.classList.contains('active') + && !fixture.appearance.panel.classList.contains('hidden') + && !fixture.services.button.classList.contains('active') + && fixture.services.panel.classList.contains('hidden'), + ); + check('getActiveSettingsTab reports the active panel', context.getActiveSettingsTab(fixture.modal) === 'appearance'); + + let activated = null; + let delegated = null; + context.bindSettingsNavigation(fixture.modal, { + openAdminTab(tab) { delegated = tab; return tab === 'system'; }, + onPanelActivated(tab) { activated = tab; }, + }); + fixture.services.button.click(); + check( + 'normal navigation activates locally and notifies the coordinator', + activated === 'services' && fixture.services.button.classList.contains('active'), + ); + activated = null; + fixture.system.button.click(); + check( + 'admin navigation delegates without performing a second local activation', + delegated === 'system' && activated === null && fixture.services.button.classList.contains('active'), + ); + + context.bindSettingsDrag(fixture.modal); + check( + 'drag binding preserves the existing Settings drag contract', + dragCalls.length === 1 + && dragCalls[0][0] === fixture.modal + && dragCalls[0][1].content === fixture.content + && dragCalls[0][1].header === fixture.header + && dragCalls[0][1].enableDock === true + && dragCalls[0][1].skipSelector === 'button, input, select, .theme-opacity-wrap', + ); + + let disconnected = 0; + fixture.modal.classList.add('modal-left-docked'); + fixture.content.style.setProperty('left', '123px'); + fixture.content.dataset._tileZone = 'left'; + fixture.content._leftDockNavObs = { + navObs: { disconnect() { disconnected += 1; } }, + reanchor() {}, + }; + context.resetSettingsWindowPlacement(fixture.modal); + check( + 'window placement reset clears docking observers and inline placement', + !fixture.modal.classList.contains('modal-left-docked') + && dockCalls.some(call => call[0] === 'left' && call[1] === fixture.modal) + && disconnected === 1 + && !('_tileZone' in fixture.content.dataset) + && fixture.content.style.left === undefined + && removedWindowListeners.some(call => call[0] === 'resize'), + ); + + fixture.modal.classList.add('modal-right-docked', 'hidden'); + context.showSettingsModal(fixture.modal); + check( + 'showSettingsModal restores a hidden modal before showing it', + !fixture.modal.classList.contains('hidden') + && !fixture.modal.classList.contains('modal-right-docked') + && dockCalls.some(call => call[0] === 'right'), + ); + + let closeCount = 0; + context.bindSettingsClose(fixture.modal, { + closeSettings() { closeCount += 1; }, + isTouchInsideModal() { return false; }, + }); + + const form = document.createElement('div'); + form.id = 'unified-intg-form'; + form.style.display = ''; + form.appendChild(document.createElement('input')); + fixture.content.appendChild(form); + document.dispatch('keydown', { + key: 'Escape', + preventDefault() {}, + stopPropagation() {}, + }); + check( + 'Escape closes an inner integration editor before closing Settings', + form.style.display === 'none' && form.children.length === 0 && closeCount === 0, + ); + + document.dispatch('keydown', { + key: 'Escape', + preventDefault() {}, + stopPropagation() {}, + }); + check('Escape closes Settings when no nested flow is active', closeCount === 1); + + const popover = document.createElement('div'); + popover.setAttribute('data-popover-open', '1'); + popover.style.display = 'block'; + fixture.content.appendChild(popover); + document.dispatch('keydown', { + key: 'Escape', + preventDefault() {}, + stopPropagation() {}, + }); + check('Escape leaves Settings open while a transient popover is active', closeCount === 1); + popover.classList.add('hidden'); + + context.hideSettingsModal(fixture.modal); + check( + 'hideSettingsModal preserves the closing animation fallback semantics', + fixture.modal.classList.contains('hidden') && !fixture.content.classList.contains('modal-closing'), + ); + + console.log(JSON.stringify(results)); + if (results.some(result => !result.pass)) process.exitCode = 1; +})(); diff --git a/tests/helpers/test_settings_shell_coordinator.mjs b/tests/helpers/test_settings_shell_coordinator.mjs new file mode 100644 index 000000000..64ec7380d --- /dev/null +++ b/tests/helpers/test_settings_shell_coordinator.mjs @@ -0,0 +1,1093 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const REPO = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); + +const JS = path.join(REPO, 'static/js'); +const SETTINGS_JS = path.join(JS, 'settings.js'); + +const REAL_MODULES = new Set([ + SETTINGS_JS, + path.join(JS, 'settings/dom.js'), + path.join(JS, 'settings/registry.js'), + path.join(JS, 'settings/search.js'), + path.join(JS, 'settings/sidebar.js'), + path.join(JS, 'settings/navigation.js'), + path.join(JS, 'settings/lifecycle.js'), +]); + +const realModulesLoaded = new Set(); + + +class ClassList { + constructor(values = []) { + this.values = new Set(values); + } + + add(...names) { + names.filter(Boolean).forEach(name => this.values.add(name)); + } + + remove(...names) { + names.forEach(name => this.values.delete(name)); + } + + contains(name) { + return this.values.has(name); + } + + toggle(name, force) { + const enabled = force === undefined + ? !this.contains(name) + : Boolean(force); + + enabled ? this.add(name) : this.remove(name); + return enabled; + } +} + + +class Style { + constructor() { + this.display = ''; + this.cssText = ''; + this.values = {}; + } + + setProperty(name, value) { + this.values[name] = value; + this[name] = value; + } + + removeProperty(name) { + delete this.values[name]; + delete this[name]; + } +} + + +function dataKey(name) { + return name + .slice(5) + .replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +} + + +function simpleMatch(element, selector) { + const text = String(selector || '').trim(); + if (!text) return false; + + const tag = text.match(/^[a-zA-Z][\w-]*/); + if (tag && element.tagName !== tag[0].toUpperCase()) { + return false; + } + + const id = text.match(/#([\w-]+)/); + if (id && element.id !== id[1]) { + return false; + } + + for (const match of text.matchAll(/\.([\w-]+)/g)) { + if (!element.classList.contains(match[1])) { + return false; + } + } + + for (const match of text.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) { + const actual = element.getAttribute(match[1]); + + if (actual === null) { + return false; + } + + if (match[2] !== undefined && actual !== match[2]) { + return false; + } + } + + return true; +} + + +function descendants(root) { + const result = []; + + function visit(node) { + for (const child of node.children || []) { + result.push(child); + visit(child); + } + } + + visit(root); + return result; +} + + +function queryAll(root, selector) { + const selectors = String(selector) + .split(',') + .map(value => value.trim()) + .filter(Boolean); + + return descendants(root).filter( + node => selectors.some(value => simpleMatch(node, value)), + ); +} + + +class Element { + constructor(tagName = 'div', documentRef = null) { + this.tagName = String(tagName).toUpperCase(); + this.ownerDocument = documentRef; + + this.children = []; + this.parentElement = null; + + this.attributes = {}; + this.dataset = {}; + + this.classList = new ClassList(); + this.style = new Style(); + + this._listeners = new Map(); + + this.value = ''; + this.checked = false; + this.disabled = false; + this.selected = false; + this.hidden = false; + + this.textContent = ''; + this.innerHTML = ''; + + this.options = []; + this.selectedOptions = []; + this.files = []; + + this.scrollTop = 0; + this.scrollHeight = 0; + this.clientHeight = 100; + this.clientWidth = 100; + } + + set id(value) { + this.attributes.id = String(value); + } + + get id() { + return this.attributes.id || ''; + } + + set className(value) { + this.classList = new ClassList( + String(value || '').split(/\s+/).filter(Boolean), + ); + } + + get className() { + return [...this.classList.values].join(' '); + } + + setAttribute(name, value) { + const text = String(value); + this.attributes[name] = text; + + if (name === 'class') { + this.className = text; + } + + if (name.startsWith('data-')) { + this.dataset[dataKey(name)] = text; + } + } + + getAttribute(name) { + return this.attributes[name] ?? null; + } + + removeAttribute(name) { + delete this.attributes[name]; + + if (name.startsWith('data-')) { + delete this.dataset[dataKey(name)]; + } + } + + toggleAttribute(name, force) { + const enabled = force === undefined + ? this.getAttribute(name) === null + : Boolean(force); + + if (enabled) this.setAttribute(name, ''); + else this.removeAttribute(name); + + return enabled; + } + + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } + + append(...children) { + children.forEach(child => this.appendChild(child)); + } + + prepend(...children) { + for (const child of [...children].reverse()) { + child.parentElement = this; + this.children.unshift(child); + } + } + + replaceChildren(...children) { + this.children = []; + this.append(...children); + } + + insertBefore(child, before) { + const index = this.children.indexOf(before); + + if (index === -1) { + return this.appendChild(child); + } + + child.parentElement = this; + this.children.splice(index, 0, child); + return child; + } + + insertAdjacentHTML() {} + + remove() { + if (!this.parentElement) return; + + this.parentElement.children = + this.parentElement.children.filter(child => child !== this); + + this.parentElement = null; + } + + addEventListener(type, handler, options = {}) { + if (!this._listeners.has(type)) { + this._listeners.set(type, []); + } + + this._listeners.get(type).push({ + handler, + once: Boolean(options?.once), + }); + } + + removeEventListener(type, handler) { + const listeners = this._listeners.get(type) || []; + + this._listeners.set( + type, + listeners.filter(entry => entry.handler !== handler), + ); + } + + dispatchEvent(event) { + event.target ||= this; + event.currentTarget = this; + + for (const entry of [...(this._listeners.get(event.type) || [])]) { + entry.handler.call(this, event); + + if (entry.once) { + this.removeEventListener(event.type, entry.handler); + } + } + + return true; + } + + click() { + this.dispatchEvent({ + type: 'click', + target: this, + preventDefault() {}, + stopPropagation() {}, + }); + } + + querySelector(selector) { + const result = queryAll(this, selector)[0]; + + if (result) return result; + + const id = String(selector).match(/^#([\w-]+)$/); + if (id && this.ownerDocument) { + return this.ownerDocument.getElementById(id[1]); + } + + return null; + } + + querySelectorAll(selector) { + return queryAll(this, selector); + } + + matches(selector) { + return String(selector) + .split(',') + .some(value => simpleMatch(this, value)); + } + + closest(selector) { + let node = this; + + while (node) { + if (node.matches(selector)) return node; + node = node.parentElement; + } + + return null; + } + + focus() {} + blur() {} + select() {} + scrollIntoView() {} + setSelectionRange() {} + + getBoundingClientRect() { + return { + top: 0, + left: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + }; + } +} + + +class DocumentShim { + constructor() { + this._generated = new Map(); + this._listeners = new Map(); + + // Pre-existing Settings initializers sometimes inspect an element's + // parent container. Generated fallback elements therefore live under one + // inert root instead of being detached DOM nodes. + this._generatedRoot = new Element('div', this); + + this.readyState = 'loading'; + + this.body = new Element('body', this); + this.head = new Element('head', this); + this.documentElement = new Element('html', this); + } + + createElement(tagName) { + return new Element(tagName, this); + } + + createDocumentFragment() { + return new Element('fragment', this); + } + + addEventListener(type, handler, options = {}) { + if (!this._listeners.has(type)) { + this._listeners.set(type, []); + } + + this._listeners.get(type).push({ + handler, + once: Boolean(options?.once), + }); + } + + removeEventListener(type, handler) { + const listeners = this._listeners.get(type) || []; + + this._listeners.set( + type, + listeners.filter(entry => entry.handler !== handler), + ); + } + + getElementById(id) { + const real = [ + this.body, + this.head, + ...descendants(this.body), + ...descendants(this.head), + ].find(node => node.id === id); + + if (real) return real; + + if (!this._generated.has(id)) { + const generated = new Element('div', this); + generated.id = id; + this._generatedRoot.appendChild(generated); + this._generated.set(id, generated); + } + + return this._generated.get(id); + } + + querySelector(selector) { + const result = this.querySelectorAll(selector)[0]; + if (result) return result; + + const id = String(selector).match(/^#([\w-]+)$/); + return id ? this.getElementById(id[1]) : null; + } + + querySelectorAll(selector) { + return [ + ...queryAll(this.body, selector), + ...queryAll(this.head, selector), + ]; + } + + getElementsByClassName(name) { + return this.querySelectorAll(`.${name}`); + } + + getElementsByTagName(name) { + return this.querySelectorAll(name); + } +} + + +function makeTab(document, nav, panels, name, active = false) { + const button = document.createElement('button'); + button.setAttribute('data-settings-tab', name); + + if (active) { + button.classList.add('active'); + } + + nav.appendChild(button); + + const panel = document.createElement('section'); + panel.setAttribute('data-settings-panel', name); + + if (!active) { + panel.classList.add('hidden'); + } + + panels.appendChild(panel); + + return { button, panel }; +} + + +function buildFixture(document) { + const modal = document.createElement('div'); + modal.id = 'settings-modal'; + modal.classList.add('hidden'); + document.body.appendChild(modal); + + const header = document.createElement('div'); + header.className = 'modal-header'; + modal.appendChild(header); + + const close = document.createElement('button'); + close.className = 'close-btn'; + header.appendChild(close); + + const content = document.createElement('div'); + content.className = 'settings-modal-content modal-content'; + modal.appendChild(content); + + const sidebar = document.createElement('div'); + sidebar.className = 'settings-sidebar'; + content.appendChild(sidebar); + + const sidebarToggle = document.createElement('button'); + sidebarToggle.id = 'settings-sidebar-toggle'; + sidebar.appendChild(sidebarToggle); + + const sidebarHandle = document.createElement('div'); + sidebarHandle.id = 'settings-sidebar-resize-handle'; + sidebar.appendChild(sidebarHandle); + + const sidebarContent = document.createElement('div'); + sidebarContent.className = 'settings-sidebar-content'; + sidebar.appendChild(sidebarContent); + + const finder = document.createElement('div'); + finder.className = 'settings-nav-search-wrap'; + sidebarContent.appendChild(finder); + + const searchInput = document.createElement('input'); + searchInput.id = 'settings-nav-search'; + finder.appendChild(searchInput); + + const searchResults = document.createElement('div'); + searchResults.id = 'settings-nav-search-results'; + searchResults.className = 'settings-nav-search-results'; + searchResults.classList.add('hidden'); + finder.appendChild(searchResults); + + const panels = document.createElement('div'); + panels.className = 'settings-panels'; + content.appendChild(panels); + + const panelIds = [ + 'services', + 'added-models', + 'ai', + 'search', + 'integrations', + 'email', + 'reminders', + 'appearance', + 'shortcuts', + 'account', + 'tools', + 'users', + 'system', + ]; + + const settingsPanels = {}; + + panelIds.forEach((name, index) => { + const button = document.createElement('button'); + button.setAttribute('data-settings-tab', name); + + if (index === 0) { + button.classList.add('active'); + } + + sidebarContent.appendChild(button); + + const panel = document.createElement('section'); + panel.setAttribute('data-settings-panel', name); + + if (index !== 0) { + panel.classList.add('hidden'); + } + + panels.appendChild(panel); + + settingsPanels[name] = { + button, + panel, + }; + }); + + return { + modal, + content, + sidebar, + sidebarToggle, + sidebarHandle, + searchInput, + searchResults, + services: settingsPanels.services, + appearance: settingsPanels.appearance, + ai: settingsPanels.ai, + system: settingsPanels.system, + settingsPanels, + }; +} + + +function functionProxy(overrides = {}) { + return new Proxy(overrides, { + get(target, property) { + if (property in target) { + return target[property]; + } + + return () => []; + }, + }); +} + + +const document = new DocumentShim(); +const fixture = buildFixture(document); + +const sandbox = { + console, + document, + + location: { + search: '', + pathname: '/', + hash: '', + origin: 'http://localhost', + }, + + history: { + replaceState() {}, + }, + + adminModule: null, + + navigator: { + userAgent: 'node-settings-shell-test', + clipboard: { + async writeText() {}, + }, + }, + + CSS: { + escape(value) { + return String(value); + }, + }, + + localStorage: { + getItem() { return null; }, + setItem() {}, + removeItem() {}, + }, + + sessionStorage: { + getItem() { return null; }, + setItem() {}, + removeItem() {}, + }, + + MutationObserver: class { + observe() {} + disconnect() {} + }, + + ResizeObserver: class { + observe() {} + disconnect() {} + }, + + IntersectionObserver: class { + observe() {} + disconnect() {} + }, + + CustomEvent: class { + constructor(type, options = {}) { + this.type = type; + this.detail = options.detail; + } + }, + + Event: class { + constructor(type) { + this.type = type; + } + }, + + Option: class extends Element { + constructor(text = '', value = '') { + super('option', document); + this.textContent = text; + this.value = value; + } + }, + + Image: class extends Element { + constructor() { + super('img', document); + } + }, + + URL, + URLSearchParams, + TextEncoder, + TextDecoder, + AbortController, + + fetch() { + // Keep unrelated asynchronous Settings initializers suspended. The shell + // behavior asserted below is synchronous and intentionally backend-free. + return new Promise(() => {}); + }, + + requestAnimationFrame(callback) { + callback?.(); + return 1; + }, + + cancelAnimationFrame() {}, + + setTimeout(callback, delay) { + // Preserve lifecycle.js's close-animation fallback without introducing + // real delays into the test. + if (delay === 250) { + callback?.(); + } + + return 1; + }, + + clearTimeout() {}, + + setInterval() { + return 1; + }, + + clearInterval() {}, + + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() {}, + + matchMedia() { + return { + matches: false, + addEventListener() {}, + removeEventListener() {}, + }; + }, + + getComputedStyle() { + return {}; + }, + + innerWidth: 1280, + innerHeight: 720, + + alert() {}, + confirm() { return true; }, + prompt() { return ''; }, +}; + +sandbox.window = sandbox; +sandbox.globalThis = sandbox; + +const context = vm.createContext(sandbox); + + +const uiModule = functionProxy({ + esc(value) { + return String(value ?? ''); + }, + + isTouchInsideModal() { + return false; + }, +}); + +const searchModule = functionProxy(); + + +const STUBS = new Map([ + [ + path.join(JS, 'ui.js'), + { default: uiModule }, + ], + [ + path.join(JS, 'search.js'), + { default: searchModule }, + ], + [ + path.join(JS, 'modelSort.js'), + { + sortModelIds(values) { + return values || []; + }, + }, + ], + [ + path.join(JS, 'providers.js'), + { + providerLogo() { + return ''; + }, + }, + ], + [ + path.join(JS, 'platform.js'), + { + isAltGrEvent() { + return false; + }, + }, + ], + [ + path.join(JS, 'escMenuStack.js'), + { + bindMenuDismiss() {}, + }, + ], + [ + path.join(JS, 'windowDrag.js'), + { + makeWindowDraggable() {}, + }, + ], + [ + path.join(JS, 'modalSnap.js'), + { + clearDockSide() {}, + }, + ], +]); + + +const moduleCache = new Map(); + + +function resolveImport(specifier, parent) { + if (!specifier.startsWith('.')) { + throw new Error(`Unexpected non-relative import: ${specifier}`); + } + + return path.resolve( + path.dirname(parent), + specifier, + ); +} + + +function syntheticModule(identifier, exports) { + return new vm.SyntheticModule( + Object.keys(exports), + function initialize() { + for (const [name, value] of Object.entries(exports)) { + this.setExport(name, value); + } + }, + { + context, + identifier, + }, + ); +} + + +async function loadModule(filename) { + const resolved = path.resolve(filename); + + if (moduleCache.has(resolved)) { + return moduleCache.get(resolved); + } + + if (STUBS.has(resolved)) { + const module = syntheticModule( + resolved, + STUBS.get(resolved), + ); + + moduleCache.set(resolved, module); + return module; + } + + if (!REAL_MODULES.has(resolved)) { + throw new Error( + `Unexpected real module requested by coordinator smoke: ${resolved}`, + ); + } + + realModulesLoaded.add(resolved); + + const source = fs.readFileSync(resolved, 'utf8'); + + const module = new vm.SourceTextModule(source, { + context, + identifier: resolved, + + importModuleDynamically: async specifier => { + const target = resolveImport(specifier, resolved); + + if (target === path.join(JS, 'presets.js')) { + const dynamic = syntheticModule(target, { + openCustomPresetModal() {}, + default: {}, + }); + + await dynamic.link(() => {}); + await dynamic.evaluate(); + + return dynamic; + } + + throw new Error( + `Unexpected dynamic import from Settings smoke: ${specifier}`, + ); + }, + }); + + moduleCache.set(resolved, module); + return module; +} + + +async function linker(specifier, referencingModule) { + const target = resolveImport( + specifier, + referencingModule.identifier, + ); + + return loadModule(target); +} + + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + + +// --------------------------------------------------------------------------- +// Load the real settings.js coordinator. +// +// Its new Settings shell dependencies are real files too. Only unrelated +// pre-existing dependencies are synthetic stubs. +// --------------------------------------------------------------------------- + +const coordinator = await loadModule(SETTINGS_JS); + +await coordinator.link(linker); +await coordinator.evaluate(); + +const settings = coordinator.namespace; + +assert( + typeof settings.open === 'function', + 'real settings.js did not export open()', +); + +assert( + typeof settings.close === 'function', + 'real settings.js did not export close()', +); + + +// A broken path/export in any new shell module must fail during native ESM +// linking before these assertions can run. +for (const required of REAL_MODULES) { + assert( + realModulesLoaded.has(required), + `real ESM graph did not load ${path.relative(REPO, required)}`, + ); +} + + +// First open drives initAll() and therefore the real shell bindings. +settings.open('services'); + +assert( + !fixture.modal.classList.contains('hidden'), + 'first open() did not show Settings', +); + +assert( + fixture.services.button.classList.contains('active'), + 'first open("services") did not activate Services', +); + + +// #6040 coordinator integration: initAll() must bind the real finder and +// sidebar controllers, not merely make their modules link successfully. +assert( + (fixture.searchInput._listeners.get('input') || []).length > 0, + 'initAll() did not bind the Settings finder', +); + +assert( + (fixture.sidebarToggle._listeners.get('click') || []).length > 0 + && (fixture.sidebarHandle._listeners.get('pointerdown') || []).length > 0, + 'initAll() did not bind the Settings sidebar controls', +); + +assert( + fixture.sidebar.style.values['--settings-sidebar-width'] === '220px', + 'first coordinator initialization did not apply the 220px sidebar default', +); + + +// Exercise settings.js -> bindSettingsSearch() -> real registry.js. +fixture.searchInput.value = 'theme'; +fixture.searchInput.dispatchEvent({ + type: 'input', + preventDefault() {}, + stopPropagation() {}, +}); + +const coordinatorSearchResult = fixture.searchResults.querySelector( + '[data-settings-search-result]', +); + +assert( + coordinatorSearchResult?.dataset?.settingsSearchResult === 'appearance', + 'coordinator-bound finder did not resolve "theme" to Appearance', +); + + +// Navigation must travel through bindSettingsNavigation() installed by +// initAll(), then invoke settings.js's coordinator callback. +fixture.appearance.button.click(); + +assert( + fixture.appearance.button.classList.contains('active'), + 'navigation click did not activate Appearance', +); + +assert( + !fixture.appearance.panel.classList.contains('hidden'), + 'navigation click did not show Appearance panel', +); + +assert( + document.body.classList.contains('settings-appearance-open'), + 'navigation callback did not apply Appearance coordinator state', +); + + +// Direct public open() after initialization must still coordinate activation. +settings.open('ai'); + +assert( + fixture.ai.button.classList.contains('active'), + 'direct open("ai") did not activate AI', +); + +assert( + !fixture.ai.panel.classList.contains('hidden'), + 'direct open("ai") did not show AI panel', +); + +assert( + !document.body.classList.contains('settings-appearance-open'), + 'direct open("ai") did not clear Appearance coordinator state', +); + + +// Public close() must route through the real lifecycle module. +settings.close(); + +assert( + fixture.modal.classList.contains('hidden'), + 'close() did not hide Settings', +); + +assert( + !document.body.classList.contains('settings-appearance-open'), + 'close() left Appearance coordinator state behind', +); + + +// initAll() starts some existing async panel initializers without awaiting +// them. Give already-ready continuations a chance to run before declaring the +// smoke successful, so late coordinator/setup exceptions still fail the test. +await Promise.resolve(); +await new Promise(resolve => setImmediate(resolve)); + +console.log(JSON.stringify({ + realEsmGraph: true, + initialization: true, + finderBinding: true, + sidebarBinding: true, + navigationCallback: true, + directOpen: true, + directClose: true, +})); diff --git a/tests/test_settings_shell_js_behavior.py b/tests/test_settings_shell_js_behavior.py new file mode 100644 index 000000000..e353972a6 --- /dev/null +++ b/tests/test_settings_shell_js_behavior.py @@ -0,0 +1,117 @@ +"""Behavioral coverage for the modular Settings shell. + +The leaf harness provides focused assertions around the extracted navigation +and lifecycle primitives. The coordinator smoke separately loads the real +settings.js module through Node's native ESM linker together with the real +Settings shell modules, covering their import/export contract and the public +open/close wiring. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent +_LEAF_HELPER = _REPO / "tests" / "helpers" / "test_settings_shell.js" +_COORDINATOR_HELPER = ( + _REPO / "tests" / "helpers" / "test_settings_shell_coordinator.mjs" +) +_HAS_NODE = shutil.which("node") is not None +_STYLE = _REPO / "static" / "style.css" + + +def test_settings_desktop_width_targets_settings_not_cookbook(): + source = _STYLE.read_text(encoding="utf-8") + + settings_rule = re.search( + r"(?ms)^\.settings-modal-content\s*\{[^}]*" + r"width:\s*min\(1040px,\s*94vw\);[^}]*\}", + source, + ) + cookbook_rule = re.search( + r"(?ms)^\.cookbook-edit-modal\s*\{[^}]*" + r"width:\s*min\(720px,\s*92vw\);[^}]*\}", + source, + ) + cookbook_wrong_width = re.search( + r"(?ms)^\.cookbook-edit-modal\s*\{[^}]*" + r"width:\s*min\(1040px,\s*94vw\);[^}]*\}", + source, + ) + + assert settings_rule is not None, ( + "The standalone .settings-modal-content rule must carry the " + "1040px/94vw desktop width" + ) + assert cookbook_rule is not None, ( + "The standalone .cookbook-edit-modal rule must retain its " + "720px/92vw width" + ) + assert cookbook_wrong_width is None, ( + "The Settings desktop width must not leak into Cookbook" + ) + + +def _run_node(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["node", *args], + cwd=str(_REPO), + capture_output=True, + text=True, + encoding="utf-8", + timeout=30, + ) + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_settings_shell_leaf_behavior(): + proc = _run_node(str(_LEAF_HELPER)) + + assert proc.returncode == 0, ( + f"Node execution error:\nSTDERR:\n{proc.stderr}\nSTDOUT:\n{proc.stdout}" + ) + + results = json.loads(proc.stdout.strip()) + + assert results, "Settings shell leaf harness returned no assertions" + + for result in results: + assert result["pass"] is True, ( + f"Failed JS behavioral test: {result['test']}" + + ( + f" ({result.get('detail')})" + if result.get("detail") + else "" + ) + ) + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_settings_shell_real_esm_coordinator(): + proc = _run_node( + "--experimental-vm-modules", + str(_COORDINATOR_HELPER), + ) + + assert proc.returncode == 0, ( + "Real Settings coordinator smoke failed:\n" + f"STDERR:\n{proc.stderr}\n" + f"STDOUT:\n{proc.stdout}" + ) + + result = json.loads(proc.stdout.strip()) + + assert result == { + "realEsmGraph": True, + "initialization": True, + "finderBinding": True, + "sidebarBinding": True, + "navigationCallback": True, + "directOpen": True, + "directClose": True, + }