ferst-core 0.4.2 → 0.4.7

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.
@@ -76,48 +76,31 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
76
76
  </section>
77
77
 
78
78
  <script>
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');
79
+ // Shared consent-gated embed loader (one implementation for calendar/map/
80
+ // locations). Accepting the cookie banner or the footer panel auto-loads every
81
+ // embed; the per-embed button is a fallback granting the same shared consent.
82
+ import { initEmbedConsent, makeEmbedFrame } from '../lib/embed-consent';
86
83
 
87
84
  // Google Calendar embeds render light-only — `bgcolor` recolours just the
88
85
  // 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
-
98
- function load(el: HTMLElement) {
99
- if (el.dataset.loaded === 'true') return;
100
- const url = el.dataset.embedUrl;
101
- const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
102
- if (!url || !frameSlot) return;
103
- const frame = document.createElement('iframe');
104
- frame.src = calendarUrl(url);
105
- frame.title = el.dataset.title || 'Calendar';
106
- frame.loading = 'lazy';
107
- frame.style.width = '100%';
108
- frame.style.height = (el.dataset.height || '600') + 'px';
109
- frame.style.border = '0';
110
- frameSlot.replaceChildren(frame);
111
- el.dataset.loaded = 'true';
112
- const consent = el.querySelector<HTMLElement>('.b-calendar__consent');
113
- if (consent) consent.hidden = true;
114
- }
115
- const loadAll = () => embeds.forEach(load);
86
+ // calendar in the dark page. We force a deterministic WHITE surround here
87
+ // (independent of the theme at load) and, in dark mode, invert the whole frame
88
+ // in CSS (below) — keyed on [data-theme] so it also flips live on a toggle.
89
+ const withWhiteSurround = (url: string) =>
90
+ /google\.com\/calendar|calendar\.google\.com/.test(url)
91
+ ? url + (url.includes('?') ? '&' : '?') + 'bgcolor=%23ffffff'
92
+ : url;
116
93
 
