@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.
- package/dist/{chunk-LFLFQ422.js → chunk-23BJ64AY.js} +2 -2
- package/dist/{chunk-WIMXB5EU.js → chunk-35C3PUUN.js} +4 -4
- package/dist/{chunk-VXJBE5NX.cjs → chunk-6ZAQOYW4.cjs} +59 -59
- package/dist/{chunk-VXJBE5NX.cjs.map → chunk-6ZAQOYW4.cjs.map} +1 -1
- package/dist/{chunk-4OLIDOLO.js → chunk-7CEJO3FE.js} +2 -2
- package/dist/{chunk-NULFIYK2.js → chunk-7UQ74ZJN.js} +2 -2
- package/dist/{chunk-BJPQJNSC.js → chunk-ATKRYY4U.js} +4 -4
- package/dist/{chunk-2JCSORCJ.cjs → chunk-B4JRV4FB.cjs} +26 -26
- package/dist/{chunk-2JCSORCJ.cjs.map → chunk-B4JRV4FB.cjs.map} +1 -1
- package/dist/{chunk-KA4WXCN3.js → chunk-BGYWTU2E.js} +4 -4
- package/dist/{chunk-XGOX5UQ2.js → chunk-DM3JIOFA.js} +4 -4
- package/dist/{chunk-CQXHHVMR.js → chunk-EQKTBIKH.js} +2 -2
- package/dist/{chunk-ZFZAHLI4.cjs → chunk-EYQESL6X.cjs} +7 -7
- package/dist/{chunk-ZFZAHLI4.cjs.map → chunk-EYQESL6X.cjs.map} +1 -1
- package/dist/{chunk-M6ODI2F5.js → chunk-FS4FVNOU.js} +2 -2
- package/dist/{chunk-3JQUNKWY.cjs → chunk-HIODHJCJ.cjs} +15 -15
- package/dist/{chunk-3JQUNKWY.cjs.map → chunk-HIODHJCJ.cjs.map} +1 -1
- package/dist/{chunk-MPOP47ME.cjs → chunk-HQG5AY64.cjs} +12 -12
- package/dist/{chunk-MPOP47ME.cjs.map → chunk-HQG5AY64.cjs.map} +1 -1
- package/dist/{chunk-Q4YGG3CL.cjs → chunk-KYN2GYUI.cjs} +31 -31
- package/dist/{chunk-Q4YGG3CL.cjs.map → chunk-KYN2GYUI.cjs.map} +1 -1
- package/dist/{chunk-OBKCQ7LA.cjs → chunk-ONXN77KP.cjs} +62 -62
- package/dist/{chunk-OBKCQ7LA.cjs.map → chunk-ONXN77KP.cjs.map} +1 -1
- package/dist/{chunk-44Q3IXZM.js → chunk-SENYQRPF.js} +2 -2
- package/dist/{chunk-TQZLC3BQ.cjs → chunk-SNFSLYVA.cjs} +5 -5
- package/dist/{chunk-TQZLC3BQ.cjs.map → chunk-SNFSLYVA.cjs.map} +1 -1
- package/dist/{chunk-JSFHTIKA.js → chunk-UCQWQ7LM.js} +2 -2
- package/dist/{chunk-IDWKP5H5.js → chunk-VCRUAKOJ.js} +245 -521
- package/dist/chunk-VCRUAKOJ.js.map +1 -0
- package/dist/{chunk-GNB5ERZP.cjs → chunk-VOPTCR5I.cjs} +735 -1011
- package/dist/chunk-VOPTCR5I.cjs.map +1 -0
- package/dist/{chunk-MIEODAAV.cjs → chunk-WIKFWION.cjs} +9 -9
- package/dist/{chunk-MIEODAAV.cjs.map → chunk-WIKFWION.cjs.map} +1 -1
- package/dist/{chunk-7MKU2EB4.cjs → chunk-YKPQINSZ.cjs} +26 -26
- package/dist/{chunk-7MKU2EB4.cjs.map → chunk-YKPQINSZ.cjs.map} +1 -1
- package/dist/{chunk-EHTE3CTT.cjs → chunk-ZVFDJ35S.cjs} +37 -37
- package/dist/{chunk-EHTE3CTT.cjs.map → chunk-ZVFDJ35S.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -4
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +1 -3
- package/dist/components/features/video-bites-strip.d.ts +4 -43
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +0 -6
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +120 -122
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +9 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +2 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -3
- package/dist/types/case-study.d.ts +1 -1
- package/dist/types/case-study.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/features/video-bites-strip.tsx +72 -317
- package/src/components/features/video.tsx +37 -167
- package/src/components/user-display.tsx +2 -2
- package/src/types/case-study.ts +1 -1
- package/dist/chunk-GNB5ERZP.cjs.map +0 -1
- package/dist/chunk-IDWKP5H5.js.map +0 -1
- /package/dist/{chunk-LFLFQ422.js.map → chunk-23BJ64AY.js.map} +0 -0
- /package/dist/{chunk-WIMXB5EU.js.map → chunk-35C3PUUN.js.map} +0 -0
- /package/dist/{chunk-4OLIDOLO.js.map → chunk-7CEJO3FE.js.map} +0 -0
- /package/dist/{chunk-NULFIYK2.js.map → chunk-7UQ74ZJN.js.map} +0 -0
- /package/dist/{chunk-BJPQJNSC.js.map → chunk-ATKRYY4U.js.map} +0 -0
- /package/dist/{chunk-KA4WXCN3.js.map → chunk-BGYWTU2E.js.map} +0 -0
- /package/dist/{chunk-XGOX5UQ2.js.map → chunk-DM3JIOFA.js.map} +0 -0
- /package/dist/{chunk-CQXHHVMR.js.map → chunk-EQKTBIKH.js.map} +0 -0
- /package/dist/{chunk-M6ODI2F5.js.map → chunk-FS4FVNOU.js.map} +0 -0
- /package/dist/{chunk-44Q3IXZM.js.map → chunk-SENYQRPF.js.map} +0 -0
- /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 {
|
|
29
|
+
* the rAF advances only when the pause-reason set {hover, offViewport,
|
|
30
30
|
* tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
|
|
31
|
-
* userScrollSuppressUntil)`.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 &&
|
|
215
|
+
(pauseOnHover && hoveredRef.current) ||
|
|
241
216
|
!nearViewportRef.current ||
|
|
242
217
|
document.visibilityState === 'hidden' ||
|
|
243
218
|
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
346
|
+
interface BiteStripCardProps {
|
|
491
347
|
bite: VideoBiteStripItem;
|
|
492
348
|
index: number;
|
|
493
|
-
|
|
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
|
-
|
|
361
|
+
function BiteStripCard({
|
|
526
362
|
bite,
|
|
527
363
|
index,
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
364
|
+
cardKey,
|
|
365
|
+
isClone,
|
|
366
|
+
isTouch,
|
|
531
367
|
height,
|
|
532
368
|
active,
|
|
533
369
|
onActivate,
|
|
534
370
|
onDeactivate,
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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)
|
|
585
|
-
const handlePointerLeave = () => { if (!isTouch)
|
|
586
|
-
const handleClick = () => { if (isTouch && !
|
|
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.
|
|
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
|
-
{
|
|
596
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
446
|
+
active ? 'pointer-events-auto' : 'pointer-events-none group-hover/card:pointer-events-auto',
|
|
658
447
|
);
|
|
659
448
|
|
|
660
|
-
|
|
449
|
+
return (
|
|
661
450
|
<div
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
style={{ aspectRatio: cssAspect,
|
|
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
|
-
{
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|