# Building a page: createPage, the router, panels that load on their own and the property grid

> Plainkit 0.28.0. Generated by scripts/build-skills.mjs from dist/js/page.js and dist/js/router.js. Do not edit: change the source and run the generator.

`createPage` gives a page the bookkeeping it always needs — a title, a status/error notice, a busy overlay around an action, a breadcrumb trail — built from elements already in your markup: it never creates or owns them, only drives the props they already have (the one exception is the loading overlay, so you write no overlay markup). The header of `js/page.js`, verbatim:

```text
A page's own state — not a component: the bookkeeping a concrete page (list-detail, form, a workspace pane) repeats by hand
otherwise — a title, a status/error notice, a busy overlay around an action, and breadcrumbs — as one small object built from
elements the host already placed in its markup. createPage never creates or owns those elements (one exception, below: the loading
overlay); it only drives the props and slots they already expose (pk-alert's kind/heading, pk-loading-overlay's busy/label,
pk-breadcrumb's slotted <a> trail).
Framework-free, so a plain page and PlainKit.Blazor's PageBase (blazor/src/PlainKit.Blazor/PageBase.cs) share the same model;
the Blazor wrapper calls the same functions from the framework's own lifecycle instead of reimplementing them.

  import { createPage } from './page.js';
  const page = createPage({
      alert: document.querySelector('#page-alert'),
      breadcrumb: document.querySelector('#page-crumbs'),
      body: document.querySelector('#page-body'),   // the framework wraps this in its own pk-loading-overlay: no overlay markup
      scope: 'orders',
  });
  page.setTitle('Orders');
  page.setBreadcrumbs([{ label: 'Home', href: '/' }, { label: 'Orders' }]);
  await page.busy(() => fetchOrders(), 'Loading orders…');
  // a fetchOrders() rejection is logged, shown as a danger status and rethrown

Busy is COUNTED: every busy action (page.busy(fn, label), or begin(label) -> end() for work that is not a promise) holds a token;
page.isBusy is true while any token is held, so overlapping actions never clear each other. page.busyLabel is the label of the most
recent action still running. Tokens are released in `finally` (and end() is idempotent); destroy() releases them all. Subscribe with
page.onBusyChange(fn): fn({ busy, label }) on every change of either; it returns its unsubscribe.

The overlay. Pass `overlay` (an element you placed) and it is driven exactly as before: immediately, no delay (delay and minTime
default to 0 for it; pass them to opt in). Pass `body` (the element that holds the page) with no overlay and the framework CREATES a
pk-loading-overlay and wraps `body` in it once, inside createPage (one element with no layout of its own: it is display:block and the
shade is absolutely positioned, so nothing moves when it shows); destroy() unwraps and removes it. `fullscreen: true` (app scope)
creates a fullscreen overlay appended to the document body instead and wraps nothing. A framework-owned overlay obeys two documented
timings, both exported and both options:
  BUSY_DELAY    (150 ms)  it appears only when busy lasts longer than this, so a fast action never flashes it;
  BUSY_MIN_TIME (300 ms)  once shown it stays at least this long, so it never flickers.
createPage({ body, delay: 150, minTime: 300 }). The busy region (`body`, else the overlay) gets aria-busy while any token is held; the
overlay announces its label politely (it is a status region) and inerts what it wraps, but never traps or moves focus. Labels are text
(the element's own label prop, rendered as text), never markup. No polling: at most two timeouts, cleared when idle and on destroy.
```

## Busy state

Busy is counted: every action holds a token, so two overlapping `page.busy(fn, label)` calls never clear each other. `page.isBusy` stays true until the last one ends and `page.busyLabel` is the most recent one still running. For work that is not a promise use `const end = page.begin('Saving…'); try { … } finally { end(); }` (`end` is safe to call twice); `page.onBusyChange(({ busy, label }) => …)` returns its unsubscribe. A rejection releases only its own token. The overlay created for `body` appears only after `BUSY_DELAY` (150 ms, so a fast action never flashes it) and stays at least `BUSY_MIN_TIME` (300 ms); pass `delay` and `minTime` to change them. It wraps `body` without moving it (no layout shift), sets `aria-busy` on it while any action runs, announces the label politely and never traps focus. `createPage({ fullscreen: true })` is the app-scope variant (a fullscreen overlay, nothing wrapped). An `overlay` element you placed yourself still works, driven at once (no delay) unless you pass `delay`. Labels are text, never markup. `page.destroy()` releases every token and timer and puts `body` back.

## Drive breadcrumbs and the title from a route tree

Do not hand-build the trail on every page: declare a route tree once with the vanilla router module and pass it in. The header of `js/router.js`, verbatim:

