@multiplatform.one/theme 6.6.0 → 7.0.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 (71) hide show
  1. package/package.json +18 -18
  2. package/src/dates.spec.ts +344 -1
  3. package/src/dates.ts +50 -3
  4. package/src/devWarn.ts +29 -1
  5. package/src/figma/figmaTokens.spec.ts +3 -3
  6. package/src/figma/figmaTokens.ts +6 -3
  7. package/src/keyboardFocusRing.ts +61 -5
  8. package/src/menuRow.spec.ts +2 -2
  9. package/src/menuRow.ts +5 -4
  10. package/src/theme/Surface.spec.tsx +146 -0
  11. package/src/theme/Surface.tsx +73 -0
  12. package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
  13. package/src/theme/createDefaultThemeConfig.ts +47 -3
  14. package/src/theme/createThemes.ts +27 -4
  15. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
  16. package/src/theme/focusState.ts +9 -1
  17. package/src/theme/groupGeometry.spec.ts +108 -0
  18. package/src/theme/groupGeometry.ts +119 -0
  19. package/src/theme/index.ts +3 -0
  20. package/src/theme/intent.spec.tsx +17 -0
  21. package/src/theme/knobs.ts +2 -2
  22. package/src/theme/layoutTokens.spec.ts +14 -0
  23. package/src/theme/layoutTokens.ts +29 -0
  24. package/src/theme/recipeInputs.ts +368 -0
  25. package/src/theme/recipes.ts +34 -0
  26. package/src/theme/resolveKnobs.spec.ts +91 -9
  27. package/src/theme/resolveKnobs.ts +55 -5
  28. package/src/theme/shared.tsx +11 -0
  29. package/src/theme/sizeRecipes.spec.ts +296 -0
  30. package/src/theme/sizeRecipes.ts +198 -0
  31. package/src/theme/useResolvedKnobs.ts +76 -24
  32. package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
  33. package/types/dates.d.ts +36 -2
  34. package/types/dates.d.ts.map +1 -1
  35. package/types/devWarn.d.ts +16 -1
  36. package/types/devWarn.d.ts.map +1 -1
  37. package/types/figma/figmaTokens.d.ts +1 -1
  38. package/types/figma/figmaTokens.d.ts.map +1 -1
  39. package/types/keyboardFocusRing.d.ts +19 -4
  40. package/types/keyboardFocusRing.d.ts.map +1 -1
  41. package/types/menuRow.d.ts +5 -4
  42. package/types/menuRow.d.ts.map +1 -1
  43. package/types/theme/Surface.d.ts +37 -0
  44. package/types/theme/Surface.d.ts.map +1 -0
  45. package/types/theme/createDefaultThemeConfig.d.ts +14 -3
  46. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  47. package/types/theme/createThemes.d.ts.map +1 -1
  48. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  49. package/types/theme/focusState.d.ts +8 -0
  50. package/types/theme/focusState.d.ts.map +1 -1
  51. package/types/theme/groupGeometry.d.ts +69 -0
  52. package/types/theme/groupGeometry.d.ts.map +1 -0
  53. package/types/theme/index.d.ts +3 -0
  54. package/types/theme/index.d.ts.map +1 -1
  55. package/types/theme/knobs.d.ts +2 -2
  56. package/types/theme/knobs.d.ts.map +1 -1
  57. package/types/theme/layoutTokens.d.ts +22 -0
  58. package/types/theme/layoutTokens.d.ts.map +1 -1
  59. package/types/theme/recipeInputs.d.ts +110 -0
  60. package/types/theme/recipeInputs.d.ts.map +1 -0
  61. package/types/theme/recipes.d.ts +47 -0
  62. package/types/theme/recipes.d.ts.map +1 -1
  63. package/types/theme/resolveKnobs.d.ts +7 -3
  64. package/types/theme/resolveKnobs.d.ts.map +1 -1
  65. package/types/theme/shared.d.ts +9 -0
  66. package/types/theme/shared.d.ts.map +1 -1
  67. package/types/theme/sizeRecipes.d.ts +68 -0
  68. package/types/theme/sizeRecipes.d.ts.map +1 -0
  69. package/types/theme/useResolvedKnobs.d.ts +10 -6
  70. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  71. package/src/audit/baselines/home-pokemon.json +0 -355
@@ -36,10 +36,66 @@ export function wasKeyboardFocus(): boolean {
36
36
  return lastInputWasKeyboard;
37
37
  }
38
38
 
