@object-ui/app-shell 4.8.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,324 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 5.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - cb4879e: form
8
+ - @object-ui/types@5.0.1
9
+ - @object-ui/core@5.0.1
10
+ - @object-ui/i18n@5.0.1
11
+ - @object-ui/react@5.0.1
12
+ - @object-ui/components@5.0.1
13
+ - @object-ui/fields@5.0.1
14
+ - @object-ui/layout@5.0.1
15
+ - @object-ui/data-objectstack@5.0.1
16
+ - @object-ui/auth@5.0.1
17
+ - @object-ui/permissions@5.0.1
18
+ - @object-ui/collaboration@5.0.1
19
+ - @object-ui/providers@5.0.1
20
+
21
+ ## 5.0.0
22
+
23
+ ### Minor Changes
24
+
25
+ - 8930b15: feat(detail): close the gap between Page-assigned and default record detail pages (Track 1)
26
+
27
+ Custom Lightning-style record detail pages (assigned via `assignedPage` /
28
+ `Page` schemas) used to feel meaningfully poorer than the auto-generated
29
+ default detail view. They were missing cross-cutting affordances and
30
+ shipped with English-only tab labels and heavy bordered section cards
31
+ even when the host locale was Chinese. Track 1 closes the visible gap:
32
+ - **app-shell `RecordDetailView`**: the `assignedPage` branch now wears
33
+ the same chrome as the default branch — lifecycle managed-by badge
34
+ and presence avatars in the top-right, `MetadataPanel` debug panel,
35
+ `ActionConfirmDialog` / `ActionParamDialog`, and an auto-appended
36
+ `RecordChatterPanel` at the bottom of the page. Authors opt out of
37
+ the auto-discussion with `assignedPage.disableDiscussion = true`.
38
+ - **plugin-detail `record:details`**: defaults to `inlineEdit: true` so
39
+ fields are click-to-edit just like the default page, and synthesises
40
+ sections with `showBorder: false` by default so a Lightning page
41
+ doesn't double-wrap every block in a heavy Card.
42
+ - **components `page:tabs` / `page:accordion`**: well-known English
43
+ labels (Details / Related / Activity / History / Notes / Files /
44
+ Tasks / Events / Attachments / Chatter / Discussion / Comments /
45
+ Overview / Summary) auto-translate to Chinese (`zh-CN` / `zh-TW`)
46
+ via a built-in dictionary keyed off `document.documentElement.lang`.
47
+ Authors supplying explicit localised labels (string or
48
+ `{ default, zh-CN, ... }`) are not affected.
49
+ - **i18n provider**: applies the initial language to
50
+ `document.documentElement.lang` on mount (i18next does not fire
51
+ `languageChanged` for the bootstrap language), so locale-aware
52
+ renderers downstream see the right value from the first render.
53
+
54
+ - 186aee8: feat(detail): default-on renderViaSchema for non-assignedPage records
55
+
56
+ Track 3 Phase G slice 6. The synthesized Page schema path (slice 2,
57
+ behind `?renderViaSchema=1`) is now the default rendering pipeline for
58
+ every object without a custom assignedPage. Visual and functional
59
+ parity verified on task and account before flipping.
60
+
61
+ Switches preserved: `?renderViaSchema=0` URL fallback,
62
+ `objectDef.detail.renderViaSchema = false` per-object opt-out.
63
+
64
+ - 927187a: Phase N.1 + N.2: visual polish for record detail pages.
65
+
66
+ **N.1 — System actions on full Lightning pages.** `PageHeaderRenderer`
67
+ now merges `headerSystemActions` from `RecordContext` with authored
68
+ actions (authored wins on name/id collision), so full custom pages
69
+ (lead, opportunity, ...) once again show 编辑 / 分享 / 删除 alongside
70
+ their authored actions. `sys_share` and `sys_delete` now use the
71
+ `outline` variant instead of `destructive` to read better in
72
+ multi-button clusters.
73
+
74
+ **N.2 — Hide empty fields by default in synth detail pages.**
75
+ `record:details` defaults `section.hideEmpty` to `true` so synthesized
76
+ pages don't render label graveyards on first load. The "显示 N 个空字段"
77
+ reveal toggle is preserved as the user-facing escape hatch. Authors can
78
+ opt back into showing every field by setting `hideEmpty: false` on the
79
+ section schema.
80
+
81
+ - 8435860: Phase N.4b: highlight↔body dedup now works for hand-authored Lightning
82
+ pages too.
83
+
84
+ Adds a small `HighlightFieldsContext` registry. `record:highlights`
85
+ registers the field names it currently surfaces; `record:details` unions
86
+ that live set into its `hideFieldNames` filter so a field shown in the
87
+ highlight strip is never duplicated in the section grid below.
88
+
89
+ Previously the dedup only fired for synth-generated pages (via the
90
+ `hideFields` prop passed by `buildDefaultPageSchema`). Custom Lightning
91
+ pages (e.g. opportunity) showed `所属客户` both in the strip and in the
92
+ body. The registry-based approach covers both code paths uniformly with
93
+ no schema author work required.
94
+
95
+ The registry uses `useSyncExternalStore` so adding/removing highlights
96
+ notifies consumers without triggering the provider value identity to
97
+ change — avoiding the update-loop that a naive context implementation
98
+ would cause.
99
+
100
+ `RecordDetailView` mounts `<HighlightFieldsProvider>` once per record
101
+ page so the two renderers share state.
102
+
103
+ - 74962b0: feat(detail): record:discussion schema component + flush accordion variant
104
+ - New `record:discussion` schema type lets authors place the record
105
+ chatter feed anywhere in a custom Page schema. Wired through a
106
+ shared `DiscussionContext` provider on the `assignedPage` branch
107
+ of `RecordDetailView`; auto-append still applies when no explicit
108
+ `record:discussion` / `record:chatter` node is present.
109
+ - `page:accordion` gains a `variant` prop. Default `flush` strips the
110
+ per-item border so accordion sections no longer double-wrap inner
111
+ Card-bearing renderers (RelatedList, etc.). Authors who want the
112
+ old visual pass `variant: 'card'`.
113
+ - `translateLabel` now handles compound labels split by `&`, `and`,
114
+ or `和` (e.g. `Notes & Attachments` → `备注与附件`).
115
+
116
+ - fa4c2cb: feat(detail): renderViaSchema opt-in routes default detail through SchemaRenderer (Track 3 Phase G slice 2)
117
+
118
+ When `?renderViaSchema=1` is in the URL, or `objectDef.detail.renderViaSchema === true`,
119
+ `RecordDetailView`'s no-assignedPage branch now synthesizes a canonical
120
+ Page schema (`page:header` → `record:highlights` → `record:path` →
121
+ `page:tabs(record:details)` → `record:discussion`) via
122
+ `buildDefaultPageSchema(objectDef, { sections, highlightFields })` and
123
+ renders it through the existing `<SchemaRenderer>` pipeline.
124
+
125
+ This means every object without a custom assigned page can opt in to
126
+ the same chrome (record-aware header chip, chevron path, flush
127
+ accordion, discussion slot) that custom Lightning pages already enjoy.
128
+
129
+ Changes:
130
+ - `buildDefaultPageSchema` now emits `page:tabs.items` (correct shape
131
+ for the renderer) rather than `tabs`.
132
+ - `PageHeaderRenderer.resolvedTitle` honors `objectSchema.primaryField`
133
+ before the legacy `name/title/display_name/label` fallbacks.
134
+ - `RecordDetailView` rebuilds the synthesized schema with
135
+ `detailSchema.sections` + `highlightFields` at render time so
136
+ `record:details` inherits the same field layout the legacy
137
+ `<DetailView>` would have produced.
138
+
139
+ Flag is intentionally off by default — flipping the default is a
140
+ separate explicit commit after empirical parity validation across
141
+ multiple objects. Known gaps tracked for slice 3: titleFormat
142
+ fallback for objects without `primaryField`, auto Activity / History
143
+ tabs, header-action buttons.
144
+
145
+ - 7213027: feat(detail): slotted record pages (Track 3 Phase I)
146
+
147
+ Introduce `kind: "slotted"` record pages that override one or more
148
+ named slots while letting the default-page synthesizer fill in the
149
+ rest. Authors no longer need to re-author the entire page just to
150
+ customize the header or one tab.
151
+
152
+ **Slot menu (v1):**
153
+ - `header` — replaces `page:header`
154
+ - `actions` — replaces the `record:quick_actions` action bar
155
+ - `highlights` — replaces the chips + chevron path strip
156
+ - `details` — replaces the Details tab body (other tabs stay synthesized)
157
+ - `tabs` — replaces the entire `page:tabs` node (wins over `details`)
158
+ - `discussion` — replaces the inline `record:discussion` footer
159
+
160
+ Each slot is a full replacement at the slot boundary. To compose
161
+ default + custom, call the corresponding `buildDefault*` sub-builder
162
+ (now exported from `@object-ui/plugin-detail`):
163
+ `buildDefaultHeader`, `buildDefaultActions`, `buildDefaultHighlights`,
164
+ `buildDefaultDetails`, `buildDefaultTabs`, `buildDefaultDiscussion`.
165
+
166
+ **Author shape:**
167
+
168
+ ```ts
169
+ {
170
+ type: 'record',
171
+ object: 'account',
172
+ kind: 'slotted',
173
+ slots: {
174
+ header: { type: 'page:header', properties: { ... } },
175
+ },
176
+ }
177
+ ```
178
+
179
+ **API changes:**
180
+ - `PageSchema` (in `@object-ui/types`): adds `kind?: 'full' | 'slotted'`
181
+ (default `'full'`) and `slots?: PageSlotMap`.
182
+ - `usePageAssignment` (in `@object-ui/react`): result now exposes a
183
+ `slots` field populated when the matched page has `kind === 'slotted'`.
184
+ Existing `page` field is unchanged for full pages.
185
+ - `buildDefaultPageSchema` (in `@object-ui/plugin-detail`): accepts an
186
+ `options.slots` map that overrides individual regions at synthesis time.
187
+
188
+ - 34b66bf: feat(detail): synthesize Related / Activity / History tabs + record:quick_actions header (Track 3 Phase G slice 4)
189
+ - `buildDefaultPageSchema` now accepts `headerActions`, `related`,
190
+ `showActivity`, and `history` options. When provided, the synthesizer
191
+ emits a `record:quick_actions` node after `page:header` and appends
192
+ the corresponding tabs to `page:tabs.items` in stable order
193
+ (Details / Related / Activity / History).
194
+ - New `record:history` renderer wraps the existing `HistoryTimeline`,
195
+ reading `entries` / `loading` from the schema. Host owns fetching.
196
+ - `RecordDetailView` forwards `detailSchema.actions[0].actions`,
197
+ `detailSchema.related[]` (unwrapped to `{objectName,relationshipField}`),
198
+ and `detailSchema.history` into the synthesizer call so the
199
+ `renderViaSchema` path reaches parity with the monolithic DetailView
200
+ tab strip and header action bar.
201
+ - 6 new unit tests covering headerActions emit/skip, Related tab
202
+ shape, Activity opt-in, History entries pass-through, and stable
203
+ tab ordering.
204
+
205
+ No behavior change for objects without the `renderViaSchema` opt-in.
206
+
207
+ - c7561a7: **Unify per-user UI state storage onto `sys_user_preference`.**
208
+
209
+ `createObjectStackUserStateAdapter` previously wrote to a bespoke
210
+ `user_app_state` object using `(user_id, kind, payload)` columns. That
211
+ parallel KV table duplicated the canonical per-user preference store
212
+ shipped by `@objectstack/plugin-auth`, and pulled UI traces (favorites,
213
+ recent items, grid widths) out of the place users actually look for
214
+ their settings.
215
+
216
+ The adapter now defaults to:
217
+ - `resource`: `sys_user_preference`
218
+ - field shape: `(user_id, key, value)` instead of `(user_id, kind, payload)`
219
+ - option name: **`key`** instead of `kind`
220
+
221
+ `ConsoleShell` is updated to attach favorites/recent under the namespaced
222
+ keys `ui.favorites` and `ui.recent`. Recommended convention for new
223
+ adapters: keep machine-written UI traces under `ui.*` so they stay
224
+ distinguishable from user-facing preferences (`theme`, `locale`, ...).
225
+
226
+ **Migration**: callers passing `kind:` need to switch to `key:`. Callers
227
+ relying on the old `user_app_state` table can pin
228
+ `resource: 'user_app_state'` to keep the legacy behaviour, but no
229
+ backend ships that schema and the new default works against any
230
+ plugin-auth-enabled environment with zero extra setup.
231
+
232
+ ### Patch Changes
233
+
234
+ - 983d5ad: fix(app-shell): suppress duplicate discussion panel on record detail pages
235
+
236
+ `RecordDetailView` auto-appends a `RecordChatterPanel` below the
237
+ rendered page unless an explicit `record:discussion` / `record:chatter`
238
+ node is found in the schema. The detection walker recursed into
239
+ `children / items / body / components / properties.*` but **not**
240
+ `regions[]`. Synthesised pages (`buildDefaultPageSchema`) and authored
241
+ full-Lightning pages place `record:discussion` inside
242
+ `regions[0].components`, so the walker missed it and a second
243
+ discussion panel rendered on top of the first.
244
+
245
+ Extracted the walker into `utils/pageSchemaIntrospect.ts`, added a
246
+ `regions` branch, and covered both shapes with unit tests.
247
+
248
+ Verified in browser on account (slotted), opportunity (full), lead,
249
+ contact, and task — each renders exactly one discussion panel.
250
+
251
+ - a4c10b2: Restore Edit / Share / Delete system actions on synthesized record detail headers.
252
+
253
+ Phase G slice 6 flipped the synth detail page on by default but did not
254
+ forward the legacy DetailView's built-in system actions to the new
255
+ `record:quick_actions` bar. Objects without authored `record_header`
256
+ business actions ended up with a bare header (only the ★ favorite +
257
+ copy-id chip from `page:header`).
258
+
259
+ This patch injects gated system actions into `synthHeaderActions` for
260
+ both the synth and slotted paths:
261
+ - `sys_edit` — visible when `affordances.edit`. Calls the existing
262
+ `onEdit` prop, opening the same form modal as before.
263
+ - `sys_share` — always visible. Uses `navigator.share` when available;
264
+ falls back to clipboard copy of the current URL with a toast.
265
+ - `sys_delete` — visible when `affordances.delete`. Confirms via
266
+ `window.confirm`, calls `dataSource.delete`, then navigates back to
267
+ the list.
268
+
269
+ Business / custom actions (e.g. Lead.convert, Contact.set_primary)
270
+ continue to render alongside the system actions, unchanged. Full
271
+ Lightning pages (objects with an `assignedPage`) are unaffected — they
272
+ remain author-owned.
273
+
274
+ - Updated dependencies [542cca9]
275
+ - Updated dependencies [8930b15]
276
+ - Updated dependencies [95b6b21]
277
+ - Updated dependencies [ddb08a7]
278
+ - Updated dependencies [f16a762]
279
+ - Updated dependencies [765d50f]
280
+ - Updated dependencies [927187a]
281
+ - Updated dependencies [bae8ba8]
282
+ - Updated dependencies [8435860]
283
+ - Updated dependencies [bece8ca]
284
+ - Updated dependencies [bb2ea48]
285
+ - Updated dependencies [77c1877]
286
+ - Updated dependencies [b14fe09]
287
+ - Updated dependencies [1911d34]
288
+ - Updated dependencies [ba98039]
289
+ - Updated dependencies [a7bef6e]
290
+ - Updated dependencies [86c04f1]
291
+ - Updated dependencies [74962b0]
292
+ - Updated dependencies [8b850b5]
293
+ - Updated dependencies [3154334]
294
+ - Updated dependencies [fa4c2cb]
295
+ - Updated dependencies [7213027]
296
+ - Updated dependencies [34b66bf]
297
+ - Updated dependencies [c7561a7]
298
+ - @object-ui/plugin-detail@5.0.0
299
+ - @object-ui/components@5.0.0
300
+ - @object-ui/i18n@5.0.0
301
+ - @object-ui/layout@5.0.0
302
+ - @object-ui/react@5.0.0
303
+ - @object-ui/types@5.0.0
304
+ - @object-ui/data-objectstack@5.0.0
305
+ - @object-ui/plugin-calendar@5.0.0
306
+ - @object-ui/plugin-kanban@5.0.0
307
+ - @object-ui/fields@5.0.0
308
+ - @object-ui/plugin-charts@5.0.0
309
+ - @object-ui/plugin-chatbot@5.0.0
310
+ - @object-ui/plugin-dashboard@5.0.0
311
+ - @object-ui/plugin-designer@5.0.0
312
+ - @object-ui/plugin-form@5.0.0
313
+ - @object-ui/plugin-grid@5.0.0
314
+ - @object-ui/plugin-list@5.0.0
315
+ - @object-ui/plugin-report@5.0.0
316
+ - @object-ui/plugin-view@5.0.0
317
+ - @object-ui/auth@5.0.0
318
+ - @object-ui/collaboration@5.0.0
319
+ - @object-ui/core@5.0.0
320
+ - @object-ui/permissions@5.0.0
321
+
3
322
  ## 4.8.0
