@xenterprises/nuxt-x-marketing 1.4.15 → 1.5.1

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,30 @@ 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.5.1] - 2026-10-12
34
+
35
+ ### Fixed
36
+
37
+ - Scroll reveal content (`.xFadeUp`, `.xFadeIn`, `.xFadeLeft`, `.xFadeRight`, `.xScale`, `.xFadeUp-stagger`, `[data-reveal]`) could stay invisible on prerendered sites and in headless Chrome. Reveal is now progressive enhancement only:
38
+ - Content is visible by default. Hidden initial states apply only under `html.x-reveal-armed`, which the client plugin sets after JS runs, so SSR, prerendered and no-JS output always shows content.
39
+ - `prefers-reduced-motion: reduce` never hides and never animates.
40
+ - Elements already in the viewport are marked visible synchronously (getBoundingClientRect) before arming, so above-the-fold content never flashes hidden.
41
+ - Without IntersectionObserver, everything is shown. If IO never fires, on-screen elements are revealed after 1.5s.
42
+ - The plugin now initializes on `app:mounted` (initial hydration) as well as `page:finish` (navigations).
43
+ - One observer app-wide: repeated `initScrollReveal()` calls, including an `app.vue` workaround, replace the previous observer instead of stacking.
44
+ - `XMarkHero` eyebrow, title, subtitle and buttons no longer use reveal classes, so hero text is never gated on JS.
45
+
46
+ ## [1.5.0] - 2026-10-12
47
+
48
+ ### Added
49
+
50
+ - `XMarkHero` `overlay="heavier"`: flat 75% black (title about 8:1 on the worst measured desk-lamps image area, versus about 5.9:1 with `heavy`).
51
+ - `XMarkHero` `overlay="scrim-left"` and `overlay="scrim-bottom"`: 90% black where the text sits, fading to 30%. White title holds 7:1 (subtitle 5.2:1) even over pure white image areas. `scrim-left` is a full 90% below `md`.
52
+ - `XMarkHero` `height` prop: `full` (default, unchanged), `tall` (75svh), `short` (50svh).
53
+ - `XMarkHero` `imagePosition` prop: CSS `object-position` for the background image or video, e.g. `left center`.
54
+
55
+ Existing values are unchanged: `heavy` stays 60%, and the default height is still full viewport.
56
+
33
57
  ## [1.4.15] - 2026-10-12
34
58
 
35
59
  ### Fixed
@@ -103,88 +103,93 @@
103
103
  * X MARKETING SCROLL ANIMATIONS
104
104
  * ============================================ */
105
105
 
