@multiplatform.one/components 7.5.0 → 7.6.1

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 (122) hide show
  1. package/dist/cjs/CopyField/index.cjs +4 -1
  2. package/dist/cjs/CopyField/index.native.js +4 -1
  3. package/dist/cjs/CopyField/index.native.js.map +1 -1
  4. package/dist/cjs/ProgressSteps/index.cjs +5 -2
  5. package/dist/cjs/ProgressSteps/index.native.js +5 -2
  6. package/dist/cjs/ProgressSteps/index.native.js.map +1 -1
  7. package/dist/cjs/Separator/index.native.js.map +1 -1
  8. package/dist/cjs/Wizard/index.cjs +1 -0
  9. package/dist/cjs/Wizard/index.native.js +3 -12
  10. package/dist/cjs/Wizard/index.native.js.map +1 -1
  11. package/dist/cjs/code/CodeBlock/index.cjs +38 -22
  12. package/dist/cjs/code/CodeBlock/index.native.js +38 -22
  13. package/dist/cjs/code/CodeBlock/index.native.js.map +1 -1
  14. package/dist/cjs/index.cjs +1 -0
  15. package/dist/cjs/index.native.js +1 -0
  16. package/dist/cjs/index.native.js.map +1 -1
  17. package/dist/cjs/surfaces.cjs +30 -7
  18. package/dist/cjs/surfaces.native.js +30 -7
  19. package/dist/cjs/surfaces.native.js.map +1 -1
  20. package/dist/cjs/views/index.cjs +3 -0
  21. package/dist/cjs/views/index.native.js +3 -0
  22. package/dist/cjs/views/index.native.js.map +1 -1
  23. package/dist/esm/CopyField/index.mjs +4 -1
  24. package/dist/esm/CopyField/index.mjs.map +1 -1
  25. package/dist/esm/CopyField/index.native.js +4 -1
  26. package/dist/esm/CopyField/index.native.js.map +1 -1
  27. package/dist/esm/ProgressSteps/index.mjs +5 -2
  28. package/dist/esm/ProgressSteps/index.mjs.map +1 -1
  29. package/dist/esm/ProgressSteps/index.native.js +5 -2
  30. package/dist/esm/ProgressSteps/index.native.js.map +1 -1
  31. package/dist/esm/Separator/index.mjs.map +1 -1
  32. package/dist/esm/Separator/index.native.js.map +1 -1
  33. package/dist/esm/Wizard/index.mjs +1 -0
  34. package/dist/esm/Wizard/index.mjs.map +1 -1
  35. package/dist/esm/Wizard/index.native.js +3 -12
  36. package/dist/esm/Wizard/index.native.js.map +1 -1
  37. package/dist/esm/code/CodeBlock/index.mjs +42 -26
  38. package/dist/esm/code/CodeBlock/index.mjs.map +1 -1
  39. package/dist/esm/code/CodeBlock/index.native.js +42 -26
  40. package/dist/esm/code/CodeBlock/index.native.js.map +1 -1
  41. package/dist/esm/index.mjs +1 -0
  42. package/dist/esm/index.mjs.map +1 -1
  43. package/dist/esm/index.native.js +1 -0
  44. package/dist/esm/index.native.js.map +1 -1
  45. package/dist/esm/surfaces.mjs +31 -8
  46. package/dist/esm/surfaces.mjs.map +1 -1
  47. package/dist/esm/surfaces.native.js +31 -8
  48. package/dist/esm/surfaces.native.js.map +1 -1
  49. package/dist/esm/views/index.mjs +2 -2
  50. package/dist/esm/views/index.mjs.map +1 -1
  51. package/dist/esm/views/index.native.js +2 -2
  52. package/dist/esm/views/index.native.js.map +1 -1
  53. package/dist/jsx/CopyField/index.mjs +4 -1
  54. package/dist/jsx/CopyField/index.mjs.map +1 -1
  55. package/dist/jsx/CopyField/index.native.js +4 -1
  56. package/dist/jsx/CopyField/index.native.js.map +1 -1
  57. package/dist/jsx/ProgressSteps/index.mjs +5 -2
  58. package/dist/jsx/ProgressSteps/index.mjs.map +1 -1
  59. package/dist/jsx/ProgressSteps/index.native.js +5 -2
  60. package/dist/jsx/ProgressSteps/index.native.js.map +1 -1
  61. package/dist/jsx/Separator/index.mjs.map +1 -1
  62. package/dist/jsx/Separator/index.native.js.map +1 -1
  63. package/dist/jsx/Wizard/index.mjs +1 -0
  64. package/dist/jsx/Wizard/index.mjs.map +1 -1
  65. package/dist/jsx/Wizard/index.native.js +3 -12
  66. package/dist/jsx/Wizard/index.native.js.map +1 -1
  67. package/dist/jsx/code/CodeBlock/index.mjs +42 -26
  68. package/dist/jsx/code/CodeBlock/index.mjs.map +1 -1
  69. package/dist/jsx/code/CodeBlock/index.native.js +38 -22
  70. package/dist/jsx/code/CodeBlock/index.native.js.map +1 -1
  71. package/dist/jsx/index.js +1 -0
  72. package/dist/jsx/index.js.map +1 -1
  73. package/dist/jsx/index.mjs +1 -0
  74. package/dist/jsx/index.mjs.map +1 -1
  75. package/dist/jsx/index.native.js +1 -0
  76. package/dist/jsx/index.native.js.map +1 -1
  77. package/dist/jsx/surfaces.mjs +31 -8
  78. package/dist/jsx/surfaces.mjs.map +1 -1
  79. package/dist/jsx/surfaces.native.js +30 -7
  80. package/dist/jsx/surfaces.native.js.map +1 -1
  81. package/dist/jsx/views/index.mjs +2 -2
  82. package/dist/jsx/views/index.mjs.map +1 -1
  83. package/dist/jsx/views/index.native.js +3 -0
  84. package/dist/jsx/views/index.native.js.map +1 -1
  85. package/package.json +9 -9
  86. package/src/Carousel/Carousel.stories.tsx +0 -1
  87. package/src/CopyField/index.tsx +10 -15
  88. package/src/Heading.native.spec.tsx +156 -0
  89. package/src/Heading.spec.tsx +14 -79
  90. package/src/Meter/Meter.spec.tsx +9 -1
  91. package/src/ProgressSteps/index.tsx +18 -3
  92. package/src/Separator/Separator.spec.tsx +68 -32
  93. package/src/Separator/index.tsx +27 -9
  94. package/src/Text.native.spec.tsx +144 -0
  95. package/src/Text.spec.tsx +12 -70
  96. package/src/Tooltip/tooltipGapContract.spec.ts +54 -0
  97. package/src/Wizard/Wizard.spec.tsx +23 -1
  98. package/src/Wizard/index.tsx +1 -8
  99. package/src/code/CodeBlock/CodeBlock.spec.tsx +6 -6
  100. package/src/code/CodeBlock/index.tsx +44 -27
  101. package/src/index.ts +12 -6
  102. package/src/overlayElevation.spec.tsx +154 -0
  103. package/src/surfaces.spec.tsx +98 -1
  104. package/src/surfaces.tsx +28 -6
  105. package/src/tamagui.spec.ts +11 -2
  106. package/src/tamagui.ts +5 -5
  107. package/src/views/index.ts +15 -0
  108. package/types/CopyField/index.d.ts +8 -14
  109. package/types/CopyField/index.d.ts.map +1 -1
  110. package/types/ProgressSteps/index.d.ts +8 -1
  111. package/types/ProgressSteps/index.d.ts.map +1 -1
  112. package/types/Separator/index.d.ts +27 -9
  113. package/types/Separator/index.d.ts.map +1 -1
  114. package/types/Tabs/index.d.ts +1 -1
  115. package/types/Wizard/index.d.ts.map +1 -1
  116. package/types/code/CodeBlock/index.d.ts.map +1 -1
  117. package/types/index.d.ts +1 -0
  118. package/types/index.d.ts.map +1 -1
  119. package/types/surfaces.d.ts +6 -6
  120. package/types/surfaces.d.ts.map +1 -1
  121. package/types/views/index.d.ts +1 -1
  122. package/types/views/index.d.ts.map +1 -1
