@flamingo-stack/openframe-frontend-core 0.0.449 → 0.0.450

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 (142) hide show
  1. package/dist/{chunk-DJP5HYFG.cjs → chunk-26JSJMMZ.cjs} +7 -7
  2. package/dist/{chunk-DJP5HYFG.cjs.map → chunk-26JSJMMZ.cjs.map} +1 -1
  3. package/dist/{chunk-NPBTNWRA.js → chunk-3ITY2LBM.js} +4 -4
  4. package/dist/{chunk-MWRFPLNB.js → chunk-4SZDO3TE.js} +3 -3
  5. package/dist/{chunk-LWT6G75Q.js → chunk-4VSTLJDO.js} +2 -2
  6. package/dist/{chunk-DRKQNXSZ.cjs → chunk-5HW5HMNF.cjs} +5 -5
  7. package/dist/{chunk-DRKQNXSZ.cjs.map → chunk-5HW5HMNF.cjs.map} +1 -1
  8. package/dist/{chunk-KKJRBMG3.cjs → chunk-5OY7HYII.cjs} +73 -73
  9. package/dist/{chunk-KKJRBMG3.cjs.map → chunk-5OY7HYII.cjs.map} +1 -1
  10. package/dist/{chunk-WYPROXL4.cjs → chunk-6GJKSYM2.cjs} +73 -73
  11. package/dist/{chunk-WYPROXL4.cjs.map → chunk-6GJKSYM2.cjs.map} +1 -1
  12. package/dist/{chunk-6TS4AIDX.cjs → chunk-6RA4NRBA.cjs} +15 -15
  13. package/dist/{chunk-6TS4AIDX.cjs.map → chunk-6RA4NRBA.cjs.map} +1 -1
  14. package/dist/{chunk-X754WDWO.cjs → chunk-76EN3GEH.cjs} +40 -40
  15. package/dist/{chunk-X754WDWO.cjs.map → chunk-76EN3GEH.cjs.map} +1 -1
  16. package/dist/{chunk-YSNMV53O.js → chunk-CM5XKHHS.js} +6 -6
  17. package/dist/{chunk-47AMTR2S.cjs → chunk-DQ5U6DCR.cjs} +18 -18
  18. package/dist/{chunk-47AMTR2S.cjs.map → chunk-DQ5U6DCR.cjs.map} +1 -1
  19. package/dist/{chunk-YHNBJ3F3.js → chunk-EQWE64RJ.js} +2 -2
  20. package/dist/{chunk-5SJGFLL2.js → chunk-HXCQHMS5.js} +6386 -6032
  21. package/dist/chunk-HXCQHMS5.js.map +1 -0
  22. package/dist/{chunk-VSWFYFIG.js → chunk-HYIHXXF3.js} +5 -5
  23. package/dist/{chunk-AZKVNRZP.cjs → chunk-J5BYYZVM.cjs} +32 -32
  24. package/dist/{chunk-AZKVNRZP.cjs.map → chunk-J5BYYZVM.cjs.map} +1 -1
  25. package/dist/{chunk-JAASDJOF.js → chunk-K3GXIGN6.js} +5 -5
  26. package/dist/{chunk-UNQVTGVG.js → chunk-M3AKWFAO.js} +2 -2
  27. package/dist/{chunk-TKGCTDQY.js → chunk-MLFFBQ5B.js} +180 -87
  28. package/dist/chunk-MLFFBQ5B.js.map +1 -0
  29. package/dist/{chunk-XLFAFH2B.js → chunk-N53FC732.js} +2 -2
  30. package/dist/{chunk-H56AEHFC.cjs → chunk-N5M72LJX.cjs} +1620 -1266
  31. package/dist/chunk-N5M72LJX.cjs.map +1 -0
  32. package/dist/{chunk-4IMXFDRI.js → chunk-PCW2TRZ4.js} +3 -3
  33. package/dist/{chunk-AN7MCJTV.cjs → chunk-QF3NQW7C.cjs} +122 -29
  34. package/dist/chunk-QF3NQW7C.cjs.map +1 -0
  35. package/dist/{chunk-EOFENGSZ.cjs → chunk-ROBGJH52.cjs} +12 -12
  36. package/dist/{chunk-EOFENGSZ.cjs.map → chunk-ROBGJH52.cjs.map} +1 -1
  37. package/dist/{chunk-HQ7S6JG5.js → chunk-RQHHACP2.js} +3 -3
  38. package/dist/{chunk-ROCJMSJI.cjs → chunk-UPRY4I63.cjs} +3 -3
  39. package/dist/{chunk-ROCJMSJI.cjs.map → chunk-UPRY4I63.cjs.map} +1 -1
  40. package/dist/{chunk-LQTX7EJS.cjs → chunk-VLDZ4363.cjs} +29 -29
  41. package/dist/{chunk-LQTX7EJS.cjs.map → chunk-VLDZ4363.cjs.map} +1 -1
  42. package/dist/{chunk-BSBWBQ7M.js → chunk-VUDZOCPW.js} +6 -6
  43. package/dist/{chunk-4S4RR7XC.cjs → chunk-XITG5ICQ.cjs} +40 -40
  44. package/dist/{chunk-4S4RR7XC.cjs.map → chunk-XITG5ICQ.cjs.map} +1 -1
  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-quick-action-row.d.ts +26 -1
  48. package/dist/components/chat/chat-quick-action-row.d.ts.map +1 -1
  49. package/dist/components/chat/index.cjs +15 -3
  50. package/dist/components/chat/index.cjs.map +1 -1
  51. package/dist/components/chat/index.d.ts +1 -0
  52. package/dist/components/chat/index.d.ts.map +1 -1
  53. package/dist/components/chat/index.js +14 -2
  54. package/dist/components/chat/quick-action-chip.d.ts +45 -4
  55. package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
  56. package/dist/components/chat/quick-action-marquee.d.ts +24 -9
  57. package/dist/components/chat/quick-action-marquee.d.ts.map +1 -1
  58. package/dist/components/chat/quick-action-wall.d.ts +96 -0
  59. package/dist/components/chat/quick-action-wall.d.ts.map +1 -0
  60. package/dist/components/contact/index.cjs +4 -4
  61. package/dist/components/contact/index.js +3 -3
  62. package/dist/components/content-loading-container.d.ts +1 -1
  63. package/dist/components/docs/index.cjs +6 -6
  64. package/dist/components/docs/index.js +5 -5
  65. package/dist/components/embeds/index.cjs +4 -4
  66. package/dist/components/embeds/index.js +3 -3
  67. package/dist/components/faq/index.cjs +5 -5
  68. package/dist/components/faq/index.js +4 -4
  69. package/dist/components/features/cards-strip.d.ts.map +1 -1
  70. package/dist/components/features/index.cjs +3 -3
  71. package/dist/components/features/index.js +2 -2
  72. package/dist/components/form.d.ts +1 -1
  73. package/dist/components/form.d.ts.map +1 -1
  74. package/dist/components/help-center-pages/index.cjs +22 -22
  75. package/dist/components/help-center-pages/index.js +13 -13
  76. package/dist/components/index.cjs +159 -135
  77. package/dist/components/index.cjs.map +1 -1
  78. package/dist/components/index.js +35 -11
  79. package/dist/components/index.js.map +1 -1
  80. package/dist/components/navigation/index.cjs +3 -3
  81. package/dist/components/navigation/index.js +2 -2
  82. package/dist/components/onboarding-guides/index.cjs +7 -7
  83. package/dist/components/onboarding-guides/index.js +6 -6
  84. package/dist/components/related-content/index.cjs +5 -5
  85. package/dist/components/related-content/index.js +4 -4
  86. package/dist/components/tickets/index.cjs +6 -6
  87. package/dist/components/tickets/index.js +5 -5
  88. package/dist/components/ui/index.cjs +27 -3
  89. package/dist/components/ui/index.cjs.map +1 -1
  90. package/dist/components/ui/index.d.ts +1 -0
  91. package/dist/components/ui/index.d.ts.map +1 -1
  92. package/dist/components/ui/index.js +26 -2
  93. package/dist/components/ui/marquee-wall.d.ts +159 -0
  94. package/dist/components/ui/marquee-wall.d.ts.map +1 -0
  95. package/dist/hooks/index.cjs +6 -2
  96. package/dist/hooks/index.cjs.map +1 -1
  97. package/dist/hooks/index.js +5 -1
  98. package/dist/hooks/ui/index.d.ts +2 -0
  99. package/dist/hooks/ui/index.d.ts.map +1 -1
  100. package/dist/hooks/ui/use-marquee-engine.d.ts +73 -0
  101. package/dist/hooks/ui/use-marquee-engine.d.ts.map +1 -0
  102. package/dist/hooks/ui/use-suppress-clone-focus.d.ts +24 -0
  103. package/dist/hooks/ui/use-suppress-clone-focus.d.ts.map +1 -0
  104. package/dist/index.cjs +31 -3
  105. package/dist/index.cjs.map +1 -1
  106. package/dist/index.js +30 -2
  107. package/dist/tailwind.config.cjs +1 -8
  108. package/dist/tailwind.config.cjs.map +1 -1
  109. package/dist/tailwind.config.js +1 -8
  110. package/dist/tailwind.config.js.map +1 -1
  111. package/package.json +1 -1
  112. package/src/components/chat/chat-quick-action-row.tsx +47 -3
  113. package/src/components/chat/index.ts +1 -0
  114. package/src/components/chat/quick-action-chip.tsx +81 -13
  115. package/src/components/chat/quick-action-marquee.tsx +54 -47
  116. package/src/components/chat/quick-action-wall.tsx +247 -0
  117. package/src/components/features/cards-strip.tsx +88 -158
  118. package/src/components/ui/index.ts +1 -0
  119. package/src/components/ui/marquee-wall.tsx +522 -0
  120. package/src/hooks/ui/index.ts +2 -0
  121. package/src/hooks/ui/use-marquee-engine.ts +158 -0
  122. package/src/hooks/ui/use-suppress-clone-focus.ts +61 -0
  123. package/src/stories/MarqueeWall.stories.tsx +132 -0
  124. package/src/stories/QuickActionMarquee.stories.tsx +8 -7
  125. package/src/stories/QuickActionWall.stories.tsx +116 -0
  126. package/tailwind.config.ts +0 -7
  127. package/dist/chunk-5SJGFLL2.js.map +0 -1
  128. package/dist/chunk-AN7MCJTV.cjs.map +0 -1
  129. package/dist/chunk-H56AEHFC.cjs.map +0 -1
  130. package/dist/chunk-TKGCTDQY.js.map +0 -1
  131. /package/dist/{chunk-NPBTNWRA.js.map → chunk-3ITY2LBM.js.map} +0 -0
  132. /package/dist/{chunk-MWRFPLNB.js.map → chunk-4SZDO3TE.js.map} +0 -0
  133. /package/dist/{chunk-LWT6G75Q.js.map → chunk-4VSTLJDO.js.map} +0 -0
  134. /package/dist/{chunk-YSNMV53O.js.map → chunk-CM5XKHHS.js.map} +0 -0
  135. /package/dist/{chunk-YHNBJ3F3.js.map → chunk-EQWE64RJ.js.map} +0 -0
  136. /package/dist/{chunk-VSWFYFIG.js.map → chunk-HYIHXXF3.js.map} +0 -0
  137. /package/dist/{chunk-JAASDJOF.js.map → chunk-K3GXIGN6.js.map} +0 -0
  138. /package/dist/{chunk-UNQVTGVG.js.map → chunk-M3AKWFAO.js.map} +0 -0
  139. /package/dist/{chunk-XLFAFH2B.js.map → chunk-N53FC732.js.map} +0 -0
  140. /package/dist/{chunk-4IMXFDRI.js.map → chunk-PCW2TRZ4.js.map} +0 -0
  141. /package/dist/{chunk-HQ7S6JG5.js.map → chunk-RQHHACP2.js.map} +0 -0
  142. /package/dist/{chunk-BSBWBQ7M.js.map → chunk-VUDZOCPW.js.map} +0 -0
