@multiplatform.one/theme 6.7.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +17 -17
  2. package/src/dates.spec.ts +344 -1
  3. package/src/dates.ts +50 -3
  4. package/src/devWarn.ts +29 -1
  5. package/src/figma/figmaTokens.spec.ts +3 -3
  6. package/src/figma/figmaTokens.ts +6 -3
  7. package/src/keyboardFocusRing.ts +61 -5
  8. package/src/menuRow.spec.ts +2 -2
  9. package/src/menuRow.ts +5 -4
  10. package/src/theme/Surface.spec.tsx +146 -0
  11. package/src/theme/Surface.tsx +73 -0
  12. package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
  13. package/src/theme/createDefaultThemeConfig.ts +47 -3
  14. package/src/theme/createThemes.ts +27 -4
  15. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
  16. package/src/theme/focusState.ts +9 -1
  17. package/src/theme/groupGeometry.spec.ts +108 -0
  18. package/src/theme/groupGeometry.ts +119 -0
  19. package/src/theme/index.ts +3 -0
  20. package/src/theme/intent.spec.tsx +17 -0
  21. package/src/theme/knobs.ts +2 -2
  22. package/src/theme/layoutTokens.spec.ts +14 -0
  23. package/src/theme/layoutTokens.ts +29 -0
  24. package/src/theme/recipeInputs.ts +368 -0
  25. package/src/theme/recipes.ts +34 -0
  26. package/src/theme/resolveKnobs.spec.ts +91 -9
  27. package/src/theme/resolveKnobs.ts +55 -5
  28. package/src/theme/shared.tsx +11 -0
  29. package/src/theme/sizeRecipes.spec.ts +296 -0
  30. package/src/theme/sizeRecipes.ts +198 -0
  31. package/src/theme/useResolvedKnobs.ts +76 -24
  32. package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
  33. package/types/dates.d.ts +36 -2
  34. package/types/dates.d.ts.map +1 -1
  35. package/types/devWarn.d.ts +16 -1
  36. package/types/devWarn.d.ts.map +1 -1
  37. package/types/figma/figmaTokens.d.ts +1 -1
  38. package/types/figma/figmaTokens.d.ts.map +1 -1
  39. package/types/keyboardFocusRing.d.ts +19 -4
  40. package/types/keyboardFocusRing.d.ts.map +1 -1
  41. package/types/menuRow.d.ts +5 -4
  42. package/types/menuRow.d.ts.map +1 -1
  43. package/types/theme/Surface.d.ts +37 -0
  44. package/types/theme/Surface.d.ts.map +1 -0
  45. package/types/theme/createDefaultThemeConfig.d.ts +14 -3
  46. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  47. package/types/theme/createThemes.d.ts.map +1 -1
  48. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  49. package/types/theme/focusState.d.ts +8 -0
  50. package/types/theme/focusState.d.ts.map +1 -1
  51. package/types/theme/groupGeometry.d.ts +69 -0
  52. package/types/theme/groupGeometry.d.ts.map +1 -0
  53. package/types/theme/index.d.ts +3 -0
  54. package/types/theme/index.d.ts.map +1 -1
  55. package/types/theme/knobs.d.ts +2 -2
  56. package/types/theme/knobs.d.ts.map +1 -1
  57. package/types/theme/layoutTokens.d.ts +22 -0
  58. package/types/theme/layoutTokens.d.ts.map +1 -1
  59. package/types/theme/recipeInputs.d.ts +110 -0
  60. package/types/theme/recipeInputs.d.ts.map +1 -0
  61. package/types/theme/recipes.d.ts +47 -0
  62. package/types/theme/recipes.d.ts.map +1 -1
  63. package/types/theme/resolveKnobs.d.ts +7 -3
  64. package/types/theme/resolveKnobs.d.ts.map +1 -1
  65. package/types/theme/shared.d.ts +9 -0
  66. package/types/theme/shared.d.ts.map +1 -1
  67. package/types/theme/sizeRecipes.d.ts +68 -0
  68. package/types/theme/sizeRecipes.d.ts.map +1 -0
  69. package/types/theme/useResolvedKnobs.d.ts +10 -6
  70. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  71. package/src/audit/baselines/home-pokemon.json +0 -355
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getGroupPosition, stackEdgeRadius, stackRadiusProps } from "./groupGeometry";
3
+
4
+ describe("getGroupPosition", () => {
5
+ it("returns only for singleton or empty groups", () => {
6
+ expect(getGroupPosition(0, 1)).toBe("only");
7
+ expect(getGroupPosition(0, 0)).toBe("only");
8
+ });
9
+
10
+ it("maps first/middle/last by index", () => {
11
+ expect(getGroupPosition(0, 3)).toBe("first");
12
+ expect(getGroupPosition(1, 3)).toBe("middle");
13
+ expect(getGroupPosition(2, 3)).toBe("last");
14
+ });
15
+
16
+ it("a two-item group has no middle", () => {
17
+ expect(getGroupPosition(0, 2)).toBe("first");
18
+ expect(getGroupPosition(1, 2)).toBe("last");
19
+ });
20
+ });
21
+
22
+ describe("stackRadiusProps (vertical)", () => {
23
+ const r = "$4";
24
+
25
+ it("first rounds only the top corners", () => {
26
+ expect(stackRadiusProps("first", r)).toEqual({
27
+ borderStartStartRadius: r,
28
+ borderStartEndRadius: r,
29
+ borderEndStartRadius: 0,
30
+ borderEndEndRadius: 0,
31
+ });
32
+ });
33
+
34
+ it("last rounds only the bottom corners", () => {
35
+ expect(stackRadiusProps("last", r)).toEqual({
36
+ borderStartStartRadius: 0,
37
+ borderStartEndRadius: 0,
38
+ borderEndStartRadius: r,
39
+ borderEndEndRadius: r,
40
+ });
41
+ });
42
+
43
+ it("middle is square on every corner", () => {
44
+ expect(stackRadiusProps("middle", r)).toEqual({
45
+ borderStartStartRadius: 0,
46
+ borderStartEndRadius: 0,
47
+ borderEndStartRadius: 0,
48
+ borderEndEndRadius: 0,
49
+ });
50
+ });
51
+
52
+ it("only rounds all four corners", () => {
53
+ expect(stackRadiusProps("only", r)).toEqual({
54
+ borderStartStartRadius: r,
55
+ borderStartEndRadius: r,
56
+ borderEndStartRadius: r,
57
+ borderEndEndRadius: r,
58
+ });
59
+ });
60
+ });
61
+
62
+ describe("stackRadiusProps (horizontal)", () => {
63
+ const r = "$4";
64
+
65
+ it("first rounds only the inline-start corners", () => {
66
+ expect(stackRadiusProps("first", r, "horizontal")).toEqual({
67
+ borderStartStartRadius: r,
68
+ borderEndStartRadius: r,
69
+ borderStartEndRadius: 0,
70
+ borderEndEndRadius: 0,
71
+ });
72
+ });
73
+
74
+ it("last rounds only the inline-end corners", () => {
75
+ expect(stackRadiusProps("last", r, "horizontal")).toEqual({
76
+ borderStartStartRadius: 0,
77
+ borderEndStartRadius: 0,
78
+ borderStartEndRadius: r,
79
+ borderEndEndRadius: r,
80
+ });
81
+ });
82
+ });
83
+
84
+ describe("stackEdgeRadius", () => {
85
+ it("supports conditional edges (open accordion trigger: last item, bottom square)", () => {
86
+ expect(stackEdgeRadius("$4", { start: false, end: false })).toEqual({
87
+ borderStartStartRadius: 0,
88
+ borderStartEndRadius: 0,
89
+ borderEndStartRadius: 0,
90
+ borderEndEndRadius: 0,
91
+ });
92
+ expect(stackEdgeRadius("$4", { start: true })).toEqual({
93
+ borderStartStartRadius: "$4",
94
+ borderStartEndRadius: "$4",
95
+ borderEndStartRadius: 0,
96
+ borderEndEndRadius: 0,
97
+ });
98
+ });
99
+
100
+ it("treats undefined radius as 0 (radius:none world stays square)", () => {
101
+ expect(stackEdgeRadius(undefined, { start: true, end: true })).toEqual({
102
+ borderStartStartRadius: 0,
103
+ borderStartEndRadius: 0,
104
+ borderEndStartRadius: 0,
105
+ borderEndEndRadius: 0,
106
+ });
107
+ });
108
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Stacked-group corner geometry — the one canonical mapping from a row's
3
+ * position in a flush stack to its corner radii (Axiom 1 R-OUTER: a nested
4
+ * rounded child directly against the frame rounds only the outer corners;
5
+ * inner boundaries stay square).
6
+ *
7
+ * A "stacked group" is any run of flush siblings composing one visual unit —
8
+ * segmented buttons, grouped inputs, accordion/sidebar rows, list rows. The
9
+ * FIRST item rounds only its leading corners, the LAST only its trailing
10
+ * corners, everything between is square, and a lone item keeps the plain
11
+ * radius. State highlights (hover/press/selection) paint on the row surface
12
+ * itself, so they inherit exactly this geometry — a uniformly rounded
13
+ * highlight against a straight interior seam is the defect this module
14
+ * exists to prevent. Web focus outlines follow the element's border-radius,
15
+ * so the focus ring inherits the same geometry for free.
16
+ *
17
+ * It lives in `@multiplatform.one/theme` because both `components`
18
+ * (Accordion, List) and `forms` (ToggleGroup, ControlGroup) consume it and
19
+ * the dependency direction is components → forms → theme (same reasoning as
20
+ * menuRow.ts). Corners are LOGICAL (start/end) so RTL flips the rounding
21
+ * with the items — physical corners would land rounded edges on interior
22
+ * seams (the ControlGroup/ToggleGroup precedent).
23
+ *
24
+ * Overlay-clipped stacks (DropdownMenu/Select rows via `menuRowFrame`,
25
+ * table frames) are the CONTAINER-CLIP arm of the same rule: rows stay flat
26
+ * and the rounded overflow-hidden container cuts the outer corners. Use that
27
+ * arm when a rounded clipping container already exists; use this arm when
28
+ * the rows themselves are the outermost painted surface.
29
+ */
30
+
31
+ import { createContext, useContext } from "react";
32
+
33
+ /** A row's position within its stacked group. */
34
+ export type GroupPosition = "only" | "first" | "middle" | "last";
35
+
36
+ /** Stacking direction: vertical stacks round top/bottom, horizontal start/end. */
37
+ export type GroupOrientation = "horizontal" | "vertical";
38
+
39
+ /** Resolve a row index to its group position (count <= 1 is "only"). */
40
+ export function getGroupPosition(index: number, count: number): GroupPosition {
41
+ if (count <= 1) return "only";
42
+ if (index <= 0) return "first";
43
+ if (index >= count - 1) return "last";
44
+ return "middle";
45
+ }
46
+
47
+ // A type alias (not interface) so the fragment stays assignable to
48
+ // Record<string, unknown>-typed style bags.
49
+ export type StackRadiusFragment = {
50
+ borderStartStartRadius: string | number;
51
+ borderStartEndRadius: string | number;
52
+ borderEndStartRadius: string | number;
53
+ borderEndEndRadius: string | number;
54
+ };
55
+
56
+ /**
57
+ * Corner fragment for a row whose leading/trailing edge may or may not be
58
+ * the group's outer edge. `start` is the top edge (vertical) or inline-start
59
+ * edge (horizontal); `end` the opposite. This is the low-level arm for rows
60
+ * whose outer-edge status is conditional — an open accordion trigger's
61
+ * bottom edge abuts its own content, so its bottom corners go square even on
62
+ * the last item.
63
+ */
64
+ export function stackEdgeRadius(
65
+ radius: string | number | undefined,
66
+ edges: { start?: boolean; end?: boolean },
67
+ orientation: GroupOrientation = "vertical",
68
+ ): StackRadiusFragment {
69
+ const r = radius ?? 0;
70
+ const start = edges.start ? r : 0;
71
+ const end = edges.end ? r : 0;
72
+ if (orientation === "horizontal") {
73
+ return {
74
+ borderStartStartRadius: start,
75
+ borderEndStartRadius: start,
76
+ borderStartEndRadius: end,
77
+ borderEndEndRadius: end,
78
+ };
79
+ }
80
+ return {
81
+ borderStartStartRadius: start,
82
+ borderStartEndRadius: start,
83
+ borderEndStartRadius: end,
84
+ borderEndEndRadius: end,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The complete corner fragment for a row at `position` in a stacked group.
90
+ * Spread it onto the row surface (the element that paints hover/press/
91
+ * selection) — never cherry-pick single corners out of it.
92
+ */
93
+ export function stackRadiusProps(
94
+ position: GroupPosition,
95
+ radius: string | number | undefined,
96
+ orientation: GroupOrientation = "vertical",
97
+ ): StackRadiusFragment {
98
+ return stackEdgeRadius(
99
+ radius,
100
+ {
101
+ start: position === "first" || position === "only",
102
+ end: position === "last" || position === "only",
103
+ },
104
+ orientation,
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Group containers provide each item's position so nested compound parts
110
+ * (an Accordion.Trigger inside an Accordion.Item) resolve their geometry
111
+ * without prop drilling. `undefined` means "not inside a stacked group" —
112
+ * consumers treat that as "only".
113
+ */
114
+ export const GroupPositionContext = createContext<GroupPosition | undefined>(undefined);
115
+
116
+ /** The current row's position within its stacked group, if any. */
117
+ export function useGroupPosition(): GroupPosition | undefined {
118
+ return useContext(GroupPositionContext);
119
+ }
@@ -9,6 +9,7 @@ export * from "./defaults/index";
9
9
  export * from "./devtools/ThemeDevtoolsPanel";
10
10
  export * from "./focusState";
11
11
  export * from "./FontKnobStyles";
12
+ export * from "./groupGeometry";
12
13
  export * from "./hairline";
13
14
  export * from "./Intent";
14
15
  export * from "./intents";
@@ -23,6 +24,8 @@ export * from "./readableColor";
23
24
  export * from "./recipes";
24
25
  export * from "./resolveKnobs";
25
26
  export * from "./shared";
27
+ export * from "./sizeRecipes";
28
+ export * from "./Surface";
26
29
 
27
30
  export * from "./theme";
28
31
  export * from "./Tint";
@@ -7,6 +7,23 @@ import { describe, expect, it, vi } from "vitest";
7
7
  vi.mock("tamagui", () => ({
8
8
  Theme: ({ name, children }: { name: string; children: ReactNode }) =>
9
9
  createElement("div", { "data-tamagui-theme": name }, children),
10
+ createStyledContext: (defaults: { size: string; density: string }) => ({
11
+ Provider: ({
12
+ children,
13
+ size,
14
+ density,
15
+ }: {
16
+ children?: ReactNode;
17
+ size?: string;
18
+ density?: string;
19
+ }) =>
20
+ createElement("div", {
21
+ "data-surface-size": size ?? defaults.size,
22
+ "data-surface-density": density ?? defaults.density,
23
+ children,
24
+ }),
25
+ useStyledContext: () => defaults,
26
+ }),
10
27
  }));
11
28
 
12
29
  import type { ThemeName } from "@tamagui/web";
@@ -58,7 +58,7 @@ export const Size = {
58
58
  } as const;
59
59
  export type Size = (typeof Size)[keyof typeof Size];
60
60
 
61
- /** Global density mode — compact steps size+space down one level. */
61
+ /** Global density mode — compact steps space down one level; size is independent. */
62
62
  export const Density = {
63
63
  Comfortable: "comfortable",
64
64
  Compact: "compact",
@@ -236,7 +236,7 @@ export interface Knobs {
236
236
  elevation: Elevation;
237
237
  space: Space;
238
238
  size: Size;
239
- /** Propagating density mode. Default comfortable; compact steps size+space. */
239
+ /** Propagating density mode. Default comfortable; compact steps space only. */
240
240
  density: Density;
241
241
  textAccent: TextAccent;
242
242
  headingFont: HeadingFont;
@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  MIN_PRESS_TARGET,
4
4
  NARROW_BREAKPOINT,
5
+ OVERLAY_ANCHOR_GAP,
5
6
  OVERLAY_BREAKPOINT,
6
7
  READING_WIDTH_CH,
7
8
  READING_WIDTH_MAX_CH,
8
9
  READING_WIDTH_MIN_CH,
9
10
  defaultMaxPanes,
10
11
  getLayoutSizeClass,
12
+ isTouchSurface,
11
13
  layoutBreakpoints,
12
14
  layoutSizeClassAtLeast,
13
15
  pressTargetHitSlop,
@@ -65,6 +67,12 @@ describe("readingWidthStyle (DG-LAY-05)", () => {
65
67
  });
66
68
  });
67
69
 
70
+ describe("overlay anchoring", () => {
71
+ it("pins the shared trigger↔overlay gap at 4px", () => {
72
+ expect(OVERLAY_ANCHOR_GAP).toBe(4);
73
+ });
74
+ });
75
+
68
76
  describe("press target (DG-LAY-04)", () => {
69
77
  it("exports 44px floor + hitSlop expansion", () => {
70
78
  expect(MIN_PRESS_TARGET).toBe(44);
@@ -73,3 +81,9 @@ describe("press target (DG-LAY-04)", () => {
73
81
  expect(pressTargetHitSlop(44)).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
74
82
  });
75
83
  });
84
+
85
+ describe("isTouchSurface (LC-66)", () => {
86
+ it("is false in the default test environment", () => {
87
+ expect(isTouchSurface()).toBe(false);
88
+ });
89
+ });
@@ -29,6 +29,7 @@
29
29
  * (840→860). Fluent sm=640 matches medium; Fluent lg=1024 matches large.
30
30
  */
31
31
 
32
+ import { isTouchable, isWebTouchable } from "@multiplatform.one/platform";
32
33
  import { useEffect, useState } from "react";
33
34
  import { isWeb, useWindowDimensions } from "tamagui";
34
35
  import { useResolvedKnobs } from "./useResolvedKnobs";
@@ -170,6 +171,11 @@ export function readingWidthStyle(
170
171
  /** Platform floor for interactive targets (Fluent/HIG web+iOS 44; M3 48 on Android). */
171
172
  export const MIN_PRESS_TARGET = 44;
172
173
 
174
+ /** Touch surfaces — native touchable or web touchable (LC-67). */
175
+ export function isTouchSurface(): boolean {
176
+ return isTouchable || isWebTouchable;
177
+ }
178
+
173
179
  export type PressTargetStyle = {
174
180
  minWidth: number;
175
181
  minHeight: number;
@@ -195,6 +201,29 @@ export function pressTargetHitSlop(
195
201
  return { top: pad, bottom: pad, left: pad, right: pad };
196
202
  }
197
203
 
204
+ // ── Overlay anchoring (house rule) ────────────────────────────────────────
205
+
206
+ /**
207
+ * House convention for every anchored overlay (Select/Combobox listboxes,
208
+ * DropdownMenu, Popover, Tooltip, FloatingPanel):
209
+ *
210
+ * - Anchored MENUS and SELECT LISTBOXES match the trigger width EXACTLY
211
+ * (min-width = trigger width — no oversize fudge). They may grow only
212
+ * wider when option content genuinely overflows, never narrower, and
213
+ * stay start-aligned (flush with the trigger's leading edge).
214
+ * - Pointer-positioned CONTEXT MENUS and free POPOVERS/TOOLTIPS size to
215
+ * content, start-aligned to their anchor.
216
+ * - All of them keep `OVERLAY_ANCHOR_GAP` px between trigger edge and
217
+ * overlay (floating-ui `offset` / tamagui Popover `offset`), and
218
+ * shift/flip at viewport edges instead of overflowing.
219
+ * - ONE SURFACE OWNER per overlay: the overlay frame draws the single
220
+ * border + radius. When a panel intentionally covers its trigger
221
+ * (Select's macOS-style aligned listbox), the covered trigger must not
222
+ * paint focus decorations — a focus ring outside the trigger's border
223
+ * box would ghost around the overlay's corners as a doubled edge.
224
+ */
225
+ export const OVERLAY_ANCHOR_GAP = 4;
226
+
198
227
  // ── Semantic gaps (DG-LAY-03) ─────────────────────────────────────────────
199
228
 
200
229
  /**