@flamingo-stack/openframe-frontend-core 0.0.455 → 0.0.456

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 (122) hide show
  1. package/dist/{chunk-DGPKGESY.js → chunk-2I3GN7WW.js} +4 -4
  2. package/dist/{chunk-NNDNJCJ3.cjs → chunk-6K6LAD6H.cjs} +18 -18
  3. package/dist/{chunk-NNDNJCJ3.cjs.map → chunk-6K6LAD6H.cjs.map} +1 -1
  4. package/dist/{chunk-RT7DIGTN.cjs → chunk-6TI3DCOG.cjs} +3 -3
  5. package/dist/{chunk-RT7DIGTN.cjs.map → chunk-6TI3DCOG.cjs.map} +1 -1
  6. package/dist/{chunk-YSKSBLCG.cjs → chunk-A7FTNFBX.cjs} +73 -73
  7. package/dist/{chunk-YSKSBLCG.cjs.map → chunk-A7FTNFBX.cjs.map} +1 -1
  8. package/dist/{chunk-XAQLBUCP.js → chunk-DAEKUR3J.js} +2 -2
  9. package/dist/{chunk-QSIZW6EX.js → chunk-DU6R7XNU.js} +2 -2
  10. package/dist/{chunk-D5PBUVGB.cjs → chunk-DYBATZJP.cjs} +5 -5
  11. package/dist/{chunk-D5PBUVGB.cjs.map → chunk-DYBATZJP.cjs.map} +1 -1
  12. package/dist/{chunk-IDCPYG7N.cjs → chunk-F2CCUPKC.cjs} +29 -29
  13. package/dist/{chunk-IDCPYG7N.cjs.map → chunk-F2CCUPKC.cjs.map} +1 -1
  14. package/dist/{chunk-EGTGRG7N.js → chunk-F3IBBAKV.js} +2 -2
  15. package/dist/{chunk-M5RT26WQ.cjs → chunk-GC63K4VD.cjs} +73 -73
  16. package/dist/{chunk-M5RT26WQ.cjs.map → chunk-GC63K4VD.cjs.map} +1 -1
  17. package/dist/{chunk-RZL7I6YN.cjs → chunk-GQZGCMRF.cjs} +7 -7
  18. package/dist/{chunk-RZL7I6YN.cjs.map → chunk-GQZGCMRF.cjs.map} +1 -1
  19. package/dist/{chunk-X62ICFPJ.js → chunk-ILUX3DXS.js} +2 -2
  20. package/dist/{chunk-XT6S6DYS.cjs → chunk-INH6SIP5.cjs} +12 -12
  21. package/dist/{chunk-XT6S6DYS.cjs.map → chunk-INH6SIP5.cjs.map} +1 -1
  22. package/dist/{chunk-KTGGM5WA.cjs → chunk-IOHQJOLR.cjs} +36 -12
  23. package/dist/chunk-IOHQJOLR.cjs.map +1 -0
  24. package/dist/{chunk-6W4ZEVIC.js → chunk-JHIT44TA.js} +3 -3
  25. package/dist/{chunk-BE77G7GW.cjs → chunk-KH5VTGCS.cjs} +40 -40
  26. package/dist/{chunk-BE77G7GW.cjs.map → chunk-KH5VTGCS.cjs.map} +1 -1
  27. package/dist/{chunk-YXCYQKAX.js → chunk-LAFCXJVT.js} +5 -5
  28. package/dist/{chunk-5YPKZM6Y.js → chunk-LMBPJAQ2.js} +6 -6
  29. package/dist/{chunk-BD3JBEUC.js → chunk-MK2YWJAA.js} +64 -40
  30. package/dist/chunk-MK2YWJAA.js.map +1 -0
  31. package/dist/{chunk-UJZ6O2ON.cjs → chunk-TXS2AMFV.cjs} +32 -32
  32. package/dist/{chunk-UJZ6O2ON.cjs.map → chunk-TXS2AMFV.cjs.map} +1 -1
  33. package/dist/{chunk-LB3OZE4B.js → chunk-U27TXTAK.js} +6 -6
  34. package/dist/{chunk-N4CHA7T7.js → chunk-UEKCD6WV.js} +131 -16
  35. package/dist/chunk-UEKCD6WV.js.map +1 -0
  36. package/dist/{chunk-FCVF4YAB.cjs → chunk-UHVVNVUE.cjs} +40 -40
  37. package/dist/{chunk-FCVF4YAB.cjs.map → chunk-UHVVNVUE.cjs.map} +1 -1
  38. package/dist/{chunk-2KUIA2AR.js → chunk-UXKSOJR7.js} +3 -3
  39. package/dist/{chunk-GNYV6HYO.cjs → chunk-VZNJ3RYI.cjs} +15 -15
  40. package/dist/{chunk-GNYV6HYO.cjs.map → chunk-VZNJ3RYI.cjs.map} +1 -1
  41. package/dist/{chunk-JUUPMMKI.js → chunk-YLTY4Z4B.js} +3 -3
  42. package/dist/{chunk-62AXZKYN.cjs → chunk-YMGOTPY7.cjs} +981 -866
  43. package/dist/chunk-YMGOTPY7.cjs.map +1 -0
  44. package/dist/{chunk-VNE5R4LQ.js → chunk-ZQHGSR3H.js} +5 -5
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  48. package/dist/components/chat/guide-welcome.d.ts.map +1 -1
  49. package/dist/components/chat/index.cjs +3 -3
  50. package/dist/components/chat/index.js +2 -2
  51. package/dist/components/chat/mingo-welcome.d.ts.map +1 -1
  52. package/dist/components/chat/quick-action-wall.d.ts +5 -1
  53. package/dist/components/chat/quick-action-wall.d.ts.map +1 -1
  54. package/dist/components/chat/types/component.types.d.ts +17 -0
  55. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  56. package/dist/components/contact/index.cjs +4 -4
  57. package/dist/components/contact/index.js +3 -3
  58. package/dist/components/docs/index.cjs +6 -6
  59. package/dist/components/docs/index.js +5 -5
  60. package/dist/components/embeds/index.cjs +4 -4
  61. package/dist/components/embeds/index.js +3 -3
  62. package/dist/components/faq/index.cjs +5 -5
  63. package/dist/components/faq/index.js +4 -4
  64. package/dist/components/features/cards-strip.d.ts.map +1 -1
  65. package/dist/components/features/index.cjs +3 -3
  66. package/dist/components/features/index.js +2 -2
  67. package/dist/components/help-center-pages/index.cjs +22 -22
  68. package/dist/components/help-center-pages/index.js +13 -13
  69. package/dist/components/index.cjs +135 -135
  70. package/dist/components/index.js +11 -11
  71. package/dist/components/navigation/index.cjs +3 -3
  72. package/dist/components/navigation/index.js +2 -2
  73. package/dist/components/onboarding-guides/index.cjs +7 -7
  74. package/dist/components/onboarding-guides/index.js +6 -6
  75. package/dist/components/related-content/index.cjs +5 -5
  76. package/dist/components/related-content/index.js +4 -4
  77. package/dist/components/tickets/index.cjs +6 -6
  78. package/dist/components/tickets/index.js +5 -5
  79. package/dist/components/ui/index.cjs +3 -3
  80. package/dist/components/ui/index.js +2 -2
  81. package/dist/components/ui/marquee-wall.d.ts +8 -1
  82. package/dist/components/ui/marquee-wall.d.ts.map +1 -1
  83. package/dist/hooks/index.cjs +4 -2
  84. package/dist/hooks/index.cjs.map +1 -1
  85. package/dist/hooks/index.js +3 -1
  86. package/dist/hooks/ui/index.d.ts +1 -0
  87. package/dist/hooks/ui/index.d.ts.map +1 -1
  88. package/dist/hooks/ui/use-reset-on-page-hidden.d.ts +19 -0
  89. package/dist/hooks/ui/use-reset-on-page-hidden.d.ts.map +1 -0
  90. package/dist/hooks/use-near-viewport.d.ts +3 -1
  91. package/dist/hooks/use-near-viewport.d.ts.map +1 -1
  92. package/dist/index.cjs +5 -3
  93. package/dist/index.cjs.map +1 -1
  94. package/dist/index.js +4 -2
  95. package/package.json +1 -1
  96. package/src/components/chat/chat-message-list.tsx +48 -5
  97. package/src/components/chat/embeddable-chat.tsx +31 -3
  98. package/src/components/chat/guide-welcome.tsx +1 -0
  99. package/src/components/chat/mingo-welcome.tsx +1 -0
  100. package/src/components/chat/quick-action-wall.tsx +7 -0
  101. package/src/components/chat/types/component.types.ts +17 -0
  102. package/src/components/features/cards-strip.tsx +16 -0
  103. package/src/components/ui/marquee-wall.tsx +160 -10
  104. package/src/hooks/ui/index.ts +1 -0
  105. package/src/hooks/ui/use-reset-on-page-hidden.ts +32 -0
  106. package/src/hooks/use-near-viewport.ts +36 -12
  107. package/dist/chunk-62AXZKYN.cjs.map +0 -1
  108. package/dist/chunk-BD3JBEUC.js.map +0 -1
  109. package/dist/chunk-KTGGM5WA.cjs.map +0 -1
  110. package/dist/chunk-N4CHA7T7.js.map +0 -1
  111. /package/dist/{chunk-DGPKGESY.js.map → chunk-2I3GN7WW.js.map} +0 -0
  112. /package/dist/{chunk-XAQLBUCP.js.map → chunk-DAEKUR3J.js.map} +0 -0
  113. /package/dist/{chunk-QSIZW6EX.js.map → chunk-DU6R7XNU.js.map} +0 -0
  114. /package/dist/{chunk-EGTGRG7N.js.map → chunk-F3IBBAKV.js.map} +0 -0
  115. /package/dist/{chunk-X62ICFPJ.js.map → chunk-ILUX3DXS.js.map} +0 -0
  116. /package/dist/{chunk-6W4ZEVIC.js.map → chunk-JHIT44TA.js.map} +0 -0
  117. /package/dist/{chunk-YXCYQKAX.js.map → chunk-LAFCXJVT.js.map} +0 -0
  118. /package/dist/{chunk-5YPKZM6Y.js.map → chunk-LMBPJAQ2.js.map} +0 -0
  119. /package/dist/{chunk-LB3OZE4B.js.map → chunk-U27TXTAK.js.map} +0 -0
  120. /package/dist/{chunk-2KUIA2AR.js.map → chunk-UXKSOJR7.js.map} +0 -0
  121. /package/dist/{chunk-JUUPMMKI.js.map → chunk-YLTY4Z4B.js.map} +0 -0
  122. /package/dist/{chunk-VNE5R4LQ.js.map → chunk-ZQHGSR3H.js.map} +0 -0
