@object-ui/plugin-detail 17.6.0 → 18.0.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 (102) hide show
  1. package/CHANGELOG.md +7755 -0
  2. package/README.md +557 -71
  3. package/dist/ActivityTimeline.d.ts.map +1 -1
  4. package/dist/CommentAttachment.d.ts.map +1 -1
  5. package/dist/CommentAuthorControls.d.ts +40 -0
  6. package/dist/CommentAuthorControls.d.ts.map +1 -0
  7. package/dist/ConcurrentUpdateDialog.d.ts +16 -1
  8. package/dist/ConcurrentUpdateDialog.d.ts.map +1 -1
  9. package/dist/DetailSection.d.ts.map +1 -1
  10. package/dist/DetailSectionNode.d.ts +97 -0
  11. package/dist/DetailSectionNode.d.ts.map +1 -0
  12. package/dist/DetailView.d.ts +13 -0
  13. package/dist/DetailView.d.ts.map +1 -1
  14. package/dist/HeaderHighlight.d.ts.map +1 -1
  15. package/dist/HistoryTimeline.d.ts +21 -4
  16. package/dist/HistoryTimeline.d.ts.map +1 -1
  17. package/dist/InlineEditSaveBar.d.ts +68 -0
  18. package/dist/InlineEditSaveBar.d.ts.map +1 -1
  19. package/dist/InlineFieldInput.d.ts +80 -7
  20. package/dist/InlineFieldInput.d.ts.map +1 -1
  21. package/dist/ReactionPicker.d.ts.map +1 -1
  22. package/dist/RecordActivityTimeline.d.ts +32 -4
  23. package/dist/RecordActivityTimeline.d.ts.map +1 -1
  24. package/dist/RecordChatterPanel.d.ts +10 -1
  25. package/dist/RecordChatterPanel.d.ts.map +1 -1
  26. package/dist/RecordComments.d.ts +1 -0
  27. package/dist/RecordComments.d.ts.map +1 -1
  28. package/dist/RecordDetailDrawer.d.ts +20 -6
  29. package/dist/RecordDetailDrawer.d.ts.map +1 -1
  30. package/dist/RecordDetailPanel.d.ts +101 -0
  31. package/dist/RecordDetailPanel.d.ts.map +1 -0
  32. package/dist/RecordMetaFooter.d.ts +1 -1
  33. package/dist/RecordMetaFooter.d.ts.map +1 -1
  34. package/dist/RelatedList.d.ts +83 -10
  35. package/dist/RelatedList.d.ts.map +1 -1
  36. package/dist/ThreadedReplies.d.ts +9 -1
  37. package/dist/ThreadedReplies.d.ts.map +1 -1
  38. package/dist/emptiness.d.ts +142 -0
  39. package/dist/emptiness.d.ts.map +1 -0
  40. package/dist/fieldEnrichment.d.ts +46 -5
  41. package/dist/fieldEnrichment.d.ts.map +1 -1
  42. package/dist/headerColor.d.ts +20 -0
  43. package/dist/headerColor.d.ts.map +1 -0
  44. package/dist/index.d.ts +94 -77
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +5693 -5487
  47. package/dist/index.umd.cjs +2 -5
  48. package/dist/recordPageProvenance.d.ts +39 -0
  49. package/dist/recordPageProvenance.d.ts.map +1 -0
  50. package/dist/renderers/record-activity.d.ts.map +1 -1
  51. package/dist/renderers/record-alert.d.ts +23 -3
  52. package/dist/renderers/record-alert.d.ts.map +1 -1
  53. package/dist/renderers/record-alert.readProps.d.ts +40 -0
  54. package/dist/renderers/record-alert.readProps.d.ts.map +1 -0
  55. package/dist/renderers/record-chatter.d.ts.map +1 -1
  56. package/dist/renderers/record-details.d.ts +12 -0
  57. package/dist/renderers/record-details.d.ts.map +1 -1
  58. package/dist/renderers/record-highlights.d.ts +10 -0
  59. package/dist/renderers/record-highlights.d.ts.map +1 -1
  60. package/dist/renderers/record-history.d.ts +1 -1
  61. package/dist/renderers/record-history.d.ts.map +1 -1
  62. package/dist/renderers/record-path.d.ts.map +1 -1
  63. package/dist/renderers/record-quick-actions.d.ts +26 -6
  64. package/dist/renderers/record-quick-actions.d.ts.map +1 -1
  65. package/dist/renderers/record-reference-rail.d.ts +40 -0
  66. package/dist/renderers/record-reference-rail.d.ts.map +1 -1
  67. package/dist/renderers/record-related-list.d.ts +51 -7
  68. package/dist/renderers/record-related-list.d.ts.map +1 -1
  69. package/dist/renderers/recordActivityFeed.d.ts +343 -33
  70. package/dist/renderers/recordActivityFeed.d.ts.map +1 -1
  71. package/dist/renderers/recordBinding.d.ts +180 -0
  72. package/dist/renderers/recordBinding.d.ts.map +1 -0
  73. package/dist/renderers/recordComponentAria.d.ts +44 -0
  74. package/dist/renderers/recordComponentAria.d.ts.map +1 -0
  75. package/dist/renderers/relatedListActions.d.ts +102 -0
  76. package/dist/renderers/relatedListActions.d.ts.map +1 -0
  77. package/dist/summaryChipPercent.d.ts +83 -0
  78. package/dist/summaryChipPercent.d.ts.map +1 -0
  79. package/dist/summaryChipRenderers.d.ts +83 -0
  80. package/dist/summaryChipRenderers.d.ts.map +1 -0
  81. package/dist/synth/buildDefaultPageSchema.d.ts +188 -21
  82. package/dist/synth/buildDefaultPageSchema.d.ts.map +1 -1
  83. package/dist/useDetailTranslation.d.ts.map +1 -1
  84. package/dist/useRecordEditable.d.ts +4 -1
  85. package/dist/useRecordEditable.d.ts.map +1 -1
  86. package/package.json +19 -18
  87. package/dist/CommentInput.d.ts +0 -24
  88. package/dist/CommentInput.d.ts.map +0 -1
  89. package/dist/DiffView.d.ts +0 -24
  90. package/dist/DiffView.d.ts.map +0 -1
  91. package/dist/InlineCreateRelated.d.ts +0 -32
  92. package/dist/InlineCreateRelated.d.ts.map +0 -1
  93. package/dist/MentionAutocomplete.d.ts +0 -43
  94. package/dist/MentionAutocomplete.d.ts.map +0 -1
  95. package/dist/PointInTimeRestore.d.ts +0 -28
  96. package/dist/PointInTimeRestore.d.ts.map +0 -1
  97. package/dist/RecordNavigationEnhanced.d.ts +0 -18
  98. package/dist/RecordNavigationEnhanced.d.ts.map +0 -1
  99. package/dist/RelationshipGraph.d.ts +0 -23
  100. package/dist/RelationshipGraph.d.ts.map +0 -1
  101. package/dist/renderers/PermissionFacetLink.d.ts +0 -38
  102. package/dist/renderers/PermissionFacetLink.d.ts.map +0 -1
