@multiplatform.one/forms 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 (233) hide show
  1. package/dist/cjs/Button/index.cjs +6 -10
  2. package/dist/cjs/Button/index.native.js +6 -10
  3. package/dist/cjs/Button/index.native.js.map +1 -1
  4. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.cjs +0 -1
  5. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  6. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  7. package/dist/cjs/FieldDisplay/index.cjs +2 -1
  8. package/dist/cjs/FieldDisplay/index.native.js +9 -2
  9. package/dist/cjs/FieldDisplay/index.native.js.map +1 -1
  10. package/dist/cjs/FloatingPanel/index.cjs +8 -1
  11. package/dist/cjs/InputParts/index.cjs +64 -2
  12. package/dist/cjs/InputParts/index.native.js +70 -4
  13. package/dist/cjs/InputParts/index.native.js.map +1 -1
  14. package/dist/cjs/fields/Barcode/BarcodePreview.cjs +15 -9
  15. package/dist/cjs/fields/Barcode/BarcodePreview.native.js +15 -9
  16. package/dist/cjs/fields/Barcode/BarcodePreview.native.js.map +1 -1
  17. package/dist/cjs/fields/Combobox/index.cjs +14 -11
  18. package/dist/cjs/fields/Combobox/index.native.js +16 -11
  19. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  20. package/dist/cjs/fields/FileUpload/index.cjs +66 -5
  21. package/dist/cjs/fields/FileUpload/index.native.js +85 -5
  22. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  23. package/dist/cjs/fields/Geolocation/geoValue.cjs +122 -0
  24. package/dist/cjs/fields/Geolocation/geoValue.native.js +151 -0
  25. package/dist/cjs/fields/Geolocation/geoValue.native.js.map +1 -0
  26. package/dist/cjs/fields/Geolocation/index.cjs +16 -29
  27. package/dist/cjs/fields/Geolocation/index.native.js +16 -29
  28. package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
  29. package/dist/cjs/fields/Icon/index.cjs +1 -0
  30. package/dist/cjs/fields/Icon/index.native.js +1 -0
  31. package/dist/cjs/fields/Icon/index.native.js.map +1 -1
  32. package/dist/cjs/fields/Input/index.cjs +175 -95
  33. package/dist/cjs/fields/Input/index.native.js +83 -4
  34. package/dist/cjs/fields/Input/index.native.js.map +1 -1
  35. package/dist/cjs/fields/MentionInput/index.cjs +6 -1
  36. package/dist/cjs/fields/MentionInput/index.native.js +6 -1
  37. package/dist/cjs/fields/MentionInput/index.native.js.map +1 -1
  38. package/dist/cjs/fields/OTPInput/index.cjs +7 -2
  39. package/dist/cjs/fields/OTPInput/index.native.js +7 -2
  40. package/dist/cjs/fields/OTPInput/index.native.js.map +1 -1
  41. package/dist/cjs/fields/PhoneInput/index.native.js +1 -0
  42. package/dist/cjs/fields/PhoneInput/index.native.js.map +1 -1
  43. package/dist/cjs/fields/SearchInput/index.native.js.map +1 -1
  44. package/dist/cjs/shared/utils.cjs +4 -14
  45. package/dist/cjs/shared/utils.native.js +6 -14
  46. package/dist/cjs/shared/utils.native.js.map +1 -1
  47. package/dist/esm/Button/index.mjs +6 -9
  48. package/dist/esm/Button/index.mjs.map +1 -1
  49. package/dist/esm/Button/index.native.js +6 -9
  50. package/dist/esm/Button/index.native.js.map +1 -1
  51. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
  52. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  53. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  54. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  55. package/dist/esm/FieldDisplay/index.mjs +2 -1
  56. package/dist/esm/FieldDisplay/index.mjs.map +1 -1
  57. package/dist/esm/FieldDisplay/index.native.js +9 -2
  58. package/dist/esm/FieldDisplay/index.native.js.map +1 -1
  59. package/dist/esm/FloatingPanel/index.mjs +8 -1
  60. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  61. package/dist/esm/InputParts/index.mjs +64 -2
  62. package/dist/esm/InputParts/index.mjs.map +1 -1
  63. package/dist/esm/InputParts/index.native.js +70 -4
  64. package/dist/esm/InputParts/index.native.js.map +1 -1
  65. package/dist/esm/fields/Barcode/BarcodePreview.mjs +15 -9
  66. package/dist/esm/fields/Barcode/BarcodePreview.mjs.map +1 -1
  67. package/dist/esm/fields/Barcode/BarcodePreview.native.js +15 -9
  68. package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
  69. package/dist/esm/fields/Combobox/index.mjs +14 -11
  70. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  71. package/dist/esm/fields/Combobox/index.native.js +16 -11
  72. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  73. package/dist/esm/fields/FileUpload/index.mjs +66 -5
  74. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  75. package/dist/esm/fields/FileUpload/index.native.js +85 -5
  76. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  77. package/dist/esm/fields/Geolocation/geoValue.mjs +95 -0
  78. package/dist/esm/fields/Geolocation/geoValue.mjs.map +1 -0
  79. package/dist/esm/fields/Geolocation/geoValue.native.js +121 -0
  80. package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -0
  81. package/dist/esm/fields/Geolocation/index.mjs +15 -28
  82. package/dist/esm/fields/Geolocation/index.mjs.map +1 -1
  83. package/dist/esm/fields/Geolocation/index.native.js +15 -28
  84. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  85. package/dist/esm/fields/Icon/index.mjs +1 -0
  86. package/dist/esm/fields/Icon/index.mjs.map +1 -1
  87. package/dist/esm/fields/Icon/index.native.js +1 -0
  88. package/dist/esm/fields/Icon/index.native.js.map +1 -1
  89. package/dist/esm/fields/Input/index.mjs +177 -98
  90. package/dist/esm/fields/Input/index.mjs.map +1 -1
  91. package/dist/esm/fields/Input/index.native.js +85 -7
  92. package/dist/esm/fields/Input/index.native.js.map +1 -1
  93. package/dist/esm/fields/MentionInput/index.mjs +6 -1
  94. package/dist/esm/fields/MentionInput/index.mjs.map +1 -1
  95. package/dist/esm/fields/MentionInput/index.native.js +6 -1
  96. package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
  97. package/dist/esm/fields/OTPInput/index.mjs +7 -3
  98. package/dist/esm/fields/OTPInput/index.mjs.map +1 -1
  99. package/dist/esm/fields/OTPInput/index.native.js +7 -3
  100. package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
  101. package/dist/esm/fields/PhoneInput/index.mjs.map +1 -1
  102. package/dist/esm/fields/PhoneInput/index.native.js +1 -0
  103. package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
  104. package/dist/esm/fields/SearchInput/index.mjs.map +1 -1
  105. package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
  106. package/dist/esm/shared/utils.mjs +4 -14
  107. package/dist/esm/shared/utils.mjs.map +1 -1
  108. package/dist/esm/shared/utils.native.js +6 -14
  109. package/dist/esm/shared/utils.native.js.map +1 -1
  110. package/dist/jsx/Button/index.mjs +6 -9
  111. package/dist/jsx/Button/index.mjs.map +1 -1
  112. package/dist/jsx/Button/index.native.js +6 -10
  113. package/dist/jsx/Button/index.native.js.map +1 -1
  114. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
  115. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  116. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  117. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  118. package/dist/jsx/FieldDisplay/index.mjs +2 -1
  119. package/dist/jsx/FieldDisplay/index.mjs.map +1 -1
  120. package/dist/jsx/FieldDisplay/index.native.js +9 -2
  121. package/dist/jsx/FieldDisplay/index.native.js.map +1 -1
  122. package/dist/jsx/FloatingPanel/index.mjs +8 -1
  123. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  124. package/dist/jsx/InputParts/index.mjs +64 -2
  125. package/dist/jsx/InputParts/index.mjs.map +1 -1
  126. package/dist/jsx/InputParts/index.native.js +70 -4
  127. package/dist/jsx/InputParts/index.native.js.map +1 -1
  128. package/dist/jsx/fields/Barcode/BarcodePreview.mjs +15 -9
  129. package/dist/jsx/fields/Barcode/BarcodePreview.mjs.map +1 -1
  130. package/dist/jsx/fields/Barcode/BarcodePreview.native.js +15 -9
  131. package/dist/jsx/fields/Barcode/BarcodePreview.native.js.map +1 -1
  132. package/dist/jsx/fields/Combobox/index.mjs +14 -11
  133. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  134. package/dist/jsx/fields/Combobox/index.native.js +16 -11
  135. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  136. package/dist/jsx/fields/FileUpload/index.mjs +66 -5
  137. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  138. package/dist/jsx/fields/FileUpload/index.native.js +85 -5
  139. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  140. package/dist/jsx/fields/Geolocation/geoValue.mjs +95 -0
  141. package/dist/jsx/fields/Geolocation/geoValue.mjs.map +1 -0
  142. package/dist/jsx/fields/Geolocation/geoValue.native.js +151 -0
  143. package/dist/jsx/fields/Geolocation/geoValue.native.js.map +1 -0
  144. package/dist/jsx/fields/Geolocation/index.mjs +15 -28
  145. package/dist/jsx/fields/Geolocation/index.mjs.map +1 -1
  146. package/dist/jsx/fields/Geolocation/index.native.js +16 -29
  147. package/dist/jsx/fields/Geolocation/index.native.js.map +1 -1
  148. package/dist/jsx/fields/Icon/index.mjs +1 -0
  149. package/dist/jsx/fields/Icon/index.mjs.map +1 -1
  150. package/dist/jsx/fields/Icon/index.native.js +1 -0
  151. package/dist/jsx/fields/Icon/index.native.js.map +1 -1
  152. package/dist/jsx/fields/Input/index.mjs +177 -98
  153. package/dist/jsx/fields/Input/index.mjs.map +1 -1
  154. package/dist/jsx/fields/Input/index.native.js +83 -4
  155. package/dist/jsx/fields/Input/index.native.js.map +1 -1
  156. package/dist/jsx/fields/MentionInput/index.mjs +6 -1
  157. package/dist/jsx/fields/MentionInput/index.mjs.map +1 -1
  158. package/dist/jsx/fields/MentionInput/index.native.js +6 -1
  159. package/dist/jsx/fields/MentionInput/index.native.js.map +1 -1
  160. package/dist/jsx/fields/OTPInput/index.mjs +7 -3
  161. package/dist/jsx/fields/OTPInput/index.mjs.map +1 -1
  162. package/dist/jsx/fields/OTPInput/index.native.js +7 -2
  163. package/dist/jsx/fields/OTPInput/index.native.js.map +1 -1
  164. package/dist/jsx/fields/PhoneInput/index.mjs.map +1 -1
  165. package/dist/jsx/fields/PhoneInput/index.native.js +1 -0
  166. package/dist/jsx/fields/PhoneInput/index.native.js.map +1 -1
  167. package/dist/jsx/fields/SearchInput/index.mjs.map +1 -1
  168. package/dist/jsx/fields/SearchInput/index.native.js.map +1 -1
  169. package/dist/jsx/shared/utils.mjs +4 -14
  170. package/dist/jsx/shared/utils.mjs.map +1 -1
  171. package/dist/jsx/shared/utils.native.js +6 -14
  172. package/dist/jsx/shared/utils.native.js.map +1 -1
  173. package/package.json +7 -7
  174. package/src/Button/Button.density.spec.tsx +118 -32
  175. package/src/Button/Button.labelInk.spec.tsx +72 -19
  176. package/src/Button/Button.lc71.spec.tsx +10 -3
  177. package/src/Button/Button.stories.tsx +97 -7
  178. package/src/Button/index.tsx +22 -12
  179. package/src/ContextualSaveBar/UnsavedChangesDialog.tsx +0 -1
  180. package/src/FieldDisplay/FieldDisplay.spec.tsx +11 -2
  181. package/src/FieldDisplay/index.tsx +5 -0
  182. package/src/FloatingPanel/index.tsx +22 -1
  183. package/src/FloatingPanel/panelWidthContract.spec.ts +1 -0
  184. package/src/InputParts/index.tsx +100 -3
  185. package/src/fields/Barcode/BarcodePreview.spec.tsx +153 -5
  186. package/src/fields/Barcode/BarcodePreview.stories.tsx +104 -0
  187. package/src/fields/Barcode/BarcodePreview.tsx +47 -15
  188. package/src/fields/ColorPicker/PresetSwatches.spec.tsx +71 -0
  189. package/src/fields/ColorPicker/PresetSwatches.stories.tsx +94 -0
  190. package/src/fields/Combobox/Combobox.spec.tsx +64 -1
  191. package/src/fields/Combobox/index.tsx +22 -11
  192. package/src/fields/FileUpload/FileUpload.spec.tsx +71 -0
  193. package/src/fields/FileUpload/index.tsx +100 -3
  194. package/src/fields/Geolocation/Geolocation.spec.tsx +126 -0
  195. package/src/fields/Geolocation/geoValue.spec.ts +169 -0
  196. package/src/fields/Geolocation/geoValue.ts +149 -0
  197. package/src/fields/Geolocation/index.tsx +15 -24
  198. package/src/fields/Icon/Icon.stories.tsx +1 -1
  199. package/src/fields/Icon/index.tsx +1 -0
  200. package/src/fields/Input/index.tsx +180 -81
  201. package/src/fields/MentionInput/index.tsx +9 -1
  202. package/src/fields/OTPInput/OtpInput.spec.tsx +36 -4
  203. package/src/fields/OTPInput/index.tsx +18 -3
  204. package/src/fields/OTPInput/otpCellHeightTouch.spec.ts +44 -0
  205. package/src/fields/PhoneInput/index.tsx +1 -0
  206. package/src/fields/SearchInput/index.tsx +5 -3
  207. package/src/shared/utils.spec.ts +46 -0
  208. package/src/shared/utils.ts +31 -16
  209. package/types/Button/index.d.ts +8 -3
  210. package/types/Button/index.d.ts.map +1 -1
  211. package/types/ContextualSaveBar/UnsavedChangesDialog.d.ts.map +1 -1
  212. package/types/FieldDisplay/index.d.ts.map +1 -1
  213. package/types/InputParts/index.d.ts +9 -5
  214. package/types/InputParts/index.d.ts.map +1 -1
  215. package/types/fields/Barcode/BarcodePreview.d.ts +20 -9
  216. package/types/fields/Barcode/BarcodePreview.d.ts.map +1 -1
  217. package/types/fields/Checkbox/index.d.ts +3 -3
  218. package/types/fields/Combobox/index.d.ts.map +1 -1
  219. package/types/fields/FileUpload/index.d.ts.map +1 -1
  220. package/types/fields/Geolocation/geoValue.d.ts +45 -0
  221. package/types/fields/Geolocation/geoValue.d.ts.map +1 -0
  222. package/types/fields/Geolocation/index.d.ts.map +1 -1
  223. package/types/fields/Icon/index.d.ts.map +1 -1
  224. package/types/fields/Input/index.d.ts +18 -6
  225. package/types/fields/Input/index.d.ts.map +1 -1
  226. package/types/fields/MentionInput/index.d.ts.map +1 -1
  227. package/types/fields/OTPInput/index.d.ts +12 -0
  228. package/types/fields/OTPInput/index.d.ts.map +1 -1
  229. package/types/fields/PhoneInput/index.d.ts.map +1 -1
  230. package/types/fields/RadioGroup/index.d.ts +2 -2
  231. package/types/fields/SearchInput/index.d.ts.map +1 -1
  232. package/types/fields/ToggleGroup/index.d.ts +1 -1
  233. package/types/shared/utils.d.ts.map +1 -1
