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,172 @@
1
+ ---
2
+ /**
3
+ * ContactForm — a Level-2 recipe: a labelled, accessible form in a centred
4
+ * column (optional heading + intro, a list of fields, a submit, optional note).
5
+ * Composes the Heading + Button primitives.
6
+ *
7
+ * `action` is optional. The core ships no backend, so with no action this
8
+ * renders a standard semantic form that a client later wires to their own
9
+ * endpoint (or a form service). Every colour reads a token, so the form
10
+ * recolours with the brand like every other block.
11
+ */
12
+ import Heading from '../blocks/Heading.astro';
13
+ import Button from '../Button.astro';
14
+
15
+ interface Field {
16
+ name: string;
17
+ label: string;
18
+ type?: 'text' | 'email' | 'tel' | 'textarea';
19
+ required?: boolean;
20
+ placeholder?: string;
21
+ }
22
+ interface Props {
23
+ title?: string;
24
+ intro?: string;
25
+ action?: string;
26
+ method?: 'post' | 'get';
27
+ submitLabel?: string;
28
+ note?: string;
29
+ fields?: Field[];
30
+ }
31
+ const {
32
+ title,
33
+ intro,
34
+ action,
35
+ method = 'post',
36
+ submitLabel = 'Send message',
37
+ note,
38
+ fields = [],
39
+ } = Astro.props;
40
+ ---
41
+
42
+ <section class="b-contact-form">
43
+ {(title || intro) && (
44
+ <div class="b-contact-form__head">
45
+ {title && <Heading text={title} level={2} align="center" />}
46
+ {intro && <p class="b-contact-form__intro">{intro}</p>}
47
+ </div>
48
+ )}
49
+ <form class="b-contact-form__form" action={action} method={method}>
50
+ {fields.map((field) => {
51
+ const id = `cf-${field.name}`;
52
+ const type = field.type ?? 'text';
53
+ return (
54
+ <div class="b-contact-form__field">
55
+ <label class="b-contact-form__label" for={id}>
56
+ {field.label}
57
+ {field.required && <span class="b-contact-form__req" aria-hidden="true"> *</span>}
58
+ </label>
59
+ {type === 'textarea' ? (
60
+ <textarea id={id} name={field.name} rows="5" required={field.required} placeholder={field.placeholder}></textarea>
61
+ ) : (
62
+ <input id={id} type={type} name={field.name} required={field.required} placeholder={field.placeholder} />
63
+ )}
64
+ </div>
65
+ );
66
+ })}
67
+ <div class="b-contact-form__action">
68
+ <Button type="submit" variant="cta">{submitLabel}</Button>
69
+ </div>
70
+ {note && <p class="b-contact-form__note">{note}</p>}
71
+ </form>
72
+ </section>
73
+
74
+ <style>
75
+ .b-contact-form {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 1.5rem;
79
+ max-width: 40rem;
80
+ margin-inline: auto;
81
+ padding-inline: 1rem;
82
+ }
83
+ .b-contact-form__head {
84
+ text-align: center;
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: 0.75rem;
88
+ }
89
+ .b-contact-form__intro {
90
+ margin: 0;
91
+ color: var(--muted);
92
+ line-height: 1.6;
93
+ }
94
+ .b-contact-form__form {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 1.1rem;
98
+ }
99
+ .b-contact-form__field {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 0.4rem;
103
+ }
104
+ .b-contact-form__label {
105
+ font-family: var(--font-body);
106
+ font-size: 0.9375rem;
107
+ font-weight: 600;
108
+ color: var(--fg);
109
+ }
110
+ .b-contact-form__req {
111
+ color: var(--gold);
112
+ }
113
+ .b-contact-form__form input,
114
+ .b-contact-form__form textarea {
115
+ font: inherit;
116
+ font-size: 1rem;
117
+ color: var(--fg);
118
+ background: var(--field-bg, var(--bg-surface));
119
+ border: var(--field-border, 1px solid var(--border));
120
+ border-radius: var(--field-radius, var(--radius-sm));
121
+ padding: 0.7rem 0.85rem;
122
+ width: 100%;
123
+ box-sizing: border-box;
124
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
125
+ }
126
+
127
+ /* Site-wide input TREATMENT — chosen once (a `data-field` attribute on <html>,
128
+ like the theme), applied to every field. Boxed is the default above; these
129
+ override the field tokens for the other two looks. */
130
+ :global(html[data-field='filled']) .b-contact-form__form input,
131
+ :global(html[data-field='filled']) .b-contact-form__form textarea {
132
+ background: var(--bg-section);
133
+ border-color: transparent;
134
+ }
135
+ :global(html[data-field='underline']) .b-contact-form__form input,
136
+ :global(html[data-field='underline']) .b-contact-form__form textarea {
137
+ background: transparent;
138
+ border: none;
139
+ border-bottom: 1.5px solid var(--border);
140
+ border-radius: 0;
141
+ padding-inline: 0;
142
+ }
143
+ :global(html[data-field='underline']) .b-contact-form__form input:focus,
144
+ :global(html[data-field='underline']) .b-contact-form__form textarea:focus {
145
+ border-bottom-color: var(--gold);
146
+ box-shadow: none;
147
+ }
148
+ .b-contact-form__form textarea {
149
+ resize: vertical;
150
+ min-height: 7rem;
151
+ }
152
+ .b-contact-form__form input::placeholder,
153
+ .b-contact-form__form textarea::placeholder {
154
+ color: var(--muted);
155
+ opacity: 0.7;
156
+ }
157
+ .b-contact-form__form input:focus,
158
+ .b-contact-form__form textarea:focus {
159
+ outline: none;
160
+ border-color: var(--gold);
161
+ box-shadow: 0 0 0 3px var(--gold-light);
162
+ }
163
+ .b-contact-form__action {
164
+ margin-top: 0.25rem;
165
+ }
166
+ .b-contact-form__note {
167
+ margin: 0;
168
+ font-size: 0.85rem;
169
+ color: var(--muted);
170
+ line-height: 1.5;
171
+ }
172
+ </style>
@@ -0,0 +1,83 @@
1
+ ---
2
+ /** CTA — a Level-2 recipe: a centred call-to-action band (heading + optional
3
+ * text + a single button), composing the Heading + Button primitives. */
4
+ import Heading from '../blocks/Heading.astro';
5
+ import Button from '../Button.astro';
6
+
7
+ interface Props {
8
+ title: string;
9
+ text?: string;
10
+ cta: { label: string; href: string };
11
+ background?: 'muted' | 'accent';
12
+ }
13
+ const { title, text, cta, background = 'accent' } = Astro.props;
14
+ ---
15
+
16
+ <section class="b-cta" data-bg={background}>
17
+ <div class="b-cta__inner">
18
+ <Heading text={title} level={2} align="center" />
19
+ {text && <p class="b-cta__text">{text}</p>}
20
+ <div class="b-cta__action">
21
+ <Button href={cta.href} variant="primary">{cta.label}</Button>
22
+ </div>
23
+ </div>
24
+ </section>
25
+
26
+ <style>
27
+ .b-cta {
28
+ padding-block: 3rem;
29
+ }
30
+ .b-cta[data-bg='muted'] {
31
+ background: var(--bg-section);
32
+ }
33
+ .b-cta[data-bg='accent'] {
34
+ background: var(--gold-light);
35
+ }
36
+ /* Mesh: a soft drifting brand gradient behind the text (static under
37
+ reduced-motion). Low opacity + blur keeps the copy readable. */
38
+ .b-cta[data-bg='mesh'] {
39
+ position: relative;
40
+ isolation: isolate;
41
+ overflow: hidden;
42
+ background: var(--bg);
43
+ }
44
+ .b-cta[data-bg='mesh']::before {
45
+ content: '';
46
+ position: absolute;
47
+ inset: -25%;
48
+ z-index: -1;
49
+ background:
50
+ radial-gradient(35% 50% at 18% 20%, var(--gold), transparent 70%),
51
+ radial-gradient(35% 50% at 82% 25%, var(--accent-2, var(--gold)), transparent 70%),
52
+ radial-gradient(45% 55% at 60% 90%, var(--gold-light), transparent 70%);
53
+ opacity: 0.35;
54
+ filter: blur(50px);
55
+ animation: b-cta-mesh 22s ease-in-out infinite alternate;
56
+ }
57
+ @keyframes b-cta-mesh {
58
+ from { transform: translate3d(-3%, -2%, 0) scale(1.06); }
59
+ to { transform: translate3d(3%, 4%, 0) scale(1.18); }
60
+ }
61
+ @media (prefers-reduced-motion: reduce) {
62
+ .b-cta[data-bg='mesh']::before { animation: none; }
63
+ }
64
+ .b-cta__inner {
65
+ max-width: var(--max);
66
+ margin-inline: auto;
67
+ padding-inline: 1rem;
68
+ text-align: center;
69
+ display: flex;
70
+ flex-direction: column;
71
+ align-items: center;
72
+ gap: 1rem;
73
+ }
74
+ .b-cta__text {
75
+ margin: 0;
76
+ color: var(--muted);
77
+ font-size: 1.0625rem;
78
+ line-height: 1.6;
79
+ }
80
+ .b-cta__action {
81
+ margin-top: 0.5rem;
82
+ }
83
+ </style>
@@ -0,0 +1,80 @@
1
+ ---
2
+ /** FAQ — a Level-2 recipe: an optional heading over a list of expandable
3
+ * question/answer items. Uses native <details>/<summary> — accessible and
4
+ * interactive with zero JavaScript. Composes the Heading primitive. */
5
+ import Heading from '../blocks/Heading.astro';
6
+
7
+ interface Props {
8
+ title?: string;
9
+ items?: { question: string; answer: string }[];
10
+ }
11
+ const { title, items = [] } = Astro.props;
12
+ ---
13
+
14
+ <section class="b-faq">
15
+ {title && <Heading text={title} level={2} align="center" />}
16
+ <div class="b-faq__list">
17
+ {items.map((item) => (
18
+ <details class="b-faq__item">
19
+ <summary class="b-faq__q">{item.question}</summary>
20
+ <div class="b-faq__a"><p>{item.answer}</p></div>
21
+ </details>
22
+ ))}
23
+ </div>
24
+ </section>
25
+
26
+ <style>
27
+ .b-faq {
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: 1.5rem;
31
+ padding-inline: 1rem;
32
+ }
33
+ .b-faq__list {
34
+ max-width: var(--max);
35
+ margin-inline: auto;
36
+ width: 100%;
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: 0.5rem;
40
+ }
41
+ .b-faq__item {
42
+ border: 1px solid var(--border);
43
+ border-radius: 8px;
44
+ padding: 0.25rem 1rem;
45
+ background: var(--bg-surface);
46
+ }
47
+ .b-faq__q {
48
+ cursor: pointer;
49
+ font-weight: 600;
50
+ color: var(--fg);
51
+ padding: 0.75rem 0;
52
+ list-style: none;
53
+ display: flex;
54
+ align-items: baseline;
55
+ }
56
+ .b-faq__q::-webkit-details-marker {
57
+ display: none;
58
+ }
59
+ .b-faq__q::before {
60
+ content: '+';
61
+ color: var(--gold);
62
+ font-weight: 700;
63
+ margin-right: 0.6rem;
64
+ }
65
+ .b-faq__item[open] .b-faq__q::before {
66
+ content: '\2013'; /* en dash */
67
+ }
68
+ .b-faq__q:focus-visible {
69
+ outline: 2px solid var(--gold);
70
+ outline-offset: 2px;
71
+ }
72
+ .b-faq__a {
73
+ padding: 0 0 0.9rem;
74
+ color: var(--muted);
75
+ line-height: 1.6;
76
+ }
77
+ .b-faq__a p {
78
+ margin: 0;
79
+ }
80
+ </style>
@@ -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>