@koehler8/cms 1.0.0-beta.9 → 1.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.
Files changed (111) hide show
  1. package/CHANGELOG.md +1522 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +33 -17
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +365 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +82 -66
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +136 -36
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/notFound.js +52 -0
  74. package/src/utils/robotsGenerator.js +95 -0
  75. package/src/utils/sha256.js +20 -0
  76. package/src/utils/sitemapGenerator.js +119 -0
  77. package/src/utils/socialMeta.js +100 -0
  78. package/src/utils/ssgRoutes.js +40 -0
  79. package/src/utils/trackingContext.js +16 -22
  80. package/src/utils/webAppManifest.js +85 -0
  81. package/src/utils/webStorage.js +82 -0
  82. package/templates/index.html +17 -24
  83. package/templates/scaffolds/extension/README.md +53 -0
  84. package/templates/scaffolds/extension/extension.config.json +10 -0
  85. package/templates/scaffolds/extension/index.js +37 -0
  86. package/templates/scaffolds/extension/package.json +22 -0
  87. package/templates/scaffolds/site/.env.example +7 -0
  88. package/templates/scaffolds/site/.nvmrc +1 -0
  89. package/templates/scaffolds/site/CLAUDE.md +121 -0
  90. package/templates/scaffolds/site/README.md +25 -0
  91. package/templates/scaffolds/site/amplify.yml +22 -0
  92. package/templates/scaffolds/site/package.json +26 -0
  93. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  95. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  96. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  97. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  98. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  99. package/templates/scaffolds/site/site/style.css +4 -0
  100. package/templates/scaffolds/site/vite.config.js +15 -0
  101. package/templates/scaffolds/theme/README.md +50 -0
  102. package/templates/scaffolds/theme/index.js +8 -0
  103. package/templates/scaffolds/theme/package.json +21 -0
  104. package/templates/scaffolds/theme/theme.config.js +228 -0
  105. package/templates/scaffolds/theme/theme.css +14 -0
  106. package/themes/base/theme.config.js +100 -57
  107. package/vite-plugin.js +549 -32
  108. package/public/robots.txt +0 -5
  109. package/scripts/check-overflow.mjs +0 -33
  110. package/scripts/migrate-to-content-dirs.mjs +0 -223
  111. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -8,13 +8,92 @@
8
8
  class="site-header__section ui-header__section"
9
9
  :class="{ 'ui-header__section--compact': isHeaderCompact }"
10
10
  >
11
- <nav class="site-header__nav">
11
+ <nav
12
+ class="site-header__nav"
13
+ aria-label="Primary"
14
+ @keydown.esc="closeNavMenu($event, 'escape')"
15
+ @focusout="onNavFocusOut"
16
+ >
12
17
  <div class="container">
13
18
  <div class="site-header__inner">
14
- <a href="/" ref="logoLinkRef" class="site-header__logo logo-flash-trigger">
15
- <img :src="logoSrc" :alt="siteName" class="logo-flash-image" />
19
+ <a href="/" ref="logoLinkRef" class="site-header__logo logo-flash-trigger" :aria-label="`${siteName || 'Home'} – home`">
20
+ <!--
21
+ Logo slot. Default rendering: <img> if site/assets/img/logo.png
22
+ exists or `content.header.logoSrc` is set, otherwise text from
23
+ `content.header.logoText` or the site title. Sites that wrap
24
+ Header in a parent component can override entirely via
25
+ <template #logo>.
26
+ -->
27
+ <slot name="logo" :src="logoSrc" :text="logoText" :site-name="siteName">
28
+ <img
29
+ v-if="logoSrc"
30
+ :src="logoSrc"
31
+ :alt="siteName"
32
+ class="logo-flash-image"
33
+ />
34
+ <span v-else class="site-header__logo-text">{{ logoText || siteName }}</span>
35
+ </slot>
16
36
  </a>
