@flamingo-stack/openframe-frontend-core 0.0.394-snapshot.20260709183206 → 0.0.394

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 (114) hide show
  1. package/dist/{chunk-ZB5HSWX4.js → chunk-2J6AOHJI.js} +2 -2
  2. package/dist/{chunk-Y4I4462K.cjs → chunk-4FT3KQD3.cjs} +15 -15
  3. package/dist/{chunk-Y4I4462K.cjs.map → chunk-4FT3KQD3.cjs.map} +1 -1
  4. package/dist/{chunk-NN73KMIK.js → chunk-5J3LR56Z.js} +2 -2
  5. package/dist/{chunk-WHJEU7V7.js → chunk-A7IGCQUD.js} +4 -4
  6. package/dist/{chunk-PFF6Q6N5.js → chunk-AGTM2H7T.js} +2 -2
  7. package/dist/{chunk-PJ2YFSMY.js → chunk-AQJ5S6RS.js} +2 -2
  8. package/dist/{chunk-T6FEE3R2.cjs → chunk-CJMM2WZP.cjs} +5 -5
  9. package/dist/{chunk-T6FEE3R2.cjs.map → chunk-CJMM2WZP.cjs.map} +1 -1
  10. package/dist/{chunk-5U72NXGG.cjs → chunk-DFCPMBYN.cjs} +9 -9
  11. package/dist/{chunk-5U72NXGG.cjs.map → chunk-DFCPMBYN.cjs.map} +1 -1
  12. package/dist/{chunk-J2UDV3FH.cjs → chunk-DRSAMA27.cjs} +62 -62
  13. package/dist/{chunk-J2UDV3FH.cjs.map → chunk-DRSAMA27.cjs.map} +1 -1
  14. package/dist/{chunk-TWLPG5IQ.js → chunk-DXIDOGF4.js} +2 -2
  15. package/dist/{chunk-3VJPMRNT.cjs → chunk-FIOOW474.cjs} +7 -7
  16. package/dist/{chunk-3VJPMRNT.cjs.map → chunk-FIOOW474.cjs.map} +1 -1
  17. package/dist/{chunk-YJE3HL6Q.js → chunk-FVFPGGDA.js} +4 -4
  18. package/dist/{chunk-TTEAYVLT.js → chunk-G6CNEEZ3.js} +2 -2
  19. package/dist/{chunk-NJPMHDFG.js → chunk-GAD4OYKN.js} +2 -2
  20. package/dist/{chunk-BEPZ5YAN.cjs → chunk-HXDBJR4A.cjs} +59 -59
  21. package/dist/{chunk-BEPZ5YAN.cjs.map → chunk-HXDBJR4A.cjs.map} +1 -1
  22. package/dist/{chunk-OSOKWP5R.cjs → chunk-IEKEUSPV.cjs} +31 -31
  23. package/dist/{chunk-OSOKWP5R.cjs.map → chunk-IEKEUSPV.cjs.map} +1 -1
  24. package/dist/{chunk-RNWLVI3V.cjs → chunk-LP3FQTCB.cjs} +26 -26
  25. package/dist/{chunk-RNWLVI3V.cjs.map → chunk-LP3FQTCB.cjs.map} +1 -1
  26. package/dist/{chunk-4YVUL4T7.cjs → chunk-N6SH4ZYN.cjs} +12 -12
  27. package/dist/{chunk-4YVUL4T7.cjs.map → chunk-N6SH4ZYN.cjs.map} +1 -1
  28. package/dist/{chunk-RWBTSF2E.js → chunk-NYBMBTBL.js} +5181 -5295
  29. package/dist/chunk-NYBMBTBL.js.map +1 -0
  30. package/dist/{chunk-M7WTWBCY.cjs → chunk-PVMISZQT.cjs} +37 -37
  31. package/dist/{chunk-M7WTWBCY.cjs.map → chunk-PVMISZQT.cjs.map} +1 -1
  32. package/dist/{chunk-PNMX75W4.cjs → chunk-R6PS3AXI.cjs} +1057 -1171
  33. package/dist/chunk-R6PS3AXI.cjs.map +1 -0
  34. package/dist/{chunk-45Z6B6CO.js → chunk-SSZL4QYX.js} +4 -4
  35. package/dist/{chunk-MPN3MV5H.js → chunk-WAKXAMRC.js} +4 -4
  36. package/dist/{chunk-ZCKVP4SU.cjs → chunk-YIJGUS7V.cjs} +26 -26
  37. package/dist/{chunk-ZCKVP4SU.cjs.map → chunk-YIJGUS7V.cjs.map} +1 -1
  38. package/dist/components/carousel.d.ts +19 -0
  39. package/dist/components/carousel.d.ts.map +1 -0
  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/index.cjs +2 -6
  53. package/dist/components/features/index.cjs.map +1 -1
  54. package/dist/components/features/index.d.ts +0 -1
  55. package/dist/components/features/index.d.ts.map +1 -1
  56. package/dist/components/features/index.js +1 -5
  57. package/dist/components/features/video-bites-strip.d.ts +26 -15
  58. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  59. package/dist/components/help-center-pages/index.cjs +20 -20
  60. package/dist/components/help-center-pages/index.js +11 -11
  61. package/dist/components/index.cjs +120 -124
  62. package/dist/components/index.cjs.map +1 -1
  63. package/dist/components/index.js +9 -13
  64. package/dist/components/index.js.map +1 -1
  65. package/dist/components/navigation/index.cjs +2 -2
  66. package/dist/components/navigation/index.js +1 -1
  67. package/dist/components/onboarding-guides/index.cjs +5 -5
  68. package/dist/components/onboarding-guides/index.js +4 -4
  69. package/dist/components/related-content/index.cjs +3 -3
  70. package/dist/components/related-content/index.js +2 -2
  71. package/dist/components/tickets/index.cjs +5 -5
  72. package/dist/components/tickets/index.js +4 -4
  73. package/dist/components/ui/index.cjs +2 -2
  74. package/dist/components/ui/index.js +1 -1
  75. package/dist/index.cjs +2 -6
  76. package/dist/index.cjs.map +1 -1
  77. package/dist/index.js +1 -5
  78. package/dist/types/blog.d.ts +10 -9
  79. package/dist/types/blog.d.ts.map +1 -1
  80. package/dist/types/case-study.d.ts +5 -2
  81. package/dist/types/case-study.d.ts.map +1 -1
  82. package/dist/types/customer-interview.d.ts +5 -2
  83. package/dist/types/customer-interview.d.ts.map +1 -1
  84. package/dist/types/index.d.ts +0 -1
  85. package/dist/types/index.d.ts.map +1 -1
  86. package/package.json +2 -1
  87. package/src/components/.carousel.md +50 -0
  88. package/src/components/carousel.tsx +262 -0
  89. package/src/components/features/index.ts +0 -3
  90. package/src/components/features/video-bites-strip.tsx +375 -53
  91. package/src/types/blog.ts +10 -9
  92. package/src/types/case-study.ts +5 -2
  93. package/src/types/customer-interview.ts +5 -2
  94. package/src/types/index.ts +0 -1
  95. package/dist/chunk-PNMX75W4.cjs.map +0 -1
  96. package/dist/chunk-RWBTSF2E.js.map +0 -1
  97. package/dist/components/features/cards-strip.d.ts +0 -114
  98. package/dist/components/features/cards-strip.d.ts.map +0 -1
  99. package/dist/types/entity-platform.d.ts +0 -22
  100. package/dist/types/entity-platform.d.ts.map +0 -1
  101. package/src/components/features/cards-strip.tsx +0 -587
  102. package/src/stories/CardsStrip.stories.tsx +0 -101
  103. package/src/types/entity-platform.ts +0 -21
  104. /package/dist/{chunk-ZB5HSWX4.js.map → chunk-2J6AOHJI.js.map} +0 -0
  105. /package/dist/{chunk-NN73KMIK.js.map → chunk-5J3LR56Z.js.map} +0 -0
  106. /package/dist/{chunk-WHJEU7V7.js.map → chunk-A7IGCQUD.js.map} +0 -0
  107. /package/dist/{chunk-PFF6Q6N5.js.map → chunk-AGTM2H7T.js.map} +0 -0
  108. /package/dist/{chunk-PJ2YFSMY.js.map → chunk-AQJ5S6RS.js.map} +0 -0
  109. /package/dist/{chunk-TWLPG5IQ.js.map → chunk-DXIDOGF4.js.map} +0 -0
  110. /package/dist/{chunk-YJE3HL6Q.js.map → chunk-FVFPGGDA.js.map} +0 -0
  111. /package/dist/{chunk-TTEAYVLT.js.map → chunk-G6CNEEZ3.js.map} +0 -0
  112. /package/dist/{chunk-NJPMHDFG.js.map → chunk-GAD4OYKN.js.map} +0 -0
  113. /package/dist/{chunk-45Z6B6CO.js.map → chunk-SSZL4QYX.js.map} +0 -0
  114. /package/dist/{chunk-MPN3MV5H.js.map → chunk-WAKXAMRC.js.map} +0 -0
