@flamingo-stack/openframe-frontend-core 0.0.388-snapshot.20260709030118 → 0.0.388

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 (95) hide show
  1. package/dist/{chunk-LFLFQ422.js → chunk-23BJ64AY.js} +2 -2
  2. package/dist/{chunk-WIMXB5EU.js → chunk-35C3PUUN.js} +4 -4
  3. package/dist/{chunk-VXJBE5NX.cjs → chunk-6ZAQOYW4.cjs} +59 -59
  4. package/dist/{chunk-VXJBE5NX.cjs.map → chunk-6ZAQOYW4.cjs.map} +1 -1
  5. package/dist/{chunk-4OLIDOLO.js → chunk-7CEJO3FE.js} +2 -2
  6. package/dist/{chunk-NULFIYK2.js → chunk-7UQ74ZJN.js} +2 -2
  7. package/dist/{chunk-BJPQJNSC.js → chunk-ATKRYY4U.js} +4 -4
  8. package/dist/{chunk-2JCSORCJ.cjs → chunk-B4JRV4FB.cjs} +26 -26
  9. package/dist/{chunk-2JCSORCJ.cjs.map → chunk-B4JRV4FB.cjs.map} +1 -1
  10. package/dist/{chunk-KA4WXCN3.js → chunk-BGYWTU2E.js} +4 -4
  11. package/dist/{chunk-XGOX5UQ2.js → chunk-DM3JIOFA.js} +4 -4
  12. package/dist/{chunk-CQXHHVMR.js → chunk-EQKTBIKH.js} +2 -2
  13. package/dist/{chunk-ZFZAHLI4.cjs → chunk-EYQESL6X.cjs} +7 -7
  14. package/dist/{chunk-ZFZAHLI4.cjs.map → chunk-EYQESL6X.cjs.map} +1 -1
  15. package/dist/{chunk-M6ODI2F5.js → chunk-FS4FVNOU.js} +2 -2
  16. package/dist/{chunk-3JQUNKWY.cjs → chunk-HIODHJCJ.cjs} +15 -15
  17. package/dist/{chunk-3JQUNKWY.cjs.map → chunk-HIODHJCJ.cjs.map} +1 -1
  18. package/dist/{chunk-MPOP47ME.cjs → chunk-HQG5AY64.cjs} +12 -12
  19. package/dist/{chunk-MPOP47ME.cjs.map → chunk-HQG5AY64.cjs.map} +1 -1
  20. package/dist/{chunk-Q4YGG3CL.cjs → chunk-KYN2GYUI.cjs} +31 -31
  21. package/dist/{chunk-Q4YGG3CL.cjs.map → chunk-KYN2GYUI.cjs.map} +1 -1
  22. package/dist/{chunk-OBKCQ7LA.cjs → chunk-ONXN77KP.cjs} +62 -62
  23. package/dist/{chunk-OBKCQ7LA.cjs.map → chunk-ONXN77KP.cjs.map} +1 -1
  24. package/dist/{chunk-44Q3IXZM.js → chunk-SENYQRPF.js} +2 -2
  25. package/dist/{chunk-TQZLC3BQ.cjs → chunk-SNFSLYVA.cjs} +5 -5
  26. package/dist/{chunk-TQZLC3BQ.cjs.map → chunk-SNFSLYVA.cjs.map} +1 -1
  27. package/dist/{chunk-JSFHTIKA.js → chunk-UCQWQ7LM.js} +2 -2
  28. package/dist/{chunk-IDWKP5H5.js → chunk-VCRUAKOJ.js} +245 -521
  29. package/dist/chunk-VCRUAKOJ.js.map +1 -0
  30. package/dist/{chunk-GNB5ERZP.cjs → chunk-VOPTCR5I.cjs} +735 -1011
  31. package/dist/chunk-VOPTCR5I.cjs.map +1 -0
  32. package/dist/{chunk-MIEODAAV.cjs → chunk-WIKFWION.cjs} +9 -9
  33. package/dist/{chunk-MIEODAAV.cjs.map → chunk-WIKFWION.cjs.map} +1 -1
  34. package/dist/{chunk-7MKU2EB4.cjs → chunk-YKPQINSZ.cjs} +26 -26
  35. package/dist/{chunk-7MKU2EB4.cjs.map → chunk-YKPQINSZ.cjs.map} +1 -1
  36. package/dist/{chunk-EHTE3CTT.cjs → chunk-ZVFDJ35S.cjs} +37 -37
  37. package/dist/{chunk-EHTE3CTT.cjs.map → chunk-ZVFDJ35S.cjs.map} +1 -1
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/contact/index.cjs +3 -3
  43. package/dist/components/contact/index.js +2 -2
  44. package/dist/components/docs/index.cjs +5 -5
  45. package/dist/components/docs/index.js +4 -4
  46. package/dist/components/embeds/index.cjs +3 -3
  47. package/dist/components/embeds/index.js +2 -2
  48. package/dist/components/faq/index.cjs +3 -3
  49. package/dist/components/faq/index.js +2 -2
  50. package/dist/components/features/index.cjs +2 -4
  51. package/dist/components/features/index.cjs.map +1 -1
  52. package/dist/components/features/index.js +1 -3
  53. package/dist/components/features/video-bites-strip.d.ts +4 -43
  54. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  55. package/dist/components/features/video.d.ts +0 -6
  56. package/dist/components/features/video.d.ts.map +1 -1
  57. package/dist/components/help-center-pages/index.cjs +20 -20
  58. package/dist/components/help-center-pages/index.js +11 -11
  59. package/dist/components/index.cjs +120 -122
  60. package/dist/components/index.cjs.map +1 -1
  61. package/dist/components/index.js +9 -11
  62. package/dist/components/index.js.map +1 -1
  63. package/dist/components/navigation/index.cjs +2 -2
  64. package/dist/components/navigation/index.js +1 -1
  65. package/dist/components/onboarding-guides/index.cjs +5 -5
  66. package/dist/components/onboarding-guides/index.js +4 -4
  67. package/dist/components/related-content/index.cjs +3 -3
  68. package/dist/components/related-content/index.js +2 -2
  69. package/dist/components/tickets/index.cjs +5 -5
  70. package/dist/components/tickets/index.js +4 -4
  71. package/dist/components/ui/index.cjs +2 -2
  72. package/dist/components/ui/index.js +1 -1
  73. package/dist/index.cjs +2 -4
  74. package/dist/index.cjs.map +1 -1
  75. package/dist/index.js +1 -3
  76. package/dist/types/case-study.d.ts +1 -1
  77. package/dist/types/case-study.d.ts.map +1 -1
  78. package/package.json +1 -1
  79. package/src/components/features/video-bites-strip.tsx +72 -317
  80. package/src/components/features/video.tsx +37 -167
  81. package/src/components/user-display.tsx +2 -2
  82. package/src/types/case-study.ts +1 -1
  83. package/dist/chunk-GNB5ERZP.cjs.map +0 -1
  84. package/dist/chunk-IDWKP5H5.js.map +0 -1
  85. /package/dist/{chunk-LFLFQ422.js.map → chunk-23BJ64AY.js.map} +0 -0
  86. /package/dist/{chunk-WIMXB5EU.js.map → chunk-35C3PUUN.js.map} +0 -0
  87. /package/dist/{chunk-4OLIDOLO.js.map → chunk-7CEJO3FE.js.map} +0 -0
  88. /package/dist/{chunk-NULFIYK2.js.map → chunk-7UQ74ZJN.js.map} +0 -0
  89. /package/dist/{chunk-BJPQJNSC.js.map → chunk-ATKRYY4U.js.map} +0 -0
  90. /package/dist/{chunk-KA4WXCN3.js.map → chunk-BGYWTU2E.js.map} +0 -0
  91. /package/dist/{chunk-XGOX5UQ2.js.map → chunk-DM3JIOFA.js.map} +0 -0
  92. /package/dist/{chunk-CQXHHVMR.js.map → chunk-EQKTBIKH.js.map} +0 -0
  93. /package/dist/{chunk-M6ODI2F5.js.map → chunk-FS4FVNOU.js.map} +0 -0
  94. /package/dist/{chunk-44Q3IXZM.js.map → chunk-SENYQRPF.js.map} +0 -0
  95. /package/dist/{chunk-JSFHTIKA.js.map → chunk-UCQWQ7LM.js.map} +0 -0