package/README.md CHANGED
@@ -7,7 +7,9 @@ DetailView plugin for ObjectUI - A comprehensive detail page component with fiel
7
7
  - **Field Grouping/Sections**: Organize fields into logical sections with titles
8
8
  - **Collapsible Sections**: Make sections collapsible to save space
9
9
  - **Tab Navigation**: Organize content into tabs for better UX
10
- - **Related Lists**: Display related records (e.g., contacts for an account)
10
+ - **Related Lists**: Display related records (e.g., contacts for an account) —
11
+ authored as a `record:related_list` block, ⛔ not as a `detail-view` `related`
12
+ array, which is retired (objectui#7997; see **RelatedList** below)
11
13
  - **Action Buttons**: Edit, Delete, and custom action buttons
12
14
  - **Readonly/Edit Mode**: Toggle between view and edit modes
13
15
  - **Back Navigation**: Built-in back button with customizable behavior
@@ -30,16 +32,20 @@ present:
30
32
 
31
33
  ```tsx
32
34
  import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
35
+ import { DetailView } from '@object-ui/plugin-detail';
36
+ import type { DataSource } from '@object-ui/types';
37
+
38
+ declare const dataSource: DataSource;
33
39
 
34
40
  // 1. Injected by an ancestor — the pattern the rest of the family uses, and the
35
41
  // one to reach for when a page renders several data-bound blocks.
36
- <SchemaRendererProvider dataSource={dataSource}>
42
+ const viaProvider = <SchemaRendererProvider dataSource={dataSource}>
37
43
  <SchemaRenderer schema={{ type: 'detail-view', objectName: 'account', resourceId: '42' }} />
38
- </SchemaRendererProvider>
44
+ </SchemaRendererProvider>;
39
45
 
40
46
  // 2. Handed to one placement — what `<DetailView dataSource={…} />` has always
41
47
  // taken, and what the examples below use.
42
- <DetailView schema={{ type: 'detail-view', objectName: 'account', resourceId: '42' }} dataSource={dataSource} />
48
+ const viaProp = <DetailView schema={{ type: 'detail-view', objectName: 'account', resourceId: '42' }} dataSource={dataSource} />;
43
49
  ```
44
50
 
45
51
  Route 1 is new as of objectui#5378: this block used to read the adapter from its
@@ -52,6 +58,131 @@ and the object it was about to read, instead of an empty shell.
52
58
  Note the record id is `resourceId` here. `object-form`'s is `recordId`; the two
53
59
  blocks do not share that key.
54
60
 
61
+ ### The provenance footer belongs to the record page
62
+
63
+ `RecordMetaFooter` is the one-line "Created … Updated …" summary of a record's
64
+ audit fields. A standalone `DetailView` draws it under its body. On a
65
+ `type: 'record'` page the footer is the PAGE's: the page host draws it once for
66
+ the record, whatever the number of `record:details` nodes the page carries, and a
67
+ `record:details` node that reads the page's record draws none of its own. So a
68
+ page with two nodes shows the line once, and a node whose sections are all
69
+ hidden draws nothing. The console's record page (`RecordDetailView` in
70
+ `@object-ui/app-shell`) draws it at the page foot.
71
+
72
+ A host that mounts `RecordContextProvider` itself renders `RecordMetaFooter`
73
+ once, where its page ends, with the record as `data` and the object definition
74
+ as `objectSchema`. A `record:details` node with a `dataSource` binding (below)
75
+ shows a record the page does not, so it keeps its own footer.
76
+
77
+ ### Record blocks without a record page (`dataSource`)
78
+
79
+ `record:details`, `record:highlights` and `record:quick_actions` show the record
80
+ their record context carries: on a `type: 'record'` page, the page's record. On
81
+ any other page, the spec's per-element `dataSource` binding supplies it — the
82
+ block reads the FIRST row of the bound set by the binding's `sort`, under the
83
+ caller's own identity:
84
+
85
+ ```json
86
+ {
87
+ "type": "record:highlights",
88
+ "properties": { "fields": ["name", "environment_type"] },
89
+ "dataSource": { "object": "sys_environment", "sort": [{ "field": "created_at", "order": "desc" }], "limit": 1 }
90
+ }
91
+ ```
92
+
93
+ No row draws the block's authored `emptyState` when it declares one, and its
94
+ no-record state when it does not. `record:quick_actions` runs the object's
95
+ declared actions against the bound row's id. The binding resolves as on
96
+ every bound block (a named `view`, an AND-combined `filter`, its `sort`), and the
97
+ row is re-read when a write to its object is announced. Declare `limit: 1`:
98
+ `objectui validate` warns on a record block whose binding does not. See
99
+ **Per-element data binding on a page** in the data-source guide
100
+ (`content/docs/guide/data-source.md`). `record:related_list`'s `dataSource` is
101
+ different: it binds the rows the list shows.
102
+
103
+ `emptyState` is the spec's empty-state shape, the one `object-grid` takes:
104
+ `{ title, message, icon, link }`. It goes in `properties`, beside the block's
105
+ other keys:
106
+
107
+ ```json
108
+ {
109
+ "type": "record:details",
110
+ "properties": {
111
+ "fields": ["region"],
112
+ "emptyState": {
113
+ "title": "No production environment",
114
+ "message": "Create one from the environments page.",
115
+ "icon": "server",
116
+ "link": { "label": "Open environments", "url": "/environments" }
117
+ }
118
+ },
119
+ "dataSource": { "object": "sys_environment", "limit": 1 }
120
+ }
121
+ ```
122
+
123
+ It is drawn in the block's place only when the binding's read settles with no
124
+ row. A read the server refuses as forbidden counts as no row. It is not drawn
125
+ while the read loads, in place of the error a failed read shows, when a row is
126
+ there, or on a block with no `dataSource`. `icon` is a Lucide name. `title` and
127
+ `message` are plain strings or inline locale maps, resolved against the display
128
+ locale. `link` is followed as a `url` action. Leave `title` out and the heading
129
+ reads "Record not found". Leave `emptyState` out and the block keeps its own
130
+ no-record state: "Record not found" on `record:details`, nothing on
131
+ `record:highlights`, and the empty bar on `record:quick_actions`.
132
+
133
+ A related list's PARENT record has a binding of its own, `parentDataSource` in
134
+ its `properties` (the spec row's member). It is read by the same one-record
135
+ rule — the first row by `sort`, re-read when its object is written — and the
136
+ list draws that record's children:
137
+
138
+ ```json
139
+ {
140
+ "type": "record:related_list",
141
+ "properties": {
142
+ "objectName": "sys_member",
143
+ "relationshipField": "environment_id",
144
+ "parentDataSource": { "object": "sys_environment", "sort": [{ "field": "created_at", "order": "desc" }], "limit": 1 }
145
+ }
146
+ }
147
+ ```
148
+
149
+ No row draws a notice in the block's place, never an empty list. Without the
150
+ key, the parent is the record page's record.
151
+
152
+ ### A section heading that counts a field's values (`count`)
153
+
154
+ A `record:details` section can show how many values a multi-value field holds
155
+ on the current record. `count.field` names the field, and the heading writes the
156
+ `{{ count }}` hole where the number goes (`@objectstack/spec` 18,
157
+ `RecordDetailsProps.sections[].count`):
158
+
159
+ ```json
160
+ {
161
+ "type": "record:details",
162
+ "properties": {
163
+ "sections": [
164
+ {
165
+ "name": "tagging",
166
+ "label": "{{ count }} tags",
167
+ "count": { "field": "tags" },
168
+ "fields": ["tags"],
169
+ "collapsible": true,
170
+ "defaultCollapsed": true
171
+ }
172
+ ]
173
+ }
174
+ }
175
+ ```
176
+
177
+ With three tags selected the heading reads `3 tags`, collapsed or not. An absent
178
+ or empty value counts 0, and a field the viewer may not read counts as absent.
179
+ The hole is filled after the heading is translated, so each locale of an inline
180
+ label map and a translated `objects.<object>._sections.<name>.label` write the
181
+ same `{{ count }}`. `count` changes the heading only: with `hideEmpty` on (the
182
+ default), a section whose fields are all empty is still absent. The spec refuses
183
+ `count` beside `group`, on a section without `label`, and on a label with no
184
+ hole. A section without `count` prints its heading as written.
185
+
55
186
  ### Basic Example
56
187
 
57
188
  ```tsx
@@ -87,7 +218,11 @@ function ContactDetail() {
87
218
  ### With Sections
88
219
 
89
220
  ```tsx
90
- <DetailView
221
+ import { DetailView } from '@object-ui/plugin-detail';
222
+
223
+ declare const accountData: Record<string, unknown>;
224
+
225
+ const accountDetail = <DetailView
91
226
  schema={{
92
227
  type: 'detail-view',
93
228
  title: 'Account Details',
@@ -117,13 +252,18 @@ function ContactDetail() {
117
252
  ],
118
253
  data: accountData,
119
254
  }}
120
- />
255
+ />;
121
256
  ```
122
257
 
123
- ### With Tabs and Related Lists
258
+ ### With Tabs
124
259
 
125
260
  ```tsx
126
- <DetailView
261
+ import { DetailView } from '@object-ui/plugin-detail';
262
+
263
+ declare const navigate: (url: string) => void;
264
+ declare const deleteAccount: (id: string) => void;
265
+
266
+ const accountDetail = <DetailView
127
267
  schema={{
128
268
  type: 'detail-view',
129
269
  title: 'Account: Acme Corp',
@@ -150,66 +290,229 @@ function ContactDetail() {
150
290
  key: 'activity',
151
291
  label: 'Activity',
152
292
  badge: '12',
293
+ // `record:activity` — the registered Activity Timeline block. Reachable
294
+ // under that exact key and no other; see the note below the block.
295
+ // Its inputs go in `properties`, as the spec declares them.
153
296
  content: {
154
- type: 'activity-timeline',
155
- data: activityData,
297
+ type: 'record:activity',
298
+ properties: { limit: 20, showCompleted: false },
156
299
  },
157
300
  },
158
301
  ],
159
- related: [
160
- {
161
- title: 'Contacts',
162
- type: 'table',
163
- api: '/api/accounts/12345/contacts',
164
- columns: ['name', 'email', 'phone', 'title'],
165
- },
166
- {
167
- title: 'Opportunities',
168
- type: 'table',
169
- api: '/api/accounts/12345/opportunities',
170
- columns: ['name', 'amount', 'stage', 'close_date'],
171
- },
172
- ],
173
302
  showEdit: true,
174
303
  showDelete: true,
175
304
  }}
