@multiplatform.one/theme 6.1.0 → 6.3.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 (187) hide show
  1. package/README.md +73 -0
  2. package/package.json +22 -9
  3. package/src/dates.spec.ts +75 -0
  4. package/src/dates.ts +130 -0
  5. package/src/devWarn.spec.ts +144 -0
  6. package/src/devWarn.ts +235 -0
  7. package/src/figma/figmaTokens.spec.ts +244 -0
  8. package/src/figma/figmaTokens.ts +503 -0
  9. package/src/font.ts +6 -8
  10. package/src/fonts/createFontLoader.web.ts +15 -4
  11. package/src/index.ts +7 -0
  12. package/src/keyboardFocusRing.ts +45 -0
  13. package/src/menuRow.spec.ts +32 -0
  14. package/src/menuRow.ts +31 -0
  15. package/src/theme/FontKnobStyles.native.tsx +24 -0
  16. package/src/theme/FontKnobStyles.tsx +256 -0
  17. package/src/theme/animations/reactNative.ts +6 -1
  18. package/src/theme/chartPalette.spec.ts +159 -0
  19. package/src/theme/chartPalette.ts +170 -0
  20. package/src/theme/colorRules.ts +33 -1
  21. package/src/theme/cookies.spec.ts +32 -0
  22. package/src/theme/cookiesPersistence.spec.ts +86 -0
  23. package/src/theme/cornerSmoothing.native.ts +16 -0
  24. package/src/theme/cornerSmoothing.spec.ts +27 -0
  25. package/src/theme/cornerSmoothing.ts +46 -0
  26. package/src/theme/createDefaultThemeConfig.ts +32 -8
  27. package/src/theme/createThemeConfig.spec.ts +137 -0
  28. package/src/theme/createThemes.ts +77 -2
  29. package/src/theme/defaults/accent.ts +39 -0
  30. package/src/theme/defaults/base.ts +63 -0
  31. package/src/theme/defaults/builderOptions.ts +174 -0
  32. package/src/theme/defaults/fonts.spec.ts +87 -0
  33. package/src/theme/defaults/fonts.ts +89 -0
  34. package/src/theme/defaults/index.ts +4 -0
  35. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
  36. package/src/theme/devtools/buildSnapshot.ts +11 -0
  37. package/src/theme/devtools/knobIcons.tsx +141 -0
  38. package/src/theme/devtools/vizConstants.ts +1 -1
  39. package/src/theme/focusState.spec.ts +174 -0
  40. package/src/theme/focusState.ts +112 -0
  41. package/src/theme/fontKnobStyles.spec.ts +98 -0
  42. package/src/theme/hairline.native.ts +43 -0
  43. package/src/theme/hairline.spec.ts +63 -0
  44. package/src/theme/hairline.ts +101 -0
  45. package/src/theme/index.ts +8 -0
  46. package/src/theme/intent.spec.tsx +2 -2
  47. package/src/theme/intents.ts +6 -2
  48. package/src/theme/knobs.ts +134 -2
  49. package/src/theme/layoutTokens.spec.ts +75 -0
  50. package/src/theme/layoutTokens.ts +213 -0
  51. package/src/theme/layoutTokensHooks.spec.tsx +157 -0
  52. package/src/theme/presets.spec.ts +17 -0
  53. package/src/theme/presets.ts +22 -2
  54. package/src/theme/readableColor.ts +101 -0
  55. package/src/theme/recipes.ts +130 -7
  56. package/src/theme/resolveKnobs.spec.ts +560 -21
  57. package/src/theme/resolveKnobs.ts +267 -23
  58. package/src/theme/shared.tsx +26 -2
  59. package/src/theme/useResolvedKnobs.ts +67 -26
  60. package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
  61. package/types/audit/constraintAudit.d.ts +83 -0
  62. package/types/audit/constraintAudit.d.ts.map +1 -0
  63. package/types/audit/diffReport.d.ts +45 -0
  64. package/types/audit/diffReport.d.ts.map +1 -0
  65. package/types/audit/index.d.ts +5 -0
  66. package/types/audit/index.d.ts.map +1 -0
  67. package/types/dates.d.ts +52 -0
  68. package/types/dates.d.ts.map +1 -0
  69. package/types/devWarn.d.ts +104 -0
  70. package/types/devWarn.d.ts.map +1 -0
  71. package/types/figma/figmaTokens.d.ts +168 -0
  72. package/types/figma/figmaTokens.d.ts.map +1 -0
  73. package/types/font.d.ts +5 -0
  74. package/types/font.d.ts.map +1 -0
  75. package/types/fonts/createFontLoader.d.ts +3 -0
  76. package/types/fonts/createFontLoader.d.ts.map +1 -0
  77. package/types/fonts/createFontLoader.web.d.ts +3 -0
  78. package/types/fonts/createFontLoader.web.d.ts.map +1 -0
  79. package/types/fonts/index.d.ts +3 -0
  80. package/types/fonts/index.d.ts.map +1 -0
  81. package/types/fonts/types.d.ts +12 -0
  82. package/types/fonts/types.d.ts.map +1 -0
  83. package/types/index.d.ts +9 -0
  84. package/types/index.d.ts.map +1 -0
  85. package/types/keyboardFocusRing.d.ts +32 -0
  86. package/types/keyboardFocusRing.d.ts.map +1 -0
  87. package/types/menuRow.d.ts +32 -0
  88. package/types/menuRow.d.ts.map +1 -0
  89. package/types/theme/FontKnobStyles.d.ts +49 -0
  90. package/types/theme/FontKnobStyles.d.ts.map +1 -0
  91. package/types/theme/FontKnobStyles.native.d.ts +12 -0
  92. package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
  93. package/types/theme/Intent.d.ts +20 -0
  94. package/types/theme/Intent.d.ts.map +1 -0
  95. package/types/theme/Preset.d.ts +15 -0
  96. package/types/theme/Preset.d.ts.map +1 -0
  97. package/types/theme/PresetContext.d.ts +18 -0
  98. package/types/theme/PresetContext.d.ts.map +1 -0
  99. package/types/theme/Tint.d.ts +19 -0
  100. package/types/theme/Tint.d.ts.map +1 -0
  101. package/types/theme/animations/css.d.ts +24 -0
  102. package/types/theme/animations/css.d.ts.map +1 -0
  103. package/types/theme/animations/index.d.ts +2 -0
  104. package/types/theme/animations/index.d.ts.map +1 -0
  105. package/types/theme/animations/index.web.d.ts +2 -0
  106. package/types/theme/animations/index.web.d.ts.map +1 -0
  107. package/types/theme/animations/reactNative.d.ts +104 -0
  108. package/types/theme/animations/reactNative.d.ts.map +1 -0
  109. package/types/theme/chartPalette.d.ts +62 -0
  110. package/types/theme/chartPalette.d.ts.map +1 -0
  111. package/types/theme/colorRules.d.ts +69 -0
  112. package/types/theme/colorRules.d.ts.map +1 -0
  113. package/types/theme/cookies.d.ts +76 -0
  114. package/types/theme/cookies.d.ts.map +1 -0
  115. package/types/theme/cornerSmoothing.d.ts +31 -0
  116. package/types/theme/cornerSmoothing.d.ts.map +1 -0
  117. package/types/theme/cornerSmoothing.native.d.ts +14 -0
  118. package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
  119. package/types/theme/createDefaultThemeConfig.d.ts +37 -0
  120. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
  121. package/types/theme/createThemes.d.ts +38 -0
  122. package/types/theme/createThemes.d.ts.map +1 -0
  123. package/types/theme/defaults/accent.d.ts +3 -0
  124. package/types/theme/defaults/accent.d.ts.map +1 -0
  125. package/types/theme/defaults/base.d.ts +3 -0
  126. package/types/theme/defaults/base.d.ts.map +1 -0
  127. package/types/theme/defaults/builderOptions.d.ts +3 -0
  128. package/types/theme/defaults/builderOptions.d.ts.map +1 -0
  129. package/types/theme/defaults/fonts.d.ts +7 -0
  130. package/types/theme/defaults/fonts.d.ts.map +1 -0
  131. package/types/theme/defaults/index.d.ts +5 -0
  132. package/types/theme/defaults/index.d.ts.map +1 -0
  133. package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
  134. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
  135. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
  136. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
  137. package/types/theme/devtools/buildSnapshot.d.ts +12 -0
  138. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
  139. package/types/theme/devtools/knobIcons.d.ts +33 -0
  140. package/types/theme/devtools/knobIcons.d.ts.map +1 -0
  141. package/types/theme/devtools/vizConstants.d.ts +5 -0
  142. package/types/theme/devtools/vizConstants.d.ts.map +1 -0
  143. package/types/theme/focusState.d.ts +42 -0
  144. package/types/theme/focusState.d.ts.map +1 -0
  145. package/types/theme/hairline.d.ts +88 -0
  146. package/types/theme/hairline.d.ts.map +1 -0
  147. package/types/theme/hairline.native.d.ts +42 -0
  148. package/types/theme/hairline.native.d.ts.map +1 -0
  149. package/types/theme/index.d.ts +29 -0
  150. package/types/theme/index.d.ts.map +1 -0
  151. package/types/theme/intents.d.ts +3 -0
  152. package/types/theme/intents.d.ts.map +1 -0
  153. package/types/theme/knobs.d.ts +236 -0
  154. package/types/theme/knobs.d.ts.map +1 -0
  155. package/types/theme/layoutTokens.d.ts +104 -0
  156. package/types/theme/layoutTokens.d.ts.map +1 -0
  157. package/types/theme/preset.types.d.ts +21 -0
  158. package/types/theme/preset.types.d.ts.map +1 -0
  159. package/types/theme/presets.d.ts +4 -0
  160. package/types/theme/presets.d.ts.map +1 -0
  161. package/types/theme/readableColor.d.ts +17 -0
  162. package/types/theme/readableColor.d.ts.map +1 -0
  163. package/types/theme/recipes.d.ts +200 -0
  164. package/types/theme/recipes.d.ts.map +1 -0
  165. package/types/theme/resolveKnobs.d.ts +22 -0
  166. package/types/theme/resolveKnobs.d.ts.map +1 -0
  167. package/types/theme/shared.d.ts +40 -0
  168. package/types/theme/shared.d.ts.map +1 -0
  169. package/types/theme/theme.d.ts +19 -0
  170. package/types/theme/theme.d.ts.map +1 -0
  171. package/types/theme/theme.native.d.ts +10 -0
  172. package/types/theme/theme.native.d.ts.map +1 -0
  173. package/types/theme/useColorScale.d.ts +7 -0
  174. package/types/theme/useColorScale.d.ts.map +1 -0
  175. package/types/theme/useColorScale.native.d.ts +6 -0
  176. package/types/theme/useColorScale.native.d.ts.map +1 -0
  177. package/types/theme/useResolvedKnobs.d.ts +32 -0
  178. package/types/theme/useResolvedKnobs.d.ts.map +1 -0
  179. package/types/theme/useTheme.d.ts +31 -0
  180. package/types/theme/useTheme.d.ts.map +1 -0
  181. package/types/theme/useTheme.native.d.ts +10 -0
  182. package/types/theme/useTheme.native.d.ts.map +1 -0
  183. package/CHANGELOG.md +0 -53
  184. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
  185. package/src/theme/pokemonScreen.spec.ts +0 -109
  186. package/tsconfig.json +0 -11
  187. package/vitest.config.mjs +0 -9
