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.
Files changed (38) hide show
  1. package/components/BlockRenderer.astro +6 -0
  2. package/components/Pagination.astro +51 -0
  3. package/components/blocks/Heading.astro +47 -46
  4. package/components/blocks/Prose.astro +10 -2
  5. package/components/blocks/Quote.astro +40 -38
  6. package/components/recipes/Accordion.astro +91 -84
  7. package/components/recipes/ContactCard.astro +172 -170
  8. package/components/recipes/Document.astro +152 -0
  9. package/components/recipes/FeatureCards.astro +112 -111
  10. package/components/recipes/Hero.astro +120 -8
  11. package/components/recipes/LinkRow.astro +91 -0
  12. package/components/recipes/MediaCards.astro +213 -212
  13. package/components/recipes/Notice.astro +90 -89
  14. package/components/recipes/Stats.astro +152 -145
  15. package/components/recipes/Table.astro +98 -97
  16. package/components/recipes/Tabs.astro +134 -133
  17. package/components/recipes/Tiles.astro +287 -271
  18. package/components/recipes/Timetable.astro +189 -0
  19. package/components/recipes/Video.astro +177 -157
  20. package/content/blocks.ts +68 -2
  21. package/content/cookies.ts +10 -0
  22. package/examples/primitives.ts +2 -0
  23. package/examples/recipes.ts +42 -36
  24. package/integration.mjs +83 -64
  25. package/layouts/Base.astro +481 -475
  26. package/lib/documents.ts +68 -62
  27. package/lib/embed-consent.ts +78 -73
  28. package/lib/inline-markdown.ts +12 -0
  29. package/lib/links.ts +12 -0
  30. package/lib/paginate.ts +23 -0
  31. package/lib/posts.ts +20 -2
  32. package/lib/timetable.ts +27 -0
  33. package/lib/video.ts +101 -56
  34. package/package.json +1 -1
  35. package/routes/cookie-policy.astro +27 -0
  36. package/routes/posts/index.astro +36 -31
  37. package/routes/posts/page/[page].astro +36 -0
  38. 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
- interface Props {
14
- title?: string;
15
- intro?: string;
16
- columns?: 2 | 3 | 4;
17
- cards?: { icon?: string; title: string; text?: string; href?: string }[];
18
- }
19
- const { title, intro, columns = 3, cards = [] } = Astro.props;
20
- // Never reserve more columns than there are cards (a lone card in a 3-col grid
21
- // would strand two empty cells); `data-single` centres a solitary card.
22
- const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3 | 4;
23
- ---
24
-
25
- <section class="b-feature-cards">
26
- {(title || intro) && (
27
- <div class="b-feature-cards__head">
28
- {title && <Heading text={title} level={2} align="center" />}
29
- {intro && <p class="b-feature-cards__intro">{intro}</p>}
30
- </div>
31
- )}
32
- <div class="b-feature-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
33
- {cards.map((card) => (
34
- <Card>
35
- {card.icon && isIconKey(card.icon) && (
36
- <span class="b-feature-cards__icon"><Icon name={card.icon} size={28} /></span>
37
- )}
38
- <h3 class="b-feature-cards__title">{card.title}</h3>
39
- {card.text && <p class="b-feature-cards__text">{card.text}</p>}
40
- {card.href && (
41
- <span class="b-feature-cards__cta">
42
- <Button href={card.href} variant="secondary" size="sm">Learn more</Button>
43
- </span>
44
- )}
45
- </Card>
46
- ))}
47
- </div>
48
- </section>
49
-
50
- <style>
51
- .b-feature-cards {
52
- display: flex;
53
- flex-direction: column;
54
- gap: 2rem;
55
- max-width: 72rem;
56
- margin-inline: auto;
57
- padding-inline: 1rem;
58
- }
59
- .b-feature-cards__head {
60
- max-width: var(--max);
61
- margin-inline: auto;
62
- text-align: center;
63
- display: flex;
64
- flex-direction: column;
65
- gap: 0.75rem;
66
- }
67
- .b-feature-cards__intro {
68
- margin: 0;
69
- color: var(--muted);
70
- font-size: 1.0625rem;
71
- line-height: 1.6;
72
- }
73
- .b-feature-cards__grid {
74
- display: grid;
75
- grid-template-columns: 1fr; /* mobile-first */
76
- gap: 1.5rem;
77
- }
78
- @media (min-width: 48em) {
79
- .b-feature-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
80
- .b-feature-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
81
- .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
82
- }
83
- /* A solitary card is a centred feature, not a full-width slab. */
84
- .b-feature-cards__grid[data-single] { max-width: 26rem; margin-inline: auto;
85
- }
86
- @media (min-width: 64em) {
87
- .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
88
- }
89
- .b-feature-cards__icon {
90
- color: var(--gold);
91
- margin-bottom: 0.5rem;
92
- display: inline-flex;
93
- }
94
- .b-feature-cards__title {
95
- margin: 0 0 0.5rem;
96
- font-size: 1.125rem;
97
- color: var(--fg);
98
- }
99
- .b-feature-cards__text {
100
- margin: 0;
101
- color: var(--muted);
102
- line-height: 1.6;
103
- }
104
- /* Push the CTA to the bottom so a row of cards aligns its buttons regardless
105
- of how much text each card has. Cards are equal height already (grid items
106
- stretch), and Card is a flex column — margin-top:auto does the rest. */
107
- .b-feature-cards__cta {
108
- margin-top: auto;
109
- padding-top: 1rem;
110
- }
111
- </style>
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 && (media?.video || slides.length) ? (media?.video ? 'video' : 'slides') : undefined;
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 bgLink = isMedia && media?.link && /^(https?:\/\/|\/)/i.test(media.link) ? media.link : undefined;
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
- const bgImage = isMedia && !media?.video && !slides.length && still ? `--hero-image: url('${still}')` : '';
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
- {media?.video ? (
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, and the slideshow timer.
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>