@davidsouther/jiffies 2026.4.1 → 2026.24.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.
Files changed (121) hide show
  1. package/README.md +0 -3
  2. package/package.json +11 -6
  3. package/src/404.html +1 -1
  4. package/src/components/accordion.ts +25 -0
  5. package/src/components/alert.ts +47 -0
  6. package/src/components/card.ts +54 -0
  7. package/src/components/children.ts +11 -0
  8. package/src/components/form.ts +25 -0
  9. package/src/components/index.ts +22 -0
  10. package/src/components/link.ts +22 -0
  11. package/src/components/modal.ts +15 -0
  12. package/src/components/nav.ts +42 -0
  13. package/src/components/property.ts +32 -0
  14. package/src/components/tabs.ts +82 -0
  15. package/src/components/virtual_scroll.ts +1 -1
  16. package/src/dom/README.md +7 -2
  17. package/src/dom/SKILL.md +201 -0
  18. package/src/dom/dom.ts +185 -41
  19. package/src/dom/fc.ts +3 -2
  20. package/src/dom/form/form.app.ts +35 -41
  21. package/src/dom/form/form.ts +79 -10
  22. package/src/dom/form/index.html +2 -2
  23. package/src/dom/hydrate.ts +206 -0
  24. package/src/dom/navigation/index.ts +349 -0
  25. package/src/dom/render.ts +41 -0
  26. package/src/dom/svg.ts +6 -2
  27. package/src/fs_node.ts +2 -2
  28. package/src/log.ts +154 -2
  29. package/src/server/http/response.ts +6 -3
  30. package/src/server/http/sitemap.ts +10 -34
  31. package/src/server/http/static.ts +0 -2
  32. package/src/server/live-reload.ts +208 -0
  33. package/src/server/main.ts +14 -7
  34. package/src/server/ws/frame.ts +36 -0
  35. package/src/server/ws/handshake.ts +42 -0
  36. package/src/server/ws/index.ts +100 -0
  37. package/src/ssg/bundle.ts +85 -0
  38. package/src/ssg/copy-public.ts +44 -0
  39. package/src/ssg/discover.ts +143 -0
  40. package/src/ssg/main.ts +168 -0
  41. package/src/ssg/rewrite.ts +18 -0
  42. package/src/ssg/ssg.ts +134 -0
  43. package/src/components/test.ts +0 -5
  44. package/src/components/virtual_scroll.test.ts +0 -30
  45. package/src/context.test.ts +0 -58
  46. package/src/context.ts +0 -67
  47. package/src/diff.test.ts +0 -48
  48. package/src/dom/fc.test.ts +0 -43
  49. package/src/dom/form/form.test.ts +0 -0
  50. package/src/dom/html.test.ts +0 -74
  51. package/src/dom/observable.test.ts +0 -43
  52. package/src/dom/test.ts +0 -11
  53. package/src/equal.test.ts +0 -23
  54. package/src/flags.test.ts +0 -43
  55. package/src/flags.ts +0 -53
  56. package/src/fs.test.ts +0 -106
  57. package/src/fs_win.test.ts +0 -11
  58. package/src/generator.test.ts +0 -27
  59. package/src/index.html +0 -82
  60. package/src/is_browser.js +0 -1
  61. package/src/lock.test.ts +0 -17
  62. package/src/observable/observable.test.ts +0 -73
  63. package/src/pico/_variables.scss +0 -66
  64. package/src/pico/components/_accordion.scss +0 -112
  65. package/src/pico/components/_button-group.scss +0 -51
  66. package/src/pico/components/_card.scss +0 -47
  67. package/src/pico/components/_dropdown.scss +0 -203
  68. package/src/pico/components/_modal.scss +0 -181
  69. package/src/pico/components/_nav.scss +0 -79
  70. package/src/pico/components/_progress.scss +0 -70
  71. package/src/pico/components/_property.scss +0 -34
  72. package/src/pico/content/_button.scss +0 -152
  73. package/src/pico/content/_code.scss +0 -63
  74. package/src/pico/content/_embedded.scss +0 -0
  75. package/src/pico/content/_form-alt.scss +0 -276
  76. package/src/pico/content/_form.scss +0 -259
  77. package/src/pico/content/_misc.scss +0 -0
  78. package/src/pico/content/_table.scss +0 -28
  79. package/src/pico/content/_toggle.scss +0 -132
  80. package/src/pico/content/_typography.scss +0 -232
  81. package/src/pico/layout/_container.scss +0 -40
  82. package/src/pico/layout/_document.scss +0 -0
  83. package/src/pico/layout/_flex.scss +0 -46
  84. package/src/pico/layout/_grid.scss +0 -24
  85. package/src/pico/layout/_scroller.scss +0 -16
  86. package/src/pico/layout/_section.scss +0 -8
  87. package/src/pico/layout/_sectioning.scss +0 -55
  88. package/src/pico/pico.scss +0 -60
  89. package/src/pico/reset/_accessibility.scss +0 -34
  90. package/src/pico/reset/_button.scss +0 -17
  91. package/src/pico/reset/_code.scss +0 -15
  92. package/src/pico/reset/_document.scss +0 -48
  93. package/src/pico/reset/_embedded.scss +0 -39
  94. package/src/pico/reset/_form.scss +0 -97
  95. package/src/pico/reset/_misc.scss +0 -23
  96. package/src/pico/reset/_nav.scss +0 -5
  97. package/src/pico/reset/_progress.scss +0 -4
  98. package/src/pico/reset/_table.scss +0 -8
  99. package/src/pico/reset/_typography.scss +0 -25
  100. package/src/pico/themes/default/_colors.scss +0 -65
  101. package/src/pico/themes/default/_dark.scss +0 -148
  102. package/src/pico/themes/default/_light.scss +0 -149
  103. package/src/pico/themes/default/_styles.scss +0 -272
  104. package/src/pico/themes/default.scss +0 -34
  105. package/src/pico/utilities/_accessibility.scss +0 -3
  106. package/src/pico/utilities/_loading.scss +0 -52
  107. package/src/pico/utilities/_reduce-motion.scss +0 -27
  108. package/src/pico/utilities/_tooltip.scss +0 -101
  109. package/src/result.test.ts +0 -101
  110. package/src/scope/describe.ts +0 -81
  111. package/src/scope/display/console.ts +0 -26
  112. package/src/scope/display/dom.ts +0 -36
  113. package/src/scope/display/junit.ts +0 -64
  114. package/src/scope/execute.ts +0 -110
  115. package/src/scope/expect.ts +0 -169
  116. package/src/scope/fix.ts +0 -30
  117. package/src/scope/index.ts +0 -11
  118. package/src/scope/scope.ts +0 -21
  119. package/src/scope/state.ts +0 -13
  120. package/src/test.mjs +0 -33
  121. package/src/test_all.ts +0 -35
