@omerdlw/base-framework 1.0.6 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/dist/atoms/index.js +1 -103
  2. package/dist/atoms/index.js.map +1 -1
  3. package/dist/chunk-3BUP64Q6.js +105 -0
  4. package/dist/chunk-3BUP64Q6.js.map +1 -0
  5. package/dist/chunk-4GMQKD6F.js +353 -0
  6. package/dist/chunk-4GMQKD6F.js.map +1 -0
  7. package/dist/chunk-6SG7V77J.js +577 -0
  8. package/dist/chunk-6SG7V77J.js.map +1 -0
  9. package/dist/chunk-6ZJVLUBE.js +26 -0
  10. package/dist/chunk-6ZJVLUBE.js.map +1 -0
  11. package/dist/chunk-7HQMXTNP.js +419 -0
  12. package/dist/chunk-7HQMXTNP.js.map +1 -0
  13. package/dist/chunk-7TAVYJ3A.js +371 -0
  14. package/dist/chunk-7TAVYJ3A.js.map +1 -0
  15. package/dist/chunk-A5RE3F6O.js +9905 -0
  16. package/dist/chunk-A5RE3F6O.js.map +1 -0
  17. package/dist/chunk-AQ4BNXA2.js +349 -0
  18. package/dist/chunk-AQ4BNXA2.js.map +1 -0
  19. package/dist/chunk-CGHZSSFR.js +2166 -0
  20. package/dist/chunk-CGHZSSFR.js.map +1 -0
  21. package/dist/chunk-DGCQWQLZ.js +61 -0
  22. package/dist/chunk-DGCQWQLZ.js.map +1 -0
  23. package/dist/chunk-FIMPHODX.js +67 -0
  24. package/dist/chunk-FIMPHODX.js.map +1 -0
  25. package/dist/chunk-GF7TUX5I.js +1889 -0
  26. package/dist/chunk-GF7TUX5I.js.map +1 -0
  27. package/dist/chunk-JELG4JLT.js +454 -0
  28. package/dist/chunk-JELG4JLT.js.map +1 -0
  29. package/dist/chunk-K676GL7Z.js +51 -0
  30. package/dist/chunk-K676GL7Z.js.map +1 -0
  31. package/dist/chunk-QI3ALLIB.js +38 -0
  32. package/dist/chunk-QI3ALLIB.js.map +1 -0
  33. package/dist/chunk-QXR55VVS.js +1139 -0
  34. package/dist/chunk-QXR55VVS.js.map +1 -0
  35. package/dist/chunk-R2YGFJDJ.js +408 -0
  36. package/dist/chunk-R2YGFJDJ.js.map +1 -0
  37. package/dist/chunk-RFZQWLC5.js +27 -0
  38. package/dist/chunk-RFZQWLC5.js.map +1 -0
  39. package/dist/chunk-SKBITB5X.js +888 -0
  40. package/dist/chunk-SKBITB5X.js.map +1 -0
  41. package/dist/chunk-SYAVGTOX.js +521 -0
  42. package/dist/chunk-SYAVGTOX.js.map +1 -0
  43. package/dist/chunk-TV3WEF5G.js +784 -0
  44. package/dist/chunk-TV3WEF5G.js.map +1 -0
  45. package/dist/chunk-XASQT4CQ.js +280 -0
  46. package/dist/chunk-XASQT4CQ.js.map +1 -0
  47. package/dist/error/index.js +5 -682
  48. package/dist/error/index.js.map +1 -1
  49. package/dist/error/reporter.d.ts.map +1 -1
  50. package/dist/error/theme.d.ts +1 -1
  51. package/dist/error/theme.d.ts.map +1 -1
  52. package/dist/events.js +1 -7
  53. package/dist/events.js.map +1 -1
  54. package/dist/hooks/index.js +1 -358
  55. package/dist/hooks/index.js.map +1 -1
  56. package/dist/hooks/use-global-event.d.ts +1 -1
  57. package/dist/index.js +18 -20029
  58. package/dist/index.js.map +1 -1
  59. package/dist/kernel/index.js +2 -2035
  60. package/dist/kernel/index.js.map +1 -1
  61. package/dist/kernel/types.d.ts +1 -1
  62. package/dist/modules/ambient/analyze.d.ts +46 -0
  63. package/dist/modules/ambient/analyze.d.ts.map +1 -0
  64. package/dist/modules/ambient/constants.d.ts +1 -5
  65. package/dist/modules/ambient/constants.d.ts.map +1 -1
  66. package/dist/modules/ambient/context.d.ts.map +1 -1
  67. package/dist/modules/ambient/dom.d.ts +11 -12
  68. package/dist/modules/ambient/dom.d.ts.map +1 -1
  69. package/dist/modules/ambient/extractor.d.ts +10 -10
  70. package/dist/modules/ambient/extractor.d.ts.map +1 -1
  71. package/dist/modules/ambient/hooks.d.ts +1 -1
  72. package/dist/modules/ambient/hooks.d.ts.map +1 -1
  73. package/dist/modules/ambient/index.d.ts +6 -4
  74. package/dist/modules/ambient/index.d.ts.map +1 -1
  75. package/dist/modules/ambient/index.js +7 -762
  76. package/dist/modules/ambient/index.js.map +1 -1
  77. package/dist/modules/ambient/module.d.ts +1 -1
  78. package/dist/modules/ambient/module.d.ts.map +1 -1
  79. package/dist/modules/ambient/oklab.d.ts +36 -0
  80. package/dist/modules/ambient/oklab.d.ts.map +1 -0
  81. package/dist/modules/ambient/pipeline.d.ts +28 -0
  82. package/dist/modules/ambient/pipeline.d.ts.map +1 -0
  83. package/dist/modules/ambient/quantize.d.ts +22 -0
  84. package/dist/modules/ambient/quantize.d.ts.map +1 -0
  85. package/dist/modules/ambient/server/address.d.ts +8 -0
  86. package/dist/modules/ambient/server/address.d.ts.map +1 -0
  87. package/dist/modules/ambient/server/index.d.ts +53 -0
  88. package/dist/modules/ambient/server/index.d.ts.map +1 -0
  89. package/dist/modules/ambient/server/index.js +221 -0
  90. package/dist/modules/ambient/server/index.js.map +1 -0
  91. package/dist/modules/ambient/settings.d.ts +19 -0
  92. package/dist/modules/ambient/settings.d.ts.map +1 -0
  93. package/dist/modules/ambient/types.d.ts +105 -9
  94. package/dist/modules/ambient/types.d.ts.map +1 -1
  95. package/dist/modules/ambient/worker/index.d.ts +15 -0
  96. package/dist/modules/ambient/worker/index.d.ts.map +1 -0
  97. package/dist/modules/ambient/worker/index.js +114 -0
  98. package/dist/modules/ambient/worker/index.js.map +1 -0
  99. package/dist/modules/background/constants.d.ts +2 -2
  100. package/dist/modules/background/constants.d.ts.map +1 -1
  101. package/dist/modules/background/hooks.d.ts +1 -1
  102. package/dist/modules/background/index.js +6 -3078
  103. package/dist/modules/background/index.js.map +1 -1
  104. package/dist/modules/background/motion.d.ts +9 -7
  105. package/dist/modules/background/motion.d.ts.map +1 -1
  106. package/dist/modules/background/overlay.d.ts +3 -8
  107. package/dist/modules/background/overlay.d.ts.map +1 -1
  108. package/dist/modules/background/types.d.ts +16 -2
  109. package/dist/modules/background/types.d.ts.map +1 -1
  110. package/dist/modules/context-menu/constants.d.ts +2 -2
  111. package/dist/modules/context-menu/constants.d.ts.map +1 -1
  112. package/dist/modules/context-menu/hooks.d.ts +1 -1
  113. package/dist/modules/context-menu/index.js +10 -1896
  114. package/dist/modules/context-menu/index.js.map +1 -1
  115. package/dist/modules/context-menu/motion.d.ts +1 -8
  116. package/dist/modules/context-menu/motion.d.ts.map +1 -1
  117. package/dist/modules/context-menu/overlay.d.ts.map +1 -1
  118. package/dist/modules/context-menu/types.d.ts +16 -2
  119. package/dist/modules/context-menu/types.d.ts.map +1 -1
  120. package/dist/modules/controls/constants.d.ts +1 -1
  121. package/dist/modules/controls/constants.d.ts.map +1 -1
  122. package/dist/modules/controls/entries.d.ts +1 -1
  123. package/dist/modules/controls/hooks.d.ts +1 -1
  124. package/dist/modules/controls/index.js +4 -1110
  125. package/dist/modules/controls/index.js.map +1 -1
  126. package/dist/modules/controls/overlay.d.ts +1 -1
  127. package/dist/modules/controls/types.d.ts +2 -2
  128. package/dist/modules/dock/cards.d.ts +711 -24
  129. package/dist/modules/dock/cards.d.ts.map +1 -1
  130. package/dist/modules/dock/constants.d.ts +2 -2
  131. package/dist/modules/dock/constants.d.ts.map +1 -1
  132. package/dist/modules/dock/helpers.d.ts +2 -2
  133. package/dist/modules/dock/hooks.d.ts +2 -2
  134. package/dist/modules/dock/index.d.ts +1 -1
  135. package/dist/modules/dock/index.d.ts.map +1 -1
  136. package/dist/modules/dock/index.js +9 -12132
  137. package/dist/modules/dock/index.js.map +1 -1
  138. package/dist/modules/dock/layout.d.ts.map +1 -1
  139. package/dist/modules/dock/module.d.ts +1 -1
  140. package/dist/modules/dock/motion.d.ts +44 -912
  141. package/dist/modules/dock/motion.d.ts.map +1 -1
  142. package/dist/modules/dock/overlay.d.ts +1 -1
  143. package/dist/modules/dock/overlay.d.ts.map +1 -1
  144. package/dist/modules/dock/runtime/dock.d.ts +1 -23
  145. package/dist/modules/dock/runtime/dock.d.ts.map +1 -1
  146. package/dist/modules/dock/styles.d.ts +4 -4
  147. package/dist/modules/dock/surface/hooks.d.ts +5 -20
  148. package/dist/modules/dock/surface/hooks.d.ts.map +1 -1
  149. package/dist/modules/dock/surface/machine.d.ts +4 -3
  150. package/dist/modules/dock/surface/machine.d.ts.map +1 -1
  151. package/dist/modules/dock/surface/stack.d.ts.map +1 -1
  152. package/dist/modules/dock/surface/view-model.d.ts.map +1 -1
  153. package/dist/modules/dock/types.d.ts +162 -3
  154. package/dist/modules/dock/types.d.ts.map +1 -1
  155. package/dist/modules/index.js +16 -17883
  156. package/dist/modules/index.js.map +1 -1
  157. package/dist/modules/loading/constants.d.ts +1 -1
  158. package/dist/modules/loading/constants.d.ts.map +1 -1
  159. package/dist/modules/loading/context.d.ts +1 -1
  160. package/dist/modules/loading/hooks.d.ts +1 -1
  161. package/dist/modules/loading/index.js +5 -1153
  162. package/dist/modules/loading/index.js.map +1 -1
  163. package/dist/modules/loading/types.d.ts +2 -2
  164. package/dist/modules/media/context.d.ts +1 -1
  165. package/dist/modules/media/index.js +3 -570
  166. package/dist/modules/media/index.js.map +1 -1
  167. package/dist/modules/media/types.d.ts +2 -2
  168. package/dist/modules/modal/constants.d.ts +2 -2
  169. package/dist/modules/modal/constants.d.ts.map +1 -1
  170. package/dist/modules/modal/context.d.ts +1 -1
  171. package/dist/modules/modal/hooks.d.ts +3 -3
  172. package/dist/modules/modal/index.js +8 -2355
  173. package/dist/modules/modal/index.js.map +1 -1
  174. package/dist/modules/modal/motion.d.ts +3 -8
  175. package/dist/modules/modal/motion.d.ts.map +1 -1
  176. package/dist/modules/modal/overlay.d.ts.map +1 -1
  177. package/dist/modules/modal/types.d.ts +21 -0
  178. package/dist/modules/modal/types.d.ts.map +1 -1
  179. package/dist/modules/notification/constants.d.ts +2 -2
  180. package/dist/modules/notification/constants.d.ts.map +1 -1
  181. package/dist/modules/notification/context.d.ts +1 -1
  182. package/dist/modules/notification/hooks.d.ts +1 -1
  183. package/dist/modules/notification/index.js +4 -828
  184. package/dist/modules/notification/index.js.map +1 -1
  185. package/dist/modules/notification/motion.d.ts +1 -7
  186. package/dist/modules/notification/motion.d.ts.map +1 -1
  187. package/dist/modules/notification/overlay.d.ts.map +1 -1
  188. package/dist/modules/notification/types.d.ts +9 -1
  189. package/dist/modules/notification/types.d.ts.map +1 -1
  190. package/dist/overlay-TZ6L52A5.js +9 -0
  191. package/dist/overlay-TZ6L52A5.js.map +1 -0
  192. package/dist/provider.d.ts +1 -1
  193. package/dist/provider.d.ts.map +1 -1
  194. package/dist/provider.js +6 -1968
  195. package/dist/provider.js.map +1 -1
  196. package/dist/result.js +2 -63
  197. package/dist/result.js.map +1 -1
  198. package/dist/theme.d.ts +45 -8
  199. package/dist/theme.d.ts.map +1 -1
  200. package/dist/theme.js +3 -41
  201. package/dist/theme.js.map +1 -1
  202. package/dist/tokens/index.d.ts +1 -0
  203. package/dist/tokens/index.d.ts.map +1 -1
  204. package/dist/tokens/index.js +126 -155
  205. package/dist/tokens/index.js.map +1 -1
  206. package/dist/tokens/motion-config.d.ts +44 -0
  207. package/dist/tokens/motion-config.d.ts.map +1 -0
  208. package/dist/tokens/motion.d.ts +23 -174
  209. package/dist/tokens/motion.d.ts.map +1 -1
  210. package/dist/utils/index.js +4 -82
  211. package/dist/utils/index.js.map +1 -1
  212. package/dist/utils/report.d.ts.map +1 -1
  213. package/docs/README.md +1 -0
  214. package/docs/architecture-and-rules.md +4 -3
  215. package/docs/core-engine.md +7 -10
  216. package/docs/getting-started.md +4 -2
  217. package/docs/modules/ambient.md +194 -80
  218. package/docs/modules/background.md +3 -3
  219. package/docs/modules/context-menu.md +2 -2
  220. package/docs/modules/dock.md +9 -6
  221. package/docs/modules/modal.md +2 -2
  222. package/docs/modules/notification.md +2 -2
  223. package/docs/theming.md +81 -9
  224. package/package.json +22 -5
  225. package/templates/AGENTS.md +1 -1
  226. package/templates/motion/background.ts +17 -0
  227. package/templates/motion/context-menu.ts +63 -0
  228. package/templates/motion/dock.ts +327 -0
  229. package/templates/motion/modal.ts +79 -0
  230. package/templates/motion/notification.ts +27 -0
  231. package/templates/motion/tokens.ts +47 -0
  232. package/templates/tsconfig.json +7 -0
  233. package/dist/modules/ambient/color.d.ts +0 -21
  234. package/dist/modules/ambient/color.d.ts.map +0 -1
