@ultimat3/render 25.2.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/src/navigation.ts CHANGED
@@ -10,25 +10,29 @@
10
10
  * over instead of letting `fetch` follow it, and a POST is never re-submitted — after a failed
11
11
  * one, only the server knows what landed.
12
12
  *
13
- * Loaded as one deferred classic script (`@ultimat3/cli` builds it to `/_x/navigation/<hash>.js`)
14
- * on documents whose surface opted in (`navigation: { client: [...] }` in `app.config.ts`).
13
+ * Loaded as one deferred classic script (`@ultimat3/cli` builds it to
14
+ * `/_x/assets/navigation/<hash>.js`) on documents whose surface opted in (`navigation: { client:
15
+ * [...] }` in `app.config.ts`).
15
16
  */
16
17
 
17
- import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
18
18
  import { navigationCache } from './navigation-cache';
19
19
  import {
20
20
  anchorOf,
21
21
  announcer,
22
22
  focusMain,
23
- formAction,
24
- formFields,
25
- formPairs,
26
23
  handOver,
24
+ intentUrl,
27
25
  linkFacts,
28
- type RunningTransition,
26
+ submitFacts,
29
27
  transition,
30
28
  } from './navigation-dom';
31
- import { type Answer, fetchDocument, metaOf } from './navigation-fetch';
29
+ import {
30
+ type Answer,
31
+ fetchDocument,
32
+ heldOrFetched,
33
+ metaOf,
34
+ responseFactsOf,
35
+ } from './navigation-fetch';
32
36
  import {
33
37
  type EntryState,
34
38
  entryOf,
@@ -36,25 +40,22 @@ import {
36
40
  scrollAfter,
37
41
  withoutFragment,
38
42
  } from './navigation-history';
43
+ import { modalController } from './navigation-modal';
44
+ import { addressOf, modalAddress, modalHistory } from './navigation-modal-rules';
45
+ import { pressTracker } from './navigation-press';
39
46
  import {
40
47
  answerMovesTab,
41
- type FormFacts,
42
48
  formVerdict,
43
49
  linkVerdict,
44
- mayPrefetch,
45
50
  NAVIGATE_EVENT,
46
51
  NAVIGATED_EVENT,
47
52
  NAVIGATING_ATTRIBUTE,
48
53
  NAVIGATION_ERROR_EVENT,
49
54
  NAVIGATION_MAX_HOPS,
50
55
  NAVIGATION_META,
51
- NAVIGATION_NO_PREFETCH_ATTRIBUTE,
52
56
  NAVIGATION_PREFETCH_DELAY_MS,
53
57
  NAVIGATION_PROGRESS_DELAY_MS,
54
- NAVIGATION_RELOAD_ATTRIBUTE,
55
- type ResponseFacts,
56
58
  responseVerdict,
57
- reusable,
58
59
  } from './navigation-rules';
59
60
  import { scrollKeeper } from './navigation-scroll';
60
61
  import {
@@ -74,11 +75,19 @@ export interface NavigateOptions {
74
75
  readonly history?: 'push' | 'replace' | 'none';
75
76
  /** Redirects already followed for this navigation. */
76
77
  readonly hops?: number;
78
+ /** The URL's hash addressed it: a modal, or nothing — never a page (`presentation`). */
79
+ readonly fromHash?: boolean;
77
80
  }
78
81
 
79
82
  export interface NavigationRouter {
80
83
  navigate(url: string, options?: NavigateOptions): Promise<void>;
81
84
  prefetch(url: string): void;
85
+ /** The page on screen, fetched again and swapped in where it is scrolled. */
86
+ refresh(): Promise<void>;
87
+ /** `path` as the modal over this page (`#<path>`); anything but a modal of this tab: nothing. */
88
+ openModal(path: string): Promise<void>;
89
+ /** As Escape: Back through the router's own entry, else the hash dropped in place. */
90
+ closeModal(): void;
82
91
  stop(): void;
83
92
  }
84
93
 
@@ -104,15 +113,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
104
113
  let rendered = withoutFragment(win.location.href);
105
114
  /** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
106
115
  let navigatingTo: string | undefined;
107
- /** The view transition still animating — skipped only for a click it swallowed (`onClick`). */
108
- let animating: RunningTransition | undefined;
109
- /** Between a press and its click: the focus the press gives a link is not a hover. */
110
- let pressed = false;
111
- /** The press landed while a transition was painting over the page (see `onClick`). */
112
- let pressedOverTransition = false;
116
+ const press = pressTracker(doc);
113
117
  /** The pending prefetch of a hover, cancelled by anything that navigates. */
114
118
  let intent: number | undefined;
115
119
  let untrusted = false;
120
+ let backing = false; // a Back this router started is traversing: the URL is still the one left
116
121
  let inflight: AbortController | undefined;
117
122
  const live = announcer(doc);
118
123
  const owned = documentHead(doc);
@@ -120,6 +125,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
120
125
  const tabs = tabSync(win, () => cache.clear());
121
126
  const forget = tabs.forget;
122
127
  const scroll = scrollKeeper(win, () => rendered);
128
+ const modal = modalController(win, () => rendered, ran, owned);
123
129
 
124
130
  const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
125
131
  fetchDocument(win, doc, url, purpose, init);
@@ -138,7 +144,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
138
144
  const failed = (url: string, method: string, reason: string): void => {
139
145
  const detail = { url, method, reason };
140
146
  const event = new CustomEvent(NAVIGATION_ERROR_EVENT, { detail, cancelable: true });
141
- if (doc.dispatchEvent(event)) win.location.assign(win.location.href);
147
+ if (!doc.dispatchEvent(event)) return;
148
+ // Under a modal the URL differs only by its hash, which `assign` merely scrolls to: reloaded,
149
+ // the page and its modal are both asked for again (the entry is the router's own GET).
150
+ if (modal.address === undefined) win.location.assign(win.location.href);
151
+ else win.location.reload();
142
152
  };
143
153
 
144
154
  const answerFor = (
@@ -154,22 +164,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
154
164
  const body = options.body === undefined ? {} : { body: options.body };
155
165
  return request(url, 'soft', { method, ...body, signal }).finally(forget);
156
166
  }
157
- const held = cache.peek(url);
158
- cache.delete(url);
159
- if (held === undefined) return request(url, 'soft', { signal });
160
- return held.value.then(
161
- (answer) =>
162
- reusable({
163
- status: answer.status,
164
- html: answer.html !== null,
165
- location: answer.location,
166
- noStore: answer.noStore,
167
- ageMs: held.ageMs,
168
- })
169
- ? answer
170
- : request(url, 'soft', { signal }),
171
- () => request(url, 'soft', { signal }),
172
- );
167
+ return heldOrFetched(cache, url, () => request(url, 'soft', { signal }));
173
168
  };
174
169
 
175
170
  const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
@@ -183,7 +178,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
183
178
  // After an answer for another principal or build was shown in place, this tab is no longer
184
179
  // trusted to swap: every navigation is a real load (`answerMovesTab`).
185
180
  if (untrusted && method === 'GET') {
186
- win.location.assign(url);
181
+ if (options.fromHash === true) modal.clear();
182
+ else win.location.assign(url);
187
183
  return;
188
184
  }
189
185
  const detail = { url, method };
@@ -212,22 +208,20 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
212
208
  if (mine.signal.aborted) return;
213
209
  const next =
214
210
  answer.html === null ? null : new DOMParser().parseFromString(answer.html, 'text/html');
215
- const facts: ResponseFacts = {
211
+ const facts = responseFactsOf(doc, next, answer, { method, requested: url, hops });
212
+ const verdict = responseVerdict(facts);
213
+ const landed = `${withoutFragment(url)}${new URL(url).hash}`;
214
+ const shown = modal.presentationOf(next, landed, {
215
+ fromHash: options.fromHash === true,
216
216
  method,
217
- requested: url,
217
+ verdict: verdict.kind,
218
218
  status: answer.status,
219
- opaqueRedirect: answer.opaqueRedirect,
220
- location: answer.location,
221
- contentType: answer.contentType,
222
- hops,
223
- surface: metaOf(doc, NAVIGATION_META),
224
- nextSurface: next === null ? null : metaOf(next, NAVIGATION_META),
225
- build: metaOf(doc, CLIENT_BUILD_META),
226
- nextBuild: answer.build ?? (next === null ? null : metaOf(next, CLIENT_BUILD_META)),
227
- scope: metaOf(doc, CLIENT_SCOPE_META),
228
- nextScope: next === null ? null : metaOf(next, CLIENT_SCOPE_META),
229
- };
230
- const verdict = responseVerdict(facts);
219
+ movesTab: answerMovesTab(facts),
220
+ });
221
+ if (shown === 'degrade') {
222
+ modal.clear();
223
+ return;
224
+ }
231
225
  switch (verdict.kind) {
232
226
  case 'stay':
233
227
  return;
@@ -242,7 +236,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
242
236
  return;
243
237
  case 'follow':
244
238
  await navigate(verdict.url, {
245
- history: options.history === 'none' ? 'replace' : 'push',
239
+ // Out of a modal, a redirect's target takes the modal's entry: Back never reopens it.
240
+ history: options.history === 'none' || modal.address !== undefined ? 'replace' : 'push',
246
241
  hops: hops + 1,
247
242
  });
248
243
  return;
@@ -253,18 +248,26 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
253
248
  break;
254
249
  }
255
250
  if (next === null) return;
256
- const landed = `${withoutFragment(url)}${new URL(url).hash}`;
251
+ // A page answered while a modal is open: the modal goes, and so does its history entry.
252
+ const leave = shown === 'page' ? modal.leaveFor(options.history, landed) : undefined;
253
+ const history = leave === undefined ? options.history : leave === 'back' ? 'none' : leave;
257
254
  try {
258
255
  // Owned from the moment they are appended: a navigation aborted after this point left its
259
256
  // sheets in the head for good, styling every page after it — the next swap retires an
260
257
  // owned sheet its page does not link.
261
258
  await loadStylesheets(doc, missingStylesheets(doc, next), owned);
262
259
  if (mine.signal.aborted) return;
260
+ if (shown === 'modal') {
261
+ await modal.present(next, addressOf(landed), landed, modalHistory(method, history));
262
+ doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
263
+ return;
264
+ }
265
+ if (leave === 'back') {
266
+ backing = true;
267
+ win.history.back();
268
+ }
269
+ modal.dismiss();
263
270
  let swapped: ReturnType<typeof swapDocument> | undefined;
264
- const track = (running: RunningTransition, finished: boolean): void => {
265
- if (!finished) animating = running;
266
- else if (animating === running) animating = undefined;
267
- };
268
271
  await transition(
269
272
  win,
270
273
  () => {
@@ -275,16 +278,16 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
275
278
  live.textContent = '';
276
279
  swapped.body.append(live);
277
280
  rendered = withoutFragment(landed);
278
- if (options.history !== 'none') {
279
- const same = landed === win.location.href || options.history === 'replace';
281
+ if (history !== 'none') {
282
+ const same = landed === win.location.href || history === 'replace';
280
283
  const state = { [STATE_KEY]: { scroll: [0, 0], doc: rendered } satisfies EntryState };
281
284
  if (same) win.history.replaceState(state, '', landed);
282
285
  else win.history.pushState(state, '', landed);
283
286
  }
284
287
  // Inside the swap, so a view transition's "after" frame is already where the page lands.
285
- scrollAfter(win, doc, landed, options.history);
288
+ scrollAfter(win, doc, landed, history);
286
289
  },
287
- track,
290
+ press.track,
288
291
  );
289
292
  const done = swapped;
290
293
  if (done === undefined) return;
@@ -302,6 +305,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
302
305
  focusMain(doc);
303
306
  live.textContent = doc.title;
304
307
  doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
308
+ reconcile();
305
309
  } catch {
306
310
  // A swap that failed part-way leaves a page nobody rendered: a GET is loaded for real; a
307
311
  // POST's answer cannot be asked for again, so the visitor is told and shown this page.
@@ -319,64 +323,30 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
319
323
  };
320
324
 
321
325
  const onPress = (): void => {
322
- pressed = true;
323
- pressedOverTransition = animating !== undefined;
326
+ press.onPress();
324
327
  // A press is not a hover: the click it becomes navigates, and a guess on its heels is a
325
- // second request for the same page. A press alone never skips the running transition — that
326
- // snapped every named element to its end mid-glide (#621); only the click below may.
328
+ // second request for the same page.
327
329
  win.clearTimeout(intent);
328
330
  };
329
- const onRelease = (): void => {
330
- pressed = false;
331
- };
332
331
 
333
332
  const onClick = (event: MouseEvent): void => {
334
333
  win.clearTimeout(intent);
335
- // While a view transition paints, the browser hit-tests every press to `<html>` — Chrome does
336
- // so whatever `pointer-events` the `::view-transition` overlay has — so the click reached
337
- // nothing: the visitor's first click after a swap was lost. It is given to the element under
338
- // the pointer, whatever it is: a link, a submit button, an island's own control. Only a
339
- // skipped transition hit-tests the page, so the skip happens HERE, at the click, never at
340
- // the press; when the animation already ended between the two, there is nothing to skip.
341
- if (pressedOverTransition && event.target === doc.documentElement) {
342
- pressedOverTransition = false;
343
- animating?.skipTransition?.();
344
- const hit = doc.elementFromPoint(event.clientX, event.clientY);
345
- if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
346
- return;
347
- }
334
+ if (press.overlayClick(event)) return;
348
335
  const anchor = anchorOf(event.target);
349
336
  if (anchor === null) return;
350
337
  const verdict = linkVerdict(linkFacts(win, anchor, event));
351
338
  if (verdict.kind !== 'soft') return;
352
339
  event.preventDefault();
353
- void navigate(verdict.url);
340
+ // A link to the page beneath an open modal (its Cancel) closes the modal: nothing is fetched.
341
+ if (modal.address !== undefined && withoutFragment(verdict.url) === rendered) modal.close();
342
+ else void navigate(verdict.url);
354
343
  };
355
344
 
356
345
  const onSubmit = (event: SubmitEvent): void => {
357
346
  const form = event.target;
358
347
  // An untrusted tab (`answerMovesTab`) leaves every form to the browser.
359
348
  if (!(form instanceof HTMLFormElement) || untrusted) return;
360
- const submitter = event.submitter;
361
- const fields = formFields(form, submitter);
362
- const say = (attr: string, fallback: string): string =>
363
- submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
364
- const facts: FormFacts = {
365
- action: formAction(say('action', ''), win.location.href, doc.baseURI),
366
- current: win.location.href,
367
- method: say('method', 'get').toLowerCase(),
368
- enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
369
- target: say('target', ''),
370
- defaultPrevented: event.defaultPrevented,
371
- reload:
372
- form.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) ||
373
- submitter?.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) === true,
374
- // `unknown`: a browser yields a `File` for a file input, whatever a server-side type says.
375
- hasFile: [...fields.values()].some(
376
- (value: unknown) => value instanceof File && value.name !== '',
377
- ),
378
- fields: formPairs(fields),
379
- };
349
+ const { facts, fields } = submitFacts(win, form, event.submitter, event.defaultPrevented);
380
350
  const verdict = formVerdict(facts);
381
351
  if (verdict.kind === 'native') return;
382
352
  event.preventDefault();
@@ -393,27 +363,12 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
393
363
 
394
364
  const onIntent = (event: Event): void => {
395
365
  // The focus a press gives a link: its click is a moment away, and it navigates.
396
- if (event.type === 'focusin' && pressed) return;
397
- const anchor = anchorOf(event.target);
398
- if (anchor === null) return;
399
- const verdict = linkVerdict(linkFacts(win, anchor));
400
- const connection = (
401
- win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
402
- ).connection;
403
- if (
404
- verdict.kind !== 'soft' ||
405
- !mayPrefetch({
406
- url: verdict.url,
407
- noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
408
- saveData: connection?.saveData,
409
- effectiveType: connection?.effectiveType,
410
- })
411
- ) {
412
- return;
413
- }
366
+ if (event.type === 'focusin' && press.pressed()) return;
367
+ const url = intentUrl(win, event.target);
368
+ if (url === undefined) return;
414
369
  win.clearTimeout(intent);
415
370
  const delay = event.type === 'pointerover' ? NAVIGATION_PREFETCH_DELAY_MS : 0;
416
- intent = win.setTimeout(() => prefetch(verdict.url), delay);
371
+ intent = win.setTimeout(() => prefetch(url), delay);
417
372
  };
418
373
  const onLeave = (): void => win.clearTimeout(intent);
419
374
 
@@ -424,7 +379,10 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
424
379
  */
425
380
  const onPop = (event: PopStateEvent): void => {
426
381
  scroll.cancel();
427
- const url = win.location.href;
382
+ backing = false;
383
+ // The page is the PATH: a modal's hash is the modal's (`reconcile`), never the page's fragment.
384
+ const at = win.location.href;
385
+ const url = modalAddress(new URL(at).hash, at) === null ? at : withoutFragment(at);
428
386
  const entry = entryOf(event.state);
429
387
  const wanted = entry?.doc ?? withoutFragment(url);
430
388
  const same =
@@ -442,40 +400,76 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
442
400
  behavior: 'instant' as ScrollBehavior,
443
401
  });
