@tokenoftrust/storefront-runner 2.0.41 → 2.0.43

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.
Files changed (148) hide show
  1. package/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
  2. package/apps/storefront/package.json +1 -0
  3. package/apps/storefront/platform-reconcile-tenants.json +3 -0
  4. package/apps/storefront/public/favicon.ico +0 -0
  5. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  6. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  7. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  8. package/apps/storefront/src/components/Img.astro +6 -5
  9. package/apps/storefront/src/components/ProductCard.astro +6 -4
  10. package/apps/storefront/src/components/Seo.astro +2 -1
  11. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  12. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  13. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  14. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  15. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  16. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  17. package/apps/storefront/src/components/home/Hero.astro +156 -4
  18. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  19. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  20. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  21. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  22. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  23. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  24. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  25. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  26. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  27. package/apps/storefront/src/config/tenants.ts +22 -0
  28. package/apps/storefront/src/layouts/Layout.astro +69 -44
  29. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  31. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  32. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  33. package/apps/storefront/src/lib/basePath.ts +70 -9
  34. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  35. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  36. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  37. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  38. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  39. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  40. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  41. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  42. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  43. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  44. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  45. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  46. package/apps/storefront/src/lib/diag/report.ts +109 -0
  47. package/apps/storefront/src/lib/diag/session.ts +93 -0
  48. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  49. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  50. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  51. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  52. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  53. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  54. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  55. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  57. package/apps/storefront/src/lib/previewReload.ts +53 -9
  58. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  59. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  60. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  61. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  62. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  63. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  64. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  65. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  66. package/apps/storefront/src/middleware/index.ts +13 -1
  67. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  68. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  69. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  70. package/apps/storefront/src/pages/[...slug].astro +6 -2
  71. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  72. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  73. package/apps/storefront/src/pages/cart.ts +23 -0
  74. package/apps/storefront/src/pages/index.astro +64 -85
  75. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  76. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  77. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  78. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  79. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  80. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  81. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  82. package/apps/storefront/src/styles/fonts.css +7 -0
  83. package/apps/storefront/src/styles/global.css +397 -3
  84. package/package.json +4 -1
  85. package/packages/cli/src/visual-parity.mjs +321 -0
  86. package/packages/public-runtime/package.json +7 -0
  87. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  88. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  89. package/packages/public-runtime/src/chrome.ts +44 -7
  90. package/packages/public-runtime/src/csp.ts +12 -1
  91. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  92. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  93. package/packages/public-runtime/src/extension-contract.ts +20 -6
  94. package/packages/public-runtime/src/image-dependencies.ts +1438 -0
  95. package/packages/public-runtime/src/index.ts +8 -0
  96. package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
  97. package/packages/public-runtime/src/integration/consent-config.ts +9 -0
  98. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
  99. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  100. package/packages/public-runtime/src/integration/index.ts +1 -0
  101. package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
  102. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  104. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  105. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
  108. package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
  109. package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
  110. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  111. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  112. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  113. package/packages/public-runtime/src/publication-space.ts +24 -3
  114. package/packages/public-runtime/src/static-bundle.ts +145 -26
  115. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  116. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  117. package/packages/public-runtime/src/tenant.ts +59 -1
  118. package/pnpm-lock.runner.yaml +19 -0
  119. package/pnpm-workspace.yaml +4 -0
  120. package/scripts/dev/git-status.mjs +1 -1
  121. package/scripts/dev/publish.mjs +6 -0
  122. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  123. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  124. package/scripts/tenant/validate.mjs +171 -8
  125. package/apps/storefront/src/lib/cfImage.ts +0 -55
  126. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  127. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  128. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  129. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  145. package/tenants/home/public/js/storefront.js +0 -129
  146. package/tenants/home/public/logo-wordmark.png +0 -0
  147. package/tenants/home/public/pages/storefront.css +0 -235
  148. package/tenants/home/theme.json +0 -6
