Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
26 KiB
Chat Switchboard UI Rewrite — Design Document
Version: 0.0.1 (draft) Date: 2026-03-20 Scope: Scorched earth rebuild of frontend on Preact+htm
1. Survey Findings
What Exists
| Metric | Value |
|---|---|
| JS files | 55 |
| Total JS lines | ~29,350 |
| CSS files | 22 (~203KB) |
| Go templates | ~1,920 lines across 14 template files |
innerHTML = assignments |
294+ (91 in ui-admin.js alone) |
querySelector / getElementById calls |
600+ (130 in ui-admin.js) |
Direct fetch() outside api.js |
~24 calls across 10 files |
| Vendor deps | marked, DOMPurify, mermaid, KaTeX, CM6 (esbuild bundle) |
The SDK (switchboard-sdk.js)
- ~860 lines, version-tagged
v0.28.5in code butv0.30.2in docs - Thin wrapper over globals:
API,Events,Theme,UserMenu - Delegates to
API._get(),API._post(), etc. — not its own fetch layer - Has pipe/filter pipeline (pre-send, stream, render) — this is good and reusable
- Has event bus bridge — good, reusable
- Has theme control — good, reusable
- No RBAC. Only
sw.isAdmin(boolean derived fromAPI.user.role) - No namespaced domain methods (e.g.,
sw.api.channels.list())
The Registry (sb.js)
- Action registry:
sb.register(name, fn)+sb.ns(name, obj) - Dual-write to
window[name]for backward compat - Go templates call
sb.call('action', args)via onclick handlers - 55 files all register into this flat global space
Server Templates
- Go templates generate initial DOM with hardcoded IDs
- JS hydrates by ID lookups (
getElementById,querySelector) - Templates inject
window.__USER__,window.__PAGE_DATA__,window.__SURFACE__ - This server-rendering + client-hydration pattern is deeply coupled
What's Missing for RBAC
- No user-facing permissions endpoint.
GET /admin/users/:id/permissionsis admin-only. There is noGET /profile/permissionsor equivalent. The login/refresh response includes onlyrole: "user"|"admin", not the resolved permission set. - The frontend has zero permission-level gating. Everything is gated by
API.isAdmin(binary) or by server-side template conditionals.
Dependency Pipeline
Dockerfile.frontend: Stage 1npm packpulls marked, DOMPurify, mermaid, KaTeX. Stage 2 builds CM6 via esbuild. Stage 3 copies into nginx image.- Preact + htm would be added to Stage 1 as additional
npm packtargets. - No bundler for app code — scripts loaded sequentially via
<script>tags inbase.html. This stays the same (Preact + htm work without a bundler).
2. Architecture
Layer Model
┌─────────────────────────────────────────────────┐
│ Layer 2: Shell │
│ UserMenu, AdminSettings, UserSettings, │
│ SurfaceViewport, Banners │
├─────────────────────────────────────────────────┤
│ Layer 1: SDK │
│ sw.api.{domain}.* — namespaced REST client │
│ sw.auth.* — tokens, session lifecycle │
│ sw.can(perm) — RBAC gate │
│ sw.on/off/emit — event bus │
│ sw.pipe.* — filter pipeline │
│ sw.theme.* — theme control │
├─────────────────────────────────────────────────┤
│ Layer 0: Primitives │
│ Menu, Dialog, Toast, Drawer, Banner, │
│ Button, FormField, Tabs, Dropdown, Tooltip │
├─────────────────────────────────────────────────┤
│ Preact + htm (~4KB) │
└─────────────────────────────────────────────────┘
Design Principles
- No raw DOM. Every UI element is a Preact component. No
innerHTML, noquerySelectorin application code. - No raw fetch. All API access through
sw.api.{domain}.*. Directfetch()only inside the SDK internals. - RBAC at the SDK level.
sw.can('channel.create')checks the cached permission set. Primitives consume it: a menu item that requiresadmin.accesssimply doesn't render if the user lacks it. - Surfaces own their content. The shell provides the viewport and chrome (menu, banners). Everything inside the viewport is a surface's responsibility. Shell and surfaces communicate only through the SDK.
- CSS custom properties, not component-scoped CSS. Keep the existing
variables.csstoken system. Primitives use those tokens. No Shadow DOM, no CSS-in-JS.
3. Layer 0 — Primitives
Preact functional components with hooks. Each primitive is a self-contained file. No business logic, no API calls.
Catalog
| Primitive | Props (key ones) | Notes |
|---|---|---|
Menu |
items[], anchor, direction, onSelect |
Flyout menu. Directions: up-left, up-right, down-left, down-right. Handles viewport overflow (reposition if clipped). Closes on outside click/Escape. |
Dialog |
open, title, children, onClose, actions[] |
Modal dialog. Focus trapping, Escape to close. Actions are {label, onClick, variant}. |
Confirm |
message, onConfirm, onCancel, destructive? |
Specialized Dialog. Returns Promise via sw.confirm(). |
Prompt |
message, defaultValue, onSubmit, onCancel |
Specialized Dialog. Returns Promise<string|null> via sw.prompt(). |
Toast |
message, variant, duration |
Auto-dismiss notification. Variants: success, error, info, warn. Stacks via a ToastContainer. |
Banner |
text, variant, dismissible? |
Persistent bar (top/bottom). Server can inject initial banner via __PAGE_DATA__. |
Drawer |
open, side, width, title, children, onClose |
Slide-in panel (left/right). Used for settings, admin sections. |
Tabs |
tabs[], active, onChange |
Horizontal tab bar with overflow scroll arrows (existing checkTabsOverflow logic). |
Dropdown |
options[], value, onChange, placeholder |
Select replacement with search/filter for long lists. |
Tooltip |
content, children, position |
Hover tooltip. Positions: top, bottom, left, right. |
FormField |
label, error, children |
Wrapper for form inputs with label + validation display. |
Button |
variant, size, disabled, loading, onClick |
Variants: primary, secondary, danger, ghost. |
Avatar |
src, name, size |
Circle avatar with letter fallback. |
Spinner |
size |
Loading indicator. |
Menu Positioning Rules
Menus are the hardest primitive. Rules:
- Anchor-relative. Menu positions relative to its anchor element.
- Direction preference. Caller specifies preferred direction. Menu tries that first.
- Viewport clamping. If the menu would overflow the viewport in the preferred direction, flip to the opposite axis. If still overflowing, clamp to viewport edge with scroll.
- Clipping ancestor escape. If any ancestor has
overflow: hidden/auto, the menu switches toposition: fixedand uses anchor'sgetBoundingClientRect(). (This is the existing_positionFlyoutlogic from switchboard-sdk.js — proven correct.) - Close triggers. Click outside, Escape key, scroll of a parent.
- Keyboard navigation. Arrow keys move focus, Enter selects, Home/End jump to first/last.
- Nested submenus. Open on hover/arrow-right, close on arrow-left. Only one submenu level deep (KISS).
Dialog Rules
- Focus trap. Tab cycles within the dialog. Focus starts on the first focusable element (or the primary action button).
- Backdrop click closes the dialog (unless
persistentprop). - Escape closes the dialog.
- Scroll lock. Body scroll is locked while dialog is open.
- Stacking. Multiple dialogs stack. Only the topmost receives input. Z-index increments per dialog.
Toast Rules
- Container is a fixed-position stack in bottom-right.
- Auto-dismiss after
durationms (default: 3000, errors: 5000). - Max visible: 5. Older toasts are dismissed to make room.
- Hover pauses the auto-dismiss timer.
- Animations: Slide-in from right, fade-out on dismiss.
4. Layer 1 — SDK
4.1 Namespaced API Client
Every ICD domain becomes an SDK namespace. Methods are typed wrappers around the REST client — no raw path construction in application code.
// Instead of:
sw.api.get('/api/v1/channels')
// You write:
sw.api.channels.list()
sw.api.channels.get(id)
sw.api.channels.create({ title, type })
sw.api.channels.update(id, { title })
sw.api.channels.del(id)
Namespace Map (derived from ICD)
| Namespace | ICD Source | Key Methods |
|---|---|---|
sw.api.auth |
auth.md | login, register, refresh, logout |
sw.api.channels |
channels.md | list, get, create, update, del, messages, send, complete |
sw.api.personas |
personas.md | list, get, create, update, del, groups |
sw.api.knowledge |
knowledge.md | list, get, create, upload, search |
sw.api.notes |
notes.md | list, get, create, update, del, search, graph |
sw.api.projects |
projects.md | list, get, create, update, del |
sw.api.workspaces |
workspaces.md | list, get, create, files, git |
sw.api.memory |
memory.md | list, review, extract |
sw.api.models |
models.md | list, preferences |
sw.api.providers |
providers.md | list, get, create, update, del, health, models |
sw.api.notifications |
notifications.md | list, markRead, preferences |
sw.api.extensions |
extensions.md | list, get, settings |
sw.api.profile |
profile.md | get, update, avatar, password, settings |
sw.api.teams |
teams.md | mine, get, members, providers, models, roles, audit, usage |
sw.api.workflows |
workflows.md | list, get, create, instances, advance, reject |
sw.api.tasks |
tasks.md | list, get, create, runs |
sw.api.surfaces |
surfaces.md | list, get, install |
sw.api.admin |
admin.md | users, settings, stats, audit, usage, vault, groups, grants, permissions |
Each namespace method returns a Promise. List methods always return
the unwrapped array (SDK strips the {"data": [...]} envelope).
Error responses throw with { status, message }.
Internal REST Client
The SDK's internal _fetch handles:
- Base path prefixing
- JWT injection (
Authorization: Bearer ...) - 401 → refresh → retry (once)
- Response envelope unwrapping (
dataarrays, error objects) - AbortSignal forwarding
- Network error normalization
This replaces the current API._get, API._post, etc. The old API
global is not exposed. Nothing should call fetch() directly.
4.2 Auth Module
sw.auth.isAuthenticated // boolean getter
sw.auth.user // { id, username, display_name, email, role, avatar }
sw.auth.permissions // Set<string> — resolved permission set
sw.auth.teams // [{ id, name, my_role }] — cached from /teams/mine
sw.auth.login(login, password)
sw.auth.logout()
sw.auth.refresh() // manual refresh (auto-refresh is internal)
sw.auth.permissions is populated at boot from a new backend endpoint
(see §7 Backend Changes). It's refreshed on token refresh.
4.3 RBAC Gate
sw.can(permission) // boolean — checks sw.auth.permissions
sw.isAdmin // shortcut: sw.auth.user?.role === 'admin'
sw.isTeamAdmin(teamId) // checks sw.auth.teams for my_role === 'admin'
Primitives Integration
// Menu item that only renders if user has the permission
html`<${Menu} items=${[
{ label: 'Settings', action: 'settings' },
sw.can('admin.access') && { label: 'Admin', action: 'admin' },
sw.isTeamAdmin(teamId) && { label: 'Team Admin', action: 'team-admin' },
].filter(Boolean)} />`
No special component needed. Standard conditional rendering in Preact
combined with sw.can() calls. The permission set is synchronous (cached
in memory), so there's no async awkwardness in render paths.
4.4 Event Bus
Carry forward the existing pattern. Same API:
sw.on('channel.switched', handler)
sw.once('chat.message.received', handler)
sw.off('channel.switched', handler)
sw.emit('custom.event', payload)
Wildcard patterns (chat.message.*) are supported.
4.5 Pipe/Filter Pipeline
Carry forward as-is from current SDK. Same three stages:
sw.pipe.pre(priority, fn, opts)— pre-sendsw.pipe.stream(priority, fn, opts)— post-receive streamsw.pipe.render(priority, fn, opts)— post-render
Same scoping, same stats, same chain execution. This is proven code.
4.6 Theme
Same as current: sw.theme.current, sw.theme.mode, sw.theme.set(mode),
sw.theme.on('change', fn). Backed by localStorage + data-theme
attribute on <html>. Early inline script in base.html prevents FOUC
(keep this).
5. Layer 2 — Shell
The shell is the application frame. It renders five things:
- User Menu — avatar + flyout (Settings, Admin, Team Admin, Debug, Sign Out)
- User Settings — drawer or route (
/settings) - Admin Settings — drawer or route (
/admin) - Surface Viewport — the
<div>where the active surface renders - Banners — top/bottom persistent bars (server-injected or SDK-triggered)
Shell Component Tree
<App>
<Banner position="top" />
<div class="shell">
<SurfaceViewport surface={__SURFACE__} />
</div>
<Banner position="bottom" />
<ToastContainer />
<DialogStack />
</App>
The UserMenu lives inside the surface viewport — each surface decides
where to place it (top-left in chat, top-right in admin, etc.). The
shell provides sw.userMenu(container, opts) as a Preact render helper
so surfaces can mount it wherever they want.
SurfaceViewport
The viewport is a plain <div> that the active surface renders into.
Surfaces are loaded by the Go template engine (same as today). The
difference: surfaces receive the Preact+htm runtime and the SDK, and
build their UI with components instead of raw DOM.
Built-in surfaces (chat, admin, settings, notes) are rebuilt as Preact
component trees. Extension surfaces (.pkg archives) get sw.* and
the primitives library, and render into #extension-mount.
Admin & Settings
These are the biggest DOM-manipulation offenders (ui-admin.js: 95KB, settings-handlers.js: 45KB, ui-settings.js: 42KB). The rewrite converts them from imperative DOM manipulation to declarative Preact components.
Admin sections become routed sub-components:
- Overview, Users, Teams, Providers, Models, Roles, Routing, Settings, Audit, Usage, Packages, Extensions, Surfaces
Settings sections:
- Profile, Appearance, Providers (BYOK), Models, Notifications, Tasks, Extensions, Data Portability
Each section is a standalone Preact component that uses sw.api.admin.*
or sw.api.profile.* for data.
6. Dependency Story
Vendoring
Add to Dockerfile.frontend Stage 1:
RUN npm pack preact@10.x.x htm@3.x.x 2>/dev/null && \
tar xzf preact-*.tgz -C /tmp && \
mkdir -p /vendor/preact && \
cp /tmp/package/dist/preact.module.js /vendor/preact/preact.module.js && \
cp /tmp/package/hooks/dist/hooks.module.js /vendor/preact/hooks.module.js && \
rm -rf /tmp/package && \
tar xzf htm-*.tgz -C /tmp && \
cp /tmp/package/dist/htm.module.js /vendor/preact/htm.module.js && \
rm -rf /tmp/package /tmp/*.tgz
Loading
In base.html, before all application scripts:
<script type="module">
import { h, render, Component } from '/vendor/preact/preact.module.js';
import { useState, useEffect, useRef, useMemo, useCallback }
from '/vendor/preact/hooks.module.js';
import htm from '/vendor/preact/htm.module.js';
const html = htm.bind(h);
// Expose globally for non-module scripts and surfaces
window.preact = { h, render, Component };
window.hooks = { useState, useEffect, useRef, useMemo, useCallback };
window.html = html;
</script>
Application files continue to load as <script type="module"> tags.
They access html, hooks.*, and preact.* from the global scope.
This avoids needing a bundler while keeping the ergonomics clean.
Surfaces (including extension packages) use the same globals. A surface JS entry point looks like:
const { useState, useEffect } = hooks;
const { render } = preact;
function MySurface() {
const [channels, setChannels] = useState([]);
useEffect(() => {
sw.api.channels.list().then(setChannels);
}, []);
return html`<div>${channels.map(c => html`<p>${c.title}</p>`)}</div>`;
}
render(html`<${MySurface} />`, document.getElementById('extension-mount'));
7. Backend Changes Required
New Endpoint: GET /api/v1/profile/permissions
Auth: Authenticated user
Returns the current user's resolved permission set:
{
"permissions": ["model.use", "kb.read", "channel.create", ...],
"groups": ["group-id-1", "group-id-2"]
}
This is the same resolution logic as GET /admin/users/:id/permissions
but scoped to self and available to non-admins. The SDK calls this at
boot and on token refresh to populate sw.auth.permissions.
New Endpoint: GET /api/v1/profile/bootstrap
Auth: Authenticated user
Optional optimization — single call that returns everything the shell needs at boot, avoiding a waterfall of requests:
{
"user": { "id", "username", "display_name", "email", "role", "avatar" },
"permissions": ["model.use", ...],
"teams": [{ "id", "name", "my_role" }],
"settings": { "theme": "dark", ... },
"policies": { "allow_user_byok": true, ... }
}
This replaces the current pattern where __USER__ and __PAGE_DATA__
are injected by Go templates. The shell calls sw.auth.bootstrap() once
at startup, then renders.
Template Simplification
Go templates are drastically simplified. base.html becomes:
<head>with CSS + early theme script (keep)<body>with a single<div id="app">mount point- Script tags for vendor libs + primitives + SDK + shell
- Surface-specific script tag (same conditional block as today)
All the server-rendered DOM (surface-admin, surface-settings,
surface-chat templates with hundreds of lines of HTML) is replaced
by Preact component trees that render client-side.
The Go template engine still handles routing (which surface to load)
and injects __SURFACE__, __BASE__, __VERSION__ globals. It no
longer generates any UI DOM.
8. Version Roadmap
0.37.1 Permission audit + enforcement fixes + GET /profile/permissions ✅
0.37.2 Layer 0 — UI primitives + Layer 1 Shell layout ✅
0.37.3 Layer 1 — SDK (namespaced API client, auth, RBAC, events, pipe)
0.37.4 Layer 2 — Shell (no auth gate, temp bypass for visual validation)
0.37.5 Login surface + Settings surface
0.37.6 Admin surface
0.37.7 Team Admin surface
0.37.8 Chat Pane (reusable component, NOT a surface)
0.37.9 Notes Pane (reusable component, NOT a surface)
0.37.10 Chat surface (composes ChatPane + sidebar + panels)
0.37.11 Notes surface (composes NotesPane + graph)
0.37.12 Extension surface container
0.37.13 Workflow surfaces
0.37.# Tag — functionality restored
Key distinction: ChatPane and NotesPane (0.37.8–9) are embeddable
components that surfaces compose via sw.chat(container, opts) and
sw.notes(container, opts). The Chat surface (0.37.10) and Notes
surface (0.37.11) are the full page layouts that wire panes into
sidebars, panels, and routing.
Each version is a changeset. CI must be green before moving to the next. Tag at the end when functionality is restored.
9. Migration Strategy
Phase 1: Foundation (no user-visible changes)
- Vendor Preact + htm into the Docker image
- Build Layer 0 primitives as standalone components in
src/js/sw/ - Build Layer 1 SDK (new
src/js/sw/sdk.js) alongside old SDK - Add
GET /profile/permissionsbackend endpoint - Primitives test page: a hidden
/dev/primitivesroute that renders all primitives for visual verification
Gate: All primitives render correctly. SDK can auth, fetch, gate.
Phase 2: Shell swap
- Replace
base.htmlwith minimal shell template - Rewrite UserMenu as Preact component
- Rewrite Banners as Preact components
- Rewrite ToastContainer / DialogStack as Preact components
- Extension surface template stays the same (it's already minimal)
Gate: Login → shell renders → user menu works → extension surfaces load and function. Old surfaces (chat, admin, settings) are temporarily broken.
Phase 3: Surface rebuild (one at a time)
Rebuild each surface as a Preact component tree using SDK + primitives. Order by dependency (simplest first):
- Settings — self-contained, no real-time, smallest scope
- Admin — self-contained, no real-time, but large (many sections)
- Notes — moderate complexity, some real-time (graph)
- Chat — highest complexity, real-time streaming, most critical
Each surface rebuild is an independent unit of work. The old JS files for a surface are deleted when its replacement ships.
Gate per surface: All ICD runner tests pass for that surface's endpoints. Manual smoke test of all sections/features.
Phase 4: Cleanup
- Delete all old JS files (
ui-admin.js,ui-core.js, etc.) - Delete old CSS files replaced by primitives
- Remove
sb.jsaction registry (replaced by SDK) - Remove
APIglobal (internalized in SDK) - Remove all
window[name]dual-writes - Update all Go templates to remove server-rendered DOM
10. File Structure (new)
src/
js/
sw/ # New — all new code lives here
vendor/ # Preact + htm (copied at build)
primitives/
menu.js
dialog.js
confirm.js
prompt.js
toast.js
banner.js
drawer.js
tabs.js
dropdown.js
tooltip.js
form-field.js
button.js
avatar.js
spinner.js
index.js # Re-exports all primitives
sdk/
client.js # Internal REST client
auth.js # Auth + token management
permissions.js # RBAC cache + sw.can()
events.js # Event bus
theme.js # Theme control
pipe.js # Filter pipeline
api/ # Namespaced domain modules
channels.js
personas.js
knowledge.js
notes.js
...
index.js # Assembles the `sw` object
shell/
app.js # Root <App> component
user-menu.js
surface-viewport.js
banner-bar.js
toast-container.js
dialog-stack.js
surfaces/
settings/
index.js
sections/
profile.js
appearance.js
providers.js
...
admin/
index.js
sections/
overview.js
users.js
teams.js
providers.js
...
chat/
index.js
... (big — own design pass)
notes/
index.js
...
css/
variables.css # KEEP — token system
primitives.css # REWRITE — styles for new primitives
shell.css # NEW — shell layout
surfaces/ # NEW — per-surface CSS
settings.css
admin.css
chat.css
notes.css
11. Open Questions
-
Should the shell use client-side routing? Currently the Go backend handles routing (
/admin,/settings,/notes). We could keep that (each is a separate page load) or switch to client-side routing where the shell persists and surfaces swap in/out without a page reload. Client-side routing is smoother but adds complexity. The Go template engine still needs to serve the right initial HTML either way. -
Chat surface scope. Chat is ~55KB of JS (
chat.js) pluschat-pane.js,channel-models.js,pane-container.js,panels.js, and significant chunks ofui-core.js. This is by far the largest surface. Should it get its own detailed design pass before Phase 3? -
Workflow surfaces. Workflow has its own template engine, stage modes, and form rendering. How much of this moves into the new primitive/SDK system vs. stays as workflow-specific code?
-
CM6 / editor integration. The editor surface uses esbuild-bundled CodeMirror 6. This is already a somewhat isolated component. Does it stay as-is with a thin Preact wrapper, or get deeper integration?
-
Test strategy. The current
__tests__/directory has ~120KB of tests (api-contracts, auth-resilience, extensions, model-processing, policy-gating, user-journeys). These test against the old globals. Rewrite tests in parallel, or accept a gap during transition?
12. Risks
| Risk | Mitigation |
|---|---|
| Big-bang rewrite takes too long | Phase 1-2 can ship without breaking existing surfaces. Phase 3 is incremental per-surface. |
| Preact+htm too unfamiliar for me (Claude) to get right | htm uses tagged templates, not JSX — no build step, no transpiler surprises. Preact's API is a strict subset of React's hooks API, which is the most-documented frontend pattern in existence. |
| Old and new code coexisting | Phase 2 is the hard cut. Old surfaces can load old JS files until their Phase 3 rebuild. The shell is the only thing that must be new from Phase 2 onward. |
| Permission caching goes stale | Refresh permissions on token refresh (every 15 min). Emit auth.permissions.changed event so components can re-render. |
| Extension surfaces depend on old globals | Extension surfaces use sw.* (SDK). During transition, the SDK can expose backward-compat shims for API, Events, Theme globals. Shims emit deprecation warnings. |