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/docs/DESIGN-SURFACES.md
2026-03-04 10:44:42 +00:00

25 KiB

DESIGN — Surface & Extension Architecture

Status: Accepted
Scope: Primitives, Components, Surfaces, Extension hooks, Themes
Depends on: v0.22.5 (Go template engine), v0.22.7 (ChatPane)
Informs: v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE


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.

// 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, ui-primitives-additions.js, 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.

{{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 the factory pattern established by ChatPane:

const pane = ChatPane.create({
    messagesEl:  document.getElementById('editorChatMessages'),
    inputEl:     document.getElementById('editorChatInput'),
    sendBtnEl:   document.getElementById('editorSendBtn'),
    channelId:   workspaceChatId,
    standalone:  true,
});

// Lifecycle
pane.renderMessages(msgs);
pane.streamResponse(resp, msgs);
pane.destroy();

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:

{{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:

{
  "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.

{
  "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 <html>.

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

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.

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 <img> elements and wraps them with action buttons. They don't know about each other — they agree on the DOM contract.

ctx.hooks.postRender((containerEl, message) => {
    containerEl.querySelectorAll('img[data-display-content]').forEach(img => {
        const actions = document.createElement('div');
        actions.className = 'image-actions';
        actions.innerHTML = '<button data-action="upscale">Upscale</button>';
        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: "<div>...", extension_id: "editor" }

Storage: Display content is persisted in a display_content JSONB column on the message (or a junction table). It survives page reload and is included in GET /channels/:id/path responses.

Hook 5: Tool Bridge

When: The LLM invokes a tool registered by the extension.
Receives: Tool name and input parameters.
Returns: Tool result (string or structured).

Existing mechanism — the WebSocket tool bridge from EXTENSIONS.md. Tool is registered via manifest, exposed to the LLM through the tools list, and executed client-side (browser tier) or server-side (starlark/sidecar tiers).

Hook 6: Surface Injection

When: Surface boot (DOMContentLoaded).
Receives: The surface ID and available mount points.
Returns: Nothing (attaches to mount points).

Extensions declare which surfaces they target and which mount points they use:

{
  "surface_injections": [{
    "surface": "chat",
    "mount_point": "side-panel",
    "component": "my-panel.js"
  }, {
    "surface": "admin",
    "mount_point": "section",
    "section_id": "my-admin-section",
    "label": "Image Gen Settings"
  }]
}

Mount points are declared by each surface template:

<div data-mount="side-panel"></div>
<div data-mount="section" data-section-id="..."></div>

Trust Model

Two tiers. The admin is the trust boundary.

Privileged (Admin-Installed)

Capability Allowed
Full DOM access Yes
Direct API calls (user's JWT) Yes
Modify other extensions' output (post-render) Yes
Create surfaces (own routes) Yes
Register tools Yes
Access all hook types Yes
Go template data loader Yes
Read window.__PAGE_DATA__ Yes

The admin chose to install it. Same trust model as a VS Code extension or a WordPress plugin — you trust the publisher.

Sandboxed (User-Installed, Future)

Capability Allowed
Shadow DOM / iframe only Yes
API calls through message bridge Yes (proxied, scoped)
Modify other extensions' output No
Create surfaces No
Register tools Limited (user-scoped)
Access hooks Post-render own output only
Go template data loader No
Read window.__PAGE_DATA__ No

Post-1.0 scope. Documented here to confirm the architecture accommodates it without redesign.


Extension Lifecycle

Boot Sequence

1. Page loads (Go template renders surface shell + banner)
2. Primitives initialize (CSS loaded, JS factories available)
3. Components hydrate (attach to server-rendered DOM)
4. Extensions.boot() — idempotent, runs on every surface
   a. Load extension manifests
   b. Register hooks (pre-completion, post-render, etc.)
   c. Register surface injections for current surface
   d. Initialize tool bridges
5. Surface-specific JS runs (app.js for chat, editor-mode.js, etc.)

Extensions.boot() replaces the current chat-specific Extensions.loadAll() / Extensions.initAll() pair and runs on every surface.

Manifest Declaration

{
  "id": "image-generator",
  "name": "AI Image Generator",
  "version": "1.0.0",
  "tier": "browser",
  "hooks": {
    "pre_completion": "hooks/pre-completion.js",
    "stream_chunk": "hooks/stream.js",
    "post_render": "hooks/post-render.js"
  },
  "tools": [{
    "name": "generate_image",
    "description": "Generate an image from a text description",
    "parameters": { ... }
  }],
  "surfaces": [{
    "id": "image-gallery",
    "route": "/s/gallery",
    "title": "Image Gallery",
    "components": ["chat-pane"],
    "data_requires": ["channels"],
    "script": "surfaces/gallery.js"
  }],
  "surface_injections": [{
    "surface": "chat",
    "mount_point": "message-actions",
    "script": "injections/image-actions.js"
  }],
  "theme": null,
  "settings_schema": { ... }
}

Display Content — Data Model

Message Extension

ALTER TABLE messages ADD COLUMN display_content JSONB DEFAULT '[]';

Array of display items:

[
  {
    "type": "image",
    "src": "data:image/png;base64,...",
    "alt": "A cat in a spacesuit",
    "extension_id": "image-generator",
    "metadata": { "model": "dall-e-3", "size": "1024x1024" }
  },
  {
    "type": "html",
    "content": "<div class='chart'>...</div>",
    "extension_id": "data-viz",
    "metadata": {}
  }
]

API Contract

Display content is included in message objects returned by GET /channels/:id/path and GET /channels/:id/messages.

It is excluded from the message array sent to the LLM provider in POST /chat/completions. The completion handler strips display_content when assembling the provider request.

Rendering

During renderMessages(), after the message content is rendered, each display_content item is rendered below the text content:

<div class="message-content">
  <p>Here's the image you requested:</p>
</div>
<div class="message-display-content">
  <img src="..." alt="..." data-display-content data-extension="image-generator">
</div>

The data-display-content attribute is the DOM contract that post-render hooks use to discover display content from any extension.


Migration Path

Current State Target State
ui-primitives.js + ui-primitives-additions.js Primitive catalog with stable factory API
ChatPane.create() (v0.22.7) Component catalog with shared instance pattern
5 Go template routes Surface registry (hardcoded core, manifest-driven extensions)
ctx.ui.replace() / ctx.ui.inject() Hook 6: Surface Injection with declared mount points
ctx.surfaces.register() Manifest surfaces field → page engine route registration
Block renderers in Extensions.initAll() Extensions.boot() on every surface
Message content only (text + attachments) display_content column for render-only visual content
Block renderer pipeline (mermaid, katex) Post-render hook + DOM contract (data-display-content)
[data-theme] with CSS variables Theme contract: stable CSS custom property namespace

Implementation Sequence

This does not prescribe version numbers. It describes dependency order.

Phase 1: Primitive consolidation

  • Audit ui-primitives.js, ui-primitives-additions.js, and Go template components
  • Define the primitive catalog and factory API
  • Define the CSS custom property contract (theme API)
  • Migrate existing callers to the consolidated API

Phase 2: Component formalization

  • ChatPane already exists (v0.22.7). Formalize the instance pattern.
  • Extract NoteEditor, FileTree, ModelSelector as components
  • Each component gets a Go template partial + JS hydration

Phase 3: Extension hooks

  • Extensions.boot() on every surface (v0.22.8)
  • Pre-completion and post-render hooks
  • Display content data model (message column + render pipeline)
  • Stream processing hook

Phase 4: Surface registry

  • Manifest-driven surface registration
  • Dynamic route generation in page engine
  • Data loader registry
  • Mount point declaration and injection

Phase 5: Theme system

  • Stable CSS custom property contract
  • Admin theme upload
  • User theme selection in Settings → Appearance

Open Questions

  1. Display content storage: JSONB column on messages vs. separate message_display_content table. Column is simpler; table allows independent lifecycle (delete display content without touching message). Leaning column for KISS.

  2. Display content size limits: Base64 images in JSONB could get large. May need blob storage for display content with URL references instead of inline data. Deferred until real usage patterns emerge.

  3. Extension ordering: Post-render hooks from multiple extensions run in what order? Manifest priority field? Installation order? Alphabetical? Matters when Extension B adds buttons to Extension A's images — A must render first.

  4. Surface layout persistence: If a surface supports resizable panes (editor: file tree width, chat pane width), where is that persisted? User settings? Per-workspace? Per-surface?

  5. Extension settings storage: Extensions need per-user config (API keys for image gen, preferences for behavior). Current UpdateUserExtensionSettings endpoint exists. Is the schema sufficient or does it need structured validation from settings_schema in the manifest?