11 KiB
Surface Development Guide
Version: 0.25.0
Audience: Anyone with admin access to a Chat Switchboard instance
Overview
A surface is a full-screen application mode in Chat Switchboard. The platform ships with core surfaces (Chat, Notes, Settings, Admin, Workflow) that cannot be uninstalled. Extension surfaces are uploaded as .surface archives via the Admin panel and can be enabled, disabled, and uninstalled at runtime.
This guide covers everything needed to build, package, install, and manage surfaces using only a running instance — no access to the Go codebase required.
Quick Start
Create a minimal surface in under 5 minutes:
1. Create the files
manifest.json:
{
"id": "hello",
"version": "1.0.0",
"title": "Hello World",
"description": "Minimal surface example",
"route": "/hello",
"auth": "authenticated",
"scripts": ["js/hello.js"],
"styles": ["css/hello.css"],
"source": "extension"
}
js/hello.js:
(function() {
document.addEventListener('DOMContentLoaded', function() {
if (window.__SURFACE__ !== 'hello') return;
var root = document.getElementById('surfaceRoot');
if (!root) return;
root.innerHTML =
'<div style="display:flex;flex-direction:column;height:100%;background:var(--bg);color:var(--text);font-family:inherit;">' +
'<div style="display:flex;align-items:center;gap:8px;padding:0 12px;height:40px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:relative;z-index:20;">' +
'<a href="' + (window.__BASE__ || '') + '/" style="color:var(--text-3);text-decoration:none;font-size:12px;">← Back</a>' +
'<span style="font-size:13px;font-weight:600;">Hello World</span>' +
'</div>' +
'<div style="flex:1;display:flex;align-items:center;justify-content:center;">' +
'<div style="text-align:center;">' +
'<div style="font-size:48px;margin-bottom:16px;">👋</div>' +
'<h1 style="font-size:24px;font-weight:600;margin:0 0 8px;">Hello from a Surface</h1>' +
'<p style="color:var(--text-3);margin:0;" id="helloStatus">Waiting for app init…</p>' +
'</div>' +
'</div>' +
'</div>';
document.addEventListener('sb:ready', function() {
var el = document.getElementById('helloStatus');
if (el) {
el.textContent = 'Logged in as ' + (API.user?.username || 'unknown') +
' | ' + (App.models?.length || 0) + ' models loaded';
}
}, { once: true });
});
})();
css/hello.css:
/* Empty for minimal example. Real surfaces use CSS classes with var(--*) tokens. */
2. Package
zip -r hello.surface manifest.json js/ css/
3. Install
Admin → System → Surfaces → + Install Surface. Upload hello.surface.
4. Visit
Navigate to https://your-instance/hello.
How Surface Loading Works
Browser requests /hello
→ Router matches route from surface_registry
→ Auth middleware runs (based on manifest "auth" field)
→ base.html renders with:
- Common CSS (variables, layout, components)
- Common JS (api.js, events.js, ui-core.js, all component factories)
- Your surface CSS and JS from manifest
- app.js (boot script)
→ DOMContentLoaded fires:
1. Your surface JS runs — build DOM, create components
2. app.js init() runs — auth, settings, models, WebSocket
3. 'sb:ready' CustomEvent fires when init is complete
Critical: Boot Timing
Your JS runs BEFORE app init completes.
| Available at DOMContentLoaded | Available at sb:ready |
|---|---|
window.__SURFACE__, window.__BASE__ |
App.models, App.settings |
| All component factories | API.user, API.isAdmin |
| DOM ready | Events WebSocket connected |
Rule: Anything needing models, user info, or settings goes in sb:ready:
document.addEventListener('sb:ready', function() {
// Safe to use App.models, API.user, App.settings
}, { once: true });
manifest.json Reference
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier. Lowercase, no spaces. |
title |
Yes | Display name in admin panel and nav. |
version |
No | Semver for display. |
description |
No | Short description for admin. |
route |
No | URL pattern. Gin params: /thing/:id. Defaults to /{id}. |
alt_routes |
No | Additional URL patterns. |
auth |
No | "authenticated" (default), "admin", "session", "public". |
scripts |
No | JS files relative to archive root. |
styles |
No | CSS files relative to archive root. |
components |
No | Component IDs used (documentation only). |
source |
No | Always "extension" for uploaded surfaces. |
.surface Archive Format
my-surface.surface (ZIP file)
├── manifest.json ← Required
├── js/
│ └── my-surface.js ← Boot script(s)
├── css/
│ └── my-surface.css ← Styles
└── assets/ ← Optional: images, fonts
Standard ZIP. .surface extension is conventional; .zip also accepted.
CSS Theme Tokens
Use these instead of hardcoded colors:
/* Backgrounds */
var(--bg) var(--bg-secondary) var(--bg-tertiary) var(--bg-raised) var(--bg-hover)
/* Text */
var(--text) var(--text-2) var(--text-3)
/* Accent */
var(--accent) var(--accent-dim) var(--accent-hover)
/* Borders */
var(--border) var(--border-light)
/* Semantic */
var(--success) var(--warning) var(--danger) var(--purple)
var(--success-dim) var(--warning-dim) var(--danger-dim) var(--purple-dim)
/* Type & Layout */
var(--mono) var(--msg-font) var(--radius) var(--radius-lg) var(--transition)
Reusable Components
All loaded by base.html on every surface. No imports needed.
UserMenu
// Build DOM into a container
container.innerHTML =
'<div class="user-menu-wrap" id="userMenuWrap">' +
'<button id="userMenuBtn" class="user-btn">' +
'<div id="userAvatar" class="user-avatar"><span id="avatarLetter">?</span></div>' +
'<span id="userName" class="sb-label">User</span>' +
'</button>' +
'<div id="userFlyout" class="user-flyout">' +
'<button id="menuSettings" class="flyout-item">Settings</button>' +
'<button id="menuAdmin" class="flyout-item" style="display:none">Admin</button>' +
'<button id="menuDebug" class="flyout-item" style="display:none">Debug</button>' +
'<hr class="flyout-divider">' +
'<button id="menuSignout" class="flyout-item flyout-danger">Sign Out</button>' +
'</div>' +
'</div>';
// Wire after auth (sb:ready)
var menu = UserMenu.create({ id: '' });
menu.setUser(API.user);
menu.bind({
onSettings: function() { location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: function() { location.href = (window.__BASE__ || '') + '/admin'; },
onSignout: function() { if (typeof handleLogout === 'function') handleLogout(); },
});
menu.showAdmin(API.isAdmin);
ChatPane
// Build DOM
slot.innerHTML =
'<div class="chat-pane" id="xChatPane">' +
'<div class="chat-pane-messages" id="xChatMessages"></div>' +
'<div class="chat-pane-input-bar">' +
'<div class="chat-pane-input-wrap">' +
'<div class="chat-pane-input" id="xChatInput"></div>' +
'<button class="chat-pane-send" id="xSendBtn">Send</button>' +
'</div>' +
'</div>' +
'</div>';
// Create instance
var pane = ChatPane.create({
id: 'x',
messagesEl: document.getElementById('xChatMessages'),
inputEl: document.getElementById('xChatInput'),
sendBtnEl: document.getElementById('xSendBtn'),
standalone: true,
});
pane.showWelcome();
PaneContainer
PaneContainer.registerPreset('my-layout', {
type: 'split', direction: 'horizontal',
sizes: [250, null, 350],
children: [
{ type: 'leaf', id: 'nav', size: 250, minSize: 150 },
{ type: 'leaf', id: 'main' }, // flex pane (no size)
{ type: 'tabbed', id: 'assist', size: 350, minSize: 200,
tabs: [{ id: 'chat', label: 'Chat' }, { id: 'tools', label: 'Tools' }]
},
],
});
var layout = PaneContainer.mount(document.getElementById('body'), 'my-layout');
var mainEl = layout._panes.get('main').el;
var chatTab = layout._panes.get('assist').getTabPanel('chat');
Rules: One child per split must omit size (flex pane). Drag handles cap at 60%. Sizes persist in localStorage.
Making API Calls
// CRUD
var data = await API._get('/api/v1/channels');
var resp = await API._post('/api/v1/channels', { title: 'New', type: 'direct' });
await API._put('/api/v1/channels/' + id, { title: 'Updated' });
await API._del('/api/v1/channels/' + id);
// Streaming (SSE)
var resp = await API.streamCompletion(channelId, content, model, abortSignal);
var reader = resp.body.getReader();
Auth tokens and base path are handled automatically.
Admin: Surface Management
Location: Admin → System → Surfaces
| Action | Description |
|---|---|
| + Install Surface | Upload .surface or .zip archive |
| Toggle | Enable/disable. Disabled surfaces redirect to Chat. |
| Uninstall | Remove extension surface (files + DB entry). |
Protected: Chat and Admin toggles are locked — cannot be disabled.
API
# List
curl -H "$AUTH" "$BASE/api/v1/admin/surfaces"
# Install
curl -H "$AUTH" -F "file=@hello.surface" "$BASE/api/v1/admin/surfaces/install"
# Disable / Enable
curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/disable"
curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/enable"
# Uninstall
curl -X DELETE -H "$AUTH" "$BASE/api/v1/admin/surfaces/hello"
# Public: list enabled (for nav)
curl -H "$AUTH" "$BASE/api/v1/surfaces"
Checklist
manifest.jsonhasidandtitle- Boot script checks
window.__SURFACE__guard - DOM built in JS (no Go template dependency)
- Deferred init via
sb:ready - Topbar:
position: relative; z-index: 20 - All colors use
var(--*)tokens - Back link:
window.__BASE__ + '/' - Graceful with 0 models
- Archive: only
manifest.json,js/,css/,assets/
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Surface doesn't appear after install | Routes registered at startup | Restart server or toggle disable/enable |
| "Cannot overwrite core surface" | ID conflicts with chat/notes/etc. | Choose different ID |
API is undefined |
Code runs before app init | Move to sb:ready listener |
| No styles applied | CSS path mismatch | Check manifest styles matches archive structure |
| Flyout hidden behind content | Missing stacking context | Add z-index: 20 to topbar |
| Drag handle snaps | v0.25.0 known issue | Sub-pixel rounding; partially mitigated |