Skip to content

The content area: routes and tabs

This is a guide, not the contract. What the platform guarantees is specified under openspec/specs/. For this page: content-tabs · routing · panes. 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.

The content area is where a routable surface opens as a tab. This page declares one, opens and refines tabs from code, and explains preview, pinned and unclosable tabs, the panes the user splits them into, and the bridge that lets a component call ctx.

Routable surfaces

The centre (a content region) is URL-addressed, not a panel: a surface reaches it by declaring routable, which makes it a shareable deep-link with browser back/forward. The distribution must set up the router with provideShellRouter().

It is the Angular router underneath, so routerLink and ActivatedRoute behave as they do anywhere, with one difference: the workbench, not a <router-outlet>, draws your surface. Routing is the router-shaped view of this page: what carries over unchanged, and what that difference means.

// a routable surface opens as a tab when visited — nothing else to declare
ctx.registerSurface({ id: 'reports', title: 'reports.title', component: ReportsView,
routable: { path: 'reports' } });
ctx.registerSurface({ id: 'doc', title: 'doc.title', component: DocView,
routable: { path: 'doc/:id' } });
// chromeless: a full-area screen that never becomes a tab (what a login or onboarding page needs)
ctx.registerSurface({ id: 'login', title: 'login.title', component: LoginView,
routable: { path: 'login', chromeless: true } });

The host draws a tab strip per pane, and every pane is a tab group the user can split and move. The strip shows everything that pane holds, and the rule is one sentence: a pane shows a strip when it holds tabs; a chromeless surface shows none. Visiting any routable surface, by click, deep-link or browser history, opens (or refines) its tab; a chromeless surface owns the whole content area while active and is excluded from splits, drags and the new-tab picker. A permanent arrangement of tabs is a workspace, declared by the distribution with provideWorkspaces, where a declared tab can be unclosable. The surface itself declares no arrangement.

A surface can also refuse closing on its own with closable: false. That is the overview screen a product keeps open while its other tabs come and go. It removes the ×, the Delete key and the menu’s close entries; moving, splitting and dragging the tab still work. Closing the pane it stands in does not take it along either: the tab joins the pane that takes the space. It applies to every tab of that surface, so it fits a parameterless route like dashboard and is almost always wrong for doc/:id, where it would make no document closable at all.

ctx.registerSurface({ id: 'dashboard', title: 'dashboard.title', component: DashboardView,
routable: { path: 'dashboard' }, closable: false });

A route component reads its params the normal Angular way (inject(ActivatedRoute)), so doc/:id resolves id itself. Don’t draw your own top-level tab bar. Open into the host strip; a nested sub-tab bar inside one document’s body (Edit | Preview) is fine, it’s a level down.

A surface’s own actions

A surface may carry actions, the buttons that belong to it alone: “new”, “sort”, “export”. The host draws them in the header of the pane that shows the surface, before the pane’s own controls, and only while that surface is the active tab. They follow the surface when the user splits the pane or drags the tab elsewhere, so you never draw a toolbar of your own for them.

ctx.registerSurface({ id: 'reports', title: 'reports.title', component: ReportsView,
routable: { path: 'reports' },
actions: [
{ id: 'reports.export', icon: 'download', title: 'reports.export', command: 'reports.export' },
] });

They are the same actions a sidebar surface carries, with the same rules: access hides or disables one, pressed makes it a toggle, and ctx.updateSurfaceAction replaces one while the surface is mounted. Sidebar surfaces has those in full. An action can also open a menu, which is how one plugin offers a place that others fill: see Menus. A sandboxed surface carries no actions.

Reaching the pane edges

The host insets nothing. A surface fills the pane it is mounted in, and what stands between its content and the pane edge is whatever the surface itself draws.

Most products want air around their prose, forms and lists, and say so once when they compose:

provideShell({ padding: 'inset' })

The option’s type is PaddingDefault, 'none' or 'inset', and 'none' is what an absent option means.

A surface that differs from its product declares padded, in either direction. false where the product insets everything and this surface is the content, such as a document viewer, a canvas, a map or an edge-to-edge table:

ctx.registerSurface({ id: 'viewer', title: 'viewer.title', component: ViewerView,
routable: { path: 'doc/:id' }, padded: false });

And true where the product insets nothing but this one surface reads better with air:

ctx.registerSurface({ id: 'settings', title: 'settings.title', component: SettingsView,
routable: { path: 'settings' }, padded: true });

