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