@xenterprises/nuxt-x-marketing 1.6.0 → 1.8.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,40 @@ 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.8.0] - 2026-10-12
34
+
35
+ All additions are opt-in; with none of them used, rendered HTML and pixels are identical to 1.7.0.
36
+
37
+ ### Added
38
+
39
+ - **`XMarkMedia`**: full-width image block (e.g. under an H1-only thin hero). Props `img`
40
+ (`{ src, alt?, sources? }`, `sources` renders `<picture>` like XMarkHero), `aspectRatio`
41
+ (CSS ratio, default `16/9`), `container` (same values as XMarkSection, default `lg`),
42
+ `rounded` (`none` default, `sm` to `xl`), `caption` (`<figure>`/`<figcaption>`),
43
+ `imagePosition`, `loading` (default `eager`).
44
+ - **`XXFooter` / `XXLegal` `container`**: `"lg"` uses `max-w-7xl px-4 sm:px-6 lg:px-8`,
45
+ matching XMarkHero/XMarkSection. Default output unchanged.
46
+ - **`appConfig.xMarketing.container: "7xl"`**: adds `html.x-container-7xl`, which sets
47
+ `--ui-container` to `80rem` so the header lines up with max-w-7xl, and makes XXFooter/XXLegal
48
+ default to `container="lg"`. Unset keeps `--ui-container: 1440px`.
49
+ - **`cta` color palette** (`--color-cta-*`, shade 500 = `#c2410c`): opt in with
50
+ `ui: { colors: { primary: 'cta' } }`; solid primary buttons get white text at 5.18:1.
51
+ - **`XXSkipLink`**: skip-to-content link (`href="#main"` by default), visually hidden until
52
+ focused. Opt-in; place it first inside `<UApp>`.
53
+
54
+ ## [1.7.0] - 2026-10-12
55
+
56
+ ### Added
57
+
58
+ - **`XMarkHero` `scrimMobile="bottom"`** (opt-in, `overlay="scrim-left"` only). Below md the scrim
59
+ becomes a bottom gradient (90% black over the bottom 55%, fading to clear by 70%) and the text is
60
+ bottom-aligned (`max-md:items-end max-md:pb-10`), so the top of the photo stays clear. md+ is
61
+ unchanged (with `scrimWidth`, the 1.6.0 soft scrim; without, the scrim-left gradient). Keep the
62
+ title and subtitle inside the bottom 55% of the hero on mobile.
63
+ - **`XMarkHero` `img.sources`**: `[{ srcset, type, media?, sizes? }]` renders
64
+ `<picture><source>...<img>` (e.g. AVIF, then WebP, with `img.src` as the fallback).
65
+ Without `sources` the output is byte-identical to 1.6.0.
66
+
33
67
  ## [1.6.0] - 2026-10-12
34
68
 
35
69
  ### Added
package/app/app.config.ts CHANGED
@@ -27,6 +27,9 @@ export default defineAppConfig({
27
27
  },
28
28
  },
