ferst-core 0.6.2 → 0.7.2

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.
@@ -1,191 +1,447 @@
1
- ---
2
- /**
3
- * Hero — the first Level-2 recipe: a curated page-opening band that composes
4
- * primitives (Heading + Button) into a designed layout. Two presets: `centered`
5
- * (stacked and centred) and `split` (text beside an image). Recipes are locked
6
- * components so quality can't drift (BLOCK-ARCHITECTURE §6).
7
- */
8
- import Heading from '../blocks/Heading.astro';
9
- import Button from '../Button.astro';
10
-
11
- interface Props {
12
- eyebrow?: string;
13
- title: string;
14
- subtitle?: string;
15
- cta?: { label: string; href: string };
16
- image?: { src: string; alt?: string };
17
- media?: { image?: string; video?: string; poster?: string; overlay?: number };
18
- layout?: 'centered' | 'split' | 'media';
19
- background?: 'none' | 'mesh';
20
- }
21
- const { eyebrow, title, subtitle, cta, image, media, layout = 'centered', background = 'none' } = Astro.props;
22
- const isMedia = layout === 'media' && media && (media.video || media.image);
23
- const overlay = (media?.overlay ?? 45) / 100;
24
- const isMesh = background === 'mesh' && !isMedia;
25
- ---
26
-
27
- <section
28
- class="b-hero"
29
- data-layout={layout}
30
- data-bg={isMesh ? 'mesh' : undefined}
31
- style={isMedia ? `--hero-overlay: ${overlay}; ${media?.image && !media?.video ? `--hero-image: url('${media.image}')` : ''}` : undefined}
32
- >
33
- {isMedia && (
34
- <div class="b-hero__bg" aria-hidden="true">
35
- {media?.video ? (
36
- <video autoplay muted loop playsinline poster={media.poster}>
37
- <source src={media.video} />
38
- </video>
39
- ) : null}
40
- <span class="b-hero__scrim"></span>
41
- </div>
42
- )}
43
- {isMesh && <div class="b-hero__mesh" aria-hidden="true"></div>}
44
- <div class="b-hero__text">
45
- {eyebrow && <p class="b-hero__eyebrow">{eyebrow}</p>}
46
- <Heading text={title} level={1} align={layout === 'split' ? 'start' : 'center'} />
47
- {subtitle && <p class="b-hero__subtitle">{subtitle}</p>}
48
- {cta && (
49
- <div class="b-hero__cta">
50
- <Button href={cta.href} variant={layout === 'media' ? 'cta' : 'primary'}>{cta.label}</Button>
51
- </div>
52
- )}
53
- </div>
54
- {layout === 'split' && image && (
55
- <div class="b-hero__media">
56
- <img src={image.src} alt={image.alt ?? ''} loading="eager" decoding="async" />
57
- </div>
58
- )}
59
- </section>
60
-
61
- <style>
62
- .b-hero {
63
- display: grid;
64
- gap: 2rem;
65
- padding-block: 3rem;
66
- }
67
- @media (min-width: 48em) {
68
- .b-hero[data-layout='split'] {
69
- grid-template-columns: 1fr 1fr;
70
- align-items: center;
71
- }
72
- }
73
- .b-hero__text {
74
- display: flex;
75
- flex-direction: column;
76
- gap: 1rem;
77
- }
78
- .b-hero[data-layout='centered'] .b-hero__text {
79
- max-width: var(--max);
80
- margin-inline: auto;
81
- align-items: center;
82
- text-align: center;
83
- }
84
- .b-hero__eyebrow {
85
- margin: 0;
86
- text-transform: uppercase;
87
- letter-spacing: 0.12em;
88
- font-size: 0.8125rem;
89
- font-weight: 600;
90
- color: var(--gold);
91
- }
92
- .b-hero__subtitle {
93
- margin: 0;
94
- font-size: 1.125rem;
95
- line-height: 1.6;
96
- color: var(--muted);
97
- max-width: var(--max);
98
- }
99
- .b-hero__cta {
100
- margin-top: 0.5rem;
101
- }
102
-
103
- /* ── Mesh background: a soft, slowly drifting brand gradient behind the text.
104
- Low opacity + blur keeps normal (dark) text perfectly readable. ── */
105
- .b-hero[data-bg='mesh'] {
106
- position: relative;
107
- isolation: isolate;
108
- overflow: hidden;
109
- border-radius: var(--radius);
110
- padding: clamp(3rem, 7vw, 5rem) clamp(1.25rem, 4vw, 3rem);
111
- }
112
- .b-hero__mesh {
113
- position: absolute;
114
- inset: 0;
115
- z-index: -1;
116
- background: var(--bg);
117
- }
118
- .b-hero__mesh::before {
119
- content: '';
120
- position: absolute;
121
- inset: -25%;
122
- background:
123
- radial-gradient(35% 45% at 20% 25%, var(--gold), transparent 70%),
124
- radial-gradient(35% 45% at 80% 20%, var(--accent-2, var(--gold)), transparent 70%),
125
- radial-gradient(40% 50% at 65% 85%, var(--gold-light), transparent 70%);
126
- opacity: 0.35;
127
- filter: blur(50px);
128
- animation: b-hero-mesh 20s ease-in-out infinite alternate;
129
- }
130
- @keyframes b-hero-mesh {
131
- from { transform: translate3d(-3%, -2%, 0) scale(1.05); }
132
- to { transform: translate3d(3%, 4%, 0) scale(1.18); }
133
- }
134
- @media (prefers-reduced-motion: reduce) {
135
- .b-hero__mesh::before { animation: none; }
136
- }
137
- .b-hero__media img {
138
- display: block;
139
- width: 100%;
140
- height: auto;
141
- border-radius: 8px;
142
- }
143
-
144
- /* ── Media layout: full-bleed image/video background with a legibility scrim ── */
145
- .b-hero[data-layout='media'] {
146
- position: relative;
147
- min-height: min(68vh, 38rem);
148
- display: grid;
149
- place-items: center;
150
- text-align: center;
151
- padding: clamp(3rem, 8vw, 6rem) 1.5rem;
152
- border-radius: var(--radius);
153
- overflow: hidden;
154
- isolation: isolate;
155
- }
156
- .b-hero__bg {
157
- position: absolute;
158
- inset: 0;
159
- z-index: -1;
160
- background-image: var(--hero-image, none);
161
- background-size: cover;
162
- background-position: center;
163
- background-color: var(--bg-section);
164
- }
165
- .b-hero__bg video {
166
- width: 100%;
167
- height: 100%;
168
- object-fit: cover;
169
- }
170
- .b-hero__scrim {
171
- position: absolute;
172
- inset: 0;
173
- background: rgba(8, 10, 18, var(--hero-overlay, 0.45));
174
- }
175
- .b-hero[data-layout='media'] .b-hero__text {
176
- max-width: var(--max);
177
- align-items: center;
178
- color: #fff;
179
- }
180
- .b-hero[data-layout='media'] .b-hero__subtitle {
181
- color: rgba(255, 255, 255, 0.9);
182
- margin-inline: auto;
183
- }
184
- .b-hero[data-layout='media'] .b-hero__eyebrow {
185
- color: #fff;
186
- opacity: 0.9;
187
- }
188
- .b-hero[data-layout='media'] :global(h1) {
189
- color: #fff;
190
- }
191
- </style>
1
+ ---
2
+ /**
3
+ * Hero — the first Level-2 recipe: a curated page-opening band that composes
4
+ * primitives (Heading + Button) into a designed layout. Presets: `centered`
5
+ * (stacked and centred), `split` (text beside an image) and `media` (text over a
6
+ * background — a looping video, a slow crossfade of images, or one image). A
7
+ * media hero can be a band, `tall`, or fill the first `screen` edge to edge.
8
+ * Recipes are locked components so quality can't drift (BLOCK-ARCHITECTURE §6).
9
+ *
10
+ * Moving backgrounds (video, slideshow) are accessible by construction: a pause
11
+ * button (WCAG 2.2.2), still under prefers-reduced-motion (the poster / first
12
+ * image), and the slideshow pauses while off-screen or the tab is hidden.
13
+ */
14
+ import Heading from '../blocks/Heading.astro';
15
+ import Button from '../Button.astro';
16
+ import { backgroundEmbed, videoEmbed } from '../../lib/video';
17
+
18
+ interface Props {
19
+ eyebrow?: string;
20
+ title: string;
21
+ subtitle?: string;
22
+ cta?: { label: string; href: string };
23
+ image?: { src: string; alt?: string };
24
+ media?: { image?: string; images?: string[]; video?: string; poster?: string; overlay?: number; link?: string; linkLabel?: string };
25
+ layout?: 'centered' | 'split' | 'media';
26
+ size?: 'standard' | 'tall' | 'screen';
27
+ background?: 'none' | 'mesh';
28
+ }
29
+ const { eyebrow, title, subtitle, cta, image, media, layout = 'centered', size = 'standard', background = 'none' } = Astro.props;
30
+ // A YouTube / Vimeo link plays as an embedded background (after consent); anything else
31
+ // in `video` is a file for a native <video>.
32
+ const embed = media?.video ? backgroundEmbed(media.video) : null;
33
+ const slides = !media?.video && (media?.images?.length ?? 0) >= 2 ? media!.images! : [];
34
+ const still = media?.image ?? media?.images?.[0];
35
+ const isMedia = layout === 'media' && media && (media.video || slides.length || still);
36
+ const motion = isMedia ? (embed ? 'embed' : media?.video ? 'video' : slides.length ? 'slides' : undefined) : undefined;
37
+ const overlay = (media?.overlay ?? 45) / 100;
38
+ const isMesh = background === 'mesh' && !isMedia;
39
+ // The background can lead somewhere — e.g. the full video on YouTube (new tab; the app on phones).
40
+ const linkTo = media?.link ?? embed?.watchUrl; // an embedded video links to itself by default
41
+ const bgLink = isMedia && linkTo && /^(https?:\/\/|\/)/i.test(linkTo) ? linkTo : undefined;
42
+ const provider = bgLink ? videoEmbed(bgLink)?.provider : undefined;
43
+ const watchLabel = media?.linkLabel || (provider ? `Watch the full video on ${provider}` : 'Watch the full video');
44
+ // The still: the image (no motion), or an embed's poster until the player may load.
45
+ const stillSrc = embed ? media?.poster ?? still : !media?.video && !slides.length ? still : undefined;
46
+ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
47
+ ---
48
+
49
+ <section
50
+ class="b-hero"
51
+ data-layout={layout}
52
+ data-size={isMedia ? size : undefined}
53
+ data-bg={isMesh ? 'mesh' : undefined}
54
+ data-motion={motion}
55
+ data-linked={bgLink ? 'true' : undefined}
56
+ style={isMedia ? `--hero-overlay: ${overlay}; ${bgImage}` : undefined}
57
+ >
58
+ {isMedia && (
59
+ <div class="b-hero__bg" aria-hidden="true">
60
+ {embed && <div class="b-hero__embed" data-embed-url={embed.embedUrl} data-provider={embed.provider}></div>}
61
+ {media?.video && !embed ? (
62
+ <video autoplay muted loop playsinline poster={media.poster} preload="metadata">
63
+ <source src={media.video} />
64
+ </video>
65
+ ) : null}
66
+ {slides.map((src, i) => (
67
+ <span class:list={['b-hero__slide', { 'is-on': i === 0 }]} style={`background-image: url('${src}')`}></span>
68
+ ))}
69
+ <span class="b-hero__scrim"></span>
70
+ </div>
71
+ )}
72
+ {bgLink && <a class="b-hero__bglink" href={bgLink} target="_blank" rel="noopener" tabindex="-1" aria-hidden="true"></a>}
73
+ {isMesh && <div class="b-hero__mesh" aria-hidden="true"></div>}
74
+ <div class="b-hero__text">
75
+ {eyebrow && <p class="b-hero__eyebrow">{eyebrow}</p>}
76
+ <Heading text={title} level={1} align={layout === 'split' ? 'start' : 'center'} />
77
+ {subtitle && <p class="b-hero__subtitle">{subtitle}</p>}
78
+ {cta && (
79
+ <div class="b-hero__cta">
80
+ <Button href={cta.href} variant={layout === 'media' ? 'cta' : 'primary'}>{cta.label}</Button>
81
+ </div>
82
+ )}
83
+ </div>
84
+ {bgLink && (
85
+ <a class="b-hero__watch" href={bgLink} target="_blank" rel="noopener">
86
+ <span class="b-hero__watch-icon" aria-hidden="true"></span>{watchLabel}<span class="b-hero__sr"> (opens in a new tab)</span>
87
+ </a>
88
+ )}
89
+ {motion && (
90
+ <button type="button" class="b-hero__pause" aria-pressed="false" aria-label="Pause background" hidden={motion === 'embed'}>
91
+ <span class="b-hero__pause-icon" aria-hidden="true"></span>
92
+ </button>
93
+ )}
94
+ {layout === 'split' && image && (
95
+ <div class="b-hero__media">
96
+ <img src={image.src} alt={image.alt ?? ''} loading="eager" decoding="async" />
97
+ </div>
98
+ )}
99
+ </section>
100
+
101
+ <script>
102
+ // Moving hero backgrounds: pause button, reduced-motion, the slideshow timer, and an
103
+ // embedded (YouTube / Vimeo) background that loads only once the visitor consents.
104
+ import { hasConsent, onConsentChange } from '../../lib/consent-client';
105
+ const SLIDE_MS = 6000;
106
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
107
+ for (const hero of document.querySelectorAll<HTMLElement>('.b-hero[data-motion]')) {
108
+ const button = hero.querySelector<HTMLButtonElement>('.b-hero__pause');
109
+ const video = hero.querySelector('video');
110
+ const slides = [...hero.querySelectorAll<HTMLElement>('.b-hero__slide')];
111
+ const embedSlot = hero.querySelector<HTMLElement>('.b-hero__embed');
112
+ let player: HTMLIFrameElement | null = null;
113
+ // Play / pause an embedded player by message, with no provider API script.
114
+ const command = (play: boolean) => {
115
+ if (!player?.contentWindow) return;
116
+ const msg = embedSlot!.dataset.provider === 'Vimeo'
117
+ ? { method: play ? 'play' : 'pause' }
118
+ : { event: 'command', func: play ? 'playVideo' : 'pauseVideo', args: [] };
119
+ player.contentWindow.postMessage(JSON.stringify(msg), new URL(player.src).origin);
120
+ };
121
+ const loadPlayer = () => {
122
+ if (player || !embedSlot || reduce) return; // reduced motion keeps the still
123
+ player = document.createElement('iframe');
124
+ player.src = embedSlot.dataset.embedUrl!;
125
+ player.title = 'Background video';
126
+ player.tabIndex = -1;
127
+ player.setAttribute('aria-hidden', 'true');
128
+ player.allow = 'autoplay; encrypted-media; picture-in-picture';
129
+ player.addEventListener('load', () => window.setTimeout(() => embedSlot.classList.add('is-on'), 600));
130
+ embedSlot.appendChild(player);
131
+ if (button) button.hidden = false;
132
+ sync();
133
+ };
134
+ let paused = reduce;
135
+ let visible = true;
136
+ let index = 0;
137
+ let timer: number | undefined;
138
+
139
+ const tick = () => {
140
+ slides[index].classList.remove('is-on');
141
+ index = (index + 1) % slides.length;
142
+ slides[index].classList.add('is-on');
143
+ };
144
+ const sync = () => {
145
+ const run = !paused && visible && !document.hidden;
146
+ if (video) {
147
+ if (run) void video.play().catch(() => {});
148
+ else video.pause();
149
+ }
150
+ if (player) command(run);
151
+ if (slides.length > 1) {
152
+ window.clearInterval(timer);
153
+ timer = run ? window.setInterval(tick, SLIDE_MS) : undefined;
154
+ }
155
+ if (button) {
156
+ button.setAttribute('aria-pressed', String(paused));
157
+ button.setAttribute('aria-label', paused ? 'Play background' : 'Pause background');
158
+ }
159
+ hero.toggleAttribute('data-paused', paused);
160
+ };
161
+ button?.addEventListener('click', () => { paused = !paused; sync(); });
162
+ document.addEventListener('visibilitychange', sync);
163
+ new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }).observe(hero);
164
+ if (embedSlot) {
165
+ if (hasConsent()) loadPlayer();
166
+ onConsentChange((allowed) => { if (allowed) loadPlayer(); });
167
+ }
168
+ sync();
169
+ }
170
+ </script>
171
+
172
+ <style>
173
+ .b-hero {
174
+ display: grid;
175
+ gap: 2rem;
176
+ padding-block: 3rem;
177
+ }
178
+ @media (min-width: 48em) {
179
+ .b-hero[data-layout='split'] {
180
+ grid-template-columns: 1fr 1fr;
181
+ align-items: center;
182
+ }
183
+ }
184
+ .b-hero__text {
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: 1rem;
188
+ }
189
+ .b-hero[data-layout='centered'] .b-hero__text {
190
+ max-width: var(--max);
191
+ margin-inline: auto;
192
+ align-items: center;
193
+ text-align: center;
194
+ }
195
+ .b-hero__eyebrow {
196
+ margin: 0;
197
+ text-transform: uppercase;
198
+ letter-spacing: 0.12em;
199
+ font-size: 0.8125rem;
200
+ font-weight: 600;
201
+ color: var(--gold);
202
+ }
203
+ .b-hero__subtitle {
204
+ margin: 0;
205
+ font-size: 1.125rem;
206
+ line-height: 1.6;
207
+ color: var(--muted);
208
+ max-width: var(--max);
209
+ }
210
+ .b-hero__cta {
211
+ margin-top: 0.5rem;
212
+ }
213
+
214
+ /* ── Mesh background: a soft, slowly drifting brand gradient behind the text.
215
+ Low opacity + blur keeps normal (dark) text perfectly readable. ── */
216
+ .b-hero[data-bg='mesh'] {
217
+ position: relative;
218
+ isolation: isolate;
219
+ overflow: hidden;
220
+ border-radius: var(--radius);
221
+ padding: clamp(3rem, 7vw, 5rem) clamp(1.25rem, 4vw, 3rem);
222
+ }
223
+ .b-hero__mesh {
224
+ position: absolute;
225
+ inset: 0;
226
+ z-index: -1;
227
+ background: var(--bg);
228
+ }
229
+ .b-hero__mesh::before {
230
+ content: '';
231
+ position: absolute;
232
+ inset: -25%;
233
+ background:
234
+ radial-gradient(35% 45% at 20% 25%, var(--gold), transparent 70%),
235
+ radial-gradient(35% 45% at 80% 20%, var(--accent-2, var(--gold)), transparent 70%),
236
+ radial-gradient(40% 50% at 65% 85%, var(--gold-light), transparent 70%);
237
+ opacity: 0.35;
238
+ filter: blur(50px);
239
+ animation: b-hero-mesh 20s ease-in-out infinite alternate;
240
+ }
241
+ @keyframes b-hero-mesh {
242
+ from { transform: translate3d(-3%, -2%, 0) scale(1.05); }
243
+ to { transform: translate3d(3%, 4%, 0) scale(1.18); }
244
+ }
245
+ @media (prefers-reduced-motion: reduce) {
246
+ .b-hero__mesh::before { animation: none; }
247
+ }
248
+ .b-hero__media img {
249
+ display: block;
250
+ width: 100%;
251
+ height: auto;
252
+ border-radius: 8px;
253
+ }
254
+
255
+ /* ── Media layout: image / slideshow / video background with a legibility scrim ── */
256
+ .b-hero[data-layout='media'] {
257
+ position: relative;
258
+ min-height: min(68vh, 38rem);
259
+ display: grid;
260
+ place-items: center;
261
+ text-align: center;
262
+ padding: clamp(3rem, 8vw, 6rem) 1.5rem;
263
+ border-radius: var(--radius);
264
+ overflow: hidden;
265
+ isolation: isolate;
266
+ }
267
+ .b-hero[data-size='tall'] {
268
+ min-height: min(85vh, 52rem);
269
+ }
270
+ /* The full first screen, edge to edge — breaks out of the page column. */
271
+ .b-hero[data-size='screen'] {
272
+ width: 100vw;
273
+ margin-inline: calc(50% - 50vw);
274
+ min-height: 100svh;
275
+ border-radius: 0;
276
+ }
277
+ .b-hero__bg {
278
+ position: absolute;
279
+ inset: 0;
280
+ z-index: -1;
281
+ background-image: var(--hero-image, none);
282
+ background-size: cover;
283
+ background-position: center;
284
+ background-color: var(--bg-section);
285
+ }
286
+ .b-hero__bg video {
287
+ width: 100%;
288
+ height: 100%;
289
+ object-fit: cover;
290
+ }
291
+ /* An embedded background player, sized to COVER the hero like object-fit (16:9 kept,
292
+ overflow cropped). Never interactive; fades in once it has started. */
293
+ .b-hero__embed {
294
+ position: absolute;
295
+ inset: 0;
296
+ overflow: hidden;
297
+ pointer-events: none;
298
+ container-type: size;
299
+ opacity: 0;
300
+ transition: opacity 1.2s ease;
301
+ }
302
+ .b-hero__embed.is-on {
303
+ opacity: 1;
304
+ }
305
+ .b-hero__embed :global(iframe) {
306
+ position: absolute;
307
+ top: 50%;
308
+ left: 50%;
309
+ width: max(100cqw, 177.78cqh);
310
+ height: max(100cqh, 56.25cqw);
311
+ max-width: none; /* the global media reset caps iframes at 100%: cover must overflow */
312
+ transform: translate(-50%, -50%);
313
+ border: 0;
314
+ }
315
+ .b-hero__pause[hidden] {
316
+ display: none;
317
+ }
318
+ .b-hero__slide {
319
+ position: absolute;
320
+ inset: 0;
321
+ background-size: cover;
322
+ background-position: center;
323
+ opacity: 0;
324
+ transition: opacity 1.6s ease-in-out;
325
+ }
326
+ .b-hero__slide.is-on {
327
+ opacity: 1;
328
+ }
329
+ @media (prefers-reduced-motion: reduce) {
330
+ .b-hero__slide { transition: none; }
331
+ }
332
+ .b-hero__scrim {
333
+ position: absolute;
334
+ inset: 0;
335
+ background: rgba(8, 10, 18, var(--hero-overlay, 0.45));
336
+ }
337
+ .b-hero[data-layout='media'] .b-hero__text {
338
+ max-width: var(--max);
339
+ align-items: center;
340
+ color: #fff;
341
+ }
342
+ .b-hero[data-layout='media'] .b-hero__subtitle {
343
+ color: rgba(255, 255, 255, 0.9);
344
+ margin-inline: auto;
345
+ }
346
+ .b-hero[data-layout='media'] .b-hero__eyebrow {
347
+ color: #fff;
348
+ opacity: 0.9;
349
+ }
350
+ .b-hero[data-layout='media'] :global(h1) {
351
+ color: #fff;
352
+ }
353
+
354
+ /* A linked background: the whole picture opens the full video; the text and
355
+ its CTA stay clickable on top, and a visible cue carries the keyboard link. */
356
+ .b-hero__bglink {
357
+ position: absolute;
358
+ inset: 0;
359
+ z-index: 0;
360
+ cursor: pointer;
361
+ }
362
+ .b-hero[data-linked] .b-hero__text {
363
+ position: relative;
364
+ z-index: 1;
365
+ pointer-events: none;
366
+ }
367
+ .b-hero[data-linked] .b-hero__text :global(a),
368
+ .b-hero[data-linked] .b-hero__text :global(button) {
369
+ pointer-events: auto;
370
+ }
371
+ .b-hero__watch {
372
+ position: absolute;
373
+ left: clamp(0.75rem, 2vw, 1.25rem);
374
+ bottom: clamp(0.75rem, 2vw, 1.25rem);
375
+ z-index: 2;
376
+ display: inline-flex;
377
+ align-items: center;
378
+ gap: 0.5rem;
379
+ padding: 0.5rem 0.9rem;
380
+ border-radius: 999px;
381
+ border: 1px solid rgba(255, 255, 255, 0.55);
382
+ background: rgba(8, 10, 18, 0.45);
383
+ color: #fff;
384
+ font-size: 0.875rem;
385
+ font-weight: 600;
386
+ text-decoration: none;
387
+ }
388
+ .b-hero__watch:hover {
389
+ background: rgba(8, 10, 18, 0.65);
390
+ }
391
+ .b-hero__watch:focus-visible {
392
+ outline: 3px solid #fff;
393
+ outline-offset: 2px;
394
+ }
395
+ .b-hero__watch-icon {
396
+ width: 0;
397
+ height: 0;
398
+ border-block: 0.35rem solid transparent;
399
+ border-inline-start: 0.6rem solid currentColor;
400
+ }
401
+ .b-hero__sr {
402
+ position: absolute;
403
+ width: 1px;
404
+ height: 1px;
405
+ overflow: hidden;
406
+ clip-path: inset(50%);
407
+ white-space: nowrap;
408
+ }
409
+
410
+ /* Pause / play for moving backgrounds — small, quiet, always reachable. */
411
+ .b-hero__pause {
412
+ position: absolute;
413
+ z-index: 2;
414
+ right: clamp(0.75rem, 2vw, 1.25rem);
415
+ bottom: clamp(0.75rem, 2vw, 1.25rem);
416
+ width: 2.5rem;
417
+ height: 2.5rem;
418
+ display: grid;
419
+ place-items: center;
420
+ border-radius: 50%;
421
+ border: 1px solid rgba(255, 255, 255, 0.55);
422
+ background: rgba(8, 10, 18, 0.45);
423
+ color: #fff;
424
+ cursor: pointer;
425
+ }
426
+ .b-hero__pause:hover {
427
+ background: rgba(8, 10, 18, 0.65);
428
+ }
429
+ .b-hero__pause:focus-visible {
430
+ outline: 3px solid #fff;
431
+ outline-offset: 2px;
432
+ }
433
+ /* Two bars (pause) → a triangle (play) when paused. */
434
+ .b-hero__pause-icon {
435
+ width: 0.75rem;
436
+ height: 0.875rem;
437
+ border-inline: 0.25rem solid currentColor;
438
+ }
439
+ .b-hero[data-paused] .b-hero__pause-icon {
440
+ width: 0;
441
+ height: 0;
442
+ border-inline: 0;
443
+ border-block: 0.45rem solid transparent;
444
+ border-inline-start: 0.75rem solid currentColor;
445
+ margin-inline-start: 0.2rem;
446
+ }
447
+ </style>