# The app framework: mountApp, defineModule, moduleFromMount and the module host

> Plainkit 0.28.0. Generated by scripts/build-skills.mjs from dist/js/app.js, dist/js/app/*.js and dist/js/tasks.js. Do not edit: change the source and run the generator.

The app framework (tracker 346) is built in steps; this is step 4 (`mountApp`) plus step 5 (page types 1, tracker 351). A module is what one part of an app is: business logic plus the configs of existing page types, with an id, a title, its own routes, optional `nav`, `state` (a `createStore` module spec, `references/state.md`), `can` and `mount`/`unmount`. `createModuleHost` runs modules one at a time with a fixed lifecycle, tracked listeners, observers and timers that are removed on unmount, and a boundary around every step that can fail: a loading state, a danger alert with Retry, a forbidden state and a not-found state. Built-in page types so far: `custom`, `states` (`pk-states-page`), `tool` (`pk-tool-page`), `settings` (`pk-settings-page`: sections of fields with a sticky Save/Discard bar; `config.nav` is `true`, `false` or `auto` (default: on from three sections), a sticky list of the sections beside the cards that scrolls to one and marks the one in view, replaced by the tab strip of pk-detail-layout on a phone; the optional `key` of a section is its anchor), `not-found` (`pk-not-found-page`) and `workspace` (`pk-workspace-page`: nav, main and aside panes; your `mount(panes, ctx)` callback draws into them and returns cleanup or a `{ destroy() }` handle), `master-detail` (`pk-master-detail-page`: a list beside the record `ctx.route.params.id` selects; `load(query, ctx)`, `rowHref(row)`, `listHref`, `mountDetail(pane, id, ctx)`), `record` (`pk-record-page`: one record read or edited beside summary cards; `config.fields`, your `load(id, ctx)`, `save(values, ctx)` and optional `delete(id, ctx)` callbacks (a Delete button that asks first), dirty tracking, inline validation and server errors; outcomes are toasted through `ctx.notify` by default, "Saved", "Could not save", "Deleted", "Could not delete", and bulk actions of the `list` page "Done" or "Could not complete": `config.toasts: false` silences them and `{ saved: "Order saved", failed: err => err.message, deleted: false }` words or drops one, so do not also toast the same outcome yourself), `wizard` (`pk-wizard-page`: a `pk-stepper` over one validated form per step, Back, Next and Submit, an optional Review step; `config.steps` and `review`, your `validate(stepId, values, ctx)`, `submit(values, ctx)`, `load(ctx)` and `mountStep(pane, step, ctx)` callbacks, `{ errors: { field: message } }` for inline errors, dirty tracking) and `doc` (`pk-doc-page`: long-form documents with a filterable nav, table of contents and pager; `config.items`, the route param `id` and query `anchor` select the item and heading, your `loadItem(id, ctx)` and `href(id, anchor, ctx)` callbacks) and `print` (`pk-print-page`: a document that is the only thing that prints, with a screen-only `toolbar` slot and `data-screen-only` parts, `size` and `margin` for `@page`, cards, field lists and table rows kept whole; `config.heading`, `size`, `margin`, `toolbar: [{ key, label }] (key back goes back, any other key prints)` and your `render(el, ctx)` callback that fills the document); the rest arrive in later steps. `mountPage(container, page, options)` mounts one page type with no module or app around it, for a consumer who wants exactly one page. Blazor: no surface yet (`IPkModule` and `PkApp` are later steps). The header of `js/app/module.js`, verbatim (it also documents the `ctx` every module and page receives):

```text
App module definition (#349, step 3 of the app framework #346): what a module IS, before anything runs it. Pure, no DOM, so a tool or a node test can read a module without mounting it.

  import { defineModule } from './plainkit/js/app.js';
  export default defineModule({
      id: 'orders',                                   // ^[a-z][a-z0-9-]{0,39}$; the key the allow-list and the address '#/orders/...' use
      title: 'Orders', icon: 'list',
      nav: ctx => [{ id: 'all', title: 'All orders', route: '/' }],   // side nav: STRUCTURE (a few stable destinations), never one per record (js/app/nav.js); navFilter: true adds the side nav's filter box
      routes: [                                        // module-relative; the app adds the module id prefix. page = a type id, or { type, config }
          { path: '/', page: 'custom', config: { mount: (el, ctx) => mountOrders(el) } },   // config: data, or ({ path, params, query }, ctx) => data
          { path: '/:id', page: 'custom', label: p => `Order ${p.id}`, config: ({ params }) => ({ mount: el => showOrder(el, params.id) }), can: ctx => ctx.auth?.has('orders.read') ?? true },
          { path: '*', page: 'not-found' },
      ],                                               // `persist: true` on a route keeps its built page, hidden, while another route shows (freed with the module; rebuilt for other params), host.js
                                                       // a route tree: a record route goes in its list's `children` (full paths), `label` is its breadcrumb
      // a route may add `context: { ids?, crumbs?, title? }` (or ({ path, params, query }) => that): where the page is when its address cannot say (ids: nav ids top to current; crumbs: [{ label, href? }]; title), #670
      state: { version: 1, defaults: { q: '' }, persist: ['q'] },   // a store.module() spec (js/store.js): ctx.store is this module's own namespace
      footer: { text: 'Run by Finance', links: [] },                // optional: replaces the app footer (config.footer) while this module is active; false draws none (#373)
      can: ctx => ctx.auth?.has('orders.read') ?? true,             // access hook; true or { allow: false, redirect }; else, or a throw, denies (fail closed)
      mount(ctx) { return () => {}; }, unmount(ctx) {},             // optional; a function returned from mount is cleanup
      pageTypes: { kanban: (host, config, ctx) => ({ destroy() {} }) },  // page types only this module has (below); layouts: { name: (host, ctx) => element }
  });

defineModule returns its argument and throws a TypeError naming the module and the mistake (bad id, duplicate route, a bad nav...). `widgets: { key: { load } }` holds dashboard widget loaders keyed like the config entry's `contributes` (#494, drawn by a dashboard route with a `surface`).
The host (js/app/host.js) validates again after a lazy import, so a module cannot skip it.

Page types and layouts. The built-in page type ids are reserved (BUILT_IN_PAGE_TYPES); 'custom', 'states', 'tool', 'settings', 'not-found',
'list', 'dashboard', 'workspace', 'master-detail', 'record', 'doc', 'wizard' 'note' and 'print' are built in (each a chunk in js/app/pages/, below), the rest arrive later. Extend the set three ways, all through the same factory shape
(host, config, ctx) => cleanup function | { destroy() } | nothing (a promise of it is awaited):
  the module   defineModule({ pageTypes: { kanban }, layouts: { split } }): only that module's routes can name them;
  the app      registerPageType('kanban', factory), registerLayout('split', factory): every module can;
  a route      { path, page: { type: 'kanban', config }, layout: 'split' }.
A layout is (host, ctx) => element: it may build chrome around `host`, returning the element the page type mounts into (host itself if none).
Lookup is module, then app, then built-in; a built-in id can never be shadowed or registered, and a name is only a key into these tables.

ctx, what a module and its pages receive (the tracker's list, minus what only the shell has: search, toast, confirm arrive with mountApp):
  id                 the module id.
  page               createPage (js/page.js) bound to the module's own alert and body (wrapped in the busy overlay): setStatus, setError, busy(fn, label), begin(label), setTitle (no breadcrumb until the shell).
  store              the module's namespaced state (js/store.js: get, set, patch, subscribe, reset), null without `state`; subscriptions end on unmount.
  settings           what the app passed as `settings`, or null.
  theme              { name, set(name), toggle(), subscribe(fn) } for data-theme (js/theme.js); subscribe is a tracked MutationObserver, on demand.
  route              { path, params, query } now, module-relative; params and query are untrusted text (textContent only).
  navigate(path, { replace }) / href(path, params, query)   module-relative router links; navigate keeps the path's own ?query; it returns false without a router.
  log                createLogger('app:<id>').
  on/observe/after   listeners, observers and one-shot timers core removes at that end; each call returns a stop-early fn; called after, they warn and no-op.
  tasks              { run(spec) } (js/tasks.js): long work as progress toasts; null without a manager. Ends with this ctx: cancellables cancel, others continue.
  notify             { info, success, warn, error }(title, details?, opts?) (js/notify.js); null without one; ends with this ctx: its toasts finish.
  dialogs            { confirm, alert, prompt, open }(config) -> Promise (js/dialogs.js), one modal app-wide; null without one; ends with this ctx: its dialogs cancel.
  signal             an AbortSignal that aborts at that end (pass it to fetch).
  auth               what the app passed as `auth`.
The ctx of mount/unmount ends on unmount. A page factory gets its own ctx (same members) whose on/observe/after/signal end when the page is
left (a route change or the module unmounting), so a module that changes route a thousand times holds only the current page's resources.
```

## The app: mountApp

`mountApp(container, config)` owns ALL the chrome: the header bar (hamburger, brand, search, settings menu with the theme switch), the side navigation, breadcrumbs, footer (`config.footer`; a module can set its own `footer: { text, links }` (or `false` for none) that replaces it while that module is active), skip links, landmarks, focus and a live announcement on every route change, the document title, the theme and its persistence, the app-level busy overlay (`app.page`), a toast stack region and a dialog host region (filled by a later step), and the hash routing `#/<moduleId>/...`. A consumer writes a config (a default-exported object, checked when it is mounted: a bad value throws, an unknown key is one warning) and modules, and no chrome, no CSS, no class and no `style` attribute of its own. The side nav is the own nav of the active module and nothing else; the modules are switched with the module picker, a button in the header showing the title of the active module (on a narrow screen the first row of the ONE drawer, opened by the one hamburger; absent with a single module). There is no layout option. The header of `js/app/app.js`, verbatim:

```text
undefined
```

The config, the header of `js/app/config.js`:

```text
undefined
```

## The module nav is structure

**A nav is a small, stable set of destinations, never one entry per record.** A collection of records (orders, users) is a LIST page whose rows link to a record route; keep that record route in the `children` of its list in the route tree, so the nav entry of the list stays current for it and the breadcrumbs read `App > Orders > Order 8` (give the route a `label`). More than 12 top-level or 40 total entries is logged once with this advice. The header of `js/app/nav.js`:

```text
undefined
```

## The host

The header of `js/app/host.js`, verbatim:

```text
The module host (#349): runs app modules one at a time inside a container, with a deterministic lifecycle and a boundary around every step that can fail.
No shell, no router of its own: mountApp (step 4) wires it to the router; it is usable and tested on its own with a fake host element.

  const host = createModuleHost(document.getElementById('app'), {
      modules: [{ id: 'orders', title: 'Orders', load: () => import('./modules/orders.js') }],   // the ALLOW-LIST: a static literal per module, never built from data
      router,          // optional mountRouter handle (hash mode): ctx.navigate / ctx.href use it
      auth, can,       // optional: ctx.auth (opaque to the framework), and (entry, { auth, id, route }) => true | { allow: false, redirect } for the whole app
      store, settings, // optional: a createStore() (default: one of its own, prefix 'pk'), and the app settings facade given to ctx.settings
      services,        // optional: more members for every ctx (mountApp adds ctx.search); core's own members win over a service of the same name
      tasks,           // optional: the app's task manager (createTasks, js/tasks.js); ctx.tasks.run(spec) runs a task of the module or page
      notify, dialogs, // optional: js/notify.js, js/dialogs.js; else ctx.notify, ctx.dialogs are null
  });
  const router = mountRouter(el, { mode: 'hash', routes: [...], guard: host.guard });   // access is checked BEFORE anything is imported...
  await host.open('/orders/7?tab=x');                                                    // ...and again here, at mount, after the import

Lifecycle, once each and in this order: resolve (the allow-listed import, 10 s timeout, one retry after 300 ms, then the boundary error with Retry) ->
can (app, entry, module; fail closed) -> unmount of the module being left -> mount(ctx) -> per route: layout, page factory (host, config, ctx) -> on a route change
page cleanup -> on a module switch page cleanup, unmount(ctx), the cleanup mount returned, then everything ctx tracked is disposed. Steps that change what is mounted
run one at a time in request order; a newer request cancels an older one in flight (its ctx.signal aborts, what it mounted is torn down first). A failed import, timeout or denied module leaves the previous module mounted and usable; a throwing mount or page shows the boundary error
and Retry, and a module can always be left. Every failure is logged through the SDK logger (scope app:<id>).

The module id in an address is only a key into the allow-list (ids are checked against MODULE_ID first); nothing else is ever passed to import().
show(id, { path, query }) / open(address, query) resolve to 'ok' | 'forbidden' | 'not-found' | 'error' | 'superseded'.

ctx (what a module and its pages receive) is documented in js/app/module.js.
```

## Rules

- The module list is the allow-list: the only code the host ever imports is the `load` function of an entry in `modules`, written as a literal `() => import('./modules/orders.js')`. A module id from an address is only a key; an unknown one shows not-found and calls no loader.
- A failed or slow import is retried once, then shows the danger alert with Retry and leaves the previous module mounted. A throwing `mount`, page or `unmount` is logged (scope `app:<id>`) and shown by the boundary; the other modules and the page keep working. The alert text is never the raw exception message (it can carry internal detail): it shows a generic "Something went wrong loading this part of the app. Try again." unless the error is marked `err.userFacing = true` (the same idea as PlainKit.Blazor's `IPkUserFacingException`) or the `app` log scope is at `debug`; the full detail is always logged.
- Register listeners, observers and timers only through `ctx.on`, `ctx.observe` and `ctx.after`: they end with the module or page, so it cannot leak. Never poll; use events.
- Long work is a task, never a hand-built toast: `ctx.tasks.run({ title, details, cancellable, retry, blocking, run(ctx) })` (`js/tasks.js`, `references/app.md`) shows it as a progress toast at the bottom end; when the page or module unmounts its cancellable tasks are cancelled and the others continue with their toast. The app passes `createTasks({ container })` to the host as `tasks`.
- Tell the user with `ctx.notify.info|success|warn|error(title, details?)` (`js/notify.js`: toasts in the same stack, error sticky, duplicates merged) and ask with `ctx.dialogs.confirm|alert|prompt|open(config)` (`js/dialogs.js`: a promise, one modal at a time, composed over pk-dialog); never hand-build a toast or dialog markup. Both are null in a host given no `notify` / `dialogs`.
- Access: `can` (app, entry, module, route) is checked at the router (`host.guard` as the router `guard`) and again at mount; only `true` allows, a throw denies. It is UX only: authorization stays on the server.
- Values from the address (`ctx.route.params`, `query`), storage and search are untrusted text: put them in the page with `textContent` or attributes, never as markup.

## Page context

Issue 670. The app works out where the reader is from the address and the route tree (nav row, breadcrumbs, title). When the address alone is ambiguous, a route declares it: `context: { ids?, crumbs?, title? }` or `({ path, params, query }) => that`. `ids` are nav ids from the top section to the current row, `crumbs` is `[{ label, href? }]` with module-relative hrefs, `title` names the page (alone it also renames the last crumb). Parts left out keep the address default; a throwing function is logged and the address decides. Standalone `pk-side-nav`/`pk-breadcrumb` and Blazor do not read it yet.

## Tasks

Long work runs through the task manager, shown as progress toasts (issue 372). The app passes `createTasks({ container })` to `createModuleHost` as `tasks` and a module uses `ctx.tasks.run(spec)`; a page without a module uses `createTasks({ busy: page.begin })` itself. It is not in `js/plainkit.js` (the base closure stays small): import `js/tasks.js`. Blazor: `IPkTasks` is a later step. The header of `js/tasks.js`, verbatim:

```text
A task manager with progress toasts (#372, step of the app framework #346): the framework runs long work and shows it as one toast per task in the bottom-end
pk-toast-stack (title, details, progress, Cancel, Retry). The consumer supplies only the work. Framework-free (no import but the logger), so a page, the module host
and a plain script share it.

  import { createTasks } from './tasks.js';
  const tasks = createTasks({ busy: page.begin });          // busy: label => end(), a blocking task holds a busy token (js/page.js)
  const h = tasks.run({
      title: 'Importing orders', details: 'Reading the file…',
      cancellable: true,                                    // it honours ctx.signal; only then does the toast offer Cancel and unmount cancel it
      retry: true,                                          // a failure offers Retry, which runs the same task again
      timeout: 60000,                                       // optional; the task ends failed ("Timed out") and its signal aborts
      blocking: false,                                      // true: also holds a busy token, so the page overlay shows
      async run(ctx) { for (…) { await step(ctx.signal); ctx.progress(i, n); ctx.details(`Row ${i} of ${n}`); } return result; },
  });
  h.id; h.state; h.cancel(); await h.promise;               // promise ALWAYS resolves: { state: 'done'|'failed'|'cancelled', value?, error? } (it never rejects)

States: queued -> running -> done | failed | cancelled (a queued task can be cancelled before it starts). At most `concurrency` (default 3, MAX_RUNNING) run at
once; the rest wait in order. ctx.progress(value, max = 100) sets a determinate bar (without a call the bar is indeterminate), ctx.details(text) the second line,
ctx.signal is an AbortSignal (aborted by cancel, timeout and destroy). Calls after the task ended are ignored. handle.cancel() works only for cancellable tasks
(returns whether it did): a task that ignores its signal is not offered a Cancel button.

Rendering is COMPOSED from existing elements, no element API: a pk-toast (heading = title, kind info while running, success, warning when cancelled, danger when failed,
duration 0 = sticky) whose default slot holds a details line and a pk-progress (inline, sm, label = the state, value readout when determinate) and whose action slot holds
pk-button Cancel or Retry. The toast is created once and UPDATED IN PLACE (properties and textContent), never re-created. The stack is the page's
pk-toast-stack[position=bottom-end] (the shell's), else one is created in `container` and removed again by destroy(); the stack's own `max` decides how many show.
After success the toast dismisses itself after DONE_DELAY (3 s, held while the pointer or focus is on it); cancelled after the same; a failure stays until dismissed
(close button or Escape). While a task is queued or running the toast cannot be dismissed (its close button is hidden), so the work is never lost from view.

Announcements (accessibility): the toast is a polite status region (role=status), the danger toast of a failure is role=alert (pk-toast's own rule). The bar's value
is not announced while the task runs (aria-live=off on it, the native progressbar semantics stay), and its final label and value are when it ends; the details line is written at most every DETAILS_INTERVAL (1 s) and the bar at most
every PROGRESS_INTERVAL (250 ms), the newest value wins, so a task calling ctx.progress in a loop costs one paint per interval and never a flood of announcements.
State changes (running, done, failed, cancelled) are always written at once.

Text is untrusted: title, details and errors go in with textContent and properties only, never markup. An error's own message is shown ONLY when it has
userFacing === true; any other failure shows FAILED_TEXT and the detail goes to the SDK logger (issue 378 discusses the policy).

Scopes. tasks.scope({ busy?, log? }) returns { run, end } for a page or module: tasks run through it are that scope's. end() (the page or module unmounts) CANCELS its
cancellable tasks and DETACHES the rest: they continue, keep their toast, and stop holding the scope's busy token. destroy() on the manager cancels what it can,
stops every timer and removes every toast it owns. No polling: progress is pushed by the task; the timers are one per task (paint), one for the timeout and one for the auto-dismiss, each cleared when done.
```

## Notifications and dialogs

Issue 373. `mountApp` passes `createNotify({ container })` and `createDialogs({ container })` to the host as `notify` and `dialogs`; a module uses `ctx.notify` and `ctx.dialogs`. Import `js/notify.js` and `js/dialogs.js` on their own for a page without the framework. Blazor: a later step. The header of `js/notify.js`, verbatim:

```text
Notifications (#373, step of the app framework #346): short messages as pk-toast in the bottom-end pk-toast-stack, the SAME stack the task manager uses (js/tasks.js,
toastStack()). Framework-free (no import but the logger and that helper), so a page, the module host and a plain script share it.

  import { createNotify } from './notify.js';
  const notify = createNotify({ container });
  notify.success('Order saved');                            // title, details?, opts?
  notify.error('Could not sync', 'The server did not answer.', { duration: 0 });
  const h = notify.warn('Low stock'); h.dismiss();

Kinds: info and success last INFO_DURATION (4 s), warn WARN_DURATION (8 s), error is sticky until dismissed; opts.duration overrides (0 = sticky). pk-toast runs
that timer itself and pauses it on hover and focus, so the manager owns no timer. The stack's own `max` decides how many show at once; the rest wait in order (the
stack's queue). `max` here (default MAX_KEPT) caps how many notifications are kept, shown plus waiting: past it the oldest is dismissed. An identical
notification (same kind and title) raised again within DEDUPE_WINDOW (2 s) while the first is still up is not stacked twice: the first is returned, its
details updated. Text is untrusted: heading and message are set as properties (the element renders them as text), never markup.

Scopes. notify.scope() returns { info, success, warn, error, end } for a page or module. end() (it unmounts) LETS ITS NOTIFICATIONS FINISH: a message such as "Order
saved" stays true after the page is left, so it keeps its toast and its timer; later calls through the ended scope are ignored (logged). destroy() on the manager
removes every toast it made and the stack when it created it. A bad call is logged, never thrown: a notification can never break the page that raised it.
```

The header of `js/dialogs.js`, verbatim:

```text
A modal factory over pk-dialog (#373, step of the app framework #346): confirm, alert, prompt and a small config-driven form dialog, each a promise, so a module
never writes dialog markup. Framework-free (no import but the logger).

  import { createDialogs } from './dialogs.js';
  const dialogs = createDialogs({ container });
  await dialogs.confirm({ heading: 'Delete order 1042?', message: 'This cannot be undone.', confirmLabel: 'Delete', danger: true });  // true | false
  await dialogs.alert({ heading: 'Export finished', message: '312 rows.' });                                                          // undefined
  await dialogs.prompt({ heading: 'Rename', label: 'Name', value: 'Draft', required: true, maxLength: 80 });                         // the text | null
  await dialogs.open({ heading: 'New customer', message: '…', template: 'tpl-id',                                                   // a <template> already in the DOM, cloned
      fields: [{ name: 'email', label: 'Email', type: 'email', required: true }], actions: [{ label: 'Create', value: 'ok' }],
      validate: values => (values.email.includes('@') ? '' : 'Enter an email address.') });                                           // { action, values } | null (cancelled)
  await dialogs.confirm({ heading: 'Delete?', danger: true, onConfirm: async () => (await remove()) ? null : 'Refused: referenced by other records.' });
  // onConfirm (confirm, prompt, open) runs when the confirming button is pressed, with the values and the pressed button's value: the dialog stays open, that button busy and the rest inert; null/'' closes it
  // (resolved as confirmed), a message stays inside the dialog (cancel or retry); a throw is logged and shows a generic message. A field `{ type: 'checkbox' }` is a pk-checkbox: its value is 'true' | 'false'.

One dialog is open at a time for the whole app (one manager per app): a call while one is open QUEUES and opens when it closes. Policy: Escape and the close
button cancel; the backdrop does not (pk-dialog's house rule) unless config.backdrop is true; config.blocking removes all three, so only an action closes it.
Focus: the native modal traps it and makes the page inert; the first field (or the safe button: Cancel of a danger confirm, else the confirming one) gets it,
and on close it goes back to the element that had it when the dialog opened. Full screen on a phone is pk-dialog's own (below 640px).
config.tint ('product' | 'archived') and config.maxWidth (pixels, before the viewport clamp) are pk-dialog's own tint and max-width.
Text is untrusted: every string goes in with textContent and properties; a template is cloned, never parsed from a string.

Scopes. dialogs.scope() returns { confirm, alert, prompt, open, end }. end() (the page or module unmounts) CANCELS its dialogs: the open one closes and resolves as
cancelled (false / undefined / null), the queued ones resolve the same without opening. destroy() does that for all. A throwing config, template or validate()
is logged and the dialog resolves as cancelled (validate throwing keeps the dialog open); the shell never sees the throw.
```

## Exports

- `dist/js/app.js`: `BUILT_IN_PAGE_TYPES`, `MODULE_ID`, `createModuleHost`, `defineModule`, `moduleFromMount`, `mountApp`, `mountPage`, `paintNav`, `registerLayout`, `registerPageType`
- `dist/js/app/module.js`: `BUILT_IN_PAGE_TYPES`, `MODULE_ID`, `defineModule`, `layoutFor`, `loadChunk`, `moduleFromMount`, `mountPage`, `pageTypeFor`, `registerLayout`, `registerPageType`
- `dist/js/app/host.js`: `createModuleHost`
- `dist/js/app/app.js`: `mountApp`
- `dist/js/app/config.js`: `readConfig`, `readFooter`
- `dist/js/app/nav.js`: `MAX_ALL`, `MAX_TOP`, `absolute`, `locate`, `markCurrent`, `navOf`, `pageContext`, `paintNav`, `routeContext`
- `dist/js/tasks.js`: `DETAILS_INTERVAL`, `DONE_DELAY`, `FAILED_TEXT`, `MAX_RUNNING`, `PROGRESS_INTERVAL`, `createTasks`, `failureText`, `toastStack`
- `dist/js/notify.js`: `DEDUPE_WINDOW`, `INFO_DURATION`, `MAX_KEPT`, `WARN_DURATION`, `createNotify`
- `dist/js/dialogs.js`: `createDialogs`

## Use it

```js
import { createModuleHost, defineModule, moduleFromMount } from './plainkit/js/app.js';
import { mountLogSettings } from './plainkit/modules/log-settings/log-settings.js';

const orders = defineModule({
    id: 'orders', title: 'Orders',
    routes: [{ path: '/', page: 'custom', config: { mount: (host, ctx) => { host.textContent = 'Orders'; ctx.on(window, 'resize', () => {}); } } }, { path: '*', page: 'not-found' }],
});
// An existing mountX tool module becomes a module unchanged:
const logging = moduleFromMount(mountLogSettings, { id: 'logging', title: 'Logging', options: { height: 'fill' } });

const host = createModuleHost(document.getElementById('app'), {
    modules: [
        { id: 'orders', title: 'Orders', load: async () => orders },
        { id: 'logging', title: 'Logging', load: async () => logging },
    ],
});
host.open('/orders');   // show(id, { path, query }) is the same by module id
```

One page, no module or app at all: `mountPage`.

```js
import { mountPage } from './plainkit/js/app.js';

const page = await mountPage(document.getElementById('app'), {
    type: 'not-found',
    config: { label: 'Go home', action: () => { location.hash = '#/'; } },
});
page.destroy();   // unmounts the page type and undoes anything its ctx.on/observe/after registered
```

## Blazor

No Blazor surface in this step. The Blazor mapping (`IPkModule`, `PkApp`) is a later step of the app-framework work.
