@motion-proto/live-tokens 0.85.0 → 0.86.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/component-editor/SlidePagerEditor.svelte +118 -0
  5. package/src/editor/component-editor/VideoFrameEditor.svelte +73 -0
  6. package/src/editor/component-editor/VideoLightboxEditor.svelte +95 -0
  7. package/src/editor/component-editor/registry.ts +40 -1
  8. package/src/editor/core/sketch/sketchLayer.ts +7 -0
  9. package/src/editor/overlay/LiveEditorOverlay.svelte +45 -52
  10. package/src/editor/pages/ComponentEditorPage.svelte +1 -1
  11. package/src/editor/ui/ThemePanel.svelte +9 -53
  12. package/src/live-tokens/data/themes/autumn.json +86 -0
  13. package/src/live-tokens/data/themes/halloween.json +86 -0
  14. package/src/live-tokens/data/themes/midnight-study.json +86 -0
  15. package/src/live-tokens/data/themes/ocean.json +86 -0
  16. package/src/live-tokens/data/themes/royal-velvet.json +86 -0
  17. package/src/live-tokens/data/themes/sketchy.json +86 -0
  18. package/src/live-tokens/data/themes/spring-meadow.json +86 -0
  19. package/src/live-tokens/data/themes/sunset.json +86 -0
  20. package/src/system/assets/slide-04.webp +0 -0
  21. package/src/system/assets/slide-05.webp +0 -0
  22. package/src/system/assets/slide-06.webp +0 -0
  23. package/src/system/assets/watch-navigation-poster.webp +0 -0
  24. package/src/system/assets/watch-navigation.mp4 +0 -0
  25. package/src/system/components/ImageLightbox.svelte +20 -21
  26. package/src/system/components/SlidePager.svelte +516 -0
  27. package/src/system/components/VideoFrame.svelte +284 -0
  28. package/src/system/components/VideoLightbox.svelte +381 -0
  29. package/src/testing-js/{chunk-RDHCBQ6I.js → chunk-OHU5BHGZ.js} +344 -2
  30. package/src/testing-js/chunk-OHU5BHGZ.js.map +1 -0
  31. package/src/testing-js/{chunk-XV5CYADO.js → chunk-Y6R7Z7VK.js} +10 -6
  32. package/src/testing-js/chunk-Y6R7Z7VK.js.map +1 -0
  33. package/src/testing-js/component-behavior.contract.js +1 -1
  34. package/src/testing-js/component-editor.contract.js +1 -1
  35. package/src/testing-js/component-render.contract.js +1 -1
  36. package/src/testing-js/index.d.ts +2 -2
  37. package/src/testing-js/index.js +2 -2
  38. package/src/testing-js/page-compliance.contract.js +1 -1
  39. package/src/testing-js/{vitest-BMLIbDs2.d.ts → vitest-5vWtqwWe.d.ts} +2 -2
  40. package/src/testing-js/vitest.d.ts +1 -1
  41. package/src/testing-js/vitest.js +2 -2
  42. package/src/testing-js/chunk-RDHCBQ6I.js.map +0 -1
  43. package/src/testing-js/chunk-XV5CYADO.js.map +0 -1
