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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/{chunk-DU2X2MH2.js → chunk-23BJ64AY.js} +2 -2
  2. package/dist/{chunk-OGNKGDHT.js → chunk-35C3PUUN.js} +4 -4
  3. package/dist/{chunk-RUHE3RJK.cjs → chunk-6ZAQOYW4.cjs} +62 -67
  4. package/dist/chunk-6ZAQOYW4.cjs.map +1 -0
  5. package/dist/{chunk-MVY4ZKQR.js → chunk-7CEJO3FE.js} +2 -2
  6. package/dist/{chunk-EHVRW3WB.js → chunk-7UQ74ZJN.js} +2 -2
  7. package/dist/{chunk-EULRZYQH.js → chunk-ATKRYY4U.js} +9 -14
  8. package/dist/chunk-ATKRYY4U.js.map +1 -0
  9. package/dist/{chunk-SOAWTQXO.cjs → chunk-B4JRV4FB.cjs} +31 -28
  10. package/dist/chunk-B4JRV4FB.cjs.map +1 -0
  11. package/dist/{chunk-GPTS2NXW.js → chunk-BGYWTU2E.js} +12 -9
  12. package/dist/chunk-BGYWTU2E.js.map +1 -0
  13. package/dist/{chunk-3BPVTHOW.js → chunk-DM3JIOFA.js} +4 -4
  14. package/dist/{chunk-XGLZK25V.js → chunk-EQKTBIKH.js} +2 -2
  15. package/dist/{chunk-SGULN3RX.cjs → chunk-EYQESL6X.cjs} +7 -7
  16. package/dist/{chunk-SGULN3RX.cjs.map → chunk-EYQESL6X.cjs.map} +1 -1
  17. package/dist/{chunk-N2LW5NHS.js → chunk-FS4FVNOU.js} +2 -2
  18. package/dist/{chunk-OLCNA32C.cjs → chunk-HIODHJCJ.cjs} +15 -15
  19. package/dist/{chunk-OLCNA32C.cjs.map → chunk-HIODHJCJ.cjs.map} +1 -1
  20. package/dist/{chunk-44ENRZZJ.cjs → chunk-HQG5AY64.cjs} +12 -12
  21. package/dist/{chunk-44ENRZZJ.cjs.map → chunk-HQG5AY64.cjs.map} +1 -1
  22. package/dist/{chunk-3L4B7MBD.cjs → chunk-KYN2GYUI.cjs} +31 -31
  23. package/dist/{chunk-3L4B7MBD.cjs.map → chunk-KYN2GYUI.cjs.map} +1 -1
  24. package/dist/{chunk-CIH2LZI7.cjs → chunk-ONXN77KP.cjs} +62 -62
  25. package/dist/{chunk-CIH2LZI7.cjs.map → chunk-ONXN77KP.cjs.map} +1 -1
  26. package/dist/{chunk-OX75ZFN6.js → chunk-SENYQRPF.js} +2 -2
  27. package/dist/{chunk-SECF2KWQ.cjs → chunk-SNFSLYVA.cjs} +5 -5
  28. package/dist/{chunk-SECF2KWQ.cjs.map → chunk-SNFSLYVA.cjs.map} +1 -1
  29. package/dist/{chunk-3KA5L7LC.js → chunk-UCQWQ7LM.js} +2 -2
  30. package/dist/{chunk-ANPV7GZT.js → chunk-VCRUAKOJ.js} +5701 -5281
  31. package/dist/chunk-VCRUAKOJ.js.map +1 -0
  32. package/dist/{chunk-DKN425XR.cjs → chunk-VOPTCR5I.cjs} +1604 -1184
  33. package/dist/chunk-VOPTCR5I.cjs.map +1 -0
  34. package/dist/{chunk-DO7LOWFE.cjs → chunk-WIKFWION.cjs} +9 -9
  35. package/dist/{chunk-DO7LOWFE.cjs.map → chunk-WIKFWION.cjs.map} +1 -1
  36. package/dist/{chunk-ADEP6E3Z.cjs → chunk-YKPQINSZ.cjs} +26 -26
  37. package/dist/{chunk-ADEP6E3Z.cjs.map → chunk-YKPQINSZ.cjs.map} +1 -1
  38. package/dist/{chunk-POJRURML.cjs → chunk-ZVFDJ35S.cjs} +37 -37
  39. package/dist/{chunk-POJRURML.cjs.map → chunk-ZVFDJ35S.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 +8 -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 -5
  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 +75 -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 +19 -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 +128 -165
  76. package/dist/components/index.cjs.map +1 -1
  77. package/dist/components/index.js +333 -370
  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 +8 -2
  97. package/dist/index.cjs.map +1 -1
  98. package/dist/index.js +11 -5
  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 +494 -0
  109. package/src/components/features/video-ratio-tabs.tsx +15 -19
  110. package/src/components/features/video.tsx +134 -3
  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-DU2X2MH2.js.map → chunk-23BJ64AY.js.map} +0 -0
  125. /package/dist/{chunk-OGNKGDHT.js.map → chunk-35C3PUUN.js.map} +0 -0
  126. /package/dist/{chunk-MVY4ZKQR.js.map → chunk-7CEJO3FE.js.map} +0 -0
  127. /package/dist/{chunk-EHVRW3WB.js.map → chunk-7UQ74ZJN.js.map} +0 -0
  128. /package/dist/{chunk-3BPVTHOW.js.map → chunk-DM3JIOFA.js.map} +0 -0
  129. /package/dist/{chunk-XGLZK25V.js.map → chunk-EQKTBIKH.js.map} +0 -0
  130. /package/dist/{chunk-N2LW5NHS.js.map → chunk-FS4FVNOU.js.map} +0 -0
  131. /package/dist/{chunk-OX75ZFN6.js.map → chunk-SENYQRPF.js.map} +0 -0
  132. /package/dist/{chunk-3KA5L7LC.js.map → chunk-UCQWQ7LM.js.map} +0 -0
