Insert two new version series after v0.9.x: - v0.10.x Panels + Composable Layout (kernel primitive) - v0.11.x Notes Reference Extension (first-party extension) Renumber existing planned work: - v0.10.x Reference Extensions → v0.12.x - v0.11.x Sidecar Tier + Polish → v0.13.x Add new design docs for panels and notes v0.11.x series. Update v1.0.0 gate criteria and design decisions log. Remove stale docs (AUDIT-surfaces, USABILITY-SURVEY). Fix dangling reference in DESIGN-shell-contract. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
26 KiB
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
dependsfield — 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:
{
"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: <package_id>.<panel_key>. The notes
reference panel above is addressed as notes.reference everywhere
in the SDK and in consumer manifests.
A package may declare multiple panels:
{
"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:
{
"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.
// 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-indexon 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
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<boolean>} — 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:
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.storagekeyed 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-mountarea 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.storagekeyed 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 <package>.<panel> identifiers. Invalid references
are warnings (soft deps), not errors.
At surface load time:
- Kernel reads the surface's
panelsarray from its manifest. - For each declared panel, check if the providing package is installed and enabled. Build the available panel list.
- Register available panels into
sw.panels._register()with their manifest metadata (entry path, title, icon, size constraints). - Panel JS is not loaded yet — lazy loading on first
sw.panels.open().
On sw.panels.open(panelId):
- If panel JS not yet loaded:
import()the entry module from the package's static asset path (/api/v1/ext/<package>/static/js/panels/<name>.js). - Call
module.mount(el, ctx)whereelis the content area of the kernel-provided container (FloatingPanel or DockedPanel component). - Store the cleanup function returned by
mount(). - Emit
panels.openedevent with{ panelId, mode }.
On sw.panels.close(panelId):
- Call stored cleanup function.
- Remove kernel container from DOM.
- Emit
panels.closedevent 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:
// 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:
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:
.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:
.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):
@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:
-
Manifest parsing (
server/extensions/manifest.go): Parse thepanelsfield from provider manifests (map of panel key → entry metadata). Parse thepanelsfield from consumer manifests (array of panel ID strings). Store in the existingPackageRecordstruct. -
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 frontendsw.panels._register()at surface boot without additional API calls. -
Static asset serving: Already works — panels are served from the same package static path as surface JS (
/api/v1/ext/<package>/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.<id>.* |
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_<panelId> |
{ x, y, w, h } |
Floating position + size |
panel_mode_<panelId> |
'floating' | 'docked-right' | ... |
Last-used presentation mode |
panel_split_<panelId> |
{ ratio: 0.3 } |
Docked split ratio |
panel_minimized_<panelId> |
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()+SharedWorkerfor event bus. Very post-1.0. - Keyboard shortcuts.
Ctrl+Shift+Nopens the notes panel. Requires a keybinding registry (not yet a kernel primitive).