@tokenoftrust/storefront-runner 2.0.41 → 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 (147) 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 +2 -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/index.ts +8 -0
  95. package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
  96. package/packages/public-runtime/src/integration/consent-config.ts +9 -0
  97. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
  98. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  99. package/packages/public-runtime/src/integration/index.ts +1 -0
  100. package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
  101. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  102. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  103. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  104. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
  105. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
  107. package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
  108. package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
  109. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  110. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  111. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  112. package/packages/public-runtime/src/publication-space.ts +24 -3
  113. package/packages/public-runtime/src/static-bundle.ts +145 -26
  114. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  115. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  116. package/packages/public-runtime/src/tenant.ts +59 -1
  117. package/pnpm-lock.runner.yaml +12 -0
  118. package/pnpm-workspace.yaml +4 -0
  119. package/scripts/dev/git-status.mjs +1 -1
  120. package/scripts/dev/publish.mjs +6 -0
  121. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  122. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  123. package/scripts/tenant/validate.mjs +171 -8
  124. package/apps/storefront/src/lib/cfImage.ts +0 -55
  125. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  126. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  127. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  128. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  129. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  144. package/tenants/home/public/js/storefront.js +0 -129
  145. package/tenants/home/public/logo-wordmark.png +0 -0
  146. package/tenants/home/public/pages/storefront.css +0 -235
  147. package/tenants/home/theme.json +0 -6
