1 Commits
v0.9.9 ... main

Author SHA1 Message Date
33278d0d69 Feat v0.10.0 panel manifest lifecycle (#84)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m50s
CI/CD / test-sqlite (push) Successful in 3m9s
CI/CD / build-and-deploy (push) Successful in 1m1s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-03 21:22:30 +00:00
11 changed files with 619 additions and 83 deletions

View File

@@ -2,6 +2,45 @@
All notable changes to Armature are documented here. 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.1v0.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 ## v0.9.9 — Surface Access via Roles
Surfaces can now gate access by team role. A manifest declaring Surfaces can now gate access by team role. A manifest declaring

View File

@@ -1,7 +1,5 @@
# Armature — Roadmap # Armature — Roadmap
## Current: v0.9.x — Workflow Redesign + Multi-Surface Packages
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
sandbox, storage, realtime, and ops are kernel primitives. Everything else sandbox, storage, realtime, and ops are kernel primitives. Everything else
is an extension. 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.x — Multi-Surface Packages + Workflow Redesign
**v0.9.0 — Multi-Surface Packages** *(completed)* | Version | Title |
|---------|-------|
Packages declare a `surfaces` array with per-path access controls, | v0.9.0 | Multi-Surface Packages |
titles, and layouts. Unified route tree dispatches between surface | v0.9.1 | Server-Side Sub-Path Routing |
rendering and ext API calls. `sw.navigate()` for client-side sub-path | v0.9.2 | Converter Consolidation |
routing. Design doc: `docs/DESIGN-multi-surface.md`. | v0.9.3 | Team User Roles |
| v0.9.4 | Package Adoption + Roles |
**v0.9.1 — Server-Side Sub-Path Routing** *(completed)* | v0.9.5 | Typed Forms SDK |
| v0.9.6 | Stage Mode Collapse |
Consolidated root and catch-all route handlers into a unified dispatcher. | v0.9.7 | Workflow Starlark Write Ops |
Added `aggregateAccess()` for early auth short-circuit on all-authenticated | v0.9.8 | Routing SDK Primitive |
packages. SDK seeds initial history state for back-button resilience. | v0.9.9 | Surface Access via Roles |
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.
--- ---
### 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 Panels are a new kernel rendering tier between surfaces (full-page) and
block renderers (inline). They solve composable companion views — e.g., block renderers (inline). They solve composable companion views — e.g.,
a notes reference panel inside chat. Design doc: `docs/DESIGN-panels.md`. 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 | | Version | Title |
|---------|-------| |---------|-------|
| v0.10.0 | Panel Manifest + Lifecycle |
| v0.10.1 | FloatingPanel Primitive | | v0.10.1 | FloatingPanel Primitive |
| v0.10.2 | Docked Panels + Mode Transitions | | v0.10.2 | Docked Panels + Mode Transitions |
| v0.10.3 | Panel Communication Patterns | | v0.10.3 | Panel Communication Patterns |

View File

@@ -1 +1 @@
0.9.9 0.10.0

View File

@@ -38,6 +38,8 @@ type ManifestInfo struct {
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics) 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 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 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, // 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 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) info.SchemaVersion = ParseSchemaVersion(manifest)
// ── Type-specific constraints ──────────────────────────────── // ── Type-specific constraints ────────────────────────────────

View File

@@ -349,3 +349,100 @@ func TestValidateManifest_AutoSynthesizeSurfaces(t *testing.T) {
t.Errorf("expected layout 'editor', got %q", s["layout"]) 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)")
}
}

View File

@@ -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{ resp := gin.H{
"id": pkgID, "id": pkgID,
"title": mInfo.Title, "title": mInfo.Title,

View File

@@ -61,6 +61,20 @@ type SurfaceManifest struct {
Source string `json:"source"` // "core" or "extension" 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. // BannerConfig holds environment banner settings.
type BannerConfig struct { type BannerConfig struct {
Text string `json:"text"` Text string `json:"text"`
@@ -109,6 +123,8 @@ type PageData struct {
BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection) 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 InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL LogoURL string // branding: custom logo URL
Tagline string // branding: tagline under instance name Tagline string // branding: tagline under instance name
@@ -237,6 +253,72 @@ func (e *Engine) browserExtensionIDs() []string {
return ids 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. // UserContext is the authenticated user's info available to templates.
type UserContext struct { type UserContext struct {
ID string `json:"id"` ID string `json:"id"`
@@ -552,6 +634,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
EnabledSurfaces: e.EnabledSurfaceIDs(), EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(), ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(), BrowserExtensions: e.browserExtensionIDs(),
Panels: e.resolvePanels(c.Request.Context(), sr.Manifest),
}) })
} }
} }

141
server/pages/panels_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -124,6 +124,7 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}} {{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
{{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}} {{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}}
{{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}} {{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}}
{{if .Panels}}window.__PANELS__ = {{.Panels | toJSON}};{{end}}
</script> </script>
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed. {{/* All surfaces use Preact SDK boot(). Legacy script includes removed.

View File

@@ -27,6 +27,7 @@ import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js'; import { createUsers } from './users.js';
import { createTesting } from './testing.js'; import { createTesting } from './testing.js';
import { createForms } from './forms.js'; import { createForms } from './forms.js';
import { createPanels } from './panels.js';
import { confirm } from '../primitives/confirm.js'; import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js'; import { prompt } from '../primitives/prompt.js';
@@ -129,6 +130,9 @@ export async function boot() {
// Forms — typed form rendering + validation (v0.9.5) // Forms — typed form rendering + validation (v0.9.5)
sw.forms = createForms(restClient); 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 // Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm; sw.confirm = confirm;
sw.prompt = prompt; sw.prompt = prompt;
@@ -239,11 +243,18 @@ export async function boot() {
} }
// Marker for idempotency // Marker for idempotency
sw._sdk = '0.9.5'; sw._sdk = '0.10.0';
// 8. Expose globally // 8. Expose globally
window.sw = sw; 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 // 9. Boot sequence
theme.init(); theme.init();

170
src/js/sw/sdk/panels.js Normal file
View File

@@ -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<string, object>} panelId → manifest meta */
const _registry = new Map();
/** @type {Map<string, {el: HTMLElement, cleanup: Function}>} panelId → active state */
const _active = new Map();
/** @type {Map<string, object>} 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<boolean>} — 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<boolean>}
*/
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()];
},
};
}