@flamingo-stack/openframe-frontend-core 0.0.407 → 0.0.408

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-4UZH3MT2.js → chunk-5HOII3WB.js} +2 -2
  2. package/dist/{chunk-SY3QS2E5.cjs → chunk-A3RW7M6B.cjs} +26 -26
  3. package/dist/{chunk-SY3QS2E5.cjs.map → chunk-A3RW7M6B.cjs.map} +1 -1
  4. package/dist/{chunk-43H2WOAW.js → chunk-E4J5KKHH.js} +2 -2
  5. package/dist/{chunk-LPC3C7MG.cjs → chunk-F4KE5AZO.cjs} +37 -37
  6. package/dist/{chunk-LPC3C7MG.cjs.map → chunk-F4KE5AZO.cjs.map} +1 -1
  7. package/dist/{chunk-NSF7JUXF.js → chunk-IK5AR6QG.js} +4 -4
  8. package/dist/{chunk-CVYSW33D.js → chunk-IVM47EGI.js} +149 -109
  9. package/dist/chunk-IVM47EGI.js.map +1 -0
  10. package/dist/{chunk-KLBEIJVZ.js → chunk-J6M2G6TD.js} +4 -4
  11. package/dist/{chunk-R4MXLJUS.cjs → chunk-KPGKNUJF.cjs} +31 -31
  12. package/dist/{chunk-R4MXLJUS.cjs.map → chunk-KPGKNUJF.cjs.map} +1 -1
  13. package/dist/{chunk-74FWQI73.cjs → chunk-KRLBVBYU.cjs} +11 -11
  14. package/dist/{chunk-74FWQI73.cjs.map → chunk-KRLBVBYU.cjs.map} +1 -1
  15. package/dist/{chunk-HB63TQQO.js → chunk-KZPVN7AZ.js} +2 -2
  16. package/dist/{chunk-XFP43YKG.js → chunk-L6OPVKVR.js} +4 -4
  17. package/dist/{chunk-H66L2265.js → chunk-LQWYXIKB.js} +4 -4
  18. package/dist/{chunk-DOBNDDNE.cjs → chunk-NEMUCNCG.cjs} +59 -59
  19. package/dist/{chunk-DOBNDDNE.cjs.map → chunk-NEMUCNCG.cjs.map} +1 -1
  20. package/dist/{chunk-GJSLKATJ.js → chunk-O5W26SCL.js} +2 -2
  21. package/dist/{chunk-5M5W6XDY.cjs → chunk-RL344I7V.cjs} +7 -7
  22. package/dist/{chunk-5M5W6XDY.cjs.map → chunk-RL344I7V.cjs.map} +1 -1
  23. package/dist/{chunk-Q2MC3CBI.cjs → chunk-RPGFIUN3.cjs} +26 -26
  24. package/dist/{chunk-Q2MC3CBI.cjs.map → chunk-RPGFIUN3.cjs.map} +1 -1
  25. package/dist/{chunk-GGDM5J4B.cjs → chunk-SOALFYU6.cjs} +149 -109
  26. package/dist/chunk-SOALFYU6.cjs.map +1 -0
  27. package/dist/{chunk-PRKMOF2U.cjs → chunk-T6ZUBXPA.cjs} +9 -9
  28. package/dist/{chunk-PRKMOF2U.cjs.map → chunk-T6ZUBXPA.cjs.map} +1 -1
  29. package/dist/{chunk-WCPUJA3Y.cjs → chunk-V3EZQWHQ.cjs} +5 -5
  30. package/dist/{chunk-WCPUJA3Y.cjs.map → chunk-V3EZQWHQ.cjs.map} +1 -1
  31. package/dist/{chunk-HJIRG7R6.js → chunk-VUW4G6SU.js} +2 -2
  32. package/dist/{chunk-YX7C5JD7.cjs → chunk-WGOATV3P.cjs} +62 -62
  33. package/dist/{chunk-YX7C5JD7.cjs.map → chunk-WGOATV3P.cjs.map} +1 -1
  34. package/dist/{chunk-LC6RRLFA.cjs → chunk-YRKPMYI3.cjs} +15 -15
  35. package/dist/{chunk-LC6RRLFA.cjs.map → chunk-YRKPMYI3.cjs.map} +1 -1
  36. package/dist/{chunk-7XVFSGQA.js → chunk-ZF5O3KUQ.js} +2 -2
  37. package/dist/{chunk-GMZL4T7C.js → chunk-ZMFQWMM7.js} +2 -2
  38. package/dist/components/case-studies/index.cjs +9 -8
  39. package/dist/components/case-studies/index.cjs.map +1 -1
  40. package/dist/components/case-studies/index.js +3 -2
  41. package/dist/components/case-studies/index.js.map +1 -1
  42. package/dist/components/chat/index.cjs +3 -2
  43. package/dist/components/chat/index.cjs.map +1 -1
  44. package/dist/components/chat/index.js +2 -1
  45. package/dist/components/chat/quick-action-chip.d.ts +11 -2
  46. package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
  47. package/dist/components/contact/index.cjs +4 -3
  48. package/dist/components/contact/index.cjs.map +1 -1
  49. package/dist/components/contact/index.js +3 -2
  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 +4 -3
  53. package/dist/components/embeds/index.cjs.map +1 -1
  54. package/dist/components/embeds/index.js +3 -2
  55. package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
  56. package/dist/components/faq/index.cjs +4 -3
  57. package/dist/components/faq/index.cjs.map +1 -1
  58. package/dist/components/faq/index.js +3 -2
  59. package/dist/components/features/index.cjs +3 -2
  60. package/dist/components/features/index.cjs.map +1 -1
  61. package/dist/components/features/index.js +2 -1
  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 +1 -0
  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 +28 -4
  71. package/dist/components/media-carousel.d.ts.map +1 -1
  72. package/dist/components/navigation/index.cjs +3 -2
  73. package/dist/components/navigation/index.cjs.map +1 -1
  74. package/dist/components/navigation/index.js +2 -1
  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 +4 -3
  78. package/dist/components/related-content/index.cjs.map +1 -1
  79. package/dist/components/related-content/index.js +3 -2
  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 +3 -2
  83. package/dist/components/ui/index.cjs.map +1 -1
  84. package/dist/components/ui/index.js +2 -1
  85. package/dist/components/ui/tag.d.ts +3 -2
  86. package/dist/components/ui/tag.d.ts.map +1 -1
  87. package/dist/index.cjs +3 -2
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.js +2 -1
  90. package/dist/utils/media-carousel-utils-stub.d.ts +3 -1
  91. package/dist/utils/media-carousel-utils-stub.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/components/chat/quick-action-chip.tsx +18 -4
  94. package/src/components/embeds/reddit-embed-client.tsx +0 -3
  95. package/src/components/index.ts +5 -0
  96. package/src/components/media-carousel.tsx +195 -149
  97. package/src/components/ui/tag.tsx +21 -3
  98. package/src/styles/ods-design-tokens.css +2 -0
  99. package/src/utils/media-carousel-utils-stub.ts +3 -1
  100. package/dist/chunk-CVYSW33D.js.map +0 -1
  101. package/dist/chunk-GGDM5J4B.cjs.map +0 -1
  102. package/dist/utils/media-carousel-utils.d.ts +0 -10
  103. package/dist/utils/media-carousel-utils.d.ts.map +0 -1
  104. package/src/utils/.media-carousel-utils.md +0 -31
  105. package/src/utils/media-carousel-utils.ts +0 -22
  106. /package/dist/{chunk-4UZH3MT2.js.map → chunk-5HOII3WB.js.map} +0 -0
  107. /package/dist/{chunk-43H2WOAW.js.map → chunk-E4J5KKHH.js.map} +0 -0
  108. /package/dist/{chunk-NSF7JUXF.js.map → chunk-IK5AR6QG.js.map} +0 -0
  109. /package/dist/{chunk-KLBEIJVZ.js.map → chunk-J6M2G6TD.js.map} +0 -0
  110. /package/dist/{chunk-HB63TQQO.js.map → chunk-KZPVN7AZ.js.map} +0 -0
  111. /package/dist/{chunk-XFP43YKG.js.map → chunk-L6OPVKVR.js.map} +0 -0
  112. /package/dist/{chunk-H66L2265.js.map → chunk-LQWYXIKB.js.map} +0 -0
  113. /package/dist/{chunk-GJSLKATJ.js.map → chunk-O5W26SCL.js.map} +0 -0
  114. /package/dist/{chunk-HJIRG7R6.js.map → chunk-VUW4G6SU.js.map} +0 -0
  115. /package/dist/{chunk-7XVFSGQA.js.map → chunk-ZF5O3KUQ.js.map} +0 -0
  116. /package/dist/{chunk-GMZL4T7C.js.map → chunk-ZMFQWMM7.js.map} +0 -0
