@flamingo-stack/openframe-frontend-core 0.0.448 → 0.0.449-snapshot.20260718143201

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 (144) hide show
  1. package/dist/{chunk-P62PIQSY.cjs → chunk-26JSJMMZ.cjs} +7 -7
  2. package/dist/{chunk-P62PIQSY.cjs.map → chunk-26JSJMMZ.cjs.map} +1 -1
  3. package/dist/{chunk-PHH7M3CF.js → chunk-3ITY2LBM.js} +4 -4
  4. package/dist/{chunk-AEPR6AXO.js → chunk-4SZDO3TE.js} +3 -3
  5. package/dist/{chunk-AMS55EES.js → chunk-4VSTLJDO.js} +2 -2
  6. package/dist/{chunk-7YSVW2JG.cjs → chunk-5HW5HMNF.cjs} +5 -5
  7. package/dist/{chunk-7YSVW2JG.cjs.map → chunk-5HW5HMNF.cjs.map} +1 -1
  8. package/dist/{chunk-3RM5Z2SH.cjs → chunk-5OY7HYII.cjs} +73 -73
  9. package/dist/{chunk-3RM5Z2SH.cjs.map → chunk-5OY7HYII.cjs.map} +1 -1
  10. package/dist/{chunk-X62WEO5Z.cjs → chunk-6GJKSYM2.cjs} +73 -73
  11. package/dist/{chunk-X62WEO5Z.cjs.map → chunk-6GJKSYM2.cjs.map} +1 -1
  12. package/dist/{chunk-JM5VSYQB.cjs → chunk-6RA4NRBA.cjs} +15 -15
  13. package/dist/{chunk-JM5VSYQB.cjs.map → chunk-6RA4NRBA.cjs.map} +1 -1
  14. package/dist/{chunk-TGEAT3G4.cjs → chunk-76EN3GEH.cjs} +40 -40
  15. package/dist/{chunk-TGEAT3G4.cjs.map → chunk-76EN3GEH.cjs.map} +1 -1
  16. package/dist/{chunk-CAEQ5BQO.js → chunk-CM5XKHHS.js} +6 -6
  17. package/dist/{chunk-HKMQ3LHY.cjs → chunk-DQ5U6DCR.cjs} +18 -18
  18. package/dist/{chunk-HKMQ3LHY.cjs.map → chunk-DQ5U6DCR.cjs.map} +1 -1
  19. package/dist/{chunk-YHNBJ3F3.js → chunk-EQWE64RJ.js} +2 -2
  20. package/dist/{chunk-QJN4XSP4.js → chunk-HXCQHMS5.js} +6424 -6035
  21. package/dist/chunk-HXCQHMS5.js.map +1 -0
  22. package/dist/{chunk-UAOR2SRY.js → chunk-HYIHXXF3.js} +5 -5
  23. package/dist/{chunk-ZGERD4OP.cjs → chunk-J5BYYZVM.cjs} +32 -32
  24. package/dist/{chunk-ZGERD4OP.cjs.map → chunk-J5BYYZVM.cjs.map} +1 -1
  25. package/dist/{chunk-KP2E22VP.js → chunk-K3GXIGN6.js} +5 -5
  26. package/dist/{chunk-PJ27XJRQ.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-ZRYCV52Q.js → chunk-N53FC732.js} +2 -2
  30. package/dist/{chunk-M2U5KN4Q.cjs → chunk-N5M72LJX.cjs} +1660 -1271
  31. package/dist/chunk-N5M72LJX.cjs.map +1 -0
  32. package/dist/{chunk-PFRUS5M4.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-NQTASSKI.cjs → chunk-ROBGJH52.cjs} +12 -12
  36. package/dist/{chunk-NQTASSKI.cjs.map → chunk-ROBGJH52.cjs.map} +1 -1
  37. package/dist/{chunk-T245XRIS.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-CCSCZSXS.cjs → chunk-VLDZ4363.cjs} +29 -29
  41. package/dist/{chunk-CCSCZSXS.cjs.map → chunk-VLDZ4363.cjs.map} +1 -1
  42. package/dist/{chunk-ANJ56NCM.js → chunk-VUDZOCPW.js} +6 -6
  43. package/dist/{chunk-5Z3YOBRE.cjs → chunk-XITG5ICQ.cjs} +40 -40
  44. package/dist/{chunk-5Z3YOBRE.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 +2 -1
  70. package/dist/components/features/cards-strip.d.ts.map +1 -1
  71. package/dist/components/features/index.cjs +3 -3
  72. package/dist/components/features/index.js +2 -2
  73. package/dist/components/form.d.ts +1 -1
  74. package/dist/components/form.d.ts.map +1 -1
  75. package/dist/components/help-center-pages/index.cjs +22 -22
  76. package/dist/components/help-center-pages/index.js +13 -13
  77. package/dist/components/index.cjs +159 -135
  78. package/dist/components/index.cjs.map +1 -1
  79. package/dist/components/index.js +35 -11
  80. package/dist/components/index.js.map +1 -1
  81. package/dist/components/navigation/index.cjs +3 -3
  82. package/dist/components/navigation/index.js +2 -2
  83. package/dist/components/onboarding-guides/index.cjs +7 -7
  84. package/dist/components/onboarding-guides/index.js +6 -6
  85. package/dist/components/related-content/index.cjs +5 -5
  86. package/dist/components/related-content/index.js +4 -4
  87. package/dist/components/tickets/index.cjs +6 -6
  88. package/dist/components/tickets/index.js +5 -5
  89. package/dist/components/ui/index.cjs +27 -3
  90. package/dist/components/ui/index.cjs.map +1 -1
  91. package/dist/components/ui/index.d.ts +1 -0
  92. package/dist/components/ui/index.d.ts.map +1 -1
  93. package/dist/components/ui/index.js +26 -2
  94. package/dist/components/ui/marquee-wall.d.ts +159 -0
  95. package/dist/components/ui/marquee-wall.d.ts.map +1 -0
  96. package/dist/hooks/index.cjs +6 -2
  97. package/dist/hooks/index.cjs.map +1 -1
  98. package/dist/hooks/index.js +5 -1
  99. package/dist/hooks/ui/index.d.ts +2 -0
  100. package/dist/hooks/ui/index.d.ts.map +1 -1
  101. package/dist/hooks/ui/use-marquee-engine.d.ts +73 -0
  102. package/dist/hooks/ui/use-marquee-engine.d.ts.map +1 -0
  103. package/dist/hooks/ui/use-suppress-clone-focus.d.ts +24 -0
  104. package/dist/hooks/ui/use-suppress-clone-focus.d.ts.map +1 -0
  105. package/dist/index.cjs +31 -3
  106. package/dist/index.cjs.map +1 -1
  107. package/dist/index.js +30 -2
  108. package/dist/tailwind.config.cjs +1 -8
  109. package/dist/tailwind.config.cjs.map +1 -1
  110. package/dist/tailwind.config.js +1 -8
  111. package/dist/tailwind.config.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/components/chat/chat-quick-action-row.tsx +47 -3
  114. package/src/components/chat/index.ts +1 -0
  115. package/src/components/chat/quick-action-chip.tsx +81 -13
  116. package/src/components/chat/quick-action-marquee.tsx +54 -47
  117. package/src/components/chat/quick-action-wall.tsx +247 -0
  118. package/src/components/features/cards-strip.tsx +160 -144
  119. package/src/components/ui/index.ts +1 -0
  120. package/src/components/ui/marquee-wall.tsx +522 -0
  121. package/src/hooks/ui/index.ts +2 -0
  122. package/src/hooks/ui/use-marquee-engine.ts +158 -0
  123. package/src/hooks/ui/use-suppress-clone-focus.ts +61 -0
  124. package/src/stories/CardsStrip.stories.tsx +5 -3
  125. package/src/stories/MarqueeWall.stories.tsx +132 -0
  126. package/src/stories/QuickActionMarquee.stories.tsx +8 -7
  127. package/src/stories/QuickActionWall.stories.tsx +116 -0
  128. package/tailwind.config.ts +0 -7
  129. package/dist/chunk-AN7MCJTV.cjs.map +0 -1
  130. package/dist/chunk-M2U5KN4Q.cjs.map +0 -1
  131. package/dist/chunk-QJN4XSP4.js.map +0 -1
  132. package/dist/chunk-TKGCTDQY.js.map +0 -1
  133. /package/dist/{chunk-PHH7M3CF.js.map → chunk-3ITY2LBM.js.map} +0 -0
  134. /package/dist/{chunk-AEPR6AXO.js.map → chunk-4SZDO3TE.js.map} +0 -0
  135. /package/dist/{chunk-AMS55EES.js.map → chunk-4VSTLJDO.js.map} +0 -0
  136. /package/dist/{chunk-CAEQ5BQO.js.map → chunk-CM5XKHHS.js.map} +0 -0
  137. /package/dist/{chunk-YHNBJ3F3.js.map → chunk-EQWE64RJ.js.map} +0 -0
  138. /package/dist/{chunk-UAOR2SRY.js.map → chunk-HYIHXXF3.js.map} +0 -0
  139. /package/dist/{chunk-KP2E22VP.js.map → chunk-K3GXIGN6.js.map} +0 -0
  140. /package/dist/{chunk-PJ27XJRQ.js.map → chunk-M3AKWFAO.js.map} +0 -0
  141. /package/dist/{chunk-ZRYCV52Q.js.map → chunk-N53FC732.js.map} +0 -0
  142. /package/dist/{chunk-PFRUS5M4.js.map → chunk-PCW2TRZ4.js.map} +0 -0
  143. /package/dist/{chunk-T245XRIS.js.map → chunk-RQHHACP2.js.map} +0 -0
  144. /package/dist/{chunk-ANJ56NCM.js.map → chunk-VUDZOCPW.js.map} +0 -0
@@ -17,7 +17,8 @@
17
17
  *
18
18
  * MODE B — children (organic): ANY card components as plain JSX children.
19
19
  * Each child is auto-wrapped in a managed cell (fixed width, hover/keyboard
20
- * marquee-pause, clone `aria-hidden` + `inert`, row-height stretch).
20
+ * marquee-pause, clone `aria-hidden` + focus-suppressed but still CLICKABLE,
21
+ * row-height stretch).
21
22
  * Adding a new card type requires ZERO strip code. Contract:
22
23
  * (a) pass an ARRAY of children (`{rows.map(...)}`) — `Children.toArray`
23
24
  * does NOT flatten through a single <Fragment> child (it would become
@@ -43,6 +44,8 @@
43
44
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44
45
  import { cn } from '../../utils/cn';
45
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';
46
49
  import { Button } from '../ui/button';
47
50
  import { SECTION_HEADING_CLASS } from '../layout/page-heading';
48
51
  import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
@@ -146,6 +149,70 @@ export const STRIP_CELL_MAX_WIDTH = '90vw';
146
149
 
147
150
  const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
148
151
 
152
+ // =============================================================================
153
+ // Children-mode managed cell
154
+ // =============================================================================
155
+
156
+ // (Clone focus suppression lives in hooks/ui/use-suppress-clone-focus — shared
157
+ // with <MarqueeWall>, which renders the same 2-copy endless-loop structure.)
158
+
159
+ /**
160
+ * Children-mode cell: fixed width + hover/keyboard activation (wired here, never
161
+ * on the card — the single-place activation invariant). The OUTER cell is never
162
+ * inert/aria-hidden — an inert cell would retarget its pointer events to the
163
+ * track and defeat the clone hover-pause; the INNER wrapper carries the clone
164
+ * a11y treatment.
165
+ */
166
+ function ManagedCell({
167
+ item,
168
+ ctx,
169
+ cardWidthMobile,
170
+ cardWidthDesktop,
171
+ }: {
172
+ item: unknown;
173
+ ctx: CardStripRenderCtx;
174
+ cardWidthMobile: number;
175
+ cardWidthDesktop: number;
176
+ }) {
177
+ // Per-child DESKTOP cell-width hint: heterogeneous card designs need
178
+ // heterogeneous cells (wide horizontal cards vs 400px vertical cards —
179
+ // variable widths are native to the engine, exactly like bite ratios). Read
180
+ // from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps the
181
+ // strip-level width (wide cards stack vertically below md anyway).
182
+ const hintedWidth = React.isValidElement(item)
183
+ ? (item.props as Record<string, unknown>)['data-strip-cell-width']
184
+ : undefined;
185
+ const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
186
+ const cloneRef = useSuppressCloneFocus(ctx.isClone);
187
+ return (
188
+ <div
189
+ className="shrink-0 self-stretch"
190
+ style={{ width: ctx.isMobile ? cardWidthMobile : desktopWidth, maxWidth: STRIP_CELL_MAX_WIDTH }}
191
+ data-strip-card-key={ctx.cardKey}
192
+ onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
193
+ onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
194
+ // Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
195
+ onFocus={() => ctx.onActivate(ctx.cardKey)}
196
+ onBlur={e => {
197
+ // Intra-cell focus moves must not blip the pause off.
198
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
199
+ ctx.onDeactivate(ctx.cardKey);
200
+ }}
201
+ >
202
+ {/* INNER: clone a11y — `aria-hidden` + focusable descendants forced out of
203
+ the tab order (useSuppressCloneFocus), but pointer events preserved so
204
+ a visible clone card stays clickable. Cards untouched. */}
205
+ <div
206
+ ref={cloneRef}
207
+ className="h-full [&>*]:h-full [&>a>*]:h-full"
208
+ aria-hidden={ctx.isClone || undefined}
209
+ >
210
+ {item as React.ReactNode}
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
149
216
  // =============================================================================
150
217
  // Component
151
218
  // =============================================================================
@@ -225,10 +292,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
225
292
  }, []);
226
293
 
227
294
  // ---- overflow measurement ---------------------------------------------------
228
- // `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.
229
303
  const [overflows, setOverflows] = useState(false);
304
+ const [marqueeEligible, setMarqueeEligible] = useState(false);
230
305
  const singleCopyWidthRef = useRef(0);
231
- const marqueeActive = autoScroll && !reducedMotion && overflows && items.length > 0;
306
+ const marqueeActive = autoScroll && !reducedMotion && marqueeEligible && items.length > 0;
232
307
 
233
308
  const measure = useCallback(() => {
234
309
  const scroller = scrollerRef.current;
@@ -239,6 +314,7 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
239
314
  const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
240
315
  singleCopyWidthRef.current = singleCopy;
241
316
  setOverflows(singleCopy > scroller.clientWidth + 1);
317
+ setMarqueeEligible(singleCopy > scroller.clientWidth + TRACK_GAP_PX);
242
318
  }, []);
243
319
 
244
320
  useEffect(() => {
@@ -254,108 +330,26 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
254
330
 
255
331
  // ---- pause-reason set + suppress timestamps ---------------------------------
256
332
  // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
257
- const nearViewportRef = useRef(true);
258
333
  const chevronSuppressUntilRef = useRef(0);
259
334
  const userScrollSuppressUntilRef = useRef(0);
260
335
 
261
- // Two-way viewport gate (unlike the fire-once `useNearViewport`): the rAF
262
- // 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);
263
342
  useEffect(() => {
264
343
  const el = wrapperRef.current;
265
344
  if (!el || typeof IntersectionObserver === 'undefined') return;
266
345
  const io = new IntersectionObserver(
267
- entries => { nearViewportRef.current = entries[0]?.isIntersecting ?? true; },
346
+ entries => setNearViewport(entries[0]?.isIntersecting ?? true),
268
347
  { rootMargin: '200px' },
269
348
  );
270
349
  io.observe(el);
271
350
  return () => io.disconnect();
272
351
  }, []);
273
352
 
274
- // ---- marquee rAF engine ------------------------------------------------------
275
- // Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
276
- // scrollLeft back each frame returns a rounded value, so sub-pixel increments
277
- // (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
278
- // ran measurably slower than the configured speed because of it.
279
- //
280
- // Chevron GLIDE also runs through this engine (glideRemainingRef): browser
281
- // `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
282
- // seam warp mid-animation made it lunge a full copy-width to catch up, warp
283
- // again, and oscillate — rapid chevron clicks left every card flickering.
284
- // The rAF tween consumes a signed remaining-distance instead, wrapping
285
- // modulo the copy width each frame, so warps are invisible to it.
286
- const marqueePosRef = useRef(0);
287
- const glideRemainingRef = useRef(0);
288
- // Velocity envelope (px/s): the marquee EASES between 0 and autoScrollSpeed
289
- // (~250ms time constant) instead of binary stop/start — pause decelerates,
290
- // resume accelerates from the current position (GSAP-marquee behavior;
291
- // hard cuts read as jank).
292
- const speedEnvRef = useRef(0);
293
- useEffect(() => {
294
- if (!marqueeActive) return;
295
- const scroller = scrollerRef.current;
296
- if (!scroller) return;
297
- let raf = 0;
298
- let last = performance.now();
299
- marqueePosRef.current = scroller.scrollLeft;
300
- const wrap = (pos: number) => {
301
- const half = singleCopyWidthRef.current;
302
- if (half <= 0) return pos;
303
- while (pos >= half) pos -= half;
304
- while (pos < 0) pos += half;
305
- return pos;
306
- };
307
- const tick = (now: number) => {
308
- const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
309
- last = now;
310
- // Container-level pause (industry-standard marquee behavior): the
311
- // marquee freezes whenever a hover-capable pointer is ANYWHERE over the
312
- // cards row — content must never move under a pointing cursor. Per-card
313
- // activeKey still pauses for keyboard focus (no pointer involved).
314
- const paused =
315
- (pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
316
- !nearViewportRef.current ||
317
- document.visibilityState === 'hidden' ||
318
- now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
319
- // Ease the marquee velocity toward its target (0 when paused) — smooth
320
- // decel on hover, smooth accel on leave, always from the CURRENT position.
321
- const targetSpeed = paused ? 0 : autoScrollSpeed;
322
- speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25);
323
- if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0;
324
-
325
- const glide = glideRemainingRef.current;
326
- if (glide !== 0) {
327
- // Ease-out toward the chevron target; runs even while "paused"
328
- // (the chevron click itself sets the suppress window).
329
- if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
330
- marqueePosRef.current = scroller.scrollLeft;
331
- }
332
- const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
333
- const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
334
- glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
335
- marqueePosRef.current = wrap(marqueePosRef.current + step);
336
- scroller.scrollLeft = marqueePosRef.current;
337
- } else if (speedEnvRef.current > 0) {
338
- // Resync after external movement (user scroll, seam warp).
339
- if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
340
- marqueePosRef.current = scroller.scrollLeft;
341
- }
342
- marqueePosRef.current = wrap(marqueePosRef.current + speedEnvRef.current * dt);
343
- scroller.scrollLeft = marqueePosRef.current;
344
- } else {
345
- marqueePosRef.current = scroller.scrollLeft;
346
- }
347
- // Track moved under a possibly-stationary pointer → re-resolve which
348
- // card is hovered (declared below; stable useCallback identity, called
349
- // lazily per frame so source order is irrelevant — deliberately NOT a
350
- // dep, same as the activeKeyRef read above).
351
- syncHoverIfScrolled();
352
- raf = requestAnimationFrame(tick);
353
- };
354
- raf = requestAnimationFrame(tick);
355
- return () => cancelAnimationFrame(raf);
356
- // eslint-disable-next-line react-hooks/exhaustive-deps -- syncHoverIfScrolled is identity-stable
357
- }, [marqueeActive, autoScrollSpeed, pauseOnHover]);
358
-
359
353
  // ---- hover / overlay state -----------------------------------------------------
360
354
  // activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
361
355
  // a clone near the wrap seam works too). It only drives per-card `active`
@@ -445,6 +439,54 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
445
439
  syncHoverToPointer();
446
440
  }, [syncHoverToPointer]);
