This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/MULTI-SURFACE-GUIDE.md
Jeffrey Smith 5ad6d77c56
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
Feat v0.9.0 multi surface packages (#73)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 12:40:47 +00:00

6.0 KiB

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:

{
  "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:

{
  "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:

const path   = window.__SURFACE_PATH__   || '/';
const params = window.__SURFACE_PARAMS__ || {};

Use these to decide which view to render:

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:

// 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):

sw.on('surface.navigate', ({ path, params }) => {
    window.__SURFACE_PATH__   = path;
    window.__SURFACE_PARAMS__ = params;
    render();
});

For simple <a> links that do full page loads:

<a href="/s/workflow-builder/monitor">Monitor</a>

For SPA-style navigation:

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:

// 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.