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

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 (118) 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/announcement-bar.d.ts.map +1 -1
  39. package/dist/components/case-studies/index.cjs +9 -8
  40. package/dist/components/case-studies/index.cjs.map +1 -1
  41. package/dist/components/case-studies/index.js +3 -2
  42. package/dist/components/case-studies/index.js.map +1 -1
  43. package/dist/components/chat/index.cjs +3 -2
  44. package/dist/components/chat/index.cjs.map +1 -1
  45. package/dist/components/chat/index.js +2 -1
  46. package/dist/components/chat/quick-action-chip.d.ts +11 -2
  47. package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
  48. package/dist/components/contact/index.cjs +4 -3
  49. package/dist/components/contact/index.cjs.map +1 -1
  50. package/dist/components/contact/index.js +3 -2
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -3
  54. package/dist/components/embeds/index.cjs.map +1 -1
  55. package/dist/components/embeds/index.js +3 -2
  56. package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
  57. package/dist/components/faq/index.cjs +4 -3
  58. package/dist/components/faq/index.cjs.map +1 -1
  59. package/dist/components/faq/index.js +3 -2
  60. package/dist/components/features/index.cjs +3 -2
  61. package/dist/components/features/index.cjs.map +1 -1
  62. package/dist/components/features/index.js +2 -1
  63. package/dist/components/help-center-pages/index.cjs +21 -21
  64. package/dist/components/help-center-pages/index.js +12 -12
  65. package/dist/components/index.cjs +136 -130
  66. package/dist/components/index.cjs.map +1 -1
  67. package/dist/components/index.d.ts +1 -0
  68. package/dist/components/index.d.ts.map +1 -1
  69. package/dist/components/index.js +20 -14
  70. package/dist/components/index.js.map +1 -1
  71. package/dist/components/media-carousel.d.ts +28 -4
  72. package/dist/components/media-carousel.d.ts.map +1 -1
  73. package/dist/components/navigation/index.cjs +3 -2
  74. package/dist/components/navigation/index.cjs.map +1 -1
  75. package/dist/components/navigation/index.js +2 -1
  76. package/dist/components/onboarding-guides/index.cjs +6 -6
  77. package/dist/components/onboarding-guides/index.js +5 -5
  78. package/dist/components/related-content/index.cjs +4 -3
  79. package/dist/components/related-content/index.cjs.map +1 -1
  80. package/dist/components/related-content/index.js +3 -2
  81. package/dist/components/tickets/index.cjs +6 -6
  82. package/dist/components/tickets/index.js +5 -5
  83. package/dist/components/ui/index.cjs +3 -2
  84. package/dist/components/ui/index.cjs.map +1 -1
  85. package/dist/components/ui/index.js +2 -1
  86. package/dist/components/ui/tag.d.ts +3 -2
  87. package/dist/components/ui/tag.d.ts.map +1 -1
  88. package/dist/index.cjs +3 -2
  89. package/dist/index.cjs.map +1 -1
  90. package/dist/index.js +2 -1
  91. package/dist/utils/media-carousel-utils-stub.d.ts +3 -1
  92. package/dist/utils/media-carousel-utils-stub.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/components/announcement-bar.tsx +18 -10
  95. package/src/components/chat/quick-action-chip.tsx +18 -4
  96. package/src/components/embeds/reddit-embed-client.tsx +0 -3
  97. package/src/components/index.ts +5 -0
  98. package/src/components/media-carousel.tsx +195 -149
  99. package/src/components/ui/tag.tsx +21 -3
  100. package/src/styles/ods-design-tokens.css +2 -0
  101. package/src/utils/media-carousel-utils-stub.ts +3 -1
  102. package/dist/chunk-CVYSW33D.js.map +0 -1
  103. package/dist/chunk-GGDM5J4B.cjs.map +0 -1
  104. package/dist/utils/media-carousel-utils.d.ts +0 -10
  105. package/dist/utils/media-carousel-utils.d.ts.map +0 -1
  106. package/src/utils/.media-carousel-utils.md +0 -31
  107. package/src/utils/media-carousel-utils.ts +0 -22
  108. /package/dist/{chunk-4UZH3MT2.js.map → chunk-5HOII3WB.js.map} +0 -0
  109. /package/dist/{chunk-43H2WOAW.js.map → chunk-E4J5KKHH.js.map} +0 -0
  110. /package/dist/{chunk-NSF7JUXF.js.map → chunk-IK5AR6QG.js.map} +0 -0
  111. /package/dist/{chunk-KLBEIJVZ.js.map → chunk-J6M2G6TD.js.map} +0 -0
  112. /package/dist/{chunk-HB63TQQO.js.map → chunk-KZPVN7AZ.js.map} +0 -0
  113. /package/dist/{chunk-XFP43YKG.js.map → chunk-L6OPVKVR.js.map} +0 -0
  114. /package/dist/{chunk-H66L2265.js.map → chunk-LQWYXIKB.js.map} +0 -0
  115. /package/dist/{chunk-GJSLKATJ.js.map → chunk-O5W26SCL.js.map} +0 -0
  116. /package/dist/{chunk-HJIRG7R6.js.map → chunk-VUW4G6SU.js.map} +0 -0
  117. /package/dist/{chunk-7XVFSGQA.js.map → chunk-ZF5O3KUQ.js.map} +0 -0
  118. /package/dist/{chunk-GMZL4T7C.js.map → chunk-ZMFQWMM7.js.map} +0 -0