447
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
+
448
490
  // ---- shared card-mount gate (by ITEM index, across both copies) -------------
449
491
  // A card and its clone show identical content half a copy-width apart. If
450
492
  // each card gated its own expensive content (e.g. video players), a seam warp
@@ -511,12 +553,12 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
511
553
  if (marqueeActiveRef.current) {
512
554
  // Wrap-aware glide via the rAF engine (see engine comment) — clicks
513
555
  // accumulate distance instead of racing browser smooth-scroll targets.
514
- glideRemainingRef.current += dir * step;
556
+ glideBy(dir * step);
515
557
  } else {
516
558
  // No clones/seam without the marquee — native smooth scroll is safe.
517
559
  scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
518
560
  }
519
- }, []);
561
+ }, [glideBy]);
520
562
 
521
563
  const onUserScrollIntent = useCallback(() => {
522
564
  userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
@@ -531,13 +573,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
531
573
  if (e.pointerType === 'touch') onUserScrollIntent();
532
574
  }, [onUserScrollIntent]);
533
575
 
534
- // Never-ending strip: warp across the clone seam on EVERY scroll (manual
535
- // wheel/drag/chevron included — the rAF only wraps while the marquee runs,
536
- // so without this a user scrolling during a pause/suppress window hits the
537
- // physical track edges). Both copies render identical content, so a ±half
538
- // jump is pixel-invisible. Backward warp only when arriving AT the left
539
- // edge from the right (never on initial rest at 0).
540
- 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.
541
588
  const marqueeActiveRef = useRef(false);
