@omerdlw/base-framework 1.0.0 → 1.0.2

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 (171) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +121 -52
  3. package/dist/atoms/index.d.ts +0 -1
  4. package/dist/atoms/index.d.ts.map +1 -1
  5. package/dist/atoms/index.js +1 -64
  6. package/dist/atoms/index.js.map +1 -1
  7. package/dist/error/index.js +22 -25
  8. package/dist/error/index.js.map +1 -1
  9. package/dist/hooks/index.d.ts +2 -9
  10. package/dist/hooks/index.d.ts.map +1 -1
  11. package/dist/hooks/index.js +97 -540
  12. package/dist/hooks/index.js.map +1 -1
  13. package/dist/hooks/types.d.ts +3 -45
  14. package/dist/hooks/types.d.ts.map +1 -1
  15. package/dist/hooks/use-controllable-state.d.ts +1 -1
  16. package/dist/hooks/use-controllable-state.d.ts.map +1 -1
  17. package/dist/hooks/use-global-event.d.ts +0 -2
  18. package/dist/hooks/use-global-event.d.ts.map +1 -1
  19. package/dist/hooks/use-hotkey.d.ts +1 -1
  20. package/dist/hooks/use-hotkey.d.ts.map +1 -1
  21. package/dist/index.js +11283 -11772
  22. package/dist/index.js.map +1 -1
  23. package/dist/kernel/adapters.d.ts.map +1 -1
  24. package/dist/kernel/index.d.ts +1 -0
  25. package/dist/kernel/index.d.ts.map +1 -1
  26. package/dist/kernel/index.js +10 -81
  27. package/dist/kernel/index.js.map +1 -1
  28. package/dist/kernel/installed.d.ts.map +1 -1
  29. package/dist/kernel/page-controller.d.ts.map +1 -1
  30. package/dist/kernel/provider.d.ts.map +1 -1
  31. package/dist/modules/ambient/constants.d.ts +1 -1
  32. package/dist/modules/ambient/constants.d.ts.map +1 -1
  33. package/dist/modules/ambient/index.js +180 -326
  34. package/dist/modules/ambient/index.js.map +1 -1
  35. package/dist/modules/ambient/module.d.ts.map +1 -1
  36. package/dist/modules/background/constants.d.ts +4 -2
  37. package/dist/modules/background/constants.d.ts.map +1 -1
  38. package/dist/modules/background/context.d.ts +3 -0
  39. package/dist/modules/background/context.d.ts.map +1 -1
  40. package/dist/modules/background/hooks.d.ts +1 -1
  41. package/dist/modules/background/index.d.ts +2 -2
  42. package/dist/modules/background/index.d.ts.map +1 -1
  43. package/dist/modules/background/index.js +1172 -1270
  44. package/dist/modules/background/index.js.map +1 -1
  45. package/dist/modules/background/overlay.d.ts +1 -1
  46. package/dist/modules/background/overlay.d.ts.map +1 -1
  47. package/dist/modules/background/types.d.ts +1 -1
  48. package/dist/modules/background/types.d.ts.map +1 -1
  49. package/dist/modules/context-menu/constants.d.ts +1 -1
  50. package/dist/modules/context-menu/constants.d.ts.map +1 -1
  51. package/dist/modules/context-menu/context.d.ts.map +1 -1
  52. package/dist/modules/context-menu/hooks.d.ts.map +1 -1
  53. package/dist/modules/context-menu/index.js +133 -238
  54. package/dist/modules/context-menu/index.js.map +1 -1
  55. package/dist/modules/controls/constants.d.ts +1 -1
  56. package/dist/modules/controls/constants.d.ts.map +1 -1
  57. package/dist/modules/controls/hooks.d.ts +1 -1
  58. package/dist/modules/controls/index.js +28 -162
  59. package/dist/modules/controls/index.js.map +1 -1
  60. package/dist/modules/controls/types.d.ts +1 -1
  61. package/dist/modules/controls/types.d.ts.map +1 -1
  62. package/dist/modules/dock/cards.d.ts +2 -2
  63. package/dist/modules/dock/constants.d.ts +1 -1
  64. package/dist/modules/dock/constants.d.ts.map +1 -1
  65. package/dist/modules/dock/context.d.ts +10 -6
  66. package/dist/modules/dock/context.d.ts.map +1 -1
  67. package/dist/modules/dock/hooks.d.ts +5 -5
  68. package/dist/modules/dock/hooks.d.ts.map +1 -1
  69. package/dist/modules/dock/index.d.ts +1 -1
  70. package/dist/modules/dock/index.d.ts.map +1 -1
  71. package/dist/modules/dock/index.js +2060 -2080
  72. package/dist/modules/dock/index.js.map +1 -1
  73. package/dist/modules/dock/runtime/dock.d.ts.map +1 -1
  74. package/dist/modules/dock/types.d.ts +1 -1
  75. package/dist/modules/dock/types.d.ts.map +1 -1
  76. package/dist/modules/index.js +10789 -11027
  77. package/dist/modules/index.js.map +1 -1
  78. package/dist/modules/loading/constants.d.ts +4 -2
  79. package/dist/modules/loading/constants.d.ts.map +1 -1
  80. package/dist/modules/loading/context.d.ts +3 -0
  81. package/dist/modules/loading/context.d.ts.map +1 -1
  82. package/dist/modules/loading/hooks.d.ts +1 -1
  83. package/dist/modules/loading/hooks.d.ts.map +1 -1
  84. package/dist/modules/loading/index.d.ts +2 -2
  85. package/dist/modules/loading/index.d.ts.map +1 -1
  86. package/dist/modules/loading/index.js +118 -222
  87. package/dist/modules/loading/index.js.map +1 -1
  88. package/dist/modules/loading/types.d.ts +1 -1
  89. package/dist/modules/loading/types.d.ts.map +1 -1
  90. package/dist/modules/media/constants.d.ts +2 -1
  91. package/dist/modules/media/constants.d.ts.map +1 -1
  92. package/dist/modules/media/context.d.ts +3 -0
  93. package/dist/modules/media/context.d.ts.map +1 -1
  94. package/dist/modules/media/index.d.ts +2 -2
  95. package/dist/modules/media/index.d.ts.map +1 -1
  96. package/dist/modules/media/index.js +61 -218
  97. package/dist/modules/media/index.js.map +1 -1
  98. package/dist/modules/modal/constants.d.ts +1 -1
  99. package/dist/modules/modal/constants.d.ts.map +1 -1
  100. package/dist/modules/modal/context.d.ts.map +1 -1
  101. package/dist/modules/modal/hooks.d.ts +2 -2
  102. package/dist/modules/modal/index.js +20 -366
  103. package/dist/modules/modal/index.js.map +1 -1
  104. package/dist/modules/notification/constants.d.ts +4 -2
  105. package/dist/modules/notification/constants.d.ts.map +1 -1
  106. package/dist/modules/notification/context.d.ts +4 -0
  107. package/dist/modules/notification/context.d.ts.map +1 -1
  108. package/dist/modules/notification/hooks.d.ts +1 -1
  109. package/dist/modules/notification/index.d.ts +2 -3
  110. package/dist/modules/notification/index.d.ts.map +1 -1
  111. package/dist/modules/notification/index.js +70 -154
  112. package/dist/modules/notification/index.js.map +1 -1
  113. package/dist/modules/notification/module.d.ts.map +1 -1
  114. package/dist/provider.js +255 -257
  115. package/dist/provider.js.map +1 -1
  116. package/dist/theme.d.ts +1 -0
  117. package/dist/theme.d.ts.map +1 -1
  118. package/dist/theme.js +2 -2
  119. package/dist/theme.js.map +1 -1
  120. package/dist/utils/index.d.ts +5 -6
  121. package/dist/utils/index.d.ts.map +1 -1
  122. package/dist/utils/index.js +1 -57
  123. package/dist/utils/index.js.map +1 -1
  124. package/dist/utils/number.d.ts +0 -1
  125. package/dist/utils/number.d.ts.map +1 -1
  126. package/dist/utils/object.d.ts +0 -2
  127. package/dist/utils/object.d.ts.map +1 -1
  128. package/dist/utils/string.d.ts +0 -3
  129. package/dist/utils/string.d.ts.map +1 -1
  130. package/dist/utils/timing.d.ts +0 -1
  131. package/dist/utils/timing.d.ts.map +1 -1
  132. package/docs/README.md +124 -0
  133. package/docs/architecture-and-rules.md +129 -0
  134. package/docs/core-engine.md +196 -0
  135. package/docs/custom-modules.md +267 -0
  136. package/docs/getting-started.md +199 -0
  137. package/docs/modules/README.md +176 -0
  138. package/docs/modules/ambient.md +150 -0
  139. package/docs/modules/background.md +195 -0
  140. package/docs/modules/context-menu.md +248 -0
  141. package/docs/modules/controls.md +159 -0
  142. package/docs/modules/dock.md +734 -0
  143. package/docs/modules/error-boundary.md +160 -0
  144. package/docs/modules/kernel.md +266 -0
  145. package/docs/modules/loading.md +157 -0
  146. package/docs/modules/media.md +196 -0
  147. package/docs/modules/modal.md +229 -0
  148. package/docs/modules/notification.md +161 -0
  149. package/docs/modules/result.md +135 -0
  150. package/docs/testing.md +160 -0
  151. package/docs/theming.md +185 -0
  152. package/package.json +50 -2
  153. package/templates/AGENTS.md +165 -0
  154. package/dist/hooks/use-async-action.d.ts +0 -3
  155. package/dist/hooks/use-async-action.d.ts.map +0 -1
  156. package/dist/hooks/use-debounce.d.ts +0 -2
  157. package/dist/hooks/use-debounce.d.ts.map +0 -1
  158. package/dist/hooks/use-intersection-observer.d.ts +0 -4
  159. package/dist/hooks/use-intersection-observer.d.ts.map +0 -1
  160. package/dist/hooks/use-media-query.d.ts +0 -2
  161. package/dist/hooks/use-media-query.d.ts.map +0 -1
  162. package/dist/hooks/use-mounted.d.ts +0 -2
  163. package/dist/hooks/use-mounted.d.ts.map +0 -1
  164. package/dist/hooks/use-server-action.d.ts +0 -4
  165. package/dist/hooks/use-server-action.d.ts.map +0 -1
  166. package/dist/hooks/use-storage-state.d.ts +0 -5
  167. package/dist/hooks/use-storage-state.d.ts.map +0 -1
  168. package/dist/modules/theme.d.ts +0 -3
  169. package/dist/modules/theme.d.ts.map +0 -1
  170. package/dist/utils/json.d.ts +0 -3
  171. package/dist/utils/json.d.ts.map +0 -1
