@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.
- package/CHANGELOG.md +1522 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +33 -17
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +365 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +82 -66
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +136 -36
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/notFound.js +52 -0
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +119 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +549 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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 || (
|
|
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;
|
package/src/components/Hero.vue
CHANGED
|
@@ -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
|
-
<
|
|
10
|
+
<p
|
|
11
11
|
:class="[
|
|
12
12
|
'h4',
|
|
13
13
|
'text-uppercase',
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
]"
|
|
17
17
|
>
|
|
18
18
|
{{ tagLine }}
|
|
19
|
-
</
|
|
19
|
+
</p>
|
|
20
20
|
</div>
|
|
21
|
-
<
|
|
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
|
-
</
|
|
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-
|
|
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(--
|
|
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;
|