@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
package/src/Text.spec.tsx CHANGED
@@ -12,19 +12,18 @@
12
12
  * family. Between knob flips and against explicit-prop renders of the
13
13
  * same value. Hashed class names are compared by identity only.
14
14
  *
15
- * WEB arm: the default module graph (isWeb true).
16
- * NATIVE arm: `@multiplatform.one/platform` mocked to isWeb:false on a fresh
17
- * module graph (the resolveKnobs.spec.ts elevation-native pattern), with
18
- * Preset/render/Text all imported from that same graph. FontKnobStyles'
19
- * CSS-variable rescue is absent in BOTH arms (renderWithProviders mounts no
20
- * theme provider), so a flip that reaches the node proves the PROPS channel
21
- * — the only channel native has.
15
+ * This file is the WEB arm (the default module graph, isWeb true). The NATIVE
16
+ * arm is ./Text.native.spec.tsx, a separate FILE so that a file-scoped
17
+ * `vi.mock` gives it one coherent module graph. FontKnobStyles' CSS-variable
18
+ * rescue is absent in BOTH arms (renderWithProviders mounts no theme
19
+ * provider), so a flip that reaches the node proves the PROPS channel — the
20
+ * only channel native has.
22
21
  */
23
22
  import { cleanup, screen } from "@testing-library/react";
24
23
  import { renderWithProviders } from "@multiplatform.one/test-utils";
25
24
  import { Preset } from "@multiplatform.one/theme";
26
25
  import { XStack } from "tamagui";
27
- import { afterEach, describe, expect, it, vi } from "vitest";
26
+ import { afterEach, describe, expect, it } from "vitest";
28
27
  import { Text } from "./Text";
29
28
 
30
29
  afterEach(cleanup);
@@ -182,65 +181,8 @@ describe("house Text — fontWeight and bodyFont reach the text node", () => {
182
181
  });
183
182
  });
184
183
 