@@ -141,6 +141,13 @@ export interface QuickActionChipButtonProps {
141
141
  lozenge?: QuickActionChipLozenge
142
142
  /** `'primary'` = accent (yellow) chip, `'outline'` = bordered chip (default). */
143
143
  variant?: 'primary' | 'outline'
144
+ /** Active single-select state (Figma "Feature Item" active): renders the
145
+ * Tag's `selected` variant (pink border + pink-secondary fill), overriding
146
+ * `variant`. Used by chip groups acting as tabs (OpenFrame categories). */
147
+ selected?: boolean
148
+ /** Chip scale, forwarded to `Tag` — `'large'` is the Figma "Feature Item"
149
+ * 48px chip (h3 bold label, 24px icon box). Default `'default'` (32px). */
150
+ size?: 'default' | 'large'
144
151
  onSelect?: () => void
145
152
  /** Pointer/keyboard focus enters the chip — e.g. preview the full prompt. */
146
153
  onHoverStart?: () => void
@@ -164,6 +171,8 @@ export interface QuickActionChipSkeletonProps {
164
171
  icon?: boolean
165
172
  /** Reserve the leading lozenge affix slot. */
166
173
  lozenge?: boolean
174
+ /** Chip scale — MUST match the loaded chips' `size` or the swap jumps. */
175
+ size?: 'default' | 'large'
167
176
  className?: string
168
177
  }
169
178
 
@@ -182,12 +191,13 @@ function ChipSkelBar({ className, style }: { className?: string; style?: React.C
182
191
  * construction. Use anywhere quick actions stream in (chat empty states,
183
192
  * marketing walls, deck panels).
184
193
  */
185
- export function QuickActionChipSkeleton({ labelCh = 16, icon = true, lozenge = false, className }: QuickActionChipSkeletonProps) {
194
+ export function QuickActionChipSkeleton({ labelCh = 16, icon = true, lozenge = false, size = 'default', className }: QuickActionChipSkeletonProps) {
186
195
  return (
187
196
  <Tag
188
197
  variant="outline"
198
+ size={size}
189
199
  className={className}
190
- icon={icon ? <ChipSkelBar className="block size-4" /> : undefined}
200
+ icon={icon ? <ChipSkelBar className={size === 'large' ? 'block size-5' : 'block size-4'} /> : undefined}
191
201
  label={
192
202
  <>
193
203
  {lozenge && <ChipSkelBar className="mr-2 inline-block h-[16px] w-[26px] translate-y-[2px]" />}
@@ -215,6 +225,8 @@ export function QuickActionChipButton({
215
225
  icon,
216
226
  lozenge,
217
227
  variant = 'outline',
228
+ selected = false,
229
+ size = 'default',
218
230
  onSelect,
219
231
  onHoverStart,
220
232
  onHoverEnd,
@@ -223,8 +235,9 @@ export function QuickActionChipButton({
223
235
  }: QuickActionChipButtonProps) {
224
236
  const resolvedIcon = renderQuickActionIcon(icon)
225
237
  const resolvedLabel = composeChipLabel(label, lozenge)
238
+ const tagVariant = selected ? 'selected' : variant
226
239
  if (!interactive) {
227
- return <Tag variant={variant} icon={resolvedIcon} label={resolvedLabel} className={className} />
240
+ return <Tag variant={tagVariant} size={size} icon={resolvedIcon} label={resolvedLabel} className={className} />
228
241
  }
229
242
  return (
230
243
  <button
@@ -234,12 +247,13 @@ export function QuickActionChipButton({
234
247
  onMouseLeave={onHoverEnd}
235
248
  onFocus={onHoverStart}
236
249
  onBlur={onHoverEnd}
250
+ aria-pressed={selected || undefined}
237
251
  className={cn(
238
252
  'shrink-0 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent',
239
253
  className,
240
254
  )}
241
255
  >
242
- <Tag variant={variant} icon={resolvedIcon} label={resolvedLabel} />
256
+ <Tag variant={tagVariant} size={size} icon={resolvedIcon} label={resolvedLabel} />
243
257
  </button>
244
258
  )
245
259
  }
@@ -448,8 +448,6 @@ export function RedditEmbedClient({ url, maxWidth = 700 }: RedditEmbedProps) {
448
448
  height: m.height,
449
449
  }));
450
450
 
451
- console.log('🎬 MediaCarousel will render with:', mediaContent.length, 'items', mediaContent);
452
-
453
451
  // Format time
454
452
  const formatTimeAgo = (timestamp: number) => {
455
453
  const now = Math.floor(Date.now() / 1000);
@@ -515,7 +513,6 @@ export function RedditEmbedClient({ url, maxWidth = 700 }: RedditEmbedProps) {
515
513
  <MediaCarousel
516
514
  media={carouselItems}
517
515
  aspectRatio="16/9"
518
- showThumbnails={carouselItems.length > 1}
519
516
  />
520
517
  )}
521
518
 
@@ -38,6 +38,11 @@ export * from './hero-image-uploader'
38
38
  export * from './icons-block'
39
39
  export * from './image-cropper'
40
40
  export * from './media-carousel'
41
+ // Explicit re-export disambiguates the star-export collision with the
42
+ // features barrel's gallery-manager MediaItem: from THIS barrel, `MediaItem`
43
+ // is the carousel item shape (the lib-wide SSOT the hub re-types against);
44
+ // the gallery-manager shape stays reachable via `./components/features`.
45
+ export type { MediaItem } from '../utils/media-carousel-utils-stub'
41
46
  export * from './metric-value'
42
47
  export * from './msp-display'
43
48
  export * from './open-source-features'
@@ -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;