diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05df59f..f73d714 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# Changelog
+## [0.37.13] — 2026-03-22
+
+### Summary
+
+Scorched Earth III — third dead-code purge. 5 files deleted (−2,269 net lines).
+The massive `ui-primitives.js` (1,133 LOC) is gone; its few remaining consumers
+(`debug.js`, `switchboard-sdk.js`) received inlined replacements. Theme management
+migrated from the global `Theme` object to the Preact SDK's `createTheme` module.
+SDK slimmed by removing 6 dead factory wrappers.
+
+### Removed
+
+- `src/js/ui-primitives.js` (1,133 LOC) — `esc()`, `createComponentRegistry()`,
+ `componentMixin()`, `Providers`, `Roles`, `showConfirm()`, `showPrompt()`,
+ `openModal()`, `closeModal()`, `Theme`, render helpers. All consumers deleted
+ or inlined.
+- `src/js/code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor, zero consumers
+- `src/js/file-tree.js` (290 LOC) — workspace file browser widget, zero consumers
+- `src/js/user-menu.js` (206 LOC) — vanilla flyout menu; all surfaces use
+ Preact `sw/shell/user-menu.js`
+- `src/js/app-state.js` (139 LOC) — global `window.App` state; Preact surfaces
+ use local state hooks; `debug.js` state snapshot already guards with `typeof`
+
+### Changed
+
+- `src/js/debug.js` — inlined `openModal()`, `closeModal()`, `showConfirm()`,
+ `_escHtml()` (~44 LOC) to replace dependency on deleted `ui-primitives.js`
+- `src/js/switchboard-sdk.js` — removed 6 dead factory wrappers (`sw.notes()`,
+ `sw.panels()`, `sw.fileTree()`, `sw.codeEditor()`, `sw.layout()`,
+ `sw.userMenu()` + `_hydrateUserMenu()`); replaced global `Theme` with
+ `import { createTheme } from './sw/sdk/theme.js'`; inlined `esc()` and
+ `openModal`/`closeModal` helpers; net −213 LOC
+- `server/pages/templates/base.html` — removed 5 script tags (`app-state.js`,
+ `ui-primitives.js`, `user-menu.js`, `file-tree.js`, `code-editor.js`);
+ consolidated removal comments
+- `src/sw.js` — removed 5 entries from `SHELL_FILES` cache manifest
+
+### Design Notes
+
+- **6 old JS files survive:** `sb.js` (debug modal onclick handlers),
+ `events.js` (extension bridge), `switchboard-sdk.js` (extension compatibility),
+ `debug.js` + `repl.js` (active tooling), `workflow-surfaces.js` (deferred to
+ Preact rewrite). Targeted for removal as extensions migrate.
+- **`user-menu.css` kept:** extension surface template still renders server-side
+ user-menu HTML; CSS provides styling for the placeholder.
+- **Cumulative scorched earth:** 49 files deleted, ~−17,710 lines across three
+ passes (v0.37.10, v0.37.12, v0.37.13).
+
+---
+
## [0.37.12] — 2026-03-22
### Summary
diff --git a/VERSION b/VERSION
index f0e92bc..fa6037a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.37.12
+0.37.13
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index aea58a9..47d00c9 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -48,8 +48,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
│ v0.37.2 Primitives ✅ │
│ v0.37.3 SDK │
│ v0.37.4 Shell │
- │ v0.37.5–12 Surfaces ✅ │
- │ v0.37.13–18 Surfaces │
+ │ v0.37.5–13 Surfaces ✅ │
+ │ v0.37.14–18 Surfaces │
│ │ │
══════╪════════════╪═════════════════╪══════
│ MVP v0.50.0 │
@@ -637,10 +637,10 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| v0.37.10 | Chat surface ✅ | Scorched earth: 21 files deleted (−12,841 net), Preact surface + ChatPane kit upgrades |
| v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix |
| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned |
-| v0.37.13 | Workflow surfaces | Form renderer, review views |
-| v0.37.14 | Pane audit | Collaborative walkthrough: each pane checked for form and function |
-| v0.37.15 | Projects surface | Project views, note/channel associations |
-| v0.37.16 | Workflow assignment | Workflow routing, assignment UX |
+| v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (−2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact |
+| v0.37.14 | Pane audit | Collaborative walkthrough: core features + theming checked |
+| v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) |
+| v0.37.16 | Projects surface | Project views, note/channel associations (design-first) |
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI |
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified |
@@ -695,15 +695,39 @@ Kept (11 JS files — extension surface + debug dependencies):
`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`,
`repl.js`, `workflow-surfaces.js`
+**v0.37.13 — Scorched Earth III ✅:**
+
+Third dead-code purge. 5 files deleted (−2,269 net lines). The 1,133-line
+`ui-primitives.js` is gone; Theme management migrated to Preact SDK module.
+SDK slimmed by removing 6 dead factory wrappers.
+
+Deleted (5 files):
+- [x] `ui-primitives.js` (1,133 LOC) — `esc`, component lifecycle, Providers,
+ Roles, modal/confirm/prompt, Theme, render helpers
+- [x] `code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor
+- [x] `file-tree.js` (290 LOC) — workspace file browser
+- [x] `user-menu.js` (206 LOC) — vanilla flyout menu
+- [x] `app-state.js` (139 LOC) — global `window.App` state
+- [x] 5 script tags removed from `base.html`
+- [x] 5 entries removed from `sw.js` SHELL_FILES
+- [x] `switchboard-sdk.js` slimmed: 6 dead wrappers removed, Theme→Preact SDK
+
+Kept (6 JS files — extension + debug dependencies):
+`sb.js`, `events.js`, `switchboard-sdk.js`, `debug.js`, `repl.js`,
+`workflow-surfaces.js`
+
+Cumulative scorched earth: 49 files, ~−17,710 lines (v0.37.10 + v0.37.12 + v0.37.13)
+
**v0.37.14 — Pane Audit:**
-Collaborative walkthrough of each pane (ChatPane, NotesPane) and surface
-checking form and function. Fixes discovered during the audit are applied
-in-place. No new architecture — just making everything work correctly.
+Collaborative walkthrough of core features and theming. Each pane and surface
+checked for form, function, and visual consistency. Fixes applied in-place.
+No new architecture — making everything work correctly.
- [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks
- [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note
- [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts
+- [ ] Theming: light/dark mode consistency audit across all surfaces
- [ ] Kit enhancement backlog triage (above items evaluated for inclusion)
**v0.37.15 — Projects Surface:**
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index d2977bf..faee5ee 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -93,34 +93,22 @@
-
- {{/* api.js removed in v0.37.10. Extension surfaces using Switchboard.init()
- should migrate to the Preact SDK (sw/sdk/). */}}
+ {{/* v0.37.13 Scorched Earth III: app-state.js, ui-primitives.js,
+ user-menu.js, file-tree.js, code-editor.js removed.
+ Earlier: api.js, ui-core.js, pages.js (v0.37.10),
+ ui-format.js, virtual-scroll.js, model-selector.js, drag-resize.js (v0.37.12),
+ chat-pane.js, pane-container.js (v0.37.10). */}}
-
- {{/* marked.min.js + purify.min.js removed from base in v0.37.12 — surfaces load their own copies */}}
- {{/* ui-format.js, virtual-scroll.js removed in v0.37.12 */}}
- {{/* ui-core.js + pages.js removed in v0.37.10 */}}
- {{/* v0.25.0: Component scripts — available on all surfaces */}}
-
-
-
- {{/* chat-pane.js + pane-container.js removed in v0.37.10 — see sw/components/chat-pane/ */}}
- {{/* model-selector.js, drag-resize.js removed in v0.37.12 */}}
- {{/* v0.28.5: SDK — composition layer over globals. Must load after all
- primitives/components it wraps, before extensions.js. */}}
+ {{/* v0.28.5: SDK — composition layer over globals. */}}
{{/* ── Universal init: SDK boot for ALL surfaces ── */}}
{{end}}
- {{/* v0.28.5: UserMenu hydration moved into Switchboard.init() (switchboard-sdk.js).
- The SDK handles create/bind/setUser for all non-chat surfaces automatically. */}}
+ {{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
{{/* ── Debug Log Modal (all surfaces) ───── */}}
diff --git a/server/version.go b/server/version.go
index 5a60975..6d97026 100644
--- a/server/version.go
+++ b/server/version.go
@@ -1,3 +1,3 @@
package main
-const Version = "0.37.12"
+const Version = "0.37.13"
diff --git a/src/js/app-state.js b/src/js/app-state.js
deleted file mode 100644
index 6cec59d..0000000
--- a/src/js/app-state.js
+++ /dev/null
@@ -1,139 +0,0 @@
-// ==========================================
-// Chat Switchboard – Shared Application State
-// ==========================================
-// Loaded by base.html for ALL surfaces. Contains the App state
-// object and universal data-loading functions (models, settings).
-// Surface-specific logic lives in app.js (chat), admin-scaffold.js, etc.
-//
-// Exports: window.App, window.fetchModels
-// ==========================================
-
-
-const App = {
- chats: [],
- models: [],
- hiddenModels: new Set(),
- isGenerating: false,
- abortController: null,
- serverSettings: {},
- policies: {},
- stagedFiles: [],
- channelFiles: {},
- storageConfigured: false,
- projects: [],
- collapsedProjects: {},
- activeProjectId: null,
- showArchivedProjects: false,
- // v0.23.2: Unified active conversation
- activeConversation: null, // { id, type } where type = direct|dm|group|channel
- // v0.23.1: Multi-user
- channels: [], // DMs + named channels (sidebar display data)
- folders: [], // chat folders (user-scoped)
- collapsedFolders: {},
- presence: {}, // { userId: 'online' | 'offline' }
- users: [], // v0.23.2: [{id, username, displayName}] for @mention autocomplete
- activeParticipants: [], // v0.24.0: user participants of active channel (for scoped autocomplete)
-
- /** Get active conversation ID (shorthand). */
- get activeId() { return this.activeConversation?.id || null; },
-
- /** Get active conversation type. */
- get activeType() { return this.activeConversation?.type || null; },
-
- /** Set the active conversation. Clears if id is null. */
- setActive(id, type) {
- this.activeConversation = id ? { id, type: type || 'direct' } : null;
- this.activeParticipants = [];
- },
-
- /** Find the active conversation's chat object (with cached messages). */
- getActiveChat() {
- const id = this.activeConversation?.id;
- if (!id) return null;
- return this.chats.find(c => c.id === id) || null;
- },
-
- findModel(id) {
- if (!id) return null;
- let m = App.models.find(m => m.id === id);
- if (m) return m;
- return App.models.find(m => m.baseModelId === id) || null;
- },
-
- settings: {
- model: '',
- stream: true,
- showThinking: false,
- systemPrompt: '',
- maxTokens: 0,
- temperature: 0.7,
- },
-};
-
-// ── Capability Resolution ──────────────────
-// Backend is source of truth. Frontend passes through as-is.
-function resolveCapabilities(backendCaps, modelId) {
- return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
-}
-
-// ── Model Loading ──────────────────────────
-async function fetchModels() {
- try {
- const data = await API.listEnabledModels();
- App.defaultModel = data.default_model || '';
-
- try {
- const prefData = await API.getModelPreferences();
- App.hiddenModels = new Set(
- (prefData.data || prefData.preferences || []).filter(p => p.hidden).map(p =>
- (p.provider_config_id || '') + ':' + p.model_id
- )
- );
- } catch (e) {
- if (!App.hiddenModels) App.hiddenModels = new Set();
- }
-
- App.models = (data.data || data.models || []).map(m => {
- const isPersona = !!m.is_persona;
- const baseModelId = m.model_id || m.id;
- const cfgId = m.config_id || m.provider_config_id || '';
- const id = isPersona
- ? (m.persona_id || m.id)
- : (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
- return {
- id,
- baseModelId,
- name: m.display_name || baseModelId,
- provider: m.provider_name || m.provider || '',
- configId: cfgId || null,
- model_type: m.model_type || 'chat',
- capabilities: resolveCapabilities(m.capabilities, baseModelId),
- isPersona,
- personaId: m.persona_id || null,
- personaHandle: m.persona_handle || null,
- personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
- personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
- personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
- source: m.source || (isPersona ? 'persona' : 'global'),
- teamName: m.persona_team_name || null,
- hidden: !isPersona && App.hiddenModels.has((cfgId || '') + ':' + baseModelId),
- };
- });
-
- const scopeOrder = { global: 0, team: 1, personal: 2 };
- App.models.sort((a, b) => {
- if (a.isPersona && !b.isPersona) return -1;
- if (!a.isPersona && b.isPersona) return 1;
- if (a.isPersona && b.isPersona) {
- const sa = scopeOrder[a.personaScope] ?? 9;
- const sb = scopeOrder[b.personaScope] ?? 9;
- if (sa !== sb) return sa - sb;
- }
- return a.name.localeCompare(b.name);
- });
- console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPersona).length} personas, ${App.models.filter(m => m.hidden).length} hidden)`);
- } catch (e) { console.warn('Model fetch failed:', e.message); }
-}
-
-sb.ns('App', App);
-sb.register('fetchModels', fetchModels);
diff --git a/src/js/code-editor.js b/src/js/code-editor.js
deleted file mode 100644
index d4f4e7c..0000000
--- a/src/js/code-editor.js
+++ /dev/null
@@ -1,362 +0,0 @@
-// ==========================================
-// Chat Switchboard — CodeEditor Component
-// ==========================================
-// v0.25.0: Extracted from editor-mode.js (lines 536-940).
-// Tabbed code editor with CM6 integration, auto-save, language detection,
-// modified indicators, and status bar.
-//
-// Usage:
-// const editor = CodeEditor.create({
-// id: 'editor',
-// workspaceId: 'ws-123',
-// onSave: (path, content) => fileTree.refresh(),
-// onActivate: (path) => fileTree.setActiveFile(path),
-// });
-// await editor.openFile('src/main.go');
-// editor.getActiveFile(); // → 'src/main.go'
-// editor.isModified('src/main.go'); // → false
-// await editor.saveAll();
-// editor.destroy();
-//
-// Exports: window.CodeEditor
-
-
-const CodeEditor = {
- ...createComponentRegistry('CodeEditor'),
-
- create(opts) {
- const pfx = opts.id || 'editor';
- const instance = componentMixin({
- id: pfx,
- tabsEl: document.getElementById(pfx + 'EditorTabs'),
- contentEl: document.getElementById(pfx + 'EditorContent'),
- welcomeEl: document.getElementById(pfx + 'EditorWelcome'),
- statusEl: document.getElementById(pfx + 'EditorStatus'),
- statusFileEl: document.getElementById(pfx + 'EditorStatusFile'),
- statusLangEl: document.getElementById(pfx + 'EditorStatusLang'),
- statusBranchEl: document.getElementById(pfx + 'EditorStatusBranch'),
- workspaceId: opts.workspaceId || null,
- onSave: opts.onSave || null,
- onActivate: opts.onActivate || null,
- _openFiles: new Map(),
- _activeFile: null,
-
- // ── Public API ──────────────────────
-
- setWorkspace(wsId) { this.workspaceId = wsId; },
-
- async openFile(path, content, language) {
- if (this._openFiles.has(path)) {
- this.activateFile(path);
- return;
- }
-
- // Fetch if content not provided
- if (content === undefined) {
- try {
- content = await API.readWorkspaceFile(this.workspaceId, path);
- } catch (e) {
- console.error('[CodeEditor] Failed to read:', e);
- if (typeof UI !== 'undefined') UI.toast('Failed to open ' + path, 'error');
- return;
- }
- }
-
- const lang = language || _ceDetectLanguage(path);
-
- // Create tab
- const tab = document.createElement('div');
- tab.className = 'code-editor-tab';
- tab.dataset.path = path;
- const fileName = path.split('/').pop();
- tab.innerHTML =
- '
' + _ceFileIcon(fileName) + '' +
- '
' + esc(fileName) + '' +
- '
●' +
- '
';
-
- const self = this;
- tab.addEventListener('click', (e) => {
- if (!e.target.closest('.code-editor-tab-close')) self.activateFile(path);
- });
- tab.querySelector('.code-editor-tab-close').addEventListener('click', (e) => {
- e.stopPropagation();
- self.closeFile(path);
- });
- if (this.tabsEl) this.tabsEl.appendChild(tab);
-
- // Create editor wrapper
- const editorWrap = document.createElement('div');
- editorWrap.className = 'code-editor-cm-wrap';
- editorWrap.style.display = 'none';
- if (this.contentEl) this.contentEl.appendChild(editorWrap);
-
- // Create CM6 editor or textarea fallback
- let editor = null;
- if (window.CM?.codeEditor) {
- editor = CM.codeEditor(editorWrap, {
- language: lang,
- value: content,
- lineNumbers: true,
- });
- if (editor.onUpdate) {
- editor.onUpdate(() => self._markModified(path));
- }
- } else {
- const ta = document.createElement('textarea');
- ta.className = 'code-editor-textarea-fallback';
- ta.value = content;
- ta.addEventListener('input', () => self._markModified(path));
- editorWrap.appendChild(ta);
- editor = {
- getValue: () => ta.value,
- setValue: (v) => { ta.value = v; },
- focus: () => ta.focus(),
- destroy: () => {},
- _textarea: true,
- };
- }
-
- this._openFiles.set(path, {
- tab, editorWrap, editor, content, modified: false, language: lang,
- });
-
- this.activateFile(path);
- },
-
- activateFile(path) {
- if (this._activeFile === path) return;
-
- // Auto-save previous if modified
- if (this._activeFile && this._openFiles.has(this._activeFile)) {
- const prev = this._openFiles.get(this._activeFile);
- if (prev.modified) this.saveFile(this._activeFile);
- prev.tab.classList.remove('active');
- prev.editorWrap.style.display = 'none';
- }
-
- this._activeFile = path;
- const f = this._openFiles.get(path);
- if (!f) return;
-
- f.tab.classList.add('active');
- f.editorWrap.style.display = '';
- if (this.welcomeEl) this.welcomeEl.style.display = 'none';
-
- this._updateStatusBar();
- if (this.onActivate) this.onActivate(path);
- if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
- },
-
- async closeFile(path) {
- const f = this._openFiles.get(path);
- if (!f) return;
-
- if (f.modified) {
- const save = await this._confirmClose(path);
- if (save === null) return; // cancelled
- if (save) await this.saveFile(path);
- }
-
- f.tab.remove();
- f.editorWrap.remove();
- if (f.editor?.destroy) f.editor.destroy();
- this._openFiles.delete(path);
-
- if (this._activeFile === path) {
- this._activeFile = null;
- const remaining = Array.from(this._openFiles.keys());
- if (remaining.length) {
- this.activateFile(remaining[remaining.length - 1]);
- } else {
- if (this.welcomeEl) this.welcomeEl.style.display = '';
- this._updateStatusBar();
- }
- }
- },
-
- async saveFile(path) {
- const f = this._openFiles.get(path);
- if (!f) return;
-
- const content = f.editor?.getValue?.() ?? '';
- try {
- await API.writeWorkspaceFile(this.workspaceId, path, content);
- f.content = content;
- f.modified = false;
- f.tab.querySelector('.code-editor-tab-modified').style.display = 'none';
- f.tab.classList.remove('modified');
- if (path === this._activeFile) this._updateStatusBar();
- if (typeof UI !== 'undefined') UI.toast('Saved ' + path.split('/').pop(), 'success');
- if (this.onSave) this.onSave(path, content);
- } catch (e) {
- console.error('[CodeEditor] Save failed:', e);
- if (typeof UI !== 'undefined') UI.toast('Failed to save: ' + e.message, 'error');
- }
- },
-
- async saveAll() {
- for (const [path, f] of this._openFiles) {
- if (f.modified) await this.saveFile(path);
- }
- },
-
- getActiveFile() { return this._activeFile; },
-
- isModified(path) {
- const f = this._openFiles.get(path);
- return f ? f.modified : false;
- },
-
- hasUnsaved() {
- for (const [, f] of this._openFiles) {
- if (f.modified) return true;
- }
- return false;
- },
-
- getOpenFiles() { return Array.from(this._openFiles.keys()); },
-
- // ── Git Branch Display ──────────────
-
- setBranch(branch) {
- if (this.statusBranchEl) {
- this.statusBranchEl.textContent = branch ? '⎇ ' + branch : '';
- }
- },
-
- // ── Keyboard Shortcuts ──────────────
-
- bind() {
- this._on(document, 'keydown', (e) => {
- // Ctrl/Cmd+S — save active file
- if ((e.ctrlKey || e.metaKey) && e.key === 's') {
- e.preventDefault();
- if (this._activeFile) this.saveFile(this._activeFile);
- }
- });
- },
-
- _cleanup() {
- for (const [, f] of this._openFiles) {
- if (f.editor?.destroy) f.editor.destroy();
- f.tab.remove();
- f.editorWrap.remove();
- }
- this._openFiles.clear();
- this._activeFile = null;
- },
-
- // ── Internal ────────────────────────
-
- _markModified(path) {
- const f = this._openFiles.get(path);
- if (!f || f.modified) return;
- const currentVal = f.editor?.getValue?.() ?? '';
- if (currentVal === f.content) return;
- f.modified = true;
- f.tab.querySelector('.code-editor-tab-modified').style.display = '';
- f.tab.classList.add('modified');
- if (path === this._activeFile) this._updateStatusBar();
- },
-
- _updateStatusBar() {
- if (!this.statusFileEl) return;
- if (!this._activeFile) {
- this.statusFileEl.textContent = 'No file open';
- if (this.statusLangEl) this.statusLangEl.textContent = '';
- return;
- }
- const f = this._openFiles.get(this._activeFile);
- this.statusFileEl.textContent = this._activeFile + (f?.modified ? ' ●' : '');
- if (this.statusLangEl && f) {
- this.statusLangEl.textContent = f.language || '';
- }
- },
-
- async _confirmClose(path) {
- const fileName = path.split('/').pop();
- if (typeof showConfirm === 'function') {
- return showConfirm('Save changes to ' + fileName + '?', {
- confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
- });
- }
- return Promise.resolve(true);
- },
-
- }, CodeEditor);
-
- CodeEditor._register(pfx, instance);
- return instance;
- },
-
- // v0.31.0: Self-mount — creates DOM + binds in one call.
- mount(container, opts) {
- const pfx = opts.id || 'ce';
- const el = document.createElement('div');
- el.className = 'code-editor';
- el.id = pfx + 'CodeEditor';
- el.innerHTML =
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
Open a file to start editing
' +
- '
Select from the file tree or Ctrl+P
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '' +
- '' +
- '' +
- '
';
- container.appendChild(el);
- const instance = this.create(opts);
- instance.bind();
- return instance;
- },
-};
-
-// ── Helpers ─────────────────────────────────
-
-
-function _ceFileIcon(name) {
- const ext = name.split('.').pop()?.toLowerCase();
- const icons = {
- js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
- go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
- html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
- json: '📋', yaml: '📋', yml: '📋', toml: '📋',
- md: '📝', txt: '📄', svg: '🖼',
- sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
- dockerfile: '🐳', makefile: '🔧',
- mod: '📦', sum: '📦', lock: '🔒',
- };
- return icons[ext] || '📄';
-}
-
-function _ceDetectLanguage(path) {
- const ext = path.split('.').pop()?.toLowerCase();
- const map = {
- js: 'javascript', mjs: 'javascript', cjs: 'javascript',
- ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
- go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
- html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
- json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
- md: 'markdown', mdx: 'markdown',
- sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
- xml: 'xml', svg: 'xml',
- c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
- java: 'java', kt: 'java',
- php: 'php', pl: 'perl',
- dockerfile: 'dockerfile',
- };
- return map[ext] || 'text';
-}
-
-// ── Exports ─────────────────────────────────
-sb.ns('CodeEditor', CodeEditor);
diff --git a/src/js/debug.js b/src/js/debug.js
index 4ce2e97..5c300ec 100644
--- a/src/js/debug.js
+++ b/src/js/debug.js
@@ -575,6 +575,53 @@ const DebugLog = {
}
};
+// ── Inlined from ui-primitives.js (Scorched Earth III, v0.37.13) ──
+
+function _escHtml(s) {
+ const d = document.createElement('div');
+ d.textContent = s;
+ return d.innerHTML;
+}
+
+function openModal(id) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.classList.add('active');
+}
+
+function closeModal(id) {
+ const el = document.getElementById(id);
+ if (el) el.classList.remove('active');
+}
+
+function showConfirm(message, opts = {}) {
+ return new Promise(resolve => {
+ const title = opts.title || 'Confirm';
+ const okText = opts.ok || 'Confirm';
+ const caText = opts.cancel || 'Cancel';
+ const danger = opts.danger !== false;
+ const overlay = document.createElement('div');
+ overlay.className = 'confirm-overlay';
+ overlay.innerHTML = `
+
+
+
${_escHtml(message)}
+
+
`;
+ function close(result) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(result); }
+ function onKey(e) { if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
+ overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
+ overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
+ document.addEventListener('keydown', onKey);
+ document.body.appendChild(overlay);
+ overlay.querySelector('[data-action="cancel"]').focus();
+ });
+}
+
// ── Modal Controls ──────────────────────────
function openDebugModal() {
diff --git a/src/js/file-tree.js b/src/js/file-tree.js
deleted file mode 100644
index a214146..0000000
--- a/src/js/file-tree.js
+++ /dev/null
@@ -1,290 +0,0 @@
-// ==========================================
-// Chat Switchboard — FileTree Component
-// ==========================================
-// v0.25.0: Extracted from editor-mode.js (lines 336-535).
-// Workspace file browser with directory expand/collapse, git status
-// badges, context menu, and file icon mapping.
-//
-// Usage:
-// const tree = FileTree.create({
-// id: 'editor',
-// workspaceId: 'ws-123',
-// onSelect: (path) => codeEditor.openFile(path),
-// onDelete: (path) => { ... },
-// onNewFile: () => { ... },
-// });
-// await tree.refresh();
-// tree.setActiveFile('src/main.go');
-// tree.destroy();
-//
-// Exports: window.FileTree
-
-
-const FileTree = {
- ...createComponentRegistry('FileTree'),
-
- create(opts) {
- const pfx = opts.id || 'editor';
- const instance = componentMixin({
- id: pfx,
- itemsEl: document.getElementById(pfx + 'TreeItems'),
- newFileEl: document.getElementById(pfx + 'TreeNewFile'),
- workspaceId: opts.workspaceId || null,
- onSelect: opts.onSelect || null,
- onDelete: opts.onDelete || null,
- onNewFile: opts.onNewFile || null,
- _treeData: [],
- _expandedDirs: new Set(['']),
- _gitStatusMap: new Map(),
- _activeFile: null,
-
- // ── Public API ──────────────────────
-
- setWorkspace(wsId) { this.workspaceId = wsId; },
-
- async refresh() {
- if (!this.workspaceId) return;
- try {
- const resp = await API.listWorkspaceFiles(this.workspaceId, '', true);
- this._treeData = resp.files || resp.data || resp || [];
- this._render();
- } catch (e) {
- console.error('[FileTree] Failed to load:', e);
- if (this.itemsEl) this.itemsEl.innerHTML = '
Failed to load files
';
- }
- // Non-blocking git status refresh
- this._refreshGitStatus();
- },
-
- setActiveFile(path) {
- this._activeFile = path;
- if (!this.itemsEl) return;
- this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
- row.classList.toggle('active', row.dataset.path === path);
- });
- },
-
- getActiveFile() { return this._activeFile; },
-
- // ── Git Status ──────────────────────
-
- async _refreshGitStatus() {
- if (!this.workspaceId) return;
- this._gitStatusMap.clear();
- try {
- const resp = await API.getWorkspaceGitStatus(this.workspaceId);
- const files = resp.files || [];
- for (const f of files) {
- this._gitStatusMap.set(f.path, f.status || '?');
- }
- // Apply to rendered rows
- if (this.itemsEl) {
- this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
- const p = row.dataset.path;
- const st = this._gitStatusMap.get(p);
- row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted');
- if (st === 'M') row.classList.add('git-modified');
- else if (st === 'A') row.classList.add('git-added');
- else if (st === '?') row.classList.add('git-untracked');
- else if (st === 'D') row.classList.add('git-deleted');
- });
- }
- } catch (_) {
- // Git not configured — ignore
- }
- },
-
- // ── Tree Rendering ──────────────────
-
- _render() {
- if (!this.itemsEl) return;
- this.itemsEl.innerHTML = '';
- if (!this._treeData.length) {
- this.itemsEl.innerHTML = '
No files
';
- return;
- }
- const tree = this._buildStructure(this._treeData);
- this._renderNodes(tree, this.itemsEl, 0);
- },
-
- _buildStructure(files) {
- const root = { name: '', path: '', isDir: true, children: [], file: null };
- const dirs = new Map();
- dirs.set('', root);
-
- const sorted = [...files].sort((a, b) => {
- if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
- return a.path.localeCompare(b.path);
- });
-
- for (const f of sorted) {
- const parts = f.path.split('/');
- const name = parts[parts.length - 1];
- const parentPath = parts.slice(0, -1).join('/');
-
- let parent = dirs.get(parentPath);
- if (!parent) {
- let accumulated = '';
- for (let i = 0; i < parts.length - 1; i++) {
- const prev = accumulated;
- accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
- if (!dirs.has(accumulated)) {
- const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
- dirs.set(accumulated, dirNode);
- const p = dirs.get(prev);
- if (p) p.children.push(dirNode);
- }
- }
- parent = dirs.get(parentPath);
- }
-
- if (f.is_directory) {
- let existing = dirs.get(f.path);
- if (!existing) {
- existing = { name, path: f.path, isDir: true, children: [], file: f };
- dirs.set(f.path, existing);
- if (parent) parent.children.push(existing);
- } else {
- existing.file = f;
- }
- } else {
- const node = { name, path: f.path, isDir: false, children: [], file: f };
- if (parent) parent.children.push(node);
- }
- }
- return root.children;
- },
-
- _renderNodes(nodes, container, depth) {
- const self = this;
- for (const node of nodes) {
- const row = document.createElement('div');
- row.className = 'file-tree-row' + (node.path === this._activeFile ? ' active' : '');
- row.style.paddingLeft = (12 + depth * 16) + 'px';
- row.dataset.path = node.path;
-
- if (node.isDir) {
- const expanded = this._expandedDirs.has(node.path);
- row.innerHTML =
- '
' + (expanded ? '▾' : '▸') + '' +
- '
📁' +
- '
' + esc(node.name) + '';
- row.addEventListener('click', () => {
- if (self._expandedDirs.has(node.path)) {
- self._expandedDirs.delete(node.path);
- } else {
- self._expandedDirs.add(node.path);
- }
- self._render();
- });
- container.appendChild(row);
- if (expanded && node.children.length) {
- this._renderNodes(node.children, container, depth + 1);
- }
- } else {
- row.innerHTML =
- '
' + _ftFileIcon(node.name) + '' +
- '
' + esc(node.name) + '';
- row.addEventListener('click', () => {
- if (self.onSelect) self.onSelect(node.path);
- });
- row.addEventListener('contextmenu', (e) => {
- e.preventDefault();
- self._showContextMenu(e, node);
- });
- container.appendChild(row);
- }
- }
- },
-
- // ── Context Menu ────────────────────
-
- _showContextMenu(e, node) {
- const existing = document.querySelector('.file-tree-ctx-menu');
- if (existing) existing.remove();
-
- const menu = document.createElement('div');
- menu.className = 'file-tree-ctx-menu';
- menu.style.left = e.clientX + 'px';
- menu.style.top = e.clientY + 'px';
-
- const items = [
- { label: 'Open', action: () => { if (this.onSelect) this.onSelect(node.path); } },
- { label: 'Delete', action: () => { if (this.onDelete) this.onDelete(node.path); } },
- ];
- for (const item of items) {
- const row = document.createElement('div');
- row.className = 'file-tree-ctx-item';
- row.textContent = item.label;
- row.addEventListener('click', () => { menu.remove(); item.action(); });
- menu.appendChild(row);
- }
-
- document.body.appendChild(menu);
- const dismiss = (ev) => {
- if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
- };
- setTimeout(() => document.addEventListener('click', dismiss), 0);
- },
-
- // ── Event Wiring ────────────────────
-
- bind() {
- this._on(this.newFileEl, 'click', () => {
- if (this.onNewFile) this.onNewFile();
- });
- },
-
- _cleanup() {
- this._treeData = [];
- this._expandedDirs = new Set(['']);
- this._gitStatusMap.clear();
- },
- }, FileTree);
-
- FileTree._register(pfx, instance);
- return instance;
- },
-
- // v0.31.0: Self-mount — creates DOM + binds in one call.
- mount(container, opts) {
- const pfx = opts.id || 'ft';
- const el = document.createElement('div');
- el.className = 'file-tree';
- el.id = pfx + 'FileTree';
- el.innerHTML =
- '' +
- '
';
- container.appendChild(el);
- const instance = this.create(opts);
- instance.bind();
- return instance;
- },
-};
-
-// ── Helpers ─────────────────────────────────
-
-
-function _ftFileIcon(name) {
- const ext = name.split('.').pop()?.toLowerCase();
- const icons = {
- js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
- go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
- html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
- json: '📋', yaml: '📋', yml: '📋', toml: '📋',
- md: '📝', txt: '📄', svg: '🖼',
- sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
- dockerfile: '🐳', makefile: '🔧',
- mod: '📦', sum: '📦', lock: '🔒',
- };
- return icons[ext] || '📄';
-}
-
-// ── Exports ─────────────────────────────────
-sb.ns('FileTree', FileTree);
diff --git a/src/js/switchboard-sdk.js b/src/js/switchboard-sdk.js
index a00d2ea..3d3d4b2 100644
--- a/src/js/switchboard-sdk.js
+++ b/src/js/switchboard-sdk.js
@@ -3,6 +3,9 @@
// ==========================================
// v0.28.5: Composition layer over platform globals. Surface and
// extension authors consume this instead of hunting through 15 JS files.
+// v0.37.13: Scorched Earth III — removed dead wrappers (FileTree,
+// CodeEditor, UserMenu, NotePanel, PaneContainer, PaneLayout),
+// inlined Theme via Preact SDK module, inlined esc/modal helpers.
//
// Usage:
// const sw = Switchboard.init();
@@ -10,12 +13,12 @@
// sw.on('chat.message.*', (payload) => { ... });
// sw.pipe.render(50, (ctx) => { ... });
//
-// Load order: after ui-core.js + pane-container.js, before extensions.js.
+// Load order: after sb.js + events.js, before extensions.js.
//
// Exports: window.Switchboard, window.sw (convenience alias)
// ==========================================
-'use strict';
+import { createTheme } from './sw/sdk/theme.js';
const Switchboard = {
@@ -190,45 +193,10 @@ const Switchboard = {
Events.emit(label, payload, opts);
};
- // ── Theme ────────────────────────────────────────────
+ // ── Theme (v0.37.13: uses Preact SDK module directly) ──
- sw.theme = {
- /** Resolved theme: 'dark' or 'light' (never 'system'). */
- get current() {
- if (typeof Theme !== 'undefined') return Theme.resolved();
- return 'dark';
- },
-
- /** User preference: 'dark', 'light', or 'system'. */
- get mode() {
- if (typeof Theme !== 'undefined') return Theme.get();
- return 'system';
- },
-
- /** Set theme mode. */
- set(mode) {
- if (typeof Theme !== 'undefined') Theme.set(mode);
- },
-
- /**
- * Subscribe to theme changes.
- * @param {string} event — 'change'
- * @param {Function} fn — receives resolved theme string
- * @returns {Function} unsubscribe
- */
- on(event, fn) {
- if (event === 'change') {
- return Events.on('theme.changed', (payload) => {
- const resolved = typeof Theme !== 'undefined'
- ? Theme.resolved()
- : 'dark';
- fn(resolved);
- });
- }
- console.warn(`[Switchboard] theme.on: unknown event '${event}'`);
- return () => {};
- },
- };
+ const _theme = createTheme((label, payload, opts) => Events.emit(label, payload, opts));
+ sw.theme = _theme;
// ── UI Primitives ────────────────────────────────────
@@ -239,16 +207,19 @@ const Switchboard = {
};
sw.confirm = function (message, opts) {
+ // v0.37.13: delegate to debug.js's showConfirm if loaded, else native
if (typeof showConfirm === 'function') return showConfirm(message, opts);
return Promise.resolve(window.confirm(message));
};
sw.modal = {
- open(contentOrId) {
- if (typeof openModal === 'function') openModal(contentOrId);
+ open(id) {
+ const el = document.getElementById(id);
+ if (el) el.classList.add('active');
},
close(id) {
- if (typeof closeModal === 'function') closeModal(id);
+ const el = document.getElementById(id);
+ if (el) el.classList.remove('active');
},
};
@@ -284,7 +255,8 @@ const Switchboard = {
}
const btn = document.createElement('button');
btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : '');
- btn.innerHTML = (item.icon || '') + '
' + (typeof esc === 'function' ? esc(item.label) : item.label) + '';
+ const d = document.createElement('div'); d.textContent = item.label;
+ btn.innerHTML = (item.icon || '') + '
' + d.innerHTML + '';
btn.addEventListener('click', () => {
result.close();
if (item.onClick) item.onClick();
@@ -470,87 +442,8 @@ const Switchboard = {
return ChatPane.mount(container, opts || {});
};
- /**
- * Mount a NotePanel instance into a container element.
- * Builds DOM + creates + wires events in one call.
- *
- * @param {HTMLElement} container — mount target
- * @param {object} opts — { projectId, onOpenGraph }
- * @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
- */
- sw.notes = function (container, opts) {
- // v0.37.9: Prefer new Preact NotesPane via sw.notesPane() when SDK is loaded
- if (window.sw?.notesPane) {
- return window.sw.notesPane(container, opts || {});
- }
- console.warn('[Switchboard] sw.notes() — use sw.notesPane() instead (v0.37.9+)');
- return null;
- };
-
- /**
- * Access the PanelRegistry through the SDK.
- * Returns a thin wrapper with open/close/toggle/isOpen/active/cycle/register.
- *
- * @returns {object|null} Panel control object
- */
- sw.panels = function () {
- if (typeof PanelRegistry === 'undefined') {
- console.warn('[Switchboard] PanelRegistry not available');
- return null;
- }
- return {
- open(name) { PanelRegistry.open(name); },
- close(name) { PanelRegistry.close(name); },
- toggle(name) { PanelRegistry.toggle(name); },
- isOpen(name) { return PanelRegistry.isOpen(name); },
- active() { return PanelRegistry.active(); },
- cycle() { PanelRegistry.cycle(); },
- register(name, opts) { PanelRegistry.register(name, opts); },
- };
- };
-
- /**
- * Mount a FileTree into a container.
- * @param {HTMLElement} container — mount target
- * @param {object} opts — { id, workspaceId, onSelect, onDelete, onNewFile }
- * @returns {object|null} FileTree instance
- */
- sw.fileTree = function (container, opts) {
- if (typeof FileTree === 'undefined') {
- console.error('[Switchboard] FileTree not available');
- return null;
- }
- return FileTree.mount(container, opts || {});
- };
-
- /**
- * Mount a CodeEditor into a container.
- * @param {HTMLElement} container — mount target
- * @param {object} opts — { id, workspaceId, onSave, onActivate }
- * @returns {object|null} CodeEditor instance
- */
- sw.codeEditor = function (container, opts) {
- if (typeof CodeEditor === 'undefined') {
- console.error('[Switchboard] CodeEditor not available');
- return null;
- }
- return CodeEditor.mount(container, opts || {});
- };
-
- /**
- * Mount a PaneContainer layout into a container.
- * @param {HTMLElement} container — mount target
- * @param {string} layoutId — layout preset name (e.g. 'editor')
- * @param {object} opts — { workspaceId }
- * @returns {object|null} PaneContainer layout instance
- */
- sw.layout = function (container, layoutId, opts) {
- if (typeof PaneContainer === 'undefined') {
- console.error('[Switchboard] PaneContainer not available');
- return null;
- }
- return PaneContainer.mount(container, layoutId, opts || {});
- };
+ // v0.37.13: sw.notes, sw.panels, sw.fileTree, sw.codeEditor,
+ // sw.layout removed (Scorched Earth III — globals deleted).
// ── Workflow ─────────────────────────────────────────
// v0.30.2: Workflow stage surface management.
@@ -780,77 +673,15 @@ const Switchboard = {
_runRender(ctx) { return _runChain('render', ctx); },
};
- // ── UserMenu ─────────────────────────────────────────
-
- const _defaultMenuHandlers = {
- onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
- onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
- onDebug: () => { sb.call('openDebugModal'); },
- onSignout: () => { sb.call('handleLogout'); },
- };
-
- /**
- * Mount a UserMenu into a container.
- * @param {HTMLElement} container — mount target
- * @param {object} [opts] — { flyout: 'down'|'up', compact, user, handlers }
- * @returns {object} UserMenu instance
- */
- sw.userMenu = function (container, opts) {
- if (typeof UserMenu === 'undefined') {
- console.error('[Switchboard] UserMenu not available');
- return null;
- }
- const _opts = Object.assign({
- user: window.__USER__ || null,
- handlers: _defaultMenuHandlers,
- _positionFn: _positionFlyout,
- }, opts || {});
- const instance = UserMenu.mount(container, _opts);
- if (!UserMenu.primary) UserMenu.primary = instance;
- return instance;
- };
-
- /**
- * Hydrate server-rendered UserMenu (non-chat surfaces).
- * Falls back to mount() if server-rendered DOM not found.
- */
- function _hydrateUserMenu() {
- if (typeof UserMenu === 'undefined') return;
- if (UserMenu.primary) return;
-
- const surface = window.__SURFACE__ || '';
- if (surface === 'chat') return;
-
- // Extension surfaces: package owns its own menu via sw.userMenu().
- // Hide the server-rendered placeholder so the package can mount fresh.
- const isExtension = document.querySelector('.extension-surface') !== null;
- if (isExtension) {
- const placeholder = document.getElementById('userMenuWrap');
- if (placeholder) placeholder.style.display = 'none';
- return;
- }
-
- // Non-extension surfaces (admin, settings, notes): hydrate server-rendered DOM
- const btn = document.getElementById('userMenuBtn');
- if (btn) {
- const menu = UserMenu.create({ id: '' });
- UserMenu.primary = menu;
- const user = window.__USER__ || {};
- menu.setUser(user);
- menu.showAdmin(user.role === 'admin');
- menu.bind(_defaultMenuHandlers);
- }
- }
+ // v0.37.13: sw.userMenu + _hydrateUserMenu removed
+ // (Scorched Earth III — all surfaces use Preact UserMenu).
// ── Perform Init ─────────────────────────────────────
- // Theme + appearance (safe on all surfaces)
- if (typeof Theme !== 'undefined') Theme.init();
+ // Theme (v0.37.13: uses imported Preact SDK module)
+ _theme.init();
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
- // UserMenu hydration
- _hydrateUserMenu();
-
// Emit ready event
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
diff --git a/src/js/ui-primitives.js b/src/js/ui-primitives.js
deleted file mode 100644
index 2b17071..0000000
--- a/src/js/ui-primitives.js
+++ /dev/null
@@ -1,1133 +0,0 @@
-// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
-// Single source of truth for constants + reusable rendering functions.
-// Loaded in base.html before all components and surfaces.
-// Follows the renderPersonaForm() pattern: (container, options) → control object.
-// Designed for extension hooks: registries are open for .add(), primitives
-// accept options for customization, and return handles for programmatic control.
-//
-// Exports: esc, createComponentRegistry, componentMixin, Providers, Roles,
-// renderCapBadges, renderProviderForm, renderProviderList,
-// renderRoleConfig, renderUsageDashboard, showConfirm, showPrompt,
-// openModal, closeModal, checkTabsOverflow, Theme, mountAvatarUpload
-// ─────────────────────────────────────────────────────────────────────────────
-
-
-// ══════════════════════════════════════════════════════════════════════════════
-// §0 CORE UTILITIES
-// ══════════════════════════════════════════════════════════════════════════════
-
-/** HTML-escape a string for safe insertion into innerHTML and attributes. */
-function esc(s) {
- if (!s) return '';
- const d = document.createElement('div');
- d.textContent = s;
- return d.innerHTML.replace(/"/g, '"');
-}
-
-// ══════════════════════════════════════════════════════════════════════════════
-// §0b COMPONENT LIFECYCLE
-// ══════════════════════════════════════════════════════════════════════════════
-
-/**
- * Create a component registry (factory namespace with instance tracking).
- *
- * @param {string} name - Component name (for debug)
- * @returns {{ primary, _instances, _register, _unregister, get }}
- */
-function createComponentRegistry(name) {
- return {
- primary: null,
- _instances: new Map(),
- _register(id, instance) { this._instances.set(id, instance); },
- _unregister(id) {
- this._instances.delete(id);
- if (this.primary && this.primary.id === id) this.primary = null;
- },
- get(id) { return this._instances.get(id) || null; },
- };
-}
-
-/**
- * Mix lifecycle methods into a component instance.
- * Adds _listeners array, _on() for tracked event binding, and destroy()
- * for automatic cleanup + registry removal.
- * Components needing extra teardown define _cleanup() which is called first.
- *
- * @param {Object} instance - The instance object (must have .id)
- * @param {Object} registry - The component registry (from createComponentRegistry)
- * @returns {Object} The instance, mutated with lifecycle methods
- */
-function componentMixin(instance, registry) {
- instance._listeners = [];
- instance._on = function(el, event, handler) {
- if (!el) return;
- el.addEventListener(event, handler);
- this._listeners.push({ el, event, handler });
- };
- instance.destroy = function() {
- if (typeof this._cleanup === 'function') this._cleanup();
- this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
- this._listeners = [];
- registry._unregister(this.id);
- };
- return instance;
-}
-
-// ══════════════════════════════════════════════════════════════════════════════
-// §1 PROVIDER REGISTRY
-// ══════════════════════════════════════════════════════════════════════════════
-
-const Providers = (() => {
- const _types = [
- { id: 'openai', label: 'OpenAI-compatible', endpoint: 'https://api.openai.com/v1' },
- { id: 'anthropic', label: 'Anthropic', endpoint: 'https://api.anthropic.com' },
- { id: 'venice', label: 'Venice.ai', endpoint: 'https://api.venice.ai/api/v1' },
- { id: 'openrouter', label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1' },
- ];
-
- return {
- /** Register a new provider type (extension hook). */
- add(entry) {
- if (!entry?.id) throw new Error('Provider entry requires id');
- if (_types.some(t => t.id === entry.id)) return; // no dupes
- _types.push(entry);
- },
- /** All registered types. */
- all() { return _types; },
- /** Lookup by id. */
- get(id) { return _types.find(t => t.id === id) || null; },
- /** Map of id → label. */
- labels() { return Object.fromEntries(_types.map(t => [t.id, t.label])); },
- /** Map of id → default endpoint. */
- endpoints() { return Object.fromEntries(_types.filter(t => t.endpoint).map(t => [t.id, t.endpoint])); },
- /** Build