All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 3m6s
CI/CD / build-and-deploy (push) Successful in 29s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
295 lines
8.7 KiB
Markdown
295 lines
8.7 KiB
Markdown
# Frontend JS Guide
|
|
|
|
Armature extensions run in the browser using **Preact + htm** — a 3 KB
|
|
runtime with no build step. The kernel provides a rich SDK at `window.sw`
|
|
that extensions use for API calls, auth, events, theming, and UI.
|
|
|
|
## Getting started
|
|
|
|
Extension surfaces are ES modules loaded via `<script type="module">`.
|
|
The SDK is available on `window.sw` after the `sw:ready` DOM event:
|
|
|
|
```javascript
|
|
document.addEventListener('sw:ready', () => {
|
|
const { html } = window;
|
|
const mount = document.getElementById('my-mount');
|
|
preact.render(html`<${App} />`, mount);
|
|
});
|
|
```
|
|
|
|
Or use the global `hooks` object for Preact hooks:
|
|
|
|
```javascript
|
|
const { useState, useEffect } = hooks;
|
|
```
|
|
|
|
## SDK modules
|
|
|
|
All modules live on the `window.sw` object. They are frozen after boot
|
|
and available to every extension.
|
|
|
|
### sw.api — REST client
|
|
|
|
Generic escape hatches for any endpoint:
|
|
|
|
```javascript
|
|
sw.api.get('/api/v1/docs')
|
|
sw.api.post('/api/v1/teams', { name: 'Eng' })
|
|
sw.api.put(path, body)
|
|
sw.api.patch(path, body)
|
|
sw.api.del(path)
|
|
sw.api.upload(path, file)
|
|
sw.api.stream(path, body, signal)
|
|
```
|
|
|
|
All methods auto-inject auth tokens and return unwrapped `data` from
|
|
`{ data: ... }` response envelopes.
|
|
|
|
**Domain namespaces** provide typed CRUD methods:
|
|
|
|
| Namespace | Key methods |
|
|
|-----------|-------------|
|
|
| `sw.api.auth` | `login`, `register`, `refresh`, `logout` |
|
|
| `sw.api.teams` | `list`, `get`, `create`, `members`, `workflows`, `assignments` |
|
|
| `sw.api.workflows` | `list`, `get`, `stages`, `instances`, `advance`, `cancel` |
|
|
| `sw.api.channels` | `list`, `get`, `create`, `update`, `del` |
|
|
| `sw.api.notifications` | `list`, `unreadCount`, `markRead`, `markAllRead` |
|
|
| `sw.api.admin` | Sub-objects for `users`, `teams`, `groups`, `packages`, `backup`, etc. |
|
|
| `sw.api.users` | `search`, `resolve` |
|
|
| `sw.api.connections` | `list`, `get`, `create`, `resolve` |
|
|
| `sw.api.ext(pkgId)` | Scoped client for extension API routes: `get`, `post`, `put`, `del` |
|
|
|
|
### sw.auth — Authentication state
|
|
|
|
```javascript
|
|
sw.auth.isAuthenticated // boolean
|
|
sw.auth.user // { id, username, display_name, email, role, avatar }
|
|
sw.auth.permissions // Set<string>
|
|
sw.auth.teams // Array<{ id, name, role }>
|
|
sw.auth.groups // Array<{ id, name, permissions }>
|
|
```
|
|
|
|
Lifecycle: `sw.auth.login(login, pw)`, `sw.auth.logout()`, `sw.auth.refresh()`.
|
|
|
|
### sw.can — RBAC gates
|
|
|
|
```javascript
|
|
sw.can('workflow.create') // true if user has permission
|
|
sw.isAdmin // true if surface.admin.access granted
|
|
sw.isTeamAdmin(teamId) // true if admin role in team
|
|
```
|
|
|
|
Use these to conditionally render UI elements.
|
|
|
|
### sw.on / sw.off / sw.emit — Event bus
|
|
|
|
```javascript
|
|
const unsub = sw.on('theme.changed', (payload) => { ... });
|
|
sw.once('auth.login', (user) => { ... });
|
|
sw.off('theme.changed'); // remove all listeners
|
|
sw.off('theme.changed', fn); // remove specific listener
|
|
sw.emit('my.event', { data });
|
|
```
|
|
|
|
The event bus bridges to the WebSocket — server-emitted events
|
|
(e.g. `notification.created`, `workflow.sla_breach`) arrive here.
|
|
|
|
### sw.theme — Theme control
|
|
|
|
```javascript
|
|
sw.theme.current // 'dark' or 'light' (resolved)
|
|
sw.theme.mode // 'dark', 'light', or 'system'
|
|
sw.theme.set('dark')
|
|
sw.theme.on('change', (theme) => { ... })
|
|
sw.theme.tokens // live CSS variables as camelCase JS object
|
|
```
|
|
|
|
### sw.storage — Namespaced localStorage
|
|
|
|
```javascript
|
|
const store = sw.storage.local('my-extension');
|
|
store.set('key', { complex: 'value' });
|
|
store.get('key') // parsed object
|
|
store.remove('key')
|
|
store.keys() // ['key', ...]
|
|
store.clear()
|
|
```
|
|
|
|
### sw.realtime — WebSocket pub/sub
|
|
|
|
```javascript
|
|
const unsub = sw.realtime.subscribe('my-channel', 'item.updated', (data) => { ... });
|
|
// Or subscribe to all events on a channel:
|
|
const unsub = sw.realtime.subscribe('my-channel', (event, data) => { ... });
|
|
```
|
|
|
|
### sw.slots — UI slot registry
|
|
|
|
Register components into named shell slots (e.g. toolbar areas):
|
|
|
|
```javascript
|
|
const unreg = sw.slots.register('topbar-actions', {
|
|
id: 'my-button',
|
|
component: MyButton,
|
|
priority: 10,
|
|
});
|
|
```
|
|
|
|
### sw.actions — Named action registry
|
|
|
|
```javascript
|
|
sw.actions.register('copy-link', {
|
|
handler: async (url) => navigator.clipboard.writeText(url),
|
|
label: 'Copy Link',
|
|
icon: 'M12 2...',
|
|
});
|
|
await sw.actions.run('copy-link', someUrl);
|
|
```
|
|
|
|
### sw.pipe — Filter pipeline
|
|
|
|
Three-stage pipeline for message processing:
|
|
|
|
```javascript
|
|
sw.pipe.pre(10, async (ctx) => { /* pre-process */ return ctx; });
|
|
sw.pipe.stream(10, async (ctx) => { /* streaming */ return ctx; });
|
|
sw.pipe.render(10, async (ctx) => { /* post-render */ return ctx; });
|
|
```
|
|
|
|
### sw.renderers — Block and post renderers
|
|
|
|
Register custom renderers for fenced code blocks or post-processing:
|
|
|
|
```javascript
|
|
sw.renderers.register('mermaid', {
|
|
type: 'block',
|
|
pattern: /^mermaid$/,
|
|
render: (code, container) => { /* render diagram */ },
|
|
});
|
|
|
|
sw.renderers.register('linkify', {
|
|
type: 'post',
|
|
render: (container) => { /* post-process rendered HTML */ },
|
|
});
|
|
```
|
|
|
|
### sw.markdown — Unified rendering
|
|
|
|
```javascript
|
|
const html = sw.markdown.renderSync(markdownString, { sanitize: false });
|
|
await sw.markdown.render(markdownString); // async variant
|
|
sw.markdown.ready // boolean — true after preload
|
|
```
|
|
|
|
Uses marked + DOMPurify + registered `sw.renderers`.
|
|
|
|
### sw.users — Identity resolution
|
|
|
|
```javascript
|
|
const user = await sw.users.resolve(userId);
|
|
const map = await sw.users.resolveMany([id1, id2]);
|
|
sw.users.displayName(userObj) // display_name || username || 'Unknown'
|
|
```
|
|
|
|
Results are cached for 60 seconds. Batch fetches use the server's
|
|
bulk resolve endpoint.
|
|
|
|
### sw.testing — Test framework
|
|
|
|
For writing package runner tests:
|
|
|
|
```javascript
|
|
sw.testing.suite('CRUD', async (s) => {
|
|
s.before(async () => { /* setup */ });
|
|
s.after(async () => { /* cleanup */ });
|
|
|
|
s.test('creates item', async (t) => {
|
|
const resp = await sw.api.post('/api/v1/items', { name: 'test' });
|
|
t.assert.status(resp, 200);
|
|
t.assert.ok(resp.id);
|
|
s.track('item', resp.id); // auto-cleanup in afterAll
|
|
});
|
|
});
|
|
await sw.testing.run();
|
|
```
|
|
|
|
### sw.shell — Topbar API
|
|
|
|
The kernel injects a two-slot topbar into every extension surface.
|
|
Extensions customize it via:
|
|
|
|
```javascript
|
|
sw.shell.topbar.setTitle('My Surface'); // text in left slot
|
|
sw.shell.topbar.setSlot(html`<${TabBar} />`); // center slot content
|
|
sw.shell.topbar.hide(); // full-bleed mode
|
|
sw.shell.topbar.show(); // restore topbar
|
|
```
|
|
|
|
### sw.toast / sw.confirm / sw.prompt — UI primitives
|
|
|
|
```javascript
|
|
sw.toast('Saved!', 'success'); // success | error | info
|
|
sw.toast('Something broke', 'error', 5000); // custom duration
|
|
|
|
const ok = await sw.confirm('Delete this item?');
|
|
const name = await sw.prompt('Enter name', 'default value');
|
|
```
|
|
|
|
## Shell topbar patterns
|
|
|
|
Every surface uses one of three patterns:
|
|
|
|
| Pattern | Description | Example |
|
|
|---------|-------------|---------|
|
|
| **A — Default** | Shell title only. No center slot content. | Docs |
|
|
| **B — Flat tabs** | `setTitle()` + tabs in center slot via `setSlot()`. Full-width content. | Settings, Team Admin |
|
|
| **C — Category tabs + sidebar** | `setTitle()` + category tabs in center slot. Surface-owned sidebar below. | Admin |
|
|
|
|
The shell provides the home link, notification bell, and user menu
|
|
on every surface for free.
|
|
|
|
## `sw.forms` — Typed Forms (v0.9.5)
|
|
|
|
Any extension can render and validate typed forms using the `sw.forms` module.
|
|
|
|
### `sw.forms.render(container, template, opts)`
|
|
|
|
Renders a typed form into a DOM container. Supports flat forms and progressive
|
|
multi-step forms (fieldsets). Returns a control handle.
|
|
|
|
```js
|
|
const handle = sw.forms.render(document.getElementById('my-form'), template, {
|
|
values: { name: 'prefilled' },
|
|
onSubmit: (data) => { console.log('submitted', data); },
|
|
});
|
|
|
|
// Programmatic access
|
|
const data = handle.getData();
|
|
handle.setErrors([{ key: 'name', message: 'Name is taken' }]);
|
|
handle.destroy();
|
|
```
|
|
|
|
### `sw.forms.validate(template, data)`
|
|
|
|
Client-side validation (no network call). Returns `{ valid, errors }`.
|
|
|
|
```js
|
|
const { valid, errors } = sw.forms.validate(template, { name: '' });
|
|
// valid === false, errors === [{ key: 'name', message: 'Name is required' }]
|
|
```
|
|
|
|
### `sw.forms.validateRemote(template, data)`
|
|
|
|
Server-side validation via `POST /api/v1/forms/validate`. Returns a Promise.
|
|
|
|
```js
|
|
const result = await sw.forms.validateRemote(template, data);
|
|
```
|
|
|
|
## Extension CSS contract
|
|
|
|
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid
|
|
conflicts with kernel styles. See the [Extension CSS](EXTENSION-CSS)
|
|
doc for the full isolation rules, available primitives from
|
|
`sw-primitives.css`, and spacing tokens.
|