@flamingo-stack/openframe-frontend-core 0.0.263 → 0.0.264-snapshot.20260612115719

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 (83) hide show
  1. package/dist/{chunk-YC357BPF.js → chunk-266DUZ6D.js} +3 -3
  2. package/dist/{chunk-36HLBLDK.cjs → chunk-2P6V2EWZ.cjs} +498 -455
  3. package/dist/chunk-2P6V2EWZ.cjs.map +1 -0
  4. package/dist/{chunk-ALEKX56R.js → chunk-6A2YQ5CY.js} +2 -2
  5. package/dist/{chunk-C3FAOQAH.cjs → chunk-D4TWJNO5.cjs} +28 -28
  6. package/dist/{chunk-C3FAOQAH.cjs.map → chunk-D4TWJNO5.cjs.map} +1 -1
  7. package/dist/{chunk-6AIXHYBZ.js → chunk-EXU5N5RM.js} +100 -57
  8. package/dist/chunk-EXU5N5RM.js.map +1 -0
  9. package/dist/{chunk-2XUWR7AH.cjs → chunk-HMHMK6U6.cjs} +11 -11
  10. package/dist/{chunk-2XUWR7AH.cjs.map → chunk-HMHMK6U6.cjs.map} +1 -1
  11. package/dist/{chunk-XKA7UFED.cjs → chunk-J5BBBDNA.cjs} +30 -30
  12. package/dist/{chunk-XKA7UFED.cjs.map → chunk-J5BBBDNA.cjs.map} +1 -1
  13. package/dist/{chunk-3DC6MHW6.cjs → chunk-QLWBBCI5.cjs} +491 -391
  14. package/dist/chunk-QLWBBCI5.cjs.map +1 -0
  15. package/dist/{chunk-U7UDESAU.js → chunk-Z5YEQWLC.js} +2 -2
  16. package/dist/{chunk-6GBA6YJN.js → chunk-ZMFT5XOB.js} +576 -476
  17. package/dist/chunk-ZMFT5XOB.js.map +1 -0
  18. package/dist/components/chat/index.cjs +3 -3
  19. package/dist/components/chat/index.js +2 -2
  20. package/dist/components/contact/index.cjs +4 -4
  21. package/dist/components/contact/index.js +3 -3
  22. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  23. package/dist/components/features/board/types.d.ts +1 -0
  24. package/dist/components/features/board/types.d.ts.map +1 -1
  25. package/dist/components/features/index.cjs +3 -3
  26. package/dist/components/features/index.js +2 -2
  27. package/dist/components/features/notifications/notification-drawer.d.ts.map +1 -1
  28. package/dist/components/features/notifications/notifications-context.d.ts +9 -1
  29. package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
  30. package/dist/components/index.cjs +50 -50
  31. package/dist/components/index.js +4 -4
  32. package/dist/components/navigation/index.cjs +3 -3
  33. package/dist/components/navigation/index.js +2 -2
  34. package/dist/components/navigation/sliding-sidebar.d.ts.map +1 -1
  35. package/dist/components/related-content/index.cjs +4 -4
  36. package/dist/components/related-content/index.js +3 -3
  37. package/dist/components/tickets/index.cjs +58 -58
  38. package/dist/components/tickets/index.js +4 -4
  39. package/dist/components/ui/drawer.d.ts +5 -0
  40. package/dist/components/ui/drawer.d.ts.map +1 -1
  41. package/dist/components/ui/index.cjs +3 -3
  42. package/dist/components/ui/index.js +2 -2
  43. package/dist/hooks/index.cjs +6 -2
  44. package/dist/hooks/index.cjs.map +1 -1
  45. package/dist/hooks/index.js +5 -1
  46. package/dist/hooks/ui/index.d.ts +6 -4
  47. package/dist/hooks/ui/index.d.ts.map +1 -1
  48. package/dist/hooks/ui/use-header-height.d.ts +8 -0
  49. package/dist/hooks/ui/use-header-height.d.ts.map +1 -0
  50. package/dist/hooks/ui/use-horizontal-scrollbar.d.ts.map +1 -1
  51. package/dist/hooks/ui/use-notification-permission.d.ts +14 -0
  52. package/dist/hooks/ui/use-notification-permission.d.ts.map +1 -0
  53. package/dist/index.cjs +11 -3
  54. package/dist/index.cjs.map +1 -1
  55. package/dist/index.js +10 -2
  56. package/dist/utils/date-utils.d.ts +9 -0
  57. package/dist/utils/date-utils.d.ts.map +1 -1
  58. package/dist/utils/index.cjs +29 -0
  59. package/dist/utils/index.cjs.map +1 -1
  60. package/dist/utils/index.d.ts +1 -1
  61. package/dist/utils/index.d.ts.map +1 -1
  62. package/dist/utils/index.js +28 -1
  63. package/dist/utils/index.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/components/features/board/ticket-card.tsx +15 -0
  66. package/src/components/features/board/types.ts +1 -0
  67. package/src/components/features/notifications/notification-drawer.tsx +51 -2
  68. package/src/components/features/notifications/notifications-context.tsx +26 -0
  69. package/src/components/navigation/sliding-sidebar.tsx +3 -82
  70. package/src/components/ui/drawer.tsx +17 -6
  71. package/src/hooks/ui/index.ts +6 -4
  72. package/src/hooks/ui/use-header-height.ts +55 -0
  73. package/src/hooks/ui/use-horizontal-scrollbar.ts +19 -2
  74. package/src/hooks/ui/use-notification-permission.ts +64 -0
  75. package/src/utils/date-utils.ts +36 -0
  76. package/src/utils/index.ts +2 -0
  77. package/dist/chunk-36HLBLDK.cjs.map +0 -1
  78. package/dist/chunk-3DC6MHW6.cjs.map +0 -1
  79. package/dist/chunk-6AIXHYBZ.js.map +0 -1
  80. package/dist/chunk-6GBA6YJN.js.map +0 -1
  81. /package/dist/{chunk-YC357BPF.js.map → chunk-266DUZ6D.js.map} +0 -0
  82. /package/dist/{chunk-ALEKX56R.js.map → chunk-6A2YQ5CY.js.map} +0 -0
  83. /package/dist/{chunk-U7UDESAU.js.map → chunk-Z5YEQWLC.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.263",
3
+ "version": "0.0.264-snapshot.20260612115719",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -7,7 +7,9 @@ import * as React from 'react'
7
7
  import { LaptopIcon, Flag02Icon } from '../../icons-v2-generated'
8
8
  import { SquareAvatar } from '../../ui/square-avatar'
9
9
  import { Tag } from '../../ui/tag'
10
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'
10
11
  import { cn } from '../../../utils/cn'
12
+ import { formatTicketRelativeTime, formatTicketFullTimestamp } from '../../../utils/date-utils'
11
13
  import type { BoardPriority, BoardTicket } from './types'
12
14
 
13
15
  const PRIORITY_COLOR_CLASS: Record<BoardPriority, string> = {
@@ -96,6 +98,9 @@ export function TicketCard({
96
98
  </div>
97
99
  ) : null
98
100
 
101
+ const timestampLabel = ticket.createdAt ? formatTicketRelativeTime(ticket.createdAt) : null
102
+ const tooltipLabel = ticket.createdAt ? formatTicketFullTimestamp(ticket.createdAt) : null
103
+
99
104
  const body = (
100
105
  <>
101
106
  <div className="flex items-start gap-[var(--spacing-system-sf)]">
@@ -111,6 +116,16 @@ export function TicketCard({
111
116
  {rightSection}
112
117
  </div>
113
118
  {ticket.tags?.length ? <TicketTagRow tags={ticket.tags} /> : null}
119
+ {timestampLabel && tooltipLabel && (
120
+ <TooltipProvider>
121
+ <Tooltip>
122
+ <TooltipTrigger asChild>
123
+ <p className="pointer-events-auto text-h6 truncate text-ods-text-secondary">{timestampLabel}</p>
124
+ </TooltipTrigger>
125
+ <TooltipContent>{tooltipLabel}</TooltipContent>
126
+ </Tooltip>
127
+ </TooltipProvider>
128
+ )}
114
129
  </>
115
130
  )
116
131
 
@@ -20,6 +20,7 @@ export interface BoardTicket {
20
20
  priority?: BoardPriority
21
21
  assignees?: BoardTicketAssignee[]
22
22
  tags?: string[]
23
+ createdAt?: string
23
24
  }
24
25
 
25
26
  export interface BoardColumnDef {
@@ -7,6 +7,7 @@ import { Button } from '../../ui/button/button'
7
7
  import { Drawer, DrawerContent, DrawerTitle } from '../../ui/drawer'
8
8
  import { Switch } from '../../ui/switch'
9
9
  import { cn } from '../../../utils/cn'
10
+ import { useNotificationPermission } from '../../../hooks/ui'
10
11
  import { useOptionalNotifications } from './notifications-context'
11
12
  import { NotificationTile } from './notification-tile'
12
13
  import type { Notification, RenderNotificationTile } from './types'
@@ -26,6 +27,7 @@ export function NotificationDrawer({
26
27
  renderTile: renderTileProp,
27
28
  }: NotificationDrawerProps) {
28
29
  const ctx = useOptionalNotifications()
30
+
29
31
  if (!ctx) return null
30
32
 
31
33
  const {
@@ -39,6 +41,9 @@ export function NotificationDrawer({
39
41
  markAllRead,
40
42
  markSettled,
41
43
  setShowPopups,
44
+ showDesktopPopups,
45
+ setShowDesktopPopups,
46
+ desktopPopupsConfigured,
42
47
  onHistoryClick,
43
48
  hasMore,
44
49
  isLoadingMore,
@@ -55,6 +60,7 @@ export function NotificationDrawer({
55
60
  <DrawerContent
56
61
  side="right"
57
62
  aria-describedby={undefined}
63
+ offsetHeader
58
64
  className={cn(
59
65
  'w-[calc(100vw-2rem)] sm:w-[26rem] sm:max-w-[calc(100vw-2rem)] gap-[var(--spacing-system-m)] p-[var(--spacing-system-zero)]',
60
66
  className,
@@ -62,6 +68,7 @@ export function NotificationDrawer({
62
68
  >
63
69
  <div className="px-[var(--spacing-system-m)] pt-[var(--spacing-system-m)]">
64
70
  <DrawerTitle
71
+ hideClose
65
72
  className="truncate"
66
73
  actions={
67
74
  <button
@@ -91,7 +98,12 @@ export function NotificationDrawer({
91
98
  />
92
99
 
93
100
  <div className="flex flex-col gap-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] pb-[var(--spacing-system-m)]">
94
- <ShowNotificationsToggleRow checked={showPopups} onChange={setShowPopups} />
101
+ <div className="overflow-hidden rounded-md border border-ods-border bg-ods-card">
102
+ <ShowNotificationsToggleRow checked={showPopups} onChange={setShowPopups} />
103
+ {desktopPopupsConfigured && (
104
+ <DesktopNotificationsToggleRow checked={showDesktopPopups} onChange={setShowDesktopPopups} />
105
+ )}
106
+ </div>
95
107
  <NotificationsHistoryButton
96
108
  onClick={onHistoryClick ? () => { onHistoryClick(); close() } : undefined}
97
109
  />
@@ -206,7 +218,7 @@ interface ToggleRowProps {
206
218
 
207
219
  function ShowNotificationsToggleRow({ checked, onChange }: ToggleRowProps) {
208
220
  return (
209
- <div className="flex items-center gap-[var(--spacing-system-s)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-sf)]">
221
+ <div className="flex items-center gap-[var(--spacing-system-s)] border-b border-ods-border p-[var(--spacing-system-sf)]">
210
222
  <Switch
211
223
  checked={checked}
212
224
  onCheckedChange={onChange}
@@ -220,6 +232,43 @@ function ShowNotificationsToggleRow({ checked, onChange }: ToggleRowProps) {
220
232
  )
221
233
  }
222
234
 
235
+ /** Switching on prompts for browser permission and commits only when granted; denied renders disabled (can't re-prompt programmatically); unsupported (SSR, iOS Safari) hides the row. */
236
+ function DesktopNotificationsToggleRow({ checked, onChange }: ToggleRowProps) {
237
+ const { supported, permission, request } = useNotificationPermission()
238
+ if (!supported) return null
239
+
240
+ const blocked = permission === 'denied'
241
+
242
+ const handleChange = async (value: boolean) => {
243
+ if (!value || permission === 'granted') {
244
+ onChange(value)
245
+ return
246
+ }
247
+ if ((await request()) === 'granted') onChange(true)
248
+ }
249
+
250
+ return (
251
+ <div className="flex items-center gap-[var(--spacing-system-s)] border-b border-ods-border p-[var(--spacing-system-sf)]">
252
+ <Switch
253
+ checked={checked && permission === 'granted'}
254
+ disabled={blocked}
255
+ onCheckedChange={handleChange}
256
+ aria-label="Show desktop notifications"
257
+ />
258
+ <div className="min-w-0 flex-1">
259
+ <p className={cn('text-h4', blocked ? 'text-ods-text-secondary' : 'text-ods-text-primary')}>
260
+ Desktop Notifications
261
+ </p>
262
+ <p className="text-h6 text-ods-text-secondary">
263
+ {blocked
264
+ ? 'Enable notifications in your browser settings'
265
+ : 'Notify when this tab is in the background'}
266
+ </p>
267
+ </div>
268
+ </div>
269
+ )
270
+ }
271
+
223
272
  interface HistoryButtonProps {
224
273
  onClick?: () => void
225
274
  }
@@ -20,6 +20,11 @@ interface NotificationsContextValue {
20
20
  close: () => void
21
21
  toggle: () => void
22
22
  setShowPopups: (value: boolean) => void
23
+ /** OS-level notification preference; only meaningful when `desktopPopupsConfigured`. */
24
+ showDesktopPopups: boolean
25
+ setShowDesktopPopups: (value: boolean) => void
26
+ /** True when the host app wired desktop notifications (passed `onShowDesktopPopupsChange`). */
27
+ desktopPopupsConfigured: boolean
23
28
  onHistoryClick?: () => void
24
29
  hasMore: boolean
25
30
  isLoadingMore: boolean
@@ -46,6 +51,9 @@ export interface NotificationsProviderProps {
46
51
  maxNotifications?: number
47
52
  defaultShowPopups?: boolean
48
53
  onShowPopupsChange?: (value: boolean) => void
54
+ /** Desktop (OS-level) notification preference; providing `onShowDesktopPopupsChange` opts into the drawer toggle. */
55
+ defaultShowDesktopPopups?: boolean
56
+ onShowDesktopPopupsChange?: (value: boolean) => void
49
57
  onHistoryClick?: () => void
50
58
  actions?: NotificationsActions
51
59
  /** Pagination — when omitted, the drawer hides its load-more sentinel. */
@@ -138,6 +146,8 @@ export function NotificationsProvider({
138
146
  maxNotifications = 50,
139
147
  defaultShowPopups = true,
140
148
  onShowPopupsChange,
149
+ defaultShowDesktopPopups = false,
150
+ onShowDesktopPopupsChange,
141
151
  onHistoryClick,
142
152
  actions,
143
153
  hasMore = false,
@@ -148,6 +158,8 @@ export function NotificationsProvider({
148
158
  const [notifications, dispatch] = React.useReducer(reducer, initialNotifications)
149
159
  const [isOpen, setIsOpen] = React.useState(false)
150
160
  const [showPopups, setShowPopupsState] = React.useState(defaultShowPopups)
161
+ const [showDesktopPopups, setShowDesktopPopupsState] = React.useState(defaultShowDesktopPopups)
162
+ const desktopPopupsConfigured = onShowDesktopPopupsChange !== undefined
151
163
 
152
164
  const actionsRef = React.useRef(actions)
153
165
  React.useEffect(() => {
@@ -221,6 +233,14 @@ export function NotificationsProvider({
221
233
  [onShowPopupsChange],
222
234
  )
223
235
 
236
+ const setShowDesktopPopups = React.useCallback(
237
+ (value: boolean) => {
238
+ setShowDesktopPopupsState(value)
239
+ onShowDesktopPopupsChange?.(value)
240
+ },
241
+ [onShowDesktopPopupsChange],
242
+ )
243
+
224
244
  const unreadCount = React.useMemo(
225
245
  () => notifications.reduce((acc, n) => (n.read ? acc : acc + 1), 0),
226
246
  [notifications],
@@ -244,6 +264,9 @@ export function NotificationsProvider({
244
264
  close,
245
265
  toggle,
246
266
  setShowPopups,
267
+ showDesktopPopups,
268
+ setShowDesktopPopups,
269
+ desktopPopupsConfigured,
247
270
  onHistoryClick,
248
271
  hasMore,
249
272
  isLoadingMore,
@@ -255,6 +278,9 @@ export function NotificationsProvider({
255
278
  unreadCount,
256
279
  isOpen,
257
280
  showPopups,
281
+ showDesktopPopups,
282
+ setShowDesktopPopups,
283
+ desktopPopupsConfigured,
258
284
  addNotification,
259
285
  upsertNotification,
260
286
  setNotifications,
@@ -3,6 +3,7 @@
3
3
  import React, { useState, useEffect } from 'react'
4
4
  import { motion, AnimatePresence } from 'framer-motion'
5
5
  import { cn } from '../../utils'
6
+ import { useHeaderHeight } from '../../hooks/ui'
6
7
  import { SlidingSidebarConfig, NavigationItem } from '../../types/navigation'
7
8
  import { Button } from '../ui/button'
8
9
 
@@ -13,89 +14,9 @@ export interface SlidingSidebarProps {
13
14
  export function SlidingSidebar({ config }: SlidingSidebarProps) {
14
15
  const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
15
16
  const [mounted, setMounted] = useState(false)
16
- const [headerHeight, setHeaderHeight] = useState(64) // Default to 64px
17
+ const headerHeight = useHeaderHeight()
17
18
 
18
- useEffect(() => {
19
- setMounted(true)
20
-
21
- // Function to calculate total header height
22
- const calculateHeaderHeight = () => {
23
- let totalHeight = 0
24
-
25
- // Find and measure the header
26
- const header = document.querySelector('header')
27
- if (header) {
28
- totalHeight += header.offsetHeight
29
- }
30
-
31
- // Find and measure the announcement bar (if exists)
32
- const announcementBar = document.querySelector('[data-announcement-bar]')
33
- if (announcementBar && announcementBar instanceof HTMLElement) {
34
- totalHeight += announcementBar.offsetHeight
35
- }
36
-
37
- // Update the header height if we found elements
38
- if (totalHeight > 0) {
39
- setHeaderHeight(totalHeight)
40
- } else {
41
- // If no header found, default to 64px
42
- setHeaderHeight(64)
43
- }
44
- }
45
-
46
- // Calculate initial height with a small delay to ensure elements are rendered
47
- setTimeout(calculateHeaderHeight, 100)
48
-
49
- // Also calculate immediately in case elements are already there
50
- calculateHeaderHeight()
51
-
52
- // Recalculate on window resize
53
- window.addEventListener('resize', calculateHeaderHeight)
54
-
55
- // Use ResizeObserver to detect header size changes
56
- const resizeObserver = new ResizeObserver(calculateHeaderHeight)
57
-
58
- // Observe header
59
- const header = document.querySelector('header')
60
- if (header) {
61
- resizeObserver.observe(header)
62
- }
63
-
64
- // Observe announcement bar
65
- const announcementBar = document.querySelector('[data-announcement-bar]')
66
- if (announcementBar) {
67
- resizeObserver.observe(announcementBar)
68
- }
69
-
70
- // Use MutationObserver to detect when announcement bar is added/removed
71
- const mutationObserver = new MutationObserver((mutations) => {
72
- // Check if announcement bar was added or removed
73
- for (const mutation of mutations) {
74
- if (mutation.type === 'childList') {
75
- // Recalculate height when DOM changes
76
- calculateHeaderHeight()
77
-
78
- // If announcement bar was added, start observing it
79
- const newAnnouncementBar = document.querySelector('[data-announcement-bar]')
80
- if (newAnnouncementBar) {
81
- resizeObserver.observe(newAnnouncementBar)
82
- }
83
- }
84
- }
85
- })
86
-
87
- // Observe the body for changes (announcement bar might be added/removed)
88
- mutationObserver.observe(document.body, {
89
- childList: true,
90
- subtree: true
91
- })
92
-
93
- return () => {
94
- window.removeEventListener('resize', calculateHeaderHeight)
95
- resizeObserver.disconnect()
96
- mutationObserver.disconnect()
97
- }
98
- }, [])
19
+ useEffect(() => { setMounted(true) }, [])
99
20
 
100
21
  const toggleExpanded = (itemId: string) => {
101
22
  const newExpanded = new Set(expandedItems)
@@ -6,6 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
6
6
  import { X } from "lucide-react"
7
7
 
8
8
  import { cn } from "../../utils/cn"
9
+ import { useHeaderHeight } from "../../hooks/ui/use-header-height"
9
10
 
10
11
  const Drawer = DialogPrimitive.Root
11
12
 
@@ -354,6 +355,9 @@ interface DrawerContentProps
354
355
  panelStyle?: React.CSSProperties
355
356
  /** Optional className applied to the inner panel. (Alias for `className`.) */
356
357
  panelClassName?: string
358
+ /** Offset the drawer's top edge below the app header + announcement bar.
359
+ * Measured live via ResizeObserver so it tracks height changes. */
360
+ offsetHeader?: boolean
357
361
  }
358
362
 
359
363
  const DrawerContent = React.forwardRef<
@@ -376,6 +380,7 @@ const DrawerContent = React.forwardRef<
376
380
  style,
377
381
  panelStyle,
378
382
  panelClassName,
383
+ offsetHeader = false,
379
384
  children,
380
385
  ...props
381
386
  },
@@ -385,6 +390,8 @@ const DrawerContent = React.forwardRef<
385
390
  const isHorizontal = HORIZONTAL_SIDES.has(resolvedSide)
386
391
  const initialSize = defaultSize ?? (isHorizontal ? 560 : 480)
387
392
 
393
+ const headerHeight = useHeaderHeight()
394
+
388
395
  const [isMobile, setIsMobile] = React.useState(false)
389
396
  React.useEffect(() => {
390
397
  if (typeof window === "undefined") return
@@ -417,7 +424,7 @@ const DrawerContent = React.forwardRef<
417
424
  <DialogPrimitive.Content
418
425
  ref={ref}
419
426
  className={cn(drawerVariants({ side, flush }))}
420
- style={style}
427
+ style={{ ...(offsetHeader ? { top: headerHeight } : {}), ...style }}
421
428
  {...props}
422
429
  >
423
430
  {/* Resize handle is a sibling of the panel — outside the panel's
@@ -468,12 +475,14 @@ interface DrawerTitleProps
468
475
  extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {
469
476
  /** Optional header actions rendered between the title and the close button. */
470
477
  actions?: React.ReactNode
478
+ /** Hide the close (X) button. */
479
+ hideClose?: boolean
471
480
  }
472
481
 
473
482
  const DrawerTitle = React.forwardRef<
474
483
  React.ComponentRef<typeof DialogPrimitive.Title>,
475
484
  DrawerTitleProps
476
- >(({ className, children, actions, ...props }, ref) => (
485
+ >(({ className, children, actions, hideClose, ...props }, ref) => (
477
486
  <div className="flex items-start gap-4">
478
487
  <DialogPrimitive.Title
479
488
  ref={ref}
@@ -486,10 +495,12 @@ const DrawerTitle = React.forwardRef<
486
495
  {children}
487
496
  </DialogPrimitive.Title>
488
497
  {actions}
489
- <DialogPrimitive.Close className="shrink-0 rounded-sm text-ods-text-secondary transition-colors hover:text-ods-text-primary outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0">
490
- <X className="size-6" />
491
- <span className="sr-only">Close</span>
492
- </DialogPrimitive.Close>
498
+ {!hideClose && (
499
+ <DialogPrimitive.Close className="shrink-0 rounded-sm text-ods-text-secondary transition-colors hover:text-ods-text-primary outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0">
500
+ <X className="size-6" />
501
+ <span className="sr-only">Close</span>
502
+ </DialogPrimitive.Close>
503
+ )}
493
504
  </div>
494
505
  ))
495
506
  DrawerTitle.displayName = "DrawerTitle"
@@ -1,13 +1,15 @@
1
1
  // UI Hooks exports
2
+ export * from './use-auto-limit-tags'
2
3
  export * from './use-debounce'
4
+ export * from './use-header-height'
5
+ export * from './use-horizontal-scrollbar'
6
+ export * from './use-image-edge-color'
3
7
  export * from './use-local-storage'
4
8
  export * from './use-media-query'
5
9
  export * from './use-memoized-callback'
10
+ export * from './use-notification-permission'
6
11
  export * from './use-onboarding-state'
12
+ export * from './use-search'
7
13
  export * from './use-table-pagination'
8
14
  export * from './use-throttle'
9
15
  export * from './use-window-size'
10
- export * from './use-horizontal-scrollbar'
11
- export * from './use-image-edge-color'
12
- export * from './use-search'
13
- export * from './use-auto-limit-tags'
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ /**
6
+ * Returns the combined height (px) of the sticky page header and announcement
7
+ * bar (if present), updated live via ResizeObserver / MutationObserver.
8
+ * Useful for offsetting fixed/absolute-positioned panels so they don't
9
+ * overlap the header.
10
+ */
11
+ export function useHeaderHeight(defaultHeight = 64): number {
12
+ const [height, setHeight] = useState(defaultHeight)
13
+
14
+ useEffect(() => {
15
+ const measure = () => {
16
+ let total = 0
17
+ const header = document.querySelector('header')
18
+ if (header) total += header.offsetHeight
19
+ const bar = document.querySelector('[data-announcement-bar]')
20
+ if (bar instanceof HTMLElement) total += bar.offsetHeight
21
+ setHeight(total > 0 ? total : defaultHeight)
22
+ }
23
+
24
+ measure()
25
+
26
+ const resizeObserver = new ResizeObserver(measure)
27
+ const header = document.querySelector('header')
28
+ if (header) resizeObserver.observe(header)
29
+ const bar = document.querySelector('[data-announcement-bar]')
30
+ if (bar) resizeObserver.observe(bar)
31
+
32
+ const mutationObserver = new MutationObserver((mutations) => {
33
+ for (const mutation of mutations) {
34
+ if (mutation.type === 'childList' || mutation.type === 'attributes') {
35
+ measure()
36
+ const newBar = document.querySelector('[data-announcement-bar]')
37
+ if (newBar) resizeObserver.observe(newBar)
38
+ }
39
+ }
40
+ })
41
+ mutationObserver.observe(document.body, {
42
+ childList: true,
43
+ subtree: true,
44
+ attributes: true,
45
+ attributeFilter: ['data-announcement-bar'],
46
+ })
47
+
48
+ return () => {
49
+ resizeObserver.disconnect()
50
+ mutationObserver.disconnect()
51
+ }
52
+ }, [defaultHeight])
53
+
54
+ return height
55
+ }
@@ -17,6 +17,7 @@ export function useHorizontalScrollbar() {
17
17
  const trackRef = useRef<HTMLDivElement>(null)
18
18
  const thumbRef = useRef<HTMLDivElement>(null)
19
19
  const roRef = useRef<ResizeObserver | null>(null)
20
+ const moRef = useRef<MutationObserver | null>(null)
20
21
  const [thumbRatio, setThumbRatio] = useState(0)
21
22
 
22
23
  // Edge-fade state
@@ -94,6 +95,10 @@ export function useHorizontalScrollbar() {
94
95
  roRef.current.disconnect()
95
96
  roRef.current = null
96
97
  }
98
+ if (moRef.current) {
99
+ moRef.current.disconnect()
100
+ moRef.current = null
101
+ }
97
102
  if (rafIdRef.current) {
98
103
  cancelAnimationFrame(rafIdRef.current)
99
104
  rafIdRef.current = 0
@@ -105,8 +110,20 @@ export function useHorizontalScrollbar() {
105
110
  const ratio = node.clientWidth / node.scrollWidth
106
111
  setThumbRatio(ratio >= 1 ? 0 : ratio)
107
112
 
108
- roRef.current = new ResizeObserver(measure)
109
- roRef.current.observe(node)
113
+ const ro = new ResizeObserver(measure)
114
+ roRef.current = ro
115
+ const observeAll = () => {
116
+ ro.disconnect()
117
+ ro.observe(node)
118
+ for (const child of Array.from(node.children)) ro.observe(child)
119
+ }
120
+ observeAll()
121
+
122
+ moRef.current = new MutationObserver(() => {
123
+ observeAll()
124
+ measure()
125
+ })
126
+ moRef.current.observe(node, { childList: true })
110
127
 
111
128
  requestAnimationFrame(() => {
112
129
  syncThumbToDOM()
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ export interface UseNotificationPermissionResult {
6
+ /** Whether the page-context Notification API exists in this browser (false during SSR and on iOS Safari). */
7
+ supported: boolean
8
+ permission: NotificationPermission
9
+ /** Prompt the user for permission. Must be called from a user gesture or browsers will auto-deny. */
10
+ request: () => Promise<NotificationPermission>
11
+ }
12
+
13
+ /**
14
+ * Tracks the browser's desktop-notification permission, staying in sync when
15
+ * the user changes the site setting externally (Permissions API change event
16
+ * where available, visibility/focus re-read as the Safari fallback).
17
+ */
18
+ export function useNotificationPermission(): UseNotificationPermissionResult {
19
+ const [supported, setSupported] = useState(false)
20
+ const [permission, setPermission] = useState<NotificationPermission>('default')
21
+
22
+ useEffect(() => {
23
+ if (typeof window === 'undefined' || !('Notification' in window)) return
24
+ setSupported(true)
25
+ const sync = () => setPermission(Notification.permission)
26
+ sync()
27
+
28
+ // The query resolves async; without the flag it would attach the listener after unmount.
29
+ let cancelled = false
30
+ let status: PermissionStatus | undefined
31
+ navigator.permissions
32
+ ?.query({ name: 'notifications' as PermissionName })
33
+ .then((s) => {
34
+ if (cancelled) return
35
+ status = s
36
+ s.addEventListener('change', sync)
37
+ })
38
+ .catch(() => undefined)
39
+
40
+ document.addEventListener('visibilitychange', sync)
41
+ window.addEventListener('focus', sync)
42
+ return () => {
43
+ cancelled = true
44
+ status?.removeEventListener('change', sync)
45
+ document.removeEventListener('visibilitychange', sync)
46
+ window.removeEventListener('focus', sync)
47
+ }
48
+ }, [])
49
+
50
+ const request = useCallback(async () => {
51
+ if (typeof window === 'undefined' || !('Notification' in window)) {
52
+ return 'denied' as NotificationPermission
53
+ }
54
+ // Older Safari only implements the callback form; resolving from both is harmless.
55
+ const result = await new Promise<NotificationPermission>((resolve) => {
56
+ const maybePromise = Notification.requestPermission(resolve)
57
+ if (maybePromise && typeof maybePromise.then === 'function') void maybePromise.then(resolve)
58
+ })
59
+ setPermission(Notification.permission)
60
+ return result
61
+ }, [])
62
+
63
+ return { supported, permission, request }
64
+ }
@@ -3,6 +3,42 @@
3
3
  * Provides consistent date formatting across all components
4
4
  */
5
5
 
6
+ /**
7
+ * Relative display label for a ticket timestamp (ISO string).
8
+ * < 1 min → "Just now" | 1-59 min → "X min ago" | 1-23 h → "X hour(s) ago" | 24+ h → "MM/DD/YYYY"
9
+ */
10
+ export function formatTicketRelativeTime(iso: string): string {
11
+ const now = new Date()
12
+ const date = new Date(iso)
13
+ if (Number.isNaN(date.getTime())) return ''
14
+ const diffMs = now.getTime() - date.getTime()
15
+ const diffMin = Math.floor(diffMs / 60_000)
16
+ if (diffMin < 1) return 'Just now'
17
+ if (diffMin < 60) return `${diffMin} min ago`
18
+ const diffHours = Math.floor(diffMin / 60)
19
+ if (diffHours < 24) return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`
20
+ const mm = String(date.getMonth() + 1).padStart(2, '0')
21
+ const dd = String(date.getDate()).padStart(2, '0')
22
+ const yyyy = date.getFullYear()
23
+ return `${mm}/${dd}/${yyyy}`
24
+ }
25
+
26
+ /**
27
+ * Full tooltip timestamp in "MM/DD/YYYY, H:MM AM/PM" format.
28
+ */
29
+ export function formatTicketFullTimestamp(iso: string): string {
30
+ const date = new Date(iso)
31
+ if (Number.isNaN(date.getTime())) return ''
32
+ const mm = String(date.getMonth() + 1).padStart(2, '0')
33
+ const dd = String(date.getDate()).padStart(2, '0')
34
+ const yyyy = date.getFullYear()
35
+ let hours = date.getHours()
36
+ const minutes = String(date.getMinutes()).padStart(2, '0')
37
+ const ampm = hours >= 12 ? 'PM' : 'AM'
38
+ hours = hours % 12 || 12
39
+ return `${mm}/${dd}/${yyyy}, ${hours}:${minutes} ${ampm}`
40
+ }
41
+
6
42
  /**
7
43
  * Format relative time from ISO timestamp
8
44
  * This is the single source of truth for all relative time formatting
@@ -113,6 +113,8 @@ export {
113
113
  isToday,
114
114
  isWithinMinutes,
115
115
  createUTCTimestamp,
116
+ formatTicketRelativeTime,
117
+ formatTicketFullTimestamp,
116
118
  } from './date-utils'
117
119
 
118
120
  // Chat source-icons + labels (server-safe — lives here in src/utils/