@flamingo-stack/openframe-frontend-core 0.0.429 → 0.0.430

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 (108) hide show
  1. package/dist/{chunk-GMR5AA3C.js → chunk-2ELINEWQ.js} +2 -2
  2. package/dist/{chunk-YGITDP6O.cjs → chunk-374KFDET.cjs} +31 -31
  3. package/dist/{chunk-YGITDP6O.cjs.map → chunk-374KFDET.cjs.map} +1 -1
  4. package/dist/{chunk-4VODJPTB.js → chunk-3AWPRVFM.js} +2 -2
  5. package/dist/{chunk-RTOVYU4D.js → chunk-3OKVH6FW.js} +4 -4
  6. package/dist/{chunk-2WJ25MTA.js → chunk-3XWBE3SM.js} +4 -4
  7. package/dist/{chunk-TJ4HJ3KX.js → chunk-67LZDLIO.js} +2 -2
  8. package/dist/{chunk-7PA4B6SD.js → chunk-A6JKKKIF.js} +4 -4
  9. package/dist/{chunk-KHKB5RY2.js → chunk-AY5TBDWZ.js} +2 -2
  10. package/dist/{chunk-CRGH6TV6.cjs → chunk-CJOFXDTK.cjs} +26 -26
  11. package/dist/{chunk-CRGH6TV6.cjs.map → chunk-CJOFXDTK.cjs.map} +1 -1
  12. package/dist/{chunk-B3Y47C2U.cjs → chunk-CTY37UUO.cjs} +59 -59
  13. package/dist/{chunk-B3Y47C2U.cjs.map → chunk-CTY37UUO.cjs.map} +1 -1
  14. package/dist/{chunk-OLGFIDZY.cjs → chunk-D4K53CES.cjs} +26 -26
  15. package/dist/{chunk-OLGFIDZY.cjs.map → chunk-D4K53CES.cjs.map} +1 -1
  16. package/dist/{chunk-WXKFFVGC.cjs → chunk-FL4IOCYH.cjs} +5 -5
  17. package/dist/{chunk-WXKFFVGC.cjs.map → chunk-FL4IOCYH.cjs.map} +1 -1
  18. package/dist/{chunk-7MJTM6QT.cjs → chunk-FYMD7CCA.cjs} +7 -7
  19. package/dist/{chunk-7MJTM6QT.cjs.map → chunk-FYMD7CCA.cjs.map} +1 -1
  20. package/dist/{chunk-Y2TW664W.js → chunk-IMOJWQDR.js} +4 -4
  21. package/dist/{chunk-2LJA3ABH.cjs → chunk-KDVGC6EG.cjs} +37 -37
  22. package/dist/{chunk-2LJA3ABH.cjs.map → chunk-KDVGC6EG.cjs.map} +1 -1
  23. package/dist/{chunk-NYISC4Z3.js → chunk-NZBQGODM.js} +2 -2
  24. package/dist/{chunk-XDBD6BJN.cjs → chunk-PP5KNHMK.cjs} +318 -215
  25. package/dist/chunk-PP5KNHMK.cjs.map +1 -0
  26. package/dist/{chunk-E4PKZNR7.cjs → chunk-Q7SYPXUX.cjs} +11 -11
  27. package/dist/{chunk-E4PKZNR7.cjs.map → chunk-Q7SYPXUX.cjs.map} +1 -1
  28. package/dist/{chunk-MAYEJ3NY.js → chunk-S7UXICHB.js} +2 -2
  29. package/dist/{chunk-B3FBMRHQ.js → chunk-TDITU4KW.js} +2 -2
  30. package/dist/{chunk-62L7S7HQ.js → chunk-TYANQCZA.js} +269 -166
  31. package/dist/chunk-TYANQCZA.js.map +1 -0
  32. package/dist/{chunk-BN3V465H.cjs → chunk-WQN4B6CM.cjs} +15 -15
  33. package/dist/{chunk-BN3V465H.cjs.map → chunk-WQN4B6CM.cjs.map} +1 -1
  34. package/dist/{chunk-CCL6JEMS.cjs → chunk-WQR7H56P.cjs} +62 -62
  35. package/dist/{chunk-CCL6JEMS.cjs.map → chunk-WQR7H56P.cjs.map} +1 -1
  36. package/dist/{chunk-GGJUXN6Z.cjs → chunk-ZUP2ZKS6.cjs} +9 -9
  37. package/dist/{chunk-GGJUXN6Z.cjs.map → chunk-ZUP2ZKS6.cjs.map} +1 -1
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/hooks/index.d.ts +1 -0
  41. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  42. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +10 -1
  43. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  44. package/dist/components/chat/hooks/use-scripted-stream.d.ts +3 -0
  45. package/dist/components/chat/hooks/use-scripted-stream.d.ts.map +1 -0
  46. package/dist/components/chat/index.cjs +12 -2
  47. package/dist/components/chat/index.cjs.map +1 -1
  48. package/dist/components/chat/index.js +11 -1
  49. package/dist/components/chat/utils/index.d.ts +1 -0
  50. package/dist/components/chat/utils/index.d.ts.map +1 -1
  51. package/dist/components/chat/utils/scripted-stream.d.ts +38 -0
  52. package/dist/components/chat/utils/scripted-stream.d.ts.map +1 -0
  53. package/dist/components/contact/index.cjs +3 -3
  54. package/dist/components/contact/index.js +2 -2
  55. package/dist/components/docs/index.cjs +5 -5
  56. package/dist/components/docs/index.js +4 -4
  57. package/dist/components/embeds/index.cjs +3 -3
  58. package/dist/components/embeds/index.js +2 -2
  59. package/dist/components/faq/index.cjs +3 -3
  60. package/dist/components/faq/index.js +2 -2
  61. package/dist/components/features/board/index.d.ts +2 -2
  62. package/dist/components/features/board/index.d.ts.map +1 -1
  63. package/dist/components/features/board/ticket-card.d.ts +22 -2
  64. package/dist/components/features/board/ticket-card.d.ts.map +1 -1
  65. package/dist/components/features/index.cjs +6 -2
  66. package/dist/components/features/index.cjs.map +1 -1
  67. package/dist/components/features/index.js +5 -1
  68. package/dist/components/help-center-pages/index.cjs +20 -20
  69. package/dist/components/help-center-pages/index.js +11 -11
  70. package/dist/components/index.cjs +142 -128
  71. package/dist/components/index.cjs.map +1 -1
  72. package/dist/components/index.js +23 -9
  73. package/dist/components/index.js.map +1 -1
  74. package/dist/components/navigation/index.cjs +2 -2
  75. package/dist/components/navigation/index.js +1 -1
  76. package/dist/components/onboarding-guides/index.cjs +5 -5
  77. package/dist/components/onboarding-guides/index.js +4 -4
  78. package/dist/components/related-content/index.cjs +3 -3
  79. package/dist/components/related-content/index.js +2 -2
  80. package/dist/components/tickets/index.cjs +5 -5
  81. package/dist/components/tickets/index.js +4 -4
  82. package/dist/components/ui/index.cjs +12 -2
  83. package/dist/components/ui/index.cjs.map +1 -1
  84. package/dist/components/ui/index.js +11 -1
  85. package/dist/index.cjs +16 -2
  86. package/dist/index.cjs.map +1 -1
  87. package/dist/index.js +15 -1
  88. package/package.json +1 -1
  89. package/src/components/chat/hooks/index.ts +1 -0
  90. package/src/components/chat/hooks/use-nats-chat-adapter.ts +23 -0
  91. package/src/components/chat/hooks/use-scripted-stream.ts +69 -0
  92. package/src/components/chat/utils/index.ts +2 -0
  93. package/src/components/chat/utils/scripted-stream.ts +111 -0
  94. package/src/components/features/board/index.ts +2 -2
  95. package/src/components/features/board/ticket-card.tsx +108 -64
  96. package/dist/chunk-62L7S7HQ.js.map +0 -1
  97. package/dist/chunk-XDBD6BJN.cjs.map +0 -1
  98. /package/dist/{chunk-GMR5AA3C.js.map → chunk-2ELINEWQ.js.map} +0 -0
  99. /package/dist/{chunk-4VODJPTB.js.map → chunk-3AWPRVFM.js.map} +0 -0
  100. /package/dist/{chunk-RTOVYU4D.js.map → chunk-3OKVH6FW.js.map} +0 -0
  101. /package/dist/{chunk-2WJ25MTA.js.map → chunk-3XWBE3SM.js.map} +0 -0
  102. /package/dist/{chunk-TJ4HJ3KX.js.map → chunk-67LZDLIO.js.map} +0 -0
  103. /package/dist/{chunk-7PA4B6SD.js.map → chunk-A6JKKKIF.js.map} +0 -0
  104. /package/dist/{chunk-KHKB5RY2.js.map → chunk-AY5TBDWZ.js.map} +0 -0
  105. /package/dist/{chunk-Y2TW664W.js.map → chunk-IMOJWQDR.js.map} +0 -0
  106. /package/dist/{chunk-NYISC4Z3.js.map → chunk-NZBQGODM.js.map} +0 -0
  107. /package/dist/{chunk-MAYEJ3NY.js.map → chunk-S7UXICHB.js.map} +0 -0
  108. /package/dist/{chunk-B3FBMRHQ.js.map → chunk-TDITU4KW.js.map} +0 -0