@@ -5,27 +5,36 @@
5
5
  * "Hero Section" node 4033-90364). Replaces the old aspect-ratio grid
6
6
  * (`<VideoBitesDisplay>` is now a deprecated shim over this).
7
7
  *
8
- * Thin wrapper over the shared <CardsStrip> engine (render-prop mode) — the
9
- * marquee/chevron/seam/measure implementation and its state-model doc live in
10
- * `cards-strip.tsx`. This file keeps only the bite-specific concerns:
11
- *
12
- * - publish filtering + newest-first sort (`video-bites-shared`)
13
- * - Mux/Supabase origin warmup (`useVideoWarmup`)
8
+ * One horizontal strip of uniform-height cards:
9
+ * - continuous marquee auto-scroll (rAF over native `scrollLeft` so the
10
+ * position stays scrubbable for chevrons + mid-track pause; the CSS
11
+ * keyframe engine in `quick-action-marquee.tsx` can't do either — we
12
+ * reuse ONLY its WCAG clone pattern: track rendered twice, clone copy
13
+ * `aria-hidden` with no focusable descendants)
14
+ * - hover = bottom-docked detail overlay + muted chromeless autoplay
15
+ * preview + marquee pause
14
16
  * - profile / description-slot / navigation injection via props
15
- * - <VideoBiteCard> hover overlay + muted chromeless autoplay preview;
16
- * the `<Video>` Mux SSoT is the only player primitive used
17
+ * - the `<Video>` Mux SSoT is the only player primitive used
17
18
  *
