@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.16",
3
+ "version": "1.0.0-beta.18",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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.75rem;
216
- right: 0.75rem;
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: #888;
223
- padding: 0.25rem;
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
- <input class="ui-form-control" type="text" :placeholder="placeholders.name" name="entry.757423084" required>
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
- <input class="ui-form-control" type="email" :placeholder="placeholders.email" name="entry.886201522" required>
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
- <input class="ui-form-control" type="text" :placeholder="placeholders.subject" name="entry.270717970">
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
- <input class="ui-form-control" type="tel" :placeholder="placeholders.phone" name="entry.1570175907">
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
- <textarea class="ui-form-control ui-form-textarea" rows="6" :placeholder="placeholders.message" name="entry.1743462629" required></textarea>
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">{{ challenge.label }}</label>
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
- <small id="msg" class="contact-feedback"></small>
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="/" 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,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