@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.
- 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-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-6ZJVLUBE.js → chunk-I5GK7VOV.js} +8 -6
- package/dist/chunk-I5GK7VOV.js.map +1 -0
- package/dist/chunk-RIOKLMYM.js +145 -0
- package/dist/chunk-RIOKLMYM.js.map +1 -0
- package/dist/{chunk-SKBITB5X.js → chunk-RRAZKP3J.js} +3 -3
- package/dist/{chunk-SKBITB5X.js.map → chunk-RRAZKP3J.js.map} +1 -1
- package/dist/{chunk-QXR55VVS.js → chunk-TG6NMHXI.js} +241 -162
- package/dist/chunk-TG6NMHXI.js.map +1 -0
- package/dist/{chunk-A5RE3F6O.js → chunk-WVRMABDN.js} +249 -427
- package/dist/chunk-WVRMABDN.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 -35
- package/dist/modules/dock/constants.d.ts.map +1 -1
- package/dist/modules/dock/index.js +3 -2
- package/dist/modules/dock/motion.d.ts +7 -5
- package/dist/modules/dock/motion.d.ts.map +1 -1
- package/dist/modules/dock/overlay.d.ts.map +1 -1
- 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 -3
- package/dist/modules/dock/runtime/dock.d.ts.map +1 -1
- package/dist/modules/dock/runtime/pipeline.d.ts.map +1 -1
- package/dist/modules/dock/styles.d.ts +1 -1
- package/dist/modules/dock/styles.d.ts.map +1 -1
- package/dist/modules/dock/surface/hooks.d.ts.map +1 -1
- package/dist/modules/dock/surface/stack.d.ts.map +1 -1
- package/dist/modules/dock/types.d.ts +88 -43
- 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 +31 -13
- package/docs/modules/loading.md +2 -2
- package/docs/modules/navigation.md +216 -0
- package/docs/proposals/navigation.md +209 -0
- package/docs/theming.md +1 -1
- package/package.json +6 -1
- package/templates/AGENTS.md +2 -0
- package/templates/motion/dock.ts +83 -15
- 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
|
@@ -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
|
|
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": "
|
|
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",
|
package/templates/AGENTS.md
CHANGED
|
@@ -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 }) {
|
package/templates/motion/dock.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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: {
|