@omerdlw/base-framework 2.0.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +4 -1
  2. package/dist/{chunk-XASQT4CQ.js → chunk-66EYJBIP.js} +47 -27
  3. package/dist/chunk-66EYJBIP.js.map +1 -0
  4. package/dist/{chunk-6SG7V77J.js → chunk-HHWRTQKQ.js} +100 -67
  5. package/dist/chunk-HHWRTQKQ.js.map +1 -0
  6. package/dist/chunk-HLL5EHXC.js +485 -0
  7. package/dist/chunk-HLL5EHXC.js.map +1 -0
  8. package/dist/{chunk-6ZJVLUBE.js → chunk-I5GK7VOV.js} +8 -6
  9. package/dist/chunk-I5GK7VOV.js.map +1 -0
  10. package/dist/chunk-RIOKLMYM.js +145 -0
  11. package/dist/chunk-RIOKLMYM.js.map +1 -0
  12. package/dist/{chunk-SKBITB5X.js → chunk-RRAZKP3J.js} +3 -3
  13. package/dist/{chunk-SKBITB5X.js.map → chunk-RRAZKP3J.js.map} +1 -1
  14. package/dist/{chunk-QXR55VVS.js → chunk-TG6NMHXI.js} +241 -162
  15. package/dist/chunk-TG6NMHXI.js.map +1 -0
  16. package/dist/{chunk-A5RE3F6O.js → chunk-WVRMABDN.js} +249 -427
  17. package/dist/chunk-WVRMABDN.js.map +1 -0
  18. package/dist/index.js +7 -5
  19. package/dist/modules/ambient/analyze.d.ts +18 -3
  20. package/dist/modules/ambient/analyze.d.ts.map +1 -1
  21. package/dist/modules/ambient/dom.d.ts +14 -1
  22. package/dist/modules/ambient/dom.d.ts.map +1 -1
  23. package/dist/modules/ambient/hooks.d.ts.map +1 -1
  24. package/dist/modules/ambient/index.d.ts +2 -2
  25. package/dist/modules/ambient/index.d.ts.map +1 -1
  26. package/dist/modules/ambient/index.js +1 -1
  27. package/dist/modules/ambient/server/index.d.ts +1 -1
  28. package/dist/modules/ambient/server/index.d.ts.map +1 -1
  29. package/dist/modules/ambient/server/index.js +2 -2
  30. package/dist/modules/ambient/server/index.js.map +1 -1
  31. package/dist/modules/ambient/settings.d.ts +1 -0
  32. package/dist/modules/ambient/settings.d.ts.map +1 -1
  33. package/dist/modules/ambient/types.d.ts +13 -0
  34. package/dist/modules/ambient/types.d.ts.map +1 -1
  35. package/dist/modules/ambient/worker/index.js +1 -1
  36. package/dist/modules/context-menu/index.js +4 -3
  37. package/dist/modules/dock/cards.d.ts.map +1 -1
  38. package/dist/modules/dock/constants.d.ts +1 -35
  39. package/dist/modules/dock/constants.d.ts.map +1 -1
  40. package/dist/modules/dock/index.js +3 -2
  41. package/dist/modules/dock/motion.d.ts +7 -5
  42. package/dist/modules/dock/motion.d.ts.map +1 -1
  43. package/dist/modules/dock/overlay.d.ts.map +1 -1
  44. package/dist/modules/dock/routing/guards.d.ts +1 -1
  45. package/dist/modules/dock/routing/navigation.d.ts +7 -4
  46. package/dist/modules/dock/routing/navigation.d.ts.map +1 -1
  47. package/dist/modules/dock/runtime/display.d.ts +31 -2
  48. package/dist/modules/dock/runtime/display.d.ts.map +1 -1
  49. package/dist/modules/dock/runtime/dock.d.ts +2 -3
  50. package/dist/modules/dock/runtime/dock.d.ts.map +1 -1
  51. package/dist/modules/dock/runtime/pipeline.d.ts.map +1 -1
  52. package/dist/modules/dock/styles.d.ts +1 -1
  53. package/dist/modules/dock/styles.d.ts.map +1 -1
  54. package/dist/modules/dock/surface/hooks.d.ts.map +1 -1
  55. package/dist/modules/dock/surface/stack.d.ts.map +1 -1
  56. package/dist/modules/dock/types.d.ts +88 -43
  57. package/dist/modules/dock/types.d.ts.map +1 -1
  58. package/dist/modules/index.d.ts +3 -2
  59. package/dist/modules/index.d.ts.map +1 -1
  60. package/dist/modules/index.js +7 -5
  61. package/dist/modules/loading/context.d.ts.map +1 -1
  62. package/dist/modules/loading/index.js +1 -1
  63. package/dist/modules/navigation/constants.d.ts +37 -0
  64. package/dist/modules/navigation/constants.d.ts.map +1 -0
  65. package/dist/modules/navigation/context.d.ts +10 -0
  66. package/dist/modules/navigation/context.d.ts.map +1 -0
  67. package/dist/modules/navigation/hooks.d.ts +29 -0
  68. package/dist/modules/navigation/hooks.d.ts.map +1 -0
  69. package/dist/modules/navigation/index.d.ts +10 -0
  70. package/dist/modules/navigation/index.d.ts.map +1 -0
  71. package/dist/modules/navigation/index.js +81 -0
  72. package/dist/modules/navigation/index.js.map +1 -0
  73. package/dist/modules/navigation/listeners.d.ts +15 -0
  74. package/dist/modules/navigation/listeners.d.ts.map +1 -0
  75. package/dist/modules/navigation/module.d.ts +25 -0
  76. package/dist/modules/navigation/module.d.ts.map +1 -0
  77. package/dist/modules/navigation/navigator.d.ts +8 -0
  78. package/dist/modules/navigation/navigator.d.ts.map +1 -0
  79. package/dist/modules/navigation/settings.d.ts +10 -0
  80. package/dist/modules/navigation/settings.d.ts.map +1 -0
  81. package/dist/modules/navigation/state.d.ts +20 -0
  82. package/dist/modules/navigation/state.d.ts.map +1 -0
  83. package/dist/modules/navigation/types.d.ts +168 -0
  84. package/dist/modules/navigation/types.d.ts.map +1 -0
  85. package/dist/modules/navigation/utils.d.ts +11 -0
  86. package/dist/modules/navigation/utils.d.ts.map +1 -0
  87. package/docs/getting-started.md +2 -0
  88. package/docs/modules/README.md +5 -1
  89. package/docs/modules/ambient.md +9 -6
  90. package/docs/modules/dock.md +31 -13
  91. package/docs/modules/loading.md +2 -2
  92. package/docs/modules/navigation.md +216 -0
  93. package/docs/proposals/navigation.md +209 -0
  94. package/docs/theming.md +1 -1
  95. package/package.json +6 -1
  96. package/templates/AGENTS.md +2 -0
  97. package/templates/motion/dock.ts +83 -15
  98. package/dist/chunk-6SG7V77J.js.map +0 -1
  99. package/dist/chunk-6ZJVLUBE.js.map +0 -1
  100. package/dist/chunk-A5RE3F6O.js.map +0 -1
  101. package/dist/chunk-QXR55VVS.js.map +0 -1
  102. package/dist/chunk-XASQT4CQ.js.map +0 -1
  103. package/dist/modules/dock/routing/transactions.d.ts +0 -21
  104. package/dist/modules/dock/routing/transactions.d.ts.map +0 -1
