Feat v0.2.3 sdk tasks (#7)
Some checks failed
CI/CD / detect-changes (push) Successful in 23s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Failing after 2m27s
CI/CD / test-sqlite (push) Successful in 2m34s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-03-27 11:06:40 +00:00
committed by xcaliber
parent 57ccf19efb
commit 239a5fb732
26 changed files with 2230 additions and 38 deletions

View File

@@ -2,6 +2,40 @@
All notable changes to Switchboard Core are documented here. All notable changes to Switchboard Core are documented here.
## [Unreleased] — v0.2.4
### Added
- **`sw.shell.Topbar`**: Standard navigation bar component for surfaces.
Composes title + extension slot + NotificationBell + UserMenu into a
consistent 44px bar. Surfaces use `<${sw.shell.Topbar} title="...">` with
children rendered in the extension slot. Graceful fallback if unavailable.
- **Schedules surface** (`packages/schedules/`): New package wrapping the
kernel `/api/v1/schedules` API. Table view with cron badge + human-readable
preview, next fire time, enable/disable toggle, manual run, and execution
logs panel. Create/edit dialog with live cron-to-english preview.
- **Manifest `icon` field**: Packages can declare an emoji icon in
`manifest.json` (`"icon": "⏰"`). Served via the surfaces API `icon` field.
Rendered in the UserMenu flyout next to each surface name.
### Changed
- **UserMenu**: Surface list now driven entirely by the `/api/v1/surfaces`
API. Removed hardcoded Chat, Notes, Projects links (gutted in Phase 0).
Core surfaces (Admin, Settings, Team Admin, Workflow) filtered from the
API list and handled as dedicated menu items with RBAC gating.
- **Tasks surface**: Replaced custom `.tasks-header` with `sw.shell.Topbar`.
View tabs and create button rendered in the extension slot.
### Fixed
- **`sw.isAdmin` RBAC regression**: `isAdmin()` in `can.js` was checking the
deprecated `user.role === 'admin'` field instead of the v0.2.0 RBAC grant
`surface.admin.access`. Admin menu item and admin-gated features now appear
correctly for users in the Admins group.
---
## [Unreleased] — v0.2.2 ## [Unreleased] — v0.2.2
### Added ### Added

View File

@@ -64,8 +64,35 @@ SDK stabilization, and the first rebuilt extension (tasks).
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI | | SDK stabilization | | `sw.api.ext()`, `sw.storage`, `sw.theme.tokens`, `sw.ui`, `sw.slots`, `sw.actions` — six new SDK modules for extension development |
| Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications | | Task extension | | Full task surface rebuilt as Starlark extension: CRUD API, kanban/list views, event triggers, webhook integration, notifications on completion |
### v0.2.4 — Shell Navigation + Schedules
| Step | Status | Description |
|------|--------|-------------|
| SDK Topbar | ✅ | `sw.shell.Topbar` — composable navigation bar (title + extension slot + bell + user menu). Surfaces get consistent nav for free. |
| Schedules surface | ✅ | New `packages/schedules/` wrapping kernel cron API. Table view, cron preview, enable/disable, manual run, execution logs. |
| Manifest icons | ✅ | `icon` field in manifest.json (emoji). Surfaces API returns icon. UserMenu renders per-surface icons. |
| UserMenu cleanup | ✅ | Removed dead Chat/Notes/Projects links. Menu driven by surfaces API. Core surfaces filtered. |
| isAdmin RBAC fix | ✅ | `can.js` isAdmin() now checks `surface.admin.access` grant instead of deprecated role column. |
### v0.2.5 — UI Polish + Dead Code Audit
| Step | Status | Description |
|------|--------|-------------|
| UI bug pass | ⬚ | Systematic review of all surfaces (admin, settings, dashboard, git-board, tasks, schedules). Fix broken CSS, missing states, responsive issues, dark/light theme gaps. |
| Dead code sweep | ⬚ | Audit for Phase 0 remnants: orphaned CSS classes, stale imports, references to gutted chat/notes/projects in templates and route registrations, unused Go handlers/stores. |
| Template cleanup | ⬚ | Remove stale CSS link tags (sw-chat-pane.css, sw-notes-pane.css etc.) from HTML templates. Clean up base.html surface wiring. |
| Package proof-of-concept status | ⬚ | Mark tasks + schedules packages as PoC (not permanent). Document what "permanent" means and the graduation criteria for extensions shipping with core. |
### v0.2.6 — Workflow Architecture
| Step | Status | Description |
|------|--------|-------------|
| Workflow design session | ⬚ | Define what "workflow" means in the extension-first model. Determine if the existing `workflows` table/handler survives, gets rebuilt, or gets removed. Document the Starlark contract for multi-step automation. |
| Trigger composition model | ⬚ | How do triggers, schedules, and workflows compose? Event chains, conditional branching, error handling. Design doc before code. |
| Extension lifecycle | ⬚ | Define permanent vs PoC extensions. Package graduation criteria. Dependency policy. What ships with core vs what's installed separately. |
## v0.3.0 — Notes Surface ## v0.3.0 — Notes Surface

View File

@@ -1 +1 @@
0.2.0 0.2.3

View File

@@ -5,6 +5,7 @@
"version": "0.31.1", "version": "0.31.1",
"tier": "browser", "tier": "browser",
"author": "Switchboard Core", "author": "Switchboard Core",
"icon": "📊",
"description": "Project dashboard exercising all SDK primitives", "description": "Project dashboard exercising all SDK primitives",
"route": "/s/dashboard", "route": "/s/dashboard",
"permissions": [], "permissions": [],

View File

@@ -5,6 +5,7 @@
"version": "0.31.0", "version": "0.31.0",
"tier": "browser", "tier": "browser",
"author": "Switchboard Core", "author": "Switchboard Core",
"icon": "✏️",
"description": "Code editor with workspace management, file tree, and AI assist", "description": "Code editor with workspace management, file tree, and AI assist",
"route": "/s/editor", "route": "/s/editor",
"layout": "editor", "layout": "editor",

View File

@@ -7,6 +7,7 @@
"auth": "authenticated", "auth": "authenticated",
"layout": "single", "layout": "single",
"version": "0.2.0", "version": "0.2.0",
"icon": "🔀",
"description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.", "description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
"author": "switchboard", "author": "switchboard",

View File

@@ -0,0 +1,277 @@
/* ── Schedules Surface ───────────────────── */
.sched-app {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
color: var(--text);
}
.sched-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.sched-header h1 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.sched-body {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.sched-count {
font-size: 0.8rem;
color: var(--text-3);
}
/* ── Table ────────────────────────────────── */
.sched-list__loading,
.sched-list__empty {
padding: 40px 16px;
text-align: center;
color: var(--text-3);
font-size: 0.85rem;
}
.sched-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.sched-table th {
text-align: left;
padding: 8px 12px;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-3);
border-bottom: 1px solid var(--border);
}
.sched-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.sched-row:hover {
background: var(--bg-hover);
}
.sched-row__name { min-width: 160px; }
.sched-link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-weight: 500;
font-size: 0.85rem;
padding: 0;
text-align: left;
}
.sched-link:hover { text-decoration: underline; }
.sched-row__desc {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 2px;
}
.sched-row__cron { white-space: nowrap; }
.sched-cron-badge {
font-family: monospace;
font-size: 0.8rem;
background: var(--bg-raised);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-2);
}
.sched-cron-human {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 2px;
}
.sched-row__next {
font-size: 0.8rem;
color: var(--text-2);
white-space: nowrap;
}
.sched-row__actions {
display: flex;
gap: 4px;
white-space: nowrap;
}
/* ── Toggle button ────────────────────────── */
.sched-toggle-btn {
font-size: 0.75rem;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sched-toggle-btn--on {
background: var(--success);
color: #fff;
border-color: var(--success);
}
.sched-toggle-btn--off {
background: var(--bg-raised);
color: var(--text-3);
}
.sched-toggle-btn:hover { opacity: 0.85; }
/* ── Action buttons ───────────────────────── */
.sched-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-2);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.sched-btn:hover { background: var(--bg-hover); }
.sched-btn--sm {
padding: 3px 8px;
font-size: 0.8rem;
}
.sched-btn--danger:hover {
background: var(--danger-dim);
color: var(--danger);
border-color: var(--danger);
}
/* ── Form ─────────────────────────────────── */
.sched-form { display: flex; flex-direction: column; gap: 12px; }
.sched-form__row { display: flex; align-items: center; gap: 12px; }
.sched-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.sched-cron-input {
font-family: monospace;
}
.sched-cron-preview {
font-size: 0.8rem;
color: var(--accent);
margin-top: 4px;
font-style: italic;
}
.sched-script {
font-family: monospace;
font-size: 0.85rem;
resize: vertical;
}
.sched-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
cursor: pointer;
}
/* ── Logs panel ───────────────────────────── */
.sched-logs {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.sched-logs__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
font-weight: 600;
}
.sched-logs__loading,
.sched-logs__empty {
padding: 20px 12px;
text-align: center;
color: var(--text-3);
font-size: 0.8rem;
}
.sched-log-entry {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
}
.sched-log-entry:last-child { border-bottom: none; }
.sched-log-entry__status {
font-weight: 700;
width: 18px;
text-align: center;
}
.sched-log-entry__status--ok { color: var(--success); }
.sched-log-entry__status--fail { color: var(--danger); }
.sched-log-entry__time {
color: var(--text-2);
white-space: nowrap;
}
.sched-log-entry__duration {
color: var(--text-3);
font-family: monospace;
font-size: 0.75rem;
}
.sched-log-entry__error {
color: var(--danger);
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
/* ── Responsive ───────────────────────────── */
@media (max-width: 768px) {
.sched-table th:nth-child(3),
.sched-table td:nth-child(3) {
display: none;
}
.sched-body { padding: 8px; }
}

View File

@@ -0,0 +1,418 @@
/**
* Schedules — Cron Job Management Surface (v0.1.0)
*
* Wraps the kernel /api/v1/schedules API. No Starlark backend —
* this surface talks directly to the core scheduled-tasks endpoints.
*
* SDK usage:
* sw.shell.Topbar — standard navigation bar
* sw.ui.* — primitive components
* sw.api.* — raw REST calls to /api/v1/schedules
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// ── Boot SDK ───────────────────────────────
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback } = hooks;
var { render } = preact;
var { Button, FormField, Dialog, Spinner, Banner } = sw.ui;
var Topbar = sw.shell?.Topbar;
// ── API helpers (core kernel endpoints) ────
var API = '/api/v1/schedules';
function apiList() { return sw.api.get(API); }
function apiCreate(data) { return sw.api.post(API, data); }
function apiUpdate(id, d) { return sw.api.put(API + '/' + id, d); }
function apiDelete(id) { return sw.api.del(API + '/' + id); }
function apiRun(id) { return sw.api.post(API + '/' + id + '/run', {}); }
function apiLogs(id) { return sw.api.get(API + '/' + id + '/logs'); }
// ═══════════════════════════════════════════
// Cron description helper
// ═══════════════════════════════════════════
var DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
function describeCron(expr) {
if (!expr) return '';
var parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return expr;
var min = parts[0], hr = parts[1], dom = parts[2], mon = parts[3], dow = parts[4];
// Format time
var timeStr = '';
if (min !== '*' && hr !== '*') {
var h = parseInt(hr, 10);
var m = parseInt(min, 10);
var ampm = h >= 12 ? 'PM' : 'AM';
var h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
timeStr = h12 + ':' + String(m).padStart(2, '0') + ' ' + ampm;
}
// Every N minutes
if (min.startsWith('*/') && hr === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + min.slice(2) + ' minutes';
}
// Every hour at :MM
if (min !== '*' && hr === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every hour at :' + String(parseInt(min, 10)).padStart(2, '0');
}
// Daily
if (timeStr && dom === '*' && mon === '*' && dow === '*') {
return 'Daily at ' + timeStr;
}
// Weekdays
if (timeStr && dom === '*' && mon === '*' && dow === '1-5') {
return 'Weekdays at ' + timeStr;
}
// Specific days
if (timeStr && dom === '*' && mon === '*' && dow !== '*') {
var dayNames = dow.split(',').map(function(d) {
var n = parseInt(d, 10);
return DAYS[n] || d;
});
return dayNames.join(', ') + ' at ' + timeStr;
}
return expr;
}
// ═══════════════════════════════════════════
// ScheduleForm — create / edit dialog
// ═══════════════════════════════════════════
function ScheduleForm({ schedule, onSave, onClose }) {
var [name, setName] = useState(schedule ? schedule.name : '');
var [desc, setDesc] = useState(schedule ? schedule.description : '');
var [cron, setCron] = useState(schedule ? schedule.cron_expr : '');
var [script, setScript] = useState(schedule ? schedule.script : '');
var [enabled, setEnabled] = useState(schedule ? schedule.enabled : true);
var [saving, setSaving] = useState(false);
var [error, setError] = useState('');
var preview = describeCron(cron);
async function handleSubmit(e) {
e.preventDefault();
if (!name.trim()) { setError('Name is required'); return; }
if (!cron.trim()) { setError('Cron expression is required'); return; }
setSaving(true);
setError('');
try {
var data = { name: name.trim(), description: desc, cron_expr: cron.trim(), script: script, enabled: enabled };
if (schedule) {
await apiUpdate(schedule.id, data);
} else {
await apiCreate(data);
}
onSave();
} catch (err) {
var msg = err.message || 'Save failed';
if (err.body?.error) msg = err.body.error;
setError(msg);
} finally {
setSaving(false);
}
}
return html`
<${Dialog} open onClose=${onClose} title=${schedule ? 'Edit Schedule' : 'New Schedule'}>
<form onSubmit=${handleSubmit} class="sched-form">
${error && html`<${Banner} variant="danger" text=${error} />`}
<${FormField} label="Name" required>
<input type="text" value=${name} onInput=${e => setName(e.target.value)}
placeholder="e.g. Daily cleanup" autofocus />
<//>
<${FormField} label="Description">
<input type="text" value=${desc} onInput=${e => setDesc(e.target.value)}
placeholder="What does this job do?" />
<//>
<${FormField} label="Cron Expression" required>
<input type="text" value=${cron} onInput=${e => setCron(e.target.value)}
placeholder="e.g. 0 9 * * 1-5" class="sched-cron-input" />
${preview && preview !== cron && html`
<div class="sched-cron-preview">${preview}</div>
`}
<//>
<${FormField} label="Script">
<textarea rows="6" value=${script} onInput=${e => setScript(e.target.value)}
placeholder="Starlark script (optional)" class="sched-script" />
<//>
<div class="sched-form__row">
<label class="sched-toggle">
<input type="checkbox" checked=${enabled} onChange=${e => setEnabled(e.target.checked)} />
<span>Enabled</span>
</label>
</div>
<div class="sched-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (schedule ? 'Update' : 'Create')}
<//>
</div>
</form>
<//>
`;
}
// ═══════════════════════════════════════════
// ScheduleLogsPanel — execution history
// ═══════════════════════════════════════════
function ScheduleLogsPanel({ scheduleId, onClose }) {
var [logs, setLogs] = useState([]);
var [loading, setLoading] = useState(true);
useEffect(function () {
if (!scheduleId) return;
setLoading(true);
apiLogs(scheduleId).then(function (res) {
setLogs(res.logs || []);
}).catch(function () {
setLogs([]);
}).finally(function () {
setLoading(false);
});
}, [scheduleId]);
return html`
<div class="sched-logs">
<div class="sched-logs__header">
<span>Execution History</span>
<button class="sched-btn sched-btn--sm" onClick=${onClose}>Close</button>
</div>
${loading && html`<div class="sched-logs__loading"><${Spinner} size="sm" /></div>`}
${!loading && logs.length === 0 && html`
<div class="sched-logs__empty">No executions yet</div>
`}
${!loading && logs.map(function (log) {
var success = log.status === 'success' || log.status === 'ok';
return html`
<div class="sched-log-entry" key=${log.id || log.started_at}>
<span class="sched-log-entry__status sched-log-entry__status--${success ? 'ok' : 'fail'}">
${success ? '✓' : '✗'}
</span>
<span class="sched-log-entry__time">${_formatTime(log.started_at)}</span>
<span class="sched-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
${log.error && html`<span class="sched-log-entry__error" title=${log.error}>${log.error}</span>`}
</div>
`;
})}
</div>
`;
}
function _formatTime(ts) {
if (!ts) return '—';
try {
return new Date(ts).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
} catch (_) { return ts; }
}
function _formatDuration(ms) {
if (ms == null) return '';
if (ms < 1000) return ms + 'ms';
return (ms / 1000).toFixed(1) + 's';
}
// ═══════════════════════════════════════════
// ScheduleList — main table
// ═══════════════════════════════════════════
function ScheduleList({ items, loading, onEdit, onShowLogs, onRefresh }) {
async function handleToggle(item) {
try {
await apiUpdate(item.id, { enabled: !item.enabled });
onRefresh();
} catch (err) {
sw.toast(err.message || 'Failed to toggle', 'danger');
}
}
async function handleRun(item) {
try {
await apiRun(item.id);
sw.toast('Schedule queued for execution', 'success');
} catch (err) {
sw.toast(err.message || 'Run failed', 'danger');
}
}
async function handleDelete(item) {
if (!confirm('Delete schedule "' + item.name + '"?')) return;
try {
await apiDelete(item.id);
sw.toast('Schedule deleted', 'success');
onRefresh();
} catch (err) {
sw.toast(err.message || 'Delete failed', 'danger');
}
}
return html`
<div class="sched-list">
${loading && html`<div class="sched-list__loading"><${Spinner} size="md" /></div>`}
${!loading && items.length === 0 && html`
<div class="sched-list__empty">No schedules yet. Create one to get started.</div>
`}
${!loading && items.length > 0 && html`
<table class="sched-table">
<thead>
<tr>
<th>Name</th>
<th>Schedule</th>
<th>Next Run</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${items.map(function (s) {
return html`
<tr key=${s.id} class="sched-row">
<td class="sched-row__name">
<button class="sched-link" onClick=${() => onEdit(s)}>${s.name}</button>
${s.description && html`<div class="sched-row__desc">${s.description}</div>`}
</td>
<td class="sched-row__cron">
<span class="sched-cron-badge">${s.cron_expr}</span>
<div class="sched-cron-human">${describeCron(s.cron_expr)}</div>
</td>
<td class="sched-row__next">${s.next_fire_at ? _formatTime(s.next_fire_at) : '—'}</td>
<td>
<button class="sched-toggle-btn sched-toggle-btn--${s.enabled ? 'on' : 'off'}"
onClick=${() => handleToggle(s)}
title=${s.enabled ? 'Disable' : 'Enable'}>
${s.enabled ? 'On' : 'Off'}
</button>
</td>
<td class="sched-row__actions">
<button class="sched-btn sched-btn--sm" onClick=${() => handleRun(s)} title="Run now">▶</button>
<button class="sched-btn sched-btn--sm" onClick=${() => onShowLogs(s.id)} title="View logs">📋</button>
<button class="sched-btn sched-btn--sm sched-btn--danger" onClick=${() => handleDelete(s)}
title="Delete">×</button>
</td>
</tr>
`;
})}
</tbody>
</table>
`}
</div>
`;
}
// ═══════════════════════════════════════════
// ScheduleApp — root component
// ═══════════════════════════════════════════
function ScheduleApp() {
var [items, setItems] = useState([]);
var [loading, setLoading] = useState(true);
var [error, setError] = useState('');
var [editing, setEditing] = useState(null); // null=closed, {}=new, {id}=edit
var [logsFor, setLogsFor] = useState(null); // schedule id or null
var loadItems = useCallback(async function () {
setLoading(true);
try {
var res = await apiList();
setItems(res.schedules || []);
setError('');
} catch (err) {
setError(err.message || 'Failed to load schedules');
} finally {
setLoading(false);
}
}, []);
useEffect(function () { loadItems(); }, []);
function handleSave() {
setEditing(null);
loadItems();
}
return html`
<div class="sched-app">
${Topbar ? html`
<${Topbar} title="Schedules">
<span class="sched-count">${items.length} schedule${items.length !== 1 ? 's' : ''}</span>
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
<//>
` : html`
<div class="sched-header">
<h1>Schedules</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
</div>
`}
${error && html`<${Banner} variant="danger" text=${error} />`}
<div class="sched-body">
<${ScheduleList}
items=${items}
loading=${loading}
onEdit=${setEditing}
onShowLogs=${setLogsFor}
onRefresh=${loadItems}
/>
${logsFor && html`
<${ScheduleLogsPanel} scheduleId=${logsFor} onClose=${() => setLogsFor(null)} />
`}
</div>
${editing !== null && html`
<${ScheduleForm}
schedule=${editing.id ? editing : null}
onSave=${handleSave}
onClose=${() => setEditing(null)}
/>
`}
</div>
`;
}
// ── Mount ──────────────────────────────────
render(html`<${ScheduleApp} />`, mount);
})();