176
305
  onEdit={() => navigate('/accounts/12345/edit')}
177
306
  onDelete={() => deleteAccount('12345')}
178
307
  onBack={() => navigate('/accounts')}
179
- />
308
+ />;
180
309
  ```
181
310
 
311
+ A tab's `content` is an **SDUI node**, not a private vocabulary: `DetailTabs`
312
+ renders it with `<SchemaRenderer schema={toRenderableSchema(tab.content)} />`,
313
+ so `content.type` is resolved by the component registry and an unregistered
314
+ name paints the `Unknown component type` panel (**OBJUI-001**) instead of a
315
+ component. Author only types this repository registers.
316
+
317
+ Two things about the Activity tab above are worth copying rather than guessing
318
+ (objectui#8114 — it previously taught `type: 'activity-timeline'`, which
319
+ nothing registers):
320
+
321
+ - **The key is `record:activity`, with the namespace spelled out.** The
322
+ registration in `src/index.tsx` passes the bare name under
323
+ `{ namespace: 'record', skipFallback: true }`, and `skipFallback` is what
324
+ stops the bare name from also being claimed globally — so `record:activity`
325
+ resolves and `activity` resolves to nothing. `activity` is additionally a tab
326
+ **key** in the example above; the two are unrelated.
327
+ - **The node declares the feed's filters, not the feed.** Its inputs (`limit`,
328
+ `showCompleted`, `types`, `filterMode`, …) go in its `properties` bag, and
329
+ the block finds its own feed: a mounted discussion context, or a self-fetch
330
+ from `sys_activity` scoped to the record `useRecordContext` binds. Both need
331
+ a record host; a bare `<DetailView>` like the one above mounts neither, so
332
+ there the tab draws the block's empty state ("No activity recorded"). Under
333
+ a record host, such as the console's record page, the same node draws that
334
+ record's activity. `items` (with its `loading` flag) is the host's feed
335
+ slot, not a key of this node: `@object-ui/types` refuses it by name, and
336
+ `data` is not a key this block reads.
337
+
338
+ See **The `record:activity` block** in the plugin-detail guide for its declared
339
+ inputs (`types`, `limit`, `filterMode`, `showCompleted`, …).
340
+
182
341
  ## Schema
183
342
 
184
- The DetailView component accepts a `DetailViewSchema`:
343
+ The DetailView component accepts a `DetailViewSchema`, declared in
344
+ `@object-ui/types`. Import it rather than re-typing it — every key below is
345
+ checked against that declaration, so this example cannot drift from the package:
185
346
 
186
347
  ```typescript