@@ -475,10 +475,10 @@ export class ConsentBroker {
475
475
  /**
476
476
  * Viewer-owned analytics consent for the public storefront document.
477
477
  *
478
- * The tenant configuration says which purposes may exist; it never speaks for the
479
- * viewer. This controller therefore removes a baked analytics grant until the viewer
480
- * has made an explicit, unexpired choice. The same synchronous early boot runs in both
481
- * document mounts before any integration or measurement loader can execute.
478
+ * The tenant configuration supplies the analytics default and a stored viewer choice
479
+ * overrides it. Global Privacy Control always denies analytics. The same synchronous
480
+ * early boot runs in both document mounts before any integration or measurement loader
481
+ * can execute.
482
482
  */
483
483
 
484
484
  export const VIEWER_ANALYTICS_CONSENT_STORAGE_KEY = "tot.viewer-analytics-consent.v1";
@@ -492,7 +492,7 @@ export type ViewerAnalyticsConsentDecision = "granted" | "denied";
492
492
  export interface ViewerAnalyticsConsentState {
493
493
  schemaVersion: 1;
494
494
  decision: ViewerAnalyticsConsentDecision;
495
- source: "default-deny" | "stored" | "global-privacy-control";
495
+ source: "tenant-default" | "default-deny" | "stored" | "global-privacy-control";
496
496
  canGrant: boolean;
497
497
  expiresAt: number | null;
498
498
  retentionDays: typeof VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS;
@@ -591,6 +591,7 @@ export function applyEarlyViewerAnalyticsConsent(
591
591
  // approval. Only the current authenticated server-rendered snapshot may grant.
592
592
  const contextComplete = tenantPublicId.length > 0 && environment.length > 0 && siteSurface.length > 0;
593
593
  const tenantApprovedAnalytics = contextComplete && rawApproved.includes("analytics");
594
+ const tenantDefaultAnalytics = tenantApprovedAnalytics && rawEligible.includes("analytics");
594
595
  const globalPrivacyControl = win.navigator?.globalPrivacyControl === true;
595
596
 
596
597
  let stored: { decision: ViewerAnalyticsConsentDecision; expiresAt: number } | null = null;
@@ -631,12 +632,14 @@ export function applyEarlyViewerAnalyticsConsent(
631
632
  const decision: ViewerAnalyticsConsentDecision =
632
633
  globalPrivacyControl || !tenantApprovedAnalytics
633
634
  ? "denied"
634
- : stored?.decision ?? "denied";
635
+ : stored?.decision ?? (tenantDefaultAnalytics ? "granted" : "denied");
635
636
  const source: ViewerAnalyticsConsentState["source"] = globalPrivacyControl
636
637
  ? "global-privacy-control"
637
638
  : stored
638
639
  ? "stored"
639
- : "default-deny";
640
+ : tenantDefaultAnalytics
641
+ ? "tenant-default"
642
+ : "default-deny";
640
643
 
641
644
  const eligible = canonicalPurposes.filter(
642
645
  (purpose) =>
@@ -258,3 +258,12 @@ export function unmetConsentDependencies(
258
258
  export function emptyConsentConfig(): TenantConsentConfig {
259
259
  return { schemaVersion: CONSENT_CONFIG_SCHEMA_VERSION, purposes: [], cmp: { mode: "none" } };
260
260
  }
261
+
262
+ /** Platform default when a tenant configured analytics and saved no consent override. */
263
+ export function defaultAnalyticsConsentConfig(): TenantConsentConfig {
264
+ return {
265
+ schemaVersion: CONSENT_CONFIG_SCHEMA_VERSION,
266
+ purposes: [{ purpose: "analytics", legalBasis: "legitimate-interest", defaultState: "granted" }],
267
+ cmp: { mode: "none" },
268
+ };
269
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The platform-owned browser binding for the two reviewed contact-sales CTAs.
2
+ * The platform-owned browser binding for reviewed contact-sales CTA identities.
3
3
  *
4
4
  * This is deliberately not a general interaction adapter. Both DOM identifiers and
5
5
  * the emitted semantic name are fixed below; the source reads no labels, URLs,
@@ -10,19 +10,41 @@ import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety
10
10
 
11
11
  /** The only reviewed CTA identities that may raise this marketing action. */
12
12
  export const CONTACT_SALES_ACTION_SOURCES = [
13
- { ctaId: "cta-book-demo", logicalId: "contact-sales-book-demo" },
14
- { ctaId: "contact-sales", logicalId: "contact-sales-lower" },
13
+ { selector: '[data-cta-id="cta-book-demo"]', logicalId: "contact-sales-book-demo" },
14
+ { selector: '[data-cta-id="book-demo"]', logicalId: "contact-sales-book-demo" },
15
+ { selector: '[data-cta-id="contact-sales"]', logicalId: "contact-sales-lower" },
16
+ { selector: '[data-tot-action="contact-sales"]', logicalId: "contact-sales-action" },
17
+ { selector: '[data-tot-action="nav-contact-sales"]', logicalId: "contact-sales-nav" },
15
18
  ] as const;
16
19
 
17
- const CONTACT_SALES_ACTION_NAME = "user_clicked_contact_sales_mktg" as const;
20
+ export const CONTACT_SALES_ACTION_NAME = "user_clicked_contact_sales_mktg" as const;
21
+ /** The reviewed action allowlist paired with this fixed platform source. */
22
+ export const CONTACT_SALES_ACTION_REGISTRY = {
23
+ schemaVersion: 1,
24
+ actions: [{ name: CONTACT_SALES_ACTION_NAME }],
25
+ } as const;
18
26
  const CORRELATION_TTL_MS = 60 * 60 * 1000;
27
+ export const CONTACT_SALES_NAVIGATION_DEFER_MS = 100;
28
+
29
+ export interface ContactSalesClickEvent {
30
+ altKey?: boolean;
31
+ button?: number;
32
+ ctrlKey?: boolean;
33
+ defaultPrevented?: boolean;
34
+ metaKey?: boolean;
35
+ preventDefault?(): void;
36
+ shiftKey?: boolean;
37
+ }
19
38
 
20
39
  export interface ContactSalesActionElement {
21
- addEventListener(type: "click", listener: () => void): void;
22
- removeEventListener(type: "click", listener: () => void): void;
40
+ download?: string;
41
+ href?: string;
42
+ target?: string;
43
+ addEventListener(type: "click", listener: (event: ContactSalesClickEvent) => void): void;
44
+ removeEventListener(type: "click", listener: (event: ContactSalesClickEvent) => void): void;
23
45
  }
24
46
 
25
- /** The narrow DOM seam used only to find the two fixed platform CTA identities. */
47
+ /** The narrow DOM seam used only to find the fixed platform CTA identities. */
26
48
  export interface ContactSalesActionDocument {
27
49
  querySelectorAll(selector: string): ArrayLike<ContactSalesActionElement>;
28
50
  }
@@ -31,10 +53,23 @@ export interface ContactSalesActionSourceOptions {
31
53
  document?: ContactSalesActionDocument;
32
54
  planRevision: string;
33
55
  now?: () => number;
56
+ navigate?: (href: string) => void;
57
+ schedule?: (callback: () => void, delayMs: number) => void;
58
+ }
59
+
60
+ function deferredNavigationHref(node: ContactSalesActionElement, event: ContactSalesClickEvent): string | undefined {
61
+ if (
62
+ event.defaultPrevented || (event.button !== undefined && event.button !== 0) ||
63
+ event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
64
+ typeof event.preventDefault !== "function" || typeof node.href !== "string" || !node.href ||
65
+ (typeof node.target === "string" && node.target !== "" && node.target !== "_self") ||
66
+ (typeof node.download === "string" && node.download !== "")
67
+ ) return undefined;
68
+ return node.href;
34
69
  }
35
70
 
36
71
  /**
37
- * Bind literal `data-cta-id` values to a typed source for the managed-analytics
72
+ * Bind the fixed reviewed DOM identities to a typed source for the managed-analytics
38
73
  * bridge. A keyboard activation of an anchor/button produces its native `click`, so
39
74
  * it follows this same semantic path without a separate key listener.
40
75
  */
@@ -43,6 +78,7 @@ export function createContactSalesActionSource(
43
78
  ): PlatformManagedActionSource {
44
79
  const document = options.document;
45
80
  const now = options.now ?? Date.now;
81
+ const schedule = options.schedule ?? ((callback, delayMs) => { setTimeout(callback, delayMs); });
46
82
  const revisionId = managedAnalyticsOpaqueRevision(options.planRevision);
47
83
  let sequence = 0;
48
84
 
@@ -50,10 +86,15 @@ export function createContactSalesActionSource(
50
86
  subscribe(listener) {
51
87
  if (!document || typeof document.querySelectorAll !== "function") return () => {};
52
88
  const removals: Array<() => void> = [];
89
+ const bound = new Set<ContactSalesActionElement>();
53
90
  for (const source of CONTACT_SALES_ACTION_SOURCES) {
54
- const nodes = document.querySelectorAll(`[data-cta-id="${source.ctaId}"]`);
91
+ const nodes = document.querySelectorAll(source.selector);
55
92
  for (const node of Array.from(nodes)) {
56
- const emit = (): void => {
93
+ if (bound.has(node)) continue;
94
+ bound.add(node);
95
+ const emit = (event: ContactSalesClickEvent = {}): void => {
96
+ const navigationHref = options.navigate ? deferredNavigationHref(node, event) : undefined;
97
+ if (navigationHref) event.preventDefault?.();
57
98
  sequence += 1;
58
99
  const at = now();
59
100
  const activation: PlatformManagedActionActivation = {
@@ -66,6 +107,11 @@ export function createContactSalesActionSource(
66
107
  sequence,
67
108
  };
68
109
  listener(activation);
110
+ // A full-document navigation destroys the isolated GA4 iframe. Give its
111
+ // MessagePort one bounded turn to receive the reviewed action first.
112
+ if (navigationHref) {
113
+ schedule(() => options.navigate?.(navigationHref), CONTACT_SALES_NAVIGATION_DEFER_MS);
114
+ }
69
115
  };
70
116
  node.addEventListener("click", emit);
71
117
  removals.push(() => node.removeEventListener("click", emit));
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The governed event-property set every reviewed analytics adapter attaches to
3
+ * every event it sends.
4
+ *
5
+ * These three values answer "whose traffic, on which surface, from which stage"
6
+ * — the question a merchant has to answer before any storefront number is worth
7
+ * reading. They are DERIVED from the renderer's own trusted context
8
+ * (`BrowserEnvironment` + the server-stamped tenant identity), never declared by
9
+ * a tenant and never read from the browser. That is the whole point: a tenant
10
+ * that could type its own `tenant` or `environment` could mislabel its traffic,
11
+ * so the adapter configuration contract bans those keys outright
12
+ * (`managed-analytics-api-v1.schemas.ts` FORBIDDEN_KEY_PATTERN, `secrets.ts`
13
+ * findForbiddenFields) and this module supplies them instead.
14
+ *
15
+ * They carry no person, device, account, or network signal, so they add nothing
16
+ * to the anonymous-collection posture the adapters already hold.
17
+ */
18
+ import {
19
+ validateBrowserEnvironment,
20
+ type BrowserEnvironment,
21
+ type IntegrationEnvironment,
22
+ type IntegrationSiteSurface,
23
+ } from "./environment.js";
24
+
25
+ /** The exact, closed key set. Adding a key here is a reviewed platform change. */
26
+ export const GOVERNED_EVENT_PROPERTY_KEYS = Object.freeze([
27
+ "tenant",
28
+ "environment",
29
+ "site_surface",
30
+ ] as const);
31
+
32
+ export interface GovernedEventProperties {
33
+ /** The tenant's public id, e.g. `tokenoftrust.com`. Server-stamped. */
34
+ tenant: string;
35
+ /** The four-stage publication stage: `local` | `candidate` | `staged` | `live`. */
36
+ environment: IntegrationEnvironment;
37
+ /** The document surface: `storefront` | `checkout` | `admin` | `marketing`. */
38
+ site_surface: IntegrationSiteSurface;
39
+ }
40
+
41
+ /**
42
+ * Build the governed property set, or null when the context cannot be trusted.
43
+ *
44
+ * Fail-closed is deliberate and matches every other adapter gate: an adapter
45
+ * that cannot prove which tenant and stage an event belongs to must not label
46
+ * it, and a caller that gets null drops the event rather than sending it
47
+ * unlabelled. Mislabelled traffic is worse than absent traffic — it silently
48
+ * corrupts every report built on it.
49
+ */
50
+ export function buildGovernedEventProperties(
51
+ environment: BrowserEnvironment,
52
+ tenantPublicId: string,
53
+ ): GovernedEventProperties | null {
54
+ const validated = validateBrowserEnvironment(environment);
55
+ if (!validated.ok) return null;
56
+ if (typeof tenantPublicId !== "string" || tenantPublicId.length === 0) return null;
57
+ return Object.freeze({
58
+ tenant: tenantPublicId,
59
+ environment: validated.value.environment,
60
+ site_surface: validated.value.siteSurface,
61
+ });
62
+ }
@@ -59,6 +59,7 @@ export * from "./platform-adapters.js";
59
59
  export * from "./native-amplitude.js";
60
60
  export * from "./native-ga4.js";
61
61
  export * from "./native-ga4-sandbox.js";
62
+ export * from "./native-ga4-sandbox-asset.mjs";
62
63
  export * from "./platform-adapter-runtime.js";
63
64
  export * from "./render-phases.js";
64
65
  export * from "./interim-bridge.js";
@@ -51,6 +51,7 @@ import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js
51
51
  import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
52
52
  import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
53
53
  import {
54
+ CONTACT_SALES_ACTION_REGISTRY,
54
55
  createContactSalesActionSource,
55
56
  type ContactSalesActionDocument,
56
57
  } from "./contact-sales-action-source.js";
@@ -68,7 +69,7 @@ export const LIFECYCLE_NAVIGATION_SINK_GLOBAL = "__TOT_INTEGRATION_NAVIGATION__"
68
69
 
69
70
  /** The narrow slice of `window` the binding depends on — structural, for edge-safe typing. */
70
71
  export interface LifecycleWindow {
71
- location: { pathname: string; search: string; hash: string; href: string };
72
+ location: { pathname: string; search: string; hash: string; href: string; assign?: (href: string) => void };
72
73
  addEventListener(type: string, listener: (event: unknown) => void): void;
73
74
  fetch?: typeof fetch;
74
75
  /**
@@ -207,6 +208,7 @@ function makeDocumentCommerceBroker(
207
208
  },
208
209
  source: `//${descriptor.tenantPublicId}`,
209
210
  consentSnapshot: getConsentSnapshot,
211
+ tenantActionRegistry: CONTACT_SALES_ACTION_REGISTRY,
210
212
  });
211
213
  }
212
214
 
@@ -242,7 +244,7 @@ function documentPageView(
242
244
  const id = `pageview-${managedAnalyticsOpaqueRevision(descriptor.planRevision)}`;
243
245
  return {
244
246
  logicalId: id,
245
- path: win.location.pathname,
247
+ path: canonicalDocumentPath(win.location.pathname, descriptor.tenantPublicId),
246
248
  correlation: {
247
249
  id,
248
250
  expiresAt: new Date(now() + PAGE_VIEW_CORRELATION_TTL_MS).toISOString(),
@@ -251,6 +253,28 @@ function documentPageView(
251
253
  };
252
254
  }
253
255
 
256
+ /**
257
+ * Project immutable preview wrapper routes back to the tenant path they render.
258
+ * The wrapper contains a commit SHA, which is both non-canonical and deliberately
259
+ * rejected by the analytics value-safety boundary. Public/live routes pass through.
260
+ */
261
+ export function canonicalDocumentPath(pathname: string, tenantPublicId: string): string {
262
+ const segments = pathname.split("/");
263
+ const previewIndex = segments[1] === tenantPublicId && segments[2] === "preview"
264
+ ? 2
265
+ : segments[1] === "preview"
266
+ ? 1
267
+ : -1;
268
+ if (
269
+ previewIndex !== -1 &&
270
+ (segments[previewIndex + 1] === "staged" || segments[previewIndex + 1] === "rev") &&
271
+ /^[0-9a-f]{40,64}$/i.test(segments[previewIndex + 2] ?? "")
272
+ ) {
273
+ return `/${segments.slice(previewIndex + 3).join("/")}`;
274
+ }
275
+ return pathname;
276
+ }
277
+
254
278
  function readDescriptor(win: LifecycleWindow): IntegrationBootDescriptor | null {
255
279
  const descriptor = win[INTEGRATION_PLAN_GLOBAL];
256
280
  if (!descriptor || typeof descriptor !== "object") return null;
@@ -364,6 +388,10 @@ export function bootIntegrationLifecycle(
364
388
  : undefined,
365
389
  planRevision: descriptor.planRevision,
366
390
  now: options.now ?? Date.now,
391
+ navigate: (href) => {
392
+ if (typeof win.location.assign === "function") win.location.assign(href);
393
+ else win.location.href = href;
394
+ },
367
395
  }),
368
396
  }),
369
397
  pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),
@@ -1,3 +1,5 @@
1
1
  /** Types for the dual-consumer (`.ts` engine + `.mjs` build plugin) host-asset path. */
2
2
  export declare const LIFECYCLE_HOST_ASSET_PATH: string;
3
3
  export declare const LIFECYCLE_HOST_ASSET_URL: string;
4
+ export declare const LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS: readonly string[];
5
+ export declare const LIFECYCLE_HOST_COMPATIBILITY_ASSET_URLS: readonly string[];
@@ -3,15 +3,29 @@
3
3
  *
4
4
  * It lives in a `.mjs` module because it has TWO consumers that cannot share a `.ts`
5
5
  * one: the render engine (`render-phases.ts`) that emits the `<script src>`, and the
6
- * build plugin (`apps/storefront/build-plugins/lifecycle-host-asset.mjs`) that produces
6
+ * build integration (`apps/storefront/integrations/lifecycle-host-asset.mjs`) that produces
7
7
  * the file at that exact path. One source of truth, so the tag and the artifact cannot
8
8
  * disagree — a mismatch would leave every static tenant page with plan data and no host,
9
9
  * which fails silently in exactly the way it is not allowed to.
10
10
  *
11
- * The path is version-pinned (`/v1/`) like a platform adapter entry, so a future host
11
+ * The path is version-pinned (`/v3/`) like a platform adapter entry, so a future host
12
12
  * with a different boot contract ships alongside rather than replacing this one.
13
13
  */
14
- export const LIFECYCLE_HOST_ASSET_PATH = "platform/lifecycle/v1/boot.mjs";
14
+ export const LIFECYCLE_HOST_ASSET_PATH = "platform/lifecycle/v3/boot.mjs";
15
+
16
+ /**
17
+ * Earlier immutable URLs that must keep serving the current host bytes during a
18
+ * version transition. Cached HTML may still reference one of these paths after a
19
+ * pointer flip, so dropping it would turn a cache refresh into a lifecycle 404.
20
+ */
21
+ export const LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS = Object.freeze([
22
+ "platform/lifecycle/v2/boot.mjs",
23
+ ]);
15
24
 
16
25
  /** The root-absolute, same-origin URL the served markup references. */
17
26
  export const LIFECYCLE_HOST_ASSET_URL = `/${LIFECYCLE_HOST_ASSET_PATH}`;
27
+
28
+ /** Root-absolute compatibility URLs emitted alongside the current URL. */
29
+ export const LIFECYCLE_HOST_COMPATIBILITY_ASSET_URLS = Object.freeze(
30
+ LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS.map((path) => `/${path}`),
31
+ );
@@ -20,6 +20,10 @@ import {
20
20
  type IntegrationEnvironment,
21
21
  } from "./environment.js";
22
22
  import { resemblesManagedAnalyticsPii } from "./managed-analytics-value-safety.js";
23
+ import {
24
+ buildGovernedEventProperties,
25
+ type GovernedEventProperties,
26
+ } from "./governed-event-properties.js";
23
27
  import type {
24
28
  IntegrationEventSubscribe,
25
29
  IntegrationLifecycleContext,
@@ -47,7 +51,7 @@ export interface AmplitudeHttpEvent {
47
51
  event_type: "page_view" | (typeof APPROVED_MARKETING_ACTION_NAMES)[number];
48
52
  device_id: typeof ANONYMOUS_DEVICE_ID;
49
53
  insert_id: string;
50
- event_properties: { page_path: string } | Record<string, never>;
54
+ event_properties: GovernedEventProperties & { page_path?: string };
51
55
  }
52
56
 
53
57
  export const AMPLITUDE_DEDUPE_CAPACITY = 512 as const;
@@ -127,6 +131,11 @@ export function mapCommerceEventToAmplitude(
127
131
  ) return null;
128
132
  if (!event.consent.eligiblePurposes.includes("analytics")) return null;
129
133
 
134
+ // Derived from the same trusted context the equality check above just proved
135
+ // this event agrees with, so labelling can never disagree with admission.
136
+ const governed = buildGovernedEventProperties(environmentResult.value, tenantPublicId);
137
+ if (!governed) return null;
138
+
130
139
  const base = {
131
140
  device_id: ANONYMOUS_DEVICE_ID,
132
141
  insert_id: eventDedupeKey(event),
@@ -137,12 +146,12 @@ export function mapCommerceEventToAmplitude(
137
146
  return {
138
147
  ...base,
139
148
  event_type: name as (typeof APPROVED_MARKETING_ACTION_NAMES)[number],
140
- event_properties: {},
149
+ event_properties: { ...governed },
141
150
  };
142
151
  }
143
152
  const path = event.data["path"];
144
153
  if (!isSafePagePath(path)) return null;
145
- return { ...base, event_type: "page_view", event_properties: { page_path: path } };
154
+ return { ...base, event_type: "page_view", event_properties: { ...governed, page_path: path } };
146
155
  }
147
156
 
148
157
  export interface RegisterAmplitudeHttpDestinationInput {
@@ -20,6 +20,15 @@ export function resolveNativeGa4SandboxUrl(parentHref) {
20
20
  if (parent.hostname === "tokenoftrust.store" || parent.hostname.endsWith(".tokenoftrust.store")) {
21
21
  return new URL(NATIVE_GA4_SANDBOX_ASSET_URL, NATIVE_GA4_SANDBOX_ORIGIN).href;
22
22
  }
23
+ // A static publication verification alias is deliberately not the tenant's
24
+ // canonical apex/www pair. Its frame must still share the tenant's site: gtag.js
25
+ // loads but suppresses collection from a third-party frame. The corresponding
26
+ // analytics-frame hostname is provisioned as a Cloudflare-for-SaaS custom host.
27
+ if (parent.hostname.startsWith("published.")) {
28
+ const sandbox = new URL(NATIVE_GA4_SANDBOX_ASSET_URL, parent);
29
+ sandbox.hostname = `analytics-frame.${parent.hostname.slice("published.".length)}`;
30
+ return sandbox.href;
31
+ }
23
32
  const sandbox = new URL(NATIVE_GA4_SANDBOX_ASSET_URL, parent);
24
33
  sandbox.hostname = parent.hostname.startsWith("www.")
25
34
  ? parent.hostname.slice("www.".length)
@@ -9,6 +9,7 @@ import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-val
9
9
 
10
10
  const MEASUREMENT_ID = /^G-[A-Z0-9]{4,20}$/;
11
11
  const CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
12
+ const GOVERNED_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
13
  const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
13
14
 
14
15
  function plainRecord(value: unknown): Record<string, unknown> | null {
@@ -44,6 +45,20 @@ function validIds(value: unknown): value is string[] {
44
45
  value.every((id) => typeof id === "string" && MEASUREMENT_ID.test(id)) && new Set(value).size === value.length;
45
46
  }
46
47
 
48
+ /**
49
+ * Governed labelling arrives across a hostile boundary, so it is re-validated
50
+ * here rather than trusted: the exact three keys, each a bare token. `tenant`
51
+ * is a public host id and the other two are closed enums the parent derives, so
52
+ * nothing here can smuggle a value gtag would treat as anything but a label.
53
+ */
54
+ function validGovernedProperties(value: unknown): value is Record<string, string> {
55
+ const record = plainRecord(value);
56
+ if (!record || !exactKeys(record, ["tenant", "environment", "site_surface"])) return false;
57
+ return Object.values(record).every((item) =>
58
+ typeof item === "string" && GOVERNED_VALUE.test(item) &&
59
+ !resemblesManagedAnalyticsProtectedValue(item));
60
+ }
61
+
47
62
  function validCampaign(value: unknown): value is Record<string, string> {
48
63
  const record = plainRecord(value);
49
64
  if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
@@ -86,6 +101,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
86
101
  let disposed = false;
87
102
  let canonicalOrigin = "";
88
103
  let currentDestinations: string[] = [];
104
+ let governedProperties: Record<string, string> = {};
89
105
  let lastPageLocation = "";
90
106
  let tagLoaded = false;
91
107
 
@@ -98,13 +114,15 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
98
114
 
99
115
  const configure = (message: Record<string, unknown>): void => {
100
116
  if (
101
- !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent"]) ||
117
+ !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"]) ||
102
118
  message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
103
119
  message["analyticsConsent"] !== "granted" ||
104
- !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"])
120
+ !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"]) ||
121
+ !validGovernedProperties(message["governedProperties"])
105
122
  ) return;
106
123
  const ids = message["destinationIds"];
107
124
  canonicalOrigin = message["canonicalOrigin"];
125
+ governedProperties = { ...message["governedProperties"] };
108
126
  currentDestinations = [...ids];
109
127
  lastPageLocation = "";
110
128
  active = true;
@@ -152,6 +170,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
152
170
  const pageLocation = `${canonicalOrigin}${message["path"]}`;
153
171
  lastPageLocation = pageLocation;
154
172
  const params: Record<string, unknown> = {
173
+ ...governedProperties,
155
174
  page_location: pageLocation,
156
175
  page_path: message["path"],
157
176
  };
@@ -175,7 +194,12 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
175
194
  message["pageLocation"] !== lastPageLocation || !lastPageLocation
176
195
  ) return;
177
196
  for (const id of currentDestinations) {
178
- gtag("event", message["name"], { send_to: id, page_location: lastPageLocation });
197
+ gtag("event", message["name"], {
198
+ ...governedProperties,
199
+ send_to: id,
200
+ page_location: lastPageLocation,
201
+ transport_type: "beacon",
202
+ });
179
203
  }
180
204
  }
181
205
  };
@@ -1,4 +1,7 @@
1
1
  /** Closed parent/iframe protocol for the reviewed GA4 isolated-origin sandbox. */
2
+ import type { GovernedEventProperties } from "./governed-event-properties.js";
3
+ import { resolveNativeGa4SandboxUrl } from "./native-ga4-sandbox-asset.mjs";
4
+
2
5
  export const NATIVE_GA4_SANDBOX_PROTOCOL = "tot.ga4-sandbox/1" as const;
3
6
  export const NATIVE_GA4_SANDBOX_MAX_DESTINATIONS = 4 as const;
4
7
 
@@ -8,6 +11,12 @@ export interface NativeGa4SandboxConfig {
8
11
  canonicalOrigin: string;
9
12
  /** The parent may configure GA4 only after the platform consent gate grants analytics. */
10
13
  analyticsConsent: "granted";
14
+ /**
15
+ * Governed labelling attached to every event this sandbox sends. Configured
16
+ * once rather than per command: all three are constant for the document's
17
+ * life, so a per-event copy would only widen the hostile boundary.
18
+ */
19
+ governedProperties: GovernedEventProperties;
11
20
  }
12
21
 
13
22
  export interface NativeGa4PageViewCommand {
@@ -97,6 +106,10 @@ export function createNativeGa4SandboxTransport(
97
106
  if (!host.parentOrigin || sandboxOrigin === host.parentOrigin) {
98
107
  throw new Error("ga4_sandbox_origin_not_isolated");
99
108
  }
109
+ const expectedSandboxOrigin = new URL(resolveNativeGa4SandboxUrl(host.parentOrigin)).origin;
110
+ if (sandboxOrigin !== expectedSandboxOrigin) {
111
+ throw new Error("ga4_sandbox_origin_not_same_site");
112
+ }
100
113
  const frame = host.createFrame();
101
114
  frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
102
115
  frame.setAttribute("referrerpolicy", "no-referrer");
@@ -17,6 +17,10 @@ import {
17
17
  } from "./platform-adapters.js";
18
18
  import { INTEGRATION_ENVIRONMENTS, type IntegrationEnvironment } from "./environment.js";
19
19
  import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
20
+ import {
21
+ buildGovernedEventProperties,
22
+ type GovernedEventProperties,
23
+ } from "./governed-event-properties.js";
20
24
  import type {
21
25
  NativeGa4SandboxCommand,
22
26
  NativeGa4SandboxConfig,
@@ -72,6 +76,7 @@ interface ActiveGa4Config {
72
76
  environment: IntegrationEnvironment;
73
77
  siteSurface: IntegrationLifecycleContext["environment"]["siteSurface"];
74
78
  canonicalOrigin: string;
79
+ governedProperties: GovernedEventProperties;
75
80
  }
76
81
 
77
82
  function observeSafely(observer: NativeGa4Observer | undefined, code: NativeGa4DiagnosticCode): void {
@@ -101,11 +106,16 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
101
106
  destinationIds.length === 0 || typeof canonicalHost !== "string" || canonicalHost.length === 0 ||
102
107
  canonicalHost.includes("/") || canonicalHost.includes("?") || canonicalHost.includes("#")
103
108
  ) return null;
109
+ // Unlabellable traffic is not collected: without a provable tenant and stage
110
+ // every number downstream is unattributable, so this joins the other gates.
111
+ const governedProperties = buildGovernedEventProperties(ctx.environment, ctx.tenantPublicId);
112
+ if (!governedProperties) return null;
104
113
  return {
105
114
  destinationIds,
106
115
  environment: ctx.environment.environment,
107
116
  siteSurface: ctx.environment.siteSurface,
108
117
  canonicalOrigin: `https://${canonicalHost}`,
118
+ governedProperties,
109
119
  };
110
120
  }
111
121
 
@@ -228,6 +238,7 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
228
238
  destinationIds: [...next.destinationIds],
229
239
  canonicalOrigin: next.canonicalOrigin,
230
240
  analyticsConsent: "granted" as const,
241
+ governedProperties: { ...next.governedProperties },
231
242
  };
232
243
  runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
233
244
  } catch {
@@ -490,7 +490,11 @@ export function buildPlatformAdapterDeclaration(
490
490
  if (def.adapterId === GOOGLE_MEASUREMENT_ADAPTER.adapterId) {
491
491
  const canonicalHost = envResult.value.canonicalHost.toLowerCase();
492
492
  const apexHost = canonicalHost.startsWith("www.") ? canonicalHost.slice(4) : canonicalHost;
493
- cspSources["frame-src"] = [`https://${apexHost}`, `https://www.${apexHost}`];
493
+ cspSources["frame-src"] = [
494
+ `https://${apexHost}`,
495
+ `https://analytics-frame.${apexHost}`,
496
+ `https://www.${apexHost}`,
497
+ ];
494
498
  }
495
499
 
496
500
  // Public config: one flat, primitive, PUBLIC id per configured stage. These are
@@ -159,6 +159,7 @@ export function renderIntegrationPhases(
159
159
  assertRenderablePlan(plan);
160
160
 
161
161
  const head =
162
+ integrationEntryPreloadLinks(plan) +
162
163
  phaseBoundary("platform.environment") +
163
164
  renderBrowserEnvironmentScript(plan.environment, nonce) +
164
165
  phaseBoundary("consent.defaults") +
@@ -197,6 +198,31 @@ export function renderIntegrationPhases(
197
198
  * under the raw mount, which stamps nonces onto the tenant's own inline tags BEFORE
198
199
  * splicing engine bytes.
199
200
  */
201
+ /**
202
+ * `modulepreload` hints for every declared integration's own same-origin entry
203
+ * asset (e.g. `/platform/google-measurement/v2/entry.mjs`,
204
+ * `/platform/amplitude-http/v1/entry.mjs`) — never a vendor/third-party URL, since
205
+ * `entry.url` is always the plan's own resolved, same-origin, exact-version asset.
206
+ *
207
+ * This is a pure network-timing hint: it lets the browser start fetching an
208
+ * integration's entry module WHILE the document.deferred lifecycle host
209
+ * (`boot.mjs`) is still being fetched/parsed, instead of only starting that fetch
210
+ * after the host finishes executing its sequential per-integration load loop. It
211
+ * changes nothing about WHEN or WHETHER an integration actually initializes: the
212
+ * consent gate and the lifecycle runtime's own dynamic `import()` are unchanged,
213
+ * so a preloaded-but-consent-gated module still never runs, and a preload can
214
+ * only ever fetch bytes this document would fetch anyway once eligible.
215
+ */
216
+ function integrationEntryPreloadLinks(plan: EffectivePlan): string {
217
+ const urls = new Set<string>();
218
+ for (const integration of plan.integrations) {
219
+ if (integration.entry.format === "module") urls.add(integration.entry.url);
220
+ }
221
+ let out = "";
222
+ for (const url of urls) out += `<link rel="modulepreload" href="${url}">`;
223
+ return out;
224
+ }
225
+
200
226
  function lifecycleHostScript(): string {
201
227
  return `<script type="module" src="${LIFECYCLE_HOST_ASSET_URL}"></script>`;
202
228
  }
@@ -208,12 +234,12 @@ function inlineScript(source: string, nonce: string): string {
208
234
 
209
235
  /** Renderer-owned, provider-neutral viewer control: identical in both document mounts. */
210
236
  function viewerAnalyticsConsentStyle(nonce: string): string {
211
- const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}`;
237
+ const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}`;
212
238
  return `<style${nonce ? ` nonce="${nonce}"` : ""}>${style}</style>`;
213
239
  }
214
240
 
215
241
  function viewerAnalyticsConsentControl(): string {
216
- return `<aside class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings">` +
242
+ return `<aside class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
217
243
  `<span class="tot-analytics-consent__copy"><strong data-tot-analytics-consent-status aria-live="polite">Analytics off</strong>` +
218
244
  `<span class="tot-analytics-consent__detail">Choose whether anonymous analytics may run.</span></span>` +
219
245
  `<span class="tot-analytics-consent__actions">` +