@multiplatform.one/theme 7.11.0 → 7.16.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 (54) hide show
  1. package/README.md +22 -2
  2. package/package.json +6 -6
  3. package/src/audit/constraintAudit.spec.ts +180 -0
  4. package/src/audit/constraintAudit.ts +116 -37
  5. package/src/audit/themeMatrix.ts +4 -1
  6. package/src/figma/figmaTokens.spec.ts +1 -2
  7. package/src/numbers.spec.ts +27 -1
  8. package/src/numbers.ts +40 -0
  9. package/src/theme/Surface.spec.tsx +9 -4
  10. package/src/theme/createDefaultThemeConfig.ts +8 -1
  11. package/src/theme/createThemes.ts +43 -6
  12. package/src/theme/index.ts +3 -0
  13. package/src/theme/intent.spec.tsx +9 -7
  14. package/src/theme/layoutTokensHooks.spec.tsx +3 -2
  15. package/src/theme/radiusClass.ts +18 -0
  16. package/src/theme/resolveKnobs.spec.ts +73 -7
  17. package/src/theme/resolveKnobs.ts +99 -25
  18. package/src/theme/semanticEntryText.spec.ts +85 -0
  19. package/src/theme/sizeRecipes.ts +8 -5
  20. package/src/theme/subsetThemes.spec.ts +102 -0
  21. package/src/theme/subsetThemes.ts +56 -0
  22. package/src/theme/theme.tsx +15 -5
  23. package/src/theme/transitionProps.native.ts +15 -0
  24. package/src/theme/transitionProps.spec.ts +14 -0
  25. package/src/theme/transitionProps.ts +16 -0
  26. package/src/theme/useResolvedKnobs.hydration.spec.tsx +141 -0
  27. package/src/theme/useResolvedKnobs.ts +28 -22
  28. package/src/theme/useResolvedKnobsBehavior.spec.tsx +15 -1
  29. package/src/theme/useTouchSurface.ts +8 -0
  30. package/types/audit/constraintAudit.d.ts.map +1 -1
  31. package/types/audit/themeMatrix.d.ts.map +1 -1
  32. package/types/numbers.d.ts +12 -0
  33. package/types/numbers.d.ts.map +1 -1
  34. package/types/theme/createDefaultThemeConfig.d.ts +7 -0
  35. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  36. package/types/theme/createThemes.d.ts.map +1 -1
  37. package/types/theme/index.d.ts +3 -0
  38. package/types/theme/index.d.ts.map +1 -1
  39. package/types/theme/radiusClass.d.ts +14 -0
  40. package/types/theme/radiusClass.d.ts.map +1 -1
  41. package/types/theme/resolveKnobs.d.ts +35 -1
  42. package/types/theme/resolveKnobs.d.ts.map +1 -1
  43. package/types/theme/sizeRecipes.d.ts +8 -5
  44. package/types/theme/sizeRecipes.d.ts.map +1 -1
  45. package/types/theme/subsetThemes.d.ts +39 -0
  46. package/types/theme/subsetThemes.d.ts.map +1 -0
  47. package/types/theme/theme.d.ts.map +1 -1
  48. package/types/theme/transitionProps.d.ts +14 -0
  49. package/types/theme/transitionProps.d.ts.map +1 -0
  50. package/types/theme/transitionProps.native.d.ts +13 -0
  51. package/types/theme/transitionProps.native.d.ts.map +1 -0
  52. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  53. package/types/theme/useTouchSurface.d.ts +3 -0
  54. 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.16.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.16.0",
