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.
- 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 +93 -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,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A layout container: a full-width band with an optional background and a
|
|
4
|
+
* width-constrained inner column, holding child blocks (rendered recursively
|
|
5
|
+
* through BlockRenderer). The first composition primitive — how leaf blocks
|
|
6
|
+
* become a laid-out page.
|
|
7
|
+
*/
|
|
8
|
+
import BlockRenderer from '../BlockRenderer.astro';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
background?: 'none' | 'muted' | 'accent';
|
|
12
|
+
width?: 'normal' | 'wide' | 'full';
|
|
13
|
+
blocks?: any[];
|
|
14
|
+
}
|
|
15
|
+
const { background = 'none', width = 'normal', blocks = [] } = Astro.props;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<section class="b-section" data-bg={background}>
|
|
19
|
+
<div class="b-section__inner" data-width={width}>
|
|
20
|
+
<BlockRenderer blocks={blocks} />
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.b-section {
|
|
26
|
+
width: 100%;
|
|
27
|
+
}
|
|
28
|
+
.b-section[data-bg='muted'] {
|
|
29
|
+
background: var(--bg-section);
|
|
30
|
+
}
|
|
31
|
+
.b-section[data-bg='accent'] {
|
|
32
|
+
background: var(--gold-light);
|
|
33
|
+
}
|
|
34
|
+
.b-section__inner {
|
|
35
|
+
margin-inline: auto;
|
|
36
|
+
padding-inline: 1rem;
|
|
37
|
+
padding-block: 2.5rem;
|
|
38
|
+
}
|
|
39
|
+
.b-section__inner[data-width='normal'] {
|
|
40
|
+
max-width: var(--max);
|
|
41
|
+
}
|
|
42
|
+
.b-section__inner[data-width='wide'] {
|
|
43
|
+
max-width: 72rem;
|
|
44
|
+
}
|
|
45
|
+
.b-section__inner[data-width='full'] {
|
|
46
|
+
max-width: none;
|
|
47
|
+
}
|
|
48
|
+
/* vertical rhythm between direct child blocks */
|
|
49
|
+
.b-section__inner > :global(* + *) {
|
|
50
|
+
margin-block-start: 1.5rem;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
size?: 'sm' | 'md' | 'lg';
|
|
4
|
+
}
|
|
5
|
+
const { size = 'md' } = Astro.props;
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<div class="b-spacer" data-size={size} aria-hidden="true"></div>
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
.b-spacer[data-size='sm'] {
|
|
12
|
+
height: 1rem;
|
|
13
|
+
}
|
|
14
|
+
.b-spacer[data-size='md'] {
|
|
15
|
+
height: 2rem;
|
|
16
|
+
}
|
|
17
|
+
.b-spacer[data-size='lg'] {
|
|
18
|
+
height: 4rem;
|
|
19
|
+
}
|
|
20
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** A layout container: stacks child blocks vertically with a consistent gap. */
|
|
3
|
+
import BlockRenderer from '../BlockRenderer.astro';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
gap?: 'sm' | 'md' | 'lg';
|
|
7
|
+
blocks?: any[];
|
|
8
|
+
}
|
|
9
|
+
const { gap = 'md', blocks = [] } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="b-stack" data-gap={gap}>
|
|
13
|
+
<BlockRenderer blocks={blocks} />
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
.b-stack {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
}
|
|
21
|
+
.b-stack[data-gap='sm'] {
|
|
22
|
+
gap: 0.75rem;
|
|
23
|
+
}
|
|
24
|
+
.b-stack[data-gap='md'] {
|
|
25
|
+
gap: 1.5rem;
|
|
26
|
+
}
|
|
27
|
+
.b-stack[data-gap='lg'] {
|
|
28
|
+
gap: 3rem;
|
|
29
|
+
}
|
|
30
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
const { value, label } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="b-stat">
|
|
10
|
+
<div class="b-stat__value">{value}</div>
|
|
11
|
+
<div class="b-stat__label">{label}</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<style>
|
|
15
|
+
.b-stat {
|
|
16
|
+
text-align: center;
|
|
17
|
+
}
|
|
18
|
+
.b-stat__value {
|
|
19
|
+
font-family: var(--font-heading);
|
|
20
|
+
font-weight: 700;
|
|
21
|
+
font-size: 2.5rem;
|
|
22
|
+
line-height: 1;
|
|
23
|
+
color: var(--gold);
|
|
24
|
+
}
|
|
25
|
+
.b-stat__label {
|
|
26
|
+
margin-top: 0.5rem;
|
|
27
|
+
color: var(--muted);
|
|
28
|
+
font-size: 0.9375rem;
|
|
29
|
+
}
|
|
30
|
+
</style>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Accordion — a Level-2 recipe: an optional heading over expand/collapse rows
|
|
3
|
+
* (title + body). Generic sibling of FAQ (which is question/answer-flavoured).
|
|
4
|
+
* Native <details>/<summary> — accessible, interactive, zero JavaScript. */
|
|
5
|
+
import Heading from '../blocks/Heading.astro';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
title?: string;
|
|
9
|
+
items?: { heading: string; body: string }[];
|
|
10
|
+
}
|
|
11
|
+
const { title, items = [] } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<section class="b-accordion">
|
|
15
|
+
{title && <Heading text={title} level={2} align="center" />}
|
|
16
|
+
<div class="b-accordion__list">
|
|
17
|
+
{items.map((item) => (
|
|
18
|
+
<details class="b-accordion__item">
|
|
19
|
+
<summary class="b-accordion__head">{item.heading}</summary>
|
|
20
|
+
<div class="b-accordion__body"><p>{item.body}</p></div>
|
|
21
|
+
</details>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.b-accordion {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
gap: 1.5rem;
|
|
31
|
+
padding-inline: 1rem;
|
|
32
|
+
}
|
|
33
|
+
.b-accordion__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-accordion__item {
|
|
42
|
+
border: var(--card-border);
|
|
43
|
+
border-radius: var(--radius-sm);
|
|
44
|
+
padding: 0.25rem 1rem;
|
|
45
|
+
background: var(--bg-surface);
|
|
46
|
+
}
|
|
47
|
+
.b-accordion__head {
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
color: var(--fg);
|
|
51
|
+
padding: 0.85rem 0;
|
|
52
|
+
list-style: none;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: space-between;
|
|
56
|
+
gap: 0.75rem;
|
|
57
|
+
}
|
|
58
|
+
.b-accordion__head::-webkit-details-marker {
|
|
59
|
+
display: none;
|
|
60
|
+
}
|
|
61
|
+
.b-accordion__head::after {
|
|
62
|
+
content: '+';
|
|
63
|
+
color: var(--gold);
|
|
64
|
+
font-weight: 700;
|
|
65
|
+
font-size: 1.25rem;
|
|
66
|
+
line-height: 1;
|
|
67
|
+
transition: transform 0.2s ease;
|
|
68
|
+
}
|
|
69
|
+
.b-accordion__item[open] .b-accordion__head::after {
|
|
70
|
+
content: '\2013'; /* en dash */
|
|
71
|
+
}
|
|
72
|
+
.b-accordion__head:focus-visible {
|
|
73
|
+
outline: 2px solid var(--gold);
|
|
74
|
+
outline-offset: 2px;
|
|
75
|
+
}
|
|
76
|
+
.b-accordion__body {
|
|
77
|
+
padding: 0 0 0.9rem;
|
|
78
|
+
color: var(--muted);
|
|
79
|
+
line-height: 1.6;
|
|
80
|
+
}
|
|
81
|
+
.b-accordion__body p {
|
|
82
|
+
margin: 0;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Announcement — a Level-2 recipe: a thin full-width bar (message + optional
|
|
3
|
+
* link), optionally dismissible. Dismissal persists per `id` in localStorage,
|
|
4
|
+
* so a returning visitor doesn't see a bar they've closed. */
|
|
5
|
+
interface Props {
|
|
6
|
+
text: string;
|
|
7
|
+
link?: { label: string; href: string };
|
|
8
|
+
tone?: 'accent' | 'neutral';
|
|
9
|
+
dismissible?: boolean;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
const { text, link, tone = 'accent', dismissible = true, id = 'default' } = Astro.props;
|
|
13
|
+
const key = `ferst-dismissed-${id}`;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<aside class="b-announce" data-tone={tone} data-key={key} role="region" aria-label="Announcement">
|
|
17
|
+
<p class="b-announce__text">
|
|
18
|
+
{text}
|
|
19
|
+
{link && <a class="b-announce__link" href={link.href}>{link.label} →</a>}
|
|
20
|
+
</p>
|
|
21
|
+
{dismissible && (
|
|
22
|
+
<button type="button" class="b-announce__close" aria-label="Dismiss announcement">×</button>
|
|
23
|
+
)}
|
|
24
|
+
</aside>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.b-announce {
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
gap: 1rem;
|
|
32
|
+
padding: 0.6rem 1.25rem;
|
|
33
|
+
font-size: 0.9rem;
|
|
34
|
+
text-align: center;
|
|
35
|
+
}
|
|
36
|
+
.b-announce[data-tone='accent'] { background: var(--gold); color: var(--text-light); }
|
|
37
|
+
.b-announce[data-tone='neutral'] { background: var(--bg-section); color: var(--fg); }
|
|
38
|
+
.b-announce__text { margin: 0; line-height: 1.4; }
|
|
39
|
+
.b-announce__link {
|
|
40
|
+
color: inherit;
|
|
41
|
+
font-weight: 700;
|
|
42
|
+
text-decoration: underline;
|
|
43
|
+
text-underline-offset: 2px;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
}
|
|
46
|
+
.b-announce[data-tone='neutral'] .b-announce__link { color: var(--gold); }
|
|
47
|
+
.b-announce__close {
|
|
48
|
+
appearance: none;
|
|
49
|
+
background: none;
|
|
50
|
+
border: none;
|
|
51
|
+
color: inherit;
|
|
52
|
+
font-size: 1.4rem;
|
|
53
|
+
line-height: 1;
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
padding: 0 0.25rem;
|
|
56
|
+
opacity: 0.85;
|
|
57
|
+
flex-shrink: 0;
|
|
58
|
+
}
|
|
59
|
+
.b-announce__close:hover { opacity: 1; }
|
|
60
|
+
.b-announce__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
61
|
+
</style>
|
|
62
|
+
|
|
63
|
+
<script>
|
|
64
|
+
document.querySelectorAll<HTMLElement>('.b-announce').forEach((bar) => {
|
|
65
|
+
const key = bar.dataset.key;
|
|
66
|
+
if (key) {
|
|
67
|
+
try { if (localStorage.getItem(key) === '1') { bar.remove(); return; } } catch (_) {}
|
|
68
|
+
}
|
|
69
|
+
bar.querySelector('.b-announce__close')?.addEventListener('click', () => {
|
|
70
|
+
bar.remove();
|
|
71
|
+
if (key) { try { localStorage.setItem(key, '1'); } catch (_) {} }
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Banner — a Level-2 recipe: a full-bleed feature band with a fixed/parallax
|
|
4
|
+
* background image, a legibility scrim, and a floating "shaded" content card
|
|
5
|
+
* over it. Composes Heading + Button.
|
|
6
|
+
*
|
|
7
|
+
* Parallax is pure CSS (`background-attachment: fixed`) — no JS, GPU-composited.
|
|
8
|
+
* It's auto-disabled on touch/coarse pointers (where `fixed` is janky or ignored,
|
|
9
|
+
* e.g. iOS) and under `prefers-reduced-motion`, both falling back to a clean
|
|
10
|
+
* `scroll` background. The card uses the surface tokens (radius, shadow) so it
|
|
11
|
+
* matches every other surface, and a backdrop blur for the "shaded glass" look.
|
|
12
|
+
*/
|
|
13
|
+
import Heading from '../blocks/Heading.astro';
|
|
14
|
+
import Button from '../Button.astro';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
image: string;
|
|
18
|
+
eyebrow?: string;
|
|
19
|
+
title: string;
|
|
20
|
+
text?: string;
|
|
21
|
+
cta?: { label: string; href: string };
|
|
22
|
+
overlay?: number;
|
|
23
|
+
align?: 'start' | 'center' | 'end';
|
|
24
|
+
parallax?: boolean;
|
|
25
|
+
}
|
|
26
|
+
const { image, eyebrow, title, text, cta, overlay = 50, align = 'start', parallax = true } = Astro.props;
|
|
27
|
+
const scrim = overlay / 100;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<section
|
|
31
|
+
class="b-banner"
|
|
32
|
+
data-align={align}
|
|
33
|
+
data-parallax={parallax ? 'true' : 'false'}
|
|
34
|
+
style={`--banner-image: url('${image}'); --banner-scrim: ${scrim};`}
|
|
35
|
+
>
|
|
36
|
+
<div class="b-banner__scrim" aria-hidden="true"></div>
|
|
37
|
+
<div class="b-banner__card">
|
|
38
|
+
{eyebrow && <p class="b-banner__eyebrow">{eyebrow}</p>}
|
|
39
|
+
<Heading text={title} level={2} align="start" />
|
|
40
|
+
{text && <p class="b-banner__text">{text}</p>}
|
|
41
|
+
{cta && (
|
|
42
|
+
<div class="b-banner__cta">
|
|
43
|
+
<Button href={cta.href} variant="cta">{cta.label}</Button>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
.b-banner {
|
|
51
|
+
position: relative;
|
|
52
|
+
isolation: isolate;
|
|
53
|
+
display: grid;
|
|
54
|
+
padding: clamp(3rem, 9vw, 7rem) clamp(1.25rem, 5vw, 4rem);
|
|
55
|
+
min-height: min(60vh, 34rem);
|
|
56
|
+
border-radius: var(--radius);
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
background-image: var(--banner-image);
|
|
59
|
+
background-size: cover;
|
|
60
|
+
background-position: center;
|
|
61
|
+
background-repeat: no-repeat;
|
|
62
|
+
}
|
|
63
|
+
/* Parallax: pin the image to the viewport so it slides under the content.
|
|
64
|
+
Only on fine pointers with motion allowed — `fixed` is ignored/janky on
|
|
65
|
+
touch (iOS) and unwelcome under reduced-motion. */
|
|
66
|
+
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
|
|
67
|
+
.b-banner[data-parallax='true'] {
|
|
68
|
+
background-attachment: fixed;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.b-banner[data-align='start'] { justify-items: start; }
|
|
73
|
+
.b-banner[data-align='center'] { justify-items: center; text-align: center; }
|
|
74
|
+
.b-banner[data-align='end'] { justify-items: end; }
|
|
75
|
+
|
|
76
|
+
.b-banner__scrim {
|
|
77
|
+
position: absolute;
|
|
78
|
+
inset: 0;
|
|
79
|
+
z-index: -1;
|
|
80
|
+
background: linear-gradient(to top, rgba(8, 10, 18, calc(var(--banner-scrim) + 0.2)), rgba(8, 10, 18, var(--banner-scrim)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* The floating "shaded glass" card. */
|
|
84
|
+
.b-banner__card {
|
|
85
|
+
max-width: 34rem;
|
|
86
|
+
display: flex;
|
|
87
|
+
flex-direction: column;
|
|
88
|
+
gap: 1rem;
|
|
89
|
+
padding: clamp(1.5rem, 3vw, 2.25rem);
|
|
90
|
+
border-radius: var(--radius);
|
|
91
|
+
background: color-mix(in srgb, var(--bg-surface) 82%, transparent);
|
|
92
|
+
border: var(--card-border);
|
|
93
|
+
box-shadow: var(--shadow-lg);
|
|
94
|
+
-webkit-backdrop-filter: blur(10px);
|
|
95
|
+
backdrop-filter: blur(10px);
|
|
96
|
+
color: var(--fg);
|
|
97
|
+
}
|
|
98
|
+
.b-banner__eyebrow {
|
|
99
|
+
margin: 0;
|
|
100
|
+
text-transform: uppercase;
|
|
101
|
+
letter-spacing: 0.12em;
|
|
102
|
+
font-size: 0.8125rem;
|
|
103
|
+
font-weight: 700;
|
|
104
|
+
color: var(--gold);
|
|
105
|
+
}
|
|
106
|
+
.b-banner__text {
|
|
107
|
+
margin: 0;
|
|
108
|
+
color: var(--muted);
|
|
109
|
+
line-height: 1.65;
|
|
110
|
+
}
|
|
111
|
+
.b-banner__cta {
|
|
112
|
+
margin-top: 0.25rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Multi-layer parallax: the background is pinned (fixed), and the card itself
|
|
116
|
+
drifts at a different rate as the banner scrolls through view — two layers,
|
|
117
|
+
two speeds. Native scroll-timeline, so no JS; off under reduced-motion or
|
|
118
|
+
where unsupported (the card just sits still). Transforming the card is safe:
|
|
119
|
+
it's not an ancestor of the fixed background. */
|
|
120
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
121
|
+
@supports (animation-timeline: view()) {
|
|
122
|
+
.b-banner__card {
|
|
123
|
+
animation: b-banner-drift linear both;
|
|
124
|
+
animation-timeline: view();
|
|
125
|
+
animation-range: cover;
|
|
126
|
+
will-change: transform;
|
|
127
|
+
}
|
|
128
|
+
@keyframes b-banner-drift {
|
|
129
|
+
from { transform: translateY(48px); }
|
|
130
|
+
to { transform: translateY(-48px); }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Bento — a Level-2 recipe: an asymmetric grid where each card picks a `size`
|
|
3
|
+
* (sm / wide / tall / lg) and a `variant` look (surface / filled / gradient /
|
|
4
|
+
* image). Dense auto-flow packs them like a Japanese bento box. Composes Icon;
|
|
5
|
+
* radius/stroke/shadow come from the global surface tokens. */
|
|
6
|
+
import Heading from '../blocks/Heading.astro';
|
|
7
|
+
import Icon from '../Icon.astro';
|
|
8
|
+
import { isIconKey } from '../../lib/icons';
|
|
9
|
+
|
|
10
|
+
interface Item {
|
|
11
|
+
size?: 'sm' | 'wide' | 'tall' | 'lg';
|
|
12
|
+
variant?: 'surface' | 'filled' | 'gradient' | 'image';
|
|
13
|
+
icon?: string;
|
|
14
|
+
eyebrow?: string;
|
|
15
|
+
title: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
href?: string;
|
|
18
|
+
image?: { src: string; alt?: string };
|
|
19
|
+
}
|
|
20
|
+
interface Props {
|
|
21
|
+
title?: string;
|
|
22
|
+
intro?: string;
|
|
23
|
+
items?: Item[];
|
|
24
|
+
}
|
|
25
|
+
const { title, intro, items = [] } = Astro.props;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<section class="b-bento">
|
|
29
|
+
{(title || intro) && (
|
|
30
|
+
<div class="b-bento__head">
|
|
31
|
+
{title && <Heading text={title} level={2} align="center" />}
|
|
32
|
+
{intro && <p class="b-bento__intro">{intro}</p>}
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
<div class="b-bento__grid">
|
|
36
|
+
{items.map((item) => {
|
|
37
|
+
const Tag = item.href ? 'a' : 'div';
|
|
38
|
+
const style = item.variant === 'image' && item.image ? `--bento-image: url('${item.image.src}')` : undefined;
|
|
39
|
+
return (
|
|
40
|
+
<Tag class="b-bento__item" data-size={item.size ?? 'sm'} data-variant={item.variant ?? 'surface'} href={item.href} style={style}>
|
|
41
|
+
{item.icon && isIconKey(item.icon) && (
|
|
42
|
+
<span class="b-bento__icon"><Icon name={item.icon} size={24} /></span>
|
|
43
|
+
)}
|
|
44
|
+
{item.eyebrow && <span class="b-bento__eyebrow">{item.eyebrow}</span>}
|
|
45
|
+
<h3 class="b-bento__title">{item.title}</h3>
|
|
46
|
+
{item.text && <p class="b-bento__text">{item.text}</p>}
|
|
47
|
+
</Tag>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<style>
|
|
54
|
+
.b-bento {
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
gap: 2rem;
|
|
58
|
+
max-width: 72rem;
|
|
59
|
+
margin-inline: auto;
|
|
60
|
+
padding-inline: 1rem;
|
|
61
|
+
}
|
|
62
|
+
.b-bento__head {
|
|
63
|
+
max-width: var(--max);
|
|
64
|
+
margin-inline: auto;
|
|
65
|
+
text-align: center;
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 0.75rem;
|
|
69
|
+
}
|
|
70
|
+
.b-bento__intro { margin: 0; color: var(--muted); line-height: 1.6; }
|
|
71
|
+
.b-bento__grid {
|
|
72
|
+
display: grid;
|
|
73
|
+
grid-template-columns: 1fr;
|
|
74
|
+
gap: 1rem;
|
|
75
|
+
}
|
|
76
|
+
@media (min-width: 48em) {
|
|
77
|
+
.b-bento__grid {
|
|
78
|
+
grid-template-columns: repeat(2, 1fr);
|
|
79
|
+
grid-auto-rows: minmax(11rem, auto);
|
|
80
|
+
grid-auto-flow: dense;
|
|
81
|
+
}
|
|
82
|
+
.b-bento__item[data-size='wide'] { grid-column: span 2; }
|
|
83
|
+
.b-bento__item[data-size='tall'] { grid-row: span 2; }
|
|
84
|
+
.b-bento__item[data-size='lg'] { grid-column: span 2; grid-row: span 2; }
|
|
85
|
+
}
|
|
86
|
+
@media (min-width: 64em) {
|
|
87
|
+
.b-bento__grid { grid-template-columns: repeat(4, 1fr); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.b-bento__item {
|
|
91
|
+
position: relative;
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 0.4rem;
|
|
95
|
+
padding: 1.5rem;
|
|
96
|
+
border-radius: var(--radius);
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
isolation: isolate;
|
|
99
|
+
text-decoration: none;
|
|
100
|
+
color: var(--fg);
|
|
101
|
+
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
102
|
+
}
|
|
103
|
+
.b-bento__item[data-variant='surface'] { background: var(--bg-surface); border: var(--card-border); box-shadow: var(--shadow-md); }
|
|
104
|
+
.b-bento__item[data-variant='filled'] { background: var(--gold-light); }
|
|
105
|
+
.b-bento__item[data-variant='gradient'] { background: linear-gradient(135deg, var(--gold), var(--accent-2, var(--gold))); color: var(--text-light); }
|
|
106
|
+
.b-bento__item[data-variant='image'] { color: #fff; justify-content: flex-end; min-height: 11rem; }
|
|
107
|
+
.b-bento__item[data-variant='image']::before {
|
|
108
|
+
content: '';
|
|
109
|
+
position: absolute;
|
|
110
|
+
inset: 0;
|
|
111
|
+
z-index: -1;
|
|
112
|
+
background-image: linear-gradient(to top, rgba(8,10,18,0.82), rgba(8,10,18,0.15)), var(--bento-image);
|
|
113
|
+
background-size: cover;
|
|
114
|
+
background-position: center;
|
|
115
|
+
}
|
|
116
|
+
a.b-bento__item:hover { transform: translateY(-3px); }
|
|
117
|
+
a.b-bento__item[data-variant='surface']:hover { box-shadow: var(--shadow-lg); }
|
|
118
|
+
a.b-bento__item:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
119
|
+
@media (prefers-reduced-motion: reduce) { .b-bento__item { transition: none; } a.b-bento__item:hover { transform: none; } }
|
|
120
|
+
|
|
121
|
+
.b-bento__icon { display: inline-flex; color: var(--gold); }
|
|
122
|
+
.b-bento__item[data-variant='gradient'] .b-bento__icon,
|
|
123
|
+
.b-bento__item[data-variant='image'] .b-bento__icon { color: currentColor; }
|
|
124
|
+
.b-bento__eyebrow {
|
|
125
|
+
font-size: 0.72rem;
|
|
126
|
+
font-weight: 700;
|
|
127
|
+
letter-spacing: 0.1em;
|
|
128
|
+
text-transform: uppercase;
|
|
129
|
+
color: var(--gold);
|
|
130
|
+
}
|
|
131
|
+
.b-bento__item[data-variant='gradient'] .b-bento__eyebrow,
|
|
132
|
+
.b-bento__item[data-variant='image'] .b-bento__eyebrow { color: currentColor; opacity: 0.85; }
|
|
133
|
+
.b-bento__title { margin: 0; font-size: 1.15rem; color: inherit; }
|
|
134
|
+
.b-bento__text { margin: 0; line-height: 1.55; color: var(--muted); }
|
|
135
|
+
.b-bento__item[data-variant='gradient'] .b-bento__text,
|
|
136
|
+
.b-bento__item[data-variant='image'] .b-bento__text,
|
|
137
|
+
.b-bento__item[data-variant='filled'] .b-bento__text { color: inherit; opacity: 0.92; }
|
|
138
|
+
</style>
|