@@ -1,5 +1,14 @@
1
1
  import type { Attrs, DenormChildren } from "../dom.ts";
2
- import { form, input, label, option, select } from "../html.ts";
2
+ import {
3
+ button,
4
+ fieldset,
5
+ form,
6
+ input,
7
+ label,
8
+ legend,
9
+ option,
10
+ select,
11
+ } from "../html.ts";
3
12
  import type {
4
13
  FormAttributes,
5
14
  InputAttributes,
@@ -38,7 +47,19 @@ export const Select = (
38
47
  ...prepareOptions(attrs.options as string[], attrs.selected).map(Option),
39
48
  ),
40
49
  );
41
- export const Button = () => {};
50
+ // Sanctioned jiffies-css button variants. The default button needs no class.
51
+ export type ButtonVariant = "secondary" | "contrast" | "outline";
52
+
53
+ // Button emits button[type=button] so it never accidentally submits a form. The
54
+ // optional variant maps to the matching sanctioned jiffies-css class.
55
+ export const Button = (
56
+ variant?: ButtonVariant,
57
+ ...children: DenormChildren[]
58
+ ) =>
59
+ button(
60
+ variant ? { type: "button", class: variant } : { type: "button" },
61
+ ...children,
62
+ );
42
63
 
43
64
  const prepareOptions = (
44
65
  attrs:
@@ -71,12 +92,60 @@ export const Dropdown = (
71
92
  ...attrs,
72
93
  options: typeof options[0] === "string" ? options : options[0],
73
94
  });