18
19
  * Perf contract: each card renders the REAL player (first frame + center
19
20
  * play control — every card looks and behaves like any other video surface)
20
- * while the card is within ~500px of the viewport, via the engine's shared
21
- * TWO-WAY IntersectionObserver mount gate (`ctx.mounted`/`ctx.rootRef`):
22
- * players mount as cards approach and UNMOUNT again once the marquee carries
23
- * them far off-screen. Live player count is therefore bounded by the visible
24
- * strip width (+margin), not by list length or clone count. Playback itself
25
- * starts only on hover (`<Video playOnHover>` — sound at 50%, muted fallback).
21
+ * while the card is within ~500px of the viewport, via a TWO-WAY
22
+ * IntersectionObserver: players mount as cards approach and UNMOUNT again
23
+ * once the marquee carries them far off-screen. Live player count is
24
+ * therefore bounded by the visible strip width (+margin), not by list
25
+ * length or clone count. Playback itself starts only on hover
26
+ * (`<Video playOnHover>` — sound at 50%, muted fallback).
27
+ *
28
+ * Marquee state model (explicit — do not "simplify" into one timer):
29
+ * the rAF advances only when the pause-reason set {cardHovered, offViewport,
30
+ * tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
31
+ * userScrollSuppressUntil)`. cardHovered means the pointer is over a CARD
32
+ * (incl. its overlay) — leaving the card resumes the marquee immediately. Chevron clicks and manual wheel/touch each set
33
+ * their own suppress-until timestamp; the player leave-grace is a separate
34
+ * concern that never touches marquee state.
26
35
  */
27
36
 
28
- import React, { useMemo, useRef, useState, useEffect } from 'react';
37
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
29
38
  import { cn } from '../../utils/cn';
30
39
  import { Video } from './video';
31
40
  import { useVideoWarmup } from './use-video-warmup';
@@ -34,10 +43,12 @@ import type { VideoTeaserWithRatio } from './video-ratio-tabs';
34
43
  import {
35
44
  DEFAULT_VIDEO_BITES_TITLE,
36
45
  sortBitesByCreatedAtDesc,
46
+ toStripProfile,
37
47
  type VideoBiteStripProfile,
38
48
  } from './video-bites-shared';
39
- import { CardsStrip, STRIP_CELL_MAX_WIDTH } from './cards-strip';
40
49
  import { UserDisplay } from '../user-display';
50
+ import { SECTION_HEADING_CLASS } from '../layout/page-heading';
51
+ import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
41
52
  import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
42
53
 
43
54
  // NOTE: the title constant / profile adapter / sort comparator live in the
@@ -75,7 +86,9 @@ export interface VideoBitesStripProps {
75
86
  headerSlot?: React.ReactNode;
76
87
  /** Marquee auto-scroll. Auto-disabled on no-overflow and prefers-reduced-motion. */
77
88
  autoScroll?: boolean;
78
- /** Marquee speed in px/s. Default 60 (see cards-strip.tsx). */
89
+ /** Marquee speed in px/s. Default 60 — marquee libraries (e.g. GSAP marquee)
90
+ * default around 100px/s; 60 keeps drift lively while cards stay easy to
91
+ * hover-target. 60px/s ≈ 1px per 60Hz frame, the smoothest integer step. */
79
92
  autoScrollSpeed?: number;
80
93
  /** Pause the marquee while a CARD is hovered (resumes as soon as the
81
94
  * pointer leaves the card — strip whitespace/heading never pauses). */
@@ -90,6 +103,12 @@ export interface VideoBitesStripProps {
90
103
  className?: string;
91
104
  }
92
105
 
106
+ // Gap between cards (px) — keep in sync with the track's `gap-4`.
107
+ const TRACK_GAP_PX = 16;
108
+ // Marquee suppression windows after manual interaction.
109
+ const CHEVRON_SUPPRESS_MS = 4000;
110
+ const USER_SCROLL_SUPPRESS_MS = 3000;
111
+
93
112
  // =============================================================================
94
113
  // Component
95
114
  // =============================================================================
@@ -116,45 +135,348 @@ export function VideoBitesStrip({
116
135
  return filtered.sort(sortBitesByCreatedAtDesc);
117
136
  }, [bites, filterPublished]);
118
137
 
138
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
139
+ const scrollerRef = useRef<HTMLDivElement | null>(null);
140
+ const trackRef = useRef<HTMLDivElement | null>(null);
141
+
142
+ // ---- environment ----------------------------------------------------------
143
+ const [reducedMotion, setReducedMotion] = useState(false);
144
+ const [isTouch, setIsTouch] = useState(false);
145
+ const [isMobile, setIsMobile] = useState(false);
146
+ useEffect(() => {
147
+ const subs: Array<() => void> = [];
148
+ const watch = (query: string, set: (v: boolean) => void) => {
149
+ const mq = window.matchMedia(query);
150
+ set(mq.matches);
151
+ const onChange = (e: MediaQueryListEvent) => set(e.matches);
152
+ mq.addEventListener('change', onChange);
153
+ subs.push(() => mq.removeEventListener('change', onChange));
154
+ };
155
+ watch('(prefers-reduced-motion: reduce)', setReducedMotion);
156
+ watch('(hover: none)', setIsTouch);
157
+ watch('(max-width: 767px)', setIsMobile);
158
+ return () => subs.forEach(fn => fn());
159
+ }, []);
160
+
161
+ // ---- overflow measurement ---------------------------------------------------
162
+ // `overflows` gates chevrons; `marqueeActive` additionally gates cloning + rAF.
163
+ const [overflows, setOverflows] = useState(false);
164
+ const singleCopyWidthRef = useRef(0);
165
+ const marqueeActive = autoScroll && !reducedMotion && overflows && items.length > 0;
166
+
167
+ const measure = useCallback(() => {
168
+ const scroller = scrollerRef.current;
169
+ const track = trackRef.current;
170
+ if (!scroller || !track) return;
171
+ // When clones are rendered the track is 2× one copy (+ one joining gap).
172
+ const copies = track.dataset.copies === '2' ? 2 : 1;
173
+ const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
174
+ singleCopyWidthRef.current = singleCopy;
175
+ setOverflows(singleCopy > scroller.clientWidth + 1);
176
+ }, []);
177
+
178
+ useEffect(() => {
179
+ measure();
180
+ const scroller = scrollerRef.current;
181
+ const track = trackRef.current;
182
+ if (!scroller || !track || typeof ResizeObserver === 'undefined') return;
183
+ const ro = new ResizeObserver(measure);
184
+ ro.observe(scroller);
185
+ ro.observe(track);
186
+ return () => ro.disconnect();
187
+ }, [measure, items.length, marqueeActive, isMobile]);
188
+
189
+ // ---- pause-reason set + suppress timestamps ---------------------------------
190
+ // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
191
+ const nearViewportRef = useRef(true);
192
+ const chevronSuppressUntilRef = useRef(0);
193
+ const userScrollSuppressUntilRef = useRef(0);
194
+
195
+ // Two-way viewport gate (unlike the fire-once `useNearViewport`): the rAF
196
+ // should stop again when the strip scrolls far off-screen.
197
+ useEffect(() => {
198
+ const el = wrapperRef.current;
199
+ if (!el || typeof IntersectionObserver === 'undefined') return;
200
+ const io = new IntersectionObserver(
201
+ entries => { nearViewportRef.current = entries[0]?.isIntersecting ?? true; },
202
+ { rootMargin: '200px' },
203
+ );
204
+ io.observe(el);
205
+ return () => io.disconnect();
206
+ }, []);
207
+
208
+ // ---- marquee rAF engine ------------------------------------------------------
209
+ // Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
210
+ // scrollLeft back each frame returns a rounded value, so sub-pixel increments
211
+ // (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
212
+ // ran measurably slower than the configured speed because of it.
213
+ //
214
+ // Chevron GLIDE also runs through this engine (glideRemainingRef): browser
215
+ // `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
216
+ // seam warp mid-animation made it lunge a full copy-width to catch up, warp
217
+ // again, and oscillate — rapid chevron clicks left every card flickering.
218
+ // The rAF tween consumes a signed remaining-distance instead, wrapping
219
+ // modulo the copy width each frame, so warps are invisible to it.
220
+ const marqueePosRef = useRef(0);
221
+ const glideRemainingRef = useRef(0);
222
+ useEffect(() => {
223
+ if (!marqueeActive) return;
224
+ const scroller = scrollerRef.current;
225
+ if (!scroller) return;
226
+ let raf = 0;
227
+ let last = performance.now();
228
+ marqueePosRef.current = scroller.scrollLeft;
229
+ const wrap = (pos: number) => {
230
+ const half = singleCopyWidthRef.current;
231
+ if (half <= 0) return pos;
232
+ while (pos >= half) pos -= half;
233
+ while (pos < 0) pos += half;
234
+ return pos;
235
+ };
236
+ const tick = (now: number) => {
237
+ const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
238
+ last = now;
239
+ const paused =
240
+ (pauseOnHover && activeKeyRef.current !== null) ||
241
+ !nearViewportRef.current ||
242
+ document.visibilityState === 'hidden' ||
243
+ now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
244
+ const glide = glideRemainingRef.current;
245
+ if (glide !== 0) {
246
+ // Ease-out toward the chevron target; runs even while "paused"
247
+ // (the chevron click itself sets the suppress window).
248
+ if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
249
+ marqueePosRef.current = scroller.scrollLeft;
250
+ }
251
+ const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
252
+ const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
253
+ glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
254
+ marqueePosRef.current = wrap(marqueePosRef.current + step);
255
+ scroller.scrollLeft = marqueePosRef.current;
256
+ } else if (!paused) {
257
+ // Resync after external movement (user scroll, seam warp).
258
+ if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
259
+ marqueePosRef.current = scroller.scrollLeft;
260
+ }
261
+ marqueePosRef.current = wrap(marqueePosRef.current + autoScrollSpeed * dt);
262
+ scroller.scrollLeft = marqueePosRef.current;
263
+ } else {
264
+ marqueePosRef.current = scroller.scrollLeft;
265
+ }
266
+ raf = requestAnimationFrame(tick);
267
+ };
268
+ raf = requestAnimationFrame(tick);
269
+ return () => cancelAnimationFrame(raf);
270
+ }, [marqueeActive, autoScrollSpeed, pauseOnHover]);
271
+
272
+ // ---- hover / overlay state -----------------------------------------------------
273
+ // activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
274
+ // a clone near the wrap seam works too). It only drives OVERLAY visibility —
275
+ // the player is mounted per-card via near-viewport gating and plays on hover
276
+ // through <Video playOnHover> (normal controls, no chrome fork).
277
+ const [activeKey, setActiveKey] = useState<string | null>(null);
278
+ // Ref mirror for the rAF loop: the marquee pauses ONLY while a CARD is
279
+ // hovered (activeKey set) — hovering strip whitespace/heading does not
280
+ // pause, and leaving a card resumes immediately.
281
+ const activeKeyRef = useRef<string | null>(null);
282
+ const activate = useCallback((key: string) => {
283
+ activeKeyRef.current = key;
284
+ setActiveKey(key);
285
+ }, []);
286
+ const deactivate = useCallback((key: string) => {
287
+ setActiveKey(current => {
288
+ const next = current === key ? null : current;
289
+ activeKeyRef.current = next;
290
+ return next;
291
+ });
292
+ }, []);
293
+
119
294
  // Preconnect Mux/Supabase origins once so first hover starts fast.
