@tokenoftrust/storefront-runner 3.12.0 → 3.12.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.
@@ -24,7 +24,7 @@ import type { CatalogProduct } from "@tot/public-runtime";
24
24
  import Img from "@/components/assets/storefront/Img.astro";
25
25
  import { withBase } from "@/lib/publication/shared/basePath";
26
26
  import { pageUrlForm } from "@/lib/content/seo/urlForm";
27
- import { heroSlideshowView } from "@/lib/content/shared/heroSlideshow";
27
+ import { heroFrame, heroSlideshowView, HERO_PHONE_MEDIA } from "@/lib/content/shared/heroSlideshow";
28
28
 
29
29
  interface Props {
30
30
  block: HeroBlock;
@@ -44,6 +44,8 @@ const heroImg = block.image;
44
44
  const hasImage = Boolean(heroImg || (prodImg && featureProduct));
45
45
  const slideshowView = heroSlideshowView(block, instanceKey, basePath);
46
46
  const slides = slideshowView.slides;
47
+ const bannerFrame = heroFrame(block.width, block.height);
48
+ const featureFrame = heroFrame(block.width, block.height, 1600, 1000);
47
49
  const slideshow = slides.length > 0;
48
50
  const slideId = slideshowView.id;
49
51
  const notice = block.notice?.trim();
@@ -76,11 +78,23 @@ const notice = block.notice?.trim();
76
78
  checked={slide.checked}
77
79
  aria-label={slide.radioLabel}
78
80
  />
79
- <div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget}>
81
+ <div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget} style={`--hero-aspect: ${slide.aspectRatio}`}>
80
82
  {slide.ctaHref ? (
81
83
  <a href={slide.href} aria-label={slide.ctaLabel ?? slide.headline ?? slide.imageAlt} class="block w-full">
82
- <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
84
+ {slide.imageMobile ? (
85
+ <picture>
86
+ <source media={HERO_PHONE_MEDIA} srcset={slide.imageMobile} />
87
+ <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
88
+ </picture>
89
+ ) : (
90
+ <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
91
+ )}
83
92
  </a>
93
+ ) : slide.imageMobile ? (
94
+ <picture>
95
+ <source media={HERO_PHONE_MEDIA} srcset={slide.imageMobile} />
96
+ <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
97
+ </picture>
84
98
  ) : (
85
99
  <img src={slide.image} alt={slide.imageAlt} width={slide.width} height={slide.height} loading={slide.loading} fetchpriority={slide.fetchpriority} decoding={slide.decoding} class="hero-slideshow__image" />
86
100
  )}
@@ -108,23 +122,53 @@ const notice = block.notice?.trim();
108
122
  aria-label={block.ctaLabel ?? block.headline}
109
123
  class="block w-full"
110
124
  >
125
+ {block.imageMobile ? (
126
+ <picture>
127
+ <source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
128
+ <img
129
+ src={block.image}
130
+ alt={block.imageAlt ?? ""}
131
+ width={bannerFrame.width}
132
+ height={bannerFrame.height}
133
+ loading="eager"
134
+ fetchpriority="high"
135
+ decoding="sync"
136
+ class="block h-auto w-full"
137
+ />
138
+ </picture>
139
+ ) : (
140
+ <img
141
+ src={block.image}
142
+ alt={block.imageAlt ?? ""}
143
+ width={bannerFrame.width}
144
+ height={bannerFrame.height}
145
+ loading="eager"
146
+ fetchpriority="high"
147
+ decoding="sync"
148
+ class="block h-auto w-full"
149
+ />
150
+ )}
151
+ </a>
152
+ ) : block.imageMobile ? (
153
+ <picture>
154
+ <source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
111
155
  <img
112
156
  src={block.image}
113
- alt={block.imageAlt ?? ""}
114
- width={1920}
115
- height={650}
157
+ alt={block.imageAlt ?? block.headline}
158
+ width={bannerFrame.width}
159
+ height={bannerFrame.height}
116
160
  loading="eager"
117
161
  fetchpriority="high"
118
162
  decoding="sync"
119
163
  class="block h-auto w-full"
120
164
  />
121
- </a>
165
+ </picture>
122
166
  ) : (
123
167
  <img
124
168
  src={block.image}
125
169
  alt={block.imageAlt ?? block.headline}
126
- width={1920}
127
- height={650}
170
+ width={bannerFrame.width}
171
+ height={bannerFrame.height}
128
172
  loading="eager"
129
173
  fetchpriority="high"
130
174
  decoding="sync"
@@ -226,16 +270,32 @@ const notice = block.notice?.trim();
226
270
  width/height attributes mean the final render still uses the
227
271
  image's natural ratio; the dims below only size the pre-load box.
228
272
  */}
