@object-ui/app-shell 4.5.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +21 -0
  3. package/dist/console/AppContent.js +6 -39
  4. package/dist/console/ConsoleShell.js +45 -4
  5. package/dist/console/home/AppCard.d.ts +2 -1
  6. package/dist/console/home/AppCard.js +24 -5
  7. package/dist/console/home/HomePage.js +29 -7
  8. package/dist/console/home/QuickActions.js +13 -7
  9. package/dist/console/home/RecentApps.js +12 -5
  10. package/dist/console/home/StarredApps.js +12 -5
  11. package/dist/context/FavoritesProvider.d.ts +9 -12
  12. package/dist/context/FavoritesProvider.js +83 -37
  13. package/dist/context/RecentItemsProvider.d.ts +45 -0
  14. package/dist/context/RecentItemsProvider.js +139 -0
  15. package/dist/context/UserStateAdapters.d.ts +61 -0
  16. package/dist/context/UserStateAdapters.js +141 -0
  17. package/dist/context/index.d.ts +4 -0
  18. package/dist/context/index.js +2 -0
  19. package/dist/hooks/index.d.ts +1 -0
  20. package/dist/hooks/index.js +1 -0
  21. package/dist/hooks/useRecentItems.d.ts +9 -17
  22. package/dist/hooks/useRecentItems.js +9 -46
  23. package/dist/hooks/useTrackRouteAsRecent.d.ts +18 -0
  24. package/dist/hooks/useTrackRouteAsRecent.js +91 -0
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +1 -1
  27. package/dist/layout/PageHeader.d.ts +29 -2
  28. package/dist/layout/PageHeader.js +20 -2
  29. package/dist/utils/resolveActionParams.d.ts +11 -0
  30. package/dist/utils/resolveActionParams.js +19 -0
  31. package/dist/views/ActionParamDialog.js +31 -2
  32. package/dist/views/ObjectView.js +63 -164
  33. package/dist/views/RecordDetailView.d.ts +20 -1
  34. package/dist/views/RecordDetailView.js +23 -9
  35. package/package.json +24 -24
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.7.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @object-ui/types@4.7.0
8
+ - @object-ui/core@4.7.0
9
+ - @object-ui/i18n@4.7.0
10
+ - @object-ui/react@4.7.0
11
+ - @object-ui/components@4.7.0
12
+ - @object-ui/fields@4.7.0
13
+ - @object-ui/layout@4.7.0
14
+ - @object-ui/data-objectstack@4.7.0
15
+ - @object-ui/auth@4.7.0
16
+ - @object-ui/permissions@4.7.0
17
+ - @object-ui/collaboration@4.7.0
18
+
19
+ ## 4.6.0
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [3ee436d]
24
+ - @object-ui/components@4.6.0
25
+ - @object-ui/fields@4.6.0
26
+ - @object-ui/layout@4.6.0
27
+ - @object-ui/types@4.6.0
28
+ - @object-ui/core@4.6.0
29
+ - @object-ui/i18n@4.6.0
30
+ - @object-ui/react@4.6.0
31
+ - @object-ui/data-objectstack@4.6.0
32
+ - @object-ui/auth@4.6.0
33
+ - @object-ui/permissions@4.6.0
34
+ - @object-ui/collaboration@4.6.0
35
+
3
36
  ## 4.5.0
4
37
 
5
38
  ### Patch Changes
package/README.md CHANGED
@@ -229,6 +229,27 @@ via the action runner, regardless of the object's `editMode`:
229
229
  See [`content/docs/guide/record-edit-modes.md`](../../content/docs/guide/record-edit-modes.md)
230
230
  for a longer walkthrough.
231
231
 
