@tokenoftrust/storefront-runner 2.0.41 → 2.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
- package/apps/storefront/package.json +1 -0
- package/apps/storefront/platform-reconcile-tenants.json +3 -0
- package/apps/storefront/public/favicon.ico +0 -0
- package/apps/storefront/public/shared/commerce-chrome.css +147 -16
- package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
- package/apps/storefront/src/components/Img.astro +6 -5
- package/apps/storefront/src/components/ProductCard.astro +6 -4
- package/apps/storefront/src/components/Seo.astro +2 -1
- package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
- package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
- package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
- package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
- package/apps/storefront/src/components/home/Hero.astro +156 -4
- package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
- package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
- package/apps/storefront/src/components/marketing/Faq.astro +9 -2
- package/apps/storefront/src/config/devTenantSeed.ts +1 -1
- package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
- package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
- package/apps/storefront/src/config/tenants.ts +22 -0
- package/apps/storefront/src/layouts/Layout.astro +69 -44
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
- package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
- package/apps/storefront/src/lib/asyncCss.ts +23 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
- package/apps/storefront/src/lib/basePath.ts +70 -9
- package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
- package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
- package/apps/storefront/src/lib/diag/capture.ts +161 -0
- package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
- package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
- package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
- package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
- package/apps/storefront/src/lib/diag/reference.ts +148 -0
- package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
- package/apps/storefront/src/lib/diag/report.ts +109 -0
- package/apps/storefront/src/lib/diag/session.ts +93 -0
- package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
- package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
- package/apps/storefront/src/lib/imageAttrs.ts +9 -6
- package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
- package/apps/storefront/src/lib/jsonScript.ts +13 -0
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
- package/apps/storefront/src/lib/previewReload.ts +53 -9
- package/apps/storefront/src/lib/rawChrome.ts +148 -19
- package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
- package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
- package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
- package/apps/storefront/src/lib/tenantRouting.ts +15 -4
- package/apps/storefront/src/lib/tot/syncState.ts +21 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -5
- package/apps/storefront/src/middleware/index.ts +13 -1
- package/apps/storefront/src/middleware/renderChain.ts +54 -11
- package/apps/storefront/src/middleware/routeBranch.ts +58 -12
- package/apps/storefront/src/middleware/staticBundle.ts +37 -3
- package/apps/storefront/src/pages/[...slug].astro +6 -2
- package/apps/storefront/src/pages/api/request-access.ts +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +11 -1
- package/apps/storefront/src/pages/cart.ts +23 -0
- package/apps/storefront/src/pages/index.astro +64 -85
- package/apps/storefront/src/pages/products/[handle].astro +8 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
- package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
- package/apps/storefront/src/styles/fonts.css +7 -0
- package/apps/storefront/src/styles/global.css +397 -3
- package/package.json +4 -1
- package/packages/cli/src/visual-parity.mjs +321 -0
- package/packages/public-runtime/package.json +7 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/chrome.ts +44 -7
- package/packages/public-runtime/src/csp.ts +12 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
- package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
- package/packages/public-runtime/src/extension-contract.ts +20 -6
- package/packages/public-runtime/src/image-dependencies.ts +1438 -0
- package/packages/public-runtime/src/index.ts +8 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
- package/packages/public-runtime/src/integration/consent-config.ts +9 -0
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
- package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
- package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
- package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
- package/packages/public-runtime/src/publication-redirects.mjs +379 -0
- package/packages/public-runtime/src/publication-space.ts +24 -3
- package/packages/public-runtime/src/static-bundle.ts +145 -26
- package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
- package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
- package/packages/public-runtime/src/tenant.ts +59 -1
- package/pnpm-lock.runner.yaml +19 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/dev/git-status.mjs +1 -1
- package/scripts/dev/publish.mjs +6 -0
- package/scripts/lib/tenant-asset-copy.mjs +4 -2
- package/scripts/tenant/materialize-tenant-content.mjs +1 -0
- package/scripts/tenant/validate.mjs +171 -8
- package/apps/storefront/src/lib/cfImage.ts +0 -55
- package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/js/storefront.js +0 -129
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +0 -235
- package/tenants/home/theme.json +0 -6
|
@@ -25,7 +25,15 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
25
25
|
import { createRequire } from "node:module";
|
|
26
26
|
import { dirname } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
LIFECYCLE_HOST_ASSET_PATH,
|
|
30
|
+
LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS,
|
|
31
|
+
} from "@tot/public-runtime/integration/lifecycle-host-asset";
|
|
32
|
+
|
|
33
|
+
const HOST_ASSET_PATHS = Object.freeze([
|
|
34
|
+
...LIFECYCLE_HOST_COMPATIBILITY_ASSET_PATHS,
|
|
35
|
+
LIFECYCLE_HOST_ASSET_PATH,
|
|
36
|
+
]);
|
|
29
37
|
|
|
30
38
|
const HOST_ENTRY = fileURLToPath(
|
|
31
39
|
new URL(
|
|
@@ -100,7 +108,7 @@ export function lifecycleHostAsset() {
|
|
|
100
108
|
"astro:server:setup": ({ server }) => {
|
|
101
109
|
server.middlewares.use((req, res, next) => {
|
|
102
110
|
const path = (req.url ?? "").split("?")[0];
|
|
103
|
-
if (path
|
|
111
|
+
if (!HOST_ASSET_PATHS.some((assetPath) => path === `/${assetPath}`)) return next();
|
|
104
112
|
bundleLifecycleHost().then(
|
|
105
113
|
(code) => {
|
|
106
114
|
res.setHeader("content-type", "text/javascript; charset=utf-8");
|
|
@@ -114,11 +122,17 @@ export function lifecycleHostAsset() {
|
|
|
114
122
|
|
|
115
123
|
"astro:build:done": async ({ logger }) => {
|
|
116
124
|
if (!clientDir) throw new Error("the lifecycle host has no resolved client output dir");
|
|
125
|
+
const outputDir = clientDir;
|
|
117
126
|
const code = await bundleLifecycleHost();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
await Promise.all(HOST_ASSET_PATHS.map(async (assetPath) => {
|
|
128
|
+
const target = fileURLToPath(new URL(assetPath, outputDir));
|
|
129
|
+
await mkdir(dirname(target), { recursive: true });
|
|
130
|
+
await writeFile(target, code, "utf8");
|
|
131
|
+
}));
|
|
132
|
+
logger.info(
|
|
133
|
+
`lifecycle host → ${HOST_ASSET_PATHS.map((path) => `/${path}`).join(", ")} ` +
|
|
134
|
+
`(${code.length} bytes each)`,
|
|
135
|
+
);
|
|
122
136
|
},
|
|
123
137
|
},
|
|
124
138
|
};
|
|
Binary file
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
/* Clip (not hidden) so a closed off-canvas mobile drawer never creates a
|
|
41
41
|
horizontal scrollbar, while position:sticky on the header still works. */
|
|
42
42
|
body { overflow-x: clip; }
|
|
43
|
+
.skip-link {
|
|
44
|
+
position: fixed; inset-block-start: 0; inset-inline-start: 0; z-index: 2147483646;
|
|
45
|
+
transform: translateY(-150%); border-radius: var(--radius-md);
|
|
46
|
+
background: var(--color-primary); color: var(--color-primary-contrast); padding: .75rem 1rem;
|
|
47
|
+
}
|
|
48
|
+
.skip-link:focus-visible { transform: translateY(0); }
|
|
43
49
|
.container { width: 100%; max-width: var(--container-max); margin-inline: auto; padding-inline: clamp(16px, 4vw, 40px); }
|
|
44
50
|
.section { padding-block: clamp(2.5rem, 6vw, 4.5rem); }
|
|
45
51
|
.section--tight { padding-block: clamp(1.75rem, 4vw, 3rem); }
|
|
@@ -68,13 +74,13 @@ body { overflow-x: clip; }
|
|
|
68
74
|
/* (1) solid brand bar */
|
|
69
75
|
.announce {
|
|
70
76
|
background: var(--color-primary); color: var(--color-primary-contrast); text-align: center;
|
|
71
|
-
font-size: .
|
|
77
|
+
font-size: .875rem; font-weight: 600; letter-spacing: .01em; padding: 9px 16px;
|
|
72
78
|
}
|
|
73
79
|
/* (2) scrolling sale marquee */
|
|
74
80
|
.announce-marquee { background: var(--color-sale); color: var(--color-primary-contrast); overflow: hidden; }
|
|
75
81
|
.announce-marquee__track {
|
|
76
82
|
display: flex; gap: 3rem; white-space: nowrap; width: max-content;
|
|
77
|
-
padding: 8px 0; font-size: .
|
|
83
|
+
padding: 8px 0; font-size: .875rem; font-weight: 600;
|
|
78
84
|
animation: chrome-marquee 22s linear infinite;
|
|
79
85
|
}
|
|
80
86
|
.announce-marquee__track span { padding-inline: 1.5rem; }
|
|
@@ -89,7 +95,7 @@ body { overflow-x: clip; }
|
|
|
89
95
|
.announce-bar { background: var(--color-accent); color: var(--color-primary-contrast); }
|
|
90
96
|
.announce-bar[hidden] { display: none; }
|
|
91
97
|
.announce-bar .container { display: flex; align-items: center; justify-content: center; gap: 14px; padding-block: 9px; position: relative; }
|
|
92
|
-
.announce-bar__msg { font-size: .
|
|
98
|
+
.announce-bar__msg { font-size: .875rem; font-weight: 700; letter-spacing: .01em; text-align: center; }
|
|
93
99
|
.announce-bar__msg a { color: var(--color-primary-contrast); text-decoration: underline; }
|
|
94
100
|
.announce-bar__close { position: absolute; right: clamp(16px, 4vw, 40px); background: none; border: 0; color: var(--color-primary-contrast); font-size: 1.1rem; line-height: 1; padding: 4px 6px; border-radius: var(--radius-sm); }
|
|
95
101
|
.announce-bar__close:hover { background: color-mix(in srgb, var(--color-primary-contrast) 12%, transparent); }
|
|
@@ -99,15 +105,21 @@ body { overflow-x: clip; }
|
|
|
99
105
|
background: var(--color-surface); border-bottom: 1px solid var(--color-border);
|
|
100
106
|
text-align: center; padding: 10px 16px;
|
|
101
107
|
}
|
|
102
|
-
|
|
108
|
+
/* Compliance notices carry a legibility FLOOR, not just a size. `.875rem` resolves to 13.12px
|
|
109
|
+
wherever the root computes to 15px, under the 14px mobile-text minimum the migration gate
|
|
110
|
+
enforces (validate-page-browser.mjs, MOBILE_TEXT_MIN) — and on a regulated storefront the age
|
|
111
|
+
warning is the text that least tolerates being the smallest thing on the page. `max()` keeps the
|
|
112
|
+
intended relative size wherever the root is large enough, refuses to go below 14px where it is
|
|
113
|
+
not, and so cannot regress if the root scale changes again. Applies to all three notices. */
|
|
114
|
+
.topbar-warning p { color: var(--color-text); font-weight: 700; font-size: max(.875rem, 14px); letter-spacing: .01em; }
|
|
103
115
|
|
|
104
116
|
.utility-nav { border-bottom: 1px solid var(--color-border); background: var(--color-surface); }
|
|
105
117
|
.utility-nav .container { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-block: 8px; }
|
|
106
118
|
.utility-nav ul { display: flex; flex-wrap: wrap; gap: 20px; }
|
|
107
|
-
.utility-nav a { font-size: .
|
|
119
|
+
.utility-nav a { font-size: .875rem; font-weight: 600; color: var(--color-text); }
|
|
108
120
|
.utility-nav a:hover { color: var(--color-primary); }
|
|
109
121
|
.utility-nav__right { display: flex; align-items: center; gap: 16px; }
|
|
110
|
-
.utility-nav__right .sel { font-size: .
|
|
122
|
+
.utility-nav__right .sel { font-size: .875rem; color: var(--color-muted); }
|
|
111
123
|
.utility-nav__right .social { display: flex; gap: 12px; }
|
|
112
124
|
.utility-nav__right .social a svg { width: 16px; height: 16px; fill: var(--color-text); }
|
|
113
125
|
|
|
@@ -121,8 +133,12 @@ body { overflow-x: clip; }
|
|
|
121
133
|
.site-header .header-row { gap: clamp(12px, 3vw, 40px); }
|
|
122
134
|
.header-row { display: flex; flex-wrap: wrap; align-items: center; gap: clamp(12px, 2.5vw, 32px); padding-block: 14px; }
|
|
123
135
|
/* Logo: image mark (bigd/giant) or text wordmark (PVC) both supported. */
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
/* object-fit is load-bearing, not polish: with height pinned and width auto, a logo whose
|
|
137
|
+
box is capped by max-width is otherwise STRETCHED to fill it. Measured 2026-09-21 on a
|
|
138
|
+
real tenant: a 500x196 logo (ratio 2.55) rendered at 154x34 (ratio 4.53) on mobile.
|
|
139
|
+
`contain` letterboxes instead, so a capped box never distorts the merchant's mark. */
|
|
140
|
+
.brand img { height: 46px; width: auto; object-fit: contain; }
|
|
141
|
+
.brand { display: inline-flex; align-items: baseline; gap: .3ch; font-family: var(--font-display); font-weight: 800; font-size: clamp(1.1rem, 2.2vw, 1.4rem); letter-spacing: .02em; white-space: nowrap; min-height: 44px; }
|
|
126
142
|
.brand .b-prime { color: var(--color-accent); }
|
|
127
143
|
.brand .b-vapor { color: var(--color-text); }
|
|
128
144
|
.brand .b-club { color: var(--color-primary); }
|
|
@@ -157,9 +173,26 @@ body { overflow-x: clip; }
|
|
|
157
173
|
.header-actions .header-cta--primary:hover { color: var(--color-primary-contrast); transform: translateY(-1px); }
|
|
158
174
|
.header-cta--secondary, .header-cta--ghost { border: 1px solid currentColor; }
|
|
159
175
|
.header-actions .header-cta--secondary:hover, .header-actions .header-cta--ghost:hover { opacity: .8; }
|
|
160
|
-
.hamburger { display: none; flex-direction: column; gap: 5px; background: none; border: 0; padding: 6px; }
|
|
176
|
+
.hamburger { display: none; flex-direction: column; gap: 5px; background: none; border: 0; padding: 6px; min-width: 44px; min-height: 44px; align-items: center; justify-content: center; }
|
|
161
177
|
.hamburger span { width: 26px; height: 2px; background: var(--color-text); border-radius: 2px; }
|
|
162
178
|
|
|
179
|
+
/* Source-parity compact chrome: one desktop row with navigation on the left,
|
|
180
|
+
an independently centered brand, and shopper actions on the right. This is
|
|
181
|
+
selected by chrome.json, never by tenant id. */
|
|
182
|
+
.site-header[data-variant="compact-centered"] { position: static; }
|
|
183
|
+
.site-header[data-variant="compact-centered"] .header-row {
|
|
184
|
+
display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
|
185
|
+
min-height: 62px; padding-block: 8px;
|
|
186
|
+
}
|
|
187
|
+
.site-header[data-variant="compact-centered"] .primary-nav { min-width: 0; }
|
|
188
|
+
.site-header[data-variant="compact-centered"] .primary-nav > ul { flex-wrap: nowrap; }
|
|
189
|
+
.site-header[data-variant="compact-centered"] .brand { justify-self: center; }
|
|
190
|
+
.site-header[data-variant="compact-centered"] .brand img { height: 38px; }
|
|
191
|
+
.site-header[data-variant="compact-centered"] .header-actions { justify-self: end; }
|
|
192
|
+
.header-search-trigger { display: inline-flex; align-items: center; color: var(--color-text); }
|
|
193
|
+
.header-search-trigger svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 1.8; }
|
|
194
|
+
.mobile-bottom-nav { display: none; }
|
|
195
|
+
|
|
163
196
|
/* --- Primary nav + mega-menu ---------------------------------------------- */
|
|
164
197
|
.primary-nav { flex: 1; }
|
|
165
198
|
.primary-nav > ul { display: flex; flex-wrap: wrap; align-items: center; gap: clamp(10px, 1.6vw, 22px); }
|
|
@@ -282,7 +315,7 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
282
315
|
/* --- Compliance strip (source-copied legal text; not fabricated) ---------- */
|
|
283
316
|
.compliance-strip { background: var(--color-surface-2); border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); }
|
|
284
317
|
.compliance-strip .container { padding-block: 14px; }
|
|
285
|
-
.compliance-strip p { color: var(--color-text); font-size: .
|
|
318
|
+
.compliance-strip p { color: var(--color-text); font-size: max(.875rem, 14px); font-weight: 600; text-align: center; letter-spacing: .01em; }
|
|
286
319
|
|
|
287
320
|
/* --- Age gate (21+ interstitial; remembered per device) ------------------- */
|
|
288
321
|
/* Overlay + on-dark surfaces derive from --color-text (deep ink) so no brand hex. */
|
|
@@ -298,14 +331,14 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
298
331
|
.age-gate__deny { display: none; }
|
|
299
332
|
.age-gate.is-denied .age-gate__enter, .age-gate.is-denied .age-gate__lede { display: none; }
|
|
300
333
|
.age-gate.is-denied .age-gate__deny { display: block; margin-top: 18px; color: var(--color-error); font-weight: 600; }
|
|
301
|
-
.age-gate__fine { margin-top: 18px; font-size: .
|
|
334
|
+
.age-gate__fine { margin-top: 18px; font-size: .875rem; color: var(--color-muted); }
|
|
302
335
|
|
|
303
336
|
/* --- Footer --------------------------------------------------------------- */
|
|
304
337
|
/* Deep-ink surface (= --color-text) with light contrast text; dimmer tiers and
|
|
305
338
|
hairlines derive from --color-primary-contrast via color-mix. */
|
|
306
339
|
.site-footer { background: var(--color-text); color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); padding-block: clamp(2.5rem, 5vw, 4rem) 1.5rem; }
|
|
307
340
|
.footer-grid { display: grid; grid-template-columns: 1.4fr repeat(3, 1fr); gap: clamp(24px, 4vw, 56px); }
|
|
308
|
-
.footer-brand img { height: 40px; filter: brightness(0) invert(1); opacity: .95; }
|
|
341
|
+
.footer-brand img { height: 40px; width: auto; filter: brightness(0) invert(1); opacity: .95; }
|
|
309
342
|
.footer-brand p { color: color-mix(in srgb, var(--color-primary-contrast) 66%, transparent); margin-top: 14px; font-size: .9rem; max-width: 34ch; }
|
|
310
343
|
/* The header .brand .b-prime/.b-vapor/.b-club rule set (line ~123) is tuned
|
|
311
344
|
for the light --color-surface header; on the dark --color-text footer
|
|
@@ -326,8 +359,36 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
326
359
|
.footer-news input[type="email"]::placeholder { color: color-mix(in srgb, var(--color-primary-contrast) 55%, transparent); }
|
|
327
360
|
.footer-news button { flex: none; background: var(--color-primary); color: var(--color-primary-contrast); border: 0; border-radius: var(--radius-md); padding: 0 16px; font-weight: 700; }
|
|
328
361
|
.footer-news button:hover { background: var(--color-primary-hover); }
|
|
329
|
-
.footer-attest { display: flex; gap: 8px; align-items: flex-start; margin-top: 10px; font-size: .
|
|
330
|
-
|
|
362
|
+
.footer-attest { display: flex; gap: 8px; align-items: flex-start; margin-top: 10px; font-size: .875rem; color: color-mix(in srgb, var(--color-primary-contrast) 66%, transparent); }
|
|
363
|
+
/* Age attestation is a required consent control, so it carries the full
|
|
364
|
+
--tap-target box. The negative margins hand the extra space straight back, so
|
|
365
|
+
the control still occupies the same 13px slot beside the label text, and the
|
|
366
|
+
glyph is drawn at 13px through a mask that follows the footer's own colors —
|
|
367
|
+
a native checkbox scales its whole glyph to the box and would swallow the
|
|
368
|
+
line. */
|
|
369
|
+
.footer-attest input[type="checkbox"] {
|
|
370
|
+
appearance: none;
|
|
371
|
+
-webkit-appearance: none;
|
|
372
|
+
flex: none;
|
|
373
|
+
inline-size: var(--tap-target, 44px);
|
|
374
|
+
block-size: var(--tap-target, 44px);
|
|
375
|
+
margin-inline: calc((13px - var(--tap-target, 44px)) / 2);
|
|
376
|
+
margin-block: calc(3px + (13px - var(--tap-target, 44px)) / 2) calc((13px - var(--tap-target, 44px)) / 2);
|
|
377
|
+
border: 0;
|
|
378
|
+
background-color: currentColor;
|
|
379
|
+
-webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'><rect x='.5' y='.5' width='12' height='12' rx='2.5' fill='none' stroke='black' stroke-width='1'/></svg>") center / 13px 13px no-repeat;
|
|
380
|
+
mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'><rect x='.5' y='.5' width='12' height='12' rx='2.5' fill='none' stroke='black' stroke-width='1'/></svg>") center / 13px 13px no-repeat;
|
|
381
|
+
}
|
|
382
|
+
.footer-attest input[type="checkbox"]:checked {
|
|
383
|
+
background-color: var(--color-accent);
|
|
384
|
+
-webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'><rect x='.5' y='.5' width='12' height='12' rx='2.5' fill='none' stroke='black' stroke-width='1'/><path d='M3.2 6.7 5.4 8.9 9.8 4.2' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
|
385
|
+
mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'><rect x='.5' y='.5' width='12' height='12' rx='2.5' fill='none' stroke='black' stroke-width='1'/><path d='M3.2 6.7 5.4 8.9 9.8 4.2' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
|
386
|
+
}
|
|
387
|
+
.footer-attest input[type="checkbox"]:focus-visible {
|
|
388
|
+
outline: 2px solid var(--color-accent);
|
|
389
|
+
outline-offset: calc((13px - var(--tap-target, 44px)) / 2 + 1px);
|
|
390
|
+
border-radius: 50%;
|
|
391
|
+
}
|
|
331
392
|
.pay-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
|
332
393
|
.pay-badges span { font-size: .62rem; font-weight: 700; letter-spacing: .04em; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); border: 1px solid color-mix(in srgb, var(--color-primary-contrast) 22%, transparent); border-radius: var(--radius-sm); padding: 4px 7px; }
|
|
333
394
|
/* Trust marks strip (PVC) */
|
|
@@ -336,15 +397,47 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
336
397
|
.trust-marks svg { width: 17px; height: 17px; stroke: var(--color-accent); fill: none; stroke-width: 1.8; }
|
|
337
398
|
.footer-warning { margin-top: 30px; padding: 16px 18px; border: 1px solid color-mix(in srgb, var(--color-primary-contrast) 22%, transparent); border-radius: var(--radius-md); }
|
|
338
399
|
.footer-warning strong { color: var(--color-primary-contrast); }
|
|
339
|
-
.footer-warning p { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .
|
|
400
|
+
.footer-warning p { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: max(.875rem, 14px); }
|
|
340
401
|
.footer-bottom { margin-top: 28px; padding-top: 18px; border-top: 1px solid color-mix(in srgb, var(--color-primary-contrast) 16%, transparent); display: flex; flex-wrap: wrap; gap: 10px 24px; justify-content: space-between; align-items: center; }
|
|
341
|
-
.footer-bottom p, .footer-bottom a { color: color-mix(in srgb, var(--color-primary-contrast) 60%, transparent); font-size: .
|
|
402
|
+
.footer-bottom p, .footer-bottom a { color: color-mix(in srgb, var(--color-primary-contrast) 60%, transparent); font-size: .875rem; }
|
|
342
403
|
/* The Legal link row (a "Legal"-titled footer column, moved here — see
|
|
343
404
|
lib/chrome/footerLegal.ts). Wraps under the copyright at narrow widths
|
|
344
405
|
rather than overflowing the bar. */
|
|
345
406
|
.footer-bottom nav ul { display: flex; flex-wrap: wrap; gap: 6px 20px; list-style: none; margin: 0; padding: 0; }
|
|
346
407
|
.footer-bottom nav a:hover { color: var(--color-primary-contrast); text-decoration: underline; }
|
|
347
408
|
|
|
409
|
+
/* Source-parity light footer. Native details provide real zero-JS disclosure
|
|
410
|
+
semantics on mobile; desktop CSS presents the same content as open columns. */
|
|
411
|
+
.site-footer[data-variant="light-collapsible"] {
|
|
412
|
+
background: var(--color-surface); color: var(--color-text);
|
|
413
|
+
border-top: 1px solid var(--color-border);
|
|
414
|
+
}
|
|
415
|
+
.site-footer[data-variant="light-collapsible"] .footer-brand img { filter: none; opacity: 1; }
|
|
416
|
+
.site-footer[data-variant="light-collapsible"] .footer-brand p,
|
|
417
|
+
.site-footer[data-variant="light-collapsible"] .footer-col a,
|
|
418
|
+
.site-footer[data-variant="light-collapsible"] .footer-bottom p,
|
|
419
|
+
.site-footer[data-variant="light-collapsible"] .footer-bottom a { color: var(--color-muted); }
|
|
420
|
+
.site-footer[data-variant="light-collapsible"] .footer-col summary {
|
|
421
|
+
list-style: none; color: var(--color-text); font-size: .875rem; font-weight: 700;
|
|
422
|
+
letter-spacing: .1em; text-transform: uppercase; margin-bottom: 14px; cursor: default;
|
|
423
|
+
}
|
|
424
|
+
.site-footer[data-variant="light-collapsible"] .footer-col summary::-webkit-details-marker { display: none; }
|
|
425
|
+
.site-footer[data-variant="light-collapsible"] .footer-col details > ul { display: flex; }
|
|
426
|
+
.site-footer[data-variant="light-collapsible"] .footer-news input[type="email"] {
|
|
427
|
+
background: var(--color-surface); color: var(--color-text); border-color: var(--color-border);
|
|
428
|
+
}
|
|
429
|
+
.site-footer[data-variant="light-collapsible"] .footer-news input[type="email"]::placeholder,
|
|
430
|
+
.site-footer[data-variant="light-collapsible"] .footer-attest { color: var(--color-muted); }
|
|
431
|
+
.site-footer[data-variant="light-collapsible"] .trust-marks,
|
|
432
|
+
.site-footer[data-variant="light-collapsible"] .footer-bottom { border-color: var(--color-border); }
|
|
433
|
+
.site-footer[data-variant="light-collapsible"] .trust-marks span { color: var(--color-muted); }
|
|
434
|
+
.site-footer[data-variant="light-collapsible"] .pay-badges span {
|
|
435
|
+
color: var(--color-muted); border-color: var(--color-border);
|
|
436
|
+
}
|
|
437
|
+
.site-footer[data-variant="light-collapsible"] .footer-warning { border-color: var(--color-border); }
|
|
438
|
+
.site-footer[data-variant="light-collapsible"] .footer-warning strong,
|
|
439
|
+
.site-footer[data-variant="light-collapsible"] .footer-warning p { color: var(--color-text); }
|
|
440
|
+
|
|
348
441
|
/* --- Reveal-on-scroll (raw-chrome markup uses .reveal/.in) ---------------- */
|
|
349
442
|
.reveal { opacity: 0; transform: translateY(18px); transition: opacity .5s ease, transform .5s ease; }
|
|
350
443
|
.reveal.in { opacity: 1; transform: none; }
|
|
@@ -381,6 +474,44 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
381
474
|
.cat-grid { grid-template-columns: 1fr 1fr; }
|
|
382
475
|
.article-grid { grid-template-columns: 1fr 1fr; }
|
|
383
476
|
.footer-grid { grid-template-columns: 1fr 1fr; }
|
|
477
|
+
.site-header[data-variant="compact-centered"] .header-row {
|
|
478
|
+
display: flex; position: relative; min-height: 58px; justify-content: space-between;
|
|
479
|
+
}
|
|
480
|
+
.site-header[data-variant="compact-centered"] .brand {
|
|
481
|
+
position: absolute; left: 50%; transform: translateX(-50%); z-index: 1;
|
|
482
|
+
}
|
|
483
|
+
.site-header[data-variant="compact-centered"] .brand img { height: 34px; max-width: 154px; }
|
|
484
|
+
.site-header[data-variant="compact-centered"] .header-actions { width: 100%; justify-content: flex-end; }
|
|
485
|
+
.site-header[data-variant="compact-centered"] .hamburger {
|
|
486
|
+
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
|
487
|
+
}
|
|
488
|
+
.site-header[data-variant="compact-centered"] .header-search-trigger,
|
|
489
|
+
.site-header[data-variant="compact-centered"] .header-actions > a[aria-label="Account"],
|
|
490
|
+
.site-header[data-variant="compact-centered"] .member-cue { display: none; }
|
|
491
|
+
.mobile-bottom-nav {
|
|
492
|
+
position: fixed; inset: auto 0 0; z-index: 130; min-height: 62px;
|
|
493
|
+
display: flex; align-items: stretch; justify-content: space-around;
|
|
494
|
+
background: var(--color-surface); border-top: 1px solid var(--color-border);
|
|
495
|
+
box-shadow: 0 -3px 14px color-mix(in srgb, var(--color-text) 10%, transparent);
|
|
496
|
+
}
|
|
497
|
+
.mobile-bottom-nav a {
|
|
498
|
+
flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center;
|
|
499
|
+
justify-content: center; gap: 2px; color: var(--color-text); font-size: 1.2rem;
|
|
500
|
+
}
|
|
501
|
+
.mobile-bottom-nav svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 1.8; }
|
|
502
|
+
.mobile-bottom-nav small { font-size: .875rem; line-height: 1; }
|
|
503
|
+
.site-footer[data-variant="light-collapsible"] { padding-bottom: calc(1.5rem + 62px); }
|
|
504
|
+
.site-footer[data-variant="light-collapsible"] .footer-grid { display: block; }
|
|
505
|
+
.site-footer[data-variant="light-collapsible"] .footer-brand { margin-bottom: 20px; }
|
|
506
|
+
.site-footer[data-variant="light-collapsible"] .footer-col { border-top: 1px solid var(--color-border); }
|
|
507
|
+
.site-footer[data-variant="light-collapsible"] .footer-col summary {
|
|
508
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
509
|
+
margin: 0; padding: 16px 0; cursor: pointer;
|
|
510
|
+
}
|
|
511
|
+
.site-footer[data-variant="light-collapsible"] .footer-col summary::after { content: "+"; font-size: 1rem; }
|
|
512
|
+
.site-footer[data-variant="light-collapsible"] .footer-col details[open] summary::after { content: "−"; }
|
|
513
|
+
.site-footer[data-variant="light-collapsible"] .footer-col details:not([open]) > ul { display: none; }
|
|
514
|
+
.site-footer[data-variant="light-collapsible"] .footer-col details > ul { padding-bottom: 16px; }
|
|
384
515
|
}
|
|
385
516
|
@media (max-width: 560px) {
|
|
386
517
|
.tile-grid { grid-template-columns: repeat(2, 1fr); }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <AsyncStylesheet> — loads a non-critical stylesheet without blocking render,
|
|
4
|
+
* and without FOUC, under the storefront's strict CSP (see lib/asyncCss.ts for
|
|
5
|
+
* why this can't be the usual `onload="this.rel='stylesheet'"` attribute
|
|
6
|
+
* trick). Use for CSS a page doesn't need for first paint — e.g. a below-fold
|
|
7
|
+
* section, an optional widget, a print stylesheet's screen equivalent. Do NOT
|
|
8
|
+
* use it for chrome/header/nav CSS that must be present before paint to avoid
|
|
9
|
+
* a visible flash.
|
|
10
|
+
*
|
|
11
|
+
* Renders a `<link rel="preload">` tagged for the shared swap script
|
|
12
|
+
* (Layout.astro renders `ASYNC_CSS_SWAP_JS` once per page — it flips every
|
|
13
|
+
* such link to `rel="stylesheet"` after it's fetched) plus a `<noscript>`
|
|
14
|
+
* fallback so the styles still apply with JS disabled.
|
|
15
|
+
*/
|
|
16
|
+
import { ASYNC_CSS_ATTR } from "@/lib/asyncCss";
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
href: string;
|
|
20
|
+
}
|
|
21
|
+
const { href } = Astro.props;
|
|
22
|
+
const swapAttr = { [ASYNC_CSS_ATTR]: true };
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<link rel="preload" as="style" href={href} {...swapAttr} />
|
|
26
|
+
<noscript><link rel="stylesheet" href={href} /></noscript>
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* <Breadcrumbs> — semantic breadcrumb trail. The last crumb is the current
|
|
4
4
|
* page (aria-current, not a link). No own container — inherits parent width.
|
|
5
|
+
*
|
|
6
|
+
* Crumb links carry `min-h-11` (44px) because a breadcrumb is a TAP TARGET, not prose. It reads
|
|
7
|
+
* as inline text, but it sits in a flex row so its computed display is `block`, which means the
|
|
8
|
+
* inline-in-a-sentence exemption to WCAG 2.5.8 does not apply — and should not, since there is
|
|
9
|
+
* no surrounding sentence that growing the link would break. Measured 2026-09-21 at 118x20,
|
|
10
|
+
* failing even 2.5.8's 24px floor on every page that renders a trail. The extra height centres
|
|
11
|
+
* around the text, so the crumb does not move.
|
|
5
12
|
*/
|
|
6
13
|
import { withBase } from "@/lib/basePath";
|
|
7
14
|
|
|
@@ -32,7 +39,7 @@ const { basePath } = Astro.locals;
|
|
|
32
39
|
) : (
|
|
33
40
|
<a
|
|
34
41
|
href={withBase(basePath, crumb.href)}
|
|
35
|
-
class="transition-colors hover:text-primary"
|
|
42
|
+
class="inline-flex min-h-11 items-center transition-colors hover:text-primary"
|
|
36
43
|
>
|
|
37
44
|
{crumb.label}
|
|
38
45
|
</a>
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* by a stable seed so the site looks polished offline AND stays CLS-free
|
|
8
8
|
* (explicit width/height always set). When real rehosted URLs land
|
|
9
9
|
* (CatalogImage.rehosted === true), the same component serves them with
|
|
10
|
-
*
|
|
10
|
+
* directly from the shared asset CDN.
|
|
11
11
|
*
|
|
12
|
-
* Responsive srcset is emitted for
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Responsive srcset is emitted for Shopify URLs whose CDN accepts `?width=`.
|
|
13
|
+
* Rehost URLs are provider-neutral originals: they must render identically when
|
|
14
|
+
* the storefront is served by Cloudflare or CloudFront, so this component never
|
|
15
|
+
* routes them through a provider-specific transformation path.
|
|
15
16
|
*/
|
|
16
17
|
import { resolveImage } from "@/lib/imageAttrs";
|
|
17
18
|
|
|
@@ -46,7 +47,7 @@ const {
|
|
|
46
47
|
loading,
|
|
47
48
|
} = Astro.props;
|
|
48
49
|
|
|
49
|
-
// Render decision (placeholder /
|
|
50
|
+
// Render decision (placeholder / direct rehost / Shopify ?width=) lives in
|
|
50
51
|
// lib/imageAttrs.ts so it's unit-testable without rendering this component.
|
|
51
52
|
const { src: resolvedSrc, srcset } = resolveImage({ src, rehosted, width, height, seed, label });
|
|
52
53
|
|
|
@@ -157,7 +157,7 @@ const quickview = {
|
|
|
157
157
|
global wishlist script; aria-pressed reflects saved state. */}
|
|
158
158
|
<button
|
|
159
159
|
type="button"
|
|
160
|
-
class="wishlist-toggle absolute right-2 top-2 z-10 grid h-
|
|
160
|
+
class="wishlist-toggle absolute right-2 top-2 z-10 grid h-11 w-11 place-items-center rounded-full bg-[color-mix(in_srgb,var(--color-surface)_88%,transparent)] text-[var(--color-text)] backdrop-blur transition-colors hover:text-[var(--color-sale)] focus-visible:text-[var(--color-sale)] aria-pressed:text-[var(--color-sale)]"
|
|
161
161
|
aria-pressed="false"
|
|
162
162
|
aria-label={`Save ${product.title}`}
|
|
163
163
|
data-wishlist={JSON.stringify(snapshot)}
|
|
@@ -170,7 +170,7 @@ const quickview = {
|
|
|
170
170
|
{/* Quick view — reveals on hover/focus-within; opens the global dialog. */}
|
|
171
171
|
<button
|
|
172
172
|
type="button"
|
|
173
|
-
class="quickview-trigger absolute inset-x-2 bottom-2 z-10 rounded-[var(--radius-sm)] bg-[color-mix(in_srgb,var(--color-surface)_92%,transparent)] py-
|
|
173
|
+
class="quickview-trigger absolute inset-x-2 bottom-2 z-10 min-h-11 rounded-[var(--radius-sm)] bg-[color-mix(in_srgb,var(--color-surface)_92%,transparent)] py-3 text-center font-display text-sm font-medium text-[var(--color-text)] opacity-0 backdrop-blur transition-opacity duration-200 hover:bg-[var(--color-surface)] focus-visible:opacity-100 motion-safe:group-hover:opacity-100 md:opacity-0"
|
|
174
174
|
data-quickview={JSON.stringify(quickview)}
|
|
175
175
|
>
|
|
176
176
|
Quick view
|
|
@@ -180,16 +180,18 @@ const quickview = {
|
|
|
180
180
|
<div class="mt-3.5">
|
|
181
181
|
{
|
|
182
182
|
product.vendor && (
|
|
183
|
-
<p class="font-mono text-[0.
|
|
183
|
+
<p class="font-mono text-[0.875rem] uppercase tracking-[0.14em] text-muted">
|
|
184
184
|
{product.vendor}
|
|
185
185
|
</p>
|
|
186
186
|
)
|
|
187
187
|
}
|
|
188
188
|
<h3 class="mt-1 font-display text-[0.95rem] font-medium leading-snug">
|
|
189
|
+
{/* `py-3` on an inline box raises the tap target to the 44px minimum
|
|
190
|
+
without changing the line box, so the card layout is untouched. */}
|
|
189
191
|
<a
|
|
190
192
|
href={withBase(basePath, `/products/${product.handle}`)}
|
|
191
193
|
data-astro-prefetch
|
|
192
|
-
class="after:absolute after:inset-0 after:z-0 transition-colors motion-safe:group-hover:text-primary"
|
|
194
|
+
class="py-3 after:absolute after:inset-0 after:z-0 transition-colors motion-safe:group-hover:text-primary"
|
|
193
195
|
>
|
|
194
196
|
{product.title}
|
|
195
197
|
</a>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { articleSeoMetaTags, type ArticleMeta } from "@/lib/seo/meta.js";
|
|
7
7
|
import { withXDefault, type Alternate } from "@/lib/seo/alternates.js";
|
|
8
|
+
import { safeJsonScriptContent } from "@/lib/jsonScript";
|
|
8
9
|
|
|
9
10
|
interface JsonLd {
|
|
10
11
|
[key: string]: unknown;
|
|
@@ -128,7 +129,7 @@ const alternateLinks = withXDefault(alternates);
|
|
|
128
129
|
graphs.map((g) => (
|
|
129
130
|
<script
|
|
130
131
|
type="application/ld+json"
|
|
131
|
-
set:html={
|
|
132
|
+
set:html={safeJsonScriptContent(g)}
|
|
132
133
|
/>
|
|
133
134
|
))
|
|
134
135
|
}
|
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
import type { ChromeLink, FooterColumn, FooterNewsletter, SocialLink, ChromeLogo, FooterVariant } from "@/lib/chrome/model";
|
|
11
11
|
import { withBase } from "@/lib/basePath";
|
|
12
12
|
import { CHROME_PLACEMENT } from "@/lib/chrome/trackingPlacements";
|
|
13
|
+
import { resolveNewsletterAgeAttestation } from "@/lib/chrome/newsletterAgeAttestation";
|
|
13
14
|
import { trackingAttributes } from "@tot/public-runtime";
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
16
17
|
columns: FooterColumn[];
|
|
17
18
|
brand?: { logo?: ChromeLogo; wordmark?: string; contact?: string[] };
|
|
18
|
-
/** Content-shape variant (lib/chrome/model.ts FooterVariant).
|
|
19
|
-
* "default" — the only member today; threaded through so a future variant
|
|
20
|
-
* is a drop-in without touching every caller. */
|
|
19
|
+
/** Content-shape variant (lib/chrome/model.ts FooterVariant). */
|
|
21
20
|
variant?: FooterVariant;
|
|
22
21
|
newsletter?: FooterNewsletter;
|
|
23
22
|
/** Payment method labels rendered as chips (e.g. "Visa", "PayPal"). */
|
|
@@ -69,9 +68,11 @@ const bottomLinkStamp = (l: ChromeLink, index: number) =>
|
|
|
69
68
|
scope: "footer-bottom",
|
|
70
69
|
index,
|
|
71
70
|
});
|
|
71
|
+
const newsletterAgeAttestation = resolveNewsletterAgeAttestation(newsletter?.ageAttestation);
|
|
72
|
+
const collapsible = variant === "light-collapsible";
|
|
72
73
|
---
|
|
73
74
|
|
|
74
|
-
<footer class:list={["border-t border-border bg-surface text-text", className]} data-variant={variant}>
|
|
75
|
+
<footer class:list={["border-t border-border bg-surface text-text", collapsible && "footer-collapsible pb-[62px] md:pb-0", className]} data-variant={variant}>
|
|
75
76
|
<div class="container-prose py-12">
|
|
76
77
|
<div class="grid gap-10 md:grid-cols-[1.4fr_repeat(3,1fr)]">
|
|
77
78
|
<!-- Brand -->
|
|
@@ -139,10 +140,9 @@ const bottomLinkStamp = (l: ChromeLink, index: number) =>
|
|
|
139
140
|
|
|
140
141
|
<!-- Link columns -->
|
|
141
142
|
{
|
|
142
|
-
columns.map((col, colIndex) =>
|
|
143
|
-
|
|
144
|
-
<
|
|
145
|
-
<ul class="mt-3 grid gap-2">
|
|
143
|
+
columns.map((col, colIndex) => {
|
|
144
|
+
const links = (
|
|
145
|
+
<ul class="mt-3 grid gap-2 pb-4 md:pb-0">
|
|
146
146
|
{col.links.map((l, i) => (
|
|
147
147
|
<li>
|
|
148
148
|
<a
|
|
@@ -156,8 +156,21 @@ const bottomLinkStamp = (l: ChromeLink, index: number) =>
|
|
|
156
156
|
</li>
|
|
157
157
|
))}
|
|
158
158
|
</ul>
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
);
|
|
160
|
+
return collapsible ? (
|
|
161
|
+
<details class="border-t border-border md:border-0">
|
|
162
|
+
<summary class="flex cursor-pointer list-none items-center justify-between py-4 font-display text-sm font-semibold md:cursor-default md:py-0">
|
|
163
|
+
{col.title}<span class="disclosure-mark md:hidden" aria-hidden="true">+</span>
|
|
164
|
+
</summary>
|
|
165
|
+
{links}
|
|
166
|
+
</details>
|
|
167
|
+
) : (
|
|
168
|
+
<div>
|
|
169
|
+
<h3 class="font-display text-sm font-semibold">{col.title}</h3>
|
|
170
|
+
{links}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
})
|
|
161
174
|
}
|
|
162
175
|
</div>
|
|
163
176
|
|
|
@@ -170,7 +183,7 @@ const bottomLinkStamp = (l: ChromeLink, index: number) =>
|
|
|
170
183
|
<form
|
|
171
184
|
action={withBase(basePath, newsletter.action ?? "/api/newsletter")}
|
|
172
185
|
method="post"
|
|
173
|
-
class="mt-4 flex max-w-md flex-col gap-3 sm:flex-row"
|
|
186
|
+
class="mt-4 flex max-w-md flex-col gap-3 sm:flex-row sm:flex-wrap"
|
|
174
187
|
>
|
|
175
188
|
<input type="hidden" name="source" value={newsletter.source ?? "footer"} />
|
|
176
189
|
<label class="sr-only" for="chrome-news-email">Email address</label>
|
|
@@ -188,6 +201,20 @@ const bottomLinkStamp = (l: ChromeLink, index: number) =>
|
|
|
188
201
|
>
|
|
189
202
|
Subscribe
|
|
190
203
|
</button>
|
|
204
|
+
{
|
|
205
|
+
newsletterAgeAttestation && (
|
|
206
|
+
<label class="flex items-start gap-2 text-xs text-muted sm:basis-full">
|
|
207
|
+
<input
|
|
208
|
+
type="checkbox"
|
|
209
|
+
name={newsletterAgeAttestation.name}
|
|
210
|
+
value={newsletterAgeAttestation.value}
|
|
211
|
+
required
|
|
212
|
+
class="mt-0.5 h-4 w-4 shrink-0"
|
|
213
|
+
/>
|
|
214
|
+
<span>{newsletterAgeAttestation.label}</span>
|
|
215
|
+
</label>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
191
218
|
</form>
|
|
192
219
|
</div>
|
|
193
220
|
)
|
|
@@ -242,3 +269,10 @@ const bottomLinkStamp = (l: ChromeLink, index: number) =>
|
|
|
242
269
|
}
|
|
243
270
|
</div>
|
|
244
271
|
</footer>
|
|
272
|
+
|
|
273
|
+
<style>
|
|
274
|
+
@media (min-width: 768px) {
|
|
275
|
+
.footer-collapsible details:not([open]) > :not(summary) { display: grid; }
|
|
276
|
+
}
|
|
277
|
+
.footer-collapsible details[open] .disclosure-mark { transform: rotate(45deg); }
|
|
278
|
+
</style>
|