39
- /** Standard manual ring props (mirrors the focusVisibleStyle contract). */
39
+ import { FOCUS_VISIBLE_RING } from "./theme/focusState";
40
+
41
+ /**
42
+ * Standard manual ring props — the SAME geometry as `FOCUS_VISIBLE_RING`
43
+ * (2px solid `$outlineColor`, inset −2), spread here so manually-painted
44
+ * rings and `focusVisibleStyle` rings can never drift apart.
45
+ */
40
46
  export const keyboardFocusRingProps = {
41
- outlineColor: "$outlineColor",
42
- outlineWidth: 2,
43
- outlineStyle: "solid",
44
- outlineOffset: -2,
47
+ ...FOCUS_VISIBLE_RING,
45
48
  } as const;
49
+
50
+ /**
51
+ * LC-71 RING-ANATOMY — CSS-synchronous ring on a composite's outer boundary.
52
+ *
53
+ * `:has(:focus-visible)` paints the same frame the keyboard heuristic would,
54
+ * with zero React latency (the prior `focused` JS path lagged one frame and
55
+ * spawned the inner-Area ring cascade). Inner targets suppress their own
56
+ * outline so the box carries exactly one ring. Class: `mp-composite-ring`.
57
+ */
58
+ export const compositeFocusRingCss = `/* LC-71: ring on the composite outer box; never on the inner field.
59
+ Inset (−2px): these frames use overflow:hidden to clip children to radius,
60
+ which severs an outer ring. Do NOT use :has(:focus-visible) on every
61
+ descendant — that would ring the whole SearchInput when the clear ✕ is
62
+ focused (two focusables → two rings, chip-dismiss rule). */
63
+ .mp-composite-ring:focus-visible,
64
+ .mp-composite-ring:has(input:focus),
65
+ .mp-composite-ring:has(textarea:focus),
66
+ .mp-composite-ring:has(.mp-input-area:focus) {
67
+ outline: 2px solid var(--outlineColor, var(--c-outlineColor, CanvasText));
68
+ outline-offset: -2px;
69
+ }
70
+ .mp-composite-ring-deep:has(:focus-visible) {
71
+ outline: 2px solid var(--outlineColor, var(--c-outlineColor, CanvasText));
72
+ outline-offset: -2px;
73
+ }
74
+ .mp-composite-ring input,
75
+ .mp-composite-ring textarea,
76
+ .mp-composite-ring [contenteditable],
77
+ .mp-input-area,
78
+ .mp-input-area:focus,
79
+ .mp-input-area:focus-visible {
80
+ outline: none !important;
81
+ box-shadow: none !important;
82
+ }
83
+ .mp-chip-dismiss:focus-visible {
84
+ outline: none !important;
85
+ }
86
+ .mp-chip-dismiss:focus-visible .mp-chip-dismiss-ring {
87
+ outline: 2px solid var(--outlineColor, var(--c-outlineColor, CanvasText));
88
+ outline-offset: 2px;
89
+ }`;
90
+
91
+ const COMPOSITE_RING_STYLE_ID = "mp-composite-focus-ring";
92
+
93
+ /** Idempotently mount the composite-boundary focus-ring stylesheet. */
94
+ export function ensureCompositeFocusRing(): void {
95
+ if (typeof document === "undefined") return;
96
+ if (document.getElementById(COMPOSITE_RING_STYLE_ID)) return;
97
+ const tag = document.createElement("style");
98
+ tag.id = COMPOSITE_RING_STYLE_ID;
99
+ tag.textContent = compositeFocusRingCss;
100
+ document.head.appendChild(tag);
101
+ }
@@ -11,8 +11,8 @@ describe("menuRowFrame (LC-16 shared menu-row geometry)", () => {
11
11
  expect(menuRowFrame.borderRadius).toBe(0);
12
12
  });
13
13
 