120
295
  const warmup = useVideoWarmup<HTMLDivElement>({ videoUrl: items[0]?.url || null });
121
296
 
297
+ // ---- shared player-mount gate (by ITEM index, across both copies) -----------
298
+ // A card and its clone show identical content half a copy-width apart. If
299
+ // each card gated its own player, a seam warp instantly swapped the visible
300
+ // copy for cards whose players were NOT mounted yet — every wrap crossing
301
+ // flashed placeholders. Mounting by INDEX (near in EITHER copy → both copies
302
+ // mount) makes the warp pixel-identical. Live players stay bounded: ~2× the
303
+ // visible strip width.
304
+ const [mountedIdx, setMountedIdx] = useState<ReadonlySet<number>>(() => new Set());
305
+ const cardObserverRef = useRef<IntersectionObserver | null>(null);
306
+ const cardElIdxRef = useRef(new Map<Element, number>());
307
+ const cardElNearRef = useRef(new Map<Element, boolean>());
308
+ useEffect(() => {
309
+ if (typeof IntersectionObserver === 'undefined') return;
310
+ const io = new IntersectionObserver(entries => {
311
+ for (const e of entries) cardElNearRef.current.set(e.target, e.isIntersecting);
312
+ const next = new Set<number>();
313
+ cardElNearRef.current.forEach((near, el) => {
314
+ if (!near) return;
315
+ const idx = cardElIdxRef.current.get(el);
316
+ if (idx !== undefined) next.add(idx);
317
+ });
318
+ setMountedIdx(prev => {
319
+ if (prev.size === next.size) {
320
+ let same = true;
321
+ next.forEach(i => { if (!prev.has(i)) same = false; });
322
+ if (same) return prev;
323
+ }
324
+ return next;
325
+ });
326
+ }, { rootMargin: '500px' });
327
+ cardObserverRef.current = io;
328
+ cardElIdxRef.current.forEach((_idx, el) => io.observe(el));
329
+ return () => { io.disconnect(); cardObserverRef.current = null; };
330
+ }, []);
331
+ // Stable per-index ref callbacks (a fresh closure per render would detach/
332
+ // re-attach the observer every render). React 19 ref-cleanup unregisters.
333
+ const cardRefFnsRef = useRef(new Map<number, (el: HTMLDivElement | null) => (() => void) | undefined>());
334
+ const getCardRef = useCallback((idx: number) => {
335
+ let fn = cardRefFnsRef.current.get(idx);
336
+ if (!fn) {
337
+ fn = (el: HTMLDivElement | null) => {
338
+ if (!el) return undefined;
339
+ cardElIdxRef.current.set(el, idx);
340
+ cardObserverRef.current?.observe(el);
341
+ return () => {
342
+ cardElIdxRef.current.delete(el);
343
+ cardElNearRef.current.delete(el);
344
+ cardObserverRef.current?.unobserve(el);
345
+ };
346
+ };
347
+ cardRefFnsRef.current.set(idx, fn);
348
+ }
349
+ return fn;
350
+ }, []);
351
+
352
+ // ---- manual navigation -----------------------------------------------------------
353
+ const scrollByCard = useCallback((dir: 1 | -1) => {
354
+ const scroller = scrollerRef.current;
355
+ const track = trackRef.current;
356
+ if (!scroller || !track) return;
357
+ const firstCard = track.firstElementChild as HTMLElement | null;
358
+ const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX;
359
+ chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS;
360
+ if (marqueeActiveRef.current) {
361
+ // Wrap-aware glide via the rAF engine (see engine comment) — clicks
362
+ // accumulate distance instead of racing browser smooth-scroll targets.
363
+ glideRemainingRef.current += dir * step;
364
+ } else {
365
+ // No clones/seam without the marquee — native smooth scroll is safe.
366
+ scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
367
+ }
368
+ }, []);
369
+
370
+ const onUserScrollIntent = useCallback(() => {
371
+ userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
372
+ }, []);
373
+
374
+ // Never-ending strip: warp across the clone seam on EVERY scroll (manual
375
+ // wheel/drag/chevron included — the rAF only wraps while the marquee runs,
376
+ // so without this a user scrolling during a pause/suppress window hits the
377
+ // physical track edges). Both copies render identical content, so a ±half
378
+ // jump is pixel-invisible. Backward warp only when arriving AT the left
379
+ // edge from the right (never on initial rest at 0).
380
+ const lastScrollLeftRef = useRef(0);
381
+ const marqueeActiveRef = useRef(false);
382
+ marqueeActiveRef.current = marqueeActive;
383
+ const onSeamWarp = useCallback(() => {
384
+ const scroller = scrollerRef.current;
385
+ if (!scroller || !marqueeActiveRef.current) return;
386
+ const half = singleCopyWidthRef.current;
387
+ if (half <= 0) return;
388
+ let sl = scroller.scrollLeft;
389
+ if (sl >= half) {
390
+ sl -= half;
391
+ scroller.scrollLeft = sl;
392
+ marqueePosRef.current = sl;
393
+ } else if (sl <= 0 && lastScrollLeftRef.current > 0.5) {
394
+ sl += half;
395
+ scroller.scrollLeft = sl;
396
+ marqueePosRef.current = sl;
397
+ }
398
+ lastScrollLeftRef.current = sl;
399
+ }, []);
400
+
401
+ if (items.length === 0) return null;
402
+
403
+ const cardHeight = isMobile ? cardHeightMobile : cardHeightDesktop;
404
+ const copies = marqueeActive ? 2 : 1;
405
+
122
406
  return (
123
- <CardsStrip
124
- items={items}
125
- itemKey={b => b.url}
126
- title={title}
127
- showTitle={showTitle}
128
- headerSlot={headerSlot}
129
- autoScroll={autoScroll}
130
- autoScrollSpeed={autoScrollSpeed}
131
- pauseOnHover={pauseOnHover}
132
- showChevrons={showChevrons}
133
- prevLabel="Previous videos"
134
- nextLabel="Next videos"
135
- rootRef={warmup.ref}
136
- className={className}
137
- renderCard={(bite, ctx) => (
138
- <VideoBiteCard
139
- bite={bite}
140
- index={ctx.index}
141
- // MUST pass ctx.cardKey — the card's own fallback `${url}__${index}`
142
- // lacks the copyIndex, so clone hover activation would never match.
143
- cardKey={ctx.cardKey}
144
- isClone={ctx.isClone}
145
- isTouch={ctx.isTouch}
146
- height={ctx.isMobile ? cardHeightMobile : cardHeightDesktop}
147
- active={ctx.active}
148
- onActivate={ctx.onActivate}
149
- onDeactivate={ctx.onDeactivate}
150
- playerMounted={ctx.mounted}
151
- rootRef={ctx.rootRef}
152
- profile={bite.profile ?? profile ?? null}
153
- sectionHref={href}
154
- onBiteNavigate={onBiteNavigate}
155
- />
407
+ <div
408
+ ref={node => { wrapperRef.current = node; warmup.ref(node); }}
409
+ className={cn('flex flex-col gap-6 w-full min-w-0', className)}
410
+ >
411
+ {showTitle && (
412
+ <h2 className={`${SECTION_HEADING_CLASS} break-words`}>{title}</h2>
156
413
  )}
157
- />
414
+ {headerSlot}
415
+
416
+ <div className="relative">
417
+ <div
418
+ ref={scrollerRef}
419
+ className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
420
+ onWheel={onUserScrollIntent}
421
+ onTouchStart={onUserScrollIntent}
422
+ onPointerDown={onUserScrollIntent}
423
+ onScroll={onSeamWarp}
424
+ >
425
+ <div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
426
+ {Array.from({ length: copies }, (_, copyIndex) =>
427
+ items.map((bite, i) => {
428
+ const key = `${bite.url}__${copyIndex}__${i}`;
429
+ const isClone = copyIndex === 1;
430
+ return (
431
+ // Clone copy: aria-hidden + no focusable descendants (WCAG —
432
+ // same rule as quick-action-marquee). Clones stay pointer-
433
+ // interactive (briefly visible near the wrap seam).
434
+ <VideoBiteCard
435
+ key={key}
436
+ bite={bite}
437
+ index={i}
438
+ cardKey={key}
439
+ isClone={isClone}
440
+ isTouch={isTouch}
441
+ height={cardHeight}
442
+ active={activeKey === key}
443
+ onActivate={activate}
444
+ onDeactivate={deactivate}
445
+ playerMounted={mountedIdx.has(i)}
446
+ rootRef={getCardRef(i)}
447
+ profile={bite.profile ?? profile ?? null}
448
+ sectionHref={href}
449
+ onBiteNavigate={onBiteNavigate}
450
+ />
451
+ );
452
+ }),
453
+ )}
454
+ </div>
455
+ </div>
456
+
457
+ {showChevrons && overflows && (
458
+ <>
459
+ {/* Solid Figma button-icon look (bg #212121 = ods-card, border soft-grey). */}
460
+ <button
461
+ type="button"
462
+ aria-label="Previous videos"
463
+ onClick={() => scrollByCard(-1)}
464
+ className="absolute left-2 top-1/2 -translate-y-1/2 z-10 p-3 rounded-md bg-ods-card border border-ods-border text-ods-text-primary hover:border-ods-accent transition-colors"
465
+ >
466
+ <Chevron02LeftIcon className="w-6 h-6" />
467
+ </button>
468
+ <button
469
+ type="button"
470
+ aria-label="Next videos"
471
+ onClick={() => scrollByCard(1)}
472
+ className="absolute right-2 top-1/2 -translate-y-1/2 z-10 p-3 rounded-md bg-ods-card border border-ods-border text-ods-text-primary hover:border-ods-accent transition-colors"
473
+ >
474
+ <Chevron02RightIcon className="w-6 h-6" />
475
+ </button>
476
+ </>
477
+ )}
478
+ </div>
479
+ </div>
158
480
  );
159
481
  }
160
482
 
@@ -340,7 +662,7 @@ export function VideoBiteCard({
340
662
  className="relative w-full"
341
663
  // Strip mode: fixed height + aspect drive the width (capped at 90vw,
342
664
  // same as the old single-box layout). Editor mode: width-driven.
343
- style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth: STRIP_CELL_MAX_WIDTH } : {}) }}
665
+ style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth: '90vw' } : {}) }}
344
666
  >
