Changeset 0.25.0 (#160)
This commit is contained in:
1288
docs/ROADMAP.md
1288
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
350
docs/SURFACES.md
Normal file
350
docs/SURFACES.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# 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:**
|
||||
```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 |
|
||||
Reference in New Issue
Block a user