@@ -0,0 +1,69 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Playback clock for {@link buildStreamFrames}: advance through pre-built
5
+ * scripted frames like a live chat stream — each frame holds for its own
6
+ * `delayMs`, then the next reveals, parking on the last. Surface-agnostic: the
7
+ * caller maps `frame.messages` to its own chat shape and reads `frame.phase` /
8
+ * `frame.typing`.
9
+ *
10
+ * `resetKey` restarts playback from the top when a NEW conversation is selected
11
+ * (identity change) — NOT when the SAME conversation grows (e.g. an Approve
12
+ * appends its continuation), which must resume from where it paused. Because the
13
+ * frame list's shared prefix is deterministic, keeping the index across a grow
14
+ * resumes seamlessly into the appended frames.
15
+ *
16
+ * `prefers-reduced-motion` (or `enabled === false`) jumps straight to the final
17
+ * frame (everything revealed, idle).
18
+ */
19
+
20
+ import { useEffect, useState } from 'react'
21
+ import type { StreamFrame } from '../utils/scripted-stream'
22
+
23
+ const EMPTY_FRAME: StreamFrame = { messages: [], phase: 'idle', typing: false, delayMs: 0 }
24
+
25
+ function prefersReducedMotion(): boolean {
26
+ return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
27
+ }
28
+
29
+ export function useScriptedStream(frames: StreamFrame[], resetKey: unknown, enabled = true): StreamFrame {
30
+ const [index, setIndex] = useState(0)
31
+ const [reducedMotion, setReducedMotion] = useState(false)
32
+
33
+ // Restart from the top on a new conversation, synchronously during render
34
+ // (React's "adjust state on prop change" pattern) so the previous stream's
35
+ // last frame never flashes before the reset effect runs.
36
+ const [prevKey, setPrevKey] = useState(resetKey)
37
+ if (resetKey !== prevKey) {
38
+ setPrevKey(resetKey)
39
+ setIndex(0)
40
+ }
41
+
42
+ useEffect(() => {
43
+ setReducedMotion(prefersReducedMotion())
44
+ const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)')
45
+ if (!mq) return
46
+ const onChange = () => setReducedMotion(mq.matches)
47
+ mq.addEventListener('change', onChange)
48
+ return () => mq.removeEventListener('change', onChange)
49
+ }, [])
50
+
51
+ const playing = enabled && !reducedMotion && frames.length > 0
52
+ const lastIndex = Math.max(0, frames.length - 1)
53
+
54
+ // Advance one frame after the current frame's hold. When `frames` grows (an
55
+ // Approve appends more), this effect re-runs and, if we were parked on the old
56
+ // last frame, schedules the next — resuming the stream. `frames[index]` is
57
+ // clamped because a shrink (a shorter new conversation before the reset lands)
58
+ // could momentarily leave `index` past the end.
59
+ useEffect(() => {
60
+ if (!playing || index >= lastIndex) return
61
+ const current = frames[Math.min(index, lastIndex)] ?? EMPTY_FRAME
62
+ const t = setTimeout(() => setIndex((i) => Math.min(i + 1, lastIndex)), current.delayMs)
63
+ return () => clearTimeout(t)
64
+ }, [index, frames, playing, lastIndex])
65
+
66
+ if (frames.length === 0) return EMPTY_FRAME
67
+ if (!playing) return frames[lastIndex]
68
+ return frames[Math.min(index, lastIndex)] ?? EMPTY_FRAME
69
+ }
@@ -198,3 +198,5 @@ export {
198
198
  resolveSourceRowCTA,
199
199
  resolveSourceIcon,
200
200
  } from './source-row-cta'
