@multiplatform.one/theme 7.11.0 → 7.15.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 (49) hide show
  1. package/README.md +22 -2
  2. package/package.json +6 -6
  3. package/src/audit/constraintAudit.spec.ts +102 -0
  4. package/src/audit/constraintAudit.ts +78 -33
  5. package/src/figma/figmaTokens.spec.ts +1 -2
  6. package/src/numbers.spec.ts +27 -1
  7. package/src/numbers.ts +40 -0
  8. package/src/theme/Surface.spec.tsx +9 -4
  9. package/src/theme/createDefaultThemeConfig.ts +8 -1
  10. package/src/theme/index.ts +3 -0
  11. package/src/theme/intent.spec.tsx +9 -7
  12. package/src/theme/layoutTokensHooks.spec.tsx +3 -2
  13. package/src/theme/radiusClass.ts +18 -0
  14. package/src/theme/resolveKnobs.spec.ts +73 -7
  15. package/src/theme/resolveKnobs.ts +84 -25
  16. package/src/theme/sizeRecipes.ts +8 -5
  17. package/src/theme/subsetThemes.spec.ts +102 -0
  18. package/src/theme/subsetThemes.ts +56 -0
  19. package/src/theme/theme.tsx +15 -5
  20. package/src/theme/transitionProps.native.ts +15 -0
  21. package/src/theme/transitionProps.spec.ts +14 -0
  22. package/src/theme/transitionProps.ts +16 -0
  23. package/src/theme/useResolvedKnobs.hydration.spec.tsx +141 -0
  24. package/src/theme/useResolvedKnobs.ts +28 -22
  25. package/src/theme/useResolvedKnobsBehavior.spec.tsx +15 -1
  26. package/src/theme/useTouchSurface.ts +8 -0
  27. package/types/audit/constraintAudit.d.ts.map +1 -1
  28. package/types/numbers.d.ts +12 -0
  29. package/types/numbers.d.ts.map +1 -1
  30. package/types/theme/createDefaultThemeConfig.d.ts +7 -0
  31. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  32. package/types/theme/index.d.ts +3 -0
  33. package/types/theme/index.d.ts.map +1 -1
  34. package/types/theme/radiusClass.d.ts +14 -0
  35. package/types/theme/radiusClass.d.ts.map +1 -1
  36. package/types/theme/resolveKnobs.d.ts +23 -1
  37. package/types/theme/resolveKnobs.d.ts.map +1 -1
  38. package/types/theme/sizeRecipes.d.ts +8 -5
  39. package/types/theme/sizeRecipes.d.ts.map +1 -1
  40. package/types/theme/subsetThemes.d.ts +39 -0
  41. package/types/theme/subsetThemes.d.ts.map +1 -0
  42. package/types/theme/theme.d.ts.map +1 -1
  43. package/types/theme/transitionProps.d.ts +14 -0
  44. package/types/theme/transitionProps.d.ts.map +1 -0
  45. package/types/theme/transitionProps.native.d.ts +13 -0
  46. package/types/theme/transitionProps.native.d.ts.map +1 -0
  47. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  48. package/types/theme/useTouchSurface.d.ts +3 -0
  49. package/types/theme/useTouchSurface.d.ts.map +1 -0
package/README.md CHANGED
@@ -66,8 +66,28 @@ Rules that keep knobs sound:
66
66
  - Resolution order: Preset → intent → intent+component → size → density →
67
67
  reduced-motion → `resolveKnobs()` override callback.
68
68
 
