@flamingo-stack/openframe-frontend-core 0.0.393 → 0.0.394-snapshot.20260709183206
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-QKQ5HQD4.cjs → chunk-3VJPMRNT.cjs} +7 -7
- package/dist/{chunk-QKQ5HQD4.cjs.map → chunk-3VJPMRNT.cjs.map} +1 -1
- package/dist/{chunk-Z2325LAV.js → chunk-45Z6B6CO.js} +4 -4
- package/dist/{chunk-3RKVI6PM.cjs → chunk-4YVUL4T7.cjs} +12 -12
- package/dist/{chunk-3RKVI6PM.cjs.map → chunk-4YVUL4T7.cjs.map} +1 -1
- package/dist/{chunk-YPDKH5UW.cjs → chunk-5U72NXGG.cjs} +9 -9
- package/dist/{chunk-YPDKH5UW.cjs.map → chunk-5U72NXGG.cjs.map} +1 -1
- package/dist/{chunk-SA2MGKME.cjs → chunk-BEPZ5YAN.cjs} +59 -59
- package/dist/{chunk-SA2MGKME.cjs.map → chunk-BEPZ5YAN.cjs.map} +1 -1
- package/dist/{chunk-DKHZGHNR.cjs → chunk-J2UDV3FH.cjs} +62 -62
- package/dist/{chunk-DKHZGHNR.cjs.map → chunk-J2UDV3FH.cjs.map} +1 -1
- package/dist/{chunk-2FOQU3NG.cjs → chunk-M7WTWBCY.cjs} +37 -37
- package/dist/{chunk-2FOQU3NG.cjs.map → chunk-M7WTWBCY.cjs.map} +1 -1
- package/dist/{chunk-NTTTGX4Z.js → chunk-MPN3MV5H.js} +4 -4
- package/dist/{chunk-DLIQSAB4.js → chunk-NJPMHDFG.js} +2 -2
- package/dist/{chunk-MOT2FIAF.js → chunk-NN73KMIK.js} +2 -2
- package/dist/{chunk-ZTBJ5LLB.cjs → chunk-OSOKWP5R.cjs} +31 -31
- package/dist/{chunk-ZTBJ5LLB.cjs.map → chunk-OSOKWP5R.cjs.map} +1 -1
- package/dist/{chunk-JG33QHBP.js → chunk-PFF6Q6N5.js} +2 -2
- package/dist/{chunk-OSGZVWBB.js → chunk-PJ2YFSMY.js} +2 -2
- package/dist/{chunk-DNPX4L7L.cjs → chunk-PNMX75W4.cjs} +1174 -1060
- package/dist/chunk-PNMX75W4.cjs.map +1 -0
- package/dist/{chunk-OXN4NGTC.cjs → chunk-RNWLVI3V.cjs} +26 -26
- package/dist/{chunk-OXN4NGTC.cjs.map → chunk-RNWLVI3V.cjs.map} +1 -1
- package/dist/{chunk-NKVCAWLP.js → chunk-RWBTSF2E.js} +5298 -5184
- package/dist/chunk-RWBTSF2E.js.map +1 -0
- package/dist/{chunk-IZYGLYQ4.cjs → chunk-T6FEE3R2.cjs} +5 -5
- package/dist/{chunk-IZYGLYQ4.cjs.map → chunk-T6FEE3R2.cjs.map} +1 -1
- package/dist/{chunk-VQNF6IV2.js → chunk-TTEAYVLT.js} +2 -2
- package/dist/{chunk-WQEHQ6LA.js → chunk-TWLPG5IQ.js} +2 -2
- package/dist/{chunk-H2MQRYTB.js → chunk-WHJEU7V7.js} +4 -4
- package/dist/{chunk-MTOMVI5S.cjs → chunk-Y4I4462K.cjs} +15 -15
- package/dist/{chunk-MTOMVI5S.cjs.map → chunk-Y4I4462K.cjs.map} +1 -1
- package/dist/{chunk-UDJ6TH7I.js → chunk-YJE3HL6Q.js} +4 -4
- package/dist/{chunk-QU2OUDMX.js → chunk-ZB5HSWX4.js} +2 -2
- package/dist/{chunk-EQUONLZW.cjs → chunk-ZCKVP4SU.cjs} +26 -26
- package/dist/{chunk-EQUONLZW.cjs.map → chunk-ZCKVP4SU.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/cards-strip.d.ts +114 -0
- package/dist/components/features/cards-strip.d.ts.map +1 -0
- package/dist/components/features/index.cjs +6 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +5 -1
- package/dist/components/features/video-bites-strip.d.ts +15 -26
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +124 -120
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -9
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/navigation/mobile-burger-menu.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +6 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -1
- package/dist/types/blog.d.ts +9 -10
- package/dist/types/blog.d.ts.map +1 -1
- package/dist/types/case-study.d.ts +2 -5
- package/dist/types/case-study.d.ts.map +1 -1
- package/dist/types/customer-interview.d.ts +2 -5
- package/dist/types/customer-interview.d.ts.map +1 -1
- package/dist/types/entity-platform.d.ts +22 -0
- package/dist/types/entity-platform.d.ts.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -2
- package/src/components/features/cards-strip.tsx +587 -0
- package/src/components/features/index.ts +3 -0
- package/src/components/features/video-bites-strip.tsx +53 -375
- package/src/components/navigation/mobile-burger-menu.tsx +11 -5
- package/src/stories/CardsStrip.stories.tsx +101 -0
- package/src/types/blog.ts +9 -10
- package/src/types/case-study.ts +2 -5
- package/src/types/customer-interview.ts +2 -5
- package/src/types/entity-platform.ts +21 -0
- package/src/types/index.ts +1 -0
- package/dist/chunk-DNPX4L7L.cjs.map +0 -1
- package/dist/chunk-NKVCAWLP.js.map +0 -1
- package/dist/components/carousel.d.ts +0 -19
- package/dist/components/carousel.d.ts.map +0 -1
- package/src/components/.carousel.md +0 -50
- package/src/components/carousel.tsx +0 -262
- /package/dist/{chunk-Z2325LAV.js.map → chunk-45Z6B6CO.js.map} +0 -0
- /package/dist/{chunk-NTTTGX4Z.js.map → chunk-MPN3MV5H.js.map} +0 -0
- /package/dist/{chunk-DLIQSAB4.js.map → chunk-NJPMHDFG.js.map} +0 -0
- /package/dist/{chunk-MOT2FIAF.js.map → chunk-NN73KMIK.js.map} +0 -0
- /package/dist/{chunk-JG33QHBP.js.map → chunk-PFF6Q6N5.js.map} +0 -0
- /package/dist/{chunk-OSGZVWBB.js.map → chunk-PJ2YFSMY.js.map} +0 -0
- /package/dist/{chunk-VQNF6IV2.js.map → chunk-TTEAYVLT.js.map} +0 -0
- /package/dist/{chunk-WQEHQ6LA.js.map → chunk-TWLPG5IQ.js.map} +0 -0
- /package/dist/{chunk-H2MQRYTB.js.map → chunk-WHJEU7V7.js.map} +0 -0
- /package/dist/{chunk-UDJ6TH7I.js.map → chunk-YJE3HL6Q.js.map} +0 -0
- /package/dist/{chunk-QU2OUDMX.js.map → chunk-ZB5HSWX4.js.map} +0 -0
|
@@ -52,10 +52,13 @@ export * from './tags-selector'
|
|
|
52
52
|
export * from './warning-block'
|
|
53
53
|
// Unified video primitives — single source of truth across all platforms.
|
|
54
54
|
// `<Video>` replaces the deleted `<VideoPlayer>` + `<YouTubeEmbed>` pair;
|
|
55
|
+
// `<CardsStrip>` is THE horizontal card-strip engine (render-prop mode for
|
|
56
|
+
// bites, organic `children` mode for any card component);
|
|
55
57
|
// `<EntityVideoSection>` + `<VideoBitesStrip>` are the public detail-page
|
|
56
58
|
// wrappers (`<VideoBitesDisplay>` is a deprecated shim over the strip);
|
|
57
59
|
// `video-ratio-tabs` keeps the aspect-ratio primitives (tabs/grid = admin-only);
|
|
58
60
|
// `video-bites-shared` is the SERVER-SAFE leaf (also its own published subpath).
|
|
61
|
+
export * from './cards-strip'
|
|
59
62
|
export * from './video'
|
|
60
63
|
export * from './video-ratio-tabs'
|
|
61
64
|
export * from './video-bites-shared'
|
|
@@ -5,36 +5,27 @@
|
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - hover = bottom-docked detail overlay + muted chromeless autoplay
|
|
15
|
-
* preview + marquee pause
|
|
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`)
|
|
16
14
|
* - profile / description-slot / navigation injection via props
|
|
17
|
-
* -
|
|
15
|
+
* - <VideoBiteCard> — hover overlay + muted chromeless autoplay preview;
|
|
16
|
+
* the `<Video>` Mux SSoT is the only player primitive used
|
|
18
17
|
*
|
|
19
18
|
* Perf contract: each card renders the REAL player (first frame + center
|
|
20
19
|
* play control — every card looks and behaves like any other video surface)
|
|
21
|
-
* while the card is within ~500px of the viewport, via
|
|
22
|
-
* IntersectionObserver
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* length or clone count. Playback itself
|
|
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.
|
|
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).
|
|
35
26
|
*/
|
|
36
27
|
|
|
37
|
-
import React, {
|
|
28
|
+
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
|
38
29
|
import { cn } from '../../utils/cn';
|
|
39
30
|
import { Video } from './video';
|
|
40
31
|
import { useVideoWarmup } from './use-video-warmup';
|
|
@@ -43,12 +34,10 @@ import type { VideoTeaserWithRatio } from './video-ratio-tabs';
|
|
|
43
34
|
import {
|
|
44
35
|
DEFAULT_VIDEO_BITES_TITLE,
|
|
45
36
|
sortBitesByCreatedAtDesc,
|
|
46
|
-
toStripProfile,
|
|
47
37
|
type VideoBiteStripProfile,
|
|
48
38
|
} from './video-bites-shared';
|
|
39
|
+
import { CardsStrip, STRIP_CELL_MAX_WIDTH } from './cards-strip';
|
|
49
40
|
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';
|
|
52
41
|
import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
|
|
53
42
|
|
|
54
43
|
// NOTE: the title constant / profile adapter / sort comparator live in the
|
|
@@ -86,9 +75,7 @@ export interface VideoBitesStripProps {
|
|
|
86
75
|
headerSlot?: React.ReactNode;
|
|
87
76
|
/** Marquee auto-scroll. Auto-disabled on no-overflow and prefers-reduced-motion. */
|
|
88
77
|
autoScroll?: boolean;
|
|
89
|
-
/** Marquee speed in px/s. Default 60
|
|
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. */
|
|
78
|
+
/** Marquee speed in px/s. Default 60 (see cards-strip.tsx). */
|
|
92
79
|
autoScrollSpeed?: number;
|
|
93
80
|
/** Pause the marquee while a CARD is hovered (resumes as soon as the
|
|
94
81
|
* pointer leaves the card — strip whitespace/heading never pauses). */
|
|
@@ -103,12 +90,6 @@ export interface VideoBitesStripProps {
|
|
|
103
90
|
className?: string;
|
|
104
91
|
}
|
|
105
92
|
|
|
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
|
-
|
|
112
93
|
// =============================================================================
|
|
113
94
|
// Component
|
|
114
95
|
// =============================================================================
|
|
@@ -135,348 +116,45 @@ export function VideoBitesStrip({
|
|
|
135
116
|
return filtered.sort(sortBitesByCreatedAtDesc);
|
|
136
117
|
}, [bites, filterPublished]);
|
|
137
118
|
|
|
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
|
-
|
|
294
119
|
// Preconnect Mux/Supabase origins once so first hover starts fast.
|
|
295
120
|
const warmup = useVideoWarmup<HTMLDivElement>({ videoUrl: items[0]?.url || null });
|
|
296
121
|
|
|
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
|
-
|
|
406
122
|
return (
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
{showTitle
|
|
412
|
-
|
|
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
|
+
/>
|
|
413
156
|
)}
|
|
414
|
-
|
|
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>
|
|
157
|
+
/>
|
|
480
158
|
);
|
|
481
159
|
}
|
|
482
160
|
|
|
@@ -662,7 +340,7 @@ export function VideoBiteCard({
|
|
|
662
340
|
className="relative w-full"
|
|
663
341
|
// Strip mode: fixed height + aspect drive the width (capped at 90vw,
|
|
664
342
|
// same as the old single-box layout). Editor mode: width-driven.
|
|
665
|
-
style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth:
|
|
343
|
+
style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth: STRIP_CELL_MAX_WIDTH } : {}) }}
|
|
666
344
|
>
|
|
667
345
|
{showPlayer ? (
|
|
668
346
|
// Clones: `inert` (not just the wrapper's aria-hidden) — the player's
|
|
@@ -718,7 +396,7 @@ export function VideoBiteCard({
|
|
|
718
396
|
height !== undefined ? 'shrink-0' : 'w-full',
|
|
719
397
|
className,
|
|
720
398
|
)}
|
|
721
|
-
style={height !== undefined ? { maxWidth:
|
|
399
|
+
style={height !== undefined ? { maxWidth: STRIP_CELL_MAX_WIDTH } : undefined}
|
|
722
400
|
onPointerEnter={handlePointerEnter}
|
|
723
401
|
onPointerLeave={handlePointerLeave}
|
|
724
402
|
onClick={handleClick}
|
|
@@ -155,10 +155,15 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
155
155
|
|
|
156
156
|
return (
|
|
157
157
|
<>
|
|
158
|
-
{/* Dim backdrop (no blur, unified with Drawer) - positioned below header
|
|
158
|
+
{/* Dim backdrop (no blur, unified with Drawer) - positioned below header.
|
|
159
|
+
`absolute` (not `fixed`) so it anchors to the AppLayout row it renders in
|
|
160
|
+
— the row that sits BELOW the optional `topBar` banner — rather than the
|
|
161
|
+
viewport. This keeps `top: HEADER_HEIGHT` measured from just under the
|
|
162
|
+
header even when a top banner pushes the header down. With no banner the
|
|
163
|
+
row fills the viewport, so this is identical to the previous behavior. */}
|
|
159
164
|
<div
|
|
160
165
|
className={cn(
|
|
161
|
-
"
|
|
166
|
+
"absolute inset-0 z-[100] md:hidden",
|
|
162
167
|
OVERLAY_BACKDROP_CLASS,
|
|
163
168
|
"transition-all duration-300",
|
|
164
169
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
@@ -168,10 +173,11 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
168
173
|
aria-hidden="true"
|
|
169
174
|
/>
|
|
170
175
|
|
|
171
|
-
{/* Menu Panel - positioned below header with slide-down animation
|
|
176
|
+
{/* Menu Panel - positioned below header with slide-down animation. `absolute`
|
|
177
|
+
for the same reason as the backdrop above (anchors below the topBar). */}
|
|
172
178
|
<div
|
|
173
179
|
className={cn(
|
|
174
|
-
"
|
|
180
|
+
"absolute left-0 right-0 z-[101] md:hidden",
|
|
175
181
|
"flex flex-col",
|
|
176
182
|
"bg-ods-bg border-b border-ods-border",
|
|
177
183
|
"transition-all duration-300 ease-out",
|
|
@@ -182,7 +188,7 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
182
188
|
)}
|
|
183
189
|
style={{
|
|
184
190
|
top: HEADER_HEIGHT,
|
|
185
|
-
maxHeight: `calc(
|
|
191
|
+
maxHeight: `calc(100% - ${HEADER_HEIGHT}px)`,
|
|
186
192
|
}}
|
|
187
193
|
role="dialog"
|
|
188
194
|
aria-modal="true"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
|
2
|
+
import { CardsStrip } from '../components/features/cards-strip'
|
|
3
|
+
import { BlogCard, CaseStudyCard, CustomerInterviewCard } from '../components/chat/entity-cards'
|
|
4
|
+
import { blogPostSummary, caseStudy, customerInterview } from './__fixtures__/chat-cards'
|
|
5
|
+
|
|
6
|
+
// All stories exercise the ORGANIC children mode: real entity cards passed as
|
|
7
|
+
// plain JSX children with zero strip-side registration. The render-prop mode
|
|
8
|
+
// is regression-tested by VideoBitesStrip.stories.tsx.
|
|
9
|
+
//
|
|
10
|
+
// Usage guidance: pass `autoScroll={false}` for chevron-style entity strips
|
|
11
|
+
// (Figma 3905:77388) — the engine default stays `true` for the bites
|
|
12
|
+
// marquee contract, so don't let the marquee-on default propagate silently.
|
|
13
|
+
|
|
14
|
+
const caseStudies = Array.from({ length: 7 }, (_, i) => ({
|
|
15
|
+
...caseStudy,
|
|
16
|
+
id: i + 1,
|
|
17
|
+
slug: `${caseStudy.slug}-${i}`,
|
|
18
|
+
title: `${caseStudy.title} (${i + 1})`,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const meta: Meta<typeof CardsStrip> = {
|
|
22
|
+
title: 'Features/CardsStrip',
|
|
23
|
+
component: CardsStrip,
|
|
24
|
+
parameters: { layout: 'fullscreen' },
|
|
25
|
+
decorators: [
|
|
26
|
+
Story => (
|
|
27
|
+
<div className="bg-ods-bg p-8 min-h-[560px]">
|
|
28
|
+
<Story />
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
export default meta
|
|
34
|
+
|
|
35
|
+
type Story = StoryObj<typeof CardsStrip>
|
|
36
|
+
|
|
37
|
+
/** Figma 3905:77388: chevron-nav strip of uniform 400px case-study cards —
|
|
38
|
+
* `autoScroll={false}`, chevrons appear only when content overflows. */
|
|
39
|
+
export const CaseStudiesChevrons: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<CardsStrip title="Case Studies" autoScroll={false}>
|
|
42
|
+
{caseStudies.map(study => (
|
|
43
|
+
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
44
|
+
))}
|
|
45
|
+
</CardsStrip>
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Any card type coexists with zero strip config — the managed cell's
|
|
50
|
+
* stretch keeps mixed card types row-aligned (verify heights match). */
|
|
51
|
+
export const MixedCardTypes: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<CardsStrip title="Latest Content" autoScroll={false}>
|
|
54
|
+
<CaseStudyCard key="cs" study={caseStudy} href={`/case-studies/${caseStudy.slug}`} />
|
|
55
|
+
<BlogCard key="blog" post={blogPostSummary} href={`/blog/${blogPostSummary.slug}`} />
|
|
56
|
+
<CustomerInterviewCard
|
|
57
|
+
key="interview"
|
|
58
|
+
interview={customerInterview}
|
|
59
|
+
href={`/interviews/${customerInterview.slug}`}
|
|
60
|
+
/>
|
|
61
|
+
<CaseStudyCard key="cs2" study={caseStudies[1]} href={`/case-studies/${caseStudies[1].slug}`} />
|
|
62
|
+
<BlogCard key="blog2" post={blogPostSummary} href={`/blog/${blogPostSummary.slug}-2`} />
|
|
63
|
+
</CardsStrip>
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Marquee opt-in (engine default). Accepted clone behaviors: the clone
|
|
68
|
+
* copy's inner cell is `aria-hidden` + `inert` (content pointer/focus-dead,
|
|
69
|
+
* links unclickable — WCAG aria-hidden-focus), while the OUTER cell still
|
|
70
|
+
* hover-pauses the marquee; keyboard focus on original cards pauses too. */
|
|
71
|
+
export const MarqueeOptIn: Story = {
|
|
72
|
+
render: () => (
|
|
73
|
+
<CardsStrip title="Case Studies">
|
|
74
|
+
{caseStudies.map(study => (
|
|
75
|
+
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
76
|
+
))}
|
|
77
|
+
</CardsStrip>
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Two cards — no overflow, so no clones, no marquee, no chevrons. */
|
|
82
|
+
export const TwoItemsNoOverflow: Story = {
|
|
83
|
+
render: () => (
|
|
84
|
+
<CardsStrip title="Case Studies" autoScroll={false}>
|
|
85
|
+
{caseStudies.slice(0, 2).map(study => (
|
|
86
|
+
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
87
|
+
))}
|
|
88
|
+
</CardsStrip>
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Narrow cells via cardWidthDesktop/Mobile overrides. */
|
|
93
|
+
export const CustomCellWidth: Story = {
|
|
94
|
+
render: () => (
|
|
95
|
+
<CardsStrip title="Compact Row" autoScroll={false} cardWidthDesktop={320} cardWidthMobile={280}>
|
|
96
|
+
{caseStudies.map(study => (
|
|
97
|
+
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
98
|
+
))}
|
|
99
|
+
</CardsStrip>
|
|
100
|
+
),
|
|
101
|
+
}
|