17
- <div v-if="hasHeaderActions || showLocaleLinks" class="site-header__actions">
37
+ <!--
38
+ Nav slot. Defaults to a horizontal <ul> of links built from
39
+ `content.header.navItems[]` (each entry: { text, href, target? }).
40
+ No items, no nav.
41
+
42
+ Below 720px the row collapses into a disclosure menu behind the
43
+ toggle button below. Before this existed the list was simply
44
+ display:none on narrow viewports, which left phone visitors with
45
+ no primary navigation at all.
46
+ -->
47
+ <slot name="nav" :items="navItems">
48
+ <ul
49
+ v-if="navItems.length"
50
+ id="site-header-nav-menu"
51
+ ref="navMenuRef"
52
+ class="site-header__nav-list"
53
+ :class="{ 'site-header__nav-list--open': isNavOpen }"
54
+ >
55
+ <li v-for="(item, idx) in navItems" :key="`${item.href || item.text}-${idx}`">
56
+ <a
57
+ :href="item.href"
58
+ :target="item.target || null"
59
+ :rel="item.target === '_blank' ? 'noopener noreferrer' : null"
60
+ @click="closeNavMenu(null, 'selection')"
61
+ >{{ item.text }}</a>
62
+ </li>
63
+ </ul>
64
+ </slot>
65
+ <button
66
+ v-if="navItems.length && !hasNavSlot"
67
+ ref="navToggleRef"
68
+ class="site-header__nav-toggle"
69
+ type="button"
70
+ aria-controls="site-header-nav-menu"
71
+ :aria-expanded="isNavOpen ? 'true' : 'false'"
72
+ :aria-label="isNavOpen ? 'Close menu' : 'Open menu'"
73
+ @click="toggleNavMenu"
74
+ >
75
+ <svg viewBox="0 0 24 24" aria-hidden="true" width="22" height="22">
76
+ <path
77
+ v-if="isNavOpen"
78
+ d="M6 6l12 12M18 6L6 18"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ stroke-width="1.8"
82
+ stroke-linecap="round"
83
+ />
84
+ <path
85
+ v-else
86
+ d="M4 7h16M4 12h16M4 17h16"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ stroke-width="1.8"
90
+ stroke-linecap="round"
91
+ />
92
+ </svg>
93
+ </button>
94
+ <div v-if="hasHeaderActions || showLocaleLinks || hasActionsSlot" class="site-header__actions">
95
+ <!-- Site-provided extra actions, rendered before the locale dropdown. -->
96
+ <slot name="actions" />
18
97
  <component
19
98
  v-if="headerActionsComponent"
20
99
  :is="headerActionsComponent"
@@ -23,6 +102,8 @@
23
102
  v-if="showLocaleLinks && availableLocales.length > 1"
24
103
  ref="localeWrapperRef"
25
104
  class="locale-wrapper"
105
+ @keydown.esc="closeLocaleMenu($event, 'escape')"
106
+ @focusout="onLocaleWrapperFocusOut"
26
107
  >
27
108
  <button
28
109
  id="languages-dropdown-invoker-2"
@@ -30,8 +111,8 @@
30
111
  class="locale-toggle"
31
112
  type="button"
32
113
  aria-controls="languages-dropdown-2"
33
- aria-haspopup="true"
34
114
  :aria-expanded="isLangOpen ? 'true' : 'false'"
115
+ aria-label="Change language"
35
116
  @click="toggleLocaleMenu"
36
117
  >
37
118
  <span class="locale-flag" aria-hidden="true">
@@ -62,7 +143,7 @@
62
143
  <li v-for="locale in availableLocales" :key="locale">
63
144
  <a
64
145
  class="locale-option"
65
- :href="`/${locale}`"
146
+ :href="localeHref(locale)"
66
147
  @click="handleLocaleLinkClick(locale)"
67
148
  >
68
149
  <span class="locale-flag" aria-hidden="true">{{ localeEmojis[locale] || '🌐' }}</span>
@@ -80,19 +161,47 @@
80
161
  </template>