69
- Full standard: `agent-os/standards/frontend/knobs-system.md` in the
70
- [multiplatform.one repo](https://gitlab.com/bitspur/frappe/multiplatform.one).
69
+ A surface that only reaches part of the theme matrix ships only that part.
70
+ Themes are CSS in the served document, so the full 1,580-theme matrix is paid
71
+ for on every page. `subset` filters which themes exist, never what one
72
+ contains:
73
+
74
+ ```ts
75
+ import { createDefaultThemeConfig } from "@multiplatform.one/theme";
76
+
77
+ export const themeConfig = createDefaultThemeConfig({
78
+ subset: {
79
+ components: ["Button", "Input", "TextArea", "Card", "ListItem"],
80
+ tints: ["accent", "active", "alt1", "alt2", "error", "success", "warning"],
81
+ },
82
+ });
83
+ ```
84
+
85
+ A dropped component sub-theme does not crash, it renders in its parent's
86
+ colours, so list everything the surface renders and enters. `subsetThemes`
87
+ is the same filter as a pure function over a built matrix.
88
+
89
+ Full standard: `docs/standards/frontend/knobs-system.md` in the
90
+ [multiplatform.one repo](https://git.corp.bitspur.com/multiplatform.one/mpo).
71
91
 
72
92
  ## License
73
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.11.0",
3
+ "version": "7.15.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -12,7 +12,7 @@
12
12
  "author": "BitSpur",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://gitlab.com/bitspur/frappe/multiplatform.one.git",
15
+ "url": "git+https://git.corp.bitspur.com/multiplatform.one/mpo.git",
16
16
  "directory": "public/theme"
17
17
  },
18
18
  "source": "src/index.ts",
@@ -44,8 +44,8 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@multiplatform.one/platform": "7.11.0",
48
- "@multiplatform.one/store": "7.11.0",
47
+ "@multiplatform.one/platform": "7.15.0",
48
+ "@multiplatform.one/store": "7.15.0",
49
49
  "@tamagui/colors": "2.7.6",
50
50
  "@tamagui/config": "2.7.6",
51
51
  "@tamagui/get-token": "2.7.6",
@@ -57,8 +57,8 @@
57
57
  "react-cookie": "^8.1.2"
58
58
  },
59
59
  "devDependencies": {
60
- "@multiplatform.one/config": "7.11.0",
61
- "@multiplatform.one/test-utils": "7.11.0",
60
+ "@multiplatform.one/config": "7.15.0",
61
+ "@multiplatform.one/test-utils": "7.15.0",
62
62
  "@tamagui/animations-css": "2.7.6",
63
63
  "@tamagui/animations-reanimated": "2.7.6",
64
64
  "@tamagui/font-inter": "2.7.6",
@@ -155,3 +155,105 @@ describe("font weight provenance", () => {
155
155
  expect(measured()?.fontWeight?.offScale).toBe(true);
156
156
  });
157
157
  });
