@duffcloudservices/cms 0.9.0 → 0.11.0

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.
@@ -0,0 +1,229 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import LiteMediaEmbed from './LiteMediaEmbed.vue'
4
+
5
+ const YT_URL = 'https://www.youtube.com/watch?v=vz7F8bc1QQ0'
6
+ const YT_ID = 'vz7F8bc1QQ0'
7
+ const IG_URL = 'https://www.instagram.com/p/AbCdEfGhIjK/'
8
+
9
+ describe('LiteMediaEmbed — YouTube facade', () => {
10
+ it('renders the poster (thumbnail + play button) and NO iframe initially', () => {
11
+ const wrapper = mount(LiteMediaEmbed, {
12
+ props: { url: YT_URL, type: 'youtube', alt: 'Reindeer Dash 2025' },
13
+ })
14
+
15
+ // No heavy iframe until the user asks for it.
16
+ expect(wrapper.find('iframe').exists()).toBe(false)
17
+
18
+ // Poster = the documented YouTube thumbnail for the video id.
19
+ const poster = wrapper.find('img.dcs-lite-embed__poster')
20
+ expect(poster.exists()).toBe(true)
21
+ expect(poster.attributes('src')).toBe(
22
+ `https://img.youtube.com/vi/${YT_ID}/hqdefault.jpg`,
23
+ )
24
+ expect(poster.attributes('loading')).toBe('lazy')
25
+
26
+ // Accessible, keyboard-operable play affordance.
27
+ const button = wrapper.find('button.dcs-lite-embed__button')
28
+ expect(button.exists()).toBe(true)
29
+ expect(button.attributes('type')).toBe('button')
30
+ expect(button.attributes('aria-label')).toBe('Play video: Reindeer Dash 2025')
31
+ })
32
+
33
+ it('injects the real nocookie autoplay iframe on click and drops the poster button', async () => {
34
+ const wrapper = mount(LiteMediaEmbed, {
35
+ props: { url: YT_URL, type: 'youtube', alt: 'Reindeer Dash 2025' },
36
+ })
37
+
38
+ await wrapper.find('button.dcs-lite-embed__button').trigger('click')
39
+
40
+ const iframe = wrapper.find('iframe.dcs-lite-embed__frame')
41
+ expect(iframe.exists()).toBe(true)
42
+ expect(iframe.attributes('src')).toBe(
43
+ `https://www.youtube-nocookie.com/embed/${YT_ID}?rel=0&autoplay=1&playsinline=1`,
44
+ )
45
+ expect(iframe.attributes('allow')).toContain('autoplay')
46
+ expect(iframe.attributes('title')).toBe('Reindeer Dash 2025')
47
+
48
+ // The poster button is gone once the iframe is live.
49
+ expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
50
+ })
51
+
52
+ it('activates via keyboard (Enter on the play button)', async () => {
53
+ const wrapper = mount(LiteMediaEmbed, {
54
+ props: { url: YT_URL, type: 'youtube', alt: 'Reindeer Dash 2025' },
55
+ })
56
+
57
+ expect(wrapper.find('iframe').exists()).toBe(false)
58
+
59
+ await wrapper.find('button.dcs-lite-embed__button').trigger('keydown.enter')
60
+
61
+ expect(wrapper.find('iframe.dcs-lite-embed__frame').exists()).toBe(true)
62
+ })
63
+
64
+ it('activates via keyboard (Space on the play button)', async () => {
65
+ const wrapper = mount(LiteMediaEmbed, {
66
+ props: { url: YT_URL, type: 'youtube' },
67
+ })
68
+
69
+ await wrapper.find('button.dcs-lite-embed__button').trigger('keydown.space')
70
+
71
+ expect(wrapper.find('iframe.dcs-lite-embed__frame').exists()).toBe(true)
72
+ })
73
+
74
+ it('accepts a bare 11-char video id', () => {
75
+ const wrapper = mount(LiteMediaEmbed, {
76
+ props: { url: YT_ID, type: 'youtube' },
77
+ })
78
+
79
+ expect(wrapper.find('img.dcs-lite-embed__poster').attributes('src')).toBe(
80
+ `https://img.youtube.com/vi/${YT_ID}/hqdefault.jpg`,
81
+ )
82
+ })
83
+ })
84
+
85
+ describe('LiteMediaEmbed — Instagram facade', () => {
86
+ it('renders a branded glyph poster (no hotlinked thumbnail) and no iframe initially', () => {
87
+ const wrapper = mount(LiteMediaEmbed, {
88
+ props: { url: IG_URL, type: 'instagram', alt: 'Latest reel' },
89
+ })
90
+
91
+ expect(wrapper.find('iframe').exists()).toBe(false)
92
+ // No image is hotlinked; a branded poster stands in.
93
+ expect(wrapper.find('img.dcs-lite-embed__poster').exists()).toBe(false)
94
+ expect(wrapper.find('.dcs-lite-embed__poster--brand').exists()).toBe(true)
95
+ expect(wrapper.find('button.dcs-lite-embed__button').attributes('aria-label')).toBe(
96
+ 'Load Instagram post: Latest reel',
97
+ )
98
+ })
99
+
100
+ it('uses a caller-supplied poster override when present', () => {
101
+ const wrapper = mount(LiteMediaEmbed, {
102
+ props: {
103
+ url: IG_URL,
104
+ type: 'instagram',
105
+ poster: 'https://files.duffcloudservices.com/kdh/assets/ig-cover.webp',
106
+ },
107
+ })
108
+
109
+ const poster = wrapper.find('img.dcs-lite-embed__poster')
110
+ expect(poster.exists()).toBe(true)
111
+ expect(poster.attributes('src')).toBe(
112
+ 'https://files.duffcloudservices.com/kdh/assets/ig-cover.webp',
113
+ )
114
+ })
115
+
116
+ it('injects the /embed/ iframe on activation', async () => {
117
+ const wrapper = mount(LiteMediaEmbed, {
118
+ props: { url: IG_URL, type: 'instagram' },
119
+ })
120
+
121
+ await wrapper.find('button.dcs-lite-embed__button').trigger('click')
122
+
123
+ const iframe = wrapper.find('iframe.dcs-lite-embed__frame')
124
+ expect(iframe.exists()).toBe(true)
125
+ expect(iframe.attributes('src')).toBe('https://www.instagram.com/p/AbCdEfGhIjK/embed/')
126
+ })
127
+ })
128
+
129
+ describe('LiteMediaEmbed — hostile url never reaches the iframe src (no CSP backstop on customer sites)', () => {
130
+ // A portal-editable / visual-editor-AI-controlled carousel `url` must never be
131
+ // passed verbatim into <iframe src>. Invalid/hostile urls fall through to the
132
+ // safe default slot rather than rendering a facade over an untrusted iframe.
133
+ // These produce NO renderable embed at all (invalid scheme/host) → the
134
+ // component renders nothing (no facade button, no media <img> carrying the url).
135
+ const inert = [
136
+ { type: 'youtube', url: 'javascript:alert(document.domain)' },
137
+ { type: 'instagram', url: 'javascript:alert(1)//' },
138
+ { type: 'instagram', url: 'https://evil.com/x' },
139
+ { type: 'instagram', url: 'data:text/html,<script>alert(1)</script>' },
140
+ { type: 'instagram', url: 'http://www.instagram.com/p/AbCdEfGhIjK/' }, // non-https
141
+ { type: 'instagram', url: 'https://instagram.com.evil.com/p/x/' }, // lookalike host
142
+ ] as const
143
+
144
+ for (const { type, url } of inert) {
145
+ it(`renders nothing (no button, no iframe, no leaked url) for ${type} url ${url}`, () => {
146
+ const wrapper = mount(LiteMediaEmbed, { props: { url, type } })
147
+
148
+ expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
149
+ expect(wrapper.find('iframe').exists()).toBe(false)
150
+ // The untrusted url is never emitted into any attribute (no broken <img src>).
151
+ expect(wrapper.html()).not.toContain('javascript:')
152
+ expect(wrapper.html()).not.toContain('evil.com')
153
+ expect(wrapper.html()).not.toContain('data:text/html')
154
+ })
155
+ }
156
+
157
+ it('extracts an id from a ?v= param on any host but only ever embeds via youtube-nocookie (never the source host)', async () => {
158
+ // extractYouTubeId pulls the sanitised 11-char id; the host is discarded, so
159
+ // the iframe origin is always the hardcoded youtube-nocookie, never evil.com.
160
+ const wrapper = mount(LiteMediaEmbed, {
161
+ props: { url: 'https://evil.com/watch?v=notArealID1', type: 'youtube' },
162
+ })
163
+ await wrapper.find('button.dcs-lite-embed__button').trigger('click')
164
+ const src = wrapper.find('iframe.dcs-lite-embed__frame').attributes('src') ?? ''
165
+ expect(src.startsWith('https://www.youtube-nocookie.com/embed/notArealID1')).toBe(true)
166
+ expect(wrapper.html()).not.toContain('evil.com')
167
+ })
168
+
169
+ it('accepts a legitimate www.instagram.com url and rebuilds a clean /embed/ src', async () => {
170
+ const wrapper = mount(LiteMediaEmbed, {
171
+ props: { url: 'https://www.instagram.com/p/AbCdEfGhIjK/?igshid=abc#frag', type: 'instagram' },
172
+ })
173
+ await wrapper.find('button.dcs-lite-embed__button').trigger('click')
174
+ // Query/fragment/userinfo are dropped — only host + path survive the rebuild.
175
+ expect(wrapper.find('iframe.dcs-lite-embed__frame').attributes('src')).toBe(
176
+ 'https://www.instagram.com/p/AbCdEfGhIjK/embed/',
177
+ )
178
+ })
179
+ })
180
+
181
+ describe('LiteMediaEmbed — non-embed types fall through to existing behavior', () => {
182
+ it('renders a plain <img> for image type (no facade button, no iframe)', () => {
183
+ const wrapper = mount(LiteMediaEmbed, {
184
+ props: { url: '/images/staff/kim.webp', type: 'image', alt: 'Kim Duff' },
185
+ })
186
+
187
+ expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
188
+ expect(wrapper.find('iframe').exists()).toBe(false)
189
+
190
+ const img = wrapper.find('img.dcs-lite-embed__media')
191
+ expect(img.exists()).toBe(true)
192
+ expect(img.attributes('src')).toBe('/images/staff/kim.webp')
193
+ expect(img.attributes('alt')).toBe('Kim Duff')
194
+ })
195
+
196
+ it('renders a <video> for video type', () => {
197
+ const wrapper = mount(LiteMediaEmbed, {
198
+ props: { url: '/videos/intro.mp4', type: 'video', alt: 'Intro' },
199
+ })
200
+
201
+ expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
202
+ const video = wrapper.find('video.dcs-lite-embed__media')
203
+ expect(video.exists()).toBe(true)
204
+ expect(video.attributes('src')).toBe('/videos/intro.mp4')
205
+ expect(video.attributes('controls')).toBeDefined()
206
+ })
207
+
208
+ it('falls through to a plain <img> for an unknown type', () => {
209
+ const wrapper = mount(LiteMediaEmbed, {
210
+ // Deliberately outside the documented union.
211
+ props: { url: '/whatever.png', type: 'gif' as never },
212
+ })
213
+
214
+ expect(wrapper.find('button.dcs-lite-embed__button').exists()).toBe(false)
215
+ expect(wrapper.find('iframe').exists()).toBe(false)
216
+ expect(wrapper.find('img.dcs-lite-embed__media').attributes('src')).toBe('/whatever.png')
217
+ })
218
+
219
+ it('lets a consumer override non-embed rendering via the default slot', () => {
220
+ const wrapper = mount(LiteMediaEmbed, {
221
+ props: { url: '/images/staff/kim.webp', type: 'image', alt: 'Kim Duff' },
222
+ slots: { default: '<figure class="custom-fallback">custom</figure>' },
223
+ })
224
+
225
+ expect(wrapper.find('figure.custom-fallback').exists()).toBe(true)
226
+ // The built-in default is replaced by slot content.
227
+ expect(wrapper.find('img.dcs-lite-embed__media').exists()).toBe(false)
228
+ })
229
+ })
@@ -0,0 +1,399 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * LiteMediaEmbed
4
+ *
5
+ * Click-to-load facade for `useMediaCarousel` embed media types (`youtube`,
6
+ * `instagram`). The poster state is the thumbnail plus an accessible play
7
+ * affordance; the real (heavy) `<iframe>` is injected only on user interaction.
8
+ * This defers the full YouTube/Instagram player payload (~0.8 MB of script for
9
+ * the YouTube player alone) until a visitor actually chooses to watch, and is
10
+ * the default renderer for future editor video embeds.
11
+ *
12
+ * Non-embed types (`image`, `video`, or anything unknown) fall through to the
13
+ * default `<slot>` — which itself defaults to a plain `<img>`/`<video>` — so a
14
+ * carousel can hand every item to this one component.
15
+ *
16
+ * SSR-safe (VitePress SSG/SSR): no `window`/`document` access at setup, the
17
+ * poster renders server-side, and the poster/iframe swap is a pure `ref` flip
18
+ * that only happens after a client-side user gesture, so server and client
19
+ * markup match on hydration.
20
+ *
21
+ * @example
22
+ * ```vue
23
+ * <LiteMediaEmbed
24
+ * :url="item.url"
25
+ * :type="item.type"
26
+ * :alt="item.alt"
27
+ * />
28
+ * ```
29
+ */
30
+ import { computed, ref } from 'vue'
31
+
32
+ const props = withDefaults(defineProps<{
33
+ /**
34
+ * Media source. For `youtube`: a watch/embed/short URL or a bare 11-char id.
35
+ * For `instagram`: the post/reel URL. For `image`/`video`: the asset URL.
36
+ */
37
+ url: string
38
+ /**
39
+ * Media type. `youtube`/`instagram` get the click-to-load facade; everything
40
+ * else falls through to the default slot (plain `<img>`/`<video>`).
41
+ */
42
+ type: 'image' | 'video' | 'youtube' | 'instagram' | (string & {})
43
+ /** Accessible label / alt text for the media. */
44
+ alt?: string
45
+ /**
46
+ * Optional poster image override. Required for a real Instagram thumbnail
47
+ * (Instagram exposes no documented thumbnail endpoint — without an override
48
+ * Instagram renders a branded glyph poster rather than hotlinking one).
49
+ */
50
+ poster?: string
51
+ /**
52
+ * CSS `aspect-ratio` for the reserved box. Poster and iframe share this exact
53
+ * box so activation causes no layout shift. Defaults to widescreen video.
54
+ */
55
+ aspectRatio?: string
56
+ }>(), {
57
+ alt: '',
58
+ poster: '',
59
+ aspectRatio: '16 / 9',
60
+ })
61
+
62
+ const activated = ref(false)
63
+
64
+ /** Types this component owns as click-to-load embeds (vs pass-through media). */
65
+ const isEmbedType = computed(() => props.type === 'youtube' || props.type === 'instagram')
66
+
67
+ // A route is only rendered as a click-to-load facade when it is a youtube/
68
+ // instagram type AND yields a trusted embed URL. An embed type whose url is
69
+ // unrecognised or hostile renders NOTHING (see the template's media fallback,
70
+ // which is gated to non-embed types) — never a broken <img src="javascript:…">
71
+ // nor a facade wrapping an untrusted iframe.
72
+ const isEmbed = computed(() => isEmbedType.value && embedSrc.value !== '')
73
+
74
+ /**
75
+ * Extract a YouTube video id from a watch URL, embed URL, short URL, or a bare
76
+ * 11-character id. Returns '' when nothing usable is found.
77
+ */
78
+ function extractYouTubeId(url: string): string {
79
+ const embedMatch = url.match(/\/embed\/([\w-]{11})/)
80
+ if (embedMatch) return embedMatch[1]
81
+
82
+ const watchMatch = url.match(/[?&]v=([\w-]{11})/)
83
+ if (watchMatch) return watchMatch[1]
84
+
85
+ const shortMatch = url.match(/youtu\.be\/([\w-]{11})/)
86
+ if (shortMatch) return shortMatch[1]
87
+
88
+ if (/^[\w-]{11}$/.test(url)) return url
89
+ return ''
90
+ }
91
+
92
+ const youTubeId = computed(() => (props.type === 'youtube' ? extractYouTubeId(props.url) : ''))
93
+
94
+ /**
95
+ * Build an Instagram embed URL from a caller-supplied post URL, rebuilt from the
96
+ * parsed host + path so nothing untrusted (userinfo, query, fragment, a foreign
97
+ * host, or a `javascript:`/`data:` scheme) can reach the iframe `src`. Returns
98
+ * '' for anything that is not an `https://…instagram.com/…` URL.
99
+ */
100
+ function instagramEmbed(rawUrl: string): string {
101
+ let parsed: URL
102
+ try {
103
+ parsed = new URL(rawUrl)
104
+ } catch {
105
+ return ''
106
+ }
107
+ if (parsed.protocol !== 'https:') return ''
108
+ const host = parsed.hostname.toLowerCase()
109
+ if (host !== 'instagram.com' && !host.endsWith('.instagram.com')) return ''
110
+ const path = parsed.pathname.replace(/\/embed\/?$/, '').replace(/\/$/, '')
111
+ return `https://${host}${path}/embed/`
112
+ }
113
+
114
+ /**
115
+ * Poster image URL for the facade. YouTube uses the documented thumbnail
116
+ * pattern (`hqdefault.jpg` always exists for a valid id); Instagram uses the
117
+ * caller-supplied override or none (branded glyph poster).
118
+ */
119
+ const posterSrc = computed(() => {
120
+ if (props.poster) return props.poster
121
+ if (props.type === 'youtube' && youTubeId.value) {
122
+ return `https://img.youtube.com/vi/${youTubeId.value}/hqdefault.jpg`
123
+ }
124
+ return ''
125
+ })
126
+
127
+ /**
128
+ * Real embed URL, only ever used after activation. Only ever a trusted
129
+ * youtube-nocookie / instagram.com URL, or '' — a portal/AI-controlled `url`
130
+ * is never passed through verbatim (no `javascript:`/`data:`/foreign-origin
131
+ * iframe sink; there is no CSP backstop on customer sites).
132
+ */
133
+ const embedSrc = computed(() => {
134
+ if (props.type === 'youtube') {
135
+ // Privacy-preserving nocookie host + autoplay so one click = playing.
136
+ return youTubeId.value
137
+ ? `https://www.youtube-nocookie.com/embed/${youTubeId.value}?rel=0&autoplay=1&playsinline=1`
138
+ : ''
139
+ }
140
+ if (props.type === 'instagram') {
141
+ return instagramEmbed(props.url)
142
+ }
143
+ return ''
144
+ })
145
+
146
+ const iframeTitle = computed(() => {
147
+ if (props.alt) return props.alt
148
+ return props.type === 'youtube' ? 'YouTube video' : 'Instagram post'
149
+ })
150
+
151
+ const iframeAllow = computed(() =>
152
+ props.type === 'youtube'
153
+ ? 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
154
+ : 'encrypted-media; picture-in-picture',
155
+ )
156
+
157
+ const playLabel = computed(() => {
158
+ const subject = props.alt?.trim()
159
+ if (props.type === 'youtube') {
160
+ return subject ? `Play video: ${subject}` : 'Play video'
161
+ }
162
+ return subject ? `Load Instagram post: ${subject}` : 'Load Instagram post'
163
+ })
164
+
165
+ function activate() {
166
+ activated.value = true
167
+ }
168
+ </script>
169
+
170
+ <template>
171
+ <div
172
+ class="dcs-lite-embed"
173
+ :class="{ 'dcs-lite-embed--embed': isEmbed }"
174
+ :style="{ aspectRatio }"
175
+ :data-dcs-lite-embed="type"
176
+ >
177
+ <template v-if="isEmbed">
178
+ <iframe
179
+ v-if="activated && embedSrc"
180
+ class="dcs-lite-embed__frame"
181
+ :src="embedSrc"
182
+ :title="iframeTitle"
183
+ :allow="iframeAllow"
184
+ allowfullscreen
185
+ loading="lazy"
186
+ />
187
+ <button
188
+ v-else
189
+ type="button"
190
+ class="dcs-lite-embed__button"
191
+ :aria-label="playLabel"
192
+ @click="activate"
193
+ @keydown.enter.prevent="activate"
194
+ @keydown.space.prevent="activate"
195
+ >
196
+ <img
197
+ v-if="posterSrc"
198
+ class="dcs-lite-embed__poster"
199
+ :src="posterSrc"
200
+ :alt="alt"
201
+ loading="lazy"
202
+ decoding="async"
203
+ >
204
+ <span
205
+ v-else
206
+ class="dcs-lite-embed__poster dcs-lite-embed__poster--brand"
207
+ :class="`dcs-lite-embed__poster--${type}`"
208
+ aria-hidden="true"
209
+ >
210
+ <svg
211
+ v-if="type === 'instagram'"
212
+ class="dcs-lite-embed__brand-glyph"
213
+ viewBox="0 0 24 24"
214
+ width="56"
215
+ height="56"
216
+ fill="none"
217
+ stroke="currentColor"
218
+ stroke-width="1.6"
219
+ stroke-linecap="round"
220
+ stroke-linejoin="round"
221
+ >
222
+ <rect x="2" y="2" width="20" height="20" rx="5" />
223
+ <circle cx="12" cy="12" r="4" />
224
+ <circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
225
+ </svg>
226
+ </span>
227
+
228
+ <span class="dcs-lite-embed__scrim" aria-hidden="true" />
229
+
230
+ <span class="dcs-lite-embed__play" aria-hidden="true">
231
+ <svg
232
+ v-if="type === 'instagram'"
233
+ class="dcs-lite-embed__play-icon"
234
+ viewBox="0 0 24 24"
235
+ width="30"
236
+ height="30"
237
+ fill="none"
238
+ stroke="currentColor"
239
+ stroke-width="2"
240
+ stroke-linecap="round"
241
+ stroke-linejoin="round"
242
+ >
243
+ <rect x="3" y="3" width="18" height="18" rx="5" />
244
+ <circle cx="12" cy="12" r="3.5" />
245
+ <circle cx="17.5" cy="6.5" r="1.1" fill="currentColor" stroke="none" />
246
+ </svg>
247
+ <svg
248
+ v-else
249
+ class="dcs-lite-embed__play-icon"
250
+ viewBox="0 0 24 24"
251
+ width="30"
252
+ height="30"
253
+ fill="currentColor"
254
+ aria-hidden="true"
255
+ >
256
+ <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14z" />
257
+ </svg>
258
+ </span>
259
+ </button>
260
+ </template>
261
+
262
+ <slot v-else>
263
+ <video
264
+ v-if="type === 'video'"
265
+ class="dcs-lite-embed__media"
266
+ :src="url"
267
+ :aria-label="alt || undefined"
268
+ controls
269
+ preload="metadata"
270
+ />
271
+ <!-- Media fallback is for genuine media types only. An embed type that
272
+ reached here failed url validation → render nothing, never a broken
273
+ <img> carrying the untrusted url. -->
274
+ <img
275
+ v-else-if="!isEmbedType"
276
+ class="dcs-lite-embed__media"
277
+ :src="url"
278
+ :alt="alt"
279
+ loading="lazy"
280
+ >
281
+ </slot>
282
+ </div>
283
+ </template>
284
+
285
+ <style scoped>
286
+ .dcs-lite-embed {
287
+ position: relative;
288
+ display: block;
289
+ width: 100%;
290
+ overflow: hidden;
291
+ aspect-ratio: var(--dcs-lite-embed-aspect, 16 / 9);
292
+ background: var(--dcs-lite-embed-bg, #0b1220);
293
+ }
294
+
295
+ .dcs-lite-embed__frame,
296
+ .dcs-lite-embed__media,
297
+ .dcs-lite-embed__poster {
298
+ position: absolute;
299
+ inset: 0;
300
+ width: 100%;
301
+ height: 100%;
302
+ border: 0;
303
+ }
304
+
305
+ .dcs-lite-embed__media,
306
+ .dcs-lite-embed__poster {
307
+ object-fit: cover;
308
+ }
309
+
310
+ .dcs-lite-embed__button {
311
+ position: absolute;
312
+ inset: 0;
313
+ width: 100%;
314
+ height: 100%;
315
+ margin: 0;
316
+ padding: 0;
317
+ border: 0;
318
+ cursor: pointer;
319
+ background: transparent;
320
+ color: #ffffff;
321
+ display: block;
322
+ -webkit-tap-highlight-color: transparent;
323
+ }
324
+
325
+ /* Branded fallback poster (Instagram has no documented thumbnail endpoint). */
326
+ .dcs-lite-embed__poster--brand {
327
+ display: flex;
328
+ align-items: center;
329
+ justify-content: center;
330
+ color: rgba(255, 255, 255, 0.85);
331
+ }
332
+
333
+ .dcs-lite-embed__poster--instagram {
334
+ background: radial-gradient(circle at 30% 107%, #fdf497 0%, #fd5949 45%, #d6249f 60%, #285aeb 90%);
335
+ }
336
+
337
+ .dcs-lite-embed__scrim {
338
+ position: absolute;
339
+ inset: 0;
340
+ background: linear-gradient(to top, rgba(2, 6, 23, 0.35) 0%, rgba(2, 6, 23, 0) 45%);
341
+ opacity: 0.55;
342
+ transition: opacity var(--motion-duration-sm, 160ms) var(--motion-ease-standard, ease);
343
+ }
344
+
345
+ .dcs-lite-embed__play {
346
+ position: absolute;
347
+ top: 50%;
348
+ left: 50%;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ width: 4.25rem;
353
+ height: 4.25rem;
354
+ transform: translate(-50%, -50%);
355
+ border-radius: 9999px;
356
+ background: var(--dcs-lite-embed-play-bg, rgba(15, 23, 42, 0.72));
357
+ box-shadow: 0 6px 20px rgba(2, 6, 23, 0.35);
358
+ transition:
359
+ transform var(--motion-duration-sm, 160ms) var(--motion-ease-standard, ease),
360
+ background var(--motion-duration-sm, 160ms) var(--motion-ease-standard, ease);
361
+ }
362
+
363
+ .dcs-lite-embed__play-icon {
364
+ transform: translateX(1px);
365
+ color: #ffffff;
366
+ }
367
+
368
+ .dcs-lite-embed--embed[data-dcs-lite-embed='instagram'] .dcs-lite-embed__play {
369
+ background: var(--dcs-lite-embed-play-bg, rgba(15, 23, 42, 0.55));
370
+ }
371
+
372
+ .dcs-lite-embed__button:hover .dcs-lite-embed__play,
373
+ .dcs-lite-embed__button:focus-visible .dcs-lite-embed__play {
374
+ transform: translate(-50%, -50%) scale(1.06);
375
+ background: var(--dcs-lite-embed-play-bg-hover, #ef4444);
376
+ }
377
+
378
+ .dcs-lite-embed__button:hover .dcs-lite-embed__scrim,
379
+ .dcs-lite-embed__button:focus-visible .dcs-lite-embed__scrim {
380
+ opacity: 0.85;
381
+ }
382
+
383
+ .dcs-lite-embed__button:focus-visible {
384
+ outline: 3px solid var(--dcs-lite-embed-focus, #60a5fa);
385
+ outline-offset: -3px;
386
+ }
387
+
388
+ @media (prefers-reduced-motion: reduce) {
389
+ .dcs-lite-embed__scrim,
390
+ .dcs-lite-embed__play {
391
+ transition: none;
392
+ }
393
+
394
+ .dcs-lite-embed__button:hover .dcs-lite-embed__play,
395
+ .dcs-lite-embed__button:focus-visible .dcs-lite-embed__play {
396
+ transform: translate(-50%, -50%);
397
+ }
398
+ }
399
+ </style>
@@ -45,7 +45,12 @@ import { isCdnAssetUrl } from '@duffcloudservices/cms-core'
45
45
  export interface MediaCarouselItem {
46
46
  /** URL to the image, video file, or embed URL */
47
47
  url: string
48
- /** Type of media: 'image', 'video' (direct file), 'youtube', or 'instagram' */
48
+ /**
49
+ * Type of media: 'image', 'video' (direct file), 'youtube', or 'instagram'.
50
+ * Render `youtube`/`instagram` items through the click-to-load facade
51
+ * `LiteMediaEmbed` (`@duffcloudservices/cms/lite-media-embed`) so the heavy
52
+ * player iframe loads only on user interaction — never eagerly.
53
+ */
49
54
  type: 'image' | 'video' | 'youtube' | 'instagram'
50
55
  /** Accessibility alt text */
51
56
  alt?: string