@multiplatform.one/theme 6.0.4 → 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 -46
  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
@@ -165,6 +165,38 @@ describe("computeOverrides", () => {
165
165
  const overrides = computeOverrides(defaultKnobs, current);
166
166
  expect(Object.keys(overrides)).toEqual(["fillStyle"]);
167
167
  });
168
+
169
+ it("persists density override via mp.ov.* pattern", () => {
170
+ const current: Knobs = { ...defaultKnobs, density: "compact" };
171
+ const overrides = computeOverrides(defaultKnobs, current);
172
+ expect(overrides).toEqual({ density: "compact" });
173
+
174
+ const { toSet } = writeOverridesCookie(overrides);
175
+ expect(toSet[0].key).toBe("mp.ov.0");
176
+ expect(JSON.parse(toSet[0].value)).toEqual({ density: "compact" });
177
+
178
+ const roundtrip = readOverridesCookie({ "mp.ov.0": toSet[0].value });
179
+ expect(roundtrip).toEqual({ density: "compact" });
180
+ });
181
+
182
+ it("persists house-decision overrides via mp.ov.* pattern", () => {
183
+ const current: Knobs = {
184
+ ...defaultKnobs,
185
+ fieldLabelPlacement: "side",
186
+ tableZebra: "on",
187
+ formAutofocus: "on",
188
+ };
189
+ const overrides = computeOverrides(defaultKnobs, current);
190
+ expect(overrides).toEqual({
191
+ fieldLabelPlacement: "side",
192
+ tableZebra: "on",
193
+ formAutofocus: "on",
194
+ });
195
+
196
+ const { toSet } = writeOverridesCookie(overrides);
197
+ expect(JSON.parse(toSet[0].value)).toEqual(overrides);
198
+ expect(readOverridesCookie({ "mp.ov.0": toSet[0].value })).toEqual(overrides);
199
+ });
168
200
  });
169
201
 
