@multiplatform.one/theme 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +73 -0
  2. package/package.json +22 -9
  3. package/src/dates.spec.ts +75 -0
  4. package/src/dates.ts +130 -0
  5. package/src/devWarn.spec.ts +144 -0
  6. package/src/devWarn.ts +235 -0
  7. package/src/figma/figmaTokens.spec.ts +244 -0
  8. package/src/figma/figmaTokens.ts +503 -0
  9. package/src/font.ts +6 -8
  10. package/src/fonts/createFontLoader.web.ts +15 -4
  11. package/src/index.ts +7 -0
  12. package/src/keyboardFocusRing.ts +45 -0
  13. package/src/menuRow.spec.ts +32 -0
  14. package/src/menuRow.ts +31 -0
  15. package/src/theme/FontKnobStyles.native.tsx +24 -0
  16. package/src/theme/FontKnobStyles.tsx +256 -0
  17. package/src/theme/animations/reactNative.ts +6 -1
  18. package/src/theme/chartPalette.spec.ts +159 -0
  19. package/src/theme/chartPalette.ts +170 -0
  20. package/src/theme/colorRules.ts +33 -1
  21. package/src/theme/cookies.spec.ts +32 -0
  22. package/src/theme/cookiesPersistence.spec.ts +86 -0
  23. package/src/theme/cornerSmoothing.native.ts +16 -0
  24. package/src/theme/cornerSmoothing.spec.ts +27 -0
  25. package/src/theme/cornerSmoothing.ts +46 -0
  26. package/src/theme/createDefaultThemeConfig.ts +32 -8
  27. package/src/theme/createThemeConfig.spec.ts +137 -0
  28. package/src/theme/createThemes.ts +77 -2
  29. package/src/theme/defaults/accent.ts +39 -0
  30. package/src/theme/defaults/base.ts +63 -0
  31. package/src/theme/defaults/builderOptions.ts +174 -0
  32. package/src/theme/defaults/fonts.spec.ts +87 -0
  33. package/src/theme/defaults/fonts.ts +89 -0
  34. package/src/theme/defaults/index.ts +4 -0
  35. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
  36. package/src/theme/devtools/buildSnapshot.ts +11 -0
  37. package/src/theme/devtools/knobIcons.tsx +141 -0
  38. package/src/theme/devtools/vizConstants.ts +1 -1
  39. package/src/theme/focusState.spec.ts +174 -0
  40. package/src/theme/focusState.ts +112 -0
  41. package/src/theme/fontKnobStyles.spec.ts +98 -0
  42. package/src/theme/hairline.native.ts +43 -0
  43. package/src/theme/hairline.spec.ts +63 -0
  44. package/src/theme/hairline.ts +101 -0
  45. package/src/theme/index.ts +8 -0
  46. package/src/theme/intent.spec.tsx +2 -2
  47. package/src/theme/intents.ts +6 -2
  48. package/src/theme/knobs.ts +134 -2
  49. package/src/theme/layoutTokens.spec.ts +75 -0
  50. package/src/theme/layoutTokens.ts +213 -0
  51. package/src/theme/layoutTokensHooks.spec.tsx +157 -0
  52. package/src/theme/presets.spec.ts +17 -0
  53. package/src/theme/presets.ts +22 -2
  54. package/src/theme/readableColor.ts +101 -0
  55. package/src/theme/recipes.ts +130 -7
  56. package/src/theme/resolveKnobs.spec.ts +560 -21
  57. package/src/theme/resolveKnobs.ts +267 -23
  58. package/src/theme/shared.tsx +26 -2
  59. package/src/theme/useResolvedKnobs.ts +67 -26
  60. package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
  61. package/types/audit/constraintAudit.d.ts +83 -0
  62. package/types/audit/constraintAudit.d.ts.map +1 -0
  63. package/types/audit/diffReport.d.ts +45 -0
  64. package/types/audit/diffReport.d.ts.map +1 -0
  65. package/types/audit/index.d.ts +5 -0
  66. package/types/audit/index.d.ts.map +1 -0
  67. package/types/dates.d.ts +52 -0
  68. package/types/dates.d.ts.map +1 -0
  69. package/types/devWarn.d.ts +104 -0
  70. package/types/devWarn.d.ts.map +1 -0
  71. package/types/figma/figmaTokens.d.ts +168 -0
  72. package/types/figma/figmaTokens.d.ts.map +1 -0
  73. package/types/font.d.ts +5 -0
  74. package/types/font.d.ts.map +1 -0
  75. package/types/fonts/createFontLoader.d.ts +3 -0
  76. package/types/fonts/createFontLoader.d.ts.map +1 -0
  77. package/types/fonts/createFontLoader.web.d.ts +3 -0
  78. package/types/fonts/createFontLoader.web.d.ts.map +1 -0
  79. package/types/fonts/index.d.ts +3 -0
  80. package/types/fonts/index.d.ts.map +1 -0
  81. package/types/fonts/types.d.ts +12 -0
  82. package/types/fonts/types.d.ts.map +1 -0
  83. package/types/index.d.ts +9 -0
  84. package/types/index.d.ts.map +1 -0
  85. package/types/keyboardFocusRing.d.ts +32 -0
  86. package/types/keyboardFocusRing.d.ts.map +1 -0
  87. package/types/menuRow.d.ts +32 -0
  88. package/types/menuRow.d.ts.map +1 -0
  89. package/types/theme/FontKnobStyles.d.ts +49 -0
  90. package/types/theme/FontKnobStyles.d.ts.map +1 -0
  91. package/types/theme/FontKnobStyles.native.d.ts +12 -0
  92. package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
  93. package/types/theme/Intent.d.ts +20 -0
  94. package/types/theme/Intent.d.ts.map +1 -0
  95. package/types/theme/Preset.d.ts +15 -0
  96. package/types/theme/Preset.d.ts.map +1 -0
  97. package/types/theme/PresetContext.d.ts +18 -0
  98. package/types/theme/PresetContext.d.ts.map +1 -0
  99. package/types/theme/Tint.d.ts +19 -0
  100. package/types/theme/Tint.d.ts.map +1 -0
  101. package/types/theme/animations/css.d.ts +24 -0
  102. package/types/theme/animations/css.d.ts.map +1 -0
  103. package/types/theme/animations/index.d.ts +2 -0
  104. package/types/theme/animations/index.d.ts.map +1 -0
  105. package/types/theme/animations/index.web.d.ts +2 -0
  106. package/types/theme/animations/index.web.d.ts.map +1 -0
  107. package/types/theme/animations/reactNative.d.ts +104 -0
  108. package/types/theme/animations/reactNative.d.ts.map +1 -0
  109. package/types/theme/chartPalette.d.ts +62 -0
  110. package/types/theme/chartPalette.d.ts.map +1 -0
  111. package/types/theme/colorRules.d.ts +69 -0
  112. package/types/theme/colorRules.d.ts.map +1 -0
  113. package/types/theme/cookies.d.ts +76 -0
  114. package/types/theme/cookies.d.ts.map +1 -0
  115. package/types/theme/cornerSmoothing.d.ts +31 -0
  116. package/types/theme/cornerSmoothing.d.ts.map +1 -0
  117. package/types/theme/cornerSmoothing.native.d.ts +14 -0
  118. package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
  119. package/types/theme/createDefaultThemeConfig.d.ts +37 -0
  120. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
  121. package/types/theme/createThemes.d.ts +38 -0
  122. package/types/theme/createThemes.d.ts.map +1 -0
  123. package/types/theme/defaults/accent.d.ts +3 -0
  124. package/types/theme/defaults/accent.d.ts.map +1 -0
  125. package/types/theme/defaults/base.d.ts +3 -0
  126. package/types/theme/defaults/base.d.ts.map +1 -0
  127. package/types/theme/defaults/builderOptions.d.ts +3 -0
  128. package/types/theme/defaults/builderOptions.d.ts.map +1 -0
  129. package/types/theme/defaults/fonts.d.ts +7 -0
  130. package/types/theme/defaults/fonts.d.ts.map +1 -0
  131. package/types/theme/defaults/index.d.ts +5 -0
  132. package/types/theme/defaults/index.d.ts.map +1 -0
  133. package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
  134. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
  135. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
  136. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
  137. package/types/theme/devtools/buildSnapshot.d.ts +12 -0
  138. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
  139. package/types/theme/devtools/knobIcons.d.ts +33 -0
  140. package/types/theme/devtools/knobIcons.d.ts.map +1 -0
  141. package/types/theme/devtools/vizConstants.d.ts +5 -0
  142. package/types/theme/devtools/vizConstants.d.ts.map +1 -0
  143. package/types/theme/focusState.d.ts +42 -0
  144. package/types/theme/focusState.d.ts.map +1 -0
  145. package/types/theme/hairline.d.ts +88 -0
  146. package/types/theme/hairline.d.ts.map +1 -0
  147. package/types/theme/hairline.native.d.ts +42 -0
  148. package/types/theme/hairline.native.d.ts.map +1 -0
  149. package/types/theme/index.d.ts +29 -0
  150. package/types/theme/index.d.ts.map +1 -0
  151. package/types/theme/intents.d.ts +3 -0
  152. package/types/theme/intents.d.ts.map +1 -0
  153. package/types/theme/knobs.d.ts +236 -0
  154. package/types/theme/knobs.d.ts.map +1 -0
  155. package/types/theme/layoutTokens.d.ts +104 -0
  156. package/types/theme/layoutTokens.d.ts.map +1 -0
  157. package/types/theme/preset.types.d.ts +21 -0
  158. package/types/theme/preset.types.d.ts.map +1 -0
  159. package/types/theme/presets.d.ts +4 -0
  160. package/types/theme/presets.d.ts.map +1 -0
  161. package/types/theme/readableColor.d.ts +17 -0
  162. package/types/theme/readableColor.d.ts.map +1 -0
  163. package/types/theme/recipes.d.ts +200 -0
  164. package/types/theme/recipes.d.ts.map +1 -0
  165. package/types/theme/resolveKnobs.d.ts +22 -0
  166. package/types/theme/resolveKnobs.d.ts.map +1 -0
  167. package/types/theme/shared.d.ts +40 -0
  168. package/types/theme/shared.d.ts.map +1 -0
  169. package/types/theme/theme.d.ts +19 -0
  170. package/types/theme/theme.d.ts.map +1 -0
  171. package/types/theme/theme.native.d.ts +10 -0
  172. package/types/theme/theme.native.d.ts.map +1 -0
  173. package/types/theme/useColorScale.d.ts +7 -0
  174. package/types/theme/useColorScale.d.ts.map +1 -0
  175. package/types/theme/useColorScale.native.d.ts +6 -0
  176. package/types/theme/useColorScale.native.d.ts.map +1 -0
  177. package/types/theme/useResolvedKnobs.d.ts +32 -0
  178. package/types/theme/useResolvedKnobs.d.ts.map +1 -0
  179. package/types/theme/useTheme.d.ts +31 -0
  180. package/types/theme/useTheme.d.ts.map +1 -0
  181. package/types/theme/useTheme.native.d.ts +10 -0
  182. package/types/theme/useTheme.native.d.ts.map +1 -0
  183. package/CHANGELOG.md +0 -53
  184. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
  185. package/src/theme/pokemonScreen.spec.ts +0 -109
  186. package/tsconfig.json +0 -11
  187. package/vitest.config.mjs +0 -9
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Layout hook specs — the live half of layoutTokens (DG-LAY-01/03): the
3
+ * viewport-driven `useLayoutSizeClass`, the pane budget `useMultiPane`, and
4
+ * the semantic gap roles `useSemanticGaps`. Locks down: widths resolve to the
5
+ * canonical size classes at mount, one matchMedia listener per breakpoint is
6
+ * registered and removed, and gap roles ride the space-knob recipes.
7
+ */
8
+
9
+ import { act, renderHook } from "@testing-library/react";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ // Controllable native-dimensions stub: the web path under test ignores it, but
13
+ // tests may mutate `width` to exercise the native fallback deterministically.
14
+ const nativeWindowDimensions = vi.hoisted(() => ({
15
+ width: 1024,
16
+ height: 768,
17
+ scale: 2,
18
+ fontScale: 1,
19
+ }));
20
+
21
+ // Partial mock: real tamagui exports pass through so future imports in
22
+ // layoutTokens don't break the suite; only the members the spec must control
23
+ // are stubbed.
24
+ vi.mock(import("tamagui"), async (importOriginal) => ({
25
+ ...(await importOriginal()),
26
+ // Pin the web path — these specs drive size class via matchMedia listeners.
27
+ isWeb: true,
28
+ useWindowDimensions: () => nativeWindowDimensions,
29
+ }));
30
+
31
+ import {
32
+ layoutBreakpoints,
33
+ useLayoutSizeClass,
34
+ useMultiPane,
35
+ useSemanticGaps,
36
+ } from "./layoutTokens";
37
+ import { useResolvedKnobs } from "./useResolvedKnobs";
38
+
39
+ interface FakeMq {
40
+ media: string;
41
+ matches: boolean;
42
+ addEventListener: ReturnType<typeof vi.fn>;
43
+ removeEventListener: ReturnType<typeof vi.fn>;
44
+ }
45
+
46
+ let createdQueries: FakeMq[];
47
+ let originalMatchMedia: typeof window.matchMedia;
48
+ let originalInnerWidth: number;
49
+
50
+ function setViewportWidth(width: number) {
51
+ Object.defineProperty(window, "innerWidth", {
52
+ value: width,
53
+ writable: true,
54
+ configurable: true,
55
+ });
56
+ }
57
+
58
+ beforeEach(() => {
59
+ createdQueries = [];
60
+ originalMatchMedia = window.matchMedia;
61
+ originalInnerWidth = window.innerWidth;
62
+ window.matchMedia = vi.fn((media: string): MediaQueryList => {
63
+ const mq: FakeMq = {
64
+ media,
65
+ matches: false,
66
+ addEventListener: vi.fn(),
67
+ removeEventListener: vi.fn(),
68
+ };
69
+ createdQueries.push(mq);
70
+ return mq as unknown as MediaQueryList;
71
+ }) as typeof window.matchMedia;
72
+ });
73
+
74
+ afterEach(() => {
75
+ window.matchMedia = originalMatchMedia;
76
+ setViewportWidth(originalInnerWidth);
77
+ });
78
+
79
+ describe("useLayoutSizeClass", () => {
80
+ it.each([
81
+ [500, "compact"],
82
+ [layoutBreakpoints.medium, "medium"],
83
+ [layoutBreakpoints.expanded, "expanded"],
84
+ [layoutBreakpoints.large, "large"],
85
+ [layoutBreakpoints.xl, "xl"],
86
+ ] as const)("resolves width %dpx to %s at mount", (width, expected) => {
87
+ setViewportWidth(width);
88
+ const { result } = renderHook(() => useLayoutSizeClass());
89
+ expect(result.current).toBe(expected);
90
+ });
91
+
92
+ it("registers a matchMedia listener per breakpoint and removes them on unmount", () => {
93
+ setViewportWidth(900);
94
+ const { unmount } = renderHook(() => useLayoutSizeClass());
95
+ expect(createdQueries).toHaveLength(4);
96
+ const medias = createdQueries.map((mq) => mq.media);
97
+ expect(medias).toEqual(
98
+ expect.arrayContaining([
99
+ `(min-width: ${layoutBreakpoints.medium}px)`,
100
+ `(min-width: ${layoutBreakpoints.expanded}px)`,
101
+ `(min-width: ${layoutBreakpoints.large}px)`,
102
+ `(min-width: ${layoutBreakpoints.xl}px)`,
103
+ ]),
104
+ );
105
+ for (const mq of createdQueries) {
106
+ expect(mq.addEventListener).toHaveBeenCalledWith("change", expect.any(Function));
107
+ }
108
+ unmount();
109
+ for (const mq of createdQueries) {
110
+ expect(mq.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function));
111
+ }
112
+ });
113
+
114
+ it("re-syncs from the viewport when a breakpoint query fires", () => {
115
+ setViewportWidth(500);
116
+ const { result } = renderHook(() => useLayoutSizeClass());
117
+ expect(result.current).toBe("compact");
118
+ // Simulate crossing into expanded: update the viewport and fire any
119
+ // registered breakpoint listener.
120
+ setViewportWidth(1000);
121
+ const handler = createdQueries[0].addEventListener.mock.calls[0][1] as () => void;
122
+ act(() => handler());
123
+ expect(result.current).toBe("expanded");
124
+ });
125
+ });
126
+
127
+ describe("useMultiPane", () => {
128
+ it("allows two panes at expanded and wider", () => {
129
+ setViewportWidth(layoutBreakpoints.expanded);
130
+ const { result } = renderHook(() => useMultiPane());
131
+ expect(result.current).toBe(true);
132
+ });
133
+
134
+ it("forces a single pane below expanded", () => {
135
+ setViewportWidth(700);
136
+ const { result } = renderHook(() => useMultiPane());
137
+ expect(result.current).toBe(false);
138
+ });
139
+
140
+ it("honors an explicit single-pane budget even on wide viewports", () => {
141
+ setViewportWidth(1400);
142
+ const { result } = renderHook(() => useMultiPane(1));
143
+ expect(result.current).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe("useSemanticGaps", () => {
148
+ it("maps within-group to knob gap and between-groups to the large gap", () => {
149
+ setViewportWidth(1000);
150
+ const { result: knobs } = renderHook(() => useResolvedKnobs());
151
+ const { result } = renderHook(() => useSemanticGaps());
152
+ expect(result.current.withinGroup).toEqual(knobs.current.knobProps.gap);
153
+ expect(result.current.betweenGroups).toEqual(knobs.current.knobProps.gapLg);
154
+ // The roles must differ, or grouping hierarchy is invisible.
155
+ expect(result.current.withinGroup.gap).not.toBe(result.current.betweenGroups.gap);
156
+ });
157
+ });
@@ -20,6 +20,17 @@ import { defaultIntents } from "./intents";
20
20
  // Preset fixtures
21
21
  // ---------------------------------------------------------------------------
22
22
 
23
+ const houseKnobDefaults = {
24
+ fieldLabelPlacement: "top",
25
+ requiredMarking: "minority",
26
+ tableZebra: "off",
27
+ bulkBarPlacement: "top",
28
+ selectAllScope: "page",
29
+ timestampStyle: "absolute",
30
+ disabledStyle: "keepLabel",
31
+ formAutofocus: "off",
32
+ } as const;
33
+
23
34
  const minimalPreset: Preset = {
24
35
  theme: "gray" as ThemeName,
25
36
  knobs: {
@@ -29,11 +40,13 @@ const minimalPreset: Preset = {
29
40
  elevation: "none",
30
41
  space: "small",
31
42
  size: "small",
43
+ density: "comfortable",
32
44
  textAccent: "low",
33
45
  headingFont: "sans-serif",
34
46
  bodyFont: "sans-serif",
35
47
  fontWeight: "regular",
36
48
  animation: "none",
49
+ ...houseKnobDefaults,
37
50
  hover: {},
38
51
  press: {},
39
52
  focus: {},
@@ -52,11 +65,13 @@ const boldPreset: Preset = {
52
65
  elevation: "medium",
53
66
  space: "large",
54
67
  size: "large",
68
+ density: "comfortable",
55
69
  textAccent: "high",
56
70
  headingFont: "sans-serif",
57
71
  bodyFont: "sans-serif",
58
72
  fontWeight: "bold",
59
73
  animation: "bouncy",
74
+ ...houseKnobDefaults,
60
75
  hover: {},
61
76
  press: {},
62
77
  focus: {},
@@ -75,11 +90,13 @@ const roundedPreset: Preset = {
75
90
  elevation: "small",
76
91
  space: "medium",
77
92
  size: "medium",
93
+ density: "comfortable",
78
94
  textAccent: "high",
79
95
  headingFont: "sans-serif",
80
96
  bodyFont: "sans-serif",
81
97
  fontWeight: "regular",
82
98
  animation: "quick",
99
+ ...houseKnobDefaults,
83
100
  hover: {},
84
101
  press: {},
85
102
  focus: {},
@@ -7,15 +7,25 @@ export const defaultPreset: Preset = {
7
7
  knobs: {
8
8
  fillStyle: "filled",
9
9
  borderRadius: "medium",
10
- borderWidth: "small",
11
- elevation: "none",
10
+ cornerSmoothing: "round",
11
+ borderWidth: "medium",
12
+ elevation: "small",
12
13
  space: "medium",
13
14
  size: "medium",
15
+ density: "comfortable",
14
16
  textAccent: "high",
15
17
  headingFont: "sans-serif",
16
18
  bodyFont: "sans-serif",
17
19
  fontWeight: "regular",
18
20
  animation: "quick",
21
+ fieldLabelPlacement: "top",
22
+ requiredMarking: "minority",
23
+ tableZebra: "off",
24
+ bulkBarPlacement: "top",
25
+ selectAllScope: "page",
26
+ timestampStyle: "absolute",
27
+ disabledStyle: "keepLabel",
28
+ formAutofocus: "off",
19
29
  hover: {},
20
30
  press: {},
21
31
  focus: {},
@@ -30,15 +40,25 @@ export const boldPreset: Preset = {
30
40
  knobs: {
31
41
  fillStyle: "filled",
32
42
  borderRadius: "full",
43
+ cornerSmoothing: "round",
33
44
  borderWidth: "none",
34
45
  elevation: "none",
35
46
  space: "small",
36
47
  size: "medium",
48
+ density: "comfortable",
37
49
  textAccent: "high",
38
50
  headingFont: "sans-serif",
39
51
  bodyFont: "sans-serif",
40
52
  fontWeight: "bold",
41
53
  animation: "bouncy",
54
+ fieldLabelPlacement: "top",
55
+ requiredMarking: "minority",
56
+ tableZebra: "off",
57
+ bulkBarPlacement: "top",
58
+ selectAllScope: "page",
59
+ timestampStyle: "absolute",
60
+ disabledStyle: "keepLabel",
61
+ formAutofocus: "off",
42
62
  hover: {},
43
63
  press: {},
44
64
  focus: {},
@@ -0,0 +1,101 @@
1
+ import { useMemo } from "react";
2
+ import { useTheme as useTamaguiTheme } from "tamagui";
3
+ import {
4
+ aaTextContrastRatio,
5
+ contrastRatio,
6
+ normalizeToHex,
7
+ pickReadableForeground,
8
+ relativeLuminance,
9
+ } from "./colorRules";
10
+
11
+ /**
12
+ * Luminance-computed readable text/glyph colors (Axiom 15 OPTICS + Axiom 12
13
+ * LEGIBLE FLOOR).
14
+ *
15
+ * Two catalog-wide contrast traps this module closes:
16
+ *
17
+ * 1. Text sitting ON an accent/solid FILL (`useReadableTextOn`). Components
18
+ * that paint a selected/active row, chip or avatar with `$accentBackground`
19
+ * (or any solid step) and then let the label inherit the scheme's `$color`
20
+ * render ink-on-accent (~2.6:1) or, worse, near-white-on-bright-tint
21
+ * (~1.2:1). The readable foreground is whichever scheme anchor
22
+ * (paper `$color1` / ink `$color12`) carries more contrast on the ACTUAL
23
+ * fill — never assumed.
24
+ *
25
+ * 2. Accent-hued text on the PAGE (`useAccentOnSurface`). `$accentColor` is the
26
+ * on-fill foreground; used as a link/emphasis color on the neutral page it
27
+ * reads ~1.9:1 in dark. The readable accent is the most chromatic step of
28
+ * the theme's own accent ramp that still clears AA (4.5:1) on both page
29
+ * surfaces — a mid accent in light, a pale accent in dark.
30
+ *
31
+ * Both read live theme values so they stay correct across schemes, tints and
32
+ * custom accent palettes; they mirror the runtime pattern already used by the
33
+ * Switch thumb, Toast and Calendar chip.
34
+ */
35
+
36
+ function resolveTokenValue(
37
+ theme: Record<string, { val?: unknown } | undefined>,
38
+ token: string,
39
+ ): string | undefined {
40
+ const key = token.startsWith("$") ? token.slice(1) : token;
41
+ const val = theme[key]?.val;
42
+ return typeof val === "string" ? val : undefined;
43
+ }
44
+
45
+ /**
46
+ * Readable text/glyph color to sit ON `fillToken` (e.g. "$accentBackground",
47
+ * "$color8"): the scheme's paper (`$color1`) or ink (`$color12`) anchor,
48
+ * whichever contrasts more on the resolved fill, returned as a concrete value
49
+ * so no nested sub-theme can flip it. Returns `undefined` when any color fails
50
+ * to resolve/parse — callers keep their own default in that case.
51
+ */
52
+ export function useReadableTextOn(fillToken: string): string | undefined {
53
+ const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
54
+ const fill = resolveTokenValue(theme, fillToken);
55
+ const paper = resolveTokenValue(theme, "color1");
56
+ const ink = resolveTokenValue(theme, "color12");
57
+ return useMemo(() => {
58
+ if (!fill || !paper || !ink) return undefined;
59
+ if (!normalizeToHex(fill) || !normalizeToHex(paper) || !normalizeToHex(ink)) return undefined;
60
+ return pickReadableForeground(fill, paper, ink);
61
+ }, [fill, paper, ink]);
62
+ }
63
+
64
+ /**
65
+ * Accent-hued text/link color readable on the page surfaces (color1/color2).
66
+ * Scans the theme's own accent ramp for the most chromatic step that clears
67
+ * the AA text floor against BOTH page surfaces; falls back to readable neutral
68
+ * ink (`$color12`) when no accent step qualifies or the ramp is absent (e.g.
69
+ * tint sub-themes, where neutral text is the house default anyway).
70
+ */
71
+ export function useAccentOnSurface(): string {
72
+ const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
73
+ const c1 = resolveTokenValue(theme, "color1");
74
+ const c2 = resolveTokenValue(theme, "color2");
75
+ const ink = resolveTokenValue(theme, "color12") ?? "$color12";
76
+ const steps: (string | undefined)[] = [];
77
+ for (let i = 1; i <= 12; i++) steps.push(resolveTokenValue(theme, `accent${i}`));
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ return useMemo(() => {
80
+ const bg1 = c1 ? normalizeToHex(c1) : null;
81
+ const bg2 = c2 ? normalizeToHex(c2) : null;
82
+ if (!bg1 || !bg2) return ink;
83
+ const l1 = relativeLuminance(bg1);
84
+ const l2 = relativeLuminance(bg2);
85
+ let best: string | undefined;
86
+ let bestMin = Number.POSITIVE_INFINITY;
87
+ for (const step of steps) {
88
+ const hex = step ? normalizeToHex(step) : null;
89
+ if (!step || !hex) continue;
90
+ const stepLum = relativeLuminance(hex);
91
+ const min = Math.min(contrastRatio(stepLum, l1), contrastRatio(stepLum, l2));
92
+ // Most chromatic passing step = the lowest contrast that still clears AA
93
+ // (accent steps get paler/whiter as contrast climbs).
94
+ if (min >= aaTextContrastRatio && min < bestMin) {
95
+ bestMin = min;
96
+ best = step;
97
+ }
98
+ }
99
+ return best ?? ink;
100
+ }, [c1, c2, ink, steps.join("|")]);
101
+ }
@@ -1,4 +1,4 @@
1
- import type { TransitionProp } from "tamagui";
1
+ import type { ColorTokens, TransitionProp } from "tamagui";
2
2
  import type { Knobs } from "./knobs";
3
3
 
4
4
  // ── State knob prop types (by concern) ───────────────────────────────────
@@ -8,6 +8,11 @@ export interface ControlStateProps {
8
8
  borderWidth?: number;
9
9
  backgroundColor?: string;
10
10
  padding?: string;
11
+ /** Focus-visible ring (DG-A11Y-01); always ≥2 via `ensureFocusVisibleRing`. */
12
+ outlineWidth?: number;
13
+ outlineStyle?: "solid" | "none";
14
+ outlineColor?: string;
15
+ outlineOffset?: number;
11
16
  }
12
17
 
13
18
  export interface TextStateProps {
@@ -18,7 +23,65 @@ export interface TextStateProps {
18
23
  }
19
24
 
20
25
  export interface ElevationStateProps {
21
- elevation?: string;
26
+ /** Size token on web ("$1"); numeric tokens.size value on native (RN-safe). */
27
+ elevation?: string | number;
28
+ }
29
+
30
+ // ── Disabled state recipe (LC-40 DISABLED-VISIBLE) ───────────────────────
31
+
32
+ /** Text-bearing control chrome (Button, Chip, Input.Box): muted fill/border, no opacity. */
33
+ export interface DisabledSurfaceProps {
34
+ backgroundColor?: string;
35
+ borderColor?: string;
36
+ cursor?: "not-allowed";
37
+ }
38
+
39
+ /** Text-free control chrome (switch track, checkbox box, radio ring, stars): opacity dim. */
40
+ export interface DisabledChromeProps {
41
+ opacity?: number;
42
+ cursor?: "not-allowed";
43
+ }
44
+
45
+ /** Label/value text and glyphs riding inside disabled surface chrome (stays ≥ AA). */
46
+ export interface DisabledTextProps {
47
+ color?: string;
48
+ }
49
+
50
+ /** Whole field assembly (FieldLayout wrapper / standalone control frame). */
51
+ export interface DisabledAssemblyProps {
52
+ opacity?: number;
53
+ }
54
+
55
+ /**
56
+ * Resolved disabled treatment (LC-40 DISABLED-VISIBLE / DG-ST-05). A control
57
+ * rendered disabled MUST be visually distinguishable from its enabled twin
58
+ * under BOTH `disabledStyle` values. Fragments by anatomy — spread whole,
59
+ * never cherry-pick, and only when `disabled` (read-only is a different
60
+ * state and never dims):
61
+ *
62
+ * - `surfaceKnobProps` — chrome that CONTAINS text (Button, Chip, Input.Box):
63
+ * fill/border wash to the muted tier without opacity so the inner
64
+ * label/value text (via `textKnobProps`, `$color11`) keeps ≥ 4.5:1.
65
+ * - `selectedSurfaceKnobProps` — a SELECTED segment inside disabled
66
+ * text-bearing chrome (ToggleGroup / segmented controls): one wash step
67
+ * stronger than `surfaceKnobProps` so which option is chosen stays
68
+ * visible while disabled (Axiom 6) — components drop their live active
69
+ * fill when disabled (the `active` variant beats the wash), so the
70
+ * recipe owns the selected tier under BOTH disabledStyle values.
71
+ * - `chromeKnobProps` — chrome with no text of its own (switch track,
72
+ * checkbox box, radio ring, slider, rating stars): opacity dim.
73
+ * - `assemblyKnobProps` — the whole assembly; populated only under
74
+ * `dimWhole` (FieldLayout wrapper; standalone controls spread it after
75
+ * their surface fragment since they are their own assembly).
76
+ */
77
+ export interface DisabledRecipe {
78
+ surfaceKnobProps: DisabledSurfaceProps;
79
+ selectedSurfaceKnobProps: DisabledSurfaceProps;
80
+ chromeKnobProps: DisabledChromeProps;
81
+ textKnobProps: DisabledTextProps;
82
+ assemblyKnobProps: DisabledAssemblyProps;
83
+ /** Effective `disabledStyle` knob value ("keepLabel" | "dimWhole"). */
84
+ style: string;
22
85
  }
23
86
 
24
87
  /**
@@ -34,18 +97,65 @@ export interface KnobRecipe<T> {
34
97
 
35
98
  // ── KnobProps: resolved style objects from knobs ──────────────────────────
36
99
 
100
+ /**
101
+ * Corner smoothing carrier (Axiom 15 OPTICS): radius-bearing fragments gain
102
+ * `className: "mp-corner-smooth"` when `cornerSmoothing: "smooth"` (web
103
+ * only) — the mounted stylesheet in `cornerSmoothing.ts` resolves it to
104
+ * `corner-shape: squircle`, a Chromium 139+ progressive enhancement other
105
+ * engines ignore. Absent at `round` and on native.
106
+ */
107
+ export interface CornerSmoothingCarrier {
108
+ className?: string;
109
+ }
110
+
37
111
  export interface KnobProps {
38
- borderRadius: { borderRadius: string; borderWidth: number };
39
- borderRadiusOuter: { borderRadius: string };
40
- elevation: string | undefined;
41
- surface: { borderWidth: number };
112
+ borderRadius: {
113
+ borderRadius: string;
114
+ borderColor: string;
115
+ borderWidth: number;
116
+ } & CornerSmoothingCarrier;
117
+ borderRadiusOuter: { borderRadius: string } & CornerSmoothingCarrier;
118
+ /**
119
+ * Axiom 1 child-clip cap for padded containers (Card/Panel/DialogContent):
120
+ * the radius token while it fits, or the padding px once it would exceed
121
+ * the container's own inset. Spread after the radius-bearing fragment.
122
+ */
123
+ containerRadius: { borderRadius: string | number } & CornerSmoothingCarrier;
124
+ /** Size token on web ("$1"); numeric tokens.size value on native (RN-safe). */
125
+ elevation: string | number | undefined;
126
+ surface: { backgroundColor: string; borderColor: string; borderWidth: number };
127
+ /** Content tier (Bento content card): padded frame, no border, no shadow. Radius carries the container cap. */
128
+ cardSurface: {
129
+ backgroundColor: string;
130
+ borderRadius: string | number;
131
+ padding: string;
132
+ } & CornerSmoothingCarrier;
133
+ /** Elevated/overlay tier (popovers, dropdowns): border + elevation. */
134
+ elevatedSurface: {
135
+ backgroundColor: string;
136
+ borderColor: string;
137
+ borderWidth: number;
138
+ borderRadius: string;
139
+ elevation: string | number | undefined;
140
+ } & CornerSmoothingCarrier;
141
+ /** Feature/marketing tier (auth, hero cards): big radius + ultra-soft wide shadow. */
142
+ featureSurface: {
143
+ backgroundColor: string;
144
+ borderRadius: string;
145
+ padding: string;
146
+ shadowColor: string;
147
+ shadowOffset: { width: number; height: number };
148
+ shadowOpacity: number;
149
+ shadowRadius: number;
150
+ } & CornerSmoothingCarrier;
42
151
  inputSurface: { borderWidth: number };
43
152
  panelPadding: { padding: string };
44
153
  gap: { gap: string };
45
154
  gapLg: { gap: string };
46
155
  sizeToken: string;
47
156
  heading: { fontFamily: string; fontWeight: string };
48
- body: { fontFamily: string; fontWeight: string };
157
+ /** color is set only for solid semantic-intent Buttons (error/warning/success). */
158
+ body: { fontFamily: string; fontWeight: string; color?: ColorTokens };
49
159
  textWeight: { fontWeight: string };
50
160
  transition: TransitionProp | undefined;
51
161
  outlined: boolean;
@@ -55,6 +165,17 @@ export interface KnobProps {
55
165
  inputBackground: string;
56
166
  space: string;
57
167
  size: string;
168
+ /** Effective density mode after compact override resolution. */
169
+ density: string;
170
+ /** House decision knobs — pass-through for consumers (forms/tables). */
171
+ fieldLabelPlacement: string;
172
+ requiredMarking: string;
173
+ tableZebra: string;
174
+ bulkBarPlacement: string;
175
+ selectAllScope: string;
176
+ timestampStyle: string;
177
+ disabledStyle: string;
178
+ formAutofocus: string;
58
179
  }
59
180
 
60
181
  // ── Return type ───────────────────────────────────────────────────────────
@@ -64,6 +185,8 @@ export interface ResolvedKnobs {
64
185
  control: KnobRecipe<ControlStateProps>;
65
186
  text: KnobRecipe<TextStateProps>;
66
187
  elevation: KnobRecipe<ElevationStateProps>;
188
+ /** LC-40: resolved disabled treatment (named to avoid `disabled` prop clashes). */
189
+ disabledState: DisabledRecipe;
67
190
  }
68
191
 
69
192
  export type KnobPropsOverride = (knobProps: KnobProps, knobs: Knobs) => Partial<KnobProps>;