229
- <img
230
- src={heroImg}
231
- alt={block.imageAlt ?? block.headline}
232
- width={1600}
233
- height={1000}
234
- loading="eager"
235
- fetchpriority="high"
236
- decoding="sync"
237
- class="h-full w-full object-cover"
238
- />
273
+ {block.imageMobile ? (
274
+ <picture>
275
+ <source media={HERO_PHONE_MEDIA} srcset={block.imageMobile} />
276
+ <img
277
+ src={heroImg}
278
+ alt={block.imageAlt ?? block.headline}
279
+ width={featureFrame.width}
280
+ height={featureFrame.height}
281
+ loading="eager"
282
+ fetchpriority="high"
283
+ decoding="sync"
284
+ class="h-full w-full object-cover"
285
+ />
286
+ </picture>
287
+ ) : (
288
+ <img
289
+ src={heroImg}
290
+ alt={block.imageAlt ?? block.headline}
291
+ width={featureFrame.width}
292
+ height={featureFrame.height}
293
+ loading="eager"
294
+ fetchpriority="high"
295
+ decoding="sync"
296
+ class="h-full w-full object-cover"
297
+ />
298
+ )}
239
299
  </div>
240
300
  </div>
241
301
  ) : (
@@ -289,11 +349,13 @@ const notice = block.notice?.trim();
289
349
 
290
350
  <style>
291
351
  .hero-slideshow { position: relative; overflow: hidden; }
292
- .hero-slideshow__slides { display: grid; aspect-ratio: 1920 / 650; background: var(--color-bg); }
352
+ .hero-slideshow__slides { display: grid; background: var(--color-bg); }
293
353
  .hero-slideshow__radio { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
294
- .hero-slideshow__slide { grid-area: 1 / 1; display: none; min-width: 0; }
295
- .hero-slideshow__radio:checked + .hero-slideshow__slide { display: grid; }
296
- .hero-slideshow__slide > a { min-width: 0; }
354
+ .hero-slideshow__slide { grid-area: 1 / 1; display: none; width: 100%; height: auto; align-self: start; justify-self: stretch; min-width: 0; aspect-ratio: var(--hero-aspect, 1920 / 650); }
355
+ .hero-slideshow__radio:checked + .hero-slideshow__slide { display: block; }
356
+ .hero-slideshow__slide > a,
357
+ .hero-slideshow__slide > picture,
358
+ .hero-slideshow__slide > a > picture { display: block; width: 100%; height: 100%; min-width: 0; }
297
359
  .hero-slideshow__image { display: block; width: 100%; height: 100%; object-fit: contain; }
298
360
  .hero-slideshow__controls { position: absolute; inset-inline: 0; bottom: 1rem; z-index: 1; display: flex; justify-content: center; gap: .5rem; }
299
361
  .hero-slideshow__controls label { display: grid; place-items: center; inline-size: 2.75rem; block-size: 2.75rem; border-radius: 999px; background: color-mix(in srgb, var(--color-bg) 86%, transparent); color: var(--color-text); cursor: pointer; font: 600 .75rem/1 var(--font-mono); }
@@ -16,26 +16,54 @@ export function nextHeroSlideIndex(current: number, slideCount: number) {
16
16
  return current < 0 ? 0 : (current + 1) % slideCount;
17
17
  }
18
18
 
19
+ /** Reserved slideshow box when a slide does not declare its own frame. */
20
+ export const HERO_FRAME_WIDTH = 1920;
21
+ export const HERO_FRAME_HEIGHT = 650;
22
+
23
+ /** Matches the slideshow's own phone layout. One `<source>` so that viewport fetches one file. */
24
+ export const HERO_PHONE_MEDIA = "(max-width: 40rem)";
25
+
26
+ export function heroFrame(
27
+ width: number | undefined,
28
+ height: number | undefined,
29
+ fallbackWidth = HERO_FRAME_WIDTH,
30
+ fallbackHeight = HERO_FRAME_HEIGHT,
31
+ ) {
32
+ const declared = typeof width === "number"
33
+ && typeof height === "number"
34
+ && Number.isFinite(width)
35
+ && Number.isFinite(height)
36
+ && width > 0
37
+ && height > 0;
38
+ const frameWidth = declared ? width : fallbackWidth;
39
+ const frameHeight = declared ? height : fallbackHeight;
40
+ return { width: frameWidth, height: frameHeight, aspectRatio: `${frameWidth} / ${frameHeight}` };
41
+ }
42
+
19
43
  export function heroSlideshowView(block: HeroBlock, instanceKey: string, basePath: string) {
20
44
  const slides = block.slides ?? [];
21
45
  return {
22
46
  id: `hero-slides-${instanceKey}`,
23
47
  autoplay: slides.length > 1,
24
48
  intervalMs: 6_000,
25
- slides: slides.map((slide, index) => ({
26
- ...slide,
27
- href: slide.ctaHref ? withBase(basePath, slide.ctaHref) : undefined,
28
- checked: index === 0,
29
- loading: index === 0 ? ("eager" as const) : ("lazy" as const),
30
- fetchpriority: index === 0 ? ("high" as const) : ("auto" as const),
31
- decoding: index === 0 ? ("sync" as const) : ("async" as const),
32
- width: 1920,
33
- height: 650,
34
- radioId: `hero-slides-${instanceKey}-${index}`,
35
- radioLabel: `Show slide ${index + 1}: ${slide.headline ?? slide.imageAlt}`,
36
- groupRole: "group" as const,
37
- groupLabel: `${index + 1} of ${slides.length}`,
38
- focusTarget: "slide" as const,
39
- })),
49
+ slides: slides.map((slide, index) => {
50
+ const frame = heroFrame(slide.width, slide.height);
51
+ return {
52
+ ...slide,
53
+ href: slide.ctaHref ? withBase(basePath, slide.ctaHref) : undefined,
54
+ checked: index === 0,
55
+ loading: index === 0 ? ("eager" as const) : ("lazy" as const),
56
+ fetchpriority: index === 0 ? ("high" as const) : ("auto" as const),
57
+ decoding: index === 0 ? ("sync" as const) : ("async" as const),
58
+ width: frame.width,
59
+ height: frame.height,
60
+ aspectRatio: frame.aspectRatio,
61
+ radioId: `hero-slides-${instanceKey}-${index}`,
62
+ radioLabel: `Show slide ${index + 1}: ${slide.headline ?? slide.imageAlt}`,
63
+ groupRole: "group" as const,
64
+ groupLabel: `${index + 1} of ${slides.length}`,
65
+ focusTarget: "slide" as const,
66
+ };
67
+ }),
40
68
  };
41
69
  }
@@ -73,11 +73,21 @@ export interface HeroBlock {
73
73
  featureCollection?: string;
74
74
  /** Direct hero image URL (e.g. the merchant's own homepage hero slide). */
75
75
  image?: string;
76
+ /** Phone asset for the single image. The desktop `image` stays the fallback. */
77
+ imageMobile?: string;
76
78
  imageAlt?: string;
79
+ /** Intrinsic frame for the single image. Both are required together. */
80
+ width?: number;
81
+ height?: number;
77
82
  /** Optional static, governed campaign slides. The first slide is the LCP image. */
78
83
  slides?: Array<{
79
84
  image: string;
80
85
  imageAlt: string;
86
+ /** Phone asset. The desktop `image` stays the fallback. */
87
+ imageMobile?: string;
88
+ /** Intrinsic frame. Both are required together; omitted keeps the reserved box. */
89
+ width?: number;
90
+ height?: number;
81
91
  headline?: string;
82
92
  ctaLabel?: string;
83
93
  ctaHref?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.12.0",
3
+ "version": "3.12.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -492,6 +492,19 @@ function validatePromoTiles(block, file, at, out, tenantId) {
492
492
  }
493
493
  }
494
494
 
495
+ function validateHeroMediaFields(owner, file, at, out, tenantId) {
496
+ if ((owner.width === undefined) !== (owner.height === undefined)) out.push(mk(ERROR, "block-prop", file, `${at}.width and ${at}.height must be supplied together`));
497
+ for (const prop of ["width", "height"]) {
498
+ if (owner[prop] !== undefined && (typeof owner[prop] !== "number" || !Number.isInteger(owner[prop]) || owner[prop] <= 0)) {
499
+ out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a positive integer`));
500
+ }
501
+ }
502
+ if (owner.imageMobile !== undefined) {
503
+ if (typeof owner.imageMobile !== "string" || !owner.imageMobile.trim()) out.push(mk(ERROR, "block-prop", file, `${at}.imageMobile must be a non-empty string`));
504
+ else validateVisualAsset(owner.imageMobile, file, `${at}.imageMobile`, out, tenantId);
505
+ }
506
+ }
507
+
495
508
  function validateHeroSlides(slides, file, at, out, tenantId) {
496
509
  if (!Array.isArray(slides) || slides.length === 0) { out.push(mk(ERROR, "block-prop", file, `${at}.slides must be a non-empty array`)); return; }
497
510
  slides.forEach((slide, index) => {
@@ -499,6 +512,7 @@ function validateHeroSlides(slides, file, at, out, tenantId) {
499
512
  if (slide == null || typeof slide !== "object" || Array.isArray(slide)) { out.push(mk(ERROR, "block-prop", file, `${slideAt} must be an object`)); return; }
500
513
  for (const prop of ["image", "imageAlt"]) if (typeof slide[prop] !== "string" || !slide[prop].trim()) out.push(mk(ERROR, "block-required", file, `${slideAt}.${prop} must be a non-empty string`));
501
514
  validateVisualAsset(slide.image, file, `${slideAt}.image`, out, tenantId);
515
+ validateHeroMediaFields(slide, file, slideAt, out, tenantId);
502
516
  for (const prop of ["headline", "ctaLabel", "ctaHref"]) if (slide[prop] !== undefined && (typeof slide[prop] !== "string" || !slide[prop].trim())) out.push(mk(ERROR, "block-prop", file, `${slideAt}.${prop} must be a non-empty string`));
503
517
  if ((slide.ctaLabel === undefined) !== (slide.ctaHref === undefined)) out.push(mk(ERROR, "block-required", file, `${slideAt}.ctaLabel and ${slideAt}.ctaHref must be supplied together`));
504
518
  if (typeof slide.ctaHref === "string" && !isSafeStorefrontHref(slide.ctaHref)) out.push(mk(ERROR, "navigation-href", file, `${slideAt}.ctaHref must be a same-storefront root-relative path`));
@@ -601,6 +615,7 @@ function validateHomeDoc(doc, file, tenantId) {
601
615
  if (component === "hero" && block.image !== undefined) {
602
616
  validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
603
617
  }
618
+ if (component === "hero") validateHeroMediaFields(block, file, at, out, tenantId);
604
619
  if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
605
620
  if (component === "hero" && block.notice !== undefined && (typeof block.notice !== "string" || !block.notice.trim())) {
606
621
  out.push(mk(ERROR, "block-prop", file, `${at}.notice must be a non-empty plain-text string`));