@@ -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/docs/theming.md CHANGED
@@ -221,7 +221,7 @@ Each animating module exports its contract type. Give it a full value, the same
221
221
 
222
222
  | Module | Spec | Contract type | What it holds |
223
223
  | :--- | :--- | :--- | :--- |
224
- | Dock | `dockTheme` | `DockMotion` | `transitions` (stack, expand, surface enter/step/exit/resize, cards, text, header swap, press, …), `variants` (fade, text crossfade, header swap/restore, controls, command bar, badge, hud, …), `deck` (elevation, compress, `peek(position)`, `exit(card)`, `tapScale`), `drag`, `skeleton`, `timings` (surface phase lengths and card staggers, in seconds) |
224
+ | Dock | `dockTheme` | `DockMotion` | `transitions` (stack, expand, surface exit, layout, cards, text, header swap, press, …), `variants` (fade, text crossfade, header swap/restore, controls, command bar, badge, hud, …), `phases` (what the deck, header and body play in each surface phase, and the deck's compression), `deck` (elevation, `peek(position)`, `exit(card)`, `tapScale`, `stacking`, breadcrumb offset, fullscreen opacity, origin, resize fallback), `opacity` (banner and description rest), `drag` (swipe feel and the dismiss distance and speed), `skeleton`, `timings` (surface phase lengths and card staggers, in seconds) |
225
225
  | Modal | `modalTheme` | `ModalMotion` | `backdrop`, `content`, `header`, `footer` variants, and `positions` (`center`, `top`, `right`, `bottom`, `left`: variants plus an optional own `transition`) |
226
226
  | Notification | `notificationTheme` | `NotificationMotion` | `toast` variants and the toast's `transition` |
227
227
  | Context menu | `contextMenuTheme` | `ContextMenuMotion` | `pop`, `content`, `item` variants (`item.visible` may take the row index), `itemTap`, `itemTransition` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omerdlw/base-framework",
3
- "version": "2.0.1",
3
+ "version": "3.0.1",
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",
@@ -141,6 +141,7 @@ import { ambientModule } from "@omerdlw/base-framework/modules/ambient";
141
141
  import { backgroundModule } from "@omerdlw/base-framework/modules/background";
142
142
  import { controlsModule } from "@omerdlw/base-framework/modules/controls";
143
143
  import { loadingModule } from "@omerdlw/base-framework/modules/loading";
144
+ import { navigationModule } from "@omerdlw/base-framework/modules/navigation";
144
145
 
145
146
  const modules = [
146
147
  dockModule,
@@ -150,6 +151,7 @@ const modules = [
150
151
  backgroundModule,
151
152
  controlsModule,
152
153
  loadingModule,
154
+ navigationModule,
153
155
  ];
154
156
 
155
157
  export function Providers({ children }: { children: React.ReactNode }) {
@@ -1,4 +1,7 @@
1
- import type { DockMotion } from "@omerdlw/base-framework/modules/dock";
1
+ import type {
2
+ DockMotion,
3
+ DockSurfacePhaseMotion,
4
+ } from "@omerdlw/base-framework/modules/dock";
2
5
  import {
3
6
  chip,
4
7
  contentSwap,
@@ -70,31 +73,51 @@ const crossfade = {
70
73
  scale: 1 - 0.018,
71
74
  };
72
75
 
76
+ // The deck's size while an open surface changes height: a longer move takes a
77
+ // little longer.
78
+ const surfaceResize = (distancePx: number) => {
79
+ const resize = surfaceResizeTransition(distancePx, {
80
+ base: 0.5,
81
+ min: 0.4,
82
+ max: durations.cinematic,
83
+ ease: easings.weighted,
84
+ });
85
+ return { ...resize, height: resize, width: resize };
86
+ };
87
+
88
+ // The deck's lift and scale while the body grows in, and just before a surface
89
+ // opens or closes.
90
+ const surfaceArrival = tween(durations.cinematic, easings.overshoot);
91
+ const anticipation = tween(SURFACE.anticipation, easings.weighted);
92
+
93
+ // The body growing in, and the body changing between steps.
94
+ const surfaceEnter = { ...surfaceBody.visible.transition, delay: SURFACE.bodyEnterDelay };
95
+ const surfaceStep = swap(durations.base, easings.enter).enter;
96
+
73
97
  const controlsSlot = (enter: object, exit: object) => ({
74
98
  hidden: { opacity: 0, scale: 0.96, width: 0, marginRight: -2 },
75
99
  visible: { opacity: 1, scale: 1, width: "auto", marginRight: 0, transition: enter },
76
100
  exit: { opacity: 0, scale: 0.96, width: 0, marginRight: -2, transition: exit },
77
101
  });
78
102
 
103
+ // A phase that plays nothing of its own: the deck rests, the header swaps and
104
+ // the body steps. Each phase below changes only what it plays differently.
105
+ const REST_PHASE: DockSurfacePhaseMotion = {
106
+ deck: null,
107
+ transform: null,
108
+ scale: 1,
109
+ body: surfaceStep,
110
+ header: headerSwap.enter,
111
+ headerReturn: null,
112
+ };
113
+
79
114
  // defineTheme(dockTheme, { slots, motion: dockMotion })
80
115
  export const dockMotion: DockMotion = {
81
116
  transitions: {
82
117
  stack: tween(COLLAPSE, easings.enter),
83
118
  expand,
84
- surfaceResize: (distancePx) => {
85
- const resize = surfaceResizeTransition(distancePx, {
86
- base: 0.5,
87
- min: 0.4,
88
- max: durations.cinematic,
89
- ease: easings.weighted,
90
- });
91
- return { ...resize, height: resize, width: resize };
92
- },
93
- surfaceArrival: tween(durations.cinematic, easings.overshoot),
94
- anticipation: tween(SURFACE.anticipation, easings.weighted),
95
- surfaceEnter: { ...surfaceBody.visible.transition, delay: SURFACE.bodyEnterDelay },
96
- surfaceStep: swap(durations.base, easings.enter).enter,
97
119
  surfaceExit: surfaceBody.exit.transition,
120
+ layout: { type: "tween", duration: 0.45, ease: [0.4, 0, 0.1, 1] },
98
121
  card,
99
122
  cardExpanded: springs.deck,
100
123
  cardPeek: tween(durations.fast, easings.enter),
@@ -279,13 +302,44 @@ export const dockMotion: DockMotion = {
279
302
  visible: { opacity: 1, transform: gpuTransform(0, 1) },
280
303
  },
281
304
  },
305
+ phases: {
306
+ idle: REST_PHASE,
307
+ // The inline action leaves and the deck compresses for a beat.
308
+ dismissAction: {
309
+ ...REST_PHASE,
310
+ transform: anticipation,
311
+ scale: 0.94,
312
+ header: anticipation,
313
+ },
314
+ // The body grows in; the deck arrives with a little overshoot.
315
+ expandBody: {
316
+ ...REST_PHASE,
317
+ deck: expand,
318
+ transform: surfaceArrival,
319
+ body: surfaceEnter,
320
+ },
321
+ open: { ...REST_PHASE, deck: surfaceResize },
322
+ // The deck compresses again before the body leaves.
323
+ anticipation: {
324
+ ...REST_PHASE,
325
+ deck: surfaceResize,
326
+ transform: anticipation,
327
+ scale: 0.96,
328
+ },
329
+ // The body leaves and the card header comes back.
330
+ collapseBody: {
331
+ ...REST_PHASE,
332
+ deck: expand,
333
+ headerReturn: surfaceEnter,
334
+ },
335
+ restoreHeader: { ...REST_PHASE, deck: expand },
336
+ },
282
337
  deck: {
283
338
  elevation: {
284
339
  rest: "0px 12px 32px 0px rgba(0,0,0,0.35), 0px 2px 8px 0px rgba(0,0,0,0.25)",
285
340
  elevated:
286
341
  "0px 32px 80px 0px rgba(0,0,0,0.55), 0px 8px 24px 0px rgba(0,0,0,0.35)",
287
342
  },
288
- compress: { open: 0.94, close: 0.96 },
289
343
  // Cards further back lift a little further and grow a little less.
290
344
  peek: (position) => {
291
345
  const progress = Math.min(position / 3, 1);
@@ -303,12 +357,26 @@ export const dockMotion: DockMotion = {
303
357
  transition: { ...cardCollapse },
304
358
  }),
305
359
  tapScale: 0.97,
360
+ // Cards stack 10px apart and shrink to 0.88 per level, fading by 0.2. The
361
+ // expanded deck spaces them by a card's height (68px) and a 4px gap.
362
+ stacking: {
363
+ collapsed: { offsetY: -10, scale: 0.88, opacityStep: 0.2, opacityMin: 0.1 },
364
+ expanded: { offsetY: -72 },
365
+ shelf: { offsetY: -42, scale: 0.94 },
366
+ visible: { stack: 3, extensions: 2, status: 1 },
367
+ },
368
+ breadcrumbOffset: -44,
369
+ fullscreenOpacity: 0,
370
+ origin: "50% 100%",
371
+ resizeFallback: 260,
306
372
  },
373
+ opacity: { banner: 0.95, description: 0.7 },
307
374
  drag: {
308
375
  elastic: { top: 0.05, bottom: 0.5 },
309
376
  range: [0, 180],
310
377
  opacity: [1, 0.75],
311
378
  scale: [1, 0.96],
379
+ dismiss: { offset: 65, velocity: 400 },
312
380
  },
313
381
  skeleton: "animate-pulse motion-reduce:animate-none",
314
382
  timings: {