@koehler8/cms 1.0.0-beta.8 → 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.
Files changed (110) hide show
  1. package/CHANGELOG.md +1491 -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 +36 -16
  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 +184 -10
  38. package/src/components/Hero.vue +17 -8
  39. package/src/components/Home.vue +81 -48
  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 +126 -28
  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/robotsGenerator.js +95 -0
  74. package/src/utils/sha256.js +20 -0
  75. package/src/utils/sitemapGenerator.js +112 -0
  76. package/src/utils/socialMeta.js +100 -0
  77. package/src/utils/ssgRoutes.js +40 -0
  78. package/src/utils/trackingContext.js +16 -22
  79. package/src/utils/webAppManifest.js +85 -0
  80. package/src/utils/webStorage.js +82 -0
  81. package/templates/index.html +17 -24
  82. package/templates/scaffolds/extension/README.md +53 -0
  83. package/templates/scaffolds/extension/extension.config.json +10 -0
  84. package/templates/scaffolds/extension/index.js +37 -0
  85. package/templates/scaffolds/extension/package.json +22 -0
  86. package/templates/scaffolds/site/.env.example +7 -0
  87. package/templates/scaffolds/site/.nvmrc +1 -0
  88. package/templates/scaffolds/site/CLAUDE.md +121 -0
  89. package/templates/scaffolds/site/README.md +25 -0
  90. package/templates/scaffolds/site/amplify.yml +22 -0
  91. package/templates/scaffolds/site/package.json +26 -0
  92. package/templates/scaffolds/site/site/assets/img/.gitkeep +0 -0
  93. package/templates/scaffolds/site/site/components/.gitkeep +0 -0
  94. package/templates/scaffolds/site/site/content/content.config.json +3 -0
  95. package/templates/scaffolds/site/site/content/en/pages/home.json +8 -0
  96. package/templates/scaffolds/site/site/content/en/shared.json +5 -0
  97. package/templates/scaffolds/site/site/content/en/site.json +10 -0
  98. package/templates/scaffolds/site/site/style.css +4 -0
  99. package/templates/scaffolds/site/vite.config.js +15 -0
  100. package/templates/scaffolds/theme/README.md +50 -0
  101. package/templates/scaffolds/theme/index.js +8 -0
  102. package/templates/scaffolds/theme/package.json +21 -0
  103. package/templates/scaffolds/theme/theme.config.js +228 -0
  104. package/templates/scaffolds/theme/theme.css +14 -0
  105. package/themes/base/theme.config.js +100 -57
  106. package/vite-plugin.js +545 -32
  107. package/public/robots.txt +0 -5
  108. package/scripts/check-overflow.mjs +0 -33
  109. package/scripts/migrate-to-content-dirs.mjs +0 -223
  110. package/scripts/patch-lru-cache-tla.js +0 -164
@@ -1,43 +1,39 @@
1
1
  <template>
2
- <IntroGate
3
- :enabled="introGateEnabled"
4
- v-bind="introGateProps"
5
- />
6
- <main>
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
- <div
14
- v-if="showLoadingIndicator"
15
- class="page-loading-placeholder"
16
- role="status"
17
- aria-live="polite"
18
- >
19
- Loading…
20
- </div>
21
- <div
22
- v-else-if="loadErrorMessage"
23
- class="page-error-message"
24
- role="alert"
25
- >
26
- {{ loadErrorMessage }}
27
- </div>
28
- </main>
29
- <ComingSoonModal
30
- :open="isComingSoonVisible"
31
- :title="comingSoonTitle"
32
- :message="comingSoonMessage"
33
- @close="closeComingSoon"
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
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
- usePageMeta({ siteData, currentPage });
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,8 +111,13 @@ provide('pageContent', pageContent);
106
111
  provide('pageConfig', currentPage);
107
112
  provide('currentPageId', computed(() => currentPage.value.id));
108
113
 
109
- const showLoadingIndicator = computed(() => isLoading.value && componentKeys.value.length === 0 && !loadError.value);
110
- 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);
111
121
 
112
122
  watch(componentKeys, async () => {
113
123
  await nextTick();
@@ -125,20 +135,43 @@ watch(
125
135
  );
126
136
  </script>
127
137
 
128
- <style scoped>
129
- .page-loading-placeholder,
130
- .page-error-message {
131
- display: flex;
132
- align-items: center;
133
- justify-content: center;
134
- min-height: 40vh;
135
- font-size: 1.125rem;
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);
136
152
  font-weight: 600;
137
- color: #111;
138
- text-align: center;
153
+ text-decoration: none;
154
+ z-index: 9999;
155
+ transform: translateY(-100%);
156
+ transition: transform 0.15s ease;
139
157
  }
140
-
141
- .page-error-message {
142
- color: #b00020;
158
+ .cms-skip-link:focus {
159
+ transform: translateY(0);
160
+ outline: 2px solid #1f2a44;
161
+ outline-offset: 2px;
143
162
  }
163
+ @media (prefers-reduced-motion: reduce) {
164
+ .cms-skip-link { transition: none; }
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; }
144
176
  </style>
177
+
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <section id="about" class="section-shell intro-section" data-analytics-section="intro">
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, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
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
- function focusDialog() {
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>
@@ -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
- border: 3px solid var(--plan-step-border, rgba(255, 255, 255, 0.3));
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
- color: var(--plan-card-title, var(--ui-text-primary, #ffffff));
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(--ui-text-muted, rgba(255, 255, 255, 0.78));
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="Visit portfolio link"></a>
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, #4f6cf0));
344
- border-color: var(--portfolio-filter-hover-border, rgba(79, 108, 240, 0.5));
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
- <div class="ui-spacer" aria-hidden="true" :style="spacerStyle"></div>
2
+ <Spacer :size="15" />
3
3
  </template>
4
4
 
5
5
  <script setup>
6
- const spacerStyle = {
7
- '--ui-spacer-size': '15px',
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
- <div class="ui-spacer" aria-hidden="true" :style="spacerStyle"></div>
2
+ <Spacer :size="30" />
3
3
  </template>
4
4
 
5
5
  <script setup>
6
- const spacerStyle = {
7
- '--ui-spacer-size': '30px',
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
- <div class="ui-spacer" aria-hidden="true" :style="spacerStyle"></div>
2
+ <Spacer :size="40" />
3
3
  </template>
4
4
 
5
5
  <script setup>
6
- const spacerStyle = {
7
- '--ui-spacer-size': '40px',
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
- <div class="ui-spacer" aria-hidden="true" :style="spacerStyle"></div>
2
+ <Spacer :size="60" />
3
3
  </template>
4
4
 
5
5
  <script setup>
6
- const spacerStyle = {
7
- '--ui-spacer-size': '60px',
8
- };
6
+ // Alias kept for existing pages/*.json components[] references.
7
+ import Spacer from './Spacer.vue';
9
8
  </script>