@xenide-io/the-old-ui-theme 0.3.2 → 0.4.3

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 (62) hide show
  1. package/dist/chunk-54ZR4VL5.mjs +4641 -0
  2. package/dist/chunk-G53YLHVE.mjs +1023 -0
  3. package/dist/{index-BgG8Homu.d.mts → index-B5NOyoKS.d.mts} +100 -100
  4. package/dist/{index-BgG8Homu.d.ts → index-B5NOyoKS.d.ts} +100 -100
  5. package/dist/index.d.mts +9 -145
  6. package/dist/index.d.ts +9 -145
  7. package/dist/index.js +653 -1111
  8. package/dist/index.mjs +86 -342
  9. package/dist/suite.d.mts +760 -0
  10. package/dist/suite.d.ts +760 -0
  11. package/dist/suite.js +2800 -0
  12. package/dist/suite.mjs +2441 -0
  13. package/dist/ui.d.mts +1 -2
  14. package/dist/ui.d.ts +1 -2
  15. package/dist/ui.js +14 -10
  16. package/dist/ui.mjs +13 -11
  17. package/package.json +43 -24
  18. package/src/components/icons/icons.tsx +0 -2
  19. package/src/components/icons/index.ts +0 -3
  20. package/src/components/sections/IconShowcase.tsx +1 -110
  21. package/src/components/sections/NewComponentsShowcase.tsx +4 -4
  22. package/src/components/sections/SidebarShowcase.tsx +3 -3
  23. package/src/components/sections/SuiteShowcase.tsx +669 -0
  24. package/src/components/ui/ComponentDocs.tsx +2 -2
  25. package/src/components/ui/ContextMenu.tsx +1 -1
  26. package/src/components/ui/Menubar.tsx +1 -1
  27. package/src/components/ui/Sidebar.tsx +10 -8
  28. package/src/styles/suite-skin.css +255 -0
  29. package/src/suite/components/ai-panel.tsx +202 -0
  30. package/src/suite/components/app-switcher.tsx +196 -0
  31. package/src/suite/components/command-palette-host.tsx +62 -0
  32. package/src/suite/components/deferred-chrome.tsx +12 -0
  33. package/src/suite/components/suite-app-layout.tsx +69 -0
  34. package/src/suite/components/suite-bottom-nav.tsx +113 -0
  35. package/src/suite/components/suite-layout.tsx +285 -0
  36. package/src/suite/components/suite-mobile-drawer.tsx +150 -0
  37. package/src/suite/components/suite-mobile-header.tsx +71 -0
  38. package/src/suite/components/suite-notification-bell.tsx +227 -0
  39. package/src/suite/components/suite-settings-mobile-nav.tsx +83 -0
  40. package/src/suite/components/suite-sidebar.tsx +198 -0
  41. package/src/suite/components/suite-skeleton.tsx +191 -0
  42. package/src/suite/components/suite-user-menu.tsx +109 -0
  43. package/src/suite/components/theme-provider.tsx +174 -0
  44. package/src/suite/components/today-calibrating.tsx +95 -0
  45. package/src/suite/components/today-page-frame.tsx +28 -0
  46. package/src/suite/components/today-ui.tsx +370 -0
  47. package/src/suite/icons/app-accents.ts +75 -0
  48. package/src/suite/icons/glyph-parts.tsx +67 -0
  49. package/src/suite/icons/glyphs.ts +203 -0
  50. package/src/suite/icons/index.ts +12 -0
  51. package/src/suite/icons/suite-app-icon.tsx +68 -0
  52. package/src/suite/icons/suite-icon.tsx +93 -0
  53. package/src/suite/index.ts +108 -0
  54. package/src/suite/lib/apps.ts +75 -0
  55. package/src/suite/lib/cn.ts +6 -0
  56. package/src/suite/lib/injected.ts +54 -0
  57. package/src/suite/lib/motion.tsx +48 -0
  58. package/src/suite/lib/use-idle-mount.ts +25 -0
  59. package/dist/chunk-5HSOBJ4F.mjs +0 -5968
  60. package/src/components/icons/lemon-brand-icons.tsx +0 -16
  61. package/src/components/icons/lemon-category-icons.tsx +0 -72
  62. package/src/components/icons/lemon-icons.tsx +0 -57