444
402
  }
403
+ reconcile();
445
404
  };
446
405
 
406
+ /**
407
+ * The modal follows the URL's hash, once the page on screen is the URL's: opened (fetched, as a
408
+ * hash address, so a stale one degrades), kept, or dismissed. On start, on Back/Forward, on a
409
+ * typed hash, and after every page swap — a Back onto a modal's entry swaps its page first.
410
+ */
411
+ function reconcile(): void {
412
+ // Until that Back lands, the hash still names the modal just left: it would be opened again.
413
+ if (backing) return;
414
+ const at = win.location.href;
415
+ if (withoutFragment(at) !== rendered) return;
416
+ const wanted = modalAddress(new URL(at).hash, at);
417
+ if (wanted === null) modal.dismiss();
418
+ else if (wanted !== modal.address) {
419
+ void navigate(new URL(wanted, at).href, { history: 'none', fromHash: true });
420
+ }
421
+ }
422
+
447
423
  // `window`, bubble phase: after every handler on the page — Solid delegates to `document` — has
448
424
  // had its chance to `preventDefault`, which the rules then honour.
449
- win.addEventListener('click', onClick);
450
- win.addEventListener('submit', onSubmit);
451
- doc.addEventListener('pointerover', onIntent, { passive: true });
452
- doc.addEventListener('pointerout', onLeave, { passive: true });
453
- doc.addEventListener('focusin', onIntent);
454
- doc.addEventListener('touchstart', onIntent, { passive: true });
455
- win.addEventListener('popstate', onPop);
456
- doc.addEventListener('pointerdown', onPress, { capture: true, passive: true });
457
- doc.addEventListener('pointerup', onRelease, { capture: true, passive: true });
458
- doc.addEventListener('pointercancel', onRelease, { capture: true, passive: true });
425
+ const passive = { passive: true };
426
+ const pressing = { capture: true, passive: true };
427
+ const listeners: readonly (readonly [EventTarget, string, EventListener, object?])[] = [
428
+ [win, 'click', onClick as EventListener],
429
+ [win, 'submit', onSubmit as EventListener],
430
+ [doc, 'pointerover', onIntent, passive],
431
+ [doc, 'pointerout', onLeave, passive],
432
+ [doc, 'focusin', onIntent],
433
+ [doc, 'touchstart', onIntent, passive],
434
+ [win, 'popstate', onPop as EventListener],
435
+ [win, 'hashchange', reconcile],
436
+ [doc, 'pointerdown', onPress, pressing],
437
+ [doc, 'pointerup', press.onRelease, pressing],
438
+ [doc, 'pointercancel', press.onRelease, pressing],
439
+ ];
440
+ for (const [target, type, listener, options] of listeners) {
441
+ target.addEventListener(type, listener, options);
442
+ }
459
443
 
