# LoomWeaver — full context for AI assistants > LoomWeaver is a domain-agnostic plugin & UI platform. A product is a **distribution**: a thin app > that composes `@loom/shell` + one or more **weavers** (plugins) from the published packages. The > platform core has **zero domain logic**. This file inlines everything an assistant needs to build a > distribution or a weaver from the outside — the mental model, the complete public contract, and the > canonical code. Prose guides live under `docs/`; this is the single-fetch brief. ## Mental model - **Platform** (`@loom/shell`, `@loom/plugin-sdk`) renders neutral chrome, holds contributions, brokers capabilities. Domain-pure. - **Weaver** (a plugin): an object with `manifest` + `activate(ctx)`. All domain UI/logic here. Imports only `@loom/plugin-sdk`. - **Distribution** (a product): composes shell + weavers, declares a layout, grants capabilities, brands itself. ~1 file. This is what you deploy. - **Capabilities are default-deny**: a weaver *declares* needs; the distribution *grants* them. Ungranted use throws `CapabilityError`. Coarse: `contributions` (`ctx.register*`, `ctx.contributeIcons`), `ui` (`ctx.ui.*`), `host` (`ctx.host.*`), `navigation` (`ctx.navigateContent`/`openContentTab`/`closeContentTab`), `session` (`ctx.session`), `theme` (`ctx.contributeTheme(tokens, dark?)` — re-skin the whole app: colors + UI font). The token vocabulary is COLOUR AND TYPE ONLY (27 colours + 2 font families) — sizes, radii and spacing have no tokens BY DECISION, because tokenising every number would freeze every rule of the chrome into a promise. A product that must change a size writes plain UNLAYERED CSS, which beats everything the shell paints (all of it is in cascade layers) — target the `.lw-*` class contracts (stable) or the element tags `lw-shell-rail`/`lw-shell-panel`/`lw-content-area`/… (structural, NOT a versioned contract). It does not reach a sandboxed surface's document. See docs/reference/design-tokens.md. Effective = grant ∩ declaration. Enforcement is **live**: a built-in Permissions settings section lets the user revoke any granted runtime capability (`ui`/`host`/`navigation`/`session`/`theme`) at runtime, or turn a whole plugin **off** (an on/off switch — the plugin unloads and none of its contributions appear). Both are user-local (the settings store) and take effect at once; a revocation throws `CapabilityError` on the plugin's next call (surfaced as a warning toast, not a silent failure). The user can only narrow, never widen; the settings surface stays reachable via `shell.openSettings` so revoking never locks the user out. - **Packages**: npm `@loom/plugin-sdk`, `@loom/shell`, `@loom/mcp` (AI-scaffolding MCP server), `@loom/cli` (scaffolding CLI), `@loom/devkit` (Nx generator collection), `@loom/sandbox-kit` (static UI assets for sandboxed plugins) — six npm packages, one shared version. The platform ships **no server package**; the backend is the product's. ## The plugin contract (`@loom/plugin-sdk`) — complete ```ts interface Plugin { readonly manifest: PluginManifest; activate(ctx: PluginContext): void | Promise; deactivate?(): void; } interface PluginManifest { readonly id: string; readonly name?: string; readonly capabilities?: readonly Capability[]; // declared; granted by the distribution } type Capability = 'contributions' | 'ui' | 'host' | 'navigation' | 'session' | 'theme'; interface PluginContext { registerCommand(command: Command): Disposable; registerSurface(surface: Surface): Disposable; // the ONE author contract: capabilities, not location. // panel view: registerSurface({ id, title, docks: [region], component }) // content view: registerSurface({ id, title, routable: { path }, component }) registerBarItem(item: BarItem): Disposable; registerRailItem(item: RailItem): Disposable; registerSettingsSection(section: SettingsSection): Disposable; registerMenuItem(item: MenuItem): Disposable; // add an item to a menu slot, e.g. content/tab/context contributeIcons(icons: Readonly>): Disposable; // name → SVG; flat, collision-safe, sanitized at registration contributeTheme(tokens: Readonly>, dark?: Readonly>): Disposable; // --lw-* token → value (colors + --lw-font-sans/-mono); re-skins the whole app; optional `dark` overrides tokens only in dark mode; [theme] cap; Product < Plugin < Tenant navigateContent(path: string): void; // go to another routable surface [navigation] openContentTab(input: OpenTabInput): void; // open a titled dynamic tab + navigate [navigation] keepContentTab(path: string): void; // promote a preview tab to permanent (0024) [navigation] pinContentTab(path: string): void; // pin: anchor to front + close-guard (0024) [navigation] unpinContentTab(path: string): void; // unpin back to a normal tab (0024) [navigation] closeContentTab(path: string): void; // close a dynamic tab [navigation] revealSurface(id: string): void; // activate a DOCKED surface's tab wherever the user placed it (sidebar pane — expanding a collapsed panel — or content pane); no-op for unknown/container-child ids. Routable surfaces: use navigateContent [navigation] readonly activeContent: () => ActiveContent | null; // signal-shaped read: which routable surface the URL pane // shows + its :params — instead of injecting the host router / // parsing URLs. Trusted rung only. [navigation] readonly ui: PluginUi; readonly host: PluginHost; readonly session: PluginSession; // read login state + roles for self-gating [session] } interface ActiveContent { // the read side of the content area (ctx.activeContent) readonly surfaceId: string | null; // matched surface's id (null if the route carries none) readonly path: string; // full active content path incl. sub-route segment readonly params: Readonly>; // :param values of the matched route pattern } interface Command { // one behaviour, many triggers (items reference it by id) readonly id: string; // e.g. "notes.add" readonly title: string; // translation key or literal readonly icon?: string; // host icon name readonly shortcut?: string; // chord, e.g. "mod+enter" (mod = ⌘/Ctrl) readonly access?: AccessRequirement; // gate this command: blocked at the one execute() seam — keybinding + palette too readonly paletteHidden?: boolean; // hide from the command palette: a context-only command whose run needs a MenuContext the palette can't supply. Menu items + keybindings still invoke it. readonly popout?: boolean; // OPT-IN: commands are MAIN-WINDOW-ONLY by default; declare popout to offer one in a pop-out window (one surface, no tab strip / rail / sidebar). Unmarked → palette omits it there, keybinding no-ops, bound item does nothing. The quiet default is deliberate: a missing command is a small annoyance, a surprising one in a detached window is worse, and the shell cannot tell them apart for a command it did not write — it marks its own two (palette, Settings) and guesses for none of yours. Independently: content navigation is REFUSED in a pop-out (dev warning), since it would take the window out of /popout/… and silently stop it being a pop-out; and Quick-Open is not registered there at all. run(context?: MenuContext): void | Promise; // context passed when invoked from a menu } // Menu contribution. `menu` = slot id (host `content/tab/context`, or your own). Behaviour via a // command id (crosses the sandbox boundary) or inline run (trusted). `when` = coarse subset match against the // opener's context (serialisable primitives) for visibility. The host draws the menu at the cursor. // A RailItem/BarButtonItem/ViewAction may carry `menu?: string` — the host opens that slot as the item's // context menu on right-click, region-agnostic, with a `{ targetKind, id, region }` context. // ONLY the element that opens a menu suppresses the native one; everywhere else (plugin content, and above // all a text field in it) the browser's own right-click menu stays — the shell suppresses nothing globally. type MenuContext = Readonly>; interface MenuItem { id?: string; menu: string; command?: string; run?(c?: MenuContext): void; title?: string; group?: string; order?: number; when?: MenuContext; checkedWhen?: MenuContext; /* checkedWhen ⇒ menuitemcheckbox, checked when it ⊆ context; the item shows its command's icon + shortcut hint. id ⇒ re-registering replaces the entry (last wins) and `provideShell({omit:[id]})` drops it; built-in entries use `menu:` (e.g. menu:shell.tab.closeAll), distinct from the command id so the command survives an entry-only omit. Without id: additive. An entry whose `command:` id no longer resolves (omitted or unregistered) is HIDDEN, not rendered as its raw id — so omitting a bare command id cleanly removes it from the palette AND the menu at once. */ } interface View { readonly id: string; readonly region: string; // a region id from the distribution's layout readonly title: string; readonly order?: number; readonly icon?: string; readonly actions?: readonly ViewAction[]; readonly access?: AccessRequirement; // gate the whole view (tab + body); hide-only (mode ignored) readonly instanceable?: boolean; // opt in to named saved instances (slice 2b): host shows a header switcher to // save/name/rename/delete configs, each with its own auto-saved VIEW_STATE blob; the non-deletable // default carries the baseline. Component code is unchanged — it just reads/writes VIEW_STATE. // The switcher travels with the view: sidebar, content pane, split and pop-out all render it. readonly component?: Type; // an Angular component readonly loadComponent?: () => Promise>; // …or deferred: the host calls it on first mount } interface ViewAction { id: string; icon: string; title: string; order?: number; menu?: string; command?: string; access?: AccessRequirement; run?(): void | Promise; } // Persisted view state (Option B): a DOCKED surface persists its own serialisable state — filter, active // sub-tab, expanded nodes, scroll position — so it survives BOTH a hide and a reload. Since the retention rule destroys a // hidden, clean surface, this is THE survival path: the rule is "evictable = reload-safe" — anything that must // outlive a tab switch/collapse/F5 goes here, local component signals are for genuinely throwaway state only. // Inject it and type it: `const vs = inject(VIEW_STATE) as ViewState`. value() is Signal-shaped (undefined // = fresh instance → apply your own default); the host auto-saves every set() (debounced) to // lw.shell.view-state: via the distribution's working-state store. Domain-pure: the platform stores an opaque // blob, only the view interprets it. No new capability. TWO TRAPS: set() replaces the WHOLE blob (keep one state // shape and spread it — `set({...current, query})` — rather than five signals), and it needs no hand-rolled debounce // (call it per keystroke/scroll; the value is live at once, the write follows once the user stops). // A ROUTABLE surface has NO handle — decided, not missing: it owns a URL, so shareable state belongs in // route params/subRoutes (deep-linkable + history), and unsaved edits are DirtySurface or retain. A SANDBOXED surface // (always routable) has none either — nothing of this shape crosses the RPC boundary; it declares retain:'always' and // the host hides the iframe in place. Injecting VIEW_STATE outside a docked surface throws. interface ViewState { readonly value: () => T | undefined; set(next: T): void; readonly instanceId: string; } const VIEW_STATE: InjectionToken; // from @loom/plugin-sdk // ctx.state — THE PLUGIN'S OWN KEYED STORE (working state), where VIEW_STATE is one view INSTANCE's blob. Every // surface of the plugin sees the same store (any dock, any instance, EVERY WINDOW), so it is both persistence and the // only channel between a plugin's own surfaces — which for a sandboxed plugin is otherwise impossible (each surface is // its own opaque origin). Keys live under lw.plugin-state::; the host prefixes them, the plugin cannot // leave the namespace, so there is NO capability (nothing foreign to reach). Working state only: settings have their // own path BECAUSE THE USER CAN SEE IT in the settings dialog. Uninstall deletes the store (settings survive). // CHECK loaded() BEFORE APPLYING A DEFAULT — with a network-backed store the value lands after the user has typed // otherwise (the LWF-02 class of bug). set() replaces the WHOLE value ⇒ one key per unit of editing (a wizard step, not // the form) and key by instanceId where a surface can exist more than once. JSON values, debounced writes, size cap per // value + count cap per plugin (dev warning at half). SANDBOXED RUNG, both channels: the logic document and each // SURFACE call stateWatch/stateSet/stateClear/stateUnwatch and receive stateChanged(key, value, loaded) pushes; the // surface channel is what lets two surfaces of one sandboxed plugin agree on anything (a surface holds no ctx). The // kit reassembles the pushes into the same handle shape: LwSandbox.state.watch(key) + .onChange(fn), feed the push in // with LwSandbox.state.apply(...) from your methods and call LwSandbox.connectState(host) once connected. interface PluginState { watch(key: string): StateHandle; } interface StateHandle { readonly value: () => T | undefined; readonly loaded: () => boolean; set(next: T): void; clear(): void; dispose(): void; } // THE ONE SURFACE CONTRACT: a Surface declares WHAT IT CAN DO — routable (URL-addressable), // instanceable (named saved instances), docks (which regions may host it; first = home) — not where it lives; the // user arranges panes freely — every pane is a TAB GROUP with its own strip; dragging a tab MOVES it (never copies) // onto another group's strip or to an edge (split with the tab) — a pane holding NO tabs takes the whole drop instead // of offering edges, so a drag fills the empty content area rather than splitting it; Split right/down in the tab menu MOVES too, while // the pane TOOLBAR split DUPLICATES the active tab into a new pane; sidebars are the // same groups shown as icon tabs (Obsidian-parity). Every pane is its own VIEW_STATE instance and a moved // tab's VIEW_STATE travels with it; every content pane shows ONE toolbar (new tab / split right / split down / // minimize / maximize / close — distribution-configurable via provideShellFeatures({ content }): // maximize fills the whole viewport over all chrome (Escape restores); minimize collapses a split pane to a strip // (icon + active tab name + `+N` badge for extra tabs, click to restore); minimize/close are symmetric on both // panes of a split — closing the URL pane dissolves the split and the neighbour takes over). Exactly ONE // pane is URL-focused/router-rendered, and the focus is switchable. registerSurface is the only authoring entry: // a panel View ≙ a non-routable Surface with docks:[region]; a ContentRoute ≙ a routable one. View/ContentRoute // are only the host's internal storage shapes — registerSurface normalises into them. // component | loadComponent (deferred: the host calls it the first time the surface is shown — routable surfaces go // straight to the router's own loadComponent, host-mounted ones render once it resolves; use it for a surface with a // heavy dependency tree so it lands in its own chunk) | iframe | container. // container ("workspace-in-a-tab"): the host draws a NESTED pane tree of child surfaces INSIDE this // surface's content tab (same drag/split/tab mechanics, one level nested, scoped to the tab). Must be `routable` // (the container tab holds its own :id — several open in parallel, deep-linkable). `children` = surface ids the inner // picker offers; `initial` = children loaded first. Children are non-routable surfaces declared with `docks: []` // ("child-only": never in a sidebar, mounted only inside a container by id) and read the container's :id off Angular's // ActivatedRoute — no global "active X". The inner tree is per-window workspace state; a popped-out container carries it. // PRESENTATION IS VALID AT EVERY MOUNT POINT: `iframe` is not a routable-only form — a surface with `docks` and // no `routable` may be an iframe and the host mounts it at the dock. A docked surface has NO address, so its pushed // `tab` is always '' and its channel's `navigate` is a NO-OP with a dev warning (the channel is only safe because it // is confined to the surface's own tab root; a docked one has none) — use ctx.navigateContent (`navigation` grant). // The pushed state carries `instanceId` (the pane / named instance), so two mounts of one surface stay distinguishable, // and `params` (route params for a routable surface, the container's :id for a container child). type SurfacePresentation = { component: Type } | { loadComponent: () => Promise> } | { iframe: string } | { container: ContainerSpec }; interface SurfaceRoutable { path: string; chromeless?: boolean; title?: string; icon?: string; titleIsLiteral?: boolean; subRoutes?: readonly string[]; rest?: boolean; follows?: boolean; } type Surface = { id: string; title: string; icon?: string; order?: number; actions?: readonly ViewAction[]; access?: AccessRequirement; instanceable?: boolean; // named saved instances (2b) routable?: SurfaceRoutable; // URL-addressable — can hold the URL pane retain?: 'always' | 'never'; // what happens when HIDDEN (rendered by no pane). Default = the // distribution's retention default (itself 'destroy'): a hidden, CLEAN surface is // destroyed and rebuilt on return — state that must survive belongs in VIEW_STATE // (rule: evictable = reload-safe). 'always' keeps the live instance while hidden // (expensive rebuild, live connection); 'never' opts back into destroy. // A retained ROUTABLE surface is host-mounted in EVERY pane (the URL pane included; // its route only carries the address) and keyed to its pane, so handing the URL role // between split panes leaves each pane's instance in place — a split shows two // independent instances, deliberately. Price: a host-fabricated ActivatedRoute // everywhere — route params work (param change = different tab = different instance), // but NO resolvers, NO query params, NO live param streams, and a nested // is inert, so never combine retain with subRoutes (dev-mode warning; // read the sub-segment from the address instead). // Honoured for SANDBOXED (iframe) surfaces too: a retained iframe is // hidden in place, never moved, so the document keeps running (no Penpal handshake // per tab switch). It is still rebuilt whenever it would have to MOVE (split, drag // to another pane, minimise) — moving an iframe in the DOM reloads it. Container // surfaces are always rebuilt. A retained instance is still destroyed when its tab // is CLOSED — retention covers hiding. saveOn?: 'hide'; // auto-save on hide — when a DIRTY instance becomes hidden the host calls its // surfaceSave() fire-and-forget. Safe by construction: in-flight/failed save keeps // the instance dirty and therefore alive; failure surfaces as an error toast. closable?: boolean; // default true. false = the user cannot close a tab of this surface (no ×, // no Delete, no close menu entries); moving/splitting/dragging still work. // Applies to EVERY tab of the surface — right for a parameterless route // like 'dashboard', almost always wrong for 'doc/:id'. padded?: boolean; // default true = the host insets the surface 24px from its pane edges. // false = the surface owns its edges (viewer, canvas, map, edge-to-edge table). // Travels with the surface: URL pane, split, sidebar and pop-out alike. // Only the inset is switchable; how WIDE it is stays plain CSS. docks?: readonly string[]; // hostable regions; first = home dock; docks:[] = container-only child } & SurfacePresentation; // Unsaved changes: implement DirtySurface on the surface COMPONENT (per instance — one // doc/:id declaration backs many tabs). While surfaceDirty() is true the instance is NEVER destroyed on // hide (no hiding gesture is blocked or prompted), and CLOSING asks via the host's own localised dialog: // Save (only when surfaceSave exists) · Discard · Cancel. beforeunload prompts while anything is dirty // (that prompt's wording/language are the BROWSER's own — pages cannot set them, browser UI language wins). // surfaceDirty() is read reactively — read your signals inside. A sandboxed surface pushes its dirty flag // over the surface channel instead: parent.setDirty(true|false) — a dirty sandboxed surface survives hiding // like any other (S5), and closing/unload asks. saveOn:'hide' is inert for a sandboxed surface (no save // channel crosses the RPC boundary; save inside and push setDirty(false)). Routable surfaces have no // VIEW_STATE handle by design — for a routed editor, DirtySurface (or retain) is the way unsaved work survives hides. interface DirtySurface { surfaceDirty(): boolean; // unsaved changes? read reactively by the host surfaceSave?(): Promise; // optional: enables "Save" in the close dialog + the saveOn:'hide' target surfaceBeforeClose?(): boolean | Promise; // optional veto for USER-initiated closes: false cancels; // runs BEFORE the unsaved-changes dialog and never bypasses it for a // still-dirty instance; host-enforced timeout + guaranteed "Close anyway" // escape (throw/reject = approve — a broken veto can't make a tab // unclosable). NOT consulted for plugin disable/uninstall or workspace // reset — those run only the unsaved-changes ask. } // Programmatic destruction is guarded too: disabling, uninstalling or UPDATING a plugin and // resetting a workspace show the unsaved-changes dialog over the affected dirty instances first. // SWITCHING a workspace never asks: each workspace remembers its own arrangement, and a dirty surface // survives the switch parked under the normal retention rule. // Sandbox wires (surface channel: expose beforeClose() next to render(); runtime channel: expose // contentTabClosed(path) to learn when a tab you opened via openContentTab closes — the RPC counterpart // of the in-process onClose hook, whose callback cannot cross the boundary). // Content area: URL-addressed. ONE rule since tab groups retired: a pane draws a strip whenever it holds // tabs, and a `chromeless` surface draws none while it is active. Visiting ANY non-chromeless, non-`follows` // route auto-opens (or re-uses) its closable tab — there are no static tabs any more; the permanent tabs are // the `follows` facet tabs, labelled by the surface's title/icon and ordered by its `order`. Every pane has its // own strip (see Surface above). Exactly ONE pane carries the address; that role follows the user — clicking a // tab (or into a pane) hands it over, and navigating to a target another pane already holds activates it THERE // instead of opening a second copy (identity is the tab root, so a sub-route lands on the tab that owns it). Instance state survives a tab switch only via VIEW_STATE or a retain // declaration (a hidden, clean surface is destroyed). // Surface = an Angular `component` or deferred `loadComponent` (trusted only — cannot cross an RPC boundary), // OR an `iframe` URL (serialises over RPC, so it is the form a sandboxed/non-Angular plugin uses), // OR a `container` (the host draws a nested pane tree of child surfaces, ; `element`/WC reserved). // Host mounts an isolated