From b937580ed89f33338f7d1f097527c689e6b2ecca Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 21:03:22 +0000 Subject: [PATCH] Feat v0.10.0 panel manifest lifecycle Panels are a new kernel rendering tier between surfaces and block renderers, enabling composable companion views (e.g. notes panel inside chat). This version ships the plumbing layer: - Manifest validation for panels field (provider map + consumer array) - Soft-dep warning at install time for unresolved panel consumers - PanelMeta struct + resolvePanels() resolves consumers at surface load - window.__PANELS__ injected into base template - sw.panels SDK module (open/close/toggle/isOpen/isAvailable/list/active) - Lazy JS loading pipeline with module caching - 10 new tests (6 validation + 4 resolve) Co-Authored-By: Claude Opus 4.6 (1M context) --- VERSION | 2 +- server/handlers/package_validate.go | 40 ++++++ server/handlers/package_validate_test.go | 97 +++++++++++++ server/handlers/packages.go | 16 +++ server/pages/pages.go | 83 +++++++++++ server/pages/panels_test.go | 141 +++++++++++++++++++ server/pages/templates/base.html | 1 + src/js/sw/sdk/index.js | 13 +- src/js/sw/sdk/panels.js | 170 +++++++++++++++++++++++ 9 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 server/pages/panels_test.go create mode 100644 src/js/sw/sdk/panels.js diff --git a/VERSION b/VERSION index 7e310ba..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.9 +0.10.0 diff --git a/server/handlers/package_validate.go b/server/handlers/package_validate.go index 1007df3..6e690bd 100644 --- a/server/handlers/package_validate.go +++ b/server/handlers/package_validate.go @@ -38,6 +38,8 @@ type ManifestInfo struct { RequiresRoles []string // team roles needed to access this package (advisory, OR semantics) Adoptable bool // if true, teams can adopt this package to get a team-scoped copy HasFormTemplate bool // if true, package declares a form_template at the top level + HasPanels bool // if true, package provides panels (provider form) + PanelConsumers []string // panel IDs this package consumes (consumer form, e.g. "notes.reference") } // ValidateManifest parses a manifest map and validates all required fields, @@ -199,6 +201,44 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { info.HasFormTemplate = true } + // v0.10.0: panels — provider (map) or consumer (array) declarations + if rawPanels := manifest["panels"]; rawPanels != nil { + switch p := rawPanels.(type) { + case map[string]any: + // Provider form: { "reference": { "entry": "...", "title": "..." }, ... } + for key, raw := range p { + panel, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("panels.%s must be an object", key) + } + entry, _ := panel["entry"].(string) + if entry == "" { + return nil, fmt.Errorf("panels.%s requires an 'entry' field", key) + } + title, _ := panel["title"].(string) + if title == "" { + return nil, fmt.Errorf("panels.%s requires a 'title' field", key) + } + } + info.HasPanels = true + case []any: + // Consumer form: ["notes.reference", "notes.graph"] + for i, raw := range p { + s, ok := raw.(string) + if !ok || s == "" { + return nil, fmt.Errorf("panels[%d] must be a non-empty string", i) + } + parts := strings.SplitN(s, ".", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("panels[%d] must be in 'package.panel' format, got %q", i, s) + } + info.PanelConsumers = append(info.PanelConsumers, s) + } + default: + return nil, fmt.Errorf("panels must be an object (provider) or array (consumer)") + } + } + info.SchemaVersion = ParseSchemaVersion(manifest) // ── Type-specific constraints ──────────────────────────────── diff --git a/server/handlers/package_validate_test.go b/server/handlers/package_validate_test.go index 96944c7..5f53fe8 100644 --- a/server/handlers/package_validate_test.go +++ b/server/handlers/package_validate_test.go @@ -349,3 +349,100 @@ func TestValidateManifest_AutoSynthesizeSurfaces(t *testing.T) { t.Errorf("expected layout 'editor', got %q", s["layout"]) } } + +// ── Panels validation (v0.10.0) ───────────────────────────── + +func TestValidateManifest_PanelsProviderValid(t *testing.T) { + m := map[string]any{ + "id": "my-notes", + "title": "Notes", + "panels": map[string]any{ + "reference": map[string]any{ + "entry": "js/panels/reference.js", + "title": "Notes Reference", + "icon": "📝", + }, + }, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.HasPanels { + t.Error("expected HasPanels to be true") + } +} + +func TestValidateManifest_PanelsProviderMissingEntry(t *testing.T) { + m := map[string]any{ + "id": "my-notes", + "title": "Notes", + "panels": map[string]any{ + "reference": map[string]any{ + "title": "Notes Reference", + }, + }, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for panel missing entry") + } +} + +func TestValidateManifest_PanelsProviderMissingTitle(t *testing.T) { + m := map[string]any{ + "id": "my-notes", + "title": "Notes", + "panels": map[string]any{ + "reference": map[string]any{ + "entry": "js/panels/reference.js", + }, + }, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for panel missing title") + } +} + +func TestValidateManifest_PanelsConsumerValid(t *testing.T) { + m := map[string]any{ + "id": "my-chat", + "title": "Chat", + "panels": []any{"notes.reference", "notes.graph"}, + } + info, err := ValidateManifest(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(info.PanelConsumers) != 2 { + t.Fatalf("expected 2 panel consumers, got %d", len(info.PanelConsumers)) + } + if info.PanelConsumers[0] != "notes.reference" { + t.Errorf("expected 'notes.reference', got %q", info.PanelConsumers[0]) + } +} + +func TestValidateManifest_PanelsConsumerInvalidFormat(t *testing.T) { + m := map[string]any{ + "id": "my-chat", + "title": "Chat", + "panels": []any{"notes-no-dot"}, + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for consumer panel without dot separator") + } +} + +func TestValidateManifest_PanelsInvalidType(t *testing.T) { + m := map[string]any{ + "id": "my-pkg", + "title": "My Package", + "panels": "invalid-string", + } + _, err := ValidateManifest(m) + if err == nil { + t.Fatal("expected error for panels as string (neither map nor array)") + } +} diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 5f26bd4..9cd1e12 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -349,6 +349,22 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { } } + // Phase 11: Soft-dep warnings for panel consumers + if len(mInfo.PanelConsumers) > 0 { + for _, ref := range mInfo.PanelConsumers { + parts := strings.SplitN(ref, ".", 2) + if len(parts) != 2 { + continue + } + provPkg, _ := h.stores.Packages.Get(c.Request.Context(), parts[0]) + if provPkg == nil { + log.Printf("[packages] %s: panel consumer %q — provider package %q not installed", pkgID, ref, parts[0]) + } else if !provPkg.Enabled { + log.Printf("[packages] %s: panel consumer %q — provider package %q is disabled", pkgID, ref, parts[0]) + } + } + } + resp := gin.H{ "id": pkgID, "title": mInfo.Title, diff --git a/server/pages/pages.go b/server/pages/pages.go index 1a29b15..06cd115 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -61,6 +61,20 @@ type SurfaceManifest struct { Source string `json:"source"` // "core" or "extension" } +// PanelMeta describes a resolved panel available to a surface. +type PanelMeta struct { + PanelID string `json:"panel_id"` // e.g. "notes.reference" + PackageID string `json:"package_id"` // e.g. "notes" + Entry string `json:"entry"` // e.g. "js/panels/reference.js" + Title string `json:"title"` // human-readable + Icon string `json:"icon,omitempty"` // emoji or icon key + Description string `json:"description,omitempty"` // short description + MinWidth int `json:"min_width,omitempty"` // minimum width in px + MinHeight int `json:"min_height,omitempty"` // minimum height in px + DefaultWidth int `json:"default_width,omitempty"` // default width in px + DefaultHeight int `json:"default_height,omitempty"` // default height in px +} + // BannerConfig holds environment banner settings. type BannerConfig struct { Text string `json:"text"` @@ -109,6 +123,8 @@ type PageData struct { BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection) + Panels []PanelMeta `json:"-"` // resolved panels available to this surface + InstanceName string // branding: instance display name LogoURL string // branding: custom logo URL Tagline string // branding: tagline under instance name @@ -237,6 +253,72 @@ func (e *Engine) browserExtensionIDs() []string { return ids } +// resolvePanels reads the consumer's panels array from the raw manifest and +// resolves each reference against installed+enabled provider packages. +// Returns nil if no panels are declared or no providers are available. +func (e *Engine) resolvePanels(ctx context.Context, manifest map[string]any) []PanelMeta { + if e.stores.Packages == nil { + return nil + } + rawPanels, ok := manifest["panels"].([]any) + if !ok || len(rawPanels) == 0 { + return nil + } + + var result []PanelMeta + for _, raw := range rawPanels { + ref, ok := raw.(string) + if !ok { + continue + } + parts := strings.SplitN(ref, ".", 2) + if len(parts) != 2 { + continue + } + pkgID, panelKey := parts[0], parts[1] + + provPkg, err := e.stores.Packages.Get(ctx, pkgID) + if err != nil || provPkg == nil || !provPkg.Enabled { + continue + } + + provPanels, ok := provPkg.Manifest["panels"].(map[string]any) + if !ok { + continue + } + panelDef, ok := provPanels[panelKey].(map[string]any) + if !ok { + continue + } + + meta := PanelMeta{ + PanelID: ref, + PackageID: pkgID, + } + meta.Entry, _ = panelDef["entry"].(string) + meta.Title, _ = panelDef["title"].(string) + meta.Icon, _ = panelDef["icon"].(string) + meta.Description, _ = panelDef["description"].(string) + if v, ok := panelDef["min_width"].(float64); ok { + meta.MinWidth = int(v) + } + if v, ok := panelDef["min_height"].(float64); ok { + meta.MinHeight = int(v) + } + if v, ok := panelDef["default_width"].(float64); ok { + meta.DefaultWidth = int(v) + } + if v, ok := panelDef["default_height"].(float64); ok { + meta.DefaultHeight = int(v) + } + + if meta.Entry != "" && meta.Title != "" { + result = append(result, meta) + } + } + return result +} + // UserContext is the authenticated user's info available to templates. type UserContext struct { ID string `json:"id"` @@ -552,6 +634,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc { EnabledSurfaces: e.EnabledSurfaceIDs(), ExtensionSurfaces: e.extensionNavItems(), BrowserExtensions: e.browserExtensionIDs(), + Panels: e.resolvePanels(c.Request.Context(), sr.Manifest), }) } } diff --git a/server/pages/panels_test.go b/server/pages/panels_test.go new file mode 100644 index 0000000..c9be804 --- /dev/null +++ b/server/pages/panels_test.go @@ -0,0 +1,141 @@ +package pages + +import ( + "context" + "testing" + + "armature/store" +) + +func TestResolvePanels_HappyPath(t *testing.T) { + // Provider package with a panels map + provider := &store.PackageRegistration{ + ID: "notes", + Title: "Notes", + Enabled: true, + Source: "extension", + Manifest: map[string]any{ + "panels": map[string]any{ + "reference": map[string]any{ + "entry": "js/panels/reference.js", + "title": "Notes Reference", + "icon": "📝", + "description": "Searchable note list", + "min_width": float64(280), + "default_width": float64(400), + }, + }, + }, + } + + // Consumer package that wants notes.reference + consumer := &store.PackageRegistration{ + ID: "chat", + Title: "Chat", + Enabled: true, + Source: "extension", + Manifest: map[string]any{ + "panels": []any{"notes.reference"}, + }, + } + + e := testEngine(t, provider, consumer) + panels := e.resolvePanels(context.Background(), consumer.Manifest) + + if len(panels) != 1 { + t.Fatalf("expected 1 panel, got %d", len(panels)) + } + p := panels[0] + if p.PanelID != "notes.reference" { + t.Errorf("expected panel_id 'notes.reference', got %q", p.PanelID) + } + if p.PackageID != "notes" { + t.Errorf("expected package_id 'notes', got %q", p.PackageID) + } + if p.Entry != "js/panels/reference.js" { + t.Errorf("expected entry 'js/panels/reference.js', got %q", p.Entry) + } + if p.Title != "Notes Reference" { + t.Errorf("expected title 'Notes Reference', got %q", p.Title) + } + if p.Icon != "📝" { + t.Errorf("expected icon '📝', got %q", p.Icon) + } + if p.MinWidth != 280 { + t.Errorf("expected min_width 280, got %d", p.MinWidth) + } + if p.DefaultWidth != 400 { + t.Errorf("expected default_width 400, got %d", p.DefaultWidth) + } +} + +func TestResolvePanels_ProviderMissing(t *testing.T) { + // Consumer references a package that doesn't exist + consumer := &store.PackageRegistration{ + ID: "chat", + Title: "Chat", + Enabled: true, + Source: "extension", + Manifest: map[string]any{ + "panels": []any{"nonexistent.panel"}, + }, + } + + e := testEngine(t, consumer) + panels := e.resolvePanels(context.Background(), consumer.Manifest) + + if len(panels) != 0 { + t.Errorf("expected 0 panels for missing provider, got %d", len(panels)) + } +} + +func TestResolvePanels_ProviderDisabled(t *testing.T) { + provider := &store.PackageRegistration{ + ID: "notes", + Title: "Notes", + Enabled: false, // disabled + Source: "extension", + Manifest: map[string]any{ + "panels": map[string]any{ + "reference": map[string]any{ + "entry": "js/panels/reference.js", + "title": "Notes Reference", + }, + }, + }, + } + + consumer := &store.PackageRegistration{ + ID: "chat", + Title: "Chat", + Enabled: true, + Source: "extension", + Manifest: map[string]any{ + "panels": []any{"notes.reference"}, + }, + } + + e := testEngine(t, provider, consumer) + panels := e.resolvePanels(context.Background(), consumer.Manifest) + + if len(panels) != 0 { + t.Errorf("expected 0 panels for disabled provider, got %d", len(panels)) + } +} + +func TestResolvePanels_NoPanelsField(t *testing.T) { + consumer := &store.PackageRegistration{ + ID: "chat", + Title: "Chat", + Enabled: true, + Source: "extension", + Manifest: map[string]any{}, + } + + e := testEngine(t, consumer) + panels := e.resolvePanels(context.Background(), consumer.Manifest) + + if panels != nil { + t.Errorf("expected nil panels for manifest without panels field, got %v", panels) + } +} diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index 8aac465..87ead59 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -124,6 +124,7 @@ {{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}} {{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}} {{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}} + {{if .Panels}}window.__PANELS__ = {{.Panels | toJSON}};{{end}} {{/* All surfaces use Preact SDK boot(). Legacy script includes removed. diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js index c89c432..526c57f 100644 --- a/src/js/sw/sdk/index.js +++ b/src/js/sw/sdk/index.js @@ -27,6 +27,7 @@ import { createMarkdown } from './markdown.js'; import { createUsers } from './users.js'; import { createTesting } from './testing.js'; import { createForms } from './forms.js'; +import { createPanels } from './panels.js'; import { confirm } from '../primitives/confirm.js'; import { prompt } from '../primitives/prompt.js'; @@ -129,6 +130,9 @@ export async function boot() { // Forms — typed form rendering + validation (v0.9.5) sw.forms = createForms(restClient); + // Panels — composable companion views (v0.10.0) + sw.panels = createPanels(events.emit.bind(events)); + // Shell helpers — imperative confirm/prompt backed by primitives sw.confirm = confirm; sw.prompt = prompt; @@ -239,11 +243,18 @@ export async function boot() { } // Marker for idempotency - sw._sdk = '0.9.5'; + sw._sdk = '0.10.0'; // 8. Expose globally window.sw = sw; + // 8b. Register panels from server-resolved metadata + if (window.__PANELS__) { + for (const meta of window.__PANELS__) { + sw.panels._register(meta.panel_id, meta); + } + } + // 9. Boot sequence theme.init(); diff --git a/src/js/sw/sdk/panels.js b/src/js/sw/sdk/panels.js new file mode 100644 index 0000000..02bcf4b --- /dev/null +++ b/src/js/sw/sdk/panels.js @@ -0,0 +1,170 @@ +// ========================================== +// Armature — SDK: Panel System (v0.10.0) +// ========================================== +// Composable companion views that surfaces can +// pull in from other packages. +// +// Factory: createPanels(emitFn) +// ========================================== + +/** + * Create the panels registry and lifecycle manager. + * + * @param {Function} emitFn — events.emit for change notifications + * @returns {object} panels + */ +export function createPanels(emitFn) { + /** @type {Map} panelId → manifest meta */ + const _registry = new Map(); + /** @type {Map} panelId → active state */ + const _active = new Map(); + /** @type {Map} panelId → cached JS module */ + const _modules = new Map(); + + return { + /** + * Register a panel from resolved manifest data. Called by the + * kernel during surface boot — not by package code directly. + * + * @param {string} panelId — e.g. 'notes.reference' + * @param {object} meta — PanelMeta from window.__PANELS__ + */ + _register(panelId, meta) { + _registry.set(panelId, meta); + }, + + /** + * Open a panel. Lazy-loads the JS entry if not yet loaded, + * creates an unstyled container, and calls mount(). + * + * @param {string} panelId — e.g. 'notes.reference' + * @param {object} [opts] + * @param {string} [opts.mode] — presentation hint (ignored in v0.10.0) + * @param {object} [opts.params] — passed to mount(el, ctx) as ctx.params + * @returns {Promise} — false if panel not available + */ + async open(panelId, opts = {}) { + if (!_registry.has(panelId)) return false; + if (_active.has(panelId)) return true; // already open + + const meta = _registry.get(panelId); + + // Lazy-load the panel JS module + if (!_modules.has(panelId)) { + try { + const base = window.__BASE__ || ''; + const url = `${base}/surfaces/${meta.package_id}/${meta.entry}`; + const mod = await import(url); + _modules.set(panelId, mod); + } catch (err) { + console.error(`[sw.panels] Failed to load ${panelId}:`, err); + return false; + } + } + + const mod = _modules.get(panelId); + if (typeof mod.mount !== 'function') { + console.error(`[sw.panels] ${panelId} does not export mount()`); + return false; + } + + // Create unstyled container (v0.10.0 — no chrome) + const el = document.createElement('div'); + el.className = 'sw-panel-container'; + el.dataset.panelId = panelId; + el.style.cssText = 'position:fixed;top:80px;right:16px;width:400px;height:350px;background:var(--bg-surface,#fff);border:1px solid var(--border,#ccc);border-radius:8px;overflow:auto;z-index:100;'; + document.body.appendChild(el); + + // Mount + const ctx = { + sw: window.sw, + params: opts.params || {}, + panelId, + close: () => this.close(panelId), + resize: () => {}, // no-op in v0.10.0 + }; + + let cleanup; + try { + cleanup = mod.mount(el, ctx); + } catch (err) { + console.error(`[sw.panels] mount() failed for ${panelId}:`, err); + el.remove(); + return false; + } + + _active.set(panelId, { el, cleanup: typeof cleanup === 'function' ? cleanup : () => {} }); + emitFn('panels.opened', { panelId, mode: opts.mode || 'plain' }, { localOnly: true }); + return true; + }, + + /** + * Close a panel. Calls cleanup, removes container from DOM. + * + * @param {string} panelId + */ + close(panelId) { + const entry = _active.get(panelId); + if (!entry) return; + + try { + entry.cleanup(); + } catch (err) { + console.warn(`[sw.panels] cleanup error for ${panelId}:`, err); + } + + entry.el.remove(); + _active.delete(panelId); + emitFn('panels.closed', { panelId }, { localOnly: true }); + }, + + /** + * Toggle a panel open/closed. + * + * @param {string} panelId + * @param {object} [opts] — passed to open() if opening + * @returns {Promise} + */ + async toggle(panelId, opts = {}) { + if (_active.has(panelId)) { + this.close(panelId); + return true; + } + return this.open(panelId, opts); + }, + + /** + * Check if a panel is currently open. + * @param {string} panelId + * @returns {boolean} + */ + isOpen(panelId) { + return _active.has(panelId); + }, + + /** + * Check if a panel is available (provider installed + enabled). + * @param {string} panelId + * @returns {boolean} + */ + isAvailable(panelId) { + return _registry.has(panelId); + }, + + /** + * List available panel IDs for the current surface. + * @returns {string[]} + */ + list() { + return [..._registry.keys()]; + }, + + /** + * List currently open panel IDs. + * @returns {string[]} + */ + active() { + return [..._active.keys()]; + }, + }; +}