# 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 `` links that do full page loads: ```html Monitor ``` 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.