187
- interface DetailViewSchema {
188
- type: 'detail-view';
189
- title?: string;
190
- objectName?: string;
191
- resourceId?: string | number;
192
- api?: string;
193
- data?: any;
194
- sections?: DetailViewSection[];
195
- fields?: DetailViewField[];
196
- tabs?: DetailViewTab[];
197
- related?: RelatedList[];
198
- actions?: ActionSchema[];
199
- showBack?: boolean;
200
- showEdit?: boolean;
201
- showDelete?: boolean;
202
- backUrl?: string;
203
- editUrl?: string;
204
- deleteConfirmation?: string;
205
- loading?: boolean;
206
- header?: SchemaNode;
207
- footer?: SchemaNode;
208
- }
348
+ import type { DetailViewSchema } from '@object-ui/types';
349
+
350
+ const schema: DetailViewSchema = {
351
+ type: 'detail-view',
352
+ title: 'Account Details',
353
+ objectName: 'accounts',
354
+ resourceId: '12345',
355
+ api: '/api/accounts/12345',
356
+ data: undefined,
357
+ // Field name whose value becomes the header title; `title` is the fallback.
358
+ primaryField: 'name',
359
+ // Field values rendered as badges beside the header title.
360
+ summaryFields: ['industry', 'website'],
361
+ layout: 'vertical',
362
+ columns: 2,
363
+ // See the examples above for the shapes these three carry.
364
+ sections: [],
365
+ fields: [],
366
+ tabs: [],
367
+ actions: [],
368
+ // The view's own heading (title, follow-star, copy-id chip); drawn unless
369
+ // `false`. `record:details` sets it from its own `showHeader`.
370
+ showHeader: true,
371
+ showBack: true,
372
+ backUrl: '/accounts',
373
+ showEdit: true,
374
+ editUrl: '/accounts/12345/edit',
375
+ showDelete: true,
376
+ deleteConfirmation: 'Delete this account?',
377
+ loading: false,
378
+ header: { type: 'text', label: 'Custom header' },
379
+ footer: { type: 'text', label: 'Custom footer' },
380
+ };
209
381
  ```
210
382
 
383
+ ### `summaryFields` and non-scalar values
384
+
385
+ A summary chip is a single-line pill, and it formats `currency`, `date`,
386
+ `datetime`, `percent` and the option families itself. Any other value it can
387
+ render as text it renders as text.
388
+
389
+ A **`percent`** chip is formatted by `@object-ui/fields`' `formatPercent` — the
390
+ same call the list cell makes — so one stored value reads the same beside the H1
391
+ as it does in a list: scaled at the storage the field declares (the spec's
392
+ `percentScaleOf`: a fraction unless the field declares a `max` above 1, read
393
+ through `percentCellScale` in `@object-ui/fields`, never guessed from the
394
+ value), rounded to the width `resolveFieldScale` (`@objectstack/spec/data`)
395
+ resolves for the field — its declared `scale`, or the protocol's own width for
396
+ a percent that declares none, which every percent face reads (objectui#9843) —
397
+ and rendered through the display locale's own percent affix rather than an
398
+ appended sign. A stored `1234.5` on a field declaring `max: 100` therefore
399
+ reads `1,235%` in an `en` session, and a stored `1` on a field declaring no
400
+ `max` reads `100%`; both carry the locale's own affix and marks elsewhere. Which stored values that convention moves, and
401
+ what each reads in both places, is re-derived by
402
+ `src/__tests__/summaryChip.percentConvention-9167.test.tsx` rather than listed
403
+ here. The chip's bar keeps the unrounded magnitude, as the list cell's bar does.
404
+
405
+ An **object** value — an expanded lookup payload, an address, a location, a
406
+ file — is drawn by that field's own cell renderer, so the chip shows what the
407
+ same value shows everywhere else on the page: the referenced record's name, the
408
+ formatted postal line, the coordinates, the file name.
409
+
410
+ Fifteen field kinds are the exception, because their renderer does not fit a
411
+ pill: the option families draw a badge (which would nest inside the chip's own),
412
+ `user` draws an avatar, `image` / `avatar` / `signature` draw an image and no
413
+ text at all, and `boolean` / `toggle` / `date` / `datetime` / `repeater` draw a
414
+ "No value" face for a value the chip only exists because the page called
415
+ filled. Those keep a text chip, carrying `@object-ui/fields`' shared value
416
+ coercion — the record's name where the object has one, and `[Object]` where it
417
+ does not. The set and the measurement behind it are
418
+ `src/summaryChipRenderers.ts`.
419
+
211
420
  ## Components
212
421
 
422
+ ### RecordDetailPanel
423
+
424
+ The record overlay's **payload**, with no shell of its own: one
425
+ `InlineEditProvider` session wrapping `DetailView` plus the record-level
426
+ `InlineEditSaveBar`, and the `objectSchema` → typed-field derivation that feeds
427
+ them.
428
+
429
+ All five list-type renderers (`ObjectGrid`, `ObjectTree`, `ObjectGantt`,
430
+ `ObjectKanban`, `ObjectCalendar`) mount this component through
431
+ `NavigationOverlay` from `@object-ui/components`, so an authored
432
+ `navigation.mode` of `drawer` / `modal` / `split` / `popover` means the same
433
+ thing on every view type (objectui#9299). ⛔ Do not re-wrap it in a shell of
434
+ your own — that is exactly the fork this component exists to remove.
435
+
436
+ ```tsx
437
+ import type { ReactNode } from 'react';
438
+ import { NavigationOverlay, useOverlayAnchor } from '@object-ui/components';
439
+ import { RecordDetailPanel } from '@object-ui/plugin-detail';
440
+ import { useNavigationOverlay } from '@object-ui/react';
441
+ import type { DataSource } from '@object-ui/types';
442
+
443
+ declare const dataSource: DataSource;
444
+ declare const objectSchema: { fields?: Record<string, unknown> };
445
+ declare const myView: ReactNode;
446
+ declare function saveField(field: string, value: unknown): Promise<void>;
447
+ declare function deleteRecord(): Promise<void>;
448
+
449
+ function ContactsOverlay() {
450
+ const navigation = useNavigationOverlay({
451
+ navigation: { mode: 'drawer' },
452
+ objectName: 'contacts',
453
+ });
454
+ // `popover` anchors to the element the click landed on; spread
455
+ // `anchorCaptureProps` on your own container, or call `captureAnchor(event)`
456
+ // at a click site that already has one.
457
+ const { anchorRef } = useOverlayAnchor();
458
+
459
+ return (
460
+ <NavigationOverlay
461
+ {...navigation}
462
+ title="Record Detail"
463
+ mainContent={myView}
464
+ popoverAnchorRef={anchorRef}
465
+ >
466
+ {(record) => (
467
+ <RecordDetailPanel
468
+ record={record}
469
+ objectName="contacts"
470
+ recordId={String(record.id)}
471
+ dataSource={dataSource}
472
+ objectSchema={objectSchema}
473
+ onFieldSave={saveField}
474
+ onDelete={deleteRecord}
475
+ onClose={navigation.close}
476
+ />
477
+ )}
478
+ </NavigationOverlay>
479
+ );
480
+ }
481
+ ```
482
+
483
+ Omit `onFieldSave` for a strictly read-only panel; omit `onDelete` for no
484
+ delete action.
485
+
486
+ **Capability is handler presence, not a boolean.** A caller that omits
487
+ `onFieldSave` gets a read-only panel and one that omits `onDelete` gets no
488
+ delete action — which is what lets a row locked through a renderer's `lockField`
489
+ open read-only without the panel knowing what a lock is.
490
+
491
+ **…and a handler is a wiring, not a grant.** The inline edit is the `recordEdit`
492
+ row of the affordance-to-grant map in `@object-ui/core` (objectui#12082), the row
493
+ the record page reads for the same in-place edit: the object's policy (read off
494
+ the `objectSchema` you pass), the effective API operation set and the caller's
495
+ **update** grant. When it is closed the panel opens no inline-edit session, as if
496
+ `onFieldSave` were absent; each field still asks its own field question. The
497
+ delete action is `DetailView`'s own `recordDelete` read. With no permission
498
+ provider mounted both read open.
499
+
500
+ `RECORD_OVERLAY_DEFAULT_WIDTH` is exported alongside it and is the single code
501
+ home of the default overlay width repo-wide. ⛔ Do not re-spell the literal at a
502
+ call site.
503
+
504
+ ### RecordDetailDrawer
505
+
506
+ The same payload in the drawer shell — a published convenience wrapper that
507
+ delegates to `NavigationOverlay` in `mode="drawer"`. It no longer owns a
508
+ `Sheet`, a drag-resize implementation, or a chrome header of its own
509
+ (objectui#9299).
510
+
511
+ A width a user had already dragged is persisted by the shell under
512
+ `ov:drawer-width:<objectName>`; a width left by the retired implementation under
513
+ `objectui.drawerWidth.<objectName>` is read once, moved forward and removed, so
514
+ existing widths carry over rather than reset.
515
+
213
516
  ### DetailSection
214
517
 
215
518
  Renders a group of fields with optional collapsing.
@@ -222,6 +525,54 @@ Tab navigation for organizing content into different views.
222
525
 
223
526
  Displays related records in list, grid, or table format.
224
527
 
528
+ > **Retired: `DetailViewSchema.related`** (objectui#7997, ADR-0049
529
+ > enforce-or-remove). Author a `record:related_list` block instead.
530
+ >
531
+ > Until objectui#7997 a `detail-view` node could carry its own `related` array,
532
+ > and this README taught it. That array is retired under ADR-0049
533
+ > enforce-or-remove (maintainer ruling 2026-09-10). It was a second entry to a
534
+ > capability `@objectstack/spec` already governs — the protocol declares no
535
+ > `DetailView` schema at all — so it mirrored nothing and drifted from the
536
+ > renderer it fed: it typed `columns` as `TableColumn` objects while the
537
+ > renderer also accepted bare field names. Authoring it is now **refused by
538
+ > name** on both the TypeScript and the JSON face, ⛔ not silently ignored.
539
+ >
540
+ > Nothing about the rendered result changed: both entries always went through
541
+ > the `RelatedList` component documented here. Only the second door closed.
542
+ >
543
+ > ```tsx
544
+ > import { SchemaRenderer } from '@object-ui/react';
545
+ >
546
+ > const contacts = (
547
+ > <SchemaRenderer
548
+ > schema={{
549
+ > type: 'record:related_list',
550
+ > properties: {
551
+ > objectName: 'contact',
552
+ > relationshipField: 'account_id',
553
+ > title: 'Contacts',
554
+ > columns: ['name', 'email', 'phone'],
555
+ > },
556
+ > }}
557
+ > />
558
+ > );
559
+ > ```
560
+ >
561
+ > ⚠️ `columns` is an array of **field-name strings**, which is what the protocol
562
+ > declares (`RecordRelatedListProps.columns`). The header comes from the related
563
+ > object's field `label` and the cell from the field's type, so a label rename
564
+ > reaches the list for free — the hand-spelled `{ accessorKey, header }` form the
565
+ > retired array taught froze both. `relationshipField` names the field on the
566
+ > related object pointing back at this record, and replaces the retired form's
567
+ > `api` endpoint.
568
+ >
569
+ > ⚠️ The block reads the parent record from the record page's `RecordContext`,
570
+ > so author it on a record page. Placed anywhere it cannot resolve a parent id
571
+ > it scopes to nothing and renders an empty list — which is also what the
572
+ > retired `related` form did whenever it declared `api` without a
573
+ > `referenceField`.
574
+
575
+
225
576
  Related lists are **paged by default**: the `record:related_list` renderer
226
577
  applies the spec default `limit` of **5** when the node doesn't declare one
227
578
  (`@objectstack/spec` `RecordRelatedListProps.limit`, "Number of records to
@@ -245,18 +596,92 @@ value the cell shows — the label this list already resolved, the formula resul
245
596
  the server hydrated — so the affordance stays and orders by that.
246
597
  Passing `data` directly keeps the historical client-side slicing, and typing
247
598
  in the opt-in filter box temporarily falls back to the full-fetch client
248
- pipeline (the contains-filter sweeps every field, which no generic server
249
- filter can express).
599
+ pipeline (the contains-filter sweeps the columns the list shows, which no
600
+ generic server filter can express).
601
+
602
+ A `password` / `secret` column draws `••••••`, and the list flags it
603
+ `masked` on the table it renders (objectui#10657), so the table never copies it
604
+ on Ctrl+C / Cmd+C, shows it as a tooltip, sorts by it from its header, or sizes
605
+ it by its value. The rule is `isMaskedFieldType()` from `@object-ui/fields`,
606
+ read over the column's authored `type` and the object's field type, so an
607
+ authored `type: 'text'` over a `secret` field keeps the flag. While the object
608
+ definition is still loading, or after its read failed, the list cannot tell
609
+ which columns are masked, so it flags every one and draws every cell it would
610
+ draw from that definition as the mask, never as text; it keeps doing so when
611
+ the read failed (objectui#10657). A column with a `cell` of your own draws what
612
+ your `cell` returns.
613
+
614
+ The list's own filter box and sort read the same flag (objectui#10728). The
615
+ opt-in filter box matches a term against the columns the list shows that are
616
+ not masked, so a masked value, and a field no column shows, never keeps a row.
617
+ A masked column gets no sort button on a `list` card, and a sort by it is
618
+ refused (including one the embedded table asks for, which a server-paged list
619
+ would send as `$orderby`). In a list that sorts in the browser, a sort set on a
620
+ column before it was flagged also stops ordering the rows. While the definition
621
+ is loading, or after its read failed, every column is flagged: the filter box
622
+ keeps no row and no sort button is offered. Not covered: on a server-paged
623
+ list, an authored `sort` naming a masked field is still sent to the server as
624
+ `$orderby`, and so is a sort the user set on a column before that column was
625
+ flagged, with each page the list fetches, until the user sorts by another
626
+ column. The column is flagged after the sort only when the `columns` or the
627
+ object of a list already on screen change; `record:related_list` sets both
628
+ once per block.
629
+
630
+ A list that authors `columns` sends a **`$select` projection** on its
631
+ auto-fetch (`objectui#10186`), the one `ListView` and `ObjectGrid` already send
632
+ (`objectui#6898`). It asks for the authored columns that pass the same gates the
633
+ column layer draws through (redaction, the parent key, field-level security),
634
+ plus `id`, the `$expand` roots, and, once the child schema has loaded, the
635
+ fields the row predicates read (the child object's `userActions` Edit/Delete
636
+ overrides, its actions, and the host's row actions), plus the mobile card
637
+ gallery's cover field when declared and readable. A column the principal
638
+ cannot read is dropped once the permission answer has loaded; a request sent
639
+ before that answer can still carry it, the same deferral `ObjectGrid` has.
640
+ If every authored column is denied, the list asks for `id` and its predicate
641
+ operands, not for everything. On a backend that honours `$select`, rows then
642
+ carry only the projected fields, so the opt-in filter box above sweeps those.
643
+ A list **without** authored `columns` derives them from `highlightFields` or
644
+ the field walk, and both choose by the emptiness of the rows fetched. That list sends no projection, and its column layer alone keeps a
645
+ denied field off screen. Against ObjectStack's server the projection is defence
646
+ in depth, because the server already strips denied fields from every row.
250
647
 
251
648
  The node's `filter` (spec `RecordRelatedListProps.filter`, "additional filter
252
649
  criteria") narrows the list beyond the parent relationship: it is
253
- **AND-combined** with `{ [relationshipField]: parentId }`, never substituted for
650
+ **AND-combined** with the parent-relationship condition, never substituted for
254
651
  it, so a related list stays scoped to the record it appears on and an additional
255
- criterion can only ever narrow that set. Authors write it in the spec's own
652
+ criterion can only ever narrow that set. That condition is **not one fixed
653
+ spelling** — it is compiled to match the relationship field's ARITY on the
654
+ child object (`objectui#7299`). A single-valued relationship gets the equality
655
+ form `{ [relationshipField]: parentId }`; a multi-valued one gets the membership
656
+ form `{ [relationshipField]: { $contains: parentId } }`, because the stored
657
+ value is an array of ids and equality would ask whether the whole array *is* one
658
+ id. The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`,
659
+ never a rule this package keeps locally. Authors write `filter` in the spec's own
256
660
  vocabulary (`[{ field, operator, value }]`); a `dataSource` binding's composed
257
661
  filter (component AND saved view AND binding) lands on the same key. Both are
258
662
  lowered to ObjectQL through the repo's single filter sink, so no second dialect
259
- appears. On the legacy raw-URL fallback path (no `dataSource` adapter, where the
663
+ appears.
664
+
665
+ **A third producer writes that same key: the relationship itself.** A `lookup` /
666
+ `master_detail` field may declare `relatedListFilter` (added to `@objectstack/spec`
667
+ in objectstack#8704 / PR #8955 — see this package's own `@objectstack/spec`
668
+ dependency in `package.json` for the version in use), a canonical Query-DSL
669
+ `FilterCondition` such as
670
+ `{ status: { $ne: 'deleted' } }`. `RecordDetailView` derives the record page's
671
+ related lists from the relationship graph and carries that value onto this node's
672
+ `filter`, so an auto-derived page honours it without anyone authoring a page
673
+ (objectui#4664). It is an authored constraint, not a user-editable suggestion —
674
+ it never becomes a filter-bar row — and it lands on the existing key rather than
675
+ a new one, so all three producers share one read site and one lowering.
676
+
677
+ The **tab badge honours the same composed filter**. `page:tabs` auto-derives a
678
+ Related tab's count from the `record:related_list` nodes underneath it, and that
679
+ probe reads this node's `filter` and composes it with the parent scope through
680
+ the same `mergeFilterNodes` sink the row query uses — one value, sent twice. That
681
+ is what keeps the badge from saying 7 above a list showing 3; it is normative in
682
+ the spec key's own contract text, not a nicety. Counts are cached per
683
+ (object, relationship, parent, **scope**), so a filtered and an unfiltered probe
684
+ over the same relationship never badge each other. On the legacy raw-URL fallback path (no `dataSource` adapter, where the
260
685
  query language is `filter[<field>]=<value>` and cannot carry an operator) a
261
686
  declared filter is refused with a console explanation rather than dropped —
262
687
  answering with more rows than the metadata asked for is the failure this key's
@@ -268,6 +693,16 @@ add callback both required the adapter already, so without it the button used to
268
693
  render and do nothing at all when clicked — visible in hosts that supply no
269
694
  `RecordContext` (Studio designer previews, context-free embeds).
270
695
 
696
+ …and only where the caller may make the write it ends in (objectui#12082). With
697
+ a `linkField` the picked records become link rows of the list's own object: the
698
+ **create** grant there, the `relatedNew` row of the affordance-to-grant map in
699
+ `@object-ui/core`. Without one the picked record is re-parented: the **update**
700
+ grant on `add.picker.object`, the `relatedRowEdit` row. The remove that the
701
+ `record:related_list` renderer falls back to when an `add` config is present and
702
+ no host handed over a delete reads the list object's `relatedRowDelete` row, the
703
+ row the host's own delete reads, so it never puts back a delete the host
704
+ withheld. With no permission provider mounted these read open.
705
+
271
706
  The `record:related_list` renderer is automatically gated on the current
272
707
  user's object-level `read` permission for the child object: when the
273
708
  permission system (`@object-ui/permissions`) is loaded and denies read,
@@ -294,6 +729,25 @@ shared draft (`InlineEditProvider` from `@object-ui/react`), committed by
294
729
  - **Approval lock**: hosts pass `locked` / `lockedHint` to the save bar and
295
730
  gate `InlineEditProvider.canEdit` when the record is approval-locked, so a
296
731
  locked record hides its edit affordances instead of rejecting at Save.
732
+ - **Approval band & recall** (#6464): the band reads `approvalPending` /
733
+ `approvalProgress` from the same provider, and offers its **Recall** button
734
+ only where the click can succeed. Recall is the submitter's lever — the
735
+ server authorizes it on submitter identity and refuses everyone else — so
736
+ hosts that resolve approvals pass `InlineEditProvider.approvalIsSubmitter`.
737
+ It is tri-state on purpose: `false` withdraws the button from a resolved
738
+ non-submitter, `true` keeps it, and **omitting it leaves the button exactly
739
+ as it was** — a host that resolves no approval identity is unchanged rather
740
+ than losing its submitter's only unlock lever. It gates the affordance only;
741
+ the recall endpoint remains the authority on who may actually recall.
742
+ - **Answered approvals read** (#12220): a host whose approvals read answered
743
+ (a 2xx) passes `InlineEditProvider.approvalReadAnswered`. Its `locked` /
744
+ `approvalPending` are then the whole answer, and the band stops falling back
745
+ to the record's `approval_status` column: a composition without the
746
+ approvals service answers the read with an empty list and enforces no lock,
747
+ so a stale `pending` in the column no longer shows a lock with nothing to
748
+ recall. Omitting it keeps the column fallback exactly as before. Hosts and
749
+ the band decide through one rule, `resolveApprovalLock` from
750
+ `@object-ui/react`.
297
751
 
298
752
  ## Links
299
753
 
@@ -309,32 +763,63 @@ shared draft (`InlineEditProvider` from `@object-ui/react`), committed by
309
763
  The "Reference Rail" is the right-hand column on the record detail page
310
764
  that surfaces summary cards for related collections (similar to
311
765
  Salesforce's **Related** rail and HubSpot's **About this record**
312
- sidebar). It is rendered by the `record:reference_rail` component and
313
- emits automatically when:
766
+ sidebar). It is rendered by the `record:reference_rail` component. The
767
+ rail is **off by default** — it fans out a collection query per related
768
+ list — so the synth emits it only when the `buildDefaultPageSchema` call
769
+ opts in with `showReferenceRail: true`, and then only when:
314
770
 
315
771
  1. The page is generated by the synth (`buildDefaultPageSchema`) — i.e.
316
772
  no explicit `Page` overrides the object's detail view.
317
- 2. The objectDef declares **≥2 related collections** (lookup/master-detail
318
- inbound fields).
773
+ 2. That same synth call passes **≥2 entries** in its `related` option —
774
+ the count is read off the caller's option, **not** off the objectDef.
319
775
  3. The viewport is **≥ xl (1280 px)** — below that the rail collapses and
320
776
  the **Related** tab keeps full coverage.
321
- 4. The objectDef does **not** opt out via `detail.hideReferenceRail`.
777
+ 4. The synth call does **not** opt out via `hideReferenceRail`.
778
+
779
+ Conditions 1, 2 and 4 are one conjunction inside `buildDefaultPageSchema`,
780
+ quoted here verbatim so that a later change to it shows up as a diff
781
+ against this page: `options.showReferenceRail === true`,
782
+ `!options.hideReferenceRail`, `Array.isArray(options.related)`,
783
+ `options.related.length >= 2`. Condition 3 is not part of that
784
+ expression — it is the `hidden xl:flex` class the synth puts on the
785
+ `aside` region.
786
+
787
+ ### Opting in
788
+
789
+ The opt-in flag and the ≥2 count are both `buildDefaultPageSchema`
790
+ **options**, never objectDef keys:
791
+
792
+ ```ts
793
+ import { buildDefaultPageSchema } from '@object-ui/plugin-detail';
794
+ import type { ObjectDefLike } from '@object-ui/plugin-detail';
795
+
796
+ declare const opportunityDef: ObjectDefLike;
797
+
798
+ const page = buildDefaultPageSchema(opportunityDef, {
799
+ showReferenceRail: true, // required — omit it and no rail is emitted
800
+ related: [
801
+ { objectName: 'quote', relationshipField: 'opportunity' },
802
+ { objectName: 'task', relationshipField: 'related_to_opportunity' },
803
+ ],
804
+ });
805
+ ```
322
806
 
323
807
  When the rail emits, the synth automatically suppresses the **Related**
324
808
  tab so the same information isn't shown twice.
325
809
 
326
- ### Per-object opt-out
810
+ ### Opting out
327
811
 
328
- Add a `detail` block to the objectDef:
812
+ The opt-out is a `buildDefaultPageSchema` **option**, not an objectDef key:
329
813
 
330
814
  ```ts
331
- ObjectSchema.create({
332
- name: 'product',
333
- // …
334
- detail: {
335
- hideReferenceRail: true, // hide the rail; restore the Related tab
336
- hideRelatedTab: true, // (optional) force-hide the Related tab too
337
- },
815
+ import { buildDefaultPageSchema } from '@object-ui/plugin-detail';
816
+ import type { ObjectDefLike } from '@object-ui/plugin-detail';
817
+
818
+ declare const productDef: ObjectDefLike;
819
+
820
+ const page = buildDefaultPageSchema(productDef, {
821
+ hideReferenceRail: true, // hide the rail; restore the Related tab
822
+ hideRelatedTab: true, // (optional) force-hide the Related tab too
338
823
  });
339
824
  ```
340
825
 
@@ -355,7 +840,7 @@ For explicit (non-synth) Pages, add an `aside` region after the `main`
355
840
  region:
356
841
 
357
842
  ```ts
358
- {
843
+ const asideRegion = {
359
844
  name: 'aside',
360
845
  width: 'small',
361
846
  className: 'hidden xl:flex flex-col gap-4',
@@ -372,11 +857,12 @@ region:
372
857
  },
373
858
  },
374
859
  ],
375
- },
860
+ };
376
861
  ```
377
862
 
378
- The renderer reads `entries` from both `schema.entries` and
379
- `schema.properties.entries` so either spec-style or flat authoring works.
863
+ The `entries` go in the node's `properties` bag, as the example shows: that
864
+ bag is the contract, and `objectui validate` refuses a flat `entries` written
865
+ on the node itself, naming `properties.entries`.
380
866
 
381
867
  ## License
382
868