@koehler8/cms 1.0.0-beta.5

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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -0
  3. package/bin/cms-generate-public-assets.js +27 -0
  4. package/bin/cms-validate-extensions.js +18 -0
  5. package/bin/cms-validate-themes.js +7 -0
  6. package/extensions/manifest.schema.json +125 -0
  7. package/package.json +84 -0
  8. package/public/img/preloaders/preloader-black.svg +1 -0
  9. package/public/img/preloaders/preloader-white.svg +1 -0
  10. package/public/robots.txt +5 -0
  11. package/scripts/check-overflow.mjs +33 -0
  12. package/scripts/generate-public-assets.js +401 -0
  13. package/scripts/patch-lru-cache-tla.js +164 -0
  14. package/scripts/validate-extensions.mjs +392 -0
  15. package/scripts/validate-themes.mjs +64 -0
  16. package/src/App.vue +3 -0
  17. package/src/components/About.vue +481 -0
  18. package/src/components/AboutValue.vue +361 -0
  19. package/src/components/BackToTop.vue +42 -0
  20. package/src/components/ComingSoon.vue +411 -0
  21. package/src/components/ComingSoonModal.vue +230 -0
  22. package/src/components/Contact.vue +518 -0
  23. package/src/components/Footer.vue +65 -0
  24. package/src/components/FooterMinimal.vue +153 -0
  25. package/src/components/Header.vue +583 -0
  26. package/src/components/Hero.vue +327 -0
  27. package/src/components/Home.vue +144 -0
  28. package/src/components/Intro.vue +130 -0
  29. package/src/components/IntroGate.vue +444 -0
  30. package/src/components/Plan.vue +116 -0
  31. package/src/components/Portfolio.vue +459 -0
  32. package/src/components/Preloader.vue +20 -0
  33. package/src/components/Principles.vue +67 -0
  34. package/src/components/Spacer15.vue +9 -0
  35. package/src/components/Spacer30.vue +9 -0
  36. package/src/components/Spacer40.vue +9 -0
  37. package/src/components/Spacer60.vue +9 -0
  38. package/src/components/StickyCTA.vue +263 -0
  39. package/src/components/Team.vue +432 -0
  40. package/src/components/icons/IconLinkedIn.vue +22 -0
  41. package/src/components/icons/IconX.vue +22 -0
  42. package/src/components/ui/SbCard.vue +52 -0
  43. package/src/components/ui/SkeletonPulse.vue +117 -0
  44. package/src/components/ui/UnitChip.vue +69 -0
  45. package/src/composables/useComingSoonConfig.js +120 -0
  46. package/src/composables/useComingSoonInterstitial.js +27 -0
  47. package/src/composables/useComponentResolver.js +196 -0
  48. package/src/composables/useEngagementTracking.js +187 -0
  49. package/src/composables/useIntroGate.js +46 -0
  50. package/src/composables/useLazyImage.js +77 -0
  51. package/src/composables/usePageConfig.js +184 -0
  52. package/src/composables/usePageMeta.js +76 -0
  53. package/src/composables/usePromoBackgroundStyles.js +67 -0
  54. package/src/constants/locales.js +20 -0
  55. package/src/extensions/extensionLoader.js +512 -0
  56. package/src/main.js +175 -0
  57. package/src/router/index.js +112 -0
  58. package/src/styles/base.css +896 -0
  59. package/src/styles/layout.css +342 -0
  60. package/src/styles/theme-base.css +84 -0
  61. package/src/themes/themeLoader.js +124 -0
  62. package/src/themes/themeManager.js +257 -0
  63. package/src/themes/themeValidator.js +380 -0
  64. package/src/utils/analytics.js +100 -0
  65. package/src/utils/appInfo.js +9 -0
  66. package/src/utils/assetResolver.js +162 -0
  67. package/src/utils/componentRegistry.js +46 -0
  68. package/src/utils/contentRequirements.js +67 -0
  69. package/src/utils/cookieConsent.js +281 -0
  70. package/src/utils/ctaCopy.js +58 -0
  71. package/src/utils/formatNumber.js +115 -0
  72. package/src/utils/imageSources.js +179 -0
  73. package/src/utils/inflateFlatConfig.js +30 -0
  74. package/src/utils/loadConfig.js +271 -0
  75. package/src/utils/semver.js +49 -0
  76. package/src/utils/siteStyles.js +40 -0
  77. package/src/utils/themeColors.js +65 -0
  78. package/src/utils/trackingContext.js +142 -0
  79. package/src/utils/unwrapDefault.js +14 -0
  80. package/src/utils/useScrollReveal.js +48 -0
  81. package/templates/index.html +36 -0
  82. package/themes/base/README.md +23 -0
  83. package/themes/base/theme.config.js +214 -0
  84. package/vite-plugin.js +637 -0