170
202
  describe("getCookieWatchList", () => {
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Cookie persistence round-trip specs — the non-React persistence surface
3
+ * (`persistPresetOverrides` / `loadPresetOverrides` / `getCookieWatchList`)
4
+ * that theme devtools and SSR hosts call directly. Locks down: overrides
5
+ * survive a write→read round-trip through document.cookie, oversized
6
+ * payloads chunk across `mp.ov.N` cookies and reassemble, stale chunks are
7
+ * cleaned up, and the SSR path reads from a raw cookie map without touching
8
+ * the document.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, it } from "vitest";
12
+ import type { Knobs } from "./knobs";
13
+ import {
14
+ cookieOverridesPrefix,
15
+ cookiePreset,
16
+ getCookieWatchList,
17
+ loadPresetOverrides,
18
+ persistPresetOverrides,
19
+ } from "./cookies";
20
+
21
+ function clearThemeCookies() {
22
+ for (const key of getCookieWatchList()) {
23
+ document.cookie = `${key}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
24
+ }
25
+ }
26
+
27
+ beforeEach(() => {
28
+ clearThemeCookies();
29
+ });
30
+
31
+ describe("getCookieWatchList", () => {
32
+ it("watches the preset cookie plus every override chunk slot", () => {
33
+ const list = getCookieWatchList();
34
+ expect(list).toContain(cookiePreset);
35
+ expect(list).toContain(`${cookieOverridesPrefix}.0`);
36
+ expect(list).toContain(`${cookieOverridesPrefix}.9`);
37
+ // preset + 10 chunk slots
38
+ expect(list).toHaveLength(11);
39
+ });
40
+ });
41
+
42
+ describe("persistPresetOverrides / loadPresetOverrides", () => {
43
+ it("round-trips small overrides through document cookies", () => {
44
+ persistPresetOverrides({ borderRadius: "large", fillStyle: "filled" } as Partial<Knobs>);
45
+ expect(loadPresetOverrides()).toEqual({ borderRadius: "large", fillStyle: "filled" });
46
+ });
47
+
48
+ it("returns an empty object when nothing is persisted", () => {
49
+ expect(loadPresetOverrides()).toEqual({});
50
+ });
51
+
52
+ it("overwrites previous overrides instead of merging them", () => {
53
+ persistPresetOverrides({ borderRadius: "large" } as Partial<Knobs>);
54
+ persistPresetOverrides({ elevation: "small" } as Partial<Knobs>);
55
+ expect(loadPresetOverrides()).toEqual({ elevation: "small" });
56
+ });
57
+
58
+ it("chunks oversized overrides across multiple cookies and reassembles them", () => {
59
+ const bigValue = "x".repeat(8000);
60
+ persistPresetOverrides({ animation: bigValue } as unknown as Partial<Knobs>);
61
+ // The serialized JSON exceeds the 3.5 KB split threshold → several chunks.
62
+ expect(document.cookie).toContain(`${cookieOverridesPrefix}.0=`);
63
+ expect(document.cookie).toContain(`${cookieOverridesPrefix}.1=`);
64
+ expect(loadPresetOverrides()).toEqual({ animation: bigValue });
65
+ });
66
+
67
+ it("cleans up stale chunk cookies when the payload shrinks", () => {
68
+ const bigValue = "y".repeat(8000);
69
+ persistPresetOverrides({ animation: bigValue } as unknown as Partial<Knobs>);
70
+ persistPresetOverrides({ borderRadius: "small" } as Partial<Knobs>);
71
+ expect(loadPresetOverrides()).toEqual({ borderRadius: "small" });
72
+ expect(document.cookie).not.toContain(`${cookieOverridesPrefix}.1=`);
73
+ });
74
+
75
+ it("reads from a raw cookie map (SSR) without touching the document", () => {
76
+ const raw = {
77
+ [`${cookieOverridesPrefix}.0`]: JSON.stringify({ space: "large" }),
78
+ };
79
+ expect(loadPresetOverrides(raw)).toEqual({ space: "large" });
80
+ });
81
+
82
+ it("treats corrupted cookie JSON as no overrides", () => {
83
+ const raw = { [`${cookieOverridesPrefix}.0`]: "{broken" };
84
+ expect(loadPresetOverrides(raw)).toEqual({});
85
+ });
86
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OPTICS corner smoothing (Axiom 15) — native twin of `cornerSmoothing.ts`.
3
+ *
4
+ * React Native has no `corner-shape`; native resolves `cornerSmoothing` to
5
+ * `round` (resolveKnobs emits no class on native) until a SquircleView
6
+ * adoption lands (phase 2). Exports stay shape-compatible for shared code.
7
+ */
8
+
9
+ /** Class attached on web only — never emitted into native fragments. */
10
+ export const cornerSmoothClassName = "mp-corner-smooth";
11
+
12
+ /** Web-only stylesheet — empty on native. */
13
+ export const cornerSmoothingCss = "";
14
+
15
+ /** Web-only stylesheet mount — no-op on native. */
16
+ export function ensureCornerSmoothingStyles(): void {}
@@ -0,0 +1,27 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ cornerSmoothClassName,
4
+ cornerSmoothingCss,
5
+ ensureCornerSmoothingStyles,
6
+ } from "./cornerSmoothing";
7
+
8
+ describe("cornerSmoothing (Axiom 15 OPTICS)", () => {
9
+ it("scopes the squircle declaration to the fragment class", () => {
10
+ expect(cornerSmoothClassName).toBe("mp-corner-smooth");
11
+ expect(cornerSmoothingCss).toBe(".mp-corner-smooth { corner-shape: squircle; }");
12
+ });
13
+
14
+ describe("stylesheet mount", () => {
15
+ beforeEach(() => {
16
+ document.getElementById("mp-corner-smoothing-styles")?.remove();
17
+ });
18
+
19
+ it("mounts the stylesheet once (idempotent)", () => {
20
+ ensureCornerSmoothingStyles();
21
+ ensureCornerSmoothingStyles();
22
+ const tags = document.querySelectorAll("#mp-corner-smoothing-styles");
23
+ expect(tags).toHaveLength(1);
24
+ expect(tags[0]?.textContent).toBe(cornerSmoothingCss);
25
+ });
26
+ });
27
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * OPTICS corner smoothing (Axiom 15): `cornerSmoothing: "smooth"` upgrades
3
+ * circular-arc corners to continuous curvature — CSS
4
+ * `corner-shape: squircle` (≡ `superellipse(2)`, ≈ Apple's continuous
5
+ * corners / Figma smoothing 0.6) — as a WEB PROGRESSIVE ENHANCEMENT.
6
+ * Chromium 139+ paints it; every other engine drops the unknown declaration
7
+ * and keeps today's plain `border-radius` arc, so no `@supports` gate is
8
+ * needed for correctness.
9
+ *
10
+ * Delivery is a class + one mounted stylesheet (the hairline pattern):
11
+ * Tamagui strips unknown keys from the `style` prop (`style` is in
12
+ * skipProps and `cornerShape` is not a valid style key), but merges
13
+ * `className` into its atomic classes — so `resolveKnobs` attaches
14
+ * `className: cornerSmoothClassName` to every radius-bearing fragment when
15
+ * the knob is `smooth`, and this module owns the CSS those classes resolve
16
+ * against.
17
+ *
18
+ * Native twin: `cornerSmoothing.native.ts` (no CSS classes — native resolves
19
+ * to `round` until a SquircleView adoption lands, phase 2).
20
+ */
21
+
22
+ /** Class attached to radius-bearing knob fragments at `cornerSmoothing: "smooth"`. */
23
+ export const cornerSmoothClassName = "mp-corner-smooth";
24
+
25
+ const STYLE_TAG_ID = "mp-corner-smoothing-styles";
26
+
27
+ /**
28
+ * The squircle declaration. `corner-shape` composes with the element's
29
+ * existing `border-radius` (it reshapes the same corner box), so the
30
+ * fallback in non-supporting engines is automatic. Exported for unit tests.
31
+ */
32
+ export const cornerSmoothingCss = `.${cornerSmoothClassName} { corner-shape: squircle; }`;
33
+
34
+ /** Idempotently mount the corner-smoothing stylesheet (no-op off-DOM). */
35
+ export function ensureCornerSmoothingStyles(): void {
36
+ if (typeof document === "undefined") return;
37
+ if (document.getElementById(STYLE_TAG_ID)) return;
38
+ const tag = document.createElement("style");
39
+ tag.id = STYLE_TAG_ID;
40
+ tag.textContent = cornerSmoothingCss;
41
+ document.head.appendChild(tag);
42
+ }
43
+
44
+ // Static CSS (no knob/theme dependency) — mount as soon as the module loads
45
+ // in a DOM environment so the class resolves before any smooth render.
46
+ ensureCornerSmoothingStyles();
@@ -9,17 +9,21 @@ import {
9
9
  type ThemeBuilderDefinition,
10
10
  createThemesBuilder,
11
11
  } from "./createThemes";
12
+ import { defaultAccentTheme } from "./defaults/accent";
13
+ import { defaultBaseTheme } from "./defaults/base";
14
+ import { defaultBuilderOptions } from "./defaults/builderOptions";
15
+ import { defaultBodyFont, defaultHeadingFont } from "./defaults/fonts";
12
16
  import type { Preset } from "./preset.types";
13
17
  import { type ThemeConfig, createThemeConfig } from "./shared";
14
18
 
15
19
  export interface CreateDefaultThemeConfigOptions {
16
- /** Base theme palette (dark/light with optional shadows) */
17
- base: BaseThemeBuilderDefinition;
18
- /** Accent theme palette (defaults to base if not provided) */
20
+ /** Base theme palette (dark/light with optional shadows; defaults to the built-in neutral ramps) */
21
+ base?: BaseThemeBuilderDefinition;
22
+ /** Accent theme palette (defaults to the built-in accent ramps) */
19
23
  accent?: ThemeBuilderDefinition;
20
24
  /** Map of extra named themes with their palettes */
21
25
  additionalThemes?: Record<string, ThemeBuilderDefinition>;
22
- /** Font definitions */
26
+ /** Font definitions (merged over the default heading/body fonts) */
23
27
  fonts?: Record<string, GenericFont>;
24
28
  /** Animation config (defaults to framework reanimated animations) */
25
29
  animations?: CreateTamaguiProps["animations"];
@@ -35,9 +39,24 @@ export interface CreateDefaultThemeConfigOptions {
35
39
  defaultPreset?: string;
36
40
  }
37
41
 
38
- export function createDefaultThemeConfig(options: CreateDefaultThemeConfigOptions): ThemeConfig {
39
- const accent = options.accent ?? options.base;
40
- const themesBuilder = createThemesBuilder(options.base, accent, options.builderOptions);
42
+ /**
43
+ * Builds a complete Tamagui theme config with batteries included: calling it
44
+ * with zero options yields the framework's polished defaults — the neutral
45
+ * Radix-mauve-curve base ramps, the solid-surface brand accent ramps, Inter
46
+ * heading/body fonts and the interaction-ramp `getTheme` theme builder.
47
+ * Every option overrides its piece independently: `base`, `accent` and
48
+ * `builderOptions` each replace their default, while `fonts` are spread OVER
49
+ * the defaults so a partial font map keeps the default heading/body.
50
+ */
51
+ export function createDefaultThemeConfig(
52
+ options: CreateDefaultThemeConfigOptions = {},
53
+ ): ThemeConfig {
54
+ const base = options.base ?? defaultBaseTheme;
55
+ const accent = options.accent ?? defaultAccentTheme;
56
+ const themesBuilder = createThemesBuilder(base, accent, {
57
+ ...defaultBuilderOptions,
58
+ ...options.builderOptions,
59
+ });
41
60
  const builtThemes: Record<string, Record<string, string>> = {
42
61
  ...themesBuilder.themes(),
43
62
  };
@@ -57,7 +76,12 @@ export function createDefaultThemeConfig(options: CreateDefaultThemeConfigOption
57
76
  ...defaultConfig,
58
77
  animations: options.animations ?? animations,
59
78
  disableSSR: false,
60
- fonts: options.fonts ?? defaultConfig.fonts,
79
+ fonts: {
80
+ ...defaultConfig.fonts,
81
+ heading: defaultHeadingFont,
82
+ body: defaultBodyFont,
83
+ ...options.fonts,
84
+ },
61
85
  shorthands,
62
86
  shouldAddPrefersColorThemes: true,
63
87
  themeClassNameOnRoot: true,
@@ -0,0 +1,137 @@
1
+ /**
2
+ * createThemeConfig specs — the app-level config factory. Locks down the
3
+ * `themeColors` extraction contract (a color exists only when BOTH
4
+ * light_/dark_ variants exist, semantic/state sub-themes are excluded,
5
+ * output is sorted), the root-theme-class settings defaults, and preset
6
+ * registration through the shared registry.
7
+ *
8
+ * `createTamagui` is mocked pass-through: this spec pins OUR factory's
9
+ * behavior, not Tamagui's config processing.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ vi.mock("tamagui", () => ({
15
+ // Identity-ish mock: createThemeConfig only forwards the object and reads
16
+ // nothing back from Tamagui's processing.
17
+ createTamagui: vi.fn((config: Record<string, unknown>) => config),
18
+ }));
19
+
20
+ import { createTamagui } from "tamagui";
21
+ import type { ThemeName } from "@tamagui/web";
22
+ import { defaultKnobs } from "./knobs";
23
+ import type { Preset } from "./preset.types";
24
+ import { clearPresets, createThemeConfig, getPreset, getPresetNames } from "./shared";
25
+
26
+ const mockedCreateTamagui = vi.mocked(createTamagui);
27
+
28
+ function makeThemes(names: string[]): Record<string, object> {
29
+ return Object.fromEntries(names.map((name) => [name, {}]));
30
+ }
31
+
32
+ function makePreset(): Preset {
33
+ return {
34
+ theme: "light" as ThemeName,
35
+ knobs: { ...defaultKnobs },
36
+ intents: {},
37
+ tints: [],
38
+ };
39
+ }
40
+
41
+ beforeEach(() => {
42
+ clearPresets();
43
+ mockedCreateTamagui.mockClear();
44
+ });
45
+
46
+ describe("createThemeConfig — themeColors extraction", () => {
47
+ it("extracts colors that exist in both light and dark variants, sorted", () => {
48
+ const config = createThemeConfig({
49
+ themes: makeThemes(["light_blue", "dark_blue", "light_red", "dark_red", "light", "dark"]),
50
+ } as never);
51
+ expect(config.themeColors).toEqual(["blue", "red"]);
52
+ });
53
+
54
+ it("drops colors missing one scheme", () => {
55
+ const config = createThemeConfig({
56
+ themes: makeThemes(["light_blue", "dark_blue", "light_orphan"]),
57
+ } as never);
58
+ expect(config.themeColors).toEqual(["blue"]);
59
+ });
60
+
61
+ it("excludes semantic and state sub-themes from the picker list", () => {
62
+ const config = createThemeConfig({
63
+ themes: makeThemes([
64
+ "light_blue",
65
+ "dark_blue",
66
+ "light_error",
67
+ "dark_error",
68
+ "light_success",
69
+ "dark_success",
70
+ "light_warning",
71
+ "dark_warning",
72
+ "light_accent",
73
+ "dark_accent",
74
+ "light_active",
75
+ "dark_active",
76
+ "light_alt1",
77
+ "dark_alt1",
78
+ "light_alt2",
79
+ "dark_alt2",
80
+ ]),
81
+ } as never);
82
+ expect(config.themeColors).toEqual(["blue"]);
83
+ });
84
+
85
+ it("ignores mixed-case and nested sub-theme names", () => {
86
+ const config = createThemeConfig({
87
+ themes: makeThemes([
88
+ "light_blue",
89
+ "dark_blue",
90
+ "light_Blue",
91
+ "dark_Blue",
92
+ "light_blue_Button",
93
+ "dark_blue_Button",
94
+ ]),
95
+ } as never);
96
+ expect(config.themeColors).toEqual(["blue"]);
97
+ });
98
+
99
+ it("returns an empty list when the config carries no themes", () => {
100
+ const config = createThemeConfig({} as never);
101
+ expect(config.themeColors).toEqual([]);
102
+ });
103
+ });
104
+
105
+ describe("createThemeConfig — settings and registration", () => {
106
+ it("defaults the root theme class settings on and keeps user settings override", () => {
107
+ createThemeConfig({ themes: {} } as never);
108
+ const forwarded = mockedCreateTamagui.mock.calls[0][0] as Record<string, any>;
109
+ expect(forwarded.settings.disableRootThemeClass).toBe(false);
110
+ expect(forwarded.settings.themeClassNameOnRoot).toBe(true);
111
+
112
+ mockedCreateTamagui.mockClear();
113
+ createThemeConfig({
114
+ themes: {},
115
+ settings: { themeClassNameOnRoot: false },
116
+ } as never);
117
+ const overridden = mockedCreateTamagui.mock.calls[0][0] as Record<string, any>;
118
+ expect(overridden.settings.themeClassNameOnRoot).toBe(false);
119
+ });
120
+
121
+ it("registers provided presets in the shared registry", () => {
122
+ const material = makePreset();
123
+ const config = createThemeConfig({ themes: {} } as never, {
124
+ presets: { material },
125
+ defaultPreset: "material",
126
+ });
127
+ expect(getPresetNames()).toContain("material");
128
+ expect(getPreset("material")).toBe(material);
129
+ expect(config.defaultPreset).toBe("material");
130
+ expect(config.presets).toEqual({ material });
131
+ });
132
+
133
+ it("registers nothing without presets", () => {
134
+ createThemeConfig({ themes: {} } as never);
135
+ expect(getPresetNames()).toEqual([]);
136
+ });
137
+ });
@@ -58,6 +58,81 @@ export interface CreateThemesBuilderOptions {
58
58
  getTheme?: (props: GetThemeProps) => Record<string, string | number>;
59
59
  }
60
60
 
61
+ // Radix hue names whose sub-themes act as decorative TINTS (Tint.tsx cycling,
62
+ // the storybook `color` global, `theme="red"` field tinting). Under a tint,
63
+ // surfaces/borders/solids keep the hue but TEXT must stay readable neutral:
64
+ // muted/secondary text and user-authored content must never render saturated
65
+ // accent (SB-D-14/SB-D-20). Exported so theme-identity consumers (chart
66
+ // palette) share the one tint registry.
67
+ export const tintHueNames = new Set([
68
+ "amber",
69
+ "blue",
70
+ "bronze",
71
+ "brown",
72
+ "crimson",
73
+ "cyan",
74
+ "gold",
75
+ "grass",
76
+ "gray",
77
+ "green",
78
+ "indigo",
79
+ "lime",
80
+ "mauve",
81
+ "mint",
82
+ "olive",
83
+ "orange",
84
+ "pink",
85
+ "plum",
86
+ "purple",
87
+ "red",
88
+ "sage",
89
+ "sand",
90
+ "sky",
91
+ "slate",
92
+ "teal",
93
+ "tomato",
94
+ "violet",
95
+ "yellow",
96
+ ]);
97
+
98
+ // Text-tier tokens re-anchored to the neutral scheme ramp inside tint themes.
99
+ const tintNeutralTextKeys = [
100
+ "color",
101
+ "colorHover",
102
+ "colorPress",
103
+ "colorFocus",
104
+ "color11",
105
+ "color12",
106
+ "placeholderColor",
107
+ ] as const;
108
+
109
+ /**
110
+ * Rebuild tint sub-themes (light_purple, dark_red_active, ...) so their
111
+ * text tiers come from the neutral base scheme while backgrounds, borders
112
+ * and solid steps (1-10) keep the hue. Only keys the tint theme already
113
+ * defines are replaced; returns new objects, never mutates inputs.
114
+ */
115
+ function neutralizeTintText(
116
+ themes: Record<string, Record<string, string>>,
117
+ ): Record<string, Record<string, string>> {
118
+ const out: Record<string, Record<string, string>> = { ...themes };
119
+ for (const [name, theme] of Object.entries(themes)) {
120
+ const parts = name.split("_");
121
+ if (parts.length < 2) continue;
122
+ const scheme = parts[0];
123
+ if (scheme !== "light" && scheme !== "dark") continue;
124
+ if (!tintHueNames.has(parts[1])) continue;
125
+ const base = themes[scheme];
126
+ if (!base) continue;
127
+ const patched: Record<string, string> = { ...theme };
128
+ for (const key of tintNeutralTextKeys) {
129
+ if (key in patched && base[key] != null) patched[key] = base[key];
130
+ }
131
+ out[name] = patched;
132
+ }
133
+ return out;
134
+ }
135
+
61
136
  const defaultLightShadows: Shadows = {
62
137
  shadow1: "rgba(0,0,0,0.12)",
63
138
  shadow2: "rgba(0,0,0,0.16)",
@@ -248,11 +323,11 @@ export function createThemesBuilder(
248
323
  const initialTheme = tamaguiCreateThemes(
249
324
  createThemesProps as Parameters<typeof tamaguiCreateThemes>[0],
250
325
  );
251
- const themes = {
326
+ const themes = neutralizeTintText({
252
327
  ...grayTheme,
253
328
  ...tamaguiThemes,
254
329
  ...initialTheme,
255
- };
330
+ });
256
331
  return {
257
332
  themes() {
258
333
  return themes;
@@ -0,0 +1,39 @@
1
+ import type { ThemeBuilderDefinition } from "../createThemes";
2
+
3
+ export const defaultAccentTheme: ThemeBuilderDefinition = {
4
+ darkPalette: [
5
+ "hsla(250, 50%, 35%, 1)",
6
+ "hsla(250, 50%, 38%, 1)",
7
+ "hsla(250, 50%, 41%, 1)",
8
+ "hsla(250, 50%, 43%, 1)",
9
+ "hsla(250, 50%, 46%, 1)",
10
+ "hsla(250, 50%, 49%, 1)",
11
+ "hsla(250, 50%, 52%, 1)",
12
+ "hsla(250, 50%, 54%, 1)",
13
+ "hsla(250, 50%, 57%, 1)",
14
+ "hsla(250, 50%, 60%, 1)",
15
+ "hsla(250, 50%, 90%, 1)",
16
+ "hsla(250, 50%, 95%, 1)",
17
+ ],
18
+ // Mirrors the darkPalette's shape: SOLID brand surfaces across [0..9] with
19
+ // near-white foreground anchors at [10..11]. The theme-builder takes
20
+ // palette[11] as the sub-theme `color` and palette[3] as the Button
21
+ // surface — the old ramp (95%->40%, no light anchor) put 40%-purple text
22
+ // on a 62%-purple button (2.3:1). Solid-with-white matches how tamagui v4
23
+ // ships its accent (bg 9%, color white, 17.9:1); here: button 48%/98%
24
+ // = 7.7:1.
25
+ lightPalette: [
26
+ "hsla(250, 50%, 62%, 1)",
27
+ "hsla(250, 50%, 58%, 1)",
28
+ "hsla(250, 50%, 54%, 1)",
29
+ "hsla(250, 50%, 48%, 1)",
30
+ "hsla(250, 50%, 46%, 1)",
31
+ "hsla(250, 50%, 44%, 1)",
32
+ "hsla(250, 50%, 42%, 1)",
33
+ "hsla(250, 50%, 40%, 1)",
34
+ "hsla(250, 50%, 38%, 1)",
35
+ "hsla(250, 50%, 36%, 1)",
36
+ "hsla(250, 50%, 95%, 1)",
37
+ "hsla(250, 50%, 98%, 1)",
38
+ ],
39
+ };
@@ -0,0 +1,63 @@
1
+ import type { BaseThemeBuilderDefinition } from "../createThemes";
2
+
3
+ export const defaultBaseTheme: BaseThemeBuilderDefinition = {
4
+ // Radix-mauve-shaped neutral ramps with a slight purple tint (hue 270)
5
+ // harmonizing with the hsla(250) accent: near-white surfaces (1-2),
6
+ // component/hover steps (3-5), borders (6-8), solid/muted (9-10),
7
+ // readable text (11-12).
8
+ //
9
+ // Step 10 (muted-ink tier) is the catalog's de-facto "muted text" step —
10
+ // placeholderColor, and ~20 components/stories set `color="$color10"` on
11
+ // hints, captions, counts, subtitles and placeholders (UX-P01/UX-015).
12
+ // At its old lightness (light 52% / dark 48%) it measured 3.76–3.98:1 on
13
+ // color1/color2 — under the AA 4.5:1 floor for normal text (Axiom 12
14
+ // LEGIBLE FLOOR). Re-anchored to light 46% / dark 54%, the lowest change
15
+ // that clears 4.5:1 on BOTH page surfaces in both schemes (light 4.9/4.7,
16
+ // dark 4.8/4.6) while staying a distinct tier above color11. Its non-text
17
+ // roles (boundary rings, spinners, slider thumb — all ≥3:1 targets) only
18
+ // gain contrast, so the shift is one-directional safe.
19
+ darkPalette: [
20
+ "hsla(270, 6%, 8.7%, 1)",
21
+ "hsla(270, 6%, 10.5%, 1)",
22
+ "hsla(270, 6%, 13.5%, 1)",
23
+ "hsla(270, 6%, 16%, 1)",
24
+ "hsla(270, 6%, 18.5%, 1)",
25
+ "hsla(270, 6%, 21.5%, 1)",
26
+ "hsla(270, 6%, 25.5%, 1)",
27
+ "hsla(270, 6%, 32%, 1)",
28
+ "hsla(270, 6%, 43%, 1)",
29
+ "hsla(270, 6%, 54%, 1)",
30
+ "hsla(270, 6%, 62.5%, 1)",
31
+ "hsla(270, 6%, 94%, 1)",
32
+ ],
33
+ lightPalette: [
34
+ "hsla(270, 6%, 99.5%, 1)",
35
+ "hsla(270, 6%, 98.2%, 1)",
36
+ "hsla(270, 6%, 95.2%, 1)",
37
+ "hsla(270, 6%, 92.5%, 1)",
38
+ "hsla(270, 6%, 90%, 1)",
39
+ "hsla(270, 6%, 87%, 1)",
40
+ "hsla(270, 6%, 82.5%, 1)",
41
+ "hsla(270, 6%, 75%, 1)",
42
+ "hsla(270, 6%, 56.5%, 1)",
43
+ "hsla(270, 6%, 46%, 1)",
44
+ "hsla(270, 6%, 40%, 1)",
45
+ "hsla(270, 6%, 14%, 1)",
46
+ ],
47
+ lightShadows: {
48
+ shadow1: "rgba(0,0,0,0.04)",
49
+ shadow2: "rgba(0,0,0,0.08)",
50
+ shadow3: "rgba(0,0,0,0.16)",
51
+ shadow4: "rgba(0,0,0,0.24)",
52
+ shadow5: "rgba(0,0,0,0.32)",
53
+ shadow6: "rgba(0,0,0,0.4)",
54
+ },
55
+ darkShadows: {
56
+ shadow1: "rgba(0,0,0,0.2)",
57
+ shadow2: "rgba(0,0,0,0.3)",
58
+ shadow3: "rgba(0,0,0,0.4)",
59
+ shadow4: "rgba(0,0,0,0.5)",
60
+ shadow5: "rgba(0,0,0,0.6)",
61
+ shadow6: "rgba(0,0,0,0.7)",
62
+ },
63
+ };