@multiplatform.one/theme 6.0.4 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +73 -0
  2. package/package.json +22 -9
  3. package/src/dates.spec.ts +75 -0
  4. package/src/dates.ts +130 -0
  5. package/src/devWarn.spec.ts +144 -0
  6. package/src/devWarn.ts +235 -0
  7. package/src/figma/figmaTokens.spec.ts +244 -0
  8. package/src/figma/figmaTokens.ts +503 -0
  9. package/src/font.ts +6 -8
  10. package/src/fonts/createFontLoader.web.ts +15 -4
  11. package/src/index.ts +7 -0
  12. package/src/keyboardFocusRing.ts +45 -0
  13. package/src/menuRow.spec.ts +32 -0
  14. package/src/menuRow.ts +31 -0
  15. package/src/theme/FontKnobStyles.native.tsx +24 -0
  16. package/src/theme/FontKnobStyles.tsx +256 -0
  17. package/src/theme/animations/reactNative.ts +6 -1
  18. package/src/theme/chartPalette.spec.ts +159 -0
  19. package/src/theme/chartPalette.ts +170 -0
  20. package/src/theme/colorRules.ts +33 -1
  21. package/src/theme/cookies.spec.ts +32 -0
  22. package/src/theme/cookiesPersistence.spec.ts +86 -0
  23. package/src/theme/cornerSmoothing.native.ts +16 -0
  24. package/src/theme/cornerSmoothing.spec.ts +27 -0
  25. package/src/theme/cornerSmoothing.ts +46 -0
  26. package/src/theme/createDefaultThemeConfig.ts +32 -8
  27. package/src/theme/createThemeConfig.spec.ts +137 -0
  28. package/src/theme/createThemes.ts +77 -2
  29. package/src/theme/defaults/accent.ts +39 -0
  30. package/src/theme/defaults/base.ts +63 -0
  31. package/src/theme/defaults/builderOptions.ts +174 -0
  32. package/src/theme/defaults/fonts.spec.ts +87 -0
  33. package/src/theme/defaults/fonts.ts +89 -0
  34. package/src/theme/defaults/index.ts +4 -0
  35. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
  36. package/src/theme/devtools/buildSnapshot.ts +11 -0
  37. package/src/theme/devtools/knobIcons.tsx +141 -0
  38. package/src/theme/devtools/vizConstants.ts +1 -1
  39. package/src/theme/focusState.spec.ts +174 -0
  40. package/src/theme/focusState.ts +112 -0
  41. package/src/theme/fontKnobStyles.spec.ts +98 -0
  42. package/src/theme/hairline.native.ts +43 -0
  43. package/src/theme/hairline.spec.ts +63 -0
  44. package/src/theme/hairline.ts +101 -0
  45. package/src/theme/index.ts +8 -0
  46. package/src/theme/intent.spec.tsx +2 -2
  47. package/src/theme/intents.ts +6 -2
  48. package/src/theme/knobs.ts +134 -2
  49. package/src/theme/layoutTokens.spec.ts +75 -0
  50. package/src/theme/layoutTokens.ts +213 -0
  51. package/src/theme/layoutTokensHooks.spec.tsx +157 -0
  52. package/src/theme/presets.spec.ts +17 -0
  53. package/src/theme/presets.ts +22 -2
  54. package/src/theme/readableColor.ts +101 -0
  55. package/src/theme/recipes.ts +130 -7
  56. package/src/theme/resolveKnobs.spec.ts +560 -21
  57. package/src/theme/resolveKnobs.ts +267 -23
  58. package/src/theme/shared.tsx +26 -2
  59. package/src/theme/useResolvedKnobs.ts +67 -26
  60. package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
  61. package/types/audit/constraintAudit.d.ts +83 -0
  62. package/types/audit/constraintAudit.d.ts.map +1 -0
  63. package/types/audit/diffReport.d.ts +45 -0
  64. package/types/audit/diffReport.d.ts.map +1 -0
  65. package/types/audit/index.d.ts +5 -0
  66. package/types/audit/index.d.ts.map +1 -0
  67. package/types/dates.d.ts +52 -0
  68. package/types/dates.d.ts.map +1 -0
  69. package/types/devWarn.d.ts +104 -0
  70. package/types/devWarn.d.ts.map +1 -0
  71. package/types/figma/figmaTokens.d.ts +168 -0
  72. package/types/figma/figmaTokens.d.ts.map +1 -0
  73. package/types/font.d.ts +5 -0
  74. package/types/font.d.ts.map +1 -0
  75. package/types/fonts/createFontLoader.d.ts +3 -0
  76. package/types/fonts/createFontLoader.d.ts.map +1 -0
  77. package/types/fonts/createFontLoader.web.d.ts +3 -0
  78. package/types/fonts/createFontLoader.web.d.ts.map +1 -0
  79. package/types/fonts/index.d.ts +3 -0
  80. package/types/fonts/index.d.ts.map +1 -0
  81. package/types/fonts/types.d.ts +12 -0
  82. package/types/fonts/types.d.ts.map +1 -0
  83. package/types/index.d.ts +9 -0
  84. package/types/index.d.ts.map +1 -0
  85. package/types/keyboardFocusRing.d.ts +32 -0
  86. package/types/keyboardFocusRing.d.ts.map +1 -0
  87. package/types/menuRow.d.ts +32 -0
  88. package/types/menuRow.d.ts.map +1 -0
  89. package/types/theme/FontKnobStyles.d.ts +49 -0
  90. package/types/theme/FontKnobStyles.d.ts.map +1 -0
  91. package/types/theme/FontKnobStyles.native.d.ts +12 -0
  92. package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
  93. package/types/theme/Intent.d.ts +20 -0
  94. package/types/theme/Intent.d.ts.map +1 -0
  95. package/types/theme/Preset.d.ts +15 -0
  96. package/types/theme/Preset.d.ts.map +1 -0
  97. package/types/theme/PresetContext.d.ts +18 -0
  98. package/types/theme/PresetContext.d.ts.map +1 -0
  99. package/types/theme/Tint.d.ts +19 -0
  100. package/types/theme/Tint.d.ts.map +1 -0
  101. package/types/theme/animations/css.d.ts +24 -0
  102. package/types/theme/animations/css.d.ts.map +1 -0
  103. package/types/theme/animations/index.d.ts +2 -0
  104. package/types/theme/animations/index.d.ts.map +1 -0
  105. package/types/theme/animations/index.web.d.ts +2 -0
  106. package/types/theme/animations/index.web.d.ts.map +1 -0
  107. package/types/theme/animations/reactNative.d.ts +104 -0
  108. package/types/theme/animations/reactNative.d.ts.map +1 -0
  109. package/types/theme/chartPalette.d.ts +62 -0
  110. package/types/theme/chartPalette.d.ts.map +1 -0
  111. package/types/theme/colorRules.d.ts +69 -0
  112. package/types/theme/colorRules.d.ts.map +1 -0
  113. package/types/theme/cookies.d.ts +76 -0
  114. package/types/theme/cookies.d.ts.map +1 -0
  115. package/types/theme/cornerSmoothing.d.ts +31 -0
  116. package/types/theme/cornerSmoothing.d.ts.map +1 -0
  117. package/types/theme/cornerSmoothing.native.d.ts +14 -0
  118. package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
  119. package/types/theme/createDefaultThemeConfig.d.ts +37 -0
  120. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
  121. package/types/theme/createThemes.d.ts +38 -0
  122. package/types/theme/createThemes.d.ts.map +1 -0
  123. package/types/theme/defaults/accent.d.ts +3 -0
  124. package/types/theme/defaults/accent.d.ts.map +1 -0
  125. package/types/theme/defaults/base.d.ts +3 -0
  126. package/types/theme/defaults/base.d.ts.map +1 -0
  127. package/types/theme/defaults/builderOptions.d.ts +3 -0
  128. package/types/theme/defaults/builderOptions.d.ts.map +1 -0
  129. package/types/theme/defaults/fonts.d.ts +7 -0
  130. package/types/theme/defaults/fonts.d.ts.map +1 -0
  131. package/types/theme/defaults/index.d.ts +5 -0
  132. package/types/theme/defaults/index.d.ts.map +1 -0
  133. package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
  134. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
  135. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
  136. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
  137. package/types/theme/devtools/buildSnapshot.d.ts +12 -0
  138. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
  139. package/types/theme/devtools/knobIcons.d.ts +33 -0
  140. package/types/theme/devtools/knobIcons.d.ts.map +1 -0
  141. package/types/theme/devtools/vizConstants.d.ts +5 -0
  142. package/types/theme/devtools/vizConstants.d.ts.map +1 -0
  143. package/types/theme/focusState.d.ts +42 -0
  144. package/types/theme/focusState.d.ts.map +1 -0
  145. package/types/theme/hairline.d.ts +88 -0
  146. package/types/theme/hairline.d.ts.map +1 -0
  147. package/types/theme/hairline.native.d.ts +42 -0
  148. package/types/theme/hairline.native.d.ts.map +1 -0
  149. package/types/theme/index.d.ts +29 -0
  150. package/types/theme/index.d.ts.map +1 -0
  151. package/types/theme/intents.d.ts +3 -0
  152. package/types/theme/intents.d.ts.map +1 -0
  153. package/types/theme/knobs.d.ts +236 -0
  154. package/types/theme/knobs.d.ts.map +1 -0
  155. package/types/theme/layoutTokens.d.ts +104 -0
  156. package/types/theme/layoutTokens.d.ts.map +1 -0
  157. package/types/theme/preset.types.d.ts +21 -0
  158. package/types/theme/preset.types.d.ts.map +1 -0
  159. package/types/theme/presets.d.ts +4 -0
  160. package/types/theme/presets.d.ts.map +1 -0
  161. package/types/theme/readableColor.d.ts +17 -0
  162. package/types/theme/readableColor.d.ts.map +1 -0
  163. package/types/theme/recipes.d.ts +200 -0
  164. package/types/theme/recipes.d.ts.map +1 -0
  165. package/types/theme/resolveKnobs.d.ts +22 -0
  166. package/types/theme/resolveKnobs.d.ts.map +1 -0
  167. package/types/theme/shared.d.ts +40 -0
  168. package/types/theme/shared.d.ts.map +1 -0
  169. package/types/theme/theme.d.ts +19 -0
  170. package/types/theme/theme.d.ts.map +1 -0
  171. package/types/theme/theme.native.d.ts +10 -0
  172. package/types/theme/theme.native.d.ts.map +1 -0
  173. package/types/theme/useColorScale.d.ts +7 -0
  174. package/types/theme/useColorScale.d.ts.map +1 -0
  175. package/types/theme/useColorScale.native.d.ts +6 -0
  176. package/types/theme/useColorScale.native.d.ts.map +1 -0
  177. package/types/theme/useResolvedKnobs.d.ts +32 -0
  178. package/types/theme/useResolvedKnobs.d.ts.map +1 -0
  179. package/types/theme/useTheme.d.ts +31 -0
  180. package/types/theme/useTheme.d.ts.map +1 -0
  181. package/types/theme/useTheme.native.d.ts +10 -0
  182. package/types/theme/useTheme.native.d.ts.map +1 -0
  183. package/CHANGELOG.md +0 -46
  184. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
  185. package/src/theme/pokemonScreen.spec.ts +0 -109
  186. package/tsconfig.json +0 -11
  187. package/vitest.config.mjs +0 -9