@@ -119,11 +119,20 @@ describe("FieldDisplay", () => {
119
119
  expect(root?.getAttribute("data-field")).toBe("datepicker");
120
120
  });
121
121
 
122
- it("draws a QR matrix when barcode fieldProps.formats starts with qr", () => {
122
+ /**
123
+ * The qr format reaches BarcodePreview (the round trip is asserted there),
124
+ * but a display cell's bound is the glyph size — around 20px — and a QR
125
+ * needs one WHOLE pixel per module, 29 minimum. So the cell draws no matrix
126
+ * and, because it already prints the value beside the symbol, contributes
127
+ * nothing rather than a second copy of the string (textFallback={false}).
128
+ * Growing the row to fit a scannable square is the thing LC-06 forbids.
129
+ */
130
+ it("prints the barcode value once and draws no unreadable QR at a display bound", () => {
123
131
  const result = renderWithProviders(
124
132
  <FieldDisplay field="barcode" value="SKU-001234" fieldProps={{ formats: ["qr"] }} />,
125
133
  );
126
- expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).not.toBeNull();
134
+ expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).toBeNull();
127
135
  expect(result.container.querySelector("canvas")).toBeNull();
136
+ expect(result.container.textContent?.split("SKU-001234").length).toBe(2); // exactly once
128
137
  });