@@ -1,10 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useState, useRef, useEffect, memo, useCallback } from 'react';
3
+ import { useState, useRef, useEffect, memo, useCallback, type KeyboardEvent, type TouchEvent } from 'react';
4
4
  import { cn } from "../utils/cn";
5
5
  import { MediaItem } from '../utils/media-carousel-utils-stub';
6
- import { Video, extractYouTubeId } from './features/video';
7
- import { VideoPlayBadge } from './features/video-center-badge';
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';
8
10
 
9
11
  // Navigation icons
10
12
  const ChevronLeftIcon = () => (
@@ -19,58 +21,79 @@ const ChevronRightIcon = () => (
19
21
  </svg>
20
22
  );
21
23
 
22
- interface MediaCarouselProps {
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 {
23
38
  media: MediaItem[];
24
39
  className?: string;
25
40
  /** Use aspect ratio instead of height classes for stable dimensions */
26
41
  aspectRatio?: '16/9' | '4/3' | '3/2' | '1/1';
27
- showThumbnails?: boolean;
28
- autoPlay?: boolean;
42
+ /** Hover-revealed prev/next arrows (default true; dots always render). */
43
+ showArrows?: boolean;
29
44
  /** How content should fit within the container */
30
45
  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;
31
57
  }
32
58
 
33
- export const MediaCarousel = memo(function MediaCarousel({
34
- media,
59
+ export const MediaCarousel = memo(function MediaCarousel({
60
+ media,
35
61
  className,
36
62
  aspectRatio = "16/9",
37
- showThumbnails = true,
38
- autoPlay = false,
39
- objectFit = 'contain'
63
+ showArrows = true,
64
+ objectFit = 'contain',
65
+ posterPriority = false,
66
+ transformImageSrc,
67
+ reserveDotRow = false,
40
68
  }: MediaCarouselProps) {
41
69
  const [currentIndex, setCurrentIndex] = useState(0);
42
70
  const [touchStart, setTouchStart] = useState<number | null>(null);
43
71
  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);
44
78
  const carouselRef = useRef<HTMLDivElement>(null);
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.
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.
53
88
  useEffect(() => {
54
89
  if (currentIndex >= media.length && media.length > 0) {
55
90
  setCurrentIndex(media.length - 1);
56
91
  }
57
92
  }, [media.length, currentIndex]);
58
93
 
59
- // Early return if no media provided
60
- if (!media || media.length === 0) {
61
- return null;
62
- }
63
-
64
94
  const currentItem = media[currentIndex] || media[0];
65
95
 
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.
96
+ // Navigation `<Video>` owns its own play/pause lifecycle.
74
97
  const nextSlide = useCallback(() => {
75
98
  setCurrentIndex((prev) => (prev + 1) % media.length);
76
99
  }, [media.length]);
@@ -80,14 +103,13 @@ export const MediaCarousel = memo(function MediaCarousel({
80
103
  }, [media.length]);
81
104
 
82
105
  const selectSlide = useCallback((index: number) => {
83
- if (index === currentIndex) return;
84
- setCurrentIndex(index);
85
- }, [currentIndex]);
106
+ setCurrentIndex((prev) => (index === prev ? prev : index));
107
+ }, []);
86
108
 
87
109
  // Keyboard navigation - only when carousel is focused
88
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
110
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
89
111
  if (media.length <= 1) return;
90
-
112
+
91
113
  if (e.key === 'ArrowLeft') {
92
114
  e.preventDefault();
93
115
  prevSlide();
@@ -97,21 +119,33 @@ export const MediaCarousel = memo(function MediaCarousel({
97
119
  }
98
120
  }, [nextSlide, prevSlide, media.length]);
99
121
 
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
+
100
132
  // Touch/swipe handling
101
133
  const minSwipeDistance = 50;
102
134
 
103
- const onTouchStart = (e: React.TouchEvent) => {
135
+ const onTouchStart = (e: TouchEvent) => {
104
136
  setTouchEnd(null);
105
137
  setTouchStart(e.targetTouches[0].clientX);
106
138
  };
107
139
 
108
- const onTouchMove = (e: React.TouchEvent) => {
140
+ const onTouchMove = (e: TouchEvent) => {
109
141
  setTouchEnd(e.targetTouches[0].clientX);
110
142
  };
111
143
 
112
144
  const onTouchEnd = () => {
113
- if (!touchStart || !touchEnd) return;
114
-
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
+
115
149
  const distance = touchStart - touchEnd;
116
150
  const isLeftSwipe = distance > minSwipeDistance;
117
151
  const isRightSwipe = distance < -minSwipeDistance;
@@ -124,52 +158,92 @@ export const MediaCarousel = memo(function MediaCarousel({
124
158
  }
125
159
  };
126
160
 
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
- );
161
+ const displayImageSrc = (url: string): string =>
162
+ transformImageSrc ? transformImageSrc(url) : url;
138
163
 
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.
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.
142
167
  const renderVideo = (item: MediaItem, index: number) => (
143
168
  <Video
169
+ key={`video-${index}-${item.src}`}
144
170
  url={item.src}
145
- poster={item.poster}
171
+ poster={item.poster ? displayImageSrc(item.poster) : undefined}
172
+ title={item.alt || `Video ${index + 1}`}
146
173
  muted
147
174
  layout="fill"
148
- priority={index === currentIndex}
175
+ priority={Boolean(posterPriority && index === 0)}
176
+ className="w-full h-full bg-black"
149
177
  />
150
178
  );
151
179
 
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
- />
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>
165
194
  </div>
166
195
  );
167
196
 
168
- // Render main media item
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).
169
244
  const renderMainMedia = (item: MediaItem, index: number) => {
170
245
  switch (item.type) {
171
246
  case 'youtube':
172
- return renderYouTubeEmbed(item, index);
173
247
  case 'video':
174
248
  return renderVideo(item, index);
175
249
  case 'image':
@@ -178,55 +252,6 @@ export const MediaCarousel = memo(function MediaCarousel({
178
252
  }
179
253
  };
180
254
 
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
-
230
255
  // Get CSS for aspect ratio
231
256
  const getAspectRatioClass = () => {
232
257
  switch (aspectRatio) {
@@ -243,12 +268,14 @@ export const MediaCarousel = memo(function MediaCarousel({
243
268
  };
244
269
 
245
270
  return (
246
- <div className={cn("space-y-4", className)}>
247
- {/* Main Display Area with Fixed Aspect Ratio */}
248
- <div
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
249
275
  ref={carouselRef}
250
276
  className={cn(
251
- "relative bg-ods-bg border border-ods-border rounded-2xl overflow-hidden group w-full",
277
+ "relative bg-ods-bg border border-ods-border rounded-md overflow-hidden group w-full",
278
+ "[box-shadow:var(--shadow-media-frame)]",
252
279
  getAspectRatioClass()
253
280
  )}
254
281
  onTouchStart={onTouchStart}
@@ -262,45 +289,64 @@ export const MediaCarousel = memo(function MediaCarousel({
262
289
  {/* Media content */}
263
290
  {renderMainMedia(currentItem, currentIndex)}
264
291
 
265
- {/* Navigation Arrows - only show if multiple items */}
266
- {media.length > 1 && (
292
+ {/* Navigation Arrows — hover-revealed, only with multiple items.
293
+ The common <Button> (transparent) with overlay positioning. */}
294
+ {showArrows && media.length > 1 && (
267
295
  <>
268
- <button
296
+ <Button
297
+ variant="transparent"
269
298
  onClick={prevSlide}
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"
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"
271
300
  aria-label="Previous media"
272
301
  >
273
302
  <ChevronLeftIcon />
274
- </button>
303
+ </Button>
275
304
 
276
- <button
305
+ <Button
306
+ variant="transparent"
277
307
  onClick={nextSlide}
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"
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"
279
309
  aria-label="Next media"
280
310
  >
281
311
  <ChevronRightIcon />
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>
312
+ </Button>
288
313
  </>
289
314
  )}
290
315
  </div>
291
316
 
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>
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
+ })}
302
344
  </div>
303
345
  )}
304
346
  </div>
305
347
  );
306
- });
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 };
@@ -7,11 +7,18 @@ import { cn } from "../../utils/cn"
7
7
 
8
8
  const tagVariants = cva(
9
9
  [
10
- "text-h5 h-8 inline-flex items-center justify-center gap-[var(--spacing-system-xxs)] p-[var(--spacing-system-xsf)] rounded-md",
10
+ "inline-flex items-center justify-center 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
+ },
15
22
  variant: {
16
23
  primary: [
17
24
  "bg-[var(--ods-open-yellow-base)] text-[var(--ods-system-greys-black)]",
@@ -42,6 +49,15 @@ const tagVariants = cva(
42
49
  "bg-[var(--ods-system-greys-soft-grey)] text-[var(--ods-system-greys-grey)]",
43
50
  "hover:bg-[var(--ods-system-greys-soft-grey-hover)] active:bg-[var(--ods-system-greys-soft-grey-action)]",
44
51
  ],
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
+ ],
45
61
  // Matches the EntityTagBadges / StatusBadge tag skin (ods-card + ods-border,
46
62
  // mono uppercase) so the tag-editor chips render identically to the public
47
63
  // tag badges. Used for FilterChipData variant 'tag' (see search-input).
@@ -53,6 +69,7 @@ const tagVariants = cva(
53
69
  },
54
70
  defaultVariants: {
55
71
  variant: "primary",
72
+ size: "default",
56
73
  },
57
74
  }
58
75
  )
@@ -86,6 +103,7 @@ export interface TagProps
86
103
  function Tag({
87
104
  label,
88
105
  variant,
106
+ size,
89
107
  icon,
90
108
  onClose,
91
109
  className,
@@ -97,7 +115,7 @@ function Tag({
97
115
  return (
98
116
  <Comp
99
117
  className={cn(
100
- tagVariants({ variant }),
118
+ tagVariants({ variant, size }),
101
119
  disabled && disabledTagClasses,
102
120
  className
103
121
  )}
@@ -105,7 +123,7 @@ function Tag({
105
123
  {...props}
106
124
  >
107
125
  {icon && (
108
- <span className="flex items-center justify-center size-5 shrink-0">
126
+ <span className={cn("flex items-center justify-center shrink-0", size === 'large' ? 'size-6' : 'size-5')}>
109
127
  {icon}
110
128
  </span>
111
129
  )}
@@ -59,6 +59,8 @@
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);
62
64
  --shadow-tooltip: var(--shadow-base);
63
65
  --shadow-focus: 0 0 0 3px rgba(255, 192, 8, 0.3);
64
66
 
@@ -1,5 +1,7 @@
1
1
  export interface MediaItem {
2
- id: string;
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;
3
5
  type: 'image' | 'video' | 'youtube';
4
6
  src: string;
5
7
  alt?: string;