@@ -0,0 +1,227 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { Bell } from 'iconoir-react';
6
+
7
+ import type { SuiteDropdownMenuComponent } from '../lib/injected';
8
+
9
+ const POLL_MS = 60_000;
10
+
11
+ /** Cross-app (suite) notification from `/api/notifications/` — shared across ShellStack apps. */
12
+ export interface SuiteNotification {
13
+ id: string;
14
+ title: string;
15
+ body: string;
16
+ href: string;
17
+ kind: string;
18
+ source_app: string;
19
+ read_at: string | null;
20
+ created_at: string;
21
+ workspace: string | null;
22
+ organisation: string | null;
23
+ }
24
+
25
+ export interface SuiteNotificationsResponse {
26
+ notifications: SuiteNotification[];
27
+ unread_count: number;
28
+ }
29
+
30
+ function timeAgo(iso: string): string {
31
+ const then = new Date(iso).getTime();
32
+ if (Number.isNaN(then)) return '';
33
+ const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
34
+ if (secs < 60) return 'just now';
35
+ const mins = Math.round(secs / 60);
36
+ if (mins < 60) return `${mins}m ago`;
37
+ const hrs = Math.round(mins / 60);
38
+ if (hrs < 24) return `${hrs}h ago`;
39
+ return `${Math.round(hrs / 24)}d ago`;
40
+ }
41
+
42
+ export function SuiteNotificationBell({
43
+ fetchNotifications,
44
+ markRead,
45
+ markAllRead,
46
+ dropdownMenu: DropdownMenu,
47
+ cacheKey = 'suite-notifications-cache',
48
+ }: {
49
+ fetchNotifications: () => Promise<SuiteNotificationsResponse>;
50
+ markRead: (id: string) => Promise<unknown>;
51
+ markAllRead: () => Promise<unknown>;
52
+ dropdownMenu: SuiteDropdownMenuComponent;
53
+ /** sessionStorage key for stale-while-revalidate; `null` disables caching. */
54
+ cacheKey?: string | null;
55
+ }) {
56
+ const router = useRouter();
57
+ const [open, setOpen] = useState(false);
58
+ const [items, setItems] = useState<SuiteNotification[]>([]);
59
+ const [unread, setUnread] = useState(0);
60
+
61
+ const load = useCallback(async () => {
62
+ try {
63
+ const data = await fetchNotifications();
64
+ setItems(data.notifications);
65
+ setUnread(data.unread_count);
66
+ // Stale-while-revalidate: keep last inbox for instant paint next visit.
67
+ if (cacheKey) {
68
+ try {
69
+ sessionStorage.setItem(
70
+ cacheKey,
71
+ JSON.stringify({
72
+ notifications: data.notifications,
73
+ unread_count: data.unread_count,
74
+ }),
75
+ );
76
+ } catch {
77
+ // ignore quota / private mode
78
+ }
79
+ }
80
+ } catch {
81
+ // Non-critical chrome — never surface bell errors to the user.
82
+ }
83
+ }, [fetchNotifications, cacheKey]);
84
+
85
+ useEffect(() => {
86
+ if (cacheKey) {
87
+ queueMicrotask(() => {
88
+ try {
89
+ const raw = sessionStorage.getItem(cacheKey);
90
+ if (raw) {
91
+ const cached = JSON.parse(raw) as {
92
+ notifications?: SuiteNotification[];
93
+ unread_count?: number;
94
+ };
95
+ if (Array.isArray(cached.notifications)) {
96
+ setItems(cached.notifications);
97
+ setUnread(cached.unread_count ?? 0);
98
+ }
99
+ }
100
+ } catch {
101
+ // ignore
102
+ }
103
+ });
104
+ }
105
+ const kick = window.setTimeout(() => void load(), 0);
106
+ const id = window.setInterval(() => void load(), POLL_MS);
107
+ return () => {
108
+ window.clearTimeout(kick);
109
+ window.clearInterval(id);
110
+ };
111
+ }, [load, cacheKey]);
112
+
113
+ const onOpenChange = useCallback(
114
+ (next: boolean) => {
115
+ setOpen(next);
116
+ if (next) void load();
117
+ },
118
+ [load],
119
+ );
120
+
121
+ const openItem = useCallback(
122
+ async (n: SuiteNotification) => {
123
+ setOpen(false);
124
+ if (!n.read_at) {
125
+ setItems((prev) =>
126
+ prev.map((i) =>
127
+ i.id === n.id ? { ...i, read_at: new Date().toISOString() } : i,
128
+ ),
129
+ );
130
+ setUnread((u) => Math.max(0, u - 1));
131
+ try {
132
+ await markRead(n.id);
133
+ } catch {
134
+ // ignore
135
+ }
136
+ }
137
+ if (!n.href) return;
138
+ if (/^https?:\/\//i.test(n.href)) window.location.assign(n.href);
139
+ else router.push(n.href);
140
+ },
141
+ [router, markRead],
142
+ );
143
+
144
+ const markAll = useCallback(async () => {
145
+ setItems((prev) =>
146
+ prev.map((i) => ({
147
+ ...i,
148
+ read_at: i.read_at ?? new Date().toISOString(),
149
+ })),
150
+ );
151
+ setUnread(0);
152
+ try {
153
+ await markAllRead();
154
+ } catch {
155
+ // ignore
156
+ }
157
+ }, [markAllRead]);
158
+
159
+ return (
160
+ <DropdownMenu
161
+ aria-label="Notifications"
162
+ align="end"
163
+ panelClassName="w-80 overflow-hidden p-0"
164
+ open={open}
165
+ onOpenChange={onOpenChange}
166
+ trigger={
167
+ <span className="relative inline-flex h-9 w-9 items-center justify-center rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
168
+ <Bell className="h-5 w-5" strokeWidth={1.75} aria-hidden />
169
+ {unread > 0 ? (
170
+ <span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-ph-brand px-1 text-[10px] font-semibold leading-none text-white ring-2 ring-ph-surface">
171
+ {unread > 9 ? '9+' : unread}
172
+ </span>
173
+ ) : null}
174
+ </span>
175
+ }
176
+ >
177
+ <div className="flex items-center justify-between border-b border-ph-border px-3 py-2">
178
+ <span className="text-sm font-semibold text-ph-ink">Notifications</span>
179
+ {unread > 0 ? (
180
+ <button
181
+ type="button"
182
+ onClick={markAll}
183
+ className="text-xs font-medium text-ph-brand hover:underline"
184
+ >
185
+ Mark all read
186
+ </button>
187
+ ) : null}
188
+ </div>
189
+ <div className="max-h-80 overflow-y-auto py-1">
190
+ {items.length === 0 ? (
191
+ <p className="px-3 py-6 text-center text-sm text-ph-mutedtext">
192
+ You&apos;re all caught up.
193
+ </p>
194
+ ) : (
195
+ items.map((n) => (
196
+ <button
197
+ key={n.id}
198
+ type="button"
199
+ onClick={() => void openItem(n)}
200
+ className="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition hover:bg-ph-muted"
201
+ >
202
+ <span className="flex items-center gap-2">
203
+ {!n.read_at ? (
204
+ <span
205
+ className="h-1.5 w-1.5 shrink-0 rounded-full bg-ph-brand"
206
+ aria-hidden
207
+ />
208
+ ) : null}
209
+ <span className="truncate text-sm font-medium text-ph-ink">
210
+ {n.title}
211
+ </span>
212
+ </span>
213
+ {n.body ? (
214
+ <span className="line-clamp-2 text-xs text-ph-mutedtext">
215
+ {n.body}
216
+ </span>
217
+ ) : null}
218
+ <span className="text-[11px] uppercase tracking-wide text-ph-subtle">
219
+ {n.source_app} · {timeAgo(n.created_at)}
220
+ </span>
221
+ </button>
222
+ ))
223
+ )}
224
+ </div>
225
+ </DropdownMenu>
226
+ );
227
+ }
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import { isValidElement, type ComponentType, type ReactNode } from 'react';
4
+ import { cn } from '../lib/cn';
5
+
6
+ export interface SuiteSettingsNavItem {
7
+ label: string;
8
+ href: string;
9
+ icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }> | ReactNode;
10
+ testId?: string;
11
+ }
12
+
13
+ interface SuiteSettingsMobileNavProps {
14
+ items: SuiteSettingsNavItem[];
15
+ activeHref: string;
16
+ onSelect: (href: string) => void;
17
+ dataTest?: string;
18
+ }
19
+
20
+ /**
21
+ * Horizontal scrollable settings nav for mobile.
22
+ * Replaces a native <select> with icon+label pills that are easier to scan,
23
+ * meet 44px touch targets, and preserve visual hierarchy (Law of UX: Hick's Law,
24
+ * Recognition over Recall).
25
+ */
26
+ export function SuiteSettingsMobileNav({
27
+ items,
28
+ activeHref,
29
+ onSelect,
30
+ dataTest,
31
+ }: SuiteSettingsMobileNavProps) {
32
+ return (
33
+ <nav
34
+ data-test={dataTest}
35
+ aria-label="Settings sections"
36
+ className="-mx-4 px-4 lg:hidden"
37
+ >
38
+ <div className="flex snap-x gap-2 overflow-x-auto pb-3 pt-1 scrollbar-hide [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
39
+ {items.map((item) => {
40
+ const Icon = item.icon as ComponentType<{
41
+ className?: string;
42
+ 'aria-hidden'?: boolean;
43
+ }>;
44
+ const active =
45
+ activeHref === item.href || activeHref.startsWith(`${item.href}/`);
46
+ const iconNode = isValidElement(item.icon) ? (
47
+ item.icon
48
+ ) : (
49
+ <Icon
50
+ className={cn(
51
+ 'h-4 w-4 shrink-0 transition',
52
+ active ? 'text-ph-surface' : 'text-ph-mutedtext',
53
+ )}
54
+ aria-hidden
55
+ />
56
+ );
57
+ return (
58
+ <button
59
+ key={item.href}
60
+ type="button"
61
+ data-test={item.testId}
62
+ onClick={() => onSelect(item.href)}
63
+ aria-current={active ? 'page' : undefined}
64
+ className={cn(
65
+ 'group relative flex shrink-0 snap-start items-center gap-2 rounded-full px-3.5 py-2.5 text-sm font-medium transition',
66
+ 'min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand',
67
+ active
68
+ ? 'bg-ph-ink text-ph-surface shadow-sm'
69
+ : 'bg-ph-surface text-ph-subtle ring-1 ring-ph-border hover:bg-ph-muted hover:text-ph-ink',
70
+ )}
71
+ >
72
+ {iconNode}
73
+ <span className="whitespace-nowrap">{item.label}</span>
74
+ {active ? (
75
+ <span className="absolute inset-x-0 -bottom-3 h-0.5 rounded-full bg-ph-ink lg:hidden" />
76
+ ) : null}
77
+ </button>
78
+ );
79
+ })}
80
+ </div>
81
+ </nav>
82
+ );
83
+ }
@@ -0,0 +1,198 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { type ReactNode } from 'react';
5
+
6
+ import { cn } from '../lib/cn';
7
+ import { Tooltip } from '../../components/ui/Tooltip';
8
+ import type { SuiteNavIcon } from './suite-bottom-nav';
9
+
10
+ type CollapsedNode = ReactNode | ((collapsed: boolean) => ReactNode);
11
+
12
+ export interface SuiteSidebarNavItem {
13
+ href: string;
14
+ label: string;
15
+ icon: SuiteNavIcon;
16
+ active?: boolean;
17
+ onClick?: () => void;
18
+ badge?: ReactNode;
19
+ /** Optional colour class for the nav icon. */
20
+ iconClassName?: string;
21
+ }
22
+
23
+ export interface SuiteSidebarProps {
24
+ /** Optional custom app switcher node, or a function of collapsed state. */
25
+ appSwitcher?: CollapsedNode;
26
+ /** Workspace/org/project switcher rendered below the app switcher. */
27
+ contextSwitcher: CollapsedNode;
28
+ navItems: SuiteSidebarNavItem[];
29
+ /** Optional secondary nav / tree rendered below primary nav. */
30
+ secondaryNav?: ReactNode;
31
+ userMenu: CollapsedNode;
32
+ notificationBell?: CollapsedNode;
33
+ /** Optional extra footer content placed next to the user menu. */
34
+ footerExtras?: CollapsedNode;
35
+ collapsed?: boolean;
36
+ className?: string;
37
+ /** Use the richer surface background instead of canvas. */
38
+ surface?: boolean;
39
+ }
40
+
41
+ function renderNode(node: CollapsedNode | undefined, collapsed: boolean): ReactNode {
42
+ if (typeof node === 'function') return node(collapsed);
43
+ return node;
44
+ }
45
+
46
+ /**
47
+ * Standardised ShellStack sidebar. Matches the original TurtleTime layout
48
+ * so the chrome feels familiar across all apps:
49
+ * - app switcher + notification bell in a compact header
50
+ * - workspace switcher at the top of the nav body
51
+ * - primary nav items styled like the original NavLink
52
+ * - avatar + optional logout in the footer
53
+ */
54
+ export function SuiteSidebar({
55
+ appSwitcher,
56
+ contextSwitcher,
57
+ navItems,
58
+ secondaryNav,
59
+ userMenu,
60
+ notificationBell,
61
+ footerExtras,
62
+ collapsed = false,
63
+ className,
64
+ surface = false,
65
+ }: SuiteSidebarProps) {
66
+ return (
67
+ <div
68
+ className={cn(
69
+ 'flex h-full w-full min-w-0 flex-col text-ph-ink',
70
+ surface ? 'bg-ph-surface' : 'bg-ph-canvas',
71
+ className,
72
+ )}
73
+ >
74
+ {/* Header */}
75
+ <div
76
+ className={cn(
77
+ 'flex shrink-0 border-b border-ph-border',
78
+ surface ? 'bg-ph-surface' : 'bg-ph-canvas',
79
+ collapsed
80
+ ? 'flex-col items-center gap-1 px-1 py-2'
81
+ : 'h-14 items-center justify-between gap-2 px-3',
82
+ )}
83
+ >
84
+ <div className={cn('min-w-0', collapsed ? 'shrink-0' : 'flex-1')}>
85
+ {renderNode(appSwitcher, collapsed)}
86
+ </div>
87
+ {collapsed ? null : (
88
+ <div className="shrink-0">{renderNode(notificationBell, collapsed)}</div>
89
+ )}
90
+ </div>
91
+
92
+ {/* Nav body */}
93
+ <div
94
+ className={cn(
95
+ 'flex min-h-0 flex-1 flex-col overflow-y-auto',
96
+ surface ? 'bg-ph-surface' : 'bg-ph-canvas',
97
+ collapsed ? 'px-1.5 py-2' : 'p-2',
98
+ )}
99
+ >
100
+ <nav
101
+ aria-label="Pages"
102
+ className="shrink-0 space-y-0.5"
103
+ >
104
+ <div className={cn('mb-3', collapsed && 'flex justify-center')}>
105
+ {renderNode(contextSwitcher, collapsed)}
106
+ </div>
107
+
108
+ <div className="space-y-0.5">
109
+ {navItems.map((item) => {
110
+ const Icon = item.icon;
111
+ const active = Boolean(item.active);
112
+ const link = (
113
+ <Link
114
+ key={item.label}
115
+ href={item.href}
116
+ data-test="nav-link"
117
+ aria-current={active ? 'page' : undefined}
118
+ onClick={item.onClick}
119
+ className={cn(
120
+ 'group relative flex items-center gap-2.5 rounded-[var(--ph-radius-app)] text-sm font-medium transition-colors',
121
+ active
122
+ ? 'bg-ph-muted'
123
+ : 'text-ph-subtle hover:bg-ph-muted hover:text-ph-ink',
124
+ collapsed
125
+ ? 'mx-auto size-10 shrink-0 justify-center gap-0 px-0 py-0'
126
+ : 'w-full px-2.5 py-2',
127
+ )}
128
+ >
129
+ <span
130
+ className={cn(
131
+ 'relative flex h-4 w-4 shrink-0 items-center justify-center',
132
+ item.iconClassName,
133
+ )}
134
+ aria-hidden
135
+ >
136
+ <Icon
137
+ className="h-full w-full"
138
+ strokeWidth={active ? 2 : 1.75}
139
+ />
140
+ </span>
141
+ <span
142
+ className={cn(
143
+ 'relative truncate',
144
+ active ? 'text-ph-ink' : 'text-ph-subtle group-hover:text-ph-ink',
145
+ collapsed && 'sr-only',
146
+ )}
147
+ >
148
+ {item.label}
149
+ </span>
150
+ {!collapsed && item.badge ? (
151
+ <span className="relative ml-auto shrink-0">{item.badge}</span>
152
+ ) : null}
153
+ </Link>
154
+ );
155
+ return collapsed ? (
156
+ <Tooltip key={item.label} content={item.label} side="right">
157
+ {link}
158
+ </Tooltip>
159
+ ) : (
160
+ link
161
+ );
162
+ })}
163
+ </div>
164
+ </nav>
165
+
166
+ {secondaryNav ? (
167
+ <div
168
+ className="min-h-0 flex-1"
169
+ data-test="suite-sidebar-secondary-nav"
170
+ >
171
+ {secondaryNav}
172
+ </div>
173
+ ) : null}
174
+ </div>
175
+
176
+ {/* Footer */}
177
+ <div
178
+ className={cn(
179
+ 'shrink-0 border-t border-ph-border',
180
+ surface ? 'bg-ph-surface' : 'bg-ph-canvas',
181
+ collapsed ? 'p-1.5' : 'p-3',
182
+ )}
183
+ >
184
+ <div
185
+ className={cn(
186
+ 'flex items-center gap-2',
187
+ collapsed && 'justify-center',
188
+ )}
189
+ >
190
+ {renderNode(userMenu, collapsed)}
191
+ {!collapsed && footerExtras ? (
192
+ <div className="ml-auto shrink-0">{renderNode(footerExtras, collapsed)}</div>
193
+ ) : null}
194
+ </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { cn } from '../lib/cn';
5
+
6
+ interface SuiteSkeletonProps {
7
+ className?: string;
8
+ lines?: number;
9
+ circle?: boolean;
10
+ width?: string;
11
+ }
12
+
13
+ /**
14
+ * Shared skeleton using the-old-ui-theme `ph-skeleton` shimmer class.
15
+ * The shimmer animation is defined in the theme, so this component is
16
+ * framework-agnostic and works in any app that imports the theme.
17
+ */
18
+ export function SuiteSkeleton({
19
+ className,
20
+ lines = 1,
21
+ circle,
22
+ width,
23
+ }: SuiteSkeletonProps) {
24
+ return (
25
+ <div
26
+ className={cn('space-y-2', circle ? 'flex flex-col items-center' : '')}
27
+ aria-hidden
28
+ >
29
+ {Array.from({ length: lines }).map((_, index) => (
30
+ <div
31
+ key={index}
32
+ className="animate-[skeleton-stagger_0.6s_ease-out_both]"
33
+ style={{ animationDelay: `${index * 80}ms` }}
34
+ >
35
+ <div
36
+ className={cn(
37
+ 'ph-skeleton',
38
+ circle ? 'h-10 w-10 rounded-full' : 'h-3',
39
+ className,
40
+ )}
41
+ style={
42
+ !circle
43
+ ? {
44
+ width:
45
+ width ?? `${70 + (index % 3) * 15}%`,
46
+ }
47
+ : undefined
48
+ }
49
+ />
50
+ </div>
51
+ ))}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ export function SuiteSkeletonCard({
57
+ className,
58
+ rows = 3,
59
+ header = true,
60
+ }: {
61
+ className?: string;
62
+ rows?: number;
63
+ header?: boolean;
64
+ }) {
65
+ return (
66
+ <div
67
+ className={cn(
68
+ 'ph-skeleton-card animate-[skeleton-stagger_0.5s_ease-out_both] rounded-2xl border border-ph-border/70 bg-ph-surface p-6 shadow-sm',
69
+ className,
70
+ )}
71
+ aria-hidden
72
+ >
73
+ {header ? (
74
+ <div
75
+ className="mb-5 flex items-center gap-3 animate-[skeleton-stagger_0.5s_ease-out_both]"
76
+ style={{ animationDelay: '100ms' }}
77
+ >
78
+ <div className="ph-skeleton h-10 w-10 rounded-xl" />
79
+ <div className="flex-1 space-y-2">
80
+ <div className="ph-skeleton h-4 w-1/3" />
81
+ <div className="ph-skeleton h-3 w-1/2" />
82
+ </div>
83
+ </div>
84
+ ) : null}
85
+ <div className="space-y-3">
86
+ {Array.from({ length: rows }).map((_, index) => (
87
+ <div
88
+ key={index}
89
+ className="animate-[skeleton-stagger_0.5s_ease-out_both]"
90
+ style={{ animationDelay: `${200 + index * 80}ms` }}
91
+ >
92
+ <div
93
+ className="ph-skeleton h-3"
94
+ style={{ width: `${70 + (index % 3) * 15}%` }}
95
+ />
96
+ </div>
97
+ ))}
98
+ </div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ export function SuiteSkeletonList({
104
+ className,
105
+ items = 4,
106
+ }: {
107
+ className?: string;
108
+ items?: number;
109
+ }) {
110
+ return (
111
+ <div className={cn('space-y-3', className)} aria-hidden>
112
+ {Array.from({ length: items }).map((_, index) => (
113
+ <div
114
+ key={index}
115
+ className="animate-[skeleton-stagger_0.6s_ease-out_both]"
116
+ style={{ animationDelay: `${index * 100}ms` }}
117
+ >
118
+ <div className="flex items-center gap-3 rounded-xl border border-ph-border/60 bg-ph-surface p-4">
119
+ <div className="ph-skeleton h-10 w-10 rounded-lg" />
120
+ <div className="min-w-0 flex-1 space-y-2">
121
+ <div className="ph-skeleton h-3.5 w-3/4" />
122
+ <div className="ph-skeleton h-3 w-1/2" />
123
+ </div>
124
+ </div>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ interface SuiteEmptyStateProps {
132
+ icon?: ReactNode;
133
+ title: string;
134
+ description?: ReactNode;
135
+ action?: ReactNode;
136
+ className?: string;
137
+ size?: 'sm' | 'md';
138
+ }
139
+
140
+ /**
141
+ * Branded empty state with a soft gradient orb and clear action affordance.
142
+ * Uses the app icon when available so empty states feel native to each app.
143
+ */
144
+ export function SuiteEmptyState({
145
+ icon,
146
+ title,
147
+ description,
148
+ action,
149
+ className,
150
+ size = 'md',
151
+ }: SuiteEmptyStateProps) {
152
+ return (
153
+ <div
154
+ className={cn(
155
+ 'relative overflow-hidden rounded-2xl border border-ph-border/80 bg-ph-surface text-center',
156
+ size === 'sm' ? 'px-5 py-8' : 'px-6 py-12 sm:px-10 sm:py-16',
157
+ className,
158
+ )}
159
+ >
160
+ <div className="pointer-events-none absolute inset-0 overflow-hidden opacity-60" aria-hidden>
161
+ <div className="absolute -left-10 -top-10 h-40 w-40 rounded-full bg-ph-brand/10 blur-3xl" />
162
+ <div className="absolute -bottom-10 -right-10 h-40 w-40 rounded-full bg-ph-accent/10 blur-3xl" />
163
+ </div>
164
+ <div className="relative">
165
+ {icon ? (
166
+ <div className="mx-auto mb-4 inline-flex items-center justify-center rounded-2xl bg-ph-muted p-3 shadow-sm ring-1 ring-black/[0.06]">
167
+ {icon}
168
+ </div>
169
+ ) : null}
170
+ <h3
171
+ className={cn(
172
+ 'font-semibold text-ph-ink',
173
+ size === 'sm' ? 'text-base' : 'text-lg',
174
+ )}
175
+ >
176
+ {title}
177
+ </h3>
178
+ {description ? (
179
+ <p className="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-ph-subtle">
180
+ {description}
181
+ </p>
182
+ ) : null}
183
+ {action ? (
184
+ <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
185
+ {action}
186
+ </div>
187
+ ) : null}
188
+ </div>
189
+ </div>
190
+ );
191
+ }