@ultimat3/render 26.0.0 → 26.1.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
@@ -109,7 +109,7 @@ axiom 6). Never `cli` (upward).
109
109
  | Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
110
110
  | An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
111
111
  | `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
112
- | 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. |
112
+ | 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`. |
113
113
  | 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. |
114
114
  | 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. |
115
115
  | 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. |
package/README.md CHANGED
@@ -126,6 +126,26 @@ posted fields (query wins). An RFC 8058 one-click `POST /correos/baja?t=…` wit
126
126
  `GET` stays the confirm page. `@ultimat3/cli` mounts it in both boots; a name no action is
127
127
  registered under is `X_ROUTE_POST_INVALID` at boot, as is `post` on a `static` page.
128
128
 
129
+ ## `navigation` — how the client router treats a page
130
+
131
+ On a surface listed in `app.config.ts`'s `navigation.client` only (elsewhere it refuses the boot,
132
+ `X_ROUTE_NAVIGATION_INVALID`). Absent: swapped in on a click, never prefetched. Full rules:
133
+ [Client Navigation](../../wiki/Client-Navigation.md).
134
+
135
+ | `navigation` | The page |
136
+ |---|---|
137
+ | `'prefetch'` | may also be fetched on hover/focus — a GET that does nothing but render |
138
+ | `'document'` | always a real document load; carries no router |
139
+ | `'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`) |
140
+
141
+ Declared on the route, never on a link: the route is the one place that covers every way of
142
+ reaching a page — a link, a redirect after a POST, a pasted hash.
143
+
144
+ From code — an island, an app script — `navigate(url, { replace? })`, `refresh()`,
145
+ `openModal(path)` and `closeModal()` go through the router when the page has one and fall back to
146
+ `location.assign`/`replace`/`reload` when it does not; they import the router's type only.
147
+ [Navigating from code](../../wiki/Client-Navigation.md#navigating-from-code).
148
+
129
149
  ## Mode invariants, checked at registration
130
150
 
131
151
  | Mode | Invariant | Error if violated |
@@ -475,6 +495,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
475
495
  | `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
476
496
  | `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
477
497
  | `IslandPropsInvalidError` | `X_ISLAND_PROPS_INVALID` | `src/errors.ts` |
498
+ | `NavigationModalPathInvalidError` | `X_NAVIGATION_MODAL_PATH_INVALID` | `src/errors.ts` |
478
499
  | `PrerenderFailedError` | `X_PRERENDER_FAILED` | `src/errors.ts` |
479
500
  | `RouteDuplicateError` | `X_ROUTE_DUPLICATE` | `src/errors.ts` |
480
501
  | `RouteFileInvalidError` | `X_ROUTE_FILE_INVALID` | `src/errors.ts` |
@@ -512,6 +533,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
512
533
  | `ISLAND_HOLD_ATTRIBUTE`, `ISLAND_HOLD_MS`, `ISLAND_HOLD_REVEAL` | a held island's marker, and the cap after which it shows its server markup regardless |
513
534
  | `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
514
535
  | `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script; it stamps `data-theme` and, beside it, the fallback itself as `data-theme-default` for `@ultimat3/ui`'s `clearTheme`/`watchOsTheme` |
536
+ | `clientNavigationTags`, `linkVerdict`, `formVerdict`, `responseVerdict`, `reusable`, `mayPrefetch`, `NAVIGATION_*`, `NAVIGATE_EVENT`, `NAVIGATED_EVENT` | the client router's document tags and its pure rules; the router itself is `@ultimat3/render/navigation`, built and served by the CLI |
537
+ | `navigate`, `refresh`, `openModal`, `closeModal`, `NavigateToOptions` | navigating from code: the router when the page has one, the browser's own load when not |
538
+ | `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 |
515
539
  | `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 |
516
540
 
517
541
  ## Notes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "26.0.0",
3
+ "version": "26.1.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",
@@ -39,11 +39,11 @@
39
39
  "test": "bun test"
40
40
  },
