ferst-core 0.1.0 → 0.2.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.
Files changed (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +85 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
@@ -1,262 +1,122 @@
1
1
  ---
2
- import type { CollectionEntry } from 'astro:content';
3
- import { opensAttachmentDirectly } from '../content/filters';
4
- import { plainTextExcerpt } from '../utils/excerpt';
5
- import { isFreshPost } from '../utils/freshness';
6
- import { formatDate } from '../utils/formatDate';
7
-
2
+ /**
3
+ * PostCard a post preview for the news index and tag pages. Token-driven (radius,
4
+ * border, shadow, colours all from the theme), equal-height in a grid, with the
5
+ * whole card clickable via a stretched title link. White-room (no CTK code).
6
+ */
8
7
  interface Props {
9
- post: CollectionEntry<'posts'>;
10
- withImage?: boolean;
11
- showExcerpt?: boolean;
12
- /** Whole-tile click (stretched-link on the title, no separate "Read" link) —
13
- used by /posts/. */
14
- linkWholeCard?: boolean;
8
+ href: string;
9
+ title: string;
10
+ date: Date | string;
11
+ summary?: string;
12
+ heroImage?: string;
13
+ /** Optional tag labels shown as a small eyebrow. */
14
+ tagLabels?: string[];
15
15
  }
16
-
17
- const { post, withImage = false, showExcerpt = true, linkWholeCard = true } = Astro.props;
18
- // A body-less post with an attachment (the typical newsletter shape) has
19
- // nothing to show on its detail page, so the tile opens the attachment
20
- // directly instead — same interaction as before the newsletters collection
21
- // existed. Any post with a body still opens /posts/<slug>/ normally, even if
22
- // it also carries an attachment (offered there as a separate link).
23
- const directAttachment = opensAttachmentDirectly(post);
24
- const href = directAttachment ? post.data.attachment! : `/posts/${post.slug}`;
25
- const linkTarget = directAttachment ? '_blank' : undefined;
26
- const linkRel = directAttachment ? 'noopener noreferrer' : undefined;
27
- const displayTags = post.data.tags;
28
- const isFresh = isFreshPost(post.data.date);
29
- const dateLabel = formatDate(post.data.date);
30
- const hasImage = Boolean(post.data.image);
31
- const excerpt = showExcerpt ? plainTextExcerpt(post.body ?? '', withImage ? 400 : 280) : '';
16
+ const { href, title, date, summary, heroImage, tagLabels = [] } = Astro.props;
17
+ const d = date instanceof Date ? date : new Date(date);
18
+ const iso = Number.isNaN(d.valueOf()) ? undefined : d.toISOString().slice(0, 10);
19
+ const display = Number.isNaN(d.valueOf())
20
+ ? ''
21
+ : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
32
22
  ---
33
23
 
34
- <article class="post-card" class:list={{ 'post-card--with-image': withImage }}>
35
- {isFresh && (
36
- <span class="new-flag" data-fresh-since={post.data.date.toISOString()}>New</span>
37
- )}
38
- {withImage && (
39
- hasImage ? (
40
- <img
41
- src={post.data.image}
42
- alt={post.data.title}
43
- class="post-card__img"
44
- loading="lazy"
45
- decoding="async"
46
- />
47
- ) : (
48
- <div class="post-card__placeholder" aria-hidden="true"></div>
49
- )
24
+ <article class="b-post-card">
25
+ {heroImage && (
26
+ <a class="b-post-card__media" href={href} tabindex="-1" aria-hidden="true">
27
+ <img src={heroImage} alt="" loading="lazy" />
28
+ </a>
50
29
  )}
51
- <div class="post-content">
52
- {displayTags.length > 0 && (
53
- <div class="post-tags" aria-label="Post tags">
54
- {displayTags.map((tag) => (
55
- <span class="post-tag">{tag}</span>
56
- ))}
57
- </div>
58
- )}
59
-
60
- <h3>{linkWholeCard ? <a href={href} target={linkTarget} rel={linkRel}>{post.data.title}</a> : post.data.title}</h3>
61
-
62
- {excerpt ? <p>{excerpt}</p> : null}
63
-
64
- <div class="post-card__footer">
65
- <time datetime={post.data.date.toISOString()}>{dateLabel}</time>
66
- {linkWholeCard ? (
67
- <svg class="post-card__chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
68
- <polyline points="9 6 15 12 9 18"/>
69
- </svg>
70
- ) : (
71
- <a href={href} target={linkTarget} rel={linkRel} aria-label={`Read ${post.data.title}`}>
72
- <svg class="post-card__chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
73
- <polyline points="9 6 15 12 9 18"/>
74
- </svg>
75
- </a>
76
- )}
30
+ <div class="b-post-card__body">
31
+ <div class="b-post-card__meta">
32
+ {tagLabels.length > 0 && <span class="b-post-card__eyebrow">{tagLabels[0]}</span>}
33
+ {display && <time datetime={iso}>{display}</time>}
77
34
  </div>
35
+ <h3 class="b-post-card__title">
36
+ <a href={href}>{title}</a>
37
+ </h3>
38
+ {summary && <p class="b-post-card__summary">{summary}</p>}
39
+ <span class="b-post-card__more" aria-hidden="true">Read more</span>
78
40
  </div>
79
41
  </article>
80
42
 
81
43
  <style>
82
- .post-card {
83
- position: relative;
84
- padding: 0;
85
- border-bottom: none;
86
- overflow: hidden;
44
+ .b-post-card {
87
45
  display: flex;
88
46
  flex-direction: column;
89
- flex: 1;
47
+ background: var(--bg-surface);
48
+ border: var(--card-border);
49
+ border-radius: var(--radius);
50
+ box-shadow: var(--shadow-sm);
51
+ overflow: hidden;
52
+ position: relative;
53
+ transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
90
54
  }
91
-
92
- .post-content {
55
+ .b-post-card:hover {
56
+ transform: translateY(-2px);
57
+ box-shadow: var(--shadow-md);
58
+ border-color: var(--gold);
59
+ }
60
+ .b-post-card__media {
61
+ display: block;
62
+ aspect-ratio: 16 / 9;
63
+ background: var(--bg-section);
64
+ }
65
+ .b-post-card__media img {
66
+ width: 100%;
67
+ height: 100%;
68
+ object-fit: cover;
69
+ display: block;
70
+ }
71
+ .b-post-card__body {
93
72
  display: flex;
94
73
  flex-direction: column;
74
+ gap: 0.5rem;
75
+ padding: 1.1rem 1.2rem 1.3rem;
95
76
  flex: 1;
96
- min-height: 0;
97
- gap: 0.75rem;
98
- padding: clamp(0.875rem, 2.2vw, 1.25rem);
99
- color: var(--news-card-body);
100
77
  }
101
-
102
- .post-tags {
78
+ .b-post-card__meta {
103
79
  display: flex;
104
80
  flex-wrap: wrap;
105
- justify-content: flex-end;
106
- gap: 0.35rem;
107
- margin: 0;
108
- width: 100%;
109
- }
110
-
111
- .post-tag {
112
- display: inline-flex;
113
81
  align-items: center;
114
- font-family: 'Poppins', sans-serif;
115
- font-size: 0.65rem;
82
+ gap: 0.5rem 0.75rem;
83
+ }
84
+ .b-post-card__eyebrow {
85
+ font-family: var(--font-heading);
86
+ font-size: 0.7rem;
116
87
  font-weight: 600;
88
+ letter-spacing: 0.08em;
117
89
  text-transform: uppercase;
118
- letter-spacing: 0.12em;
119
- color: var(--brown);
120
- background: rgba(180, 145, 70, 0.22);
121
- border-radius: 4px;
122
- padding: 0.15rem 0.45rem;
123
- line-height: 1.2;
124
- }
125
- :global(html[data-theme="dark"]) .post-tag {
126
- color: var(--text-light);
127
- background: rgba(255, 255, 255, 0.12);
128
- }
129
-
130
- .post-content h3 {
131
- /* Full title, never truncated — the admin's exact wording is shown
132
- in full, however many lines it takes. */
133
- margin-block: 0;
90
+ color: var(--gold);
134
91
  }
135
-
136
- /* Stretched-link pattern: the title anchor's ::after is expanded to cover
137
- the whole card (positioned against .post-card, the nearest positioned
138
- ancestor), so clicking anywhere on the tile opens the post — no JS
139
- needed. The footer "Read" link sits above it via z-index so it stays
140
- independently clickable/focusable. */
141
- .post-card h3 a {
142
- color: inherit;
143
- text-decoration: none;
92
+ .b-post-card__title {
93
+ margin: 0;
144
94
  }
145
-
146
- .post-card h3 a::after {
95
+ /* Stretch the title link over the whole card so the card is clickable, while
96
+ the meta/summary stay selectable. */
97
+ .b-post-card__title a::after {
147
98
  content: '';
148
99
  position: absolute;
149
100
  inset: 0;
150
- z-index: 1;
151
101
  }
152
-
153
- .new-flag {
154
- position: absolute;
155
- top: clamp(0.5rem, 1.6vw, 0.75rem);
156
- left: clamp(0.5rem, 1.6vw, 0.75rem);
157
- z-index: 2;
158
- display: inline-block;
159
- font-family: 'Poppins', sans-serif;
160
- font-size: 0.6875rem;
161
- font-weight: 700;
162
- letter-spacing: 0.12em;
163
- text-transform: uppercase;
164
- color: #fff;
165
- background: var(--accent);
166
- border-radius: 4px;
167
- padding: 0.22rem 0.55rem;
168
- line-height: 1.2;
169
- transform: rotate(-4deg);
170
- transform-origin: top left;
171
- box-shadow:
172
- 0 2px 6px rgba(0, 0, 0, 0.18),
173
- 0 1px 2px rgba(0, 0, 0, 0.08);
174
- pointer-events: none;
102
+ .b-post-card__title a {
103
+ color: var(--fg);
175
104
  }
176
- :global(html[data-theme="dark"]) .new-flag {
177
- background: var(--gold);
178
- color: #2A1F12;
105
+ .b-post-card:hover .b-post-card__title a {
106
+ color: var(--gold);
179
107
  }
180
-
181
- .post-content > p {
182
- font-size: clamp(0.8125rem, 0.7rem + 0.5vw, 0.9375rem);
108
+ .b-post-card__summary {
109
+ margin: 0;
110
+ color: var(--muted);
111
+ font-size: 0.95rem;
183
112
  line-height: 1.55;
184
- display: -webkit-box;
185
- -webkit-line-clamp: 3;
186
- -webkit-box-orient: vertical;
187
- overflow: hidden;
188
113
  }
189
-
190
- /* Date on the left, chevron on the right — chevron keeps the same
191
- slide-right-on-hover language as the homepage's Latest from the
192
- Notice rows (.notice-row / .notice-chevron). */
193
- .post-card__footer {
194
- display: flex;
195
- justify-content: space-between;
196
- align-items: center;
197
- gap: 0.4rem;
114
+ .b-post-card__more {
198
115
  margin-top: auto;
199
- }
200
-
201
- .post-card__footer time {
202
- flex-shrink: 0;
203
- }
204
-
205
- .post-card__footer a {
206
- position: relative;
207
- z-index: 2;
208
- display: inline-flex;
209
- align-items: center;
210
- }
211
-
212
- .post-card__chevron {
213
- flex-shrink: 0;
214
- color: var(--muted);
215
- transition: transform 0.15s ease, color 0.15s ease;
216
- }
217
-
218
- /* Decorative in linkWholeCard mode (the real link is the title's
219
- stretched-link), so it reacts to hover on the whole card. */
220
- .post-card:hover .post-card__chevron {
221
- transform: translateX(3px);
116
+ padding-top: 0.4rem;
117
+ font-family: var(--font-heading);
118
+ font-size: 0.85rem;
119
+ font-weight: 600;
222
120
  color: var(--gold);
223
121
  }
224
-
225
- /* With-image variant: opt-in via the `withImage` prop, used by the
226
- /posts/ listing. Image always sits above the text, at every screen
227
- size. */
228
- .post-card--with-image .post-card__img,
229
- .post-card--with-image .post-card__placeholder {
230
- display: block;
231
- width: 100%;
232
- aspect-ratio: 16 / 9;
233
- border-radius: 10px 10px 0 0;
234
- margin: 0;
235
- }
236
-
237
- .post-card--with-image .post-card__img {
238
- object-fit: cover;
239
- object-position: center;
240
- }
241
-
242
- .post-card--with-image .post-card__placeholder {
243
- background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 100%);
244
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
245
- }
246
-
247
- :global(html[data-theme="dark"]) .post-card--with-image .post-card__placeholder {
248
- background: linear-gradient(135deg, #4A6741 0%, #2C3B2A 100%);
249
- }
250
-
251
- .post-card--with-image .post-content > p {
252
- -webkit-line-clamp: 5;
253
- }
254
-
255
- .post-card--with-image .post-tags {
256
- justify-content: flex-start;
257
- }
258
-
259
- .post-card--with-image .post-content {
260
- padding-top: clamp(1.5rem, 3.5vw, 2rem);
261
- }
262
122
  </style>
@@ -131,7 +131,7 @@ const year = new Date().getFullYear();
131
131
  }
132
132
  .site-footer .footer-brand {
133
133
  margin: 0;
134
- font-family: 'Poppins', sans-serif;
134
+ font-family: var(--font-heading);
135
135
  font-weight: 700;
136
136
  font-size: 1.15rem;
137
137
  line-height: 1.3;
@@ -217,7 +217,7 @@ const year = new Date().getFullYear();
217
217
  }
218
218
  .site-footer .footer-copy {
219
219
  margin: 0;
220
- font-family: 'Inter', sans-serif;
220
+ font-family: var(--font-body);
221
221
  font-size: 0.68rem;
222
222
  font-weight: 400;
223
223
  color: var(--muted);
@@ -15,8 +15,8 @@ import type { NavLink, NavGroup } from '../content/schemas';
15
15
  interface Props {
16
16
  path: string;
17
17
  headerClass?: string;
18
- primaryLogoLight: string;
19
- primaryLogoDark: string;
18
+ primaryLogoLight?: string;
19
+ primaryLogoDark?: string;
20
20
  brandAlt: string;
21
21
  primaryNav: NavLink[];
22
22
  /** Optional dropdown / mega-menu group. Client-configured — the core ships none. */
@@ -56,28 +56,34 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
56
56
  -->
57
57
  <header class:list={['site-header', headerClass]} transition:persist transition:name="site-header" transition:animate="none">
58
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
- />
59
+ {primaryLogoLight ? (
60
+ <img
61
+ id="primary-logo"
62
+ src={primaryLogoLight}
63
+ data-src-light={primaryLogoLight}
64
+ data-src-dark={primaryLogoDark ?? primaryLogoLight}
65
+ alt={brandAlt}
66
+ class="primary-logo"
67
+ width="545"
68
+ height="151"
69
+ loading="eager"
70
+ fetchpriority="high"
71
+ decoding="sync"
72
+ />
73
+ ) : (
74
+ <span class="brand-wordmark">{brandAlt}</span>
75
+ )}
72
76
  </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>
77
+ {primaryLogoLight && (
78
+ <script is:inline define:vars={{ primaryLogoDark, primaryLogoLight }}>
79
+ (function () {
80
+ var img = document.getElementById('primary-logo');
81
+ if (!img) return;
82
+ var t = document.documentElement.dataset.theme;
83
+ img.src = t === 'dark' ? (primaryLogoDark || primaryLogoLight) : primaryLogoLight;
84
+ })();
85
+ </script>
86
+ )}
81
87
 
82
88
  <nav class="site-nav" id="site-nav" aria-label="Main navigation">
83
89
  {dropdown && (
@@ -196,6 +202,21 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
196
202
  width: auto;
197
203
  }
198
204
 
205
+ /* Text wordmark fallback — shown when a client has configured no logo image
206
+ (var(--fg) auto-switches for dark mode via theme.css). */
207
+ .brand-wordmark {
208
+ font-family: var(--font-heading);
209
+ font-weight: 700;
210
+ font-size: 1.25rem;
211
+ line-height: 1;
212
+ color: var(--fg);
213
+ letter-spacing: 0.01em;
214
+ white-space: nowrap;
215
+ }
216
+ @media (min-width: 64em) {
217
+ .brand-wordmark { font-size: 1.5rem; }
218
+ }
219
+
199
220
  /* ── Header ── */
200
221
  /* Logo + header: mobile-first — compact until desktop ≥64em scales up (matches button/theme sizing). */
201
222
  .site-header {
@@ -218,6 +239,31 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
218
239
  }
219
240
  }
220
241
  [data-theme="dark"] .site-header { background: var(--dark-bg); }
242
+
243
+ /* Sticky "shrink on scroll": the header condenses and gains a shadow once the
244
+ page is scrolled, then expands again at the top. Purely additive; safe with
245
+ the sticky positioning. Motion honoured. */
246
+ .site-header {
247
+ transition: height 0.25s ease, box-shadow 0.25s ease;
248
+ }
249
+ .site-header.is-compact {
250
+ box-shadow: 0 2px 14px rgba(15, 23, 42, 0.10);
251
+ height: 50px;
252
+ }
253
+ @media (min-width: 64em) {
254
+ .site-header.is-compact { height: 54px; }
255
+ }
256
+ .site-header .primary-logo,
257
+ .site-header .brand-wordmark {
258
+ transition: height 0.25s ease, font-size 0.25s ease;
259
+ }
260
+ .site-header.is-compact .primary-logo { height: 38px; }
261
+ .site-header.is-compact .brand-wordmark { font-size: 1.15rem; }
262
+ @media (prefers-reduced-motion: reduce) {
263
+ .site-header,
264
+ .site-header .primary-logo,
265
+ .site-header .brand-wordmark { transition: none; }
266
+ }
221
267
  .brand {
222
268
  text-decoration: none;
223
269
  display: flex;
@@ -265,7 +311,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
265
311
  padding: 14px 0;
266
312
  color: var(--accent);
267
313
  text-decoration: none;
268
- font-family: 'Inter', 'Lato', sans-serif;
314
+ font-family: var(--font-body);
269
315
  font-size: 0.875rem;
270
316
  font-weight: 500;
271
317
  letter-spacing: 0.1em;
@@ -316,10 +362,10 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
316
362
  white-space: nowrap;
317
363
  }
318
364
  .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; }
365
+ .nav-cta.active { background: color-mix(in srgb, var(--gold) 85%, #000); color: var(--text-light); border: none; }
366
+ [data-theme="dark"] .nav-cta { background: var(--gold); color: var(--dark-bg); border: none; }
321
367
  [data-theme="dark"] .nav-cta:hover,
322
- [data-theme="dark"] .nav-cta.active { background: var(--text-footer-link); color: var(--fg); border: none; }
368
+ [data-theme="dark"] .nav-cta.active { background: color-mix(in srgb, var(--gold) 85%, #fff); color: var(--dark-bg); border: none; }
323
369
 
324
370
  .nav-mobile-theme .nav-cta--drawer {
325
371
  display: none;
@@ -389,7 +435,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
389
435
  }
390
436
  .theme-icon--sun { opacity: 1; }
391
437
  [data-theme="dark"] .theme-icon--sun { opacity: 0.5; }
392
- [data-theme="dark"] .theme-icon--moon { opacity: 1; color: #2A1F12; }
438
+ [data-theme="dark"] .theme-icon--moon { opacity: 1; color: var(--dark-bg); }
393
439
 
394
440
  /* Mobile theme toggle row — hidden on desktop */
395
441
  .nav-mobile-theme { display: none; }
@@ -416,7 +462,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
416
462
  position: absolute;
417
463
  width: 22px;
418
464
  height: 2px;
419
- background: #2A3A2A;
465
+ background: var(--fg);
420
466
  border-radius: 2px;
421
467
  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
468
  transform-origin: center;
@@ -501,7 +547,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
501
547
  margin: 0;
502
548
  font-size: 1rem;
503
549
  color: var(--accent);
504
- font-family: 'Poppins', sans-serif;
550
+ font-family: var(--font-heading);
505
551
  font-weight: 600;
506
552
  letter-spacing: 0.04em;
507
553
  }
@@ -567,7 +613,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
567
613
  font-size: 0.875rem;
568
614
  font-weight: 600;
569
615
  color: var(--accent);
570
- font-family: 'Inter', sans-serif;
616
+ font-family: var(--font-body);
571
617
  line-height: 1.3;
572
618
  transition: color 0.15s;
573
619
  }
@@ -576,14 +622,14 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
576
622
  font-size: 0.75rem;
577
623
  color: var(--accent);
578
624
  font-weight: 500;
579
- font-family: 'Inter', sans-serif;
625
+ font-family: var(--font-body);
580
626
  line-height: 1.4;
581
627
  }
582
628
  .mega-about-sub {
583
629
  font-size: 0.8rem;
584
630
  color: var(--accent);
585
631
  font-weight: 500;
586
- font-family: 'Inter', sans-serif;
632
+ font-family: var(--font-body);
587
633
  }
588
634
  [data-theme="dark"] .mega-about-desc,
589
635
  [data-theme="dark"] .mega-about-sub {
@@ -626,8 +672,8 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
626
672
  left: 50%;
627
673
  transform: translateX(-50%);
628
674
  min-width: 280px;
629
- background: #1e2d1c;
630
- border: 1px solid #4A6741;
675
+ background: var(--bg-surface);
676
+ border: 1px solid var(--border);
631
677
  border-top: 2px solid var(--gold);
632
678
  border-radius: 0 0 10px 10px;
633
679
  box-shadow: 0 12px 32px rgba(0,0,0,0.35);
@@ -643,21 +689,21 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
643
689
  align-items: center;
644
690
  gap: 12px;
645
691
  padding: 0.65rem 1.1rem;
646
- color: #D9CDB8;
692
+ color: var(--muted);
647
693
  text-decoration: none;
648
694
  transition: background 0.12s, color 0.12s;
649
695
  border-radius: 0;
650
696
  }
651
697
  .nav-dropdown li a:hover {
652
698
  background: rgba(200,151,58,0.1);
653
- color: #FAF6EF;
699
+ color: var(--fg);
654
700
  }
655
701
  .nav-dropdown li a:focus-visible {
656
702
  outline: 2px solid var(--gold);
657
703
  outline-offset: -2px;
658
704
  }
659
705
  .nav-dropdown li a:hover .drop-title { color: var(--gold); }
660
- .nav-dropdown li a.active { color: #FAF6EF; }
706
+ .nav-dropdown li a.active { color: var(--fg); }
661
707
  .nav-dropdown li a.active .drop-title { color: var(--gold); }
662
708
  .nav-dropdown li a.active .drop-icon { background: rgba(200,151,58,0.25); }
663
709
  .nav-dropdown li + li {
@@ -683,16 +729,16 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
683
729
  }
684
730
 
685
731
  .drop-title {
686
- font-family: 'Inter', sans-serif;
732
+ font-family: var(--font-body);
687
733
  font-size: 0.8125rem;
688
734
  font-weight: 600;
689
- color: #C8D4C2;
735
+ color: var(--text-light);
690
736
  line-height: 1.3;
691
737
  transition: color 0.12s;
692
738
  }
693
739
 
694
740
  .drop-desc {
695
- font-family: 'Inter', sans-serif;
741
+ font-family: var(--font-body);
696
742
  font-size: 0.6875rem;
697
743
  color: var(--text-subtle);
698
744
  line-height: 1.3;
@@ -766,11 +812,11 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
766
812
  padding: 1rem 3rem;
767
813
  font-size: 0.9375rem;
768
814
  font-weight: 300;
769
- font-family: 'Poppins', sans-serif;
815
+ font-family: var(--font-heading);
770
816
  border-radius: 0;
771
817
  text-align: left;
772
818
  width: 100%;
773
- color: #2A3A2A;
819
+ color: var(--fg);
774
820
  transition: color 0.18s ease;
775
821
  }
776
822
  .nav-link::before {
@@ -785,9 +831,9 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
785
831
  /* Last link (Calendar) sits directly above .nav-mobile-theme's own
786
832
  border-top — drop its trailing divider so the two don't double up. */
787
833
  .site-nav > .nav-link:last-of-type::before { display: none; }
788
- .nav-link:hover { color: #C8973A; background: none; }
834
+ .nav-link:hover { color: var(--gold); background: none; }
789
835
  .nav-link::after { display: none; }
790
- .nav-link.active { color: #C8973A; font-weight: 400; }
836
+ .nav-link.active { color: var(--gold); font-weight: 400; }
791
837
 
792
838
  .nav-mobile-theme {
793
839
  display: flex;
@@ -831,19 +877,19 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
831
877
  gap: 10px;
832
878
  justify-content: flex-start;
833
879
  font-size: 0.8rem;
834
- color: #2A3A2A;
880
+ color: var(--fg);
835
881
  transition: color 0.18s ease;
836
882
  }
837
- .nav-dropdown li a:hover { color: #C8973A; background: none; }
883
+ .nav-dropdown li a:hover { color: var(--gold); background: none; }
838
884
  .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; }
885
+ .drop-title { font-size: 0.75rem; color: var(--fg); transition: color 0.18s ease; }
840
886
  [data-theme="dark"] .nav-dropdown li a { color: rgba(250,246,239,0.75); }
841
887
  [data-theme="dark"] .nav-dropdown li a:hover,
842
- [data-theme="dark"] .nav-dropdown li a:active { color: #C8973A; }
888
+ [data-theme="dark"] .nav-dropdown li a:active { color: var(--gold); }
843
889
  [data-theme="dark"] .nav-dropdown li a:hover .drop-title,
844
- [data-theme="dark"] .nav-dropdown li a:active .drop-title { color: #C8973A; }
890
+ [data-theme="dark"] .nav-dropdown li a:active .drop-title { color: var(--gold); }
845
891
  [data-theme="dark"] .nav-dropdown li a:hover .drop-icon,
846
- [data-theme="dark"] .nav-dropdown li a:active .drop-icon { color: #C8973A; }
892
+ [data-theme="dark"] .nav-dropdown li a:active .drop-icon { color: var(--gold); }
847
893
  [data-theme="dark"] .drop-title { color: rgba(250,246,239,0.9); }
848
894
  .drop-desc { display: none; }
849
895
  .nav-dropdown li + li { border-top: 1px solid rgba(255,255,255,0.04); }
@@ -937,6 +983,13 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
937
983
 
938
984
  const siteHeader = document.querySelector('.site-header') as HTMLElement | null;
939
985
 
986
+ // Shrink-on-scroll: condense the header once the page is scrolled.
987
+ if (siteHeader) {
988
+ const onScrollCompact = () => siteHeader.classList.toggle('is-compact', window.scrollY > 40);
989
+ onScrollCompact();
990
+ window.addEventListener('scroll', onScrollCompact, { passive: true });
991
+ }
992
+
940
993
  function openMega() {
941
994
  if (!megaNav || !aboutTrigger) return;
942
995
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;