48
+ "@multiplatform.one/store": "7.16.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.16.0",
61
+ "@multiplatform.one/test-utils": "7.16.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,183 @@ 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("stacked rows cap their outer corners and square the inner ones (MPO-56)", () => {
246
+ const row = (position: string, radius: string, space: string) => ({
247
+ "data-constraint-container": "StackedRow",
248
+ "data-stack-position": position,
249
+ "data-radius-knob": radius,
250
+ "data-space-knob": space,
251
+ });
252
+ const corners = (tl: number, tr: number, br: number, bl: number) =>
253
+ `border-top-left-radius:${tl}px;border-top-right-radius:${tr}px;` +
254
+ `border-bottom-right-radius:${br}px;border-bottom-left-radius:${bl}px;padding:7px 13px`;
255
+
256
+ it.each([
257
+ ["first", corners(13, 13, 0, 0)],
258
+ ["middle", corners(0, 0, 0, 0)],
259
+ ["last", corners(0, 0, 13, 13)],
260
+ ["only", corners(13, 13, 13, 13)],
261
+ ])("accepts a %s row capped at the row padding", (position, style) => {
262
+ specimen(style, row(position, "large", "small"));
263
+ expect(measured()?.violations).toEqual([]);
264
+ });
265
+
266
+ it("names the stop it resolved on an outer corner", () => {
267
+ specimen(corners(18, 18, 0, 0), row("first", "full", "medium"));
268
+ expect(measured()?.borderTopLeftRadius?.knob).toBe(
269
+ "StackedRow min(radius:full, panelPadding:medium)",
270
+ );
271
+ });
272
+
273
+ it.each([
274
+ ["an uncapped outer corner", corners(16, 16, 0, 0), row("first", "large", "small")],
275
+ ["a rounded inner corner", corners(13, 13, 13, 0), row("first", "large", "small")],
276
+ ["a rounded middle row", corners(13, 0, 0, 0), row("middle", "large", "small")],
277
+ ["a missing position", corners(13, 13, 13, 13), row("", "large", "small")],
278
+ ])("rejects %s", (_label, style, attrs) => {
279
+ specimen(style, attrs);
280
+ expect(measured()?.borderRadius?.offScale).toBe(true);
281
+ });
282
+
283
+ it("says which corner is inner", () => {
284
+ specimen(corners(0, 0, 13, 9), row("last", "large", "small"));
285
+ expect(measured()?.violations).toEqual([
286
+ "borderBottomLeftRadius: 9px does not match StackedRow cap (13px)",
287
+ ]);
288
+ specimen(corners(9, 0, 13, 13), row("last", "large", "small")).id = "other";
289
+ const other = audit().elements.find((el) => el.id === "other");
290
+ expect(other?.violations).toEqual([
291
+ "borderTopLeftRadius: 9px is an inner corner of a last StackedRow (0px)",
292
+ ]);
293
+ });
294
+ });
295
+
296
+ describe("element labels hold nothing a knob or a mount writes", () => {
297
+ it("drops React useId ids and Tamagui atomic classes", () => {
298
+ const el = specimen("border-radius:12px");
299
+ el.id = "_r_3i_";
300
+ el.className = "is_View _borderStartStartRadius-0hover-c-radius-12 _outlineWidth-0focus-0px";
301
+ expect(measured()).toBeUndefined();
302
+ const found = audit().elements.find((entry) => entry.label.startsWith("div"));
303
+ expect(found?.label).toBe("div.is_View");
304
+ });
305
+
306
+ it("keeps an authored id", () => {
307
+ specimen("border-radius:12px");
308
+ expect(audit().elements[0]?.label).toBe("div#specimen");
309
+ });
310
+ });
311
+
312
+ describe("a fixed-padding container caps at the padding it has (DG-RAD-04, MPO-56)", () => {
313
+ const own = (radius: string) => ({
314
+ "data-constraint-container": "OwnInset",
315
+ "data-radius-knob": radius,
316
+ "data-space-knob": "medium",
317
+ });
318
+
319
+ it("accepts full capped at its own 13px padding whatever the space stop", () => {
320
+ specimen("border-radius:13px;padding:13px", own("full"));
321
+ expect(measured()?.violations).toEqual([]);
322
+ expect(measured()?.borderRadius?.knob).toBe("OwnInset min(radius:full, own padding)");
323
+ });
324
+
325
+ it("accepts the token while it fits", () => {
326
+ specimen("border-radius:9px;padding:13px", own("medium"));
327
+ expect(measured()?.violations).toEqual([]);
328
+ });
329
+
330
+ it.each([
331
+ ["the panelPadding cap instead of its own", "border-radius:18px;padding:13px", own("full")],
332
+ ["the token past its padding", "border-radius:16px;padding:13px", own("large")],
333
+ ])("rejects %s", (_label, style, attrs) => {
334
+ specimen(style, attrs);
335
+ expect(measured()?.borderRadius?.offScale).toBe(true);
336
+ });
337
+ });
@@ -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,48 +734,90 @@ 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 stackedRow = containerName === "StackedRow";
743
+ // A container padded with a fixed token rather than the panelPadding knob
744
+ // caps at the padding it actually has.
745
+ const ownInset = containerName === "OwnInset";
746
+ const capContainer =
747
+ containerName === "Card"
748
+ ? ["content", "elevated"].includes(el.getAttribute("data-tier") ?? "")
749
+ : containerName === "PageSection" || containerName === "TextArea" || stackedRow || ownInset;
750
+ if (containerName !== null && containerName !== "Card" && !capContainer) {
751
+ entry.violations.push(
752
+ `data-constraint-container: "${containerName}" is not a container the cap audit knows`,
753
+ );
754
+ }
755
+ const binaryPart = el.getAttribute("data-radius-resolution") === "BINARY";
756
+ const corners = [
757
+ "borderTopLeftRadius",
758
+ "borderTopRightRadius",
759
+ "borderBottomRightRadius",
760
+ "borderBottomLeftRadius",
761
+ ] as const;
762
+ // MPO-56: a row in a vertical stack caps its OUTER corners and squares
763
+ // the inner ones (LC-72), so the cap applies per corner by position.
764
+ const stackPosition = el.getAttribute("data-stack-position") ?? "";
765
+ const outerCorners: Record<string, readonly (typeof corners)[number][]> = {
766
+ only: corners,
767
+ first: ["borderTopLeftRadius", "borderTopRightRadius"],
768
+ middle: [],
769
+ last: ["borderBottomRightRadius", "borderBottomLeftRadius"],
770
+ };
771
+ const stackOuter = Object.hasOwn(outerCorners, stackPosition)
772
+ ? outerCorners[stackPosition]
773
+ : undefined;
774
+ if (stackedRow && !stackOuter) {
775
+ entry.violations.push(
776
+ `data-stack-position: "${stackPosition}" is not a StackedRow position (only, first, middle, last)`,
777
+ );
778
+ }
779
+ if (capContainer) {
738
780
  const radiusKnob = el.getAttribute("data-radius-knob") ?? "";
739
781
  const spaceKnob = el.getAttribute("data-space-knob") ?? "";
740
782
  const radiusStop = Object.hasOwn(_BORDER_RADIUS_KNOB, radiusKnob)
741
783
  ? _BORDER_RADIUS_KNOB[radiusKnob]
742
784
  : undefined;
743
- const inset = Object.hasOwn(_PANEL_PADDING_KNOB, spaceKnob)
744
- ? _PANEL_PADDING_KNOB[spaceKnob]
745
- : undefined;
746
785
  const paddingSides = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] as const;