It travels with the surface, so it holds wherever the user puts it: the address pane, a split, a sidebar, a pop-out window. Only whether there is an inset is yours; how wide it is stays a styling question, so a product that wants a different amount everywhere writes plain unlayered CSS.

Opening tabs from code

Open a tab yourself with:

ctx.openContentTab({
path: `doc/${doc.id}`,
title: doc.name, // a document name is a literal, not a Transloco key…
titleIsLiteral: true, // …so mark it literal: the host shows it verbatim, no i18n lookup, no warning
onClose: () => this.forget(doc.id), // runs once when THIS tab is closed — free per-tab state
});
ctx.closeContentTab(`doc/${doc.id}`); // the host activates a neighbour

registerSurface needs the contributions capability. navigateContent, openContentTab, keepContentTab, pinContentTab, unpinContentTab and closeContentTab need navigation.

Your plugin does not decide, and does not need to know, which workspace the tab lands in. Where the product has given that address to a workspace of its own, the host activates that workspace first and opens the tab there. The call returns straight away either way; the tab appears once the switch has happened.

Docked (non-routable) surfaces have their own opener: ctx.revealSurface(id) activates the surface’s tab wherever the user has placed it, its sidebar (expanding a collapsed one) or a content pane. So a palette command like “Focus Library” works no matter where the view lives. It is a no-op for an unknown id, and container-only children (docks: []) stay inside their container. Routable surfaces are reached with navigateContent instead. Requires navigation.

A dynamic tab title is usually a runtime literal (a document name, an entity label). Set titleIsLiteral: true so the host renders it verbatim instead of treating it as a translation key. Otherwise the value is looked up and a benign “missing translation” warning is logged in dev. Omit it (default false) when the title genuinely is a Transloco key. Pass onClose to run teardown exactly once when that tab is closed (the host’s ×, or closeContentTab). That is the place to free per-tab state, cancel in-flight work or persist a draft. (In-process weavers only; a sandboxed plugin’s onClose does not cross the RPC boundary.)

Preview tabs

For file-browsing UX, open with preview: true: the host keeps one reused, italic preview tab in the main area: the next preview open of a different path replaces it in place, so browsing many items doesn’t pile up tabs. The user can drag the preview into another pane, beside your list for example; it stays a preview there, the next preview lands in it, and that pane takes the address. Without a preview, the next one opens in the pane carrying the address. Promote it to a permanent tab explicitly: call ctx.keepContentTab(path) (e.g. on your list’s double-click or when the content is edited). The host’s own double-click cycle on the tab is the distribution’s to switch off, so do not build your flow on it. Re-opening an already-open tab just refines it (title, sub-route, badge) and keeps its preview state, so a view can safely call openContentTab on mount to set the real title without accidentally promoting itself:

// src/lib/views/library-view.ts — inside the component
onSingleClick(doc) { ctx.openContentTab({ path: `doc/${doc.id}`, title: doc.name, titleIsLiteral: true, preview: true }); }
onDoubleClick(doc) { ctx.keepContentTab(`doc/${doc.id}`); }

A distribution can turn the whole behaviour off (provideShellFeatures({ content: { preview: false } })), in which case preview is ignored and every open is permanent. Treat preview as a hint, not a guarantee.

Marking a tab: badge

A tab can carry a badge beside its title: a short word such as “Beta” or “Developer”, an icon, or both, in one of the badge tones (neutral, brand, success, danger). The shape is the exported TabBadge type, { text?, textIsLiteral?, icon?, tone? }. Declare it on the surface with badge, and every tab that shows the surface carries it, in the content area, in a container’s inner panes and as a view tab in a pane:

ctx.registerSurface({
id: 'reports.forecast',
title: 'reports.forecast.title',
badge: { text: 'reports.badge.beta', tone: 'brand' },
routable: { path: 'reports/forecast' },
component: ForecastView,
});

Change it while the surface is mounted with ctx.updateSurfaceBadge(id, badge), and take it away with null. The surface is not rebuilt, as with ctx.retitleSurface, and only surfaces your plugin registered can be changed. Both need the contributions capability. A content tab can also carry a badge of its own, passed to ctx.openContentTab({ ..., badge }): it wins over the surface’s, is refined by opening the same path again, survives a restart with the tab, and is taken away with badge: null.

Opening again brings the tab forward, which is wrong when the content changes behind the tab the person is looking at. To change an open tab’s title, icon or badge where it stands, call ctx.updateContentTab(path, label): it works in whichever pane of the main area the tab is, and the focus and the address stay as they are:

