ferst-core 0.2.9 → 0.4.0

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
 
@@ -76,42 +76,53 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
76
76
  </section>
77
77
 
78
78
  <script>
79
- // Bundled (not is:inline), so it runs once even with multiple calendars on a
80
- // page. Wires each embed's consent gate; auto-loads where consent was given.
81
- const CONSENT_KEY = 'ferst-calendar-consent';
82
- document.querySelectorAll<HTMLElement>('.b-calendar__embed').forEach((el) => {
79
+ // Bundled (runs once even with multiple calendars on a page). Consent is the
80
+ // shared site-wide embed consent: accepting the cookie banner (or the footer
81
+ // panel) auto-loads every embed. The per-embed button is a fallback that grants
82
+ // the same consent, so one click still loads everything and is remembered.
83
+ import { hasConsent, setConsent, onConsentChange } from '../lib/consent-client';
84
+
85
+ const embeds = document.querySelectorAll<HTMLElement>('.b-calendar__embed');
86
+
87
+ // Best-effort theme blend: Google Calendar embeds expose only `bgcolor` (the
88
+ // surround), not a real dark mode — the grid stays Google's own light. We match
89
+ // the surround to the current theme surface so the frame doesn't sit as a hard
90
+ // block. Resolved from --bg-surface at load (a later theme toggle keeps it).
91
+ function themedCalendarUrl(url: string): string {
92
+ if (!/google\.com\/calendar|calendar\.google\.com/.test(url)) return url;
93
+ const probe = document.createElement('span');
94
+ probe.style.cssText = 'display:none;background:var(--bg-surface)';
95
+ document.body.appendChild(probe);
96
+ const m = getComputedStyle(probe).backgroundColor.match(/\d+/g);
97
+ probe.remove();
98
+ if (!m) return url;
99
+ const hex = m.slice(0, 3).map((n) => (+n).toString(16).padStart(2, '0')).join('');
100
+ return url + (url.includes('?') ? '&' : '?') + 'bgcolor=%23' + hex;
101
+ }
102
+
103
+ function load(el: HTMLElement) {
104
+ if (el.dataset.loaded === 'true') return;
83
105
  const url = el.dataset.embedUrl;
84
- if (!url) return;
85
- const height = el.dataset.height || '600';
86
- const title = el.dataset.title || 'Calendar';
87
106
  const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
107
+ if (!url || !frameSlot) return;
108
+ const frame = document.createElement('iframe');
109
+ frame.src = themedCalendarUrl(url);
110
+ frame.title = el.dataset.title || 'Calendar';
111
+ frame.loading = 'lazy';
112
+ frame.style.width = '100%';
113
+ frame.style.height = (el.dataset.height || '600') + 'px';
114
+ frame.style.border = '0';
115
+ frameSlot.replaceChildren(frame);
116
+ el.dataset.loaded = 'true';
88
117
  const consent = el.querySelector<HTMLElement>('.b-calendar__consent');
89
- const btn = el.querySelector<HTMLButtonElement>('.b-calendar__load');
90
-
91
- function load() {
92
- if (el.dataset.loaded === 'true' || !frameSlot) return;
93
- const frame = document.createElement('iframe');
94
- frame.src = url as string;
95
- frame.title = title;
96
- frame.loading = 'lazy';
97
- frame.style.width = '100%';
98
- frame.style.height = height + 'px';
99
- frame.style.border = '0';
100
- frameSlot.replaceChildren(frame);
101
- el.dataset.loaded = 'true';
102
- if (consent) consent.hidden = true;
103
- }
104
-
105
- try {
106
- if (localStorage.getItem(CONSENT_KEY) === '1') load();
107
- } catch (_) {}
118
+ if (consent) consent.hidden = true;
119
+ }
120
+ const loadAll = () => embeds.forEach(load);
108
121
 
109
- btn?.addEventListener('click', () => {
110
- try {
111
- localStorage.setItem(CONSENT_KEY, '1');
112
- } catch (_) {}
113
- load();
114
- });
122
+ if (hasConsent()) loadAll();
123
+ onConsentChange((allowed) => { if (allowed) loadAll(); });
124
+ embeds.forEach((el) => {
125
+ el.querySelector<HTMLButtonElement>('.b-calendar__load')?.addEventListener('click', () => setConsent(true));
115
126
  });
116
127
  </script>
117
128
 
@@ -0,0 +1,126 @@
1
+ ---
2
+ /**
3
+ * CookieBanner — the site's single cookie consent prompt, shown once on the first
4
+ * visit until the visitor chooses. Accepting lets embedded third-party content
5
+ * (Google Calendar, Google Maps) load automatically wherever it appears — no
6
+ * separate per-embed gate. The footer's "Cookie preferences" panel lets them
7
+ * change their mind later. Site chrome, rendered by Base after the page.
8
+ *
9
+ * Hidden by default; the client script reveals it only when no choice is stored,
10
+ * so it never flashes for returning visitors.
11
+ */
12
+ interface Props {
13
+ /** Link to the cookie policy page (omit to hide the link). */
14
+ policyHref?: string;
15
+ }
16
+ const { policyHref = '/cookie-policy/' } = Astro.props;
17
+ ---
18
+
19
+ <div class="cookie-banner" id="cookie-banner" role="dialog" aria-label="Cookie notice" aria-live="polite" hidden>
20
+ <p class="cookie-banner__text">
21
+ We use a few cookies to run the site and to show embedded content like our
22
+ calendar and maps.{policyHref && (<> See our <a href={policyHref}>cookie policy</a>.</>)}
23
+ </p>
24
+ <div class="cookie-banner__actions">
25
+ <button type="button" class="cookie-banner__btn cookie-banner__btn--ghost" id="cookie-banner-decline">Decline</button>
26
+ <button type="button" class="cookie-banner__btn cookie-banner__btn--primary" id="cookie-banner-accept">Accept</button>
27
+ </div>
28
+ </div>
29
+
30
+ <script>
31
+ import { isDecided, setConsent } from '../lib/consent-client';
32
+
33
+ const banner = document.getElementById('cookie-banner');
34
+ if (banner && !isDecided()) {
35
+ banner.hidden = false;
36
+ // Next frame so the entry transition runs.
37
+ requestAnimationFrame(() => banner.classList.add('visible'));
38
+ }
39
+
40
+ function dismiss(granted: boolean) {
41
+ setConsent(granted);
42
+ banner?.classList.remove('visible');
43
+ if (banner) setTimeout(() => { banner.hidden = true; }, 400);
44
+ }
45
+
46
+ document.getElementById('cookie-banner-accept')?.addEventListener('click', () => dismiss(true));
47
+ document.getElementById('cookie-banner-decline')?.addEventListener('click', () => dismiss(false));
48
+ </script>
49
+
50
+ <style is:global>
51
+ .cookie-banner {
52
+ position: fixed;
53
+ left: 20px;
54
+ right: 20px;
55
+ bottom: 20px;
56
+ z-index: 210;
57
+ max-width: 40rem;
58
+ margin-inline: auto;
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ gap: 0.75rem 1.25rem;
64
+ padding: 1rem 1.2rem;
65
+ background: var(--bg-surface);
66
+ border: 1px solid var(--border);
67
+ border-radius: 14px;
68
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.18);
69
+ transform: translateY(12px);
70
+ opacity: 0;
71
+ transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s ease;
72
+ }
73
+ .cookie-banner.visible {
74
+ transform: translateY(0);
75
+ opacity: 1;
76
+ }
77
+ @media (prefers-reduced-motion: reduce) {
78
+ .cookie-banner { transition: opacity 0.2s ease; transform: none; }
79
+ }
80
+ .cookie-banner__text {
81
+ margin: 0;
82
+ flex: 1 1 18rem;
83
+ min-width: 0;
84
+ font-family: var(--font-body);
85
+ font-size: 0.85rem;
86
+ line-height: 1.55;
87
+ color: var(--muted);
88
+ }
89
+ .cookie-banner__text a {
90
+ color: var(--muted);
91
+ text-decoration: underline;
92
+ text-underline-offset: 2px;
93
+ }
94
+ .cookie-banner__text a:hover { color: var(--fg); }
95
+ .cookie-banner__actions {
96
+ display: flex;
97
+ gap: 0.6rem;
98
+ flex-shrink: 0;
99
+ }
100
+ .cookie-banner__btn {
101
+ font: inherit;
102
+ font-family: var(--font-heading);
103
+ font-size: 0.85rem;
104
+ font-weight: 600;
105
+ padding: 0.5rem 1.1rem;
106
+ border-radius: var(--radius-sm);
107
+ cursor: pointer;
108
+ transition: filter 0.15s ease, background 0.15s ease, color 0.15s ease;
109
+ }
110
+ .cookie-banner__btn--primary {
111
+ border: none;
112
+ background: var(--gold);
113
+ color: var(--text-light);
114
+ }
115
+ .cookie-banner__btn--primary:hover { filter: brightness(0.94); }
116
+ .cookie-banner__btn--ghost {
117
+ border: 1px solid var(--border);
118
+ background: transparent;
119
+ color: var(--fg);
120
+ }
121
+ .cookie-banner__btn--ghost:hover { background: var(--bg-section); }
122
+ .cookie-banner__btn:focus-visible {
123
+ outline: 2px solid var(--gold);
124
+ outline-offset: 2px;
125
+ }
126
+ </style>
@@ -29,16 +29,16 @@
29
29
 
30
30
  <div class="cookie-prefs__row">
31
31
  <div class="cookie-prefs__row-text">
32
- <p class="cookie-prefs__row-title">Google Calendar</p>
33
- <p class="cookie-prefs__row-desc">Loads the embedded calendar on the <a href="/calendar/">Calendar</a> page. Google may set its own cookies once it's loaded.</p>
32
+ <p class="cookie-prefs__row-title">Embedded content</p>
33
+ <p class="cookie-prefs__row-desc">Lets our <a href="/calendar/">calendar</a> and maps load from Google. Google may set its own cookies once loaded.</p>
34
34
  </div>
35
35
  <button
36
36
  type="button"
37
37
  class="cookie-prefs__switch"
38
- id="cookie-prefs-calendar-toggle"
38
+ id="cookie-prefs-embed-toggle"
39
39
  role="switch"
40
40
  aria-checked="false"
41
- aria-label="Allow Google Calendar embed"
41
+ aria-label="Allow embedded content (maps and calendar)"
42
42
  >
43
43
  <span class="cookie-prefs__switch-thumb"></span>
44
44
  </button>
@@ -197,15 +197,14 @@
197
197
  </style>
198
198
 
199
199
  <script>
200
- const CALENDAR_KEY = 'calendar-consent';
200
+ import { hasConsent, setConsent, onConsentChange } from '../lib/consent-client';
201
201
 
202
202
  const panel = document.getElementById('cookie-prefs') as HTMLElement;
203
203
  const closeBtn = document.getElementById('cookie-prefs-close') as HTMLButtonElement;
204
- const calendarToggle = document.getElementById('cookie-prefs-calendar-toggle') as HTMLButtonElement;
204
+ const embedToggle = document.getElementById('cookie-prefs-embed-toggle') as HTMLButtonElement;
205
205
 
206
206
  function syncToggle() {
207
- const allowed = localStorage.getItem(CALENDAR_KEY) === '1';
208
- calendarToggle.setAttribute('aria-checked', String(allowed));
207
+ embedToggle.setAttribute('aria-checked', String(hasConsent()));
209
208
  }
210
209
 
211
210
  function open() {
@@ -217,13 +216,14 @@
217
216
  panel.classList.remove('visible');
218
217
  }
219
218
 
220
- calendarToggle.addEventListener('click', () => {
221
- const next = calendarToggle.getAttribute('aria-checked') !== 'true';
222
- calendarToggle.setAttribute('aria-checked', String(next));
223
- localStorage.setItem(CALENDAR_KEY, next ? '1' : '0');
224
- document.dispatchEvent(new CustomEvent('calendar-consent-changed', { detail: { allowed: next } }));
219
+ embedToggle.addEventListener('click', () => {
220
+ setConsent(embedToggle.getAttribute('aria-checked') !== 'true');
221
+ syncToggle();
225
222
  });
226
223
 
224
+ // Keep the switch in sync if consent changes elsewhere (e.g. the banner).
225
+ onConsentChange(syncToggle);
226
+
227
227
  closeBtn.addEventListener('click', close);
228
228
  document.addEventListener('keydown', (e) => {
229
229
  if (e.key === 'Escape' && panel.classList.contains('visible')) close();
@@ -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
 
@@ -0,0 +1,145 @@
1
+ ---
2
+ /**
3
+ * ContactCard — a Level-2 recipe: a rich, branded "get in touch" band. A gradient
4
+ * panel (anchored on --ink so it stays a deep, warm brand tone in BOTH themes)
5
+ * carries a display-type title + a subtitle on the left, the contact channels as
6
+ * tappable pills on the right, and a closing note across the bottom. For a contact
7
+ * or find-us page — the polished alternative to a plain notice box.
8
+ *
9
+ * Token-driven: the gradient is --ink → brand-tinted, pills are translucent
10
+ * recesses on it, icons + hovers use --gold, so a CMS colour change recolours the
11
+ * whole band. Whole pill links when `href` is set.
12
+ */
13
+ import Icon from '../Icon.astro';
14
+ import { isIconKey } from '../../lib/icons';
15
+
16
+ interface Item {
17
+ icon?: string;
18
+ value: string;
19
+ href?: string;
20
+ }
21
+ interface Props {
22
+ eyebrow?: string;
23
+ title: string;
24
+ subtitle?: string;
25
+ note?: string;
26
+ items?: Item[];
27
+ }
28
+ const { eyebrow, title, subtitle, note, items = [] } = Astro.props;
29
+ const rel = (href?: string) => (href && /^https?:\/\//.test(href) ? 'noopener' : undefined);
30
+ const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank' : undefined);
31
+ ---
32
+
33
+ <section class="b-contact">
34
+ <div class="b-contact__panel">
35
+ <div class="b-contact__head">
36
+ {eyebrow && <p class="b-contact__eyebrow">{eyebrow}</p>}
37
+ <h2 class="b-contact__title">{title}</h2>
38
+ {subtitle && <p class="b-contact__subtitle">{subtitle}</p>}
39
+ </div>
40
+
41
+ {items.length > 0 && (
42
+ <ul class="b-contact__items">
43
+ {items.map((item) => {
44
+ const inner = (
45
+ <>
46
+ {item.icon && isIconKey(item.icon) && (
47
+ <span class="b-contact__icon" aria-hidden="true"><Icon name={item.icon} size={18} /></span>
48
+ )}
49
+ <span class="b-contact__value">{item.value}</span>
50
+ </>
51
+ );
52
+ return (
53
+ <li>
54
+ {item.href ? (
55
+ <a class="b-contact__item" href={item.href} target={target(item.href)} rel={rel(item.href)}>{inner}</a>
56
+ ) : (
57
+ <div class="b-contact__item">{inner}</div>
58
+ )}
59
+ </li>
60
+ );
61
+ })}
62
+ </ul>
63
+ )}
64
+
65
+ {note && <p class="b-contact__note">{note}</p>}
66
+ </div>
67
+ </section>
68
+
69
+ <style>
70
+ .b-contact {
71
+ max-width: 72rem;
72
+ margin-inline: auto;
73
+ padding-inline: 1rem;
74
+ }
75
+ /* Anchored on --ink (a source colour that stays dark in both themes) so the band
76
+ reads as a deep, warm brand panel regardless of light/dark. */
77
+ .b-contact__panel {
78
+ display: grid;
79
+ grid-template-columns: 1fr;
80
+ gap: 1.5rem 2.5rem;
81
+ padding: clamp(1.75rem, 4vw, 3rem);
82
+ border-radius: var(--radius);
83
+ background: linear-gradient(135deg, var(--ink), color-mix(in srgb, var(--gold) 26%, var(--ink)));
84
+ color: var(--text-light);
85
+ box-shadow: var(--shadow-md);
86
+ }
87
+ @media (min-width: 48rem) {
88
+ .b-contact__panel { grid-template-columns: 1.25fr 1fr; align-items: center; }
89
+ }
90
+ .b-contact__head { display: flex; flex-direction: column; gap: 0.5rem; }
91
+ .b-contact__eyebrow {
92
+ margin: 0;
93
+ font-family: var(--font-heading);
94
+ font-size: 0.75rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
95
+ color: color-mix(in srgb, var(--text-light) 70%, transparent);
96
+ }
97
+ .b-contact__title {
98
+ margin: 0;
99
+ font-family: var(--font-display, var(--font-heading));
100
+ font-size: clamp(1.9rem, 4vw, 2.8rem);
101
+ line-height: 1.05;
102
+ text-wrap: balance;
103
+ color: var(--text-light);
104
+ }
105
+ .b-contact__subtitle {
106
+ margin: 0.15rem 0 0;
107
+ font-family: var(--font-heading);
108
+ font-weight: 600;
109
+ color: var(--gold);
110
+ }
111
+ .b-contact__items {
112
+ list-style: none;
113
+ margin: 0; padding: 0;
114
+ display: flex; flex-direction: column; gap: 0.6rem;
115
+ }
116
+ .b-contact__item {
117
+ display: flex; align-items: center; gap: 0.75rem;
118
+ padding: 0.7rem 1rem;
119
+ border-radius: var(--radius-sm);
120
+ background: color-mix(in srgb, #000 22%, transparent);
121
+ border: 1px solid color-mix(in srgb, #fff 14%, transparent);
122
+ color: var(--text-light);
123
+ font-family: var(--font-heading);
124
+ font-weight: 600;
125
+ font-size: 0.95rem;
126
+ text-decoration: none;
127
+ transition: border-color 0.15s ease, background 0.15s ease;
128
+ }
129
+ a.b-contact__item:hover {
130
+ border-color: var(--gold);
131
+ background: color-mix(in srgb, #000 12%, transparent);
132
+ }
133
+ a.b-contact__item:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
134
+ .b-contact__icon { display: inline-flex; color: var(--gold); flex: 0 0 auto; }
135
+ .b-contact__value { min-width: 0; overflow-wrap: anywhere; }
136
+ .b-contact__note {
137
+ grid-column: 1 / -1;
138
+ margin: 0;
139
+ padding-top: 1.25rem;
140
+ border-top: 1px solid color-mix(in srgb, #fff 14%, transparent);
141
+ color: color-mix(in srgb, var(--text-light) 78%, transparent);
142
+ font-size: 0.9rem;
143
+ line-height: 1.6;
144
+ }
145
+ </style>
@@ -3,12 +3,16 @@
3
3
  * question/answer items. Uses native <details>/<summary> — accessible and
4
4
  * interactive with zero JavaScript. Composes the Heading primitive. */
5
5
  import Heading from '../blocks/Heading.astro';
6
+ import { inlineMarkdown } from '../../lib/inline-markdown';
6
7
 
7
8
  interface Props {
8
9
  title?: string;
9
10
  items?: { question: string; answer: string }[];
10
11
  }
11
12
  const { title, items = [] } = Astro.props;
13
+ // Answers may carry a link or bold via a tiny markdown subset (inlineMarkdown),
14
+ // split into paragraphs on blank lines. Trusted editor/CMS text, sanitised.
15
+ const paragraphs = (answer: string) => answer.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
12
16
  ---
13
17
 
14
18
  <section class="b-faq">
@@ -17,7 +21,9 @@ const { title, items = [] } = Astro.props;
17
21
  {items.map((item) => (
18
22
  <details class="b-faq__item">
19
23
  <summary class="b-faq__q">{item.question}</summary>
20
- <div class="b-faq__a"><p>{item.answer}</p></div>
24
+ <div class="b-faq__a">
25
+ {paragraphs(item.answer).map((p) => <p set:html={inlineMarkdown(p)} />)}
26
+ </div>
21
27
  </details>
22
28
  ))}
23
29
  </div>
@@ -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>
@@ -71,43 +71,37 @@ const safe = isSafeEmbedUrl(embedUrl);
71
71
  </section>
72
72
 
73
73
  <script>
74
- // Bundled (not is:inline), so it runs once even with multiple maps on a page.
75
- // Wires each embed's consent gate; auto-loads where consent was given.
76
- const CONSENT_KEY = 'ferst-map-consent';
77
- document.querySelectorAll<HTMLElement>('.b-map__embed').forEach((el) => {
74
+ // Bundled (runs once even with multiple maps on a page). Uses the shared site
75
+ // embed consent: accepting the cookie banner (or the footer panel) auto-loads
76
+ // every embed. The per-embed button grants that same consent as a fallback.
77
+ import { hasConsent, setConsent, onConsentChange } from '../../lib/consent-client';
78
+
79
+ const embeds = document.querySelectorAll<HTMLElement>('.b-map__embed');
80
+
81
+ function load(el: HTMLElement) {
82
+ if (el.dataset.loaded === 'true') return;
78
83
  const url = el.dataset.embedUrl;
79
- if (!url) return;
80
- const height = el.dataset.height || '450';
81
- const title = el.dataset.title || 'Map';
82
84
  const frameSlot = el.querySelector<HTMLElement>('.b-map__frame');
85
+ if (!url || !frameSlot) return;
86
+ const frame = document.createElement('iframe');
87
+ frame.src = url;
88
+ frame.title = el.dataset.title || 'Map';
89
+ frame.loading = 'lazy';
90
+ frame.referrerPolicy = 'no-referrer-when-downgrade';
91
+ frame.style.width = '100%';
92
+ frame.style.height = (el.dataset.height || '450') + 'px';
93
+ frame.style.border = '0';
94
+ frameSlot.replaceChildren(frame);
95
+ el.dataset.loaded = 'true';
83
96
  const consent = el.querySelector<HTMLElement>('.b-map__consent');
84
- const btn = el.querySelector<HTMLButtonElement>('.b-map__load');
85
-
86
- function load() {
87
- if (el.dataset.loaded === 'true' || !frameSlot) return;
88
- const frame = document.createElement('iframe');
89
- frame.src = url as string;
90
- frame.title = title;
91
- frame.loading = 'lazy';
92
- frame.referrerPolicy = 'no-referrer-when-downgrade';
93
- frame.style.width = '100%';
94
- frame.style.height = height + 'px';
95
- frame.style.border = '0';
96
- frameSlot.replaceChildren(frame);
97
- el.dataset.loaded = 'true';
98
- if (consent) consent.hidden = true;
99
- }
100
-
101
- try {
102
- if (localStorage.getItem(CONSENT_KEY) === '1') load();
103
- } catch (_) {}
97
+ if (consent) consent.hidden = true;
98
+ }
99
+ const loadAll = () => embeds.forEach(load);
104
100
 
105
- btn?.addEventListener('click', () => {
106
- try {
107
- localStorage.setItem(CONSENT_KEY, '1');
108
- } catch (_) {}
109
- load();
110
- });
101
+ if (hasConsent()) loadAll();
102
+ onConsentChange((allowed) => { if (allowed) loadAll(); });
103
+ embeds.forEach((el) => {
104
+ el.querySelector<HTMLButtonElement>('.b-map__load')?.addEventListener('click', () => setConsent(true));
111
105
  });
112
106
  </script>
113
107
 
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,
@@ -644,6 +689,23 @@ export const pageSchema = z.object({
644
689
  * bespoke page may have none.
645
690
  */
646
691
  templateId: z.string().optional(),
692
+ /**
693
+ * Editor change-requests captured in the CMS — the seed of AI-driven editing
694
+ * ([[ferst_editing_ai_first_pdlc]]): rather than let editors edit block JSON
695
+ * directly (easy to break), they describe a desired change against the page and
696
+ * it's stored here for an agent to action later → PR → human verify. NEVER
697
+ * rendered; carried through the schema so a CMS save preserves it.
698
+ */
699
+ requests: z
700
+ .array(
701
+ z.object({
702
+ text: z.string(),
703
+ at: z.string().optional(),
704
+ by: z.string().optional(),
705
+ status: z.enum(['open', 'done']).default('open'),
706
+ }),
707
+ )
708
+ .default([]),
647
709
  /** Keep the JSON in the repo but exclude it from the build. */
648
710
  draft: z.boolean().default(false),
649
711
  blocks: z.array(blockSchema).default([]),
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({
@@ -24,6 +24,7 @@ import '../styles/fonts.css';
24
24
  import SiteHeader from '../components/SiteHeader.astro';
25
25
  import SiteFooter from '../components/SiteFooter.astro';
26
26
  import CookiePreferences from '../components/CookiePreferences.astro';
27
+ import CookieBanner from '../components/CookieBanner.astro';
27
28
  import StickyCallout from '../components/StickyCallout.astro';
28
29
  import SecondaryLogo from '../components/SecondaryLogo.astro';
29
30
  import { loadSiteSettings } from '../lib/siteSettings';
@@ -170,6 +171,7 @@ const metaDescription = description ?? identity.description;
170
171
  />
171
172
 
172
173
  <CookiePreferences />
174
+ <CookieBanner />
173
175
 
174
176
  <StickyCallout {...callout} />
175
177
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Client-only consent state for embedded third-party content (Google Calendar,
3
+ * Google Maps). One shared key + event so the cookie banner, the footer's cookie
4
+ * preferences panel, and every embed on the page stay in sync: accept once (via
5
+ * the banner) and every embed loads — no separate per-embed gate.
6
+ *
7
+ * Import this ONLY from component `<script>` blocks (it touches localStorage /
8
+ * document). Every accessor is wrapped so a blocked/absent storage never throws.
9
+ */
10
+ export const CONSENT_KEY = 'ferst-embed-consent';
11
+ export const CONSENT_EVENT = 'ferst-embed-consent-changed';
12
+
13
+ /** True once the visitor has explicitly accepted embedded content. */
14
+ export function hasConsent(): boolean {
15
+ try {
16
+ return localStorage.getItem(CONSENT_KEY) === '1';
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /** True once the visitor has made ANY choice (accept or decline) — hides the banner. */
23
+ export function isDecided(): boolean {
24
+ try {
25
+ return localStorage.getItem(CONSENT_KEY) !== null;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /** Record a choice and broadcast it so open embeds react immediately. */
32
+ export function setConsent(granted: boolean): void {
33
+ try {
34
+ localStorage.setItem(CONSENT_KEY, granted ? '1' : '0');
35
+ } catch {
36
+ /* storage blocked — the event below still drives this page */
37
+ }
38
+ document.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail: { allowed: granted } }));
39
+ }
40
+
41
+ /** Subscribe to consent changes (fires with the new allowed state). */
42
+ export function onConsentChange(cb: (allowed: boolean) => void): void {
43
+ document.addEventListener(CONSENT_EVENT, (e) => {
44
+ cb((e as CustomEvent).detail?.allowed === true);
45
+ });
46
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
3
+ * text: links `[label](href)`, bold `**text**`, and single newlines → `<br>`.
4
+ * Everything is HTML-escaped first, and only safe href schemes (http(s), root-
5
+ * relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
6
+ * as literal text. The result is safe to drop into `set:html` for trusted content.
7
+ *
8
+ * This exists so recipes that show a line or two of editor copy (FAQ answers,
9
+ * notices) can carry a link without a full markdown pipeline — and without the
10
+ * `[text](url)` showing through as literal characters.
11
+ */
12
+ const ESCAPE: Record<string, string> = {
13
+ '&': '&amp;',
14
+ '<': '&lt;',
15
+ '>': '&gt;',
16
+ '"': '&quot;',
17
+ "'": '&#39;',
18
+ };
19
+ function escapeHtml(s: string): string {
20
+ return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
21
+ }
22
+
23
+ /** Only these href schemes are allowed to become real links. */
24
+ const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
25
+
26
+ const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
27
+
28
+ /** Bold + single-newline breaks, applied to already-escaped text. */
29
+ function inlineFormat(escaped: string): string {
30
+ return escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
31
+ }
32
+
33
+ export function inlineMarkdown(text: string | undefined | null): string {
34
+ if (!text) return '';
35
+ let out = '';
36
+ let last = 0;
37
+ let m: RegExpExecArray | null;
38
+ LINK_RE.lastIndex = 0;
39
+ while ((m = LINK_RE.exec(text))) {
40
+ out += inlineFormat(escapeHtml(text.slice(last, m.index)));
41
+ const label = inlineFormat(escapeHtml(m[1]));
42
+ const href = m[2];
43
+ if (SAFE_HREF.test(href)) {
44
+ out += `<a href="${escapeHtml(href)}">${label}</a>`;
45
+ } else {
46
+ out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
47
+ }
48
+ last = m.index + m[0].length;
49
+ }
50
+ out += inlineFormat(escapeHtml(text.slice(last)));
51
+ return out;
52
+ }
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.2.9",
3
+ "version": "0.4.0",
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",