Feat v0.2.3 sdk tasks (#7)
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:
@@ -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
70
src/js/sw/sdk/actions.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
77
src/js/sw/sdk/slots.js
Normal 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
60
src/js/sw/sdk/storage.js
Normal 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));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
30
src/js/sw/shell/topbar.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user