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:
Alexandre Teixeira
2026-08-16 02:48:19 +01:00
committed by GitHub
parent 0dd70a7556
commit cee319050c
12 changed files with 3267 additions and 154 deletions
+51 -1
View File
@@ -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
View File
@@ -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.
+7
View File
@@ -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);
}
+176
View File
@@ -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');
}
+57
View File
@@ -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);
}
});
});
}
+237
View File
@@ -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;
}
+172
View File
@@ -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 };
}
+238
View File
@@ -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
View File
@@ -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;
}
}
+775
View File
@@ -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
+117
View File
@@ -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,
}