@tokenoftrust/storefront-runner 2.0.40 → 2.0.42

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 (150) 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/platform/google-measurement/v2/entry.mjs +1 -1
  6. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  7. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  8. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  9. package/apps/storefront/src/components/Img.astro +6 -5
  10. package/apps/storefront/src/components/ProductCard.astro +6 -4
  11. package/apps/storefront/src/components/Seo.astro +2 -1
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  14. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  15. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  16. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  17. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  18. package/apps/storefront/src/components/home/Hero.astro +156 -4
  19. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  20. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  21. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  22. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  23. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  24. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  25. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  26. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  27. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  28. package/apps/storefront/src/config/tenants.ts +22 -0
  29. package/apps/storefront/src/layouts/Layout.astro +69 -44
  30. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  31. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  32. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  33. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  34. package/apps/storefront/src/lib/basePath.ts +70 -9
  35. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  36. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  37. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  38. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  39. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  40. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  41. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  42. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  43. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  44. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  45. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  46. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  47. package/apps/storefront/src/lib/diag/report.ts +109 -0
  48. package/apps/storefront/src/lib/diag/session.ts +93 -0
  49. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  50. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  51. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  52. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  53. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  54. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  55. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  56. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  57. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  58. package/apps/storefront/src/lib/previewReload.ts +53 -9
  59. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  60. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  63. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  64. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  65. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  66. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  67. package/apps/storefront/src/middleware/index.ts +13 -1
  68. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  69. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  70. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  71. package/apps/storefront/src/pages/[...slug].astro +6 -2
  72. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  73. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  74. package/apps/storefront/src/pages/cart.ts +23 -0
  75. package/apps/storefront/src/pages/index.astro +64 -85
  76. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  77. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  78. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  79. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  80. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  81. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  82. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  83. package/apps/storefront/src/styles/fonts.css +7 -0
  84. package/apps/storefront/src/styles/global.css +397 -3
  85. package/package.json +4 -1
  86. package/packages/cli/src/visual-parity.mjs +321 -0
  87. package/packages/public-runtime/package.json +2 -0
  88. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  89. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  90. package/packages/public-runtime/src/chrome.ts +44 -7
  91. package/packages/public-runtime/src/csp.ts +12 -1
  92. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  93. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  94. package/packages/public-runtime/src/extension-contract.ts +20 -6
  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/csp-compiler.ts +6 -3
  100. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  101. package/packages/public-runtime/src/integration/index.ts +1 -0
  102. package/packages/public-runtime/src/integration/lifecycle-boot.ts +34 -6
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  104. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  105. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
  108. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
  109. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
  110. package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
  111. package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
  112. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  113. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  114. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  115. package/packages/public-runtime/src/publication-space.ts +24 -3
  116. package/packages/public-runtime/src/static-bundle.ts +145 -26
  117. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  118. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  119. package/packages/public-runtime/src/tenant.ts +59 -1
  120. package/pnpm-lock.runner.yaml +12 -0
  121. package/pnpm-workspace.yaml +4 -0
  122. package/scripts/dev/git-status.mjs +1 -1
  123. package/scripts/dev/publish.mjs +6 -0
  124. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  125. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  126. package/scripts/tenant/validate.mjs +171 -8
  127. package/apps/storefront/src/lib/cfImage.ts +0 -55
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  129. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  145. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  146. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  147. package/tenants/home/public/js/storefront.js +0 -129
  148. package/tenants/home/public/logo-wordmark.png +0 -0
  149. package/tenants/home/public/pages/storefront.css +0 -235
  150. package/tenants/home/theme.json +0 -6
@@ -7,30 +7,60 @@
7
7
  * reimplement verification here. Affirmation is remembered in localStorage.
8
8
  */
9
9
  import { useEffect, useRef, useState } from "preact/hooks";
10
+ import type { ChromeAgeGate } from "@/lib/chrome/model";
11
+ import { deriveAgeGateStorageKey, resolveAgeGateCopy } from "@/lib/chrome/model";
10
12
 
