mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
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
This commit is contained in:
+51
-1
@@ -1421,6 +1421,55 @@
|
||||
</div>
|
||||
<div class="settings-layout">
|
||||
<div class="settings-sidebar">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-sidebar-toggle"
|
||||
id="settings-sidebar-toggle"
|
||||
aria-label="Collapse settings navigation"
|
||||
title="Collapse settings navigation"
|
||||
>
|
||||
<svg class="settings-sidebar-toggle-collapse" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
<svg class="settings-sidebar-toggle-expand" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="settings-sidebar-resize-handle"
|
||||
id="settings-sidebar-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize settings navigation"
|
||||
aria-valuemin="150"
|
||||
aria-valuemax="340"
|
||||
aria-valuenow="220"
|
||||
tabindex="0"
|
||||
></div>
|
||||
|
||||
<div class="settings-sidebar-content">
|
||||
<div class="settings-nav-search-wrap">
|
||||
<svg class="settings-nav-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<path d="M20 20l-4-4"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="settings-nav-search"
|
||||
class="settings-nav-search"
|
||||
placeholder="Find settings…"
|
||||
autocomplete="off"
|
||||
aria-label="Find settings"
|
||||
aria-controls="settings-nav-search-results"
|
||||
/>
|
||||
<div
|
||||
id="settings-nav-search-results"
|
||||
class="settings-nav-search-results hidden"
|
||||
role="listbox"
|
||||
aria-label="Settings search results"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
||||
<button class="settings-nav-item active" data-settings-tab="services">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
@@ -1481,10 +1530,11 @@
|
||||
<span>Users</span>
|
||||
</button>
|
||||
<button class="settings-nav-item admin-only" data-settings-tab="system">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"/></svg>
|
||||
<span>System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panels">
|
||||
|
||||
<!-- ═══ AI TAB ═══ -->
|
||||
|
||||
+81
-148
@@ -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']);
|
||||
|
||||
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.
|
||||
/* ── 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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 openAdminSettingsTab(tab) {
|
||||
if (window.adminModule && typeof window.adminModule.open === 'function') {
|
||||
window.adminModule.open(tab);
|
||||
return 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;
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+261
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user