14
- it("suppresses the focus outline (row surface highlight is the indicator)", () => {
15
- expect(menuRowFrame.outlineStyle).toBe("none");
14
+ it("does not delete the focus ring (LC-71 — fill and ring are orthogonal)", () => {
15
+ expect(menuRowFrame).not.toHaveProperty("outlineStyle");
16
16
  });
17
17
 
18
18
  it("is a centered row with a transparent base surface", () => {
package/src/menuRow.ts CHANGED
@@ -4,9 +4,11 @@
4
4
  * One geometry recipe for every menu/listbox row (DropdownMenu `ItemRow`,
5
5
  * Select `SelectRow`, MentionInput `MentionSuggestionItem`): the row spans
6
6
  * the full overlay width (edge-to-edge highlight, SP-EDGE), owns its
7
- * horizontal padding, keeps flat corners (the overlay container clips to the
8
- * radius knob), and suppresses the focus outline — the row surface highlight
9
- * IS the focus/active indicator.
7
+ * horizontal padding, and keeps flat corners (the overlay container clips to
8
+ * the radius knob — LC-72 CONTAINER-CLIP). LC-71 RING-ANATOMY: the highlight
9
+ * fill and the focus ring are orthogonal; consumers paint
10
+ * `ensureFocusVisibleRing({ outlineOffset: -2 })` on `focusVisibleStyle`
11
+ * (inset so the overlay clip does not sever it). Do not delete the ring.
10
12
  *
11
13
  * It lives in `@multiplatform.one/theme` (not `components`) because the
12
14
  * dependency direction is `components` → `forms` → `theme`; the two forms
@@ -27,5 +29,4 @@ export const menuRowFrame = {
27
29
  borderRadius: 0,
28
30
  userSelect: "none",
29
31
  backgroundColor: "transparent",
30
- outlineStyle: "none" as any,
31
32
  } as const;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Surface context — nested size/density step-down (LC-68).
3
+ */
4
+
5
+ import { render, renderHook } from "@testing-library/react";
6
+ import { createElement, type ReactNode } from "react";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import {
9
+ Surface,
10
+ SurfaceContext,
11
+ clampSurfaceDensity,
12
+ clampSurfaceSize,
13
+ type SurfaceIntent,
14
+ } from "./Surface";
15
+ import { useResolvedKnobs } from "./useResolvedKnobs";
16
+
17
+ let originalMatchMedia: typeof window.matchMedia;
18
+
19
+ beforeEach(() => {
20
+ originalMatchMedia = window.matchMedia;
21
+ window.matchMedia = vi.fn(
22
+ () =>
23
+ ({
24
+ matches: false,
25
+ addEventListener: vi.fn(),
26
+ removeEventListener: vi.fn(),
27
+ }) as unknown as MediaQueryList,
28
+ ) as typeof window.matchMedia;
29
+ });
30
+
31
+ afterEach(() => {
32
+ window.matchMedia = originalMatchMedia;
33
+ });
34
+
35
+ function Probe({ onRead }: { onRead: (intent: SurfaceIntent) => void }) {
36
+ onRead(SurfaceContext.useStyledContext());
37
+ return null;
38
+ }
39
+
40
+ function renderNested(
41
+ outer: { size?: "small" | "medium" | "large"; density?: "compact" | "comfortable" },
42
+ inner: { size?: "small" | "medium" | "large"; density?: "compact" | "comfortable" },
43
+ ): SurfaceIntent {
44
+ let intent: SurfaceIntent | undefined;
45
+ render(
46
+ createElement(
47
+ Surface,
48
+ outer,
49
+ createElement(Surface, inner, createElement(Probe, { onRead: (value) => (intent = value) })),
50
+ ),
51
+ );
52
+ if (!intent) throw new Error("Surface probe did not render");
53
+ return intent;
54
+ }
55
+
56
+ describe("clampSurfaceSize", () => {
57
+ it("unset parent is no ceiling", () => {
58
+ expect(clampSurfaceSize("large", "unset")).toBe("large");
59
+ expect(clampSurfaceSize("unset", "unset")).toBe("unset");
60
+ });
61
+
62
+ it("unset requested inherits parent", () => {
63
+ expect(clampSurfaceSize("unset", "small")).toBe("small");
64
+ expect(clampSurfaceSize("unset", "large")).toBe("large");
65
+ });
66
+
67
+ it("never exceeds parent", () => {
68
+ expect(clampSurfaceSize("large", "small")).toBe("small");
69
+ expect(clampSurfaceSize("medium", "small")).toBe("small");
70
+ expect(clampSurfaceSize("large", "medium")).toBe("medium");
71
+ });
72
+
73
+ it("tighter than parent is allowed", () => {
74
+ expect(clampSurfaceSize("small", "large")).toBe("small");
75
+ expect(clampSurfaceSize("medium", "large")).toBe("medium");
76
+ expect(clampSurfaceSize("small", "medium")).toBe("small");
77
+ });
78
+ });
79
+
80
+ describe("clampSurfaceDensity", () => {
81
+ it("unset parent is no ceiling", () => {
82
+ expect(clampSurfaceDensity("comfortable", "unset")).toBe("comfortable");
83
+ });
84
+
85
+ it("unset requested inherits parent", () => {
86
+ expect(clampSurfaceDensity("unset", "compact")).toBe("compact");
87
+ });
88
+
89
+ it("never exceeds parent; tighter is allowed", () => {
90
+ expect(clampSurfaceDensity("comfortable", "compact")).toBe("compact");
91
+ expect(clampSurfaceDensity("compact", "comfortable")).toBe("compact");
92
+ });
93
+ });
94
+
95
+ describe("Surface nesting (LC-68)", () => {
96
+ it("nested Surface size large inside small resolves small", () => {
97
+ expect(renderNested({ size: "small" }, { size: "large" }).size).toBe("small");
98
+ });
99
+
100
+ it("nested comfortable inside compact resolves compact", () => {
101
+ expect(renderNested({ density: "compact" }, { density: "comfortable" }).density).toBe(
102
+ "compact",
103
+ );
104
+ });
105
+
106
+ it("nested small inside large resolves small (tighter allowed)", () => {
107
+ expect(renderNested({ size: "large" }, { size: "small" }).size).toBe("small");
108
+ });
109
+ });
110
+
111
+ describe("useResolvedKnobs inside Surface", () => {
112
+ it("size=small density=compact → sizeToken $3, compact gap, small desktop control height", () => {
113
+ const wrapper = ({ children }: { children: ReactNode }) =>
114
+ createElement(Surface, { size: "small", density: "compact" }, children);
115
+ const { result } = renderHook(() => useResolvedKnobs(), { wrapper });
116
+ expect(result.current.knobProps.size).toBe("small");
117
+ expect(result.current.knobProps.sizeToken).toBe("$3");
118
+ expect(result.current.knobProps.density).toBe("compact");
119
+ expect(result.current.knobProps.gap).toEqual({ gap: "$2" });
120
+ expect(result.current.knobProps.control.height).toBe(28);
121
+ });
122
+
123
+ it("a large size request inside a medium Surface resolves medium", () => {
124
+ const wrapper = ({ children }: { children: ReactNode }) =>
125
+ createElement(Surface, { size: "medium" }, children);
126
+ const { result } = renderHook(() => useResolvedKnobs({ size: "$5" }), { wrapper });
127
+ expect(result.current.knobProps.size).toBe("medium");
128
+ expect(result.current.knobProps.sizeToken).toBe("$4");
129
+ });
130
+
131
+ it("Surface size is inherited as the subtree default (lg over global medium)", () => {
132
+ const wrapper = ({ children }: { children: ReactNode }) =>
133
+ createElement(Surface, { size: "lg" }, children);
134
+ const { result } = renderHook(() => useResolvedKnobs(), { wrapper });
135
+ expect(result.current.knobProps.size).toBe("large");
136
+ expect(result.current.knobProps.sizeToken).toBe("$5");
137
+ });
138
+
139
+ it("compact:false inside a compact Surface stays compact and still steps space", () => {
140
+ const wrapper = ({ children }: { children: ReactNode }) =>
141
+ createElement(Surface, { density: "compact" }, children);
142
+ const { result } = renderHook(() => useResolvedKnobs({ compact: false }), { wrapper });
143
+ expect(result.current.knobProps.density).toBe("compact");
144
+ expect(result.current.knobProps.gap).toEqual({ gap: "$2" });
145
+ });
146
+ });
@@ -0,0 +1,73 @@
1
+ import type { ReactNode } from "react";
2
+ import { createStyledContext } from "tamagui";
3
+
4
+ export type SurfaceSize = "small" | "medium" | "large";
5
+ export type SurfaceSizeIntent = SurfaceSize | "sm" | "md" | "lg";
6
+ export type SurfaceDensity = "compact" | "comfortable";
7
+ export type SurfaceIntent = { size: SurfaceSize | "unset"; density: SurfaceDensity | "unset" };
8
+
9
+ const SIZE_INTENT_ALIASES: Record<SurfaceSizeIntent, SurfaceSize> = {
10
+ sm: "small",
11
+ small: "small",
12
+ md: "medium",
13
+ medium: "medium",
14
+ lg: "large",
15
+ large: "large",
16
+ };
17
+
18
+ export function normalizeSurfaceSize(size: SurfaceSizeIntent): SurfaceSize {
19
+ return SIZE_INTENT_ALIASES[size];
20
+ }
21
+
22
+ export const SurfaceContext = createStyledContext<SurfaceIntent>({
23
+ size: "unset",
24
+ density: "unset",
25
+ });
26
+
27
+ export const SURFACE_SIZE_RANK = { unset: 99, small: 0, medium: 1, large: 2 } as const;
28
+ export const SURFACE_DENSITY_RANK = { unset: 99, compact: 0, comfortable: 1 } as const;
29
+
30
+ /**
31
+ * Nested surfaces/controls may inherit or go tighter (lower rank), never larger
32
+ * than the parent (LC-68). `unset` parent = no ceiling. `unset` requested = inherit.
33
+ */
34
+ export function clampSurfaceSize(
35
+ requested: SurfaceSize | "unset",
36
+ parent: SurfaceSize | "unset",
37
+ ): SurfaceSize | "unset" {
38
+ if (parent === "unset") return requested;
39
+ if (requested === "unset") return parent;
40
+ return SURFACE_SIZE_RANK[requested] <= SURFACE_SIZE_RANK[parent] ? requested : parent;
41
+ }
42
+
43
+ export function clampSurfaceDensity(
44
+ requested: SurfaceDensity | "unset",
45
+ parent: SurfaceDensity | "unset",
46
+ ): SurfaceDensity | "unset" {
47
+ if (parent === "unset") return requested;
48
+ if (requested === "unset") return parent;
49
+ return SURFACE_DENSITY_RANK[requested] <= SURFACE_DENSITY_RANK[parent] ? requested : parent;
50
+ }
51
+
52
+ /**
53
+ * Declares size/density INTENT for a subtree. Nested Surfaces clamp step-down
54
+ * only — never raw gap tokens (LC-68, LC-69). Accepts sm/md/lg aliases.
55
+ */
56
+ export function Surface({
57
+ size,
58
+ density,
59
+ children,
60
+ }: {
61
+ size?: SurfaceSizeIntent;
62
+ density?: SurfaceDensity;
63
+ children?: ReactNode;
64
+ }) {
65
+ const parent = SurfaceContext.useStyledContext();
66
+ const nextSize = clampSurfaceSize(size ? normalizeSurfaceSize(size) : "unset", parent.size);
67
+ const nextDensity = clampSurfaceDensity(density ?? "unset", parent.density);
68
+ return (
69
+ <SurfaceContext.Provider size={nextSize} density={nextDensity}>
70
+ {children}
71
+ </SurfaceContext.Provider>
72
+ );
73
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * createDefaultThemeConfig specs — the consumer-facing batteries-included
3
+ * factory. Locks down the `builderOptions.getTheme` COMPOSITION contract
4
+ * (DF-03 / DG-A11Y-01): a consumer's partial getTheme merges over the
5
+ * framework default instead of replacing it, so the derived solid
6
+ * `$outlineColor` focus ring and the component sub-theme anchors survive,
7
+ * and consumer keys that resolve to undefined inside narrow sub-theme
8
+ * templates never leak the literal string "undefined" into theme values
9
+ * (the t_Button `--backgroundPress: undefined` regression).
10
+ *
11
+ * `createTamagui` is mocked pass-through: these specs pin OUR factory's
12
+ * theme building, not Tamagui's config processing.
13
+ */
14
+
15
+ import { afterEach, describe, expect, it, vi } from "vitest";
16
+
17
+ vi.mock("tamagui", () => ({
18
+ createTamagui: vi.fn((config: Record<string, unknown>) => config),
19
+ }));
20
+
21
+ // The reanimated animation driver imports react-native, which has no Node
22
+ // build; themes are what these specs pin, so stub the driver out.
23
+ vi.mock("./animations/index", () => ({ animations: {} }));
24
+
25
+ import { contrastRatio, minContrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
26
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
27
+ import type { GetThemeProps } from "./createThemes";
28
+ import { setSizeRecipeInputs } from "./sizeRecipes";
29
+
30
+ // Mirrors the shc app theme config: a consumer getTheme written against the
31
+ // FULL base ramp (theme.colorN) with no outlineColor of its own. Inside
32
+ // component sub-themes (t_Button, ...) every one of these reads undefined.
33
+ function consumerGetTheme({ theme }: GetThemeProps): Record<string, string> {
34
+ return {
35
+ backgroundPress: theme.color5,
36
+ backgroundFocus: theme.color6,
37
+ backgroundActive: theme.color4,
38
+ placeholderColor: theme.color10,
39
+ borderColor: theme.color5,
40
+ borderColorHover: theme.color6,
41
+ borderColorFocus: theme.color7,
42
+ borderColorPress: theme.color7,
43
+ textMuted: theme.color10,
44
+ textSubtle: theme.color6,
45
+ };
46
+ }
47
+
48
+ // With createTamagui mocked pass-through, config.tamagui IS the raw config
49
+ // object, so .themes is the raw built themes record.
50
+ type RawThemes = Record<string, Record<string, string>>;
51
+
52
+ function buildThemes(): RawThemes {
53
+ const config = createDefaultThemeConfig({
54
+ builderOptions: { getTheme: consumerGetTheme },
55
+ }) as unknown as { tamagui: { themes: RawThemes } };
56
+ return config.tamagui.themes;
57
+ }
58
+
59
+ describe("createDefaultThemeConfig — builderOptions.getTheme composition (DF-03)", () => {
60
+ afterEach(() => {
61
+ setSizeRecipeInputs();
62
+ });
63
+ const themes = buildThemes();
64
+
65
+ for (const scheme of ["light", "dark"] as const) {
66
+ it(`${scheme}: custom getTheme keeps the derived solid ≥3:1 $outlineColor`, () => {
67
+ const theme = themes[scheme];
68
+ expect(theme).toBeTruthy();
69
+ const ring = normalizeToHex(theme.outlineColor);
70
+ const bg1 = normalizeToHex(theme.color1);
71
+ const bg2 = normalizeToHex(theme.color2);
72
+ expect(ring).toBeTruthy();
73
+ expect(bg1).toBeTruthy();
74
+ expect(bg2).toBeTruthy();
75
+ const c1 = contrastRatio(relativeLuminance(ring!), relativeLuminance(bg1!));
76
+ const c2 = contrastRatio(relativeLuminance(ring!), relativeLuminance(bg2!));
77
+ expect(Math.min(c1, c2)).toBeGreaterThanOrEqual(minContrastRatio);
78
+ });
79
+ }
80
+
81
+ it("consumer keys win over the default derivation per key", () => {
82
+ // Default getTheme anchors textMuted at color11; the consumer moves it
83
+ // to color10 — composition must keep the consumer's choice.
84
+ for (const scheme of ["light", "dark"] as const) {
85
+ expect(themes[scheme].textMuted).toBe(themes[scheme].color10);
86
+ }
87
+ });
88
+
89
+ it("component sub-themes keep template values instead of literal undefined", () => {
90
+ const subThemeNames = Object.keys(themes).filter((name) => name.endsWith("_Button"));
91
+ expect(subThemeNames.length).toBeGreaterThan(0);
92
+ for (const name of subThemeNames) {
93
+ for (const [key, value] of Object.entries(themes[name])) {
94
+ expect(value, `${name}.${key}`).toBeDefined();
95
+ expect(String(value), `${name}.${key}`).not.toBe("undefined");
96
+ }
97
+ }
98
+ });
99
+
100
+ it("component sub-themes still derive a solid outlineColor", () => {
101
+ for (const name of ["light_Button", "dark_Button"]) {
102
+ const subTheme = themes[name];
103
+ expect(subTheme, name).toBeTruthy();
104
+ const ring = subTheme.outlineColor;
105
+ expect(ring, `${name}.outlineColor`).toBeTruthy();
106
+ expect(normalizeToHex(ring), `${name}.outlineColor solid`).toBeTruthy();
107
+ }
108
+ });
109
+
110
+ it("no built theme carries an undefined or literal-undefined value", () => {
111
+ for (const [name, theme] of Object.entries(themes)) {
112
+ for (const [key, value] of Object.entries(theme)) {
113
+ expect(value, `${name}.${key}`).not.toBeUndefined();
114
+ expect(value, `${name}.${key}`).not.toBeNull();
115
+ expect(String(value), `${name}.${key}`).not.toBe("undefined");
116
+ }
117
+ }
118
+ });
119
+
120
+ it("recipeInputs: { baseHeight: 40 } regenerates the control family on ThemeConfig, not color themes", () => {
121
+ const config = createDefaultThemeConfig({
122
+ recipeInputs: { baseHeight: 40 },
123
+ });
124
+ expect(config.recipeFamilies?.control.boxVariants.$4.height).toBe(40);
125
+ expect(config.recipeFamilies?.control.recipes.$4).toEqual({
126
+ height: 40,
127
+ paddingHorizontal: Math.round(40 * (12 / 32)),
128
+ fontSize: Math.round(40 * (14 / 32)),
129
+ iconSize: Math.round(40 * (16 / 32)),
130
+ gap: Math.round(40 * (6 / 32)),
131
+ });
132
+ expect(config.recipeInputs?.baseHeight).toBe(40);
133
+ const themes = (config.tamagui as unknown as { themes: RawThemes }).themes;
134
+ for (const [name, theme] of Object.entries(themes)) {
135
+ expect(theme, name).not.toHaveProperty("baseHeight");
136
+ expect(theme, name).not.toHaveProperty("radiusPx");
137
+ expect(theme, name).not.toHaveProperty("paddingHorizontal");
138
+ expect(theme, name).not.toHaveProperty("sizeFactors");
139
+ }
140
+ });
141
+
142
+ it("zero-options build matches the default getTheme behavior", () => {
143
+ const config = createDefaultThemeConfig();
144
+ const themes = (config.tamagui as unknown as { themes: RawThemes }).themes;
145
+ for (const scheme of ["light", "dark"] as const) {
146
+ const theme = themes[scheme];
147
+ expect(normalizeToHex(theme.outlineColor)).toBeTruthy();
148
+ expect(theme.textMuted).toBe(theme.color11);
149
+ }
150
+ expect(config.recipeFamilies?.control.boxVariants.$4.height).toBe(32);
151
+ });
152
+ });
@@ -6,6 +6,7 @@ import { animations } from "./animations/index";
6
6
  import {
7
7
  type BaseThemeBuilderDefinition,
8
8
  type CreateThemesBuilderOptions,
9
+ type GetThemeProps,
9
10
  type ThemeBuilderDefinition,
10
11
  createThemesBuilder,
11
12
  } from "./createThemes";
@@ -14,7 +15,9 @@ import { defaultBaseTheme } from "./defaults/base";
14
15
  import { defaultBuilderOptions } from "./defaults/builderOptions";
15
16
  import { defaultBodyFont, defaultHeadingFont } from "./defaults/fonts";
16
17
  import type { Preset } from "./preset.types";
18
+ import { type SizeRecipeInputs } from "./recipeInputs";
17
19
  import { type ThemeConfig, createThemeConfig } from "./shared";
20
+ import { getGeneratedSizeRecipes, getSizeRecipeInputs, setSizeRecipeInputs } from "./sizeRecipes";
18
21
 
19
22
  export interface CreateDefaultThemeConfigOptions {
20
23
  /** Base theme palette (dark/light with optional shadows; defaults to the built-in neutral ramps) */
@@ -37,6 +40,41 @@ export interface CreateDefaultThemeConfigOptions {
37
40
  presets?: Record<string, Preset>;
38
41
  /** Default preset name to use when no `mp.preset` cookie is set */
39
42
  defaultPreset?: string;
43
+ /**
44
+ * Size-recipe inputs (LC-70). Merged over `defaultRecipeInputs`; tables are
45
+ * generated, written into the live aliases Button/Input already import, and
46
+ * attached on ThemeConfig as `recipeInputs` / `recipeFamilies`. Not written
47
+ * into Tamagui `themes` (those stay color palettes from createThemes /
48
+ * theme-builder).
49
+ */
50
+ recipeInputs?: Partial<SizeRecipeInputs>;
51
+ }
52
+
53
+ /**
54
+ * Compose a consumer `getTheme` WITH the framework default instead of
55
+ * replacing it. The default always runs first — it derives the solid ≥3:1
56
+ * `$outlineColor` focus ring (DG-A11Y-01), the interaction ramp and the
57
+ * component sub-theme border/outline anchors — then the consumer's result is
58
+ * merged over it per key, so a partial override keeps every derivation it
59
+ * doesn't explicitly change. Consumer entries that resolve to nullish are
60
+ * dropped: a getTheme written against the full base ramp (`theme.color5`
61
+ * etc.) also runs inside narrow component sub-themes where those steps don't
62
+ * exist, and its undefineds must not erase the defaults (DF-03).
63
+ */
64
+ function composeGetTheme(
65
+ custom: CreateThemesBuilderOptions["getTheme"],
66
+ ): NonNullable<CreateThemesBuilderOptions["getTheme"]> {
67
+ const base = defaultBuilderOptions.getTheme as NonNullable<
68
+ CreateThemesBuilderOptions["getTheme"]
69
+ >;
70
+ if (!custom) return base;
71
+ return (props: GetThemeProps) => {
72
+ const merged: Record<string, string | number> = { ...base(props) };
73
+ for (const [key, value] of Object.entries(custom(props))) {
74
+ if (value != null) merged[key] = value;
75
+ }
76
+ return merged;
77
+ };
40
78
  }
41
79
 
42
80
  /**
@@ -44,18 +82,22 @@ export interface CreateDefaultThemeConfigOptions {
44
82
  * with zero options yields the framework's polished defaults — the neutral
45
83
  * Radix-mauve-curve base ramps, the solid-surface brand accent ramps, Inter
46
84
  * 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.
85
+ * Every option overrides its piece independently: `base` and `accent` each
86
+ * replace their default, `fonts` are spread OVER the defaults so a partial
87
+ * font map keeps the default heading/body, and `builderOptions.getTheme`
88
+ * COMPOSES with the default (consumer keys win; framework derivations like
89
+ * the solid `$outlineColor` focus ring survive partial overrides).
50
90
  */
51
91
  export function createDefaultThemeConfig(
52
92
  options: CreateDefaultThemeConfigOptions = {},
53
93
  ): ThemeConfig {
94
+ setSizeRecipeInputs(options.recipeInputs ?? {});
54
95
  const base = options.base ?? defaultBaseTheme;
55
96
  const accent = options.accent ?? defaultAccentTheme;
56
97
  const themesBuilder = createThemesBuilder(base, accent, {
57
98
  ...defaultBuilderOptions,
58
99
  ...options.builderOptions,
100
+ getTheme: composeGetTheme(options.builderOptions?.getTheme),
59
101
  });
60
102
  const builtThemes: Record<string, Record<string, string>> = {
61
103
  ...themesBuilder.themes(),
@@ -105,6 +147,8 @@ export function createDefaultThemeConfig(
105
147
  {
106
148
  presets: options.presets,
107
149
  defaultPreset: options.defaultPreset,
150
+ recipeInputs: getSizeRecipeInputs(),
151
+ recipeFamilies: getGeneratedSizeRecipes().families,
108
152
  },
109
153
  );
110
154
  }
@@ -58,6 +58,28 @@ export interface CreateThemesBuilderOptions {
58
58
  getTheme?: (props: GetThemeProps) => Record<string, string | number>;
59
59
  }
60
60
 
61
+ /**
62
+ * Wrap a `getTheme` so entries whose value is nullish are dropped. Tamagui's
63
+ * ThemeBuilder spreads the result OVER the template-derived theme
64
+ * (`{...theme, ...getTheme(props)}`), so a key that resolves to `undefined`
65
+ * (e.g. a consumer getTheme reading `theme.color5` inside a narrow component
66
+ * sub-theme that has no colorN steps) would otherwise erase the template's
67
+ * real value and reach the CSS variable emitter as the literal string
68
+ * "undefined" — t_Button's `--backgroundPress`/`--borderColorHover` blanked
69
+ * pressed fills and drew phantom currentColor borders (DF-03).
70
+ */
71
+ function sanitizeGetTheme(
72
+ getTheme: NonNullable<CreateThemesBuilderOptions["getTheme"]>,
73
+ ): NonNullable<CreateThemesBuilderOptions["getTheme"]> {
74
+ return (props: GetThemeProps) => {
75
+ const out: Record<string, string | number> = {};
76
+ for (const [key, value] of Object.entries(getTheme(props))) {
77
+ if (value != null) out[key] = value;
78
+ }
79
+ return out;
80
+ };
81
+ }
82
+
61
83
  // Radix hue names whose sub-themes act as decorative TINTS (Tint.tsx cycling,
62
84
  // the storybook `color` global, `theme="red"` field tinting). Under a tint,
63
85
  // surfaces/borders/solids keep the hue but TEXT must stay readable neutral:
@@ -156,6 +178,7 @@ export function createThemesBuilder(
156
178
  accentTheme: ThemeBuilderDefinition,
157
179
  options?: CreateThemesBuilderOptions,
158
180
  ) {
181
+ const getTheme = options?.getTheme ? sanitizeGetTheme(options.getTheme) : undefined;
159
182
  const base = {
160
183
  palette: {
161
184
  dark: baseTheme.darkPalette,
@@ -317,8 +340,8 @@ export function createThemesBuilder(
317
340
  if (options?.grandChildrenThemes) {
318
341
  createThemesProps.grandChildrenThemes = options.grandChildrenThemes;
319
342
  }
320
- if (options?.getTheme) {
321
- createThemesProps.getTheme = options.getTheme;
343
+ if (getTheme) {
344
+ createThemesProps.getTheme = getTheme;
322
345
  }
323
346
  const initialTheme = tamaguiCreateThemes(
324
347
  createThemesProps as Parameters<typeof tamaguiCreateThemes>[0],
@@ -346,8 +369,8 @@ export function createThemesBuilder(
346
369
  if (options?.grandChildrenThemes) {
347
370
  addThemeProps.grandChildrenThemes = options.grandChildrenThemes;
348
371
  }
349
- if (options?.getTheme) {
350
- addThemeProps.getTheme = options.getTheme;
372
+ if (getTheme) {
373
+ addThemeProps.getTheme = getTheme;
351
374
  }
352
375
  const createdTheme = tamaguiCreateThemes(
353
376
  addThemeProps as Parameters<typeof tamaguiCreateThemes>[0],
@@ -1230,9 +1230,13 @@ export function ThemeDevtoolsPanel({
1230
1230
  const handleResetToPreset = useCallback(() => {
1231
1231
  if (activePresetName) {
1232
1232
  setPreset(activePresetName);
1233
- setOverrides(new Set());
1233
+ } else {
1234
+ // Anonymous default preset (no `mp.preset` cookie): resetting must
1235
+ // still clear the persisted overrides instead of silently no-opping.
1236
+ setKnobs({ ...defaultKnobs });
1234
1237
  }
1235
- }, [activePresetName, setPreset]);
1238
+ setOverrides(new Set());
1239
+ }, [activePresetName, setPreset, setKnobs]);
1236
1240
 
1237
1241
  // -------------------------------------------------------------------------
1238
1242
  // Bidirectional theme sync: devtools panel ↔ website color scheme
@@ -46,11 +46,19 @@ export function focusRingColorMeetsContrast(ringColor: string, surfaceColor: str
46
46
  );
47
47
  }
48
48
 
49
+ /**
50
+ * ONE ring geometry (DG-A11Y-01): 2px solid `$outlineColor`, inset −2.
51
+ * Inset is the only offset that survives fused/clipped groups (segmented
52
+ * controls clip children at the group radius; an outset ring paints square
53
+ * across the clip or disappears under `overflow: hidden`) and it matches
54
+ * `keyboardFocusRingProps`, the manual ring painted for roving-focus
55
+ * widgets — so keyboard focus reads identically everywhere.
56
+ */
49
57
  export const FOCUS_VISIBLE_RING = {
50
58
  outlineWidth: FOCUS_RING_MIN_WIDTH,
51
59
  outlineStyle: "solid" as const,
52
60
  outlineColor: "$outlineColor",
53
- outlineOffset: 2,
61
+ outlineOffset: -2,
54
62
  } satisfies ControlStateProps;
55
63
 
56
64
  /**