11
- interface Props {
13
+ export interface Props {
12
14
  minAge: number;
13
15
  brand: string;
16
+ tenantId: string;
17
+ presentation?: ChromeAgeGate;
18
+ /** Platform wrapper hook; tenants cannot supply or override this. */
19
+ onBlockingChange?: (blocking: boolean) => void;
14
20
  }
15
21
 
16
- const KEY = "tot:age-affirmed";
22
+ export function resolveAgeGateRuntime({
23
+ minAge,
24
+ brand,
25
+ tenantId,
26
+ presentation,
27
+ }: Props) {
28
+ return {
29
+ minAge,
30
+ storageKey: deriveAgeGateStorageKey(tenantId, minAge),
31
+ compact: presentation?.variant === "compact-modal",
32
+ title: resolveAgeGateCopy(presentation?.title, minAge) ?? `Are you ${minAge} or older?`,
33
+ lede:
34
+ resolveAgeGateCopy(presentation?.lede, minAge) ??
35
+ `${brand} sells age-restricted products. You must be ${minAge}+ to enter. By continuing you confirm your age — we verify it for real at checkout.`,
36
+ yesLabel:
37
+ resolveAgeGateCopy(presentation?.yesLabel, minAge) ?? `Yes, I'm ${minAge} or older`,
38
+ noLabel: resolveAgeGateCopy(presentation?.noLabel, minAge) ?? "No",
39
+ denyText:
40
+ resolveAgeGateCopy(presentation?.denyText, minAge) ??
41
+ `You must be ${minAge} or older to view this store. Please close this tab.`,
42
+ fineText: resolveAgeGateCopy(presentation?.fineText, minAge),
43
+ };
44
+ }
17
45
 
18
- export default function AgeGate({ minAge, brand }: Props) {
46
+ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlockingChange }: Props) {
19
47
  const [state, setState] = useState<"hidden" | "ask" | "denied">("hidden");
20
48
  const yesRef = useRef<HTMLButtonElement>(null);
49
+ const retryRef = useRef<HTMLButtonElement>(null);
50
+ const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText } =
51
+ resolveAgeGateRuntime({ minAge, brand, tenantId, presentation });
21
52
 
22
53
  useEffect(() => {
23
54
  let affirmed = false;
24
55
  try {
25
- affirmed = localStorage.getItem(KEY) === "yes";
56
+ affirmed = window.localStorage.getItem(storageKey) === "yes";
26
57
  } catch {
27
58
  /* storage disabled — show the gate */
28
59
  }
29
60
  if (!affirmed) {
30
61
  setState("ask");
31
- document.documentElement.style.overflow = "hidden";
32
62
  }
33
- }, []);
63
+ }, [storageKey]);
34
64
 
35
65
  useEffect(() => {
36
66
  // preventScroll: the gate is a fixed overlay, but its host <div> is appended
@@ -38,12 +68,15 @@ export default function AgeGate({ minAge, brand }: Props) {
38
68
  // underlying page down to that host — leaving the page at the bottom once the
39
69
  // gate is dismissed. preventScroll keeps the a11y focus without the jump.
40
70
  if (state === "ask") yesRef.current?.focus({ preventScroll: true });
41
- if (state === "hidden") document.documentElement.style.overflow = "";
42
- }, [state]);
71
+ if (state === "denied") retryRef.current?.focus({ preventScroll: true });
72
+ onBlockingChange?.(state !== "hidden");
73
+ }, [state, onBlockingChange]);
74
+
75
+ useEffect(() => () => onBlockingChange?.(false), [onBlockingChange]);
43
76
 