@@ -1,30 +1,41 @@
1
1
  # Ambient — `@omerdlw/base-framework/modules/ambient`
2
2
 
3
- > **Read this first if you need to:** tint a page from an image (cover, avatar, album art), set theme colors from code, scope a tint to one element, or know which CSS variables change.
3
+ > **Read this first if you need to:** tint a page from an image (cover, avatar, album art), compute a palette on the server and hand it to the client, use more than one colour from an image (roles, tonal ramps, region glows, text colours), set theme colors from code, scope a tint to one element, or know which CSS variables change.
4
4
 
5
5
  ## 1. At a glance
6
6
 
7
- | | |
8
- | :----------------- | :----------------------------------------------------------------------------- |
9
- | **Import** | `import { useAmbientTheme, defineAmbient, … } from "@omerdlw/base-framework/modules/ambient"` |
10
- | **Module id** | `ambient` (`ambientModule`, in `defaultModules`) |
11
- | **Renders** | Nothing. It writes **CSS custom properties** on an element (default `<html>`). |
12
- | **Registry type** | none (no `registry` definition; it only uses the page hook) |
13
- | **Page slice** | `usePage({ ambient })` and the dock banner → `page.modules.ambient.set(...)` |
14
- | **Peers (`uses`)** | `background` (reads `posterUrl` through `useModuleState`) |
15
- | **Theme** | `ambientTheme` → one slot, `transition` (`src/config/ambient.module.theme.ts`) |
16
- | **HTTP route** | `GET /api/ambient/proxy?url=…` (CORS-safe image proxy, used as a fallback) |
17
- | **Tests** | `tests/modules/ambient.test.ts` |
7
+ | | |
8
+ | :----------------- | :------------------------------------------------------------------------------------------------ |
9
+ | **Import** | `import { useAmbientTheme, configureAmbient, … } from "@omerdlw/base-framework/modules/ambient"` |
10
+ | **Server entry** | `import { extractAmbientPalette } from "@omerdlw/base-framework/ambient/server"` (Node; no React) |
11
+ | **Worker entry** | `import { serveAmbientWorker } from "@omerdlw/base-framework/ambient/worker"` |
12
+ | **Module id** | `ambient` (`ambientModule`, in `defaultModules`) |
13
+ | **Renders** | Nothing. It writes **CSS custom properties** on an element (default `<html>`). |
14
+ | **Registry type** | none (no `registry` definition; it only uses the page hook) |
15
+ | **Page slice** | `usePage({ ambient })` and the dock banner → `page.modules.ambient.set(...)` |
16
+ | **Peers (`uses`)** | `background` (reads `posterUrl` through `useModuleState`) |
17
+ | **Theme** | `ambientTheme` → one slot, `transition` |
18
+ | **HTTP route** | optional proxy, `/api/ambient/proxy?url=…` by default (`configureAmbient({ proxy })`) |
19
+ | **Tests** | `tests/modules/ambient.test.ts`, `tests/modules/ambient-palette.test.ts` |
18
20
 
