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 8c53f61b71 Changeset 0.37.1 (#213)
Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
2026-03-20 22:15:57 +00:00

685 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (Menu, Dialog, Toast, Drawer, etc.)
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. |