Feat v0.9.0 multi surface packages (#73)
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 2m46s
CI/CD / test-sqlite (push) Successful in 2m57s
CI/CD / build-and-deploy (push) Successful in 44s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #73.
This commit is contained in:
2026-04-03 12:40:47 +00:00
committed by xcaliber
parent 98fd3eb3e6
commit 5ad6d77c56
12 changed files with 1075 additions and 62 deletions

201
docs/MULTI-SURFACE-GUIDE.md Normal file
View File

@@ -0,0 +1,201 @@
# Multi-Surface Packages
A multi-surface package serves multiple pages from a single package, each
with its own URL path, access level, and layout. Before v0.9.0, a package
got one route (`/s/{id}`), one auth posture, and one layout. Now a single
package can serve a public submission form alongside an authenticated
dashboard and an admin settings page.
## When to Use Multi-Surface
Use multi-surface when your package has logically related pages that share
the same backend (Starlark hooks, ext API, database tables) but need:
- **Different access levels** — public intake form + authenticated dashboard
- **Multiple views** — list view, detail view, edit view, monitor view
- **Sub-pages** — settings, admin, or debug pages within the package
If your pages don't share backend state, use separate packages instead.
## Manifest Setup
Add a `surfaces` array to your manifest. Each entry declares a page:
```json
{
"id": "workflow-builder",
"title": "Workflow Builder",
"type": "full",
"version": "0.1.0",
"icon": "🔧",
"auth": "authenticated",
"layout": "single",
"surfaces": [
{ "path": "/", "title": "Workflows" },
{ "path": "/new", "title": "New Workflow", "nav": false },
{ "path": "/:id/edit", "title": "Edit Workflow", "nav": false },
{ "path": "/monitor", "title": "Monitor", "nav": true },
{ "path": "/monitor/:id", "title": "Instance Detail", "nav": false }
],
"hooks": ["surface"],
"permissions": ["workflow.access"]
}
```
This generates five server routes, all under `/s/workflow-builder/`:
| Route | Access | Nav |
|-------|--------|-----|
| `/s/workflow-builder/` | authenticated | yes |
| `/s/workflow-builder/new` | authenticated | no |
| `/s/workflow-builder/:id/edit` | authenticated | no |
| `/s/workflow-builder/monitor` | authenticated | yes |
| `/s/workflow-builder/monitor/:id` | authenticated | no |
### Surface Entry Fields
| Field | Default | Description |
|----------|----------------|-------------|
| `path` | **required** | URL path relative to `/s/{id}`. Supports `:param` segments. |
| `access` | package `auth` | `public`, `authenticated`, `admin`, or `group:{name}`. |
| `title` | package `title`| Label shown in nav and page title. |
| `layout` | package `layout`| `single` or `editor`. |
| `nav` | `true` for `/`, `false` otherwise | Whether this surface appears in the sidebar. |
### Mixed Access Levels
A package can mix public and authenticated surfaces:
```json
{
"id": "bug-tracker",
"auth": "authenticated",
"surfaces": [
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
{ "path": "/", "title": "Dashboard" },
{ "path": "/:id", "title": "Bug Detail", "nav": false },
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
]
}
```
The kernel enforces access per-surface. Unauthenticated visitors can reach
`/submit` but are redirected to login if they try `/` or `/:id`.
## Frontend: Routing Within Your Package
### Reading the Current Surface
When your package JS loads, two globals tell you which surface was matched:
```js
const path = window.__SURFACE_PATH__ || '/';
const params = window.__SURFACE_PARAMS__ || {};
```
Use these to decide which view to render:
```js
function render() {
const path = window.__SURFACE_PATH__ || '/';
const params = window.__SURFACE_PARAMS__ || {};
const root = document.getElementById('surface-root');
switch (path) {
case '/': return renderList(root);
case '/new': return renderEditor(root, null);
case '/:id/edit': return renderEditor(root, params.id);
case '/monitor': return renderMonitor(root);
case '/monitor/:id':return renderDetail(root, params.id);
default: return render404(root);
}
}
render();
```
### SPA Navigation with `sw.navigate()`
Navigate between surfaces without a full page reload:
```js
// Navigate to a static path
sw.navigate('/new');
// Navigate with params
sw.navigate('/:id/edit', { id: 'wf-42' });
// Navigate to monitor sub-page
sw.navigate('/monitor/:id', { id: 'inst-7' });
```
`sw.navigate()` does three things:
1. Updates `window.__SURFACE_PATH__` and `window.__SURFACE_PARAMS__`
2. Calls `history.pushState()` to update the URL
3. Emits a `surface.navigate` event
### Listening for Navigation Events
Re-render when the user navigates (including browser back/forward):
```js
sw.on('surface.navigate', ({ path, params }) => {
window.__SURFACE_PATH__ = path;
window.__SURFACE_PARAMS__ = params;
render();
});
```
### Links Between Surfaces
For simple `<a>` links that do full page loads:
```html
<a href="/s/workflow-builder/monitor">Monitor</a>
```
For SPA-style navigation:
```js
button.onclick = () => sw.navigate('/monitor');
```
## API Routes
All surfaces in a package share the same ext API. API calls go through
`/s/{id}/api/*` regardless of which surface is active:
```js
// These work from any surface in the package
const items = await sw.api.get('/items');
const item = await sw.api.get(`/items/${id}`);
await sw.api.post('/items', { title: 'New item' });
```
The ext API handler checks `package.status == "active"` — if the package
is in `pending_review`, all API calls return 403.
## Backward Compatibility
Packages without a `surfaces` array continue to work. The kernel
synthesizes a single-entry array from the legacy `auth` and `layout`
fields:
```
auth: "authenticated" + layout: "single"
→ surfaces: [{ "path": "/", "access": "authenticated", "layout": "single" }]
```
No migration is required for existing packages.
## Constraints
- **No cross-package routing.** Surface paths are relative to the package
mount point. A package cannot claim arbitrary top-level routes.
- **No per-surface Starlark hooks.** All surfaces share the same backend
hooks. Use the surface path in your hook logic to differentiate.
- **No SSR.** Surface rendering is client-side. The kernel serves the
shell template; your JS renders the content.

View File

@@ -60,9 +60,10 @@ Only `manifest.json` is required. All other directories are optional and include
| `description` | Short description |
| `icon` | Emoji for sidebar/menu display |
| `author` | Package author |
| `route` | URL path for surfaces (e.g., `/s/my-package`) |
| `auth` | `authenticated` or `public` |
| `layout` | Surface layout mode (e.g., `single`) |
| `surfaces` | Array of surface entries with per-path access, title, layout (see below) |
| `route` | *(deprecated — use `surfaces`)* URL path for surfaces |
| `auth` | Default access level for all surfaces: `authenticated`, `public`, `admin` |
| `layout` | Default layout for all surfaces: `single`, `editor` |
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
| `db_tables` | Table definitions with columns and indexes |
@@ -75,6 +76,71 @@ Only `manifest.json` is required. All other directories are optional and include
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
| `schema_version` | Integer for additive schema migrations |
## Multi-Surface Packages
A package can serve multiple pages, each with its own path, access level,
title, and layout. Declare a `surfaces` array in the manifest:
```json
{
"id": "bug-tracker",
"title": "Bug Tracker",
"type": "full",
"auth": "authenticated",
"layout": "single",
"surfaces": [
{ "path": "/", "title": "Dashboard" },
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
{ "path": "/:id", "title": "Bug Detail", "nav": false },
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
]
}
```
### Surface Entry Fields
| Field | Type | Default | Description |
|----------|--------|----------------|-------------|
| `path` | string | **required** | Relative to `/s/{pkg-id}`. Supports `:param` segments. |
| `access` | string | package `auth` | `public`, `authenticated`, `admin`, `group:{name}`. |
| `title` | string | package `title`| Human label. Used in nav if `nav: true`. |
| `layout` | string | package `layout`| `single`, `editor`, or future layouts. |
| `nav` | bool | see rules | Show in sidebar navigation. |
### Nav Visibility Rules
- `path: "/"` defaults to `nav: true` (primary entry point)
- All others default to `nav: false` (sub-pages)
- Explicit `"nav": true` overrides — a package can put multiple entries in nav
- The sidebar links to the first surface with `nav: true`
### Backward Compatibility
If `surfaces` is absent, the kernel synthesizes one entry from the legacy
`auth` and `layout` fields. No existing packages break.
### Client-Side Navigation
Within a multi-surface package, use `sw.navigate()` for SPA-style routing:
```js
const path = window.__SURFACE_PATH__ || '/';
const params = window.__SURFACE_PARAMS__ || {};
if (path === '/') renderDashboard();
if (path === '/submit') renderSubmitForm();
if (path === '/:id') renderDetail(params.id);
// Navigate to another surface within the package
sw.navigate('/submit');
sw.navigate('/:id', { id: 'bug-42' });
// Listen for navigation events (including back/forward)
sw.on('surface.navigate', ({ path, params }) => {
renderView(path, params);
});
```
## Composability: Slots and Contributions
Packages compose with each other through named UI injection points (**slots**) and **contributions**.