ferst-core 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -84,20 +84,15 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
|
|
|
84
84
|
|
|
85
85
|
const embeds = document.querySelectorAll<HTMLElement>('.b-calendar__embed');
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
// surround
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
87
|
+
// Google Calendar embeds render light-only — `bgcolor` recolours just the
|
|
88
|
+
// surround, never the grid, so matching it to a dark surface still left a white
|
|
89
|
+
// calendar sitting in the dark page. Instead we force a deterministic WHITE
|
|
90
|
+
// surround here (independent of the theme at load) and, in dark mode, invert the
|
|
91
|
+
// whole frame in CSS (below). The invert is keyed on the site's [data-theme], so
|
|
92
|
+
// it also flips live when the visitor toggles the theme.
|
|
93
|
+
function calendarUrl(url: string): string {
|
|
92
94
|
if (!/google\.com\/calendar|calendar\.google\.com/.test(url)) return url;
|
|
93
|
-
|
|
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;
|
|
95
|
+
return url + (url.includes('?') ? '&' : '?') + 'bgcolor=%23ffffff';
|
|
101
96
|
}
|
|
102
97
|
|
|
103
98
|
function load(el: HTMLElement) {
|
|
@@ -106,7 +101,7 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
|
|
|
106
101
|
const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
|
|
107
102
|
if (!url || !frameSlot) return;
|
|
108
103
|
const frame = document.createElement('iframe');
|
|
109
|
-
frame.src =
|
|
104
|
+
frame.src = calendarUrl(url);
|
|
110
105
|
frame.title = el.dataset.title || 'Calendar';
|
|
111
106
|
frame.loading = 'lazy';
|
|
112
107
|
frame.style.width = '100%';
|
|
@@ -151,7 +146,13 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
|
|
|
151
146
|
display: block;
|
|
152
147
|
border: var(--card-border);
|
|
153
148
|
border-radius: var(--radius);
|
|
154
|
-
background:
|
|
149
|
+
background: #fff;
|
|
150
|
+
}
|
|
151
|
+
/* Google Calendar is light-only; in dark mode invert the frame so it reads as a
|
|
152
|
+
dark calendar (hue-rotate keeps event colours roughly true). Keyed on the
|
|
153
|
+
site's [data-theme] so it also flips live on a theme toggle. */
|
|
154
|
+
:global([data-theme='dark']) .b-calendar__frame :global(iframe) {
|
|
155
|
+
filter: invert(1) hue-rotate(180deg);
|
|
155
156
|
}
|
|
156
157
|
/* Consent card stands in for the calendar until the visitor opts in. */
|
|
157
158
|
.b-calendar__consent {
|
|
@@ -48,6 +48,16 @@ const { policyHref = '/cookie-policy/' } = Astro.props;
|
|
|
48
48
|
</script>
|
|
49
49
|
|
|
50
50
|
<style is:global>
|
|
51
|
+
/* `display: flex` below is an author rule and would beat the UA `[hidden] {
|
|
52
|
+
display: none }`, so the banner would stay in the layout while dismissed (or
|
|
53
|
+
for returning visitors, where it starts hidden) — an invisible opacity:0
|
|
54
|
+
fixed bar that still INTERCEPTS pointer events over whatever sits at the
|
|
55
|
+
viewport bottom (e.g. the footer's later links). This explicit rule makes
|
|
56
|
+
`hidden` truly remove it. Must come before `.cookie-banner` so specificity,
|
|
57
|
+
not order, decides — (0,1,1) beats (0,1,0). */
|
|
58
|
+
.cookie-banner[hidden] {
|
|
59
|
+
display: none;
|
|
60
|
+
}
|
|
51
61
|
.cookie-banner {
|
|
52
62
|
position: fixed;
|
|
53
63
|
left: 20px;
|
|
@@ -190,11 +190,17 @@ const year = new Date().getFullYear();
|
|
|
190
190
|
text-align: center;
|
|
191
191
|
gap: 0.6rem 1.5rem;
|
|
192
192
|
}
|
|
193
|
+
/* Tight gap: each link carries its own inline padding (below), so the visible
|
|
194
|
+
hit target is a clear region *centred on its label* — no wide dead gap between
|
|
195
|
+
labels where a hover lands on nothing (and the browser keeps showing the
|
|
196
|
+
previous link's URL, which reads as an off-by-one). The negative inline margin
|
|
197
|
+
cancels the first/last item's padding so the row still edge-aligns. */
|
|
193
198
|
.site-footer .footer-nav {
|
|
194
199
|
display: flex;
|
|
195
200
|
flex-wrap: wrap;
|
|
196
201
|
justify-content: center;
|
|
197
|
-
gap: 0.
|
|
202
|
+
gap: 0.15rem 0.15rem;
|
|
203
|
+
margin-inline: -0.55rem;
|
|
198
204
|
}
|
|
199
205
|
.site-footer .footer-nav a,
|
|
200
206
|
.site-footer .footer-nav button {
|
|
@@ -208,13 +214,32 @@ const year = new Date().getFullYear();
|
|
|
208
214
|
color: var(--accent);
|
|
209
215
|
background: none;
|
|
210
216
|
border: none;
|
|
211
|
-
|
|
217
|
+
border-radius: var(--radius-sm, 6px);
|
|
218
|
+
padding: 0 0.55rem;
|
|
212
219
|
cursor: pointer;
|
|
220
|
+
text-decoration: none;
|
|
221
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
222
|
+
}
|
|
223
|
+
/* Visible hover/focus affordance: makes each link's boundary unmistakable, so
|
|
224
|
+
it's always clear which one you're on. */
|
|
225
|
+
.site-footer .footer-nav a:hover,
|
|
226
|
+
.site-footer .footer-nav button:hover,
|
|
227
|
+
.site-footer .footer-nav a:focus-visible,
|
|
228
|
+
.site-footer .footer-nav button:focus-visible {
|
|
229
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
230
|
+
text-decoration: underline;
|
|
231
|
+
text-underline-offset: 3px;
|
|
213
232
|
}
|
|
214
233
|
[data-theme="dark"] .site-footer .footer-nav a,
|
|
215
234
|
[data-theme="dark"] .site-footer .footer-nav button {
|
|
216
235
|
color: var(--gold);
|
|
217
236
|
}
|
|
237
|
+
[data-theme="dark"] .site-footer .footer-nav a:hover,
|
|
238
|
+
[data-theme="dark"] .site-footer .footer-nav button:hover,
|
|
239
|
+
[data-theme="dark"] .site-footer .footer-nav a:focus-visible,
|
|
240
|
+
[data-theme="dark"] .site-footer .footer-nav button:focus-visible {
|
|
241
|
+
background: color-mix(in srgb, var(--gold) 16%, transparent);
|
|
242
|
+
}
|
|
218
243
|
.site-footer .footer-copy {
|
|
219
244
|
margin: 0;
|
|
220
245
|
font-family: var(--font-body);
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* ContactCard — a Level-2 recipe: a rich, branded "get in touch" band. A
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* or find-us page — the polished alternative to a plain notice box.
|
|
3
|
+
* ContactCard — a Level-2 recipe: a rich, branded "get in touch" band. A display-
|
|
4
|
+
* type title + subtitle on the left, the contact channels as tappable tiles on the
|
|
5
|
+
* right, and a closing note across the bottom. For a contact or find-us page — the
|
|
6
|
+
* polished alternative to a plain notice box.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Fully THEME-ADAPTIVE (composes in light AND dark): the panel is `--bg-section`
|
|
9
|
+
* with a soft `--gold` corner wash for brand presence, text is `--fg`/`--accent`/
|
|
10
|
+
* `--muted`, tiles are `--bg-surface` recesses, icons + hovers use `--gold`. Every
|
|
11
|
+
* one of those tokens flips per theme, so the band re-composes correctly in each
|
|
12
|
+
* mode (never the old always-dark band) and a CMS colour change recolours it.
|
|
13
|
+
* Contrast-safe: brand gold is used only decoratively (wash, rule, icons, hovers),
|
|
14
|
+
* never as body/label text — those use the ink/text tokens that meet AA on the
|
|
15
|
+
* panel in both themes. Whole tile links when `href` is set.
|
|
12
16
|
*/
|
|
13
17
|
import Icon from '../Icon.astro';
|
|
14
18
|
import { isIconKey } from '../../lib/icons';
|
|
@@ -72,27 +76,48 @@ const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank'
|
|
|
72
76
|
margin-inline: auto;
|
|
73
77
|
padding-inline: 1rem;
|
|
74
78
|
}
|
|
75
|
-
/*
|
|
76
|
-
|
|
79
|
+
/* Adaptive panel: a section surface that flips light↔dark with the theme, lifted
|
|
80
|
+
off the page by a border + shadow, with a soft brand-gold wash for presence. */
|
|
77
81
|
.b-contact__panel {
|
|
82
|
+
position: relative;
|
|
83
|
+
isolation: isolate;
|
|
84
|
+
overflow: hidden;
|
|
78
85
|
display: grid;
|
|
79
86
|
grid-template-columns: 1fr;
|
|
80
87
|
gap: 1.5rem 2.5rem;
|
|
81
88
|
padding: clamp(1.75rem, 4vw, 3rem);
|
|
82
89
|
border-radius: var(--radius);
|
|
83
|
-
background:
|
|
84
|
-
color: var(--
|
|
90
|
+
background: var(--bg-section);
|
|
91
|
+
color: var(--fg);
|
|
92
|
+
border: 1px solid var(--border);
|
|
85
93
|
box-shadow: var(--shadow-md);
|
|
86
94
|
}
|
|
95
|
+
/* Brand wash — a gold glow in the top-right, behind the content (z-index below the
|
|
96
|
+
panel's own children via isolation). Adds brand presence in both themes without
|
|
97
|
+
tinting the text ground, so contrast is never at risk. */
|
|
98
|
+
.b-contact__panel::before {
|
|
99
|
+
content: '';
|
|
100
|
+
position: absolute;
|
|
101
|
+
inset: 0;
|
|
102
|
+
z-index: -1;
|
|
103
|
+
background: radial-gradient(115% 130% at 100% 0%, color-mix(in srgb, var(--gold) 20%, transparent), transparent 62%);
|
|
104
|
+
pointer-events: none;
|
|
105
|
+
}
|
|
87
106
|
@media (min-width: 48rem) {
|
|
88
|
-
.b-contact__panel { grid-template-columns: 1.
|
|
107
|
+
.b-contact__panel { grid-template-columns: 1.2fr 1fr; align-items: center; }
|
|
89
108
|
}
|
|
90
109
|
.b-contact__head { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
91
110
|
.b-contact__eyebrow {
|
|
92
111
|
margin: 0;
|
|
112
|
+
display: inline-flex; align-items: center; gap: 0.6rem;
|
|
93
113
|
font-family: var(--font-heading);
|
|
94
114
|
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
|
|
95
|
-
color:
|
|
115
|
+
color: var(--accent);
|
|
116
|
+
}
|
|
117
|
+
/* Short gold rule carries the brand into the eyebrow without colouring the text. */
|
|
118
|
+
.b-contact__eyebrow::before {
|
|
119
|
+
content: ''; flex: 0 0 auto; width: 1.5rem; height: 2px; border-radius: 2px;
|
|
120
|
+
background: var(--gold);
|
|
96
121
|
}
|
|
97
122
|
.b-contact__title {
|
|
98
123
|
margin: 0;
|
|
@@ -100,13 +125,13 @@ const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank'
|
|
|
100
125
|
font-size: clamp(1.9rem, 4vw, 2.8rem);
|
|
101
126
|
line-height: 1.05;
|
|
102
127
|
text-wrap: balance;
|
|
103
|
-
color: var(--
|
|
128
|
+
color: var(--fg);
|
|
104
129
|
}
|
|
105
130
|
.b-contact__subtitle {
|
|
106
131
|
margin: 0.15rem 0 0;
|
|
107
132
|
font-family: var(--font-heading);
|
|
108
133
|
font-weight: 600;
|
|
109
|
-
color: var(--
|
|
134
|
+
color: var(--accent);
|
|
110
135
|
}
|
|
111
136
|
.b-contact__items {
|
|
112
137
|
list-style: none;
|
|
@@ -117,9 +142,9 @@ const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank'
|
|
|
117
142
|
display: flex; align-items: center; gap: 0.75rem;
|
|
118
143
|
padding: 0.7rem 1rem;
|
|
119
144
|
border-radius: var(--radius-sm);
|
|
120
|
-
background:
|
|
121
|
-
border: 1px solid
|
|
122
|
-
color: var(--
|
|
145
|
+
background: var(--bg-surface);
|
|
146
|
+
border: 1px solid var(--border);
|
|
147
|
+
color: var(--fg);
|
|
123
148
|
font-family: var(--font-heading);
|
|
124
149
|
font-weight: 600;
|
|
125
150
|
font-size: 0.95rem;
|
|
@@ -128,7 +153,7 @@ const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank'
|
|
|
128
153
|
}
|
|
129
154
|
a.b-contact__item:hover {
|
|
130
155
|
border-color: var(--gold);
|
|
131
|
-
background: color-mix(in srgb,
|
|
156
|
+
background: color-mix(in srgb, var(--gold) 8%, var(--bg-surface));
|
|
132
157
|
}
|
|
133
158
|
a.b-contact__item:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
134
159
|
.b-contact__icon { display: inline-flex; color: var(--gold); flex: 0 0 auto; }
|
|
@@ -137,8 +162,8 @@ const target = (href?: string) => (href && /^https?:\/\//.test(href) ? '_blank'
|
|
|
137
162
|
grid-column: 1 / -1;
|
|
138
163
|
margin: 0;
|
|
139
164
|
padding-top: 1.25rem;
|
|
140
|
-
border-top: 1px solid
|
|
141
|
-
color:
|
|
165
|
+
border-top: 1px solid var(--border);
|
|
166
|
+
color: var(--muted);
|
|
142
167
|
font-size: 0.9rem;
|
|
143
168
|
line-height: 1.6;
|
|
144
169
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
/** Stats — a Level-2 recipe: a band of headline numbers over an optional
|
|
3
|
-
* heading/intro. Composes the Heading primitive; numbers use the brand accent.
|
|
3
|
+
* heading/intro. Composes the Heading primitive; numbers use the brand accent.
|
|
4
|
+
*
|
|
5
|
+
* Self-composing: the grid never reserves more columns than there are stats, so
|
|
6
|
+
* a single item centres as a feature instead of stranding an empty half-band
|
|
7
|
+
* (the "£10 pinned to one side" bug); each stat is a token-driven tile (radius/
|
|
8
|
+
* surface/stroke/shadow knobs, like every Card), so it reads as a designed unit
|
|
9
|
+
* at any count and in either theme. Labels are width-capped so a long caption
|
|
10
|
+
* wraps to a tidy block rather than a full-width line. */
|
|
4
11
|
import Heading from '../blocks/Heading.astro';
|
|
5
12
|
|
|
6
13
|
interface Item { value: string; label: string; description?: string }
|
|
@@ -12,6 +19,9 @@ interface Props {
|
|
|
12
19
|
items?: Item[];
|
|
13
20
|
}
|
|
14
21
|
const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.props;
|
|
22
|
+
// Cap the column count at the item count — one stat should never sit in a
|
|
23
|
+
// two-column grid (half empty). Falls back to 1 for an empty list.
|
|
24
|
+
const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
|
|
15
25
|
---
|
|
16
26
|
|
|
17
27
|
<section class="b-stats" data-bg={background}>
|
|
@@ -22,11 +32,11 @@ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.pr
|
|
|
22
32
|
{intro && <p class="b-stats__intro">{intro}</p>}
|
|
23
33
|
</div>
|
|
24
34
|
)}
|
|
25
|
-
<div class="b-stats__grid" data-cols={
|
|
35
|
+
<div class="b-stats__grid" data-cols={cols}>
|
|
26
36
|
{items.map((item) => (
|
|
27
37
|
<div class="b-stats__item">
|
|
28
38
|
<span class="b-stats__value">{item.value}</span>
|
|
29
|
-
<span class="b-stats__label">{item.label}</span>
|
|
39
|
+
{item.label && <span class="b-stats__label">{item.label}</span>}
|
|
30
40
|
{item.description && <span class="b-stats__desc">{item.description}</span>}
|
|
31
41
|
</div>
|
|
32
42
|
))}
|
|
@@ -58,21 +68,35 @@ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.pr
|
|
|
58
68
|
.b-stats__grid {
|
|
59
69
|
display: grid;
|
|
60
70
|
grid-template-columns: 1fr;
|
|
61
|
-
gap: 1.
|
|
71
|
+
gap: 1.25rem;
|
|
72
|
+
width: 100%;
|
|
73
|
+
margin-inline: auto;
|
|
62
74
|
}
|
|
63
75
|
@media (min-width: 40em) {
|
|
64
|
-
.b-stats__grid { grid-template-columns: repeat(2, 1fr); }
|
|
76
|
+
.b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
77
|
+
.b-stats__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
78
|
+
.b-stats__grid[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
65
79
|
}
|
|
66
80
|
@media (min-width: 60em) {
|
|
67
|
-
.b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
|
|
68
|
-
.b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
|
|
81
|
+
.b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
82
|
+
.b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
69
83
|
}
|
|
84
|
+
/* A lone stat is a centred feature, not a full-width slab. */
|
|
85
|
+
.b-stats__grid[data-cols='1'] { max-width: 30rem; }
|
|
86
|
+
/* Token-driven tile: the same radius / surface / stroke / shadow knobs as every
|
|
87
|
+
Card, so it stays consistent and theme-correct. On the muted band the tile's
|
|
88
|
+
--bg-surface sits a step above --bg-section, giving depth in both themes. */
|
|
70
89
|
.b-stats__item {
|
|
71
90
|
display: flex;
|
|
72
91
|
flex-direction: column;
|
|
73
92
|
align-items: center;
|
|
74
93
|
text-align: center;
|
|
75
|
-
gap: 0.
|
|
94
|
+
gap: 0.5rem;
|
|
95
|
+
padding: clamp(1.5rem, 3vw, 2.25rem) clamp(1.25rem, 3vw, 1.75rem);
|
|
96
|
+
border-radius: var(--radius);
|
|
97
|
+
background: var(--bg-surface);
|
|
98
|
+
border: var(--card-border);
|
|
99
|
+
box-shadow: var(--shadow-md);
|
|
76
100
|
}
|
|
77
101
|
.b-stats__value {
|
|
78
102
|
font-family: var(--font-heading);
|
|
@@ -81,8 +105,8 @@ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.pr
|
|
|
81
105
|
line-height: 1;
|
|
82
106
|
color: var(--gold);
|
|
83
107
|
}
|
|
84
|
-
.b-stats__label { font-weight: 600; color: var(--fg); }
|
|
85
|
-
.b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; }
|
|
108
|
+
.b-stats__label { font-weight: 600; color: var(--fg); line-height: 1.4; max-width: 30ch; }
|
|
109
|
+
.b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; max-width: 32ch; }
|
|
86
110
|
</style>
|
|
87
111
|
|
|
88
112
|
<script>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
|
|
6
6
|
"license": "BUSL-1.1",
|