201
+
202
+ export * from './scripted-stream'
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Scripted-conversation replay — turn a stored `HistoricalMessage[]` into ordered
3
+ * "mock SSE" playback frames that animate a chat surface exactly like the live
4
+ * NATS/SSE stream (a `thinking` pause → assistant text token-by-token → tool /
5
+ * approval segments popping in → `idle`), just replayed from a script instead of
6
+ * a network stream.
7
+ *
8
+ * Lives in the lib (not a host) so any consumer — marketing hero demos, docs,
9
+ * onboarding tours — can drive a `previewMode` `EmbeddableChat` or a
10
+ * `ChatMessageList` from a canned conversation without re-implementing the
11
+ * streaming cadence. Pair with {@link useScriptedStream} (the playback clock).
12
+ */
13
+
14
+ import type { HistoricalMessage, MessageData, StreamingPhase } from '../types'
15
+
16
+ /** Normalize the single-or-array `messageData` to an array (empty when absent). */
17
+ export function normalizeHistoricalMessageData(md: HistoricalMessage['messageData']): MessageData[] {
18
+ return Array.isArray(md) ? md : md ? [md] : []
19
+ }
20
+
21
+ /** A message the user authored carries an `owner`; assistant turns have none —
22
+ * drives whether a turn streams (assistant) or lands at once (user). */
23
+ export function isHistoricalUserTurn(m: HistoricalMessage): boolean {
24
+ return !!m.owner
25
+ }
26
+
27
+ const isTextSegment = (d: MessageData): d is MessageData & { type: 'TEXT'; text: string } =>
28
+ (d as { type?: string })?.type === 'TEXT' && typeof (d as { text?: unknown }).text === 'string'
29
+
30
+ /** One rendered step of the mocked stream. */
31
+ export interface StreamFrame {
32
+ /** The partial message list to show at this step. */
33
+ messages: HistoricalMessage[]
34
+ phase: StreamingPhase
35
+ /** The chat "typing" indicator (up during the `thinking` pause). */
36
+ typing: boolean
37
+ /** Hold time (ms) before advancing to the next frame. */
38
+ delayMs: number
39
+ }
40
+
41
+ // Playback cadence. Assistant turns "think" then stream token-by-token; user
42
+ // turns land at once. Tuned to read like a real chat without dragging.
43
+ const THINK_MS = 550
44
+ const TOKEN_MS = 55 // per word revealed while streaming text
45
+ const USER_MS = 480 // dwell on a user turn before the reply starts thinking
46
+ const SEGMENT_MS = 340 // a tool / approval card pops in
47
+ const POST_MS = 240 // dwell after a completed assistant turn
48
+
49
+ /** Split text into tokens that KEEP surrounding whitespace, so re-joining a
50
+ * prefix reproduces the original spacing exactly (`"a b"` → `["a ", "b"]`). */
51
+ function tokenize(text: string): string[] {
52
+ return text.match(/\s*\S+\s*/g) ?? (text ? [text] : [])
53
+ }
54
+
55
+ /**
56
+ * Expand a message list into ordered playback frames reproducing the live
57
+ * stream: each user turn lands whole; each assistant turn first shows a
58
+ * `thinking` indicator, then streams its text token-by-token, then pops any
59
+ * tool / approval segments. Frame COUNT for a given prefix is deterministic, so
60
+ * a host that appends a continuation (e.g. after an Approve) resumes seamlessly
61
+ * from where it paused — the shared prefix's frames are unchanged.
62
+ */
63
+ export function buildStreamFrames(messages: HistoricalMessage[]): StreamFrame[] {
64
+ const frames: StreamFrame[] = []
65
+ const revealed: HistoricalMessage[] = []
66
+ const push = (msgs: HistoricalMessage[], phase: StreamingPhase, typing: boolean, delayMs: number) =>
67
+ frames.push({ messages: msgs.slice(), phase, typing, delayMs })
68
+
69
+ messages.forEach((msg) => {
70
+ if (isHistoricalUserTurn(msg)) {
71
+ revealed.push(msg)
72
+ push(revealed, 'idle', false, USER_MS)
73
+ return
74
+ }
75
+
76
+ // Assistant turn: a `thinking` pause, then stream this turn's segments in
77
+ // order. The pause is emitted ONLY when there is already content to show
78
+ // (`revealed.length > 0`) — an empty first frame would hand the surface a
79
+ // zero-message list, flashing its empty state before the opening line.
80
+ if (revealed.length > 0) push(revealed, 'thinking', true, THINK_MS)
81
+
82
+ const segs = normalizeHistoricalMessageData(msg.messageData)
83
+ const built: MessageData[] = []
84
+ const emit = () => push([...revealed, { ...msg, messageData: [...built] }], 'streaming', false, 0)
85
+
86
+ segs.forEach((seg) => {
87
+ if (isTextSegment(seg)) {
88
+ const tokens = tokenize(seg.text)
89
+ let acc = ''
90
+ built.push({ ...seg, text: '' })
91
+ tokens.forEach((tok) => {
92
+ acc += tok
93
+ built[built.length - 1] = { ...seg, text: acc }
94
+ push([...revealed, { ...msg, messageData: [...built] }], 'streaming', false, TOKEN_MS)
95
+ })
96
+ built[built.length - 1] = { ...seg }
97
+ } else {
98
+ // Tool / approval segment: pops in fully.
99
+ built.push(seg)
100
+ emit()
101
+ frames[frames.length - 1].delayMs = SEGMENT_MS
102
+ }
103
+ })
104
+
105
+ revealed.push(msg)
106
+ push(revealed, 'idle', false, POST_MS)
107
+ })
108
+
109
+ if (frames.length === 0) push(revealed, 'idle', false, 0)
110
+ return frames
111
+ }
@@ -6,8 +6,8 @@ export { BoardColumn } from './board-column'
6
6
  export type { BoardColumnProps } from './board-column'