232
+ ## User-scoped state (favorites, recent items)
233
+
234
+ `<ConsoleShell>` includes `FavoritesProvider` and `RecentItemsProvider` —
235
+ shared, user-scoped state for pinned apps and recently visited entities.
236
+
237
+ Both providers are **localStorage-first**: instant first paint, no flash of
238
+ empty UI. If a `UserDataAdapter` is attached via `UserStateAdaptersProvider`,
239
+ they additionally hydrate from and write through to a backend (debounced).
240
+ The official ObjectStack adapter lives in `@object-ui/data-objectstack`
241
+ (`createObjectStackUserStateAdapter`).
242
+
243
+ ```tsx
244
+ import { useFavorites, useRecentItems } from '@object-ui/app-shell';
245
+
246
+ const { favorites, toggleFavorite, isFavorite } = useFavorites();
247
+ const { recentItems, addRecentItem } = useRecentItems();
248
+ ```
249
+
250
+ See [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
251
+ for the adapter contract, backend schema, and how to plug in your own backend.
252
+
232
253
  <!-- release-metadata:v3.3.0 -->
233
254
 
234
255
  ## Compatibility
@@ -19,7 +19,7 @@ import { useAuth } from '@object-ui/auth';
19
19
  import { useMetadata } from '../providers/MetadataProvider';
20
20
  import { useAdapter } from '../providers/AdapterProvider';
21
21
  import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
22
- import { useRecentItems } from '../hooks/useRecentItems';
22
+ import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent';
23
23
  import { resolveRecordFormTarget, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
24
24
  import { ExpressionEvaluator } from '@object-ui/core';
25
25
  // Components (eagerly loaded — always needed)
@@ -82,7 +82,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
82
82
  const [isDialogOpen, setIsDialogOpen] = useState(false);
83
83
  const [editingRecord, setEditingRecord] = useState(null);
84
84
  const [refreshKey, setRefreshKey] = useState(0);
85
- const { addRecentItem } = useRecentItems();
86
85
  const { execute: executeAction, runner } = useActionRunner();
87
86
  useGlobalUndo({
88
87
  dataSource: dataSource ?? undefined,
@@ -190,43 +189,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
190
189
  executeAction({ type: 'dialog_cancel' });
191
190
  }, [executeAction]);
192
191
  // Track recent items on route change.
193
- useEffect(() => {
194
- if (!activeApp)
195
- return;
196
- const parts = location.pathname.split('/').filter(Boolean);
197
- let objName = parts[2];
198
- if (objName === 'view' || objName === 'record' || objName === 'page' || objName === 'dashboard' || objName === 'design') {
199
- objName = '';
200
- }
201
- const basePath = `/apps/${activeApp.name}`;
202
- if (objName) {
203
- const obj = allObjects.find((o) => o.name === objName);
204
- if (obj) {
205
- addRecentItem({
206
- id: `object:${obj.name}`,
207
- label: obj.label || obj.name,
208
- href: `${basePath}/${obj.name}`,
209
- type: 'object',
210
- });
211
- }
212
- }
213
- else if (parts[2] === 'dashboard' && parts[3]) {
214
- addRecentItem({
215
- id: `dashboard:${parts[3]}`,
216
- label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
217
- href: `${basePath}/dashboard/${parts[3]}`,
218
- type: 'dashboard',
219
- });
220
- }
221
- else if (parts[2] === 'report' && parts[3]) {
222
- addRecentItem({
223
- id: `report:${parts[3]}`,
224
- label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
225
- href: `${basePath}/report/${parts[3]}`,
226
- type: 'report',
227
- });
228
- }
229
- }, [location.pathname, addRecentItem]); // eslint-disable-line react-hooks/exhaustive-deps
192
+ useTrackRouteAsRecent({
193
+ pathname: location.pathname,
194
+ appName: activeApp?.name,
195
+ objects: allObjects,
196
+ });
230
197
  const handleEdit = (record) => {
231
198
  // Page-mode opt-in: when the object metadata declares
232
199
  // `editMode: 'page'`, route to the full-screen create/edit page instead
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Console building blocks — composable JSX elements that consumers assemble in
4
4
  * their own App.tsx to build the console routing tree.
@@ -10,14 +10,17 @@ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
10
10
  * JSX in App.tsx. See examples/console-starter/src/App.tsx for a minimal example,
11
11
  * apps/console/src/App.tsx for one with custom system routes + CreateApp.
12
12
  */
13
- import { Suspense } from 'react';
13
+ import { Suspense, useEffect } from 'react';
14
14
  import { Navigate, useLocation } from 'react-router-dom';
15
15
  import { AuthGuard, useAuth } from '@object-ui/auth';
16
16
  import { SchemaRendererProvider } from '@object-ui/react';
17
+ import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
17
18
  import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
18
19
  import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
19
20
  import { NavigationProvider } from '../context/NavigationContext';
20
21
  import { FavoritesProvider } from '../context/FavoritesProvider';
22
+ import { RecentItemsProvider } from '../context/RecentItemsProvider';
23
+ import { UserStateAdaptersProvider, useAttachUserStateAdapters, } from '../context/UserStateAdapters';
21
24
  import { ThemeProvider } from '../chrome/ThemeProvider';
22
25
  export function LoadingFallback() {
23
26
  return (_jsx("div", { className: "h-screen flex items-center justify-center text-sm text-muted-foreground", children: "Loading\u2026" }));
@@ -37,7 +40,7 @@ export function LoadingFallback() {
37
40
  * </BrowserRouter>
38
41
  */
39
42
  export function ConsoleShell({ children }) {
40
- return (_jsx(ThemeProvider, { defaultTheme: "system", storageKey: "object-ui-theme", children: _jsx(NavigationProvider, { children: _jsx(FavoritesProvider, { children: _jsx(Suspense, { fallback: _jsx(LoadingFallback, {}), children: children }) }) }) }));
43
+ return (_jsx(ThemeProvider, { defaultTheme: "system", storageKey: "object-ui-theme", children: _jsx(NavigationProvider, { children: _jsx(UserStateAdaptersProvider, { children: _jsx(FavoritesProvider, { children: _jsx(RecentItemsProvider, { children: _jsx(Suspense, { fallback: _jsx(LoadingFallback, {}), children: children }) }) }) }) }) }));
41
44
  }
42
45
  /**
43
46
  * ConnectedShell — mounts the data layer (AdapterProvider + MetadataProvider).
@@ -53,7 +56,45 @@ function ConnectedShellInner({ children }) {
53
56
  return _jsx(LoadingFallback, {});
54
57
  // Expose the adapter via SchemaRendererContext so descendant hooks like
55
58
  // useDiscovery() (used to gate the global AI chatbot) can resolve it.
56
- return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(MetadataProvider, { adapter: adapter, children: children }) }));
59
+ return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsxs(MetadataProvider, { adapter: adapter, children: [_jsx(UserStateBridge, {}), children] }) }));
60
+ }
61
+ /**
62
+ * UserStateBridge — once we have an authenticated user + a connected data
63
+ * adapter, plug ObjectStack-backed persistence into the favorites and
64
+ * recent-items providers. Renders nothing.
65
+ *
66
+ * Failure modes (object schema not configured, network errors, etc.) are
67
+ * absorbed by the adapter itself — the UI then transparently falls back to
68
+ * localStorage-only behaviour.
69
+ */
70
+ function UserStateBridge() {
71
+ const { user } = useAuth();
72
+ const dataSource = useAdapter();
73
+ const attach = useAttachUserStateAdapters();
74
+ useEffect(() => {
75
+ if (!user?.id || !dataSource) {
76
+ attach('favorites', null);
77
+ attach('recent', null);
78
+ return;
79
+ }
80
+ const favorites = createObjectStackUserStateAdapter({
81
+ dataSource,
82
+ userId: user.id,
83
+ kind: 'favorites',
84
+ });
85
+ const recent = createObjectStackUserStateAdapter({
86
+ dataSource,
87
+ userId: user.id,
88
+ kind: 'recent',
89
+ });
90
+ attach('favorites', favorites);
91
+ attach('recent', recent);
92
+ return () => {
93
+ attach('favorites', null);
94
+ attach('recent', null);
95
+ };
96
+ }, [user?.id, dataSource, attach]);
97
+ return null;
57
98
  }
58
99
  /**
59
100
  * RequireOrganization — redirects to /organizations when the multi-tenant
@@ -9,6 +9,7 @@ interface AppCardProps {
9
9
  app: any;
10
10
  onClick: () => void;
11
11
  isFavorite: boolean;
12
+ index?: number;
12
13
  }
13
- export declare function AppCard({ app, onClick, isFavorite }: AppCardProps): import("react/jsx-runtime").JSX.Element;
14
+ export declare function AppCard({ app, onClick, isFavorite, index }: AppCardProps): import("react/jsx-runtime").JSX.Element;
14
15
  export {};
@@ -6,14 +6,30 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  *
7
7
  * @module
8
8
  */
9
- import { Star, StarOff } from 'lucide-react';
9
+ import { Star, StarOff, ArrowUpRight } from 'lucide-react';
10
10
  import { Card, CardContent, Button, Badge } from '@object-ui/components';
11
11
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
12
12
  import { resolveI18nLabel } from '../../utils';
13
13
  import { useFavorites } from '../../hooks/useFavorites';
14
14
  import { getIcon } from '../../utils/getIcon';
15
15
  import { cn } from '@object-ui/components';
16
- export function AppCard({ app, onClick, isFavorite }) {
16
+ // Deterministic accent palette for apps that don't declare a brand color.
17
+ const ACCENTS = [
18
+ { from: 'from-blue-500/15', to: 'to-indigo-500/10', text: 'text-blue-600 dark:text-blue-400', ring: 'group-hover:border-blue-500/40', solid: 'bg-blue-500' },
19
+ { from: 'from-emerald-500/15', to: 'to-teal-500/10', text: 'text-emerald-600 dark:text-emerald-400', ring: 'group-hover:border-emerald-500/40', solid: 'bg-emerald-500' },
20
+ { from: 'from-fuchsia-500/15', to: 'to-pink-500/10', text: 'text-fuchsia-600 dark:text-fuchsia-400', ring: 'group-hover:border-fuchsia-500/40', solid: 'bg-fuchsia-500' },
21
+ { from: 'from-amber-500/15', to: 'to-orange-500/10', text: 'text-amber-600 dark:text-amber-400', ring: 'group-hover:border-amber-500/40', solid: 'bg-amber-500' },
22
+ { from: 'from-sky-500/15', to: 'to-cyan-500/10', text: 'text-sky-600 dark:text-sky-400', ring: 'group-hover:border-sky-500/40', solid: 'bg-sky-500' },
23
+ { from: 'from-violet-500/15', to: 'to-purple-500/10', text: 'text-violet-600 dark:text-violet-400', ring: 'group-hover:border-violet-500/40', solid: 'bg-violet-500' },
24
+ { from: 'from-rose-500/15', to: 'to-red-500/10', text: 'text-rose-600 dark:text-rose-400', ring: 'group-hover:border-rose-500/40', solid: 'bg-rose-500' },
25
+ ];
26
+ function hashStr(s) {
27
+ let h = 0;
28
+ for (let i = 0; i < s.length; i++)
29
+ h = (h * 31 + s.charCodeAt(i)) | 0;
30
+ return Math.abs(h);
31
+ }
32
+ export function AppCard({ app, onClick, isFavorite, index = 0 }) {
17
33
  const { t } = useObjectTranslation();
18
34
  const { appLabel, appDescription } = useObjectLabel();
19
35
  const { toggleFavorite } = useFavorites();
@@ -21,6 +37,7 @@ export function AppCard({ app, onClick, isFavorite }) {
21
37
  const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) });
22
38
  const description = appDescription({ name: app.name, description: resolveI18nLabel(app.description, t) });
23
39
  const primaryColor = app.branding?.primaryColor;
40
+ const accent = ACCENTS[(hashStr(app.name) + index) % ACCENTS.length];
24
41
  const handleToggleFavorite = (e) => {
25
42
  e.stopPropagation();
26
43
  toggleFavorite({
@@ -30,7 +47,9 @@ export function AppCard({ app, onClick, isFavorite }) {
30
47
  type: 'object',
31
48
  });
32
49
  };
33
- return (_jsx(Card, { className: "cursor-pointer hover:shadow-lg transition-all group relative", onClick: onClick, "data-testid": `app-card-${app.name}`, children: _jsxs(CardContent, { className: "p-6", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity", onClick: handleToggleFavorite, "aria-label": isFavorite
34
- ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) + ` — ${label}`
35
- : 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-yellow-400 text-yellow-400" })) : (_jsx(StarOff, { className: "h-4 w-4" })) }), _jsx("div", { className: cn('inline-flex p-3 rounded-lg mb-4', primaryColor ? '' : 'bg-primary/10'), style: primaryColor ? { backgroundColor: `${primaryColor}20` } : {}, children: _jsx(Icon, { className: "h-8 w-8", style: primaryColor ? { color: primaryColor } : {} }) }), _jsxs("div", { children: [_jsx("h3", { className: "font-semibold text-lg mb-1", children: label }), description && (_jsx("p", { className: "text-sm text-muted-foreground line-clamp-2", children: description })), !description && (_jsx("p", { className: "text-sm text-muted-foreground", children: t('home.appCard.noDescription', { defaultValue: 'No description' }) }))] }), app.isDefault && (_jsx("div", { className: "mt-3", children: _jsx(Badge, { variant: "secondary", children: t('home.appCard.default', { defaultValue: 'Default' }) }) }))] }) }));
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
51
+ ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) + ` — ${label}`
52
+ : 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
+ ? { backgroundColor: `${primaryColor}1f`, boxShadow: `inset 0 0 0 1px ${primaryColor}33` }
54
+ : undefined, children: _jsx(Icon, { className: cn('h-7 w-7', primaryColor ? '' : accent.text), style: primaryColor ? { color: primaryColor } : undefined }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("h3", { className: "font-semibold text-base sm:text-lg leading-tight truncate", children: label }), app.isDefault && (_jsx(Badge, { variant: "secondary", className: "shrink-0 text-[10px] px-1.5 py-0", children: t('home.appCard.default', { defaultValue: 'Default' }) }))] }), _jsx("p", { className: cn('text-sm text-muted-foreground mt-1.5 line-clamp-2 min-h-[2.5rem]', !description && 'italic'), children: description || t('home.appCard.noDescription', { defaultValue: 'No description' }) })] }), _jsx("div", { className: "mt-4 flex items-center justify-between text-xs font-medium", children: _jsxs("span", { className: "inline-flex items-center gap-1 text-muted-foreground transition-colors group-hover:text-foreground", children: [t('home.open', { defaultValue: 'Open' }), _jsx(ArrowUpRight, { className: "h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" })] }) })] })] }));
36
55
  }
