# LoomWeaver — full context for AI assistants > LoomWeaver: open-source plugin platform for Angular workbenches. A product is a **distribution**: a thin app > that composes `@loomweaver/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 - **The contract is `openspec/specs/`**, one capability per file, stating what the workbench guarantees. Every guide and this file are derived from it; where any of them and a specification disagree, the specification is right. - **Platform** (`@loomweaver/shell`, `@loomweaver/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 `@loomweaver/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 (29 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-address-pane-header`/… (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`/`automation`) 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 `@loomweaver/plugin-sdk`, `@loomweaver/shell`, `@loomweaver/mcp` (AI-scaffolding MCP server), `@loomweaver/cli` (scaffolding CLI), `@loomweaver/devkit` (Nx generator collection), `@loomweaver/frame-kit` (static UI assets for sandboxed plugins), `@loomweaver/ag-ui` (AG-UI adapter: the workbench's own commands offered to an agent as tools, headless — no transport/UI/agent; its stability follows AG-UI, not the platform) — seven npm packages, one shared version. The platform ships **no server package**; the backend is the product's. ## The plugin contract (`@loomweaver/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' | 'automation'; 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 }) retitleSurface(id: string, title: string): void; // rename a surface you registered while it is MOUNTED: its tab, the panel header, // a picker that lists it all follow; the surface is NOT rebuilt (typed/scrolled/folded // state survives); a key still translates; an id you did not register = no-op [contributions] updateSurfaceBadge(id: string, badge: TabBadge | null): void; // give a surface you registered a badge, replace it, or take it away (null) // while it is MOUNTED: every tab showing it follows, the surface is NOT rebuilt, the // content routes are not recomputed; a key still translates; an id you did not // register (or another plugin did) = no-op; crosses the sandbox [contributions] setChildShown(childSurfaceId: string, shown: boolean): void; // leave a container child you registered OUT (false) or bring it back (true), at // any time, for a reason of your own (a licence, a setting). In every container listing // it: no tab, no keyboard stop, not in the inner picker, spared by closing in bulk, and // NO access placeholder; a pane holding only such children is not drawn. Its place is // kept, so it returns where it stood; the focus and the address move to a shown child; // open() of it does nothing. Per child, not per open container. Not a container child // you registered = no-op with a dev warning; crosses the sandbox [contributions] updateSurfaceAction(id: string, action: ViewAction): void; // replace ONE action of a surface you registered, by the action's id, while it is // MOUNTED: the header that draws it (panel or content pane) follows, the surface is NOT rebuilt; an action id the surface // did not carry is ADDED in its `order` place. This is how a toggle moves: replace it with the // opposite `pressed` when the state changes. Surface id you did not register = no-op; a // sandboxed surface carries no actions, so nothing there to replace [contributions] 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 [navigation] pinContentTab(path: string): void; // pin: anchor to front + close-guard [navigation] unpinContentTab(path: string): void; // unpin back to a normal tab [navigation] closeContentTab(path: string): void; // close a dynamic tab [navigation] updateContentTab(path: string, label: ContentTabLabel): void; // change an OPEN tab's title/icon/badge where it stands, WITHOUT // bringing it forward: the tab in front, the focus and the address stay; a // tab in another pane of the main area changes there. What label leaves out // stays; badge: null removes the tab's own badge. Never opens a tab (no open // tab = no-op); a tab whose content another plugin registered is left alone; // crosses the sandbox [contributions] 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 address pane // shows + its :params — instead of injecting the host router / // parsing URLs. Trusted rung only. [navigation] isShowingUnder(path: string): boolean; // is the address shown at or BELOW `path`, on SEGMENT boundaries: 'sales/quotes' // is under 'sales', 'sales/quotesomething' is NOT — the rule startsWith gets wrong. // Reads activeContent, so live in the same way. Trusted rung only. [navigation] invokeCommand(id: string, args?: CommandArguments): Promise; // run a registered command by id and get what it answered. // ANSWERS a refusal instead of throwing one (a caller working // through a list handles "you may not" as an outcome); the user // is still told. Own commands need no grant. [automation] readonly invocableCommands: () => readonly InvocableCommand[]; // what this caller may run — the workbench's own account, already // narrowed by callable + access + window + grant, texts resolved // to the active language. Read it instead of keeping a second // list: a second list is a second answer to "may this run". // Empty without the grant beyond your own. [automation] readonly ui: PluginUi; readonly host: PluginHost; readonly session: PluginSession; // read login state + roles for self-gating [session] readonly state: PluginState; // the plugin's OWN keyed store, every surface, every window (see ctx.state below); no capability } 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. A LITERAL is any string without the shape of a key // (dot-separated word segments, at least one dot, no whitespace): "DE", "Deutsch", a // person's name are shown as they are and never dev-warned as missing; a dotted string // no bundle knows IS warned, so a literal like "1.2.3" is the one case that still is. // The same rule holds for every "key or literal" field below. 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. readonly description?: string; // what the command DOES, in prose (key or literal) — for something CHOOSING between // actions, not for a control labelling one. No description ⇒ none; the title is never // substituted, because a label is not an explanation. readonly arguments?: readonly CommandArgument[]; // checked before run: a missing required argument, a wrong kind or a choice // outside the set is REFUSED and the command does not run. For discovery, NOT for safety. readonly answers?: string; // what the return value means. Declaring it is what makes run()'s return the answer; // without it an invocation succeeds carrying nothing. The value must be plain data. readonly callable?: boolean; // OPT-IN, exactly like popout: let a caller OTHER than this plugin invoke it by id. // Unmarked ⇒ unreachable that way by every route and absent from invocableCommands(). // Its own plugin always reaches it. Opening widens nothing else — access, popout and // the caller's grants still decide, so a caller never reaches past what the user could. readonly agentConsent?: 'allow' | 'ask' | 'ask-always' | 'never'; // what running this on an AGENT'S WORD alone amounts to: consent given in advance · // the person asked first · asked every time · not on an agent's word at all. // A STATEMENT, NOT A GATE, exactly as access is presentation and not protection: the // platform asks nobody, remembers no answer and refuses no invocation on this account // — a command declaring ask-always still runs when invoked. Asking, and remembering an // answer, belong to whoever runs commands on an agent's behalf, because only a product // knows how it talks to its users. It travels to where that decision is made: // invocableCommands() carries it, and toolFor() puts it in the tool's metadata. // `never` is a statement too — the platform cannot tell an agent from any other // foreign caller, so what puts a command beyond an agent's reach is leaving // `callable` off, which IS enforced. Omitted ⇒ says nothing, the default. run(context?: MenuContext, args?: CommandArguments): unknown; // context from a menu; args from an invocation by id } // text | number | boolean | choice, plus `list: true` for a list of any of them. The set is CLOSED on purpose: // a caller has to describe your command to something that has never seen it, and a closed set makes a wrong // declaration a compile error instead of a silent no-op. Widening it later is additive; narrowing would not be. type CommandArgument = | { name: string; description: string; required?: boolean; list?: boolean; kind: 'text' | 'number' | 'boolean' } | { name: string; description: string; required?: boolean; list?: boolean; kind: 'choice'; choices: readonly string[] }; // choices: read whenever the command is described or checked for a plugin in the page (a getter offers values added // later); a sandboxed plugin's list is the one it registered, so it registers the command again to change it. type CommandArguments = Readonly>; // What a command says an agent's word alone amounts to (see Command.agentConsent above): consent given in // advance · the person asked first · asked every time · not on an agent's word at all. A statement, not a gate. type AgentConsent = 'allow' | 'ask' | 'ask-always' | 'never'; type CommandOutcome = | { outcome: 'answered'; value?: CommandAnswer } // it ran | { outcome: 'refused'; reason: 'unavailable' | 'invalid-arguments' | 'too-deep'; message: string } // it did not run | { outcome: 'failed'; message: string }; // it ran and broke // `unavailable` is deliberately ONE answer for no-such-command / not callable / session does not qualify / // wrong window / missing grant — telling them apart would let a caller map what is installed by probing ids. interface InvocableCommand { id: string; title: string; description?: string; arguments?: readonly CommandArgument[]; answers?: string; agentConsent?: 'allow' | 'ask' | 'ask-always' | 'never' } // 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. // ViewActions are drawn in the header of WHATEVER HOLDS the surface while it is the one shown: a panel's header, and a content // pane's header BEFORE the pane's own controls (address pane, split pane, strip-less floating controls alike); they follow the // surface when it is moved, so a content surface needs NO toolbar of its own. A sandboxed surface carries no actions. // A RailItem/BarButtonItem/ViewAction may add `menuTrigger?: 'context' | 'primary' | 'both'` (default 'context') to // say which gesture opens it: 'primary'/'both' open it on ACTIVATION (click, Enter, Space) ANCHORED to the // control — the account entry every workbench needs. Activation offers the item's OWN slot alone (the // workbench's entries for that item, hide/move, stay on the right-click); the menu is placed beside the // control and flips to its other side rather than covering it; the host sets aria-haspopup/aria-expanded and // returns focus on dismiss. Such an item is DRAWN ONLY WHILE ITS SLOT OFFERS AN ENTRY to the current session (it appears and // goes as entries come and go) — the owner-and-filler pattern: one plugin puts `menu` + `menuTrigger: 'primary'` on a surface // action, others `registerMenuItem` into the slot, nobody reads the slot. Such an item needs NO command/run and is drawn without one — where it names one // anyway the menu wins (dev warning), and on an item carrying `workspace:` the click is the switch, so the // menu keeps the right-click. Such an item may add `menuHeader?: MenuHeader` — { title, detail?, icon?, // initials? }, title/detail Transloco keys or literals — and the host draws it ABOVE the first entry: not an // entry itself (no menuitem role, the arrow keys pass over it like a separator, nothing activates it), and // the menu is labelled from it so the name is announced exactly once. Only for a menu opened by activation; // a menu at the pointer carries none, because what it acts on is under the pointer. // With `command` (a registered command id) the heading LEADS to what it names (an account menu's heading opening // the profile): it becomes the FIRST entry (menuitem role, reached first by the arrow keys, click/Enter/Space // runs the command with the menu context and closes the menu), announced by the command's title while the menu // keeps the name. An unregistered command leaves it a plain heading; a menu whose only entry is it still opens. // A RailItem, a BarButtonItem and a MenuHeader may carry `image?: string` — anything an takes, an address you serve // or a data URL — drawn round in place of icon/initials. The ladder is PICTURE, then initials, then icon, // and the HOST falls back: a picture that is absent or fails to load leaves the control exactly as it // would look without one, so a product never has to handle the ordinary case of there being no photo. The // picture is decoration (the entry keeps being announced by its title, the menu by its heading); the // workbench does not fetch it, so another origin is yours to allow in your own content policy. // 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 MenuHeader { title: string; detail?: string; icon?: string; initials?: string; image?: string; command?: string } // a menu's heading; see the menuTrigger note above 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 readonly iframe?: string; // …or a sandboxed document (same-origin URL) instead of a component readonly retain?: 'always' | 'never'; // this surface's own retention, over provideShell({ retention }) readonly saveOn?: 'hide'; // ask the DirtySurface to save when it is hidden rather than only when closed readonly closable?: boolean; // may the user close its tab (carried through from Surface.closable) readonly padded?: boolean; // inset from the pane edges, over the product's PaddingDefault } interface ViewAction { id: string; icon: string; title: string; order?: number; menu?: string; menuTrigger?: 'context' | 'primary' | 'both'; menuHeader?: MenuHeader; command?: string; access?: AccessRequirement; pressed?: boolean; run?(): void | Promise; } // `pressed` makes the action a TOGGLE: true = on, false = off, drawn as the control's pressed look AND `aria-pressed` // (one attribute drives both, so seen and announced cannot drift). Omit for a plain button (no aria-pressed at all). // Nothing on an action is live; to move a toggle, call `ctx.updateSurfaceAction(surfaceId, { ...action, pressed })`. // 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 @loomweaver/plugin-sdk // SURFACE_HOLD: a docked in-page instance's switch for being SHOWN ELSEWHERE by the product (e.g. a floating window). // const hold = inject(SURFACE_HOLD); call hold.hold() BEFORE moving your element out and hold.release() on EVERY way // back, the window closing by itself included. While held, collapsing the panel or switching the view or workspace // neither removes, hides, moves back nor destroys-for-being-hidden the instance; moving the view to another sidebar or // pane keeps that same instance, and release places it where the view now is. On release the workbench re-applies // what it deferred (back in its place if visible, else hidden or released as usual), so you need not move it back. // Closing (close the view, plugin off) still ends it wherever its nodes are, and ends the hold: the view opened // again starts unheld and is placed as usual until it calls hold() itself. Resetting the workspace ends the hold // first, then treats the instance as never held. held() turning false without your release means close your // window. The workbench // detects nothing and offers no window, gesture or style mirroring. Routable and sandboxed surfaces have none, and // injecting it anywhere else throws. interface SurfaceHold { readonly held: () => boolean; hold(): void; release(): void; } const SURFACE_HOLD: InjectionToken; // from @loomweaver/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 (400 ms of quiet, never longer than 2 s, flushed when the page is left — VIEW_STATE alike), 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: LwFrame.state.watch(key) + .onChange(fn), feed the push in // with LwFrame.state.apply(...) from your methods and call LwFrame.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; onChange(listener: (value: T | undefined, loaded: boolean) => void): void; } // onChange: called whenever the value arrives or changes, and at once where it is already there; needs NO injection // context, so it is how activate() waits for the store every time the plugin is switched on. dispose() ends it. // 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. 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 address pane dissolves the split and the neighbour takes over). Exactly ONE // pane is the address pane and the focus is switchable; every pane draws its own surface in place (the router holds // only the address, its guards and history), so moving the address moves no surface and rebuilds none. 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 and the pane renders it // 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; badge?: TabBadge; // a mark drawn beside the title on EVERY tab that shows this surface (content area, // a container's inner panes, a view tab in a pane); change it live with // ctx.updateSurfaceBadge. In an icons-only strip (sidebar switcher, panel panes) its // text is in the tooltip and the accessible name only actions?: readonly ViewAction[]; access?: AccessRequirement; instanceable?: boolean; // named saved instances (2b) routable?: SurfaceRoutable; // URL-addressable: can hold the address 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. // EVERY routable surface (kept or not) is drawn by its pane, the address pane included, // and keyed to that pane, so handing the address role between split panes leaves each // pane's instance in place — a split shows two independent instances, deliberately. // Its ActivatedRoute is the host's and LIVE: params (param change = different tab = // different instance), data.sub + firstChild (sub-address/rest; child params = the // declared sub-route's values), query + fragment while its pane carries the address, // data.urlDriven = does it (changes when the address moves). NO resolvers; a nested // is inert — read the sub-segment from the route. // 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; // absent = the product decides: the host insets NOTHING unless the // distribution asked for it with provideShell({ padding: 'inset' }). // true = inset this surface anyway; false = this surface owns its edges // (viewer, canvas, map, edge-to-edge table) in a product that insets. // Travels with the surface: address pane, split, sidebar and pop-out alike. // Only WHETHER there is an inset is switchable; how WIDE 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. // A DIALOG body opened with ui.open implements the same interface: while dirty, every dismissal its dismiss option // allows (backdrop, Escape, close control) and a declared button without a value run surfaceBeforeClose, then ask // Save · Discard · Cancel. A button with a value and DialogRef.close never ask. A body's OWN cancel control calls // DialogRef.requestClose(): Promise instead, which makes the close-control close (veto, then the question // while dirty, Save only with surfaceSave) whatever dismiss says; true once closed, false when cancelled or vetoed. 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