129
138
  });
@@ -563,11 +563,16 @@ function BarcodeDisplay({ value, fieldProps }: { value: any; fieldProps?: Record
563
563
  return (
564
564
  <XStack alignItems="center" minWidth={0} flexShrink={1} {...knobProps.gap}>
565
565
  <ValueText mono>{text}</ValueText>
566
+ {/* The value is already printed beside the symbol, so an undrawable one
567
+ contributes NOTHING rather than a second copy of the string. At this
568
+ bound that is every QR past a short value at a large size — a display
569
+ cell cannot hold 33 whole-pixel modules without growing the row. */}
566
570
  <BarcodePreview
567
571
  value={text}
568
572
  height={Math.max(12, glyphSize)}
569
573
  moduleWidth={1}
570
574
  format={typeof format === "string" ? format : undefined}
575
+ textFallback={false}
571
576
  />
572
577
  </XStack>
573
578
  );
@@ -607,6 +607,18 @@ function FloatingPanelWeb({
607
607
  // animation=none → no CSS tween (same contract as knobProps.transition === undefined)
608
608
  const webTransition = panel.knobProps.transition ? panelTransition : "none";
609
609
 
610
+ // `elevatedSurface` carries a web-only `style: { boxShadow }` (MPO-69,
611
+ // d326cc0c2). Two `style` props on one element do NOT merge — the later
612
+ // spread replaces the earlier — so spreading the complete fragment after the
613
+ // positioning style silently dropped `position: fixed`, `top`, `left`,
614
+ // `zIndex` and `overflow`, and every web FloatingPanel rendered in normal
615
+ // flow, pushing the page down instead of floating over it. Measured on
616
+ // origin/main 048289071: DatePicker's panel sat at trigger.bottom + 5.39 (the
617
+ // field container's flex gap), not the contract's 0.
618
+ // The complete fragment still goes on the element (AGENTS.md) — its style is
619
+ // folded into the positioning style rather than competing with it.
620
+ const { style: elevatedSurfaceStyle, ...elevatedSurfaceProps } = panel.knobProps.elevatedSurface;
621
+
610
622
  // Same slot problem as the sheet branch, but only half of it lands here. The
611
623
  // POINTER path survives, because `useClick(..., { event: "mousedown" })`
612
624
  // listens on mousedown and Tamagui only stops the click. The KEYBOARD path
@@ -650,6 +662,13 @@ function FloatingPanelWeb({
650
662
  })}
651
663
  cursor={disabled ? "not-allowed" : "pointer"}
652
664
  opacity={disabled ? 0.5 : 1}
665
+ // The cover contract is stated as a relation between the panel's top
666
+ // edge and THIS element's bottom edge — the box floating-ui anchors
667
+ // to, which is the whole trigger, not whatever glyph inside it was
668
+ // clicked. Marking it keeps the measurement unambiguous (a ColorPicker
669
+ // read from its swatch measures the swatch's inset, not the gap).
670
+ // Same idiom TreeSelect already uses for `data-fp-panel`.
671
+ data-fp-reference=""
653
672
  >
654
673
  {trigger}
655
674
  </View>
@@ -659,6 +678,7 @@ function FloatingPanelWeb({
659
678
  {...getFloatingProps({
660
679
  ref: panel.refs.setFloating,
661
680
  style: {
681
+ ...elevatedSurfaceStyle,
662
682
  position: panel.strategy,
663
683
  top: overlayStyleRef.current?.top ?? panel.y ?? 0,
664
684
  left: panel.x ?? 0,
@@ -675,9 +695,10 @@ function FloatingPanelWeb({
675
695
  onScroll: handleScroll,
676
696
  })}
677
697
  data-testid="floating-panel-viewport"
698
+ data-fp-panel=""
678
699
  {...panelChromeFocus}
679
700
  pointerEvents={panel.visible ? "auto" : "none"}
680
- {...panel.knobProps.elevatedSurface}
701
+ {...elevatedSurfaceProps}
681
702
  // Complete elevatedSurface fragment above is the ONLY surface source
682
703
  // (SF-OVERLAY "including border and elevation", Round 2 docket).
683
704
  // The one layered exception is anatomy, not surface: the dropdown
@@ -27,6 +27,7 @@ const fieldShapedPickers = [
27
27
  "DatePicker/DatetimePicker.tsx",
28
28
  "TimePicker/index.tsx",
29
29
  "ColorPicker/parts.tsx",
30
+ "Icon/index.tsx",
30
31
  ];
31
32
 
32
33
  /** Panels opened from an icon/button trigger, where fit-content is correct. */
@@ -819,6 +819,7 @@ const InputAreaImpl = InputFrame.styleable<{
819
819
  {...knobProps.body}
820
820
  type={secureTextEntry ? "password" : props.type}
821
821
  padding={0}
822
+ // size-recipe-escape: zero-reset — Input.Box owns the inset (LC-71); the inner field paints no padding
822
823
  paddingHorizontal={0}
823
824
  paddingVertical={0}
824
825
  keyboardType={resolvedKeyboardType}
@@ -907,24 +908,115 @@ const InputButton = styled(TButton, {
907
908
  } as const,
908
909
  });
909
910
 
910
- const InputButtonImpl = InputButton.styleable((buttonProps, ref) => {
911
+ /**
912
+ * LC-71 RING-ANATOMY / LC-49 ONE-TAB-STOP — the `glyphRing` end cap.
913
+ *
914
+ * A bare Input.Button is DECORATIVE: the Box rings for it and it takes no
915
+ * tab stop of its own (Stepper carets, Select/Combobox chevrons). Pass
916
+ * `glyphRing` to promote it to the field's SECOND focusable — the Password
917
+ * reveal, the CopyField copy — where it must ring its own GLYPH, not the
918
+ * 44px hit box. A band on the box would double with the Box's composite
919
+ * ring; a band on the glyph is the chip-dismiss recipe
920
+ * (`.mp-chip-dismiss` + `.mp-chip-dismiss-ring`, keyboardFocusRing.ts).
921
+ *
922
+ * It renders a View rather than TButton because TButton swallows a consumer
923
+ * className and pins its own tabIndex, so the ring class never reaches the
924
+ * node that actually receives focus. SearchInput hand-rolled exactly this
925
+ * pair for that reason (fields/SearchInput/index.tsx:348-380) and left a
926
+ * note saying the classes "cannot ride Input.Button until glyphRing" lands.
927
+ * This is that pair made reusable, so the square comes from the size recipe
928
+ * (LC-70) instead of every call site measuring its own.
929
+ */
930
+ const InputGlyphButton = styled(View, {
931
+ name: "InputGlyphButton",
932
+ context: InputContext,
933
+ justifyContent: "center",
934
+ alignItems: "center",
935
+ backgroundColor: "transparent",
936
+ borderWidth: 0,
937
+ borderRadius: 0,
938
+ cursor: "pointer",
939
+ flexShrink: 0,
940
+ outlineWidth: 0,
941
+ focusStyle: { outlineWidth: 0 },
942
+ focusVisibleStyle: { outlineWidth: 0 },
943
+
944
+ variants: {
945
+ scaleIcon: {
946
+ ":number": {} as any,
947
+ },
948
+ size: {
949
+ ...sizeRecipeEndCapVariants,
950
+ "...size": sizeRecipeEndCapFallback,
951
+ },
952
+ } as const,
953
+ });
954
+
955
+ const InputButtonImpl = InputButton.styleable<{ glyphRing?: boolean }>((buttonProps, ref) => {
911
956
  const {
912
957
  children,
913
958
  color: colorProp,
959
+ glyphRing,
960
+ size: sizeProp,
961
+ tabIndex: tabIndexProp,
962
+ className,
914
963
  ...props
915
964
  } = buttonProps as typeof buttonProps & {
916
965
  color?: string;
966
+ glyphRing?: boolean;
967
+ size?: FontSizeTokens;
968
+ tabIndex?: number;
969
+ className?: string;
917
970
  };
918
- const { size, color: contextColor } = InputContext.useStyledContext();
971
+ const { size: contextSize, color: contextColor } = InputContext.useStyledContext();
919
972
  const { knobProps } = useResolvedKnobs();
920
973
  const theme = useTheme();
921
- const square = knobProps.control.height;
974
+ const size = (sizeProp ?? contextSize) as FontSizeTokens;
922
975
  const paint = resolveGlyphPaint(theme as never, colorProp ?? contextColor);
923
976
  const getThemedIcon = useGetThemedIcon({
924
977
  size: knobProps.controlIcon.width,
925
978
  color: paint as never,
926
979
  });
927
980
 
981
+ if (glyphRing) {
982
+ // A real tab stop must square off the token it was HANDED, not the
983
+ // knob's: a $2 field inside a $4 knob would otherwise ship a 44px stop
984
+ // on a 32px frame. LC-77 keeps the sub-floor case pressable via the
985
+ // caller's hitSlop, never by inflating the cap.
986
+ const square = sizeRecipeForToken(String(size ?? "$true"), { touch: isTouchSurface() }).height;
987
+ const disabled = (props as { disabled?: boolean }).disabled === true;
988
+ if (isWeb) ensureCompositeFocusRing();
989
+ return (
990
+ <InputGlyphButton
991
+ ref={ref as never}
992
+ role="button"
993
+ size={size}
994
+ height={square}
995
+ width={square}
996
+ paddingHorizontal={0} // size-recipe-escape: square end-cap; pad lives on Input.Box
997
+ opacity={disabled ? 0.5 : 1}
998
+ {...props}
999
+ aria-disabled={disabled || undefined}
1000
+ tabIndex={tabIndexProp ?? (disabled ? -1 : 0)}
1001
+ className={
1002
+ isWeb
1003
+ ? ["mp-chip-dismiss", className].filter(Boolean).join(" ")
1004
+ : (className ?? undefined)
1005
+ }
1006
+ data-end-cap={square}
1007
+ >
1008
+ <View
1009
+ className={isWeb ? "mp-chip-dismiss-ring" : undefined}
1010
+ alignItems="center"
1011
+ justifyContent="center"
1012
+ >
1013
+ {children}
1014
+ </View>
1015
+ </InputGlyphButton>
1016
+ );
1017
+ }
1018
+
1019
+ const square = knobProps.control.height;
928
1020
  return (
929
1021
  <InputButton
930
1022
  ref={ref}
@@ -933,7 +1025,12 @@ const InputButtonImpl = InputButton.styleable((buttonProps, ref) => {
933
1025
  width={square}
934
1026
  paddingHorizontal={0} // size-recipe-escape: square end-cap; pad lives on Input.Box
935
1027
  color={paint}
1028
+ // LC-49: the decorative cap is not a tab stop. tabIndex alone is not
1029
+ // enough — TButton re-derives focusability and stamps tabindex="0".
1030
+ focusable={false}
936
1031
  tabIndex={-1}
1032
+ className={className}
1033
+ data-end-cap={square}
937
1034
  {...props}
938
1035
  >
939
1036
  {getThemedIcon(children)}
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import { renderWithProviders } from "@multiplatform.one/test-utils";
3
3
  import { BarcodePreview, barcodePreviewSymbology } from "./BarcodePreview";
4
4
  import { countModules, encodeCode128B } from "./code128";
5
- import { encodeQr } from "./qr";
5
+ import { QR_QUIET_ZONE_MODULES, encodeQr } from "./qr";
6
6
  import { resolveHtml5FormatIds, resolveNativeBarcodeTypes } from "./scannerTypes";
7
7
 
8
8
  /** Rebuild the module string ("1" = bar) from the encoder's runs. */
@@ -101,8 +101,27 @@ describe("barcodePreviewSymbology — scanner round trip", () => {
101
101
  });
102
102
 
103
103
  describe("BarcodePreview — qr format", () => {
104
+ /** The field's own default bound (Barcode/index.tsx): 36 comfortable, 24 compact. */
105
+ const formHeight = 36;
106
+
107
+ it("QR modules paint $color on $background (themed polarity; MPO-94 is not this lane)", () => {
108
+ const result = renderWithProviders(<BarcodePreview value="SHC-42" format="qr" height={120} />);
109
+ const square = result.container.querySelector(
110
+ '[data-testid="barcode-preview-qr"]',
111
+ ) as HTMLElement | null;
112
+ expect(square).not.toBeNull();
113
+ expect(square!.className).toContain("_bg-background");
114
+ const darkModule = [...square!.querySelectorAll("*")].find((el) =>
115
+ (el as HTMLElement).className?.toString().includes("_bg-color"),
116
+ ) as HTMLElement | undefined;
117
+ expect(darkModule).toBeTruthy();
118
+ expect(darkModule!.className).not.toMatch(/_bg-(?:white|black|color12)\b/);
119
+ });
120
+
104
121
  it("draws the QR matrix canvas-free (labeled image, real module rows)", () => {
105
- const result = renderWithProviders(<BarcodePreview value="SKU-001234" format="qr" />);
122
+ const result = renderWithProviders(
123
+ <BarcodePreview value="SKU-001234" format="qr" height={formHeight} />,
124
+ );
106
125
  const preview = result.container.querySelector('[data-testid="barcode-preview"]');
107
126
  expect(preview).not.toBeNull();
108
127
  expect(preview?.getAttribute("aria-label")).toContain("SKU-001234");
@@ -114,12 +133,16 @@ describe("BarcodePreview — qr format", () => {
114
133
  });
115
134
 
116
135
  it("draws QR for the Frappe fieldtype's qrcode token via the shared alias table", () => {
117
- const result = renderWithProviders(<BarcodePreview value="SKU-001234" format="qrcode" />);
136
+ const result = renderWithProviders(
137
+ <BarcodePreview value="SKU-001234" format="qrcode" height={formHeight} />,
138
+ );
118
139
  expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).not.toBeNull();
119
140
  });
120
141
 
121
142
  it("draws values the 1-D strip cannot encode (the closed asymmetry)", () => {
122
- const result = renderWithProviders(<BarcodePreview value="héllo" format="qr" />);
143
+ const result = renderWithProviders(
144
+ <BarcodePreview value="héllo" format="qr" height={formHeight} />,
145
+ );
123
146
  expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).not.toBeNull();
124
147
  });
125
148
 
@@ -130,7 +153,9 @@ describe("BarcodePreview — qr format", () => {
130
153
 
131
154
  it("falls back to honest mono text past version 10 capacity", () => {
132
155
  const long = "x".repeat(214);
133
- const result = renderWithProviders(<BarcodePreview value={long} format="qr" />);
156
+ const result = renderWithProviders(
157
+ <BarcodePreview value={long} format="qr" height={formHeight} />,
158
+ );
134
159
  expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).toBeNull();
135
160
  expect(result.container.textContent).toContain(long);
136
161
  });
@@ -141,3 +166,126 @@ describe("BarcodePreview — qr format", () => {
141
166
  expect(result.container.querySelector('[data-testid="barcode-preview"]')).not.toBeNull();
142
167
  });
143
168
  });
169
+
170
+ /**
171
+ * Whole-pixel modules. Measured on the media-11 board at DPR 2 BEFORE this
172
+ * rule existed: bounds 33, 72 and 96 decoded and bounds 18, 24, 44 and 56 did
173
+ * not — not a size floor (33 read, 44 did not) but a resampling one. 33/33
174
+ * modules is exactly 1.000 px per module; 44/33 is 1.333, and a fractional
175
+ * module rounds to different widths across the grid. It reached the field's
176
+ * own default path: at height 36 / moduleWidth 1 a payload over 26 bytes
177
+ * clamps under one pixel per module and stops reading, which is precisely the
178
+ * scan-to-draw round trip MPO-53 AC-9 exists to close.
179
+ */
180
+ describe("BarcodePreview — qr geometry is whole-pixel or nothing", () => {
181
+ const qrSquare = (ui: React.ReactElement) =>
182
+ renderWithProviders(ui).container.querySelector(
183
+ '[data-testid="barcode-preview-qr"]',
184
+ ) as HTMLElement | null;
185
+
186
+ /**
187
+ * Tamagui compiles geometry to atomic classes (`_w-87px`, `_pt-12px`), not
188
+ * inline style, so the measurement reads the class list. Same numbers, and
189
+ * it is the class the browser actually applies.
190
+ */
191
+ const px = (el: HTMLElement, prefix: string) => {
192
+ const hit = (el.getAttribute("class") ?? "")
193
+ .split(/\s+/)
194
+ .find((c) => c.startsWith(`_${prefix}-`));
195
+ return hit ? Number.parseFloat(hit.slice(prefix.length + 2)) : Number.NaN;
196
+ };
197
+ const edgeOf = (el: HTMLElement) => px(el, "w");
198
+ const quietOf = (el: HTMLElement) => px(el, "pt");
199
+
200
+ /** Modules per side including the 4-module quiet zone on each side. */
201
+ const totalModules = (value: string) => encodeQr(value)!.size + 2 * QR_QUIET_ZONE_MODULES;
202
+
203
+ it("draws at the natural size when moduleWidth fits under the bound", () => {
204
+ // 6 bytes → version 1 → 21 + 8 = 29 modules. moduleWidth 3 asks for 3px
205
+ // modules and the 120px bound allows 4, so 3 wins: 29 × 3 = 87.
206
+ const value = "SHC-42";
207
+ expect(totalModules(value)).toBe(29);
208
+ const square = qrSquare(
209
+ <BarcodePreview value={value} format="qr" height={120} moduleWidth={3} />,
210
+ );
211
+ expect(square).not.toBeNull();
212
+ expect(edgeOf(square!)).toBe(87);
213
+ expect(px(square!, "h")).toBe(87);
214
+ expect(quietOf(square!)).toBe(12); // 4 modules × 3px
215
+ });
216
+
217
+ it("treats the height bound as a CEILING, never a target", () => {
218
+ // 18 bytes → version 2 → 25 + 8 = 33 modules at moduleWidth 1 = 33px. A
219
+ // 44px bound does not stretch it to 44 and a fractional 1.333px module.
220
+ const value = "SHC-COMPOSE01-0417";
221
+ expect(totalModules(value)).toBe(33);
222
+ for (const height of [36, 44, 56]) {
223
+ const square = qrSquare(<BarcodePreview value={value} format="qr" height={height} />);
224
+ expect(edgeOf(square!)).toBe(33);
225
+ expect(quietOf(square!)).toBe(4);
226
+ }
227
+ });
228
+
229
+ it("snaps the module DOWN to a whole pixel rather than draw a fractional one", () => {
230
+ // 33 modules under a 96px bound would be 2.909px per module. Snap to 2.
231
+ const square = qrSquare(
232
+ <BarcodePreview value="SHC-COMPOSE01-0417" format="qr" height={96} moduleWidth={9} />,
233
+ );
234
+ expect(edgeOf(square!)).toBe(66);
235
+ expect(quietOf(square!)).toBe(8);
236
+ });
237
+
238
+ it("never draws a module narrower than one whole pixel — every bound, every payload", () => {
239
+ for (const value of ["SHC-42", "SHC-COMPOSE01-0417", "SHC-COMPOSE01-BACKUP-2026-08-28"]) {
240
+ for (const height of [18, 24, 29, 33, 36, 37, 44, 66, 96, 120]) {
241
+ for (const moduleWidth of [1, 2, 3]) {
242
+ const square = qrSquare(
243
+ <BarcodePreview value={value} format="qr" height={height} moduleWidth={moduleWidth} />,
244
+ );
245
+ if (!square) continue; // honest text fallback — asserted below
246
+ const edge = edgeOf(square);
247
+ const pad = quietOf(square);
248
+ const cell = edge / totalModules(value);
249
+ expect(Number.isInteger(cell)).toBe(true);
250
+ expect(cell).toBeGreaterThanOrEqual(1);
251
+ expect(cell).toBeLessThanOrEqual(moduleWidth);
252
+ expect(edge).toBeLessThanOrEqual(height); // LC-10 containment
253
+ expect(pad).toBe(cell * QR_QUIET_ZONE_MODULES);
254
+ }
255
+ }
256
+ }
257
+ });
258
+
259
+ it("falls back to honest text under one whole pixel per module", () => {
260
+ // compact (24) cannot hold 33 modules at a whole pixel each.
261
+ const value = "SHC-COMPOSE01-0417";
262
+ const result = renderWithProviders(<BarcodePreview value={value} format="qr" height={24} />);
263
+ expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).toBeNull();
264
+ expect(result.container.textContent).toContain(value);
265
+ });
266
+
267
+ it("falls back to honest text when the payload outgrows the form bound", () => {
268
+ // 31 bytes → version 3 → 29 + 8 = 37 modules, which does not fit 36px at
269
+ // one whole pixel each. This is the case that silently shipped unreadable.
270
+ const value = "SHC-COMPOSE01-BACKUP-2026-08-28";
271
+ expect(totalModules(value)).toBe(37);
272
+ const result = renderWithProviders(<BarcodePreview value={value} format="qr" height={36} />);
273
+ expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).toBeNull();
274
+ expect(result.container.textContent).toContain(value);
275
+ });
276
+
277
+ it("draws nothing at all when the host already shows the value (textFallback false)", () => {
278
+ const value = "SHC-COMPOSE01-0417";
279
+ const result = renderWithProviders(
280
+ <BarcodePreview value={value} format="qr" height={24} textFallback={false} />,
281
+ );
282
+ expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).toBeNull();
283
+ expect(result.container.textContent).not.toContain(value);
284
+ });
285
+
286
+ it("leaves the 1-D strip's geometry untouched", () => {
287
+ const result = renderWithProviders(<BarcodePreview value="SKU-001234" height={18} />);
288
+ expect(result.container.querySelector('[data-testid="barcode-preview"]')).not.toBeNull();
289
+ expect(result.container.querySelector('[data-testid="barcode-preview-qr"]')).toBeNull();
290
+ });
291
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * BarcodePreview — the drawn face of the barcode field, not the input.
3
+ * Barcode.stories.tsx mounts the field; this file mounts the preview alone
4
+ * so Kitchen Sink can show QR and Code 128 without a scanner.
5
+ *
6
+ * QR polarity is themed ($color on $background). MPO-94 is Clay's call;
7
+ * these stories do not pin dark-on-light.
8
+ */
9
+
10
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
11
+ import { Text, XStack, YStack } from "tamagui";
12
+ import { BarcodePreview } from "./BarcodePreview";
13
+
14
+ const meta: Meta<typeof BarcodePreview> = {
15
+ title: "Forms/BarcodePreview",
16
+ component: BarcodePreview,
17
+ parameters: {
18
+ status: { type: "stable" },
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "Canvas-free barcode/QR preview. Theme ink on theme surface in both schemes. MPO-94 (pin vs themed QR polarity) is not taken here.",
23
+ },
24
+ },
25
+ },
26
+ argTypes: {
27
+ value: { control: "text" },
28
+ format: { control: "select", options: ["code128", "qr", "qrcode"] },
29
+ height: { control: "number" },
30
+ moduleWidth: { control: "number" },
31
+ showText: { control: "boolean" },
32
+ textFallback: { control: "boolean" },
33
+ },
34
+ };
35
+
36
+ export default meta;
37
+ type Story = StoryObj<typeof BarcodePreview>;
38
+
39
+ export const Main: Story = {
40
+ name: "Main",
41
+ args: {
42
+ value: "SKU-001234",
43
+ height: 36,
44
+ showText: true,
45
+ },
46
+ };
47
+
48
+ export const Code128: Story = {
49
+ args: {
50
+ value: "SKU-001234",
51
+ format: "code128",
52
+ height: 36,
53
+ showText: true,
54
+ },
55
+ };
56
+
57
+ /**
58
+ * QR face (MPO-53 AC-9). Height 120 so a short payload draws at a whole
59
+ * pixel per module. Polarity stays themed — MPO-94 is not this lane.
60
+ */
61
+ export const Qr: Story = {
62
+ args: {
63
+ value: "SHC-42",
64
+ format: "qr",
65
+ height: 120,
66
+ showText: true,
67
+ },
68
+ };
69
+
70
+ /** Host owns the empty marker; the preview paints nothing. */
71
+ export const Empty: Story = {
72
+ render: () => (
73
+ <YStack gap="$2">
74
+ <Text fontSize="$2" color="$color10">
75
+ empty value — preview is null
76
+ </Text>
77
+ <BarcodePreview value="" format="qr" height={72} />
78
+ </YStack>
79
+ ),
80
+ };
81
+
82
+ /**
83
+ * Themed polarity specimen. Both faces paint $color on $background, so a
84
+ * scheme flip restyles them with the rest of the screen. Do not treat this
85
+ * as a pin-to-dark-on-light preview; that decision lives on MPO-94.
86
+ */
87
+ export const ThemedPolarity: Story = {
88
+ render: () => (
89
+ <XStack gap="$6" flexWrap="wrap" padding="$4" alignItems="flex-end">
90
+ <YStack gap="$2" alignItems="center">
91
+ <Text fontSize="$2" color="$color10">
92
+ QR (themed)
93
+ </Text>
94
+ <BarcodePreview value="SHC-42" format="qr" height={120} showText />
95
+ </YStack>
96
+ <YStack gap="$2" alignItems="center">
97
+ <Text fontSize="$2" color="$color10">
98
+ Code 128 (themed)
99
+ </Text>
100
+ <BarcodePreview value="SKU-001234" format="code128" height={36} showText />
101
+ </YStack>
102
+ </XStack>
103
+ ),
104
+ };
@@ -11,15 +11,16 @@
11
11
  *
12
12
  * Containment contract (LC-10): the symbol never exceeds its host. The strip
13
13
  * caps width at 100% and bars shrink proportionally (preserving relative
14
- * module widths) instead of painting past the frame; the QR square renders at
15
- * its natural module size and shrinks uniformly once the `height` bound
16
- * clamps it — always square, never letterboxed or clipped. Hosts bound the
17
- * height via the `height` prop (table cells keep it under the row height).
14
+ * module widths) instead of painting past the frame; the QR square is drawn at
15
+ * a WHOLE number of pixels per module, capped by `moduleWidth` and by the
16
+ * `height` bound — always square, never letterboxed or clipped. Hosts bound
17
+ * the height via the `height` prop (table cells keep it under the row height).
18
18
  *
19
19
  * Honest states: an empty value renders nothing (hosts show their own empty
20
20
  * marker); a value the symbology cannot encode (non-ASCII for Code 128 B,
21
- * over version 10 capacity for QR) falls back to the raw value as mono text —
22
- * never a fake or truncated symbol.
21
+ * over version 10 capacity for QR), or a QR whose modules would be under one
22
+ * whole pixel at the host's bound, falls back to the raw value as mono text —
23
+ * never a fake, truncated or unreadable symbol.
23
24
  */
24
25
 
25
26
  import { Text, View, XStack, YStack } from "tamagui";
@@ -44,10 +45,11 @@ export interface BarcodePreviewProps {
44
45
  value?: string;
45
46
  /**
46
47
  * Symbol height bound in px — content geometry, bounded by the host. The
47
- * 1-D strip always fills it; the QR square uses it as a maximum edge.
48
+ * 1-D strip always fills it; the QR square uses it as a CEILING and draws at
49
+ * the largest whole-pixel module that fits under it.
48
50
  */
49
51
  height?: number;
50
- /** Width of one barcode module in px. */
52
+ /** Width of one barcode module in px. Fractional values floor for QR. */
51
53
  moduleWidth?: number;
52
54
  /** Render the human-readable value under the symbol. */
53
55
  showText?: boolean;
@@ -56,6 +58,15 @@ export interface BarcodePreviewProps {
56
58
  * alias of `qr` draws the QR matrix; others draw the Code 128 strip.
57
59
  */
58
60
  format?: string;
61
+ /**
62
+ * Render the raw value as mono text when the symbology cannot draw it —
63
+ * non-ASCII for Code 128 B, over capacity or under one whole pixel per
64
+ * module for QR. Default true, which is right for a standalone preview:
65
+ * disappearing silently would be worse than an honest string. A host that
66
+ * ALREADY shows the value beside the symbol (FieldDisplay) passes false, so
67
+ * an undrawable symbol contributes nothing instead of printing it twice.
68
+ */
69
+ textFallback?: boolean;
59
70
  }
60
71
 
61
72
  /** Mono value text (LC-07 CODE-KNOB-IMMUNE) — both the honest fallback for
@@ -74,15 +85,22 @@ export function BarcodePreview({
74
85
  moduleWidth = 1,
75
86
  showText = false,
76
87
  format,
88
+ textFallback = true,
77
89
  }: BarcodePreviewProps) {
78
90
  if (!value) return null;
79
91
  if (barcodePreviewSymbology(format) === "qr") {
80
92
  return (
81
- <QrPreview value={value} height={height} moduleWidth={moduleWidth} showText={showText} />
93
+ <QrPreview
94
+ value={value}
95
+ height={height}
96
+ moduleWidth={moduleWidth}
97
+ showText={showText}
98
+ textFallback={textFallback}
99
+ />
82
100
  );
83
101
  }
84
102
  const runs = encodeCode128B(value);
85
- if (!runs) return <MonoValue value={value} />;
103
+ if (!runs) return textFallback ? <MonoValue value={value} /> : null;
86
104
  const quietZone = moduleWidth * 10;
87
105
  return (
88
106
  <View
@@ -126,19 +144,33 @@ function QrPreview({
126
144
  height,
127
145
  moduleWidth,
128
146
  showText,
147
+ textFallback,
129
148
  }: {
130
149
  value: string;
131
150
  height: number;
132
151
  moduleWidth: number;
133
152
  showText: boolean;
153
+ textFallback: boolean;
134
154
  }) {
135
155
  const matrix = encodeQr(value);
136
- if (!matrix) return <MonoValue value={value} />;
137
- // Natural module size until the host's height bound clamps the square
138
- // then every module shrinks uniformly so the symbol stays square (LC-10).
156
+ if (!matrix) return textFallback ? <MonoValue value={value} /> : null;
157
+ // A module must land on a WHOLE pixel. A fractional module width rounds to
158
+ // different widths across the grid, so a decoder cannot resample the symbol
159
+ // measured on the media-11 board at DPR 2: the 33px square (1.000 px/module)
160
+ // decoded and the 44px one (1.333 px/module) did not, in either scheme. So
161
+ // the module is the largest whole pixel that fits, capped by `moduleWidth`
162
+ // and by the host's `height` bound (LC-10 — the square never exceeds its
163
+ // host, and the bound is a ceiling, never a target).
139
164
  const totalModules = matrix.size + 2 * QR_QUIET_ZONE_MODULES;
140
- const edge = Math.min(totalModules * moduleWidth, height);
141
- const cell = edge / totalModules;
165
+ const cell = Math.floor(Math.min(moduleWidth, height / totalModules));
166
+ // Under one whole pixel there is no readable symbol to draw at all. Fall
167
+ // back to the value as text, the same honest contract the over-capacity and
168
+ // non-ASCII cases have: a symbol no reader can resample is decoration, and
169
+ // decoration that looks like data is the one thing this component refuses to
170
+ // paint. This is what a `compact` bound (24) or a table-row bound does to
171
+ // anything past a short SKU.
172
+ if (cell < 1) return textFallback ? <MonoValue value={value} /> : null;
173
+ const edge = cell * totalModules;
142
174
  const quietZone = cell * QR_QUIET_ZONE_MODULES;
143
175
  const rows = qrRowRuns(matrix);
144
176
  return (