ferst-core 0.1.0 → 0.2.0

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 +85 -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,105 @@
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
+ ---
21
+
22
+ <section class="b-feature-cards">
23
+ {(title || intro) && (
24
+ <div class="b-feature-cards__head">
25
+ {title && <Heading text={title} level={2} align="center" />}
26
+ {intro && <p class="b-feature-cards__intro">{intro}</p>}
27
+ </div>
28
+ )}
29
+ <div class="b-feature-cards__grid" data-cols={columns}>
30
+ {cards.map((card) => (
31
+ <Card>
32
+ {card.icon && isIconKey(card.icon) && (
33
+ <span class="b-feature-cards__icon"><Icon name={card.icon} size={28} /></span>
34
+ )}
35
+ <h3 class="b-feature-cards__title">{card.title}</h3>
36
+ {card.text && <p class="b-feature-cards__text">{card.text}</p>}
37
+ {card.href && (
38
+ <span class="b-feature-cards__cta">
39
+ <Button href={card.href} variant="secondary" size="sm">Learn more</Button>
40
+ </span>
41
+ )}
42
+ </Card>
43
+ ))}
44
+ </div>
45
+ </section>
46
+
47
+ <style>
48
+ .b-feature-cards {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 2rem;
52
+ max-width: 72rem;
53
+ margin-inline: auto;
54
+ padding-inline: 1rem;
55
+ }
56
+ .b-feature-cards__head {
57
+ max-width: var(--max);
58
+ margin-inline: auto;
59
+ text-align: center;
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 0.75rem;
63
+ }
64
+ .b-feature-cards__intro {
65
+ margin: 0;
66
+ color: var(--muted);
67
+ font-size: 1.0625rem;
68
+ line-height: 1.6;
69
+ }
70
+ .b-feature-cards__grid {
71
+ display: grid;
72
+ grid-template-columns: 1fr; /* mobile-first */
73
+ gap: 1.5rem;
74
+ }
75
+ @media (min-width: 48em) {
76
+ .b-feature-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
77
+ .b-feature-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
78
+ .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
79
+ }
80
+ @media (min-width: 64em) {
81
+ .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
82
+ }
83
+ .b-feature-cards__icon {
84
+ color: var(--gold);
85
+ margin-bottom: 0.5rem;
86
+ display: inline-flex;
87
+ }
88
+ .b-feature-cards__title {
89
+ margin: 0 0 0.5rem;
90
+ font-size: 1.125rem;
91
+ color: var(--fg);
92
+ }
93
+ .b-feature-cards__text {
94
+ margin: 0;
95
+ color: var(--muted);
96
+ line-height: 1.6;
97
+ }
98
+ /* Push the CTA to the bottom so a row of cards aligns its buttons regardless
99
+ of how much text each card has. Cards are equal height already (grid items
100
+ stretch), and Card is a flex column — margin-top:auto does the rest. */
101
+ .b-feature-cards__cta {
102
+ margin-top: auto;
103
+ padding-top: 1rem;
104
+ }
105
+ </style>
@@ -0,0 +1,55 @@
1
+ ---
2
+ /** Gallery — a Level-2 recipe: a responsive grid of images with optional
3
+ * captions. (Distinct from the `grid` layout primitive, which holds arbitrary
4
+ * child blocks; this one is image-specific.) */
5
+ interface Props {
6
+ columns?: 2 | 3 | 4;
7
+ images?: { src: string; alt?: string; caption?: string }[];
8
+ }
9
+ const { columns = 3, images = [] } = Astro.props;
10
+ ---
11
+
12
+ <div class="b-gallery" data-cols={columns}>
13
+ {images.map((img) => (
14
+ <figure class="b-gallery__item">
15
+ <img src={img.src} alt={img.alt ?? ''} loading="lazy" decoding="async" />
16
+ {img.caption && <figcaption>{img.caption}</figcaption>}
17
+ </figure>
18
+ ))}
19
+ </div>
20
+
21
+ <style>
22
+ .b-gallery {
23
+ display: grid;
24
+ grid-template-columns: 1fr;
25
+ gap: 1rem;
26
+ max-width: 72rem;
27
+ margin-inline: auto;
28
+ padding-inline: 1rem;
29
+ }
30
+ @media (min-width: 48em) {
31
+ .b-gallery[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
32
+ .b-gallery[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
33
+ .b-gallery[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
34
+ }
35
+ @media (min-width: 64em) {
36
+ .b-gallery[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
37
+ }
38
+ .b-gallery__item {
39
+ margin: 0;
40
+ }
41
+ .b-gallery__item img {
42
+ display: block;
43
+ width: 100%;
44
+ height: auto;
45
+ aspect-ratio: 4 / 3;
46
+ object-fit: cover;
47
+ border-radius: 6px;
48
+ }
49
+ .b-gallery__item figcaption {
50
+ margin-top: 0.4rem;
51
+ color: var(--muted);
52
+ font-size: 0.875rem;
53
+ text-align: center;
54
+ }
55
+ </style>
@@ -0,0 +1,191 @@
1
+ ---
2
+ /**
3
+ * Hero — the first Level-2 recipe: a curated page-opening band that composes
4
+ * primitives (Heading + Button) into a designed layout. Two presets: `centered`
5
+ * (stacked and centred) and `split` (text beside an image). Recipes are locked
6
+ * components so quality can't drift (BLOCK-ARCHITECTURE §6).
7
+ */
8
+ import Heading from '../blocks/Heading.astro';
9
+ import Button from '../Button.astro';
10
+
11
+ interface Props {
12
+ eyebrow?: string;
13
+ title: string;
14
+ subtitle?: string;
15
+ cta?: { label: string; href: string };
16
+ image?: { src: string; alt?: string };
17
+ media?: { image?: string; video?: string; poster?: string; overlay?: number };
18
+ layout?: 'centered' | 'split' | 'media';
19
+ background?: 'none' | 'mesh';
20
+ }
21
+ const { eyebrow, title, subtitle, cta, image, media, layout = 'centered', background = 'none' } = Astro.props;
22
+ const isMedia = layout === 'media' && media && (media.video || media.image);
23
+ const overlay = (media?.overlay ?? 45) / 100;
24
+ const isMesh = background === 'mesh' && !isMedia;
25
+ ---
26
+
27
+ <section
28
+ class="b-hero"
29
+ data-layout={layout}
30
+ data-bg={isMesh ? 'mesh' : undefined}
31
+ style={isMedia ? `--hero-overlay: ${overlay}; ${media?.image && !media?.video ? `--hero-image: url('${media.image}')` : ''}` : undefined}
32
+ >
33
+ {isMedia && (
34
+ <div class="b-hero__bg" aria-hidden="true">
35
+ {media?.video ? (
36
+ <video autoplay muted loop playsinline poster={media.poster}>
37
+ <source src={media.video} />
38
+ </video>
39
+ ) : null}
40
+ <span class="b-hero__scrim"></span>
41
+ </div>
42
+ )}
43
+ {isMesh && <div class="b-hero__mesh" aria-hidden="true"></div>}
44
+ <div class="b-hero__text">
45
+ {eyebrow && <p class="b-hero__eyebrow">{eyebrow}</p>}
46
+ <Heading text={title} level={1} align={layout === 'split' ? 'start' : 'center'} />
47
+ {subtitle && <p class="b-hero__subtitle">{subtitle}</p>}
48
+ {cta && (
49
+ <div class="b-hero__cta">
50
+ <Button href={cta.href} variant={layout === 'media' ? 'cta' : 'primary'}>{cta.label}</Button>
51
+ </div>
52
+ )}
53
+ </div>
54
+ {layout === 'split' && image && (
55
+ <div class="b-hero__media">
56
+ <img src={image.src} alt={image.alt ?? ''} loading="eager" decoding="async" />
57
+ </div>
58
+ )}
59
+ </section>
60
+
61
+ <style>
62
+ .b-hero {
63
+ display: grid;
64
+ gap: 2rem;
65
+ padding-block: 3rem;
66
+ }
67
+ @media (min-width: 48em) {
68
+ .b-hero[data-layout='split'] {
69
+ grid-template-columns: 1fr 1fr;
70
+ align-items: center;
71
+ }
72
+ }
73
+ .b-hero__text {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 1rem;
77
+ }
78
+ .b-hero[data-layout='centered'] .b-hero__text {
79
+ max-width: var(--max);
80
+ margin-inline: auto;
81
+ align-items: center;
82
+ text-align: center;
83
+ }
84
+ .b-hero__eyebrow {
85
+ margin: 0;
86
+ text-transform: uppercase;
87
+ letter-spacing: 0.12em;
88
+ font-size: 0.8125rem;
89
+ font-weight: 600;
90
+ color: var(--gold);
91
+ }
92
+ .b-hero__subtitle {
93
+ margin: 0;
94
+ font-size: 1.125rem;
95
+ line-height: 1.6;
96
+ color: var(--muted);
97
+ max-width: var(--max);
98
+ }
99
+ .b-hero__cta {
100
+ margin-top: 0.5rem;
101
+ }
102
+
103
+ /* ── Mesh background: a soft, slowly drifting brand gradient behind the text.
104
+ Low opacity + blur keeps normal (dark) text perfectly readable. ── */
105
+ .b-hero[data-bg='mesh'] {
106
+ position: relative;
107
+ isolation: isolate;
108
+ overflow: hidden;
109
+ border-radius: var(--radius);
110
+ padding: clamp(3rem, 7vw, 5rem) clamp(1.25rem, 4vw, 3rem);
111
+ }
112
+ .b-hero__mesh {
113
+ position: absolute;
114
+ inset: 0;
115
+ z-index: -1;
116
+ background: var(--bg);
117
+ }
118
+ .b-hero__mesh::before {
119
+ content: '';
120
+ position: absolute;
121
+ inset: -25%;
122
+ background:
123
+ radial-gradient(35% 45% at 20% 25%, var(--gold), transparent 70%),
124
+ radial-gradient(35% 45% at 80% 20%, var(--accent-2, var(--gold)), transparent 70%),
125
+ radial-gradient(40% 50% at 65% 85%, var(--gold-light), transparent 70%);
126
+ opacity: 0.35;
127
+ filter: blur(50px);
128
+ animation: b-hero-mesh 20s ease-in-out infinite alternate;
129
+ }
130
+ @keyframes b-hero-mesh {
131
+ from { transform: translate3d(-3%, -2%, 0) scale(1.05); }
132
+ to { transform: translate3d(3%, 4%, 0) scale(1.18); }
133
+ }
134
+ @media (prefers-reduced-motion: reduce) {
135
+ .b-hero__mesh::before { animation: none; }
136
+ }
137
+ .b-hero__media img {
138
+ display: block;
139
+ width: 100%;
140
+ height: auto;
141
+ border-radius: 8px;
142
+ }
143
+
144
+ /* ── Media layout: full-bleed image/video background with a legibility scrim ── */
145
+ .b-hero[data-layout='media'] {
146
+ position: relative;
147
+ min-height: min(68vh, 38rem);
148
+ display: grid;
149
+ place-items: center;
150
+ text-align: center;
151
+ padding: clamp(3rem, 8vw, 6rem) 1.5rem;
152
+ border-radius: var(--radius);
153
+ overflow: hidden;
154
+ isolation: isolate;
155
+ }
156
+ .b-hero__bg {
157
+ position: absolute;
158
+ inset: 0;
159
+ z-index: -1;
160
+ background-image: var(--hero-image, none);
161
+ background-size: cover;
162
+ background-position: center;
163
+ background-color: var(--bg-section);
164
+ }
165
+ .b-hero__bg video {
166
+ width: 100%;
167
+ height: 100%;
168
+ object-fit: cover;
169
+ }
170
+ .b-hero__scrim {
171
+ position: absolute;
172
+ inset: 0;
173
+ background: rgba(8, 10, 18, var(--hero-overlay, 0.45));
174
+ }
175
+ .b-hero[data-layout='media'] .b-hero__text {
176
+ max-width: var(--max);
177
+ align-items: center;
178
+ color: #fff;
179
+ }
180
+ .b-hero[data-layout='media'] .b-hero__subtitle {
181
+ color: rgba(255, 255, 255, 0.9);
182
+ margin-inline: auto;
183
+ }
184
+ .b-hero[data-layout='media'] .b-hero__eyebrow {
185
+ color: #fff;
186
+ opacity: 0.9;
187
+ }
188
+ .b-hero[data-layout='media'] :global(h1) {
189
+ color: #fff;
190
+ }
191
+ </style>
@@ -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>