@@ -0,0 +1,327 @@
1
+ <template>
2
+ <section
3
+ id="promo"
4
+ ref="heroSection"
5
+ :class="promoSectionClasses"
6
+ :style="promoBackgroundStyle"
7
+ >
8
+ <div class="container text-center hero-content hero-inner">
9
+ <div class="hero-eyebrow fade-up-in js-scroll-fade" style="--fade-up-delay: 0s">
10
+ <h2
11
+ :class="[
12
+ 'h4',
13
+ 'text-uppercase',
14
+ 'section-eyebrow-divider',
15
+ 'hero-eyebrow__text',
16
+ ]"
17
+ >
18
+ {{ tagLine }}
19
+ </h2>
20
+ </div>
21
+ <h3
22
+ :class="[
23
+ 'display-heading',
24
+ 'text-uppercase',
25
+ 'hero-headline',
26
+ 'fade-up-in',
27
+ 'js-scroll-fade'
28
+ ]"
29
+ style="--fade-up-delay: 0.1s"
30
+ >
31
+ <span
32
+ v-for="(part, index) in heroHeadlineParts"
33
+ :key="`${part}-${index}`"
34
+ :class="[
35
+ 'display-heading__part',
36
+ { 'display-heading__part--nowrap': index === 0 }
37
+ ]"
38
+ >
39
+ {{ part }}
40
+ </span>
41
+ </h3>
42
+ <div
43
+ class="hero-cta-stack fade-up-in js-scroll-fade"
44
+ style="--fade-up-delay: 0.2s"
45
+ >
46
+ <a
47
+ v-for="(action, idx) in promoActions"
48
+ :key="idx"
49
+ :class="buildPromoActionClasses(action)"
50
+ :href="action.href"
51
+ :data-target="isComingSoonAction(action.href) ? null : action.href"
52
+ @click="handlePromoActionClick($event, action)"
53
+ >
54
+ {{ action.text }}
55
+ </a>
56
+ </div>
57
+ </div>
58
+ </section>
59
+ </template>
60
+
61
+ <script setup>
62
+ import { computed, inject, onMounted, ref } from 'vue';
63
+ import { useComingSoonInterstitial } from '../composables/useComingSoonInterstitial.js';
64
+ import { usePromoBackgroundStyles } from '../composables/usePromoBackgroundStyles.js';
65
+ import { trackEvent } from '../utils/analytics.js';
66
+ import { useComingSoonResolver } from '../composables/useComingSoonConfig.js';
67
+ import { useResponsiveImage } from '../utils/imageSources.js';
68
+ import { registerScrollReveal } from '../utils/useScrollReveal.js';
69
+
70
+ const { openComingSoon } = useComingSoonInterstitial();
71
+
72
+ const pageContent = inject('pageContent', ref({}));
73
+ const siteData = inject('siteData', ref({}));
74
+ const { resolve: resolveComingSoon, isComingSoonAction } = useComingSoonResolver(pageContent);
75
+
76
+ const promoData = computed(() => pageContent.value?.promo || {});
77
+
78
+ const tagLine = computed(() => promoData.value?.tagLine || '');
79
+ const siteName = computed(() => promoData.value?.mainText || '');
80
+ const promoActions = computed(() =>
81
+ (Array.isArray(promoData.value?.actions) ? promoData.value.actions : []).map(
82
+ (action, index) => normalizePromoAction(action, index)
83
+ )
84
+ );
85
+
86
+ const heroHeadlineParts = computed(() => {
87
+ const value = siteName.value || '';
88
+ if (!value.includes('.')) {
89
+ return [value];
90
+ }
91
+ const segments = value.split('.').map((segment) => segment.trim()).filter(Boolean);
92
+ if (!segments.length) return [value];
93
+ return segments.map((segment, index) => (index < segments.length - 1 ? `${segment}.` : segment));
94
+ });
95
+
96
+ const promoImageSet = useResponsiveImage('img/promo', {
97
+ widths: [960, 1440, 1920],
98
+ fallbackFormat: 'jpg',
99
+ });
100
+
101
+ const heroSection = ref(null);
102
+ const HERO_SCROLL_MARGIN = 32;
103
+ const HERO_SMALL_SCREEN_QUERY = '(max-width: 640px)';
104
+
105
+ const promoSectionClasses = computed(() => [
106
+ 'promo-surface',
107
+ 'section-shell',
108
+ 'hero-shell',
109
+ ]);
110
+
111
+ const promoBackgroundStyle = usePromoBackgroundStyles({
112
+ imageSet: promoImageSet,
113
+ });
114
+
115
+ onMounted(() => {
116
+ if (!heroSection.value) return;
117
+ const targets = heroSection.value.querySelectorAll('.js-scroll-fade');
118
+ registerScrollReveal(targets);
119
+ });
120
+
121
+ function normalizePromoAction(entry, index) {
122
+ const action = entry && typeof entry === 'object' ? { ...entry } : {};
123
+ action.variant = resolveActionVariant(action, index);
124
+ return action;
125
+ }
126
+
127
+ function resolveActionVariant(action, index) {
128
+ const style = typeof action?.style === 'string' ? action.style.trim().toLowerCase() : '';
129
+ if (style === 'link' || style === 'text') {
130
+ return 'link';
131
+ }
132
+ if (style === 'dark' || style === 'primary' || style === 'solid') {
133
+ return 'primary';
134
+ }
135
+ if (style === 'light' || style === 'secondary' || style === 'outline' || style === 'ghost') {
136
+ return 'secondary';
137
+ }
138
+ return index === 0 ? 'primary' : 'secondary';
139
+ }
140
+
141
+ function buildPromoActionClasses(action = {}) {
142
+ const variant = action.variant || 'secondary';
143
+ const classes = [];
144
+ if (variant === 'link') {
145
+ classes.push('action-link');
146
+ } else {
147
+ classes.push('action-pill');
148
+ if (variant === 'primary') {
149
+ classes.push('primary-button');
150
+ } else {
151
+ classes.push('action-pill--secondary');
152
+ }
153
+ }
154
+ return classes;
155
+ }
156
+
157
+ function handlePromoActionClick(event, action) {
158
+ if (!event) return;
159
+
160
+ const label = (action?.text ?? '').toString();
161
+ const trimmedLabel = label.trim();
162
+
163
+ const href = (action?.href ?? '').toString().trim();
164
+
165
+ const modalPayload = resolveComingSoon({
166
+ href,
167
+ });
168
+ if (modalPayload) {
169
+ event.preventDefault();
170
+ openComingSoon(modalPayload);
171
+ trackEvent('coming_soon_interstitial_shown', {
172
+ trigger: 'promo-action',
173
+ label: trimmedLabel ? trimmedLabel.toLowerCase() : 'coming-soon',
174
+ });
175
+ return;
176
+ }
177
+
178
+ if (shouldHandleAnchorScroll(href)) {
179
+ event.preventDefault();
180
+ scrollToPromoTarget(href);
181
+ }
182
+ }
183
+
184
+ function shouldHandleAnchorScroll(href) {
185
+ if (typeof href !== 'string') return false;
186
+ const trimmed = href.trim();
187
+ if (!trimmed.length) return false;
188
+ return trimmed.startsWith('#') && trimmed.length > 1;
189
+ }
190
+
191
+ function scrollToPromoTarget(targetSelector) {
192
+ if (typeof document === 'undefined' || typeof window === 'undefined') return;
193
+ if (!targetSelector) return;
194
+ const element = document.querySelector(targetSelector);
195
+ if (!element) return;
196
+
197
+ const rect = element.getBoundingClientRect();
198
+ const headerOffset = getHeaderOffset();
199
+ const isCompact = window.matchMedia && window.matchMedia(HERO_SMALL_SCREEN_QUERY).matches;
200
+ const scrollMargin = isCompact ? 0 : HERO_SCROLL_MARGIN;
201
+ const targetTop = Math.max(rect.top + window.pageYOffset - headerOffset - scrollMargin, 0);
202
+
203
+ window.scrollTo({
204
+ top: targetTop,
205
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
206
+ });
207
+ }
208
+
209
+ function prefersReducedMotion() {
210
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
211
+ return false;
212
+ }
213
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
214
+ }
215
+
216
+ function getHeaderOffset() {
217
+ if (typeof document === 'undefined') return 0;
218
+ const header = document.getElementById('js-header');
219
+ if (!header) return 0;
220
+ return header.offsetHeight || 0;
221
+ }
222
+ </script>
223
+
224
+ <style scoped>
225
+
226
+ .hero-shell {
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: center;
230
+ min-height: calc(100vh + 60px);
231
+ padding-block: clamp(96px, 18vh, 200px);
232
+ }
233
+
234
+ .hero-content {
235
+ position: relative;
236
+ z-index: 1;
237
+ }
238
+
239
+ .hero-inner {
240
+ width: min(960px, 90vw);
241
+ margin: 0 auto;
242
+ display: flex;
243
+ flex-direction: column;
244
+ align-items: center;
245
+ gap: var(--ui-space-16, 16px);
246
+ text-align: center;
247
+ }
248
+
249
+ .hero-eyebrow {
250
+ margin-bottom: var(--ui-space-16, 16px);
251
+ }
252
+
253
+ .hero-eyebrow__text {
254
+ display: inline-block;
255
+ font-weight: 600;
256
+ letter-spacing: 0.12em;
257
+ font-size: clamp(1.1rem, 1.5vw, 1.55rem);
258
+ line-height: 1.35;
259
+ color: var(--hero-text-color, var(--ui-text-primary, var(--brand-fg-100, #111)));
260
+ border-bottom: 1px solid currentColor;
261
+ }
262
+
263
+ .hero-headline {
264
+ font-weight: 700;
265
+ font-size: clamp(2.1rem, 4.6vw, 3.5rem);
266
+ line-height: 1.08;
267
+ margin-bottom: clamp(18px, 2.5vw, 28px);
268
+ letter-spacing: 0.04em;
269
+ color: var(--hero-text-color, var(--ui-text-primary, var(--brand-fg-100, #111)));
270
+ }
271
+
272
+ .promo-surface {
273
+ position: relative;
274
+ min-height: var(--promo-surface-min-height, calc(100vh + 75px));
275
+ background: var(
276
+ --promo-surface-bg,
277
+ var(--hero-surface-bg, var(--theme-body-background, #060212))
278
+ );
279
+ background-repeat: no-repeat;
280
+ background-size: cover;
281
+ background-position: center;
282
+ }
283
+
284
+ .hero-cta-stack {
285
+ display: grid;
286
+ grid-template-columns: repeat(auto-fit, minmax(170px, max-content));
287
+ justify-content: center;
288
+ justify-items: center;
289
+ column-gap: 20px;
290
+ row-gap: 16px;
291
+ margin-bottom: var(--ui-space-24, 24px);
292
+ max-width: min(960px, 100%);
293
+ margin-inline: auto;
294
+ }
295
+
296
+ .hero-cta-stack :deep(.primary-button),
297
+ .hero-cta-stack :deep(.action-pill) {
298
+ width: auto;
299
+ min-width: 170px;
300
+ padding: 0.75rem 1.8rem;
301
+ min-height: 46px;
302
+ font-size: 0.88rem;
303
+ letter-spacing: 0.04em;
304
+ }
305
+
306
+ @media (max-width: 480px) {
307
+ .hero-headline {
308
+ font-size: clamp(2rem, 9vw, 3rem);
309
+ }
310
+ }
311
+
312
+ @media (max-width: 640px) {
313
+ .hero-cta-stack {
314
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
315
+ gap: 14px;
316
+ }
317
+
318
+ .hero-cta-stack :deep(.primary-button),
319
+ .hero-cta-stack :deep(.action-pill) {
320
+ width: 100%;
321
+ padding: 0.7rem 1.1rem;
322
+ min-height: 42px;
323
+ font-size: 0.84rem;
324
+ }
325
+ }
326
+
327
+ </style>
@@ -0,0 +1,144 @@
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"
12
+ />
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
+ />
35
+ </template>
36
+
37
+ <script setup>
38
+ import { computed, nextTick, provide, watch } from 'vue';
39
+
40
+ import ComingSoonModal from '../components/ComingSoonModal.vue';
41
+ import IntroGate from '../components/IntroGate.vue';
42
+
43
+ import { registry } from '../utils/componentRegistry.js';
44
+ import { usePageConfig } from '../composables/usePageConfig.js';
45
+ import { useComponentResolver } from '../composables/useComponentResolver.js';
46
+ import { usePageMeta } from '../composables/usePageMeta.js';
47
+ import { useIntroGate } from '../composables/useIntroGate.js';
48
+ import { useEngagementTracking } from '../composables/useEngagementTracking.js';
49
+ import { useComingSoonInterstitial } from '../composables/useComingSoonInterstitial.js';
50
+
51
+ const props = defineProps({
52
+ pageId: {
53
+ type: String,
54
+ default: null,
55
+ },
56
+ pagePath: {
57
+ type: String,
58
+ default: null,
59
+ },
60
+ locale: {
61
+ type: String,
62
+ default: null,
63
+ },
64
+ });
65
+
66
+ const {
67
+ siteData,
68
+ pageContent,
69
+ currentPage,
70
+ componentKeys,
71
+ isLoading,
72
+ loadError,
73
+ } = usePageConfig({
74
+ pageId: () => props.pageId,
75
+ pagePath: () => props.pagePath,
76
+ locale: () => props.locale,
77
+ });
78
+
79
+ const { loadedComponents } = useComponentResolver({
80
+ componentKeys,
81
+ pageContent,
82
+ currentPage,
83
+ registry,
84
+ });
85
+
86
+ const { introGateEnabled, introGateProps } = useIntroGate({ siteData, pageContent });
87
+
88
+ usePageMeta({ siteData, currentPage });
89
+
90
+ const {
91
+ isComingSoonVisible,
92
+ comingSoonTitle,
93
+ comingSoonMessage,
94
+ closeComingSoon,
95
+ } = useComingSoonInterstitial();
96
+
97
+ const { refreshVisibilityTargets, resetEngagementTracking } = useEngagementTracking({
98
+ getContext: () => ({
99
+ page_id: currentPage.value.id || '',
100
+ locale: props.locale || '',
101
+ }),
102
+ });
103
+
104
+ provide('siteData', siteData);
105
+ provide('pageContent', pageContent);
106
+ provide('pageConfig', currentPage);
107
+ provide('currentPageId', computed(() => currentPage.value.id));
108
+
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.') : '');
111
+
112
+ watch(componentKeys, async () => {
113
+ await nextTick();
114
+ refreshVisibilityTargets();
115
+ }, { flush: 'post' });
116
+
117
+ watch(
118
+ () => currentPage.value.id,
119
+ async () => {
120
+ await nextTick();
121
+ resetEngagementTracking();
122
+ refreshVisibilityTargets();
123
+ },
124
+ { immediate: true, flush: 'post' },
125
+ );
126
+ </script>
127
+
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;
136
+ font-weight: 600;
137
+ color: #111;
138
+ text-align: center;
139
+ }
140
+
141
+ .page-error-message {
142
+ color: #b00020;
143
+ }
144
+ </style>
@@ -0,0 +1,130 @@
1
+ <template>
2
+ <section id="about" class="section-shell intro-section" data-analytics-section="intro">
3
+ <div class="container">
4
+ <div class="intro-grid">
5
+ <div class="intro-column">
6
+ <div class="intro-heading">
7
+ <h2 class="intro-title">
8
+ {{ introTitle }}
9
+ </h2>
10
+ <div class="intro-divider" aria-hidden="true"></div>
11
+ </div>
12
+ <div class="intro-copy">
13
+ <p>{{ introText }}</p>
14
+ </div>
15
+ </div>
16
+ <div class="intro-column">
17
+ <div
18
+ ref="introImageTarget"
19
+ class="responsive-picture-shell rounded intro-media"
20
+ style="aspect-ratio: 18 / 10;"
21
+ >
22
+ <picture v-if="isIntroImageVisible" class="responsive-picture">
23
+ <source
24
+ v-for="source in introImageSources"
25
+ :key="source.type"
26
+ :type="source.type"
27
+ :srcset="source.srcset"
28
+ :sizes="introImageSizes"
29
+ />
30
+ <img
31
+ :src="introImageFallback"
32
+ :srcset="introImageFallbackSet || undefined"
33
+ :sizes="introImageSizes"
34
+ :alt="introImgText"
35
+ @error="introImageHandleError"
36
+ width="540"
37
+ height="300"
38
+ loading="lazy"
39
+ decoding="async"
40
+ class="rounded intro-image responsive-picture__img"
41
+ />
42
+ </picture>
43
+ <div
44
+ v-else
45
+ class="responsive-picture-placeholder rounded intro-image"
46
+ :style="introPlaceholder ? { backgroundImage: `url(${introPlaceholder})` } : null"
47
+ ></div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </section>
53
+ </template>
54
+
55
+ <script setup>
56
+ import { computed, inject, ref } from 'vue';
57
+ import { useResponsiveImage } from '../utils/imageSources.js';
58
+ import { useLazyImage } from '../composables/useLazyImage.js';
59
+
60
+ const injectedSiteData = inject('siteData', ref({}));
61
+ const pageContent = inject('pageContent', ref({}));
62
+
63
+ const introData = computed(() => pageContent.value?.intro || {});
64
+
65
+ const introTitle = computed(() => introData.value?.title || '');
66
+ const introText = computed(() => introData.value?.text || '');
67
+
68
+ const introImage = useResponsiveImage('img/intro', {
69
+ widths: [320, 540],
70
+ fallbackFormat: 'jpg',
71
+ });
72
+ const introImageHandleError = introImage.handleError;
73
+
74
+ const introImageSizes = '(min-width: 1200px) 40vw, (min-width: 992px) 45vw, 100vw';
75
+ const { isVisible: isIntroImageVisible, targetRef: introImageTarget } = useLazyImage({
76
+ rootMargin: '200px 0px',
77
+ });
78
+
79
+ const introImageSources = introImage.sources;
80
+ const introImageFallback = introImage.fallbackSrc;
81
+ const introImageFallbackSet = introImage.fallbackSrcSet;
82
+ const introPlaceholder = introImage.placeholderSrc;
83
+
84
+ const introImgText = computed(() => {
85
+ if (introData.value?.imgAlt) return introData.value.imgAlt;
86
+ const siteTitle = injectedSiteData.value?.site?.title || '';
87
+ return siteTitle ? `Introducing ${siteTitle}` : 'Introducing';
88
+ });
89
+ </script>
90
+
91
+ <style scoped>
92
+ .intro-grid {
93
+ display: grid;
94
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
95
+ gap: clamp(24px, 6vw, 48px);
96
+ align-items: center;
97
+ }
98
+
99
+ .intro-heading {
100
+ margin-bottom: var(--ui-space-24, 24px);
101
+ }
102
+
103
+ .intro-title {
104
+ text-transform: uppercase;
105
+ letter-spacing: 0.16em;
106
+ font-size: clamp(1.5rem, 4vw, 2.25rem);
107
+ margin: 0 0 0.75rem;
108
+ }
109
+
110
+ .intro-divider {
111
+ width: 60px;
112
+ height: 3px;
113
+ background: var(--intro-divider-color, var(--brand-accent-electric, #4f6cf0));
114
+ border-radius: 999px;
115
+ margin-top: var(--ui-space-12, 12px);
116
+ }
117
+
118
+ .intro-copy {
119
+ font-size: 1rem;
120
+ color: var(--ui-text-primary, var(--brand-fg-100, #1f2a44));
121
+ }
122
+
123
+ .intro-media {
124
+ transition: box-shadow 0.3s ease;
125
+ }
126
+
127
+ .intro-image {
128
+ border-radius: inherit;
129
+ }
130
+ </style>