This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/UI redesign.md
gobha 4f1abc6321 Changeset 0.37.2 (#214)
Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
2026-03-20 23:52:55 +00:00

26 KiB
Raw Blame History

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.5 in code but v0.30.2 in 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 from API.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/permissions is admin-only. There is no GET /profile/permissions or equivalent. The login/refresh response includes only role: "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 1 npm pack pulls 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 pack targets.
  • No bundler for app code — scripts loaded sequentially via <script> tags in base.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

  1. No raw DOM. Every UI element is a Preact component. No innerHTML, no querySelector in application code.
  2. No raw fetch. All API access through sw.api.{domain}.*. Direct fetch() only inside the SDK internals.
  3. RBAC at the SDK level. sw.can('channel.create') checks the cached permission set. Primitives consume it: a menu item that requires admin.access simply doesn't render if the user lacks it.
  4. 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.
  5. CSS custom properties, not component-scoped CSS. Keep the existing variables.css token 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:

  1. Anchor-relative. Menu positions relative to its anchor element.
  2. Direction preference. Caller specifies preferred direction. Menu tries that first.
  3. 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.
  4. Clipping ancestor escape. If any ancestor has overflow: hidden/auto, the menu switches to position: fixed and uses anchor's getBoundingClientRect(). (This is the existing _positionFlyout logic from switchboard-sdk.js — proven correct.)
  5. Close triggers. Click outside, Escape key, scroll of a parent.
  6. Keyboard navigation. Arrow keys move focus, Enter selects, Home/End jump to first/last.
  7. Nested submenus. Open on hover/arrow-right, close on arrow-left. Only one submenu level deep (KISS).

Dialog Rules

  1. Focus trap. Tab cycles within the dialog. Focus starts on the first focusable element (or the primary action button).
  2. Backdrop click closes the dialog (unless persistent prop).
  3. Escape closes the dialog.
  4. Scroll lock. Body scroll is locked while dialog is open.
  5. Stacking. Multiple dialogs stack. Only the topmost receives input. Z-index increments per dialog.

Toast Rules

  1. Container is a fixed-position stack in bottom-right.
  2. Auto-dismiss after duration ms (default: 3000, errors: 5000).
  3. Max visible: 5. Older toasts are dismissed to make room.
  4. Hover pauses the auto-dismiss timer.
  5. 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 (data arrays, 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-send
  • sw.pipe.stream(priority, fn, opts) — post-receive stream
  • sw.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:

  1. User Menu — avatar + flyout (Settings, Admin, Team Admin, Debug, Sign Out)
  2. User Settings — drawer or route (/settings)
  3. Admin Settings — drawer or route (/admin)
  4. Surface Viewport — the <div> where the active surface renders
  5. 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:

  1. <head> with CSS + early theme script (keep)
  2. <body> with a single <div id="app"> mount point
  3. Script tags for vendor libs + primitives + SDK + shell
  4. 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.89) 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)

  1. Vendor Preact + htm into the Docker image
  2. Build Layer 0 primitives as standalone components in src/js/sw/
  3. Build Layer 1 SDK (new src/js/sw/sdk.js) alongside old SDK
  4. Add GET /profile/permissions backend endpoint
  5. Primitives test page: a hidden /dev/primitives route that renders all primitives for visual verification

Gate: All primitives render correctly. SDK can auth, fetch, gate.

Phase 2: Shell swap

  1. Replace base.html with minimal shell template
  2. Rewrite UserMenu as Preact component
  3. Rewrite Banners as Preact components
  4. Rewrite ToastContainer / DialogStack as Preact components
  5. 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):

  1. Settings — self-contained, no real-time, smallest scope
  2. Admin — self-contained, no real-time, but large (many sections)
  3. Notes — moderate complexity, some real-time (graph)
  4. 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

  1. Delete all old JS files (ui-admin.js, ui-core.js, etc.)
  2. Delete old CSS files replaced by primitives
  3. Remove sb.js action registry (replaced by SDK)
  4. Remove API global (internalized in SDK)
  5. Remove all window[name] dual-writes
  6. 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

  1. 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.

  2. Chat surface scope. Chat is ~55KB of JS (chat.js) plus chat-pane.js, channel-models.js, pane-container.js, panels.js, and significant chunks of ui-core.js. This is by far the largest surface. Should it get its own detailed design pass before Phase 3?

  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?

  4. 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?

  5. 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.