ferst-core 0.7.2 → 0.7.5
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 +33 -2
- package/cms/config.template.yml +214 -206
- package/components/BlockRenderer.astro +10 -0
- package/components/Pagination.astro +51 -0
- package/components/SiteFooter.astro +274 -270
- package/components/SiteHeader.astro +1243 -1183
- package/components/blocks/Heading.astro +51 -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/Contacts.astro +139 -0
- package/components/recipes/Document.astro +157 -0
- package/components/recipes/FeatureCards.astro +112 -111
- package/components/recipes/Hero.astro +52 -4
- package/components/recipes/LinkRow.astro +91 -0
- package/components/recipes/MediaCards.astro +213 -212
- package/components/recipes/MediaText.astro +92 -0
- package/components/recipes/Notice.astro +90 -89
- package/components/recipes/Stats.astro +152 -145
- package/components/recipes/Table.astro +101 -97
- package/components/recipes/Tabs.astro +134 -133
- package/components/recipes/Tiles.astro +4 -2
- package/components/recipes/Timetable.astro +189 -0
- package/components/recipes/Video.astro +177 -157
- package/content/blocks.ts +113 -1
- package/content/cookies.ts +10 -0
- package/content/schemas.ts +167 -161
- package/examples/primitives.ts +2 -0
- package/examples/recipes.ts +7 -0
- package/integration.mjs +83 -64
- package/layouts/Base.astro +483 -475
- package/lib/documents.ts +68 -62
- package/lib/embed-consent.ts +78 -73
- package/lib/inline-markdown.ts +6 -0
- package/lib/links.ts +22 -0
- package/lib/paginate.ts +23 -0
- package/lib/posts.ts +20 -2
- package/lib/siteSettings.ts +61 -60
- package/lib/slug.ts +32 -0
- package/lib/table.ts +10 -0
- package/lib/timetable.ts +27 -0
- package/lib/video.ts +101 -75
- 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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Document — a policy or legal text as ONE block. Sections are numbered automatically,
|
|
4
|
+
* a contents list links to each (beside the text, sticky, on wide screens), and an
|
|
5
|
+
* optional "last updated" line sits at the top. Section bodies take prose formatting
|
|
6
|
+
* (links, bold, lists); `items` add a list. Print-friendly: the contents list is
|
|
7
|
+
* dropped on paper.
|
|
8
|
+
*/
|
|
9
|
+
import { proseParts } from '../../lib/prose';
|
|
10
|
+
import { inlineMarkdown } from '../../lib/inline-markdown';
|
|
11
|
+
import { longDate } from '../../lib/documents';
|
|
12
|
+
import { pageSlugs, uniqueSlug } from '../../lib/slug';
|
|
13
|
+
|
|
14
|
+
interface Section { title: string; body?: string; items?: string[] }
|
|
15
|
+
interface Props {
|
|
16
|
+
intro?: string;
|
|
17
|
+
updated?: string;
|
|
18
|
+
numbered?: boolean;
|
|
19
|
+
toc?: boolean;
|
|
20
|
+
sections: Section[];
|
|
21
|
+
}
|
|
22
|
+
const { intro, updated, numbered = true, toc = true, sections = [] } = Astro.props;
|
|
23
|
+
const updatedLabel = longDate(updated);
|
|
24
|
+
const showToc = toc && sections.length > 1;
|
|
25
|
+
// Readable anchors that survive renumbering (#payment); #clause-N stays on the heading.
|
|
26
|
+
const used = pageSlugs(Astro.locals);
|
|
27
|
+
const ids = sections.map((s) => uniqueSlug(s.title, used));
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<section class="b-doc" data-toc={showToc ? 'true' : undefined}>
|
|
31
|
+
{showToc && (
|
|
32
|
+
<nav class="b-doc__toc" aria-label="Contents">
|
|
33
|
+
<p class="b-doc__toc-title">Contents</p>
|
|
34
|
+
<ol>
|
|
35
|
+
{sections.map((s, i) => (
|
|
36
|
+
<li><a href={`#${ids[i]}`}>{numbered && <span class="b-doc__toc-num">{i + 1}.</span>} {s.title}</a></li>
|
|
37
|
+
))}
|
|
38
|
+
</ol>
|
|
39
|
+
</nav>
|
|
40
|
+
)}
|
|
41
|
+
<div class="b-doc__body">
|
|
42
|
+
{updatedLabel && <p class="b-doc__updated">Last updated {updatedLabel}</p>}
|
|
43
|
+
{intro && <p class="b-doc__intro" set:html={inlineMarkdown(intro)} />}
|
|
44
|
+
{sections.map((s, i) => (
|
|
45
|
+
<section class="b-doc__clause" id={ids[i]}>
|
|
46
|
+
<h2 id={`clause-${i + 1}`}>{numbered && <span class="b-doc__num">{i + 1}.</span>} {s.title}</h2>
|
|
47
|
+
{proseParts(s.body).map((part) =>
|
|
48
|
+
part.kind === 'p' ? <p set:html={part.html} />
|
|
49
|
+
: part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
|
|
50
|
+
: <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
|
|
51
|
+
)}
|
|
52
|
+
{s.items && s.items.length > 0 && <ul>{s.items.map((x) => <li set:html={inlineMarkdown(x)} />)}</ul>}
|
|
53
|
+
</section>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<style>
|
|
59
|
+
.b-doc {
|
|
60
|
+
display: grid;
|
|
61
|
+
gap: 2rem;
|
|
62
|
+
width: 100%;
|
|
63
|
+
max-width: 64rem;
|
|
64
|
+
}
|
|
65
|
+
@media (min-width: 64em) {
|
|
66
|
+
.b-doc[data-toc] {
|
|
67
|
+
grid-template-columns: 15rem minmax(0, 1fr);
|
|
68
|
+
align-items: start;
|
|
69
|
+
}
|
|
70
|
+
.b-doc__toc {
|
|
71
|
+
position: sticky;
|
|
72
|
+
top: 6rem;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
.b-doc__toc {
|
|
76
|
+
border: var(--card-border);
|
|
77
|
+
border-radius: var(--radius);
|
|
78
|
+
background: var(--bg-surface);
|
|
79
|
+
padding: 1rem 1.1rem;
|
|
80
|
+
}
|
|
81
|
+
.b-doc__toc-title {
|
|
82
|
+
margin: 0 0 0.5rem;
|
|
83
|
+
font-size: 0.75rem;
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
letter-spacing: 0.08em;
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
color: var(--muted);
|
|
88
|
+
}
|
|
89
|
+
.b-doc__toc ol {
|
|
90
|
+
list-style: none;
|
|
91
|
+
margin: 0;
|
|
92
|
+
padding: 0;
|
|
93
|
+
display: grid;
|
|
94
|
+
gap: 0.35rem;
|
|
95
|
+
font-size: 0.9375rem;
|
|
96
|
+
}
|
|
97
|
+
.b-doc__toc a {
|
|
98
|
+
color: var(--fg);
|
|
99
|
+
text-decoration: none;
|
|
100
|
+
}
|
|
101
|
+
.b-doc__toc a:hover {
|
|
102
|
+
color: var(--gold);
|
|
103
|
+
text-decoration: underline;
|
|
104
|
+
}
|
|
105
|
+
.b-doc__toc-num {
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
font-variant-numeric: tabular-nums;
|
|
108
|
+
}
|
|
109
|
+
.b-doc__body {
|
|
110
|
+
max-width: var(--max);
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: 1.75rem;
|
|
114
|
+
}
|
|
115
|
+
.b-doc__updated {
|
|
116
|
+
margin: 0;
|
|
117
|
+
font-size: 0.875rem;
|
|
118
|
+
color: var(--muted);
|
|
119
|
+
}
|
|
120
|
+
.b-doc__intro {
|
|
121
|
+
margin: 0;
|
|
122
|
+
font-size: 1.125rem;
|
|
123
|
+
line-height: 1.6;
|
|
124
|
+
color: var(--muted);
|
|
125
|
+
}
|
|
126
|
+
.b-doc__clause {
|
|
127
|
+
scroll-margin-top: 6rem;
|
|
128
|
+
}
|
|
129
|
+
.b-doc__clause h2 {
|
|
130
|
+
scroll-margin-top: 6rem;
|
|
131
|
+
margin: 0 0 0.6rem;
|
|
132
|
+
font-size: 1.25rem;
|
|
133
|
+
}
|
|
134
|
+
.b-doc__num {
|
|
135
|
+
color: var(--gold);
|
|
136
|
+
font-variant-numeric: tabular-nums;
|
|
137
|
+
}
|
|
138
|
+
.b-doc__clause p,
|
|
139
|
+
.b-doc__clause ul,
|
|
140
|
+
.b-doc__clause ol {
|
|
141
|
+
margin: 0 0 0.75rem;
|
|
142
|
+
line-height: 1.7;
|
|
143
|
+
}
|
|
144
|
+
.b-doc__clause ul,
|
|
145
|
+
.b-doc__clause ol {
|
|
146
|
+
padding-inline-start: 1.4em;
|
|
147
|
+
}
|
|
148
|
+
.b-doc__clause :global(a) {
|
|
149
|
+
color: var(--accent);
|
|
150
|
+
text-decoration: underline;
|
|
151
|
+
text-underline-offset: 0.15em;
|
|
152
|
+
}
|
|
153
|
+
@media print {
|
|
154
|
+
.b-doc__toc { display: none; }
|
|
155
|
+
.b-doc[data-toc] { grid-template-columns: 1fr; }
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
@@ -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>
|
|
@@ -24,9 +24,10 @@ 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;
|
|
30
31
|
// A YouTube / Vimeo link plays as an embedded background (after consent); anything else
|
|
31
32
|
// in `video` is a file for a native <video>.
|
|
32
33
|
const embed = media?.video ? backgroundEmbed(media.video) : null;
|
|
@@ -53,6 +54,7 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
53
54
|
data-bg={isMesh ? 'mesh' : undefined}
|
|
54
55
|
data-motion={motion}
|
|
55
56
|
data-linked={bgLink ? 'true' : undefined}
|
|
57
|
+
data-parallax={isMedia && parallax ? 'true' : undefined}
|
|
56
58
|
style={isMedia ? `--hero-overlay: ${overlay}; ${bgImage}` : undefined}
|
|
57
59
|
>
|
|
58
60
|
{isMedia && (
|
|
@@ -102,6 +104,7 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
102
104
|
// Moving hero backgrounds: pause button, reduced-motion, the slideshow timer, and an
|
|
103
105
|
// embedded (YouTube / Vimeo) background that loads only once the visitor consents.
|
|
104
106
|
import { hasConsent, onConsentChange } from '../../lib/consent-client';
|
|
107
|
+
import { playerStarted } from '../../lib/video';
|
|
105
108
|
const SLIDE_MS = 6000;
|
|
106
109
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
107
110
|
for (const hero of document.querySelectorAll<HTMLElement>('.b-hero[data-motion]')) {
|
|
@@ -126,7 +129,18 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
126
129
|
player.tabIndex = -1;
|
|
127
130
|
player.setAttribute('aria-hidden', 'true');
|
|
128
131
|
player.allow = 'autoplay; encrypted-media; picture-in-picture';
|
|
129
|
-
player
|
|
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
|
+
});
|
|
130
144
|
embedSlot.appendChild(player);
|
|
131
145
|
if (button) button.hidden = false;
|
|
132
146
|
sync();
|
|
@@ -162,11 +176,40 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
162
176
|
document.addEventListener('visibilitychange', sync);
|
|
163
177
|
new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }).observe(hero);
|
|
164
178
|
if (embedSlot) {
|
|
165
|
-
|
|
166
|
-
|
|
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(); } });
|
|
167
190
|
}
|
|
168
191
|
sync();
|
|
169
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
|
+
}
|
|
170
213
|
</script>
|
|
171
214
|
|
|
172
215
|
<style>
|
|
@@ -283,6 +326,11 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
283
326
|
background-position: center;
|
|
284
327
|
background-color: var(--bg-section);
|
|
285
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
|
+
}
|
|
286
334
|
.b-hero__bg video {
|
|
287
335
|
width: 100%;
|
|
288
336
|
height: 100%;
|
|
@@ -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>
|