@@ -28,6 +28,19 @@ export function sizeIcon(value: string, color: string): ReactNode {
28
28
  return <div style={{ width: 4, height: height, background: color, borderRadius: 1 }} />;
29
29
  }
30
30
 
31
+ export function densityIcon(value: string, color: string): ReactNode {
32
+ // Comfortable = taller stack; compact = compressed stack.
33
+ const gap = value === "compact" ? 1 : 2;
34
+ const barH = value === "compact" ? 3 : 4;
35
+ return (
36
+ <div style={{ display: "flex", flexDirection: "column", gap, width: 14 }}>
37
+ <div style={{ height: barH, background: color, borderRadius: 1 }} />
38
+ <div style={{ height: barH, background: color, borderRadius: 1 }} />
39
+ <div style={{ height: barH, background: color, borderRadius: 1 }} />
40
+ </div>
41
+ );
42
+ }
43
+
31
44
  export function borderRadiusIcon(value: string, color: string): ReactNode {
32
45
  const radius =
33
46
  ({ none: 0, small: 2, medium: 4, large: 7, full: 10 } as Record<string, number>)[value] ?? 0;
@@ -36,6 +49,26 @@ export function borderRadiusIcon(value: string, color: string): ReactNode {
36
49
  );
37
50
  }
38
51
 
