@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
@@ -29,6 +29,7 @@ import { isCurrentNavHref } from "./chrome/navCurrent.js";
29
29
  import { ctaIdentityAttr } from "./chrome/ctaIdentity.js";
30
30
  import { CHROME_PLACEMENT } from "./chrome/trackingPlacements.js";
31
31
  import { LEGAL_NAV_LABEL, splitFooterLegal } from "./chrome/footerLegal.js";
32
+ import { resolveNewsletterAgeAttestation } from "./chrome/newsletterAgeAttestation.js";
32
33
  import { trackingAttributesHtml } from "@tot/public-runtime";
33
34
  import type {
34
35
  ChromeAgeGate,
@@ -44,7 +45,7 @@ import type {
44
45
  HeaderVariant,
45
46
  SocialLink,
46
47
  } from "./chrome/model.js";
47
- import { resolveChromeAssignment } from "./chrome/model.js";
48
+ import { resolveAgeGateCopy, resolveChromeAssignment } from "./chrome/model.js";
48
49
  import { resolveHeaderVariant } from "./chrome/variant.js";
49
50
 
50
51
  /** HTML-escape text/attribute content. Covers the five significant characters. */
@@ -289,6 +290,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
289
290
  const cart = header.cartHref
290
291
  ? `<a class="cart" href="${esc(header.cartHref)}" aria-label="Cart">${ICON_CART}<span class="count">0</span></a>`
291
292
  : "";
293
+ const compact = variant === "compact-centered";
292
294
  // "minimal" (e.g. checkout/policy pages): brand + a reduced action set only —
293
295
  // no primary nav bar, no search, no mobile-menu toggle. "primary": the full header.
294
296
  const minimal = variant === "minimal";
@@ -323,24 +325,49 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
323
325
  const navItems = header.nav
324
326
  .map((item, i) => renderNavItem(item, i, currentPath))
325
327
  .join("\n ");
328
+ const primaryNav =
329
+ `<nav class="primary-nav" id="primaryNav" aria-label="Primary">\n` +
330
+ ` <ul>\n ${navItems}\n </ul>\n` +
331
+ ` </nav>`;
326
332
  const navBar = minimal
327
333
  ? ""
334
+ : compact
335
+ ? ""
328
336
  : ` <nav class="primary-nav-bar" aria-label="Primary">\n` +
329
337
  ` <div class="container">\n` +
330
- ` <nav class="primary-nav" id="primaryNav">\n` +
331
- ` <ul>\n ${navItems}\n </ul>\n` +
332
- ` </nav>\n` +
338
+ ` ${primaryNav}\n` +
333
339
  ` </div>\n` +
334
340
  ` </nav>\n`;
341
+ const compactSearch =
342
+ compact && header.search
343
+ ? `<a class="header-search-trigger" href="${esc(header.search.action ?? "/search")}" aria-label="${esc(header.search.label ?? "Search")}">${ICON_SEARCH}</a>`
344
+ : "";
345
+ const mobileBottom =
346
+ compact
347
+ ? `<nav class="mobile-bottom-nav" aria-label="Quick links">` +
348
+ `<a href="/" aria-label="Home"><span aria-hidden="true">⌂</span><small>Home</small></a>` +
349
+ (header.search
350
+ ? `<a href="${esc(header.search.action ?? "/search")}" aria-label="${esc(header.search.label ?? "Search")}">${ICON_SEARCH}<small>Search</small></a>`
351
+ : "") +
352
+ (header.accountHref
353
+ ? `<a href="${esc(header.accountHref)}" aria-label="Account">${ICON_ACCOUNT}<small>Account</small></a>`
354
+ : "") +
355
+ (header.cartHref
356
+ ? `<a href="${esc(header.cartHref)}" aria-label="Cart">${ICON_CART}<small>Cart</small></a>`
357
+ : "") +
358
+ `</nav>`
359
+ : "";
335
360
  return (
336
361
  `<header class="site-header" data-variant="${esc(variant)}">\n` +
337
362
  ` <div class="container header-row">\n` +
363
+ (compact ? ` ${primaryNav}\n` : "") +
338
364
  ` ${renderBrand(header.brand)}\n` +
339
- (!minimal && search ? ` ${search}\n` : "") +
340
- ` <div class="header-actions">${memberCue}${ctas}${account}${cart}${hamburger}</div>\n` +
365
+ (!minimal && !compact && search ? ` ${search}\n` : "") +
366
+ ` <div class="header-actions">${memberCue}${ctas}${compactSearch}${account}${cart}${hamburger}</div>\n` +
341
367
  ` </div>\n` +
342
368
  navBar +
343
- `</header>`
369
+ `</header>` +
370
+ mobileBottom
344
371
  );
345
372
  }