185
- describe("house Text NATIVE environment (isWeb false, fresh module graph)", () => {
186
- it("textAccent and fontWeight flips still reach the text node", async () => {
187
- vi.resetModules();
188
- vi.doMock("@multiplatform.one/platform", async (importOriginal) => ({
189
- ...(await importOriginal<typeof import("@multiplatform.one/platform")>()),
190
- isWeb: false,
191
- isNative: true,
192
- }));
193
- try {
194
- // Everything must come from the SAME fresh graph: a Preset from the
195
- // old graph would provide a context the fresh Text never reads.
196
- const [{ Text: NativeText }, { Preset: NativePreset }, testUtils, testingLibrary] =
197
- await Promise.all([
198
- import("./Text"),
199
- import("@multiplatform.one/theme"),
200
- import("@multiplatform.one/test-utils"),
201
- import("@testing-library/react"),
202
- ]);
203
-
204
- const renderNative = (node: React.ReactElement, prefixes: string[]): string[] => {
205
- testUtils.renderWithProviders(node);
206
- const el = testingLibrary.screen.getByText(LABEL);
207
- const classes = classesByPrefix(el, prefixes);
208
- testingLibrary.cleanup();
209
- return classes;
210
- };
211
-
212
- const high = renderNative(
213
- <NativePreset overrides={{ textAccent: "high" }}>
214
- <NativeText>{LABEL}</NativeText>
215
- </NativePreset>,
216
- ["_col-"],
217
- );
218
- const low = renderNative(
219
- <NativePreset overrides={{ textAccent: "low" }}>
220
- <NativeText>{LABEL}</NativeText>
221
- </NativePreset>,
222
- ["_col-"],
223
- );
224
- expect(high).not.toHaveLength(0);
225
- expect(high).not.toEqual(low);
226
-
227
- const regular = renderNative(
228
- <NativePreset overrides={{ fontWeight: "regular" }}>
229
- <NativeText>{LABEL}</NativeText>
230
- </NativePreset>,
231
- ["_fow-"],
232
- );
233
- const bold = renderNative(
234
- <NativePreset overrides={{ fontWeight: "bold" }}>
235
- <NativeText>{LABEL}</NativeText>
236
- </NativePreset>,
237
- ["_fow-"],
238
- );
239
- expect(regular).not.toHaveLength(0);
240
- expect(regular).not.toEqual(bold);
241
- } finally {
242
- vi.doUnmock("@multiplatform.one/platform");
243
- vi.resetModules();
244
- }
245
- });
246
- });
184
+ // The NATIVE arm lives in ./Text.native.spec.tsx. It used to sit here as a
185
+ // `vi.resetModules()` + dynamic-import block, but that returned a SPLIT module
186
+ // graph — a fresh `renderWithProviders` over a stale `useResolvedKnobs` — and
187
+ // threw "Missing theme." instead of measuring anything. A file-scoped
188
+ // `vi.mock` in its own spec file is one coherent graph.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * MPO-38 AC-7, the source half: Tooltip owns no gap constant of its own.
3
+ *
4
+ * The runtime value is asserted where it lives — `layoutTokens.spec.ts` pins
5
+ * `OVERLAY_ANCHOR_GAP` at 4. Asserting that number again from a Tooltip spec
6
+ * proves nothing about Tooltip: it passes just as happily if this component
7
+ * goes back to a hardcoded `offset={4}`, which is exactly the drift the AC
8
+ * was written to close.
9
+ *
10
+ * So this is the source half, in the shape of `panelWidthContract.spec.ts`.
11
+ * A Tooltip is a FREE overlay — no trigger to attach to — so it is one of the
12
+ * few overlays `OVERLAY_ANCHOR_GAP` still governs after the cover ruling
13
+ * (`OVERLAY_ATTACH_GAP = 0`). It must READ that token rather than restate its
14
+ * value under a second name (LC-16 ONE-IMPLEMENTATION).
15
+ *
16
+ * Both twins are checked. The web/native split is where a duplicate constant
17
+ * survives longest: fixing one file and leaving the other still renders the
18
+ * right gap on the platform anyone happened to look at.
19
+ */
20
+ import { readFileSync } from "node:fs";
21
+ import { dirname, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { describe, expect, it } from "vitest";
24
+
25
+ const tooltipDir = dirname(fileURLToPath(import.meta.url));
26
+
27
+ /** The web twin, the native twin, and the module they share. */
28
+ const tooltipSources = ["index.tsx", "index.native.tsx", "tooltipShared.tsx"];
29
+
30
+ function read(relative: string): string {
31
+ return readFileSync(resolve(tooltipDir, relative), "utf8");
32
+ }
33
+
34
+ describe("Tooltip gap contract (MPO-38 AC-7)", () => {
35
+ it.each(tooltipSources)("%s declares no gap constant of its own", (relative) => {
36
+ // Named for the constant this AC deleted, but the assertion is the rule,
37
+ // not the name: any local OFFSET/GAP constant is the same drift again.
38
+ expect(read(relative)).not.toMatch(/\b(?:const|let|var)\s+\w*(?:OFFSET|GAP)\w*\s*=/);
39
+ });
40
+
41
+ it.each(["index.tsx", "index.native.tsx"])(
42
+ "%s positions the bubble with the shared FREE-overlay token",
43
+ (relative) => {
44
+ const source = read(relative);
45
+ expect(source).toMatch(
46
+ /import\s*\{[^}]*\bOVERLAY_ANCHOR_GAP\b[^}]*\}\s*from\s*"@multiplatform\.one\/theme"/s,
47
+ );
48
+ expect(source).toContain("offset={OVERLAY_ANCHOR_GAP}");
49
+ // A literal offset is the regression: it renders the same 4px today and
50
+ // silently stops tracking the token the moment the house rule moves.
51
+ expect(source).not.toMatch(/offset=\{\s*\d/);
52
+ },
53
+ );
54
+ });
@@ -1,8 +1,9 @@
1
1
  import { Form, Input, Switch, useFormContext } from "@multiplatform.one/forms";
2
2
  import { renderWithProviders } from "@multiplatform.one/test-utils";
3
+ import { __resetDevWarnSeen } from "@multiplatform.one/theme";
3
4
  import { act, fireEvent } from "@testing-library/react";
4
5
  import { useEffect } from "react";
5
- import { describe, expect, it } from "vitest";
6
+ import { describe, expect, it, vi } from "vitest";
6
7
  import { Wizard, useWizard, type WizardFormLike, type WizardStep } from "./index";
7
8
 
8
9
  const STEPS: WizardStep[] = [
@@ -214,6 +215,27 @@ describe("Wizard LC-63 / DG-VAL", () => {
214
215
  });
215
216
  });
