ferst-core 0.7.2 → 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 (37) 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/Document.astro +152 -0
  8. package/components/recipes/FeatureCards.astro +112 -111
  9. package/components/recipes/Hero.astro +52 -4
  10. package/components/recipes/LinkRow.astro +91 -0
  11. package/components/recipes/MediaCards.astro +213 -212
  12. package/components/recipes/Notice.astro +90 -89
  13. package/components/recipes/Stats.astro +152 -145
  14. package/components/recipes/Table.astro +98 -97
  15. package/components/recipes/Tabs.astro +134 -133
  16. package/components/recipes/Tiles.astro +4 -2
  17. package/components/recipes/Timetable.astro +189 -0
  18. package/components/recipes/Video.astro +177 -157
  19. package/content/blocks.ts +65 -1
  20. package/content/cookies.ts +10 -0
  21. package/examples/primitives.ts +2 -0
  22. package/examples/recipes.ts +5 -0
  23. package/integration.mjs +83 -64
  24. package/layouts/Base.astro +481 -475
  25. package/lib/documents.ts +68 -62
  26. package/lib/embed-consent.ts +78 -73
  27. package/lib/inline-markdown.ts +6 -0
  28. package/lib/links.ts +12 -0
  29. package/lib/paginate.ts +23 -0
  30. package/lib/posts.ts +20 -2
  31. package/lib/timetable.ts +27 -0
  32. package/lib/video.ts +101 -75
  33. package/package.json +1 -1
  34. package/routes/cookie-policy.astro +27 -0
  35. package/routes/posts/index.astro +36 -31
  36. package/routes/posts/page/[page].astro +36 -0
  37. 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>
@@ -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.addEventListener('load', () => window.setTimeout(() => embedSlot.classList.add('is-on'), 600));
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
- if (hasConsent()) loadPlayer();
166
- onConsentChange((allowed) => { if (allowed) loadPlayer(); });
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>