@ultimat3/render 26.0.0 → 26.1.1

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
@@ -10,7 +10,12 @@ loaders that make an app's `.tsx` and `.scss` runnable**.
10
10
  `node:url`, which a browser build cannot link; no `sideEffects` value repairs that). Never re-export
11
11
  a name from both barrels: `index.test.ts` asserts the empty name intersection and that `index.ts`'s
12
12
  runtime import graph reaches none of those seven modules; `scripts/browser-barrel.test.ts` holds
13
- the end property.
13
+ the end property. **`"./client"` (`client.ts`) is THE browser entry** for what an island calls
14
+ (`navigate`, `refresh`, `openModal`, `closeModal`, the modal refusal, the three event names): its
15
+ graph never reaches `errors.ts` (the barrel's `sideEffects` keeps that table in every chunk), held
16
+ by `client-bundle.test.ts`. A name there stays on `.` too (additive); a refusal it can construct
17
+ lives in its own module over `@ultimat3/core/page`'s `UltimateError` (`navigation-errors.ts`) and
18
+ `errors.ts` re-exports it — one class, never two.
14
19
 
15
20
  `island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a second
16
21
  render mode. It adds no key to `defineRoute`.
@@ -109,7 +114,7 @@ axiom 6). Never `cli` (upward).
109
114
  | Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
110
115
  | An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
111
116
  | `<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. |
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`. |
113
118
  | 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
119
  | 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
120
  | 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,27 @@ 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. An
147
+ island imports them from **`@ultimat3/render/client`**, never the barrel — see
148
+ [Two entry points](#two-entry-points). [Navigating from code](../../wiki/Client-Navigation.md#navigating-from-code).
149
+
129
150
  ## Mode invariants, checked at registration
130
151
 
131
152
  | Mode | Invariant | Error if violated |
@@ -463,6 +484,19 @@ does.
463
484
  side effect. Anything that loads an app's source — `x dev`, `x build`, `server.ts`, a test that
464
485
  `await import()`s a `page.tsx` — reaches it before the module it loads.
465
486
 
487
+ `@ultimat3/render/client` is the **browser** entry — what an island or a page script calls:
488
+ `navigate`, `refresh`, `openModal`, `closeModal`, `NavigateToOptions`,
489
+ `NavigationModalPathInvalidError`, `NAVIGATE_EVENT`, `NAVIGATED_EVENT`, `NAVIGATION_ERROR_EVENT`.
490
+ The barrel bundles for the browser but its `sideEffects` keeps `errors.ts` — render's code table,
491
+ and core's and schema's titles tables behind it — in every chunk that reaches it; this entry's
492
+ graph is the navigation helpers plus `@ultimat3/core/page` (`client-bundle.test.ts`). `As of
493
+ 2026-10-08`, an island whose body is `refresh()`: 9,323 B minified from the barrel, 216 B from
494
+ `/client`. The barrel keeps the same names, for compatibility.
495
+
496
+ ```ts
497
+ import { navigate, refresh } from '@ultimat3/render/client';
498
+ ```
499
+
466
500
  ## Error classes
467
501
 
468
502
  Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
@@ -475,6 +509,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
475
509
  | `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
476
510
  | `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
477
511
  | `IslandPropsInvalidError` | `X_ISLAND_PROPS_INVALID` | `src/errors.ts` |
512
+ | `NavigationModalPathInvalidError` | `X_NAVIGATION_MODAL_PATH_INVALID` | `src/navigation-errors.ts` (re-exported by `src/errors.ts`; also on `/client`) |
478
513
  | `PrerenderFailedError` | `X_PRERENDER_FAILED` | `src/errors.ts` |
479
514
  | `RouteDuplicateError` | `X_ROUTE_DUPLICATE` | `src/errors.ts` |
480
515
  | `RouteFileInvalidError` | `X_ROUTE_FILE_INVALID` | `src/errors.ts` |
@@ -488,7 +523,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
488
523
 
489
524
  ## Public API
490
525
 
491
- `†` marks a name on `@ultimat3/render/server`.
526
+ `†` marks a name on `@ultimat3/render/server`; `‡` a name also on `@ultimat3/render/client`.
492
527
 
493
528
  | Export | Owns |
494
529
  |---|---|
@@ -512,6 +547,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
512
547
  | `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
548
  | `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
514
549
  | `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` |
550
+ | `clientNavigationTags`, `linkVerdict`, `formVerdict`, `responseVerdict`, `reusable`, `mayPrefetch`, `NAVIGATION_*` (`NAVIGATION_ERROR_EVENT`‡), `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 |
551
+ | `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 |
552
+ | `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
553
  | `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
554
 
517
555
  ## 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.1",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "exports": {
22
22
  ".": "./src/index.ts",
23
+ "./client": "./src/client.ts",
23
24
  "./server": "./src/server.ts",
24
25
  "./navigation": "./src/navigation-entry.ts"
25
26
  },
@@ -39,11 +40,11 @@
39
40
  "test": "bun test"
40
41
  },
41
42
  "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",
43
+ "@ultimat3/cache": "26.1.1",
44
+ "@ultimat3/core": "26.1.1",
45
+ "@ultimat3/http": "26.1.1",
46
+ "@ultimat3/i18n": "26.1.1",
47
+ "@ultimat3/seo": "26.1.1",
47
48
  "sass": "1.105.1"
48
49
  }
49
50
  }
@@ -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/client.ts ADDED
@@ -0,0 +1,14 @@
1
+ // `@ultimat3/render/client` — THE browser entry of this package: what an island or a page script
2
+ // may call, and nothing that drags the rest along. The `.` barrel bare-retains `errors.ts` (render's
3
+ // whole code table, registered at import, plus core's titles tables); this entry's graph is
4
+ // `navigation-api.ts`, `navigation-errors.ts`, the pure rules and `@ultimat3/core/page`, held there
5
+ // by `client-bundle.test.ts`.
6
+ //
7
+ // import { navigate, openModal, refresh } from '@ultimat3/render/client';
8
+ //
9
+ // The same names stay exported from `.` for compatibility; an island imports them from here.
10
+
11
+ export type { NavigateToOptions } from './navigation-api';
12
+ export { closeModal, navigate, openModal, refresh } from './navigation-api';
13
+ export { NavigationModalPathInvalidError } from './navigation-errors';
14
+ export { NAVIGATE_EVENT, NAVIGATED_EVENT, NAVIGATION_ERROR_EVENT } from './navigation-rules';
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,11 +217,16 @@ 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",
217
221
  });
218
222
  }
219
223
  }
220
224
 
225
+ // `openModal(path)` with a path no hash can address on this origin. Re-exported, never re-declared:
226
+ // the class lives in `navigation-errors.ts` so `@ultimat3/render/client` constructs it without this
227
+ // module's table. One class, so `instanceof` holds from either entry.
228
+ export { NavigationModalPathInvalidError } from './navigation-errors';
229
+
221
230
  export class RouteLoadInvalidError extends UltimateError {
222
231
  static readonly code = 'X_ROUTE_LOAD_INVALID' as const;
223
232
  constructor(cause: string, fix: string) {
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,27 @@ export {
106
107
  defaultIslandBudget,
107
108
  MODE_SPECS,
108
109
  } from './modes';
110
+ /**
111
+ * Navigating from code — through the router when the page has one, the browser's load when not.
112
+ * Kept here for compatibility; an island imports them from `@ultimat3/render/client` (`client.ts`),
113
+ * whose graph does not retain this barrel's `errors.ts` and the code tables behind it.
114
+ */
115
+ export type { NavigateToOptions } from './navigation-api';
116
+ export { closeModal, navigate, openModal, refresh } from './navigation-api';
109
117
  /** Client-side navigation over server-rendered documents — `navigation: { client }` surfaces. */
110
118
  export type { CachedDocument, NavigationCache, NavigationCacheOptions } from './navigation-cache';
111
119
  export { cacheKey, NAVIGATION_CACHE_MAX_ENTRIES, navigationCache } from './navigation-cache';
120
+ export type { Presentation, PresentationFacts } from './navigation-modal-rules';
121
+ export {
122
+ addressOf,
123
+ leaveModal,
124
+ modalAddress,
125
+ modalHistory,
126
+ modalLocation,
127
+ NAVIGATION_MODAL_ATTRIBUTE,
128
+ NAVIGATION_PRESENTATION_META,
129
+ presentation,
130
+ } from './navigation-modal-rules';
112
131
  export type {
113
132
  CachedFacts,
114
133
  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 type { NavigationRouter } from './navigation';
11
+ import { NavigationModalPathInvalidError } from './navigation-errors';
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
  /**
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The one refusal a BROWSER can reach in this package — `openModal`'s — apart from `errors.ts` for
3
+ * bytes: that module registers render's whole code table at import, and an island that calls
4
+ * `openModal` must not carry it. `errors.ts` re-exports this class, so it is ONE class wherever it is
5
+ * imported from and `instanceof` agrees across both entries. Without the table loaded, core titles
6
+ * the code from its name; the code, cause and fix are the same either way. Runs nothing at import.
7
+ */
8
+
9
+ import { UltimateError } from '@ultimat3/core/page';
10
+
11
+ /** `openModal` was handed something no `#<path>` hash can address. Rejected, never thrown. */
12
+ export class NavigationModalPathInvalidError extends UltimateError {
13
+ static readonly code = 'X_NAVIGATION_MODAL_PATH_INVALID' as const;
14
+ constructor(path: string) {
15
+ super({
16
+ code: NavigationModalPathInvalidError.code,
17
+ cause: `openModal(${JSON.stringify(path)}): a modal is addressed by '#<path>', and this is not an absolute path on this origin`,
18
+ fix: "openModal('/runs/new') — the modal route's own path, as its link carries it; never a full URL or a relative one",
19
+ });
20
+ }
21
+ }
@@ -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
+ }