From 33278d0d695a51c2d864cf58452a0981d904674e Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 21:22:30 +0000 Subject: [PATCH] Feat v0.10.0 panel manifest lifecycle (#84) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 39 ++++++ ROADMAP.md | 100 +++---------- 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 +++++++++++++++++++++++ 11 files changed, 619 insertions(+), 83 deletions(-) create mode 100644 server/pages/panels_test.go create mode 100644 src/js/sw/sdk/panels.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bad08b..64540c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to Armature are documented here. +## v0.10.0 — Panel Manifest + Lifecycle + +First version in the Panels series. Adds the plumbing layer for +composable companion views — a new rendering tier between surfaces +(full-page) and block renderers (inline). No presentation UI ships +in this version; floating and docked chrome arrive in v0.10.1–v0.10.2. + +**Manifest: `panels` field (provider + consumer)** + +- Provider form: packages declare panels as a map of `{ entry, title, + icon, description, min_width, min_height, default_width, default_height }` +- Consumer form: surfaces declare panel dependencies as an array of + `"package.panel"` strings (soft dependency — install succeeds even + if provider is missing) +- Validation enforces required `entry` and `title` for providers, + `pkg.panel` dot-format for consumers + +**Backend: panel resolution at surface load** + +- `PanelMeta` struct carries resolved panel metadata +- `resolvePanels()` resolves consumer references against installed and + enabled provider packages at surface render time +- `window.__PANELS__` injected into base template for extension surfaces +- Soft-dependency warning logged at install time for unresolved consumers + +**Frontend: `sw.panels` SDK module** + +- `sw.panels.open(panelId, opts)` — lazy-loads panel JS via dynamic + `import()`, mounts into container, caches module for reuse +- `sw.panels.close(panelId)` — calls cleanup, removes container +- `sw.panels.toggle(panelId, opts)` — open if closed, close if open +- `sw.panels.isOpen(panelId)` / `sw.panels.isAvailable(panelId)` — boolean queries +- `sw.panels.list()` / `sw.panels.active()` — registered and open panel IDs +- Events: `panels.opened` and `panels.closed` emitted on lifecycle transitions +- Mount context: `{ sw, params, panelId, close, resize }` +- Unstyled container (position:fixed div) — v0.10.1 adds FloatingPanel chrome + +**Tests:** 10 new — 6 manifest validation, 4 panel resolution + ## v0.9.9 — Surface Access via Roles Surfaces can now gate access by team role. A manifest declaring diff --git a/ROADMAP.md b/ROADMAP.md index e4326eb..3fd4e3a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,5 @@ # Armature — Roadmap -## Current: v0.9.x — Workflow Redesign + Multi-Surface Packages - Self-hosted extensible platform kernel. Auth, identity, packages, Starlark sandbox, storage, realtime, and ops are kernel primitives. Everything else is an extension. @@ -73,97 +71,37 @@ All completed work is documented in `CHANGELOG.md`. --- -## Planned - ### v0.9.x — Multi-Surface Packages + Workflow Redesign -**v0.9.0 — Multi-Surface Packages** *(completed)* - -Packages declare a `surfaces` array with per-path access controls, -titles, and layouts. Unified route tree dispatches between surface -rendering and ext API calls. `sw.navigate()` for client-side sub-path -routing. Design doc: `docs/DESIGN-multi-surface.md`. - -**v0.9.1 — Server-Side Sub-Path Routing** *(completed)* - -Consolidated root and catch-all route handlers into a unified dispatcher. -Added `aggregateAccess()` for early auth short-circuit on all-authenticated -packages. SDK seeds initial history state for back-button resilience. -8 handler integration tests + 5 aggregateAccess unit tests. - -**v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup** *(completed)* - -Consolidated duplicate Go↔Starlark converters into `sandbox/convert.go` -(4 exported functions) and snapshot parsers into `models/snapshot.go`. -Standardized on wrapped snapshot format. ~350 lines of duplication removed. -Design doc: `docs/DESIGN-workflow-redesign.md`. - -**v0.9.3 — Team User Roles** *(completed)* - -Many-to-many `team_user_roles` table. `RequireRole()` middleware. -Manifest `requires_roles` field (advisory). Starlark `teams` module -with `get_member_roles()` and `has_role()`. Team-admin UI with role -badge chips and assignment dropdown. 10 new tests. - -**v0.9.4 — Package Adoption + Roles** *(completed)* - -`adoptable` manifest field + `team_role_catalog` table. When a team -adopts an adoptable package, the package's `requires_roles` auto-populate -into the team's role catalog. Adopted packages reference the original via -`adopted_from` column (shared assets, no disk duplication). -`AdoptTeamWorkflow` deprecated in favor of package-level adoption. -4 new endpoints, migration 017, 11 new tests. - -**v0.9.5 — Typed Forms → SDK Primitive** *(completed)* - -Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from -`models/workflow.go` into a standalone `forms` package. REST endpoint -`POST /api/v1/forms/validate`. Starlark `forms.validate()` module. -FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`. -Manifest `form_template` accepted at package level. 16 new tests. - -**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** *(completed)* - -`stage_type` deprecated (no longer validated, defaults to "simple"). -`stage_mode` collapsed from 4→3 values: form / delegated / automated. -"review" mapped to "form" on input; review surface removed (~110 lines). -Migration 018. 4 package manifests updated. - -**v0.9.7 — Full Read/Write Workflow Starlark Module** *(completed)* - -`WorkflowEngine` interface extracted in sandbox package to break -circular import. Four write builtins added: `workflow.start()`, -`workflow.advance()`, `workflow.cancel()`, `workflow.submit_signoff()`. -`instanceToDict` and `signoffToDict` helpers shared by read+write paths. -6 new tests. - -**v0.9.8 — Conditional Routing → SDK Primitive** *(completed)* - -`routing.evaluate(rules, data)` Starlark builtin — a generic decision -engine reusable by any extension. 10 operators (exists, not_exists, eq, -neq, gt, lt, gte, lte, in, contains), first-match-wins, returns target -string or None. Always available (pure computation, no permission). -8 new tests. - -**v0.9.9 — Surface Access via Roles** *(completed)* - -`role:ROLENAME` surface access level. User must hold the role in any -team (any-team semantics, no URL context needed). `evaluateAccess` -promoted to Engine method for store access. `HasRoleInAnyTeam` store -method queries both primary and additional roles. Admin bypass, fail- -closed on nil store. 10 new tests. +| Version | Title | +|---------|-------| +| v0.9.0 | Multi-Surface Packages | +| v0.9.1 | Server-Side Sub-Path Routing | +| v0.9.2 | Converter Consolidation | +| v0.9.3 | Team User Roles | +| v0.9.4 | Package Adoption + Roles | +| v0.9.5 | Typed Forms SDK | +| v0.9.6 | Stage Mode Collapse | +| v0.9.7 | Workflow Starlark Write Ops | +| v0.9.8 | Routing SDK Primitive | +| v0.9.9 | Surface Access via Roles | --- -### v0.10.x — Panels + Composable Layout +## Current: v0.10.x — Panels + Composable Layout Panels are a new kernel rendering tier between surfaces (full-page) and block renderers (inline). They solve composable companion views — e.g., a notes reference panel inside chat. Design doc: `docs/DESIGN-panels.md`. +**v0.10.0 — Panel Manifest + Lifecycle** *(completed)* + +Manifest `panels` field (provider map + consumer array, soft dependency). +`resolvePanels()` at surface load. `sw.panels` SDK module with full +lifecycle API. Lazy JS loading with module caching. 10 new tests. + | Version | Title | |---------|-------| -| v0.10.0 | Panel Manifest + Lifecycle | | v0.10.1 | FloatingPanel Primitive | | v0.10.2 | Docked Panels + Mode Transitions | | v0.10.3 | Panel Communication Patterns | 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()]; + }, + }; +}