diff --git a/specs/settings-admin.md b/specs/settings-admin.md index f406867f9..ef468bf35 100644 --- a/specs/settings-admin.md +++ b/specs/settings-admin.md @@ -20,7 +20,7 @@ This spec covers settings and admin surfaces in: - `routes/model_routes.py` for `/api/tools` and settings-bound model endpoint references; - `src/agent_tools/admin_tools.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_index.py` for `manage_settings`; - `src/agent_loop.py` for stale agent prompt references to settings APIs; -- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`; +- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`; - CLI helpers `scripts/odysseus-preset` and `scripts/odysseus-theme`. Generic API integrations are cross-referenced in `integrations.md`. Model endpoint CRUD and endpoint cleanup are covered in `llm-models.md`. Email/contact/calendar legacy setting fallbacks stay with their domain specs. @@ -75,7 +75,7 @@ Admin gates inherit the auth contracts in `auth-security.md`: normal deployments - bundled accessibility font selection such as OpenDyslexic and text-size variable application; - CSS variable application. -`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state. +`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state. Settings panels cover provider/model/search/research/reminder/email/CalDAV/CardDAV/vault, accessibility/font/text-size, scoped tokens, and unified integrations. The hidden legacy fallback editor was removed; no current Settings panel exposes the new foreground fallback keys, so opt-in exists only through owner-scoped preferences/internal callers until a deliberate UI is added. Email OAuth connect preserves the selected SMTP security mode and returns to the Settings surface after callback. `static/js/admin.js` owns user/admin panels, model endpoints, builtin tool toggles, MCP forms, feature toggles, token/webhook panels, diagnostics, backup/import, and danger-zone wipes. @@ -184,7 +184,7 @@ Current targeted coverage includes settings store fallback/error paths, settings - Add diagnostics tests for broader error redaction and sensitive output limits. - Add admin wipe tests for every wipe kind, unknown-kind 400, rollback behavior, and admin gating. - Add vault route tests for session omission, permission setting, login/unlock failures, lock/logout clearing, corrupt config, and admin gates. -- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests. +- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests, and the real-ESM coordinator smoke in `tests/helpers/test_settings_shell_coordinator.mjs` additionally covers the shell's admin-only visibility, admin-controller handoff and Peek fade/clear behavior. - Decide whether `user_templates` and `group_presets` should remain shared despite user-facing names. - Decide whether backup/import should preserve explicit owner fields or force imported owner ownership. -- Continue moving shell/navigation concerns out of the still-large `static/js/settings.js` and `static/js/admin.js` domain boundary without duplicating registry ownership. +- Continue decomposing the still-large `static/js/settings.js` (5,583 lines) and `static/js/admin.js` (4,122 lines) without duplicating registry ownership. The shell and navigation concerns now live in `static/js/settings/`; what remains in `settings.js` is panel code, and `initUnifiedIntegrations()` alone is ~2,140 lines of it. `admin.js` has no extracted shell of its own yet. diff --git a/static/js/settings.js b/static/js/settings.js index d6c13558c..4b1c3c664 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -4,24 +4,18 @@ import uiModule from './ui.js?v=20260916largetoolscroll1'; import searchModule from './search.js'; import { byId } from './settings/dom.js'; -import { - getSettingsRegistryIssues, - isAdminManagedSettingsTab, -} from './settings/registry.js'; +import { getSettingsRegistryIssues } 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 { bindSettingsNavigation } from './settings/navigation.js'; import { bindSettingsDrag, bindSettingsClose, bindOpenPromptModalLink, - showSettingsModal, - hideSettingsModal, } from './settings/lifecycle.js'; +import { bindSettingsPeekToggle } from './settings/peek.js'; +import { createSettingsShell } from './settings/shell.js'; +import { handleSettingsOauthReturn } from './settings/oauthReturn.js'; import { sortModelIds } from './modelSort.js'; import { providerLogo } from './providers.js'; import { isAltGrEvent } from './platform.js'; @@ -65,79 +59,18 @@ function safeRasterDataUrl(raw) { return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : ''; } -/* ── 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'); - - // AI endpoints are intentionally refreshed only when entering the AI panel. - if (tab === 'ai') refreshAiModelEndpoints(); -} - -function openAdminSettingsTab(tab) { - if (window.adminModule && typeof window.adminModule.open === 'function') { - window.adminModule.open(tab); - return true; - } - return false; -} - -/* ── Appearance-tab opacity slider ── - Mirrors the Theme customizer's slider: fades the settings modal's - background (and inner cards) via color-mix so the user can watch the - rest of the UI react to toggles, while keeping text/controls crisp - (no element opacity). Only shown/active on the Appearance tab. */ -const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on -function _applySettingsOpacity(on) { - const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content'); - if (!content) return; - const cards = content.querySelectorAll('.admin-card'); - if (on) { - const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`; - const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`; - content.style.setProperty('background', bgMix, 'important'); - content.style.setProperty('backdrop-filter', 'none', 'important'); - content.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); - cards.forEach(c => { - c.style.setProperty('background', panelMix, 'important'); - c.style.setProperty('backdrop-filter', 'none', 'important'); - c.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); - }); - } else { - content.style.removeProperty('background'); - content.style.removeProperty('backdrop-filter'); - content.style.removeProperty('-webkit-backdrop-filter'); - cards.forEach(c => { - c.style.removeProperty('background'); - c.style.removeProperty('backdrop-filter'); - c.style.removeProperty('-webkit-backdrop-filter'); - }); - } -} - -// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade. -function syncAppearanceOpacity(active) { - const toggle = el('settings-opacity-wrap'); - if (toggle) toggle.classList.toggle('hidden', !active); - if (active) { - _applySettingsOpacity(toggle ? toggle.classList.contains('active') : false); - } else { - _applySettingsOpacity(false); // clear the fade off the Appearance tab - } -} - -function initOpacityToggle() { - const toggle = el('settings-opacity-wrap'); - if (!toggle || toggle.dataset.bound === '1') return; - toggle.dataset.bound = '1'; - toggle.addEventListener('click', () => { - const on = !toggle.classList.contains('active'); - toggle.classList.toggle('active', on); - toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); - _applySettingsOpacity(on); - }); -} +/* ── Settings shell coordination ── + Panel activation side effects, the admin handoff, admin-only visibility and + the public open/close API live in ./settings/shell.js. This coordinator only + supplies the panel-owned pieces the shell needs. */ +const _shell = createSettingsShell({ + getModal: () => modalEl, + ensureInitialized: () => { if (!initialized) initAll(); }, + syncAppearanceCheckboxes: () => syncAppearanceCheckboxes(), + refreshAiModelEndpoints: () => refreshAiModelEndpoints(), + isAdmin: () => !!window._isAdmin, + getAdminModule: () => window.adminModule, +}); /* ═══════════════════════════════════════════ AI TAB @@ -2141,8 +2074,8 @@ function initAll() { modalEl = el('settings-modal'); bindSettingsNavigation(modalEl, { - openAdminTab: openAdminSettingsTab, - onPanelActivated: onSettingsPanelActivated, + openAdminTab: _shell.openAdminTab, + onPanelActivated: _shell.onPanelActivated, }); bindSettingsSearch(modalEl, { @@ -2172,7 +2105,7 @@ function initAll() { closeSettings: close, }); - initOpacityToggle(); + bindSettingsPeekToggle(modalEl); initialized = true; initDefaultChat(); initTeacherModel(); @@ -5635,85 +5568,14 @@ async function initUnifiedIntegrations() { await renderList(); } -/* ── Admin visibility sync ── */ -function syncAdminVisibility() { - if (!modalEl) return; - const isAdmin = !!window._isAdmin; - modalEl.querySelectorAll('.admin-only').forEach(el => { - el.style.display = isAdmin ? '' : 'none'; - }); -} - /* ═══════════════════════════════════════════ PUBLIC API ═══════════════════════════════════════════ */ -export function open(tab) { - if (!initialized) initAll(); +export function open(tab) { _shell.open(tab); } +export function close() { _shell.close(); } +export function syncAdminVisibility() { _shell.syncAdminVisibility(); } - syncAppearanceCheckboxes(); - showSettingsModal(modalEl); - syncAdminVisibility(); - - if (tab) { - activateSettingsPanel(modalEl, tab); - } - - // 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 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); - - hideSettingsModal(modalEl); -} - -// Handle redirect back from Google OAuth2 — open settings to integrations and show status. -(function _handleOauthRedirect() { - const sp = new URLSearchParams(window.location.search); - if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return; - // Strip params from URL without a page reload. - const clean = window.location.pathname + window.location.hash; - window.history.replaceState(null, '', clean); - const success = sp.has('email_oauth_success'); - const errMsg = sp.get('email_oauth_error') || ''; - // Open settings → integrations once the document is ready. This module owns - // the open() API, so it does not need to wait for a window-level alias. - function _showResult() { - open('integrations'); - // Brief toast-style banner. - const banner = document.createElement('div'); - banner.textContent = success - ? 'Google account connected — email is ready' - : `Google OAuth failed: ${errMsg || 'unknown error'}`; - Object.assign(banner.style, { - position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)', - background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)', - color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px', - fontWeight: '600', zIndex: '99999', pointerEvents: 'none', - boxShadow: '0 2px 12px rgba(0,0,0,0.3)', - }); - document.body.appendChild(banner); - setTimeout(() => banner.remove(), 4000); - } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', _showResult, { once: true }); - } else { - _showResult(); - } -})(); +handleSettingsOauthReturn({ openSettings: open }); const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints }; diff --git a/static/js/settings/oauthReturn.js b/static/js/settings/oauthReturn.js new file mode 100644 index 000000000..cac163482 --- /dev/null +++ b/static/js/settings/oauthReturn.js @@ -0,0 +1,47 @@ +// Return path from the Google OAuth2 redirect. +// +// The provider sends the browser back to the app root with a result in the +// query string, so something has to notice on load and put the user back where +// they were — the Integrations panel — with the outcome visible. That is +// navigation, not an Integrations concern, and it runs once per page load +// rather than once per panel open. + +/** + * @param {object} options + * @param {(tab: string) => void} options.openSettings the Settings open() entry point + */ +export function handleSettingsOauthReturn({ openSettings } = {}) { + const sp = new URLSearchParams(window.location.search); + if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return; + // Strip params from URL without a page reload. + const clean = window.location.pathname + window.location.hash; + window.history.replaceState(null, '', clean); + const success = sp.has('email_oauth_success'); + const errMsg = sp.get('email_oauth_error') || ''; + + // The caller passes its own open() in, so this never has to wait for a + // window-level alias to exist. + function _showResult() { + openSettings('integrations'); + // Brief toast-style banner. + const banner = document.createElement('div'); + banner.textContent = success + ? 'Google account connected — email is ready' + : `Google OAuth failed: ${errMsg || 'unknown error'}`; + Object.assign(banner.style, { + position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)', + background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)', + color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px', + fontWeight: '600', zIndex: '99999', pointerEvents: 'none', + boxShadow: '0 2px 12px rgba(0,0,0,0.3)', + }); + document.body.appendChild(banner); + setTimeout(() => banner.remove(), 4000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', _showResult, { once: true }); + } else { + _showResult(); + } +} diff --git a/static/js/settings/peek.js b/static/js/settings/peek.js new file mode 100644 index 000000000..1ae1547be --- /dev/null +++ b/static/js/settings/peek.js @@ -0,0 +1,68 @@ +// Settings window peek (transparency) chrome. +// +// The Appearance panel lets the user watch the rest of the UI react to a toggle +// without closing Settings. That is window chrome, not panel data, so it lives +// here rather than with the Appearance panel's checkbox handling: the fade is +// applied to the Settings window's own background via color-mix — never element +// opacity, so controls and text stay crisp — and it has to be cleared the +// moment the user leaves Appearance or closes the window. Mirrors the Theme +// customizer's slider. + +import { byId } from './dom.js'; + +const PEEK_OPACITY = 55; // % opacity when the Peek toggle is on +const TOGGLE_ID = 'settings-opacity-wrap'; + +function _toggleEl() { + return byId(TOGGLE_ID); +} + +export function applySettingsPeek(modalEl, on) { + const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content'); + if (!content) return; + const cards = content.querySelectorAll('.admin-card'); + if (on) { + const bgMix = `color-mix(in srgb, var(--bg) ${PEEK_OPACITY}%, transparent)`; + const panelMix = `color-mix(in srgb, var(--panel) ${PEEK_OPACITY}%, transparent)`; + content.style.setProperty('background', bgMix, 'important'); + content.style.setProperty('backdrop-filter', 'none', 'important'); + content.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); + cards.forEach(c => { + c.style.setProperty('background', panelMix, 'important'); + c.style.setProperty('backdrop-filter', 'none', 'important'); + c.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); + }); + } else { + content.style.removeProperty('background'); + content.style.removeProperty('backdrop-filter'); + content.style.removeProperty('-webkit-backdrop-filter'); + cards.forEach(c => { + c.style.removeProperty('background'); + c.style.removeProperty('backdrop-filter'); + c.style.removeProperty('-webkit-backdrop-filter'); + }); + } +} + +// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade. +export function syncSettingsPeek(modalEl, active) { + const toggle = _toggleEl(); + if (toggle) toggle.classList.toggle('hidden', !active); + if (active) { + applySettingsPeek(modalEl, toggle ? toggle.classList.contains('active') : false); + } else { + applySettingsPeek(modalEl, false); // clear the fade off the Appearance tab + } +} + +export function bindSettingsPeekToggle(modalEl) { + const toggle = _toggleEl(); + if (!toggle || toggle.dataset.bound === '1') return; + toggle.dataset.bound = '1'; + toggle.addEventListener('click', () => { + const on = !toggle.classList.contains('active'); + toggle.classList.toggle('active', on); + toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); + applySettingsPeek(modalEl, on); + }); +} diff --git a/static/js/settings/shell.js b/static/js/settings/shell.js new file mode 100644 index 000000000..2c1700ca9 --- /dev/null +++ b/static/js/settings/shell.js @@ -0,0 +1,105 @@ +// Settings shell coordination. +// +// settings.js still owns every panel's data loading. This module owns the shell +// around them: what happens when a panel becomes active, where an +// admin-managed tab is handed off to admin.js, which elements are admin-only, +// and the public open/close entry points. +// +// It keeps no panel state. Everything it needs from the panels arrives through +// the options below, so panels can move out of settings.js one at a time +// without this file changing — and so the shell's behavior is testable without +// booting a panel. + +import { activateSettingsPanel, getActiveSettingsTab } from './navigation.js'; +import { showSettingsModal, hideSettingsModal } from './lifecycle.js'; +import { isAdminManagedSettingsTab } from './registry.js'; +import { syncSettingsPeek } from './peek.js'; + +/** + * @param {object} options + * @param {() => Element|null} options.getModal the Settings modal, once initialized + * @param {() => void} options.ensureInitialized first-open panel initialization + * @param {() => void} options.syncAppearanceCheckboxes Appearance panel state refresh + * @param {() => void} options.refreshAiModelEndpoints AI panel endpoint refresh + * @param {() => boolean} options.isAdmin current admin status + * @param {() => object|null} options.getAdminModule the lazily loaded admin module + */ +export function createSettingsShell(options = {}) { + const getModal = options.getModal; + const ensureInitialized = options.ensureInitialized; + const syncAppearanceCheckboxes = options.syncAppearanceCheckboxes; + const refreshAiModelEndpoints = options.refreshAiModelEndpoints; + const isAdmin = options.isAdmin; + const getAdminModule = options.getAdminModule; + + const _modal = () => (typeof getModal === 'function' ? getModal() : null); + const _admin = () => (typeof getAdminModule === 'function' ? getAdminModule() : null); + + function onPanelActivated(tab) { + // Appearance keeps its existing transparent preview behavior. + document.body.classList.toggle('settings-appearance-open', tab === 'appearance'); + syncSettingsPeek(_modal(), tab === 'appearance'); + + // AI endpoints are intentionally refreshed only when entering the AI panel. + if (tab === 'ai' && typeof refreshAiModelEndpoints === 'function') { + refreshAiModelEndpoints(); + } + } + + function openAdminTab(tab) { + const admin = _admin(); + if (admin && typeof admin.open === 'function') { + admin.open(tab); + return true; + } + return false; + } + + function syncAdminVisibility() { + const modalEl = _modal(); + if (!modalEl) return; + const admin = typeof isAdmin === 'function' ? !!isAdmin() : false; + modalEl.querySelectorAll('.admin-only').forEach(el => { + el.style.display = admin ? '' : 'none'; + }); + } + + function open(tab) { + if (typeof ensureInitialized === 'function') ensureInitialized(); + + if (typeof syncAppearanceCheckboxes === 'function') syncAppearanceCheckboxes(); + + const modalEl = _modal(); + showSettingsModal(modalEl); + syncAdminVisibility(); + + if (tab) { + activateSettingsPanel(modalEl, tab); + } + + // 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); + onPanelActivated(activeTab); + + // Auto-init admin data if showing an admin tab. + const admin = _admin(); + if (isAdminManagedSettingsTab(activeTab) && admin && !admin._initialized) { + admin._initData(); + } + } + + function close() { + const modalEl = _modal(); + if (!modalEl) return; + + // 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'); + syncSettingsPeek(modalEl, false); + + hideSettingsModal(modalEl); + } + + return { open, close, onPanelActivated, openAdminTab, syncAdminVisibility }; +} diff --git a/tests/helpers/test_settings_shell_coordinator.mjs b/tests/helpers/test_settings_shell_coordinator.mjs index 458f0516a..6607ecddb 100644 --- a/tests/helpers/test_settings_shell_coordinator.mjs +++ b/tests/helpers/test_settings_shell_coordinator.mjs @@ -20,6 +20,9 @@ const REAL_MODULES = new Set([ path.join(JS, 'settings/sidebar.js'), path.join(JS, 'settings/navigation.js'), path.join(JS, 'settings/lifecycle.js'), + path.join(JS, 'settings/shell.js'), + path.join(JS, 'settings/peek.js'), + path.join(JS, 'settings/oauthReturn.js'), path.join(JS, 'searchProviderIcons.js'), ]); @@ -497,6 +500,11 @@ function buildFixture(document) { header.className = 'modal-header'; modal.appendChild(header); + const peekToggle = document.createElement('button'); + peekToggle.id = 'settings-opacity-wrap'; + peekToggle.className = 'theme-opacity-wrap theme-opacity-toggle hidden'; + header.appendChild(peekToggle); + const close = document.createElement('button'); close.className = 'close-btn'; header.appendChild(close); @@ -539,6 +547,14 @@ function buildFixture(document) { panels.className = 'settings-panels'; content.appendChild(panels); + const adminCard = document.createElement('div'); + adminCard.className = 'admin-card'; + panels.appendChild(adminCard); + + const adminOnly = document.createElement('div'); + adminOnly.className = 'admin-only'; + adminCard.appendChild(adminOnly); + const panelIds = [ 'services', 'added-models', @@ -590,6 +606,9 @@ function buildFixture(document) { sidebarHandle, searchInput, searchResults, + peekToggle, + adminCard, + adminOnly, services: settingsPanels.services, appearance: settingsPanels.appearance, ai: settingsPanels.ai, @@ -994,6 +1013,14 @@ assert( ); +// shell.js owns admin-only visibility. A non-admin must not merely see an +// unpopulated admin control — the element has to be hidden on every open(). +assert( + fixture.adminOnly.style.display === 'none', + 'open() did not hide .admin-only for a non-admin', +); + + // #6040 coordinator integration: initAll() must bind the real finder and // sidebar controllers, not merely make their modules link successfully. assert( @@ -1050,6 +1077,28 @@ assert( 'navigation callback did not apply Appearance coordinator state', ); +assert( + !fixture.peekToggle.classList.contains('hidden'), + 'Appearance activation did not reveal the Peek toggle', +); + + +// peek.js fades the window background via color-mix, never element opacity, so +// the controls stay readable while the user previews the page behind Settings. +fixture.peekToggle.click(); + +assert( + fixture.content.style.values.background + === 'color-mix(in srgb, var(--bg) 55%, transparent)', + 'Peek toggle did not fade the Settings window background', +); + +assert( + fixture.adminCard.style.values.background + === 'color-mix(in srgb, var(--panel) 55%, transparent)', + 'Peek toggle did not fade the Settings cards', +); + // Direct public open() after initialization must still coordinate activation. settings.open('ai'); @@ -1069,6 +1118,19 @@ assert( 'direct open("ai") did not clear Appearance coordinator state', ); +// Leaving Appearance with Peek still toggled on must not leave the rest of +// Settings faded — this is the bug the sync exists to prevent. +assert( + fixture.content.style.values.background === undefined + && fixture.adminCard.style.values.background === undefined, + 'leaving Appearance left the Peek fade applied', +); + +assert( + fixture.peekToggle.classList.contains('hidden'), + 'leaving Appearance left the Peek toggle visible', +); + // Public close() must route through the real lifecycle module. settings.close(); @@ -1084,6 +1146,44 @@ assert( ); +// shell.js hands an admin-managed tab to admin.js and must not then perform a +// second local activation. Nothing before this point installs an admin module, +// so the earlier assertions covered the no-admin-module fallback. +const adminCalls = []; + +sandbox.adminModule = { + open(tab) { + adminCalls.push(tab); + return true; + }, + _initData() { + adminCalls.push('_initData'); + }, +}; + +fixture.settingsPanels.users.button.click(); + +assert( + adminCalls.length === 1 && adminCalls[0] === 'users', + `admin tab click did not hand "users" to the admin module: ${adminCalls}`, +); + +assert( + !fixture.settingsPanels.users.button.classList.contains('active'), + 'shell activated an admin tab locally after the admin module claimed it', +); + + +// Admin status is read per open(), not cached at initialization. +sandbox._isAdmin = true; +settings.open('services'); + +assert( + fixture.adminOnly.style.display === '', + 'open() did not reveal .admin-only for an admin', +); + + // 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. @@ -1098,4 +1198,7 @@ console.log(JSON.stringify({ navigationCallback: true, directOpen: true, directClose: true, + peekChrome: true, + adminVisibility: true, + adminTabHandoff: true, })); diff --git a/tests/test_email_oauth_settings_redirect.py b/tests/test_email_oauth_settings_redirect.py index f7d588132..110fbb67f 100644 --- a/tests/test_email_oauth_settings_redirect.py +++ b/tests/test_email_oauth_settings_redirect.py @@ -4,16 +4,24 @@ from pathlib import Path _REPO = Path(__file__).resolve().parents[1] +_HANDLER = _REPO / "static" / "js" / "settings" / "oauthReturn.js" +_COORDINATOR = _REPO / "static" / "js" / "settings.js" def test_oauth_redirect_uses_the_module_local_settings_api(): - source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8") - handler = source[ - source.index("(function _handleOauthRedirect"): - source.index("const settingsModule =") - ] + handler = _HANDLER.read_text(encoding="utf-8") - assert "open('integrations');" in handler + assert "openSettings('integrations');" in handler assert "window.settingsModule" not in handler assert "window.__odysseusAppStarted" not in handler assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" in handler + + +def test_settings_coordinator_hands_the_handler_its_own_open(): + source = _COORDINATOR.read_text(encoding="utf-8") + + # The handler only stays window-free if the coordinator passes its own + # export in. A stray `window.settingsModule` here would reintroduce the + # load-order dependency the handler was written to avoid. + assert "handleSettingsOauthReturn({ openSettings: open });" in source + assert "from './settings/oauthReturn.js'" in source diff --git a/tests/test_settings_shell_js_behavior.py b/tests/test_settings_shell_js_behavior.py index e353972a6..af27557cc 100644 --- a/tests/test_settings_shell_js_behavior.py +++ b/tests/test_settings_shell_js_behavior.py @@ -114,4 +114,7 @@ def test_settings_shell_real_esm_coordinator(): "navigationCallback": True, "directOpen": True, "directClose": True, + "peekChrome": True, + "adminVisibility": True, + "adminTabHandoff": True, }