@flamingo-stack/openframe-frontend-core 0.0.407-snapshot.20260711133234 → 0.0.407

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 (116) hide show
  1. package/dist/{chunk-E4J5KKHH.js → chunk-43H2WOAW.js} +2 -2
  2. package/dist/{chunk-5HOII3WB.js → chunk-4UZH3MT2.js} +2 -2
  3. package/dist/{chunk-RL344I7V.cjs → chunk-5M5W6XDY.cjs} +7 -7
  4. package/dist/{chunk-RL344I7V.cjs.map → chunk-5M5W6XDY.cjs.map} +1 -1
  5. package/dist/{chunk-KRLBVBYU.cjs → chunk-74FWQI73.cjs} +11 -11
  6. package/dist/{chunk-KRLBVBYU.cjs.map → chunk-74FWQI73.cjs.map} +1 -1
  7. package/dist/{chunk-ZF5O3KUQ.js → chunk-7XVFSGQA.js} +2 -2
  8. package/dist/{chunk-IVM47EGI.js → chunk-CVYSW33D.js} +109 -149
  9. package/dist/chunk-CVYSW33D.js.map +1 -0
  10. package/dist/{chunk-NEMUCNCG.cjs → chunk-DOBNDDNE.cjs} +59 -59
  11. package/dist/{chunk-NEMUCNCG.cjs.map → chunk-DOBNDDNE.cjs.map} +1 -1
  12. package/dist/{chunk-SOALFYU6.cjs → chunk-GGDM5J4B.cjs} +109 -149
  13. package/dist/chunk-GGDM5J4B.cjs.map +1 -0
  14. package/dist/{chunk-O5W26SCL.js → chunk-GJSLKATJ.js} +2 -2
  15. package/dist/{chunk-ZMFQWMM7.js → chunk-GMZL4T7C.js} +2 -2
  16. package/dist/{chunk-LQWYXIKB.js → chunk-H66L2265.js} +4 -4
  17. package/dist/{chunk-KZPVN7AZ.js → chunk-HB63TQQO.js} +2 -2
  18. package/dist/{chunk-VUW4G6SU.js → chunk-HJIRG7R6.js} +2 -2
  19. package/dist/{chunk-J6M2G6TD.js → chunk-KLBEIJVZ.js} +4 -4
  20. package/dist/{chunk-YRKPMYI3.cjs → chunk-LC6RRLFA.cjs} +15 -15
  21. package/dist/{chunk-YRKPMYI3.cjs.map → chunk-LC6RRLFA.cjs.map} +1 -1
  22. package/dist/{chunk-F4KE5AZO.cjs → chunk-LPC3C7MG.cjs} +37 -37
  23. package/dist/{chunk-F4KE5AZO.cjs.map → chunk-LPC3C7MG.cjs.map} +1 -1
  24. package/dist/{chunk-IK5AR6QG.js → chunk-NSF7JUXF.js} +4 -4
  25. package/dist/{chunk-T6ZUBXPA.cjs → chunk-PRKMOF2U.cjs} +9 -9
  26. package/dist/{chunk-T6ZUBXPA.cjs.map → chunk-PRKMOF2U.cjs.map} +1 -1
  27. package/dist/{chunk-RPGFIUN3.cjs → chunk-Q2MC3CBI.cjs} +26 -26
  28. package/dist/{chunk-RPGFIUN3.cjs.map → chunk-Q2MC3CBI.cjs.map} +1 -1
  29. package/dist/{chunk-KPGKNUJF.cjs → chunk-R4MXLJUS.cjs} +31 -31
  30. package/dist/{chunk-KPGKNUJF.cjs.map → chunk-R4MXLJUS.cjs.map} +1 -1
  31. package/dist/{chunk-A3RW7M6B.cjs → chunk-SY3QS2E5.cjs} +26 -26
  32. package/dist/{chunk-A3RW7M6B.cjs.map → chunk-SY3QS2E5.cjs.map} +1 -1
  33. package/dist/{chunk-V3EZQWHQ.cjs → chunk-WCPUJA3Y.cjs} +5 -5
  34. package/dist/{chunk-V3EZQWHQ.cjs.map → chunk-WCPUJA3Y.cjs.map} +1 -1
  35. package/dist/{chunk-L6OPVKVR.js → chunk-XFP43YKG.js} +4 -4
  36. package/dist/{chunk-WGOATV3P.cjs → chunk-YX7C5JD7.cjs} +62 -62
  37. package/dist/{chunk-WGOATV3P.cjs.map → chunk-YX7C5JD7.cjs.map} +1 -1
  38. package/dist/components/case-studies/index.cjs +8 -9
  39. package/dist/components/case-studies/index.cjs.map +1 -1
  40. package/dist/components/case-studies/index.js +2 -3
  41. package/dist/components/case-studies/index.js.map +1 -1
  42. package/dist/components/chat/index.cjs +2 -3
  43. package/dist/components/chat/index.cjs.map +1 -1
  44. package/dist/components/chat/index.js +1 -2
  45. package/dist/components/chat/quick-action-chip.d.ts +2 -11
  46. package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
  47. package/dist/components/contact/index.cjs +3 -4
  48. package/dist/components/contact/index.cjs.map +1 -1
  49. package/dist/components/contact/index.js +2 -3
  50. package/dist/components/docs/index.cjs +6 -6
  51. package/dist/components/docs/index.js +5 -5
  52. package/dist/components/embeds/index.cjs +3 -4
  53. package/dist/components/embeds/index.cjs.map +1 -1
  54. package/dist/components/embeds/index.js +2 -3
  55. package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
  56. package/dist/components/faq/index.cjs +3 -4
  57. package/dist/components/faq/index.cjs.map +1 -1
  58. package/dist/components/faq/index.js +2 -3
  59. package/dist/components/features/index.cjs +2 -3
  60. package/dist/components/features/index.cjs.map +1 -1
  61. package/dist/components/features/index.js +1 -2
  62. package/dist/components/help-center-pages/index.cjs +21 -21
  63. package/dist/components/help-center-pages/index.js +12 -12
  64. package/dist/components/index.cjs +126 -126
  65. package/dist/components/index.cjs.map +1 -1
  66. package/dist/components/index.d.ts +0 -1
  67. package/dist/components/index.d.ts.map +1 -1
  68. package/dist/components/index.js +10 -10
  69. package/dist/components/index.js.map +1 -1
  70. package/dist/components/media-carousel.d.ts +4 -28
  71. package/dist/components/media-carousel.d.ts.map +1 -1
  72. package/dist/components/navigation/index.cjs +2 -3
  73. package/dist/components/navigation/index.cjs.map +1 -1
  74. package/dist/components/navigation/index.js +1 -2
  75. package/dist/components/onboarding-guides/index.cjs +6 -6
  76. package/dist/components/onboarding-guides/index.js +5 -5
  77. package/dist/components/related-content/index.cjs +3 -4
  78. package/dist/components/related-content/index.cjs.map +1 -1
  79. package/dist/components/related-content/index.js +2 -3
  80. package/dist/components/tickets/index.cjs +6 -6
  81. package/dist/components/tickets/index.js +5 -5
  82. package/dist/components/ui/index.cjs +2 -3
  83. package/dist/components/ui/index.cjs.map +1 -1
  84. package/dist/components/ui/index.js +1 -2
  85. package/dist/components/ui/tag.d.ts +2 -3
  86. package/dist/components/ui/tag.d.ts.map +1 -1
  87. package/dist/index.cjs +2 -3
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.js +1 -2
  90. package/dist/utils/media-carousel-utils-stub.d.ts +1 -3
  91. package/dist/utils/media-carousel-utils-stub.d.ts.map +1 -1
  92. package/dist/utils/media-carousel-utils.d.ts +10 -0
  93. package/dist/utils/media-carousel-utils.d.ts.map +1 -0
  94. package/package.json +1 -1
  95. package/src/components/chat/quick-action-chip.tsx +4 -18
  96. package/src/components/embeds/reddit-embed-client.tsx +3 -0
  97. package/src/components/index.ts +0 -5
  98. package/src/components/media-carousel.tsx +149 -195
  99. package/src/components/ui/tag.tsx +3 -21
  100. package/src/styles/ods-design-tokens.css +0 -2
  101. package/src/utils/.media-carousel-utils.md +31 -0
  102. package/src/utils/media-carousel-utils-stub.ts +1 -3
  103. package/src/utils/media-carousel-utils.ts +22 -0
  104. package/dist/chunk-IVM47EGI.js.map +0 -1
  105. package/dist/chunk-SOALFYU6.cjs.map +0 -1
  106. /package/dist/{chunk-E4J5KKHH.js.map → chunk-43H2WOAW.js.map} +0 -0
  107. /package/dist/{chunk-5HOII3WB.js.map → chunk-4UZH3MT2.js.map} +0 -0
  108. /package/dist/{chunk-ZF5O3KUQ.js.map → chunk-7XVFSGQA.js.map} +0 -0
  109. /package/dist/{chunk-O5W26SCL.js.map → chunk-GJSLKATJ.js.map} +0 -0
  110. /package/dist/{chunk-ZMFQWMM7.js.map → chunk-GMZL4T7C.js.map} +0 -0
  111. /package/dist/{chunk-LQWYXIKB.js.map → chunk-H66L2265.js.map} +0 -0
  112. /package/dist/{chunk-KZPVN7AZ.js.map → chunk-HB63TQQO.js.map} +0 -0
  113. /package/dist/{chunk-VUW4G6SU.js.map → chunk-HJIRG7R6.js.map} +0 -0
  114. /package/dist/{chunk-J6M2G6TD.js.map → chunk-KLBEIJVZ.js.map} +0 -0
  115. /package/dist/{chunk-IK5AR6QG.js.map → chunk-NSF7JUXF.js.map} +0 -0
  116. /package/dist/{chunk-L6OPVKVR.js.map → chunk-XFP43YKG.js.map} +0 -0
