@multiplatform.one/theme 7.34.4 → 7.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/package.json +20 -5
  2. package/src/audit/constraintAudit.spec.ts +274 -87
  3. package/src/audit/constraintAudit.ts +135 -120
  4. package/src/audit/constraintScope.spec.ts +1 -1
  5. package/src/audit/designTokens.ts +42 -0
  6. package/src/audit/themeMatrix.spec.ts +83 -2
  7. package/src/audit/themeMatrix.ts +33 -1
  8. package/src/figma/figmaTokens.spec.ts +26 -34
  9. package/src/figma/figmaTokens.ts +26 -50
  10. package/src/fonts/createFontLoader.ts +4 -1
  11. package/src/menuRow.ts +4 -1
  12. package/src/theme/FontKnobStyles.tsx +2 -1
  13. package/src/theme/Preset.stories.tsx +0 -1
  14. package/src/theme/Preset.tsx +6 -1
  15. package/src/theme/PresetContext.ts +4 -0
  16. package/src/theme/Surface.spec.tsx +114 -67
  17. package/src/theme/Surface.stories.tsx +8 -25
  18. package/src/theme/Surface.tsx +87 -43
  19. package/src/theme/accentFloor.spec.ts +37 -11
  20. package/src/theme/activateThemeConfig.ts +22 -6
  21. package/src/theme/animations/animations.spec.ts +62 -0
  22. package/src/theme/animations/css.ts +9 -35
  23. package/src/theme/animations/reactNative.ts +98 -25
  24. package/src/theme/boundaryFloor.spec.ts +57 -0
  25. package/src/theme/chartPalette.spec.ts +56 -27
  26. package/src/theme/chartPalette.ts +33 -28
  27. package/src/theme/colorRules.spec.ts +32 -72
  28. package/src/theme/cookies.spec.ts +49 -18
  29. package/src/theme/cookies.ts +36 -7
  30. package/src/theme/cookiesPersistence.spec.ts +5 -5
  31. package/src/theme/createDefaultThemeConfig.spec.ts +182 -20
  32. package/src/theme/createDefaultThemeConfig.ts +34 -28
  33. package/src/theme/createThemes.ts +169 -218
  34. package/src/theme/defaults/accent.ts +3 -35
  35. package/src/theme/defaults/base.ts +14 -53
  36. package/src/theme/defaults/builderOptions.ts +68 -85
  37. package/src/theme/defaults/categoryFonts.ts +10 -0
  38. package/src/theme/defaults/fonts.spec.ts +84 -4
  39. package/src/theme/defaults/fonts.ts +166 -124
  40. package/src/theme/defaults/house.ts +48 -0
  41. package/src/theme/defaults/index.ts +1 -0
  42. package/src/theme/defaults/neutralRamp.spec.ts +52 -47
  43. package/src/theme/devtools/ColorLineVisualizer.tsx +6 -6
  44. package/src/theme/devtools/ThemeDevtoolsPanel.setMarkers.spec.tsx +13 -6
  45. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +14 -77
  46. package/src/theme/devtools/animationOptions.spec.ts +23 -37
  47. package/src/theme/devtools/animationOptions.ts +4 -6
  48. package/src/theme/devtools/buildSnapshot.ts +0 -3
  49. package/src/theme/devtools/knobIcons.tsx +4 -24
  50. package/src/theme/focusState.spec.ts +9 -30
  51. package/src/theme/index.ts +17 -0
  52. package/src/theme/intentFill.spec.ts +42 -0
  53. package/src/theme/knobs.ts +14 -42
  54. package/src/theme/layoutTokens.ts +15 -1
  55. package/src/theme/layoutTokensHooks.spec.tsx +8 -4
  56. package/src/theme/mergedTokens.ts +20 -0
  57. package/src/theme/motion.spec.ts +84 -0
  58. package/src/theme/motion.ts +53 -0
  59. package/src/theme/motionStops.ts +17 -0
  60. package/src/theme/motionTiming.ts +59 -0
  61. package/src/theme/mpo13Proof.spec.ts +43 -46
  62. package/src/theme/mpo19Proof.spec.ts +13 -14
  63. package/src/theme/namedInkFloor.spec.ts +60 -0
  64. package/src/theme/overlaySurfaces.tsx +330 -0
  65. package/src/theme/preset.types.ts +16 -0
  66. package/src/theme/presetPreview.ts +32 -0
  67. package/src/theme/presetRoot.spec.tsx +89 -0
  68. package/src/theme/presetStore.persistence.spec.ts +141 -0
  69. package/src/theme/presetStore.spec.ts +205 -0
  70. package/src/theme/presetStore.ts +286 -0
  71. package/src/theme/presetUserSync.spec.tsx +168 -0
  72. package/src/theme/presetUserSync.ts +47 -0
  73. package/src/theme/presets.spec.ts +22 -15
  74. package/src/theme/presets.ts +22 -10
  75. package/src/theme/radiusClass.spec.ts +60 -41
  76. package/src/theme/radiusClass.ts +17 -4
  77. package/src/theme/radiusClassTable.generated.ts +3 -3
  78. package/src/theme/readableColor.spec.ts +59 -152
  79. package/src/theme/readableColor.ts +34 -174
  80. package/src/theme/recipeInputs.ts +153 -295
  81. package/src/theme/recipes.ts +58 -18
  82. package/src/theme/resolveKnobs.spec.ts +528 -588
  83. package/src/theme/resolveKnobs.ts +250 -325
  84. package/src/theme/scale/ScaleStyles.native.tsx +19 -0
  85. package/src/theme/scale/ScaleStyles.tsx +48 -0
  86. package/src/theme/scale/activeScale.ts +30 -0
  87. package/src/theme/scale/deriveRecipeInputs.ts +110 -0
  88. package/src/theme/scale/generateScale.spec.ts +260 -0
  89. package/src/theme/scale/generateScale.ts +147 -0
  90. package/src/theme/scale/index.ts +54 -0
  91. package/src/theme/scale/installScaleConfig.native.spec.ts +77 -0
  92. package/src/theme/scale/installScaleConfig.native.ts +57 -0
  93. package/src/theme/scale/installScaleConfig.ts +7 -0
  94. package/src/theme/scale/scaleAttribute.ts +21 -0
  95. package/src/theme/scale/scaleCss.probe.spec.tsx +64 -0
  96. package/src/theme/scale/scaleCss.spec.ts +132 -0
  97. package/src/theme/scale/scaleCss.ts +95 -0
  98. package/src/theme/scale/scaleFonts.ts +106 -0
  99. package/src/theme/scale/scaleInputs.ts +322 -0
  100. package/src/theme/scale/themeScales.ts +181 -0
  101. package/src/theme/scale/useActiveScale.spec.tsx +129 -0
  102. package/src/theme/scale/useActiveScale.ts +95 -0
  103. package/src/theme/schemeStore.spec.ts +81 -0
  104. package/src/theme/schemeStore.ts +67 -0
  105. package/src/theme/shared.spec.ts +7 -6
  106. package/src/theme/shared.tsx +37 -3
  107. package/src/theme/sizeLadder.spec.ts +37 -36
  108. package/src/theme/sizeRecipes.spec.ts +179 -85
  109. package/src/theme/sizeRecipes.ts +48 -25
  110. package/src/theme/subsetThemes.spec.ts +9 -3
  111. package/src/theme/switchThumb.spec.ts +69 -0
  112. package/src/theme/syntaxFloor.spec.ts +11 -8
  113. package/src/theme/theme.hydration.native.spec.tsx +51 -0
  114. package/src/theme/theme.native.tsx +59 -21
  115. package/src/theme/theme.palette.spec.tsx +76 -0
  116. package/src/theme/theme.preset.native.spec.tsx +73 -0
  117. package/src/theme/theme.provider.spec.ts +143 -1
  118. package/src/theme/theme.tsx +61 -25
  119. package/src/theme/tileSizes.ts +6 -7
  120. package/src/theme/transitionProps.native.ts +25 -2
  121. package/src/theme/transitionProps.spec.ts +34 -0
  122. package/src/theme/transitionProps.ts +26 -2
  123. package/src/theme/usePresetSwitch.native.spec.tsx +68 -0
  124. package/src/theme/usePresetSwitch.native.ts +32 -0
  125. package/src/theme/usePresetSwitch.spec.tsx +103 -0
  126. package/src/theme/usePresetSwitch.ts +54 -0
  127. package/src/theme/useResolvedKnobs.hydration.spec.tsx +2 -2
  128. package/src/theme/useResolvedKnobs.ts +23 -90
  129. package/src/theme/useResolvedKnobsBehavior.spec.tsx +25 -30
  130. package/src/theme/useSchemePreference.native.ts +33 -0
  131. package/src/theme/useSchemePreference.spec.tsx +51 -0
  132. package/src/theme/useSchemePreference.ts +32 -0
  133. package/src/theme/useSchemePreference.web.spec.tsx +32 -0
  134. package/src/theme/useTheme.native.spec.tsx +68 -0
  135. package/src/theme/useTheme.native.ts +49 -24
  136. package/src/theme/useTheme.ts +7 -4
  137. package/types/audit/constraintAudit.d.ts +6 -5
  138. package/types/audit/constraintAudit.d.ts.map +1 -1
  139. package/types/audit/designTokens.d.ts +13 -0
  140. package/types/audit/designTokens.d.ts.map +1 -0
  141. package/types/audit/themeMatrix.d.ts +10 -0
  142. package/types/audit/themeMatrix.d.ts.map +1 -1
  143. package/types/figma/figmaTokens.d.ts +11 -28
  144. package/types/figma/figmaTokens.d.ts.map +1 -1
  145. package/types/fonts/createFontLoader.d.ts.map +1 -1
  146. package/types/menuRow.d.ts +4 -1
  147. package/types/menuRow.d.ts.map +1 -1
  148. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  149. package/types/theme/Preset.d.ts.map +1 -1
  150. package/types/theme/PresetContext.d.ts +4 -0
  151. package/types/theme/PresetContext.d.ts.map +1 -1
  152. package/types/theme/Surface.d.ts +57 -26
  153. package/types/theme/Surface.d.ts.map +1 -1
  154. package/types/theme/activateThemeConfig.d.ts +5 -3
  155. package/types/theme/activateThemeConfig.d.ts.map +1 -1
  156. package/types/theme/animations/css.d.ts +27 -34
  157. package/types/theme/animations/css.d.ts.map +1 -1
  158. package/types/theme/animations/reactNative.d.ts +196 -45
  159. package/types/theme/animations/reactNative.d.ts.map +1 -1
  160. package/types/theme/chartPalette.d.ts +11 -3
  161. package/types/theme/chartPalette.d.ts.map +1 -1
  162. package/types/theme/colorRules.d.ts +1 -1
  163. package/types/theme/cookies.d.ts +12 -1
  164. package/types/theme/cookies.d.ts.map +1 -1
  165. package/types/theme/createDefaultThemeConfig.d.ts +13 -13
  166. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  167. package/types/theme/createThemes.d.ts +58 -2
  168. package/types/theme/createThemes.d.ts.map +1 -1
  169. package/types/theme/defaults/accent.d.ts.map +1 -1
  170. package/types/theme/defaults/base.d.ts.map +1 -1
  171. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  172. package/types/theme/defaults/categoryFonts.d.ts +10 -0
  173. package/types/theme/defaults/categoryFonts.d.ts.map +1 -1
  174. package/types/theme/defaults/fonts.d.ts +330 -30
  175. package/types/theme/defaults/fonts.d.ts.map +1 -1
  176. package/types/theme/defaults/house.d.ts +8 -0
  177. package/types/theme/defaults/house.d.ts.map +1 -0
  178. package/types/theme/defaults/index.d.ts +1 -0
  179. package/types/theme/defaults/index.d.ts.map +1 -1
  180. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +3 -3
  181. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  182. package/types/theme/devtools/animationOptions.d.ts.map +1 -1
  183. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -1
  184. package/types/theme/devtools/knobIcons.d.ts +0 -3
  185. package/types/theme/devtools/knobIcons.d.ts.map +1 -1
  186. package/types/theme/index.d.ts +11 -0
  187. package/types/theme/index.d.ts.map +1 -1
  188. package/types/theme/knobs.d.ts +8 -36
  189. package/types/theme/knobs.d.ts.map +1 -1
  190. package/types/theme/layoutTokens.d.ts +13 -1
  191. package/types/theme/layoutTokens.d.ts.map +1 -1
  192. package/types/theme/mergedTokens.d.ts +11 -0
  193. package/types/theme/mergedTokens.d.ts.map +1 -0
  194. package/types/theme/motion.d.ts +17 -0
  195. package/types/theme/motion.d.ts.map +1 -0
  196. package/types/theme/motionStops.d.ts +3 -0
  197. package/types/theme/motionStops.d.ts.map +1 -0
  198. package/types/theme/motionTiming.d.ts +36 -0
  199. package/types/theme/motionTiming.d.ts.map +1 -0
  200. package/types/theme/overlaySurfaces.d.ts +70 -0
  201. package/types/theme/overlaySurfaces.d.ts.map +1 -0
  202. package/types/theme/preset.types.d.ts +16 -0
  203. package/types/theme/preset.types.d.ts.map +1 -1
  204. package/types/theme/presetPreview.d.ts +14 -0
  205. package/types/theme/presetPreview.d.ts.map +1 -0
  206. package/types/theme/presetStore.d.ts +40 -0
  207. package/types/theme/presetStore.d.ts.map +1 -0
  208. package/types/theme/presetUserSync.d.ts +7 -0
  209. package/types/theme/presetUserSync.d.ts.map +1 -0
  210. package/types/theme/presets.d.ts +12 -4
  211. package/types/theme/presets.d.ts.map +1 -1
  212. package/types/theme/radiusClass.d.ts +5 -2
  213. package/types/theme/radiusClass.d.ts.map +1 -1
  214. package/types/theme/radiusClassTable.generated.d.ts +3 -3
  215. package/types/theme/readableColor.d.ts +11 -49
  216. package/types/theme/readableColor.d.ts.map +1 -1
  217. package/types/theme/recipeInputs.d.ts +39 -70
  218. package/types/theme/recipeInputs.d.ts.map +1 -1
  219. package/types/theme/recipes.d.ts +79 -18
  220. package/types/theme/recipes.d.ts.map +1 -1
  221. package/types/theme/resolveKnobs.d.ts +64 -32
  222. package/types/theme/resolveKnobs.d.ts.map +1 -1
  223. package/types/theme/scale/ScaleStyles.d.ts +18 -0
  224. package/types/theme/scale/ScaleStyles.d.ts.map +1 -0
  225. package/types/theme/scale/ScaleStyles.native.d.ts +10 -0
  226. package/types/theme/scale/ScaleStyles.native.d.ts.map +1 -0
  227. package/types/theme/scale/activeScale.d.ts +13 -0
  228. package/types/theme/scale/activeScale.d.ts.map +1 -0
  229. package/types/theme/scale/deriveRecipeInputs.d.ts +10 -0
  230. package/types/theme/scale/deriveRecipeInputs.d.ts.map +1 -0
  231. package/types/theme/scale/generateScale.d.ts +38 -0
  232. package/types/theme/scale/generateScale.d.ts.map +1 -0
  233. package/types/theme/scale/index.d.ts +17 -0
  234. package/types/theme/scale/index.d.ts.map +1 -0
  235. package/types/theme/scale/installScaleConfig.d.ts +4 -0
  236. package/types/theme/scale/installScaleConfig.d.ts.map +1 -0
  237. package/types/theme/scale/installScaleConfig.native.d.ts +13 -0
  238. package/types/theme/scale/installScaleConfig.native.d.ts.map +1 -0
  239. package/types/theme/scale/scaleAttribute.d.ts +14 -0
  240. package/types/theme/scale/scaleAttribute.d.ts.map +1 -0
  241. package/types/theme/scale/scaleCss.d.ts +25 -0
  242. package/types/theme/scale/scaleCss.d.ts.map +1 -0
  243. package/types/theme/scale/scaleFonts.d.ts +16 -0
  244. package/types/theme/scale/scaleFonts.d.ts.map +1 -0
  245. package/types/theme/scale/scaleInputs.d.ts +66 -0
  246. package/types/theme/scale/scaleInputs.d.ts.map +1 -0
  247. package/types/theme/scale/themeScales.d.ts +52 -0
  248. package/types/theme/scale/themeScales.d.ts.map +1 -0
  249. package/types/theme/scale/useActiveScale.d.ts +31 -0
  250. package/types/theme/scale/useActiveScale.d.ts.map +1 -0
  251. package/types/theme/schemeStore.d.ts +6 -0
  252. package/types/theme/schemeStore.d.ts.map +1 -0
  253. package/types/theme/shared.d.ts +18 -0
  254. package/types/theme/shared.d.ts.map +1 -1
  255. package/types/theme/sizeRecipes.d.ts +20 -15
  256. package/types/theme/sizeRecipes.d.ts.map +1 -1
  257. package/types/theme/theme.d.ts +2 -1
  258. package/types/theme/theme.d.ts.map +1 -1
  259. package/types/theme/theme.native.d.ts +10 -1
  260. package/types/theme/theme.native.d.ts.map +1 -1
  261. package/types/theme/tileSizes.d.ts +6 -7
  262. package/types/theme/tileSizes.d.ts.map +1 -1
  263. package/types/theme/transitionProps.d.ts +3 -1
  264. package/types/theme/transitionProps.d.ts.map +1 -1
  265. package/types/theme/transitionProps.native.d.ts +3 -1
  266. package/types/theme/transitionProps.native.d.ts.map +1 -1
  267. package/types/theme/usePresetSwitch.d.ts +10 -0
  268. package/types/theme/usePresetSwitch.d.ts.map +1 -0
  269. package/types/theme/usePresetSwitch.native.d.ts +10 -0
  270. package/types/theme/usePresetSwitch.native.d.ts.map +1 -0
  271. package/types/theme/useResolvedKnobs.d.ts +13 -13
  272. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  273. package/types/theme/useSchemePreference.d.ts +8 -0
  274. package/types/theme/useSchemePreference.d.ts.map +1 -0
  275. package/types/theme/useSchemePreference.native.d.ts +8 -0
  276. package/types/theme/useSchemePreference.native.d.ts.map +1 -0
  277. package/types/theme/useTheme.d.ts +4 -2
  278. package/types/theme/useTheme.d.ts.map +1 -1
  279. package/types/theme/useTheme.native.d.ts +10 -1
  280. package/types/theme/useTheme.native.d.ts.map +1 -1