117
- if (hasConsent()) loadAll();
118
- onConsentChange((allowed) => { if (allowed) loadAll(); });
119
- embeds.forEach((el) => {
120
- el.querySelector<HTMLButtonElement>('.b-calendar__load')?.addEventListener('click', () => setConsent(true));
94
+ initEmbedConsent({
95
+ root: '.b-calendar__embed',
96
+ frame: '.b-calendar__frame',
97
+ consent: '.b-calendar__consent',
98
+ button: '.b-calendar__load',
99
+ buildFrame: (el, url) =>
100
+ makeEmbedFrame(withWhiteSurround(url), {
101
+ title: el.dataset.title || 'Calendar',
102
+ height: (el.dataset.height || '600') + 'px',
103
+ }),
121
104
  });
122
105
  </script>
123
106
 
@@ -0,0 +1,157 @@
1
+ ---
2
+ /**
3
+ * CookiePolicy — the auto-generated cookie policy page body. It does NOT decide
4
+ * what to show: the consumer scans its own pages (scanCookieUses in
5
+ * content/cookies) and passes the resulting model, so the categories and items
6
+ * always reflect the recipes/templates the site actually uses (functionality-
7
+ * driven, not client-specific).
8
+ *
9
+ * Necessary items are the site's own functional storage (always on). Additional
10
+ * items are third-party embeds gated behind the shared embed consent; one
11
+ * category toggle turns them on/off, wired to the SAME consent as the cookie
12
+ * banner and footer panel, so all three stay in sync. Token-driven.
13
+ */
14
+ import type { CookiePolicyModel } from '../content/cookies';
15
+
16
+ interface Props {
17
+ model: CookiePolicyModel;
18
+ title?: string;
19
+ intro?: string;
20
+ }
21
+ const {
22
+ model,
23
+ title = 'Cookie Policy',
24
+ intro = 'This page explains the cookies and similar storage this website uses. It is generated from the features actually in use here, so it always reflects what is really on the site. You can turn additional (third-party) content on or off at any time — your choice is remembered on this device.',
25
+ } = Astro.props;
26
+ const hasAdditional = model.additional.length > 0;
27
+ ---
28
+
29
+ <section class="b-cookie-policy">
30
+ <h1 class="b-cookie-policy__title">{title}</h1>
31
+ <p class="b-cookie-policy__intro">{intro}</p>
32
+
33
+ <div class="b-cookie-policy__cat ferst-tile">
34
+ <div class="b-cookie-policy__cat-head">
35
+ <h2 class="b-cookie-policy__cat-title">Necessary</h2>
36
+ <span class="b-cookie-policy__badge">Always on</span>
37
+ </div>
38
+ <p class="b-cookie-policy__cat-note">These keep the site working and remember your choices. They stay on your device only and are never shared, so they can’t be switched off.</p>
39
+ <ul class="b-cookie-policy__list">
40
+ {model.necessary.map((u) => (
41
+ <li class="b-cookie-policy__item">
42
+ <p class="b-cookie-policy__item-name">{u.name}</p>
43
+ <p class="b-cookie-policy__item-purpose">{u.purpose}</p>
44
+ <p class="b-cookie-policy__item-storage">{u.storage}</p>
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ </div>
49
+
50
+ <div class="b-cookie-policy__cat ferst-tile">
51
+ <div class="b-cookie-policy__cat-head">
52
+ <h2 class="b-cookie-policy__cat-title">Additional</h2>
53
+ {hasAdditional && (
54
+ <label class="b-cookie-policy__switch">
55
+ <span class="b-cookie-policy__switch-state" data-on="On" data-off="Off">Off</span>
56
+ <input type="checkbox" id="cookie-policy-additional" class="b-cookie-policy__switch-input" />
57
+ <span class="b-cookie-policy__switch-track" aria-hidden="true"></span>
58
+ </label>
59
+ )}
60
+ </div>
61
+ {hasAdditional ? (
62
+ <>
63
+ <p class="b-cookie-policy__cat-note">These let third-party content load, which may set that provider’s cookies. Nothing loads until you turn this on.</p>
64
+ <ul class="b-cookie-policy__list">
65
+ {model.additional.map((u) => (
66
+ <li class="b-cookie-policy__item">
67
+ <p class="b-cookie-policy__item-name">{u.name}{u.provider && <span class="b-cookie-policy__item-provider"> · {u.provider}</span>}</p>
68
+ <p class="b-cookie-policy__item-purpose">{u.purpose}</p>
69
+ <p class="b-cookie-policy__item-storage">{u.storage}</p>
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ </>
74
+ ) : (
75
+ <p class="b-cookie-policy__cat-note">This site currently uses no additional or third-party cookies.</p>
76
+ )}
77
+ </div>
78
+ </section>
79
+
80
+ <script>
81
+ // Wire the Additional toggle to the shared embed consent so it stays in sync
82
+ // with the cookie banner and footer panel (accept there → this shows On, and
83
+ // vice-versa). Turning it on/off here records the same site-wide choice.
84
+ import { hasConsent, setConsent, onConsentChange } from '../lib/consent-client';
85
+ const box = document.getElementById('cookie-policy-additional') as HTMLInputElement | null;
86
+ if (box) {
87
+ const state = box.closest('.b-cookie-policy__switch')?.querySelector<HTMLElement>('.b-cookie-policy__switch-state');
88
+ const sync = (on: boolean) => {
89
+ box.checked = on;
90
+ if (state) state.textContent = on ? state.dataset.on ?? 'On' : state.dataset.off ?? 'Off';
91
+ };
92
+ sync(hasConsent());
93
+ box.addEventListener('change', () => setConsent(box.checked));
94
+ onConsentChange((allowed) => sync(allowed));
95
+ }
96
+ </script>
97
+
98
+ <style>
99
+ .b-cookie-policy {
100
+ max-width: 52rem;
101
+ margin-inline: auto;
102
+ padding-inline: 1rem;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 1.5rem;
106
+ }
107
+ .b-cookie-policy__title { margin: 0; }
108
+ .b-cookie-policy__intro { margin: 0; color: var(--muted); line-height: 1.7; max-width: 46rem; }
109
+
110
+ /* .ferst-tile supplies the surface (bg/border/radius/shadow). */
111
+ .b-cookie-policy__cat { padding: clamp(1.25rem, 3vw, 1.75rem); display: flex; flex-direction: column; gap: 0.9rem; }
112
+ .b-cookie-policy__cat-head {
113
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
114
+ }
115
+ .b-cookie-policy__cat-title { margin: 0; font-size: 1.3rem; }
116
+ .b-cookie-policy__cat-note { margin: 0; color: var(--muted); line-height: 1.6; }
117
+ .b-cookie-policy__badge {
118
+ font-family: var(--font-heading); font-size: 0.72rem; font-weight: 700;
119
+ letter-spacing: 0.08em; text-transform: uppercase;
120
+ color: var(--gold);
121
+ border: 1px solid color-mix(in srgb, var(--gold) 45%, transparent);
122
+ border-radius: 999px; padding: 0.25rem 0.7rem; white-space: nowrap;
123
+ }
124
+
125
+ .b-cookie-policy__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
126
+ .b-cookie-policy__item {
127
+ padding-top: 0.75rem;
128
+ border-top: 1px solid var(--border);
129
+ }
130
+ .b-cookie-policy__item:first-child { padding-top: 0; border-top: none; }
131
+ .b-cookie-policy__item-name { margin: 0; font-family: var(--font-heading); font-weight: 700; }
132
+ .b-cookie-policy__item-provider { color: var(--muted); font-weight: 400; }
133
+ .b-cookie-policy__item-purpose { margin: 0.15rem 0 0; line-height: 1.55; }
134
+ .b-cookie-policy__item-storage { margin: 0.25rem 0 0; font-size: 0.85rem; color: var(--muted); }
135
+
136
+ /* Accessible toggle: a real checkbox drives a styled track. */
137
+ .b-cookie-policy__switch { display: inline-flex; align-items: center; gap: 0.6rem; cursor: pointer; }
138
+ .b-cookie-policy__switch-state {
139
+ font-family: var(--font-heading); font-size: 0.8rem; font-weight: 700; color: var(--muted); min-width: 1.6rem; text-align: right;
140
+ }
141
+ .b-cookie-policy__switch-input {
142
+ position: absolute; opacity: 0; width: 44px; height: 26px; margin: 0; cursor: pointer;
143
+ }
144
+ .b-cookie-policy__switch-track {
145
+ position: relative; width: 44px; height: 26px; border-radius: 999px; flex: 0 0 auto;
146
+ background: color-mix(in srgb, var(--muted) 45%, transparent);
147
+ transition: background 0.15s ease;
148
+ }
149
+ .b-cookie-policy__switch-track::after {
150
+ content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
151
+ border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.3);
152
+ transition: transform 0.15s ease;
153
+ }
154
+ .b-cookie-policy__switch-input:checked ~ .b-cookie-policy__switch-track { background: var(--gold); }
155
+ .b-cookie-policy__switch-input:checked ~ .b-cookie-policy__switch-track::after { transform: translateX(18px); }
156
+ .b-cookie-policy__switch-input:focus-visible ~ .b-cookie-policy__switch-track { outline: 2px solid var(--gold); outline-offset: 2px; }
157
+ </style>
@@ -17,6 +17,9 @@ interface Props {
17
17
  cards?: { icon?: string; title: string; text?: string; href?: string }[];
18
18
  }
19
19
  const { title, intro, columns = 3, cards = [] } = Astro.props;
20
+ // Never reserve more columns than there are cards (a lone card in a 3-col grid
21
+ // would strand two empty cells); `data-single` centres a solitary card.
22
+ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3 | 4;
20
23
  ---
21
24
 
22
25
  <section class="b-feature-cards">
@@ -26,7 +29,7 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
26
29
  {intro && <p class="b-feature-cards__intro">{intro}</p>}
27
30
  </div>
28
31
  )}
29
- <div class="b-feature-cards__grid" data-cols={columns}>
32
+ <div class="b-feature-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
30
33
  {cards.map((card) => (
31
34
  <Card>
32
35
  {card.icon && isIconKey(card.icon) && (
@@ -77,6 +80,9 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
77
80
  .b-feature-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
78
81
  .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
79
82
  }
83
+ /* A solitary card is a centred feature, not a full-width slab. */
84
+ .b-feature-cards__grid[data-single] { max-width: 26rem; margin-inline: auto;
85
+ }
80
86
  @media (min-width: 64em) {
81
87
  .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
82
88
  }
@@ -7,9 +7,11 @@ interface Props {
7
7
  images?: { src: string; alt?: string; caption?: string }[];
8
8
  }
9
9
  const { columns = 3, images = [] } = Astro.props;
10
+ // Never reserve more columns than there are images; `data-single` centres a lone one.
11
+ const cols = Math.min(columns, Math.max(images.length, 1)) as 1 | 2 | 3 | 4;
10
12
  ---
11
13
 
12
- <div class="b-gallery" data-cols={columns}>
14
+ <div class="b-gallery" data-cols={cols} data-single={images.length === 1 ? 'true' : undefined}>
13
15
  {images.map((img) => (
14
16
  <figure class="b-gallery__item">
15
17
  <img src={img.src} alt={img.alt ?? ''} loading="lazy" decoding="async" />
@@ -27,6 +29,8 @@ const { columns = 3, images = [] } = Astro.props;
27
29
  margin-inline: auto;
28
30
  padding-inline: 1rem;
29
31
  }
32
+ /* A solitary image is a centred feature, not a full-bleed slab. */
33
+ .b-gallery[data-single] { max-width: 34rem; }
30
34
  @media (min-width: 48em) {
31
35
  .b-gallery[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
32
36
  .b-gallery[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
@@ -25,6 +25,9 @@ interface Props {
25
25
  items?: Item[];
26
26
  }
27
27
  const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astro.props;
28
+ // A single location shouldn't sit in one half of a two-column grid; `data-single`
29
+ // centres a lone tile. (A deliberate columns=1 with several items is untouched.)
30
+ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2;
28
31
  ---
29
32
 
30
33
  <section class="b-locations">
@@ -34,9 +37,9 @@ const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astr
34
37
  {intro && <p class="b-locations__intro">{intro}</p>}
35
38
  </div>
36
39
  )}
37
- <div class="b-locations__grid" data-cols={columns}>
40
+ <div class="b-locations__grid" data-cols={cols} data-single={items.length === 1 ? 'true' : undefined}>
38
41
  {items.map((item) => (
39
- <article class="b-locations__card">
42
+ <article class="b-locations__card ferst-tile ferst-tile--sm">
40
43
  <div class="b-locations__map">
41
44
  {isSafeEmbedUrl(item.embedUrl) ? (
42
45
  <div class="b-loc-map" data-embed-url={item.embedUrl} data-title={`Map — ${item.name}`}>
@@ -65,34 +68,18 @@ const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astr
65
68
  </section>
66
69
 
67
70
  <script>
68
- // Shared site consent: accepting the cookie banner auto-loads every map here.
69
- import { hasConsent, setConsent, onConsentChange } from '../../lib/consent-client';
71
+ // Shared consent-gated embed loader (see lib/embed-consent). Accepting the cookie
72
+ // banner or the footer panel auto-loads every map tile here; the per-tile button
73
+ // is a fallback that grants the same shared consent.
74
+ import { initEmbedConsent, makeEmbedFrame } from '../../lib/embed-consent';
70
75
 
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));
76
+ initEmbedConsent({
77
+ root: '.b-loc-map',
78
+ frame: '.b-loc-map__frame',
79
+ consent: '.b-loc-map__consent',
80
+ button: '.b-loc-map__load',
81
+ buildFrame: (el, url) =>
82
+ makeEmbedFrame(url, { title: el.dataset.title || 'Map', height: '100%', referrer: true }),
96
83
  });
97
84
  </script>
98
85
 
@@ -108,16 +95,15 @@ const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astr
108
95
  .b-locations__head { display: flex; flex-direction: column; gap: 0.6rem; }
109
96
  .b-locations__intro { margin: 0; color: var(--muted); max-width: var(--max); }
110
97
  .b-locations__grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
98
+ /* A solitary location is a centred feature, not a full-width slab. */
99
+ .b-locations__grid[data-single] { max-width: 32rem; margin-inline: auto; }
111
100
  @media (min-width: 44em) {
112
101
  .b-locations__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
113
102
  }
114
103
  .b-locations__card {
115
104
  display: flex;
116
105
  flex-direction: column;
117
- background: var(--bg-surface);
118
- border: var(--card-border);
119
- border-radius: var(--radius);
120
- box-shadow: var(--shadow-sm);
106
+ /* surface comes from .ferst-tile .ferst-tile--sm (bg/border/radius/shadow-sm) */
121
107
  overflow: hidden;
122
108
  }
123
109
  /* Map region — uniform ratio; the consent card fills it until the iframe loads. */
@@ -71,37 +71,22 @@ const safe = isSafeEmbedUrl(embedUrl);
71
71
  </section>
72
72
 
73
73
  <script>
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';
74
+ // Shared consent-gated embed loader (see lib/embed-consent). Accepting the cookie
75
+ // banner or the footer panel auto-loads every embed; the per-embed button is a
76
+ // fallback that grants the same shared consent.
77
+ import { initEmbedConsent, makeEmbedFrame } from '../../lib/embed-consent';
78
78
 
79
- const embeds = document.querySelectorAll<HTMLElement>('.b-map__embed');
80
-
81
- function load(el: HTMLElement) {
82
- if (el.dataset.loaded === 'true') return;
83
- const url = el.dataset.embedUrl;
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';
96
- const consent = el.querySelector<HTMLElement>('.b-map__consent');
97
- if (consent) consent.hidden = true;
98
- }
99
- const loadAll = () => embeds.forEach(load);
100
-
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));
79
+ initEmbedConsent({
80
+ root: '.b-map__embed',
81
+ frame: '.b-map__frame',
82
+ consent: '.b-map__consent',
83
+ button: '.b-map__load',
84
+ buildFrame: (el, url) =>
85
+ makeEmbedFrame(url, {
86
+ title: el.dataset.title || 'Map',
87
+ height: (el.dataset.height || '450') + 'px',
88
+ referrer: true,
89
+ }),
105
90
  });
106
91
  </script>
107
92
 
@@ -28,6 +28,8 @@ interface Props {
28
28
  cards?: Card[];
29
29
  }
30
30
  const { title, intro, columns = 3, cards = [] } = Astro.props;
31
+ // Never reserve more columns than there are cards; `data-single` centres a lone one.
32
+ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
31
33
  ---
32
34
 
33
35
  <section class="b-media-cards">
@@ -37,9 +39,14 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
37
39
  {intro && <p class="b-media-cards__intro">{intro}</p>}
38
40
  </div>
39
41
  )}
40
- <div class="b-media-cards__grid" data-cols={columns}>
41
- {cards.map((card) => (
42
- <article class:list={['b-media-cards__card', card.href && 'is-link']}>
42
+ <div class="b-media-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
43
+ {cards.map((card) => {
44
+ // Surface a mailto:/tel: as visible, copyable text — a plain mailto does
45
+ // nothing on a device with no mail app, so the address must be readable too.
46
+ const email = card.href?.startsWith('mailto:') ? card.href.slice(7).split('?')[0] : null;
47
+ const phone = card.href?.startsWith('tel:') ? card.href.slice(4) : null;
48
+ return (
49
+ <article class:list={['b-media-cards__card', 'ferst-tile', 'ferst-tile--sm', card.href && 'is-link']}>
43
50
  {card.image && (
44
51
  <div class="b-media-cards__media">
45
52
  <img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" />
@@ -54,12 +61,16 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
54
61
  </h3>
55
62
  {card.meta && <p class="b-media-cards__meta">{card.meta}</p>}
56
63
  {card.text && <p class="b-media-cards__text">{card.text}</p>}
57
- {card.href && (
64
+ {(email || phone) && (
65
+ <span class="b-media-cards__contact">{email ?? phone}</span>
66
+ )}
67
+ {card.href && !email && !phone && (
58
68
  <span class="b-media-cards__cta" aria-hidden="true">{card.linkLabel ?? 'Learn more'} →</span>
59
69
  )}
60
70
  </div>
61
71
  </article>
62
- ))}
72
+ );
73
+ })}
63
74
  </div>
64
75
  </section>
65
76
 
@@ -89,6 +100,8 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
89
100
  grid-template-columns: 1fr;
90
101
  gap: 1.5rem;
91
102
  }
103
+ /* A solitary card is a centred feature, not a full-width slab. */
104
+ .b-media-cards__grid[data-single] { max-width: 28rem; margin-inline: auto; }
92
105
  @media (min-width: 40em) {
93
106
  .b-media-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
94
107
  .b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(2, 1fr); }
@@ -100,10 +113,7 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
100
113
  position: relative;
101
114
  display: flex;
102
115
  flex-direction: column;
103
- background: var(--bg-surface);
104
- border: var(--card-border);
105
- border-radius: var(--radius);
106
- box-shadow: var(--shadow-sm);
116
+ /* surface comes from .ferst-tile .ferst-tile--sm (bg/border/radius/shadow-sm) */
107
117
  overflow: hidden;
108
118
  transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
109
119
  }
@@ -189,4 +199,14 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
189
199
  font-weight: 600;
190
200
  color: var(--gold);
191
201
  }
202
+ /* A mailto/tel shown as visible, copyable text (below any description). */
203
+ .b-media-cards__contact {
204
+ margin-top: auto;
205
+ padding-top: 0.5rem;
206
+ font-family: var(--font-heading);
207
+ font-size: 0.85rem;
208
+ font-weight: 600;
209
+ color: var(--gold);
210
+ overflow-wrap: anywhere;
211
+ }
192
212
  </style>
@@ -32,7 +32,7 @@ const { title, intro, plans = [] } = Astro.props;
32
32
  )}
33
33
  <div class="b-pricing__grid" data-count={plans.length}>
34
34
  {plans.map((plan) => (
35
- <div class="b-pricing__plan" data-featured={plan.featured ? 'true' : 'false'}>
35
+ <div class="b-pricing__plan ferst-tile" data-featured={plan.featured ? 'true' : 'false'}>
36
36
  {plan.featured && <span class="b-pricing__badge">Most popular</span>}
37
37
  <p class="b-pricing__name">{plan.name}</p>
38
38
  <p class="b-pricing__price">
@@ -94,10 +94,8 @@ const { title, intro, plans = [] } = Astro.props;
94
94
  flex-direction: column;
95
95
  gap: 0.75rem;
96
96
  padding: 1.75rem;
97
- border-radius: var(--radius);
98
- border: var(--card-border);
99
- background: var(--bg-surface);
100
- box-shadow: var(--shadow-md);
97
+ /* surface (bg/border/radius/shadow) comes from .ferst-tile; the featured
98
+ variant below overrides border-colour + shadow at higher specificity. */
101
99
  }
102
100
  .b-pricing__plan[data-featured='true'] {
103
101
  border-color: var(--gold);
@@ -34,7 +34,7 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
34
34
  )}
35
35
  <div class="b-stats__grid" data-cols={cols}>
36
36
  {items.map((item) => (
37
- <div class="b-stats__item">
37
+ <div class="b-stats__item ferst-tile">
38
38
  <span class="b-stats__value">{item.value}</span>
39
39
  {item.label && <span class="b-stats__label">{item.label}</span>}
40
40
  {item.description && <span class="b-stats__desc">{item.description}</span>}
@@ -93,10 +93,7 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
93
93
  text-align: center;
94
94
  gap: 0.5rem;
95
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);
96
+ /* surface (bg/border/radius/shadow) comes from .ferst-tile */
100
97
  }
101
98
  .b-stats__value {
102
99
  font-family: var(--font-heading);
@@ -34,6 +34,8 @@ interface Props {
34
34
  tiles?: Tile[];
35
35
  }
36
36
  const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tiles = [] } = Astro.props;
37
+ // Never reserve more columns than there are tiles; `data-single` centres a lone one.
38
+ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
37
39
  ---
38
40
 
39
41
  <section class="b-tiles">
@@ -43,7 +45,7 @@ const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tile
43
45
  {intro && <p class="b-tiles__intro">{intro}</p>}
44
46
  </div>
45
47
  )}
46
- <div class="b-tiles__grid" data-cols={columns} data-variant={variant} data-spotlight={spotlight ? 'true' : 'false'}>
48
+ <div class="b-tiles__grid" data-cols={cols} data-single={tiles.length === 1 ? 'true' : undefined} data-variant={variant} data-spotlight={spotlight ? 'true' : 'false'}>
47
49
  {tiles.map((tile) => {
48
50
  const Tag = tile.href ? 'a' : 'div';
49
51
  const bg =
@@ -93,6 +95,8 @@ const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tile
93
95
  grid-template-columns: 1fr;
94
96
  gap: 1.25rem;
95
97
  }
98
+ /* A solitary tile is a centred feature, not a full-width slab. */
99
+ .b-tiles__grid[data-single] { max-width: 26rem; margin-inline: auto; }
96
100
  @media (min-width: 48em) {
97
101
  .b-tiles__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
98
102
  .b-tiles__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Cookie / third-party-storage registry + a scanner that reports which uses are
3
+ * actually active on a given site. This powers the auto-generated cookie policy
4
+ * page: its categories and items are DERIVED from the recipes/templates the site
5
+ * uses — functionality-driven, never hand-written or client-specific.
6
+ *
7
+ * Two canonical categories:
8
+ * • necessary — the site's OWN functional storage (no third parties). Always
9
+ * on; can't be switched off. Present on every site.
10
+ * • additional — third-party embeds that may set the provider's cookies when
11
+ * they load. Gated behind the site's shared embed consent, and
12
+ * listed only when a recipe/template that uses them is present.
13
+ *
14
+ * Adding a new cookie-using recipe = one entry here (its `blocks` and/or a
15
+ * detection flag); the policy page then lists it automatically wherever it's used.
16
+ * Pure + framework-free so it's unit-testable without a render.
17
+ */
18
+
19
+ export type CookieCategory = 'necessary' | 'additional';
20
+
21
+ export interface CookieUse {
22
+ /** Stable id (also the consent grouping key in future per-item models). */
23
+ id: string;
24
+ category: CookieCategory;
25
+ /** Human name shown on the policy page, e.g. "Google Maps". */
26
+ name: string;
27
+ /** Third-party provider, when applicable. */
28
+ provider?: string;
29
+ /** One-line plain-English purpose. */
30
+ purpose: string;
31
+ /** What is stored and where. */
32
+ storage: string;
33
+ }
34
+
35
+ /** Additional uses carry detection rules; stripped before returning to the view. */
36
+ interface AdditionalUse extends CookieUse {
37
+ /** Present if any of these block `type`s appears anywhere on the site. */
38
+ blocks?: string[];
39
+ /** Present if the site has a Google Calendar configured (a dedicated page). */
40
+ calendar?: boolean;
41
+ }
42
+
43
+ /** The site's own functional storage — always present, always on. */
44
+ const NECESSARY: readonly CookieUse[] = [
45
+ {
46
+ id: 'consent-choice',
47
+ category: 'necessary',
48
+ name: 'Cookie preference',
49
+ purpose: 'Remembers whether you allowed or declined additional content, so you are not asked again.',
50
+ storage: 'Stored on your device only (local storage), never shared.',
51
+ },
52
+ {
53
+ id: 'theme',
54
+ category: 'necessary',
55
+ name: 'Appearance',
56
+ purpose: 'Remembers your light or dark theme choice.',
57
+ storage: 'Stored on your device only (local storage), never shared.',
58
+ },
59
+ ];
60
+
61
+ /** Third-party embeds — listed only when a site actually uses them. */
62
+ const ADDITIONAL: readonly AdditionalUse[] = [
63
+ {
64
+ id: 'google-maps',
65
+ category: 'additional',
66
+ name: 'Google Maps',
67
+ provider: 'Google',
68
+ purpose: 'Shows embedded maps of where to find us. Google may set cookies when a map loads.',
69
+ storage: 'Third-party cookies set by Google.',
70
+ blocks: ['locations', 'mapEmbed'],
71
+ },
72
+ {
73
+ id: 'google-calendar',
74
+ category: 'additional',
75
+ name: 'Google Calendar',
76
+ provider: 'Google',
77
+ purpose: 'Shows the parish calendar. Google may set cookies when the calendar loads.',
78
+ storage: 'Third-party cookies set by Google.',
79
+ blocks: ['calendar'],
80
+ calendar: true,
81
+ },
82
+ ];
83
+
84
+ export interface CookieScanInput {
85
+ /** Every block `type` that appears anywhere on the site. */
86
+ blockTypes: Iterable<string>;
87
+ /** True when the site has a Google Calendar configured (dedicated calendar page). */
88
+ calendarConfigured?: boolean;
89
+ }
90
+
91
+ export interface CookiePolicyModel {
92
+ necessary: CookieUse[];
93
+ additional: CookieUse[];
94
+ }
95
+
96
+ /** Report the cookie uses actually active on a site, grouped by category. */
97
+ export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
98
+ const types = new Set(input.blockTypes);
99
+ const additional = ADDITIONAL.filter(
100
+ (u) => (u.blocks?.some((b) => types.has(b)) ?? false) || (u.calendar === true && !!input.calendarConfigured),
101
+ ).map(({ blocks, calendar, ...use }) => use); // drop detection fields from the view model
102
+ return { necessary: NECESSARY.map((u) => ({ ...u })), additional };
103
+ }
@@ -142,6 +142,35 @@ const metaDescription = description ?? identity.description;
142
142
  }
143
143
  }) as EventListener);
144
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
+ if (url.host === host) return; // same-site
164
+ if (!a.target) a.target = '_blank';
165
+ const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
166
+ rel.add('noopener');
167
+ rel.add('noreferrer');
168
+ a.setAttribute('rel', Array.from(rel).join(' '));
169
+ });
170
+ }
171
+ ferstMarkExternalLinks();
172
+ document.addEventListener('astro:page-load', ferstMarkExternalLinks);
173
+ </script>
145
174
  </head>
146
175
  <body>
147
176
  <SiteHeader
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared client-side loader for consent-gated third-party embeds (Google Calendar,
3
+ * Google Maps, the Locations tiles). One implementation, so every embed gates,
4
+ * loads and reacts to consent identically — instead of the same ~25-line script
5
+ * copied into each component (where a fix, or a bug, lands in only one copy).
6
+ *
7
+ * Behaviour: nothing loads until the visitor consents. Accepting the site cookie
8
+ * banner (or the footer preferences panel) grants the shared consent and
9
+ * auto-loads EVERY registered embed on the page; the per-embed button is a
10
+ * fallback that grants that same shared consent. Consent is remembered per
11
+ * visitor. Import from a component `<script>` (it touches the DOM).
12
+ *
13
+ * Each embed root element carries the data the loader needs:
14
+ * data-embed-url the https URL to load (required)
15
+ * data-title the iframe title (optional)
16
+ * data-height pixel height, for fixed-height embeds (optional)
17
+ * and contains a `frame` slot (filled with the iframe), a `consent` card (hidden
18
+ * once loaded) and a `button` that grants consent.
19
+ */
20
+ import { hasConsent, setConsent, onConsentChange } from './consent-client';
21
+
22
+ export interface EmbedConsentConfig {
23
+ /** Selector for each embed root (one per embed on the page). */
24
+ root: string;
25
+ /** Selector, within a root, of the empty slot the iframe is injected into. */
26
+ frame: string;
27
+ /** Selector, within a root, of the consent card to hide once loaded. */
28
+ consent: string;
29
+ /** Selector, within a root, of the button that grants consent. */
30
+ button: string;
31
+ /** Build the iframe for one root — lets each embed set its own src / size / attrs. */
32
+ buildFrame: (el: HTMLElement, url: string) => HTMLIFrameElement;
33
+ }
34
+
35
+ /** Wire up every consent-gated embed matching `cfg.root`. Call once per component. */
36
+ export function initEmbedConsent(cfg: EmbedConsentConfig): void {
37
+ const roots = document.querySelectorAll<HTMLElement>(cfg.root);
38
+ if (!roots.length) return;
39
+
40
+ function load(el: HTMLElement): void {
41
+ if (el.dataset.loaded === 'true') return;
42
+ const url = el.dataset.embedUrl;
43
+ const slot = el.querySelector<HTMLElement>(cfg.frame);
44
+ if (!url || !slot) return;
45
+ slot.replaceChildren(cfg.buildFrame(el, url));
46
+ el.dataset.loaded = 'true';
47
+ const card = el.querySelector<HTMLElement>(cfg.consent);
48
+ if (card) card.hidden = true;
49
+ }
50
+ const loadAll = (): void => roots.forEach(load);
51
+
52
+ if (hasConsent()) loadAll();
53
+ onConsentChange((allowed) => { if (allowed) loadAll(); });
54
+ roots.forEach((el) => {
55
+ el.querySelector<HTMLButtonElement>(cfg.button)?.addEventListener('click', () => setConsent(true));
56
+ });
57
+ }
58
+
59
+ /** Build a standard lazy embed iframe (100% width, 0 border). */
60
+ export function makeEmbedFrame(
61
+ src: string,
62
+ opts: { title?: string; height?: string; referrer?: boolean } = {},
63
+ ): HTMLIFrameElement {
64
+ const f = document.createElement('iframe');
65
+ f.src = src;
66
+ if (opts.title) f.title = opts.title;
67
+ f.loading = 'lazy';
68
+ if (opts.referrer) f.referrerPolicy = 'no-referrer-when-downgrade';
69
+ f.style.width = '100%';
70
+ f.style.height = opts.height ?? '100%';
71
+ f.style.border = '0';
72
+ return f;
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.4.2",
3
+ "version": "0.4.7",
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",
package/styles/theme.css CHANGED
@@ -130,3 +130,29 @@
130
130
  --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.45);
131
131
  --accent-2: color-mix(in oklch, var(--gold) 82%, #fff);
132
132
  }
133
+
134
+ /* ── Global utilities ────────────────────────────────────────────────────────
135
+ Unscoped helpers usable from any component — including from inside an Astro
136
+ scoped style, where a parent can't reach a child element's root. */
137
+
138
+ /* Overlay convention: an element hidden via the `hidden` attribute must never
139
+ linger in the layout intercepting pointer events. An author `display` rule
140
+ (e.g. `display:flex` on a fixed banner) otherwise beats the UA `[hidden]`
141
+ rule, leaving an invisible click-blocker — the cookie-banner / map-tile
142
+ footgun we hit twice. This is the site-wide safety net; components need no
143
+ per-element guard of their own. */
144
+ [hidden] { display: none !important; }
145
+
146
+ /* Card/tile surface — the single home for the "tile" look, driven by the global
147
+ surface knobs (--radius / --card-border / --shadow-*) so every tile restyles
148
+ at once, in both themes. Add `.ferst-tile` to a recipe's card element instead
149
+ of re-inlining these four properties; `.ferst-tile--sm` swaps to the lighter
150
+ shadow for denser grids. Recipes with a genuinely different surface (image /
151
+ gradient tiles, tinted panels) keep their own styling. */
152
+ .ferst-tile {
153
+ background: var(--bg-surface);
154
+ border: var(--card-border);
155
+ border-radius: var(--radius);
156
+ box-shadow: var(--shadow-md);
157
+ }
158
+ .ferst-tile--sm { box-shadow: var(--shadow-sm); }