29
29
  xMarketing: {
30
+ /** 1.8.0 opt-in. "7xl" aligns --ui-container (header) and XXFooter/XXLegal with
31
+ * the max-w-7xl column used by XMarkHero/XMarkSection. Unset keeps 1440px. */
32
+ container: undefined as undefined | "7xl",
30
33
  /** Site name — used by the shipped shell for the SEO title template
31
34
  * ("%s | name") and og:site_name. Leave undefined to keep bare titles. */
32
35
  name: undefined,
@@ -459,6 +459,21 @@
459
459
  --color-brand-950: #000a1a;
460
460
 
461
461
  /* Deep blacks with warm undertone */
462
+ /* cta (1.8.0, opt-in): AA orange palette whose shade 500 is orange-700, so
463
+ * solid buttons with white text pass AA (5.18:1). Opt in from app.config:
464
+ * ui: { colors: { primary: 'cta' } }. */
465
+ --color-cta-50: #fff7ed;
466
+ --color-cta-100: #ffedd5;
467
+ --color-cta-200: #fed7aa;
468
+ --color-cta-300: #fdba74;
469
+ --color-cta-400: #ea580c;
470
+ --color-cta-500: #c2410c;
471
+ --color-cta-600: #9a3412;
472
+ --color-cta-700: #7c2d12;
473
+ --color-cta-800: #6c2710;
474
+ --color-cta-900: #431407;
475
+ --color-cta-950: #2a0c04;
476
+
462
477
  --color-deep-50: #f5f5f4;
463
478
  --color-deep-100: #e8e8e3;
464
479
  --color-deep-200: #c5c5bd;
@@ -682,3 +697,30 @@ body {
682
697
  -moz-osx-font-smoothing: grayscale;
683
698
  text-rendering: optimizeLegibility;
684
699
  }
700
+
701
+ /* Opt-in (1.8.0): appConfig.xMarketing.container = "7xl" adds html.x-container-7xl,
702
+ * aligning UContainer / UHeader (--ui-container) with max-w-7xl (80rem), the
703
+ * XMarkHero and XMarkSection column. Default stays 1440px. */
704
+ html.x-container-7xl {
705
+ --ui-container: 80rem;
706
+ }
707
+
708
+ /* XXSkipLink (1.8.0) */
709
+ .x-skip-link {
710
+ position: absolute;
711
+ left: 0.5rem;
712
+ top: 0.5rem;
713
+ z-index: 100;
714
+ padding: 0.5rem 0.75rem;
715
+ background: #fff;
716
+ color: #111;
717
+ border-radius: 0.25rem;
718
+ font-weight: 600;
719
+ transform: translateY(-200%);
720
+ }
721
+ .x-skip-link:focus,
722
+ .x-skip-link:focus-visible {
723
+ transform: none;
724
+ outline: 2px solid #111;
725
+ outline-offset: 2px;
726
+ }
@@ -26,6 +26,26 @@
26
26
  data-parallax-speed="0.3"
27
27
  />
28
28
 
29
+ <!-- Image Background with format sources (opt-in, 1.7.0) -->
30
+ <picture v-else-if="img?.src && img.sources?.length">
31
+ <source
32
+ v-for="(source, i) in img.sources"
33
+ :key="i"
34
+ :srcset="source.srcset"
35
+ :type="source.type"
36
+ :media="source.media"
37
+ :sizes="source.sizes"
38
+ >
39
+ <img
40
+ :src="img.src"
41
+ :alt="img.alt || ''"
42
+ fetchpriority="high"
43
+ class="absolute inset-0 w-full h-full object-cover xParallax"
44
+ :style="imagePositionStyle"
45
+ data-parallax-speed="0.3"
46
+ >
47
+ </picture>
48
+
29
49
  <!-- Image Background -->
30
50
  <img
31
51
  v-else-if="img?.src"
@@ -40,10 +60,17 @@
40
60
 
41
61
  <!-- Overlay -->
42
62
  <div
43
- v-if="overlay !== 'none' && !softScrim"
63
+ v-if="overlay !== 'none' && !softScrim && !mobileBottom"
44
64
  class="absolute inset-0"
45
65
  :class="overlayClass"
46
66
  />
67
+ <!-- scrim-left + scrimMobile="bottom" (opt-in, 1.7.0) -->
68
+ <div
69
+ v-else-if="mobileBottom"
70
+ class="absolute inset-0 x-hero-scrim-mb"
71
+ :class="scrimWidth ? 'x-hero-scrim-w' : 'x-hero-scrim-55'"
72
+ v-bind="scrimWidth ? { style: { '--x-scrim-w': scrimWidth } } : {}"
73
+ />
47
74
  <!-- scrim-left + scrimWidth (opt-in, 1.6.0) -->
48
75
  <div
49
76
  v-else-if="softScrim"
@@ -127,11 +154,19 @@
127
154
 
128
155
  <script setup>
129
156
  const props = defineProps({
130
- /** Background image object { src: string, alt?: string } */
157
+ /**
158
+ * Background image object { src: string, alt?: string, sources?: Array<{ srcset, type, media?, sizes? }> }.
159
+ * With sources (1.7.0) it renders <picture><source>...<img>, e.g. AVIF then WebP with src as fallback.
160
+ */
131
161
  img: {
132
162
  type: Object,
133
163
  default: null,
134
- validator: (v) => !v || typeof v.src === "string",
164
+ validator: (v) =>
165
+ !v ||
166
+ (typeof v.src === "string" &&
167
+ (v.sources === undefined ||
168
+ (Array.isArray(v.sources) &&
169
+ v.sources.every((x) => x && typeof x.srcset === "string")))),
135
170
  },
136
171
  /** Background video object { src: string, poster?: string } */
137
172
  video: {
@@ -214,6 +249,17 @@ const props = defineProps({
214
249
  default: "",
215
250
  validator: (v) => v === "" || /^\d+(\.\d+)?(%|px|rem|em|vw)$/.test(v),
216
251
  },
252
+ /**
253
+ * scrim-left only, opt-in (1.7.0). "bottom": below md the scrim becomes a bottom
254
+ * gradient behind the text (90% over the bottom 55%, clear from 70% up) and the text
255
+ * is bottom-aligned, so the top of the photo stays clear. md+ is unchanged.
256
+ * Empty (default) keeps 1.6.0 output.
257
+ */
258
+ scrimMobile: {
259
+ type: String,
260
+ default: "",
261
+ validator: (v) => ["", "bottom"].includes(v),
262
+ },
217
263
  /** Add top padding for non-transparent nav */
218
264
  includeNavPadding: {
219
265
  type: Boolean,
@@ -241,6 +287,10 @@ const overlayClass = computed(() => {
241
287
 
242
288
  // scrim-left with scrimWidth renders a separate overlay element; without it the
243
289
  // 1.5.x overlay element renders unchanged (no extra class or style attribute).
290
+ const mobileBottom = computed(
291
+ () => props.overlay === "scrim-left" && props.scrimMobile === "bottom",
292
+ );
293
+
244
294
  const softScrim = computed(() => props.overlay === "scrim-left" && !!props.scrimWidth);
245
295
 
246
296
  const heightClass = computed(() => {
@@ -270,7 +320,8 @@ const verticalAlignClasses = computed(() => {
270
320
  center: "items-center",
271
321
  bottom: "items-end pb-24",
272
322
  };
273
- return aligns[props.verticalAlign];
323
+ const base = aligns[props.verticalAlign];
324
+ return mobileBottom.value ? `${base} max-md:items-end max-md:pb-10` : base;
274
325
  });
275
326
 
276
327
  const contentAlignmentClasses = computed(() => {
@@ -295,6 +346,30 @@ const contentAlignmentClasses = computed(() => {
295
346
  rgb(0 0 0 / 0.35) 100%
296
347
  );
297
348
  }
349
+ /* scrimMobile="bottom" (1.7.0), below md: bottom gradient behind bottom-aligned
350
+ * text, top of the photo clear. md+ falls through to the rules below. */
351
+ @media (max-width: 767.98px) {
352
+ .x-hero-scrim-mb {
353
+ background-image: linear-gradient(
354
+ to top,
355
+ rgb(0 0 0 / 0.9) 0%,
356
+ rgb(0 0 0 / 0.9) 55%,
357
+ rgb(0 0 0 / 0.5) 62%,
358
+ rgb(0 0 0 / 0) 70%
359
+ ) !important;
360
+ }
361
+ }
362
+ /* scrimMobile without scrimWidth: md+ matches the 1.5.x scrim-left gradient. */
363
+ @media (min-width: 768px) {
364
+ .x-hero-scrim-55 {
365
+ background-image: linear-gradient(
366
+ to right,
367
+ rgb(0 0 0 / 0.9) 0%,
368
+ rgb(0 0 0 / 0.9) 55%,
369
+ rgb(0 0 0 / 0.3) 100%
370
+ );
371
+ }
372
+ }
298
373
  /* md+: 90% up to --x-scrim-w, then a long eased fade to clear. */
299
374
  @media (min-width: 768px) {
300
375
  .x-hero-scrim-w {
@@ -0,0 +1,120 @@
1
+ <template>
2
+ <div :class="containerClasses">
3
+ <figure class="m-0">
4
+ <div
5
+ class="relative w-full overflow-hidden"
6
+ :class="roundedClass"
7
+ :style="aspectStyle"
8
+ >
9
+ <picture v-if="img?.sources?.length">
10
+ <source
11
+ v-for="(source, i) in img.sources"
12
+ :key="i"
13
+ :srcset="source.srcset"
14
+ :type="source.type"
15
+ :media="source.media"
16
+ :sizes="source.sizes"
17
+ >
18
+ <img
19
+ :src="img.src"
20
+ :alt="img.alt || ''"
21
+ :loading="loading"
22
+ class="absolute inset-0 w-full h-full object-cover"
23
+ :style="positionStyle"
24
+ >
25
+ </picture>
26
+ <img
27
+ v-else
28
+ :src="img.src"
29
+ :alt="img.alt || ''"
30
+ :loading="loading"
31
+ class="absolute inset-0 w-full h-full object-cover"
32
+ :style="positionStyle"
33
+ >
34
+ </div>
35
+ <figcaption
36
+ v-if="caption"
37
+ class="mt-3 text-sm text-neutral-600"
38
+ >
39
+ {{ caption }}
40
+ </figcaption>
41
+ </figure>
42
+ </div>
43
+ </template>
44
+
45
+ <script setup>
46
+ /**
47
+ * XMarkMedia (1.8.0) - full-width image block, e.g. directly under a thin H1 hero.
48
+ * img.sources renders <picture> (same shape as XMarkHero img.sources).
49
+ */
50
+ const props = defineProps({
51
+ /** { src, alt?, sources?: [{ srcset, type?, media?, sizes? }] } */
52
+ img: {
53
+ type: Object,
54
+ required: true,
55
+ validator: (v) =>
56
+ !!v &&
57
+ typeof v.src === "string" &&
58
+ (v.sources === undefined ||
59
+ (Array.isArray(v.sources) &&
60
+ v.sources.every((x) => x && typeof x.srcset === "string"))),
61
+ },
62
+ /** CSS aspect-ratio, e.g. "16/9", "21/9", "4/3" */
63
+ aspectRatio: {
64
+ type: String,
65
+ default: "16/9",
66
+ validator: (v) => /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(v),
67
+ },
68
+ /** Same values and classes as XMarkSection container */
69
+ container: {
70
+ type: String,
71
+ default: "lg",
72
+ validator: (v) => ["sm", "md", "lg", "xl", "full", "none"].includes(v),
73
+ },
74
+ /** Corner radius: none, sm, md, lg, xl */
75
+ rounded: {
76
+ type: String,
77
+ default: "none",
78
+ validator: (v) => ["none", "sm", "md", "lg", "xl"].includes(v),
79
+ },
80
+ /** Optional caption, rendered as <figcaption> */
81
+ caption: {
82
+ type: String,
83
+ default: "",
84
+ },
85
+ /** CSS object-position, e.g. "center 30%" */
86
+ imagePosition: {
87
+ type: String,
88
+ default: "",
89
+ },
90
+ /** Native loading hint; eager by default since it sits above the fold */
91
+ loading: {
92
+ type: String,
93
+ default: "eager",
94
+ validator: (v) => ["eager", "lazy"].includes(v),
95
+ },
96
+ });
97
+
98
+ const containerClasses = computed(() => {
99
+ const containers = {
100
+ sm: "max-w-[42rem] mx-auto px-4 sm:px-6",
101
+ md: "max-w-[64rem] mx-auto px-4 sm:px-6 lg:px-8",
102
+ lg: "max-w-[80rem] mx-auto px-4 sm:px-6 lg:px-8",
103
+ xl: "max-w-[96rem] mx-auto px-4 sm:px-6 lg:px-8",
104
+ full: "w-full px-4 sm:px-6 lg:px-8",
105
+ none: "",
106
+ };
107
+ return containers[props.container];
108
+ });
109
+
110
+ const roundedClass = computed(() => {
111
+ const r = { none: "rounded-none", sm: "rounded-sm", md: "rounded-md", lg: "rounded-lg", xl: "rounded-xl" };
112
+ return r[props.rounded];
113
+ });
114
+
115
+ const aspectStyle = computed(() => ({ aspectRatio: props.aspectRatio.replace(/\s+/g, " ") }));
116
+
117
+ const positionStyle = computed(() =>
118
+ props.imagePosition ? { objectPosition: props.imagePosition } : undefined,
119
+ );
120
+ </script>
@@ -18,9 +18,7 @@
18
18
  :src="footer?.bg?.img?.src"
19
19
  :alt="footer?.bg?.img?.alt"
20
20
  />
21
- <div
22
- class="relative z-10 container mx-auto max-w-screen-2xl px-4 md:px-8"
23
- >
21
+ <div :class="innerClass">
24
22
  <div class="pb-24">
25
23
  <div
26
24
  class="flex flex-col lg:flex-row lg:justify-between gap-8 lg:gap-12"
@@ -97,4 +95,24 @@
97
95
  const emit = defineEmits(["newsletter-submit"]);
98
96
  const appConfig = useAppConfig();
99
97
  const footer = appConfig?.xMarketing?.footer;
98
+
99
+ const props = defineProps({
100
+ /**
101
+ * Inner container (1.8.0+, opt-in). "lg" = max-w-7xl px-4 sm:px-6 lg:px-8, the
102
+ * XMarkHero / XMarkSection column. Empty follows appConfig.xMarketing.container
103
+ * ("7xl" means "lg"); otherwise the default max-w-screen-2xl output is unchanged.
104
+ */
105
+ container: {
106
+ type: String,
107
+ default: "",
108
+ validator: (v) => ["", "default", "lg"].includes(v),
109
+ },
110
+ });
111
+
112
+ const innerClass = computed(() => {
113
+ const c = props.container || (appConfig?.xMarketing?.container === "7xl" ? "lg" : "default");
114
+ return c === "lg"
115
+ ? "relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"
116
+ : "relative z-10 container mx-auto max-w-screen-2xl px-4 md:px-8";
117
+ });
100
118
  </script>
@@ -8,9 +8,7 @@
8
8
  :alt="resolvedLogo.alt"
9
9
  class="h-6 w-6 mx-auto -mt-4 relative z-10"
10
10
  />
11
- <div
12
- class="flex flex-col md:flex-row justify-between items-center px-6 pt-3 pb-5 max-w-screen-2xl mx-auto gap-4"
13
- >
11
+ <div :class="innerClass">
14
12
  <div class="text-center md:text-left">
15
13
  {{ resolvedCopyright }}
16
14
  </div>
@@ -51,6 +49,15 @@ const props = defineProps({
51
49
  type: Array,
52
50
  default: null,
53
51
  },
52
+ /**
53
+ * Inner container (1.8.0+, opt-in). "lg" = max-w-7xl px-4 sm:px-6 lg:px-8. Empty follows
54
+ * appConfig.xMarketing.container ("7xl" means "lg"); otherwise output is unchanged.
55
+ */
56
+ container: {
57
+ type: String,
58
+ default: "",
59
+ validator: (v) => ["", "default", "lg"].includes(v),
60
+ },
54
61
  /** Logo object { src, alt } (defaults to X Enterprises logo). Pass false to hide. href is ignored; logo is display-only. */
55
62
  logo: {
56
63
  type: [Object, Boolean],
@@ -58,6 +65,14 @@ const props = defineProps({
58
65
  },
59
66
  });
60
67
 
68
+ const appConfig = useAppConfig();
69
+ const innerClass = computed(() => {
70
+ const c = props.container || (appConfig?.xMarketing?.container === "7xl" ? "lg" : "default");
71
+ return c === "lg"
72
+ ? "flex flex-col md:flex-row justify-between items-center px-4 sm:px-6 lg:px-8 pt-3 pb-5 max-w-7xl mx-auto gap-4"
73
+ : "flex flex-col md:flex-row justify-between items-center px-6 pt-3 pb-5 max-w-screen-2xl mx-auto gap-4";
74
+ });
75
+
61
76
  // Default X Enterprises values
62
77
  const defaultLogo = {
63
78
  src: "https://cdn.x.enterprises/logo-x.png",
@@ -0,0 +1,23 @@
1
+ <template>
2
+ <a :href="`#${target}`" class="x-skip-link">{{ label }}</a>
3
+ </template>
4
+
5
+ <script setup>
6
+ /**
7
+ * XXSkipLink (1.8.0) - skip-to-content link. Opt-in: place it first in app.vue.
8
+ * Visually hidden until focused (first Tab), then shown top-left.
9
+ * Pair with <main id="main"> (or set target).
10
+ */
11
+ defineProps({
12
+ /** id of the main content element, without # */
13
+ target: {
14
+ type: String,
15
+ default: "main",
16
+ },
17
+ /** Link text */
18
+ label: {
19
+ type: String,
20
+ default: "Skip to content",
21
+ },
22
+ });
23
+ </script>
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 1.8.0 opt-in: appConfig.xMarketing.container === "7xl" adds html.x-container-7xl,
3
+ * which sets --ui-container to 80rem (max-w-7xl). Unset: no head change at all.
4
+ */
5
+ export default defineNuxtPlugin(() => {
6
+ const appConfig = useAppConfig() as { xMarketing?: { container?: string } };
7
+ if (appConfig?.xMarketing?.container === "7xl") {
8
+ useHead({ htmlAttrs: { class: "x-container-7xl" } });
9
+ }
10
+ });
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.6.0",
5
+ "version": "1.8.0",
6
6
  "main": "./nuxt.config.ts",
7
7
  "scripts": {
8
8
  "dev": "nuxi dev .playground",