Samples
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:surfaces·routing·commands·ui-primitives·access-gating·surface-retention·persistence-ports·i18n. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
Complete, copyable recipes: whole files with the path they belong at, what to wire, and what you get
on screen. Every one of them compiles against the published @loomweaver/plugin-sdk.
They all assume a distribution set up by the quickstart or by hand, and a weaver of your own. If you have neither yet:
npx @loomweaver/cli weaver --id notes --out src/notesEverything below goes inside activate(ctx) of your plugin unless the path says otherwise. The
capabilities each recipe needs are listed with it. Declare them in your manifest and have the
distribution grant them, or the call throws CapabilityError.
What the generator already writes
Six of these twelve recipes are what the generator writes, and one more, recipe 10, is half written for you. That is worth knowing before you copy anything: a generated weaver compiles, passes its own lint, and comes out the same every time, so your attention goes to the part that is actually yours.
| Recipe | The invocation that writes it |
|---|---|
| 1 · A sidebar view | weaver --id notes --instanceable — the docked surface and its rail item; the persisted state is yours |
| 2 · A content surface with its own URL | weaver --id notes — the default shape, at /notes; the :id is what you add |
| 3 · One behaviour, many triggers | weaver --id notes --command --shortcut 'mod+shift+n' --menu content/tab/context --bar-item |
| 4 · A settings section | weaver --id notes --settings |
| 5 · Gating a surface behind a login | weaver --id notes --access authenticated |
| 10 · Letting an AG-UI agent drive your product | weaver --id notes --agent — the connection, a panel and a stand-in that works before you have a transport; what you replace is one file |
| 12 · A session without a backend | auth-source --name dev — the session source, the plugin with sign-in, switch and sign-out, the bundles, composed into your app; --bare for the source alone |
The options compose, so that is one call:
npx @loomweaver/cli weaver --id notes --out src/notes \ --command --shortcut 'mod+shift+n' --menu content/tab/context --bar-item \ --settings --access authenticated --agentRecipes 6 to 9 and 11 have no generator behind them, and that is the honest split: they are the
ones where you decide something. Recipe 11 stays a recipe on purpose: a navigation tree is four
files whose content, the groups, destinations and labels, is yours, and a generator would only
write placeholders you replace. Recipe 10 is the half-way case: --agent writes the wiring and
something that runs on the first serve, and the transport it talks to stays yours.
It does not matter who invokes it. One description of each generator serves every route into it,
so @loomweaver/cli on a command line, @loomweaver/devkit as an Nx generator and @loomweaver/mcp
over MCP produce the same files from the same options. An assistant with the MCP server registered
scaffolds a weaver with a tool instead of writing plugin code from memory. Ask it for “a weaver
called notes with a settings section and a command on mod+shift+n” and what lands in your diff is
the output above, not an invention you have to review line by line. See
scaffolding for the full option table and the three adapters, and
Building with an AI assistant for the path with an assistant,
recorded as it ran.
The file layout these recipes assume
src/notes/src/ index.ts export { notesPlugin } from './lib/plugin/notes.plugin'; lib/plugin/notes.plugin.ts the manifest + activate(ctx) — all the recipes below lib/views/*.ts|.html your components lib/i18n/en.json, de.json your translation bundle, served under /i18n/notes/1 · A sidebar view that remembers its state
A surface docked into a panel region, whose sort order survives a reload: the host stores the blob, the view never touches storage.
Generated by weaver --id notes --instanceable: the docked surface and the rail item that
reveals it. What follows is the part it leaves to you, the state that has to survive being
hidden.
Capabilities: contributions
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';import { VIEW_STATE, type ViewState } from '@loomweaver/plugin-sdk';
interface ListState { readonly sort: 'natural' | 'alpha';}
@Component({ selector: 'app-notes-list', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <button type="button" class="lw-btn lw-btn--default" (click)="toggle()"> Sort: {{ sort() }} </button> <ul class="mt-3 space-y-1 text-sm text-content"> @for (note of ordered(); track note) { <li>{{ note }}</li> } </ul> `,})export class NotesList { private readonly state = inject(VIEW_STATE) as ViewState<ListState>; private readonly notes = ['Roadmap', 'Anna', 'Budget'];
// `undefined` for a fresh instance — apply your own default. protected readonly sort = computed(() => this.state.value()?.sort ?? 'natural');
protected readonly ordered = computed(() => this.sort() === 'alpha' ? [...this.notes].sort() : this.notes, );
protected toggle(): void { this.state.set({ sort: this.sort() === 'alpha' ? 'natural' : 'alpha' }); }}// in activate(ctx)ctx.registerSurface({ id: 'notes.list', title: 'notes.list.title', icon: 'notes', component: NotesList, docks: ['left-panel'], // a region id from your layout — must be a `panel`});You get: a view in the left sidebar, tabbed automatically if other views dock there. Toggle the sort and reload, and it comes back. Writes are debounced by the host. Since a hidden surface is destroyed as soon as it is clean, this is also how the sort survives a tab switch or a collapsed sidebar; recipe 7 is the same idea scaled to everything a real view holds.
docks[0]must name a panel region. Docking a non-routable surface into acontent,barorrailregion is a silent no-op (dev mode warns).
2 · A content surface with its own URL
The main area is URL-addressed, so a surface that lives there is deep-linkable, and visiting it opens a tab in the strip the host draws for the pane.
Generated by weaver --id notes: this is the default shape, emitted at /notes with the same
rail item beside it. The :id below is the one thing you add.
Capabilities: contributions, navigation
// in activate(ctx)ctx.registerSurface({ id: 'notes.detail', title: 'notes.detail.title', icon: 'notes', component: NoteDetail, routable: { path: 'note/:id' }, // add chromeless: true for a full-area screen with no strip});
ctx.registerRailItem({ id: 'notes.rail', rail: 'primary', // a `rail` region id from your layout icon: 'notes', title: 'notes.title', run: () => ctx.openContentTab({ path: 'note/roadmap', title: 'Roadmap', titleIsLiteral: true }),});Read the parameter the ordinary Angular way:
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';import { ActivatedRoute } from '@angular/router';import { toSignal } from '@angular/core/rxjs-interop';import { map } from 'rxjs';
@Component({ selector: 'app-note-detail', changeDetection: ChangeDetectionStrategy.OnPush, template: `<h1 class="text-lg font-medium text-content">Note {{ id() }}</h1>`,})export class NoteDetail { private readonly route = inject(ActivatedRoute); protected readonly id = toSignal( this.route.paramMap.pipe(map((p) => p.get('id') ?? '')), { initialValue: '' }, );}You get: a rail icon that opens /note/roadmap as a tab titled Roadmap.
titleIsLiteral: true says the title is text, not a translation key. The URL is shareable,
back/forward work, and the user can split the tab into its own pane. Add chromeless: true and
everything still works except the tab: the surface then fills the area on its own with no strip,
which is what you want for a login or onboarding screen.
3 · One behaviour, many triggers
Register the behaviour once as a command; a shortcut, a status-bar button and a menu entry all
reference it by id. Never duplicate the run.
Generated by weaver --id notes --command --shortcut 'mod+shift+n' --menu content/tab/context --bar-item: all three registrations, wired to each other, with a toast standing in for the action
until you write it.
Capabilities: contributions, ui
// in activate(ctx)ctx.registerCommand({ id: 'notes.add', title: 'notes.add', icon: 'notes', shortcut: 'mod+shift+n', // `mod` = ⌘ on macOS, Ctrl elsewhere — never write cmd/ctrl run: () => ctx.ui.toast({ message: 'notes.added', kind: 'success', timeoutMs: 4000 }),});
ctx.registerBarItem({ id: 'notes.bar.add', // an item id must NOT equal the command id bar: 'status-bar', slot: 'end', // 'start' | 'center' | 'end' command: 'notes.add', icon: 'notes', tooltip: 'notes.add', showShortcut: true, // render the chord next to the label});
ctx.registerMenuItem({ menu: 'content/tab/context', command: 'notes.add',});You get: the action in the command palette (mod+k), on its shortcut, as a status-bar button
showing ⌘⇧N, and in the tab context menu. Give the toast a timeoutMs unless you want it sticky.
4 · A settings section
Rows the built-in settings dialog renders. Your plugin owns the value: the platform never persists foreign data behind your back.
Generated by weaver --id notes --settings: the same section, a toggle and a text field over
two signals. The description and the placeholder below are yours to add.
Capabilities: contributions
// in activate(ctx) — `signal` imported from '@angular/core'const compact = signal(false);const author = signal('');
ctx.registerSettingsSection({ id: 'notes.settings', title: 'notes.settings.title', rows: [ { id: 'notes.compact', label: 'notes.settings.compact', description: 'notes.settings.compactDesc', control: { kind: 'toggle', value: () => compact(), set: (v) => compact.set(v) }, }, { id: 'notes.author', label: 'notes.settings.author', control: { kind: 'text', value: () => author(), set: (v) => author.set(v), placeholder: 'notes.settings.authorPlaceholder', }, }, ],});You get: a Notes entry in the settings dialog’s left nav with a switch and a text field, saving
as you type. To persist across reloads, write the values through your own storage in set. See
backend integration for doing it through the distribution’s settings store.
5 · Gating a surface behind a login
Declare the requirement; the host enforces it on every surface it draws, and re-evaluates when the session changes.
Generated by weaver --id notes --access authenticated: the requirement lands on the surface
and on the rail item together, which is the pairing you want. A role requirement and mode you
write yourself.
Capabilities: contributions · navigation (the rail item calls navigateContent) · plus
session only if you also want to read the session
// in activate(ctx)ctx.registerSurface({ id: 'notes.admin', title: 'notes.admin.title', component: NotesAdmin, routable: { path: 'notes/admin' }, access: { anyRole: ['admin'] }, // or { authenticated: true }});
ctx.registerRailItem({ id: 'notes.rail.admin', rail: 'primary', icon: 'notes', title: 'notes.admin.title', run: () => ctx.navigateContent('notes/admin'), access: { anyRole: ['admin'], mode: 'hide' }, // 'disable' greys it out instead});Reading it yourself, for UI that adapts rather than disappears:
// in activate(ctx) — needs the `session` capabilityconst canEdit = () => ctx.session.hasRole('admin');You get: the rail item vanishes for everyone else, and the route refuses to activate: a
deep-link renders a neutral “sign-in required” placeholder with the URL intact, or redirects if the
distribution wired provideUnauthorizedRedirect. Client-side gating is presentation: enforce it in
your backend too. Full matrix in access gating.
6 · Asking before doing something destructive
Capabilities: contributions (the command) · ui (the dialogs and the toast)
// in activate(ctx)ctx.registerCommand({ id: 'notes.deleteAll', title: 'notes.deleteAll.title', run: async () => { const confirmed = await ctx.ui.confirm({ title: 'notes.deleteAll.title', message: 'notes.deleteAll.body', // rendered as Markdown tone: 'danger', confirmLabel: 'notes.deleteAll.yes', }); if (!confirmed) return;
// withProgress takes the promise itself, not a callback. await ctx.ui.withProgress({ message: 'notes.deleting' }, deleteEverything()); ctx.ui.toast({ message: 'notes.deleted', kind: 'success', timeoutMs: 4000 }); },});You get: a modal in the host’s own vocabulary, with a tinted icon, a danger-red confirm button,
Escape and backdrop dismissal and focus trapped. Then a non-dismissable progress dialog while the
work runs.
The whole ctx.ui surface is listed in the Distribution API.
7 · Everything a view must persist
A hidden surface is destroyed as soon as it is clean, so anything that must outlive a tab switch, a
collapsed sidebar or a reload lives in VIEW_STATE. This is recipe 1 grown up: one state shape
instead of five signals, and one place that writes it.
Capabilities: contributions · applies to: a docked surface; a routable one has no handle
import { ChangeDetectionStrategy, Component, ElementRef, afterNextRender, computed, inject, viewChild,} from '@angular/core';import { VIEW_STATE, type ViewState } from '@loomweaver/plugin-sdk';
interface WorkspaceState { readonly query: string; readonly tab: 'open' | 'archived'; readonly expanded: readonly string[]; readonly scrollTop: number;}
const FRESH: WorkspaceState = { query: '', tab: 'open', expanded: [], scrollTop: 0 };
@Component({ selector: 'app-notes-workspace', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <input #q class="lw-field w-full" placeholder="Filter" [value]="query()" (input)="patch({ query: q.value })" />
<div class="lw-segmented mt-3"> @for (name of tabs; track name) { <button type="button" class="lw-segmented-item w-auto px-3" [attr.aria-pressed]="tab() === name" (click)="patch({ tab: name })" > {{ name }} </button> } </div>
<ul #list class="mt-3 h-64 overflow-auto" (scroll)="patch({ scrollTop: list.scrollTop })"> @for (note of visible(); track note.title) { <li> <button type="button" class="lw-btn lw-btn--ghost" (click)="toggle(note.title)"> {{ note.title }} </button> @if (isExpanded(note.title)) { <p class="px-3 text-sm text-content-faint">{{ note.body }}</p> } </li> } </ul> `,})export class NotesWorkspace { private readonly viewState = inject(VIEW_STATE) as ViewState<WorkspaceState>; private readonly list = viewChild.required<ElementRef<HTMLElement>>('list');
private readonly notes = [ { title: 'Roadmap', body: 'Ship the thing.', archived: false }, { title: 'Anna', body: 'Call back.', archived: false }, { title: 'Budget', body: 'Signed off.', archived: true }, ];
protected readonly tabs = ['open', 'archived'] as const;
// One read of the blob; `undefined` for a fresh instance → your own default. private readonly state = computed(() => this.viewState.value() ?? FRESH);
// Narrow computeds: scrolling changes `state()`, but `query()` keeps its value, // so `visible()` is not recomputed on every scroll event. protected readonly query = computed(() => this.state().query); protected readonly tab = computed(() => this.state().tab); protected readonly expanded = computed(() => this.state().expanded);
protected readonly visible = computed(() => { const needle = this.query().toLowerCase(); const archived = this.tab() === 'archived'; return this.notes.filter( (note) => note.archived === archived && note.title.toLowerCase().includes(needle), ); });
constructor() { afterNextRender(() => { this.list().nativeElement.scrollTop = this.state().scrollTop; }); }
protected isExpanded(title: string): boolean { return this.expanded().includes(title); }
protected toggle(title: string): void { const open = this.expanded(); this.patch({ expanded: open.includes(title) ? open.filter((t) => t !== title) : [...open, title], }); }
// The one writer: `set` replaces the whole blob, so spread what is already there. protected patch(part: Partial<WorkspaceState>): void { this.viewState.set({ ...this.state(), ...part }); }}// in activate(ctx)ctx.registerSurface({ id: 'notes.workspace', title: 'notes.workspace.title', icon: 'notes', component: NotesWorkspace, docks: ['left-panel'],});You get: a view you can filter, switch, expand and scroll, then move to another pane, collapse the sidebar, reload the browser, and find it exactly as you left it. The host writes the blob for you, debounced. The same view written with local signals loses the filter and the expanded rows the moment the surface is hidden, and it always lost them on reload:
export class NotesWorkspaceLocal { protected readonly query = signal(''); protected readonly expanded = signal<readonly string[]>([]);}The rules behind the recipe (set replaces rather than merges, a set per keystroke is fine, a
routable surface has no handle and uses its address instead) are
View state that survives.
8 · An editor with unsaved changes
A hidden surface is destroyed as soon as it is clean, and dirty is what makes it not clean.
Implement DirtySurface on your component and the host takes over the unsaved-work protocol. You
write two members; everything else is the host’s job.
Capabilities: contributions
// in activate(ctx)ctx.registerSurface({ id: 'notes.editor', title: 'notes.editor.title', icon: 'notes', component: NoteEditor, routable: { path: 'note-editor' },});import { ChangeDetectionStrategy, Component, computed, signal,} from '@angular/core';import { DirtySurface } from '@loomweaver/plugin-sdk';
@Component({ selector: 'app-note-editor', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div class="flex h-full flex-col gap-3 p-6"> <textarea class="lw-field min-h-40 flex-1" [value]="draft()" (input)="onInput($event)" ></textarea> <button class="lw-btn lw-btn--primary self-start" [disabled]="!dirty()" (click)="save()" > Save </button> </div> `,})export class NoteEditor implements DirtySurface { // What the backend has; a real editor loads it (see recipe 7 for view state). private readonly saved = signal(''); protected readonly draft = signal(this.saved()); protected readonly dirty = computed(() => this.draft() !== this.saved());
// The host reads this reactively: while it returns true the instance is // never destroyed on hide, and closing runs the Save · Discard · Cancel ask. surfaceDirty(): boolean { return this.dirty(); }
// Optional — its presence puts the Save button into the host's close dialog. async surfaceSave(): Promise<void> { // await this.api.save(this.draft()); this.saved.set(this.draft()); }
protected save(): void { void this.surfaceSave(); }
protected onInput(event: Event): void { this.draft.set((event.target as HTMLTextAreaElement).value); }}You get: while surfaceDirty() is true the instance survives every hiding gesture without a
question; closing runs the host’s Save · Discard · Cancel dialog, where Save appears only because
surfaceSave exists; closing the browser window triggers the native beforeunload prompt. Declare
saveOn: 'hide' on the registration and the question becomes an auto-save. The optional
surfaceBeforeClose replaces the dialog with your own flow:
// Optional veto — replace the standard ask with your own close flow. async surfaceBeforeClose(): Promise<boolean> { if (!this.surfaceDirty()) { return true; } const choice = await this.askOwnDialog(); // 'save' | 'discard' | 'stay' if (choice === 'stay') { return false; // cancels the close } if (choice === 'save') { await this.surfaceSave(); } else { this.draft.set(this.saved()); // discard: back to the saved body } return true; // now clean — the host's standard ask will not appear }What each of those does in detail, what a failed save does, and the sandboxed variant that pushes
setDirty over the surface channel are Unsaved changes.
9 · Sync your own state across browser windows
Everything the shell persists already follows across same-origin windows live, with nothing to wire. This recipe is for your own state: a distribution key, a product session, a backend push.
First decide where the state lives, because that decides how it syncs:
| Your state is… | Persist it… | Sync story |
|---|---|---|
| a deliberate user decision (a preference) | through SETTINGS_STORE | broadcasts by itself — register a reaction |
| usage state (drafts-of-layout, MRU, traces) | through WORKING_STATE_STORE | broadcasts by itself — register a reaction |
| outside both ports (a product session) | wherever it lives today | announce on write + register('external', …) |
Capabilities: none. This is distribution wiring (app.config.ts), not a plugin API.
// src/app/app.config.ts — in the providers arrayimport { inject, provideEnvironmentInitializer } from '@angular/core';import { SETTINGS_STORE, StateSyncService } from '@loomweaver/shell';
provideEnvironmentInitializer(() => { const sync = inject(StateSyncService);
// ① State on a port syncs itself — you only register the reaction. // 'settings' names where the fresh value is read back from. const store = inject(SETTINGS_STORE); sync.register('settings', 'acme.density', (raw) => { applyDensity(raw ?? 'comfortable'); // apply WITHOUT writing back }); // Writing through the port broadcasts on its own: // void store.set('acme.density', 'compact');
// ② State outside the ports announces itself and re-reads its own storage. sync.register('external', acmeSession.key, () => acmeSession.reload());
// ③ Live cross-device tier: a backend push transport rings the bell for // THIS window; the applier reads the fresh value back through the store. const events = new EventSource('/api/state/events'); events.onmessage = (event) => sync.notifyRemoteChange(String(event.data));}),The 'external' half needs the owning store to announce its own writes. A broadcast only happens
by itself for writes that go through the ports:
// src/app/session.ts — YOUR session store, persisted outside the shell's portsconst SESSION_KEY = 'acme.session';
export const acmeSession = { key: SESSION_KEY, current(): string | null { return localStorage.getItem(SESSION_KEY); }, signIn(token: string, announce: (key: string) => void): void { localStorage.setItem(SESSION_KEY, token); announce(SESSION_KEY); // tell the other windows — nothing else can know }, reload(): void { // re-read SESSION_KEY and update your AuthSource signal },};You get: a density change made in one window applied in every other, a sign-in in one window picked up by the rest, and a change your backend pushes applied in the window that received it, all by reading the fresh value back through the store. An applier must set state without persisting again, or two windows write back and forth forever. How the sync works, what the shell already syncs and why layout keys stay per window are Windows and sync.
10 · Letting an AG-UI agent drive your product
An agentic backend speaking AG-UI can run the actions your product already has, without you keeping a tool registry or a dispatch switch beside the command registry. The rule underneath it: an agent reaches what the user could have reached, and nothing more.
Capabilities: automation · plus ui if you confirm before a consequential call
The generator writes all of this: weaver --id notes --agent emits the connection below, a docked
panel to watch it through, and a stand-in that produces the protocol’s own events so the whole path
runs before you have a transport. Read on for what it wrote and where your part begins.
npm install @loomweaver/ag-ui @ag-ui/coreimport { commandTools, type CommandTools } from '@loomweaver/ag-ui';import type { PluginContext } from '@loomweaver/plugin-sdk';
export function connectNotes(ctx: PluginContext): CommandTools { return commandTools(ctx, { // What an agent's word is enough for is the command's own statement, declared beside the // command (`agentConsent: 'ask'`) and read off the call here. Keep no list of ids: it drifts // from the commands, and it cannot speak for a command another plugin registered. before: async (call) => { if (call.agentConsent === 'never') { return { decision: 'decline', reason: 'it is not run on an agent’s word.' }; } if (call.agentConsent !== 'ask' && call.agentConsent !== 'ask-always') { return { decision: 'run' }; } const confirmed = await ctx.ui.confirm({ title: 'notes.agent.confirm', message: 'notes.agent.confirmBody', tone: 'warning', }); return confirmed ? { decision: 'run' } : { decision: 'decline', reason: 'the person at the keyboard said no.' }; }, });}Wherever you drive a run, a panel usually, the loop is the same every time. askAgent is yours,
the part that talks to a model, and back is how an answer reaches it:
const tools = connectNotes(ctx);// Ask at the start of every run. Never keep the answer: what you may reach changes.for await (const event of askAgent({ prompt, tools: tools.list() })) { // Hand it every event of the run; send back whatever it answers. const answer = await tools.receive(event); if (answer) { back(answer); }}// A call the run left open is answered too, one at a time, until none is left.for (let left = await tools.flush(); left; left = await tools.flush()) { back(left);}You get: every command that declared itself callable described to the agent as a tool, with its
declared arguments as JSON Schema, already narrowed by everything that would refuse it: the session,
the window, your grant. A call arrives as a start, a stream of argument deltas and an end; receive
assembles it, puts it to the workbench through the same seam a keystroke uses, and answers a tool
message carrying a real outcome. A refusal and a failure both come back in the protocol’s error
field, worded so an agent can tell “you may not” from “it broke”.
The package brings no transport, no user interface and no agent: you open the connection, you draw the conversation, and you decide what the agent is. The full contract, including what the agent never learns and why, is in agent tools.
11 · A navigation tree in the sidebar
A sidebar that lists your destinations, grouped and folded, marking the one the user is at. The workbench draws the tree from what you declare and reports what the user chose; you navigate. The story behind every line is A navigation tree in the sidebar.
Capabilities: contributions · navigation (for activeContent, isShowingUnder and
navigateContent)
export interface Destination { readonly path: string; readonly label: string; readonly icon: string;}
export interface Group { readonly key: string; readonly label: string; readonly destinations: readonly Destination[]; readonly startsShut?: boolean;}
export const NOTES_NAVIGATION = { groups: [ { key: 'notes/writing', label: 'notes.nav.writing', destinations: [ { path: 'notes', label: 'notes.nav.all', icon: 'document' }, { path: 'notes/drafts', label: 'notes.nav.drafts', icon: 'edit' }, ], }, { key: 'notes/archive', label: 'notes.nav.archive', startsShut: true, destinations: [{ path: 'notes/archive', label: 'notes.nav.archived', icon: 'lock' }], }, ] as readonly Group[], loose: [{ path: 'notes/search', label: 'notes.nav.search', icon: 'search' }] as readonly Destination[],};
export function groupShowing( groups: readonly Group[], showingUnder: (path: string) => boolean,): Group | undefined { let deepest: { readonly group: Group; readonly depth: number } | undefined; for (const group of groups) { for (const destination of group.destinations) { if (showingUnder(destination.path) && destination.path.length > (deepest?.depth ?? -1)) { deepest = { group, depth: destination.path.length }; } } } return deepest?.group;}import type { PluginContext } from '@loomweaver/plugin-sdk';
let ctx: PluginContext | undefined;let lastTitle: string | undefined;
export const navigation = { bind(next: PluginContext): void { ctx = next; }, unbind(): void { ctx = undefined; lastTitle = undefined; }, activePath(): string { return ctx?.activeContent()?.path ?? ''; }, showingUnder(path: string): boolean { return ctx?.isShowingUnder(path) ?? false; }, go(path: string): void { ctx?.navigateContent(path); }, retitle(surfaceId: string, title: string): void { if (!ctx || lastTitle === title) { return; } lastTitle = title; ctx.retitleSurface(surfaceId, title); },};import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, computed, effect,} from '@angular/core';import { TranslocoPipe } from '@jsverse/transloco';import { navigation } from '../plugin/navigation';import { NOTES_NAVIGATION, groupShowing } from './notes-navigation';
@Component({ selector: 'app-notes-navigation', changeDetection: ChangeDetectionStrategy.OnPush, schemas: [CUSTOM_ELEMENTS_SCHEMA], imports: [TranslocoPipe], templateUrl: './notes-navigation-view.html',})export class NotesNavigationView { protected readonly groups = computed(() => NOTES_NAVIGATION.groups); protected readonly loose = NOTES_NAVIGATION.loose; protected readonly shown = computed(() => navigation.activePath());
constructor() { effect(() => { const group = groupShowing(this.groups(), (path) => navigation.showingUnder(path)); navigation.retitle('notes.navigation', group?.label ?? 'notes.nav.title'); }); }
protected open(event: Event): void { navigation.go((event as CustomEvent<{ path: string }>).detail.path); }}<lw-nav-tree [attr.current]="shown()" [attr.aria-label]="'notes.nav.title' | transloco" (lw-nav-select)="open($event)"> @for (group of groups(); track group.key) { <lw-nav-group [attr.label]="group.label | transloco" [attr.key]="group.key" [attr.collapsed]="group.startsShut ? '' : null" > @for (destination of group.destinations; track destination.path) { <lw-nav-item [attr.path]="destination.path" [attr.icon]="destination.icon" [attr.label]="destination.label | transloco" ></lw-nav-item> } </lw-nav-group> } @for (destination of loose; track destination.path) { <lw-nav-item [attr.path]="destination.path" [attr.icon]="destination.icon" [attr.label]="destination.label | transloco" ></lw-nav-item> }</lw-nav-tree>// in activate(ctx) — the manifest declares ['contributions', 'navigation']navigation.bind(ctx);ctx.registerSurface({ id: 'notes.navigation', title: 'notes.nav.title', icon: 'notes', component: NotesNavigationView, docks: ['left-panel'], padded: false,});
// in deactivate()navigation.unbind();You get: a tree in the left sidebar with two groups and a loose entry, the archive group shut
until the user opens it. Choosing an entry navigates the content area; opening a draft marks the
drafts entry, because notes/drafts/d-17 lies under notes/drafts; the panel header says
“Writing” or “Archive” for wherever the user is. Fold a group, collapse the sidebar and open it
again, and the fold is as the user left it. Reload, and the declaration wins again.
Every group has a
key, andcollapsedis set as''or removed asnull. Both are rules, not habits: the guide says what goes wrong without them. Hiding destinations whose route nobody registered is on the same page, and it needs the distribution’s registry, which is why it is not in this recipe.
12 · A session without a backend
The platform owns no sign-in. Before your product has an identity provider you still want to see gating work: a rail item that says who is signed in, a menu to sign in, switch the account and sign out, and every gated surface following. This is a stand-in, presentation for a product without a backend yet; the real integration is in Auth integration.
Generated by auth-source --name dev, whole: the session source, the plugin with the three
verbs, an index.ts and both language bundles, composed into your app.config.ts. The flag
--bare writes the session source alone, for a product that has a session of its own to map onto
it. The files are shown here so that you can read what landed, or copy them into a project the
scaffold cannot reach.
Capabilities: contributions. The plugin does not read the session through ctx; it holds the
source itself.
// Provider-neutral AuthSource. LoomWeaver owns no authentication — it only reacts to// a session snapshot. Wire it with: provideAuthSource(() => devAuthSource()).// Replace the dev switcher below by mapping your product's real session onto an AuthSnapshot.import { signal, Signal } from '@angular/core';import { ANONYMOUS, AuthSnapshot } from '@loomweaver/plugin-sdk';
const USER: AuthSnapshot = { authenticated: true, roles: ['user'], claims: {}, displayName: 'Signed-in user',};
const ADMIN: AuthSnapshot = { authenticated: true, roles: ['user', 'admin'], claims: {}, displayName: 'Administrator',};
const state = signal<AuthSnapshot>(ANONYMOUS);
export function devAuthSource(): Signal<AuthSnapshot> { return state.asReadonly();}
export function signInDevUser(): void { state.set(USER);}
export function switchDevAccount(): void { state.set(state().roles.includes('admin') ? USER : ADMIN);}
export function signOutDevUser(): void { state.set(ANONYMOUS);}Three sessions and a verb for each move between them: sign in, switch the account, sign out. The plugin puts the verbs where a user looks:
// The verbs a user needs to operate the stand-in session: sign in, switch the account, sign// out, from a rail item whose menu carries them. This is presentation for a product that has no// backend yet; nothing here protects anything. Delete it once your own session arrives.import type { Disposable, Plugin, PluginContext } from '@loomweaver/plugin-sdk';import { devAuthSource, signInDevUser, signOutDevUser, switchDevAccount,} from './dev-auth-source';
const MENU = 'session.account/menu';const snapshot = devAuthSource();
let drawn: Disposable[] = [];
function initialsOf(name: string): string { return name .split(' ') .map((word) => word[0] ?? '') .join('') .slice(0, 2) .toUpperCase();}
function draw(ctx: PluginContext): void { for (const part of drawn) { part.dispose(); } const current = snapshot(); const name = current.displayName ?? ''; drawn = [ ctx.registerRailItem({ id: 'session.account', rail: 'primary', icon: 'account', title: current.authenticated ? name : 'session.signIn', anchor: 'bottom', order: 20, menu: MENU, menuTrigger: 'primary', ...(current.authenticated ? { initials: initialsOf(name) } : {}), menuHeader: current.authenticated ? { title: name, detail: `session.role.${current.roles.includes('admin') ? 'admin' : 'user'}`, initials: initialsOf(name), } : { title: 'session.signedOut', icon: 'account' }, }), ...(current.authenticated ? [ ctx.registerMenuItem({ id: 'session.menu.switch', menu: MENU, command: 'session.switchAccount', group: 'account', order: 10, }), ctx.registerMenuItem({ id: 'session.menu.signOut', menu: MENU, command: 'session.signOut', group: 'account', order: 20, }), ] : [ ctx.registerMenuItem({ id: 'session.menu.signIn', menu: MENU, command: 'session.signIn', group: 'account', order: 10, }), ]), ];}
export const devSessionPlugin: Plugin = { manifest: { id: 'session', name: 'Account', capabilities: ['contributions'] }, activate(ctx) { const andRedraw = (step: () => void) => () => { step(); draw(ctx); }; ctx.registerCommand({ id: 'session.signIn', title: 'session.signIn', icon: 'account', access: { authenticated: false }, run: andRedraw(signInDevUser), }); ctx.registerCommand({ id: 'session.switchAccount', title: 'session.switchAccount', icon: 'account', access: { authenticated: true }, run: andRedraw(switchDevAccount), }); ctx.registerCommand({ id: 'session.signOut', title: 'session.signOut', icon: 'signOut', access: { authenticated: true }, run: andRedraw(signOutDevUser), }); draw(ctx); }, deactivate() { for (const part of drawn) { part.dispose(); } drawn = []; },};Beside them, src/auth/index.ts exports the three symbols, and src/auth/i18n/en.json and
de.json carry the session.* keys, served under /i18n/session/ by the assets glob the scaffold
adds. What the scaffold composes into app.config.ts, and what you add by hand where it could not:
// src/app/app.config.ts — in the providers arrayimport { heroArrowRightStartOnRectangle, heroUserCircle } from '@ng-icons/heroicons/outline';import { provideAuthSource, provideCapabilityGrants, provideIcons, providePlugins, provideTranslationNamespaces } from '@loomweaver/shell';import { devAuthSource, devSessionPlugin } from '../auth';
provideAuthSource(() => devAuthSource()),provideIcons({ account: heroUserCircle, signOut: heroArrowRightStartOnRectangle }),provideTranslationNamespaces('session'),provideCapabilityGrants({ session: ['contributions'] }),...providePlugins(devSessionPlugin),The scaffold never adds a second provideAuthSource: where your composition root already carries
one, it keeps yours and says so, because in Angular the last provider wins and a stand-in composed
after a real session would silently replace it.
You get: a rail item at the bottom of the rail that reads “Sign in” for a visitor and carries
the user’s initials once signed in. Its menu offers sign-in to a visitor, and switching and signing
out to a user; the three are commands, so the palette offers them too, each only when it applies.
Signing in flips the snapshot, and every surface, rail item and command gated with access follows
without a reload. Switch to the administrator and whatever asks for the admin role appears; sign
out and it goes.
Nothing here protects anything. The snapshot is a signal in the browser, and a gated surface is
hidden, not withheld. When the product gets its identity provider, the generated source is what you
replace, with provideAuthSource mapping the real session, and the plugin’s three verbs become
calls into it or go away in favour of a login page or dialog.
Translations for all of the above
Every title / label / message in the recipes above is a translation key, and this bundle
carries every one of them, so the recipes render words rather than keys when copied whole:
{ "title": "Notes", "add": "New note", "added": "Note created", "list": { "title": "All notes" }, "detail": { "title": "Note" }, "editor": { "title": "Editor" }, "admin": { "title": "Administration" }, "workspace": { "title": "Workspace" }, "nav": { "title": "Notes", "writing": "Writing", "all": "All notes", "drafts": "Drafts", "archive": "Archive", "archived": "Archived notes", "search": "Search" }, "settings": { "title": "Notes", "compact": "Compact rows", "compactDesc": "Less space between notes", "author": "Author", "authorPlaceholder": "Your name" }, "deleteAll": { "title": "Delete all notes", "body": "This removes **every** note. There is no undo.", "yes": "Delete everything" }, "deleting": "Deleting…", "deleted": "All notes deleted", "agent": { "confirm": "Let the assistant do this?", "confirmBody": "It asked to run a command that changes your notes." }}The distribution composes it with provideTranslationNamespaces('notes'), so these live under
notes.* and can never collide with host keys, which is why the recipes above write
'notes.add'. Serve the bundle with an assets glob (input: "src/notes/src/lib/i18n",
output: "i18n/notes"). An unknown key renders as-is, so a literal string works while you are
sketching.
Next: Authoring a weaver is the complete contract behind these recipes, and The plugin system covers trusted, sandboxed and user-installed plugins.