@@ -12,16 +12,16 @@
12
12
  * against explicit-prop renders of the same value. Hashed class names are
13
13
  * compared by identity only.
14
14
  *
15
- * WEB arm: default module graph. NATIVE arm: `@multiplatform.one/platform`
16
- * mocked to isWeb:false on a fresh module graph with Preset/render/Heading
17
- * imported from that same graph. FontKnobStyles' web-only CSS rescue is
18
- * mounted in NEITHER arm, so flips reaching the node prove the props
19
- * channel — the only channel native has.
15
+ * This file is the WEB arm (the default module graph). The NATIVE arm is
16
+ * ./Heading.native.spec.tsx, a separate FILE so that a file-scoped `vi.mock`
17
+ * gives it one coherent module graph. FontKnobStyles' web-only CSS rescue is
18
+ * mounted in NEITHER arm, so flips reaching the node prove the props channel
19
+ * — the only channel native has.
20
20
  */
21
21
  import { cleanup, screen } from "@testing-library/react";
22
22
  import { renderWithProviders } from "@multiplatform.one/test-utils";
23
23
  import { Preset } from "@multiplatform.one/theme";
24
- import { afterEach, describe, expect, it, vi } from "vitest";
24
+ import { afterEach, describe, expect, it } from "vitest";
25
25
  import { H1, H2, H3, Heading } from "./Heading";
