# DESIGN — Surface & Extension Architecture
**Status:** Accepted (Phases 1–2 implemented in v0.25.1)
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit)
**Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE
**Note:** For current package/surface authoring, see [PACKAGES.md](PACKAGES.md) and [EXTENSION-SURFACES.md](EXTENSION-SURFACES.md). For workflow surfaces, see [WORKFLOW-PACKAGES.md](WORKFLOW-PACKAGES.md).
---
## Overview
The UI is a four-layer architecture: Primitives, Components, Surfaces,
and Banners. Each layer has a single responsibility and a strict
dependency direction — primitives know nothing, components compose
primitives, surfaces compose components, banners are platform chrome
outside the surface boundary.
Extensions participate at every layer through six defined hooks.
Admin-installed extensions are privileged (full DOM, direct API access,
own routes). Themes are pure CSS custom property overrides that
propagate through every layer uniformly.
This document defines the layer contracts, extension hook system,
display content model, trust boundaries, and theme architecture.
---
## Definitions
A **surface** is a full-page layout — a composition of components with
a route, a data loader, and a boot script. Chat is a surface. The
editor is a surface. A custom triage intake form is a surface.
An **extension** is a package — a manifest plus code plus assets that
participates in the platform through hooks. An extension can do one,
some, or all of:
- Register tools the LLM can call (Hook 5: Tool Bridge)
- Transform content during streaming (Hook 2: Stream Processing)
- Enhance rendered messages (Hook 3: Post-Render)
- Attach visual content to messages (Hook 4: Display Content)
- Inject panels or sections into existing surfaces (Hook 6: Surface Injection)
- Create entirely new surfaces with their own routes (Surface Registration)
The five **core surfaces** (Chat, Editor, Notes, Admin, Settings) exist
without extensions. They are built from the same primitive and component
layers that extensions use, but they are registered directly in Go
rather than through a manifest.
An extension that creates a surface gets its own route (`/s/:slug`),
its own data loader, and the full primitive/component library. An
extension that only registers a post-render hook (like a block
renderer) doesn't create any surfaces at all.
**Examples:**
| Extension | Creates Surface? | Hooks Used |
|-----------|-----------------|------------|
| Mermaid renderer | No | Post-Render |
| KaTeX renderer | No | Post-Render |
| Calculator tool | No | Tool Bridge |
| Image Generator | Yes (`/s/gallery`) | Tool Bridge, Stream Processing, Post-Render, Display Content, Surface |
| Custom Dashboard | Yes (`/s/dashboard`) | Surface, Surface Injection (admin section) |
---
## Existing Extension Mapping
The current extension mechanisms map directly into this architecture:
| Current Mechanism | Architecture Equivalent |
|-------------------|------------------------|
| Block renderers (`ctx.renderers.register()`) | Hook 3: Post-Render |
| Tool bridge (`ctx.tools.register()`) | Hook 5: Tool Bridge |
| `ctx.ui.toast()`, `ctx.ui.openPreview()` | Primitive layer (unchanged API) |
| `ctx.ui.isDark()`, `ctx.ui.isMobile()` | Theme layer queries |
| `ctx.surfaces.getCurrent()` | Returns `window.__SURFACE__` |
The manifest schema gains optional new fields (`hooks`, `surfaces`,
`surface_injections`, `theme`). Existing fields retain their meaning.
`Extensions.boot()` is the single entry point for extension
initialization on every surface. It loads manifests, registers
renderers, and initializes tool bridges — same sequence, available
on every surface rather than only chat.
---
## Layer Model
```
┌──────────────────────────────────────────────┐
│ Banner (top) │ ← Platform config
├──────────────────────────────────────────────┤
│ │
│ Surface │ ← Layout + lifecycle
│ ┌─────────────┬──────────────────────────┐ │
│ │ Component │ Component │ │ ← Domain-aware
│ │ (ChatPane) │ (NoteEditor) │ │
│ │ ┌─────────┐ │ ┌────────┐ ┌──────────┐ │ │
│ │ │Primitive│ │ │Primitv.│ │Primitive │ │ │ ← Atomic UI
│ │ │ (input) │ │ │ (menu) │ │ (toggle) │ │ │
│ │ └─────────┘ │ └────────┘ └──────────┘ │ │
│ └─────────────┴──────────────────────────┘ │
│ │
├──────────────────────────────────────────────┤
│ Banner (bottom) │ ← Platform config
└──────────────────────────────────────────────┘
```
Four layers, strict dependency direction: Primitives know nothing.
Components use Primitives. Surfaces compose Components. Banners are
global chrome outside the surface boundary.
---
## Layer 1: Primitives
Atomic UI elements. No domain logic. A toggle doesn't know if it's
toggling a KB or a theme — it takes a label, a state, and a callback.
Styled entirely via CSS custom properties (the theme contract). Every
primitive reads from the same property namespace, so theme changes
propagate instantly.
### Catalog
| Primitive | Description |
|-----------|-------------|
| `Input` | Text, textarea, number, password, with label + validation |
| `Select` | Dropdown, single or multi |
| `Toggle` | Boolean switch with label |
| `Checkbox` | Checkbox with label |
| `Button` | Text button, variants: primary, secondary, danger, ghost |
| `IconButton` | Icon-only button with tooltip |
| `ColorPicker` | Color input with hex text field |
| `Menu` | Dropdown or context menu, item list with icons + shortcuts |
| `Dialog` | Modal: confirm, prompt, or custom form content |
| `Toast` | Transient notification: success, error, warning, info |
| `Badge` | Inline label: accent, success, danger, warning, muted |
| `Avatar` | Image circle with upload affordance |
| `Tabs` | Tab bar with content switching |
| `FormGroup` | Label + input + validation message + help text |
| `SectionHeader` | Titled section divider |
| `EmptyState` | Placeholder with icon + message + action |
| `Spinner` | Loading indicator |
| `Table` | Sortable, paginated data table |
### Implementation
Each primitive is a factory function that returns a DOM element (or
attaches to an existing one). No classes, no inheritance — just
functions.
```js
// Example — not prescriptive API, just the pattern:
Primitives.toggle({ label: 'Auto-search', value: true, onChange: fn })
Primitives.menu({ anchor: el, items: [...], onSelect: fn })
Primitives.dialog({ title: 'Confirm', body: el, onConfirm: fn })
```
Current locations: `ui-primitives.js` (factory functions, `esc()`,
`createComponentRegistry()`, `componentMixin()`, `showConfirm()`,
`showPrompt()`, `Theme`, `mountAvatarUpload()`)
and Go template components (`model-select.html`, `team-select.html`,
`file-upload.html`). These converge into one coherent set.
### Go Template Primitives
Some primitives need server-rendered initial state (e.g., model-select
needs the model list, team-select needs the team list). These are Go
template partials that render the HTML + initial data, then JS hydrates
interactivity on load.
```html
{{template "model-select" dict "ID" "channelModel" "Models" .Models "Type" "chat"}}
```
The JS primitive attaches to the server-rendered DOM by ID, not by
replacing it.
---
## Layer 2: Components
Domain-aware compositions of primitives. Each component owns its DOM
subtree, manages its own state, and exposes a clean API for the surface
to interact with.
### Catalog
| Component | Primitives Used | Domain |
|-----------|----------------|--------|
| `ChatPane` | Input, Button, Menu, Spinner, Toast | Channels, Messages, Streaming |
| `NoteEditor` | Input (CM6), Menu, Tabs, Badge | Notes, Wikilinks, Folders |
| `FileTree` | Menu (context), Button, Spinner | Workspaces, Files |
| `ModelSelector` | Select, Badge, Spinner | Models, Capabilities, Health |
| `PersonaPicker` | Select, Badge, Avatar | Personas, Scopes |
| `KBPicker` | Toggle, Badge, Select | Knowledge Bases, Discoverability |
| `ProjectSidebar` | Menu, Tabs, Badge, EmptyState | Projects, Channels, DnD |
| `AuditLog` | Table, Select, Badge | Audit, Filtering, Pagination |
| `CodeEditor` | Input (CM6), Select, Tabs | Workspaces, Languages |
| `SettingsForm` | FormGroup, Toggle, Select, Button | User/Admin Settings |
### Instance Pattern
Components use `createComponentRegistry()` and `componentMixin()` from
`ui-primitives.js` for shared lifecycle (instance tracking, event
listener cleanup, destroy):
```js
const ChatPane = {
...createComponentRegistry('ChatPane'),
create(opts) {
const instance = componentMixin({
id: opts.id || 'pane-' + Date.now(),
messagesEl: opts.messagesEl,
inputEl: opts.inputEl,
channelId: opts.channelId || null,
// ... domain-specific state
// Component-specific teardown (called by mixin's destroy())
_cleanup() {
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
},
}, ChatPane);
ChatPane._register(instance.id, instance);
return instance;
},
// Custom queries beyond .get(id)
forChannel(channelId) { /* ... */ },
};
```
The mixin provides `_on(el, event, handler)` for tracked event binding
and `destroy()` which calls `_cleanup()` then removes all listeners and
unregisters from the registry. Components that need extra teardown define
`_cleanup()`.
Components can be instantiated multiple times on the same page (editor
has a ChatPane, notes has a ChatPane — independent instances).
### Server-Rendered Shell + JS Hydration
Components have a Go template partial for the server-rendered scaffold:
```html
{{template "chat-pane" dict "ID" "editor"}}
```
This renders the DOM structure with predictable IDs. The JS component
attaches to these IDs on DOMContentLoaded. No client-side DOM
construction for the initial layout.
---
## Layer 3: Surfaces
A surface is a full-page layout that composes components. Each surface
declares:
1. **What components it uses** (ChatPane, NoteEditor, FileTree, etc.)
2. **How they're arranged** (CSS grid/flex layout)
3. **What data it needs on load** (Go data loader)
4. **What JS runs on boot** (script block or module)
### Current Surfaces
| Surface | Components | Data Loader |
|---------|-----------|-------------|
| Chat | ChatPane, ProjectSidebar, ModelSelector, PersonaPicker | channels, personas, models, projects |
| Editor | FileTree, CodeEditor, ChatPane (assist) | workspace, files, models |
| Notes | NoteEditor, NoteGraph, ChatPane (assist) | notes, folders, graph |
| Admin | AuditLog, SettingsForm, Table (various) | users, configs, models, health |
| Settings | SettingsForm, KBPicker, PersonaPicker | profile, preferences, policies |
### Surface Registration
Today: hardcoded in Go (`pageEngine.RenderSurface("chat")`).
Future: manifest-driven. An admin-installed extension declares a
surface in its manifest:
```json
{
"surfaces": [{
"id": "triage-intake",
"route": "/s/triage",
"title": "Triage Intake",
"components": ["chat-pane", "settings-form"],
"data_requires": ["personas", "models"],
"script": "surfaces/triage.js",
"auth": "authenticated"
}]
}
```
The page engine reads registered surfaces, generates routes, assembles
data loaders from the declared requirements, and renders a shell
template that loads the surface's script.
**Route namespace:** Admin-created surfaces live under `/s/:slug` to
avoid collision with core routes.
### Data Loaders
Each surface declares what data it needs. The page engine has a
registry of data providers:
```
"personas" → loads personas for the current user
"models" → loads enabled models with health status
"channels" → loads user's channels
"workspace" → loads workspace by :wsId param
"notes" → loads notes list
...
```
The surface's `data_requires` field pulls from this registry. Data is
injected into the page as `window.__PAGE_DATA__` (existing pattern).
The surface's JS reads from there on boot — no waterfall of API calls
on page load.
---
## Layer 4: Banners
The banner is global chrome outside the surface boundary. It exists
at the top, bottom, or both — configured by the platform admin via
`PUT /admin/settings/banner`.
```json
{
"enabled": true,
"text": "DEVELOPMENT",
"position": "both|top|bottom",
"bg": "#007a33",
"fg": "#ffffff"
}
```
The Go template base layout renders banners before and after the
surface content area. CSS custom properties (`--banner-top-height`,
`--banner-bottom-height`, `--banner-bg`, `--banner-fg`) allow surfaces
to account for banner space without knowing banner state.
Banners are not configurable by extensions or themes. They are a
platform-level trust signal.
---
## Themes
A theme is a set of CSS custom property overrides applied to ``.
```css
[data-theme="corporate"] {
--bg: #f5f5f5;
--text: #1a1a1a;
--accent: #0066cc;
--border: #d1d5db;
--font-ui: 'Inter', sans-serif;
--font-code: 'Fira Code', monospace;
--radius: 4px;
/* ... full property set */
}
```
### What a Theme Can Do
- Override any CSS custom property in the theme contract
- Change colors, fonts, border radii, spacing scale
- Switch between light and dark base palettes
- Apply to every primitive, component, and surface uniformly
### What a Theme Cannot Do
- Add or remove DOM elements
- Execute JavaScript
- Modify component behavior or layout
- Override banner appearance (platform chrome)
- Access APIs or user data
### Theme Contract
The set of CSS custom properties that all primitives read from. This
is the stable API between themes and the UI. Properties are namespaced:
```
--bg, --bg-secondary, --bg-tertiary (backgrounds)
--text, --text-secondary, --text-muted (typography)
--accent, --accent-hover, --accent-muted (interactive)
--border, --border-strong (edges)
--success, --warning, --danger (semantic)
--font-ui, --font-code (typefaces)
--radius, --radius-lg (shapes)
--shadow, --shadow-lg (elevation)
--banner-bg, --banner-fg (read-only, set by platform)
```
Themes are admin-installed. An admin uploads a CSS file that declares
a `[data-theme="name"]` rule set. Users can select from installed
themes in Settings → Appearance.
Built-in themes: `dark` (default), `light`. The system preference
auto-detection (`prefers-color-scheme`) continues to work.
---
## Extension Hooks
Extensions interact with the application through a defined set of
hooks. Each hook has a specific trigger point in the lifecycle and
a clear contract for what the extension receives and returns.
### Hook 1: Pre-Completion
**When:** After the user sends a message, before the API request fires.
**Receives:** The completion request object (model, messages, tools, etc.)
**Returns:** Modified request object (or unmodified to pass through).
**Use case:** Inject additional tools, modify system prompt, add context.
```js
ctx.hooks.preCompletion(request => {
request.tools.push(myCustomTool);
return request;
});
```
### Hook 2: Stream Processing
**When:** On each SSE chunk during streaming.
**Receives:** The chunk (content delta, tool_use, tool_result, etc.)
**Returns:** Modified chunk (or null to suppress).
**Use case:** Transform content, intercept tool calls, accumulate data.
```js
ctx.hooks.streamChunk((chunk, context) => {
if (chunk.type === 'tool_result' && chunk.name === 'image_gen') {
context.displayContent.push({ type: 'image', src: chunk.data.url });
return null; // suppress from text content
}
return chunk;
});
```
### Hook 3: Post-Render
**When:** After a message is rendered into the DOM.
**Receives:** The message container element, the message object.
**Returns:** Nothing (mutates DOM in place).
**Use case:** Add action buttons, wrap elements, enhance display.
This is how extensions compose with each other's output. Extension A
produces an image via display content. Extension B's post-render hook
finds `` elements and wraps them with action buttons. They don't
know about each other — they agree on the DOM contract.
```js
ctx.hooks.postRender((containerEl, message) => {
containerEl.querySelectorAll('img[data-display-content]').forEach(img => {
const actions = document.createElement('div');
actions.className = 'image-actions';
actions.innerHTML = '';
img.parentElement.appendChild(actions);
});
});
```
### Hook 4: Display Content
**When:** After a completion finishes (all chunks received).
**Receives:** The assistant message object, accumulated display content.
**Returns:** Display content items to attach to the message.
Display content is **message-scoped, rendered inline, but excluded from
the LLM context window**. It's not in `messages.content` and not sent
back on the next turn.
```
Message
├── content (text — goes to LLM)
├── attachments (files — go to LLM via multimodal assembly)
└── display_content[] ← rendered inline, NOT sent to LLM
├── { type: "image", src: "...", extension_id: "img-gen" }
└── { type: "html", content: "