ferst-core 0.2.5 → 0.2.6

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.
@@ -41,6 +41,7 @@ import LatestPosts from './recipes/LatestPosts.astro';
41
41
  import MapEmbed from './recipes/MapEmbed.astro';
42
42
  import Table from './recipes/Table.astro';
43
43
  import Notice from './recipes/Notice.astro';
44
+ import MediaCards from './recipes/MediaCards.astro';
44
45
 
45
46
  interface Props {
46
47
  blocks: Block[];
@@ -84,6 +85,7 @@ const registry: Record<BlockType, any> = {
84
85
  mapEmbed: MapEmbed,
85
86
  table: Table,
86
87
  notice: Notice,
88
+ mediaCards: MediaCards,
87
89
  };
88
90
  ---
89
91
 
@@ -0,0 +1,192 @@
1
+ ---
2
+ /**
3
+ * MediaCards — a Level-2 recipe: a grid of rich content cards with the IMAGE as a
4
+ * header on top (cover-cropped to a uniform ratio, like PostCard), then an optional
5
+ * icon badge, a title, a meta line (e.g. "Sundays 10:45 · CTK"), a description, and
6
+ * an optional CTA link. The image-HEADER pattern (distinct from `tiles`, which puts
7
+ * the image BEHIND the text): use it for groups, team members, services or events —
8
+ * anywhere the photo and the copy both need to be fully legible. Whole card links
9
+ * when `href` is set. Token-driven, equal-height.
10
+ */
11
+ import Icon from '../Icon.astro';
12
+ import { isIconKey } from '../../lib/icons';
13
+ import Heading from '../blocks/Heading.astro';
14
+
15
+ interface Card {
16
+ image?: { src: string; alt?: string };
17
+ icon?: string;
18
+ title: string;
19
+ meta?: string;
20
+ text?: string;
21
+ href?: string;
22
+ linkLabel?: string;
23
+ }
24
+ interface Props {
25
+ title?: string;
26
+ intro?: string;
27
+ columns?: 2 | 3;
28
+ cards?: Card[];
29
+ }
30
+ const { title, intro, columns = 3, cards = [] } = Astro.props;
31
+ ---
32
+
33
+ <section class="b-media-cards">
34
+ {(title || intro) && (
35
+ <div class="b-media-cards__head">
36
+ {title && <Heading text={title} level={2} />}
37
+ {intro && <p class="b-media-cards__intro">{intro}</p>}
38
+ </div>
39
+ )}
40
+ <div class="b-media-cards__grid" data-cols={columns}>
41
+ {cards.map((card) => (
42
+ <article class:list={['b-media-cards__card', card.href && 'is-link']}>
43
+ {card.image && (
44
+ <div class="b-media-cards__media">
45
+ <img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" />
46
+ </div>
47
+ )}
48
+ <div class="b-media-cards__body">
49
+ {card.icon && isIconKey(card.icon) && (
50
+ <span class="b-media-cards__badge" aria-hidden="true"><Icon name={card.icon} size={20} /></span>
51
+ )}
52
+ <h3 class="b-media-cards__title">
53
+ {card.href ? <a href={card.href}>{card.title}</a> : card.title}
54
+ </h3>
55
+ {card.meta && <p class="b-media-cards__meta">{card.meta}</p>}
56
+ {card.text && <p class="b-media-cards__text">{card.text}</p>}
57
+ {card.href && (
58
+ <span class="b-media-cards__cta" aria-hidden="true">{card.linkLabel ?? 'Learn more'} →</span>
59
+ )}
60
+ </div>
61
+ </article>
62
+ ))}
63
+ </div>
64
+ </section>
65
+
66
+ <style>
67
+ .b-media-cards {
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 2rem;
71
+ max-width: 72rem;
72
+ margin-inline: auto;
73
+ padding-inline: 1rem;
74
+ }
75
+ .b-media-cards__head {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 0.75rem;
79
+ }
80
+ .b-media-cards__intro {
81
+ margin: 0;
82
+ color: var(--muted);
83
+ font-size: 1.0625rem;
84
+ line-height: 1.6;
85
+ max-width: var(--max);
86
+ }
87
+ .b-media-cards__grid {
88
+ display: grid;
89
+ grid-template-columns: 1fr;
90
+ gap: 1.5rem;
91
+ }
92
+ @media (min-width: 40em) {
93
+ .b-media-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
94
+ .b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(2, 1fr); }
95
+ }
96
+ @media (min-width: 60em) {
97
+ .b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
98
+ }
99
+ .b-media-cards__card {
100
+ position: relative;
101
+ display: flex;
102
+ flex-direction: column;
103
+ background: var(--bg-surface);
104
+ border: var(--card-border);
105
+ border-radius: var(--radius);
106
+ box-shadow: var(--shadow-sm);
107
+ overflow: hidden;
108
+ transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
109
+ }
110
+ .b-media-cards__card.is-link:hover {
111
+ transform: translateY(-2px);
112
+ box-shadow: var(--shadow-md);
113
+ border-color: var(--gold);
114
+ }
115
+ /* Image header — cover-cropped to a uniform 16:9 so any source ratio fits (the
116
+ PostCard technique; also out-specifies the global img{height:auto}). */
117
+ .b-media-cards__media {
118
+ position: relative;
119
+ aspect-ratio: 16 / 9;
120
+ overflow: hidden;
121
+ background: var(--bg-section);
122
+ }
123
+ .b-media-cards .b-media-cards__media img {
124
+ position: absolute;
125
+ inset: 0;
126
+ width: 100%;
127
+ height: 100%;
128
+ object-fit: cover;
129
+ display: block;
130
+ }
131
+ .b-media-cards__body {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 0.5rem;
135
+ padding: 1.1rem 1.2rem 1.3rem;
136
+ flex: 1;
137
+ }
138
+ /* Icon badge — a small brand chip; slightly lifted over the image edge when an
139
+ image is present (the negative margin only bites inside a card with a header). */
140
+ .b-media-cards__badge {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ width: 2.25rem;
145
+ height: 2.25rem;
146
+ border-radius: 999px;
147
+ background: var(--gold-light);
148
+ color: var(--gold);
149
+ margin-top: -2.3rem;
150
+ margin-bottom: 0.2rem;
151
+ border: 3px solid var(--bg-surface);
152
+ }
153
+ /* No image → the badge sits normally at the top of the body. */
154
+ .b-media-cards__card:not(:has(.b-media-cards__media)) .b-media-cards__badge {
155
+ margin-top: 0;
156
+ border: none;
157
+ }
158
+ .b-media-cards__title {
159
+ margin: 0;
160
+ font-size: 1.2rem;
161
+ color: var(--fg);
162
+ }
163
+ .b-media-cards__card.is-link:hover .b-media-cards__title a { color: var(--gold); }
164
+ .b-media-cards__title a { color: var(--fg); }
165
+ /* Stretch the title link over the whole card so it's clickable. */
166
+ .b-media-cards__title a::after { content: ''; position: absolute; inset: 0; }
167
+ .b-media-cards__meta {
168
+ margin: 0;
169
+ align-self: flex-start;
170
+ padding: 0.2rem 0.6rem;
171
+ border-radius: 999px;
172
+ background: var(--bg-section);
173
+ color: var(--muted);
174
+ font-family: var(--font-heading);
175
+ font-size: 0.72rem;
176
+ font-weight: 600;
177
+ letter-spacing: 0.03em;
178
+ }
179
+ .b-media-cards__text {
180
+ margin: 0;
181
+ color: var(--muted);
182
+ line-height: 1.6;
183
+ }
184
+ .b-media-cards__cta {
185
+ margin-top: auto;
186
+ padding-top: 0.5rem;
187
+ font-family: var(--font-heading);
188
+ font-size: 0.85rem;
189
+ font-weight: 600;
190
+ color: var(--gold);
191
+ }
192
+ </style>
package/content/blocks.ts CHANGED
@@ -442,6 +442,34 @@ export const noticeBlock = z.object({
442
442
  link: z.object({ label: z.string(), href: z.string() }).optional(),
443
443
  });