@@ -16,40 +16,62 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  *
17
17
  * @module
18
18
  */
19
+ import { useMemo } from 'react';
19
20
  import { useNavigate } from 'react-router-dom';
20
21
  import { useMetadata } from '../../providers/MetadataProvider';
21
22
  import { useRecentItems } from '../../hooks/useRecentItems';
22
23
  import { useFavorites } from '../../hooks/useFavorites';
23
24
  import { useObjectTranslation } from '@object-ui/i18n';
25
+ import { useAuth } from '@object-ui/auth';
24
26
  import { AppCard } from './AppCard';
25
27
  import { RecentApps } from './RecentApps';
26
28
  import { StarredApps } from './StarredApps';
27
29
  import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
28
- import { Plus, Settings } from 'lucide-react';
30
+ import { Plus, Settings, Sparkles, Star, Clock, ArrowDown } from 'lucide-react';
31
+ function pickGreetingKey(hour) {
32
+ if (hour < 5)
33
+ return 'home.greetingNight';
34
+ if (hour < 12)
35
+ return 'home.greetingMorning';
36
+ if (hour < 18)
37
+ return 'home.greetingAfternoon';
38
+ if (hour < 23)
39
+ return 'home.greetingEvening';
40
+ return 'home.greetingNight';
41
+ }
42
+ /**
43
+ * Friendly onboarding hint shown above the All Apps grid when the user has
44
+ * no starred or recent items yet. Replaces per-section empty states (which
45
+ * would flicker as the backend adapter hydrates).
46
+ */
47
+ function GettingStartedHint({ t }) {
48
+ return (_jsxs("section", { "data-testid": "home-getting-started", className: "relative overflow-hidden rounded-2xl border border-border/60 bg-card/70 backdrop-blur-sm p-6 sm:p-8", children: [_jsx("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-fuchsia-500/5" }), _jsxs("div", { className: "relative flex flex-col sm:flex-row sm:items-center gap-5", children: [_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsx("span", { className: "inline-flex h-10 w-10 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-5 w-5" }) }), _jsx("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 ring-1 ring-emerald-500/20 text-emerald-600 dark:text-emerald-400", children: _jsx(Clock, { className: "h-5 w-5" }) })] }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h2", { className: "text-lg font-semibold tracking-tight", children: t('home.gettingStarted.title', { defaultValue: 'Make this home yours' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1 max-w-xl", children: t('home.gettingStarted.description', {
49
+ defaultValue: 'Star an app to pin it here for one-click access. Anything you open will show up under Recently Accessed automatically.',
50
+ }) })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs font-medium text-muted-foreground", children: [_jsx("span", { children: t('home.gettingStarted.cta', { defaultValue: 'Browse all applications' }) }), _jsx(ArrowDown, { className: "h-3.5 w-3.5" })] })] })] }));
51
+ }
29
52
  export function HomePage() {
30
53
  const navigate = useNavigate();
31
54
  const { t } = useObjectTranslation();
32
55
  const { apps, loading } = useMetadata();
33
56
  const { recentItems } = useRecentItems();
34
57
  const { favorites } = useFavorites();
35
- // Filter active apps
58
+ const { user } = useAuth();
36
59
  const activeApps = apps.filter((a) => a.active !== false);
37
- // Get recent apps (only apps, not objects/dashboards)
38
60
  const recentApps = recentItems
39
61
  .filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page')
40
62
  .slice(0, 6);
41
- // Get starred apps
42
63
  const starredApps = favorites
43
64
  .filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page')
44
65
  .slice(0, 8);
66
+ const greeting = useMemo(() => t(pickGreetingKey(new Date().getHours()), { defaultValue: 'Welcome' }), [t]);
67
+ const displayName = (user?.name?.trim() || user?.email?.split('@')[0] || '').trim();
45
68
  if (loading) {
46
- return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx("div", { className: "text-muted-foreground", children: "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...' }) }) }));
47
70
  }
48
- // Empty state - no apps configured
49
71
  if (activeApps.length === 0) {
50
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', {
51
73
  defaultValue: 'Get started by creating your first application or configure your system settings.',
52
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' })] })] })] }) }));
53
75
  }
54
- return (_jsxs("div", { className: "bg-background", children: [_jsxs("div", { className: "px-4 sm:px-6 pt-6 pb-4", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight", children: t('home.title', { defaultValue: 'Home' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('home.subtitle', { defaultValue: 'Your workspace dashboard' }) })] }), _jsxs("div", { className: "px-4 sm:px-6 py-4 space-y-8", children: [starredApps.length > 0 && (_jsx(StarredApps, { items: starredApps })), recentApps.length > 0 && (_jsx(RecentApps, { items: recentApps })), _jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-4", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app) => (_jsx(AppCard, { app: app, 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", 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))) })] })] }) })] }));
55
77
  }
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  import { useNavigate } from 'react-router-dom';
11
11
  import { useObjectTranslation } from '@object-ui/i18n';
12
12
  import { Card, CardContent } from '@object-ui/components';
13
- import { Plus, Settings, Database } from 'lucide-react';
13
+ import { Plus, Settings, Database, ArrowUpRight } from 'lucide-react';
14
14
  import { cn } from '@object-ui/components';
15
15
  export function QuickActions() {
16
16
  const navigate = useNavigate();
@@ -22,7 +22,9 @@ export function QuickActions() {
22
22
  description: t('home.quickActions.createAppDesc', { defaultValue: 'Start with a new application' }),
23
23
  icon: Plus,
24
24
  href: '/create-app',
25
- color: 'text-blue-600 dark:text-blue-400',
25
+ iconBg: 'bg-gradient-to-br from-blue-500/15 to-indigo-500/10 ring-blue-500/20',
26
+ iconText: 'text-blue-600 dark:text-blue-400',
27
+ hoverBorder: 'hover:border-blue-500/40',
26
28
  },
27
29
  {
28
30
  id: 'manage-objects',
@@ -30,7 +32,9 @@ export function QuickActions() {
30
32
  description: t('home.quickActions.manageObjectsDesc', { defaultValue: 'Configure data models' }),
31
33
  icon: Database,
32
34
  href: '/apps/setup/system/metadata/object',
33
- color: 'text-purple-600 dark:text-purple-400',
35
+ iconBg: 'bg-gradient-to-br from-violet-500/15 to-purple-500/10 ring-violet-500/20',
36
+ iconText: 'text-violet-600 dark:text-violet-400',
37
+ hoverBorder: 'hover:border-violet-500/40',
34
38
  },
35
39
  {
36
40
  id: 'system-settings',
@@ -38,16 +42,18 @@ export function QuickActions() {
38
42
  description: t('home.quickActions.systemSettingsDesc', { defaultValue: 'Configure your workspace' }),
39
43
  icon: Settings,
40
44
  href: '/apps/setup',
41
- color: 'text-gray-600 dark:text-gray-400',
45
+ iconBg: 'bg-gradient-to-br from-emerald-500/15 to-teal-500/10 ring-emerald-500/20',
46
+ iconText: 'text-emerald-600 dark:text-emerald-400',
47
+ hoverBorder: 'hover:border-emerald-500/40',
42
48
  },
43
49
  ];
44
- return (_jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-4", 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) => {
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) => {
45
51
  const Icon = action.icon;
46
- return (_jsx(Card, { className: "cursor-pointer hover:shadow-md transition-shadow", 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-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) => {
47
53
  if (e.key === 'Enter' || e.key === ' ') {
48
54
  e.preventDefault();
49
55
  navigate(action.href);
50
56
  }
51
- }, "aria-label": action.label, children: _jsx(CardContent, { className: "p-6", children: _jsxs("div", { className: "flex items-start gap-4", children: [_jsx("div", { className: cn('p-3 rounded-lg bg-muted', action.color), children: _jsx(Icon, { className: "h-6 w-6" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "font-semibold text-base mb-1", children: action.label }), _jsx("p", { className: "text-sm text-muted-foreground", 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-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));
52
58
  }) })] }));
53
59
  }
@@ -8,25 +8,32 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  */
9
9
  import { useNavigate } from 'react-router-dom';
10
10
  import { useObjectTranslation } from '@object-ui/i18n';
11
- import { Card, CardContent } from '@object-ui/components';
12
- import { Clock } from 'lucide-react';
11
+ import { Card, CardContent, cn } from '@object-ui/components';
12
+ import { Clock, ArrowUpRight } from 'lucide-react';
13
13
  import { getIcon } from '../../utils/getIcon';
14
14
  import { capitalizeFirst } from '../../utils';
15
+ const TYPE_TONES = {
16
+ object: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 ring-blue-500/20',
17
+ dashboard: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 ring-violet-500/20',
18
+ page: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20',
19
+ record: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20',
20
+ };
15
21
  export function RecentApps({ items }) {
16
22
  const navigate = useNavigate();
17
23
  const { t } = useObjectTranslation();
18
24
  if (items.length === 0)
19
25
  return null;
20
- return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-center gap-2 mb-4", children: [_jsx(Clock, { className: "h-5 w-5 text-muted-foreground" }), _jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.recentApps.title', { defaultValue: 'Recently Accessed' }) })] }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: items.map((item) => {
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-sky-500/10 ring-1 ring-sky-500/20 text-sky-600 dark:text-sky-400", children: _jsx(Clock, { className: "h-4 w-4" }) }), _jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.recentApps.title', { defaultValue: 'Recently Accessed' }) })] }), _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) => {
21
27
  const Icon = getIcon(item.type);
22
28
  const typeLabel = t(`home.recentApps.itemType.${item.type}`, {
23
29
  defaultValue: capitalizeFirst(item.type),
24
30
  });
25
- return (_jsx(Card, { className: "cursor-pointer hover:shadow-md transition-shadow", onClick: () => navigate(item.href), "data-testid": `recent-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
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) => {
26
33
  if (e.key === 'Enter' || e.key === ' ') {
27
34
  e.preventDefault();
28
35
  navigate(item.href);
29
36
  }
30
- }, children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "p-2 rounded-lg bg-muted shrink-0", 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 })] })] }) }) }, 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-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
31
38
  }) })] }));
32
39
  }
@@ -8,22 +8,29 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  */
9
9
  import { useNavigate } from 'react-router-dom';
10
10
  import { useObjectTranslation } from '@object-ui/i18n';
11
- import { Card, CardContent } from '@object-ui/components';
12
- import { Star } from 'lucide-react';
11
+ import { Card, CardContent, cn } from '@object-ui/components';
12
+ import { Star, ArrowUpRight } from 'lucide-react';
13
13
  import { getIcon } from '../../utils/getIcon';
14
14
  import { capitalizeFirst } from '../../utils';
15
+ const TYPE_TONES = {
16
+ object: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 ring-blue-500/20',
17
+ dashboard: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 ring-violet-500/20',
18
+ page: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20',
19
+ record: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20',
20
+ };
15
21
  export function StarredApps({ items }) {
16
22
  const navigate = useNavigate();
17
23
  const { t } = useObjectTranslation();
18
24
  if (items.length === 0)
19
25
  return null;
20
- return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-center gap-2 mb-4", children: [_jsx(Star, { className: "h-5 w-5 text-yellow-500 fill-yellow-500" }), _jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.starredApps.title', { defaultValue: 'Starred' }) })] }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: items.map((item) => {
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) => {
21
27
  const Icon = getIcon(item.type);
22
- return (_jsx(Card, { className: "cursor-pointer hover:shadow-md transition-shadow", onClick: () => navigate(item.href), "data-testid": `starred-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
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) => {
23
30
  if (e.key === 'Enter' || e.key === ' ') {
24
31
  e.preventDefault();
25
32
  navigate(item.href);
26
33
  }
27
- }, children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "p-2 rounded-lg bg-muted shrink-0", 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) })] })] }) }) }, 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-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
28
35
  }) })] }));
