@koehler8/cms 1.0.0-beta.16 → 1.0.0-beta.18
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/README.md +6 -0
- package/package.json +1 -1
- package/src/components/ComingSoonModal.vue +37 -5
- package/src/components/Contact.vue +91 -9
- package/src/components/DraftGate.vue +212 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +36 -3
- package/src/components/Hero.vue +7 -6
- package/src/components/Home.vue +77 -17
- package/src/components/Plan.vue +6 -3
- package/src/components/Portfolio.vue +7 -3
- package/src/components/StickyCTA.vue +5 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useDraftGate.js +89 -0
- package/src/composables/usePageConfig.js +5 -1
- package/src/composables/usePageMeta.js +23 -4
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +25 -0
- package/src/utils/draftMode.js +65 -0
- package/src/utils/robotsGenerator.js +62 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +65 -0
- package/templates/index.html +8 -3
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +83 -3
- package/public/robots.txt +0 -5
package/README.md
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
Vertex CMS is a lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support.
|
|
7
7
|
|
|
8
|
+
## Accessibility
|
|
9
|
+
|
|
10
|
+
Vertex CMS targets **[WCAG 2.2 Level AA](https://www.w3.org/WAI/WCAG22/quickref/?currentsidebar=%23col_overview&versions=2.2&levels=aaa)** as a hard requirement for the bundled components, the `base` theme, and the page wrapper. Sites that ship on this framework with the bundled pieces inherit a skip link, single `<main>` landmark, real form labels with `aria-live` errors, visible focus rings, modal focus trap+restore, AA-verified palette contrast, and `prefers-reduced-motion` support out of the box.
|
|
11
|
+
|
|
12
|
+
When extending or theming, **don't regress AA**. See the "Accessibility" section of [CLAUDE.md](CLAUDE.md#accessibility-wcag-22-level-aa--hard-requirement) for the requirements, the token naming conventions for safe colors, and the audit checklist to run when changing palette/markup/CSS.
|
|
13
|
+
|
|
8
14
|
## Quick Start
|
|
9
15
|
|
|
10
16
|
### 1. Create a site repo
|
package/package.json
CHANGED
|
@@ -68,6 +68,7 @@ const props = defineProps({
|
|
|
68
68
|
const emit = defineEmits(['close']);
|
|
69
69
|
|
|
70
70
|
const dialogRef = ref(null);
|
|
71
|
+
const previousFocusedElement = ref(null);
|
|
71
72
|
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
72
73
|
const dialogTitleId = computed(() => 'coming-soon-title');
|
|
73
74
|
const dialogDescriptionId = computed(() => 'coming-soon-message');
|
|
@@ -76,6 +77,13 @@ const emitClose = () => {
|
|
|
76
77
|
emit('close');
|
|
77
78
|
};
|
|
78
79
|
|
|
80
|
+
const onDocumentKeydown = (event) => {
|
|
81
|
+
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
emitClose();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
79
87
|
const trapFocus = (event) => {
|
|
80
88
|
if (!props.open) return;
|
|
81
89
|
if (typeof document === 'undefined') return;
|
|
@@ -103,6 +111,9 @@ watch(
|
|
|
103
111
|
(isOpen) => {
|
|
104
112
|
if (typeof document === 'undefined') return;
|
|
105
113
|
if (isOpen) {
|
|
114
|
+
// Capture whatever was focused before the modal opened so we can
|
|
115
|
+
// restore focus on close (WCAG 2.4.3 Focus Order).
|
|
116
|
+
previousFocusedElement.value = document.activeElement;
|
|
106
117
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
107
118
|
window.requestAnimationFrame(() => {
|
|
108
119
|
dialogRef.value?.focus();
|
|
@@ -111,8 +122,15 @@ watch(
|
|
|
111
122
|
dialogRef.value?.focus();
|
|
112
123
|
}
|
|
113
124
|
document.addEventListener('keydown', trapFocus);
|
|
125
|
+
document.addEventListener('keydown', onDocumentKeydown);
|
|
114
126
|
} else {
|
|
115
127
|
document.removeEventListener('keydown', trapFocus);
|
|
128
|
+
document.removeEventListener('keydown', onDocumentKeydown);
|
|
129
|
+
const previous = previousFocusedElement.value;
|
|
130
|
+
if (previous && typeof previous.focus === 'function') {
|
|
131
|
+
previous.focus();
|
|
132
|
+
}
|
|
133
|
+
previousFocusedElement.value = null;
|
|
116
134
|
}
|
|
117
135
|
}
|
|
118
136
|
);
|
|
@@ -121,12 +139,14 @@ onMounted(() => {
|
|
|
121
139
|
if (typeof document === 'undefined') return;
|
|
122
140
|
if (props.open) {
|
|
123
141
|
document.addEventListener('keydown', trapFocus);
|
|
142
|
+
document.addEventListener('keydown', onDocumentKeydown);
|
|
124
143
|
}
|
|
125
144
|
});
|
|
126
145
|
|
|
127
146
|
onBeforeUnmount(() => {
|
|
128
147
|
if (typeof document === 'undefined') return;
|
|
129
148
|
document.removeEventListener('keydown', trapFocus);
|
|
149
|
+
document.removeEventListener('keydown', onDocumentKeydown);
|
|
130
150
|
});
|
|
131
151
|
</script>
|
|
132
152
|
|
|
@@ -212,19 +232,31 @@ onBeforeUnmount(() => {
|
|
|
212
232
|
|
|
213
233
|
.coming-soon-close {
|
|
214
234
|
position: absolute;
|
|
215
|
-
top: 0.
|
|
216
|
-
right: 0.
|
|
235
|
+
top: 0.5rem;
|
|
236
|
+
right: 0.5rem;
|
|
217
237
|
background: transparent;
|
|
218
238
|
border: none;
|
|
219
239
|
font-size: 1.5rem;
|
|
220
240
|
line-height: 1;
|
|
221
241
|
cursor: pointer;
|
|
222
|
-
color:
|
|
223
|
-
|
|
242
|
+
/* AA-safe color (#595959 = 7:1 on white) and a 32×32 hit area for
|
|
243
|
+
2.5.8 Target Size (Minimum). */
|
|
244
|
+
color: #595959;
|
|
245
|
+
padding: 0;
|
|
246
|
+
min-width: 32px;
|
|
247
|
+
min-height: 32px;
|
|
248
|
+
display: inline-flex;
|
|
249
|
+
align-items: center;
|
|
250
|
+
justify-content: center;
|
|
251
|
+
border-radius: 6px;
|
|
224
252
|
}
|
|
225
253
|
|
|
226
254
|
.coming-soon-close:hover,
|
|
227
|
-
.coming-soon-close:focus {
|
|
255
|
+
.coming-soon-close:focus-visible {
|
|
228
256
|
color: #111111;
|
|
229
257
|
}
|
|
258
|
+
.coming-soon-close:focus-visible {
|
|
259
|
+
outline: 2px solid #1f2a44;
|
|
260
|
+
outline-offset: 2px;
|
|
261
|
+
}
|
|
230
262
|
</style>
|
|
@@ -21,43 +21,104 @@
|
|
|
21
21
|
|
|
22
22
|
<div class="contact-grid">
|
|
23
23
|
<div class="contact-field">
|
|
24
|
-
<
|
|
24
|
+
<label class="contact-label ui-label-sm" for="contact-name">
|
|
25
|
+
{{ placeholders.name }}<span class="contact-required" aria-hidden="true">*</span>
|
|
26
|
+
</label>
|
|
27
|
+
<input
|
|
28
|
+
id="contact-name"
|
|
29
|
+
class="ui-form-control"
|
|
30
|
+
type="text"
|
|
31
|
+
:placeholder="placeholders.name"
|
|
32
|
+
name="entry.757423084"
|
|
33
|
+
autocomplete="name"
|
|
34
|
+
required
|
|
35
|
+
aria-required="true"
|
|
36
|
+
>
|
|
25
37
|
</div>
|
|
26
38
|
<div class="contact-field">
|
|
27
|
-
<
|
|
39
|
+
<label class="contact-label ui-label-sm" for="contact-email">
|
|
40
|
+
{{ placeholders.email }}<span class="contact-required" aria-hidden="true">*</span>
|
|
41
|
+
</label>
|
|
42
|
+
<input
|
|
43
|
+
id="contact-email"
|
|
44
|
+
class="ui-form-control"
|
|
45
|
+
type="email"
|
|
46
|
+
:placeholder="placeholders.email"
|
|
47
|
+
name="entry.886201522"
|
|
48
|
+
autocomplete="email"
|
|
49
|
+
required
|
|
50
|
+
aria-required="true"
|
|
51
|
+
>
|
|
28
52
|
</div>
|
|
29
53
|
<div class="contact-field">
|
|
30
|
-
<
|
|
54
|
+
<label class="contact-label ui-label-sm" for="contact-subject">{{ placeholders.subject }}</label>
|
|
55
|
+
<input
|
|
56
|
+
id="contact-subject"
|
|
57
|
+
class="ui-form-control"
|
|
58
|
+
type="text"
|
|
59
|
+
:placeholder="placeholders.subject"
|
|
60
|
+
name="entry.270717970"
|
|
61
|
+
>
|
|
31
62
|
</div>
|
|
32
63
|
<div class="contact-field">
|
|
33
|
-
<
|
|
64
|
+
<label class="contact-label ui-label-sm" for="contact-phone">{{ placeholders.phone }}</label>
|
|
65
|
+
<input
|
|
66
|
+
id="contact-phone"
|
|
67
|
+
class="ui-form-control"
|
|
68
|
+
type="tel"
|
|
69
|
+
:placeholder="placeholders.phone"
|
|
70
|
+
name="entry.1570175907"
|
|
71
|
+
autocomplete="tel"
|
|
72
|
+
>
|
|
34
73
|
</div>
|
|
35
74
|
<div class="contact-field contact-field--full">
|
|
36
|
-
<
|
|
75
|
+
<label class="contact-label ui-label-sm" for="contact-message">
|
|
76
|
+
{{ placeholders.message }}<span class="contact-required" aria-hidden="true">*</span>
|
|
77
|
+
</label>
|
|
78
|
+
<textarea
|
|
79
|
+
id="contact-message"
|
|
80
|
+
class="ui-form-control ui-form-textarea"
|
|
81
|
+
rows="6"
|
|
82
|
+
:placeholder="placeholders.message"
|
|
83
|
+
name="entry.1743462629"
|
|
84
|
+
required
|
|
85
|
+
aria-required="true"
|
|
86
|
+
></textarea>
|
|
37
87
|
</div>
|
|
38
88
|
<div class="contact-field contact-field--full">
|
|
39
|
-
<label class="contact-label ui-label-sm"
|
|
89
|
+
<label class="contact-label ui-label-sm" for="contact-challenge">
|
|
90
|
+
{{ challenge.label }}<span class="contact-required" aria-hidden="true">*</span>
|
|
91
|
+
</label>
|
|
40
92
|
<small
|
|
41
93
|
v-if="challenge.instructions"
|
|
94
|
+
id="contact-challenge-hint"
|
|
42
95
|
class="contact-hint ui-form-hint"
|
|
43
96
|
:class="{ 'contact-hint--inline': challenge.label.length < 18 }"
|
|
44
97
|
>
|
|
45
98
|
{{ challenge.instructions }}
|
|
46
99
|
</small>
|
|
47
100
|
<div class="ui-input-group">
|
|
48
|
-
<span class="ui-input-group__addon">{{ challenge.question }}</span>
|
|
101
|
+
<span class="ui-input-group__addon" id="contact-challenge-prompt">{{ challenge.question }}</span>
|
|
49
102
|
<input
|
|
103
|
+
id="contact-challenge"
|
|
50
104
|
type="text"
|
|
51
105
|
class="ui-input-group__input"
|
|
52
106
|
:placeholder="challenge.placeholder"
|
|
53
107
|
v-model="challengeAnswer"
|
|
54
108
|
autocomplete="off"
|
|
55
109
|
required
|
|
110
|
+
aria-required="true"
|
|
111
|
+
:aria-describedby="challengeDescribedBy"
|
|
112
|
+
:aria-invalid="hasChallengeError ? 'true' : null"
|
|
56
113
|
>
|
|
57
114
|
</div>
|
|
58
115
|
</div>
|
|
59
116
|
</div>
|
|
60
117
|
|
|
118
|
+
<p class="contact-required-note">
|
|
119
|
+
<span class="contact-required" aria-hidden="true">*</span> Required fields
|
|
120
|
+
</p>
|
|
121
|
+
|
|
61
122
|
<div class="contact-actions">
|
|
62
123
|
<div
|
|
63
124
|
v-if="isConfirmationVisible"
|
|
@@ -69,7 +130,7 @@
|
|
|
69
130
|
</div>
|
|
70
131
|
<button class="primary-button contact-submit" type="submit">{{ submitLabel }}</button>
|
|
71
132
|
</div>
|
|
72
|
-
<
|
|
133
|
+
<p id="msg" class="contact-feedback" role="status" aria-live="polite" aria-atomic="true"></p>
|
|
73
134
|
</form>
|
|
74
135
|
</div>
|
|
75
136
|
</div>
|
|
@@ -77,7 +138,7 @@
|
|
|
77
138
|
</template>
|
|
78
139
|
|
|
79
140
|
<script setup>
|
|
80
|
-
import { inject, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
141
|
+
import { computed, inject, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
81
142
|
import { trackEvent } from '../utils/analytics.js';
|
|
82
143
|
|
|
83
144
|
const title = ref('Contact Us')
|
|
@@ -129,6 +190,14 @@ const challenge = reactive({
|
|
|
129
190
|
})
|
|
130
191
|
|
|
131
192
|
const challengeAnswer = ref('')
|
|
193
|
+
const hasChallengeError = ref(false)
|
|
194
|
+
|
|
195
|
+
const challengeDescribedBy = computed(() => {
|
|
196
|
+
const ids = ['contact-challenge-prompt']
|
|
197
|
+
if (challenge.instructions) ids.push('contact-challenge-hint')
|
|
198
|
+
if (hasChallengeError.value) ids.push('msg')
|
|
199
|
+
return ids.join(' ')
|
|
200
|
+
})
|
|
132
201
|
|
|
133
202
|
let expectedChallengeAnswer = ''
|
|
134
203
|
let activeChallengeType = 'arithmetic'
|
|
@@ -405,12 +474,14 @@ onMounted(() => {
|
|
|
405
474
|
|
|
406
475
|
if (!isChallengeAnswerCorrect(challengeAnswer.value)) {
|
|
407
476
|
msg.textContent = challenge.errorMessage
|
|
477
|
+
hasChallengeError.value = true
|
|
408
478
|
emitSubmitEvent('challenge_failed')
|
|
409
479
|
challengeAnswer.value = ''
|
|
410
480
|
generateChallenge()
|
|
411
481
|
return
|
|
412
482
|
}
|
|
413
483
|
|
|
484
|
+
hasChallengeError.value = false
|
|
414
485
|
msg.textContent = pendingMessage.value
|
|
415
486
|
|
|
416
487
|
try {
|
|
@@ -491,6 +562,17 @@ onUnmounted(() => {
|
|
|
491
562
|
color: var(--contact-label-color, var(--ui-text-primary, #1f2a44));
|
|
492
563
|
}
|
|
493
564
|
|
|
565
|
+
.contact-required {
|
|
566
|
+
margin-left: 0.2em;
|
|
567
|
+
color: var(--contact-required-color, #a83838); /* AA-safe critical text */
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
.contact-required-note {
|
|
571
|
+
margin: -8px 0 0;
|
|
572
|
+
font-size: 0.8rem;
|
|
573
|
+
color: var(--contact-required-note-color, var(--ui-text-muted, #54627b));
|
|
574
|
+
}
|
|
575
|
+
|
|
494
576
|
.contact-hint--inline {
|
|
495
577
|
display: inline-block;
|
|
496
578
|
margin-left: 6px;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
class="draft-gate"
|
|
4
|
+
role="dialog"
|
|
5
|
+
aria-modal="true"
|
|
6
|
+
aria-labelledby="draft-gate-title"
|
|
7
|
+
aria-describedby="draft-gate-description"
|
|
8
|
+
>
|
|
9
|
+
<div class="draft-gate-card" ref="cardRef" tabindex="-1">
|
|
10
|
+
<p class="draft-gate-eyebrow">Draft</p>
|
|
11
|
+
<h1 id="draft-gate-title" class="draft-gate-title">
|
|
12
|
+
This page is not yet public
|
|
13
|
+
</h1>
|
|
14
|
+
<p id="draft-gate-description" class="draft-gate-body">
|
|
15
|
+
{{ bodyText }}
|
|
16
|
+
</p>
|
|
17
|
+
<form class="draft-gate-form" @submit.prevent="handleSubmit" novalidate>
|
|
18
|
+
<label class="draft-gate-label" for="draft-gate-password">
|
|
19
|
+
Password
|
|
20
|
+
</label>
|
|
21
|
+
<input
|
|
22
|
+
id="draft-gate-password"
|
|
23
|
+
ref="inputRef"
|
|
24
|
+
v-model="passwordInput"
|
|
25
|
+
type="password"
|
|
26
|
+
autocomplete="current-password"
|
|
27
|
+
autocapitalize="off"
|
|
28
|
+
autocorrect="off"
|
|
29
|
+
spellcheck="false"
|
|
30
|
+
:aria-invalid="hasError ? 'true' : 'false'"
|
|
31
|
+
aria-describedby="draft-gate-error"
|
|
32
|
+
class="draft-gate-input"
|
|
33
|
+
/>
|
|
34
|
+
<p
|
|
35
|
+
id="draft-gate-error"
|
|
36
|
+
class="draft-gate-error"
|
|
37
|
+
aria-live="polite"
|
|
38
|
+
role="status"
|
|
39
|
+
>
|
|
40
|
+
{{ errorMessage }}
|
|
41
|
+
</p>
|
|
42
|
+
<button type="submit" class="draft-gate-submit">
|
|
43
|
+
{{ submitLabel }}
|
|
44
|
+
</button>
|
|
45
|
+
</form>
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<script setup>
|
|
51
|
+
import { computed, nextTick, onMounted, ref } from 'vue';
|
|
52
|
+
|
|
53
|
+
const props = defineProps({
|
|
54
|
+
hasPassword: {
|
|
55
|
+
type: Boolean,
|
|
56
|
+
default: false,
|
|
57
|
+
},
|
|
58
|
+
errorMessage: {
|
|
59
|
+
type: String,
|
|
60
|
+
default: '',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const emit = defineEmits(['submit']);
|
|
65
|
+
|
|
66
|
+
const passwordInput = ref('');
|
|
67
|
+
const inputRef = ref(null);
|
|
68
|
+
const cardRef = ref(null);
|
|
69
|
+
const hasError = computed(() => Boolean(props.errorMessage));
|
|
70
|
+
|
|
71
|
+
const bodyText = computed(() =>
|
|
72
|
+
props.hasPassword
|
|
73
|
+
? 'Enter the password to preview this content.'
|
|
74
|
+
: 'No password is required — submit to continue.',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const submitLabel = computed(() => (props.hasPassword ? 'Unlock' : 'Continue'));
|
|
78
|
+
|
|
79
|
+
function handleSubmit() {
|
|
80
|
+
emit('submit', passwordInput.value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onMounted(() => {
|
|
84
|
+
if (import.meta.env.SSR) return;
|
|
85
|
+
nextTick(() => {
|
|
86
|
+
inputRef.value?.focus();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<style scoped>
|
|
92
|
+
.draft-gate {
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
justify-content: center;
|
|
96
|
+
min-height: 100dvh;
|
|
97
|
+
padding: clamp(1.5rem, 5vw, 3rem);
|
|
98
|
+
background: var(--brand-surface, #f7f7f8);
|
|
99
|
+
color: var(--brand-text, #1f2a44);
|
|
100
|
+
font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.draft-gate-card {
|
|
104
|
+
width: 100%;
|
|
105
|
+
max-width: 28rem;
|
|
106
|
+
padding: clamp(1.75rem, 4vw, 2.5rem);
|
|
107
|
+
background: var(--brand-surface-elevated, #ffffff);
|
|
108
|
+
border: 1px solid var(--brand-border, rgba(31, 42, 68, 0.12));
|
|
109
|
+
border-radius: clamp(12px, 2vw, 18px);
|
|
110
|
+
box-shadow: 0 18px 38px rgba(15, 22, 42, 0.08);
|
|
111
|
+
outline: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.draft-gate-eyebrow {
|
|
115
|
+
margin: 0 0 0.5rem;
|
|
116
|
+
letter-spacing: 0.16em;
|
|
117
|
+
text-transform: uppercase;
|
|
118
|
+
font-size: 0.7rem;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.6));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.draft-gate-title {
|
|
124
|
+
margin: 0 0 0.75rem;
|
|
125
|
+
font-size: clamp(1.4rem, 2.6vw, 1.75rem);
|
|
126
|
+
line-height: 1.2;
|
|
127
|
+
font-weight: 700;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.draft-gate-body {
|
|
131
|
+
margin: 0 0 1.5rem;
|
|
132
|
+
font-size: 1rem;
|
|
133
|
+
line-height: 1.55;
|
|
134
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.75));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.draft-gate-form {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-direction: column;
|
|
140
|
+
gap: 0.5rem;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.draft-gate-label {
|
|
144
|
+
font-size: 0.85rem;
|
|
145
|
+
font-weight: 600;
|
|
146
|
+
color: var(--brand-text, #1f2a44);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.draft-gate-input {
|
|
150
|
+
width: 100%;
|
|
151
|
+
min-height: 2.75rem;
|
|
152
|
+
padding: 0.65rem 0.85rem;
|
|
153
|
+
font-size: 1rem;
|
|
154
|
+
font-family: inherit;
|
|
155
|
+
background: var(--brand-surface, #ffffff);
|
|
156
|
+
color: inherit;
|
|
157
|
+
border: 1px solid var(--brand-border, rgba(31, 42, 68, 0.25));
|
|
158
|
+
border-radius: var(--brand-input-radius, 8px);
|
|
159
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.draft-gate-input:focus-visible {
|
|
163
|
+
outline: 2px solid var(--brand-primary, #1f2a44);
|
|
164
|
+
outline-offset: 2px;
|
|
165
|
+
border-color: var(--brand-primary, #1f2a44);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.draft-gate-input[aria-invalid='true'] {
|
|
169
|
+
border-color: var(--brand-criticalText, #b3261e);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.draft-gate-error {
|
|
173
|
+
min-height: 1.25rem;
|
|
174
|
+
margin: 0;
|
|
175
|
+
font-size: 0.85rem;
|
|
176
|
+
color: var(--brand-criticalText, #b3261e);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.draft-gate-submit {
|
|
180
|
+
margin-top: 0.5rem;
|
|
181
|
+
min-height: 2.75rem;
|
|
182
|
+
padding: 0.7rem 1.4rem;
|
|
183
|
+
font-size: 1rem;
|
|
184
|
+
font-family: inherit;
|
|
185
|
+
font-weight: 600;
|
|
186
|
+
color: var(--brand-primary-cta-text, #ffffff);
|
|
187
|
+
background: var(--brand-primary-cta, var(--brand-primary, #1f2a44));
|
|
188
|
+
border: none;
|
|
189
|
+
border-radius: var(--brand-button-radius, 10px);
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.draft-gate-submit:hover {
|
|
195
|
+
transform: translateY(-1px);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.draft-gate-submit:focus-visible {
|
|
199
|
+
outline: 2px solid var(--brand-primary, #1f2a44);
|
|
200
|
+
outline-offset: 3px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@media (prefers-reduced-motion: reduce) {
|
|
204
|
+
.draft-gate-submit,
|
|
205
|
+
.draft-gate-input {
|
|
206
|
+
transition: none;
|
|
207
|
+
}
|
|
208
|
+
.draft-gate-submit:hover {
|
|
209
|
+
transform: none;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
</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,10 +8,10 @@
|
|
|
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">
|
|
14
|
+
<a href="/" ref="logoLinkRef" class="site-header__logo logo-flash-trigger" :aria-label="`${siteName || 'Home'} – home`">
|
|
15
15
|
<!--
|
|
16
16
|
Logo slot. Default rendering: <img> if site/assets/img/logo.png
|
|
17
17
|
exists or `content.header.logoSrc` is set, otherwise text from
|
|
@@ -56,6 +56,8 @@
|
|
|
56
56
|
v-if="showLocaleLinks && availableLocales.length > 1"
|
|
57
57
|
ref="localeWrapperRef"
|
|
58
58
|
class="locale-wrapper"
|
|
59
|
+
@keydown.esc="closeLocaleMenu($event, 'escape')"
|
|
60
|
+
@focusout="onLocaleWrapperFocusOut"
|
|
59
61
|
>
|
|
60
62
|
<button
|
|
61
63
|
id="languages-dropdown-invoker-2"
|
|
@@ -63,8 +65,8 @@
|
|
|
63
65
|
class="locale-toggle"
|
|
64
66
|
type="button"
|
|
65
67
|
aria-controls="languages-dropdown-2"
|
|
66
|
-
aria-haspopup="true"
|
|
67
68
|
:aria-expanded="isLangOpen ? 'true' : 'false'"
|
|
69
|
+
aria-label="Change language"
|
|
68
70
|
@click="toggleLocaleMenu"
|
|
69
71
|
>
|
|
70
72
|
<span class="locale-flag" aria-hidden="true">
|
|
@@ -221,6 +223,37 @@ const updateLocaleMenuPosition = () => {
|
|
|
221
223
|
}
|
|
222
224
|
};
|
|
223
225
|
|
|
226
|
+
// Closes the dropdown and (for keyboard dismissal) returns focus to the
|
|
227
|
+
// toggle button. Without focus restoration, ESC would leave focus on a
|
|
228
|
+
// hidden menu item — failing 1.4.13 (Content on Hover or Focus).
|
|
229
|
+
const closeLocaleMenu = (event, trigger = 'escape') => {
|
|
230
|
+
if (!isLangOpen.value) return;
|
|
231
|
+
if (event) {
|
|
232
|
+
event.preventDefault?.();
|
|
233
|
+
event.stopPropagation?.();
|
|
234
|
+
}
|
|
235
|
+
isLangOpen.value = false;
|
|
236
|
+
trackEvent('header_locale_menu_toggle', {
|
|
237
|
+
state: 'close',
|
|
238
|
+
trigger,
|
|
239
|
+
active_locale: currentLocale.value || 'en',
|
|
240
|
+
});
|
|
241
|
+
if (trigger === 'escape') {
|
|
242
|
+
nextTick(() => langButtonRef.value?.focus());
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Closes the menu when keyboard focus leaves the wrapper entirely.
|
|
247
|
+
// Without this, Tab would walk past the dropdown but leave it visible
|
|
248
|
+
// and clickable — also fails 1.4.13's "dismissable" requirement.
|
|
249
|
+
const onLocaleWrapperFocusOut = (event) => {
|
|
250
|
+
if (!isLangOpen.value) return;
|
|
251
|
+
const wrapper = localeWrapperRef.value;
|
|
252
|
+
const next = event.relatedTarget;
|
|
253
|
+
if (wrapper && next && wrapper.contains(next)) return;
|
|
254
|
+
closeLocaleMenu(null, 'focusout');
|
|
255
|
+
};
|
|
256
|
+
|
|
224
257
|
const prefersReducedMotion = () =>
|
|
225
258
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
226
259
|
|