7
7
  export { BoardColumnHeader } from './board-column-header'
8
8
  export type { BoardColumnHeaderProps } from './board-column-header'
9
- export { TicketCard } from './ticket-card'
10
- export type { TicketCardProps } from './ticket-card'
9
+ export { TicketCard, TicketCardView, TicketCardBody } from './ticket-card'
10
+ export type { TicketCardProps, TicketCardViewProps, TicketCardBodyProps } from './ticket-card'
11
11
  export { BoardTicketApproval } from './board-ticket-approval'
12
12
  export type { BoardTicketApprovalProps } from './board-ticket-approval'
13
13
  export { TicketCardSkeleton } from './ticket-card-skeleton'
@@ -4,7 +4,7 @@ import { useSortable } from '@dnd-kit/sortable'
4
4
  import { CSS } from '@dnd-kit/utilities'
5
5
  import Link from '../../../embed-shims/next-link'
6
6
  import * as React from 'react'
7
- import { LaptopIcon, Flag02Icon, MessagesIcon, CheckCircleIcon } from '../../icons-v2-generated'
7
+ import { LaptopIcon, Flag02Icon, MessagesIcon } from '../../icons-v2-generated'
8
8
  import { SquareAvatar } from '../../ui/square-avatar'
9
9
  import { Tag } from '../../ui/tag'
