# DESIGN: Panels — v0.10.x ## Status: Proposed ## Problem Packages export one rendering granularity today: a **surface** — a full-page application that owns `#extension-mount`. There is no way for a package to offer a lightweight, composable view of itself that another surface can pull in. Concrete example: the `notes` package provides a full note editor surface at `/s/notes`. When a user is in the `chat` surface and wants to reference their notes, the only option is to navigate away. There is no mechanism to embed a notes panel alongside chat, whether as a floating window, a docked sidebar, or a bottom strip. This forces users into one-thing-at-a-time workflows and prevents the "extensions extending extensions" composability story from reaching the UI layer. Slots and actions allow injection of buttons and menu items, but not entire companion views. Without kernel coordination, package authors who need this will roll their own floating containers, producing: - **Z-index wars** between packages and kernel overlays (Dialog at 1000, debug at 9999, toast in between). - **Inconsistent drag/resize** behavior (touch support, bounds clamping, accessibility). - **No position persistence** — panels reset on every navigation. - **No layout negotiation** — docked panels can't tell the host surface to shrink. ## Non-Goals - **Window management system.** This is not a tiling WM. The kernel provides a small set of presentation modes (floating, docked). Complex layouts are post-1.0 horizon. - **Cross-surface panel sharing at runtime.** A panel runs inside one host surface's page context. It is not an iframe or a separate browsing context. - **Panel-to-panel communication.** Panels communicate with their host surface (and each other) via `sw.events`. No new IPC mechanism. - **Server-side panel rendering.** Panels are frontend-only. The kernel resolves dependencies and serves JS; rendering happens in the browser. ## What Already Exists **Three rendering tiers exist today; panels fill the gap between the first two:** | Tier | Scope | Example | |------|-------|---------| | **Surface** | Full-page app, owns `#extension-mount` | Notes editor, Chat, Admin | | *(gap)* | *Composable companion view* | *Notes reference panel in Chat* | | **Block renderer** | Inline content unit in markdown | Mermaid diagram, KaTeX formula | **Kernel primitives that panels build on:** - `sw.ui.Dialog` — centered modal with focus trap, backdrop, z-index 1000. - `sw.ui.Drawer` — slide-in side panel (left/right), backdrop, z-index 1000. - `sw.events` — pub/sub event bus, local and realtime. Already the communication channel between surfaces and slot contributors. - `sw.storage` — per-user localStorage wrapper with namespaced keys. - `sw.slots` — named UI injection points with manifest declarations. - Manifest `depends` field — package dependency resolution at install time. **What panels add:** A second rendering granularity between surfaces and block renderers, with kernel-managed presentation modes and lifecycle. --- ## Architecture ### 1. Three-Tier Rendering Hierarchy After this change, every package can export up to three things: ``` Surface — full-page app, owns the mount, has shell topbar slots Panel — composable view, kernel-managed container, presentation-agnostic Renderer — inline content block (sw.renderers) ``` The key architectural property: **the panel component does not know its presentation mode.** It renders into whatever container the kernel provides. Floating, docked-right, docked-bottom — that is a shell/layout concern. The panel receives a mount element and a size. Nothing else. ### 2. Manifest Schema #### Provider: `panels` field The package that provides a panel declares it in its manifest: ```json { "id": "notes", "surfaces": ["/notes"], "panels": { "reference": { "entry": "js/panels/reference.js", "title": "Notes", "icon": "📝", "description": "Searchable note list with quick preview", "min_width": 280, "min_height": 200, "default_width": 400, "default_height": 350 } } } ``` Panel IDs are namespaced: `.`. The notes reference panel above is addressed as `notes.reference` everywhere in the SDK and in consumer manifests. A package may declare multiple panels: ```json { "id": "notes", "panels": { "reference": { "entry": "js/panels/reference.js", ... }, "graph": { "entry": "js/panels/graph.js", ... } } } ``` #### Consumer: `panels` dependency The consuming surface declares which panels it wants available: ```json { "id": "chat", "surfaces": ["/chat"], "panels": ["notes.reference"] } ``` This is a **soft dependency**: the panel is available if the providing package is installed and enabled. If not, `sw.panels.open()` returns `false` and the consuming surface can degrade gracefully (hide the button, show a tooltip explaining the missing package). This is distinct from `depends` (hard dependency — install fails without it). A consumer does not need the provider installed to function; the panel is an enhancement. ### 3. Panel Entry Point Contract The panel JS module exports a single `mount` function. The kernel calls it with a DOM element and a context object. It returns a cleanup function. ```js // notes/js/panels/reference.js const { html } = window; const { render } = preact; export function mount(el, ctx) { // ctx.sw — SDK instance // ctx.params — optional params from sw.panels.open() // ctx.panelId — 'notes.reference' // ctx.close — function to request close // ctx.resize — function(width, height) to request resize render(html`<${NotesReference} sw=${ctx.sw} />`, el); // Return cleanup return () => render(null, el); } ``` **Rules:** - The panel must not assume any particular container size. Use CSS `width: 100%; height: 100%` and let the kernel container control dimensions. - The panel must not create its own overlay, backdrop, or drag handle. The kernel wraps the panel in the appropriate chrome. - The panel must not set `z-index` on anything. The kernel manages stacking. - The panel may use `ctx.close()` to request its own dismissal (e.g. user clicks an "X" inside the panel content area). - The panel may use `ctx.resize(w, h)` to suggest a new size, but the kernel may clamp or ignore the request. - Communication with the host surface is via `sw.events` — the same bus surfaces and slot contributors already use. ### 4. SDK API — `sw.panels` New SDK module: `src/js/sw/sdk/panels.js` ```js export function createPanels(events, storage) { const _registry = new Map(); // panelId → manifest entry const _active = new Map(); // panelId → { el, cleanup, mode, ... } return { /** * Register a panel from manifest data. Called by the kernel * during surface load — not by package code directly. */ _register(panelId, manifest) { ... }, /** * Open a panel. Lazy-loads the JS entry if not yet loaded. * * @param {string} panelId — e.g. 'notes.reference' * @param {object} opts * @param {string} opts.mode — 'floating' | 'docked-right' | 'docked-left' | 'docked-bottom' * @param {object} opts.params — passed to mount(el, ctx) * @param {number} opts.width — override default width * @param {number} opts.height — override default height * @returns {Promise} — false if panel not available */ async open(panelId, opts = {}) { ... }, /** * Close a panel. Calls cleanup, removes container. */ close(panelId) { ... }, /** * Toggle open/close. */ toggle(panelId, opts = {}) { ... }, /** * Check if a panel is currently open. */ isOpen(panelId) { ... }, /** * Check if a panel is available (provider installed + enabled). */ isAvailable(panelId) { ... }, /** * List available panel IDs for the current surface. */ list() { ... }, /** * List currently open panel IDs. */ active() { ... }, }; } ``` **Exposed on `sw` as:** ```js sw.panels.open('notes.reference', { mode: 'floating' }); sw.panels.open('notes.reference', { mode: 'docked-right' }); sw.panels.close('notes.reference'); sw.panels.toggle('notes.reference'); sw.panels.isOpen('notes.reference'); // boolean sw.panels.isAvailable('notes.reference'); // boolean sw.panels.list(); // ['notes.reference', 'notes.graph'] sw.panels.active(); // ['notes.reference'] ``` ### 5. Presentation Modes #### Floating (v0.10.1) ``` ┌──────────────────────────────────────────────────────────┐ │ ← │ Chat │ 🔔 │ 👤 │ ├──────────────────────────────────────────────────────────┤ │ │ │ Host surface (interactive) │ │ ┌───────────────────┐ │ │ │ ≡ Notes ─ ✕ │ │ │ │ │ │ │ │ [panel content] │ │ │ │ │ │ │ │ ◢ │ │ │ └───────────────────┘ │ │ │ └──────────────────────────────────────────────────────────┘ ``` - Draggable via title bar (pointer + touch events). - Resize via bottom-right handle. - Viewport bounds clamping — panel cannot be dragged fully offscreen (at least 48px of title bar must remain visible). - No backdrop — host surface remains fully interactive. - Z-index above surfaces, below Dialog/Drawer overlays. - On focus or drag-start, panel gets next-z (multiple floating panels stack correctly). - Escape key: configurable — close panel or do nothing (default: close). - Position and size persisted in `sw.storage` keyed by panel ID. **Title bar chrome (kernel-provided):** ``` ┌────────────────────────────────────┐ │ ≡ │ {icon} {title} │ ─ ✕ │ └────────────────────────────────────┘ │ │ │ drag handle minimize close ``` - **≡** Drag handle / grip indicator. - **─** Minimize: collapses to a small pill at the bottom of the viewport. Click to restore. Minimized state persisted. - **✕** Close: calls cleanup, removes panel. - Title and icon from manifest. #### Docked (v0.10.2) ``` ┌──────────────────────────────────────────────────────────┐ │ ← │ Chat │ 🔔 │ 👤 │ ├──────────────────────────────┬──┬────────────────────────┤ │ │▐▐│ 📝 Notes ✕ │ │ Host surface │▐▐│ │ │ (shrinks to fit) │▐▐│ [panel content] │ │ │▐▐│ │ │ │▐▐│ │ └──────────────────────────────┴──┴────────────────────────┘ resize gutter ``` - Docked panels consume space from the host surface. The `#extension-mount` area shrinks via CSS flex or grid. - Resize gutter between host and panel (drag to adjust split). - Three dock positions: right (default), left, bottom. - No z-index concerns — docked panels are in normal flow. - Split ratio persisted in `sw.storage` keyed by panel ID + mode. - Close button removes panel, host surface reclaims full width/height. #### Mode Transitions (v0.10.2) A floating panel can be dragged to a dock zone (edge highlight on hover). A docked panel's title bar can be dragged away to float. The panel component is never unmounted during a mode transition — only the wrapping container changes. The user controls presentation mode. Surfaces can suggest a default mode in `sw.panels.open()`, but the user's last-used mode (persisted) takes precedence. ### 6. Z-Index Strategy Panels slot into the existing stacking context: | Layer | Z-Index | Contents | |-------|---------|----------| | Base | 0 | Surface content, docked panels (in flow) | | Floating panels | 100–199 | `sw.panels` floating mode (auto-incrementing) | | Shell overlays | 200–299 | Dropdowns, tooltips, menus | | Drawer | 1000 | `sw.ui.Drawer` | | Dialog | 1000 | `sw.ui.Dialog` + confirm/prompt | | Toast | 1100 | `sw.ui.toast` | | Debug | 9999 | Debug panel | Floating panels start at z-index 100. Each panel that receives focus or drag-start gets `max(current panel z-indexes) + 1`, capped at 199. If the cap is hit, all floating panels are renumbered starting from 100 (maintaining relative order). This prevents unbounded z-index growth. When a Dialog or Drawer opens, floating panels remain visible beneath the overlay — they do not auto-hide. This preserves the "see while doing" property. ### 7. Dependency Resolution + Loading **At install time:** The kernel validates that panel dependencies reference valid `.` identifiers. Invalid references are warnings (soft deps), not errors. **At surface load time:** 1. Kernel reads the surface's `panels` array from its manifest. 2. For each declared panel, check if the providing package is installed and enabled. Build the available panel list. 3. Register available panels into `sw.panels._register()` with their manifest metadata (entry path, title, icon, size constraints). 4. Panel JS is **not** loaded yet — lazy loading on first `sw.panels.open()`. **On `sw.panels.open(panelId)`:** 1. If panel JS not yet loaded: `import()` the entry module from the package's static asset path (`/api/v1/ext//static/js/panels/.js`). 2. Call `module.mount(el, ctx)` where `el` is the content area of the kernel-provided container (FloatingPanel or DockedPanel component). 3. Store the cleanup function returned by `mount()`. 4. Emit `panels.opened` event with `{ panelId, mode }`. **On `sw.panels.close(panelId)`:** 1. Call stored cleanup function. 2. Remove kernel container from DOM. 3. Emit `panels.closed` event with `{ panelId }`. ### 8. Panel Communication No new mechanism. Panels and their host surface share the same `sw.events` bus and `sw` SDK instance. Convention-based event namespacing: ```js // Host surface requests the notes panel to show a specific note sw.emit('panel.notes.reference.show', { noteId: '...' }); // Panel listens sw.on('panel.notes.reference.show', (data) => { navigateToNote(data.noteId); }); // Panel notifies the host that a note was selected sw.emit('panel.notes.reference.selected', { noteId: '...', title: '...' }); // Host listens sw.on('panel.notes.reference.selected', (data) => { insertNoteLink(data); }); ``` The `params` object in `sw.panels.open()` provides initial state: ```js sw.panels.open('notes.reference', { mode: 'floating', params: { folderId: 'inbox', highlight: noteId } }); ``` Params are passed to `mount(el, ctx)` as `ctx.params`. This avoids the need for an event round-trip on first open. ### 9. CSS New file: `src/css/panels.css` **Floating panel:** ```css .sw-panel-floating { position: fixed; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-surface); box-shadow: var(--shadow-lg); overflow: hidden; /* z-index set dynamically by JS */ } .sw-panel-floating__titlebar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border-bottom: 1px solid var(--border); cursor: grab; user-select: none; flex-shrink: 0; } .sw-panel-floating__titlebar:active { cursor: grabbing; } .sw-panel-floating__title { flex: 1; font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sw-panel-floating__actions { display: flex; gap: var(--sp-1); } .sw-panel-floating__action { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); font-size: 14px; line-height: 1; } .sw-panel-floating__action:hover { color: var(--text); background: var(--bg-hover); } .sw-panel-floating__body { flex: 1; overflow: auto; } .sw-panel-floating__resize { position: absolute; bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; } /* Minimized pill */ .sw-panel-pill { position: fixed; bottom: var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); cursor: pointer; font-size: 12px; color: var(--text-2); z-index: 100; } .sw-panel-pill:hover { color: var(--text); background: var(--bg-raised); } ``` **Docked panel:** ```css .sw-panel-dock-container { display: flex; height: 100%; overflow: hidden; } .sw-panel-dock-container--bottom { flex-direction: column; } .sw-panel-dock-gutter { flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border); cursor: col-resize; width: 5px; } .sw-panel-dock-container--bottom .sw-panel-dock-gutter { cursor: row-resize; width: auto; height: 5px; } .sw-panel-docked { display: flex; flex-direction: column; overflow: hidden; background: var(--bg-surface); } .sw-panel-docked__header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); background: var(--bg-raised); flex-shrink: 0; } .sw-panel-docked__body { flex: 1; overflow: auto; } ``` **Touch targets (mobile):** ```css @media (max-width: 768px) { .sw-panel-floating__action { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; } .sw-panel-floating__titlebar { padding: var(--sp-3) var(--sp-4); } .sw-panel-dock-gutter { width: 10px; } .sw-panel-dock-container--bottom .sw-panel-dock-gutter { height: 10px; } } ``` ### 10. Backend Changes **Minimal.** The kernel backend already serves extension static assets and reads manifests. Changes: 1. **Manifest parsing** (`server/extensions/manifest.go`): Parse the `panels` field from provider manifests (map of panel key → entry metadata). Parse the `panels` field from consumer manifests (array of panel ID strings). Store in the existing `PackageRecord` struct. 2. **Surface load endpoint** (`/api/v1/surfaces/:id`): Include resolved panel metadata in the response — for each panel declared by the surface, include the provider's panel manifest entry (title, icon, entry path, size constraints) if the provider is installed and enabled. This lets the frontend `sw.panels._register()` at surface boot without additional API calls. 3. **Static asset serving**: Already works — panels are served from the same package static path as surface JS (`/api/v1/ext//static/...`). **No new tables.** No new migrations. Panel metadata lives in the existing manifest JSON stored in the packages table. ### 11. Admin Visibility The admin packages page already shows package manifests. With panels: - The package detail view shows declared panels with their metadata. - The dependency view shows which surfaces consume which panels. - Panel availability warnings surface when a provider package is disabled but consumers reference its panels. This falls out naturally from the existing manifest introspection — no new admin surfaces needed. --- ## Event Inventory | Event | Emitted by | Payload | |-------|-----------|---------| | `panels.opened` | Kernel (sw.panels) | `{ panelId, mode }` | | `panels.closed` | Kernel (sw.panels) | `{ panelId }` | | `panels.mode_changed` | Kernel (sw.panels) | `{ panelId, from, to }` | | `panels.focused` | Kernel (sw.panels) | `{ panelId }` | | `panel..*` | Convention (package code) | Package-defined | All panel events are `localOnly: true` — they do not broadcast over the WebSocket. Panels are a frontend-only concern. --- ## Persistence Panel state persisted in `sw.storage` (localStorage wrapper): | Key | Value | Scope | |-----|-------|-------| | `panel_pos_` | `{ x, y, w, h }` | Floating position + size | | `panel_mode_` | `'floating' \| 'docked-right' \| ...` | Last-used presentation mode | | `panel_split_` | `{ ratio: 0.3 }` | Docked split ratio | | `panel_minimized_` | `true` | Minimized state | Persisted per-user (sw.storage is already user-scoped). Cleared when the providing package is uninstalled. --- ## Version Plan | Version | Title | Scope | |---------|-------|-------| | v0.10.0 | Panel Manifest + Lifecycle | Manifest `panels` field (provider + consumer). Backend parsing. `sw.panels` SDK module (register, open, close, toggle, isOpen, isAvailable, list). Lazy JS loading pipeline. No presentation UI yet — panels render into a plain unstyled container for contract validation. | | v0.10.1 | FloatingPanel Primitive | `FloatingPanel` Preact component with drag, resize, minimize, z-index stacking, viewport bounds, position persistence. `panels.css` floating section. Touch support. | | v0.10.2 | Docked Panels + Mode Transitions | `DockedPanel` component. Layout negotiation (flex resize of `#extension-mount`). Resize gutter. Drag-to-dock / drag-to-float transitions. `panels.css` docked section. Split ratio persistence. | | v0.10.3 | Panel Communication Patterns | Event namespace conventions documented. `ctx.params` for initial state. SDK helper: `sw.panels.send(panelId, event, data)` as sugar over `sw.emit('panel.' + panelId + '.' + event, data)`. Panel communication section in extension developer guide. | | v0.10.4 | Reference Panel: Notes | Notes package ships `panels.reference` — searchable note list with quick preview. Chat declares it as a consumer. End-to-end proof: open notes panel in chat, select a note, insert link. Test runner coverage. | Each version independently CI-green. v0.10.0 is the foundation that all subsequent versions build on. --- ## Changeset Plan — v0.10.0 | CS | Scope | Description | |----|-------|-------------| | CS-1 | Backend (Go) | Manifest parsing: `panels` field on provider + consumer manifests. `PackageRecord` struct update. Surface load endpoint includes resolved panel metadata. | | CS-2 | Frontend (JS) | `sw.panels` SDK module: `createPanels()`, register, open, close, toggle, isOpen, isAvailable, list, active. Lazy `import()` loader. Unstyled container mount. | | CS-3 | Frontend (JS) | SDK boot integration: wire `sw.panels` into SDK boot sequence. Register panels from `__MANIFEST__` surface data. | | CS-4 | Frontend (JS) | Panel lifecycle events: `panels.opened`, `panels.closed`, `panels.focused`. | | CS-5 | Tests | SDK test runner: panel registration, open/close lifecycle, lazy loading, availability checks, event emission. | --- ## Design Decisions | Decision | Rationale | |----------|-----------| | Kernel primitive, not package-level | Z-index coordination, drag/resize consistency, layout negotiation, and position persistence are all kernel concerns. Multiple packages need this. | | Soft dependency (not `depends`) | Chat should work without notes installed. Panel availability is a runtime check, not an install-time requirement. | | Presentation-agnostic panel contract | `mount(el, ctx)` / cleanup is the entire API surface. Panels never know if they are floating or docked. This lets us add new modes without touching panel code. | | Lazy loading | Panel JS is not loaded until first `sw.panels.open()`. Surfaces that declare panels but the user never opens them pay zero cost. | | Event bus for communication | `sw.events` already exists and is the established pattern for cross-package communication. No new mechanism needed. | | No new backend tables | Panel metadata is manifest data stored in the existing packages table JSON. No schema changes. | | Z-index band 100–199 | Above surface content, below shell overlays and Dialog/Drawer. Renumber on cap to prevent unbounded growth. | | User controls mode, surface suggests | The surface can pass `mode: 'docked-right'` as a default, but the user's persisted mode preference wins. Users know their own workflow. | --- ## Future Considerations (post-v0.10.x) - **User-composable layouts.** Power users drag panels from different packages into a persistent workspace layout. Requires a layout persistence model beyond per-panel position. - **Panel marketplace metadata.** Panels become a discoverability feature: "this package provides 2 surfaces and 3 panels." - **Multi-monitor.** Pop a floating panel into a separate browser window via `window.open()` + `SharedWorker` for event bus. Very post-1.0. - **Keyboard shortcuts.** `Ctrl+Shift+N` opens the notes panel. Requires a keybinding registry (not yet a kernel primitive).