74
- export const Radios = () => {};
75
- export const Checks = () => {};
76
- export const Switches = () => {};
95
+ // A {value: label} map: option value (also the id/name stem) to display text.
96
+ export type ChoiceOptions = Record<string, string>;
97
+
98
+ // Derive a stable name/id stem from the legend text.
99
+ const slug = (text: string) =>
100
+ text
101
+ .toLowerCase()
102
+ .replace(/[^a-z0-9]+/g, "-")
103
+ .replace(/^-+|-+$/g, "");
104
+
105
+ // Shared builder for Radios/Checks/Switches: fieldset[role=group] > legend +
106
+ // (input[type] + label[for])* — the jiffies-css grouped-controls structure. The
107
+ // shared name groups the inputs; id/for pairs each input to its label.
108
+ const choiceGroup = (
109
+ type: "radio" | "checkbox",
110
+ legendText: string,
111
+ options: ChoiceOptions,
112
+ role?: "switch",
113
+ ): HTMLFieldSetElement => {
114
+ const name = slug(legendText);
115
+ const children: DenormChildren[] = [legend(legendText)];
116
+ for (const [value, labelText] of Object.entries(options)) {
117
+ const id = `${name}-${value}`;
118
+ const box = input({ type, name, id, value });
119
+ if (role) {
120
+ box.setAttribute("role", role);
121
+ }
122
+ const lbl = label(labelText);
123
+ lbl.setAttribute("for", id);
124
+ children.push(box, lbl);
125
+ }
126
+ const group = fieldset(...children);
127
+ group.setAttribute("role", "group");
128
+ return group;
129
+ };
130
+
131
+ export const Radios = (legendText: string, options: ChoiceOptions) =>
132
+ choiceGroup("radio", legendText, options);
133
+ export const Checks = (legendText: string, options: ChoiceOptions) =>
134
+ choiceGroup("checkbox", legendText, options);
135
+ export const Switches = (legendText: string, options: ChoiceOptions) =>
136
+ choiceGroup("checkbox", legendText, options, "switch");
77
137
 
78
- export const Radio = (_attrs: Omit<InputAttributes, "type">) =>
79
- Input({ type: "radio" });
80
- export const Checkbox = (_attrs: Omit<InputAttributes, "type">) =>
81
- Input({ type: "checkbox" });
82
- export const Switch = () => Checkbox({ role: "switch" });
138
+ // Single-item controls wrap the input in its label (label > input + text), the
139
+ // jiffies-css labelled-control pattern. type and role are fixed per variant.
140
+ export const Radio = (
141
+ labelText: string,
142
+ attrs: Omit<InputAttributes, "type"> = {},
143
+ ) => Input({ ...attrs, type: "radio" }, labelText);
144
+ export const Checkbox = (
145
+ labelText: string,
146
+ attrs: Omit<InputAttributes, "type"> = {},
147
+ ) => Input({ ...attrs, type: "checkbox" }, labelText);
148
+ export const Switch = (
149
+ labelText: string,
150
+ attrs: Omit<InputAttributes, "type" | "role"> = {},
151
+ ) => Input({ ...attrs, type: "checkbox", role: "switch" }, labelText);
@@ -1,9 +1,9 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en-us">
2
+ <html lang="en-US">
3
3
  <head>
4
4
  <title>Jiffies Form</title>
5
5
  <base href="/dom/form/" />
6
- <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
6
+ <link rel="stylesheet" href="https://unpkg.com/@davidsouther/jiffies-css/dist/index.css">
7
7
  </head>
8
8
  <body>
9
9
  <script type="module">
