@multiplatform.one/forms 7.73.0 → 7.75.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 (248) hide show
  1. package/dist/cjs/Button/IconButtonGroup.cjs +32 -0
  2. package/dist/cjs/Button/IconButtonGroup.native.js +32 -0
  3. package/dist/cjs/Button/IconButtonGroup.native.js.map +1 -1
  4. package/dist/cjs/Button/glass.cjs +4 -1
  5. package/dist/cjs/Button/index.cjs +71 -18
  6. package/dist/cjs/Button/index.native.js +81 -20
  7. package/dist/cjs/Button/index.native.js.map +1 -1
  8. package/dist/cjs/Button/webGlass.cjs +62 -0
  9. package/dist/cjs/Button/webGlass.native.js +68 -0
  10. package/dist/cjs/Button/webGlass.native.js.map +1 -0
  11. package/dist/cjs/fields/FileUpload/index.cjs +8 -3
  12. package/dist/cjs/fields/FileUpload/index.native.js +8 -3
  13. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  14. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  15. package/dist/esm/Button/IconButtonGroup.mjs +32 -0
  16. package/dist/esm/Button/IconButtonGroup.mjs.map +1 -1
  17. package/dist/esm/Button/IconButtonGroup.native.js +32 -0
  18. package/dist/esm/Button/IconButtonGroup.native.js.map +1 -1
  19. package/dist/esm/Button/circleGlyph.native.js.map +1 -1
  20. package/dist/esm/Button/glass.mjs +4 -2
  21. package/dist/esm/Button/glass.mjs.map +1 -1
  22. package/dist/esm/Button/iconButtonGroupContext.mjs.map +1 -1
  23. package/dist/esm/Button/iconButtonGroupContext.native.js.map +1 -1
  24. package/dist/esm/Button/index.mjs +72 -20
  25. package/dist/esm/Button/index.mjs.map +1 -1
  26. package/dist/esm/Button/index.native.js +82 -22
  27. package/dist/esm/Button/index.native.js.map +1 -1
  28. package/dist/esm/Button/mediaPlate.native.js.map +1 -1
  29. package/dist/esm/Button/webGlass.mjs +36 -0
  30. package/dist/esm/Button/webGlass.mjs.map +1 -0
  31. package/dist/esm/Button/webGlass.native.js +39 -0
  32. package/dist/esm/Button/webGlass.native.js.map +1 -0
  33. package/dist/esm/Chip/index.native.js.map +1 -1
  34. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  35. package/dist/esm/ContextualSaveBar/clientKnownIdentity.native.js.map +1 -1
  36. package/dist/esm/ContextualSaveBar/commitImmediateField.native.js.map +1 -1
  37. package/dist/esm/ContextualSaveBar/index.native.js.map +1 -1
  38. package/dist/esm/ContextualSaveBar/useUnsavedChangesGuard.native.js.map +1 -1
  39. package/dist/esm/ControlGroup/index.native.js.map +1 -1
  40. package/dist/esm/DescriptionList/index.native.js.map +1 -1
  41. package/dist/esm/ErrorSummary.native.js.map +1 -1
  42. package/dist/esm/FieldDisplay/editMode.native.js.map +1 -1
  43. package/dist/esm/FieldDisplay/formatters.native.js.map +1 -1
  44. package/dist/esm/FieldDisplay/index.native.js.map +1 -1
  45. package/dist/esm/FieldGroup.native.js.map +1 -1
  46. package/dist/esm/FloatingPanel/PanelHeader.native.js.map +1 -1
  47. package/dist/esm/FloatingPanel/RegularPanel.native.js.map +1 -1
  48. package/dist/esm/FloatingPanel/compactFace.native.js.map +1 -1
  49. package/dist/esm/FloatingPanel/containingBlock.native.js.map +1 -1
  50. package/dist/esm/FloatingPanel/echoContext.native.js.map +1 -1
  51. package/dist/esm/FloatingPanel/iconEcho.native.js.map +1 -1
  52. package/dist/esm/FloatingPanel/nestedAction.native.js.map +1 -1
  53. package/dist/esm/FloatingPanel/paintedBox.native.js.map +1 -1
  54. package/dist/esm/FloatingPanel/panelWidth.native.js.map +1 -1
  55. package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -1
  56. package/dist/esm/FloatingPanel/regularPlacement.native.js.map +1 -1
  57. package/dist/esm/FloatingPanel/trailingAction.native.js.map +1 -1
  58. package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  59. package/dist/esm/Form/index.native.js.map +1 -1
  60. package/dist/esm/Form/submissionController.native.js.map +1 -1
  61. package/dist/esm/Form/validateOn.native.js.map +1 -1
  62. package/dist/esm/FormGrid.native.js.map +1 -1
  63. package/dist/esm/FormSection.native.js.map +1 -1
  64. package/dist/esm/InputParts/PasswordReveal.native.js.map +1 -1
  65. package/dist/esm/InputParts/index.native.js.map +1 -1
  66. package/dist/esm/Label/index.native.js.map +1 -1
  67. package/dist/esm/MediaPicker/base64.native.js.map +1 -1
  68. package/dist/esm/MediaPicker/payload.native.js.map +1 -1
  69. package/dist/esm/MediaPicker/types.native.js.map +1 -1
  70. package/dist/esm/MediaPicker/useMediaPicker.native.js.map +1 -1
  71. package/dist/esm/Skeleton/index.native.js.map +1 -1
  72. package/dist/esm/Spinner/SpinnerGlyph.native.js.map +1 -1
  73. package/dist/esm/Spinner/spinnerSize.native.js.map +1 -1
  74. package/dist/esm/Table/index.native.js.map +1 -1
  75. package/dist/esm/Wheel/haptic.native.js.map +1 -1
  76. package/dist/esm/Wheel/index.native.js.map +1 -1
  77. package/dist/esm/Wheel/wheelShared.native.js.map +1 -1
  78. package/dist/esm/actionLabel.native.js.map +1 -1
  79. package/dist/esm/fieldDefaults.native.js.map +1 -1
  80. package/dist/esm/fieldLayout.native.js.map +1 -1
  81. package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
  82. package/dist/esm/fields/Barcode/code128.native.js.map +1 -1
  83. package/dist/esm/fields/Barcode/index.native.js.map +1 -1
  84. package/dist/esm/fields/Barcode/qr.native.js.map +1 -1
  85. package/dist/esm/fields/Barcode/scannerTypes.native.js.map +1 -1
  86. package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
  87. package/dist/esm/fields/Checkbox/CheckboxBox.native.js.map +1 -1
  88. package/dist/esm/fields/Checkbox/CheckboxCard.native.js.map +1 -1
  89. package/dist/esm/fields/Checkbox/CheckboxFieldFrame.native.js.map +1 -1
  90. package/dist/esm/fields/Checkbox/CheckboxGroup.native.js.map +1 -1
  91. package/dist/esm/fields/Checkbox/checkboxGroupContext.native.js.map +1 -1
  92. package/dist/esm/fields/Checkbox/index.native.js.map +1 -1
  93. package/dist/esm/fields/ColorPicker/SystemColorField.native.js.map +1 -1
  94. package/dist/esm/fields/ColorPicker/index.native.js.map +1 -1
  95. package/dist/esm/fields/ColorPicker/parts.native.js.map +1 -1
  96. package/dist/esm/fields/ColorPicker/systemColorWell.gnome.native.js.map +1 -1
  97. package/dist/esm/fields/Combobox/authorOptions.native.js.map +1 -1
  98. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  99. package/dist/esm/fields/DatePicker/Calendar.native.js.map +1 -1
  100. package/dist/esm/fields/DatePicker/DateRangePicker.native.js.map +1 -1
  101. package/dist/esm/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
  102. package/dist/esm/fields/DatePicker/MonthPicker.native.js.map +1 -1
  103. package/dist/esm/fields/DatePicker/MultiDatePicker.native.js.map +1 -1
  104. package/dist/esm/fields/DatePicker/catalog.native.js.map +1 -1
  105. package/dist/esm/fields/DatePicker/fieldValueSync.native.js.map +1 -1
  106. package/dist/esm/fields/DatePicker/parts.native.js.map +1 -1
  107. package/dist/esm/fields/DatePicker/utils.native.js.map +1 -1
  108. package/dist/esm/fields/Duration/durationMath.native.js.map +1 -1
  109. package/dist/esm/fields/Duration/index.native.js.map +1 -1
  110. package/dist/esm/fields/FileUpload/filePickerTypes.native.js.map +1 -1
  111. package/dist/esm/fields/FileUpload/index.mjs +8 -3
  112. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  113. package/dist/esm/fields/FileUpload/index.native.js +8 -3
  114. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  115. package/dist/esm/fields/FileUpload/nativePickerLoader.native.js.map +1 -1
  116. package/dist/esm/fields/Geolocation/expoMapsLoader.native.js.map +1 -1
  117. package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -1
  118. package/dist/esm/fields/Geolocation/geocoder.native.js.map +1 -1
  119. package/dist/esm/fields/Geolocation/geocoderContext.native.js.map +1 -1
  120. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  121. package/dist/esm/fields/Geolocation/mapMath.native.js.map +1 -1
  122. package/dist/esm/fields/Geolocation/mapTypes.native.js.map +1 -1
  123. package/dist/esm/fields/Geolocation/nativeSearchFocus.native.js.map +1 -1
  124. package/dist/esm/fields/Geolocation/photonGeocoder.native.js.map +1 -1
  125. package/dist/esm/fields/Icon/drawnSet.native.js.map +1 -1
  126. package/dist/esm/fields/Icon/index.native.js.map +1 -1
  127. package/dist/esm/fields/ImageField/index.native.js.map +1 -1
  128. package/dist/esm/fields/Input/index.native.js.map +1 -1
  129. package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
  130. package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
  131. package/dist/esm/fields/OTPInput/otpCellCss.native.js.map +1 -1
  132. package/dist/esm/fields/PhoneInput/dialCodes.native.js.map +1 -1
  133. package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
  134. package/dist/esm/fields/PhoneInput/phone.native.js.map +1 -1
  135. package/dist/esm/fields/PhoneInput/regionNames.native.js.map +1 -1
  136. package/dist/esm/fields/Progress/index.native.js.map +1 -1
  137. package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
  138. package/dist/esm/fields/Rating/index.native.js.map +1 -1
  139. package/dist/esm/fields/Rating/ratingMath.native.js.map +1 -1
  140. package/dist/esm/fields/Rating/types.native.js.map +1 -1
  141. package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
  142. package/dist/esm/fields/Select/InlineChoices.native.js.map +1 -1
  143. package/dist/esm/fields/Select/SheetPickerRow.native.js.map +1 -1
  144. package/dist/esm/fields/Select/index.native.js.map +1 -1
  145. package/dist/esm/fields/Select/shouldInlineChoices.native.js.map +1 -1
  146. package/dist/esm/fields/Signature/index.native.js.map +1 -1
  147. package/dist/esm/fields/Slider/index.native.js.map +1 -1
  148. package/dist/esm/fields/Slider/jumpMotion.native.js.map +1 -1
  149. package/dist/esm/fields/Stepper/index.native.js.map +1 -1
  150. package/dist/esm/fields/Stepper/resolveStepperPlacement.native.js.map +1 -1
  151. package/dist/esm/fields/Switch/footprint.native.js.map +1 -1
  152. package/dist/esm/fields/Switch/index.native.js.map +1 -1
  153. package/dist/esm/fields/TextArea/index.native.js.map +1 -1
  154. package/dist/esm/fields/TimePicker/catalog.native.js.map +1 -1
  155. package/dist/esm/fields/TimePicker/parts.native.js.map +1 -1
  156. package/dist/esm/fields/TimeRangeScrubber/geometry.native.js.map +1 -1
  157. package/dist/esm/fields/TimeRangeScrubber/index.native.js.map +1 -1
  158. package/dist/esm/fields/ToggleGroup/index.native.js.map +1 -1
  159. package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
  160. package/dist/esm/fields/nativeDateTime/dateTimePickerLoader.native.js.map +1 -1
  161. package/dist/esm/fields/nativeDateTime/osDateTimeIosDisplay.native.js.map +1 -1
  162. package/dist/esm/fields/nativeDateTime/sheetKeyboard.native.js.map +1 -1
  163. package/dist/esm/fields/nativeSelect.native.js.map +1 -1
  164. package/dist/esm/fields/selectedOrder.native.js.map +1 -1
  165. package/dist/esm/formSpacing.native.js.map +1 -1
  166. package/dist/esm/hooks/useFocusManagement.native.js.map +1 -1
  167. package/dist/esm/index.native.js.map +1 -1
  168. package/dist/esm/keyboard/chain.native.js.map +1 -1
  169. package/dist/esm/keyboard/index.native.js.map +1 -1
  170. package/dist/esm/keyboard/types.native.js.map +1 -1
  171. package/dist/esm/requiredMarking.native.js.map +1 -1
  172. package/dist/esm/shared/TimeColumn.native.js.map +1 -1
  173. package/dist/esm/shared/capabilityPreflight.native.js.map +1 -1
  174. package/dist/esm/shared/choiceRowPress.native.js.map +1 -1
  175. package/dist/esm/shared/colorRamps.native.js.map +1 -1
  176. package/dist/esm/shared/devWarn.native.js.map +1 -1
  177. package/dist/esm/shared/floatingList.native.js.map +1 -1
  178. package/dist/esm/shared/focusManagement.native.js.map +1 -1
  179. package/dist/esm/shared/groupContext.native.js.map +1 -1
  180. package/dist/esm/shared/i18n.native.js.map +1 -1
  181. package/dist/esm/shared/instantMotion.native.js.map +1 -1
  182. package/dist/esm/shared/nativeA11y.native.js.map +1 -1
  183. package/dist/esm/shared/nativeAutoFocus.native.js.map +1 -1
  184. package/dist/esm/shared/overlaySizeClass.native.js.map +1 -1
  185. package/dist/esm/shared/overlayStack.native.js.map +1 -1
  186. package/dist/esm/shared/panelPortal.native.js.map +1 -1
  187. package/dist/esm/shared/pickerTriggerContract.native.js.map +1 -1
  188. package/dist/esm/shared/pressSlopProps.native.js.map +1 -1
  189. package/dist/esm/shared/restoreRetries.native.js.map +1 -1
  190. package/dist/esm/shared/sheetNavigation.native.js.map +1 -1
  191. package/dist/esm/shared/systemControlModules.gnome.native.js.map +1 -1
  192. package/dist/esm/shared/systemControls.native.js.map +1 -1
  193. package/dist/esm/shared/t.native.js.map +1 -1
  194. package/dist/esm/shared/tableCellContext.native.js.map +1 -1
  195. package/dist/esm/shared/useDragScroll.native.js.map +1 -1
  196. package/dist/esm/shared/useKeyboardInset.native.js.map +1 -1
  197. package/dist/esm/shared/useSheetEscapeDismiss.native.js.map +1 -1
  198. package/dist/esm/shared/useWheelIncrement.native.js.map +1 -1
  199. package/dist/esm/shared/utils.native.js.map +1 -1
  200. package/dist/esm/shared/zIndex.native.js.map +1 -1
  201. package/dist/esm/standardActions.native.js.map +1 -1
  202. package/dist/esm/themedPrimitives.native.js.map +1 -1
  203. package/dist/esm/types.native.js.map +1 -1
  204. package/dist/esm/utils/LinkCell.native.js.map +1 -1
  205. package/dist/jsx/Button/IconButtonGroup.mjs +32 -0
  206. package/dist/jsx/Button/IconButtonGroup.mjs.map +1 -1
  207. package/dist/jsx/Button/IconButtonGroup.native.js +32 -0
  208. package/dist/jsx/Button/IconButtonGroup.native.js.map +1 -1
  209. package/dist/jsx/Button/glass.mjs +4 -2
  210. package/dist/jsx/Button/glass.mjs.map +1 -1
  211. package/dist/jsx/Button/iconButtonGroupContext.mjs.map +1 -1
  212. package/dist/jsx/Button/iconButtonGroupContext.native.js.map +1 -1
  213. package/dist/jsx/Button/index.mjs +72 -20
  214. package/dist/jsx/Button/index.mjs.map +1 -1
  215. package/dist/jsx/Button/index.native.js +81 -20
  216. package/dist/jsx/Button/index.native.js.map +1 -1
  217. package/dist/jsx/Button/webGlass.mjs +36 -0
  218. package/dist/jsx/Button/webGlass.mjs.map +1 -0
  219. package/dist/jsx/Button/webGlass.native.js +68 -0
  220. package/dist/jsx/Button/webGlass.native.js.map +1 -0
  221. package/dist/jsx/fields/FileUpload/index.mjs +8 -3
  222. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  223. package/dist/jsx/fields/FileUpload/index.native.js +8 -3
  224. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  225. package/package.json +8 -8
  226. package/src/Button/Button.accentInk.spec.tsx +39 -8
  227. package/src/Button/Button.confirmFill.spec.tsx +141 -0
  228. package/src/Button/Button.iconButton.spec.tsx +143 -3
  229. package/src/Button/Button.stories.tsx +55 -38
  230. package/src/Button/IconButtonGroup.spec.tsx +40 -0
  231. package/src/Button/IconButtonGroup.tsx +39 -0
  232. package/src/Button/glass.spec.ts +73 -0
  233. package/src/Button/glass.ts +8 -3
  234. package/src/Button/iconButtonGroupContext.ts +2 -1
  235. package/src/Button/index.tsx +111 -16
  236. package/src/Button/webGlass.ts +42 -0
  237. package/src/fields/FileUpload/FileUpload.spec.tsx +8 -0
  238. package/src/fields/FileUpload/index.tsx +12 -4
  239. package/types/Button/IconButtonGroup.d.ts.map +1 -1
  240. package/types/Button/glass.d.ts +5 -2
  241. package/types/Button/glass.d.ts.map +1 -1
  242. package/types/Button/iconButtonGroupContext.d.ts +2 -1
  243. package/types/Button/iconButtonGroupContext.d.ts.map +1 -1
  244. package/types/Button/index.d.ts +7 -0
  245. package/types/Button/index.d.ts.map +1 -1
  246. package/types/Button/webGlass.d.ts +32 -0
  247. package/types/Button/webGlass.d.ts.map +1 -0
  248. package/types/fields/FileUpload/index.d.ts.map +1 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * FZ-8439: an overlay's one confirm is the accent ✓ (IconButton
