@xenterprises/nuxt-x-marketing 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -30,6 +30,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
30
30
|
section and its sample product data were dropped with them. Every other section of the
|
|
31
31
|
demo page is unchanged.
|
|
32
32
|
|
|
33
|
+
## [1.6.0] - 2026-10-12
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`XMarkHero` `scrimWidth` prop** (opt-in, `overlay="scrim-left"` only). CSS length such as
|
|
38
|
+
`"40%"` or `"36rem"`. On md+ the scrim is 90% black up to `scrimWidth`, then fades out over a
|
|
39
|
+
further ~34% of the width so the photo on the right stays vivid. Below md it darkens a 90% band
|
|
40
|
+
from 18% to 82% of the hero height (35% at the top and bottom edges) instead of a flat 90%.
|
|
41
|
+
Without `scrimWidth`, `scrim-left` and every other default render byte-identical to 1.5.2.
|
|
42
|
+
Keep the whole text column inside the 90% zone: white title over 90% black on a pure-white
|
|
43
|
+
pixel is 17.8:1, white/80 subtitle 11.6:1; outside the zone contrast drops fast.
|
|
44
|
+
|
|
45
|
+
## [1.5.2] - 2026-10-12
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **Scroll reveal uses one long-lived shared IntersectionObserver.** It is created once
|
|
50
|
+
and never disconnected or rebuilt; plugin hooks (`app:mounted`, `page:finish`) and
|
|
51
|
+
components only scan for new reveal elements and observe them. Removes the observer
|
|
52
|
+
churn that left elements unrevealed when IO callbacks did not fire in time. In-viewport
|
|
53
|
+
elements are still marked visible synchronously, and the fallback timer remains.
|
|
54
|
+
XMarkHero default look is unchanged from 1.5.1 (text visible by default, new props opt-in).
|
|
55
|
+
|
|
33
56
|
## [1.5.1] - 2026-10-12
|
|
34
57
|
|
|
35
58
|
### Fixed
|
|
@@ -40,10 +40,16 @@
|
|
|
40
40
|
|
|
41
41
|
<!-- Overlay -->
|
|
42
42
|
<div
|
|
43
|
-
v-if="overlay !== 'none'"
|
|
43
|
+
v-if="overlay !== 'none' && !softScrim"
|
|
44
44
|
class="absolute inset-0"
|
|
45
45
|
:class="overlayClass"
|
|
46
46
|
/>
|
|
47
|
+
<!-- scrim-left + scrimWidth (opt-in, 1.6.0) -->
|
|
48
|
+
<div
|
|
49
|
+
v-else-if="softScrim"
|
|
50
|
+
class="absolute inset-0 x-hero-scrim-w"
|
|
51
|
+
:style="{ '--x-scrim-w': scrimWidth }"
|
|
52
|
+
/>
|
|
47
53
|
</div>
|
|
48
54
|
|
|
49
55
|
<!-- Content -->
|
|
@@ -196,6 +202,18 @@ const props = defineProps({
|
|
|
196
202
|
type: String,
|
|
197
203
|
default: "",
|
|
198
204
|
},
|
|
205
|
+
/**
|
|
206
|
+
* scrim-left only, opt-in. CSS length for the 90% black zone, e.g. "40%" or "36rem".
|
|
207
|
+
* When set: desktop (md+) keeps 90% up to this width, then a long soft fade so the
|
|
208
|
+
* photo on the right stays vivid; mobile darkens a band behind the text instead of
|
|
209
|
+
* a flat 90% over the whole hero. Keep the text column inside the 90% zone.
|
|
210
|
+
* Unset (default): scrim-left renders exactly as in 1.5.x.
|
|
211
|
+
*/
|
|
212
|
+
scrimWidth: {
|
|
213
|
+
type: String,
|
|
214
|
+
default: "",
|
|
215
|
+
validator: (v) => v === "" || /^\d+(\.\d+)?(%|px|rem|em|vw)$/.test(v),
|
|
216
|
+
},
|
|
199
217
|
/** Add top padding for non-transparent nav */
|
|
200
218
|
includeNavPadding: {
|
|
201
219
|
type: Boolean,
|
|
@@ -221,6 +239,10 @@ const overlayClass = computed(() => {
|
|
|
221
239
|
return overlays[props.overlay] || "";
|
|
222
240
|
});
|
|
223
241
|
|
|
242
|
+
// scrim-left with scrimWidth renders a separate overlay element; without it the
|
|
243
|
+
// 1.5.x overlay element renders unchanged (no extra class or style attribute).
|
|
244
|
+
const softScrim = computed(() => props.overlay === "scrim-left" && !!props.scrimWidth);
|
|
245
|
+
|
|
224
246
|
const heightClass = computed(() => {
|
|
225
247
|
const heights = {
|
|
226
248
|
full: "min-h-[calc(100svh-var(--ui-header-height))]",
|
|
@@ -260,3 +282,32 @@ const contentAlignmentClasses = computed(() => {
|
|
|
260
282
|
return aligns[props.align];
|
|
261
283
|
});
|
|
262
284
|
</script>
|
|
285
|
+
|
|
286
|
+
<style>
|
|
287
|
+
/* scrim-left + scrimWidth (1.6.0). Mobile: 90% band behind the text column,
|
|
288
|
+
* lighter at the top and bottom edges of the hero. */
|
|
289
|
+
.x-hero-scrim-w {
|
|
290
|
+
background-image: linear-gradient(
|
|
291
|
+
to bottom,
|
|
292
|
+
rgb(0 0 0 / 0.35) 0%,
|
|
293
|
+
rgb(0 0 0 / 0.9) 18%,
|
|
294
|
+
rgb(0 0 0 / 0.9) 82%,
|
|
295
|
+
rgb(0 0 0 / 0.35) 100%
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
/* md+: 90% up to --x-scrim-w, then a long eased fade to clear. */
|
|
299
|
+
@media (min-width: 768px) {
|
|
300
|
+
.x-hero-scrim-w {
|
|
301
|
+
background-image: linear-gradient(
|
|
302
|
+
to right,
|
|
303
|
+
rgb(0 0 0 / 0.9) 0,
|
|
304
|
+
rgb(0 0 0 / 0.9) var(--x-scrim-w),
|
|
305
|
+
rgb(0 0 0 / 0.72) calc(var(--x-scrim-w) + 4%),
|
|
306
|
+
rgb(0 0 0 / 0.45) calc(var(--x-scrim-w) + 10%),
|
|
307
|
+
rgb(0 0 0 / 0.2) calc(var(--x-scrim-w) + 18%),
|
|
308
|
+
rgb(0 0 0 / 0.06) calc(var(--x-scrim-w) + 26%),
|
|
309
|
+
rgb(0 0 0 / 0) calc(var(--x-scrim-w) + 34%)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
</style>
|
|
@@ -24,10 +24,12 @@ const animationSelectors = [
|
|
|
24
24
|
'.xFadeUp-stagger',
|
|
25
25
|
].join(', ');
|
|
26
26
|
|
|
27
|
-
// One
|
|
28
|
-
//
|
|
29
|
-
|
|
27
|
+
// One long-lived observer shared app-wide. It is created once and never
|
|
28
|
+
// rebuilt: every init (plugin hooks, components, app.vue workarounds) only
|
|
29
|
+
// scans for new elements and observes them, so there is no observer churn.
|
|
30
|
+
let sharedObserver = null;
|
|
30
31
|
let fallbackTimer = null;
|
|
32
|
+
const observed = new WeakSet();
|
|
31
33
|
|
|
32
34
|
const prefersReducedMotion = () =>
|
|
33
35
|
typeof window !== 'undefined' &&
|
|
@@ -41,18 +43,40 @@ const inViewport = (el) => {
|
|
|
41
43
|
return r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw;
|
|
42
44
|
};
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const revealInViewport = () => {
|
|
47
|
+
document.querySelectorAll(animationSelectors).forEach((el) => {
|
|
48
|
+
if (!el.classList.contains('is-visible') && inViewport(el)) {
|
|
49
|
+
el.classList.add('is-visible');
|
|
50
|
+
sharedObserver?.unobserve(el);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
};
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
const getSharedObserver = () => {
|
|
56
|
+
if (!sharedObserver) {
|
|
57
|
+
sharedObserver = new IntersectionObserver((entries) => {
|
|
58
|
+
entries.forEach((entry) => {
|
|
59
|
+
if (entry.isIntersecting) {
|
|
60
|
+
entry.target.classList.add('is-visible');
|
|
61
|
+
sharedObserver?.unobserve(entry.target);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}, { threshold: 0.15, rootMargin: '0px 0px -80px 0px' });
|
|
65
|
+
}
|
|
66
|
+
return sharedObserver;
|
|
67
|
+
};
|
|
54
68
|
|
|
55
|
-
|
|
69
|
+
// Test-only: drop the shared observer and pending fallback.
|
|
70
|
+
export const __resetScrollRevealForTests = () => {
|
|
71
|
+
sharedObserver = null;
|
|
72
|
+
if (fallbackTimer) clearTimeout(fallbackTimer);
|
|
73
|
+
fallbackTimer = null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const useScrollReveal = () => {
|
|
77
|
+
// Lifecycle hooks are only registered inside a component instance.
|
|
78
|
+
// Outside one (e.g. a plugin) the caller drives initScrollReveal itself.
|
|
79
|
+
const instance = getCurrentInstance();
|
|
56
80
|
|
|
57
81
|
const initScrollReveal = () => {
|
|
58
82
|
if (!import.meta.client || typeof document === 'undefined') return;
|
|
@@ -75,54 +99,31 @@ export const useScrollReveal = (options = {}) => {
|
|
|
75
99
|
});
|
|
76
100
|
root.classList.add(REVEAL_ARMED_CLASS);
|
|
77
101
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (options.once !== false) {
|
|
86
|
-
observer.value?.unobserve(entry.target);
|
|
87
|
-
}
|
|
88
|
-
} else if (options.once === false) {
|
|
89
|
-
entry.target.classList.remove('is-visible');
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
}, defaultOptions);
|
|
93
|
-
activeObserver = observer.value;
|
|
94
|
-
|
|
95
|
-
elements.forEach((el) => observer.value?.observe(el));
|
|
102
|
+
const io = getSharedObserver();
|
|
103
|
+
elements.forEach((el) => {
|
|
104
|
+
if (!el.classList.contains('is-visible') && !observed.has(el)) {
|
|
105
|
+
observed.add(el);
|
|
106
|
+
io.observe(el);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
96
109
|
|
|
97
110
|
// Fallback: if IO never fires, reveal anything on screen after a short wait.
|
|
98
111
|
if (fallbackTimer) clearTimeout(fallbackTimer);
|
|
99
112
|
fallbackTimer = setTimeout(() => {
|
|
100
113
|
fallbackTimer = null;
|
|
101
|
-
|
|
102
|
-
if (!el.classList.contains('is-visible') && inViewport(el)) {
|
|
103
|
-
el.classList.add('is-visible');
|
|
104
|
-
}
|
|
105
|
-
});
|
|
114
|
+
revealInViewport();
|
|
106
115
|
}, REVEAL_FALLBACK_MS);
|
|
107
116
|
};
|
|
108
117
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (activeObserver === observer.value) activeObserver = null;
|
|
113
|
-
observer.value = null;
|
|
114
|
-
}
|
|
115
|
-
};
|
|
118
|
+
// The shared observer is long-lived; per-caller cleanup is a no-op.
|
|
119
|
+
// Detached elements are released by the WeakSet and by IO itself.
|
|
120
|
+
const cleanup = () => {};
|
|
116
121
|
|
|
117
122
|
if (import.meta.client && instance) {
|
|
118
123
|
onMounted(() => {
|
|
119
124
|
// Small delay to ensure DOM is ready
|
|
120
125
|
setTimeout(initScrollReveal, 100);
|
|
121
126
|
});
|
|
122
|
-
|
|
123
|
-
onUnmounted(() => {
|
|
124
|
-
cleanup();
|
|
125
|
-
});
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
return {
|
|
@@ -9,7 +9,8 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
9
9
|
// instance, so drive it from app hooks: app:mounted covers initial
|
|
10
10
|
// hydration (including prerendered pages, where page:finish can fire
|
|
11
11
|
// before mount or not at all), page:finish covers client navigations.
|
|
12
|
-
// initScrollReveal
|
|
12
|
+
// initScrollReveal only scans and observes new elements on one shared,
|
|
13
|
+
// long-lived observer; it never tears down or rebuilds it.
|
|
13
14
|
const { initScrollReveal } = useScrollReveal();
|
|
14
15
|
const run = () => nextTick(() => initScrollReveal());
|
|
15
16
|
nuxtApp.hook('app:mounted', run);
|