ferst-core 0.2.7 → 0.2.9

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.
@@ -22,8 +22,9 @@ interface Props {
22
22
  overlay?: number;
23
23
  align?: 'start' | 'center' | 'end';
24
24
  parallax?: boolean;
25
+ fullBleed?: boolean;
25
26
  }
26
- const { image, eyebrow, title, text, cta, overlay = 50, align = 'start', parallax = true } = Astro.props;
27
+ const { image, eyebrow, title, text, cta, overlay = 50, align = 'start', parallax = true, fullBleed = false } = Astro.props;
27
28
  const scrim = overlay / 100;
28
29
  ---
29
30
 
@@ -31,6 +32,7 @@ const scrim = overlay / 100;
31
32
  class="b-banner"
32
33
  data-align={align}
33
34
  data-parallax={parallax ? 'true' : 'false'}
35
+ data-full-bleed={fullBleed ? 'true' : 'false'}
34
36
  style={`--banner-image: url('${image}'); --banner-scrim: ${scrim};`}
35
37
  >
36
38
  <div class="b-banner__scrim" aria-hidden="true"></div>
@@ -60,6 +62,16 @@ const scrim = overlay / 100;
60
62
  background-position: center;
61
63
  background-repeat: no-repeat;
62
64
  }
65
+ /* Full-bleed: break out of the content column to the full viewport width, edges
66
+ squared off. Safe from a horizontal scrollbar because Base sets overflow-x:
67
+ clip on html/body. The card keeps a comfortable inset that tracks the content
68
+ column on wide screens, so it still lines up with the page below. */
69
+ .b-banner[data-full-bleed='true'] {
70
+ width: 100vw;
71
+ margin-inline: calc(50% - 50vw);
72
+ border-radius: 0;
73
+ padding-inline: max(clamp(1.25rem, 5vw, 4rem), calc((100vw - 72rem) / 2 + 2rem));
74
+ }
63
75
  /* Parallax: pin the image to the viewport so it slides under the content.
64
76
  Only on fine pointers with motion allowed — `fixed` is ignored/janky on
65
77
  touch (iOS) and unwelcome under reduced-motion. */
@@ -11,6 +11,7 @@ import PostCard from '../PostCard.astro';
11
11
  import Heading from '../blocks/Heading.astro';
12
12
  import Button from '../Button.astro';
13
13
  import { getIndexPosts, getPostsByTag } from '../../lib/posts';
14
+ import { isRecent } from '../../content/posts';
14
15
 
15
16
  interface Props {
16
17
  title?: string;
@@ -18,6 +19,8 @@ interface Props {
18
19
  limit?: number;
19
20
  tag?: string;
20
21
  layout?: 'grid' | 'list';
22
+ /** In the list layout, badge posts newer than this many days as "New" (0 = off). */
23
+ newWithinDays?: number;
21
24
  viewAllHref?: string;
22
25
  viewAllLabel?: string;
23
26
  }
@@ -27,6 +30,7 @@ const {
27
30
  limit = 3,
28
31
  tag,
29
32
  layout = 'grid',
33
+ newWithinDays = 14,
30
34
  viewAllHref = '/posts',
31
35
  viewAllLabel = 'All posts',
32
36
  } = Astro.props;
@@ -40,6 +44,14 @@ const fmt = (date: Date | string) => {
40
44
  ? { iso: undefined as string | undefined, label: '' }
41
45
  : { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) };
42
46
  };
47
+
48
+ // The "New" badge marks only the single most-recent post, and only if it was
49
+ // published within `newWithinDays` of build time — so a site that posts often
50
+ // doesn't end up flagging every row (which would make the badge meaningless).
51
+ // Recomputed each build (a site rebuilds on publish, so it stays fresh); isRecent
52
+ // lives in the pure posts module so it's unit-tested independently.
53
+ const nowMs = Date.now();
54
+ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
43
55
  ---
44
56
 
45
57
  <section class="b-latest-posts">