747
786
  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
- );
787
+ const inset = ownInset
788
+ ? measuredInsets.some((v) => v === null)
789
+ ? undefined
790
+ : Math.min(...(measuredInsets as number[]))
791
+ : Object.hasOwn(_PANEL_PADDING_KNOB, spaceKnob)
792
+ ? _PANEL_PADDING_KNOB[spaceKnob]
793
+ : undefined;
794
+ if (ownsInset) {
795
+ for (const [index, prop] of paddingSides.entries()) {
796
+ const value = measuredInsets[index];
797
+ const offScale = inset === undefined || value !== inset;
798
+ entry[prop] = {
799
+ value: value ?? 0,
800
+ unit: "px",
801
+ knob: `${containerName} panelPadding:${spaceKnob}`,
802
+ offScale,
803
+ };
804
+ if (offScale)
805
+ entry.violations.push(
806
+ `${prop}: ${style[prop]} does not match ${containerName} panelPadding:${spaceKnob} (${inset ?? "unknown"}px)`,
807
+ );
808
+ }
761
809
  }
762
- const expected =
763
- radiusStop === undefined || inset === undefined || measuredInsets.some((v) => v === null)
810
+ const cap =
811
+ radiusStop === undefined ||
812
+ inset === undefined ||
813
+ (stackedRow && !stackOuter) ||
814
+ (ownsInset && measuredInsets.some((v) => v === null))
764
815
  ? undefined
