@koehler8/cms 1.0.0-beta.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +1522 -0
  2. package/README.md +108 -28
  3. package/bin/cms-create-extension.js +7 -0
  4. package/bin/cms-create-site.js +7 -0
  5. package/bin/cms-create-theme.js +7 -0
  6. package/bin/cms-ssg-build.js +13 -0
  7. package/bin/cms-validate-extensions.js +4 -0
  8. package/bin/cms-validate-themes.js +11 -0
  9. package/extensions/manifest.schema.json +1 -1
  10. package/package.json +33 -17
  11. package/scripts/create-extension.mjs +11 -0
  12. package/scripts/create-site.mjs +11 -0
  13. package/scripts/create-theme.mjs +11 -0
  14. package/scripts/generate-public-assets.js +70 -36
  15. package/scripts/image-variants/cache.js +40 -0
  16. package/scripts/image-variants/classifier.js +53 -0
  17. package/scripts/image-variants/config.js +54 -0
  18. package/scripts/image-variants/index.js +18 -0
  19. package/scripts/image-variants/manifest.js +76 -0
  20. package/scripts/image-variants/planner.js +95 -0
  21. package/scripts/image-variants/reconcile.js +194 -0
  22. package/scripts/image-variants/renderer.js +51 -0
  23. package/scripts/lib/extension-manifests.mjs +70 -0
  24. package/scripts/scaffold.mjs +242 -0
  25. package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
  26. package/scripts/ssg-build/index.mjs +253 -0
  27. package/scripts/validate-extensions.mjs +50 -382
  28. package/scripts/validate-themes.mjs +37 -5
  29. package/src/components/About.vue +4 -2
  30. package/src/components/AboutValue.vue +5 -4
  31. package/src/components/ComingSoon.vue +12 -1
  32. package/src/components/ComingSoonModal.vue +33 -61
  33. package/src/components/Contact.vue +216 -120
  34. package/src/components/DraftGate.vue +211 -0
  35. package/src/components/Footer.vue +15 -2
  36. package/src/components/FooterMinimal.vue +13 -12
  37. package/src/components/Header.vue +365 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +82 -66
  40. package/src/components/Intro.vue +4 -2
  41. package/src/components/IntroGate.vue +30 -73
  42. package/src/components/NotFound.vue +100 -0
  43. package/src/components/Plan.vue +10 -4
  44. package/src/components/Portfolio.vue +29 -3
  45. package/src/components/Principles.vue +3 -1
  46. package/src/components/Spacer.vue +23 -0
  47. package/src/components/Spacer15.vue +3 -4
  48. package/src/components/Spacer30.vue +3 -4
  49. package/src/components/Spacer40.vue +3 -4
  50. package/src/components/Spacer60.vue +3 -4
  51. package/src/components/StickyCTA.vue +23 -1
  52. package/src/components/Team.vue +3 -1
  53. package/src/composables/useComponentResolver.js +49 -1
  54. package/src/composables/useDraftGate.js +93 -0
  55. package/src/composables/useFocusTrap.js +85 -0
  56. package/src/composables/usePageConfig.js +136 -36
  57. package/src/composables/usePageMeta.js +178 -5
  58. package/src/composables/usePromoBackgroundStyles.js +30 -14
  59. package/src/extensions/extensionLoader.js +34 -24
  60. package/src/main.js +69 -6
  61. package/src/router/index.js +98 -37
  62. package/src/styles/base.css +11 -2
  63. package/src/themes/buildCssVarMap.js +34 -0
  64. package/src/utils/appInfo.js +8 -5
  65. package/src/utils/breadcrumbs.js +121 -0
  66. package/src/utils/canonicalUrl.js +53 -0
  67. package/src/utils/componentRegistry.js +98 -16
  68. package/src/utils/cookieConsent.js +38 -24
  69. package/src/utils/draftMode.js +65 -0
  70. package/src/utils/imageSources.js +9 -1
  71. package/src/utils/jsonLd.js +66 -0
  72. package/src/utils/loadConfig.js +106 -6
  73. package/src/utils/notFound.js +52 -0
  74. package/src/utils/robotsGenerator.js +95 -0
  75. package/src/utils/sha256.js +20 -0
  76. package/src/utils/sitemapGenerator.js +119 -0
  77. package/src/utils/socialMeta.js +100 -0
  78. package/src/utils/ssgRoutes.js +40 -0
  79. package/src/utils/trackingContext.js +16 -22
  80. package/src/utils/webAppManifest.js +85 -0
  81. package/src/utils/webStorage.js +82 -0
  82. package/templates/index.html +17 -24
  83. package/templates/scaffolds/extension/README.md +53 -0
  84. package/templates/scaffolds/extension/extension.config.json +10 -0
  85. package/templates/scaffolds/extension/index.js +37 -0
  86. package/templates/scaffolds/extension/package.json +22 -0
  87. package/templates/scaffolds/site/.env.example +7 -0
  88. package/templates/scaffolds/site/.nvmrc +1 -0
  89. package/templates/scaffolds/site/CLAUDE.md +121 -0
  90. package/templates/scaffolds/site/README.md +25 -0
  91. package/templates/scaffolds/site/amplify.yml +22 -0
  92. package/templates/scaffolds/site/package.json +26 -0
  93. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  95. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  96. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  97. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  98. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  99. package/templates/scaffolds/site/site/style.css +4 -0
  100. package/templates/scaffolds/site/vite.config.js +15 -0
  101. package/templates/scaffolds/theme/README.md +50 -0
  102. package/templates/scaffolds/theme/index.js +8 -0
  103. package/templates/scaffolds/theme/package.json +21 -0
  104. package/templates/scaffolds/theme/theme.config.js +228 -0
  105. package/templates/scaffolds/theme/theme.css +14 -0
  106. package/themes/base/theme.config.js +100 -57
  107. package/vite-plugin.js +549 -32
  108. package/public/robots.txt +0 -5
  109. package/scripts/check-overflow.mjs +0 -33
  110. package/scripts/migrate-to-content-dirs.mjs +0 -223
  111. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -48,7 +48,8 @@