26
26
 
27
27
  afterEach(cleanup);
@@ -174,9 +174,7 @@ describe("house H3 — subordinate headings are mono 400 on the TEXT NODE", () =
174
174
  "H3",
175
175
  );
176
176
  expect(ejected).toEqual(explicit);
177
- expect(ejected).not.toEqual(
178
- renderHeadingClasses(<H3>{TITLE}</H3>, ["_ff-", "_fow-"], "H3"),
179
- );
177
+ expect(ejected).not.toEqual(renderHeadingClasses(<H3>{TITLE}</H3>, ["_ff-", "_fow-"], "H3"));
180
178
  });
181
179
  });
182
180
 
@@ -207,11 +205,7 @@ describe("house H1 — pageTitleScale reaches the title text node (LC-17 / D-11)
207
205
  // pageTitleSizeMap). Identity against explicit size props pins the
208
206
  // fragment to the table without hardcoding hashed classes or px.
209
207
  const moderate = renderHeadingClasses(<H1>{TITLE}</H1>, ["_fos-", "_lh-"], "H1");
210
- const explicit8 = renderHeadingClasses(
211
- <H1 size="$8">{TITLE}</H1>,
212
- ["_fos-", "_lh-"],
213
- "H1",
214
- );
208
+ const explicit8 = renderHeadingClasses(<H1 size="$8">{TITLE}</H1>, ["_fos-", "_lh-"], "H1");
215
209
  const display = renderHeadingClasses(
216
210
  <Preset overrides={{ pageTitleScale: "display" }}>
217
211
  <H1>{TITLE}</H1>
@@ -219,11 +213,7 @@ describe("house H1 — pageTitleScale reaches the title text node (LC-17 / D-11)
219
213
  ["_fos-", "_lh-"],
220
214
  "H1",
221
215
  );
222
- const explicit10 = renderHeadingClasses(
223
- <H1 size="$10">{TITLE}</H1>,
224
- ["_fos-", "_lh-"],
225
- "H1",
226
- );
216
+ const explicit10 = renderHeadingClasses(<H1 size="$10">{TITLE}</H1>, ["_fos-", "_lh-"], "H1");
227
217
  expect(moderate).toEqual(explicit8);
228
218
  expect(display).toEqual(explicit10);
229
219
  });
@@ -259,63 +249,8 @@ describe("house H1 — pageTitleScale reaches the title text node (LC-17 / D-11)
259
249
  });
260
250
  });
261
251
 
262
- describe("house Heading NATIVE environment (isWeb false, fresh module graph)", () => {
263
- it("fontWeight and pageTitleScale flips still reach the mounted heading", async () => {
264
- vi.resetModules();
265
- vi.doMock("@multiplatform.one/platform", async (importOriginal) => ({
266
- ...(await importOriginal<typeof import("@multiplatform.one/platform")>()),
267
- isWeb: false,
268
- isNative: true,
269
- }));
270
- try {
271
- const [{ H1: NativeH1, Heading: NativeHeading }, { Preset: NativePreset }, testUtils, testingLibrary] =
272
- await Promise.all([
273
- import("./Heading"),
274
- import("@multiplatform.one/theme"),
275
- import("@multiplatform.one/test-utils"),
276
- import("@testing-library/react"),
277
- ]);
278
-
279
- const renderNative = (node: React.ReactElement, prefixes: string[]): string[] => {
280
- testUtils.renderWithProviders(node);
281
- const el = testingLibrary.screen.getByText(TITLE);
282
- const classes = classesByPrefix(el, prefixes);
283
- testingLibrary.cleanup();
284
- return classes;
285
- };
286
-
287
- const regular = renderNative(
288
- <NativePreset overrides={{ fontWeight: "regular" }}>
289
- <NativeHeading>{TITLE}</NativeHeading>
290
- </NativePreset>,
291
- ["_fow-"],
292
- );
293
- const bold = renderNative(
294
- <NativePreset overrides={{ fontWeight: "bold" }}>
295
- <NativeHeading>{TITLE}</NativeHeading>
296
- </NativePreset>,
297
- ["_fow-"],
298
- );
299
- expect(regular).not.toHaveLength(0);
300
- expect(regular).not.toEqual(bold);
301
-
302
- const moderate = renderNative(
303
- <NativePreset overrides={{ pageTitleScale: "moderate" }}>
304
- <NativeH1>{TITLE}</NativeH1>
305
- </NativePreset>,
306
- ["_fos-", "_lh-"],
307
- );
308
- const display = renderNative(
309
- <NativePreset overrides={{ pageTitleScale: "display" }}>
310
- <NativeH1>{TITLE}</NativeH1>
311
- </NativePreset>,
312
- ["_fos-", "_lh-"],
313
- );
314
- expect(moderate).not.toHaveLength(0);
315
- expect(moderate).not.toEqual(display);
316
- } finally {
317
- vi.doUnmock("@multiplatform.one/platform");
318
- vi.resetModules();
319
- }
320
- });
321
- });
252
+ // The NATIVE arm lives in ./Heading.native.spec.tsx. It used to sit here as a
253
+ // `vi.resetModules()` + dynamic-import block, but that returned a SPLIT module
254
+ // graph in which the knob overrides never reached the heading, so both sides
255
+ // of the flip measured `_fow-400` and the assertion passed a knob-dead render.
256
+ // A file-scoped `vi.mock` in its own spec file is one coherent graph.
@@ -12,7 +12,11 @@ import { Preset, resolveRadiusClass } from "@multiplatform.one/theme";
12
12
  import type { ReactElement } from "react";
