ferst-core 0.7.0 → 0.7.4
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/components/BlockRenderer.astro +6 -0
- package/components/Pagination.astro +51 -0
- package/components/blocks/Heading.astro +47 -46
- package/components/blocks/Prose.astro +10 -2
- package/components/blocks/Quote.astro +40 -38
- package/components/recipes/Accordion.astro +91 -84
- package/components/recipes/ContactCard.astro +172 -170
- package/components/recipes/Document.astro +152 -0
- package/components/recipes/FeatureCards.astro +112 -111
- package/components/recipes/Hero.astro +120 -8
- package/components/recipes/LinkRow.astro +91 -0
- package/components/recipes/MediaCards.astro +213 -212
- package/components/recipes/Notice.astro +90 -89
- package/components/recipes/Stats.astro +152 -145
- package/components/recipes/Table.astro +98 -97
- package/components/recipes/Tabs.astro +134 -133
- package/components/recipes/Tiles.astro +287 -271
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +68 -2
- package/content/cookies.ts +10 -0
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +42 -36
- package/integration.mjs +83 -64
- package/layouts/Base.astro +481 -475
- package/lib/documents.ts +68 -62
- package/lib/embed-consent.ts +78 -73
- package/lib/inline-markdown.ts +12 -0
- package/lib/links.ts +12 -0
- package/lib/paginate.ts +23 -0
- package/lib/posts.ts +20 -2
- package/lib/timetable.ts +27 -0
- package/lib/video.ts +101 -56
- package/package.json +1 -1
- package/routes/cookie-policy.astro +27 -0
- package/routes/posts/index.astro +36 -31
- package/routes/posts/page/[page].astro +36 -0
- package/routes/posts/tag/[...tag].astro +41 -37
|
@@ -1,111 +1,112 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* FeatureCards — a Level-2 recipe: an optional section header over a responsive
|
|
4
|
-
* grid of cards, each with an optional icon, a title, text, and an optional
|
|
5
|
-
* link. Composes the Card, Icon, Heading and Button primitives.
|
|
6
|
-
*/
|
|
7
|
-
import Heading from '../blocks/Heading.astro';
|
|
8
|
-
import Card from '../Card.astro';
|
|
9
|
-
import Icon from '../Icon.astro';
|
|
10
|
-
import Button from '../Button.astro';
|
|
11
|
-
import { isIconKey } from '../../lib/icons';
|
|
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
|
-
{card.
|
|
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
|
-
.b-feature-cards__grid[data-cols='
|
|
81
|
-
.b-feature-cards__grid[data-cols='
|
|
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
|
-
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FeatureCards — a Level-2 recipe: an optional section header over a responsive
|
|
4
|
+
* grid of cards, each with an optional icon, a title, text, and an optional
|
|
5
|
+
* link. Composes the Card, Icon, Heading and Button primitives.
|
|
6
|
+
*/
|
|
7
|
+
import Heading from '../blocks/Heading.astro';
|
|
8
|
+
import Card from '../Card.astro';
|
|
9
|
+
import Icon from '../Icon.astro';
|
|
10
|
+
import Button from '../Button.astro';
|
|
11
|
+
import { isIconKey } from '../../lib/icons';
|
|
12
|
+
|
|
13
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
14
|
+
interface Props {
|
|
15
|
+
title?: string;
|
|
16
|
+
intro?: string;
|
|
17
|
+
columns?: 2 | 3 | 4;
|
|
18
|
+
cards?: { icon?: string; title: string; text?: string; href?: string }[];
|
|
19
|
+
}
|
|
20
|
+
const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
21
|
+
// Never reserve more columns than there are cards (a lone card in a 3-col grid
|
|
22
|
+
// would strand two empty cells); `data-single` centres a solitary card.
|
|
23
|
+
const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3 | 4;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<section class="b-feature-cards">
|
|
27
|
+
{(title || intro) && (
|
|
28
|
+
<div class="b-feature-cards__head">
|
|
29
|
+
{title && <Heading text={title} level={2} align="center" />}
|
|
30
|
+
{intro && <p class="b-feature-cards__intro" set:html={inlineMarkdown(intro)} />}
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
<div class="b-feature-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
|
|
34
|
+
{cards.map((card) => (
|
|
35
|
+
<Card>
|
|
36
|
+
{card.icon && isIconKey(card.icon) && (
|
|
37
|
+
<span class="b-feature-cards__icon"><Icon name={card.icon} size={28} /></span>
|
|
38
|
+
)}
|
|
39
|
+
<h3 class="b-feature-cards__title">{card.title}</h3>
|
|
40
|
+
{card.text && <p class="b-feature-cards__text" set:html={inlineMarkdown(card.text)} />}
|
|
41
|
+
{card.href && (
|
|
42
|
+
<span class="b-feature-cards__cta">
|
|
43
|
+
<Button href={card.href} variant="secondary" size="sm">Learn more</Button>
|
|
44
|
+
</span>
|
|
45
|
+
)}
|
|
46
|
+
</Card>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<style>
|
|
52
|
+
.b-feature-cards {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: 2rem;
|
|
56
|
+
max-width: 72rem;
|
|
57
|
+
margin-inline: auto;
|
|
58
|
+
padding-inline: 1rem;
|
|
59
|
+
}
|
|
60
|
+
.b-feature-cards__head {
|
|
61
|
+
max-width: var(--max);
|
|
62
|
+
margin-inline: auto;
|
|
63
|
+
text-align: center;
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
gap: 0.75rem;
|
|
67
|
+
}
|
|
68
|
+
.b-feature-cards__intro {
|
|
69
|
+
margin: 0;
|
|
70
|
+
color: var(--muted);
|
|
71
|
+
font-size: 1.0625rem;
|
|
72
|
+
line-height: 1.6;
|
|
73
|
+
}
|
|
74
|
+
.b-feature-cards__grid {
|
|
75
|
+
display: grid;
|
|
76
|
+
grid-template-columns: 1fr; /* mobile-first */
|
|
77
|
+
gap: 1.5rem;
|
|
78
|
+
}
|
|
79
|
+
@media (min-width: 48em) {
|
|
80
|
+
.b-feature-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
|
|
81
|
+
.b-feature-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
|
|
82
|
+
.b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
|
|
83
|
+
}
|
|
84
|
+
/* A solitary card is a centred feature, not a full-width slab. */
|
|
85
|
+
.b-feature-cards__grid[data-single] { max-width: 26rem; margin-inline: auto;
|
|
86
|
+
}
|
|
87
|
+
@media (min-width: 64em) {
|
|
88
|
+
.b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
|
|
89
|
+
}
|
|
90
|
+
.b-feature-cards__icon {
|
|
91
|
+
color: var(--gold);
|
|
92
|
+
margin-bottom: 0.5rem;
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
}
|
|
95
|
+
.b-feature-cards__title {
|
|
96
|
+
margin: 0 0 0.5rem;
|
|
97
|
+
font-size: 1.125rem;
|
|
98
|
+
color: var(--fg);
|
|
99
|
+
}
|
|
100
|
+
.b-feature-cards__text {
|
|
101
|
+
margin: 0;
|
|
102
|
+
color: var(--muted);
|
|
103
|
+
line-height: 1.6;
|
|
104
|
+
}
|
|
105
|
+
/* Push the CTA to the bottom so a row of cards aligns its buttons regardless
|
|
106
|
+
of how much text each card has. Cards are equal height already (grid items
|
|
107
|
+
stretch), and Card is a flex column — margin-top:auto does the rest. */
|
|
108
|
+
.b-feature-cards__cta {
|
|
109
|
+
margin-top: auto;
|
|
110
|
+
padding-top: 1rem;
|
|
111
|
+
}
|
|
112
|
+
</style>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import Heading from '../blocks/Heading.astro';
|
|
15
15
|
import Button from '../Button.astro';
|
|
16
|
-
import { videoEmbed } from '../../lib/video';
|
|
16
|
+
import { backgroundEmbed, videoEmbed } from '../../lib/video';
|
|
17
17
|
|
|
18
18
|
interface Props {
|
|
19
19
|
eyebrow?: string;
|
|
@@ -24,20 +24,27 @@ interface Props {
|
|
|
24
24
|
media?: { image?: string; images?: string[]; video?: string; poster?: string; overlay?: number; link?: string; linkLabel?: string };
|
|
25
25
|
layout?: 'centered' | 'split' | 'media';
|
|
26
26
|
size?: 'standard' | 'tall' | 'screen';
|
|
27
|
+
parallax?: boolean;
|
|
27
28
|
background?: 'none' | 'mesh';
|
|
28
29
|
}
|
|
29
|
-
const { eyebrow, title, subtitle, cta, image, media, layout = 'centered', size = 'standard', background = 'none' } = Astro.props;
|
|
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;
|
|
30
34
|
const slides = !media?.video && (media?.images?.length ?? 0) >= 2 ? media!.images! : [];
|
|
31
35
|
const still = media?.image ?? media?.images?.[0];
|
|
32
36
|
const isMedia = layout === 'media' && media && (media.video || slides.length || still);
|
|
33
|
-
const motion = isMedia
|
|
37
|
+
const motion = isMedia ? (embed ? 'embed' : media?.video ? 'video' : slides.length ? 'slides' : undefined) : undefined;
|
|
34
38
|
const overlay = (media?.overlay ?? 45) / 100;
|
|
35
39
|
const isMesh = background === 'mesh' && !isMedia;
|
|
36
40
|
// The background can lead somewhere — e.g. the full video on YouTube (new tab; the app on phones).
|
|
37
|
-
const
|
|
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;
|
|
38
43
|
const provider = bgLink ? videoEmbed(bgLink)?.provider : undefined;
|
|
39
44
|
const watchLabel = media?.linkLabel || (provider ? `Watch the full video on ${provider}` : 'Watch the full video');
|
|
40
|
-
|
|
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}')` : '';
|
|
41
48
|
---
|
|
42
49
|
|
|
43
50
|
<section
|
|
@@ -47,11 +54,13 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
|
|
|
47
54
|
data-bg={isMesh ? 'mesh' : undefined}
|
|
48
55
|
data-motion={motion}
|
|
49
56
|
data-linked={bgLink ? 'true' : undefined}
|
|
57
|
+
data-parallax={isMedia && parallax ? 'true' : undefined}
|
|
50
58
|
style={isMedia ? `--hero-overlay: ${overlay}; ${bgImage}` : undefined}
|
|
51
59
|
>
|
|
52
60
|
{isMedia && (
|
|
53
61
|
<div class="b-hero__bg" aria-hidden="true">
|
|
54
|
-
{
|
|
62
|
+
{embed && <div class="b-hero__embed" data-embed-url={embed.embedUrl} data-provider={embed.provider}></div>}
|
|
63
|
+
{media?.video && !embed ? (
|
|
55
64
|
<video autoplay muted loop playsinline poster={media.poster} preload="metadata">
|
|
56
65
|
<source src={media.video} />
|
|
57
66
|
</video>
|
|
@@ -80,7 +89,7 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
|
|
|
80
89
|
</a>
|
|
81
90
|
)}
|
|
82
91
|
{motion && (
|
|
83
|
-
<button type="button" class="b-hero__pause" aria-pressed="false" aria-label="Pause background">
|
|
92
|
+
<button type="button" class="b-hero__pause" aria-pressed="false" aria-label="Pause background" hidden={motion === 'embed'}>
|
|
84
93
|
<span class="b-hero__pause-icon" aria-hidden="true"></span>
|
|
85
94
|
</button>
|
|
86
95
|
)}
|
|
@@ -92,13 +101,50 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
|
|
|
92
101
|
</section>
|
|
93
102
|
|
|
94
103
|
<script>
|
|
95
|
-
// Moving hero backgrounds: pause button, reduced-motion,
|
|
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';
|
|
96
108
|
const SLIDE_MS = 6000;
|
|
97
109
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
98
110
|
for (const hero of document.querySelectorAll<HTMLElement>('.b-hero[data-motion]')) {
|
|
99
111
|
const button = hero.querySelector<HTMLButtonElement>('.b-hero__pause');
|
|
100
112
|
const video = hero.querySelector('video');
|
|
101
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
|
+
};
|
|
102
148
|
let paused = reduce;
|
|
103
149
|
let visible = true;
|
|
104
150
|
let index = 0;
|
|
@@ -115,6 +161,7 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
|
|
|
115
161
|
if (run) void video.play().catch(() => {});
|
|
116
162
|
else video.pause();
|
|
117
163
|
}
|
|
164
|
+
if (player) command(run);
|
|
118
165
|
if (slides.length > 1) {
|
|
119
166
|
window.clearInterval(timer);
|
|
120
167
|
timer = run ? window.setInterval(tick, SLIDE_MS) : undefined;
|
|
@@ -128,8 +175,41 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
|
|
|
128
175
|
button?.addEventListener('click', () => { paused = !paused; sync(); });
|
|
129
176
|
document.addEventListener('visibilitychange', sync);
|
|
130
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
|
+
}
|
|
131
191
|
sync();
|
|
132
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
|
+
}
|
|
133
213
|
</script>
|
|
134
214
|
|
|
135
215
|
<style>
|
|
@@ -246,11 +326,43 @@ const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-im
|
|
|
246
326
|
background-position: center;
|
|
247
327
|
background-color: var(--bg-section);
|
|
248
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
|
+
}
|
|
249
334
|
.b-hero__bg video {
|
|
250
335
|
width: 100%;
|
|
251
336
|
height: 100%;
|
|
252
337
|
object-fit: cover;
|
|
253
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
|
+
}
|
|
254
366
|
.b-hero__slide {
|
|
255
367
|
position: absolute;
|
|
256
368
|
inset: 0;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* LinkRow — a titled row of links as tappable pills (phone, email, web), each with the
|
|
4
|
+
* icon its link implies (lib/links), plus an optional intro and footnote. For a contact
|
|
5
|
+
* who doesn't need a whole contact card: "Vocations — phone · email · website".
|
|
6
|
+
*/
|
|
7
|
+
import Heading from '../blocks/Heading.astro';
|
|
8
|
+
import Icon from '../Icon.astro';
|
|
9
|
+
import { linkIcon } from '../../lib/links';
|
|
10
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title?: string;
|
|
14
|
+
intro?: string;
|
|
15
|
+
links: Array<{ label: string; href: string }>;
|
|
16
|
+
note?: string;
|
|
17
|
+
}
|
|
18
|
+
const { title, intro, links = [], note } = Astro.props;
|
|
19
|
+
const external = (href: string) => /^https?:\/\//i.test(href);
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<section class="b-linkrow">
|
|
23
|
+
{title && <Heading text={title} level={3} />}
|
|
24
|
+
{intro && <p class="b-linkrow__intro" set:html={inlineMarkdown(intro)} />}
|
|
25
|
+
<ul class="b-linkrow__links">
|
|
26
|
+
{links.map((l) => (
|
|
27
|
+
<li>
|
|
28
|
+
<a class="b-linkrow__pill" href={l.href} target={external(l.href) ? '_blank' : undefined} rel={external(l.href) ? 'noopener' : undefined}>
|
|
29
|
+
<Icon name={linkIcon(l.href)} size={18} />
|
|
30
|
+
<span>{l.label}</span>
|
|
31
|
+
</a>
|
|
32
|
+
</li>
|
|
33
|
+
))}
|
|
34
|
+
</ul>
|
|
35
|
+
{note && <p class="b-linkrow__note" set:html={inlineMarkdown(note)} />}
|
|
36
|
+
</section>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
.b-linkrow {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
gap: 0.75rem;
|
|
43
|
+
max-width: var(--max);
|
|
44
|
+
}
|
|
45
|
+
:global([data-content-align='center']) .b-linkrow {
|
|
46
|
+
margin-inline: auto;
|
|
47
|
+
align-items: center;
|
|
48
|
+
text-align: center;
|
|
49
|
+
}
|
|
50
|
+
.b-linkrow__intro,
|
|
51
|
+
.b-linkrow__note {
|
|
52
|
+
margin: 0;
|
|
53
|
+
color: var(--muted);
|
|
54
|
+
line-height: 1.6;
|
|
55
|
+
}
|
|
56
|
+
.b-linkrow__note {
|
|
57
|
+
font-size: 0.9375rem;
|
|
58
|
+
}
|
|
59
|
+
.b-linkrow__links {
|
|
60
|
+
list-style: none;
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 0;
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-wrap: wrap;
|
|
65
|
+
gap: 0.6rem;
|
|
66
|
+
}
|
|
67
|
+
.b-linkrow__pill {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 0.5rem;
|
|
71
|
+
padding: 0.55rem 1rem;
|
|
72
|
+
border-radius: 999px;
|
|
73
|
+
border: var(--card-border);
|
|
74
|
+
background: var(--bg-surface);
|
|
75
|
+
color: var(--fg);
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
text-decoration: none;
|
|
78
|
+
overflow-wrap: anywhere;
|
|
79
|
+
}
|
|
80
|
+
.b-linkrow__pill :global(svg) {
|
|
81
|
+
color: var(--gold);
|
|
82
|
+
flex: none;
|
|
83
|
+
}
|
|
84
|
+
.b-linkrow__pill:hover {
|
|
85
|
+
background: var(--bg-section);
|
|
86
|
+
}
|
|
87
|
+
.b-linkrow__pill:focus-visible {
|
|
88
|
+
outline: 2px solid var(--gold);
|
|
89
|
+
outline-offset: 2px;
|
|
90
|
+
}
|
|
91
|
+
</style>
|