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/DESIGN-panels.md
Jeffrey Smith 0cae963480
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-sqlite (push) Has been skipped
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-go-pg (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / build-and-deploy (push) Successful in 30s
Chore roadmap v010x shift (#76)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 14:52:41 +00:00

26 KiB
Raw Blame History

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:

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

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.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 100199 sw.panels floating mode (auto-incrementing)
Shell overlays 200299 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:

  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/<package>/static/js/panels/<name>.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:

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

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