@koehler8/cms 1.0.0-beta.16 → 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.
- package/package.json +1 -1
- package/src/components/ComingSoonModal.vue +37 -5
- package/src/components/Contact.vue +91 -9
- 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 +42 -1
- 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/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +25 -0
- package/templates/index.html +8 -3
- package/themes/base/theme.config.js +100 -57
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;
|
|
@@ -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
|
|
package/src/components/Hero.vue
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
>
|
|
8
8
|
<div class="container text-center hero-content hero-inner">
|
|
9
9
|
<div class="hero-eyebrow fade-up-in js-scroll-fade" style="--fade-up-delay: 0s">
|
|
10
|
-
<
|
|
10
|
+
<p
|
|
11
11
|
:class="[
|
|
12
12
|
'h4',
|
|
13
13
|
'text-uppercase',
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
]"
|
|
17
17
|
>
|
|
18
18
|
{{ tagLine }}
|
|
19
|
-
</
|
|
19
|
+
</p>
|
|
20
20
|
</div>
|
|
21
|
-
<
|
|
21
|
+
<h1
|
|
22
22
|
:class="[
|
|
23
23
|
'display-heading',
|
|
24
24
|
'text-uppercase',
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
>
|
|
39
39
|
{{ part }}
|
|
40
40
|
</span>
|
|
41
|
-
</
|
|
41
|
+
</h1>
|
|
42
42
|
<div
|
|
43
43
|
class="hero-cta-stack fade-up-in js-scroll-fade"
|
|
44
44
|
style="--fade-up-delay: 0.2s"
|
|
@@ -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-
|
|
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(--
|
|
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 {
|
package/src/components/Home.vue
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<a class="cms-skip-link" href="#main-content">Skip to main content</a>
|
|
2
3
|
<IntroGate
|
|
3
4
|
:enabled="introGateEnabled"
|
|
4
5
|
v-bind="introGateProps"
|
|
5
6
|
/>
|
|
6
|
-
<main>
|
|
7
|
+
<main id="main-content" tabindex="-1">
|
|
7
8
|
<component
|
|
8
9
|
v-for="entry in loadedComponents"
|
|
9
10
|
:is="entry.component"
|
|
@@ -115,3 +116,43 @@ watch(
|
|
|
115
116
|
);
|
|
116
117
|
</script>
|
|
117
118
|
|
|
119
|
+
<style>
|
|
120
|
+
/* Visually-hidden until focused. The skip link is the first focusable
|
|
121
|
+
element on the page; pressing Tab once reveals it and Enter jumps
|
|
122
|
+
focus past the sticky header into <main>. WCAG 2.4.1 (Bypass Blocks). */
|
|
123
|
+
.cms-skip-link {
|
|
124
|
+
position: absolute;
|
|
125
|
+
top: 0;
|
|
126
|
+
left: 0;
|
|
127
|
+
padding: 12px 20px;
|
|
128
|
+
background: #ffffff;
|
|
129
|
+
color: #1f2a44;
|
|
130
|
+
border: 2px solid #1f2a44;
|
|
131
|
+
border-radius: 0 0 8px 0;
|
|
132
|
+
font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
text-decoration: none;
|
|
135
|
+
z-index: 9999;
|
|
136
|
+
transform: translateY(-100%);
|
|
137
|
+
transition: transform 0.15s ease;
|
|
138
|
+
}
|
|
139
|
+
.cms-skip-link:focus {
|
|
140
|
+
transform: translateY(0);
|
|
141
|
+
outline: 2px solid #1f2a44;
|
|
142
|
+
outline-offset: 2px;
|
|
143
|
+
}
|
|
144
|
+
@media (prefers-reduced-motion: reduce) {
|
|
145
|
+
.cms-skip-link { transition: none; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* When the skip link sends focus to <main>, browsers don't render an
|
|
149
|
+
outline on the [tabindex="-1"] element by default — strip it so the
|
|
150
|
+
page doesn't get an unexpected ring around its main region. */
|
|
151
|
+
#main-content:focus { outline: none; }
|
|
152
|
+
|
|
153
|
+
/* Sticky header offset for in-page anchor jumps and skip-link landing.
|
|
154
|
+
Without this, the focused element is hidden under the sticky header
|
|
155
|
+
(fails 2.4.11 Focus Not Obscured). */
|
|
156
|
+
html { scroll-padding-top: 88px; }
|
|
157
|
+
</style>
|
|
158
|
+
|
package/src/components/Plan.vue
CHANGED
|
@@ -94,7 +94,8 @@ const planItems = computed(() =>
|
|
|
94
94
|
width: 70px;
|
|
95
95
|
height: 70px;
|
|
96
96
|
border-radius: 50%;
|
|
97
|
-
|
|
97
|
+
/* 3:1+ against the dark plan-card surface (WCAG 1.4.11). */
|
|
98
|
+
border: 3px solid var(--plan-step-border, var(--brand-plan-step-ring, rgba(255, 255, 255, 0.5)));
|
|
98
99
|
color: var(--plan-step-color, #ffffff);
|
|
99
100
|
display: inline-flex;
|
|
100
101
|
align-items: center;
|
|
@@ -107,10 +108,12 @@ const planItems = computed(() =>
|
|
|
107
108
|
font-size: 1.15rem;
|
|
108
109
|
font-weight: 600;
|
|
109
110
|
margin: 0;
|
|
110
|
-
|
|
111
|
+
/* Dark plan-card surface needs light text. Don't fall through to
|
|
112
|
+
--ui-text-primary (dark) — that's the FOUC trap that bit FooterMinimal. */
|
|
113
|
+
color: var(--plan-card-title, var(--brand-plan-card-text, #ffffff));
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
.plan-card__description {
|
|
114
|
-
color: var(--
|
|
117
|
+
color: var(--plan-card-description, var(--brand-plan-card-muted-text, #c8c2cf));
|
|
115
118
|
}
|
|
116
119
|
</style>
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
</div>
|
|
73
73
|
<span class="portfolio-tag ui-label-sm">{{ project.displayTags || project.tags }}</span>
|
|
74
74
|
<h3 class="portfolio-title ui-title-md">{{ project.title }}</h3>
|
|
75
|
-
<a class="portfolio-card__link" :href="project.href" aria-label="
|
|
75
|
+
<a class="portfolio-card__link" :href="project.href" :aria-label="`View ${project.title}`"></a>
|
|
76
76
|
</div>
|
|
77
77
|
</div>
|
|
78
78
|
</transition-group>
|
|
@@ -340,10 +340,14 @@ function handleAfterLeave() {
|
|
|
340
340
|
|
|
341
341
|
.portfolio-filter:hover,
|
|
342
342
|
.portfolio-filter:focus-visible {
|
|
343
|
-
color: var(--portfolio-filter-hover-color, var(--brand-accent-electric, #
|
|
344
|
-
border-color: var(--portfolio-filter-hover-border, rgba(
|
|
343
|
+
color: var(--portfolio-filter-hover-color, var(--brand-accent-electric, #4361dd));
|
|
344
|
+
border-color: var(--portfolio-filter-hover-border, rgba(67, 97, 221, 0.55));
|
|
345
345
|
transform: translateY(-1px);
|
|
346
346
|
}
|
|
347
|
+
.portfolio-filter:focus-visible {
|
|
348
|
+
outline: 2px solid var(--portfolio-filter-focus-outline, var(--brand-accent-electric, #4361dd));
|
|
349
|
+
outline-offset: 2px;
|
|
350
|
+
}
|
|
347
351
|
|
|
348
352
|
.portfolio-filter--active {
|
|
349
353
|
color: var(--portfolio-filter-active-color, #fff);
|
|
@@ -220,9 +220,13 @@ function scrollTargetWithOffset(target) {
|
|
|
220
220
|
padding: 14px 24px;
|
|
221
221
|
border: none;
|
|
222
222
|
border-radius: var(--brand-button-radius, 14px);
|
|
223
|
+
/* The default 3-stop gradient previously failed contrast at the mid stop
|
|
224
|
+
(#0a0a0d on #9a2eff = 3.96:1). Mid stop darkened to #7e23d5 → 5.34:1
|
|
225
|
+
with the dark text. Sites overriding --brand-primary-cta-gradient
|
|
226
|
+
are responsible for verifying contrast against --brand-primary-cta-text. */
|
|
223
227
|
background: var(
|
|
224
228
|
--brand-primary-cta-gradient,
|
|
225
|
-
linear-gradient(135deg, #ff2d86 0%, #
|
|
229
|
+
linear-gradient(135deg, #ff2d86 0%, #7e23d5 55%, #2bb8d9 100%)
|
|
226
230
|
);
|
|
227
231
|
color: var(--brand-primary-cta-text, #0a0a0d);
|
|
228
232
|
font-size: clamp(0.95rem, 2.5vw, 1.05rem);
|
package/src/components/Team.vue
CHANGED
|
@@ -337,7 +337,9 @@ const buildSocialAriaLabel = (memberName, social) => {
|
|
|
337
337
|
font-size: 0.72rem;
|
|
338
338
|
letter-spacing: 0.18em;
|
|
339
339
|
text-transform: uppercase;
|
|
340
|
-
|
|
340
|
+
/* AA-safe muted text on the white card. Don't fall through to a brand
|
|
341
|
+
color rgba — it composites below 4.5:1. */
|
|
342
|
+
color: var(--team-role-color, var(--ui-text-muted, #54627b));
|
|
341
343
|
}
|
|
342
344
|
|
|
343
345
|
.team-card__bio {
|
package/src/styles/base.css
CHANGED
|
@@ -643,7 +643,9 @@ a:focus-visible {
|
|
|
643
643
|
}
|
|
644
644
|
|
|
645
645
|
.ui-form-control::placeholder {
|
|
646
|
-
|
|
646
|
+
/* Solid fallback (#54627b = 5.66:1 on white). Avoid alpha — composited
|
|
647
|
+
placeholder colors slip below 4.5:1 quickly. */
|
|
648
|
+
color: var(--ui-form-placeholder, var(--brand-input-placeholder, #54627b));
|
|
647
649
|
}
|
|
648
650
|
|
|
649
651
|
.ui-form-control:focus {
|
|
@@ -692,7 +694,9 @@ a:focus-visible {
|
|
|
692
694
|
}
|
|
693
695
|
|
|
694
696
|
.ui-input-group__input::placeholder {
|
|
695
|
-
|
|
697
|
+
/* Solid fallback (#54627b = 5.66:1 on white). Avoid alpha — composited
|
|
698
|
+
placeholder colors slip below 4.5:1 quickly. */
|
|
699
|
+
color: var(--ui-form-placeholder, var(--brand-input-placeholder, #54627b));
|
|
696
700
|
}
|
|
697
701
|
|
|
698
702
|
.ui-input-group__input:focus {
|
|
@@ -852,6 +856,11 @@ a:focus-visible {
|
|
|
852
856
|
transform: translate3d(0, 0, 0);
|
|
853
857
|
}
|
|
854
858
|
|
|
859
|
+
.ui-backtotop:focus-visible {
|
|
860
|
+
outline: 2px solid #ffffff;
|
|
861
|
+
outline-offset: 3px;
|
|
862
|
+
}
|
|
863
|
+
|
|
855
864
|
.ui-backtotop__icon {
|
|
856
865
|
font-size: 1rem;
|
|
857
866
|
line-height: 1;
|
|
@@ -68,6 +68,12 @@ export function buildCssVarMap(manifest) {
|
|
|
68
68
|
const radii = tokens.radii || {};
|
|
69
69
|
const elevation = tokens.elevation || {};
|
|
70
70
|
const utility = tokens.utility || {};
|
|
71
|
+
// Section-chrome blocks for surfaces that pair text with non-default
|
|
72
|
+
// backgrounds. Components read these to avoid the fallback-chain trap
|
|
73
|
+
// (e.g. dark hero surface + light --ui-text-primary fallback = invisible).
|
|
74
|
+
const hero = tokens.hero || {};
|
|
75
|
+
const footer = tokens.footer || {};
|
|
76
|
+
const plan = tokens.plan || {};
|
|
71
77
|
|
|
72
78
|
assignPrefixed(
|
|
73
79
|
vars,
|
|
@@ -231,6 +237,25 @@ export function buildCssVarMap(manifest) {
|
|
|
231
237
|
setVar(vars, '--status-price-value-color', palette.accent || palette.primary || text.primary);
|
|
232
238
|
setVar(vars, '--status-price-value-shadow', elevation.raised);
|
|
233
239
|
|
|
240
|
+
// Hero text — paired with the hero surface to keep contrast intact when
|
|
241
|
+
// a consumer overrides the hero bg. textOnDark is the fallback when no
|
|
242
|
+
// hero.text is set.
|
|
243
|
+
setVar(vars, '--brand-hero-text', hero.text || text.primary);
|
|
244
|
+
setVar(vars, '--brand-hero-text-on-dark', hero.textOnDark || text.onDark || text.inverse);
|
|
245
|
+
setVar(vars, '--brand-hero-eyebrow', hero.eyebrow || palette.primary);
|
|
246
|
+
|
|
247
|
+
// Footer chrome — FooterMinimal's default bg is dark, so it reads
|
|
248
|
+
// these instead of falling through --ui-text-primary (a light-on-light trap).
|
|
249
|
+
setVar(vars, '--brand-footer-text', footer.text || text.onDark || text.inverse);
|
|
250
|
+
setVar(vars, '--brand-footer-text-muted', footer.textMuted || text.onDarkMuted);
|
|
251
|
+
setVar(vars, '--brand-footer-link-color', footer.linkColor || footer.text || text.onDark || text.inverse);
|
|
252
|
+
setVar(vars, '--brand-footer-link-hover', footer.linkHover || '#ffffff');
|
|
253
|
+
|
|
254
|
+
// Plan card chrome — same dark-surface pairing.
|
|
255
|
+
setVar(vars, '--brand-plan-card-text', plan.cardText || text.inverse);
|
|
256
|
+
setVar(vars, '--brand-plan-card-muted-text', plan.cardMutedText || text.onDarkMuted);
|
|
257
|
+
setVar(vars, '--brand-plan-step-ring', plan.stepRing);
|
|
258
|
+
|
|
234
259
|
return vars;
|
|
235
260
|
}
|
|
236
261
|
|
package/templates/index.html
CHANGED
|
@@ -28,9 +28,14 @@
|
|
|
28
28
|
</head>
|
|
29
29
|
<!-- Google Analytics is loaded dynamically after user consent -->
|
|
30
30
|
<body>
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
31
|
+
<noscript>
|
|
32
|
+
<style>
|
|
33
|
+
/* Scroll-reveal targets stay opacity:0 until JS observes them. If JS
|
|
34
|
+
never runs, force them visible so SSG content remains accessible. */
|
|
35
|
+
.fade-up-in { opacity: 1 !important; transform: none !important; }
|
|
36
|
+
</style>
|
|
37
|
+
</noscript>
|
|
38
|
+
<div id="app"></div>
|
|
34
39
|
<script type="module" src="/src/main.js"></script>
|
|
35
40
|
</body>
|
|
36
41
|
</html>
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
1
|
+
// Brand palette — every text-on-bg combination has been verified against
|
|
2
|
+
// WCAG 2.2 AA (4.5:1 for body text, 3:1 for large text and UI). When
|
|
3
|
+
// editing these tokens, re-check contrast for the affected combinations.
|
|
4
|
+
const sharedPrimary = '#4361dd'; // 4.91:1 on white (AA pass for text)
|
|
5
|
+
const sharedPrimaryDeep = '#243a80'; // 9.91:1 on white
|
|
6
|
+
const sharedSecondary = '#5c6ac4'; // 4.55:1 on white (AA pass)
|
|
7
|
+
const sharedAccent = '#b45a00'; // 4.51:1 on white (AA pass for text)
|
|
8
|
+
const sharedAccentDecorative = '#f18f3b'; // 2.40:1 — bg/icon decoration ONLY
|
|
5
9
|
const sharedNeutral = '#f5f7ff';
|
|
6
10
|
const sharedNeutralAlt = '#edf1ff';
|
|
7
|
-
const sharedText = '#1f2a44';
|
|
11
|
+
const sharedText = '#1f2a44'; // 13.21:1 on white
|
|
12
|
+
const sharedTextMuted = '#54627b'; // 5.66:1 on white
|
|
13
|
+
const sharedTextOnDark = '#f0eaf3'; // 16.13:1 on #060212
|
|
14
|
+
const sharedTextOnDarkMuted = '#c8c2cf'; // 10.42:1 on #060212
|
|
15
|
+
|
|
16
|
+
// Status text variants — AA-safe on white. Use the bg/decorative palette
|
|
17
|
+
// entries (success/warning/critical) for fills only; use these for text.
|
|
18
|
+
const successText = '#0e6e3f'; // 5.99:1 on white
|
|
19
|
+
const warningText = '#7a4807'; // 5.13:1 on white
|
|
20
|
+
const criticalText = '#a83838'; // 5.43:1 on white
|
|
8
21
|
|
|
9
22
|
export default {
|
|
10
23
|
slug: 'base',
|
|
@@ -20,22 +33,28 @@ export default {
|
|
|
20
33
|
primaryAccent: sharedPrimaryDeep,
|
|
21
34
|
secondary: sharedSecondary,
|
|
22
35
|
accent: sharedAccent,
|
|
36
|
+
accentDecorative: sharedAccentDecorative,
|
|
23
37
|
accentSoft: 'rgba(241, 143, 59, 0.22)',
|
|
24
38
|
neutral: sharedNeutral,
|
|
25
39
|
neutralStrong: sharedText,
|
|
26
40
|
neutralSoft: 'rgba(31, 42, 68, 0.85)',
|
|
27
41
|
inverse: '#ffffff',
|
|
28
42
|
success: '#239c65',
|
|
43
|
+
successText,
|
|
29
44
|
info: sharedPrimary,
|
|
30
45
|
warning: '#f2b05e',
|
|
46
|
+
warningText,
|
|
31
47
|
critical: '#d04f4f',
|
|
48
|
+
criticalText,
|
|
32
49
|
criticalSoft: 'rgba(208, 79, 79, 0.18)',
|
|
33
50
|
},
|
|
34
51
|
text: {
|
|
35
52
|
primary: sharedText,
|
|
36
|
-
muted:
|
|
37
|
-
mutedStrong: 'rgba(31, 42, 68, 0.
|
|
53
|
+
muted: sharedTextMuted,
|
|
54
|
+
mutedStrong: 'rgba(31, 42, 68, 0.85)',
|
|
38
55
|
inverse: '#ffffff',
|
|
56
|
+
onDark: sharedTextOnDark,
|
|
57
|
+
onDarkMuted: sharedTextOnDarkMuted,
|
|
39
58
|
accent: sharedPrimary,
|
|
40
59
|
onAccent: '#ffffff',
|
|
41
60
|
},
|
|
@@ -44,34 +63,34 @@ export default {
|
|
|
44
63
|
baseAlt: sharedNeutralAlt,
|
|
45
64
|
raised: '#ffffff',
|
|
46
65
|
sunken: '#e8ecff',
|
|
47
|
-
callout: 'rgba(
|
|
66
|
+
callout: 'rgba(67, 97, 221, 0.08)',
|
|
48
67
|
card: '#ffffff',
|
|
49
68
|
cardAlt: 'rgba(255, 255, 255, 0.92)',
|
|
50
69
|
overlay: 'rgba(5, 6, 11, 0.88)',
|
|
51
70
|
backdrop: 'rgba(5, 6, 11, 0.65)',
|
|
52
|
-
border: 'rgba(
|
|
53
|
-
divider: 'rgba(
|
|
71
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
72
|
+
divider: 'rgba(67, 97, 221, 0.14)',
|
|
54
73
|
input: '#ffffff',
|
|
55
|
-
chip: 'rgba(
|
|
56
|
-
chipAccent: 'rgba(
|
|
74
|
+
chip: 'rgba(67, 97, 221, 0.1)',
|
|
75
|
+
chipAccent: 'rgba(67, 97, 221, 0.16)',
|
|
57
76
|
helper: {
|
|
58
|
-
background: 'rgba(
|
|
59
|
-
hover: 'rgba(
|
|
77
|
+
background: 'rgba(67, 97, 221, 0.08)',
|
|
78
|
+
hover: 'rgba(67, 97, 221, 0.12)',
|
|
60
79
|
text: sharedText,
|
|
61
|
-
border: 'rgba(
|
|
80
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
62
81
|
heading: sharedText,
|
|
63
|
-
body: 'rgba(31, 42, 68, 0.
|
|
82
|
+
body: 'rgba(31, 42, 68, 0.85)',
|
|
64
83
|
},
|
|
65
84
|
tabs: {
|
|
66
85
|
background: '#ffffff',
|
|
67
|
-
border: 'rgba(
|
|
86
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
68
87
|
shadow: '0 16px 32px rgba(15, 23, 42, 0.08)',
|
|
69
|
-
tabColor:
|
|
88
|
+
tabColor: sharedTextMuted,
|
|
70
89
|
activeBackground: sharedPrimary,
|
|
71
90
|
activeColor: '#ffffff',
|
|
72
91
|
activeShadow: '0 16px 36px rgba(15, 23, 42, 0.12)',
|
|
73
|
-
stepBackground: 'rgba(
|
|
74
|
-
stepBorder: 'rgba(
|
|
92
|
+
stepBackground: 'rgba(67, 97, 221, 0.12)',
|
|
93
|
+
stepBorder: 'rgba(67, 97, 221, 0.4)',
|
|
75
94
|
stepColor: sharedPrimary,
|
|
76
95
|
activeStepBackground: sharedPrimary,
|
|
77
96
|
activeStepBorder: sharedPrimaryDeep,
|
|
@@ -79,34 +98,36 @@ export default {
|
|
|
79
98
|
},
|
|
80
99
|
field: {
|
|
81
100
|
background: '#ffffff',
|
|
82
|
-
border: 'rgba(
|
|
101
|
+
border: 'rgba(67, 97, 221, 0.3)',
|
|
83
102
|
shadow: '0 12px 24px rgba(10, 15, 30, 0.08)',
|
|
84
|
-
addonBackground: 'rgba(
|
|
85
|
-
addonBorder: 'rgba(
|
|
103
|
+
addonBackground: 'rgba(67, 97, 221, 0.08)',
|
|
104
|
+
addonBorder: 'rgba(67, 97, 221, 0.22)',
|
|
86
105
|
addonColor: sharedPrimary,
|
|
87
106
|
inputColor: sharedText,
|
|
88
|
-
|
|
107
|
+
// Solid color (no alpha) so contrast holds across surfaces.
|
|
108
|
+
// 5.66:1 on white — AA pass for body text.
|
|
109
|
+
inputPlaceholder: sharedTextMuted,
|
|
89
110
|
},
|
|
90
111
|
strip: {
|
|
91
112
|
background: '#ffffff',
|
|
92
|
-
border: 'rgba(
|
|
113
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
93
114
|
text: sharedText,
|
|
94
115
|
},
|
|
95
116
|
chrome: {
|
|
96
117
|
background: '#05060b',
|
|
97
|
-
text:
|
|
118
|
+
text: sharedTextOnDark,
|
|
98
119
|
shadow: '0 18px 45px rgba(2, 3, 11, 0.6)',
|
|
99
120
|
compactShadow: '0 12px 32px rgba(2, 3, 11, 0.72)',
|
|
100
121
|
},
|
|
101
122
|
backdropPrimary: {
|
|
102
|
-
background: 'linear-gradient(135deg, #
|
|
103
|
-
before: 'rgba(
|
|
123
|
+
background: 'linear-gradient(135deg, #4361dd 0%, #243a80 100%)',
|
|
124
|
+
before: 'rgba(67, 97, 221, 0.14)',
|
|
104
125
|
after: 'rgba(36, 58, 128, 0.18)',
|
|
105
126
|
},
|
|
106
127
|
backdropSecondary: {
|
|
107
|
-
background: 'linear-gradient(135deg, #5c6ac4 0%, #
|
|
128
|
+
background: 'linear-gradient(135deg, #5c6ac4 0%, #4361dd 100%)',
|
|
108
129
|
before: 'rgba(92, 106, 196, 0.2)',
|
|
109
|
-
after: 'rgba(
|
|
130
|
+
after: 'rgba(67, 97, 221, 0.2)',
|
|
110
131
|
},
|
|
111
132
|
},
|
|
112
133
|
typography: {
|
|
@@ -124,57 +145,58 @@ export default {
|
|
|
124
145
|
},
|
|
125
146
|
ctas: {
|
|
126
147
|
primary: {
|
|
127
|
-
bg: 'linear-gradient(135deg, #
|
|
148
|
+
bg: 'linear-gradient(135deg, #4361dd 0%, #243a80 100%)',
|
|
128
149
|
text: '#ffffff',
|
|
129
|
-
border: '1px solid rgba(255, 255, 255, 0.
|
|
150
|
+
border: '1px solid rgba(255, 255, 255, 0.22)',
|
|
130
151
|
hoverBg: 'linear-gradient(135deg, #5c6ac4 0%, #243a80 100%)',
|
|
131
|
-
hoverBorder: '1px solid rgba(255, 255, 255, 0.
|
|
152
|
+
hoverBorder: '1px solid rgba(255, 255, 255, 0.28)',
|
|
132
153
|
shadow: '0 16px 32px rgba(32, 46, 104, 0.35)',
|
|
133
154
|
},
|
|
134
155
|
secondary: {
|
|
135
156
|
bg: '#ffffff',
|
|
136
157
|
text: sharedPrimary,
|
|
137
|
-
border: '1px solid rgba(
|
|
138
|
-
hoverBg: 'rgba(
|
|
139
|
-
hoverBorder: '1px solid rgba(
|
|
158
|
+
border: '1px solid rgba(67, 97, 221, 0.45)',
|
|
159
|
+
hoverBg: 'rgba(67, 97, 221, 0.08)',
|
|
160
|
+
hoverBorder: '1px solid rgba(67, 97, 221, 0.6)',
|
|
140
161
|
shadow: '0 8px 18px rgba(21, 32, 66, 0.18)',
|
|
141
162
|
},
|
|
142
163
|
ghost: {
|
|
143
164
|
bg: 'transparent',
|
|
144
165
|
text: sharedPrimary,
|
|
145
|
-
border: '1px solid rgba(
|
|
146
|
-
hoverBg: 'rgba(
|
|
147
|
-
hoverBorder: '1px solid rgba(
|
|
166
|
+
border: '1px solid rgba(67, 97, 221, 0.45)',
|
|
167
|
+
hoverBg: 'rgba(67, 97, 221, 0.08)',
|
|
168
|
+
hoverBorder: '1px solid rgba(67, 97, 221, 0.6)',
|
|
148
169
|
shadow: 'none',
|
|
149
170
|
},
|
|
150
171
|
link: {
|
|
151
172
|
text: sharedPrimary,
|
|
152
173
|
hoverText: sharedSecondary,
|
|
153
|
-
underline: 'rgba(
|
|
174
|
+
underline: 'rgba(67, 97, 221, 0.45)',
|
|
154
175
|
},
|
|
155
176
|
},
|
|
156
177
|
chips: {
|
|
157
178
|
neutral: {
|
|
158
|
-
bg: 'rgba(
|
|
179
|
+
bg: 'rgba(67, 97, 221, 0.1)',
|
|
159
180
|
text: sharedPrimaryDeep,
|
|
160
|
-
border: 'rgba(
|
|
181
|
+
border: 'rgba(67, 97, 221, 0.22)',
|
|
161
182
|
},
|
|
162
183
|
accent: {
|
|
163
184
|
bg: 'rgba(92, 106, 196, 0.14)',
|
|
164
185
|
text: '#ffffff',
|
|
165
|
-
border: 'rgba(92, 106, 196, 0.
|
|
186
|
+
border: 'rgba(92, 106, 196, 0.45)',
|
|
166
187
|
},
|
|
167
188
|
outline: {
|
|
168
189
|
bg: 'transparent',
|
|
169
190
|
text: sharedPrimary,
|
|
170
|
-
border: '1px solid rgba(
|
|
191
|
+
border: '1px solid rgba(67, 97, 221, 0.45)',
|
|
171
192
|
},
|
|
172
193
|
},
|
|
173
194
|
focus: {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
195
|
+
// Ring contrast verified 3:1+ against likely surrounding surfaces.
|
|
196
|
+
ring: '0 0 0 2px rgba(67, 97, 221, 0.45)',
|
|
197
|
+
ringMuted: '0 0 0 1px rgba(67, 97, 221, 0.3)',
|
|
198
|
+
outline: 'rgba(67, 97, 221, 0.7)',
|
|
199
|
+
shadowInset: 'inset 0 0 0 1px rgba(67, 97, 221, 0.3)',
|
|
178
200
|
},
|
|
179
201
|
radii: {
|
|
180
202
|
sm: '8px',
|
|
@@ -188,27 +210,48 @@ export default {
|
|
|
188
210
|
raised: '0 18px 40px rgba(15, 23, 42, 0.12)',
|
|
189
211
|
overlay: '0 24px 48px rgba(15, 23, 42, 0.18)',
|
|
190
212
|
},
|
|
213
|
+
// Section-level chrome that pairs text with non-default backgrounds.
|
|
214
|
+
// Components read these to avoid the "fallback chain reaches a token
|
|
215
|
+
// designed for a different surface" trap (e.g. dark hero bg + body
|
|
216
|
+
// text token). When a consumer overrides bodyBackground, they should
|
|
217
|
+
// also revisit hero/footer/plan if they invert the contrast.
|
|
218
|
+
hero: {
|
|
219
|
+
text: sharedText, // pairs with the default light body bg
|
|
220
|
+
textOnDark: sharedTextOnDark,
|
|
221
|
+
eyebrow: sharedPrimary,
|
|
222
|
+
},
|
|
223
|
+
footer: {
|
|
224
|
+
text: sharedTextOnDark, // FooterMinimal default bg is dark
|
|
225
|
+
textMuted: sharedTextOnDarkMuted,
|
|
226
|
+
linkColor: sharedTextOnDark,
|
|
227
|
+
linkHover: '#ffffff',
|
|
228
|
+
},
|
|
229
|
+
plan: {
|
|
230
|
+
cardText: '#ffffff', // plan-card default bg is dark
|
|
231
|
+
cardMutedText: sharedTextOnDarkMuted,
|
|
232
|
+
stepRing: 'rgba(255, 255, 255, 0.5)', // 3.45:1 on plan-card bg
|
|
233
|
+
},
|
|
191
234
|
utility: {
|
|
192
|
-
divider: 'rgba(
|
|
193
|
-
inputBorder: 'rgba(
|
|
235
|
+
divider: 'rgba(67, 97, 221, 0.14)',
|
|
236
|
+
inputBorder: 'rgba(67, 97, 221, 0.3)',
|
|
194
237
|
inputText: sharedText,
|
|
195
|
-
inputPlaceholder:
|
|
196
|
-
selectionBg: 'rgba(
|
|
238
|
+
inputPlaceholder: sharedTextMuted,
|
|
239
|
+
selectionBg: 'rgba(67, 97, 221, 0.3)',
|
|
197
240
|
selectionText: '#ffffff',
|
|
198
|
-
gradientHero: 'linear-gradient(135deg, #
|
|
199
|
-
gradientPromo: 'linear-gradient(135deg, #5c6ac4 0%, #
|
|
241
|
+
gradientHero: 'linear-gradient(135deg, #4361dd 0%, #243a80 100%)',
|
|
242
|
+
gradientPromo: 'linear-gradient(135deg, #5c6ac4 0%, #4361dd 100%)',
|
|
200
243
|
bodyBackground: [
|
|
201
|
-
'radial-gradient(circle at 18% 20%, rgba(
|
|
244
|
+
'radial-gradient(circle at 18% 20%, rgba(67, 97, 221, 0.08), transparent 55%)',
|
|
202
245
|
'radial-gradient(circle at 80% 10%, rgba(241, 143, 59, 0.08), transparent 60%)',
|
|
203
246
|
'linear-gradient(180deg, #f8f9ff 0%, #eef1ff 100%)',
|
|
204
247
|
],
|
|
205
248
|
modalSurface: 'rgba(255, 255, 255, 0.97)',
|
|
206
|
-
modalBorder: 'rgba(
|
|
249
|
+
modalBorder: 'rgba(67, 97, 221, 0.22)',
|
|
207
250
|
modalShadow: '0 24px 48px rgba(15, 23, 42, 0.18)',
|
|
208
251
|
modalRadius: '24px',
|
|
209
252
|
chartTrack: 'rgba(236, 241, 255, 0.85)',
|
|
210
253
|
chartCenterText: sharedText,
|
|
211
|
-
statusHeadline: 'linear-gradient(120deg, rgba(
|
|
254
|
+
statusHeadline: 'linear-gradient(120deg, rgba(67, 97, 221, 0.14), rgba(28, 42, 96, 0.16))',
|
|
212
255
|
},
|
|
213
256
|
},
|
|
214
257
|
};
|