19
21
  ## 2. Mental model
20
22
 
21
23
  ```
22
- image URL ──fetch (CORS) ─┬─▶ 32×32 canvas ─▶ OKLCH sampling ─▶ { primary, black } palette
23
- └─ fallback: /api/ambient/proxy
24
- palette ─▶ CSS variables on the target element ─▶ every rule that reads them re-tints
24
+ server (RSC) image URL ─fetch─▶ sharp ─▶ 128px sample ─┐
25
+ client image URL ─fetch─▶ createImageBitmap ─────┤ (worker or main thread)
26
+ ▼
27
+ letterbox cut ─▶ OKLab k-means (coloured + grey apart) ─▶ clusters
28
+ ▼
29
+ roles: dominant · vibrant · muted · accent (each: colour, `on`, 11-tone ramp)
30
+ regions: top · bottom · left · right · center
31
+ shadow: the deep tone of the image's shadows
32
+ ▼
33
+ CSS variables on the target element ─▶ everything that reads them re-tints
25
34
  ```
26
35
 
27
- Ambient is a **colour pipeline**, not a component: pick an image (or pass colors directly), and the variables the design system already reads (`--primary`, `--black`, …) change. Because they are plain CSS variables, Tailwind classes such as `bg-primary` / `text-primary` and anything using `var(--black)` follow automatically, with a CSS color transition.
36
+ Ambient is a **colour pipeline**, not a component: pick an image (or pass colors directly), and the `--ambient-*` variables change. The app maps its own tokens onto them, e.g. `:root { --primary: var(--ambient-vibrant, #101010); --black: var(--ambient-shadow, #0b0b0b) }`; Tailwind classes such as `bg-primary` and anything using `var(--black)` then follow automatically, and the fallbacks apply whenever no palette is set.
37
+
38
+ A palette is plain JSON (~2.7 KB), so it can be computed once on the server, cached, and passed down as `initialPalette`. When its `source` is the same image URL, the client does not extract again. Client extraction is the fallback.
28
39
 
29
40
  ## 3. Quick start
30
41
 
@@ -42,24 +53,73 @@ useAmbientTheme({ image: backdropUrl || account?.avatarUrl || null });
42
53
  // 4. Explicit colors, scoped to one element
43
54
  const cardRef = useRef<HTMLDivElement>(null);
44
55
  useAmbientTheme({
45
- colors: { primary: "#e5d3ff", black: "#140d1f" },
56
+ colors: { "--ambient-vibrant": "#e5d3ff", "--ambient-shadow": "#140d1f" },
46
57
  scope: cardRef,
47
58
  });