```text
A router/state module: one route tree, declared once, that says where the page is and derives the breadcrumb trail from it, so no
page builds its own list. It does not render anything and never owns an element; createPage (js/page.js) takes it as `router` and
drives the page's breadcrumb and title from it. The vanilla SDK has no other router; PlainKit.Blazor keeps NavigationManager
(issue 219 records the Blazor side as a follow-up), so nothing here replaces or fights a framework router.

A route tree is a nested array: { path, label, children?, crumb? }. `path` is the full path ('/orders/:id'; a `:name` segment
captures), `label` is a string or (params) => string, `crumb: false` leaves the route out of the trail; a route with path '*' answers
any path nothing else matches. A path with no route is "not found": current() is null and crumbs() is empty (unless a not-found route
exists, below). Static segments beat a :param at the same depth.

  import { mountRouter } from './plainkit/js/router.js';
  import { createPage } from './plainkit/js/page.js';
  const router = mountRouter(document.body, {
      routes: [{ path: '/', label: 'Home', children: [
          { path: '/orders', label: 'Orders', children: [{ path: '/orders/:id', label: p => `Order ${p.id}` }] },
      ] }],
      intercept: true, // same-origin <a> clicks inside the container become router.navigate(href); so does a pk-navigate (pk-link's `to`)
  });
  const page = createPage({ breadcrumb: document.getElementById('crumbs'), router });
  router.navigate('/orders/7'); // crumbs: Home > Orders > Order 7, document.title 'Order 7'
  router.destroy();

Hash mode (for an app on static hosting: no server rewrites): mountRouter(el, { routes, mode: 'hash' }) reads and writes
'#/<path>?<query>' (the query lives inside the hash: '#/gallery/elements/pk-button?nav=top') and follows 'hashchange'. `mode` is 'path'
(the default, pushState on the pathname) or 'hash'. These options work in both modes:
  guard    (route) => true | { allow: false, redirect?: '/app/path' }. Runs on every address before anything is resolved or rendered. Only
           true (or { allow: true }) lets the route through; anything else, and a guard that throws, denies it (fail closed: current().status
           is 403 and no route is reported). A redirect is followed only when it is an app-relative path (safeRoute, js/safe-url.js rules),
           never another site, so a guard cannot become an open redirect. route = { path, params, query, node, notFound }. A client guard is
           UX only; the server stays the source of truth.
  aliases  { '/elements/:name': '/gallery/elements/:name' }: old addresses that keep working. :params and the query carry over and the
           address is replaced (no extra history entry). A value may be a function (path, query, params) => new address | null, for the old
           '#path=a&line=2' form (a query on '/'). Only app-relative results are followed; a loop stops after 5 hops.
  notFound a route node or a label string for a path nothing matches (a '*' route in the tree does the same). Hash mode always has one
           ('Not found'), so there an unknown address is a normal match with status 404, never an exception and never null.
current() is { path, url, params, query, label, status } (status 200, 404 or 403) or null. `path` is the route's pattern, `url` the address.
params and query are untrusted text: put them in the page with textContent only. A same-page anchor ('#/guides/x/heading') is just a path the router
reports; the page type scrolls to it. navigate(to, { replace }) takes an app-relative path with an optional query and refuses anything else.
```

```js
import { mountRouter } from './plainkit/js/router.js';
import { createPage } from './plainkit/js/page.js';

const router = mountRouter(document.body, {
    routes: [{ path: '/', label: 'Home', children: [{ path: '/orders/:id', label: p => `Order ${p.id}` }] }],
    intercept: true,
});
const page = createPage({ breadcrumb: crumbsEl, router });   // sets breadcrumbs and title on every route change
```

`label` is a string or `(params) => string`, `crumb: false` skips a route, and `router.navigate(path)`, `crumbs()`, `current()`, `subscribe(fn)` and `destroy()` (which also stops `createPage` following it) are the handle. For an app on static hosting use hash mode, `mountRouter(el, { routes, mode: 'hash' })`: addresses are `#/<path>?<query>`, an unknown address is a normal match with `status` 404 (never null, never an exception), and `current()` is `{ path, url, params, query, label, status }`. `guard: route => true | { allow: false, redirect }` runs before anything is rendered and denies (status 403) on anything but `true` or a throw; a redirect is followed only when it is an app-relative path. `aliases: { '/elements/:name': '/gallery/elements/:name' }` keeps old addresses working (params and query carry over), `notFound` or a `{ path: '*' }` route sets the not-found page, and `navigate(to, { replace })` refuses anything that is not an app-relative path. Route params and query are untrusted text: put them in with `textContent`. A Blazor app keeps `NavigationManager`; this is for the vanilla SDK.

## A panel that loads on its own

`pk-card` has no loading, empty or error state of its own (it renders no other element). A single panel that loads on its own (a dashboard widget) is a `pk-card` whose body the page draws into: `import { showState } from './plainkit/js/page-shell.js'` (or `renderState` from `js/page-states.js`), then `const box = document.createElement('div'); card.replaceChildren(box); showState(box, 'loading', { label: 'Loading revenue' })`, later `showState(box, 'error', { error: err, retry: () => reload() })` for a danger alert with Retry or `'empty'` with `{ heading, description }`, and finally `card.replaceChildren(content)`. The card's heading, actions and footer are unchanged in every state. Migrating from `<pk-card state="loading">`, `stateHeading`, `stateDescription` and `card.retry`: draw the state yourself as above; `pk-dashboard-page` already does this for its widgets.

## A live property inspector

A property inspector is `pk-property-grid`: `grid.config = { groups: [{ heading, collapsed?, fields: [{ key, type: 'text'|'number'|'unit'|'range'|'color'|'select'|'switch', label, required?, min?, max?, options?, units?, visibleWhen?: { key, equals } }] }] }`, `grid.values = {...}` for the starting values, and `pk-property-change` (`detail.key`, `value`, `values`, `valid`) per edit. Drive it live without a rebuild (focus, scroll and open groups stay): `grid.setValue('width', 640)`, `grid.errors = { width: 'Taken' }`, and `grid.state = { note: { disabled: true }, seed: { hidden: true } }`; `visibleWhen` shows a property only while another has a given value. A `unit` property is a number with a unit picker (`units: ['px', '%']`, the first is the default): its value and the event's `value` are `{ value, unit }`, and `min`/`max` apply to the number. Keyboard across properties: Ctrl+Up/Down move to the previous or next enabled, visible property (Ctrl+Home/End the first or last); a switch takes the plain arrows and Home/End as well. Tab is never trapped.
