@object-ui/fields 17.3.0 → 17.6.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 (265) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +23 -3
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +35 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +651 -0
  8. package/dist/index.d.ts +474 -85
  9. package/dist/index.js +5418 -5023
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AutoNumberField.d.ts +1 -1
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/BooleanField.d.ts +1 -1
  14. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  15. package/dist/widgets/CheckboxesField.d.ts +1 -1
  16. package/dist/widgets/CodeField.d.ts +1 -1
  17. package/dist/widgets/ColorField.d.ts +1 -1
  18. package/dist/widgets/CurrencyField.d.ts +1 -1
  19. package/dist/widgets/DateField.d.ts +1 -1
  20. package/dist/widgets/DateTimeField.d.ts +1 -1
  21. package/dist/widgets/EmailField.d.ts +1 -1
  22. package/dist/widgets/FileField.d.ts +18 -2
  23. package/dist/widgets/FilterConditionField.d.ts +28 -1
  24. package/dist/widgets/FormulaField.d.ts +1 -1
  25. package/dist/widgets/FullscreenFieldEditor.d.ts +49 -152
  26. package/dist/widgets/GeolocationField.d.ts +1 -1
  27. package/dist/widgets/GridField.d.ts +68 -6
  28. package/dist/widgets/ImageField.d.ts +1 -1
  29. package/dist/widgets/LocationField.d.ts +1 -1
  30. package/dist/widgets/LookupField.d.ts +1 -1
  31. package/dist/widgets/MasterDetailField.d.ts +1 -1
  32. package/dist/widgets/MultiSelectField.d.ts +1 -1
  33. package/dist/widgets/NumberField.d.ts +1 -1
  34. package/dist/widgets/ObjectField.d.ts +1 -1
  35. package/dist/widgets/ObjectRefField.d.ts +1 -1
  36. package/dist/widgets/OptionsEmptyState.d.ts +21 -1
  37. package/dist/widgets/PasswordField.d.ts +1 -1
  38. package/dist/widgets/PercentField.d.ts +1 -1
  39. package/dist/widgets/PhoneField.d.ts +1 -1
  40. package/dist/widgets/QRCodeField.d.ts +1 -1
  41. package/dist/widgets/RadioField.d.ts +1 -1
  42. package/dist/widgets/RatingField.d.ts +1 -1
  43. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  44. package/dist/widgets/RecordPickerDialog.d.ts +34 -10
  45. package/dist/widgets/RichTextField.d.ts +37 -1
  46. package/dist/widgets/SelectField.d.ts +26 -6
  47. package/dist/widgets/SignatureField.d.ts +32 -2
  48. package/dist/widgets/SliderField.d.ts +37 -2
  49. package/dist/widgets/SummaryField.d.ts +1 -1
  50. package/dist/widgets/TagsField.d.ts +1 -1
  51. package/dist/widgets/TextAreaField.d.ts +6 -2
  52. package/dist/widgets/TextField.d.ts +1 -1
  53. package/dist/widgets/TimeField.d.ts +1 -1
  54. package/dist/widgets/UrlField.d.ts +1 -1
  55. package/dist/widgets/UserField.d.ts +1 -1
  56. package/dist/widgets/VectorField.d.ts +1 -1
  57. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  58. package/dist/widgets/address-format.d.ts +88 -0
  59. package/dist/widgets/file-size-guard.d.ts +41 -0
  60. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  61. package/dist/widgets/nativeDateValue.d.ts +37 -0
  62. package/dist/widgets/richTextDisplay.d.ts +80 -0
  63. package/dist/widgets/toDomProps.d.ts +30 -10
  64. package/dist/widgets/toHostGroupProps.d.ts +189 -0
  65. package/dist/widgets/types.d.ts +8 -3
  66. package/dist/widgets/useRecordQuery.d.ts +7 -1
  67. package/package.json +17 -14
  68. package/dist/FieldEditWidget.test.d.ts +0 -8
  69. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  70. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  71. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  72. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  73. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  74. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  75. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  76. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  77. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  78. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  79. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  80. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  81. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  82. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  83. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  84. package/dist/coerce-safe-value.test.d.ts +0 -1
  85. package/dist/complex-widgets.test.d.ts +0 -1
  86. package/dist/datetime-widgets.test.d.ts +0 -1
  87. package/dist/field-type-coverage.test.d.ts +0 -1
  88. package/dist/standard-widgets.test.d.ts +0 -0
  89. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  90. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  91. package/dist/widgets/CharacterCount.d.ts +0 -56
  92. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  93. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  94. package/dist/widgets/GridField.test.d.ts +0 -1
  95. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  96. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  97. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  98. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  99. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  100. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  101. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  102. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  103. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  104. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  105. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  106. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  107. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  108. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  109. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  110. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  111. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  112. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  113. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  114. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  115. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  116. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  117. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  118. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  119. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  120. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  121. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  122. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  123. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  124. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  125. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  126. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  127. package/dist/widgets/file-value.test.d.ts +0 -1
  128. package/dist/widgets/personDisplay.test.d.ts +0 -8
  129. package/dist/widgets/recentLookups.test.d.ts +0 -1
  130. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  131. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
  132. package/src/FieldEditWidget.test.ts +0 -133
  133. package/src/FieldEditWidget.tsx +0 -191
  134. package/src/__tests__/DateCellRenderer.test.tsx +0 -73
  135. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  136. package/src/__tests__/cell-truncation.test.tsx +0 -96
  137. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  138. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  139. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  140. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  141. package/src/__tests__/registry-collision.test.ts +0 -91
  142. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  143. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  144. package/src/__tests__/validation-feedback.test.tsx +0 -361
  145. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  146. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  147. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -598
  148. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  149. package/src/coerce-safe-value.test.ts +0 -34
  150. package/src/complex-widgets.test.tsx +0 -908
  151. package/src/currency.ts +0 -14
  152. package/src/datetime-widgets.test.tsx +0 -93
  153. package/src/field-type-alias.ts +0 -100
  154. package/src/field-type-coverage.test.ts +0 -67
  155. package/src/index.tsx +0 -2503
  156. package/src/standard-widgets.test.tsx +0 -357
  157. package/src/widgets/AddressField.tsx +0 -132
  158. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
  159. package/src/widgets/AutoNumberField.tsx +0 -18
  160. package/src/widgets/AvatarField.tsx +0 -117
  161. package/src/widgets/BooleanField.tsx +0 -52
  162. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  163. package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
  164. package/src/widgets/CharacterCount.tsx +0 -263
  165. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  166. package/src/widgets/CheckboxesField.tsx +0 -136
  167. package/src/widgets/CodeField.tsx +0 -41
  168. package/src/widgets/ColorField.tsx +0 -53
  169. package/src/widgets/CurrencyField.tsx +0 -95
  170. package/src/widgets/DateField.tsx +0 -31
  171. package/src/widgets/DateTimeField.tsx +0 -37
  172. package/src/widgets/EmailField.tsx +0 -37
  173. package/src/widgets/FileField.tsx +0 -494
  174. package/src/widgets/FilterConditionField.tsx +0 -454
  175. package/src/widgets/FormulaField.tsx +0 -33
  176. package/src/widgets/FullscreenFieldEditor.tsx +0 -271
  177. package/src/widgets/GeolocationField.tsx +0 -167
  178. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  179. package/src/widgets/GridField.test.tsx +0 -406
  180. package/src/widgets/GridField.tsx +0 -966
  181. package/src/widgets/ImageCropperDialog.tsx +0 -245
  182. package/src/widgets/ImageField.tsx +0 -255
  183. package/src/widgets/ImageLightbox.test.tsx +0 -52
  184. package/src/widgets/ImageLightbox.tsx +0 -108
  185. package/src/widgets/LocationField.tsx +0 -57
  186. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  187. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  188. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  189. package/src/widgets/LookupField.idField.test.tsx +0 -61
  190. package/src/widgets/LookupField.tsx +0 -1291
  191. package/src/widgets/MarkdownContent.tsx +0 -19
  192. package/src/widgets/MasterDetailField.tsx +0 -131
  193. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  194. package/src/widgets/MultiSelectField.tsx +0 -142
  195. package/src/widgets/NumberField.tsx +0 -49
  196. package/src/widgets/ObjectField.tsx +0 -94
  197. package/src/widgets/ObjectRefField.tsx +0 -121
  198. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  199. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  200. package/src/widgets/OptionsEmptyState.tsx +0 -83
  201. package/src/widgets/PasswordField.tsx +0 -49
  202. package/src/widgets/PeoplePicker.test.tsx +0 -360
  203. package/src/widgets/PeoplePicker.tsx +0 -579
  204. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  205. package/src/widgets/PercentField.tsx +0 -95
  206. package/src/widgets/PersonRow.tsx +0 -117
  207. package/src/widgets/PhoneField.tsx +0 -37
  208. package/src/widgets/QRCodeField.tsx +0 -103
  209. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  210. package/src/widgets/RadioField.tsx +0 -111
  211. package/src/widgets/RatingField.tsx +0 -81
  212. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  213. package/src/widgets/RecipientPickerField.tsx +0 -188
  214. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  215. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  216. package/src/widgets/RecordPickerDialog.tsx +0 -1316
  217. package/src/widgets/RichTextField.tsx +0 -176
  218. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  219. package/src/widgets/SelectField.tsx +0 -162
  220. package/src/widgets/SelectionTray.tsx +0 -110
  221. package/src/widgets/SignatureField.tsx +0 -140
  222. package/src/widgets/SliderField.tsx +0 -39
  223. package/src/widgets/SummaryField.tsx +0 -31
  224. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  225. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  226. package/src/widgets/TagsField.tsx +0 -88
  227. package/src/widgets/TextAreaField.tsx +0 -213
  228. package/src/widgets/TextField.tsx +0 -45
  229. package/src/widgets/TimeField.tsx +0 -31
  230. package/src/widgets/UrlField.tsx +0 -46
  231. package/src/widgets/UserField.tsx +0 -58
  232. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  233. package/src/widgets/VectorField.tsx +0 -29
  234. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  235. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -142
  236. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  237. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  238. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  239. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  240. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  241. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  242. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  243. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  244. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  245. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  246. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  247. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  248. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  249. package/src/widgets/deriveLookupColumns.ts +0 -108
  250. package/src/widgets/file-value.test.ts +0 -230
  251. package/src/widgets/file-value.ts +0 -246
  252. package/src/widgets/openNativePicker.ts +0 -18
  253. package/src/widgets/personDisplay.test.ts +0 -113
  254. package/src/widgets/personDisplay.ts +0 -92
  255. package/src/widgets/recentLookups.test.ts +0 -45
  256. package/src/widgets/recentLookups.ts +0 -47
  257. package/src/widgets/toDomProps.ts +0 -175
  258. package/src/widgets/types.ts +0 -242
  259. package/src/widgets/useCascadingOptions.ts +0 -46
  260. package/src/widgets/useFieldTranslation.ts +0 -150
  261. package/src/widgets/useRecordQuery.test.ts +0 -174
  262. package/src/widgets/useRecordQuery.ts +0 -264
  263. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  264. package/src/widgets/useUploadingSignal.ts +0 -20
  265. package/src/withFieldCarrier.tsx +0 -75
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1643 @@
1
1
  # @object-ui/fields
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a09bc33: fix(fields): render the option colour an author declared as an explicit hex, instead of quantizing it to nine palette families (objectui#5141)
8
+
9
+ `options[].color` accepts any hex, but the badge renderer answered a lossy
10
+ question with it: `hexToPaletteName` bucketed the value by hue into nine
11
+ families (`red`/`orange`/`yellow`/`green`/`blue`/`indigo`/`purple`/`pink`, plus
12
+ `gray` below 22% saturation), and `BADGE_COLOR_MAP` held exactly one class set
13
+ per family. Two tiers an author declared as distinct therefore rendered
14
+ byte-identical: `#2ecc71` ("in progress") and `#1e8449` ("completed") differ by
15
+ 0.1 degree of hue, both landed in `green`, and both emitted
16
+ `bg-green-50 text-green-700 border-green-200`. Pressing the second colour darker
17
+ still changed nothing. End users could not tell the two states apart in a list.
18
+
19
+ `plugin-gantt` had already settled this class of conflict the other way —
20
+ *explicit colorField value (hex or semantic name) — metadata wins* — and
21
+ Studio's own option editor paints the author's swatch straight from the raw hex.
22
+ Badges were the odd surface out.
23
+
24
+ Now an explicitly declared hex is rendered as declared: the soft-pill surface,
25
+ label and border are derived from that hex rather than snapped to a family, for
26
+ both `appearance: 'badge'` and `appearance: 'dot'`.
27
+
28
+ Two properties the family maps gave us for free are kept deliberately:
29
+
30
+ - **The design system keeps control of theming.** The derived colours are
31
+ published as CSS custom properties and consumed by *static* Tailwind
32
+ utilities, so light and dark remain ordinary `dark:` variants
33
+ (`.dark\:bg-...:where(.dark, .dark *)` in the built sheet) rather than a
34
+ hard-coded inline background that would render identically in dark mode.
35
+ Tailwind cannot generate a class for a runtime value, so the custom property —
36
+ not the colour — has to be the dynamic part.
37
+ - **Contrast is pinned, not just colour identity.** The label is the lightness
38
+ along the declared hue nearest the declared one that still clears WCAG AA
39
+ (4.5:1) against the surface actually rendered. Authors can and do declare
40
+ colours that are unreadable under a label; honoring the declaration must not
41
+ turn legibility loose across every list view. Dots are held to 1.9:1 against
42
+ the row, the measured floor of the `-500` shades shipped today.
43
+
44
+ **What changes for an author relying on the current look:** every select/status
45
+ badge whose option colour is declared as a hex — which the renderer's own notes
46
+ describe as almost all of them — will render in that declared colour rather than
47
+ its palette family's fixed pill. Colours near a family's canonical shade look
48
+ much as before; a colour the author picked deliberately *away* from it (a deep
49
+ green, a muted red) now looks like what was written, and the pill's depth tracks
50
+ the declared lightness. Declarations that are not an explicit hex are untouched:
51
+ family names, the semantic value map and the deterministic hash fallback all
52
+ resolve exactly as they did, and `getSemanticColorName` still returns family
53
+ names, so the Gantt path is unaffected.
54
+
55
+ The badge classes exported by `getBadgeColorClasses` are unchanged, so callers
56
+ that consume only a class string (the grid's compact card view and group-header
57
+ pills, Kanban) keep today's quantized rendering until they adopt the new
58
+ `getBadgeHexAppearance` / `getDotHexAppearance` helpers.
59
+ - 167ec42: `ComponentMeta.labelling` grows a third value: `'control' | 'group' | 'display'`
60
+ (objectui#4857, ruled jointly with objectui#4871 as the single repo-wide vocabulary for
61
+ "how does a host learn what a widget will render"). `'display'` declares a widget whose
62
+ whole surface is a pure display in EVERY state — no focusable control, nothing a
63
+ `<label for>` could ever reach.
64
+
65
+ The form renderer answers the declaration with the objectui#4788 host container (field
66
+ id + `aria-labelledby` + `aria-describedby` + `role="group"`) in the editable state too;
67
+ the `readonly === true` arm keeps its exact #4788 semantics for undeclared widgets. The
68
+ display-only four (`formula` / `summary` / `auto_number` / `vector`) declare `'display'`
69
+ — on the real object-form path they arrive `disabled`, never `readonly` (a deliberate
70
+ distinction this change does not touch), so their visible labels pointed `for` at an id
71
+ no element carried and their help text had zero consumers in every editable form.
72
+
73
+ `grid` was re-measured before being classified: its only bare-config focusable is the
74
+ auxiliary "Add line" button (routing `for` there would have label clicks insert rows),
75
+ and every realistic config is a table of per-cell inputs — a composite. It declares
76
+ `labelling: 'group'` and its root container now consumes the host id, name and
77
+ description, exactly like `address` / `checkboxes`.
78
+
79
+ Companion registry gate: `FIELD_WIDGET_LABELLING` (exported) is a `Record` keyed by the
80
+ field-widget map's own literal key union, so registering a widget without deciding its
81
+ labelling is a compile error rather than a silent fall-through to the dangling-`for`
82
+ path, and the declaration test asserts the registered meta agrees with it key by key.
83
+
84
+ ### Patch Changes
85
+
86
+ - 69251bf: `AddressField` is translatable, shows no US example placeholders, and formats its readonly line in the reader's address order.
87
+
88
+ The five sub-labels ("Street Address", "City", "State / Province", "ZIP /
89
+ Postal Code", "Country") were English string literals with no i18n key. On a
90
+ non-English console every address field showed five English words in the middle
91
+ of an otherwise fully translated form, and an app had no way to reach them: the
92
+ parts are not fields on the object (`billing_address` is a single `address`
93
+ column), so a translation bundle had nothing to key on, there is no `subLabels`
94
+ property to declare, and the widget cannot be replaced from metadata. They now
95
+ resolve through `fields.address.street` / `.city` / `.state` / `.postalCode` /
96
+ `.country`, added to all ten locale packs. The `en` values are byte-identical to
97
+ the literals they replace, and `FIELD_DEFAULTS` carries the same five, so
98
+ English and provider-less rendering are unchanged.
99
+
100
+ The five input placeholders (`123 Main St`, `San Francisco`, `CA`, `94102`,
101
+ `United States`) are **removed** rather than keyed. They were untranslated and
102
+ US-specific — a zh/ja/ar user was shown an American address as the example of
103
+ what to type — and the right example is a function of the address's country,
104
+ not the reader's language, which no channel in the stored value can supply
105
+ today. Each box keeps the visible label that names it.
106
+
107
+ The readonly line's part order now follows the reader's display locale
108
+ (`useDisplayLocale()`): `zh`, `ja` and `ko` read largest-first (`Country, ZIP
109
+ State, City, Street`), every other locale keeps the unchanged small-to-large
110
+ order (`Street, City, State ZIP, Country`). The display cell renderer takes the
111
+ same locale through the same shared `formatAddress`, so a stored address reads
112
+ identically in a readonly form and in a grid cell.
113
+ - 0ae27f7: The form renderer's built-in `textarea` branch now honours a declared character cap the same way the registered `field:textarea` widget does.
114
+
115
+ One `maxLength` declaration produced two experiences. The registered path has
116
+ shipped four things since objectui#3406/#3408/#3417 — the native cap, visible
117
+ `{n}/{max}` digits, a description reached through `aria-describedby` so the
118
+ limit is announced on focus, and a threshold-gated debounced near-limit notice.
119
+ The built-in branch — the path standalone and embedded hosts take, the ones that
120
+ call no `registerAllFields()` — shipped a subset of one of them.
121
+
122
+ The accessibility half is the half that mattered: a screen-reader user on this
123
+ path learned the field's limit only as a validation error AFTER submitting. All
124
+ four affordances now render on both of the branch's surfaces (the inline control
125
+ and the fullscreen dialog), from the SAME `CharacterCount` component the widget
126
+ renders rather than a second copy of it.
127
+
128
+ Also fixed, and wider than the visible gap: the branch never READ the cap, it
129
+ only spread its leftover field props onto the element. A camelCase `maxLength`
130
+ therefore worked by coincidence — it names a real DOM attribute — while the
131
+ legacy `max_length` spelling, which the registered widget and all three
132
+ producers of a form field have dual-read since framework#1878 §3, landed as a
133
+ stray inert `max_length="…"` attribute and capped nothing at all. The branch now
134
+ resolves both spellings and keeps the non-attribute spelling off the DOM.
135
+
136
+ `CharacterCount` moved from `@object-ui/fields` to `@object-ui/components`, the
137
+ package both render paths may import, in the direction and for the reason
138
+ objectui#3398 measured for `FullscreenEditor`. It was internal to `fields` (never
139
+ exported from that package's barrel), so no published export changed; it is a
140
+ new export of `@object-ui/components`. Its copy moved with it onto the same
141
+ `fields.textarea.*` keys with byte-identical English defaults, so the ten locale
142
+ packs need no edit and provider-less rendering is unchanged.
143
+ - bbe8b86: The allow-list of option widgets that are fed the live record is now one exported constant, `CASCADE_OPTION_WIDGET_TYPES`, instead of three private copies.
144
+
145
+ `select` / `multiselect` / `radio` / `checkboxes` are the widgets whose OFFERED
146
+ option set is re-resolved against a record (per-option `visibleWhen`, plus the
147
+ `dependsOn` gate), so they are the widgets a surface must thread its live record
148
+ to. Three surfaces feed that one evaluator — the object form, the single-record
149
+ action dialog and the bulk action dialog — and until now each carried its own
150
+ private `new Set([...])` of the same four keys, with a comment in each asking the
151
+ next person to change all three together. Nothing could have reported them
152
+ drifting: every copy passed its own behavioural tests, and a divergence would
153
+ have shown up only as one surface silently disagreeing with another about what
154
+ "the record" is.
155
+
156
+ The set now lives in `@object-ui/core`, next to `resolveCascadingOptions` — the
157
+ evaluator that reads that record — because core is the one package all three
158
+ surfaces already depend on, and it is re-exported from `@object-ui/fields` next
159
+ to `resolveFormWidgetType`, whose output is the vocabulary the keys are written
160
+ in. Both are the same object, pinned by test; each consumer keeps its own
161
+ normalization (`normalizeFieldType` in the form, `resolveFormWidgetType` in the
162
+ dialogs), which agree on these four members.
163
+
164
+ No behaviour changes: the members are identical on all three surfaces, and the
165
+ existing pins for each surface still assert the same records reaching the same
166
+ widgets. The rationale that was repeated in the three copies — including the
167
+ note that the widget-hint picker family (`filter-condition`, `recipient-picker`,
168
+ the lookup family) reads a different sibling key off the same channel and is
169
+ deliberately NOT in this set — is now stated once, in the constant's own
170
+ documentation. Whether the action and bulk dialogs should ever feed those
171
+ pickers stays an open question (objectui#4771), unchanged by this convergence.
172
+ - 65e88e6: `@object-ui/fields` stops publishing its `src/` tree
173
+
174
+ The manifest's `files` array listed `src` alongside `dist`, so every published tarball carried all 173 source files — 97 of them `*.test.tsx` / `*.test.ts`. It has been that way since the file's first commit (`780a1b993`), where `files` was already `["dist", "src", "README.md"]` while `exports` named only `dist`, so the entry was never added for a consumer. objectui#4006 recorded this exact shape and did not act on it: its scope was the `*.test.d.ts` half the build program emitted into `dist`, and it noted in passing that the test sources were already in the tarball by this other route.
175
+
176
+ Nothing in the published surface reached those files, which is why no consumer changes in either direction. Measured on a cleanly rebuilt `dist`, all four ways in are closed: the `exports` map has two entries and both target `dist` (`.` resolves `types` / `import` / `require` to `./dist/index.d.ts` / `./dist/index.js` / `./dist/index.cjs`, and `./style.css` to `./dist/index.css`); `main` / `module` / `types` are `./dist/index.cjs`, `./dist/index.js`, `./dist/index.d.ts`; no deep import into this package exists anywhere in the repo or the docs — the one that used to, `@object-ui/fields/widgets/MarkdownContent`, was ruled out by objectui#4325 precisely because a package's surface is its index, and the `../fields/src` paths in sibling `vite.config.ts` files are workspace aliases resolved through `resolve()` against the source tree, which no `files` array shapes; and the tarball holds no sourcemap that could point back at `src`. That last one is the check that had to be measured rather than assumed, because this package emits its declarations through `vite-plugin-dts` rather than the `tsup` of objectui#4847 or the bare `tsc` of `@object-ui/types`: a clean rebuild writes 78 files into `dist`, of which zero are `.d.ts.map`, zero are `.js.map`, zero contain a `sourceMappingURL` comment and zero mention `../src`. `src/index.css` is an input to `scripts/build-css.mjs`, not an output anyone resolves; the sheet the `./style.css` export names is the built `dist/index.css`, which still ships.
177
+
178
+ `npm pack --dry-run` across the change, on the same `dist`:
179
+
180
+ | | before | after |
181
+ | --- | --- | --- |
182
+ | entries | 255 | 82 |
183
+ | unpacked | 2265557 B | 841772 B |
184
+ | tarball | 629843 B | 252364 B |
185
+
186
+ 173 files leave, none arrives, nothing outside `src/` moves, and every surviving entry is byte-identical apart from the edited `package.json` itself. The 173 are the 97 tests plus 76 implementation modules, whose published form remains the bundled `dist/index.js` / `dist/index.cjs` and the 75 declaration files beside them.
187
+
188
+ `@object-ui/types` keeps its `src` entry for now, and that is a different judgement rather than an omission: it builds with a bare `tsc` under a `declarationMap` / `sourceMap` config with no `inlineSources`, so its shipped `dist/*.d.ts.map` name `../src/*.ts` with no embedded content and dropping `src` there would leave published maps pointing at files the tarball no longer carries. That trade-off is filed as objectui#4851.
189
+ - 37f6844: FilterConditionField can author the spec's `$icontains` — case-insensitive contains is reachable from the filter UI.
190
+
191
+ `@objectstack/spec`'s `FieldOperatorsSchema` gained `$icontains` between
192
+ `17.0.0-rc.2` and `rc.5`, and every driver and evaluation face the platform
193
+ ships now executes it. `FilterConditionField` had no builder operator that could
194
+ author it, so the capability was unreachable from the sharing-rule criteria
195
+ builder and sat in that widget's parity test as an explicit `KNOWN_UNREACHABLE`
196
+ entry.
197
+
198
+ The FilterBuilder gains a `containsCaseInsensitive` operator ("Contains (ignore
199
+ case)", translated in all ten locale packs). `condToMongo` emits
200
+ `{ field: { $icontains: value } }` and `kvToCondition` reads it back, so a saved
201
+ criteria reopens in the visual builder instead of falling into the raw-JSON
202
+ editor. Today's `contains` is unchanged and still emits the case-SENSITIVE
203
+ `$contains`; whether it should have been case-insensitive all along is a product
204
+ question that stays open, and stored filter views keep meaning what they meant.
205
+
206
+ The fold is ASCII-only by contract — `café` does not match `CAFÉ`.
207
+
208
+ The new operator is **opt-in per consumer**: `FilterBuilder` takes an
209
+ `extraOperators` prop, and only `FilterConditionField` passes it. The one
210
+ dropdown feeds three at-rest dialects and only the MongoDB-style criteria this
211
+ widget writes can carry the operator — the spec's `VIEW_FILTER_OPERATORS` (saved
212
+ views) and `VALID_AST_OPERATORS` (the live grid's filter AST) have no
213
+ case-insensitive contains, so offering it there would author a filter those
214
+ paths cannot execute. Every other FilterBuilder is unchanged.
215
+ - 911ceaa: The fullscreen long-text dialog announces the field's validation state and carries the field's name
216
+
217
+ objectui#4824, objectui#4832.
218
+
219
+ `mobile.fullscreenLongText` is a shipped opt-in, and with it on the phone user
220
+ edits long text in this dialog and nowhere else. Measured on all three surfaces
221
+ that render the dialog — `TextAreaField`, `RichTextField`, and the form
222
+ renderer's built-in `textarea` branch — with the field genuinely invalid at that
223
+ moment:
224
+
225
+ ```
226
+ INLINE richtext aria-invalid= true
227
+ DIALOG richtext aria-invalid= false aria-describedby= null
228
+ INLINE textarea aria-invalid= true
229
+ DIALOG textarea aria-invalid= null aria-describedby= null
230
+ ```
231
+
232
+ and the accessible name of every dialog control empty, against `F` on every
233
+ inline one. The rich-text row is the sharp half: the dialog was not silent about
234
+ the failure, it was announcing the OPPOSITE of the inline control for the same
235
+ field at the same moment, because `RichTextEditorSurface` computed
236
+ `aria-invalid={!!error}` from an `error` prop the dialog rendering never
237
+ received. 3 surfaces, 3 broken, one cause: the dialog's control is built from
238
+ scratch by the host, so none of the wiring the inline control gets from the form
239
+ renderer reaches it.
240
+
241
+ **Answered once, in the primitive.** `FullscreenEditor` now takes the field's
242
+ `error` and owns what the dialog does with it: it renders the message in a
243
+ dialog-local node, and hands `children` a required fourth argument — a
244
+ spreadable set of DOM attributes — carrying `aria-labelledby` (the dialog
245
+ title's text, i.e. the field label #3393 already put there), `aria-invalid`, and
246
+ `aria-errormessage` naming that node. The host spreads it; the host never learns
247
+ an id, so it cannot name the wrong node, cannot compose the attributes subtly
248
+ wrong, and cannot compute its own `aria-invalid` from a prop it forgot to plumb.
249
+ Three hosts hand-answering this is the shape that produced three identical
250
+ holes.
251
+
252
+ **On objectui#3222's "the text belongs to `FormMessage`".** The maintainer's
253
+ ruling of 2026-08-16 restates that rule as what it was always protecting —
254
+ only one copy of the error text is in the accessibility tree at any moment —
255
+ which the dialog-local node satisfies: it exists only while the dialog is open,
256
+ and for exactly that window Radix `aria-hidden`s everything outside the modal,
257
+ `FormMessage` included. The shortcut of pointing the dialog control's
258
+ `aria-describedby` / `aria-errormessage` at the host's `FormMessage` id is
259
+ forbidden rather than merely unused: it resolves to a node that is `aria-hidden`
260
+ for the whole time the reference is live (an ARIA MUST violation), and neither
261
+ happy-dom nor jsdom can see the difference — which is why every new pin asserts
262
+ that the named node is inside THIS dialog, not merely that it exists.
263
+
264
+ `aria-errormessage` carries a single IDREF and is emitted only alongside
265
+ `aria-invalid="true"`. It is deliberately not folded into the host's
266
+ `aria-describedby` chain, which on the textarea surface already carries the
267
+ fullscreen character counter's sentence.
268
+
269
+ **The name reuses the visible title rather than minting a second author for it**
270
+ (#3978): `aria-labelledby` points at a span inside `DialogTitle`, not at
271
+ `DialogTitle` itself — Radix renders the title as `h2` with the id its own
272
+ `DialogContent` `aria-labelledby` names, so putting an id on it would buy the
273
+ control a name at the cost of the dialog's.
274
+
275
+ **Breaking (shipped as `minor`, see below), `@object-ui/components` only.**
276
+ `FullscreenEditorProps.error` is REQUIRED, not optional, and
277
+ `FullscreenEditorProps['children']` takes a fourth argument.
278
+
279
+ FROM → TO for an out-of-repo host:
280
+
281
+ ```
282
+ <FullscreenEditor value={v} onCommit={c} label={l} testIdPrefix="x">
283
+ {(draft, setDraft, disabled) => <textarea … />}
284
+ </FullscreenEditor>
285
+
286
+ <FullscreenEditor value={v} onCommit={c} label={l} testIdPrefix="x" error={err}>
287
+ {(draft, setDraft, disabled, aria) => <textarea {...aria} … />}
288
+ </FullscreenEditor>
289
+ ```
290
+
291
+ A render prop may still declare fewer parameters, so only `error` fails to
292
+ compile — which is the point of making it required. Every consumer already has
293
+ the value at hand (registered widgets take `error` off the widget props
294
+ contract, objectui#3222; the built-in branch reads `fieldState.error?.message`),
295
+ and an omitted `error` reproduces this defect exactly: a dialog announcing
296
+ `aria-invalid="false"` for a field its own form has already failed. An optional
297
+ key was forgotten by three surfaces in a row; a required one cannot be.
298
+
299
+ `@object-ui/fields` is `patch`: `FullscreenFieldEditor` is internal to that
300
+ package (not re-exported from its entry), so nothing in its public surface
301
+ changes — only the behaviour of the two long-text widgets' dialogs.
302
+
303
+ `minor` rather than `major` follows the repo's standing retirement precedent
304
+ (AGENTS.md §版本号策略, enforced by `scripts/check-changeset-no-major.mjs`): all
305
+ publishable packages sit in one `fixed` group, so a `major` here would carry the
306
+ whole family up against an `@objectstack` that has not moved.
307
+ - 0bffb18: A readonly group-labelled field is now DESCRIBED by its own help text, not just named by its label.
308
+
309
+ The seven `labelling: 'group'` widgets (`address`, `geolocation`, `checkboxes`, `radio`, `rating`, `file`, `multiselect`) render a `<FormDescription>` in their readonly and zero-option states, and the form renderer publishes its `id` — but nothing in the document referenced that id, so the visible help text had no programmatic association with the field it describes. Measured before the change, one field per row: `consumers=0` on every readonly surface of all seven, against `consumers=1` on the same widget's editable one.
310
+
311
+ `toHostGroupProps` now carries `aria-describedby` alongside the host `id` and `aria-labelledby`, onto the `role="group"` surface those states already render (a group is a description carrier under ARIA 1.2 — no focusable control required, which is why this became possible only once objectui#3990 gave those surfaces a role).
312
+
313
+ Control-channel state deliberately does NOT come along: `aria-invalid` and `aria-required` report what a user's editing may do wrong, and a readonly display cannot be edited. The boundary is pinned from both sides, including with a live failed validation on a readonly required field. Editable surfaces are unchanged — a composite's editable container still leaves the description on the sub-input the user focuses, so the help text is never announced twice.
314
+ - 800f455: fix(fields): grid columns are keyed by the declared `name`, so spec-compliant grid metadata renders populated cells
315
+
316
+ `GridField` declared its own column interface keyed by `field` and read
317
+ `c.field` at every site (`key=`, `row[…]`, the blank row, cell writes, the
318
+ column chooser, the running-total lookup), while the published
319
+ `GridColumnDefinition` in `@object-ui/types` — and the grid documentation, and
320
+ the `fields-grid` catalog examples — declare the key as `name`. Metadata
321
+ authored against the published type therefore rendered a grid with the correct
322
+ row count and every cell empty, plus a React "unique key" warning per column.
323
+
324
+ The renderer now reads the declared `name`, and the master-detail derivation
325
+ (`deriveColumns` / `hydrateColumns` / `pickAmountField`) produces and consumes
326
+ the same key. There is deliberately **no** `col.field ?? col.name` alias: one
327
+ spelling at the producer (AGENTS.md #0.1).
328
+
329
+ **Breaking for `field`-keyed columns.** Grid / line-item / master-detail
330
+ subform columns spelled `{ field: 'amount' }` must be re-spelled
331
+ `{ name: 'amount' }`. This affects author-supplied `columns` on the `grid`
332
+ field, `record:line_items`, `object-master-detail-form` details and a
333
+ relationship field's `inlineColumns`. Auto-derived columns (no explicit
334
+ `columns` block) need no change. List-view and `object-grid` columns are a
335
+ different contract (`ListColumn`) and keep their own `field` key.
336
+ - 5458414: Publish relative import specifiers with explicit `.js` extensions so these six packages load under plain Node ESM.
337
+
338
+ Node's ESM resolver does not extension-search relative specifiers and `tsc` never rewrites them, so an extensionless `./Foo` in the source shipped as an extensionless `./Foo` in `dist` and importing the package entry outside a bundler failed with `ERR_MODULE_NOT_FOUND`. Bundled consumers were unaffected. Unbundled consumers — plain Node ESM, an SSR host importing the package directly, anyone running the published tarball without a build step — can now import these entries, and so can the downstream `@object-ui/plugin-*` packages that evaluate through `mobile`, `permissions` and `providers`.
339
+ - 3241559: `object-kanban` now caps its fetch with a real top-level `$top`, and honours `limit`.
340
+
341
+ The board's row cap was written `dataSource.find(objectName, { options: { $top: 100 }, $filter: … })`
342
+ — `$filter` at the top level, where the adapters read it, and the cap one level
343
+ down under `options`, which is not a `QueryParams` key. Nothing in this repo
344
+ reads `params.options` (`convertQueryParams` in `@object-ui/data-objectstack` and
345
+ `ApiDataSource` both read `params.$top`), so the intended cap never reached the
346
+ wire: a board over a large object fetched every row the server would return and
347
+ grouped all of it into lanes client-side. The author's 100 was never live.
348
+
349
+ The cap is now `$top: schema.limit ?? 100`, the shape `object-timeline` took for
350
+ the identical defect. Two consequences worth reading before upgrading:
351
+
352
+ - **A board that used to fetch unbounded now fetches 100 rows by default.** That
353
+ is the previously-declared intent taking effect, not a new limit — but a board
354
+ over an object with more than 100 matching records will show fewer cards than
355
+ it did. Authors who want the old behaviour should declare the window they
356
+ actually want (`limit: 500`), not rely on the absence of one.
357
+ - **`limit` is now mapped from the `dataSource` binding** (`object-kanban`'s
358
+ `ElementDataSourceMapping` gains `limit: 'limit'`). A board bound to a saved
359
+ view is capped by that view's `pagination.pageSize`, and the binding's own
360
+ `limit` overrides it. The flag comes with the read site: it stayed unmapped
361
+ until now on the rationale that the board had a "fixed window", which is the
362
+ claim this change falsifies. `sort` remains unmapped — the board still has no
363
+ `$orderby` read site — and `columns` remains unmapped because a board's columns
364
+ are its swimlanes, not a field projection.
365
+
366
+ `KanbanSchema` gains `limit?: number` (the board's row cap; distinct from a
367
+ column's `limit`, which is that lane's WIP limit and never reaches the query),
368
+ and the guide's per-block table in `content/docs/guide/data-source.md` no longer
369
+ claims a fixed window for `object-kanban`.
370
+
371
+ `@object-ui/fields` carried the same nesting in the lookup-name fallback
372
+ (`find(referenceTo, { $filter: { id }, options: { $top: 1 } })`) where it was
373
+ harmless — a primary-key equality returns at most one row with or without a cap.
374
+ It is spelled `$top: 1` now, so no live instance of the dead nesting is left in
375
+ the repo to read as precedent. No behaviour change there.
376
+ - 616a2a5: The list filter builder no longer offers `Is set` / `Is not set`, which its query dialects cannot express.
377
+
378
+ **User-visible before/after.** The operator dropdown in the list toolbar's
379
+ filter popover — and in the Studio view/tab/page filter inspectors, the dataset
380
+ inspector and the generic `filter` config field — loses two rows: **"Is set"**
381
+ and **"Is not set"**. The sharing-rule criteria builder (`FilterConditionField`)
382
+ keeps both, unchanged. `Is null` / `Is not null` and `Is empty` / `Is not empty`
383
+ are untouched everywhere and remain the way to filter on a missing value from
384
+ the list.
385
+
386
+ Nothing that worked stops working. Every save path behind those two rows was
387
+ already broken, in three different ways depending on the surface:
388
+
389
+ - **Live grid** — `ListView.mapOperator` had no row for either id, so its
390
+ `default:` arm returned the id verbatim and the query went out as
391
+ `['name', 'exists', 'x']`. `exists` is not a member of the spec's
392
+ `VALID_AST_OPERATORS`, so `isFilterAST()` rejects the shape: an unfiltered
393
+ read or a 400, never the filter the user asked for.
394
+ - **Save as view** — `foldFilterGroupToSpecRules` normalizes through the spec's
395
+ `normalizeFilterOperator`, which does not know the pair, and
396
+ `ViewFilterRuleSchema`'s enum then refuses the rule.
397
+ - **Dataset inspector** — `groupToCondition` has no row either and drops the
398
+ condition silently, so the filter simply never applied.
399
+
400
+ **Why withheld rather than mapped.** Measured on `@objectstack/spec`
401
+ 17.0.0-rc.6: neither `VIEW_FILTER_OPERATORS` nor `VALID_AST_OPERATORS` contains
402
+ an existence operator, under any spelling — both sets have zero members matching
403
+ `/exist/`. Only the MongoDB-style `FieldOperatorsSchema` criteria carries
404
+ `$exists`, and that is precisely the dialect `FilterConditionField` writes, so
405
+ the pair moves behind the existing `OPT_IN_OPERATORS` gate and that widget opts
406
+ in. Collapsing them onto `isNotNull` / `isNull` was rejected: the builder
407
+ already draws those as their own rows, the round trip is lossy (a saved
408
+ `exists` reads back as `isNotNull`), and the spec's own note records `$exists` =
409
+ has-value as still unsettled across drivers — `driver-memory`'s live mingo path
410
+ and `driver-mongodb` read key-presence.
411
+
412
+ **The class is now closed by an assertion, not by discipline.** objectui's three
413
+ existing operator-parity guards all sweep spec vocabulary → objectui; none asked
414
+ whether an id the dropdown draws is an id the consumer can persist, which is the
415
+ direction that broke. `plugin-list`'s new
416
+ `list-offered-operator-expressible-parity.test.ts` forces the set the list
417
+ toolbar offers to **equal** the set its two dialects can express, in both
418
+ directions — so an unexpressible operator cannot be offered, and an operator
419
+ that becomes expressible upstream cannot stay needlessly withheld.
420
+ - 6c68b13: Lookup "recently used" now obeys the field's declared filters. The recents rail
421
+ re-fetched its rows without merging either `lookupFilters` or the `dependsOn`
422
+ cascade, so a record the author's metadata excludes stayed visible and
423
+ selectable — pick a product under project A, switch the form to project B, and
424
+ project A's product was still offered. Both re-fetch channels are fixed: the
425
+ inline dropdown (which re-read each remembered id with an unfiltered `findOne`)
426
+ and the search-first picker (which batched recents into an unfiltered `$in`
427
+ query). The currently-selected value still resolves unfiltered, so an existing
428
+ value keeps its label and a selection tray is never silently emptied.
429
+ - 5607092: objectui#4029 — the repo root now lints `no-console` (`error`, allowing
430
+ `warn`/`error`) so a stray module- or function-scope `console.log`/`info`/
431
+ `debug` fails CI instead of shipping silently (as `console.log('Registering
432
+ object-map...')` did in #7139, caught only by hand). Landing the rule meant
433
+ individually judging every real hit outside the tooling exemptions
434
+ (`scripts/**`, `**/examples/**`, test files, `packages/cli/src/**`,
435
+ `packages/create-plugin/src/**`) — this changeset covers the published
436
+ packages whose call sites changed:
437
+
438
+ - `@object-ui/app-shell`: `ObjectDataPage`'s dropped-URL-filter message is a
439
+ real diagnostic (data silently discarded), so it moves from `console.debug`
440
+ to `console.warn` to match the house convention.
441
+ - `@object-ui/plugin-detail`: `DetailView`'s Web Share API failure now reports
442
+ via `console.error` (it is a real failure, not debug noise); a redundant
443
+ "Link copied to clipboard" success log is removed.
444
+ - `@object-ui/fields`: `MasterDetailField`'s `handleView` stub no longer logs
445
+ the item it does nothing with.
446
+ - `@object-ui/runner`: `App`'s loader-selection debug prints, `LayoutRenderer`'s
447
+ unused click-handler stub log, and `MockDataSource`'s per-call narration
448
+ (`find`/`create`/`getObjectSchema`) are removed — none diagnosed a problem,
449
+ they only echoed the happy path.
450
+ - `object-ui` (VS Code extension): the "extension is now active!" activation
451
+ log is removed.
452
+
453
+ No behavior changes beyond console output. `@object-ui/core` and
454
+ `@object-ui/data-objectstack` also touch `no-console`-adjacent lines
455
+ (`debugLog`/`debugTime`/`debugTimeEnd`, `createQuietHttpLogger`) but only to
456
+ add `eslint-disable-next-line` documentation — those ARE the repo's
457
+ deliberate debug/logger infrastructure, not leaked residue, so their own
458
+ changeset carries empty frontmatter.
459
+ - e7747f1: fix(fields): retire the `owner` field-type alias with a loud tombstone
460
+
461
+ `owner` was a synonym for `user` with zero behavioral delta — both resolved to
462
+ the same `UserField` widget — and it is not a member of `@objectstack/spec`'s
463
+ closed `FieldType`, so no object schema could ever declare it. It was reachable
464
+ only through hand-written SDUI, and the three code faces that read it had
465
+ already drifted apart on the word: the form's data-source rule excluded it,
466
+ while plugin-grid's bulk-action dialog and app-shell's `paramToField` included
467
+ it.
468
+
469
+ The retired spelling now fails **loudly**. Deleting the alias on its own would
470
+ have been absorbed by two silent tails (`mapFieldTypeToFormType`'s
471
+ `|| 'field:text'` and `resolveFormWidgetType`'s `: 'text'`), each handing back a
472
+ working plain text input with no check turning red — so anyone who had written
473
+ `type: 'owner'`, including an AI author copying it out of a doc, would have
474
+ shipped a text box believing they shipped a person picker. Instead:
475
+
476
+ - `type: 'owner'` and `widget: 'field:owner'` both resolve to a registered
477
+ tombstone widget that renders a visible refusal naming the migration;
478
+ - the same prescription is written to the console once per spelling;
479
+ - the read/cell path degrades to the text cell deliberately and says so.
480
+
481
+ Migration: write the record-owner field as `{ type: 'user', name: 'owner' }` —
482
+ the field NAME carries the ownership meaning, the type carries the widget.
483
+ `UserField` and `UserCellRenderer` are unchanged; only the synonym is gone.
484
+
485
+ Also corrects the `dataSource` TSDoc in `@object-ui/fields`, which listed `grid`
486
+ among the widgets the form renderer wires a DataSource to. `GridField` never
487
+ read `dataSource` and no data-source table ever contained the key.
488
+ - ac2f332: Retired field-type spellings can no longer reach an inline editor by delegation — the grid's inline cell editor stops offering a working person picker for `owner`.
489
+
490
+ objectui#4814 retired the `owner` field type with a loud tombstone, and the
491
+ record form has answered `type: 'owner'` with a visible refusal ever since.
492
+ `FieldEditWidget` did not participate, and the reason it was missed is that no
493
+ routing table had to list the spelling for it to stay alive:
494
+ `hasFieldEditWidget` answers `resolveInlineEditType(type) in EDIT_WIDGETS`, and
495
+ `resolveInlineEditType` returns a type **unchanged** when it is already a key of
496
+ `EDIT_WIDGETS`. So while `owner: UserField` sat in that map, the alias table —
497
+ where the retirement lives — was never consulted, and the retirement never
498
+ applied. Measured: `hasFieldEditWidget('owner') === true`, and a direct
499
+ `FieldEditWidget` call rendered a working person picker. Every host built on
500
+ this seam, the data grid's inline cell editor being the user-facing one, still
501
+ let a person be picked and saved for a field the record form refuses.
502
+
503
+ The gate now consults the retirement table itself, so this closes the **class**
504
+ rather than the spelling — the next retirement covers this seam the day it lands
505
+ in `RETIRED_FIELD_TYPES`, with no edit here:
506
+
507
+ - `hasFieldEditWidget()` answers `false` for any retired spelling, ahead of
508
+ every table lookup (so it holds even if a retired spelling is still a key of
509
+ the widget map);
510
+ - `isInlineExcludedFieldType()` answers `true` for any retired spelling, which
511
+ is what routes a host to its read-only path;
512
+ - `FieldEditWidget` itself renders no control for a retired spelling and writes
513
+ the once-per-spelling console prescription naming the migration;
514
+ - `owner` is dropped from `EDIT_WIDGETS` and from the cosmetic
515
+ `COMPACT_EDIT_TYPES` sizing set.
516
+
517
+ The second bullet is load-bearing and not cosmetic. `ObjectGrid` marks a column
518
+ `editable: false` exactly when `isFieldInlineEditable` is false, and that
519
+ predicate consults `isInlineExcludedFieldType`. Without it, closing the first
520
+ gate alone would have left the column editable, handed the cell editor a `null`
521
+ widget, and let DataTable fall back to its built-in **plain text input** — which
522
+ for a stored person reference displays a coerced value and writes a bare string
523
+ straight back over it. Measured before/after on the grid consumer: a stored
524
+ `owner` field went from an editable cell rendering the person picker to a
525
+ read-only cell (`isFieldInlineEditable('owner')` `true` → `false`), which is the
526
+ same disposition `password`, `formula` and `composite` already get, for the same
527
+ reason. The read path is unchanged from #4814: the text cell plus the console
528
+ prescription.
529
+
530
+ `user` is untouched — it keeps the person picker, its compact sizing and its
531
+ expanded-object value resolution. The record-owner idiom survives verbatim as
532
+ `{ type: 'user', name: 'owner' }`: the field NAME carries the ownership meaning,
533
+ the type carries the widget.
534
+ - a777058: Editable `markdown` / `html` / `richtext` fields now carry the host's `id` and `aria-describedby` on the editor.
535
+
536
+ `RichTextField` — the one widget all three registry keys resolve to — read only
537
+ `className` and `disabled` off its props and had no `toDomProps` anywhere in the
538
+ file, so everything `<FormControl>`'s Radix `Slot` hands a field widget landed on
539
+ no element at all. Measured on a real form with a field carrying a description:
540
+
541
+ ```
542
+ markdown editable descEl=SET consumers=0 hostIdEl=NONE for=DANGLING
543
+ text editable descEl=SET consumers=1 hostIdEl=input for=RESOLVES
544
+ textarea editable descEl=SET consumers=1 hostIdEl=textarea for=RESOLVES
545
+ ```
546
+
547
+ Two user-visible consequences: the form's visible label pointed at an id nothing
548
+ carried, so clicking it did not reach the editor and the control had no
549
+ accessible name; and the `<FormDescription>` the form rendered below the field
550
+ had zero consumers, so a screen reader never announced the help text or — on a
551
+ failed submit — the error message id that rides in the same `aria-describedby`.
552
+
553
+ The fix is objectui#3318's standing recipe: `toDomProps(props)` spread onto the
554
+ REAL focusable control, which here is the `<Textarea>` inside the shared
555
+ `RichTextEditorSurface`. The pass-through is given to the inline surface only —
556
+ the fullscreen dialog's copy of the same surface must not carry a duplicate of
557
+ the host id, and the description ids it names sit outside the modal Radix
558
+ `aria-hidden`s. `aria-invalid` still comes from the widget's own `error` read,
559
+ kept after the spread so objectui#3318's PASS entry for this widget is unchanged.
560
+
561
+ The readonly branch is untouched: the same reading there belongs to
562
+ objectui#4788, whose mechanism is still open.
563
+ - 75444e3: The dependency-gate hint now enumerates its controlling fields with the locale's
564
+ own list separator, and reads identically whichever caller produced it.
565
+
566
+ `lookup.selectFirst` and `fields.options.selectFirst` are deliberately one
567
+ wording, so a field gated on two or more parents says the same thing whether the
568
+ lookup widget or the form renderer rendered it. The sentence was shared but its
569
+ `{{fields}}` slot was not: each call site joined the controlling-field names
570
+ with its own hardcoded separator, and not even the same one — `', '` in
571
+ `LookupField`, `' / '` in the form renderer's `gatedHint` and in
572
+ `OptionsEmptyState`. A field gated on Account and Lead Source read
573
+ `Select Account, Lead Source first` from one side and
574
+ `Select Account / Lead Source first` from the other.
575
+
576
+ A list separator is a property of the locale rather than of the code, so both
577
+ spellings were also wrong for the script under zh/ja (which enumerate with
578
+ U+3001) and under ar (U+060C). All three call sites now read
579
+ `validation.formInvalidJoiner` — the key already shipped in all ten packs for
580
+ the invalid-submit toast's field list, which is the same class of truncated-name
581
+ list. One key, every caller: a second, gate-specific key would have recreated
582
+ the divergence the shared sentence exists to prevent.
583
+
584
+ No locale pack changes, and no change to what a provider-less render produces in
585
+ English: the `@object-ui/fields` defaults table declares the joiner as `', '`,
586
+ the `en` pack's value and the literal `LookupField` previously hardcoded.
587
+ - dad51e5: fix(fields): deliver the host's a11y channels to `slider` and name `signature`
588
+
589
+ `SliderField` and `SignatureField` forwarded nothing a form host handed them —
590
+ neither spread `toDomProps(props)` at all — so `<FormControl>`'s whole payload
591
+ landed on nothing. Measured on a real form, one required field per row, freshly
592
+ failed validation:
593
+
594
+ ```
595
+ slider ariaInvalidTrue=[] labelFor=…-form-item -> DANGLING descConsumers=0 ids=[]
596
+ signature ariaInvalidTrue=[] labelFor=…-form-item -> DANGLING descConsumers=0 ids=[]
597
+ text ariaInvalidTrue=[input] labelFor -> input descConsumers=1
598
+ ```
599
+
600
+ `ids=[]` is the tell: no element in either row carried an id at all, so the
601
+ visible label pointed `for` at nothing, the rendered help text had zero
602
+ consumers, and a failed slider announced no error state.
603
+
604
+ **`slider`** now delivers all three. Its focusable control is Radix's
605
+ `span[role="slider"]` thumb, which the synced `ui/slider.tsx` renders internally
606
+ and does not export, so the primitive grew a declared `thumbProps` — routed
607
+ through a new `lib/slider-thumb` and applied to the no-touch file as a declared
608
+ sync patch, so it survives regeneration. The split of which keys stay on Root
609
+ (`name`, `disabled`) is the one the `select` fix already settled.
610
+
611
+ **`signature`** gets the name and the description on a `role="group"` container.
612
+ Its control state deliberately does not follow: the drawing surface is a
613
+ `<canvas>` with no keyboard path, and its only other element is disabled while
614
+ the pad is empty, so there is no element a control state could be read from.
615
+
616
+ Both are now declared `labelling: 'group'` — a `<span>` and a `<canvas>` are not
617
+ labelable elements, so a host `for` could only dangle at them.
618
+ - Updated dependencies [88085e3]
619
+ - Updated dependencies [69251bf]
620
+ - Updated dependencies [57e668f]
621
+ - Updated dependencies [516663d]
622
+ - Updated dependencies [41ac1b7]
623
+ - Updated dependencies [1eaf0a1]
624
+ - Updated dependencies [460c4d0]
625
+ - Updated dependencies [0ae27f7]
626
+ - Updated dependencies [2533ec5]
627
+ - Updated dependencies [78c0f9a]
628
+ - Updated dependencies [bbe8b86]
629
+ - Updated dependencies [8477be5]
630
+ - Updated dependencies [279fb13]
631
+ - Updated dependencies [2e82ab2]
632
+ - Updated dependencies [ad07b65]
633
+ - Updated dependencies [41f498b]
634
+ - Updated dependencies [ef0d150]
635
+ - Updated dependencies [f34226e]
636
+ - Updated dependencies [564b605]
637
+ - Updated dependencies [e1d4251]
638
+ - Updated dependencies [40d3a33]
639
+ - Updated dependencies [8b9dc62]
640
+ - Updated dependencies [1184192]
641
+ - Updated dependencies [a2a9747]
642
+ - Updated dependencies [a1609a6]
643
+ - Updated dependencies [53f23bc]
644
+ - Updated dependencies [c4533dc]
645
+ - Updated dependencies [be60815]
646
+ - Updated dependencies [37f6844]
647
+ - Updated dependencies [93de4f6]
648
+ - Updated dependencies [2b50261]
649
+ - Updated dependencies [384f30d]
650
+ - Updated dependencies [ac600e5]
651
+ - Updated dependencies [97fba31]
652
+ - Updated dependencies [232f61a]
653
+ - Updated dependencies [d374caf]
654
+ - Updated dependencies [5673576]
655
+ - Updated dependencies [c1ef923]
656
+ - Updated dependencies [911ceaa]
657
+ - Updated dependencies [98eab36]
658
+ - Updated dependencies [af5e292]
659
+ - Updated dependencies [3fbbea1]
660
+ - Updated dependencies [5458414]
661
+ - Updated dependencies [7f96b10]
662
+ - Updated dependencies [167ec42]
663
+ - Updated dependencies [616a2a5]
664
+ - Updated dependencies [0046d8f]
665
+ - Updated dependencies [f1d4748]
666
+ - Updated dependencies [bea374e]
667
+ - Updated dependencies [b1119ec]
668
+ - Updated dependencies [9f23d2b]
669
+ - Updated dependencies [578e025]
670
+ - Updated dependencies [af025ee]
671
+ - Updated dependencies [d109a4d]
672
+ - Updated dependencies [598c89a]
673
+ - Updated dependencies [4a0bd17]
674
+ - Updated dependencies [b8b9af4]
675
+ - Updated dependencies [31676be]
676
+ - Updated dependencies [8c0d52e]
677
+ - Updated dependencies [aff10e2]
678
+ - Updated dependencies [70a774b]
679
+ - Updated dependencies [9ce096f]
680
+ - Updated dependencies [e05db88]
681
+ - Updated dependencies [7458a41]
682
+ - Updated dependencies [ad13d63]
683
+ - Updated dependencies [5ffcc14]
684
+ - Updated dependencies [d971e51]
685
+ - Updated dependencies [97abb24]
686
+ - Updated dependencies [deb157a]
687
+ - Updated dependencies [9c60144]
688
+ - Updated dependencies [d2ce342]
689
+ - Updated dependencies [9695da7]
690
+ - Updated dependencies [75444e3]
691
+ - Updated dependencies [58b8346]
692
+ - Updated dependencies [2d0bd16]
693
+ - Updated dependencies [a9e17b4]
694
+ - Updated dependencies [b8ce7dc]
695
+ - Updated dependencies [dad51e5]
696
+ - Updated dependencies [1c9c342]
697
+ - Updated dependencies [787c738]
698
+ - Updated dependencies [8396656]
699
+ - Updated dependencies [dbbd38a]
700
+ - Updated dependencies [8871c14]
701
+ - Updated dependencies [93fe362]
702
+ - Updated dependencies [dfc6975]
703
+ - Updated dependencies [3cf4de0]
704
+ - Updated dependencies [c9dc811]
705
+ - Updated dependencies [144ef9b]
706
+ - Updated dependencies [138ab04]
707
+ - Updated dependencies [a0b9e91]
708
+ - Updated dependencies [99bd015]
709
+ - Updated dependencies [21e4585]
710
+ - @object-ui/types@17.6.0
711
+ - @object-ui/i18n@17.6.0
712
+ - @object-ui/react@17.6.0
713
+ - @object-ui/components@17.6.0
714
+ - @object-ui/core@17.6.0
715
+ - @object-ui/providers@17.6.0
716
+
717
+ ## 17.5.0
718
+
719
+ ### Minor Changes
720
+
721
+ - ebb4e0e: The date formatter's last three en-US channels now follow the display locale
722
+ (objectui#4272).
723
+
724
+ objectui#4468 (PR #4512) pointed every date _renderer_ at `useDisplayLocale()`.
725
+ Three channels were out of its reach because they are properties of the
726
+ formatter's signature and of its callers rather than of any renderer, so a `zh`
727
+ console still met English dates in three places:
728
+
729
+ - **`formatDate`'s `'short'` branch** hardcoded
730
+ `toLocaleDateString('en-US', { month: 'short' })`, so it rendered an English
731
+ month even when the caller had threaded `options.locale` into that very call.
732
+ Its only consumers are ObjectGrid's two mobile-card date cells, which threaded
733
+ no locale — fixing either half alone moves nothing, so both land here.
734
+ - **`formatDateTime` took no options parameter at all**, so no caller could
735
+ localize it however hard it tried; it always handed `Intl` an `undefined` tag,
736
+ which means the MACHINE's locale — neither of the repo's two locale channels.
737
+ The parameter is optional and lands together with its consumers, plugin-gantt's
738
+ four tooltip call sites.
739
+ - **The lookup picker's MongoDB `$date` fallback** called a bare
740
+ `toLocaleDateString()` with no tag.
741
+
742
+ One resolver everywhere, as before: `useDisplayLocale()` (tenant regional
743
+ default → active UI language → `'en'`). `Intl` accepts `'zh'` verbatim, so there
744
+ is still no mapping table anywhere.
745
+
746
+ English output is byte-identical at every touched site — `en` and `en-US` agree
747
+ on all twelve short month names — and the `'short'` layout itself is unchanged:
748
+ only the month token is localized, the compact `"Jan 15, '24"` shape around it
749
+ is a deliberate fixed layout for narrow cards.
750
+
751
+ `@object-ui/fields` is `minor` because `formatDateTime`'s new optional parameter
752
+ is visible in the package's entry `.d.ts`; the plugin packages' own `.d.ts` files
753
+ are byte-identical, so their change is module-local.
754
+
755
+ - 36310dc: `formatPercent` groups its output and follows the display locale — the last
756
+ tooltip/cell channel (objectui#4553).
757
+
758
+ PR #4557 threaded the gantt tooltip's number and currency rows and measured that
759
+ the percent row could not follow: `formatPercent(value, precision)` took no
760
+ locale parameter, and its whole body was
761
+ `${percentDisplayValue(value).toFixed(precision)}%`. It built no
762
+ `Intl.NumberFormat` and never reached `formatDisplayNumber` — so unlike its
763
+ siblings it did not render in the MACHINE's locale, it rendered in **no** locale:
764
+ an ASCII decimal mark, never a grouping separator, byte-identical on every
765
+ machine.
766
+
767
+ **English output MOVES, and that is the fix.** Because the function never
768
+ grouped, `1235%` was wrong in en-US too, not only in German. Grouping and locale
769
+ therefore land together:
770
+
771
+ | | before | after |
772
+ | ---------- | ------- | -------------- |
773
+ | en, 1234.5 | `1235%` | `1,235%` |
774
+ | de, 1234.5 | `1235%` | `1.235\u00a0%` |
775
+ | de, 80 | `80%` | `80\u00a0%` |
776
+
777
+ Values below the grouping threshold are unchanged in English (`80%`, `12.5%`,
778
+ `33.33%`), so the move is confined to four digits and up. German changes at every
779
+ magnitude, because the no-break space before the sign is part of the locale's
780
+ percent convention — which is what routing through `Intl` buys over appending a
781
+ literal `%`.
782
+
783
+ The scaling contract is untouched: `percentDisplayValue` still disambiguates a
784
+ fraction-stored percent (`0.8` → 80%) from a whole one, so the list cell and the
785
+ dashboard measure formatter still agree.
786
+
787
+ Consumers are threaded in the same change, the parameter never landing
788
+ speculatively:
789
+
790
+ - **fields** — `PercentCellRenderer`, on BOTH of its paths. Its whole-percent
791
+ branch (`progress` / `completion` fields, which store 0-100 and must skip the
792
+ fraction scaling) was a second bare `toFixed` call; leaving it behind would
793
+ have made one grid internally inconsistent, so both branches now share one
794
+ locale-aware body and differ only in the scaling policy.
795
+ - **plugin-gantt** — the tooltip percent row, completing objectui#4553's switch.
796
+ - **plugin-grid** — the mobile card's percent cell, which sits in the same
797
+ density row as a date cell objectui#4272 had already localized.
798
+ - **plugin-dashboard** — `renderFieldValue`'s percent branch. It is a plain
799
+ function rather than a component, so it takes the locale as an optional fourth
800
+ parameter beside the `tenantCurrency` already threaded that way, and both of
801
+ its callers pass it and declare it in their memo dependency arrays.
802
+
803
+ Bumps follow each package's own `.d.ts` diff, measured in both directions.
804
+ `@object-ui/fields` and `@object-ui/plugin-dashboard` are `minor` on the
805
+ objectui#4272 / PR #4544 precedent — quoted from that changeset: "`@object-ui/fields`
806
+ is `minor` because `formatDateTime`'s new optional parameter is visible in the
807
+ package's entry `.d.ts`; the plugin packages' own `.d.ts` files are
808
+ byte-identical, so their change is module-local." Here `formatPercent` and
809
+ `renderFieldValue` each gain an entry-visible optional parameter, while
810
+ plugin-gantt's and plugin-grid's `.d.ts` files are byte-identical and stay
811
+ `patch`.
812
+
813
+ - 52d878a: fix(fields): `formatPercent` renders percentage points directly — ties round half-up and extremes keep every digit
814
+
815
+ `formatPercent` rendered a value that is already in percentage POINTS through
816
+ `Intl`'s `style: 'percent'`, which expects a FRACTION, so the body divided by
817
+ 100 for `Intl` to multiply straight back. That round trip is not
818
+ value-preserving: `Intl` formats from the shortest decimal representation of the
819
+ double it is handed, and the quotient's is not the authored one. A stored
820
+ `1.005` at 2 decimals rendered `1.00%` where half-up on the authored decimal is
821
+ `1.01%`; `1.45` at 1 decimal rendered `1.4%` for `1.5%`. Every case was a
822
+ last-digit off-by-one — the failure mode least likely to be noticed and most
823
+ likely to be trusted.
824
+
825
+ The body now renders through `style: 'percentPoints'` with no scaling round
826
+ trip. Measured on this repo's runner (node v22.22.2 / ICU 78.2), 27,577 of
827
+ 1,200,003 ordinary en-US forms move (0.005-step grid to 2,000, precisions
828
+ 0/1/2), and the same artefact at the top of the double range is gone too:
829
+ `Number.MAX_SAFE_INTEGER` percentage points rendered `9,007,199,254,740,990%`
830
+ and now render `9,007,199,254,740,991%`.
831
+
832
+ The locale percent CONVENTION is unchanged — this is a numeral move only.
833
+ `'percentPoints'` is `Intl`'s `style: 'unit'` / `unit: 'percent'`, re-measured on
834
+ this call shape across 720 combinations (10 locales x 18 values x 4 precisions):
835
+ 0 convention differences, 130 numeral differences. The no-break space in
836
+ de/fr/ru/sv, Turkish's prefixed sign, Arabic's own percent sign and Bengali's
837
+ digits all render exactly as before. Percent SCALING (a stored fraction below 1
838
+ scaling by 100) is upstream of the render and untouched.
839
+
840
+ A percentage point now reads identically in a list cell and in a dashboard
841
+ measure, which `formatMeasure` already rendered this way.
842
+
843
+ - bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
844
+ (objectui#4167, objectstack#4115).
845
+
846
+ The rc.6 bump published nine names this repo already declared locally, on top of
847
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
848
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
849
+ triaged on its own rather than blanket-renamed, because the right answer differs
850
+ per symbol: five bind to the spec, three are renamed because the spec's
851
+ same-named export means something else, five arrive by derivation, and one is a
852
+ declared dialect with a written reason.
853
+
854
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
855
+ `@object-ui/types`** — three exported names changed, because the spec exports the
856
+ same name for a _different_ thing:
857
+
858
+ | package | was | now | what the spec's same-named export actually is |
859
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
860
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
861
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
862
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
863
+
864
+ `resolveI18nLabel` is the one where the collision had already started costing
865
+ something. rc.6 widened `I18nLabel` from `string` to
866
+ `string | Record< string, string >`, so the same authored value now reaches
867
+ either resolver — and each answers wrongly, silently, for the other's input: the
868
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
869
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
870
+ tags. The rc.6 bump PR met this and aliased the spec's import as
871
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
872
+ them. That is a review convention, which is what objectstack#4115 exists to
873
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
874
+ name says which vocabulary it resolves at every call site.
875
+
876
+ **Eleven keep their names and are now imported or derived from the spec** instead
877
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
878
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
879
+ `GanttConfig`, plus the three renamed above at their new names.
880
+
881
+ **Four of the copies were losing information, not just duplicating it.**
882
+
883
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
884
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
885
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
886
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
887
+ `quickFilters`, `autoZoomToFilter` — are all read by
888
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
889
+ intersection that existed only because this type did not carry them. It now
890
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
891
+ genuinely local extension; the schema is `$loose` upstream, so that key is
892
+ legal metadata rather than a second dialect.
893
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
894
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
895
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
896
+ `formatted` were missing, under a comment already claiming to be "the part
897
+ names of `AddressSchema`". The widget still renders five inputs; binding the
898
+ type stops it from asserting the platform cannot store the other two, and makes
899
+ the `{ ...address }` write-through say so.
900
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
901
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
902
+ own doc comment names this module as one of the three. It is now the spec's
903
+ array by reference, and the local date-macro bounds table is pinned complete
904
+ against it with `satisfies`, so a preset the schema gains without bounds here
905
+ is a compile error rather than a filter that validates clean and then selects
906
+ nothing.
907
+
908
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
909
+ it is bound directly, with a both-directions type pin that it stays the same type
910
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
911
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
912
+ schemas and are now re-exports — "still exact" is the argument for binding them,
913
+ since a copy with nothing to protect can only drift.
914
+
915
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
916
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
917
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
918
+ moved a derivation the guard could see into an object literal it deliberately
919
+ does not descend into. The dialect is unchanged and its three divergences are
920
+ pinned; which side moves on the refinement itself is objectui#4165.
921
+
922
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
923
+ `@object-ui/layout`: its public type surface now references the spec.
924
+
925
+ ### Patch Changes
926
+
927
+ - e2e6360: A `Field.address` value now reads as a formatted postal address on the record detail page, instead of stringified JSON.
928
+
929
+ The display (read) registry mapped `address` straight to `JsonCellRenderer`, so a populated address rendered as `{"street":"中策路 1 号","city":"杭州",…}` — while a `location` field sitting next to it in the same field group rendered formatted, and the create/edit dialog rendered the very same value as proper Street / City / State / ZIP / Country inputs. The gap was display-side only: the input registry has always carried `address`. Both read surfaces the detail page exposes are affected and both are fixed, because they share one `displayValue` path — read mode, and the inline-edit read state (the row carrying the pencil affordance, before a field is actually being edited).
930
+
931
+ Layout is not invented for the read side. `AddressField`'s readonly branch already collapsed a stored address to a single line, and that rule — `Street, City, State ZIP, Country`, with `state` and the postal code sharing one comma group — is now the _only_ implementation, moved into a pure `address-format` module that both surfaces call. A readonly form and a detail page therefore cannot spell one stored address two ways; a second copy next to the renderer would have been a rule that drifts. The module is deliberately React-free, so the eagerly-loaded barrel can format a cell without pulling `AddressField` and its inputs out of the lazy widget chunk.
932
+
933
+ Partial values degrade the way the readonly line already did: absent, non-string and whitespace-only parts are dropped rather than spaced over, so a street-only address renders as `中策路 1 号` and never as `, , ,` or as the string `undefined`. Legacy records whose postal code was written under `zipCode` (objectstack#5143) still render it, matching what the input widget reads.
934
+
935
+ Nothing is silently swallowed by the change: a value the formatter cannot recognize — an object carrying no known part — keeps today's compact-JSON rendering rather than disappearing, and `{}` or a null value shows the usual empty placeholder. A plain string address passes straight through. `location`, `geolocation`, and the genuinely structural `json` / `object` types are untouched.
936
+
937
+ The address _input_ is unchanged on every surface, including the create/edit dialog.
938
+
939
+ - dde7283: `chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431)
940
+
941
+ Both registrations destructured `schema` and `className` and forwarded everything else. `SchemaRenderer` hands a registered component the authored node's own keys, the contents of its `props` container, the ARIA it resolved and the host's trailing props — so all of it became attributes on the chat root `div`, because React passes unknown lowercase attributes through in silence and stringifies object values. Measured through the real SDUI path with a data-source adapter attached: **14 non-DOM attributes on each widget**, including `datasource="[object Object]"` (the injected adapter, which only appears on a deployment that really loads data) and a camelCase `arialabel` sitting next to the resolved `aria-label`, so the element carried each ARIA value twice under two spellings — one of them meaningless to assistive technology.
942
+
943
+ Both are now consume-or-whitelist: configuration is read off `schema` as before, the evaluated `disabled` verdict is consumed by name, and only `toDomProps`' output reaches the element. The resolved `aria-label` / `aria-describedby`, `role`, `id`, `tabIndex` and the `data-*` family still arrive — dropping them would have been an accessibility regression dressed as a leak fix, so the pin asserts the delivered set exactly, not just the absent one. `chatbot-floating` is untouched: its content mounts through a portal and its root never spread.
944
+
945
+ `@object-ui/core` gains the shared executor this migration needs (`utils/dom-props.ts`): `toDomProps` for the SDUI widget contract, plus `pickDomProps` — the mechanism — for a package whose own contract declares a different key set. That is the objectui#4409 dependency direction: plugin packages declare `@object-ui/core` and must not grow a dependency on `@object-ui/fields` to reach a whitelist.
946
+
947
+ `@object-ui/fields` keeps its own key list and its compile-time bindings, and now executes them through core's mechanism. Its behaviour is unchanged and its exported `DomProps<P>` is the same structural type. The two lists differ for measured reasons and no longer can drift silently: `name` and `disabled` are legal only on form controls, which is what every field widget renders and what `FieldWidgetComponentProps` declares, while `role` is resolved by `SchemaRenderer` for every SDUI node and is not part of the field contract. A new assertion binds every shared key in both directions, with `role` named as the single deliberate exception.
948
+
949
+ - 0f21348: Currency amounts now follow each currency's own ISO 4217 fraction-digit
950
+ convention instead of a hardcoded 2 (objectui#4361).
951
+
952
+ Both currency formatting paths in `@object-ui/fields` picked a fraction-digit
953
+ width and handed it to `Intl.NumberFormat`, which OVERRIDES the digit count
954
+ `Intl` already knows for the currency being rendered. `formatCurrency` derived
955
+ its width from the VALUE's wholeness alone (`isWhole ? 0 : 2` — a literal 2 for
956
+ every currency on earth), and `CurrencyField` defaulted an undeclared
957
+ `precision` to the same literal. So a yen amount was printed with cents the
958
+ currency does not have and a dinar amount with one digit fewer than it does:
959
+
960
+ | | before | after |
961
+ | ------------ | -------------- | ----------- |
962
+ | JPY `1234.5` | `¥1,234.50` | `¥1,235` |
963
+ | KWD `1.5` | `KWD 1.50` | `KWD 1.500` |
964
+ | CLP `1234.5` | `CLP 1,234.50` | `CLP 1,235` |
965
+ | BHD `2.5` | `BHD 2.50` | `BHD 2.500` |
966
+ | USD `1234.5` | `$1,234.50` | `$1,234.50` |
967
+ | USD `1234` | `$1,234` | `$1,234` |
968
+
969
+ Both call sites now derive the width from the currency itself
970
+ (`Intl.NumberFormat(undefined, { style: 'currency', currency })
971
+ .resolvedOptions().maximumFractionDigits`, memoized per code) and switch
972
+ wholeness against THAT.
973
+
974
+ **The whole-number convention is extended, not retired.** Simply dropping both
975
+ bounds and letting `Intl` decide would have fixed the digit count while turning
976
+ `$1,234` back into `$1,234.00` — the Salesforce convention `formatCurrency`
977
+ documents and objectui#4033 pinned. A whole amount still drops the fraction, now
978
+ for every currency: `KWD 1` renders `KWD 1`, not `KWD 1.000`. Two-decimal
979
+ currencies are byte-identical to before, which is why the objectui#4033 and
980
+ objectui#4332 pins pass unchanged.
981
+
982
+ **On `CurrencyField`, an explicitly authored `precision` still wins** — it is
983
+ authored metadata and authored metadata keeps priority, so a JPY field declaring
984
+ `precision: 2` still renders `¥1,234.50`. Only an ABSENT `precision` derives from
985
+ the currency; because that derivation is the widget's one precision, it also
986
+ reaches the spinner `step` and the blur rounding, so a JPY field no longer offers
987
+ a `0.01` step for a currency with no minor unit. Whether a declared `precision`
988
+ that contradicts the currency's ISO 4217 digits should be REJECTED at publish
989
+ time is a contract question, filed upstream in `@objectstack/spec` rather than
990
+ answered here by overriding the author.
991
+
992
+ Reachable wherever the resolved currency is not a 2-decimal one — the field's
993
+ `currency`, `currencyConfig.defaultCurrency`, or the tenant default (ADR-0053).
994
+
995
+ - d2e2caf: fix(fields): `formatCurrency` keeps both cents digits on a fractional amount
996
+
997
+ The symbol branch passed `minimumFractionDigits: 0` against a
998
+ wholeness-switched `maximumFractionDigits`, which handed `Intl` the range
999
+ `[0, 2]` — and `Intl` emits the shortest representation in range, so a real
1000
+ cents value of `.50` was printed as `.5`. Any price ending in a zero cent digit
1001
+ rendered one digit short: `$1,234.50` as `$1,234.5`, `$19.90` as `$19.9`,
1002
+ `$0.50` as `$0.5` — money on a record page and in grid cells reading as a data
1003
+ error rather than a formatting one.
1004
+
1005
+ Both bounds now take the same wholeness-switched width, so the function
1006
+ delivers the contract its own doc comment states: a fractional amount shows
1007
+ exactly two digits, a whole amount still drops `.00` (`$1,234`). The
1008
+ no-currency branch and the bad-currency fallback already behaved this way; only
1009
+ the symbol branch disagreed.
1010
+
1011
+ Reaches every consumer of the shared helper: `CurrencyCellRenderer`,
1012
+ `ObjectGrid`, the dashboard `recordFields` and `ObjectGantt`.
1013
+
1014
+ - 3a9021e: fields: the currency adornment has one symbol channel
1015
+
1016
+ `CurrencyField` carried the same one-entry fact twice — a dead `CURRENCY_SYMBOLS`
1017
+ map that nothing read, and a live `currency === 'USD' ? '$' : currency` ternary
1018
+ two lines below it. Both were hand copies of knowledge `Intl` already carries,
1019
+ and both are gone: a new `currencySymbol(currency, locale)` beside
1020
+ `currencyFractionDigits()` reads the `currency` part of the very format the
1021
+ widget's readonly branch already renders amounts with.
1022
+
1023
+ USD is unchanged at the display-locale default. Other currencies now show their
1024
+ real symbol instead of the bare ISO code — `€` for EUR, `¥` for JPY, `£` for
1025
+ GBP — which is what the same widget's readonly mode has always displayed; the
1026
+ edit adornment simply stopped disagreeing with it. Currencies CLDR has no symbol
1027
+ for (KWD, BHD, CHF, ISK, CLP) still render their code, exactly as before.
1028
+
1029
+ - 8f60d73: `@object-ui/fields` and `@object-ui/plugin-editor` stop publishing their test declarations
1030
+
1031
+ Both packages' build tsconfigs set `include: ["src"]` with no test exclude, so every test file entered the declaration program and its `.d.ts` was written into `dist/`. Both are published (`private` is false, `files` contains `dist`), so those declarations shipped: 85 from `@object-ui/fields` and one from `@object-ui/plugin-editor`. Adding the test exclude the other twenty-odd packages already use removes them.
1032
+
1033
+ Nothing else about either artifact moves. Measured by building each package both ways from a cleared `dist/`, then diffing the file lists: `@object-ui/fields` goes from 163 files to 78 and `@object-ui/plugin-editor` from 6 to 5, every one of the 86 disappearances is a `*.test.d.ts`, no file appears, and all 83 surviving files are byte-identical by sha256 — including each package's entry `dist/index.d.ts`. The entry type surface is therefore unchanged and no import can break; this is the tarball shedding files nothing resolved.
1034
+
1035
+ The type coverage those files were a side effect of did not go with them. Because the build program read the tests, these two packages counted as "tests type-checked" in `scripts/check-type-check-coverage.mjs` — a correct verdict reached through an emit nobody wanted. Excluding the tests alone would have silently dropped 86 test files out of every `tsc` program, so the same change adds a `tsconfig.test.json` per package, chained from each package's `type-check` script, and the coverage gate stays at 41 of 41 packages compiling their tests with zero declared debt on both sides of the change.
1036
+
1037
+ - cb13400: One fullscreen long-text editor, hoisted to the package both render paths may import
1038
+
1039
+ The "expand to a full-height dialog" interaction had two independent implementations. `FullscreenTextarea` lived inside the form renderer's built-in (unregistered) `textarea` branch in `@object-ui/components`; `FullscreenFieldEditor` lived in `@object-ui/fields` and served the registered `TextAreaField` / `RichTextField` widgets. They exist because ONE form-level promise — `ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text field as `mobile_fullscreen` — is honoured on two render paths, and each path grew its own answer.
1040
+
1041
+ Two copies of a state machine drift, and these did, in both directions: objectui#3400 measured a read-only long-text field that was fully editable through the built-in branch's dialog (and "Done" wrote the edit into form state), objectui#3402 measured the same write-back hole for `disabled` on the registered path, and objectui#3393 (the dialog title needs the field label) and objectui#3272 (the copy needs i18n) each landed on one side before the other. Every repair was correct and none of them scaled.
1042
+
1043
+ `@object-ui/components` now exports `FullscreenEditor`, a single primitive owning the affordance, the dialog, the draft/commit state machine and the copy. The direction follows the measured import graph rather than fighting it: `@object-ui/fields` depends on `@object-ui/components`, and `components` declares no dependency on `fields` in either `dependencies` or `peerDependencies`, so the shared code can only live in `components`. `FullscreenFieldEditor` becomes a thin wrapper over it and keeps its name, its props and its test-id namespaces, so both hosts and their pins are unchanged.
1044
+
1045
+ The load-bearing part of the merge is that the primitive DEFINES `readOnly` and `disabled` instead of inheriting them by accident. Neither copy defined both: the built-in one grew them under objectui#3400, while the fields one declared only `disabled` and was shielded from `readonly` by its hosts' early return — a single implementation cannot be shielded by one caller's control flow. So both are answered once, and both call paths inherit the same answers: `readOnly` renders no affordance at all (it means "shown plainly", so advertising an expand button the user cannot use is worse than showing none), `disabled` leaves an inert one (it means "not interactive, muted"). Neither relies on the toggle alone, because `disabled` also carries the form's `isSubmitting` and can flip to true while the dialog is already open — so opening refuses independently of the attribute, the injected editor is told, "Done" is disabled, and `onCommit` is gated as the single point where a value leaves for host state.
1046
+
1047
+ No copy changed and no locale pack needed an edit: the primitive consumes the same `form.fullscreen.*` / `common.cancel` keys both copies already read, through `createSafeTranslation` with English defaults byte-identical to the literals, so provider-less hosts render exactly what they did. The now-unread `form.fullscreen.*` defaults are dropped from `useFieldTranslation`, where they would have re-created in the defaults map precisely the duplication this change removes from the components.
1048
+
1049
+ `toggleClassName` is not carried into the new primitive. It was declared on `FullscreenFieldEditorProps` and written by nobody — zero producers repo-wide — and `FullscreenFieldEditor` is not exported from the `@object-ui/fields` barrel, so no consumer outside the package could ever have set it. Minting it as part of a NEW public export in `@object-ui/components` would have published a prop with no producer, the shape objectui#3232/#3233 keeps deleting.
1050
+
1051
+ - bc64bfe: A dependency-gated option list no longer deletes the field's stored value on mount
1052
+
1053
+ The four fixed-option widgets (`SelectField`, `MultiSelectField`, `CheckboxesField`, `RadioField`) end their cascade resolution with a "drop what is no longer offered" effect, and the form renderer runs an equivalent clear of its own over every option field. Both read `resolveCascadingOptions`, which returns an **empty** offered set whenever the list is _gated_ — a declared `dependsOn` parent is still empty. Nothing the field held could be "still offered" against an empty set, so both paths wrote the field empty **on mount, with no interaction**, while the control rendered "Select Country first" beside it: it told the user it could not offer anything, and deleted what they had.
1054
+
1055
+ Gated means **unknown**, not invalid. The cascade clear exists (ADR-0058) so a user-driven parent change prunes a now-invalid child; a withheld list on mount is missing information — the record simply arrived with its controlling field empty (a later-cleared parent, an import, a partially-migrated row) — and that is not a reason to destroy stored data. Both clears now skip while gated, reading the resolver's own `gated` flag rather than re-deriving it from an empty offered set, which would collide with the distinct never-configured case guarded separately in objectui#4220.
1056
+
1057
+ Convergence stays exactly where it belongs: once the parent **is** chosen and the resolved set genuinely excludes the stored value, the prune applies unchanged — including at the moment the gate lifts, so picking a parent whose list does not contain the old value still clears it on that transition. The three states are pinned apart (never-configured / gated / resolved-and-excludes) across all four widgets and the form host, so a future edit cannot collapse them back into one empty-set test.
1058
+
1059
+ Reachable on every host that mounts these widgets with a live record: the form renderer, the grid's inline cell editor, and the detail page's inline editor — where each `onChange` went straight into the record draft the save bar commits.
1060
+
1061
+ - 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
1062
+
1063
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
1064
+
1065
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
1066
+
1067
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
1068
+
1069
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
1070
+
1071
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
1072
+
1073
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
1074
+
1075
+ No behavior, no keys added or removed, no placeholder changed.
1076
+
1077
+ - bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
1078
+
1079
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
1080
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
1081
+ in all ten locales on screens where objectui#3878 had converged everything else on
1082
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
1083
+ was appended to right-to-left text). Both now read `table.search`, which is already
1084
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
1085
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
1086
+ packs. No new keys.
1087
+
1088
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
1089
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
1090
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
1091
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
1092
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
1093
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
1094
+
1095
+ - 433ff9f: An image field's declared `maxSize` is enforced before the upload starts, not after it finishes
1096
+
1097
+ `ImageField` received a `maxSize` and ignored it. `paramToField` copies `maxSize` onto the field config for every action param regardless of type, so an image param declared with a 5 MB limit handed the widget its constraint and the widget uploaded anyway: a 6.3 MB PNG fired the full `presigned → PUT → complete` chain and rendered a thumbnail, with no rejection anywhere. The sibling file param, declared the same way, refused the identical pick without a single request. Reported from a QA run driving the two side by side (objectui#4141).
1098
+
1099
+ Both of the widget's upload doors now check the limit first. The native picker rejects oversize picks before any request, keeping FileField's partial-acceptance rule — the in-limit members of a multi-select still upload, and only the oversize ones are reported. The crop dialog is the second door and needed the check in its own right: the cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a blob _over_ the limit, and it is the crop's size that is uploaded. A rejected pick or crop now surfaces the same message the file widget has always shown, in a new error row — this widget had no surface for rejections before, because it never rejected anything.
1100
+
1101
+ The guard itself moved into one shared `maxSizeError` helper that both widgets call, rather than a second copy living in the image widget. The check is the only thing between a declared limit and a real upload, and a per-widget copy is what let these two drift apart unnoticed in the first place. Both widgets also share the existing `fields.file.exceedsMaxSize` message: it names a file and a limit, says nothing file-specific, and is already translated in all built-in locales, so no new key was added and no translation is pending. FileField's own behavior is unchanged — same threshold, same message, same partial acceptance.
1102
+
1103
+ An undeclared `maxSize` still means unrestricted; the falsy check is preserved deliberately, so a missing limit can never be read as a zero-byte one.
1104
+
1105
+ - e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
1106
+
1107
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
1108
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
1109
+ and written back as whatever the user typed — a bare string.
1110
+
1111
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
1112
+ `checkboxes`, an options-less multi picklist) were offered for editing as
1113
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
1114
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
1115
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
1116
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
1117
+ value its option list never offered.
1118
+
1119
+ Types the switch already routes keep their editors. Everything else that the
1120
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
1121
+ control the form renders, `json` → the code editor included — and only genuinely
1122
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
1123
+ input. A drift guard asserts every field type is exactly one of routed /
1124
+ excluded / delegated / benign, so a new type can no longer inherit the
1125
+ value-destroying default in silence.
1126
+
1127
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
1128
+ value when the field declares no `options` at all. An empty offered set had two
1129
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
1130
+ authored (nothing to decide) — and the second deleted the value on mount, which
1131
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
1132
+ also forwards `autoFocus` to the widget it renders.
1133
+
1134
+ - b953a97: fix(detail): lookup field values link to the referenced record
1135
+
1136
+ A valued lookup on a record detail page rendered as plain text plus a copy
1137
+ button — the referenced document's name was visible but unreachable, so users
1138
+ copied the number and searched for it from the list page instead. Lookup cells
1139
+ inside a related list that pointed at a third object were dead the same way.
1140
+
1141
+ `LookupCellRenderer` — the one cell renderer both surfaces resolve through —
1142
+ now renders the display value as a link to the referenced record. The display
1143
+ name resolution, the copy affordance and every non-lookup field are unchanged,
1144
+ and a lookup with no value still renders its placeholder rather than an empty
1145
+ link.
1146
+
1147
+ The URL is not assembled in the renderer. `RelatedRecordActionsContext` gains
1148
+ an optional `recordHref` / `openRecord` pair, published by the console's
1149
+ `RelatedRecordActionsBridge` from the SAME builder its related-list row
1150
+ navigation already used, so there is one record-route shape rather than a
1151
+ second one. A host that does not provide it (Studio designer, embedded
1152
+ renderers, standalone grids) renders exactly what it rendered before.
1153
+
1154
+ - 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
1155
+
1156
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
1157
+
1158
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
1159
+
1160
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
1161
+
1162
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
1163
+
1164
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
1165
+
1166
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
1167
+
1168
+ - ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
1169
+
1170
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
1171
+ built its dialog placeholder by concatenating the key with three ASCII full
1172
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
1173
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
1174
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
1175
+ readers repo-wide while it still existed in all ten packs.
1176
+
1177
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
1178
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
1179
+ `common` namespace takes a two-member union parameter (`'openChat' |
1180
+ 'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
1181
+
1182
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
1183
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
1184
+ module-local `Record<string, string>` read only when no `LocalizationProvider`
1185
+ is mounted; it is not exported, so removing an entry no reader asks for changes
1186
+ no rendered output and narrows no public type. Hence patch for that package,
1187
+ while the pack change is a minor: deleting a key from `en` narrows the exported
1188
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
1189
+ `common.search` stops type-checking. Same grading, for the same reason, as
1190
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
1191
+ break.
1192
+
1193
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
1194
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
1195
+ reader inherits an unreviewed contract, and a dormant key beside a live
1196
+ `table.search` is where a second dialect gets started. The objectui#4328
1197
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
1198
+
1199
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
1200
+ PR #4397) is a different key and is untouched.
1201
+
1202
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
1203
+ fails if the key returns to any pack, if any package reads or re-declares it, or
1204
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
1205
+ runs call site to key, and none of them can see a key with no call site.
1206
+
1207
+ - 06915b0: fix(i18n): every date branch threads the active locale, so a `zh` session no longer renders half its dates in English
1208
+
1209
+ Date rendering had two locale channels and only one followed the user's
1210
+ language, so the same row could read `逾期 6 天` in one column and `In 3 days`
1211
+ in the next, with a datetime column showing `8/11/2026 12:00 am`
1212
+ (objectui#4468).
1213
+
1214
+ The overdue phrase resolves through the translate fn (the active UI language),
1215
+ while every `Intl` branch took its tag from the raw tenant locale
1216
+ (`useLocalization().locale`) — which is `undefined` on any workspace that never
1217
+ configured one, and `undefined` makes `Intl` use the _machine's_ locale.
1218
+ `DateTimeCellRenderer` passed no tag at all.
1219
+
1220
+ Every date-formatting site in `@object-ui/fields` now resolves through the one
1221
+ existing channel, `useDisplayLocale()` (tenant regional default → active UI
1222
+ language → `en`): `DateCellRenderer` (relative past, relative future, near-today
1223
+ and the beyond-±7-days absolute fallback), `DateTimeCellRenderer`, the read-only
1224
+ `DateField` / `DateTimeField` / `FormulaField` faces, and the sub-grid's
1225
+ temporal cells. English output is unchanged, and the already-localized overdue
1226
+ wording is untouched.
1227
+
1228
+ No public signature changed. `@object-ui/i18n` carries a documentation
1229
+ correction only: `useDisplayLocale`'s docstring claimed `DateCellRenderer`
1230
+ already formatted from this channel, which was the very thing that was not true.
1231
+
1232
+ - Updated dependencies [0e67b53]
1233
+ - Updated dependencies [ceccdcf]
1234
+ - Updated dependencies [d6e5124]
1235
+ - Updated dependencies [debad27]
1236
+ - Updated dependencies [dc2aa3e]
1237
+ - Updated dependencies [ee66e2e]
1238
+ - Updated dependencies [ee26e65]
1239
+ - Updated dependencies [5900ac5]
1240
+ - Updated dependencies [932cbcd]
1241
+ - Updated dependencies [734d186]
1242
+ - Updated dependencies [f650253]
1243
+ - Updated dependencies [3d9769a]
1244
+ - Updated dependencies [8f85f8b]
1245
+ - Updated dependencies [d0c3b26]
1246
+ - Updated dependencies [3fc2971]
1247
+ - Updated dependencies [aca27fa]
1248
+ - Updated dependencies [dde7283]
1249
+ - Updated dependencies [f7c6430]
1250
+ - Updated dependencies [4dadf0d]
1251
+ - Updated dependencies [ae10a01]
1252
+ - Updated dependencies [92876f0]
1253
+ - Updated dependencies [f279deb]
1254
+ - Updated dependencies [4b70d28]
1255
+ - Updated dependencies [eb7f586]
1256
+ - Updated dependencies [e901131]
1257
+ - Updated dependencies [d9d3463]
1258
+ - Updated dependencies [2a40f69]
1259
+ - Updated dependencies [bec3e14]
1260
+ - Updated dependencies [613b167]
1261
+ - Updated dependencies [b4d3c22]
1262
+ - Updated dependencies [1f9b905]
1263
+ - Updated dependencies [cb13400]
1264
+ - Updated dependencies [828549a]
1265
+ - Updated dependencies [e1ade8f]
1266
+ - Updated dependencies [bc64bfe]
1267
+ - Updated dependencies [abb0f81]
1268
+ - Updated dependencies [38ab505]
1269
+ - Updated dependencies [3e19fe7]
1270
+ - Updated dependencies [bb58d1d]
1271
+ - Updated dependencies [5cc847c]
1272
+ - Updated dependencies [fa21254]
1273
+ - Updated dependencies [33c32bf]
1274
+ - Updated dependencies [66fb4fa]
1275
+ - Updated dependencies [b953a97]
1276
+ - Updated dependencies [d7f3e30]
1277
+ - Updated dependencies [6d641c9]
1278
+ - Updated dependencies [7e4f0e5]
1279
+ - Updated dependencies [a84385b]
1280
+ - Updated dependencies [45e1949]
1281
+ - Updated dependencies [92250d6]
1282
+ - Updated dependencies [c1d939f]
1283
+ - Updated dependencies [58bebf6]
1284
+ - Updated dependencies [405e808]
1285
+ - Updated dependencies [49ae9f4]
1286
+ - Updated dependencies [a3ae404]
1287
+ - Updated dependencies [bfdf3d4]
1288
+ - Updated dependencies [bb68488]
1289
+ - Updated dependencies [c0f9a4b]
1290
+ - Updated dependencies [b1e42d0]
1291
+ - Updated dependencies [2459a3e]
1292
+ - Updated dependencies [ac853ce]
1293
+ - Updated dependencies [fa51109]
1294
+ - Updated dependencies [d6aa172]
1295
+ - Updated dependencies [fe52a04]
1296
+ - Updated dependencies [d46f9b8]
1297
+ - Updated dependencies [3f5f87c]
1298
+ - Updated dependencies [2fea4d2]
1299
+ - Updated dependencies [f5e1143]
1300
+ - Updated dependencies [7f1cb33]
1301
+ - Updated dependencies [f148a64]
1302
+ - Updated dependencies [bb68488]
1303
+ - Updated dependencies [2e3b0c0]
1304
+ - Updated dependencies [9461dd3]
1305
+ - Updated dependencies [78fa331]
1306
+ - Updated dependencies [47f551b]
1307
+ - Updated dependencies [31ab1ac]
1308
+ - Updated dependencies [0082db8]
1309
+ - Updated dependencies [ab04728]
1310
+ - Updated dependencies [5bf09fd]
1311
+ - Updated dependencies [06915b0]
1312
+ - Updated dependencies [ff84b05]
1313
+ - @object-ui/i18n@17.5.0
1314
+ - @object-ui/react@17.5.0
1315
+ - @object-ui/components@17.5.0
1316
+ - @object-ui/core@17.5.0
1317
+ - @object-ui/types@17.5.0
1318
+ - @object-ui/providers@17.5.0
1319
+
1320
+ ## 17.4.0
1321
+
1322
+ ### Minor Changes
1323
+
1324
+ - ecae400: Retire the `capability-multiselect` field widget name, which existed only on the docs-site registration path and which nothing ever stamped (objectui#3308, ADR-0049 enforce-or-remove)
1325
+
1326
+ `field:capability-multiselect` was registered by `registerFields()` and only there. That function's sole caller is the docs site, so the key never existed on the live path (`registerAllFields()`, run at module import, iterates `fieldWidgetMap` — which never listed it). A field authored with `widget: 'capability-multiselect'` therefore resolved to nothing in every real application, while the comment above the registration described it as usable from a record form: a code comment promising a capability that does not exist, which is the worst direction for a metadata renderer AI-authored apps read as authority.
1327
+
1328
+ Nothing stamped the hint either. ADR-0056 P1 stamps `permission-facet-link` on all six `sys_permission_set` facets — `system_permissions` included — through the single `ObjectStackAdapter.getObjectSchema` choke point, and P2 put the capability editor in Studio. The widget name was a leftover from an intermediate iteration of that rollout.
1329
+
1330
+ Removed, with a tombstone at each site:
1331
+
1332
+ - `@object-ui/fields` — the `field:capability-multiselect` registration and the comment that advertised it. **Breaking in name only**: the key was unreachable outside the docs site, so no application could have resolved it. A field still carrying the hint now degrades to its declared `type` renderer, the defined behavior for an unregistered widget.
1333
+ - `@object-ui/plugin-detail` — `InlineFieldInput`'s `widget === 'capability-multiselect'` branch, the hint's last honoring surface. Leaving one consumer for a name no producer emits and no form resolves is the same declared-vs-enforced split, inverted. The sibling `permission-facet-link` branch is untouched and pinned.
1334
+ - `@object-ui/components` — the dead `capability-multiselect` entry in the form renderer's `DATA_SOURCE_FIELD_TYPES` set, which could never match a resolvable widget.
1335
+ - `@object-ui/plugin-form` — a comment naming `capability-multiselect` as the widget stamped onto `sys_permission_set.system_permissions`; it names `permission-facet-link` now, which is what is actually stamped.
1336
+
1337
+ `CapabilityMultiSelectField` itself is **unchanged and still exported**: Studio's `PermissionMatrixEditor` imports and renders it directly, which is ADR-0056 P2's design. Only the widget name is retired — the component is not a registry field widget and its doc comment now says so.
1338
+
1339
+ `registerFields()` is also **kept**, with its `@deprecated Use registerAllFields() instead` note corrected. The two are not interchangeable: it registers `createFieldRenderer(widget)`, which synthesizes the label, description and the local `value`/`onChange` state that lets a bare field node (`{ type: 'currency', label: 'Amount' }`) render standalone in the docs demos. Retiring it needs a decision about where that demo chrome goes; the note now records that instead of implying a drop-in replacement.
1340
+
1341
+ - 65bb513: fields: remove the docs-demo registration path (`registerFields` + `createFieldRenderer`), and host the docs field examples in a real form
1342
+
1343
+ **Breaking (shipped as `minor` per AGENTS.md §版本号策略 — objectui's major tracks `@objectstack`, so the repo's own breaking changes are `minor` with the break spelled out here):** two public exports are removed from `@object-ui/fields`:
1344
+
1345
+ - `registerFields()` — registered every widget under the same `field:<type>` keys as `registerAllFields()`, but wrapped in a demo-only renderer. Two paths writing the same registry keys meant whoever ran LAST won it for every consumer sharing the registry.
1346
+ - `createFieldRenderer()` — that wrapper. It synthesized a `label`, a description, and a local `useState`/`onChange` around a widget.
1347
+
1348
+ **Migration:** there is nothing to migrate for an application. `registerAllFields()` runs on import of `@object-ui/fields` and is the one registration seam (`registerField(type)` for a single type); no shipped application ever called the removed pair — the docs site was the only caller. Code that rendered a **bare field node** standalone (`{ type: 'currency', label: 'Amount' }`) and relied on the wrapper for label and value state must host the field in a form instead: `{ type: 'form', fields: [{ name: 'amount', label: 'Amount', type: 'currency' }] }`. Seed values through the form's `defaultValues`, not a field-level `value`.
1349
+
1350
+ **Why the removal rather than a relocation** (ruling B of objectui#3798, confirmed by the maintainer; objectui#3308 is the origin, PR #3793 the safety net that first corrected the misleading `@deprecated` note): the wrapper existed only so the documentation could render a bare field node as a labelled, editable input. No application produces that rendering — on the live path a bare field node has no host for its label or its value. So the field docs, which are a first-hand transcription source for AI authors, were teaching a shape that does not work, and an author copying it got a node with neither label nor `onChange`. Relocating the wrapper into the docs site would have preserved that divergence under a new owner. Hosting the examples in a real form removes the reason for the wrapper to exist: the form renderer already owns label and value state, so the docs can only show what an application actually renders.
1351
+
1352
+ The 74 bare-node examples under `examples/schema-catalog/src/schemas/fields-*` are now form-hosted (the other 2 already were). A field's `value` moved to the form's `defaultValues`, because the form renderer spreads react-hook-form's state after the schema props and a field-level `value` is therefore ignored — a catalog guard now pins that so a dead `value` cannot come back.
1353
+
1354
+ - b19162d: Build and publish `@object-ui/fields/style.css` — the subpath the package has always declared and never shipped
1355
+
1356
+ `packages/fields/package.json` has declared `"./style.css": "./dist/index.css"` for the package's entire life, while its build was `tsc && vite build` and the package contained no `.css` file for Vite to extract. **No published version up to and including 17.3.0 contains a stylesheet** — the `@object-ui/fields@17.3.0` tarball has zero `.css` files in it. The subpath did not merely render badly, it failed to resolve: a consumer writing the `@import '@object-ui/fields/style.css'` that the quick-start guide taught got a build error. This release is the first one where that import works, so it is a new capability rather than a repair of a working one, and no existing consumer can be relying on the old behaviour — an import that never resolved has no working callers.
1357
+
1358
+ Removing the export was the cheaper option and was rejected on a measurement: fields' class surface is not a subset of what `@object-ui/components` publishes. 155 classes exist only here, and 17 of them (`hover:bg-accent/30`, `ring-destructive/50`, `bg-primary/20`, …) resolve `@theme` tokens declared in unpublished package source, so no consumer-side Tailwind configuration can generate them. Dropping the export would have made the field widgets permanently under-styled with no supported remedy.
1359
+
1360
+ The new sheet is a **supplement, not a replacement** — it is compiled against the components theme and then has every rule that package's sheet already ships subtracted from it, so it is ~22 kB rather than another ~180 kB of near-duplicate CSS. Import it after the components sheet:
1361
+
1362
+ ```css
1363
+ @import "tailwindcss";
1364
+ @import "@object-ui/components/style.css";
1365
+ @import "@object-ui/fields/style.css";
1366
+ ```
1367
+
1368
+ Also adds a workspace-wide guard (`scripts/__tests__/package-files-exist.test.ts`) that fails when any package exports a subpath its published tarball cannot contain, so a stylesheet export with nothing building it cannot recur silently.
1369
+
1370
+ - 1bd6faa: fix(fields,plugin-form): stop the inline child grid from collapsing `datetime`/`time` columns onto the `date` control
1371
+
1372
+ `deriveMasterDetail`'s `fieldTypeToColumnType` mapped `date`, `datetime` and `time` onto the single `date` grid column type, and `GridField` renders that as `<input type="date">`. The consequence was not cosmetic: that control emits a bare `YYYY-MM-DD` on change, so a user who merely re-picked the **day** of a `datetime` cell silently wrote the record's time component out of existence — a `14:30` became midnight with no warning and no undo.
1373
+
1374
+ `GridColumn['type']` now carries `'datetime'` and `'time'` alongside `'date'`, and each renders its own control with its own read/write adapter:
1375
+
1376
+ - `datetime` → `<input type="datetime-local">`, read through `toDateTimeInputValue` and written back through `fromDateTimeInputValue`, so the stored shape stays ISO-8601 and read and write share one basis (the contract `DateTimeField` already follows, objectui#3127).
1377
+ - `time` → `<input type="time">`, round-tripping the stored zone-less `HH:mm[:ss]` verbatim.
1378
+ - `date` → unchanged.
1379
+
1380
+ The read-only surfaces are fixed with it. `displayText()` and the read-only table both fell through to `String(value)` and printed the raw stored ISO on screen (`2026-06-17T00:00:00.000Z`); each temporal type now formats as itself — a day for `date`, day + local time for `datetime`, the wall clock for `time`. That could not be fixed before the type collapse was undone, because with one column type the renderer had no way to know which of the two to show.
1381
+
1382
+ Authors writing explicit grid `columns` can now declare `type: 'datetime'` / `type: 'time'`; previously those spellings were not part of the exported union.
1383
+
1384
+ ### Patch Changes
1385
+
1386
+ - 0186cdc: `address` widget: the ZIP box now reads and writes `postalCode`, the part name the platform stores
1387
+
1388
+ `AddressField` bound its ZIP input to `zipCode` — a part name that appears
1389
+ nowhere in `@objectstack/spec`. The stored value uses `postalCode`, which
1390
+ `AddressValueSchema` declares and enforces with `$strip` semantics, so the two
1391
+ sides never met:
1392
+
1393
+ - opening a stored address showed an **empty** ZIP box (no input read
1394
+ `postalCode`), while street / city / state / country all populated — four of
1395
+ five parts working is what let this survive review;
1396
+ - anything the user then typed into that box was written back as `zipCode` and
1397
+ **stripped at the contract boundary**. On a new record the postal code was
1398
+ lost outright; on an existing one the correction was silently discarded and
1399
+ the stale stored code remained, with no error anywhere.
1400
+
1401
+ The widget now uses `postalCode` throughout — state key, sub-input id, the
1402
+ `onChange` part name and the read-only formatter. Data written by the previous
1403
+ builds is still **read** through `zipCode` as a compatibility limb, and the
1404
+ first edit of any part normalizes such a record onto `postalCode` rather than
1405
+ writing the split shape back out. Nothing writes `zipCode` any more, and it is
1406
+ deliberately not part of the exported `AddressValue` type, so authoring code
1407
+ cannot spell it.
1408
+
1409
+ `AddressValue` is otherwise unchanged in shape; consumers that referenced
1410
+ `AddressValue['zipCode']` must read `postalCode`.
1411
+
1412
+ Fixes objectstack-ai/objectstack#5143.
1413
+
1414
+ - ea41a59: fix(fields): `BooleanField` uses the control id its host hands down, so a boolean field's visible label is really associated with the switch
1415
+
1416
+ A `boolean` / `checkbox` field inside a form emitted **two** labels with the same text, and the visible one pointed at nothing. `<FormControl>` (a Radix `Slot`) hands the control the id its `<FormLabel htmlFor>` already references, and the widget replaced it with the field name — so `label for="_r_3_-form-item"` had no target while the switch carried `id="notifications"`. Clicking the visible label, the normal affordance for a switch/checkbox row, toggled nothing on every generated form in every app; the accessible name survived only through the widget's own `sr-only` label.
1417
+
1418
+ The widget now honours the id it was handed (`id` is a declared key of the widget contract, forwarded by `toDomProps`) and stops emitting its own label when a host supplied one. Standalone rendering is unchanged: with no host id the id still falls back to the field name and then to `useId()`, and the `sr-only` label — the only accessible name the inline grid editor and the console's action-param dialog have — is still emitted.
1419
+
1420
+ - c3b01a7: Give composite and grouped field widgets a real accessible name: the form renderer now associates its label by IDREF for widgets that declare `labelling: 'group'`, instead of emitting a `<label for>` that nothing labelable answers (objectui#3961).
1421
+
1422
+ Six widgets rendered a visible group label that named **nothing** in the accessibility tree. Measured in a real form, one field per row, reading each label's `for` against the DOM:
1423
+
1424
+ ```
1425
+ address for=…-form-item -> MISSING byLabelText=0 role+name=0
1426
+ geolocation for=…-form-item -> MISSING byLabelText=0 role+name=0
1427
+ checkboxes for=…-form-item -> div byLabelText=0 role+name=0
1428
+ radio for=…-form-item -> div[radiogroup] byLabelText=0 role+name=0
1429
+ rating for=…-form-item -> div byLabelText=0 role+name=0
1430
+ file for=…-form-item -> div[role=button] byLabelText=0 role+name=0
1431
+ ```
1432
+
1433
+ Two shapes, one outcome. `address` / `geolocation` spread the host's id onto their first sub-input and then replaced it with that input's own unique id (objectui#3343, correct in itself), so the `for` named an id no element carried — clicking "Shipping Address" did nothing and the group label was absent from the accessibility tree entirely. `checkboxes` / `radio` / `rating` / `file` kept the id, but on a `div`: a `<label for>` on a non-labelable element is inert HTML — `HTMLLabelElement.control` is `null`, so it activates nothing and contributes no name. A screen reader heard "Street Address", "City", "Alpha", "Beta" — never which group they belonged to.
1434
+
1435
+ The fix is the WAI-ARIA group pattern, driven by a DECLARATION rather than by the host guessing at widget DOM:
1436
+
1437
+ - `@object-ui/core` — `ComponentMeta` gains `labelling?: 'control' | 'group'`. Additive and optional; absent means `'control'`, which is every existing component's behaviour.
1438
+ - `@object-ui/components` — the form renderer reads it. For a `'group'` field the `<FormLabel>` publishes an `id` and drops its `for`, and the widget receives `aria-labelledby`. The single-control path is unchanged down to the attribute: no id on the label, no `aria-labelledby` key on the widget, so no field acquires a second naming channel. `ui/form.tsx` is untouched (Shadcn no-touch) — both halves travel as ordinary props, since `<FormLabel>` spreads props after its own `htmlFor`.
1439
+ - `@object-ui/fields` — the six audited widgets declare `labelling: 'group'`. `address` / `geolocation` move the host id (and only the id) from their first sub-input to the group container; `checkboxes` / `rating` answer a host-supplied `aria-labelledby` with `role="group"`; `radio` keeps Radix's more specific `radiogroup`; `file` takes the name on its dropzone with no invented group layer, because it has exactly one control that merely happens not to be labelable.
1440
+
1441
+ No new key in the widget props contract: `aria-*` is already declared on it and forwarded by `toDomProps`, the same channel `aria-required` (objectui#3290) travels.
1442
+
1443
+ Deliberately unchanged: sub-labels keep naming their own inputs (`aria-labelledby` overrides `<label for>`, so putting the group name on the first sub-input would have replaced "Street Address" with the field name — the concatenated-name outcome this issue rejected), `aria-describedby` stays on the first focusable sub-input where focus can reach it (objectui#3318), the sub-input ids of objectui#3343 do not move, and standalone rendering — the inline grid editor, a bare SDUI node, where nobody hands down an id and there is no host label to point at — emits no role and no IDREF at all.
1444
+
1445
+ A widget that does not declare itself keeps the old `for`, which the label-association tests report as an association resolving to a non-labelable element. Silence was the failure mode being fixed; the default path stays loud.
1446
+
1447
+ - c97a45e: Group-labelled field widgets now consume the host label's IDREF in their readonly and zero-option states, so the visible label names something there too
1448
+
1449
+ A field declared `labelling: 'group'` (objectui#3961) is named by IDREF: the form
1450
+ renderer publishes its `<FormLabel>`'s own `id`, drops the inert `for`, and hands
1451
+ the widget `aria-labelledby` plus the host control `id`. All seven such widgets
1452
+ consumed that pair on their editable surface only. A field-level
1453
+ `readonly: true`, and an option list that resolved to zero offered options, take
1454
+ early returns that render before the container doing the consuming — so the label
1455
+ published an id that no element in the document referenced.
1456
+
1457
+ Measured on the previous `main`, one field per row in a real form, counting the
1458
+ elements that reference the host label's published id:
1459
+
1460
+ ```
1461
+ consumers byLabelText named group
1462
+ multiselect readonly+value 0 0 0
1463
+ multiselect readonly+empty 0 0 0
1464
+ multiselect zeroOptions 0 0 0
1465
+ multiselect editable 1 1 1
1466
+ ```
1467
+
1468
+ All seven measured identically in every readonly state; each is 1 now. The
1469
+ user-visible effect is the one objectui#3961 fixed for editable fields: the
1470
+ visible label was the accessible name of NOTHING, so a readonly "Tags" or
1471
+ "Shipping Address" was announced as loose text next to unattributed values. It is
1472
+ not a regression of #3961 or objectui#3975 — before them these same states
1473
+ emitted a `for` pointing at an id no element carried, which named nothing either.
1474
+ The shape changed from a dangling `for` to an unconsumed IDREF; the defect did
1475
+ not.
1476
+
1477
+ Each readonly surface now carries exactly two keys — the host `id` and
1478
+ `aria-labelledby` — plus the `role="group"` that makes them meaningful, in one
1479
+ shared spelling (`toHostGroupProps`). The narrow pair is deliberate: a readonly
1480
+ display has no focusable control, so `aria-describedby` / `aria-required` /
1481
+ `disabled` / the focus handlers have nothing to announce on, and `name` on a
1482
+ `div` is exactly the DOM leak objectui#3291 sweeps for.
1483
+
1484
+ Two widgets answer with a different role than they do while editable, because
1485
+ they render a different surface: `radio` (editable `radiogroup`) shows the chosen
1486
+ option's label as text with no radios in it, and `file` (editable `button`, the
1487
+ dropzone) shows file names with no dropzone. `role="group"` is also what the
1488
+ shared "no options available" box now answers with for `checkboxes` / `radio` /
1489
+ `multiselect`; the single `select` is not group-labelled, keeps its working
1490
+ `for`, and that box emits nothing new for it.
1491
+
1492
+ Standalone rendering is untouched. The grid's inline cell editor and bare SDUI
1493
+ nodes hand down neither key, so nothing is emitted and the markup stays
1494
+ byte-identical — including the `EmptyValue` placeholder, which keeps its own
1495
+ `aria-label` and gains no role. Hosted-and-empty, that placeholder is the whole
1496
+ readonly surface, so it carries the group props: its `aria-label` ("No value") is
1497
+ then outranked by `aria-labelledby` per accname, which is the intended outcome —
1498
+ on the `generic` role that span carries, an author name is prohibited and was
1499
+ never exposed, so the choice was between the field's name and no name.
1500
+
1501
+ - d8a0be4: A form-hosted `multiselect` field is now NAMED by its visible label. It was the
1502
+ residual of objectui#3961: that issue's probe audited six widgets and fixed
1503
+ them, and re-running the same probe over the whole widget map afterwards put
1504
+ `multiselect` on the byte-identical failure shape as `checkboxes` — the host's
1505
+ `id` kept, but on the chip row's wrapper `div`, where a `<label for>` is inert
1506
+ HTML (`HTMLLabelElement.control` is `null`, so it activates nothing and
1507
+ contributes no accessible name). Measured on the tree that already carried
1508
+ #3961's fix, one field per row:
1509
+
1510
+ ```
1511
+ checkboxes for=(none) ownId=…-group-label byLabelText=1(div[role=group])
1512
+ multiselect for=…-form-item ownId=(none) byLabelText=0
1513
+ ```
1514
+
1515
+ Visually the field had a "Tags" label; a screen reader heard only "Alpha" /
1516
+ "Beta" and nothing about what the set of chips was for.
1517
+
1518
+ No new mechanism — #3961's declaration, applied to one more widget:
1519
+ `multiselect` declares `labelling: 'group'` at the registration boundary, so
1520
+ the form renderer publishes its label's `id` and drops the dead `for`, and the
1521
+ chip row answers with `role="group"` + the handed-down `aria-labelledby`.
1522
+
1523
+ Unchanged on purpose: each chip keeps its own accessible name from its text
1524
+ content (the group name sits one level up and does not override it), and
1525
+ STANDALONE rendering — the grid's inline cell editor, a bare SDUI node, where
1526
+ nobody hands an id and there is no host label to point at — emits no role and
1527
+ no IDREF, exactly as before.
1528
+
1529
+ - f4b97c8: `percent` / `progress` cells now give the NUMBER shrink priority over the decorative bar (objectstack#5066)
1530
+
1531
+ In a narrow clipping container the percent display renderer sacrificed the wrong
1532
+ half of itself. It emitted a `w-16 shrink-0` bar in front of a shrinkable value
1533
+ span, so in a `record:highlights` chip — `basis-[9rem]`, shrinking toward
1534
+ `min-w-[7rem]` as chips are added, clipping with `truncate` — the 64px bar plus
1535
+ the 8px gap consumed the content box and `truncate` removed what was left of the
1536
+ value. A stored `33.33` carried `33%` in the DOM and read as `3` on screen
1537
+ (measured: 79px clip box, 32px text node, 25px overflow), with no ellipsis and
1538
+ nothing in the accessible name to signal the loss: a silently smaller, plausible
1539
+ number rather than a cosmetic glitch. Downstream the app had to override the
1540
+ highlight's render type to `number` to get its digits back, losing the `%` glyph
1541
+ and the bar entirely.
1542
+
1543
+ The priority is now inverted, on the principle that the number is the CONTENT and
1544
+ the bar is DECORATION: the value span is `shrink-0` and the bar carries
1545
+ `min-w-0 shrink`, keeping `w-16` only as its PREFERRED width, so a squeezed
1546
+ container eats the bar and the digits survive intact.
1547
+
1548
+ The bar is deliberately NOT `flex-1`. `flex-1` would let it GROW as well as
1549
+ shrink, stretching the bar across every wide grid cell and changing a surface
1550
+ that has no bug; `w-16` stays the upper bound, so wide containers render exactly
1551
+ as before and only the shrink direction changed. Below roughly 40px of content
1552
+ the bar has collapsed to nothing and the value clips like any other single-line
1553
+ cell — same floor as the plain number renderer, which is as far as a
1554
+ self-contained renderer fix reaches (the chip's own width is a function of how
1555
+ many chips the record has, so the strip's `@container` cannot see it).
1556
+
1557
+ The neighbouring `number` renderer is untouched.
1558
+
1559
+ - c2ecbae: 相关列表 Add 选择器兑现 `add.picker.filter`:作者限定的候选范围现在真的生效
1560
+
1561
+ `record:related_list.add.picker.filter` 被 spec 声明为「Restrict which records the picker offers」,但渲染器从未读过它 —— 挂 `RecordPickerDialog` 时不传任何 filter,对话框照样提供 `picker.object` 的全部记录,选中即建链接行或改父,`os validate` / `os build` 全绿、运行时零诊断。作者写下「只允许指派 active 的岗位」「只允许挂未过期的许可」,得到的是完整候选列表。
1562
+
1563
+ 现在它按原样传给 `RecordPickerDialog` 的 `baseFilter` —— 不是 `lookupFilters`,后者会把条件渲染成用户可编辑的筛选栏行,等于把作者的硬性限制降级成建议。
1564
+
1565
+ `baseFilter` 因此接受两种形状,按结构判别(`Array.isArray`):
1566
+
1567
+ - **`QueryParams.$filter` 记录形式**(依赖型 lookup 链)保持原有的键覆盖语义逐字节不变 —— 级联父值必须**替换**同字段上过期的 `lookupFilters` 条目,而不是与之求交。
1568
+ - **spec 的 `ViewFilterRule[]`** 经 `mergeFilterNodes`(仓内唯一的 filter 下沉口)下沉,19 个 operator 全部无损到达服务端,包括记录形式没有 `$op` 可用的 `before` / `after` / `is_empty` / `is_not_empty`。此处**不新增**第二份 operator 词汇表。
1569
+
1570
+ 槽位类型同时从 `Record<string, any>` 收紧为 `unknown`:前者会接受规则数组(数组满足 `any` 的字符串索引),旧的对象展开再把它压成 `{"0": {...}}`,于是查询去过滤名为 `0` 的列 —— 类型全绿、查询错误、无任何诊断。
1571
+
1572
+ - 11c1e71: Resolve a `select` field declared `multiple: true` to the `field:multiselect` widget, so the object form's visible label actually names the chip picker it renders (objectui#3986).
1573
+
1574
+ `mapFieldTypeToFormType` keyed the widget id on the field's `type` string alone, so an object-schema `{ type: 'select', multiple: true }` picklist — a spec-legal, entirely ordinary shape — became `field:select`. `SelectField` then delegated to `MultiSelectField` on `config.multiple`, so the component that RENDERED was the chip picker while everything keyed on the widget id still answered for the single-value combobox. Above all the label-association declaration (`ComponentMeta.labelling`, objectui#3961), which the form renderer resolves per widget id: the host emitted `<label for>` at the chip row's wrapper `div`, where a `for` is inert — `HTMLLabelElement.control` returns `null`. Visually the field had a label; in the accessibility tree that label named nothing. Measured on the object-form path, `role=group` + accessible name went from 1 for a `multiselect`-typed field (fixed in objectui#3975) to 0 for this one.
1575
+
1576
+ Declaring `select` itself `labelling: 'group'` was not available: a single-value select's trigger is a labelable `button[role=combobox]` whose `for` association works, and a bare `select` is a builtin the renderer resolves without consulting the registry at all. The fix is therefore at the producer — the widget id now carries the arity, so one place decides which widget renders and the declaration can no longer be addressed to a widget that is not rendering.
1577
+
1578
+ - `mapFieldTypeToFormType(fieldType, config?)` takes an optional second argument — the rest of the field definition, of which only `multiple` is read. Existing single-argument calls are unchanged, and so is every type outside the new table: `select` is the only one whose `multiple` form is a different WIDGET. The spec's multi-capable set is larger (select / lookup / file / image, with `radio` on the select branch and `user` storing like `lookup`), but `LookupField`, `FileField` and `ImageField` each render both arities themselves, so their id — and their labelling declaration — is already right for either.
1579
+ - The four object-form producers pass the pair: `ObjectForm`, `DrawerForm`, `ModalForm`, and `sectionFields` (Tabbed / Wizard / Split / Drawer / Modal). In `sectionFields` the id is now computed once from the EFFECTIVE pair, after view-level overrides have merged, because `multiple` is itself a spec `FormField` key: a view restating only `multiple: true` over a single-value object field moves the widget too, and `multiple: false` moves it back.
1580
+ - `SelectField`'s `multiple` delegation is KEPT, not retired. Measured, it stays reachable from three entrances that never consult the alias map: the inline grid editor (`FieldEditWidget` finds `select` in its own table first), `ActionParamDialog` (`resolveFormWidgetType` returns `select` from `fieldWidgetMap` first), and hand-written SDUI addressing `field:select` by name with `multiple` on its metadata.
1581
+
1582
+ Read-only rendering of these widgets is untouched (objectui#4005), as is the built-in `Select` branch (objectui#3976).
1583
+
1584
+ - Updated dependencies [794c497]
1585
+ - Updated dependencies [993336f]
1586
+ - Updated dependencies [f0a625a]
1587
+ - Updated dependencies [b5980f4]
1588
+ - Updated dependencies [8aad9fd]
1589
+ - Updated dependencies [6719877]
1590
+ - Updated dependencies [56ff091]
1591
+ - Updated dependencies [7864f03]
1592
+ - Updated dependencies [0cbdca8]
1593
+ - Updated dependencies [d229dfa]
1594
+ - Updated dependencies [ecae400]
1595
+ - Updated dependencies [4bc6c23]
1596
+ - Updated dependencies [d3e738a]
1597
+ - Updated dependencies [c3b01a7]
1598
+ - Updated dependencies [f5f8744]
1599
+ - Updated dependencies [7ed3360]
1600
+ - Updated dependencies [69becd2]
1601
+ - Updated dependencies [5e52495]
1602
+ - Updated dependencies [0fa5e4d]
1603
+ - Updated dependencies [b750823]
1604
+ - Updated dependencies [5bfaabd]
1605
+ - Updated dependencies [e06810e]
1606
+ - Updated dependencies [ab3ad4f]
1607
+ - Updated dependencies [c2fd122]
1608
+ - Updated dependencies [ac2139c]
1609
+ - Updated dependencies [b14ab3a]
1610
+ - Updated dependencies [e24d767]
1611
+ - Updated dependencies [8c60819]
1612
+ - Updated dependencies [aca561a]
1613
+ - Updated dependencies [e64a52e]
1614
+ - Updated dependencies [844d17f]
1615
+ - Updated dependencies [48132f7]
1616
+ - Updated dependencies [4dcd52a]
1617
+ - Updated dependencies [42ae5c6]
1618
+ - Updated dependencies [0ef9dfd]
1619
+ - Updated dependencies [1d723e3]
1620
+ - Updated dependencies [0109f54]
1621
+ - Updated dependencies [7e5bb5d]
1622
+ - Updated dependencies [fbc23e0]
1623
+ - Updated dependencies [6d762da]
1624
+ - Updated dependencies [e6fdbdc]
1625
+ - Updated dependencies [54233b1]
1626
+ - Updated dependencies [f9faa7d]
1627
+ - Updated dependencies [97b63d7]
1628
+ - Updated dependencies [6bb454a]
1629
+ - Updated dependencies [523be48]
1630
+ - Updated dependencies [7e2b7e9]
1631
+ - Updated dependencies [33526fd]
1632
+ - Updated dependencies [32413ec]
1633
+ - Updated dependencies [c1e1e6b]
1634
+ - @object-ui/components@17.4.0
1635
+ - @object-ui/react@17.4.0
1636
+ - @object-ui/core@17.4.0
1637
+ - @object-ui/i18n@17.4.0
1638
+ - @object-ui/types@17.4.0
1639
+ - @object-ui/providers@17.4.0
1640
+
3
1641
  ## 17.3.0
4
1642
 
5
1643
  ### Minor Changes
@@ -60,6 +1698,7 @@ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
60
1698
  so that line filtered nothing — the comment actively misled. It is gone.
61
1699
 
62
1700
  ## What changed
1701
+
63
1702
  - New `toDomProps(props)`, exported from `@object-ui/fields`. It keeps only
64
1703
  what may legitimately become a DOM attribute and drops the rest.
65
1704
  - The 14 field widgets that spread onto a host element now go through it:
@@ -245,6 +1884,7 @@ props}` _alongside_ `field`. The predictable result was ~30 widgets resolving
245
1884
  compile time rather than work under one host and not another.
246
1885
 
247
1886
  ## What did NOT change
1887
+
248
1888
  - **Host metadata (SDUI JSON) is untouched.** No authored schema changes; this is
249
1889
  a change to how widgets are _written_, not to what apps declare.
250
1890
  - **`schema` is still the universal SDUI prop** every registered component
@@ -1666,6 +3306,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1666
3306
  objects+fields were editable in Studio; this reworks both surfaces.
1667
3307
 
1668
3308
  **Setup (assign + read-only):**
3309
+
1669
3310
  - The six facets (`object_permissions`, `field_permissions`, `system_permissions`,
1670
3311
  `row_level_security`, `tab_permissions`, `admin_scope`) now render read-only on
1671
3312
  the `sys_permission_set` record page as a compact summary (counts, or capability
@@ -1680,6 +3321,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1680
3321
 
1681
3322
  **Studio (design every facet):** the permission matrix editor gains structured
1682
3323
  editors for the facets that were JSON-only —
3324
+
1683
3325
  - **System Capabilities**: a multi-select over the live `sys_capability` registry
1684
3326
  (scope-grouped, labelled chips).
1685
3327
  - **Row-Level Security**: per-policy rows (object · operation · enabled) with CEL
@@ -2455,6 +4097,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2455
4097
  assuming USD.
2456
4098
 
2457
4099
  - d912a60: CRM polish — denser kanban cards, smarter currency, calmer dates.
4100
+
2458
4101
  - **plugin-kanban card body**: drop the verbose `Label: value` two-column
2459
4102
  grid in favor of a single-column dense list (values only, with the
2460
4103
  field label preserved as a hover `title` for accessibility). Pipeline
@@ -2483,6 +4126,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2483
4126
  `defaultCurrency` is set on the field/column, formatting is unchanged.
2484
4127
 
2485
4128
  Fixed call sites:
4129
+
2486
4130
  - `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
2487
4131
  `CurrencyCellRenderer` no longer default-param `'USD'`.
2488
4132
  - `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
@@ -2643,6 +4287,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2643
4287
  - d714e85: Lookup display-name resolution now falls back through a Salesforce-style chain
2644
4288
  when an `$expand`'d reference object lacks a top-level `name`/`label`/
2645
4289
  `display_name`/`title` field:
4290
+
2646
4291
  1. Standard display fields (existing behaviour)
2647
4292
  2. `salutation first_name last_name` composite — handles person records that
2648
4293
  only carry first/last name parts
@@ -2832,6 +4477,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2832
4477
  ### Patch Changes
2833
4478
 
2834
4479
  - 89ae109: Fix click navigation and required-FK form rendering
4480
+
2835
4481
  - **plugin-grid**: ObjectGrid's `getSelectFields()` now always includes `id` in
2836
4482
  the SELECT projection. Previously, when a view configured `columns` without
2837
4483
  `id`, the SQL driver stripped it from results, and row-click handlers silently
@@ -2845,6 +4491,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2845
4491
  to create child records.
2846
4492
 
2847
4493
  - 1b6dc64: fix: complete Tailwind v3→v4 migration cleanup
4494
+
2848
4495
  - Rename deprecated `flex-shrink-0` → `shrink-0` and `flex-grow-N` →
2849
4496
  `grow-N` (Tailwind v4 dropped the long-form aliases). Affects
2850
4497
  data-table, fields/index, FileField, ChatbotEnhanced,
@@ -2909,6 +4556,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2909
4556
  Library builds (vite lib mode) now externalize every non-relative import instead of bundling third-party CJS dependencies into the published dist. This avoids inlined `require("react")` / `require("react-dom")` calls that cause `Calling \`require\` for "react" in an environment that doesn't expose the \`require\` function` runtime errors when consumer apps re-bundle the published dist.
2910
4557
 
2911
4558
  Specifically fixes:
4559
+
2912
4560
  - `@object-ui/plugin-dashboard` no longer inlines `react-grid-layout` (and its transitive `react-draggable` / `react-resizable` CJS bundles). `react-grid-layout` is now declared as a peer dependency so consumers install a single ESM-friendly copy.
2913
4561
  - `@object-ui/components`, `@object-ui/plugin-calendar`, `@object-ui/plugin-charts`, `@object-ui/plugin-designer` no longer inline `react-i18next` / `i18next` / `use-sync-external-store` CJS shims.
2914
4562
  - All plugin packages now use a unified `external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)` rule, ensuring future additions of CJS deps are automatically externalized.
@@ -2971,24 +4619,29 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2971
4619
  - a2d7023: End-user feature batch — forms, designer history, import/export, and PWA offline sync.
2972
4620
 
2973
4621
  **Forms (`@object-ui/fields`, `@object-ui/providers`)**
4622
+
2974
4623
  - `FileField`: native `<input capture="environment">` camera capture for mobile devices, plus a uploading-progress indicator driven by `UploadProvider`.
2975
4624
  - `ImageField`: per-image inline crop/rotate via the lazy-loaded `ImageCropperDialog` (canvas-based, zero new deps).
2976
4625
  - New `UploadProvider` in `@object-ui/providers` with pluggable adapters for S3 and Azure Blob (plus the default object-URL adapter for local previews). XHR-based with progress, abort, and retry.
2977
4626
  - `LookupField`: `lookup.dependsOn: string | string[]` to chain dependent lookups (e.g. State depends on Country); the trigger is gated until parent values are present and the OData `$filter` is built automatically.
2978
4627
 
2979
4628
  **Container-aware widget widths (`@object-ui/components`)**
4629
+
2980
4630
  - New `useResizeObserver(ref)` hook exposing `{ width, height }` of any element. SSR-safe; reads the initial size via `getBoundingClientRect`.
2981
4631
  - `plugin-gantt` and `plugin-kanban` now react to their container size instead of `window.innerWidth`, so they behave correctly inside split panels and dashboards.
2982
4632
 
2983
4633
  **Designer history (`@object-ui/plugin-designer`)**
4634
+
2984
4635
  - `useUndoRedo` (and therefore `useDesignerHistory`) gains `persistKey` + `storage` options to round-trip the undo/redo stack through `sessionStorage`, plus a `clearPersisted()` cleanup helper. Drafts now survive accidental tab refreshes.
2985
4636
  - New `<HistoryPanel>` component renders the timeline visually with one-click jump-to-checkpoint via the new `jumpTo(index)` API.
2986
4637
 
2987
4638
  **Import wizard (`@object-ui/plugin-grid`)**
4639
+
2988
4640
  - Saved column-mapping templates: name, save, re-apply, and delete via a new template bar in the mapping step. Persisted under `objectui:import-templates:${objectName}` (override via `templateStorageKey` / `templateStorage`).
2989
4641
  - Inline validation correction: cells with errors in the preview step are now editable; corrections feed straight into the import without requiring a re-upload, with green-bar status indicators for fixed rows.
2990
4642
 
2991
4643
  **PWA offline sync (`@object-ui/mobile`)**
4644
+
2992
4645
  - New `MemoryOfflineQueue` / `IndexedDbOfflineQueue` (`createOfflineQueue()` picks the best backend) backed by IndexedDB.
2993
4646
  - `createOfflineDataSource(inner, { queue })` wraps any DataSource so mutations issued while offline (or that fail with a network-style error) are queued and replayed in order on reconnect. Includes `replay()`, `drop()`, `clear()`, `pending()`, an `onChange` notifier, and an opt-in `resolveConflict` hook for stale-write conflicts.
2994
4647
  - New `useOfflineSync(source)` hook exposes `{ isOnline, pending, isReplaying, replay, drop, clear }` and auto-replays on the browser's `online` event.
@@ -3016,11 +4669,13 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
3016
4669
  `FormSchema.mobileStickyActions` (new) is the lower-level escape hatch — applied automatically when `mobile.stickyActions` is set on `ObjectFormSchema`.
3017
4670
 
3018
4671
  **`@object-ui/plugin-form`** — `ObjectForm` now:
4672
+
3019
4673
  - propagates `mobile.fullscreenLongText` to every textarea/markdown/html field as `mobile_fullscreen: true`,
3020
4674
  - sets `mobileStickyActions` on the inner form schema and adds `pb-20` padding so content isn't covered by the fixed bar,
3021
4675
  - when `mobile.stepper === true` (or `'auto'` + `useIsMobile()` + > `stepperMinFields` fields), routes the flat field list through the existing `WizardForm` with synthetic single-field "steps" — keeping per-step validation and the existing `Next`/`Back`/`Submit` flow.
3022
4676
 
3023
4677
  **`@object-ui/components`** — the registered `form` renderer adds:
4678
+
3024
4679
  - a `mobileStickyActions` opt-in that turns the action row into a `position: sticky; bottom: 0` bar on small viewports, and
3025
4680
  - an inline `FullscreenTextarea` wrapper used when no field-package widget is registered, providing the same expand-button + edit-dialog UX so the feature works even in lighter setups.
3026
4681