41
41
  "dependencies": {
42
- "@ultimat3/cache": "26.0.0",
43
- "@ultimat3/core": "26.0.0",
44
- "@ultimat3/http": "26.0.0",
45
- "@ultimat3/i18n": "26.0.0",
46
- "@ultimat3/seo": "26.0.0",
42
+ "@ultimat3/cache": "26.1.0",
43
+ "@ultimat3/core": "26.1.0",
44
+ "@ultimat3/http": "26.1.0",
45
+ "@ultimat3/i18n": "26.1.0",
46
+ "@ultimat3/seo": "26.1.0",
47
47
  "sass": "1.105.1"
48
48
  }
49
49
  }
@@ -11,7 +11,7 @@ export interface ClientSyncHead {
11
11
  readonly syncUrl: string;
12
12
  readonly buildId: string;
13
13
  readonly workerUrl?: string | undefined;
14
- /** `/_x/page-boot/<hash>.js` — realtime's page boot, when the app has realtime. */
14
+ /** `/_x/assets/page-boot/<hash>.js` — realtime's page boot, when the app has realtime. */
15
15
  readonly bootUrl?: string | undefined;
16
16
  }
17
17
 
package/src/errors.ts CHANGED
@@ -28,6 +28,7 @@ export const RENDER_ERROR_CODES = [
28
28
  'X_ASSET_MISSING',
29
29
  'X_ROUTE_POST_INVALID',
30
30
  'X_ROUTE_NAVIGATION_INVALID',
31
+ 'X_NAVIGATION_MODAL_PATH_INVALID',
31
32
  ] as const;
32
33
 
33
34
  export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
@@ -53,7 +54,9 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
53
54
  X_ASSET_MISSING: 'a page names a site asset the app does not have',
54
55
  X_ROUTE_POST_INVALID: "a page route's post binding cannot be mounted",
55
56
  X_ROUTE_NAVIGATION_INVALID:
56
- "a route's navigation is not 'prefetch' or 'document', or its surface has no client navigation",
57
+ "a route's navigation is not 'prefetch', 'document' or 'modal', or its surface has no client navigation",
58
+ X_NAVIGATION_MODAL_PATH_INVALID:
59
+ 'openModal was given something that is not a path of this origin',
57
60
  };
58
61
 
