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
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
744 lines
26 KiB
Markdown
744 lines
26 KiB
Markdown
# 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: `<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:
|
||
|
||
```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<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:**
|
||
|
||
```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 `<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:
|
||
|
||
```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/<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()` + `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).
|