158
+
159
+ function laidOut(el: HTMLElement, width: number, height: number) {
160
+ Object.defineProperty(el, "offsetWidth", { configurable: true, value: width });
161
+ Object.defineProperty(el, "offsetHeight", { configurable: true, value: height });
162
+ return el;
163
+ }
164
+
165
+ describe("BINARY parts measure 0 or their own h/2 (DG-RAD-03)", () => {
166
+ const binary = { "data-radius-resolution": "BINARY" };
167
+
168
+ it.each([
169
+ ["switch track", 88, 44, 22],
170
+ ["bold thumb", 40, 40, 20],
171
+ ["default thumb", 38, 38, 19],
172
+ ])("accepts a %s at h/2", (_part, width, height, radius) => {
173
+ laidOut(specimen(`border-radius:${radius}px`, binary), width, height);
174
+ expect(measured()?.violations).toEqual([]);
175
+ expect(measured()?.borderRadius?.knob).toBe("BINARY 0 | h/2");
176
+ });
177
+
178
+ it("accepts a square BINARY part at none", () => {
179
+ laidOut(specimen("border-radius:0", binary), 88, 44);
180
+ expect(measured()?.violations ?? []).toEqual([]);
181
+ });
182
+
183
+ it.each([
184
+ ["an intermediate radius", "border-radius:9px"],
185
+ ["the uncapped token", "border-radius:50px"],
186
+ ["h/2 of the longer edge", "border-radius:44px"],
187
+ ["one corner off h/2", "border-radius:22px 22px 22px 16px"],
188
+ ["a percentage", "border-radius:50%"],
189
+ ])("rejects %s", (_label, style) => {
190
+ laidOut(specimen(style, binary), 88, 44);
191
+ expect(measured()?.borderRadius?.offScale).toBe(true);
192
+ expect(measured()?.violations.length).toBeGreaterThan(0);
193
+ });
194
+
195
+ it("keeps an undeclared h/2 radius off-scale", () => {
196
+ laidOut(specimen("border-radius:22px"), 88, 44);
197
+ expect(measured()?.borderRadius?.offScale).toBe(true);
198
+ });
199
+ });
200
+
201
+ describe("declared capped containers beyond Card (DG-RAD-04)", () => {
202
+ const declared = (container: string, radius: string, space: string) => ({
203
+ "data-constraint-container": container,
204
+ "data-radius-knob": radius,
205
+ "data-space-knob": space,
206
+ });
207
+
208
+ it("accepts a PageSection surface capped at its own panelPadding", () => {
209
+ specimen("border-radius:13px;padding:13px", declared("PageSection", "full", "small"));
210
+ expect(measured()?.violations).toEqual([]);
211
+ });
212
+
213
+ it("measures a PageSection surface inset like a Card's", () => {
214
+ specimen("border-radius:13px;padding:12px", declared("PageSection", "full", "small"));
215
+ expect(measured()?.violations.join(" ")).toContain("PageSection panelPadding:small");
216
+ });
217
+
218
+ it("accepts a TextArea box at the cap whatever its field inset", () => {
219
+ specimen("border-radius:13px;padding:0", declared("TextArea", "full", "small"));
220
+ expect(measured()?.violations).toEqual([]);
221
+ expect(measured()?.borderRadius?.knob).toBe("TextArea min(radius:full, panelPadding:small)");
222
+ });
223
+
224
+ it("audits a TextArea box's own padding on the ordinary scale", () => {
225
+ specimen("border-radius:9px;padding:15px", declared("TextArea", "medium", "medium"));
226
+ expect(measured()?.violations.join(" ")).toContain("paddingTop: 15px is off-scale");
227
+ });
228
+
229
+ it.each([
230
+ ["the uncapped token", "border-radius:50px", declared("TextArea", "full", "small")],
231
+ ["a cap for another space", "border-radius:18px", declared("TextArea", "full", "small")],
232
+ ["the token past the cap", "border-radius:16px", declared("TextArea", "large", "small")],
233
+ ["an unknown radius stop", "border-radius:13px", declared("TextArea", "round", "small")],
234
+ ])("rejects %s", (_label, style, attrs) => {
235
+ specimen(style, attrs);
236
+ expect(measured()?.borderRadius?.offScale).toBe(true);
237
+ });
238
+
239
+ it("names a container the audit does not know instead of trusting it", () => {
240
+ specimen("border-radius:13px;padding:13px", declared("Tile", "full", "small"));
241
+ expect(measured()?.violations.join(" ")).toContain('"Tile" is not a container');
242
+ });
243
+ });
244
+
245
+ describe("element labels hold nothing a knob or a mount writes", () => {
246
+ it("drops React useId ids and Tamagui atomic classes", () => {
247
+ const el = specimen("border-radius:12px");
248
+ el.id = "_r_3i_";
249
+ el.className = "is_View _borderStartStartRadius-0hover-c-radius-12 _outlineWidth-0focus-0px";
250
+ expect(measured()).toBeUndefined();
251
+ const found = audit().elements.find((entry) => entry.label.startsWith("div"));
252
+ expect(found?.label).toBe("div.is_View");
253
+ });
254
+
255
+ it("keeps an authored id", () => {
256
+ specimen("border-radius:12px");
257
+ expect(audit().elements[0]?.label).toBe("div#specimen");
258
+ });
259
+ });
@@ -568,10 +568,15 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
568
568
 
