@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.
- package/dist/{chunk-B4JRV4FB.cjs → chunk-2JCSORCJ.cjs} +26 -26
- package/dist/{chunk-B4JRV4FB.cjs.map → chunk-2JCSORCJ.cjs.map} +1 -1
- package/dist/{chunk-HIODHJCJ.cjs → chunk-3JQUNKWY.cjs} +15 -15
- package/dist/{chunk-HIODHJCJ.cjs.map → chunk-3JQUNKWY.cjs.map} +1 -1
- package/dist/{chunk-SENYQRPF.js → chunk-44Q3IXZM.js} +2 -2
- package/dist/{chunk-7CEJO3FE.js → chunk-4OLIDOLO.js} +2 -2
- package/dist/{chunk-YKPQINSZ.cjs → chunk-7MKU2EB4.cjs} +26 -26
- package/dist/{chunk-YKPQINSZ.cjs.map → chunk-7MKU2EB4.cjs.map} +1 -1
- package/dist/{chunk-ATKRYY4U.js → chunk-BJPQJNSC.js} +4 -4
- package/dist/{chunk-EQKTBIKH.js → chunk-CQXHHVMR.js} +2 -2
- package/dist/{chunk-ZVFDJ35S.cjs → chunk-EHTE3CTT.cjs} +37 -37
- package/dist/{chunk-ZVFDJ35S.cjs.map → chunk-EHTE3CTT.cjs.map} +1 -1
- package/dist/{chunk-VOPTCR5I.cjs → chunk-GNB5ERZP.cjs} +1011 -735
- package/dist/chunk-GNB5ERZP.cjs.map +1 -0
- package/dist/{chunk-VCRUAKOJ.js → chunk-IDWKP5H5.js} +521 -245
- package/dist/chunk-IDWKP5H5.js.map +1 -0
- package/dist/{chunk-UCQWQ7LM.js → chunk-JSFHTIKA.js} +2 -2
- package/dist/{chunk-BGYWTU2E.js → chunk-KA4WXCN3.js} +4 -4
- package/dist/{chunk-23BJ64AY.js → chunk-LFLFQ422.js} +2 -2
- package/dist/{chunk-FS4FVNOU.js → chunk-M6ODI2F5.js} +2 -2
- package/dist/{chunk-WIKFWION.cjs → chunk-MIEODAAV.cjs} +9 -9
- package/dist/{chunk-WIKFWION.cjs.map → chunk-MIEODAAV.cjs.map} +1 -1
- package/dist/{chunk-HQG5AY64.cjs → chunk-MPOP47ME.cjs} +12 -12
- package/dist/{chunk-HQG5AY64.cjs.map → chunk-MPOP47ME.cjs.map} +1 -1
- package/dist/{chunk-7UQ74ZJN.js → chunk-NULFIYK2.js} +2 -2
- package/dist/{chunk-ONXN77KP.cjs → chunk-OBKCQ7LA.cjs} +62 -62
- package/dist/{chunk-ONXN77KP.cjs.map → chunk-OBKCQ7LA.cjs.map} +1 -1
- package/dist/{chunk-KYN2GYUI.cjs → chunk-Q4YGG3CL.cjs} +31 -31
- package/dist/{chunk-KYN2GYUI.cjs.map → chunk-Q4YGG3CL.cjs.map} +1 -1
- package/dist/{chunk-SNFSLYVA.cjs → chunk-TQZLC3BQ.cjs} +5 -5
- package/dist/{chunk-SNFSLYVA.cjs.map → chunk-TQZLC3BQ.cjs.map} +1 -1
- package/dist/{chunk-6ZAQOYW4.cjs → chunk-VXJBE5NX.cjs} +59 -59
- package/dist/{chunk-6ZAQOYW4.cjs.map → chunk-VXJBE5NX.cjs.map} +1 -1
- package/dist/{chunk-35C3PUUN.js → chunk-WIMXB5EU.js} +4 -4
- package/dist/{chunk-DM3JIOFA.js → chunk-XGOX5UQ2.js} +4 -4
- package/dist/{chunk-EYQESL6X.cjs → chunk-ZFZAHLI4.cjs} +7 -7
- package/dist/{chunk-EYQESL6X.cjs.map → chunk-ZFZAHLI4.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 +4 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +3 -1
- package/dist/components/features/video-bites-strip.d.ts +43 -4
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +6 -0
- 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 +122 -120
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +11 -9
- 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 +4 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -1
- 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 +317 -72
- package/src/components/features/video.tsx +167 -37
- package/src/components/user-display.tsx +2 -2
- package/src/types/case-study.ts +1 -1
- package/dist/chunk-VCRUAKOJ.js.map +0 -1
- package/dist/chunk-VOPTCR5I.cjs.map +0 -1
- /package/dist/{chunk-SENYQRPF.js.map → chunk-44Q3IXZM.js.map} +0 -0
- /package/dist/{chunk-7CEJO3FE.js.map → chunk-4OLIDOLO.js.map} +0 -0
- /package/dist/{chunk-ATKRYY4U.js.map → chunk-BJPQJNSC.js.map} +0 -0
- /package/dist/{chunk-EQKTBIKH.js.map → chunk-CQXHHVMR.js.map} +0 -0
- /package/dist/{chunk-UCQWQ7LM.js.map → chunk-JSFHTIKA.js.map} +0 -0
- /package/dist/{chunk-BGYWTU2E.js.map → chunk-KA4WXCN3.js.map} +0 -0
- /package/dist/{chunk-23BJ64AY.js.map → chunk-LFLFQ422.js.map} +0 -0
- /package/dist/{chunk-FS4FVNOU.js.map → chunk-M6ODI2F5.js.map} +0 -0
- /package/dist/{chunk-7UQ74ZJN.js.map → chunk-NULFIYK2.js.map} +0 -0
- /package/dist/{chunk-35C3PUUN.js.map → chunk-WIMXB5EU.js.map} +0 -0
- /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 {
|
|
29
|
+
* the rAF advances only when the pause-reason set {cardHovered, offViewport,
|
|
30
30
|
* tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
|
|
31
|
-
* userScrollSuppressUntil)`.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 &&
|
|
240
|
+
(pauseOnHover && activeKeyRef.current !== null) ||
|
|
216
241
|
!nearViewportRef.current ||
|
|
217
242
|
document.visibilityState === 'hidden' ||
|
|
218
243
|
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
<
|
|
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 (
|
|
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
|
|
490
|
+
export interface VideoBiteCardProps {
|
|
347
491
|
bite: VideoBiteStripItem;
|
|
348
492
|
index: number;
|
|
349
|
-
|
|
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
|
|
525
|
+
export function VideoBiteCard({
|
|
362
526
|
bite,
|
|
363
527
|
index,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
528
|
+
profile = null,
|
|
529
|
+
sectionHref,
|
|
530
|
+
onBiteNavigate,
|
|
367
531
|
height,
|
|
368
532
|
active,
|
|
369
533
|
onActivate,
|
|
370
534
|
onDeactivate,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
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
|
-
|
|
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)
|
|
405
|
-
const handlePointerLeave = () => { if (!isTouch)
|
|
406
|
-
const handleClick = () => { if (isTouch && !
|
|
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
|
-
{
|
|
414
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
+
const media = (
|
|
450
661
|
<div
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
style={{
|
|
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
|
-
{
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
}
|