@object-ui/fields 17.2.0 → 17.5.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 (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1523 @@
1
1
  # @object-ui/fields
2
2
 
3
+ ## 17.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ebb4e0e: The date formatter's last three en-US channels now follow the display locale
8
+ (objectui#4272).
9
+
10
+ objectui#4468 (PR #4512) pointed every date _renderer_ at `useDisplayLocale()`.
11
+ Three channels were out of its reach because they are properties of the
12
+ formatter's signature and of its callers rather than of any renderer, so a `zh`
13
+ console still met English dates in three places:
14
+
15
+ - **`formatDate`'s `'short'` branch** hardcoded
16
+ `toLocaleDateString('en-US', { month: 'short' })`, so it rendered an English
17
+ month even when the caller had threaded `options.locale` into that very call.
18
+ Its only consumers are ObjectGrid's two mobile-card date cells, which threaded
19
+ no locale — fixing either half alone moves nothing, so both land here.
20
+ - **`formatDateTime` took no options parameter at all**, so no caller could
21
+ localize it however hard it tried; it always handed `Intl` an `undefined` tag,
22
+ which means the MACHINE's locale — neither of the repo's two locale channels.
23
+ The parameter is optional and lands together with its consumers, plugin-gantt's
24
+ four tooltip call sites.
25
+ - **The lookup picker's MongoDB `$date` fallback** called a bare
26
+ `toLocaleDateString()` with no tag.
27
+
28
+ One resolver everywhere, as before: `useDisplayLocale()` (tenant regional
29
+ default → active UI language → `'en'`). `Intl` accepts `'zh'` verbatim, so there
30
+ is still no mapping table anywhere.
31
+
32
+ English output is byte-identical at every touched site — `en` and `en-US` agree
33
+ on all twelve short month names — and the `'short'` layout itself is unchanged:
34
+ only the month token is localized, the compact `"Jan 15, '24"` shape around it
35
+ is a deliberate fixed layout for narrow cards.
36
+
37
+ `@object-ui/fields` is `minor` because `formatDateTime`'s new optional parameter
38
+ is visible in the package's entry `.d.ts`; the plugin packages' own `.d.ts` files
39
+ are byte-identical, so their change is module-local.
40
+
41
+ - 36310dc: `formatPercent` groups its output and follows the display locale — the last
42
+ tooltip/cell channel (objectui#4553).
43
+
44
+ PR #4557 threaded the gantt tooltip's number and currency rows and measured that
45
+ the percent row could not follow: `formatPercent(value, precision)` took no
46
+ locale parameter, and its whole body was
47
+ `${percentDisplayValue(value).toFixed(precision)}%`. It built no
48
+ `Intl.NumberFormat` and never reached `formatDisplayNumber` — so unlike its
49
+ siblings it did not render in the MACHINE's locale, it rendered in **no** locale:
50
+ an ASCII decimal mark, never a grouping separator, byte-identical on every
51
+ machine.
52
+
53
+ **English output MOVES, and that is the fix.** Because the function never
54
+ grouped, `1235%` was wrong in en-US too, not only in German. Grouping and locale
55
+ therefore land together:
56
+
57
+ | | before | after |
58
+ | ---------- | ------- | -------------- |
59
+ | en, 1234.5 | `1235%` | `1,235%` |
60
+ | de, 1234.5 | `1235%` | `1.235\u00a0%` |
61
+ | de, 80 | `80%` | `80\u00a0%` |
62
+
63
+ Values below the grouping threshold are unchanged in English (`80%`, `12.5%`,
64
+ `33.33%`), so the move is confined to four digits and up. German changes at every
65
+ magnitude, because the no-break space before the sign is part of the locale's
66
+ percent convention — which is what routing through `Intl` buys over appending a
67
+ literal `%`.
68
+
69
+ The scaling contract is untouched: `percentDisplayValue` still disambiguates a
70
+ fraction-stored percent (`0.8` → 80%) from a whole one, so the list cell and the
71
+ dashboard measure formatter still agree.
72
+
73
+ Consumers are threaded in the same change, the parameter never landing
74
+ speculatively:
75
+
76
+ - **fields** — `PercentCellRenderer`, on BOTH of its paths. Its whole-percent
77
+ branch (`progress` / `completion` fields, which store 0-100 and must skip the
78
+ fraction scaling) was a second bare `toFixed` call; leaving it behind would
79
+ have made one grid internally inconsistent, so both branches now share one
80
+ locale-aware body and differ only in the scaling policy.
81
+ - **plugin-gantt** — the tooltip percent row, completing objectui#4553's switch.
82
+ - **plugin-grid** — the mobile card's percent cell, which sits in the same
83
+ density row as a date cell objectui#4272 had already localized.
84
+ - **plugin-dashboard** — `renderFieldValue`'s percent branch. It is a plain
85
+ function rather than a component, so it takes the locale as an optional fourth
86
+ parameter beside the `tenantCurrency` already threaded that way, and both of
87
+ its callers pass it and declare it in their memo dependency arrays.
88
+
89
+ Bumps follow each package's own `.d.ts` diff, measured in both directions.
90
+ `@object-ui/fields` and `@object-ui/plugin-dashboard` are `minor` on the
91
+ objectui#4272 / PR #4544 precedent — quoted from that changeset: "`@object-ui/fields`
92
+ is `minor` because `formatDateTime`'s new optional parameter is visible in the
93
+ package's entry `.d.ts`; the plugin packages' own `.d.ts` files are
94
+ byte-identical, so their change is module-local." Here `formatPercent` and
95
+ `renderFieldValue` each gain an entry-visible optional parameter, while
96
+ plugin-gantt's and plugin-grid's `.d.ts` files are byte-identical and stay
97
+ `patch`.
98
+
99
+ - 52d878a: fix(fields): `formatPercent` renders percentage points directly — ties round half-up and extremes keep every digit
100
+
101
+ `formatPercent` rendered a value that is already in percentage POINTS through
102
+ `Intl`'s `style: 'percent'`, which expects a FRACTION, so the body divided by
103
+ 100 for `Intl` to multiply straight back. That round trip is not
104
+ value-preserving: `Intl` formats from the shortest decimal representation of the
105
+ double it is handed, and the quotient's is not the authored one. A stored
106
+ `1.005` at 2 decimals rendered `1.00%` where half-up on the authored decimal is
107
+ `1.01%`; `1.45` at 1 decimal rendered `1.4%` for `1.5%`. Every case was a
108
+ last-digit off-by-one — the failure mode least likely to be noticed and most
109
+ likely to be trusted.
110
+
111
+ The body now renders through `style: 'percentPoints'` with no scaling round
112
+ trip. Measured on this repo's runner (node v22.22.2 / ICU 78.2), 27,577 of
113
+ 1,200,003 ordinary en-US forms move (0.005-step grid to 2,000, precisions
114
+ 0/1/2), and the same artefact at the top of the double range is gone too:
115
+ `Number.MAX_SAFE_INTEGER` percentage points rendered `9,007,199,254,740,990%`
116
+ and now render `9,007,199,254,740,991%`.
117
+
118
+ The locale percent CONVENTION is unchanged — this is a numeral move only.
119
+ `'percentPoints'` is `Intl`'s `style: 'unit'` / `unit: 'percent'`, re-measured on
120
+ this call shape across 720 combinations (10 locales x 18 values x 4 precisions):
121
+ 0 convention differences, 130 numeral differences. The no-break space in
122
+ de/fr/ru/sv, Turkish's prefixed sign, Arabic's own percent sign and Bengali's
123
+ digits all render exactly as before. Percent SCALING (a stored fraction below 1
124
+ scaling by 100) is upstream of the render and untouched.
125
+
126
+ A percentage point now reads identically in a list cell and in a dashboard
127
+ measure, which `formatMeasure` already rendered this way.
128
+
129
+ - bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
130
+ (objectui#4167, objectstack#4115).
131
+
132
+ The rc.6 bump published nine names this repo already declared locally, on top of
133
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
134
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
135
+ triaged on its own rather than blanket-renamed, because the right answer differs
136
+ per symbol: five bind to the spec, three are renamed because the spec's
137
+ same-named export means something else, five arrive by derivation, and one is a
138
+ declared dialect with a written reason.
139
+
140
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
141
+ `@object-ui/types`** — three exported names changed, because the spec exports the
142
+ same name for a _different_ thing:
143
+
144
+ | package | was | now | what the spec's same-named export actually is |
145
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
147
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
148
+ | `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 |
149
+
150
+ `resolveI18nLabel` is the one where the collision had already started costing
151
+ something. rc.6 widened `I18nLabel` from `string` to
152
+ `string | Record< string, string >`, so the same authored value now reaches
153
+ either resolver — and each answers wrongly, silently, for the other's input: the
154
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
155
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
156
+ tags. The rc.6 bump PR met this and aliased the spec's import as
157
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
158
+ them. That is a review convention, which is what objectstack#4115 exists to
159
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
160
+ name says which vocabulary it resolves at every call site.
161
+
162
+ **Eleven keep their names and are now imported or derived from the spec** instead
163
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
164
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
165
+ `GanttConfig`, plus the three renamed above at their new names.
166
+
167
+ **Four of the copies were losing information, not just duplicating it.**
168
+
169
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
170
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
171
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
172
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
173
+ `quickFilters`, `autoZoomToFilter` — are all read by
174
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
175
+ intersection that existed only because this type did not carry them. It now
176
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
177
+ genuinely local extension; the schema is `$loose` upstream, so that key is
178
+ legal metadata rather than a second dialect.
179
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
180
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
181
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
182
+ `formatted` were missing, under a comment already claiming to be "the part
183
+ names of `AddressSchema`". The widget still renders five inputs; binding the
184
+ type stops it from asserting the platform cannot store the other two, and makes
185
+ the `{ ...address }` write-through say so.
186
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
187
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
188
+ own doc comment names this module as one of the three. It is now the spec's
189
+ array by reference, and the local date-macro bounds table is pinned complete
190
+ against it with `satisfies`, so a preset the schema gains without bounds here
191
+ is a compile error rather than a filter that validates clean and then selects
192
+ nothing.
193
+
194
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
195
+ it is bound directly, with a both-directions type pin that it stays the same type
196
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
197
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
198
+ schemas and are now re-exports — "still exact" is the argument for binding them,
199
+ since a copy with nothing to protect can only drift.
200
+
201
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
202
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
203
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
204
+ moved a derivation the guard could see into an object literal it deliberately
205
+ does not descend into. The dialect is unchanged and its three divergences are
206
+ pinned; which side moves on the refinement itself is objectui#4165.
207
+
208
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
209
+ `@object-ui/layout`: its public type surface now references the spec.
210
+
211
+ ### Patch Changes
212
+
213
+ - e2e6360: A `Field.address` value now reads as a formatted postal address on the record detail page, instead of stringified JSON.
214
+
215
+ 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).
216
+
217
+ 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.
218
+
219
+ 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.
220
+
221
+ 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.
222
+
223
+ The address _input_ is unchanged on every surface, including the create/edit dialog.
224
+
225
+ - dde7283: `chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431)
226
+
227
+ 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.
228
+
229
+ 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.
230
+
231
+ `@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.
232
+
233
+ `@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.
234
+
235
+ - 0f21348: Currency amounts now follow each currency's own ISO 4217 fraction-digit
236
+ convention instead of a hardcoded 2 (objectui#4361).
237
+
238
+ Both currency formatting paths in `@object-ui/fields` picked a fraction-digit
239
+ width and handed it to `Intl.NumberFormat`, which OVERRIDES the digit count
240
+ `Intl` already knows for the currency being rendered. `formatCurrency` derived
241
+ its width from the VALUE's wholeness alone (`isWhole ? 0 : 2` — a literal 2 for
242
+ every currency on earth), and `CurrencyField` defaulted an undeclared
243
+ `precision` to the same literal. So a yen amount was printed with cents the
244
+ currency does not have and a dinar amount with one digit fewer than it does:
245
+
246
+ | | before | after |
247
+ | ------------ | -------------- | ----------- |
248
+ | JPY `1234.5` | `¥1,234.50` | `¥1,235` |
249
+ | KWD `1.5` | `KWD 1.50` | `KWD 1.500` |
250
+ | CLP `1234.5` | `CLP 1,234.50` | `CLP 1,235` |
251
+ | BHD `2.5` | `BHD 2.50` | `BHD 2.500` |
252
+ | USD `1234.5` | `$1,234.50` | `$1,234.50` |
253
+ | USD `1234` | `$1,234` | `$1,234` |
254
+
255
+ Both call sites now derive the width from the currency itself
256
+ (`Intl.NumberFormat(undefined, { style: 'currency', currency })
257
+ .resolvedOptions().maximumFractionDigits`, memoized per code) and switch
258
+ wholeness against THAT.
259
+
260
+ **The whole-number convention is extended, not retired.** Simply dropping both
261
+ bounds and letting `Intl` decide would have fixed the digit count while turning
262
+ `$1,234` back into `$1,234.00` — the Salesforce convention `formatCurrency`
263
+ documents and objectui#4033 pinned. A whole amount still drops the fraction, now
264
+ for every currency: `KWD 1` renders `KWD 1`, not `KWD 1.000`. Two-decimal
265
+ currencies are byte-identical to before, which is why the objectui#4033 and
266
+ objectui#4332 pins pass unchanged.
267
+
268
+ **On `CurrencyField`, an explicitly authored `precision` still wins** — it is
269
+ authored metadata and authored metadata keeps priority, so a JPY field declaring
270
+ `precision: 2` still renders `¥1,234.50`. Only an ABSENT `precision` derives from
271
+ the currency; because that derivation is the widget's one precision, it also
272
+ reaches the spinner `step` and the blur rounding, so a JPY field no longer offers
273
+ a `0.01` step for a currency with no minor unit. Whether a declared `precision`
274
+ that contradicts the currency's ISO 4217 digits should be REJECTED at publish
275
+ time is a contract question, filed upstream in `@objectstack/spec` rather than
276
+ answered here by overriding the author.
277
+
278
+ Reachable wherever the resolved currency is not a 2-decimal one — the field's
279
+ `currency`, `currencyConfig.defaultCurrency`, or the tenant default (ADR-0053).
280
+
281
+ - d2e2caf: fix(fields): `formatCurrency` keeps both cents digits on a fractional amount
282
+
283
+ The symbol branch passed `minimumFractionDigits: 0` against a
284
+ wholeness-switched `maximumFractionDigits`, which handed `Intl` the range
285
+ `[0, 2]` — and `Intl` emits the shortest representation in range, so a real
286
+ cents value of `.50` was printed as `.5`. Any price ending in a zero cent digit
287
+ rendered one digit short: `$1,234.50` as `$1,234.5`, `$19.90` as `$19.9`,
288
+ `$0.50` as `$0.5` — money on a record page and in grid cells reading as a data
289
+ error rather than a formatting one.
290
+
291
+ Both bounds now take the same wholeness-switched width, so the function
292
+ delivers the contract its own doc comment states: a fractional amount shows
293
+ exactly two digits, a whole amount still drops `.00` (`$1,234`). The
294
+ no-currency branch and the bad-currency fallback already behaved this way; only
295
+ the symbol branch disagreed.
296
+
297
+ Reaches every consumer of the shared helper: `CurrencyCellRenderer`,
298
+ `ObjectGrid`, the dashboard `recordFields` and `ObjectGantt`.
299
+
300
+ - 3a9021e: fields: the currency adornment has one symbol channel
301
+
302
+ `CurrencyField` carried the same one-entry fact twice — a dead `CURRENCY_SYMBOLS`
303
+ map that nothing read, and a live `currency === 'USD' ? '$' : currency` ternary
304
+ two lines below it. Both were hand copies of knowledge `Intl` already carries,
305
+ and both are gone: a new `currencySymbol(currency, locale)` beside
306
+ `currencyFractionDigits()` reads the `currency` part of the very format the
307
+ widget's readonly branch already renders amounts with.
308
+
309
+ USD is unchanged at the display-locale default. Other currencies now show their
310
+ real symbol instead of the bare ISO code — `€` for EUR, `¥` for JPY, `£` for
311
+ GBP — which is what the same widget's readonly mode has always displayed; the
312
+ edit adornment simply stopped disagreeing with it. Currencies CLDR has no symbol
313
+ for (KWD, BHD, CHF, ISK, CLP) still render their code, exactly as before.
314
+
315
+ - 8f60d73: `@object-ui/fields` and `@object-ui/plugin-editor` stop publishing their test declarations
316
+
317
+ 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.
318
+
319
+ 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.
320
+
321
+ 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.
322
+
323
+ - cb13400: One fullscreen long-text editor, hoisted to the package both render paths may import
324
+
325
+ 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.
326
+
327
+ 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.
328
+
329
+ `@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.
330
+
331
+ 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.
332
+
333
+ 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.
334
+
335
+ `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.
336
+
337
+ - bc64bfe: A dependency-gated option list no longer deletes the field's stored value on mount
338
+
339
+ 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.
340
+
341
+ 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.
342
+
343
+ 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.
344
+
345
+ 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.
346
+
347
+ - 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
348
+
349
+ 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.
350
+
351
+ **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.
352
+
353
+ 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.
354
+
355
+ 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.
356
+
357
+ **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.
358
+
359
+ **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.
360
+
361
+ No behavior, no keys added or removed, no placeholder changed.
362
+
363
+ - bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
364
+
365
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
366
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
367
+ in all ten locales on screens where objectui#3878 had converged everything else on
368
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
369
+ was appended to right-to-left text). Both now read `table.search`, which is already
370
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
371
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
372
+ packs. No new keys.
373
+
374
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
375
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
376
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
377
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
378
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
379
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
380
+
381
+ - 433ff9f: An image field's declared `maxSize` is enforced before the upload starts, not after it finishes
382
+
383
+ `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).
384
+
385
+ 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.
386
+
387
+ 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.
388
+
389
+ 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.
390
+
391
+ - e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
392
+
393
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
394
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
395
+ and written back as whatever the user typed — a bare string.
396
+
397
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
398
+ `checkboxes`, an options-less multi picklist) were offered for editing as
399
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
400
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
401
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
402
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
403
+ value its option list never offered.
404
+
405
+ Types the switch already routes keep their editors. Everything else that the
406
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
407
+ control the form renders, `json` → the code editor included — and only genuinely
408
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
409
+ input. A drift guard asserts every field type is exactly one of routed /
410
+ excluded / delegated / benign, so a new type can no longer inherit the
411
+ value-destroying default in silence.
412
+
413
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
414
+ value when the field declares no `options` at all. An empty offered set had two
415
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
416
+ authored (nothing to decide) — and the second deleted the value on mount, which
417
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
418
+ also forwards `autoFocus` to the widget it renders.
419
+
420
+ - b953a97: fix(detail): lookup field values link to the referenced record
421
+
422
+ A valued lookup on a record detail page rendered as plain text plus a copy
423
+ button — the referenced document's name was visible but unreachable, so users
424
+ copied the number and searched for it from the list page instead. Lookup cells
425
+ inside a related list that pointed at a third object were dead the same way.
426
+
427
+ `LookupCellRenderer` — the one cell renderer both surfaces resolve through —
428
+ now renders the display value as a link to the referenced record. The display
429
+ name resolution, the copy affordance and every non-lookup field are unchanged,
430
+ and a lookup with no value still renders its placeholder rather than an empty
431
+ link.
432
+
433
+ The URL is not assembled in the renderer. `RelatedRecordActionsContext` gains
434
+ an optional `recordHref` / `openRecord` pair, published by the console's
435
+ `RelatedRecordActionsBridge` from the SAME builder its related-list row
436
+ navigation already used, so there is one record-route shape rather than a
437
+ second one. A host that does not provide it (Studio designer, embedded
438
+ renderers, standalone grids) renders exactly what it rendered before.
439
+
440
+ - 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
441
+
442
+ 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.
443
+
444
+ 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`.
445
+
446
+ `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.
447
+
448
+ 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.
449
+
450
+ 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".
451
+
452
+ `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.
453
+
454
+ - ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
455
+
456
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
457
+ built its dialog placeholder by concatenating the key with three ASCII full
458
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
459
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
460
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
461
+ readers repo-wide while it still existed in all ten packs.
462
+
463
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
464
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
465
+ `common` namespace takes a two-member union parameter (`'openChat' |
466
+ 'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
467
+
468
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
469
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
470
+ module-local `Record<string, string>` read only when no `LocalizationProvider`
471
+ is mounted; it is not exported, so removing an entry no reader asks for changes
472
+ no rendered output and narrows no public type. Hence patch for that package,
473
+ while the pack change is a minor: deleting a key from `en` narrows the exported
474
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
475
+ `common.search` stops type-checking. Same grading, for the same reason, as
476
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
477
+ break.
478
+
479
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
480
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
481
+ reader inherits an unreviewed contract, and a dormant key beside a live
482
+ `table.search` is where a second dialect gets started. The objectui#4328
483
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
484
+
485
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
486
+ PR #4397) is a different key and is untouched.
487
+
488
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
489
+ fails if the key returns to any pack, if any package reads or re-declares it, or
490
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
491
+ runs call site to key, and none of them can see a key with no call site.
492
+
493
+ - 06915b0: fix(i18n): every date branch threads the active locale, so a `zh` session no longer renders half its dates in English
494
+
495
+ Date rendering had two locale channels and only one followed the user's
496
+ language, so the same row could read `逾期 6 天` in one column and `In 3 days`
497
+ in the next, with a datetime column showing `8/11/2026 12:00 am`
498
+ (objectui#4468).
499
+
500
+ The overdue phrase resolves through the translate fn (the active UI language),
501
+ while every `Intl` branch took its tag from the raw tenant locale
502
+ (`useLocalization().locale`) — which is `undefined` on any workspace that never
503
+ configured one, and `undefined` makes `Intl` use the _machine's_ locale.
504
+ `DateTimeCellRenderer` passed no tag at all.
505
+
506
+ Every date-formatting site in `@object-ui/fields` now resolves through the one
507
+ existing channel, `useDisplayLocale()` (tenant regional default → active UI
508
+ language → `en`): `DateCellRenderer` (relative past, relative future, near-today
509
+ and the beyond-±7-days absolute fallback), `DateTimeCellRenderer`, the read-only
510
+ `DateField` / `DateTimeField` / `FormulaField` faces, and the sub-grid's
511
+ temporal cells. English output is unchanged, and the already-localized overdue
512
+ wording is untouched.
513
+
514
+ No public signature changed. `@object-ui/i18n` carries a documentation
515
+ correction only: `useDisplayLocale`'s docstring claimed `DateCellRenderer`
516
+ already formatted from this channel, which was the very thing that was not true.
517
+
518
+ - Updated dependencies [0e67b53]
519
+ - Updated dependencies [ceccdcf]
520
+ - Updated dependencies [d6e5124]
521
+ - Updated dependencies [debad27]
522
+ - Updated dependencies [dc2aa3e]
523
+ - Updated dependencies [ee66e2e]
524
+ - Updated dependencies [ee26e65]
525
+ - Updated dependencies [5900ac5]
526
+ - Updated dependencies [932cbcd]
527
+ - Updated dependencies [734d186]
528
+ - Updated dependencies [f650253]
529
+ - Updated dependencies [3d9769a]
530
+ - Updated dependencies [8f85f8b]
531
+ - Updated dependencies [d0c3b26]
532
+ - Updated dependencies [3fc2971]
533
+ - Updated dependencies [aca27fa]
534
+ - Updated dependencies [dde7283]
535
+ - Updated dependencies [f7c6430]
536
+ - Updated dependencies [4dadf0d]
537
+ - Updated dependencies [ae10a01]
538
+ - Updated dependencies [92876f0]
539
+ - Updated dependencies [f279deb]
540
+ - Updated dependencies [4b70d28]
541
+ - Updated dependencies [eb7f586]
542
+ - Updated dependencies [e901131]
543
+ - Updated dependencies [d9d3463]
544
+ - Updated dependencies [2a40f69]
545
+ - Updated dependencies [bec3e14]
546
+ - Updated dependencies [613b167]
547
+ - Updated dependencies [b4d3c22]
548
+ - Updated dependencies [1f9b905]
549
+ - Updated dependencies [cb13400]
550
+ - Updated dependencies [828549a]
551
+ - Updated dependencies [e1ade8f]
552
+ - Updated dependencies [bc64bfe]
553
+ - Updated dependencies [abb0f81]
554
+ - Updated dependencies [38ab505]
555
+ - Updated dependencies [3e19fe7]
556
+ - Updated dependencies [bb58d1d]
557
+ - Updated dependencies [5cc847c]
558
+ - Updated dependencies [fa21254]
559
+ - Updated dependencies [33c32bf]
560
+ - Updated dependencies [66fb4fa]
561
+ - Updated dependencies [b953a97]
562
+ - Updated dependencies [d7f3e30]
563
+ - Updated dependencies [6d641c9]
564
+ - Updated dependencies [7e4f0e5]
565
+ - Updated dependencies [a84385b]
566
+ - Updated dependencies [45e1949]
567
+ - Updated dependencies [92250d6]
568
+ - Updated dependencies [c1d939f]
569
+ - Updated dependencies [58bebf6]
570
+ - Updated dependencies [405e808]
571
+ - Updated dependencies [49ae9f4]
572
+ - Updated dependencies [a3ae404]
573
+ - Updated dependencies [bfdf3d4]
574
+ - Updated dependencies [bb68488]
575
+ - Updated dependencies [c0f9a4b]
576
+ - Updated dependencies [b1e42d0]
577
+ - Updated dependencies [2459a3e]
578
+ - Updated dependencies [ac853ce]
579
+ - Updated dependencies [fa51109]
580
+ - Updated dependencies [d6aa172]
581
+ - Updated dependencies [fe52a04]
582
+ - Updated dependencies [d46f9b8]
583
+ - Updated dependencies [3f5f87c]
584
+ - Updated dependencies [2fea4d2]
585
+ - Updated dependencies [f5e1143]
586
+ - Updated dependencies [7f1cb33]
587
+ - Updated dependencies [f148a64]
588
+ - Updated dependencies [bb68488]
589
+ - Updated dependencies [2e3b0c0]
590
+ - Updated dependencies [9461dd3]
591
+ - Updated dependencies [78fa331]
592
+ - Updated dependencies [47f551b]
593
+ - Updated dependencies [31ab1ac]
594
+ - Updated dependencies [0082db8]
595
+ - Updated dependencies [ab04728]
596
+ - Updated dependencies [5bf09fd]
597
+ - Updated dependencies [06915b0]
598
+ - Updated dependencies [ff84b05]
599
+ - @object-ui/i18n@17.5.0
600
+ - @object-ui/react@17.5.0
601
+ - @object-ui/components@17.5.0
602
+ - @object-ui/core@17.5.0
603
+ - @object-ui/types@17.5.0
604
+ - @object-ui/providers@17.5.0
605
+
606
+ ## 17.4.0
607
+
608
+ ### Minor Changes
609
+
610
+ - 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)
611
+
612
+ `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.
613
+
614
+ 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.
615
+
616
+ Removed, with a tombstone at each site:
617
+
618
+ - `@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.
619
+ - `@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.
620
+ - `@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.
621
+ - `@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.
622
+
623
+ `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.
624
+
625
+ `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.
626
+
627
+ - 65bb513: fields: remove the docs-demo registration path (`registerFields` + `createFieldRenderer`), and host the docs field examples in a real form
628
+
629
+ **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`:
630
+
631
+ - `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.
632
+ - `createFieldRenderer()` — that wrapper. It synthesized a `label`, a description, and a local `useState`/`onChange` around a widget.
633
+
634
+ **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`.
635
+
636
+ **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.
637
+
638
+ 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.
639
+
640
+ - b19162d: Build and publish `@object-ui/fields/style.css` — the subpath the package has always declared and never shipped
641
+
642
+ `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.
643
+
644
+ 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.
645
+
646
+ 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:
647
+
648
+ ```css
649
+ @import "tailwindcss";
650
+ @import "@object-ui/components/style.css";
651
+ @import "@object-ui/fields/style.css";
652
+ ```
653
+
654
+ 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.
655
+
656
+ - 1bd6faa: fix(fields,plugin-form): stop the inline child grid from collapsing `datetime`/`time` columns onto the `date` control
657
+
658
+ `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.
659
+
660
+ `GridColumn['type']` now carries `'datetime'` and `'time'` alongside `'date'`, and each renders its own control with its own read/write adapter:
661
+
662
+ - `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).
663
+ - `time` → `<input type="time">`, round-tripping the stored zone-less `HH:mm[:ss]` verbatim.
664
+ - `date` → unchanged.
665
+
666
+ 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.
667
+
668
+ Authors writing explicit grid `columns` can now declare `type: 'datetime'` / `type: 'time'`; previously those spellings were not part of the exported union.
669
+
670
+ ### Patch Changes
671
+
672
+ - 0186cdc: `address` widget: the ZIP box now reads and writes `postalCode`, the part name the platform stores
673
+
674
+ `AddressField` bound its ZIP input to `zipCode` — a part name that appears
675
+ nowhere in `@objectstack/spec`. The stored value uses `postalCode`, which
676
+ `AddressValueSchema` declares and enforces with `$strip` semantics, so the two
677
+ sides never met:
678
+
679
+ - opening a stored address showed an **empty** ZIP box (no input read
680
+ `postalCode`), while street / city / state / country all populated — four of
681
+ five parts working is what let this survive review;
682
+ - anything the user then typed into that box was written back as `zipCode` and
683
+ **stripped at the contract boundary**. On a new record the postal code was
684
+ lost outright; on an existing one the correction was silently discarded and
685
+ the stale stored code remained, with no error anywhere.
686
+
687
+ The widget now uses `postalCode` throughout — state key, sub-input id, the
688
+ `onChange` part name and the read-only formatter. Data written by the previous
689
+ builds is still **read** through `zipCode` as a compatibility limb, and the
690
+ first edit of any part normalizes such a record onto `postalCode` rather than
691
+ writing the split shape back out. Nothing writes `zipCode` any more, and it is
692
+ deliberately not part of the exported `AddressValue` type, so authoring code
693
+ cannot spell it.
694
+
695
+ `AddressValue` is otherwise unchanged in shape; consumers that referenced
696
+ `AddressValue['zipCode']` must read `postalCode`.
697
+
698
+ Fixes objectstack-ai/objectstack#5143.
699
+
700
+ - 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
701
+
702
+ 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.
703
+
704
+ 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.
705
+
706
+ - 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).
707
+
708
+ 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:
709
+
710
+ ```
711
+ address for=…-form-item -> MISSING byLabelText=0 role+name=0
712
+ geolocation for=…-form-item -> MISSING byLabelText=0 role+name=0
713
+ checkboxes for=…-form-item -> div byLabelText=0 role+name=0
714
+ radio for=…-form-item -> div[radiogroup] byLabelText=0 role+name=0
715
+ rating for=…-form-item -> div byLabelText=0 role+name=0
716
+ file for=…-form-item -> div[role=button] byLabelText=0 role+name=0
717
+ ```
718
+
719
+ 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.
720
+
721
+ The fix is the WAI-ARIA group pattern, driven by a DECLARATION rather than by the host guessing at widget DOM:
722
+
723
+ - `@object-ui/core` — `ComponentMeta` gains `labelling?: 'control' | 'group'`. Additive and optional; absent means `'control'`, which is every existing component's behaviour.
724
+ - `@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`.
725
+ - `@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.
726
+
727
+ 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.
728
+
729
+ 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.
730
+
731
+ 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.
732
+
733
+ - 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
734
+
735
+ A field declared `labelling: 'group'` (objectui#3961) is named by IDREF: the form
736
+ renderer publishes its `<FormLabel>`'s own `id`, drops the inert `for`, and hands
737
+ the widget `aria-labelledby` plus the host control `id`. All seven such widgets
738
+ consumed that pair on their editable surface only. A field-level
739
+ `readonly: true`, and an option list that resolved to zero offered options, take
740
+ early returns that render before the container doing the consuming — so the label
741
+ published an id that no element in the document referenced.
742
+
743
+ Measured on the previous `main`, one field per row in a real form, counting the
744
+ elements that reference the host label's published id:
745
+
746
+ ```
747
+ consumers byLabelText named group
748
+ multiselect readonly+value 0 0 0
749
+ multiselect readonly+empty 0 0 0
750
+ multiselect zeroOptions 0 0 0
751
+ multiselect editable 1 1 1
752
+ ```
753
+
754
+ All seven measured identically in every readonly state; each is 1 now. The
755
+ user-visible effect is the one objectui#3961 fixed for editable fields: the
756
+ visible label was the accessible name of NOTHING, so a readonly "Tags" or
757
+ "Shipping Address" was announced as loose text next to unattributed values. It is
758
+ not a regression of #3961 or objectui#3975 — before them these same states
759
+ emitted a `for` pointing at an id no element carried, which named nothing either.
760
+ The shape changed from a dangling `for` to an unconsumed IDREF; the defect did
761
+ not.
762
+
763
+ Each readonly surface now carries exactly two keys — the host `id` and
764
+ `aria-labelledby` — plus the `role="group"` that makes them meaningful, in one
765
+ shared spelling (`toHostGroupProps`). The narrow pair is deliberate: a readonly
766
+ display has no focusable control, so `aria-describedby` / `aria-required` /
767
+ `disabled` / the focus handlers have nothing to announce on, and `name` on a
768
+ `div` is exactly the DOM leak objectui#3291 sweeps for.
769
+
770
+ Two widgets answer with a different role than they do while editable, because
771
+ they render a different surface: `radio` (editable `radiogroup`) shows the chosen
772
+ option's label as text with no radios in it, and `file` (editable `button`, the
773
+ dropzone) shows file names with no dropzone. `role="group"` is also what the
774
+ shared "no options available" box now answers with for `checkboxes` / `radio` /
775
+ `multiselect`; the single `select` is not group-labelled, keeps its working
776
+ `for`, and that box emits nothing new for it.
777
+
778
+ Standalone rendering is untouched. The grid's inline cell editor and bare SDUI
779
+ nodes hand down neither key, so nothing is emitted and the markup stays
780
+ byte-identical — including the `EmptyValue` placeholder, which keeps its own
781
+ `aria-label` and gains no role. Hosted-and-empty, that placeholder is the whole
782
+ readonly surface, so it carries the group props: its `aria-label` ("No value") is
783
+ then outranked by `aria-labelledby` per accname, which is the intended outcome —
784
+ on the `generic` role that span carries, an author name is prohibited and was
785
+ never exposed, so the choice was between the field's name and no name.
786
+
787
+ - d8a0be4: A form-hosted `multiselect` field is now NAMED by its visible label. It was the
788
+ residual of objectui#3961: that issue's probe audited six widgets and fixed
789
+ them, and re-running the same probe over the whole widget map afterwards put
790
+ `multiselect` on the byte-identical failure shape as `checkboxes` — the host's
791
+ `id` kept, but on the chip row's wrapper `div`, where a `<label for>` is inert
792
+ HTML (`HTMLLabelElement.control` is `null`, so it activates nothing and
793
+ contributes no accessible name). Measured on the tree that already carried
794
+ #3961's fix, one field per row:
795
+
796
+ ```
797
+ checkboxes for=(none) ownId=…-group-label byLabelText=1(div[role=group])
798
+ multiselect for=…-form-item ownId=(none) byLabelText=0
799
+ ```
800
+
801
+ Visually the field had a "Tags" label; a screen reader heard only "Alpha" /
802
+ "Beta" and nothing about what the set of chips was for.
803
+
804
+ No new mechanism — #3961's declaration, applied to one more widget:
805
+ `multiselect` declares `labelling: 'group'` at the registration boundary, so
806
+ the form renderer publishes its label's `id` and drops the dead `for`, and the
807
+ chip row answers with `role="group"` + the handed-down `aria-labelledby`.
808
+
809
+ Unchanged on purpose: each chip keeps its own accessible name from its text
810
+ content (the group name sits one level up and does not override it), and
811
+ STANDALONE rendering — the grid's inline cell editor, a bare SDUI node, where
812
+ nobody hands an id and there is no host label to point at — emits no role and
813
+ no IDREF, exactly as before.
814
+
815
+ - f4b97c8: `percent` / `progress` cells now give the NUMBER shrink priority over the decorative bar (objectstack#5066)
816
+
817
+ In a narrow clipping container the percent display renderer sacrificed the wrong
818
+ half of itself. It emitted a `w-16 shrink-0` bar in front of a shrinkable value
819
+ span, so in a `record:highlights` chip — `basis-[9rem]`, shrinking toward
820
+ `min-w-[7rem]` as chips are added, clipping with `truncate` — the 64px bar plus
821
+ the 8px gap consumed the content box and `truncate` removed what was left of the
822
+ value. A stored `33.33` carried `33%` in the DOM and read as `3` on screen
823
+ (measured: 79px clip box, 32px text node, 25px overflow), with no ellipsis and
824
+ nothing in the accessible name to signal the loss: a silently smaller, plausible
825
+ number rather than a cosmetic glitch. Downstream the app had to override the
826
+ highlight's render type to `number` to get its digits back, losing the `%` glyph
827
+ and the bar entirely.
828
+
829
+ The priority is now inverted, on the principle that the number is the CONTENT and
830
+ the bar is DECORATION: the value span is `shrink-0` and the bar carries
831
+ `min-w-0 shrink`, keeping `w-16` only as its PREFERRED width, so a squeezed
832
+ container eats the bar and the digits survive intact.
833
+
834
+ The bar is deliberately NOT `flex-1`. `flex-1` would let it GROW as well as
835
+ shrink, stretching the bar across every wide grid cell and changing a surface
836
+ that has no bug; `w-16` stays the upper bound, so wide containers render exactly
837
+ as before and only the shrink direction changed. Below roughly 40px of content
838
+ the bar has collapsed to nothing and the value clips like any other single-line
839
+ cell — same floor as the plain number renderer, which is as far as a
840
+ self-contained renderer fix reaches (the chip's own width is a function of how
841
+ many chips the record has, so the strip's `@container` cannot see it).
842
+
843
+ The neighbouring `number` renderer is untouched.
844
+
845
+ - c2ecbae: 相关列表 Add 选择器兑现 `add.picker.filter`:作者限定的候选范围现在真的生效
846
+
847
+ `record:related_list.add.picker.filter` 被 spec 声明为「Restrict which records the picker offers」,但渲染器从未读过它 —— 挂 `RecordPickerDialog` 时不传任何 filter,对话框照样提供 `picker.object` 的全部记录,选中即建链接行或改父,`os validate` / `os build` 全绿、运行时零诊断。作者写下「只允许指派 active 的岗位」「只允许挂未过期的许可」,得到的是完整候选列表。
848
+
849
+ 现在它按原样传给 `RecordPickerDialog` 的 `baseFilter` —— 不是 `lookupFilters`,后者会把条件渲染成用户可编辑的筛选栏行,等于把作者的硬性限制降级成建议。
850
+
851
+ `baseFilter` 因此接受两种形状,按结构判别(`Array.isArray`):
852
+
853
+ - **`QueryParams.$filter` 记录形式**(依赖型 lookup 链)保持原有的键覆盖语义逐字节不变 —— 级联父值必须**替换**同字段上过期的 `lookupFilters` 条目,而不是与之求交。
854
+ - **spec 的 `ViewFilterRule[]`** 经 `mergeFilterNodes`(仓内唯一的 filter 下沉口)下沉,19 个 operator 全部无损到达服务端,包括记录形式没有 `$op` 可用的 `before` / `after` / `is_empty` / `is_not_empty`。此处**不新增**第二份 operator 词汇表。
855
+
856
+ 槽位类型同时从 `Record<string, any>` 收紧为 `unknown`:前者会接受规则数组(数组满足 `any` 的字符串索引),旧的对象展开再把它压成 `{"0": {...}}`,于是查询去过滤名为 `0` 的列 —— 类型全绿、查询错误、无任何诊断。
857
+
858
+ - 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).
859
+
860
+ `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.
861
+
862
+ 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.
863
+
864
+ - `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.
865
+ - 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.
866
+ - `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.
867
+
868
+ Read-only rendering of these widgets is untouched (objectui#4005), as is the built-in `Select` branch (objectui#3976).
869
+
870
+ - Updated dependencies [794c497]
871
+ - Updated dependencies [993336f]
872
+ - Updated dependencies [f0a625a]
873
+ - Updated dependencies [b5980f4]
874
+ - Updated dependencies [8aad9fd]
875
+ - Updated dependencies [6719877]
876
+ - Updated dependencies [56ff091]
877
+ - Updated dependencies [7864f03]
878
+ - Updated dependencies [0cbdca8]
879
+ - Updated dependencies [d229dfa]
880
+ - Updated dependencies [ecae400]
881
+ - Updated dependencies [4bc6c23]
882
+ - Updated dependencies [d3e738a]
883
+ - Updated dependencies [c3b01a7]
884
+ - Updated dependencies [f5f8744]
885
+ - Updated dependencies [7ed3360]
886
+ - Updated dependencies [69becd2]
887
+ - Updated dependencies [5e52495]
888
+ - Updated dependencies [0fa5e4d]
889
+ - Updated dependencies [b750823]
890
+ - Updated dependencies [5bfaabd]
891
+ - Updated dependencies [e06810e]
892
+ - Updated dependencies [ab3ad4f]
893
+ - Updated dependencies [c2fd122]
894
+ - Updated dependencies [ac2139c]
895
+ - Updated dependencies [b14ab3a]
896
+ - Updated dependencies [e24d767]
897
+ - Updated dependencies [8c60819]
898
+ - Updated dependencies [aca561a]
899
+ - Updated dependencies [e64a52e]
900
+ - Updated dependencies [844d17f]
901
+ - Updated dependencies [48132f7]
902
+ - Updated dependencies [4dcd52a]
903
+ - Updated dependencies [42ae5c6]
904
+ - Updated dependencies [0ef9dfd]
905
+ - Updated dependencies [1d723e3]
906
+ - Updated dependencies [0109f54]
907
+ - Updated dependencies [7e5bb5d]
908
+ - Updated dependencies [fbc23e0]
909
+ - Updated dependencies [6d762da]
910
+ - Updated dependencies [e6fdbdc]
911
+ - Updated dependencies [54233b1]
912
+ - Updated dependencies [f9faa7d]
913
+ - Updated dependencies [97b63d7]
914
+ - Updated dependencies [6bb454a]
915
+ - Updated dependencies [523be48]
916
+ - Updated dependencies [7e2b7e9]
917
+ - Updated dependencies [33526fd]
918
+ - Updated dependencies [32413ec]
919
+ - Updated dependencies [c1e1e6b]
920
+ - @object-ui/components@17.4.0
921
+ - @object-ui/react@17.4.0
922
+ - @object-ui/core@17.4.0
923
+ - @object-ui/i18n@17.4.0
924
+ - @object-ui/types@17.4.0
925
+ - @object-ui/providers@17.4.0
926
+
927
+ ## 17.3.0
928
+
929
+ ### Minor Changes
930
+
931
+ - 3889ffb: Console chrome i18n gaps (objectstack#5407).
932
+
933
+ - A dependency-gated lookup now names its controlling field by its **label**
934
+ instead of its raw API name. The sentence was localized but the interpolated
935
+ name was not, so every locale — English included — read `Select crm_account
936
+ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
937
+ lookup-side counterpart of `emptyHint`, which it already resolves to labels
938
+ for the fixed-option widgets); a name the host does not cover still falls
939
+ back to itself.
940
+ - The page-header overflow trigger's `More actions` accessible name now reads
941
+ `detail.moreActions`, the same key `action:menu`'s own overflow trigger uses,
942
+ so the two cannot diverge per locale.
943
+ - The activity-feed reaction button's `Add reaction` accessible name is now a
944
+ bundle key (`detail.addReaction`, added to all ten packs).
945
+ - The "check the highlighted fields" toast joins field names with a per-locale
946
+ separator (`validation.formInvalidJoiner`) instead of a hardcoded `、`
947
+ (U+3001) — right for zh/ja by accident, wrong in English and every Latin
948
+ locale. Latin packs use `, `, CJK `、`, Arabic `، `.
949
+ - The Spanish `validation.required` / `validation.unique` templates gained
950
+ their own masculine head noun (`El campo {{field}} es obligatorio`) so the
951
+ adjective agrees for feminine field labels too — `Cuenta es obligatorio` was
952
+ ungrammatical.
953
+
954
+ - 19b8c9b: Field widgets no longer spread renderer-only props — or arbitrary keys from a
955
+ field config — onto the DOM element they render (objectui#3291).
956
+
957
+ **Behaviour change:** an unknown key written on a field configuration (or on an
958
+ SDUI `field:*` node) stops becoming an HTML attribute on the rendered control.
959
+ Nothing reads those attributes, but they were serialized into the DOM, into
960
+ snapshots, and into anything scraping rendered markup.
961
+
962
+ ## What was happening
963
+
964
+ Widgets forwarded their leftover props with a bare spread, so whatever a host
965
+ handed them became an attribute. Measured on a real form with a real widget:
966
+
967
+ ```
968
+ <input placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
969
+ zzcanarynum="42" id="…" type="text" value="" name="f">
970
+ ```
971
+
972
+ `zzcanaryobj="[object Object]"` is an ordinary extra key on the field config
973
+ being `String()`-ed onto an attribute. React 19 does not warn about any of it:
974
+ an all-lowercase unknown attribute is passed through in complete silence, which
975
+ is why this went unnoticed.
976
+
977
+ Eleven widgets carried a line that looked like it prevented exactly this:
978
+
979
+ ```ts
980
+ const { inputType, ...domProps } = props as any; // "Filter out non-DOM props"
981
+ ```
982
+
983
+ `inputType` is already stripped by the form renderer before a widget sees it,
984
+ so that line filtered nothing — the comment actively misled. It is gone.
985
+
986
+ ## What changed
987
+
988
+ - New `toDomProps(props)`, exported from `@object-ui/fields`. It keeps only
989
+ what may legitimately become a DOM attribute and drops the rest.
990
+ - The 14 field widgets that spread onto a host element now go through it:
991
+ `text`, `textarea`, `number`, `boolean`, `date`, `datetime`, `time`, `email`,
992
+ `phone`, `url`, `password`, `currency`, `percent`, and `select`.
993
+
994
+ **It is a whitelist, not a list of keys to drop.** The largest leak source is
995
+ not any named renderer prop — it is the open tail of author-supplied keys. The
996
+ form renderer destructures a fixed set of known keys and forwards the rest
997
+ verbatim, and `SchemaRenderer` is wider still: it spreads the whole authored
998
+ node as props with no strip layer at all, so on that path a widget's own spread
999
+ is the only line of defence. A blacklist of today's renderer-only props would
1000
+ pass every canary above and would not stop the next authored key either.
1001
+
1002
+ The forwarded set is the one `FieldWidgetComponentProps` already **declares**:
1003
+ `id`, `name`, `autoFocus`, `tabIndex`, `onBlur`, `onFocus`, `onClick`,
1004
+ `className`, `disabled`, plus `aria-*` and the `data-*` family. Until now that
1005
+ was a type-level claim a widget could violate at runtime just by spreading;
1006
+ `toDomProps` is its executable form.
1007
+
1008
+ Two compile-time assertions tie the helper to the declaration, and it is worth
1009
+ being exact about which drift each one prevents:
1010
+
1011
+ - the contract's DOM pass-through block is now a named type
1012
+ (`FieldWidgetDomProps`), and **both directions are compiler-bound**:
1013
+ forwarding a key the contract does not declare fails to compile, and
1014
+ declaring a DOM key the helper does not forward fails to compile too. The
1015
+ second direction guards _declared but not delivered_ — a key that
1016
+ type-checks, reads as supported, and silently never reaches the element. The
1017
+ leak test structurally cannot see that class of bug: it looks for attributes
1018
+ that arrive, not for ones that go missing.
1019
+ - `className` and `disabled` are bound in the **forward direction only**. They
1020
+ are DOM-legal and are forwarded, but they live in the controlled-input block
1021
+ because widgets also interpret them, so they are deliberately outside
1022
+ `FieldWidgetDomProps`.
1023
+
1024
+ An HTML global attribute the contract does not declare (`role`, say) is no
1025
+ longer forwarded. It only ever arrived through the open spread. If a field node
1026
+ should be able to author one, declare it on `FieldWidgetComponentProps` and add
1027
+ it to the whitelist — the fix belongs at the contract, not in a wider spread.
1028
+
1029
+ ## Regression gate
1030
+
1031
+ A new contract test renders **every** registered field widget through **both**
1032
+ hosts — the real form renderer and `SchemaRenderer` — and fails on any
1033
+ attribute HTML does not define for that element. It walks real DOM attributes
1034
+ rather than listening for React warnings (React 19 is silent for the exact case
1035
+ that leaked), asserts a validation error genuinely rendered before scanning the
1036
+ error variant, and calibrates its own judge against two fixtures: standard
1037
+ markup that must produce zero findings, and planted fake attributes that must
1038
+ all be found. A new widget type is covered automatically — the sweep is derived
1039
+ from the widget registry, so adding one without covering it fails the test.
1040
+
1041
+ - 56409c2: Field widgets are finally told when their field fails validation, and the props
1042
+ slot that carries it takes the name the published contract gives it
1043
+ (objectui#3222).
1044
+
1045
+ **Breaking** for anyone implementing a field widget (see migration below). The
1046
+ repo version policy keeps this a `minor` — objectui's major tracks
1047
+ `@objectstack`'s — so read the bump as "breaking within objectui".
1048
+
1049
+ ## The a11y defect this fixes
1050
+
1051
+ `@objectstack/spec/ui`'s `FieldWidgetPropsSchema` — the published contract that
1052
+ third-party and AI-authored field widgets are written against — has always
1053
+ declared `error?: string`. `@object-ui/fields` declared its own slot as
1054
+ `errorMessage`. That looked like a naming split; it was worse:
1055
+
1056
+ ```
1057
+ producers of `errorMessage` anywhere in packages/ + apps/ : 0
1058
+ reads of `errorMessage` in packages/fields/src : 15 (7 widgets)
1059
+ reads of `props.error` : 0
1060
+ ```
1061
+
1062
+ The slot was dead under BOTH spellings. No host ever passed it: the form
1063
+ renderer showed validation text through its own `<FormMessage/>` and never
1064
+ forwarded the prop. So `EmailField`, `CurrencyField`, `UrlField`,
1065
+ `RichTextField`, `PercentField`, `TextAreaField` and `PhoneField` each computed
1066
+ `aria-invalid={!!errorMessage}` from a value that was `undefined` forever —
1067
+ **`aria-invalid` had never once been set, and a screen reader was never told
1068
+ the field had failed validation.**
1069
+
1070
+ Worse than "never set": `<FormControl>` is a Radix `Slot` that hands its child a
1071
+ CORRECT `aria-invalid`, but a widget's own attribute is written after the props
1072
+ spread, so it wins. Those seven widgets were actively overwriting the right
1073
+ answer with `false`.
1074
+
1075
+ FROM: `renderFieldComponent` received no validation state, and the widget props
1076
+ type declared `errorMessage?: string`, which nothing produced.
1077
+ TO: the form renderer passes react-hook-form's `fieldState.error?.message` down
1078
+ as `error` when it renders a registered widget, and the props type declares
1079
+ `error?: string`. Both ends of the contract are live for the first time; a
1080
+ rename alone would only have swapped one dead key for another.
1081
+
1082
+ ## Migration for widget authors
1083
+
1084
+ ```diff
1085
+ -export function MyField({ value, onChange, field, readonly, errorMessage }: FieldWidgetComponentProps< string >) {
1086
+ - return <Input value={value} aria-invalid={!!errorMessage} />;
1087
+ +export function MyField({ value, onChange, field, readonly, error }: FieldWidgetComponentProps< string >) {
1088
+ + return <Input value={value} aria-invalid={!!error} />;
1089
+ ```
1090
+
1091
+ No alias is kept. `errorMessage` was retained nowhere on purpose — a tolerant
1092
+ second spelling is exactly the de-facto second contract AGENTS.md #0.1 forbids,
1093
+ and it is what would let a missed call site go quiet again. Because
1094
+ objectui#3221 had already removed the type's `[key: string]: any`, every missed
1095
+ site is a compile error rather than a silent `any`, so the compiler — not grep
1096
+ — validated this rename.
1097
+
1098
+ ## Responsibilities are split, not duplicated
1099
+
1100
+ The widget consumes `error` **only** to drive `aria-invalid` on the control it
1101
+ renders (which only it can do — `aria-invalid` has to sit on the input element).
1102
+ The message TEXT stays with `<FormMessage/>` in the form renderer. A widget that
1103
+ also renders the text double-displays it, and the docs, the agent prompt and the
1104
+ tests all now say so.
1105
+
1106
+ For the same reason `required` — also declared by the spec, also never delivered
1107
+ — is deliberately NOT lowered into widget props: the required marker has exactly
1108
+ one author, the renderer's `<FormLabel>`, and giving widgets the flag invites a
1109
+ second asterisk. The a11y state a widget could legitimately carry is
1110
+ `aria-required`, which needs no contract change at all (`AriaAttributes` is
1111
+ already part of the type and widgets already forward it).
1112
+
1113
+ Builtin field types are unaffected: they render inside `<FormControl>`, whose
1114
+ Slot already supplies `aria-invalid`, so `error` is stripped there rather than
1115
+ leaking into the DOM as a stray attribute.
1116
+
1117
+ Docs updated to match: `content/docs/guide/plugin-development.md`,
1118
+ `skills/objectui/guides/plugin-development.md` and
1119
+ `.github/prompts/component.prompt.md` — the last of which additionally used the
1120
+ spec's non-generic type alias as a generic (`FieldWidgetProps< number >`) and
1121
+ destructured a `mode` prop that exists on neither type.
1122
+
1123
+ - 042e09d: **BREAKING (v17)** — field widgets receive their metadata on ONE key, `field`.
1124
+ `schema` is removed from the widget contract (objectui#3233).
1125
+
1126
+ ## What changed
1127
+
1128
+ `schema` was a second carrier for what `field` already means. Two producers fed
1129
+ it: `SchemaRenderer` passed the authored node as `schema`, and the form
1130
+ renderer's `renderFieldComponent` passed `schema={props.field || props.schema ||
1131
+ props}` _alongside_ `field`. The predictable result was ~30 widgets resolving
1132
+ their config as `field || schema` — one concept, two spellings, a de-facto
1133
+ second contract (AGENTS.md #0.1).
1134
+
1135
+ - `FieldWidgetComponentProps` no longer declares `schema`. Reading
1136
+ `props.schema` is now a compile error, not a silent `any`.
1137
+ - Both producers converged. The form renderer passes `field` only. The SDUI node
1138
+ → `field` translation happens exactly once, in a new registration adapter
1139
+ (`withFieldCarrier`), which every built-in field widget is registered through.
1140
+ - All `field || schema` reads in `@object-ui/fields` are now plain `field` reads.
1141
+
1142
+ ## Migrating a widget you wrote
1143
+
1144
+ **Reading the metadata** — replace the fallback with the single key:
1145
+
1146
+ ```diff
1147
+ -const config = field || (props as any).schema;
1148
+ +const config = field;
1149
+ ```
1150
+
1151
+ **Registering a widget** — if your widget can be rendered from a schema node
1152
+ (anything `SchemaRenderer` dispatches, not just forms), wrap it once so it still
1153
+ gets `field`:
1154
+
1155
+ ```diff
1156
+ +import { withFieldCarrier } from '@object-ui/fields';
1157
+ +
1158
+ -ComponentRegistry.register('color', ColorField, { namespace: 'field' });
1159
+ +ComponentRegistry.register('color', withFieldCarrier(ColorField), { namespace: 'field' });
1160
+ ```
1161
+
1162
+ `withFieldCarrier` forwards the node **by reference** — nothing is copied,
1163
+ narrowed or renamed — and consumes `schema` so it cannot reach the DOM through a
1164
+ widget's `...props` spread.
1165
+
1166
+ A third-party widget that still reads `props.schema` and is **not** re-registered
1167
+ through the adapter will read `undefined` in v17 and silently render an empty /
1168
+ default state. That is the deliberate cost of a major boundary: one contract
1169
+ beats N dialects, and a widget that picks the wrong spelling should fail at
1170
+ compile time rather than work under one host and not another.
1171
+
1172
+ ## What did NOT change
1173
+
1174
+ - **Host metadata (SDUI JSON) is untouched.** No authored schema changes; this is
1175
+ a change to how widgets are _written_, not to what apps declare.
1176
+ - **`schema` is still the universal SDUI prop** every registered component
1177
+ receives from `SchemaRenderer` (`element:*`, `page:*`, grids, reports). Only
1178
+ the _field-widget_ contract retired it. In particular `renderFieldComponent`
1179
+ still passes `schema` when a form field type resolves to a plain component
1180
+ through the bare-name fallback (e.g. `type: 'text'` reaching the display text
1181
+ widget) — that component's contract is the node, and dropping it there would
1182
+ render `undefined.className`.
1183
+
1184
+ ## Payload equivalence
1185
+
1186
+ Every path that used to deliver a payload through `schema` now delivers the
1187
+ identical object through `field`, and both halves are pinned by tests asserting
1188
+ **object identity**, not shape:
1189
+
1190
+ - form path — `packages/components/src/renderers/form/__tests__/form-field-carrier.test.tsx`
1191
+ - SDUI path — `packages/fields/src/__tests__/field-carrier-sdui.test.tsx`
1192
+
1193
+ - 30ae33a: `RichTextField` honours `mobile_fullscreen`, so `mobile.fullscreenLongText` is
1194
+ finally true of rich text too (objectui#3301).
1195
+
1196
+ `ObjectFormSchema.mobile.fullscreenLongText` has always been documented as
1197
+ "textarea/rich-text get an expand button", and `ObjectForm` has always stamped
1198
+ `mobile_fullscreen` onto `field:markdown` / `field:html` fields to deliver it.
1199
+ Both of those types resolve to `RichTextField`, and that widget never read the
1200
+ flag: a producer with no consumer. Turning the setting on gave a phone user an
1201
+ expand affordance on their textareas and nothing at all on their markdown or
1202
+ HTML fields, with nothing anywhere reporting that half the feature was inert.
1203
+
1204
+ FROM: `RichTextField` ignored the flag entirely (`grep fullscreen` over that
1205
+ file returned nothing). TO: it reads `field.mobile_fullscreen` — the same single
1206
+ metadata carrier `TextAreaField` reads, and nowhere else — and renders the same
1207
+ expand affordance and full-height editing dialog.
1208
+
1209
+ **The affordance now has one implementation, not two.** One form-level setting
1210
+ should produce one behaviour, so the expand button, the dialog and the
1211
+ draft/commit semantics moved into a shared `FullscreenFieldEditor` that both
1212
+ widgets render; only the EDITOR is per-widget. A second hand-written copy of
1213
+ that state machine would be the same defect this release fixes, with an extra
1214
+ step — it drifts, and nothing reports the drift. The rich-text dialog hosts the
1215
+ widget's real editing surface (same format indicator, same editor), not a bare
1216
+ textarea, so whatever that editor grows into, both positions get it at once.
1217
+
1218
+ Behaviour is identical across the two widgets and unchanged for
1219
+ `TextAreaField`: the dialog seeds its draft from the committed value at open
1220
+ time, keeps typing local (a react-hook-form field is not marked dirty by an
1221
+ edit the user may still cancel), commits once on "Done", and discards on
1222
+ "Cancel". Test ids follow the existing convention per widget —
1223
+ `richtext-fullscreen-toggle` / `-dialog` / `-input` / `-save` alongside the
1224
+ `textarea-*` ones, since a single form can contain both.
1225
+
1226
+ There is deliberately no prop spelling of the flag and no `??` fallback chain in
1227
+ either widget. The field metadata is the one carrier (objectui#3233), so a
1228
+ misspelled or misplaced flag stays inert and visible rather than being quietly
1229
+ caught by a tolerant consumer.
1230
+
1231
+ Also removes a dead type from the producer: `ObjectForm` stamped the flag on
1232
+ `'string-multiline'`, a string that `grep -rn` finds exactly once across both
1233
+ this repo and `objectstack` — that line itself. No producer emitted it, no
1234
+ registry key matched it, no widget read it. The remaining four stamped types
1235
+ (`textarea`, `field:textarea`, `field:markdown`, `field:html`) each have a real
1236
+ reader.
1237
+
1238
+ ### Patch Changes
1239
+
1240
+ - b7165ce: AddressField / GeolocationField sub-inputs now derive their DOM ids from a `useId()` prefix + sub-field name (the RadioField / CheckboxesField `groupId` paradigm) instead of hardcoded literals ("street", "city", "state", "zipCode", "country", "latitude", "longitude"). Two address or geolocation fields in one form no longer produce duplicate DOM ids, and each sub-label's `htmlFor` resolves to and focuses its own field's input instead of the first match in the document (#3343).
1241
+ - 7d08c3f: `TextAreaField` / `RichTextField` now honour `disabled` on their fullscreen editing path. `disabled` used to reach the inline control only: `showFullscreenButton` never consulted it, neither widget forwarded it to `FullscreenFieldEditor`, and that component did not declare the prop at all. A disabled long-text or rich-text field therefore sat correctly greyed out next to a live expand button whose dialog accepted any edit and wrote it straight back through `onCommit` — reproduced dynamically before the fix as toggle `disabled=false`, dialog input `disabled=false`, `onChange` called with "EDITED WHILE DISABLED". The state was easy to miss precisely because the visible control looked right.
1242
+
1243
+ `FullscreenFieldEditor` now declares `disabled`: the expand button stays (disabled means "not interactive, muted", unlike `readonly`, which suppresses the affordance entirely via each widget's read-only early return) but is disabled and refuses to open, the dialog's editor is disabled through a new third `children` argument, and "Done" is both disabled and gated before `onCommit`. The dialog locks on its own rather than trusting the button, because the form renderer folds `isSubmitting` into `disabled` — so a submit starting while the dialog was already open used to leave the field editable for the duration of the submit. Cancel and Esc stay live in every state. This is the registered-widget half of the same defect #3400 / #3401 fixed on the built-in `form.tsx` path, so both render paths now give the same metadata the same behaviour (#3402).
1244
+
1245
+ - 6fe485b: fix(fields): translate the registered path's fullscreen long-text dialog (objectui#3404)
1246
+
1247
+ `FullscreenFieldEditor` — the expand button and dialog that `TextAreaField`
1248
+ (`field:textarea`) and `RichTextField` (`field:markdown` / `field:html`) render
1249
+ when `ObjectFormSchema.mobile.fullscreenLongText` is on — shipped four English
1250
+ literals: the toggle's accessible name (`Edit {label} fullscreen`), the title
1251
+ fallback `Edit text`, `Cancel` and `Done`.
1252
+
1253
+ No translation was missing. `form.fullscreen.*` and `common.cancel` have shipped
1254
+ in all ten locale packs since objectui#3272 translated the built-in branch; this
1255
+ path simply never consumed them. The result was visible inside a SINGLE form: a
1256
+ zh session saw 「取消 / 完成」 on a built-in-rendered long-text field and
1257
+ `Cancel / Done` on a registered-widget one.
1258
+
1259
+ All four now consume those existing keys — **no new keys, no locale-pack
1260
+ change**. The dialog also gained the sr-only `form.fullscreen.description` the
1261
+ built-in branch already carries, so it has an accessible description
1262
+ (`aria-describedby`) instead of none.
1263
+
1264
+ Copy resolves through `useFieldTranslation()` (`createSafeTranslation`), as the
1265
+ built-in branch does, whose English defaults are byte-identical to the literals
1266
+ they replace — so widgets rendered with no `I18nProvider` (standalone/embedded
1267
+ hosts) render exactly what they did before rather than raw i18n keys.
1268
+
1269
+ - 825bbe3: The option widgets' "this list cannot be filled" message now has one source, and
1270
+ it is translated (objectui#3231).
1271
+
1272
+ FROM: `SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` each
1273
+ carried their own copy of the empty/gated state, each destructured the declared
1274
+ `emptyHint` prop into `_emptyHint` and dropped it, and each rendered a hardcoded
1275
+ English literal (`'No options available'`, `` `Select ${…} first` ``) even in a
1276
+ Chinese or Japanese session. TO: one shared `OptionsEmptyState` — the host's
1277
+ `emptyHint` when it supplied one, otherwise a translated fallback
1278
+ (`fields.options.empty` / `fields.options.selectFirst`, added to all ten locale
1279
+ packs).
1280
+
1281
+ `emptyHint` was declared, produced by the form renderer and transported, then
1282
+ lost three times over — so no registered widget could ever render it. All three
1283
+ breaks are fixed, because closing only the last one delivers nothing:
1284
+
1285
+ - `isOptionField` compared the raw resolved type against `'select'` /`'radio'` /
1286
+ `'multiselect'` / `'checkboxes'`. Object-derived forms emit
1287
+ `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none of
1288
+ them, so for every option field coming from an object schema — the normal case
1289
+ in the console — the whole cascade block was skipped and no hint was computed
1290
+ at all. It now normalizes the `field:` prefix, the same normalization
1291
+ `stripRegisteredFieldProps` already applied a few lines below.
1292
+ - `stripRegisteredFieldProps` then removed the `emptyHint` key from what was
1293
+ left. It is now forwarded to the four cascade option types, alongside
1294
+ `dependentValues`. This stays an allow-list rather than a blanket
1295
+ pass-through: every other registered widget spreads its leftover props onto a
1296
+ DOM node, where an unknown `emptyHint` attribute is a React warning.
1297
+ - the widgets themselves discarded it. Keeping it out of the `...props` spread
1298
+ was correct; not using it afterwards was not.
1299
+
1300
+ User-visible effect: a dependency-gated option list now prompts with the
1301
+ controlling field's **label** ("Select Country first") instead of its raw
1302
+ metadata name, in the session's language; an unconfigured list says so in the
1303
+ session's language too. The gate sentence is one i18n key shared by the renderer
1304
+ and the widget fallback, so the two sides cannot word it differently.
1305
+
1306
+ Untouched: the built-in (unregistered) `select` branch of the form renderer,
1307
+ which already consumed `emptyHint`. That is a separate live path.
1308
+
1309
+ - 34d9169: The record picker's filter panel now sends the AUTHORED value of a picked `select` filter option instead of the control's stringified form. Radix `Select` speaks strings — options render as `String(opt.value)` and `onValueChange` returns that string — and the panel stored it as-is, so a filter option whose value is a number or a boolean queried `{ level: "1" }` against records storing `level: 1`. The user picked an option that plainly has records and got an empty list. This bit the `lookup_filters` auto-derivation in particular: `lookup_filters: [{ field: 'level', operator: 'in', value: [1, 2, 3] }]` derives options whose values keep the author's type (`LookupFilterDef.value` is `unknown`), so every non-string option in the picker was unfilterable. The control's string is now mapped back through `col.options` at the control boundary — the control speaks string, the payload keeps the authored type — reusing the `matchOptionValue` / `toControlValue` semantics `@object-ui/components` introduced for the standalone form's select (#3090), rather than coercing in `filterValuesToRecord`, which would have to guess whether `"1"` meant `1` or `"1"`. Options coming from the object schema are strings by spec and round-trip unchanged, and the `number` / `boolean` filter inputs (which already converted explicitly) are untouched — the `select` branch is lifted to their standard, resolving the three-way inconsistency (#3422).
1310
+ - 5881a2c: The lookup "Browse all records" Record Picker's filter panel now offers the
1311
+ options a `select` field declares in its schema (objectui#3336). `LookupField`
1312
+ turns each typed picker column into a filter column, and those carried no
1313
+ `options` — so the filter panel's dropdown opened EMPTY and the column could
1314
+ not be filtered at all, even though the same column's table cells had rendered
1315
+ the authored option labels since objectui#3333.
1316
+
1317
+ `RecordPickerDialog` now fills a `select` filter column's missing `options`
1318
+ from `fieldsMeta` (the referenced object's schema `fields` map) through the
1319
+ same resolver — and the same i18n option translation — the table cells use, so
1320
+ the filter dropdown and the cells can never disagree about what an option is
1321
+ called. Explicitly authored filter `options` still win (including the ones
1322
+ auto-derived from an `in`/`notIn` `lookup_filters` entry), and a select field
1323
+ whose schema declares no options keeps an empty dropdown: no options are
1324
+ synthesised from the loaded page's raw stored values.
1325
+
1326
+ - 9bc3709: The lookup "Browse all records" Record Picker now formats its columns with
1327
+ the same field metadata the list view uses (objectui#3333). Previously the
1328
+ dialog handed cell renderers a bare `{ name, type }` descriptor, so a
1329
+ `select` column had no `options` and fell back to title-casing the raw
1330
+ stored value (`manufacturing` rendered as "Manufacturing" instead of the
1331
+ authored option label, e.g. "03 制造") — while the same field displayed
1332
+ correctly in the list view and on the record detail page.
1333
+
1334
+ `RecordPickerDialog` gains an optional `fieldsMeta` prop (the referenced
1335
+ object's schema `fields` map). When provided, each column's field descriptor
1336
+ is enriched from the schema — `options` (run through the shared i18n option
1337
+ translation), `currency`, `scale`, `precision`, `format`, `reference_to`, … —
1338
+ and columns authored as plain strings in `lookup_columns` inherit the schema
1339
+ field's `type`, so they format identically to typed columns. `LookupField`
1340
+ passes the referenced object's schema it already fetches for `titleFormat`.
1341
+ Callers that don't pass `fieldsMeta` keep the previous behavior.
1342
+
1343
+ - 49f7449: `field:select` now announces its validation state to assistive tech: the
1344
+ widget's DOM pass-through lands on the Radix `SelectTrigger` — the focusable
1345
+ `<button role="combobox">` a user actually interacts with — instead of
1346
+ `Select.Root`, which renders no DOM element of its own and silently dropped
1347
+ every `aria-*` the form renderer delivered (objectui#3306).
1348
+
1349
+ Before this, a required select that failed validation showed the red message
1350
+ while a screen reader was told nothing: `aria-invalid`, `aria-describedby`
1351
+ (the link to the message text) and `aria-required` (objectui#3290's state
1352
+ channel) all landed on Root and vanished. All three now reach the trigger,
1353
+ and the widget computes `aria-invalid` from the published `error` slot after
1354
+ the spread, the objectui#3222 discipline — a valid field explicitly says
1355
+ `aria-invalid="false"` rather than staying mute.
1356
+
1357
+ Two keys deliberately stay on Root: `name` (Root forwards it to the hidden
1358
+ native `<select>` that takes part in form submission) and `disabled` (Root is
1359
+ the single authority that disables trigger, items and hidden select together).
1360
+
1361
+ Guarding the whole class forward, a new registry-wide sweep
1362
+ (`widget-aria-invalid-registry-e2e.test.tsx`, the objectui#3291 leak-sweep
1363
+ paradigm) renders every registered field widget through the real form, drives
1364
+ a real validation failure, and asserts `aria-invalid="true"` appears inside
1365
+ the field's row. The 29 widget types measured not to deliver yet are pinned in
1366
+ a ratchet ledger (tracked in objectui#3318): fixing one turns its ledger row
1367
+ red until the entry is removed, so the ledger only ever shrinks.
1368
+
1369
+ - c7ed4c3: `TagsField` no longer ships a hardcoded Chinese input placeholder
1370
+ (objectui#3342, AGENTS.md Commandment #-1). The placeholder now resolves
1371
+ through the pinned chain: the author-declared `field.placeholder` wins
1372
+ (previously ignored by this widget); otherwise the widget's own copy arrives
1373
+ via `useFieldTranslation()` under the new `fields.tags.placeholder` key, added
1374
+ at full parity across all locale packs (Chinese lives in the zh pack, not in
1375
+ code); with no `I18nProvider` mounted the English default from FIELD_DEFAULTS
1376
+ renders — never a raw key.
1377
+ - 2409e1d: `TextAreaField`'s character counter now announces itself in the session locale. The counter block — rendered only when the field declares `maxLength` — carried the accessible name `Character count: {n} of {max}` as an English literal, and the element is an `aria-live="polite"` region, so a zh/ja/ar session had that English sentence read out on every keystroke while sighted users saw only the language-independent `{n}/{max}` digits. Nothing was wrong on screen, which is why it survived: only screen reader users could perceive it (#3406).
1378
+
1379
+ Unlike #3404, no key existed to consume — none of the ten locale packs had any character-count string. `fields.textarea.characterCount` is new in all ten, interpolating `{{count}}` and `{{max}}` as one sentence rather than parts assembled in code, because `ja` and `ko` put the cap before the count ("of {{max}} characters, {{count}}"), an order no concatenation can produce. The English pack value and the `FIELD_DEFAULTS` fallback are byte-identical to the literal they replace, so an `en` session and a provider-less embed both render exactly what they did before.
1380
+
1381
+ Behaviour is unchanged: `aria-live="polite"` and the per-keystroke recompute are deliberately untouched here and tracked separately (#3408).
1382
+
1383
+ - 789fe3e: `TextAreaField`'s character counter no longer re-announces itself on every keystroke. Measured on `main` in a zh session with `maxLength: 500`, typing a 52-character sentence one character at a time produced 52 distinct screen-reader announcements totalling 979 spoken characters — roughly 19x the text being written, each one cutting off the reader's echo of the letter just typed. The counter element was simultaneously the visible `{n}/{max}` digits, the carrier of the translated sentence and the `aria-live` region itself, so "re-render" and "announce" were the same event; the field also had no `aria-describedby`, so focusing it said nothing about the cap at all (#3408).
1384
+
1385
+ It is now the three-node shape the GOV.UK Design System character-count component uses: the visible digits are `aria-hidden` and purely decorative; the counter sentence (`fields.textarea.characterCount`, unchanged in all ten packs) has moved onto the textarea's `aria-describedby`, so focus reads "Character count: 12 of 500" once and then stays quiet; and a separate visually-hidden `aria-live="polite"` region carries a new near-limit warning, `fields.textarea.charactersRemaining` (new in all ten packs), which stays silent until the value is inside the last 10% or last 20 characters of the cap — whichever the typist reaches first — and updates only after typing pauses for a second. The same 52-keystroke probe now produces zero announcements; a run that types all the way onto a 500-character cap produces five. Any `aria-describedby` the host already supplied (the form renderer's description and error-message ids) is appended to, never replaced.
1386
+
1387
+ No metadata change: the counter still renders exactly when the field declares `maxLength` (or the legacy `max_length`), and a widget rendered with no `I18nProvider` still shows the same English sentences.
1388
+
1389
+ - f789c3b: `TextAreaField`'s fullscreen edit dialog now gives screen reader users the character count it has always shown sighted ones. The dialog's footer counter was a bare `{n}/{max}` span: no accessible name, nothing `aria-live`, and nothing tying it to the dialog's textarea — so browse mode read "5 slash 500" if it happened to sweep the footer, and focusing the input said nothing about the cap at all. The inline surface of the same field has carried a proper three-node counter since #3408, so the fullscreen branch was at zero for the same field, the same cap and the same user (#3417). It is reachable on any phone form with `ObjectFormSchema.mobile.fullscreenLongText` on, for every long-text field that declares a limit.
1390
+
1391
+ The counting UI is now ONE shared `CharacterCount` component that both surfaces render, instead of two hand-written copies that could only drift. In the dialog it renders `aria-hidden` digits plus a visually-hidden description carrying `fields.textarea.characterCount`, wired to the dialog's textarea through `aria-describedby`, so focus reads "Character count: 12 of 500" once and the count follows the draft as it is edited. The description ids are per surface, because the dialog's draft and the committed value diverge as soon as the user types.
1392
+
1393
+ The dialog deliberately gets NO live region: it is a modal opened to write at length, the description already delivers the cap on focus, and the inline surface's `aria-live` region stays mounted behind the overlay. The inline surface is unchanged — same DOM, same threshold-gated debounced announcements, same ten locale packs and the same English fallbacks with no `I18nProvider` mounted. No new i18n keys and no metadata change: the counter still renders exactly when the field declares `maxLength` (or the legacy `max_length`).
1394
+
1395
+ - a321fa4: `TextAreaField`'s mobile fullscreen flag converges on its one real producer
1396
+ (objectui#3232).
1397
+
1398
+ FROM: the widget resolved the "show the expand affordance" decision through a
1399
+ four-way `??` chain — a `mobileFullscreen` (camelCase) prop, the field
1400
+ metadata's `mobile_fullscreen`, a `mobile_fullscreen` prop, and
1401
+ `schema.mobile_fullscreen`. TO: a single read of the field metadata's
1402
+ `mobile_fullscreen`, resolved through the `field || schema` carrier pair every
1403
+ widget in this package already uses.
1404
+
1405
+ No runtime behaviour changes, because three of those four reads were
1406
+ permanently `undefined`:
1407
+
1408
+ - `mobileFullscreen` (camelCase) had **no producer anywhere in the repo** — the
1409
+ only occurrences of that spelling were the widget's own read and the
1410
+ destructure that kept it off the DOM spread. The doc comment nonetheless
1411
+ claimed "the host form passes `mobileFullscreen`", so the contract it
1412
+ described had never held.
1413
+ - `mobile_fullscreen` as a **prop** cannot arrive: the form renderer's
1414
+ `stripRegisteredFieldProps` explicitly removes `mobile_fullscreen` and
1415
+ `fullscreen` from the props forwarded to registered field widgets.
1416
+ - `schema.mobile_fullscreen` was the same object `field || schema` already
1417
+ resolves, so it could only ever restate the metadata read.
1418
+
1419
+ What actually drives the affordance — and is now the only thing that does — is
1420
+ the field metadata flag `ObjectForm` stamps onto long-text fields from
1421
+ `ObjectFormSchema.mobile.fullscreenLongText`. That path is unchanged and is now
1422
+ pinned by tests (button, dialog, and the committed edit), so the cleanup cannot
1423
+ have silently removed the working behaviour.
1424
+
1425
+ Also untouched: the built-in (unregistered) `textarea` branch of the form
1426
+ renderer, which reads `mobile_fullscreen || fullscreen` off the form-field
1427
+ props and renders its own `FullscreenTextarea`. That is a separate live path.
1428
+
1429
+ Why this is worth a changeset rather than a silent tidy-up: reads that nobody
1430
+ writes are not free. They document a contract that does not exist — the next
1431
+ author follows the comment, passes the prop, and is ignored without a word —
1432
+ and a `??` chain that accepts four spellings and rejects none is exactly where
1433
+ a misspelled key hides. With one source, a wrong spelling has no read path left
1434
+ to absorb it. Per AGENTS.md #0.1 and Prime Directive #12, divergence like this
1435
+ converges at the producer, not by accumulating tolerance at the consumer. No
1436
+ host-override prop was invented in its place: inventing a key with no producer
1437
+ is the same mistake in the other direction.
1438
+
1439
+ - 8d8094a: 20 more registered field widgets now announce a failed validation to assistive
1440
+ tech: `multiselect`, `radio`, `checkboxes`, `tags`, `lookup`, `master_detail`,
1441
+ `user`, `owner`, `file`, `image`, `location`, `object`, `color`, `rating`,
1442
+ `code`, `avatar`, `address`, `geolocation`, `qrcode` and `object-ref` carry
1443
+ `aria-invalid="true"` on their real focusable control after a validation
1444
+ failure, where before the red message rendered while a screen reader was told
1445
+ nothing (objectui#3318, the registry-wide gap objectui#3306's sweep measured).
1446
+
1447
+ Each widget follows the objectui#3222/#3306 pattern: the `toDomProps(props)`
1448
+ whitelist spread goes onto the control the user actually focuses — the input,
1449
+ the lookup trigger button, the radiogroup (`role="radiogroup"` is the
1450
+ ARIA-designated carrier for a set of radios), every chip/checkbox/star of the
1451
+ composite option widgets, the upload dropzone/button — followed by an explicit
1452
+ `aria-invalid={!!error}` computed from the published `error` slot. Wrapper
1453
+ `<div>`s never carry the state, and `name` is withheld from non-form-control
1454
+ elements (the objectui#3291 leak class).
1455
+
1456
+ `Combobox` (`@object-ui/components`) now accepts standard button attributes
1457
+ and forwards them to its focusable `role="combobox"` trigger, giving
1458
+ combobox-based widgets an element to deliver `aria-invalid` /
1459
+ `aria-describedby` to — the same seam objectui#3306 opened on
1460
+ `SelectTrigger`.
1461
+
1462
+ Nine types remain on the objectui#3318 ratchet ledger with their blockers
1463
+ documented there (`formula`/`summary`/`auto_number`/`vector` render no
1464
+ focusable control; `grid`, `slider`, `signature` need component-level design;
1465
+ `filter-condition`/`recipient-picker` deliver in their editable states but
1466
+ render a dependency-gate hint with no control in a fresh form).
1467
+
1468
+ - Updated dependencies [18cd432]
1469
+ - Updated dependencies [532cf8b]
1470
+ - Updated dependencies [680080a]
1471
+ - Updated dependencies [a7651e6]
1472
+ - Updated dependencies [d915c47]
1473
+ - Updated dependencies [b71fc92]
1474
+ - Updated dependencies [65516ba]
1475
+ - Updated dependencies [94c5b7c]
1476
+ - Updated dependencies [ca0fa8f]
1477
+ - Updated dependencies [34595eb]
1478
+ - Updated dependencies [3889ffb]
1479
+ - Updated dependencies [5781fb1]
1480
+ - Updated dependencies [7e2406a]
1481
+ - Updated dependencies [9e9e9a9]
1482
+ - Updated dependencies [56409c2]
1483
+ - Updated dependencies [042e09d]
1484
+ - Updated dependencies [9cbcbf4]
1485
+ - Updated dependencies [85c4c9c]
1486
+ - Updated dependencies [fd54c3e]
1487
+ - Updated dependencies [4eeb932]
1488
+ - Updated dependencies [5c856ec]
1489
+ - Updated dependencies [23018cc]
1490
+ - Updated dependencies [53811d1]
1491
+ - Updated dependencies [68b6a28]
1492
+ - Updated dependencies [0554e88]
1493
+ - Updated dependencies [d915c47]
1494
+ - Updated dependencies [f44d872]
1495
+ - Updated dependencies [28b2e65]
1496
+ - Updated dependencies [509104a]
1497
+ - Updated dependencies [825bbe3]
1498
+ - Updated dependencies [6195841]
1499
+ - Updated dependencies [5dd0127]
1500
+ - Updated dependencies [06632e9]
1501
+ - Updated dependencies [a415684]
1502
+ - Updated dependencies [a4cff5b]
1503
+ - Updated dependencies [175bd79]
1504
+ - Updated dependencies [5af2852]
1505
+ - Updated dependencies [f833d3a]
1506
+ - Updated dependencies [a6ec93d]
1507
+ - Updated dependencies [2a9513d]
1508
+ - Updated dependencies [71be406]
1509
+ - Updated dependencies [d22ae31]
1510
+ - Updated dependencies [c7ed4c3]
1511
+ - Updated dependencies [2409e1d]
1512
+ - Updated dependencies [789fe3e]
1513
+ - Updated dependencies [8d8094a]
1514
+ - @object-ui/core@17.3.0
1515
+ - @object-ui/components@17.3.0
1516
+ - @object-ui/types@17.3.0
1517
+ - @object-ui/i18n@17.3.0
1518
+ - @object-ui/react@17.3.0
1519
+ - @object-ui/providers@17.3.0
1520
+
3
1521
  ## 17.2.0
4
1522
 
5
1523
  ### Minor Changes
@@ -1074,6 +2592,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1074
2592
  objects+fields were editable in Studio; this reworks both surfaces.
1075
2593
 
1076
2594
  **Setup (assign + read-only):**
2595
+
1077
2596
  - The six facets (`object_permissions`, `field_permissions`, `system_permissions`,
1078
2597
  `row_level_security`, `tab_permissions`, `admin_scope`) now render read-only on
1079
2598
  the `sys_permission_set` record page as a compact summary (counts, or capability
@@ -1088,6 +2607,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1088
2607
 
1089
2608
  **Studio (design every facet):** the permission matrix editor gains structured
1090
2609
  editors for the facets that were JSON-only —
2610
+
1091
2611
  - **System Capabilities**: a multi-select over the live `sys_capability` registry
1092
2612
  (scope-grouped, labelled chips).
1093
2613
  - **Row-Level Security**: per-policy rows (object · operation · enabled) with CEL
@@ -1863,6 +3383,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1863
3383
  assuming USD.
1864
3384
 
1865
3385
  - d912a60: CRM polish — denser kanban cards, smarter currency, calmer dates.
3386
+
1866
3387
  - **plugin-kanban card body**: drop the verbose `Label: value` two-column
1867
3388
  grid in favor of a single-column dense list (values only, with the
1868
3389
  field label preserved as a hover `title` for accessibility). Pipeline
@@ -1891,6 +3412,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
1891
3412
  `defaultCurrency` is set on the field/column, formatting is unchanged.
1892
3413
 
1893
3414
  Fixed call sites:
3415
+
1894
3416
  - `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
1895
3417
  `CurrencyCellRenderer` no longer default-param `'USD'`.
1896
3418
  - `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
@@ -2051,6 +3573,7 @@ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and th
2051
3573
  - d714e85: Lookup display-name resolution now falls back through a Salesforce-style chain
2052
3574
  when an `$expand`'d reference object lacks a top-level `name`/`label`/
2053
3575
  `display_name`/`title` field:
3576
+
2054
3577
  1. Standard display fields (existing behaviour)
2055
3578
  2. `salutation first_name last_name` composite — handles person records that
2056
3579
  only carry first/last name parts
@@ -2240,6 +3763,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2240
3763
  ### Patch Changes
2241
3764
 
2242
3765
  - 89ae109: Fix click navigation and required-FK form rendering
3766
+
2243
3767
  - **plugin-grid**: ObjectGrid's `getSelectFields()` now always includes `id` in
2244
3768
  the SELECT projection. Previously, when a view configured `columns` without
2245
3769
  `id`, the SQL driver stripped it from results, and row-click handlers silently
@@ -2253,6 +3777,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2253
3777
  to create child records.
2254
3778
 
2255
3779
  - 1b6dc64: fix: complete Tailwind v3→v4 migration cleanup
3780
+
2256
3781
  - Rename deprecated `flex-shrink-0` → `shrink-0` and `flex-grow-N` →
2257
3782
  `grow-N` (Tailwind v4 dropped the long-form aliases). Affects
2258
3783
  data-table, fields/index, FileField, ChatbotEnhanced,
@@ -2317,6 +3842,7 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2317
3842
  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.
2318
3843
 
2319
3844
  Specifically fixes:
3845
+
2320
3846
  - `@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.
2321
3847
  - `@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.
2322
3848
  - All plugin packages now use a unified `external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)` rule, ensuring future additions of CJS deps are automatically externalized.
@@ -2379,24 +3905,29 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2379
3905
  - a2d7023: End-user feature batch — forms, designer history, import/export, and PWA offline sync.
2380
3906
 
2381
3907
  **Forms (`@object-ui/fields`, `@object-ui/providers`)**
3908
+
2382
3909
  - `FileField`: native `<input capture="environment">` camera capture for mobile devices, plus a uploading-progress indicator driven by `UploadProvider`.
2383
3910
  - `ImageField`: per-image inline crop/rotate via the lazy-loaded `ImageCropperDialog` (canvas-based, zero new deps).
2384
3911
  - 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.
2385
3912
  - `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.
2386
3913
 
2387
3914
  **Container-aware widget widths (`@object-ui/components`)**
3915
+
2388
3916
  - New `useResizeObserver(ref)` hook exposing `{ width, height }` of any element. SSR-safe; reads the initial size via `getBoundingClientRect`.
2389
3917
  - `plugin-gantt` and `plugin-kanban` now react to their container size instead of `window.innerWidth`, so they behave correctly inside split panels and dashboards.
2390
3918
 
2391
3919
  **Designer history (`@object-ui/plugin-designer`)**
3920
+
2392
3921
  - `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.
2393
3922
  - New `<HistoryPanel>` component renders the timeline visually with one-click jump-to-checkpoint via the new `jumpTo(index)` API.
2394
3923
 
2395
3924
  **Import wizard (`@object-ui/plugin-grid`)**
3925
+
2396
3926
  - 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`).
2397
3927
  - 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.
2398
3928
 
2399
3929
  **PWA offline sync (`@object-ui/mobile`)**
3930
+
2400
3931
  - New `MemoryOfflineQueue` / `IndexedDbOfflineQueue` (`createOfflineQueue()` picks the best backend) backed by IndexedDB.
2401
3932
  - `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.
2402
3933
  - New `useOfflineSync(source)` hook exposes `{ isOnline, pending, isReplaying, replay, drop, clear }` and auto-replays on the browser's `online` event.
@@ -2424,11 +3955,13 @@ Lin` and no `name` field now renders as `Bob Lin` everywhere — instead of
2424
3955
  `FormSchema.mobileStickyActions` (new) is the lower-level escape hatch — applied automatically when `mobile.stickyActions` is set on `ObjectFormSchema`.
2425
3956
 
2426
3957
  **`@object-ui/plugin-form`** — `ObjectForm` now:
3958
+
2427
3959
  - propagates `mobile.fullscreenLongText` to every textarea/markdown/html field as `mobile_fullscreen: true`,
2428
3960
  - sets `mobileStickyActions` on the inner form schema and adds `pb-20` padding so content isn't covered by the fixed bar,
2429
3961
  - 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.
2430
3962
 
2431
3963
  **`@object-ui/components`** — the registered `form` renderer adds:
3964
+
2432
3965
  - a `mobileStickyActions` opt-in that turns the action row into a `position: sticky; bottom: 0` bar on small viewports, and
2433
3966
  - 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.
2434
3967