ferst-core 0.7.7 → 0.7.8

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