59
62
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -205,7 +208,8 @@ export class RoutePostInvalidError extends UltimateError {
205
208
 
206
209
  /**
207
210
  * `defineRoute({ navigation })` that says nothing a process can honour: a value other than
208
- * `'prefetch'` or `'document'`, or — at boot — a page whose surface has no client navigation.
211
+ * `'prefetch'`, `'document'` or `'modal'`, or — at boot — a page whose surface has no client
212
+ * navigation.
209
213
  */
210
214
  export class RouteNavigationInvalidError extends UltimateError {
211
215
  static readonly code = 'X_ROUTE_NAVIGATION_INVALID' as const;
@@ -213,7 +217,19 @@ export class RouteNavigationInvalidError extends UltimateError {
213
217
  super({
214
218
  code: RouteNavigationInvalidError.code,
215
219
  cause,
216
- fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
220
+ fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' | 'modal' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
221
+ });
222
+ }
223
+ }
224
+
225
+ /** `openModal(path)` with a path no hash can address on this origin. Thrown in the browser. */
226
+ export class NavigationModalPathInvalidError extends UltimateError {
227
+ static readonly code = 'X_NAVIGATION_MODAL_PATH_INVALID' as const;
228
+ constructor(path: string) {
229
+ super({
230
+ code: NavigationModalPathInvalidError.code,
231
+ cause: `openModal(${JSON.stringify(path)}): a modal is addressed by '#<path>', and this is not an absolute path on this origin`,
232
+ fix: "openModal('/runs/new') — the modal route's own path, as its link carries it; never a full URL or a relative one",
217
233
  });
218
234
  }
219
235
  }
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  IslandInvalidError,
39
39
  IslandNotHydratedError,
40
40
  IslandPropsInvalidError,
41
+ NavigationModalPathInvalidError,
41
42
  PrerenderFailedError,
42
43
  RENDER_ERROR_CODES,
43
44
  RENDER_ERROR_TITLES,
@@ -106,9 +107,23 @@ export {
106
107
  defaultIslandBudget,
107
108
  MODE_SPECS,
108
109
  } from './modes';
110
+ /** Navigating from code — through the router when the page has one, the browser's load when not. */
111
+ export type { NavigateToOptions } from './navigation-api';
112
+ export { closeModal, navigate, openModal, refresh } from './navigation-api';
109
113
  /** Client-side navigation over server-rendered documents — `navigation: { client }` surfaces. */
110
114
  export type { CachedDocument, NavigationCache, NavigationCacheOptions } from './navigation-cache';
111
115
  export { cacheKey, NAVIGATION_CACHE_MAX_ENTRIES, navigationCache } from './navigation-cache';
116
+ export type { Presentation, PresentationFacts } from './navigation-modal-rules';
117
+ export {
118
+ addressOf,
119
+ leaveModal,
120
+ modalAddress,
121
+ modalHistory,
122
+ modalLocation,
123
+ NAVIGATION_MODAL_ATTRIBUTE,
124
+ NAVIGATION_PRESENTATION_META,
125
+ presentation,
126
+ } from './navigation-modal-rules';
112
127
  export type {
113
128
  CachedFacts,
114
129
  FormFacts,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Navigating from code: the client router's public face for an island or an app script —
3
+ * `navigate`, `refresh`, `openModal`, `closeModal`. Each goes through the router when the page
4
+ * carries one (`window.__xNavigation`, started by `@ultimat3/render/navigation`), and falls back to
5
+ * what the browser does without it — so the same call is right on a `site/` page, a
6
+ * `navigation: 'document'` page, or with the router's script blocked. Imports the router's TYPE
7
+ * only: an island that navigates never ships the router.
8
+ */
9
+
10
+ import { NavigationModalPathInvalidError } from './errors';
11
+ import type { NavigationRouter } from './navigation';
12
+ import { modalAddress } from './navigation-modal-rules';
13
+
14
+ export interface NavigateToOptions {
15
+ /** Replace the current history entry instead of pushing one (`location.replace` without the router). */
16
+ readonly replace?: boolean;
17
+ }
18
+
19
+ interface NavigationWindow {
20
+ readonly location: Location;
21
+ readonly __xNavigation?: NavigationRouter;
22
+ }
23
+
24
+ /** The page's window, or `undefined` on the server — where there is nothing to navigate. */
25
+ const page = (): NavigationWindow | undefined =>
26
+ (globalThis as { window?: NavigationWindow }).window;
27
+
28
+ /**
29
+ * Go to `url` (resolved against the page) as a click on a link to it would: through the router on
30
+ * this origin — its own rules and the server's gate decide swap, modal or full load — and as a
31
+ * document load anywhere else, or when the page has no router.
32
+ */
33
+ export function navigate(url: string, options: NavigateToOptions = {}): Promise<void> {
34
+ const win = page();
35
+ if (win === undefined) return Promise.resolve();
36
+ const to = new URL(url, win.location.href);
37
+ const router = win.__xNavigation;
38
+ if (router === undefined || to.origin !== new URL(win.location.href).origin) {
39
+ if (options.replace === true) win.location.replace(to.href);
40
+ else win.location.assign(to.href);
41
+ return Promise.resolve();
42
+ }
43
+ return router.navigate(to.href, options.replace === true ? { history: 'replace' } : {});
44
+ }
45
+
46
+ /**
47
+ * The page on screen, rendered again by the server and swapped in where the visitor is scrolled —
48
+ * after a write the page's own islands do not reflect. Without the router: a reload.
49
+ */
50
+ export function refresh(): Promise<void> {
51
+ const win = page();
52
+ if (win === undefined) return Promise.resolve();
53
+ if (win.__xNavigation === undefined) {
54
+ win.location.reload();
55
+ return Promise.resolve();
56
+ }
57
+ return win.__xNavigation.refresh();
58
+ }
59
+
60
+ /**
61
+ * Open the `navigation: 'modal'` route at `path` over the page on screen (`#<path>`), pushed so
62
+ * Back closes it. A path that is not a modal of this tab opens nothing, as a stale hash does.
63
+ * Without the router: the modal route's own whole page. A path no hash can address (another
64
+ * origin, `//host`, a relative one) is refused, `X_NAVIGATION_MODAL_PATH_INVALID`.
65
+ */
66
+ export function openModal(path: string): Promise<void> {
67
+ const win = page();
68
+ const base = win?.location.href ?? 'http://localhost/';
69
+ if (!path.startsWith('/') || modalAddress(`#${path}`, base) === null) {
70
+ // Rejected, never thrown: one failure channel for an async call — `.catch` sees it.
71
+ return Promise.reject(new NavigationModalPathInvalidError(path));
72
+ }
73
+ if (win === undefined) return Promise.resolve();
74
+ if (win.__xNavigation === undefined) {
75
+ win.location.assign(new URL(path, base).href);
76
+ return Promise.resolve();
77
+ }
78
+ return win.__xNavigation.openModal(path);
79
+ }
80
+
81
+ /** Close the open modal as Escape does. Nothing is open without the router: nothing to do. */
82
+ export function closeModal(): void {
83
+ page()?.__xNavigation?.closeModal();
84
+ }
@@ -4,8 +4,13 @@
4
4
  * browser. Split from `navigation.ts` so that file is the controller and nothing else.
5
5
  */
6
6
 
7
- import type { LinkFacts } from './navigation-rules';
8
- import { NAVIGATION_RELOAD_ATTRIBUTE } from './navigation-rules';
7
+ import type { FormFacts, LinkFacts } from './navigation-rules';
8
+ import {
9
+ linkVerdict,
10
+ mayPrefetch,
11
+ NAVIGATION_NO_PREFETCH_ATTRIBUTE,
12
+ NAVIGATION_RELOAD_ATTRIBUTE,
13
+ } from './navigation-rules';
9
14
 
10
15
  /** A link as the rules read it; `event` absent for a hover, which is no click at all. */
11
16
  export function linkFacts(
@@ -35,6 +40,24 @@ export function anchorOf(target: EventTarget | null): HTMLAnchorElement | HTMLAr
35
40
  : null;
36
41
  }
37
42
 
43
+ /** The URL a hover or focus on `target` may prefetch, or `undefined` (`linkVerdict`, `mayPrefetch`). */
44
+ export function intentUrl(win: Window, target: EventTarget | null): string | undefined {
45
+ const anchor = anchorOf(target);
46
+ if (anchor === null) return undefined;
47
+ const verdict = linkVerdict(linkFacts(win, anchor));
48
+ if (verdict.kind !== 'soft') return undefined;
49
+ const connection = (
50
+ win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
51
+ ).connection;
52
+ const may = mayPrefetch({
53
+ url: verdict.url,
54
+ noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
55
+ saveData: connection?.saveData,
56
+ effectiveType: connection?.effectiveType,
57
+ });
58
+ return may ? verdict.url : undefined;
59
+ }
60
+
38
61
  /** A field as a GET query carries it: a file by its name, as the browser does. */
39
62
  export const fieldText = (value: unknown): string =>
40
63
  value instanceof File ? value.name : String(value);
@@ -70,6 +93,35 @@ export function formFields(form: HTMLFormElement, submitter: HTMLElement | null)
70
93
  }
71
94
  }