48
59
  ```
49
60
 
61
+ ### 3.1 Server palette → client (recommended)
62
+
63
+ ```tsx
64
+ // app/movie/[id]/page.tsx (server component; `"use cache"` needs `cacheComponents: true`,
65
+ // otherwise wrap the call in `unstable_cache`)
66
+ import { cacheLife } from "next/cache";
67
+ import { extractAmbientPalette } from "@omerdlw/base-framework/ambient/server";
68
+
69
+ async function paletteOf(url: string) {
70
+ "use cache";
71
+ cacheLife("max");
72
+ return extractAmbientPalette(url, {
73
+ allow: (target) => target.hostname === "image.tmdb.org",
74
+ });
75
+ }
76
+
77
+ export default async function Page({ params }) {
78
+ const movie = await getMovie((await params).id);
79
+ const backdrop = `https://image.tmdb.org/t/p/w780${movie.backdrop_path}`;
80
+ const palette = await paletteOf(backdrop);
81
+ return <MovieChrome backdrop={backdrop} palette={palette} />;
82
+ }
83
+
84
+ // movie-chrome.tsx ("use client")
85
+ useAmbientTheme({ image: backdrop, initialPalette: palette ?? undefined });
86
+ // palette.source === backdrop → applied at once, no fetch, no flash.
87
+ ```
88
+
89
+ Use the **same URL** on both sides (the palette is keyed by `source`). A palette for a different size of the same image works as `initialPalette` too, but the client then extracts its own.
90
+
91
+ ### 3.2 Worker (optional)
92
+
93
+ ```ts
94
+ // app/ambient.worker.ts
95
+ import { serveAmbientWorker } from "@omerdlw/base-framework/ambient/worker";
96
+ serveAmbientWorker();
97
+
98
+ // once, in a client module that runs at start-up
99
+ configureAmbient({
100
+ worker: () =>
101
+ new Worker(new URL("./ambient.worker.ts", import.meta.url), { type: "module" }),
102
+ });
103
+ ```
104
+
105
+ The app builds the `Worker` itself because only its bundler can turn `new URL("./x.ts", import.meta.url)` into a worker chunk. Without a worker, decoding still happens off the main thread (`createImageBitmap`); only drawing a 128 px bitmap and the analysis (a few ms) run on it.
106
+
50
107
  ## 4. What gets written
51
108
 
52
- | Variable | Set when | Value |
53
- | :----------------------------- | :------------------------------------------- | :----------------------------------------------------------- |
54
- | `--primary` | an `image` is given and extracted | bright OKLCH colour; lightness 0.60–0.86 and chroma 0–0.24 follow the image (neutral images give a neutral, chroma 0) |
55
- | `--color-ambient-glow` | same | same as `--primary` |
56
- | `--color-primary` | same **and** `tintGlobals` is true (default) | same as `--primary` |
57
- | `--black` | an `image` is given and extracted | deep OKLCH (lightness ≈ 0.15, chroma ≤ 0.055, tint scales with the image; 0 for neutral images) |
58
- | any variable named in `colors` | `colors` is set | the literal value |
109
+ Default names; see §5.2 to rename them or turn groups off. Nothing is written until a palette exists.
59
110
 
60
- `colors` keys are mapped as: `primary` → `--primary`, `black` → `--black`, `white` → `--white`, `ambientGlow` → `--color-ambient-glow`, `colorPrimary` → `--color-primary`; a key starting with `--` is used verbatim; anything else becomes `--color-<key>`. `colors` always wins over extracted values.
111
+ | Variable | Value |
112
+ | :----------------------------------------- | :--------------------------------------------------------------------- |
113
+ | `--ambient-shadow` | deep tone (L ≈ 0.16) of the shadows' hue, at the chroma sRGB allows there: navy, burgundy, forest… (chroma 0 for grey images) |
114
+ | `--ambient-{role}` | the role's colour; roles: `dominant`, `vibrant`, `muted`, `accent` |
115
+ | `--ambient-on-{role}` | text colour on that role, ≥ 4.5:1 (WCAG 2) by default |
116
+ | `--ambient-{role}-{tone}` | ramp tones `10 15 20 30 40 50 60 70 80 90 95` (= OKLCH L × 100) |
117
+ | `--ambient-{region}` | regions: `top`, `bottom`, `left`, `right`, `center` |
118
+ | any variable named in `colors` | the literal value |
61
119
 
62
- On cleanup the previous inline values (and priorities) are restored exactly. If `transition` is true (default) the theme's `transition` classes (`transition-colors duration-moderate ease-out-quart`) are added to the target while the theme is active (and removed again only for scoped targets, so the global tint keeps animating between pages).
120
+ `colors` keys are custom property names: a key starting with `--` is used verbatim; anything else becomes `--color-<key>`. `colors` always wins over extracted values.
121
+
122
+ On cleanup the previous inline values (and priorities) are restored exactly. If `transition` is true (default) the theme's `transition` classes are added to the target while the theme is active (and removed again only for scoped targets, so the global tint keeps animating between pages).
63
123
 
64
124
  ## 5. Public API
65
125
 
@@ -68,14 +128,19 @@ On cleanup the previous inline values (and priorities) are restored exactly. If
68
128
  | `ambientModule` | module | `defineModule` definition (provider + `usePage({ ambient })`). |
69
129
  | `AmbientProvider` | component | Holds the **current shared palette** and `isExtracting` in a store. Prop `initialPalette`. |
70
130
  | `useAmbientTheme(config)` | hook | **Main entry.** `config` is an image URL string or `AmbientConfig`. Extracts, applies CSS variables, updates the shared palette. Returns `{ palette, isExtracting }`. |
71
- | `useAmbientColor(image, options?)` | hook | Extract a palette **without** applying it. |
131
+ | `useAmbientColor(image, options?)` | hook | Extract a palette **without** applying it. Uses `options.initialPalette` as is when its `source` is this image. |
72
132
  | `useAmbient(configOrImage?)` | hook | The shared state `{ palette, isExtracting, setPalette, setIsExtracting }`; with an argument it also applies that theme and returns its palette. Throws outside `AmbientProvider`. |
73
133
  | `defineAmbient(def)` | builder | Reusable theme; `image` / `colors` may be functions of props. Returns `{ id, config, create(props?, overrides?), use(props?, overrides?) }`. |
74
- | `extractPaletteFromImage(src, options?)` | util | Async extraction with cache and in-flight de-duplication; resolves the fallback palette on any failure. |
75
- | `rgbToOklch(r, g, b)`, `oklchToString(l, c, h, alpha?)` | utils | Colour conversion helpers. |
134
+ | `configureAmbient(settings)` | config | Proxy, CSS variable names, strategy, worker, analysis defaults (§5.2). Returns a function that restores the previous settings. `getAmbientSettings()` reads them. |
135
+ | `extractPaletteFromImage(src, options?)` | util | Async client extraction with cache and in-flight de-duplication; resolves `null` on any failure. |
136
+ | `primeAmbientPalette(src, palette)`, `getCachedAmbientPalette(src)` | utils | Seed / read the client cache (e.g. with server palettes). |
137
+ | `analyzeAmbientPixels(pixels, options?)` | util | The pure analysis on RGBA pixels (`{ data, width, height }`). Same function on server, worker and client. |
138
+ | `findContentBounds(pixels)` | util | The letterbox / pillarbox cut used by the analysis. |
139
+ | `maxChroma(l, h)`, `toneOf(h, l, rel, cap?)`, `onColorFor(bg, min?)`, `contrastRatio(a, b)`, `formatOklch(lch)`, `srgbToOklab(r, g, b)` | utils | Gamut-aware OKLCH helpers. |
140
+ | `paletteVarMap(palette, names?)`, `resolveAmbientVarMap(…)`, `resolveCssVarNames(overrides?)` | utils | The variable maps the hooks write. |
76
141
  | `resolvePageAmbientTheme(slice, posterUrl)` | util | Maps the page's `ambient` + dock banner to a theme config. |
77
- | `AMBIENT_CSS_VARS`, `AMBIENT_DEFAULTS`, `COLOR_EXTRACT_CONFIG`, `ambientTheme`, `AmbientContext` | constants | |
78
- | Types | | `AmbientConfig`, `AmbientPalette`, `AmbientDescriptor`, `AmbientTarget`, `AmbientImageSource`, `AmbientPageConfig`, … |
142
+ | `AMBIENT_TONES`, `AMBIENT_ROLES`, `AMBIENT_REGIONS`, `DEFAULT_ANALYSIS`, `DEFAULT_CSS_VAR_NAMES`, `ambientTheme`, `AmbientContext` | constants | |
143
+ | Types | | `AmbientPalette`, `AmbientSwatch`, `AmbientRegionColor`, `AmbientConfig`, `AmbientSettings`, `AmbientAnalysisOptions`, `AmbientCssVarNames`, `AmbientStrategy`, … |
79
144
 
80
145
  ### 5.1 `AmbientConfig`
81
146
 
@@ -83,72 +148,121 @@ On cleanup the previous inline values (and priorities) are restored exactly. If
83
148
  | :--------------- | :---------------------------------- | :---------------------------------------------------------------------------------------------- |
84
149
  | `image` | `null` | URL string or `{ src }`. |
85
150
  | `colors` | `null` | Explicit variables (see §4). A config with only `colors` is active without extraction. |
151
+ | `cssVars` | `null` | Per-theme override of the variable names (same shape as `configureAmbient({ cssVars })`). |
86
152
  | `scope` | `null` → `document.documentElement` | CSS selector string, element, or `ref`. A selector that matches nothing applies nothing. |
87
- | `tintGlobals` | `true` | Also set `--color-primary`. Set `false` to tint only `--primary`/`--black`/glow inside a scope. |
88
153
  | `transition` | `true` | Add the theme's transition classes to the target. |
89
- | `initialPalette` | defaults | Palette shown before extraction finishes. |
90
- | `options` | `null` | `{ initialPalette, fallbackPalette }` (the fallback is used when extraction fails). |
91
-
92
- `AMBIENT_DEFAULTS` = `{ primary: "#101010", black: "#0b0b0b" }` is used until a palette exists.
154
+ | `initialPalette` | `null` | Palette shown before extraction finishes (and if it fails); used as is (no extraction) when `source` equals `image`. |
155
+ | `options` | `null` | `{ initialPalette, strategy, proxy, analysis }` for this theme. |
156
+
157
+ Until a palette exists the shared state's `palette` is `null` and no variable is written, so the app's own CSS fallbacks apply.
158
+
159
+ ### 5.2 `configureAmbient(settings)`
160
+
161
+ Settings live on `globalThis`, so every bundled copy of the module (one per package entry) shares them. Call it once at start-up, in a client module (and on the server too if you change `analysis`).
162
+
163
+ | Setting | Default | Meaning |
164
+ | :----------- | :---------------------------------------- | :------ |
165
+ | `proxy` | `"/api/ambient/proxy?url="` | Tried last, only for cross-origin images whose direct request (and its one cache-bypassing retry) failed. A string is a prefix the encoded URL is appended to, or a template with `{url}`; a function returns the URL; `null` turns it off. |
166
+ | `strategy` | `"auto"` | `"auto"`: the worker if one is configured, else the main thread. `"worker"`: worker only (no main-thread extraction). `"main"`: main thread only. `"none"`: never extract on the client (server palettes only). |
167
+ | `worker` | `null` | `() => Worker` running `serveAmbientWorker()` (§3.2). If it fails to start or errors, extraction falls back to the main thread (except under `"worker"`). |
168
+ | `cssVars` | `--ambient-shadow`, `--ambient-{role}`, `--ambient-on-{role}`, `--ambient-{role}-{tone}`, `--ambient-{region}`, all 11 tones | Rename any variable; `null` turns that variable or group off; `tones: [10, 20, 90]` or `false` limits the ramp variables. |
169
+ | `analysis` | `DEFAULT_ANALYSIS` | `sampleSize` 128, `clusters` 7, `centerWeight` 0.5, `letterbox` true, `minHueDistance` 40, `minContrast` 4.5, `shadowLightness` 0.16. |
170
+ | `cacheLimit` | `100` | Client palettes kept (oldest evicted). |
171
+
172
+ ### 5.3 `AmbientPalette`
173
+
174
+ ```ts
175
+ {
176
+ source?: string | null; // the image URL it was made from
177
+ neutral: boolean; // the image has (almost) no colour
178
+ shadow: string; // deep tone of the shadows (oklch())
179
+ roles: Record<"dominant" | "vibrant" | "muted" | "accent", {
180
+ color: string; on: string; // oklch() strings
181
+ l: number; c: number; h: number;
182
+ population: number; // share of the image (centre-weighted)
183
+ fromImage: boolean; // false: derived (e.g. no second hue → accent = vibrant)
184
+ ramp: Record<10 | 15 | … | 95, string>;
185
+ }>;
186
+ regions: Record<"top" | "bottom" | "left" | "right" | "center", {
187
+ color: string; l: number; c: number; h: number;
188
+ strength: number; // 0 grey … 1 fully coloured
189
+ }>;
190
+ }
191
+ ```
93
192
 
94
- ### 5.2 `usePage({ ambient })` resolution
193
+ ### 5.4 Server entry: `@omerdlw/base-framework/ambient/server`
95
194
 
96
- `resolvePageAmbientTheme` runs with the page's `ambient`, the dock **banner**, and the background's `posterUrl`:
195
+ | Export | Description |
196
+ | :----- | :---------- |
197
+ | `extractAmbientPalette(url, options?)` | Fetches and analyzes; returns the palette (with `source = url`) or `null`. In-process cache (500) and in-flight sharing; wrap it in `"use cache"` / `unstable_cache` to keep palettes across requests. |
198
+ | `extractAmbientPaletteFromBytes(bytes, options?)` | Same, from bytes you already have. |
199
+ | `sharpDecoder` | The default decoder: `sharp` (optional peer dependency, `^0.35.0`), loaded on first use with a bundler-ignored dynamic import; Next.js already installs it. |
200
+ | `isPublicImageUrl(url)` | The default `allow`: http(s), no credentials, no localhost / `.local` / `.internal` / single-label names, no private, loopback, link-local, CGNAT or reserved IP literals in any spelling (`127.1`, `0x7f000001`, `[::ffff:127.0.0.1]`, …). `isPrivateAddress(ip)` and `isPrivateHostname(name)` are exported too. |
201
+ | `AmbientDecoderMissingError` | Thrown (not swallowed) when `sharp` is missing and no `decode` was passed. |
202
+ | `analyzeAmbientPixels`, `configureAmbient`, colour helpers, types | Re-exported. |
97
203
 
98
- | `ambient` value | Result |
99
- | :-------------- | :--------------------------------------------------------------------------------------------------- |
100
- | `false` | No theme (also disables the banner tint). |
101
- | `true` | `image` = banner, else the background video poster. |
102
- | string | That image (`tintGlobals: true`). |
103
- | object | `{ image: ambient.image ?? banner ?? poster, ...ambient }`. |
104
- | not set | If the dock has a banner and the background has no poster → tint with the banner; otherwise nothing. |
204
+ Options: `allow(url)` (checked on every redirect hop), `resolve` (see below), `decode(bytes, size)`, `fetch`, `init` (e.g. `{ next: { revalidate } }`), `maxBytes` (12 MB), `timeoutMs` (8 s), `cache` (true), `analysis`.
105
205
 
106
- `page.modules.ambient` is just `{ set(ambient) }`.
206
+ Before every request, redirects included, the host is resolved (`resolve`, default `node:dns` lookup of all addresses) and the request is refused if **any** address is loopback, private, link-local, CGNAT, multicast or reserved, even when a custom `allow` let the URL through. `resolve: false` turns this off; only do that behind an `allow` list. The fetch itself resolves the name again, so a host that changes its answer in between (DNS rebinding) is only ruled out by an `allow` list of hosts you trust, e.g. `allow: (url) => url.hostname === "image.tmdb.org"`. Use one in production.
107
207
 
108
208
  ## 6. Extraction details
109
209
 
110
- - **Source loading.** Remote (`http…`) images are first fetched with `cors=1` and `mode: "cors"`, then through `/api/ambient/proxy?url=…`; the blob is read from an object URL (revoked afterwards). Same-origin or non-http sources are loaded directly (with `crossOrigin="anonymous"` when no object URL was needed).
111
- - **The proxy** only serves `image/*` (415 otherwise), blocks unsafe/internal URLs with `isSafeUrl` (including on every redirect, up to 5), and caches responses for a day. Do not point it at anything but images.
112
- - **Sampling.** The image is drawn to a 64×64 canvas. Pixels with alpha < 128 are skipped; near-black and near-white pixels (unreliable hue) are ignored, and pixels with chroma < 0.03 count as neutral. Remaining pixels vote into 36 hue bins with weight `chroma² × lightness-usability`, so vivid mid-tone pixels beat muddy or very dark/light ones. The best bin plus half-weighted neighbours gives hue, chroma and lightness (weighted). If coloured pixels are under 4% of the colourable ones, or there are none, the result is a **neutral** (`c 0`, lightness = image average); a small accent on a mostly neutral image is kept but subdued.
113
- - **Derivation.** A source chroma < `0.012` is treated as neutral (no hue is invented). Otherwise chroma is clamped to `≤ 0.28`. `primary` chroma is `0.24 × (1 − e^(−chroma × 9.4))` and lightness is `0.74 + (L − 0.6) × 0.5` clamped to `0.60–0.86`; `black` chroma is `min(0.055, chroma × 0.35)` and lightness is `0.15 + (L − 0.6) × 0.06` clamped to `0.12–0.18`.
114
- - **Cache.** Up to 100 palettes (`cacheLimit`, oldest evicted) and concurrent requests for the same URL share one promise.
115
- - **Stability.** `colors`, `options` and `initialPalette` are stabilized with `shallowEqual`, so passing fresh object literals each render does **not** re-apply the theme. (A test forbids `JSON.stringify` for this.)
116
- - **Failure.** Any failure resolves the fallback palette (`initialPalette` → `fallbackPalette` → defaults). It never throws.
210
+ - **Loading (client).** The image URL is fetched as is with `mode: "cors"`: no `?cors=1`, so the request the page already made is answered from the HTTP cache. If that is blocked and the image is cross-origin, it is fetched once more with `cache: "reload"`: some CDNs (TMDB's among them) send CORS headers only to requests that carry an `Origin` and do not `Vary: Origin`, so the copy a plain `<img>` cached has none. Only then is the proxy tried. Give such images `crossOrigin="anonymous"` (`next/image` takes it too) and the one download serves both the `<img>` and the palette, with no blocked request in the console. Decoding uses `createImageBitmap` (asking for a 128 px wide bitmap where supported) and an `OffscreenCanvas` (or a `<canvas>`), so a full-size image is never drawn on the main thread; browsers without `createImageBitmap` fall back to an `<img>`.
211
+ - **Sampling.** The image is scaled to 128 px on its long side, keeping its aspect ratio. Pixels with alpha < 128 are skipped. Uniformly near-black rows/columns at the edges (letterbox / pillarbox; mean L < 0.12, max L < 0.2, at most 22% per side) are cut. Each pixel's weight falls from 1.5 at the centre to 0.5 at the corners (`centerWeight` 0.5).
212
+ - **Clustering.** Pixels are binned by 15-bit RGB (OKLab averaged per bin). Coloured bins (chroma ≥ 0.022) and grey bins are clustered **apart** with weighted, deterministically seeded k-means in OKLab (ΔL counts 0.45×, so hue beats lightness; vivid bins pull up to ~2.5× harder), 5 + 2 clusters by default. Splitting them keeps the greys of a dark poster from using up every cluster and swallowing its one coloured detail.
213
+ - **Roles.** A hue's share is that of its family (clusters within 20°). `dominant` = max share × chroma; `vibrant` = max chroma² × share^0.3; `accent` = the best hue at least `minHueDistance` (40°) from `dominant`, or `vibrant` marked `fromImage: false` when the image has one hue; `muted` = the largest low-chroma (≤ 0.09) colour, or a quiet tone of the dominant hue. Hue trust fades below L 0.28 and above L 0.9.
214
+ - **Tones.** Every role colour and ramp tone is `toneOf(h, L, rel)`: `rel` (the source colour's share of the available chroma, lifted per role) × the most chroma sRGB allows at that L and hue (`maxChroma`, a binary search on the gamut edge, × 0.96). So a dark tone of blue stays blue, and nothing is ever out of gamut.
215
+ - **`shadow`.** The hue of the shadows (clusters at L ≤ 0.5, scored by share × relative chroma) or, without coloured shadows, of `dominant`; at L `shadowLightness` ± 0.02 and up to 0.1 chroma.
216
+ - **`on`.** A near-white or near-black carrying a hint of the role's hue, pushed toward pure white / black until it reaches `minContrast`.
217
+ - **Regions.** Each region (thirds of the content box; `center` is the middle half) takes its most chromatic-and-present cluster at L 0.42–0.72; `strength` is how coloured the region is.
218
+ - **Neutral images.** If coloured pixels make up under 3% (weighted), the palette is grey: `neutral: true`, `dominant`, `vibrant`, `muted`, `shadow` and regions have chroma 0. A vivid detail (≥ 0.6% of the image, chroma ≥ 0.06) still becomes `accent`, e.g. the red coat in *Schindler's List*.
219
+ - **Cost.** About 3–8 ms per image for the analysis (Node, warm); in Chromium, ~13 ms for a cached image end to end on the main thread, ~20 ms through the worker (off the main thread). ~2.7 KB of JSON per palette.
220
+ - **Cache.** Client: `cacheLimit` palettes (oldest evicted), keyed by URL (+ analysis overrides); concurrent requests share one promise. Server: 500 per process.
221
+ - **Stability.** `colors`, `cssVars`, `options` and `initialPalette` are stabilized with `shallowEqual`, so fresh object literals each render do **not** re-apply the theme (keep `cssVars.tones` a constant array). The same pixels always give the same palette.
222
+ - **Failure.** Client: `extractPaletteFromImage` resolves `null` and the hooks keep `initialPalette` (or nothing); it never throws. Server: `null`, except a missing decoder.
117
223
 
118
224
  ## 7. Recipes and gotchas
119
225
 
120
- | I want to… | Do this |
121
- | :------------------------------ | :--------------------------------------------------------------------- |
122
- | Tint a page from its hero image | `usePage({ ambient: heroUrl })` |
123
- | Tint only one section | `useAmbientTheme({ image, scope: "#section", tintGlobals: false })` |
124
- | Read the palette in JS | `const { palette } = useAmbientTheme(image)` or `useAmbient().palette` |
125
- | Disable tint on one page | `usePage({ ambient: false })` |
126
- | Brand colours without images | `useAmbientTheme({ colors: { primary: "#…" } })` |
127
-
128
- - The theme is global by default: two components calling `useAmbientTheme` without `scope` fight over `--primary`; the last effect wins.
129
- - Cross-origin images that block CORS **and** the proxy cannot be sampled; the fallback palette is used.
226
+ | I want to… | Do this |
227
+ | :----------------------------------- | :---------------------------------------------------------------------- |
228
+ | Tint a page from its hero image | `usePage({ ambient: heroUrl })` |
229
+ | No flash, no client work | Compute on the server (§3.1); add `configureAmbient({ strategy: "none" })` if every image has one |
230
+ | Glows that follow the picture | `radial-gradient(at 20% 0%, color-mix(in oklab, var(--ambient-top) 30%, transparent), transparent)` … |
231
+ | Text on a role colour | `background: var(--ambient-accent); color: var(--ambient-on-accent)` |
232
+ | A surface a few steps above the shadow | `var(--ambient-dominant-20)` / `-30` |
233
+ | Tint only one section | `useAmbientTheme({ image, scope: "#section" })` |
234
+ | Rename the variables | `configureAmbient({ cssVars: { shadow: "--bg", role: "--tint-{role}" } })` |
235
+ | Fewer variables on `<html>` | `configureAmbient({ cssVars: { tones: [20, 80], region: null } })` |
236
+ | Own proxy / no proxy | `configureAmbient({ proxy: "/img?u={url}" })` / `{ proxy: null }` |
237
+ | Disable tint on one page | `usePage({ ambient: false })` |
238
+
239
+ - The theme is global by default: two components calling `useAmbientTheme` without `scope` fight over the `--ambient-*` variables; the last effect wins.
240
+ - Cross-origin images that block CORS **and** the proxy cannot be sampled on the client; `initialPalette` (or the app's CSS fallbacks) stays. The server has no CORS limits.
130
241
  - `useAmbient()` without arguments only _reads_ the shared palette; it does not apply anything.
131
- - Components should read the variables (`bg-primary`, `var(--black)`) rather than the palette object, so they follow transitions for free.
242
+ - Components should read the variables rather than the palette object, so they follow transitions for free.
132
243
 
133
244
  ## 8. File map
134
245
 
135
- | File | Responsibility |
136
- | :------------- | :-------------------------------------------------------------------------------------------------- |
137
- | `index.ts` | Public barrel export. |
138
- | `types.ts` | Palette, config, descriptor, target/source, page contracts. |
139
- | `constants.ts` | Default palette constants and `ambientTheme` spec. |
140
- | `color.ts` | Pure OKLCH color math, linear sRGB conversions, string formatting, and palette derivation. |
141
- | `extractor.ts` | Canvas 2D pixel histogram sampling, CORS/proxy image fetch, and LRU palette cache. |
142
- | `dom.ts` | Target element resolution, CSS variable mapping (`AMBIENT_CSS_VARS`), and scoped style restoration. |
143
- | `hooks.ts` | React hooks: `useAmbientColor`, `useAmbientTheme`, `useAmbient`, `useStableObject`. |
144
- | `builder.ts` | Reusable theme factory builder (`defineAmbient`). |
145
- | `context.tsx` | Microkernel global `AmbientContext` and `AmbientProvider` store. |
146
- | `module.tsx` | `ambientModule` definition, `resolvePageAmbientTheme`, and kernel type augmentation. |
147
-
148
- No `view.tsx` or `motion.ts`: the module renders nothing, and transitions are CSS classes from the theme ([README](./README.md#42-why-some-optional-slots-are-absent)).
246
+ | File | Responsibility |
247
+ | :------------------- | :--------------------------------------------------------------------------------------------- |
248
+ | `index.ts` | Public barrel export (client entry). |
249
+ | `types.ts` | Palette, swatch, region, settings, config, descriptor, page contracts. |
250
+ | `settings.ts` | Defaults, `configureAmbient` (on `globalThis`), proxy resolution. Pure. |
251
+ | `oklab.ts` | OKLab/OKLCH math, gamut room (`maxChroma`), tones, WCAG contrast, `on` colours. Pure. |
252
+ | `quantize.ts` | Deterministic weighted k-means. Pure. |
253
+ | `analyze.ts` | Letterbox cut, binning, clustering, roles, ramps, regions, `shadow`. Pure. |
254
+ | `pipeline.ts` | fetch → `createImageBitmap` → canvas pixels → analysis; runs in a window or a worker. |
255
+ | `extractor.ts` | Client cache, strategy, worker dispatch with main-thread fallback. |
256
+ | `worker/index.ts` | `serveAmbientWorker` (the `ambient/worker` entry). |
257
+ | `server/index.ts` | `extractAmbientPalette`, sharp decoder, URL guard, server cache (the `ambient/server` entry). |
258
+ | `dom.ts` | Target resolution, CSS variable maps, scoped style restoration. |
259
+ | `hooks.ts` | `useAmbientColor`, `useAmbientTheme`, `useAmbient`, `useStableObject`. |
260
+ | `context.tsx` | `AmbientContext` and `AmbientProvider` store. |
261
+ | `module.tsx` | `ambientModule`, `resolvePageAmbientTheme`, `defineAmbient`, kernel type augmentation. |
149
262
 
150
263
  ## 9. Dependencies
151
264
 
152
- - **Uses:** `@omerdlw/base-framework/kernel` (`defineModule`, `useModuleState`), `@omerdlw/base-framework/hooks` (`useRequiredContext`, `useStore`), `@omerdlw/base-framework/utils` (`createStore`, `shallowEqual`), `@omerdlw/base-framework/theme`; `src/app/api/ambient/proxy` and `@/infrastructure/security/url-safety` (server side).
265
+ - **Uses:** `@omerdlw/base-framework/kernel` (`defineModule`, `useModuleState`), `@omerdlw/base-framework/hooks` (`useRequiredContext`, `useStore`), `@omerdlw/base-framework/utils` (`createStore`, `shallowEqual`), `@omerdlw/base-framework/theme`.
266
+ - **Server entry:** `sharp` `^0.35.0`, an **optional peer dependency** (Next.js already installs it), loaded on first use with a bundler-ignored dynamic import. Apps that never call the server entry, or pass their own `decode`, do not need it.
153
267
  - **Peer:** `background` (poster fallback), read through `useModuleState("background", …, null)`, so it works when background is not installed.
154
- - **Used by:** `src/core/provider.tsx`, `usePage({ ambient })`, and features such as the account layout (`useAmbientTheme`).
268
+ - **Used by:** `usePage({ ambient })`, and features such as the account layout (`useAmbientTheme`).
@@ -71,7 +71,7 @@ usePage({
71
71
  | Edge fades | `fadeEdges` (`number`, `"20"`, `{ left, right }`, `false`), `leftGradient` / `rightGradient` (strength 1–n). A custom `width` implies a 20 % fade unless `fadeEdges: false`. |
72
72
  | Film grain | `noiseStyle: { opacity, mixBlendMode }`. |
73
73
  | Layout | `width` (number px or CSS length) places a video frame left/right/center via `position`; `fit` / `videoOptions.fit` (`cover contain fill none scale-down`); `bg-*` class tokens in `className`/`videoClassName` map to object-position slots. |
74
- | Custom transition | `animation: { initial, animate, exit, transition, exitDurationFactor }` (defaults: opacity fade with `DURATION_TOKENS.SLOW`; exit factor 0.6). |
74
+ | Custom transition | `animation: { initial, animate, exit, transition, exitDurationFactor }`, field by field over the app's background motion (`motion: BackgroundMotion` in the theme; the starter fades opacity over 0.58 s, exit factor 0.6). It follows the app's tempo.|
75
75
  | Programmatic control | `useBackgroundActions()` / `page.modules.background`. |
76
76
  | Dock media controls | Automatic through the `media` module ([§8](#8-media-integration)). |
77
77
 
@@ -155,7 +155,7 @@ return <MediaSource src="/audio/city.mp3" follow={BACKGROUND_MEDIA_ID} />;
155
155
 
156
156
  ## 9. Theming
157
157
 
158
- All classes come from `backgroundTheme` (`src/config/background.module.theme.ts`): layer slots (`root`, `image`, `solid`, `gradient`, `edge`, `noise`), direct video slots (`video`, `videoFrame`) and YouTube player slots (`youtube*`). Variants are `data-*` modifiers on the slot (`data-side`, `data-position`, `data-width`, `data-visible`). `fit` and `bg-*` tokens are translated to inline `object-fit` / `object-position` by the module, not by theme slots. Stays in code: compositor hints (`will-change`), mask/gradient math (`generateBaseGradient`, `generateEdgeGradient`, `getEdgeFadeMask`), and motion (defaults derive from `DURATION_TOKENS` / `EASING_CURVES`, [Rule 4](../architecture-and-rules.md)). The module deliberately has **no presets** (a test bans `BACKGROUND_PRESETS`): pass a URL or a state object, or build your own with `defineBackground`.
158
+ All classes come from `backgroundTheme` (`src/config/background.module.theme.ts`): layer slots (`root`, `image`, `solid`, `gradient`, `edge`, `noise`), direct video slots (`video`, `videoFrame`) and YouTube player slots (`youtube*`). Variants are `data-*` modifiers on the slot (`data-side`, `data-position`, `data-width`, `data-visible`). `fit` and `bg-*` tokens are translated to inline `object-fit` / `object-position` by the module, not by theme slots. Motion is the app's: `motion: BackgroundMotion` in the theme ([theming §5](../theming.md#5-motion-your-app-owns-every-animation), starter in `templates/motion/background.ts`) gives the default `initial`, `animate`, `exit` and `transition`, plus `exitDurationFactor` for a page exit without its own transition. Stays in code: compositor hints (`will-change`) and mask/gradient math (`generateBaseGradient`, `generateEdgeGradient`, `getEdgeFadeMask`). The module deliberately has **no presets** (a test bans `BACKGROUND_PRESETS`): pass a URL or a state object, or build your own with `defineBackground`.
159
159
 
160
160
  ## 10. Recipes and gotchas
161
161
 
@@ -183,7 +183,7 @@ All classes come from `backgroundTheme` (`src/config/background.module.theme.ts`
183
183
  | `visual.ts` | Gradient generation, smoothstep edge-fade masks, and video styling/fit options. |
184
184
  | `playback.ts` | Direct HTML video DOM controls: play/pause, volume/mute, loop, and playbackRate sync. |
185
185
  | `overlay.tsx` | `BackgroundOverlay`, `NativeVideo`, and backdrop overlay layers (solid, noise, gradients). |
186
- | `motion.ts` | Motion animations, transition presets, and CSS duration/easing helpers. |
186
+ | `motion.ts` | `useBackgroundMotion`, how a page's `animation` merges over the app's motion, the exit fallback, and CSS duration/easing helpers.|
187
187
  | `context.tsx` | `BackgroundProvider`: store, actions, `media` module bridge, and `useBackground*` hooks. |
188
188
  | `module.tsx` | `backgroundModule` definition, `selectPageBackground`, `defineBackground`, and kernel type augmentation. |
189
189
  | `youtube/parse.ts` | YouTube URL/ID parsing, time params, stream/thumbnail URLs, and video element proxy. |
@@ -208,7 +208,7 @@ Important: the global capture listener runs **before** React's element handler.
208
208
 
209
209
  ## 6. Theming
210
210
 
211
- No classes or colors in the module, except the pointer-following `left/top/position: fixed`. Slots in `contextMenuTheme`: `backdrop`, `menu`, `header`, `headerIcon`, `headerText`, `headerEyebrow`, `headerTitle`, `headerDescription`, `separator`, `item`, `itemIcon`, `itemLabel`, `itemShortcut`, plus optional per-slot `styles` (the starter theme sets the backdrop/menu z-index there). A config's `classNames` (and an item's `className`) are appended on top of the theme. Active items get `data-active="true"`, handy for theme selectors. Motion (`menuPopVariants`, `menuContentVariants`, `menuItemVariants`, tap/spring) lives in `motion.ts`.
211
+ No classes or colors in the module, except the pointer-following `left/top/position: fixed`. Slots in `contextMenuTheme`: `backdrop`, `menu`, `header`, `headerIcon`, `headerText`, `headerEyebrow`, `headerTitle`, `headerDescription`, `separator`, `item`, `itemIcon`, `itemLabel`, `itemShortcut`, plus optional per-slot `styles` (the starter theme sets the backdrop/menu z-index there). A config's `classNames` (and an item's `className`) are appended on top of the theme. Active items get `data-active="true"`, handy for theme selectors. Motion comes from the theme too (`motion: ContextMenuMotion`, [theming §5](../theming.md#5-motion-your-app-owns-every-animation)): `pop` (the surface), `content` (the header), `item` (rows; `visible` may take the row index for a stagger), `itemTap` and `itemTransition`. Start from `templates/motion/context-menu.ts`.
212
212
 
213
213
  ## 7. Recipes and gotchas
214
214
 
@@ -238,7 +238,7 @@ No classes or colors in the module, except the pointer-following `left/top/posit
238
238
  | `resolver.ts` | Candidate normalization, path/target/route scoring, context building, `resolveContextMenu`, `prepareMenu`. |
239
239
  | `context.tsx` | `ContextMenuProvider` (store, `openMenu`, `closeMenu`, `bind`), `useContextMenu*` consumer hooks. |
240
240
  | `overlay.tsx` | `ContextMenuGlobal`, portalled renderer, content host, header, item components, and interaction listeners. |
241
- | `motion.ts` | Pop, content and item animation variants; tap and spring presets. |
241
+ | `motion.ts` | `useContextMenuMotion`. The values come from the app's context-menu theme. |
242
242
  | `module.tsx` | `contextMenuModule`, page API adapter, `defineContextMenu`, kernel type augmentation. |
243
243
 
244
244
  ## 9. Dependencies
@@ -220,7 +220,7 @@ Everything below is exported from `@omerdlw/base-framework/modules/dock`. Anythi
220
220
  | `DockCardHeader`, `DockCardBanner`, `DockIcon`, `DockTitle`, `DockDescription` | The same pieces the route card uses. |
221
221
  | `useDockActionClass()` | Returns `(options?) ⇒ className` for buttons that must look like dock actions (`variant`, `className`, `tone`). Needs the theme. |
222
222
  | `isValidBannerUrl(value)` | Banner URL guard (rejects unsafe/empty values). |
223
- | `DOCK_FADE_TRANSITION`, `dockFadeVariants`, `dockListItemVariants`, `textCrossfadeVariants` | The only motion presets surface content should use ([Rule 4](../architecture-and-rules.md)). |
223
+ | `useDockMotion()` | The app's dock motion (retimed by its tempo, or reduced), for surface content that should move like the dock: `useDockMotion().variants.fade`, `.transitions.text`, … |
224
224
  | `dockTheme` | The theme spec (for `defineTheme`). |
225
225
  | Types | `DockItem`, `DockPageConfig`, `DockPageApi`, `DockActions`, `DockState`, `DockActionDescriptor`, `SurfaceDescriptor` / `SurfaceEntry`, `SurfaceStep`, `SurfaceResult`, `SurfaceFlowDefinitionInput`, `DockHudDescriptor`, `DockOperation`, `DockGuardDefinition`, `BreadcrumbConfig`, … (`export type * from "./types"`). |
226
226
 
@@ -339,7 +339,7 @@ The dock itself closes surfaces with these reasons: `"dock"` (route change, prog
339
339
 
340
340
  Focus returns to the element that opened the surface after the last surface closes, except for `browser-back`, `dock`, `unmount`.
341
341
 
342
- Lifecycle phases (`DOCK_SURFACE_PHASE`): `idle → dismissing_action → expanding_body → open → closing_anticipation → collapsing_body → restoring_header → idle`. They drive animation and height locking and are exposed as `surfacePhase` in `DockState`.
342
+ Lifecycle phases (`DOCK_SURFACE_PHASE`): `idle → dismissing_action → expanding_body → open → closing_anticipation → collapsing_body → restoring_header → idle`. They drive animation and are exposed as `surfacePhase` in `DockState`. In `dismissing_action`, `collapsing_body` and `restoring_header` the card faces the page: it shows the page's header and action (the surface item carries the page's action as `restingAction`) and keeps the page's measured height, so the card resizes once, straight between the page's size and the surface's. Set `timings.restoreHeader` to `0` to go from `collapsing_body` straight to `idle`.
343
343
 
344
344
  ### 8.5 Steps (wizards)
345
345
 
@@ -409,12 +409,13 @@ Imperative equivalents on `DockActions`: `openSurfaceFlow(def, input)`, `restore
409
409
 
410
410
  ```tsx
411
411
  function InviteForm({ close }) {
412
+ const dockMotion = useDockMotion();
412
413
  return (
413
414
  <>
414
415
  <DockSurfaceHeaderButton icon="solar:question-circle-bold" onClick={openHelp} />
415
416
  <DockSurfaceExtension id="invite-tabs" align="center"><Tabs … /></DockSurfaceExtension>
416
417
  <DockSurfaceAction><Button onClick={() => close({ success: true })}>Send</Button></DockSurfaceAction>
417
- <motion.ul variants={dockFadeVariants} initial="hidden" animate="visible">…</motion.ul>
418
+ <motion.ul variants={dockMotion.variants.fade} initial="hidden" animate="visible">…</motion.ul>
418
419
  </>
419
420
  );
420
421
  }
@@ -663,7 +664,9 @@ Reserve space with `useDockHeight().padding` or `padding-bottom: var(--dock-h)`.
663
664
 
664
665
  The module contains **no design classes**. Every slot comes from `dockTheme` (`src/config/dock.module.theme.ts`, 101 slots, named after the component that renders them): `stack`, `backdrop`, `card*`, `banner*`, `header*`, `title*`, `description*`, `icon*` (incl. `iconBadge`, `iconOverlay*`), `command*`, `status*`, `surface*`, `control*` (incl. `controlHeaderButton`), `extension*`, `scrubber*`, `media*`, `breadcrumbs*`, `loading*`, `action*`. State variants are `data-*` modifiers on the slot itself (`data-placement`, `data-blur`, `data-shelf`, `data-anchored`, `data-ghost`, `data-fill`, `data-active`, `data-banner`, `data-multiline`, `data-interactive`, `data-text`); the header of `src/config/dock.module.theme.ts` lists them. `styles` can add inline CSS per slot (z-index, banner masks, stack size/radius).
665
666
 
666
- Stays in code: stack motion (offsets, scale, opacity), measured geometry, per-position z-index (`Z_INDEX.DOCK_CARD_STACK_BASE − position`), compositor hints, the skeleton pulse class. All motion presets derive from `@omerdlw/base-framework/tokens` ([Rule 4](../architecture-and-rules.md)). The theme is **required**: a missing theme throws `Theme "dock" is missing…`.
667
+ The theme also carries the dock's **motion** (`motion: DockMotion`, [theming §5](../theming.md#5-motion-your-app-owns-every-animation)): every transition and variant, the deck's elevation, compression, peek and card exit, the swipe-to-dismiss feel, the skeleton class, and `timings`, which hold how long each surface phase lasts (`dismissAction`, `anticipation`, `expandBody`, `collapseBody`, `restoreHeader`) plus the card staggers, in seconds. The surface state machine schedules its phases from those timings, so they follow the app's tempo with everything else. Start from `templates/motion/dock.ts`, the dock's original motion.
668
+
669
+ Stays in code: measured geometry and stack layout (card offsets and scales per position, the breadcrumb lift), per-position z-index (`Z_INDEX.DOCK_CARD_STACK_BASE − position`), compositor hints, swipe thresholds, and which of the app's transitions each state uses. The theme and its motion are **required**: a missing theme throws `Theme "dock" is missing…` and a theme without motion throws `Motion for theme "dock" is missing…`. `DockProvider` on its own (no dock theme) still runs; its surface phases advance immediately.
667
670
 
668
671
  Per-card overrides use `style` on the card (see [§6.1](#61-dockpageconfig-the-dock-key-of-usepage)). Custom buttons inside surfaces should use `useDockActionClass()` so they match.
669
672
 
@@ -687,7 +690,7 @@ Gotchas:
687
690
  - A flow `snapshot` must be a plain object; anything else becomes `null`.
688
691
  - Status events are global: remember to emit `DOCK_STATUS_CLEAR` for sticky (`duration: 0`) statuses.
689
692
  - Guards registered by an unmounted page disappear with it; `clearDockGuards()` drops every guard, including other pages'.
690
- - No arbitrary durations or `z-*` classes in surface content: use the exported motion presets and `Z_INDEX` ([Rules 4 and 5](../architecture-and-rules.md)).
693
+ - No arbitrary durations or `z-*` classes in surface content: use your app's motion (`useDockMotion()`, or your own tokens) and `Z_INDEX` ([Rules 4 and 5](../architecture-and-rules.md)).
691
694
 
692
695
  ## 19. File map
693
696
 
@@ -711,7 +714,7 @@ Gotchas:
711
714
  | `overlay.tsx` | `Dock` and every rendered piece: cards, header, banner, command bar, surface shell/controls/extensions, HUD view, media controls and scrubber, breadcrumbs card, `ErrorActions`, `GuardActions`. |
712
715
  | `cards.tsx` | View models for the card components (banner, header, command bar, item, media controls/scrubber), `applyMediaAction`, action-slot resolution. |
713
716
  | `hud.ts` | HUD descriptors, registry hook, `defineHud`, `useHud`, `useDockHud`, HUD lifecycle and view model, operation HUD. |
714
- | `motion.ts` | Dock durations, springs, transitions, variants and factories, all derived from `@omerdlw/base-framework/tokens`. |
717
+ | `motion.ts` | `useDockMotion`, `useDockSurfaceTimings`, and helpers that pick from the app's dock motion by state (stack, cards, media, backdrop). No motion values. |
715
718
  | `runtime/dock.tsx` | `useDock` (view controller), `useDockModel`, layout ordering, expansion, operation state, route bridge. |
716
719
  | `runtime/display.ts` | Item pipeline: registry entries → filtered items → active item (applies surface / status / media). |
717
720
  | `runtime/pipeline.ts` | Item identity, active item detection, ancestor deduplication, loading item cleanup, and stacking reorder. |
@@ -191,7 +191,7 @@ No classes or colors in the module except the two z-indexes. Slots in `modalThem
191
191
 
192
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
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)).
194
+ Motion comes from the theme too (`motion: ModalMotion`, [theming §5](../theming.md#5-motion-your-app-owns-every-animation)): the `backdrop`, and the `content`, `header` and `footer` rows of a `ModalContainer`, plus `positions`, the dialog's variants per position (`center`, `top`, `right`, `bottom`, `left`) with an optional own `transition` (the starter gives `center` a spring). A theme without motion throws `Motion for theme "modal" is missing…`. Start from `templates/motion/modal.ts`. Use `Z_INDEX` for anything layered above or below a modal ([Rule 5](../architecture-and-rules.md)).
195
195
 
196
196
  ## 8. Recipes and gotchas
197
197
 
@@ -223,7 +223,7 @@ Motion (`motion.ts`: backdrop, per-position panel variants/transitions, header/b
223
223
  | `context.tsx` | `ModalProvider` (stack, pending promises, actions), `useModal*` hooks, registry lookup. |
224
224
  | `hooks.ts` | View models: `useModalModel` (visibility, viewport, scroll lock), `useModalLayerModel` (focus, Esc), `useModalContainerModel`. |
225
225
  | `overlay.tsx` | `Modal`, `ModalLayer`, `ModalContainer`, switcher, close button. |
226
- | `motion.ts` | Backdrop, panel, header, content and footer motion. |
226
+ | `motion.ts` | `useModalMotion`, `getModalPositionMotion`, compositor hints. The values come from the app's modal theme. |
227
227
  | `module.tsx` | `modalModule`, lazy renderer, page API, `defineModal`, kernel type augmentation. |
228
228
 
229
229
  ## 10. Dependencies
@@ -116,7 +116,7 @@ String messages are trimmed and **trailing periods/whitespace are removed** (`"S
116
116
 
117
117
  ## 7. Theming
118
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`.
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. The toast's motion comes from the theme as well (`motion: NotificationMotion`, [theming §5](../theming.md#5-motion-your-app-owns-every-animation)): `toast` variants (`hidden` → `visible` → `exit`) and its `transition`. Start from `templates/motion/notification.ts`.
120
120
 
121
121
  ## 8. Writing toast copy
122
122
 
@@ -150,7 +150,7 @@ No classes or inline styles in the module. `notificationTheme` slots: `layer`, `
150
150
  | `context.tsx` | `NotificationProvider` (store, timers, show/dismiss actions), `NotificationContext`. |
151
151
  | `hooks.ts` | Public hooks (`useToast`, `useNotification*`), `NotificationListener`, container view model. |
152
152
  | `overlay.tsx` | `Toast`, `NotificationContainer` (dock/floating portal), `NotificationLayer`. |
153
- | `motion.ts` | Toast variants and transition. |
153
+ | `motion.ts` | `useNotificationMotion`, compositor hints. The values come from the app's notification theme.|
154
154
  | `module.tsx` | `notificationModule` and the page API. |
155
155
 
156
156
  `builder.ts` is absent: toasts are imperative ([README](./README.md#42-why-some-optional-slots-are-absent)).