@@ -0,0 +1,206 @@
1
+ "use client"; // Hydrate runs entirely client side.
2
+
3
+ import { reconcileChildren } from "./dom.ts";
4
+
5
+ /**
6
+ * Self-contained IIFE source for the capture stub. Embedded inline by the SSG
7
+ * build pass so events fired before the client bundle loads are queued in
8
+ * window.__hydrateQueue and replayed after hydration. No external references.
9
+ */
10
+ export const captureStubSource = `(function(){
11
+ window.__hydrateQueue = window.__hydrateQueue || [];
12
+ var queue = window.__hydrateQueue;
13
+ var handler = function(event) {
14
+ var path = event.composedPath();
15
+ var unitEl = null;
16
+ for (var i = 0; i < path.length; i++) {
17
+ var node = path[i];
18
+ if (node instanceof Element && customElements.get(node.localName)) {
19
+ unitEl = node;
20
+ break;
21
+ }
22
+ }
23
+ if (!unitEl) return;
24
+ var target = event.target;
25
+ var targetPath = [];
26
+ var cur = target;
27
+ while (cur !== unitEl) {
28
+ var parent = cur.parentNode;
29
+ var siblings = Array.from(parent.childNodes);
30
+ targetPath.unshift(siblings.indexOf(cur));
31
+ cur = parent;
32
+ }
33
+ queue.push({ unitEl: unitEl, type: event.type, targetPath: targetPath, init: { bubbles: event.bubbles, cancelable: event.cancelable } });
34
+ };
35
+ var types = ["click","input","change","submit","keydown"];
36
+ for (var t = 0; t < types.length; t++) {
37
+ document.addEventListener(types[t], handler, true);
38
+ }
39
+ })()`;
40
+
41
+ /**
42
+ * Serialize `units` to a JSON string safe for embedding in an HTML script tag
43
+ * (angle brackets and ampersands are Unicode-escaped).
44
+ */
45
+ export function buildPayload(units: Record<string, unknown>[]): string {
46
+ return JSON.stringify(units)
47
+ .replace(/&/g, "\\u0026")
48
+ .replace(/</g, "\\u003c")
49
+ .replace(/>/g, "\\u003e");
50
+ }
51
+
52
+ /**
53
+ * Read the hydration payload from the `#__hydration` script element embedded
54
+ * by the SSG build step. Returns an empty array when the element is absent.
55
+ */
56
+ export function readPayload(): Record<string, unknown>[] {
57
+ const el = window.document.getElementById("__hydration");
58
+ if (!el) return [];
59
+ return JSON.parse(el.textContent ?? "[]") as Record<string, unknown>[];
60
+ }
61
+
62
+ /**
63
+ * Walk `root` depth-first, returning every element whose localName is a
64
+ * defined custom element. Does NOT descend into matched elements — each custom
65
+ * element owns its own subtree; inner elements will be reached by their
66
+ * parent's `el.update()` call, not by `start()`.
67
+ */
68
+ function scanUnits(root: ParentNode): Element[] {
69
+ const results: Element[] = [];
70
+ const stack: Element[] = [...root.children].reverse();
71
+ while (stack.length > 0) {
72
+ const el = stack.pop() as Element;
73
+ if (customElements.get(el.localName)) {
74
+ results.push(el);
75
+ } else {
76
+ for (let i = el.children.length - 1; i >= 0; i--) {
77
+ stack.push(el.children[i] as Element);
78
+ }
79
+ }
80
+ }
81
+ return results;
82
+ }
83
+
84
+ /**
85
+ * Scan `root` for registered custom elements and schedule each for hydration.
86
+ * `customElements.whenDefined` resolves as a microtask even when the element
87
+ * is already defined. The callback clears server-rendered children then runs
88
+ * `el.update()`, which re-executes the element's render function and rebuilds
89
+ * its subtree. `root` defaults to `window.document.body`.
90
+ */
91
+ export function start(root?: ParentNode): void {
92
+ const r = root ?? window.document.body;
93
+ const units = scanUnits(r);
94
+ const payload = readPayload();
95
+ units.forEach((el, index) => {
96
+ customElements.whenDefined(el.localName).then(() => {
97
+ el.replaceChildren();
98
+ el.update(payload[index]);
99
+ drainQueue(el);
100
+ });
101
+ });
102
+ }
103
+
104
+ // Hydrate custom elements inside `root` without clearing their server-rendered
105
+ // children first. `el.update()` reconciles onto the existing DOM so attributes
106
+ // and listeners are grafted in place. Recurses after each element hydrates so
107
+ // parents are always processed before their nested custom elements.
108
+ function startHydrate(root: ParentNode): void {
109
+ for (const el of scanUnits(root)) {
110
+ customElements.whenDefined(el.localName).then(() => {
111
+ el.update();
112
+ startHydrate(el);
113
+ });
114
+ }
115
+ }
116
+
117
+ interface HydrateQueueEntry {
118
+ unitEl: Element;
119
+ type: string;
120
+ targetPath: number[];
121
+ init: { bubbles: boolean; cancelable: boolean };
122
+ }
123
+
124
+ function getHydrateQueue(): HydrateQueueEntry[] {
125
+ const w = window as unknown as Record<string, unknown>;
126
+ w.__hydrateQueue ??= [];
127
+ return w.__hydrateQueue as HydrateQueueEntry[];
128
+ }
129
+
130
+ /**
131
+ * Install capture-phase listeners on `document` that intercept events
132
+ * targeting nodes inside un-hydrated custom elements and push descriptors into
133
+ * `window.__hydrateQueue`. `start()` drains the queue for each element after
134
+ * `el.update()` runs by calling `drainQueue`.
135
+ */
136
+ export function installCaptureStub(): void {
137
+ const queue = getHydrateQueue();
138
+ const handler = (event: Event) => {
139
+ const path = event.composedPath() as Node[];
140
+ let unitEl: Element | null = null;
141
+ for (const node of path) {
142
+ if (node instanceof Element && customElements.get(node.localName)) {
143
+ unitEl = node;
144
+ break;
145
+ }
146
+ }
147
+ if (!unitEl) return;
148
+ const target = event.target as Node;
149
+ const targetPath: number[] = [];
150
+ let cur: Node = target;
151
+ while (cur !== unitEl) {
152
+ const parent = cur.parentNode as Node;
153
+ targetPath.unshift(
154
+ Array.from(parent.childNodes).indexOf(cur as ChildNode),
155
+ );
156
+ cur = parent;
157
+ }
158
+ queue.push({
159
+ unitEl,
160
+ type: event.type,
161
+ targetPath,
162
+ init: { bubbles: event.bubbles, cancelable: event.cancelable },
163
+ });
164
+ };
165
+ for (const type of ["click", "input", "change", "submit", "keydown"]) {
166
+ window.document.addEventListener(type, handler, true);
167
+ }
168
+ }
169
+
170
+ function drainQueue(el: Element): void {
171
+ const w = window as unknown as Record<string, unknown>;
172
+ const allEntries = (w.__hydrateQueue ?? []) as HydrateQueueEntry[];
173
+ const mine = allEntries.filter((e) => e.unitEl === el);
174
+ w.__hydrateQueue = allEntries.filter((e) => e.unitEl !== el);
175
+ for (const entry of mine) {
176
+ let node: Node = el;
177
+ let resolved = true;
178
+ for (const idx of entry.targetPath) {
179
+ const child = node.childNodes[idx];
180
+ if (!child) {
181
+ console.warn(
182
+ `hydrateQueue: path index ${idx} out of range, dropping queued ${entry.type}`,
183
+ );
184
+ resolved = false;
185
+ break;
186
+ }
187
+ node = child;
188
+ }
189
+ if (resolved) {
190
+ node.dispatchEvent(new Event(entry.type, entry.init));
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Hydrate a server-rendered page: call `render` once, reconcile the result
197
+ * into `mount` without replacing existing DOM nodes, and graft event handlers
198
+ * onto kept server nodes. Custom-element boundaries are left for each element
199
+ * to hydrate itself. Use this instead of `start` when the full page tree is
200
+ * produced by a single render function rather than independent custom elements.
201
+ */
202
+ export function hydrateRoot(mount: Element, render: () => Node | Node[]): void {
203
+ const fresh = [render()].flat() as Node[];
204
+ reconcileChildren(mount, fresh);
205
+ startHydrate(mount);
206
+ }
@@ -0,0 +1,349 @@
1
+ "use client"; // The navigation runtime drives same-document transitions client side.
2
+
3
+ import { start } from "../hydrate.ts";
4
+
5
+ /**
6
+ * Context describing a completed navigation, handed to every `onNavigate` hook
7
+ * (e.g. an analytics pageview). Built by `navigate()` after the head reconcile,
8
+ * so `title` reflects the destination and `url` is absolute.
9
+ */
10
+ export interface NavigationContext {
11
+ /** Absolute destination URL of the navigation. */
12
+ url: URL;
13
+ /** document.title after the head reconcile for this navigation. */
14
+ title: string;
15
+ /**
16
+ * "first" on initial load; otherwise the navigation's entry kind —
17
+ * "push" | "traverse" | "replace" — mirrored from the NavigateEvent on the
18
+ * interception path. A direct `navigate(url)` with no event, and a "reload",
19
+ * both report "push".
20
+ */
21
+ type: "first" | "push" | "traverse" | "replace";
22
+ }
23
+
24
+ /** A hook registered through `onNavigate`, run once per completed navigation. */
25
+ type NavigateCallback = (ctx: NavigationContext) => void;
26
+
27
+ /**
28
+ * Registered `onNavigate` hooks, fired in registration order after every
29
+ * completed navigation. Module-level state only: this module installs no
30
+ * listeners, touches no DOM, and never hydrates or calls `start()` at import
31
+ * time (M1 plan Step 1 invariant). The static `../hydrate.ts` import does pull
32
+ * in `../dom.ts`, whose jsdom bootstrap runs only in windowless Node and is
33
+ * skipped under a browser or jsdom where `window` already exists — so importing
34
+ * `./index.ts` is side-effect-free in those environments.
35
+ */
36
+ const navigateCallbacks: NavigateCallback[] = [];
37
+
38
+ /**
39
+ * Register `cb` to run after every in-app navigation completes (body swapped and
40
+ * hydration scheduled). Invariant: callbacks fire exactly once per navigation,
41
+ * in registration order, with the navigation's `NavigationContext`. Used by
42
+ * shell code such as a GA `page_view`.
43
+ */
44
+ export function onNavigate(cb: NavigateCallback): void {
45
+ navigateCallbacks.push(cb);
46
+ }
47
+
48
+ /** A hook registered through `onFirstLoad`, run once when the initial page hydrates. */
49
+ type FirstLoadCallback = (ctx: NavigationContext) => void;
50
+
51
+ /** Hooks awaiting the first load, fired once during bootstrap in registration order. */
52
+ const firstLoadCallbacks: FirstLoadCallback[] = [];
53
+
54
+ /**
55
+ * The first-load context, set by `bootstrap()` once the initial page hydrates.
56
+ * `undefined` until then. Retained so an `onFirstLoad` registered AFTER first load
57
+ * (e.g. a shell module that imports the runtime lazily) still receives the event.
58
+ */
59
+ let firstLoadContext: NavigationContext | undefined;
60
+
61
+ /**
62
+ * Register `cb` to run once, when the initial page hydrates on first document load.
63
+ * Invariant: if registered BEFORE bootstrap, `cb` is queued and fired during
64
+ * bootstrap with the first-load context (`type: "first"`). If registered AFTER first
65
+ * load, `cb` is invoked immediately with the retained context, so a late
66
+ * registration never drops the initial event (design §1). Registering installs no
67
+ * listeners and touches no DOM (import-side-effect-free invariant).
68
+ */
69
+ export function onFirstLoad(cb: FirstLoadCallback): void {
70
+ if (firstLoadContext) {
71
+ cb(firstLoadContext);
72
+ return;
73
+ }
74
+ firstLoadCallbacks.push(cb);
75
+ }
76
+
77
+ /**
78
+ * Attribute marking a one-time "shell" node in `<head>` (a theme bootstrap, an
79
+ * analytics tag): the build emits it, and the head reconciler preserves any node
80
+ * carrying it by identity across navigations so its inline script never re-runs.
81
+ */
82
+ const SHELL_ATTR = "data-shell";
83
+
84
+ /** True for a head node the reconciler must preserve in place across navigations. */
85
+ function isShell(node: ChildNode): boolean {
86
+ return node instanceof Element && node.hasAttribute(SHELL_ATTR);
87
+ }
88
+
89
+ /**
90
+ * Abandon the same-document path for an ordinary full document load of `url`
91
+ * (design "Failure modes"). Returns `null` so a fetch caller can `return fullLoad(url)`
92
+ * to both trigger the load and signal "stop here" — the destination becomes a normal
93
+ * navigation, never a broken intermediate state.
94
+ */
95
+ function fullLoad(url: URL): null {
96
+ window.location.assign(url.href);
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Fetch `url` and parse its body text into a detached Document via the global
102
+ * DOMParser. Returns the parsed document, or `null` when the same-document path
103
+ * must be abandoned for a full load: a non-2xx response or a network error falls
104
+ * back to `fullLoad(url)` so the caller aborts before reconciling. Invariant: a
105
+ * returned document is detached — its nodes must be adopted with
106
+ * `document.importNode` before insertion into the live document.
107
+ */
108
+ async function fetchDocument(url: URL): Promise<Document | null> {
109
+ let response: Response;
110
+ try {
111
+ response = await fetch(url);
112
+ } catch {
113
+ return fullLoad(url); // network error
114
+ }
115
+ if (!response.ok) {
116
+ return fullLoad(url); // non-2xx
117
+ }
118
+ const html = await response.text();
119
+ return new DOMParser().parseFromString(html, "text/html");
120
+ }
121
+
122
+ /**
123
+ * Reconcile the live `<head>` against `destHead`. Preserves every existing
124
+ * `[data-shell]` node by identity; replaces all non-shell live nodes with
125
+ * `destHead`'s non-shell nodes (adopted into the live document). Applies the
126
+ * destination `<title>` via `document.title` and mirrors `<html lang>`. Leaves
127
+ * the destination `#__hydration` payload in place for the subsequent `start()`.
128
+ */
129
+ function reconcileHead(destHead: HTMLHeadElement): void {
130
+ const liveHead = window.document.head;
131
+
132
+ // Drop every live per-page node, leaving the [data-shell] nodes untouched —
133
+ // preserved by identity so their inline scripts (theme, analytics) never re-run.
134
+ for (const node of [...liveHead.childNodes]) {
135
+ if (!isShell(node)) node.remove();
136
+ }
137
+
138
+ // Adopt the destination's per-page nodes (title, metadata, the #__hydration
139
+ // payload) and append them. The destination's own shell nodes are dropped —
140
+ // the live ones already cover them.
141
+ for (const node of [...destHead.childNodes]) {
142
+ if (isShell(node)) continue;
143
+ liveHead.appendChild(window.document.importNode(node, true));
144
+ }
145
+
146
+ // Apply <title> through document.title so it takes effect immediately, and
147
+ // mirror <html lang> from the destination when it differs.
148
+ const destRoot = destHead.ownerDocument.documentElement;
149
+ window.document.title = destHead.ownerDocument.title;
150
+ if (destRoot.lang && destRoot.lang !== window.document.documentElement.lang) {
151
+ window.document.documentElement.lang = destRoot.lang;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Replace the live `<body>` children with `destBody`'s children, adopted via
157
+ * `document.importNode`. New element instances replace the old ones — a child
158
+ * replacement, not an in-place patch — so the destination's island is a fresh
159
+ * node. Any `<script type="module">` rides along inert: a script inserted via
160
+ * the DOM never executes on its own, which is why `importPageModules` imports it
161
+ * explicitly. (`destBody` is typed `HTMLElement` because `Document.body` is.)
162
+ */
163
+ function swapBody(destBody: HTMLElement): void {
164
+ const adopted = [...destBody.childNodes].map((node) =>
165
+ window.document.importNode(node, true),
166
+ );
167
+ window.document.body.replaceChildren(...adopted);
168
+ }
169
+
170
+ /** Matches each inline `import "<spec>";` statement, capturing the specifier. */
171
+ const IMPORT_STATEMENT = /import\s+["']([^"']+)["']\s*;?/g;
172
+
173
+ /**
174
+ * Extract every `import "<spec>";` specifier from `root`'s
175
+ * `<script type="module">` elements and dynamically import each, awaiting all.
176
+ * The build emits inline `import` statements (not `src` attributes, matching
177
+ * src/ssg/rewrite.ts), and a script node inserted via the DOM never executes on
178
+ * its own, so the runtime imports the specifiers itself. The ES module cache
179
+ * dedupes chunks already loaded this session.
180
+ */
181
+ async function importPageModules(root: ParentNode): Promise<void> {
182
+ const specifiers: string[] = [];
183
+ for (const script of root.querySelectorAll('script[type="module"]')) {
184
+ for (const match of (script.textContent ?? "").matchAll(IMPORT_STATEMENT)) {
185
+ specifiers.push(match[1]);
186
+ }
187
+ }
188
+ await Promise.all(specifiers.map((spec) => import(spec)));
189
+ }
190
+
191
+ /**
192
+ * Same-document transition hints the interceptor lifts off the `NavigateEvent`
193
+ * and threads into `navigate()` — the View-Transition guard reads a field only
194
+ * the interception path sees. `hasUAVisualTransition` is true when the browser
195
+ * already ran its own visual transition for this navigation (a cross- to
196
+ * same-document hand-off), so the runtime must NOT start a second one;
197
+ * `navigationType` is the entry kind, reported on the `NavigationContext`. A
198
+ * direct `navigate(url)` (no event) passes neither.
199
+ */
200
+ interface NavigateOptions {
201
+ hasUAVisualTransition?: boolean;
202
+ navigationType?: "push" | "replace" | "traverse" | "reload";
203
+ }
204
+
205
+ /**
206
+ * The shared same-document core the Navigation API interceptor funnels into
207
+ * (design §2). Fetches the destination's built HTML, reconciles `<head>`, swaps
208
+ * `<body>` — inside a View Transition when one is available — imports the
209
+ * destination's page modules, hydrates, then fires `onNavigate`. Resolves when
210
+ * hydration has been scheduled and hooks have fired. If `fetchDocument` fell back
211
+ * to a full load (non-2xx or network error), it returns `null` and this aborts
212
+ * without reconciling, swapping, or firing hooks.
213
+ */
214
+ export async function navigate(
215
+ url: string | URL,
216
+ options: NavigateOptions = {},
217
+ ): Promise<void> {
218
+ const target = new URL(url, window.location.href);
219
+ const destination = await fetchDocument(target);
220
+ if (destination === null) return; // fell back to a full document load
221
+ reconcileHead(destination.head);
222
+
223
+ // Swap the body inside a same-document View Transition when the browser
224
+ // supports one AND has not already animated this navigation itself (design §2
225
+ // step 4). startViewTransition snapshots the live DOM, runs the swap callback
226
+ // to mutate it, then animates the before/after states; awaiting
227
+ // updateCallbackDone resumes once the swap has applied (the DOM is updated),
228
+ // before modules import and hydration run. Where startViewTransition is absent
229
+ // or the UA already transitioned (hasUAVisualTransition), the swap is applied
230
+ // directly — degraded to an abrupt replacement, never broken (design "Failure
231
+ // modes").
232
+ const doc = window.document;
233
+ const applySwap = () => swapBody(destination.body);
234
+ if (
235
+ typeof doc.startViewTransition === "function" &&
236
+ !options.hasUAVisualTransition
237
+ ) {
238
+ await doc.startViewTransition(applySwap).updateCallbackDone;
239
+ } else {
240
+ applySwap();
241
+ }
242
+
243
+ await importPageModules(window.document.body);
244
+
245
+ // Hydrate the swapped-in body: start() reads the destination #__hydration
246
+ // payload (placed by reconcileHead) and schedules each island's update().
247
+ start(window.document.body);
248
+
249
+ // Report the completed navigation. title reflects the reconciled <head>; url
250
+ // is the absolute target; type mirrors the navigation's entry kind (a "reload"
251
+ // or a direct navigate() with no event reports "push"). Hooks fire once each,
252
+ // in registration order.
253
+ const { navigationType } = options;
254
+ const context: NavigationContext = {
255
+ url: target,
256
+ title: window.document.title,
257
+ type:
258
+ navigationType === "push" ||
259
+ navigationType === "replace" ||
260
+ navigationType === "traverse"
261
+ ? navigationType
262
+ : "push",
263
+ };
264
+ for (const cb of navigateCallbacks) cb(context);
265
+ }
266
+
267
+ /**
268
+ * Install the navigation interceptor. The Navigation API is the sole interception
269
+ * mechanism: it delivers one `navigate` event for every same-document candidate —
270
+ * link click, programmatic navigation, and back/forward — so a single listener
271
+ * replaces a click handler plus a `popstate` listener, and the API owns history
272
+ * and scroll restoration (so the core stays `history.pushState`-free).
273
+ *
274
+ * The Navigation API is Baseline as of Jan 2026; this project targets evergreen
275
+ * browsers. Where it is absent there is NO interception: links perform normal
276
+ * full-document navigations — degraded (no shared-runtime hydration) but never
277
+ * broken. That minimal alternative is the entire fallback.
278
+ *
279
+ * Called by `bootstrap()` after `start()` and before firing `onFirstLoad`. The
280
+ * listener is added only here, never at module top level, so importing this module
281
+ * stays side-effect-free. Types come from `@types/dom-navigation` (folded into
282
+ * lib.dom as of TS 6.0); see the tracking task in `docs/developer/TASKS.md`.
283
+ */
284
+ function installInterceptor(): void {
285
+ if (!("navigation" in window)) return;
286
+
287
+ window.navigation.addEventListener("navigate", (event) => {
288
+ // Decline (let the browser navigate natively) when the API cannot intercept
289
+ // — cross-origin, etc. — or for a hash-only, download, or non-GET (form)
290
+ // navigation. Form submissions are out of scope (design §2, Summary).
291
+ if (!event.canIntercept) return;
292
+ if (event.hashChange) return;
293
+ if (event.downloadRequest !== null) return;
294
+ if (event.formData !== null) return;
295
+
296
+ // Claim the navigation: run the shared core as the same-document transition.
297
+ // navigate() fetches, reconciles <head>, swaps <body> (inside a View
298
+ // Transition when the browser offers one and did not already animate this
299
+ // navigation), imports the page module, hydrates, and fires onNavigate. The
300
+ // API commits the history entry. hasUAVisualTransition and navigationType are
301
+ // threaded through because only this interception path sees the event.
302
+ const { url } = event.destination;
303
+ event.intercept({
304
+ handler: () =>
305
+ navigate(url, {
306
+ hasUAVisualTransition: event.hasUAVisualTransition,
307
+ navigationType: event.navigationType,
308
+ }),
309
+ });
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Bootstrap route hydration for the initial document. Explicit entry — NOT run at
315
+ * import time (the module stays side-effect-free; tests and the M3 injected entry
316
+ * decide when this runs). On call it:
317
+ * 1. `start(window.document.body)` — hydrate the server-rendered initial islands
318
+ * in place (reads the initial `#__hydration` payload already in <head>).
319
+ * 2. `installInterceptor()` — register the Navigation API `navigate` listener to
320
+ * capture subsequent in-app navigations, between `start()` and `onFirstLoad`.
321
+ * 3. Build the first-load `NavigationContext`: `url` from `window.location.href`,
322
+ * `title` from `document.title`, `type: "first"`. Store it in `firstLoadContext`.
323
+ * 4. Fire every queued `onFirstLoad` callback once, in registration order.
324
+ * Invariant: `onFirstLoad` fires exactly once per bootstrap.
325
+ */
326
+ export async function bootstrap(): Promise<void> {
327
+ // 1. Hydrate the server-rendered initial islands in place. start() reads the
328
+ // initial #__hydration payload already in <head> and schedules each update().
329
+ start(window.document.body);
330
+
331
+ // 2. Register the Navigation API interceptor so subsequent in-app navigations
332
+ // are captured. Between start() and firing onFirstLoad, per design §1.
333
+ installInterceptor();
334
+
335
+ // 3. Build and retain the first-load context (design §1). url is the initial
336
+ // location, title the current document title, type "first".
337
+ firstLoadContext = {
338
+ url: new URL(window.location.href),
339
+ title: window.document.title,
340
+ type: "first",
341
+ };
342
+
343
+ // 4. Fire every queued onFirstLoad callback once, in registration order, then
344
+ // clear the queue so the event fires exactly once per bootstrap. A callback
345
+ // registered AFTER this point fires immediately against firstLoadContext (see
346
+ // onFirstLoad), so a late registration never drops the initial event.
347
+ for (const cb of firstLoadCallbacks) cb(firstLoadContext);
348
+ firstLoadCallbacks.length = 0;
349
+ }