542
589
  marqueeActiveRef.current = marqueeActive;
543
590
  const onSeamWarp = useCallback(() => {
@@ -550,18 +597,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
550
597
  if (!marqueeActiveRef.current) return;
551
598
  const half = singleCopyWidthRef.current;
552
599
  if (half <= 0) return;
600
+ const buffer = seamBuffer();
553
601
  let sl = scroller.scrollLeft;
554
- if (sl >= half) {
602
+ if (sl >= half + buffer) {
555
603
  sl -= half;
556
604
  scroller.scrollLeft = sl;
557
605
  marqueePosRef.current = sl;
558
- } else if (sl <= 0 && lastScrollLeftRef.current > 0.5) {
606
+ } else if (sl < buffer) {
559
607
  sl += half;
560
608
  scroller.scrollLeft = sl;
561
609
  marqueePosRef.current = sl;
562
610
  }
563
- lastScrollLeftRef.current = sl;
564
- }, [syncHoverIfScrolled]);
611
+ }, [syncHoverIfScrolled, seamBuffer]);
565
612
 
566
613
  // ---- render ----------------------------------------------------------------
567
614
 
@@ -570,47 +617,16 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
570
617
  // Seam assertion (see normalization comment above).
571
618
  return (props.renderCard as (item: unknown, ctx: CardStripRenderCtx) => React.ReactNode)(item, ctx);