View File

@@ -0,0 +1,15 @@
{
"id": "schedules",
"title": "Schedules",
"type": "full",
"version": "0.1.0",
"tier": "browser",
"author": "Switchboard Core",
"icon": "⏰",
"description": "Manage cron jobs and scheduled automations",
"route": "/s/schedules",
"permissions": [],
"settings": [
{ "key": "default_view", "label": "Default View", "type": "string", "default": "table" }
]
}

280
packages/tasks/css/main.css Normal file
View File

@@ -0,0 +1,280 @@
/* ── Tasks Surface Styles ────────────────── */
.tasks-app {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ──────────────────────────────── */
.tasks-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-shrink: 0;
}
.tasks-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.tasks-stats {
font-size: 13px;
color: var(--text-3);
}
.tasks-header__right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.tasks-view-tabs {
display: flex;
gap: 2px;
background: var(--bg-raised);
border-radius: var(--radius);
padding: 2px;
}
.tasks-view-tab {
padding: 5px 14px;
font-size: 13px;
border: none;
background: transparent;
color: var(--text-2);
border-radius: calc(var(--radius) - 2px);
cursor: pointer;
transition: var(--transition);
}
.tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
.tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
/* ── List View ───────────────────────────── */
.task-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.task-list__filters {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-shrink: 0;
}
.task-filter {
padding: 5px 10px;
font-size: 13px;
background: var(--bg-raised);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.task-list__count {
font-size: 12px;
color: var(--text-3);
}
.task-list__loading, .task-list__empty {
display: flex;
justify-content: center;
padding: 40px 0;
color: var(--text-3);
font-size: 14px;
}
/* ── Task Card ───────────────────────────── */
.task-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
transition: var(--transition);
}
.task-card:hover {
border-color: var(--border-light);
background: var(--bg-raised);
}
.task-card__header {
display: flex;
align-items: center;
gap: 8px;
}
.task-card__priority { font-size: 10px; }
.task-card__title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--text);
cursor: pointer;
}
.task-card__title:hover { color: var(--accent); }
.task-card__status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.task-card__status--todo { background: var(--accent-dim); color: var(--accent); }
.task-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
.task-card__status--done { background: var(--success-dim); color: var(--success); }
.task-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
.task-card__desc {
font-size: 13px;
color: var(--text-2);
margin-top: 6px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-card__footer {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
font-size: 12px;
color: var(--text-3);
}
.task-card__actions {
margin-left: auto;
display: flex;
gap: 4px;
opacity: 0;
transition: var(--transition);
}
.task-card:hover .task-card__actions { opacity: 1; }
/* ── Inline buttons ──────────────────────── */
.task-btn {
border: none;
background: var(--bg-raised);
color: var(--text-2);
cursor: pointer;
border-radius: var(--radius);
transition: var(--transition);
}
.task-btn--sm { padding: 2px 8px; font-size: 13px; }
.task-btn:hover { background: var(--bg-hover); color: var(--text); }
.task-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
/* ── Board View ──────────────────────────── */
.task-board {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
overflow-x: auto;
overflow-y: hidden;
}
.task-board__col {
background: var(--bg-raised);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-height: 0;
}
.task-board__col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.task-board__col-count {
font-size: 11px;
color: var(--text-3);
background: var(--bg-hover);
padding: 1px 7px;
border-radius: 10px;
}
.task-board__col-body {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.task-board__card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: pointer;
transition: var(--transition);
}
.task-board__card:hover {
border-color: var(--accent);
}
.task-board__card-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
}
.task-board__card-due {
font-size: 11px;
color: var(--text-3);
margin-top: 4px;
}
/* ── Form ────────────────────────────────── */
.task-form { display: flex; flex-direction: column; gap: 12px; }
.task-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.task-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.task-form textarea,
.task-form input,
.task-form select {
width: 100%;
padding: 7px 10px;
font-size: 13px;
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font);
}
.task-form textarea:focus,
.task-form input:focus,
.task-form select:focus {
outline: none;
border-color: var(--accent);
}
/* ── Statusbar widget ────────────────────── */
.task-status-widget {
font-size: 12px;
color: var(--text-3);
padding: 4px 10px;
}
/* ── Slot containers ─────────────────────── */
.sw-slot {
display: flex;
align-items: center;
gap: 8px;
}
.sw-slot--statusbar {
padding: 4px 12px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-3);
flex-shrink: 0;
}
.sw-slot--toolbar {
padding: 4px 12px;
flex-shrink: 0;
}