13
13
  import { Theme } from "tamagui";
14
14
  import { afterEach, describe, expect, it } from "vitest";
15
- import { ListCard, ProgressCard, ShortcutCard } from "../views/Dashboard";
15
+ // Deliberately the views BARREL, not ../views/Dashboard: the deep path always
16
+ // resolved, so importing it here let 5c7af7134 drop the re-exports without a
17
+ // failing test. This import IS the export-gap assertion.
18
+ import { ListCard, ProgressCard, ShortcutCard } from "../views";
19
+ import * as dashboard from "../views/Dashboard";
16
20
  import * as pkg from "../index";
17
21
  import { Meter } from "./index";
18
22
  import {
@@ -199,5 +203,9 @@ describe("barrel exports (the ProgressCard export gap)", () => {
199
203
  expect(typeof ProgressCard).toBe("function");
200
204
  expect(typeof ShortcutCard).toBe("function");
201
205
  expect(typeof ListCard).toBe("function");
206
+ // The barrel export must be the Dashboard implementation, not a copy.
207
+ expect(ProgressCard).toBe(dashboard.ProgressCard);
208
+ expect(ShortcutCard).toBe(dashboard.ShortcutCard);
209
+ expect(ListCard).toBe(dashboard.ListCard);
202
210
  });
203
211
  });
@@ -53,6 +53,13 @@ export interface ProgressStepsProps extends Omit<GetProps<typeof StepsRoot>, "ch
53
53
  linear?: boolean;
54
54
  /** Compact markers + type (density compact override). */
55
55
  compact?: boolean;
56
+ /**
57
+ * Rail label treatment. `"hidden"` keeps markers (and a11y names) and
58
+ * drops the visible per-step label — the 390 wizard treatment; the
59
+ * current step's name rides the wizard legend as "Step n of m — X".
60
+ * @default "visible"
61
+ */
62
+ labels?: "visible" | "hidden";
56
63
  }
57
64
 
58
65
  type StepState = "completed" | "current" | "upcoming";
