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

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 (132) hide show
  1. package/dist/{chunk-SOAWTQXO.cjs → chunk-2JCSORCJ.cjs} +31 -28
  2. package/dist/chunk-2JCSORCJ.cjs.map +1 -0
  3. package/dist/{chunk-OLCNA32C.cjs → chunk-3JQUNKWY.cjs} +15 -15
  4. package/dist/{chunk-OLCNA32C.cjs.map → chunk-3JQUNKWY.cjs.map} +1 -1
  5. package/dist/{chunk-OX75ZFN6.js → chunk-44Q3IXZM.js} +2 -2
  6. package/dist/{chunk-MVY4ZKQR.js → chunk-4OLIDOLO.js} +2 -2
  7. package/dist/{chunk-ADEP6E3Z.cjs → chunk-7MKU2EB4.cjs} +26 -26
  8. package/dist/{chunk-ADEP6E3Z.cjs.map → chunk-7MKU2EB4.cjs.map} +1 -1
  9. package/dist/{chunk-EULRZYQH.js → chunk-BJPQJNSC.js} +9 -14
  10. package/dist/chunk-BJPQJNSC.js.map +1 -0
  11. package/dist/{chunk-XGLZK25V.js → chunk-CQXHHVMR.js} +2 -2
  12. package/dist/{chunk-POJRURML.cjs → chunk-EHTE3CTT.cjs} +37 -37
  13. package/dist/{chunk-POJRURML.cjs.map → chunk-EHTE3CTT.cjs.map} +1 -1
  14. package/dist/{chunk-DKN425XR.cjs → chunk-GNB5ERZP.cjs} +1923 -1227
  15. package/dist/chunk-GNB5ERZP.cjs.map +1 -0
  16. package/dist/{chunk-ANPV7GZT.js → chunk-IDWKP5H5.js} +5974 -5278
  17. package/dist/chunk-IDWKP5H5.js.map +1 -0
  18. package/dist/{chunk-3KA5L7LC.js → chunk-JSFHTIKA.js} +2 -2
  19. package/dist/{chunk-GPTS2NXW.js → chunk-KA4WXCN3.js} +12 -9
  20. package/dist/chunk-KA4WXCN3.js.map +1 -0
  21. package/dist/{chunk-DU2X2MH2.js → chunk-LFLFQ422.js} +2 -2
  22. package/dist/{chunk-N2LW5NHS.js → chunk-M6ODI2F5.js} +2 -2
  23. package/dist/{chunk-DO7LOWFE.cjs → chunk-MIEODAAV.cjs} +9 -9
  24. package/dist/{chunk-DO7LOWFE.cjs.map → chunk-MIEODAAV.cjs.map} +1 -1
  25. package/dist/{chunk-44ENRZZJ.cjs → chunk-MPOP47ME.cjs} +12 -12
  26. package/dist/{chunk-44ENRZZJ.cjs.map → chunk-MPOP47ME.cjs.map} +1 -1
  27. package/dist/{chunk-EHVRW3WB.js → chunk-NULFIYK2.js} +2 -2
  28. package/dist/{chunk-CIH2LZI7.cjs → chunk-OBKCQ7LA.cjs} +62 -62
  29. package/dist/{chunk-CIH2LZI7.cjs.map → chunk-OBKCQ7LA.cjs.map} +1 -1
  30. package/dist/{chunk-3L4B7MBD.cjs → chunk-Q4YGG3CL.cjs} +31 -31
  31. package/dist/{chunk-3L4B7MBD.cjs.map → chunk-Q4YGG3CL.cjs.map} +1 -1
  32. package/dist/{chunk-SECF2KWQ.cjs → chunk-TQZLC3BQ.cjs} +5 -5
  33. package/dist/{chunk-SECF2KWQ.cjs.map → chunk-TQZLC3BQ.cjs.map} +1 -1
  34. package/dist/{chunk-RUHE3RJK.cjs → chunk-VXJBE5NX.cjs} +62 -67
  35. package/dist/chunk-VXJBE5NX.cjs.map +1 -0
  36. package/dist/{chunk-OGNKGDHT.js → chunk-WIMXB5EU.js} +4 -4
  37. package/dist/{chunk-3BPVTHOW.js → chunk-XGOX5UQ2.js} +4 -4
  38. package/dist/{chunk-SGULN3RX.cjs → chunk-ZFZAHLI4.cjs} +7 -7
  39. package/dist/{chunk-SGULN3RX.cjs.map → chunk-ZFZAHLI4.cjs.map} +1 -1
  40. package/dist/components/case-studies/index.cjs +8 -8
  41. package/dist/components/case-studies/index.js +2 -2
  42. package/dist/components/chat/index.cjs +2 -2
  43. package/dist/components/chat/index.js +1 -1
  44. package/dist/components/contact/index.cjs +3 -3
  45. package/dist/components/contact/index.js +2 -2
  46. package/dist/components/docs/index.cjs +5 -5
  47. package/dist/components/docs/index.js +4 -4
  48. package/dist/components/embeds/index.cjs +3 -3
  49. package/dist/components/embeds/index.js +2 -2
  50. package/dist/components/faq/index.cjs +3 -3
  51. package/dist/components/faq/index.js +2 -2
  52. package/dist/components/features/entity-video-section.d.ts +15 -3
  53. package/dist/components/features/entity-video-section.d.ts.map +1 -1
  54. package/dist/components/features/index.cjs +10 -2
  55. package/dist/components/features/index.cjs.map +1 -1
  56. package/dist/components/features/index.d.ts +2 -0
  57. package/dist/components/features/index.d.ts.map +1 -1
  58. package/dist/components/features/index.js +11 -3
  59. package/dist/components/features/video-bites-display.d.ts +12 -26
  60. package/dist/components/features/video-bites-display.d.ts.map +1 -1
  61. package/dist/components/features/video-bites-shared.cjs +24 -0
  62. package/dist/components/features/video-bites-shared.cjs.map +1 -0
  63. package/dist/components/features/video-bites-shared.d.ts +47 -0
  64. package/dist/components/features/video-bites-shared.d.ts.map +1 -0
  65. package/dist/components/features/video-bites-shared.js +20 -0
  66. package/dist/components/features/video-bites-shared.js.map +1 -0
  67. package/dist/components/features/video-bites-strip.d.ts +114 -0
  68. package/dist/components/features/video-bites-strip.d.ts.map +1 -0
  69. package/dist/components/features/video-ratio-tabs.d.ts +6 -9
  70. package/dist/components/features/video-ratio-tabs.d.ts.map +1 -1
  71. package/dist/components/features/video.d.ts +25 -0
  72. package/dist/components/features/video.d.ts.map +1 -1
  73. package/dist/components/help-center-pages/index.cjs +20 -20
  74. package/dist/components/help-center-pages/index.js +11 -11
  75. package/dist/components/index.cjs +130 -165
  76. package/dist/components/index.cjs.map +1 -1
  77. package/dist/components/index.js +333 -368
  78. package/dist/components/index.js.map +1 -1
  79. package/dist/components/navigation/index.cjs +2 -2
  80. package/dist/components/navigation/index.js +1 -1
  81. package/dist/components/onboarding-guides/index.cjs +5 -5
  82. package/dist/components/onboarding-guides/index.js +4 -4
  83. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  84. package/dist/components/related-content/index.cjs +3 -3
  85. package/dist/components/related-content/index.js +2 -2
  86. package/dist/components/shared/product-release/release-detail-page.d.ts +6 -7
  87. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  88. package/dist/components/square-avatar.d.ts +3 -1
  89. package/dist/components/square-avatar.d.ts.map +1 -1
  90. package/dist/components/tickets/index.cjs +5 -5
  91. package/dist/components/tickets/index.js +4 -4
  92. package/dist/components/ui/index.cjs +2 -2
  93. package/dist/components/ui/index.js +1 -1
  94. package/dist/components/user-display.d.ts +5 -1
  95. package/dist/components/user-display.d.ts.map +1 -1
  96. package/dist/index.cjs +10 -2
  97. package/dist/index.cjs.map +1 -1
  98. package/dist/index.js +11 -3
  99. package/dist/types/case-study.d.ts +18 -0
  100. package/dist/types/case-study.d.ts.map +1 -1
  101. package/dist/types/video-processing.d.ts +11 -0
  102. package/dist/types/video-processing.d.ts.map +1 -1
  103. package/package.json +7 -1
  104. package/src/components/features/entity-video-section.tsx +26 -5
  105. package/src/components/features/index.ts +6 -3
  106. package/src/components/features/video-bites-display.tsx +17 -189
  107. package/src/components/features/video-bites-shared.ts +62 -0
  108. package/src/components/features/video-bites-strip.tsx +739 -0
  109. package/src/components/features/video-ratio-tabs.tsx +15 -19
  110. package/src/components/features/video.tsx +265 -4
  111. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +10 -9
  112. package/src/components/shared/product-release/release-detail-page.tsx +12 -18
  113. package/src/components/square-avatar.tsx +5 -2
  114. package/src/components/user-display.tsx +14 -4
  115. package/src/stories/VideoBitesStrip.stories.tsx +82 -0
  116. package/src/types/case-study.ts +23 -0
  117. package/src/types/video-processing.ts +11 -0
  118. package/dist/chunk-ANPV7GZT.js.map +0 -1
  119. package/dist/chunk-DKN425XR.cjs.map +0 -1
  120. package/dist/chunk-EULRZYQH.js.map +0 -1
  121. package/dist/chunk-GPTS2NXW.js.map +0 -1
  122. package/dist/chunk-RUHE3RJK.cjs.map +0 -1
  123. package/dist/chunk-SOAWTQXO.cjs.map +0 -1
  124. /package/dist/{chunk-OX75ZFN6.js.map → chunk-44Q3IXZM.js.map} +0 -0
  125. /package/dist/{chunk-MVY4ZKQR.js.map → chunk-4OLIDOLO.js.map} +0 -0
  126. /package/dist/{chunk-XGLZK25V.js.map → chunk-CQXHHVMR.js.map} +0 -0
  127. /package/dist/{chunk-3KA5L7LC.js.map → chunk-JSFHTIKA.js.map} +0 -0
  128. /package/dist/{chunk-DU2X2MH2.js.map → chunk-LFLFQ422.js.map} +0 -0
  129. /package/dist/{chunk-N2LW5NHS.js.map → chunk-M6ODI2F5.js.map} +0 -0
  130. /package/dist/{chunk-EHVRW3WB.js.map → chunk-NULFIYK2.js.map} +0 -0
  131. /package/dist/{chunk-OGNKGDHT.js.map → chunk-WIMXB5EU.js.map} +0 -0
  132. /package/dist/{chunk-3BPVTHOW.js.map → chunk-XGOX5UQ2.js.map} +0 -0
