@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.
- package/CHANGELOG.md +1491 -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 +184 -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 +126 -28
- 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/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +112 -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 +545 -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
|
@@ -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="/"
|
|
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">></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
|
-
|
|
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(--
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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 || (
|
|
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;
|
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;
|