72
95
 
96
+ /** A submit as the rules read it — the submitter's `form*` attributes over the form's — and its fields. */
97
+ export function submitFacts(
98
+ win: Window,
99
+ form: HTMLFormElement,
100
+ submitter: HTMLElement | null,
101
+ defaultPrevented: boolean,
102
+ ): { readonly facts: FormFacts; readonly fields: FormData } {
103
+ const fields = formFields(form, submitter);
104
+ const say = (attr: string, fallback: string): string =>
105
+ submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
106
+ const facts: FormFacts = {
107
+ action: formAction(say('action', ''), win.location.href, win.document.baseURI),
108
+ current: win.location.href,
109
+ method: say('method', 'get').toLowerCase(),
110
+ enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
111
+ target: say('target', ''),
112
+ defaultPrevented,
113
+ reload:
114
+ form.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) ||
115
+ submitter?.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) === true,
116
+ // `unknown`: a browser yields a `File` for a file input, whatever a server-side type says.
117
+ hasFile: [...fields.values()].some(
118
+ (value: unknown) => value instanceof File && value.name !== '',
119
+ ),
120
+ fields: formPairs(fields),
121
+ };
122
+ return { facts, fields };
123
+ }
124
+
73
125
  const hush = (): void => undefined;
74
126
 