@@ -1,7 +1,18 @@
1
+ import { isWeb } from "@multiplatform.one/platform";
1
2
  import type { TransitionProp } from "tamagui";
2
- import type { Knobs, StateKnobs } from "./knobs";
3
+ // Importing the class constant also mounts the corner-shape stylesheet at
4
+ // theme load (web only, idempotent) — see cornerSmoothing.ts.
5
+ import { cornerSmoothClassName } from "./cornerSmoothing";
6
+ import {
7
+ ensureFocusVisibleRing,
8
+ STATE_LAYER_HOVER,
9
+ STATE_LAYER_PRESS,
10
+ withDefaultStateLayer,
11
+ } from "./focusState";
12
+ import type { CornerSmoothing, Knobs, StateKnobs } from "./knobs";
3
13
  import type {
4
14
  ControlStateProps,
15
+ DisabledRecipe,
5
16
  ElevationStateProps,
6
17
  KnobProps,
7
18
  KnobPropsOverride,
@@ -10,6 +21,42 @@ import type {
10
21
  TextStateProps,
11
22
  } from "./recipes";
12
23
 
24
+ // ── Density (compact) stepping ────────────────────────────────
25
+
26
+ /** Step size down one level for compact density. */
27
+ export const compactSizeMap: Record<Knobs["size"], Knobs["size"]> = {
28
+ small: "small",
29
+ medium: "small",
30
+ large: "medium",
31
+ };
32
+
33
+ /** Step space down one level for compact density. */
34
+ export const compactSpaceMap: Record<Knobs["space"], Knobs["space"]> = {
35
+ small: "small",
36
+ medium: "small",
37
+ large: "medium",
38
+ };
39
+
40
+ /**
41
+ * Apply density mode to knobs before token resolution.
42
+ *
43
+ * - `density: "compact"` steps size + space down one level.
44
+ * - `compactOverride` wins over the density knob when provided:
45
+ * `true` forces compact stepping; `false` forces comfortable (no step).
46
+ */
47
+ export function applyDensity(knobs: Knobs, compactOverride?: boolean): Knobs {
48
+ const useCompact = compactOverride !== undefined ? compactOverride : knobs.density === "compact";
49
+ if (!useCompact) {
50
+ return knobs.density === "comfortable" ? knobs : { ...knobs, density: "comfortable" };
51
+ }
52
+ return {
53
+ ...knobs,
54
+ density: "compact",
55
+ size: compactSizeMap[knobs.size],
56
+ space: compactSpaceMap[knobs.space],
57
+ };
58
+ }
59
+
13
60
  // ── Knob-to-token mapping tables ──────────────────────────────
14
61
 
15
62
  const borderRadiusMap = {
@@ -35,22 +82,74 @@ const borderWidthMap = {
35
82
  large: 2,
36
83
  } as const;
37
84
 
85
+ // ── Corner smoothing (Axiom 15 OPTICS) ────────────────────────
86
+ // `smooth` upgrades circular-arc corners to continuous curvature —
87
+ // `corner-shape: squircle` (≡ superellipse(2), ≈ Apple / Figma 0.6) — as a
88
+ // web progressive enhancement: Chromium 139+ paints it, every other engine
89
+ // drops the unknown declaration and keeps the plain border-radius arc.
90
+ // Delivery is a class + mounted stylesheet (cornerSmoothing.ts) because
91
+ // Tamagui strips unknown keys from the `style` prop (skipProps) but merges
92
+ // `className`. Native resolves to `round` for now (SquircleView adoption is
93
+ // phase 2); GTK has no corner-shape equivalent and stays on the plain arc.
94
+ const cornerSmoothingStyle: Record<CornerSmoothing, { className: string } | undefined> = isWeb
95
+ ? { round: undefined, smooth: { className: cornerSmoothClassName } }
96
+ : { round: undefined, smooth: undefined };
97
+
38
98
  const panelPaddingMap = {
39
- small: "$2",
40
- medium: "$3",
41
- large: "$4",
99
+ small: "$3",
100
+ medium: "$4",
101
+ large: "$6",
42
102
  } as const;
43
103
 
104
+ // ── Container radius cap (Axiom 1 CLIP) ──────────────────────
105
+ // A padded container's effective radius must not exceed its own padding:
106
+ // past that, the corner curve sweeps through the cell where children sit
107
+ // (radius > padding clips child corners). Px values mirror the
108
+ // @tamagui/themes tokens the maps above resolve to (radius $0/$2/$4/$6/$12
109
+ // = 0/5/9/16/50; space $3/$4/$6 = 13/18/32) — resolveKnobs stays a pure
110
+ // module, so the comparison happens on mirrored numbers, not live tokens.
111
+ // The cap only bites at `full` (every space) and `large` under
112
+ // `space=small`; default knobs are unchanged.
113
+
114
+ const radiusPxMap: Record<Knobs["borderRadius"], number> = {
115
+ none: 0,
116
+ small: 5,
117
+ medium: 9,
118
+ large: 16,
119
+ full: 50,
120
+ };
121
+
122
+ const panelPaddingPxMap: Record<Knobs["space"], number> = {
123
+ small: 13,
124
+ medium: 18,
125
+ large: 32,
126
+ };
127
+
128
+ /**
129
+ * Cap a container's radius at its own padding (Axiom 1 CLIP: "my children's
130
+ * pixel clips first → cap at padding"). Returns the radius token untouched
131
+ * while it fits, or the padding px number once the token would exceed it.
132
+ */
133
+ export function capContainerRadius(
134
+ borderRadius: Knobs["borderRadius"],
135
+ space: Knobs["space"],
136
+ ): string | number {
137
+ const radiusPx = radiusPxMap[borderRadius];
138
+ const paddingPx = panelPaddingPxMap[space];
139
+ return radiusPx > paddingPx ? paddingPx : borderRadiusMap[borderRadius];
140
+ }
141
+
44
142
  const gapMap = {
45
143
  small: "$2",
46
- medium: "$3",
47
- large: "$4",
144
+ medium: "$4",
145
+ large: "$5",
48
146
  } as const;
49
147
 
148
+ // gapLg stays one step above gapMap.
50
149
  const gapLgMap = {
51
150
  small: "$3",
52
- medium: "$4",
53
- large: "$5",
151
+ medium: "$5",
152
+ large: "$6",
54
153
  } as const;
55
154
 
56
155
  const sizeTokenMap = {
@@ -92,19 +191,85 @@ const fontWeightMap = {
92
191
  bold: "700",
93
192
  } as const;
94
193
 
95
- const elevationMap: Record<string, string | undefined> = {
96
- none: undefined,
97
- small: "$1",
98
- medium: "$2",
99
- large: "$4",
100
- };
194
+ // Web keeps Tamagui size-token strings — Tamagui resolves them to boxShadow.
195
+ // Native must emit NUMBERS: knobProps.elevation gets spread onto plain RN
196
+ // Views by non-Tamagui consumers, and Android's RCTView `elevation` prop is a
197
+ // Double — a token string kills the ReactHost with "java.lang.String cannot
198
+ // be cast to java.lang.Double" (SB-N-04). The numbers are the exact
199
+ // tokens.size values Tamagui resolves those tokens to ($1=20, $2=28, $4=44),
200
+ // so Tamagui consumers render identical shadows either way.
201
+ const elevationMap: Record<string, string | number | undefined> = isWeb
202
+ ? { none: undefined, small: "$1", medium: "$2", large: "$4" }
203
+ : { none: undefined, small: 20, medium: 28, large: 44 };
101
204
 
205
+ // Legibility floor: body/label text must never land below ~4.5:1 against its
206
+ // surface. $color11 is the lowest ramp step that clears AA on color1/2
207
+ // surfaces, so "low" clamps there ($color9 measured 1.29-3.43:1 — SB-R-01).
102
208
  const textAccentColorMap: Record<string, string> = {
103
- low: "$color9",
209
+ low: "$color11",
104
210
  medium: "$color11",
105
211
  high: "$color",
106
212
  };
107
213
 
214
+ // ── Disabled treatment (LC-40 DISABLED-VISIBLE) ───────────────
215
+ // A disabled control must be visually distinguishable from its enabled twin
216
+ // under BOTH `disabledStyle` values (Axiom 6 HONEST STATE; DG-ST-05 names
217
+ // the dial: Fluent keepLabel / Material dimWhole). Tiers are theme-relative
218
+ // ramp steps so intent sub-themes desaturate within their own hue instead
219
+ // of fighting it:
220
+ // - keepLabel (default): the control CHROME washes out while label/value
221
+ // text keeps ≥ AA. Text-bearing chrome swaps fill/border to the muted
222
+ // tiers ($color3 wash — the native `input:disabled` idiom — bordered by
223
+ // $color6) with NO opacity so inner text, re-anchored to $color11 (the
224
+ // documented lowest AA step, see textAccentColorMap above), stays ≥4.5:1.
225
+ // Text-free chrome (track/box/ring/star) opacity-dims at 0.5 — the value
226
+ // the catalog's hand-rolled treatments already shipped.
227
+ // - dimWhole: the whole ASSEMBLY (label included) dims at 0.5; control
228
+ // fragments then carry only the not-allowed affordance so the dim is
229
+ // applied once, by the assembly owner.
230
+
231
+ const DISABLED_CHROME_OPACITY = 0.5;
232
+ const DISABLED_ASSEMBLY_OPACITY = 0.5;
233
+
234
+ const disabledCursor = { cursor: "not-allowed" as const };
235
+
236
+ // Selected-while-disabled tier: one ramp step stronger than the plain wash
237
+ // ($color3/$color6) so a chosen segment stays visibly chosen (Axiom 6 —
238
+ // owner-ratified recipe-level "selected wash"). Carried under BOTH
239
+ // disabledStyle values: consumers drop their live active fill when disabled
240
+ // (the `active` variant beats the wash), so without this tier a dimWhole
241
+ // assembly dim alone could not distinguish selection either.
242
+ const disabledSelectedSurface = {
243
+ backgroundColor: "$color5",
244
+ borderColor: "$color8",
245
+ ...disabledCursor,
246
+ };
247
+
248
+ function resolveDisabled(disabledStyle: Knobs["disabledStyle"]): DisabledRecipe {
249
+ if (disabledStyle === "dimWhole") {
250
+ return {
251
+ surfaceKnobProps: { ...disabledCursor },
252
+ selectedSurfaceKnobProps: { ...disabledSelectedSurface },
253
+ chromeKnobProps: { ...disabledCursor },
254
+ textKnobProps: {},
255
+ assemblyKnobProps: { opacity: DISABLED_ASSEMBLY_OPACITY },
256
+ style: disabledStyle,
257
+ };
258
+ }
259
+ return {
260
+ surfaceKnobProps: {
261
+ backgroundColor: "$color3",
262
+ borderColor: "$color6",
263
+ ...disabledCursor,
264
+ },
265
+ selectedSurfaceKnobProps: { ...disabledSelectedSurface },
266
+ chromeKnobProps: { opacity: DISABLED_CHROME_OPACITY, ...disabledCursor },
267
+ textKnobProps: { color: "$color11" },
268
+ assemblyKnobProps: {},
269
+ style: disabledStyle,
270
+ };
271
+ }
272
+
108
273
  // ── State style resolver ──────────────────────────────────────
109
274
 
110
275
  function resolveStateKnobs(state: StateKnobs): {
@@ -159,10 +324,32 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
159
324
  const bodyFontValue = bodyFontMap[knobs.bodyFont] ?? "$body";
160
325
  const transition = knobs.animation === "none" ? undefined : (knobs.animation as TransitionProp);
161
326
 
327
+ const containerRadiusValue = capContainerRadius(knobs.borderRadius, knobs.space);
328
+
329
+ // Corner smoothing rides every radius-bearing fragment so consumers need
330
+ // zero edits (KNOB-TOTALITY, LC-18). Identity guard (Axiom 3 NULL +
331
+ // R-BINARY/R-PILL): `none` has no arc to smooth, and at `full` the
332
+ // uncapped token turns control-tier parts into circles/capsules whose
333
+ // shape is identity — squircle ends would distort them — so the
334
+ // raw-radius fragments only smooth the arc classes (small/medium/large).
335
+ // Capped container fragments stay plain arcs at every knob (the Axiom 1
336
+ // cap = padding px, which superellipse corners under-fill, so the cap
337
+ // remains a safe over-approximation) and smooth whenever a nonzero arc
338
+ // exists. featureSurface's radius is fixed ($10, knob-independent), so it
339
+ // smooths whenever the knob says smooth.
340
+ const smoothing = cornerSmoothingStyle[knobs.cornerSmoothing ?? "round"];
341
+ const scaleSmoothing =
342
+ knobs.borderRadius === "none" || knobs.borderRadius === "full" ? undefined : smoothing;
343
+ const containerSmoothing = knobs.borderRadius === "none" ? undefined : smoothing;
344
+
162
345
  const outlined = knobs.fillStyle === "outlined";
163
346
  const elevationToken = elevationMap[knobs.elevation];
164
347
  const textAccentColor = textAccentColorMap[knobs.textAccent] ?? "$color";
165
- const inputBackground = outlined ? "transparent" : "$color6";
348
+ // One input surface (DF-19 / Axiom 5 ONE SOURCE): the knob resolves to the
349
+ // same tier forms' InputContainer uses (formInputColors.background.base =
350
+ // $color2), so Timeline's composer, RichEditor, and form fields agree on
351
+ // what an input surface is. $color6 forked a darker second voice.
352
+ const inputBackground = outlined ? "transparent" : "$color2";
166
353
 
167
354
  const hover = resolveStateKnobs(knobs.hover);
168
355
  const press = resolveStateKnobs(knobs.press);
@@ -172,11 +359,13 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
172
359
  const nonEmpty = <T extends object>(obj: T): T | undefined =>
173
360
  Object.keys(obj).length > 0 ? obj : undefined;
174
361
 
362
+ // DG-STATE-01: hover/press always carry on-color state-layer fills.
363
+ // DG-A11Y-01: focus-visible ring is always present and non-removable.
175
364
  const control: KnobRecipe<ControlStateProps> = {
176
- hoverKnobProps: nonEmpty(hover.control),
177
- pressKnobProps: nonEmpty(press.control),
365
+ hoverKnobProps: withDefaultStateLayer(hover.control, STATE_LAYER_HOVER),
366
+ pressKnobProps: withDefaultStateLayer(press.control, STATE_LAYER_PRESS),
178
367
  focusKnobProps: nonEmpty(focus.control),
179
- focusVisibleKnobProps: nonEmpty(focusVisible.control),
368
+ focusVisibleKnobProps: ensureFocusVisibleRing(focusVisible.control),
180
369
  };
181
370
  const text: KnobRecipe<TextStateProps> = {
182
371
  hoverKnobProps: nonEmpty(hover.text),
@@ -192,10 +381,49 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
192
381
  };
193
382
 
194
383
  const knobProps: KnobProps = {
195
- borderRadius: { borderRadius: radiusToken, borderWidth: borderWidthValue },
196
- borderRadiusOuter: { borderRadius: outerRadiusToken },
384
+ borderRadius: {
385
+ borderRadius: radiusToken,
386
+ borderColor: "$borderColor",
387
+ borderWidth: borderWidthValue,
388
+ ...scaleSmoothing,
389
+ },
390
+ borderRadiusOuter: { borderRadius: outerRadiusToken, ...containerSmoothing },
391
+ // Axiom 1 child-clip cap: padded containers (Card/Panel/DialogContent)
392
+ // spread this after their radius-bearing fragment. Non-container parts
393
+ // keep the uncapped `borderRadius` fragment.
394
+ containerRadius: { borderRadius: containerRadiusValue, ...containerSmoothing },
197
395
  elevation: elevationToken,
198
- surface: { borderWidth: borderWidthValue },
396
+ surface: {
397
+ backgroundColor: "$color1",
398
+ borderColor: "$borderColor",
399
+ borderWidth: borderWidthValue,
400
+ },
401
+ cardSurface: {
402
+ backgroundColor: "$color1",
403
+ borderRadius: containerRadiusValue,
404
+ padding: paddingToken,
405
+ ...containerSmoothing,
406
+ },
407
+ elevatedSurface: {
408
+ backgroundColor: "$color1",
409
+ borderColor: "$borderColor",
410
+ borderWidth: borderWidthValue,
411
+ borderRadius: radiusToken,
412
+ elevation: elevationToken,
413
+ ...scaleSmoothing,
414
+ },
415
+ featureSurface: {
416
+ backgroundColor: "$color1",
417
+ borderRadius: "$10",
418
+ padding: "$6",
419
+ // Bento marketing/auth card shadow: ~0px 9px 12px rgba(0,0,0,0.02) —
420
+ // softer/wider than any elevationMap token, so explicit shadow props.
421
+ shadowColor: "rgba(0,0,0,0.02)",
422
+ shadowOffset: { width: 0, height: 9 },
423
+ shadowOpacity: 1,
424
+ shadowRadius: 12,
425
+ ...smoothing,
426
+ },
199
427
  inputSurface: { borderWidth: borderWidthValue },
200
428
  panelPadding: { padding: paddingToken },
201
429
  gap: { gap: gapToken },
@@ -218,9 +446,25 @@ export function resolveKnobs(knobs: Knobs, override?: KnobPropsOverride): Resolv
218
446
  inputBackground,
219
447
  space: knobs.space,
220
448
  size: knobs.size,
449
+ density: knobs.density,
450
+ // House decisions — behavioral pass-through (no token mapping).
451
+ fieldLabelPlacement: knobs.fieldLabelPlacement,
452
+ requiredMarking: knobs.requiredMarking,
453
+ tableZebra: knobs.tableZebra,
454
+ bulkBarPlacement: knobs.bulkBarPlacement,
455
+ selectAllScope: knobs.selectAllScope,
456
+ timestampStyle: knobs.timestampStyle,
457
+ disabledStyle: knobs.disabledStyle,
458
+ formAutofocus: knobs.formAutofocus,
221
459
  };
222
460
 
223
461
  const finalKnobProps = override ? { ...knobProps, ...override(knobProps, knobs) } : knobProps;
224
462
 
225
- return { knobProps: finalKnobProps, control, text, elevation };
463
+ return {
464
+ knobProps: finalKnobProps,
465
+ control,
466
+ text,
467
+ elevation,
468
+ disabledState: resolveDisabled(knobs.disabledStyle),
469
+ };
226
470
  }
@@ -8,12 +8,35 @@ import type { Preset } from "./preset.types";
8
8
 
9
9
  const presetRegistry = new Map<string, Preset>();
10
10
 
11
+ /** Unknown-preset names already warned about, so a render loop doesn't spam. */
12
+ const warnedUnknownPresets = new Set<string>();
13
+
11
14
  export function registerPreset(name: string, preset: Preset): void {
12
15
  presetRegistry.set(name, preset);
13
16
  }
14
17
 
18
+ /**
19
+ * Look up a registered preset by name.
20
+ *
21
+ * A requested-but-unregistered name is a silent bug otherwise: callers just
22
+ * fall back to the default preset, so e.g. `preset:material` renders
23
+ * byte-identical to `default` with no signal. Warn ONCE per unknown name
24
+ * (empty string and the implicit "default" are legitimate no-ops and stay
25
+ * quiet) so a typo'd or unregistered preset is loud instead of invisible.
26
+ */
15
27
  export function getPreset(name: string): Preset | undefined {
16
- return presetRegistry.get(name);
28
+ const preset = presetRegistry.get(name);
29
+ if (!preset && name && name !== "default" && !warnedUnknownPresets.has(name)) {
30
+ warnedUnknownPresets.add(name);
31
+ const known = getPresetNames();
32
+ console.warn(
33
+ `[@multiplatform.one/theme] Unknown preset "${name}" is not registered — ` +
34
+ `falling back to the default preset. Registered presets: ${
35
+ known.length ? known.join(", ") : "(none)"
36
+ }. Register it with registerPreset("${name}", …) or pass a known name.`,
37
+ );
38
+ }
39
+ return preset;
17
40
  }
18
41
 
19
42
  export function getPresetNames(): string[] {
@@ -22,6 +45,7 @@ export function getPresetNames(): string[] {
22
45
 
23
46
  export function clearPresets(): void {
24
47
  presetRegistry.clear();
48
+ warnedUnknownPresets.clear();
25
49
  }
26
50
 
27
51
  // ── Preset resolution ─────────────────────────────────────────────────────
@@ -34,7 +58,7 @@ export function clearPresets(): void {
34
58
  * using `defaultKnobs`.
35
59
  */
36
60
  export function resolveThemeFromPreset(presetName: string, overrides: Partial<Knobs>): Preset {
37
- const preset = presetRegistry.get(presetName);
61
+ const preset = getPreset(presetName);
38
62
  const base: Preset = preset ?? {
39
63
  theme: "" as ThemeName,
40
64
  knobs: { ...defaultKnobs },
@@ -1,9 +1,9 @@
1
- import { createContext, useContext, useMemo } from "react";
1
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
2
2
  import type { SizeTokens } from "tamagui";
3
3
  import { defaultKnobs, type Knobs } from "./knobs";
4
4
  import type { IntentOverride } from "./preset.types";
5
5
  import type { KnobPropsOverride, ResolvedKnobs } from "./recipes";
6
- import { resolveKnobs } from "./resolveKnobs";
6
+ import { applyDensity, resolveKnobs } from "./resolveKnobs";
7
7
  import { usePresetContext } from "./PresetContext";
8
8
  import { defaultIntents } from "./intents";
9
9
 
@@ -13,13 +13,41 @@ export function useIntentContext(): string | null {
13
13
  return useContext(IntentContext);
14
14
  }
15
15
 
16
+ /** OS `prefers-reduced-motion: reduce` — forces animation=none for A-* decorative motion. */
17
+ function usePrefersReducedMotion(): boolean {
18
+ const [reduced, setReduced] = useState(() => {
19
+ if (typeof window === "undefined" || !window.matchMedia) return false;
20
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
21
+ });
22
+ useEffect(() => {
23
+ if (typeof window === "undefined" || !window.matchMedia) return;
24
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
25
+ const handler = () => setReduced(mq.matches);
26
+ handler();
27
+ // RN / older WebKit: MediaQueryList has addListener, not addEventListener
28
+ if (typeof mq.addEventListener === "function") {
29
+ mq.addEventListener("change", handler);
30
+ return () => mq.removeEventListener("change", handler);
31
+ }
32
+ if (typeof mq.addListener === "function") {
33
+ mq.addListener(handler);
34
+ return () => mq.removeListener?.(handler);
35
+ }
36
+ }, []);
37
+ return reduced;
38
+ }
39
+
16
40
  export interface UseResolvedKnobsOptions {
17
41
  intent?: string;
18
42
  component?: string;
19
43
  override?: KnobPropsOverride;
20
44
  /** Override the size from knobs */
21
45
  size?: SizeTokens;
22
- /** Use compact sizing (steps size down one level) */
46
+ /**
47
+ * Compact sizing override. When set, wins over `knobs.density`:
48
+ * `true` forces compact stepping; `false` forces comfortable.
49
+ * When omitted, the density knob controls compact vs comfortable.
50
+ */
23
51
  compact?: boolean;
24
52
  }
25
53
 
@@ -34,19 +62,13 @@ const sizeTokenToKnobSize: Record<string, Knobs["size"]> = {
34
62
  $true: "medium",
35
63
  };
36
64
 
37
- // Step down size for compact mode
38
- const compactSizeMap: Record<Knobs["size"], Knobs["size"]> = {
39
- small: "small",
40
- medium: "small",
41
- large: "medium",
42
- };
43
-
44
- // Step down space for compact mode
45
- const compactSpaceMap: Record<Knobs["space"], Knobs["space"]> = {
46
- small: "small",
47
- medium: "small",
48
- large: "medium",
49
- };
65
+ // Semantic intents whose Buttons render SOLID (see defaults/builderOptions):
66
+ // the _Button sub-theme surfaces on the hue's step 11 and carries a
67
+ // luminance-computed on-fill foreground as its `color` token (Axiom 15 /
68
+ // LC-05), so filled labels bind `$color` and outlined labels pin to the
69
+ // hue's readable text tier instead of the on-fill foreground. Exported so
70
+ // Button can anchor outlined frames (icon currentColor) the same way.
71
+ export const solidButtonIntents = new Set(["error", "warning", "success"]);
50
72
 
51
73
  /**
52
74
  * Resolve knobs → { knobProps }.
@@ -56,12 +78,14 @@ const compactSpaceMap: Record<Knobs["space"], Knobs["space"]> = {
56
78
  * 2. Merge intent-level overrides (if intent provided or from context)
57
79
  * 3. Merge intent+component overrides (if both provided)
58
80
  * 4. Apply size override if provided
59
- * 5. Apply compact mode (steps size and space down one level)
60
- * 6. Call resolveKnobs(mergedKnobs, override)
81
+ * 5. Apply density (knobs.density, or compact option which wins)
82
+ * 6. OS reduced-motion forces animation="none"
83
+ * 7. Call resolveKnobs(mergedKnobs, override) — override merges last
61
84
  */
62
85
  export function useResolvedKnobs(options?: UseResolvedKnobsOptions): ResolvedKnobs {
63
86
  const presetCtx = usePresetContext();
64
87
  const contextIntent = useIntentContext();
88
+ const prefersReducedMotion = usePrefersReducedMotion();
65
89
 
66
90
  const intent = options?.intent ?? contextIntent ?? undefined;
67
91
  const component = options?.component;
@@ -107,15 +131,32 @@ export function useResolvedKnobs(options?: UseResolvedKnobsOptions): ResolvedKno
107
131
  }
108
132
  }
109
133
 
110
- // Apply compact mode - step down size and space
111
- if (compact) {
112
- merged = {
113
- ...merged,
114
- size: compactSizeMap[merged.size],
115
- space: compactSpaceMap[merged.space],
134
+ // Density knob or per-call compact override (override wins when provided)
135
+ merged = applyDensity(merged, compact);
136
+
137
+ // OS reduced-motion ≡ animation=none for decorative A-* motion (knob consumers).
138
+ if (prefersReducedMotion) {
139
+ merged = { ...merged, animation: "none" };
140
+ }
141
+
142
+ const resolved = resolveKnobs(merged, override);
143
+
144
+ // Semantic-intent Button labels: filled binds `$color` — the sub-theme's
145
+ // luminance-computed on-fill foreground (defaults/builderOptions, Axiom
146
+ // 15 / LC-05) — while outlined pins the hue's readable text tier
147
+ // ($color11 falls through the narrow _Button sub-theme to the intent
148
+ // theme), since the on-fill foreground is illegible on page surfaces.
149
+ if (intent && component === "Button" && solidButtonIntents.has(intent)) {
150
+ const labelColor = merged.fillStyle === "filled" ? "$color" : "$color11";
151
+ return {
152
+ ...resolved,
153
+ knobProps: {
154
+ ...resolved.knobProps,
155
+ body: { ...resolved.knobProps.body, color: labelColor },
156
+ },
116
157
  };
117
158
  }
118
159
 
119
- return resolveKnobs(merged, override);
120
- }, [presetCtx, intent, component, override, sizeOverride, compact]);
160
+ return resolved;
161
+ }, [presetCtx, intent, component, override, sizeOverride, compact, prefersReducedMotion]);
121
162
  }