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.
- package/LICENSE +102 -201
- package/LICENSE-Apache-2.0 +201 -0
- package/NOTICE +24 -5
- package/README.md +89 -73
- package/bin/check-thin.mjs +74 -0
- package/components/BlockRenderer.astro +89 -0
- package/components/Button.astro +85 -33
- package/components/CalendarEmbed.astro +188 -0
- package/components/Card.astro +7 -17
- package/components/Icon.astro +35 -0
- package/components/PostArticle.astro +133 -0
- package/components/PostCard.astro +85 -225
- package/components/SiteFooter.astro +2 -2
- package/components/SiteHeader.astro +104 -51
- package/components/blocks/Badge.astro +29 -0
- package/components/blocks/ButtonBlock.astro +13 -0
- package/components/blocks/Divider.astro +12 -0
- package/components/blocks/Grid.astro +48 -0
- package/components/blocks/Heading.astro +42 -0
- package/components/blocks/ImageBlock.astro +80 -0
- package/components/blocks/List.astro +78 -0
- package/components/blocks/Prose.astro +29 -0
- package/components/blocks/Quote.astro +32 -0
- package/components/blocks/Section.astro +52 -0
- package/components/blocks/Spacer.astro +20 -0
- package/components/blocks/Stack.astro +30 -0
- package/components/blocks/Stat.astro +30 -0
- package/components/recipes/Accordion.astro +84 -0
- package/components/recipes/Announcement.astro +74 -0
- package/components/recipes/Banner.astro +134 -0
- package/components/recipes/Bento.astro +138 -0
- package/components/recipes/ContactForm.astro +172 -0
- package/components/recipes/Cta.astro +83 -0
- package/components/recipes/Faq.astro +80 -0
- package/components/recipes/FeatureCards.astro +105 -0
- package/components/recipes/Gallery.astro +55 -0
- package/components/recipes/Hero.astro +191 -0
- package/components/recipes/Logos.astro +77 -0
- package/components/recipes/Marquee.astro +75 -0
- package/components/recipes/Pricing.astro +149 -0
- package/components/recipes/Stats.astro +124 -0
- package/components/recipes/Steps.astro +120 -0
- package/components/recipes/Tabs.astro +133 -0
- package/components/recipes/Testimonial.astro +66 -0
- package/components/recipes/Tiles.astro +248 -0
- package/content/blocks.ts +534 -0
- package/content/calendar.ts +40 -0
- package/content/collections.ts +25 -14
- package/content/posts.ts +89 -0
- package/content/schemas.ts +58 -123
- package/layouts/Base.astro +30 -56
- package/lib/calendar.ts +12 -0
- package/lib/icons.ts +64 -0
- package/lib/pages.ts +42 -0
- package/lib/posts.ts +93 -0
- package/lib/siteSettings.ts +18 -36
- package/lib/themeTokens.ts +163 -0
- package/package.json +61 -48
- package/styles/theme.css +42 -0
- package/components/DocLayout.astro +0 -292
- package/components/GroupCard.astro +0 -164
- package/components/LatestPostList.astro +0 -64
- package/components/PaginationNav.astro +0 -81
- package/components/PostsToolbar.astro +0 -83
- package/content/filters.ts +0 -107
- package/lib/mailLinks.ts +0 -13
- package/lib/mapsLink.ts +0 -7
- package/utils/excerpt.ts +0 -56
- package/utils/formatDate.ts +0 -22
- 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>
|