75
127
  /**
@@ -14,7 +14,8 @@ import {
14
14
  CLIENT_NAVIGATION_SURFACE_HEADER,
15
15
  CLIENT_SCOPE_META,
16
16
  } from '@ultimat3/core/page';
17
- import { NAVIGATION_META } from './navigation-rules';
17
+ import type { NavigationCache } from './navigation-cache';
18
+ import { NAVIGATION_META, type ResponseFacts, reusable } from './navigation-rules';
18
19
 
19
20
  /** One fetched answer, read once — a prefetch and the click after it share it. */
20
21
  export interface Answer {
@@ -85,3 +86,53 @@ export function fetchDocument(
85
86
  };
86
87
  });
87
88
  }
89
+
90
+ /**
91
+ * A GET's answer: the prefetch held for `url` when it may stand in for the click (`reusable`), else
92
+ * `fetchAgain()`. Taken out of the cache either way — a held answer answers one click.
93
+ */
94
+ export function heldOrFetched(
95
+ cache: NavigationCache<Answer>,
96
+ url: string,
97
+ fetchAgain: () => Promise<Answer>,
98
+ ): Promise<Answer> {
99
+ const held = cache.peek(url);
100
+ cache.delete(url);
101
+ if (held === undefined) return fetchAgain();
102
+ return held.value.then(
103
+ (answer) =>
104
+ reusable({
105
+ status: answer.status,
106
+ html: answer.html !== null,
107
+ location: answer.location,
108
+ noStore: answer.noStore,
109
+ ageMs: held.ageMs,
110
+ })
111
+ ? answer
112
+ : fetchAgain(),
113
+ fetchAgain,
114
+ );
115
+ }
116
+
117
+ /** What `responseVerdict` reads, off the answer, this document and the next one. */
118
+ export function responseFactsOf(
119
+ doc: Document,
120
+ next: Document | null,
121
+ answer: Answer,
122
+ asked: Pick<ResponseFacts, 'method' | 'requested' | 'hops'>,
123
+ ): ResponseFacts {
124
+ const nextMeta = (name: string): string | null => (next === null ? null : metaOf(next, name));
125
+ return {
126
+ ...asked,
127
+ status: answer.status,
128
+ opaqueRedirect: answer.opaqueRedirect,
129
+ location: answer.location,
130
+ contentType: answer.contentType,
131
+ surface: metaOf(doc, NAVIGATION_META),
132
+ nextSurface: nextMeta(NAVIGATION_META),
133
+ build: metaOf(doc, CLIENT_BUILD_META),
134
+ nextBuild: answer.build ?? nextMeta(CLIENT_BUILD_META),
135
+ scope: metaOf(doc, CLIENT_SCOPE_META),
136
+ nextScope: nextMeta(CLIENT_SCOPE_META),
137
+ };
138
+ }
@@ -12,6 +12,8 @@ export interface EntryState {
12
12
  readonly scroll: readonly [number, number];
13
13
  /** The document this entry shows — what back/forward compares with the one on screen. */
14
14
  readonly doc: string;
15
+ /** The modal open over `doc` (`navigation-modal-rules.ts`), on an entry this router pushed. */
16
+ readonly modal?: string;
15
17
  }