@@ -44,6 +44,8 @@
44
44
  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
+ import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine';
48
+ import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus';
47
49
  import { Button } from '../ui/button';
48
50
  import { SECTION_HEADING_CLASS } from '../layout/page-heading';
49
51
  import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
@@ -151,60 +153,8 @@ const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
151
153
  // Children-mode managed cell
152
154
  // =============================================================================
153
155
 
154
- // Every focusable a clone might containmatched so it can be pulled out of the
155
- // keyboard tab order (see useSuppressCloneFocus). `[contenteditable]:not(
156
- // [contenteditable="false"])` catches the empty/`"true"`/`"plaintext-only"`
157
- // editable forms; `summary` is focusable inside a <details>.
158
- const CLONE_FOCUSABLE_SELECTOR =
159
- 'a[href],button,input,select,textarea,iframe,summary,audio[controls],video[controls],[tabindex],[contenteditable]:not([contenteditable="false"])';
160
-
161
- /**
162
- * Keep a clone cell's content pointer-CLICKABLE while taking it out of the
163
- * accessibility tree and the keyboard tab order.
164
- *
165
- * `inert` (the previous approach) does all three at once: a11y-hidden,
166
- * un-focusable AND un-clickable. That third effect was the "news marquee stops
167
- * being clickable after you scroll it for a while" bug — the 2-copy endless
168
- * loop ALWAYS paints clone cards inside the viewport near the copy seam, so a
169
- * user who manually scrolls and parks there is looking at real-looking cards
170
- * that silently swallow every click. Clones must therefore stay clickable (a
171
- * click on ANY visible card must open its link — mouse, ⌘-click, middle-click
172
- * and context-menu all handled natively by the clone's own anchor).
173
- *
174
- * The a11y contract is still met without `inert`: the cell keeps `aria-hidden`
175
- * (out of the a11y tree) and every focusable descendant is forced to
176
- * `tabindex="-1"` (out of the tab order → no duplicate tab stops, no
177
- * aria-hidden-focus violation). Re-applied on subtree mutation. This is the
178
- * standard loop-clone treatment used by Swiper/Splide.
179
- */
180
- function useSuppressCloneFocus(active: boolean) {
181
- const ref = useRef<HTMLDivElement | null>(null);
182
- useEffect(() => {
183
- const root = ref.current;
184
- if (!root || !active) return;
185
- const suppress = () => {
186
- root.querySelectorAll<HTMLElement>(CLONE_FOCUSABLE_SELECTOR).forEach(el => {
187
- // Only stamp the ones not already stamped — avoids touching the DOM (and
188
- // clobbering an intentional tabindex) on every mutation.
189
- if (el.getAttribute('tabindex') !== '-1') el.setAttribute('tabindex', '-1');
190
- });
191
- };
192
- suppress();
193
- // Watch structure AND the attributes that can turn a descendant tabbable
194
- // (an anchor gaining `href`, React restoring `tabindex="0"`, an element
195
- // becoming editable, media gaining `controls`). The `!== '-1'` guard above
196
- // makes our own tabindex writes a no-op, so the observer never loops.
197
- const mo = new MutationObserver(suppress);
198
- mo.observe(root, {
199
- childList: true,
200
- subtree: true,
201
- attributes: true,
202
- attributeFilter: ['href', 'tabindex', 'contenteditable', 'controls'],
203
- });
204
- return () => mo.disconnect();
205
- }, [active]);
206
- return ref;
207
- }
156
+ // (Clone focus suppression lives in hooks/ui/use-suppress-clone-focusshared
157
+ // with <MarqueeWall>, which renders the same 2-copy endless-loop structure.)
208
158
 
