@tokenoftrust/storefront-runner 2.4.35 → 2.4.37
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/public/shared/commerce-chrome.css +5 -1
- package/apps/storefront/src/components/islands/AgeGate.tsx +54 -19
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +7 -3
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +47 -11
- package/apps/storefront/src/components/islands/MobileNav.tsx +3 -1
- package/apps/storefront/src/lib/chrome/model.ts +1 -0
- package/docs/widget-library-guide.md +2 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/chrome.ts +29 -0
|
@@ -321,7 +321,11 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
321
321
|
|
|
322
322
|
/* --- Age gate (21+ interstitial; remembered per device) ------------------- */
|
|
323
323
|
/* Overlay + on-dark surfaces derive from --color-text (deep ink) so no brand hex. */
|
|
324
|
-
|
|
324
|
+
/* Sized by viewport units, not `inset: 0`: on a phone the fixed containing block is the
|
|
325
|
+
LAYOUT viewport, which widens with content wider than the screen and shrinks as it
|
|
326
|
+
settles, so a panel centered in it moves with the page and can sit off-screen. The
|
|
327
|
+
shadow spread carries the backdrop over any layout viewport wider than the screen. */
|
|
328
|
+
.age-gate { box-sizing: border-box; position: fixed; top: 0; left: 0; width: 100vw; height: 100svh; overflow-y: auto; z-index: 1000; display: none; align-items: center; justify-content: center; padding: 24px; background: color-mix(in srgb, var(--color-text) 78%, transparent); box-shadow: 0 0 0 200vmax color-mix(in srgb, var(--color-text) 78%, transparent); }
|
|
325
329
|
.age-gate.is-open { display: flex; }
|
|
326
330
|
.age-gate__panel { width: min(30rem, 100%); background: var(--color-surface); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: clamp(24px, 4vw, 40px); text-align: center; }
|
|
327
331
|
.age-gate__mark { font-family: var(--font-display); font-weight: 800; letter-spacing: .04em; font-size: 1.05rem; color: var(--color-text); }
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
10
10
|
import type { ChromeAgeGate } from "@/lib/chrome/model";
|
|
11
|
-
import { deriveAgeGateStorageKey, resolveAgeGateCopy } from "@/lib/chrome/model";
|
|
11
|
+
import { deriveAgeGateStorageKey, isSafeAgeGateHref, resolveAgeGateCopy } from "@/lib/chrome/model";
|
|
12
12
|
|
|
13
13
|
export interface Props {
|
|
14
14
|
minAge: number;
|
|
@@ -40,6 +40,18 @@ export function resolveAgeGateRuntime({
|
|
|
40
40
|
resolveAgeGateCopy(presentation?.denyText, minAge) ??
|
|
41
41
|
`You must be ${minAge} or older to view this store. Please close this tab.`,
|
|
42
42
|
fineText: resolveAgeGateCopy(presentation?.fineText, minAge),
|
|
43
|
+
showNo: presentation?.showNo !== false,
|
|
44
|
+
eyebrow:
|
|
45
|
+
presentation?.eyebrow === false
|
|
46
|
+
? null
|
|
47
|
+
: (resolveAgeGateCopy(presentation?.eyebrow, minAge) ?? "Age verification"),
|
|
48
|
+
helpLink:
|
|
49
|
+
presentation?.helpLink && isSafeAgeGateHref(presentation.helpLink.href)
|
|
50
|
+
? {
|
|
51
|
+
label: resolveAgeGateCopy(presentation.helpLink.label, minAge)!,
|
|
52
|
+
href: presentation.helpLink.href,
|
|
53
|
+
}
|
|
54
|
+
: null,
|
|
43
55
|
};
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -47,7 +59,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
47
59
|
const [state, setState] = useState<"hidden" | "ask" | "denied">("hidden");
|
|
48
60
|
const yesRef = useRef<HTMLButtonElement>(null);
|
|
49
61
|
const retryRef = useRef<HTMLButtonElement>(null);
|
|
50
|
-
const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText } =
|
|
62
|
+
const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText, showNo, eyebrow, helpLink } =
|
|
51
63
|
resolveAgeGateRuntime({ minAge, brand, tenantId, presentation });
|
|
52
64
|
|
|
53
65
|
useEffect(() => {
|
|
@@ -91,24 +103,36 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
91
103
|
// attributes are intentionally admitted by style-src-attr. Keep the modal
|
|
92
104
|
// self-contained, token-driven, and responsive without opening either the
|
|
93
105
|
// shadow root or the CSP.
|
|
106
|
+
// The backdrop covers the fixed containing block, which on mobile is the LAYOUT
|
|
107
|
+
// viewport: it grows with any content wider than the screen and shrinks as that
|
|
108
|
+
// content settles. Centering the panel in it let the page under the gate move the
|
|
109
|
+
// panel (CLS 0.30 on product pages). The panel is placed in a frame sized only
|
|
110
|
+
// by viewport units, which no document content can change.
|
|
94
111
|
const overlayStyle = {
|
|
95
112
|
boxSizing: "border-box",
|
|
96
113
|
position: "fixed" as const,
|
|
97
114
|
inset: "0",
|
|
98
|
-
display: "flex",
|
|
99
|
-
alignItems: compact ? "flex-start" : "center",
|
|
100
|
-
justifyContent: "center",
|
|
101
|
-
width: "100%",
|
|
102
|
-
height: "100%",
|
|
103
|
-
padding: compact ? "min(24px, 4vw)" : "20px",
|
|
104
115
|
background: compact ? "rgba(0, 0, 0, 0.5)" : "color-mix(in srgb, var(--color-text, #111) 75%, transparent)",
|
|
105
116
|
backdropFilter: compact ? undefined : "blur(4px)",
|
|
106
117
|
color: "var(--color-text, #171717)",
|
|
107
118
|
fontFamily: "var(--font-body, system-ui, sans-serif)",
|
|
108
119
|
pointerEvents: "auto",
|
|
109
120
|
};
|
|
121
|
+
const frameStyle = {
|
|
122
|
+
boxSizing: "border-box",
|
|
123
|
+
position: "absolute" as const,
|
|
124
|
+
top: "0",
|
|
125
|
+
left: "0",
|
|
126
|
+
width: "100vw",
|
|
127
|
+
height: "100svh",
|
|
128
|
+
display: "flex",
|
|
129
|
+
overflowY: "auto" as const,
|
|
130
|
+
padding: compact ? "min(24px, 4vw)" : "20px",
|
|
131
|
+
};
|
|
110
132
|
const panelStyle = {
|
|
111
133
|
boxSizing: "border-box",
|
|
134
|
+
// Auto margins center without clipping a panel taller than the viewport.
|
|
135
|
+
margin: compact ? "0 auto auto" : "auto",
|
|
112
136
|
width: compact ? "min(600px, calc(100vw - 32px))" : "min(480px, calc(100vw - 40px))",
|
|
113
137
|
minHeight: compact ? "min(382px, calc(100vh - 48px))" : undefined,
|
|
114
138
|
maxHeight: compact ? "calc(100vh - 48px)" : undefined,
|
|
@@ -141,7 +165,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
141
165
|
onKeyDown={(event) => {
|
|
142
166
|
if (event.key !== "Tab") return;
|
|
143
167
|
const controls = Array.from(
|
|
144
|
-
event.currentTarget.querySelectorAll<
|
|
168
|
+
event.currentTarget.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]"),
|
|
145
169
|
);
|
|
146
170
|
if (controls.length === 0) return;
|
|
147
171
|
const first = controls[0]!;
|
|
@@ -155,6 +179,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
155
179
|
}
|
|
156
180
|
}}
|
|
157
181
|
>
|
|
182
|
+
<div style={frameStyle}>
|
|
158
183
|
<div style={panelStyle}>
|
|
159
184
|
{state === "ask" ? (
|
|
160
185
|
<>
|
|
@@ -164,9 +189,9 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
164
189
|
<span class={part.className}>{part.text}</span>
|
|
165
190
|
))}
|
|
166
191
|
</p>
|
|
167
|
-
) : (
|
|
168
|
-
<p style={{ margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "12px", fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--color-muted, #616161)" }}>
|
|
169
|
-
)}
|
|
192
|
+
) : eyebrow ? (
|
|
193
|
+
<p style={{ margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "12px", fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--color-muted, #616161)" }}>{eyebrow}</p>
|
|
194
|
+
) : null}
|
|
170
195
|
<h2 id="agegate-title" style={{ margin: 0, fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "clamp(24px, 4vw, 32px)", fontWeight: 600, lineHeight: 1.1, letterSpacing: "-.02em" }}>
|
|
171
196
|
{title}
|
|
172
197
|
</h2>
|
|
@@ -182,14 +207,23 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
182
207
|
>
|
|
183
208
|
{yesLabel}
|
|
184
209
|
</button>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
210
|
+
{showNo && (
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={() => setState("denied")}
|
|
214
|
+
style={{ ...buttonBase, width: compact ? "100%" : undefined, border: "1px solid var(--color-border, #d0d0d0)", background: "transparent", color: "var(--color-text, #171717)", fontWeight: 500 }}
|
|
215
|
+
>
|
|
216
|
+
{noLabel}
|
|
217
|
+
</button>
|
|
218
|
+
)}
|
|
192
219
|
</div>
|
|
220
|
+
{helpLink && (
|
|
221
|
+
<p style={{ margin: "16px 0 0", fontSize: "14px", lineHeight: 1.45 }}>
|
|
222
|
+
<a href={helpLink.href} style={{ color: "var(--color-accent-ink, var(--color-primary, #111))", textDecoration: "underline" }}>
|
|
223
|
+
{helpLink.label}
|
|
224
|
+
</a>
|
|
225
|
+
</p>
|
|
226
|
+
)}
|
|
193
227
|
{fineText && (
|
|
194
228
|
<p style={{ maxWidth: "32rem", margin: "20px auto 0", color: "var(--color-muted, #616161)", fontSize: "12px", lineHeight: 1.45 }}>
|
|
195
229
|
{fineText}
|
|
@@ -213,6 +247,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
|
|
|
213
247
|
</>
|
|
214
248
|
)}
|
|
215
249
|
</div>
|
|
250
|
+
</div>
|
|
216
251
|
</div>
|
|
217
252
|
);
|
|
218
253
|
}
|
|
@@ -375,17 +375,20 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
375
375
|
|
|
376
376
|
return (
|
|
377
377
|
<div
|
|
378
|
-
class="fixed inset-0 z-[110]
|
|
378
|
+
class="fixed inset-0 z-[110] bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] backdrop-blur-sm"
|
|
379
379
|
role="dialog"
|
|
380
380
|
aria-modal="true"
|
|
381
381
|
aria-labelledby="cgate-title"
|
|
382
382
|
onClick={(e) => {
|
|
383
|
-
if (e.target === e.currentTarget) close();
|
|
383
|
+
if (e.target === e.currentTarget || (e.target as HTMLElement).hasAttribute("data-viewport-frame")) close();
|
|
384
384
|
}}
|
|
385
385
|
>
|
|
386
|
+
{/* The fixed box is the LAYOUT viewport, which content wider than a phone
|
|
387
|
+
stretches; place the dialog in a frame sized to what the visitor sees. */}
|
|
388
|
+
<div data-viewport-frame class="absolute left-0 top-0 flex h-[100svh] w-[100vw] overflow-y-auto p-5">
|
|
386
389
|
<div
|
|
387
390
|
ref={dialogRef}
|
|
388
|
-
class="w-[min(32rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-left shadow-[var(--shadow-lg)]"
|
|
391
|
+
class="m-auto w-[min(32rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-left shadow-[var(--shadow-lg)]"
|
|
389
392
|
>
|
|
390
393
|
<p class="eyebrow eyebrow--tick mb-3 w-fit">Compliance check</p>
|
|
391
394
|
<h2 id="cgate-title" class="display-sm">
|
|
@@ -606,6 +609,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
606
609
|
</button>
|
|
607
610
|
</div>
|
|
608
611
|
</div>
|
|
612
|
+
</div>
|
|
609
613
|
</div>
|
|
610
614
|
);
|
|
611
615
|
}
|
|
@@ -118,6 +118,7 @@ export function isolateAgeGateBackground(host: HTMLElement): BackgroundIsolation
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
const HOST_MARKER = "data-tot-compliance";
|
|
121
|
+
const HOST_MOVES_PER_TASK = 20;
|
|
121
122
|
let mountedHost: HTMLElement | null = null;
|
|
122
123
|
const HOST_STYLE_FLOOR = {
|
|
123
124
|
all: "initial",
|
|
@@ -217,23 +218,58 @@ export function mountIsolatedAgeGate({ minAge, brand, tenantId, presentation }:
|
|
|
217
218
|
mount,
|
|
218
219
|
);
|
|
219
220
|
|
|
220
|
-
// Re-assert: restore the host if tenant code detaches or hides it.
|
|
221
|
+
// Re-assert: restore the host if tenant code detaches or hides it. The repair
|
|
222
|
+
// writes exactly what this observer watches, so it runs disconnected: a repair
|
|
223
|
+
// that is not perfectly idempotent can never re-trigger itself and spin the
|
|
224
|
+
// main thread in an unbounded microtask loop.
|
|
225
|
+
//
|
|
226
|
+
// Another script that also keeps itself last in <body> would answer every move
|
|
227
|
+
// with one of its own, and two observers can ping-pong forever without either
|
|
228
|
+
// re-triggering itself. Moves are budgeted per task; past the budget the repair
|
|
229
|
+
// yields to the event loop and resumes on the next task.
|
|
230
|
+
let active = true;
|
|
231
|
+
let moves = 0;
|
|
232
|
+
let resumeScheduled = false;
|
|
233
|
+
const spendMove = () => {
|
|
234
|
+
if (!resumeScheduled) {
|
|
235
|
+
resumeScheduled = true;
|
|
236
|
+
setTimeout(() => {
|
|
237
|
+
resumeScheduled = false;
|
|
238
|
+
moves = 0;
|
|
239
|
+
if (active) ensure();
|
|
240
|
+
}, 0);
|
|
241
|
+
}
|
|
242
|
+
return ++moves <= HOST_MOVES_PER_TASK;
|
|
243
|
+
};
|
|
244
|
+
const observeHost = () => {
|
|
245
|
+
observer.observe(document.body, { childList: true });
|
|
246
|
+
observer.observe(host, {
|
|
247
|
+
attributes: true,
|
|
248
|
+
attributeFilter: ["style", "class", "hidden"],
|
|
249
|
+
});
|
|
250
|
+
};
|
|
221
251
|
const ensure = () => {
|
|
222
|
-
|
|
223
|
-
|
|
252
|
+
observer.disconnect();
|
|
253
|
+
try {
|
|
254
|
+
if ((!host.isConnected || document.body.lastElementChild !== host) && spendMove()) {
|
|
255
|
+
// Moving the host blurs whatever the gate had focused; give it back so a
|
|
256
|
+
// keyboard visitor is never stranded by a script appending to <body>.
|
|
257
|
+
const focused = shadow.activeElement as HTMLElement | null;
|
|
258
|
+
document.body.appendChild(host);
|
|
259
|
+
focused?.focus({ preventScroll: true });
|
|
260
|
+
}
|
|
261
|
+
if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
|
|
262
|
+
hardenComplianceHost(host);
|
|
263
|
+
backgroundIsolation?.refresh();
|
|
264
|
+
} finally {
|
|
265
|
+
observeHost();
|
|
224
266
|
}
|
|
225
|
-
if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
|
|
226
|
-
hardenComplianceHost(host);
|
|
227
|
-
backgroundIsolation?.refresh();
|
|
228
267
|
};
|
|
229
268
|
const observer = new MutationObserver(ensure);
|
|
230
|
-
|
|
231
|
-
observer.observe(host, {
|
|
232
|
-
attributes: true,
|
|
233
|
-
attributeFilter: ["style", "class", "hidden"],
|
|
234
|
-
});
|
|
269
|
+
observeHost();
|
|
235
270
|
|
|
236
271
|
return () => {
|
|
272
|
+
active = false;
|
|
237
273
|
observer.disconnect();
|
|
238
274
|
stylesheetObserver.disconnect();
|
|
239
275
|
backgroundIsolation?.release();
|
|
@@ -167,7 +167,9 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
|
|
|
167
167
|
|
|
168
168
|
{open && (
|
|
169
169
|
<div
|
|
170
|
-
|
|
170
|
+
// Sized to what the visitor sees, not `inset-0`: on a phone the fixed box is the
|
|
171
|
+
// layout viewport, which wide page content stretches past the screen's edge.
|
|
172
|
+
class="fixed left-0 top-0 z-50 h-[100dvh] w-[100vw]"
|
|
171
173
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
172
174
|
onKeyDown={onKeyDown}
|
|
173
175
|
>
|
|
@@ -1426,7 +1426,8 @@ stable `id` (a governed-action-registry substitute, independent of the visible `
|
|
|
1426
1426
|
to `ChromeHeader`/`ChromeFooter`, it exports `ChromeBrand`, `ChromeSearch`, `ChromeUtilityNav`,
|
|
1427
1427
|
`ChromeAnnouncement` (`{ variant: "marquee" | "bar" | "solid"; messages: string[]; label?;
|
|
1428
1428
|
dismissible?; rotateMs?; storageKey? }`), `ChromeAgeGate` (`{ minAge: number; storageKey?; title?;
|
|
1429
|
-
lede?; yesLabel?; noLabel?; denyText?; fineText?; mark
|
|
1429
|
+
lede?; yesLabel?; noLabel?; denyText?; fineText?; mark?; showNo?; eyebrow?: string | false;
|
|
1430
|
+
helpLink?: { label; href } }`), `ChromeFooterBrand`, `ChromeTrustMark`,
|
|
1430
1431
|
`ChromeHead`, `ChromeCta`, `ChromeNavItem`, and `ChromeConfig` itself.
|
|
1431
1432
|
|
|
1432
1433
|
### `lib/chrome/parseConfig.ts` — the validation gate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.37",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -228,6 +228,17 @@ export interface ChromeAgeGate {
|
|
|
228
228
|
fineText?: string;
|
|
229
229
|
/** Optional styled wordmark above the title (spans, like ChromeBrand.wordmark). */
|
|
230
230
|
mark?: { text: string; className?: string }[];
|
|
231
|
+
/** `false` renders a single affirm button; the deny flow is the default. */
|
|
232
|
+
showNo?: boolean;
|
|
233
|
+
/** The line above the title when no `mark` is set: text replaces "Age verification"; `false` omits it. */
|
|
234
|
+
eyebrow?: string | false;
|
|
235
|
+
/** Help/FAQ link under the actions. `href` is a same-site path or an https URL. */
|
|
236
|
+
helpLink?: { label: string; href: string };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A help link the platform-owned gate will render: a same-site path or an https URL. */
|
|
240
|
+
export function isSafeAgeGateHref(href: string): boolean {
|
|
241
|
+
return /^\/(?![/\\])/.test(href) || /^https:\/\/[^/\\]/i.test(href);
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
/** Platform-owned persistence scope for a tenant's enforced age affirmation. */
|
|
@@ -590,6 +601,24 @@ function validateAgeGate(v: unknown, path: string, errors: string[]): v is Chrom
|
|
|
590
601
|
});
|
|
591
602
|
}
|
|
592
603
|
}
|
|
604
|
+
if (v.showNo !== undefined && typeof v.showNo !== "boolean") {
|
|
605
|
+
errors.push(`${path}.showNo: must be a boolean`);
|
|
606
|
+
}
|
|
607
|
+
if (v.eyebrow !== undefined && v.eyebrow !== false && !isNonEmptyString(v.eyebrow)) {
|
|
608
|
+
errors.push(`${path}.eyebrow: must be a non-empty string or false`);
|
|
609
|
+
}
|
|
610
|
+
if (v.helpLink !== undefined) {
|
|
611
|
+
if (!isObject(v.helpLink)) {
|
|
612
|
+
errors.push(`${path}.helpLink: must be an object`);
|
|
613
|
+
} else {
|
|
614
|
+
if (!isNonEmptyString(v.helpLink.label)) {
|
|
615
|
+
errors.push(`${path}.helpLink.label: required non-empty string`);
|
|
616
|
+
}
|
|
617
|
+
if (typeof v.helpLink.href !== "string" || !isSafeAgeGateHref(v.helpLink.href)) {
|
|
618
|
+
errors.push(`${path}.helpLink.href: required; a same-site path ("/pages/faq") or an https:// URL`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
593
622
|
return errors.length === before;
|
|
594
623
|
}
|
|
595
624
|
|