@@ -54,15 +66,28 @@ const fmt = (date: Date | string) => {
54
66
  <p class="b-latest-posts__empty">No posts yet.</p>
55
67
  ) : layout === 'list' ? (
56
68
  <ul class="b-latest-posts__list">
57
- {posts.map((p) => {
69
+ {posts.map((p, i) => {
58
70
  const { iso, label } = fmt(p.data.date);
71
+ const href = `/posts/${p.slug}`;
72
+ const cats = (p.data.tags ?? []).slice(0, 2);
59
73
  return (
60
- <li class="b-latest-posts__row">
61
- <a class="b-latest-posts__row-link" href={`/posts/${p.slug}`}>
62
- <span class="b-latest-posts__row-title">{p.data.title}</span>
63
- {label && <time class="b-latest-posts__row-date" datetime={iso}>{label}</time>}
64
- </a>
65
- {p.data.summary && <p class="b-latest-posts__row-summary">{p.data.summary}</p>}
74
+ <li class="b-post-row">
75
+ {p.data.heroImage && (
76
+ <a class="b-post-row__media" href={href} tabindex="-1" aria-hidden="true">
77
+ <img src={p.data.heroImage} alt="" loading="lazy" />
78
+ </a>
79
+ )}
80
+ <div class="b-post-row__main">
81
+ {cats.length > 0 && (
82
+ <span class="b-post-row__cat">{cats.join(' · ')}</span>
83
+ )}
84
+ <h3 class="b-post-row__title"><a href={href}>{p.data.title}</a></h3>
85
+ </div>
86
+ <div class="b-post-row__aside">
87
+ {i === 0 && isNew(p.data.date) && <span class="b-post-row__new">New</span>}
88
+ {label && <time class="b-post-row__date" datetime={iso}>{label}</time>}
89
+ <span class="b-post-row__chevron" aria-hidden="true">&rsaquo;</span>
90
+ </div>
66
91
  </li>
67
92
  );
68
93
  })}
@@ -122,7 +147,9 @@ const fmt = (date: Date | string) => {
122
147
  @media (min-width: 60em) {
123
148
  .b-latest-posts__grid { grid-template-columns: repeat(3, 1fr); }
124
149
  }
125
- /* Compact list — no images; a tidy dated index for sidebars or short homepages. */
150
+ /* Compact miniature list — a thumbnail + category + title per row with the date
151
+ (and an optional "New" badge) on the right, split by hairlines. Stays short
152
+ however many posts it shows; the whole row is one click target. */
126
153
  .b-latest-posts__list {
127
154
  list-style: none;
128
155
  margin: 0;
@@ -130,40 +157,118 @@ const fmt = (date: Date | string) => {
130
157
  display: flex;
131
158
  flex-direction: column;
132
159
  }
133
- .b-latest-posts__row {
134
- padding: 0.9rem 0;
135
- border-top: var(--card-border);
160
+ .b-post-row {
161
+ position: relative;
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 1rem;
165
+ padding: 0.85rem 0;
166
+ border-top: 1px solid var(--border);
136
167
  }
137
- .b-latest-posts__row:first-child {
168
+ .b-post-row:first-child {
138
169
  border-top: 0;
139
170
  }
140
- .b-latest-posts__row-link {
171
+ /* Thumbnail — a small square, cover-cropped (never stretched). */
172
+ .b-post-row__media {
173
+ flex: 0 0 auto;
174
+ display: block;
175
+ width: 3.25rem;
176
+ height: 3.25rem;
177
+ border-radius: var(--radius-sm);
178
+ overflow: hidden;
179
+ background: var(--bg-section);
180
+ }
181
+ .b-post-row .b-post-row__media img {
182
+ width: 100%;
183
+ height: 100%;
184
+ object-fit: cover;
185
+ display: block;
186
+ }
187
+ .b-post-row__main {
188
+ flex: 1 1 auto;
189
+ min-width: 0;
141
190
  display: flex;
142
- flex-wrap: wrap;
143
- align-items: baseline;
144
- justify-content: space-between;
145
- gap: 0.25rem 1rem;
191
+ flex-direction: column;
192
+ gap: 0.25rem;
193
+ }
194
+ /* Category chip — the post's tags, joined, as a small brand-tinted pill. */
195
+ .b-post-row__cat {
196
+ align-self: flex-start;
197
+ max-width: 100%;
198
+ padding: 0.1rem 0.5rem;
199
+ border-radius: var(--radius-pill);
200
+ background: var(--bg-tag);
201
+ color: var(--muted);
202
+ font-family: var(--font-heading);
203
+ font-size: 0.66rem;
204
+ font-weight: 700;
205
+ letter-spacing: 0.06em;
206
+ text-transform: uppercase;
207
+ white-space: nowrap;
208
+ overflow: hidden;
209
+ text-overflow: ellipsis;
210
+ }
211
+ .b-post-row__title {
212
+ margin: 0;
213
+ font-size: 1.05rem;
214
+ line-height: 1.35;
215
+ }
216
+ .b-post-row__title a {
146
217
  color: var(--fg);
147
- text-decoration: none;
218
+ font-family: var(--font-heading);
219
+ font-weight: 600;
148
220
  }
149
- .b-latest-posts__row-link:hover .b-latest-posts__row-title {
221
+ .b-post-row:hover .b-post-row__title a {
150
222
  color: var(--gold);
151
223
  }
152
- .b-latest-posts__row-title {
224
+ /* Stretch the title link across the whole row so any part of it is clickable. */
225
+ .b-post-row__title a::after {
226
+ content: '';
227
+ position: absolute;
228
+ inset: 0;
229
+ }
230
+ .b-post-row__aside {
231
+ flex: 0 0 auto;
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 0.6rem 0.8rem;
235
+ }
236
+ .b-post-row__new {
237
+ padding: 0.12rem 0.45rem;
238
+ border-radius: var(--radius-pill);
239
+ background: var(--gold);
240
+ color: var(--text-light);
153
241
  font-family: var(--font-heading);
154
- font-weight: 600;
155
- font-size: 1.05rem;
242
+ font-size: 0.62rem;
243
+ font-weight: 700;
244
+ letter-spacing: 0.06em;
245
+ text-transform: uppercase;
156
246
  }
157
- .b-latest-posts__row-date {
247
+ .b-post-row__date {
158
248
  color: var(--muted);
159
249
  font-size: 0.85rem;
160
250
  white-space: nowrap;
251
+ font-variant-numeric: tabular-nums;
161
252
  }
162
- .b-latest-posts__row-summary {
163
- margin: 0.35rem 0 0;
253
+ .b-post-row__chevron {
164
254
  color: var(--muted);
165
- font-size: 0.95rem;
166
- line-height: 1.55;
255
+ font-size: 1.25rem;
256
+ line-height: 1;
257
+ }
258
+ .b-post-row:hover .b-post-row__chevron {
259
+ color: var(--gold);
260
+ }
261
+ /* On narrow screens keep the title on the left and let the date tuck under the
262
+ "New" badge if needed; the chevron drops (touch targets are the whole row). */
263
+ @media (max-width: 30rem) {
264
+ .b-post-row__chevron {
265
+ display: none;
266
+ }
267
+ .b-post-row__aside {
268
+ flex-direction: column;
269
+ align-items: flex-end;
270
+ gap: 0.25rem;
271
+ }
167
272
  }
168
273
  .b-latest-posts__empty {
169
274
  margin: 0;
package/content/blocks.ts CHANGED
@@ -268,6 +268,10 @@ export const bannerBlock = z.object({
268
268
  overlay: z.number().min(0).max(100).default(50),
269
269
  align: z.enum(['start', 'center', 'end']).default('start'),
270
270
  parallax: z.boolean().default(true),
271
+ /** Break the band out to the full viewport width (edge-to-edge, no rounded
272
+ * corners); the card stays aligned to the page's content column. Great for a
273
+ * homepage hero. Off by default (the band sits inside the content column). */
274
+ fullBleed: z.boolean().default(false),
271
275
  });
272
276
 
273
277
  /** Stats — a band of headline numbers (value + label + optional note), over an
@@ -393,7 +397,13 @@ export const latestPostsBlock = z.object({
393
397
  limit: z.number().int().min(1).max(12).default(3),
394
398
  /** Filter to a single tag slug; omit for the main index (minus excluded tags). */
395
399
  tag: z.string().optional(),
400
+ /** `grid` = full image cards; `list` = a compact miniature index (thumbnail +
401
+ * category + date per row) that stays short even with many posts. */
396
402
  layout: z.enum(['grid', 'list']).default('grid'),
403
+ /** In the `list` layout, badge the most-recent post as "New" when it was
404
+ * published within this many days (0 disables it). Only the newest row is ever
405
+ * flagged, so a frequently-posting site doesn't mark every row. Grid ignores it. */
406
+ newWithinDays: z.number().int().min(0).max(365).default(14),
397
407
  /** "View all" target + label (the posts index by default). */
398
408
  viewAllHref: z.string().default('/posts'),
399
409
  viewAllLabel: z.string().default('All posts'),
package/content/posts.ts CHANGED
@@ -87,3 +87,17 @@ export function tagCounts(posts: Array<{ data: { tags?: string[] } }>): Record<s
87
87
  for (const p of posts) for (const t of p.data.tags ?? []) counts[t] = (counts[t] ?? 0) + 1;
88
88
  return counts;
89
89
  }
90
+
91
+ /**
92
+ * Whether a post date is within the last `days` of `now` — used to flag a "New"
93
+ * badge in the compact latest-posts list. `days <= 0` disables it; a future or
94
+ * unparseable date is never "new". Pure; `now` is injectable for tests.
95
+ */
96
+ export function isRecent(date: Date | string, days: number, now: number = Date.now()): boolean {
97
+ if (!days || days <= 0) return false;
98
+ const d = date instanceof Date ? date : new Date(date);
99
+ const t = d.valueOf();
100
+ if (Number.isNaN(t)) return false;
101
+ const ageMs = now - t;
102
+ return ageMs >= 0 && ageMs <= days * 86_400_000;
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",