@koehler8/cms 1.0.0-beta.9 → 1.0.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 (110) hide show
  1. package/CHANGELOG.md +1491 -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 +184 -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 +126 -28
  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/robotsGenerator.js +95 -0
  74. package/src/utils/sha256.js +20 -0
  75. package/src/utils/sitemapGenerator.js +112 -0
  76. package/src/utils/socialMeta.js +100 -0
  77. package/src/utils/ssgRoutes.js +40 -0
  78. package/src/utils/trackingContext.js +16 -22
  79. package/src/utils/webAppManifest.js +85 -0
  80. package/src/utils/webStorage.js +82 -0
  81. package/templates/index.html +17 -24
  82. package/templates/scaffolds/extension/README.md +53 -0
  83. package/templates/scaffolds/extension/extension.config.json +10 -0
  84. package/templates/scaffolds/extension/index.js +37 -0
  85. package/templates/scaffolds/extension/package.json +22 -0
  86. package/templates/scaffolds/site/.env.example +7 -0
  87. package/templates/scaffolds/site/.nvmrc +1 -0
  88. package/templates/scaffolds/site/CLAUDE.md +121 -0
  89. package/templates/scaffolds/site/README.md +25 -0
  90. package/templates/scaffolds/site/amplify.yml +22 -0
  91. package/templates/scaffolds/site/package.json +26 -0
  92. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  93. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  95. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  96. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  97. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  98. package/templates/scaffolds/site/site/style.css +4 -0
  99. package/templates/scaffolds/site/vite.config.js +15 -0
  100. package/templates/scaffolds/theme/README.md +50 -0
  101. package/templates/scaffolds/theme/index.js +8 -0
  102. package/templates/scaffolds/theme/package.json +21 -0
  103. package/templates/scaffolds/theme/theme.config.js +228 -0
  104. package/templates/scaffolds/theme/theme.css +14 -0
  105. package/themes/base/theme.config.js +100 -57
  106. package/vite-plugin.js +545 -32
  107. package/public/robots.txt +0 -5
  108. package/scripts/check-overflow.mjs +0 -33
  109. package/scripts/migrate-to-content-dirs.mjs +0 -223
  110. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -0,0 +1,211 @@
