Chore roadmap v010x shift (#76)
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>
This commit was merged in pull request #76.
This commit is contained in:
2026-04-03 14:52:41 +00:00
committed by xcaliber
parent 983d761bbe
commit 0cae963480
6 changed files with 1552 additions and 488 deletions

743
docs/DESIGN-panels.md Normal file
View File

@@ -0,0 +1,743 @@
# 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 | 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:
```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 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).