29
36
  }
@@ -4,18 +4,15 @@
4
4
  * React Context + Provider for shared favorites state across all consumers
5
5
  * (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps).
6
6
  *
7
- * Replaces the standalone `useFavorites` hook pattern — all callers now share
8
- * a single state instance so that toggling a star in AppCard immediately
9
- * reflects in HomePage's Starred section and sidebar.
10
- *
11
- * Persistence: localStorage (key: "objectui-favorites", max 20 items).
12
- *
13
- * TODO: Migrate persistence to server-side storage via the adapter/API layer
14
- * (e.g. PUT /api/user/preferences) so favorites sync across devices and browsers.
15
- * The provider should accept an optional `persistenceAdapter` prop that implements
16
- * `load(): Promise<FavoriteItem[]>` and `save(items: FavoriteItem[]): Promise<void>`.
17
- * When the adapter is provided, localStorage should be used only as a fallback
18
- * during the initial load while the server response is in-flight.
7
+ * Persistence is **localStorage-first** with optional backend hydration:
8
+ * - On mount we render synchronously from localStorage (no flash of empty UI).
9
+ * - If a `UserDataAdapter<FavoriteItem>` is attached via `UserStateAdaptersProvider`
10
+ * (see `./UserStateAdapters`), the provider hydrates from the backend and
11
+ * writes-through every mutation through a debounced `adapter.save()`.
12
+ * - localStorage stays in sync so offline / pre-auth sessions still work and
13
+ * gives an instant first paint after sign-in.
14
+ * - The local key is scoped per `user.id` so different accounts on the same
15
+ * browser don't cross-contaminate.
19
16
  *
20
17
  * @module
21
18
  */