ferst-core 0.4.0 → 0.4.2

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
- // 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 {
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
- 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;
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 = themedCalendarUrl(url);
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: var(--bg-surface);
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.3rem 1rem;
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
- padding: 0;
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 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.
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
- * 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.
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
- /* 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. */
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: linear-gradient(135deg, var(--ink), color-mix(in srgb, var(--gold) 26%, var(--ink)));
84
- color: var(--text-light);
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.25fr 1fr; align-items: center; }
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: color-mix(in srgb, var(--text-light) 70%, transparent);
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(--text-light);
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(--gold);
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: color-mix(in srgb, #000 22%, transparent);
121
- border: 1px solid color-mix(in srgb, #fff 14%, transparent);
122
- color: var(--text-light);
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, #000 12%, transparent);
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 color-mix(in srgb, #fff 14%, transparent);
141
- color: color-mix(in srgb, var(--text-light) 78%, transparent);
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
  }
@@ -123,10 +123,17 @@ const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astr
123
123
  /* Map region — uniform ratio; the consent card fills it until the iframe loads. */
124
124
  .b-locations__map { position: relative; aspect-ratio: 4 / 3; background: var(--bg-section); }
125
125
  .b-loc-map, .b-loc-map__frame, .b-loc-map__consent, .b-loc-map__fallback { position: absolute; inset: 0; }
126
+ /* The frame slot sits AFTER the consent card (both inset:0), so while it's empty
127
+ it would paint on top and swallow clicks meant for the "Show map" button. Keep
128
+ an empty slot click-through; once the iframe is injected it's no longer :empty
129
+ and becomes interactive. The consent card is also lifted above it as a belt-
130
+ and-braces guard while it's visible. */
131
+ .b-loc-map__frame:empty { pointer-events: none; }
126
132
  .b-loc-map__frame :global(iframe) { display: block; }
127
133
  .b-loc-map__consent {
128
134
  display: flex; flex-direction: column; align-items: center; justify-content: center;
129
135
  gap: 0.75rem; padding: 1.5rem; text-align: center;
136
+ z-index: 1;
130
137
  }
131
138
  .b-loc-map__consent[hidden] { display: none; }
132
139
  .b-loc-map__text { margin: 0; color: var(--muted); font-size: 0.9rem; max-width: 32ch; }
@@ -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={columns}>
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.5rem 2rem;
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.35rem;
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.0",
3
+ "version": "0.4.2",
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",