216
217
 
218
+ describe("Wizard LC-57 guardrail-clean at rest", () => {
219
+ it("a ready wizard at rest emits zero [theme] guardrail warnings", async () => {
220
+ __resetDevWarnSeen();
221
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
222
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
223
+ try {
224
+ renderWizard();
225
+ await act(async () => {
226
+ await new Promise((resolve) => setTimeout(resolve, 0));
227
+ });
228
+ const themeWarns = [...warn.mock.calls, ...error.mock.calls]
229
+ .map((args) => String(args[0]))
230
+ .filter((msg) => msg.startsWith("[theme]"));
231
+ expect(themeWarns).toEqual([]);
232
+ } finally {
233
+ warn.mockRestore();
234
+ error.mockRestore();
235
+ }
236
+ });
237
+ });
238
+
217
239
  describe("Wizard states", () => {
218
240
  it("draws loading, empty, and error shells", () => {
219
241
  const loading = renderWithProviders(
@@ -521,17 +521,10 @@ function WizardRoot({
521
521
  {...props}
522
522
  >
523
523
  <YStack data-wizard-rail="" width="100%">
524
- {/*
525
- * MPO-81: no `labels` prop here. ProgressStepsProps does not declare
526
- * one and ProgressSteps reads no such prop, so passing it was inert
527
- * at runtime and failed the build with TS2322. `hideLabels` still
528
- * drives the per-step caption below, which is where it has always
529
- * had a real effect. Re-add the pass in the same change that gives
530
- * ProgressSteps a labels prop it actually honours.
531
- */}
532
524
  <ProgressSteps
533
525
  steps={railSteps}
534
526
  current={current}
527
+ labels={hideLabels ? "hidden" : "visible"}
535
528
  compact={compact}
536
529
  linear
537
530
  onStepChange={(index) => {
@@ -85,19 +85,19 @@ function greet() {
85
85
  describe("CodeBlock", () => {
86
86
  it("keeps the copy action reachable at the 44px press floor", () => {
87
87
  renderWithProviders(<CodeBlock>const x = 1;</CodeBlock>);
88
- const button = screen.getByLabelText("Copy code to clipboard");
88
+ const button = screen.getByLabelText("Copy to clipboard");
89
89
  expect(button).toBeTruthy();
90
90
  expect(button.getAttribute("data-press-min")).toBe(String(MIN_PRESS_TARGET));
91
+ // Default knob: the glyph-ring end-cap square itself meets the floor
92
+ // (44). Smaller size steps keep the floor by spilling hitSlop instead
93
+ // (CodeBlock.copy.spec covers the 36/52 cap geometry).
94
+ expect(Number(button.getAttribute("data-end-cap"))).toBeGreaterThanOrEqual(MIN_PRESS_TARGET);
91
95
  const style = getComputedStyle(button);
92
96
  expect(style.display).not.toBe("none");
93
- const minWidth = Number.parseFloat(style.minWidth);
94
- const minHeight = Number.parseFloat(style.minHeight);
95
- if (!Number.isNaN(minWidth)) expect(minWidth).toBeGreaterThanOrEqual(MIN_PRESS_TARGET);
96
- if (!Number.isNaN(minHeight)) expect(minHeight).toBeGreaterThanOrEqual(MIN_PRESS_TARGET);
97
97
  });
98
98
 
99
99
  it("hides the copy action only when disableCopy is set", () => {
100
100
  renderWithProviders(<CodeBlock disableCopy>const x = 1;</CodeBlock>);
101
- expect(screen.queryByLabelText("Copy code to clipboard")).toBeNull();
101
+ expect(screen.queryByLabelText("Copy to clipboard")).toBeNull();
102
102
  });
103
103
  });
@@ -7,21 +7,28 @@
7
7
  */
8
8
 
9
9
  import { LinearGradient } from "@tamagui/linear-gradient";
10
- import { CheckCircleIcon, ClipboardIcon, WarningCircleIcon } from "@phosphor-icons/react";
11
- import { MIN_PRESS_TARGET, useResolvedKnobs } from "@multiplatform.one/theme";
10
+ import { ClipboardIcon } from "@phosphor-icons/react";
11
+ import {
12
+ MIN_PRESS_TARGET,
13
+ isTouchSurface,
14
+ pressTargetHitSlop,
15
+ sizeRecipeForToken,
16
+ useResolvedKnobs,
17
+ } from "@multiplatform.one/theme";
12
18
  import { useEffect, useRef, useState } from "react";
13
19
  import { useTranslation } from "../../shared/i18n";
14
20
  import type { TamaguiElement } from "tamagui";
15
21
  import { ScrollView } from "tamagui";
16
- import { Button, zIndex } from "@multiplatform.one/forms";
17
- import { Spacer, TooltipSimple, XStack, YStack } from "tamagui";
22
+ import { Button, Input, zIndex } from "@multiplatform.one/forms";
23
+ import { Spacer, XStack, YStack } from "tamagui";
18
24
  import { settleMs } from "../../AnimateHeight";
19
25
  import { ErrorBoundary } from "../../ErrorBoundary";
26
+ import { NotifyRegion } from "../../feedback/NotifyHost";
27
+ import { copyAndNotify } from "../../feedback/copy";
20
28
  import { Code } from "../Code";
21
29
  import { HighlightedCode } from "../HighlightedCode";
22
30
  import { useShikiTokens } from "../Highlighter";
23
31
  import { Pre } from "../Pre";
24
- import { useClipboard } from "../../hooks/useClipboard";
25
32
 
26
33
  /** Visible lines before the "show more" clamp (content constraint, LC-18). */
27
34
  const VISIBLE_LINES = 22;
@@ -111,7 +118,14 @@ export function CodeBlock({
111
118
  }, settleMs(transition));
112
119
  };
113
120
  const clamped = isCutoff || expandTarget != null;
114
- const { hasCopied, hasCopyFailed, onCopy } = useClipboard(codeText);
121
+ // Copy is the same Input.Button glyph-ring cap the CopyField/Password
122
+ // slot uses (LC-16); confirm is notify() and the glyph does not flip
123
+ // (DG-FB-01 — the D-copyfield verdict replaces TooltipSimple + icon-swap).
124
+ const showCopy = Boolean(codeText) && !disableCopy;
125
+ const activateCopy = () => {
126
+ void copyAndNotify(codeText);
127
+ };
128
+ const capSquare = sizeRecipeForToken(String(codeSize), { touch: isTouchSurface() }).height;
115
129
 
116
130
  return (
117
131
  <YStack position="relative" marginBottom="$4">
@@ -156,6 +170,7 @@ export function CodeBlock({
156
170
  borderWidth={0}
157
171
  {...knobProps.panelPadding}
158
172
  size={codeSize}
173
+ data-register="code"
159
174
  >
160
175
  <HighlightedCode
161
176
  tokens={tokens}
@@ -168,33 +183,35 @@ export function CodeBlock({
168
183
  </Code>
169
184
  </ScrollView>
170
185
  </Pre>
171
- {!disableCopy && (
172
- <XStack position="absolute" top="$3" insetInlineEnd="$3">
173
- <TooltipSimple
174
- label={
175
- hasCopied
176
- ? t("Copied")
177
- : hasCopyFailed
178
- ? t("Copy failed")
179
- : t("Copy to clipboard")
186
+ {showCopy && (
187
+ <XStack position="absolute" top={0} insetInlineEnd={0} zIndex={zIndex.localTop}>
188
+ <Input.Button
189
+ glyphRing
190
+ size={codeSize}
191
+ type="button"
192
+ aria-label="Copy to clipboard"
193
+ data-press-min={MIN_PRESS_TARGET}
194
+ hitSlop={capSquare < MIN_PRESS_TARGET ? pressTargetHitSlop(capSquare) : undefined}
195
+ onPress={activateCopy}
196
+ onKeyDown={
197
+ ((e: KeyboardEvent) => {
198
+ if (e.key === "Enter" || e.key === " ") {
199
+ e.preventDefault();
200
+ e.stopPropagation();
201
+ activateCopy();
202
+ }
203
+ }) as unknown as () => void
180
204
  }
181
205
  >
182
- <Button
183
- aria-label={t("Copy code to clipboard")}
184
- size={codeSize}
185
- minWidth={MIN_PRESS_TARGET}
186
- minHeight={MIN_PRESS_TARGET}
187
- data-press-min={MIN_PRESS_TARGET}
188
- icon={
189
- hasCopied ? CheckCircleIcon : hasCopyFailed ? WarningCircleIcon : ClipboardIcon
190
- }
191
- onPress={onCopy}
192
- />
193
- </TooltipSimple>
206
+ <Input.Icon aria-hidden>
207
+ <ClipboardIcon />
208
+ </Input.Icon>
209
+ </Input.Button>
194
210
  </XStack>
195
211
  )}
196
212
  </YStack>
197
213
  </ErrorBoundary>
214
+ <NotifyRegion scope="field" compact />
198
215
  </YStack>
199
216
  );
200
217
  }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  export * from "./Badge";
16
16
  export * from "./Carousel";
17
17
  export * from "./Chip";
18
+ export * from "./CopyField";
18
19
  export * from "./DotIndicator";
19
20
  export * from "./DropdownMenu";
20
21
  export * from "./ContextMenu";
@@ -154,12 +155,17 @@ export type { AnchorProps, ParagraphProps, SizableTextProps, TextProps } from ".
154
155
  export { H1, H2, H3, H4, H5, H6, Heading } from "./Heading";
155
156
  export type { HeadingProps } from "./Heading";
156
157
 
157
- // Shadow tamagui Separator with the house Separator (MPO-47): the raw
158
- // primitive painted NOTHING96 of 96 measured separators were transparent
159
- // 1px boxes. The house rule is a painted 1px line in $borderColor (measured
160
- // law §7.3: 1px never 0.5px; quiet = opacity 0.5, never a lighter colour).
161
- // The curated ./tamagui surface no longer exports the bare name; raw stays
162
- // reachable as TamaguiSeparator.
158
+ // Shadow tamagui Separator with the house Separator (MPO-47). Two reasons,
159
+ // both measured: LC-45 / LC-70 a bare name a house barrel exports is a
160
+ // catalog part, and this one resolved to a raw primitive; and DG-OVF-07
161
+ // the raw primitive is a `flex: 1` item, so the rule can absorb free space.
162
+ // The house rule is a painted 1px line in $borderColor (measured law §7.3:
163
+ // 1px never 0.5px; quiet = opacity 0.5, never a lighter colour) that cannot
164
+ // flex-grow. The curated ./tamagui surface no longer exports the bare name;
165
+ // raw stays reachable as TamaguiSeparator.
166
+ //
167
+ // Correction 2026-09-01: the ticket's "96 of 96 paint nothing" was a probe
168
+ // artefact, not a defect — see ./Separator/index.tsx for the measurement.
163
169
  export { Separator } from "./Separator";
164
170
  export type { SeparatorProps } from "./Separator";
165
171
 
@@ -0,0 +1,154 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { Preset } from "@multiplatform.one/theme";
3
+ import { cleanup } from "@testing-library/react";
4
+ import { AlertDialog, Dialog, Theme } from "tamagui";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { Button } from "./Button";
7
+ import { AlertDialogContent, DialogContent } from "./surfaces";
8
+
9
+ afterEach(cleanup);
10
+
11
+ // ── What this file measures, and why not the class list ──────────────────
12
+ //
13
+ // MPO-49 AC: "DialogContent paints the reference §8 overlay shadow" and "a
14
+ // spec asserts the dialog shadow differs from the Button shadow".
15
+ //
16
+ // The obvious probe — read the frame's `_bxsh-*` atom — passes on a frame
17
+ // that paints something else entirely. `resolveKnobs.elevationPaint` emits
18
+ // its web paint as `style: { boxShadow }`, an INLINE style, and Tamagui only
19
+ // stamps `!important` on PSEUDO atoms (`getCSSStylesAtomic`: `const
20
+ // important = !!pseudo`). A base atom therefore loses the cascade to any
21
+ // inline style on the same node. Measured on `6eb0995`, before this lane:
22
+ //
23
+ // class _bxsh-0px12px24px… (the §8 value — dead)
24
+ // style 0 3px 6px rgba(0,0,0,0.15), 0 2px 4px … (the `$2` value — painted)
25
+ //
26
+ // So these assert the INLINE style, which is what a browser paints, and read
27
+ // the class only to prove the two now agree. Reading the class alone is the
28
+ // same hole MPO-40 / MPO-44 / MPO-48 all fell through, one layer further in:
29
+ // the fragment reaches the frame and still never reaches the pixel.
30
+
31
+ /** Reference §8, tamagui-rendered-reference.md — the one dialog shadow. */
32
+ const referenceOverlayShadow = "0px 12px 24px rgba(0,0,0,0.12)";
33
+ /** Same §8 geometry and alpha, hue inverted — LC-85 forbids a dark umbra. */
34
+ const referenceOverlayShadowDark = "0px 12px 24px rgba(255,255,255,0.12)";
35
+ /** `webShadowLight.$1` — the in-page control stop every Button carries. */
36
+ const controlStopPaint = "0 1px 2px rgba(0,0,0,0.12), 0 0 1px rgba(0,0,0,0.08)";
37
+ /** `webShadowLight.$2` — `elevatedSurface`'s own overlay paint. */
38
+ const elevatedSurfacePaint = "0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.10)";
39
+
40
+ function frame(id: string): HTMLElement {
41
+ const el = document.querySelector(`[data-testid="${id}"]`);
42
+ if (!el) throw new Error(`no frame rendered for ${id}`);
43
+ return el as HTMLElement;
44
+ }
45
+
46
+ /** The value a browser paints: the inline style wins over every base atom. */
47
+ function paintedShadow(id: string): string {
48
+ return frame(id).style.boxShadow;
49
+ }
50
+
51
+ function shadowAtoms(id: string): string[] {
52
+ return String(frame(id).className || "")
53
+ .split(/\s+/)
54
+ .filter((c) => c.startsWith("_bxsh-"))
55
+ .sort();
56
+ }
57
+
58
+ function bgAtoms(id: string): string[] {
59
+ return String(frame(id).className || "")
60
+ .split(/\s+/)
61
+ .filter((c) => c.startsWith("_bg-"));
62
+ }
63
+
64
+ function renderDialogsBesideAButton() {
65
+ renderWithProviders(
66
+ <Preset>
67
+ <Button testID="control">Save</Button>
68
+ <Dialog modal open>
69
+ <Dialog.Portal>
70
+ <DialogContent testID="dialog">dialog</DialogContent>
71
+ </Dialog.Portal>
72
+ </Dialog>
73
+ <AlertDialog open>
74
+ <AlertDialog.Portal>
75
+ <AlertDialogContent testID="alertdialog">confirm</AlertDialogContent>
76
+ </AlertDialog.Portal>
77
+ </AlertDialog>
78
+ </Preset>,
79
+ );
80
+ }
81
+
82
+ describe("overlay elevation is a different distance off the surface (MPO-49)", () => {
83
+ it("DialogContent PAINTS the reference §8 shadow, not the $2 fragment underneath it", () => {
84
+ renderDialogsBesideAButton();
85
+ expect(paintedShadow("dialog")).toBe(referenceOverlayShadow);
86
+ expect(paintedShadow("dialog")).not.toBe(elevatedSurfacePaint);
87
+ expect(paintedShadow("dialog")).not.toBe(controlStopPaint);
88
+ });
89
+
90
+ it("the atom and the inline style agree — no dead §8 class over a live $2 paint", () => {
91
+ renderDialogsBesideAButton();
92
+ // The atom half alone does NOT discriminate: it reads `_bxsh-0px12px24px…`
93
+ // with the defect present too, which is why a class-only probe passes
94
+ // against a frame painting `$2`. Pair it with the painted value.
95
+ expect(shadowAtoms("dialog")).toHaveLength(1);
96
+ expect(shadowAtoms("dialog")[0]).toMatch(/^_bxsh-0px12px24px/);
97
+ expect(paintedShadow("dialog")).toContain("12px 24px");
98
+ });
99
+
100
+ it("the dialog shadow differs from the Button shadow, and the Button keeps the $1 stop", () => {
101
+ renderDialogsBesideAButton();
102
+ // "differs" alone does not discriminate: with the defect present the
103
+ // dialog paints `$2` and the Button `$1`, which also differ. The §8 value
104
+ // itself is pinned by the first case in this file; here the control side
105
+ // is pinned to `$1` so a Button regression cannot fake this one either.
106
+ expect(paintedShadow("control")).toBe(controlStopPaint);
107
+ expect(paintedShadow("dialog")).not.toBe(paintedShadow("control"));
108
+ expect(shadowAtoms("dialog")).not.toEqual(shadowAtoms("control"));
109
+ });
110
+
111
+ it("AlertDialogContent wears the same §8 shadow — one dialog row, one distance", () => {
112
+ // Tamagui's AlertDialog.Content IS Dialog.Content, and reference §8
113
+ // carries a single dialog row. confirm-dialog-{destructive,gated,warning}
114
+ // render through this frame.
115
+ renderDialogsBesideAButton();
116
+ expect(paintedShadow("alertdialog")).toBe(referenceOverlayShadow);
117
+ expect(paintedShadow("alertdialog")).toBe(paintedShadow("dialog"));
118
+ });
119
+
120
+ it("dark inverts the hue and keeps the §8 geometry; the ground steps up the ramp (LC-85)", () => {
121
+ renderWithProviders(
122
+ <Preset>
123
+ <Dialog modal open>
124
+ <Dialog.Portal>
125
+ <DialogContent testID="dialog">dialog</DialogContent>
126
+ </Dialog.Portal>
127
+ </Dialog>
128
+ </Preset>,
129
+ );
130
+ expect(paintedShadow("dialog")).toBe(referenceOverlayShadow);
131
+ expect(bgAtoms("dialog")).toEqual(["_bg-background"]);
132
+ cleanup();
133
+
134
+ renderWithProviders(
135
+ <Theme name="dark">
136
+ <Preset>
137
+ <Dialog modal open>
138
+ <Dialog.Portal>
139
+ <DialogContent testID="dialog">dialog</DialogContent>
140
+ </Dialog.Portal>
141
+ </Dialog>
142
+ </Preset>
143
+ </Theme>,
144
+ );
145
+ // LC-85 DARK-ELEVATION-TINT: light-coloured shadow, never a black umbra.
146
+ expect(paintedShadow("dialog")).toBe(referenceOverlayShadowDark);
147
+ expect(paintedShadow("dialog")).not.toBe(referenceOverlayShadow);
148
+ expect(paintedShadow("dialog")).not.toMatch(/rgba\(0,\s*0,\s*0/);
149
+ // resolveKnobs owns the dark ground: `overlayFill` steps the filled rest
150
+ // fill up the ramp under dark + elevation (LC-85). Asserted so a later
151
+ // change to that rule shows up here rather than silently on a board.
152
+ expect(bgAtoms("dialog")).toEqual(["_bg-color2"]);
153
+ });
154
+ });
@@ -1,8 +1,12 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { Preset } from "@multiplatform.one/theme";
3
+ import { cleanup } from "@testing-library/react";
1
4
  import { readFileSync } from "node:fs";
2
5
  import { dirname, join } from "node:path";
3
6
  import { fileURLToPath } from "node:url";
4
7
  import { Card as TamaguiCard, Dialog, Popover, Sheet } from "tamagui";
5
- import { describe, expect, it } from "vitest";
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+ import { Button } from "./Button";
6
10
  import * as pkg from "./index";
7
11
  import {
8
12
  Card,
@@ -21,6 +25,8 @@ import {
21
25
  SheetFrame,
22
26
  } from "./surfaces";
23
27
 
28
+ afterEach(cleanup);
29
+
24
30
  describe("surfaces package exports", () => {
25
31
  it("package Card is the themed surface, not the tamagui primitive", () => {
26
32
  expect(pkg.Card).toBe(Card);
@@ -86,3 +92,94 @@ describe("DialogContent overlay elevation (MPO-49)", () => {
86
92
  expect(dialog).toContain("panelPadding");
87
93
  });
88
94
  });
95
+
96
+ // ── Render proofs ────────────────────────────────────────────────────────
97
+ //
98
+ // The source-text assertions above prove the CALL SITE. These prove the
99
+ // FRAME, which is the distinction MPO-40 / MPO-44 / MPO-48 all fell through:
100
+ // a fragment that exists in source and dies on the way to the frame reads
101
+ // identical to one that never existed.
102
+ //
103
+ // happy-dom cannot cascade Tamagui's class CSS, so — as in Button.spec.tsx —
104
+ // these read the atomic classes the knob emits. The atom name is hashed from
105
+ // the VALUE, so the class IS the value, and no theme config is needed: what
106
+ // the fragment addresses is visible in the class name itself.
107
+ //
108
+ // Measured both ways on 2026-09-01 by reverting each defect in the working
109
+ // tree and re-running (see the MR description):
110
+ //
111
+ // surface addressing defect `$color1` -> `_bg-color1`
112
+ // fixed `$background` -> `_bg-background`
113
+ // dialog elevation defect Button/control `$1` -> `_bxsh-0px1px2px…`
114
+ // fixed reference §8 -> `_bxsh-0px12px24px…`
115
+ //
116
+ // A "differs from the Button" assertion does NOT discriminate: the Button
117
+ // paints `0 6px 12px` from its own source in both states, so that comparison
118
+ // passes with the defect present. Assert against the $1 CONTROL stop.
119
+
120
+ function atomsWithPrefix(el: Element | null, prefix: string): string[] {
121
+ if (!el) return [];
122
+ return String((el as HTMLElement).className || "")
123
+ .split(/\s+/)
124
+ .filter((c) => c.startsWith(prefix))
125
+ .sort();
126
+ }
127
+
128
+ /** The `$1` control paint — `webShadowLight.$1`, "0 1px 2px …" (resolveKnobs.ts). */
129
+ const controlStopShadowAtom = /^_bxsh-0px1px2px/;
130
+ /** Rendered reference §8 DialogContent — `rgba(0,0,0,0.12) 0 12px 24px`. */
131
+ const overlayStopShadowAtom = /^_bxsh-0px12px24px/;
132
+
133
+ describe("surfaces address $background at the FRAME (MPO-19 X5/X6)", () => {
134
+ it("Card, Panel and DialogContent all paint _bg-background, never a raw ramp step", () => {
135
+ renderWithProviders(
136
+ <Preset>
137
+ <Card testID="mpo-card">card</Card>
138
+ <Panel testID="mpo-panel">panel</Panel>
139
+ <Dialog modal open>
140
+ <Dialog.Portal>
141
+ <DialogContent testID="mpo-dialog">dialog</DialogContent>
142
+ </Dialog.Portal>
143
+ </Dialog>
144
+ </Preset>,
145
+ );
146
+
147
+ for (const id of ["mpo-card", "mpo-panel", "mpo-dialog"]) {
148
+ const frame = document.querySelector(`[data-testid="${id}"]`);
149
+ expect(frame, `${id} frame`).toBeTruthy();
150
+ const bg = atomsWithPrefix(frame, "_bg-");
151
+ expect(bg, `${id} background atom`).toEqual(["_bg-background"]);
152
+ expect(bg.join(" "), `${id} must not address a raw ramp step`).not.toMatch(/_bg-color[12]\b/);
153
+ }
154
+ });
155
+ });
156
+
157
+ describe("DialogContent paints the OVERLAY elevation stop, not the control one (MPO-49)", () => {
158
+ it("renders the reference §8 shadow and not the Button $1 stop", () => {
159
+ renderWithProviders(
160
+ <Preset>
161
+ <Button testID="mpo-button-control">Save</Button>
162
+ <Dialog modal open>
163
+ <Dialog.Portal>
164
+ <DialogContent testID="mpo-dialog-overlay">body</DialogContent>
165
+ </Dialog.Portal>
166
+ </Dialog>
167
+ </Preset>,
168
+ );
169
+
170
+ const dialog = document.querySelector('[data-testid="mpo-dialog-overlay"]');
171
+ const button = document.querySelector('[data-testid="mpo-button-control"]');
172
+ expect(dialog, "DialogContent frame").toBeTruthy();
173
+ expect(button, "Button frame").toBeTruthy();
174
+
175
+ const dialogShadow = atomsWithPrefix(dialog, "_bxsh-");
176
+ expect(dialogShadow, "DialogContent paints exactly one shadow atom").toHaveLength(1);
177
+ expect(dialogShadow[0], "dialog wears the §8 overlay shadow").toMatch(overlayStopShadowAtom);
178
+ expect(dialogShadow[0], "dialog must not wear the $1 control stop").not.toMatch(
179
+ controlStopShadowAtom,
180
+ );
181
+ expect(dialogShadow, "dialog and control are different distances off the surface").not.toEqual(
182
+ atomsWithPrefix(button, "_bxsh-"),
183
+ );
184
+ });
185
+ });