397
packages/tasks/js/main.js Normal file
View File

@@ -0,0 +1,397 @@
/**
* Tasks — Surface Entry Point (v0.1.0)
*
* Task management surface using the v0.2.3 SDK:
* sw.api.ext('tasks') — scoped API client
* sw.ui.* — primitive components
* sw.slots — statusbar widget
* sw.actions — create-task action
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// ── Boot SDK ───────────────────────────────
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
return;
}
var { html } = window;
var { useState, useEffect, useCallback, useRef } = hooks;
var { render } = preact;
// ── SDK modules ────────────────────────────
var api = sw.api.ext('tasks');
var { Button, FormField, Dialog, Spinner, Dropdown, Tabs, Banner } = sw.ui;
// ── Constants ──────────────────────────────
var STATUSES = ['todo', 'in_progress', 'done', 'cancelled'];
var PRIORITIES = ['low', 'medium', 'high', 'urgent'];
var STATUS_LABELS = {
todo: 'To Do', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled'
};
var PRIORITY_COLORS = {
low: 'var(--text-3)', medium: 'var(--accent)', high: 'var(--warning)', urgent: 'var(--danger)'
};
// ═══════════════════════════════════════════
// TaskForm — create / edit dialog
// ═══════════════════════════════════════════
function TaskForm({ task, onSave, onClose }) {
var [title, setTitle] = useState(task ? task.title : '');
var [desc, setDesc] = useState(task ? task.description : '');
var [status, setStatus] = useState(task ? task.status : STATUSES[0]);
var [priority, setPriority] = useState(task ? task.priority : 'medium');
var [dueDate, setDueDate] = useState(task ? task.due_date : '');
var [tags, setTags] = useState(task ? task.tags : '');
var [saving, setSaving] = useState(false);
var [error, setError] = useState('');
async function handleSubmit(e) {
e.preventDefault();
if (!title.trim()) { setError('Title is required'); return; }
setSaving(true);
setError('');
try {
var data = { title: title.trim(), description: desc, status, priority, due_date: dueDate, tags };
if (task) {
await api.put('/items/' + task.id, data);
} else {
await api.post('/items', data);
}
onSave();
} catch (err) {
setError(err.message || 'Save failed');
} finally {
setSaving(false);
}
}
return html`
<${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
<form onSubmit=${handleSubmit} class="task-form">
${error && html`<${Banner} variant="danger" text=${error} />`}
<${FormField} label="Title" required>
<input type="text" value=${title} onInput=${e => setTitle(e.target.value)}
placeholder="What needs to be done?" autofocus />
<//>
<${FormField} label="Description">
<textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)}
placeholder="Details (optional)" />
<//>
<div class="task-form__row">
<${FormField} label="Status">
<select value=${status} onChange=${e => setStatus(e.target.value)}>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</select>
<//>
<${FormField} label="Priority">
<select value=${priority} onChange=${e => setPriority(e.target.value)}>
${PRIORITIES.map(p => html`<option value=${p}>${p}</option>`)}
</select>
<//>
</div>
<div class="task-form__row">
<${FormField} label="Due Date">
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
<//>
<${FormField} label="Tags">
<input type="text" value=${tags} onInput=${e => setTags(e.target.value)}
placeholder="comma-separated" />
<//>
</div>
<div class="task-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
<//>
</div>
</form>
<//>
`;
}
// ═══════════════════════════════════════════
// TaskList — filterable task table
// ═══════════════════════════════════════════
function TaskList({ items, loading, onEdit, onRefresh }) {
var [filter, setFilter] = useState('all');
var filtered = items;
if (filter !== 'all') {
filtered = items.filter(function(t) { return t.status === filter; });
}
async function handleDelete(id) {
if (!confirm('Delete this task?')) return;
await api.del('/items/' + id);
onRefresh();
}
async function handleQuickStatus(id, newStatus) {
await api.put('/items/' + id, { status: newStatus });
onRefresh();
}
return html`
<div class="task-list">
<div class="task-list__filters">
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="task-filter">
<option value="all">All</option>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</select>
<span class="task-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span>
</div>
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`}
${!loading && filtered.length === 0 && html`
<div class="task-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div>
`}
${!loading && filtered.map(function(t) {
return html`
<div class="task-card" key=${t.id}>
<div class="task-card__header">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
</span>
<span class="task-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
<span class="task-card__status task-card__status--${t.status}">
${STATUS_LABELS[t.status] || t.status}
</span>
</div>
${t.description && html`
<div class="task-card__desc">${t.description}</div>
`}
<div class="task-card__footer">
${t.due_date && html`<span class="task-card__due">Due: ${t.due_date}</span>`}
${t.tags && html`<span class="task-card__tags">${t.tags}</span>`}
<div class="task-card__actions">
${t.status !== 'done' && html`
<button class="task-btn task-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')}
title="Mark done">✓</button>
`}
<button class="task-btn task-btn--sm task-btn--danger" onClick=${() => handleDelete(t.id)}
title="Delete">×</button>
</div>
</div>
</div>
`;
})}
</div>
`;
}
// ═══════════════════════════════════════════
// TaskBoard — kanban columns
// ═══════════════════════════════════════════
function TaskBoard({ items, loading, onEdit, onRefresh }) {
async function handleQuickStatus(id, newStatus) {
await api.put('/items/' + id, { status: newStatus });
onRefresh();
}
return html`
<div class="task-board">
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`}
${!loading && STATUSES.map(function(status) {
var col = items.filter(function(t) { return t.status === status; });
return html`
<div class="task-board__col" key=${status}>
<div class="task-board__col-header">
<span class="task-board__col-title">${STATUS_LABELS[status] || status}</span>
<span class="task-board__col-count">${col.length}</span>
</div>
<div class="task-board__col-body">
${col.map(function(t) {
return html`
<div class="task-board__card" key=${t.id} onClick=${() => onEdit(t)}>
<div class="task-board__card-title">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
${t.title}
</div>
${t.due_date && html`<div class="task-board__card-due">Due: ${t.due_date}</div>`}
</div>
`;
})}
</div>
</div>
`;
})}
</div>
`;
}
// ═══════════════════════════════════════════
// TaskApp — root component
// ═══════════════════════════════════════════
function TaskApp() {
var [items, setItems] = useState([]);
var [loading, setLoading] = useState(true);
var [error, setError] = useState('');
var [view, setView] = useState('list');
var [editTask, setEditTask] = useState(null); // null = closed, {} = new, {id} = edit
var [stats, setStats] = useState(null);
var loadItems = useCallback(async function () {
setLoading(true);
try {
var res = await api.get('/items');
setItems(res.data || res || []);
setError('');
} catch (err) {
setError(err.message || 'Failed to load tasks');
} finally {
setLoading(false);
}
}, []);
var loadStats = useCallback(async function () {
try {
var res = await api.get('/stats');
setStats(res);
} catch (_) {}
}, []);
useEffect(function () {
loadItems();
loadStats();
}, []);
// Live updates via event bus
useEffect(function () {
var off = sw.on('task.*', function () {
loadItems();
loadStats();
});
return off;
}, []);
function handleSave() {
setEditTask(null);
loadItems();
loadStats();
}
var tabs = [
{ id: 'list', label: 'List' },
{ id: 'board', label: 'Board' },
];
var Topbar = sw.shell?.Topbar;
return html`
<div class="tasks-app">
${Topbar ? html`
<${Topbar} title="Tasks">
${stats && html`<span class="tasks-stats">${stats.total || 0} total</span>`}
<div class="tasks-view-tabs">
${tabs.map(function(tab) {
return html`<button key=${tab.id}
class="tasks-view-tab ${view === tab.id ? 'tasks-view-tab--active' : ''}"
onClick=${() => setView(tab.id)}>${tab.label}</button>`;
})}
</div>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
<//>
` : html`
<div class="tasks-header">
<h1 class="tasks-title">Tasks</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
</div>
`}
${error && html`<${Banner} variant="danger" text=${error} />`}
${view === 'list' && html`
<${TaskList} items=${items} loading=${loading}
onEdit=${setEditTask} onRefresh=${loadItems} />
`}
${view === 'board' && html`
<${TaskBoard} items=${items} loading=${loading}
onEdit=${setEditTask} onRefresh=${loadItems} />
`}
${editTask !== null && html`
<${TaskForm} task=${editTask.id ? editTask : null}
onSave=${handleSave}
onClose=${() => setEditTask(null)} />
`}
</div>
`;
}
// ═══════════════════════════════════════════
// Actions & Slots registration
// ═══════════════════════════════════════════
// Register create-task action
sw.actions.register('tasks.create', {
label: 'Create Task',
handler: function () {
// Emit event that the app listens to
sw.emit('tasks.open-create', {}, { localOnly: true });
},
});
// Statusbar widget
function TaskStatusWidget() {
var [count, setCount] = useState(null);
useEffect(function () {
async function load() {
try {
var res = await api.get('/stats');
var open = (res.counts || {}).todo || 0;
var inProg = (res.counts || {}).in_progress || 0;
setCount(open + inProg);
} catch (_) {}
}
load();
var off = sw.on('task.*', load);
return off;
}, []);
if (count === null) return null;
return html`<span class="task-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`;
}
sw.slots.register('statusbar', {
id: 'tasks-open-count',
component: TaskStatusWidget,
priority: 50,
});
// ── Mount ──────────────────────────────────
render(html`<${TaskApp} />`, mount);
})();

View File

@@ -0,0 +1,74 @@
{
"id": "tasks",
"title": "Tasks",
"type": "full",
"tier": "starlark",
"route": "/s/tasks",
"auth": "authenticated",
"layout": "single",
"version": "0.1.0",
"icon": "✅",
"description": "Task management surface with lifecycle events, webhook integration, and scheduled reminders.",
"author": "switchboard",
"permissions": ["db.write", "notifications.send"],
"api_routes": [
{"method": "GET", "path": "/items"},
{"method": "POST", "path": "/items"},
{"method": "GET", "path": "/items/*"},
{"method": "PUT", "path": "/items/*"},
{"method": "DELETE", "path": "/items/*"},
{"method": "GET", "path": "/stats"}
],
"db_tables": {
"items": {
"columns": {
"title": "text",
"description": "text",
"status": "text",
"priority": "text",
"assignee_id": "text",
"creator_id": "text",
"due_date": "text",
"tags": "text",
"updated_at": "text",
"completed_at": "text"
},
"indexes": [
["status"],
["assignee_id"],
["creator_id"]
]
}
},
"triggers": [
{
"type": "event",
"pattern": "task.*",
"entry_point": "on_task_event"
},
{
"type": "webhook",
"slug": "create",
"entry_point": "on_webhook"
}
],
"settings": {
"statuses": {
"type": "string",
"label": "Statuses",
"description": "Comma-separated task statuses",
"default": "todo,in_progress,done,cancelled"
},
"priorities": {
"type": "string",
"label": "Priorities",
"description": "Comma-separated task priorities",
"default": "low,medium,high,urgent"
}
}
}

263
packages/tasks/script.star Normal file
View File

@@ -0,0 +1,263 @@
# Tasks — Starlark Backend (v0.1.0)
#
# Task management extension using ext_data + notifications + triggers.
#
# Entry points:
# on_request(req) → surface API routes
# on_task_event(event) → event trigger (task.*)
# on_webhook(req) → webhook trigger for external task creation
#
# Modules: db, json, notifications, settings
# ═══════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════
def _resp(status, data):
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
def _str(v):
if v == None:
return ""
return str(v)
def _now():
"""ISO-ish timestamp from Starlark (no time module — use db auto-created_at or pass from caller)."""
return ""
def _statuses():
raw = settings.get("statuses")
if not raw:
return ["todo", "in_progress", "done", "cancelled"]
return [s.strip() for s in raw.split(",") if s.strip()]
def _priorities():
raw = settings.get("priorities")
if not raw:
return ["low", "medium", "high", "urgent"]
return [s.strip() for s in raw.split(",") if s.strip()]
# ═══════════════════════════════════════════════
# Surface API routes (/s/tasks/api/*)
# ═══════════════════════════════════════════════
def on_request(req):
path = req["path"]
method = req["method"]
# GET /items — list tasks
if method == "GET" and path == "/items":
return _list_items(req)
# POST /items — create task
if method == "POST" and path == "/items":
return _create_item(req)
# GET /stats — aggregate counts
if method == "GET" and path == "/stats":
return _get_stats()
# GET /items/:id
if method == "GET" and path.startswith("/items/"):
return _get_item(path[len("/items/"):])
# PUT /items/:id
if method == "PUT" and path.startswith("/items/"):
return _update_item(path[len("/items/"):], req)
# DELETE /items/:id
if method == "DELETE" and path.startswith("/items/"):
return _delete_item(path[len("/items/"):])
return _resp(404, {"error": "not found"})
def _list_items(req):
q = req.get("query", {})
filters = {}
status = _str(q.get("status", ""))
if status:
filters["status"] = status
assignee = _str(q.get("assignee_id", ""))
if assignee:
filters["assignee_id"] = assignee
priority = _str(q.get("priority", ""))
if priority:
filters["priority"] = priority
creator = _str(q.get("creator_id", ""))
if creator:
filters["creator_id"] = creator
order = _str(q.get("order", "")) or "created_at"
limit_str = _str(q.get("limit", ""))
limit = int(limit_str) if limit_str else 100
rows = db.query("items", filters=filters, order=order, limit=limit)
return _resp(200, {"data": rows or []})
def _create_item(req):
body = json.decode(req.get("body", "{}"))
user_id = req.get("user_id", "")
title = _str(body.get("title", ""))
if not title:
return _resp(400, {"error": "title is required"})
statuses = _statuses()
status = _str(body.get("status", ""))
if not status:
status = statuses[0] if statuses else "todo"
priorities = _priorities()
priority = _str(body.get("priority", ""))
if not priority:
priority = priorities[0] if priorities else "medium"
row = db.insert("items", {
"title": title,
"description": _str(body.get("description", "")),
"status": status,
"priority": priority,
"assignee_id": _str(body.get("assignee_id", "")) or user_id,
"creator_id": user_id,
"due_date": _str(body.get("due_date", "")),
"tags": _str(body.get("tags", "")),
"updated_at": "",
"completed_at":"",
})
return _resp(201, row)
def _get_item(item_id):
rows = db.query("items", filters={"id": item_id}, limit=1)
if not rows:
return _resp(404, {"error": "task not found"})
return _resp(200, rows[0])
def _update_item(item_id, req):
body = json.decode(req.get("body", "{}"))
user_id = req.get("user_id", "")
# Fetch existing to detect transitions
existing = db.query("items", filters={"id": item_id}, limit=1)
if not existing:
return _resp(404, {"error": "task not found"})
old = existing[0]
updates = {}
for key in ["title", "description", "status", "priority", "assignee_id", "due_date", "tags"]:
if key in body:
updates[key] = _str(body[key])
# Detect completion transition
new_status = updates.get("status", "")
old_status = _str(old.get("status", ""))
if new_status == "done" and old_status != "done":
updates["completed_at"] = "now" # sentinel — db module handles timestamp
ok = db.update("items", item_id, updates)
if not ok:
return _resp(500, {"error": "update failed"})
# Notify creator on completion if assignee is different
if new_status == "done" and old_status != "done":
creator = _str(old.get("creator_id", ""))
assignee = _str(old.get("assignee_id", ""))
if creator and assignee and creator != assignee:
notifications.send(
creator,
"Task completed: " + _str(old.get("title", "")),
body=_str(old.get("title", "")) + " was marked done by assignee.",
)
# Re-fetch updated row
rows = db.query("items", filters={"id": item_id}, limit=1)
return _resp(200, rows[0] if rows else {})
def _delete_item(item_id):
ok = db.delete("items", item_id)
if not ok:
return _resp(404, {"error": "task not found"})
return _resp(200, {"deleted": True})
def _get_stats():
all_items = db.query("items", limit=10000)
items = all_items or []
counts = {}
for s in _statuses():
counts[s] = 0
for item in items:
st = _str(item.get("status", ""))
if st in counts:
counts[st] = counts[st] + 1
else:
counts[st] = 1
return _resp(200, {"counts": counts, "total": len(items)})
# ═══════════════════════════════════════════════
# Event trigger handler
# ═══════════════════════════════════════════════
def on_task_event(event):
"""Handle task lifecycle events emitted by the platform."""
label = event.get("event_label", "")
payload = event.get("event_payload", {})
if type(payload) == "string":
payload = json.decode(payload) if payload else {}
# Could be used for audit logging, metrics, etc.
# For now just a placeholder — actual notifications happen inline in _update_item.
pass
# ═══════════════════════════════════════════════
# Webhook trigger handler
# ═══════════════════════════════════════════════
def on_webhook(req):
"""
External task creation via webhook.
POST /api/v1/hooks/tasks/create with JSON body:
{ "title": "...", "description": "...", "priority": "...", "assignee_id": "..." }
"""
raw_body = req.get("body", "{}")
body = json.decode(raw_body) if raw_body else {}
title = _str(body.get("title", ""))
if not title:
return {"status": 400, "body": json.encode({"error": "title is required"})}
priorities = _priorities()
priority = _str(body.get("priority", ""))
if not priority:
priority = priorities[0] if priorities else "medium"
statuses = _statuses()
status = statuses[0] if statuses else "todo"
row = db.insert("items", {
"title": title,
"description": _str(body.get("description", "")),
"status": status,
"priority": priority,
"assignee_id": _str(body.get("assignee_id", "")),
"creator_id": "",
"due_date": _str(body.get("due_date", "")),
"tags": _str(body.get("tags", "")),
"updated_at": "",
"completed_at":"",
})
return {"status": 201, "body": json.encode(row), "headers": {"Content-Type": "application/json"}}

View File

@@ -2,7 +2,10 @@ package auth
import ( import (
"context" "context"
"encoding/json"
"log"
"switchboard-core/database"
"switchboard-core/store" "switchboard-core/store"
) )
@@ -65,8 +68,42 @@ func EnsureEveryoneGroup(ctx context.Context, stores store.Stores, userID string
_ = stores.Groups.AddMember(ctx, EveryoneGroupID, userID, userID) _ = stores.Groups.AddMember(ctx, EveryoneGroupID, userID, userID)
} }
// EnsureAdminsGroup creates the Admins system group if it does not exist.
// Handles the case where migration 002 was applied before the Admins INSERT
// was added (no new migrations pre-MVP — edits in place).
// Uses raw SQL because the store's Create() overwrites the ID.
func EnsureAdminsGroup(ctx context.Context, stores store.Stores) {
if _, err := stores.Groups.GetByID(ctx, AdminsGroupID); err == nil {
return // already exists
}
permsJSON, _ := json.Marshal(AllPermissions)
db := database.DB
if db == nil {
return
}
_, err := db.ExecContext(ctx, `
INSERT OR IGNORE INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES (?, 'Admins', 'Full platform access — replaces legacy admin role.',
'global', NULL, 'system', ?)`,
AdminsGroupID, string(permsJSON))
if err != nil {
// Postgres variant
_, err = db.ExecContext(ctx, `
INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
VALUES ($1, 'Admins', 'Full platform access — replaces legacy admin role.',
'global', NULL, 'system', $2::jsonb)
ON CONFLICT (id) DO NOTHING`,
AdminsGroupID, string(permsJSON))
if err != nil {
log.Printf("⚠ EnsureAdminsGroup: %v", err)
}
}
}
// AddToAdminsGroup adds a user to the Admins group (idempotent). // AddToAdminsGroup adds a user to the Admins group (idempotent).
// Ensures the Admins group exists before attempting membership.
func AddToAdminsGroup(ctx context.Context, stores store.Stores, userID string) { func AddToAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
EnsureAdminsGroup(ctx, stores)
_ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID) _ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID)
} }

View File

@@ -726,6 +726,7 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Route string `json:"route"` Route string `json:"route"`
Icon string `json:"icon,omitempty"`
} }
var enabled []navSurface var enabled []navSurface
@@ -738,10 +739,12 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
continue continue
} }
route, _ := p.Manifest["route"].(string) route, _ := p.Manifest["route"].(string)
icon, _ := p.Manifest["icon"].(string)
enabled = append(enabled, navSurface{ enabled = append(enabled, navSurface{
ID: p.ID, ID: p.ID,
Title: p.Title, Title: p.Title,
Route: route, Route: route,
Icon: icon,
}) })
} }
if enabled == nil { if enabled == nil {

View File

@@ -123,6 +123,43 @@
flex-direction: column; flex-direction: column;
} }
/* ── Topbar — standard surface navigation bar ── */
.sw-topbar {
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 10px;
}
.sw-topbar__title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
flex-shrink: 0;
white-space: nowrap;
}
.sw-topbar__spacer {
flex: 1;
}
.sw-topbar__slot {
display: flex;
align-items: center;
gap: 8px;
}
.sw-topbar__right {
display: flex;
align-items: center;
gap: 8px;
}
/* ── User menu trigger ───────────────────── */ /* ── User menu trigger ───────────────────── */
.sw-user-menu__trigger { .sw-user-menu__trigger {

70
src/js/sw/sdk/actions.js Normal file
View File

@@ -0,0 +1,70 @@
// ==========================================
// Switchboard Core — SDK: Action Registry
// ==========================================
// Named actions that extensions expose for invocation.
//
// Factory: createActions(emitFn)
// ==========================================
/**
* Create the action registry.
*
* @param {Function} emitFn — events.emit for notifications
* @returns {object} actions
*/
export function createActions(emitFn) {
/** @type {Map<string, {handler:Function, label?:string, icon?:string}>} */
const _registry = new Map();
return {
/**
* Register a named action.
* @param {string} id — unique action id (e.g. 'tasks.create')
* @param {object} def — { handler, label?, icon? }
* @returns {Function} unregister
*/
register(id, { handler, label, icon }) {
_registry.set(id, { handler, label, icon });
emitFn('action.registered', { id, label }, { localOnly: true });
return () => this.unregister(id);
},
/**
* Remove an action.
*/
unregister(id) {
if (_registry.delete(id)) {
emitFn('action.unregistered', { id }, { localOnly: true });
}
},
/**
* Execute an action by id.
* @param {string} id
* @param {...any} args — passed to handler
* @returns {Promise<any>}
*/
async run(id, ...args) {
const entry = _registry.get(id);
if (!entry) throw new Error(`[sw.actions] Unknown action: ${id}`);
const result = await entry.handler(...args);
emitFn('action.executed', { id }, { localOnly: true });
return result;
},
/**
* List registered action metadata.
* @returns {Array<{id:string, label?:string, icon?:string}>}
*/
list() {
return [..._registry.entries()].map(([id, { label, icon }]) => ({ id, label, icon }));
},
/**
* Check if an action is registered.
*/
has(id) {
return _registry.has(id);
},
};
}

View File

@@ -283,5 +283,22 @@ export function createDomains(restClient) {
}, },
health: () => rc.get('/api/v1/health'), health: () => rc.get('/api/v1/health'),
// ── Extension-scoped client ──────────
/**
* Scoped API client for an extension's surface routes.
* @param {string} packageId — package slug (e.g. 'tasks')
* @returns {object} — { get, post, put, del, upload }
*/
ext(packageId) {
const base = '/s/' + packageId + '/api';
return {
get: (path, opts) => rc.get(base + path + _qs(opts)),
post: (path, data) => rc.post(base + path, data),
put: (path, data) => rc.put(base + path, data),
del: (path) => rc.del(base + path),
upload: (path, file) => rc.upload(base + path, file),
};
},
}; };
} }