48
48
  </template>
49
49
 
50
50
  <script setup>
51
- import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
51
+ import { computed, ref } from 'vue';
52
+ import { useFocusTrap } from '../composables/useFocusTrap.js';
52
53
 
53
54
  const props = defineProps({
54
55
  open: {
@@ -68,7 +69,6 @@ const props = defineProps({
68
69
  const emit = defineEmits(['close']);
69
70
 
70
71
  const dialogRef = ref(null);
71
- const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
72
72
  const dialogTitleId = computed(() => 'coming-soon-title');
73
73
  const dialogDescriptionId = computed(() => 'coming-soon-message');
74
74
 
@@ -76,58 +76,7 @@ const emitClose = () => {
76
76
  emit('close');
77
77
  };
78
78
 
79
- const trapFocus = (event) => {
80
- if (!props.open) return;
81
- if (typeof document === 'undefined') return;
82
- if (event.key !== 'Tab') return;
83
- const dialogEl = dialogRef.value;
84
- if (!dialogEl) return;
85
- const focusable = dialogEl.querySelectorAll(focusableSelector);
86
- if (!focusable.length) {
87
- event.preventDefault();
88
- return;
89
- }
90
- const first = focusable[0];
91
- const last = focusable[focusable.length - 1];
92
- if (event.shiftKey && document.activeElement === first) {
93
- event.preventDefault();
94
- last.focus();
95
- } else if (!event.shiftKey && document.activeElement === last) {
96
- event.preventDefault();
97
- first.focus();
98
- }
99
- };
100
-
101
- watch(
102
- () => props.open,
103
- (isOpen) => {
104
- if (typeof document === 'undefined') return;
105
- if (isOpen) {
106
- if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
107
- window.requestAnimationFrame(() => {
108
- dialogRef.value?.focus();
109
- });
110
- } else {
111
- dialogRef.value?.focus();
112
- }
113
- document.addEventListener('keydown', trapFocus);
114
- } else {
115
- document.removeEventListener('keydown', trapFocus);
116
- }
117
- }
118
- );
119
-
120
- onMounted(() => {
121
- if (typeof document === 'undefined') return;
122
- if (props.open) {
123
- document.addEventListener('keydown', trapFocus);
124
- }
125
- });
126
-
127
- onBeforeUnmount(() => {
128
- if (typeof document === 'undefined') return;
129
- document.removeEventListener('keydown', trapFocus);
130
- });
79
+ useFocusTrap(dialogRef, () => props.open, { onEscape: emitClose });
131
80
  </script>
132
81
 
133
82
  <style scoped>
@@ -200,31 +149,54 @@ onBeforeUnmount(() => {
200
149
  }
201
150
 
202
151
  .coming-soon-cta:hover,
203
- .coming-soon-cta:focus {
152
+ .coming-soon-cta:focus-visible {
204
153
  opacity: 0.92;
205
154
  transform: translateY(-1px);
206
155
  }
207
156
 
208
- .coming-soon-cta:focus {
157
+ .coming-soon-cta:focus-visible {
209
158
  outline: 2px solid #111111;
210
159
  outline-offset: 2px;
211
160
  }
212
161
 
213
162
  .coming-soon-close {
214
163
  position: absolute;
215
- top: 0.75rem;
216
- right: 0.75rem;
164
+ top: 0.5rem;
165
+ right: 0.5rem;
217
166
  background: transparent;
218
167
  border: none;
219
168
  font-size: 1.5rem;
220
169
  line-height: 1;
221
170
  cursor: pointer;
222
- color: #888;
223
- padding: 0.25rem;
171
+ /* AA-safe color (#595959 = 7:1 on white) and a 32×32 hit area for
172
+ 2.5.8 Target Size (Minimum). */
173
+ color: #595959;
174
+ padding: 0;
175
+ min-width: 32px;
176
+ min-height: 32px;
177
+ display: inline-flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ border-radius: 6px;
224
181
  }
225
182
 
226
183
  .coming-soon-close:hover,
227
- .coming-soon-close:focus {
184
+ .coming-soon-close:focus-visible {
228
185
  color: #111111;
229
186
  }
187
+ .coming-soon-close:focus-visible {
188
+ outline: 2px solid #1f2a44;
189
+ outline-offset: 2px;
190
+ }
191
+
192
+ @media (prefers-reduced-motion: reduce) {
193
+ .coming-soon-cta {
194
+ transition: none;
195
+ }
196
+
197
+ .coming-soon-cta:hover,
198
+ .coming-soon-cta:focus-visible {
199
+ transform: none;
200
+ }
201
+ }
230
202
  </style>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <section id="contact" class="contact-section ui-section ui-section--stacked" data-analytics-section="contact">
2
+ <section v-if="hasContent" id="contact" class="contact-section ui-section ui-section--stacked" data-analytics-section="contact">
3
3
  <div class="container">
4
4
  <header class="section-header text-center">
5
5
  <div class="section-heading">
@@ -9,9 +9,15 @@
9
9
  <p class="section-description">{{ subtitle }}</p>
10
10
  </header>
11
11
  <div class="contact-wrapper">
12
- <form id="signup" class="contact-form ui-card ui-card-surface" :action="formAction" method="POST">
12
+ <form
13
+ id="signup"
14
+ ref="formRef"
15
+ class="contact-form ui-card ui-card-surface"
16
+ :action="formAction"
17
+ method="POST"
18
+ @submit.prevent="handleSubmit"
19
+ >
13
20
  <!-- Google Forms requires these hidden fields -->
14
- <input type="hidden" name="entry.1210125274" id="siteToken">
15
21
  <input type="hidden" name="fvv" :value="hiddenFields.fvv">
16
22
  <!-- Replace the fbzx value with the real one from your form's HTML source or prefilled link -->
17
23
  <input type="hidden" name="fbzx" :value="hiddenFields.fbzx">
@@ -21,55 +27,122 @@
21
27
 
22
28
  <div class="contact-grid">
23
29
  <div class="contact-field">
24
- <input class="ui-form-control" type="text" :placeholder="placeholders.name" name="entry.757423084" required>
30
+ <label class="contact-label ui-label-sm" for="contact-name">
31
+ {{ placeholders.name }}<span class="contact-required" aria-hidden="true">*</span>
32
+ </label>
33
+ <input
34
+ id="contact-name"
35
+ class="ui-form-control"
36
+ type="text"
37
+ :placeholder="placeholders.name"
38
+ name="entry.757423084"
39
+ autocomplete="name"
40
+ required
41
+ aria-required="true"
42
+ >
25
43
  </div>
26
44
  <div class="contact-field">
27
- <input class="ui-form-control" type="email" :placeholder="placeholders.email" name="entry.886201522" required>
45
+ <label class="contact-label ui-label-sm" for="contact-email">
46
+ {{ placeholders.email }}<span class="contact-required" aria-hidden="true">*</span>
47
+ </label>
48
+ <input
49
+ id="contact-email"
50
+ class="ui-form-control"
51
+ type="email"
52
+ :placeholder="placeholders.email"
53
+ name="entry.886201522"
54
+ autocomplete="email"
55
+ required
56
+ aria-required="true"
57
+ >
28
58
  </div>
29
59
  <div class="contact-field">
30
- <input class="ui-form-control" type="text" :placeholder="placeholders.subject" name="entry.270717970">
60
+ <label class="contact-label ui-label-sm" for="contact-subject">{{ placeholders.subject }}</label>
61
+ <input
62
+ id="contact-subject"
63
+ class="ui-form-control"
64
+ type="text"
65
+ :placeholder="placeholders.subject"
66
+ name="entry.270717970"
67
+ >
31
68
  </div>
32
69
  <div class="contact-field">
33
- <input class="ui-form-control" type="tel" :placeholder="placeholders.phone" name="entry.1570175907">
70
+ <label class="contact-label ui-label-sm" for="contact-phone">{{ placeholders.phone }}</label>
71
+ <input
72
+ id="contact-phone"
73
+ class="ui-form-control"
74
+ type="tel"
75
+ :placeholder="placeholders.phone"
76
+ name="entry.1570175907"
77
+ autocomplete="tel"
78
+ >
34
79
  </div>
35
80
  <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>
81
+ <label class="contact-label ui-label-sm" for="contact-message">
82
+ {{ placeholders.message }}<span class="contact-required" aria-hidden="true">*</span>
83
+ </label>
84
+ <textarea
85
+ id="contact-message"
86
+ class="ui-form-control ui-form-textarea"
87
+ rows="6"
88
+ :placeholder="placeholders.message"
89
+ name="entry.1743462629"
90
+ required
91
+ aria-required="true"
92
+ ></textarea>
37
93
  </div>
38
94
  <div class="contact-field contact-field--full">
39
- <label class="contact-label ui-label-sm">{{ challenge.label }}</label>
95
+ <label class="contact-label ui-label-sm" for="contact-challenge">
96
+ {{ challenge.label }}<span class="contact-required" aria-hidden="true">*</span>
97
+ </label>
40
98
  <small
41
99
  v-if="challenge.instructions"
100
+ id="contact-challenge-hint"
42
101
  class="contact-hint ui-form-hint"
43
102
  :class="{ 'contact-hint--inline': challenge.label.length < 18 }"
44
103
  >
45
104
  {{ challenge.instructions }}
46
105
  </small>
47
106
  <div class="ui-input-group">
48
- <span class="ui-input-group__addon">{{ challenge.question }}</span>
107
+ <span class="ui-input-group__addon" id="contact-challenge-prompt">{{ challenge.question }}</span>
49
108
  <input
109
+ id="contact-challenge"
50
110
  type="text"
51
111
  class="ui-input-group__input"
52
112
  :placeholder="challenge.placeholder"
53
113
  v-model="challengeAnswer"
54
114
  autocomplete="off"
55
115
  required
116
+ aria-required="true"
117
+ :aria-describedby="challengeDescribedBy"
118
+ :aria-invalid="hasChallengeError ? 'true' : null"
56
119
  >
57
120
  </div>
58
121
  </div>
59
122
  </div>
60
123
 
124
+ <p class="contact-required-note">
125
+ <span class="contact-required" aria-hidden="true">*</span> Required fields
126
+ </p>
127
+
61
128
  <div class="contact-actions">
62
- <div
63
- v-if="isConfirmationVisible"
64
- class="submitted"
65
- role="status"
66
- aria-live="polite"
67
- >
68
- {{ confirmationText }}
69
- </div>
70
- <button class="primary-button contact-submit" type="submit">{{ submitLabel }}</button>
129
+ <button
130
+ class="primary-button contact-submit"
131
+ type="submit"
132
+ :disabled="!isFormConfigured"
133
+ >{{ submitLabel }}</button>
71
134
  </div>
72
- <small id="msg" class="contact-feedback"></small>
135
+ <p v-if="!isFormConfigured" class="contact-feedback">
136
+ The contact form isn't accepting messages right now.
137
+ </p>
138
+ <p
139
+ id="msg"
140
+ class="contact-feedback"
141
+ :class="feedbackKind ? `contact-feedback--${feedbackKind}` : null"
142
+ role="status"
143
+ aria-live="polite"
144
+ aria-atomic="true"
145
+ >{{ feedbackText }}</p>
73
146
  </form>
74
147
  </div>
75
148
  </div>
@@ -77,28 +150,32 @@
77
150
  </template>
78
151
 
79
152
  <script setup>
80
- import { inject, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
153
+ import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
81
154
  import { trackEvent } from '../utils/analytics.js';
155
+ import { readStoredLocale } from '../utils/webStorage.js';
82
156
 
83
157
  const title = ref('Contact Us')
84
158
  const subtitle = ref("Send us a message and we'll keep you updated.")
85
159
  const formAction = ref('')
86
160
  const submitLabel = ref('Send Message')
87
161
  const confirmationText = ref('Your message has been sent successfully.')
88
- const isConfirmationVisible = ref(false)
89
162
  const honeypotMessage = ref('Thanks!')
90
163
  const pendingMessage = ref('Submitting…')
91
164
  const successMessage = ref('Thanks! Your message was sent.')
92
165
  const errorMessage = ref('Sorry—something went wrong. Please try again.')
93
166
 
94
- const SUBMISSION_TOKEN = '';
167
+ // Single live feedback region (#msg): success, error, and pending states all
168
+ // announce through it exactly once.
169
+ const feedbackText = ref('')
170
+ const feedbackKind = ref('')
171
+
172
+ // A configured action URL is the only way a submission can go anywhere.
173
+ // Without one the form is inert and says so — previously it posted to the
174
+ // page's own URL and reported success.
175
+ const isFormConfigured = computed(() => /^https?:\/\//i.test(formAction.value || ''))
95
176
 
96
177
  function getTrackedLocale() {
97
- try {
98
- const stored = localStorage.getItem('locale');
99
- if (stored) return stored;
100
- } catch (_) {}
101
- return 'default';
178
+ return readStoredLocale() || 'default';
102
179
  }
103
180
 
104
181
  const placeholders = reactive({
@@ -129,6 +206,14 @@ const challenge = reactive({
129
206
  })
130
207
 
131
208
  const challengeAnswer = ref('')
209
+ const hasChallengeError = ref(false)
210
+
211
+ const challengeDescribedBy = computed(() => {
212
+ const ids = ['contact-challenge-prompt']
213
+ if (challenge.instructions) ids.push('contact-challenge-hint')
214
+ if (hasChallengeError.value) ids.push('msg')
215
+ return ids.join(' ')
216
+ })
132
217
 
133
218
  let expectedChallengeAnswer = ''
134
219
  let activeChallengeType = 'arithmetic'
@@ -243,6 +328,11 @@ function normalizeChallengeType(type) {
243
328
  }
244
329
 
245
330
  function generateChallenge() {
331
+ // Deferred until mount: generating during setup ran on both the server and
332
+ // the client with different random values, so the prerendered question
333
+ // never matched what the client validated against — a guaranteed hydration
334
+ // mismatch on every page load.
335
+ if (!isMounted.value) return
246
336
  const configuredTypes = Array.isArray(challenge.types) && challenge.types.length
247
337
  ? challenge.types
248
338
  : [...DEFAULT_CHALLENGE_TYPES]
@@ -258,8 +348,6 @@ function generateChallenge() {
258
348
  activeChallengeType = chosenType
259
349
  }
260
350
 
261
- generateChallenge()
262
-
263
351
  function answersMatch(expected, received) {
264
352
  const expectedValue = String(expected ?? '').trim()
265
353
  const receivedValue = String(received ?? '').trim()
@@ -276,12 +364,16 @@ function isChallengeAnswerCorrect(answer) {
276
364
  return answersMatch(expectedChallengeAnswer, answer)
277
365
  }
278
366
 
279
- let formElement
280
- let submitHandler
281
- let tokenField
367
+ const formRef = ref(null)
368
+ const isMounted = ref(false)
282
369
 
283
370
  const pageContent = inject('pageContent', ref({}))
284
371
 
372
+ // components[] contract: no content.contact block → render nothing. A
373
+ // contact form can't work without configuration anyway (the default form
374
+ // action is empty), so an unconfigured Contact rendered a decoy form.
375
+ const hasContent = computed(() => Object.keys(pageContent.value?.contact || {}).length > 0)
376
+
285
377
  const defaultTitle = title.value
286
378
  const defaultSubtitle = subtitle.value
287
379
  const defaultFormAction = formAction.value
@@ -314,7 +406,6 @@ watch(
314
406
  formAction.value = formCopy.action ?? defaultFormAction
315
407
  submitLabel.value = formCopy.submitLabel ?? defaultSubmitLabel
316
408
  confirmationText.value = formCopy.confirmationText ?? defaultConfirmationText
317
- isConfirmationVisible.value = false
318
409
  honeypotMessage.value = formCopy.honeypotMessage ?? defaultHoneypot
319
410
  pendingMessage.value = formCopy.pendingMessage ?? defaultPending
320
411
  successMessage.value = formCopy.successMessage ?? defaultSuccess
@@ -354,103 +445,97 @@ watch(
354
445
  )
355
446
 
356
447
  onMounted(() => {
357
- formElement = document.getElementById('signup')
358
- const msg = document.getElementById('msg')
359
- tokenField = document.getElementById('siteToken')
360
-
361
- const resetTokenField = () => {
362
- if (tokenField) {
363
- tokenField.value = SUBMISSION_TOKEN
364
- tokenField.defaultValue = SUBMISSION_TOKEN
365
- }
366
- }
367
-
368
- resetTokenField()
448
+ isMounted.value = true
449
+ challengeAnswer.value = ''
450
+ generateChallenge()
451
+ })
369
452
 
370
- if (!formElement || !msg) return
453
+ async function handleSubmit() {
454
+ const formElement = formRef.value
455
+ if (!formElement) return
371
456
 
372
- submitHandler = async (e) => {
373
- e.preventDefault()
374
- isConfirmationVisible.value = false
457
+ feedbackKind.value = ''
458
+ feedbackText.value = ''
375
459
 
376
- const startedAt = typeof performance !== 'undefined' && typeof performance.now === 'function'
377
- ? performance.now()
378
- : undefined
379
- const baseParams = {
380
- locale: getTrackedLocale()
381
- }
382
- const emitSubmitEvent = (status, extra = {}) => {
383
- const params = { ...baseParams, status, challenge_type: activeChallengeType, ...extra }
384
- if (startedAt !== undefined && typeof performance !== 'undefined' && typeof performance.now === 'function') {
385
- params.duration_ms = Math.round(performance.now() - startedAt)
386
- }
387
- trackEvent('contact_form_submit', params)
460
+ const startedAt = typeof performance !== 'undefined' && typeof performance.now === 'function'
461
+ ? performance.now()
462
+ : undefined
463
+ const baseParams = {
464
+ locale: getTrackedLocale()
465
+ }
466
+ const emitSubmitEvent = (status, extra = {}) => {
467
+ const params = { ...baseParams, status, challenge_type: activeChallengeType, ...extra }
468
+ if (startedAt !== undefined && typeof performance !== 'undefined' && typeof performance.now === 'function') {
469
+ params.duration_ms = Math.round(performance.now() - startedAt)
388
470
  }
471
+ trackEvent('contact_form_submit', params)
472
+ }
389
473
 
390
- emitSubmitEvent('attempt')
391
-
392
- const hp = formElement.querySelector('input[name="website"]')
393
- if (hp && hp.value) {
394
- msg.textContent = honeypotMessage.value
395
- formElement.reset()
396
- challengeAnswer.value = ''
397
- generateChallenge()
398
- resetTokenField()
399
- trackEvent('contact_form_honeypot_triggered', {
400
- ...baseParams,
401
- challenge_type: 'honeypot_field'
402
- })
403
- return
404
- }
474
+ emitSubmitEvent('attempt')
405
475
 
406
- if (!isChallengeAnswerCorrect(challengeAnswer.value)) {
407
- msg.textContent = challenge.errorMessage
408
- emitSubmitEvent('challenge_failed')
409
- challengeAnswer.value = ''
410
- generateChallenge()
411
- return
412
- }
476
+ if (!isFormConfigured.value) {
477
+ feedbackKind.value = 'error'
478
+ feedbackText.value = errorMessage.value
479
+ emitSubmitEvent('unconfigured')
480
+ return
481
+ }
413
482
 
414
- msg.textContent = pendingMessage.value
415
-
416
- try {
417
- resetTokenField()
418
- const formData = new FormData(formElement)
419
- formData.set('entry.1210125274', SUBMISSION_TOKEN)
420
-
421
- await fetch(formElement.action, {
422
- method: 'POST',
423
- mode: 'no-cors',
424
- body: formData
425
- })
426
- msg.textContent = successMessage.value
427
- formElement.reset()
428
- challengeAnswer.value = ''
429
- generateChallenge()
430
- resetTokenField()
431
- isConfirmationVisible.value = !!(confirmationText.value && confirmationText.value.trim())
432
- emitSubmitEvent('success')
433
- } catch (err) {
434
- console.error('[Contact] Form submission failed:', err)
435
- msg.textContent = errorMessage.value
436
- emitSubmitEvent('error', {
437
- error_name: err?.name || 'unknown',
438
- error_code: err?.code || 'unknown'
439
- })
440
- challengeAnswer.value = ''
441
- generateChallenge()
442
- resetTokenField()
443
- }
483
+ const hp = formElement.querySelector('input[name="website"]')
484
+ if (hp && hp.value) {
485
+ feedbackKind.value = 'success'
486
+ feedbackText.value = honeypotMessage.value
487
+ formElement.reset()
488
+ challengeAnswer.value = ''
489
+ generateChallenge()
490
+ trackEvent('contact_form_honeypot_triggered', {
491
+ ...baseParams,
492
+ challenge_type: 'honeypot_field'
493
+ })
494
+ return
444
495
  }
445
496
 
446
- formElement.addEventListener('submit', submitHandler)
447
- })
497
+ if (!isChallengeAnswerCorrect(challengeAnswer.value)) {
498
+ feedbackKind.value = 'error'
499
+ feedbackText.value = challenge.errorMessage
500
+ hasChallengeError.value = true
501
+ emitSubmitEvent('challenge_failed')
502
+ challengeAnswer.value = ''
503
+ generateChallenge()
504
+ return
505
+ }
448
506
 
449
- onUnmounted(() => {
450
- if (formElement && submitHandler) {
451
- formElement.removeEventListener('submit', submitHandler)
507
+ hasChallengeError.value = false
508
+ feedbackKind.value = 'pending'
509
+ feedbackText.value = pendingMessage.value
510
+
511
+ try {
512
+ const formData = new FormData(formElement)
513
+ await fetch(formAction.value, {
514
+ method: 'POST',
515
+ mode: 'no-cors',
516
+ body: formData
517
+ })
518
+ // no-cors responses are opaque: resolving means the request reached the
519
+ // endpoint, not that it was accepted — the strongest claim available.
520
+ feedbackKind.value = 'success'
521
+ feedbackText.value = (confirmationText.value || '').trim() || successMessage.value
522
+ formElement.reset()
523
+ challengeAnswer.value = ''
524
+ generateChallenge()
525
+ emitSubmitEvent('success')
526
+ } catch (err) {
527
+ // Network-level failure: report it and keep everything the visitor typed.
528
+ console.error('[Contact] Form submission failed:', err)
529
+ feedbackKind.value = 'error'
530
+ feedbackText.value = errorMessage.value
531
+ emitSubmitEvent('error', {
532
+ error_name: err?.name || 'unknown',
533
+ error_code: err?.code || 'unknown'
534
+ })
535
+ challengeAnswer.value = ''
536
+ generateChallenge()
452
537
  }
453
- })
538
+ }
454
539
  </script>
455
540
 
456
541
  <style scoped>
@@ -491,6 +576,17 @@ onUnmounted(() => {
491
576
  color: var(--contact-label-color, var(--ui-text-primary, #1f2a44));
492
577
  }
493
578
 
579
+ .contact-required {
580
+ margin-left: 0.2em;
581
+ color: var(--contact-required-color, #a83838); /* AA-safe critical text */
582
+ }
583
+
584
+ .contact-required-note {
585
+ margin: -8px 0 0;
586
+ font-size: 0.8rem;
587
+ color: var(--contact-required-note-color, var(--ui-text-muted, #54627b));
588
+ }
589
+
494
590
  .contact-hint--inline {
495
591
  display: inline-block;
496
592
  margin-left: 6px;