81
162
 
82
163
  <script setup>
83
- import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
164
+ import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
84
165
  import { useRoute } from 'vue-router';
85
166
  import { resolveAsset } from '../utils/assetResolver.js';
86
167
  import { trackEvent } from '../utils/analytics.js';
87
168
  import { getExtensionComponent } from '../extensions/extensionLoader.js';
88
- import { availableLocales as siteLocales } from '../utils/loadConfig.js';
169
+ import { availableLocales as siteLocales, baseLocale as siteBaseLocale } from '../utils/loadConfig.js';
170
+ import { readStoredLocale, writeStoredLocale } from '../utils/webStorage.js';
89
171
 
90
- const logoSrc = computed(() => resolveAsset('img/logo.png'));
172
+ const slots = useSlots();
173
+ const hasActionsSlot = computed(() => Boolean(slots.actions));
174
+ // A site overriding the nav slot owns its own responsive behaviour, so the
175
+ // bundled toggle must not render alongside it.
176
+ const hasNavSlot = computed(() => Boolean(slots.nav));
91
177
 
92
178
  const isLangOpen = ref(false);
179
+ const isNavOpen = ref(false);
93
180
  const isHeaderCompact = ref(false);
94
181
  const currentLocale = ref('');
95
182
  const availableLocales = computed(() => siteLocales.map((code) => code.trim()).filter(Boolean));