ctx.updateContentTab(`quotes/${quote.id}`, {
badge: { text: 'quotes.status.sent', tone: 'brand' },
});

What the ContentTabLabel leaves out stays, and badge: null takes the tab’s own badge away. The call never opens a tab, so it is safe to make for content that may not be open, and it leaves a tab alone whose content another plugin registered. It needs contributions.

The badge is not a control of its own, so the tab stays one keyboard stop, and its text joins the tab’s accessible name and its tooltip after the title. Where a tab runs out of room, the title comes first: the badge narrows to a small mark in its tone before the title is shortened, and the tooltip still reads it out in full. In a strip that shows icons only, such as a sidebar switcher, the text is in the tooltip and the accessible name, so a badge made of an icon alone does not show there. The text is a translation key unless textIsLiteral is set. The list of tabs that do not fit, Quick-Open and the minimized strip name a tab without its badge.

Pinned tabs

The permanence ladder has a top rung: ctx.pinContentTab(path) / ctx.unpinContentTab(path) pin a tab to the front of its strip and guard it against accidental close (its close control becomes an unpin control). Pinning also promotes a preview tab. It’s a post-hoc action (not an open-time flag) and survives a re-open. The host also has a double-click cycle on the tab (preview → keep → pin → unpin), on by default and switchable off by the distribution.

Navigating to a dynamic route without opening it (a shared deep-link, browser history, navigateContent) auto-opens its tab too, so shared links land with a proper tab, not just bare content. If the tab is already open in another pane, whether a split or a pane the user’s workspace declares, nothing is duplicated: that pane takes the address and activates the tab it already holds. Give the route a default title/icon for that auto-opened tab; you can still refine it via openContentTab (e.g. the real document name):

// src/lib/plugin/notes.plugin.ts — in activate(ctx)
ctx.registerSurface({ id: 'doc', title: 'doc.title', icon: 'document', component: DocView,
routable: { path: 'doc/:id', title: 'Document', titleIsLiteral: true } });

Calling ctx from a component

ctx is handed to activate(ctx), but you usually open a document from a click inside a component (a tree/list) that holds no ctx. Bridge it with a tiny service the plugin fills at activation and the component imports:

import { PluginContext } from '@loomweaver/plugin-sdk';
class NotesNav {
private ctx?: Pick<PluginContext, 'openContentTab'>;
bind(ctx: Pick<PluginContext, 'openContentTab'>): void { this.ctx = ctx; }
unbind(): void { this.ctx = undefined; }
open(doc: { id: string; name: string }): void {
this.ctx?.openContentTab({ path: `doc/${doc.id}`, title: doc.name, titleIsLiteral: true });
}
}
export const notesNav = new NotesNav();
// in activate(ctx): notesNav.bind(ctx);
// in deactivate(): notesNav.unbind();
// the list component imports { notesNav } and calls notesNav.open(doc) in its click handler

Use a module-level facade (a plain exported instance), not an Angular @Injectable filled via inject() inside activate(). Activation is not guaranteed to run in Angular’s injection context. It re-runs, for instance, when the user re-enables your plugin at runtime. An inject() there can therefore throw.

A trusted in-process component may also inject Angular’s Router directly, but the bridge keeps the weaver on the public ctx surface, the same path a sandboxed plugin gets later.

Panes and tab groups

The tabs you open aren’t confined to one strip. Every pane is a tab group with its own strip, and the user can rearrange them without any extra API from you:

  • Drag a tab to a pane edge to split the area, taking that tab into a new group; drag it onto another group’s strip to move it there. Dropping the last tab out of a group collapses it. A tab’s context menu offers Split right / Split down as the keyboard/touch equivalent.
  • A pane holding no tabs, the content area before anything is open, takes the whole drop instead of offering edges, so a dragged tab fills it rather than splitting it against an empty half. The highlight while dragging spans the whole pane, which is exactly what the drop will do.
  • Dragging moves a tab (never copies): the source group loses it. This holds for every tab, including parameterised (doc/:id) and sandboxed iframe routes: exactly one pane is the address pane (it drives the deep link and back/forward), and moving a routed tab hands that role to its new pane.
  • The sidebars are the same tab groups, just shown as icon tabs. A view can be dragged into the centre (it becomes a titled tab beside your documents) and a document into a sidebar, and back.

None of this changes your contract: you keep contributing routes and views the same way; the host provides the pane/tab behaviour on top. Reload restores the whole arrangement.

Where next