@@ -1,12 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useState, useRef, useEffect, memo, useCallback, type KeyboardEvent, type TouchEvent } from 'react';
3
+ import { useState, useRef, useEffect, memo, useCallback } from 'react';
4
4
  import { cn } from "../utils/cn";
5
5
  import { MediaItem } from '../utils/media-carousel-utils-stub';
6
- import { Image, Link } from '../embed-shims';
7
- import { Button } from './ui/button';
8
- import { Video } from './features/video';
9
- import { ImageOffIcon } from './icons-v2-generated/audio-and-visual/image-off-icon';
6
+ import { Video, extractYouTubeId } from './features/video';
7
+ import { VideoPlayBadge } from './features/video-center-badge';
10
8
 
11
9
  // Navigation icons
12
10
  const ChevronLeftIcon = () => (
@@ -21,79 +19,58 @@ const ChevronRightIcon = () => (
21
19
  </svg>
22
20
  );
23
21
 
24
- /**
25
- * THE media carousel (Figma 4033:90570) — the single slide viewer for the
26
- * whole codebase: OpenFrame homepage categories, openmsp vendor screenshots,
27
- * who-am-i profiles, reddit embeds. One viewport + dot pagination; no
28
- * thumbnail rail, no slide counter (both removed with the 2026-07 redesign).
29
- *
30
- * - Slides render through the SSoT `<Video>` (MuxPlayer / lite-youtube
31
- * facade) for `video`/`youtube` items and a broken-image-resilient
32
- * `<Image>` shim for `image` items.
33
- * - Dots: 24px hit target, 8px dot, active = flamingo pink (per design).
34
- * - Hover-revealed prev/next arrows (`showArrows`), keyboard arrows, and
35
- * touch swipe all preserved from the previous generation.
36
- */
37
- export interface MediaCarouselProps {
22
+ interface MediaCarouselProps {
38
23
  media: MediaItem[];
39
24
  className?: string;
40
25
  /** Use aspect ratio instead of height classes for stable dimensions */
41
26
  aspectRatio?: '16/9' | '4/3' | '3/2' | '1/1';
42
- /** Hover-revealed prev/next arrows (default true; dots always render). */
43
- showArrows?: boolean;
27
+ showThumbnails?: boolean;
28
+ autoPlay?: boolean;
44
29
  /** How content should fit within the container */
45
30
  objectFit?: 'contain' | 'cover';
46
- /** When true, the first slide's image or YouTube poster uses `priority` / eager load (e.g. vendor detail LCP). */
47
- posterPriority?: boolean;
48
- /** Optional per-host rewrite for image URLs (e.g. the hub proxies plain
49
- * `http://` sources to avoid mixed content). Applied to image slide srcs
50
- * and derived thumbnails only — video/youtube URLs go to `<Video>` raw. */
51
- transformImageSrc?: (url: string) => string;
52
- /** Always reserve the dot-row height (24px) even when there is nothing to
53
- * paginate — for surfaces that swap media sets in place (the OpenFrame
54
- * categories showcase), so 1-slide vs N-slide sets never shift layout.
55
- * Default false: standalone carousels (vendor page) collapse the row. */
56
- reserveDotRow?: boolean;
57
31
  }
58
32
 
59
- export const MediaCarousel = memo(function MediaCarousel({
60
- media,
33
+ export const MediaCarousel = memo(function MediaCarousel({
34
+ media,
61
35
  className,
62
36
  aspectRatio = "16/9",
63
- showArrows = true,
64
- objectFit = 'contain',
65
- posterPriority = false,
66
- transformImageSrc,
67
- reserveDotRow = false,
37
+ showThumbnails = true,
38
+ autoPlay = false,
39
+ objectFit = 'contain'
68
40
  }: MediaCarouselProps) {
69
41
  const [currentIndex, setCurrentIndex] = useState(0);
70
42
  const [touchStart, setTouchStart] = useState<number | null>(null);
71
43
  const [touchEnd, setTouchEnd] = useState<number | null>(null);
72
- /** Resource URLs that failed to load (e.g. 404, blocked CDN) */
73
- const [brokenResourceUrls, setBrokenResourceUrls] = useState<Record<string, true>>({});
74
- /** The src whose <Image> finished loading — keyed by URL (not a shared
75
- * boolean) so a late onLoad from a previous slide can never reveal the
76
- * current image prematurely; slide changes need no reset effect. */
77
- const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null);
78
44
  const carouselRef = useRef<HTMLDivElement>(null);
79
-
80
- const onImageError = useCallback((originalUrl: string) => {
81
- const key = originalUrl.trim() || '__missing_src__';
82
- setBrokenResourceUrls((b) => (b[key] ? b : { ...b, [key]: true }));
83
- }, []);
84
-
85
- // Clamp `currentIndex` whenever `media` shrinks (e.g. an admin removes a
86
- // slide while the user is on the last one) so the dot active state +
87
- // `currentItem` lookup stay in sync.
45
+ const thumbnailsRef = useRef<HTMLDivElement>(null);
46
+
47
+ // Clamp `currentIndex` whenever `media` shrinks (e.g. an admin removes
48
+ // a slide while the user is on the last one) so the thumbnail active
49
+ // state + `currentItem` lookup stay in sync. Without this, the
50
+ // `media[currentIndex] || media[0]` fallback renders the first slide
51
+ // but every thumbnail's `isActive = index === currentIndex` reads
52
+ // false visually nothing is selected.
88
53
  useEffect(() => {
89
54
  if (currentIndex >= media.length && media.length > 0) {
90
55
  setCurrentIndex(media.length - 1);
91
56
  }
92
57
  }, [media.length, currentIndex]);
93
58
 
59
+ // Early return if no media provided
60
+ if (!media || media.length === 0) {
61
+ return null;
62
+ }
63
+
94
64
  const currentItem = media[currentIndex] || media[0];
95
65
 
96
- // Navigation `<Video>` owns its own play/pause lifecycle.
66
+ // Additional safety check
67
+ if (!currentItem) {
68
+ return null;
69
+ }
70
+
71
+ // Navigation functions — `<Video>` (MuxPlayer/YT facade) owns its own
72
+ // play/pause lifecycle, so the previous bare-`<video>`-element pause
73
+ // logic in nextSlide/prevSlide/selectSlide is no longer needed.
97
74
  const nextSlide = useCallback(() => {
98
75
  setCurrentIndex((prev) => (prev + 1) % media.length);
99
76
  }, [media.length]);
@@ -103,13 +80,14 @@ export const MediaCarousel = memo(function MediaCarousel({
103
80
  }, [media.length]);
104
81
 
105
82
  const selectSlide = useCallback((index: number) => {
106
- setCurrentIndex((prev) => (index === prev ? prev : index));
107
- }, []);
83
+ if (index === currentIndex) return;
84
+ setCurrentIndex(index);
85
+ }, [currentIndex]);
108
86
 
109
87
  // Keyboard navigation - only when carousel is focused
110
- const handleKeyDown = useCallback((e: KeyboardEvent) => {
88
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
111
89
  if (media.length <= 1) return;
112
-
90
+
113
91
  if (e.key === 'ArrowLeft') {
114
92
  e.preventDefault();
115
93
  prevSlide();
@@ -119,33 +97,21 @@ export const MediaCarousel = memo(function MediaCarousel({
119
97
  }
120
98
  }, [nextSlide, prevSlide, media.length]);
121
99
 
122
- // Early return if no media provided
123
- if (!media || media.length === 0) {
124
- return null;
125
- }
126
-
127
- // Additional safety check
128
- if (!currentItem) {
129
- return null;
130
- }
131
-
132
100
  // Touch/swipe handling
133
101
  const minSwipeDistance = 50;
134
102
 
135
- const onTouchStart = (e: TouchEvent) => {
103
+ const onTouchStart = (e: React.TouchEvent) => {
136
104
  setTouchEnd(null);
137
105
  setTouchStart(e.targetTouches[0].clientX);
138
106
  };
139
107
 
140
- const onTouchMove = (e: TouchEvent) => {
108
+ const onTouchMove = (e: React.TouchEvent) => {
141
109
  setTouchEnd(e.targetTouches[0].clientX);
142
110
  };
143
111
 
144
112
  const onTouchEnd = () => {
145
- // Explicit null checks — a swipe starting/ending at the viewport edge has
146
- // clientX === 0, which a truthiness check would wrongly discard.
147
- if (touchStart === null || touchEnd === null) return;
148
-
113
+ if (!touchStart || !touchEnd) return;
114
+
149
115
  const distance = touchStart - touchEnd;
150
116
  const isLeftSwipe = distance > minSwipeDistance;
151
117
  const isRightSwipe = distance < -minSwipeDistance;
@@ -158,92 +124,52 @@ export const MediaCarousel = memo(function MediaCarousel({
158
124
  }
159
125
  };
160
126
 
161
- const displayImageSrc = (url: string): string =>
162
- transformImageSrc ? transformImageSrc(url) : url;
127
+ // Render YouTube embed via the SSoT `<Video>` — same lite-youtube
128
+ // facade everywhere, no carousel-local fork.
129
+ const renderYouTubeEmbed = (item: MediaItem, index: number) => (
130
+ <Video
131
+ kind="youtube"
132
+ url={item.src}
133
+ title={item.alt || `Video ${index + 1}`}
134
+ layout="fill"
135
+ priority={index === currentIndex}
136
+ />
137
+ );
163
138
 
164
- // Render any playable slide via the SSoT `<Video>` — its default `auto`
165
- // routing owns the youtube-vs-file decision (lite-youtube facade vs
166
- // MuxPlayer), so the carousel keeps NO url-detection fork of its own.
139
+ // Render video via the SSoT `<Video>` — MuxPlayer handles both HLS
140
+ // and plain MP4, so the carousel no longer needs its own bare
141
+ // `<video>` element + custom play overlay + 60 LOC of error handling.
167
142
  const renderVideo = (item: MediaItem, index: number) => (
168
143
  <Video
169
- key={`video-${index}-${item.src}`}
170
144
  url={item.src}
171
- poster={item.poster ? displayImageSrc(item.poster) : undefined}
172
- title={item.alt || `Video ${index + 1}`}
145
+ poster={item.poster}
173
146
  muted
174
147
  layout="fill"
175
- priority={Boolean(posterPriority && index === 0)}
176
- className="w-full h-full bg-black"
148
+ priority={index === currentIndex}
177
149
  />
178
150
  );
179
151
 
180
- const renderImageUnavailable = (failedUrl: string) => (
181
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-ods-card px-6 text-center">
182
- <ImageOffIcon className="size-8 text-ods-text-secondary" />
183
- <p className="text-ods-text-secondary text-sm max-w-md">
184
- This image is no longer available at the original URL.
185
- </p>
186
- <Link
187
- href={failedUrl}
188
- target="_blank"
189
- rel="noopener noreferrer"
190
- className="text-ods-accent text-sm underline break-all"
191
- >
192
- Open link
193
- </Link>
152
+ // Render image
153
+ const renderImage = (item: MediaItem, index: number) => (
154
+ <div className="absolute inset-0 bg-black">
155
+ <img
156
+ src={item.src}
157
+ alt={item.alt || `Media ${index + 1}`}
158
+ className={`w-full h-full object-${objectFit}`}
159
+ loading="lazy"
160
+ onError={(e) => {
161
+ const target = e.target as HTMLImageElement;
162
+ target.style.display = 'none';
163
+ }}
164
+ />
194
165
  </div>
195
166
  );
196
167
 
197
- // Render image skeleton until onLoad; keep Image opacity-0 until then to
198
- // avoid the browser broken-image icon flash.
199
- const renderImage = (item: MediaItem, index: number) => {
200
- const rawSrc = typeof item.src === 'string' ? item.src.trim() : '';
201
- if (!rawSrc) {
202
- return (
203
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-ods-card px-6 text-center">
204
- <p className="text-ods-text-secondary text-sm max-w-md">No image URL for this slide.</p>
205
- </div>
206
- );
207
- }
208
- if (brokenResourceUrls[rawSrc]) {
209
- return renderImageUnavailable(rawSrc);
210
- }
211
- const mainImagePriority = Boolean(posterPriority && index === 0);
212
- const imageLoaded = loadedImageSrc === rawSrc;
213
- return (
214
- <div className="absolute inset-0 bg-ods-bg">
215
- {!imageLoaded && (
216
- <div
217
- className="absolute inset-0 z-[1] bg-ods-card/90 animate-pulse"
218
- aria-hidden
219
- />
220
- )}
221
- <Image
222
- src={displayImageSrc(rawSrc)}
223
- alt={item.alt || `Media ${index + 1}`}
224
- className={cn(
225
- `w-full h-full object-${objectFit} relative z-[2]`,
226
- !imageLoaded && 'opacity-0',
227
- imageLoaded && 'opacity-100 transition-opacity duration-200'
228
- )}
229
- priority={mainImagePriority}
230
- loading={mainImagePriority ? 'eager' : 'lazy'}
231
- sizes="(max-width: 1279px) 100vw, 1428px"
232
- width={1428}
233
- height={802}
234
- unoptimized
235
- onLoad={() => setLoadedImageSrc(rawSrc)}
236
- onError={() => onImageError(rawSrc)}
237
- />
238
- </div>
239
- );
240
- };
241
-
242
- // Render main media item — video AND youtube both route through the SSoT
243
- // `<Video>` (its `auto` kind owns the detection).
168
+ // Render main media item
244
169
  const renderMainMedia = (item: MediaItem, index: number) => {
245
170
  switch (item.type) {
246
171
  case 'youtube':
172
+ return renderYouTubeEmbed(item, index);
247
173
  case 'video':
248
174
  return renderVideo(item, index);
249
175
  case 'image':
@@ -252,6 +178,55 @@ export const MediaCarousel = memo(function MediaCarousel({
252
178
  }
253
179
  };
254
180
 
181
+ // Render thumbnail
182
+ const renderThumbnail = (item: MediaItem, index: number) => {
183
+ const isActive = index === currentIndex;
184
+
185
+ let thumbnailSrc = item.src;
186
+ if (item.type === 'youtube') {
187
+ // Use the SSoT `extractYouTubeId` (strict URL parsing, ReDoS-safe)
188
+ // rather than a local regex so YouTube id extraction has a single
189
+ // implementation across the lib.
190
+ const videoId = extractYouTubeId(item.src);
191
+ thumbnailSrc = videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : item.src;
192
+ } else if (item.type === 'video' && item.poster) {
193
+ thumbnailSrc = item.poster;
194
+ }
195
+
196
+ return (
197
+ <button
198
+ key={index}
199
+ onClick={() => selectSlide(index)}
200
+ className={cn(
201
+ "relative flex-shrink-0 overflow-hidden transition-all duration-200",
202
+ "w-20 h-20 md:w-24 md:h-24 rounded-lg border-2",
203
+ isActive
204
+ ? "border-[#FFC008] ring-2 ring-[#FFC008]/20"
205
+ : "border-ods-border hover:border-[#888888]"
206
+ )}
207
+ >
208
+ <img
209
+ src={thumbnailSrc}
210
+ alt={item.alt || `Thumbnail ${index + 1}`}
211
+ className="w-full h-full object-cover"
212
+ loading="lazy"
213
+ />
214
+
215
+ {/* Play icon overlay for videos */}
216
+ {(item.type === 'video' || item.type === 'youtube') && (
217
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
218
+ <VideoPlayBadge size="sm" />
219
+ </div>
220
+ )}
221
+
222
+ {/* Active indicator */}
223
+ {isActive && (
224
+ <div className="absolute bottom-1 right-1 w-2 h-2 bg-[#FFC008] rounded-full" />
225
+ )}
226
+ </button>
227
+ );
228
+ };
229
+
255
230
  // Get CSS for aspect ratio
256
231
  const getAspectRatioClass = () => {
257
232
  switch (aspectRatio) {
@@ -268,14 +243,12 @@ export const MediaCarousel = memo(function MediaCarousel({
268
243
  };
269
244
 
270
245
  return (
271
- <div className={cn("flex flex-col items-center gap-[var(--spacing-system-l)]", className)}>
272
- {/* Main Display Area with Fixed Aspect Ratio — Figma media frame:
273
- rounded-md, ods border, semantic elevation token. */}
274
- <div
246
+ <div className={cn("space-y-4", className)}>
247
+ {/* Main Display Area with Fixed Aspect Ratio */}
248
+ <div
275
249
  ref={carouselRef}
276
250
  className={cn(
277
- "relative bg-ods-bg border border-ods-border rounded-md overflow-hidden group w-full",
278
- "[box-shadow:var(--shadow-media-frame)]",
251
+ "relative bg-ods-bg border border-ods-border rounded-2xl overflow-hidden group w-full",
279
252
  getAspectRatioClass()
280
253
  )}
281
254
  onTouchStart={onTouchStart}
@@ -289,64 +262,45 @@ export const MediaCarousel = memo(function MediaCarousel({
289
262
  {/* Media content */}
290
263
  {renderMainMedia(currentItem, currentIndex)}
291
264
 
292
- {/* Navigation Arrows — hover-revealed, only with multiple items.
293
- The common <Button> (transparent) with overlay positioning. */}
294
- {showArrows && media.length > 1 && (
265
+ {/* Navigation Arrows - only show if multiple items */}
266
+ {media.length > 1 && (
295
267
  <>
296
- <Button
297
- variant="transparent"
268
+ <button
298
269
  onClick={prevSlide}
299
- className="absolute left-3 top-1/2 h-auto md:h-auto -translate-y-1/2 rounded-full bg-black/50 p-2 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 hover:bg-black/70 z-10"
270
+ className="absolute left-3 top-1/2 transform -translate-y-1/2 bg-black/50 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-black/70 z-10"
300
271
  aria-label="Previous media"
301
272
  >
302
273
  <ChevronLeftIcon />
303
- </Button>
274
+ </button>
304
275
 
305
- <Button
306
- variant="transparent"
276
+ <button
307
277
  onClick={nextSlide}
308
- className="absolute right-3 top-1/2 h-auto md:h-auto -translate-y-1/2 rounded-full bg-black/50 p-2 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 hover:bg-black/70 z-10"
278
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 bg-black/50 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-black/70 z-10"
309
279
  aria-label="Next media"
310
280
  >
311
281
  <ChevronRightIcon />
312
- </Button>
282
+ </button>
283
+
284
+ {/* Media Counter */}
285
+ <div className="absolute top-3 right-3 bg-black/70 text-white px-3 py-1 rounded-lg text-sm font-medium">
286
+ {currentIndex + 1} / {media.length}
287
+ </div>
313
288
  </>
314
289
  )}
315
290
  </div>
316
291
 
317
- {/* Dot pagination (Figma) 24px hit target, 8px dot, active pink.
318
- Plain grouped buttons (NOT tablist: no roving tabIndex/arrow-key
319
- model here — aria-current marks the active slide instead). */}
320
- {(media.length > 1 || reserveDotRow) && (
321
- <div className="flex h-6 items-center gap-[var(--spacing-system-xs)]" role="group" aria-label="Choose slide">
322
- {media.length > 1 && media.map((item, index) => {
323
- const isActive = index === currentIndex;
324
- return (
325
- <Button
326
- key={index}
327
- variant="transparent"
328
- aria-current={isActive ? 'true' : undefined}
329
- aria-label={`Go to slide ${index + 1}`}
330
- onClick={() => selectSlide(index)}
331
- className="flex h-6 w-6 md:h-6 shrink-0 items-center justify-center rounded-full p-0"
332
- >
333
- <span
334
- className={cn(
335
- "size-2 rounded-full transition-colors duration-150",
336
- isActive
337
- ? "bg-[var(--ods-flamingo-pink-base)]"
338
- : "bg-[var(--ods-system-greys-soft-grey)] hover:bg-[var(--ods-system-greys-soft-grey-hover)]"
339
- )}
340
- />
341
- </Button>
342
- );
343
- })}
292
+ {/* Thumbnail Navigation - only show if multiple items and showThumbnails is true */}
293
+ {media.length > 1 && showThumbnails && (
294
+ <div className="w-full">
295
+ <div
296
+ ref={thumbnailsRef}
297
+ className="flex gap-2 overflow-x-auto scrollbar-none py-2"
298
+ style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
299
+ >
300
+ {media.map((item, index) => renderThumbnail(item, index))}
301
+ </div>
344
302
  </div>
345
303
  )}
346
304
  </div>
347
305
  );
348
- });
349
-
350
- // The carousel's item shape is the lib-wide SSOT — re-exported here so hub
351
- // code can `import type { MediaItem } from '.../components'`.
352
- export type { MediaItem };
306
+ });
@@ -7,18 +7,11 @@ import { cn } from "../../utils/cn"
7
7
 
8
8
  const tagVariants = cva(
9
9
  [
10
- "inline-flex items-center justify-center rounded-md",
10
+ "text-h5 h-8 inline-flex items-center justify-center gap-[var(--spacing-system-xxs)] p-[var(--spacing-system-xsf)] rounded-md",
11
11
  "transition-colors duration-150",
12
12
  ],
13
13
  {
14
14
  variants: {
15
- // Chip scale. `default` is the classic 32px mono-uppercase tag; `large`
16
- // is the Figma "Feature Item" chip (48px, h3 bold body label) used by
17
- // chip groups acting as tabs (OpenFrame categories).
18
- size: {
19
- default: "text-h5 h-8 gap-[var(--spacing-system-xxs)] p-[var(--spacing-system-xsf)]",
20
- large: "text-h3 font-bold h-12 gap-[var(--spacing-system-xs)] p-[var(--spacing-system-s)]",
21
- },
22
15
  variant: {
23
16
  primary: [
24
17
  "bg-[var(--ods-open-yellow-base)] text-[var(--ods-system-greys-black)]",
@@ -49,15 +42,6 @@ const tagVariants = cva(
49
42
  "bg-[var(--ods-system-greys-soft-grey)] text-[var(--ods-system-greys-grey)]",
50
43
  "hover:bg-[var(--ods-system-greys-soft-grey-hover)] active:bg-[var(--ods-system-greys-soft-grey-action)]",
51
44
  ],
52
- // Active/selected chip state (Figma "Feature Item" active): pink
53
- // border + pink-secondary fill. A dedicated variant (not appended
54
- // utilities) so its own hover rules win — the outline variant's
55
- // hover:bg/hover:border would otherwise repaint an active chip grey.
56
- selected: [
57
- "bg-[var(--ods-flamingo-pink-secondary)] text-[var(--ods-system-greys-white)] border border-[var(--ods-flamingo-pink-base)]",
58
- "hover:bg-[var(--ods-flamingo-pink-secondary-hover)] hover:border-[var(--ods-flamingo-pink-base)]",
59
- "active:bg-[var(--ods-flamingo-pink-secondary-action)]",
60
- ],
61
45
  // Matches the EntityTagBadges / StatusBadge tag skin (ods-card + ods-border,
62
46
  // mono uppercase) so the tag-editor chips render identically to the public
63
47
  // tag badges. Used for FilterChipData variant 'tag' (see search-input).
@@ -69,7 +53,6 @@ const tagVariants = cva(
69
53
  },
70
54
  defaultVariants: {
71
55
  variant: "primary",
72
- size: "default",
73
56
  },
74
57
  }
75
58
  )
@@ -103,7 +86,6 @@ export interface TagProps
103
86
  function Tag({
104
87
  label,
105
88
  variant,
106
- size,
107
89
  icon,
108
90
  onClose,
109
91
  className,
@@ -115,7 +97,7 @@ function Tag({
115
97
  return (
116
98
  <Comp
117
99
  className={cn(
118
- tagVariants({ variant, size }),
100
+ tagVariants({ variant }),
119
101
  disabled && disabledTagClasses,
120
102
  className
121
103
  )}
@@ -123,7 +105,7 @@ function Tag({
123
105
  {...props}
124
106
  >
125
107
  {icon && (
126
- <span className={cn("flex items-center justify-center shrink-0", size === 'large' ? 'size-6' : 'size-5')}>
108
+ <span className="flex items-center justify-center size-5 shrink-0">
127
109
  {icon}
128
110
  </span>
129
111
  )}
@@ -59,8 +59,6 @@
59
59
  --shadow-card-hover: var(--shadow-md);
60
60
  --shadow-modal: var(--shadow-xl);
61
61
  --shadow-dropdown: var(--shadow-lg);
62
- /* Figma media-frame elevation (Flamingo website carousels/panels, 4033:90570) */
63
- --shadow-media-frame: 0px 24px 48px 0px rgba(0, 0, 0, 0.24);
64
62
  --shadow-tooltip: var(--shadow-base);
65
63
  --shadow-focus: 0 0 0 3px rgba(255, 192, 8, 0.3);
66
64
 
@@ -0,0 +1,31 @@
1
+ <!-- source-hash: 4cdb3166112b0dff8dcc314964dcbba3 -->
2
+ Utility functions for processing and handling media items in carousel components, providing standardized interfaces for images, videos, and embedded content.
3
+
4
+ ## Key Components
5
+
6
+ - **`MediaItem`** - Interface defining the structure for carousel media items with id, type, URL, and optional metadata
7
+ - **`processMediaItems()`** - Transforms raw media data arrays into standardized MediaItem objects with fallback values
8
+ - **`getMediaThumbnail()`** - Retrieves thumbnail URL for media items (currently returns the main URL)
9
+
10
+ ## Usage Example
11
+
12
+ ```typescript
13
+ import { MediaItem, processMediaItems, getMediaThumbnail } from './media-carousel-utils';
14
+
15
+ // Process raw media data
16
+ const rawItems = [
17
+ { id: '1', type: 'image', url: '/photo1.jpg', alt: 'Photo 1' },
18
+ { url: '/video1.mp4', type: 'video', caption: 'Demo video' },
19
+ { url: '/embed-content' } // Missing fields will use defaults
20
+ ];
21
+
22
+ const mediaItems: MediaItem[] = processMediaItems(rawItems);
23
+
24
+ // Get thumbnail for display
25
+ mediaItems.forEach(item => {
26
+ const thumbnail = getMediaThumbnail(item);
27
+ console.log(`${item.type}: ${thumbnail}`);
28
+ });
29
+ ```
30
+
31
+ The utility handles missing properties gracefully, using index-based IDs and 'image' as the default type when not specified.
@@ -1,7 +1,5 @@
1
1
  export interface MediaItem {
2
- /** Optional stable key. The carousel keys slides by index; converters may
3
- * omit this (hub vendor/profile/category converters all do). */
4
- id?: string;
2
+ id: string;
5
3
  type: 'image' | 'video' | 'youtube';
6
4
  src: string;
7
5
  alt?: string;
@@ -0,0 +1,22 @@
1
+ // Media carousel utilities stub
2
+ export interface MediaItem {
3
+ id: string;
4
+ type: 'image' | 'video' | 'embed';
5
+ url: string;
6
+ alt?: string;
7
+ caption?: string;
8
+ }
9
+
10
+ export function processMediaItems(items: any[]): MediaItem[] {
11
+ return items.map((item, index) => ({
12
+ id: item.id || index.toString(),
13
+ type: item.type || 'image',
14
+ url: item.url || '',
15
+ alt: item.alt || '',
16
+ caption: item.caption || ''
17
+ }));
18
+ }
19
+
20
+ export function getMediaThumbnail(item: MediaItem): string {
21
+ return item.url;
22
+ }