16
18
 
17
19
  /** Back/forward, as the router passes it: `none` restores; the others start at the top. */
@@ -38,7 +40,12 @@ export function saveEntryScroll(win: Window, rendered: string): void {
38
40
  const state = (win.history.state ?? {}) as Record<string, unknown>;
39
41
  const held = entryOf(state)?.doc;
40
42
  if (held !== undefined && held !== rendered) return;
41
- const entry: EntryState = { scroll: [win.scrollX, win.scrollY], doc: held ?? rendered };
43
+ // Spread: the entry's modal mark is what lets Escape go Back (`navigation-modal.ts`).
44
+ const entry: EntryState = {
45
+ ...entryOf(state),
46
+ scroll: [win.scrollX, win.scrollY],
47
+ doc: held ?? rendered,
48
+ };
42
49
  win.history.replaceState({ ...state, [STATE_KEY]: entry }, '');
43
50
  }
44
51
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Route-presented modals, as pure rules: the hash grammar (`/runs#/runs/new` — the PATH is the page
3
+ * on screen, the HASH is the one modal over it), whether an answer is shown as a modal, as the page,
4
+ * or dropped back to "just the page", and what leaving a modal does to history. The controller
5
+ * (`navigation-modal.ts`) only gathers the facts; every rule is testable without a DOM.
6
+ */
7
+
8
+ import type { ResponseVerdict } from './navigation-rules';
9
+
10
+ /**
11
+ * `<meta name="ultimate-presentation" content="modal">` — on the document of a route that declared
12
+ * `navigation: 'modal'`. The SERVER's word that this page is presented over another: the router
13
+ * never decides it from a link, so a redirect target and a hand-typed hash are answered alike.
14
+ */
15
+ export const NAVIGATION_PRESENTATION_META = 'ultimate-presentation';
16
+ /** On the `<dialog>` the router opens. `@ultimat3/ui`'s `global.scss` styles it, by this name. */
17
+ export const NAVIGATION_MODAL_ATTRIBUTE = 'data-x-modal';
18
+
19
+ /**
20
+ * The modal a hash addresses — `#/runs/new?x=1` → `/runs/new?x=1` — or `null` for every other
21
+ * fragment (`#below`, `#`, `#//host`, one that leaves the origin). NEVER throws: a stale or
22
+ * hand-edited hash degrades to "just the page", it is never an error.
23
+ */
24
+ export function modalAddress(hash: string, base: string): string | null {
25
+ // `#//host` would resolve to another origin; a backslash is a slash to the URL parser.
26
+ if (!hash.startsWith('#/') || hash.startsWith('#//') || hash.includes('\\')) return null;
27
+ const said = hash.slice(1);
28
+ if (!URL.canParse(base) || !URL.canParse(said, base)) return null;
29
+ const url = new URL(said, base);
30
+ if (url.origin !== new URL(base).origin) return null;
31
+ return `${url.pathname}${url.search}`;
32
+ }
33
+
34
+ /** The URL the address bar shows for `address` over the page `page`: `/runs#/runs/new`. */
35
+ export const modalLocation = (page: string, address: string): string =>
36
+ `${page.split('#')[0] ?? page}#${address}`;
37
+
38
+ /** `/runs/new?x=1` of an absolute URL — the hash a modal at that URL is addressed by. */
39
+ export const addressOf = (url: string): string => {
40
+ const parsed = new URL(url);
41
+ return `${parsed.pathname}${parsed.search}`;
42
+ };
43
+
44
+ /**
45
+ * - `modal`: shown in the dialog over the page on screen.
46
+ * - `page`: the router's ordinary handling (a swap, a follow, a load…).
47
+ * - `degrade`: the hash named something that is not a modal for this tab — the hash is dropped in
48
+ * place and the page stays, with nothing loaded, followed or shown as an error.
49
+ */
50
+ export type Presentation = 'modal' | 'page' | 'degrade';
51
+
52
+ /** What decides a presentation, read off the answer, the tab and how the request came about. */
53
+ export interface PresentationFacts {
54
+ /** The address came from the URL's hash (a load, Back/Forward, a typed hash), not a click. */
55
+ readonly fromHash: boolean;
56
+ readonly method: 'GET' | 'POST';
57
+ readonly verdict: ResponseVerdict['kind'];
58
+ readonly status: number;
59
+ /** The answer carries `NAVIGATION_PRESENTATION_META` = `modal`. */
60
+ readonly modalPage: boolean;
61
+ /** A modal is open now. */
62
+ readonly modalOpen: boolean;
63
+ /** The answered URL, and the page on screen — both without a fragment. */
64
+ readonly landed: string;
65
+ readonly rendered: string;
66
+ /** The answer belongs to another principal or build (`answerMovesTab`). */
67
+ readonly movesTab: boolean;
68
+ }
69
+
70
+ export function presentation(facts: PresentationFacts): Presentation {
71
+ const modal = facts.verdict === 'swap' && facts.modalPage && !facts.movesTab;
72
+ // From the hash, only a 2xx modal of this tab opens; anything else (a 404, a redirect, a page that
73
+ // is not a modal, another principal) is a stale address — never followed, never loaded.
74
+ if (facts.fromHash) {
75
+ return modal && facts.status >= 200 && facts.status < 300 ? 'modal' : 'degrade';
76
+ }
77
+ if (!modal) return 'page';
78
+ // A POST re-renders a modal only from inside one: the full page's own form stays a page.
79
+ if (facts.method === 'POST') return facts.modalOpen ? 'modal' : 'page';
80
+ // A link to the page on screen is that page, whatever it declared.
81
+ return facts.landed === facts.rendered ? 'page' : 'modal';
82
+ }
83
+
84
+ /**
85
+ * The history move for a modal shown: a POST answered in place re-renders it where it is (the
86
+ * entry is the modal's already); Back/Forward and a hash move nothing; a click pushes, so Back
87
+ * steps out of it (nesting deeper is a deeper path in the same dialog, with its own Back stop).
88
+ */
89
+ export function modalHistory(
90
+ method: 'GET' | 'POST',
91
+ history: 'push' | 'replace' | 'none' | undefined,
92
+ ): 'push' | 'replace' | 'none' {
93
+ if (history === 'none') return 'none';
94
+ return method === 'POST' ? 'replace' : (history ?? 'push');
95
+ }
96
+
97
+ /**
98
+ * Leaving an open modal for a PAGE: the modal's entry is never left behind to reopen a spent form
99
+ * on Back. Onto the page beneath, through an entry this router pushed: Back to it (two entries for
100
+ * one URL read as a Back that did nothing). Anywhere else: the modal's entry is replaced.
101
+ */
102
+ export function leaveModal(facts: {
103
+ readonly history: 'push' | 'replace' | 'none' | undefined;
104
+ readonly landed: string;
105
+ readonly rendered: string;
106
+ readonly owned: boolean;
107
+ }): 'back' | 'replace' | 'none' {
108
+ if (facts.history === 'none') return 'none';
109
+ return facts.landed === facts.rendered && facts.owned ? 'back' : 'replace';
110
+ }