ferst-core 0.1.0 → 0.2.1

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 (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +93 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
@@ -0,0 +1,77 @@
1
+ ---
2
+ /** Logos — a Level-2 recipe: a "trusted by" wall of images. Muted (greyscale +
3
+ * dimmed) by default, coming to full colour on hover/focus. Each logo optionally
4
+ * links out. Decorative-leaning, so it stays quiet against the page. */
5
+ interface Item { src: string; alt?: string; href?: string }
6
+ interface Props {
7
+ title?: string;
8
+ items?: Item[];
9
+ }
10
+ const { title, items = [] } = Astro.props;
11
+ ---
12
+
13
+ <section class="b-logos">
14
+ {title && <p class="b-logos__title">{title}</p>}
15
+ <ul class="b-logos__row">
16
+ {items.map((item) => (
17
+ <li class="b-logos__item">
18
+ {item.href ? (
19
+ <a href={item.href} target="_blank" rel="noopener noreferrer">
20
+ <img src={item.src} alt={item.alt ?? ''} loading="lazy" decoding="async" />
21
+ </a>
22
+ ) : (
23
+ <img src={item.src} alt={item.alt ?? ''} loading="lazy" decoding="async" />
24
+ )}
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ </section>
29
+
30
+ <style>
31
+ .b-logos {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 1.5rem;
35
+ max-width: 72rem;
36
+ margin-inline: auto;
37
+ padding-inline: 1rem;
38
+ }
39
+ .b-logos__title {
40
+ margin: 0;
41
+ text-align: center;
42
+ font-size: 0.8125rem;
43
+ font-weight: 600;
44
+ letter-spacing: 0.1em;
45
+ text-transform: uppercase;
46
+ color: var(--muted);
47
+ }
48
+ .b-logos__row {
49
+ list-style: none;
50
+ margin: 0;
51
+ padding: 0;
52
+ display: flex;
53
+ flex-wrap: wrap;
54
+ align-items: center;
55
+ justify-content: center;
56
+ gap: clamp(1.5rem, 5vw, 3.5rem);
57
+ }
58
+ .b-logos__item img {
59
+ display: block;
60
+ height: clamp(1.75rem, 4vw, 2.5rem);
61
+ width: auto;
62
+ filter: grayscale(1);
63
+ opacity: 0.6;
64
+ transition: filter 0.2s ease, opacity 0.2s ease;
65
+ }
66
+ .b-logos__item a:hover img,
67
+ .b-logos__item a:focus-visible img,
68
+ .b-logos__item img:hover {
69
+ filter: grayscale(0);
70
+ opacity: 1;
71
+ }
72
+ .b-logos__item a:focus-visible {
73
+ outline: 2px solid var(--gold);
74
+ outline-offset: 4px;
75
+ border-radius: 4px;
76
+ }
77
+ </style>
@@ -0,0 +1,75 @@
1
+ ---
2
+ /** Marquee — a Level-2 recipe: an infinite horizontal scroll of short phrases
3
+ * (a feature strip). Pure-CSS transform loop (GPU-composited); pauses on hover.
4
+ * Under reduced-motion it doesn't move — the phrases wrap and centre statically.
5
+ * The moving track is duplicated for a seamless loop and marked decorative. */
6
+ interface Props {
7
+ items?: string[];
8
+ speed?: 'slow' | 'normal' | 'fast';
9
+ }
10
+ const { items = [], speed = 'normal' } = Astro.props;
11
+ ---
12
+
13
+ <div class="b-marquee" data-speed={speed}>
14
+ <div class="b-marquee__track" aria-hidden="true">
15
+ {[...items, ...items].map((item) => (
16
+ <span class="b-marquee__item">{item}</span>
17
+ ))}
18
+ </div>
19
+ </div>
20
+
21
+ <style>
22
+ .b-marquee {
23
+ overflow: hidden;
24
+ border-block: 1px solid var(--border);
25
+ padding-block: 0.85rem;
26
+ background: var(--bg-section);
27
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
28
+ mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
29
+ }
30
+ .b-marquee__track {
31
+ display: flex;
32
+ width: max-content;
33
+ gap: 0;
34
+ animation: b-marquee-scroll linear infinite;
35
+ }
36
+ .b-marquee[data-speed='slow'] .b-marquee__track { animation-duration: 45s; }
37
+ .b-marquee[data-speed='normal'] .b-marquee__track { animation-duration: 30s; }
38
+ .b-marquee[data-speed='fast'] .b-marquee__track { animation-duration: 18s; }
39
+ .b-marquee:hover .b-marquee__track { animation-play-state: paused; }
40
+
41
+ .b-marquee__item {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ padding-inline: 1.75rem;
45
+ font-family: var(--font-heading);
46
+ font-weight: 600;
47
+ font-size: 0.95rem;
48
+ color: var(--fg);
49
+ white-space: nowrap;
50
+ }
51
+ .b-marquee__item::after {
52
+ content: '';
53
+ width: 6px;
54
+ height: 6px;
55
+ margin-left: 3.5rem;
56
+ border-radius: 999px;
57
+ background: var(--gold);
58
+ }
59
+
60
+ @keyframes b-marquee-scroll {
61
+ from { transform: translateX(0); }
62
+ to { transform: translateX(-50%); }
63
+ }
64
+
65
+ @media (prefers-reduced-motion: reduce) {
66
+ .b-marquee { -webkit-mask-image: none; mask-image: none; }
67
+ .b-marquee__track {
68
+ animation: none;
69
+ width: auto;
70
+ flex-wrap: wrap;
71
+ justify-content: center;
72
+ gap: 0.25rem 0;
73
+ }
74
+ }
75
+ </style>
@@ -0,0 +1,149 @@
1
+ ---
2
+ /** Pricing — a Level-2 recipe: a row of plan cards (name, price, period,
3
+ * feature list, CTA). One plan may be `featured`. Composes Heading + Button +
4
+ * the check icon. Token-driven, so it recolours + restyles with the theme. */
5
+ import Heading from '../blocks/Heading.astro';
6
+ import Button from '../Button.astro';
7
+ import Icon from '../Icon.astro';
8
+
9
+ interface Plan {
10
+ name: string;
11
+ price: string;
12
+ period?: string;
13
+ description?: string;
14
+ features?: string[];
15
+ cta?: { label: string; href: string };
16
+ featured?: boolean;
17
+ }
18
+ interface Props {
19
+ title?: string;
20
+ intro?: string;
21
+ plans?: Plan[];
22
+ }
23
+ const { title, intro, plans = [] } = Astro.props;
24
+ ---
25
+
26
+ <section class="b-pricing">
27
+ {(title || intro) && (
28
+ <div class="b-pricing__head">
29
+ {title && <Heading text={title} level={2} align="center" />}
30
+ {intro && <p class="b-pricing__intro">{intro}</p>}
31
+ </div>
32
+ )}
33
+ <div class="b-pricing__grid" data-count={plans.length}>
34
+ {plans.map((plan) => (
35
+ <div class="b-pricing__plan" data-featured={plan.featured ? 'true' : 'false'}>
36
+ {plan.featured && <span class="b-pricing__badge">Most popular</span>}
37
+ <p class="b-pricing__name">{plan.name}</p>
38
+ <p class="b-pricing__price">
39
+ <span class="b-pricing__amount">{plan.price}</span>
40
+ {plan.period && <span class="b-pricing__period">{plan.period}</span>}
41
+ </p>
42
+ {plan.description && <p class="b-pricing__desc">{plan.description}</p>}
43
+ {plan.features && plan.features.length > 0 && (
44
+ <ul class="b-pricing__features">
45
+ {plan.features.map((f) => (
46
+ <li><span class="b-pricing__check" aria-hidden="true"><Icon name="check" size={16} /></span>{f}</li>
47
+ ))}
48
+ </ul>
49
+ )}
50
+ {plan.cta && (
51
+ <div class="b-pricing__cta">
52
+ <Button href={plan.cta.href} variant={plan.featured ? 'cta' : 'secondary'}>{plan.cta.label}</Button>
53
+ </div>
54
+ )}
55
+ </div>
56
+ ))}
57
+ </div>
58
+ </section>
59
+
60
+ <style>
61
+ .b-pricing {
62
+ display: flex;
63
+ flex-direction: column;
64
+ gap: 2rem;
65
+ max-width: 72rem;
66
+ margin-inline: auto;
67
+ padding-inline: 1rem;
68
+ }
69
+ .b-pricing__head {
70
+ max-width: var(--max);
71
+ margin-inline: auto;
72
+ text-align: center;
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 0.75rem;
76
+ }
77
+ .b-pricing__intro { margin: 0; color: var(--muted); line-height: 1.6; }
78
+ .b-pricing__grid {
79
+ display: grid;
80
+ grid-template-columns: 1fr;
81
+ gap: 1.5rem;
82
+ align-items: start;
83
+ }
84
+ @media (min-width: 48em) {
85
+ .b-pricing__grid[data-count='2'] { grid-template-columns: repeat(2, 1fr); }
86
+ .b-pricing__grid[data-count='3'] { grid-template-columns: repeat(3, 1fr); }
87
+ .b-pricing__grid[data-count='4'] { grid-template-columns: repeat(2, 1fr); }
88
+ }
89
+ @media (min-width: 64em) {
90
+ .b-pricing__grid[data-count='4'] { grid-template-columns: repeat(4, 1fr); }
91
+ }
92
+ .b-pricing__plan {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 0.75rem;
96
+ padding: 1.75rem;
97
+ border-radius: var(--radius);
98
+ border: var(--card-border);
99
+ background: var(--bg-surface);
100
+ box-shadow: var(--shadow-md);
101
+ }
102
+ .b-pricing__plan[data-featured='true'] {
103
+ border-color: var(--gold);
104
+ box-shadow: var(--shadow-lg);
105
+ position: relative;
106
+ }
107
+ .b-pricing__badge {
108
+ align-self: flex-start;
109
+ font-size: 0.7rem;
110
+ font-weight: 700;
111
+ letter-spacing: 0.08em;
112
+ text-transform: uppercase;
113
+ color: var(--text-light);
114
+ background: var(--gold);
115
+ padding: 0.2rem 0.6rem;
116
+ border-radius: var(--radius-pill);
117
+ }
118
+ .b-pricing__name {
119
+ margin: 0;
120
+ font-family: var(--font-heading);
121
+ font-weight: 600;
122
+ font-size: 1.1rem;
123
+ color: var(--fg);
124
+ }
125
+ .b-pricing__price { margin: 0; display: flex; align-items: baseline; gap: 0.35rem; }
126
+ .b-pricing__amount {
127
+ font-family: var(--font-heading);
128
+ font-weight: 700;
129
+ font-size: clamp(2rem, 1.6rem + 1.4vw, 2.6rem);
130
+ line-height: 1;
131
+ color: var(--fg);
132
+ }
133
+ .b-pricing__period { color: var(--muted); font-size: 0.9rem; }
134
+ .b-pricing__desc { margin: 0; color: var(--muted); line-height: 1.5; }
135
+ .b-pricing__features {
136
+ list-style: none;
137
+ margin: 0.5rem 0;
138
+ padding: 0;
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 0.5rem;
142
+ color: var(--muted);
143
+ line-height: 1.5;
144
+ }
145
+ .b-pricing__features li { display: flex; gap: 0.5rem; align-items: flex-start; }
146
+ .b-pricing__check { color: var(--gold); display: inline-flex; margin-top: 0.1rem; flex-shrink: 0; }
147
+ .b-pricing__cta { margin-top: auto; padding-top: 0.5rem; }
148
+ .b-pricing__cta :global(.btn) { width: 100%; }
149
+ </style>
@@ -0,0 +1,124 @@
1
+ ---
2
+ /** Stats — a Level-2 recipe: a band of headline numbers over an optional
3
+ * heading/intro. Composes the Heading primitive; numbers use the brand accent. */
4
+ import Heading from '../blocks/Heading.astro';
5
+
6
+ interface Item { value: string; label: string; description?: string }
7
+ interface Props {
8
+ title?: string;
9
+ intro?: string;
10
+ background?: 'none' | 'muted' | 'accent';
11
+ columns?: 2 | 3 | 4;
12
+ items?: Item[];
13
+ }
14
+ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.props;
15
+ ---
16
+
17
+ <section class="b-stats" data-bg={background}>
18
+ <div class="b-stats__inner">
19
+ {(title || intro) && (
20
+ <div class="b-stats__head">
21
+ {title && <Heading text={title} level={2} align="center" />}
22
+ {intro && <p class="b-stats__intro">{intro}</p>}
23
+ </div>
24
+ )}
25
+ <div class="b-stats__grid" data-cols={columns}>
26
+ {items.map((item) => (
27
+ <div class="b-stats__item">
28
+ <span class="b-stats__value">{item.value}</span>
29
+ <span class="b-stats__label">{item.label}</span>
30
+ {item.description && <span class="b-stats__desc">{item.description}</span>}
31
+ </div>
32
+ ))}
33
+ </div>
34
+ </div>
35
+ </section>
36
+
37
+ <style>
38
+ .b-stats { padding-block: clamp(2.5rem, 6vw, 4rem); }
39
+ .b-stats[data-bg='muted'] { background: var(--bg-section); }
40
+ .b-stats[data-bg='accent'] { background: var(--gold-light); }
41
+ .b-stats__inner {
42
+ max-width: 72rem;
43
+ margin-inline: auto;
44
+ padding-inline: 1rem;
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 2rem;
48
+ }
49
+ .b-stats__head {
50
+ max-width: var(--max);
51
+ margin-inline: auto;
52
+ text-align: center;
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.75rem;
56
+ }
57
+ .b-stats__intro { margin: 0; color: var(--muted); line-height: 1.6; }
58
+ .b-stats__grid {
59
+ display: grid;
60
+ grid-template-columns: 1fr;
61
+ gap: 1.5rem 2rem;
62
+ }
63
+ @media (min-width: 40em) {
64
+ .b-stats__grid { grid-template-columns: repeat(2, 1fr); }
65
+ }
66
+ @media (min-width: 60em) {
67
+ .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
68
+ .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
69
+ }
70
+ .b-stats__item {
71
+ display: flex;
72
+ flex-direction: column;
73
+ align-items: center;
74
+ text-align: center;
75
+ gap: 0.35rem;
76
+ }
77
+ .b-stats__value {
78
+ font-family: var(--font-heading);
79
+ font-weight: 700;
80
+ font-size: clamp(2.25rem, 1.6rem + 2.6vw, 3.25rem);
81
+ line-height: 1;
82
+ color: var(--gold);
83
+ }
84
+ .b-stats__label { font-weight: 600; color: var(--fg); }
85
+ .b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; }
86
+ </style>
87
+
88
+ <script>
89
+ // Count-up: numbers tick from 0 to their value when they scroll into view.
90
+ // Progressive — the final value is server-rendered, so with no JS (or under
91
+ // reduced-motion) it just shows the number. Non-numeric values (One, Any…) are
92
+ // left untouched; a leading sign / trailing suffix (+, %, " day") is preserved.
93
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
94
+ const values = document.querySelectorAll<HTMLElement>('.b-stats__value');
95
+ if (!reduce && 'IntersectionObserver' in window && values.length) {
96
+ const parse = (t: string) => {
97
+ const m = t.match(/^(\D*)(\d[\d,]*(?:\.\d+)?)(.*)$/);
98
+ if (!m) return null;
99
+ const decimals = (m[2].split('.')[1] || '').length;
100
+ return { pre: m[1], num: parseFloat(m[2].replace(/,/g, '')), suf: m[3], decimals };
101
+ };
102
+ const fmt = (n: number, d: number) => (d > 0 ? n.toFixed(d) : Math.round(n).toLocaleString());
103
+ const run = (el: HTMLElement) => {
104
+ const final = (el.textContent || '').trim();
105
+ const p = parse(final);
106
+ if (!p) return;
107
+ const dur = 1100;
108
+ const t0 = performance.now();
109
+ const tick = (now: number) => {
110
+ const t = Math.min(1, (now - t0) / dur);
111
+ const eased = 1 - Math.pow(1 - t, 3);
112
+ el.textContent = p.pre + fmt(p.num * eased, p.decimals) + p.suf;
113
+ if (t < 1) requestAnimationFrame(tick);
114
+ else el.textContent = final;
115
+ };
116
+ requestAnimationFrame(tick);
117
+ };
118
+ const io = new IntersectionObserver(
119
+ (entries) => entries.forEach((e) => { if (e.isIntersecting) { run(e.target as HTMLElement); io.unobserve(e.target); } }),
120
+ { threshold: 0.4 }
121
+ );
122
+ values.forEach((v) => io.observe(v));
123
+ }
124
+ </script>
@@ -0,0 +1,120 @@
1
+ ---
2
+ /** Steps — a Level-2 recipe: a numbered process. `horizontal` lays connected
3
+ * cards in a row; `vertical` a stacked timeline. Composes Heading + Icon. */
4
+ import Heading from '../blocks/Heading.astro';
5
+ import Icon from '../Icon.astro';
6
+ import { isIconKey } from '../../lib/icons';
7
+
8
+ interface Item { title: string; text?: string; icon?: string }
9
+ interface Props {
10
+ title?: string;
11
+ intro?: string;
12
+ layout?: 'horizontal' | 'vertical';
13
+ items?: Item[];
14
+ }
15
+ const { title, intro, layout = 'horizontal', items = [] } = Astro.props;
16
+ ---
17
+
18
+ <section class="b-steps" data-layout={layout}>
19
+ {(title || intro) && (
20
+ <div class="b-steps__head">
21
+ {title && <Heading text={title} level={2} align="center" />}
22
+ {intro && <p class="b-steps__intro">{intro}</p>}
23
+ </div>
24
+ )}
25
+ <ol class="b-steps__list">
26
+ {items.map((item, i) => (
27
+ <li class="b-steps__item">
28
+ <span class="b-steps__marker" aria-hidden="true">
29
+ {item.icon && isIconKey(item.icon) ? <Icon name={item.icon} size={20} /> : i + 1}
30
+ </span>
31
+ <div class="b-steps__body">
32
+ <h3 class="b-steps__title">{item.title}</h3>
33
+ {item.text && <p class="b-steps__text">{item.text}</p>}
34
+ </div>
35
+ </li>
36
+ ))}
37
+ </ol>
38
+ </section>
39
+
40
+ <style>
41
+ .b-steps {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 2rem;
45
+ max-width: 72rem;
46
+ margin-inline: auto;
47
+ padding-inline: 1rem;
48
+ }
49
+ .b-steps__head {
50
+ max-width: var(--max);
51
+ margin-inline: auto;
52
+ text-align: center;
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.75rem;
56
+ }
57
+ .b-steps__intro { margin: 0; color: var(--muted); line-height: 1.6; }
58
+ .b-steps__list {
59
+ list-style: none;
60
+ margin: 0;
61
+ padding: 0;
62
+ display: grid;
63
+ gap: 1.5rem;
64
+ }
65
+ /* Base (and mobile): marker left, body right. */
66
+ .b-steps__item {
67
+ display: flex;
68
+ gap: 1rem;
69
+ align-items: flex-start;
70
+ }
71
+ .b-steps__marker {
72
+ flex-shrink: 0;
73
+ width: 2.5rem;
74
+ height: 2.5rem;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ border-radius: var(--radius-pill);
79
+ background: var(--gold);
80
+ color: var(--text-light);
81
+ font-family: var(--font-heading);
82
+ font-weight: 700;
83
+ font-size: 1.05rem;
84
+ }
85
+ .b-steps__title { margin: 0 0 0.35rem; font-size: 1.1rem; color: var(--fg); }
86
+ .b-steps__text { margin: 0; color: var(--muted); line-height: 1.6; }
87
+
88
+ /* Vertical: a centred, stacked timeline. */
89
+ .b-steps[data-layout='vertical'] .b-steps__list { max-width: 40rem; margin-inline: auto; }
90
+
91
+ /* Horizontal (≥48em): connected cards in a row, marker over centred body. */
92
+ @media (min-width: 48em) {
93
+ .b-steps[data-layout='horizontal'] .b-steps__list {
94
+ grid-auto-flow: column;
95
+ grid-auto-columns: 1fr;
96
+ gap: 1rem;
97
+ }
98
+ .b-steps[data-layout='horizontal'] .b-steps__item {
99
+ flex-direction: column;
100
+ align-items: center;
101
+ text-align: center;
102
+ gap: 0.85rem;
103
+ position: relative;
104
+ }
105
+ /* Connector from this marker's right edge to the next marker's left edge.
106
+ The +1.5rem / -2rem account for the 2.5rem marker AND the 1rem grid gap,
107
+ so the line sits centred between markers (not falling short). */
108
+ .b-steps[data-layout='horizontal'] .b-steps__item:not(:last-child)::after {
109
+ content: '';
110
+ position: absolute;
111
+ top: calc(1.25rem - 1px);
112
+ left: calc(50% + 1.5rem);
113
+ width: calc(100% - 2rem);
114
+ height: 2px;
115
+ background: var(--border);
116
+ z-index: 0;
117
+ }
118
+ .b-steps[data-layout='horizontal'] .b-steps__marker { position: relative; z-index: 1; }
119
+ }
120
+ </style>
@@ -0,0 +1,133 @@
1
+ ---
2
+ /** Tabs — a Level-2 recipe: labelled panels, one open at a time. Progressive:
3
+ * the first panel is visible with no JavaScript; a small script adds selection,
4
+ * ARIA state and arrow-key navigation. Composes the Heading primitive. */
5
+ import Heading from '../blocks/Heading.astro';
6
+
7
+ interface Item { label: string; heading?: string; text?: string; image?: { src: string; alt?: string } }
8
+ interface Props {
9
+ title?: string;
10
+ items?: Item[];
11
+ }
12
+ const { title, items = [] } = Astro.props;
13
+ const uid = 'tabs-' + Math.random().toString(36).slice(2, 9);
14
+ ---
15
+
16
+ <section class="b-tabs">
17
+ {title && <Heading text={title} level={2} align="center" />}
18
+ <div class="b-tabs__inner">
19
+ <div class="b-tabs__list" role="tablist" aria-label={title ?? 'Tabs'}>
20
+ {items.map((item, i) => (
21
+ <button
22
+ type="button"
23
+ role="tab"
24
+ id={`${uid}-tab-${i}`}
25
+ aria-controls={`${uid}-panel-${i}`}
26
+ aria-selected={i === 0 ? 'true' : 'false'}
27
+ tabindex={i === 0 ? 0 : -1}
28
+ class="b-tabs__tab"
29
+ >
30
+ {item.label}
31
+ </button>
32
+ ))}
33
+ </div>
34
+ {items.map((item, i) => (
35
+ <div
36
+ role="tabpanel"
37
+ id={`${uid}-panel-${i}`}
38
+ aria-labelledby={`${uid}-tab-${i}`}
39
+ class="b-tabs__panel"
40
+ hidden={i !== 0}
41
+ tabindex="0"
42
+ >
43
+ {item.image && (
44
+ <img class="b-tabs__img" src={item.image.src} alt={item.image.alt ?? ''} loading="lazy" decoding="async" />
45
+ )}
46
+ <div class="b-tabs__copy">
47
+ {item.heading && <h3 class="b-tabs__heading">{item.heading}</h3>}
48
+ {item.text && <p class="b-tabs__text">{item.text}</p>}
49
+ </div>
50
+ </div>
51
+ ))}
52
+ </div>
53
+ </section>
54
+
55
+ <style>
56
+ .b-tabs {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: 1.5rem;
60
+ max-width: 60rem;
61
+ margin-inline: auto;
62
+ padding-inline: 1rem;
63
+ }
64
+ .b-tabs__list {
65
+ display: flex;
66
+ flex-wrap: wrap;
67
+ gap: 0.25rem;
68
+ border-bottom: 1px solid var(--border);
69
+ }
70
+ .b-tabs__tab {
71
+ appearance: none;
72
+ background: none;
73
+ border: none;
74
+ border-bottom: 2px solid transparent;
75
+ margin-bottom: -1px;
76
+ padding: 0.7rem 1rem;
77
+ font-family: var(--font-heading);
78
+ font-size: 0.95rem;
79
+ font-weight: 600;
80
+ color: var(--muted);
81
+ cursor: pointer;
82
+ transition: color 0.15s, border-color 0.15s;
83
+ }
84
+ .b-tabs__tab:hover { color: var(--fg); }
85
+ .b-tabs__tab[aria-selected='true'] { color: var(--gold); border-bottom-color: var(--gold); }
86
+ .b-tabs__tab:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
87
+ .b-tabs__panel {
88
+ display: grid;
89
+ gap: 1.25rem;
90
+ }
91
+ /* A class `display` beats the UA `[hidden]{display:none}` (equal specificity,
92
+ author origin wins), so make the hidden state explicit — for the first paint
93
+ and every JS toggle. */
94
+ .b-tabs__panel[hidden] {
95
+ display: none;
96
+ }
97
+ @media (min-width: 48em) {
98
+ .b-tabs__panel:has(.b-tabs__img) { grid-template-columns: 1fr 1fr; align-items: center; }
99
+ }
100
+ .b-tabs__panel:focus-visible { outline: 2px solid var(--gold); outline-offset: 4px; border-radius: var(--radius-sm); }
101
+ .b-tabs__img { width: 100%; height: auto; border-radius: var(--radius); }
102
+ .b-tabs__heading { margin: 0 0 0.5rem; color: var(--fg); }
103
+ .b-tabs__text { margin: 0; color: var(--muted); line-height: 1.65; }
104
+ </style>
105
+
106
+ <script>
107
+ // Wire every tabs group on the page: selection, ARIA state, arrow-key nav.
108
+ document.querySelectorAll<HTMLElement>('.b-tabs').forEach((root) => {
109
+ const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
110
+ const panels = Array.from(root.querySelectorAll<HTMLElement>('[role="tabpanel"]'));
111
+ if (!tabs.length) return;
112
+
113
+ function select(index: number, focus = true) {
114
+ tabs.forEach((tab, i) => {
115
+ const on = i === index;
116
+ tab.setAttribute('aria-selected', on ? 'true' : 'false');
117
+ tab.tabIndex = on ? 0 : -1;
118
+ panels[i]?.toggleAttribute('hidden', !on);
119
+ });
120
+ if (focus) tabs[index]?.focus();
121
+ }
122
+
123
+ tabs.forEach((tab, i) => {
124
+ tab.addEventListener('click', () => select(i, false));
125
+ tab.addEventListener('keydown', (e) => {
126
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); select((i + 1) % tabs.length); }
127
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); select((i - 1 + tabs.length) % tabs.length); }
128
+ else if (e.key === 'Home') { e.preventDefault(); select(0); }
129
+ else if (e.key === 'End') { e.preventDefault(); select(tabs.length - 1); }
130
+ });
131
+ });
132
+ });
133
+ </script>