209
159
  /**
210
160
  * Children-mode cell: fixed width + hover/keyboard activation (wired here, never
@@ -342,10 +292,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
342
292
  }, []);
343
293
 
344
294
  // ---- overflow measurement ---------------------------------------------------
345
- // `overflows` gates chevrons; `marqueeActive` additionally gates cloning + rAF.
295
+ // TWO overflow states on purpose: `overflows` (any real overflow) gates
296
+ // chevrons — a strip clipping even a few px must stay navigable —
297
+ // while `marqueeEligible` (overflow beyond the joining gap) additionally
298
+ // gates cloning + rAF: the cloned track's physical maxScroll is
299
+ // 2·copy − gap − viewport, so a copy overflowing by less than the gap
300
+ // makes even the zero-buffer wrap range [0, copy) unreachable — the
301
+ // engine would clamp at the track end and freeze. Razor-thin overflow
302
+ // (≤16px) stays a static, chevron-navigable row instead.
346
303
  const [overflows, setOverflows] = useState(false);
304
+ const [marqueeEligible, setMarqueeEligible] = useState(false);
347
305
  const singleCopyWidthRef = useRef(0);
348
- const marqueeActive = autoScroll && !reducedMotion && overflows && items.length > 0;
306
+ const marqueeActive = autoScroll && !reducedMotion && marqueeEligible && items.length > 0;
349
307
 
350
308
  const measure = useCallback(() => {
351
309
  const scroller = scrollerRef.current;
@@ -356,6 +314,7 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
356
314
  const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
357
315
  singleCopyWidthRef.current = singleCopy;
358
316
  setOverflows(singleCopy > scroller.clientWidth + 1);
317
+ setMarqueeEligible(singleCopy > scroller.clientWidth + TRACK_GAP_PX);
359
318
  }, []);
360
319
 
361
320
  useEffect(() => {
@@ -371,108 +330,26 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
371
330
 
372
331
  // ---- pause-reason set + suppress timestamps ---------------------------------
373
332
  // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
374
- const nearViewportRef = useRef(true);
375
333
  const chevronSuppressUntilRef = useRef(0);
376
334
  const userScrollSuppressUntilRef = useRef(0);
377
335
 
378
- // Two-way viewport gate (unlike the fire-once `useNearViewport`): the rAF
379
- // should stop again when the strip scrolls far off-screen.
336
+ // Two-way viewport gate (unlike the fire-once `useNearViewport`): STATE,
337
+ // not a pause reason — it gates the engine's `active` so the rAF fully
338
+ // stops while the strip is far off-screen (a paused engine would keep
339
+ // scheduling frames forever), and because the velocity envelope persists
340
+ // across engine restarts, scrolling back resumes at cruise speed instantly.
341
+ const [nearViewport, setNearViewport] = useState(true);
380
342
  useEffect(() => {
381
343
  const el = wrapperRef.current;
382
344
  if (!el || typeof IntersectionObserver === 'undefined') return;
383
345
  const io = new IntersectionObserver(
384
- entries => { nearViewportRef.current = entries[0]?.isIntersecting ?? true; },
346
+ entries => setNearViewport(entries[0]?.isIntersecting ?? true),
385
347
  { rootMargin: '200px' },
386
348
  );
387
349
  io.observe(el);
388
350
  return () => io.disconnect();
389
351
  }, []);
390
352
 
391
- // ---- marquee rAF engine ------------------------------------------------------
392
- // Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
393
- // scrollLeft back each frame returns a rounded value, so sub-pixel increments
394
- // (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
395
- // ran measurably slower than the configured speed because of it.
396
- //
397
- // Chevron GLIDE also runs through this engine (glideRemainingRef): browser
398
- // `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
399
- // seam warp mid-animation made it lunge a full copy-width to catch up, warp
400
- // again, and oscillate — rapid chevron clicks left every card flickering.
401
- // The rAF tween consumes a signed remaining-distance instead, wrapping
402
- // modulo the copy width each frame, so warps are invisible to it.
403
- const marqueePosRef = useRef(0);
404
- const glideRemainingRef = useRef(0);
405
- // Velocity envelope (px/s): the marquee EASES between 0 and autoScrollSpeed
406
- // (~250ms time constant) instead of binary stop/start — pause decelerates,
407
- // resume accelerates from the current position (GSAP-marquee behavior;
408
- // hard cuts read as jank).
409
- const speedEnvRef = useRef(0);
410
- useEffect(() => {
411
- if (!marqueeActive) return;
412
- const scroller = scrollerRef.current;
413
- if (!scroller) return;
414
- let raf = 0;
415
- let last = performance.now();
416
- marqueePosRef.current = scroller.scrollLeft;
417
- const wrap = (pos: number) => {
418
- const half = singleCopyWidthRef.current;
419
- if (half <= 0) return pos;
420
- while (pos >= half) pos -= half;
421
- while (pos < 0) pos += half;
422
- return pos;
423
- };
424
- const tick = (now: number) => {
425
- const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
426
- last = now;
427
- // Container-level pause (industry-standard marquee behavior): the
428
- // marquee freezes whenever a hover-capable pointer is ANYWHERE over the
429
- // cards row — content must never move under a pointing cursor. Per-card
430
- // activeKey still pauses for keyboard focus (no pointer involved).
431
- const paused =
432
- (pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
433
- !nearViewportRef.current ||
434
- document.visibilityState === 'hidden' ||
435
- now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
436
- // Ease the marquee velocity toward its target (0 when paused) — smooth
437
- // decel on hover, smooth accel on leave, always from the CURRENT position.
438
- const targetSpeed = paused ? 0 : autoScrollSpeed;
439
- speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25);
440
- if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0;
441
-
442
- const glide = glideRemainingRef.current;
443
- if (glide !== 0) {
444
- // Ease-out toward the chevron target; runs even while "paused"
445
- // (the chevron click itself sets the suppress window).
446
- if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
447
- marqueePosRef.current = scroller.scrollLeft;
448
- }
449
- const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
450
- const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
451
- glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
452
- marqueePosRef.current = wrap(marqueePosRef.current + step);
453
- scroller.scrollLeft = marqueePosRef.current;
454
- } else if (speedEnvRef.current > 0) {
455
- // Resync after external movement (user scroll, seam warp).
456
- if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
457
- marqueePosRef.current = scroller.scrollLeft;
458
- }
459
- marqueePosRef.current = wrap(marqueePosRef.current + speedEnvRef.current * dt);
460
- scroller.scrollLeft = marqueePosRef.current;
461
- } else {
462
- marqueePosRef.current = scroller.scrollLeft;
463
- }
464
- // Track moved under a possibly-stationary pointer → re-resolve which
465
- // card is hovered (declared below; stable useCallback identity, called
466
- // lazily per frame so source order is irrelevant — deliberately NOT a
467
- // dep, same as the activeKeyRef read above).
468
- syncHoverIfScrolled();
469
- raf = requestAnimationFrame(tick);
470
- };
471
- raf = requestAnimationFrame(tick);
472
- return () => cancelAnimationFrame(raf);
473
- // eslint-disable-next-line react-hooks/exhaustive-deps -- syncHoverIfScrolled is identity-stable
474
- }, [marqueeActive, autoScrollSpeed, pauseOnHover]);
475
-
476
353
  // ---- hover / overlay state -----------------------------------------------------
477
354
  // activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
478
355
  // a clone near the wrap seam works too). It only drives per-card `active`
@@ -562,6 +439,54 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
562
439
  syncHoverToPointer();
563
440
  }, [syncHoverToPointer]);
564
441
 
442
+ // ---- marquee rAF engine (shared core) ---------------------------------------
443
+ // Position/envelope/wrap/glide live in `useMarqueeEngine` — THE marquee
444
+ // animation, shared with <MarqueeWall> (quick-action walls, chip marquees).
445
+ // CardsStrip contributes the scroller driver (apply/readBack on scrollLeft:
446
+ // the user can also drag this surface) and its pause-reason set: container-
447
+ // level hover (industry-standard — content must never move under a pointing
448
+ // cursor; per-card activeKey still pauses for keyboard focus), hidden tab,
449
+ // and the chevron / user-scroll suppress windows. Chevron GLIDE
450
+ // runs through the engine too: browser `scrollBy({behavior:'smooth'})`
451
+ // animates toward an ABSOLUTE target, so a seam warp mid-animation made it
452
+ // lunge a full copy-width to catch up, warp again, and oscillate.
453
+ // Seam-warp BUFFER: the re-center zones sit this far inside the physical
454
+ // track edges. An exact-edge check (`scrollLeft <= 0`) is racy against the
455
+ // engine — the rAF nudges the scroller off 0 before the scroll handler
456
+ // samples it, so a user scrolling left "sticks" 1–2px from the edge and
457
+ // the loop never wraps. The engine's wrap range is aligned to
458
+ // [buffer, buffer + copy) so engine-written positions can never enter a
459
+ // zone and ping-pong with the handler. The buffer is ALSO capped by the
460
+ // physical slack (copy − viewport − gap): the range's upper end
461
+ // buffer + copy must stay reachable below maxScroll = 2·copy − gap −
462
+ // viewport, or a barely-overflowing strip clamps at the track end and the
463
+ // wrap threshold is never crossed — a permanently frozen marquee.
464
+ const seamBuffer = useCallback(() => {
465
+ const half = singleCopyWidthRef.current;
466
+ const viewport = scrollerRef.current?.clientWidth ?? 0;
467
+ return Math.max(0, Math.min(200, half / 4, half - viewport - TRACK_GAP_PX - 1));
468
+ }, []);
469
+
470
+ const { posRef: marqueePosRef, glideBy } = useMarqueeEngine({
471
+ // Viewport gates `active` (the rAF fully stops off-screen — a paused
472
+ // engine would keep scheduling frames forever), same treatment as
473
+ // <MarqueeWall>; the envelope persists, so re-entry resumes at cruise.
474
+ active: marqueeActive && nearViewport,
475
+ speed: autoScrollSpeed,
476
+ isPaused: now =>
477
+ (pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
478
+ document.visibilityState === 'hidden' ||
479
+ now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current),
480
+ getWrapSize: () => singleCopyWidthRef.current,
481
+ getWrapMin: seamBuffer,
482
+ apply: pos => {
483
+ const scroller = scrollerRef.current;
484
+ if (scroller) scroller.scrollLeft = pos;
485
+ },
486
+ readBack: () => scrollerRef.current?.scrollLeft ?? 0,
487
+ onAfterFrame: syncHoverIfScrolled,
488
+ });
489
+
565
490
  // ---- shared card-mount gate (by ITEM index, across both copies) -------------
566
491
  // A card and its clone show identical content half a copy-width apart. If
567
492
  // each card gated its own expensive content (e.g. video players), a seam warp
@@ -628,12 +553,12 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
628
553
  if (marqueeActiveRef.current) {
629
554
  // Wrap-aware glide via the rAF engine (see engine comment) — clicks
630
555
  // accumulate distance instead of racing browser smooth-scroll targets.
631
- glideRemainingRef.current += dir * step;
556
+ glideBy(dir * step);
632
557
  } else {
633
558
  // No clones/seam without the marquee — native smooth scroll is safe.
634
559
  scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
635
560
  }
636
- }, []);
561
+ }, [glideBy]);
637
562
 
638
563
  const onUserScrollIntent = useCallback(() => {
639
564
  userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
@@ -648,13 +573,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
648
573
  if (e.pointerType === 'touch') onUserScrollIntent();
649
574
  }, [onUserScrollIntent]);
650
575
 
651
- // Never-ending strip: warp across the clone seam on EVERY scroll (manual
652
- // wheel/drag/chevron included — the rAF only wraps while the marquee runs,
653
- // so without this a user scrolling during a pause/suppress window hits the
654
- // physical track edges). Both copies render identical content, so a ±half
655
- // jump is pixel-invisible. Backward warp only when arriving AT the left
656
- // edge from the right (never on initial rest at 0).
657
- const lastScrollLeftRef = useRef(0);
576
+ // Never-ending strip: re-center across the clone seam on EVERY scroll
577
+ // (manual wheel/drag/chevron included — the rAF only wraps while the
578
+ // marquee runs, so without this a user scrolling during a pause/suppress
579
+ // window hits the physical track edges). Both copies render identical
580
+ // content, so a ±half jump is pixel-invisible. ZONES, not exact edges:
581
+ // below `buffer` warp forward (+half, lands under half+buffer outside
582
+ // the other zone, so the pair is hysteresis-stable), at/above
583
+ // half+buffer warp back (−half, lands at ≥buffer). The user can never
584
+ // reach a physical edge in either direction, and the racy "observe
585
+ // exactly 0" check that stuck leftward scrolling is gone. Initial rest at
586
+ // 0 warps once to `half` — pixel-identical, and it buys leftward headroom
587
+ // immediately.
658
588
  const marqueeActiveRef = useRef(false);
659
589
  marqueeActiveRef.current = marqueeActive;
660
590
  const onSeamWarp = useCallback(() => {
@@ -667,18 +597,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
667
597
  if (!marqueeActiveRef.current) return;
668
598
  const half = singleCopyWidthRef.current;
669
599
  if (half <= 0) return;
600
+ const buffer = seamBuffer();
670
601
  let sl = scroller.scrollLeft;
671
- if (sl >= half) {
602
+ if (sl >= half + buffer) {
672
603
  sl -= half;
673
604
  scroller.scrollLeft = sl;
674
605
  marqueePosRef.current = sl;
675
- } else if (sl <= 0 && lastScrollLeftRef.current > 0.5) {
606
+ } else if (sl < buffer) {
676
607
  sl += half;
677
608
  scroller.scrollLeft = sl;
678
609
  marqueePosRef.current = sl;
679
610
  }
680
- lastScrollLeftRef.current = sl;
681
- }, [syncHoverIfScrolled]);
611
+ }, [syncHoverIfScrolled, seamBuffer]);
682
612
 
683
613
  // ---- render ----------------------------------------------------------------
684
614
 
@@ -172,6 +172,7 @@ export * from './ticket-detail-section'
172
172
  export * from './ticket-attachments-list'
173
173
  export * from './ticket-note-card'
174
174
  export * from './ticket-notes-section'
175
+ export * from './marquee-wall'
175
176
  export * from './simple-markdown-renderer'
176
177
  export { RichMarkdownRenderer, type RichMarkdownRendererProps } from './rich-markdown-renderer'
177
178
  export * from './filter-pill-row'