@@ -0,0 +1,196 @@
1
+ # Media — `@omerdlw/base-framework/modules/media`
2
+
3
+ > **Read this first if you need to:** play an audio file for a page, make sure the dock's media controls work for audio or video, sync an audio track to the background video, or understand which source the controls operate on.
4
+
5
+ ## 1. At a glance
6
+
7
+ | | |
8
+ | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
9
+ | **Import** | `import { useMedia, MediaSource, … } from "@omerdlw/base-framework/modules/media"` |
10
+ | **Module id** | `media` (`mediaModule`, in `defaultModules`) |
11
+ | **Renders** | Nothing. `MediaOverlay` is an overlay that returns `null`; it only plays the page's declared audio. |
12
+ | **Registry type** | `media` — singleton key `page-media`, lifecycle `immediate` (entries are removed the moment the registering page unmounts; the declared 600 ms delay applies only if a registration opts into `graceful` / `route`) |
13
+ | **Page slice** | `usePage({ media })` → controller `page.modules.media` |
14
+ | **Peers** | none (it _is_ a peer: `background` and `dock` read it through `definePeer("media")`) |
15
+ | **Theme** | none (the controls are themed by the dock: `media*` and `scrubber*` slots) |
16
+ | **Tests** | `tests/modules/media.test.ts` (session resolution, follower sync, displacement) |
17
+
18
+ ## 2. Mental model
19
+
20
+ The dock shows **one** set of media controls. The `media` module decides what those controls operate on. Any code can register a **source** (a playing element); the module resolves all registered sources into one **session**:
21
+
22
+ ```
23
+ sources ──resolveMediaSession──▶ { transport, audible, followers }
24
+ │ │ └─ mirror the transport (play/pause/seek/speed)
25
+ │ └─ volume & mute controls
26
+ └─ play/pause, scrubber, ±10 s, speed, loop, PiP
27
+ ```
28
+
29
+ | Group | Controls | Bound to |
30
+ | :------------ | :----------------------------------------------------------- | :----------------------------------------------------------------------------- |
31
+ | **Transport** | play/pause, scrubber, ±10 s, speed, loop, Picture-in-Picture | the newest **standalone** source |
32
+ | **Audible** | volume, mute | the newest **audio follower** of the transport; otherwise the transport itself |
33
+
34
+ A source is a **follower** only when it says so explicitly with `follow: "<id>"`. Followers never get their own controls.
35
+
36
+ | Scenario | Transport | Audible |
37
+ | :------------------------------------------------------------ | :--------- | :--------- |
38
+ | Background video only | video | video |
39
+ | Audio only | audio | audio |
40
+ | Muted background video + `useMedia({ follow: "background" })` | video | audio |
41
+ | Video + audio with no `follow` | newest one | newest one |
42
+
43
+ Picture-in-Picture is offered only when the transport is a video.
44
+
45
+ ## 3. Quick start
46
+
47
+ ```tsx
48
+ // Page level: a URL, or the same options as useMedia (minus id/enabled).
49
+ // The audio belongs to the route and stops when the user leaves it.
50
+ usePage({ media: "/audio/rain.mp3" });
51
+ usePage({ media: { src: "/audio/rain.mp3", loop: true, volume: 0.6 } });
52
+ ```
53
+
54
+ ```tsx
55
+ // Component level: the source lives as long as the component is mounted
56
+ function Ambience() {
57
+ const { toggle } = useMedia({ src: "/audio/rain.mp3", loop: true });
58
+ return <button onClick={toggle}>Play / pause</button>;
59
+ }
60
+
61
+ // Declarative form
62
+ <MediaSource src="/audio/rain.mp3" loop />;
63
+ ```
64
+
65
+ ```tsx
66
+ // Muted background video + a parallel audio track.
67
+ // The video drives the timeline; volume and mute control the audio.
68
+ usePage({
69
+ background: { video: "/video/city.mp4", videoOptions: { muted: true } },
70
+ });
71
+ return <MediaSource src="/audio/city.mp3" follow={BACKGROUND_MEDIA_ID} />; // "background"
72
+ ```
73
+
74
+ ## 4. Behaviour
75
+
76
+ - **Registration.** `useMedia` creates an `HTMLAudioElement` (`preload="auto"`) when `src` is truthy and `enabled` is not `false`, registers it, and mirrors its `play` / `playing` / `pause` / `ended` events into `isPlaying`. On cleanup it pauses, clears `src`, and unregisters.
77
+ - **Autoplay.** Standalone sources try to play immediately unless `autoplay: false`. Followers never autoplay: they start when the transport starts. A blocked autoplay leaves the source paused and the dock shows a play button.
78
+ - **Following.** `bindFollowers` mirrors `play`, `playing`, `pause`, `waiting`, `ended`, `seeking`, `seeked`, `ratechange` and `timeupdate`; sets follower playback rate to the transport's; and corrects drift only when it exceeds `MEDIA_SYNC_DRIFT_SECONDS` (0.3 s) during playback (hard-seeks on seeks). Followers' own `loop` is disabled while bound and restored on cleanup.
79
+ - **Displacement.** When a **new standalone** source takes over the controls, other standalone audio that is still playing is **paused**, so two tracks never play over each other while the dock only controls one. Videos, followers and already-paused tracks are untouched, and a paused track does not resume on its own when the newer one goes away.
80
+ - **Edge cases are resolved, never thrown.** A `follow` whose target is missing, is itself a follower, or points at itself makes the source standalone. Follow cycles never leave the dock without a transport. When a standalone source unmounts, control falls back to the previous one.
81
+ - **State updates.** The published `MediaState` is shallow-compared; unrelated source updates do not re-render consumers.
82
+ - **Page media.** `MediaOverlay` reads the registry value, and calls `useMedia({ ...page, id: "page", src })`. When the page unmounts the registry drops the value, which stops the audio.
83
+
84
+ ## 5. Public API
85
+
86
+ | Export | Kind | Description |
87
+ | :--------------------------------------------------------------------------------------- | :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
+ | `mediaModule` | module | `defineModule` definition (provider, overlay, `usePage({ media })`). |
89
+ | `MediaProvider` | component | Source registry, session resolution, follower sync, actions. Mounted by the host. |
90
+ | `useMedia(options)` | hook | Register an audio file for the lifetime of the component. Returns `{ id, play, pause, toggle }`. |
91
+ | `MediaSource` | component | Declarative form of `useMedia` (renders `null`). |
92
+ | `MediaOverlay` | component | Plays the page-declared `media`. Mounted by the host. |
93
+ | `useMediaState()` | hook | Read-only `MediaState` (re-renders on change). |
94
+ | `useMediaActions()` | hook | `MediaActions`. Both hooks throw outside `MediaProvider`. |
95
+ | `MediaContext` | context | For tests / custom providers. |
96
+ | `resolveMediaSession(sources)` | util | Pure: sources → `{ transport, audible, followers }` or `null`. |
97
+ | `createMediaState(session)` | util | Session → `MediaState` (`DEFAULT_MEDIA_STATE` for `null`). |
98
+ | `bindFollowers(transport, followers)` | util | Wire followers to a transport element; returns a cleanup function. |
99
+ | `selectPageMedia(media)` | util | Normalizes `usePage({ media })` input. |
100
+ | `DEFAULT_MEDIA_STATE`, `MEDIA_REGISTRY_KEY`, `MEDIA_SYNC_DRIFT_SECONDS`, `PAGE_MEDIA_ID` | constants | `PAGE_MEDIA_ID` is `"page"`. |
101
+ | Types | | `MediaOptions`, `MediaHandle`, `MediaState`, `MediaActions`, `MediaEntry`, `MediaEntryInput`, `MediaSession`, `MediaKind`, `MediaPageConfig`, `MediaPageApi`, … |
102
+
103
+ ### 5.1 `useMedia` / `<MediaSource>` options
104
+
105
+ | Option | Default | Meaning |
106
+ | :---------------- | :-------------------------- | :------------------------------------------------------------------------------------------------ |
107
+ | `src` | — | Audio URL. A falsy value registers nothing. |
108
+ | `id` | generated (`media-<useId>`) | Stable id other sources can `follow`. |
109
+ | `follow` | `null` | Id of the source to mirror, e.g. `"background"`. Always explicit; nothing is followed implicitly. |
110
+ | `autoplay` | `true` | Start as soon as the file is ready. Ignored for followers. |
111
+ | `loop` | `false` | Loop the file. Ignored while following (the transport decides). |
112
+ | `muted`, `volume` | — | Element state (`volume` is clamped to 0–1). Updated live when the option changes. |
113
+ | `enabled` | `true` | `false` unregisters without unmounting. |
114
+
115
+ `usePage({ media })` accepts a URL string or `MediaPageOptions` (`MediaOptions` minus `id` and `enabled`, plus `registry`). An object without `src` registers nothing.
116
+
117
+ ### 5.2 `MediaState`
118
+
119
+ | Field | Meaning |
120
+ | :------------------ | :------------------------------------------------ |
121
+ | `hasMedia` | A session exists. |
122
+ | `element` | The **transport** element (timeline, speed, PiP). |
123
+ | `audibleElement` | The element volume/mute act on. |
124
+ | `isPlaying`, `loop` | Transport state. |
125
+ | `kind` | `"audio"` or `"video"` (transport). |
126
+ | `sourceId` | Id of the transport source. |
127
+
128
+ ### 5.3 `MediaActions`
129
+
130
+ | Action | Effect |
131
+ | :-------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
+ | `toggle()` | Play/pause the transport (through the source's own `controls.toggle` when it has one, e.g. the background video). |
133
+ | `toggleLoop()` | Toggle loop on the transport. |
134
+ | `setMuted(muted)` | Mute/unmute the **audible** element. |
135
+ | `upsertSource(input)` | Register or patch a source (`id` required). New sources get the next `order`; patches keep their order. Used by `useMedia` and by the background module. |
136
+ | `removeSource(id)` | Unregister. |
137
+
138
+ `page.modules.media` is these plus `set(media)` (`page.set({ media })`).
139
+
140
+ ### 5.4 Registering a custom source
141
+
142
+ `useMedia` is for audio files. For anything else (a video element you own, a streaming player) register through the actions:
143
+
144
+ ```tsx
145
+ const { upsertSource, removeSource } = useMediaActions();
146
+ useEffect(() => {
147
+ upsertSource({
148
+ id: "my-player",
149
+ element: videoEl,
150
+ kind: "video",
151
+ isPlaying: false,
152
+ loop: false,
153
+ controls: {
154
+ toggle: () => player.toggle(),
155
+ toggleLoop: () => player.toggleLoop(),
156
+ setMuted: (m) => player.mute(m),
157
+ },
158
+ });
159
+ return () => removeSource("my-player");
160
+ }, [videoEl]);
161
+ ```
162
+
163
+ Keep `isPlaying` current with another `upsertSource({ id, isPlaying })` call. `controls` overrides the default element handling (the background module does exactly this).
164
+
165
+ ## 6. Recipes and gotchas
166
+
167
+ | I want to… | Do this |
168
+ | :------------------------------------- | :---------------------------------------------------------------------- |
169
+ | Page ambience that stops on navigation | `usePage({ media: url })`. |
170
+ | Audio that survives navigation | Mount `<MediaSource>` in a layout, not a page. |
171
+ | Let the user mute only the audio track | Nothing: the dock's volume binds to the audible follower automatically. |
172
+ | Disable autoplay | `autoplay: false`; the dock shows play. |
173
+
174
+ - Two **unrelated** standalone audios cannot play at once; the newer pauses the older. If you need layering, make one a follower of the other or of the background video.
175
+ - `follow` ids must exist **at registration time or later**; a follower registered before its target is standalone until the target appears.
176
+ - `useMedia` returns controls for **its own** element only; use `useMediaActions().toggle()` to control whatever the dock controls.
177
+
178
+ ## 7. File map
179
+
180
+ | File | Responsibility |
181
+ | :------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
182
+ | `index.ts` | Public barrel. |
183
+ | `types.ts` | Entries, sessions, state, actions, option and page-config contracts. |
184
+ | `constants.ts` | `DEFAULT_MEDIA_STATE`, registry key, drift threshold, page source id. |
185
+ | `utils.ts` | `resolveMediaSession`, `createMediaState`, `mergeMediaSource`, `bindFollowers`, `toggleElement`, `findDisplacedAudio`, `selectPageMedia` (pure or DOM-only, no React). |
186
+ | `context.tsx` | `MediaProvider`: source registry, session publishing, follower binding, actions; `useMediaState`, `useMediaActions`. |
187
+ | `hooks.ts` | `useMedia`: owns the `HTMLAudioElement` lifecycle and registers it. |
188
+ | `overlay.tsx` | `MediaSource` and `MediaOverlay` (components that render nothing). |
189
+ | `module.tsx` | `mediaModule`, `usePage({ media })` page API, kernel type augmentation. |
190
+
191
+ The module has no `view.tsx`, `motion.ts` or `provider.tsx` (the provider is `context.tsx`) because it renders no UI.
192
+
193
+ ## 8. Dependencies
194
+
195
+ - **Uses:** `@omerdlw/base-framework/kernel` (`defineModule`, `useRegistryValue`), `@omerdlw/base-framework/hooks` (`useStore`, `useRequiredContext`), `@omerdlw/base-framework/utils` (`createStore`, `shallowEqual`).
196
+ - **Used by:** `src/core/provider.tsx` (via `defaultModules`); the **background** module (registers its video as `background`, with its own controls); the **dock** (reads the session with `definePeer("media")`, inert when not installed). Both list `media` in `uses`.
@@ -0,0 +1,229 @@
1
+ # Modal — `@omerdlw/base-framework/modules/modal`
2
+
3
+ > **Read this first if you need to:** open a dialog and `await` its answer (confirm, pick, form), show a bottom sheet or side panel, stack dialogs, or expose route-specific dialogs by key.
4
+ >
5
+ > For the dock-integrated sheet use a dock **surface** ([dock.md](./dock.md#8-surfaces)); `modal` is the classic centered/edge dialog with a backdrop.
6
+
7
+ ## 1. At a glance
8
+
9
+ | | |
10
+ | :----------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | **Import** | `import { defineModal, useModal, ModalContainer, … } from "@omerdlw/base-framework/modules/modal"` |
12
+ | **Module id** | `modal` (`modalModule`, in `defaultModules`) |
13
+ | **Renders** | `Modal` overlay, **lazy-loaded** (`next/dynamic`, `ssr: false`), portalled to `document.body` |
14
+ | **Registry type** | `modal` — key policy `named`, value kind `component`, lifecycle `immediate` (entries are removed the moment the registering page unmounts; the declared 600 ms delay applies only if a registration opts into `graceful` / `route`) |
15
+ | **Page slice** | `usePage({ modal: { key: Component } })` → `page.modules.modal` (`open`, `set`, + actions) |
16
+ | **Peers (`uses`)** | none |
17
+ | **Theme** | `modalTheme` slots in `src/config/modal.module.theme.ts` |
18
+ | **z-index** | layer `Z_INDEX.MODAL`, frame `Z_INDEX.MODAL_FRAME` |
19
+ | **Tests** | `tests/modules/modal.test.ts` |
20
+
21
+ ## 2. Mental model
22
+
23
+ - A modal is **a component + options**. `openModal(input, options)` pushes an entry on a **stack** and returns a **promise** that resolves with whatever the component passes to `close(result)` (or `null` when dismissed).
24
+ - Only the **top** modal is interactive. Lower modals stay mounted, are dimmed (clicking the dim closes the top one) and the top gets a small _"Previous / Current"_ switcher to go back.
25
+ - Modals are identified by a **type** string (`id`, `type`, the component's `displayName`/`name`, or the string you passed). The same type or component cannot be on top twice.
26
+ - The component you render receives `close`, `data` and `header` props. `ModalContainer` consumes `header` to draw a consistent title bar.
27
+
28
+ ## 3. Quick start
29
+
30
+ ```tsx
31
+ const ConfirmDelete = defineModal({
32
+ component: ConfirmDialog, // receives { close, data, header }
33
+ title: (data) => `Delete ${data.name}?`, // string, node, or function of data
34
+ position: { mobile: "bottom", desktop: "center" },
35
+ });
36
+
37
+ function DeleteButton({ post }) {
38
+ const [openConfirm] = useModal(ConfirmDelete); // [open, controls]
39
+ return (
40
+ <Button
41
+ onClick={async () => {
42
+ if (await openConfirm({ name: post.title })) await deletePost(post.id);
43
+ }}
44
+ >
45
+ Delete
46
+ </Button>
47
+ );
48
+ }
49
+
50
+ function ConfirmDialog({ data, close, header }) {
51
+ return (
52
+ <ModalContainer
53
+ close={close}
54
+ header={header}
55
+ footer={{
56
+ right: (
57
+ <>
58
+ <Button onClick={() => close(false)}>Cancel</Button>
59
+ <Button onClick={() => close(true)}>Delete</Button>
60
+ </>
61
+ ),
62
+ }}
63
+ >
64
+ This cannot be undone.
65
+ </ModalContainer>
66
+ );
67
+ }
68
+ ```
69
+
70
+ ```tsx
71
+ // Route-level modals, opened by key
72
+ const page = usePage({ modal: { share: ShareModal } });
73
+ await page.modules.modal?.open("share", { url });
74
+
75
+ // Ad-hoc
76
+ const { openModal } = useModalActions();
77
+ const answer = await openModal(PickDate, {
78
+ data: { min: today },
79
+ position: "right",
80
+ title: "Pick a date",
81
+ });
82
+ ```
83
+
84
+ ## 4. Capability matrix
85
+
86
+ | Capability | How |
87
+ | :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
+ | Awaitable result | `await openModal(...)` → the value passed to `close(result)`; `null` when dismissed (Esc, backdrop, dim, switcher, `closeModal()`). |
89
+ | Five positions | `center`, `top`, `bottom`, `left`, `right`. |
90
+ | Responsive position | `position: { mobile?: ModalPosition; desktop?: ModalPosition }` (mobile = `max-width: 639px`). Updates live on resize. |
91
+ | Two chromes | `"panel"` (default: surface, radius, header/close) or `"bare"` (your component owns the look). |
92
+ | Stacking | Open from within a modal; lower ones dim, top shows a back switcher. |
93
+ | Header | `title`, `actions` (header actions node), `showClose` (default `true`) via options; drawn by `ModalContainer`. |
94
+ | Header/footer slots | `ModalContainer` `header` / `footer` configs with `left` / `center` / `right`, `sticky`. |
95
+ | Focus management | Initial focus on the first focusable element; Tab wraps inside the top modal; focus returns to the previous element on close. |
96
+ | Scroll lock | Body scroll is locked while any modal is visible (`acquireGlobalScrollLock`) and a `modal:smooth-scroll-lock` window event tells smooth-scroll libraries. |
97
+ | By key | `usePage({ modal })` / `useModalRegistration` register components by key; open with `open("key", data)`. |
98
+
99
+ ## 5. Public API
100
+
101
+ | Export | Kind | Description |
102
+ | :---------------------------------------------- | :-------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
+ | `modalModule` | module | `defineModule` definition. |
104
+ | `ModalProvider` | component | Stack state, actions, pending promises. Prop `modalRenderer` renders a custom stack renderer inside the provider. Mounted by the host. |
105
+ | `Modal` | component | The stack renderer (portal). Mounted lazily by the host. |
106
+ | `ModalContainer` | component | Header / body / footer layout for modal content. |
107
+ | `useModal()` | hook | `ModalState & ModalActions` (state + `openModal`, `closeModal`, `closeAllModals`). |
108
+ | `useModal(input)` | hook | Binding for one modal: `[open, controls]` that is also `{ open, close, closeAll, isOpen, state }`. |
109
+ | `useModalActions()`, `useModalState()` | hooks | Split access (stable actions / state). Throw outside `ModalProvider`. |
110
+ | `defineModal(def)` | builder | Reusable modal definition (frozen) with `.use()`. |
111
+ | `useModalRegistration(config, options?)` | hook | Register modal components by key outside `usePage`. |
112
+ | `ModalContext` | context | For tests and custom providers. |
113
+ | `MODAL_POSITIONS`, `MODAL_CHROME`, `modalTheme` | constants | |
114
+ | Types | | `ModalPosition`, `ResponsiveModalPosition`, `ModalChrome`, `ModalInput`, `ModalOptions`, `ModalActions`, `ModalState`, `ModalEntry`, `ModalDefinition`, `DefineModalOptions`, `ModalContainerProps`, `ModalPageConfig`, `ModalPageApi`, … |
115
+
116
+ ### 5.1 `ModalInput`
117
+
118
+ `openModal(input, options?)` accepts:
119
+
120
+ | Input | Resolved how |
121
+ | :------------------------------------- | :------------------------------------------------------------------------------------------------------------- |
122
+ | `ModalDefinition` (from `defineModal`) | Its `component`, `type`/`id`, `defaultData`, and option defaults. Call options override definition options. |
123
+ | A component | Type = `displayName ?? name ?? "modal"`. |
124
+ | A string | Looked up in the `modal` registry (page/registry-registered components). An unknown key opens nothing visible. |
125
+ | falsy | Resolves `null`. |
126
+
127
+ ### 5.2 `ModalOptions`
128
+
129
+ | Option | Default | Meaning |
130
+ | :---------------- | :-------- | :------------------------------------------------------------------------------------------ |
131
+ | `data` | `{}` | Props passed to the component as `data` (merged over `defaultData`). |
132
+ | `title` | `null` | Node or `(data) => node`; shown by `ModalContainer`. |
133
+ | `actions` | `null` | Node shown in the header (`header.actions`). |
134
+ | `position` | `center` | `ModalPosition` or `{ mobile, desktop }`. |
135
+ | `chrome` | `"panel"` | `"panel"` or `"bare"`. |
136
+ | `showClose` | `true` | Close button in the header. |
137
+ | `onClose(result)` | | Called once when the modal closes (errors, including async ones, are reported, not thrown). |
138
+
139
+ ### 5.3 What the component receives
140
+
141
+ ```ts
142
+ { close(result?), data, header: { actions, position, showClose, title, titleId } }
143
+ ```
144
+
145
+ `close` is bound to **this** modal's id (closing a lower modal from the top one is safe). `header.titleId` is wired to `aria-labelledby` of the dialog.
146
+
147
+ ### 5.4 `ModalContainer` props
148
+
149
+ | Prop | Meaning |
150
+ | :--------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
151
+ | `header` | `false` hides it; a config `{ title, left, center, right, actions, showClose, sticky, position, titleId }`; or a node (rendered in the center). The easiest is to pass the received `header` straight through. `showClose` must be `true` to draw a close button (needs `close`). |
152
+ | `footer` | `false`/absent hides it; config `{ left, center, right, sticky }`. |
153
+ | `close` | The `close` prop of the modal. |
154
+ | `position` | Override position (side positions switch to full-height layout). |
155
+ | `className`, `bodyClassName` | A height class (`h-*`, `max-h-*`) in `className` disables the default max-height. |
156
+ | `children` | The body (scrollable; `data-lenis-prevent` is set). |
157
+
158
+ ### 5.5 Actions
159
+
160
+ | Action | Description |
161
+ | :--------------------------- | :--------------------------------------------------------------------------------------------------------------------- |
162
+ | `openModal(input, options?)` | Push a modal. **Duplicate guard:** if the top entry has the same type or component, resolves `null` and opens nothing. |
163
+ | `closeModal(result?, id?)` | Close the top modal (or the one with `id`); resolves its promise. Unknown ids are ignored. |
164
+ | `closeAllModals(result?)` | Close everything; every pending promise resolves with the same `result` (default `null`). |
165
+
166
+ `page.modules.modal` = actions plus `open(modalOrKey, data?)` (looks the key up in **this page's** `modal` slice first, then falls back to the registry) and `set(modal)`.
167
+
168
+ `ModalState`: `isOpen`, `modalStack`, `activeModalId`, `modalType`, `title`, `props`, `position`, `responsivePosition`, `chrome`, `showClose`, `headerActions` (the top entry's values).
169
+
170
+ ## 6. Behaviour details
171
+
172
+ - **Dismissal.** `Esc` (top modal), backdrop click, and clicking the dim over a lower modal close the **top** modal with `null`. During a top-modal exit animation the backdrop ignores clicks so one click cannot close two modals.
173
+ - **Lifecycle.** Entries are removed from the stack immediately; the exit animation is played by the renderer. Promises resolve at close time, not after the animation.
174
+ - **Rendering.** Entries whose component cannot be resolved (string type not in the registry) are skipped; they remain in `modalStack` but not on screen.
175
+ - **Error isolation.** Each modal body is wrapped in `ModuleBoundary`, so a crash shows the module error UI for that modal only.
176
+ - **Accessibility.** `role="dialog"`, `aria-modal` on the top modal only, labelled by the title id; lower layers are covered by a dim that closes the top modal.
177
+ - **SSR.** Nothing renders until mounted (`useSyncExternalStore` guard); the module is lazy and client-only.
178
+
179
+ ## 7. Theming
180
+
181
+ No classes or colors in the module except the two z-indexes. Slots in `modalTheme`:
182
+
183
+ | Group | Slots |
184
+ | :-------- | :------------------------------------------------------------------------------ |
185
+ | Backdrop | `backdrop`, `dim` |
186
+ | Placement | `layer` (`data-position`, `data-inset`), `frame` (`data-layout`, `data-active`) |
187
+ | Panel | `panel`, `panelChrome`, `panelBare` (`data-layout`) |
188
+ | Container | `content` (`data-height`), `body`, `title`, `closeButton` |
189
+ | Rows | `row` (`data-columns`, `data-sticky`), `rowStart`, `rowCenter`, `rowEnd` |
190
+ | Switcher | `switcher`, `switcherButton`, `switcherDivider`, `switcherCurrent` |
191
+
192
+ `data-layout` is one of `center`, `top`, `bottom`, `left`, `right`, `top-mobile`, `bottom-mobile`, `side-mobile` (`getModalLayout`), so each position/viewport combination is a single selector.
193
+
194
+ Motion (`motion.ts`: backdrop, per-position panel variants/transitions, header/body/footer variants) is derived from `@omerdlw/base-framework/tokens` ([Rule 4](../architecture-and-rules.md)). Use `Z_INDEX` for anything layered above or below a modal ([Rule 5](../architecture-and-rules.md)).
195
+
196
+ ## 8. Recipes and gotchas
197
+
198
+ | I want to… | Do this |
199
+ | :------------------------------------------ | :---------------------------------------------------------------------------------------------- |
200
+ | A confirm dialog | `defineModal` + `close(true/false)`; `await open()`. |
201
+ | A bottom sheet on phones, dialog on desktop | `position: { mobile: "bottom", desktop: "center" }`. |
202
+ | Chain dialogs | Call `openModal` from inside the first one; the second gets the "back" switcher. |
203
+ | A fully custom look | `chrome: "bare"` and skip `ModalContainer`, or style via the theme. |
204
+ | Open by key from anywhere | Register with `usePage({ modal })` / `useModalRegistration`, then `openModal("key", { data })`. |
205
+
206
+ - Treat `null` as "dismissed"; only a result you pass to `close(...)` is meaningful.
207
+ - Opening the same modal twice quickly returns `null` for the second call.
208
+ - Registering components via the registry makes them available **only while the page that registered them is mounted** (lifecycle `immediate`).
209
+ - `useModal()` re-renders on every stack change; use `useModalActions()` for fire-and-forget buttons.
210
+
211
+ ## 9. File map
212
+
213
+ | File | Responsibility |
214
+ | :------------- | :------------------------------------------------------------------------------------------------------------------------------------ |
215
+ | `index.ts` | Public barrel. |
216
+ | `types.ts` | Positions, chrome, entries, state, actions, definitions, container props, theme slots. |
217
+ | `constants.ts` | `MODAL_POSITIONS`, `MODAL_CHROME`, mobile breakpoint, scroll-lock event, focusable selector, height-constraint pattern, `modalTheme`. |
218
+ | `state.ts` | `createModalState` (stack → state) and the initial state. |
219
+ | `utils.ts` | Identity resolution, position normalization (responsive), focus trap helpers, header-action resolution, labels. |
220
+ | `context.tsx` | `ModalProvider` (stack, pending promises, actions), `useModal*` hooks, registry lookup. |
221
+ | `hooks.ts` | View models: `useModalModel` (visibility, viewport, scroll lock), `useModalLayerModel` (focus, Esc), `useModalContainerModel`. |
222
+ | `overlay.tsx` | `Modal`, `ModalLayer`, `ModalContainer`, switcher, close button. |
223
+ | `motion.ts` | Backdrop, panel, header, content and footer motion. |
224
+ | `module.tsx` | `modalModule`, lazy renderer, page API, `defineModal`, kernel type augmentation. |
225
+
226
+ ## 10. Dependencies
227
+
228
+ - **Uses:** `@omerdlw/base-framework/kernel` (`defineModule`, `useModuleRegistration`, `useRegistryEntries`, `ModuleBoundary`), `@omerdlw/base-framework/tokens` (`Z_INDEX`, motion), `@omerdlw/base-framework/utils` (`acquireGlobalScrollLock`, `createStore`), `@omerdlw/base-framework/atoms` (`Button`, `Icon`), `@omerdlw/base-framework/theme`, `next/dynamic`, `motion/react`.
229
+ - **Used by:** `src/core/provider.tsx` (installs it), pages and features through `useModal` / `page.modules.modal`.
@@ -0,0 +1,161 @@
1
+ # Notification — `@omerdlw/base-framework/modules/notification`
2
+
3
+ > **Read this first if you need to:** show feedback ("Saved", "Link copied"), toast a promise or a server-action `Result`, raise feedback from code that does not import React, or understand where toasts appear.
4
+
5
+ ## 1. At a glance
6
+
7
+ | | |
8
+ | :------------------ | :------------------------------------------------------------------------------------------------- |
9
+ | **Import** | `import { useToast } from "@omerdlw/base-framework/modules/notification"` |
10
+ | **Module id** | `notification` (`notificationModule`, in `defaultModules`) |
11
+ | **Renders** | `NotificationLayer` overlay: the toast + an event listener |
12
+ | **Registry type** | none |
13
+ | **Page slice** | `usePage({ notification: 3000 })` or `{ duration }` → `page.modules.notification = { toast, set }` |
14
+ | **Peers (`uses`)** | none (the dock reads _this_ module as a peer) |
15
+ | **Theme** | `notificationTheme` slots in `src/config/notification.module.theme.ts` |
16
+ | **Events listened** | `API_UNAUTHORIZED`, `APP_ERROR` (`notify: true`), `STATE_CHANGE` (`notify: true`) |
17
+ | **DOM contract** | Portals into `#dock-card-stack` (owned by the dock) when it exists |
18
+ | **Tests** | `tests/modules/notification.test.ts` |
19
+
20
+ ## 2. Mental model
21
+
22
+ - There is **one toast slot**. Showing a toast replaces the one on screen (and cancels its timer); the newest always wins. There is no queue and no stack.
23
+ - A toast has an **id**. Showing a toast with an id that is already visible updates it **in place**; this is how `toast.promise` turns "Uploading…" into "Done".
24
+ - The toast is rendered **against the dock**: it portals into the dock's card stack so feedback appears where the user's attention already is. Without a dock it falls back to a floating layer on `document.body`.
25
+ - Feedback can also come from **events**, so features never import this module.
26
+
27
+ ## 3. Quick start
28
+
29
+ ```tsx
30
+ const toast = useToast();
31
+
32
+ toast("Profile saved");
33
+ toast("Link copied", 1500); // duration in ms
34
+ toast("Syncing…", { id: "sync", dedupeKey: "sync" }); // update in place
35
+ toast("Stays until dismissed", { duration: null });
36
+
37
+ await toast.promise(uploadAvatar(file), {
38
+ loading: "Uploading…",
39
+ success: "Avatar updated",
40
+ error: (e) => `Upload failed: ${toUserMessage(e)}`,
41
+ });
42
+
43
+ toast.fromResult(await updateProfileAction(form), { success: "Saved" }); // Result<T, E>
44
+ ```
45
+
46
+ ```ts
47
+ // From anywhere, without importing the module
48
+ globalEvents.emit(EVENT_TYPES.STATE_CHANGE, {
49
+ message: "Draft restored",
50
+ notify: true,
51
+ });
52
+ ```
53
+
54
+ ## 4. Public API
55
+
56
+ | Export | Kind | Description |
57
+ | :--------------------------------------------------- | :-------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
58
+ | `useToast(defaultDuration?)` | hook | Returns a `ToastController` (below). `defaultDuration` is the duration used when a call does not pass one (default 2000 ms). |
59
+ | `notificationModule` | module | `defineModule` definition. |
60
+ | `NotificationProvider` | component | Holds the notification store, timers and actions. Mounted by the host. |
61
+ | `useNotification()` | hook | `NotificationState & NotificationActions`. |
62
+ | `useNotificationActions()`, `useNotificationState()` | hooks | Split access. Throw outside the provider. |
63
+ | `NotificationContext` | context | For tests and the dock's peer reader. |
64
+ | `TOAST_DURATIONS` | constant | `{ DEFAULT: 2000, SHORT: 1500 }`. |
65
+ | `notificationTheme` | spec | For `defineTheme`. |
66
+ | Types | | `NotificationData`, `NotificationEntry`, `NotificationOptions` / `ToastOptions`, `ToastOptionsInput`, `NotificationActions`, `ToastController`, `ToastPromiseMessages`, `NotificationPageConfig`, `NotificationPageApi`, `NotificationThemeSlot`. |
67
+
68
+ ### 4.1 `ToastController`
69
+
70
+ | Member | Behaviour |
71
+ | :----------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `toast(message, options?)` | Show a toast. `options` is a number (duration) or `{ id?, dedupeKey?, duration? }`. Returns the toast id, or `null` when the message is empty. |
73
+ | `toast.promise(promiseOrFn, messages, options?)` | Shows `messages.loading` (sticky, `duration: null`) under a stable id, awaits, then replaces it with `success` / `error` (same id). If the resolved value is a `Result`, `ok` → `success(data)`, `err` → `error(error)`. Errors are **re-thrown**. Without `loading`, only the outcome toast is shown; with `loading` but no outcome message, the loading toast is dismissed. |
74
+ | `toast.fromResult(result, messages?, options?)` | If `result` is a `Result`, shows `messages.success(data)` or `messages.error(error)` (falling back to `toUserMessage(error)`) and **returns the result unchanged**. Non-`Result` values are ignored. |
75
+ | `toast.dismiss(id?)` | Dismiss one toast, or all when no id. |
76
+ | `toast.dismissAll()` | Dismiss everything. |
77
+
78
+ `messages` values may be a node or a function of the data / error.
79
+
80
+ ### 4.2 Options and durations
81
+
82
+ | Option | Meaning |
83
+ | :---------- | :-------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `duration` | Milliseconds. `undefined` → the hook default (2000). `null` → sticky. Any non-positive or non-numeric value is treated as sticky. |
85
+ | `id` | Identity for in-place updates. |
86
+ | `dedupeKey` | Same as `id` (wins over `id`). Equal keys replace each other. |
87
+
88
+ When neither is given the id is the first 64 characters of the message (string messages) or `notification-N`.
89
+
90
+ ### 4.3 Text normalization
91
+
92
+ String messages are trimmed and **trailing periods/whitespace are removed** (`"Saved. "` → `"Saved"`). Empty / `null` / `undefined` messages are ignored (`toast` returns `null`).
93
+
94
+ ### 4.4 Page default
95
+
96
+ `usePage({ notification: 3000 })` (or `{ duration: 3000 }`) sets the default duration for `page.modules.notification.toast` while the page is mounted; calls can still pass their own duration. `page.modules.notification.set(...)` changes it at runtime.
97
+
98
+ ## 5. Events
99
+
100
+ `NotificationListener` (part of the overlay) turns framework events into toasts:
101
+
102
+ | Event | Condition | Toast |
103
+ | :----------------------------- | :----------------------------- | :------------------------------------------------------------------------------ |
104
+ | `EVENT_TYPES.API_UNAUTHORIZED` | `source` is missing or `"app"` | `USER_MESSAGES.unauthorized` ("Your session has expired. Please sign in again") |
105
+ | `EVENT_TYPES.APP_ERROR` | `notify: true` and a `message` | the message |
106
+ | `EVENT_TYPES.STATE_CHANGE` | `notify: true` and a `message` | the message |
107
+
108
+ (The **dock** independently turns every `APP_ERROR` and every critical `API_ERROR` into its error status card, whether or not `notify` is set; `notify: true` only adds this module's toast.)
109
+
110
+ ## 6. Rendering
111
+
112
+ - Docked: `createPortal(…, #dock-card-stack)` with `toastDocked`; announced with `aria-live="polite"`.
113
+ - Floating (no dock in the DOM): a `layer` on `document.body` with `toast`.
114
+ - The toast is `role="alert"`, focusable, and dismisses on **click** or **Escape**. Nothing renders before hydration.
115
+ - While a toast is visible the dock hides its breadcrumbs companion card (the toast takes the companion slot).
116
+
117
+ ## 7. Theming
118
+
119
+ No classes or inline styles in the module. `notificationTheme` slots: `layer`, `toast`, `toastDocked`, `toastMessage` (Tailwind classes, all required) plus optional inline `styles` per slot. A missing theme throws a descriptive error. Motion (`toastVariants`, `NOTIFICATION_TRANSITION`) stays in `motion.ts`.
120
+
121
+ ## 8. Writing toast copy
122
+
123
+ - Speak to the user, never the developer: no codes, no vendor names, no stack wording.
124
+ - Say what happened and what to do: "Couldn't rename this passkey", not "Failed to rename".
125
+ - Sentence case, no trailing period, no exclamation marks. Success is short: "Profile updated".
126
+ - Never pass `error.message` to `toast`. Use `toUserMessage(error, { fallback: "Couldn't …" })`; `fromResult` and `promise` already do.
127
+
128
+ ## 9. Recipes and gotchas
129
+
130
+ | I want to… | Do this |
131
+ | :------------------------------- | :--------------------------------------------------------------------------- |
132
+ | Toast after a server action | `toast.fromResult(await action(), { success: "Saved" })` |
133
+ | A toast that updates | Give it an `id`; call `toast(…, { id })` again. |
134
+ | A persistent toast | `{ duration: null }`, then `toast.dismiss(id)`. |
135
+ | Feedback from a non-React module | Emit `STATE_CHANGE` with `notify: true`. |
136
+ | Show several messages at once | Not supported (single slot). Combine the messages, or use a dock status/HUD. |
137
+
138
+ - Calling `toast` rapidly keeps only the last message; the earlier ones disappear immediately.
139
+ - `useToast` is a hook; outside React use the `STATE_CHANGE` event.
140
+ - `promise` shows the loading toast **only if** `messages.loading` is set.
141
+
142
+ ## 10. File map
143
+
144
+ | File | Responsibility |
145
+ | :------------- | :------------------------------------------------------------------------------------------ |
146
+ | `index.ts` | Public barrel. |
147
+ | `types.ts` | Notification data, options, controller, page and theme-slot contracts. |
148
+ | `constants.ts` | `TOAST_DURATIONS`, session-expired message, `DOCK_STACK_ELEMENT_ID`, `notificationTheme`. |
149
+ | `utils.ts` | Option normalization, default duration, text normalization, active-toast selection, guards. |
150
+ | `context.tsx` | `NotificationProvider` (store, timers, show/dismiss), `useNotification*` hooks. |
151
+ | `hooks.ts` | `useToast` (`promise`, `fromResult`), `NotificationListener`, container view model. |
152
+ | `overlay.tsx` | `Toast`, `NotificationContainer` (dock/floating portal), `NotificationLayer`. |
153
+ | `motion.ts` | Toast variants and transition. |
154
+ | `module.ts` | `notificationModule` and the page API (plain `.ts`: it has no JSX). |
155
+
156
+ `builder.ts` is absent: toasts are imperative ([README](./README.md#42-why-some-optional-slots-are-absent)).
157
+
158
+ ## 11. Dependencies
159
+
160
+ - **Uses:** `@omerdlw/base-framework/kernel` (`defineModule`), `@omerdlw/base-framework/events` (`EVENT_TYPES`), `@omerdlw/base-framework/hooks` (`useGlobalEvent`, `useRequiredContext`, `useStore`), `@omerdlw/base-framework/result` (`isResult`), `@omerdlw/base-framework/utils` (`toUserMessage`, `USER_MESSAGES`, `createStore`), `@omerdlw/base-framework/theme`, `motion/react`. It finds the dock through the DOM id `dock-card-stack`.
161
+ - **Used by:** `src/core/provider.tsx`, `page.modules.notification`, features (for example the account social surface), and the **dock**, which reads "is a toast visible" from this module's store through `definePeer("notification")`.