@tokenoftrust/storefront-runner 4.4.0 → 4.5.0

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.
@@ -7,7 +7,7 @@
7
7
  * Encoding handled by src/lib/publication/shared/url.ts (opt.<Name>=<value>, tag=, price, avail).
8
8
  */
9
9
  import type { Facet, ListProductsQuery } from "@tot/public-runtime";
10
- import { toggleFacet, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
10
+ import { toggleFacet, splitFacetValues, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/publication/shared/url";
11
11
  import { PRICE_BUCKETS, bucketByKey } from "@/lib/catalog/shared/priceBuckets";
12
12
 
13
13
  interface Props {
@@ -52,17 +52,29 @@ const clearHref =
52
52
 
53
53
  <div class="space-y-7">
54
54
  {
55
- ordered.map((facet) => (
56
- <fieldset>
57
- <legend class="mb-2.5 font-display text-[0.95rem] font-medium">
58
- {facet.name === "tags"
59
- ? "Tags"
60
- : facet.name === "availability"
61
- ? "Availability"
62
- : facet.name}
63
- </legend>
55
+ ordered.map((facet) => {
56
+ const hasSelected = facet.values.some((fv) => selected(facet.name, fv.value));
57
+ const label =
58
+ facet.name === "tags" ? "Tags" : facet.name === "availability" ? "Availability" : facet.name;
59
+ // The first values (plus any selected one) are listed; the rest sit in a nested
60
+ // "Show more" <details>. A selected value is never in the remainder.
61
+ const { shown, rest } = splitFacetValues(facet.values, (v) => selected(facet.name, v));
62
+ const segments = [
63
+ { more: false, values: shown },
64
+ ...(rest.length > 0 ? [{ more: true, values: rest }] : []),
65
+ ];
66
+ return (
67
+ <details open={hasSelected} data-facet-group={facet.name}>
68
+ <summary class="mb-2.5 cursor-pointer font-display text-[0.95rem] font-medium">
69
+ {label} ({facet.values.length})
70
+ </summary>
71
+ {segments.map((seg) => {
72
+ const Wrap = seg.more ? "details" : Fragment;
73
+ return (
74
+ <Wrap>
75
+ {seg.more && <summary class="cursor-pointer py-1 text-xs text-[var(--color-muted)]">Show more</summary>}
64
76
  <ul class="space-y-1.5">
65
- {facet.values.map((fv) => {
77
+ {seg.values.map((fv) => {
66
78
  const isSel = selected(facet.name, fv.value);
67
79
  const href =
68
80
  basePath + toggleFacet(query, facet.name, fv.value);
@@ -101,8 +113,12 @@ const clearHref =
101
113
  );
102
114
  })}
103
115
  </ul>
104
- </fieldset>
105
- ))
116
+ </Wrap>
117
+ );
118
+ })}
119
+ </details>
120
+ );
121
+ })
106
122
  }
107
123
 
108
124
  {/* Price range — bucketed quick-select (live counts) + a free-text form
@@ -64,6 +64,12 @@ interface Props {
64
64
  */
65
65
  layout?: HeaderVariant;
66
66
  sticky?: boolean;
67
+ /**
68
+ * Render the brand link inside `<h1 class="brand-h1">` (margin:0; font:inherit).
69
+ * The caller sets it on the home route only, and only when the page content
70
+ * supplies no h1 of its own — exactly one h1 per page.
71
+ */
72
+ brandAsH1?: boolean;
67
73
  class?: string;
68
74
  }
69
75
 
@@ -85,6 +91,7 @@ const {
85
91
  variant = "light",
86
92
  layout = "primary",
87
93
  sticky = true,
94
+ brandAsH1 = false,
88
95
  class: className,
89
96
  } = Astro.props;
90
97
 
@@ -96,7 +103,16 @@ const dark = variant === "dark";
96
103
  const minimal = layout === "minimal";
97
104
  const compact = layout === "compact-centered";
98
105
 
99
- const ctaClass = (v?: string) =>
106
+ // Home brand heading: the brand link sits inside <h1 class="brand-h1"> (look unchanged);
107
+ // the compact layout's positioning classes move to the h1, which is then the grid child.
108
+ const brandCompactClass =
109
+ "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center";
110
+ const BrandWrap = brandAsH1 ? "h1" : Fragment;
111
+ const brandWrapProps = brandAsH1
112
+ ? { class: ["brand-h1", compact && brandCompactClass].filter(Boolean).join(" "), style: "margin:0;font:inherit" }
113
+ : {};
114
+
115
+ const ctaClass =(v?: string) =>
100
116
  v === "secondary" || v === "ghost"
101
117
  ? "inline-flex items-center rounded-full border border-current px-4 py-2 font-display text-sm font-medium transition-opacity hover:opacity-80"
102
118
  : "inline-flex items-center rounded-full bg-accent px-4 py-2 font-display text-sm font-medium text-accent-contrast transition-transform hover:-translate-y-0.5";
@@ -151,11 +167,12 @@ const currentPath = Astro.url.pathname;
151
167
  )
152
168
  }
153
169
  <!-- Logo / wordmark -->
170
+ <BrandWrap {...brandWrapProps}>
154
171
  <a
155
172
  href={homeHref}
156
173
  class:list={[
157
174
  "flex shrink-0 items-center transition-opacity hover:opacity-80",
158
- compact && "absolute left-1/2 -translate-x-1/2 md:static md:translate-x-0 md:justify-self-center",
175
+ !brandAsH1 && compact && brandCompactClass,
159
176
  ]}
160
177
  aria-label={logo?.alt ?? wordmark ?? "Home"}
161
178
  >
@@ -203,6 +220,7 @@ const currentPath = Astro.url.pathname;
203
220
  )
204
221
  }
205
222
  </a>
223
+ </BrandWrap>
206
224
 
207
225
  <!-- Search (live typeahead island) -->
208
226
  {
@@ -29,7 +29,14 @@ import { organizationLd, websiteLd } from "@/lib/content/shared/jsonld";
29
29
  import { composeStructuredData, structuredDataProfile, type StructuredDataPage } from "@/lib/content/seo/structuredData";
30
30
  import { canonicalUrl } from "@/lib/content/seo/urlForm";
31
31
  import { documentTitle } from "@/lib/content/seo/documentTitle";
32
- import { nonceInlineTags, prefixSharedChrome, CHROME_BODY_MARKER } from "@/lib/content/shared/rawMarketingHtml";
32
+ import {
33
+ nonceInlineTags,
34
+ prefixSharedChrome,
35
+ CHROME_BODY_MARKER,
36
+ demoteBrandH1,
37
+ hasContentH1,
38
+ isHomePath,
39
+ } from "@/lib/content/shared/rawMarketingHtml";
33
40
  import { withBase } from "@/lib/publication/shared/basePath";
34
41
  import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/content/shared/rawChrome";
35
42
  import { ASYNC_CSS_SWAP_JS } from "@/lib/content/shared/asyncCss";
@@ -218,6 +225,16 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
218
225
  };
219
226
  }