5
+ * plate="primary"), never a black pill. Clay on the FZ-8116 deck
6
+ * (rv_0ea65e640a0ef261): "The cancel and Move button are not how we like to
7
+ * do buttons." His complaint was the word in a pill, not ink: a hued preset
8
+ * fills the ✓ circle with its accent, and the hue-less house preset fills it
9
+ * with its ink under an inverse check, iOS's mono style (rv_d2757722a7cb0992,
10
+ * "ink").
11
+ */
12
+
13
+ import { cleanup, render, screen } from "@testing-library/react";
14
+ import {
15
+ contrastRatio,
16
+ createDefaultThemeConfig,
17
+ normalizeToHex,
18
+ relativeLuminance,
19
+ } from "@multiplatform.one/theme";
20
+ import type { ReactElement } from "react";
21
+ import { TamaguiProvider, YStack } from "tamagui";
22
+ import { afterEach, describe, expect, it } from "vitest";
23
+ import { Button } from "./index";
24
+
25
+ afterEach(cleanup);
26
+
27
+ const houseConfig = createDefaultThemeConfig();
28
+ const SCHEMES = ["light", "dark"] as const;
29
+
30
+ function Check({ size, color }: { size?: number; color?: string }) {
31
+ return <svg data-glyph="" width={size} height={size} color={color} />;
32
+ }
33
+
34
+ function renderIn(ui: ReactElement, theme: string) {
35
+ return render(
36
+ <TamaguiProvider config={houseConfig.tamagui} defaultTheme={theme}>
37
+ <YStack backgroundColor="$background">{ui}</YStack>
38
+ </TamaguiProvider>,
39
+ );
40
+ }
41
+
42
+ function injectedRuleValue(className: string, property: string): string | null {
43
+ for (const sheet of Array.from(document.styleSheets)) {
44
+ for (const rule of Array.from(sheet.cssRules)) {
45
+ if (!(rule instanceof CSSStyleRule)) continue;
46
+ if (!rule.selectorText.endsWith(`.${className}`)) continue;
47
+ const value = rule.style.getPropertyValue(property);
48
+ if (value) return value.trim();
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function resting(el: Element, prefix: string, property: string): string | null {
55
+ const cls = Array.from(el.classList).find(
56
+ (c) => c.startsWith(prefix) && !new RegExp(`^${prefix}0(hover|active|focus|press)`).test(c),
57
+ );
58
+ return cls ? injectedRuleValue(cls, property) : null;
59
+ }
60
+
61
+ /**
62
+ * Tamagui paints `$token` values as CSS variables from the theme the Button
63
+ * wraps itself in; jsdom drops tamagui's theme stylesheet, so read the
64
+ * variable off that theme in the config, as the browser would.
65
+ */
66
+ function resolvePaint(paint: string, themeName: string): string {
67
+ const variable = /^var\(--([^)]+)\)$/.exec(paint)?.[1];
68
+ if (!variable) return paint;
69
+ const themes = houseConfig.tamagui.themes as Record<string, Record<string, { val?: unknown }>>;
70
+ const theme = themes[themeName];
71
+ if (!theme) {
72
+ const near = Object.keys(themes).filter((name) => name.endsWith("_accent"));
73
+ throw new Error(`no theme ${themeName}; accent themes: ${near.slice(0, 12).join(", ")}`);
74
+ }
75
+ const value = theme[variable]?.val;
76
+ return typeof value === "string" ? resolvePaint(value, themeName) : paint;
77
+ }
78
+
79
+ function luminance(paint: string): number {
80
+ const hex = normalizeToHex(paint);
81
+ if (!hex) throw new Error(`unreadable paint ${paint}`);
82
+ return relativeLuminance(hex);
83
+ }
84
+
85
+ function confirmPaint(scheme: string) {
86
+ const confirm = screen.getByRole("button", { name: "Move" });
87
+ const fill = resting(confirm, "_bg-", "background-color");
88
+ expect(fill).toBeTruthy();
89
+ const glyph = confirm.querySelector("[data-glyph]")!;
90
+ const glyphColor = glyph.getAttribute("color");
91
+ const ink =
92
+ glyphColor && glyphColor !== "currentColor" ? glyphColor : resting(confirm, "_col-", "color");
93
+ expect(ink).toBeTruthy();
94
+ return {
95
+ confirm,
96
+ fill: luminance(resolvePaint(fill!, `${scheme}_accent`)),
97
+ check: luminance(resolvePaint(ink!, `${scheme}_accent`)),
98
+ dark: scheme.startsWith("dark"),
99
+ };
100
+ }
101
+
102
+ describe("FZ-8439: the overlay confirm is a ✓ circle, never a word in a pill", () => {
103
+ for (const scheme of SCHEMES) {
104
+ it(`${scheme}: the confirm is an icon-only circle named by its action`, () => {
105
+ renderIn(<Button plate="primary" icon={Check} aria-label="Move" />, scheme);
106
+ const confirm = screen.getByRole("button", { name: "Move" });
107
+ expect(confirm.getAttribute("data-mp-icon-button")).toBe("primary");
108
+ expect(confirm.textContent).toBe("");
109
+ });
110
+
111
+ it(`${scheme}, hue-less house preset: an ink circle under an inverse check`, () => {
112
+ renderIn(<Button plate="primary" icon={Check} aria-label="Move" />, scheme);
113
+ const { fill, check, dark } = confirmPaint(scheme);
114
+ // Ink is the scheme's text pole: dark on a light page, light on a dark one.
115
+ if (dark) expect(fill).toBeGreaterThan(0.6);
116
+ else expect(fill).toBeLessThan(0.05);
117
+ expect(contrastRatio(check, fill)).toBeGreaterThanOrEqual(4.5);
118
+ // r3: white wherever it holds on the fill, so light mode's ✓ is white.
119
+ if (!dark) expect(check).toBeCloseTo(1, 5);
120
+ });
121
+
122
+ it(`${scheme}, hued preset: the circle takes the accent hue`, () => {
123
+ renderIn(<Button plate="primary" icon={Check} aria-label="Move" />, `${scheme}_blue`);
124
+ const { fill, check } = confirmPaint(`${scheme}_blue`);
125
+ expect(fill).toBeGreaterThan(0.05);
126
+ expect(fill).toBeLessThan(0.6);
127
+ expect(contrastRatio(check, fill)).toBeGreaterThanOrEqual(3);
128
+ // r3 (Clay): the ✓ on an accent fill is white, iOS's checkmark.
129
+ expect(check).toBeCloseTo(1, 5);
130
+ });
131
+
132
+ it(`${scheme}: the circle's rest fill is the accent as a literal, so native paints the browser's colour`, () => {
133
+ renderIn(<Button plate="primary" icon={Check} aria-label="Move" />, scheme);
134
+ const confirm = screen.getByRole("button", { name: "Move" });
135
+ const fill = resting(confirm, "_bg-", "background-color");
136
+ const accent = resolvePaint("var(--background)", `${scheme}_accent`);
137
+ expect(normalizeToHex(fill!)).toBe(normalizeToHex(accent));
138
+ expect(fill).not.toMatch(/^var\(/);
139
+ });
140
+ }
141
+ });
@@ -14,16 +14,22 @@
14
14
  */
15
15
 
16
16
  import { cleanup, render, screen } from "@testing-library/react";
17
- import { createDefaultThemeConfig, Surface } from "@multiplatform.one/theme";
17
+ import {
18
+ createDefaultThemeConfig,
19
+ GlyphTintFallbackProvider,
20
+ Surface,
21
+ } from "@multiplatform.one/theme";
18
22
  import type { ReactElement } from "react";
19
23
  import { TamaguiProvider, YStack } from "tamagui";
20
24
  import { afterEach, describe, expect, it, vi } from "vitest";
21
25
  import {
22
26
  Button,
27
+ GLYPH_BOLD_STROKE_UNITS,
23
28
  ICON_BUTTON_CIRCLE_GLYPH_RATIO,
24
29
  ICON_BUTTON_GLYPH_PX,
25
30
  ICON_BUTTON_NEUTRAL_GLYPH_PX,
26
31
  IconButton,
32
+ IconButtonGroup,
27
33
  iconButtonGlyphPx,
28
34
  labelLinePx,
29
35
  } from "./index";
@@ -44,8 +50,27 @@ const houseConfig = createDefaultThemeConfig();
44
50
 
45
51
  const SCHEMES = ["light", "dark"] as const;
46
52
 
47
- function Glyph({ size, color, weight }: { size?: number; color?: string; weight?: string }) {
48
- return <svg data-glyph="" data-weight={weight} width={size} height={size} color={color} />;
53
+ function Glyph({
54
+ size,
55
+ color,
56
+ weight,
57
+ strokeWidth,
58
+ }: {
59
+ size?: number;
60
+ color?: string;
61
+ weight?: string;
62
+ strokeWidth?: number;
63
+ }) {
64
+ return (
65
+ <svg
66
+ data-glyph=""
67
+ data-weight={weight}
68
+ data-stroke-width={strokeWidth}
69
+ width={size}
70
+ height={size}
71
+ color={color}
72
+ />
73
+ );
49
74
  }
50
75
 
51
76
  function glyphOf(label: string): SVGElement {
@@ -144,6 +169,25 @@ describe("LC-176: an icon-only Button is a bare glyph", () => {
144
169
  },
145
170
  );
146
171
 
172
+ it.each(SCHEMES)("%s: a bare glyph fills its box less 2pt, up to the row's glyph", (scheme) => {
173
+ // Round 4's critique: the SHC "+" at $3 drew a 19pt ink beside a 23pt
174
+ // "+" at the default size in the same app.
175
+ houseRender(
176
+ <>
177
+ <Button size="$3" circular chromeless accentInk icon={Glyph} aria-label="Add" />
178
+ <Button size="$2" circular chromeless accentInk icon={Glyph} aria-label="Copy" />
179
+ <Button chromeless accentInk icon={Glyph} aria-label="Default" />
180
+ </>,
181
+ scheme,
182
+ );
183
+ const glyph = (label: string) => Number(glyphOf(label).getAttribute("width"));
184
+ const box = (label: string) =>
185
+ Number(screen.getByLabelText(label).getAttribute("data-mp-button-height"));
186
+ for (const label of ["Add", "Copy"])
187
+ expect(glyph(label)).toBe(Math.min(glyph("Default"), box(label) - 4));
188
+ expect(glyph("Add")).toBeGreaterThanOrEqual(ICON_BUTTON_GLYPH_PX);
189
+ });
190
+
147
191
  it.each(["$3", "$4"] as const)(
148
192
  "%s: a circle the caller asks for carries its glyph at the Settings ratio, bold",
149
193
  (size) => {
@@ -183,6 +227,22 @@ describe("LC-176: an icon-only Button is a bare glyph", () => {
183
227
  expect(rest(media, "_bg-").join(" ")).not.toMatch(/transparent/);
184
228
  });
185
229
 
230
+ it.each(SCHEMES)("%s: a lone media plate and a group's capsule are one material", (scheme) => {
231
+ // Round 4's critique: the lone heart painted the tinted tier's opaque
232
+ // ground beside the capsule's translucent material on the same tile.
233
+ const { container } = houseRender(
234
+ <>
235
+ <Button plate="media" icon={Glyph} aria-label="Favourite" />
236
+ <IconButtonGroup plate="media">
237
+ <Button plate="media" icon={Glyph} aria-label="Replace" />
238
+ </IconButtonGroup>
239
+ </>,
240
+ scheme,
241
+ );
242
+ const capsule = container.querySelector('[data-mp-icon-button-group="media"]')!;
243
+ expect(rest(screen.getByLabelText("Favourite"), "_bg-")).toEqual(rest(capsule, "_bg-"));
244
+ });
245
+
186
246
  it("a toggle over media shows its state in the glyph, not a selected fill", () => {
187
247
  houseRender(<Button plate="media" icon={Glyph} aria-label="Like" aria-pressed />, "light");
188
248
  expect(screen.getByLabelText("Like").hasAttribute("data-mp-selected")).toBe(false);
@@ -231,6 +291,9 @@ describe("LC-176: an icon-only Button is a bare glyph", () => {
231
291
  const search = screen.getByLabelText("Search");
232
292
  expect(search.getAttribute("data-mp-icon-button")).toBe("glass");
233
293
  expect(search.querySelector("[data-glass]")).not.toBeNull();
294
+ // iOS Safari: the button holds its absolute glass circle under the glyph.
295
+ expect(search.className).toMatch(/(?:^| )_pos-relative(?: |$)/);
296
+ expect(search.className).toMatch(/(?:^| )_zi-0(?: |$)/);
234
297
  expect(glyphOf("Search").getAttribute("data-weight")).toBe("bold");
235
298
  // Glass is the bar button; content and inline glyphs never float on it.
236
299
  expect(screen.getByLabelText("Content").querySelector("[data-glass]")).toBeNull();
@@ -313,6 +376,25 @@ describe("LC-176: an icon-only Button is a bare glyph", () => {
313
376
  }
314
377
  });
315
378
 
379
+ it("a caller's small px never shrinks a bar glyph (the DataTable toolbar's 14pt icons)", () => {
380
+ houseRender(
381
+ <Surface bar>
382
+ <Button chromeless size="$3" icon={Glyph} aria-label="Bare" />
383
+ <Button chromeless size="$3" icon={<Glyph size={14} />} aria-label="Save view" />
384
+ <Button icon={<Glyph size={40} />} aria-label="Large" />
385
+ <Button size="$3" icon={<Glyph size={14} />}>
386
+ Sort
387
+ </Button>
388
+ </Surface>,
389
+ "light",
390
+ );
391
+ const bar = Number(glyphOf("Bare").getAttribute("width"));
392
+ expect(Number(glyphOf("Save view").getAttribute("width"))).toBe(bar);
393
+ expect(Number(glyphOf("Large").getAttribute("width"))).toBe(40);
394
+ const sort = screen.getByText("Sort").closest("[role=button]")!.querySelector("[data-glyph]")!;
395
+ expect(Number(sort.getAttribute("width"))).toBe(14);
396
+ });
397
+
316
398
  it("an inline glyph is the text's size, 8pt after the value, bold and tinted in content", () => {
317
399
  houseRender(
318
400
  <>
@@ -338,4 +420,62 @@ describe("LC-176: an icon-only Button is a bare glyph", () => {
338
420
  );
339
421
  expect(glyphOf("Copy").getAttribute("data-weight")).toBe("bold");
340
422
  });
423
+
424
+ it("a bold glyph is stroked up to an SF Symbols semibold-bold stem; a regular one is not", () => {
425
+ // Clay's critique of round 3: the SHC + read thin. Phosphor bold's stem
426
+ // is 24 of a 200-unit plus (0.12); SF semibold's is 0.153, SF bold's 0.18.
427
+ // The trash lid's 16-unit slot must stay open, so the stroke stays under 16.
428
+ houseRender(
429
+ <>
430
+ <Button size="$3" circular chromeless accentInk icon={Glyph} aria-label="Add" />
431
+ <IconButton glyphWeight="regular" icon={Glyph} aria-label="Regular" />
432
+ </>,
433
+ "light",
434
+ );
435
+ expect(Number(glyphOf("Add").getAttribute("data-stroke-width"))).toBe(GLYPH_BOLD_STROKE_UNITS);
436
+ expect((24 + GLYPH_BOLD_STROKE_UNITS) / (200 + GLYPH_BOLD_STROKE_UNITS)).toBeGreaterThan(0.15);
437
+ expect(GLYPH_BOLD_STROKE_UNITS).toBeLessThan(16);
438
+ expect(glyphOf("Regular").hasAttribute("data-stroke-width")).toBe(false);
439
+ });
440
+
441
+ it.each(SCHEMES)(
442
+ "%s: a glyph on a plate takes the plate's on-colour at full strength",
443
+ (scheme) => {
444
+ houseRender(
445
+ <>
446
+ <Button plate="media" icon={Glyph} aria-label="Favourite" />
447
+ <Button size="$3" circular outlined icon={Glyph} aria-label="Update" />
448
+ </>,
449
+ scheme,
450
+ );
451
+ expect(rest(screen.getByLabelText("Favourite"), "_col-").join(" ")).toMatch(/color12/);
452
+ expect(rest(screen.getByLabelText("Update"), "_col-").length).toBeGreaterThan(0);
453
+ },
454
+ );
455
+
456
+ it.each(SCHEMES)(
457
+ "%s: with no hue in the theme, the tint follows the one fallback token",
458
+ (scheme) => {
459
+ houseRender(
460
+ <>
461
+ <GlyphTintFallbackProvider value="ink">
462
+ <Button chromeless accentInk icon={Glyph} aria-label="Ink" />
463
+ </GlyphTintFallbackProvider>
464
+ <GlyphTintFallbackProvider value="system">
465
+ <Button chromeless accentInk icon={Glyph} aria-label="System" />
466
+ </GlyphTintFallbackProvider>
467
+ <GlyphTintFallbackProvider value="accent">
468
+ <Button chromeless accentInk icon={Glyph} aria-label="Accent" />
469
+ </GlyphTintFallbackProvider>
470
+ </>,
471
+ scheme,
472
+ );
473
+ const ink = rest(screen.getByLabelText("Ink"), "_col-");
474
+ const system = rest(screen.getByLabelText("System"), "_col-");
475
+ const accent = rest(screen.getByLabelText("Accent"), "_col-");
476
+ expect(system).not.toEqual(ink);
477
+ expect(accent).not.toEqual(ink);
478
+ expect(accent).not.toEqual(system);
479
+ },
480
+ );
341
481
  });
@@ -5,8 +5,11 @@ import {
5
5
  FOCUS_RING_CLIPPED_OFFSET,
6
6
  FOCUS_RING_MIN_CONTRAST,
7
7
  ensureFocusVisibleRing,
8
+ GlyphTintFallbackProvider,
8
9
  Paragraph,
9
10
  Surface,
11
+ glyphTintFallback,
12
+ type GlyphTintFallback,
10
13
  } from "@multiplatform.one/theme";
11
14
  import { Button as TamaguiButton, XStack, YStack, useTheme } from "tamagui";
12
15
  import {
@@ -539,25 +542,36 @@ export const IconButtons: Story = {
539
542
  // SHC's own code rather than this reproduction.
540
543
  const shcRoundGlyph = { circular: true, chromeless: true, accentInk: true } as const;
541
544
 
545
+ // FZ-8020 r4: the house theme has no hue, so `tint` renders each fallback
546
+ // (ink, the system tint, a house hue) from one build for Clay's compare.
547
+ type TintStory = StoryObj<{ tint: GlyphTintFallback }>;
548
+ const tintArgs: Pick<TintStory, "args" | "argTypes"> = {
549
+ args: { tint: glyphTintFallback },
550
+ argTypes: { tint: { control: "inline-radio", options: ["ink", "system", "accent"] } },
551
+ };
552
+
542
553
  /** Clay's SHC Nodes screen (IMG_1925 on FZ-8020): the add and the copy. */
543
- export const ShcNodesScreen: Story = {
544
- render: () => (
545
- <YStack gap="$4" padding="$4" backgroundColor="$background" data-testid="shc-nodes">
546
- <XStack alignItems="center" gap="$2">
547
- <Paragraph flex={1} size="$9" fontWeight="700">
548
- Nodes
549
- </Paragraph>
550
- <Button size="$3" {...shcRoundGlyph} icon={PlusIcon} aria-label="Add node" />
551
- </XStack>
552
- <XStack alignItems="center" gap="$2">
553
- <Paragraph fontFamily="$mono">shc node join</Paragraph>
554
- <Button size="$2" {...shcRoundGlyph} icon={CopyIcon} aria-label="Copy" />
555
- </XStack>
556
- <XStack alignItems="center">
557
- <Paragraph fontFamily="$mono">shc node join</Paragraph>
558
- <IconButton inline icon={CopyIcon} aria-label="Copy (inline)" />
559
- </XStack>
560
- </YStack>
554
+ export const ShcNodesScreen: TintStory = {
555
+ ...tintArgs,
556
+ render: ({ tint }) => (
557
+ <GlyphTintFallbackProvider value={tint}>
558
+ <YStack gap="$4" padding="$4" backgroundColor="$background" data-testid="shc-nodes">
559
+ <XStack alignItems="center" gap="$2">
560
+ <Paragraph flex={1} size="$9" fontWeight="700">
561
+ Nodes
562
+ </Paragraph>
563
+ <Button size="$3" {...shcRoundGlyph} icon={PlusIcon} aria-label="Add node" />
564
+ </XStack>
565
+ <XStack alignItems="center" gap="$2">
566
+ <Paragraph fontFamily="$mono">shc node join</Paragraph>
567
+ <Button size="$2" {...shcRoundGlyph} icon={CopyIcon} aria-label="Copy" />
568
+ </XStack>
569
+ <XStack alignItems="center">
570
+ <Paragraph fontFamily="$mono">shc node join</Paragraph>
571
+ <IconButton inline icon={CopyIcon} aria-label="Copy (inline)" />
572
+ </XStack>
573
+ </YStack>
574
+ </GlyphTintFallbackProvider>
561
575
  ),
562
576
  };
563
577
 
@@ -568,30 +582,33 @@ const shcUpdatePlate = { circular: true, outlined: true } as const;
568
582
  * Clay's SHC Updates rows (IMG_1926 on FZ-8020): the plate SHC asks for, its
569
583
  * glyph now at the circle ratio, above the bare tinted glyph LC-176 wants.
570
584
  */
571
- export const ShcUpdateRows: Story = {
572
- render: () => (
573
- <YStack gap="$2" padding="$4" backgroundColor="$background" data-testid="shc-update-rows">
574
- {(["$3", "$4"] as const).map((size) => (
575
- <XStack key={size} alignItems="center" gap="$3" minHeight="$5">
585
+ export const ShcUpdateRows: TintStory = {
586
+ ...tintArgs,
587
+ render: ({ tint }) => (
588
+ <GlyphTintFallbackProvider value={tint}>
589
+ <YStack gap="$2" padding="$4" backgroundColor="$background" data-testid="shc-update-rows">
590
+ {(["$3", "$4"] as const).map((size) => (
591
+ <XStack key={size} alignItems="center" gap="$3" minHeight="$5">
592
+ <YStack flex={1}>
593
+ <Paragraph fontWeight="600">pokemon</Paragraph>
594
+ <Paragraph color="$color11">1.2.0+34</Paragraph>
595
+ </YStack>
596
+ <Button
597
+ size={size}
598
+ {...shcUpdatePlate}
599
+ icon={ArrowCircleUpIcon}
600
+ aria-label={`Update pokemon (plate ${size})`}
601
+ />
602
+ </XStack>
603
+ ))}
604
+ <XStack alignItems="center" gap="$3" minHeight="$5">
576
605
  <YStack flex={1}>
577
606
  <Paragraph fontWeight="600">pokemon</Paragraph>
578
607
  <Paragraph color="$color11">1.2.0+34</Paragraph>
579
608
  </YStack>
580
- <Button
581
- size={size}
582
- {...shcUpdatePlate}
583
- icon={ArrowCircleUpIcon}
584
- aria-label={`Update pokemon (plate ${size})`}
585
- />
609
+ <IconButton accentInk icon={ArrowCircleUpIcon} aria-label="Update pokemon" />
586
610
  </XStack>
587
- ))}
588
- <XStack alignItems="center" gap="$3" minHeight="$5">
589
- <YStack flex={1}>
590
- <Paragraph fontWeight="600">pokemon</Paragraph>
591
- <Paragraph color="$color11">1.2.0+34</Paragraph>
592
- </YStack>
593
- <IconButton accentInk icon={ArrowCircleUpIcon} aria-label="Update pokemon" />
594
- </XStack>
595
- </YStack>
611
+ </YStack>
612
+ </GlyphTintFallbackProvider>
596
613
  ),
597
614
  };
@@ -29,10 +29,14 @@ vi.mock("./glass", () => ({
29
29
 
30
30
  afterEach(() => {
31
31
  cleanup();
32
+ vi.unstubAllGlobals();
32
33
  glass.available = false;
33
34
  glass.containerLoads = 0;
34
35
  });
35
36
 
37
+ const IPHONE =
38
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1";
39
+
36
40
  const houseConfig = createDefaultThemeConfig();
37
41
 
38
42
  function Glyph({ size, weight }: { size?: number; weight?: string }) {
@@ -48,6 +52,42 @@ function houseRender(ui: ReactElement) {
48
52
  }
49
53
 
50
54
  describe("LC-176: IconButtonGroup", () => {
55
+ it("on iOS Safari a bar group is one glass capsule, its buttons bare glyphs on it", () => {
56
+ vi.stubGlobal("navigator", { userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 });
57
+ glass.available = true;
58
+ houseRender(
59
+ <Surface bar>
60
+ <IconButtonGroup data-testid="group">
61
+ <IconButton icon={Glyph} aria-label="Archive" />
62
+ <IconButton icon={Glyph} aria-label="Delete" />
63
+ </IconButtonGroup>
64
+ <IconButton icon={Glyph} aria-label="Back" />
65
+ </Surface>,
66
+ );
67
+ const group = screen.getByTestId("group");
68
+ expect(group.getAttribute("data-mp-icon-button-group")).toBe("glass");
69
+ expect(group.querySelectorAll('[data-mp-glass="web"]')).toHaveLength(1);
70
+ expect(group.querySelector("[data-glass]")).toBeNull();
71
+ for (const name of ["Archive", "Delete"])
72
+ expect(screen.getByLabelText(name).getAttribute("data-mp-icon-button")).not.toBe("glass");
73
+ // A lone bar button keeps its own circle.
74
+ expect(screen.getByLabelText("Back").querySelector("[data-glass]")).not.toBeNull();
75
+ });
76
+
77
+ it("a lone glass circle fills the button's border box, as wide as the ✓ beside it (FZ-8439)", () => {
78
+ glass.available = true;
79
+ houseRender(
80
+ <Surface bar>
81
+ <IconButton icon={Glyph} aria-label="Close" />
82
+ </Surface>,
83
+ );
84
+ const face = screen.getByLabelText("Close").querySelector<HTMLElement>("[data-glass]")!;
85
+ const inset = Number.parseFloat(face.style.top);
86
+ expect(inset).toBeLessThan(0);
87
+ for (const side of ["right", "bottom", "left"] as const)
88
+ expect(Number.parseFloat(face.style[side])).toBe(inset);
89
+ });
90
+
51
91
  it("over media, the group is one capsule and its buttons draw their glyphs alone", () => {
52
92
  houseRender(
53
93
  <>
@@ -3,6 +3,7 @@ import { useCallback, useState, type ReactNode } from "react";
3
3
  import { isWeb, Theme, View, XStack, type XStackProps } from "tamagui";
4
4
  import { loadGlassView } from "./glass";
5
5
  import { MEDIA_PLATE_WEB_STYLE } from "./mediaPlate";
6
+ import { isAppleTouchWeb, WebGlassFace } from "./webGlass";
6
7
  import { IconButtonGroupContext, IconButtonGroupRegistry } from "./iconButtonGroupContext";
7
8
 
8
9
  /**
@@ -55,6 +56,44 @@ export function IconButtonGroup({ plate, children, ...rest }: IconButtonGroupPro
55
56
  </IconButtonGroupContext.Provider>
56
57
  );
57
58
  }
59
+ if (bar && isWeb && isAppleTouchWeb()) {
60
+ // The caller's layout props stay on the outer row; the capsule hugs the
61
+ // buttons inside it, so a stretched slot never draws a wide empty pill.
62
+ return (
63
+ <IconButtonGroupRegistry.Provider value={register}>
64
+ <IconButtonGroupContext.Provider value="glass">
65
+ <XStack
66
+ alignItems="center"
67
+ data-mp-icon-button-group={members > 0 ? "glass" : "bar"}
68
+ {...rest}
69
+ >
70
+ <XStack
71
+ alignItems="center"
72
+ position="relative"
73
+ zIndex={0}
74
+ paddingHorizontal="$2"
75
+ gap="$1"
76
+ data-mp-glass-capsule=""
77
+ >
78
+ {members > 0 ? (
79
+ <WebGlassFace
80
+ style={{
81
+ position: "absolute",
82
+ top: 0,
83
+ right: 0,
84
+ bottom: 0,
85
+ left: 0,
86
+ borderRadius: 100000,
87
+ }}
88
+ />
89
+ ) : null}
90
+ {children}
91
+ </XStack>
92
+ </XStack>
93
+ </IconButtonGroupContext.Provider>
94
+ </IconButtonGroupRegistry.Provider>
95
+ );
96
+ }
58
97
  const GlassView = bar ? loadGlassView() : null;
59
98
  const row = (
60
99
  <XStack
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * LC-176 / LC-266: on the web only iOS Safari takes the glass face (Clay on
5
+ * rv_3bb3f1f65d6b981f).
6
+ */
7
+
8
+ import { createDefaultThemeConfig } from "@multiplatform.one/theme";
9
+ import { cleanup, render } from "@testing-library/react";
10
+ import { createElement } from "react";
11
+ import { TamaguiProvider } from "tamagui";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
+ import { isAppleTouchWeb, loadGlassView } from "./glass";
14
+
15
+ const IPHONE =
16
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1";
17
+ const MAC_SAFARI =
18
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15";
19
+ const houseConfig = createDefaultThemeConfig();
20
+ const PIXEL =
21
+ "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36";
22
+
23
+ describe("isAppleTouchWeb", () => {
24
+ it("an iPhone's Safari takes the glass face", () => {
25
+ expect(isAppleTouchWeb({ userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 })).toBe(
26
+ true,
27
+ );
28
+ });
29
+
30
+ it("iPadOS reports a Mac with touch, and is iOS", () => {
31
+ expect(
32
+ isAppleTouchWeb({ userAgent: MAC_SAFARI, platform: "MacIntel", maxTouchPoints: 5 }),
33
+ ).toBe(true);
34
+ });
35
+
36
+ it("a Mac, an Android phone and no navigator draw the bare glyph", () => {
37
+ expect(
38
+ isAppleTouchWeb({ userAgent: MAC_SAFARI, platform: "MacIntel", maxTouchPoints: 0 }),
39
+ ).toBe(false);
40
+ expect(isAppleTouchWeb({ userAgent: PIXEL, platform: "Linux armv8l", maxTouchPoints: 5 })).toBe(
41
+ false,
42
+ );
43
+ expect(isAppleTouchWeb(undefined)).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("loadGlassView on the web", () => {
48
+ afterEach(() => {
49
+ cleanup();
50
+ vi.unstubAllGlobals();
51
+ });
52
+
53
+ it("iOS Safari's glass circle sits under the glyph it backs", () => {
54
+ vi.stubGlobal("navigator", { userAgent: IPHONE, platform: "iPhone", maxTouchPoints: 5 });
55
+ const Glass = loadGlassView();
56
+ expect(Glass).not.toBeNull();
57
+ const { container } = render(
58
+ createElement(
59
+ TamaguiProvider,
60
+ { config: houseConfig.tamagui, defaultTheme: "light", disableInjectCSS: true },
61
+ createElement(Glass!, { style: { position: "absolute" } }),
62
+ ),
63
+ );
64
+ const face = container.querySelector('[data-mp-glass="web"]') as HTMLElement | null;
65
+ expect(face).not.toBeNull();
66
+ expect(face!.style.zIndex).toBe("-1");
67
+ });
68
+
69
+ it("every other browser draws the bare glyph", () => {
70
+ vi.stubGlobal("navigator", { userAgent: PIXEL, platform: "Linux armv8l", maxTouchPoints: 5 });
71
+ expect(loadGlassView()).toBeNull();
72
+ });
73
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
+ import { isAppleTouchWeb, WebGlassFace } from "./webGlass";
2
3
 
3
4
  /** The slice of expo-glass-effect's `GlassView` an icon button draws. */
4
5
  export type GlassViewComponent = ComponentType<{
@@ -15,13 +16,17 @@ export type GlassContainerComponent = ComponentType<{
15
16
  children?: ReactNode;
16
17
  }>;
17
18
 
19
+ export { isAppleTouchWeb } from "./webGlass";
20
+
18
21
  /**
19
22
  * LC-176: the iOS 26 Liquid Glass material an icon button floats on in a bar.
20
- * Only iOS has it (`glass.native.ts`; the native module is Apple-only, so Android probes null); web, Android and GNOME draw the bare
21
- * tinted glyph.
23
+ * Native iOS draws it from expo-glass-effect (`glass.native.ts`; Android
24
+ * probes null). On the web only iOS Safari takes the glass face (Clay on
25
+ * rv_3bb3f1f65d6b981f: "Is there a way to have the buttons at the bottom be
26
+ * liquid glass if it's an iPhone"); every other browser draws the bare glyph.
22
27
  */
23
28
  export function loadGlassView(): GlassViewComponent | null {
24
- return null;
29
+ return isAppleTouchWeb() ? (WebGlassFace as GlassViewComponent) : null;
25
30
  }
26
31
 
27
32
  /**