ferst-core 0.3.0 → 0.4.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.
@@ -42,6 +42,8 @@ import MapEmbed from './recipes/MapEmbed.astro';
42
42
  import Table from './recipes/Table.astro';
43
43
  import Notice from './recipes/Notice.astro';
44
44
  import MediaCards from './recipes/MediaCards.astro';
45
+ import ContactCard from './recipes/ContactCard.astro';
46
+ import Locations from './recipes/Locations.astro';
45
47
 
46
48
  interface Props {
47
49
  blocks: Block[];
@@ -86,6 +88,8 @@ const registry: Record<BlockType, any> = {
86
88
  table: Table,
87
89
  notice: Notice,
88
90
  mediaCards: MediaCards,
91
+ contactCard: ContactCard,
92
+ locations: Locations,
89
93
  };
90
94
  ---
91
95
 
@@ -84,13 +84,24 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
84
84
 
85
85
  const embeds = document.querySelectorAll<HTMLElement>('.b-calendar__embed');
86
86
 
87
+ // Google Calendar embeds render light-only — `bgcolor` recolours just the
88
+ // surround, never the grid, so matching it to a dark surface still left a white
89
+ // calendar sitting in the dark page. Instead we force a deterministic WHITE
90
+ // surround here (independent of the theme at load) and, in dark mode, invert the
91
+ // whole frame in CSS (below). The invert is keyed on the site's [data-theme], so
92
+ // it also flips live when the visitor toggles the theme.
93
+ function calendarUrl(url: string): string {
94
+ if (!/google\.com\/calendar|calendar\.google\.com/.test(url)) return url;
95
+ return url + (url.includes('?') ? '&' : '?') + 'bgcolor=%23ffffff';
96
+ }
97
+
87
98
  function load(el: HTMLElement) {
88
99
  if (el.dataset.loaded === 'true') return;
89
100
  const url = el.dataset.embedUrl;
90
101
  const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
91
102
  if (!url || !frameSlot) return;
92
103
  const frame = document.createElement('iframe');
93
- frame.src = url;
104
+ frame.src = calendarUrl(url);
94
105
  frame.title = el.dataset.title || 'Calendar';
95
106
  frame.loading = 'lazy';
96
107
  frame.style.width = '100%';
@@ -135,7 +146,13 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
135
146
  display: block;
136
147
  border: var(--card-border);
137
148
  border-radius: var(--radius);
138
- background: var(--bg-surface);
149
+ background: #fff;
150
+ }
151
+ /* Google Calendar is light-only; in dark mode invert the frame so it reads as a
152
+ dark calendar (hue-rotate keeps event colours roughly true). Keyed on the
153
+ site's [data-theme] so it also flips live on a theme toggle. */
154
+ :global([data-theme='dark']) .b-calendar__frame :global(iframe) {
155
+ filter: invert(1) hue-rotate(180deg);
139
156
  }
140
157
  /* Consent card stands in for the calendar until the visitor opts in. */
141
158
  .b-calendar__consent {
@@ -48,6 +48,16 @@ const { policyHref = '/cookie-policy/' } = Astro.props;
48
48
  </script>
49
49
 
50
50
  <style is:global>
51
+ /* `display: flex` below is an author rule and would beat the UA `[hidden] {
52
+ display: none }`, so the banner would stay in the layout while dismissed (or
53
+ for returning visitors, where it starts hidden) — an invisible opacity:0
54
+ fixed bar that still INTERCEPTS pointer events over whatever sits at the
55
+ viewport bottom (e.g. the footer's later links). This explicit rule makes
56
+ `hidden` truly remove it. Must come before `.cookie-banner` so specificity,
57
+ not order, decides — (0,1,1) beats (0,1,0). */
58
+ .cookie-banner[hidden] {
59
+ display: none;
60
+ }
51
61
  .cookie-banner {
52
62
  position: fixed;
53
63
  left: 20px;
@@ -12,8 +12,14 @@ interface Props {
12
12
  heroImage?: string;
13
13
  /** Optional tag labels shown as a small eyebrow. */
14
14
  tagLabels?: string[];
15
+ /** Open the link in a new tab (e.g. a post that's just an attached PDF). */
16
+ newTab?: boolean;
17
+ /** The call-to-action label (default "Read more"). */
18
+ moreLabel?: string;
15
19
  }
16
- const { href, title, date, summary, heroImage, tagLabels = [] } = Astro.props;
20
+ const { href, title, date, summary, heroImage, tagLabels = [], newTab = false, moreLabel = 'Read more' } = Astro.props;
21
+ const linkRel = newTab ? 'noopener' : undefined;
22
+ const linkTarget = newTab ? '_blank' : undefined;
17
23
  const d = date instanceof Date ? date : new Date(date);
18
24
  const iso = Number.isNaN(d.valueOf()) ? undefined : d.toISOString().slice(0, 10);
19
25
  const display = Number.isNaN(d.valueOf())
@@ -23,7 +29,7 @@ const display = Number.isNaN(d.valueOf())
23
29
 
24
30
  <article class="b-post-card">
25
31
  {heroImage && (
26
- <a class="b-post-card__media" href={href} tabindex="-1" aria-hidden="true">
32
+ <a class="b-post-card__media" href={href} target={linkTarget} rel={linkRel} tabindex="-1" aria-hidden="true">
27
33
  <img src={heroImage} alt="" loading="lazy" />
28
34
  </a>
29
35
  )}
@@ -33,10 +39,10 @@ const display = Number.isNaN(d.valueOf())
33
39
  {display && <time datetime={iso}>{display}</time>}
34
40
  </div>
35
41
  <h3 class="b-post-card__title">
36
- <a href={href}>{title}</a>
42
+ <a href={href} target={linkTarget} rel={linkRel}>{title}</a>
37
43
  </h3>
38
44
  {summary && <p class="b-post-card__summary">{summary}</p>}
39
- <span class="b-post-card__more" aria-hidden="true">Read more</span>
45
+ <span class="b-post-card__more" aria-hidden="true">{moreLabel}</span>
40
46
  </div>
41
47
  </article>
42
48
 
@@ -190,11 +190,17 @@ const year = new Date().getFullYear();
190
190
  text-align: center;
191
191
  gap: 0.6rem 1.5rem;
192
192
  }
193
+ /* Tight gap: each link carries its own inline padding (below), so the visible
194
+ hit target is a clear region *centred on its label* — no wide dead gap between
195
+ labels where a hover lands on nothing (and the browser keeps showing the
196
+ previous link's URL, which reads as an off-by-one). The negative inline margin
197
+ cancels the first/last item's padding so the row still edge-aligns. */
193
198
  .site-footer .footer-nav {
194
199
  display: flex;
195
200
  flex-wrap: wrap;
196
201
  justify-content: center;
197
- gap: 0.3rem 1rem;
202
+ gap: 0.15rem 0.15rem;
203
+ margin-inline: -0.55rem;
198
204
  }
199
205
  .site-footer .footer-nav a,
200
206
  .site-footer .footer-nav button {
@@ -208,13 +214,32 @@ const year = new Date().getFullYear();
208
214
  color: var(--accent);
209
215
  background: none;
210
216
  border: none;
211
- padding: 0;
217
+ border-radius: var(--radius-sm, 6px);
218
+ padding: 0 0.55rem;
212
219
  cursor: pointer;
220
+ text-decoration: none;
221
+ transition: background 0.15s ease, color 0.15s ease;
222
+ }
223
+ /* Visible hover/focus affordance: makes each link's boundary unmistakable, so
224
+ it's always clear which one you're on. */
225
+ .site-footer .footer-nav a:hover,
226
+ .site-footer .footer-nav button:hover,
227
+ .site-footer .footer-nav a:focus-visible,
228
+ .site-footer .footer-nav button:focus-visible {
229
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
230
+ text-decoration: underline;
231
+ text-underline-offset: 3px;
213
232
  }
214
233
  [data-theme="dark"] .site-footer .footer-nav a,
215
234
  [data-theme="dark"] .site-footer .footer-nav button {
216
235
  color: var(--gold);
217
236
  }
237
+ [data-theme="dark"] .site-footer .footer-nav a:hover,
238
+ [data-theme="dark"] .site-footer .footer-nav button:hover,
239
+ [data-theme="dark"] .site-footer .footer-nav a:focus-visible,
240
+ [data-theme="dark"] .site-footer .footer-nav button:focus-visible {
241
+ background: color-mix(in srgb, var(--gold) 16%, transparent);
242
+ }
218
243
  .site-footer .footer-copy {
219
244
  margin: 0;
220
245
  font-family: var(--font-body);
@@ -0,0 +1,170 @@
1
+ ---
2
+ /**
3
+ * ContactCard — a Level-2 recipe: a rich, branded "get in touch" band. A display-
4
+ * type title + subtitle on the left, the contact channels as tappable tiles on the
5
+ * right, and a closing note across the bottom. For a contact or find-us page — the
6
+ * polished alternative to a plain notice box.
7
+ *
8
+ * Fully THEME-ADAPTIVE (composes in light AND dark): the panel is `--bg-section`
9
+ * with a soft `--gold` corner wash for brand presence, text is `--fg`/`--accent`/
10
+ * `--muted`, tiles are `--bg-surface` recesses, icons + hovers use `--gold`. Every
11
+ * one of those tokens flips per theme, so the band re-composes correctly in each
12
+ * mode (never the old always-dark band) and a CMS colour change recolours it.
13
+ * Contrast-safe: brand gold is used only decoratively (wash, rule, icons, hovers),
14
+ * never as body/label text — those use the ink/text tokens that meet AA on the
15
+ * panel in both themes. Whole tile links when `href` is set.
16
+ */
17
+ import Icon from '../Icon.astro';
18
+ import { isIconKey } from '../../lib/icons';
19
+
20
+ interface Item {
21
+ icon?: string;
22
+ value: string;
23
+ href?: string;
24
+ }
25
+ interface Props {
26
+ eyebrow?: string;
27
+ title: string;
28
+ subtitle?: string;
29
+ note?: string;
30
+ items?: Item[];
31
+ }
32
+ const { eyebrow, title, subtitle, note, items = [] } = Astro.props;
33
+ const rel = (href?: string) => (href && /^https?:\/\//.test(href) ? 'noopener' : undefined);
34
+ const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank' : undefined);
35
+ ---
36
+
37
+ <section class="b-contact">
38
+ <div class="b-contact__panel">
39
+ <div class="b-contact__head">
40
+ {eyebrow && <p class="b-contact__eyebrow">{eyebrow}</p>}
41
+ <h2 class="b-contact__title">{title}</h2>
42
+ {subtitle && <p class="b-contact__subtitle">{subtitle}</p>}
43
+ </div>
44
+
45
+ {items.length > 0 && (
46
+ <ul class="b-contact__items">
47
+ {items.map((item) => {
48
+ const inner = (
49
+ <>
50
+ {item.icon && isIconKey(item.icon) && (
51
+ <span class="b-contact__icon" aria-hidden="true"><Icon name={item.icon} size={18} /></span>
52
+ )}
53
+ <span class="b-contact__value">{item.value}</span>
54
+ </>
55
+ );
56
+ return (
57
+ <li>
58
+ {item.href ? (
59
+ <a class="b-contact__item" href={item.href} target={target(item.href)} rel={rel(item.href)}>{inner}</a>
60
+ ) : (
61
+ <div class="b-contact__item">{inner}</div>
62
+ )}
63
+ </li>
64
+ );
65
+ })}
66
+ </ul>
67
+ )}
68
+
69
+ {note && <p class="b-contact__note">{note}</p>}
70
+ </div>
71
+ </section>
72
+
73
+ <style>
74
+ .b-contact {
75
+ max-width: 72rem;
76
+ margin-inline: auto;
77
+ padding-inline: 1rem;
78
+ }
79
+ /* Adaptive panel: a section surface that flips light↔dark with the theme, lifted
80
+ off the page by a border + shadow, with a soft brand-gold wash for presence. */
81
+ .b-contact__panel {
82
+ position: relative;
83
+ isolation: isolate;
84
+ overflow: hidden;
85
+ display: grid;
86
+ grid-template-columns: 1fr;
87
+ gap: 1.5rem 2.5rem;
88
+ padding: clamp(1.75rem, 4vw, 3rem);
89
+ border-radius: var(--radius);
90
+ background: var(--bg-section);
91
+ color: var(--fg);
92
+ border: 1px solid var(--border);
93
+ box-shadow: var(--shadow-md);
94
+ }
95
+ /* Brand wash — a gold glow in the top-right, behind the content (z-index below the
96
+ panel's own children via isolation). Adds brand presence in both themes without
97
+ tinting the text ground, so contrast is never at risk. */
98
+ .b-contact__panel::before {
99
+ content: '';
100
+ position: absolute;
101
+ inset: 0;
102
+ z-index: -1;
103
+ background: radial-gradient(115% 130% at 100% 0%, color-mix(in srgb, var(--gold) 20%, transparent), transparent 62%);
104
+ pointer-events: none;
105
+ }
106
+ @media (min-width: 48rem) {
107
+ .b-contact__panel { grid-template-columns: 1.2fr 1fr; align-items: center; }
108
+ }
109
+ .b-contact__head { display: flex; flex-direction: column; gap: 0.5rem; }
110
+ .b-contact__eyebrow {
111
+ margin: 0;
112
+ display: inline-flex; align-items: center; gap: 0.6rem;
113
+ font-family: var(--font-heading);
114
+ font-size: 0.75rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
115
+ color: var(--accent);
116
+ }
117
+ /* Short gold rule carries the brand into the eyebrow without colouring the text. */
118
+ .b-contact__eyebrow::before {
119
+ content: ''; flex: 0 0 auto; width: 1.5rem; height: 2px; border-radius: 2px;
120
+ background: var(--gold);
121
+ }
122
+ .b-contact__title {
123
+ margin: 0;
124
+ font-family: var(--font-display, var(--font-heading));
125
+ font-size: clamp(1.9rem, 4vw, 2.8rem);
126
+ line-height: 1.05;
127
+ text-wrap: balance;
128
+ color: var(--fg);
129
+ }
130
+ .b-contact__subtitle {
131
+ margin: 0.15rem 0 0;
132
+ font-family: var(--font-heading);
133
+ font-weight: 600;
134
+ color: var(--accent);
135
+ }
136
+ .b-contact__items {
137
+ list-style: none;
138
+ margin: 0; padding: 0;
139
+ display: flex; flex-direction: column; gap: 0.6rem;
140
+ }
141
+ .b-contact__item {
142
+ display: flex; align-items: center; gap: 0.75rem;
143
+ padding: 0.7rem 1rem;
144
+ border-radius: var(--radius-sm);
145
+ background: var(--bg-surface);
146
+ border: 1px solid var(--border);
147
+ color: var(--fg);
148
+ font-family: var(--font-heading);
149
+ font-weight: 600;
150
+ font-size: 0.95rem;
151
+ text-decoration: none;
152
+ transition: border-color 0.15s ease, background 0.15s ease;
153
+ }
154
+ a.b-contact__item:hover {
155
+ border-color: var(--gold);
156
+ background: color-mix(in srgb, var(--gold) 8%, var(--bg-surface));
157
+ }
158
+ a.b-contact__item:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
159
+ .b-contact__icon { display: inline-flex; color: var(--gold); flex: 0 0 auto; }
160
+ .b-contact__value { min-width: 0; overflow-wrap: anywhere; }
161
+ .b-contact__note {
162
+ grid-column: 1 / -1;
163
+ margin: 0;
164
+ padding-top: 1.25rem;
165
+ border-top: 1px solid var(--border);
166
+ color: var(--muted);
167
+ font-size: 0.9rem;
168
+ line-height: 1.6;
169
+ }
170
+ </style>
@@ -11,7 +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
+ import { isRecent, postLink } from '../../content/posts';
15
15
 
16
16
  interface Props {
17
17
  title?: string;
@@ -68,12 +68,12 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
68
68
  <ul class="b-latest-posts__list">
69
69
  {posts.map((p, i) => {
70
70
  const { iso, label } = fmt(p.data.date);
71
- const href = `/posts/${p.slug}`;
71
+ const { href, newTab } = postLink(p);
72
72
  const cats = (p.data.tags ?? []).slice(0, 2);
73
73
  return (
74
74
  <li class="b-post-row">
75
75
  {p.data.heroImage && (
76
- <a class="b-post-row__media" href={href} tabindex="-1" aria-hidden="true">
76
+ <a class="b-post-row__media" href={href} target={newTab ? '_blank' : undefined} rel={newTab ? 'noopener' : undefined} tabindex="-1" aria-hidden="true">
77
77
  <img src={p.data.heroImage} alt="" loading="lazy" />
78
78
  </a>
79
79
  )}
@@ -81,7 +81,7 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
81
81
  {cats.length > 0 && (
82
82
  <span class="b-post-row__cat">{cats.join(' · ')}</span>
83
83
  )}
84
- <h3 class="b-post-row__title"><a href={href}>{p.data.title}</a></h3>
84
+ <h3 class="b-post-row__title"><a href={href} target={newTab ? '_blank' : undefined} rel={newTab ? 'noopener' : undefined}>{p.data.title}</a></h3>
85
85
  </div>
86
86
  <div class="b-post-row__aside">
87
87
  {i === 0 && isNew(p.data.date) && <span class="b-post-row__new">New</span>}
@@ -94,16 +94,21 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
94
94
  </ul>
95
95
  ) : (
96
96
  <div class="b-latest-posts__grid">
97
- {posts.map((p) => (
98
- <PostCard
99
- href={`/posts/${p.slug}`}
100
- title={p.data.title}
101
- date={p.data.date}
102
- summary={p.data.summary}
103
- heroImage={p.data.heroImage}
104
- tagLabels={p.data.tags}
105
- />
106
- ))}
97
+ {posts.map((p) => {
98
+ const { href, newTab } = postLink(p);
99
+ return (
100
+ <PostCard
101
+ href={href}
102
+ newTab={newTab}
103
+ moreLabel={newTab ? 'Open PDF' : undefined}
104
+ title={p.data.title}
105
+ date={p.data.date}
106
+ summary={p.data.summary}
107
+ heroImage={p.data.heroImage}
108
+ tagLabels={p.data.tags}
109
+ />
110
+ );
111
+ })}
107
112
  </div>
108
113
  )}
109
114
 
@@ -0,0 +1,150 @@
1
+ ---
2
+ /**
3
+ * Locations ("Where to find us") — a Level-2 recipe: one or more places, each a
4
+ * neat tile with a consent-gated map on top and its name / address / directions
5
+ * below. Replaces stacking bare map embeds. The map loads once the visitor accepts
6
+ * the site cookie banner (shared consent — no per-tile gate for anyone who
7
+ * accepted); the per-tile "Show map" button is a fallback that grants the same
8
+ * consent. Token-driven, equal-height tiles. https embed URLs only.
9
+ */
10
+ import Icon from '../Icon.astro';
11
+ import Heading from '../blocks/Heading.astro';
12
+ import { isSafeEmbedUrl } from '../../content/calendar';
13
+
14
+ interface Item {
15
+ name: string;
16
+ address?: string;
17
+ embedUrl: string;
18
+ directionsUrl?: string;
19
+ }
20
+ interface Props {
21
+ title?: string;
22
+ intro?: string;
23
+ columns?: 1 | 2;
24
+ provider?: string;
25
+ items?: Item[];
26
+ }
27
+ const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astro.props;
28
+ ---
29
+
30
+ <section class="b-locations">
31
+ {(title || intro) && (
32
+ <div class="b-locations__head">
33
+ {title && <Heading text={title} level={2} />}
34
+ {intro && <p class="b-locations__intro">{intro}</p>}
35
+ </div>
36
+ )}
37
+ <div class="b-locations__grid" data-cols={columns}>
38
+ {items.map((item) => (
39
+ <article class="b-locations__card">
40
+ <div class="b-locations__map">
41
+ {isSafeEmbedUrl(item.embedUrl) ? (
42
+ <div class="b-loc-map" data-embed-url={item.embedUrl} data-title={`Map — ${item.name}`}>
43
+ <div class="b-loc-map__consent">
44
+ <p class="b-loc-map__text">This map is provided by {provider}.</p>
45
+ <button type="button" class="b-loc-map__load">Show map</button>
46
+ </div>
47
+ <div class="b-loc-map__frame"></div>
48
+ </div>
49
+ ) : (
50
+ <div class="b-loc-map__fallback" aria-hidden="true"><Icon name="map-pin" size={30} /></div>
51
+ )}
52
+ </div>
53
+ <div class="b-locations__body">
54
+ <h3 class="b-locations__name">{item.name}</h3>
55
+ {item.address && <p class="b-locations__address">{item.address}</p>}
56
+ {item.directionsUrl && (
57
+ <a class="b-locations__dir" href={item.directionsUrl} target="_blank" rel="noopener">
58
+ Get directions <span aria-hidden="true">→</span>
59
+ </a>
60
+ )}
61
+ </div>
62
+ </article>
63
+ ))}
64
+ </div>
65
+ </section>
66
+
67
+ <script>
68
+ // Shared site consent: accepting the cookie banner auto-loads every map here.
69
+ import { hasConsent, setConsent, onConsentChange } from '../../lib/consent-client';
70
+
71
+ const maps = document.querySelectorAll<HTMLElement>('.b-loc-map');
72
+ function load(el: HTMLElement) {
73
+ if (el.dataset.loaded === 'true') return;
74
+ const url = el.dataset.embedUrl;
75
+ const slot = el.querySelector<HTMLElement>('.b-loc-map__frame');
76
+ if (!url || !slot) return;
77
+ const frame = document.createElement('iframe');
78
+ frame.src = url;
79
+ frame.title = el.dataset.title || 'Map';
80
+ frame.loading = 'lazy';
81
+ frame.referrerPolicy = 'no-referrer-when-downgrade';
82
+ frame.style.width = '100%';
83
+ frame.style.height = '100%';
84
+ frame.style.border = '0';
85
+ slot.replaceChildren(frame);
86
+ el.dataset.loaded = 'true';
87
+ const consent = el.querySelector<HTMLElement>('.b-loc-map__consent');
88
+ if (consent) consent.hidden = true;
89
+ }
90
+ const loadAll = () => maps.forEach(load);
91
+
92
+ if (hasConsent()) loadAll();
93
+ onConsentChange((allowed) => { if (allowed) loadAll(); });
94
+ maps.forEach((el) => {
95
+ el.querySelector<HTMLButtonElement>('.b-loc-map__load')?.addEventListener('click', () => setConsent(true));
96
+ });
97
+ </script>
98
+
99
+ <style>
100
+ .b-locations {
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 1.75rem;
104
+ max-width: 72rem;
105
+ margin-inline: auto;
106
+ padding-inline: 1rem;
107
+ }
108
+ .b-locations__head { display: flex; flex-direction: column; gap: 0.6rem; }
109
+ .b-locations__intro { margin: 0; color: var(--muted); max-width: var(--max); }
110
+ .b-locations__grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
111
+ @media (min-width: 44em) {
112
+ .b-locations__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
113
+ }
114
+ .b-locations__card {
115
+ display: flex;
116
+ flex-direction: column;
117
+ background: var(--bg-surface);
118
+ border: var(--card-border);
119
+ border-radius: var(--radius);
120
+ box-shadow: var(--shadow-sm);
121
+ overflow: hidden;
122
+ }
123
+ /* Map region — uniform ratio; the consent card fills it until the iframe loads. */
124
+ .b-locations__map { position: relative; aspect-ratio: 4 / 3; background: var(--bg-section); }
125
+ .b-loc-map, .b-loc-map__frame, .b-loc-map__consent, .b-loc-map__fallback { position: absolute; inset: 0; }
126
+ .b-loc-map__frame :global(iframe) { display: block; }
127
+ .b-loc-map__consent {
128
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
129
+ gap: 0.75rem; padding: 1.5rem; text-align: center;
130
+ }
131
+ .b-loc-map__consent[hidden] { display: none; }
132
+ .b-loc-map__text { margin: 0; color: var(--muted); font-size: 0.9rem; max-width: 32ch; }
133
+ .b-loc-map__load {
134
+ font: inherit; font-family: var(--font-heading); font-weight: 600; font-size: 0.9rem;
135
+ padding: 0.5rem 1.1rem; border: none; border-radius: var(--radius-sm);
136
+ background: var(--gold); color: var(--text-light); cursor: pointer;
137
+ transition: filter 0.15s ease;
138
+ }
139
+ .b-loc-map__load:hover { filter: brightness(0.94); }
140
+ .b-loc-map__load:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
141
+ .b-loc-map__fallback { display: flex; align-items: center; justify-content: center; color: var(--muted); }
142
+ .b-locations__body { display: flex; flex-direction: column; gap: 0.4rem; padding: 1.1rem 1.2rem 1.3rem; }
143
+ .b-locations__name { margin: 0; font-size: 1.15rem; }
144
+ .b-locations__address { margin: 0; color: var(--muted); line-height: 1.55; white-space: pre-line; }
145
+ .b-locations__dir {
146
+ margin-top: 0.35rem;
147
+ font-family: var(--font-heading); font-weight: 600; font-size: 0.9rem;
148
+ color: var(--gold);
149
+ }
150
+ </style>
@@ -1,6 +1,13 @@
1
1
  ---
2
2
  /** Stats — a Level-2 recipe: a band of headline numbers over an optional
3
- * heading/intro. Composes the Heading primitive; numbers use the brand accent. */
3
+ * heading/intro. Composes the Heading primitive; numbers use the brand accent.
4
+ *
5
+ * Self-composing: the grid never reserves more columns than there are stats, so
6
+ * a single item centres as a feature instead of stranding an empty half-band
7
+ * (the "£10 pinned to one side" bug); each stat is a token-driven tile (radius/
8
+ * surface/stroke/shadow knobs, like every Card), so it reads as a designed unit
9
+ * at any count and in either theme. Labels are width-capped so a long caption
10
+ * wraps to a tidy block rather than a full-width line. */
4
11
  import Heading from '../blocks/Heading.astro';
5
12
 
6
13
  interface Item { value: string; label: string; description?: string }
@@ -12,6 +19,9 @@ interface Props {
12
19
  items?: Item[];
13
20
  }
14
21
  const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.props;
22
+ // Cap the column count at the item count — one stat should never sit in a
23
+ // two-column grid (half empty). Falls back to 1 for an empty list.
24
+ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
15
25
  ---
16
26
 
17
27
  <section class="b-stats" data-bg={background}>
@@ -22,11 +32,11 @@ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.pr
22
32
  {intro && <p class="b-stats__intro">{intro}</p>}
23
33
  </div>
24
34
  )}
25
- <div class="b-stats__grid" data-cols={columns}>
35
+ <div class="b-stats__grid" data-cols={cols}>
26
36
  {items.map((item) => (
27
37
  <div class="b-stats__item">
28
38
  <span class="b-stats__value">{item.value}</span>
29
- <span class="b-stats__label">{item.label}</span>
39
+ {item.label && <span class="b-stats__label">{item.label}</span>}
30
40
  {item.description && <span class="b-stats__desc">{item.description}</span>}
31
41
  </div>
32
42
  ))}
@@ -58,21 +68,35 @@ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.pr
58
68
  .b-stats__grid {
59
69
  display: grid;
60
70
  grid-template-columns: 1fr;
61
- gap: 1.5rem 2rem;
71
+ gap: 1.25rem;
72
+ width: 100%;
73
+ margin-inline: auto;
62
74
  }
63
75
  @media (min-width: 40em) {
64
- .b-stats__grid { grid-template-columns: repeat(2, 1fr); }
76
+ .b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
77
+ .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
78
+ .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
65
79
  }
66
80
  @media (min-width: 60em) {
67
- .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
68
- .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
81
+ .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
82
+ .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
69
83
  }
84
+ /* A lone stat is a centred feature, not a full-width slab. */
85
+ .b-stats__grid[data-cols='1'] { max-width: 30rem; }
86
+ /* Token-driven tile: the same radius / surface / stroke / shadow knobs as every
87
+ Card, so it stays consistent and theme-correct. On the muted band the tile's
88
+ --bg-surface sits a step above --bg-section, giving depth in both themes. */
70
89
  .b-stats__item {
71
90
  display: flex;
72
91
  flex-direction: column;
73
92
  align-items: center;
74
93
  text-align: center;
75
- gap: 0.35rem;
94
+ gap: 0.5rem;
95
+ padding: clamp(1.5rem, 3vw, 2.25rem) clamp(1.25rem, 3vw, 1.75rem);
96
+ border-radius: var(--radius);
97
+ background: var(--bg-surface);
98
+ border: var(--card-border);
99
+ box-shadow: var(--shadow-md);
76
100
  }
77
101
  .b-stats__value {
78
102
  font-family: var(--font-heading);
@@ -81,8 +105,8 @@ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.pr
81
105
  line-height: 1;
82
106
  color: var(--gold);
83
107
  }
84
- .b-stats__label { font-weight: 600; color: var(--fg); }
85
- .b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; }
108
+ .b-stats__label { font-weight: 600; color: var(--fg); line-height: 1.4; max-width: 30ch; }
109
+ .b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; max-width: 32ch; }
86
110
  </style>
87
111
 
88
112
  <script>
package/content/blocks.ts CHANGED
@@ -480,6 +480,47 @@ export const mediaCardsBlock = z.object({
480
480
  .default([]),
481
481
  });
482
482
 
483
+ /** ContactCard — a branded "get in touch" band: display title + subtitle on the
484
+ * left, contact channels as tappable pills on the right, a note across the bottom. */
485
+ export const contactCardBlock = z.object({
486
+ type: z.literal('contactCard'),
487
+ enabled: z.boolean().default(true),
488
+ eyebrow: z.string().optional(),
489
+ title: z.string(),
490
+ subtitle: z.string().optional(),
491
+ note: z.string().optional(),
492
+ items: z
493
+ .array(
494
+ z.object({
495
+ icon: z.string().optional(),
496
+ value: z.string(),
497
+ href: z.string().optional(),
498
+ })
499
+ )
500
+ .default([]),
501
+ });
502
+
503
+ /** Locations ("Where to find us") — location tiles, each a consent-gated map over
504
+ * a name / address / directions. https embed URLs only (isSafeEmbedUrl). */
505
+ export const locationsBlock = z.object({
506
+ type: z.literal('locations'),
507
+ enabled: z.boolean().default(true),
508
+ title: z.string().optional(),
509
+ intro: z.string().optional(),
510
+ columns: z.union([z.literal(1), z.literal(2)]).default(2),
511
+ provider: z.string().default('Google Maps'),
512
+ items: z
513
+ .array(
514
+ z.object({
515
+ name: z.string(),
516
+ address: z.string().optional(),
517
+ embedUrl: z.string(),
518
+ directionsUrl: z.string().optional(),
519
+ })
520
+ )
521
+ .default([]),
522
+ });
523
+
483
524
  export type RecipeBlock =
484
525
  | z.infer<typeof heroBlock>
485
526
  | z.infer<typeof featureCardsBlock>
@@ -503,7 +544,9 @@ export type RecipeBlock =
503
544
  | z.infer<typeof mapEmbedBlock>
504
545
  | z.infer<typeof tableBlock>
505
546
  | z.infer<typeof noticeBlock>
506
- | z.infer<typeof mediaCardsBlock>;
547
+ | z.infer<typeof mediaCardsBlock>
548
+ | z.infer<typeof contactCardBlock>
549
+ | z.infer<typeof locationsBlock>;
507
550
 
508
551
  /* ── Level-1 layout / container primitives ─────────────────────────────────
509
552
  * These hold child blocks, so the schema is **recursive** (a container's
@@ -612,6 +655,8 @@ export const blockSchema = z.discriminatedUnion('type', [
612
655
  tableBlock,
613
656
  noticeBlock,
614
657
  mediaCardsBlock,
658
+ contactCardBlock,
659
+ locationsBlock,
615
660
  sectionBlock,
616
661
  gridBlock,
617
662
  stackBlock,
package/content/posts.ts CHANGED
@@ -88,6 +88,23 @@ export function tagCounts(posts: Array<{ data: { tags?: string[] } }>): Record<s
88
88
  return counts;
89
89
  }
90
90
 
91
+ /**
92
+ * Where a post card/row should link. Normally the post's own page — but a post
93
+ * that is just an attached file with no body (e.g. a newsletter that's only a PDF)
94
+ * links straight to the file, opened in a new tab, so there's no empty post page
95
+ * in between. Pure; the caller passes the entry's `body` (raw markdown) so
96
+ * "has text" is decided here.
97
+ */
98
+ export function postLink(
99
+ post: { slug: string; body?: string | null; data: { attachment?: string } },
100
+ base = '/posts',
101
+ ): { href: string; newTab: boolean } {
102
+ const attachment = post.data.attachment;
103
+ const hasBody = !!post.body && post.body.trim() !== '';
104
+ if (attachment && !hasBody) return { href: attachment, newTab: true };
105
+ return { href: `${base}/${post.slug}`, newTab: false };
106
+ }
107
+
91
108
  /**
92
109
  * Whether a post date is within the last `days` of `now` — used to flag a "New"
93
110
  * badge in the compact latest-posts list. `days <= 0` disables it; a future or
@@ -126,6 +126,22 @@ export const themeSettingsSchema = z.object({
126
126
  stroke: z.enum(['on', 'off']).default('on'),
127
127
  /** Input treatment — a single site-wide choice (a `data-field` attr on <html>). */
128
128
  fields: z.enum(['boxed', 'filled', 'underline']).default('boxed'),
129
+ /** Dark-mode formula knobs (root-derivation model). `darkDepth` = how light the
130
+ * dark page background sits (OKLCH L); `darkChroma` = the cap on how much colour
131
+ * the dark ground may carry. Both feed the AUTOMATIC dark roots. */
132
+ darkDepth: z.number().min(0.12).max(0.4).default(0.21),
133
+ darkChroma: z.number().min(0).max(0.15).default(0.05),
134
+ /** Explicit overrides for the three dark ROOTS. Each blank ⇒ the root is derived
135
+ * from its light source by the formula; set ⇒ every dark token in that role
136
+ * re-derives from this colour. Roots only (ground / text / accent) — never a
137
+ * lone downstream token — so the palette can never fall out of sync. */
138
+ darkOverrides: z
139
+ .object({
140
+ ground: z.string().default(''),
141
+ text: z.string().default(''),
142
+ accent: z.string().default(''),
143
+ })
144
+ .default({}),
129
145
  /** Raw per-token overrides, layered last. Rarely needed. */
130
146
  advanced: z
131
147
  .object({
package/lib/posts.ts CHANGED
@@ -24,6 +24,10 @@ import {
24
24
  type PostsSettings,
25
25
  } from '../content/posts';
26
26
 
27
+ // Re-export the pure link helper so clients can import it alongside the data
28
+ // helpers (e.g. a custom NewsList) from one place.
29
+ export { postLink } from '../content/posts';
30
+
27
31
  export type PostEntry = CollectionEntry<'posts'>;
28
32
 
29
33
  /** A tag resolved for display: its slug + label/description + how many posts use it. */
@@ -66,29 +66,84 @@ const LIGHT_DERIVATION = [
66
66
  '--news-card-meta:color-mix(in srgb, var(--ink) 72%, var(--surface))',
67
67
  ].join(';');
68
68
 
69
- /** Dark-mode derivations — the OKLCH translation of the same three source vars. */
70
- const DARK_DERIVATION = [
71
- '--fg:oklch(from var(--ink) 0.94 calc(c * 0.3) h)',
72
- '--muted:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
73
- '--accent:oklch(from var(--ink) 0.86 calc(c * 0.3) h)',
74
- '--brown:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
75
- '--bg:oklch(from var(--brand) 0.16 0.015 h)',
76
- '--bg-surface:oklch(from var(--brand) 0.21 0.02 h)',
77
- '--bg-section:oklch(from var(--brand) 0.19 0.018 h)',
78
- '--bg-tag:oklch(from var(--brand) 0.14 0.015 h)',
79
- '--dark-bg:oklch(from var(--brand) 0.12 0.015 h)',
80
- '--border:oklch(from var(--brand) 0.32 0.02 h)',
81
- '--gold:oklch(from var(--brand) 0.78 calc(c * 0.9) h)',
82
- '--gold-light:oklch(from var(--brand) 0.30 calc(c * 0.55) h)',
83
- '--accent-2:oklch(from var(--brand) 0.70 calc(c * 0.9) calc(h + 26))',
84
- '--text-footer:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
85
- '--text-footer-link:oklch(from var(--brand) 0.78 calc(c * 0.9) h)',
86
- '--text-copyright:oklch(from var(--ink) 0.55 calc(c * 0.3) h)',
87
- '--text-subtle:oklch(from var(--ink) 0.55 calc(c * 0.3) h)',
88
- '--divider-dark:oklch(from var(--brand) 0.32 0.02 h)',
89
- '--news-card-meta:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
69
+ /**
70
+ * Dark mode uses a ROOT-DERIVATION model that mirrors light's three sources, so an
71
+ * editor can only ever change coherent roots — never a lone downstream token that
72
+ * would then clash with the rest.
73
+ *
74
+ * Three dark ROOTS, one per role:
75
+ * --dark-ground (surfaces) surface, chroma BOOSTED as it darkens so a pale
76
+ * tint survives instead of collapsing to grey/black;
77
+ * lightness = --dark-bg-l (the depth knob).
78
+ * --dark-text (text) ink, lightened for a dark ground.
79
+ * --dark-accent (accents) ← brand, brightened for a dark ground.
80
+ * Each defaults to this formula but can be OVERRIDDEN with an explicit colour (see
81
+ * buildThemeStyle); either way EVERY dark token below derives from the three roots,
82
+ * so the palette stays internally consistent whether automatic or hand-set.
83
+ */
84
+ const DARK_ROOTS_AUTO = [
85
+ '--dark-ground:oklch(from var(--surface) var(--dark-bg-l) clamp(0, calc(c * 3.5), var(--dark-chroma-cap)) h)',
86
+ '--dark-text:oklch(from var(--ink) 0.94 calc(c * 0.3) h)',
87
+ '--dark-accent:oklch(from var(--brand) 0.78 calc(c * 0.9) h)',
90
88
  ].join(';');
91
89
 
90
+ /** Every working dark token, derived from the three roots (offsets in OKLCH L). */
91
+ const DARK_DOWNSTREAM = [
92
+ // Grounds ← --dark-ground
93
+ '--bg:var(--dark-ground)',
94
+ '--bg-surface:oklch(from var(--dark-ground) calc(l + 0.05) c h)',
95
+ '--bg-section:oklch(from var(--dark-ground) calc(l + 0.025) c h)',
96
+ '--bg-tag:oklch(from var(--dark-ground) calc(l - 0.03) c h)',
97
+ '--dark-bg:oklch(from var(--dark-ground) calc(l - 0.05) c h)',
98
+ '--border:oklch(from var(--dark-ground) calc(l + 0.19) c h)',
99
+ '--divider-dark:oklch(from var(--dark-ground) calc(l + 0.19) c h)',
100
+ // Text ← --dark-text
101
+ '--fg:var(--dark-text)',
102
+ '--muted:oklch(from var(--dark-text) calc(l - 0.20) c h)',
103
+ '--accent:oklch(from var(--dark-text) calc(l - 0.08) c h)',
104
+ '--brown:oklch(from var(--dark-text) calc(l - 0.20) c h)',
105
+ '--text-footer:oklch(from var(--dark-text) calc(l - 0.20) c h)',
106
+ '--text-copyright:oklch(from var(--dark-text) calc(l - 0.39) c h)',
107
+ '--text-subtle:oklch(from var(--dark-text) calc(l - 0.39) c h)',
108
+ '--news-card-meta:oklch(from var(--dark-text) calc(l - 0.20) c h)',
109
+ // Accents ← --dark-accent
110
+ '--gold:var(--dark-accent)',
111
+ '--gold-light:oklch(from var(--dark-accent) calc(l - 0.46) calc(c * 0.6) h)',
112
+ '--accent-2:oklch(from var(--dark-accent) calc(l - 0.08) c calc(h + 26))',
113
+ '--text-footer-link:var(--dark-accent)',
114
+ ].join(';');
115
+
116
+ /** Defaults for the dark-formula knobs (also the CMS/schema defaults). */
117
+ export const DARK_DEFAULTS = { depth: 0.21, chroma: 0.05 } as const;
118
+
119
+ /** Clamp a numeric knob to a range, falling back to `fallback` when absent/NaN. */
120
+ function clampNum(v: unknown, min: number, max: number, fallback: number): number {
121
+ const n = typeof v === 'number' && Number.isFinite(v) ? v : fallback;
122
+ return Math.min(max, Math.max(min, n));
123
+ }
124
+
125
+ /**
126
+ * Compose the dark block: the depth/chroma knobs, the three auto roots, any
127
+ * explicit root overrides (which win over the auto value), then the downstream
128
+ * derivations that read whichever root value ended up winning.
129
+ */
130
+ function darkDeclarations(theme: ThemeSettings): string {
131
+ const depth = clampNum(theme.darkDepth, 0.12, 0.4, DARK_DEFAULTS.depth);
132
+ const chroma = clampNum(theme.darkChroma, 0, 0.15, DARK_DEFAULTS.chroma);
133
+ const ov = theme.darkOverrides ?? {};
134
+ const overrides: string[] = [];
135
+ if (clean(ov.ground)) overrides.push(`--dark-ground:${clean(ov.ground)}`);
136
+ if (clean(ov.text)) overrides.push(`--dark-text:${clean(ov.text)}`);
137
+ if (clean(ov.accent)) overrides.push(`--dark-accent:${clean(ov.accent)}`);
138
+ return [
139
+ `--dark-bg-l:${depth}`,
140
+ `--dark-chroma-cap:${chroma}`,
141
+ DARK_ROOTS_AUTO,
142
+ ...overrides,
143
+ DARK_DOWNSTREAM,
144
+ ].join(';');
145
+ }
146
+
92
147
  /** Drop a value that could break out of a CSS declaration. The source is trusted
93
148
  * repo JSON, but this keeps a stray value honest (defence in depth). */
94
149
  function clean(v: unknown): string {
@@ -151,7 +206,7 @@ export function buildThemeStyle(theme: ThemeSettings): ThemeStyle {
151
206
  const css = [
152
207
  rootDecls.length ? `:root:root{${rootDecls.join(';')}}` : '',
153
208
  themed ? `:root:root:not([data-theme="dark"]){${LIGHT_DERIVATION}}` : '',
154
- themed ? `:root:root[data-theme="dark"]{${DARK_DERIVATION}}` : '',
209
+ themed ? `:root:root[data-theme="dark"]{${darkDeclarations(theme)}}` : '',
155
210
  rawBlock(':root:root', theme.advanced?.light),
156
211
  rawBlock(':root:root[data-theme="dark"]', theme.advanced?.dark),
157
212
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",