@koehler8/cms 1.0.0-beta.15 → 1.0.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/cms-create-site.js +7 -0
  2. package/package.json +3 -2
  3. package/scripts/create-site.mjs +11 -0
  4. package/scripts/scaffold.mjs +55 -19
  5. package/src/components/ComingSoonModal.vue +37 -5
  6. package/src/components/Contact.vue +91 -9
  7. package/src/components/Footer.vue +15 -2
  8. package/src/components/FooterMinimal.vue +13 -12
  9. package/src/components/Header.vue +36 -3
  10. package/src/components/Hero.vue +7 -6
  11. package/src/components/Home.vue +42 -1
  12. package/src/components/Plan.vue +6 -3
  13. package/src/components/Portfolio.vue +7 -3
  14. package/src/components/StickyCTA.vue +5 -1
  15. package/src/components/Team.vue +3 -1
  16. package/src/styles/base.css +11 -2
  17. package/src/themes/buildCssVarMap.js +25 -0
  18. package/templates/index.html +8 -3
  19. package/templates/scaffolds/site/.nvmrc +1 -0
  20. package/templates/scaffolds/site/CLAUDE.md +121 -0
  21. package/templates/scaffolds/site/README.md +25 -0
  22. package/templates/scaffolds/site/amplify.yml +22 -0
  23. package/templates/scaffolds/site/package.json +26 -0
  24. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  25. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  26. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  27. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  28. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  29. package/templates/scaffolds/site/site/content/en/site.json +7 -0
  30. package/templates/scaffolds/site/site/style.css +4 -0
  31. package/templates/scaffolds/site/vite.config.js +15 -0
  32. package/themes/base/theme.config.js +100 -57
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const scriptPath = path.resolve(__dirname, '..', 'scripts', 'create-site.mjs');
7
+ await import(pathToFileURL(scriptPath).href);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.15",
3
+ "version": "1.0.0-beta.17",
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",
@@ -49,7 +49,8 @@
49
49
  "cms-validate-themes": "./bin/cms-validate-themes.js",
50
50
  "cms-validate-extensions": "./bin/cms-validate-extensions.js",
51
51
  "cms-create-theme": "./bin/cms-create-theme.js",
52
- "cms-create-extension": "./bin/cms-create-extension.js"
52
+ "cms-create-extension": "./bin/cms-create-extension.js",
53
+ "cms-create-site": "./bin/cms-create-site.js"
53
54
  },
54
55
  "files": [
55
56
  "src",
@@ -0,0 +1,11 @@
1
+ import { runScaffold } from './scaffold.mjs';
2
+
3
+ const result = await runScaffold({
4
+ kind: 'site',
5
+ argv: process.argv.slice(2),
6
+ });
7
+
8
+ if (result.message) {
9
+ process[result.ok ? 'stdout' : 'stderr'].write(result.message + '\n');
10
+ }
11
+ process.exit(result.ok ? 0 : 1);
@@ -53,11 +53,16 @@ async function pathExists(p) {
53
53
  }
54
54
  }
55
55
 