345
667
  {showPlayer ? (
346
668
  // Clones: `inert` (not just the wrapper's aria-hidden) — the player's
@@ -396,7 +718,7 @@ export function VideoBiteCard({
396
718
  height !== undefined ? 'shrink-0' : 'w-full',
397
719
  className,
398
720
  )}
399
- style={height !== undefined ? { maxWidth: STRIP_CELL_MAX_WIDTH } : undefined}
721
+ style={height !== undefined ? { maxWidth: '90vw' } : undefined}
400
722
  onPointerEnter={handlePointerEnter}
401
723
  onPointerLeave={handlePointerLeave}
402
724
  onClick={handleClick}
package/src/types/blog.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { UserProfile } from "./user"
2
- import type { EntityPlatformAssoc } from "./entity-platform"
3
2
 
4
3
  // Core Platform Types (dynamically fetched from database via API)
5
4
  export type Platform = string;
@@ -59,10 +58,14 @@ export interface BlogPost {
59
58
  blog_media_assets?: BlogMediaAsset[];
60
59
  }
61
60
 
62
- /** Hydrated `entity_platforms` association row — the shared junction shape
63
- * (see entity-platform.ts). The legacy `{blog_post_id, platform}` junction
64
- * declaration is gone; runtime always produced the hydrated shape. */
65
- export type BlogPostPlatform = EntityPlatformAssoc;
61
+ export interface BlogPostPlatform {
62
+ id: string;
63
+ blog_post_id: number;
64
+ platform: Platform;
65
+ is_featured: boolean;
66
+ featured_order?: number;
67
+ created_at: string;
68
+ }
66
69
 
67
70
  export interface BlogPostCategory {
68
71
  post_id: number;
@@ -149,8 +152,7 @@ export interface CreateBlogPostData {
149
152
  og_image_url?: string;
150
153
  published_at?: string | null;
151
154
  platforms: Platform[];
152
- /** Platform UUID (Platform is a string alias; kept as `string` for clarity — setEntityPlatforms compares against platform_id). */
153
- featured_platform?: string;
155
+ featured_platform?: Platform;
154
156
  categories: number[];
155
157
  tags: number[];
156
158
  }
@@ -168,8 +170,7 @@ export interface UpdateBlogPostData {
168
170
  og_image_url?: string;
169
171
  published_at?: string | null;
170
172
  platforms?: Platform[];
171
- /** Platform UUID (Platform is a string alias; kept as `string` for clarity — setEntityPlatforms compares against platform_id). */
172
- featured_platform?: string;
173
+ featured_platform?: Platform;
173
174
  categories?: number[];
174
175
  tags?: number[];
175
176
  }
@@ -2,7 +2,6 @@
2
2
  // Following the blog.ts pattern for consistency
3
3
 
4
4
  import type { PlatformRecord } from './platform'
5
- import type { EntityPlatformAssoc } from './entity-platform'
6
5
  import type { BlogTag, TagAssoc } from './blog'
7
6
  import type { MSP } from './stack'
8
7
  import type { UserProfile } from './user'
@@ -73,7 +72,11 @@ export interface CaseStudy {
73
72
  msp?: MSP // Populated MSP data via user.msp_id - includes industry, company_size, testimonials, etc.
74
73
  author?: UserProfile // Article author
75
74
  customer_interview?: CustomerInterview // Linked customer interview
76
- case_study_platforms?: EntityPlatformAssoc[]
75
+ case_study_platforms?: Array<{
76
+ platform_id: string
77
+ is_featured: boolean
78
+ featured_order: number | null
79
+ }>
77
80
  // Flat unified tag-association shape (hydrated by entity-tag-utils).
78
81
  case_study_tags?: TagAssoc[]
79
82
  }
@@ -2,7 +2,6 @@
2
2
  // Following the case-study.ts pattern for consistency
3
3
 
4
4
  import type { PlatformRecord } from './platform'
5
- import type { EntityPlatformAssoc } from './entity-platform'
6
5
  import type { MSP } from './stack'
7
6
  import type { UserProfile } from './user'
8
7
  import type { CaseStudy } from './case-study'
@@ -106,7 +105,11 @@ export interface CustomerInterview {
106
105
  msp?: MSP // Customer's MSP data via user.msp_id
107
106
  author?: UserProfile // Interview author
108
107
  case_study?: CaseStudy // Linked case study
109
- customer_interview_platforms?: EntityPlatformAssoc[]
108
+ customer_interview_platforms?: Array<{
109
+ platform_id: string
110
+ is_featured: boolean
111
+ featured_order: number | null
112
+ }>
110
113
  }
111
114
 
112
115
  export interface CreateCustomerInterviewData {
@@ -22,7 +22,6 @@ export * from './auth'
22
22
  export * from './blog'
23
23
  export * from './entity-author'
24
24
  export * from './case-study'
25
- export * from './entity-platform'
26
25
  export * from './customer-interview'
27
26
  export * from './customer-interview-ai.types'
28
27
  export * from './luma'