220
227
 
228
+ // One-h1 contract: on the home route the header brand is the page's <h1> unless the
229
+ // page content supplies its own (then the brand stays a plain link). Only home
230
+ // renders the slot to a string to inspect it.
231
+ const isHome = isHomePath(path);
232
+ const homeSlotHtml = isHome ? await Astro.slots.render("default") : null;
233
+ const brandIsH1 = isHome && !hasContentH1(homeSlotHtml ?? "");
234
+ if (sharedChrome && !brandIsH1) {
235
+ sharedChrome.header = demoteBrandH1(sharedChrome.header);
236
+ }
237
+
221
238
  const compliance = tenant.compliance;
222
239
  const sections = complianceSections({
223
240
  compliance,
@@ -395,6 +412,7 @@ const footerWidgets = await renderWidgetSlot(
395
412
  nav={chrome.header.nav}
396
413
  variant={headerDark ? "dark" : "light"}
397
414
  layout={resolvedHeaderVariant}
415
+ brandAsH1={brandIsH1}
398
416
  announcement={chrome.header.announcement?.messages[0]}
399
417
  searchAction={
400
418
  features.productSearch
@@ -415,7 +433,7 @@ const footerWidgets = await renderWidgetSlot(
415
433
  )}
416
434
 
417
435
  <main id="main">
418
- <slot />
436
+ {homeSlotHtml !== null ? <Fragment set:html={homeSlotHtml} /> : <slot />}
419
437
  </main>
420
438
 
421
439
  {sections.includes("pact-act") && (
@@ -30,6 +30,15 @@ export interface SeamView {
30
30
  codes?: readonly string[];
31
31
  /** Flavor labels that triggered a `flavored_prohibited` block, for the refusal. */
32
32
  flavors?: readonly string[];
33
+ /** Every refused block with the compliance engine's own shopper-facing sentence. */
34
+ lines?: readonly RefusedLine[];
35
+ }
36
+
37
+ export interface RefusedLine {
38
+ code: string;
39
+ message: string;
40
+ sku?: string;
41
+ title?: string;
33
42
  }
34
43
 
35
44
  export interface FinalCartRequest {
@@ -17,10 +17,24 @@
17
17
  *
18
18
  * A cart-origin link click or form submit that beats the drawer is held, not
19
19
  * lost: it is cancelled, the loader is fetched, and the same control is activated
20
- * again once the drawer reports ready (or after a bounded wait), so it opens in
21
- * the drawer exactly as it would have. Holding runs in the window bubble phase,
22
- * after every page handler, so an action another handler already took over (the
23
- * compliance gate) is left alone.
20
+ * again once the drawer client reports `ready.done`, so it opens in the drawer
21
+ * exactly as it would have. Foxy binds its cart-link handler in `FC.client.init`
22
+ * and initialises the side cart on `ready.done`; before that a cart link is a
23
+ * plain navigation to the full cart page. So a held action waits for that signal,
24
+ * not a clock: it follows the link to the cart page only when the drawer cannot
25
+ * come up (the loader or bundle errors, no client appears, or nothing reports
26
+ * within LAZY_CHECKOUT_READY_TIMEOUT_MS). A held action never outlives the page
27
+ * that held it. Holding runs in the window bubble phase, after every page
28
+ * handler, so an action another handler already took over (the compliance gate,
29
+ * or the drawer itself) is left alone.
30
+ *
31
+ * Every cart action that reaches the window still unhandled once the drawer has
32
+ * settled is about to navigate to the cart page. That is recorded: a
33
+ * `[tot-cart]` console warning, a `tot:cart-fallthrough` window event, and
34
+ * `data-cart-fallthrough="<reason>[:<cause>]"` on the root element. The detail
35
+ * carries only the reason, the cause, elapsed ms, whether the action was held,
36
+ * whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
37
+ * document ready state and viewport/screen/body widths — never a URL or session.
24
38
  *
25
39
  * Every cart open waits for this store's Foxy session. The id comes from the
26
40
  * same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
@@ -43,8 +57,12 @@ import { CART_UNAVAILABLE_MESSAGE } from "@/lib/checkout/commerce/cartSession";
43
57
 
44
58
  export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
45
59
 
46
- /** Upper bound on waiting for the drawer to report ready before replaying a held action. */
47
- export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 4000;
60
+ /**
61
+ * Upper bound on waiting for the drawer client to report ready before a held action
62
+ * follows its link to the full cart page. Long enough for a cold drawer bundle on a
63
+ * slow phone; reached only when the drawer is not coming up at all.
64
+ */
65
+ export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 12000;
48
66
 
49
67
  export const LAZY_CHECKOUT_LOADER_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
50
68
 
@@ -81,7 +99,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
81
99
  var referrerPolicy = ${literal(CHECKOUT_NAVIGATION_REFERRER_POLICY)};
82
100
  var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
83
101
  var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
102
+ // idle → loading → ready (the drawer client reported ready.done) | failed (it cannot come up).
84
103
  var state = 'idle';
104
+ var failure = null;
105
+ var armedAt = 0;
106
+ var replaying = null;
85
107
  var waiters = [];
86
108
  var sessionId = null;
87
109
  var sessionPromise = null;
@@ -143,7 +165,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
143
165
  document.addEventListener("visibilitychange", function () {
144
166
  if (document.visibilityState === "hidden") endCartNavigation();
145
167
  });
146
- window.addEventListener("pagehide", endCartNavigation);
168
+ window.addEventListener("pagehide", function () {
169
+ endCartNavigation();
170
+ dropHeld();
171
+ });
147
172
  window.addEventListener("pageshow", function (event) {
148
173
  if (event.persisted) endCartNavigation();
149
174
  });
@@ -201,20 +226,75 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
201
226
  });
202
227
  return sessionPromise;
203
228
  }
204
- function markSessionUnavailable() {
229
+ function release(held) {
230
+ if (held.el && held.el.removeAttribute) held.el.removeAttribute('aria-busy');
231
+ }
232
+ function dropHeld() {
233
+ var dropped = waiters;
205
234
  waiters = [];
235
+ for (var i = 0; i < dropped.length; i++) release(dropped[i]);
236
+ }
237
+ function markSessionUnavailable() {
238
+ dropHeld();
206
239
  state = 'idle';
207
240
  document.documentElement.setAttribute('data-cart-session', 'unavailable');
208
241
  for (var i = 0; i < events.length; i++) {
209
242
  document.addEventListener(events[i], onInput, { capture: true, passive: true });
210
243
  }
211
244
  }
245
+ function settled() {
246
+ return state === 'ready' || state === 'failed';
247
+ }
248
+ function runHeld() {
249
+ var run = waiters;
250
+ waiters = [];
251
+ for (var i = 0; i < run.length; i++) {
252
+ release(run[i]);
253
+ run[i].fn();
254
+ }
255
+ }
212
256
  function ready() {
213
257
  if (state === 'ready') return;
214
258
  state = 'ready';
215
- var run = waiters;
216
- waiters = [];
217
- for (var i = 0; i < run.length; i++) run[i]();
259
+ failure = null;
260
+ runHeld();
261
+ }
262
+ function fail(cause) {
263
+ if (state !== 'loading') return;
264
+ state = 'failed';
265
+ failure = cause;
266
+ runHeld();
267
+ }
268
+ // A cart action reaching the window unhandled after the drawer settled navigates
269
+ // to the full cart page. Record why, with no URL or session in the record.
270
+ function fallthrough(kind, el) {
271
+ var fc = window.FC || {};
272
+ var fullpage = null;
273
+ try {
274
+ if (fc.sidecart && typeof fc.sidecart.shouldUseFullpageCart === 'function') fullpage = !!fc.sidecart.shouldUseFullpageCart();
275
+ } catch (e) {}
276
+ var body = document.body;
277
+ function bypassed(node) {
278
+ return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
279
+ }
280
+ var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
281
+ var detail = {
282
+ reason: reason,
283
+ cause: failure,
284
+ kind: kind,
285
+ held: replaying === el,
286
+ elapsedMs: armedAt ? Date.now() - armedAt : null,
287
+ fcClient: !!fc.client,
288
+ fullpage: fullpage,
289
+ bypass: bypassed(body) || bypassed(el),
290
+ docState: document.readyState,
291
+ viewport: window.innerWidth,
292
+ screenWidth: window.screen ? window.screen.availWidth : null,
293
+ bodyWidth: body ? body.clientWidth : null
294
+ };
295
+ document.documentElement.setAttribute('data-cart-fallthrough', reason + (failure ? ':' + failure : ''));
296
+ try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
297
+ try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
218
298
  }
219
299
  function armDrawer(id) {
220
300
  document.documentElement.removeAttribute('data-cart-session');
@@ -225,23 +305,25 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
225
305
  window.FC.settings.cookiepath = cookiePath;
226
306
  window.FC.settings.sitedomain = location.hostname;
227
307
  };
228
- setTimeout(ready, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
308
+ armedAt = Date.now();
309
+ setTimeout(function () { fail('ready-timeout'); }, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
229
310
  var s = document.createElement('script');
230
311
  s.src = src;
231
312
  s.async = true;
232
- // The loader appends the drawer bundle and exposes it as fc_script; the drawer is
233
- // ready once that bundle has run and its client reports ready.done.
313
+ // The loader appends the drawer bundle and exposes it as fc_script. The bundle's
314
+ // FC.client.init binds the cart-link handler and ready.done initialises the side
315
+ // cart, so ready.done is the only signal that a cart link will open the drawer.
234
316
  s.addEventListener('load', function () {
235
317
  var bundle = window.fc_script;
236
- if (!bundle) return ready();
318
+ if (!bundle) return fail('no-bundle');
237
319
  bundle.addEventListener('load', function () {
238
320
  var client = window.FC && window.FC.client;
239
- if (!client || typeof client.on !== 'function') return ready();
321
+ if (!client || typeof client.on !== 'function') return fail('no-client');
240
322
  client.on('ready.done', ready);
241
323
  });
242
- bundle.addEventListener('error', ready);
324
+ bundle.addEventListener('error', function () { fail('bundle-error'); });
243
325
  });
244
- s.addEventListener('error', ready);
326
+ s.addEventListener('error', function () { fail('loader-error'); });
245
327
  document.body.appendChild(s);
246
328
  }
247
329
  function start() {
@@ -253,11 +335,16 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
253
335
  armDrawer(id);
254
336
  });
255
337
  }
256
- function whenReady(fn) {
257
- if (state === 'ready') return fn();
258
- waiters.push(fn);
338
+ function whenReady(el, fn) {
339
+ if (settled()) return fn();
340
+ el.setAttribute('aria-busy', 'true');
341
+ waiters.push({ el: el, fn: fn });
259
342
  start();
260
343
  }
344
+ function replay(el, fn) {
345
+ replaying = el;
346
+ try { fn(); } finally { replaying = null; }
347
+ }
261
348
  function onInput() {
262
349
  if (commerceReachable()) start();
263
350
  }
@@ -292,16 +379,19 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
292
379
  var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
293
380
  if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
294
381
  anchor.referrerPolicy = referrerPolicy;
295
- if (state === 'ready') {
382
+ if (settled()) {
296
383
  stampAnchor(anchor);
297
- if (!event.defaultPrevented) revealForNavigation();
384
+ if (!event.defaultPrevented) {
385
+ fallthrough('link', anchor);
386
+ revealForNavigation();
387
+ }
298
388
  return;
299
389
  }
300
390
  if (event.defaultPrevented) return;
301
391
  event.preventDefault();
302
- whenReady(function () {
392
+ whenReady(anchor, function () {
303
393
  stampAnchor(anchor);
304
- anchor.click();
394
+ replay(anchor, function () { anchor.click(); });
305
395
  });
306
396
  });
307
397
 
@@ -352,10 +442,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
352
442
  var cartForm = targetsCart(form.getAttribute('action'));
353
443
  if (!managed && !cartForm) return;
354
444
  if (form.dataset.cartSession === '1') {
355
- if (!cartForm || state === 'ready') {
356
- if (!event.defaultPrevented && state === 'ready') {
445
+ if (!cartForm || settled()) {
446
+ if (!event.defaultPrevented && settled()) {
357
447
  form.referrerPolicy = referrerPolicy;
358
448
  if (cartForm) {
449
+ fallthrough('form', form);
359
450
  form.setAttribute("referrerpolicy", referrerPolicy);
360
451
  loosenReferrer();
361
452
  revealForNavigation();
@@ -364,7 +455,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
364
455
  return;
365
456
  }
366
457
  event.preventDefault();
367
- whenReady(function () { stampForm(form); resubmit(form); });
458
+ whenReady(form, function () { stampForm(form); replay(form, function () { resubmit(form); }); });
368
459
  return;
369
460
  }
370
461
  event.preventDefault();
@@ -372,7 +463,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
372
463
  openSessionThen(form, function () {
373
464
  if (cartForm) {
374
465
  stampForm(form);
375
- whenReady(function () { resubmit(form); });
466
+ whenReady(form, function () { replay(form, function () { resubmit(form); }); });
376
467
  return;
377
468
  }
378
469
  resubmit(form);
@@ -24,7 +24,12 @@
24
24
  * stray quote/angle-bracket can't break out of an attribute or element; this is
25
25
  * defense-in-depth, not a sanitizer for untrusted input.
26
26
  */
27
- import { CHROME_BODY_MARKER, CHROME_HEAD_MARKER } from "./rawMarketingHtml.js";
27
+ import {
28
+ BRAND_H1_CLASS,
29
+ CHROME_BODY_MARKER,
30
+ CHROME_HEAD_MARKER,
31
+ isHomePath,
32
+ } from "./rawMarketingHtml.js";
28
33
  import { isCurrentNavHref } from "../chrome/navCurrent.js";
29
34
  import { ctaIdentityAttr } from "../chrome/ctaIdentity.js";
30
35
  import { CHROME_PLACEMENT } from "../chrome/trackingPlacements.js";
@@ -325,6 +330,14 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
325
330
  }
326
331
 
327
332
  function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?: string): string {
333
+ // One-h1 contract: on the home route (header only, never the footer) the brand
334
+ // renders inside <h1 class="brand-h1">. When the spliced page body supplies its
335
+ // own <h1>, wrapFragmentInChrome / Layout unwrap this one (demoteBrandH1), so
336
+ // every page carries exactly one h1.
337
+ const brandLink = renderBrand(header.brand);
338
+ const brandBlock = isHomePath(currentPath)
339
+ ? `<h1 class="${BRAND_H1_CLASS}" style="margin:0;font:inherit">${brandLink}</h1>`
340
+ : brandLink;
328
341
  const search = header.search ? renderSearchForm(header.search) : "";
329
342
  const memberCue = header.memberCue
330
343
  ? `<a class="member-cue" href="${esc(header.memberCue.href)}" aria-label="${esc(header.memberCue.label)}">${esc(header.memberCue.label)}</a>`
@@ -424,7 +437,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
424
437
  ` <div class="container header-row">\n` +
425
438
  (menuLeft && hamburger ? ` ${hamburger}\n` : "") +
426
439
  (compact ? ` ${primaryNav}\n` : "") +
427
- ` ${renderBrand(header.brand)}\n` +
440
+ ` ${brandBlock}\n` +
428
441
  (!minimal && !compact && search ? ` ${search}\n` : "") +
429
442
  ` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
430
443
  ` </div>\n` +
@@ -56,13 +56,39 @@ export function isFullDocument(html: string): boolean {
56
56
  return /^\s*<!doctype/i.test(html) || /<html[\s>]/i.test(html);
57
57
  }
58
58
 
59
+ /** Class of the home-route header brand heading (`<h1 class="brand-h1">`). */
60
+ export const BRAND_H1_CLASS = "brand-h1";
61
+
62
+ /** True for the home route ("/" with optional query/hash); false when no path is known. */
63
+ export function isHomePath(path: string | undefined): boolean {
64
+ return path !== undefined && (path.split(/[?#]/)[0] ?? "") === "/";
65
+ }
66
+
67
+ /** True when page content already supplies an `<h1>`. */
68
+ export function hasContentH1(html: string): boolean {
69
+ return /<h1[\s>]/i.test(html);
70
+ }
71
+
72
+ /**
73
+ * Unwrap the home brand heading (keeping the brand link) so a page whose own
74
+ * content supplies an `<h1>` ends up with exactly one h1.
75
+ */
76
+ export function demoteBrandH1(html: string): string {
77
+ return html.replace(
78
+ new RegExp(`<h1 class="${BRAND_H1_CLASS}"[^>]*>([\\s\\S]*?)</h1>`),
79
+ "$1",
80
+ );
81
+ }
82
+
59
83
  /**
60
84
  * Splice a body-only fragment into a tenant chrome wrapper at CHROME_BODY_MARKER.
85
+ * When the fragment carries its own `<h1>`, the wrapper's brand h1 is unwrapped.
61
86
  * If the wrapper has no marker (misconfigured), the fragment is returned unwrapped
62
87
  * so a page never disappears.
63
88
  */
64
89
  export function wrapFragmentInChrome(fragment: string, chromeWrapper: string): string {
65
90
  if (!chromeWrapper.includes(CHROME_BODY_MARKER)) return fragment;
91
+ if (hasContentH1(fragment)) chromeWrapper = demoteBrandH1(chromeWrapper);
66
92
  // A fragment may lead with a <head>…</head> of page-specific SEO tags. Split it
67
93
  // out so the shared wrapper keeps per-page title/meta/canonical/JSON-LD.
68
94
  let head = "";
@@ -96,6 +96,25 @@ export function buildQueryString(
96
96
  return s ? `?${s}` : "";
97
97
  }
98
98
 
99
+ /** Facet values shown before the nested "Show more" disclosure. */
100
+ export const FACET_VISIBLE_VALUES = 8;
101
+
102
+ /**
103
+ * Split a facet group's values into the always-visible head and the collapsed
104
+ * remainder: the first FACET_VISIBLE_VALUES, plus every selected value wherever
105
+ * it sits, so a selected value is never hidden in the remainder.
106
+ */
107
+ export function splitFacetValues<T extends { value: string }>(
108
+ values: readonly T[],
109
+ isSelected: (value: string) => boolean,
110
+ limit: number = FACET_VISIBLE_VALUES,
111
+ ): { shown: T[]; rest: T[] } {
112
+ const shown: T[] = [];
113
+ const rest: T[] = [];
114
+ values.forEach((fv, i) => (i < limit || isSelected(fv.value) ? shown : rest).push(fv));
115
+ return { shown, rest };
116
+ }
117
+
99
118
  /** Toggle a single facet value on/off, returning the new querystring (page reset). */
100
119
  export function toggleFacet(
101
120
  current: ListProductsQuery,
@@ -948,6 +948,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
948
948
  const key = pageKey;
949
949
  await timed(timing, "page-cache", () => putCachedPage(cache, key, response));
950
950
  }
951
+ // A preview-token (`test`) render is unpublished content shown to one viewer: no
952
+ // browser, proxy, or CDN may keep it or hand it to the next visitor of this URL.
953
+ if (customization.env === "test") response.headers.set("Cache-Control", "private, no-store");
951
954
  // MATERIALIZE-ONLY capture (ADR 0013): expose the tenant's resolved route-document
952
955
  // Cache-Control so the publish/materialize plane can bake it into the bundle
953
956
  // manifest (the same capture pattern as CSP). Gated on the materialize probe
@@ -8,7 +8,7 @@ import {
8
8
  } from "@tot/public-runtime";
9
9
  import { fromD1 } from "@/lib/catalog/d1/catalog.js";
10
10
  import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
11
- import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
11
+ import { evaluateFinalCart, type RefusedLine, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
12
12
  import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
13
13
  import { readMirrorBalance, readMirrorStock } from "@/lib/inventory/mirrorStock.js";
14
14
  import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
@@ -108,8 +108,19 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
108
108
  const blocks = decision.blocks;
109
109
  if (blocks.length === 0) return { status: "clear", verified };
110
110
  const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
111
+ const titleOf = (sku: string) => products.find(p => p.variants.some(v => v.sku === sku))?.title;
112
+ const seen = new Set<string>();
113
+ const lines: RefusedLine[] = [];
114
+ for (const block of blocks) {
115
+ if (block.failedClosed === true || typeof block.message !== "string" || !block.message) continue;
116
+ const key = `${block.code}\n${block.sku ?? ""}\n${block.message}`;
117
+ if (seen.has(key)) continue;
118
+ seen.add(key);
119
+ const title = block.sku ? titleOf(block.sku) : undefined;
120
+ lines.push({ code: block.code, message: block.message, ...(block.sku ? { sku: block.sku } : {}), ...(title ? { title } : {}) });
121
+ }
111
122
  return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
112
- ...(flavors.length ? { flavors } : {}) };
123
+ ...(flavors.length ? { flavors } : {}), ...(lines.length ? { lines } : {}) };
113
124
  } catch { return { status: "absent", verified: false }; }
114
125
  }
115
126
  function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
@@ -306,8 +317,10 @@ async function authorize(context: APIContext): Promise<Response> {
306
317
  seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
307
318
  trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
308
319
  if (!decision.ok) {
309
- if (decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors) {
310
- return json({ ok: false, finding: decision.finding, flavors: seamView.flavors }, 409);
320
+ if (decision.finding.startsWith("verification-refused") && (seamView.flavors || seamView.lines)) {
321
+ return json({ ok: false, finding: decision.finding,
322
+ ...(decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors ? { flavors: seamView.flavors } : {}),
323
+ ...(seamView.lines ? { lines: seamView.lines } : {}) }, 409);
311
324
  }
312
325
  return deny(decision.finding, 409, decision.owner);
313
326
  }
@@ -158,16 +158,26 @@ export async function GET(context: APIContext): Promise<Response> {
158
158
  const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
159
159
 
160
160
  const pathname = url.pathname;
161
+ const publicationChannel =
162
+ sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live";
161
163
  const fromPublication = await tryServePublicationRootAsset({
162
164
  tenant: context.locals.tenant,
163
165
  bucket: bucket as unknown as PublicationArtifactReadBucketV1,
164
166
  kvGet: (key) => kv.get(key),
165
167
  tenantId: urlTenant,
166
168
  pathname,
167
- channel: sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live",
169
+ channel: publicationChannel,
168
170
  ifNoneMatch: context.request.headers.get("if-none-match"),
169
171
  });
170
- if (fromPublication) return fromPublication;
172
+ if (fromPublication) {
173
+ // Preview bytes answer a tokenless URL (the credential rides in a cookie), so
174
+ // they must never be cached where the next visitor of that URL would get them.
175
+ if (publicationChannel === "preview") {
176
+ fromPublication.headers.set("cache-control", "no-store");
177
+ fromPublication.headers.set("x-robots-tag", "noindex, nofollow");
178
+ }
179
+ return fromPublication;
180
+ }
171
181
 
172
182
  if (previewVersionId) {
173
183
  const overlay = await tryServePreviewRewriteOverlay({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
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",
@@ -782,6 +782,16 @@ export const CONSPICUITY_FLOOR = Object.freeze({ contrast: 7, fontSizePx: 12, fo
782
782
  /** Bounds on the size a custom style may declare, so the bar cannot swallow the header. */
783
783
  const MAX_FONT_SIZE_PX = 24;
784
784
 
785
+ /** Bounds on the optional box and spacing keys; an unset key renders the preset's own frame. */
786
+ export const NICOTINE_WARNING_BOX_BOUNDS = Object.freeze({
787
+ borderWidthPx: Object.freeze({ min: 0, max: 8, integer: true }),
788
+ paddingYPx: Object.freeze({ min: 0, max: 24, integer: true }),
789
+ letterSpacingPx: Object.freeze({ min: 0, max: 4, integer: false }),
790
+ });
791
+
792
+ /** Weight 500 is allowed only on a boxed bar that is at least this large, contrasty and bordered. */
793
+ export const MEDIUM_WEIGHT_REQUIREMENTS = Object.freeze({ fontSizePx: 16, contrast: 7, borderWidthPx: 2 });
794
+
785
795
  export const DEFAULT_NICOTINE_WARNING_PRESET = "dark";
786
796
 
787
797
  /**
@@ -816,6 +826,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
816
826
  * textTransform: "uppercase" | "none",
817
827
  * fontWeight: number,
818
828
  * fontSizePx: number,
829
+ * borderWidthPx?: number,
830
+ * paddingYPx?: number,
831
+ * letterSpacingPx?: number,
819
832
  * }} NicotineWarningPresentation
820
833
  */
821
834
 
@@ -824,9 +837,10 @@ export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWid
824
837
 
825
838
  export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
826
839
  "background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
840
+ "borderWidthPx", "paddingYPx", "letterSpacingPx",
827
841
  ]);
828
842
  const TEXT_TRANSFORMS = ["uppercase", "none"];
829
- const FONT_WEIGHTS = [600, 700, 800, 900];
843
+ const FONT_WEIGHTS = [500, 600, 700, 800, 900];
830
844
  const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
831
845
 
832
846
  /** `#abc` / `#AABBCC` → `#aabbcc`. */
@@ -865,7 +879,15 @@ export function conspicuityProblems(p) {
865
879
  out.push(`font size ${p.fontSizePx}px is below the ${CONSPICUITY_FLOOR.fontSizePx}px floor`);
866
880
  }
867
881
  if (p.fontWeight < CONSPICUITY_FLOOR.fontWeight) {
868
- out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor`);
882
+ const need = MEDIUM_WEIGHT_REQUIREMENTS;
883
+ const medium = p.fontWeight === 500 && p.fontSizePx >= need.fontSizePx && ratio >= need.contrast
884
+ && (p.borderWidthPx ?? 0) >= need.borderWidthPx;
885
+ if (!medium) {
886
+ out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor` +
887
+ (p.fontWeight === 500
888
+ ? ` (500 needs font size >= ${need.fontSizePx}px, contrast >= ${need.contrast}:1 and borderWidthPx >= ${need.borderWidthPx})`
889
+ : ""));
890
+ }
869
891
  }
870
892
  return out;
871
893
  }
@@ -885,6 +907,14 @@ function styleValueProblem(key, value) {
885
907
  return typeof value === "number" && Number.isFinite(value) && value >= CONSPICUITY_FLOOR.fontSizePx && value <= MAX_FONT_SIZE_PX
886
908
  ? null
887
909
  : `\`fontSizePx\` must be a number from ${CONSPICUITY_FLOOR.fontSizePx} to ${MAX_FONT_SIZE_PX}`;
910
+ case "borderWidthPx":
911
+ case "paddingYPx":
912
+ case "letterSpacingPx": {
913
+ const { min, max, integer } = NICOTINE_WARNING_BOX_BOUNDS[key];
914
+ return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value))
915
+ ? null
916
+ : `\`${key}\` must be ${integer ? "a whole number" : "a number"} from ${min} to ${max}`;
917
+ }
888
918
  default:
889
919
  return `\`${key}\` is not a nicotine-warning style key (allowed: ${NICOTINE_WARNING_STYLE_KEYS.join(", ")})`;
890
920
  }
@@ -993,7 +1023,10 @@ export function nicotineWarningCheckWidths(declaration) {
993
1023
  */
994
1024
  export function describeNicotineWarningPresentation(p) {
995
1025
  return `background ${p.background}, text ${p.text}, border ${p.border}, ` +
996
- `${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px`;
1026
+ `${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px` +
1027
+ (p.borderWidthPx === undefined ? "" : `, border width ${p.borderWidthPx}px`) +
1028
+ (p.paddingYPx === undefined ? "" : `, vertical padding ${p.paddingYPx}px`) +
1029
+ (p.letterSpacingPx === undefined ? "" : `, letter spacing ${p.letterSpacingPx}px`);
997
1030
  }
998
1031
 
999
1032
  /**
@@ -1017,11 +1050,12 @@ export function describeNicotineWarningPresentation(p) {
1017
1050
  */
1018
1051
  export function nicotineWarningHtml(declaration, variant) {
1019
1052
  const { presentation, preset } = resolveNicotineWarning(declaration);
1053
+ const barPadding = presentation.paddingYPx === undefined ? "0.5rem" : `${presentation.paddingYPx}px`;
1020
1054
  const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
1021
1055
  `data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
1022
1056
  const text = NICOTINE_WARNING_TEXT_LOCK;
1023
1057
  return variant === "bar"
1024
- ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
1058
+ ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:${barPadding};text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
1025
1059
  : `<div ${attrs}><p style="margin:0;${text}">${NICOTINE_WARNING_TEXT}</p></div>`;
1026
1060
  }
1027
1061
 
@@ -1061,15 +1095,17 @@ const NICOTINE_WARNING_TEXT_LOCK = [
1061
1095
  */
1062
1096
  export function nicotineWarningStyle(p, variant) {
1063
1097
  const size = variant === "block" ? Math.max(p.fontSizePx, 14) : p.fontSizePx;
1098
+ const boxed = `${p.borderWidthPx ?? 2}px`;
1064
1099
  const frame = variant === "bar"
1065
- ? [`border-bottom:2px solid ${p.border}`]
1066
- : [`border:2px solid ${p.border}`, "border-radius:var(--radius-md)", "padding:0.75rem 1rem"];
1100
+ ? [p.borderWidthPx === undefined ? `border-bottom:2px solid ${p.border}` : `border:${boxed} solid ${p.border}`]
1101
+ : [`border:${boxed} solid ${p.border}`, "border-radius:var(--radius-md)",
1102
+ `padding:${p.paddingYPx === undefined ? "0.75rem" : `${p.paddingYPx}px`} 1rem`];
1067
1103
  return [
1068
1104
  ...frame,
1069
1105
  `background-color:${p.background}`,
1070
1106
  `color:${p.text}`,
1071
1107
  `text-transform:${p.textTransform}`,
1072
- `letter-spacing:${p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
1108
+ `letter-spacing:${p.letterSpacingPx !== undefined ? `${p.letterSpacingPx}px` : p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
1073
1109
  `font-weight:${p.fontWeight}`,
1074
1110
  `font-size:${size}px`,
1075
1111
  "line-height:1.4",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
+ "./preview-url": "./src/preview-url.mjs",
13
14
  "./publication-redirects": "./src/publication-redirects.mjs",
14
15
  "./image-dependencies": "./src/image-dependencies.ts",
15
16
  "./image-finding-dependencies": "./src/image-finding-dependencies.ts",
@@ -0,0 +1,10 @@
1
+ export const PREVIEW_URL_SHAPE: string;
2
+ export function previewPath(store: string, subPath?: string): string;
3
+ export function previewUrl(origin: string, store: string): string;
4
+ export function previewPrUrl(origin: string, store: string, prNumber: number | string): string | null;
5
+ export function previewRevUrl(origin: string, store: string, versionId: string): string;
6
+ export function legacyPreviewLocation(
7
+ pathname: string,
8
+ search: string | undefined,
9
+ isStore: (segment: string) => boolean,
10
+ ): string | null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The one builder of preview URLs. A store's preview lives under the store, never under a
3
+ * `/preview/<store>` prefix:
4
+ *
5
+ * <origin>/<store>/preview the store's current shared preview
6
+ * <origin>/<store>/preview/pr/<n> candidate PR n
7
+ * <origin>/<store>/preview/rev/<sha>/ an immutable revision
8
+ *
9
+ * Every producer (Worker, CLI) calls these; `apps/storefront/src/pages/preview/preview-url-routes.test.ts`
10
+ * resolves each shape against the page route table. `packages/cli/src/preview-url.mjs` is a
11
+ * byte-identical copy (a CLI test pins it).
12
+ */
13
+
14
+ /** The canonical shapes, for messages that name the right URL. */
15
+ export const PREVIEW_URL_SHAPE = "/<store>/preview[/pr/<n>]";
16
+
17
+ function trimOrigin(origin) {
18
+ return String(origin).trim().replace(/\/+$/, "");
19
+ }
20
+
21
+ /** `/<store>/preview` with an optional sub-path (`/pr/5`). */
22
+ export function previewPath(store, subPath = "") {
23
+ return `/${encodeURIComponent(store)}/preview${subPath}`;
24
+ }
25
+
26
+ export function previewUrl(origin, store) {
27
+ return `${trimOrigin(origin)}${previewPath(store)}`;
28
+ }
29
+
30
+ /** `null` when `prNumber` is not a positive integer. */
31
+ export function previewPrUrl(origin, store, prNumber) {
32
+ const n = Number(prNumber);
33
+ if (!Number.isInteger(n) || n < 1) return null;
34
+ return `${trimOrigin(origin)}${previewPath(store, `/pr/${n}`)}`;
35
+ }
36
+
37
+ export function previewRevUrl(origin, store, versionId) {
38
+ return `${trimOrigin(origin)}${previewPath(store, `/rev/${encodeURIComponent(versionId)}/`)}`;
39
+ }
40
+
41
+ /**
42
+ * The canonical location for a legacy `/preview/<store>[/pr/<n>[/...]]` request, or `null` when
43
+ * the path is not that shape. `isStore` decides whether the first segment names a store
44
+ * (the caller owns what a store id looks like), so `/preview/pr/5`, `/preview/rev/...` and the
45
+ * other first-segment routes are never read as a store.
46
+ */
47
+ export function legacyPreviewLocation(pathname, search, isStore) {
48
+ const m = /^\/preview\/([^/]+)(\/.*)?$/.exec(pathname);
49
+ if (!m) return null;
50
+ const store = m[1];
51
+ if (!/^[A-Za-z0-9.-]+$/.test(store) || !isStore(store)) return null;
52
+ const rest = (m[2] ?? "").replace(/\/+$/, "");
53
+ return `/${store}/preview${rest}${search ?? ""}`;
54
+ }
@@ -296,6 +296,9 @@ export interface NicotineWarningDeclaration {
296
296
  textTransform?: "uppercase" | "none";
297
297
  fontWeight?: number;
298
298
  fontSizePx?: number;
299
+ borderWidthPx?: number;
300
+ paddingYPx?: number;
301
+ letterSpacingPx?: number;
299
302
  };
300
303
  }
301
304