@@ -26,10 +26,9 @@
26
26
  * (`<Video playOnHover>` — sound at 50%, muted fallback).
27
27
  *
28
28
  * Marquee state model (explicit — do not "simplify" into one timer):
29
- * the rAF advances only when the pause-reason set {cardHovered, offViewport,
29
+ * the rAF advances only when the pause-reason set {hover, offViewport,
30
30
  * tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
31
- * userScrollSuppressUntil)`. cardHovered means the pointer is over a CARD
32
- * (incl. its overlay) — leaving the card resumes the marquee immediately. Chevron clicks and manual wheel/touch each set
31
+ * userScrollSuppressUntil)`. Chevron clicks and manual wheel/touch each set
33
32
  * their own suppress-until timestamp; the player leave-grace is a separate
34
33
  * concern that never touches marquee state.
35
34
  */
@@ -86,12 +85,9 @@ export interface VideoBitesStripProps {
86
85
  headerSlot?: React.ReactNode;
87
86
  /** Marquee auto-scroll. Auto-disabled on no-overflow and prefers-reduced-motion. */
88
87
  autoScroll?: boolean;
89
- /** Marquee speed in px/s. Default 60 — marquee libraries (e.g. GSAP marquee)
90
- * default around 100px/s; 60 keeps drift lively while cards stay easy to
91
- * hover-target. 60px/s ≈ 1px per 60Hz frame, the smoothest integer step. */
88
+ /** Marquee speed in px/s. */
92
89
  autoScrollSpeed?: number;
93
- /** Pause the marquee while a CARD is hovered (resumes as soon as the
94
- * pointer leaves the card — strip whitespace/heading never pauses). */
90
+ /** Pause the marquee while the pointer is anywhere over the strip. */
95
91
  pauseOnHover?: boolean;
96
92
  /** Card height in px per breakpoint (Figma: 416 desktop). */
97
93
  cardHeightDesktop?: number;
@@ -122,7 +118,7 @@ export function VideoBitesStrip({
122
118
  href,
123
119
  headerSlot,
124
120
  autoScroll = true,
125
- autoScrollSpeed = 60,
121
+ autoScrollSpeed = 28,
126
122
  pauseOnHover = true,
127
123
  cardHeightDesktop = 416,
128
124
  cardHeightMobile = 300,
@@ -187,7 +183,7 @@ export function VideoBitesStrip({
187
183
  }, [measure, items.length, marqueeActive, isMobile]);
188
184
 
189
185
  // ---- pause-reason set + suppress timestamps ---------------------------------
190
- // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
186
+ const hoveredRef = useRef(false);
191
187
  const nearViewportRef = useRef(true);
192
188
  const chevronSuppressUntilRef = useRef(0);
193
189
  const userScrollSuppressUntilRef = useRef(0);
@@ -206,62 +202,26 @@ export function VideoBitesStrip({
206
202
  }, []);
207
203
 
208
204
  // ---- marquee rAF engine ------------------------------------------------------
209
- // Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
210
- // scrollLeft back each frame returns a rounded value, so sub-pixel increments
211
- // (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
212
- // ran measurably slower than the configured speed because of it.
213
- //
214
- // Chevron GLIDE also runs through this engine (glideRemainingRef): browser
215
- // `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
216
- // seam warp mid-animation made it lunge a full copy-width to catch up, warp
217
- // again, and oscillate — rapid chevron clicks left every card flickering.
218
- // The rAF tween consumes a signed remaining-distance instead, wrapping
219
- // modulo the copy width each frame, so warps are invisible to it.
220
- const marqueePosRef = useRef(0);
221
- const glideRemainingRef = useRef(0);
222
205
  useEffect(() => {
223
206
  if (!marqueeActive) return;
224
207
  const scroller = scrollerRef.current;
225
208
  if (!scroller) return;
226
209
  let raf = 0;
227
210
  let last = performance.now();
228
- marqueePosRef.current = scroller.scrollLeft;
229
- const wrap = (pos: number) => {
230
- const half = singleCopyWidthRef.current;
231
- if (half <= 0) return pos;
232
- while (pos >= half) pos -= half;
233
- while (pos < 0) pos += half;
234
- return pos;
235
- };
236
211
  const tick = (now: number) => {
237
212
  const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
238
213
  last = now;
239
214
  const paused =
240
- (pauseOnHover && activeKeyRef.current !== null) ||
215
+ (pauseOnHover && hoveredRef.current) ||
241
216
  !nearViewportRef.current ||
242
217
  document.visibilityState === 'hidden' ||
243
218
  now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
244
- const glide = glideRemainingRef.current;
245
- if (glide !== 0) {
246
- // Ease-out toward the chevron target; runs even while "paused"
247
- // (the chevron click itself sets the suppress window).
248
- if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
249
- marqueePosRef.current = scroller.scrollLeft;
250
- }
251
- const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
252
- const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
253
- glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
254
- marqueePosRef.current = wrap(marqueePosRef.current + step);
255
- scroller.scrollLeft = marqueePosRef.current;
256
- } else if (!paused) {
257
- // Resync after external movement (user scroll, seam warp).
258
- if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
259
- marqueePosRef.current = scroller.scrollLeft;
219
+ if (!paused) {
220
+ scroller.scrollLeft += autoScrollSpeed * dt;
221
+ const half = singleCopyWidthRef.current;
222
+ if (half > 0 && scroller.scrollLeft >= half) {
223
+ scroller.scrollLeft -= half; // seamless wrap at the clone seam
260
224
  }
261
- marqueePosRef.current = wrap(marqueePosRef.current + autoScrollSpeed * dt);
262
- scroller.scrollLeft = marqueePosRef.current;
263
- } else {
264
- marqueePosRef.current = scroller.scrollLeft;
265
225
  }
266
226
  raf = requestAnimationFrame(tick);
267
227
  };
@@ -275,80 +235,14 @@ export function VideoBitesStrip({
275
235
  // the player is mounted per-card via near-viewport gating and plays on hover
276
236
  // through <Video playOnHover> (normal controls, no chrome fork).
277
237
  const [activeKey, setActiveKey] = useState<string | null>(null);
278
- // Ref mirror for the rAF loop: the marquee pauses ONLY while a CARD is
279
- // hovered (activeKey set) — hovering strip whitespace/heading does not
280
- // pause, and leaving a card resumes immediately.
281
- const activeKeyRef = useRef<string | null>(null);
282
- const activate = useCallback((key: string) => {
283
- activeKeyRef.current = key;
284
- setActiveKey(key);
285
- }, []);
238
+ const activate = useCallback((key: string) => setActiveKey(key), []);
286
239
  const deactivate = useCallback((key: string) => {
287
- setActiveKey(current => {
288
- const next = current === key ? null : current;
289
- activeKeyRef.current = next;
290
- return next;
291
- });
240
+ setActiveKey(current => (current === key ? null : current));
292
241
  }, []);
293
242
 
294
243
  // Preconnect Mux/Supabase origins once so first hover starts fast.
295
244
  const warmup = useVideoWarmup<HTMLDivElement>({ videoUrl: items[0]?.url || null });
296
245
 
297
- // ---- shared player-mount gate (by ITEM index, across both copies) -----------
298
- // A card and its clone show identical content half a copy-width apart. If
299
- // each card gated its own player, a seam warp instantly swapped the visible
300
- // copy for cards whose players were NOT mounted yet — every wrap crossing
301
- // flashed placeholders. Mounting by INDEX (near in EITHER copy → both copies
302
- // mount) makes the warp pixel-identical. Live players stay bounded: ~2× the
303
- // visible strip width.
304
- const [mountedIdx, setMountedIdx] = useState<ReadonlySet<number>>(() => new Set());
305
- const cardObserverRef = useRef<IntersectionObserver | null>(null);
306
- const cardElIdxRef = useRef(new Map<Element, number>());
307
- const cardElNearRef = useRef(new Map<Element, boolean>());
308
- useEffect(() => {
309
- if (typeof IntersectionObserver === 'undefined') return;
310
- const io = new IntersectionObserver(entries => {
311
- for (const e of entries) cardElNearRef.current.set(e.target, e.isIntersecting);
312
- const next = new Set<number>();
313
- cardElNearRef.current.forEach((near, el) => {
314
- if (!near) return;
315
- const idx = cardElIdxRef.current.get(el);
316
- if (idx !== undefined) next.add(idx);
317
- });
318
- setMountedIdx(prev => {
319
- if (prev.size === next.size) {
320
- let same = true;
321
- next.forEach(i => { if (!prev.has(i)) same = false; });
322
- if (same) return prev;
323
- }
324
- return next;
325
- });
326
- }, { rootMargin: '500px' });
327
- cardObserverRef.current = io;
328
- cardElIdxRef.current.forEach((_idx, el) => io.observe(el));
329
- return () => { io.disconnect(); cardObserverRef.current = null; };
330
- }, []);
331
- // Stable per-index ref callbacks (a fresh closure per render would detach/
332
- // re-attach the observer every render). React 19 ref-cleanup unregisters.
333
- const cardRefFnsRef = useRef(new Map<number, (el: HTMLDivElement | null) => (() => void) | undefined>());
334
- const getCardRef = useCallback((idx: number) => {
335
- let fn = cardRefFnsRef.current.get(idx);
336
- if (!fn) {
337
- fn = (el: HTMLDivElement | null) => {
338
- if (!el) return undefined;
339
- cardElIdxRef.current.set(el, idx);
340
- cardObserverRef.current?.observe(el);
341
- return () => {
342
- cardElIdxRef.current.delete(el);
343
- cardElNearRef.current.delete(el);
344
- cardObserverRef.current?.unobserve(el);
345
- };
346
- };
347
- cardRefFnsRef.current.set(idx, fn);
348
- }
349
- return fn;
350
- }, []);
351
-
352
246
  // ---- manual navigation -----------------------------------------------------------
353
247
  const scrollByCard = useCallback((dir: 1 | -1) => {
354
248
  const scroller = scrollerRef.current;
@@ -357,47 +251,13 @@ export function VideoBitesStrip({
357
251
  const firstCard = track.firstElementChild as HTMLElement | null;
358
252
  const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX;
359
253
  chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS;
360
- if (marqueeActiveRef.current) {
361
- // Wrap-aware glide via the rAF engine (see engine comment) — clicks
362
- // accumulate distance instead of racing browser smooth-scroll targets.
363
- glideRemainingRef.current += dir * step;
364
- } else {
365
- // No clones/seam without the marquee — native smooth scroll is safe.
366
- scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
367
- }
254
+ scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
368
255
  }, []);
369
256
 
370
257
  const onUserScrollIntent = useCallback(() => {
371
258
  userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
372
259
  }, []);
373
260
 
374
- // Never-ending strip: warp across the clone seam on EVERY scroll (manual
375
- // wheel/drag/chevron included — the rAF only wraps while the marquee runs,
376
- // so without this a user scrolling during a pause/suppress window hits the
377
- // physical track edges). Both copies render identical content, so a ±half
378
- // jump is pixel-invisible. Backward warp only when arriving AT the left
379
- // edge from the right (never on initial rest at 0).
380
- const lastScrollLeftRef = useRef(0);
381
- const marqueeActiveRef = useRef(false);
382
- marqueeActiveRef.current = marqueeActive;
383
- const onSeamWarp = useCallback(() => {
384
- const scroller = scrollerRef.current;
385
- if (!scroller || !marqueeActiveRef.current) return;
386
- const half = singleCopyWidthRef.current;
387
- if (half <= 0) return;
388
- let sl = scroller.scrollLeft;
389
- if (sl >= half) {
390
- sl -= half;
391
- scroller.scrollLeft = sl;
392
- marqueePosRef.current = sl;
393
- } else if (sl <= 0 && lastScrollLeftRef.current > 0.5) {
394
- sl += half;
395
- scroller.scrollLeft = sl;
396
- marqueePosRef.current = sl;
397
- }
398
- lastScrollLeftRef.current = sl;
399
- }, []);
400
-
401
261
  if (items.length === 0) return null;
402
262
 
403
263
  const cardHeight = isMobile ? cardHeightMobile : cardHeightDesktop;
@@ -407,6 +267,8 @@ export function VideoBitesStrip({
407
267
  <div
408
268
  ref={node => { wrapperRef.current = node; warmup.ref(node); }}
409
269
  className={cn('flex flex-col gap-6 w-full min-w-0', className)}
270
+ onPointerEnter={() => { hoveredRef.current = true; }}
271
+ onPointerLeave={() => { hoveredRef.current = false; }}
410
272
  >
411
273
  {showTitle && (
412
274
  <h2 className={`${SECTION_HEADING_CLASS} break-words`}>{title}</h2>
@@ -420,7 +282,6 @@ export function VideoBitesStrip({
420
282
  onWheel={onUserScrollIntent}
421
283
  onTouchStart={onUserScrollIntent}
422
284
  onPointerDown={onUserScrollIntent}
423
- onScroll={onSeamWarp}
424
285
  >
425
286
  <div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
426
287
  {Array.from({ length: copies }, (_, copyIndex) =>
@@ -431,7 +292,7 @@ export function VideoBitesStrip({
431
292
  // Clone copy: aria-hidden + no focusable descendants (WCAG —
432
293
  // same rule as quick-action-marquee). Clones stay pointer-
433
294
  // interactive (briefly visible near the wrap seam).
434
- <VideoBiteCard
295
+ <BiteStripCard
435
296
  key={key}
436
297
  bite={bite}
437
298
  index={i}
@@ -442,8 +303,6 @@ export function VideoBitesStrip({
442
303
  active={activeKey === key}
443
304
  onActivate={activate}
444
305
  onDeactivate={deactivate}
445
- playerMounted={mountedIdx.has(i)}
446
- rootRef={getCardRef(i)}
447
306
  profile={bite.profile ?? profile ?? null}
448
307
  sectionHref={href}
449
308
  onBiteNavigate={onBiteNavigate}
@@ -481,91 +340,53 @@ export function VideoBitesStrip({
481
340
  }
482
341
 
483
342
  // =============================================================================
484
- // Card — THE bite card (exported). One component for every surface: the
485
- // public strip passes controlled hover/mount state; the admin bites editor
486
- // renders it standalone (self-managed hover, grid-cell sizing, action slots,
487
- // inline title editing) so the admin sees EXACTLY the public card.
343
+ // Card (private)
488
344
  // =============================================================================
489
345
 
490
- export interface VideoBiteCardProps {
346
+ interface BiteStripCardProps {
491
347
  bite: VideoBiteStripItem;
492
348
  index: number;
493
- profile?: VideoBiteStripProfile | null;
349
+ cardKey: string;
350
+ isClone: boolean;
351
+ isTouch: boolean;
352
+ height: number;
353
+ active: boolean;
354
+ onActivate: (key: string) => void;
355
+ onDeactivate: (key: string) => void;
356
+ profile: VideoBiteStripProfile | null;
494
357
  sectionHref?: string;
495
358
  onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;
496
- /** Fixed card height (strip). Omit → width-driven: card fills its grid
497
- * cell and the aspect-ratio derives the height (editor grids). */
498
- height?: number;
499
- /** Controlled hover-activation (strip). Omit → the card manages its own
500
- * hover/focus state. */
501
- active?: boolean;
502
- onActivate?: (key: string) => void;
503
- onDeactivate?: (key: string) => void;
504
- cardKey?: string;
505
- isClone?: boolean;
506
- isTouch?: boolean;
507
- /** Controlled player mount (the strip's shared-by-index gate). Omit → the
508
- * card runs its own two-way near-viewport observer. */
509
- playerMounted?: boolean;
510
- /** Root-element ref hook (strip observer registration). May return a
511
- * cleanup (React 19 ref contract). */
512
- rootRef?: (el: HTMLDivElement | null) => (() => void) | undefined;
513
- /** Admin action toolbar rendered BELOW the media on a solid surface —
514
- * never floated over the video (white icons on a white frame fail the
515
- * WCAG 3:1 non-text contrast minimum; a solid `bg-ods-card` row always
516
- * passes). Editor passes publish / download / star / upload / delete. */
517
- toolbar?: React.ReactNode;
518
- /** Admin: the overlay title renders as an inline editor. */
519
- titleEditable?: boolean;
520
- onTitleChange?: (value: string) => void;
521
- onTitleCommit?: (value: string) => void;
522
- className?: string;
523
359
  }
524
360
 
525
- export function VideoBiteCard({
361
+ function BiteStripCard({
526
362
  bite,
527
363
  index,
528
- profile = null,
529
- sectionHref,
530
- onBiteNavigate,
364
+ cardKey,
365
+ isClone,
366
+ isTouch,
531
367
  height,
532
368
  active,
533
369
  onActivate,
534
370
  onDeactivate,
535
- cardKey,
536
- isClone = false,
537
- isTouch = false,
538
- playerMounted,
539
- rootRef,
540
- toolbar,
541
- titleEditable = false,
542
- onTitleChange,
543
- onTitleCommit,
544
- className,
545
- }: VideoBiteCardProps) {
371
+ profile,
372
+ sectionHref,
373
+ onBiteNavigate,
374
+ }: BiteStripCardProps) {
546
375
  const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
547
376
  const targetHref = bite.href ?? sectionHref;
548
377
  const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);
549
- const key = cardKey ?? `${bite.url}__${index}`;
550
-
551
- // Hover activation: controlled by the strip (activeKey) or self-managed
552
- // when rendered standalone (admin editor).
553
- const controlled = active !== undefined;
554
- const [selfActive, setSelfActive] = useState(false);
555
- const isActive = controlled ? !!active : selfActive;
556
- const activate = () => (controlled ? onActivate?.(key) : setSelfActive(true));
557
- const deactivate = () => (controlled ? onDeactivate?.(key) : setSelfActive(false));
558
-
559
- // Player mount: controlled (strip's shared-by-index gate) or an internal
560
- // TWO-WAY near-viewport observer (standalone). Two-way — NOT the fire-once
561
- // `useNearViewport` — so players unmount again >500px away and live
562
- // MuxPlayer instances stay bounded.
563
- const gateControlled = playerMounted !== undefined;
564
- const rootElRef = useRef<HTMLDivElement | null>(null);
378
+
379
+ // TWO-WAY viewport gating (unlike the fire-once `useNearViewport`): the
380
+ // marquee cycles every card past the viewport, so a fire-once gate would
381
+ // permanently mount a player per card (originals + clones). This observer
382
+ // unmounts players again once the card scrolls >500px away, bounding live
383
+ // MuxPlayer instances to roughly the visible strip. Near cards render the
384
+ // REAL player (first frame + center play control); hover-preview comes
385
+ // from <Video playOnHover>.
386
+ const nearRef = useRef<HTMLDivElement | null>(null);
565
387
  const [isNear, setIsNear] = useState(false);
566
388
  useEffect(() => {
567
- if (gateControlled) return;
568
- const el = rootElRef.current;
389
+ const el = nearRef.current;
569
390
  if (!el || typeof IntersectionObserver === 'undefined') return;
570
391
  const io = new IntersectionObserver(
571
392
  entries => setIsNear(entries[0]?.isIntersecting ?? false),
@@ -573,56 +394,24 @@ export function VideoBiteCard({
573
394
  );
574
395
  io.observe(el);
575
396
  return () => io.disconnect();
576
- }, [gateControlled]);
577
- const showPlayer = gateControlled ? playerMounted : isNear;
397
+ }, []);
578
398
 
579
399
  const navigate = () => {
580
400
  if (bite.onNavigate) bite.onNavigate();
581
401
  else onBiteNavigate?.(bite, index);
582
402
  };
583
403
 
584
- const handlePointerEnter = () => { if (!isTouch) activate(); };
585
- const handlePointerLeave = () => { if (!isTouch) deactivate(); };
586
- const handleClick = () => { if (isTouch && !isActive) activate(); };
404
+ const handlePointerEnter = () => { if (!isTouch) onActivate(cardKey); };
405
+ const handlePointerLeave = () => { if (!isTouch) onDeactivate(cardKey); };
406
+ const handleClick = () => { if (isTouch && !active) onActivate(cardKey); };
587
407
 
588
408
  // Bottom-docked detail (Figma node 4033:90369): title row + profile row +
589
409
  // chevron affordance. The WHOLE footer is the navigation target — it links
590
- // to the entity the bite originated from. In the editor the title row is
591
- // the inline title editor (edited directly on the card).
592
- const titleClass = "font-['DM_Sans'] text-sm font-medium leading-5 text-ods-text-primary";
410
+ // to the entity the bite originated from.
593
411
  const overlayContent = (
594
412
  <>
595
- {titleEditable ? (
596
- // Textarea, not input: the public title wraps to two lines
597
- // (line-clamp-2) and the editor must render identically. rows=1 +
598
- // auto-grow keeps short titles single-line; max-height caps at the
599
- // same two lines the clamp allows.
600
- <textarea
601
- value={bite.title || ''}
602
- placeholder="Title (optional)"
603
- aria-label="Bite title"
604
- rows={1}
605
- onChange={e => {
606
- e.target.style.height = 'auto';
607
- e.target.style.height = `${Math.min(e.target.scrollHeight, 40)}px`;
608
- onTitleChange?.(e.target.value);
609
- }}
610
- ref={el => {
611
- if (el) {
612
- el.style.height = 'auto';
613
- el.style.height = `${Math.min(el.scrollHeight, 40)}px`;
614
- }
615
- }}
616
- onBlur={e => onTitleCommit?.(e.target.value)}
617
- onClick={e => e.stopPropagation()}
618
- className={cn(
619
- titleClass,
620
- 'w-full max-h-10 resize-none overflow-hidden bg-transparent outline-none',
621
- 'placeholder:text-ods-text-secondary border-b border-transparent focus:border-ods-border',
622
- )}
623
- />
624
- ) : (
625
- bite.title && <p className={cn(titleClass, 'line-clamp-2')}>{bite.title}</p>
413
+ {bite.title && (
414
+ <p className="font-['DM_Sans'] text-sm font-medium leading-5 text-ods-text-primary line-clamp-1">{bite.title}</p>
626
415
  )}
627
416
  {(profile || hasTarget) && (
628
417
  <div className="flex items-center gap-2 min-w-0">
@@ -631,7 +420,7 @@ export function VideoBiteCard({
631
420
  name={profile.name}
632
421
  avatarUrl={profile.avatarUrl}
633
422
  subtitle={profile.subtitle}
634
- size={22}
423
+ size={28}
635
424
  shape="round"
636
425
  compact
637
426
  className="flex-1"
@@ -651,36 +440,33 @@ export function VideoBiteCard({
651
440
  const overlayClass = cn(
652
441
  'absolute inset-x-0 bottom-0 p-3 gap-2 bg-black/75 border border-ods-border shadow-2xl',
653
442
  'flex flex-col transition-opacity duration-200',
654
- isActive ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100',
443
+ active ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100',
655
444
  // Non-interactive while invisible so it never swallows clicks on the
656
445
  // resting card / player controls.
657
- isActive ? 'pointer-events-auto' : 'pointer-events-none group-hover/card:pointer-events-auto group-focus-within/card:pointer-events-auto',
446
+ active ? 'pointer-events-auto' : 'pointer-events-none group-hover/card:pointer-events-auto',
658
447
  );
659
448
 
660
- const media = (
449
+ return (
661
450
  <div
662
- className="relative w-full"
663
- // Strip mode: fixed height + aspect drive the width (capped at 90vw,
664
- // same as the old single-box layout). Editor mode: width-driven.
665
- style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth: '90vw' } : {}) }}
451
+ ref={nearRef}
452
+ aria-hidden={isClone || undefined}
453
+ className="relative shrink-0 rounded-md border border-ods-border bg-ods-card overflow-hidden group/card"
454
+ style={{ height, aspectRatio: cssAspect, maxWidth: '90vw' }}
455
+ onPointerEnter={handlePointerEnter}
456
+ onPointerLeave={handlePointerLeave}
457
+ onClick={handleClick}
458
+ onFocus={() => onActivate(cardKey)}
459
+ onBlur={() => onDeactivate(cardKey)}
666
460
  >
667
- {showPlayer ? (
668
- // Clones: `inert` (not just the wrapper's aria-hidden) — the player's
669
- // shadow-DOM center control is otherwise still tab-reachable inside a
670
- // hidden subtree (axe aria-hidden-focus). Hover preview on clones
671
- // keeps working: playback is driven imperatively from the CARD's own
672
- // pointer handlers, never from focus/clicks on the player chrome.
673
- <div className="absolute inset-0" inert={isClone || undefined}>
674
- {/* CONTROLLED hover playback keyed to CARD hover (`isActive`): the
675
- detail overlay is part of the card, so moving the pointer onto
676
- it keeps playing. Sound at 50% (pre-activation: muted start +
677
- live unmute on the user's first gesture); chrome = center
678
- play/pause only (Figma card look). */}
461
+ {isNear ? (
462
+ <div className="absolute inset-0">
463
+ {/* Hover = auto-play WITH sound at 50% (muted fallback on autoplay
464
+ policy); chrome = center play/pause only (Figma card look). */}
679
465
  <Video
680
466
  kind="file"
681
467
  url={bite.url}
682
468
  poster={bite.thumbnail_url}
683
- playWhenHovered={isActive}
469
+ playOnHover
684
470
  centerControlsOnly
685
471
  layout="fill"
686
472
  />
@@ -690,7 +476,7 @@ export function VideoBiteCard({
690
476
  <div className="absolute inset-0 bg-ods-card" />
691
477
  )}
692
478
 
693
- {hasTarget && !isClone && !titleEditable ? (
479
+ {hasTarget && !isClone ? (
694
480
  targetHref ? (
695
481
  <a href={targetHref} aria-label={`Open ${bite.title || 'source content'}`} className={overlayClass}>
696
482
  {overlayContent}
@@ -705,35 +491,4 @@ export function VideoBiteCard({
705
491
  )}
706
492
  </div>
707
493
  );
708
-
709
- return (
710
- <div
711
- ref={node => {
712
- rootElRef.current = node;
713
- return rootRef?.(node);
714
- }}
715
- aria-hidden={isClone || undefined}
716
- className={cn(
717
- 'relative rounded-md border border-ods-border bg-ods-card overflow-hidden group/card',
718
- height !== undefined ? 'shrink-0' : 'w-full',
719
- className,
720
- )}
721
- style={height !== undefined ? { maxWidth: '90vw' } : undefined}
722
- onPointerEnter={handlePointerEnter}
723
- onPointerLeave={handlePointerLeave}
724
- onClick={handleClick}
725
- onFocus={activate}
726
- onBlur={deactivate}
727
- >
728
- {media}
729
- {toolbar && (
730
- // Solid-surface action row UNDER the media (WCAG non-text contrast —
731
- // see the `toolbar` prop doc). Part of the card, so hovering it keeps
732
- // hover playback alive.
733
- <div className="flex items-center justify-between gap-1 border-t border-ods-border bg-ods-card px-2 py-1.5">
734
- {toolbar}
735
- </div>
736
- )}
737
- </div>
738
- );
739
494
  }