4
323
 
5
324
  ### Minor Changes
@@ -45,7 +45,7 @@ export function ObjectRenderer({ objectName, viewId, dataSource, onRecordClick:
45
45
  }
46
46
  }, [objectName, dataSource, externalObjectDef]);
47
47
  if (loading) {
48
- return (_jsx("div", { className: "flex h-full items-center justify-center", children: _jsx("div", { className: "text-muted-foreground", children: "Loading..." }) }));
48
+ return (_jsx("div", { className: "flex h-full items-center justify-center", children: _jsx("div", { className: "text-muted-foreground", children: "Loading\u2026" }) }));
49
49
  }
50
50
  if (error) {
51
51
  return (_jsx("div", { className: "flex h-full items-center justify-center", children: _jsxs("div", { className: "text-destructive", children: ["Error: ", error] }) }));
@@ -80,12 +80,12 @@ function UserStateBridge() {
80
80
  const favorites = createObjectStackUserStateAdapter({
81
81
  dataSource,
82
82
  userId: user.id,
83
- kind: 'favorites',
83
+ key: 'ui.favorites',
84
84
  });
85
85
  const recent = createObjectStackUserStateAdapter({
86
86
  dataSource,
87
87
  userId: user.id,
88
- kind: 'recent',
88
+ key: 'ui.recent',
89
89
  });
90
90
  attach('favorites', favorites);
91
91
  attach('recent', recent);
@@ -47,7 +47,12 @@ export function AppCard({ app, onClick, isFavorite, index = 0 }) {
47
47
  type: 'object',
48
48
  });
49
49
  };
