Feat v0.2.4: SDK Topbar, Schedules surface, manifest icons, UserMenu cleanup

Shell navigation:
- sw.shell.Topbar — composable nav bar (title + slot + bell + user menu)
- Topbar CSS in sw-shell.css, wired into SDK via dynamic import

Schedules surface (packages/schedules/):
- Wraps kernel /api/v1/schedules CRUD + run + logs
- Table view with cron badge, human-readable preview, enable toggle
- Create/edit dialog with live cron-to-english preview

Manifest icons:
- icon field in manifest.json (emoji string)
- Surfaces API returns icon from package manifest
- UserMenu renders per-surface icons

UserMenu cleanup:
- Removed dead Chat/Notes/Projects hardcoded links
- Menu now driven by /api/v1/surfaces API (installed surfaces only)
- Core surfaces filtered via CORE_IDS set

Bug fixes:
- isAdmin() in can.js now checks surface.admin.access RBAC grant
  instead of deprecated user.role column (v0.2.0 regression)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 10:16:37 +00:00
parent 57ccf19efb
commit 22c70034c6
25 changed files with 2212 additions and 37 deletions

View File

@@ -2,6 +2,40 @@
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
### Added

View File

@@ -64,8 +64,18 @@ SDK stabilization, and the first rebuilt extension (tasks).
| Step | Status | Description |
|------|--------|-------------|
| SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI |
| Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications |
| 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 | | 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.3.0 — Notes Surface

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
"auth": "authenticated",
"layout": "single",
"version": "0.2.0",
"icon": "🔀",
"description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
"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 (
"context"
"encoding/json"
"log"
"switchboard-core/database"
"switchboard-core/store"
)
@@ -65,8 +68,42 @@ func EnsureEveryoneGroup(ctx context.Context, stores store.Stores, userID string
_ = 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).
// Ensures the Admins group exists before attempting membership.
func AddToAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
EnsureAdminsGroup(ctx, stores)
_ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID)
}

View File

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

View File

@@ -123,6 +123,43 @@
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 ───────────────────── */
.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'),
// ── 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?
* v0.2.0: Uses RBAC grant instead of legacy role field.
* @returns {boolean}
*/
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 { createPipe } from './pipe.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 { prompt } from '../primitives/prompt.js';
@@ -61,9 +64,12 @@ export async function boot() {
// 6. Remaining modules
const { can, isAdmin, isTeamAdmin } = createCan(auth);
const api = createDomains(restClient);
const pipe = createPipe();
const theme = createTheme(events.emit.bind(events));
const api = createDomains(restClient);
const pipe = createPipe();
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
const sw = Object.create(null);
@@ -89,16 +95,19 @@ export async function boot() {
sw.emit = events.emit.bind(events);
sw.events = events;
// Pipe & Theme
sw.pipe = pipe;
sw.theme = theme;
// Pipe, Theme, Storage, Slots, Actions
sw.pipe = pipe;
sw.theme = theme;
sw.storage = storage;
sw.slots = slots;
sw.actions = actions;
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
// Shell — layout utilities (decouples surface code from shell DOM)
sw.shell = Object.freeze({
const _shell = {
/** CSS transform scale on #surfaceInner (appearance zoom). */
getScale() {
const el = document.getElementById('surfaceInner');
@@ -108,7 +117,10 @@ export async function boot() {
const m = t.match(/matrix\(([^,]+)/);
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
try {
@@ -121,6 +133,26 @@ export async function boot() {
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)
sw.userMenu = function (container, opts = {}) {
import('../shell/user-menu.js').then(({ UserMenu }) => {
@@ -130,7 +162,7 @@ export async function boot() {
};
// Marker for idempotency
sw._sdk = '0.38.1';
sw._sdk = '0.2.3';
// 8. Expose globally
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);
},
/**
* 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. */
init() {
_apply(_getMode());

View File

@@ -20,7 +20,7 @@
* children — Surface content
*/
const { html } = window;
const { useState, useEffect, useRef } = hooks;
const { useState, useEffect, useRef, useReducer } = hooks;
function ShellBanner({ position, text, variant = 'info' }) {
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 }) {
const [msgDismissed, setMsgDismissed] = useState(false);
@@ -62,6 +84,9 @@ export function AppShell({ banner, message, footer, children }) {
</div>
`}
<${SlotRenderer} name="toolbar" />
<${SlotRenderer} name="surface.header" />
<main class="sw-shell__surface">
${children}
</main>
@@ -71,6 +96,8 @@ export function AppShell({ banner, message, footer, children }) {
${footer}
</footer>
`}
<${SlotRenderer} name="statusbar" />
</div>
${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 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(() => {
if (!sw?.api?.surfaces?.list) return;
sw.api.surfaces.list().then(data => {
const all = data || [];
const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'projects', 'workflow', 'workflow-landing']);
setExtSurfaces(all.filter(s => !CORE.has(s.id)));
const raw = Array.isArray(data) ? data : data?.data || [];
setExtSurfaces(raw.filter(s => !CORE_IDS.has(s.id)));
}).catch(() => {});
}, [authenticated]);
@@ -48,22 +50,11 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const current = _currentSurface();
const list = [];
// ── Surface links (auto-filtered) ──────
// Each surface that exists gets a menu item, except the current one.
const surfaces = [
{ key: 'chat', label: 'Chat', icon: '\ud83d\udcac' },
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
{ 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 }));
// ── Surface links (from API — only installed surfaces) ──────
const DEFAULT_ICON = '\ud83e\udde9';
const surfaceItems = extSurfaces
.filter(s => s.id !== current)
.map(s => ({ label: s.title, action: s.id, icon: s.icon || DEFAULT_ICON }));
if (surfaceItems.length) {
list.push(...surfaceItems);
@@ -119,11 +110,8 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
case 'debug':
window.dispatchEvent(new CustomEvent('debug:toggle'));
break;
case 'chat':
location.href = BASE + '/';
break;
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);
location.href = BASE + (ext?.route || '/' + action);
break;