@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.
- package/package.json +18 -18
- package/src/dates.spec.ts +344 -1
- package/src/dates.ts +50 -3
- package/src/devWarn.ts +29 -1
- package/src/figma/figmaTokens.spec.ts +3 -3
- package/src/figma/figmaTokens.ts +6 -3
- package/src/keyboardFocusRing.ts +61 -5
- package/src/menuRow.spec.ts +2 -2
- package/src/menuRow.ts +5 -4
- package/src/theme/Surface.spec.tsx +146 -0
- package/src/theme/Surface.tsx +73 -0
- package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
- package/src/theme/createDefaultThemeConfig.ts +47 -3
- package/src/theme/createThemes.ts +27 -4
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
- package/src/theme/focusState.ts +9 -1
- package/src/theme/groupGeometry.spec.ts +108 -0
- package/src/theme/groupGeometry.ts +119 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/intent.spec.tsx +17 -0
- package/src/theme/knobs.ts +2 -2
- package/src/theme/layoutTokens.spec.ts +14 -0
- package/src/theme/layoutTokens.ts +29 -0
- package/src/theme/recipeInputs.ts +368 -0
- package/src/theme/recipes.ts +34 -0
- package/src/theme/resolveKnobs.spec.ts +91 -9
- package/src/theme/resolveKnobs.ts +55 -5
- package/src/theme/shared.tsx +11 -0
- package/src/theme/sizeRecipes.spec.ts +296 -0
- package/src/theme/sizeRecipes.ts +198 -0
- package/src/theme/useResolvedKnobs.ts +76 -24
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
- package/types/dates.d.ts +36 -2
- package/types/dates.d.ts.map +1 -1
- package/types/devWarn.d.ts +16 -1
- package/types/devWarn.d.ts.map +1 -1
- package/types/figma/figmaTokens.d.ts +1 -1
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/keyboardFocusRing.d.ts +19 -4
- package/types/keyboardFocusRing.d.ts.map +1 -1
- package/types/menuRow.d.ts +5 -4
- package/types/menuRow.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +37 -0
- package/types/theme/Surface.d.ts.map +1 -0
- package/types/theme/createDefaultThemeConfig.d.ts +14 -3
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/focusState.d.ts +8 -0
- package/types/theme/focusState.d.ts.map +1 -1
- package/types/theme/groupGeometry.d.ts +69 -0
- package/types/theme/groupGeometry.d.ts.map +1 -0
- package/types/theme/index.d.ts +3 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +2 -2
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +22 -0
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +110 -0
- package/types/theme/recipeInputs.d.ts.map +1 -0
- package/types/theme/recipes.d.ts +47 -0
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +7 -3
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/shared.d.ts +9 -0
- package/types/theme/shared.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +68 -0
- package/types/theme/sizeRecipes.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +10 -6
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- package/src/audit/baselines/home-pokemon.json +0 -355
package/src/keyboardFocusRing.ts
CHANGED
|
@@ -36,10 +36,66 @@ export function wasKeyboardFocus(): boolean {
|
|
|
36
36
|
return lastInputWasKeyboard;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/menuRow.spec.ts
CHANGED
|
@@ -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("
|
|
15
|
-
expect(menuRowFrame
|
|
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
|
|
8
|
-
* radius knob
|
|
9
|
-
*
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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 (
|
|
321
|
-
createThemesProps.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 (
|
|
350
|
-
addThemeProps.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
|
-
|
|
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
|
-
|
|
1238
|
+
setOverrides(new Set());
|
|
1239
|
+
}, [activePresetName, setPreset, setKnobs]);
|
|
1236
1240
|
|
|
1237
1241
|
// -------------------------------------------------------------------------
|
|
1238
1242
|
// Bidirectional theme sync: devtools panel ↔ website color scheme
|
package/src/theme/focusState.ts
CHANGED
|
@@ -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
|
/**
|