@ultimat3/render 29.0.0 → 29.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -30,7 +30,7 @@ axiom 6). Never `cli` (upward).
30
30
  |---|---|
31
31
  | `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
32
32
  | `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
33
- | `defineRoute` shape | exactly the contract's 12 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot; `navigation`, `'prefetch' \| 'document'`, the twelfth — read by the server's navigation gate, refused off a `navigation.client` surface at boot, `X_ROUTE_NAVIGATION_INVALID`). New route *metadata* goes inside `meta`. |
33
+ | `defineRoute` shape | exactly the contract's 12 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot; `navigation`, the twelfth — `route-navigation.ts`, read only through `navigationModeOf`/`navigationPrefetches`; refused off a `navigation.client` surface at boot, `X_ROUTE_NAVIGATION_INVALID`). New route *metadata* goes inside `meta`. |
34
34
  | `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
35
35
  | `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
36
36
  | A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
@@ -115,10 +115,11 @@ axiom 6). Never `cli` (upward).
115
115
  | An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
116
116
  | `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
117
117
  | Client navigation | `navigation*.ts` — soft navigation over server-rendered documents, NOT a mode: the router renders nothing, it swaps the document the route already rendered. Rules are pure (`navigation-rules.ts`: `linkVerdict`, `formVerdict`, `responseVerdict`, `mayPrefetch`) and unit-tested; the DOM half (`navigation-swap.ts`, `navigation.ts`) is proven in Chrome by `packages/cli/e2e/client-navigation-*.e2e.test.ts`. `./navigation` (`navigation-entry.ts`) is the script the CLI builds; importing it starts it, and a document without `ultimate-navigation` is never intercepted. Route-presented modals (`navigation: 'modal'`): the hash grammar and verdicts are pure (`navigation-modal-rules.ts`), the `<dialog>` is `navigation-modal.ts`, styled by `@ultimat3/ui`'s `global.scss` under `NAVIGATION_MODAL_ATTRIBUTE` (pinned by `navigation-modal-style.test.ts` — render never imports ui), proven by `client-navigation-modal.e2e.test.ts`. |
118
+ | Modal on the click | `navigation-modal-frame.ts`: `modal.loading(url)` in `navigate`'s synchronous prefix opens the frame off `NAVIGATION_MODALS_META`; the answer's meta still decides. Closing aborts (`abandon`); the router `release()`s before its own dismissals; `settle()` in `finally`. Never `showModal()` where popovers exist, never a toggle on `<html>`/`<body>` ([why](../../docs/history/render.md#the-modal-that-opens-on-the-click)) |
118
119
  | Router ↔ the page's lifecycle | `navigation-scroll.ts` owns `scrollRestoration = 'manual'` and RESTORES the entry's saved offset at start, before its first save (a reload, or Back after a full load); `navigation-tabs.ts` owns the `BroadcastChannel`, closed on `pagehide` (it blocks the back/forward cache), reopened and the cache emptied on a persisted `pageshow`. A sheet `loadStylesheets` appends is `owned` at once, so an aborted navigation's sheets are retired by the next swap. A view transition is tracked by identity: an older one finishing never clears the running one. |
119
120
  | Router ↔ islands | ONE bootstrap: the swap re-runs the inline hydration runtime, whose `each` visits a root once per tab (`el.__v`). Never add a second mount path for the router. Dispose = call what `el.__x` resolved to. |
120
121
  | Router head diff | only elements a SERVER document put in `<head>` (`documentHead`'s set) may be removed; a script-added one is the tab's. Scripts and stylesheets are never diffed. |
121
- | Router: nothing is sent twice | every router fetch is `cache: 'no-cache'` — the browser's HTTP cache never answers, the router's own is the only reuse layer (#693; `navigation-fetch.test.ts`, `client-navigation-cache.e2e.test.ts`). The router follows no redirect (`redirect: 'manual'`; the server hands it over as `204` + `x-ultimate-location`), never re-submits a POST (a failure is `ultimate:navigation-error`), and hands a 2xx non-page answer to the browser from the bytes it already has. A 4xx/5xx non-page answer (an offline worker's empty 503) is never handed over (it became a `blob:` URL): a POST fails, a GET is loaded by the browser — the ONE deliberate re-send, as there is nothing to show (`responseVerdict`). Its cache never answers with a non-2xx, a hand-over, or a `no-store` page older than 5 s, and is emptied by any POST (when sent AND when it settles), `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
122
+ | Router: nothing is sent twice | every fetch `cache: 'no-cache'` (#693), `redirect: 'manual'`, a POST never re-sent; the cache answers only a reusable 2xx and is emptied by every write signal ([full rule](../../docs/history/render.md#router-nothing-is-sent-twice)) |
122
123
 
123
124
  Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
124
125
  Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
package/README.md CHANGED
@@ -144,7 +144,20 @@ On a surface listed in `app.config.ts`'s `navigation.client` only (elsewhere it
144
144
  |---|---|
145
145
  | `'prefetch'` | may also be fetched on hover/focus — a GET that does nothing but render |
146
146
  | `'document'` | always a real document load; carries no router |
147
- | `'modal'` | `As of 26.1.0`. Shown OVER the page a visitor is on, in a native `<dialog>`, addressed by the hash (`/runs#/runs/new`); reload, Back/Forward and a pasted URL reopen it, a stale hash is just the page. A full load of its own URL is still the whole page — the server renders one document for both and names it a modal (`NAVIGATION_PRESENTATION_META`) |
147
+ | `'modal'` | `As of 26.1.0`. Shown OVER the page a visitor is on, in a native `<dialog>`, addressed by the hash (`/runs#/runs/new`); reload, Back/Forward and a pasted URL reopen it, a stale hash is just the page. A full load of its own URL is still the whole page — the server renders one document for both and names it a modal (`NAVIGATION_PRESENTATION_META`). Never prefetched |
148
+ | `{ modal: true, prefetch: 'intent' }` | `As of 29.1.0`. A `'modal'` whose GET only reads (a peek, a detail view): also fetched on hover/focus/touchstart, as `'prefetch'` is. Exactly these two keys and values (`isRouteNavigation`); read it with `navigationModeOf` / `navigationPrefetches`, never by comparing against a string |
149
+
150
+ **A modal opens on the click** (`As of 29.1.0`). Every document of a surface with a modal route
151
+ names those routes (`<meta name="ultimate-modals">`, `modalRouteList` — most specific first, with
152
+ the pages that outrank one as `!<pattern>`) and the frame's words in its locale (`ui.close`,
153
+ `ui.loading`). A click on a link to one, `openModal(path)` or a hash addressing one opens the
154
+ dialog in the same task: `[data-x-modal-body][aria-busy="true"]` with a skeleton, and a close button
155
+ (`[data-x-modal-close]`, a `type="button"`, never a `<form>`). The answer fills it; closing it first aborts
156
+ the request and pushes nothing; an answer that is no modal takes it down and is handled as before.
157
+ The dialog is opened with `show()` + `showPopover()`, never `showModal()`, whose inert document
158
+ restyles every element beneath (124 ms of RecalcStyle on a 7,011-element page at 4x CPU, against
159
+ 2.6 ms) — `aria-modal`, focus containment and Escape are the router's. Numbers and rules:
160
+ [Client Navigation](../../wiki/Client-Navigation.md#opening-on-the-click).
148
161
 
149
162
  Declared on the route, never on a link: the route is the one place that covers every way of
150
163
  reaching a page — a link, a redirect after a POST, a pasted hash.
@@ -581,6 +594,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
581
594
  | `navigate`‡, `refresh`‡, `openModal`‡, `closeModal`‡, `NavigateToOptions`‡, `NavigationModalPathInvalidError`‡ | navigating from code: the router when the page has one, the browser's own load when not. Import from `/client` in an island |
582
595
  | `disposeIslands` (on `/client` only) | dispose the islands mounted under an element: what the router does on a swap, for an island that drops markup holding other islands |
583
596
  | `modalAddress`, `modalLocation`, `addressOf`, `presentation`, `modalHistory`, `leaveModal`, `NAVIGATION_PRESENTATION_META`, `NAVIGATION_MODAL_ATTRIBUTE` | route-presented modals (`navigation: 'modal'`): the hash grammar, when an answer is a modal, and what leaving one does to history |
597
+ | `declaresModal`, `parseModalRoutes`, `modalRouteList`, `ModalRoutes`, `NAVIGATION_MODALS_META`, `NAVIGATION_MODAL_BODY_ATTRIBUTE`, `NAVIGATION_MODAL_CLOSE_ATTRIBUTE`, `NAVIGATION_MODAL_SKELETON_ATTRIBUTE` | the modal that opens on the click: the meta naming a surface's modal routes (composed by the server, read by the router) and the frame's attributes, styled by `@ultimat3/ui` |
598
+ | `isRouteNavigation`, `navigationModeOf`, `navigationPrefetches`, `RouteNavigationDeclaration`, `RouteModalPrefetch` | `navigation:` read back: the four spellings, which mode, prefetched or not |
584
599
  | `clientPathStyleTags`, `CLIENT_PATH_STYLE_META` | `<meta name="ultimate-path-style">` — the action path style the server serves, for the browser's typed client. No tag for `'resource'`, the default |
585
600
 
586
601
  ## Notes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "29.0.0",
3
+ "version": "29.2.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,11 +40,11 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/cache": "29.0.0",
44
- "@ultimat3/core": "29.0.0",
45
- "@ultimat3/http": "29.0.0",
46
- "@ultimat3/i18n": "29.0.0",
47
- "@ultimat3/seo": "29.0.0",
43
+ "@ultimat3/cache": "29.2.0",
44
+ "@ultimat3/core": "29.2.0",
45
+ "@ultimat3/http": "29.2.0",
46
+ "@ultimat3/i18n": "29.2.0",
47
+ "@ultimat3/seo": "29.2.0",
48
48
  "sass": "1.105.1"
49
49
  }
