ferst-core 0.3.0 → 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.
- package/components/BlockRenderer.astro +4 -0
- package/components/CalendarEmbed.astro +17 -1
- package/components/PostCard.astro +10 -4
- package/components/recipes/ContactCard.astro +145 -0
- package/components/recipes/LatestPosts.astro +19 -14
- package/components/recipes/Locations.astro +150 -0
- package/content/blocks.ts +46 -1
- package/content/posts.ts +17 -0
- package/content/schemas.ts +16 -0
- package/lib/posts.ts +4 -0
- package/lib/themeTokens.ts +77 -22
- package/package.json +1 -1
|
@@ -42,6 +42,8 @@ import MapEmbed from './recipes/MapEmbed.astro';
|
|
|
42
42
|
import Table from './recipes/Table.astro';
|
|
43
43
|
import Notice from './recipes/Notice.astro';
|
|
44
44
|
import MediaCards from './recipes/MediaCards.astro';
|
|
45
|
+
import ContactCard from './recipes/ContactCard.astro';
|
|
46
|
+
import Locations from './recipes/Locations.astro';
|
|
45
47
|
|
|
46
48
|
interface Props {
|
|
47
49
|
blocks: Block[];
|
|
@@ -86,6 +88,8 @@ const registry: Record<BlockType, any> = {
|
|
|
86
88
|
table: Table,
|
|
87
89
|
notice: Notice,
|
|
88
90
|
mediaCards: MediaCards,
|
|
91
|
+
contactCard: ContactCard,
|
|
92
|
+
locations: Locations,
|
|
89
93
|
};
|
|
90
94
|
---
|
|
91
95
|
|
|
@@ -84,13 +84,29 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
|
|
|
84
84
|
|
|
85
85
|
const embeds = document.querySelectorAll<HTMLElement>('.b-calendar__embed');
|
|
86
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
|
+
|
|
87
103
|
function load(el: HTMLElement) {
|
|
88
104
|
if (el.dataset.loaded === 'true') return;
|
|
89
105
|
const url = el.dataset.embedUrl;
|
|
90
106
|
const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
|
|
91
107
|
if (!url || !frameSlot) return;
|
|
92
108
|
const frame = document.createElement('iframe');
|
|
93
|
-
frame.src = url;
|
|
109
|
+
frame.src = themedCalendarUrl(url);
|
|
94
110
|
frame.title = el.dataset.title || 'Calendar';
|
|
95
111
|
frame.loading = 'lazy';
|
|
96
112
|
frame.style.width = '100%';
|
|
@@ -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">
|
|
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>
|
|
@@ -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 =
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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>
|
package/content/blocks.ts
CHANGED
|
@@ -480,6 +480,47 @@ export const mediaCardsBlock = z.object({
|
|
|
480
480
|
.default([]),
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
/** ContactCard — a branded "get in touch" band: display title + subtitle on the
|
|
484
|
+
* left, contact channels as tappable pills on the right, a note across the bottom. */
|
|
485
|
+
export const contactCardBlock = z.object({
|
|
486
|
+
type: z.literal('contactCard'),
|
|
487
|
+
enabled: z.boolean().default(true),
|
|
488
|
+
eyebrow: z.string().optional(),
|
|
489
|
+
title: z.string(),
|
|
490
|
+
subtitle: z.string().optional(),
|
|
491
|
+
note: z.string().optional(),
|
|
492
|
+
items: z
|
|
493
|
+
.array(
|
|
494
|
+
z.object({
|
|
495
|
+
icon: z.string().optional(),
|
|
496
|
+
value: z.string(),
|
|
497
|
+
href: z.string().optional(),
|
|
498
|
+
})
|
|
499
|
+
)
|
|
500
|
+
.default([]),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
/** Locations ("Where to find us") — location tiles, each a consent-gated map over
|
|
504
|
+
* a name / address / directions. https embed URLs only (isSafeEmbedUrl). */
|
|
505
|
+
export const locationsBlock = z.object({
|
|
506
|
+
type: z.literal('locations'),
|
|
507
|
+
enabled: z.boolean().default(true),
|
|
508
|
+
title: z.string().optional(),
|
|
509
|
+
intro: z.string().optional(),
|
|
510
|
+
columns: z.union([z.literal(1), z.literal(2)]).default(2),
|
|
511
|
+
provider: z.string().default('Google Maps'),
|
|
512
|
+
items: z
|
|
513
|
+
.array(
|
|
514
|
+
z.object({
|
|
515
|
+
name: z.string(),
|
|
516
|
+
address: z.string().optional(),
|
|
517
|
+
embedUrl: z.string(),
|
|
518
|
+
directionsUrl: z.string().optional(),
|
|
519
|
+
})
|
|
520
|
+
)
|
|
521
|
+
.default([]),
|
|
522
|
+
});
|
|
523
|
+
|
|
483
524
|
export type RecipeBlock =
|
|
484
525
|
| z.infer<typeof heroBlock>
|
|
485
526
|
| z.infer<typeof featureCardsBlock>
|
|
@@ -503,7 +544,9 @@ export type RecipeBlock =
|
|
|
503
544
|
| z.infer<typeof mapEmbedBlock>
|
|
504
545
|
| z.infer<typeof tableBlock>
|
|
505
546
|
| z.infer<typeof noticeBlock>
|
|
506
|
-
| z.infer<typeof mediaCardsBlock
|
|
547
|
+
| z.infer<typeof mediaCardsBlock>
|
|
548
|
+
| z.infer<typeof contactCardBlock>
|
|
549
|
+
| z.infer<typeof locationsBlock>;
|
|
507
550
|
|
|
508
551
|
/* ── Level-1 layout / container primitives ─────────────────────────────────
|
|
509
552
|
* These hold child blocks, so the schema is **recursive** (a container's
|
|
@@ -612,6 +655,8 @@ export const blockSchema = z.discriminatedUnion('type', [
|
|
|
612
655
|
tableBlock,
|
|
613
656
|
noticeBlock,
|
|
614
657
|
mediaCardsBlock,
|
|
658
|
+
contactCardBlock,
|
|
659
|
+
locationsBlock,
|
|
615
660
|
sectionBlock,
|
|
616
661
|
gridBlock,
|
|
617
662
|
stackBlock,
|
package/content/posts.ts
CHANGED
|
@@ -88,6 +88,23 @@ export function tagCounts(posts: Array<{ data: { tags?: string[] } }>): Record<s
|
|
|
88
88
|
return counts;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Where a post card/row should link. Normally the post's own page — but a post
|
|
93
|
+
* that is just an attached file with no body (e.g. a newsletter that's only a PDF)
|
|
94
|
+
* links straight to the file, opened in a new tab, so there's no empty post page
|
|
95
|
+
* in between. Pure; the caller passes the entry's `body` (raw markdown) so
|
|
96
|
+
* "has text" is decided here.
|
|
97
|
+
*/
|
|
98
|
+
export function postLink(
|
|
99
|
+
post: { slug: string; body?: string | null; data: { attachment?: string } },
|
|
100
|
+
base = '/posts',
|
|
101
|
+
): { href: string; newTab: boolean } {
|
|
102
|
+
const attachment = post.data.attachment;
|
|
103
|
+
const hasBody = !!post.body && post.body.trim() !== '';
|
|
104
|
+
if (attachment && !hasBody) return { href: attachment, newTab: true };
|
|
105
|
+
return { href: `${base}/${post.slug}`, newTab: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
/**
|
|
92
109
|
* Whether a post date is within the last `days` of `now` — used to flag a "New"
|
|
93
110
|
* badge in the compact latest-posts list. `days <= 0` disables it; a future or
|
package/content/schemas.ts
CHANGED
|
@@ -126,6 +126,22 @@ export const themeSettingsSchema = z.object({
|
|
|
126
126
|
stroke: z.enum(['on', 'off']).default('on'),
|
|
127
127
|
/** Input treatment — a single site-wide choice (a `data-field` attr on <html>). */
|
|
128
128
|
fields: z.enum(['boxed', 'filled', 'underline']).default('boxed'),
|
|
129
|
+
/** Dark-mode formula knobs (root-derivation model). `darkDepth` = how light the
|
|
130
|
+
* dark page background sits (OKLCH L); `darkChroma` = the cap on how much colour
|
|
131
|
+
* the dark ground may carry. Both feed the AUTOMATIC dark roots. */
|
|
132
|
+
darkDepth: z.number().min(0.12).max(0.4).default(0.21),
|
|
133
|
+
darkChroma: z.number().min(0).max(0.15).default(0.05),
|
|
134
|
+
/** Explicit overrides for the three dark ROOTS. Each blank ⇒ the root is derived
|
|
135
|
+
* from its light source by the formula; set ⇒ every dark token in that role
|
|
136
|
+
* re-derives from this colour. Roots only (ground / text / accent) — never a
|
|
137
|
+
* lone downstream token — so the palette can never fall out of sync. */
|
|
138
|
+
darkOverrides: z
|
|
139
|
+
.object({
|
|
140
|
+
ground: z.string().default(''),
|
|
141
|
+
text: z.string().default(''),
|
|
142
|
+
accent: z.string().default(''),
|
|
143
|
+
})
|
|
144
|
+
.default({}),
|
|
129
145
|
/** Raw per-token overrides, layered last. Rarely needed. */
|
|
130
146
|
advanced: z
|
|
131
147
|
.object({
|
package/lib/posts.ts
CHANGED
|
@@ -24,6 +24,10 @@ import {
|
|
|
24
24
|
type PostsSettings,
|
|
25
25
|
} from '../content/posts';
|
|
26
26
|
|
|
27
|
+
// Re-export the pure link helper so clients can import it alongside the data
|
|
28
|
+
// helpers (e.g. a custom NewsList) from one place.
|
|
29
|
+
export { postLink } from '../content/posts';
|
|
30
|
+
|
|
27
31
|
export type PostEntry = CollectionEntry<'posts'>;
|
|
28
32
|
|
|
29
33
|
/** A tag resolved for display: its slug + label/description + how many posts use it. */
|
package/lib/themeTokens.ts
CHANGED
|
@@ -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
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'--
|
|
86
|
-
'--text
|
|
87
|
-
'--
|
|
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"]{${
|
|
209
|
+
themed ? `:root:root[data-theme="dark"]{${darkDeclarations(theme)}}` : '',
|
|
155
210
|
rawBlock(':root:root', theme.advanced?.light),
|
|
156
211
|
rawBlock(':root:root[data-theme="dark"]', theme.advanced?.dark),
|
|
157
212
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"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",
|