765
- : Math.min(radiusStop, inset, ...(measuredInsets as number[]));
766
- const corners = [
767
- "borderTopLeftRadius",
768
- "borderTopRightRadius",
769
- "borderBottomRightRadius",
770
- "borderBottomLeftRadius",
771
- ] as const;
816
+ : Math.min(radiusStop, inset, ...(ownsInset ? (measuredInsets as number[]) : []));
772
817
  for (const prop of corners) {
773
818
  const raw = style[prop];
819
+ const inner = stackedRow && stackOuter !== undefined && !stackOuter.includes(prop);
820
+ const expected = inner ? 0 : cap;
774
821
  // A corner may have two elliptical axes. Percentages are not px caps.
775
822
  const axes = raw.trim().split(/\s+/);
776
823
  const offScale =
@@ -779,13 +826,19 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
779
826
  const result = {
780
827
  value: parsePx(raw) ?? 0,
781
828
  unit: "px",
782
- knob: `Card min(radius:${radiusKnob}, panelPadding:${spaceKnob})`,
829
+ knob: inner
830
+ ? `${containerName} ${stackPosition} inner corner 0`
831
+ : ownInset
832
+ ? `${containerName} min(radius:${radiusKnob}, own padding)`
833
+ : `${containerName} min(radius:${radiusKnob}, panelPadding:${spaceKnob})`,
783
834
  offScale,
784
835
  };
785
836
  entry[prop] = result;
786
837
  if (offScale)
787
838
  entry.violations.push(
788
- `${prop}: ${raw} does not match Card cap (${expected ?? "unknown"}px)`,
839
+ inner
840
+ ? `${prop}: ${raw} is an inner corner of a ${stackPosition} StackedRow (0px)`
841
+ : `${prop}: ${raw} does not match ${containerName} cap (${expected ?? "unknown"}px)`,
789
842
  );
790
843
  }
791
844
  entry.borderRadius = {
@@ -793,6 +846,32 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
793
846
  offScale: corners.some((prop) => entry[prop]?.offScale),
794
847
  };
795
848
  hasAnyProp = true;
849
+ } else if (binaryPart) {
850
+ // DG-RAD-03: square, or a pill/circle of the part's own shorter edge,
851
+ // at every stop and in every state. Layout size, not the transformed
852
+ // box, so a translated thumb measures the same as a resting one.
853
+ const box = el as HTMLElement;
854
+ const half = Math.min(box.offsetWidth, box.offsetHeight) / 2;
855
+ const measuredCorners = corners.map((prop) => style[prop].trim().split(/\s+/));
856
+ if (measuredCorners.some((axes) => axes.some((axis) => parsePx(axis) !== 0))) {
857
+ for (const [index, prop] of corners.entries()) {
858
+ const raw = style[prop];
859
+ const offScale = measuredCorners[index].some((axis) => {
860
+ if (!/^\d+(?:\.\d+)?(?:px)?$/.test(axis)) return true;
861
+ const value = parsePx(axis) ?? 0;
862
+ // offsetWidth/offsetHeight round to whole px; the painted h/2 does not.
863
+ return value !== 0 && Math.abs(value - half) > 0.5;
864
+ });
865
+ entry[prop] = { value: parsePx(raw) ?? 0, unit: "px", knob: "BINARY 0 | h/2", offScale };
866
+ if (offScale)
867
+ entry.violations.push(`${prop}: ${raw} is neither 0 nor BINARY h/2 (${half}px)`);
868
+ }
869
+ entry.borderRadius = {
870
+ ...entry.borderTopLeftRadius!,
871
+ offScale: corners.some((prop) => entry[prop]?.offScale),
872
+ };
873
+ hasAnyProp = true;
874
+ }
796
875
  } else if (!isPercentageBorderRadius(el)) {
797
876
  // Ordinary controls and identity recipes retain their existing scales.
798
877
  const br = parsePx(style.borderTopLeftRadius || style.borderRadius);
@@ -839,7 +918,7 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
839
918
  // Only check padding on elements that look like surfaces (have border-radius
840
919
  // or border-width already audited). Padding on all elements creates too many
841
920
  // false positives from browser defaults and third-party components.
842
- if (!cardContainer && (entry.borderRadius || entry.borderWidth)) {
921
+ if (!(capContainer && ownsInset) && (entry.borderRadius || entry.borderWidth)) {
843
922
  const sides = [
844
923
  ["paddingTop", style.paddingTop],
845
924
  ["paddingRight", style.paddingRight],
@@ -969,7 +969,10 @@ export function captureMatrixSnapshot(): MatrixSnapshot {
969
969
  marks.length > 0 &&
970
970
  marks.every(
971
971
  (mark, index) =>
972
- mark.tagName.toLowerCase() === "rect" &&
972
+ // A bar path that declares its box (value-end rounding, MPO-56)
973
+ // paints inside that box just as a rect does.
974
+ (mark.tagName.toLowerCase() === "rect" ||
975
+ (mark.tagName.toLowerCase() === "path" && mark.hasAttribute("data-mpo-chart-bar"))) &&
973
976
  getComputedStyle(mark).stroke === "none" &&
974
977
  !sample.marks[index].unmeasurable,
975
978
  )
@@ -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,