44
77
  const affirm = () => {
45
78
  try {
46
- localStorage.setItem(KEY, "yes");
79
+ window.localStorage.setItem(storageKey, "yes");
47
80
  } catch {
48
81
  /* ignore */
49
82
  }
@@ -52,52 +85,128 @@ export default function AgeGate({ minAge, brand }: Props) {
52
85
 
53
86
  if (state === "hidden") return null;
54
87
 
88
+ // This component lives in a closed shadow root. Its presentation must not
89
+ // depend on a stylesheet crossing that boundary: a dynamically-created
90
+ // <style> would be refused by the page's strict style-src, while style
91
+ // attributes are intentionally admitted by style-src-attr. Keep the modal
92
+ // self-contained, token-driven, and responsive without opening either the
93
+ // shadow root or the CSP.
94
+ const overlayStyle = {
95
+ boxSizing: "border-box",
96
+ position: "fixed" as const,
97
+ inset: "0",
98
+ display: "flex",
99
+ alignItems: compact ? "flex-start" : "center",
100
+ justifyContent: "center",
101
+ width: "100%",
102
+ height: "100%",
103
+ padding: compact ? "min(24px, 4vw)" : "20px",
104
+ background: compact ? "rgba(0, 0, 0, 0.5)" : "color-mix(in srgb, var(--color-text, #111) 75%, transparent)",
105
+ backdropFilter: compact ? undefined : "blur(4px)",
106
+ color: "var(--color-text, #171717)",
107
+ fontFamily: "var(--font-body, system-ui, sans-serif)",
108
+ pointerEvents: "auto",
109
+ };
110
+ const panelStyle = {
111
+ boxSizing: "border-box",
112
+ width: compact ? "min(600px, calc(100vw - 32px))" : "min(480px, calc(100vw - 40px))",
113
+ minHeight: compact ? "min(382px, calc(100vh - 48px))" : undefined,
114
+ maxHeight: compact ? "calc(100vh - 48px)" : undefined,
115
+ overflowY: compact ? "auto" : undefined,
116
+ padding: compact ? "32px" : "28px",
117
+ borderRadius: compact ? "5px" : "var(--radius-lg, 12px)",
118
+ background: "var(--color-surface, #fff)",
119
+ boxShadow: compact ? "0 20px 60px rgba(0, 0, 0, 0.35)" : "var(--shadow-lg, 0 24px 60px -20px rgba(0, 0, 0, .22))",
120
+ textAlign: "center" as const,
121
+ };
122
+ const buttonBase = {
123
+ boxSizing: "border-box",
124
+ minHeight: compact ? "48px" : undefined,
125
+ padding: "12px 24px",
126
+ borderRadius: compact ? "3px" : "var(--radius-md, 6px)",
127
+ fontFamily: "var(--font-display, system-ui, sans-serif)",
128
+ fontSize: "14px",
129
+ fontWeight: 600,
130
+ lineHeight: 1.2,
131
+ cursor: "pointer",
132
+ };
133
+
55
134
  return (
56
135
  <div
57
- class="fixed inset-0 z-[100] grid place-items-center bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] p-5 backdrop-blur-sm"
136
+ style={overlayStyle}
58
137
  role="dialog"
59
138
  aria-modal="true"
60
139
  aria-labelledby="agegate-title"
140
+ aria-describedby="agegate-description"
141
+ onKeyDown={(event) => {
142
+ if (event.key !== "Tab") return;
143
+ const controls = Array.from(
144
+ event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not([disabled])"),
145
+ );
146
+ if (controls.length === 0) return;
147
+ const first = controls[0]!;
148
+ const last = controls[controls.length - 1]!;
149
+ if (event.shiftKey && event.target === first) {
150
+ event.preventDefault();
151
+ last.focus();
152
+ } else if (!event.shiftKey && event.target === last) {
153
+ event.preventDefault();
154
+ first.focus();
155
+ }
156
+ }}
61
157
  >
62
- <div class="w-[min(30rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-center shadow-[var(--shadow-lg)]">
158
+ <div style={panelStyle}>
63
159
  {state === "ask" ? (
64
160
  <>
65
- <p class="eyebrow eyebrow--tick mx-auto mb-4 w-fit">Age verification</p>
66
- <h2 id="agegate-title" class="display-md">
67
- Are you {minAge} or older?
161
+ {presentation?.mark?.length ? (
162
+ <p style={{ display: "flex", justifyContent: "center", margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "18px", fontWeight: 700, letterSpacing: ".04em" }}>
163
+ {presentation.mark.map((part) => (
164
+ <span class={part.className}>{part.text}</span>
165
+ ))}
166
+ </p>
167
+ ) : (
168
+ <p style={{ margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "12px", fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--color-muted, #616161)" }}>Age verification</p>
169
+ )}
170
+ <h2 id="agegate-title" style={{ margin: 0, fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "clamp(24px, 4vw, 32px)", fontWeight: 600, lineHeight: 1.1, letterSpacing: "-.02em" }}>
171
+ {title}
68
172
  </h2>
69
- <p class="mx-auto mt-3 max-w-sm text-[var(--color-muted)]">
70
- {brand} sells age-restricted products. You must be {minAge}+ to enter.
71
- By continuing you confirm your age — we verify it for real at checkout.
173
+ <p id="agegate-description" style={{ maxWidth: "32rem", margin: "12px auto 0", color: "var(--color-muted, #616161)", fontSize: "16px", lineHeight: 1.55 }}>
174
+ {lede}
72
175
  </p>
73
- <div class="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
176
+ <div style={{ display: "flex", flexDirection: compact ? "column" : "row", flexWrap: "wrap", justifyContent: "center", gap: "12px", marginTop: "24px" }}>
74
177
  <button
75
178
  ref={yesRef}
76
179
  type="button"
77
180
  onClick={affirm}
78
- class="rounded-[var(--radius-md)] bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-semibold text-[var(--color-primary-contrast)]"
181
+ style={{ ...buttonBase, width: compact ? "100%" : undefined, border: 0, background: "var(--color-primary, #111)", color: "var(--color-primary-contrast, #fff)" }}
79
182
  >
80
- Yes, I'm {minAge} or older
183
+ {yesLabel}
81
184
  </button>
82
185
  <button
83
186
  type="button"
84
187
  onClick={() => setState("denied")}
85
- class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] hover:border-[var(--color-text)]"
188
+ style={{ ...buttonBase, width: compact ? "100%" : undefined, border: "1px solid var(--color-border, #d0d0d0)", background: "transparent", color: "var(--color-text, #171717)", fontWeight: 500 }}
86
189
  >
87
- No
190
+ {noLabel}
88
191
  </button>
89
192
  </div>
193
+ {fineText && (
194
+ <p style={{ maxWidth: "32rem", margin: "20px auto 0", color: "var(--color-muted, #616161)", fontSize: "12px", lineHeight: 1.45 }}>
195
+ {fineText}
196
+ </p>
197
+ )}
90
198
  </>
91
199
  ) : (
92
200
  <>
93
- <h2 id="agegate-title" class="display-md">Come back when you're {minAge}</h2>
94
- <p class="mx-auto mt-3 max-w-sm text-[var(--color-muted)]">
95
- You must be {minAge} or older to view this store. Please close this tab.
201
+ <h2 id="agegate-title" style={{ margin: 0, fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "clamp(24px, 4vw, 32px)", fontWeight: 600, lineHeight: 1.1 }}>Come back when you're {minAge}</h2>
202
+ <p id="agegate-description" style={{ maxWidth: "24rem", margin: "12px auto 0", color: "var(--color-muted, #616161)", fontSize: "16px", lineHeight: 1.55 }}>
203
+ {denyText}
96
204
  </p>
97
205
  <button
206
+ ref={retryRef}
98
207
  type="button"
99
208
  onClick={() => setState("ask")}
100
- class="link-accent mt-6 inline-block font-display text-sm font-medium"
209
+ style={{ display: "inline-block", marginTop: "24px", border: 0, background: "transparent", color: "var(--color-accent-ink, var(--color-primary, #111))", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "14px", fontWeight: 500, textDecoration: "underline", cursor: "pointer" }}
101
210
  >
102
211
  I entered my age incorrectly
103
212
  </button>
@@ -17,7 +17,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
17
17
  import type { CatalogImage } from "@tot/public-runtime";
18
18
  import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
19
19
  import { resolveImage } from "@/lib/imageAttrs";
20
- import { cfImageUrl } from "@/lib/cfImage";
21
20
 
22
21
  interface Props {
23
22
  images: CatalogImage[];
@@ -34,9 +33,8 @@ const isShopifyCdnUrl = (url: string): boolean =>
34
33
 
35
34
  /**
36
35
  * Main-image src + responsive srcset. Delegates to the SHARED `resolveImage`
37
- * decision (same as <Img> on the PLP) so rehosted (R2) images go through
38
- * Cloudflare Image Resizing — `?width=` is a no-op on the R2 bucket, so the old
39
- * per-island srcset silently served full-size bytes at every breakpoint.
36
+ * decision (same as <Img> on the PLP) so rehosted images use the same
37
+ * provider-neutral shared-CDN URL on Cloudflare and CloudFront.
40
38
  */
41
39
  function mainImage(img: CatalogImage, title: string): { src: string; srcset?: string } {
42
40
  return resolveImage({
@@ -50,10 +48,9 @@ function mainImage(img: CatalogImage, title: string): { src: string; srcset?: st
50
48
  }
51
49
 
52
50
  /**
53
- * Thumbnail src — a single small (160px, 2x of the 80px strip) request, so a
54
- * thumbnail never downloads a full-size photo. Rehosted images resize via
55
- * Cloudflare Image Resizing; referenced Shopify images via their native
56
- * `?width=`; dead fixture URLs draw a deterministic placeholder.
51
+ * Thumbnail src. Referenced Shopify images use their native `?width=` contract;
52
+ * rehosted images use the provider-neutral shared-CDN original; dead fixture
53
+ * URLs draw a deterministic placeholder.
57
54
  */
58
55
  const THUMB_WIDTH = 160;
59
56
  function thumbSrc(img: CatalogImage, title: string): string {
@@ -65,7 +62,7 @@ function thumbSrc(img: CatalogImage, title: string): string {
65
62
  label: title,
66
63
  });
67
64
  }
68
- if (img.rehosted) return cfImageUrl(img.url, { width: THUMB_WIDTH });
65
+ if (img.rehosted) return img.url;
69
66
  if (isShopifyCdnUrl(img.url)) {
70
67
  return `${img.url}${img.url.includes("?") ? "&" : "?"}width=${THUMB_WIDTH}`;
71
68
  }
@@ -3,8 +3,9 @@
3
3
  * root** so no tenant CSS or JavaScript can select, restyle, or hide it.
4
4
  *
5
5
  * Why a wrapper instead of rendering <AgeGate> directly: a closed shadow root
6
- * also blocks the platform's own global stylesheet, so we clone the document's
7
- * stylesheets into the shadow root (Tailwind utilities + fonts). Theme tokens
6
+ * also blocks the platform's own global stylesheet, so we clone only the
7
+ * platform-generated/shared stylesheets into the shadow root (utilities + fonts).
8
+ * Tenant stylesheet links are excluded. Theme tokens
8
9
  * are CSS custom properties on :root, which *do* inherit across the shadow
9
10
  * boundary, so the gate keeps the tenant's colors.
10
11
  *
@@ -16,58 +17,205 @@
16
17
  */
17
18
  import { render } from "preact";
18
19
  import { useEffect } from "preact/hooks";
20
+ import type { ChromeAgeGate } from "@/lib/chrome/model";
19
21
  import AgeGate from "./AgeGate";
20
22
 
21
- interface Props {
23
+ export interface Props {
22
24
  minAge: number;
23
25
  brand: string;
26
+ tenantId: string;
27
+ presentation?: ChromeAgeGate;
28
+ }
29
+
30
+ interface BackgroundIsolation {
31
+ release(): void;
32
+ refresh(): void;
33
+ }
34
+
35
+ /**
36
+ * Width the classic scrollbar occupies right now; 0 where the scrollbar is an overlay.
37
+ *
38
+ * Locking scroll with `overflow: hidden` REMOVES that scrollbar, which widens the viewport and
39
+ * reflows the whole document — every block re-measures, text re-wraps, and content jumps.
40
+ * Measured on a regulated tenant it cost CLS 0.82-0.90 on every page long enough to scroll,
41
+ * while pages that never scrolled were untouched — which is the tell. That dragged Lighthouse
42
+ * performance from ~99 to ~74 with every timing metric still green. Core Web Vitals are
43
+ * field-measured and feed ranking, so it is a real cost on a revenue-bearing surface, and the
44
+ * gate is mandatory for these tenants: they cannot opt out of paying it.
45
+ */
46
+ function scrollbarGutterWidth(): number {
47
+ const width = window.innerWidth - document.documentElement.clientWidth;
48
+ return Number.isFinite(width) && width > 0 ? width : 0;
49
+ }
50
+
51
+ /** Make the storefront behind the gate non-interactive without disturbing prior inert state. */
52
+ export function isolateAgeGateBackground(host: HTMLElement): BackgroundIsolation {
53
+ const isolated = new Set<HTMLElement>();
54
+ const previousOverflow = document.documentElement.style.overflow;
55
+ const previousPaddingRight = document.documentElement.style.paddingRight;
56
+ let active = true;
57
+
58
+ const refresh = () => {
59
+ if (!active) return;
60
+ // Reserve the scrollbar's width BEFORE hiding it, so the viewport keeps its dimensions and
61
+ // nothing behind the gate moves. Measure first: once overflow is hidden the gutter reads 0.
62
+ const gutter = scrollbarGutterWidth();
63
+ if (gutter > 0 && !document.documentElement.style.paddingRight) {
64
+ document.documentElement.style.paddingRight = `${gutter}px`;
65
+ }
66
+ document.documentElement.style.overflow = "hidden";
67
+ for (const sibling of Array.from(document.body.children)) {
68
+ if (!(sibling instanceof HTMLElement) || sibling === host || sibling.hasAttribute("inert")) continue;
69
+ sibling.setAttribute("inert", "");
70
+ isolated.add(sibling);
71
+ }
72
+ };
73
+ const release = () => {
74
+ if (!active) return;
75
+ active = false;
76
+ for (const sibling of isolated) sibling.removeAttribute("inert");
77
+ isolated.clear();
78
+ document.documentElement.style.overflow = previousOverflow;
79
+ document.documentElement.style.paddingRight = previousPaddingRight;
80
+ };
81
+
82
+ refresh();
83
+ return { release, refresh };
24
84
  }
25
85
 
26
86
  const HOST_MARKER = "data-tot-compliance";
87
+ let mountedHost: HTMLElement | null = null;
88
+ const HOST_STYLE_FLOOR = {
89
+ all: "initial",
90
+ display: "block",
91
+ position: "fixed",
92
+ inset: "0",
93
+ "z-index": "2147483647",
94
+ visibility: "visible",
95
+ opacity: "1",
96
+ "pointer-events": "none",
97
+ } as const;
98
+
99
+ /** Inline-important floor: tenant selectors cannot hide or cover the compliance host. */
100
+ export function hardenComplianceHost(host: HTMLElement): void {
101
+ for (const [property, value] of Object.entries(HOST_STYLE_FLOOR)) {
102
+ if (
103
+ host.style.getPropertyValue(property) !== value ||
104
+ host.style.getPropertyPriority(property) !== "important"
105
+ ) {
106
+ host.style.setProperty(property, value, "important");
107
+ }
108
+ }
109
+ }
110
+
111
+ export function isPlatformStylesheetHref(
112
+ href: string,
113
+ pageOrigin = "https://storefront.invalid/",
114
+ resolutionBase = pageOrigin,
115
+ ): boolean {
116
+ try {
117
+ const origin = new URL(pageOrigin).origin;
118
+ const url = new URL(href, resolutionBase);
119
+ return (
120
+ url.origin === origin &&
121
+ (url.pathname.startsWith("/_astro/") || url.pathname.startsWith("/shared/"))
122
+ );
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ export function mountIsolatedAgeGate({ minAge, brand, tenantId, presentation }: Props) {
129
+ if (mountedHost?.isConnected) return () => {};
130
+
131
+ const host = document.createElement("div");
132
+ host.setAttribute(HOST_MARKER, "");
133
+ hardenComplianceHost(host);
134
+ const shadow = host.attachShadow({ mode: "closed" });
135
+ mountedHost = host;
136
+ let backgroundIsolation: BackgroundIsolation | null = null;
137
+
138
+ // Bring platform styles into the shadow root via allowlisted external
139
+ // stylesheet paths only (Tailwind utilities; build externalizes all CSS).
140
+ // Island CSS is inserted in <head> after client:load begins, so observe that
141
+ // trusted source as well as copying the links already present. Tenant
142
+ // /tenants/** stylesheets never cross this boundary. We do NOT clone inline
143
+ // theme styles: their nonce is response-specific, while inherited custom
144
+ // properties already carry the theme tokens across the boundary.
145
+ const copiedStylesheets = new Set<string>();
146
+ const copyPlatformStylesheet = (link: HTMLLinkElement) => {
147
+ const href = link.getAttribute("href") ?? "";
148
+ if (!isPlatformStylesheetHref(href, location.origin, document.baseURI)) return;
149
+ const resolvedHref = new URL(href, document.baseURI).href;
150
+ if (copiedStylesheets.has(resolvedHref)) return;
151
+ copiedStylesheets.add(resolvedHref);
152
+ shadow.appendChild(link.cloneNode(true));
153
+ };
154
+ document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]').forEach(copyPlatformStylesheet);
155
+ const stylesheetObserver = new MutationObserver((records) => {
156
+ for (const record of records) {
157
+ record.addedNodes.forEach((node) => {
158
+ if (node instanceof HTMLLinkElement && node.rel === "stylesheet") {
159
+ copyPlatformStylesheet(node);
160
+ }
161
+ });
162
+ }
163
+ });
164
+ stylesheetObserver.observe(document.head, { childList: true });
165
+
166
+ const mount = document.createElement("div");
167
+ shadow.appendChild(mount);
168
+ render(
169
+ <AgeGate
170
+ minAge={minAge}
171
+ brand={brand}
172
+ tenantId={tenantId}
173
+ presentation={presentation}
174
+ onBlockingChange={(blocking) => {
175
+ if (blocking) {
176
+ backgroundIsolation ??= isolateAgeGateBackground(host);
177
+ backgroundIsolation.refresh();
178
+ } else {
179
+ backgroundIsolation?.release();
180
+ backgroundIsolation = null;
181
+ }
182
+ }}
183
+ />,
184
+ mount,
185
+ );
186
+ document.body.appendChild(host);
187
+
188
+ // Re-assert: restore the host if tenant code detaches or hides it.
189
+ const ensure = () => {
190
+ if (!host.isConnected || document.body.lastElementChild !== host) {
191
+ document.body.appendChild(host);
192
+ }
193
+ if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
194
+ hardenComplianceHost(host);
195
+ backgroundIsolation?.refresh();
196
+ };
197
+ const observer = new MutationObserver(ensure);
198
+ observer.observe(document.body, { childList: true });
199
+ observer.observe(host, {
200
+ attributes: true,
201
+ attributeFilter: ["style", "class", "hidden"],
202
+ });
203
+
204
+ return () => {
205
+ observer.disconnect();
206
+ stylesheetObserver.disconnect();
207
+ backgroundIsolation?.release();
208
+ render(null, mount);
209
+ host.remove();
210
+ if (mountedHost === host) mountedHost = null;
211
+ };
212
+ }
27
213
 
28
- export default function IsolatedAgeGate({ minAge, brand }: Props) {
29
- useEffect(() => {
30
- // Guard against an accidental double-mount (e.g. HMR).
31
- if (document.querySelector(`[${HOST_MARKER}]`)) return;
32
-
33
- const host = document.createElement("div");
34
- host.setAttribute(HOST_MARKER, "");
35
- const shadow = host.attachShadow({ mode: "closed" });
36
-
37
- // Bring platform styles into the shadow root via the external stylesheet
38
- // <link>s only (Tailwind utilities; build externalizes all CSS). We do NOT
39
- // clone the inline theme <style>: cloning strips its CSP nonce, and it's
40
- // unneeded — CSS custom properties (the theme tokens on :root) inherit
41
- // across the shadow boundary, so the gate keeps the tenant's colors anyway.
42
- document
43
- .querySelectorAll('link[rel="stylesheet"]')
44
- .forEach((link) => shadow.appendChild(link.cloneNode(true)));
45
-
46
- const mount = document.createElement("div");
47
- shadow.appendChild(mount);
48
- render(<AgeGate minAge={minAge} brand={brand} />, mount);
49
- document.body.appendChild(host);
50
-
51
- // Re-assert: restore the host if tenant code detaches or hides it.
52
- const ensure = () => {
53
- if (!host.isConnected) document.body.appendChild(host);
54
- if (host.style.display === "none") host.style.display = "";
55
- if (host.style.visibility === "hidden") host.style.visibility = "";
56
- if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
57
- };
58
- const observer = new MutationObserver(ensure);
59
- observer.observe(document.body, { childList: true });
60
- observer.observe(host, {
61
- attributes: true,
62
- attributeFilter: ["style", "class", "hidden"],
63
- });
64
-
65
- return () => {
66
- observer.disconnect();
67
- render(null, mount);
68
- host.remove();
69
- };
70
- }, [minAge, brand]);
214
+ export default function IsolatedAgeGate(props: Props) {
215
+ useEffect(
216
+ () => mountIsolatedAgeGate(props),
217
+ [props.minAge, props.brand, props.tenantId, props.presentation],
218
+ );
71
219
 
72
220
  return null;
73
221
  }
@@ -18,6 +18,8 @@ import { withClientBase } from "@/lib/basePath";
18
18
 
19
19
  interface Props {
20
20
  action?: string;
21
+ placeholder?: string;
22
+ label?: string;
21
23
  }
22
24
 
23
25
  const MAX_RESULTS = 6;
@@ -35,7 +37,11 @@ function thumbFor(doc: TypeaheadDoc): string {
35
37
  });
36
38
  }
37
39
 
38
- export default function SearchTypeahead({ action = "/search" }: Props) {
40
+ export default function SearchTypeahead({
41
+ action = "/search",
42
+ placeholder = "Search products",
43
+ label,
44
+ }: Props) {
39
45
  const [query, setQuery] = useState("");
40
46
  const [results, setResults] = useState<TypeaheadDoc[]>([]);
41
47
  const [open, setOpen] = useState(false);
@@ -145,7 +151,7 @@ export default function SearchTypeahead({ action = "/search" }: Props) {
145
151
  <form
146
152
  ref={rootRef}
147
153
  role="search"
148
- aria-label="Site search"
154
+ aria-label={label ?? "Search"}
149
155
  action={action}
150
156
  method="get"
151
157
  onSubmit={(e) => {
@@ -176,14 +182,14 @@ export default function SearchTypeahead({ action = "/search" }: Props) {
176
182
  value={query}
177
183
  autocomplete="off"
178
184
  spellcheck={false}
179
- aria-label="Search products"
185
+ aria-label={label ?? "Search"}
180
186
  // eslint-disable-next-line jsx-a11y/role-has-required-aria-props
181
187
  role="combobox"
182
188
  aria-expanded={open}
183
189
  aria-controls={LISTBOX_ID}
184
190
  aria-autocomplete="list"
185
191
  aria-activedescendant={activeId}
186
- placeholder="Search products"
192
+ placeholder={placeholder}
187
193
  onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
188
194
  onFocus={() => {
189
195
  if (results.length > 0) setOpen(true);
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import Section from "@/components/Section.astro";
8
8
  import type { FaqBlock } from "@/lib/storyblok/content-model";
9
+ import { safeJsonScriptContent } from "@/lib/jsonScript";
9
10
  interface Props {
10
11
  block: FaqBlock;
11
12
  }
@@ -20,12 +21,18 @@ const faqLd = {
20
21
  acceptedAnswer: { "@type": "Answer", text: it.a },
21
22
  })),
22
23
  };
24
+ const faqJsonLd = safeJsonScriptContent(faqLd);
23
25
  ---
24
26
  <Section eyebrow={block.eyebrow} title={block.title} tone="surface">
27
+ {block.lede && (
28
+ <p class="mb-8 max-w-[42rem] text-[var(--color-muted)]" data-reveal>{block.lede}</p>
29
+ )}
25
30
  <div class="mx-auto max-w-[46rem] divide-y divide-[var(--color-border)]" data-reveal>
26
31
  {block.items.map((it) => (
27
32
  <details class="group py-4">
28
- <summary class="flex cursor-pointer list-none items-center justify-between gap-4 font-display text-base font-semibold text-[var(--color-text)]">
33
+ {/* `py-2.5 -my-2.5` takes the disclosure row to the 44px tap minimum
34
+ and gives the space straight back, so the rows keep their rhythm. */}
35
+ <summary class="flex cursor-pointer list-none items-center justify-between gap-4 py-2.5 -my-2.5 font-display text-base font-semibold text-[var(--color-text)]">
29
36
  {it.q}
30
37
  <span aria-hidden="true" class="shrink-0 text-[var(--color-accent)] transition-transform group-open:rotate-45">+</span>
31
38
  </summary>
@@ -34,4 +41,4 @@ const faqLd = {
34
41
  ))}
35
42
  </div>
36
43
  </Section>
37
- <script type="application/ld+json" set:html={JSON.stringify(faqLd)} is:inline />
44
+ <script type="application/ld+json" set:html={faqJsonLd} is:inline />
@@ -70,7 +70,7 @@ export function synthesizeDevTenant(
70
70
  const appDomain = hints.appDomain ?? id;
71
71
  // The tenant's OWN tree analytics.json → the render plane's declaration seam,
72
72
  // fail-closed (a missing/invalid file yields no declarations, never a throw).
73
- const integrations = loadTenantAnalyticsDeclarations(hints.analyticsRaw, id);
73
+ const integrations = loadTenantAnalyticsDeclarations(hints.analyticsRaw, id, appDomain);
74
74
  const base: TenantConfig = {
75
75
  tenant_id: id,
76
76
  appDomain,
@@ -16,6 +16,7 @@
16
16
  * keeps working before KV is seeded and during a gradual cutover from static.
17
17
  */
18
18
  import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
19
+ import { withPlatformPolicy } from "@/config/tenants";
19
20
 
20
21
  const DOMAIN_PREFIX = "domain:";
21
22
  const TENANT_PREFIX = "tenant:";
@@ -35,11 +36,16 @@ export class KvTenantResolver implements TenantResolver {
35
36
  private readonly fallback?: TenantResolver,
36
37
  ) {}
37
38
 
39
+ // Platform policy is composed over every KV-sourced record (ADR 0035). A record
40
+ // written before a platform field existed does not carry it, so without this the
41
+ // declaration is inert for exactly the established tenants — and a record could
42
+ // otherwise set policy that is not a tenant's to set. The static fallback needs
43
+ // no composition: it IS the declaration.
38
44
  private async readTenant(id: string): Promise<TenantConfig | null> {
39
45
  const raw = await this.kv.get(TENANT_PREFIX + id, { cacheTtl: CACHE_TTL });
40
46
  if (!raw) return null;
41
47
  try {
42
- return JSON.parse(raw) as TenantConfig;
48
+ return withPlatformPolicy(JSON.parse(raw) as TenantConfig);
43
49
  } catch {
44
50
  return null;
45
51
  }