50
50
  }
package/src/errors.ts CHANGED
@@ -54,7 +54,7 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
54
54
  X_ASSET_MISSING: 'a page names a site asset the app does not have',
55
55
  X_ROUTE_POST_INVALID: "a page route's post binding cannot be mounted",
56
56
  X_ROUTE_NAVIGATION_INVALID:
57
- "a route's navigation is not 'prefetch', 'document' or 'modal', or its surface has no client navigation",
57
+ "a route's navigation is not 'prefetch', 'document', 'modal' or { modal: true, prefetch: 'intent' }, or its surface has no client navigation",
58
58
  X_NAVIGATION_MODAL_PATH_INVALID:
59
59
  'openModal was given something that is not a path of this origin',
60
60
  };
@@ -208,8 +208,8 @@ export class RoutePostInvalidError extends UltimateError {
208
208
 
209
209
  /**
210
210
  * `defineRoute({ navigation })` that says nothing a process can honour: a value other than
211
- * `'prefetch'`, `'document'` or `'modal'`, or — at boot — a page whose surface has no client
212
- * navigation.
211
+ * `'prefetch'`, `'document'`, `'modal'` or `{ modal: true, prefetch: 'intent' }`, or — at boot — a
212
+ * page whose surface has no client navigation.
213
213
  */
214
214
  export class RouteNavigationInvalidError extends UltimateError {
215
215
  static readonly code = 'X_ROUTE_NAVIGATION_INVALID' as const;
@@ -217,7 +217,7 @@ export class RouteNavigationInvalidError extends UltimateError {
217
217
  super({
218
218
  code: RouteNavigationInvalidError.code,
219
219
  cause,
220
- fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' | 'modal' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
220
+ fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' | 'modal' | { modal: true, prefetch: 'intent' } }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
221
221
  });
222
222
  }
223
223
  }
package/src/index.ts CHANGED
@@ -117,15 +117,21 @@ export { closeModal, navigate, openModal, refresh } from './navigation-api';
117
117
  /** Client-side navigation over server-rendered documents — `navigation: { client }` surfaces. */
118
118
  export type { CachedDocument, NavigationCache, NavigationCacheOptions } from './navigation-cache';
119
119
  export { cacheKey, NAVIGATION_CACHE_MAX_ENTRIES, navigationCache } from './navigation-cache';
120
- export type { Presentation, PresentationFacts } from './navigation-modal-rules';
120
+ export type { ModalRoutes, Presentation, PresentationFacts } from './navigation-modal-rules';
121
121
  export {
122
122
  addressOf,
123
+ declaresModal,
123
124
  leaveModal,
124
125
  modalAddress,
125
126
  modalHistory,
126
127
  modalLocation,
127
128
  NAVIGATION_MODAL_ATTRIBUTE,
129
+ NAVIGATION_MODAL_BODY_ATTRIBUTE,
130
+ NAVIGATION_MODAL_CLOSE_ATTRIBUTE,
131
+ NAVIGATION_MODAL_SKELETON_ATTRIBUTE,
132
+ NAVIGATION_MODALS_META,
128
133
  NAVIGATION_PRESENTATION_META,
134
+ parseModalRoutes,
129
135
  presentation,
130
136
  } from './navigation-modal-rules';
131
137
  export type {
@@ -160,7 +166,7 @@ export {
160
166
  reusable,
161
167
  } from './navigation-rules';
162
168
  export type { ClientNavigationHead } from './navigation-tags';
163
- export { clientNavigationTags } from './navigation-tags';
169
+ export { clientNavigationTags, modalRouteList } from './navigation-tags';
164
170
  export type {
165
171
  MountedRouteInput,
166
172
  RegisterRouteInput,
@@ -200,18 +206,23 @@ export type {
200
206
  RouteMetaAsyncFn,
201
207
  RouteMetaContext,
202
208
  RouteMetaFn,
203
- RouteNavigationMode,
204
209
  RouteParams,
205
210
  } from './route';
206
- export {
207
- DEFAULT_ISLAND_HYDRATE,
208
- defineRoute,
209
- isRouteConfig,
210
- ROUTE_NAVIGATION_MODES,
211
- } from './route';
211
+ export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig } from './route';
212
212
  export type { RouteComponent } from './route-component';
213
213
  export { pageComponentOf } from './route-component';
214
214
  export { metaContextFor, routeDataFor } from './route-data';
215
+ export type {
216
+ RouteModalPrefetch,
217
+ RouteNavigationDeclaration,
218
+ RouteNavigationMode,
219
+ } from './route-navigation';
220
+ export {
221
+ isRouteNavigation,
222
+ navigationModeOf,
223
+ navigationPrefetches,
224
+ ROUTE_NAVIGATION_MODES,
225
+ } from './route-navigation';
215
226
  export type { CompiledPattern } from './route-pattern';
216
227
  export { compilePattern } from './route-pattern';
217
228
  export { noStore, routeNoStoreOf, routeStatusOf, withStatus } from './route-status';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The route-presented modal's `<dialog>` as DOM: its frame (a body region, a close button), how it
3
+ * is opened into the top layer and kept modal, and where focus goes. `navigation-modal.ts` decides
4
+ * when; this file only builds, opens, fills and hides.
5
+ *
6
+ * Opened with `show()` + `showPopover()`, NOT `showModal()`: a modal dialog makes the rest of the
7
+ * document inert, and Chrome computes inertness in style — every element of the page beneath has
8
+ * its style recalculated (7,011 elements, 4x CPU: 124 ms of RecalcStyle for `showModal()`, 2.6 ms
9
+ * this way; `inert` on one wrapper costs the same 111 ms). The popover is the top layer and the
10
+ * `::backdrop`; `aria-modal` tells assistive technology the page beneath is out of reach; the
11
+ * backdrop takes every press, and Tab wraps inside. A browser without popovers gets `showModal()`.
12
+ */
13
+
14
+ import {
15
+ type ModalRoutes,
16
+ NAVIGATION_MODAL_ATTRIBUTE,
17
+ NAVIGATION_MODAL_BODY_ATTRIBUTE,
18
+ NAVIGATION_MODAL_CLOSE_ATTRIBUTE,
19
+ NAVIGATION_MODAL_SKELETON_ATTRIBUTE,
20
+ } from './navigation-modal-rules';
21
+
22
+ export type ModalLabels = Pick<ModalRoutes, 'close' | 'loading'>;
23
+
24
+ /** What may take focus inside the dialog — filtered for `disabled` and `tabindex="-1"` below. */
25
+ const FOCUSABLE =
26
+ 'a[href], area[href], button, input, select, textarea, [tabindex], [contenteditable]';
27
+
28
+ const supportsPopover = (el: HTMLElement): boolean =>
29
+ typeof (el as { showPopover?: unknown }).showPopover === 'function';
30
+
31
+ /** The region the modal page's `<main>` goes in. */
32
+ export const bodyOf = (dialog: HTMLDialogElement): Element =>
33
+ dialog.querySelector(`[${NAVIGATION_MODAL_BODY_ATTRIBUTE}]`) ?? dialog;
34
+
35
+ /**
36
+ * A dialog with its body region and, when the document named the words for it, its close button:
37
+ * a plain `type="button"` that closes as Escape does (`onClose`) — never a `<form>`, which a page's
38
+ * own `dialog form` would find first. AFTER the body, so the first focusable thing is the page's.
39
+ */
40
+ export function createModal(
41
+ doc: Document,
42
+ labels: ModalLabels | undefined,
43
+ onClose: () => void,
44
+ ): HTMLDialogElement {
45
+ const dialog = doc.createElement('dialog');
46
+ dialog.setAttribute(NAVIGATION_MODAL_ATTRIBUTE, '');
47
+ // Light dismiss: Escape AND a press outside close it, as every modal does (`Dialog` included).
48
+ dialog.setAttribute('closedby', 'any');
49
+ dialog.setAttribute('aria-modal', 'true');
50
+ // Focusable itself, so focus is inside from the first frame — before there is content to take it.
51
+ dialog.setAttribute('tabindex', '-1');
52
+ const body = doc.createElement('div');
53
+ body.setAttribute(NAVIGATION_MODAL_BODY_ATTRIBUTE, '');
54
+ dialog.append(body);
55
+ if (labels !== undefined) {
56
+ const close = doc.createElement('button');
57
+ close.setAttribute('type', 'button');
58
+ close.setAttribute(NAVIGATION_MODAL_CLOSE_ATTRIBUTE, '');
59
+ close.setAttribute('aria-label', labels.close);
60
+ close.addEventListener('click', onClose);
61
+ dialog.append(close);
62
+ }
63
+ return dialog;
64
+ }
65
+
66
+ /** The body as "on its way": busy, a skeleton in place of content that is not there yet. */
67
+ export function markBusy(dialog: HTMLDialogElement, empty: boolean): void {
68
+ const body = bodyOf(dialog);
69
+ body.setAttribute('aria-busy', 'true');
70
+ if (!empty) return;
71
+ const skeleton = dialog.ownerDocument.createElement('div');
72
+ skeleton.setAttribute(NAVIGATION_MODAL_SKELETON_ATTRIBUTE, '');
73
+ skeleton.setAttribute('aria-hidden', 'true');
74
+ body.replaceChildren(skeleton);
75
+ }
76
+
77
+ export const markIdle = (dialog: HTMLDialogElement): void =>
78
+ bodyOf(dialog).removeAttribute('aria-busy');
79
+
80
+ /** Into the top layer, without the inert document (see the header); `showModal()` without popovers. */
81
+ export function raiseModal(dialog: HTMLDialogElement): void {
82
+ if (!supportsPopover(dialog)) {
83
+ dialog.showModal();
84
+ return;
85
+ }
86
+ dialog.setAttribute('popover', 'manual');
87
+ dialog.show();
88
+ dialog.showPopover();
89
+ }
90
+
91
+ /** Out of the top layer: the popover first, so no frame shows a backdrop over nothing. */
92
+ export function lowerModal(dialog: HTMLDialogElement): void {
93
+ if (dialog.hasAttribute('popover') && supportsPopover(dialog)) {
94
+ try {
95
+ dialog.hidePopover();
96
+ } catch {
97
+ // Already hidden (removed, or hidden by the browser): nothing is showing.
98
+ }
99
+ }
100
+ if (dialog.open) dialog.close();
101
+ }
102
+
103
+ const focusable = (dialog: HTMLDialogElement): HTMLElement[] =>
104
+ [...dialog.querySelectorAll(FOCUSABLE)].filter(
105
+ (el) =>
106
+ !el.hasAttribute('disabled') &&
107
+ el.getAttribute('tabindex') !== '-1' &&
108
+ el.getAttribute('type') !== 'hidden',
109
+ ) as HTMLElement[];
110
+
111
+ /** As `showModal()` focuses: `[autofocus]`, else the first focusable thing, else the dialog. */
112
+ export function focusInto(dialog: HTMLDialogElement): void {
113
+ const target =
114
+ (dialog.querySelector('[autofocus]') as HTMLElement | null) ?? focusable(dialog)[0] ?? dialog;
115
+ target.focus?.({ preventScroll: true });
116
+ }
117
+
118
+ /**
119
+ * What `showModal()` gave for free and a popover does not: Tab stays inside, and — where the
120
+ * browser has no `closedby` — Escape closes. Returns the remover. Escape on something that is
121
+ * itself a dialog or popover inside (a `Dialog`, a menu) is that one's.
122
+ */
123
+ export function keepModal(
124
+ doc: Document,
125
+ dialog: HTMLDialogElement,
126
+ onEscape: () => void,
127
+ ): () => void {
128
+ if (!dialog.hasAttribute('popover')) return () => undefined;
129
+ const escapes = !('closedBy' in dialog);
130
+ const onKey = (event: KeyboardEvent): void => {
131
+ if (event.defaultPrevented) return;
132
+ const at = event.target as Element | null;
133
+ const inner = at?.closest?.('dialog[open], [popover]') ?? null;
134
+ if (event.key === 'Escape' && escapes && (inner === null || inner === dialog)) {
135
+ event.preventDefault();
136
+ onEscape();
137
+ return;
138
+ }
139
+ if (event.key !== 'Tab' || (inner !== null && inner !== dialog)) return;
140
+ const all = focusable(dialog);
141
+ const first = all[0];
142
+ const last = all[all.length - 1];
143
+ if (first === undefined || last === undefined) {
144
+ event.preventDefault();
145
+ dialog.focus();
146
+ return;
147
+ }
148
+ const active = doc.activeElement;
149
+ const inside = active !== null && dialog.contains(active);
150
+ if (
151
+ event.shiftKey ? !inside || active === first || active === dialog : !inside || active === last
152
+ ) {
153
+ event.preventDefault();
154
+ (event.shiftKey ? last : first).focus();
155
+ }
156
+ };
157
+ doc.addEventListener('keydown', onKey as EventListener);
158
+ return () => doc.removeEventListener('keydown', onKey as EventListener);
159
+ }
@@ -16,6 +16,81 @@ export const NAVIGATION_PRESENTATION_META = 'ultimate-presentation';
16
16
  /** On the `<dialog>` the router opens. `@ultimat3/ui`'s `global.scss` styles it, by this name. */
17
17
  export const NAVIGATION_MODAL_ATTRIBUTE = 'data-x-modal';
18
18
 
19
+ /**
20
+ * `<meta name="ultimate-modals" content='{"routes":[…],"close":"…","loading":"…"}'>` — on every
21
+ * opted-in document of a surface with a `navigation: 'modal'` route: the patterns of the routes
22
+ * that present as a modal, so a click opens the dialog BEFORE their document arrives, and the
23
+ * frame's two words in the document's own locale. Still not the router's decision: the answer's
24
+ * `NAVIGATION_PRESENTATION_META` is, and an answer that is no modal takes the frame down.
25
+ */
26
+ export const NAVIGATION_MODALS_META = 'ultimate-modals';
27
+ /** The dialog's content region; `aria-busy="true"` on it while the modal's document is on its way. */
28
+ export const NAVIGATION_MODAL_BODY_ATTRIBUTE = 'data-x-modal-body';
29
+ /** The placeholder inside a busy body: no words, so nothing to translate; hidden from AT. */
30
+ export const NAVIGATION_MODAL_SKELETON_ATTRIBUTE = 'data-x-modal-skeleton';
31
+ /** The frame's close button — a `<form method="dialog">` button, so it closes without the router. */
32
+ export const NAVIGATION_MODAL_CLOSE_ATTRIBUTE = 'data-x-modal-close';
33
+
34
+ /** What the modals meta says. `routes`: most specific first; a `!` prefix is a page that is not. */
35
+ export interface ModalRoutes {
36
+ readonly routes: readonly string[];
37
+ /** The close button's accessible name. */
38
+ readonly close: string;
39
+ /** The busy dialog's accessible name until the modal's own heading names it. */
40
+ readonly loading: string;
41
+ }
42
+
43
+ /** The meta's content read back, or `undefined` for anything malformed — never a throw. */
44
+ export function parseModalRoutes(content: string | null): ModalRoutes | undefined {
45
+ if (content === null) return undefined;
46
+ let said: unknown;
47
+ try {
48
+ said = JSON.parse(content);
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ if (typeof said !== 'object' || said === null) return undefined;
53
+ const { routes, close, loading } = said as Record<string, unknown>;
54
+ if (!Array.isArray(routes) || !routes.every((r) => typeof r === 'string')) return undefined;
55
+ if (typeof close !== 'string' || typeof loading !== 'string') return undefined;
56
+ return { routes, close, loading };
57
+ }
58
+
59
+ /** One pattern (`/runs/:id/peek`, `/docs/*rest`) against a pathname's decoded segments. */
60
+ function patternMatches(pattern: string, segments: readonly string[]): boolean {
61
+ const parts = pattern.split('/').filter((part) => part.length > 0);
62
+ for (const [i, part] of parts.entries()) {
63
+ if (part.startsWith('*')) return true;
64
+ const segment = segments[i];
65
+ if (segment === undefined) return false;
66
+ if (!part.startsWith(':') && part !== segment) return false;
67
+ }
68
+ return parts.length === segments.length;
69
+ }
70
+
71
+ /**
72
+ * Whether `url`'s path is a route the meta names a modal: the FIRST pattern that matches decides,
73
+ * as the list is most specific first — a more specific page (`!/runs/new`) beats `/runs/:id`.
74
+ * A path that does not decode is no modal: the router then waits for the answer, as before.
75
+ */
76
+ export function declaresModal(url: string, routes: readonly string[]): boolean {
77
+ if (!URL.canParse(url)) return false;
78
+ let segments: string[];
79
+ try {
80
+ segments = new URL(url).pathname
81
+ .split('/')
82
+ .filter((segment) => segment.length > 0)
83
+ .map(decodeURIComponent);
84
+ } catch {
85
+ return false;
86
+ }
87
+ for (const route of routes) {
88
+ const not = route.startsWith('!');
89
+ if (patternMatches(not ? route.slice(1) : route, segments)) return !not;
90
+ }
91
+ return false;
92
+ }
93
+
19
94
  /**
20
95
  * The modal a hash addresses — `#/runs/new?x=1` → `/runs/new?x=1` — or `null` for every other
21
96
  * fragment (`#below`, `#`, `#//host`, one that leaves the origin). NEVER throws: a stale or
@@ -1,21 +1,36 @@
1
1
  /**
2
2
  * The route-presented modal: ONE native `<dialog>` over the page on screen, holding the `<main>` of
3
3
  * a page whose route declared `navigation: 'modal'`. The page beneath is never touched — its
4
- * islands, socket and state stay live, `showModal()` makes it inert — and the modal is addressed by
5
- * the URL's hash (`/runs#/runs/new`), so a reload, Back/Forward and a pasted link reopen it.
6
- * `navigation.ts` decides WHEN (`navigation-modal-rules.ts`); this file only shows and hides.
4
+ * islands, socket and state stay live, out of reach behind the top layer — and the modal is
5
+ * addressed by the URL's hash (`/runs#/runs/new`), so a reload, Back/Forward and a pasted link
6
+ * reopen it. A route the document names a modal (`NAVIGATION_MODALS_META`) opens on the click, as a
7
+ * busy frame the answer fills. `navigation.ts` decides WHEN (`navigation-modal-rules.ts`); this
8
+ * file only shows and hides, and `navigation-modal-frame.ts` is the dialog's DOM.
7
9
  */
8
10
 
9
11
  import { disposeIslands } from './island-dispose';
10
12
  import { metaOf } from './navigation-fetch';
11
13
  import { entryOf, STATE_KEY, withoutFragment } from './navigation-history';
12
14
  import {
15
+ bodyOf,
16
+ createModal,
17
+ focusInto,
18
+ keepModal,
19
+ lowerModal,
20
+ type ModalLabels,
21
+ markBusy,
22
+ markIdle,
23
+ raiseModal,
24
+ } from './navigation-modal-frame';
25
+ import {
26
+ declaresModal,
13
27
  leaveModal,
14
28
  modalLocation,
15
- NAVIGATION_MODAL_ATTRIBUTE,
29
+ NAVIGATION_MODALS_META,
16
30
  NAVIGATION_PRESENTATION_META,
17
31
  type Presentation,
18
32
  type PresentationFacts,
33
+ parseModalRoutes,
19
34
  presentation,
20
35
  } from './navigation-modal-rules';
21
36
  import { runScripts } from './navigation-swap';
@@ -43,6 +58,16 @@ export interface ModalController {
43
58
  landed: string,
44
59
  history: 'push' | 'replace' | 'none',
45
60
  ): Promise<void>;
61
+ /**
62
+ * A GET of `url` is starting: when the document names it a modal, the dialog opens NOW — a busy
63
+ * frame, or the open modal marked busy — and `true` is answered. Nothing is pushed until
64
+ * `present`; closing it before then aborts the request (the controller's `abandon`).
65
+ */
66
+ loading(url: string): boolean;
67
+ /** A newer navigation took over: the frame is no longer this request's to abort. */
68
+ release(): void;
69
+ /** The request ended without `present`: a frame with no content goes, an open modal is idle. */
70
+ settle(): void;
46
71
  /** Escape, a native close, a link to the page beneath: Back when this router pushed the entry. */
47
72
  close(): void;
48
73
  /** The hash names no modal for this tab: dropped in place, the page stays. */
@@ -71,23 +96,33 @@ function rebase(root: Element, landed: string): void {
71
96
 
72
97
  /** Named by its first heading, as `@ultimat3/ui`'s `Dialog` is by its title. */
73
98
  function label(dialog: HTMLDialogElement, title: string): void {
74
- const heading = dialog.querySelector('h1, h2');
99
+ const heading = bodyOf(dialog).querySelector('h1, h2');
75
100
  if (heading === null) {
101
+ dialog.removeAttribute('aria-labelledby');
76
102
  dialog.setAttribute('aria-label', title);
77
103
  return;
78
104
  }
79
105
  if (heading.id === '') heading.id = 'x-modal-title';
106
+ dialog.removeAttribute('aria-label');
80
107
  dialog.setAttribute('aria-labelledby', heading.id);
81
108
  }
82
109
 
110
+ /**
111
+ * `abandon` aborts the request a frame is waiting on — called when the visitor closes it (Escape,
112
+ * the close button, Back) before its answer arrived, never when the router itself moves on.
113
+ */
83
114
  export function modalController(
84
115
  win: Window,
85
116
  rendered: () => string,
86
117
  ran: Set<string>,
87
118
  head: WeakSet<Element>,
119
+ abandon: () => void = () => undefined,
88
120
  ): ModalController {
89
121
  const doc = win.document;
90
122
  let dialog: HTMLDialogElement | undefined;
123
+ /** A request is on its way to fill the dialog: closing it now aborts that request. */
124
+ let pending = false;
125
+ let unkeep: () => void = () => undefined;
91
126
  /** The open modal's path (`/runs/new`), as the URL bar shows it. */
92
127
  let openAt: string | undefined;
93
128
  let opener: Element | null = null;
@@ -102,13 +137,18 @@ export function modalController(
102
137
  };
103
138
 
104
139
  const dismiss = (): void => {
140
+ if (pending) {
141
+ pending = false;
142
+ abandon();
143
+ }
105
144
  const open = dialog;
106
145
  if (open === undefined) return;
107
146
  dialog = undefined;
108
147
  openAt = undefined;
109
148
  leaving = false;
149
+ unkeep();
110
150
  disposeIslands(open);
111
- if (open.open) open.close();
151
+ lowerModal(open);
112
152
  open.remove();
113
153
  doc.title = title;
114
154
  // Back where the visitor was: the link or button that opened it, when it is still there.
@@ -127,6 +167,15 @@ export function modalController(
127
167
  };
128
168
 
129
169
  const close = (): void => {
170
+ // A frame with no content yet: nothing was pushed, so closing is the request abandoned.
171
+ if (openAt === undefined && pending) {
172
+ dismiss();
173
+ return;
174
+ }
175
+ if (pending) {
176
+ pending = false;
177
+ abandon();
178
+ }
130
179
  if (openAt === undefined || leaving) return;
131
180
  if (owned()) {
132
181
  leaving = true;
@@ -145,7 +194,10 @@ export function modalController(
145
194
  // A `<form method="dialog">`, or an Escape the browser would not let be cancelled: the dialog
146
195
  // closed itself, and the URL must follow. One it closed for `dismiss` is no longer `dialog`.
147
196
  const onClose = (event: Event): void => {
148
- if (event.target === dialog && dialog !== undefined && !dialog.open) close();
197
+ if (event.target !== dialog || dialog === undefined || dialog.open) return;
198
+ // Closed by the browser, still a popover: out of the top layer now, not when history settles.
199
+ lowerModal(dialog);
200
+ close();
149
201
  };
150
202
  // A press on the backdrop. `closedby="any"` has the browser close the dialog itself (and fire
151
203
  // `close`, read above); a browser without it reports the press as a click whose target is the
@@ -171,6 +223,45 @@ export function modalController(
171
223
  if (began && outside(event)) close();
172
224
  };
173
225
 
226
+ /** A new dialog, its listeners, opened into the top layer: the page's title and focus kept. */
227
+ const raise = (labels: ModalLabels | undefined): HTMLDialogElement => {
228
+ opener = doc.activeElement;
229
+ title = doc.title;
230
+ const open = createModal(doc, labels, () => close());
231
+ open.addEventListener('cancel', onCancel);
232
+ open.addEventListener('close', onClose);
233
+ open.addEventListener('pointerdown', onPointerDown);
234
+ open.addEventListener('click', onClick);
235
+ doc.body.append(open);
236
+ raiseModal(open);
237
+ unkeep = keepModal(doc, open, close);
238
+ return open;
239
+ };
240
+
241
+ const loading = (url: string): boolean => {
242
+ const named = parseModalRoutes(metaOf(doc, NAVIGATION_MODALS_META));
243
+ if (named === undefined || !declaresModal(url, named.routes)) return false;
244
+ pending = true;
245
+ if (dialog !== undefined) {
246
+ // One modal at a time: the open one stays on screen, busy, until the next one replaces it.
247
+ markBusy(dialog, false);
248
+ return true;
249
+ }
250
+ dialog = raise(named);
251
+ markBusy(dialog, true);
252
+ dialog.setAttribute('aria-label', named.loading);
253
+ // Inside from the first frame: Escape, Tab and a screen reader are already the dialog's.
254
+ dialog.focus({ preventScroll: true });
255
+ return true;
256
+ };
257
+
258
+ const settle = (): void => {
259
+ pending = false;
260
+ if (dialog === undefined) return;
261
+ if (openAt === undefined) dismiss();
262
+ else markIdle(dialog);
263
+ };
264
+
174
265
  const present = async (
175
266
  next: Document,
176
267
  at: string,
@@ -183,34 +274,26 @@ export function modalController(
183
274
  rebase(source, landed);
184
275
  const content = [...source.childNodes, ...outside].map((node) => doc.adoptNode(node));
185
276
  const headScripts = [...next.head.querySelectorAll('script[src]')] as HTMLScriptElement[];
186
- let open = dialog;
187
- if (open === undefined) {
188
- opener = doc.activeElement;
189
- title = doc.title;
190
- open = doc.createElement('dialog');
191
- open.setAttribute(NAVIGATION_MODAL_ATTRIBUTE, '');
192
- // Light dismiss: Escape AND a press outside close it, as every modal does (`Dialog` included).
193
- open.setAttribute('closedby', 'any');
194
- open.addEventListener('cancel', onCancel);
195
- open.addEventListener('close', onClose);
196
- open.addEventListener('pointerdown', onPointerDown);
197
- open.addEventListener('click', onClick);
198
- doc.body.append(open);
199
- } else {
200
- // One modal at a time: the next address replaces this one's content, in the same dialog.
201
- // Closed as `dismiss` closes it — no longer `dialog` — so its own `close` event, queued or
202
- // (in any engine that fires it at once) synchronous, is never read as the visitor closing.
203
- disposeIslands(open);
204
- dialog = undefined;
205
- if (open.open) open.close();
206
- }
277
+ // The frame the click opened, the modal already open, or — for a document that names no
278
+ // modal routes — a dialog opened now. Never closed and reopened: its `close` event would read
279
+ // as the visitor closing, and reopening is the work this frame exists to do once.
280
+ const fresh = dialog === undefined;
281
+ const open = dialog ?? raise(parseModalRoutes(metaOf(doc, NAVIGATION_MODALS_META)));
282
+ disposeIslands(open);
207
283
  dialog = open;
208
284
  openAt = at;
209
285
  leaving = false;
210
- open.replaceChildren(...content);
286
+ pending = false;
287
+ const body = bodyOf(open);
288
+ // Focus moves in as `showModal()` moved it — unless the visitor already moved it to the close
289
+ // button while the frame waited.
290
+ const active = doc.activeElement;
291
+ const moveFocus = fresh || active === null || active === open || body.contains(active);
292
+ body.replaceChildren(...content);
293
+ markIdle(open);
211
294
  label(open, next.title);
212
295
  doc.title = next.title;
213
- open.showModal();
296
+ if (moveFocus || doc.activeElement?.isConnected !== true) focusInto(open);
214
297
  if (history !== 'none') {
215
298
  const state = {
216
299
  [STATE_KEY]: { scroll: [win.scrollX, win.scrollY], doc: rendered(), modal: at },
@@ -251,6 +334,11 @@ export function modalController(
251
334
  owned: owned(),
252
335
  }),
253
336
  present,
337
+ loading,
338
+ release() {
339
+ pending = false;
340
+ },
341
+ settle,
254
342
  close,
255
343
  clear() {
256
344
  clearEntry();
@@ -1,13 +1,17 @@
1
1
  /**
2
- * What an opted-in document carries for the client router: which surface rendered it, which build
3
- * (the skew check — the same meta realtime's sync tags write, deduped by key), the router's
4
- * script, and — for a `navigation: 'modal'` route — that it is presented over another page. A
5
- * surface that did not opt in gets none of them, so it pays no byte.
2
+ * What an opted-in document carries for the client router: its surface, its build (the skew check,
3
+ * deduped with realtime's sync tags by key), the router's script, a modal route's presentation, and
4
+ * the surface's modal routes so a click opens the dialog before the answer. Nothing elsewhere.
6
5
  */
7
6
 
8
- import { CLIENT_BUILD_META } from '@ultimat3/core';
7
+ import { CLIENT_BUILD_META, routeRank } from '@ultimat3/core';
8
+ import { byCodeUnit } from './code-unit-order';
9
9
  import type { HeadTag } from './head';
10
- import { NAVIGATION_PRESENTATION_META } from './navigation-modal-rules';
10
+ import {
11
+ type ModalRoutes,
12
+ NAVIGATION_MODALS_META,
13
+ NAVIGATION_PRESENTATION_META,
14
+ } from './navigation-modal-rules';
11
15
  import { NAVIGATION_META } from './navigation-rules';
12
16
 
13
17
  export interface ClientNavigationHead {
@@ -18,6 +22,41 @@ export interface ClientNavigationHead {
18
22
  readonly scriptUrl: string;
19
23
  /** The route declared `navigation: 'modal'`: the router shows it over the page beneath. */
20
24
  readonly modal?: boolean;
25
+ /** The surface's modal routes (`modalRouteList`) and the frame's words, in the page's locale. */
26
+ readonly modals?: ModalRoutes;
27
+ }
28
+
29
+ /** Whether some pathname matches both patterns — segment by segment, a catch-all takes the rest. */
30
+ function overlap(a: string, b: string): boolean {
31
+ const left = a.split('/').filter((s) => s.length > 0);
32
+ const right = b.split('/').filter((s) => s.length > 0);
33
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
34
+ const l = left[i];
35
+ const r = right[i];
36
+ if (l?.startsWith('*') === true || r?.startsWith('*') === true) return true;
37
+ if (l === undefined || r === undefined) return false;
38
+ if (!l.startsWith(':') && !r.startsWith(':') && l !== r) return false;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ /**
44
+ * The modals meta's `routes`: every modal pattern, plus — as `!<pattern>` — every page that is NOT
45
+ * a modal yet matches a path a modal pattern matches and outranks it (`routeRank`, the request
46
+ * router's own order: `/runs/new/peek` before `/runs/:id/peek`). Most specific first, so the
47
+ * router's first match is the route the server will run. Deterministic: ties by code unit.
48
+ */
49
+ export function modalRouteList(
50
+ pages: readonly { readonly path: string; readonly modal: boolean }[],
51
+ ): string[] {
52
+ const modals = pages.filter((page) => page.modal).map((page) => page.path);
53
+ const shadows = pages
54
+ .filter((page) => !page.modal)
55
+ .map((page) => page.path)
56
+ .filter((path) => modals.some((m) => routeRank(path) > routeRank(m) && overlap(path, m)));
57
+ return [...modals, ...shadows]
58
+ .sort((a, b) => routeRank(b) - routeRank(a) || byCodeUnit(a, b))
59
+ .map((path) => (modals.includes(path) ? path : `!${path}`));
21
60
  }
22
61
 
23
62
  export function clientNavigationTags(head: ClientNavigationHead): readonly HeadTag[] {
@@ -41,6 +80,15 @@ export function clientNavigationTags(head: ClientNavigationHead): readonly HeadT
41
80
  },
42
81
  ]
43
82
  : []),
83
+ ...(head.modals !== undefined && head.modals.routes.length > 0
84
+ ? [
85
+ {
86
+ kind: 'meta' as const,
87
+ key: `meta:${NAVIGATION_MODALS_META}`,
88
+ attrs: { name: NAVIGATION_MODALS_META, content: JSON.stringify(head.modals) },
89
+ },
90
+ ]
91
+ : []),
44
92
  // Deferred, in `<head>`: it runs once, after parsing, and survives every swap of the body.
45
93
  {
46
94
  kind: 'script',
package/src/navigation.ts CHANGED
@@ -125,7 +125,14 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
125
125
  const tabs = tabSync(win, () => cache.clear());
126
126
  const forget = tabs.forget;
127
127
  const scroll = scrollKeeper(win, () => rendered);
128
- const modal = modalController(win, () => rendered, ran, owned);
128
+ // Closing a frame before its answer arrived abandons the request it waits on.
129
+ const modal = modalController(
130
+ win,
131
+ () => rendered,
132
+ ran,
133
+ owned,
134
+ () => inflight?.abort(),
135
+ );
129
136
 
130
137
  const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
131
138
  fetchDocument(win, doc, url, purpose, init);
@@ -187,8 +194,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
187
194
  inflight?.abort();
188
195
  const mine = new AbortController();
189
196
  inflight = mine;
197
+ modal.release();
190
198
  win.clearTimeout(intent);
191
199
  navigatingTo = withoutFragment(url);
200
+ // A route the document names a modal opens NOW, in the click's own task; the answer fills it.
201
+ if (method === 'GET' && navigatingTo !== rendered) modal.loading(url);
192
202
  if (options.history !== 'none') scroll.save();
193
203
  const progress = win.setTimeout(
194
204
  () => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
@@ -219,6 +229,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
219
229
  movesTab: answerMovesTab(facts),
220
230
  });
221
231
  if (shown === 'degrade') {
232
+ modal.release();
222
233
  modal.clear();
223
234
  return;
224
235
  }
@@ -266,6 +277,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
266
277
  backing = true;
267
278
  win.history.back();
268
279
  }
280
+ modal.release();
269
281
  modal.dismiss();
270
282
  let swapped: ReturnType<typeof swapDocument> | undefined;
271
283
  await transition(
@@ -317,6 +329,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
317
329
  if (inflight === mine) {
318
330
  inflight = undefined;
319
331
  navigatingTo = undefined;
332
+ // Whatever the answer was, a frame nothing filled goes; an open modal is no longer busy.
333
+ modal.settle();
320
334
  doc.documentElement.removeAttribute(NAVIGATING_ATTRIBUTE);
321
335
  }
322
336
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `defineRoute({ navigation })` read back: the ONE spelling per meaning — `'prefetch'`,
3
+ * `'document'`, `'modal'`, or `{ modal: true, prefetch: 'intent' }` for a modal that may also be
4
+ * fetched on intent — and the two questions every reader asks of it (which mode, prefetched or
5
+ * not), so no reader compares against a string the object form would slip past.
6
+ */
7
+
8
+ /**
9
+ * How the client router treats this page (`navigation: { client }` surfaces only). Absent: swapped
10
+ * in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
11
+ * page whose GET does nothing but render. `'document'`: always a real document load, the route run
12
+ * once by the browser — for a GET that records something (an open, a download, a token consumed).
13
+ * `'modal'`: presented OVER the page a visitor came from, addressed by the hash
14
+ * (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
15
+ * whole page. Never fetched before a click: a modal is often a form, and its GET may prepare one.
16
+ * The server enforces all three before `load` (`@ultimat3/http`'s navigation gate) and names a
17
+ * modal in its document (`NAVIGATION_PRESENTATION_META`).
18
+ */
19
+ export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
20
+
21
+ export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
22
+ 'prefetch',
23
+ 'document',
24
+ 'modal',
25
+ ];
26
+
27
+ /**
28
+ * A `'modal'` whose GET only reads (a peek, a detail view), so it may be fetched on hover, focus
29
+ * or touchstart, as `'prefetch'` pages are — the click then fills the frame from the answer
30
+ * already held. `'intent'` is the one value: the router's own hover/focus/touchstart prefetch.
31
+ */
32
+ export interface RouteModalPrefetch {
33
+ readonly modal: true;
34
+ readonly prefetch: 'intent';
35
+ }
36
+
37
+ /** What `navigation:` takes. */
38
+ export type RouteNavigationDeclaration = RouteNavigationMode | RouteModalPrefetch;
39
+
40
+ const MODAL_PREFETCH_KEYS = ['modal', 'prefetch'] as const;
41
+
42
+ /** Exactly one of the four spellings — an object with anything more, or less, is not one. */
43
+ export function isRouteNavigation(value: unknown): value is RouteNavigationDeclaration {
44
+ if (typeof value === 'string') return ROUTE_NAVIGATION_MODES.some((mode) => mode === value);
45
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
46
+ const keys = Object.keys(value);
47
+ const said = value as Record<string, unknown>;
48
+ return (
49
+ keys.length === MODAL_PREFETCH_KEYS.length &&
50
+ MODAL_PREFETCH_KEYS.every((key) => keys.includes(key)) &&
51
+ said['modal'] === true &&
52
+ said['prefetch'] === 'intent'
53
+ );
54
+ }
55
+
56
+ /** The mode a declaration says; the object form is a `'modal'`. */
57
+ export const navigationModeOf = (
58
+ declared: RouteNavigationDeclaration | undefined,
59
+ ): RouteNavigationMode | undefined =>
60
+ declared === undefined || typeof declared === 'string' ? declared : 'modal';
61
+
62
+ /** Whether the server answers a prefetch of this page as a visit (`RouteNavigation.prefetch`). */
63
+ export const navigationPrefetches = (declared: RouteNavigationDeclaration | undefined): boolean =>
64
+ declared === 'prefetch' || (typeof declared === 'object' && declared.prefetch === 'intent');
package/src/route.ts CHANGED
@@ -29,6 +29,11 @@ import {
29
29
  import type { IslandSpec } from './island';
30
30
  import { drainDeclaredIslands } from './island';
31
31
  import { assertModeShape } from './modes';
32
+ import {
33
+ isRouteNavigation,
34
+ ROUTE_NAVIGATION_MODES,
35
+ type RouteNavigationDeclaration,
36
+ } from './route-navigation';
32
37
  import { isErrorStatus, routeStatusOf } from './route-status';
33
38
 
34
39
  /**
@@ -234,25 +239,6 @@ export type LoadRequirement<TData> = RouteContext extends TData
234
239
  */
235
240
  export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
236
241
 
237
- /**
238
- * How the client router treats this page (`navigation: { client }` surfaces only). Absent: swapped
239
- * in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
240
- * page whose GET does nothing but render. `'document'`: always a real document load, the route run
241
- * once by the browser — for a GET that records something (an open, a download, a token consumed).
242
- * `'modal'`: presented OVER the page a visitor came from, addressed by the hash
243
- * (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
244
- * whole page. Never fetched before a click. The server enforces all three before `load`
245
- * (`@ultimat3/http`'s navigation gate) and names a modal in its document
246
- * (`NAVIGATION_PRESENTATION_META`).
247
- */
248
- export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
249
-
250
- export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
251
- 'prefetch',
252
- 'document',
253
- 'modal',
254
- ];
255
-
256
242
  /** The input shape of `defineRoute` — exactly the contract's twelve keys, nothing else. */
257
243
  export interface RouteDefinition<TData = RouteData> {
258
244
  readonly render: RenderMode;
@@ -284,8 +270,11 @@ export interface RouteDefinition<TData = RouteData> {
284
270
  * Never on `static`: a file on disk has no process to answer a `POST`.
285
271
  */
286
272
  readonly post?: string;
287
- /** See `RouteNavigationMode`. Refused on a surface without client navigation, at boot. */
288
- readonly navigation?: RouteNavigationMode;
273
+ /**
274
+ * See `RouteNavigationMode` and `RouteModalPrefetch` (`route-navigation.ts`). Refused on a
275
+ * surface without client navigation, at boot.
276
+ */
277
+ readonly navigation?: RouteNavigationDeclaration;
289
278
  }
290
279
 
291
280
  /**
@@ -368,9 +357,9 @@ export function defineRoute<TData = RouteData>(
368
357
  }
369
358
 
370
359
  if (def.post !== undefined) assertPostBinding(def.post, def.render);
371
- if (def.navigation !== undefined && !ROUTE_NAVIGATION_MODES.includes(def.navigation)) {
360
+ if (def.navigation !== undefined && !isRouteNavigation(def.navigation)) {
372
361
  throw new RouteNavigationInvalidError(
373
- `navigation: ${renderCauseValue(def.navigation)} is not one of ${ROUTE_NAVIGATION_MODES.join(', ')}`,
362
+ `navigation: ${renderCauseValue(def.navigation)} is not one of ${ROUTE_NAVIGATION_MODES.join(', ')} or { modal: true, prefetch: 'intent' }`,
374
363
  );
375
364
  }
376
365