@@ -1,15 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * Aspect-ratio tab + grouping primitives for video grids.
4
+ * Aspect-ratio tab + grouping primitives for video surfaces.
5
5
  *
6
- * Used by `<VideoBitesDisplay>` and by app-level admin editors
7
- * (VideoBitesEditor, VideoLibraryGrid) so portrait / square / landscape
8
- * clips render in cohesive groups.
6
+ * `RatioTabs` + `RATIO_GRID_CLASS` are ADMIN-ONLY now (VideoBitesEditor,
7
+ * VideoLibraryGrid) the public grid was replaced by `<VideoBitesStrip>`,
8
+ * which consumes only `detectAspectRatio` + `RATIO_TO_CSS_ASPECT` from here.
9
9
  *
10
- * Why these live here in the lib (not the hub): `<VideoBitesDisplay>`
11
- * was moved into the lib for SSoT, and it depends on these primitives.
12
- * The hub's admin editors re-export from here.
10
+ * Why these live here in the lib (not the hub): the strip and the hub's
11
+ * admin editors share the ratio primitives, and the lib is the SSoT.
13
12
  */
14
13
 
15
14
  import {
@@ -29,16 +28,13 @@ export type VizardAspectRatio = '9:16' | '16:9' | '1:1';
29
28
 
30
29
  /**
31
30
  * Extended VideoTeaser with aspect_ratio metadata from Vizard.
32
- * The lib `VideoTeaser` is the canonical type but doesn't include
33
- * `aspect_ratio` (stored in JSONB, preserved through spreads).
34
- * Import this when you need the ratio at compile time.
31
+ * The base `VideoTeaser` carries every other persisted bite field
32
+ * (timing/confidence/viral_reason live there); this adds ONLY the
33
+ * ratio. Import this when you need the ratio at compile time
34
+ * it is the canonical type display/aggregation code extends.
35
35
  */
36
36
  export interface VideoTeaserWithRatio extends VideoTeaser {
37
37
  aspect_ratio?: VizardAspectRatio;
38
- confidence?: number;
39
- viral_reason?: string;
40
- start_time_ms?: number;
41
- end_time_ms?: number;
42
38
  }
43
39
 
44
40
  /** Ratio category used for grid layout and tab grouping. */
@@ -55,11 +51,11 @@ export const RATIO_GRID_CLASS: Record<RatioCategory, string> = {
55
51
  landscape: 'grid grid-cols-1 md:grid-cols-2 gap-4',
56
52
  };
57
53
 
58
- /** Grid class for public display (wider grids on detail pages). */
59
- export const RATIO_DISPLAY_GRID_CLASS: Record<RatioCategory, string> = {
60
- portrait: 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4',
61
- square: 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4',
62
- landscape: 'grid grid-cols-1 md:grid-cols-2 gap-6',
54
+ /** CSS `aspect-ratio` value per category sizing for strip cards + placeholders. */
55
+ export const RATIO_TO_CSS_ASPECT: Record<RatioCategory, string> = {
56
+ portrait: '9 / 16',
57
+ square: '1 / 1',
58
+ landscape: '16 / 9',
63
59
  };
64
60
 
65
61
  const RATIO_TAB_CONFIG: { key: RatioCategory; label: string }[] = [
@@ -30,9 +30,10 @@
30
30
  * layout="native" → intrinsic aspect ratio. Bites grid, blog cards.
31
31
  */
32
32
 
33
- import React, { useEffect, useMemo, useRef, useState } from 'react';
33
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
34
34
  import MuxPlayer from '@mux/mux-player-react';
35
35
  import { PlayIcon } from '../icons-v2-generated/media-playback/play-icon';
36
+ import { VolumeXmarkIcon } from '../icons-v2-generated/audio-and-visual/volume-xmark-icon';
36
37
  import { fetchPriorityProp } from '../../utils/fetch-priority';
37
38
 
38
39
  // =============================================================================
@@ -105,6 +106,32 @@ if (typeof window !== 'undefined' && typeof console !== 'undefined') {
105
106
  }
106
107
  }
107
108
 
109
+ // =============================================================================
110
+ // User-activation tracker (module scope) — Chrome's autoplay policy rejects
111
+ // UNMUTED play() until the user has interacted with the page (click/keydown;
112
+ // pointer MOVEMENT does not count). Hover-preview surfaces use this to pick
113
+ // the right first move (sound vs muted) WITHOUT a rejection round-trip, and
114
+ // to unmute a live muted preview the instant the first gesture lands.
115
+ // =============================================================================
116
+
117
+ let userHasInteracted = false;
118
+ const activationWaiters = new Set<() => void>();
119
+ if (typeof window !== 'undefined') {
120
+ const w = window as unknown as { __VIDEO_ACTIVATION_TRACKED__?: boolean };
121
+ if (!w.__VIDEO_ACTIVATION_TRACKED__) {
122
+ w.__VIDEO_ACTIVATION_TRACKED__ = true;
123
+ const markActivated = () => {
124
+ userHasInteracted = true;
125
+ activationWaiters.forEach(fn => { try { fn(); } catch { /* ignore */ } });
126
+ activationWaiters.clear();
127
+ window.removeEventListener('pointerdown', markActivated, true);
128
+ window.removeEventListener('keydown', markActivated, true);
129
+ };
130
+ window.addEventListener('pointerdown', markActivated, true);
131
+ window.addEventListener('keydown', markActivated, true);
132
+ }
133
+ }
134
+
108
135
  // =============================================================================
109
136
  // URL classifiers (private — `<Video>` is the only consumer)
110
137
  // =============================================================================
@@ -208,6 +235,24 @@ interface VideoFileProps extends VideoCommonProps {
208
235
  srtContent?: string | null;
209
236
  /** HTTPS URL to a VTT captions file. Rendered as a native `<track>`. */
210
237
  captionsUrl?: string | null;
238
+ /** Autoplay muted on mount (forwarded as MuxPlayer `autoPlay="muted"`) — hover-preview surfaces. */
239
+ autoPlay?: boolean;
240
+ /** Loop playback — short bite previews. */
241
+ loop?: boolean;
242
+ /** Hide all player chrome (MuxPlayer `--controls: none`) — chromeless preview mode. */
243
+ chromeless?: boolean;
244
+ /** Play while the pointer hovers the player, pause on leave. Tries WITH
245
+ * sound at 50% volume first (bite-strip behavior); falls back to muted when
246
+ * the browser's autoplay policy rejects unmuted hover playback. */
247
+ playOnHover?: boolean;
248
+ /** CONTROLLED variant of playOnHover: the host owns the hover state (e.g.
249
+ * the bite-strip card, whose overlay also counts as "hovering the card").
250
+ * true → start hover playback, false → pause. When provided, the internal
251
+ * pointer handlers are disabled. */
252
+ playWhenHovered?: boolean;
253
+ /** Hide the bottom control bar, keep only the CENTER play/pause control
254
+ * (bite-strip cards per Figma). */
255
+ centerControlsOnly?: boolean;
211
256
  }
212
257
 
213
258
  interface VideoYouTubeProps extends VideoCommonProps {
@@ -221,6 +266,13 @@ interface VideoAutoProps extends VideoCommonProps {
221
266
  url: string;
222
267
  srtContent?: string | null;
223
268
  captionsUrl?: string | null;
269
+ /** See VideoFileProps — no-ops when the URL resolves to the YouTube branch. */
270
+ autoPlay?: boolean;
271
+ loop?: boolean;
272
+ chromeless?: boolean;
273
+ playOnHover?: boolean;
274
+ playWhenHovered?: boolean;
275
+ centerControlsOnly?: boolean;
224
276
  }
225
277
 
226
278
  export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
@@ -252,6 +304,12 @@ export function Video(props: VideoProps): React.ReactElement | null {
252
304
  muted={props.muted}
253
305
  srtContent={'srtContent' in props ? props.srtContent : null}
254
306
  captionsUrl={'captionsUrl' in props ? props.captionsUrl : null}
307
+ autoPlay={'autoPlay' in props ? props.autoPlay : undefined}
308
+ loop={'loop' in props ? props.loop : undefined}
309
+ chromeless={'chromeless' in props ? props.chromeless : undefined}
310
+ playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
311
+ playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
312
+ centerControlsOnly={'centerControlsOnly' in props ? props.centerControlsOnly : undefined}
255
313
  className={props.className}
256
314
  />
257
315
  );
@@ -308,7 +366,7 @@ function wrapWithLayout(
308
366
  return <div className="absolute inset-0 w-full h-full">{inner}</div>;
309
367
  case 'native':
310
368
  default:
311
- // `native` callers (LazyBite in `<VideoBitesDisplay>`, blog cards) are
369
+ // `native` callers (blog cards etc.) are
312
370
  // expected to provide their own aspect-ratio container so the layout
313
371
  // primitive doesn't override portrait/square/landscape bites with 16:9.
314
372
  return inner;
@@ -325,6 +383,12 @@ interface FilePlayerProps {
325
383
  muted?: boolean;
326
384
  srtContent?: string | null;
327
385
  captionsUrl?: string | null;
386
+ autoPlay?: boolean;
387
+ loop?: boolean;
388
+ chromeless?: boolean;
389
+ playOnHover?: boolean;
390
+ playWhenHovered?: boolean;
391
+ centerControlsOnly?: boolean;
328
392
  className?: string;
329
393
  }
330
394
 
@@ -334,8 +398,138 @@ function FilePlayer({
334
398
  muted,
335
399
  srtContent,
336
400
  captionsUrl,
401
+ autoPlay,
402
+ loop,
403
+ chromeless,
404
+ playOnHover,
405
+ playWhenHovered,
406
+ centerControlsOnly,
337
407
  className,
338
408
  }: FilePlayerProps): React.ReactElement {
409
+ // centerControlsOnly: the center play button shows at REST only — while
410
+ // playing, ALL chrome hides (no pause sign over the hover-preview).
411
+ const [isPlaying, setIsPlaying] = useState(false);
412
+ // True while hover playback is running MUTED because the browser's autoplay
413
+ // policy blocked sound (no user activation yet). Drives the center unmute
414
+ // control — the industry pattern (muted autoplay + explicit unmute button)
415
+ // instead of silently waiting for a click somewhere.
416
+ const [hoverMutedFallback, setHoverMutedFallback] = useState(false);
417
+ // playOnHover drives the underlying mux-player element imperatively — the
418
+ // element exposes native play()/pause()/muted/volume; the chrome stays as
419
+ // configured. Sound-first: volume 0.5 unmuted, muted fallback when the
420
+ // browser's autoplay policy rejects unmuted hover playback (hover is not a
421
+ // user gesture in Chrome's activation model).
422
+ const hoverPlayerRef = useRef<{
423
+ play?: () => Promise<void> | void;
424
+ pause?: () => void;
425
+ muted?: boolean;
426
+ volume?: number;
427
+ } | null>(null);
428
+ // Tracks whether the pointer is STILL over the player. A fast hover-out
429
+ // pauses the in-flight play(), which rejects it with AbortError — that must
430
+ // NOT trigger the muted retry (it would restart playback after the pointer
431
+ // left, with no visible control to stop it). Only a genuine autoplay-policy
432
+ // rejection (NotAllowedError) while still hovered retries muted.
433
+ const hoverActiveRef = useRef(false);
434
+ // Per-enter generation token: a LATE NotAllowedError from a previous enter
435
+ // must not fire the muted fallback into a newer (intended-sound) session.
436
+ const hoverGenerationRef = useRef(0);
437
+ // This instance's pending activation waiter — pruned on hover-leave,
438
+ // re-enter, and unmount so pre-activation hovers don't accumulate stale
439
+ // closures in the module-level set (they'd otherwise setState against
440
+ // unmounted instances when the first user gesture finally lands).
441
+ const activationWaiterRef = useRef<(() => void) | null>(null);
442
+ const clearActivationWaiter = useCallback(() => {
443
+ if (activationWaiterRef.current) {
444
+ activationWaiters.delete(activationWaiterRef.current);
445
+ activationWaiterRef.current = null;
446
+ }
447
+ }, []);
448
+ useEffect(() => clearActivationWaiter, [clearActivationWaiter]);
449
+ const startHoverPlayback = useCallback(() => {
450
+ hoverActiveRef.current = true;
451
+ const generation = ++hoverGenerationRef.current;
452
+ const el = hoverPlayerRef.current;
453
+ if (!el) return;
454
+ try {
455
+ el.volume = 0.5;
456
+ if (userHasInteracted) {
457
+ // Post-activation: unmuted playback is allowed — play with sound.
458
+ // The NotAllowedError guard stays as a belt-and-suspenders fallback;
459
+ // a fast hover-out's pause() rejects with AbortError and must not
460
+ // restart playback (name mismatch + cleared hoverActiveRef).
461
+ el.muted = false;
462
+ (el.play?.() as Promise<void> | undefined)?.catch?.((err: unknown) => {
463
+ const name = (err as { name?: string } | null)?.name;
464
+ if (
465
+ name === 'NotAllowedError' &&
466
+ hoverActiveRef.current &&
467
+ generation === hoverGenerationRef.current
468
+ ) {
469
+ try {
470
+ el.muted = true;
471
+ (el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
472
+ setHoverMutedFallback(true);
473
+ } catch { /* give up silently */ }
474
+ }
475
+ });
476
+ } else {
477
+ // Pre-activation: unmuted WOULD be rejected (hover isn't a gesture in
478
+ // Chrome's activation model) — start muted immediately with no
479
+ // rejection round-trip, and UNMUTE LIVE the instant the user's first
480
+ // click/keydown lands anywhere while this hover is still active.
481
+ el.muted = true;
482
+ (el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
483
+ setHoverMutedFallback(true);
484
+ clearActivationWaiter();
485
+ const waiter = () => {
486
+ activationWaiterRef.current = null;
487
+ if (hoverActiveRef.current && generation === hoverGenerationRef.current) {
488
+ try { el.muted = false; el.volume = 0.5; } catch { /* ignore */ }
489
+ setHoverMutedFallback(false);
490
+ }
491
+ };
492
+ activationWaiterRef.current = waiter;
493
+ activationWaiters.add(waiter);
494
+ }
495
+ } catch { /* ignore */ }
496
+ }, []);
497
+ const stopHoverPlayback = useCallback(() => {
498
+ hoverActiveRef.current = false;
499
+ setHoverMutedFallback(false);
500
+ clearActivationWaiter();
501
+ try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
502
+ }, [clearActivationWaiter]);
503
+
504
+ // Explicit unmute affordance: the click IS the user activation the autoplay
505
+ // policy wants, so unmuting here always succeeds (and the window-level
506
+ // activation listener flips the module flag for every other player too).
507
+ const unmuteNow = useCallback((e: React.MouseEvent) => {
508
+ e.stopPropagation();
509
+ e.preventDefault();
510
+ const el = hoverPlayerRef.current;
511
+ try {
512
+ if (el) {
513
+ el.muted = false;
514
+ el.volume = 0.5;
515
+ (el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
516
+ }
517
+ } catch { /* ignore */ }
518
+ setHoverMutedFallback(false);
519
+ }, []);
520
+
521
+ // Controlled hover mode (playWhenHovered): the HOST owns hover detection —
522
+ // e.g. the bite-strip card, where the detail overlay is part of the card and
523
+ // must NOT pause playback when the pointer moves onto it.
524
+ const hoverControlled = typeof playWhenHovered === 'boolean';
525
+ useEffect(() => {
526
+ if (!hoverControlled) return;
527
+ if (playWhenHovered) startHoverPlayback();
528
+ else stopHoverPlayback();
529
+ }, [hoverControlled, playWhenHovered, startHoverPlayback, stopHoverPlayback]);
530
+
531
+ const handleHoverEnter = playOnHover && !hoverControlled ? startHoverPlayback : undefined;
532
+ const handleHoverLeave = playOnHover && !hoverControlled ? stopHoverPlayback : undefined;
339
533
  // Raw SRT text is unusable without a custom overlay — and we just deleted
340
534
  // the 900-LOC custom-controls layer that owned that overlay. Consumers
341
535
  // pass `captionsUrl` (the API-side VTT conversion) alongside `srtContent`
@@ -349,8 +543,11 @@ function FilePlayer({
349
543
  );
350
544
  }
351
545
 
352
- return (
546
+ const player = (
353
547
  <MuxPlayer
548
+ ref={hoverPlayerRef as React.Ref<never>}
549
+ onPlay={centerControlsOnly ? () => setIsPlaying(true) : undefined}
550
+ onPause={centerControlsOnly ? () => setIsPlaying(false) : undefined}
354
551
  src={url}
355
552
  poster={poster || undefined}
356
553
  streamType="on-demand"
@@ -365,6 +562,8 @@ function FilePlayer({
365
562
  // is ever undefined on a `data-app-type` we haven't themed yet.
366
563
  // NEVER let Mux pink leak onto a non-Flamingo platform.
367
564
  accentColor="var(--ods-accent, var(--color-accent-primary))"
565
+ autoPlay={autoPlay ? 'muted' : undefined}
566
+ loop={loop}
368
567
  className={className}
369
568
  // Fill the wrapping aspect-ratio container instead of MuxPlayer's
370
569
  // intrinsic size. Without this, MuxPlayer renders at its default
@@ -373,7 +572,25 @@ function FilePlayer({
373
572
  // flickers and grows" CLS we're killing. With `aspect-video` on
374
573
  // the centered wrapper and `width/height: 100%` here, the box is
375
574
  // 16:9 from first paint and stays put.
376
- style={{ width: '100%', height: '100%' }}
575
+ // `--controls: none` is media-chrome's kill switch for ALL player
576
+ // chrome — the chromeless preview mode. `--bottom-controls: none`
577
+ // hides only the bottom bar (center play/pause stays) — the
578
+ // bite-strip card look per Figma. Merged (never replacing) into the
579
+ // sizing style; custom properties need the CSSProperties cast.
580
+ style={{
581
+ width: '100%',
582
+ height: '100%',
583
+ ...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
584
+ ...(centerControlsOnly && !chromeless
585
+ ? ({
586
+ '--bottom-controls': 'none',
587
+ '--top-controls': 'none',
588
+ // While playing, hide the center control too — no pause sign
589
+ // over the hover-preview; it reappears when playback pauses.
590
+ ...(isPlaying ? { '--center-controls': 'none' } : {}),
591
+ } as React.CSSProperties)
592
+ : {}),
593
+ }}
377
594
  >
378
595
  {captionsUrl ? (
379
596
  <track
@@ -386,6 +603,50 @@ function FilePlayer({
386
603
  ) : null}
387
604
  </MuxPlayer>
388
605
  );
606
+
607
+ // Center unmute control — shown while hover playback runs muted because the
608
+ // autoplay policy blocked sound. Best-practice pattern (Mux / FB / IG):
609
+ // muted autoplay + an explicit unmute affordance, never forced sound.
610
+ // Styled to match media-chrome's center controls exactly (the play glyph in
611
+ // the same slot): plain large white glyph, no circle/border/background,
612
+ // slight dim on hover — so unmute reads as just another center control.
613
+ const unmuteBadge = hoverMutedFallback ? (
614
+ <button
615
+ type="button"
616
+ aria-label="Unmute"
617
+ title="Unmute"
618
+ onClick={unmuteNow}
619
+ className="absolute inset-0 z-10 m-auto flex h-14 w-14 items-center justify-center text-ods-text-primary transition-opacity hover:opacity-75"
620
+ >
621
+ <VolumeXmarkIcon size={56} />
622
+ </button>
623
+ ) : null;
624
+
625
+ // MuxPlayerProps has no pointer-event props — the hover-play handlers live
626
+ // on a full-size wrapper instead (only in UNCONTROLLED playOnHover mode;
627
+ // controlled playWhenHovered hosts own their hover detection). Either
628
+ // hover-capable mode gets a relative wrapper so the unmute badge can dock.
629
+ if (playOnHover && !hoverControlled) {
630
+ return (
631
+ <div
632
+ className="relative w-full h-full"
633
+ onPointerEnter={handleHoverEnter}
634
+ onPointerLeave={handleHoverLeave}
635
+ >
636
+ {player}
637
+ {unmuteBadge}
638
+ </div>
639
+ );
640
+ }
641
+ if (hoverControlled) {
642
+ return (
643
+ <div className="relative w-full h-full">
644
+ {player}
645
+ {unmuteBadge}
646
+ </div>
647
+ );
648
+ }
649
+ return player;
389
650
  }
390
651
 
391
652
  // -----------------------------------------------------------------------------
@@ -26,7 +26,8 @@ import { useRouter } from '../../embed-shims'
26
26
  import { PageShell } from '../layout/article-detail-layout'
27
27
  import { DetailPageSkeleton } from '../shared/detail-page-skeleton'
28
28
  import { EntityVideoSection } from '../features/entity-video-section'
29
- import { VideoBitesDisplay } from '../features/video-bites-display'
29
+ import { VideoBitesStrip } from '../features/video-bites-strip'
30
+ import { toStripProfile } from '../features/video-bites-shared'
30
31
  import { useVideoWarmup } from '../features/use-video-warmup'
31
32
  import { getCaptionsUrl } from '../features/captions-url'
32
33
  import { RichMarkdownRenderer } from '../ui/rich-markdown-renderer'
@@ -208,14 +209,14 @@ export function OnboardingGuideDetailView({
208
209
  </div>
209
210
  )}
210
211
 
211
- {/* Video Bites */}
212
- {guide.video_bites && guide.video_bites.length > 0 && (
213
- <VideoBitesDisplay
214
- bites={guide.video_bites as VideoTeaser[]}
215
- filterPublished={true}
216
- showTitle={false}
217
- />
218
- )}
212
+ {/* Video Bites — unified strip; renders nothing when no published bites. */}
213
+ <VideoBitesStrip
214
+ bites={(guide.video_bites as VideoTeaser[] | null) ?? []}
215
+ filterPublished={true}
216
+ showTitle={false}
217
+ profile={toStripProfile(guide.author)}
218
+ href={`/onboarding-guides/${guide.slug}`}
219
+ />
219
220
 
220
221
  {/* End-of-article author byline (avatar + linked name + bio) — the
221
222
  author DESCRIPTION block every article-shaped detail page renders.
@@ -25,6 +25,11 @@ import { DetailPageSkeleton } from '../detail-page-skeleton';
25
25
  import type { ChangelogEntry } from '../../../types/product-release';
26
26
  import type { TagAssoc } from '../../../types/blog';
27
27
  import type { VideoTeaser } from '../../../types/video-processing';
28
+ import {
29
+ DEFAULT_VIDEO_BITES_TITLE,
30
+ toStripProfile,
31
+ type VideoBiteStripProfile,
32
+ } from '../../features/video-bites-shared';
28
33
 
29
34
  // Types for injectable components
30
35
  export interface MarkdownRendererProps {
@@ -59,12 +64,6 @@ export interface DeliverySectionProps {
59
64
  isLoading: boolean;
60
65
  }
61
66
 
62
- export interface VideoSectionProps {
63
- bites: VideoTeaser[];
64
- title?: string;
65
- filterPublished?: boolean;
66
- }
67
-
68
67
  // Type for the useRelease hook result
69
68
  export interface UseReleaseResult {
70
69
  data: unknown;
@@ -83,6 +82,10 @@ export interface VideoDisplaySectionProps {
83
82
  videoBites?: VideoTeaser[];
84
83
  bitesTitle?: string;
85
84
  filterPublishedBites?: boolean;
85
+ /** Profile shown in the bites strip's hover overlay (kit sources it from release.author). */
86
+ bitesProfile?: VideoBiteStripProfile | null;
87
+ /** Overlay-footer navigation target (the release the bites originated from). */
88
+ bitesHref?: string;
86
89
  srtContent?: string | null;
87
90
  captionsUrl?: string | null;
88
91
  }
@@ -96,7 +99,6 @@ export interface ReleaseDetailPageProps {
96
99
  MarkdownRenderer?: ComponentType<MarkdownRendererProps>;
97
100
  RoadmapSection?: ComponentType<RoadmapSectionProps>;
98
101
  DeliverySection?: ComponentType<DeliverySectionProps>;
99
- VideoSection?: ComponentType<VideoSectionProps>;
100
102
  /** Injectable video display section with tabs for full/highlight video + summary + bites */
101
103
  VideoDisplaySection?: ComponentType<VideoDisplaySectionProps>;
102
104
  // API endpoints for fetching linked tasks
@@ -134,7 +136,6 @@ export function ReleaseDetailPage({
134
136
  MarkdownRenderer = DefaultMarkdownRenderer,
135
137
  RoadmapSection,
136
138
  DeliverySection,
137
- VideoSection,
138
139
  VideoDisplaySection,
139
140
  roadmapApiEndpoint = '/api/roadmap',
140
141
  deliveryApiEndpoint = '/api/delivery',
@@ -335,8 +336,10 @@ export function ReleaseDetailPage({
335
336
  highlightVideoThumbnail={highlightVideoThumbnail}
336
337
  title={releaseTitle}
337
338
  videoBites={videoBites}
338
- bitesTitle="Video Clips"
339
+ bitesTitle={DEFAULT_VIDEO_BITES_TITLE}
339
340
  filterPublishedBites={true}
341
+ bitesProfile={toStripProfile(author)}
342
+ bitesHref={typeof release.slug === 'string' ? `/releases/${release.slug}` : undefined}
340
343
  srtContent={release?.srt_content as string | null | undefined}
341
344
  captionsUrl={release?.captionsUrl as string | undefined}
342
345
  />
@@ -434,15 +437,6 @@ export function ReleaseDetailPage({
434
437
  MarkdownRenderer={MarkdownRenderer}
435
438
  />
436
439
 
437
- {/* Video Bites Section - Only when VideoDisplaySection is not handling it */}
438
- {!VideoDisplaySection && VideoSection && videoBites && videoBites.length > 0 && (
439
- <VideoSection
440
- bites={videoBites}
441
- title="Video Clips"
442
- filterPublished={true}
443
- />
444
- )}
445
-
446
440
  {/* Related Roadmap Items */}
447
441
  {RoadmapSection && (roadmapLoading || roadmapTasks.length > 0) && (
448
442
  <div className="space-y-4 w-full">
@@ -13,6 +13,8 @@ interface SquareAvatarProps {
13
13
  size?: number;
14
14
  /** If true, avatar takes full width with square aspect ratio */
15
15
  fullWidth?: boolean;
16
+ /** Corner treatment. `'square'` = rounded-lg (default, historical look); `'round'` = fully circular. */
17
+ shape?: 'square' | 'round';
16
18
  className?: string;
17
19
  }
18
20
 
@@ -20,7 +22,7 @@ interface SquareAvatarProps {
20
22
  * Square avatar with rounded edges used across cards / dashboards.
21
23
  * Automatically shows image (via proxied URL) or initials fallback.
22
24
  */
23
- export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = false, className }: SquareAvatarProps) {
25
+ export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = false, shape = 'square', className }: SquareAvatarProps) {
24
26
  const initials = React.useMemo(() => {
25
27
  if (!fallbackName) return '';
26
28
  return fallbackName
@@ -36,7 +38,8 @@ export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = fa
36
38
  return (
37
39
  <div
38
40
  className={cn(
39
- 'rounded-lg border border-ods-border flex items-center justify-center overflow-hidden bg-ods-bg-secondary',
41
+ 'border border-ods-border flex items-center justify-center overflow-hidden bg-ods-bg-secondary',
42
+ shape === 'round' ? 'rounded-full' : 'rounded-lg',
40
43
  fullWidth ? 'w-full aspect-square' : 'flex-shrink-0',
41
44
  className,
42
45
  )}
@@ -11,6 +11,10 @@ interface UserDisplayProps {
11
11
  subtitle?: string | null;
12
12
  /** Avatar size in px (defaults 32) */
13
13
  size?: number;
14
+ /** Avatar corner treatment, forwarded to SquareAvatar. Default 'square'. */
15
+ shape?: 'square' | 'round';
16
+ /** Compact typography (14px/20px rows — video-bite overlay density). */
17
+ compact?: boolean;
14
18
  className?: string;
15
19
  }
16
20
 
@@ -18,16 +22,22 @@ interface UserDisplayProps {
18
22
  * Reusable horizontal avatar + name (+ optional subtitle) row that follows
19
23
  * the visual pattern used in CommentCard headers.
20
24
  */
21
- export function UserDisplay({ name, avatarUrl, subtitle, size = 32, className }: UserDisplayProps) {
25
+ export function UserDisplay({ name, avatarUrl, subtitle, size = 32, shape = 'square', compact = false, className }: UserDisplayProps) {
22
26
  return (
23
27
  <div className={cn('flex items-center gap-2 min-w-0', className)}>
24
- <SquareAvatar src={avatarUrl ?? undefined} fallbackName={name} size={size} />
28
+ <SquareAvatar src={avatarUrl ?? undefined} fallbackName={name} size={size} shape={shape} />
25
29
  <div className="min-w-0 flex-1">
26
- <p className="font-['DM_Sans'] text-lg leading-[22px] text-ods-text-primary truncate">
30
+ <p className={cn(
31
+ "font-['DM_Sans'] text-ods-text-primary truncate",
32
+ compact ? 'text-xs leading-4 font-medium' : 'text-lg leading-[22px]',
33
+ )}>
27
34
  {name}
28
35
  </p>
29
36
  {subtitle && (
30
- <span className="font-['DM_Sans'] text-md leading-[16px] text-ods-text-secondary truncate">
37
+ <span className={cn(
38
+ "font-['DM_Sans'] text-ods-text-secondary truncate block",
39
+ compact ? 'text-xs leading-4 font-medium' : 'text-md leading-[16px]',
40
+ )}>
31
41
  {subtitle}
32
42
  </span>
33
43
  )}