572
619
  }
573
- // Per-child DESKTOP cell-width hint: heterogeneous card designs need
574
- // heterogeneous cells (wide horizontal cards vs 400px vertical cards —
575
- // variable widths are native to the engine, exactly like bite ratios).
576
- // Read from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps
577
- // the strip-level width (wide cards stack vertically below md anyway).
578
- const hintedWidth = React.isValidElement(item)
579
- ? (item.props as Record<string, unknown>)['data-strip-cell-width']
580
- : undefined;
581
- const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
582
620
  // Managed cell — the ONLY place cell width / activation / clone a11y are
583
- // encoded for children mode.
621
+ // encoded for children mode (extracted to a component so the clone
622
+ // focus-suppression hook can run per cell; see ManagedCell).
584
623
  return (
585
- // OUTER cell: width + activation handlers (never inert — an inert cell
586
- // would retarget pointer events to the track and defeat hover-pause on
587
- // clones).
588
- <div
589
- className="shrink-0 self-stretch"
590
- style={{ width: ctx.isMobile ? cardWidthMobile : desktopWidth, maxWidth: STRIP_CELL_MAX_WIDTH }}
591
- data-strip-card-key={ctx.cardKey}
592
- onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
593
- onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
594
- // Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
595
- onFocus={() => ctx.onActivate(ctx.cardKey)}
596
- onBlur={e => {
597
- // Intra-cell focus moves must not blip the pause off.
598
- if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
599
- ctx.onDeactivate(ctx.cardKey);
600
- }}
601
- >
602
- {/* INNER: clone a11y + uniform stretch — cards untouched. `inert` on
603
- the clone strips anchor focusability without modifying any card
604
- (WCAG aria-hidden-focus); clone content is pointer/focus-dead by
605
- design while the outer cell still hover-pauses. */}
606
- <div
607
- className="h-full [&>*]:h-full [&>a>*]:h-full"
608
- aria-hidden={ctx.isClone || undefined}
609
- inert={ctx.isClone || undefined}
610
- >
611
- {item as React.ReactNode}
612
- </div>
613
- </div>
624
+ <ManagedCell
625
+ item={item}
626
+ ctx={ctx}
627
+ cardWidthMobile={cardWidthMobile}
628
+ cardWidthDesktop={cardWidthDesktop}
629
+ />
614
630
  );
615
631
  };
616
632
 
@@ -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'