460
444
  const router: NavigationRouter = {
461
445
  navigate,
462
446
  prefetch,
447
+ refresh() {
448
+ // A fresh answer for the page on screen, landing where the visitor is scrolled.
449
+ cache.delete(rendered);
450
+ scroll.save();
451
+ return navigate(rendered, { history: 'none' });
452
+ },
453
+ openModal(path) {
454
+ const address = modalAddress(`#${path}`, win.location.href);
455
+ if (address === null) return Promise.resolve();
456
+ return navigate(new URL(address, win.location.href).href, {
457
+ fromHash: true,
458
+ history: 'push',
459
+ });
460
+ },
461
+ closeModal: () => modal.close(),
463
462
  stop() {
464
- win.removeEventListener('click', onClick);
465
- win.removeEventListener('submit', onSubmit);
466
- doc.removeEventListener('pointerover', onIntent);
467
- doc.removeEventListener('pointerout', onLeave);
468
- doc.removeEventListener('focusin', onIntent);
469
- doc.removeEventListener('touchstart', onIntent);
470
- win.removeEventListener('popstate', onPop);
471
- doc.removeEventListener('pointerdown', onPress, { capture: true });
472
- doc.removeEventListener('pointerup', onRelease, { capture: true });
473
- doc.removeEventListener('pointercancel', onRelease, { capture: true });
463
+ for (const [target, type, listener, options] of listeners) {
464
+ target.removeEventListener(type, listener, options);
465
+ }
466
+ modal.dismiss();
474
467
  scroll.stop();
475
468
  tabs.stop();
476
469
  delete win.__xNavigation;
477
470
  },
478
471
  };