569
569
  function elementLabel(el: Element): string {
570
570
  const tag = el.tagName.toLowerCase();
571
- const id = el.id ? `#${el.id}` : "";
572
- // Only take non-hash classes (Tamagui hash classes start with _ or are short hashes)
571
+ // Labels are part of the no-breakage signature, so nothing a knob or a
572
+ // render order writes may reach them: React useId ids renumber per mount,
573
+ // and Tamagui atomic classes (always `_`-prefixed) spell resolved values.
574
+ const generatedId = /^(?:_r_[0-9a-z]+_|:r[0-9a-z]+:|«r[0-9a-z]+»)$/.test(el.id);
575
+ const id = el.id && !generatedId ? `#${el.id}` : "";
573
576
  const classes = Array.from(el.classList)
574
- .filter((c) => c.length > 3 && !/^[_a-z0-9]{1,8}-/.test(c) && !/^\d/.test(c))
577
+ .filter(
578
+ (c) => c.length > 3 && !c.startsWith("_") && !/^[_a-z0-9]{1,8}-/.test(c) && !/^\d/.test(c),
579
+ )
575
580
  .slice(0, 3)
576
581
  .join(".");
577
582
  const cls = classes ? `.${classes}` : "";
@@ -729,12 +734,28 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
729
734
 
730
735
  let hasAnyProp = false;
731
736
 
732
- // Axiom 1 / DG-RAD-04: only the documented padded Card tiers own this
733
- // cap. Provenance is declarative; every inset and corner is still measured.
734
- const cardContainer =
735
- el.getAttribute("data-constraint-container") === "Card" &&
736
- ["content", "elevated"].includes(el.getAttribute("data-tier") ?? "");
737
- if (cardContainer) {
737
+ // Axiom 1 / DG-RAD-04: only a declared padded container owns the cap.
738
+ // Provenance is declarative; every corner is still measured, and so is the
739
+ // inset of the containers whose own padding IS the panelPadding knob.
740
+ const containerName = el.getAttribute("data-constraint-container");
741
+ const ownsInset = containerName === "Card" || containerName === "PageSection";
742
+ const capContainer =
743
+ containerName === "Card"
744
+ ? ["content", "elevated"].includes(el.getAttribute("data-tier") ?? "")
745
+ : containerName === "PageSection" || containerName === "TextArea";
746
+ if (containerName !== null && containerName !== "Card" && !capContainer) {
747
+ entry.violations.push(
748
+ `data-constraint-container: "${containerName}" is not a container the cap audit knows`,
749
+ );
750
+ }
751
+ const binaryPart = el.getAttribute("data-radius-resolution") === "BINARY";
752
+ const corners = [
753
+ "borderTopLeftRadius",
754
+ "borderTopRightRadius",
755
+ "borderBottomRightRadius",
756
+ "borderBottomLeftRadius",
757
+ ] as const;
758
+ if (capContainer) {
738
759
  const radiusKnob = el.getAttribute("data-radius-knob") ?? "";
739
760
  const spaceKnob = el.getAttribute("data-space-knob") ?? "";
740
761
  const radiusStop = Object.hasOwn(_BORDER_RADIUS_KNOB, radiusKnob)
@@ -745,30 +766,28 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
745
766
  : undefined;
746
767
  const paddingSides = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] as const;
747
768
  const measuredInsets = paddingSides.map((prop) => parsePx(style[prop]));
748
- for (const [index, prop] of paddingSides.entries()) {
749
- const value = measuredInsets[index];
750
- const offScale = inset === undefined || value !== inset;
751
- entry[prop] = {
752
- value: value ?? 0,
753
- unit: "px",
754
- knob: `Card panelPadding:${spaceKnob}`,
755
- offScale,
756
- };
757
- if (offScale)
758
- entry.violations.push(
759
- `${prop}: ${style[prop]} does not match Card panelPadding:${spaceKnob} (${inset ?? "unknown"}px)`,
760
- );
769
+ if (ownsInset) {
770
+ for (const [index, prop] of paddingSides.entries()) {
771
+ const value = measuredInsets[index];
772
+ const offScale = inset === undefined || value !== inset;
773
+ entry[prop] = {
774
+ value: value ?? 0,
775
+ unit: "px",
776
+ knob: `${containerName} panelPadding:${spaceKnob}`,
777
+ offScale,
778
+ };
779
+ if (offScale)
780
+ entry.violations.push(
781
+ `${prop}: ${style[prop]} does not match ${containerName} panelPadding:${spaceKnob} (${inset ?? "unknown"}px)`,
782
+ );
783
+ }
761
784
  }
762
785
  const expected =
763
- radiusStop === undefined || inset === undefined || measuredInsets.some((v) => v === null)
786
+ radiusStop === undefined ||
787
+ inset === undefined ||
788
+ (ownsInset && measuredInsets.some((v) => v === null))
764
789
  ? undefined
765
- : Math.min(radiusStop, inset, ...(measuredInsets as number[]));
766
- const corners = [
767
- "borderTopLeftRadius",
768
- "borderTopRightRadius",
769
- "borderBottomRightRadius",
770
- "borderBottomLeftRadius",
771
- ] as const;
790
+ : Math.min(radiusStop, inset, ...(ownsInset ? (measuredInsets as number[]) : []));
772
791
  for (const prop of corners) {
773
792
  const raw = style[prop];
774
793
  // A corner may have two elliptical axes. Percentages are not px caps.
@@ -779,13 +798,13 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
779
798
  const result = {
780
799
  value: parsePx(raw) ?? 0,
781
800
  unit: "px",
782
- knob: `Card min(radius:${radiusKnob}, panelPadding:${spaceKnob})`,
801
+ knob: `${containerName} min(radius:${radiusKnob}, panelPadding:${spaceKnob})`,
783
802
  offScale,
784
803
  };
785
804
  entry[prop] = result;
786
805
  if (offScale)
787
806
  entry.violations.push(
788
- `${prop}: ${raw} does not match Card cap (${expected ?? "unknown"}px)`,
807
+ `${prop}: ${raw} does not match ${containerName} cap (${expected ?? "unknown"}px)`,
789
808
  );
790
809
  }
791
810
  entry.borderRadius = {
@@ -793,6 +812,32 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
793
812
  offScale: corners.some((prop) => entry[prop]?.offScale),
794
813
  };
795
814
  hasAnyProp = true;
815
+ } else if (binaryPart) {
816
+ // DG-RAD-03: square, or a pill/circle of the part's own shorter edge,
817
+ // at every stop and in every state. Layout size, not the transformed
818
+ // box, so a translated thumb measures the same as a resting one.
819
+ const box = el as HTMLElement;
820
+ const half = Math.min(box.offsetWidth, box.offsetHeight) / 2;
821
+ const measuredCorners = corners.map((prop) => style[prop].trim().split(/\s+/));
822
+ if (measuredCorners.some((axes) => axes.some((axis) => parsePx(axis) !== 0))) {
823
+ for (const [index, prop] of corners.entries()) {
824
+ const raw = style[prop];
825
+ const offScale = measuredCorners[index].some((axis) => {
826
+ if (!/^\d+(?:\.\d+)?(?:px)?$/.test(axis)) return true;
827
+ const value = parsePx(axis) ?? 0;
828
+ // offsetWidth/offsetHeight round to whole px; the painted h/2 does not.
829
+ return value !== 0 && Math.abs(value - half) > 0.5;
830
+ });
831
+ entry[prop] = { value: parsePx(raw) ?? 0, unit: "px", knob: "BINARY 0 | h/2", offScale };
832
+ if (offScale)
833
+ entry.violations.push(`${prop}: ${raw} is neither 0 nor BINARY h/2 (${half}px)`);
834
+ }
835
+ entry.borderRadius = {
836
+ ...entry.borderTopLeftRadius!,
837
+ offScale: corners.some((prop) => entry[prop]?.offScale),
838
+ };
839
+ hasAnyProp = true;
840
+ }
796
841
  } else if (!isPercentageBorderRadius(el)) {
797
842
  // Ordinary controls and identity recipes retain their existing scales.
798
843
  const br = parsePx(style.borderTopLeftRadius || style.borderRadius);
@@ -839,7 +884,7 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
839
884
  // Only check padding on elements that look like surfaces (have border-radius
840
885
  // or border-width already audited). Padding on all elements creates too many
841
886
  // false positives from browser defaults and third-party components.
842
- if (!cardContainer && (entry.borderRadius || entry.borderWidth)) {
887
+ if (!(capContainer && ownsInset) && (entry.borderRadius || entry.borderWidth)) {
843
888
  const sides = [
844
889
  ["paddingTop", style.paddingTop],
845
890
  ["paddingRight", style.paddingRight],
@@ -183,8 +183,7 @@ describe("buildFigmaTokens", () => {
183
183
  it("exports platform-normalized elevation modes", () => {
184
184
  const { modes } = result.knobs.elevation;
185
185
  expect(modes.none).toEqual({ sizeToken: null, px: null });
186
- expect(modes.small.sizeToken).toBe("$1");
187
- expect(modes.small.px).toBe((defaultConfig.tokens.size as Record<string, number>).$1);
186
+ expect(modes.small).toEqual({ sizeToken: null, px: null });
188
187
  expect(modes.medium.sizeToken).toBe("$2");
189
188
  expect(modes.large.sizeToken).toBe("$4");
190
189
  });
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { formatCurrency, formatNumber, formatPercent, resolveCurrencyCode } from "./numbers";
2
+ import {
3
+ currencyAffix,
4
+ formatCurrency,
5
+ formatNumber,
6
+ formatPercent,
7
+ resolveCurrencyCode,
8
+ } from "./numbers";
3
9
 
4
10
  // Pin locale so assertions don't depend on the host machine.
5
11
  const enUS = { locale: "en-US" } as const;
@@ -110,3 +116,23 @@ describe("resolveCurrencyCode", () => {
110
116
  expect(resolveCurrencyCode(undefined)).toBeUndefined();
111
117
  });
112
118
  });
119
+
120
+ describe("currencyAffix (money editor glyph, MPO-221)", () => {
121
+ it("takes the glyph and its side from the locale's money register", () => {
122
+ expect(currencyAffix("USD", "en-US")).toEqual({ glyph: "$", position: "leading" });
123
+ expect(currencyAffix("EUR", "de-DE")).toEqual({ glyph: "€", position: "trailing" });
124
+ });
125
+
126
+ it("resolves a Frappe-style symbol through its code", () => {
127
+ expect(currencyAffix("₹", "en-IN")).toEqual({ glyph: "₹", position: "leading" });
128
+ });
129
+
130
+ it("returns a symbol with no known code as itself, leading", () => {
131
+ expect(currencyAffix("Fr", "en-US")).toEqual({ glyph: "Fr", position: "leading" });
132
+ });
133
+
134
+ it("returns nothing without a currency", () => {
135
+ expect(currencyAffix(undefined)).toBeUndefined();
136
+ expect(currencyAffix("")).toBeUndefined();
137
+ });
138
+ });
package/src/numbers.ts CHANGED
@@ -76,6 +76,46 @@ export function resolveCurrencyCode(symbol: string | undefined): string | undefi
76
76
  return CURRENCY_SYMBOL_TO_CODE[symbol];
77
77
  }
78
78
 
79
+ export interface CurrencyAffix {
80
+ glyph: string;
81
+ /** Which side of the amount the locale writes the glyph on. */
82
+ position: "leading" | "trailing";
83
+ }
84
+
85
+ /**
86
+ * The glyph a money field writes beside its amount ("$", "€", "₹"), and on
87
+ * which side, for a code or a Frappe-style symbol. The money register's own
88
+ * placement, so an editor and `formatCurrency` agree. A symbol with no known
89
+ * code comes back as itself, leading.
90
+ */
91
+ export function currencyAffix(
92
+ currency: string | undefined,
93
+ locale?: string,
94
+ ): CurrencyAffix | undefined {
95
+ if (!currency) return undefined;
96
+ const code = resolveCurrencyCode(currency);
97
+ if (code) {
98
+ try {
99
+ const parts = new Intl.NumberFormat(locale, {
100
+ style: "currency",
101
+ currency: code,
102
+ currencyDisplay: "narrowSymbol",
103
+ }).formatToParts(1);
104
+ const glyphIndex = parts.findIndex((part) => part.type === "currency");
105
+ const amountIndex = parts.findIndex((part) => part.type === "integer");
106
+ if (glyphIndex !== -1) {
107
+ return {
108
+ glyph: parts[glyphIndex].value,
109
+ position: amountIndex !== -1 && glyphIndex > amountIndex ? "trailing" : "leading",
110
+ };
111
+ }
112
+ } catch {
113
+ // A malformed code falls through to the literal glyph below.
114
+ }
115
+ }
116
+ return { glyph: currency, position: "leading" };
117
+ }
118
+
79
119
  interface CoercedNumber {
80
120
  num: number;
81
121
  }
@@ -1,3 +1,4 @@
1
+ import { TestProviders } from "@multiplatform.one/test-utils";
1
2
  /**
2
3
  * Surface context — nested size/density step-down (LC-68).
3
4
  */
@@ -117,7 +118,11 @@ describe("Surface nesting (LC-68)", () => {
117
118
  describe("useResolvedKnobs inside Surface", () => {
118
119
  it("size=small density=compact → sizeToken $3, compact gap, small desktop control height", () => {
119
120
  const wrapper = ({ children }: { children: ReactNode }) =>
120
- createElement(Surface, { size: "small", density: "compact" }, children);
121
+ createElement(
122
+ TestProviders,
123
+ null,
124
+ createElement(Surface, { size: "small", density: "compact" }, children),
125
+ );
121
126
  const { result } = renderHook(() => useResolvedKnobs(), { wrapper });
122
127
  expect(result.current.knobProps.size).toBe("small");
123
128
  expect(result.current.knobProps.sizeToken).toBe("$3");
@@ -128,7 +133,7 @@ describe("useResolvedKnobs inside Surface", () => {
128
133
 
129
134
  it("a large size request inside a medium Surface resolves medium", () => {
130
135
  const wrapper = ({ children }: { children: ReactNode }) =>
131
- createElement(Surface, { size: "medium" }, children);
136
+ createElement(TestProviders, null, createElement(Surface, { size: "medium" }, children));
132
137
  const { result } = renderHook(() => useResolvedKnobs({ size: "$5" }), { wrapper });
133
138
  expect(result.current.knobProps.size).toBe("medium");
134
139
  expect(result.current.knobProps.sizeToken).toBe("$4");
@@ -136,7 +141,7 @@ describe("useResolvedKnobs inside Surface", () => {
136
141
 
137
142
  it("Surface size is inherited as the subtree default (lg over global medium)", () => {
138
143
  const wrapper = ({ children }: { children: ReactNode }) =>
139
- createElement(Surface, { size: "lg" }, children);
144
+ createElement(TestProviders, null, createElement(Surface, { size: "lg" }, children));
140
145
  const { result } = renderHook(() => useResolvedKnobs(), { wrapper });
141
146
  expect(result.current.knobProps.size).toBe("large");
142
147
  expect(result.current.knobProps.sizeToken).toBe("$5");
@@ -144,7 +149,7 @@ describe("useResolvedKnobs inside Surface", () => {
144
149
 
145
150
  it("compact:false inside a compact Surface stays compact and still steps space", () => {
146
151
  const wrapper = ({ children }: { children: ReactNode }) =>
147
- createElement(Surface, { density: "compact" }, children);
152
+ createElement(TestProviders, null, createElement(Surface, { density: "compact" }, children));
148
153
  const { result } = renderHook(() => useResolvedKnobs({ compact: false }), { wrapper });
149
154
  expect(result.current.knobProps.density).toBe("compact");
150
155
  expect(result.current.knobProps.gap).toEqual({ gap: "$2" });
@@ -18,6 +18,7 @@ import { type SizeRecipeInputs } from "./recipeInputs";
18
18
  import { type ThemeConfig, createThemeConfig } from "./shared";
19
19
  import { assertResolvableThemeValues } from "./themeValue";
20
20
  import { getGeneratedSizeRecipes, getSizeRecipeInputs, setSizeRecipeInputs } from "./sizeRecipes";
21
+ import { type ThemeSubset, subsetThemes } from "./subsetThemes";
21
22
 
22
23
  /**
23
24
  * The Tamagui era this theme config resolves against (MPO-19 X13; rendered
@@ -66,6 +67,12 @@ export interface CreateDefaultThemeConfigOptions {
66
67
  * theme-builder).
67
68
  */
68
69
  recipeInputs?: Partial<SizeRecipeInputs>;
70
+ /**
71
+ * Ship only the themes this surface can reach (`10` AC-6). Applied to the
72
+ * built matrix, after `additionalThemes`, so a subset filters which themes
73
+ * exist and never changes what one contains. See `subsetThemes`.
74
+ */
75
+ subset?: ThemeSubset;
69
76
  }
70
77
 
71
78
  /**
@@ -136,7 +143,7 @@ export function createDefaultThemeConfig(
136
143
  // theming. This optimization breaks builds served outside Tamagui's SSR
137
144
  // pipeline (e.g. the Frappe SPA) where runtime JS themes are required.
138
145
  // Always keep themes to ensure TamaguiProvider has valid theme data.
139
- const themes = builtThemes;
146
+ const themes = options.subset ? subsetThemes(builtThemes, options.subset) : builtThemes;
140
147
  return createThemeConfig(
141
148
  {
142
149
  ...defaultConfig,
@@ -30,10 +30,13 @@ export * from "./resolveKnobs";
30
30
  export * from "./shared";
31
31
  export * from "./themeValue";
32
32
  export * from "./sizeRecipes";
33
+ export * from "./subsetThemes";
33
34
  export * from "./Surface";
34
35
  export * from "./tileSizes";
36
+ export * from "./transitionProps";
35
37
 
36
38
  export * from "./theme";
37
39
  export * from "./Tint";
38
40
  export * from "./useResolvedKnobs";
39
41
  export * from "./useTheme";
42
+ export * from "./useTouchSurface";
@@ -4,7 +4,9 @@ import { renderToString } from "react-dom/server";
4
4
  import { describe, expect, it, vi } from "vitest";
5
5
 
6
6
  // Mock Tamagui's <Theme> so we can inspect the chosen theme name
7
- vi.mock("tamagui", () => ({
7
+ vi.mock("tamagui", async (importOriginal) => ({
8
+ useDidFinishSSR: (await importOriginal<typeof import("tamagui")>()).useDidFinishSSR,
9
+ // These tests inspect intent names with a fixed light scheme.
8
10
  useThemeName: () => "light",
9
11
  Theme: ({ name, children }: { name: string; children: ReactNode }) =>
10
12
  createElement("div", { "data-tamagui-theme": name }, children),
@@ -214,8 +216,8 @@ describe("useResolvedKnobs with Intent", () => {
214
216
  ),
215
217
  );
216
218
 
217
- // "error" → Button: { elevation: "small" } → resolved to "$1"
218
- expect(capture.result.knobProps.elevation).toBe("$1");
219
+ // "error" → Button: { elevation: "small" }, and small paints no control shadow (MPO-96)
220
+ expect(capture.result.knobProps.elevation).toBeUndefined();
219
221
  });
220
222
 
221
223
  it("resolves component-specific intent overrides via explicit option", () => {
@@ -223,8 +225,8 @@ describe("useResolvedKnobs with Intent", () => {
223
225
 
224
226
  renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
225
227
 
226
- // "error" → Button: { elevation: "small" } → resolved to "$1"
227
- expect(capture.result.knobProps.elevation).toBe("$1");
228
+ // "error" → Button: { elevation: "small" }, and small paints no control shadow (MPO-96)
229
+ expect(capture.result.knobProps.elevation).toBeUndefined();
228
230
  });
229
231
 
230
232
  it("preset intent overrides take precedence over defaultIntents", () => {
@@ -287,8 +289,8 @@ describe("useResolvedKnobs with Intent", () => {
287
289
 
288
290
  renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
289
291
 
290
- // Default knobs: elevation "small" → resolved to "$1"
291
- expect(capture.result.knobProps.elevation).toBe("$1");
292
+ // Default knobs: elevation "small" paints no control shadow (MPO-96)
293
+ expect(capture.result.knobProps.elevation).toBeUndefined();
292
294
  expect(capture.result.knobProps.textAccent).toBe("high");
293
295
  });
294
296
  });
@@ -1,3 +1,4 @@
1
+ import { TestProviders } from "@multiplatform.one/test-utils";
1
2
  /**
2
3
  * Layout hook specs — the live half of layoutTokens (DG-LAY-01/03): the
3
4
  * viewport-driven `useLayoutSizeClass`, the pane budget `useMultiPane`, and
@@ -148,8 +149,8 @@ describe("useMultiPane", () => {
148
149
  describe("useSemanticGaps", () => {
149
150
  it("maps within-group to knob gap and between-groups to the large gap", () => {
150
151
  setViewportWidth(1000);
151
- const { result: knobs } = renderHook(() => useResolvedKnobs());
152
- const { result } = renderHook(() => useSemanticGaps());
152
+ const { result: knobs } = renderHook(() => useResolvedKnobs(), { wrapper: TestProviders });
153
+ const { result } = renderHook(() => useSemanticGaps(), { wrapper: TestProviders });
153
154
  expect(result.current.withinGroup).toEqual(knobs.current.knobProps.gap);
154
155
  expect(result.current.betweenGroups).toEqual(knobs.current.knobProps.gapLg);
155
156
  // The roles must differ, or grouping hierarchy is invisible.
@@ -81,6 +81,24 @@ export function radiusClassProps(
81
81
  return { "data-radius-class": radiusClass, "data-radius-part": part };
82
82
  }
83
83
 
84
+ export interface RadiusResolutionDeclaration {
85
+ "data-radius-resolution": "BINARY";
86
+ }
87
+
88
+ /**
89
+ * Declare that a part resolves through the BINARY row (DG-RAD-03), so the
90
+ * constraint audit measures it as 0 or half its own shorter edge. The token
91
+ * scale has no h/2 value, so an undeclared thumb or track reads off-scale at
92
+ * every stop. Spread it only where the painted radius IS the resolved one: a
93
+ * consumer radius override ejects the part from the class.
94
+ *
95
+ * @example
96
+ * <StyledSwitch.Thumb {...radiusResolutionProps("BINARY")} borderRadius={thumbRadius} />
97
+ */
98
+ export function radiusResolutionProps(radiusClass: "BINARY"): RadiusResolutionDeclaration {
99
+ return { "data-radius-resolution": radiusClass };
100
+ }
101
+
84
102
  // ── Radius resolution (DG-RAD-01..05) ─────────────────────────
85
103
 
86
104
  /**