ferst-core 0.6.1 → 0.7.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.
- package/bin/ferst-update.mjs +135 -6
- package/components/BlockRenderer.astro +107 -103
- package/components/blocks/Prose.astro +56 -39
- package/components/recipes/Documents.astro +146 -0
- package/components/recipes/Hero.astro +383 -191
- package/components/recipes/Tiles.astro +23 -4
- package/components/recipes/Video.astro +157 -0
- package/content/blocks.ts +773 -719
- package/content/cookies.ts +112 -103
- package/examples/primitives.ts +28 -28
- package/examples/recipes.ts +4 -0
- package/lib/documents.ts +62 -0
- package/lib/inline-markdown.ts +59 -52
- package/lib/prose.ts +29 -0
- package/lib/video.ts +56 -0
- package/package.json +1 -1
|
@@ -1,191 +1,383 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* Hero — the first Level-2 recipe: a curated page-opening band that composes
|
|
4
|
-
* primitives (Heading + Button) into a designed layout.
|
|
5
|
-
* (stacked and centred)
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
display:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 { 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
|
+
const slides = !media?.video && (media?.images?.length ?? 0) >= 2 ? media!.images! : [];
|
|
31
|
+
const still = media?.image ?? media?.images?.[0];
|
|
32
|
+
const isMedia = layout === 'media' && media && (media.video || slides.length || still);
|
|
33
|
+
const motion = isMedia && (media?.video || slides.length) ? (media?.video ? 'video' : 'slides') : undefined;
|
|
34
|
+
const overlay = (media?.overlay ?? 45) / 100;
|
|
35
|
+
const isMesh = background === 'mesh' && !isMedia;
|
|
36
|
+
// The background can lead somewhere — e.g. the full video on YouTube (new tab; the app on phones).
|
|
37
|
+
const bgLink = isMedia && media?.link && /^(https?:\/\/|\/)/i.test(media.link) ? media.link : undefined;
|
|
38
|
+
const provider = bgLink ? videoEmbed(bgLink)?.provider : undefined;
|
|
39
|
+
const watchLabel = media?.linkLabel || (provider ? `Watch the full video on ${provider}` : 'Watch the full video');
|
|
40
|
+
const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-image: url('${still}')` : '';
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<section
|
|
44
|
+
class="b-hero"
|
|
45
|
+
data-layout={layout}
|
|
46
|
+
data-size={isMedia ? size : undefined}
|
|
47
|
+
data-bg={isMesh ? 'mesh' : undefined}
|
|
48
|
+
data-motion={motion}
|
|
49
|
+
data-linked={bgLink ? 'true' : undefined}
|
|
50
|
+
style={isMedia ? `--hero-overlay: ${overlay}; ${bgImage}` : undefined}
|
|
51
|
+
>
|
|
52
|
+
{isMedia && (
|
|
53
|
+
<div class="b-hero__bg" aria-hidden="true">
|
|
54
|
+
{media?.video ? (
|
|
55
|
+
<video autoplay muted loop playsinline poster={media.poster} preload="metadata">
|
|
56
|
+
<source src={media.video} />
|
|
57
|
+
</video>
|
|
58
|
+
) : null}
|
|
59
|
+
{slides.map((src, i) => (
|
|
60
|
+
<span class:list={['b-hero__slide', { 'is-on': i === 0 }]} style={`background-image: url('${src}')`}></span>
|
|
61
|
+
))}
|
|
62
|
+
<span class="b-hero__scrim"></span>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
{bgLink && <a class="b-hero__bglink" href={bgLink} target="_blank" rel="noopener" tabindex="-1" aria-hidden="true"></a>}
|
|
66
|
+
{isMesh && <div class="b-hero__mesh" aria-hidden="true"></div>}
|
|
67
|
+
<div class="b-hero__text">
|
|
68
|
+
{eyebrow && <p class="b-hero__eyebrow">{eyebrow}</p>}
|
|
69
|
+
<Heading text={title} level={1} align={layout === 'split' ? 'start' : 'center'} />
|
|
70
|
+
{subtitle && <p class="b-hero__subtitle">{subtitle}</p>}
|
|
71
|
+
{cta && (
|
|
72
|
+
<div class="b-hero__cta">
|
|
73
|
+
<Button href={cta.href} variant={layout === 'media' ? 'cta' : 'primary'}>{cta.label}</Button>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
{bgLink && (
|
|
78
|
+
<a class="b-hero__watch" href={bgLink} target="_blank" rel="noopener">
|
|
79
|
+
<span class="b-hero__watch-icon" aria-hidden="true"></span>{watchLabel}<span class="b-hero__sr"> (opens in a new tab)</span>
|
|
80
|
+
</a>
|
|
81
|
+
)}
|
|
82
|
+
{motion && (
|
|
83
|
+
<button type="button" class="b-hero__pause" aria-pressed="false" aria-label="Pause background">
|
|
84
|
+
<span class="b-hero__pause-icon" aria-hidden="true"></span>
|
|
85
|
+
</button>
|
|
86
|
+
)}
|
|
87
|
+
{layout === 'split' && image && (
|
|
88
|
+
<div class="b-hero__media">
|
|
89
|
+
<img src={image.src} alt={image.alt ?? ''} loading="eager" decoding="async" />
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</section>
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
// Moving hero backgrounds: pause button, reduced-motion, and the slideshow timer.
|
|
96
|
+
const SLIDE_MS = 6000;
|
|
97
|
+
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
98
|
+
for (const hero of document.querySelectorAll<HTMLElement>('.b-hero[data-motion]')) {
|
|
99
|
+
const button = hero.querySelector<HTMLButtonElement>('.b-hero__pause');
|
|
100
|
+
const video = hero.querySelector('video');
|
|
101
|
+
const slides = [...hero.querySelectorAll<HTMLElement>('.b-hero__slide')];
|
|
102
|
+
let paused = reduce;
|
|
103
|
+
let visible = true;
|
|
104
|
+
let index = 0;
|
|
105
|
+
let timer: number | undefined;
|
|
106
|
+
|
|
107
|
+
const tick = () => {
|
|
108
|
+
slides[index].classList.remove('is-on');
|
|
109
|
+
index = (index + 1) % slides.length;
|
|
110
|
+
slides[index].classList.add('is-on');
|
|
111
|
+
};
|
|
112
|
+
const sync = () => {
|
|
113
|
+
const run = !paused && visible && !document.hidden;
|
|
114
|
+
if (video) {
|
|
115
|
+
if (run) void video.play().catch(() => {});
|
|
116
|
+
else video.pause();
|
|
117
|
+
}
|
|
118
|
+
if (slides.length > 1) {
|
|
119
|
+
window.clearInterval(timer);
|
|
120
|
+
timer = run ? window.setInterval(tick, SLIDE_MS) : undefined;
|
|
121
|
+
}
|
|
122
|
+
if (button) {
|
|
123
|
+
button.setAttribute('aria-pressed', String(paused));
|
|
124
|
+
button.setAttribute('aria-label', paused ? 'Play background' : 'Pause background');
|
|
125
|
+
}
|
|
126
|
+
hero.toggleAttribute('data-paused', paused);
|
|
127
|
+
};
|
|
128
|
+
button?.addEventListener('click', () => { paused = !paused; sync(); });
|
|
129
|
+
document.addEventListener('visibilitychange', sync);
|
|
130
|
+
new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }).observe(hero);
|
|
131
|
+
sync();
|
|
132
|
+
}
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<style>
|
|
136
|
+
.b-hero {
|
|
137
|
+
display: grid;
|
|
138
|
+
gap: 2rem;
|
|
139
|
+
padding-block: 3rem;
|
|
140
|
+
}
|
|
141
|
+
@media (min-width: 48em) {
|
|
142
|
+
.b-hero[data-layout='split'] {
|
|
143
|
+
grid-template-columns: 1fr 1fr;
|
|
144
|
+
align-items: center;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
.b-hero__text {
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 1rem;
|
|
151
|
+
}
|
|
152
|
+
.b-hero[data-layout='centered'] .b-hero__text {
|
|
153
|
+
max-width: var(--max);
|
|
154
|
+
margin-inline: auto;
|
|
155
|
+
align-items: center;
|
|
156
|
+
text-align: center;
|
|
157
|
+
}
|
|
158
|
+
.b-hero__eyebrow {
|
|
159
|
+
margin: 0;
|
|
160
|
+
text-transform: uppercase;
|
|
161
|
+
letter-spacing: 0.12em;
|
|
162
|
+
font-size: 0.8125rem;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
color: var(--gold);
|
|
165
|
+
}
|
|
166
|
+
.b-hero__subtitle {
|
|
167
|
+
margin: 0;
|
|
168
|
+
font-size: 1.125rem;
|
|
169
|
+
line-height: 1.6;
|
|
170
|
+
color: var(--muted);
|
|
171
|
+
max-width: var(--max);
|
|
172
|
+
}
|
|
173
|
+
.b-hero__cta {
|
|
174
|
+
margin-top: 0.5rem;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ── Mesh background: a soft, slowly drifting brand gradient behind the text.
|
|
178
|
+
Low opacity + blur keeps normal (dark) text perfectly readable. ── */
|
|
179
|
+
.b-hero[data-bg='mesh'] {
|
|
180
|
+
position: relative;
|
|
181
|
+
isolation: isolate;
|
|
182
|
+
overflow: hidden;
|
|
183
|
+
border-radius: var(--radius);
|
|
184
|
+
padding: clamp(3rem, 7vw, 5rem) clamp(1.25rem, 4vw, 3rem);
|
|
185
|
+
}
|
|
186
|
+
.b-hero__mesh {
|
|
187
|
+
position: absolute;
|
|
188
|
+
inset: 0;
|
|
189
|
+
z-index: -1;
|
|
190
|
+
background: var(--bg);
|
|
191
|
+
}
|
|
192
|
+
.b-hero__mesh::before {
|
|
193
|
+
content: '';
|
|
194
|
+
position: absolute;
|
|
195
|
+
inset: -25%;
|
|
196
|
+
background:
|
|
197
|
+
radial-gradient(35% 45% at 20% 25%, var(--gold), transparent 70%),
|
|
198
|
+
radial-gradient(35% 45% at 80% 20%, var(--accent-2, var(--gold)), transparent 70%),
|
|
199
|
+
radial-gradient(40% 50% at 65% 85%, var(--gold-light), transparent 70%);
|
|
200
|
+
opacity: 0.35;
|
|
201
|
+
filter: blur(50px);
|
|
202
|
+
animation: b-hero-mesh 20s ease-in-out infinite alternate;
|
|
203
|
+
}
|
|
204
|
+
@keyframes b-hero-mesh {
|
|
205
|
+
from { transform: translate3d(-3%, -2%, 0) scale(1.05); }
|
|
206
|
+
to { transform: translate3d(3%, 4%, 0) scale(1.18); }
|
|
207
|
+
}
|
|
208
|
+
@media (prefers-reduced-motion: reduce) {
|
|
209
|
+
.b-hero__mesh::before { animation: none; }
|
|
210
|
+
}
|
|
211
|
+
.b-hero__media img {
|
|
212
|
+
display: block;
|
|
213
|
+
width: 100%;
|
|
214
|
+
height: auto;
|
|
215
|
+
border-radius: 8px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ── Media layout: image / slideshow / video background with a legibility scrim ── */
|
|
219
|
+
.b-hero[data-layout='media'] {
|
|
220
|
+
position: relative;
|
|
221
|
+
min-height: min(68vh, 38rem);
|
|
222
|
+
display: grid;
|
|
223
|
+
place-items: center;
|
|
224
|
+
text-align: center;
|
|
225
|
+
padding: clamp(3rem, 8vw, 6rem) 1.5rem;
|
|
226
|
+
border-radius: var(--radius);
|
|
227
|
+
overflow: hidden;
|
|
228
|
+
isolation: isolate;
|
|
229
|
+
}
|
|
230
|
+
.b-hero[data-size='tall'] {
|
|
231
|
+
min-height: min(85vh, 52rem);
|
|
232
|
+
}
|
|
233
|
+
/* The full first screen, edge to edge — breaks out of the page column. */
|
|
234
|
+
.b-hero[data-size='screen'] {
|
|
235
|
+
width: 100vw;
|
|
236
|
+
margin-inline: calc(50% - 50vw);
|
|
237
|
+
min-height: 100svh;
|
|
238
|
+
border-radius: 0;
|
|
239
|
+
}
|
|
240
|
+
.b-hero__bg {
|
|
241
|
+
position: absolute;
|
|
242
|
+
inset: 0;
|
|
243
|
+
z-index: -1;
|
|
244
|
+
background-image: var(--hero-image, none);
|
|
245
|
+
background-size: cover;
|
|
246
|
+
background-position: center;
|
|
247
|
+
background-color: var(--bg-section);
|
|
248
|
+
}
|
|
249
|
+
.b-hero__bg video {
|
|
250
|
+
width: 100%;
|
|
251
|
+
height: 100%;
|
|
252
|
+
object-fit: cover;
|
|
253
|
+
}
|
|
254
|
+
.b-hero__slide {
|
|
255
|
+
position: absolute;
|
|
256
|
+
inset: 0;
|
|
257
|
+
background-size: cover;
|
|
258
|
+
background-position: center;
|
|
259
|
+
opacity: 0;
|
|
260
|
+
transition: opacity 1.6s ease-in-out;
|
|
261
|
+
}
|
|
262
|
+
.b-hero__slide.is-on {
|
|
263
|
+
opacity: 1;
|
|
264
|
+
}
|
|
265
|
+
@media (prefers-reduced-motion: reduce) {
|
|
266
|
+
.b-hero__slide { transition: none; }
|
|
267
|
+
}
|
|
268
|
+
.b-hero__scrim {
|
|
269
|
+
position: absolute;
|
|
270
|
+
inset: 0;
|
|
271
|
+
background: rgba(8, 10, 18, var(--hero-overlay, 0.45));
|
|
272
|
+
}
|
|
273
|
+
.b-hero[data-layout='media'] .b-hero__text {
|
|
274
|
+
max-width: var(--max);
|
|
275
|
+
align-items: center;
|
|
276
|
+
color: #fff;
|
|
277
|
+
}
|
|
278
|
+
.b-hero[data-layout='media'] .b-hero__subtitle {
|
|
279
|
+
color: rgba(255, 255, 255, 0.9);
|
|
280
|
+
margin-inline: auto;
|
|
281
|
+
}
|
|
282
|
+
.b-hero[data-layout='media'] .b-hero__eyebrow {
|
|
283
|
+
color: #fff;
|
|
284
|
+
opacity: 0.9;
|
|
285
|
+
}
|
|
286
|
+
.b-hero[data-layout='media'] :global(h1) {
|
|
287
|
+
color: #fff;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* A linked background: the whole picture opens the full video; the text and
|
|
291
|
+
its CTA stay clickable on top, and a visible cue carries the keyboard link. */
|
|
292
|
+
.b-hero__bglink {
|
|
293
|
+
position: absolute;
|
|
294
|
+
inset: 0;
|
|
295
|
+
z-index: 0;
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
}
|
|
298
|
+
.b-hero[data-linked] .b-hero__text {
|
|
299
|
+
position: relative;
|
|
300
|
+
z-index: 1;
|
|
301
|
+
pointer-events: none;
|
|
302
|
+
}
|
|
303
|
+
.b-hero[data-linked] .b-hero__text :global(a),
|
|
304
|
+
.b-hero[data-linked] .b-hero__text :global(button) {
|
|
305
|
+
pointer-events: auto;
|
|
306
|
+
}
|
|
307
|
+
.b-hero__watch {
|
|
308
|
+
position: absolute;
|
|
309
|
+
left: clamp(0.75rem, 2vw, 1.25rem);
|
|
310
|
+
bottom: clamp(0.75rem, 2vw, 1.25rem);
|
|
311
|
+
z-index: 2;
|
|
312
|
+
display: inline-flex;
|
|
313
|
+
align-items: center;
|
|
314
|
+
gap: 0.5rem;
|
|
315
|
+
padding: 0.5rem 0.9rem;
|
|
316
|
+
border-radius: 999px;
|
|
317
|
+
border: 1px solid rgba(255, 255, 255, 0.55);
|
|
318
|
+
background: rgba(8, 10, 18, 0.45);
|
|
319
|
+
color: #fff;
|
|
320
|
+
font-size: 0.875rem;
|
|
321
|
+
font-weight: 600;
|
|
322
|
+
text-decoration: none;
|
|
323
|
+
}
|
|
324
|
+
.b-hero__watch:hover {
|
|
325
|
+
background: rgba(8, 10, 18, 0.65);
|
|
326
|
+
}
|
|
327
|
+
.b-hero__watch:focus-visible {
|
|
328
|
+
outline: 3px solid #fff;
|
|
329
|
+
outline-offset: 2px;
|
|
330
|
+
}
|
|
331
|
+
.b-hero__watch-icon {
|
|
332
|
+
width: 0;
|
|
333
|
+
height: 0;
|
|
334
|
+
border-block: 0.35rem solid transparent;
|
|
335
|
+
border-inline-start: 0.6rem solid currentColor;
|
|
336
|
+
}
|
|
337
|
+
.b-hero__sr {
|
|
338
|
+
position: absolute;
|
|
339
|
+
width: 1px;
|
|
340
|
+
height: 1px;
|
|
341
|
+
overflow: hidden;
|
|
342
|
+
clip-path: inset(50%);
|
|
343
|
+
white-space: nowrap;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* Pause / play for moving backgrounds — small, quiet, always reachable. */
|
|
347
|
+
.b-hero__pause {
|
|
348
|
+
position: absolute;
|
|
349
|
+
z-index: 2;
|
|
350
|
+
right: clamp(0.75rem, 2vw, 1.25rem);
|
|
351
|
+
bottom: clamp(0.75rem, 2vw, 1.25rem);
|
|
352
|
+
width: 2.5rem;
|
|
353
|
+
height: 2.5rem;
|
|
354
|
+
display: grid;
|
|
355
|
+
place-items: center;
|
|
356
|
+
border-radius: 50%;
|
|
357
|
+
border: 1px solid rgba(255, 255, 255, 0.55);
|
|
358
|
+
background: rgba(8, 10, 18, 0.45);
|
|
359
|
+
color: #fff;
|
|
360
|
+
cursor: pointer;
|
|
361
|
+
}
|
|
362
|
+
.b-hero__pause:hover {
|
|
363
|
+
background: rgba(8, 10, 18, 0.65);
|
|
364
|
+
}
|
|
365
|
+
.b-hero__pause:focus-visible {
|
|
366
|
+
outline: 3px solid #fff;
|
|
367
|
+
outline-offset: 2px;
|
|
368
|
+
}
|
|
369
|
+
/* Two bars (pause) → a triangle (play) when paused. */
|
|
370
|
+
.b-hero__pause-icon {
|
|
371
|
+
width: 0.75rem;
|
|
372
|
+
height: 0.875rem;
|
|
373
|
+
border-inline: 0.25rem solid currentColor;
|
|
374
|
+
}
|
|
375
|
+
.b-hero[data-paused] .b-hero__pause-icon {
|
|
376
|
+
width: 0;
|
|
377
|
+
height: 0;
|
|
378
|
+
border-inline: 0;
|
|
379
|
+
border-block: 0.45rem solid transparent;
|
|
380
|
+
border-inline-start: 0.75rem solid currentColor;
|
|
381
|
+
margin-inline-start: 0.2rem;
|
|
382
|
+
}
|
|
383
|
+
</style>
|
|
@@ -20,9 +20,10 @@ import { isIconKey } from '../../lib/icons';
|
|
|
20
20
|
interface Tile {
|
|
21
21
|
icon?: string;
|
|
22
22
|
eyebrow?: string;
|
|
23
|
-
title
|
|
23
|
+
title?: string;
|
|
24
24
|
text?: string;
|
|
25
25
|
href?: string;
|
|
26
|
+
linkLabel?: string;
|
|
26
27
|
image?: { src: string; alt?: string };
|
|
27
28
|
}
|
|
28
29
|
interface Props {
|
|
@@ -52,15 +53,25 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
52
53
|
variant === 'image' && tile.image
|
|
53
54
|
? `--tile-image: url('${tile.image.src}')`
|
|
54
55
|
: undefined;
|
|
56
|
+
// A picture tile: an image with no words — the picture itself is the tile (and
|
|
57
|
+
// the button, when it links), named by its alt text.
|
|
58
|
+
const plain = variant === 'image' && !!tile.image && !tile.title && !tile.text && !tile.eyebrow;
|
|
55
59
|
return (
|
|
56
|
-
<Tag
|
|
60
|
+
<Tag
|
|
61
|
+
class="b-tile"
|
|
62
|
+
href={tile.href}
|
|
63
|
+
style={bg}
|
|
64
|
+
data-plain={plain ? 'true' : undefined}
|
|
65
|
+
role={plain && !tile.href ? 'img' : undefined}
|
|
66
|
+
aria-label={plain ? tile.image?.alt || undefined : undefined}
|
|
67
|
+
>
|
|
57
68
|
{tile.icon && isIconKey(tile.icon) && (
|
|
58
69
|
<span class="b-tile__icon"><Icon name={tile.icon} size={26} /></span>
|
|
59
70
|
)}
|
|
60
71
|
{tile.eyebrow && <span class="b-tile__eyebrow">{tile.eyebrow}</span>}
|
|
61
|
-
<h3 class="b-tile__title">{tile.title}</h3>
|
|
72
|
+
{tile.title && <h3 class="b-tile__title">{tile.title}</h3>}
|
|
62
73
|
{tile.text && <p class="b-tile__text">{tile.text}</p>}
|
|
63
|
-
{tile.href && <span class="b-tile__more" aria-hidden="true">Learn more →</span>}
|
|
74
|
+
{tile.href && !plain && <span class="b-tile__more" aria-hidden="true">{tile.linkLabel ?? 'Learn more'} →</span>}
|
|
64
75
|
</Tag>
|
|
65
76
|
);
|
|
66
77
|
})}
|
|
@@ -152,6 +163,14 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
152
163
|
background-size: cover;
|
|
153
164
|
background-position: center;
|
|
154
165
|
}
|
|
166
|
+
/* A picture tile shows the image as it is — no scrim, nothing over it. */
|
|
167
|
+
.b-tiles__grid[data-variant='image'] .b-tile[data-plain]::before {
|
|
168
|
+
background-image: var(--tile-image);
|
|
169
|
+
}
|
|
170
|
+
a.b-tile[data-plain]:focus-visible {
|
|
171
|
+
outline: 3px solid var(--accent);
|
|
172
|
+
outline-offset: 3px;
|
|
173
|
+
}
|
|
155
174
|
|
|
156
175
|
/* Hover: only tiles that link lift. */
|
|
157
176
|
a.b-tile:hover {
|