@@ -0,0 +1,330 @@
1
+ import { transitionProps } from "./transitionProps";
2
+ import { Surface } from "./Surface";
3
+ import { useResolvedKnobs } from "./useResolvedKnobs";
4
+ import type { ReactNode } from "react";
5
+ import { createContext, forwardRef, useContext, useEffect } from "react";
6
+ import {
7
+ AlertDialog,
8
+ Dialog,
9
+ isWeb,
10
+ Popover,
11
+ useDialogContext,
12
+ type DialogContentProps as TamaguiDialogContentProps,
13
+ type DialogOverlayProps as TamaguiDialogOverlayProps,
14
+ type SizeTokens,
15
+ type TamaguiElement,
16
+ type ViewProps,
17
+ } from "tamagui";
18
+
19
+ // ── Overlay frame focus (LC-71) ──────────────────────────────────────────
20
+ // Radix Dialog.Content / Primer Dialog are tabIndex=-1 and auto-focused on
21
+ // open. The frame is not a control: UA :focus-visible must not ring the
22
+ // panel. Inner controls ring on keyboard-origin focus.
23
+
24
+ const overlayFrameFocus = {
25
+ outlineWidth: 0,
26
+ outlineStyle: "none" as any,
27
+ };
28
+
29
+ const overlayFrameFocusStyle = {
30
+ outlineWidth: 0,
31
+ outlineStyle: "none" as any,
32
+ } as const;
33
+
34
+ // ── Overlay open focus (MPO-452, MPO-466) ────────────────────────────────
35
+ // Tamagui 2.7's FocusScope decides "focus is not inside yet", waits for
36
+ // idle (up to 200 ms), then focuses the first tabbable that does not
37
+ // already hold focus: a click into a field during that wait had focus
38
+ // moved to another control, so the typing that followed went there. The
39
+ // house handler checks and focuses in one step. With no tabbable yet,
40
+ // tamagui's deferred path still runs. Dialog content sits in a native
41
+ // <dialog> whose show() puts focus inside before the check in browsers,
42
+ // so there FocusScope never calls the handler; useDialogShowFocus does
43
+ // when show() left focus on something that is not a tab stop (MPO-470).
44
+
45
+ function isTabbableWithin(node: HTMLElement, container: HTMLElement): boolean {
46
+ if (node.tabIndex < 0 || node.hidden || node.tagName === "A") return false;
47
+ if ((node as HTMLInputElement).disabled) return false;
48
+ if (node instanceof HTMLInputElement && node.type === "hidden") return false;
49
+ if (getComputedStyle(node).visibility === "hidden") return false;
50
+ for (let el: HTMLElement | null = node; el && el !== container; el = el.parentElement) {
51
+ if (getComputedStyle(el).display === "none") return false;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ function focusFirstTabbableOnOpen(event: Event): void {
57
+ const container = event.currentTarget;
58
+ if (typeof HTMLElement === "undefined" || !(container instanceof HTMLElement)) return;
59
+ const first = Array.from(container.querySelectorAll<HTMLElement>("*")).find((node) =>
60
+ isTabbableWithin(node, container),
61
+ );
62
+ if (!first) return;
63
+ event.preventDefault();
64
+ first.focus({ preventScroll: true });
65
+ if (first instanceof HTMLInputElement) first.select();
66
+ }
67
+
68
+ type OpenAutoFocus = (event: Event) => void;
69
+
70
+ /** The caller's handler runs first; its `preventDefault()` skips the house open focus. */
71
+ function withHouseOpenFocus(onOpenAutoFocus?: OpenAutoFocus): OpenAutoFocus {
72
+ return (event) => {
73
+ onOpenAutoFocus?.(event);
74
+ if (!event.defaultPrevented) focusFirstTabbableOnOpen(event);
75
+ };
76
+ }
77
+
78
+ const DIALOG_SHOW_FOCUS_EVENT = "mp.dialogShowFocus";
79
+
80
+ /**
81
+ * Chromium and WebKit's `<dialog>.show()` focuses the first focusable
82
+ * descendant, so a tabIndex -1 frame (the house Input's group frame, a
83
+ * tabIndex -1 panel) takes focus; Firefox and the HTML spec take the first
84
+ * tab stop. When focus sits on something that is not a tab stop, run
85
+ * `onOpenAutoFocus` against the content the way FocusScope would. The
86
+ * content is found by its id because FocusScope clones it with its own ref,
87
+ * so a ref passed to `Dialog.Content` never attaches on web. Passive effects
88
+ * run after the portal's layout-effect `show()`.
89
+ */
90
+ function useDialogShowFocus(onOpenAutoFocus: OpenAutoFocus): void {
91
+ const { open, contentId } = useDialogContext();
92
+ useEffect(() => {
93
+ if (!open || !contentId || !isWeb || typeof document === "undefined") return;
94
+ const content = document.getElementById(contentId);
95
+ const focused = document.activeElement;
96
+ if (!content || !(focused instanceof HTMLElement)) return;
97
+ if (!content.contains(focused) || focused.tabIndex >= 0) return;
98
+ content.addEventListener(DIALOG_SHOW_FOCUS_EVENT, onOpenAutoFocus, { once: true });
99
+ content.dispatchEvent(new CustomEvent(DIALOG_SHOW_FOCUS_EVENT, { cancelable: true }));
100
+ }, [open, contentId]);
101
+ }
102
+
103
+ /**
104
+ * The house open focus for a raw `Dialog.Content`, which `DialogContent` and
105
+ * `AlertDialogContent` already run: render it inside the content. When
106
+ * `<dialog>.show()` left focus off a tab stop, the caller's `onOpenAutoFocus`
107
+ * runs, then the first tabbable takes focus (MPO-470).
108
+ */
109
+ export function DialogShowFocus({ onOpenAutoFocus }: { onOpenAutoFocus?: OpenAutoFocus }): null {
110
+ useDialogShowFocus(withHouseOpenFocus(onOpenAutoFocus));
111
+ return null;
112
+ }
113
+
114
+ // ── Dialog region context (DG-OVL-04) ───────────────────────────────────
115
+
116
+ export interface DialogRegionContextValue {
117
+ /** True inside a dialog surface — dialog action rules apply (DG-OVL-04). */
118
+ inDialogRegion: boolean;
119
+ }
120
+
121
+ /**
122
+ * `DialogContent` and `AlertDialogContent` provide this automatically. Wrap custom dialog-like
123
+ * surfaces (sheet-as-dialog, hand-rolled dialog chrome) so their action rows
124
+ * inherit dialog rules such as the max-2-actions cap (DG-OVL-04).
125
+ */
126
+ export const DialogRegionContext = createContext<DialogRegionContextValue>({
127
+ inDialogRegion: false,
128
+ });
129
+
130
+ export function useDialogRegionContext(): DialogRegionContextValue {
131
+ return useContext(DialogRegionContext);
132
+ }
133
+
134
+ export function useIsInDialogRegion(): boolean {
135
+ return useDialogRegionContext().inDialogRegion;
136
+ }
137
+
138
+ const IN_DIALOG_REGION: DialogRegionContextValue = { inDialogRegion: true };
139
+
140
+ // ── DialogContent ────────────────────────────────────────────────────────
141
+
142
+ const overlayEnterExit = { opacity: 0, y: -4, scale: 0.97 } as const;
143
+
144
+ export type DialogContentProps = Omit<TamaguiDialogContentProps, "size"> & {
145
+ children?: ReactNode;
146
+ size?: SizeTokens;
147
+ };
148
+
149
+ export const DialogContent = forwardRef<TamaguiElement, DialogContentProps>(function DialogContent(
150
+ { children, size = "$true", ...props },
151
+ ref,
152
+ ) {
153
+ return (
154
+ <Surface size={size} painted={false}>
155
+ <DialogContentInner ref={ref} {...props}>
156
+ {children}
157
+ </DialogContentInner>
158
+ </Surface>
159
+ );
160
+ });
161
+
162
+ const DialogContentInner = forwardRef<
163
+ TamaguiElement,
164
+ TamaguiDialogContentProps & { children?: ReactNode }
165
+ >(function DialogContentInner({ children, onOpenAutoFocus, ...props }, ref) {
166
+ const { knobProps } = useResolvedKnobs({ component: "DialogContent" });
167
+ const openFocus = withHouseOpenFocus(onOpenAutoFocus);
168
+ useDialogShowFocus(openFocus);
169
+ // ThemeableStack unstyled:false defaults `elevate: true`, which paints
170
+ // getElevation(size) — `$true` is a control stop. `elevate={false}` leaves
171
+ // the overlay ladder in `elevatedSurface` as the only shadow (MPO-99).
172
+ return (
173
+ <Dialog.Content
174
+ ref={ref}
175
+ {...knobProps.elevatedSurface}
176
+ {...knobProps.containerRadius}
177
+ {...knobProps.panelPadding}
178
+ {...knobProps.gap}
179
+ elevate={false}
180
+ {...transitionProps(knobProps.motion.overlay)}
181
+ enterStyle={overlayEnterExit}
182
+ exitStyle={overlayEnterExit}
183
+ {...overlayFrameFocus}
184
+ focusStyle={overlayFrameFocusStyle}
185
+ focusVisibleStyle={overlayFrameFocusStyle}
186
+ onOpenAutoFocus={openFocus}
187
+ {...props}
188
+ >
189
+ <DialogRegionContext.Provider value={IN_DIALOG_REGION}>
190
+ {children}
191
+ </DialogRegionContext.Provider>
192
+ </Dialog.Content>
193
+ );
194
+ });
195
+
196
+ // ── DialogOverlay ────────────────────────────────────────────────────────
197
+
198
+ // A scrim only fades — sliding or scaling a full-bleed backdrop reads as a
199
+ // glitch, so this stays opacity-only rather than reusing `overlayEnterExit`.
200
+ const scrimFade = { opacity: 0 } as const;
201
+
202
+ /**
203
+ * Knob-aware dialog scrim. Bare `Dialog.Overlay` carries no transition, so
204
+ * the backdrop popped in and out while the content faded (LC-24: enter/exit
205
+ * styles without a resolved token are dead code on the css driver). Pairs
206
+ * with `DialogContent`; `transition` drops to undefined at `animation=none`
207
+ * and under `prefers-reduced-motion`, which is the instant path.
208
+ */
209
+ export const DialogOverlay = forwardRef<
210
+ TamaguiElement,
211
+ TamaguiDialogOverlayProps & { children?: ReactNode }
212
+ >(function DialogOverlay({ children, ...props }, ref) {
213
+ const { knobProps } = useResolvedKnobs();
214
+ return (
215
+ <Dialog.Overlay
216
+ ref={ref}
217
+ backgroundColor="$shadow6"
218
+ {...transitionProps(knobProps.motion.scrim)}
219
+ enterStyle={scrimFade}
220
+ exitStyle={scrimFade}
221
+ {...props}
222
+ >
223
+ {children}
224
+ </Dialog.Overlay>
225
+ );
226
+ });
227
+
228
+ // ── AlertDialogContent (MPO-21 G6) ───────────────────────────────────────
229
+ // Tamagui's AlertDialog.Content is Dialog.Content under a different name:
230
+ // the styled frame bakes `elevate: true` (plus bg/border/radius/padding).
231
+ // `unstyled` drops that chrome so elevatedSurface is the only surface source.
232
+
233
+ export const AlertDialogContent = forwardRef<TamaguiElement, DialogContentProps>(
234
+ function AlertDialogContent({ children, size = "$true", ...props }, ref) {
235
+ return (
236
+ <Surface size={size} painted={false}>
237
+ <AlertDialogContentInner ref={ref} {...props}>
238
+ {children}
239
+ </AlertDialogContentInner>
240
+ </Surface>
241
+ );
242
+ },
243
+ );
244
+
245
+ const AlertDialogContentInner = forwardRef<
246
+ TamaguiElement,
247
+ TamaguiDialogContentProps & { children?: ReactNode }
248
+ >(function AlertDialogContentInner({ children, ...props }, ref) {
249
+ const { knobProps } = useResolvedKnobs({ component: "AlertDialogContent" });
250
+ useDialogShowFocus(withHouseOpenFocus(props.onOpenAutoFocus));
251
+ return (
252
+ <AlertDialog.Content
253
+ ref={ref}
254
+ unstyled
255
+ {...knobProps.elevatedSurface}
256
+ {...knobProps.containerRadius}
257
+ {...knobProps.panelPadding}
258
+ {...knobProps.gap}
259
+ elevate={false}
260
+ {...transitionProps(knobProps.motion.overlay)}
261
+ enterStyle={overlayEnterExit}
262
+ exitStyle={overlayEnterExit}
263
+ {...overlayFrameFocus}
264
+ focusStyle={overlayFrameFocusStyle}
265
+ focusVisibleStyle={overlayFrameFocusStyle}
266
+ {...props}
267
+ >
268
+ <DialogRegionContext.Provider value={IN_DIALOG_REGION}>
269
+ {children}
270
+ </DialogRegionContext.Provider>
271
+ </AlertDialog.Content>
272
+ );
273
+ });
274
+
275
+ export const AlertDialogOverlay = forwardRef<
276
+ TamaguiElement,
277
+ TamaguiDialogOverlayProps & { children?: ReactNode }
278
+ >(function AlertDialogOverlay({ children, ...props }, ref) {
279
+ const { knobProps } = useResolvedKnobs();
280
+ return (
281
+ <AlertDialog.Overlay
282
+ ref={ref}
283
+ backgroundColor="$shadow6"
284
+ {...transitionProps(knobProps.motion.scrim)}
285
+ enterStyle={scrimFade}
286
+ exitStyle={scrimFade}
287
+ {...props}
288
+ >
289
+ {children}
290
+ </AlertDialog.Overlay>
291
+ );
292
+ });
293
+
294
+ // ── PopoverContent ───────────────────────────────────────────────────────
295
+ // OVERLAY_ANCHOR_GAP rule (theme/layoutTokens): free popovers size to
296
+ // content, start-aligned to their anchor with the shared gap (the caller's
297
+ // tamagui <Popover offset={OVERLAY_ANCHOR_GAP}>), flipping/shifting at
298
+ // viewport edges. This frame is the overlay's single surface owner —
299
+ // border comes from the elevatedSurface recipe and radius from the
300
+ // containerRadius cap spread over it; children must not draw their own
301
+ // outer border.
302
+
303
+ type PopoverContentProps = ViewProps & {
304
+ children?: ReactNode;
305
+ /** Runs first; `preventDefault()` skips the house open focus. */
306
+ onOpenAutoFocus?: OpenAutoFocus;
307
+ };
308
+
309
+ export const PopoverContent = forwardRef<TamaguiElement, PopoverContentProps>(
310
+ function PopoverContent({ children, onOpenAutoFocus, ...props }, ref) {
311
+ const { knobProps } = useResolvedKnobs({ component: "PopoverContent", painted: false });
312
+ return (
313
+ <Popover.Content
314
+ ref={ref}
315
+ {...knobProps.elevatedSurface}
316
+ {...knobProps.containerRadius}
317
+ {...transitionProps(knobProps.motion.overlay)}
318
+ enterStyle={overlayEnterExit}
319
+ exitStyle={overlayEnterExit}
320
+ {...overlayFrameFocus}
321
+ focusStyle={overlayFrameFocusStyle}
322
+ focusVisibleStyle={overlayFrameFocusStyle}
323
+ onOpenAutoFocus={withHouseOpenFocus(onOpenAutoFocus)}
324
+ {...props}
325
+ >
326
+ <Surface painted={false}>{children}</Surface>
327
+ </Popover.Content>
328
+ );
329
+ },
330
+ );
@@ -1,6 +1,14 @@
1
1
  import type { ThemeName } from "@tamagui/web";
2
2
  import type { Knobs } from "./knobs";
3
+ import type { ScaleInputs } from "./scale/scaleInputs";
3
4
 
5
+ /**
6
+ * A preset is the whole composition a surface renders with: a Tamagui color
7
+ * theme, the knobs, the intent overrides, the tint sequence and the token
8
+ * scale. Names, fixed: `Preset` is this composition; `ThemeConfig` is the
9
+ * built Tamagui config (tokens + Tamagui color themes) it renders against;
10
+ * "theme" alone means a Tamagui color theme. UI copy calls a preset a Theme.
11
+ */
4
12
  export interface Preset {
5
13
  /** Tamagui color theme (accent color). */
6
14
  theme: ThemeName;
@@ -15,6 +23,14 @@ export interface Preset {
15
23
  };
16
24
  /** Tint sequence for color cycling across nested regions. */
17
25
  tints: ThemeName[];
26
+ /**
27
+ * The token scale this preset renders on: `$size`, `$space`, `$radius`
28
+ * and the type tables are generated from these inputs, so a preset that
29
+ * needs more or less room says so here, never through knobs. Omitted =
30
+ * the default scale. Applies where the preset is the provider's active
31
+ * one; a nested `<Preset>` changes knobs, not tokens.
32
+ */
33
+ scale?: ScaleInputs;
18
34
  }
19
35
 
20
36
  export type IntentOverride = Partial<Knobs> & {
@@ -0,0 +1,32 @@
1
+ import { getConfig, getVariableValue } from "@tamagui/web";
2
+
3
+ export interface PresetPreviewColors {
4
+ background: string;
5
+ border: string;
6
+ ink: string;
7
+ accent: string;
8
+ }
9
+
10
+ /**
11
+ * A preset's page, edge, ink and accent colours in `scheme`, read from the
12
+ * built themes. A preview cannot sit inside `<Theme name={scheme}>` instead:
13
+ * Tamagui swaps only the scheme of a coloured ancestor (`dark_blue` stays
14
+ * blue), so a neutral preset would pick up the active preset's hue.
15
+ */
16
+ export function getPresetPreviewColors(
17
+ hue: string | undefined,
18
+ scheme: "light" | "dark",
19
+ ): PresetPreviewColors {
20
+ const themes = getConfig().themes as Record<string, Record<string, unknown> | undefined>;
21
+ const theme = (hue ? themes[`${scheme}_${hue}`] : undefined) ?? themes[scheme] ?? {};
22
+ const read = (key: string): string => {
23
+ const value = getVariableValue(theme[key] as Parameters<typeof getVariableValue>[0]);
24
+ return typeof value === "string" ? value : "";
25
+ };
26
+ return {
27
+ background: read("background"),
28
+ border: read("borderColor"),
29
+ ink: read("color12"),
30
+ accent: read(hue && themes[`${scheme}_${hue}`] ? "color9" : "color12"),
31
+ };
32
+ }
@@ -0,0 +1,89 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import { Cookies, CookiesProvider } from "react-cookie";
3
+ import { useThemeName } from "tamagui";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
6
+ import { PresetContext } from "./PresetContext";
7
+ import { defaultPreset } from "./presets";
8
+ import { setStoredPreset } from "./presetStore";
9
+ import { registerPreset } from "./shared";
10
+ import { ThemeProvider } from "./theme";
11
+ import { ThemeProvider as NativeThemeProvider } from "./theme.native";
12
+
13
+ const themeConfig = createDefaultThemeConfig();
14
+ const bluePreset = { ...defaultPreset, theme: "blue" as const };
15
+ const schemePreset = { ...defaultPreset, theme: "" as const };
16
+
17
+ function ThemeNameProbe() {
18
+ return <span data-testid="theme-name">{useThemeName()}</span>;
19
+ }
20
+
21
+ beforeEach(() => {
22
+ registerPreset("root-blue", bluePreset);
23
+ registerPreset("root-scheme", schemePreset);
24
+ setStoredPreset(undefined);
25
+ });
26
+
27
+ afterEach(() => {
28
+ cleanup();
29
+ new Cookies().remove("mp.preset", { path: "/" });
30
+ setStoredPreset(undefined);
31
+ });
32
+
33
+ for (const [platform, Provider] of [
34
+ ["web", ThemeProvider],
35
+ ["native", NativeThemeProvider],
36
+ ] as const) {
37
+ describe(`${platform} preset root theme`, () => {
38
+ it("applies persisted presets over the configured default and follows changes", () => {
39
+ const cookies = new Cookies();
40
+ const selectPreset = (name: string) => {
41
+ if (platform === "web") cookies.set("mp.preset", name, { path: "/" });
42
+ else setStoredPreset(name);
43
+ };
44
+ selectPreset("root-blue");
45
+ render(
46
+ <CookiesProvider cookies={cookies}>
47
+ <Provider config={{ ...themeConfig, defaultPreset: "root-scheme" }} systemTheme="light">
48
+ <ThemeNameProbe />
49
+ </Provider>
50
+ </CookiesProvider>,
51
+ );
52
+ expect(screen.getByTestId("theme-name").textContent).toBe("light_blue");
53
+ act(() => selectPreset("root-scheme"));
54
+ expect(screen.getByTestId("theme-name").textContent).toBe("light");
55
+ });
56
+
57
+ for (const scheme of ["light", "dark"] as const) {
58
+ it.each([
59
+ ["root-blue", `${scheme}_blue`],
60
+ ["root-scheme", scheme],
61
+ ])("applies registered preset %s under " + scheme, (presetName, expected) => {
62
+ render(
63
+ <CookiesProvider cookies={new Cookies()}>
64
+ <Provider config={{ ...themeConfig, defaultPreset: presetName }} systemTheme={scheme}>
65
+ <ThemeNameProbe />
66
+ </Provider>
67
+ </CookiesProvider>,
68
+ );
69
+ expect(screen.getByTestId("theme-name").textContent).toBe(expected);
70
+ });
71
+ }
72
+
73
+ it.each([
74
+ ["blue", bluePreset, "root-scheme", "light_blue"],
75
+ ["scheme", schemePreset, "root-blue", "light"],
76
+ ])("uses the ancestor preset's %s theme", (_, preset, presetName, expected) => {
77
+ render(
78
+ <CookiesProvider cookies={new Cookies()}>
79
+ <PresetContext.Provider value={{ preset, presetName }}>
80
+ <Provider config={themeConfig} systemTheme="light">
81
+ <ThemeNameProbe />
82
+ </Provider>
83
+ </PresetContext.Provider>
84
+ </CookiesProvider>,
85
+ );
86
+ expect(screen.getByTestId("theme-name").textContent).toBe(expected);
87
+ });
88
+ });
89
+ }
@@ -0,0 +1,141 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const backend = vi.hoisted(() => ({
4
+ data: new Map<string, string>(),
5
+ getItem: vi.fn(),
6
+ setItem: vi.fn(),
7
+ removeItem: vi.fn(),
8
+ }));
9
+ vi.mock("@multiplatform.one/store", () => ({ storage: backend }));
10
+
11
+ beforeEach(() => {
12
+ vi.resetModules();
13
+ backend.data.clear();
14
+ backend.getItem.mockReset().mockImplementation((key) => backend.data.get(key) ?? null);
15
+ backend.setItem.mockReset().mockImplementation((key, value) => {
16
+ backend.data.set(key, value);
17
+ });
18
+ backend.removeItem.mockReset().mockImplementation((key) => {
19
+ backend.data.delete(key);
20
+ });
21
+ });
22
+ afterEach(() => vi.useRealTimers());
23
+
24
+ describe("native preset persistence", () => {
25
+ it("hydrates synchronous storage immediately and sanitizes corrupt overrides", async () => {
26
+ backend.data.set("mp.preset", "bold");
27
+ backend.data.set(
28
+ "mp.ov",
29
+ JSON.stringify({ borderRadius: "large", hover: { fillStyle: "filled", bad: [] } }),
30
+ );
31
+ const store = await import("./presetStore");
32
+ const hydration = store.hydrateStoredPreset();
33
+ expect(store.getStoredPreset()).toEqual({
34
+ presetName: "bold",
35
+ overrides: { borderRadius: "large", hover: { fillStyle: "filled" } },
36
+ hydrated: true,
37
+ });
38
+ await hydration;
39
+ });
40
+
41
+ it("never overwrites a selection made before asynchronous hydration", async () => {
42
+ let finish!: (value: string) => void;
43
+ backend.getItem.mockImplementation((key) =>
44
+ key === "mp.preset"
45
+ ? new Promise((resolve) => {
46
+ finish = resolve;
47
+ })
48
+ : Promise.resolve(null),
49
+ );
50
+ const store = await import("./presetStore");
51
+ const hydration = store.hydrateStoredPreset();
52
+ store.setStoredPreset("hero");
53
+ finish("bold");
54
+ await hydration;
55
+ expect(store.getStoredPreset()).toEqual({ presetName: "hero", overrides: {}, hydrated: true });
56
+ });
57
+
58
+ it("releases a hung backend after one second", async () => {
59
+ vi.useFakeTimers();
60
+ backend.getItem.mockImplementation(() => new Promise(() => {}));
61
+ const store = await import("./presetStore");
62
+ const hydration = store.hydrateStoredPreset();
63
+ expect(store.getStoredPreset().hydrated).toBe(false);
64
+ await vi.advanceTimersByTimeAsync(1000);
65
+ await hydration;
66
+ expect(store.getStoredPreset().hydrated).toBe(true);
67
+ });
68
+
69
+ it("serializes writes so a slow older selection cannot win on relaunch", async () => {
70
+ const releases: (() => void)[] = [];
71
+ backend.setItem.mockImplementation(
72
+ (key, value) =>
73
+ new Promise<void>((resolve) => {
74
+ releases.push(() => {
75
+ backend.data.set(key, value);
76
+ resolve();
77
+ });
78
+ }),
79
+ );
80
+ const store = await import("./presetStore");
81
+ await store.hydrateStoredPreset();
82
+ store.setStoredPreset("bold");
83
+ store.setStoredPreset("hero");
84
+ await vi.waitFor(() => expect(releases).toHaveLength(1));
85
+ releases[0]();
86
+ await vi.waitFor(() => expect(releases).toHaveLength(2));
87
+ releases[1]();
88
+ await vi.waitFor(() => expect(backend.data.get("mp.preset")).toBe("hero"));
89
+ vi.resetModules();
90
+ const relaunched = await import("./presetStore");
91
+ await relaunched.hydrateStoredPreset();
92
+ expect(relaunched.getStoredPreset().presetName).toBe("hero");
93
+ });
94
+
95
+ it("keeps live state if storage throws, then continues queued writes", async () => {
96
+ backend.getItem.mockImplementation(() => {
97
+ throw new Error("unavailable");
98
+ });
99
+ backend.setItem.mockRejectedValueOnce(new Error("unavailable"));
100
+ const store = await import("./presetStore");
101
+ await store.hydrateStoredPreset();
102
+ store.setStoredPreset("bold");
103
+ store.setStoredPreset("hero", { borderRadius: "large" });
104
+ expect(store.getStoredPreset().presetName).toBe("hero");
105
+ await vi.waitFor(() => expect(backend.data.get("mp.preset")).toBe("hero"));
106
+ await vi.waitFor(() =>
107
+ expect(JSON.parse(backend.data.get("mp.ov")!)).toEqual({ borderRadius: "large" }),
108
+ );
109
+ });
110
+ });
111
+
112
+ it("cannot read or write native preset state during web SSR", async () => {
113
+ const store = await import("./presetStore");
114
+ vi.stubGlobal("window", undefined);
115
+ try {
116
+ store.setStoredPreset("bold");
117
+ await store.hydrateStoredPreset();
118
+ expect(store.getStoredPreset()).toEqual({ hydrated: true, overrides: {} });
119
+ expect(backend.getItem).not.toHaveBeenCalled();
120
+ expect(backend.setItem).not.toHaveBeenCalled();
121
+ } finally {
122
+ vi.unstubAllGlobals();
123
+ }
124
+ });
125
+
126
+ it("restores saved overrides on relaunch and clears them when switching presets", async () => {
127
+ let store = await import("./presetStore");
128
+ store.setStoredPreset("bold");
129
+ store.setStoredOverrides({ borderRadius: "large" });
130
+ await vi.waitFor(() => expect(backend.data.get("mp.ov")).toBe('{"borderRadius":"large"}'));
131
+ vi.resetModules();
132
+ store = await import("./presetStore");
133
+ await store.hydrateStoredPreset();
134
+ expect(store.getStoredPreset()).toEqual({
135
+ presetName: "bold",
136
+ overrides: { borderRadius: "large" },
137
+ hydrated: true,
138
+ });
139
+ store.setStoredPreset("default");
140
+ await vi.waitFor(() => expect(backend.data.has("mp.ov")).toBe(false));
141
+ });