@koehler8/cms 1.0.0-beta.9 → 1.0.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.
- package/CHANGELOG.md +1491 -0
- package/README.md +108 -28
- package/bin/cms-create-extension.js +7 -0
- package/bin/cms-create-site.js +7 -0
- package/bin/cms-create-theme.js +7 -0
- package/bin/cms-ssg-build.js +13 -0
- package/bin/cms-validate-extensions.js +4 -0
- package/bin/cms-validate-themes.js +11 -0
- package/extensions/manifest.schema.json +1 -1
- package/package.json +33 -17
- package/scripts/create-extension.mjs +11 -0
- package/scripts/create-site.mjs +11 -0
- package/scripts/create-theme.mjs +11 -0
- package/scripts/generate-public-assets.js +70 -36
- package/scripts/image-variants/cache.js +40 -0
- package/scripts/image-variants/classifier.js +53 -0
- package/scripts/image-variants/config.js +54 -0
- package/scripts/image-variants/index.js +18 -0
- package/scripts/image-variants/manifest.js +76 -0
- package/scripts/image-variants/planner.js +95 -0
- package/scripts/image-variants/reconcile.js +194 -0
- package/scripts/image-variants/renderer.js +51 -0
- package/scripts/lib/extension-manifests.mjs +70 -0
- package/scripts/scaffold.mjs +242 -0
- package/scripts/ssg-build/findEmptyRenders.mjs +42 -0
- package/scripts/ssg-build/index.mjs +253 -0
- package/scripts/validate-extensions.mjs +50 -382
- package/scripts/validate-themes.mjs +37 -5
- package/src/components/About.vue +4 -2
- package/src/components/AboutValue.vue +5 -4
- package/src/components/ComingSoon.vue +12 -1
- package/src/components/ComingSoonModal.vue +33 -61
- package/src/components/Contact.vue +216 -120
- package/src/components/DraftGate.vue +211 -0
- package/src/components/Footer.vue +15 -2
- package/src/components/FooterMinimal.vue +13 -12
- package/src/components/Header.vue +184 -10
- package/src/components/Hero.vue +17 -8
- package/src/components/Home.vue +82 -66
- package/src/components/Intro.vue +4 -2
- package/src/components/IntroGate.vue +30 -73
- package/src/components/NotFound.vue +100 -0
- package/src/components/Plan.vue +10 -4
- package/src/components/Portfolio.vue +29 -3
- package/src/components/Principles.vue +3 -1
- package/src/components/Spacer.vue +23 -0
- package/src/components/Spacer15.vue +3 -4
- package/src/components/Spacer30.vue +3 -4
- package/src/components/Spacer40.vue +3 -4
- package/src/components/Spacer60.vue +3 -4
- package/src/components/StickyCTA.vue +23 -1
- package/src/components/Team.vue +3 -1
- package/src/composables/useComponentResolver.js +49 -1
- package/src/composables/useDraftGate.js +93 -0
- package/src/composables/useFocusTrap.js +85 -0
- package/src/composables/usePageConfig.js +126 -28
- package/src/composables/usePageMeta.js +178 -5
- package/src/composables/usePromoBackgroundStyles.js +30 -14
- package/src/extensions/extensionLoader.js +34 -24
- package/src/main.js +69 -6
- package/src/router/index.js +98 -37
- package/src/styles/base.css +11 -2
- package/src/themes/buildCssVarMap.js +34 -0
- package/src/utils/appInfo.js +8 -5
- package/src/utils/breadcrumbs.js +121 -0
- package/src/utils/canonicalUrl.js +53 -0
- package/src/utils/componentRegistry.js +98 -16
- package/src/utils/cookieConsent.js +38 -24
- package/src/utils/draftMode.js +65 -0
- package/src/utils/imageSources.js +9 -1
- package/src/utils/jsonLd.js +66 -0
- package/src/utils/loadConfig.js +106 -6
- package/src/utils/robotsGenerator.js +95 -0
- package/src/utils/sha256.js +20 -0
- package/src/utils/sitemapGenerator.js +112 -0
- package/src/utils/socialMeta.js +100 -0
- package/src/utils/ssgRoutes.js +40 -0
- package/src/utils/trackingContext.js +16 -22
- package/src/utils/webAppManifest.js +85 -0
- package/src/utils/webStorage.js +82 -0
- package/templates/index.html +17 -24
- package/templates/scaffolds/extension/README.md +53 -0
- package/templates/scaffolds/extension/extension.config.json +10 -0
- package/templates/scaffolds/extension/index.js +37 -0
- package/templates/scaffolds/extension/package.json +22 -0
- package/templates/scaffolds/site/.env.example +7 -0
- package/templates/scaffolds/site/.nvmrc +1 -0
- package/templates/scaffolds/site/CLAUDE.md +121 -0
- package/templates/scaffolds/site/README.md +25 -0
- package/templates/scaffolds/site/amplify.yml +22 -0
- package/templates/scaffolds/site/package.json +26 -0
- package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
- package/templates/scaffolds/site/site/components/.gitkeep +0 -0
- package/templates/scaffolds/site/site/content/content.config.json +3 -0
- package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
- package/templates/scaffolds/site/site/content/en/shared.json +5 -0
- package/templates/scaffolds/site/site/content/en/site.json +10 -0
- package/templates/scaffolds/site/site/style.css +4 -0
- package/templates/scaffolds/site/vite.config.js +15 -0
- package/templates/scaffolds/theme/README.md +50 -0
- package/templates/scaffolds/theme/index.js +8 -0
- package/templates/scaffolds/theme/package.json +21 -0
- package/templates/scaffolds/theme/theme.config.js +228 -0
- package/templates/scaffolds/theme/theme.css +14 -0
- package/themes/base/theme.config.js +100 -57
- package/vite-plugin.js +545 -32
- package/public/robots.txt +0 -5
- package/scripts/check-overflow.mjs +0 -33
- package/scripts/migrate-to-content-dirs.mjs +0 -223
- package/scripts/patch-lru-cache-tla.js +0 -164
package/src/components/Home.vue
CHANGED
|
@@ -1,43 +1,39 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
<component
|
|
8
|
-
v-for="entry in loadedComponents"
|
|
9
|
-
:is="entry.component"
|
|
10
|
-
:key="entry.key"
|
|
11
|
-
v-bind="entry.props"
|
|
2
|
+
<template v-if="isDraft && !isUnlocked">
|
|
3
|
+
<DraftGate
|
|
4
|
+
:has-password="hasPassword"
|
|
5
|
+
:error-message="errorMessage"
|
|
6
|
+
@submit="attemptUnlock"
|
|
12
7
|
/>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
</
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
8
|
+
</template>
|
|
9
|
+
<template v-else>
|
|
10
|
+
<a class="cms-skip-link" href="#main-content">Skip to main content</a>
|
|
11
|
+
<IntroGate
|
|
12
|
+
:enabled="introGateEnabled"
|
|
13
|
+
v-bind="introGateProps"
|
|
14
|
+
/>
|
|
15
|
+
<main id="main-content" tabindex="-1">
|
|
16
|
+
<component
|
|
17
|
+
v-for="entry in loadedComponents"
|
|
18
|
+
:is="entry.component"
|
|
19
|
+
:key="entry.key"
|
|
20
|
+
v-bind="entry.props"
|
|
21
|
+
/>
|
|
22
|
+
</main>
|
|
23
|
+
<ComingSoonModal
|
|
24
|
+
:open="isComingSoonVisible"
|
|
25
|
+
:title="comingSoonTitle"
|
|
26
|
+
:message="comingSoonMessage"
|
|
27
|
+
@close="closeComingSoon"
|
|
28
|
+
/>
|
|
29
|
+
</template>
|
|
35
30
|
</template>
|
|
36
31
|
|
|
37
32
|
<script setup>
|
|
38
|
-
import { computed, nextTick,
|
|
33
|
+
import { computed, nextTick, provide, watch } from 'vue';
|
|
39
34
|
|
|
40
35
|
import ComingSoonModal from '../components/ComingSoonModal.vue';
|
|
36
|
+
import DraftGate from '../components/DraftGate.vue';
|
|
41
37
|
import IntroGate from '../components/IntroGate.vue';
|
|
42
38
|
|
|
43
39
|
import { registry } from '../utils/componentRegistry.js';
|
|
@@ -45,6 +41,7 @@ import { usePageConfig } from '../composables/usePageConfig.js';
|
|
|
45
41
|
import { useComponentResolver } from '../composables/useComponentResolver.js';
|
|
46
42
|
import { usePageMeta } from '../composables/usePageMeta.js';
|
|
47
43
|
import { useIntroGate } from '../composables/useIntroGate.js';
|
|
44
|
+
import { useDraftGate } from '../composables/useDraftGate.js';
|
|
48
45
|
import { useEngagementTracking } from '../composables/useEngagementTracking.js';
|
|
49
46
|
import { useComingSoonInterstitial } from '../composables/useComingSoonInterstitial.js';
|
|
50
47
|
|
|
@@ -85,7 +82,15 @@ const { loadedComponents } = useComponentResolver({
|
|
|
85
82
|
|
|
86
83
|
const { introGateEnabled, introGateProps } = useIntroGate({ siteData, pageContent });
|
|
87
84
|
|
|
88
|
-
|
|
85
|
+
const {
|
|
86
|
+
isDraft,
|
|
87
|
+
isUnlocked,
|
|
88
|
+
hasPassword,
|
|
89
|
+
errorMessage,
|
|
90
|
+
attemptUnlock,
|
|
91
|
+
} = useDraftGate({ siteData, currentPage });
|
|
92
|
+
|
|
93
|
+
usePageMeta({ siteData, currentPage, locale: () => props.locale });
|
|
89
94
|
|
|
90
95
|
const {
|
|
91
96
|
isComingSoonVisible,
|
|
@@ -106,25 +111,13 @@ provide('pageContent', pageContent);
|
|
|
106
111
|
provide('pageConfig', currentPage);
|
|
107
112
|
provide('currentPageId', computed(() => currentPage.value.id));
|
|
108
113
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
nextTick().then(() => {
|
|
117
|
-
isHydrating.value = false;
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
const showLoadingIndicator = computed(() =>
|
|
122
|
-
!isHydrating.value &&
|
|
123
|
-
isLoading.value &&
|
|
124
|
-
componentKeys.value.length === 0 &&
|
|
125
|
-
!loadError.value
|
|
126
|
-
);
|
|
127
|
-
const loadErrorMessage = computed(() => loadError.value ? (loadError.value.message || 'Unable to load page content.') : '');
|
|
114
|
+
// Loading + error state are exposed via inject so sites can build their
|
|
115
|
+
// own preloader / error surface as a component (e.g. site/components/
|
|
116
|
+
// Preloader.vue or the bundled Preloader). Home itself no longer renders
|
|
117
|
+
// any built-in placeholder UI — sites that want one opt in by listing a
|
|
118
|
+
// preloader component in their pages/{pageId}.json `components[]`.
|
|
119
|
+
provide('pageIsLoading', isLoading);
|
|
120
|
+
provide('pageLoadError', loadError);
|
|
128
121
|
|
|
129
122
|
watch(componentKeys, async () => {
|
|
130
123
|
await nextTick();
|
|
@@ -142,20 +135,43 @@ watch(
|
|
|
142
135
|
);
|
|
143
136
|
</script>
|
|
144
137
|
|
|
145
|
-
<style
|
|
146
|
-
.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
138
|
+
<style>
|
|
139
|
+
/* Visually-hidden until focused. The skip link is the first focusable
|
|
140
|
+
element on the page; pressing Tab once reveals it and Enter jumps
|
|
141
|
+
focus past the sticky header into <main>. WCAG 2.4.1 (Bypass Blocks). */
|
|
142
|
+
.cms-skip-link {
|
|
143
|
+
position: absolute;
|
|
144
|
+
top: 0;
|
|
145
|
+
left: 0;
|
|
146
|
+
padding: 12px 20px;
|
|
147
|
+
background: #ffffff;
|
|
148
|
+
color: #1f2a44;
|
|
149
|
+
border: 2px solid #1f2a44;
|
|
150
|
+
border-radius: 0 0 8px 0;
|
|
151
|
+
font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
|
|
153
152
|
font-weight: 600;
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
text-decoration: none;
|
|
154
|
+
z-index: 9999;
|
|
155
|
+
transform: translateY(-100%);
|
|
156
|
+
transition: transform 0.15s ease;
|
|
156
157
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
.cms-skip-link:focus {
|
|
159
|
+
transform: translateY(0);
|
|
160
|
+
outline: 2px solid #1f2a44;
|
|
161
|
+
outline-offset: 2px;
|
|
162
|
+
}
|
|
163
|
+
@media (prefers-reduced-motion: reduce) {
|
|
164
|
+
.cms-skip-link { transition: none; }
|
|
160
165
|
}
|
|
166
|
+
|
|
167
|
+
/* When the skip link sends focus to <main>, browsers don't render an
|
|
168
|
+
outline on the [tabindex="-1"] element by default — strip it so the
|
|
169
|
+
page doesn't get an unexpected ring around its main region. */
|
|
170
|
+
#main-content:focus { outline: none; }
|
|
171
|
+
|
|
172
|
+
/* Sticky header offset for in-page anchor jumps and skip-link landing.
|
|
173
|
+
Without this, the focused element is hidden under the sticky header
|
|
174
|
+
(fails 2.4.11 Focus Not Obscured). */
|
|
175
|
+
html { scroll-padding-top: 88px; }
|
|
161
176
|
</style>
|
|
177
|
+
|
package/src/components/Intro.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<section id="
|
|
2
|
+
<section v-if="hasContent" id="intro" class="section-shell intro-section" data-analytics-section="intro">
|
|
3
3
|
<div class="container">
|
|
4
4
|
<div class="intro-grid">
|
|
5
5
|
<div class="intro-column">
|
|
@@ -61,12 +61,14 @@
|
|
|
61
61
|
const pageContent = inject('pageContent', ref({}));
|
|
62
62
|
|
|
63
63
|
const introData = computed(() => pageContent.value?.intro || {});
|
|
64
|
+
// components[] contract: no content.intro block → render nothing (never an
|
|
65
|
+
// empty heading, divider, and image column).
|
|
66
|
+
const hasContent = computed(() => Object.keys(introData.value).length > 0);
|
|
64
67
|
|
|
65
68
|
const introTitle = computed(() => introData.value?.title || '');
|
|
66
69
|
const introText = computed(() => introData.value?.text || '');
|
|
67
70
|
|
|
68
71
|
const introImage = useResponsiveImage('img/intro', {
|
|
69
|
-
widths: [320, 540],
|
|
70
72
|
fallbackFormat: 'jpg',
|
|
71
73
|
});
|
|
72
74
|
const introImageHandleError = introImage.handleError;
|
|
@@ -57,8 +57,9 @@
|
|
|
57
57
|
</template>
|
|
58
58
|
|
|
59
59
|
<script setup>
|
|
60
|
-
import { computed,
|
|
60
|
+
import { computed, onMounted, ref, watch } from 'vue';
|
|
61
61
|
import { trackEvent } from '../utils/analytics.js';
|
|
62
|
+
import { useFocusTrap } from '../composables/useFocusTrap.js';
|
|
62
63
|
|
|
63
64
|
const props = defineProps({
|
|
64
65
|
enabled: {
|
|
@@ -101,11 +102,9 @@ const props = defineProps({
|
|
|
101
102
|
|
|
102
103
|
const isOpen = ref(false);
|
|
103
104
|
const dialogRef = ref(null);
|
|
104
|
-
const previousFocusedElement = ref(null);
|
|
105
105
|
|
|
106
106
|
const dialogTitleId = computed(() => 'intro-gate-title');
|
|
107
107
|
const dialogDescriptionId = computed(() => 'intro-gate-description');
|
|
108
|
-
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
109
108
|
const eyebrowText = computed(() => (typeof props.eyebrow === 'string' ? props.eyebrow.trim() : ''));
|
|
110
109
|
const titleText = computed(() => (typeof props.title === 'string' ? props.title.trim() : ''));
|
|
111
110
|
const bodyText = computed(() => (typeof props.body === 'string' ? props.body.trim() : ''));
|
|
@@ -166,67 +165,7 @@ function handlePrimaryClick() {
|
|
|
166
165
|
handleDismiss('continue');
|
|
167
166
|
}
|
|
168
167
|
|
|
169
|
-
|
|
170
|
-
if (typeof window === 'undefined') return;
|
|
171
|
-
nextTick(() => {
|
|
172
|
-
dialogRef.value?.focus();
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function restoreFocus() {
|
|
177
|
-
if (!previousFocusedElement.value) return;
|
|
178
|
-
if (typeof previousFocusedElement.value.focus === 'function') {
|
|
179
|
-
previousFocusedElement.value.focus();
|
|
180
|
-
}
|
|
181
|
-
previousFocusedElement.value = null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function trapFocus(event) {
|
|
185
|
-
if (!isOpen.value) return;
|
|
186
|
-
if (typeof document === 'undefined') return;
|
|
187
|
-
if (event.key !== 'Tab') return;
|
|
188
|
-
const dialogEl = dialogRef.value;
|
|
189
|
-
if (!dialogEl) return;
|
|
190
|
-
const focusable = dialogEl.querySelectorAll(focusableSelector);
|
|
191
|
-
if (!focusable.length) {
|
|
192
|
-
event.preventDefault();
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
const first = focusable[0];
|
|
196
|
-
const last = focusable[focusable.length - 1];
|
|
197
|
-
if (event.shiftKey && document.activeElement === first) {
|
|
198
|
-
event.preventDefault();
|
|
199
|
-
last.focus();
|
|
200
|
-
} else if (!event.shiftKey && document.activeElement === last) {
|
|
201
|
-
event.preventDefault();
|
|
202
|
-
first.focus();
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function handleKeydown(event) {
|
|
207
|
-
if (!isOpen.value) return;
|
|
208
|
-
if (event.key === 'Escape') {
|
|
209
|
-
event.preventDefault();
|
|
210
|
-
handleDismiss('escape');
|
|
211
|
-
} else if (event.key === 'Tab') {
|
|
212
|
-
trapFocus(event);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
watch(
|
|
217
|
-
() => isOpen.value,
|
|
218
|
-
(open) => {
|
|
219
|
-
if (import.meta.env.SSR) return;
|
|
220
|
-
if (open) {
|
|
221
|
-
previousFocusedElement.value = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
222
|
-
focusDialog();
|
|
223
|
-
document.addEventListener('keydown', handleKeydown);
|
|
224
|
-
} else {
|
|
225
|
-
document.removeEventListener('keydown', handleKeydown);
|
|
226
|
-
restoreFocus();
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
);
|
|
168
|
+
useFocusTrap(dialogRef, isOpen, { onEscape: () => handleDismiss('escape') });
|
|
230
169
|
|
|
231
170
|
function maybeOpenGate() {
|
|
232
171
|
if (import.meta.env.SSR) return;
|
|
@@ -251,11 +190,6 @@ onMounted(() => {
|
|
|
251
190
|
if (import.meta.env.SSR) return;
|
|
252
191
|
maybeOpenGate();
|
|
253
192
|
});
|
|
254
|
-
|
|
255
|
-
onBeforeUnmount(() => {
|
|
256
|
-
if (import.meta.env.SSR) return;
|
|
257
|
-
document.removeEventListener('keydown', handleKeydown);
|
|
258
|
-
});
|
|
259
193
|
</script>
|
|
260
194
|
|
|
261
195
|
<style scoped>
|
|
@@ -319,7 +253,7 @@ onBeforeUnmount(() => {
|
|
|
319
253
|
}
|
|
320
254
|
|
|
321
255
|
.intro-gate-close:hover,
|
|
322
|
-
.intro-gate-close:focus {
|
|
256
|
+
.intro-gate-close:focus-visible {
|
|
323
257
|
color: #ffffff;
|
|
324
258
|
transform: scale(1.05);
|
|
325
259
|
}
|
|
@@ -389,7 +323,7 @@ onBeforeUnmount(() => {
|
|
|
389
323
|
}
|
|
390
324
|
|
|
391
325
|
.intro-gate-primary:hover,
|
|
392
|
-
.intro-gate-primary:focus {
|
|
326
|
+
.intro-gate-primary:focus-visible {
|
|
393
327
|
transform: translateY(-2px);
|
|
394
328
|
box-shadow: var(--brand-primary-cta-hover-shadow, 0 20px 44px rgba(255, 45, 134, 0.55));
|
|
395
329
|
}
|
|
@@ -404,7 +338,7 @@ onBeforeUnmount(() => {
|
|
|
404
338
|
}
|
|
405
339
|
|
|
406
340
|
.intro-gate-primary:hover::after,
|
|
407
|
-
.intro-gate-primary:focus::after {
|
|
341
|
+
.intro-gate-primary:focus-visible::after {
|
|
408
342
|
transform: translateX(0%);
|
|
409
343
|
}
|
|
410
344
|
|
|
@@ -421,7 +355,7 @@ onBeforeUnmount(() => {
|
|
|
421
355
|
}
|
|
422
356
|
|
|
423
357
|
.intro-gate-secondary:hover,
|
|
424
|
-
.intro-gate-secondary:focus {
|
|
358
|
+
.intro-gate-secondary:focus-visible {
|
|
425
359
|
color: var(--brand-neon-pink, #ff2d86);
|
|
426
360
|
border-color: rgba(255, 45, 134, 0.55);
|
|
427
361
|
transform: translateY(-1px);
|
|
@@ -441,4 +375,27 @@ onBeforeUnmount(() => {
|
|
|
441
375
|
padding-inline: 1.2rem;
|
|
442
376
|
}
|
|
443
377
|
}
|
|
378
|
+
|
|
379
|
+
@media (prefers-reduced-motion: reduce) {
|
|
380
|
+
.intro-gate-close,
|
|
381
|
+
.intro-gate-primary,
|
|
382
|
+
.intro-gate-secondary,
|
|
383
|
+
.intro-gate-primary::after {
|
|
384
|
+
transition: none;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.intro-gate-close:hover,
|
|
388
|
+
.intro-gate-close:focus-visible,
|
|
389
|
+
.intro-gate-primary:hover,
|
|
390
|
+
.intro-gate-primary:focus-visible,
|
|
391
|
+
.intro-gate-secondary:hover,
|
|
392
|
+
.intro-gate-secondary:focus-visible {
|
|
393
|
+
transform: none;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.intro-gate-primary:hover::after,
|
|
397
|
+
.intro-gate-primary:focus-visible::after {
|
|
398
|
+
transform: translateX(-120%);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
444
401
|
</style>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="not-found" aria-labelledby="not-found-heading">
|
|
3
|
+
<p class="not-found__eyebrow" aria-hidden="true">404</p>
|
|
4
|
+
<h1 id="not-found-heading" class="not-found__title">{{ heading }}</h1>
|
|
5
|
+
<p class="not-found__body">{{ body }}</p>
|
|
6
|
+
<a class="not-found__home" :href="homeHref">{{ homeLabel }}</a>
|
|
7
|
+
</section>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<script setup>
|
|
11
|
+
import { computed, inject } from 'vue';
|
|
12
|
+
|
|
13
|
+
const pageContent = inject('pageContent', null);
|
|
14
|
+
const siteData = inject('siteData', null);
|
|
15
|
+
|
|
16
|
+
const config = computed(() => {
|
|
17
|
+
const fromShared = siteData?.value?.shared?.content?.notFound;
|
|
18
|
+
const fromPage = pageContent?.value?.notFound;
|
|
19
|
+
return { ...(fromShared || {}), ...(fromPage || {}) };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const heading = computed(() => config.value.heading || 'Page not found');
|
|
23
|
+
const body = computed(
|
|
24
|
+
() => config.value.body || "We couldn't find the page you're looking for.",
|
|
25
|
+
);
|
|
26
|
+
const homeLabel = computed(() => config.value.homeLabel || 'Return home');
|
|
27
|
+
const homeHref = computed(() => config.value.homeHref || '/');
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<style scoped>
|
|
31
|
+
.not-found {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
min-height: 60vh;
|
|
37
|
+
padding: clamp(2rem, 6vw, 4rem) clamp(1rem, 4vw, 2rem);
|
|
38
|
+
text-align: center;
|
|
39
|
+
color: var(--brand-text, #1f2a44);
|
|
40
|
+
background: var(--brand-surface, transparent);
|
|
41
|
+
font-family: var(--ui-font-body, system-ui, -apple-system, sans-serif);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.not-found__eyebrow {
|
|
45
|
+
margin: 0 0 0.25rem;
|
|
46
|
+
font-size: clamp(2.5rem, 8vw, 4rem);
|
|
47
|
+
font-weight: 700;
|
|
48
|
+
line-height: 1;
|
|
49
|
+
letter-spacing: 0.02em;
|
|
50
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.55));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.not-found__title {
|
|
54
|
+
margin: 0 0 0.75rem;
|
|
55
|
+
font-size: clamp(1.5rem, 4vw, 2.25rem);
|
|
56
|
+
font-weight: 700;
|
|
57
|
+
line-height: 1.2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.not-found__body {
|
|
61
|
+
margin: 0 0 1.5rem;
|
|
62
|
+
max-width: 32rem;
|
|
63
|
+
font-size: 1rem;
|
|
64
|
+
line-height: 1.6;
|
|
65
|
+
color: var(--brand-text-muted, rgba(31, 42, 68, 0.7));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.not-found__home {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
min-height: 2.75rem;
|
|
73
|
+
padding: 0.7rem 1.5rem;
|
|
74
|
+
font-size: 1rem;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
text-decoration: none;
|
|
77
|
+
color: var(--brand-primary-cta-text, #ffffff);
|
|
78
|
+
background: var(--brand-primary-cta, var(--brand-primary, #1f2a44));
|
|
79
|
+
border-radius: var(--brand-button-radius, 10px);
|
|
80
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.not-found__home:hover {
|
|
84
|
+
transform: translateY(-1px);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.not-found__home:focus-visible {
|
|
88
|
+
outline: 2px solid var(--brand-primary, #1f2a44);
|
|
89
|
+
outline-offset: 3px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (prefers-reduced-motion: reduce) {
|
|
93
|
+
.not-found__home {
|
|
94
|
+
transition: none;
|
|
95
|
+
}
|
|
96
|
+
.not-found__home:hover {
|
|
97
|
+
transform: none;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
</style>
|
package/src/components/Plan.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<section id="plan" class="section-shell plan-section" data-analytics-section="plan">
|
|
2
|
+
<section v-if="hasContent" id="plan" class="section-shell plan-section" data-analytics-section="plan">
|
|
3
3
|
<div class="container">
|
|
4
4
|
<header class="plan-heading text-center">
|
|
5
5
|
<div class="plan-heading__divider" aria-hidden="true"></div>
|
|
@@ -35,6 +35,9 @@ import { computed, inject, ref } from 'vue';
|
|
|
35
35
|
const pageContent = inject('pageContent', ref({}));
|
|
36
36
|
|
|
37
37
|
const planData = computed(() => pageContent.value?.plan || {});
|
|
38
|
+
// components[] contract: no content.plan block → render nothing (never a
|
|
39
|
+
// placeholder heading over an empty grid).
|
|
40
|
+
const hasContent = computed(() => Object.keys(planData.value).length > 0);
|
|
38
41
|
|
|
39
42
|
const planTitle = computed(() => planData.value?.title || 'The Plan');
|
|
40
43
|
const planSubtitle = computed(() => planData.value?.subtitle || '');
|
|
@@ -94,7 +97,8 @@ const planItems = computed(() =>
|
|
|
94
97
|
width: 70px;
|
|
95
98
|
height: 70px;
|
|
96
99
|
border-radius: 50%;
|
|
97
|
-
|
|
100
|
+
/* 3:1+ against the dark plan-card surface (WCAG 1.4.11). */
|
|
101
|
+
border: 3px solid var(--plan-step-border, var(--brand-plan-step-ring, rgba(255, 255, 255, 0.5)));
|
|
98
102
|
color: var(--plan-step-color, #ffffff);
|
|
99
103
|
display: inline-flex;
|
|
100
104
|
align-items: center;
|
|
@@ -107,10 +111,12 @@ const planItems = computed(() =>
|
|
|
107
111
|
font-size: 1.15rem;
|
|
108
112
|
font-weight: 600;
|
|
109
113
|
margin: 0;
|
|
110
|
-
|
|
114
|
+
/* Dark plan-card surface needs light text. Don't fall through to
|
|
115
|
+
--ui-text-primary (dark) — that's the FOUC trap that bit FooterMinimal. */
|
|
116
|
+
color: var(--plan-card-title, var(--brand-plan-card-text, #ffffff));
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
.plan-card__description {
|
|
114
|
-
color: var(--
|
|
120
|
+
color: var(--plan-card-description, var(--brand-plan-card-muted-text, #c8c2cf));
|
|
115
121
|
}
|
|
116
122
|
</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);
|
|
@@ -456,4 +460,26 @@ function handleAfterLeave() {
|
|
|
456
460
|
transform: translateY(24px) scale(0.96);
|
|
457
461
|
}
|
|
458
462
|
|
|
463
|
+
@media (prefers-reduced-motion: reduce) {
|
|
464
|
+
.portfolio-filter,
|
|
465
|
+
.portfolio-card,
|
|
466
|
+
.portfolio-move,
|
|
467
|
+
.portfolio-enter-active,
|
|
468
|
+
.portfolio-leave-active {
|
|
469
|
+
transition: none;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.portfolio-filter:hover,
|
|
473
|
+
.portfolio-filter:focus-visible,
|
|
474
|
+
.portfolio-filter--active:hover,
|
|
475
|
+
.portfolio-filter--active:focus-visible,
|
|
476
|
+
.portfolio-card:hover {
|
|
477
|
+
transform: none;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.portfolio-enter-from,
|
|
481
|
+
.portfolio-leave-to {
|
|
482
|
+
transform: none;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
459
485
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<section id="principles" class="section-shell principles-section" data-analytics-section="principles">
|
|
2
|
+
<section v-if="hasContent" id="principles" class="section-shell principles-section" data-analytics-section="principles">
|
|
3
3
|
<div class="container">
|
|
4
4
|
<header class="section-header text-center">
|
|
5
5
|
<div class="section-heading">
|
|
@@ -29,6 +29,8 @@ import { computed, inject, ref } from 'vue';
|
|
|
29
29
|
const pageContent = inject('pageContent', ref({}));
|
|
30
30
|
|
|
31
31
|
const principlesData = computed(() => pageContent.value?.principles || {});
|
|
32
|
+
// components[] contract: no content.principles block → render nothing.
|
|
33
|
+
const hasContent = computed(() => Object.keys(principlesData.value).length > 0);
|
|
32
34
|
|
|
33
35
|
const principlesTitle = computed(() => principlesData.value?.title || 'Principles');
|
|
34
36
|
const principlesSubtitle = computed(() => principlesData.value?.subtitle || '');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="ui-spacer" aria-hidden="true" :style="spacerStyle"></div>
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup>
|
|
6
|
+
import { computed } from 'vue';
|
|
7
|
+
|
|
8
|
+
// Vertical spacing utility. The numeric-suffixed names (Spacer15/30/40/60)
|
|
9
|
+
// used in pages/*.json components[] are thin aliases wrapping this with a
|
|
10
|
+
// fixed size.
|
|
11
|
+
const props = defineProps({
|
|
12
|
+
size: {
|
|
13
|
+
type: [Number, String],
|
|
14
|
+
default: 30,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const spacerStyle = computed(() => {
|
|
19
|
+
const parsed = Number.parseInt(props.size, 10);
|
|
20
|
+
const px = Number.isFinite(parsed) && parsed >= 0 ? parsed : 30;
|
|
21
|
+
return { '--ui-spacer-size': `${px}px` };
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<Spacer :size="15" />
|
|
3
3
|
</template>
|
|
4
4
|
|
|
5
5
|
<script setup>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
6
|
+
// Alias kept for existing pages/*.json components[] references.
|
|
7
|
+
import Spacer from './Spacer.vue';
|
|
9
8
|
</script>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<Spacer :size="30" />
|
|
3
3
|
</template>
|
|
4
4
|
|
|
5
5
|
<script setup>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
6
|
+
// Alias kept for existing pages/*.json components[] references.
|
|
7
|
+
import Spacer from './Spacer.vue';
|
|
9
8
|
</script>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<Spacer :size="40" />
|
|
3
3
|
</template>
|
|
4
4
|
|
|
5
5
|
<script setup>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
6
|
+
// Alias kept for existing pages/*.json components[] references.
|
|
7
|
+
import Spacer from './Spacer.vue';
|
|
9
8
|
</script>
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<Spacer :size="60" />
|
|
3
3
|
</template>
|
|
4
4
|
|
|
5
5
|
<script setup>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
6
|
+
// Alias kept for existing pages/*.json components[] references.
|
|
7
|
+
import Spacer from './Spacer.vue';
|
|
9
8
|
</script>
|