@@ -208,6 +208,19 @@ interface VideoFileProps extends VideoCommonProps {
208
208
  srtContent?: string | null;
209
209
  /** HTTPS URL to a VTT captions file. Rendered as a native `<track>`. */
210
210
  captionsUrl?: string | null;
211
+ /** Autoplay muted on mount (forwarded as MuxPlayer `autoPlay="muted"`) — hover-preview surfaces. */
212
+ autoPlay?: boolean;
213
+ /** Loop playback — short bite previews. */
214
+ loop?: boolean;
215
+ /** Hide all player chrome (MuxPlayer `--controls: none`) — chromeless preview mode. */
216
+ chromeless?: boolean;
217
+ /** Play while the pointer hovers the player, pause on leave. Tries WITH
218
+ * sound at 50% volume first (bite-strip behavior); falls back to muted when
219
+ * the browser's autoplay policy rejects unmuted hover playback. */
220
+ playOnHover?: boolean;
221
+ /** Hide the bottom control bar, keep only the CENTER play/pause control
222
+ * (bite-strip cards per Figma). */
223
+ centerControlsOnly?: boolean;
211
224
  }
212
225
 
213
226
  interface VideoYouTubeProps extends VideoCommonProps {
@@ -221,6 +234,12 @@ interface VideoAutoProps extends VideoCommonProps {
221
234
  url: string;
222
235
  srtContent?: string | null;
223
236
  captionsUrl?: string | null;
237
+ /** See VideoFileProps — no-ops when the URL resolves to the YouTube branch. */
238
+ autoPlay?: boolean;
239
+ loop?: boolean;
240
+ chromeless?: boolean;
241
+ playOnHover?: boolean;
242
+ centerControlsOnly?: boolean;
224
243
  }
225
244
 
226
245
  export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
@@ -252,6 +271,11 @@ export function Video(props: VideoProps): React.ReactElement | null {
252
271
  muted={props.muted}
253
272
  srtContent={'srtContent' in props ? props.srtContent : null}
254
273
  captionsUrl={'captionsUrl' in props ? props.captionsUrl : null}
274
+ autoPlay={'autoPlay' in props ? props.autoPlay : undefined}
275
+ loop={'loop' in props ? props.loop : undefined}
276
+ chromeless={'chromeless' in props ? props.chromeless : undefined}
277
+ playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
278
+ centerControlsOnly={'centerControlsOnly' in props ? props.centerControlsOnly : undefined}
255
279
  className={props.className}
256
280
  />
257
281
  );
@@ -308,7 +332,7 @@ function wrapWithLayout(
308
332
  return <div className="absolute inset-0 w-full h-full">{inner}</div>;
309
333
  case 'native':
310
334
  default:
311
- // `native` callers (LazyBite in `<VideoBitesDisplay>`, blog cards) are
335
+ // `native` callers (blog cards etc.) are
312
336
  // expected to provide their own aspect-ratio container so the layout
313
337
  // primitive doesn't override portrait/square/landscape bites with 16:9.
314
338
  return inner;
@@ -325,6 +349,11 @@ interface FilePlayerProps {
325
349
  muted?: boolean;
326
350
  srtContent?: string | null;
327
351
  captionsUrl?: string | null;
352
+ autoPlay?: boolean;
353
+ loop?: boolean;
354
+ chromeless?: boolean;
355
+ playOnHover?: boolean;
356
+ centerControlsOnly?: boolean;
328
357
  className?: string;
329
358
  }
330
359
 
@@ -334,8 +363,72 @@ function FilePlayer({
334
363
  muted,
335
364
  srtContent,
336
365
  captionsUrl,
366
+ autoPlay,
367
+ loop,
368
+ chromeless,
369
+ playOnHover,
370
+ centerControlsOnly,
337
371
  className,
338
372
  }: FilePlayerProps): React.ReactElement {
373
+ // centerControlsOnly: the center play button shows at REST only — while
374
+ // playing, ALL chrome hides (no pause sign over the hover-preview).
375
+ const [isPlaying, setIsPlaying] = useState(false);
376
+ // playOnHover drives the underlying mux-player element imperatively — the
377
+ // element exposes native play()/pause()/muted/volume; the chrome stays as
378
+ // configured. Sound-first: volume 0.5 unmuted, muted fallback when the
379
+ // browser's autoplay policy rejects unmuted hover playback (hover is not a
380
+ // user gesture in Chrome's activation model).
381
+ const hoverPlayerRef = useRef<{
382
+ play?: () => Promise<void> | void;
383
+ pause?: () => void;
384
+ muted?: boolean;
385
+ volume?: number;
386
+ } | null>(null);
387
+ // Tracks whether the pointer is STILL over the player. A fast hover-out
388
+ // pauses the in-flight play(), which rejects it with AbortError — that must
389
+ // NOT trigger the muted retry (it would restart playback after the pointer
390
+ // left, with no visible control to stop it). Only a genuine autoplay-policy
391
+ // rejection (NotAllowedError) while still hovered retries muted.
392
+ const hoverActiveRef = useRef(false);
393
+ // Per-enter generation token: a LATE NotAllowedError from a previous enter
394
+ // must not fire the muted fallback into a newer (intended-sound) session.
395
+ const hoverGenerationRef = useRef(0);
396
+ const handleHoverEnter = playOnHover
397
+ ? () => {
398
+ hoverActiveRef.current = true;
399
+ const generation = ++hoverGenerationRef.current;
400
+ const el = hoverPlayerRef.current;
401
+ if (!el) return;
402
+ try {
403
+ el.volume = 0.5;
404
+ el.muted = false;
405
+ const attempt = el.play?.();
406
+ if (attempt && typeof (attempt as Promise<void>).catch === 'function') {
407
+ (attempt as Promise<void>).catch((err: unknown) => {
408
+ const name = (err as { name?: string } | null)?.name;
409
+ if (
410
+ name === 'NotAllowedError' &&
411
+ hoverActiveRef.current &&
412
+ generation === hoverGenerationRef.current
413
+ ) {
414
+ try {
415
+ el.muted = true;
416
+ // Swallow the retry's own rejection too (a hover-out mid-retry
417
+ // aborts it — that must not surface as an unhandled rejection).
418
+ (el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
419
+ } catch { /* give up silently */ }
420
+ }
421
+ });
422
+ }
423
+ } catch { /* ignore */ }
424
+ }
425
+ : undefined;
426
+ const handleHoverLeave = playOnHover
427
+ ? () => {
428
+ hoverActiveRef.current = false;
429
+ try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
430
+ }
431
+ : undefined;
339
432
  // Raw SRT text is unusable without a custom overlay — and we just deleted
340
433
  // the 900-LOC custom-controls layer that owned that overlay. Consumers
341
434
  // pass `captionsUrl` (the API-side VTT conversion) alongside `srtContent`
@@ -349,8 +442,11 @@ function FilePlayer({
349
442
  );
350
443
  }
351
444
 
352
- return (
445
+ const player = (
353
446
  <MuxPlayer
447
+ ref={hoverPlayerRef as React.Ref<never>}
448
+ onPlay={centerControlsOnly ? () => setIsPlaying(true) : undefined}
449
+ onPause={centerControlsOnly ? () => setIsPlaying(false) : undefined}
354
450
  src={url}
355
451
  poster={poster || undefined}
356
452
  streamType="on-demand"
@@ -365,6 +461,8 @@ function FilePlayer({
365
461
  // is ever undefined on a `data-app-type` we haven't themed yet.
366
462
  // NEVER let Mux pink leak onto a non-Flamingo platform.
367
463
  accentColor="var(--ods-accent, var(--color-accent-primary))"
464
+ autoPlay={autoPlay ? 'muted' : undefined}
465
+ loop={loop}
368
466
  className={className}
369
467
  // Fill the wrapping aspect-ratio container instead of MuxPlayer's
370
468
  // intrinsic size. Without this, MuxPlayer renders at its default
@@ -373,7 +471,25 @@ function FilePlayer({
373
471
  // flickers and grows" CLS we're killing. With `aspect-video` on
374
472
  // the centered wrapper and `width/height: 100%` here, the box is
375
473
  // 16:9 from first paint and stays put.
376
- style={{ width: '100%', height: '100%' }}
474
+ // `--controls: none` is media-chrome's kill switch for ALL player
475
+ // chrome — the chromeless preview mode. `--bottom-controls: none`
476
+ // hides only the bottom bar (center play/pause stays) — the
477
+ // bite-strip card look per Figma. Merged (never replacing) into the
478
+ // sizing style; custom properties need the CSSProperties cast.
479
+ style={{
480
+ width: '100%',
481
+ height: '100%',
482
+ ...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
483
+ ...(centerControlsOnly && !chromeless
484
+ ? ({
485
+ '--bottom-controls': 'none',
486
+ '--top-controls': 'none',
487
+ // While playing, hide the center control too — no pause sign
488
+ // over the hover-preview; it reappears when playback pauses.
489
+ ...(isPlaying ? { '--center-controls': 'none' } : {}),
490
+ } as React.CSSProperties)
491
+ : {}),
492
+ }}
377
493
  >
378
494
  {captionsUrl ? (
379
495
  <track
@@ -386,6 +502,21 @@ function FilePlayer({
386
502
  ) : null}
387
503
  </MuxPlayer>
388
504
  );
505
+
506
+ // MuxPlayerProps has no pointer-event props — the hover-play handlers live
507
+ // on a full-size wrapper instead (only rendered in playOnHover mode).
508
+ if (playOnHover) {
509
+ return (
510
+ <div
511
+ className="w-full h-full"
512
+ onPointerEnter={handleHoverEnter}
513
+ onPointerLeave={handleHoverLeave}
514
+ >
515
+ {player}
516
+ </div>
517
+ );
518
+ }
519
+ return player;
389
520
  }
390
521
 
391
522
  // -----------------------------------------------------------------------------
@@ -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-sm leading-5 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-sm leading-5 font-medium' : 'text-md leading-[16px]',
40
+ )}>
31
41
  {subtitle}
32
42
  </span>
33
43
  )}
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import {
3
+ VideoBitesStrip,
4
+ type VideoBiteStripItem,
5
+ } from '../components/features/video-bites-strip'
6
+
7
+ // Public-domain sample clips (Google CDN test media) — small MP4s so the
8
+ // hover-mount preview is exercised realistically in Storybook.
9
+ const SAMPLE_MP4 =
10
+ 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4'
11
+ const SAMPLE_MP4_2 =
12
+ 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
13
+
14
+ const bite = (over: Partial<VideoBiteStripItem>): VideoBiteStripItem => ({
15
+ url: SAMPLE_MP4,
16
+ title: 'How an MSSP Director Tests Agentic AI for Regulated Law Firms',
17
+ published: true,
18
+ aspect_ratio: '9:16',
19
+ created_at: '2026-07-01T00:00:00Z',
20
+ thumbnail_url: 'https://picsum.photos/seed/bite/270/480',
21
+ ...over,
22
+ })
23
+
24
+ const PROFILE = {
25
+ name: 'Michael Crisafio',
26
+ avatarUrl: 'https://picsum.photos/seed/avatar/68/68',
27
+ subtitle: 'Tier 1 Consulting',
28
+ }
29
+
30
+ const MANY_MIXED: VideoBiteStripItem[] = [
31
+ bite({ title: 'Portrait clip A', href: '#a' }),
32
+ bite({ url: SAMPLE_MP4_2, title: 'Landscape clip', aspect_ratio: '16:9', thumbnail_url: 'https://picsum.photos/seed/land/480/270', href: '#b' }),
33
+ bite({ title: 'Square clip', aspect_ratio: '1:1', thumbnail_url: 'https://picsum.photos/seed/sq/360/360' }),
34
+ bite({ title: 'No-thumbnail clip (PlayIcon fallback)', thumbnail_url: undefined }),
35
+ bite({ title: 'Portrait clip B', thumbnail_url: 'https://picsum.photos/seed/b2/270/480' }),
36
+ bite({ title: 'Portrait clip C', thumbnail_url: 'https://picsum.photos/seed/b3/270/480' }),
37
+ bite({ title: 'Portrait clip D', thumbnail_url: 'https://picsum.photos/seed/b4/270/480' }),
38
+ ]
39
+
40
+ const meta: Meta<typeof VideoBitesStrip> = {
41
+ title: 'Features/VideoBitesStrip',
42
+ component: VideoBitesStrip,
43
+ parameters: { layout: 'fullscreen' },
44
+ decorators: [
45
+ Story => (
46
+ <div className="bg-ods-bg p-8 min-h-[560px]">
47
+ <Story />
48
+ </div>
49
+ ),
50
+ ],
51
+ }
52
+ export default meta
53
+
54
+ type Story = StoryObj<typeof VideoBitesStrip>
55
+
56
+ /** Mixed ratios at uniform height — the marquee engages once content overflows. */
57
+ export const MixedRatiosMarquee: Story = {
58
+ args: { bites: MANY_MIXED, profile: PROFILE },
59
+ }
60
+
61
+ /** Two bites — no overflow, so no clones, no marquee, no chevrons. */
62
+ export const TwoBitesNoMarquee: Story = {
63
+ args: { bites: MANY_MIXED.slice(0, 2), profile: PROFILE },
64
+ }
65
+
66
+ /** Missing thumbnails fall back to bg-ods-card + PlayIcon; preview still mounts on hover. */
67
+ export const NoThumbnails: Story = {
68
+ args: {
69
+ bites: MANY_MIXED.map(b => ({ ...b, thumbnail_url: undefined })),
70
+ profile: PROFILE,
71
+ },
72
+ }
73
+
74
+ /** autoScroll off — plain scrollable row with chevrons (reduced-motion behaves the same). */
75
+ export const AutoScrollDisabled: Story = {
76
+ args: { bites: MANY_MIXED, autoScroll: false, profile: PROFILE },
77
+ }
78
+
79
+ /** Unpublished bites are filtered by default — this renders nothing (returns null). */
80
+ export const AllUnpublishedRendersNull: Story = {
81
+ args: { bites: MANY_MIXED.map(b => ({ ...b, published: false })) },
82
+ }
@@ -34,6 +34,19 @@ export interface CaseStudy {
34
34
  video_bites: VideoTeaser[] // Array of video clips from interviews or manually added
35
35
  customer_interview_id: number | null // Linked customer interview for bidirectional relationship
36
36
 
37
+ // Video processing pipeline (FULL parity with customer_interviews since
38
+ // 2026-07: transcribe & summarize, captions, clip config, highlight).
39
+ // NOTE: transcript_words_data is server-only and intentionally NOT typed here.
40
+ transcript?: string | null
41
+ srt_content?: string | null
42
+ video_summary?: string | null
43
+ custom_instructions?: string | null
44
+ config?: Record<string, unknown> | null // clip/highlight processing config
45
+ highlight_video_url?: string | null
46
+ highlight_video_thumbnail?: string | null
47
+ highlight_video_source?: 'manual' | 'ai_generated' | null
48
+ highlight_video_duration_ms?: number | null
49
+
37
50
  // SEO
38
51
  seo_title: string | null
39
52
  seo_description: string | null
@@ -85,6 +98,16 @@ export interface CreateCaseStudyData {
85
98
  video_source?: 'manual' | 'ai_generated'
86
99
  video_bites?: VideoTeaser[]
87
100
  customer_interview_id?: number | null
101
+ // Video processing pipeline (parity with customer_interviews)
102
+ transcript?: string | null
103
+ srt_content?: string | null
104
+ video_summary?: string | null
105
+ custom_instructions?: string | null
106
+ config?: Record<string, unknown> | null
107
+ highlight_video_url?: string | null
108
+ highlight_video_thumbnail?: string | null
109
+ highlight_video_source?: string | null
110
+ highlight_video_duration_ms?: number | null
88
111
  // SEO
89
112
  seo_title?: string
90
113
  seo_description?: string
@@ -4,14 +4,25 @@
4
4
  /**
5
5
  * VideoTeaser represents a short video clip extracted from a longer video.
6
6
  * Used for teasers/bites that can be shown on preview pages.
7
+ *
8
+ * CANONICAL BITE SHAPE: this is the base persistence type — the TRUE SUPERSET
9
+ * of every field persisted in the `video_bites` / `teasers` jsonb columns.
10
+ * `VideoTeaserWithRatio` (video-ratio-tabs.tsx) adds only `aspect_ratio` and is
11
+ * the one type display/aggregation code extends. The AI-extraction `VideoClip`
12
+ * below stays a separate shape by design.
7
13
  */
8
14
  export interface VideoTeaser {
9
15
  url: string
10
16
  title?: string
11
17
  thumbnail_url?: string // Optional thumbnail image URL for video preview. If not provided, video player will show first frame automatically.
12
18
  published?: boolean // Controls visibility on public preview page (default: false, admin must select)
19
+ featured?: boolean // Opts the bite into cross-entity featured pulls (homepage/configured surfaces). Independent of `published`.
13
20
  source?: 'manual' | 'ai_generated' // Track origin of teaser
14
21
  created_at?: string // ISO timestamp for sorting (newer items first)
22
+ start_time_ms?: number // Vizard extraction: clip start offset in the source video
23
+ end_time_ms?: number // Vizard extraction: clip end offset in the source video
24
+ confidence?: number // Vizard extraction: 0-100 virality confidence
25
+ viral_reason?: string // Vizard extraction: why this clip was picked
15
26
  // Duration auto-detected from video file
16
27
  }
17
28