mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-25 07:12:20 +01:00
Merge pull request #24 from o3LL/refactor/settings-shell-modules
refactor(settings): move the shell out of settings.js into settings/
This commit is contained in:
@@ -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;
|
- `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_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;
|
- `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`.
|
- 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.
|
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;
|
- bundled accessibility font selection such as OpenDyslexic and text-size variable application;
|
||||||
- CSS 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.
|
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 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 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 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 `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.
|
- 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.
|
||||||
|
|||||||
+24
-162
@@ -9,24 +9,18 @@ import { initTtsSettings, initSttSettings } from './settings/speech.js';
|
|||||||
import { initDocumentWritingStyle } from './settings/writingStyle.js';
|
import { initDocumentWritingStyle } from './settings/writingStyle.js';
|
||||||
import { initImageSettings } from './settings/imageModels.js';
|
import { initImageSettings } from './settings/imageModels.js';
|
||||||
import { initAgentSettings } from './settings/agent.js';
|
import { initAgentSettings } from './settings/agent.js';
|
||||||
import {
|
import { getSettingsRegistryIssues } from './settings/registry.js';
|
||||||
getSettingsRegistryIssues,
|
|
||||||
isAdminManagedSettingsTab,
|
|
||||||
} from './settings/registry.js';
|
|
||||||
import { bindSettingsSearch } from './settings/search.js';
|
import { bindSettingsSearch } from './settings/search.js';
|
||||||
import { bindSettingsSidebar } from './settings/sidebar.js';
|
import { bindSettingsSidebar } from './settings/sidebar.js';
|
||||||
import {
|
import { bindSettingsNavigation } from './settings/navigation.js';
|
||||||
activateSettingsPanel,
|
|
||||||
getActiveSettingsTab,
|
|
||||||
bindSettingsNavigation,
|
|
||||||
} from './settings/navigation.js';
|
|
||||||
import {
|
import {
|
||||||
bindSettingsDrag,
|
bindSettingsDrag,
|
||||||
bindSettingsClose,
|
bindSettingsClose,
|
||||||
bindOpenPromptModalLink,
|
bindOpenPromptModalLink,
|
||||||
showSettingsModal,
|
|
||||||
hideSettingsModal,
|
|
||||||
} from './settings/lifecycle.js';
|
} 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 { sortModelIds } from './modelSort.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { isAltGrEvent } from './platform.js';
|
import { isAltGrEvent } from './platform.js';
|
||||||
@@ -58,79 +52,18 @@ function safeRasterDataUrl(raw) {
|
|||||||
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Settings shell coordination ── */
|
/* ── Settings shell coordination ──
|
||||||
function onSettingsPanelActivated(tab) {
|
Panel activation side effects, the admin handoff, admin-only visibility and
|
||||||
// Appearance keeps its existing transparent preview behavior.
|
the public open/close API live in ./settings/shell.js. This coordinator only
|
||||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
supplies the panel-owned pieces the shell needs. */
|
||||||
syncAppearanceOpacity(tab === 'appearance');
|
const _shell = createSettingsShell({
|
||||||
|
getModal: () => modalEl,
|
||||||
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
ensureInitialized: () => { if (!initialized) initAll(); },
|
||||||
if (tab === 'ai') refreshAiModelEndpoints();
|
syncAppearanceCheckboxes: () => syncAppearanceCheckboxes(),
|
||||||
}
|
refreshAiModelEndpoints: () => refreshAiModelEndpoints(),
|
||||||
|
isAdmin: () => !!window._isAdmin,
|
||||||
function openAdminSettingsTab(tab) {
|
getAdminModule: () => window.adminModule,
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
/* ═══════════════════════════════════════════
|
||||||
AI TAB
|
AI TAB
|
||||||
@@ -1796,8 +1729,8 @@ function initAll() {
|
|||||||
modalEl = el('settings-modal');
|
modalEl = el('settings-modal');
|
||||||
|
|
||||||
bindSettingsNavigation(modalEl, {
|
bindSettingsNavigation(modalEl, {
|
||||||
openAdminTab: openAdminSettingsTab,
|
openAdminTab: _shell.openAdminTab,
|
||||||
onPanelActivated: onSettingsPanelActivated,
|
onPanelActivated: _shell.onPanelActivated,
|
||||||
});
|
});
|
||||||
|
|
||||||
bindSettingsSearch(modalEl, {
|
bindSettingsSearch(modalEl, {
|
||||||
@@ -1827,7 +1760,7 @@ function initAll() {
|
|||||||
closeSettings: close,
|
closeSettings: close,
|
||||||
});
|
});
|
||||||
|
|
||||||
initOpacityToggle();
|
bindSettingsPeekToggle(modalEl);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
initDefaultChat();
|
initDefaultChat();
|
||||||
initTeacherModel();
|
initTeacherModel();
|
||||||
@@ -5236,85 +5169,14 @@ async function initUnifiedIntegrations() {
|
|||||||
await renderList();
|
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
|
PUBLIC API
|
||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
export function open(tab) {
|
export function open(tab) { _shell.open(tab); }
|
||||||
if (!initialized) initAll();
|
export function close() { _shell.close(); }
|
||||||
|
function syncAdminVisibility() { _shell.syncAdminVisibility(); }
|
||||||
|
|
||||||
syncAppearanceCheckboxes();
|
handleSettingsOauthReturn({ openSettings: open });
|
||||||
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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ const REAL_MODULES = new Set([
|
|||||||
path.join(JS, 'settings/writingStyle.js'),
|
path.join(JS, 'settings/writingStyle.js'),
|
||||||
path.join(JS, 'settings/imageModels.js'),
|
path.join(JS, 'settings/imageModels.js'),
|
||||||
path.join(JS, 'settings/agent.js'),
|
path.join(JS, 'settings/agent.js'),
|
||||||
|
path.join(JS, 'settings/shell.js'),
|
||||||
|
path.join(JS, 'settings/peek.js'),
|
||||||
|
path.join(JS, 'settings/oauthReturn.js'),
|
||||||
path.join(JS, 'searchProviderIcons.js'),
|
path.join(JS, 'searchProviderIcons.js'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -502,6 +505,11 @@ function buildFixture(document) {
|
|||||||
header.className = 'modal-header';
|
header.className = 'modal-header';
|
||||||
modal.appendChild(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');
|
const close = document.createElement('button');
|
||||||
close.className = 'close-btn';
|
close.className = 'close-btn';
|
||||||
header.appendChild(close);
|
header.appendChild(close);
|
||||||
@@ -544,6 +552,14 @@ function buildFixture(document) {
|
|||||||
panels.className = 'settings-panels';
|
panels.className = 'settings-panels';
|
||||||
content.appendChild(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 = [
|
const panelIds = [
|
||||||
'services',
|
'services',
|
||||||
'added-models',
|
'added-models',
|
||||||
@@ -595,6 +611,9 @@ function buildFixture(document) {
|
|||||||
sidebarHandle,
|
sidebarHandle,
|
||||||
searchInput,
|
searchInput,
|
||||||
searchResults,
|
searchResults,
|
||||||
|
peekToggle,
|
||||||
|
adminCard,
|
||||||
|
adminOnly,
|
||||||
services: settingsPanels.services,
|
services: settingsPanels.services,
|
||||||
appearance: settingsPanels.appearance,
|
appearance: settingsPanels.appearance,
|
||||||
ai: settingsPanels.ai,
|
ai: settingsPanels.ai,
|
||||||
@@ -999,6 +1018,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
|
// #6040 coordinator integration: initAll() must bind the real finder and
|
||||||
// sidebar controllers, not merely make their modules link successfully.
|
// sidebar controllers, not merely make their modules link successfully.
|
||||||
assert(
|
assert(
|
||||||
@@ -1055,6 +1082,28 @@ assert(
|
|||||||
'navigation callback did not apply Appearance coordinator state',
|
'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.
|
// Direct public open() after initialization must still coordinate activation.
|
||||||
settings.open('ai');
|
settings.open('ai');
|
||||||
@@ -1074,6 +1123,19 @@ assert(
|
|||||||
'direct open("ai") did not clear Appearance coordinator state',
|
'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.
|
// Public close() must route through the real lifecycle module.
|
||||||
settings.close();
|
settings.close();
|
||||||
@@ -1089,6 +1151,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
|
// initAll() starts some existing async panel initializers without awaiting
|
||||||
// them. Give already-ready continuations a chance to run before declaring the
|
// them. Give already-ready continuations a chance to run before declaring the
|
||||||
// smoke successful, so late coordinator/setup exceptions still fail the test.
|
// smoke successful, so late coordinator/setup exceptions still fail the test.
|
||||||
@@ -1103,4 +1203,7 @@ console.log(JSON.stringify({
|
|||||||
navigationCallback: true,
|
navigationCallback: true,
|
||||||
directOpen: true,
|
directOpen: true,
|
||||||
directClose: true,
|
directClose: true,
|
||||||
|
peekChrome: true,
|
||||||
|
adminVisibility: true,
|
||||||
|
adminTabHandoff: true,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,16 +4,24 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
_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():
|
def test_oauth_redirect_uses_the_module_local_settings_api():
|
||||||
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
handler = _HANDLER.read_text(encoding="utf-8")
|
||||||
handler = source[
|
|
||||||
source.index("(function _handleOauthRedirect"):
|
|
||||||
source.index("const settingsModule =")
|
|
||||||
]
|
|
||||||
|
|
||||||
assert "open('integrations');" in handler
|
assert "openSettings('integrations');" in handler
|
||||||
assert "window.settingsModule" not in handler
|
assert "window.settingsModule" not in handler
|
||||||
assert "window.__odysseusAppStarted" not in handler
|
assert "window.__odysseusAppStarted" not in handler
|
||||||
assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" 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
|
||||||
|
|||||||
@@ -113,4 +113,7 @@ def test_settings_shell_real_esm_coordinator():
|
|||||||
"navigationCallback": True,
|
"navigationCallback": True,
|
||||||
"directOpen": True,
|
"directOpen": True,
|
||||||
"directClose": True,
|
"directClose": True,
|
||||||
|
"peekChrome": True,
|
||||||
|
"adminVisibility": True,
|
||||||
|
"adminTabHandoff": True,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user