@@ -0,0 +1,284 @@
1
+ <script module lang="ts">
2
+ import type { CatalogueEntry } from '../../editor/component-editor/scaffolding/types';
3
+
4
+ export const catalogue = {
5
+ description: 'A poster frame that plays its clip in place when clicked.',
6
+ whenToUse:
7
+ 'footage whose motion is the point, standing in a layout where a still would otherwise sit.',
8
+ whenNotToUse: [
9
+ { when: 'a picture whose detail is the point.', use: 'imagelightbox' },
10
+ { when: 'decoration.', use: 'image' },
11
+ { when: 'a clip the reader opens over the page from a thumbnail.', use: 'videolightbox' }
12
+ ],
13
+ constraints: ['A clip the deploy does not serve falls back to its poster.'],
14
+ props: {
15
+ chrome:
16
+ '`badge` shows the play badge and hands playback to the native controls. `none` drops both, and a click toggles play.',
17
+ fit: '`fill` scales the clip to the frame. `natural` holds it at its own size on the frame’s ground, which wants width and height.',
18
+ loop: 'true repeats the clip.'
19
+ }
20
+ } satisfies CatalogueEntry;
21
+ </script>
22
+
23
+ <script lang="ts">
24
+ interface Props {
25
+ /** The clip. Served as a static file, so a missing one is survivable. */
26
+ src: string;
27
+ /** The still shown until play, and the fallback when the clip is absent. */
28
+ poster: string;
29
+ alt?: string;
30
+ /** Natural size of the poster. Sets the box before anything loads. */
31
+ width?: number;
32
+ height?: number;
33
+ maxWidth?: number | string;
34
+ loop?: boolean;
35
+ /** "none" drops the badge and the native controls; a click then toggles play. */
36
+ chrome?: 'badge' | 'none';
37
+ /** "natural" holds the clip at its own pixel size on the frame's ground. Needs width and height. */
38
+ fit?: 'fill' | 'natural';
39
+ class?: string;
40
+ }
41
+
42
+ let {
43
+ src,
44
+ poster,
45
+ alt = '',
46
+ width = undefined,
47
+ height = undefined,
48
+ maxWidth = undefined,
49
+ loop = false,
50
+ chrome = 'badge',
51
+ fit = 'fill',
52
+ class: className = ''
53
+ }: Props = $props();
54
+
55
+ let videoEl: HTMLVideoElement | undefined = $state();
56
+ let started = $state(false);
57
+ let playing = $state(false);
58
+ // A clip the deploy does not serve falls back to the poster. A HEAD answers
59
+ // that on its own; a load error would not, since it fires the same way for a
60
+ // codec the browser cannot decode. An SPA rewrite answers 200 with
61
+ // index.html for a path it has no file for, so the content type settles it.
62
+ let missing = $state(false);
63
+
64
+ $effect(() => {
65
+ let live = true;
66
+ fetch(src, { method: 'HEAD' })
67
+ .then((res) => {
68
+ const type = res.headers.get('content-type') ?? '';
69
+ if (live) missing = !res.ok || !type.startsWith('video/');
70
+ })
71
+ .catch(() => {
72
+ if (live) missing = true;
73
+ });
74
+ return () => {
75
+ live = false;
76
+ };
77
+ });
78
+
79
+ const ratio = $derived(width && height ? `${width} / ${height}` : undefined);
80
+ const cap = $derived(typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth);
81
+
82
+ function start() {
83
+ started = true;
84
+ videoEl?.play();
85
+ }
86
+
87
+ function toggle() {
88
+ if (!videoEl) return;
89
+ started = true;
90
+ if (videoEl.paused) videoEl.play();
91
+ else videoEl.pause();
92
+ }
93
+ </script>
94
+
95
+ <div class="videoframe" style:max-width={cap}>
96
+ <div
97
+ class="videoframe-frame sketch-surface {className}"
98
+ style:aspect-ratio={ratio}
99
+ data-playing={started ? 'true' : null}
100
+ >
101
+ <div class="videoframe-clip" class:is-natural={fit === 'natural'}>
102
+ {#if missing}
103
+ <img src={poster} {alt} {width} {height} />
104
+ {:else}
105
+ <!-- No caption track ships with the source footage. -->
106
+ <!-- svelte-ignore a11y_media_has_caption -->
107
+ <video
108
+ bind:this={videoEl}
109
+ {src}
110
+ {poster}
111
+ {loop}
112
+ preload="metadata"
113
+ playsinline
114
+ controls={started && chrome === 'badge'}
115
+ onplay={() => (playing = true)}
116
+ onpause={() => (playing = false)}
117
+ ></video>
118
+ {/if}
119
+ </div>
120
+
121
+ {#if !missing && (chrome === 'none' || !started)}
122
+ <button
123
+ type="button"
124
+ class="videoframe-trigger"
125
+ onclick={chrome === 'none' ? toggle : start}
126
+ >
127
+ <span class="visually-hidden">{playing ? 'Pause' : 'Play'}: {alt}</span>
128
+ {#if chrome === 'badge'}
129
+ <span class="videoframe-badge sketch-chip" aria-hidden="true">
130
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.5v13l11-6.5z" /></svg>
131
+ </span>
132
+ {/if}
133
+ </button>
134
+ {/if}
135
+ </div>
136
+ </div>
137
+
138
+ <style>
139
+ :global(:root) {
140
+ --videoframe-frame-surface: var(--surface-neutral-lowest);
141
+ /* The ground behind the clip. Only shows where the clip leaves the frame
142
+ unfilled, so it wants the footage's own background colour. */
143
+ --videoframe-clip-surface: var(--color-transparent);
144
+ --videoframe-frame-border: var(--border-neutral);
145
+ --videoframe-frame-border-width: var(--border-width-1);
146
+ --videoframe-frame-radius: var(--radius-2xl);
147
+
148
+ --videoframe-badge-default-surface: var(--surface-brand-lower);
149
+ --videoframe-badge-default-border: var(--border-brand-medium);
150
+ --videoframe-badge-default-border-width: var(--border-width-2);
151
+ --videoframe-badge-default-icon: var(--text-primary);
152
+ --videoframe-badge-default-size: var(--space-64);
153
+ --videoframe-badge-default-icon-size: var(--icon-size-4xl);
154
+ --videoframe-badge-duration: var(--duration-200);
155
+ --videoframe-badge-easing: var(--ease-out-quad);
156
+
157
+ --videoframe-badge-hover-surface: var(--surface-brand);
158
+ --videoframe-badge-hover-border: var(--border-brand-strong);
159
+ --videoframe-badge-hover-icon: var(--text-primary);
160
+ }
161
+
162
+ .videoframe {
163
+ display: block;
164
+ width: 100%;
165
+ margin-inline: auto;
166
+ }
167
+
168
+ .videoframe-frame {
169
+ position: relative;
170
+ width: 100%;
171
+ background: var(--videoframe-frame-surface);
172
+ border: var(--videoframe-frame-border-width) solid var(--videoframe-frame-border);
173
+ border-radius: var(--videoframe-frame-radius);
174
+
175
+ --sketch-fill: var(--videoframe-frame-surface);
176
+ --sketch-stroke: var(--videoframe-frame-border);
177
+ --sketch-hatch-color: var(--videoframe-frame-border);
178
+ --sketch-radius: var(--videoframe-frame-radius);
179
+ --sketch-shadow: none;
180
+ }
181
+
182
+ /* The clip lives one level in: the sketch layer forces the drawn part's
183
+ overflow visible, which would let square media corners escape the frame. */
184
+ .videoframe-clip {
185
+ overflow: hidden;
186
+ background: var(--videoframe-clip-surface);
187
+ border-radius: var(--sketch-radius, var(--videoframe-frame-radius));
188
+ }
189
+
190
+ .videoframe-clip img,
191
+ .videoframe-clip video {
192
+ display: block;
193
+ width: 100%;
194
+ height: 100%;
195
+ object-fit: contain;
196
+ }
197
+
198
+ /* Absolute, so the frame's aspect-ratio box stays the pad the clip sits in. */
199
+ .videoframe-clip.is-natural {
200
+ position: absolute;
201
+ inset: 0;
202
+ display: grid;
203
+ place-items: center;
204
+ }
205
+
206
+ .videoframe-clip.is-natural img,
207
+ .videoframe-clip.is-natural video {
208
+ width: auto;
209
+ height: auto;
210
+ max-width: 100%;
211
+ max-height: 100%;
212
+ }
213
+
214
+ .videoframe-trigger {
215
+ position: absolute;
216
+ inset: 0;
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ padding: 0;
221
+ background: none;
222
+ border: none;
223
+ cursor: pointer;
224
+ }
225
+
226
+ .videoframe-trigger:focus-visible {
227
+ outline: none;
228
+ box-shadow: var(--ring-focus-md);
229
+ border-radius: var(--sketch-radius, var(--videoframe-frame-radius));
230
+ }
231
+
232
+ .videoframe-badge {
233
+ box-sizing: border-box;
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: center;
237
+ width: var(--videoframe-badge-default-size);
238
+ height: var(--videoframe-badge-default-size);
239
+ color: var(--videoframe-badge-default-icon);
240
+ background: var(--videoframe-badge-default-surface);
241
+ border: var(--videoframe-badge-default-border-width) solid
242
+ var(--videoframe-badge-default-border);
243
+ border-radius: var(--radius-full);
244
+ transition:
245
+ background var(--videoframe-badge-duration) var(--videoframe-badge-easing),
246
+ border-color var(--videoframe-badge-duration) var(--videoframe-badge-easing),
247
+ color var(--videoframe-badge-duration) var(--videoframe-badge-easing);
248
+
249
+ --sketch-fill: var(--videoframe-badge-default-surface);
250
+ --sketch-stroke: var(--videoframe-badge-default-border);
251
+ --sketch-hatch-color: var(--videoframe-badge-default-border);
252
+ --sketch-radius: var(--radius-full);
253
+ --sketch-shadow: none;
254
+ /* Travel is stated in px, and the play triangle is small enough to tear. */
255
+ --sketch-icon-off: var(--sketch-icon-soft);
256
+ }
257
+
258
+ .videoframe-badge svg {
259
+ width: var(--videoframe-badge-default-icon-size);
260
+ height: var(--videoframe-badge-default-icon-size);
261
+ /* The triangle's mass sits left of centre; nudge it back onto the axis. */
262
+ margin-inline-start: 6%;
263
+ }
264
+
265
+ .videoframe-trigger:hover .videoframe-badge,
266
+ .videoframe-frame.force-hover .videoframe-badge {
267
+ color: var(--videoframe-badge-hover-icon);
268
+ background: var(--videoframe-badge-hover-surface);
269
+ border-color: var(--videoframe-badge-hover-border);
270
+
271
+ --sketch-fill: var(--videoframe-badge-hover-surface);
272
+ --sketch-stroke: var(--videoframe-badge-hover-border);
273
+ --sketch-hatch-color: var(--videoframe-badge-hover-border);
274
+ }
275
+
276
+ .visually-hidden {
277
+ position: absolute;
278
+ width: 1px;
279
+ height: 1px;
280
+ overflow: hidden;
281
+ clip-path: inset(50%);
282
+ white-space: nowrap;
283
+ }
284
+ </style>
@@ -0,0 +1,381 @@
1
+ <script module lang="ts">
2
+ import type { CatalogueEntry } from '../../editor/component-editor/scaffolding/types';
3
+
4
+ export const catalogue = {
5
+ description:
6
+ 'A small still that fades the page away and plays its clip in the middle of the screen when clicked.',
7
+ whenToUse:
8
+ 'a short clip on a plain ground, such as a product shot, that the reader opens from a thumbnail beside the copy.',
9
+ whenNotToUse: [
10
+ { when: 'a picture whose detail is the point.', use: 'imagelightbox' },
11
+ { when: 'footage that plays in the layout at full size, with no thumbnail to open.', use: 'videoframe' }
12
+ ],
13
+ props: {
14
+ inline: 'true renders the open lightbox in flow, without the thumbnail. The editor preview uses it.',
15
+ loop: 'true repeats the clip.',
16
+ hoverPlay:
17
+ 'true plays the thumbnail muted under the pointer, and rewinds it on the way out. A reader who asked for less motion keeps the still.',
18
+ stretch:
19
+ 'true spreads the trigger over the nearest positioned ancestor, so a whole card opens the clip. The consumer positions that ancestor.',
20
+ thumbAspect:
21
+ 'the ratio the thumbnail box holds, when it should not be the clip\'s own. The clip still opens at its full frame.',
22
+ thumbFit:
23
+ '`cover` fills a thumbnail box the clip does not share the shape of, and crops what will not fit. `contain` keeps the whole frame and letterboxes it.',
24
+ previewStart:
25
+ 'seconds into the clip the hover preview starts, for footage that opens on a title card. The clip itself still opens at the beginning.'
26
+ }
27
+ } satisfies CatalogueEntry;
28
+ </script>
29
+
30
+ <script lang="ts">
31
+ import { tick } from 'svelte';
32
+ import { portal } from '../internal/portal';
33
+
34
+ interface Props {
35
+ src: string;
36
+ /** The thumbnail, and the frame shown before the clip starts. */
37
+ poster: string;
38
+ alt?: string;
39
+ /** Natural size of the clip. Sets the thumbnail's box and the open size. */
40
+ width?: number;
41
+ height?: number;
42
+ loop?: boolean;
43
+ /** Renders the open lightbox in flow, without the thumbnail. Used by the editor preview. */
44
+ inline?: boolean;
45
+ /** Plays the thumbnail muted under the pointer. Still for reduced motion. */
46
+ hoverPlay?: boolean;
47
+ /** Spreads the hit area over the nearest positioned ancestor. */
48
+ stretch?: boolean;
49
+ /** The thumbnail box's ratio, when it differs from the clip's own. */
50
+ thumbAspect?: number;
51
+ /** How the still meets a box it does not share the shape of. */
52
+ thumbFit?: 'contain' | 'cover';
53
+ /** Seconds in that the hover preview starts. The open clip still plays from 0. */
54
+ previewStart?: number;
55
+ /** Editor preview hook: `force-hover` on an `inline` lightbox paints the close button's hover. */
56
+ class?: string;
57
+ }
58
+
59
+ let {
60
+ src,
61
+ poster,
62
+ alt = '',
63
+ width = undefined,
64
+ height = undefined,
65
+ loop = false,
66
+ inline = false,
67
+ hoverPlay = false,
68
+ stretch = false,
69
+ thumbAspect = undefined,
70
+ thumbFit = 'contain',
71
+ previewStart = 0,
72
+ class: className = ''
73
+ }: Props = $props();
74
+
75
+ let thumbEl: HTMLVideoElement | undefined = $state();
76
+
77
+ function previewIn() {
78
+ if (!thumbEl || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
79
+ if (thumbEl.currentTime < previewStart) thumbEl.currentTime = previewStart;
80
+ thumbEl.play().catch(() => {});
81
+ }
82
+
83
+ function previewOut() {
84
+ if (!thumbEl) return;
85
+ thumbEl.pause();
86
+ thumbEl.currentTime = previewStart;
87
+ }
88
+
89
+ let triggerEl: HTMLButtonElement | undefined = $state();
90
+ let modalEl: HTMLDivElement | undefined = $state();
91
+ let videoEl: HTMLVideoElement | undefined = $state();
92
+ let closeBtnEl: HTMLButtonElement | undefined = $state();
93
+ // `mounted` keeps the lightbox in the DOM through the fade out, `shown`
94
+ // drives the fade, and `open` is what the trigger reports.
95
+ let mounted = $state(false);
96
+ let shown = $state(false);
97
+ let open = $state(false);
98
+
99
+ const ratio = $derived(width && height ? width / height : undefined);
100
+ // The box the still sits in; the clip itself always opens at `ratio`.
101
+ const tileRatio = $derived(thumbAspect ?? ratio);
102
+
103
+ async function openLightbox() {
104
+ if (open) return;
105
+ open = true;
106
+ mounted = true;
107
+ await tick();
108
+ if (!modalEl) return;
109
+ // Commit the transparent start so the class change below fades in.
110
+ modalEl.getBoundingClientRect();
111
+ shown = true;
112
+ modalEl.focus();
113
+ videoEl?.play();
114
+ }
115
+
116
+ async function closeLightbox() {
117
+ if (!open || !modalEl) return;
118
+ open = false;
119
+ shown = false;
120
+ videoEl?.pause();
121
+ await tick();
122
+ await Promise.all(modalEl.getAnimations().map((a) => a.finished)).catch(() => {});
123
+ if (open) return;
124
+ mounted = false;
125
+ triggerEl?.focus();
126
+ }
127
+
128
+ // A page may act on keys it hears at the window, such as a deck turning
129
+ // slides, so the lightbox keeps every key to itself while it is open.
130
+ function onModalKey(e: KeyboardEvent) {
131
+ e.stopPropagation();
132
+ if (e.key === 'Escape') {
133
+ closeLightbox();
134
+ } else if (e.key === ' ' && e.target === modalEl) {
135
+ e.preventDefault();
136
+ if (videoEl?.paused) videoEl.play();
137
+ else videoEl?.pause();
138
+ } else if (e.key === 'Tab') {
139
+ e.preventDefault();
140
+ (document.activeElement === closeBtnEl ? modalEl : closeBtnEl)?.focus();
141
+ }
142
+ }
143
+ </script>
144
+
145
+ {#if inline}
146
+ <div class="videolightbox-modal inline shown {className}" style:--videolightbox-ratio={ratio}>
147
+ <!-- svelte-ignore a11y_media_has_caption -->
148
+ <video class="videolightbox-video" {src} {poster} preload="metadata" playsinline></video>
149
+ <button
150
+ type="button"
151
+ class="videolightbox-close"
152
+ aria-label="Close" tabindex="-1"
153
+ >
154
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
155
+ <path d="M18 6L6 18" />
156
+ <path d="M6 6l12 12" />
157
+ </svg>
158
+ </button>
159
+ </div>
160
+ {:else}
161
+ <div
162
+ class="videolightbox {className}"
163
+ style:aspect-ratio={tileRatio}
164
+ style:--videolightbox-thumb-fit={thumbFit}
165
+ >
166
+ <button
167
+ bind:this={triggerEl}
168
+ type="button"
169
+ class="videolightbox-trigger"
170
+ class:stretch
171
+ aria-label={alt ? `Play video: ${alt}` : 'Play video'}
172
+ aria-haspopup="dialog"
173
+ aria-expanded={open}
174
+ onclick={openLightbox}
175
+ onpointerenter={hoverPlay ? previewIn : undefined}
176
+ onpointerleave={hoverPlay ? previewOut : undefined}
177
+ onfocus={hoverPlay ? previewIn : undefined}
178
+ onblur={hoverPlay ? previewOut : undefined}
179
+ >
180
+ {#if hoverPlay}
181
+ <!-- Muted and silent by design; the sound belongs to the open clip. -->
182
+ <!-- svelte-ignore a11y_media_has_caption -->
183
+ <video
184
+ bind:this={thumbEl}
185
+ class="videolightbox-thumb"
186
+ {src}
187
+ {poster}
188
+ {width}
189
+ {height}
190
+ muted
191
+ loop
192
+ playsinline
193
+ preload="metadata"
194
+ ></video>
195
+ {:else}
196
+ <img src={poster} alt="" {width} {height} draggable="false" />
197
+ {/if}
198
+ </button>
199
+ </div>
200
+
201
+ {#if mounted}
202
+ <!-- Clicking anywhere closes it, the clip included; the close button and
203
+ Escape do the same. -->
204
+ <div
205
+ bind:this={modalEl}
206
+ class="videolightbox-modal"
207
+ class:shown
208
+ role="dialog"
209
+ aria-modal="true"
210
+ aria-label={alt}
211
+ tabindex="-1"
212
+ style:--videolightbox-ratio={ratio}
213
+ style:--videolightbox-natural-width={width ? `${width}px` : undefined}
214
+ use:portal
215
+ onclick={closeLightbox}
216
+ onkeydown={onModalKey}
217
+ >
218
+ <!-- No caption track ships with the source footage. -->
219
+ <!-- svelte-ignore a11y_media_has_caption -->
220
+ <video bind:this={videoEl} class="videolightbox-video" {src} {poster} {loop} playsinline></video>
221
+ <button
222
+ bind:this={closeBtnEl}
223
+ type="button"
224
+ class="videolightbox-close"
225
+ aria-label="Close"
226
+ onclick={closeLightbox}
227
+ >
228
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
229
+ <path d="M18 6L6 18" />
230
+ <path d="M6 6l12 12" />
231
+ </svg>
232
+ </button>
233
+ </div>
234
+ {/if}
235
+ {/if}
236
+
237
+ <style>
238
+ :global(:root) {
239
+ --videolightbox-tile-surface: var(--color-transparent);
240
+ --videolightbox-tile-border: var(--color-transparent);
241
+ --videolightbox-tile-border-width: var(--border-width-0);
242
+ --videolightbox-tile-radius: var(--radius-2xl);
243
+ --videolightbox-tile-shadow: var(--shadow-md);
244
+
245
+ --videolightbox-scrim-surface: var(--scrim-high);
246
+ --videolightbox-overlay-padding: var(--space-64);
247
+ --videolightbox-overlay-duration: var(--duration-500);
248
+ --videolightbox-overlay-easing: var(--ease-out-quad);
249
+
250
+ --videolightbox-chrome-surface: var(--surface-neutral-low);
251
+ --videolightbox-chrome-border: var(--border-brand);
252
+ --videolightbox-chrome-border-width: var(--border-width-1);
253
+ --videolightbox-chrome-radius: var(--radius-full);
254
+ --videolightbox-chrome-icon: var(--text-primary);
255
+ --videolightbox-chrome-hover-surface: var(--surface-brand-high);
256
+ }
257
+
258
+ .videolightbox {
259
+ width: 100%;
260
+ background: var(--videolightbox-tile-surface);
261
+ border: var(--videolightbox-tile-border-width) solid var(--videolightbox-tile-border);
262
+ border-radius: var(--videolightbox-tile-radius);
263
+ box-shadow: var(--videolightbox-tile-shadow);
264
+ }
265
+
266
+ /* The clip lives one level in: the sketch layer forces the drawn part's
267
+ overflow visible, which would let the picture's square corners escape. */
268
+ .videolightbox-trigger {
269
+ display: block;
270
+ width: 100%;
271
+ height: 100%;
272
+ padding: 0;
273
+ border: none;
274
+ background: none;
275
+ overflow: hidden;
276
+ border-radius: var(--sketch-radius, var(--videolightbox-tile-radius));
277
+ cursor: pointer;
278
+ }
279
+
280
+ /* The pad is laid over the ancestor the consumer positioned, so the one
281
+ button keeps the label and the whole card answers the pointer. It rides
282
+ above the consumer's own content, or copy beside the still would swallow
283
+ the pointer before the trigger heard it.
284
+ A transform on anything between here and that ancestor would make itself
285
+ the containing block and shrink the pad back to the still. */
286
+ .videolightbox-trigger.stretch::after {
287
+ content: '';
288
+ position: absolute;
289
+ inset: 0;
290
+ z-index: 1;
291
+ }
292
+
293
+ .videolightbox-trigger:focus-visible {
294
+ outline: none;
295
+ box-shadow: var(--ring-focus-md);
296
+ }
297
+
298
+ .videolightbox-trigger img,
299
+ .videolightbox-trigger .videolightbox-thumb {
300
+ display: block;
301
+ width: 100%;
302
+ height: 100%;
303
+ object-fit: var(--videolightbox-thumb-fit, contain);
304
+ user-select: none;
305
+ }
306
+
307
+ .videolightbox-modal {
308
+ position: fixed;
309
+ inset: 0;
310
+ z-index: var(--z-modal);
311
+ box-sizing: border-box;
312
+ display: grid;
313
+ place-items: center;
314
+ padding: var(--videolightbox-overlay-padding);
315
+ background: var(--videolightbox-scrim-surface);
316
+ container-type: size;
317
+ opacity: 0;
318
+ transition: opacity var(--videolightbox-overlay-duration) var(--videolightbox-overlay-easing);
319
+ cursor: pointer;
320
+ }
321
+
322
+ .videolightbox-modal.shown {
323
+ opacity: 1;
324
+ }
325
+
326
+ .videolightbox-modal:focus {
327
+ outline: none;
328
+ }
329
+
330
+ .videolightbox-modal.inline {
331
+ position: relative;
332
+ inset: auto;
333
+ z-index: auto;
334
+ width: 100%;
335
+ aspect-ratio: 16 / 9;
336
+ cursor: default;
337
+ }
338
+
339
+ .videolightbox-close {
340
+ position: absolute;
341
+ top: var(--space-24);
342
+ right: var(--space-24);
343
+ /* 2.75rem = 44px, the min touch-target floor. */
344
+ width: 2.75rem;
345
+ height: 2.75rem;
346
+ display: flex;
347
+ align-items: center;
348
+ justify-content: center;
349
+ padding: 0;
350
+ background: var(--videolightbox-chrome-surface);
351
+ border: var(--videolightbox-chrome-border-width) solid var(--videolightbox-chrome-border);
352
+ border-radius: var(--videolightbox-chrome-radius);
353
+ color: var(--videolightbox-chrome-icon);
354
+ backdrop-filter: blur(var(--blur-md));
355
+ cursor: pointer;
356
+ transition: background var(--duration-200) ease;
357
+ }
358
+
359
+ .videolightbox-close:hover,
360
+ .videolightbox-modal:global(.force-hover) .videolightbox-close {
361
+ background: var(--videolightbox-chrome-hover-surface);
362
+ }
363
+
364
+ .videolightbox-close:focus-visible {
365
+ outline: none;
366
+ box-shadow: var(--ring-focus-md);
367
+ }
368
+
369
+ @media (prefers-reduced-motion: reduce) {
370
+ .videolightbox-modal {
371
+ transition: none;
372
+ }
373
+ }
374
+
375
+ /* The clip plays at its own pixel size and shrinks only to fit the screen. */
376
+ .videolightbox-video {
377
+ display: block;
378
+ width: min(var(--videolightbox-natural-width, 100cqw), 100cqw, 100cqh * var(--videolightbox-ratio, 16 / 9));
379
+ aspect-ratio: var(--videolightbox-ratio, 16 / 9);
380
+ }
381
+ </style>