gridmason docs / Pages & Layouts

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:

  1. the typed context the page provides to the widgets placed on it,
  2. the default layout those widgets start from,
  3. the slots it locks so user customization cannot move or remove them, and
  4. 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 no fixed: true flag.

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-ref with no recordType, a malformed locks/pages list, or a duplicate id throws a PageTypeRegistrationError.
  • Emits a pageType:registered change event on the registry's events emitter, 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:

  1. Context subset — the widget's requiresContext must be a typed subset of the page's provided context.
  2. Page match — the widget's supportsPages glob must match the page-type id.
  3. Gate on — the widget's governance gate must be on (your gates adapter).
  4. 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 a recordType), string, number, bool, id.
  • Composites: list (with an element type) and object (with a fields map).
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? }
  • widgetID is 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.
  • i is 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.
  • slot is an optional stable role id. Page-type locks bind to slot ids, so a lock follows the instance wherever governance places it.
  • schemaVersion is a positive integer. The current version is CURRENT_LAYOUT_SCHEMA_VERSION (currently 1).

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 ScopeKey via the persistence adapter's put. The EditController does this for you on each edit.
  • Reset to default — available at every level. resetLevel(inputs, 'user') returns new resolve-inputs with that level's layout removed (any locks it contributes are governance and are left intact), so resolution falls back to the upstream document. The persistence side mirrors it by delete-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).