1
+ <template>
2
+ <section
3
+ ref="dialogRef"
4
+ class="draft-gate"
5
+ role="dialog"
6
+ aria-modal="true"
7
+ aria-labelledby="draft-gate-title"
8
+ aria-describedby="draft-gate-description"
9
+ >
10
+ <div class="draft-gate-card">
11
+ <p class="draft-gate-eyebrow">Draft</p>
12
+ <h1 id="draft-gate-title" class="draft-gate-title">
13
+ This page is not yet public
14
+ </h1>
15
+ <p id="draft-gate-description" class="draft-gate-body">
16
+ {{ bodyText }}
17
+ </p>
18
+ <form class="draft-gate-form" @submit.prevent="handleSubmit" novalidate>
19
+ <label class="draft-gate-label" for="draft-gate-password">
20
+ Password
21
+ </label>
22
+ <input
23
+ id="draft-gate-password"
24
+ ref="inputRef"
25
+ v-model="passwordInput"
26
+ type="password"
27
+ autocomplete="current-password"
28
+ autocapitalize="off"
29
+ autocorrect="off"
30
+ spellcheck="false"
31
+ :aria-invalid="hasError ? 'true' : 'false'"
32
+ aria-describedby="draft-gate-error"
33
+ class="draft-gate-input"
34
+ />
35
+ <p
36
+ id="draft-gate-error"
37
+ class="draft-gate-error"
38
+ aria-live="polite"
39
+ role="status"
40
+ >
41
+ {{ errorMessage }}
42
+ </p>
43
+ <button type="submit" class="draft-gate-submit">
44
+ {{ submitLabel }}
45
+ </button>
46
+ </form>
47
+ </div>
48
+ </section>
49
+ </template>
50
+
51
+ <script setup>
52
+ import { computed, ref } from 'vue';
53
+ import { useFocusTrap } from '../composables/useFocusTrap.js';
54
+
55
+ const props = defineProps({
56
+ hasPassword: {
57
+ type: Boolean,
58
+ default: false,
59
+ },
60
+ errorMessage: {
61
+ type: String,
62
+ default: '',
63
+ },
64
+ });
65
+
66
+ const emit = defineEmits(['submit']);
67
+
68
+ const passwordInput = ref('');
69
+ const inputRef = ref(null);
70
+ const dialogRef = ref(null);
71
+ const hasError = computed(() => Boolean(props.errorMessage));
72
+
73
+ // The gate IS the page while locked, so the trap is permanently active and
74
+ // deliberately has no Escape dismiss. Focus lands on the password input.
75
+ useFocusTrap(dialogRef, () => true, { initialFocusRef: inputRef });
76
+
77
+ const bodyText = computed(() =>
78
+ props.hasPassword
79
+ ? 'Enter the password to preview this content.'
80
+ : 'No password is required — submit to continue.',
81
+ );
82
+
83
+ const submitLabel = computed(() => (props.hasPassword ? 'Unlock' : 'Continue'));
84
+
85
+ function handleSubmit() {
86
+ emit('submit', passwordInput.value);
87
+ }
88
+ </script>
89
+
90
+ <style scoped>
91
+ .draft-gate {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ min-height: 100dvh;
96
+ padding: clamp(1.5rem, 5vw, 3rem);
97
+ background: var(--brand-surface, #f7f7f8);
98
+ color: var(--brand-text, #1f2a44);
99
+ font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
100
+ }
101
+
102
+ .draft-gate-card {
103
+ width: 100%;
104
+ max-width: 28rem;
105
+ padding: clamp(1.75rem, 4vw, 2.5rem);
106
+ background: var(--brand-surface-elevated, #ffffff);
107
+ border: 1px solid var(--brand-border, rgba(31, 42, 68, 0.12));
108
+ border-radius: clamp(12px, 2vw, 18px);
109
+ box-shadow: 0 18px 38px rgba(15, 22, 42, 0.08);
110
+ outline: none;
111
+ }
112
+
113
+ .draft-gate-eyebrow {
114
+ margin: 0 0 0.5rem;
115
+ letter-spacing: 0.16em;
116
+ text-transform: uppercase;
117
+ font-size: 0.7rem;
118
+ font-weight: 600;
119
+ color: var(--brand-text-muted, rgba(31, 42, 68, 0.6));
120
+ }
121
+
122
+ .draft-gate-title {
123
+ margin: 0 0 0.75rem;
124
+ font-size: clamp(1.4rem, 2.6vw, 1.75rem);
125
+ line-height: 1.2;
126
+ font-weight: 700;
127
+ }
128
+
129
+ .draft-gate-body {
130
+ margin: 0 0 1.5rem;
131
+ font-size: 1rem;
132
+ line-height: 1.55;
133
+ color: var(--brand-text-muted, rgba(31, 42, 68, 0.75));
134
+ }
135
+
136
+ .draft-gate-form {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 0.5rem;
140
+ }
141
+
142
+ .draft-gate-label {
143
+ font-size: 0.85rem;
144
+ font-weight: 600;
145
+ color: var(--brand-text, #1f2a44);
146
+ }
147
+
148
+ .draft-gate-input {
149
+ width: 100%;
150
+ min-height: 2.75rem;
151
+ padding: 0.65rem 0.85rem;
152
+ font-size: 1rem;
153
+ font-family: inherit;
154
+ background: var(--brand-surface, #ffffff);
155
+ color: inherit;
156
+ border: 1px solid var(--brand-border, rgba(31, 42, 68, 0.25));
157
+ border-radius: var(--brand-input-radius, 8px);
158
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
159
+ }
160
+
161
+ .draft-gate-input:focus-visible {
162
+ outline: 2px solid var(--brand-primary, #1f2a44);
163
+ outline-offset: 2px;
164
+ border-color: var(--brand-primary, #1f2a44);
165
+ }
166
+
167
+ .draft-gate-input[aria-invalid='true'] {
168
+ border-color: var(--brand-criticalText, #b3261e);
169
+ }
170
+
171
+ .draft-gate-error {
172
+ min-height: 1.25rem;
173
+ margin: 0;
174
+ font-size: 0.85rem;
175
+ color: var(--brand-criticalText, #b3261e);
176
+ }
177
+
178
+ .draft-gate-submit {
179
+ margin-top: 0.5rem;
180
+ min-height: 2.75rem;
181
+ padding: 0.7rem 1.4rem;
182
+ font-size: 1rem;
183
+ font-family: inherit;
184
+ font-weight: 600;
185
+ color: var(--brand-primary-cta-text, #ffffff);
186
+ background: var(--brand-primary-cta, var(--brand-primary, #1f2a44));
187
+ border: none;
188
+ border-radius: var(--brand-button-radius, 10px);
189
+ cursor: pointer;
190
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
191
+ }
192
+
193
+ .draft-gate-submit:hover {
194
+ transform: translateY(-1px);
195
+ }
196
+
197
+ .draft-gate-submit:focus-visible {
198
+ outline: 2px solid var(--brand-primary, #1f2a44);
199
+ outline-offset: 3px;
200
+ }
201
+
202
+ @media (prefers-reduced-motion: reduce) {
203
+ .draft-gate-submit,
204
+ .draft-gate-input {
205
+ transition: none;
206
+ }
207
+ .draft-gate-submit:hover {
208
+ transform: none;
209
+ }
210
+ }
211
+ </style>
@@ -2,10 +2,10 @@
2
2
  <footer class="site-footer ui-footer" data-analytics-section="footer">
3
3
  <div class="container ui-footer__grid">
4
4
  <div class="ui-footer__brand">
5
- <a class="ui-footer__logo" href="/" aria-label="Return home">
5
+ <a class="ui-footer__logo" href="/">
6
6
  <img
7
7
  :src="logoSrc"
8
- :alt="siteName"
8
+ :alt="`${siteName} – home`"
9
9
  class="site-footer__logo-img"
10
10
  loading="lazy"
11
11
  decoding="async"
@@ -25,6 +25,7 @@
25
25
  rel="noopener noreferrer"
26
26
  >
27
27
  <span>{{ link.text }}</span>
28
+ <span class="ui-footer__sr-only">(opens in a new tab)</span>
28
29
  <span class="ui-footer__link-icon" aria-hidden="true">&gt;</span>
29
30
  </a>
30
31
  </li>
@@ -62,4 +63,16 @@ const footerLinks = computed(() =>
62
63
  .ui-footer__text {
63
64
  margin-bottom: 0;
64
65
  }
66
+
67
+ .ui-footer__sr-only {
68
+ position: absolute;
69
+ width: 1px;
70
+ height: 1px;
71
+ padding: 0;
72
+ margin: -1px;
73
+ overflow: hidden;
74
+ clip: rect(0, 0, 0, 0);
75
+ white-space: nowrap;
76
+ border: 0;
77
+ }
65
78
  </style>
@@ -3,7 +3,7 @@
3
3
  <div class="container" role="presentation">
4
4
  <div class="footer-grid">
5
5
  <a class="footer-logo" href="/">
6
- <img :src="logoSrc" :alt="siteName" class="img-fluid" />
6
+ <img :src="logoSrc" :alt="`${siteName} – home`" class="img-fluid" />
7
7
  </a>
8
8
  <p class="footer-text">{{ footerText }}</p>
9
9
  <p v-if="footerDisclaimer" class="footer-disclaimer">
@@ -78,7 +78,10 @@ const footerDisclaimer = computed(() => footerContent.value?.disclaimer || '');
78
78
  --footer-bg,
79
79
  color-mix(in srgb, var(--brand-bg-900, #070410) 88%, var(--brand-bg-800, #120b1f) 12%)
80
80
  );
81
- color: var(--footer-text, var(--ui-text-primary, var(--brand-card-text, #f0eaf3)));
81
+ /* Read from --brand-footer-text (defined by the theme via tokens.footer.text)
82
+ so the default light/text-on-dark pairing always holds. The intermediate
83
+ --footer-text exists for site-level overrides. */
84
+ color: var(--footer-text, var(--brand-footer-text, #f0eaf3));
82
85
  padding: 48px 0 28px;
83
86
  }
84
87
 
@@ -96,7 +99,7 @@ const footerDisclaimer = computed(() => footerContent.value?.disclaimer || '');
96
99
  .footer-text {
97
100
  max-width: 520px;
98
101
  margin: 0;
99
- color: var(--footer-text, var(--ui-text-primary, var(--brand-card-text, #f0eaf3)));
102
+ color: var(--footer-text, var(--brand-footer-text, #f0eaf3));
100
103
  font-size: 0.9rem;
101
104
  line-height: 1.55;
102
105
  }
@@ -107,10 +110,7 @@ const footerDisclaimer = computed(() => footerContent.value?.disclaimer || '');
107
110
  font-size: 0.75rem;
108
111
  font-style: italic;
109
112
  line-height: 1.6;
110
- color: var(
111
- --footer-disclaimer,
112
- color-mix(in srgb, var(--ui-text-muted, var(--brand-card-text, #f0eaf3)) 70%, transparent)
113
- );
113
+ color: var(--footer-disclaimer, var(--brand-footer-text-muted, #c8c2cf));
114
114
  }
115
115
 
116
116
  .footer-links {
@@ -124,19 +124,20 @@ const footerDisclaimer = computed(() => footerContent.value?.disclaimer || '');
124
124
  }
125
125
 
126
126
  .footer-link {
127
- color: var(
128
- --footer-link-color,
129
- color-mix(in srgb, var(--ui-text-primary, var(--brand-card-text, #f0eaf3)) 75%, transparent)
130
- );
127
+ color: var(--footer-link-color, var(--brand-footer-link-color, #f0eaf3));
131
128
  text-decoration: none;
132
129
  transition: color 0.2s ease, text-decoration 0.2s ease;
133
130
  }
134
131
 
135
132
  .footer-link:hover,
136
133
  .footer-link:focus-visible {
137
- color: var(--footer-link-hover, var(--brand-accent-electric, #27f3ff));
134
+ color: var(--footer-link-hover, var(--brand-footer-link-hover, #ffffff));
138
135
  text-decoration: underline;
139
136
  }
137
+ .footer-link:focus-visible {
138
+ outline: 2px solid currentColor;
139
+ outline-offset: 3px;
140
+ }
140
141
 
141
142
  @media (prefers-reduced-motion: reduce) {
142
143
  .footer-link {
@@ -8,13 +8,46 @@
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 class="site-header__nav" aria-label="Primary">
12
12
  <div class="container">
13
13
  <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" />
14
+ <a href="/" ref="logoLinkRef" class="site-header__logo logo-flash-trigger" :aria-label="`${siteName || 'Home'} – home`">
15
+ <!--
16
+ Logo slot. Default rendering: <img> if site/assets/img/logo.png
17
+ exists or `content.header.logoSrc` is set, otherwise text from
18
+ `content.header.logoText` or the site title. Sites that wrap
19
+ Header in a parent component can override entirely via
20
+ <template #logo>.
21
+ -->
22
+ <slot name="logo" :src="logoSrc" :text="logoText" :site-name="siteName">
23
+ <img
24
+ v-if="logoSrc"
25
+ :src="logoSrc"
26
+ :alt="siteName"
27
+ class="logo-flash-image"
28
+ />
29
+ <span v-else class="site-header__logo-text">{{ logoText || siteName }}</span>
30
+ </slot>
16
31
  </a>
17
- <div v-if="hasHeaderActions || showLocaleLinks" class="site-header__actions">
32
+ <!--
33
+ Nav slot. Defaults to a horizontal <ul> of links built from
34
+ `content.header.navItems[]` (each entry: { text, href, target? }).
35
+ No items, no nav.
36
+ -->
37
+ <slot name="nav" :items="navItems">
38
+ <ul v-if="navItems.length" class="site-header__nav-list">
39
+ <li v-for="(item, idx) in navItems" :key="`${item.href || item.text}-${idx}`">
40
+ <a
41
+ :href="item.href"
42
+ :target="item.target || null"
43
+ :rel="item.target === '_blank' ? 'noopener noreferrer' : null"
44
+ >{{ item.text }}</a>
45
+ </li>
46
+ </ul>
47
+ </slot>
48
+ <div v-if="hasHeaderActions || showLocaleLinks || hasActionsSlot" class="site-header__actions">
49
+ <!-- Site-provided extra actions, rendered before the locale dropdown. -->
50
+ <slot name="actions" />
18
51
  <component
19
52
  v-if="headerActionsComponent"
20
53
  :is="headerActionsComponent"
@@ -23,6 +56,8 @@
23
56
  v-if="showLocaleLinks && availableLocales.length > 1"
24
57
  ref="localeWrapperRef"
25
58
  class="locale-wrapper"
59
+ @keydown.esc="closeLocaleMenu($event, 'escape')"
60
+ @focusout="onLocaleWrapperFocusOut"
26
61
  >
27
62
  <button
28
63
  id="languages-dropdown-invoker-2"
@@ -30,8 +65,8 @@
30
65
  class="locale-toggle"
31
66
  type="button"
32
67
  aria-controls="languages-dropdown-2"
33
- aria-haspopup="true"
34
68
  :aria-expanded="isLangOpen ? 'true' : 'false'"
69
+ aria-label="Change language"
35
70
  @click="toggleLocaleMenu"
36
71
  >
37
72
  <span class="locale-flag" aria-hidden="true">
@@ -62,7 +97,7 @@
62
97
  <li v-for="locale in availableLocales" :key="locale">
63
98
  <a
64
99
  class="locale-option"
65
- :href="`/${locale}`"
100
+ :href="localeHref(locale)"
66
101
  @click="handleLocaleLinkClick(locale)"
67
102
  >
68
103
  <span class="locale-flag" aria-hidden="true">{{ localeEmojis[locale] || '🌐' }}</span>
@@ -80,19 +115,43 @@
80
115
  </template>
81
116
 
82
117
  <script setup>
83
- import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
118
+ import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
84
119
  import { useRoute } from 'vue-router';
85
120
  import { resolveAsset } from '../utils/assetResolver.js';
86
121
  import { trackEvent } from '../utils/analytics.js';
87
122
  import { getExtensionComponent } from '../extensions/extensionLoader.js';
88
- import { availableLocales as siteLocales } from '../utils/loadConfig.js';
123
+ import { availableLocales as siteLocales, baseLocale as siteBaseLocale } from '../utils/loadConfig.js';
124
+ import { readStoredLocale, writeStoredLocale } from '../utils/webStorage.js';
89
125
 
90
- const logoSrc = computed(() => resolveAsset('img/logo.png'));
126
+ const slots = useSlots();
127
+ const hasActionsSlot = computed(() => Boolean(slots.actions));
91
128
 
92
129
  const isLangOpen = ref(false);
93
130
  const isHeaderCompact = ref(false);
94
131
  const currentLocale = ref('');
95
132
  const availableLocales = computed(() => siteLocales.map((code) => code.trim()).filter(Boolean));
133
+
134
+ // Switching language keeps the visitor on the SAME page rather than bouncing
135
+ // them to the locale's home. Slugs are shared across locales, so take the
136
+ // current path, strip any leading non-base locale segment, and re-prefix with
137
+ // the target locale. The base locale is served unprefixed (`/path`); others at
138
+ // `/{locale}/path`. (`/{baseLocale}/path` doesn't pre-render and would 404,
139
+ // hence the base stays unprefixed.)
140
+ const localeHref = (locale) => {
141
+ const segments = (route.path || '/').split('/').filter(Boolean);
142
+ if (segments.length) {
143
+ const first = segments[0].toLowerCase();
144
+ if (first !== siteBaseLocale && availableLocales.value.includes(first)) {
145
+ segments.shift();
146
+ }
147
+ }
148
+ const rest = segments.join('/');
149
+ const slash = injectedSiteData.value?.site?.trailingSlash === true ? '/' : '';
150
+ if (locale === siteBaseLocale) {
151
+ return rest ? `/${rest}${slash}` : '/';
152
+ }
153
+ return rest ? `/${locale}/${rest}${slash}` : `/${locale}${slash}`;
154
+ };
96
155
  const localeLabels = {
97
156
  en: 'English',
98
157
  fr: 'Français',
@@ -187,6 +246,37 @@ const updateLocaleMenuPosition = () => {
187
246
  }
188
247
  };
189
248
 
249
+ // Closes the dropdown and (for keyboard dismissal) returns focus to the
250
+ // toggle button. Without focus restoration, ESC would leave focus on a
251
+ // hidden menu item — failing 1.4.13 (Content on Hover or Focus).
252
+ const closeLocaleMenu = (event, trigger = 'escape') => {
253
+ if (!isLangOpen.value) return;
254
+ if (event) {
255
+ event.preventDefault?.();
256
+ event.stopPropagation?.();
257
+ }
258
+ isLangOpen.value = false;
259
+ trackEvent('header_locale_menu_toggle', {
260
+ state: 'close',
261
+ trigger,
262
+ active_locale: currentLocale.value || 'en',
263
+ });
264
+ if (trigger === 'escape') {
265
+ nextTick(() => langButtonRef.value?.focus());
266
+ }
267
+ };
268
+
269
+ // Closes the menu when keyboard focus leaves the wrapper entirely.
270
+ // Without this, Tab would walk past the dropdown but leave it visible
271
+ // and clickable — also fails 1.4.13's "dismissable" requirement.
272
+ const onLocaleWrapperFocusOut = (event) => {
273
+ if (!isLangOpen.value) return;
274
+ const wrapper = localeWrapperRef.value;
275
+ const next = event.relatedTarget;
276
+ if (wrapper && next && wrapper.contains(next)) return;
277
+ closeLocaleMenu(null, 'focusout');
278
+ };
279
+
190
280
  const prefersReducedMotion = () =>
191
281
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
192
282
 
@@ -231,6 +321,40 @@ const updateLocaleMenuPosition = () => {
231
321
 
232
322
  const siteName = computed(() => injectedSiteData.value?.site?.title || '');
233
323
 
324
+ // Header content (merged from shared.json + per-page overrides). Three
325
+ // optional knobs sites can use to customise the bundled Header without
326
+ // wrapping it in a parent component:
327
+ // - content.header.logoText → text shown in place of the site title
328
+ // - content.header.logoSrc → full URL to use as the logo image
329
+ // (overrides the site/assets/img/logo.png
330
+ // asset resolution)
331
+ // - content.header.navItems → array of { text, href, target? } to
332
+ // render as a top-level nav menu
333
+ const headerContent = computed(() => {
334
+ const fromPage = pageContent.value?.header;
335
+ if (fromPage && typeof fromPage === 'object') return fromPage;
336
+ const fromSite = injectedSiteData.value?.header;
337
+ return (fromSite && typeof fromSite === 'object') ? fromSite : {};
338
+ });
339
+ const logoText = computed(() => {
340
+ const t = headerContent.value?.logoText;
341
+ return typeof t === 'string' && t.trim() ? t.trim() : '';
342
+ });
343
+ const logoSrc = computed(() => {
344
+ const customSrc = headerContent.value?.logoSrc;
345
+ if (typeof customSrc === 'string' && customSrc.trim()) {
346
+ return customSrc.trim();
347
+ }
348
+ // Fall back to the conventional site/assets/img/logo.png. resolveAsset
349
+ // returns '' when the asset doesn't exist; we treat that as "no image"
350
+ // and the template renders the text fallback instead of a broken <img>.
351
+ return resolveAsset('img/logo.png') || '';
352
+ });
353
+ const navItems = computed(() => {
354
+ const items = headerContent.value?.navItems;
355
+ return Array.isArray(items) ? items.filter((it) => it && typeof it === 'object') : [];
356
+ });
357
+
234
358
  const resolveHeaderSettings = () => {
235
359
  const pageHeader = pageContent.value?.header;
236
360
  if (pageHeader && typeof pageHeader === 'object') {
@@ -305,11 +429,20 @@ const updateLocaleMenuPosition = () => {
305
429
  });
306
430
  }
307
431
  isLangOpen.value = false;
432
+ // Persist the user's explicit choice so it survives navigation to the
433
+ // unprefixed base-locale URL (`/`). The router guard only writes
434
+ // localStorage when a locale param is present in the URL — clicking
435
+ // the base locale navigates to `/` (no param), so the write must
436
+ // happen here instead. Also keeps loadConfigData's localStorage
437
+ // fallback in sync with the active selection.
438
+ if (!import.meta.env.SSR) {
439
+ writeStoredLocale(locale);
440
+ }
308
441
  };
309
442
 
310
443
  onMounted(() => {
311
444
  handleHeaderScroll();
312
- const loc = (route.params.locale || (typeof localStorage !== 'undefined' && localStorage.getItem('locale')) || 'en').toString();
445
+ const loc = (route.params.locale || readStoredLocale() || 'en').toString();
313
446
  currentLocale.value = loc;
314
447
  document.addEventListener('click', onDocumentClick);
315
448
  setupLogoFlash();
@@ -396,6 +529,47 @@ const updateLocaleMenuPosition = () => {
396
529
  width: auto;
397
530
  }
398
531
 
532
+ /* Text fallback when no logo asset / logoSrc is provided. Uses theme tokens
533
+ so the active theme drives appearance; consumers can override via scoped
534
+ CSS in a wrapping component or a brand-var override. */
535
+ .site-header__logo-text {
536
+ font-family: var(--ui-font-display, var(--ui-font-body, inherit));
537
+ font-weight: 600;
538
+ font-size: clamp(1rem, 2.5vw, 1.25rem);
539
+ letter-spacing: 0.02em;
540
+ color: var(--brand-header-text, var(--brand-fg-100, inherit));
541
+ white-space: nowrap;
542
+ }
543
+
544
+ /* Nav menu rendered from content.header.navItems[]. Sits between the logo
545
+ and the actions cluster. Hidden on narrow viewports — sites that need
546
+ responsive nav should override the slot. */
547
+ .site-header__nav-list {
548
+ display: none;
549
+ list-style: none;
550
+ margin: 0;
551
+ padding: 0;
552
+ gap: clamp(0.75rem, 2vw, 1.75rem);
553
+ align-items: center;
554
+ }
555
+ @media (min-width: 720px) {
556
+ .site-header__nav-list {
557
+ display: flex;
558
+ }
559
+ }
560
+ .site-header__nav-list a {
561
+ color: var(--brand-header-text, var(--brand-fg-100, inherit));
562
+ text-decoration: none;
563
+ font-weight: 500;
564
+ font-size: 0.95rem;
565
+ transition: opacity 0.15s ease;
566
+ }
567
+ .site-header__nav-list a:hover,
568
+ .site-header__nav-list a:focus-visible {
569
+ opacity: 0.7;
570
+ text-decoration: underline;
571
+ }
572
+
399
573
  .site-header__actions {
400
574
  display: inline-flex;
401
575
  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;