@flamingo-stack/openframe-frontend-core 0.0.388 → 0.0.389

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-B4JRV4FB.cjs → chunk-2JCSORCJ.cjs} +26 -26
  2. package/dist/{chunk-B4JRV4FB.cjs.map → chunk-2JCSORCJ.cjs.map} +1 -1
  3. package/dist/{chunk-HIODHJCJ.cjs → chunk-3JQUNKWY.cjs} +15 -15
  4. package/dist/{chunk-HIODHJCJ.cjs.map → chunk-3JQUNKWY.cjs.map} +1 -1
  5. package/dist/{chunk-SENYQRPF.js → chunk-44Q3IXZM.js} +2 -2
  6. package/dist/{chunk-7CEJO3FE.js → chunk-4OLIDOLO.js} +2 -2
  7. package/dist/{chunk-YKPQINSZ.cjs → chunk-7MKU2EB4.cjs} +26 -26
  8. package/dist/{chunk-YKPQINSZ.cjs.map → chunk-7MKU2EB4.cjs.map} +1 -1
  9. package/dist/{chunk-ATKRYY4U.js → chunk-BJPQJNSC.js} +4 -4
  10. package/dist/{chunk-EQKTBIKH.js → chunk-CQXHHVMR.js} +2 -2
  11. package/dist/{chunk-ZVFDJ35S.cjs → chunk-EHTE3CTT.cjs} +37 -37
  12. package/dist/{chunk-ZVFDJ35S.cjs.map → chunk-EHTE3CTT.cjs.map} +1 -1
  13. package/dist/{chunk-VOPTCR5I.cjs → chunk-GNB5ERZP.cjs} +1011 -735
  14. package/dist/chunk-GNB5ERZP.cjs.map +1 -0
  15. package/dist/{chunk-VCRUAKOJ.js → chunk-IDWKP5H5.js} +521 -245
  16. package/dist/chunk-IDWKP5H5.js.map +1 -0
  17. package/dist/{chunk-UCQWQ7LM.js → chunk-JSFHTIKA.js} +2 -2
  18. package/dist/{chunk-BGYWTU2E.js → chunk-KA4WXCN3.js} +4 -4
  19. package/dist/{chunk-23BJ64AY.js → chunk-LFLFQ422.js} +2 -2
  20. package/dist/{chunk-FS4FVNOU.js → chunk-M6ODI2F5.js} +2 -2
  21. package/dist/{chunk-WIKFWION.cjs → chunk-MIEODAAV.cjs} +9 -9
  22. package/dist/{chunk-WIKFWION.cjs.map → chunk-MIEODAAV.cjs.map} +1 -1
  23. package/dist/{chunk-HQG5AY64.cjs → chunk-MPOP47ME.cjs} +12 -12
  24. package/dist/{chunk-HQG5AY64.cjs.map → chunk-MPOP47ME.cjs.map} +1 -1
  25. package/dist/{chunk-7UQ74ZJN.js → chunk-NULFIYK2.js} +2 -2
  26. package/dist/{chunk-ONXN77KP.cjs → chunk-OBKCQ7LA.cjs} +62 -62
  27. package/dist/{chunk-ONXN77KP.cjs.map → chunk-OBKCQ7LA.cjs.map} +1 -1
  28. package/dist/{chunk-KYN2GYUI.cjs → chunk-Q4YGG3CL.cjs} +31 -31
  29. package/dist/{chunk-KYN2GYUI.cjs.map → chunk-Q4YGG3CL.cjs.map} +1 -1
  30. package/dist/{chunk-SNFSLYVA.cjs → chunk-TQZLC3BQ.cjs} +5 -5
  31. package/dist/{chunk-SNFSLYVA.cjs.map → chunk-TQZLC3BQ.cjs.map} +1 -1
  32. package/dist/{chunk-6ZAQOYW4.cjs → chunk-VXJBE5NX.cjs} +59 -59
  33. package/dist/{chunk-6ZAQOYW4.cjs.map → chunk-VXJBE5NX.cjs.map} +1 -1
  34. package/dist/{chunk-35C3PUUN.js → chunk-WIMXB5EU.js} +4 -4
  35. package/dist/{chunk-DM3JIOFA.js → chunk-XGOX5UQ2.js} +4 -4
  36. package/dist/{chunk-EYQESL6X.cjs → chunk-ZFZAHLI4.cjs} +7 -7
  37. package/dist/{chunk-EYQESL6X.cjs.map → chunk-ZFZAHLI4.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 +4 -2
  51. package/dist/components/features/index.cjs.map +1 -1
  52. package/dist/components/features/index.js +3 -1
  53. package/dist/components/features/video-bites-strip.d.ts +43 -4
  54. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  55. package/dist/components/features/video.d.ts +6 -0
  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 +122 -120
  60. package/dist/components/index.cjs.map +1 -1
  61. package/dist/components/index.js +11 -9
  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 +4 -2
  74. package/dist/index.cjs.map +1 -1
  75. package/dist/index.js +3 -1
  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 +317 -72
  80. package/src/components/features/video.tsx +167 -37
  81. package/src/components/user-display.tsx +2 -2
  82. package/src/types/case-study.ts +1 -1
  83. package/dist/chunk-VCRUAKOJ.js.map +0 -1
  84. package/dist/chunk-VOPTCR5I.cjs.map +0 -1
  85. /package/dist/{chunk-SENYQRPF.js.map → chunk-44Q3IXZM.js.map} +0 -0
  86. /package/dist/{chunk-7CEJO3FE.js.map → chunk-4OLIDOLO.js.map} +0 -0
  87. /package/dist/{chunk-ATKRYY4U.js.map → chunk-BJPQJNSC.js.map} +0 -0
  88. /package/dist/{chunk-EQKTBIKH.js.map → chunk-CQXHHVMR.js.map} +0 -0
  89. /package/dist/{chunk-UCQWQ7LM.js.map → chunk-JSFHTIKA.js.map} +0 -0
  90. /package/dist/{chunk-BGYWTU2E.js.map → chunk-KA4WXCN3.js.map} +0 -0
  91. /package/dist/{chunk-23BJ64AY.js.map → chunk-LFLFQ422.js.map} +0 -0
  92. /package/dist/{chunk-FS4FVNOU.js.map → chunk-M6ODI2F5.js.map} +0 -0
  93. /package/dist/{chunk-7UQ74ZJN.js.map → chunk-NULFIYK2.js.map} +0 -0
  94. /package/dist/{chunk-35C3PUUN.js.map → chunk-WIMXB5EU.js.map} +0 -0
  95. /package/dist/{chunk-DM3JIOFA.js.map → chunk-XGOX5UQ2.js.map} +0 -0
@@ -26,9 +26,10 @@
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 {hover, offViewport,
29
+ * the rAF advances only when the pause-reason set {cardHovered, offViewport,
30
30
  * tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
31
- * userScrollSuppressUntil)`. Chevron clicks and manual wheel/touch each set
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
32
33
  * their own suppress-until timestamp; the player leave-grace is a separate
33
34
  * concern that never touches marquee state.
34
35
  */
@@ -85,9 +86,12 @@ export interface VideoBitesStripProps {
85
86
  headerSlot?: React.ReactNode;
86
87
  /** Marquee auto-scroll. Auto-disabled on no-overflow and prefers-reduced-motion. */
87
88
  autoScroll?: boolean;
88
- /** Marquee speed in px/s. */
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. */
89
92
  autoScrollSpeed?: number;
90
- /** Pause the marquee while the pointer is anywhere over the strip. */
93
+ /** Pause the marquee while a CARD is hovered (resumes as soon as the
94
+ * pointer leaves the card — strip whitespace/heading never pauses). */
91
95
  pauseOnHover?: boolean;
92
96
  /** Card height in px per breakpoint (Figma: 416 desktop). */
93
97
  cardHeightDesktop?: number;
@@ -118,7 +122,7 @@ export function VideoBitesStrip({
118
122
  href,
119
123
  headerSlot,
120
124
  autoScroll = true,
121
- autoScrollSpeed = 28,
125
+ autoScrollSpeed = 60,
122
126
  pauseOnHover = true,
123
127
  cardHeightDesktop = 416,
124
128
  cardHeightMobile = 300,
@@ -183,7 +187,7 @@ export function VideoBitesStrip({
183
187
  }, [measure, items.length, marqueeActive, isMobile]);
184
188
 
185
189
  // ---- pause-reason set + suppress timestamps ---------------------------------
186
- const hoveredRef = useRef(false);
190
+ // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
187
191
  const nearViewportRef = useRef(true);
188
192
  const chevronSuppressUntilRef = useRef(0);
189
193
  const userScrollSuppressUntilRef = useRef(0);
@@ -202,26 +206,62 @@ export function VideoBitesStrip({
202
206
  }, []);
203
207
 
204
208
  // ---- 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);
205
222
  useEffect(() => {
206
223
  if (!marqueeActive) return;
207
224
  const scroller = scrollerRef.current;
208
225
  if (!scroller) return;
209
226
  let raf = 0;
210
227
  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
+ };
211
236
  const tick = (now: number) => {
212
237
  const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
213
238
  last = now;
214
239
  const paused =
215
- (pauseOnHover && hoveredRef.current) ||
240
+ (pauseOnHover && activeKeyRef.current !== null) ||
216
241
  !nearViewportRef.current ||
217
242
  document.visibilityState === 'hidden' ||
218
243
  now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
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
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;
224
260
  }
261
+ marqueePosRef.current = wrap(marqueePosRef.current + autoScrollSpeed * dt);
262
+ scroller.scrollLeft = marqueePosRef.current;
263
+ } else {
264
+ marqueePosRef.current = scroller.scrollLeft;
225
265
  }
226
266
  raf = requestAnimationFrame(tick);
227
267
  };
@@ -235,14 +275,80 @@ export function VideoBitesStrip({
235
275
  // the player is mounted per-card via near-viewport gating and plays on hover
236
276
  // through <Video playOnHover> (normal controls, no chrome fork).
237
277
  const [activeKey, setActiveKey] = useState<string | null>(null);
238
- const activate = useCallback((key: string) => setActiveKey(key), []);
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
+ }, []);
239
286
  const deactivate = useCallback((key: string) => {
240
- setActiveKey(current => (current === key ? null : current));
287
+ setActiveKey(current => {
288
+ const next = current === key ? null : current;
289
+ activeKeyRef.current = next;
290
+ return next;
291
+ });
241
292
  }, []);
242
293
 
243
294
  // Preconnect Mux/Supabase origins once so first hover starts fast.
244
295
  const warmup = useVideoWarmup<HTMLDivElement>({ videoUrl: items[0]?.url || null });
245
296
 
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
+
246
352
  // ---- manual navigation -----------------------------------------------------------
247
353
  const scrollByCard = useCallback((dir: 1 | -1) => {
248
354
  const scroller = scrollerRef.current;
@@ -251,13 +357,47 @@ export function VideoBitesStrip({
251
357
  const firstCard = track.firstElementChild as HTMLElement | null;
252
358
  const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX;
253
359
  chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS;
254
- scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
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
+ }
255
368
  }, []);
256
369
 
257
370
  const onUserScrollIntent = useCallback(() => {
258
371
  userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
259
372
  }, []);
260
373
 
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
+
261
401
  if (items.length === 0) return null;
262
402
 
263
403
  const cardHeight = isMobile ? cardHeightMobile : cardHeightDesktop;
@@ -267,8 +407,6 @@ export function VideoBitesStrip({
267
407
  <div
268
408
  ref={node => { wrapperRef.current = node; warmup.ref(node); }}
269
409
  className={cn('flex flex-col gap-6 w-full min-w-0', className)}
270
- onPointerEnter={() => { hoveredRef.current = true; }}
271
- onPointerLeave={() => { hoveredRef.current = false; }}
272
410
  >
273
411
  {showTitle && (
274
412
  <h2 className={`${SECTION_HEADING_CLASS} break-words`}>{title}</h2>
@@ -282,6 +420,7 @@ export function VideoBitesStrip({
282
420
  onWheel={onUserScrollIntent}
283
421
  onTouchStart={onUserScrollIntent}
284
422
  onPointerDown={onUserScrollIntent}
423
+ onScroll={onSeamWarp}
285
424
  >
286
425
  <div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
287
426
  {Array.from({ length: copies }, (_, copyIndex) =>
@@ -292,7 +431,7 @@ export function VideoBitesStrip({
292
431
  // Clone copy: aria-hidden + no focusable descendants (WCAG —
293
432
  // same rule as quick-action-marquee). Clones stay pointer-
294
433
  // interactive (briefly visible near the wrap seam).
295
- <BiteStripCard
434
+ <VideoBiteCard
296
435
  key={key}
297
436
  bite={bite}
298
437
  index={i}
@@ -303,6 +442,8 @@ export function VideoBitesStrip({
303
442
  active={activeKey === key}
304
443
  onActivate={activate}
305
444
  onDeactivate={deactivate}
445
+ playerMounted={mountedIdx.has(i)}
446
+ rootRef={getCardRef(i)}
306
447
  profile={bite.profile ?? profile ?? null}
307
448
  sectionHref={href}
308
449
  onBiteNavigate={onBiteNavigate}
@@ -340,53 +481,91 @@ export function VideoBitesStrip({
340
481
  }
341
482
 
342
483
  // =============================================================================
343
- // Card (private)
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.
344
488
  // =============================================================================
345
489
 
346
- interface BiteStripCardProps {
490
+ export interface VideoBiteCardProps {
347
491
  bite: VideoBiteStripItem;
348
492
  index: number;
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;
493
+ profile?: VideoBiteStripProfile | null;
357
494
  sectionHref?: string;
358
495
  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;
359
523
  }
360
524
 
361
- function BiteStripCard({
525
+ export function VideoBiteCard({
362
526
  bite,
363
527
  index,
364
- cardKey,
365
- isClone,
366
- isTouch,
528
+ profile = null,
529
+ sectionHref,
530
+ onBiteNavigate,
367
531
  height,
368
532
  active,
369
533
  onActivate,
370
534
  onDeactivate,
371
- profile,
372
- sectionHref,
373
- onBiteNavigate,
374
- }: BiteStripCardProps) {
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) {
375
546
  const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
376
547
  const targetHref = bite.href ?? sectionHref;
377
548
  const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);
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);
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);
387
565
  const [isNear, setIsNear] = useState(false);
388
566
  useEffect(() => {
389
- const el = nearRef.current;
567
+ if (gateControlled) return;
568
+ const el = rootElRef.current;
390
569
  if (!el || typeof IntersectionObserver === 'undefined') return;
391
570
  const io = new IntersectionObserver(
392
571
  entries => setIsNear(entries[0]?.isIntersecting ?? false),
@@ -394,24 +573,56 @@ function BiteStripCard({
394
573
  );
395
574
  io.observe(el);
396
575
  return () => io.disconnect();
397
- }, []);
576
+ }, [gateControlled]);
577
+ const showPlayer = gateControlled ? playerMounted : isNear;
398
578
 
399
579
  const navigate = () => {
400
580
  if (bite.onNavigate) bite.onNavigate();
401
581
  else onBiteNavigate?.(bite, index);
402
582
  };
403
583
 
404
- const handlePointerEnter = () => { if (!isTouch) onActivate(cardKey); };
405
- const handlePointerLeave = () => { if (!isTouch) onDeactivate(cardKey); };
406
- const handleClick = () => { if (isTouch && !active) onActivate(cardKey); };
584
+ const handlePointerEnter = () => { if (!isTouch) activate(); };
585
+ const handlePointerLeave = () => { if (!isTouch) deactivate(); };
586
+ const handleClick = () => { if (isTouch && !isActive) activate(); };
407
587
 
408
588
  // Bottom-docked detail (Figma node 4033:90369): title row + profile row +
409
589
  // chevron affordance. The WHOLE footer is the navigation target — it links
410
- // to the entity the bite originated from.
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";
411
593
  const overlayContent = (
412
594
  <>
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>
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>
415
626
  )}
416
627
  {(profile || hasTarget) && (
417
628
  <div className="flex items-center gap-2 min-w-0">
@@ -420,7 +631,7 @@ function BiteStripCard({
420
631
  name={profile.name}
421
632
  avatarUrl={profile.avatarUrl}
422
633
  subtitle={profile.subtitle}
423
- size={28}
634
+ size={22}
424
635
  shape="round"
425
636
  compact
426
637
  className="flex-1"
@@ -440,33 +651,36 @@ function BiteStripCard({
440
651
  const overlayClass = cn(
441
652
  'absolute inset-x-0 bottom-0 p-3 gap-2 bg-black/75 border border-ods-border shadow-2xl',
442
653
  'flex flex-col transition-opacity duration-200',
443
- active ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100',
654
+ isActive ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100',
444
655
  // Non-interactive while invisible so it never swallows clicks on the
445
656
  // resting card / player controls.
446
- active ? 'pointer-events-auto' : 'pointer-events-none group-hover/card:pointer-events-auto',
657
+ isActive ? 'pointer-events-auto' : 'pointer-events-none group-hover/card:pointer-events-auto group-focus-within/card:pointer-events-auto',
447
658
  );
448
659
 
449
- return (
660
+ const media = (
450
661
  <div
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)}
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' } : {}) }}
460
666
  >
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). */}
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). */}
465
679
  <Video
466
680
  kind="file"
467
681
  url={bite.url}
468
682
  poster={bite.thumbnail_url}
469
- playOnHover
683
+ playWhenHovered={isActive}
470
684
  centerControlsOnly
471
685
  layout="fill"
472
686
  />
@@ -476,7 +690,7 @@ function BiteStripCard({
476
690
  <div className="absolute inset-0 bg-ods-card" />
477
691
  )}
478
692
 
479
- {hasTarget && !isClone ? (
693
+ {hasTarget && !isClone && !titleEditable ? (
480
694
  targetHref ? (
481
695
  <a href={targetHref} aria-label={`Open ${bite.title || 'source content'}`} className={overlayClass}>
482
696
  {overlayContent}
@@ -491,4 +705,35 @@ function BiteStripCard({
491
705
  )}
492
706
  </div>
493
707
  );
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
+ );
494
739
  }