Pages, Layouts & the Engine
@gridmason/core is the framework-agnostic engine that turns a saved layout
document into a rendered grid of widgets. It is split in two halves that you can
import independently:
@gridmason/core/engine— the headless half. It never touches the DOM. It models page types, holds the widget catalog, resolves layouts through the governance rules, decides which widgets a viewer may see, and emits change events. Every function here is pure or observable; none of it renders anything.@gridmason/core/canvas— the DOM half. It is the gridstack.js binding: the<gm-page-canvas>custom element that mounts one widget per placed item, plus the edit-mode controller, the per-widget error boundary, and offscreen virtualization.@gridmason/core/adapters— interfaces only. Your host application implements them (persistence, gates, permissions, telemetry) so the engine stays free of any storage backend, auth model, or network I/O.
The package is ESM-only, requires Node ≥ 22, and depends only on gridstack and
@gridmason/protocol (the contract types). It makes zero network calls and
loads nothing — it mounts custom-element tags you have already registered with
customElements.define.
The page model
What a page is
Every route in a Gridmason host renders a page canvas — there are no special-case page components. A "fixed" full-screen tool (a map, a flow editor) is not a hand-written screen; it is an ordinary page whose default layout happens to be a single, maximized, locked widget. Same engine, same canvas, no exceptions.
What a page type is
A page type is a descriptor — pure data, not a component. It declares four things:
- the typed context the page provides to the widgets placed on it,
- the default layout those widgets start from,
- the slots it locks so user customization cannot move or remove them, and
- whether end users may customize it at all.
You register page-type descriptors into a PageTypeRegistry. Registration
validates the descriptor — including its declared context — up front, so a
malformed page type fails loudly at registration rather than silently at widget
mount.
import { PageTypeRegistry } from '@gridmason/core/engine';
const pageTypes = new PageTypeRegistry();
const customerPage = pageTypes.register({
id: 'crm.customer-detail',
context: {
record: { type: 'record-ref', recordType: 'customer' },
},
default_layout: 'layouts/customer-detail.json',
locks: ['header-summary'],
allow_user_customization: true,
});
The registration input (PageTypeInput) has these fields:
| Field | Type | Meaning |
|---|---|---|
id |
string (required) |
Page-type identity, e.g. crm.customer-detail. Unique within a registry. |
context |
ContextMap (required) |
The typed context this page provides, keyed by slot name. Validated against the protocol context grammar. |
default_layout |
string |
Tag or path of the layout applied to fresh instances of this page type. |
locks |
readonly string[] |
Slot ids the page type pins for the levels below it. |
allow_user_customization |
boolean |
Whether users may add/move/remove widgets. Defaults to false when omitted — customization is opt-in. |
pages |
readonly string[] |
A migration-only escape hatch for legacy route-regex patterns. The engine never compiles them; prefer typed context + supportsPages globs. |
A "fixed" page is not a separate kind. A fully locked page is simply
allow_user_customization: false. There is nofixed: trueflag.
What registering a page type gives you
register returns a normalized, validated RegisteredPageType (the same shape
you can later fetch with .get(id)), with locks and allow_user_customization
always populated (defaulted during registration). Registering also:
- Validates the declared context against the protocol grammar (see
Page contexts below). An unknown context type, a
record-refwith norecordType, a malformedlocks/pageslist, or a duplicateidthrows aPageTypeRegistrationError. - Emits a
pageType:registeredchange event on the registry'seventsemitter, so a picker or navigation UI can stay in sync.
pageTypes.events.on('pageType:registered', ({ pageType }) => {
console.log('registered', pageType.id);
});
pageTypes.get('crm.customer-detail'); // RegisteredPageType | undefined
pageTypes.has('crm.customer-detail'); // boolean
pageTypes.list(); // readonly RegisteredPageType[], registration order
A RegisteredPageType is what the picker and the layout resolver consult: its
context is the subset target for widget eligibility, its id is the glob target
for supportsPages, and its locks feed the default level of layout resolution.
Tabbed pages
Tabs are a real engine primitive — but only within a single page. They are not the mechanism for "multiple pages under one route." It is worth separating the two ideas the word "tabs" can mean, because the engine answers them differently.
In-page tabs (an engine primitive)
A single LayoutDoc (LayoutPage) can be organized into tabs. The document
carries a hasTabs boolean and a tabs array; each LayoutTab owns its own grid:
// from @gridmason/protocol
interface LayoutPage {
schemaVersion: number;
page: string; // the page-type id
name: string;
default: boolean;
grid: LayoutGrid; // rendered when hasTabs is false
hasTabs: boolean;
tabs: readonly LayoutTab[]; // rendered when hasTabs is true
}
interface LayoutTab {
name: string;
grid: LayoutGrid;
}
The canvas renders one tab at a time. Which one is controlled by the
activeTab property on <gm-page-canvas> (a numeric index):
canvas.activeTab = 1; // render the second tab's grid; re-renders synchronously
Switching tabs runs the full widget mount/unmount lifecycle — the widgets of the
outgoing tab receive disconnectedCallback before the incoming tab's widgets mount
(see The canvas).
Authoring tabs (add, rename, switch) is done through the EditController, and it
is gated by the page type: tab authoring is only available when the controller
is constructed with allowTabs: true. Layout resolution and gating both understand
tabs — governance composes items within each matching tab scope, and gating filters
widgets per-tab, retaining a tab that loses all its widgets as an empty tab.
import { EditController } from '@gridmason/core/canvas';
const controller = new EditController({
canvas,
persistence,
scopeKey,
inherited: effectiveLayout,
allowTabs: true, // required, or addTab/renameTab throw
// ...
});
controller.addTab('Overview');
controller.renameTab(0, 'Summary');
controller.switchTab(1);
If allowTabs is false (the default), addTab/renameTab throw
"tab authoring is not allowed on this page type (allowTabs is false)".
Multiple page views (host-level composition, NOT an engine primitive)
If what you want is a nav bar with several distinct pages — a home dashboard, a
record-detail page, a settings page — that is not engine tabs. There is no
"multi-page" object in the engine. Instead, each page is its own page type with its
own layout, and your host's router decides which one to render into a single,
generic <gm-page-canvas>. This is exactly how the reference dashboard does it.
The dashboard defines a tiny route model (src/routes.ts) whose only job is to
resolve a PageRef — a page-type id plus an optional entity id — from the URL:
// dashboard/src/routes.ts (paraphrased contract)
export const ROUTES = {
home: '/',
page: '/p/:pageType',
pageEntity: '/p/:pageType/:entityId',
governance: '/governance',
};
interface PageRef {
pageType: string; // which page type's layout to resolve
entityId?: string; // the entity a typed-context page is scoped to
}
Every route resolves to a PageRef and renders the same canvas host; there is
no per-page-type component. Navigation is just a change of pageType in the URL,
which resolves a different descriptor and a different default layout. This is the
recommended pattern for "multiple pages": register each as a page type, and let your
router map URLs to page-type ids.
Recommended pattern summary. Use in-page tabs (
hasTabs/tabs/activeTab) when several grids belong to one logical page and one entity/context. Use host routing over multiple page types when you have genuinely different pages. The two compose: a single page type's layout may itself be tabbed.
Limiting which widgets appear on which page
Which widgets a given user sees on a given page is decided by four checks, run against every widget. All four must hold:
- Context subset — the widget's
requiresContextmust be a typed subset of the page's providedcontext. - Page match — the widget's
supportsPagesglob must match the page-type id. - Gate on — the widget's governance gate must be on (your gates adapter).
- Permission held — the user must hold the widget's declared data permissions (your permissions adapter).
The engine owns checks 1–2 (pure, in-process) and orchestrates 3–4 through adapter ports you implement. Core itself makes no network call to resolve a gate or a permission. A widget that fails any check is absent, never greyed-out — no disabled marker, no name, no reason is surfaced, so the result leaks no capability the viewer is not entitled to see.
The single predicate that runs all four checks is isWidgetEligible, and it is
used in two places.
1. The add-widget picker (which types you may add)
eligibleWidgets filters your catalog down to the widget types a viewer may add on
a given page:
import { WidgetCatalog, eligibleWidgets } from '@gridmason/core/engine';
const eligible = eligibleWidgets(
catalog.list(), // Iterable<WidgetCatalogEntry>
registeredPageType, // { id, context } — a RegisteredPageType satisfies this
{ gates, permissions },// your adapter ports
);
// eligible: WidgetCatalogEntry[] — only widgets that pass all four checks
2. Layout resolution (which saved instances render)
The same four checks run again when a persisted layout is resolved for
rendering, because governance can change after a layout is saved. A saved instance
whose gate is now off, whose permission was revoked, or whose page no longer matches
is silently omitted from the effective layout. Crucially this is a view-time
filter, not a write: the stored LayoutDoc is untouched, so re-enabling the gate
restores the instance on the next resolution — a clean, lossless round-trip.
import { resolveAndGateLayout } from '@gridmason/core/engine';
const effective = resolveAndGateLayout(
{ default: { layout: defaultDoc, locks: pageType.locks }, org, user },
{
pageType, // { id, context }
manifests: { manifestFor: (id) => catalog.get(id)?.manifest },
gates,
permissions,
},
);
(If you already have a resolved EffectiveLayout, gateResolvedLayout(effective, context)
applies just the gating pass.)
Load failure is not gating. A saved instance whose type is unknown (the host never registered it) is kept, so the canvas can render its error-boundary fallback card. Only a gated-off or unpermitted instance is omitted. The two cases are deliberately distinct.
Worked example: "widget X allowed on page A but not page B"
Two levers express this, and you can use either or both:
By page-type match (supportsPages on the widget's manifest). The widget
declares the pages it is placeable on as globs matched by a safe matcher (never
new RegExp on user input):
// acme-sales-chart manifest
{
"tag": "acme-sales-chart",
"kind": "widget",
"requiresContext": { "record": { "recordType": "customer" } },
"supportsPages": ["crm.customer-detail", "dashboards.*"]
}
This widget is eligible on crm.customer-detail and on any dashboards.* page,
but absent on, say, admin.settings. An omitted supportsPages means no page
restriction (placeable anywhere its context is satisfied); a present list must
match, so an empty list [] admits no page at all.
By context requirement. Because the sales chart also declares
requiresContext: { record: { recordType: "customer" } }, it is eligible only on
pages whose declared context provides a record slot typed as a customer
record-ref. It appears on crm.customer-detail (which provides that context) but is
absent on a free dashboards.home (context {}), even though the dashboards.* glob
would match — because check 1 fails there. A widget with no requiresContext
requires nothing and passes check 1 everywhere.
Between the two, a widget can be scoped to exactly the pages that both name it and supply its data.
Page contexts
A typed page context is the contract between a page and the widgets on it. The
page type declares the types of context it provides; at render time the host
supplies the concrete values; the canvas serializes those values to every widget.
@gridmason/protocol owns the shape of a context type — never your domain
vocabulary. You declare your own recordType strings (customer, team, …) and the
protocol treats them as opaque identifiers matched by equality.
The declaration side — ContextMap
A page type's context (and a widget's requiresContext) is a ContextMap: a map
of slot keys to declared ContextTypes. The grammar recognises these types:
- Primitives:
record-ref(with arecordType),string,number,bool,id. - Composites:
list(with anelementtype) andobject(with afieldsmap).
import type { ContextMap } from '@gridmason/protocol';
const context: ContextMap = {
record: { type: 'record-ref', recordType: 'customer' },
ownerId: { type: 'id' },
tags: { type: 'list', element: { type: 'string' } },
};
This is validated at PageTypeRegistry.register time. The subset relation
requiresContext ⊆ pageContext — the heart of check 1 — is isContextSubset
from the protocol: every key the widget requires must be present in the page with a
matching type (the page may declare more keys; surplus context is always safe).
The value side — PageContext
At render time the host builds the runtime value for each slot — a PageContext
(a map of slot keys to ContextValues). The mapping is: record-ref → a
{ recordType, id } object; string/id → a string; number → a finite number;
bool → a boolean; list<T> → an array; object<…> → a nested object. The protocol
exports matchesContextMap(value, contextMap) to check a value against a declaration.
How it flows to widgets
You assign the value to the canvas's context property; the canvas serializes it
(as JSON) to the context attribute on every mounted widget on the page — the
same value for every widget:
canvas.context = { record: { recordType: 'customer', id: 'cust-42' } };
Inside a widget custom element you read this.getAttribute('context') and parse it;
list context in observedAttributes to react to changes. The context can be
updated in place without re-mounting the widget — a context change never tears the
widget down or loses its state.
How a host defines one (from the reference dashboard)
The dashboard binds a typed record-ref context to the route's entityId. Its page
type declares the type; a small builder produces the value:
// dashboard/src/pages/context.ts (paraphrased)
export function buildPageContext(pageType, entityId?) {
const context = {};
for (const [slot, type] of Object.entries(pageType.descriptor.context)) {
if (type.type === 'record-ref') {
context[slot] = { recordType: type.recordType, id: entityId ?? null };
}
}
return Object.keys(context).length > 0 ? context : undefined;
}
So /p/demo.record-detail/cust-42 produces
{ record: { recordType: 'customer', id: 'cust-42' } }. A page type with no declared
context (a free home page, the locked demos) provides undefined.
LayoutDoc + persistence
Structure
A layout document is a versioned JSON page description — the LayoutPage type from
@gridmason/protocol. Its nesting is:
LayoutPage { schemaVersion, page, name, default, hasTabs, grid, tabs[] }
→ LayoutTab { name, grid } (when hasTabs is true)
→ LayoutGrid { items[] }
→ LayoutWidget { widgetID:{source,tag}, i, x, y, w, h, props?, slot? }
widgetIDis source-qualified ({ source, tag }), not a bare tag. A saved instance only ever mounts the widget from the source it was saved with, so a multi-registry host can never have one publisher's tag silently impersonate another's.iis the stable grid-item key (unique within its grid);{x,y,w,h}is the grid geometry, carried over unchanged from the proof-of-concept.slotis an optional stable role id. Page-typelocksbind to slot ids, so a lock follows the instance wherever governance places it.schemaVersionis a positive integer. The current version isCURRENT_LAYOUT_SCHEMA_VERSION(currently1).
The 3-level governance model
A page's effective layout is composed from up to three candidate layouts, in order of
increasing specificity, by the pure resolveLayout function:
plugin/host default (ships with the page type)
→ organization published layout (per org node/role; may add locks)
→ user personal layout (per page type, optionally per entity)
Two rules bind the composition:
- Most-specific wins. The user level overrides the org level overrides the default, per item. A level that is silent about an item inherits it unchanged.
- Locked slots merge down. A slot locked at the default or org level is fixed for every level below it: a lower level's attempt to move, resize, remove, or replace that slot is ignored, not applied.
import { resolveLayout } from '@gridmason/core/engine';
const effective = resolveLayout({
default: { layout: defaultDoc, locks: pageType.locks }, // page-type locks
org: { layout: orgDoc, locks: ['metrics'] }, // org may add locks
user: { layout: userDoc }, // user locks are ignored
});
// → { layout: LayoutPage, lockedSlots: readonly string[] }
Any level may be absent, but at least one must supply a layout (else
resolveLayout throws ResolveLayoutError). The result carries lockedSlots — the
de-duplicated union of the default and org locks — which the canvas edit mode and the
gating pass both honor. A locks declared at the user level has nothing below it to
govern and is ignored.
Copy-on-write user overrides
A user with no personal layout is inheriting — resolution falls through to the org
or default level. The first time they genuinely edit an inherited layout, the
engine forks a personal copy at the user level rather than mutating the upstream
document. "Genuinely" is the crux: fork detection uses a structural diff, not a
JSON.stringify hash, so reloading and re-serializing an inherited layout (different
key order, whitespace, or item ordering) does not fork.
import { forkOnEdit } from '@gridmason/core/engine';
const result = forkOnEdit(inheritedDoc, editedDoc);
// { forked: false } — the edit changed nothing structural; keep inheriting
// { forked: true, layout: LayoutPage } — a detached personal copy to store at the user level
Keeping forked: false matters: an inheriting user still receives later upstream
changes, whereas a fork detaches them.
Save and reset semantics
- Save — a fork (and every subsequent edit) is written under the user's
ScopeKeyvia the persistence adapter'sput. TheEditControllerdoes this for you on each edit. - Reset to default — available at every level.
resetLevel(inputs, 'user')returns new resolve-inputs with that level'slayoutremoved (anylocksit contributes are governance and are left intact), so resolution falls back to the upstream document. The persistence side mirrors it bydelete-ing that level's key.
The persistence adapter a host implements
Persistence is an adapter — core performs no I/O itself. You implement a scope-keyed document store over any KV backend:
import type { PersistenceAdapter, ScopeKey } from '@gridmason/core/adapters';
import { scopeKeyString } from '@gridmason/core/adapters';
interface PersistenceAdapter {
get(key: ScopeKey): Promise<LayoutPage | undefined>; // undefined for a missing key; never throws
put(key: ScopeKey, doc: LayoutPage): Promise<void>;
delete(key: ScopeKey): Promise<void>;
}
A ScopeKey is { owner, pageType, entityId? }, where owner is either 'user' or
{ node: string } (an org scope-node). scopeKeyString(key) gives a canonical,
order-independent string suitable as a raw KV key. The contract your store must honor:
get-after-put returns an equal document, scope isolation keeps distinct keys from
colliding, and missing-key resolves to undefined rather than throwing. A bundled
DevPersistenceAdapter (in-memory + localStorage, dev-only) is provided for local use.
Migration is migrate-on-read
Documents are upgraded on load, never eagerly rewritten. loadLayout (a pure
wrapper over the protocol's migrate) reads a persisted doc at any known version and
returns either a loadable document (migrated: true means storage should write it
back) or, for a document from a newer build than this library understands, a
read-only result returned untouched with a warning for the canvas's read-only
banner. It never throws on a version ground and never destructively downgrades.
LayoutStore is the observable current-document holder: load(doc) runs loadLayout
and emits layout:loaded; replace(doc) swaps the document wholesale and emits
layout:changed; current is the render-ready document (or undefined before a load
or when read-only). The canvas re-renders off these events instead of polling.
Export / import
exportLayout(doc) serializes a LayoutPage to indented JSON text.
importLayout(json) parses untrusted JSON text and validates it against the schema
before returning { ok: true, doc } or { ok: false, error } — it never throws.
Import is JSON-in only: no eval, no new Function, no URL/base64/<script>
import path, and a malformed-JSON fault reports a fixed message (never the parser's
own text, which could echo a widget tag and leak a capability). Import validates
shape, not availability — a doc referencing a widget you don't have imports fine,
and those references degrade to anonymous cards at render time.
The canvas
The <gm-page-canvas> custom element (class PageCanvas) is the gridstack.js
binding and the only DOM consumer in core. It renders a resolved EffectiveLayout by
mounting one widget custom element per placed item.
Element lifecycle
Register the element once (idempotent), then set inputs as properties (they carry structured values a string attribute cannot):
import { PageCanvas } from '@gridmason/core/canvas';
PageCanvas.define(); // registers <gm-page-canvas>; safe to call repeatedly
const canvas = new PageCanvas();
canvas.layout = effective; // EffectiveLayout from resolveLayout / resolveAndGateLayout
canvas.context = pageContextValue; // serialized to every widget's `context` attribute
canvas.sdk = hostSdkHandle; // opaque host SDK handle; core never inspects it
document.body.append(canvas);
Every property assignment re-renders synchronously, so the effect is observable
immediately after assignment. Read-back helpers: mountedInstanceIds,
widgetElement(i), geometryOf(i) (the live {x,y,w,h,i}), and boundaryOf(i)
(a widget's boundary state: loading / ready / error).
The mount lifecycle guarantee: every mount/unmount flows through a
WidgetMountManager, which removes a widget from the DOM (firing its
disconnectedCallback) before its slot is reused. On any re-render — layout
change, tab switch, or resolution-gate flip — the canvas unmounts departing and
identity-changed widgets first, then mounts arrivals, so every
disconnectedCallback is delivered before any new connectedCallback. A widget must
release what it allocated outside the SDK (timers, observers, foreign listeners) in
its disconnectedCallback.
Edit mode
editMode is a boolean property; setting it reflects the edit-mode attribute onto
every widget and takes gridstack out of static mode (enabling drag/resize). The
EditController (@gridmason/core/canvas) orchestrates an authoring session on top
of it: enter()/exit() toggle edit mode; addWidget(input) first-fits a new
instance (its eligible list comes from the four gating checks); removeWidget(id)
tears one down; addTab/renameTab/switchTab author tabs when allowTabs is set.
A locked slot is never offered a move/resize/remove, and editing is refused
entirely when the page type's allow_user_customization is false. Every edit forks a
copy-on-write personal layout on first genuine change and writes it back through the
persistence port. The controller drives the canvas only through
layout/editMode/activeTab and the geometry event — it never touches gridstack —
so the engine's DOM-free split holds.
Geometry and lifecycle events
The canvas dispatches CustomEvents (all bubbling/composed) that report what the grid
is doing:
| Event | Constant | detail |
Fires when |
|---|---|---|---|
gm:geometry-change |
CANVAS_GEOMETRY_CHANGE_EVENT |
{ geometry: WidgetGeometry[] } |
A user drag/resize settles in edit mode. Programmatic re-renders do not fire it — so you can round-trip an edit back into layout without a feedback loop. |
gm:rendered |
CANVAS_RENDERED_EVENT |
{ instanceIds } |
After every programmatic render reconciles the grid. Lists every placed instance (not the mounted subset). |
gm:widget-mounted |
CANVAS_WIDGET_MOUNTED_EVENT |
{ instanceId } |
Under virtualization only, when one widget mounts because its cell scrolled into view. |
gm:widget-unmounted |
CANVAS_WIDGET_UNMOUNTED_EVENT |
{ instanceId } |
Under virtualization only, when one widget unmounts because its cell scrolled out. |
gm:geometry-change is the hook the edit-mode controller listens on; the a11y layer
uses the render/mount/unmount events to keep keyboard landmarks and focus in sync.
Error boundary and fallback
Every widget is mounted inside a per-widget error boundary — one widget's failure
never takes the page down, and the canvas never blocks on widget code. A widget signals
readiness with events: a synchronous widget dispatches nothing (interactive when
connectedCallback returns); an async one signals pending (a gm:loading event or
the gm-loading attribute) and the boundary shows a skeleton until it dispatches
gm:ready; a gm:error at any time falls back to an error card.
The card shows the widget's name (resolved via a widgetDescriptor you supply) and a
retry button that re-runs the whole mount lifecycle cleanly. When no name is
available (an unknown/unentitled tag) the card is anonymous ("Unavailable widget")
and echoes no tag or name — consistent with the no-capability-leakage rule. Cards and
skeletons are accessible (a skeleton is role="status", a card is a labelled
role="group" with a role="alert" message and a real focusable retry button) and
ship WCAG AA-compliant colours that a host can override via CSS custom properties.
A special case: a layout may render before a widget's customElements.define runs
(a slow or code-split bundle). Such a widget falls back as unresolved, but the
boundary waits on customElements.whenDefined(tag) and auto-re-mounts it once the
tag is defined — no user action, and a widget.recovery telemetry event lets the host
observe the race.
Per-widget error and latency events flow to a host telemetry sink (never over the
network — core makes zero network calls), each carrying the full instance identity so
the host can attribute which widget, from which source failed or ran slow — and
optionally auto-degrade a widget that exceeds its latencyBudgetMs.
Virtualization (real)
Virtualization is implemented, not aspirational. Set the virtualize property to
true and the canvas mounts only the widgets whose grid cells are in (or near) the
viewport, driven by an IntersectionObserver over a configurable band
(virtualizeRootMargin; a virtualizeObserverFactory is injectable for tests). An
offscreen item stays placed — its grid item exists so geometry and page height stay
correct — but its widget content is unmounted (its disconnectedCallback fires) until
it scrolls back into the band. The gm:widget-mounted / gm:widget-unmounted events
fire per widget as this happens, and a keyboard landmark tracks mount state so an
offscreen, torn-down widget is not a Tab stop until it returns. This keeps a long
page's interactive cost bounded (the SPEC targets p95 canvas-interactive < 300 ms).