@@ -130,6 +137,7 @@ export function ProgressSteps({
130
137
  allowJump = true,
131
138
  linear = true,
132
139
  compact,
140
+ labels = "visible",
133
141
  ...props
134
142
  }: ProgressStepsProps) {
135
143
  const { t } = useTranslation();
@@ -155,7 +163,13 @@ export function ProgressSteps({
155
163
  const gapToken = knobProps.gap.gap;
156
164
 
157
165
  return (
158
- <StepsRoot role="list" aria-label={t("Progress")} data-density={knobProps.density} {...props}>
166
+ <StepsRoot
167
+ role="list"
168
+ aria-label={t("Progress")}
169
+ data-density={knobProps.density}
170
+ data-labels={labels}
171
+ {...props}
172
+ >
159
173
  <XStack
160
174
  flexDirection={horizontal ? "row" : "column"}
161
175
  alignItems={horizontal ? "flex-start" : "stretch"}
@@ -310,10 +324,11 @@ export function ProgressSteps({
310
324
  />
311
325
  ) : null;
312
326
 
327
+ const showLabels = labels !== "hidden";
313
328
  const body = horizontal ? (
314
329
  <YStack alignItems="center" flexShrink={0}>
315
330
  {markerTarget}
316
- {labelBlock}
331
+ {showLabels ? labelBlock : null}
317
332
  </YStack>
318
333
  ) : (
319
334
  <XStack alignItems="stretch" gap={gapToken} minHeight={MIN_PRESS_TARGET}>
@@ -321,7 +336,7 @@ export function ProgressSteps({
321
336
  {markerTarget}
322
337
  {verticalConnector}
323
338
  </YStack>
324
- {labelBlock}
339
+ {showLabels ? labelBlock : null}
325
340
  </XStack>
326
341
  );
327
342
 
@@ -3,17 +3,25 @@
3
3
  *
4
4
  * MPO-47 — Separator paints a line.
5
5
  *
6
- * The defect this locks against: 96 of 96 separators measured height 1px,
7
- * background-color rgba(0,0,0,0), border-top-width 0px — the box was there,
8
- * the PAINT was not. Asserting height and never colour is exactly why that
9
- * shipped, so this spec asserts the painted colour first.
6
+ * What this locks: the house rule paints, at 1 CSS px, in $borderColor, in
7
+ * both schemes, and cannot flex-grow (DG-OVF-07 — the raw primitive it
8
+ * replaced declares `flex: 1`). Asserting a separator's height and never its
9
+ * colour is how a transparent rule ships, so this spec asserts the painted
10
+ * colour first.
10
11
  *
11
- * jsdom cannot cascade Tamagui's class-based CSS, so paint is asserted the
12
- * house way (Button LC-18 / DotIndicator technique): read the atomic classes
13
- * the style emits. `_bg-borderColor` is the background atom bound to the
14
- * `$borderColor` theme token one token, resolved per scheme and the
15
- * both-schemes arm additionally asserts the token's light AND dark values are
16
- * real, non-transparent colours in the active config.
12
+ * (The ticket's "96 of 96 paint nothing" reading was a probe artefact — it
13
+ * read borderTopWidth on a primitive that paints borderBottomWidth. See the
14
+ * component header. The assertions below are unaffected: they are about what
15
+ * the HOUSE rule paints, not about what the raw one did.)
16
+ *
17
+ * jsdom cannot CASCADE Tamagui's class-based CSS, so `getComputedStyle` on the
18
+ * element reads nothing — but Tamagui does INJECT its atomic rules into
19
+ * `document.styleSheets`, so the declaration behind each of the element's own
20
+ * atoms is readable and is what this spec asserts. That is deliberate: the
21
+ * first version of this file matched atom NAMES (`/_o-0d0t5/`), the encoder
22
+ * emits `_o-0--5`, and the quiet test was red from the day it merged without
23
+ * anyone seeing it — CI runs `turbo run build` only (MPO-78). A declaration is
24
+ * the law; an atom name is an implementation detail of the encoder.
17
25
  */
18
26
  import { renderWithProviders } from "@multiplatform.one/test-utils";
19
27
  import { cleanup } from "@testing-library/react";
@@ -29,6 +37,39 @@ function getSeparator(container: HTMLElement): HTMLElement {
29
37
  return el as HTMLElement;
30
38
  }
31
39
 
40
+ const escapeForRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
41
+
42
+ /**
43
+ * Every value Tamagui declared for `property` through one of THIS element's
44
+ * atomic classes. Empty means the element does not carry that property at all.
45
+ */
46
+ function declared(el: HTMLElement, property: string): string[] {
47
+ const atoms = el.className.split(/\s+/).filter(Boolean);
48
+ const values: string[] = [];
49
+ for (const sheet of Array.from(document.styleSheets)) {
50
+ let rules: CSSRuleList | undefined;
51
+ try {
52
+ rules = sheet.cssRules;
53
+ } catch {
54
+ continue; // cross-origin sheet; nothing of ours lives there
55
+ }
56
+ for (const rule of Array.from(rules ?? [])) {
57
+ const parsed = (rule.cssText ?? "").match(/^([^{]+)\{([^}]*)\}$/);
58
+ if (!parsed) continue;
59
+ const [, selector, body] = parsed;
60
+ const mine = atoms.some((atom) =>
61
+ new RegExp(`\\.${escapeForRegExp(atom)}(?![\\w-])`).test(selector),
62
+ );
63
+ if (!mine) continue;
64
+ const decl = body.match(
65
+ new RegExp(`(?:^|[;\\s])${escapeForRegExp(property)}\\s*:\\s*([^;]+)`),
66
+ );
67
+ if (decl) values.push(decl[1].trim());
68
+ }
69
+ }
70
+ return values;
71
+ }
72
+
32
73
  /** Resolve a theme colour Variable to its raw value string. */
33
74
  function themeColorVal(scheme: "light" | "dark", key: string): string {
34
75
  const themes = getConfig().themes as Record<string, Record<string, { val?: unknown }>>;
@@ -47,14 +88,10 @@ describe("Separator — painted colour (the regression this component exists for
47
88
  it("paints its 1px box with the $borderColor token, not a transparent box", () => {
48
89
  const { container } = renderWithProviders(<Separator />);
49
90
  const separator = getSeparator(container);
50
- // PAINT: the background atom is bound to the borderColor token.
51
- expect(separator.className).toMatch(/_bg-borderColo/);
52
- // Nothing re-zeroes the paint: no transparent background atom.
53
- expect(separator.className).not.toMatch(/_bg-transparent/);
91
+ // PAINT: the declared background is the borderColor token, not transparent.
92
+ expect(declared(separator, "background-color")).toEqual(["var(--borderColor)"]);
54
93
  // GEOMETRY: exactly 1 CSS px tall — never 0.5 (§7.3: "1px is the value").
55
- expect(separator.className).toMatch(/_h-1px/);
56
- expect(separator.className).not.toMatch(/_h-0d0t5px/);
57
- expect(separator.className).not.toMatch(/_mah-0d0t5px/);
94
+ expect(declared(separator, "height")).toEqual(["1px"]);
58
95
  });
59
96
 
60
97
  it("carries a visible $borderColor in BOTH schemes (token values are real colours)", () => {
@@ -69,15 +106,15 @@ describe("Separator — painted colour (the regression this component exists for
69
106
  }
70
107
  });
71
108
 
72
- it("paints the same token atom under an explicit dark theme subtree", () => {
109
+ it("paints the same token under an explicit dark theme subtree", () => {
73
110
  const { container } = renderWithProviders(
74
111
  <Theme name="dark">
75
112
  <Separator />
76
113
  </Theme>,
77
114
  );
78
115
  const separator = getSeparator(container);
79
- expect(separator.className).toMatch(/_bg-borderColo/);
80
- expect(separator.className).toMatch(/_h-1px/);
116
+ expect(declared(separator, "background-color")).toEqual(["var(--borderColor)"]);
117
+ expect(declared(separator, "height")).toEqual(["1px"]);
81
118
  });
82
119
  });
83
120
 
@@ -85,14 +122,14 @@ describe("Separator — quiet variant (§7.3: opacity 0.5, never a lighter colou
85
122
  it("dims with opacity 0.5 and keeps the exact same paint token", () => {
86
123
  const { container } = renderWithProviders(<Separator quiet />);
87
124
  const separator = getSeparator(container);
88
- expect(separator.className).toMatch(/_o-0d0t5/);
89
- // Never a lighter colour: the background atom is unchanged from default.
90
- expect(separator.className).toMatch(/_bg-borderColo/);
125
+ expect(declared(separator, "opacity")).toEqual(["0.5"]);
126
+ // Never a lighter colour: the paint token is unchanged from the default.
127
+ expect(declared(separator, "background-color")).toEqual(["var(--borderColor)"]);
91
128
  });
92
129
 
93
130
  it("default separator carries no opacity dim", () => {
94
131
  const { container } = renderWithProviders(<Separator />);
95
- expect(getSeparator(container).className).not.toMatch(/_o-0d0t5/);
132
+ expect(declared(getSeparator(container), "opacity")).toEqual([]);
96
133
  });
97
134
  });
98
135
 
@@ -100,8 +137,8 @@ describe("Separator — vertical (§7.3: vertical separators are 1px wide)", ()
100
137
  it("is 1px wide, releases the horizontal height, and declares its orientation", () => {
101
138
  const { container } = renderWithProviders(<Separator vertical />);
102
139
  const separator = getSeparator(container);
103
- expect(separator.className).toMatch(/_w-1px/);
104
- expect(separator.className).not.toMatch(/_w-0d0t5px/);
140
+ expect(declared(separator, "width")).toEqual(["1px"]);
141
+ expect(declared(separator, "height")).toEqual(["auto"]);
105
142
  expect(separator.getAttribute("aria-orientation")).toBe("vertical");
106
143
  });
107
144
 
@@ -115,10 +152,10 @@ describe("Separator — DG-OVF-07: never leftover space", () => {
115
152
  it("cannot grow or shrink (flex: none discipline on the painting edge)", () => {
116
153
  const { container } = renderWithProviders(<Separator />);
117
154
  const separator = getSeparator(container);
118
- expect(separator.className).toMatch(/_fg-0/);
119
- expect(separator.className).toMatch(/_fs-0/);
120
- // The raw primitive was a `flex: 1` item — the defect DG-OVF-07 measured.
121
- expect(separator.className).not.toMatch(/_fg-1/);
155
+ // The raw primitive it replaced declares `flex: 1` — the defect DG-OVF-07
156
+ // measures — so both ends of the flex story are pinned here.
157
+ expect(declared(separator, "flex-grow")).toEqual(["0"]);
158
+ expect(declared(separator, "flex-shrink")).toEqual(["0"]);
122
159
  });
123
160
  });
124
161
 
@@ -126,7 +163,6 @@ describe("Separator — consumer eject stays the last word (STD-EJECT-LAST)", ()
126
163
  it("an explicit backgroundColor prop wins over the house paint", () => {
127
164
  const { container } = renderWithProviders(<Separator backgroundColor="$red9" />);
128
165
  const separator = getSeparator(container);
129
- expect(separator.className).toMatch(/_bg-red9/);
130
- expect(separator.className).not.toMatch(/_bg-borderColo/);
166
+ expect(declared(separator, "background-color")).toEqual(["var(--red9)"]);
131
167
  });
132
168
  });
@@ -1,15 +1,33 @@
1
1
  /**
2
- * House Separator — a rule that actually paints (MPO-47).
2
+ * House Separator — the catalog rule (MPO-47).
3
3
  *
4
4
  * The raw tamagui Separator rode the curated re-export as a "layout primitive
5
- * with no house replacement" and painted NOTHING on every measured board:
6
- * 96 of 96 separators across the action-and-feedback gallery measured
7
- * height 1px, background-color rgba(0,0,0,0), border-top-width 0px a 1px
8
- * gap where a line should be. LC-45 / LC-70 forbid a raw primitive behind a
9
- * bare catalog name; this is the house implementation. The paint recipe is
10
- * lifted from DropdownMenu's proven private SeparatorLine (a FILLED line, not
11
- * a borderBlink floors sub-1px borders and drops 0-height bordered boxes
12
- * out of paint entirely, which is how the raw one shipped invisible).
5
+ * with no house replacement". Two measured reasons it may not:
6
+ *
7
+ * - LC-45 / LC-70: a bare name a house barrel exports is a catalog part, and
8
+ * this one resolved to a raw primitive.
9
+ * - DG-OVF-07: the raw primitive declares `flex: 1`
10
+ * (@tamagui/separator@2.7.6 Separator.tsx), so the rule is CAPABLE of
11
+ * absorbing free spacethe exact defect DG-OVF-07 names.
12
+ *
13
+ * The paint recipe is lifted from DropdownMenu's proven private
14
+ * SeparatorLine: a FILLED line rather than a border, because Blink floors
15
+ * sub-1px border widths and a filled fractional height is crisp in every
16
+ * engine.
17
+ *
18
+ * MEASUREMENT CORRECTION (2026-09-01). MPO-47's headline — "96 of 96
19
+ * separators paint nothing: height 1px, background rgba(0,0,0,0),
20
+ * border-top-width 0px" — was a PROBE ARTEFACT, not a defect. The gallery
21
+ * probe computed `paints = backgroundColor.alpha > 0 || borderTopWidth > 0`,
22
+ * while tamagui's Separator paints with borderBOTTOMWidth (horizontal) and
23
+ * borderRIGHTWidth (vertical), so the expression was structurally blind to
24
+ * the one primitive it audited. The shots that reading produced carry the
25
+ * painted 1px $borderColor rules in their own pixels, in both schemes; a
26
+ * pixel audit of all 52 action-and-feedback shots finds at least as many
27
+ * painted 1px $borderColor rules as the sidecar called blank, on every
28
+ * board. The raw rule did paint. It was still the wrong thing behind a
29
+ * catalog name, and it could still flex-grow — which is why this component
30
+ * stands.
13
31
  *
14
32
  * Measured law (tamagui-rendered-reference.md §7.3):
15
33
  * - 1 CSS px, never 0.5px. Vertical separators are 1px wide.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * House Text knob wiring, NATIVE arm (MPO-20). Sibling of
3
+ * ./Heading.native.spec.tsx — see its header for WHY the arm is a separate
4
+ * file (the `vi.resetModules()` version returned a split module graph and
5
+ * proved nothing) and for what a `.native` spec can and cannot claim.
6
+ */
7
+ import { cleanup, screen } from "@testing-library/react";
8
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
9
+ import { Preset } from "@multiplatform.one/theme";
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import { Paragraph, SizableText, Text } from "./Text";
12
+
13
+ vi.mock("@multiplatform.one/platform", async (importOriginal) => ({
14
+ ...(await importOriginal<typeof import("@multiplatform.one/platform")>()),
15
+ isWeb: false,
16
+ isNative: true,
17
+ }));
18
+
19
+ afterEach(cleanup);
20
+
21
+ const LABEL = "Measure the text node";
22
+
23
+ function classesByPrefix(el: Element, prefixes: string[]): string[] {
24
+ return String((el as HTMLElement).className || "")
25
+ .split(" ")
26
+ .filter((c) => prefixes.some((p) => c.startsWith(p)))
27
+ .sort();
28
+ }
29
+
30
+ /** Tamagui font-scope class on the TEXT NODE (`font_body` / `font_mono` / …). */
31
+ function fontScopeClass(el: Element): string {
32
+ return (
33
+ String((el as HTMLElement).className || "")
34
+ .split(" ")
35
+ .find((c) => c.startsWith("font_")) ?? ""
36
+ );
37
+ }
38
+
39
+ /** Render, read the TEXT NODE's prefixed classes, clean up. */
40
+ function renderTextClasses(node: React.ReactElement, prefixes: string[]): string[] {
41
+ renderWithProviders(node);
42
+ const textNode = screen.getByText(LABEL);
43
+ const classes = classesByPrefix(textNode, prefixes);
44
+ cleanup();
45
+ return classes;
46
+ }
47
+
48
+ describe("house Text — NATIVE environment (isWeb false)", () => {
49
+ it("really is running with the native platform flags", async () => {
50
+ const platform = await import("@multiplatform.one/platform");
51
+ expect(platform.isWeb).toBe(false);
52
+ expect(platform.isNative).toBe(true);
53
+ });
54
+
55
+ it("changes colour on a textAccent flip", () => {
56
+ const high = renderTextClasses(
57
+ <Preset overrides={{ textAccent: "high" }}>
58
+ <Text>{LABEL}</Text>
59
+ </Preset>,
60
+ ["_col-"],
61
+ );
62
+ const low = renderTextClasses(
63
+ <Preset overrides={{ textAccent: "low" }}>
64
+ <Text>{LABEL}</Text>
65
+ </Preset>,
66
+ ["_col-"],
67
+ );
68
+ expect(high).not.toHaveLength(0);
69
+ expect(high).not.toEqual(low);
70
+ });
71
+
72
+ it("changes weight on a fontWeight flip, pinned to the 400/700 label law", () => {
73
+ const regular = renderTextClasses(
74
+ <Preset overrides={{ fontWeight: "regular" }}>
75
+ <Text>{LABEL}</Text>
76
+ </Preset>,
77
+ ["_fow-"],
78
+ );
79
+ const bold = renderTextClasses(
80
+ <Preset overrides={{ fontWeight: "bold" }}>
81
+ <Text>{LABEL}</Text>
82
+ </Preset>,
83
+ ["_fow-"],
84
+ );
85
+ const explicit400 = renderTextClasses(<Text fontWeight="400">{LABEL}</Text>, ["_fow-"]);
86
+ const explicit700 = renderTextClasses(<Text fontWeight="700">{LABEL}</Text>, ["_fow-"]);
87
+ expect(regular).not.toHaveLength(0);
88
+ expect(regular).not.toEqual(bold);
89
+ expect(regular).toEqual(explicit400);
90
+ expect(bold).toEqual(explicit700);
91
+ });
92
+
93
+ it("changes family on a bodyFont flip — the props channel, not the CSS rescue", () => {
94
+ const scope = (node: React.ReactElement) => {
95
+ renderWithProviders(node);
96
+ const cls = fontScopeClass(screen.getByText(LABEL));
97
+ cleanup();
98
+ return cls;
99
+ };
100
+ const sans = scope(
101
+ <Preset overrides={{ bodyFont: "sans-serif" }}>
102
+ <Text>{LABEL}</Text>
103
+ </Preset>,
104
+ );
105
+ const mono = scope(
106
+ <Preset overrides={{ bodyFont: "mono" }}>
107
+ <Text>{LABEL}</Text>
108
+ </Preset>,
109
+ );
110
+ expect(sans).not.toBe("");
111
+ expect(sans).not.toBe(mono);
112
+ expect(mono).toBe("font_mono");
113
+ });
114
+
115
+ it("carries the same knobs on the sibling body voices", () => {
116
+ for (const Voice of [SizableText, Paragraph]) {
117
+ const high = renderTextClasses(
118
+ <Preset overrides={{ textAccent: "high" }}>
119
+ <Voice>{LABEL}</Voice>
120
+ </Preset>,
121
+ ["_col-"],
122
+ );
123
+ const low = renderTextClasses(
124
+ <Preset overrides={{ textAccent: "low" }}>
125
+ <Voice>{LABEL}</Voice>
126
+ </Preset>,
127
+ ["_col-"],
128
+ );
129
+ expect(high).not.toHaveLength(0);
130
+ expect(high).not.toEqual(low);
131
+ }
132
+ });
133
+
134
+ it("explicit consumer props still eject (STD-EJECT-LAST)", () => {
135
+ const ejected = renderTextClasses(
136
+ <Preset overrides={{ fontWeight: "bold" }}>
137
+ <Text fontWeight="400">{LABEL}</Text>
138
+ </Preset>,
139
+ ["_fow-"],
140
+ );
141
+ const explicit400 = renderTextClasses(<Text fontWeight="400">{LABEL}</Text>, ["_fow-"]);
142
+ expect(ejected).toEqual(explicit400);
143
+ });
144
+ });