@@ -96,6 +96,8 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
96
96
  isLoading = false,
97
97
  isTyping = false,
98
98
  autoScroll = true,
99
+ overscrollContain = true,
100
+ pinBottom = false,
99
101
  showAvatars = true,
100
102
  fullWidth = false,
101
103
  contentClassName,
@@ -254,6 +256,42 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
254
256
  }
255
257
  }, [autoScroll, messages, dialogId, scrollToBottom, contentRef])
256
258
 
259
+ // ---- Passive-demo hard pin (pinBottom) ---------------------------
260
+ // Scripted in-page replays (the Fae/Mingo demos) have no human at the
261
+ // keyboard, so the newest streamed content must ALWAYS sit at the
262
+ // bottom. `useStickToBottom`'s smart follow only tracks while the
263
+ // viewer is "at bottom", which is unreliable for an assistant-only
264
+ // stream from a cold, non-bottom mount (the exact bug where the last
265
+ // reply landed below the fold). When `pinBottom` is set we snap the
266
+ // scroller to its full height — the SAME mechanism for BOTH demo
267
+ // surfaces, so Fae and Mingo behave 1:1.
268
+ //
269
+ // CRUCIAL: only re-pin when the content HEIGHT actually changed (a
270
+ // streamed token appended, or the conversation swapped/reset). A parent
271
+ // that recreates props on every render — EmbeddableChat in previewMode
272
+ // rebuilds its scripted state each render, churning the `messages` array
273
+ // identity — would otherwise fire this effect on every re-render and snap
274
+ // the scroller back to the bottom, trapping the visitor and blocking
275
+ // internal scroll (Mingo couldn't scroll while Fae, with stable memoized
276
+ // props, could). Skipping the pin when the height is UNCHANGED means an
277
+ // idle re-render leaves the scroller alone, so once the stream settles the
278
+ // visitor can scroll up in BOTH surfaces; a real growth (streaming) or a
279
+ // shrink (a new conversation) still re-pins to the fresh bottom.
280
+ // `useLayoutEffect` so the write lands before paint (no visible jump).
281
+ // Pair with `autoScroll={false}` so the library's spring doesn't fight.
282
+ const pinPrevHeightRef = useRef(0)
283
+ useLayoutEffect(() => {
284
+ if (!pinBottom) {
285
+ pinPrevHeightRef.current = 0
286
+ return
287
+ }
288
+ const el = scrollRef.current
289
+ if (!el) return
290
+ if (el.scrollHeight === pinPrevHeightRef.current) return
291
+ pinPrevHeightRef.current = el.scrollHeight
292
+ el.scrollTop = el.scrollHeight
293
+ }, [pinBottom, messages, isTyping, pendingApprovals, scrollRef])
294
+
257
295
  // ---- Prepend anchoring (load-older) ------------------------------