@@ -35,6 +35,10 @@ interface Props {
35
35
  nav?: ChromeNavItem[];
36
36
  /** Search form action (GET). Omit to hide search. Mounts the live SearchTypeahead island. */
37
37
  searchAction?: string;
38
+ /** Search input placeholder (defaults to "Search products"). */
39
+ searchPlaceholder?: string;
40
+ /** Accessible search label (defaults to "Search"). */
41
+ searchLabel?: string;
38
42
  /** Announcement text surfaced inside the mobile drawer (the desktop banner is a Layout-level sibling, e.g. <AnnouncementBar>). */
39
43
  announcement?: string;
40
44
  /** Generic account-icon link (no session awareness). Prefer `authLink` for a real session-aware entry point — passing both renders two affordances. */
@@ -69,6 +73,8 @@ const {
69
73
  brandHref,
70
74
  nav = [],
71
75
  searchAction,
76
+ searchPlaceholder,
77
+ searchLabel,
72
78
  announcement,
73
79
  accountHref,
74
80
  authLink,
@@ -87,6 +93,7 @@ const homeHref = withBase(basePath, brandHref ?? "/");
87
93
  const showLogo = !!logo && /^(https?:|\/)/.test(logo.src);
88
94
  const dark = variant === "dark";
89
95
  const minimal = layout === "minimal";
96
+ const compact = layout === "compact-centered";
90
97
 
91
98
  const ctaClass = (v?: string) =>
92
99
  v === "secondary" || v === "ghost"
@@ -120,7 +127,7 @@ const mobileNav = nav.map((item) => ({
120
127
  const currentPath = Astro.url.pathname;
121
128
  ---
122
129
 
123
- <header class:list={[sticky && "sticky top-0 z-40", className]}>
130
+ <header class:list={[sticky && !compact && "sticky top-0 z-40", className]} data-layout={layout}>
124
131
  <div
125
132
  class:list={[
126
133
  "border-b backdrop-blur",
@@ -129,11 +136,26 @@ const currentPath = Astro.url.pathname;
129
136
  : "border-border bg-surface/95 text-text",
130
137
  ]}
131
138
  >
132
- <div class="container-prose flex h-16 items-center gap-4">
139
+ <div
140
+ class:list={[
141
+ "container-prose h-16 items-center gap-4",
142
+ compact ? "relative grid grid-cols-[1fr_auto_1fr]" : "flex",
143
+ ]}
144
+ >
145
+ {
146
+ compact && nav.length > 0 && (
147
+ <div class="hidden min-w-0 md:block">
148
+ <MegaMenu items={nav} />
149
+ </div>
150
+ )
151
+ }
133
152
  <!-- Logo / wordmark -->
134
153
  <a
135
154
  href={homeHref}
136
- class="flex shrink-0 items-center transition-opacity hover:opacity-80"
155
+ class:list={[
156
+ "flex shrink-0 items-center transition-opacity hover:opacity-80",
157
+ compact && "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center",
158
+ ]}
137
159
  aria-label={logo?.alt ?? wordmark ?? "Home"}
138
160
  >
139
161
  {
@@ -142,9 +164,23 @@ const currentPath = Astro.url.pathname;
142
164
  Brand logo: a bare merchant URL shown at a fixed header height via
143
165
  object-contain. It can't go through <Img> — that forces an
144
166
  aspect-ratio crop box + a placeholder background, which would alter
145
- a transparent logo's appearance. Keep raw, but set explicit
146
- width/height (reserve space → CLS-free) and, since it sits
147
- above the fold, eager/async loading hints.
167
+ a transparent logo's appearance.
168
+
169
+ BOTH dimensions are pinned in CSS, deliberately. `h-9 w-auto` looks
170
+ like it reserves space — the width/height attributes give an
171
+ aspect ratio — but the width still RESOLVES from the image: the
172
+ declared ratio before load, the real file's ratio after. When those
173
+ disagree the header resizes on load and everything below it jumps.
174
+ Measured 2026-09-21: declared 176x69, rendered 87x34, and the
175
+ resulting reflow of an 18,666px <main> scored CLS 0.82 — 96% of the
176
+ page's total, and enough to make the whole run unmeasurable.
177
+
178
+ Computing the width here from the DECLARED dimensions makes the box
179
+ a pure function of config: identical before and after load, for
180
+ every tenant, whether or not their declared size matches the file.
181
+ object-contain then letterboxes any mismatch instead of resizing
182
+ the box, which is exactly the failure mode we can never see coming
183
+ because it depends on a merchant's asset.
148
184
  */
149
185
  <img
150
186
  src={logo!.src}
@@ -153,7 +189,11 @@ const currentPath = Astro.url.pathname;
153
189
  height={logo!.height ?? 48}
154
190
  loading="eager"
155
191
  decoding="async"
156
- class="h-9 w-auto max-w-[180px] object-contain"
192
+ class="h-9 object-contain"
193
+ style={`width:${Math.min(
194
+ 180,
195
+ Math.round((36 * (logo!.width ?? 160)) / (logo!.height ?? 48)),
196
+ )}px`}
157
197
  />
158
198
  ) : (
159
199
  <span class="font-display text-lg font-bold tracking-tight">
@@ -165,15 +205,34 @@ const currentPath = Astro.url.pathname;
165
205
 
166
206
  <!-- Search (live typeahead island) -->
167
207
  {
168
- !minimal && searchAction && (
208
+ !minimal && !compact && searchAction && (
169
209
  <div class="hidden flex-1 md:flex">
170
- <SearchTypeahead client:idle action={searchAction} />
210
+ <SearchTypeahead
211
+ client:idle
212
+ action={searchAction}
213
+ placeholder={searchPlaceholder}
214
+ label={searchLabel}
215
+ />
171
216
  </div>
172
217
  )
173
218
  }
174
219
 
175
220
  <!-- Actions: saved items + marketing CTAs + auth + account + cart (link only) + mobile menu -->
176
- <div class="ml-auto flex items-center gap-1">
221
+ <div class:list={["flex items-center gap-1", compact ? "col-start-3 justify-self-end" : "ml-auto"]}>
222
+ {
223
+ compact && searchAction && (
224
+ <a
225
+ href={withBase(basePath, searchAction)}
226
+ aria-label={searchLabel ?? "Search"}
227
+ class="hidden h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent md:grid"
228
+ >
229
+ <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
230
+ <circle cx="11" cy="11" r="7" />
231
+ <path d="M21 21l-4.3-4.3" />
232
+ </svg>
233
+ </a>
234
+ )
235
+ }
177
236
  {
178
237
  !minimal && savedItemsHref && (
179
238
  <a
@@ -247,7 +306,7 @@ const currentPath = Astro.url.pathname;
247
306
  cartHref && (
248
307
  <a
249
308
  href={withBase(basePath, cartHref)}
250
- aria-label="Cart"
309
+ aria-label={`Cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}
251
310
  class="relative grid h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent"
252
311
  >
253
312
  <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
@@ -269,7 +328,7 @@ const currentPath = Astro.url.pathname;
269
328
  <!-- Mobile menu: the MobileNav island (focus-trap, scroll-lock, Escape-to-close) -->
270
329
  {
271
330
  !minimal && nav.length > 0 && (
272
- <div class="md:hidden">
331
+ <div class:list={["md:hidden", compact && "absolute left-0 top-1/2 -translate-y-1/2"]}>
273
332
  <MobileNav client:idle nav={mobileNav} announcement={announcement} currentPath={currentPath} />
274
333
  </div>
275
334
  )
@@ -288,7 +347,7 @@ const currentPath = Astro.url.pathname;
288
347
  [data-mobile-nav-fallback] effect) so JS-enabled visitors get the drawer
289
348
  as the primary/enhanced experience; this is additive, not a replacement. -->
290
349
  {
291
- !minimal && nav.length > 0 && (
350
+ !minimal && !compact && nav.length > 0 && (
292
351
  <div class="border-b border-border bg-surface">
293
352
  <div class="container-prose">
294
353
  <MegaMenu items={nav} />
@@ -310,3 +369,14 @@ const currentPath = Astro.url.pathname;
310
369
  )
311
370
  }
312
371
  </header>
372
+
373
+ {
374
+ compact && (
375
+ <nav class:list={["fixed inset-x-0 bottom-0 z-50 grid h-[62px] border-t border-border bg-surface shadow-[0_-3px_14px_color-mix(in_srgb,var(--color-text)_10%,transparent)] md:hidden", cartHref ? "grid-cols-4" : "grid-cols-3"]} aria-label="Quick links">
376
+ <a href={homeHref} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Home</a>
377
+ <a href={withBase(basePath, searchAction ?? "/search")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Search</a>
378
+ <a href={accountHref ? withBase(basePath, accountHref) : (authLink?.href ?? "/auth/login")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Account</a>
379
+ {cartHref && <a href={withBase(basePath, cartHref)} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Cart</a>}
380
+ </nav>
381
+ )
382
+ }
@@ -39,7 +39,7 @@ const label =
39
39
  : `Rated ${clamped} out of ${max}`;
40
40
  ---
41
41
 
42
- <span class:list={["inline-flex items-center gap-1.5", className]} aria-label={label}>
42
+ <span class:list={["inline-flex items-center gap-1.5", className]} role="img" aria-label={label}>
43
43
  <span class:list={["relative inline-block leading-none tracking-[0.08em]", dim]} aria-hidden="true">
44
44
  <span class="text-[var(--color-border)]">{stars}</span>
45
45
  <span
@@ -56,7 +56,7 @@ const TONE_TEXT: Record<Tone, string> = {
56
56
  aria-label={label}
57
57
  data-compliance={hook}
58
58
  >
59
- <p class={`container-prose py-2 text-center text-[0.72rem] font-semibold uppercase tracking-[0.05em] sm:text-xs ${TONE_TEXT[tone]}`}>
59
+ <p class={`container-prose py-2 text-center text-sm font-semibold uppercase tracking-[0.05em] ${TONE_TEXT[tone]}`}>
60
60
  {symbol && <span aria-hidden="true" class="mr-1">{symbol}</span>}
61
61
  <slot />
62
62
  </p>
@@ -64,7 +64,7 @@ const TONE_TEXT: Record<Tone, string> = {
64
64
  ) : variant === "footer" ? (
65
65
  <div class="border-t border-[var(--color-border)] bg-[var(--color-bg)]" data-compliance={hook}>
66
66
  <p
67
- class="container-prose py-3 text-center text-xs text-[var(--color-muted)]"
67
+ class="container-prose py-3 text-center text-sm text-[var(--color-muted)]"
68
68
  role="note"
69
69
  aria-label={label}
70
70
  >
@@ -0,0 +1,107 @@
1
+ ---
2
+ /** Static, provider-neutral commerce promo presentations. */
3
+ import Section from "@/components/Section.astro";
4
+ import type { PromoTilesBlock } from "@/lib/storyblok/content-model";
5
+ import { promoTilesView } from "@/lib/homeVisualParity";
6
+
7
+ interface Props {
8
+ block: PromoTilesBlock;
9
+ basePath: string;
10
+ tenantId: string;
11
+ }
12
+
13
+ const { block, basePath, tenantId } = Astro.props;
14
+ const view = promoTilesView(block, basePath, tenantId);
15
+ const railId = `promo-${tenantId.replace(/[^a-z0-9]+/gi, "-")}-${(block.title ?? "tiles").replace(/[^a-z0-9]+/gi, "-")}`;
16
+ ---
17
+
18
+ <Section title={block.title} eyebrow={view.eyebrow} tone="bg" class={view.isLogoStrip ? "py-8" : undefined}>
19
+ <div
20
+ id={view.isPortrait ? railId : undefined}
21
+ class={view.gridClass}
22
+ data-presentation={view.presentation}
23
+ aria-label={view.isPortrait ? (block.title ?? "Featured collections") : undefined}
24
+ >
25
+ {view.tiles.map((tile) => (
26
+ <a
27
+ href={tile.href}
28
+ class:list={[
29
+ "group block",
30
+ view.isOverlay && "relative aspect-[16/9] overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]",
31
+ view.isLogoStrip && "flex min-h-16 items-center justify-center px-3 py-2",
32
+ view.isPortrait && "promo-portrait-card snap-start",
33
+ ]}
34
+ >
35
+ {tile.image && (
36
+ <img
37
+ src={tile.image}
38
+ alt={tile.imageAlt ?? tile.label}
39
+ width={tile.imageWidth}
40
+ height={tile.imageHeight}
41
+ loading="lazy"
42
+ class:list={tile.imageClass}
43
+ />
44
+ )}
45
+ {view.isOverlay ? (
46
+ <span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-4 font-display text-lg font-semibold text-white">
47
+ {tile.label}
48
+ </span>
49
+ ) : !view.isLogoStrip ? (
50
+ <span class="mt-3 block">
51
+ <span class="block font-display text-base font-semibold text-[var(--color-text)]">{tile.label}</span>
52
+ {tile.meta && <span class="mt-1 block text-sm text-[var(--color-muted)]">{tile.meta}</span>}
53
+ </span>
54
+ ) : (
55
+ <span class="sr-only">{tile.label}</span>
56
+ )}
57
+ </a>
58
+ ))}
59
+ </div>
60
+ {view.isPortrait && view.tiles.length > 1 && (
61
+ <div class="promo-portrait-dots" aria-hidden="true">
62
+ {view.tiles.map(() => <span />)}
63
+ </div>
64
+ )}
65
+ {view.cta && (
66
+ <div class="mt-8 text-center">
67
+ <a
68
+ href={view.cta.href}
69
+ class="link-accent link-accent--cta font-display text-sm font-medium"
70
+ >
71
+ {view.cta.label} →
72
+ </a>
73
+ </div>
74
+ )}
75
+ </Section>
76
+
77
+ <style>
78
+ /* Fixed track widths, not `minmax(0, …)`: a shrinkable minimum lets the grid
79
+ squeeze every card into the container instead of overflowing, which is what
80
+ makes this a scroll-snap rail at all. */
81
+ .promo-portrait-rail {
82
+ display: grid;
83
+ grid-auto-flow: column;
84
+ grid-auto-columns: 78%;
85
+ gap: 1rem;
86
+ overflow-x: auto;
87
+ overscroll-behavior-inline: contain;
88
+ padding: .125rem max(1rem, calc((100vw - var(--container, 80rem)) / 2));
89
+ margin-inline: min(-1rem, calc((var(--container, 80rem) - 100vw) / 2));
90
+ scroll-padding-inline: 1rem;
91
+ scroll-snap-type: inline mandatory;
92
+ scrollbar-width: none;
93
+ }
94
+ .promo-portrait-rail::-webkit-scrollbar { display: none; }
95
+ .promo-portrait-card { min-width: 0; }
96
+ /* Position indicators, not controls: one dot per card is ~9px wide at any
97
+ realistic card count, far under the 44px tap minimum, and every card it
98
+ would scroll to is already a focusable link in the rail above. */
99
+ .promo-portrait-dots { display: flex; justify-content: center; gap: .45rem; margin-top: 1.25rem; }
100
+ .promo-portrait-dots span { inline-size: .55rem; block-size: .55rem; border-radius: 999px; background: color-mix(in srgb, var(--color-text) 24%, transparent); }
101
+ @media (min-width: 40rem) {
102
+ .promo-portrait-rail { grid-auto-columns: 42%; }
103
+ }
104
+ @media (min-width: 64rem) {
105
+ .promo-portrait-rail { grid-auto-columns: calc((100% - 4rem) / 5); scroll-padding-inline: 0; padding-inline: 0; margin-inline: 0; }
106
+ }
107
+ </style>
@@ -9,36 +9,52 @@
9
9
  * data-widget="trust-strip".
10
10
  */
11
11
  import type { TrustStripItem } from "@/lib/content/trustStrip";
12
- import { sanitizeTrustStrip, ratingAriaLabel } from "@/lib/content/trustStrip";
12
+ import { orderTrustStripForMobile, sanitizeTrustStrip, ratingAriaLabel } from "@/lib/content/trustStrip";
13
13
 
14
14
  interface Props {
15
15
  items: TrustStripItem[];
16
+ tenantId?: string;
17
+ mobile?: { hidden?: boolean; order?: string[] };
16
18
  }
17
- const items = sanitizeTrustStrip(Astro.props.items);
19
+ const items = sanitizeTrustStrip(Astro.props.items, Astro.props.tenantId);
20
+ const mobileItems = orderTrustStripForMobile(items, Astro.props.mobile?.order);
18
21
  ---
19
22
  {
20
23
  items.length > 0 && (
21
24
  <div
22
- class="border-y border-[var(--color-border)] bg-[var(--color-surface)]"
25
+ class:list={["border-y border-[var(--color-border)] bg-[var(--color-surface)]", Astro.props.mobile?.hidden && "max-md:hidden"]}
23
26
  data-widget="trust-strip"
24
27
  >
25
- <ul class="container-prose flex flex-wrap items-center justify-center gap-x-6 gap-y-2 py-4 text-center font-mono text-[0.72rem] uppercase tracking-[0.1em] text-[var(--color-muted)]">
28
+ <ul class="container-prose hidden items-stretch text-center text-sm text-[var(--color-text)] md:grid md:grid-cols-4">
26
29
  {items.map((item) =>
27
30
  item.kind === "rating" ? (
28
- <li
29
- class="flex items-center gap-1.5 text-[var(--color-text)]"
30
- data-trust="rating"
31
- >
31
+ <li class="flex min-h-16 items-center justify-center gap-1.5 border-[var(--color-border)] px-4 py-3 md:[&:not(:last-child)]:border-r" data-trust="rating">
32
32
  <span aria-hidden="true" class="text-[var(--color-warning)]">★</span>
33
- <span aria-hidden="true">
34
- {Math.round(item.average * 10) / 10}
35
- {item.label ? ` ${item.label}` : ` (${item.count})`}
36
- </span>
33
+ <span aria-hidden="true">{Math.round(item.average * 10) / 10}{item.label ? ` ${item.label}` : ` (${item.count})`}</span>
37
34
  <span class="sr-only">{ratingAriaLabel(item)}</span>
38
35
  </li>
39
36
  ) : (
40
- <li data-trust="claim">{item.label}</li>
41
- ),
37
+ <li class="flex min-h-16 items-center justify-center gap-3 border-[var(--color-border)] px-4 py-3 md:[&:not(:last-child)]:border-r" data-trust="claim">
38
+ {item.icon && <img src={item.icon} alt={item.iconAlt ?? ""} width="28" height="28" loading="lazy" class="h-7 w-7 shrink-0 object-contain" />}
39
+ <span>{item.label}</span>
40
+ </li>
41
+ )
42
+ )}
43
+ </ul>
44
+ <ul class="container-prose grid grid-cols-2 items-stretch text-center text-sm text-[var(--color-text)] md:hidden">
45
+ {mobileItems.map((item) =>
46
+ item.kind === "rating" ? (
47
+ <li class="flex min-h-16 items-center justify-center gap-1.5 border-[var(--color-border)] px-4 py-3 [&:nth-child(odd)]:border-r" data-trust="rating">
48
+ <span aria-hidden="true" class="text-[var(--color-warning)]">★</span>
49
+ <span aria-hidden="true">{Math.round(item.average * 10) / 10}{item.label ? ` ${item.label}` : ` (${item.count})`}</span>
50
+ <span class="sr-only">{ratingAriaLabel(item)}</span>
51
+ </li>
52
+ ) : (
53
+ <li class="flex min-h-16 items-center justify-center gap-3 border-[var(--color-border)] px-4 py-3 [&:nth-child(odd)]:border-r" data-trust="claim">
54
+ {item.icon && <img src={item.icon} alt={item.iconAlt ?? ""} width="28" height="28" loading="lazy" class="h-7 w-7 shrink-0 object-contain" />}
55
+ <span>{item.label}</span>
56
+ </li>
57
+ )
42
58
  )}
43
59
  </ul>
44
60
  </div>
@@ -1,31 +1,117 @@
1
1
  ---
2
2
  /**
3
3
  * Homepage hero — the SIGNATURE moment (brief §6: "hero is a thesis", spend
4
- * boldness in ONE place). Two treatments, by the tenant's brand.heroVariant:
4
+ * boldness in ONE place). It has a governed slideshow plus three heroVariant
5
+ * treatments:
6
+ * - non-empty `block.slides`: a static, accessible slideshow whose first
7
+ * rehosted slide is the LCP image. Slides take precedence over heroVariant.
5
8
  * - "bold" (default): full-bleed primary background, light contrast text.
6
9
  * - "light": hero on the page background, brand color as an accent on the
7
10
  * headline + CTA — matches merchants whose own site is a light page with
8
11
  * color accents. The brand adopter sets "light" when it captures a hero image.
9
- * The feature image is the merchant's own hero slide (block.image) when adopted,
12
+ * - "banner": a merchant-supplied wide campaign asset edge-to-edge, for
13
+ * visual-parity migrations where the artwork owns the visible copy.
14
+ * The feature image is the merchant's own hero image (block.image) when adopted,
10
15
  * else the hero collection's first product.
11
16
  */
12
17
  import type { HeroBlock } from "@/lib/storyblok/content-model";
13
18
  import type { CatalogProduct } from "@tot/public-runtime";
14
19
  import Img from "@/components/Img.astro";
15
20
  import { withBase } from "@/lib/basePath";
21
+ import { heroSlideshowView } from "@/lib/heroSlideshow";
16
22
 
17
23
  interface Props {
18
24
  block: HeroBlock;
19
25
  featureProduct?: CatalogProduct | null;
26
+ instanceKey?: string;
20
27
  }
21
- const { block, featureProduct } = Astro.props;
28
+ const { block, featureProduct, instanceKey = "home" } = Astro.props;
22
29
  const { basePath } = Astro.locals;
23
- const light = (Astro.locals.tenant?.theme_tokens?.brand?.heroVariant ?? "bold") === "light";
30
+ const heroVariant = Astro.locals.tenant?.theme_tokens?.brand?.heroVariant ?? "bold";
31
+ const light = heroVariant === "light";
32
+ const banner = heroVariant === "banner" && Boolean(block.image);
24
33
  const prodImg = featureProduct?.images[0];
25
34
  const heroImg = block.image;
26
35
  const hasImage = Boolean(heroImg || (prodImg && featureProduct));
36
+ const slideshowView = heroSlideshowView(block, instanceKey, basePath);
37
+ const slides = slideshowView.slides;
38
+ const slideshow = slides.length > 0;
39
+ const slideId = slideshowView.id;
27
40
  ---
28
41
 
42
+ {slideshow ? (
43
+ <section
44
+ class="hero-slideshow bg-[var(--color-bg)]"
45
+ aria-roledescription="carousel"
46
+ aria-label={block.headline}
47
+ data-hero-slideshow
48
+ data-autoplay={slideshowView.autoplay ? "true" : "false"}
49
+ data-interval={slideshowView.intervalMs}
50
+ >
51
+ <h1 class="sr-only">{block.headline}</h1>
52
+ <div class="hero-slideshow__slides">
53
+ {slides.map((slide) => (
54
+ <Fragment>
55
+ <input
56
+ class="hero-slideshow__radio"
57
+ type="radio"
58
+ name={slideId}
59
+ id={slide.radioId}
60
+ checked={slide.checked}
61
+ aria-label={slide.radioLabel}
62
+ />
63
+ <div class="hero-slideshow__slide" role={slide.groupRole} aria-roledescription="slide" aria-label={slide.groupLabel} data-focus-target={slide.focusTarget}>
64
+ {slide.ctaHref ? (
65
+ <a href={slide.href} aria-label={slide.ctaLabel ?? slide.headline ?? slide.imageAlt} class="block w-full">
66
+ <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" />
67
+ </a>
68
+ ) : (
69
+ <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" />
70
+ )}
71
+ </div>
72
+ </Fragment>
73
+ ))}
74
+ </div>
75
+ {slides.length > 1 && (
76
+ <div class="hero-slideshow__controls" aria-label="Hero slides">
77
+ {slides.map((slide, index) => <label for={slide.radioId}>{index + 1}<span class="sr-only">: {slide.headline ?? slide.imageAlt}</span></label>)}
78
+ </div>
79
+ )}
80
+ </section>
81
+ ) : banner ? (
82
+ <section class="bg-[var(--color-bg)]">
83
+ <h1 class="sr-only">{block.headline}</h1>
84
+ {block.ctaHref ? (
85
+ <a
86
+ href={withBase(basePath, block.ctaHref)}
87
+ aria-label={block.ctaLabel ?? block.headline}
88
+ class="block w-full"
89
+ >
90
+ <img
91
+ src={block.image}
92
+ alt={block.imageAlt ?? ""}
93
+ width={1920}
94
+ height={650}
95
+ loading="eager"
96
+ fetchpriority="high"
97
+ decoding="sync"
98
+ class="block h-auto w-full"
99
+ />
100
+ </a>
101
+ ) : (
102
+ <img
103
+ src={block.image}
104
+ alt={block.imageAlt ?? block.headline}
105
+ width={1920}
106
+ height={650}
107
+ loading="eager"
108
+ fetchpriority="high"
109
+ decoding="sync"
110
+ class="block h-auto w-full"
111
+ />
112
+ )}
113
+ </section>
114
+ ) : (
29
115
  <section
30
116
  class:list={[
31
117
  "relative overflow-hidden",
@@ -176,3 +262,69 @@ const hasImage = Boolean(heroImg || (prodImg && featureProduct));
176
262
  >
177
263
  </div>
178
264
  </section>
265
+ )}
266
+
267
+ <style>
268
+ .hero-slideshow { position: relative; overflow: hidden; }
269
+ .hero-slideshow__slides { display: grid; aspect-ratio: 1920 / 650; background: var(--color-bg); }
270
+ .hero-slideshow__radio { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
271
+ .hero-slideshow__slide { grid-area: 1 / 1; display: none; min-width: 0; }
272
+ .hero-slideshow__radio:checked + .hero-slideshow__slide { display: grid; }
273
+ .hero-slideshow__slide > a { min-width: 0; }
274
+ .hero-slideshow__image { display: block; width: 100%; height: 100%; object-fit: contain; }
275
+ .hero-slideshow__controls { position: absolute; inset-inline: 0; bottom: 1rem; z-index: 1; display: flex; justify-content: center; gap: .5rem; }
276
+ .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); }
277
+ .hero-slideshow__radio:focus-visible + .hero-slideshow__slide { outline: 2px solid var(--color-accent); outline-offset: -2px; }
278
+ @media (max-width: 40rem) {
279
+ .hero-slideshow__controls { bottom: .35rem; gap: .3rem; }
280
+ .hero-slideshow__controls label { inline-size: 2.75rem; block-size: 2.75rem; font-size: .65rem; }
281
+ }
282
+ @media (prefers-reduced-motion: reduce) { .hero-slideshow__image { scroll-behavior: auto; } }
283
+ </style>
284
+
285
+ <script>
286
+ import { heroAutoplayAllowed, nextHeroSlideIndex } from "@/lib/heroSlideshow";
287
+
288
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
289
+
290
+ for (const root of document.querySelectorAll<HTMLElement>("[data-hero-slideshow]")) {
291
+ const radios = Array.from(root.querySelectorAll<HTMLInputElement>(".hero-slideshow__radio"));
292
+ const interval = Number(root.dataset.interval) || 6000;
293
+ let timer: number | undefined;
294
+ let paused = false;
295
+
296
+ const stop = () => {
297
+ if (timer !== undefined) window.clearInterval(timer);
298
+ timer = undefined;
299
+ };
300
+ const start = () => {
301
+ stop();
302
+ if (!heroAutoplayAllowed({
303
+ enabled: root.dataset.autoplay === "true",
304
+ reducedMotion: reducedMotion.matches,
305
+ paused,
306
+ pageVisible: !document.hidden,
307
+ slideCount: radios.length,
308
+ })) return;
309
+ timer = window.setInterval(() => {
310
+ const current = radios.findIndex((radio) => radio.checked);
311
+ radios[nextHeroSlideIndex(current, radios.length)]?.click();
312
+ }, interval);
313
+ };
314
+
315
+ root.addEventListener("pointerenter", () => { paused = true; stop(); });
316
+ root.addEventListener("pointerleave", () => { paused = false; start(); });
317
+ root.addEventListener("focusin", () => { paused = true; stop(); });
318
+ root.addEventListener("focusout", (event) => {
319
+ if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return;
320
+ paused = false;
321
+ start();
322
+ });
323
+ root.addEventListener("change", (event) => {
324
+ if (event.target instanceof HTMLInputElement && event.target.matches(".hero-slideshow__radio")) start();
325
+ });
326
+ document.addEventListener("visibilitychange", () => document.hidden ? stop() : start());
327
+ reducedMotion.addEventListener("change", start);
328
+ start();
329
+ }
330
+ </script>