479
472
  win.__xNavigation = router;
473
+ reconcile();
480
474
  return router;
481
475
  }
package/src/registry.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The route table — the single source of route truth. File path → URL conventions for
3
- * `site/`, `app/` and `api/`, plus `describePages()`, the serializable projection that
3
+ * `site/` and `app/`, plus `describePages()`, the serializable projection that
4
4
  * `x.manifest.json`, the `/_x` routes panel, the sitemap and `sw.js` are all generated
5
5
  * from. Nothing downstream may keep its own list of routes.
6
6
  */
@@ -31,40 +31,31 @@ import { isRouteConfig, routeTagKeys } from './route';
31
31
  import type { RouteComponent } from './route-component';
32
32
  import type { CompiledPattern } from './route-pattern';
33
33
  import { compilePattern } from './route-pattern';
34
- import type { Surface } from './surfaces';
34
+ import type { RouteSurface, Surface } from './surfaces';
35
35
  import { locateSurface } from './surfaces';
36
36
 
37
37
  /**
38
- * The one filename a route may carry, per surface. `shared/` is `Exclude`d rather than merely
39
- * absent: it is a leaf of helpers with no URL, so a route file there has nowhere to resolve to —
40
- * and stating that in the key type makes the other three MANDATORY. `Partial<Record<Surface, …>>`
41
- * said the same thing about `shared` and let any of the three go missing, which only three
42
- * registration tests would have caught. A dropped `api` row is not a crash: `assertRouteFilename`
43
- * reads `undefined` as "this file is under shared/", so every `api/` route author would have been
44
- * told their file is a leaf of helpers.
38
+ * The one filename a route may carry, per route surface. Keyed by `RouteSurface`, so both rows are
39
+ * MANDATORY: a dropped row would read as `undefined` in `assertRouteFilename`.
45
40
  */
