@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,263 @@
1
+ <template>
2
+ <transition name="slide-up">
3
+ <div
4
+ v-if="isVisible"
5
+ class="sticky-cta"
6
+ role="complementary"
7
+ aria-label="Primary site action"
8
+ >
9
+ <button
10
+ type="button"
11
+ class="sticky-cta__button"
12
+ role="button"
13
+ :aria-label="buttonLabel"
14
+ @click="handleClick"
15
+ >
16
+ {{ buttonLabel }}
17
+ </button>
18
+ </div>
19
+ </transition>
20
+ </template>
21
+
22
+ <script setup>
23
+ import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue';
24
+ import { trackEvent } from '../utils/analytics.js';
25
+ import { resolveCtaCopy } from '../utils/ctaCopy.js';
26
+
27
+ const MOBILE_MAX_WIDTH = 820; // px
28
+ const SCROLL_THRESHOLD = 0.25; // 25%
29
+ const DEFAULT_LABEL = 'Get Started';
30
+
31
+ const pageContent = inject('pageContent', ref({}));
32
+ const siteData = inject('siteData', ref({}));
33
+
34
+ const ctaConfig = computed(() => pageContent.value?.stickyCta || {});
35
+ const targetSelector = computed(() => ctaConfig.value?.target || '');
36
+ const buttonLabel = computed(() => {
37
+ const fallback = ctaConfig.value?.label || DEFAULT_LABEL;
38
+ const ctaType =
39
+ typeof ctaConfig.value?.ctaType === 'string' && ctaConfig.value.ctaType.trim()
40
+ ? ctaConfig.value.ctaType.trim()
41
+ : 'stickyPrimary';
42
+ return resolveCtaCopy(siteData, ctaType, fallback);
43
+ });
44
+
45
+ const isVisible = ref(false);
46
+ let lastKnownScrollRatio = 0;
47
+ let scrollRaf = null;
48
+ const hasTrackedImpression = ref(false);
49
+ const isTargetVisible = ref(false);
50
+ let targetObserver = null;
51
+ let observerRetryId = null;
52
+
53
+ function withinMobileViewport() {
54
+ return window.innerWidth <= MOBILE_MAX_WIDTH;
55
+ }
56
+
57
+ function calculateScrollRatio() {
58
+ const scrollTop = window.scrollY || window.pageYOffset || 0;
59
+ const docHeight = document.documentElement.scrollHeight;
60
+ const windowHeight = window.innerHeight || 1;
61
+ const availableScroll = Math.max(docHeight - windowHeight, 1);
62
+ return scrollTop / availableScroll;
63
+ }
64
+
65
+ function updateVisibility() {
66
+ if (!withinMobileViewport()) {
67
+ isVisible.value = false;
68
+ return;
69
+ }
70
+
71
+ if (isTargetVisible.value) {
72
+ isVisible.value = false;
73
+ return;
74
+ }
75
+
76
+ const ratio = calculateScrollRatio();
77
+ lastKnownScrollRatio = ratio;
78
+ isVisible.value = ratio >= SCROLL_THRESHOLD;
79
+ }
80
+
81
+ function onScroll() {
82
+ if (scrollRaf) return;
83
+ scrollRaf = window.requestAnimationFrame(() => {
84
+ scrollRaf = null;
85
+ updateVisibility();
86
+ });
87
+ }
88
+
89
+ function handleClick() {
90
+ trackEvent('sticky_cta_clicked', {
91
+ target: targetSelector.value,
92
+ });
93
+
94
+ if (typeof window !== 'undefined') {
95
+ const target = resolveTargetElement();
96
+ if (target) {
97
+ scrollTargetWithOffset(target);
98
+ }
99
+ }
100
+ }
101
+
102
+ function disconnectObserver() {
103
+ if (targetObserver) {
104
+ targetObserver.disconnect();
105
+ targetObserver = null;
106
+ }
107
+ if (observerRetryId && typeof window !== 'undefined') {
108
+ window.clearTimeout(observerRetryId);
109
+ observerRetryId = null;
110
+ }
111
+ }
112
+
113
+ function handleIntersection(entries) {
114
+ const entry = entries && entries[0];
115
+ if (!entry) return;
116
+ const intersecting = entry.isIntersecting && entry.intersectionRatio > 0;
117
+ isTargetVisible.value = intersecting;
118
+ if (intersecting) {
119
+ isVisible.value = false;
120
+ } else {
121
+ updateVisibility();
122
+ }
123
+ }
124
+
125
+ function observeTarget() {
126
+ disconnectObserver();
127
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
128
+
129
+ const target = document.querySelector(targetSelector.value);
130
+ if (!target) {
131
+ observerRetryId = window.setTimeout(observeTarget, 400);
132
+ return;
133
+ }
134
+
135
+ targetObserver = new IntersectionObserver(handleIntersection, {
136
+ threshold: [0, 0.1],
137
+ rootMargin: '0px 0px -20% 0px',
138
+ });
139
+ targetObserver.observe(target);
140
+ }
141
+
142
+ onMounted(() => {
143
+ if (typeof window === 'undefined') return;
144
+ observeTarget();
145
+ updateVisibility();
146
+ window.addEventListener('scroll', onScroll, { passive: true });
147
+ window.addEventListener('resize', updateVisibility, { passive: true });
148
+
149
+ watch(
150
+ isVisible,
151
+ (visible) => {
152
+ if (visible && !hasTrackedImpression.value) {
153
+ hasTrackedImpression.value = true;
154
+ trackEvent('sticky_cta_shown', {
155
+ target: targetSelector.value,
156
+ scroll_ratio: Number.isFinite(lastKnownScrollRatio) ? Number(lastKnownScrollRatio.toFixed(3)) : null,
157
+ });
158
+ }
159
+ },
160
+ { immediate: true }
161
+ );
162
+ });
163
+
164
+ watch(
165
+ targetSelector,
166
+ () => {
167
+ if (typeof window === 'undefined') return;
168
+ observeTarget();
169
+ },
170
+ { flush: 'post' }
171
+ );
172
+
173
+ onUnmounted(() => {
174
+ if (typeof window === 'undefined') return;
175
+ window.removeEventListener('scroll', onScroll);
176
+ window.removeEventListener('resize', updateVisibility);
177
+ if (scrollRaf) {
178
+ window.cancelAnimationFrame(scrollRaf);
179
+ scrollRaf = null;
180
+ }
181
+ disconnectObserver();
182
+ });
183
+
184
+ function resolveTargetElement() {
185
+ if (typeof document === 'undefined') return null;
186
+ const selector = (targetSelector.value || '').trim();
187
+ if (!selector) return null;
188
+ return document.querySelector(selector);
189
+ }
190
+
191
+ function scrollTargetWithOffset(target) {
192
+ if (typeof window === 'undefined' || typeof document === 'undefined' || !target) return false;
193
+ const header = document.getElementById('js-header');
194
+ const headerOffset = header ? header.offsetHeight || 0 : 0;
195
+ const prefersReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
196
+ const rect = target.getBoundingClientRect();
197
+ const currentScroll = window.pageYOffset || document.documentElement.scrollTop || 0;
198
+ const offset = Math.max(rect.top + currentScroll - headerOffset - 16, 0);
199
+ window.scrollTo({ top: offset, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
200
+ return true;
201
+ }
202
+ </script>
203
+
204
+ <style scoped>
205
+ .sticky-cta {
206
+ position: fixed;
207
+ left: 0;
208
+ right: 0;
209
+ bottom: env(safe-area-inset-bottom);
210
+ padding: 12px clamp(16px, 5vw, 32px);
211
+ display: flex;
212
+ justify-content: center;
213
+ pointer-events: none;
214
+ z-index: 1050;
215
+ }
216
+
217
+ .sticky-cta__button {
218
+ pointer-events: auto;
219
+ min-height: 48px;
220
+ padding: 14px 24px;
221
+ border: none;
222
+ border-radius: var(--brand-button-radius, 14px);
223
+ background: var(
224
+ --brand-primary-cta-gradient,
225
+ linear-gradient(135deg, #ff2d86 0%, #9a2eff 55%, #27f3ff 100%)
226
+ );
227
+ color: var(--brand-primary-cta-text, #0a0a0d);
228
+ font-size: clamp(0.95rem, 2.5vw, 1.05rem);
229
+ font-family: 'Inter', 'Helvetica Neue', sans-serif;
230
+ font-weight: 700;
231
+ letter-spacing: 0.12em;
232
+ text-transform: uppercase;
233
+ cursor: pointer;
234
+ box-shadow: var(--brand-primary-cta-shadow, 0 18px 40px rgba(255, 60, 125, 0.45));
235
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
236
+ white-space: nowrap;
237
+ }
238
+
239
+ .sticky-cta__button:active {
240
+ transform: scale(0.98);
241
+ }
242
+
243
+ .sticky-cta__button:hover {
244
+ transform: var(--brand-primary-cta-hover-translate, translateY(-2px));
245
+ box-shadow: var(--brand-primary-cta-hover-shadow, 0 20px 44px rgba(255, 60, 125, 0.55));
246
+ }
247
+
248
+ .sticky-cta__button:focus-visible {
249
+ outline: 2px solid var(--brand-accent-electric, #27f3ff);
250
+ outline-offset: 3px;
251
+ }
252
+
253
+ .slide-up-enter-active,
254
+ .slide-up-leave-active {
255
+ transition: transform 0.25s ease, opacity 0.25s ease;
256
+ }
257
+
258
+ .slide-up-enter-from,
259
+ .slide-up-leave-to {
260
+ transform: translateY(30%);
261
+ opacity: 0;
262
+ }
263
+ </style>
@@ -0,0 +1,432 @@
1
+ <template>
2
+ <section
3
+ id="team"
4
+ class="team-section ui-section ui-section--stacked"
5
+ data-analytics-section="team"
6
+ ref="teamSection"
7
+ >
8
+ <div class="container">
9
+ <header v-if="teamTitle || teamDescription" class="section-header text-center">
10
+ <div v-if="teamTitle" class="section-heading">
11
+ <h2 class="display-heading team-heading">{{ formattedTeamTitle }}</h2>
12
+ <span class="section-divider" aria-hidden="true"></span>
13
+ </div>
14
+ <p v-if="teamDescription" class="section-description">{{ teamDescription }}</p>
15
+ </header>
16
+
17
+ <div class="team-grid ui-card-grid ui-card-grid--team">
18
+ <div
19
+ v-for="(member, idx) in members"
20
+ :key="idx"
21
+ class="team-card ui-card-grid__item"
22
+ >
23
+ <figure
24
+ class="team-card__inner text-center ui-card ui-card-surface fade-up-in js-scroll-fade"
25
+ :style="{ '--fade-up-delay': `${Math.min(idx, 4) * 0.06}s` }"
26
+ >
27
+ <div class="profile-frame ui-visual-shell ui-visual-shell--circle">
28
+ <img
29
+ class="profile-photo"
30
+ :src="member.image"
31
+ :alt="member.name"
32
+ loading="lazy"
33
+ decoding="async"
34
+ >
35
+ </div>
36
+ <div class="team-card__meta">
37
+ <h4 class="team-card__name">{{ member.name }}</h4>
38
+ <em class="team-card__role">{{ member.role }}</em>
39
+ </div>
40
+ <p class="team-card__bio">{{ member.bio }}</p>
41
+ <ul v-if="member.socials.length" class="team-card__socials">
42
+ <li
43
+ v-for="(social, socialIdx) in member.socials"
44
+ :key="socialIdx"
45
+ class="team-card__social"
46
+ >
47
+ <a
48
+ :href="social.href"
49
+ class="team-card__social-link"
50
+ target="_blank"
51
+ rel="noopener noreferrer"
52
+ :aria-label="buildSocialAriaLabel(member.name, social)"
53
+ >
54
+ <component
55
+ v-if="social.iconComponent"
56
+ :is="social.iconComponent"
57
+ class="team-card__icon-svg"
58
+ />
59
+ <span v-else-if="social.iconGlyph" class="team-card__icon-glyph" aria-hidden="true">
60
+ {{ social.iconGlyph }}
61
+ </span>
62
+ <img
63
+ v-else-if="social.icon"
64
+ class="team-card__icon-img"
65
+ :src="resolveImage(social.icon)"
66
+ :alt="social.label || social.icon"
67
+ loading="lazy"
68
+ decoding="async"
69
+ >
70
+ <span v-else class="team-card__icon-fallback" aria-hidden="true">↗</span>
71
+ </a>
72
+ </li>
73
+ </ul>
74
+ </figure>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </section>
79
+ </template>
80
+
81
+ <script setup>
82
+ import { computed, inject, markRaw, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
83
+ import IconX from './icons/IconX.vue';
84
+ import IconLinkedIn from './icons/IconLinkedIn.vue';
85
+ import { resolveMedia } from '../utils/assetResolver.js';
86
+ import { registerScrollReveal } from '../utils/useScrollReveal.js';
87
+
88
+ const teamSection = ref(null);
89
+ const teamTitle = ref('');
90
+ const teamDescription = ref('');
91
+ const members = ref([]);
92
+
93
+ const resolveImage = (src) => resolveMedia(src);
94
+
95
+ const SOCIAL_GLYPH_MAP = {
96
+ twitter: '𝕏',
97
+ x: '𝕏',
98
+ linkedin: 'in',
99
+ facebook: 'f',
100
+ instagram: 'IG',
101
+ discord: '☍',
102
+ telegram: '✈',
103
+ github: '{ }',
104
+ medium: 'M',
105
+ };
106
+
107
+ const SOCIAL_ICON_COMPONENTS = {
108
+ twitter: markRaw(IconX),
109
+ x: markRaw(IconX),
110
+ linkedin: markRaw(IconLinkedIn),
111
+ };
112
+
113
+ const shouldTreatAsAsset = (value = '') => {
114
+ if (!value) return false;
115
+ return /[./]/.test(value) || value.startsWith('data:') || value.startsWith('http');
116
+ };
117
+
118
+ const normalizeSocials = (socials) => {
119
+ if (!Array.isArray(socials)) return [];
120
+ return socials
121
+ .filter((item) => item && typeof item.href === 'string' && item.href.trim())
122
+ .map((item) => ({
123
+ icon: shouldTreatAsAsset(item.icon) ? item.icon.trim() : '',
124
+ iconGlyph:
125
+ item.iconGlyph ||
126
+ (!shouldTreatAsAsset(item.icon) && typeof item.icon === 'string'
127
+ ? SOCIAL_GLYPH_MAP[item.icon.trim().toLowerCase()] || item.icon.trim().slice(0, 2).toUpperCase()
128
+ : ''),
129
+ iconComponent:
130
+ typeof item.icon === 'string' ? SOCIAL_ICON_COMPONENTS[item.icon.trim().toLowerCase()] || null : null,
131
+ href: item.href.trim(),
132
+ label: typeof item.label === 'string' ? item.label.trim() : '',
133
+ ariaLabel: typeof item.ariaLabel === 'string' ? item.ariaLabel.trim() : '',
134
+ }));
135
+ };
136
+
137
+ const pageContent = inject('pageContent', ref({}));
138
+
139
+ const formattedTeamTitle = computed(() => teamTitle.value || '');
140
+
141
+ const setupScrollReveal = () => {
142
+ nextTick(() => {
143
+ if (!teamSection.value) return;
144
+ const targets = teamSection.value.querySelectorAll('.js-scroll-fade');
145
+ registerScrollReveal(targets);
146
+ });
147
+ };
148
+
149
+ watch(
150
+ () => pageContent.value,
151
+ (content) => {
152
+ const teamConfig = content?.team || {};
153
+
154
+ teamTitle.value = teamConfig.title || '';
155
+ teamDescription.value = teamConfig.description || '';
156
+
157
+ members.value = Array.isArray(teamConfig.members)
158
+ ? teamConfig.members.map((member) => ({
159
+ name: member?.name || '',
160
+ role: member?.role || '',
161
+ bio: member?.bio || '',
162
+ image: resolveImage(member?.image),
163
+ socials: normalizeSocials(member?.socials),
164
+ }))
165
+ : [];
166
+ setupScrollReveal();
167
+ nextTick(() => {
168
+ registerResizeHandlers();
169
+ scheduleEqualizeTeamCards();
170
+ });
171
+ },
172
+ { immediate: true }
173
+ );
174
+
175
+ let resizeObserver;
176
+ let windowResizeAttached = false;
177
+ let equalizeRaf = 0;
178
+
179
+ function equalizeTeamCards() {
180
+ const section = teamSection.value;
181
+ if (!section) return;
182
+
183
+ section.style.removeProperty('--team-card-equal-height');
184
+ const cards = section.querySelectorAll('.team-card__inner');
185
+ if (!cards.length) return;
186
+
187
+ let tallest = 0;
188
+ cards.forEach((card) => {
189
+ const { height } = card.getBoundingClientRect();
190
+ if (height > tallest) {
191
+ tallest = height;
192
+ }
193
+ });
194
+
195
+ if (tallest > 0) {
196
+ section.style.setProperty('--team-card-equal-height', `${Math.ceil(tallest)}px`);
197
+ }
198
+ }
199
+
200
+ function scheduleEqualizeTeamCards() {
201
+ if (typeof window === 'undefined') return;
202
+ if (equalizeRaf) {
203
+ cancelAnimationFrame(equalizeRaf);
204
+ }
205
+ equalizeRaf = window.requestAnimationFrame(() => {
206
+ equalizeRaf = 0;
207
+ equalizeTeamCards();
208
+ });
209
+ }
210
+
211
+ function registerResizeHandlers() {
212
+ if (typeof window === 'undefined') return;
213
+ if (typeof ResizeObserver !== 'undefined') {
214
+ if (!resizeObserver) {
215
+ resizeObserver = new ResizeObserver(() => {
216
+ scheduleEqualizeTeamCards();
217
+ });
218
+ } else {
219
+ resizeObserver.disconnect();
220
+ }
221
+ if (teamSection.value) {
222
+ resizeObserver.observe(teamSection.value);
223
+ }
224
+ return;
225
+ }
226
+
227
+ if (!windowResizeAttached) {
228
+ window.addEventListener('resize', scheduleEqualizeTeamCards);
229
+ windowResizeAttached = true;
230
+ }
231
+ }
232
+
233
+ function cleanupResizeHandlers() {
234
+ if (resizeObserver) {
235
+ resizeObserver.disconnect();
236
+ resizeObserver = null;
237
+ return;
238
+ }
239
+ if (windowResizeAttached) {
240
+ window.removeEventListener('resize', scheduleEqualizeTeamCards);
241
+ windowResizeAttached = false;
242
+ }
243
+ }
244
+
245
+ onMounted(() => {
246
+ setupScrollReveal();
247
+ nextTick(() => {
248
+ registerResizeHandlers();
249
+ scheduleEqualizeTeamCards();
250
+ });
251
+ });
252
+
253
+ onBeforeUnmount(() => {
254
+ cleanupResizeHandlers();
255
+ if (equalizeRaf) {
256
+ cancelAnimationFrame(equalizeRaf);
257
+ equalizeRaf = 0;
258
+ }
259
+ });
260
+
261
+ const buildSocialAriaLabel = (memberName, social) => {
262
+ const safeName = typeof memberName === 'string' && memberName.trim() ? memberName.trim() : 'Team member';
263
+ if (social.ariaLabel) {
264
+ return social.ariaLabel;
265
+ }
266
+
267
+ const platform = social.label || (social.icon ? social.icon.toString().replace(/[^a-z0-9]+/gi, ' ').trim() : 'social profile');
268
+ const base = `${safeName} on ${platform}`.trim();
269
+ return `${base} (opens in a new tab)`;
270
+ };
271
+ </script>
272
+
273
+ <style scoped>
274
+ .team-section {
275
+ background: var(
276
+ --team-section-bg,
277
+ var(--ui-section-bg, var(--theme-body-background, var(--brand-bg-900, #f5f7ff)))
278
+ );
279
+ --section-divider-color: var(--brand-border-highlight, rgba(79, 108, 240, 0.28));
280
+ --section-description-color: var(--ui-text-muted, rgba(31, 42, 68, 0.72));
281
+ --team-card-padding-x: clamp(16px, 3vw, 26px);
282
+ --team-card-padding-y: clamp(12px, 2.5vw, 22px);
283
+ --team-card-gap: var(--ui-card-gap, 24px);
284
+ --team-social-gap: clamp(18px, 3vw, 28px);
285
+ --team-card-max-width: clamp(300px, 28vw, 360px);
286
+ --team-card-stack-gap: clamp(4px, 1vw, 12px);
287
+ }
288
+
289
+ .team-card {
290
+ height: 100%;
291
+ display: flex;
292
+ }
293
+
294
+ .team-card__inner {
295
+ flex: 1;
296
+ height: 100%;
297
+ width: min(100%, var(--team-card-max-width, clamp(300px, 28vw, 360px)));
298
+ margin: 0 auto;
299
+ min-height: var(--team-card-equal-height, auto);
300
+ padding:
301
+ var(--team-card-padding-y, clamp(12px, 2.5vw, 22px))
302
+ var(--team-card-padding-x, clamp(16px, 3vw, 26px));
303
+ display: flex;
304
+ flex-direction: column;
305
+ align-items: center;
306
+ text-align: center;
307
+ gap: var(--team-card-stack-gap, clamp(4px, 1vw, 12px));
308
+ }
309
+
310
+ .profile-frame {
311
+ width: min(220px, 72%);
312
+ aspect-ratio: 1 / 1;
313
+ margin-bottom: 0;
314
+ }
315
+
316
+ .profile-photo {
317
+ width: 100%;
318
+ height: 100%;
319
+ object-fit: cover;
320
+ object-position: center;
321
+ display: block;
322
+ box-shadow: var(--brand-card-shadow, 0 12px 24px rgba(15, 23, 42, 0.18));
323
+ }
324
+
325
+ .team-card__meta {
326
+ margin-bottom: 0;
327
+ }
328
+
329
+ .team-card__name {
330
+ margin: 0 0 2px;
331
+ font-size: 1.18rem;
332
+ color: var(--ui-text-primary, var(--brand-fg-100));
333
+ }
334
+
335
+ .team-card__role {
336
+ font-style: normal;
337
+ font-size: 0.72rem;
338
+ letter-spacing: 0.18em;
339
+ text-transform: uppercase;
340
+ color: var(--ui-text-muted, var(--brand-border-highlight, rgba(79, 108, 240, 0.8)));
341
+ }
342
+
343
+ .team-card__bio {
344
+ color: var(--ui-text-muted, rgba(31, 42, 68, 0.72));
345
+ font-size: 0.92rem;
346
+ margin-bottom: 4px;
347
+ }
348
+
349
+ .team-card__socials {
350
+ display: flex;
351
+ justify-content: center;
352
+ flex-wrap: wrap;
353
+ gap: var(--team-social-gap, clamp(18px, 3vw, 28px));
354
+ list-style: none;
355
+ margin: 0;
356
+ padding: 0;
357
+ margin-top: auto;
358
+ padding-bottom: 4px;
359
+ }
360
+
361
+ .team-card__social {
362
+ margin: 0;
363
+ }
364
+
365
+ .team-card__social-link {
366
+ min-width: 44px;
367
+ height: 44px;
368
+ padding: 0;
369
+ border-radius: 999px;
370
+ display: inline-flex;
371
+ align-items: center;
372
+ justify-content: center;
373
+ font-size: 1.1rem;
374
+ color: var(
375
+ --team-social-icon-color,
376
+ color-mix(in srgb, var(--helper-strip-color, rgba(190, 197, 212, 0.95)) 92%, white 8%)
377
+ );
378
+ background: var(
379
+ --team-social-icon-bg,
380
+ color-mix(in srgb, var(--helper-strip-bg, rgba(196, 200, 214, 0.24)) 65%, white 35%)
381
+ );
382
+ border: 1px solid
383
+ var(
384
+ --team-social-icon-border,
385
+ color-mix(in srgb, var(--helper-strip-border, rgba(200, 204, 216, 0.32)) 80%, white 20%)
386
+ );
387
+ box-shadow: var(--team-social-icon-shadow, 0 8px 20px rgba(10, 8, 20, 0.18));
388
+ transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
389
+ }
390
+
391
+ .team-card__social-link:hover,
392
+ .team-card__social-link:focus-visible {
393
+ color: var(
394
+ --helper-strip-hover-color,
395
+ var(--helper-strip-color, var(--brand-icon-badge-color, rgba(210, 215, 226, 1)))
396
+ );
397
+ background: var(
398
+ --helper-strip-hover-bg,
399
+ var(--helper-strip-bg, rgba(180, 186, 198, 0.25))
400
+ );
401
+ border-color: var(
402
+ --helper-strip-hover-border,
403
+ var(--helper-strip-border, var(--brand-border-glow, rgba(210, 215, 226, 0.45)))
404
+ );
405
+ transform: translateY(-1px);
406
+ outline: 2px solid
407
+ var(
408
+ --helper-strip-hover-color,
409
+ var(--helper-strip-color, var(--brand-border-glow, rgba(210, 215, 226, 0.45)))
410
+ );
411
+ outline-offset: 2px;
412
+ }
413
+
414
+ .team-card__icon-img {
415
+ width: 20px;
416
+ height: 20px;
417
+ object-fit: contain;
418
+ }
419
+
420
+ .team-card__icon-svg :deep(svg) {
421
+ width: 20px;
422
+ height: 20px;
423
+ color: currentColor;
424
+ }
425
+
426
+ .team-card__icon-glyph,
427
+ .team-card__icon-fallback {
428
+ font-size: 1rem;
429
+ line-height: 1;
430
+ color: currentColor;
431
+ }
432
+ </style>
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <svg
3
+ class="icon-linkedin"
4
+ width="20"
5
+ height="20"
6
+ viewBox="0 0 24 24"
7
+ fill="none"
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ aria-hidden="true"
10
+ >
11
+ <path
12
+ d="M5.2 9h3.1v10.9H5.2V9Zm1.6-5.8a2 2 0 1 1 0 4.1 2 2 0 0 1 0-4.1ZM10.5 9h3v1.6h.1c.4-.8 1.6-1.7 3.3-1.7 3.5 0 4.2 2.1 4.2 4.9v6.1h-3.2v-5.4c0-1.3 0-3-1.8-3-1.8 0-2.1 1.4-2.1 2.9v5.5h-3.2V9Z"
13
+ fill="currentColor"
14
+ />
15
+ </svg>
16
+ </template>
17
+
18
+ <style scoped>
19
+ .icon-linkedin {
20
+ display: block;
21
+ }
22
+ </style>