351 lines
11 KiB
Markdown
351 lines
11 KiB
Markdown
# Surface Development Guide
|
|
|
|
**Version:** 0.25.1
|
|
**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:**
|
|
```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:**
|
|
```javascript
|
|
(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:**
|
|
```css
|
|
/* Empty for minimal example. Real surfaces use CSS classes with var(--*) tokens. */
|
|
```
|
|
|
|
### 2. Package
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```css
|
|
/* 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```bash
|
|
# 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.json` has `id` and `title`
|
|
- [ ] 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 |
|