ferst-core 0.7.13 → 0.8.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.
@@ -1,498 +1,515 @@
1
- ---
2
- /**
3
- * Shared page chrome. After the refactor this file is a thin wrapper:
4
- * <head>, the early-paint theme script, and three composed components —
5
- * <SiteHeader />, <SiteFooter />, <CookiePreferences />. Everything brand-specific
6
- * (brand name, navbar links, footer links, the Facebook URL, the
7
- * contact email) is read from the `siteSettings` collection so a single
8
- * JSON edit propagates to every page that uses this layout.
9
- *
10
- * No general "essential cookies" notice: this site sets no cookies of its
11
- * own (see /cookie-policy/), so there's nothing to ask consent for — a
12
- * one-button "Got it" banner would be pure FYI, not a real decision, and
13
- * it duplicated the one place there IS a real decision (the Google
14
- * Calendar embed's own consent gate, /calendar/). <CookiePreferences />
15
- * — opened from the footer — is that one real choice.
16
- *
17
- * Pages opt in to the secondary-logo footer block with `<Base showSecondaryLogo>`
18
- * instead of importing `<SecondaryLogo />` themselves — fewer imports,
19
- * fewer placement decisions, same visual outcome.
20
- */
21
- import '../styles/theme.css';
22
- import '../styles/fonts.css';
23
-
24
- import SiteHeader from '../components/SiteHeader.astro';
25
- import SiteFooter from '../components/SiteFooter.astro';
26
- import CookiePreferences from '../components/CookiePreferences.astro';
27
- import CookieBanner from '../components/CookieBanner.astro';
28
- import StickyCallout from '../components/StickyCallout.astro';
29
- import SecondaryLogo from '../components/SecondaryLogo.astro';
30
- import { loadSiteSettings } from '../lib/siteSettings';
31
- import { getCalloutSettings } from '../lib/callout';
32
- import { buildThemeStyle } from '../lib/themeTokens';
33
-
34
- interface Props {
35
- title: string;
36
- description?: string;
37
- heroImage?: string;
38
- headerClass?: string;
39
- mainClass?: string;
40
- footerClass?: string;
41
- /** Render the secondary logo centred at the bottom of the page content. */
42
- showSecondaryLogo?: boolean;
43
- /** Template-level content alignment — cascades to text-bearing blocks via
44
- * `data-content-align` on <main>. `start` (default) keeps long-form readable. */
45
- align?: 'start' | 'center' | 'end';
46
- }
47
-
48
- const {
49
- title,
50
- description,
51
- heroImage,
52
- headerClass,
53
- mainClass,
54
- footerClass,
55
- showSecondaryLogo = false,
56
- align = 'start',
57
- } = Astro.props;
58
-
59
- const settings = await loadSiteSettings();
60
- const { identity, contact, navigation, theme } = settings;
61
-
62
- // Site-level corner launcher (chrome) — off unless a client configures it.
63
- const callout = await getCalloutSettings();
64
-
65
- const path = Astro.url.pathname;
66
-
67
- // The brand → an inline <style> that layers over the core's neutral defaults
68
- // (styles/theme.css). `buildThemeStyle` derives ~40 working tokens from the
69
- // three source colours (light color-mix + dark OKLCH) and emits the font/knob
70
- // overrides too; selectors are doubled (`:root:root`) so they win by
71
- // specificity regardless of stylesheet order. `themeField` is the site-wide
72
- // input treatment, applied as a `data-field` attribute on <html>.
73
- const { css: themeCss, field: themeField } = buildThemeStyle(theme);
74
-
75
- // Logos are optional. When a client hasn't configured one, SiteHeader renders a
76
- // text wordmark (the site name) rather than pointing <img> at a baked-in path
77
- // that may not exist — a broken logo by default was the old bug. Dark falls back
78
- // to the light variant when only one is supplied.
79
- const primaryLogoLight = settings.primaryLogoLight;
80
- const primaryLogoDark = settings.primaryLogoDark ?? settings.primaryLogoLight;
81
- const secondaryLogoLight = settings.secondaryLogoLight;
82
- const secondaryLogoDark = settings.secondaryLogoDark;
83
- const secondaryLogoLink = settings.secondaryLogoLink;
84
- const brandAlt = [identity.shortName, identity.location].filter(Boolean).join(', ');
85
- const metaDescription = description ?? identity.description;
86
- ---
87
-
88
- <!doctype html>
89
- <html lang="en-GB" data-field={themeField}>
90
- <head>
91
- <meta charset="utf-8" />
92
- <meta name="viewport" content="width=device-width, initial-scale=1" />
93
- <slot name="head" />
94
- <meta name="description" content={metaDescription} />
95
- <title>{title} — {identity.siteTitle}</title>
96
- {themeCss && <style is:inline set:html={themeCss} />}
97
- <!-- Self-hosted (see src/styles/fonts.css) — h1 (Poppins 700) and body copy
98
- (Inter 400) are what virtually every page needs at first paint. -->
99
- <link rel="preload" as="font" type="font/woff2" href="/fonts/poppins-latin-700-normal.woff2" crossorigin />
100
- <link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin-400-normal.woff2" crossorigin />
101
- {heroImage && <link rel="preload" as="image" href={heroImage} />}
102
- <!--
103
- Early-paint theme detection: localStorage override → system preference.
104
- Must be inline + run before <body> paints, otherwise dark-mode users see
105
- a light-mode flash. Also preloads the correct brand logo for that theme.
106
- -->
107
- <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
108
- (function () {
109
- var source = localStorage.getItem('theme-source');
110
- var saved = localStorage.getItem('theme');
111
- var t;
112
- if (source === 'user' && (saved === 'light' || saved === 'dark')) {
113
- t = saved;
114
- } else {
115
- t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
116
- }
117
- document.documentElement.dataset.theme = t;
118
- var logoHref = t === 'dark' ? primaryLogoDark : primaryLogoLight;
119
- if (logoHref) {
120
- var link = document.createElement('link');
121
- link.rel = 'preload';
122
- link.as = 'image';
123
- link.href = logoHref;
124
- document.head.appendChild(link);
125
- }
126
- })();
127
- </script>
128
- <script>
129
- // Pages that opt into Astro's <ClientRouter /> (currently /posts/)
130
- // swap <html>'s attributes to match the newly-fetched document by
131
- // default. Since dark/light mode is only ever set client-side above
132
- // (never baked into the server-rendered HTML, which doesn't know the
133
- // visitor's localStorage), that wipe resets data-theme on every
134
- // transition — the whole page flips back to light mode after every
135
- // filter click. Carry the current theme over before the swap applies.
136
- import type { TransitionBeforeSwapEvent } from 'astro:transitions/client';
137
-
138
- document.addEventListener('astro:before-swap', ((event: TransitionBeforeSwapEvent) => {
139
- const theme = document.documentElement.dataset.theme;
140
- if (theme) {
141
- event.newDocument.documentElement.dataset.theme = theme;
142
- }
143
- }) as EventListener);
144
- </script>
145
- <script>
146
- // Open external links in a new tab — site-wide and future-proof. Any <a>
147
- // pointing to a DIFFERENT origin over http(s) gets target=_blank +
148
- // rel="noopener noreferrer"; mailto:/tel:/anchors and same-site links are
149
- // left untouched. Runs on load and after every view-transition navigation,
150
- // so it covers every component AND markdown-authored links (e.g. FAQ
151
- // answers, prose) without each one having to opt in. Idempotent, so
152
- // components that already set target/rel are unaffected.
153
- function ferstMarkExternalLinks() {
154
- const host = location.host;
155
- document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
156
- let url: URL;
157
- try {
158
- url = new URL(a.href, location.href);
159
- } catch {
160
- return;
161
- }
162
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return; // skip mailto/tel/etc.
163
- // Same-site pages stay in the tab; a same-site document (a PDF leaflet, a form)
164
- // opens in a new one, like an external link.
165
- if (url.host === host && !/\.(?:pdf|docx?|xlsx?|pptx?)$/i.test(url.pathname)) return;
166
- if (!a.target) a.target = '_blank';
167
- const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
168
- rel.add('noopener');
169
- rel.add('noreferrer');
170
- a.setAttribute('rel', Array.from(rel).join(' '));
171
- });
172
- }
173
- ferstMarkExternalLinks();
174
- document.addEventListener('astro:page-load', ferstMarkExternalLinks);
175
- </script>
176
- <script>
177
- // Live theme preview: only when this page is FRAMED and either loaded with
178
- // ?preview=1 OR already in a preview session (so the draft survives clicking
179
- // links inside the frame, which drop the query). Listens for draft theme
180
- // settings over postMessage and re-themes live. Dynamically imported so the
181
- // code never ships to normal visitors; never runs unframed.
182
- try {
183
- var inPreview =
184
- new URLSearchParams(location.search).has('preview') ||
185
- sessionStorage.getItem('ferst-preview-on') === '1';
186
- if (window.parent !== window && inPreview) {
187
- import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
188
- }
189
- } catch (_) {
190
- /* sessionStorage blocked — fall back to the ?preview query only */
191
- if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
192
- import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
193
- }
194
- }
195
- </script>
196
- </head>
197
- <body>
198
- <SiteHeader
199
- path={path}
200
- headerClass={headerClass}
201
- primaryLogoLight={primaryLogoLight}
202
- primaryLogoDark={primaryLogoDark}
203
- brandAlt={brandAlt}
204
- primaryNav={navigation.primary}
205
- dropdown={navigation.dropdown}
206
- cta={navigation.cta}
207
- />
208
-
209
- <main class:list={['site-main', mainClass]} data-content-align={align}>
210
- <slot name="hero" />
211
- <slot />
212
- {showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
213
- <SecondaryLogo logoLight={secondaryLogoLight} logoDark={secondaryLogoDark} link={secondaryLogoLink} />
214
- )}
215
- </main>
216
-
217
- <SiteFooter
218
- facebookUrl={contact.facebookUrl}
219
- footerLinks={navigation.footer}
220
- groups={navigation.footerGroups}
221
- cta={navigation.cta}
222
- footerClass={footerClass}
223
- shortName={identity.shortName}
224
- tagline={identity.description}
225
- />
226
-
227
- <CookiePreferences />
228
- <CookieBanner />
229
-
230
- <StickyCallout {...callout} />
231
-
232
- <style is:global>
233
- /* ── Global resets & typography ─────────────────────────────────────
234
- Anything that affects every page's body / typography / article
235
- content lives here. Header, footer, and cookie banner each own
236
- their own <style is:global> in their component file. Theme tokens
237
- (`:root`, `[data-theme=dark]`) come from styles/theme.css and are
238
- overridable per-client via the themeSettings collection. Every
239
- colour below reads a token, so light/dark and any client palette
240
- all flow from the same variables — no per-mode hex overrides here.
241
- */
242
- * { box-sizing: border-box; }
243
- html, body { margin: 0; padding: 0; }
244
- html {
245
- height: 100%;
246
- overflow-x: clip;
247
- overscroll-behavior-x: none;
248
- }
249
- body {
250
- font-family: var(--font-body);
251
- color: var(--fg);
252
- background: var(--bg);
253
- line-height: 1.6;
254
- min-height: 100%;
255
- display: flex;
256
- flex-direction: column;
257
- overflow-x: clip;
258
- overscroll-behavior-x: none;
259
- }
260
- h1, h2, h3, h4 {
261
- font-family: var(--font-display);
262
- }
263
- h1 {
264
- font-family: var(--font-heading);
265
- color: var(--fg);
266
- font-size: clamp(2rem, 0.95rem + 4.5vw, 3.35rem);
267
- font-weight: 700;
268
- line-height: 1.34;
269
- letter-spacing: -0.01em;
270
- margin: 0;
271
- }
272
- h2 {
273
- font-family: var(--font-display);
274
- color: var(--fg);
275
- font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
276
- line-height: 1.3;
277
- margin: 0;
278
- }
279
- h3 {
280
- font-family: var(--font-heading);
281
- color: var(--fg);
282
- font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
283
- font-weight: 600;
284
- line-height: 1.35;
285
- margin: 0;
286
- }
287
- h4 {
288
- font-family: var(--font-heading);
289
- color: var(--accent);
290
- font-size: clamp(0.6875rem, 0.9vw, 0.75rem);
291
- font-weight: 500;
292
- letter-spacing: 0.2em;
293
- text-transform: uppercase;
294
- margin: 0;
295
- }
296
-
297
- p, li {
298
- font-family: var(--font-body);
299
- color: var(--muted);
300
- font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
301
- font-weight: 400;
302
- line-height: 1.65;
303
- margin: 0;
304
- }
305
-
306
- time {
307
- color: var(--muted);
308
- font-size: clamp(0.7rem, 0.62rem + 0.25vw, 0.8rem);
309
- letter-spacing: 0.04em;
310
- }
311
-
312
- /* ── Typography utility classes (mobile-first, fluid via clamp).
313
- Apply explicitly to elements: .t-h1/.t-h2/.t-h3 for headings,
314
- .t-body for body copy, .t-lead for intro paragraphs, .t-meta for small/meta text.
315
- Mobile baseline matches iOS Human Interface Guidelines (Dynamic Type, "Large").
316
- Large Title 34 / Title1 28 / Title2 22 / Title3 ~22px mobile → max 26px / Body 17 / Footnote 13. */
317
- .t-h1 {
318
- font-size: clamp(2.125rem, 1.6rem + 1.75vw, 3rem);
319
- line-height: 1.2;
320
- }
321
- .t-h2 {
322
- font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
323
- line-height: 1.3;
324
- margin: 0;
325
- }
326
- .t-h3 {
327
- font-size: var(--type-h3-size);
328
- line-height: 1.35;
329
- margin: 0;
330
- }
331
- .t-body {
332
- font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
333
- line-height: 1.65;
334
- }
335
- .t-lead {
336
- font-size: clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem);
337
- line-height: 1.7;
338
- }
339
- .t-meta {
340
- font-size: clamp(0.8125rem, 0.76rem + 0.18vw, 0.9375rem);
341
- line-height: 1.5;
342
- }
343
-
344
- /* Reveal-on-scroll utility — paired with the `armReveal()` helper in
345
- src/utils/scrollReveal.ts. An element only gets hidden once JS adds
346
- .js-reveal, so it stays fully visible with no JS or under
347
- prefers-reduced-motion. Global (not page-scoped) since it's shared
348
- by the homepage and the /posts/ listing. */
349
- .js-reveal {
350
- opacity: 0;
351
- transform: translateY(16px);
352
- transition: opacity 0.6s ease, transform 0.6s ease;
353
- }
354
- .js-reveal.is-visible {
355
- opacity: 1;
356
- transform: none;
357
- }
358
- /* Optimal reading measure for long-form article content. Below this
359
- breakpoint the viewport itself is already narrower than 65ch, so
360
- the cap only needs to do work on wider (tablet/desktop) screens. */
361
- article :where(p, ul, ol, blockquote, h2, h3, h4) {
362
- max-width: 65ch;
363
- }
364
- @media (max-width: 47.9375em) {
365
- article :where(p, ul, ol, blockquote, h2, h3, h4) {
366
- max-width: none;
367
- }
368
- }
369
- article :where(img, picture, video, table, figure, iframe) {
370
- max-width: 100%;
371
- }
372
- /* Prose images — editor/markdown images dropped into long-form body content
373
- get a centred block treatment (rounded, soft shadow). Scoped to the real
374
- prose containers (post body, Prose block) so it never leaks onto STRUCTURAL
375
- component images — cards, banners, media headers — which own their own
376
- framing and crop absolutely. (A leaked `margin` pushes an inset image down
377
- inside its overflow-hidden box, exposing a strip above it — the "empty
378
- space above the image" a bare `article img` rule caused.) */
379
- .b-post__body img,
380
- .b-prose img {
381
- display: block;
382
- margin: 1.5rem auto;
383
- border-radius: 8px;
384
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
385
- }
386
- @media (min-width: 44em) {
387
- .b-post__body img,
388
- .b-prose img {
389
- border-radius: 12px;
390
- }
391
- }
392
- a {
393
- font-family: var(--font-body);
394
- font-size: clamp(0.875rem, 0.82rem + 0.25vw, 1rem);
395
- font-weight: 600;
396
- color: var(--accent);
397
- text-decoration: none;
398
- transition: color 0.2s ease;
399
- }
400
- h1 a, h2 a, h3 a, h4 a {
401
- font-size: inherit;
402
- font-family: inherit;
403
- font-weight: inherit;
404
- line-height: inherit;
405
- }
406
-
407
- /* ── Fluid media: never overflow container ── */
408
- /* The logo is exempt: it carries a fixed height in SiteHeader that `height:
409
- auto` would collapse. Card/media images set their own size at higher
410
- specificity, so this only ever backstops loose images. */
411
- img:not(.primary-logo), picture, video {
412
- max-width: 100%;
413
- height: auto;
414
- }
415
-
416
- .site-main {
417
- flex: 1;
418
- min-width: 0;
419
- width: min(100%, 72rem);
420
- max-width: 72rem;
421
- margin-inline: auto;
422
- padding-inline: clamp(1rem, 3vw, 2.5rem);
423
- }
424
- .site-main {
425
- display: flex;
426
- flex-direction: column;
427
- gap: clamp(2rem, 4vw, 3rem);
428
- padding-block: clamp(3rem, 6vw, 5rem);
429
- }
430
- .site-main > * {
431
- margin-block: 0;
432
- }
433
- /* A full-bleed opener (a full-screen hero, a full-bleed banner) as the page's first
434
- block sits flush under the header, not below the main's top padding. */
435
- .site-main:has(> .b-hero[data-size='screen']:first-child),
436
- .site-main:has(> .b-hero[data-full-bleed='true']:first-child),
437
- .site-main:has(> .b-banner[data-full-bleed='true']:first-child) {
438
- padding-block-start: 0;
439
- }
440
-
441
- /* ── Content alignment cascade ──────────────────────────────────────
442
- A page sets its default alignment via the `align` prop (→
443
- data-content-align on <main>); a `section` block can override it for
444
- its own subtree. Text-bearing blocks inherit `text-align` from here, so
445
- a whole template stays on one alignment axis instead of each recipe
446
- hard-coding its own. A few blocks adjust when centred so the result
447
- reads as deliberately centred, not centred-inside-a-left-column.
448
- `start` (left) is the readable default for long-form; a heading can
449
- still override per-block via its own `align`. */
450
- /* The same choice for flex-column layouts (cards), where text-align no longer places
451
- children, so an icon, a pill or a button follows the words around it:
452
- --content-items for a card's children (start keeps today's stretch), and
453
- --content-self for an item that keeps its own width, like a pill. */
454
- [data-content-align='start'] { text-align: start; --content-items: stretch; --content-self: flex-start; }
455
- [data-content-align='center'] { text-align: center; --content-items: center; --content-self: center; }
456
- [data-content-align='end'] { text-align: end; --content-items: flex-end; --content-self: flex-end; }
457
- /* A block's own `align` (BlockRenderer) sets the cascade through a wrapper that adds no box. */
458
- .block-align { display: contents; }
459
- /* A recipe's title + intro column (data-recipe-head) sits where its alignment says:
460
- centred by default, against the start or end edge when re-aligned. (:root lifts
461
- these above each recipe's own scoped margin.) */
462
- :root [data-recipe-head][data-content-align='start'] { margin-inline: 0 auto; }
463
- :root [data-recipe-head][data-content-align='end'] { margin-inline: auto 0; }
464
- /* Each content block owns how it ADJUSTS when centred/ended (re-centring
465
- its reading column, dropping a left rule, etc.) in its own component,
466
- so the rule out-specifies the block's own scoped styles. Here we only
467
- set the inherited text-align axis. */
468
-
469
- /* ── Secondary logo block: centered ── */
470
- .secondary-logo {
471
- display: flex;
472
- flex-direction: column;
473
- align-items: center;
474
- justify-content: center;
475
- width: 100%;
476
- box-sizing: border-box;
477
- }
478
- .secondary-logo__img {
479
- display: block;
480
- max-width: min(150px, 100%);
481
- height: auto;
482
- }
483
- html:not([data-theme='dark']) .secondary-logo__img {
484
- opacity: 0.85;
485
- mix-blend-mode: multiply;
486
- }
487
- html[data-theme='dark'] .secondary-logo__img {
488
- mix-blend-mode: normal;
489
- opacity: 1;
490
- filter: var(--secondary-logo-filter);
491
- }
492
-
493
- @media (max-width: 22.5em) {
494
- .site-main { padding-inline: 1rem; }
495
- }
496
- </style>
497
- </body>
498
- </html>
1
+ ---
2
+ /**
3
+ * Shared page chrome. After the refactor this file is a thin wrapper:
4
+ * <head>, the early-paint theme script, and three composed components —
5
+ * <SiteHeader />, <SiteFooter />, <CookiePreferences />. Everything brand-specific
6
+ * (brand name, navbar links, footer links, the Facebook URL, the
7
+ * contact email) is read from the `siteSettings` collection so a single
8
+ * JSON edit propagates to every page that uses this layout.
9
+ *
10
+ * No general "essential cookies" notice: this site sets no cookies of its
11
+ * own (see /cookie-policy/), so there's nothing to ask consent for — a
12
+ * one-button "Got it" banner would be pure FYI, not a real decision, and
13
+ * it duplicated the one place there IS a real decision (the Google
14
+ * Calendar embed's own consent gate, /calendar/). <CookiePreferences />
15
+ * — opened from the footer — is that one real choice.
16
+ *
17
+ * Pages opt in to the secondary-logo footer block with `<Base showSecondaryLogo>`
18
+ * instead of importing `<SecondaryLogo />` themselves — fewer imports,
19
+ * fewer placement decisions, same visual outcome.
20
+ */
21
+ import '../styles/theme.css';
22
+ import '../styles/fonts.css';
23
+
24
+ import SiteHeader from '../components/SiteHeader.astro';
25
+ import SiteFooter from '../components/SiteFooter.astro';
26
+ import CookiePreferences from '../components/CookiePreferences.astro';
27
+ import CookieBanner from '../components/CookieBanner.astro';
28
+ import StickyCallout from '../components/StickyCallout.astro';
29
+ import SecondaryLogo from '../components/SecondaryLogo.astro';
30
+ import { loadSiteSettings } from '../lib/siteSettings';
31
+ import { getCalloutSettings } from '../lib/callout';
32
+ import { buildThemeStyle } from '../lib/themeTokens';
33
+ import { canonicalUrl } from '../lib/seo.mjs';
34
+
35
+ interface Props {
36
+ title: string;
37
+ description?: string;
38
+ heroImage?: string;
39
+ headerClass?: string;
40
+ mainClass?: string;
41
+ footerClass?: string;
42
+ /** Render the secondary logo centred at the bottom of the page content. */
43
+ showSecondaryLogo?: boolean;
44
+ /** Template-level content alignment — cascades to text-bearing blocks via
45
+ * `data-content-align` on <main>. `start` (default) keeps long-form readable. */
46
+ align?: 'start' | 'center' | 'end';
47
+ /** False for a page with no address of its own (the "page not found" page): it gets no
48
+ * canonical address and asks search engines not to index it. */
49
+ indexable?: boolean;
50
+ }
51
+
52
+ const {
53
+ title,
54
+ description,
55
+ heroImage,
56
+ headerClass,
57
+ mainClass,
58
+ footerClass,
59
+ showSecondaryLogo = false,
60
+ align = 'start',
61
+ indexable = true,
62
+ } = Astro.props;
63
+
64
+ const settings = await loadSiteSettings();
65
+ const { identity, contact, navigation, theme } = settings;
66
+
67
+ // Site-level corner launcher (chrome) — off unless a client configures it.
68
+ const callout = await getCalloutSettings();
69
+
70
+ const path = Astro.url.pathname;
71
+
72
+ // The brand → an inline <style> that layers over the core's neutral defaults
73
+ // (styles/theme.css). `buildThemeStyle` derives ~40 working tokens from the
74
+ // three source colours (light color-mix + dark OKLCH) and emits the font/knob
75
+ // overrides too; selectors are doubled (`:root:root`) so they win by
76
+ // specificity regardless of stylesheet order. `themeField` is the site-wide
77
+ // input treatment, applied as a `data-field` attribute on <html>.
78
+ const { css: themeCss, field: themeField } = buildThemeStyle(theme);
79
+
80
+ // Logos are optional. When a client hasn't configured one, SiteHeader renders a
81
+ // text wordmark (the site name) rather than pointing <img> at a baked-in path
82
+ // that may not exist — a broken logo by default was the old bug. Dark falls back
83
+ // to the light variant when only one is supplied.
84
+ const primaryLogoLight = settings.primaryLogoLight;
85
+ const primaryLogoDark = settings.primaryLogoDark ?? settings.primaryLogoLight;
86
+ const secondaryLogoLight = settings.secondaryLogoLight;
87
+ const secondaryLogoDark = settings.secondaryLogoDark;
88
+ const secondaryLogoLink = settings.secondaryLogoLink;
89
+ const brandAlt = [identity.shortName, identity.location].filter(Boolean).join(', ');
90
+ const metaDescription = description ?? identity.description;
91
+
92
+ // Search signals (lib/seo.mjs, set by the ferst() integration): one canonical address per
93
+ // page once the site has an address, and noindex on a test copy (the integration also sends
94
+ // an X-Robots-Tag header there, which covers files such as PDFs too).
95
+ const siteUrl = import.meta.env.FERST_SITE_URL || '';
96
+ const canonical = siteUrl && indexable ? canonicalUrl(siteUrl, path) : null;
97
+ const noindex = !indexable || import.meta.env.FERST_SEO_PRODUCTION === false;
98
+ ---
99
+
100
+ <!doctype html>
101
+ <html lang="en-GB" data-field={themeField}>
102
+ <head>
103
+ <meta charset="utf-8" />
104
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
105
+ <slot name="head" />
106
+ <meta name="description" content={metaDescription} />
107
+ {canonical && <link rel="canonical" href={canonical} />}
108
+ {noindex && <meta name="robots" content="noindex" />}
109
+ <title>{title} — {identity.siteTitle}</title>
110
+ <!-- The browser icon, written into every build by the ferst() integration (lib/site-icon). -->
111
+ <link rel="icon" href="/favicon.ico" sizes="32x32 48x48" />
112
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
113
+ {themeCss && <style is:inline set:html={themeCss} />}
114
+ <!-- Self-hosted (see src/styles/fonts.css) — h1 (Poppins 700) and body copy
115
+ (Inter 400) are what virtually every page needs at first paint. -->
116
+ <link rel="preload" as="font" type="font/woff2" href="/fonts/poppins-latin-700-normal.woff2" crossorigin />
117
+ <link rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin-400-normal.woff2" crossorigin />
118
+ {heroImage && <link rel="preload" as="image" href={heroImage} />}
119
+ <!--
120
+ Early-paint theme detection: localStorage override → system preference.
121
+ Must be inline + run before <body> paints, otherwise dark-mode users see
122
+ a light-mode flash. Also preloads the correct brand logo for that theme.
123
+ -->
124
+ <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
125
+ (function () {
126
+ var source = localStorage.getItem('theme-source');
127
+ var saved = localStorage.getItem('theme');
128
+ var t;
129
+ if (source === 'user' && (saved === 'light' || saved === 'dark')) {
130
+ t = saved;
131
+ } else {
132
+ t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
133
+ }
134
+ document.documentElement.dataset.theme = t;
135
+ var logoHref = t === 'dark' ? primaryLogoDark : primaryLogoLight;
136
+ if (logoHref) {
137
+ var link = document.createElement('link');
138
+ link.rel = 'preload';
139
+ link.as = 'image';
140
+ link.href = logoHref;
141
+ document.head.appendChild(link);
142
+ }
143
+ })();
144
+ </script>
145
+ <script>
146
+ // Pages that opt into Astro's <ClientRouter /> (currently /posts/)
147
+ // swap <html>'s attributes to match the newly-fetched document by
148
+ // default. Since dark/light mode is only ever set client-side above
149
+ // (never baked into the server-rendered HTML, which doesn't know the
150
+ // visitor's localStorage), that wipe resets data-theme on every
151
+ // transition — the whole page flips back to light mode after every
152
+ // filter click. Carry the current theme over before the swap applies.
153
+ import type { TransitionBeforeSwapEvent } from 'astro:transitions/client';
154
+
155
+ document.addEventListener('astro:before-swap', ((event: TransitionBeforeSwapEvent) => {
156
+ const theme = document.documentElement.dataset.theme;
157
+ if (theme) {
158
+ event.newDocument.documentElement.dataset.theme = theme;
159
+ }
160
+ }) as EventListener);
161
+ </script>
162
+ <script>
163
+ // Open external links in a new tab — site-wide and future-proof. Any <a>
164
+ // pointing to a DIFFERENT origin over http(s) gets target=_blank +
165
+ // rel="noopener noreferrer"; mailto:/tel:/anchors and same-site links are
166
+ // left untouched. Runs on load and after every view-transition navigation,
167
+ // so it covers every component AND markdown-authored links (e.g. FAQ
168
+ // answers, prose) without each one having to opt in. Idempotent, so
169
+ // components that already set target/rel are unaffected.
170
+ function ferstMarkExternalLinks() {
171
+ const host = location.host;
172
+ document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
173
+ let url: URL;
174
+ try {
175
+ url = new URL(a.href, location.href);
176
+ } catch {
177
+ return;
178
+ }
179
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return; // skip mailto/tel/etc.
180
+ // Same-site pages stay in the tab; a same-site document (a PDF leaflet, a form)
181
+ // opens in a new one, like an external link.
182
+ if (url.host === host && !/\.(?:pdf|docx?|xlsx?|pptx?)$/i.test(url.pathname)) return;
183
+ if (!a.target) a.target = '_blank';
184
+ const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
185
+ rel.add('noopener');
186
+ rel.add('noreferrer');
187
+ a.setAttribute('rel', Array.from(rel).join(' '));
188
+ });
189
+ }
190
+ ferstMarkExternalLinks();
191
+ document.addEventListener('astro:page-load', ferstMarkExternalLinks);
192
+ </script>
193
+ <script>
194
+ // Live theme preview: only when this page is FRAMED and either loaded with
195
+ // ?preview=1 OR already in a preview session (so the draft survives clicking
196
+ // links inside the frame, which drop the query). Listens for draft theme
197
+ // settings over postMessage and re-themes live. Dynamically imported so the
198
+ // code never ships to normal visitors; never runs unframed.
199
+ try {
200
+ var inPreview =
201
+ new URLSearchParams(location.search).has('preview') ||
202
+ sessionStorage.getItem('ferst-preview-on') === '1';
203
+ if (window.parent !== window && inPreview) {
204
+ import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
205
+ }
206
+ } catch (_) {
207
+ /* sessionStorage blocked — fall back to the ?preview query only */
208
+ if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
209
+ import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
210
+ }
211
+ }
212
+ </script>
213
+ </head>
214
+ <body>
215
+ <SiteHeader
216
+ path={path}
217
+ headerClass={headerClass}
218
+ primaryLogoLight={primaryLogoLight}
219
+ primaryLogoDark={primaryLogoDark}
220
+ brandAlt={brandAlt}
221
+ primaryNav={navigation.primary}
222
+ dropdown={navigation.dropdown}
223
+ cta={navigation.cta}
224
+ />
225
+
226
+ <main class:list={['site-main', mainClass]} data-content-align={align}>
227
+ <slot name="hero" />
228
+ <slot />
229
+ {showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
230
+ <SecondaryLogo logoLight={secondaryLogoLight} logoDark={secondaryLogoDark} link={secondaryLogoLink} />
231
+ )}
232
+ </main>
233
+
234
+ <SiteFooter
235
+ facebookUrl={contact.facebookUrl}
236
+ footerLinks={navigation.footer}
237
+ groups={navigation.footerGroups}
238
+ cta={navigation.cta}
239
+ footerClass={footerClass}
240
+ shortName={identity.shortName}
241
+ tagline={identity.description}
242
+ />
243
+
244
+ <CookiePreferences />
245
+ <CookieBanner />
246
+
247
+ <StickyCallout {...callout} />
248
+
249
+ <style is:global>
250
+ /* ── Global resets & typography ─────────────────────────────────────
251
+ Anything that affects every page's body / typography / article
252
+ content lives here. Header, footer, and cookie banner each own
253
+ their own <style is:global> in their component file. Theme tokens
254
+ (`:root`, `[data-theme=dark]`) come from styles/theme.css and are
255
+ overridable per-client via the themeSettings collection. Every
256
+ colour below reads a token, so light/dark and any client palette
257
+ all flow from the same variables — no per-mode hex overrides here.
258
+ */
259
+ * { box-sizing: border-box; }
260
+ html, body { margin: 0; padding: 0; }
261
+ html {
262
+ height: 100%;
263
+ overflow-x: clip;
264
+ overscroll-behavior-x: none;
265
+ }
266
+ body {
267
+ font-family: var(--font-body);
268
+ color: var(--fg);
269
+ background: var(--bg);
270
+ line-height: 1.6;
271
+ min-height: 100%;
272
+ display: flex;
273
+ flex-direction: column;
274
+ overflow-x: clip;
275
+ overscroll-behavior-x: none;
276
+ }
277
+ h1, h2, h3, h4 {
278
+ font-family: var(--font-display);
279
+ }
280
+ h1 {
281
+ font-family: var(--font-heading);
282
+ color: var(--fg);
283
+ font-size: clamp(2rem, 0.95rem + 4.5vw, 3.35rem);
284
+ font-weight: 700;
285
+ line-height: 1.34;
286
+ letter-spacing: -0.01em;
287
+ margin: 0;
288
+ }
289
+ h2 {
290
+ font-family: var(--font-display);
291
+ color: var(--fg);
292
+ font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
293
+ line-height: 1.3;
294
+ margin: 0;
295
+ }
296
+ h3 {
297
+ font-family: var(--font-heading);
298
+ color: var(--fg);
299
+ font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
300
+ font-weight: 600;
301
+ line-height: 1.35;
302
+ margin: 0;
303
+ }
304
+ h4 {
305
+ font-family: var(--font-heading);
306
+ color: var(--accent);
307
+ font-size: clamp(0.6875rem, 0.9vw, 0.75rem);
308
+ font-weight: 500;
309
+ letter-spacing: 0.2em;
310
+ text-transform: uppercase;
311
+ margin: 0;
312
+ }
313
+
314
+ p, li {
315
+ font-family: var(--font-body);
316
+ color: var(--muted);
317
+ font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
318
+ font-weight: 400;
319
+ line-height: 1.65;
320
+ margin: 0;
321
+ }
322
+
323
+ time {
324
+ color: var(--muted);
325
+ font-size: clamp(0.7rem, 0.62rem + 0.25vw, 0.8rem);
326
+ letter-spacing: 0.04em;
327
+ }
328
+
329
+ /* ── Typography utility classes (mobile-first, fluid via clamp).
330
+ Apply explicitly to elements: .t-h1/.t-h2/.t-h3 for headings,
331
+ .t-body for body copy, .t-lead for intro paragraphs, .t-meta for small/meta text.
332
+ Mobile baseline matches iOS Human Interface Guidelines (Dynamic Type, "Large").
333
+ Large Title 34 / Title1 28 / Title2 22 / Title3 ~22px mobile → max 26px / Body 17 / Footnote 13. */
334
+ .t-h1 {
335
+ font-size: clamp(2.125rem, 1.6rem + 1.75vw, 3rem);
336
+ line-height: 1.2;
337
+ }
338
+ .t-h2 {
339
+ font-size: clamp(1.75rem, 1.45rem + 1vw, 2.25rem);
340
+ line-height: 1.3;
341
+ margin: 0;
342
+ }
343
+ .t-h3 {
344
+ font-size: var(--type-h3-size);
345
+ line-height: 1.35;
346
+ margin: 0;
347
+ }
348
+ .t-body {
349
+ font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
350
+ line-height: 1.65;
351
+ }
352
+ .t-lead {
353
+ font-size: clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem);
354
+ line-height: 1.7;
355
+ }
356
+ .t-meta {
357
+ font-size: clamp(0.8125rem, 0.76rem + 0.18vw, 0.9375rem);
358
+ line-height: 1.5;
359
+ }
360
+
361
+ /* Reveal-on-scroll utility — paired with the `armReveal()` helper in
362
+ src/utils/scrollReveal.ts. An element only gets hidden once JS adds
363
+ .js-reveal, so it stays fully visible with no JS or under
364
+ prefers-reduced-motion. Global (not page-scoped) since it's shared
365
+ by the homepage and the /posts/ listing. */
366
+ .js-reveal {
367
+ opacity: 0;
368
+ transform: translateY(16px);
369
+ transition: opacity 0.6s ease, transform 0.6s ease;
370
+ }
371
+ .js-reveal.is-visible {
372
+ opacity: 1;
373
+ transform: none;
374
+ }
375
+ /* Optimal reading measure for long-form article content. Below this
376
+ breakpoint the viewport itself is already narrower than 65ch, so
377
+ the cap only needs to do work on wider (tablet/desktop) screens. */
378
+ article :where(p, ul, ol, blockquote, h2, h3, h4) {
379
+ max-width: 65ch;
380
+ }
381
+ @media (max-width: 47.9375em) {
382
+ article :where(p, ul, ol, blockquote, h2, h3, h4) {
383
+ max-width: none;
384
+ }
385
+ }
386
+ article :where(img, picture, video, table, figure, iframe) {
387
+ max-width: 100%;
388
+ }
389
+ /* Prose images — editor/markdown images dropped into long-form body content
390
+ get a centred block treatment (rounded, soft shadow). Scoped to the real
391
+ prose containers (post body, Prose block) so it never leaks onto STRUCTURAL
392
+ component images — cards, banners, media headers — which own their own
393
+ framing and crop absolutely. (A leaked `margin` pushes an inset image down
394
+ inside its overflow-hidden box, exposing a strip above it — the "empty
395
+ space above the image" a bare `article img` rule caused.) */
396
+ .b-post__body img,
397
+ .b-prose img {
398
+ display: block;
399
+ margin: 1.5rem auto;
400
+ border-radius: 8px;
401
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
402
+ }
403
+ @media (min-width: 44em) {
404
+ .b-post__body img,
405
+ .b-prose img {
406
+ border-radius: 12px;
407
+ }
408
+ }
409
+ a {
410
+ font-family: var(--font-body);
411
+ font-size: clamp(0.875rem, 0.82rem + 0.25vw, 1rem);
412
+ font-weight: 600;
413
+ color: var(--accent);
414
+ text-decoration: none;
415
+ transition: color 0.2s ease;
416
+ }
417
+ h1 a, h2 a, h3 a, h4 a {
418
+ font-size: inherit;
419
+ font-family: inherit;
420
+ font-weight: inherit;
421
+ line-height: inherit;
422
+ }
423
+
424
+ /* ── Fluid media: never overflow container ── */
425
+ /* The logo is exempt: it carries a fixed height in SiteHeader that `height:
426
+ auto` would collapse. Card/media images set their own size at higher
427
+ specificity, so this only ever backstops loose images. */
428
+ img:not(.primary-logo), picture, video {
429
+ max-width: 100%;
430
+ height: auto;
431
+ }
432
+
433
+ .site-main {
434
+ flex: 1;
435
+ min-width: 0;
436
+ width: min(100%, 72rem);
437
+ max-width: 72rem;
438
+ margin-inline: auto;
439
+ padding-inline: clamp(1rem, 3vw, 2.5rem);
440
+ }
441
+ .site-main {
442
+ display: flex;
443
+ flex-direction: column;
444
+ gap: clamp(2rem, 4vw, 3rem);
445
+ padding-block: clamp(3rem, 6vw, 5rem);
446
+ }
447
+ .site-main > * {
448
+ margin-block: 0;
449
+ }
450
+ /* A full-bleed opener (a full-screen hero, a full-bleed banner) as the page's first
451
+ block sits flush under the header, not below the main's top padding. */
452
+ .site-main:has(> .b-hero[data-size='screen']:first-child),
453
+ .site-main:has(> .b-hero[data-full-bleed='true']:first-child),
454
+ .site-main:has(> .b-banner[data-full-bleed='true']:first-child) {
455
+ padding-block-start: 0;
456
+ }
457
+
458
+ /* ── Content alignment cascade ──────────────────────────────────────
459
+ A page sets its default alignment via the `align` prop (→
460
+ data-content-align on <main>); a `section` block can override it for
461
+ its own subtree. Text-bearing blocks inherit `text-align` from here, so
462
+ a whole template stays on one alignment axis instead of each recipe
463
+ hard-coding its own. A few blocks adjust when centred so the result
464
+ reads as deliberately centred, not centred-inside-a-left-column.
465
+ `start` (left) is the readable default for long-form; a heading can
466
+ still override per-block via its own `align`. */
467
+ /* The same choice for flex-column layouts (cards), where text-align no longer places
468
+ children, so an icon, a pill or a button follows the words around it:
469
+ --content-items for a card's children (start keeps today's stretch), and
470
+ --content-self for an item that keeps its own width, like a pill. */
471
+ [data-content-align='start'] { text-align: start; --content-items: stretch; --content-self: flex-start; }
472
+ [data-content-align='center'] { text-align: center; --content-items: center; --content-self: center; }
473
+ [data-content-align='end'] { text-align: end; --content-items: flex-end; --content-self: flex-end; }
474
+ /* A block's own `align` (BlockRenderer) sets the cascade through a wrapper that adds no box. */
475
+ .block-align { display: contents; }
476
+ /* A recipe's title + intro column (data-recipe-head) sits where its alignment says:
477
+ centred by default, against the start or end edge when re-aligned. (:root lifts
478
+ these above each recipe's own scoped margin.) */
479
+ :root [data-recipe-head][data-content-align='start'] { margin-inline: 0 auto; }
480
+ :root [data-recipe-head][data-content-align='end'] { margin-inline: auto 0; }
481
+ /* Each content block owns how it ADJUSTS when centred/ended (re-centring
482
+ its reading column, dropping a left rule, etc.) in its own component,
483
+ so the rule out-specifies the block's own scoped styles. Here we only
484
+ set the inherited text-align axis. */
485
+
486
+ /* ── Secondary logo block: centered ── */
487
+ .secondary-logo {
488
+ display: flex;
489
+ flex-direction: column;
490
+ align-items: center;
491
+ justify-content: center;
492
+ width: 100%;
493
+ box-sizing: border-box;
494
+ }
495
+ .secondary-logo__img {
496
+ display: block;
497
+ max-width: min(150px, 100%);
498
+ height: auto;
499
+ }
500
+ html:not([data-theme='dark']) .secondary-logo__img {
501
+ opacity: 0.85;
502
+ mix-blend-mode: multiply;
503
+ }
504
+ html[data-theme='dark'] .secondary-logo__img {
505
+ mix-blend-mode: normal;
506
+ opacity: 1;
507
+ filter: var(--secondary-logo-filter);
508
+ }
509
+
510
+ @media (max-width: 22.5em) {
511
+ .site-main { padding-inline: 1rem; }
512
+ }
513
+ </style>
514
+ </body>
515
+ </html>