258
296
  // The library doesn't preserve user position when content grows ABOVE
259
297
  // the viewport. We do: from the pre-load geometry snapshot (taken in the
@@ -494,11 +532,16 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
494
532
  <div
495
533
  ref={setScrollRef}
496
534
  className={cn(
497
- // `overscroll-contain`: reaching the top/bottom of the thread must
498
- // NOT chain the wheel/touch scroll to the page behind the chat —
499
- // e.g. the company-hub deck (native body scroll + sticky slide
500
- // panels) would otherwise advance slides while you scroll the chat.
501
- "flex h-full w-full flex-col overflow-y-auto overflow-x-hidden overscroll-contain flex-1",
535
+ "flex h-full w-full flex-col overflow-y-auto overflow-x-hidden flex-1",
536
+ // `overscroll-contain` (default ON, opt-out via `overscrollContain={false}`):
537
+ // reaching the top/bottom of the thread must NOT chain the wheel/
538
+ // touch scroll to the page behind the chat — e.g. the company-hub
539
+ // deck (native body scroll + sticky slide panels) would otherwise
540
+ // advance slides while you scroll the chat (fixed in #1501; kept as
541
+ // the prop default here). Passive in-page DEMO chats disable it so
542
+ // the surrounding page keeps scrolling normally when the pointer is
543
+ // over the (non-interactive) thread.
544
+ overscrollContain && "overscroll-contain",
502
545
  "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
503
546
  className,
504
547
  )}