346
373
 
@@ -352,13 +379,52 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
352
379
  * can't close the tag early.
353
380
  */
354
381
  function renderAgeGate(g: ChromeAgeGate): string {
355
- const json = JSON.stringify(g).replace(/</g, "\\u003c");
382
+ const resolved = {
383
+ ...g,
384
+ title: resolveAgeGateCopy(g.title, g.minAge),
385
+ lede: resolveAgeGateCopy(g.lede, g.minAge),
386
+ yesLabel: resolveAgeGateCopy(g.yesLabel, g.minAge),
387
+ noLabel: resolveAgeGateCopy(g.noLabel, g.minAge),
388
+ denyText: resolveAgeGateCopy(g.denyText, g.minAge),
389
+ fineText: resolveAgeGateCopy(g.fineText, g.minAge),
390
+ };
391
+ const json = JSON.stringify(resolved).replace(/</g, "\\u003c");
356
392
  return `<script type="application/json" id="tot-age-gate-config">${json}</script>`;
357
393
  }
358
394
 
395
+ /** Remove a wrapper's raw gate payload before Layout mounts its governed component gate. */
396
+ export function stripRawChromeAgeGateConfig(html: string): string {
397
+ return html.replace(
398
+ /<script\b(?=[^>]*\bid=["']tot-age-gate-config["'])[^>]*>[\s\S]*?<\/script>\s*/gi,
399
+ "",
400
+ );
401
+ }
402
+
403
+ /** Neutralize the reserved light-DOM marker before tenant-authored chrome is inserted. */
404
+ export function stripReservedComplianceMarkers(html: string): string {
405
+ return html.replace(
406
+ /\sdata-tot-compliance(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi,
407
+ "",
408
+ );
409
+ }
410
+
411
+ /** Remove executable tenant script elements, including unterminated tags. */
412
+ export function stripTenantScripts(html: string): string {
413
+ return html.replace(
414
+ /<script\b[^>]*>(?:[\s\S]*?<\/script\s*>|[\s\S]*$)/gi,
415
+ "",
416
+ );
417
+ }
418
+
419
+ /** Compliance-safe shared-chrome fragment for Layout's component-rendered path. */
420
+ export function sanitizeSharedChromeFragment(html: string): string {
421
+ return stripReservedComplianceMarkers(stripTenantScripts(html));
422
+ }
423
+
359
424
  function renderFooterNewsletter(n: NonNullable<ChromeFooter["newsletter"]>): string {
360
- const attest = n.ageAttestation
361
- ? `\n <label class="footer-attest"><input type="checkbox" name="age_attestation" value="21plus" required> I confirm I am 21 years of age or older and agree to receive marketing emails.</label>`
425
+ const ageAttestation = resolveNewsletterAgeAttestation(n.ageAttestation);
426
+ const attest = ageAttestation
427
+ ? `\n <label class="footer-attest"><input type="checkbox" name="${ageAttestation.name}" value="${ageAttestation.value}" required> ${esc(ageAttestation.label)}</label>`
362
428
  : "";
363
429
  const title = n.title ? `<h4>${esc(n.title)}</h4>` : "";
364
430
  const body = n.body ? `<p>${esc(n.body)}</p>` : "";
@@ -376,6 +442,7 @@ function renderFooterNewsletter(n: NonNullable<ChromeFooter["newsletter"]>): str
376
442
  }
377
443
 
378
444
  function renderFooter(footer: ChromeFooter): string {
445
+ const collapsible = footer.variant === "light-collapsible";
379
446
  const brand = footer.brand;
380
447
  const brandBlock =
381
448
  brand || footer.newsletter
@@ -411,7 +478,9 @@ function renderFooter(footer: ChromeFooter): string {
411
478
  return `<li><a href="${esc(l.href)}"${relFor(l)}${stamp}>${esc(l.label)}</a></li>`;
412
479
  })
413
480
  .join("");
414
- return ` <div class="footer-col">\n <h4>${esc(c.title)}</h4>\n <ul>${links}</ul>\n </div>`;
481
+ return collapsible
482
+ ? ` <div class="footer-col"><details>\n <summary>${esc(c.title)}</summary>\n <ul>${links}</ul>\n </details></div>`
483
+ : ` <div class="footer-col">\n <h4>${esc(c.title)}</h4>\n <ul>${links}</ul>\n </div>`;
415
484
  })
416
485
  .join("\n");
417
486
  const trust = footer.trustMarks?.length
@@ -453,7 +522,7 @@ function renderFooter(footer: ChromeFooter): string {
453
522
  const bottom =
454
523
  bottomText || legalNav ? `\n <div class="footer-bottom">${bottomText}${legalNav}</div>` : "";
455
524
  return (
456
- `<footer class="site-footer">\n` +
525
+ `<footer class="site-footer" data-variant="${esc(footer.variant)}">\n` +
457
526
  ` <div class="container">\n` +
458
527
  ` <div class="footer-grid">\n${brandBlock}${columns}\n </div>` +
459
528
  `${trust}${social}${payments}${warning}${bottom}\n` +
@@ -511,8 +580,8 @@ export const CHROME_BEHAVIOR_JS = `(function () {
511
580
  // DOM (data-tot-compliance, the same marker components/islands/IsolatedAgeGate.tsx
512
581
  // uses); its contents sit behind a closed shadow root, so tenant CSS/JS can't
513
582
  // select, restyle, or hide the dialog. A MutationObserver re-asserts the host the
514
- // same way that component does. Config comes from the JSON payload rawChrome.ts
515
- // renders (ChromeAgeGate, unchanged shape) so authoring stays the same.
583
+ // same way that component does. Config comes from the validated ChromeAgeGate
584
+ // JSON payload rawChrome.ts renders.
516
585
  var gateConfigEl = document.getElementById('tot-age-gate-config');
517
586
  if (gateConfigEl && !document.querySelector('[data-tot-compliance]')) {
518
587
  var gcfg; try { gcfg = JSON.parse(gateConfigEl.textContent || 'null'); } catch (e) { gcfg = null; }
@@ -524,10 +593,39 @@ export const CHROME_BEHAVIOR_JS = `(function () {
524
593
 
525
594
  var host = document.createElement('div');
526
595
  host.setAttribute('data-tot-compliance', '');
596
+ var gharden = function () {
597
+ var floor = { all: 'initial', display: 'block', position: 'fixed', inset: '0', 'z-index': '2147483647', visibility: 'visible', opacity: '1', 'pointer-events': 'none' };
598
+ Object.keys(floor).forEach(function (property) {
599
+ if (host.style.getPropertyValue(property) !== floor[property] || host.style.getPropertyPriority(property) !== 'important') {
600
+ host.style.setProperty(property, floor[property], 'important');
601
+ }
602
+ });
603
+ };
604
+ gharden();
527
605
  var shadow = host.attachShadow({ mode: 'closed' });
528
- document.querySelectorAll('link[rel="stylesheet"]').forEach(function (l) {
529
- shadow.appendChild(l.cloneNode(true));
606
+ var gcopiedSheets = {};
607
+ var gcopyPlatformSheet = function (l) {
608
+ var href = l.getAttribute('href') || '';
609
+ var sheetUrl; try { sheetUrl = new URL(href, document.baseURI); } catch (e) { sheetUrl = null; }
610
+ var platformPath = sheetUrl && sheetUrl.origin === location.origin &&
611
+ (sheetUrl.pathname.indexOf('/_astro/') === 0 || sheetUrl.pathname.indexOf('/shared/') === 0);
612
+ if (platformPath && !gcopiedSheets[sheetUrl.href]) {
613
+ gcopiedSheets[sheetUrl.href] = true;
614
+ shadow.appendChild(l.cloneNode(true));
615
+ }
616
+ };
617
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach(gcopyPlatformSheet);
618
+ // Astro may append an extracted island stylesheet after this behavior
619
+ // script runs. Copy the late, same-origin platform link into the closed
620
+ // root; never synthesize an inline style element, which strict CSP would block.
621
+ var gsheetObserver = new MutationObserver(function (records) {
622
+ records.forEach(function (record) {
623
+ record.addedNodes.forEach(function (node) {
624
+ if (node.nodeType === 1 && node.tagName === 'LINK' && node.rel === 'stylesheet') gcopyPlatformSheet(node);
625
+ });
626
+ });
530
627
  });
628
+ gsheetObserver.observe(document.head, { childList: true });
531
629
  var mount = document.createElement('div');
532
630
  shadow.appendChild(mount);
533
631
 
@@ -554,10 +652,41 @@ export const CHROME_BEHAVIOR_JS = `(function () {
554
652
  '</div></div>';
555
653
 
556
654
  var gate = mount.querySelector('#ageGate');
655
+ gate.style.pointerEvents = 'auto';
656
+ if (gcfg.variant === 'compact-modal') {
657
+ gate.style.background = 'rgba(0,0,0,.5)';
658
+ gate.style.padding = '16px';
659
+ gate.style.alignItems = 'flex-start';
660
+ var panel = mount.querySelector('.age-gate__panel');
661
+ panel.style.width = 'min(600px, calc(100vw - 32px))';
662
+ panel.style.minHeight = 'min(382px, calc(100vh - 32px))';
663
+ panel.style.borderRadius = '5px';
664
+ panel.style.boxShadow = '0 20px 60px rgba(0,0,0,.35)';
665
+ var actions = mount.querySelector('.age-gate__actions');
666
+ actions.style.flexDirection = 'column';
667
+ Array.prototype.forEach.call(actions.querySelectorAll('button'), function (button) {
668
+ button.style.width = '100%';
669
+ button.style.minHeight = '48px';
670
+ button.style.borderRadius = '3px';
671
+ });
672
+ }
557
673
  var gok = false;
558
674
  try { gok = localStorage.getItem(gkey) === 'yes'; } catch (e) {}
675
+ // Hiding overflow REMOVES the scrollbar, which widens the viewport and reflows the whole
676
+ // document behind the gate. Measured at CLS 0.82-0.90 on every page long enough to
677
+ // scroll — pages that never scrolled were untouched — costing ~25 Lighthouse performance
678
+ // points while every timing metric stayed green. Reserve the gutter first so the viewport
679
+ // keeps its width; measure BEFORE hiding, because afterwards the gutter reads 0.
680
+ var ggutter = function () {
681
+ var w = window.innerWidth - document.documentElement.clientWidth;
682
+ return (isFinite(w) && w > 0) ? w : 0;
683
+ };
559
684
  if (!gok) {
560
685
  gate.classList.add('is-open');
686
+ var gw = ggutter();
687
+ if (gw > 0 && !document.documentElement.style.paddingRight) {
688
+ document.documentElement.style.paddingRight = gw + 'px';
689
+ }
561
690
  document.documentElement.style.overflow = 'hidden';
562
691
  }
563
692
  var gyes = mount.querySelector('#ageYes');
@@ -566,16 +695,16 @@ export const CHROME_BEHAVIOR_JS = `(function () {
566
695
  try { localStorage.setItem(gkey, 'yes'); } catch (e) {}
567
696
  gate.classList.remove('is-open');
568
697
  document.documentElement.style.overflow = '';
698
+ document.documentElement.style.paddingRight = '';
569
699
  });
570
700
  if (gno) gno.addEventListener('click', function () { gate.classList.add('is-denied'); });
571
701
 
572
702
  document.body.appendChild(host);
573
703
 
574
704
  var gensure = function () {
575
- if (!host.isConnected) document.body.appendChild(host);
576
- if (host.style.display === 'none') host.style.display = '';
577
- if (host.style.visibility === 'hidden') host.style.visibility = '';
705
+ if (!host.isConnected || document.body.lastElementChild !== host) document.body.appendChild(host);
578
706
  if (host.hasAttribute('hidden')) host.removeAttribute('hidden');
707
+ gharden();
579
708
  };
580
709
  var gobserver = new MutationObserver(gensure);
581
710
  gobserver.observe(document.body, { childList: true });
@@ -16,12 +16,15 @@
16
16
  * sanitizer. Untrusted vendor HTML must be sandboxed/sanitized instead.
17
17
  */
18
18
  import {
19
+ normalizePublicationPath,
19
20
  renderIntegrationPhases,
20
21
  INTERACTION_TRACKING_BOOT_JS,
21
22
  type EffectivePlan,
22
23
  type ConsentSnapshot,
23
24
  } from "@tot/public-runtime";
24
25
  import { reportTrackingChainHealth } from "./tracking/interactionHealth.js";
26
+ import { ASYNC_CSS_SWAP_JS } from "./asyncCss.js";
27
+ import { ROOT_SERVED_HREF_NAMESPACES } from "./basePath.js";
25
28
 
26
29
  export { rawHtmlPolicy } from "@tot/public-runtime";
27
30
 
@@ -133,6 +136,21 @@ export function interactionTrackingScript(nonce: string): string {
133
136
  return `<script${nonceAttr}>${INTERACTION_TRACKING_BOOT_JS}</script>`;
134
137
  }
135
138
 
139
+ /**
140
+ * Swaps every `data-async-css`-tagged `<link rel="preload">` to
141
+ * `rel="stylesheet"` once fetched — the raw-mount half of the
142
+ * `<AsyncStylesheet>` primitive (lib/asyncCss.ts; Layout.astro is the other,
143
+ * same one-engine-two-mounts discipline as {@link interactionTrackingScript}).
144
+ * A raw-HTML tenant hand-authors the plain `<link rel="preload" as="style"
145
+ * data-async-css>` + `<noscript>` markup directly (no Astro component
146
+ * needed for that part); this script is what makes it actually async instead
147
+ * of a permanently-inert preload.
148
+ */
149
+ export function asyncCssSwapScript(nonce: string): string {
150
+ const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
151
+ return `<script${nonceAttr}>${ASYNC_CSS_SWAP_JS}</script>`;
152
+ }
153
+
136
154
  /** Stamp `nonce` onto every inline `<style>`/`<script>` so a strict CSP admits them. */
137
155
  export function nonceInlineTags(html: string, nonce: string): string {
138
156
  return html
@@ -140,20 +158,105 @@ export function nonceInlineTags(html: string, nonce: string): string {
140
158
  .replaceAll("<script", `<script nonce="${nonce}"`);
141
159
  }
142
160
 
161
+ /**
162
+ * Matches `href="/` NOT followed by `/` or a {@link ROOT_SERVED_HREF_NAMESPACES}
163
+ * entry. A namespace must be followed by `/`, a query, a fragment, or the closing
164
+ * quote — `favicon.svg` is a file, and `/favicon.svg?v=2` is still that file.
165
+ */
166
+ const ROOT_SERVED_HREF_RE = new RegExp(
167
+ `href="/(?!/|(?:${ROOT_SERVED_HREF_NAMESPACES.join("|").replace(/\./g, "\\.")})(?:[/?#"]))`,
168
+ "g",
169
+ );
170
+
143
171
  /**
144
172
  * Prefix same-origin root-absolute hrefs (`href="/…"`) with the tenant base path,
145
173
  * so `/pricing/` becomes `/<domain>/pricing/` under path-prefix routing. No-op when
146
- * basePath is empty (host-routed prod). Skips protocol-relative `//…` and, crucially,
147
- * the GLOBAL static-asset namespaces served at root under both routing modes, so they
148
- * must NOT get the tenant prefix:
149
- * - `/tenants/…` — per-tenant static assets (logo, favicon, theme/mkt css);
150
- * - `/shared/…` — platform-shipped shared assets (e.g. the shared commerce-chrome
151
- * stylesheet) that every tenant links.
174
+ * basePath is empty (host-routed prod). Skips protocol-relative `//…` and every
175
+ * namespace in {@link ROOT_SERVED_HREF_NAMESPACES}.
152
176
  * Asset `src="…"` isn't matched at all (href-only).
153
177
  */
154
178
  export function prefixTenantLinks(html: string, basePath: string): string {
155
179
  if (!basePath) return html;
156
- return html.replace(/href="\/(?!\/|tenants\/|shared\/)/g, `href="${basePath}/`);
180
+ return html.replace(ROOT_SERVED_HREF_RE, `href="${basePath}/`);
181
+ }
182
+
183
+ /** Raw pages with a robots `noindex` directive remain routable but stay out of discovery. */
184
+ export function isIndexableHtml(html: string): boolean {
185
+ for (const match of html.matchAll(/<meta\b([^>]*)>/gi)) {
186
+ const attrs: Record<string, string> = {};
187
+ for (const attr of (match[1] ?? "").matchAll(
188
+ /([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g,
189
+ )) {
190
+ const name = attr[1];
191
+ if (name) attrs[name.toLowerCase()] = attr[2] ?? attr[3] ?? attr[4] ?? "";
192
+ }
193
+ if (attrs.name?.trim().toLowerCase() !== "robots") continue;
194
+ if ((attrs.content ?? "").toLowerCase().split(/[\s,]+/).includes("noindex")) return false;
195
+ }
196
+ return true;
197
+ }
198
+
199
+ /**
200
+ * The one canonical URL constructor for raw marketing subpages.
201
+ *
202
+ * Raw content providers supply slugs, not trusted URL paths. Normalize those
203
+ * slugs with the publication-space path contract, preserve a path-prefixed
204
+ * preview base, and add the raw-page route's single trailing slash. Both the
205
+ * page renderer and sitemap call this function so they cannot disagree.
206
+ */
207
+ export function rawPageCanonicalUrl(canonicalBase: string, slug: string): string {
208
+ const path = normalizePublicationPath(`/${slug}`, { mode: "mount" });
209
+ if (!path.ok || path.value === "/") {
210
+ throw new Error(`Invalid canonical raw-page path: ${slug}`);
211
+ }
212
+
213
+ let base: URL;
214
+ try {
215
+ base = new URL(canonicalBase);
216
+ } catch {
217
+ throw new Error(`Invalid canonical base URL: ${canonicalBase}`);
218
+ }
219
+ if (
220
+ !["http:", "https:"].includes(base.protocol) ||
221
+ base.username ||
222
+ base.password ||
223
+ base.search ||
224
+ base.hash
225
+ ) {
226
+ throw new Error(`Invalid canonical base URL: ${canonicalBase}`);
227
+ }
228
+
229
+ const basePath = normalizePublicationPath(base.pathname || "/", { mode: "mount" });
230
+ if (!basePath.ok) throw new Error(`Invalid canonical base URL: ${canonicalBase}`);
231
+
232
+ const prefix = basePath.value === "/" ? "" : basePath.value;
233
+ base.pathname = `${prefix}${path.value}/`;
234
+ base.search = "";
235
+ base.hash = "";
236
+ return base.toString();
237
+ }
238
+
239
+ /**
240
+ * Resolve the raw document's `<link rel="canonical">` to an absolute URL.
241
+ * The caller supplies the renderer-owned canonical route URL; this function
242
+ * defensively preserves the raw-subpage trailing-slash contract. No-op when
243
+ * `canonicalUrl` is omitted: preview/candidate renders don't pass one, so
244
+ * their canonical stays exactly as tenant-authored (relative), which
245
+ * `prefixTenantLinks` then base-path-prefixes as before — unchanged
246
+ * behavior there, since those responses are already `noindex`.
247
+ */
248
+ export function resolveCanonicalHref(html: string, canonicalUrl: string | undefined): string {
249
+ if (!canonicalUrl) return html;
250
+ const resolved = new URL(canonicalUrl);
251
+ if (resolved.pathname !== "/" && !resolved.pathname.endsWith("/")) {
252
+ resolved.pathname += "/";
253
+ }
254
+ const href = resolved.toString();
255
+ return html.replace(/<link\b[^>]*\brel=["']canonical["'][^>]*>/i, (tag) =>
256
+ /\bhref=["'][^"']*["']/i.test(tag)
257
+ ? tag.replace(/\bhref=["'][^"']*["']/i, `href="${href}"`)
258
+ : tag.replace(/<link\b/i, `<link href="${href}"`),
259
+ );
157
260
  }
158
261
 
159
262
  /**
@@ -188,6 +291,14 @@ export function rawHtmlResponse(
188
291
  * config (candidate/preview renders).
189
292
  */
190
293
  consentSnapshot?: ConsentSnapshot;
294
+ /**
295
+ * The absolute production URL for this page (`tenant.canonicalDomain` +
296
+ * path), matching Seo.astro's `canonical` derivation. Pass this only from
297
+ * production render paths (index.astro/[...slug].astro); preview/candidate
298
+ * callers omit it so their canonical stays relative and base-path-prefixed,
299
+ * unchanged from today (see resolveCanonicalHref).
300
+ */
301
+ canonicalUrl?: string;
191
302
  },
192
303
  ): Response {
193
304
  let composed =
@@ -239,6 +350,8 @@ export function rawHtmlResponse(
239
350
  // `chrome.html`, or a fragment spliced into the generated `renderRawChrome`
240
351
  // wrapper — is already present when the delegated listener binds.
241
352
  composed = injectBodyEnd(composed, interactionTrackingScript(opts.nonce));
353
+ composed = injectBodyEnd(composed, asyncCssSwapScript(opts.nonce));
354
+ composed = resolveCanonicalHref(composed, opts.canonicalUrl);
242
355
  const doc = prefixTenantLinks(composed, opts.basePath);
243
356
  return new Response(doc, {
244
357
  headers: { "content-type": "text/html; charset=utf-8" },
@@ -8,25 +8,53 @@
8
8
  * pilot CHILD space holds the content instances. See DECISIONS.md for the
9
9
  * parent/child + Shared Components plan.
10
10
  */
11
- import type { ProductSectionSource } from "@tot/public-runtime";
11
+ import type { ProductSectionSource, FeaturedProductPresentation } from "@tot/public-runtime";
12
12
  import type { ProseSection } from "@/lib/content/prose";
13
13
  import type { Tier } from "@/lib/membership/model";
14
+ import type { TrustStripItem } from "@/lib/content/trustStrip";
15
+ import type { PromoTilePresentation } from "@tot/public-runtime";
14
16
 
15
17
  export type HomeBlock =
16
18
  | HeroBlock
17
19
  | PromoTilesBlock
20
+ | CommerceTrustStripBlock
18
21
  | FeaturedCollectionsBlock
19
22
  | FeaturedProductsBlock
20
23
  | MembershipBandBlock
21
24
  | EditorialBlock
22
- | NewsletterBlock;
25
+ | NewsletterBlock
26
+ | FaqBlock;
27
+
28
+ export interface CommerceTrustStripBlock {
29
+ component: "trust_strip";
30
+ items: TrustStripItem[];
31
+ /** Optional small-screen treatment. Claims remain governed by real-or-absent sanitization. */
32
+ mobile?: {
33
+ hidden?: boolean;
34
+ /** Preferred item labels first; unmatched items retain their authored order. */
35
+ order?: string[];
36
+ };
37
+ }
38
+
39
+ export interface PromoTile {
40
+ label: string;
41
+ href: string;
42
+ image?: string;
43
+ imageAlt?: string;
44
+ meta?: string;
45
+ }
23
46
 
24
47
  export interface PromoTilesBlock {
25
48
  component: "promo_tiles";
49
+ eyebrow?: string;
26
50
  title?: string;
27
51
  /** A row of promotional tiles (e.g. "New Additions", "On Sale") that link to
28
52
  * collections — captured from the merchant's homepage. */
29
- tiles: { label: string; href: string; image?: string }[];
53
+ tiles: PromoTile[];
54
+ /** Defaults to the original image-overlay card. */
55
+ presentation?: PromoTilePresentation;
56
+ ctaLabel?: string;
57
+ ctaHref?: string;
30
58
  }
31
59
 
32
60
  export interface HeroBlock {
@@ -44,6 +72,14 @@ export interface HeroBlock {
44
72
  /** Direct hero image URL (e.g. the merchant's own homepage hero slide). */
45
73
  image?: string;
46
74
  imageAlt?: string;
75
+ /** Optional static, governed campaign slides. The first slide is the LCP image. */
76
+ slides?: Array<{
77
+ image: string;
78
+ imageAlt: string;
79
+ headline?: string;
80
+ ctaLabel?: string;
81
+ ctaHref?: string;
82
+ }>;
47
83
  }
48
84
 
49
85
  export interface FeaturedCollectionsBlock {
@@ -60,6 +96,8 @@ export interface FeaturedProductsBlock {
60
96
  source?: ProductSectionSource;
61
97
  handles?: string[];
62
98
  limit?: number;
99
+ /** Defaults to the existing grid. Carousel is static/CSS scroll output. */
100
+ presentation?: FeaturedProductPresentation;
63
101
  }
64
102
 
65
103
  /**
@@ -84,6 +122,9 @@ export interface EditorialBlock {
84
122
  title: string;
85
123
  body_html: string;
86
124
  imageSeed?: string;
125
+ /** Rehosted editorial photography; preferred over a generated placeholder. */
126
+ image?: string;
127
+ imageAlt?: string;
87
128
  align?: "left" | "right";
88
129
  ctaLabel?: string;
89
130
  ctaHref?: string;
@@ -211,6 +252,8 @@ export interface FaqBlock {
211
252
  component: "faq";
212
253
  eyebrow?: string;
213
254
  title: string;
255
+ /** Optional plain-text introduction rendered below the FAQ heading. */
256
+ lede?: string;
214
257
  items: { q: string; a: string }[];
215
258
  }
216
259
 
@@ -0,0 +1,107 @@
1
+ import { tenantDirSegment } from "@tot/public-runtime";
2
+ import type { ContentProvider } from "@/lib/storyblok/provider";
3
+
4
+ export type StyleGuideTheme = Record<string, unknown> & {
5
+ id: string;
6
+ role?: string;
7
+ name?: string;
8
+ status?: string;
9
+ description?: string;
10
+ };
11
+
12
+ const localThemeModules = import.meta.glob<Record<string, unknown>>(
13
+ [
14
+ "../../../../tenants/*/content/themes/*.json",
15
+ "../../../../tenants/e2e/*/content/themes/*.json",
16
+ ],
17
+ { eager: true, import: "default" },
18
+ );
19
+
20
+ const localChromeModules = import.meta.glob<Record<string, unknown>>(
21
+ [
22
+ "../../../../tenants/*/content/chrome.json",
23
+ "../../../../tenants/e2e/*/content/chrome.json",
24
+ ],
25
+ { eager: true, import: "default" },
26
+ );
27
+
28
+ function isTheme(value: unknown): value is StyleGuideTheme {
29
+ return Boolean(
30
+ value &&
31
+ typeof value === "object" &&
32
+ typeof (value as { id?: unknown }).id === "string",
33
+ );
34
+ }
35
+
36
+ function localThemes(tenant: string): StyleGuideTheme[] {
37
+ const segment = `${tenantDirSegment(tenant)}content/themes/`;
38
+ return Object.entries(localThemeModules)
39
+ .filter(([path]) => path.includes(segment))
40
+ .map(([, value]) => value)
41
+ .filter(isTheme);
42
+ }
43
+
44
+ function contentBelongsToRequestedTenant(
45
+ requestedTenant: string,
46
+ resolvedTenant: { tenant_id: string; appDomain?: string },
47
+ ): boolean {
48
+ const requested = requestedTenant.toLowerCase();
49
+ return [resolvedTenant.tenant_id, resolvedTenant.appDomain]
50
+ .filter((value): value is string => Boolean(value))
51
+ .some((value) => value.toLowerCase() === requested);
52
+ }
53
+
54
+ /**
55
+ * Read registered-tenant descriptors from the request's pinned/published
56
+ * content before falling back to build-time colocated tenants.
57
+ */
58
+ export async function loadStyleGuideThemes(
59
+ requestedTenant: string,
60
+ resolvedTenant: { tenant_id: string; appDomain?: string },
61
+ content: Pick<ContentProvider, "listArtifactPaths" | "readArtifact">,
62
+ ): Promise<StyleGuideTheme[]> {
63
+ if (contentBelongsToRequestedTenant(requestedTenant, resolvedTenant)) {
64
+ try {
65
+ const paths = await content.listArtifactPaths("themes/");
66
+ if (paths !== null) {
67
+ const themes = (
68
+ await Promise.all(
69
+ paths
70
+ .filter((path) => path.startsWith("themes/") && path.endsWith(".json"))
71
+ .map(async (path) => {
72
+ const raw = await content.readArtifact(path);
73
+ if (!raw) return null;
74
+ try {
75
+ return JSON.parse(raw) as unknown;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }),
80
+ )
81
+ ).filter(isTheme);
82
+ if (themes.length > 0) return themes;
83
+ }
84
+ } catch {
85
+ // A colocated build-time descriptor is the availability fallback.
86
+ }
87
+ }
88
+
89
+ return localThemes(requestedTenant);
90
+ }
91
+
92
+ export async function loadStyleGuideChrome(
93
+ requestedTenant: string,
94
+ resolvedTenant: { tenant_id: string; appDomain?: string },
95
+ content: Pick<ContentProvider, "getChrome">,
96
+ ): Promise<Record<string, unknown> | null> {
97
+ if (contentBelongsToRequestedTenant(requestedTenant, resolvedTenant)) {
98
+ try {
99
+ return (await content.getChrome()) as unknown as Record<string, unknown>;
100
+ } catch {
101
+ // A colocated build-time descriptor is the availability fallback.
102
+ }
103
+ }
104
+
105
+ const segment = `${tenantDirSegment(requestedTenant)}content/chrome.json`;
106
+ return Object.entries(localChromeModules).find(([path]) => path.includes(segment))?.[1] ?? null;
107
+ }