444
444
 
445
+ /** MediaCards — a grid of rich content cards with the IMAGE as a header on top
446
+ * (cover-cropped), then an optional icon badge, title, meta line, description and
447
+ * CTA. The image-HEADER pattern (distinct from `tiles`, which puts the image
448
+ * BEHIND the text) — for groups, team, services, events where the photo AND copy
449
+ * must both stay legible. Whole card links when a card has `href`. */
450
+ export const mediaCardsBlock = z.object({
451
+ type: z.literal('mediaCards'),
452
+ enabled: z.boolean().default(true),
453
+ title: z.string().optional(),
454
+ intro: z.string().optional(),
455
+ columns: z.union([z.literal(2), z.literal(3)]).default(3),
456
+ cards: z
457
+ .array(
458
+ z.object({
459
+ image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
460
+ icon: z.string().optional(),
461
+ title: z.string(),
462
+ /** A short meta line shown as a pill (e.g. "Sundays 10:45 · CTK"). */
463
+ meta: z.string().optional(),
464
+ text: z.string().optional(),
465
+ href: z.string().optional(),
466
+ /** CTA label when the card links (defaults to "Learn more"). */
467
+ linkLabel: z.string().optional(),
468
+ })
469
+ )
470
+ .default([]),
471
+ });
472
+
445
473
  export type RecipeBlock =
446
474
  | z.infer<typeof heroBlock>
447
475
  | z.infer<typeof featureCardsBlock>
@@ -464,7 +492,8 @@ export type RecipeBlock =
464
492
  | z.infer<typeof latestPostsBlock>
465
493
  | z.infer<typeof mapEmbedBlock>
466
494
  | z.infer<typeof tableBlock>
467
- | z.infer<typeof noticeBlock>;
495
+ | z.infer<typeof noticeBlock>
496
+ | z.infer<typeof mediaCardsBlock>;
468
497
 
469
498
  /* ── Level-1 layout / container primitives ─────────────────────────────────
470
499
  * These hold child blocks, so the schema is **recursive** (a container's
@@ -572,6 +601,7 @@ export const blockSchema = z.discriminatedUnion('type', [
572
601
  mapEmbedBlock,
573
602
  tableBlock,
574
603
  noticeBlock,
604
+ mediaCardsBlock,
575
605
  sectionBlock,
576
606
  gridBlock,
577
607
  stackBlock,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",