@open-mercato/ui 0.6.2-develop.3372.1.de9a169973 → 0.6.2-develop.3380.1.bb14509041

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/ai/AiIcon.js +1 -1
  4. package/dist/ai/AiIcon.js.map +2 -2
  5. package/dist/backend/AppShell.js +223 -153
  6. package/dist/backend/AppShell.js.map +2 -2
  7. package/dist/backend/ProfileDropdown.js +101 -79
  8. package/dist/backend/ProfileDropdown.js.map +2 -2
  9. package/dist/backend/notifications/NotificationCountBadge.js +1 -1
  10. package/dist/backend/notifications/NotificationCountBadge.js.map +2 -2
  11. package/dist/backend/notifications/NotificationItem.js +67 -50
  12. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  13. package/dist/backend/notifications/NotificationPanel.js +108 -85
  14. package/dist/backend/notifications/NotificationPanel.js.map +2 -2
  15. package/dist/primitives/breadcrumb.js +161 -0
  16. package/dist/primitives/breadcrumb.js.map +7 -0
  17. package/dist/primitives/sheet.js +114 -0
  18. package/dist/primitives/sheet.js.map +7 -0
  19. package/package.json +3 -3
  20. package/src/ai/AiIcon.tsx +1 -1
  21. package/src/backend/AppShell.tsx +151 -49
  22. package/src/backend/ProfileDropdown.tsx +115 -88
  23. package/src/backend/__tests__/AppShell.test.tsx +8 -0
  24. package/src/backend/notifications/NotificationCountBadge.tsx +1 -1
  25. package/src/backend/notifications/NotificationItem.tsx +97 -104
  26. package/src/backend/notifications/NotificationPanel.tsx +126 -102
  27. package/src/primitives/__tests__/breadcrumb.test.tsx +298 -0
  28. package/src/primitives/__tests__/sheet.test.tsx +152 -0
  29. package/src/primitives/breadcrumb.tsx +185 -0
  30. package/src/primitives/sheet.tsx +165 -0
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
  import * as React from 'react'
3
3
  import Link from 'next/link'
4
- import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check } from 'lucide-react'
4
+ import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'
5
5
  import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
7
7
  import { useTheme } from '@open-mercato/ui/theme'
8
- import { Button } from '../primitives/button'
8
+ import { cn } from '@open-mercato/shared/lib/utils'
9
9
  import { IconButton } from '../primitives/icon-button'
10
+ import { Switch } from '../primitives/switch'
10
11
  import { useInjectedMenuItems } from './injection/useInjectedMenuItems'