View File

@@ -27,10 +27,11 @@ export function createCan(authRef) {
/** /**
* Is the current user a platform admin? * Is the current user a platform admin?
* v0.2.0: Uses RBAC grant instead of legacy role field.
* @returns {boolean} * @returns {boolean}
*/ */
function isAdmin() { function isAdmin() {
return authRef.user?.role === 'admin'; return authRef.permissions.has('surface.admin.access');
} }
/** /**

View File

@@ -18,6 +18,9 @@ import { createDomains } from './api-domains.js';
import { createEvents } from './events.js'; import { createEvents } from './events.js';
import { createPipe } from './pipe.js'; import { createPipe } from './pipe.js';
import { createTheme } from './theme.js'; import { createTheme } from './theme.js';
import { createStorage } from './storage.js';
import { createSlots } from './slots.js';
import { createActions } from './actions.js';
import { confirm } from '../primitives/confirm.js'; import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js'; import { prompt } from '../primitives/prompt.js';
@@ -61,9 +64,12 @@ export async function boot() {
// 6. Remaining modules // 6. Remaining modules
const { can, isAdmin, isTeamAdmin } = createCan(auth); const { can, isAdmin, isTeamAdmin } = createCan(auth);
const api = createDomains(restClient); const api = createDomains(restClient);
const pipe = createPipe(); const pipe = createPipe();
const theme = createTheme(events.emit.bind(events)); const theme = createTheme(events.emit.bind(events));
const storage = createStorage();
const slots = createSlots(events.emit.bind(events));
const actions = createActions(events.emit.bind(events));
// 7. Assemble sw object // 7. Assemble sw object
const sw = Object.create(null); const sw = Object.create(null);
@@ -89,16 +95,19 @@ export async function boot() {
sw.emit = events.emit.bind(events); sw.emit = events.emit.bind(events);
sw.events = events; sw.events = events;
// Pipe & Theme // Pipe, Theme, Storage, Slots, Actions
sw.pipe = pipe; sw.pipe = pipe;
sw.theme = theme; sw.theme = theme;
sw.storage = storage;
sw.slots = slots;
sw.actions = actions;
// Shell helpers — imperative confirm/prompt backed by primitives // Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm; sw.confirm = confirm;
sw.prompt = prompt; sw.prompt = prompt;
// Shell — layout utilities (decouples surface code from shell DOM) // Shell — layout utilities (decouples surface code from shell DOM)
sw.shell = Object.freeze({ const _shell = {
/** CSS transform scale on #surfaceInner (appearance zoom). */ /** CSS transform scale on #surfaceInner (appearance zoom). */
getScale() { getScale() {
const el = document.getElementById('surfaceInner'); const el = document.getElementById('surfaceInner');
@@ -108,7 +117,10 @@ export async function boot() {
const m = t.match(/matrix\(([^,]+)/); const m = t.match(/matrix\(([^,]+)/);
return m ? parseFloat(m[1]) || 1 : 1; return m ? parseFloat(m[1]) || 1 : 1;
}, },
}); /** Topbar — set after dynamic import below */
Topbar: null,
};
sw.shell = _shell;
// Toast — dynamic import to avoid module resolution issues // Toast — dynamic import to avoid module resolution issues
try { try {
@@ -121,6 +133,26 @@ export async function boot() {
console.warn('[sw] Toast import failed:', e.message); console.warn('[sw] Toast import failed:', e.message);
} }
// UI primitives — extensions use sw.ui.Button, sw.ui.Dialog, etc.
try {
const primitives = await import('../primitives/index.js');
sw.ui = Object.freeze({ ...primitives });
} catch (e) {
console.warn('[sw] Primitives import failed:', e.message);
sw.ui = Object.freeze({});
}
// Topbar — standard navigation bar for surfaces
try {
const topbarMod = await import('../shell/topbar.js');
_shell.Topbar = topbarMod.Topbar;
} catch (e) {
console.warn('[sw] Topbar import failed:', e.message);
}
// Freeze shell now that Topbar is loaded
sw.shell = Object.freeze(_shell);
// UserMenu render helper — surfaces call sw.userMenu(container, opts) // UserMenu render helper — surfaces call sw.userMenu(container, opts)
sw.userMenu = function (container, opts = {}) { sw.userMenu = function (container, opts = {}) {
import('../shell/user-menu.js').then(({ UserMenu }) => { import('../shell/user-menu.js').then(({ UserMenu }) => {
@@ -130,7 +162,7 @@ export async function boot() {
}; };
// Marker for idempotency // Marker for idempotency
sw._sdk = '0.38.1'; sw._sdk = '0.2.3';
// 8. Expose globally // 8. Expose globally
window.sw = sw; window.sw = sw;

77
src/js/sw/sdk/slots.js Normal file
View File

@@ -0,0 +1,77 @@
// ==========================================
// Switchboard Core — SDK: Slot System
// ==========================================
// Named shell regions where extensions inject UI.
//
// Factory: createSlots(emitFn)
// ==========================================
/**
* Create the slot registry.
*
* @param {Function} emitFn — events.emit for change notifications
* @returns {object} slots
*/
export function createSlots(emitFn) {
/** @type {Map<string, Array<{id:string, component:Function, priority:number}>>} */
const _registry = new Map();
function _sort(arr) {
arr.sort((a, b) => a.priority - b.priority);
}
return {
/**
* Register a component into a named slot.
* @param {string} name — slot name (e.g. 'toolbar', 'statusbar')
* @param {object} entry — { id, component, priority? }
* @returns {Function} unregister
*/
register(name, { id, component, priority = 100 }) {
if (!_registry.has(name)) _registry.set(name, []);
const list = _registry.get(name);
// Prevent duplicate ids in same slot
const idx = list.findIndex(e => e.id === id);
if (idx !== -1) list.splice(idx, 1);
list.push({ id, component, priority });
_sort(list);
emitFn('slots.changed', { slot: name, action: 'register', id }, { localOnly: true });
return () => this.unregister(name, id);
},
/**
* Remove a component from a slot.
*/
unregister(name, id) {
const list = _registry.get(name);
if (!list) return;
const idx = list.findIndex(e => e.id === id);
if (idx !== -1) {
list.splice(idx, 1);
if (list.length === 0) _registry.delete(name);
emitFn('slots.changed', { slot: name, action: 'unregister', id }, { localOnly: true });
}
},
/**
* Get sorted entries for a slot.
* @param {string} name
* @returns {Array<{id:string, component:Function, priority:number}>}
*/
get(name) {
return _registry.get(name) || [];
},
/**
* List all slot names that have content.
* @returns {string[]}
*/
names() {
return [..._registry.keys()];
},
};
}

60
src/js/sw/sdk/storage.js Normal file
View File

@@ -0,0 +1,60 @@
// ==========================================
// Switchboard Core — SDK: Storage Module
// ==========================================
// Namespaced localStorage wrapper.
//
// Factory: createStorage()
// ==========================================
/**
* Create the storage module.
*
* @returns {object} storage — { local(namespace) }
*/
export function createStorage() {
return {
/**
* Scoped localStorage wrapper.
* @param {string} ns — namespace (e.g. package id)
* @returns {object} — { get, set, remove, keys, clear }
*/
local(ns) {
const prefix = 'sw::' + ns + '::';
return {
get(key) {
const raw = localStorage.getItem(prefix + key);
if (raw === null) return null;
try { return JSON.parse(raw); }
catch { return raw; }
},
set(key, val) {
localStorage.setItem(prefix + key, JSON.stringify(val));
},
remove(key) {
localStorage.removeItem(prefix + key);
},
keys() {
const out = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k.startsWith(prefix)) out.push(k.slice(prefix.length));
}
return out;
},
clear() {
const toRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k.startsWith(prefix)) toRemove.push(k);
}
toRemove.forEach(k => localStorage.removeItem(k));
},
};
},
};
}

View File

@@ -88,6 +88,31 @@ export function createTheme(emitFn) {
return () => _listeners.delete(fn); return () => _listeners.delete(fn);
}, },
/**
* Live CSS variable tokens as a JS object.
* Keys are camelCased variable names (e.g. --bg-surface → bgSurface).
* Recomputes on each access to reflect the current theme.
*/
get tokens() {
const style = getComputedStyle(document.documentElement);
const names = [
'bg', 'bg-surface', 'bg-raised', 'bg-hover', 'bg-elevated',
'border', 'border-elevated', 'border-light',
'text', 'text-2', 'text-3',
'accent', 'accent-hover', 'accent-dim',
'danger', 'success', 'warning', 'purple',
'danger-dim', 'success-dim', 'warning-dim',
'overlay', 'glass', 'input-bg', 'shadow-lg',
'radius', 'radius-lg', 'font', 'mono', 'transition',
];
const t = {};
for (const n of names) {
t[n.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())] =
style.getPropertyValue('--' + n).trim();
}
return Object.freeze(t);
},
/** Apply stored theme. Called during boot. */ /** Apply stored theme. Called during boot. */
init() { init() {
_apply(_getMode()); _apply(_getMode());

View File

@@ -20,7 +20,7 @@
* children — Surface content * children — Surface content
*/ */
const { html } = window; const { html } = window;
const { useState, useEffect, useRef } = hooks; const { useState, useEffect, useRef, useReducer } = hooks;
function ShellBanner({ position, text, variant = 'info' }) { function ShellBanner({ position, text, variant = 'info' }) {
const ref = useRef(null); const ref = useRef(null);
@@ -41,6 +41,28 @@ function ShellBanner({ position, text, variant = 'info' }) {
`; `;
} }
/**
* Renders all components registered in a named slot.
* Re-renders when sw.slots changes (via 'slots.changed' event).
*/
function SlotRenderer({ name }) {
const [, tick] = useReducer(n => n + 1, 0);
useEffect(() => {
if (!window.sw) return;
const off = window.sw.on('slots.changed', (e) => {
if (e.slot === name) tick();
});
return () => { if (typeof off === 'function') off(); else window.sw.off('slots.changed', tick); };
}, [name]);
const items = window.sw?.slots?.get(name) || [];
if (!items.length) return null;
return html`<div class="sw-slot sw-slot--${name}">
${items.map(i => html`<${i.component} key=${i.id} />`)}
</div>`;
}
export function AppShell({ banner, message, footer, children }) { export function AppShell({ banner, message, footer, children }) {
const [msgDismissed, setMsgDismissed] = useState(false); const [msgDismissed, setMsgDismissed] = useState(false);
@@ -62,6 +84,9 @@ export function AppShell({ banner, message, footer, children }) {
</div> </div>
`} `}
<${SlotRenderer} name="toolbar" />
<${SlotRenderer} name="surface.header" />
<main class="sw-shell__surface"> <main class="sw-shell__surface">
${children} ${children}
</main> </main>
@@ -71,6 +96,8 @@ export function AppShell({ banner, message, footer, children }) {
${footer} ${footer}
</footer> </footer>
`} `}
<${SlotRenderer} name="statusbar" />
</div> </div>
${banner && html` ${banner && html`

30
src/js/sw/shell/topbar.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* Topbar — Standard navigation bar for surfaces
*
* Composes NotificationBell + UserMenu into a consistent bar.
* Surfaces use: <${sw.shell.Topbar} title="My Surface">...slot...<//>
*
* Props:
* title — Surface name (falls back to window.__MANIFEST__?.title)
* children — Extension slot content (buttons, tabs, pickers)
*/
const { html } = window;
import { NotificationBell } from './notification-bell.js';
import { UserMenu } from './user-menu.js';
export function Topbar({ title, children }) {
const displayTitle = title || window.__MANIFEST__?.title || '';
return html`
<div class="sw-topbar">
<span class="sw-topbar__title">${displayTitle}</span>
<div class="sw-topbar__spacer" />
${children && html`<div class="sw-topbar__slot">${children}</div>`}
<div class="sw-topbar__right">
<${NotificationBell} />
<${UserMenu} placement="down-right" />
</div>
</div>
`;
}

View File

@@ -34,13 +34,15 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const user = sw?.auth?.user; const user = sw?.auth?.user;
const authenticated = sw?.auth?.isAuthenticated; const authenticated = sw?.auth?.isAuthenticated;
// Fetch extension surfaces once on mount // Fetch installed surfaces once on mount.
// Filter out core surfaces that have dedicated menu entries below.
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing']);
useEffect(() => { useEffect(() => {
if (!sw?.api?.surfaces?.list) return; if (!sw?.api?.surfaces?.list) return;
sw.api.surfaces.list().then(data => { sw.api.surfaces.list().then(data => {
const all = data || []; const raw = Array.isArray(data) ? data : data?.data || [];
const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'projects', 'workflow', 'workflow-landing']); setExtSurfaces(raw.filter(s => !CORE_IDS.has(s.id)));
setExtSurfaces(all.filter(s => !CORE.has(s.id)));
}).catch(() => {}); }).catch(() => {});
}, [authenticated]); }, [authenticated]);
@@ -48,22 +50,11 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const current = _currentSurface(); const current = _currentSurface();
const list = []; const list = [];
// ── Surface links (auto-filtered) ────── // ── Surface links (from API — only installed surfaces) ──────
// Each surface that exists gets a menu item, except the current one. const DEFAULT_ICON = '\ud83e\udde9';
const surfaces = [ const surfaceItems = extSurfaces
{ key: 'chat', label: 'Chat', icon: '\ud83d\udcac' }, .filter(s => s.id !== current)
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' }, .map(s => ({ label: s.title, action: s.id, icon: s.icon || DEFAULT_ICON }));
{ key: 'projects', label: 'Projects', icon: '\ud83d\udcc1' },
];
// Add extension surfaces fetched from API
for (const ext of extSurfaces) {
surfaces.push({ key: ext.id, label: ext.title, icon: '\ud83e\udde9', route: ext.route });
}
const surfaceItems = surfaces
.filter(s => s.key !== current)
.map(s => ({ label: s.label, action: s.key, icon: s.icon }));
if (surfaceItems.length) { if (surfaceItems.length) {
list.push(...surfaceItems); list.push(...surfaceItems);
@@ -119,11 +110,8 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
case 'debug': case 'debug':
window.dispatchEvent(new CustomEvent('debug:toggle')); window.dispatchEvent(new CustomEvent('debug:toggle'));
break; break;
case 'chat':
location.href = BASE + '/';
break;
default: { default: {
// Extension surfaces use /s/{id} routes // Surfaces use their route from the API, or fall back to /{id}
const ext = extSurfaces.find(s => s.id === action); const ext = extSurfaces.find(s => s.id === action);
location.href = BASE + (ext?.route || '/' + action); location.href = BASE + (ext?.route || '/' + action);
break; break;