ferst-core 0.1.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.
@@ -0,0 +1,1130 @@
1
+ ---
2
+ /**
3
+ * Site-wide header: brand logo, primary nav (data-driven), About Us mega
4
+ * panel (declarative), Contact CTA, hamburger drawer, theme toggle.
5
+ *
6
+ * Lifted out of `Base.astro` so the layout file is no longer 1700 lines
7
+ * and so a future "edit navbar entries" feature only has to render a
8
+ * different `primaryNav` array. The About Us mega menu stays declarative
9
+ * because it carries icons and a custom panel — easy to extend, hard to
10
+ * shoe-horn into a flat JSON entry until it earns its keep.
11
+ */
12
+ import Button from './Button.astro';
13
+ import type { NavLink, NavGroup } from '../content/schemas';
14
+
15
+ interface Props {
16
+ path: string;
17
+ headerClass?: string;
18
+ primaryLogoLight: string;
19
+ primaryLogoDark: string;
20
+ brandAlt: string;
21
+ primaryNav: NavLink[];
22
+ /** Optional dropdown / mega-menu group. Client-configured — the core ships none. */
23
+ dropdown?: NavGroup;
24
+ }
25
+
26
+ const { path, headerClass, primaryLogoLight, primaryLogoDark, brandAlt, primaryNav, dropdown } = Astro.props;
27
+
28
+ /**
29
+ * Longest-prefix-wins active-nav matching. A plain independent `startsWith`
30
+ * per link (the previous approach) breaks once one link's href is a subpath
31
+ * of another's — e.g. "Newsletters" → /posts/tag/newsletter/ sits under
32
+ * "Posts" → /posts/, so both would light up on a newsletter-tag page.
33
+ * Computing every link's match length up front and keeping only the
34
+ * longest means only the most specific matching link is ever active,
35
+ * however nav hrefs happen to nest.
36
+ */
37
+ const primaryNavMatchLengths = primaryNav.map((link) => {
38
+ const trimmed = link.href.replace(/\/$/, '');
39
+ return trimmed.length > 0 && path.startsWith(trimmed) ? trimmed.length : -1;
40
+ });
41
+ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
42
+ ---
43
+
44
+ <!--
45
+ transition:persist — on pages using Astro's <ClientRouter /> (currently
46
+ /posts/), this keeps the exact same header DOM node across a filter
47
+ click instead of tearing it down and re-inserting a fresh copy. Without
48
+ it: the logo's <script is:inline> and the theme-toggle's <script> (which
49
+ attaches its click listener and calls applyThemeFromStorageOrSystem()) are
50
+ identical on every /posts/ page, so Astro's script-dedup logic skips
51
+ re-running them on the swapped-in copy — the new toggle button ends up
52
+ with no click listener at all, and the logo can flash to a stale value
53
+ before this page's own remaining scripts settle. The "POSTS" nav-link's
54
+ active state (path.startsWith below) is identical across every page in
55
+ this family, so nothing here goes stale by persisting it.
56
+ -->
57
+ <header class:list={['site-header', headerClass]} transition:persist transition:name="site-header" transition:animate="none">
58
+ <a href="/" class="brand">
59
+ <img
60
+ id="primary-logo"
61
+ src={primaryLogoLight}
62
+ data-src-light={primaryLogoLight}
63
+ data-src-dark={primaryLogoDark}
64
+ alt={brandAlt}
65
+ class="primary-logo"
66
+ width="545"
67
+ height="151"
68
+ loading="eager"
69
+ fetchpriority="high"
70
+ decoding="sync"
71
+ />
72
+ </a>
73
+ <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
74
+ (function () {
75
+ var img = document.getElementById('primary-logo');
76
+ if (!img) return;
77
+ var t = document.documentElement.dataset.theme;
78
+ img.src = t === 'dark' ? primaryLogoDark : primaryLogoLight;
79
+ })();
80
+ </script>
81
+
82
+ <nav class="site-nav" id="site-nav" aria-label="Main navigation">
83
+ {dropdown && (
84
+ <div class="nav-item nav-has-dropdown" data-panel="about">
85
+ <a
86
+ href={dropdown.href}
87
+ class:list={['nav-link', { active: path.startsWith(dropdown.href.replace(/\/$/, '')) }]}
88
+ aria-haspopup="true"
89
+ aria-expanded="false"
90
+ aria-controls="about-submenu"
91
+ >
92
+ {dropdown.label}
93
+ <svg class="nav-chevron" width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
94
+ <path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
95
+ </svg>
96
+ </a>
97
+ <ul class="nav-dropdown" id="about-submenu">
98
+ {dropdown.items.map((item) => (
99
+ <li>
100
+ <a href={item.href} class:list={[{ active: path === item.href }]}>
101
+ <span class="drop-text">
102
+ <span class="drop-title">{item.title}</span>
103
+ {item.desc && <span class="drop-desc">{item.desc}</span>}
104
+ </span>
105
+ </a>
106
+ </li>
107
+ ))}
108
+ </ul>
109
+ </div>
110
+ )}
111
+ {/* Primary nav links — data-driven so editing siteSettings.navigation.primary
112
+ adds, reorders, or removes entries without touching the layout.
113
+ Active-state matching uses startsWith on the href to keep policy-style
114
+ subpages highlighting their parent (e.g. /posts/tag/event/), but only
115
+ the longest/most-specific match wins — see primaryNavLongestMatch
116
+ above — so a link whose href is a subpath of another's (Newsletters
117
+ under Posts) doesn't light up both at once. */}
118
+ {primaryNav.map((link, i) => {
119
+ const isActive =
120
+ primaryNavMatchLengths[i] >= 0 && primaryNavMatchLengths[i] === primaryNavLongestMatch;
121
+ return (
122
+ <a href={link.href} class:list={['nav-link', { active: isActive }]}>
123
+ {link.label}
124
+ </a>
125
+ );
126
+ })}
127
+ <div class="nav-mobile-theme">
128
+ <Button
129
+ href="/contact/"
130
+ variant="cta"
131
+ class:list={['nav-cta', 'nav-cta--drawer', { active: path.startsWith('/contact') }]}
132
+ >
133
+ Contact
134
+ </Button>
135
+ <button type="button" class="theme-toggle-mobile" id="theme-toggle-mobile" role="switch" aria-checked="false" aria-label="Toggle dark mode">
136
+ <span class="theme-toggle__highlight" aria-hidden="true"></span>
137
+ <svg class="theme-icon theme-icon--sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
138
+ <svg class="theme-icon theme-icon--moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
139
+ </button>
140
+ </div>
141
+ </nav>
142
+
143
+ <div class="nav-right">
144
+ <button type="button" class="theme-toggle" id="theme-toggle" role="switch" aria-checked="false" aria-label="Toggle dark mode">
145
+ <span class="theme-toggle__highlight" aria-hidden="true"></span>
146
+ <svg class="theme-icon theme-icon--sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
147
+ <svg class="theme-icon theme-icon--moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
148
+ </button>
149
+ <span class="nav-cta-header-wrap">
150
+ <Button
151
+ href="/contact/"
152
+ variant="cta"
153
+ class:list={['nav-cta', 'nav-cta--header', { active: path.startsWith('/contact') }]}
154
+ >
155
+ Contact
156
+ </Button>
157
+ </span>
158
+ <button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="site-nav">
159
+ <span></span><span></span><span></span>
160
+ </button>
161
+ </div>
162
+ </header>
163
+
164
+ <div class="mobile-overlay" id="mobile-overlay"></div>
165
+
166
+ <!-- Optional dropdown mega panel (desktop only) — client-configured, core ships none -->
167
+ {dropdown && (
168
+ <div class="mega-nav" id="mega-nav" aria-hidden="true">
169
+ <div class="mega-nav__backdrop" id="mega-backdrop"></div>
170
+
171
+ <div class="mega-panel mega-panel--about" data-panel="about">
172
+ <div class="mega-panel__inner">
173
+ <div class="mega-section-header">
174
+ <h2>{dropdown.label}</h2>
175
+ </div>
176
+ <div class="mega-about-grid">
177
+ {dropdown.items.map((item) => (
178
+ <a href={item.href} class:list={['mega-about-card', { active: path === item.href }]}>
179
+ <span class="mega-about-title">{item.title}</span>
180
+ {item.desc && <span class="mega-about-desc">{item.desc}</span>}
181
+ </a>
182
+ ))}
183
+ </div>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ )}
188
+
189
+ <style is:global>
190
+ /* Header chrome — every selector here used to live in Base.astro's giant
191
+ <style is:global> block. Kept global because dark-mode rules cross the
192
+ boundary between header markup and html[data-theme="dark"]. */
193
+
194
+ .primary-logo {
195
+ height: 44px;
196
+ width: auto;
197
+ }
198
+
199
+ /* ── Header ── */
200
+ /* Logo + header: mobile-first — compact until desktop ≥64em scales up (matches button/theme sizing). */
201
+ .site-header {
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: space-between;
205
+ height: 56px;
206
+ padding: 0 1.25rem;
207
+ background: var(--bg-tag);
208
+ position: sticky;
209
+ top: 0;
210
+ z-index: 100;
211
+ isolation: isolate;
212
+ overflow: visible;
213
+ }
214
+ @media (min-width: 64em) {
215
+ .site-header {
216
+ height: 64px;
217
+ padding: 0 28px;
218
+ }
219
+ }
220
+ [data-theme="dark"] .site-header { background: var(--dark-bg); }
221
+ .brand {
222
+ text-decoration: none;
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 12px;
226
+ min-width: 0;
227
+ flex-shrink: 1;
228
+ }
229
+ .brand:focus-visible {
230
+ outline: 2px solid var(--gold);
231
+ outline-offset: 3px;
232
+ border-radius: 4px;
233
+ }
234
+ @media (min-width: 64em) {
235
+ .brand { gap: 16px; }
236
+ }
237
+ .primary-logo {
238
+ flex-shrink: 0;
239
+ display: block;
240
+ height: 44px;
241
+ width: auto;
242
+ max-width: 100%;
243
+ object-fit: contain;
244
+ }
245
+ @media (min-width: 48em) {
246
+ .primary-logo { height: 48px; }
247
+ }
248
+ @media (min-width: 64em) {
249
+ .primary-logo { height: 56px; }
250
+ }
251
+
252
+ /* ── Center nav ── */
253
+ .site-nav {
254
+ display: flex;
255
+ align-items: center;
256
+ gap: 28px;
257
+ position: absolute;
258
+ left: 50%;
259
+ transform: translateX(-50%);
260
+ }
261
+ .nav-link {
262
+ position: relative;
263
+ display: inline-flex;
264
+ align-items: center;
265
+ padding: 14px 0;
266
+ color: var(--accent);
267
+ text-decoration: none;
268
+ font-family: 'Inter', 'Lato', sans-serif;
269
+ font-size: 0.875rem;
270
+ font-weight: 500;
271
+ letter-spacing: 0.1em;
272
+ text-transform: uppercase;
273
+ transition: color 0.25s ease;
274
+ }
275
+ .nav-link::after {
276
+ content: '';
277
+ position: absolute;
278
+ bottom: 4px;
279
+ left: 0;
280
+ right: 0;
281
+ height: 2px;
282
+ background: var(--gold);
283
+ transform: scaleX(0);
284
+ transition: transform 0.2s ease;
285
+ border-radius: 1px;
286
+ }
287
+ .nav-link:hover { color: var(--gold); }
288
+ .nav-link:focus-visible {
289
+ outline: 2px solid var(--gold);
290
+ outline-offset: 3px;
291
+ border-radius: 2px;
292
+ }
293
+ .nav-link.active {
294
+ color: var(--gold);
295
+ font-weight: 500;
296
+ }
297
+ .nav-link.active::after { display: none; }
298
+ .nav-item.panel-active > .nav-link::after { display: none; }
299
+ [data-theme="dark"] .nav-link { color: var(--text-light); }
300
+ [data-theme="dark"] .nav-link:hover { color: var(--gold); }
301
+ [data-theme="dark"] .nav-link.active { color: var(--gold); }
302
+ [data-theme="dark"] .nav-toggle span { background: var(--text-light); }
303
+
304
+ /* ── Right side ── */
305
+ .nav-right {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 12px;
309
+ flex-shrink: 0;
310
+ position: relative;
311
+ z-index: 101; /* always above the mobile full-screen nav (z-index: 99) */
312
+ }
313
+
314
+ /* ── Contact CTA ── */
315
+ .nav-cta {
316
+ white-space: nowrap;
317
+ }
318
+ .nav-cta:hover,
319
+ .nav-cta.active { background: #1A2A1A; color: var(--text-light); border: none; }
320
+ [data-theme="dark"] .nav-cta { background: var(--gold); color: var(--fg); border: none; }
321
+ [data-theme="dark"] .nav-cta:hover,
322
+ [data-theme="dark"] .nav-cta.active { background: var(--text-footer-link); color: var(--fg); border: none; }
323
+
324
+ .nav-mobile-theme .nav-cta--drawer {
325
+ display: none;
326
+ }
327
+ @media (min-width: 64em) {
328
+ .nav-mobile-theme .btn.nav-cta--drawer,
329
+ .nav-mobile-theme a.nav-cta--drawer {
330
+ display: none !important;
331
+ }
332
+ }
333
+
334
+ /* ── Theme toggle: segmented sun/moon switch, shared by the desktop
335
+ button and the mobile drawer button. Both icons stay in the DOM at all
336
+ times, pinned to either side; a gold highlight slides behind whichever
337
+ one is active. --overlay-accent-08/-18 already resolve to the dark-mode
338
+ white-tinted values via the [data-theme="dark"] root override in
339
+ theme.css, so no manual dark-mode override is needed for the track
340
+ itself. */
341
+ .theme-toggle,
342
+ .theme-toggle-mobile {
343
+ position: relative;
344
+ display: inline-flex;
345
+ align-items: center;
346
+ justify-content: space-around;
347
+ width: 64px;
348
+ height: 30px;
349
+ padding: 3px;
350
+ background: var(--overlay-accent-08);
351
+ border: 1px solid var(--overlay-accent-18);
352
+ border-radius: 999px;
353
+ cursor: pointer;
354
+ flex-shrink: 0;
355
+ transition:
356
+ background-color 0.35s cubic-bezier(0.4, 0, 0.2, 1),
357
+ border-color 0.35s cubic-bezier(0.4, 0, 0.2, 1);
358
+ }
359
+ .theme-toggle:hover,
360
+ .theme-toggle-mobile:hover {
361
+ border-color: var(--gold);
362
+ }
363
+ .theme-toggle:focus-visible,
364
+ .theme-toggle-mobile:focus-visible {
365
+ outline: 2px solid var(--gold);
366
+ outline-offset: 2px;
367
+ }
368
+
369
+ .theme-toggle__highlight {
370
+ position: absolute;
371
+ top: 3px;
372
+ left: 3px;
373
+ width: calc(50% - 3px);
374
+ height: calc(100% - 6px);
375
+ border-radius: 999px;
376
+ background: var(--gold);
377
+ transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
378
+ }
379
+ [data-theme="dark"] .theme-toggle__highlight { transform: translateX(100%); }
380
+
381
+ .theme-icon {
382
+ position: relative;
383
+ z-index: 1;
384
+ width: 14px;
385
+ height: 14px;
386
+ color: var(--fg);
387
+ opacity: 0.5;
388
+ transition: opacity 0.3s ease, color 0.3s ease;
389
+ }
390
+ .theme-icon--sun { opacity: 1; }
391
+ [data-theme="dark"] .theme-icon--sun { opacity: 0.5; }
392
+ [data-theme="dark"] .theme-icon--moon { opacity: 1; color: #2A1F12; }
393
+
394
+ /* Mobile theme toggle row — hidden on desktop */
395
+ .nav-mobile-theme { display: none; }
396
+
397
+ /* ── Hamburger ── */
398
+ .nav-toggle {
399
+ display: none;
400
+ flex-direction: column;
401
+ justify-content: center;
402
+ align-items: center;
403
+ width: 44px;
404
+ height: 44px;
405
+ padding: 0;
406
+ background: none;
407
+ border: none;
408
+ cursor: pointer;
409
+ border-radius: 8px;
410
+ transition: background 0.15s;
411
+ position: relative;
412
+ }
413
+ .nav-toggle:hover { background: var(--overlay-white-08); }
414
+ .nav-toggle span {
415
+ display: block;
416
+ position: absolute;
417
+ width: 22px;
418
+ height: 2px;
419
+ background: #2A3A2A;
420
+ border-radius: 2px;
421
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease, top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
422
+ transform-origin: center;
423
+ }
424
+ .nav-toggle span:nth-child(1) { top: 13px; }
425
+ .nav-toggle span:nth-child(2) { top: 19px; }
426
+ .nav-toggle span:nth-child(3) { top: 25px; }
427
+ .nav-toggle.open span:nth-child(1) { top: 19px; transform: rotate(45deg); }
428
+ .nav-toggle.open span:nth-child(2) { opacity: 0; transform: scaleX(0); }
429
+ .nav-toggle.open span:nth-child(3) { top: 19px; transform: rotate(-45deg); }
430
+
431
+ /* ── Mega nav system (tablet/desktop, not phone overlay) ── */
432
+ .mega-nav {
433
+ display: none;
434
+ position: fixed;
435
+ top: 56px;
436
+ left: 0;
437
+ right: 0;
438
+ z-index: 98;
439
+ pointer-events: none;
440
+ }
441
+ .mega-nav.is-open {
442
+ display: block;
443
+ pointer-events: auto;
444
+ }
445
+ .mega-nav__backdrop {
446
+ position: fixed;
447
+ inset: 0;
448
+ top: 56px;
449
+ background: rgba(0,0,0,0.45);
450
+ z-index: -1;
451
+ animation: backdropIn 0.2s ease forwards;
452
+ }
453
+ @media (min-width: 64em) {
454
+ .mega-nav,
455
+ .mega-nav__backdrop {
456
+ top: 64px;
457
+ }
458
+ }
459
+ @media (max-width: 47.9375em) {
460
+ .mega-nav,
461
+ .mega-nav__backdrop {
462
+ top: 60px;
463
+ }
464
+ }
465
+ @keyframes backdropIn {
466
+ from { opacity: 0; }
467
+ to { opacity: 1; }
468
+ }
469
+
470
+ .mega-panel {
471
+ display: none;
472
+ }
473
+ .mega-nav.is-open .mega-panel {
474
+ display: block;
475
+ animation: panelIn 0.18s ease forwards;
476
+ }
477
+ .mega-panel--about {
478
+ background: var(--bg);
479
+ box-shadow: 0 16px 48px rgba(0,0,0,0.4);
480
+ }
481
+
482
+ @keyframes panelIn {
483
+ from { opacity: 0; transform: translateY(-6px); }
484
+ to { opacity: 1; transform: translateY(0); }
485
+ }
486
+
487
+ .mega-panel__inner {
488
+ max-width: 1100px;
489
+ margin: 0 auto;
490
+ padding: 2rem 2.5rem;
491
+ }
492
+
493
+ .mega-section-header {
494
+ display: flex;
495
+ align-items: baseline;
496
+ justify-content: space-between;
497
+ margin-bottom: 1.25rem;
498
+ padding-bottom: 0.75rem;
499
+ }
500
+ .mega-section-header h2 {
501
+ margin: 0;
502
+ font-size: 1rem;
503
+ color: var(--accent);
504
+ font-family: 'Poppins', sans-serif;
505
+ font-weight: 600;
506
+ letter-spacing: 0.04em;
507
+ }
508
+ [data-theme="dark"] .mega-section-header h2 { color: var(--text-light); }
509
+ .mega-about-grid {
510
+ display: grid;
511
+ grid-template-columns: repeat(5, 1fr);
512
+ gap: 0.85rem;
513
+ }
514
+ .mega-about-card {
515
+ display: flex;
516
+ flex-direction: column;
517
+ align-items: flex-start;
518
+ gap: 6px;
519
+ padding: 1rem 1.1rem;
520
+ background: rgba(200,151,58,0.1);
521
+ border: 1px solid rgba(200,151,58,0.35);
522
+ border-radius: 10px;
523
+ text-decoration: none;
524
+ transition: background 0.15s, border-color 0.15s, transform 0.15s;
525
+ }
526
+ .mega-about-card:hover {
527
+ background: rgba(200,151,58,0.1);
528
+ border-color: rgba(200,151,58,0.35);
529
+ transform: translateY(-2px);
530
+ }
531
+ .mega-about-card:focus-visible {
532
+ outline: 2px solid var(--gold);
533
+ outline-offset: 2px;
534
+ }
535
+ .mega-about-card:hover .mega-about-title { color: var(--gold); }
536
+ [data-theme="dark"] .mega-about-card { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.07); }
537
+ [data-theme="dark"] .mega-about-card:hover { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.07); transform: translateY(-2px); }
538
+ .mega-about-card.active,
539
+ .mega-about-card.active:hover {
540
+ background: rgba(200,151,58,0.12);
541
+ border-color: rgba(200,151,58,0.45);
542
+ }
543
+ /* [data-theme="dark"] .mega-about-card:hover above has higher specificity
544
+ (attribute selector + class + pseudo-class) than .mega-about-card.active
545
+ (two classes), so without this it would win and flatten the active card
546
+ back to the plain hover look. Matching specificity + declared after it
547
+ keeps the active highlight showing through hover in dark mode too. */
548
+ [data-theme="dark"] .mega-about-card.active,
549
+ [data-theme="dark"] .mega-about-card.active:hover {
550
+ background: rgba(200,151,58,0.12);
551
+ border-color: rgba(200,151,58,0.45);
552
+ }
553
+ .mega-about-card.active .mega-about-title { color: var(--gold); }
554
+ .mega-about-card.active .mega-about-icon { background: rgba(200,151,58,0.25); }
555
+ .mega-about-icon {
556
+ width: 42px;
557
+ height: 42px;
558
+ background: rgba(200,151,58,0.12);
559
+ border-radius: 8px;
560
+ display: flex;
561
+ align-items: center;
562
+ justify-content: center;
563
+ color: var(--gold);
564
+ margin-bottom: 2px;
565
+ }
566
+ .mega-about-title {
567
+ font-size: 0.875rem;
568
+ font-weight: 600;
569
+ color: var(--accent);
570
+ font-family: 'Inter', sans-serif;
571
+ line-height: 1.3;
572
+ transition: color 0.15s;
573
+ }
574
+ [data-theme="dark"] .mega-about-title { color: var(--text-light); }
575
+ .mega-about-desc {
576
+ font-size: 0.75rem;
577
+ color: var(--accent);
578
+ font-weight: 500;
579
+ font-family: 'Inter', sans-serif;
580
+ line-height: 1.4;
581
+ }
582
+ .mega-about-sub {
583
+ font-size: 0.8rem;
584
+ color: var(--accent);
585
+ font-weight: 500;
586
+ font-family: 'Inter', sans-serif;
587
+ }
588
+ [data-theme="dark"] .mega-about-desc,
589
+ [data-theme="dark"] .mega-about-sub {
590
+ color: var(--muted);
591
+ font-weight: 400;
592
+ }
593
+
594
+ .mega-empty {
595
+ color: var(--text-subtle);
596
+ font-size: 0.9rem;
597
+ font-style: italic;
598
+ }
599
+
600
+ /* Hide mega nav whenever hamburger / drawer nav is used (≤1023px) */
601
+ @media (max-width: 63.9375em) {
602
+ .mega-nav { display: none !important; }
603
+ }
604
+
605
+ /* Desktop only: hide flyout dropdown (replaced by mega panel) */
606
+ @media (min-width: 64em) {
607
+ .nav-dropdown { display: none; }
608
+ }
609
+
610
+ /* ── About dropdown (mega-menu style, mobile only) ── */
611
+ .nav-item { position: relative; display: flex; align-items: center; }
612
+ .nav-chevron {
613
+ display: inline-block;
614
+ vertical-align: middle;
615
+ margin-left: 3px;
616
+ transform: rotate(0deg);
617
+ transition: transform 0.2s ease;
618
+ }
619
+ .nav-item.open .nav-chevron,
620
+ .nav-item.panel-active .nav-chevron { transform: rotate(180deg); }
621
+
622
+ .nav-dropdown {
623
+ display: none;
624
+ position: absolute;
625
+ top: calc(100% + 12px);
626
+ left: 50%;
627
+ transform: translateX(-50%);
628
+ min-width: 280px;
629
+ background: #1e2d1c;
630
+ border: 1px solid #4A6741;
631
+ border-top: 2px solid var(--gold);
632
+ border-radius: 0 0 10px 10px;
633
+ box-shadow: 0 12px 32px rgba(0,0,0,0.35);
634
+ list-style: none;
635
+ padding: 0.35rem 0;
636
+ margin: 0;
637
+ z-index: 200;
638
+ }
639
+ .nav-item.open .nav-dropdown { display: block; }
640
+
641
+ .nav-dropdown li a {
642
+ display: flex;
643
+ align-items: center;
644
+ gap: 12px;
645
+ padding: 0.65rem 1.1rem;
646
+ color: #D9CDB8;
647
+ text-decoration: none;
648
+ transition: background 0.12s, color 0.12s;
649
+ border-radius: 0;
650
+ }
651
+ .nav-dropdown li a:hover {
652
+ background: rgba(200,151,58,0.1);
653
+ color: #FAF6EF;
654
+ }
655
+ .nav-dropdown li a:focus-visible {
656
+ outline: 2px solid var(--gold);
657
+ outline-offset: -2px;
658
+ }
659
+ .nav-dropdown li a:hover .drop-title { color: var(--gold); }
660
+ .nav-dropdown li a.active { color: #FAF6EF; }
661
+ .nav-dropdown li a.active .drop-title { color: var(--gold); }
662
+ .nav-dropdown li a.active .drop-icon { background: rgba(200,151,58,0.25); }
663
+ .nav-dropdown li + li {
664
+ border-top: 1px solid rgba(255,255,255,0.05);
665
+ }
666
+
667
+ .drop-icon {
668
+ flex-shrink: 0;
669
+ width: 32px;
670
+ height: 32px;
671
+ display: flex;
672
+ align-items: center;
673
+ justify-content: center;
674
+ background: rgba(200,151,58,0.12);
675
+ border-radius: 6px;
676
+ color: var(--gold);
677
+ }
678
+
679
+ .drop-text {
680
+ display: flex;
681
+ flex-direction: column;
682
+ gap: 1px;
683
+ }
684
+
685
+ .drop-title {
686
+ font-family: 'Inter', sans-serif;
687
+ font-size: 0.8125rem;
688
+ font-weight: 600;
689
+ color: #C8D4C2;
690
+ line-height: 1.3;
691
+ transition: color 0.12s;
692
+ }
693
+
694
+ .drop-desc {
695
+ font-family: 'Inter', sans-serif;
696
+ font-size: 0.6875rem;
697
+ color: var(--text-subtle);
698
+ line-height: 1.3;
699
+ }
700
+
701
+ /* ── Tablet md: 768px+ ── */
702
+ @media (min-width: 48em) {
703
+ .site-header { padding: 0 2.5rem; }
704
+ }
705
+
706
+ /* ── Mobile overlay backdrop ── */
707
+ .mobile-overlay {
708
+ position: fixed;
709
+ inset: 0;
710
+ background: rgba(0,0,0,0.5);
711
+ z-index: 98;
712
+ opacity: 0;
713
+ visibility: hidden;
714
+ transition: opacity 0.22s ease, visibility 0.22s ease;
715
+ pointer-events: none;
716
+ }
717
+ .mobile-overlay.visible {
718
+ opacity: 1;
719
+ visibility: visible;
720
+ pointer-events: auto;
721
+ }
722
+
723
+ /* ── Hamburger + slide-out nav (≤1023px) ── */
724
+ @media (max-width: 63.9375em) {
725
+ .nav-toggle { display: flex; }
726
+ .nav-cta-header-wrap { display: none; }
727
+ .nav-right .theme-toggle { display: none; }
728
+
729
+ .site-nav {
730
+ display: flex;
731
+ flex-direction: column;
732
+ align-items: stretch;
733
+ position: fixed;
734
+ top: 56px;
735
+ left: 0;
736
+ right: 0;
737
+ bottom: 0;
738
+ width: 100%;
739
+ transform: none;
740
+ background: var(--bg-tag);
741
+ border-top: 1px solid rgba(44,59,42,0.10);
742
+ padding: 2rem 0 2rem;
743
+ gap: 0;
744
+ overflow-y: auto;
745
+ overflow-x: hidden;
746
+ pointer-events: none;
747
+ opacity: 0;
748
+ visibility: hidden;
749
+ transition: opacity 0.22s ease, visibility 0.22s ease;
750
+ z-index: 99;
751
+ justify-content: flex-start;
752
+ }
753
+ [data-theme="dark"] .site-nav { background: var(--dark-bg); border-top-color: rgba(255,255,255,0.10); }
754
+ .site-nav.open {
755
+ opacity: 1;
756
+ visibility: visible;
757
+ pointer-events: auto;
758
+ }
759
+
760
+ .nav-link {
761
+ display: flex;
762
+ align-items: center;
763
+ justify-content: flex-start;
764
+ position: relative;
765
+ gap: 0;
766
+ padding: 1rem 3rem;
767
+ font-size: 0.9375rem;
768
+ font-weight: 300;
769
+ font-family: 'Poppins', sans-serif;
770
+ border-radius: 0;
771
+ text-align: left;
772
+ width: 100%;
773
+ color: #2A3A2A;
774
+ transition: color 0.18s ease;
775
+ }
776
+ .nav-link::before {
777
+ content: '';
778
+ position: absolute;
779
+ bottom: 0;
780
+ left: 3rem;
781
+ right: 0;
782
+ height: 1px;
783
+ background: rgba(255,255,255,0.08);
784
+ }
785
+ /* Last link (Calendar) sits directly above .nav-mobile-theme's own
786
+ border-top — drop its trailing divider so the two don't double up. */
787
+ .site-nav > .nav-link:last-of-type::before { display: none; }
788
+ .nav-link:hover { color: #C8973A; background: none; }
789
+ .nav-link::after { display: none; }
790
+ .nav-link.active { color: #C8973A; font-weight: 400; }
791
+
792
+ .nav-mobile-theme {
793
+ display: flex;
794
+ justify-content: flex-end;
795
+ align-items: center;
796
+ padding: 1rem 1.5rem;
797
+ border-top: 1px solid var(--border);
798
+ margin-top: 0.5rem;
799
+ }
800
+
801
+ .nav-item { width: 100%; flex-direction: column; align-items: stretch; }
802
+ .nav-item .nav-link {
803
+ width: 100%;
804
+ display: flex;
805
+ justify-content: flex-start;
806
+ align-items: center;
807
+ }
808
+ .nav-item .nav-chevron { margin-left: auto; }
809
+ .nav-dropdown {
810
+ position: static;
811
+ transform: none;
812
+ display: block;
813
+ width: 100%;
814
+ background: transparent;
815
+ border: none;
816
+ border-radius: 0;
817
+ box-shadow: none;
818
+ min-width: 0;
819
+ max-height: 0;
820
+ overflow: hidden;
821
+ padding: 0;
822
+ transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s ease;
823
+ }
824
+ .nav-item.open .nav-dropdown {
825
+ max-height: min(70vh, 560px);
826
+ max-height: min(70dvh, 560px);
827
+ padding: 0.35rem 0;
828
+ }
829
+ .nav-dropdown li a {
830
+ padding: 0.6rem 1.5rem 0.6rem 3rem;
831
+ gap: 10px;
832
+ justify-content: flex-start;
833
+ font-size: 0.8rem;
834
+ color: #2A3A2A;
835
+ transition: color 0.18s ease;
836
+ }
837
+ .nav-dropdown li a:hover { color: #C8973A; background: none; }
838
+ .drop-icon { width: 24px; height: 24px; flex-shrink: 0; transition: color 0.18s ease; }
839
+ .drop-title { font-size: 0.75rem; color: #2A3A2A; transition: color 0.18s ease; }
840
+ [data-theme="dark"] .nav-dropdown li a { color: rgba(250,246,239,0.75); }
841
+ [data-theme="dark"] .nav-dropdown li a:hover,
842
+ [data-theme="dark"] .nav-dropdown li a:active { color: #C8973A; }
843
+ [data-theme="dark"] .nav-dropdown li a:hover .drop-title,
844
+ [data-theme="dark"] .nav-dropdown li a:active .drop-title { color: #C8973A; }
845
+ [data-theme="dark"] .nav-dropdown li a:hover .drop-icon,
846
+ [data-theme="dark"] .nav-dropdown li a:active .drop-icon { color: #C8973A; }
847
+ [data-theme="dark"] .drop-title { color: rgba(250,246,239,0.9); }
848
+ .drop-desc { display: none; }
849
+ .nav-dropdown li + li { border-top: 1px solid rgba(255,255,255,0.04); }
850
+
851
+ .site-nav .nav-mobile-theme .btn.nav-cta--drawer,
852
+ .site-nav .nav-mobile-theme a.nav-cta--drawer {
853
+ display: inline-flex !important;
854
+ }
855
+ .site-nav .nav-mobile-theme {
856
+ justify-content: space-between;
857
+ align-items: center;
858
+ gap: 0.75rem;
859
+ }
860
+ .site-nav .nav-mobile-theme .nav-cta--drawer {
861
+ flex: 1 1 auto;
862
+ min-width: 0;
863
+ max-width: calc(100% - 3.25rem);
864
+ justify-content: center;
865
+ }
866
+ }
867
+
868
+ /* ── Phone only: shorter header (≤767px) ── */
869
+ @media (max-width: 47.9375em) {
870
+ .site-nav { top: 60px; }
871
+ .site-header { padding: 0 1.25rem; height: 60px; }
872
+ .brand { gap: 10px; }
873
+ }
874
+ </style>
875
+
876
+ <script>
877
+ // ── Hamburger (mobile) ──
878
+ const toggle = document.querySelector('.nav-toggle') as HTMLButtonElement;
879
+ const nav = document.querySelector('#site-nav') as HTMLElement;
880
+ const megaNav = document.getElementById('mega-nav') as HTMLElement;
881
+ const aboutTrigger = document.querySelector('.nav-has-dropdown') as HTMLElement;
882
+ const aboutLink = aboutTrigger?.querySelector('.nav-link') as HTMLAnchorElement;
883
+ const mobileOverlay = document.getElementById('mobile-overlay') as HTMLElement;
884
+
885
+ let scrollY = 0;
886
+
887
+ function openNav() {
888
+ scrollY = window.scrollY;
889
+ document.body.style.position = 'fixed';
890
+ document.body.style.top = `-${scrollY}px`;
891
+ document.body.style.width = '100%';
892
+ nav.classList.add('open');
893
+ toggle.classList.add('open');
894
+ mobileOverlay.classList.add('visible');
895
+ toggle.setAttribute('aria-expanded', 'true');
896
+ }
897
+
898
+ function closeNav() {
899
+ const wasFixed = document.body.style.position === 'fixed';
900
+ document.body.style.position = '';
901
+ document.body.style.top = '';
902
+ document.body.style.width = '';
903
+ if (wasFixed) window.scrollTo(0, scrollY);
904
+ nav.classList.remove('open');
905
+ toggle.classList.remove('open');
906
+ mobileOverlay.classList.remove('visible');
907
+ toggle.setAttribute('aria-expanded', 'false');
908
+ aboutTrigger?.classList.remove('open');
909
+ aboutLink?.setAttribute('aria-expanded', 'false');
910
+ }
911
+
912
+ toggle.addEventListener('click', () => {
913
+ nav.classList.contains('open') ? closeNav() : openNav();
914
+ });
915
+
916
+ mobileOverlay?.addEventListener('click', closeNav);
917
+
918
+ document.addEventListener('click', e => {
919
+ const target = e.target as HTMLElement;
920
+ if (!target.closest('.site-header') && !target.closest('#mobile-overlay')) {
921
+ closeNav();
922
+ }
923
+ });
924
+
925
+ // Close the drawer when a real link inside it is clicked. This used to be
926
+ // an accidental side effect of navigation always hard-reloading the page
927
+ // (nav starts closed on a fresh load) — now that the header persists
928
+ // across Astro's <ClientRouter /> transitions (see /posts/), that DOM
929
+ // node's "open" state carries over untouched too, so it needs an explicit
930
+ // close here. Skip the "About Us" trigger: on mobile that link expands the
931
+ // submenu in place (preventDefault below) instead of navigating away.
932
+ nav.addEventListener('click', e => {
933
+ const link = (e.target as HTMLElement).closest('a[href]');
934
+ if (!link || link.getAttribute('aria-haspopup') === 'true') return;
935
+ closeNav();
936
+ });
937
+
938
+ const siteHeader = document.querySelector('.site-header') as HTMLElement | null;
939
+
940
+ function openMega() {
941
+ if (!megaNav || !aboutTrigger) return;
942
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
943
+ const scrollY = window.scrollY;
944
+ megaNav.classList.add('is-open');
945
+ megaNav.removeAttribute('aria-hidden');
946
+ aboutTrigger.classList.add('panel-active');
947
+ aboutLink?.setAttribute('aria-expanded', 'true');
948
+ siteHeader?.classList.add('header--scrolled');
949
+
950
+ document.body.style.position = 'fixed';
951
+ document.body.style.top = `-${scrollY}px`;
952
+ document.body.style.left = '0';
953
+ document.body.style.right = '0';
954
+ document.body.style.paddingRight = scrollbarWidth + 'px';
955
+ document.body.dataset.scrollY = String(scrollY);
956
+ }
957
+
958
+ function closeMega() {
959
+ if (!megaNav || !aboutTrigger) return;
960
+ megaNav.classList.remove('is-open');
961
+ megaNav.setAttribute('aria-hidden', 'true');
962
+ aboutTrigger.classList.remove('panel-active');
963
+ aboutLink?.setAttribute('aria-expanded', 'false');
964
+
965
+ const scrollY = parseInt(document.body.dataset.scrollY || '0', 10);
966
+ document.body.style.position = '';
967
+ document.body.style.top = '';
968
+ document.body.style.left = '';
969
+ document.body.style.right = '';
970
+ document.body.style.paddingRight = '';
971
+ delete document.body.dataset.scrollY;
972
+ window.scrollTo(0, scrollY);
973
+
974
+ if (scrollY < 1) siteHeader?.classList.remove('header--scrolled');
975
+ }
976
+
977
+ aboutLink?.addEventListener('click', e => {
978
+ if (window.matchMedia('(min-width: 64em)').matches) {
979
+ e.preventDefault();
980
+ if (megaNav.classList.contains('is-open')) {
981
+ closeMega();
982
+ } else {
983
+ openMega();
984
+ }
985
+ } else {
986
+ e.preventDefault();
987
+ aboutTrigger.classList.toggle('open');
988
+ aboutLink?.setAttribute('aria-expanded', aboutTrigger.classList.contains('open') ? 'true' : 'false');
989
+ }
990
+ });
991
+
992
+ document.addEventListener('click', e => {
993
+ if (
994
+ window.matchMedia('(min-width: 64em)').matches &&
995
+ megaNav?.classList.contains('is-open') &&
996
+ !(e.target as HTMLElement).closest('.nav-has-dropdown') &&
997
+ !(e.target as HTMLElement).closest('#mega-nav')
998
+ ) {
999
+ closeMega();
1000
+ }
1001
+ });
1002
+
1003
+ document.getElementById('mega-backdrop')?.addEventListener('click', closeMega);
1004
+ document.addEventListener('keydown', e => {
1005
+ if (e.key !== 'Escape') return;
1006
+ closeMega();
1007
+ if (nav.classList.contains('open')) closeNav();
1008
+ });
1009
+
1010
+ // ── Theme toggle ──
1011
+ const html = document.documentElement;
1012
+ const themeToggle = document.getElementById('theme-toggle') as HTMLButtonElement | null;
1013
+ const themeToggleMobile = document.getElementById('theme-toggle-mobile') as HTMLButtonElement | null;
1014
+
1015
+ const THEME_STORAGE_KEY = 'theme';
1016
+ /** Set only when the user clicks the toggle — that's when localStorage takes
1017
+ priority over the system preference. */
1018
+ const THEME_SOURCE_KEY = 'theme-source';
1019
+
1020
+ function syncPrimaryLogo() {
1021
+ const img = document.getElementById('primary-logo') as HTMLImageElement | null;
1022
+ if (!img) return;
1023
+ const src = html.dataset.theme === 'dark'
1024
+ ? (img.dataset.srcDark ?? img.dataset.srcLight)
1025
+ : img.dataset.srcLight;
1026
+ if (src) img.src = src;
1027
+ }
1028
+
1029
+ function syncThemedRasterLogos() {
1030
+ syncPrimaryLogo();
1031
+ }
1032
+
1033
+ /** Keeps the switch's accessible state (role="switch" aria-checked) in
1034
+ sync with the rendered theme — called everywhere html.dataset.theme
1035
+ is set. */
1036
+ function syncToggleAria() {
1037
+ const checked = html.dataset.theme === 'dark' ? 'true' : 'false';
1038
+ themeToggle?.setAttribute('aria-checked', checked);
1039
+ themeToggleMobile?.setAttribute('aria-checked', checked);
1040
+ }
1041
+
1042
+ function systemTheme(): 'dark' | 'light' {
1043
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1044
+ }
1045
+
1046
+ function userThemeOverride(): 'dark' | 'light' | null {
1047
+ if (localStorage.getItem(THEME_SOURCE_KEY) !== 'user') return null;
1048
+ const v = localStorage.getItem(THEME_STORAGE_KEY);
1049
+ return v === 'light' || v === 'dark' ? v : null;
1050
+ }
1051
+
1052
+ function applyThemeFromStorageOrSystem() {
1053
+ const override = userThemeOverride();
1054
+ html.dataset.theme = override ?? systemTheme();
1055
+ syncThemedRasterLogos();
1056
+ syncToggleAria();
1057
+ }
1058
+
1059
+ function onSystemThemeChange() {
1060
+ if (userThemeOverride() !== null) return;
1061
+ html.dataset.theme = systemTheme();
1062
+ syncThemedRasterLogos();
1063
+ syncToggleAria();
1064
+ }
1065
+
1066
+ function setUserThemeChoice(theme: 'dark' | 'light') {
1067
+ localStorage.setItem(THEME_SOURCE_KEY, 'user');
1068
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
1069
+ html.dataset.theme = theme;
1070
+ syncThemedRasterLogos();
1071
+ syncToggleAria();
1072
+ }
1073
+
1074
+ applyThemeFromStorageOrSystem();
1075
+ const colorSchemeMq = window.matchMedia('(prefers-color-scheme: dark)');
1076
+ colorSchemeMq.addEventListener('change', onSystemThemeChange);
1077
+
1078
+ function toggleTheme() {
1079
+ const next = html.dataset.theme === 'dark' ? 'light' : 'dark';
1080
+ setUserThemeChoice(next);
1081
+ }
1082
+
1083
+ themeToggle?.addEventListener('click', (e) => {
1084
+ e.preventDefault();
1085
+ e.stopPropagation();
1086
+ toggleTheme();
1087
+ });
1088
+
1089
+ themeToggleMobile?.addEventListener('click', (e) => {
1090
+ e.preventDefault();
1091
+ e.stopPropagation();
1092
+ toggleTheme();
1093
+ });
1094
+
1095
+ // ── Primary nav active-state re-sync ──
1096
+ // The header is `transition:persist` (see the comment above <header>), so
1097
+ // on a soft navigation within a ClientRouter-enabled page family
1098
+ // (/posts/*), this DOM node survives untouched — the freshly-computed
1099
+ // active state on the destination page's own server-rendered header never
1100
+ // reaches the screen, since that header copy is discarded, not this one.
1101
+ // `astro:page-load` fires after every navigation, initial load and every
1102
+ // soft transition alike, so re-deriving the active link from the current
1103
+ // URL here keeps the persisted header in sync regardless of how it got
1104
+ // stale. Same longest-match rule as the server-side computation above
1105
+ // (SiteHeader's frontmatter) — ported to run client-side since this is
1106
+ // the only copy guaranteed to run on every navigation, including ones the
1107
+ // server-rendered version never gets a chance to apply.
1108
+ document.addEventListener('astro:page-load', () => {
1109
+ const navLinks = Array.from(
1110
+ document.querySelectorAll<HTMLAnchorElement>('#site-nav > a.nav-link'),
1111
+ );
1112
+ if (!navLinks.length) return;
1113
+
1114
+ const path = window.location.pathname;
1115
+ let bestLink: HTMLAnchorElement | null = null;
1116
+ let bestLength = -1;
1117
+
1118
+ for (const link of navLinks) {
1119
+ const trimmed = (link.getAttribute('href') ?? '').replace(/\/$/, '');
1120
+ if (trimmed.length > bestLength && path.startsWith(trimmed)) {
1121
+ bestLink = link;
1122
+ bestLength = trimmed.length;
1123
+ }
1124
+ }
1125
+
1126
+ for (const link of navLinks) {
1127
+ link.classList.toggle('active', link === bestLink);
1128
+ }
1129
+ });
1130
+ </script>