11
12
  import { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'
12
13
  import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
@@ -99,8 +100,14 @@ export function ProfileDropdown({
99
100
  } catch {}
100
101
  }
101
102
 
102
- const menuItemClass =
103
- 'w-full text-left text-sm cursor-pointer px-3 py-2 rounded hover:bg-accent inline-flex items-center gap-2.5 outline-none focus-visible:ring-1 focus-visible:ring-ring'
103
+ // Unified row class — every menu item uses this for perfectly aligned layout.
104
+ // h-9 keeps all rows the same height regardless of whether they have a trailing element.
105
+ const menuItemClass = cn(
106
+ 'group flex h-9 w-full items-center gap-3 rounded-md px-2.5 text-sm text-foreground',
107
+ 'transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',
108
+ 'cursor-pointer',
109
+ )
110
+ const menuIconClass = 'size-4 shrink-0 text-muted-foreground group-hover:text-foreground'
104
111
 
105
112
  const resolveMenuLabel = React.useCallback(
106
113
  (item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {
@@ -139,6 +146,12 @@ export function ProfileDropdown({
139
146
  (item: MergedMenuItem) => {
140
147
  const label = resolveMenuLabel(item)
141
148
  const icon = resolveInjectedIcon(item.icon)
149
+ const inner = (
150
+ <>
151
+ <span className={menuIconClass}>{icon}</span>
152
+ <span className="flex-1 truncate">{label}</span>
153
+ </>
154
+ )
142
155
  if (item.href) {
143
156
  return (
144
157
  <Link
@@ -149,18 +162,15 @@ export function ProfileDropdown({
149
162
  data-menu-item-id={item.id}
150
163
  onClick={() => setOpen(false)}
151
164
  >
152
- {icon}
153
- <span>{label}</span>
165
+ {inner}
154
166
  </Link>
155
167
  )
156
168
  }
157
169
  return (
158
- <Button
170
+ <button
159
171
  key={item.id}
160
172
  type="button"
161
- variant="ghost"
162
- size="sm"
163
- className="w-full justify-start"
173
+ className={menuItemClass}
164
174
  role="menuitem"
165
175
  data-menu-item-id={item.id}
166
176
  onClick={() => {
@@ -168,12 +178,11 @@ export function ProfileDropdown({
168
178
  setOpen(false)
169
179
  }}
170
180
  >
171
- {icon}
172
- <span>{label}</span>
173
- </Button>
181
+ {inner}
182
+ </button>
174
183
  )
175
184
  },
176
- [menuItemClass, resolveMenuLabel],
185
+ [menuItemClass, menuIconClass, resolveMenuLabel],
177
186
  )
178
187
 
179
188
  const renderBuiltInItem = React.useCallback(
@@ -187,8 +196,8 @@ export function ProfileDropdown({
187
196
  role="menuitem"
188
197
  onClick={() => setOpen(false)}
189
198
  >
190
- <Key className="size-4" />
191
- <span>{t('ui.profileMenu.changePassword', 'Change Password')}</span>
199
+ <Key className={menuIconClass} />
200
+ <span className="flex-1 truncate">{t('ui.profileMenu.changePassword', 'Change Password')}</span>
192
201
  </Link>
193
202
  )
194
203
  }
@@ -202,68 +211,78 @@ export function ProfileDropdown({
202
211
  role="menuitem"
203
212
  onClick={() => setOpen(false)}
204
213
  >
205
- <Bell className="size-4" />
206
- <span>{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>
214
+ <Bell className={menuIconClass} />
215
+ <span className="flex-1 truncate">{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>
207
216
  </Link>
208
217
  )
209
218
  }
210
219
 
211
220
  if (id === 'theme-toggle') {
212
- return mounted ? (
213
- <Button
221
+ if (!mounted) return null
222
+ return (
223
+ <div
214
224
  key={id}
215
- type="button"
216
- variant="ghost"
217
- size="sm"
218
- className="w-full justify-between"
225
+ className={cn(menuItemClass, 'justify-between')}
219
226
  role="menuitem"
220
227
  onClick={handleThemeToggle}
228
+ onKeyDown={(e) => {
229
+ if (e.key === 'Enter' || e.key === ' ') {
230
+ e.preventDefault()
231
+ handleThemeToggle()
232
+ }
233
+ }}
234
+ tabIndex={0}
221
235
  >
222
- <span className="inline-flex items-center gap-2.5">
223
- {isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
224
- <span>{t('ui.profileMenu.theme', 'Dark Mode')}</span>
236
+ <span className="flex flex-1 items-center gap-3">
237
+ {isDark ? <Moon className={menuIconClass} /> : <Sun className={menuIconClass} />}
238
+ <span className="flex-1 truncate">{t('ui.profileMenu.theme', 'Theme')}</span>
225
239
  </span>
226
- <div className={`w-8 h-4 rounded-full transition-colors ${isDark ? 'bg-primary' : 'bg-muted'} relative`}>
227
- <div className={`absolute top-0.5 w-3 h-3 rounded-full bg-background shadow transition-transform ${isDark ? 'translate-x-4' : 'translate-x-0.5'}`} />
228
- </div>
229
- </Button>
230
- ) : null
240
+ <Switch
241
+ checked={isDark}
242
+ onCheckedChange={handleThemeToggle}
243
+ aria-label={t('ui.profileMenu.theme', 'Theme')}
244
+ onClick={(e) => e.stopPropagation()}
245
+ />
246
+ </div>
247
+ )
231
248
  }
232
249
 
233
250
  if (id === 'language') {
234
251
  return (
235
- <div key={id} className="relative">
236
- <Button
252
+ <div key={id} className="contents">
253
+ <button
237
254
  type="button"
238
- variant="ghost"
239
- size="sm"
240
- className="w-full justify-between"
255
+ className={menuItemClass}
241
256
  role="menuitem"
242
257
  onClick={() => setLanguageOpen(!languageOpen)}
243
258
  aria-expanded={languageOpen}
244
259
  >
245
- <span className="inline-flex items-center gap-2.5">
246
- <Globe className="size-4" />
247
- <span>{t('ui.profileMenu.language', 'Language')}</span>
248
- </span>
249
- <span className="text-xs text-muted-foreground">
250
- {localeLabels[currentLocale]}
251
- </span>
252
- </Button>
260
+ <Globe className={menuIconClass} />
261
+ <span className="truncate">{t('ui.profileMenu.language', 'Language')}</span>
262
+ <span className="text-xs text-muted-foreground">{localeLabels[currentLocale]}</span>
263
+ <ChevronRight
264
+ className={cn(
265
+ 'ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform',
266
+ languageOpen && 'rotate-90',
267
+ )}
268
+ aria-hidden="true"
269
+ />
270
+ </button>
253
271
  {languageOpen && (
254
- <div className="mt-1 ml-6 space-y-0.5 border-l pl-2">
272
+ <div className="ml-7 mr-1 flex flex-col gap-0.5 border-l pl-2 py-1">
255
273
  {locales.map((locale) => (
256
- <Button
274
+ <button
257
275
  key={locale}
258
276
  type="button"
259
- variant="ghost"
260
- size="sm"
261
- className={`w-full justify-start gap-2 ${locale === currentLocale ? 'text-primary font-medium' : ''}`}
277
+ className={cn(
278
+ 'flex h-8 w-full items-center justify-between gap-2 rounded-md px-2 text-sm transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',
279
+ locale === currentLocale ? 'font-medium text-foreground' : 'text-muted-foreground',
280
+ )}
262
281
  onClick={() => handleLocaleChange(locale)}
263
282
  >
264
- <span>{localeLabels[locale]}</span>
265
- {locale === currentLocale && <Check className="size-3.5" />}
266
- </Button>
283
+ <span className="truncate">{localeLabels[locale]}</span>
284
+ {locale === currentLocale && <Check className="size-3.5 shrink-0 text-accent-indigo" aria-hidden="true" />}
285
+ </button>
267
286
  ))}
268
287
  </div>
269
288
  )}
@@ -274,16 +293,14 @@ export function ProfileDropdown({
274
293
  if (id === 'sign-out') {
275
294
  return (
276
295
  <form key={id} action="/api/auth/logout" method="POST">
277
- <Button
278
- variant="ghost"
279
- size="sm"
280
- className="w-full justify-start"
296
+ <button
281
297
  type="submit"
298
+ className={menuItemClass}
282
299
  role="menuitem"
283
300
  >
284
- <LogOut className="size-4" />
285
- <span>{t('ui.userMenu.logout', 'Sign Out')}</span>
286
- </Button>
301
+ <LogOut className={menuIconClass} />
302
+ <span className="flex-1 truncate text-left">{t('ui.userMenu.logout', 'Sign Out')}</span>
303
+ </button>
287
304
  </form>
288
305
  )
289
306
  }
@@ -297,6 +314,7 @@ export function ProfileDropdown({
297
314
  isDark,
298
315
  languageOpen,
299
316
  menuItemClass,
317
+ menuIconClass,
300
318
  mounted,
301
319
  notificationsHref,
302
320
  t,
@@ -321,42 +339,51 @@ export function ProfileDropdown({
321
339
  {open && (
322
340
  <div
323
341
  ref={menuRef}
324
- className="absolute right-0 top-full mt-1 w-56 rounded-md border bg-background p-1 shadow-lg z-dropdown"
342
+ className="absolute right-0 top-full z-popover mt-2 w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg"
325
343
  role="menu"
326
344
  data-testid="profile-dropdown"
327
345
  >
328
346
  {/* User info header */}
329
347
  {(displayName || email) && (
330
- <div className="px-3 py-2.5 border-b mb-1">
331
- {displayName && (
332
- <div className="font-medium text-sm flex items-center gap-2">
333
- <User className="size-4" />
334
- {displayName}
335
- </div>
336
- )}
337
- {displayName && email && (
338
- <div className="text-xs text-muted-foreground mt-0.5 ml-6">{email}</div>
339
- )}
340
- {!displayName && email && (
341
- <div className="text-xs text-muted-foreground">
342
- {t('ui.userMenu.loggedInAs', 'Logged in as:')} {email}
343
- </div>
344
- )}
348
+ <div className="flex items-center gap-3 border-b bg-muted/30 px-3 py-3">
349
+ <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo">
350
+ <User className="size-4" aria-hidden="true" />
351
+ </div>
352
+ <div className="flex min-w-0 flex-1 flex-col">
353
+ {displayName ? (
354
+ <span className="truncate text-sm font-medium leading-5 text-foreground">{displayName}</span>
355
+ ) : null}
356
+ {email ? (
357
+ <span className={cn(
358
+ 'truncate text-xs leading-4',
359
+ displayName ? 'text-muted-foreground' : 'text-foreground font-medium',
360
+ )}>
361
+ {email}
362
+ </span>
363
+ ) : null}
364
+ {!displayName && email ? (
365
+ <span className="truncate text-overline uppercase tracking-wider text-muted-foreground/80">
366
+ {t('ui.userMenu.loggedInAs', 'Logged in')}
367
+ </span>
368
+ ) : null}
369
+ </div>
345
370
  </div>
346
371
  )}
347
372
 
348
- {mergedMenuItems.map((item) => (
349
- <React.Fragment key={item.id}>
350
- {item.separator ? <div className="my-1 border-t" /> : null}
351
- {item.source === 'injected'
352
- ? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)
353
- : renderBuiltInItem(item.id)}
354
- </React.Fragment>
355
- ))}
356
- <InjectionSpot
357
- spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}
358
- context={injectionContext}
359
- />
373
+ <div className="flex flex-col p-1.5">
374
+ {mergedMenuItems.map((item) => (
375
+ <React.Fragment key={item.id}>
376
+ {item.separator ? <div className="my-1 h-px bg-border" aria-hidden="true" /> : null}
377
+ {item.source === 'injected'
378
+ ? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)
379
+ : renderBuiltInItem(item.id)}
380
+ </React.Fragment>
381
+ ))}
382
+ <InjectionSpot
383
+ spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}
384
+ context={injectionContext}
385
+ />
386
+ </div>
360
387
  </div>
361
388
  )}
362
389
  </div>
@@ -173,6 +173,14 @@ describe('AppShell', () => {
173
173
  expect(screen.getByTestId('injection-spot:backend:sidebar:footer')).toBeInTheDocument()
174
174
  expect(screen.getByTestId('injection-spot:backend-mutation:global')).toBeInTheDocument()
175
175
  expect(screen.getByText('Child content')).toBeInTheDocument()
176
+
177
+ const breadcrumbNav = screen.getByRole('navigation', { name: 'Breadcrumb' })
178
+ expect(breadcrumbNav).toHaveAttribute('data-slot', 'breadcrumb')
179
+ expect(breadcrumbNav).toHaveAttribute('data-divider', 'arrow')
180
+ const dashboardHome = within(breadcrumbNav).getByRole('link', { name: 'Dashboard' })
181
+ expect(dashboardHome).toHaveAttribute('href', '/backend')
182
+ const activePage = within(breadcrumbNav).getByText((_, el) => el?.getAttribute('data-slot') === 'breadcrumb-page')
183
+ expect(activePage).toHaveAttribute('aria-current', 'page')
176
184
  expect(mockInjectionSpot).toHaveBeenCalledWith(
177
185
  expect.objectContaining({
178
186
  spotId: 'backend-mutation:global',
@@ -7,7 +7,7 @@ export type NotificationCountBadgeProps = {
7
7
  export function NotificationCountBadge({ count }: NotificationCountBadgeProps) {
8
8
  if (count <= 0) return null
9
9
  return (
10
- <span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-overline font-medium text-destructive-foreground">
10
+ <span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground ring-2 ring-background">
11
11
  {count > 99 ? '99+' : count}
12
12
  </span>
13
13
  )
@@ -1,8 +1,9 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
- import { X, Bell, AlertTriangle, CheckCircle2, XCircle, Info, Loader2 } from 'lucide-react'
4
+ import { Bell, AlertTriangle, CheckCircle2, XCircle, Info, Loader2, X } from 'lucide-react'
5
5
  import { Button } from '../../primitives/button'
6
+ import { IconButton } from '../../primitives/icon-button'
6
7
  import { cn } from '@open-mercato/shared/lib/utils'
7
8
  import { formatRelativeTime } from '@open-mercato/shared/lib/time'
8
9
  import type { NotificationDto, NotificationRendererProps, NotificationTypeAction } from '@open-mercato/shared/modules/notifications/types'
@@ -15,37 +16,9 @@ export type NotificationItemProps = {
15
16
  onExecuteAction: (actionId: string) => Promise<{ href?: string }>
16
17
  onDismiss: () => Promise<void>
17
18
  t: TranslateFn
18
- /**
19
- * Optional custom renderer component for this notification type.
20
- * When provided, this component will be used instead of the default rendering.
21
- *
22
- * Custom renderers receive full control over the notification's appearance while
23
- * still having access to action handlers and notification data.
24
- *
25
- * @example
26
- * ```tsx
27
- * // In your module's notifications.client.ts
28
- * export const salesNotificationTypes = [
29
- * {
30
- * type: 'sales.order.created',
31
- * Renderer: SalesOrderCreatedRenderer,
32
- * // ...other fields
33
- * }
34
- * ]
35
- *
36
- * // Usage in NotificationPanel
37
- * const renderer = salesNotificationTypes.find(t => t.type === notification.type)?.Renderer
38
- * <NotificationItem
39
- * notification={notification}
40
- * customRenderer={renderer}
41
- * ...
42
- * />
43
- * ```
44
- */
45
19
  customRenderer?: ComponentType<NotificationRendererProps>
46
20
  }
47
21
 
48
-
49
22
  function resolveNotificationText(params: {
50
23
  key?: string | null
51
24
  fallback?: string | null
@@ -69,13 +42,30 @@ const severityIcons = {
69
42
  error: XCircle,
70
43
  }
71
44
 
72
- const severityColors = {
45
+ // Avatar background — soft tints matching the Figma example item palette
46
+ const severityAvatarBg = {
47
+ info: 'bg-status-info-bg',
48
+ warning: 'bg-status-warning-bg',
49
+ success: 'bg-status-success-bg',
50
+ error: 'bg-status-error-bg',
51
+ }
52
+
53
+ const severityAvatarText = {
73
54
  info: 'text-status-info-icon',
74
55
  warning: 'text-status-warning-icon',
75
56
  success: 'text-status-success-icon',
76
57
  error: 'text-status-error-icon',
77
58
  }
78
59
 
60
+ function humanizeModuleId(moduleId?: string | null): string | null {
61
+ if (!moduleId) return null
62
+ return moduleId
63
+ .split(/[_:.-]+/)
64
+ .filter(Boolean)
65
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
66
+ .join(' ')
67
+ }
68
+
79
69
  export function NotificationItem({
80
70
  notification,
81
71
  onMarkAsRead,
@@ -89,8 +79,11 @@ export function NotificationItem({
89
79
 
90
80
  const isUnread = notification.status === 'unread'
91
81
  const hasActions = notification.actions && notification.actions.length > 0
92
- const severity = notification.severity as keyof typeof severityIcons
82
+ const severity = (notification.severity as keyof typeof severityIcons) ?? 'info'
93
83
  const IconComponent = severityIcons[severity] ?? Bell
84
+ const avatarBg = severityAvatarBg[severity] ?? 'bg-muted'
85
+ const avatarText = severityAvatarText[severity] ?? 'text-muted-foreground'
86
+
94
87
  const titleText = resolveNotificationText({
95
88
  key: notification.titleKey,
96
89
  fallback: notification.title,
@@ -103,14 +96,12 @@ export function NotificationItem({
103
96
  variables: notification.bodyVariables ?? undefined,
104
97
  t,
105
98
  })
99
+ const timeAgo = formatRelativeTime(notification.createdAt, { translate: t }) ?? ''
100
+ const sourceLabel = humanizeModuleId(notification.sourceModule)
106
101
 
107
102
  const handleClick = async () => {
108
- if (isUnread) {
109
- await onMarkAsRead()
110
- }
111
- if (notification.linkHref) {
112
- router.push(notification.linkHref)
113
- }
103
+ if (isUnread) await onMarkAsRead()
104
+ if (notification.linkHref) router.push(notification.linkHref)
114
105
  }
115
106
 
116
107
  const handleAction = async (actionId: string, event?: React.MouseEvent) => {
@@ -118,9 +109,7 @@ export function NotificationItem({
118
109
  setExecuting(actionId)
119
110
  try {
120
111
  const result = await onExecuteAction(actionId)
121
- if (result.href) {
122
- router.push(result.href)
123
- }
112
+ if (result.href) router.push(result.href)
124
113
  } finally {
125
114
  setExecuting(null)
126
115
  }
@@ -131,7 +120,6 @@ export function NotificationItem({
131
120
  await onDismiss()
132
121
  }
133
122
 
134
- // Convert notification actions to the format expected by custom renderers
135
123
  const rendererActions: NotificationTypeAction[] = notification.actions.map((action) => ({
136
124
  id: action.id,
137
125
  labelKey: action.labelKey ?? action.label,
@@ -139,7 +127,6 @@ export function NotificationItem({
139
127
  icon: action.icon,
140
128
  }))
141
129
 
142
- // Use custom renderer if provided
143
130
  if (CustomRenderer) {
144
131
  return (
145
132
  <CustomRenderer
@@ -168,11 +155,14 @@ export function NotificationItem({
168
155
  )
169
156
  }
170
157
 
158
+ const showActions = hasActions && notification.status !== 'actioned'
159
+ const showBodyBubble = !!bodyText && bodyText.length > 0
160
+
171
161
  return (
172
162
  <div
173
163
  className={cn(
174
- 'group relative px-4 py-3 hover:bg-muted/50 cursor-pointer transition-colors',
175
- isUnread && 'bg-muted/30'
164
+ 'group relative flex gap-4 items-start rounded-xl p-3 transition-colors hover:bg-muted/40 cursor-pointer',
165
+ isUnread && 'bg-muted/20'
176
166
  )}
177
167
  onClick={handleClick}
178
168
  onKeyDown={(e) => {
@@ -184,79 +174,82 @@ export function NotificationItem({
184
174
  role="button"
185
175
  tabIndex={0}
186
176
  >
187
- {isUnread && (
188
- <div className="absolute left-1.5 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-primary" />
189
- )}
177
+ {/* Avatar: severity-tinted circle with severity icon */}
178
+ <div className={cn('relative shrink-0 flex size-10 items-center justify-center rounded-full', avatarBg)}>
179
+ <IconComponent className={cn('size-5', avatarText)} aria-hidden="true" />
180
+ {isUnread ? (
181
+ <span
182
+ className="absolute -right-1 -top-1 size-3 rounded-full bg-accent-indigo ring-2 ring-background"
183
+ aria-hidden="true"
184
+ />
185
+ ) : null}
186
+ </div>
190
187
 
191
- <div className="flex gap-3">
192
- <div
193
- className={cn(
194
- 'flex-shrink-0 mt-0.5',
195
- severityColors[severity] ?? 'text-muted-foreground'
196
- )}
197
- >
198
- <IconComponent className="h-5 w-5" />
188
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
189
+ {/* Title row */}
190
+ <p className="text-sm font-medium leading-5 tracking-tight text-foreground">
191
+ {titleText}
192
+ </p>
193
+
194
+ {/* Description row: time · source */}
195
+ <div className="flex flex-wrap items-center gap-1 text-xs leading-4 text-muted-foreground">
196
+ {timeAgo ? <span className="whitespace-nowrap">{timeAgo}</span> : null}
197
+ {timeAgo && sourceLabel ? (
198
+ <span aria-hidden="true" className="text-text-disabled">·</span>
199
+ ) : null}
200
+ {sourceLabel ? <span className="truncate">{sourceLabel}</span> : null}
199
201
  </div>
200
202
 
201
- <div className="flex-1 min-w-0">
202
- <div className="flex items-start justify-between gap-2">
203
- <h4 className={cn('text-sm font-medium', isUnread && 'font-semibold')}>
204
- {titleText}
205
- </h4>
206
- <span className="flex-shrink-0 text-xs text-muted-foreground">
207
- {formatRelativeTime(notification.createdAt, { translate: t }) ?? ''}
208
- </span>
203
+ {/* Optional body bubble */}
204
+ {showBodyBubble ? (
205
+ <div className="mt-2 inline-flex max-w-full self-start rounded-tr-lg rounded-br-lg rounded-bl-lg rounded-tl-sm border bg-background px-3 py-2 text-sm leading-5 tracking-tight text-muted-foreground shadow-xs">
206
+ <p className="whitespace-pre-line break-words">{bodyText}</p>
209
207
  </div>
210
-
211
- {bodyText && (
212
- <p className="mt-1 text-sm text-muted-foreground line-clamp-2">
213
- {bodyText}
214
- </p>
215
- )}
216
-
217
- {hasActions && notification.status !== 'actioned' && (
218
- <div className="mt-2 flex flex-wrap items-start gap-2">
219
- {notification.actions.map((action) => (
208
+ ) : null}
209
+
210
+ {/* Optional actions row */}
211
+ {showActions ? (
212
+ <div className="mt-2 flex flex-wrap items-center gap-2">
213
+ {notification.actions.map((action, idx) => {
214
+ const isPrimary = idx === notification.actions.length - 1 || action.variant === 'default'
215
+ return (
220
216
  <Button
221
217
  key={action.id}
222
- variant={
223
- (action.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost') ??
224
- 'outline'
225
- }
218
+ type="button"
219
+ variant={isPrimary ? 'default' : 'outline'}
226
220
  size="sm"
227
- className="h-auto min-h-8 max-w-full min-w-0 whitespace-normal break-words text-left"
221
+ className={cn(
222
+ 'h-8 rounded-md px-2.5 py-1.5 text-sm font-medium',
223
+ isPrimary && 'bg-accent-indigo text-accent-indigo-foreground hover:bg-accent-indigo/90',
224
+ )}
228
225
  onClick={(event) => handleAction(action.id, event)}
229
226
  disabled={executing !== null}
230
227
  >
231
- {action.labelKey
232
- ? t(action.labelKey, action.label)
233
- : t(action.label, action.label)}
234
- {executing === action.id && (
235
- <Loader2 className="ml-1 h-3 w-3 animate-spin" />
236
- )}
228
+ {action.labelKey ? t(action.labelKey, action.label) : t(action.label, action.label)}
229
+ {executing === action.id ? <Loader2 className="ml-1 size-3 animate-spin" /> : null}
237
230
  </Button>
238
- ))}
239
- </div>
240
- )}
241
-
242
- {notification.status === 'actioned' && notification.actionTaken && (
243
- <p className="mt-1 text-xs text-muted-foreground italic">
244
- {t('notifications.actionTaken', 'Action taken: {action}', {
245
- action: notification.actionTaken,
246
- })}
247
- </p>
248
- )}
249
- </div>
231
+ )
232
+ })}
233
+ </div>
234
+ ) : null}
250
235
 
251
- <Button
252
- variant="ghost"
253
- size="icon"
254
- className="flex-shrink-0 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
255
- onClick={handleDismiss}
256
- >
257
- <X className="h-3 w-3" />
258
- </Button>
236
+ {notification.status === 'actioned' && notification.actionTaken ? (
237
+ <p className="mt-1 text-xs italic text-muted-foreground">
238
+ {t('notifications.actionTaken', 'Action taken: {action}', { action: notification.actionTaken })}
239
+ </p>
240
+ ) : null}
259
241
  </div>
242
+
243
+ <IconButton
244
+ type="button"
245
+ variant="ghost"
246
+ size="xs"
247
+ className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
248
+ onClick={handleDismiss}
249
+ aria-label={t('notifications.actions.dismiss', 'Dismiss')}
250
+ >
251
+ <X className="size-3.5" />
252
+ </IconButton>
260
253
  </div>
261
254
  )
262
255
  }