Changeset 0.25.1 (#161)

This commit is contained in:
2026-03-08 18:54:53 +00:00
parent 2b01d540d6
commit b3f8b747dd
48 changed files with 531 additions and 888 deletions

View File

@@ -1,6 +1,6 @@
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
// Single source of truth for constants + reusable rendering functions.
// Loaded after ui-format.js, before ui-core.js.
// 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.
@@ -8,6 +8,67 @@
'use strict';
// ══════════════════════════════════════════════════════════════════════════════
// §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
// ══════════════════════════════════════════════════════════════════════════════
@@ -933,3 +994,98 @@ function updateTabArrows(tabs) {
left.classList.toggle('visible', !atStart);
right.classList.toggle('visible', !atEnd);
}
// ══════════════════════════════════════════════════════════════════════════════
// §9 THEME
// ══════════════════════════════════════════════════════════════════════════════
const Theme = {
_key: 'switchboard_theme',
init() {
this.set(localStorage.getItem(this._key) || 'system');
},
set(mode) {
localStorage.setItem(this._key, mode);
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', mode);
},
get() {
return localStorage.getItem(this._key) || 'system';
},
resolved() {
const mode = this.get();
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
renderToggle(containerId) {
const el = document.getElementById(containerId);
if (!el) return;
const current = this.get();
const options = [
{ id: 'light', label: 'Light', icon: '☀' },
{ id: 'dark', label: 'Dark', icon: '☾' },
{ id: 'system',label: 'System',icon: '⊞' },
];
el.innerHTML = '<div class="toggle-group">' + options.map(o =>
`<button class="toggle-btn${current === o.id ? ' active' : ''}" data-theme="${o.id}" title="${o.label}">${o.icon}</button>`
).join('') + '</div>';
el.querySelectorAll('.toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.set(btn.dataset.theme);
this.renderToggle(containerId);
});
});
},
};
// ══════════════════════════════════════════════════════════════════════════════
// §10 AVATAR UPLOAD
// ══════════════════════════════════════════════════════════════════════════════
/**
* Mount an avatar upload component into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} [opts.current] - Current avatar URL
* @param {number} [opts.size=80] - Avatar size in px
* @param {Function} [opts.onUpload] - Called with (file, dataURL)
* @returns {{ setImage }}
*/
function mountAvatarUpload(containerEl, opts = {}) {
const size = opts.size || 80;
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
containerEl.className = 'avatar-upload';
containerEl.style.width = size + 'px';
containerEl.style.height = size + 'px';
function render(src) {
if (src) containerEl.innerHTML = `<img src="${esc(src)}" alt="Avatar">`;
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
containerEl.appendChild(input);
}
render(opts.current || null);
containerEl.addEventListener('click', () => input.click());
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
render(ev.target.result);
if (opts.onUpload) opts.onUpload(file, ev.target.result);
};
reader.readAsDataURL(file);
});
return { setImage: render };
}