46
- export const ROUTE_FILENAME = Object.freeze<Record<Exclude<Surface, 'shared'>, string>>({
41
+ export const ROUTE_FILENAME = Object.freeze<Record<RouteSurface, string>>({
47
42
  site: 'page.tsx',
48
43
  app: 'page.tsx',
49
- api: 'route.ts',
50
44
  });
51
45
 
52
46
  /** Stems that already meant "this directory", so the repair is a rename in place, not a new folder. */
53
47
  const DIRECTORY_STEMS = new Set(['index', 'page', 'route']);
54
48
 
55
- const API_PREFIX = '/api';
56
-
57
49
  export interface RouteEntry<TData = RouteData> {
58
50
  readonly file: string;
59
51
  readonly path: string;
60
- readonly surface: Surface;
52
+ readonly surface: RouteSurface;
61
53
  readonly config: RouteConfig<TData>;
62
54
  readonly suspenseBoundaries: number;
63
55
  readonly islands: readonly string[];
64
56
  readonly pattern: CompiledPattern;
65
57
  /**
66
- * The module's page component. Absent for `api/` routes and for a module that exports none —
67
- * a `spa` shell is the mode that legitimately has no server-rendered body.
58
+ * The module's page component. Absent for a module that exports none.
68
59
  */
69
60
  readonly component?: RouteComponent;
70
61
  }
@@ -119,9 +110,8 @@ export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-r
119
110
  * | `site/blog/[slug]/page.tsx` | `/blog/:slug` |
120
111
  * | `site/docs/[...path]/page.tsx` | `/docs/*path` |
121
112
  * | `app/dashboard/page.tsx` | `/dashboard` |
122
- * | `api/posts/route.ts` | `/api/posts` |
123
113
  */
124
- export function routePathFromFile(file: string): { surface: Surface; path: string } {
114
+ export function routePathFromFile(file: string): { surface: RouteSurface; path: string } {
125
115
  const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
126
116
  // One reader of the surface segment, and it answers WHERE as well as WHICH. Slicing at
127
117
  // `indexOf('app/')` instead matched inside `myapp/`, so `apps/myapp/app/page.tsx` resolved to
@@ -130,10 +120,10 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
130
120
  if (located === null) {
131
121
  throw new SurfaceBoundaryError(
132
122
  `${file} is not inside a surface directory, so it has no URL and no bundle graph`,
133
- `move ${file} under site/, app/ or api/`,
123
+ `move ${file} under site/ or app/, named page.tsx`,
134
124
  );
135
125
  }
136
- const surface = located.surface;
126
+ const surface = routeSurfaceOf(normalized, located.surface);
137
127
 
138
128
  const rawSegments = located.rest.split('/').filter((s) => s.length > 0);
139
129
  assertRouteFilename(normalized, surface, rawSegments[rawSegments.length - 1]);
@@ -143,8 +133,7 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
143
133
  .filter((s) => !(s.startsWith('(') && s.endsWith(')')))
144
134
  .map(toUrlSegment);
145
135
 
146
- const base = surface === 'api' ? API_PREFIX : '';
147
- const path = `${base}/${urlSegments.join('/')}`.replace(/\/+$/, '') || '/';
136
+ const path = `/${urlSegments.join('/')}`.replace(/\/+$/, '') || '/';
148
137
  return { surface, path };
149
138
  }
150
139
 
@@ -156,20 +145,37 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
156
145
  const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
157
146
 
158
147
  /**
159
- * Enforced rather than documented (axiom 3): a convention that is not a build error is not a
160
- * convention. The fix is the move that makes the file a route, spelled out — the directory the
161
- * author already meant, plus the one filename that surface accepts.
148
+ * Only `site/` and `app/` hold route files. `shared/` is a leaf of helpers with no URL; `api/` is
149
+ * the action and query projections `defineApi()` collects — a wire format an action cannot speak
150
+ * is a plain HTTP route in the `routes` runtime override, never a file under `api/`.
162
151
  */
163
- function assertRouteFilename(file: string, surface: Surface, basename: string | undefined): void {
164
- // `shared` is the one surface with no filename, and the key type now says so — which is why
165
- // this is a comparison rather than an `undefined` check on the lookup.
166
- const expected = surface === 'shared' ? undefined : ROUTE_FILENAME[surface];
167
- if (expected === undefined) {
152
+ function routeSurfaceOf(file: string, surface: Surface): RouteSurface {
153
+ if (surface === 'shared') {
168
154
  throw new RouteFileInvalidError(
169
155
  `${file} is under shared/, which is a leaf of helpers with no URL — a route cannot live there`,
170
- `move ${file} under site/, app/ or api/, named page.tsx (site/, app/) or route.ts (api/)`,
156
+ `move ${file} under site/ or app/, named page.tsx`,
171
157
  );
172
158
  }
159
+ if (surface === 'api') {
160
+ throw new RouteFileInvalidError(
161
+ `${file} is under api/, which holds no route file: api/ is the actions and queries defineApi() collects`,
162
+ `JSON in and out: replace ${file} with an action (x g action <name>) collected by defineApi() in api/index.ts. Another wire format (a webhook, an OAuth endpoint): delete ${file} and add a plain HTTP route to the routes array exported as runtime from apps/<app>/runtime.ts`,
163
+ );
164
+ }
165
+ return surface;
166
+ }
167
+
168
+ /**
169
+ * Enforced rather than documented (axiom 3): a convention that is not a build error is not a
170
+ * convention. The fix is the move that makes the file a route, spelled out — the directory the
171
+ * author already meant, plus the one filename that surface accepts.
172
+ */
173
+ function assertRouteFilename(
174
+ file: string,
175
+ surface: RouteSurface,
176
+ basename: string | undefined,
177
+ ): void {
178
+ const expected = ROUTE_FILENAME[surface];
173
179
  if (basename === expected) return;
174
180
 
175
181
  // The directory the author meant is the file's own path minus its extension: `site/pricing.tsx`
package/src/route.ts CHANGED
@@ -191,11 +191,19 @@ export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
191
191
  * in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
192
192
  * page whose GET does nothing but render. `'document'`: always a real document load, the route run
193
193
  * once by the browser — for a GET that records something (an open, a download, a token consumed).
194
- * The server enforces both before `load` (`@ultimat3/http`'s navigation gate).
194
+ * `'modal'`: presented OVER the page a visitor came from, addressed by the hash
195
+ * (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
196
+ * whole page. Never fetched before a click. The server enforces all three before `load`
197
+ * (`@ultimat3/http`'s navigation gate) and names a modal in its document
198
+ * (`NAVIGATION_PRESENTATION_META`).
195
199
  */
196
- export type RouteNavigationMode = 'prefetch' | 'document';
200
+ export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
197
201
 
198
- export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = ['prefetch', 'document'];
202
+ export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
203
+ 'prefetch',
204
+ 'document',
205
+ 'modal',
206
+ ];
199
207
 
200
208
  /** The input shape of `defineRoute` — exactly the contract's twelve keys, nothing else. */
201
209
  export interface RouteDefinition<TData = RouteData> {
package/src/surfaces.ts CHANGED
@@ -11,6 +11,12 @@ import { SurfaceBoundaryError } from './errors';
11
11
 
12
12
  export type Surface = 'site' | 'app' | 'api' | 'shared';
13
13
 
14
+ /**
15
+ * The two surfaces a route file lives on. `api/` is a surface for the import boundary only: it holds
16
+ * the action and query projections `defineApi()` collects, and no route file.
17
+ */
18
+ export type RouteSurface = Exclude<Surface, 'api' | 'shared'>;
19
+
14
20
  export const SURFACES = ['site', 'app', 'api', 'shared'] as const;
15
21
 
16
22
  export interface SurfaceSpec {