106
- /* xFadeUp - Global fade up on scroll */
107
- .xFadeUp {
108
- opacity: 0;
109
- transform: translateY(24px);
110
- transition:
111
- opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
112
- transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
113
- }
114
-
115
- .xFadeUp.is-visible {
116
- opacity: 1;
117
- transform: translateY(0);
118
- transition:
119
- opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
120
- transform 700ms cubic-bezier(0.16, 1, 0.3, 1),
121
- box-shadow 700ms cubic-bezier(0.16, 1, 0.3, 1);
122
- }
123
-
124
- /* Stagger children */
125
- .xFadeUp-stagger > * {
126
- opacity: 0;
127
- transform: translateY(24px);
128
- transition:
129
- opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
130
- transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
131
- }
132
-
133
- .xFadeUp-stagger.is-visible > *:nth-child(1) { transition-delay: 0ms; opacity: 1; transform: translateY(0); }
134
- .xFadeUp-stagger.is-visible > *:nth-child(2) { transition-delay: 100ms; opacity: 1; transform: translateY(0); }
135
- .xFadeUp-stagger.is-visible > *:nth-child(3) { transition-delay: 200ms; opacity: 1; transform: translateY(0); }
136
- .xFadeUp-stagger.is-visible > *:nth-child(4) { transition-delay: 300ms; opacity: 1; transform: translateY(0); }
137
- .xFadeUp-stagger.is-visible > *:nth-child(5) { transition-delay: 400ms; opacity: 1; transform: translateY(0); }
138
- .xFadeUp-stagger.is-visible > *:nth-child(6) { transition-delay: 500ms; opacity: 1; transform: translateY(0); }
139
-
140
- /* xFadeIn - Simple fade */
141
- .xFadeIn {
142
- opacity: 0;
143
- transition: opacity 700ms cubic-bezier(0.16, 1, 0.3, 1);
144
- }
145
- .xFadeIn.is-visible { opacity: 1; }
146
-
147
- /* xFadeLeft - Fade from left */
148
- .xFadeLeft {
149
- opacity: 0;
150
- transform: translateX(-24px);
151
- transition:
152
- opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
153
- transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
154
- }
155
- .xFadeLeft.is-visible { opacity: 1; transform: translateX(0); }
106
+ /* Reveal is progressive enhancement: hidden states apply only after the
107
+ * client plugin sets html.x-reveal-armed, and never with reduced motion.
108
+ * SSR, prerendered and no-JS output always shows content. */
109
+ @media (prefers-reduced-motion: no-preference) {
110
+ /* xFadeUp - Global fade up on scroll */
111
+ html.x-reveal-armed .xFadeUp {
112
+ opacity: 0;
113
+ transform: translateY(24px);
114
+ transition:
115
+ opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
116
+ transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
117
+ }
156
118
 
157
- /* xFadeRight - Fade from right */
158
- .xFadeRight {
159
- opacity: 0;
160
- transform: translateX(24px);
161
- transition:
162
- opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
163
- transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
164
- }
165
- .xFadeRight.is-visible { opacity: 1; transform: translateX(0); }
119
+ html.x-reveal-armed .xFadeUp.is-visible {
120
+ opacity: 1;
121
+ transform: translateY(0);
122
+ transition:
123
+ opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
124
+ transform 700ms cubic-bezier(0.16, 1, 0.3, 1),
125
+ box-shadow 700ms cubic-bezier(0.16, 1, 0.3, 1);
126
+ }
166
127
 
167
- /* xScale - Scale in */
168
- .xScale {
169
- opacity: 0;
170
- transform: scale(0.95);
171
- transition:
172
- opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
173
- transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
174
- }
175
- .xScale.is-visible { opacity: 1; transform: scale(1); }
128
+ /* Stagger children */
129
+ html.x-reveal-armed .xFadeUp-stagger > * {
130
+ opacity: 0;
131
+ transform: translateY(24px);
132
+ transition:
133
+ opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
134
+ transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
135
+ }
176
136
 
177
- /* Legacy reveal (kept for compatibility) */
178
- [data-reveal] {
179
- opacity: 0;
180
- transform: translateY(40px);
181
- transition: opacity 0.4s cubic-bezier(0.25, 1, 0.5, 1),
182
- transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
183
- }
137
+ html.x-reveal-armed .xFadeUp-stagger.is-visible > *:nth-child(1) { transition-delay: 0ms; opacity: 1; transform: translateY(0); }
138
+ html.x-reveal-armed .xFadeUp-stagger.is-visible > *:nth-child(2) { transition-delay: 100ms; opacity: 1; transform: translateY(0); }
139
+ html.x-reveal-armed .xFadeUp-stagger.is-visible > *:nth-child(3) { transition-delay: 200ms; opacity: 1; transform: translateY(0); }
140
+ html.x-reveal-armed .xFadeUp-stagger.is-visible > *:nth-child(4) { transition-delay: 300ms; opacity: 1; transform: translateY(0); }
141
+ html.x-reveal-armed .xFadeUp-stagger.is-visible > *:nth-child(5) { transition-delay: 400ms; opacity: 1; transform: translateY(0); }
142
+ html.x-reveal-armed .xFadeUp-stagger.is-visible > *:nth-child(6) { transition-delay: 500ms; opacity: 1; transform: translateY(0); }
143
+
144
+ /* xFadeIn - Simple fade */
145
+ html.x-reveal-armed .xFadeIn {
146
+ opacity: 0;
147
+ transition: opacity 700ms cubic-bezier(0.16, 1, 0.3, 1);
148
+ }
149
+ html.x-reveal-armed .xFadeIn.is-visible { opacity: 1; }
150
+
151
+ /* xFadeLeft - Fade from left */
152
+ html.x-reveal-armed .xFadeLeft {
153
+ opacity: 0;
154
+ transform: translateX(-24px);
155
+ transition:
156
+ opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
157
+ transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
158
+ }
159
+ html.x-reveal-armed .xFadeLeft.is-visible { opacity: 1; transform: translateX(0); }
160
+
161
+ /* xFadeRight - Fade from right */
162
+ html.x-reveal-armed .xFadeRight {
163
+ opacity: 0;
164
+ transform: translateX(24px);
165
+ transition:
166
+ opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
167
+ transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
168
+ }
169
+ html.x-reveal-armed .xFadeRight.is-visible { opacity: 1; transform: translateX(0); }
170
+
171
+ /* xScale - Scale in */
172
+ html.x-reveal-armed .xScale {
173
+ opacity: 0;
174
+ transform: scale(0.95);
175
+ transition:
176
+ opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
177
+ transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
178
+ }
179
+ html.x-reveal-armed .xScale.is-visible { opacity: 1; transform: scale(1); }
180
+
181
+ /* Legacy reveal (kept for compatibility) */
182
+ html.x-reveal-armed [data-reveal] {
183
+ opacity: 0;
184
+ transform: translateY(40px);
185
+ transition: opacity 0.4s cubic-bezier(0.25, 1, 0.5, 1),
186
+ transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
187
+ }
184
188
 
185
- [data-reveal].is-visible {
186
- opacity: 1;
187
- transform: translateY(0);
189
+ html.x-reveal-armed [data-reveal].is-visible {
190
+ opacity: 1;
191
+ transform: translateY(0);
192
+ }
188
193
  }
189
194
 
190
195
  /* ============================================
@@ -1,7 +1,8 @@
1
1
  <template>
2
2
  <section
3
- class="relative min-h-[calc(100svh-var(--ui-header-height))] flex overflow-hidden"
3
+ class="relative flex overflow-hidden"
4
4
  :class="[
5
+ heightClass,
5
6
  alignmentClasses,
6
7
  verticalAlignClasses,
7
8
  { 'pt-20': includeNavPadding },
@@ -21,6 +22,7 @@
21
22
  muted
22
23
  playsinline
23
24
  class="absolute inset-0 w-full h-full object-cover xParallax"
25
+ :style="imagePositionStyle"
24
26
  data-parallax-speed="0.3"
25
27
  />
26
28
 
@@ -31,6 +33,7 @@
31
33
  :alt="img.alt || ''"
32
34
  fetchpriority="high"
33
35
  class="absolute inset-0 w-full h-full object-cover xParallax"
36
+ :style="imagePositionStyle"
34
37
  data-parallax-speed="0.3"
35
38
  />
36
39
  </slot>
@@ -47,14 +50,13 @@
47
50
  <div class="relative z-10 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
48
51
  <div class="max-w-3xl pb-6 md:pb-8" :class="contentAlignmentClasses">
49
52
  <!-- Eyebrow -->
50
- <p v-if="eyebrow" class="xText-eyebrow text-white/70 mb-4 xFadeUp">
53
+ <p v-if="eyebrow" class="xText-eyebrow text-white/70 mb-4">
51
54
  {{ eyebrow }}
52
55
  </p>
53
56
 
54
57
  <!-- Title -->
55
58
  <h1
56
- class="xText-display text-white xText-balance xFadeUp"
57
- style="transition-delay: 100ms"
59
+ class="xText-display text-white xText-balance"
58
60
  >
59
61
  <slot name="title">{{ title }}</slot>
60
62
  </h1>
@@ -62,8 +64,7 @@
62
64
  <!-- Subtitle -->
63
65
  <p
64
66
  v-if="subtitle || $slots.subtitle"
65
- class="mt-6 text-xl text-white/80 max-w-2xl xFadeUp"
66
- style="transition-delay: 200ms"
67
+ class="mt-6 text-xl text-white/80 max-w-2xl"
67
68
  >
68
69
  <slot name="subtitle">{{ subtitle }}</slot>
69
70
  </p>
@@ -71,12 +72,11 @@
71
72
  <!-- Actions -->
72
73
  <div
73
74
  v-if="buttons.length || $slots.actions"
74
- class="mt-10 flex flex-wrap gap-4 xFadeUp"
75
+ class="mt-10 flex flex-wrap gap-4"
75
76
  :class="{
76
77
  'justify-center': align === 'center',
77
78
  'justify-end': align === 'right',
78
79
  }"
79
- style="transition-delay: 300ms"
80
80
  >
81
81
  <slot name="actions">
82
82
  <UButton
@@ -137,7 +137,15 @@ const props = defineProps({
137
137
  overlay: {
138
138
  type: String,
139
139
  default: "gradient",
140
- validator: (v) => ["light", "heavy", "gradient", "none"].includes(v),
140
+ validator: (v) => [
141
+ "light",
142
+ "heavy",
143
+ "heavier",
144
+ "gradient",
145
+ "scrim-left",
146
+ "scrim-bottom",
147
+ "none",
148
+ ].includes(v),
141
149
  },
142
150
  /** Small text above title */
143
151
  eyebrow: {
@@ -177,6 +185,17 @@ const props = defineProps({
177
185
  type: Boolean,
178
186
  default: true,
179
187
  },
188
+ /** Section height: full (viewport minus header, default), tall (75svh), short (50svh) */
189
+ height: {
190
+ type: String,
191
+ default: "full",
192
+ validator: (v) => ["full", "tall", "short"].includes(v),
193
+ },
194
+ /** CSS object-position for the background image/video, e.g. "left center" */
195
+ imagePosition: {
196
+ type: String,
197
+ default: "",
198
+ },
180
199
  /** Add top padding for non-transparent nav */
181
200
  includeNavPadding: {
182
201
  type: Boolean,
@@ -188,11 +207,33 @@ const overlayClass = computed(() => {
188
207
  const overlays = {
189
208
  light: "bg-black/30",
190
209
  heavy: "bg-black/60",
210
+ // 75% flat: white title >= 8:1 on image areas up to ~0.32 relative luminance
211
+ heavier: "bg-black/75",
191
212
  gradient: "bg-gradient-to-t from-black/80 via-black/40 to-transparent",
213
+ // 90% where text sits: white title >= 7:1 even over pure white image areas.
214
+ // Full 90% on small screens where text spans the width.
215
+ "scrim-left":
216
+ "bg-black/90 md:bg-transparent md:bg-gradient-to-r md:from-black/90 md:via-black/90 md:via-55% md:to-black/30",
217
+ // 90% across the bottom half; pair with verticalAlign="bottom"
218
+ "scrim-bottom":
219
+ "bg-gradient-to-t from-black/90 via-black/90 via-50% to-black/30",
192
220
  };
193
221
  return overlays[props.overlay] || "";
194
222
  });
195
223
 
224
+ const heightClass = computed(() => {
225
+ const heights = {
226
+ full: "min-h-[calc(100svh-var(--ui-header-height))]",
227
+ tall: "min-h-[75svh]",
228
+ short: "min-h-[50svh]",
229
+ };
230
+ return heights[props.height] || heights.full;
231
+ });
232
+
233
+ const imagePositionStyle = computed(() =>
234
+ props.imagePosition ? { objectPosition: props.imagePosition } : undefined,
235
+ );
236
+
196
237
  const alignmentClasses = computed(() => {
197
238
  const aligns = {
198
239
  left: "text-left",
@@ -8,6 +8,39 @@
8
8
  * - .xFadeUp, .xFadeIn, .xFadeLeft, .xFadeRight, .xScale classes
9
9
  * - .xFadeUp-stagger for staggered child animations
10
10
  */
11
+ // Class set on <html> once JS has armed reveal. Hidden initial states in
12
+ // x-marketing.css only apply under it, so SSR, prerendered and no-JS pages
13
+ // always render reveal content visible (progressive enhancement).
14
+ export const REVEAL_ARMED_CLASS = 'x-reveal-armed';
15
+ export const REVEAL_FALLBACK_MS = 1500;
16
+
17
+ const animationSelectors = [
18
+ '[data-reveal]',
19
+ '.xFadeUp',
20
+ '.xFadeIn',
21
+ '.xFadeLeft',
22
+ '.xFadeRight',
23
+ '.xScale',
24
+ '.xFadeUp-stagger',
25
+ ].join(', ');
26
+
27
+ // One active observer app-wide. Every init (plugin, app.vue workaround,
28
+ // component) replaces the previous one, so callers never stack observers.
29
+ let activeObserver = null;
30
+ let fallbackTimer = null;
31
+
32
+ const prefersReducedMotion = () =>
33
+ typeof window !== 'undefined' &&
34
+ typeof window.matchMedia === 'function' &&
35
+ Boolean(window.matchMedia('(prefers-reduced-motion: reduce)')?.matches);
36
+
37
+ const inViewport = (el) => {
38
+ const r = el.getBoundingClientRect();
39
+ const vh = window.innerHeight || document.documentElement.clientHeight;
40
+ const vw = window.innerWidth || document.documentElement.clientWidth;
41
+ return r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw;
42
+ };
43
+
11
44
  export const useScrollReveal = (options = {}) => {
12
45
  // Lifecycle hooks are only registered inside a component instance.
13
46
  // Outside one (e.g. a plugin) the caller drives initScrollReveal/cleanup itself.
@@ -21,44 +54,62 @@ export const useScrollReveal = (options = {}) => {
21
54
 
22
55
  const observer = ref(null);
23
56
 
24
- // Selector for all animation elements
25
- const animationSelectors = [
26
- '[data-reveal]',
27
- '.xFadeUp',
28
- '.xFadeIn',
29
- '.xFadeLeft',
30
- '.xFadeRight',
31
- '.xScale',
32
- '.xFadeUp-stagger',
33
- ].join(', ');
34
-
35
57
  const initScrollReveal = () => {
36
- if (import.meta.client && typeof IntersectionObserver !== 'undefined') {
37
- if (observer.value) observer.value.disconnect();
38
- observer.value = new IntersectionObserver((entries) => {
39
- entries.forEach((entry) => {
40
- if (entry.isIntersecting) {
41
- entry.target.classList.add('is-visible');
42
- // Optionally unobserve after animation
43
- if (options.once !== false) {
44
- observer.value?.unobserve(entry.target);
45
- }
46
- } else if (options.once === false) {
47
- // Re-hide if not in view and once is false
48
- entry.target.classList.remove('is-visible');
49
- }
50
- });
51
- }, defaultOptions);
58
+ if (!import.meta.client || typeof document === 'undefined') return;
59
+ const root = document.documentElement;
60
+ const elements = Array.from(document.querySelectorAll(animationSelectors));
52
61
 
53
- // Observe all animation elements
54
- const elements = document.querySelectorAll(animationSelectors);
55
- elements.forEach((el) => observer.value?.observe(el));
62
+ // No IO or reduced motion: never hide, show everything, no animation.
63
+ if (typeof IntersectionObserver === 'undefined' || prefersReducedMotion()) {
64
+ root.classList.remove(REVEAL_ARMED_CLASS);
65
+ elements.forEach((el) => el.classList.add('is-visible'));
66
+ return;
56
67
  }
68
+
69
+ // Mark what is already on screen visible synchronously, before arming,
70
+ // so above-the-fold content never flashes hidden.
71
+ elements.forEach((el) => {
72
+ if (!el.classList.contains('is-visible') && inViewport(el)) {
73
+ el.classList.add('is-visible');
74
+ }
75
+ });
76
+ root.classList.add(REVEAL_ARMED_CLASS);
77
+
78
+ if (activeObserver && activeObserver !== observer.value) activeObserver.disconnect();
79
+ if (observer.value) observer.value.disconnect();
80
+
81
+ observer.value = new IntersectionObserver((entries) => {
82
+ entries.forEach((entry) => {
83
+ if (entry.isIntersecting) {
84
+ entry.target.classList.add('is-visible');
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));
96
+
97
+ // Fallback: if IO never fires, reveal anything on screen after a short wait.
98
+ if (fallbackTimer) clearTimeout(fallbackTimer);
99
+ fallbackTimer = setTimeout(() => {
100
+ fallbackTimer = null;
101
+ document.querySelectorAll(animationSelectors).forEach((el) => {
102
+ if (!el.classList.contains('is-visible') && inViewport(el)) {
103
+ el.classList.add('is-visible');
104
+ }
105
+ });
106
+ }, REVEAL_FALLBACK_MS);
57
107
  };
58
108
 
59
109
  const cleanup = () => {
60
110
  if (observer.value) {
61
111
  observer.value.disconnect();
112
+ if (activeObserver === observer.value) activeObserver = null;
62
113
  observer.value = null;
63
114
  }
64
115
  };
@@ -5,14 +5,15 @@
5
5
  * From spec-prompt.md specification
6
6
  */
7
7
  export default defineNuxtPlugin((nuxtApp) => {
8
- // Initialize scroll reveal globally. A plugin has no component instance,
9
- // so drive the observer from page lifecycle hooks: initial hydration and
10
- // every client-side navigation re-scan the DOM for reveal elements.
11
- const { initScrollReveal, cleanup } = useScrollReveal();
12
- nuxtApp.hook('page:finish', () => {
13
- cleanup();
14
- nextTick(() => setTimeout(initScrollReveal, 100));
15
- });
8
+ // Scroll reveal is progressive enhancement. A plugin has no component
9
+ // instance, so drive it from app hooks: app:mounted covers initial
10
+ // hydration (including prerendered pages, where page:finish can fire
11
+ // before mount or not at all), page:finish covers client navigations.
12
+ // initScrollReveal is idempotent and keeps one observer app-wide.
13
+ const { initScrollReveal } = useScrollReveal();
14
+ const run = () => nextTick(() => initScrollReveal());
15
+ nuxtApp.hook('app:mounted', run);
16
+ nuxtApp.hook('page:finish', run);
16
17
 
17
18
  // Initialize parallax globally
18
19
  useParallax();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xenterprises/nuxt-x-marketing",
3
3
  "license": "SEE LICENSE IN LICENSE",
4
4
  "type": "module",
5
- "version": "1.4.15",
5
+ "version": "1.5.1",
6
6
  "main": "./nuxt.config.ts",
7
7
  "scripts": {
8
8
  "dev": "nuxi dev .playground",