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,
+ }