@synthex-engineers/refined-tech 1.0.0 → 1.0.3

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.
@@ -0,0 +1,302 @@
1
+ ---
2
+ // FooterBounceCurve.astro - Fondo oficial unificado en SVG con curva elástica deformable y textura de grano 35mm
3
+ interface Props {
4
+ /**
5
+ * Altura que ocupará el SVG. Permite cualquier valor CSS válido (ej: '100%', '100vh', '850px').
6
+ * Por defecto '100%' para acoplarse con exactitud al alto del contenedor padre (el footer).
7
+ */
8
+ height?: string | number;
9
+ /**
10
+ * Clases CSS opcionales para el contenedor del SVG.
11
+ */
12
+ class?: string;
13
+ /**
14
+ * Selector o ID del elemento trigger para disparar el rebote con ScrollTrigger.
15
+ * Por defecto: 'contacto' (el ID oficial del footer).
16
+ */
17
+ triggerId?: string;
18
+ /**
19
+ * Amplitud vertical máxima de la deformación elástica en unidades de viewBox (por defecto: 70).
20
+ */
21
+ bounceAmplitude?: number;
22
+ }
23
+
24
+ const {
25
+ height = '100%',
26
+ class: className = '',
27
+ triggerId = 'contacto',
28
+ bounceAmplitude = 70,
29
+ } = Astro.props;
30
+
31
+ const containerHeight = typeof height === 'number' ? `${height}px` : height;
32
+ ---
33
+
34
+ <div
35
+ id="footer-bounce-wrap"
36
+ class:list={[
37
+ "absolute inset-x-0 top-0 w-full min-w-full pointer-events-none select-none overflow-visible z-10",
38
+ className,
39
+ ]}
40
+ style={{ height: containerHeight }}
41
+ aria-hidden="true"
42
+ data-trigger-id={triggerId}
43
+ data-amplitude={bounceAmplitude}
44
+ >
45
+ <svg
46
+ id="footer-bounce-svg"
47
+ class="w-full h-full block overflow-visible"
48
+ xmlns="http://www.w3.org/2000/svg"
49
+ viewBox="0 0 1440 1000"
50
+ preserveAspectRatio="none"
51
+ >
52
+ <defs>
53
+ <!-- Gradiente oficial Synthex: Pistacho (#E0F9B5) a Blanco Nieve (#F4FDE4) -->
54
+ <linearGradient id="footer-bounce-gradient" x1="0" y1="0" x2="0" y2="1">
55
+ <stop offset="0%" stop-color="#E0F9B5" />
56
+ <stop offset="100%" stop-color="#F4FDE4" />
57
+ </linearGradient>
58
+
59
+ <!-- Filtro de Grano de Película 35mm integrado y recortado exactamente a la silueta del trazado -->
60
+ <filter
61
+ id="footer-bounce-grain"
62
+ x="0%"
63
+ y="-20%"
64
+ width="100%"
65
+ height="140%"
66
+ color-interpolation-filters="sRGB"
67
+ >
68
+ <feTurbulence
69
+ type="fractalNoise"
70
+ baseFrequency="0.75"
71
+ numOctaves="4"
72
+ stitchTiles="stitch"
73
+ result="noise"
74
+ />
75
+ <feColorMatrix
76
+ type="saturate"
77
+ values="0"
78
+ in="noise"
79
+ result="grayNoise"
80
+ />
81
+ <feComponentTransfer in="grayNoise" result="subtleNoise">
82
+ <feFuncR type="linear" slope="0.5" intercept="0.5" />
83
+ <feFuncG type="linear" slope="0.5" intercept="0.5" />
84
+ <feFuncB type="linear" slope="0.5" intercept="0.5" />
85
+ </feComponentTransfer>
86
+ <feBlend in="SourceGraphic" in2="subtleNoise" mode="multiply" result="blended" />
87
+ <feComposite in="blended" in2="SourceAlpha" operator="in" />
88
+ </filter>
89
+ </defs>
90
+
91
+ <!-- Trazado ÚNICO: Fondo Pistacho Deformable con Granulado 35mm Palpable -->
92
+ <path
93
+ id="bouncy-path"
94
+ class="footer-bounce-path"
95
+ fill="url(#footer-bounce-gradient)"
96
+ filter="url(#footer-bounce-grain)"
97
+ d="M 0,0 Q 720,0 1440,0 L 1440,1000 L 0,1000 Z"
98
+ />
99
+ </svg>
100
+ </div>
101
+
102
+ <script>
103
+ import gsap from "gsap";
104
+ import { ScrollTrigger } from "gsap/ScrollTrigger";
105
+
106
+ gsap.registerPlugin(ScrollTrigger);
107
+
108
+ let activeScrollTrigger: ScrollTrigger | null = null;
109
+ let activeCleanup: (() => void) | null = null;
110
+ let refreshDebounceTimer: any = null;
111
+
112
+ function scheduleRefresh() {
113
+ clearTimeout(refreshDebounceTimer);
114
+ refreshDebounceTimer = setTimeout(() => {
115
+ ScrollTrigger.refresh();
116
+ const lenis = (window as any).__lenis;
117
+ if (lenis && typeof lenis.resize === "function") {
118
+ lenis.resize();
119
+ }
120
+ }, 60);
121
+ }
122
+
123
+ function initFooterBounce() {
124
+ const wrap = document.getElementById("footer-bounce-wrap");
125
+ const bouncyPath = document.getElementById("bouncy-path");
126
+ if (!wrap || !bouncyPath) return;
127
+
128
+ // Destruir instancias previas y listeners para evitar fugas de memoria
129
+ if (activeCleanup) {
130
+ activeCleanup();
131
+ activeCleanup = null;
132
+ }
133
+ if (activeScrollTrigger) {
134
+ activeScrollTrigger.kill();
135
+ activeScrollTrigger = null;
136
+ }
137
+
138
+ const rest = "M 0,0 Q 720,0 1440,0 L 1440,1000 L 0,1000 Z";
139
+ const isMobile = window.matchMedia("(max-width: 768px)").matches;
140
+ const baseStretch = parseInt(wrap.getAttribute("data-amplitude") || "70", 10) || 70;
141
+ const STRETCH_PIXELS = isMobile ? Math.min(baseStretch, 45) : baseStretch;
142
+
143
+ const prefersReducedMotion = window.matchMedia(
144
+ "(prefers-reduced-motion: reduce)",
145
+ ).matches;
146
+
147
+ if (!prefersReducedMotion) {
148
+ let hasSnapped = false;
149
+ let activeTween: gsap.core.Tween | null = null;
150
+
151
+ const initialRect = wrap.getBoundingClientRect();
152
+ if (initialRect.top < window.innerHeight - STRETCH_PIXELS) {
153
+ hasSnapped = true;
154
+ bouncyPath.setAttribute("d", rest);
155
+ }
156
+
157
+ const updateMembrane = () => {
158
+ const rect = wrap.getBoundingClientRect();
159
+ const S = window.innerHeight - rect.top;
160
+
161
+ if (S <= 0) {
162
+ if (hasSnapped || activeTween) {
163
+ hasSnapped = false;
164
+ if (activeTween) {
165
+ activeTween.kill();
166
+ activeTween = null;
167
+ }
168
+ bouncyPath.setAttribute("d", rest);
169
+ }
170
+ return;
171
+ }
172
+
173
+ if (hasSnapped) {
174
+ return;
175
+ }
176
+
177
+ if (S >= STRETCH_PIXELS) {
178
+ hasSnapped = true;
179
+ if (activeTween) {
180
+ activeTween.kill();
181
+ activeTween = null;
182
+ }
183
+
184
+ const H = rect.height || 1000;
185
+ const snapC = (2 * STRETCH_PIXELS * 1000) / H;
186
+ const snapAnim = { c: snapC };
187
+
188
+ activeTween = gsap.to(snapAnim, {
189
+ c: 0,
190
+ duration: 1.8,
191
+ ease: "elastic.out(1.2, 0.35)",
192
+ overwrite: "auto",
193
+ onUpdate: () => {
194
+ bouncyPath.setAttribute(
195
+ "d",
196
+ `M 0,0 Q 720,${snapAnim.c} 1440,0 L 1440,1000 L 0,1000 Z`,
197
+ );
198
+ },
199
+ onComplete: () => {
200
+ bouncyPath.setAttribute("d", rest);
201
+ activeTween = null;
202
+ },
203
+ });
204
+ return;
205
+ }
206
+
207
+ const H = rect.height || 1000;
208
+ const currentC = (2 * S * 1000) / H;
209
+ bouncyPath.setAttribute(
210
+ "d",
211
+ `M 0,0 Q 720,${currentC} 1440,0 L 1440,1000 L 0,1000 Z`,
212
+ );
213
+ };
214
+
215
+ activeScrollTrigger = ScrollTrigger.create({
216
+ trigger: wrap,
217
+ start: "top bottom",
218
+ end: "bottom top",
219
+ onUpdate: updateMembrane,
220
+ onLeaveBack: () => {
221
+ hasSnapped = false;
222
+ if (activeTween) {
223
+ activeTween.kill();
224
+ activeTween = null;
225
+ }
226
+ bouncyPath.setAttribute("d", rest);
227
+ },
228
+ });
229
+
230
+ window.addEventListener("scroll", updateMembrane, { passive: true });
231
+ const lenis = (window as any).__lenis;
232
+ if (lenis && typeof lenis.on === "function") {
233
+ lenis.on("scroll", updateMembrane);
234
+ }
235
+
236
+ activeCleanup = () => {
237
+ window.removeEventListener("scroll", updateMembrane);
238
+ const l = (window as any).__lenis;
239
+ if (l && typeof l.off === "function") {
240
+ l.off("scroll", updateMembrane);
241
+ }
242
+ if (activeTween) {
243
+ activeTween.kill();
244
+ activeTween = null;
245
+ }
246
+ };
247
+ } else {
248
+ bouncyPath.setAttribute("d", rest);
249
+ }
250
+
251
+ scheduleRefresh();
252
+ }
253
+
254
+ function startFooterBounce() {
255
+ initFooterBounce();
256
+ }
257
+
258
+ if (typeof window !== "undefined" && "ResizeObserver" in window) {
259
+ const bodyResizeObserver = new ResizeObserver(() => {
260
+ scheduleRefresh();
261
+ });
262
+ if (document.body) {
263
+ bodyResizeObserver.observe(document.body);
264
+ } else {
265
+ document.addEventListener("DOMContentLoaded", () => {
266
+ bodyResizeObserver.observe(document.body);
267
+ });
268
+ }
269
+ }
270
+
271
+ window.addEventListener("synthex:lang-changed", () => {
272
+ scheduleRefresh();
273
+ setTimeout(scheduleRefresh, 120);
274
+ });
275
+
276
+ window.addEventListener("resize", scheduleRefresh);
277
+
278
+ document.addEventListener(
279
+ "toggle",
280
+ (e) => {
281
+ if ((e.target as HTMLElement)?.tagName === "DETAILS") {
282
+ scheduleRefresh();
283
+ }
284
+ },
285
+ true,
286
+ );
287
+
288
+ if (
289
+ (window as any).__synthexPageReady ||
290
+ sessionStorage.getItem("synthex_preloaded_session")
291
+ ) {
292
+ startFooterBounce();
293
+ } else {
294
+ window.addEventListener("synthex:page-ready", startFooterBounce, {
295
+ once: true,
296
+ });
297
+ }
298
+
299
+ document.addEventListener("astro:page-load", () => {
300
+ startFooterBounce();
301
+ });
302
+ </script>
@@ -1,119 +1,239 @@
1
- ---
2
- interface Props {
3
- title: string;
4
- description: string;
5
- titleKey?: string;
6
- descKey?: string;
7
- align?: 'left' | 'center' | 'right';
8
- className?: string;
9
- }
10
-
11
- const {
12
- title,
13
- description,
14
- titleKey,
15
- descKey,
16
- align = 'center',
17
- className = '',
18
- } = Astro.props;
19
-
20
- const alignClasses = {
21
- left: 'left-0 translate-x-0',
22
- center: 'left-1/2 -translate-x-1/2',
23
- right: 'right-0 translate-x-0',
24
- }[align];
25
-
26
- const arrowAlignClasses = {
27
- left: 'left-3',
28
- center: 'left-1/2 -translate-x-1/2',
29
- right: 'right-3',
30
- }[align];
31
- ---
32
-
33
- <span class={`relative inline-flex items-center group/bubble ml-1.5 align-middle group-hover/bubble:z-50 group-focus-within/bubble:z-50 ${className}`}>
34
- <button
35
- type="button"
36
- class="info-bubble-trigger w-4 h-4 rounded-full bg-white/10 hover:bg-brand-pistachio/20 text-white/70 hover:text-brand-pistachio border border-white/20 hover:border-brand-pistachio/40 text-xs font-mono font-bold inline-flex items-center justify-center cursor-help transition-all duration-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand-pistachio"
37
- aria-label={`${title}: ${description}`}
38
- >
39
- ?
40
- </button>
41
-
42
- <!-- Popover Flotante Accesible (Hover & Focus) con fondo sólido opaco y contención -->
43
- <span
44
- role="tooltip"
45
- class={`info-bubble-tooltip absolute bottom-full ${alignClasses} mb-2.5 w-64 sm:w-72 p-3.5 rounded-xl bg-charcoal border border-white/20 text-left shadow-2xl z-[100] pointer-events-none opacity-0 translate-y-1 group-hover/bubble:opacity-100 group-hover/bubble:pointer-events-auto group-hover/bubble:translate-y-0 group-focus-within/bubble:opacity-100 group-focus-within/bubble:pointer-events-auto group-focus-within/bubble:translate-y-0 transition-all duration-200 backdrop-blur-xl`}
46
- >
47
- <span class="block text-xs font-display font-bold text-brand-pistachio mb-1" data-i18n={titleKey}>
48
- {title}
49
- </span>
50
- <span class="block text-xs font-body text-white/90 leading-relaxed" data-i18n={descKey}>
51
- {description}
52
- </span>
53
-
54
- <!-- Flecha indicadora inferior -->
55
- <span
56
- class={`info-bubble-arrow absolute top-full ${arrowAlignClasses} -mt-[1px] border-4 border-transparent border-t-charcoal`}
57
- ></span>
58
- </span>
59
- </span>
60
-
61
- <script>
62
- function initHelpTooltips() {
63
- const triggers = document.querySelectorAll<HTMLElement>('.info-bubble-trigger');
64
-
65
- triggers.forEach((trigger) => {
66
- if ((trigger as any).__infoBubbleBound) return;
67
- (trigger as any).__infoBubbleBound = true;
68
-
69
- const tooltip = trigger.parentElement?.querySelector<HTMLElement>('.info-bubble-tooltip');
70
- if (!tooltip) return;
71
-
72
- function repositionTooltip() {
73
- if (!tooltip) return;
74
- tooltip.style.removeProperty('left');
75
- tooltip.style.removeProperty('right');
76
- tooltip.style.removeProperty('transform');
77
-
78
- requestAnimationFrame(() => {
79
- const rect = tooltip.getBoundingClientRect();
80
- const viewportWidth = window.innerWidth;
81
- const margin = 16;
82
-
83
- // Corrección si se sale por la derecha
84
- if (rect.right > viewportWidth - margin) {
85
- const overflow = rect.right - viewportWidth + margin;
86
- tooltip.style.transform = `translateX(-${overflow}px)`;
87
- } else if (rect.left < margin) {
88
- const overflow = margin - rect.left;
89
- tooltip.style.transform = `translateX(${overflow}px)`;
90
- }
91
-
92
- // Inversión vertical si no cabe por arriba
93
- const arrow = tooltip.querySelector<HTMLElement>('.info-bubble-arrow');
94
- if (rect.top < 60) {
95
- tooltip.classList.remove('bottom-full', 'mb-2.5');
96
- tooltip.classList.add('top-full', 'mt-2.5');
97
- if (arrow) {
98
- arrow.classList.remove('top-full', '-mt-[1px]', 'border-t-charcoal');
99
- arrow.classList.add('bottom-full', '-mb-[1px]', 'border-b-charcoal');
100
- }
101
- } else {
102
- tooltip.classList.remove('top-full', 'mt-2.5');
103
- tooltip.classList.add('bottom-full', 'mb-2.5');
104
- if (arrow) {
105
- arrow.classList.remove('bottom-full', '-mb-[1px]', 'border-b-charcoal');
106
- arrow.classList.add('top-full', '-mt-[1px]', 'border-t-charcoal');
107
- }
108
- }
109
- });
110
- }
111
-
112
- trigger.addEventListener('mouseenter', repositionTooltip);
113
- trigger.addEventListener('focus', repositionTooltip);
114
- });
115
- }
116
-
117
- initHelpTooltips();
118
- document.addEventListener('astro:page-load', initHelpTooltips);
119
- </script>
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ description: string;
5
+ titleKey?: string;
6
+ descKey?: string;
7
+ align?: 'left' | 'center' | 'right';
8
+ className?: string;
9
+ }
10
+
11
+ const {
12
+ title,
13
+ description,
14
+ titleKey,
15
+ descKey,
16
+ align = 'center',
17
+ className = '',
18
+ } = Astro.props;
19
+ ---
20
+
21
+ <span
22
+ class={`relative inline-flex items-center group/bubble ml-1.5 align-middle ${className}`}
23
+ data-align={align}
24
+ >
25
+ <button
26
+ type="button"
27
+ class="info-bubble-trigger w-4 h-4 rounded-full bg-white/10 hover:bg-pistachio/20 text-white/70 hover:text-pistachio border border-white/20 hover:border-pistachio/40 text-xs font-mono font-bold inline-flex items-center justify-center cursor-help transition-all duration-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-pistachio shrink-0 select-none"
28
+ aria-label={`${title}: ${description}`}
29
+ aria-expanded="false"
30
+ >
31
+ ?
32
+ </button>
33
+
34
+ <!-- Popover Flotante Accesible (Hover, Focus & Click/Tap): sale justo arriba a 8px sin tapar el botón ? -->
35
+ <span
36
+ role="tooltip"
37
+ class="info-bubble-tooltip absolute bottom-[calc(100%+8px)] left-1/2 -translate-x-1/2 w-64 sm:w-72 max-w-[calc(100vw-32px)] p-3.5 rounded-xl border border-white/20 text-left shadow-2xl z-[100] pointer-events-none opacity-0 invisible transition-opacity duration-200"
38
+ >
39
+ <span class="block text-xs font-display font-bold text-pistachio mb-1" data-i18n={titleKey}>
40
+ {title}
41
+ </span>
42
+ <span class="block text-xs font-body text-white/90 leading-relaxed" data-i18n={descKey}>
43
+ {description}
44
+ </span>
45
+
46
+ <!-- Flecha indicadora apuntando al botón ? -->
47
+ <span
48
+ class="info-bubble-arrow absolute top-full left-1/2 -translate-x-1/2 -mt-[1px] border-[5px] border-transparent border-t-[#141414] pointer-events-none"
49
+ ></span>
50
+ </span>
51
+ </span>
52
+
53
+ <style is:global>
54
+ /* Visibilidad y z-index garantizados independientemente de Tailwind */
55
+ .group\/bubble:hover .info-bubble-tooltip,
56
+ .group\/bubble:focus-within .info-bubble-tooltip,
57
+ .group\/bubble[data-state="open"] .info-bubble-tooltip,
58
+ .group\/bubble.is-open .info-bubble-tooltip {
59
+ opacity: 1 !important;
60
+ pointer-events: auto !important;
61
+ visibility: visible !important;
62
+ }
63
+
64
+ .group\/bubble:hover,
65
+ .group\/bubble:focus-within,
66
+ .group\/bubble[data-state="open"],
67
+ .group\/bubble.is-open {
68
+ z-index: 60 !important;
69
+ }
70
+
71
+ .info-bubble-tooltip {
72
+ background-color: #141414 !important;
73
+ }
74
+
75
+ /* Inversión vertical automática si no cabe por arriba */
76
+ .info-bubble-tooltip.is-inverted {
77
+ bottom: auto !important;
78
+ top: calc(100% + 8px) !important;
79
+ }
80
+
81
+ .info-bubble-arrow {
82
+ border-top-color: #141414 !important;
83
+ }
84
+
85
+ .info-bubble-tooltip.is-inverted .info-bubble-arrow {
86
+ top: auto !important;
87
+ bottom: 100% !important;
88
+ margin-top: 0 !important;
89
+ margin-bottom: -1px !important;
90
+ border-top-color: transparent !important;
91
+ border-bottom-color: #141414 !important;
92
+ }
93
+ </style>
94
+
95
+ <script>
96
+ function setupHelpTooltipSystem() {
97
+ if ((window as any).__synthexHelpTooltipInitialized) return;
98
+ (window as any).__synthexHelpTooltipInitialized = true;
99
+
100
+ let activeBubble: HTMLElement | null = null;
101
+
102
+ function closeActiveBubble() {
103
+ if (activeBubble) {
104
+ activeBubble.removeAttribute('data-state');
105
+ activeBubble.classList.remove('is-open');
106
+ const trigger = activeBubble.querySelector<HTMLButtonElement>('.info-bubble-trigger');
107
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
108
+ activeBubble = null;
109
+ }
110
+ }
111
+
112
+ function reposition(bubble: HTMLElement) {
113
+ const trigger = bubble.querySelector<HTMLElement>('.info-bubble-trigger');
114
+ const tooltip = bubble.querySelector<HTMLElement>('.info-bubble-tooltip');
115
+ const arrow = bubble.querySelector<HTMLElement>('.info-bubble-arrow');
116
+ if (!trigger || !tooltip) return;
117
+
118
+ // 1. Reset base
119
+ tooltip.classList.remove('is-inverted');
120
+ tooltip.style.left = '50%';
121
+ tooltip.style.transform = 'translateX(-50%)';
122
+ if (arrow) {
123
+ arrow.style.left = '50%';
124
+ arrow.style.transform = 'translateX(-50%)';
125
+ }
126
+
127
+ // 2. Medir cotas en el viewport
128
+ const triggerRect = trigger.getBoundingClientRect();
129
+ const tooltipRect = tooltip.getBoundingClientRect();
130
+ const viewportWidth = window.innerWidth;
131
+ const margin = 16;
132
+
133
+ // 3. Comprobar si cabe verticalmente arriba (mínimo 60px libres desde el top)
134
+ if (triggerRect.top - tooltipRect.height - 12 < 60) {
135
+ tooltip.classList.add('is-inverted');
136
+ }
137
+
138
+ // 4. Cálculo horizontal para no salirse de los límites de la pantalla
139
+ const triggerCenterX = triggerRect.left + (triggerRect.width / 2);
140
+ const idealLeft = triggerCenterX - (tooltipRect.width / 2);
141
+ const idealRight = idealLeft + tooltipRect.width;
142
+
143
+ let shiftX = 0;
144
+ if (idealRight > viewportWidth - margin) {
145
+ shiftX = (viewportWidth - margin) - idealRight;
146
+ } else if (idealLeft < margin) {
147
+ shiftX = margin - idealLeft;
148
+ }
149
+
150
+ if (shiftX !== 0) {
151
+ tooltip.style.transform = `translateX(calc(-50% + ${shiftX}px))`;
152
+
153
+ // Mantener la flecha apuntando exactamente al centro del botón ?
154
+ if (arrow) {
155
+ const arrowLeft = (tooltipRect.width / 2) - shiftX;
156
+ const clampedArrowLeft = Math.max(14, Math.min(tooltipRect.width - 14, arrowLeft));
157
+ arrow.style.left = `${clampedArrowLeft}px`;
158
+ arrow.style.transform = 'translateX(-50%)';
159
+ }
160
+ }
161
+ }
162
+
163
+ // Delegación universal de Clic / Tap
164
+ document.addEventListener('click', (e) => {
165
+ const target = e.target as HTMLElement | null;
166
+ const trigger = target?.closest<HTMLButtonElement>('.info-bubble-trigger');
167
+
168
+ if (trigger) {
169
+ e.preventDefault();
170
+ e.stopPropagation();
171
+ const bubble = trigger.closest<HTMLElement>('.group\\/bubble');
172
+ if (!bubble) return;
173
+
174
+ const isOpen = bubble.getAttribute('data-state') === 'open' || bubble.classList.contains('is-open');
175
+ closeActiveBubble();
176
+
177
+ if (!isOpen) {
178
+ bubble.setAttribute('data-state', 'open');
179
+ bubble.classList.add('is-open');
180
+ trigger.setAttribute('aria-expanded', 'true');
181
+ activeBubble = bubble;
182
+ reposition(bubble);
183
+ }
184
+ return;
185
+ }
186
+
187
+ // Si se hace clic dentro del tooltip, mantenerlo abierto
188
+ if (target?.closest('.info-bubble-tooltip')) {
189
+ return;
190
+ }
191
+
192
+ // Clic fuera -> cerrar
193
+ closeActiveBubble();
194
+ });
195
+
196
+ // Delegación universal de Hover (MouseEnter & MouseOver en fase de captura)
197
+ document.addEventListener('mouseover', (e) => {
198
+ const target = e.target as HTMLElement | null;
199
+ const trigger = target?.closest<HTMLButtonElement>('.info-bubble-trigger');
200
+ if (trigger) {
201
+ const bubble = trigger.closest<HTMLElement>('.group\\/bubble');
202
+ if (bubble) reposition(bubble);
203
+ }
204
+ }, true);
205
+
206
+ // Delegación universal de Foco Accesible
207
+ document.addEventListener('focusin', (e) => {
208
+ const target = e.target as HTMLElement | null;
209
+ const trigger = target?.closest<HTMLButtonElement>('.info-bubble-trigger');
210
+ if (trigger) {
211
+ const bubble = trigger.closest<HTMLElement>('.group\\/bubble');
212
+ if (bubble) reposition(bubble);
213
+ }
214
+ });
215
+
216
+ // Cierre accesible con tecla Escape
217
+ document.addEventListener('keydown', (e) => {
218
+ if (e.key === 'Escape') {
219
+ closeActiveBubble();
220
+ }
221
+ });
222
+
223
+ // Recalcular o cerrar en scroll/resize
224
+ window.addEventListener('resize', () => {
225
+ if (activeBubble) reposition(activeBubble);
226
+ }, { passive: true });
227
+
228
+ window.addEventListener('scroll', () => {
229
+ if (activeBubble) closeActiveBubble();
230
+ }, { passive: true });
231
+ }
232
+
233
+ setupHelpTooltipSystem();
234
+ if (document.readyState === 'loading') {
235
+ document.addEventListener('DOMContentLoaded', setupHelpTooltipSystem);
236
+ }
237
+ document.addEventListener('astro:page-load', setupHelpTooltipSystem);
238
+ window.addEventListener('synthex:page-ready', setupHelpTooltipSystem);
239
+ </script>