@flamingo-stack/openframe-frontend-core 0.0.445 → 0.0.446-snapshot.20260717214810

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-R6WGCJBB.cjs → chunk-2PNWAAU4.cjs} +73 -73
  2. package/dist/{chunk-R6WGCJBB.cjs.map → chunk-2PNWAAU4.cjs.map} +1 -1
  3. package/dist/{chunk-H352AZJN.js → chunk-3GBLPPSW.js} +6 -6
  4. package/dist/{chunk-TCEBFMLQ.js → chunk-4ONE3HQP.js} +2 -2
  5. package/dist/{chunk-K4RASNQM.js → chunk-5BJJRPDH.js} +3 -3
  6. package/dist/{chunk-H2DYCT6M.js → chunk-6TAIXSF5.js} +2 -2
  7. package/dist/{chunk-VIJARDBM.cjs → chunk-AN7MCJTV.cjs} +5 -3
  8. package/dist/chunk-AN7MCJTV.cjs.map +1 -0
  9. package/dist/{chunk-7R256DKK.js → chunk-D2NLNBLH.js} +2 -2
  10. package/dist/{chunk-N64VTHZ5.cjs → chunk-E6HKLQ3Q.cjs} +7 -7
  11. package/dist/{chunk-N64VTHZ5.cjs.map → chunk-E6HKLQ3Q.cjs.map} +1 -1
  12. package/dist/{chunk-DTLNCOSG.cjs → chunk-EZMM2OMP.cjs} +32 -32
  13. package/dist/{chunk-DTLNCOSG.cjs.map → chunk-EZMM2OMP.cjs.map} +1 -1
  14. package/dist/{chunk-5FUQTSTT.cjs → chunk-FR6NVZVM.cjs} +18 -18
  15. package/dist/{chunk-5FUQTSTT.cjs.map → chunk-FR6NVZVM.cjs.map} +1 -1
  16. package/dist/{chunk-Z4N7XLFG.js → chunk-G7QSLGMI.js} +3 -3
  17. package/dist/{chunk-5DWPXK37.cjs → chunk-ITJMUH4Q.cjs} +40 -40
  18. package/dist/{chunk-5DWPXK37.cjs.map → chunk-ITJMUH4Q.cjs.map} +1 -1
  19. package/dist/{chunk-P3FN4NAE.cjs → chunk-KYLOPUIH.cjs} +29 -29
  20. package/dist/{chunk-P3FN4NAE.cjs.map → chunk-KYLOPUIH.cjs.map} +1 -1
  21. package/dist/{chunk-YCLSV2H6.cjs → chunk-PF6CKZIH.cjs} +5 -5
  22. package/dist/{chunk-YCLSV2H6.cjs.map → chunk-PF6CKZIH.cjs.map} +1 -1
  23. package/dist/{chunk-QL2EI56X.cjs → chunk-RNXMP7GB.cjs} +1014 -988
  24. package/dist/chunk-RNXMP7GB.cjs.map +1 -0
  25. package/dist/{chunk-PTT4J5GF.cjs → chunk-ROCJMSJI.cjs} +3 -3
  26. package/dist/{chunk-PTT4J5GF.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
  27. package/dist/{chunk-RXLMN6B5.cjs → chunk-S7VMUDUB.cjs} +12 -12
  28. package/dist/{chunk-RXLMN6B5.cjs.map → chunk-S7VMUDUB.cjs.map} +1 -1
  29. package/dist/{chunk-22NSWMI6.js → chunk-SHC5NFOH.js} +3 -3
  30. package/dist/{chunk-5NYXGWBN.js → chunk-TKGCTDQY.js} +4 -2
  31. package/dist/{chunk-5NYXGWBN.js.map → chunk-TKGCTDQY.js.map} +1 -1
  32. package/dist/{chunk-3DQ5BFPM.js → chunk-TMZYFYSS.js} +5 -5
  33. package/dist/{chunk-KISQUH33.cjs → chunk-WLU37UZO.cjs} +73 -73
  34. package/dist/{chunk-KISQUH33.cjs.map → chunk-WLU37UZO.cjs.map} +1 -1
  35. package/dist/{chunk-FIAXFNAA.js → chunk-WMOF3WQT.js} +5 -5
  36. package/dist/{chunk-MPSA6YAM.cjs → chunk-X4XHW3OV.cjs} +40 -40
  37. package/dist/{chunk-MPSA6YAM.cjs.map → chunk-X4XHW3OV.cjs.map} +1 -1
  38. package/dist/{chunk-3HYSD7NR.cjs → chunk-XUS75SQW.cjs} +15 -15
  39. package/dist/{chunk-3HYSD7NR.cjs.map → chunk-XUS75SQW.cjs.map} +1 -1
  40. package/dist/{chunk-3BHV2QAH.js → chunk-YCSNDUDE.js} +510 -484
  41. package/dist/chunk-YCSNDUDE.js.map +1 -0
  42. package/dist/{chunk-G2KXNMFQ.js → chunk-YHNBJ3F3.js} +2 -2
  43. package/dist/{chunk-MQUS6BFR.js → chunk-ZQVEB6KT.js} +6 -6
  44. package/dist/{chunk-XPZLIONZ.js → chunk-ZYB3BYBA.js} +4 -4
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/cards-strip.d.ts.map +1 -1
  58. package/dist/components/features/highlight-video-section.d.ts.map +1 -1
  59. package/dist/components/features/index.cjs +5 -3
  60. package/dist/components/features/index.cjs.map +1 -1
  61. package/dist/components/features/index.js +4 -2
  62. package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
  63. package/dist/components/features/mux-origins.cjs +14 -0
  64. package/dist/components/features/mux-origins.cjs.map +1 -1
  65. package/dist/components/features/mux-origins.d.ts +26 -2
  66. package/dist/components/features/mux-origins.d.ts.map +1 -1
  67. package/dist/components/features/mux-origins.js +14 -1
  68. package/dist/components/features/mux-origins.js.map +1 -1
  69. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  70. package/dist/components/features/release-media-manager.d.ts.map +1 -1
  71. package/dist/components/features/use-video-warmup.d.ts +12 -0
  72. package/dist/components/features/use-video-warmup.d.ts.map +1 -1
  73. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  74. package/dist/components/features/video-source-selector.d.ts +1 -11
  75. package/dist/components/features/video-source-selector.d.ts.map +1 -1
  76. package/dist/components/features/video.d.ts +14 -1
  77. package/dist/components/features/video.d.ts.map +1 -1
  78. package/dist/components/help-center-pages/index.cjs +22 -22
  79. package/dist/components/help-center-pages/index.js +13 -13
  80. package/dist/components/index.cjs +137 -135
  81. package/dist/components/index.cjs.map +1 -1
  82. package/dist/components/index.js +13 -11
  83. package/dist/components/index.js.map +1 -1
  84. package/dist/components/navigation/index.cjs +3 -3
  85. package/dist/components/navigation/index.js +2 -2
  86. package/dist/components/onboarding-guides/index.cjs +7 -7
  87. package/dist/components/onboarding-guides/index.js +6 -6
  88. package/dist/components/related-content/index.cjs +5 -5
  89. package/dist/components/related-content/index.js +4 -4
  90. package/dist/components/tickets/index.cjs +6 -6
  91. package/dist/components/tickets/index.js +5 -5
  92. package/dist/components/ui/index.cjs +3 -3
  93. package/dist/components/ui/index.js +2 -2
  94. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  95. package/dist/hooks/index.cjs +4 -2
  96. package/dist/hooks/index.cjs.map +1 -1
  97. package/dist/hooks/index.js +3 -1
  98. package/dist/hooks/use-near-viewport.d.ts +7 -0
  99. package/dist/hooks/use-near-viewport.d.ts.map +1 -1
  100. package/dist/index.cjs +7 -3
  101. package/dist/index.cjs.map +1 -1
  102. package/dist/index.js +6 -2
  103. package/package.json +1 -1
  104. package/src/components/features/cards-strip.tsx +2 -1
  105. package/src/components/features/highlight-video-section.tsx +10 -7
  106. package/src/components/features/media-gallery-manager.tsx +6 -8
  107. package/src/components/features/mux-origins.ts +40 -2
  108. package/src/components/features/policy-configuration-panel.tsx +34 -49
  109. package/src/components/features/release-media-manager.tsx +6 -8
  110. package/src/components/features/use-video-warmup.ts +19 -3
  111. package/src/components/features/video-bites-strip.tsx +24 -4
  112. package/src/components/features/video-source-selector.tsx +16 -56
  113. package/src/components/features/video.tsx +103 -8
  114. package/src/components/ui/rich-markdown-renderer.tsx +20 -0
  115. package/src/components/ui/simple-markdown-renderer.tsx +9 -5
  116. package/src/hooks/use-near-viewport.ts +9 -1
  117. package/src/stories/PolicyConfigurationPanel.stories.tsx +8 -6
  118. package/dist/chunk-3BHV2QAH.js.map +0 -1
  119. package/dist/chunk-QL2EI56X.cjs.map +0 -1
  120. package/dist/chunk-VIJARDBM.cjs.map +0 -1
  121. /package/dist/{chunk-H352AZJN.js.map → chunk-3GBLPPSW.js.map} +0 -0
  122. /package/dist/{chunk-TCEBFMLQ.js.map → chunk-4ONE3HQP.js.map} +0 -0
  123. /package/dist/{chunk-K4RASNQM.js.map → chunk-5BJJRPDH.js.map} +0 -0
  124. /package/dist/{chunk-H2DYCT6M.js.map → chunk-6TAIXSF5.js.map} +0 -0
  125. /package/dist/{chunk-7R256DKK.js.map → chunk-D2NLNBLH.js.map} +0 -0
  126. /package/dist/{chunk-Z4N7XLFG.js.map → chunk-G7QSLGMI.js.map} +0 -0
  127. /package/dist/{chunk-22NSWMI6.js.map → chunk-SHC5NFOH.js.map} +0 -0
  128. /package/dist/{chunk-3DQ5BFPM.js.map → chunk-TMZYFYSS.js.map} +0 -0
  129. /package/dist/{chunk-FIAXFNAA.js.map → chunk-WMOF3WQT.js.map} +0 -0
  130. /package/dist/{chunk-G2KXNMFQ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
  131. /package/dist/{chunk-MQUS6BFR.js.map → chunk-ZQVEB6KT.js.map} +0 -0
  132. /package/dist/{chunk-XPZLIONZ.js.map → chunk-ZYB3BYBA.js.map} +0 -0
@@ -34,6 +34,21 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
34
34
  import MuxPlayer from '@mux/mux-player-react';
35
35
  import { VideoPlayBadge, VideoUnmuteGlyph } from './video-center-badge';
36
36
  import { fetchPriorityProp } from '../../utils/fetch-priority';
37
+ import { saveDataEnabled } from './use-video-warmup';
38
+
39
+ // =============================================================================
40
+ // Dev-only hover→playing latency instrumentation gate. Always on in dev
41
+ // builds; opt-in in production via `localStorage.VIDEO_PERF_DEBUG` so an
42
+ // "instant hover" regression can be measured on a live deployment.
43
+ // =============================================================================
44
+ function videoPerfDebugEnabled(): boolean {
45
+ if (process.env.NODE_ENV !== 'production') return true;
46
+ try {
47
+ return typeof localStorage !== 'undefined' && localStorage.getItem('VIDEO_PERF_DEBUG') !== null;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
37
52
 
38
53
  // =============================================================================
39
54
  // Suppress Google Cast SDK loading (CSP-friendly)
@@ -253,8 +268,19 @@ interface VideoFileProps extends VideoCommonProps {
253
268
  * seeked to `#t=0.1` (media-fragment trick; paints on iOS Safari where a
254
269
  * fragmentless metadata load stays blank). No chrome, no playback, no
255
270
  * MuxPlayer cost — the resting facade layer under strip cards when no
256
- * poster asset exists. All player props are ignored. */
271
+ * poster asset exists. `poster`/`fit`/`className` are honored; all other
272
+ * player props are ignored. */
257
273
  firstFrameOnly?: boolean;
274
+ /** Media preload hint. When omitted, the SSOT default applies:
275
+ * `'metadata'` (manifest + ~1 segment buffered on mount — instant
276
+ * hover/click start, bounded by playback-core's maxBufferLength=1 clamp),
277
+ * downgraded to `'none'` on Save-Data connections. Pass a value only to
278
+ * override the policy deliberately. */
279
+ preload?: 'none' | 'metadata' | 'auto';
280
+ /** Object-fit for the media inside the player box. Default `'contain'`
281
+ * (MuxPlayer default); `'cover'` crops to fill — aspect-cropped grid
282
+ * cells and social-post mockups. */
283
+ fit?: 'contain' | 'cover';
258
284
  }
259
285
 
260
286
  interface VideoYouTubeProps extends VideoCommonProps {
@@ -275,6 +301,8 @@ interface VideoAutoProps extends VideoCommonProps {
275
301
  playOnHover?: boolean;
276
302
  playWhenHovered?: boolean;
277
303
  firstFrameOnly?: boolean;
304
+ preload?: 'none' | 'metadata' | 'auto';
305
+ fit?: 'contain' | 'cover';
278
306
  }
279
307
 
280
308
  export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
@@ -300,7 +328,12 @@ export function Video(props: VideoProps): React.ReactElement | null {
300
328
  minimalControls={props.minimalControls}
301
329
  />
302
330
  ) : 'firstFrameOnly' in props && props.firstFrameOnly ? (
303
- <FirstFramePreview url={url} className={props.className} />
331
+ <FirstFramePreview
332
+ url={url}
333
+ poster={props.poster}
334
+ fit={'fit' in props ? props.fit : undefined}
335
+ className={props.className}
336
+ />
304
337
  ) : (
305
338
  <FilePlayer
306
339
  url={url}
@@ -313,6 +346,8 @@ export function Video(props: VideoProps): React.ReactElement | null {
313
346
  chromeless={'chromeless' in props ? props.chromeless : undefined}
314
347
  playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
315
348
  playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
349
+ preload={'preload' in props ? props.preload : undefined}
350
+ fit={'fit' in props ? props.fit : undefined}
316
351
  className={props.className}
317
352
  />
318
353
  );
@@ -387,16 +422,30 @@ function wrapWithLayout(
387
422
  * first frame (also fixes iOS Safari, which stays blank on a fragmentless
388
423
  * metadata load). The transparent media background keeps the box showing the
389
424
  * card surface (never black) until the frame decodes. */
390
- function FirstFramePreview({ url, className }: { url: string; className?: string }): React.ReactElement {
425
+ function FirstFramePreview({
426
+ url,
427
+ poster,
428
+ fit,
429
+ className,
430
+ }: {
431
+ url: string;
432
+ poster?: string | null;
433
+ fit?: 'contain' | 'cover';
434
+ className?: string;
435
+ }): React.ReactElement {
391
436
  return (
392
437
  <div
393
438
  aria-hidden
394
439
  className="h-full w-full"
395
440
  style={{ '--media-background-color': 'transparent' } as React.CSSProperties}
396
441
  >
442
+ {/* No explicit `preload` — inherits the SSOT default ('metadata',
443
+ 'none' under Save-Data) so posterless facade cards honor the
444
+ Save-Data policy like every other surface. */}
397
445
  <FilePlayer
398
446
  url={`${url}#t=0.1`}
399
- preload="metadata"
447
+ poster={poster}
448
+ fit={fit}
400
449
  muted
401
450
  chromeless
402
451
  className={className}
@@ -420,8 +469,11 @@ interface FilePlayerProps {
420
469
  chromeless?: boolean;
421
470
  playOnHover?: boolean;
422
471
  playWhenHovered?: boolean;
423
- /** Media preload hint — the firstFrameOnly facade passes 'metadata'. */
472
+ /** Media preload hint — see the public `VideoFileProps.preload` JSDoc.
473
+ * When omitted, defaults to 'metadata' ('none' under Save-Data). */
424
474
  preload?: 'none' | 'metadata' | 'auto';
475
+ /** Object-fit — 'cover' maps to media-chrome's `--media-object-fit`. */
476
+ fit?: 'contain' | 'cover';
425
477
  className?: string;
426
478
  }
427
479
 
@@ -437,8 +489,21 @@ function FilePlayer({
437
489
  playOnHover,
438
490
  playWhenHovered,
439
491
  preload,
492
+ fit,
440
493
  className,
441
494
  }: FilePlayerProps): React.ReactElement {
495
+ // Explicit preload policy — never rely on the browser/MuxPlayer implicit
496
+ // default. 'metadata' makes playback-core load the manifest immediately
497
+ // and clamp buffering to maxBufferLength=1/maxBufferSize=1 (manifest +
498
+ // ~1 segment, then idle); play() restores full buffering automatically.
499
+ // That bounded prefetch is what makes hover/click start instant. Save-Data
500
+ // connections get 'none' (nothing until play()). SSR note: the server pass
501
+ // always emits 'metadata' (saveDataEnabled() is false server-side); for
502
+ // Save-Data users React reconciles the property to 'none' during hydration.
503
+ // The mux-player custom element upgrades from client JS, so the window for
504
+ // an early manifest fetch is at most one bounded manifest+segment — the
505
+ // Save-Data verification gate covers it (see plan A2.4).
506
+ const effectivePreload = preload ?? (saveDataEnabled() ? 'none' : 'metadata');
442
507
  // True while hover playback is running MUTED because the browser's autoplay
443
508
  // policy blocked sound (no user activation yet). Drives the center unmute
444
509
  // control — the industry pattern (muted autoplay + explicit unmute button)
@@ -454,7 +519,22 @@ function FilePlayer({
454
519
  pause?: () => void;
455
520
  muted?: boolean;
456
521
  volume?: number;
522
+ addEventListener?: (type: string, listener: () => void) => void;
523
+ removeEventListener?: (type: string, listener: () => void) => void;
457
524
  } | null>(null);
525
+ // Dev/opt-in hover→'playing' latency metric (see videoPerfDebugEnabled).
526
+ // One listener at a time — re-entering hover replaces it; hover-leave and
527
+ // unmount clear it so no stale listener survives across generations.
528
+ const perfListenerRef = useRef<(() => void) | null>(null);
529
+ const clearPerfListener = useCallback(() => {
530
+ if (perfListenerRef.current) {
531
+ try {
532
+ hoverPlayerRef.current?.removeEventListener?.('playing', perfListenerRef.current);
533
+ } catch { /* element already torn down */ }
534
+ perfListenerRef.current = null;
535
+ }
536
+ }, []);
537
+ useEffect(() => clearPerfListener, [clearPerfListener]);
458
538
  // Tracks whether the pointer is STILL over the player. A fast hover-out
459
539
  // pauses the in-flight play(), which rejects it with AbortError — that must
460
540
  // NOT trigger the muted retry (it would restart playback after the pointer
@@ -481,6 +561,17 @@ function FilePlayer({
481
561
  const generation = ++hoverGenerationRef.current;
482
562
  const el = hoverPlayerRef.current;
483
563
  if (!el) return;
564
+ if (videoPerfDebugEnabled()) {
565
+ clearPerfListener();
566
+ const startedAt = performance.now();
567
+ const onPlaying = () => {
568
+ clearPerfListener();
569
+ // eslint-disable-next-line no-console
570
+ console.debug('[Video] hover→playing %dms', Math.round(performance.now() - startedAt));
571
+ };
572
+ perfListenerRef.current = onPlaying;
573
+ try { el.addEventListener?.('playing', onPlaying); } catch { /* ignore */ }
574
+ }
484
575
  try {
485
576
  el.volume = 0.5;
486
577
  if (userHasInteracted) {
@@ -523,13 +614,14 @@ function FilePlayer({
523
614
  activationWaiters.add(waiter);
524
615
  }
525
616
  } catch { /* ignore */ }
526
- }, []);
617
+ }, [clearActivationWaiter, clearPerfListener]);
527
618
  const stopHoverPlayback = useCallback(() => {
528
619
  hoverActiveRef.current = false;
529
620
  setHoverMutedFallback(false);
530
621
  clearActivationWaiter();
622
+ clearPerfListener();
531
623
  try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
532
- }, [clearActivationWaiter]);
624
+ }, [clearActivationWaiter, clearPerfListener]);
533
625
 
534
626
  // Explicit unmute affordance: the click IS the user activation the autoplay
535
627
  // policy wants, so unmuting here always succeeds (and the window-level
@@ -579,7 +671,7 @@ function FilePlayer({
579
671
  src={url}
580
672
  poster={poster || undefined}
581
673
  streamType="on-demand"
582
- preload={preload}
674
+ preload={effectivePreload}
583
675
  playsInline
584
676
  muted={muted}
585
677
  preferCmcd="header"
@@ -606,10 +698,13 @@ function FilePlayer({
606
698
  // hides only the bottom bar (center play/pause stays) — the
607
699
  // bite-strip card look per Figma. Merged (never replacing) into the
608
700
  // sizing style; custom properties need the CSSProperties cast.
701
+ // `--media-object-fit: cover` is media-chrome's documented object-fit
702
+ // var — the `fit="cover"` crop for grid cells / mockups.
609
703
  style={{
610
704
  width: '100%',
611
705
  height: '100%',
612
706
  ...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
707
+ ...(fit === 'cover' ? ({ '--media-object-fit': 'cover' } as React.CSSProperties) : {}),
613
708
  }}
614
709
  >
615
710
  {captionsUrl ? (
@@ -1100,6 +1100,26 @@ const RichMarkdownInner: React.FC<InnerProps> = ({
1100
1100
  return <MarkdownImage src={src.trim()} alt={alt} />;
1101
1101
  },
1102
1102
 
1103
+ // Raw <video> tags in stored content (e.g. the hub's blog publisher
1104
+ // injects `<video src class="w-full my-8 rounded-lg" controls>` via
1105
+ // injectVideoInBlogContent) pass through rehypeRaw — route them to the
1106
+ // <Video> SSOT (MuxPlayer) instead of a native element so Mux HLS
1107
+ // manifests play on every browser and playback UX is unified. The
1108
+ // incoming className is forwarded so existing embeds keep their
1109
+ // width/margin/rounding.
1110
+ video: ({ src, poster, className }: any) => {
1111
+ if (!src || typeof src !== 'string' || src.trim() === '') {
1112
+ return null;
1113
+ }
1114
+ return (
1115
+ <div className={`overflow-hidden ${className || 'w-full my-8 rounded-lg'}`}>
1116
+ <div className="w-full aspect-video">
1117
+ <Video kind="file" url={src.trim()} poster={typeof poster === 'string' ? poster : undefined} />
1118
+ </div>
1119
+ </div>
1120
+ );
1121
+ },
1122
+
1103
1123
  // Style lists
1104
1124
  ul: ({ children }: any) => (
1105
1125
  <ul className={`list-disc list-outside my-4 ml-8 space-y-2 ${
@@ -200,7 +200,11 @@ function cardAwareUrlTransform(url: string, key: string): string {
200
200
  * 18 logs "tag is unrecognized in this browser" for every unknown tag;
201
201
  * React 19 throws on tags with reserved kebab-case forms. We pre-escape
202
202
  * to keep the renderer pristine without losing legitimate inline HTML
203
- * (details/summary, video, iframe, kbd, mark, etc.).
203
+ * (details/summary, iframe, kbd, mark, etc.). `video` is deliberately
204
+ * NOT allow-listed: chat strips <video> tags server-side, and all video
205
+ * playback goes through the <Video> SSOT — a stray tag renders as
206
+ * escaped literal text (visible + debuggable) rather than a native
207
+ * player outside the unified pipeline.
204
208
  *
205
209
  * The allow-list mirrors HTML5 + the elements the chat shell wires
206
210
  * component overrides for. Kept lower-case; matched case-insensitively.
@@ -215,8 +219,8 @@ const SAFE_HTML_TAGS = new Set([
215
219
  'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
216
220
  'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
217
221
  'ul', 'var', 'wbr',
218
- // Media
219
- 'img', 'picture', 'source', 'audio', 'video', 'iframe', 'track',
222
+ // Media ('video' intentionally excluded — see the header comment)
223
+ 'img', 'picture', 'source', 'audio', 'iframe', 'track',
220
224
  // Forms (rehype-raw allows them; mostly harmless for chat output)
221
225
  'button', 'input', 'label', 'select', 'option', 'optgroup', 'textarea', 'form', 'fieldset', 'legend',
222
226
  ])
@@ -652,8 +656,8 @@ const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
652
656
  // logs a noisy "tag is unrecognized in this browser" warning AND can
653
657
  // crash the SimpleMarkdownRenderer when the tag has a kebab-case
654
658
  // form React rejects outright). Allow-listed tags pass through
655
- // unchanged so legitimate inline HTML (details/summary, video,
656
- // iframe, etc.) still renders.
659
+ // unchanged so legitimate inline HTML (details/summary, iframe,
660
+ // etc.) still renders ('video' is excluded — see SAFE_HTML_TAGS).
657
661
  const processedContent = useMemo(
658
662
  () => escapeUnknownHtmlTags(preprocessContent ? preprocessContent(content) : content),
659
663
  [preprocessContent, content],
@@ -58,6 +58,14 @@ function getObserverFor(rootMargin: string): IntersectionObserver {
58
58
  return io;
59
59
  }
60
60
 
61
+ /**
62
+ * Default near-viewport lookahead — the SSOT for "mount media on approach".
63
+ * Shared by this hook's default AND the raw two-way IntersectionObservers in
64
+ * `cards-strip.tsx` / `video-bites-strip.tsx` (which need mount+unmount, not
65
+ * this hook's fire-once semantics, but must agree on the distance).
66
+ */
67
+ export const NEAR_VIEWPORT_ROOT_MARGIN = '500px';
68
+
61
69
  export interface UseNearViewportResult<T extends Element = HTMLElement> {
62
70
  /** Ref to attach to the element you want to gate on visibility. */
63
71
  ref: (node: T | null) => void;
@@ -72,7 +80,7 @@ export interface UseNearViewportResult<T extends Element = HTMLElement> {
72
80
  * '0px' = strict on-screen detection.
73
81
  */
74
82
  export function useNearViewport<T extends Element = HTMLElement>(
75
- rootMargin: string = '500px'
83
+ rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN
76
84
  ): UseNearViewportResult<T> {
77
85
  const [isNear, setIsNear] = useState(false);
78
86
  const elRef = useRef<T | null>(null);
@@ -239,12 +239,14 @@ export const GroupedPolicySections: Story = {
239
239
  <PolicyConfigurationPanel
240
240
  categories={categories}
241
241
  editMode={args.editMode}
242
- onPolicyPermissionChange={(categoryId, policyId, level) =>
243
- updateGroup(groupName, categories => applyPolicyLevel(categories, categoryId, policyId, level))
244
- }
245
- onCategoryPermissionChange={(categoryId, level) =>
246
- updateGroup(groupName, categories => applyCategoryLevel(categories, categoryId, level))
247
- }
242
+ onPolicyPermissionChange={(categoryId, policyId, level) => {
243
+ args.onPolicyPermissionChange(categoryId, policyId, level)
244
+ updateGroup(groupName, prev => applyPolicyLevel(prev, categoryId, policyId, level))
245
+ }}
246
+ onCategoryPermissionChange={(categoryId, level) => {
247
+ args.onCategoryPermissionChange(categoryId, level)
248
+ updateGroup(groupName, prev => applyCategoryLevel(prev, categoryId, level))
249
+ }}
248
250
  />
249
251
  </div>
250
252
  ))}