56
+ // Files in the template tree that should never be copied into the
57
+ // scaffolded output (OS junk, editor artifacts). Dotfiles in general
58
+ // ARE copied — site templates need .nvmrc, .gitignore, .env.example.
59
+ const TEMPLATE_SKIP = new Set(['.DS_Store', 'Thumbs.db', '.git']);
60
+
56
61
  async function collectTemplateFiles(dir) {
57
62
  const out = [];
58
63
  const entries = await readdir(dir, { withFileTypes: true });
59
64
  for (const entry of entries) {
60
- if (entry.name.startsWith('.')) continue;
65
+ if (TEMPLATE_SKIP.has(entry.name)) continue;
61
66
  const full = path.join(dir, entry.name);
62
67
  if (entry.isDirectory()) {
63
68
  out.push(...(await collectTemplateFiles(full)));
@@ -86,7 +91,7 @@ function applySubstitutions(content, subs) {
86
91
  * @returns {Promise<{ok: boolean, slug?: string, targetDir?: string, message?: string}>}
87
92
  */
88
93
  export async function runScaffold({ kind, argv, cwd = process.cwd(), frameworkRoot } = {}) {
89
- if (kind !== 'theme' && kind !== 'extension') {
94
+ if (kind !== 'theme' && kind !== 'extension' && kind !== 'site') {
90
95
  return { ok: false, message: `unknown kind: ${kind}` };
91
96
  }
92
97
  const root = frameworkRoot || defaultFrameworkRoot();
@@ -115,10 +120,18 @@ export async function runScaffold({ kind, argv, cwd = process.cwd(), frameworkRo
115
120
  const displayName = toDisplayName(slug);
116
121
  const subs = { __SLUG__: slug, __DISPLAY_NAME__: displayName };
117
122
  const templateDir = path.join(root, 'templates', 'scaffolds', kind);
118
- const defaultDirName = kind === 'theme' ? 'themes' : 'extensions';
119
- const targetDir = args.out
120
- ? path.resolve(cwd, args.out)
121
- : path.join(cwd, defaultDirName, slug);
123
+ // Default target dir: themes/<slug>/ for themes, extensions/<slug>/ for
124
+ // extensions, site-<slug>/ (sibling of cwd) for sites — matches the
125
+ // conventional koehler8/site-<slug> repo naming.
126
+ let defaultTarget;
127
+ if (kind === 'site') {
128
+ defaultTarget = path.join(cwd, `site-${slug}`);
129
+ } else if (kind === 'theme') {
130
+ defaultTarget = path.join(cwd, 'themes', slug);
131
+ } else {
132
+ defaultTarget = path.join(cwd, 'extensions', slug);
133
+ }
134
+ const targetDir = args.out ? path.resolve(cwd, args.out) : defaultTarget;
122
135
 
123
136
  if (!(await pathExists(templateDir))) {
124
137
  return {
@@ -153,8 +166,7 @@ export async function runScaffold({ kind, argv, cwd = process.cwd(), frameworkRo
153
166
  }
154
167
 
155
168
  function successHeader(kind, slug, targetRel) {
156
- const fileCount = kind === 'theme' ? 5 : 4;
157
- return `✓ Scaffolded ${kind} "${slug}" → ${targetRel}/ (${fileCount} files)`;
169
+ return `✓ Scaffolded ${kind} "${slug}" → ${targetRel}/`;
158
170
  }
159
171
 
160
172
  function nextSteps(kind, slug, targetRel) {
@@ -173,33 +185,57 @@ function nextSteps(kind, slug, targetRel) {
173
185
  '',
174
186
  ].join('\n');
175
187
  }
188
+ if (kind === 'extension') {
189
+ return [
190
+ '',
191
+ 'Next steps:',
192
+ ' 1. Add to your site\'s package.json dependencies:',
193
+ ` "cms-ext-${slug}": "file:./${targetRel}"`,
194
+ ' 2. Run `npm install` to symlink it into node_modules.',
195
+ ' 3. Add to vite.config.js\'s extensions array:',
196
+ ` extensions: ['cms-ext-${slug}'],`,
197
+ ` 4. Add components to ${targetRel}/components/ as needed (see ${targetRel}/README.md).`,
198
+ ` Or, for one-off site-only components, prefer site/components/ instead.`,
199
+ '',
200
+ ].join('\n');
201
+ }
202
+ // site
176
203
  return [
177
204
  '',
178
205
  'Next steps:',
179
- ' 1. Add to your site\'s package.json dependencies:',
180
- ` "cms-ext-${slug}": "file:./${targetRel}"`,
181
- ' 2. Run `npm install` to symlink it into node_modules.',
182
- ' 3. Add to vite.config.js\'s extensions array:',
183
- ` extensions: ['cms-ext-${slug}'],`,
184
- ` 4. Add components to ${targetRel}/components/ as needed (see ${targetRel}/README.md).`,
185
- ` Or, for one-off site-only components, prefer site/components/ instead.`,
206
+ ` 1. cd ${targetRel}`,
207
+ ' 2. nvm use && npm install',
208
+ ' 3. npm run dev # Vite dev server on http://localhost:5173',
209
+ ' 4. Read CLAUDE.md # navigation guide for "where things go"',
210
+ '',
211
+ 'Then start customizing:',
212
+ ` - Edit site/content/en/site.json (title, description, theme key)`,
213
+ ` - Edit site/content/en/pages/home.json (hero copy, components)`,
214
+ ` - Add a theme: npx cms-create-theme <slug>`,
215
+ ` - Add a local extension: npx cms-create-extension <slug>`,
216
+ ` - Add a one-off Vue component: drop a file in site/components/`,
186
217
  '',
187
218
  ].join('\n');
188
219
  }
189
220
 
190
221
  function usage(kind) {
191
- const cmd = kind === 'theme' ? 'cms-create-theme' : 'cms-create-extension';
192
- const dir = kind === 'theme' ? 'themes' : 'extensions';
222
+ const cmds = { theme: 'cms-create-theme', extension: 'cms-create-extension', site: 'cms-create-site' };
223
+ const cmd = cmds[kind];
224
+ const defaultDir = {
225
+ theme: './themes/<slug>/',
226
+ extension: './extensions/<slug>/',
227
+ site: './site-<slug>/',
228
+ }[kind];
193
229
  return `
194
230
  Usage: npx ${cmd} <slug> [--out <directory>] [--force]
195
231
 
196
- Scaffolds a new @koehler8/cms ${kind} into the current site.
232
+ Scaffolds a new @koehler8/cms ${kind} into the current directory.
197
233
 
198
234
  Arguments:
199
235
  <slug> Lowercase kebab-case slug (e.g. "coastal", "lush-canopy").
200
236
 
201
237
  Options:
202
- --out <dir> Where to create the ${kind}. Defaults to ./${dir}/<slug>/.
238
+ --out <dir> Where to create the ${kind}. Defaults to ${defaultDir}.
203
239
  --force Overwrite an existing target directory.
204
240
  -h, --help Show this message.
205
241
  `;
@@ -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;
@@ -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
 
@@ -7,7 +7,7 @@
7
7
  >
8
8
  <div class="container text-center hero-content hero-inner">
9
9
  <div class="hero-eyebrow fade-up-in js-scroll-fade" style="--fade-up-delay: 0s">
10
- <h2
10
+ <p
11
11
  :class="[
12
12
  'h4',
13
13
  'text-uppercase',
@@ -16,9 +16,9 @@
16
16
  ]"
17
17
  >
18
18
  {{ tagLine }}
19
- </h2>
19
+ </p>
20
20
  </div>
21
- <h3
21
+ <h1
22
22
  :class="[
23
23
  'display-heading',
24
24
  'text-uppercase',
@@ -38,7 +38,7 @@
38
38
  >
39
39
  {{ part }}
40
40
  </span>
41
- </h3>
41
+ </h1>
42
42
  <div
43
43
  class="hero-cta-stack fade-up-in js-scroll-fade"
44
44
  style="--fade-up-delay: 0.2s"
@@ -256,8 +256,9 @@ function getHeaderOffset() {
256
256
  letter-spacing: 0.12em;
257
257
  font-size: clamp(1.1rem, 1.5vw, 1.55rem);
258
258
  line-height: 1.35;
259
- color: var(--hero-text-color, var(--ui-text-primary, var(--brand-fg-100, #111)));
259
+ color: var(--hero-eyebrow-color, var(--brand-hero-text-on-dark, var(--brand-hero-text, #f0eaf3)));
260
260
  border-bottom: 1px solid currentColor;
261
+ margin: 0;
261
262
  }
262
263
 
263
264
  .hero-headline {
@@ -266,7 +267,7 @@ function getHeaderOffset() {
266
267
  line-height: 1.08;
267
268
  margin-bottom: clamp(18px, 2.5vw, 28px);
268
269
  letter-spacing: 0.04em;
269
- color: var(--hero-text-color, var(--ui-text-primary, var(--brand-fg-100, #111)));
270
+ color: var(--hero-text-color, var(--brand-hero-text-on-dark, var(--brand-hero-text, #f0eaf3)));
270
271
  }
271
272
 
272
273
  .promo-surface {