52
+ export function cornerSmoothingIcon(value: string, color: string): ReactNode {
53
+ // Same nominal corner size, different curvature: circular-arc rounded
54
+ // rect for `round`, continuous-curvature superellipse for `smooth`.
55
+ const smooth = value === "smooth";
56
+ return (
57
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" role="img" aria-label={value}>
58
+ <title>{value}</title>
59
+ {smooth ? (
60
+ <path
61
+ d="M7 1 C2.8 1 1 2.8 1 7 C1 11.2 2.8 13 7 13 C11.2 13 13 11.2 13 7 C13 2.8 11.2 1 7 1 Z"
62
+ stroke={color}
63
+ strokeWidth="1.5"
64
+ />
65
+ ) : (
66
+ <rect x="1" y="1" width="12" height="12" rx="4" stroke={color} strokeWidth="1.5" />
67
+ )}
68
+ </svg>
69
+ );
70
+ }
71
+
39
72
  export function borderWidthIcon(value: string, color: string): ReactNode {
40
73
  if (value === "none") {
41
74
  return (
@@ -215,3 +248,111 @@ export function modeIcon(value: "system" | "light" | "dark", color: string): Rea
215
248
  }
216
249
  return <div style={{ ...base, background: color }} />;
217
250
  }
251
+
252
+ /** Compact text glyph used by house-decision knobs in the panel. */
253
+ function glyph(label: string, color: string, size = 9): ReactNode {
254
+ return (
255
+ <span
256
+ style={{
257
+ fontSize: size,
258
+ fontWeight: 600,
259
+ color,
260
+ fontFamily: "system-ui, sans-serif",
261
+ lineHeight: 1,
262
+ letterSpacing: "-0.02em",
263
+ }}
264
+ >
265
+ {label}
266
+ </span>
267
+ );
268
+ }
269
+
270
+ export function fieldLabelPlacementIcon(value: string, color: string): ReactNode {
271
+ if (value === "side") return glyph("⊟", color, 12);
272
+ if (value === "floating") return glyph("⬚", color, 12);
273
+ return glyph("⊞", color, 12); // top
274
+ }
275
+
276
+ export function requiredMarkingIcon(value: string, color: string): ReactNode {
277
+ if (value === "asterisk") return glyph("*", color, 14);
278
+ if (value === "optional") return glyph("opt", color, 8);
279
+ return glyph("min", color, 8); // minority
280
+ }
281
+
282
+ export function tableZebraIcon(value: string, color: string): ReactNode {
283
+ const on = value === "on";
284
+ return (
285
+ <div style={{ display: "flex", flexDirection: "column", gap: 1, width: 14 }}>
286
+ <div style={{ height: 3, background: color, borderRadius: 1, opacity: on ? 1 : 0.35 }} />
287
+ <div style={{ height: 3, background: color, borderRadius: 1, opacity: on ? 0.35 : 0.35 }} />
288
+ <div style={{ height: 3, background: color, borderRadius: 1, opacity: on ? 1 : 0.35 }} />
289
+ </div>
290
+ );
291
+ }
292
+
293
+ export function bulkBarPlacementIcon(value: string, color: string): ReactNode {
294
+ const top = value !== "bottom";
295
+ return (
296
+ <div
297
+ style={{
298
+ width: 14,
299
+ height: 14,
300
+ border: `1px solid ${color}`,
301
+ borderRadius: 2,
302
+ display: "flex",
303
+ flexDirection: "column",
304
+ justifyContent: top ? "flex-start" : "flex-end",
305
+ padding: 1,
306
+ }}
307
+ >
308
+ <div style={{ height: 3, background: color, borderRadius: 1 }} />
309
+ </div>
310
+ );
311
+ }
312
+
313
+ export function selectAllScopeIcon(value: string, color: string): ReactNode {
314
+ const filtered = value === "filtered";
315
+ // Three rows; page scope highlights only the top rows (current page),
316
+ // filtered scope highlights all rows (all matching).
317
+ return (
318
+ <div style={{ display: "flex", flexDirection: "column", gap: 1, width: 14 }}>
319
+ <div style={{ height: 3, background: color, borderRadius: 1 }} />
320
+ <div style={{ height: 3, background: color, borderRadius: 1 }} />
321
+ <div style={{ height: 3, background: color, borderRadius: 1, opacity: filtered ? 1 : 0.3 }} />
322
+ </div>
323
+ );
324
+ }
325
+
326
+ export function timestampStyleIcon(value: string, color: string): ReactNode {
327
+ return glyph(value === "relative" ? "~t" : "12:00", color, value === "relative" ? 10 : 7);
328
+ }
329
+
330
+ export function disabledStyleIcon(value: string, color: string): ReactNode {
331
+ if (value === "dimWhole") {
332
+ return (
333
+ <div style={{ width: 14, height: 14, background: color, borderRadius: 2, opacity: 0.35 }} />
334
+ );
335
+ }
336
+ // keepLabel — solid label bar over a dimmed body
337
+ return (
338
+ <div style={{ display: "flex", flexDirection: "column", gap: 2, width: 14 }}>
339
+ <div style={{ height: 3, background: color, borderRadius: 1 }} />
340
+ <div style={{ height: 7, background: color, borderRadius: 1, opacity: 0.3 }} />
341
+ </div>
342
+ );
343
+ }
344
+
345
+ export function formAutofocusIcon(value: string, color: string): ReactNode {
346
+ return (
347
+ <div
348
+ style={{
349
+ width: 14,
350
+ height: 14,
351
+ border: `1.5px solid ${color}`,
352
+ borderRadius: 2,
353
+ boxShadow: value === "on" ? `0 0 0 2px ${color}` : undefined,
354
+ opacity: value === "on" ? 1 : 0.45,
355
+ }}
356
+ />
357
+ );
358
+ }
@@ -1,4 +1,4 @@
1
- export function luminance(rgb: string): number {
1
+ function luminance(rgb: string): number {
2
2
  const canvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
3
3
  if (!canvas) return 0.5;
4
4
  const context = canvas.getContext("2d");
@@ -0,0 +1,174 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { __resetDevWarnSeen, warnOutlineNone, warnPositiveTabIndex } from "../devWarn";
3
+ import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
4
+ import { createThemesBuilder } from "./createThemes";
5
+ import { defaultAccentTheme } from "./defaults/accent";
6
+ import { defaultBaseTheme } from "./defaults/base";
7
+ import { defaultBuilderOptions } from "./defaults/builderOptions";
8
+ import {
9
+ ensureFocusVisibleRing,
10
+ focusRingColorMeetsContrast,
11
+ FOCUS_RING_MIN_CONTRAST,
12
+ FOCUS_RING_MIN_WIDTH,
13
+ FOCUS_VISIBLE_RING,
14
+ STATE_LAYER_HOVER,
15
+ STATE_LAYER_PRESS,
16
+ withDefaultStateLayer,
17
+ } from "./focusState";
18
+ import { defaultKnobs } from "./knobs";
19
+ import { resolveKnobs } from "./resolveKnobs";
20
+
21
+ describe("focusState tokens (DG-A11Y-01 / DG-STATE-01 / W12)", () => {
22
+ it("FOCUS_VISIBLE_RING meets ≥2px solid outline contract", () => {
23
+ expect(FOCUS_VISIBLE_RING.outlineWidth).toBeGreaterThanOrEqual(FOCUS_RING_MIN_WIDTH);
24
+ expect(FOCUS_VISIBLE_RING.outlineStyle).toBe("solid");
25
+ expect(FOCUS_VISIBLE_RING.outlineColor).toBe("$outlineColor");
26
+ expect(FOCUS_RING_MIN_CONTRAST).toBeGreaterThanOrEqual(3);
27
+ });
28
+
29
+ it("ensureFocusVisibleRing always emits a ≥2px solid ring", () => {
30
+ expect(ensureFocusVisibleRing()).toMatchObject(FOCUS_VISIBLE_RING);
31
+ expect(ensureFocusVisibleRing({})).toMatchObject(FOCUS_VISIBLE_RING);
32
+ });
33
+
34
+ it("ensureFocusVisibleRing clamps outlineWidth < 2 and rejects outline:none", () => {
35
+ const clamped = ensureFocusVisibleRing({
36
+ outlineWidth: 0,
37
+ outlineStyle: "none",
38
+ outlineColor: "transparent",
39
+ borderWidth: 1,
40
+ });
41
+ expect(clamped.outlineWidth).toBe(FOCUS_RING_MIN_WIDTH);
42
+ expect(clamped.outlineStyle).toBe("solid");
43
+ expect(clamped.outlineColor).toBe("$outlineColor");
44
+ expect(clamped.borderWidth).toBe(1);
45
+ });
46
+
47
+ it("ensureFocusVisibleRing allows thicker rings and preserves extras", () => {
48
+ const thick = ensureFocusVisibleRing({
49
+ outlineWidth: 4,
50
+ borderColor: "$borderColorFocus",
51
+ } as { outlineWidth: number; borderColor: string });
52
+ expect(thick.outlineWidth).toBe(4);
53
+ expect(thick.borderColor).toBe("$borderColorFocus");
54
+ });
55
+
56
+ it("ensureFocusVisibleRing reverts alpha-carrying ring colors (DF-03)", () => {
57
+ // The stock Tamagui ring (20%-alpha ink) can't guarantee ≥3:1.
58
+ for (const outlineColor of ["rgba(36, 34, 38, 0.2)", "hsla(270, 6%, 14%, 0.2)", "#2422260a"]) {
59
+ expect(
60
+ ensureFocusVisibleRing({ outlineColor } as { outlineColor: string }).outlineColor,
61
+ ).toBe(FOCUS_VISIBLE_RING.outlineColor);
62
+ }
63
+ // A solid override is respected.
64
+ expect(
65
+ ensureFocusVisibleRing({ outlineColor: "#5b5563" } as { outlineColor: string }).outlineColor,
66
+ ).toBe("#5b5563");
67
+ });
68
+
69
+ it("focusRingColorMeetsContrast enforces the ≥3:1 color floor", () => {
70
+ expect(focusRingColorMeetsContrast("#767085", "#ffffff")).toBe(true);
71
+ expect(focusRingColorMeetsContrast("#e6e4e7", "#ffffff")).toBe(false);
72
+ // Alpha colors can't be reduced to a single hue → treated as failing.
73
+ expect(focusRingColorMeetsContrast("rgba(36,34,38,0.2)", "#ffffff")).toBe(false);
74
+ });
75
+
76
+ it("withDefaultStateLayer fills background when unset; knob wins when set", () => {
77
+ expect(withDefaultStateLayer({}, STATE_LAYER_HOVER)).toEqual(STATE_LAYER_HOVER);
78
+ expect(withDefaultStateLayer({ borderWidth: 2 }, STATE_LAYER_PRESS)).toEqual({
79
+ backgroundColor: "$backgroundPress",
80
+ borderWidth: 2,
81
+ });
82
+ expect(withDefaultStateLayer({ backgroundColor: "transparent" }, STATE_LAYER_HOVER)).toEqual({
83
+ backgroundColor: "transparent",
84
+ });
85
+ });
86
+ });
87
+
88
+ describe("$outlineColor token contrast (DF-03 / DG-A11Y-01)", () => {
89
+ const themes = createThemesBuilder(
90
+ defaultBaseTheme,
91
+ defaultAccentTheme,
92
+ defaultBuilderOptions,
93
+ ).themes() as Record<string, Record<string, string>>;
94
+
95
+ for (const scheme of ["light", "dark"] as const) {
96
+ it(`${scheme} $outlineColor clears ≥3:1 against color1 and color2`, () => {
97
+ const theme = themes[scheme];
98
+ expect(theme).toBeTruthy();
99
+ const ring = normalizeToHex(theme.outlineColor);
100
+ const bg1 = normalizeToHex(theme.color1);
101
+ const bg2 = normalizeToHex(theme.color2);
102
+ expect(ring).toBeTruthy();
103
+ expect(bg1).toBeTruthy();
104
+ expect(bg2).toBeTruthy();
105
+ const c1 = contrastRatio(relativeLuminance(ring!), relativeLuminance(bg1!));
106
+ const c2 = contrastRatio(relativeLuminance(ring!), relativeLuminance(bg2!));
107
+ expect(Math.min(c1, c2)).toBeGreaterThanOrEqual(FOCUS_RING_MIN_CONTRAST);
108
+ });
109
+ }
110
+ });
111
+
112
+ describe("resolveKnobs control recipes (W12)", () => {
113
+ it("always emits hover/press state layers and focus-visible ring", () => {
114
+ const { control } = resolveKnobs(defaultKnobs);
115
+ expect(control.hoverKnobProps).toEqual(STATE_LAYER_HOVER);
116
+ expect(control.pressKnobProps).toEqual(STATE_LAYER_PRESS);
117
+ expect(control.focusVisibleKnobProps).toMatchObject(FOCUS_VISIBLE_RING);
118
+ expect(control.focusVisibleKnobProps?.outlineWidth).toBeGreaterThanOrEqual(2);
119
+ });
120
+
121
+ it("merges knob overrides without removing the focus-visible ring", () => {
122
+ const { control } = resolveKnobs({
123
+ ...defaultKnobs,
124
+ focusVisible: { borderWidth: "large", borderRadius: "full" },
125
+ hover: { borderWidth: "large" },
126
+ press: { fillStyle: "outlined" },
127
+ });
128
+ expect(control.focusVisibleKnobProps).toMatchObject({
129
+ ...FOCUS_VISIBLE_RING,
130
+ borderWidth: 2,
131
+ borderRadius: "$12",
132
+ });
133
+ expect(control.hoverKnobProps).toMatchObject({
134
+ backgroundColor: "$backgroundHover",
135
+ borderWidth: 2,
136
+ });
137
+ expect(control.pressKnobProps?.backgroundColor).toBe("transparent");
138
+ });
139
+ });
140
+
141
+ describe("theme DEV warn (outline-none / positive-tabindex)", () => {
142
+ afterEach(() => {
143
+ __resetDevWarnSeen();
144
+ vi.restoreAllMocks();
145
+ });
146
+
147
+ it("warnOutlineNone fires once for outline:none / width 0", () => {
148
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
149
+ warnOutlineNone({ outlineStyle: "none" }, { component: "Test" });
150
+ warnOutlineNone({ outlineStyle: "none" }, { component: "Test" });
151
+ warnOutlineNone({ outlineWidth: 0 }, { component: "Other" });
152
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("outline-none"));
153
+ expect(spy).toHaveBeenCalledTimes(2);
154
+ });
155
+
156
+ it("warnOutlineNone ignores healthy focus rings", () => {
157
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
158
+ warnOutlineNone(FOCUS_VISIBLE_RING, { component: "Test" });
159
+ expect(spy).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it("warnPositiveTabIndex fires for tabIndex > 0 only", () => {
163
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
164
+ warnPositiveTabIndex({ value: 0, component: "Test" });
165
+ warnPositiveTabIndex({ value: -1, component: "Test" });
166
+ warnPositiveTabIndex({ value: undefined, component: "Test" });
167
+ expect(spy).not.toHaveBeenCalled();
168
+ warnPositiveTabIndex({ value: 2, component: "Test" });
169
+ warnPositiveTabIndex({ value: 2, component: "Test" });
170
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("positive-tabindex"));
171
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("value=2"));
172
+ expect(spy).toHaveBeenCalledTimes(1);
173
+ });
174
+ });
@@ -0,0 +1,112 @@
1
+ import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
2
+ import type { ControlStateProps } from "./recipes";
3
+
4
+ /**
5
+ * DG-A11Y-01 — keyboard focus-visible ring.
6
+ * Width ≥2px; color is the theme `$outlineColor` token (scheme-aware, ≥3:1
7
+ * against adjacent surface in both light and dark).
8
+ */
9
+ export const FOCUS_RING_MIN_WIDTH = 2;
10
+ export const FOCUS_RING_MIN_CONTRAST = 3;
11
+
12
+ /**
13
+ * True when a CSS color carries alpha < 1 (rgba/hsla/#rrggbbaa). Such a ring
14
+ * cannot guarantee the ≥3:1 floor — its effective contrast depends on the
15
+ * backdrop it composites over (this is exactly how the stock Tamagui ring,
16
+ * `rgba(ink, 0.2)`, dropped to 1.36:1). The guard reverts these to the solid
17
+ * `$outlineColor` token.
18
+ */
19
+ function ringColorCarriesAlpha(color: string): boolean {
20
+ const s = color.trim().toLowerCase();
21
+ const fn = s.match(/^(?:rgba|hsla)\([^)]*[,/]\s*([\d.]+%?)\s*\)$/);
22
+ if (fn) {
23
+ const raw = fn[1];
24
+ const a = raw.endsWith("%") ? Number.parseFloat(raw) / 100 : Number.parseFloat(raw);
25
+ return Number.isFinite(a) && a < 0.999;
26
+ }
27
+ if (/^#[0-9a-f]{8}$/.test(s)) {
28
+ return Number.parseInt(s.slice(7, 9), 16) / 255 < 0.999;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * VERIFY helper (DG-A11Y-01 / Axiom 12): does a *resolved* ring color clear
35
+ * the ≥3:1 contrast floor against a given surface? Both args are CSS color
36
+ * strings (hex/hsl/named). Returns false when either can't be parsed (e.g. an
37
+ * alpha-blended color that can't be reduced to a single hue) so callers treat
38
+ * unresolved rings as failing.
39
+ */
40
+ export function focusRingColorMeetsContrast(ringColor: string, surfaceColor: string): boolean {
41
+ const ring = normalizeToHex(ringColor);
42
+ const surface = normalizeToHex(surfaceColor);
43
+ if (!ring || !surface) return false;
44
+ return (
45
+ contrastRatio(relativeLuminance(ring), relativeLuminance(surface)) >= FOCUS_RING_MIN_CONTRAST
46
+ );
47
+ }
48
+
49
+ export const FOCUS_VISIBLE_RING = {
50
+ outlineWidth: FOCUS_RING_MIN_WIDTH,
51
+ outlineStyle: "solid" as const,
52
+ outlineColor: "$outlineColor",
53
+ outlineOffset: 2,
54
+ } satisfies ControlStateProps;
55
+
56
+ /**
57
+ * DG-STATE-01 — Material-style state layers as theme tokens.
58
+ * Ramp lives in `defaults/builderOptions` (hover/press background steps);
59
+ * recipes overlay those tokens rather than inventing a second channel.
60
+ */
61
+ export const STATE_LAYER_HOVER = {
62
+ backgroundColor: "$backgroundHover",
63
+ } satisfies ControlStateProps;
64
+
65
+ export const STATE_LAYER_PRESS = {
66
+ backgroundColor: "$backgroundPress",
67
+ } satisfies ControlStateProps;
68
+
69
+ /**
70
+ * Merge + clamp so variants/knobs cannot remove the focus-visible ring.
71
+ * Transparent / none / sub-2px outlines are restored to the baseline token.
72
+ * Extra style keys (e.g. `borderColor`) are preserved for consumer merges.
73
+ */
74
+ export function ensureFocusVisibleRing<T extends ControlStateProps>(
75
+ override?: (T & ControlStateProps) | null,
76
+ ): T & ControlStateProps {
77
+ const merged = {
78
+ ...FOCUS_VISIBLE_RING,
79
+ ...override,
80
+ } as T & ControlStateProps;
81
+ const width =
82
+ typeof merged.outlineWidth === "number" ? merged.outlineWidth : FOCUS_RING_MIN_WIDTH;
83
+ merged.outlineWidth = Math.max(width, FOCUS_RING_MIN_WIDTH);
84
+ if (merged.outlineStyle !== "solid") {
85
+ merged.outlineStyle = "solid";
86
+ }
87
+ if (
88
+ !merged.outlineColor ||
89
+ merged.outlineColor === "transparent" ||
90
+ merged.outlineColor === "none" ||
91
+ // Enforce COLOR, not just shape: an alpha-carrying ring can't guarantee
92
+ // ≥3:1 (its contrast depends on the backdrop), so restore the solid token.
93
+ (typeof merged.outlineColor === "string" && ringColorCarriesAlpha(merged.outlineColor))
94
+ ) {
95
+ merged.outlineColor = FOCUS_VISIBLE_RING.outlineColor;
96
+ }
97
+ if (typeof merged.outlineOffset !== "number") {
98
+ merged.outlineOffset = FOCUS_VISIBLE_RING.outlineOffset;
99
+ }
100
+ return merged;
101
+ }
102
+
103
+ /** Apply default hover/press state-layer fill when knobs left background unset. */
104
+ export function withDefaultStateLayer(
105
+ control: ControlStateProps,
106
+ layer: ControlStateProps,
107
+ ): ControlStateProps {
108
+ if (control.backgroundColor !== undefined) {
109
+ return control;
110
+ }
111
+ return { ...layer, ...control };
112
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { FontScopeTables } from "./FontKnobStyles";
3
+ import { buildFontKnobCss, fontCategoryMetrics } from "./FontKnobStyles";
4
+ import type { FontCategory } from "./knobs";
5
+
6
+ const FAMILIES: Partial<Record<FontCategory, string>> = {
7
+ slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
8
+ mono: "monospace",
9
+ blackletter: "'UnifrakturCook', 'Fraktur', serif",
10
+ };
11
+
12
+ const resolveFamily = (category: FontCategory) => FAMILIES[category];
13
+ const noTables = () => undefined;
14
+
15
+ // Inter-shaped tables: display sizes tight (~1.16), small sizes loose, and
16
+ // negative display tracking — the exact shape the floors exist to correct.
17
+ const TABLES: Record<"heading" | "body", FontScopeTables> = {
18
+ heading: {
19
+ size: { 7: 28, 10: 64 },
20
+ lineHeight: { 7: 38, 10: 74 },
21
+ letterSpacing: { 7: 0, 10: -3 },
22
+ },
23
+ body: {
24
+ size: { 4: 14, 10: 46 },
25
+ lineHeight: { 4: 24, 10: 56 },
26
+ // positive caption tracking (tracking-by-size curve) + negative display
27
+ letterSpacing: { 1: 0.05, 10: -3 },
28
+ },
29
+ };
30
+ const resolveTables = (scope: "heading" | "body") => TABLES[scope];
31
+
32
+ describe("buildFontKnobCss", () => {
33
+ it("emits only zero-specificity default leading when both knobs are at the sans-serif default (LC-11)", () => {
34
+ const css = buildFontKnobCss("sans-serif", "sans-serif", resolveFamily, noTables);
35
+ expect(css).toContain(":where(:root .font_heading) { line-height: 1.25; }");
36
+ expect(css).toContain(":where(:root .font_body) { line-height: 1.5; }");
37
+ expect(css).not.toContain("--f-family");
38
+ });
39
+
40
+ it("overrides the heading font scope for a non-default headingFont", () => {
41
+ const css = buildFontKnobCss("slab", "sans-serif", resolveFamily, noTables);
42
+ expect(css).toContain(
43
+ ":root .font_heading { --f-family: 'Roboto Slab', 'Rockwell', 'Courier New', serif !important; }",
44
+ );
45
+ });
46
+
47
+ it("overrides the body font scope for a non-default bodyFont", () => {
48
+ const css = buildFontKnobCss("sans-serif", "mono", resolveFamily, noTables);
49
+ expect(css).toContain(":root .font_body { --f-family: monospace !important; }");
50
+ });
51
+
52
+ it("overrides both scopes independently", () => {
53
+ const css = buildFontKnobCss("blackletter", "mono", resolveFamily, noTables);
54
+ expect(css).toContain("--f-family: 'UnifrakturCook', 'Fraktur', serif !important");
55
+ expect(css).toContain(":root .font_body { --f-family: monospace !important");
56
+ });
57
+
58
+ it("skips the family override for unregistered fonts but keeps the category's default leading", () => {
59
+ const css = buildFontKnobCss("cursive", "cursive", resolveFamily, noTables);
60
+ expect(css).not.toContain("--f-family");
61
+ // cursive heading floor 1.3 beats the 1.25 minimum; body max(1.5, 1.5)
62
+ expect(css).toContain(":where(:root .font_heading) { line-height: 1.3; }");
63
+ expect(css).toContain(":where(:root .font_body) { line-height: 1.5; }");
64
+ });
65
+
66
+ it("lifts heading lineHeight to the category floor only where base leading is tighter", () => {
67
+ // mono heading floor 1.25: $10 base 74/64 = 1.16 -> 80px; $7 base 38/28 = 1.36 -> untouched
68
+ const css = buildFontKnobCss("mono", "sans-serif", resolveFamily, resolveTables);
69
+ expect(css).toContain("--f-lineHeight-10: 80px !important");
70
+ expect(css).not.toContain("--f-lineHeight-7");
71
+ });
72
+
73
+ it("lifts body lineHeight to the body floor for large sizes", () => {
74
+ // mono body floor 1.45: $10 base 56/46 = 1.22 -> 67px; $4 base 24/14 = 1.71 -> untouched
75
+ const css = buildFontKnobCss("sans-serif", "mono", resolveFamily, resolveTables);
76
+ expect(css).toContain("--f-lineHeight-10: 67px !important");
77
+ expect(css).not.toContain("--f-lineHeight-4");
78
+ });
79
+
80
+ it("zeroes all non-zero tracking for neutral-tracking categories only (LC-11)", () => {
81
+ const mono = buildFontKnobCss("mono", "sans-serif", resolveFamily, resolveTables);
82
+ expect(mono).toContain("--f-letterSpacing-10: 0px !important");
83
+ expect(mono).not.toContain("--f-letterSpacing-7"); // base 0 stays
84
+ // positive caption tracking is also Inter-specific: neutral zeroes it too
85
+ const monoBody = buildFontKnobCss("sans-serif", "mono", resolveFamily, resolveTables);
86
+ expect(monoBody).toContain("--f-letterSpacing-1: 0px !important");
87
+ expect(monoBody).toContain("--f-letterSpacing-10: 0px !important");
88
+ const slab = buildFontKnobCss("slab", "sans-serif", resolveFamily, resolveTables);
89
+ expect(slab).not.toContain("--f-letterSpacing"); // "inherit" tracking
90
+ });
91
+
92
+ it("keeps every category's heading floor at or above 1.15 and body at or above 1.4", () => {
93
+ for (const metrics of Object.values(fontCategoryMetrics)) {
94
+ expect(metrics.heading).toBeGreaterThanOrEqual(1.15);
95
+ expect(metrics.body).toBeGreaterThanOrEqual(1.4);
96
+ }
97
+ });
98
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * OPTICS hairline (Axiom 15) — native twin of `hairline.ts`.
3
+ *
4
+ * React Native's StyleSheet.hairlineWidth already resolves to one device
5
+ * pixel for the current screen density (e.g. 1/3 on 3x, 1/2 on 2x), so no
6
+ * fallback plumbing is needed; `className` entries are omitted (web-only).
7
+ */
8
+ import { StyleSheet } from "react-native";
9
+
10
+ /** Divider line width: one device pixel for the current screen density. */
11
+ export const hairlineWidth: number = StyleSheet.hairlineWidth;
12
+
13
+ /** Spreadable divider treatments — see `hairline.ts` for usage. */
14
+ export const hairline = {
15
+ /** Standalone horizontal rule as a filled hairline-height line. */
16
+ line: {
17
+ height: hairlineWidth,
18
+ maxHeight: hairlineWidth,
19
+ flexBasis: hairlineWidth,
20
+ borderBottomWidth: 0,
21
+ backgroundColor: "$borderColor",
22
+ },
23
+ /** Standalone vertical rule as a filled hairline-width line. */
24
+ vline: {
25
+ width: hairlineWidth,
26
+ maxWidth: hairlineWidth,
27
+ flexBasis: hairlineWidth,
28
+ borderRightWidth: 0,
29
+ backgroundColor: "$borderColor",
30
+ },
31
+ /** Already-filled 1px line elements (e.g. menu separator views). */
32
+ height: { height: hairlineWidth },
33
+ bottom: { borderBottomWidth: hairlineWidth },
34
+ top: { borderTopWidth: hairlineWidth },
35
+ right: { borderRightWidth: hairlineWidth },
36
+ left: { borderLeftWidth: hairlineWidth },
37
+ } as const;
38
+
39
+ /** Web-only fallback stylesheet — no-op on native. */
40
+ export const hairlineFallbackCss = "";
41
+
42
+ /** Web-only fallback mount — no-op on native. */
43
+ export function ensureHairlineFallback(): void {}
@@ -0,0 +1,63 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { ensureHairlineFallback, hairline, hairlineFallbackCss, hairlineWidth } from "./hairline";
3
+
4
+ describe("hairline (Axiom 15 OPTICS)", () => {
5
+ it("resolves half a CSS pixel on web", () => {
6
+ expect(hairlineWidth).toBe(0.5);
7
+ });
8
+
9
+ it("renders standalone rules as filled fractional-size lines (Blink floors sub-1px borders)", () => {
10
+ expect(hairline.line).toEqual({
11
+ height: 0.5,
12
+ maxHeight: 0.5,
13
+ flexBasis: 0.5,
14
+ borderBottomWidth: 0,
15
+ backgroundColor: "$borderColor",
16
+ className: "mp-hairline-h",
17
+ });
18
+ expect(hairline.vline).toEqual({
19
+ width: 0.5,
20
+ maxWidth: 0.5,
21
+ flexBasis: 0.5,
22
+ borderRightWidth: 0,
23
+ backgroundColor: "$borderColor",
24
+ className: "mp-hairline-w",
25
+ });
26
+ expect(hairline.height).toEqual({ height: 0.5, className: "mp-hairline-h" });
27
+ });
28
+
29
+ it("pairs each edge treatment with its low-DPI fallback class", () => {
30
+ expect(hairline.bottom).toEqual({ borderBottomWidth: 0.5, className: "mp-hairline-b" });
31
+ expect(hairline.top).toEqual({ borderTopWidth: 0.5, className: "mp-hairline-t" });
32
+ expect(hairline.right).toEqual({ borderRightWidth: 0.5, className: "mp-hairline-r" });
33
+ expect(hairline.left).toEqual({ borderLeftWidth: 0.5, className: "mp-hairline-l" });
34
+ });
35
+
36
+ it("falls back to 1px through a resolution media query on low-DPI screens", () => {
37
+ expect(hairlineFallbackCss).toMatch(/@media \(max-resolution: 1\.49dppx\)/);
38
+ // every fallback class resolves a full CSS pixel
39
+ for (const cls of ["b", "t", "r", "l", "h", "w", "ie"]) {
40
+ expect(hairlineFallbackCss).toContain(`.mp-hairline-${cls}`);
41
+ }
42
+ expect(hairlineFallbackCss).toContain("border-bottom-width: 1px !important");
43
+ expect(hairlineFallbackCss).toContain("border-inline-end-width: 1px !important");
44
+ // filled-line fallbacks must pin max-height/max-width too (the `line`
45
+ // treatment sets both, so height alone could not win)
46
+ expect(hairlineFallbackCss).toContain("height: 1px !important; max-height: 1px !important");
47
+ expect(hairlineFallbackCss).toContain("width: 1px !important; max-width: 1px !important");
48
+ });
49
+
50
+ describe("fallback stylesheet mount", () => {
51
+ beforeEach(() => {
52
+ document.getElementById("mp-hairline-styles")?.remove();
53
+ });
54
+
55
+ it("mounts the fallback stylesheet once (idempotent)", () => {
56
+ ensureHairlineFallback();
57
+ ensureHairlineFallback();
58
+ const tags = document.querySelectorAll("#mp-hairline-styles");
59
+ expect(tags).toHaveLength(1);
60
+ expect(tags[0]?.textContent).toBe(hairlineFallbackCss);
61
+ });
62
+ });
63
+ });