diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e30e28..bdec59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ All notable changes to Armature are documented here. +## v0.8.5 — Extension Composability + +Extensions can now compose with each other through declared slots, UI +contributions, and cross-package function calls. This is the last kernel +feature before 1.0 — the platform now supports the "extensions extending +extensions" pattern. + +**Manifest declarations** + +- Surfaces declare named `slots` in their manifest (e.g., `toolbar-actions`, + `note-footer`) with context documentation for extension authors. +- Extensions declare `contributes` entries targeting `{host}:{slot}` names + (e.g., `notes:toolbar-actions`). Coupling is soft — install order doesn't + matter. +- The kernel validates slot/contribution conventions at install time. + +**Backend: `lib.require()` relaxation** + +- `lib.require()` now works with any package that declares `exports`, not + just `type: "library"` packages. A full package (with surfaces, settings, + UI) can export callable functions for cross-package use. +- The existing `depends` and permission model is unchanged — the called + function runs with the target package's permissions. + +**Admin slots endpoint** + +- `GET /api/v1/admin/slots` returns an aggregated map of all declared slots + across installed packages with their contributors. +- Uninstalling a package that declares slots warns about orphaned + contributions in other packages. + +**SDK additions** + +- `sw.slots.renderAll(name, context)` — convenience helper for host surfaces + to render all components in a slot with error isolation. +- `sw.slots.declare(name, description)` — runtime slot declaration for + discoverability and debugging. + +**Documentation** + +- `PACKAGE-FORMAT.md` — added `slots`, `contributes`, `depends` field docs. +- `EXTENSION-GUIDE.md` — new "Extension Composability" section with slot + naming conventions, contribution patterns, and cross-package call examples. +- `STARLARK-REFERENCE.md` — updated `lib` module to reflect exports-based + calling (not library-type-only). + +**Modified files:** + +- `server/sandbox/lib_module.go` — type check → exports check +- `server/handlers/extensions.go` — composability field validation +- `server/handlers/packages.go` — orphaned contribution warning +- `server/main.go` — admin slots route registration +- `src/js/sw/sdk/slots.js` — `renderAll()`, `declare()`, `declarations()` +- `docs/PACKAGE-FORMAT.md` — slots, contributes, depends +- `docs/EXTENSION-GUIDE.md` — composability section +- `docs/STARLARK-REFERENCE.md` — lib module update +- `docs/DESIGN-extension-composability.md` — status Draft → Implemented + +**New files:** + +- `server/handlers/admin_slots.go` — admin slot aggregation endpoint + ## v0.8.4 — Documentation Refresh + Surface Sizing Fix Eight versions of module additions (v0.7.5–v0.8.3) shipped without a diff --git a/ROADMAP.md b/ROADMAP.md index 545e2a0..a0fa1bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Armature — Roadmap -## Current: v0.8.5 — Extension Composability +## Current: v0.8.5 — Extension Composability (Kernel Complete) Self-hosted extensible platform kernel. Auth, identity, packages, Starlark sandbox, storage, realtime, and ops are kernel primitives. Everything else @@ -444,7 +444,7 @@ sizing fix: `.surface-inner` converted to flex column layout so the shell topbar and surface container share vertical space correctly — eliminates 44px scroll cutoff on all surfaces. -**v0.8.5 — Extension Composability** +**v0.8.5 — Extension Composability** ✅ Design doc: `docs/DESIGN-extension-composability.md` @@ -453,14 +453,16 @@ and `contributes` (extensions declare UI contributions to other packages' slots). `lib.require()` relaxed from library-only to any package with `exports` — enables full packages (with surfaces, settings, UI) to also expose callable backend functions. `sw.slots.renderAll()` SDK helper. -Admin slot aggregation endpoint. +Admin slot aggregation endpoint. Uninstall orphan warnings. The composability primitive that enables: LLM tool registration, UI contribution (toolbar buttons, image actions), and cross-extension function calls. No new tables, migrations, or permissions. -Modified: `sandbox/lib_module.go` (~1 line), `handlers/extensions.go`, -`src/js/sw/sdk/slots.js`. New: `handlers/admin_slots.go`. +Modified: `sandbox/lib_module.go`, `handlers/extensions.go`, +`handlers/packages.go`, `src/js/sw/sdk/slots.js`, `server/main.go`. +New: `handlers/admin_slots.go`. Docs: PACKAGE-FORMAT, EXTENSION-GUIDE, +STARLARK-REFERENCE updated. --- diff --git a/VERSION b/VERSION index b60d719..7ada0d3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.4 +0.8.5 diff --git a/docs/DESIGN-extension-composability.md b/docs/DESIGN-extension-composability.md index a50f55f..8f751ab 100644 --- a/docs/DESIGN-extension-composability.md +++ b/docs/DESIGN-extension-composability.md @@ -1,7 +1,7 @@ # DESIGN — Extension Composability -**Version:** v0.8.4 -**Status:** Draft +**Version:** v0.8.5 +**Status:** Implemented **Author:** Jeff / Claude session 2026-04-02 --- diff --git a/docs/EXTENSION-GUIDE.md b/docs/EXTENSION-GUIDE.md index 263b22d..81f34a1 100644 --- a/docs/EXTENSION-GUIDE.md +++ b/docs/EXTENSION-GUIDE.md @@ -77,7 +77,10 @@ Every package has a `manifest.json` at its root. Example for a surface: | `api_schema` | no | OpenAPI documentation for extension API routes (see below) | | `db_tables` | no | Table definitions (see below) | | `settings` | no | User-configurable settings schema | -| `exports` | libraries | Functions exported for other packages | +| `exports` | no | Functions exported for cross-package calls via `lib.require()` | +| `depends` | no | Array of package IDs this package depends on | +| `slots` | no | Named UI injection points for host surfaces | +| `contributes` | no | Slot contributions into other surfaces | | `hooks` | no | Event bus subscriptions | | `config_section` | no | Settings/Admin panel injection (see below) | | `capabilities` | no | Environment requirements (see below) | @@ -194,6 +197,117 @@ sandbox permission model. In Starlark, check permissions inline via `req["permissions"]` or call `permissions.check(user_id, "image-gen.use")`. +## Extension Composability + +Extensions compose with each other through three mechanisms: manifest-declared +**slots** (UI injection points), **contributions** (UI components injected into +those slots), and cross-package **function calls** via `lib.require()`. + +### Slots — Host Surfaces Declare Injection Points + +A surface declares named slots in its manifest where other extensions can +inject UI components: + +```json +{ + "id": "notes", + "type": "surface", + "slots": { + "toolbar-actions": { + "description": "Toolbar action buttons", + "context": { + "noteId": "string", + "getContent": "function — returns note body", + "setContent": "function — replaces note body" + } + } + } +} +``` + +The surface renders slot contents using the SDK helper: + +```javascript +html`
` +``` + +### Contributions — Extensions Inject UI + +An extension declares which slots it contributes to: + +```json +{ + "id": "note-dictate", + "type": "extension", + "contributes": { + "notes:toolbar-actions": { + "label": "Dictate", + "icon": "🎤", + "description": "Voice-to-text dictation" + } + } +} +``` + +In its JavaScript, it registers the component: + +```javascript +sw.slots.register('notes:toolbar-actions', { + id: 'note-dictate', + priority: 200, + component: ({ noteId, setContent, getContent }) => { + // ... component implementation + return html``; + } +}); +``` + +Contributions are soft-coupled — install order doesn't matter. The admin +can view all slots and contributors at **Admin > Packages** or via +`GET /api/v1/admin/slots`. + +### Cross-Package Function Calls + +Any package that declares `exports` in its manifest can be called by other +packages via `lib.require()`. The caller declares the dependency: + +```json +{ + "id": "note-ai", + "depends": ["llm-bridge"], + "permissions": ["api.http"] +} +``` + +In Starlark: + +```python +llm = lib.require("llm-bridge") +result = llm.complete([{"role": "user", "content": prompt}]) +``` + +The called function runs with the *target* package's permissions, not the +caller's. This is the same security model as library packages. + +### Slot Naming Convention + +Slot names follow `{host-package-id}:{slot-name}`. The colon separates the +namespace from the slot. Standard slots for first-party packages: + +| Slot | Host | Use Case | +|------|------|----------| +| `notes:toolbar-actions` | notes | Dictation, AI tools, formatting | +| `notes:note-footer` | notes | Related items, AI summary | +| `chat:composer-tools` | chat | Image gen, file attach | +| `chat:message-actions` | chat | Reactions, translate, bookmark | +| `chat:image-actions` | chat | Regen, edit, upscale | + ## Starlark Sandbox API Starlark scripts run server-side with a 1M operation budget and no diff --git a/docs/PACKAGE-FORMAT.md b/docs/PACKAGE-FORMAT.md index 8ef48eb..1e57a40 100644 --- a/docs/PACKAGE-FORMAT.md +++ b/docs/PACKAGE-FORMAT.md @@ -68,10 +68,66 @@ Only `manifest.json` is required. All other directories are optional and include | `db_tables` | Table definitions with columns and indexes | | `settings` | User-configurable settings with type, label, description, default | | `hooks` | Event bus subscription patterns | -| `exports` | Functions exported by library packages | +| `exports` | Functions exported for cross-package calls via `lib.require()` | +| `depends` | Array of package IDs this package depends on | +| `slots` | Named UI injection points (host surfaces declare these) | +| `contributes` | Slot contributions this package injects into other surfaces | | `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` | | `schema_version` | Integer for additive schema migrations | +## Composability: Slots and Contributions + +Packages compose with each other through named UI injection points (**slots**) and **contributions**. + +### Declaring Slots (Host Surfaces) + +Surfaces declare slots where other extensions can inject UI: + +```json +{ + "slots": { + "toolbar-actions": { + "description": "Toolbar action buttons", + "context": { + "noteId": "string — current note ID", + "getContent": "function — returns note body text" + } + } + } +} +``` + +Slot names are namespaced at runtime as `{package-id}:{slot-name}` (e.g., `notes:toolbar-actions`). The `context` field documents what data the slot provides — it is not enforced at runtime. + +### Contributing to Slots + +Extensions declare which slots they inject into: + +```json +{ + "contributes": { + "notes:toolbar-actions": { + "label": "Dictate", + "icon": "🎤", + "description": "Voice-to-text dictation" + } + } +} +``` + +Contributing extensions can be installed before or after their host surface — the coupling is soft. The admin can view all slots and their contributors at `GET /api/v1/admin/slots`. + +### Cross-Package Function Calls + +Any package that declares `exports` can be called via `lib.require()`, not just library-type packages. The caller must declare the target in its `depends` array: + +```json +{ + "depends": ["image-gen"], + "permissions": ["api.http"] +} +``` + ## Package Lifecycle 1. **Install**: Upload a `.pkg` file via Admin > Packages or `POST /api/v1/admin/packages/install`. The kernel extracts the archive, creates database tables, and registers routes. diff --git a/docs/STARLARK-REFERENCE.md b/docs/STARLARK-REFERENCE.md index 8b916a1..f3abf68 100644 --- a/docs/STARLARK-REFERENCE.md +++ b/docs/STARLARK-REFERENCE.md @@ -50,7 +50,7 @@ is detected by the kernel. Detected capabilities: `pgvector`, `workspace`, ### lib -Load exported functions from library packages. +Load exported functions from other packages. ```python helpers = lib.require("my-utils") @@ -58,11 +58,17 @@ result = helpers.format_date("2026-01-15") ``` Requirements: -- The library must be declared in your package manifest's `dependencies`. -- The library must be type `library`, status `active`, tier `starlark`. +- The target package must be declared in your manifest's `depends` array. +- The target package must declare `exports` in its manifest. +- The target package must be status `active`, tier `starlark`. +- Any package type (`library`, `extension`, `full`) can be called as long + as it declares exports. This enables full packages to expose callable + functions alongside their UI and API routes. - Circular dependencies are detected and rejected. - Results are cached per execution (calling `require` twice returns the same object). +- The called function runs with the *target* package's permissions, not + the caller's. ### permissions diff --git a/server/handlers/admin_slots.go b/server/handlers/admin_slots.go new file mode 100644 index 0000000..81bbe3c --- /dev/null +++ b/server/handlers/admin_slots.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// slotInfo describes a declared slot and its contributors. +type slotInfo struct { + Host string `json:"host"` + Description string `json:"description"` + Context map[string]any `json:"context,omitempty"` + Contributors []slotContributor `json:"contributors"` +} + +// slotContributor describes one package's contribution to a slot. +type slotContributor struct { + Package string `json:"package"` + Label string `json:"label,omitempty"` + Icon string `json:"icon,omitempty"` + Description string `json:"description,omitempty"` +} + +// ListSlots returns an aggregated map of all declared slots across installed +// packages together with their contributors. Built on-the-fly from manifests. +// GET /api/v1/admin/slots +func (h *PackageHandler) ListSlots(c *gin.Context) { + pkgs, err := h.stores.Packages.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"}) + return + } + + slots := map[string]*slotInfo{} + + // Pass 1: collect declared slots from host surfaces + for _, pkg := range pkgs { + slotsRaw, ok := pkg.Manifest["slots"].(map[string]any) + if !ok { + continue + } + for name, v := range slotsRaw { + fullName := pkg.ID + ":" + name + entry, ok := v.(map[string]any) + if !ok { + continue + } + desc, _ := entry["description"].(string) + var ctx map[string]any + if c, ok := entry["context"].(map[string]any); ok { + ctx = c + } + slots[fullName] = &slotInfo{ + Host: pkg.ID, + Description: desc, + Context: ctx, + Contributors: []slotContributor{}, + } + } + } + + // Pass 2: collect contributions + for _, pkg := range pkgs { + if !pkg.Enabled { + continue + } + contribs, ok := pkg.Manifest["contributes"].(map[string]any) + if !ok { + continue + } + for slotKey, v := range contribs { + // Ensure the slot entry exists (contributor can arrive before host) + if _, exists := slots[slotKey]; !exists { + host := slotKey + if idx := strings.Index(slotKey, ":"); idx >= 0 { + host = slotKey[:idx] + } + slots[slotKey] = &slotInfo{ + Host: host, + Contributors: []slotContributor{}, + } + } + contrib := slotContributor{Package: pkg.ID} + if m, ok := v.(map[string]any); ok { + contrib.Label, _ = m["label"].(string) + contrib.Icon, _ = m["icon"].(string) + contrib.Description, _ = m["description"].(string) + } + slots[slotKey].Contributors = append(slots[slotKey].Contributors, contrib) + } + } + + c.JSON(http.StatusOK, gin.H{"data": slots}) +} diff --git a/server/handlers/composability_test.go b/server/handlers/composability_test.go new file mode 100644 index 0000000..da2d1da --- /dev/null +++ b/server/handlers/composability_test.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "testing" +) + +func TestValidateComposabilityFields_ValidSlots(t *testing.T) { + m := map[string]any{ + "slots": map[string]any{ + "toolbar-actions": map[string]any{ + "description": "Toolbar action buttons", + "context": map[string]any{ + "noteId": "string", + }, + }, + }, + } + if err := ValidateComposabilityFields(m); err != "" { + t.Errorf("expected valid, got: %s", err) + } +} + +func TestValidateComposabilityFields_SlotMissingDescription(t *testing.T) { + m := map[string]any{ + "slots": map[string]any{ + "toolbar": map[string]any{ + "context": map[string]any{}, + }, + }, + } + if err := ValidateComposabilityFields(m); err == "" { + t.Error("expected error for slot missing description") + } +} + +func TestValidateComposabilityFields_SlotsNotObject(t *testing.T) { + m := map[string]any{ + "slots": "bad", + } + if err := ValidateComposabilityFields(m); err == "" { + t.Error("expected error for non-object slots") + } +} + +func TestValidateComposabilityFields_ValidContributes(t *testing.T) { + m := map[string]any{ + "contributes": map[string]any{ + "notes:toolbar-actions": map[string]any{ + "label": "Dictate", + "icon": "🎤", + }, + }, + } + if err := ValidateComposabilityFields(m); err != "" { + t.Errorf("expected valid, got: %s", err) + } +} + +func TestValidateComposabilityFields_ContributesMissingColon(t *testing.T) { + m := map[string]any{ + "contributes": map[string]any{ + "toolbar-actions": map[string]any{ + "label": "Bad", + }, + }, + } + if err := ValidateComposabilityFields(m); err == "" { + t.Error("expected error for contributes key without colon") + } +} + +func TestValidateComposabilityFields_Empty(t *testing.T) { + m := map[string]any{ + "id": "test", + } + if err := ValidateComposabilityFields(m); err != "" { + t.Errorf("expected valid for manifest without slots/contributes, got: %s", err) + } +} + +func TestValidateComposabilityFields_BothValid(t *testing.T) { + m := map[string]any{ + "slots": map[string]any{ + "my-slot": map[string]any{ + "description": "A slot", + }, + }, + "contributes": map[string]any{ + "other:their-slot": map[string]any{ + "label": "Hello", + }, + }, + } + if err := ValidateComposabilityFields(m); err != "" { + t.Errorf("expected valid, got: %s", err) + } +} diff --git a/server/handlers/extensions.go b/server/handlers/extensions.go index 88f7c90..692a28b 100644 --- a/server/handlers/extensions.go +++ b/server/handlers/extensions.go @@ -37,6 +37,44 @@ var validTiers = map[string]bool{ models.ExtTierSidecar: true, } +// ValidateComposabilityFields checks that manifest slots and contributes +// fields follow the expected conventions. Slots values must have a +// "description" string. Contributes keys must follow "pkg:slot" naming. +// Returns nil if valid or missing; returns an error string otherwise. +func ValidateComposabilityFields(manifest map[string]any) string { + // Validate slots: map of name → {description, ...} + if slotsRaw, ok := manifest["slots"]; ok { + slots, ok := slotsRaw.(map[string]any) + if !ok { + return "slots must be an object" + } + for name, v := range slots { + entry, ok := v.(map[string]any) + if !ok { + return "slots." + name + " must be an object" + } + if _, ok := entry["description"]; !ok { + return "slots." + name + " must have a description" + } + } + } + + // Validate contributes: map of "pkg:slot" → {label, ...} + if contribRaw, ok := manifest["contributes"]; ok { + contribs, ok := contribRaw.(map[string]any) + if !ok { + return "contributes must be an object" + } + for key := range contribs { + if !strings.Contains(key, ":") { + return "contributes key " + key + " must follow pkg:slot convention" + } + } + } + + return "" +} + // ── User endpoints ────────────────────────────── // ListUserExtensions returns all enabled extensions for the current user, @@ -208,6 +246,12 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) { manifestMap = map[string]any{} } + // Validate composability fields (slots / contributes) + if errMsg := ValidateComposabilityFields(manifestMap); errMsg != "" { + c.JSON(400, gin.H{"error": "manifest: " + errMsg}) + return + } + pkg := &store.PackageRegistration{ ID: body.ExtID, Title: body.Name, diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 78448b4..a970845 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -212,10 +212,48 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true}) + resp := gin.H{"id": id, "deleted": true} + + // Warn about orphaned slot contributions + if orphaned := findOrphanedContributors(c, h.stores, id); len(orphaned) > 0 { + resp["warning"] = "slot contributions orphaned in: " + strings.Join(orphaned, ", ") + } + + c.JSON(http.StatusOK, resp) h.broadcastPackageChanged("deleted", id) } +// findOrphanedContributors returns package IDs that declare contributes +// targeting slots owned by the given package (e.g. "notes:toolbar-actions" +// targets the "notes" package). This is a warning, not a blocker. +func findOrphanedContributors(c *gin.Context, stores store.Stores, deletedPkgID string) []string { + allPkgs, err := stores.Packages.List(c.Request.Context()) + if err != nil { + return nil + } + prefix := deletedPkgID + ":" + seen := map[string]bool{} + for _, p := range allPkgs { + if p.ID == deletedPkgID || !p.Enabled { + continue + } + contribs, ok := p.Manifest["contributes"].(map[string]any) + if !ok { + continue + } + for key := range contribs { + if strings.HasPrefix(key, prefix) { + seen[p.ID] = true + } + } + } + result := make([]string, 0, len(seen)) + for id := range seen { + result = append(result, id) + } + return result +} + // InstallPackage uploads and installs a .pkg/.surface archive. // POST /api/v1/admin/packages/install // @@ -466,6 +504,13 @@ func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string) return nil, nil, nil, err } + // Validate composability fields (slots / contributes) + if errMsg := ValidateComposabilityFields(manifest); errMsg != "" { + zr.Close() + c.JSON(http.StatusBadRequest, gin.H{"error": "manifest: " + errMsg}) + return nil, nil, nil, fmt.Errorf("invalid composability fields") + } + // Signing hook (reserved) if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" { if mInfo.Signature != "" { diff --git a/server/main.go b/server/main.go index 2079bc9..b742acc 100644 --- a/server/main.go +++ b/server/main.go @@ -859,6 +859,7 @@ func main() { admin.POST("/packages/:id/update", pkgAdm.UpdatePackage) admin.GET("/packages/:id/export", pkgAdm.ExportPackage) admin.GET("/dependencies", pkgAdm.ListAllDependencies) + admin.GET("/slots", pkgAdm.ListSlots) // Workflow package export wfPkgH := handlers.NewWorkflowPackageHandler(stores) diff --git a/server/sandbox/lib_module.go b/server/sandbox/lib_module.go index cb50c6b..6d7805b 100644 --- a/server/sandbox/lib_module.go +++ b/server/sandbox/lib_module.go @@ -79,19 +79,21 @@ func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *Ru return nil, fmt.Errorf("lib.require: package %q does not declare dependency on %q", consumerPkgID, libraryID) } - // 4. Load library's PackageRegistration + // 4. Load target PackageRegistration libPkg, err := r.stores.Packages.Get(ctx, libraryID) if err != nil { - return nil, fmt.Errorf("lib.require: library %q not found: %w", libraryID, err) + return nil, fmt.Errorf("lib.require: package %q not found: %w", libraryID, err) } - if libPkg.Type != "library" { - return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type) + // Any package with exports is callable — not just type "library" + exports := extractExports(libPkg.Manifest) + if len(exports) == 0 { + return nil, fmt.Errorf("lib.require: package %q declares no exports", libraryID) } if libPkg.Status != models.PackageStatusActive { - return nil, fmt.Errorf("lib.require: library %q is %s, not active", libraryID, libPkg.Status) + return nil, fmt.Errorf("lib.require: package %q is %s, not active", libraryID, libPkg.Status) } if libPkg.Tier != models.ExtTierStarlark { - return nil, fmt.Errorf("lib.require: library %q is tier %s, not starlark", libraryID, libPkg.Tier) + return nil, fmt.Errorf("lib.require: package %q is tier %s, not starlark", libraryID, libPkg.Tier) } // 5. Build library's module set (library's own permissions) @@ -113,12 +115,7 @@ func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *Ru return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err) } - // 8. Extract exports from manifest - exports := extractExports(libPkg.Manifest) - if len(exports) == 0 { - return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID) - } - + // 8. Build exported members (exports already extracted above) members := make(starlark.StringDict, len(exports)) for _, name := range exports { val, ok := result.Globals[name] diff --git a/src/js/sw/sdk/slots.js b/src/js/sw/sdk/slots.js index 995e5d6..af49a20 100644 --- a/src/js/sw/sdk/slots.js +++ b/src/js/sw/sdk/slots.js @@ -15,6 +15,8 @@ export function createSlots(emitFn) { /** @type {Map