183
+
184
+ // Switching language keeps the visitor on the SAME page rather than bouncing
185
+ // them to the locale's home. Slugs are shared across locales, so take the
186
+ // current path, strip any leading non-base locale segment, and re-prefix with
187
+ // the target locale. The base locale is served unprefixed (`/path`); others at
188
+ // `/{locale}/path`. (`/{baseLocale}/path` doesn't pre-render and would 404,
189
+ // hence the base stays unprefixed.)
190
+ const localeHref = (locale) => {
191
+ const segments = (route.path || '/').split('/').filter(Boolean);
192
+ if (segments.length) {
193
+ const first = segments[0].toLowerCase();
194
+ if (first !== siteBaseLocale && availableLocales.value.includes(first)) {
195
+ segments.shift();
196
+ }
197
+ }
198
+ const rest = segments.join('/');
199
+ const slash = injectedSiteData.value?.site?.trailingSlash === true ? '/' : '';
200
+ if (locale === siteBaseLocale) {
201
+ return rest ? `/${rest}${slash}` : '/';
202
+ }
203
+ return rest ? `/${locale}/${rest}${slash}` : `/${locale}${slash}`;
204
+ };
96
205
  const localeLabels = {
97
206
  en: 'English',
98
207
  fr: 'Français',
@@ -130,6 +239,8 @@ const localeEmojis = {
130
239
  };
131
240
  const langButtonRef = ref(null);
132
241
  const langMenuRef = ref(null);
242
+ const navToggleRef = ref(null);
243
+ const navMenuRef = ref(null);
133
244
  const localeWrapperRef = ref(null);
134
245
  const localeMenuStyle = ref({ animationDuration: '500ms' });
135
246
  const logoLinkRef = ref(null);
@@ -169,9 +280,21 @@ const updateLocaleMenuPosition = () => {
169
280
  if (isLangOpen.value) {
170
281
  updateLocaleMenuPosition();
171
282
  }
283
+ // Above the breakpoint the toggle is display:none. Leaving isNavOpen true
284
+ // would strand aria-expanded="true" on a button no longer in the a11y tree.
285
+ if (isNavOpen.value && typeof window !== 'undefined' && window.innerWidth >= 720) {
286
+ isNavOpen.value = false;
287
+ }
172
288
  };
173
289
 
174
290
  const onDocumentClick = (e) => {
291
+ const navBtn = navToggleRef.value;
292
+ const navMenu = navMenuRef.value;
293
+ if (isNavOpen.value && !navBtn?.contains(e.target) && !navMenu?.contains(e.target)) {
294
+ isNavOpen.value = false;
295
+ trackEvent('header_nav_menu_toggle', { state: 'close', trigger: 'outside_click' });
296
+ }
297
+
175
298
  const btn = langButtonRef.value;
176
299
  const menu = langMenuRef.value;
177
300
  if (btn && btn.contains(e.target)) return; // toggle handled by button
@@ -187,6 +310,72 @@ const updateLocaleMenuPosition = () => {
187
310
  }
188
311
  };
189
312
 
313
+ // Closes the dropdown and (for keyboard dismissal) returns focus to the
314
+ // toggle button. Without focus restoration, ESC would leave focus on a
315
+ // hidden menu item — failing 1.4.13 (Content on Hover or Focus).
316
+ const closeLocaleMenu = (event, trigger = 'escape') => {
317
+ if (!isLangOpen.value) return;
318
+ if (event) {
319
+ event.preventDefault?.();
320
+ event.stopPropagation?.();
321
+ }
322
+ isLangOpen.value = false;
323
+ trackEvent('header_locale_menu_toggle', {
324
+ state: 'close',
325
+ trigger,
326
+ active_locale: currentLocale.value || 'en',
327
+ });
328
+ if (trigger === 'escape') {
329
+ nextTick(() => langButtonRef.value?.focus());
330
+ }
331
+ };
332
+
333
+ const toggleNavMenu = () => {
334
+ isNavOpen.value = !isNavOpen.value;
335
+ trackEvent('header_nav_menu_toggle', {
336
+ state: isNavOpen.value ? 'open' : 'close',
337
+ trigger: 'click',
338
+ });
339
+ };
340
+
341
+ // Mirrors closeLocaleMenu: ESC restores focus to the toggle so keyboard
342
+ // dismissal doesn't strand focus inside a hidden menu (WCAG 2.4.3, 1.4.13).
343
+ const closeNavMenu = (event, trigger = 'escape') => {
344
+ if (!isNavOpen.value) return;
345
+ if (event) {
346
+ event.preventDefault?.();
347
+ event.stopPropagation?.();
348
+ }
349
+ isNavOpen.value = false;
350
+ trackEvent('header_nav_menu_toggle', { state: 'close', trigger });
351
+ if (trigger === 'escape') {
352
+ nextTick(() => navToggleRef.value?.focus());
353
+ }
354
+ };
355
+
356
+ // Closes the menu when focus leaves both the menu and its toggle. Scoped to
357
+ // those two elements rather than the whole <nav> so tabbing from a menu link
358
+ // to the locale button (also inside <nav>) still dismisses it.
359
+ const onNavFocusOut = (event) => {
360
+ if (!isNavOpen.value) return;
361
+ const next = event.relatedTarget;
362
+ if (!next) return;
363
+ if (navMenuRef.value?.contains(next)) return;
364
+ if (navToggleRef.value?.contains(next)) return;
365
+ closeNavMenu(null, 'focusout');
366
+ };
367
+
368
+ // Closes the menu when keyboard focus leaves the wrapper entirely.
369
+ // Without this, Tab would walk past the dropdown but leave it visible
370
+ // and clickable — also fails 1.4.13's "dismissable" requirement.
371
+ const onLocaleWrapperFocusOut = (event) => {
372
+ if (!isLangOpen.value) return;
373
+ const wrapper = localeWrapperRef.value;
374
+ const next = event.relatedTarget;
375
+ if (wrapper && next && wrapper.contains(next)) return;
376
+ closeLocaleMenu(null, 'focusout');
377
+ };
378
+
190
379
  const prefersReducedMotion = () =>
191
380
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
192
381
 
@@ -231,6 +420,40 @@ const updateLocaleMenuPosition = () => {
231
420
 
232
421
  const siteName = computed(() => injectedSiteData.value?.site?.title || '');
233
422
 
423
+ // Header content (merged from shared.json + per-page overrides). Three
424
+ // optional knobs sites can use to customise the bundled Header without
425
+ // wrapping it in a parent component:
426
+ // - content.header.logoText → text shown in place of the site title
427
+ // - content.header.logoSrc → full URL to use as the logo image
428
+ // (overrides the site/assets/img/logo.png
429
+ // asset resolution)
430
+ // - content.header.navItems → array of { text, href, target? } to
431
+ // render as a top-level nav menu
432
+ const headerContent = computed(() => {
433
+ const fromPage = pageContent.value?.header;
434
+ if (fromPage && typeof fromPage === 'object') return fromPage;
435
+ const fromSite = injectedSiteData.value?.header;
436
+ return (fromSite && typeof fromSite === 'object') ? fromSite : {};
437
+ });
438
+ const logoText = computed(() => {
439
+ const t = headerContent.value?.logoText;
440
+ return typeof t === 'string' && t.trim() ? t.trim() : '';
441
+ });
442
+ const logoSrc = computed(() => {
443
+ const customSrc = headerContent.value?.logoSrc;
444
+ if (typeof customSrc === 'string' && customSrc.trim()) {
445
+ return customSrc.trim();
446
+ }
447
+ // Fall back to the conventional site/assets/img/logo.png. resolveAsset
448
+ // returns '' when the asset doesn't exist; we treat that as "no image"
449
+ // and the template renders the text fallback instead of a broken <img>.
450
+ return resolveAsset('img/logo.png') || '';
451
+ });
452
+ const navItems = computed(() => {
453
+ const items = headerContent.value?.navItems;
454
+ return Array.isArray(items) ? items.filter((it) => it && typeof it === 'object') : [];
455
+ });
456
+
234
457
  const resolveHeaderSettings = () => {
235
458
  const pageHeader = pageContent.value?.header;
236
459
  if (pageHeader && typeof pageHeader === 'object') {
@@ -305,11 +528,20 @@ const updateLocaleMenuPosition = () => {
305
528
  });
306
529
  }
307
530
  isLangOpen.value = false;
531
+ // Persist the user's explicit choice so it survives navigation to the
532
+ // unprefixed base-locale URL (`/`). The router guard only writes
533
+ // localStorage when a locale param is present in the URL — clicking
534
+ // the base locale navigates to `/` (no param), so the write must
535
+ // happen here instead. Also keeps loadConfigData's localStorage
536
+ // fallback in sync with the active selection.
537
+ if (!import.meta.env.SSR) {
538
+ writeStoredLocale(locale);
539
+ }
308
540
  };
309
541
 
310
542
  onMounted(() => {
311
543
  handleHeaderScroll();
312
- const loc = (route.params.locale || (typeof localStorage !== 'undefined' && localStorage.getItem('locale')) || 'en').toString();
544
+ const loc = (route.params.locale || readStoredLocale() || 'en').toString();
313
545
  currentLocale.value = loc;
314
546
  document.addEventListener('click', onDocumentClick);
315
547
  setupLogoFlash();
@@ -342,6 +574,17 @@ const updateLocaleMenuPosition = () => {
342
574
  currentLocale.value = normalized;
343
575
  }
344
576
  );
577
+
578
+ // In-SPA navigation must dismiss the mobile menu. The link click handler
579
+ // covers taps on the menu's own links; this covers everything else that
580
+ // changes the route while it's open (back button, a link elsewhere on the
581
+ // page, a programmatic push).
582
+ watch(
583
+ () => route.path,
584
+ () => {
585
+ isNavOpen.value = false;
586
+ }
587
+ );
345
588
  </script>
346
589
 
347
590
  <style scoped>
@@ -396,6 +639,118 @@ const updateLocaleMenuPosition = () => {
396
639
  width: auto;
397
640
  }
398
641
 
642
+ /* Text fallback when no logo asset / logoSrc is provided. Uses theme tokens
643
+ so the active theme drives appearance; consumers can override via scoped
644
+ CSS in a wrapping component or a brand-var override. */
645
+ .site-header__logo-text {
646
+ font-family: var(--ui-font-display, var(--ui-font-body, inherit));
647
+ font-weight: 600;
648
+ font-size: clamp(1rem, 2.5vw, 1.25rem);
649
+ letter-spacing: 0.02em;
650
+ color: var(--brand-header-text, var(--brand-fg-100, inherit));
651
+ white-space: nowrap;
652
+ }
653
+
654
+ /* Nav menu rendered from content.header.navItems[]. Sits between the logo
655
+ and the actions cluster.
656
+
657
+ Below 720px it becomes a disclosure panel dropped beneath the header bar,
658
+ opened by .site-header__nav-toggle. (It used to be plain display:none here,
659
+ with no toggle — phone visitors got no primary navigation at all.) Sites
660
+ that want something else still override the nav slot, which also suppresses
661
+ the bundled toggle. */
662
+ .site-header__nav-list {
663
+ display: none;
664
+ list-style: none;
665
+ margin: 0;
666
+ padding: 0;
667
+ gap: clamp(0.75rem, 2vw, 1.75rem);
668
+ align-items: center;
669
+ }
670
+ .site-header__nav-list--open {
671
+ /* Containing block is .site-header (position: sticky), so this drops the
672
+ panel flush under the full-width bar rather than under the inner column. */
673
+ display: flex;
674
+ position: absolute;
675
+ top: 100%;
676
+ left: 0;
677
+ right: 0;
678
+ flex-direction: column;
679
+ align-items: stretch;
680
+ gap: 0;
681
+ padding: 0.5rem 0;
682
+ background: var(--brand-header-bg, #020409);
683
+ border-top: 1px solid var(--ui-header-divider, rgba(255, 255, 255, 0.12));
684
+ box-shadow: var(--site-header-shadow, 0 18px 45px rgba(0, 0, 0, 0.35));
685
+ max-height: calc(100dvh - 100%);
686
+ overflow-y: auto;
687
+ }
688
+ .site-header__nav-list--open a {
689
+ display: block;
690
+ /* Comfortably past the 24x24 minimum for pointer targets (WCAG 2.5.8). */
691
+ padding: 0.85rem clamp(1rem, 5vw, 2rem);
692
+ }
693
+
694
+ /* Toggle is mobile-only; the row speaks for itself at 720px and up. */
695
+ .site-header__nav-toggle {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ justify-content: center;
699
+ flex: none;
700
+ /* 44px square — the comfortable touch target, well past WCAG 2.5.8's 24px. */
701
+ width: 44px;
702
+ height: 44px;
703
+ margin-left: auto;
704
+ padding: 0;
705
+ border: 0;
706
+ border-radius: 8px;
707
+ background: transparent;
708
+ color: inherit;
709
+ cursor: pointer;
710
+ }
711
+ .site-header__nav-toggle:hover {
712
+ background: var(--ui-header-hover, rgba(255, 255, 255, 0.08));
713
+ }
714
+
715
+ @media (min-width: 720px) {
716
+ .site-header__nav-list {
717
+ display: flex;
718
+ }
719
+ /* Neutralize the open state above the breakpoint: resizing past it while the
720
+ menu is open must not leave a dropped panel on a desktop layout. */
721
+ .site-header__nav-list--open {
722
+ position: static;
723
+ flex-direction: row;
724
+ align-items: center;
725
+ gap: clamp(0.75rem, 2vw, 1.75rem);
726
+ padding: 0;
727
+ background: none;
728
+ border-top: 0;
729
+ box-shadow: none;
730
+ max-height: none;
731
+ overflow-y: visible;
732
+ }
733
+ .site-header__nav-list--open a {
734
+ display: inline;
735
+ padding: 0;
736
+ }
737
+ .site-header__nav-toggle {
738
+ display: none;
739
+ }
740
+ }
741
+ .site-header__nav-list a {
742
+ color: var(--brand-header-text, var(--brand-fg-100, inherit));
743
+ text-decoration: none;
744
+ font-weight: 500;
745
+ font-size: 0.95rem;
746
+ transition: opacity 0.15s ease;
747
+ }
748
+ .site-header__nav-list a:hover,
749
+ .site-header__nav-list a:focus-visible {
750
+ opacity: 0.7;
751
+ text-decoration: underline;
752
+ }
753
+
399
754
  .site-header__actions {
400
755
  display: inline-flex;
401
756
  align-items: center;
@@ -7,7 +7,7 @@
7
7
  >
8
8
  <div class="container text-center hero-content hero-inner">
9
9
  <div class="hero-eyebrow fade-up-in js-scroll-fade" style="--fade-up-delay: 0s">
10
- <h2
10
+ <p
11
11
  :class="[
12
12
  'h4',
13
13
  'text-uppercase',
@@ -16,9 +16,9 @@
16
16
  ]"
17
17
  >
18
18
  {{ tagLine }}
19
- </h2>
19
+ </p>
20
20
  </div>
21
- <h3
21
+ <h1
22
22
  :class="[
23
23
  'display-heading',
24
24
  'text-uppercase',
@@ -38,7 +38,7 @@
38
38
  >
39
39
  {{ part }}
40
40
  </span>
41
- </h3>
41
+ </h1>
42
42
  <div
43
43
  class="hero-cta-stack fade-up-in js-scroll-fade"
44
44
  style="--fade-up-delay: 0.2s"
@@ -94,7 +94,6 @@ const heroHeadlineParts = computed(() => {
94
94
  });
95
95
 
96
96
  const promoImageSet = useResponsiveImage('img/promo', {
97
- widths: [960, 1440, 1920],
98
97
  fallbackFormat: 'jpg',
99
98
  });
100
99
 
@@ -256,8 +255,9 @@ function getHeaderOffset() {
256
255
  letter-spacing: 0.12em;
257
256
  font-size: clamp(1.1rem, 1.5vw, 1.55rem);
258
257
  line-height: 1.35;
259
- color: var(--hero-text-color, var(--ui-text-primary, var(--brand-fg-100, #111)));
258
+ color: var(--hero-eyebrow-color, var(--brand-hero-text-on-dark, var(--brand-hero-text, #f0eaf3)));
260
259
  border-bottom: 1px solid currentColor;
260
+ margin: 0;
261
261
  }
262
262
 
263
263
  .hero-headline {
@@ -266,16 +266,25 @@ function getHeaderOffset() {
266
266
  line-height: 1.08;
267
267
  margin-bottom: clamp(18px, 2.5vw, 28px);
268
268
  letter-spacing: 0.04em;
269
- color: var(--hero-text-color, var(--ui-text-primary, var(--brand-fg-100, #111)));
269
+ color: var(--hero-text-color, var(--brand-hero-text-on-dark, var(--brand-hero-text, #f0eaf3)));
270
270
  }
271
271
 
272
272
  .promo-surface {
273
273
  position: relative;
274
274
  min-height: var(--promo-surface-min-height, calc(100vh + 75px));
275
- background: var(
275
+ background-color: var(
276
276
  --promo-surface-bg,
277
277
  var(--hero-surface-bg, var(--theme-body-background, #060212))
278
278
  );
279
+ /* Two background-image declarations form a CSS-cascade fallback chain.
280
+ The first sets a basic url(); the second, if the browser parses
281
+ image-set() (and the type() argument), overrides with format
282
+ negotiation. Browsers that can't parse image-set fall back to the
283
+ url() above. Custom properties are populated by usePromoBackgroundStyles
284
+ via Vue's :style object binding — see that composable for why this
285
+ pattern is needed instead of a single background-image string. */
286
+ background-image: var(--promo-bg-fallback);
287
+ background-image: var(--promo-bg-set, var(--promo-bg-fallback));
279
288
  background-repeat: no-repeat;
280
289
  background-size: cover;
281
290
  background-position: center;