50
- return (_jsxs(Card, { className: cn('group relative cursor-pointer overflow-hidden border border-border/70 bg-card/80 backdrop-blur-sm', 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg', !primaryColor && accent.ring), onClick: onClick, "data-testid": `app-card-${app.name}`, style: primaryColor ? { borderColor: undefined } : undefined, children: [_jsx("div", { "aria-hidden": true, className: cn('absolute inset-x-0 top-0 h-1', primaryColor ? '' : accent.solid), style: primaryColor ? { backgroundColor: primaryColor } : undefined }), !primaryColor && (_jsx("div", { "aria-hidden": true, className: cn('absolute inset-0 bg-gradient-to-br opacity-0 transition-opacity duration-300 group-hover:opacity-100', accent.from, accent.to) })), _jsxs(CardContent, { className: "relative p-5", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "absolute top-2 right-2 h-8 w-8 p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity", onClick: handleToggleFavorite, "aria-label": isFavorite
50
+ return (_jsxs(Card, { role: "button", tabIndex: 0, "aria-label": label, className: cn('group relative cursor-pointer overflow-hidden border border-border/70 bg-card/80 backdrop-blur-sm', 'transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-lg', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 'motion-reduce:transition-none motion-reduce:hover:transform-none', !primaryColor && accent.ring), onClick: onClick, onKeyDown: (e) => {
51
+ if (e.key === 'Enter' || e.key === ' ') {
52
+ e.preventDefault();
53
+ onClick();
54
+ }
55
+ }, "data-testid": `app-card-${app.name}`, style: primaryColor ? { borderColor: undefined } : undefined, children: [_jsx("div", { "aria-hidden": true, className: cn('absolute inset-x-0 top-0 h-1', primaryColor ? '' : accent.solid), style: primaryColor ? { backgroundColor: primaryColor } : undefined }), !primaryColor && (_jsx("div", { "aria-hidden": true, className: cn('absolute inset-0 bg-gradient-to-br opacity-0 transition-opacity duration-300 group-hover:opacity-100', accent.from, accent.to) })), _jsxs(CardContent, { className: "relative p-5", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "absolute top-2 right-2 h-8 w-8 p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity", onClick: handleToggleFavorite, "aria-label": isFavorite
51
56
  ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) + ` — ${label}`
52
57
  : t('common.addToFavorites', { defaultValue: 'Add to favorites' }) + ` — ${label}`, "aria-pressed": isFavorite, "data-testid": `favorite-btn-${app.name}`, children: isFavorite ? (_jsx(Star, { className: "h-4 w-4 fill-amber-400 text-amber-400" })) : (_jsx(StarOff, { className: "h-4 w-4" })) }), _jsx("div", { className: cn('inline-flex h-14 w-14 items-center justify-center rounded-xl mb-4 ring-1 ring-inset', primaryColor ? '' : cn('bg-gradient-to-br', accent.from, accent.to, 'ring-border/40')), style: primaryColor
53
58
  ? { backgroundColor: `${primaryColor}1f`, boxShadow: `inset 0 0 0 1px ${primaryColor}33` }
@@ -58,20 +58,20 @@ export function HomePage() {
58
58
  const { user } = useAuth();
59
59
  const activeApps = apps.filter((a) => a.active !== false);
60
60
  const recentApps = recentItems
61
- .filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page')
61
+ .filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page' || item.type === 'record')
62
62
  .slice(0, 6);
63
63
  const starredApps = favorites
64
- .filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page')
64
+ .filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page' || item.type === 'record')
65
65
  .slice(0, 8);
66
66
  const greeting = useMemo(() => t(pickGreetingKey(new Date().getHours()), { defaultValue: 'Welcome' }), [t]);
67
67
  const displayName = (user?.name?.trim() || user?.email?.split('@')[0] || '').trim();
68
68
  if (loading) {
69
- return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx("div", { className: "text-muted-foreground", children: t('home.loading', { defaultValue: 'Loading workspace...' }) }) }));
69
+ return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx("div", { className: "text-muted-foreground", children: t('home.loading', { defaultValue: 'Loading workspace' }) }) }));
70
70
  }
71
71
  if (activeApps.length === 0) {
72
72
  return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('home.welcome', { defaultValue: 'Welcome to ObjectUI' }) }), _jsx(EmptyDescription, { children: t('home.welcomeDescription', {
73
73
  defaultValue: 'Get started by creating your first application or configure your system settings.',
74
74
  }) }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('home.createFirstApp', { defaultValue: 'Create Your First App' })] }), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('home.systemSettings', { defaultValue: 'System Settings' })] })] })] }) }));
75
75
  }