@@ -2264,7 +2264,19 @@ function EmbeddableChatInner({
2264
2264
  <ChatMessageList
2265
2265
  messages={messages}
2266
2266
  isTyping={chatLoading}
2267
- autoScroll={true}
2267
+ // Real drawer: the library's smart follow. Passive in-page
2268
+ // demo (previewMode): deterministic hard pin instead — a
2269
+ // scripted assistant-only stream from a cold mount never
2270
+ // satisfies the library's "at bottom" gate, so the reply
2271
+ // landed below the fold. `pinBottom` snaps to bottom on
2272
+ // every frame; identical mechanism to the Fae demo box, so
2273
+ // both surfaces behave 1:1.
2274
+ autoScroll={!previewMode}
2275
+ pinBottom={previewMode}
2276
+ // Passive in-page demos (previewMode) let the surrounding
2277
+ // page scroll over the thread; the real drawer keeps
2278
+ // containment (deck slide-scroll fix).
2279
+ overscrollContain={!previewMode}
2268
2280
  assistantType="mingo"
2269
2281
  assistantIcon={mingoAssistantIcon}
2270
2282
  renderEntityCard={renderEntityCard}
@@ -2272,11 +2284,27 @@ function EmbeddableChatInner({
2272
2284
  renderContextItem={renderContextItem}
2273
2285
  renderMention={renderMention}
2274
2286
  NavLinkAnchor={NavLinkAnchorViaRuntime}
2275
- // Hide the message-list scrollbar for the Mingo panel
2287
+ // Real Mingo drawer: hide the message-list scrollbar
2276
2288
  // (scroll stays functional). Scoped here via `className`
2277
2289
  // instead of `ChatMessageList` itself, so other list
2278
2290
  // consumers (host chat, tickets) keep their thin bar.
2279
- className="flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
2291
+ //
2292
+ // `previewMode` (the passive in-page demo) instead:
2293
+ // • KEEP the default thin scrollbar — the "elevator" —
2294
+ // so the Mingo demo's scroll controls match the Fae
2295
+ // demo box (which renders the default ChatMessageList
2296
+ // scrollbar) 1:1.
2297
+ // • Re-enable pointer events on the scroller: previewMode
2298
+ // puts `pointer-events-none` on the whole panel (line
2299
+ // ~1981) so the demo doesn't trap clicks, but that also
2300
+ // killed wheel/touch scroll on the thread (the Mingo
2301
+ // embed couldn't scroll while the Fae box could). Only
2302
+ // the scroller re-enables; composer + chrome stay inert.
2303
+ className={
2304
+ previewMode
2305
+ ? 'flex-1 pointer-events-auto'
2306
+ : 'flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
2307
+ }
2280
2308
  // No inner `px`/`pb`: the panel wrapper already pads with
2281
2309
  // `p-[var(--spacing-system-m)]`. The default content class
2282
2310
  // adds `px-[var(--spacing-system-m)]` + `pb-…xs`, which
@@ -207,6 +207,7 @@ export function GuideWelcome({
207
207
  chips={chipItems}
208
208
  rows={4}
209
209
  pauseOnHover
210
+ dragScroll
210
211
  fade={['left', 'right']}
211
212
  fadeSize={{ left: 32 }}
212
213
  fadeColor="var(--color-bg)"
@@ -339,6 +339,7 @@ export function MingoWelcome({
339
339
  }))}
340
340
  rows={4}
341
341
  pauseOnHover
342
+ dragScroll
342
343
  fade={['left', 'right']}
343
344
  fadeSize={{ left: 32 }}
344
345
  fadeColor="var(--color-bg)"
@@ -71,6 +71,10 @@ export interface QuickActionWallProps {
71
71
  /** Marquee on/off (auto-off when content fits / reduced motion / loading). */
72
72
  animate?: boolean
73
73
  pauseOnHover?: boolean
74
+ /** Opt-in manual drag + wheel scroll (forwarded to {@link MarqueeWall}) — the
75
+ * auto-marquee pauses while the user drags/wheels and resumes from there.
76
+ * Enabled on the embeddable-chat quick-action walls. */
77
+ dragScroll?: boolean
74
78
  fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
75
79
  /** Color behind the wall — the fades blend into it. */
76
80
  fadeColor?: string
@@ -139,6 +143,7 @@ export function QuickActionWall({
139
143
  speed,
140
144
  animate = true,
141
145
  pauseOnHover,
146
+ dragScroll = false,
142
147
  fade,
143
148
  fadeColor,
144
149
  fadeSize,
@@ -206,6 +211,7 @@ export function QuickActionWall({
206
211
  speed={speed}
207
212
  animate={animate && !loading}
208
213
  pauseOnHover={pauseOnHover}
214
+ dragScroll={dragScroll}
209
215
  copyGap={gap}
210
216
  contentClassName={cn('flex items-center', contentClassName)}
211
217
  contentStyle={{ gap }}
@@ -229,6 +235,7 @@ export function QuickActionWall({
229
235
  // Skeleton walls stay put — the marquee starts when real chips land.
230
236
  animate={animate && !loading}
231
237
  pauseOnHover={pauseOnHover}
238
+ dragScroll={dragScroll}
232
239
  fade={fade}
233
240
  fadeColor={fadeColor}
234
241
  fadeSize={fadeSize}
@@ -222,6 +222,23 @@ export interface ChatMessageListProps extends HTMLAttributes<HTMLDivElement> {
222
222
  typingMessage?: string
223
223
  smoothScroll?: boolean
224
224
  autoScroll?: boolean
225
+ /** `overscroll-behavior: contain` on the scroller (default true) — stops
226
+ * wheel/touch scroll from chaining to the page at the thread's edges (the
227
+ * deck / drawer need this). Passive in-page demo chats set `false` so the
228
+ * page keeps scrolling normally over the non-interactive thread. */
229
+ overscrollContain?: boolean
230
+ /** Deterministic stick-to-bottom for PASSIVE scripted replays (the in-page
231
+ * Fae/Mingo demos). Whenever the scroll HEIGHT changes — a streamed token
232
+ * grows the thread, or a new conversation replaces it — the scroller is
233
+ * hard-snapped to its full height, so the newest streamed line is always
234
+ * visible even though no human is at the keyboard. Unlike `autoScroll` (the
235
+ * library's smart follow, which only tracks while the viewer is "at bottom"
236
+ * and is unreliable for an assistant-only stream from a cold, non-bottom
237
+ * mount), this pins on real growth without that gate. A same-height idle
238
+ * re-render does NOT re-pin, so once the stream settles the viewer can freely
239
+ * scroll up. Pair with `autoScroll={false}` so the library's spring doesn't
240
+ * run in parallel. */
241
+ pinBottom?: boolean
225
242
  showAvatars?: boolean
226
243
  /** Same `fullWidth` semantics as `ChatHeaderProps.fullWidth` — drops
227
244
  * the inner content wrapper's `max-w-ods-content-narrow` so messages
@@ -45,6 +45,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
45
45
  import { cn } from '../../utils/cn';
46
46
  import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
47
47
  import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine';
48
+ import { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden';
48
49
  import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus';
49
50
  import { Button } from '../ui/button';
50
51
  import { SECTION_HEADING_CLASS } from '../layout/page-heading';
@@ -424,6 +425,20 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
424
425
  const current = activeKeyRef.current;
425
426
  if (current !== null) deactivate(current);
426
427
  }, [deactivate]);
428
+ // Unconditional hover-clear (any pointer type) — the self-heal for a MISSED
429
+ // `pointerleave`. A leave can be swallowed by a tab blur, an interrupted
430
+ // pointer (pointercancel), or an overlay mounting under the cursor, leaving
431
+ // `inside` stuck true so the marquee freezes FOREVER ("gets stuck every once
432
+ // in a while"). Reset it on pointercancel + whenever the tab hides / the
433
+ // window loses focus; the next real hover re-arms it. Can only un-stick.
434
+ const clearHover = useCallback(() => {
435
+ hoverPointerRef.current.inside = false;
436
+ const current = activeKeyRef.current;
437
+ if (current !== null) deactivate(current);
438
+ }, [deactivate]);
439
+ // Shared SSOT self-heal: un-stick the hover flag whenever the tab hides / the
440
+ // window loses focus (a `pointerleave`/`pointercancel` can be swallowed).
441
+ useResetOnPageHidden(clearHover);
427
442
  /** Re-sync hover only when the track actually moved under the pointer.
428
443
  * KNOWN HOT-PATH COST (intentional): while the marquee runs with the
429
444
  * pointer parked over strip whitespace (no card → not paused), scrollLeft
@@ -654,6 +669,7 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
654
669
  onPointerEnter={onHoverPointerEnter}
655
670
  onPointerMove={onHoverPointerMove}
656
671
  onPointerLeave={onHoverPointerLeave}
672
+ onPointerCancel={clearHover}
657
673
  onScroll={onSeamWarp}
658
674
  >
659
675
  <div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { cn } from '../../utils/cn'
5
5
  import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine'
6
6
  import { useMediaQuery } from '../../hooks/ui/use-media-query'
7
+ import { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden'
7
8
  import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus'
8
9
  import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport'
9
10
 
@@ -153,6 +154,13 @@ export interface MarqueeWallProps {
153
154
  * must never move under a pointing cursor (required for interactive chip
154
155
  * walls). Default true. */
155
156
  pauseOnHover?: boolean
157
+ /** Opt-in MANUAL scroll: pointer-drag + horizontal wheel move the wall along
158
+ * its axis (transform-based — no inner scroller is created, so it stays
159
+ * deck-safe). The auto-marquee pauses during the interaction and briefly
160
+ * after, then resumes drifting from wherever the user left it. Off by
161
+ * default (decorative walls stay auto-only); enabled on the embeddable-chat
162
+ * quick-action walls so users can browse the actions by hand. */
163
+ dragScroll?: boolean
156
164
  /** Edge fade(s) — the "there's more" affordance. ONE spelling of the
157
165
  * clip-and-fade across every wall surface. */
158
166
  fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
@@ -295,6 +303,7 @@ export function MarqueeWall({
295
303
  speed = 40,
296
304
  animate = true,
297
305
  pauseOnHover = true,
306
+ dragScroll = false,
298
307
  fade,
299
308
  fadeColor = 'var(--color-bg)',
300
309
  fadeSize,
@@ -386,6 +395,9 @@ export function MarqueeWall({
386
395
 
387
396
  // ---- pause-reason set ------------------------------------------------------
388
397
  const pointerInsideRef = React.useRef(false)
398
+ const clearPointerInside = React.useCallback(() => {
399
+ pointerInsideRef.current = false
400
+ }, [])
389
401
  const onPointerEnter = React.useCallback((e: React.PointerEvent) => {
390
402
  if (e.pointerType === 'touch') return
391
403
  pointerInsideRef.current = true
@@ -394,6 +406,21 @@ export function MarqueeWall({
394
406
  if (e.pointerType === 'touch') return
395
407
  pointerInsideRef.current = false
396
408
  }, [])
409
+ // Stale-flag self-heal (shared SSOT): if a `pointerleave` is MISSED (tab blur,
410
+ // overlay under the cursor, interrupted pointer), `inside` stays stuck true and
411
+ // the wall freezes forever — so un-stick it whenever the tab hides / the window
412
+ // loses focus. The next real pointer/enter re-arms it.
413
+ useResetOnPageHidden(clearPointerInside)
414
+
415
+ // ---- manual drag / wheel scroll (opt-in via `dragScroll`) ------------------
416
+ // Transform-based (no inner scroller → deck-safe): drag/wheel move the
417
+ // engine's float position directly; `draggingRef` + a short suppress window
418
+ // pause the auto-marquee during and just after the interaction so it resumes
419
+ // drifting from where the user left it. Refs declared here (before the engine)
420
+ // so `isPaused` can read them; the handlers (which need the engine's `wrap`)
421
+ // are defined after the engine call.
422
+ const draggingRef = React.useRef(false)
423
+ const dragSuppressUntilRef = React.useRef(0)
397
424
 
398
425
  // ---- transform driver ------------------------------------------------------
399
426
  const applyTransform = React.useCallback(
@@ -414,6 +441,26 @@ export function MarqueeWall({
414
441
  },
415
442
  [axis, reverse, trackId],
416
443
  )
444
+ // Sync-aware position writer — THE single entry point for every position
445
+ // update (the engine's rAF apply AND the manual drag/wheel handlers). With a
446
+ // `sync` controller it publishes to the shared position and drives every peer
447
+ // wall's transform; without one it just writes our own. Routing drag/wheel
448
+ // through here (instead of raw `applyTransform`) keeps a wall that is both
449
+ // `sync`-ed and `dragScroll`-ed in lockstep with its peers — a direct
450
+ // `applyTransform` would move only this wall and leave `sync.position` + peers
451
+ // stale. Members' own `apply` stays the raw `applyTransform` (no re-publish),
452
+ // so the `forEach` fan-out can't recurse.
453
+ const applyPos = React.useCallback(
454
+ (pos: number) => {
455
+ if (sync) {
456
+ sync.position = pos
457
+ sync.members.forEach(m => m.apply(pos))
458
+ } else {
459
+ applyTransform(pos)
460
+ }
461
+ },
462
+ [sync, applyTransform],
463
+ )
417
464
  // Clear any residual offset only when the marquee turns off STRUCTURALLY
418
465
  // (content shrank so it fits, reduced-motion flipped) — the static wall must
419
466
  // sit at its natural origin. A mere run-gate pause (`animate` false) does NOT
@@ -457,18 +504,16 @@ export function MarqueeWall({
457
504
  }
458
505
  }, [sync, applyTransform])
459
506
 
460
- const { posRef } = useMarqueeEngine({
507
+ const { posRef, wrap } = useMarqueeEngine({
461
508
  active: marqueeActive && nearViewport && isDriver,
462
509
  speed,
463
- isPaused: () =>
464
- (pauseOnHover && pointerInsideRef.current) || document.visibilityState === 'hidden',
510
+ isPaused: now =>
511
+ (pauseOnHover && pointerInsideRef.current) ||
512
+ draggingRef.current ||
513
+ now < dragSuppressUntilRef.current ||
514
+ document.visibilityState === 'hidden',
465
515
  getWrapSize: () => wrapSizeRef.current,
466
- apply: sync
467
- ? pos => {
468
- sync.position = pos
469
- sync.members.forEach(m => m.apply(pos))
470
- }
471
- : applyTransform,
516
+ apply: applyPos,
472
517
  // Synced pairs: a re-elected driver resumes from the pair's live position
473
518
  // (engine seeds from readBack on activation) instead of restarting at 0.
474
519
  readBack: sync ? () => sync.position : undefined,
@@ -490,17 +535,122 @@ export function MarqueeWall({
490
535
  }
491
536
  }, [marqueeMounted, reverse, posRef])
492
537
 
538
+ // ---- manual drag / wheel handlers (only wired when `dragScroll`) -----------
539
+ const DRAG_RESUME_MS = 1200
540
+ const dragStartRef = React.useRef({ coord: 0, pos: 0 })
541
+ const dragMovedRef = React.useRef(false)
542
+ const suppressClickRef = React.useRef(false)
543
+
544
+ const onDragPointerDown = React.useCallback((e: React.PointerEvent) => {
545
+ if (!marqueeMounted) return
546
+ draggingRef.current = true
547
+ dragMovedRef.current = false
548
+ // Clear any click-suppress left armed by a prior drag that ended WITHOUT a
549
+ // synthetic click on this container (pointercancel, or the pointer released
550
+ // off the wall) — otherwise `onDragClickCapture` would swallow this fresh
551
+ // tap's action. A new press is unambiguously a new intent.
552
+ suppressClickRef.current = false
553
+ dragStartRef.current = { coord: axis === 'y' ? e.clientY : e.clientX, pos: posRef.current }
554
+ try { containerRef.current?.setPointerCapture(e.pointerId) } catch { /* capture is best-effort */ }
555
+ }, [marqueeMounted, axis, posRef])
556
+
557
+ const onDragPointerMove = React.useCallback((e: React.PointerEvent) => {
558
+ if (!draggingRef.current) return
559
+ const delta = (axis === 'y' ? e.clientY : e.clientX) - dragStartRef.current.coord
560
+ if (Math.abs(delta) > 3) dragMovedRef.current = true
561
+ // Track follows the pointer. `applyPos` maps pos→translate as `-pos`
562
+ // (forward) / `pos-wrap` (reverse), so a rightward/downward drag (delta > 0)
563
+ // must DECREASE pos on a forward wall and INCREASE it on a reverse one.
564
+ // Via `applyPos` (not raw `applyTransform`) so synced peer walls follow.
565
+ posRef.current = wrap(dragStartRef.current.pos + (reverse ? delta : -delta))
566
+ applyPos(posRef.current)
567
+ dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS
568
+ }, [axis, reverse, wrap, posRef, applyPos])
569
+
570
+ const onDragPointerUp = React.useCallback((e: React.PointerEvent) => {
571
+ if (!draggingRef.current) return
572
+ draggingRef.current = false
573
+ // A real drag ends over a chip → swallow the synthetic click so the drag
574
+ // doesn't fire the chip's action.
575
+ if (dragMovedRef.current) suppressClickRef.current = true
576
+ dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS
577
+ try { containerRef.current?.releasePointerCapture(e.pointerId) } catch { /* best-effort */ }
578
+ }, [])
579
+
580
+ const onDragClickCapture = React.useCallback((e: React.MouseEvent) => {
581
+ if (suppressClickRef.current) {
582
+ suppressClickRef.current = false
583
+ e.preventDefault()
584
+ e.stopPropagation()
585
+ }
586
+ }, [])
587
+
588
+ // Along-axis wheel → move the wall. Wired as a NATIVE non-passive listener
589
+ // (not React's `onWheel`, which React attaches passively so `preventDefault`
590
+ // is a no-op): once WE consume an along-axis wheel we must `preventDefault` to
591
+ // suppress the browser's horizontal history-swipe (two-finger trackpad) on an
592
+ // x-wall. An x-wall ignores vertical page-scroll wheels (and vice-versa) so the
593
+ // surrounding page still scrolls normally.
594
+ React.useEffect(() => {
595
+ const el = containerRef.current
596
+ if (!el || !dragScroll || !marqueeMounted) return
597
+ const onWheel = (e: WheelEvent) => {
598
+ // Only claim the DOMINANT-axis wheel (per axis), so a cross-axis wheel
599
+ // still scrolls the page: an x-wall ignores vertical wheels, a y-wall
600
+ // ignores horizontal ones.
601
+ const primary =
602
+ axis === 'y'
603
+ ? Math.abs(e.deltaY) >= Math.abs(e.deltaX)
604
+ ? e.deltaY
605
+ : 0
606
+ : Math.abs(e.deltaX) >= Math.abs(e.deltaY)
607
+ ? e.deltaX
608
+ : 0
609
+ if (primary === 0) return
610
+ e.preventDefault()
611
+ // Via `applyPos` (not raw `applyTransform`) so synced peer walls follow.
612
+ posRef.current = wrap(posRef.current + (reverse ? -primary : primary))
613
+ applyPos(posRef.current)
614
+ dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS
615
+ }
616
+ el.addEventListener('wheel', onWheel, { passive: false })
617
+ return () => el.removeEventListener('wheel', onWheel)
618
+ }, [dragScroll, marqueeMounted, axis, reverse, wrap, posRef, applyPos])
619
+
493
620
  // ---- render ----------------------------------------------------------------
494
621
  const renderContent = (isClone: boolean) =>
495
622
  typeof children === 'function' ? children({ isClone }) : children
496
623
 
624
+ // Reduced-motion static fallback: when the marquee does NOT mount (chiefly
625
+ // prefers-reduced-motion) but the content OVERFLOWS, the drag/transform path
626
+ // is inert — so on a `dragScroll` wall fall back to NATIVE
627
+ // overflow scroll along the axis. Otherwise the overflowing chips would be
628
+ // clipped by `overflow-hidden` with no way to reach them (a11y regression vs
629
+ // the old wrapping row). The visible thin scrollbar (global `*` rule) is the
630
+ // right affordance for reduced-motion users. No engine needed — the browser
631
+ // scrolls the single static track.
632
+ const staticScrollable = dragScroll && overflows && !marqueeMounted
633
+
497
634
  return (
498
635
  <div
499
636
  ref={containerRef}
500
- className={cn('relative overflow-hidden', className)}
637
+ className={cn(
638
+ 'relative',
639
+ staticScrollable ? (axis === 'y' ? 'overflow-y-auto' : 'overflow-x-auto') : 'overflow-hidden',
640
+ dragScroll && marqueeMounted && 'cursor-grab active:cursor-grabbing select-none',
641
+ className,
642
+ )}
643
+ // x-walls let vertical page-scroll pass (`pan-y`) and own horizontal
644
+ // drags; y-walls the reverse. Only when drag is actually live.
645
+ style={dragScroll && marqueeMounted ? { touchAction: axis === 'y' ? 'pan-x' : 'pan-y' } : undefined}
501
646
  data-marquee-track={trackId}
502
647
  onPointerEnter={onPointerEnter}
503
648
  onPointerLeave={onPointerLeave}
649
+ onPointerDown={dragScroll ? onDragPointerDown : undefined}
650
+ onPointerMove={dragScroll ? onDragPointerMove : undefined}
651
+ onPointerUp={dragScroll ? onDragPointerUp : undefined}
652
+ onPointerCancel={dragScroll ? onDragPointerUp : undefined}
653
+ onClickCapture={dragScroll ? onDragClickCapture : undefined}
504
654
  >
505
655
  <div
506
656
  ref={trackRef}
@@ -12,6 +12,7 @@ export * from './use-media-query'
12
12
  export * from './use-memoized-callback'
13
13
  export * from './use-notification-permission'
14
14
  export * from './use-onboarding-state'
15
+ export * from './use-reset-on-page-hidden'
15
16
  export * from './use-search'
16
17
  export * from './use-suppress-clone-focus'
17
18
  export * from './use-table-pagination'
@@ -0,0 +1,32 @@
1
+ import { useEffect } from 'react'
2
+
3
+ /**
4
+ * Runs `reset` whenever the tab is hidden or the window loses focus — exactly the
5
+ * moments a `pointerleave` / `pointercancel` can be SWALLOWED (tab blur, an
6
+ * overlay mounting under the cursor, an interrupted pointer), which otherwise
7
+ * leaves a hover/"inside" flag stuck `true` and freezes a marquee FOREVER. The
8
+ * next real pointer/hover re-arms the flag.
9
+ *
10
+ * SSOT self-heal for every marquee surface (MarqueeWall, CardsStrip) so the
11
+ * blur + `visibilitychange` wiring lives in one place. `reset` almost always
12
+ * only UN-sticks state. The one edge: on `blur` WITHOUT the tab hiding (a second
13
+ * window focused beside this one), clearing the hover-pause can let the marquee
14
+ * resume under a still-stationary cursor in the now-unfocused window — it
15
+ * re-pauses on the next `pointermove`/`pointerenter`. We keep the `blur` reset
16
+ * anyway because it's the only signal that catches a tab-blur that swallows the
17
+ * `pointerleave` without a `visibilitychange`. Pass a STABLE `reset` (memoize
18
+ * with `useCallback`) — the listeners re-attach whenever its identity changes.
19
+ */
20
+ export function useResetOnPageHidden(reset: () => void): void {
21
+ useEffect(() => {
22
+ const onVisibility = () => {
23
+ if (document.visibilityState === 'hidden') reset()
24
+ }
25
+ window.addEventListener('blur', reset)
26
+ document.addEventListener('visibilitychange', onVisibility)
27
+ return () => {
28
+ window.removeEventListener('blur', reset)
29
+ document.removeEventListener('visibilitychange', onVisibility)
30
+ }
31
+ }, [reset])
32
+ }
@@ -29,19 +29,39 @@
29
29
 
30
30
  import { useEffect, useRef, useState, useCallback } from 'react';
31
31
 
32
- // Per-rootMargin IO map. Multiple call sites with different margins each
33
- // get their own singleton observer.
32
+ // Per-(rootMargin, threshold) IO map. Multiple call sites with different
33
+ // margins/thresholds each get their own singleton observer.
34
34
  const observers = new Map<string, IntersectionObserver>();
35
35
  const subscribers = new WeakMap<Element, () => void>();
36
36
 
37
- function getObserverFor(rootMargin: string): IntersectionObserver {
38
- const existing = observers.get(rootMargin);
37
+ // Slack for the explicit ratio check: an element sized to exactly meet the
38
+ // threshold can report a ratio a hair under it (sub-pixel / float rounding), so
39
+ // accept `ratio >= threshold - EPSILON`. With `threshold: 0` this is a no-op
40
+ // (ratio is always ≥ 0 > -EPSILON) — the `isIntersecting` check alone gates.
41
+ const THRESHOLD_EPSILON = 0.01;
42
+
43
+ /** Stable map key for an observer config. */
44
+ function observerKey(rootMargin: string, threshold: number): string {
45
+ return `${rootMargin}|${threshold}`;
46
+ }
47
+
48
+ function getObserverFor(rootMargin: string, threshold: number): IntersectionObserver {
49
+ const key = observerKey(rootMargin, threshold);
50
+ const existing = observers.get(key);
39
51
  if (existing) return existing;
40
52
 
41
53
  const io = new IntersectionObserver(
42
54
  (entries) => {
43
55
  entries.forEach((entry) => {
56
+ // Enforce the threshold EXPLICITLY. `isIntersecting` is spec-defined as
57
+ // `intersectionRatio > 0` (any overlap), so the initial `observe()`
58
+ // firing can report `true` at, say, 20% visible even with
59
+ // `threshold: 0.5` — which would prematurely pass the gate. Require both
60
+ // an intersection AND the ratio to actually meet the threshold (minus a
61
+ // small epsilon for the float rounding where the ratio lands a hair
62
+ // under the exact value).
44
63
  if (!entry.isIntersecting) return;
64
+ if (entry.intersectionRatio < threshold - THRESHOLD_EPSILON) return;
45
65
  // Race-safe: re-read the callback at fire time. A late IO firing
46
66
  // after cleanup must not invoke a stale callback.
47
67
  const cb = subscribers.get(entry.target);
@@ -52,9 +72,9 @@ function getObserverFor(rootMargin: string): IntersectionObserver {
52
72
  }
53
73
  });
54
74
  },
55
- { rootMargin }
75
+ { rootMargin, threshold }
56
76
  );
57
- observers.set(rootMargin, io);
77
+ observers.set(key, io);
58
78
  return io;
59
79
  }
60
80
 
@@ -78,12 +98,16 @@ export interface UseNearViewportResult<T extends Element = HTMLElement> {
78
98
  * '500px' = element starts mounting 500px before scroll-in.
79
99
  * '1000px' = a full viewport's worth of lookahead.
80
100
  * '0px' = strict on-screen detection.
101
+ * @param threshold Fraction of the element that must be visible before firing
102
+ * (0 = any pixel — the default; 0.5 = at least half on-screen).
81
103
  */
82
104
  export function useNearViewport<T extends Element = HTMLElement>(
83
- rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN
105
+ rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN,
106
+ threshold: number = 0
84
107
  ): UseNearViewportResult<T> {
85
108
  const [isNear, setIsNear] = useState(false);
86
109
  const elRef = useRef<T | null>(null);
110
+ const key = observerKey(rootMargin, threshold);
87
111
 
88
112
  // Subscribe/unsubscribe on element change.
89
113
  const ref = useCallback(
@@ -96,7 +120,7 @@ export function useNearViewport<T extends Element = HTMLElement>(
96
120
  const stillOurs = subscribers.get(prev);
97
121
  if (stillOurs) {
98
122
  subscribers.delete(prev);
99
- observers.get(rootMargin)?.unobserve(prev);
123
+ observers.get(key)?.unobserve(prev);
100
124
  }
101
125
  }
102
126
 
@@ -105,9 +129,9 @@ export function useNearViewport<T extends Element = HTMLElement>(
105
129
 
106
130
  const cb = () => setIsNear(true);
107
131
  subscribers.set(node, cb);
108
- getObserverFor(rootMargin).observe(node);
132
+ getObserverFor(rootMargin, threshold).observe(node);
109
133
  },
110
- [rootMargin]
134
+ [key, rootMargin, threshold]
111
135
  );
112
136
 
113
137
  // Unsubscribe on unmount. Identity check guards the StrictMode race.
@@ -117,10 +141,10 @@ export function useNearViewport<T extends Element = HTMLElement>(
117
141
  if (!el) return;
118
142
  if (subscribers.get(el)) {
119
143
  subscribers.delete(el);
120
- observers.get(rootMargin)?.unobserve(el);
144
+ observers.get(key)?.unobserve(el);
121
145
  }
122
146
  };
123
- }, [rootMargin]);
147
+ }, [key]);
124
148
 
125
149
  return { ref, isNear };
126
150
  }