10
10
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'
@@ -24,64 +24,36 @@ const PRIORITY_COLOR_CLASS: Record<BoardPriority, string> = {
24
24
  const MAX_VISIBLE_TAGS = 2
25
25
  const MAX_VISIBLE_ASSIGNEES = 3
26
26
 
27
- export interface TicketCardProps {
27
+ /** Shared card shell (border / padding / bg). Same footprint for the draggable
28
+ * board card and the static {@link TicketCardView}. */
29
+ const TICKET_CARD_SHELL =
30
+ 'relative flex flex-col gap-[var(--spacing-system-sf)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-sf)] select-none text-left'
31
+
32
+ // =============================================================================
33
+ // Presentational body — the ONE ticket-card design, no drag/link/board context.
34
+ // Reused by the draggable `TicketCard` AND the static `TicketCardView`.
35
+ // =============================================================================
36
+
37
+ export interface TicketCardBodyProps {
28
38
  ticket: BoardTicket
29
- columnId: string
30
39
  columnColor?: string
31
- href?: string
32
- isOverlay?: boolean
33
- dragDisabled?: boolean
34
- approved?: boolean
35
40
  renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
36
- onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
37
- onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
41
+ /** Approval callbacks receive the request id directly (the draggable
42
+ * `TicketCard` adapts its own `(ticketId, requestId)` signature onto these). */
43
+ onApprove?: (requestId?: string) => void | Promise<void>
44
+ onReject?: (requestId?: string) => void | Promise<void>
38
45
  }
39
46
 
40
- export function TicketCard({
41
- ticket,
42
- columnId,
43
- columnColor,
44
- href,
45
- isOverlay = false,
46
- dragDisabled,
47
- approved = false,
48
- renderAssignSlot,
49
- onApprove,
50
- onReject,
51
- }: TicketCardProps) {
52
- const sortableData = React.useMemo(
53
- () => ({ columnId, type: 'ticket' as const }),
54
- [columnId],
55
- )
56
- const sortable = useSortable({
57
- id: ticket.id,
58
- data: sortableData,
59
- disabled: dragDisabled,
60
- })
61
-
47
+ /** The card's inner content: title + device/org, priority + assignees, tags,
48
+ * timestamp, "New Message", and the approval row. Pure — driven only by props. */
49
+ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprove, onReject }: TicketCardBodyProps) {
62
50
  const showNewMessage = !!ticket.hasNewMessage && !!columnColor
63
51
  const newMessageTextColor = columnColor ? getReadableTextColor(columnColor) : undefined
64
52
 
65
- const style: React.CSSProperties = isOverlay
66
- ? {}
67
- : {
68
- transform: CSS.Transform.toString(sortable.transform),
69
- transition: sortable.transition,
70
- }
71
- if (showNewMessage) style.borderColor = columnColor
72
-
73
53
  const showDeviceRow = !!(ticket.deviceHostnames?.length || ticket.organizationName)
74
- const deviceText = [
75
- ticket.deviceHostnames?.join(', '),
76
- ticket.organizationName,
77
- ].filter(Boolean).join(', ')
78
-
79
- const handleClick = (e: React.MouseEvent) => {
80
- if (sortable.isDragging) e.preventDefault()
81
- }
82
-
83
- const hasRightSection = !!(approved || ticket.priority || ticket.assignees?.length || renderAssignSlot)
54
+ const deviceText = [ticket.deviceHostnames?.join(', '), ticket.organizationName].filter(Boolean).join(', ')
84
55
 
56
+ const hasRightSection = !!(ticket.priority || ticket.assignees?.length || renderAssignSlot)
85
57
  const rightSection = hasRightSection ? (
86
58
  <div className="pointer-events-auto flex shrink-0 items-center gap-[var(--spacing-system-xsf)]">
87
59
  {ticket.priority && (
@@ -90,14 +62,6 @@ export function TicketCard({
90
62
  aria-label={`Priority: ${ticket.priority}`}
91
63
  />
92
64
  )}
93
- {approved && (
94
- <CheckCircleIcon
95
- size={20}
96
- color="var(--ods-attention-green-success)"
97
- className="shrink-0"
98
- aria-label="Approved"
99
- />
100
- )}
101
65
  {renderAssignSlot ? (
102
66
  renderAssignSlot(ticket)
103
67
  ) : ticket.assignees?.length ? (
@@ -125,7 +89,7 @@ export function TicketCard({
125
89
  const timestampLabel = ticket.createdAt ? formatTicketRelativeTime(ticket.createdAt) : null
126
90
  const tooltipLabel = ticket.createdAt ? formatTicketFullTimestamp(ticket.createdAt) : null
127
91
 
128
- const body = (
92
+ return (
129
93
  <>
130
94
  <div className="flex items-start gap-[var(--spacing-system-sf)]">
131
95
  <div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-zero)]" title={ticket.title}>
@@ -159,17 +123,97 @@ export function TicketCard({
159
123
  />
160
124
  )}
161
125
  {ticket.pendingApproval && (
162
- <BoardTicketApproval
163
- pendingApproval={ticket.pendingApproval}
164
- onApprove={onApprove ? requestId => onApprove(ticket.id, requestId) : undefined}
165
- onReject={onReject ? requestId => onReject(ticket.id, requestId) : undefined}
166
- />
126
+ <BoardTicketApproval pendingApproval={ticket.pendingApproval} onApprove={onApprove} onReject={onReject} />
167
127
  )}
168
128
  </>
169
129
  )
130
+ }
131
+
132
+ // =============================================================================
133
+ // Static card — the real board card design, rendered from props with NO
134
+ // drag-and-drop / board context (for embeds, marketing heroes, previews).
135
+ // =============================================================================
136
+
137
+ export interface TicketCardViewProps extends TicketCardBodyProps {
138
+ className?: string
139
+ }
140
+
141
+ /**
142
+ * The exact board `TicketCard` visual, standalone — same shell + `TicketCardBody`
143
+ * as the draggable card, but without the `useSortable` dnd-kit dependency, so it
144
+ * renders anywhere from static props. Use this outside a `<Board>`.
145
+ */
146
+ export function TicketCardView({ className, ...bodyProps }: TicketCardViewProps) {
147
+ const { ticket, columnColor } = bodyProps
148
+ const showNewMessage = !!ticket.hasNewMessage && !!columnColor
149
+ return (
150
+ <div
151
+ className={cn(TICKET_CARD_SHELL, className)}
152
+ style={showNewMessage ? { borderColor: columnColor } : undefined}
153
+ >
154
+ <div className="relative z-10 flex flex-col gap-[var(--spacing-system-sf)]">
155
+ <TicketCardBody {...bodyProps} />
156
+ </div>
157
+ </div>
158
+ )
159
+ }
160
+
161
+ // =============================================================================
162
+ // Draggable board card
163
+ // =============================================================================
164
+
165
+ export interface TicketCardProps {
166
+ ticket: BoardTicket
167
+ columnId: string
168
+ columnColor?: string
169
+ href?: string
170
+ isOverlay?: boolean
171
+ dragDisabled?: boolean
172
+ renderAssignSlot?: (ticket: BoardTicket) => React.ReactNode
173
+ onApprove?: (ticketId: string, requestId?: string) => void | Promise<void>
174
+ onReject?: (ticketId: string, requestId?: string) => void | Promise<void>
175
+ }
176
+
177
+ export function TicketCard({
178
+ ticket,
179
+ columnId,
180
+ columnColor,
181
+ href,
182
+ isOverlay = false,
183
+ dragDisabled,
184
+ renderAssignSlot,
185
+ onApprove,
186
+ onReject,
187
+ }: TicketCardProps) {
188
+ const sortableData = React.useMemo(() => ({ columnId, type: 'ticket' as const }), [columnId])
189
+ const sortable = useSortable({ id: ticket.id, data: sortableData, disabled: dragDisabled })
190
+
191
+ const showNewMessage = !!ticket.hasNewMessage && !!columnColor
192
+
193
+ const style: React.CSSProperties = isOverlay
194
+ ? {}
195
+ : {
196
+ transform: CSS.Transform.toString(sortable.transform),
197
+ transition: sortable.transition,
198
+ }
199
+ if (showNewMessage) style.borderColor = columnColor
200
+
201
+ const handleClick = (e: React.MouseEvent) => {
202
+ if (sortable.isDragging) e.preventDefault()
203
+ }
204
+
205
+ const body = (
206
+ <TicketCardBody
207
+ ticket={ticket}
208
+ columnColor={columnColor}
209
+ renderAssignSlot={renderAssignSlot}
210
+ onApprove={onApprove ? requestId => onApprove(ticket.id, requestId) : undefined}
211
+ onReject={onReject ? requestId => onReject(ticket.id, requestId) : undefined}
212
+ />
213
+ )
170
214
 
171
215
  const cardClasses = cn(
172
- 'relative flex flex-col gap-[var(--spacing-system-sf)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-sf)] select-none text-left',
216
+ TICKET_CARD_SHELL,
173
217
  !dragDisabled && 'cursor-pointer',
174
218
  sortable.isDragging && !isOverlay && 'opacity-40',
175
219
  isOverlay && 'rotate-1 shadow-card-hover',