@omerdlw/base-framework 2.0.1 → 3.0.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/README.md +4 -1
- package/dist/{chunk-XASQT4CQ.js → chunk-66EYJBIP.js} +47 -27
- package/dist/chunk-66EYJBIP.js.map +1 -0
- package/dist/{chunk-SKBITB5X.js → chunk-CYU6SGAL.js} +3 -3
- package/dist/{chunk-SKBITB5X.js.map → chunk-CYU6SGAL.js.map} +1 -1
- package/dist/{chunk-6SG7V77J.js → chunk-HHWRTQKQ.js} +100 -67
- package/dist/chunk-HHWRTQKQ.js.map +1 -0
- package/dist/chunk-HLL5EHXC.js +485 -0
- package/dist/chunk-HLL5EHXC.js.map +1 -0
- package/dist/{chunk-A5RE3F6O.js → chunk-JIXBFFRD.js} +193 -357
- package/dist/chunk-JIXBFFRD.js.map +1 -0
- package/dist/{chunk-6ZJVLUBE.js → chunk-M57SNWXK.js} +8 -6
- package/dist/chunk-M57SNWXK.js.map +1 -0
- package/dist/chunk-RIOKLMYM.js +145 -0
- package/dist/chunk-RIOKLMYM.js.map +1 -0
- package/dist/{chunk-QXR55VVS.js → chunk-TG6NMHXI.js} +241 -162
- package/dist/chunk-TG6NMHXI.js.map +1 -0
- package/dist/index.js +7 -5
- package/dist/modules/ambient/analyze.d.ts +18 -3
- package/dist/modules/ambient/analyze.d.ts.map +1 -1
- package/dist/modules/ambient/dom.d.ts +14 -1
- package/dist/modules/ambient/dom.d.ts.map +1 -1
- package/dist/modules/ambient/hooks.d.ts.map +1 -1
- package/dist/modules/ambient/index.d.ts +2 -2
- package/dist/modules/ambient/index.d.ts.map +1 -1
- package/dist/modules/ambient/index.js +1 -1
- package/dist/modules/ambient/server/index.d.ts +1 -1
- package/dist/modules/ambient/server/index.d.ts.map +1 -1
- package/dist/modules/ambient/server/index.js +2 -2
- package/dist/modules/ambient/server/index.js.map +1 -1
- package/dist/modules/ambient/settings.d.ts +1 -0
- package/dist/modules/ambient/settings.d.ts.map +1 -1
- package/dist/modules/ambient/types.d.ts +13 -0
- package/dist/modules/ambient/types.d.ts.map +1 -1
- package/dist/modules/ambient/worker/index.js +1 -1
- package/dist/modules/context-menu/index.js +4 -3
- package/dist/modules/dock/cards.d.ts.map +1 -1
- package/dist/modules/dock/constants.d.ts +1 -20
- package/dist/modules/dock/constants.d.ts.map +1 -1
- package/dist/modules/dock/index.js +3 -2
- package/dist/modules/dock/routing/guards.d.ts +1 -1
- package/dist/modules/dock/routing/navigation.d.ts +7 -4
- package/dist/modules/dock/routing/navigation.d.ts.map +1 -1
- package/dist/modules/dock/runtime/display.d.ts +31 -2
- package/dist/modules/dock/runtime/display.d.ts.map +1 -1
- package/dist/modules/dock/runtime/dock.d.ts +2 -2
- package/dist/modules/dock/runtime/dock.d.ts.map +1 -1
- package/dist/modules/dock/types.d.ts +5 -27
- package/dist/modules/dock/types.d.ts.map +1 -1
- package/dist/modules/index.d.ts +3 -2
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +7 -5
- package/dist/modules/loading/context.d.ts.map +1 -1
- package/dist/modules/loading/index.js +1 -1
- package/dist/modules/navigation/constants.d.ts +37 -0
- package/dist/modules/navigation/constants.d.ts.map +1 -0
- package/dist/modules/navigation/context.d.ts +10 -0
- package/dist/modules/navigation/context.d.ts.map +1 -0
- package/dist/modules/navigation/hooks.d.ts +29 -0
- package/dist/modules/navigation/hooks.d.ts.map +1 -0
- package/dist/modules/navigation/index.d.ts +10 -0
- package/dist/modules/navigation/index.d.ts.map +1 -0
- package/dist/modules/navigation/index.js +81 -0
- package/dist/modules/navigation/index.js.map +1 -0
- package/dist/modules/navigation/listeners.d.ts +15 -0
- package/dist/modules/navigation/listeners.d.ts.map +1 -0
- package/dist/modules/navigation/module.d.ts +25 -0
- package/dist/modules/navigation/module.d.ts.map +1 -0
- package/dist/modules/navigation/navigator.d.ts +8 -0
- package/dist/modules/navigation/navigator.d.ts.map +1 -0
- package/dist/modules/navigation/settings.d.ts +10 -0
- package/dist/modules/navigation/settings.d.ts.map +1 -0
- package/dist/modules/navigation/state.d.ts +20 -0
- package/dist/modules/navigation/state.d.ts.map +1 -0
- package/dist/modules/navigation/types.d.ts +168 -0
- package/dist/modules/navigation/types.d.ts.map +1 -0
- package/dist/modules/navigation/utils.d.ts +11 -0
- package/dist/modules/navigation/utils.d.ts.map +1 -0
- package/docs/getting-started.md +2 -0
- package/docs/modules/README.md +5 -1
- package/docs/modules/ambient.md +9 -6
- package/docs/modules/dock.md +12 -10
- package/docs/modules/loading.md +2 -2
- package/docs/modules/navigation.md +216 -0
- package/docs/proposals/navigation.md +209 -0
- package/package.json +6 -1
- package/templates/AGENTS.md +2 -0
- package/dist/chunk-6SG7V77J.js.map +0 -1
- package/dist/chunk-6ZJVLUBE.js.map +0 -1
- package/dist/chunk-A5RE3F6O.js.map +0 -1
- package/dist/chunk-QXR55VVS.js.map +0 -1
- package/dist/chunk-XASQT4CQ.js.map +0 -1
- package/dist/modules/dock/routing/transactions.d.ts +0 -21
- package/dist/modules/dock/routing/transactions.d.ts.map +0 -1
package/docs/modules/ambient.md
CHANGED
|
@@ -119,7 +119,7 @@ Default names; see §5.2 to rename them or turn groups off. Nothing is written u
|
|
|
119
119
|
|
|
120
120
|
`colors` keys are custom property names: a key starting with `--` is used verbatim; anything else becomes `--color-<key>`. `colors` always wins over extracted values.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
Themes on the same element stack: the latest wins each variable, and when one goes the one beneath shows again; when the last goes, the inline values (and priorities) from before are restored exactly. A page-wide theme **lingers** `linger` ms (3 s) after it goes, and the next theme to arrive takes over from it at once: so a page that leaves through a `loading.tsx` hands its colours to the next page instead of dropping to the CSS fallbacks for the length of the loading screen. With no next theme, the variables are removed when the time runs out. If `transition` is true (default) the theme's `transition` classes are added to the target while the theme is active (and removed again only for scoped targets, so the global tint keeps animating between pages).
|
|
123
123
|
|
|
124
124
|
## 5. Public API
|
|
125
125
|
|
|
@@ -135,6 +135,7 @@ On cleanup the previous inline values (and priorities) are restored exactly. If
|
|
|
135
135
|
| `extractPaletteFromImage(src, options?)` | util | Async client extraction with cache and in-flight de-duplication; resolves `null` on any failure. |
|
|
136
136
|
| `primeAmbientPalette(src, palette)`, `getCachedAmbientPalette(src)` | utils | Seed / read the client cache (e.g. with server palettes). |
|
|
137
137
|
| `analyzeAmbientPixels(pixels, options?)` | util | The pure analysis on RGBA pixels (`{ data, width, height }`). Same function on server, worker and client. |
|
|
138
|
+
| `AMBIENT_ANALYSIS_VERSION`, `skinLikeness(color)` | const / util | The analysis version (put it in long-lived palette cache keys); how skin-like an OKLCH colour is (0–1). |
|
|
138
139
|
| `findContentBounds(pixels)` | util | The letterbox / pillarbox cut used by the analysis. |
|
|
139
140
|
| `maxChroma(l, h)`, `toneOf(h, l, rel, cap?)`, `onColorFor(bg, min?)`, `contrastRatio(a, b)`, `formatOklch(lch)`, `srgbToOklab(r, g, b)` | utils | Gamut-aware OKLCH helpers. |
|
|
140
141
|
| `paletteVarMap(palette, names?)`, `resolveAmbientVarMap(…)`, `resolveCssVarNames(overrides?)` | utils | The variable maps the hooks write. |
|
|
@@ -151,6 +152,7 @@ On cleanup the previous inline values (and priorities) are restored exactly. If
|
|
|
151
152
|
| `cssVars` | `null` | Per-theme override of the variable names (same shape as `configureAmbient({ cssVars })`). |
|
|
152
153
|
| `scope` | `null` → `document.documentElement` | CSS selector string, element, or `ref`. A selector that matches nothing applies nothing. |
|
|
153
154
|
| `transition` | `true` | Add the theme's transition classes to the target. |
|
|
155
|
+
| `linger` | `configureAmbient` `linger` (3000) on the page root, `0` on a scoped target | How long (ms) the variables stay after the theme goes, unless another theme takes over first. |
|
|
154
156
|
| `initialPalette` | `null` | Palette shown before extraction finishes (and if it fails); used as is (no extraction) when `source` equals `image`. |
|
|
155
157
|
| `options` | `null` | `{ initialPalette, strategy, proxy, analysis }` for this theme. |
|
|
156
158
|
|
|
@@ -168,6 +170,7 @@ Settings live on `globalThis`, so every bundled copy of the module (one per pack
|
|
|
168
170
|
| `cssVars` | `--ambient-shadow`, `--ambient-{role}`, `--ambient-on-{role}`, `--ambient-{role}-{tone}`, `--ambient-{region}`, all 11 tones | Rename any variable; `null` turns that variable or group off; `tones: [10, 20, 90]` or `false` limits the ramp variables. |
|
|
169
171
|
| `analysis` | `DEFAULT_ANALYSIS` | `sampleSize` 128, `clusters` 7, `centerWeight` 0.5, `letterbox` true, `minHueDistance` 40, `minContrast` 4.5, `shadowLightness` 0.16. |
|
|
170
172
|
| `cacheLimit` | `100` | Client palettes kept (oldest evicted). |
|
|
173
|
+
| `linger` | `3000` (`DEFAULT_LINGER_MS`) | How long (ms) a page-wide theme's variables stay after it goes, so the next page takes over from them. `0` removes them at once. |
|
|
171
174
|
|
|
172
175
|
### 5.3 `AmbientPalette`
|
|
173
176
|
|
|
@@ -209,15 +212,15 @@ Before every request, redirects included, the host is resolved (`resolve`, defau
|
|
|
209
212
|
|
|
210
213
|
- **Loading (client).** The image URL is fetched as is with `mode: "cors"`: no `?cors=1`, so the request the page already made is answered from the HTTP cache. If that is blocked and the image is cross-origin, it is fetched once more with `cache: "reload"`: some CDNs (TMDB's among them) send CORS headers only to requests that carry an `Origin` and do not `Vary: Origin`, so the copy a plain `<img>` cached has none. Only then is the proxy tried. Give such images `crossOrigin="anonymous"` (`next/image` takes it too) and the one download serves both the `<img>` and the palette, with no blocked request in the console. Decoding uses `createImageBitmap` (asking for a 128 px wide bitmap where supported) and an `OffscreenCanvas` (or a `<canvas>`), so a full-size image is never drawn on the main thread; browsers without `createImageBitmap` fall back to an `<img>`.
|
|
211
214
|
- **Sampling.** The image is scaled to 128 px on its long side, keeping its aspect ratio. Pixels with alpha < 128 are skipped. Uniformly near-black rows/columns at the edges (letterbox / pillarbox; mean L < 0.12, max L < 0.2, at most 22% per side) are cut. Each pixel's weight falls from 1.5 at the centre to 0.5 at the corners (`centerWeight` 0.5).
|
|
212
|
-
- **Clustering.** Pixels are binned by 15-bit RGB (OKLab averaged per bin). Coloured bins (chroma ≥ 0.022) and grey bins are clustered **apart** with weighted, deterministically seeded k-means in OKLab (ΔL counts 0.45×, so hue beats lightness; vivid bins pull up to ~2.5× harder), 5 + 2 clusters by default. Splitting them keeps the greys of a dark poster from using up every cluster and swallowing its one coloured detail.
|
|
213
|
-
- **Roles.** A hue's share is that of its family (clusters within 20°). `dominant` = max share × chroma; `vibrant` = max chroma² × share^0.3; `accent` = the best hue at least `minHueDistance` (40°) from `dominant`, or `vibrant` marked `fromImage: false` when the image has one hue; `muted` = the largest low-chroma (≤ 0.09) colour, or a quiet tone of the dominant hue. Hue trust fades below L 0.28 and above L 0.9.
|
|
214
|
-
- **Tones.**
|
|
215
|
+
- **Clustering.** Pixels are binned by 15-bit RGB (OKLab averaged per bin). Coloured bins (chroma ≥ 0.022) and grey bins are clustered **apart** with weighted, deterministically seeded k-means in OKLab (ΔL counts 0.45×, so hue beats lightness; vivid bins pull up to ~2.5× harder), 5 + 2 clusters by default. Splitting them keeps the greys of a dark poster from using up every cluster and swallowing its one coloured detail. The vivid pull only shapes the clusters: a cluster's colour is the plain mean of its pixels, so a face is not tinted toward the red title it shares a cluster with.
|
|
216
|
+
- **Roles.** A hue's share is that of its family (clusters within 20°). `dominant` = max share × chroma; `vibrant` = max chroma² × share^0.3; `accent` = the best hue at least `minHueDistance` (40°) from `dominant`, or `vibrant` marked `fromImage: false` when the image has one hue; `muted` = the largest low-chroma (≤ 0.09) colour, or a quiet tone of the dominant hue. Skin, hair, wood and tungsten-lit walls (hues near 54°, chroma 0.03–0.12, see `skinLikeness`) count up to 65% less in every pick, roles and regions alike: film frames are full of them, and they are what a frame shows, rarely its mood. Hue trust fades below L 0.28 and above L 0.9.
|
|
217
|
+
- **Tones.** A role keeps about its source's **chroma**, not its share of the gamut: × 1.15 (`dominant`, regions), × 1.3 (`accent`), × 1.35 (`vibrant`), × 1 (`muted`); a faint tint is raised to at most 0.05 so it still reads as a colour; skin is held back up to 30%; and reds and oranges get a 30% lower ceiling, since they read louder than blues at the same chroma. Carrying the gamut share instead (as before `AMBIENT_ANALYSIS_VERSION` 2) turned pale faces and beige rooms into loud orange: at mid lightness orange has twice the room teal has. The ramp keeps the role's share of the gamut at each L (`toneOf`, × 0.96 of `maxChroma`), so a dark tone of blue stays blue; nothing is ever out of gamut.
|
|
215
218
|
- **`shadow`.** The hue of the shadows (clusters at L ≤ 0.5, scored by share × relative chroma) or, without coloured shadows, of `dominant`; at L `shadowLightness` ± 0.02 and up to 0.1 chroma.
|
|
216
219
|
- **`on`.** A near-white or near-black carrying a hint of the role's hue, pushed toward pure white / black until it reaches `minContrast`.
|
|
217
|
-
- **Regions.** Each region (thirds of the content box; `center` is the middle half) takes its most chromatic-and-present cluster at L 0.42–0.72; `strength` is how coloured the region is.
|
|
220
|
+
- **Regions.** Each region (thirds of the content box; `center` is the middle half) takes its most chromatic-and-present cluster, coloured by that cluster's own pixels **in the region** (not its frame-wide mean), at L 0.42–0.72 and × 1.15 its chroma; `strength` is how coloured the region is.
|
|
218
221
|
- **Neutral images.** If coloured pixels make up under 3% (weighted), the palette is grey: `neutral: true`, `dominant`, `vibrant`, `muted`, `shadow` and regions have chroma 0. A vivid detail (≥ 0.6% of the image, chroma ≥ 0.06) still becomes `accent`, e.g. the red coat in *Schindler's List*.
|
|
219
222
|
- **Cost.** About 3–8 ms per image for the analysis (Node, warm); in Chromium, ~13 ms for a cached image end to end on the main thread, ~20 ms through the worker (off the main thread). ~2.7 KB of JSON per palette.
|
|
220
|
-
- **Cache.** Client: `cacheLimit` palettes (oldest evicted), keyed by URL (+ analysis overrides); concurrent requests share one promise. Server: 500 per process.
|
|
223
|
+
- **Cache.** Client: `cacheLimit` palettes (oldest evicted), keyed by URL (+ analysis overrides); concurrent requests share one promise. Server: 500 per process. A cache that outlives a deploy (`unstable_cache`, a database) should carry `AMBIENT_ANALYSIS_VERSION` in its key, so palettes are computed again when the analysis changes.
|
|
221
224
|
- **Stability.** `colors`, `cssVars`, `options` and `initialPalette` are stabilized with `shallowEqual`, so fresh object literals each render do **not** re-apply the theme (keep `cssVars.tones` a constant array). The same pixels always give the same palette.
|
|
222
225
|
- **Failure.** Client: `extractPaletteFromImage` resolves `null` and the hooks keep `initialPalette` (or nothing); it never throws. Server: `null`, except a missing decoder.
|
|
223
226
|
|
package/docs/modules/dock.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
| **Renders** | `Dock` overlay, portalled to `document.body`, DOM id `dock-card-stack` |
|
|
12
12
|
| **Registry type** | `dock` (key policy `path`, lifecycle `route`, cleanup delay 600 ms, entries merged by priority) |
|
|
13
13
|
| **Page slice** | `usePage({ dock: {…} })` → controller `page.modules.dock` |
|
|
14
|
-
| **Peers (`uses`)** | `loading`, `media`, `notification` (optional; inert stand-ins when absent)
|
|
14
|
+
| **Peers (`uses`)** | `loading`, `media`, `navigation`, `notification` (optional; inert stand-ins when absent, a private navigator for `navigation`) |
|
|
15
15
|
| **Theme** | `dockTheme` slots in `src/config/dock.module.theme.ts` |
|
|
16
16
|
| **Events it owns** | `DOCK_STATUS_SET`, `DOCK_STATUS_CLEAR`, `DOCK_GUARD`, `DOCK_NOT_FOUND`, `DOCK_NAVIGATE_START` / `DOCK_NAVIGATE` / `DOCK_NAVIGATE_END`, `DOCK_UPDATE_BADGE` |
|
|
17
17
|
| **CSS output** | `--dock-h` on `<html>` (dock height + 16 px buffer) |
|
|
@@ -35,7 +35,7 @@ Three ideas explain everything else:
|
|
|
35
35
|
|
|
36
36
|
1. **Cards come from the registry.** A page registers a card (`usePage({ dock })`, `useDockRegistration`, or static entries in `src/app/registry.tsx`). The card whose `path` matches the URL is the active item. Expanding the stack lists all registered cards and turns it into a navigator.
|
|
37
37
|
2. **One thing owns the top card at a time ("attention").** Surface, status, HUD, operation, loading and the plain route all compete; the highest priority wins (see [§7](#7-attention-who-owns-the-top-card)).
|
|
38
|
-
3. **Navigation goes through the dock.** `navigate()` runs guards, records scroll/focus so "back" restores position, tracks a transaction (timeout, supersession), and prefetches on hover intent.
|
|
38
|
+
3. **Navigation goes through the dock.** `navigate()` runs guards, records scroll/focus so "back" restores position, tracks a transaction (timeout, supersession), and prefetches on hover intent. The transaction is a trip of the [navigation module](./navigation.md) when it is installed.
|
|
39
39
|
|
|
40
40
|
## 3. Quick start
|
|
41
41
|
|
|
@@ -270,6 +270,8 @@ How `usePage` turns the slice into registry entries (`dockModule.page`):
|
|
|
270
270
|
|
|
271
271
|
The active card is chosen from registered items in this order: a selected data-source item → exact `path` / `targetPath` match → longest path-prefix match → first item. On a 404 page only `/` and the not-found card are listed. While a navigation transaction is pending, the card for the **destination** is used so the dock changes before the route does.
|
|
272
272
|
|
|
273
|
+
When the path changes and no card claims it yet (only `/` as a prefix, or the first item), the dock **holds the previous card** on top instead of falling back to Home. This covers a `loading.tsx` boundary or a slow fetch before the new page's `usePage` registers. The held card carries `isRoutePending: true` and stays even if its own registration is gone. The hold lasts while the navigation is in flight plus `DOCK_PENDING_ROUTE_HOLD_MS` (2.5 s) after it commits. If no card arrives by then, the usual fallback applies. The first page load has nothing to hold.
|
|
274
|
+
|
|
273
275
|
## 7. Attention: who owns the top card?
|
|
274
276
|
|
|
275
277
|
`resolveDockAttention` scores each candidate and the highest wins. A new candidate with a higher score takes over immediately; when it goes away the next one returns.
|
|
@@ -552,12 +554,13 @@ The starter `src/app/error.tsx` (Next.js's segment boundary, which catches page
|
|
|
552
554
|
Returns `Promise<boolean>` — `true` only when the route change was committed.
|
|
553
555
|
|
|
554
556
|
1. Rejects unsafe or non-internal hrefs and the current location (`false`).
|
|
555
|
-
2. Starts a **transaction** `{ id, from, to, source, status }` (superseding any pending one).
|
|
556
|
-
3. Unless `force`, awaits every registered guard. If one blocks → the transaction is cancelled with reason `guard`, a `GUARD` status is shown, and the promise resolves `false`. "Leave" later re-commits through the same path (source `guard-confirmation`).
|
|
557
|
-
4.
|
|
558
|
-
5.
|
|
557
|
+
2. Starts a **transaction** `{ id, from, to, source, status }` (superseding any pending one). The transaction is a navigation trip ([navigation.md](./navigation.md)): of the shared navigator when the `navigation` module is installed, otherwise of a navigator the dock creates for itself.
|
|
558
|
+
3. Unless `force`, awaits every registered guard (as the trip's `guard`). If one blocks → the transaction is cancelled with reason `guard`, a `GUARD` status is shown, and the promise resolves `false`. "Leave" later re-commits through the same path (source `guard-confirmation`, `force`).
|
|
559
|
+
4. Navigation holds run (exit animations, `useNavigationHold`).
|
|
560
|
+
5. Commit: remember scroll/focus of the old path, blur the active element, prepare the route reset policy, emit `DOCK_NAVIGATE_START`, `router.push`, emit `DOCK_NAVIGATE`.
|
|
561
|
+
6. `DOCK_NAVIGATE_END` is emitted when the location key changes (or in the next frame for same-path hash/query changes); the `loading` module is told to stop.
|
|
559
562
|
|
|
560
|
-
A transaction times out after 15 s (`stopLoading` is called). Statuses: `pending → completed | cancelled | failed | timed-out`. Expanding or navigating collapses the stack and clears hover/search.
|
|
563
|
+
A transaction times out after 15 s from its start and again from its commit (`navigation` `timeoutMs`; `stopLoading` is called). With the module installed, `dockTransaction` is also set for trips the dock did not start (back/forward, `useNavigation().navigate`), so the dock shows the destination card for them too. Statuses: `pending → completed | cancelled | failed | timed-out`. Expanding or navigating collapses the stack and clears hover/search.
|
|
561
564
|
|
|
562
565
|
### 12.2 Guards
|
|
563
566
|
|
|
@@ -577,7 +580,7 @@ const { isActive, setGuard, clearGuard } = useDockGuard({
|
|
|
577
580
|
|
|
578
581
|
### 12.3 Prefetch
|
|
579
582
|
|
|
580
|
-
Hovering a card for 90 ms prefetches its `path` with `router.prefetch` (leaving the card cancels a pending prefetch); focusing a card with the keyboard prefetches its `targetPath ?? path` **immediately**. Not prefetched: loading, overlay and surface cards, cards with `prefetchDisabled` or `dockPolicy.prefetch: false`. A route is prefetched once; if Next.js invalidates it, it can be prefetched again.
|
|
583
|
+
Hovering a card for 90 ms prefetches its `path` with `router.prefetch` (leaving the card cancels a pending prefetch); focusing a card with the keyboard prefetches its `targetPath ?? path` **immediately**. Not prefetched: loading, overlay and surface cards, cards with `prefetchDisabled` or `dockPolicy.prefetch: false`. A route is prefetched once; if Next.js invalidates it, it can be prefetched again. With the `navigation` module installed the prefetch goes through its shared warm cache (`prefetchKind`, `prefetchTtlMs`, intent listeners), the same one plain links use.
|
|
581
584
|
|
|
582
585
|
### 12.4 Route policy
|
|
583
586
|
|
|
@@ -719,8 +722,7 @@ Gotchas:
|
|
|
719
722
|
| `runtime/display.ts` | Item pipeline: registry entries → filtered items → active item (applies surface / status / media). |
|
|
720
723
|
| `runtime/pipeline.ts` | Item identity, active item detection, ancestor deduplication, loading item cleanup, and stacking reorder. |
|
|
721
724
|
| `runtime/commands.ts` | Context-action registry, `useDockContextActions`, `defineDockAction`. |
|
|
722
|
-
| `routing/navigation.ts` | `useDockNavigation
|
|
723
|
-
| `routing/transactions.ts` | Navigation transaction reducer and timeout. |
|
|
725
|
+
| `routing/navigation.ts` | `useDockNavigation` (on a navigation trip), route policy, topology, prefetch, location key, view connection. |
|
|
724
726
|
| `routing/guards.ts` | Guard registry, `useDockGuard`, guard confirmation. |
|
|
725
727
|
| `routing/continuity.ts` | Scroll/focus snapshots and return handoffs. |
|
|
726
728
|
| `routing/breadcrumbs.tsx` | Breadcrumb resolution, provider, overrides, `defineBreadcrumb`. |
|
package/docs/modules/loading.md
CHANGED
|
@@ -95,7 +95,7 @@ CheckoutLoading.use(isSubmitting); // boolean | message string | LoadingOptions
|
|
|
95
95
|
| `stopLoading()` | Turn it off. If `minDuration` has not elapsed yet, the stop is **delayed** by the remaining time. With no `minDuration` it is immediate. |
|
|
96
96
|
| `setLoading(boolean)` | Shorthand for the two above (no options). |
|
|
97
97
|
| `setSkeleton(nodeOrUpdater)` | Replace the manual skeleton; accepts `(current) => next`. No re-render when unchanged. |
|
|
98
|
-
| `withLoading(task, options \| message)` |
|
|
98
|
+
| `withLoading(task, options \| message)` | Starts its own task, awaits the promise (or calls the function and awaits its result), ends the task in `finally`. Rejections propagate. |
|
|
99
99
|
|
|
100
100
|
`page.modules.loading` = these actions plus `set(loading)` (`page.set({ loading })`).
|
|
101
101
|
|
|
@@ -115,7 +115,7 @@ The same value also feeds the dock's `isLoading` card flag (`usePage({ dock })`
|
|
|
115
115
|
- **Graceful cleanup.** The registry entry survives 600 ms after the page unmounts, so a route change does not flash the overlay off and on between pages.
|
|
116
116
|
- **`minDuration` is manual-only.** Page-registered loading ends as soon as the page stops declaring it.
|
|
117
117
|
- **Navigation.** The dock calls `stopLoading()` after a navigation completes or times out (15 s), so a `startLoading()` made before navigating does not stick.
|
|
118
|
-
- **
|
|
118
|
+
- **Overlapping tasks.** Each `withLoading` call is its own task: loading stays on until the **last** one ends, and the overlay shows the latest task's message and skeleton (the one beneath shows again when it ends). `startLoading` / `stopLoading` / `setLoading` are one more task, a single switch: calling `startLoading` twice and `stopLoading` once turns it off. `minDuration` counts from the first task to the last.
|
|
119
119
|
- **Fullscreen suppression.** While `useIsFullscreenStateActive()` is true (a full-screen view is active) the overlay is hidden.
|
|
120
120
|
- **Accessibility.** The overlay has `role="status"`, `aria-busy="true"` and `aria-label="Loading"`.
|
|
121
121
|
- **Next.js `loading.tsx`.** `src/app/loading.tsx` (a bare `Spinner`) is the route-segment fallback and is independent of this module.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Navigation — `@omerdlw/base-framework/modules/navigation`
|
|
2
|
+
|
|
3
|
+
> **Read this first if you need to:** play an exit animation before the route changes (on a click, the dock or the back button), hand data from the page being left to the page being opened (a palette, a scroll position, a grade), prefetch links on hover intent, or know whether a navigation is under way.
|
|
4
|
+
|
|
5
|
+
## 1. At a glance
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
| :----------------- | :---------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| **Import** | `import { useNavigation, useNavigationHold, useArrival, … } from "@omerdlw/base-framework/modules/navigation"` (also `/navigation`) |
|
|
10
|
+
| **Module id** | `navigation` (`navigationModule`, in `defaultModules`) |
|
|
11
|
+
| **Renders** | nothing; `NavigationProvider` binds the Next router and installs the `popstate` and intent listeners |
|
|
12
|
+
| **Registry type** | none |
|
|
13
|
+
| **Page slice** | none |
|
|
14
|
+
| **Peers (`uses`)** | none (the **dock** reads _this_ module through `useModule("navigation")`) |
|
|
15
|
+
| **Events** | `NAVIGATION_START`, `NAVIGATION_COMMIT`, `NAVIGATION_END` on `globalEvents` |
|
|
16
|
+
| **DOM contracts** | `a[href]` for intent prefetch; `data-prefetch="false"` opts a link out |
|
|
17
|
+
| **Tests** | `tests/modules/navigation.test.ts` |
|
|
18
|
+
|
|
19
|
+
## 2. Mental model
|
|
20
|
+
|
|
21
|
+
Every navigation is a **trip**. There is one active trip at a time; a new one supersedes it.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
navigate(href) / dock click back / forward (popstate)
|
|
25
|
+
│ │
|
|
26
|
+
▼ ▼
|
|
27
|
+
┌─────────────────────────── trip ───────────────────────────┐
|
|
28
|
+
│ phase "leaving" guard (dock) → holds run, capped │
|
|
29
|
+
│ payload collected from providers │
|
|
30
|
+
│ phase "routing" router.push / replayed popstate │
|
|
31
|
+
│ timeoutMs of patience │
|
|
32
|
+
│ settled completed | cancelled | failed | timed-out │
|
|
33
|
+
└─────────────────────────────────────────────────────────────┘
|
|
34
|
+
trip.signal aborts on anything but "completed"
|
|
35
|
+
the destination reads the trip with useArrival()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- A **hold** is a function that runs when a trip leaves and returns a promise; the router is asked for the route once every hold settles (or after `maxHoldMs`). This is where exit animations go.
|
|
39
|
+
- The **payload** is what the page being left hands the next one. Components contribute keys while they are mounted (`useNavigationPayload`); getters are read when the trip starts.
|
|
40
|
+
- A trip **arrives** when the location changes while it is `routing` (a different location than the one asked for counts too: a redirect, recorded as `arrivedAt`). A location change while it is still `leaving` cancels it.
|
|
41
|
+
- `useArrival()` returns the trip from the moment the route is asked for (so a route's `loading.tsx` can read it) until the page it landed on is left, or `arrivalTtlMs` after it started.
|
|
42
|
+
|
|
43
|
+
## 3. Quick start
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// An exit animation for links, the dock and the back button alike
|
|
47
|
+
const [leaving, setLeaving] = useState(false);
|
|
48
|
+
useNavigationHold(
|
|
49
|
+
reduced
|
|
50
|
+
? null
|
|
51
|
+
: (trip) => {
|
|
52
|
+
setLeaving(true);
|
|
53
|
+
trip.signal.addEventListener("abort", () => setLeaving(false)); // never landed
|
|
54
|
+
return delay(600);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Hand the destination this page's palette
|
|
59
|
+
useNavigationPayload("palette", palette);
|
|
60
|
+
useNavigationPayload("grade", () => readGrade()); // a getter, read at departure
|
|
61
|
+
|
|
62
|
+
// The destination (its loading.tsx too)
|
|
63
|
+
const arrival = useArrival();
|
|
64
|
+
const [inherited] = useState(() => arrival?.payload.palette ?? null);
|
|
65
|
+
|
|
66
|
+
// Navigate from code
|
|
67
|
+
const { navigate } = useNavigation();
|
|
68
|
+
await navigate("/movie/42", { payload: { palette } });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Type the payload once, in the app:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
declare module "@omerdlw/base-framework/modules/navigation" {
|
|
75
|
+
interface NavigationPayload {
|
|
76
|
+
palette?: AmbientPalette | null;
|
|
77
|
+
grade?: string | null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Tune it once, at start-up:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
configureNavigation({
|
|
86
|
+
intent: { match: (href) => /^\/(movie|person)\//.test(href) },
|
|
87
|
+
maxHoldMs: 3000,
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## 4. Capabilities
|
|
92
|
+
|
|
93
|
+
| Capability | API | Notes |
|
|
94
|
+
| :----------------------------------- | :----------------------------------------------- | :------------------------------------------------------- |
|
|
95
|
+
| Navigate with holds and a payload | `useNavigation().navigate` | [§5.2](#52-navigatehref-options) |
|
|
96
|
+
| Exit animation before the route | `useNavigationHold` | Also for back/forward unless `{ history: false }`. |
|
|
97
|
+
| Data for the next page | `useNavigationPayload`, `useArrival` | Typed by augmenting `NavigationPayload`. |
|
|
98
|
+
| "We are leaving" UI | `useDeparture`, `useNavigationState` | |
|
|
99
|
+
| Intent prefetch for every link | built in, `configureNavigation({ intent })` | 180 ms mouse dwell; pointerdown and focus at once. |
|
|
100
|
+
| Shared warm cache | `prefetch`, `useNavigationIntent` | The dock's card prefetch goes through it too. |
|
|
101
|
+
| Hold the back button | built in | Only while a hold with `history` is mounted. |
|
|
102
|
+
| React to trips anywhere | `NAVIGATION_EVENTS` on `globalEvents` | |
|
|
103
|
+
| Use the machine outside React | `createNavigator`, `watchHistory`, `watchIntent` | What the provider and the dock use. |
|
|
104
|
+
|
|
105
|
+
## 5. API reference
|
|
106
|
+
|
|
107
|
+
### 5.1 Exports
|
|
108
|
+
|
|
109
|
+
| Export | Kind | Description |
|
|
110
|
+
| :--------------------------------------------- | :-------- | :------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
111
|
+
| `navigationModule` | module | `defineModule` definition. |
|
|
112
|
+
| `NavigationProvider` | component | Creates the navigator, binds `useRouter()`, reports `usePathname()` arrivals, installs the listeners. Mounted by the host. |
|
|
113
|
+
| `NavigationContext` | context | `{ actions, store }`. |
|
|
114
|
+
| `useNavigation()` | hook | `NavigationActions`. Throws outside the provider. |
|
|
115
|
+
| `useOptionalNavigation()` | hook | `NavigationActions`, or `INERT_NAVIGATION_ACTIONS` (no trips, nothing held, `navigate` resolves `{ ok: false }`) when the module is absent. |
|
|
116
|
+
| `useNavigationState(selector?, isEqual?)` | hook | `{ active, last, location }`. Idle state without the module. |
|
|
117
|
+
| `useNavigationHold(hold, { history? })` | hook | Registers `hold` while mounted. `false`/`null` holds nothing. The latest `hold` is called; no deps needed. |
|
|
118
|
+
| `useNavigationPayload(key, value)` | hook | Contributes `key` while mounted. A function is a getter called when a trip starts. The latest registration of a key wins. |
|
|
119
|
+
| `useNavigationIntent((path, href) => void)` | hook | Called with every path the navigator warms (links, dock, code). Use it to preload images for the page. |
|
|
120
|
+
| `useDeparture()` | hook | The active trip when it leads away from the current pathname, else `null`. |
|
|
121
|
+
| `useArrival(pathname?)` | hook | The trip that brought the page to `pathname` (default: current), see §2. |
|
|
122
|
+
| `createNavigator(options?)` | factory | The pure machine (§5.4). |
|
|
123
|
+
| `watchHistory(navigator, window?)` | function | The `popstate` capture listener. Returns the cleanup. |
|
|
124
|
+
| `watchIntent(navigator, settings, document?)` | function | The intent listeners. Returns the cleanup. |
|
|
125
|
+
| `configureNavigation(input)` | function | Global settings (§5.3). Returns a restore function. |
|
|
126
|
+
| `getNavigationSettings()` | function | Current settings. |
|
|
127
|
+
| `NAVIGATION_EVENTS`, `NAVIGATION_STATUS`, `NAVIGATION_PHASE`, `NAVIGATION_REASON`, `NAVIGATION_SOURCE` | constants | |
|
|
128
|
+
| `isSafeInternalHref`, `locationKeyOf`, `pathOf` | utils | `locationKeyOf("/a/?q=1")` → `"/a?q=1"`. |
|
|
129
|
+
| Types | | `NavigationTrip`, `NavigationState`, `NavigationPayload`, `NavigateOptions`, `NavigationResult`, `NavigationActions`, `Navigator`, … |
|
|
130
|
+
|
|
131
|
+
### 5.2 `navigate(href, options?)`
|
|
132
|
+
|
|
133
|
+
Resolves `{ ok, trip, blocked? }`; `ok` is `true` when the router was asked for the route.
|
|
134
|
+
|
|
135
|
+
| Option | Default | Meaning |
|
|
136
|
+
| :--------- | :----------- | :------------------------------------------------------------------------------------------------------- |
|
|
137
|
+
| `payload` | — | Merged over what `useNavigationPayload` contributes. |
|
|
138
|
+
| `replace` | `false` | `router.replace` instead of `router.push`. |
|
|
139
|
+
| `scroll` | — | Passed to the router when set. |
|
|
140
|
+
| `source` | `"navigate"` | Recorded on the trip (`"dock"`, `"history"`, `"guard-confirmation"` are used by the framework). |
|
|
141
|
+
| `guard` | — | `(trip) => boolean \| { blocked, message? }` (sync or async), before holds. Blocking cancels with reason `guard`. |
|
|
142
|
+
| `force` | `false` | Skip `guard`. Holds still run. |
|
|
143
|
+
| `onCommit` | — | `(trip) => void` right before the router call. |
|
|
144
|
+
|
|
145
|
+
Unsafe hrefs (not starting with a single `/`) and the current location start nothing (`{ ok: false, trip: null }`).
|
|
146
|
+
|
|
147
|
+
### 5.3 Settings
|
|
148
|
+
|
|
149
|
+
| Setting | Default | Meaning |
|
|
150
|
+
| :--------------- | :----------------- | :---------------------------------------------------------------------------- |
|
|
151
|
+
| `intent` | on | `false` turns intent prefetch off; an object merges into the current one. |
|
|
152
|
+
| `intent.delayMs` | `180` | Mouse dwell before a hovered link is warmed. Touch hover is ignored. |
|
|
153
|
+
| `intent.match` | every internal link | `(href) => boolean`. |
|
|
154
|
+
| `prefetchKind` | `"full"` | Passed to `router.prefetch` as `kind`. |
|
|
155
|
+
| `prefetchTtlMs` | `240000` | A warmed href (without hash) is not prefetched again for this long, unless Next invalidates it. |
|
|
156
|
+
| `arrivalTtlMs` | `20000` | How long `useArrival` returns a trip after it started. |
|
|
157
|
+
| `maxHoldMs` | `5000` | Cap on holds. |
|
|
158
|
+
| `timeoutMs` | `15000` | Patience from the start, and again from the commit; then `timed-out`. |
|
|
159
|
+
|
|
160
|
+
Settings live on `globalThis`, so every bundled entry shares them.
|
|
161
|
+
|
|
162
|
+
### 5.4 `createNavigator(options?)`
|
|
163
|
+
|
|
164
|
+
`{ router?, settings?, now?, readLocation?, nextFrame?, emit? }` → `Navigator`: every action in `NavigationActions` plus `store` and `dispose()`. The provider and the dock create one each way; tests inject a fake router, clock and location. `arrive(location)` reports location changes, `depart(to, { replay? })` starts a history trip (with `replay`, holds with `history` run before it is called), `setRouter` rebinds.
|
|
165
|
+
|
|
166
|
+
### 5.5 `NavigationTrip`
|
|
167
|
+
|
|
168
|
+
`{ id, from, to, href, source, phase, status, startedAt, endedAt?, reason?, error?, arrivedAt?, payload, signal }`. `from`/`to` are location keys (pathname, search, hash). Reasons: `superseded`, `guard`, `returned` (stepped back to the origin during a held back step), `time-out`, or what `cancel(reason)` was given.
|
|
169
|
+
|
|
170
|
+
## 6. Behaviour details
|
|
171
|
+
|
|
172
|
+
- **Back/forward.** A capture listener on `window` runs before Next's own `popstate` listener. With no hold that wants history steps the event is left alone and recorded as a trip that is already `routing`. Otherwise it is stopped (`stopImmediatePropagation`), its `state` kept, the holds run, and `new PopStateEvent("popstate", { state })` is dispatched for Next. During the hold the URL already shows the destination while the old page stays rendered. Stepping back to the origin cancels the trip and lets the event through. A step that keeps the pathname (hash, query) is ignored. Unmounting the provider replays a held step so the page never stays behind the URL.
|
|
173
|
+
- **Same-pathname pushes.** No pathname changes, so the navigator reads `window.location` on the next frame and arrives if it matches.
|
|
174
|
+
- **No `useSearchParams`.** The provider watches `usePathname()` only (`useSearchParams` would force a client-side bailout on static routes).
|
|
175
|
+
- **Side effects at import:** none (the package is `sideEffects: false`); listeners are installed in effects.
|
|
176
|
+
- **Plain `next/link` clicks** are warmed on intent and recorded on arrival, but they do not go through `navigate()`, so they get no hold. Route them through `navigate` (or the dock) when the exit matters.
|
|
177
|
+
|
|
178
|
+
## 7. With the dock
|
|
179
|
+
|
|
180
|
+
The dock lists `navigation` in `uses`. When the module is installed, dock navigation **is** a trip of the shared navigator (`source: "dock"`): holds and payloads apply to dock clicks, `useDock().dockTransaction` is the active trip, and card prefetch uses the shared warm cache. Dock guards run as the trip's `guard`. Without the module the dock creates a navigator of its own from the same factory (no listeners, no shared cache), so its pending state and timeouts behave the same. See [dock.md §12](./dock.md#12-navigation-guards-and-transactions).
|
|
181
|
+
|
|
182
|
+
## 8. Recipes and gotchas
|
|
183
|
+
|
|
184
|
+
| I want to… | Do this |
|
|
185
|
+
| :------------------------------------------ | :------------------------------------------------------------------------------------------ |
|
|
186
|
+
| Skip the exit for reduced motion | `useNavigationHold(reduced ? null : hold)`. |
|
|
187
|
+
| Exit on links but not on back | `useNavigationHold(hold, { history: false })`. |
|
|
188
|
+
| Show the destination's tint in `loading.tsx` | Make it a client component and read `useArrival()?.payload`. |
|
|
189
|
+
| Preload a page's hero image on hover | `useNavigationIntent((path) => preloadHero(path))`. |
|
|
190
|
+
| Never warm a link | `data-prefetch="false"` on the `<a>`. |
|
|
191
|
+
| Undo the exit when the route never comes | `trip.signal.addEventListener("abort", undo)` inside the hold. |
|
|
192
|
+
|
|
193
|
+
- A hold's promise is awaited only up to `maxHoldMs`; an animation longer than that is cut.
|
|
194
|
+
- `useNavigationPayload` with the same key in two mounted components: the one registered last wins.
|
|
195
|
+
- Trips record `from` as the navigator last saw it; without the provider (`createNavigator` alone) call `arrive` yourself.
|
|
196
|
+
|
|
197
|
+
## 9. File map
|
|
198
|
+
|
|
199
|
+
| File | Responsibility |
|
|
200
|
+
| :-------------- | :---------------------------------------------------------------------------------- |
|
|
201
|
+
| `index.ts` | Public barrel. |
|
|
202
|
+
| `types.ts` | Trip, state, options, actions, settings contracts; the `NavigationPayload` to augment. |
|
|
203
|
+
| `constants.ts` | Statuses, phases, reasons, sources, event names, default timings. |
|
|
204
|
+
| `utils.ts` | Href and location-key helpers, the link lookup for intent. |
|
|
205
|
+
| `state.ts` | Pure trip factory and settling. |
|
|
206
|
+
| `settings.ts` | `configureNavigation`, `getNavigationSettings` (on `globalThis`). |
|
|
207
|
+
| `navigator.ts` | `createNavigator`: the state machine, holds, payload, warm cache, arrivals. |
|
|
208
|
+
| `listeners.ts` | `watchHistory` (popstate hold and replay), `watchIntent` (hover/press/focus). |
|
|
209
|
+
| `context.tsx` | `NavigationContext`, `NavigationProvider`, inert actions and store. |
|
|
210
|
+
| `hooks.ts` | Public hooks. |
|
|
211
|
+
| `module.tsx` | `navigationModule`, event and kernel type augmentation. |
|
|
212
|
+
|
|
213
|
+
## 10. Dependencies
|
|
214
|
+
|
|
215
|
+
- **Uses:** `next/navigation` (`useRouter`, `usePathname`), `@omerdlw/base-framework/kernel` (`defineModule`, `getOrCreateGlobalContext`), `@omerdlw/base-framework/events`, `@omerdlw/base-framework/hooks`, `@omerdlw/base-framework/utils`.
|
|
216
|
+
- **Used by:** the **dock** (`useModule("navigation")`, falling back to its own navigator); pages and features through the hooks.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Proposal: a navigation layer (`modules/navigation`)
|
|
2
|
+
|
|
3
|
+
> Status: **approved and implemented** (see [modules/navigation.md](../modules/navigation.md)).
|
|
4
|
+
> Changes from the draft: there is no `getArrival(pathname)`; read `useArrival()` and keep what you need in `useState(() => …)`. A module-level getter would need a global navigator, which the module avoids. `useNavigationHold` takes no deps array (it always calls the latest hold). `navigate` also takes `onCommit`, which the dock uses for its own commit-time work.
|
|
5
|
+
|
|
6
|
+
## 1. Why
|
|
7
|
+
|
|
8
|
+
Guffin, the first app on the framework, wrote its own navigation layer
|
|
9
|
+
(`src/motion/handoff.tsx`, ~250 lines) because the framework has the pieces
|
|
10
|
+
only inside the dock:
|
|
11
|
+
|
|
12
|
+
| Guffin needed | What it wrote | What the framework has |
|
|
13
|
+
| :------------------------------------------------------------------------ | :-------------------------------------------------------------------------------- | :---------------------------------------------------------------------------- |
|
|
14
|
+
| Prefetch any link on intent (180 ms hover, pointerdown, focus), warm 4 min | `watchIntent`, `warm`, a `Map` of warmed paths, `foresee` listeners | `useRoutePrefetch`, dock cards only, 90 ms, no shared cache |
|
|
15
|
+
| A "trip": what the departing page hands the destination (palette, grade) | a module-level `trip`, `beginHandoff`, `handoffTo(pathname)`, `useDeparture` | nothing |
|
|
16
|
+
| An exit animation before the route changes | a dock **guard** whose `when` resolves `false` after `wait` (a guard used as a delay) | guards can only block |
|
|
17
|
+
| Give up when the route never commits, and undo the exit | `depart()` with two patience timers keyed off `DOCK_EVENTS.NAVIGATE_START` | the dock's transaction times out, but nobody outside the dock can react to it |
|
|
18
|
+
| The same exit on the browser back button | a capture-phase `popstate` holder (`holdSteps`) that swallows and replays the event | nothing |
|
|
19
|
+
|
|
20
|
+
Every app with page transitions will need the same five things. They belong in
|
|
21
|
+
the framework, behind one state machine that the dock also uses.
|
|
22
|
+
|
|
23
|
+
## 2. Decision: a new module, read by the dock as a peer
|
|
24
|
+
|
|
25
|
+
Two options were on the table:
|
|
26
|
+
|
|
27
|
+
- **A. Lift the transaction machine into the kernel.** The kernel would own a
|
|
28
|
+
router binding, DOM listeners (`popstate`, `pointerover`) and timers. That
|
|
29
|
+
grows the microkernel with app-runtime concerns it has avoided so far, and
|
|
30
|
+
every app would pay for it.
|
|
31
|
+
- **B. A new `modules/navigation`.** ✅ Recommended.
|
|
32
|
+
|
|
33
|
+
Why B:
|
|
34
|
+
|
|
35
|
+
1. It is installable and removable like every other module; `defaultModules`
|
|
36
|
+
gets it, a subset install can leave it out.
|
|
37
|
+
2. The machine itself is a **pure factory** (`createNavigator`, no React, no
|
|
38
|
+
Next imports), so it is unit-testable with a fake router and fake timers.
|
|
39
|
+
3. The dock lists `navigation` in `uses` and reads it with
|
|
40
|
+
`useModule("navigation")`. **When the module is missing, the dock creates a
|
|
41
|
+
private navigator from the same factory**, bound to its own router and
|
|
42
|
+
without global listeners. So the dock keeps its pending state and timeouts
|
|
43
|
+
in a subset install, and there is still only one implementation of the
|
|
44
|
+
machine. This is the `definePeer` contract (the peer may be absent, the
|
|
45
|
+
reader still works), with a working stand-in instead of an inert one.
|
|
46
|
+
4. Features that only want to read trips use `useOptionalNavigation()`, a
|
|
47
|
+
`definePeer("navigation", inert)` whose inert runtime never has a trip.
|
|
48
|
+
|
|
49
|
+
The dock's `routing/transactions.ts` reducer moves into
|
|
50
|
+
`modules/navigation/state.ts`. `useDockNavigation()` keeps its return shape
|
|
51
|
+
(`activeTransaction`, `lastTransaction`, `cancelDock`, `navigate`, …), so
|
|
52
|
+
`runtime/dock.tsx` does not change. `DOCK_TRANSACTION_*` and the transaction
|
|
53
|
+
reducer types are removed (`DockTransaction` is now `NavigationTrip`);
|
|
54
|
+
`DOCK_EVENTS.NAVIGATE_*` keep firing.
|
|
55
|
+
|
|
56
|
+
## 3. The model
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
navigate(href, { payload }) popstate (back/forward)
|
|
60
|
+
│ │
|
|
61
|
+
▼ ▼
|
|
62
|
+
┌──────────────── trip (one active at a time) ────────────────┐
|
|
63
|
+
│ phase "leaving" ─ holds run (exit animations), capped │
|
|
64
|
+
│ │ payload collected from providers │
|
|
65
|
+
│ ▼ │
|
|
66
|
+
│ phase "routing" ─ router.push / replayed popstate │
|
|
67
|
+
│ │ patience timer (timeoutMs) │
|
|
68
|
+
│ ▼ │
|
|
69
|
+
│ settled: completed | cancelled | failed | timed-out │
|
|
70
|
+
└──────────────────────────────────────────────────────────────┘
|
|
71
|
+
│ trip.signal aborts on cancel / time-out
|
|
72
|
+
▼
|
|
73
|
+
destination reads it: useArrival() within arrivalTtlMs
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Trip** = today's `DockTransaction` plus `href`, `phase`, `payload` and an
|
|
77
|
+
`AbortSignal`. Same ids, same status strings, so the dock maps it 1:1.
|
|
78
|
+
- A new trip **supersedes** the active one (cancelled, reason `superseded`), as
|
|
79
|
+
today.
|
|
80
|
+
- **Arrival**: the provider watches `usePathname()` (and, after a same-path
|
|
81
|
+
push, `window.location` on the next frame, as the dock does now). A
|
|
82
|
+
location change while the trip is `routing` completes it; while it is
|
|
83
|
+
`leaving` it cancels it.
|
|
84
|
+
|
|
85
|
+
## 4. API sketch
|
|
86
|
+
|
|
87
|
+
### Engine (pure, `createNavigator(options)`)
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const navigator = createNavigator({ router, timeoutMs, maxHoldMs, now });
|
|
91
|
+
|
|
92
|
+
navigator.navigate(href, { source?, replace?, scroll?, payload?, force?, guard? })
|
|
93
|
+
// → Promise<{ ok: boolean; trip: NavigationTrip; blocked?: { message?: string } }>
|
|
94
|
+
navigator.hold((trip) => Promise<void> | void, { history?: true }) // → unregister
|
|
95
|
+
navigator.provide(key, valueOrGetter) // → unregister
|
|
96
|
+
navigator.prefetch(href, { immediate?, kind? }) // shared warm cache
|
|
97
|
+
navigator.onIntent((path, href) => void) // → unsubscribe
|
|
98
|
+
navigator.arrive(locationKey) // called by the provider
|
|
99
|
+
navigator.cancel(reason?)
|
|
100
|
+
navigator.arrival(pathname) // the fresh trip that ended here, or null
|
|
101
|
+
navigator.store // ExternalStore<{ active, last }>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`guard` is the dock's hook to run its existing guard registry and
|
|
105
|
+
confirmation surface inside the trip; app-wide blockers are out of scope (§7).
|
|
106
|
+
|
|
107
|
+
### React (`@omerdlw/base-framework/modules/navigation`)
|
|
108
|
+
|
|
109
|
+
| Export | Replaces in Guffin |
|
|
110
|
+
| :-------------------------------------- | :-------------------------------------------------- |
|
|
111
|
+
| `navigationModule` | — |
|
|
112
|
+
| `useNavigation()` / `useOptionalNavigation()` | `useRouter` + `depart` |
|
|
113
|
+
| `useNavigationHold(fn, deps, options?)` | the guard-as-delay + `depart` + `onReturn` + `holdSteps` |
|
|
114
|
+
| `useNavigationPayload(key, value)` | `useHeldPalette`, the `--grade` read in `beginHandoff` |
|
|
115
|
+
| `useDeparture()` | `useDeparture(pathname)` |
|
|
116
|
+
| `useArrival()` / `getArrival(pathname)` | `handoffTo(pathname)` (`getArrival` for `useState` initialisers) |
|
|
117
|
+
| `useNavigationIntent(fn)` | `foresee` |
|
|
118
|
+
| `configureNavigation(settings)` | the constants `DWELL`, `WARM_FOR`, `FRESH`, `isFilmPage` |
|
|
119
|
+
| `NAVIGATION_EVENTS` | listening to `DOCK_EVENTS.NAVIGATE_START` |
|
|
120
|
+
|
|
121
|
+
The payload is typed by augmentation, so the framework does not depend on
|
|
122
|
+
ambient:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
declare module "@omerdlw/base-framework/modules/navigation" {
|
|
126
|
+
interface NavigationPayload {
|
|
127
|
+
palette?: AmbientPalette | null;
|
|
128
|
+
grade?: string | null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Settings (`configureNavigation`, same pattern as `configureAmbient`)
|
|
134
|
+
|
|
135
|
+
| Setting | Default | Meaning |
|
|
136
|
+
| :--------------- | :------- | :----------------------------------------------------------------------- |
|
|
137
|
+
| `intent` | `true` | Document-level intent prefetch for `a[href]`; `false` turns it off. |
|
|
138
|
+
| `intent.delayMs` | `180` | Mouse dwell before prefetching. `pointerdown` and `focusin` are immediate. |
|
|
139
|
+
| `intent.match` | internal | `(href) => boolean`, which links to warm (Guffin: film pages). |
|
|
140
|
+
| `prefetchKind` | `"full"` | Passed to `router.prefetch`. |
|
|
141
|
+
| `prefetchTtlMs` | `240000` | How long a warmed path is not prefetched again. |
|
|
142
|
+
| `arrivalTtlMs` | `20000` | How long a trip stays readable by `useArrival`. |
|
|
143
|
+
| `maxHoldMs` | `5000` | Cap on holds; past it the trip commits anyway. |
|
|
144
|
+
| `timeoutMs` | `15000` | Patience after commit (today's `DOCK_TRANSACTION_TIMEOUT_MS`). |
|
|
145
|
+
|
|
146
|
+
Links opt out with `data-prefetch="false"`.
|
|
147
|
+
|
|
148
|
+
## 5. Guffin after the move (illustrative, Guffin is not edited here)
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
// stage.tsx: the exit, for dock clicks and the back button alike
|
|
152
|
+
useNavigationHold(
|
|
153
|
+
(trip) => {
|
|
154
|
+
setLeaving(true);
|
|
155
|
+
trip.signal.addEventListener("abort", () => setLeaving(false));
|
|
156
|
+
return delay(wait * 1000);
|
|
157
|
+
},
|
|
158
|
+
[wait],
|
|
159
|
+
{ enabled: !reduced },
|
|
160
|
+
);
|
|
161
|
+
useNavigationPayload("grade", () => readGrade()); // getter, read at departure
|
|
162
|
+
|
|
163
|
+
// movie-page-chrome.tsx
|
|
164
|
+
useNavigationPayload("palette", lit);
|
|
165
|
+
const [inherited] = useState(() => getArrival(pathname)?.payload.palette);
|
|
166
|
+
|
|
167
|
+
// loading.tsx
|
|
168
|
+
const arrival = useArrival();
|
|
169
|
+
return arrival ? <Dark palette={arrival.payload.palette ?? null} /> : children;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## 6. Next.js App Router notes
|
|
173
|
+
|
|
174
|
+
- Everything is a client module (`"use client"` entry, like the other
|
|
175
|
+
modules). The package is `sideEffects: false`, so listeners are installed in
|
|
176
|
+
the provider's effects, never at import.
|
|
177
|
+
- Back/forward: a capture-phase `popstate` listener on `window` runs before
|
|
178
|
+
Next's own listener. With holds registered it swallows the event
|
|
179
|
+
(`stopImmediatePropagation`), keeps `event.state`, and after the holds
|
|
180
|
+
replays `new PopStateEvent("popstate", { state })` so Next restores the
|
|
181
|
+
route itself. A second step back to the origin during the hold cancels the
|
|
182
|
+
trip. Without holds the event is left alone and only recorded as a trip.
|
|
183
|
+
- No `useSearchParams` in the provider (it would force a client-side bailout
|
|
184
|
+
on static routes); search/hash arrivals use the location check the dock
|
|
185
|
+
already does.
|
|
186
|
+
- `router.prefetch(href, { kind })` is called defensively; a throw drops the
|
|
187
|
+
path from the warm cache.
|
|
188
|
+
|
|
189
|
+
## 7. Out of scope for this change
|
|
190
|
+
|
|
191
|
+
- App-wide blockers (`navigation.block`): the dock's guards stay where they
|
|
192
|
+
are and run through the `guard` option.
|
|
193
|
+
- A `<NavigationLink>` that routes plain link clicks through `navigate()`.
|
|
194
|
+
`next/link` clicks still prefetch on intent and are recorded on arrival, but
|
|
195
|
+
get no exit hold. Follow-up if wanted.
|
|
196
|
+
- Scroll restoration and the dock's continuity store.
|
|
197
|
+
|
|
198
|
+
## 8. Plan
|
|
199
|
+
|
|
200
|
+
1. `src/modules/navigation/` with the canonical files (`index`, `types`,
|
|
201
|
+
`constants`, `utils`, `state`, `context.tsx`, `hooks.ts`, `module.tsx`), a
|
|
202
|
+
`tsup` entry and a `package.json` export.
|
|
203
|
+
2. Dock: `routing/navigation.ts` and `routing/transactions.ts` move onto the
|
|
204
|
+
navigator; `cards.tsx` prefetch goes through it; `uses` gains
|
|
205
|
+
`navigation`. `runtime/dock.tsx`, `display.ts` untouched.
|
|
206
|
+
3. Tests: `tests/modules/navigation.test.ts` (engine with fake timers,
|
|
207
|
+
provider + popstate with happy-dom), existing dock tests unchanged, a dist
|
|
208
|
+
smoke check that the module and its context are one object.
|
|
209
|
+
4. Docs: `docs/modules/navigation.md`, index row, dock page cross-links.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omerdlw/base-framework",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Microkernel engine and first-party declarative UI modules for app-like Next.js products.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -117,6 +117,11 @@
|
|
|
117
117
|
"import": "./dist/modules/media/index.js",
|
|
118
118
|
"default": "./dist/modules/media/index.js"
|
|
119
119
|
},
|
|
120
|
+
"./navigation": {
|
|
121
|
+
"types": "./dist/modules/navigation/index.d.ts",
|
|
122
|
+
"import": "./dist/modules/navigation/index.js",
|
|
123
|
+
"default": "./dist/modules/navigation/index.js"
|
|
124
|
+
},
|
|
120
125
|
"./modules": {
|
|
121
126
|
"types": "./dist/modules/index.d.ts",
|
|
122
127
|
"import": "./dist/modules/index.js",
|