76
- return (_jsxs("div", { className: "relative isolate min-h-full bg-gradient-to-b from-background via-background to-muted/40", children: [_jsxs("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[28rem] overflow-hidden", children: [_jsx("div", { className: "absolute -top-32 -left-24 h-[28rem] w-[28rem] rounded-full bg-primary/30 blur-3xl opacity-70 dark:opacity-40" }), _jsx("div", { className: "absolute -top-20 right-[-6rem] h-[26rem] w-[36rem] rounded-full bg-sky-400/30 blur-3xl opacity-70 dark:opacity-35" }), _jsx("div", { className: "absolute top-32 left-1/3 h-[18rem] w-[24rem] rounded-full bg-fuchsia-400/25 blur-3xl opacity-60 dark:opacity-25" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-b from-transparent via-background/40 to-background" })] }), _jsx("section", { className: "px-4 sm:px-6 lg:px-8 pt-10 pb-6", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "flex items-center gap-2 text-xs font-medium text-muted-foreground mb-3", children: [_jsx(Sparkles, { className: "h-3.5 w-3.5 text-primary" }), _jsx("span", { className: "uppercase tracking-wider", children: t('home.title', { defaultValue: 'Home' }) })] }), _jsxs("h1", { className: "text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight", children: [_jsxs("span", { className: "bg-gradient-to-r from-foreground via-foreground to-foreground/70 bg-clip-text text-transparent", children: [greeting, displayName ? `, ${displayName}` : ''] }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "text-base sm:text-lg text-muted-foreground mt-2 max-w-2xl", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }) }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto space-y-10", children: [starredApps.length === 0 && recentApps.length === 0 && (_jsx(GettingStartedHint, { t: t })), starredApps.length > 0 && _jsx(StarredApps, { items: starredApps }), recentApps.length > 0 && _jsx(RecentApps, { items: recentApps }), _jsxs("section", { children: [_jsx("div", { className: "flex items-end justify-between mb-5", children: _jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1", children: [activeApps.length, ' · ', t('home.stats.apps', { defaultValue: 'Applications' })] })] }) }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app, idx) => (_jsx(AppCard, { app: app, index: idx, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] }) })] }));
76
+ return (_jsxs("div", { className: "relative isolate min-h-full bg-gradient-to-b from-background via-background to-muted/40", children: [_jsxs("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[28rem] overflow-hidden", children: [_jsx("div", { className: "absolute -top-32 -left-24 h-[28rem] w-[28rem] rounded-full bg-primary/30 blur-3xl opacity-70 dark:opacity-40" }), _jsx("div", { className: "absolute -top-20 right-[-6rem] h-[26rem] w-[36rem] rounded-full bg-sky-400/30 blur-3xl opacity-70 dark:opacity-35" }), _jsx("div", { className: "absolute top-32 left-1/3 h-[18rem] w-[24rem] rounded-full bg-fuchsia-400/25 blur-3xl opacity-60 dark:opacity-25" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-b from-transparent via-background/40 to-background" })] }), _jsx("section", { className: "px-4 sm:px-6 lg:px-8 pt-10 pb-6", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "flex items-center gap-2 text-xs font-medium text-muted-foreground mb-3", children: [_jsx(Sparkles, { className: "h-3.5 w-3.5 text-primary" }), _jsx("span", { className: "uppercase tracking-wider", children: t('home.title', { defaultValue: 'Home' }) })] }), _jsxs("h1", { className: "text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-pretty", children: [_jsxs("span", { className: "bg-gradient-to-r from-foreground via-foreground to-foreground/70 bg-clip-text text-transparent", children: [greeting, displayName ? `, ${displayName}` : ''] }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "text-base sm:text-lg text-muted-foreground mt-2 max-w-2xl", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }) }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto space-y-10", children: [starredApps.length === 0 && recentApps.length === 0 && (_jsx(GettingStartedHint, { t: t })), starredApps.length > 0 && _jsx(StarredApps, { items: starredApps }), recentApps.length > 0 && _jsx(RecentApps, { items: recentApps }), _jsxs("section", { children: [_jsx("div", { className: "flex items-end justify-between mb-5", children: _jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1", children: [activeApps.length, ' · ', t('home.stats.apps', { defaultValue: 'Applications' })] })] }) }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app, idx) => (_jsx(AppCard, { app: app, index: idx, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] }) })] }));
77
77
  }
@@ -49,11 +49,11 @@ export function QuickActions() {
49
49
  ];
50
50
  return (_jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-5", children: t('home.quickActions.title', { defaultValue: 'Quick Actions' }) }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-3 gap-4", children: actions.map((action) => {
51
51
  const Icon = action.icon;
52
- return (_jsx(Card, { className: cn('group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm', 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', action.hoverBorder), onClick: () => navigate(action.href), "data-testid": `quick-action-${action.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
52
+ return (_jsx(Card, { className: cn('group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm', 'transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-md motion-reduce:transition-none motion-reduce:hover:transform-none', action.hoverBorder), onClick: () => navigate(action.href), "data-testid": `quick-action-${action.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
53
53
  if (e.key === 'Enter' || e.key === ' ') {
54
54
  e.preventDefault();
55
55
  navigate(action.href);
56
56
  }
57
- }, "aria-label": action.label, children: _jsx(CardContent, { className: "p-5", children: _jsxs("div", { className: "flex items-start gap-4", children: [_jsx("div", { className: cn('inline-flex h-11 w-11 items-center justify-center rounded-xl ring-1 ring-inset shrink-0', action.iconBg), children: _jsx(Icon, { className: cn('h-5 w-5', action.iconText) }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("h3", { className: "font-semibold text-base leading-tight", children: action.label }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: action.description })] })] }) }) }, action.id));
57
+ }, "aria-label": action.label, children: _jsx(CardContent, { className: "p-5", children: _jsxs("div", { className: "flex items-start gap-4", children: [_jsx("div", { className: cn('inline-flex h-11 w-11 items-center justify-center rounded-xl ring-1 ring-inset shrink-0', action.iconBg), children: _jsx(Icon, { className: cn('h-5 w-5', action.iconText) }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("h3", { className: "font-semibold text-base leading-tight", children: action.label }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-[opacity,transform] duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: action.description })] })] }) }) }, action.id));
58
58
  }) })] }));
59
59
  }
@@ -29,11 +29,11 @@ export function RecentApps({ items }) {
29
29
  defaultValue: capitalizeFirst(item.type),
30
30
  });
31
31
  const tone = TYPE_TONES[item.type] || TYPE_TONES.object;
32
- return (_jsx(Card, { className: "group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-foreground/20", onClick: () => navigate(item.href), "data-testid": `recent-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
32
+ return (_jsx(Card, { className: "group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-foreground/20 motion-reduce:transition-none motion-reduce:hover:transform-none", onClick: () => navigate(item.href), "data-testid": `recent-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
33
33
  if (e.key === 'Enter' || e.key === ' ') {
34
34
  e.preventDefault();
35
35
  navigate(item.href);
36
36
  }
37
- }, children: _jsx(CardContent, { className: "p-3.5", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: cn('inline-flex h-10 w-10 items-center justify-center rounded-lg ring-1 shrink-0', tone), children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "font-medium text-sm truncate", children: item.label }), _jsx("p", { className: "text-xs text-muted-foreground", children: typeLabel })] }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
37
+ }, children: _jsx(CardContent, { className: "p-3.5", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: cn('inline-flex h-10 w-10 items-center justify-center rounded-lg ring-1 shrink-0', tone), children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "font-medium text-sm truncate", children: item.label }), _jsx("p", { className: "text-xs text-muted-foreground", children: typeLabel })] }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-[opacity,transform] duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
38
38
  }) })] }));
39
39
  }
@@ -26,11 +26,11 @@ export function StarredApps({ items }) {
26
26
  return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-center gap-2 mb-5", children: [_jsx("span", { className: "inline-flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 ring-1 ring-amber-500/20 text-amber-600 dark:text-amber-400", children: _jsx(Star, { className: "h-4 w-4 fill-current" }) }), _jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.starredApps.title', { defaultValue: 'Starred' }) })] }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3", children: items.map((item) => {
27
27
  const Icon = getIcon(item.type);
28
28
  const tone = TYPE_TONES[item.type] || TYPE_TONES.object;
29
- return (_jsx(Card, { className: "group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-foreground/20", onClick: () => navigate(item.href), "data-testid": `starred-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
29
+ return (_jsx(Card, { className: "group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-foreground/20 motion-reduce:transition-none motion-reduce:hover:transform-none", onClick: () => navigate(item.href), "data-testid": `starred-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
30
30
  if (e.key === 'Enter' || e.key === ' ') {
31
31
  e.preventDefault();
32
32
  navigate(item.href);
33
33
  }
34
- }, children: _jsx(CardContent, { className: "p-3.5", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: cn('inline-flex h-10 w-10 items-center justify-center rounded-lg ring-1 shrink-0', tone), children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "font-medium text-sm truncate", children: item.label }), _jsx("p", { className: "text-xs text-muted-foreground", children: capitalizeFirst(item.type) })] }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
34
+ }, children: _jsx(CardContent, { className: "p-3.5", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: cn('inline-flex h-10 w-10 items-center justify-center rounded-lg ring-1 shrink-0', tone), children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "font-medium text-sm truncate", children: item.label }), _jsx("p", { className: "text-xs text-muted-foreground", children: capitalizeFirst(item.type) })] }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-[opacity,transform] duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
35
35
  }) })] }));
36
36
  }
@@ -4,11 +4,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  *
5
5
  * Organization settings: general info form + danger zone.
6
6
  */
7
- import { useCallback, useEffect, useState } from 'react';
8
- import { Button, Input, Label, Separator, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@object-ui/components';
7
+ import { useCallback, useEffect, useRef, useState } from 'react';
8
+ import { Avatar, AvatarFallback, AvatarImage, Button, Input, Label, Separator, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@object-ui/components';
9
9
  import { useAuth } from '@object-ui/auth';
10
10
  import { useObjectTranslation } from '@object-ui/i18n';
11
- import { Loader2 } from 'lucide-react';
11
+ import { useUpload } from '@object-ui/providers';
12
+ import { Loader2, Upload, X } from 'lucide-react';
12
13
  import { toast } from 'sonner';
13
14
  import { useNavigate } from 'react-router-dom';
14
15
  import { useOrgContext } from './orgContext';
@@ -22,6 +23,9 @@ export function SettingsPage() {
22
23
  const [slug, setSlug] = useState(org.slug ?? '');
23
24
  const [logo, setLogo] = useState(org.logo ?? '');
24
25
  const [isSaving, setIsSaving] = useState(false);
26
+ const [isUploadingLogo, setIsUploadingLogo] = useState(false);
27
+ const logoInputRef = useRef(null);
28
+ const { upload } = useUpload();
25
29
  // Owner check
26
30
  const [isOwner, setIsOwner] = useState(null);
27
31
  const [membersLoading, setMembersLoading] = useState(true);
@@ -118,7 +122,33 @@ export function SettingsPage() {
118
122
  defaultValue: 'Update your organization information.',
119
123
  }) }), _jsx(Separator, { className: "my-4" }), !isOwner ? (_jsx("div", { className: "rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground", children: t('organization.settings.readOnlyNote', {
120
124
  defaultValue: 'Only owners can change settings.',
121
- }) })) : (_jsxs("form", { onSubmit: handleSave, className: "space-y-4 max-w-md", children: [_jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-name", children: t('organization.settings.nameLabel', { defaultValue: 'Organization name' }) }), _jsx(Input, { id: "org-name", value: name, onChange: (e) => setName(e.target.value), required: true, "data-testid": "settings-name-input" })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-slug", children: t('organization.settings.slugLabel', { defaultValue: 'Slug' }) }), _jsx(Input, { id: "org-slug", value: slug, onChange: (e) => setSlug(e.target.value), "data-testid": "settings-slug-input" })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-logo", children: t('organization.settings.logoLabel', { defaultValue: 'Logo URL (optional)' }) }), _jsx(Input, { id: "org-logo", type: "url", value: logo, onChange: (e) => setLogo(e.target.value), placeholder: "https://example.com/logo.png", "data-testid": "settings-logo-input" })] }), _jsxs(Button, { type: "submit", disabled: isSaving, "data-testid": "settings-save-btn", children: [isSaving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), t('organization.settings.save', { defaultValue: 'Save changes' })] })] }))] }), _jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold text-destructive", children: t('organization.settings.dangerZone', { defaultValue: 'Danger zone' }) }), _jsx(Separator, { className: "my-4" }), _jsxs("div", { className: "space-y-4 rounded-lg border border-destructive/50 bg-destructive/5 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { children: [_jsx("p", { className: "font-medium text-sm", children: t('organization.settings.leaveTitle', { defaultValue: 'Leave organization' }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('organization.settings.leaveDescription', {
125
+ }) })) : (_jsxs("form", { onSubmit: handleSave, className: "space-y-4 max-w-md", children: [_jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-name", children: t('organization.settings.nameLabel', { defaultValue: 'Organization name' }) }), _jsx(Input, { id: "org-name", value: name, onChange: (e) => setName(e.target.value), required: true, "data-testid": "settings-name-input" })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-slug", children: t('organization.settings.slugLabel', { defaultValue: 'Slug' }) }), _jsx(Input, { id: "org-slug", value: slug, onChange: (e) => setSlug(e.target.value), "data-testid": "settings-slug-input" })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { children: t('organization.settings.logoLabel', { defaultValue: 'Logo' }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs(Avatar, { className: "size-16 rounded-md", children: [logo ? (_jsx(AvatarImage, { src: logo, alt: name, className: "object-cover" })) : null, _jsx(AvatarFallback, { className: "rounded-md text-base", children: (name || 'O').slice(0, 2).toUpperCase() })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { ref: logoInputRef, type: "file", accept: "image/*", className: "hidden", onChange: async (e) => {
126
+ const file = e.target.files?.[0];
127
+ if (logoInputRef.current)
128
+ logoInputRef.current.value = '';
129
+ if (!file)
130
+ return;
131
+ setIsUploadingLogo(true);
132
+ try {
133
+ const result = await upload(file);
134
+ setLogo(result.url);
135
+ toast.success(t('organization.settings.logoUploaded', {
136
+ defaultValue: 'Logo uploaded — save to apply',
137
+ }));
138
+ }
139
+ catch (err) {
140
+ toast.error(err instanceof Error
141
+ ? err.message
142
+ : t('organization.settings.logoUploadFailed', {
143
+ defaultValue: 'Failed to upload logo',
144
+ }));
145
+ }
146
+ finally {
147
+ setIsUploadingLogo(false);
148
+ }
149
+ }, "data-testid": "settings-logo-file" }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: isUploadingLogo, onClick: () => logoInputRef.current?.click(), "data-testid": "settings-logo-upload-btn", children: [isUploadingLogo ? (_jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" })) : (_jsx(Upload, { className: "mr-2 h-4 w-4" })), logo
150
+ ? t('organization.settings.logoReplace', { defaultValue: 'Replace' })
151
+ : t('organization.settings.logoUpload', { defaultValue: 'Upload' })] }), logo && (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setLogo(''), "data-testid": "settings-logo-clear-btn", children: [_jsx(X, { className: "mr-2 h-4 w-4" }), t('organization.settings.logoClear', { defaultValue: 'Remove' })] }))] }), _jsx(Input, { id: "org-logo", type: "url", value: logo, onChange: (e) => setLogo(e.target.value), placeholder: "https://example.com/logo.png", className: "text-xs", "data-testid": "settings-logo-input" })] })] })] }), _jsxs(Button, { type: "submit", disabled: isSaving, "data-testid": "settings-save-btn", children: [isSaving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), t('organization.settings.save', { defaultValue: 'Save changes' })] })] }))] }), _jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold text-destructive", children: t('organization.settings.dangerZone', { defaultValue: 'Danger zone' }) }), _jsx(Separator, { className: "my-4" }), _jsxs("div", { className: "space-y-4 rounded-lg border border-destructive/50 bg-destructive/5 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { children: [_jsx("p", { className: "font-medium text-sm", children: t('organization.settings.leaveTitle', { defaultValue: 'Leave organization' }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('organization.settings.leaveDescription', {
122
152
  defaultValue: 'You will lose access to this organization.',
123
153
  }) })] }), _jsx(Button, { variant: "destructive", size: "sm", onClick: () => setIsLeaveOpen(true), children: t('organization.settings.leaveAction', { defaultValue: 'Leave' }) })] }), _jsx(Separator, {}), _jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { children: [_jsx("p", { className: "font-medium text-sm", children: t('organization.settings.deleteTitle', { defaultValue: 'Delete organization' }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('organization.settings.deleteDescription', {
124
154
  defaultValue: 'Permanently delete this organization and all its data.',
@@ -22,7 +22,7 @@ export interface FavoriteItem {
22
22
  id: string;
23
23
  label: string;
24
24
  href: string;
25
- type: 'object' | 'dashboard' | 'page' | 'report';
25
+ type: 'object' | 'dashboard' | 'page' | 'report' | 'record';
26
26
  /** ISO timestamp of when the item was favorited */
27
27
  favoritedAt: string;
28
28
  }
@@ -376,7 +376,7 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
376
376
  const lastSegmentLabel = extraSegments[extraSegments.length - 1]?.label || appName || '';
377
377
  return (_jsxs("div", { className: "flex items-center justify-between w-full h-full", children: [_jsxs("div", { className: "flex items-center min-w-0 flex-1", children: [_jsx(Link, { to: "/home", className: cn("flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors", isApp && "hidden sm:flex"), title: "ObjectStack", children: _jsx(Boxes, { className: "h-4 w-4" }) }), resolvedVariant === 'home' && (_jsx("span", { className: "hidden sm:inline ml-2 text-sm font-semibold tracking-tight", children: "ObjectStack" })), resolvedVariant === 'orgs' && (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: t('organizations.title', { defaultValue: 'Organizations' }) })] })), isApp && (_jsxs(_Fragment, { children: [_jsx(SidebarTrigger, { className: "md:hidden shrink-0 ml-1", "aria-label": t('common.toggleSidebar') || 'Toggle sidebar' }), activeAppName && onAppChange ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "hidden sm:flex items-center", children: _jsx(PathSep, {}) }), _jsx("div", { className: "hidden sm:flex items-center", children: _jsx(AppSwitcher, { activeAppName: activeAppName, onAppChange: onAppChange }) })] })) : appName ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "hidden sm:flex items-center", children: _jsx(PathSep, {}) }), _jsx("span", { className: "hidden sm:inline text-sm font-medium text-foreground/80 px-1.5", children: appName })] })) : null, extraSegments.map((seg, i) => {
378
378
  const isLast = i === extraSegments.length - 1;
379
- return (_jsxs("span", { className: "hidden sm:flex items-center min-w-0", children: [_jsx(PathSep, {}), seg.siblings && seg.siblings.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { className: `flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium transition-colors outline-none hover:bg-accent hover:text-foreground ${!isLast ? 'text-foreground/60' : 'text-foreground/80'}`, children: [seg.label, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-56 max-h-72 overflow-y-auto", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Object" }), _jsx(DropdownMenuSeparator, {}), seg.siblings.map((sibling) => (_jsx(DropdownMenuItem, { asChild: true, children: _jsx(Link, { to: sibling.href, className: "w-full", children: sibling.label }) }, sibling.href)))] })] })) : seg.href ? (_jsx(Link, { to: seg.href, className: `rounded-md px-1.5 py-1 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label })) : (_jsx("span", { className: `px-1.5 py-1 text-sm font-medium truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label }))] }, i));
379
+ return (_jsxs("span", { className: "hidden sm:flex items-center min-w-0", children: [_jsx(PathSep, {}), seg.siblings && seg.siblings.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { className: `flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:bg-accent hover:text-foreground ${!isLast ? 'text-foreground/60' : 'text-foreground/80'}`, children: [seg.label, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-56 max-h-72 overflow-y-auto", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Object" }), _jsx(DropdownMenuSeparator, {}), seg.siblings.map((sibling) => (_jsx(DropdownMenuItem, { asChild: true, children: _jsx(Link, { to: sibling.href, className: "w-full", children: sibling.label }) }, sibling.href)))] })] })) : seg.href ? (_jsx(Link, { to: seg.href, className: `rounded-md px-1.5 py-1 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label })) : (_jsx("span", { className: `px-1.5 py-1 text-sm font-medium truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label }))] }, i));
380
380
  }), mobileSwitcher && mobileSwitcher.views.length > 0 ? (mobileSwitcher.views.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "sm:hidden flex items-center gap-0.5 min-w-0 ml-1 rounded-md px-1.5 py-1 text-sm font-medium hover:bg-accent active:bg-accent/80 transition-colors", "aria-label": "Switch view", children: [_jsx("span", { className: "truncate max-w-[180px]", children: mobileSwitcher.triggerLabel ??
381
381
  mobileSwitcher.views.find((v) => v.id === mobileSwitcher.activeViewId)?.label ??
382
382
  lastSegmentLabel }), _jsx(ChevronDown, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "min-w-[220px] max-w-[280px]", children: mobileSwitcher.views.map((v) => {
@@ -385,5 +385,5 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
385
385
  if (!isActive)
386
386
  mobileSwitcher.onChange(v.id);
387
387
  }, className: "gap-2", children: [v.icon ? (_jsx("span", { className: "shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4", children: v.icon })) : null, _jsx("span", { className: "flex-1 truncate", children: v.label }), v.locked ? (_jsx(Lock, { className: "h-3 w-3 shrink-0 text-muted-foreground", "aria-hidden": true })) : null, isActive ? (_jsx(Check, { className: "h-4 w-4 shrink-0 text-foreground", "aria-hidden": true })) : null] }, v.id));
388
- }) })] })) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label }))) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel }))] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [" ", _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { className: "text-[11px] font-normal text-muted-foreground uppercase tracking-wide px-2", children: t('user.preferences', { defaultValue: 'Preferences' }) }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.theme', { defaultValue: 'Theme' }) }), _jsx(ModeToggle, {})] }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.language', { defaultValue: 'Language' }) }), _jsx(LocaleSwitcher, {})] }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] })] })] })] }));
388
+ }) })] })) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label }))) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel }))] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [" ", _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { className: "text-[11px] font-normal text-muted-foreground uppercase tracking-wide px-2", children: t('user.preferences', { defaultValue: 'Preferences' }) }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.theme', { defaultValue: 'Theme' }) }), _jsx(ModeToggle, {})] }), _jsxs("div", { className: "flex items-center justify-between px-2 py-1.5 text-sm", children: [_jsx("span", { className: "text-foreground/80", children: t('user.language', { defaultValue: 'Language' }) }), _jsx(LocaleSwitcher, {})] }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] })] })] })] }));
389
389
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * Helpers that introspect a page schema tree without needing the React
6
+ * runtime. Used by RecordDetailView to decide whether to auto-append
7
+ * a discussion / chatter slot at the bottom of the page.
8
+ */
9
+ /**
10
+ * Walks a page schema tree and returns true if any node has a
11
+ * `type` of `record:discussion` or `record:chatter`.
12
+ *
13
+ * Recurses into:
14
+ * - `children`, `items`, `body`, `components`
15
+ * - `properties.children`, `properties.items`
16
+ * - `regions` (synth + full-Lightning pages nest components here)
17
+ *
18
+ * Cycles are guarded with a WeakSet.
19
+ */
20
+ export declare function hasExplicitDiscussion(root: unknown): boolean;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * Helpers that introspect a page schema tree without needing the React
6
+ * runtime. Used by RecordDetailView to decide whether to auto-append
7
+ * a discussion / chatter slot at the bottom of the page.
8
+ */
9
+ const DISCUSSION_TYPES = new Set(['record:discussion', 'record:chatter']);
10
+ /**
11
+ * Walks a page schema tree and returns true if any node has a
12
+ * `type` of `record:discussion` or `record:chatter`.
13
+ *
14
+ * Recurses into:
15
+ * - `children`, `items`, `body`, `components`
16
+ * - `properties.children`, `properties.items`
17
+ * - `regions` (synth + full-Lightning pages nest components here)
18
+ *
19
+ * Cycles are guarded with a WeakSet.
20
+ */
21
+ export function hasExplicitDiscussion(root) {
22
+ const seen = new WeakSet();
23
+ const walk = (node) => {
24
+ if (!node || typeof node !== 'object')
25
+ return false;
26
+ if (seen.has(node))
27
+ return false;
28
+ seen.add(node);
29
+ if (Array.isArray(node))
30
+ return node.some(walk);
31
+ const t = node?.type;
32
+ if (typeof t === 'string' && DISCUSSION_TYPES.has(t))
33
+ return true;
34
+ const candidates = [
35
+ node.children,
36
+ node.items,
37
+ node.body,
38
+ node.components,
39
+ node.properties?.children,
40
+ node.properties?.items,
41
+ // Synth + full-Lightning pages nest components inside
42
+ // `regions[].components[]`. Without this branch the walker
43
+ // fails to see the `record:discussion` baked in by
44
+ // `buildDefaultPageSchema`, and the host appends a second
45
+ // chatter panel on top of it.
46
+ node.regions,
47
+ ];
48
+ return candidates.some(walk);
49
+ };
50
+ return walk(root);
51
+ }
@@ -19,7 +19,8 @@ import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@
19
19
  // uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
20
20
  // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here.
21
21
  import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay, } from '@object-ui/components';
22
- import { Plus, Upload, Table as TableIcon, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
22
+ import { Plus, Upload, Star, StarOff, Table as TableIcon, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
23
+ import { useFavorites } from '../hooks/useFavorites';
23
24
  import { getIcon } from '../utils/getIcon';
24
25
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
25
26
  import { ViewConfigPanel } from './ViewConfigPanel';
@@ -194,11 +195,12 @@ function fromSysViewRecord(sv) {
194
195
  }
195
196
  export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }) {
196
197
  const navigate = useNavigate();
197
- const { objectName, viewId } = useParams();
198
+ const { appName, objectName, viewId } = useParams();
198
199
  const [searchParams, setSearchParams] = useSearchParams();
199
200
  const { showDebug } = useMetadataInspector();
200
201
  const { t } = useObjectTranslation();
201
202
  const { objectLabel, objectDescription: objectDesc, viewLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
203
+ const { isFavorite, toggleFavorite } = useFavorites();
202
204
  // Inline view config panel state (Airtable-style right sidebar)
203
205
  const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
204
206
  const [viewConfigPanelMode, setViewConfigPanelMode] = useState('edit');
@@ -1581,7 +1583,16 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey })
1581
1583
  activeOrganization: activeOrganization
1582
1584
  ? { id: activeOrganization.id, slug: activeOrganization.slug, name: activeOrganization.name }
1583
1585
  : null,
1584
- }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx("div", { className: "hidden sm:block", children: _jsx(PageHeader, { title: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "hidden sm:inline-flex shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1586
+ }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: [_jsxs("div", { className: "h-full flex flex-col bg-background min-w-0 overflow-hidden", children: [_jsx("div", { className: "hidden sm:block", children: _jsx(PageHeader, { title: _jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: objectLabel(objectDef) }), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), description: objectDef.description ? objectDesc(objectDef) : undefined, icon: (() => { const I = getIcon(objectDef?.icon); return _jsx(I, { className: "h-4 w-4" }); })(), actions: _jsxs(_Fragment, { children: [objectName && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => toggleFavorite({
1587
+ id: `object:${objectName}`,
1588
+ label: objectLabel(objectDef),
1589
+ href: `/apps/${appName}/${objectName}`,
1590
+ type: 'object',
1591
+ }), className: "h-8 sm:h-9 px-2", "aria-pressed": isFavorite(`object:${objectName}`), "aria-label": isFavorite(`object:${objectName}`)
1592
+ ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' })
1593
+ : t('common.addToFavorites', { defaultValue: 'Add to favorites' }), "data-testid": `object-favorite-btn-${objectName}`, children: isFavorite(`object:${objectName}`)
1594
+ ? _jsx(Star, { className: "h-4 w-4 fill-amber-400 text-amber-400" })
1595
+ : _jsx(StarOff, { className: "h-4 w-4" }) })), affordances.create && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", onClick: actions.create, className: "shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.new') })] })), affordances.import && can(objectDef.name, 'create') && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setShowImport(true), className: "hidden sm:inline-flex shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9", title: t('console.objectView.importTitle'), "data-testid": "object-view-import-button", children: [_jsx(Upload, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('console.objectView.import') })] })), objectDef.actions?.some((a) => a.locations?.includes('list_toolbar')) && (_jsx(SchemaRenderer, { schema: {
1585
1596
  type: 'action:bar',
1586
1597
  location: 'list_toolbar',
1587
1598
  actions: (objectDef.actions || []).map((a) => ({
@@ -8,24 +8,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  */
9
9
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
10
10
  import { useParams, useNavigate } from 'react-router-dom';
11
- import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
12
- import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
11
+ import { DetailView, RecordChatterPanel, buildDefaultPageSchema } from '@object-ui/plugin-detail';
12
+ import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
13
13
  import { PresenceAvatars } from '@object-ui/collaboration';
14
14
  import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
15
- import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer } from '@object-ui/react';
15
+ import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider } from '@object-ui/react';
16
16
  import { buildExpandFields } from '@object-ui/core';
17
17
  import { toast } from 'sonner';
18
- import { Database, Users } from 'lucide-react';
18
+ import { Database, Users, Star, StarOff } from 'lucide-react';
19
19
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
20
20
  import { SkeletonDetail } from '../skeletons';
21
21
  import { ManagedByBadge } from '../components/ManagedByBadge';
22
22
  import { resolveCrudAffordances } from '../utils/crudAffordances';
23
+ import { hasExplicitDiscussion } from '../utils/pageSchemaIntrospect';
23
24
  import { ActionConfirmDialog } from './ActionConfirmDialog';
24
25
  import { ActionParamDialog } from './ActionParamDialog';
25
26
  import { resolveActionParams } from '../utils/resolveActionParams';
26
27
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
27
28
  import { useRecordApprovals } from '../hooks/useRecordApprovals';
28
29
  import { getRecordDisplayName } from '../utils';
30
+ import { useFavorites } from '../hooks/useFavorites';
29
31
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
30
32
  /**
31
33
  * Audit field names auto-injected by the framework's `applySystemFields`.
@@ -57,6 +59,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
57
59
  const navigate = useNavigate();
58
60
  const { t } = useObjectTranslation();
59
61
  const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
62
+ const { isFavorite, toggleFavorite } = useFavorites();
60
63
  const [isLoading, setIsLoading] = useState(true);
61
64
  const [feedItems, setFeedItems] = useState([]);
62
65
  const [recordViewers, setRecordViewers] = useState([]);
@@ -79,11 +82,50 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
79
82
  // it via SchemaRenderer (which dispatches to the registered 'record'
80
83
  // PageRenderer in @object-ui/components). Otherwise we fall through to
81
84
  // the legacy auto-generated DetailView path below.
82
- const { page: assignedPage } = usePageAssignment(objectName);
85
+ //
86
+ // Track 3 Phase G slice 6 — `renderViaSchema` is now default-on. The
87
+ // no-assignedPage branch synthesizes a canonical Page via
88
+ // `buildDefaultPageSchema(objectDef)` so the default detail page rides
89
+ // the same SchemaRenderer pipeline as custom pages. Kill-switches:
90
+ // 1) URL query param `?renderViaSchema=0` (per-request fallback to
91
+ // the legacy DetailView monolith — useful for debugging regressions)
92
+ // 2) `objectDef.detail?.renderViaSchema === false` (per-object opt-out)
93
+ const { page: assignedPage, slots: assignedSlots } = usePageAssignment(objectName);
94
+ const renderViaSchemaFlag = useMemo(() => {
95
+ if (typeof window !== 'undefined') {
96
+ try {
97
+ const qp = new URLSearchParams(window.location.search).get('renderViaSchema');
98
+ if (qp === '0' || qp === 'false')
99
+ return false;
100
+ if (qp === '1' || qp === 'true')
101
+ return true;
102
+ }
103
+ catch { }
104
+ }
105
+ if (objectDef?.detail?.renderViaSchema === false)
106
+ return false;
107
+ return true;
108
+ }, [objectDef]);
109
+ const synthesizedPage = useMemo(() => {
110
+ // Synthesizer drives two cases:
111
+ // 1) no assignedPage at all → pure default detail page
112
+ // 2) assignedSlots (slotted page) → synth with slot overrides
113
+ // In either case the page-record load effect below only needs
114
+ // "is there a page?"; the fully-detailed schema is rebuilt at
115
+ // render time once `detailSchema.sections` are known.
116
+ if (assignedPage)
117
+ return null;
118
+ if (!objectDef)
119
+ return null;
120
+ if (!renderViaSchemaFlag && !assignedSlots)
121
+ return null;
122
+ return buildDefaultPageSchema(objectDef, assignedSlots ? { slots: assignedSlots } : undefined);
123
+ }, [renderViaSchemaFlag, assignedPage, assignedSlots, objectDef]);
124
+ const effectivePage = assignedPage || synthesizedPage;
83
125
  const [pageRecord, setPageRecord] = useState(null);
84
126
  useEffect(() => {
85
127
  let cancelled = false;
86
- if (!assignedPage || !pureRecordId || !objectName || !dataSource?.findOne) {
128
+ if (!effectivePage || !pureRecordId || !objectName || !dataSource?.findOne) {
87
129
  setPageRecord(null);
88
130
  return;
89
131
  }
@@ -107,7 +149,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
107
149
  return () => {
108
150
  cancelled = true;
109
151
  };
110
- }, [assignedPage, objectName, pureRecordId, dataSource, objectDef]);
152
+ }, [effectivePage, objectName, pureRecordId, dataSource, objectDef]);
111
153
  // ─── Action Provider Handlers ───────────────────────────────────────
112
154
  // Confirm dialog state (promise-based)
113
155
  const [confirmState, setConfirmState] = useState({ open: false, message: '' });
@@ -1006,10 +1048,178 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1006
1048
  if (!objectDef) {
1007
1049
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) })] }) }));
1008
1050
  }
1009
- if (assignedPage) {
1010
- return (_jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx("div", { className: "bg-background p-3 sm:p-4 lg:p-6", children: _jsx(SchemaRenderer, { schema: assignedPage }) }) }) }));
1051
+ if (effectivePage) {
1052
+ const disableDiscussion = effectivePage?.disableDiscussion === true;
1053
+ // When the page schema embeds an explicit `record:discussion` /
1054
+ // `record:chatter` slot, skip the bottom auto-append so the
1055
+ // author placement (or synth default) wins. The walker recurses
1056
+ // into `regions[]` so `buildDefaultPageSchema` output and
1057
+ // full-Lightning authored pages are both detected.
1058
+ const hasDiscussion = hasExplicitDiscussion(effectivePage);
1059
+ const showAutoDiscussion = !disableDiscussion && !hasDiscussion;
1060
+ // Slice 2 — when we're synthesizing (no author assignedPage), rebuild
1061
+ // the schema with the actual detailSchema.sections + highlight fields
1062
+ // so record:details renders the same field layout the legacy
1063
+ // DetailView would have produced.
1064
+ // Slice 4 — also forward header actions, related lists, activities,
1065
+ // and history so the synthesized page reaches parity with the
1066
+ // monolithic DetailView (tabs strip + record_header quick actions).
1067
+ // Business / custom actions authored on objectDef and routed to the
1068
+ // record_header location (e.g. Lead.convert, Contact.set_primary).
1069
+ const synthBusinessActions = (() => {
1070
+ const acts = detailSchema.actions;
1071
+ if (!Array.isArray(acts))
1072
+ return [];
1073
+ // detailSchema wraps actions in a `{type:'action:bar', actions:[]}`
1074
+ // shape; unwrap to the flat ActionDef[] the renderer expects.
1075
+ const bar = acts.find((a) => Array.isArray(a?.actions));
1076
+ const flat = bar?.actions ?? acts;
1077
+ return Array.isArray(flat) ? flat : [];
1078
+ })();
1079
+ // System actions (Edit / Share / Delete) — the legacy DetailView
1080
+ // monolith always synthesized these. The synth-path replacement
1081
+ // (Phase G slice 6) initially dropped them, leaving objects without
1082
+ // authored record_header actions with a bare header. Re-inject here
1083
+ // so every record page surfaces the basic affordances.
1084
+ const synthSystemActions = (() => {
1085
+ const affordances = resolveCrudAffordances(objectDef);
1086
+ const items = [];
1087
+ if (affordances.edit) {
1088
+ items.push({
1089
+ name: 'sys_edit',
1090
+ label: t('detail.edit', { defaultValue: 'Edit' }),
1091
+ type: 'script',
1092
+ locations: ['record_header'],
1093
+ variant: 'default',
1094
+ onClick: () => onEdit({ id: pureRecordId }),
1095
+ });
1096
+ }
1097
+ items.push({
1098
+ name: 'sys_share',
1099
+ label: t('detail.share', { defaultValue: 'Share' }),
1100
+ type: 'script',
1101
+ locations: ['record_header'],
1102
+ variant: 'outline',
1103
+ onClick: async () => {
1104
+ try {
1105
+ if (navigator.share) {
1106
+ await navigator.share({
1107
+ title: document.title,
1108
+ url: window.location.href,
1109
+ });
1110
+ }
1111
+ else {
1112
+ await navigator.clipboard.writeText(window.location.href);
1113
+ toast.success(t('detail.linkCopied', { defaultValue: 'Link copied' }));
1114
+ }
1115
+ }
1116
+ catch {
1117
+ // user dismissed share sheet — no-op
1118
+ }
1119
+ },
1120
+ });
1121
+ if (affordances.delete) {
1122
+ items.push({
1123
+ name: 'sys_delete',
1124
+ label: t('detail.delete', { defaultValue: 'Delete' }),
1125
+ type: 'script',
1126
+ locations: ['record_header'],
1127
+ variant: 'outline',
1128
+ onClick: async () => {
1129
+ const msg = t('detail.deleteConfirmation', {
1130
+ defaultValue: 'Are you sure you want to delete this record?',
1131
+ });
1132
+ if (!window.confirm(msg))
1133
+ return;
1134
+ try {
1135
+ await dataSource.delete(objectName, pureRecordId);
1136
+ toast.success(t('detail.deleted', { defaultValue: 'Record deleted' }));
1137
+ const baseAppUrl = appName ? `/apps/${appName}` : '';
1138
+ navigate(`${baseAppUrl}/${objectName}`, { replace: true });
1139
+ }
1140
+ catch (err) {
1141
+ toast.error(err?.message || 'Delete failed');
1142
+ }
1143
+ },
1144
+ });
1145
+ }
1146
+ return items;
1147
+ })();
1148
+ // The synth path now hands ONLY business actions to the page schema.
1149
+ // System actions (Edit / Share / Delete) ride through
1150
+ // `RecordContext.headerSystemActions` instead, so they reach both
1151
+ // synth/slotted pages AND authored full-Lightning pages without
1152
+ // mutating the assignedPage tree. `PageHeaderRenderer` dedupes by
1153
+ // name so authored business actions still win on collision.
1154
+ const synthHeaderActions = synthBusinessActions.length > 0 ? synthBusinessActions : undefined;
1155
+ const synthRelated = Array.isArray(detailSchema.related)
1156
+ ? detailSchema.related
1157
+ .filter((r) => r?.api && r?.referenceField)
1158
+ .map((r) => ({
1159
+ title: r.title,
1160
+ objectName: r.api,
1161
+ relationshipField: r.referenceField,
1162
+ ...(Array.isArray(r.columns) ? { columns: r.columns } : {}),
1163
+ ...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}),
1164
+ ...(r.icon ? { icon: r.icon } : {}),
1165
+ }))
1166
+ : undefined;
1167
+ const synthHistory = detailSchema.history
1168
+ ? {
1169
+ entries: detailSchema.history.entries ?? [],
1170
+ loading: !!detailSchema.history.loading,
1171
+ emptyText: detailSchema.history.emptyText,
1172
+ }
1173
+ : undefined;
1174
+ const renderedPage = assignedPage
1175
+ ? effectivePage
1176
+ : buildDefaultPageSchema(objectDef, {
1177
+ sections: detailSchema.sections,
1178
+ highlightFields: Array.isArray(detailSchema.highlightFields)
1179
+ ? detailSchema.highlightFields
1180
+ .map((f) => (typeof f === 'string' ? f : f?.name))
1181
+ .filter((n) => !!n)
1182
+ : undefined,
1183
+ headerActions: synthHeaderActions,
1184
+ related: synthRelated,
1185
+ history: synthHistory,
1186
+ ...(assignedSlots ? { slots: assignedSlots } : {}),
1187
+ });
1188
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [objectName && pureRecordId && (_jsx(Button, { size: "sm", variant: "ghost", className: "h-8 w-8 p-0", onClick: () => toggleFavorite({
1189
+ id: `record:${objectName}:${pureRecordId}`,
1190
+ label: recordTitle || pureRecordId || '',
1191
+ href: `/apps/${appName}/${objectName}/record/${pureRecordId}`,
1192
+ type: 'record',
1193
+ }), "aria-pressed": isFavorite(`record:${objectName}:${pureRecordId}`), "aria-label": isFavorite(`record:${objectName}:${pureRecordId}`)
1194
+ ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' })
1195
+ : t('common.addToFavorites', { defaultValue: 'Add to favorites' }), "data-testid": `record-favorite-btn-${pureRecordId}`, children: isFavorite(`record:${objectName}:${pureRecordId}`)
1196
+ ? _jsx(Star, { className: "h-4 w-4 fill-amber-400 text-amber-400" })
1197
+ : _jsx(StarOff, { className: "h-4 w-4" }) })), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] }))] }), _jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, headerSystemActions: synthSystemActions, children: _jsx(HighlightFieldsProvider, { children: _jsx(DiscussionContextProvider, { items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsxs("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: [_jsx(SchemaRenderer, { schema: renderedPage }), showAutoDiscussion && (_jsx("div", { className: "mt-6", children: _jsx(RecordChatterPanel, { config: {
1198
+ position: 'bottom',
1199
+ collapsible: false,
1200
+ feed: {
1201
+ enableReactions: true,
1202
+ enableThreading: true,
1203
+ showCommentInput: true,
1204
+ },
1205
+ }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) }))] }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Page Schema', data: renderedPage }] })] }) }) }) }) }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
1206
+ if (!open)
1207
+ setConfirmState(s => ({ ...s, open: false }));
1208
+ } }), _jsx(ActionParamDialog, { state: paramState, onOpenChange: (open) => {
1209
+ if (!open)
1210
+ setParamState(s => ({ ...s, open: false }));
1211
+ } })] }));
1011
1212
  }
1012
- return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [_jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] }))] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
1213
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [objectName && pureRecordId && (_jsx(Button, { size: "sm", variant: "ghost", className: "h-8 w-8 p-0", onClick: () => toggleFavorite({
1214
+ id: `record:${objectName}:${pureRecordId}`,
1215
+ label: recordTitle || pureRecordId || '',
1216
+ href: `/apps/${appName}/${objectName}/record/${pureRecordId}`,
1217
+ type: 'record',
1218
+ }), "aria-pressed": isFavorite(`record:${objectName}:${pureRecordId}`), "aria-label": isFavorite(`record:${objectName}:${pureRecordId}`)
1219
+ ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' })
1220
+ : t('common.addToFavorites', { defaultValue: 'Add to favorites' }), "data-testid": `record-favorite-btn-${pureRecordId}`, children: isFavorite(`record:${objectName}:${pureRecordId}`)
1221
+ ? _jsx(Star, { className: "h-4 w-4 fill-amber-400 text-amber-400" })
1222
+ : _jsx(StarOff, { className: "h-4 w-4" }) })), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] }))] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), onDataLoaded: (record) => {
1013
1223
  if (!record || typeof record !== 'object')
1014
1224
  return;
1015
1225
  // Resolve the same way DetailView's header does, so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.8.0",
3
+ "version": "5.0.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,34 +27,35 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.16.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "4.8.0",
31
- "@object-ui/collaboration": "4.8.0",
32
- "@object-ui/components": "4.8.0",
33
- "@object-ui/core": "4.8.0",
34
- "@object-ui/data-objectstack": "4.8.0",
35
- "@object-ui/fields": "4.8.0",
36
- "@object-ui/i18n": "4.8.0",
37
- "@object-ui/layout": "4.8.0",
38
- "@object-ui/permissions": "4.8.0",
39
- "@object-ui/react": "4.8.0",
40
- "@object-ui/types": "4.8.0"
30
+ "@object-ui/auth": "5.0.1",
31
+ "@object-ui/collaboration": "5.0.1",
32
+ "@object-ui/components": "5.0.1",
33
+ "@object-ui/core": "5.0.1",
34
+ "@object-ui/data-objectstack": "5.0.1",
35
+ "@object-ui/fields": "5.0.1",
36
+ "@object-ui/i18n": "5.0.1",
37
+ "@object-ui/layout": "5.0.1",
38
+ "@object-ui/permissions": "5.0.1",
39
+ "@object-ui/providers": "5.0.1",
40
+ "@object-ui/react": "5.0.1",
41
+ "@object-ui/types": "5.0.1"
41
42
  },
42
43
  "peerDependencies": {
43
44
  "react": "^18.0.0 || ^19.0.0",
44
45
  "react-dom": "^18.0.0 || ^19.0.0",
45
46
  "react-router-dom": "^6.0.0 || ^7.0.0",
46
- "@object-ui/plugin-calendar": "^4.8.0",
47
- "@object-ui/plugin-charts": "^4.8.0",
48
- "@object-ui/plugin-chatbot": "^4.8.0",
49
- "@object-ui/plugin-dashboard": "^4.8.0",
50
- "@object-ui/plugin-designer": "^4.8.0",
51
- "@object-ui/plugin-detail": "^4.8.0",
52
- "@object-ui/plugin-form": "^4.8.0",
53
- "@object-ui/plugin-grid": "^4.8.0",
54
- "@object-ui/plugin-kanban": "^4.8.0",
55
- "@object-ui/plugin-list": "^4.8.0",
56
- "@object-ui/plugin-report": "^4.8.0",
57
- "@object-ui/plugin-view": "^4.8.0"
47
+ "@object-ui/plugin-calendar": "^5.0.1",
48
+ "@object-ui/plugin-charts": "^5.0.1",
49
+ "@object-ui/plugin-chatbot": "^5.0.1",
50
+ "@object-ui/plugin-dashboard": "^5.0.1",
51
+ "@object-ui/plugin-designer": "^5.0.1",
52
+ "@object-ui/plugin-detail": "^5.0.1",
53
+ "@object-ui/plugin-form": "^5.0.1",
54
+ "@object-ui/plugin-grid": "^5.0.1",
55
+ "@object-ui/plugin-kanban": "^5.0.1",
56
+ "@object-ui/plugin-list": "^5.0.1",
57
+ "@object-ui/plugin-report": "^5.0.1",
58
+ "@object-ui/plugin-view": "^5.0.1"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@types/node": "^25.9.0",