Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
685 lines
26 KiB
Markdown
685 lines
26 KiB
Markdown
# 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<boolean> 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.
|
||
|
||
```js
|
||
// 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
|
||
|
||
```js
|
||
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
|
||
|
||
```js
|
||
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
|
||
|
||
```js
|
||
// 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:
|
||
|
||
```js
|
||
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:
|
||
|
||
```dockerfile
|
||
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:
|
||
|
||
```html
|
||
<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:
|
||
|
||
```js
|
||
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:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
{
|
||
"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.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)
|
||
|
||
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. | |