@tokenoftrust/storefront-runner 2.2.126 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/apps/storefront/package.json +3 -1
  2. package/apps/storefront/public/shared/commerce-chrome.css +57 -15
  3. package/apps/storefront/public/shared/commerce-marketing.css +7 -7
  4. package/apps/storefront/src/components/Badge.astro +1 -1
  5. package/apps/storefront/src/components/Breadcrumbs.astro +8 -3
  6. package/apps/storefront/src/components/Section.astro +4 -1
  7. package/apps/storefront/src/components/Seo.astro +7 -5
  8. package/apps/storefront/src/components/blog/BlogIndexView.astro +22 -13
  9. package/apps/storefront/src/components/blog/ShareRow.astro +43 -0
  10. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +1 -1
  11. package/apps/storefront/src/components/chrome/NavDropdown.astro +7 -7
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +17 -0
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +5 -5
  14. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +1 -1
  15. package/apps/storefront/src/components/compliance/NicotineWarning.astro +12 -22
  16. package/apps/storefront/src/components/compliance/PactActNotice.astro +9 -10
  17. package/apps/storefront/src/components/content/ProseSections.astro +2 -2
  18. package/apps/storefront/src/components/home/Hero.astro +34 -12
  19. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +39 -5
  20. package/apps/storefront/src/components/islands/VariantSelector.tsx +3 -3
  21. package/apps/storefront/src/components/marketing/CardGrid.astro +1 -1
  22. package/apps/storefront/src/components/marketing/CodeSample.astro +1 -1
  23. package/apps/storefront/src/components/marketing/CtaBand.astro +1 -1
  24. package/apps/storefront/src/components/marketing/Faq.astro +1 -1
  25. package/apps/storefront/src/components/marketing/Integrations.astro +1 -1
  26. package/apps/storefront/src/components/marketing/MarketingCtas.astro +1 -1
  27. package/apps/storefront/src/components/marketing/MarketingHero.astro +1 -1
  28. package/apps/storefront/src/components/marketing/ProofStrip.astro +1 -1
  29. package/apps/storefront/src/components/marketing/SplitCompare.astro +1 -1
  30. package/apps/storefront/src/components/marketing/Steps.astro +1 -1
  31. package/apps/storefront/src/components/marketing/Testimonials.astro +2 -2
  32. package/apps/storefront/src/components/marketing/TrustBar.astro +1 -1
  33. package/apps/storefront/src/components/membership/TierComparisonTable.astro +1 -1
  34. package/apps/storefront/src/components/plp/ActiveFilters.astro +1 -1
  35. package/apps/storefront/src/components/plp/FacetSidebar.astro +4 -4
  36. package/apps/storefront/src/components/plp/Pagination.astro +1 -1
  37. package/apps/storefront/src/components/plp/SortSelect.astro +1 -1
  38. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +18 -3
  39. package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -5
  40. package/apps/storefront/src/config/devTenantSeed.ts +23 -34
  41. package/apps/storefront/src/config/resolver.ts +0 -38
  42. package/apps/storefront/src/layouts/Layout.astro +8 -1
  43. package/apps/storefront/src/lib/assets/preview-version-cookie.ts +52 -5
  44. package/apps/storefront/src/lib/blog/pagination.ts +20 -10
  45. package/apps/storefront/src/lib/blog/presentation.ts +94 -0
  46. package/apps/storefront/src/lib/blog/rss.ts +1 -1
  47. package/apps/storefront/src/lib/blog/types.ts +24 -7
  48. package/apps/storefront/src/lib/breadcrumbs.ts +25 -0
  49. package/apps/storefront/src/lib/checkoutCommerce.ts +26 -3
  50. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  51. package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
  52. package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
  53. package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
  54. package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
  55. package/apps/storefront/src/lib/jsonld.ts +5 -5
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
  57. package/apps/storefront/src/lib/rawChrome.ts +74 -12
  58. package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
  59. package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
  60. package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
  63. package/apps/storefront/src/pages/[...slug].astro +13 -8
  64. package/apps/storefront/src/pages/blog/[slug].astro +56 -24
  65. package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
  66. package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
  67. package/apps/storefront/src/pages/blog/index.astro +2 -0
  68. package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
  69. package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
  70. package/apps/storefront/src/pages/collections/[handle].astro +5 -4
  71. package/apps/storefront/src/pages/collections/index.astro +4 -3
  72. package/apps/storefront/src/pages/index.astro +34 -27
  73. package/apps/storefront/src/pages/products/[handle].astro +5 -4
  74. package/apps/storefront/src/pages/saved.astro +4 -3
  75. package/apps/storefront/src/pages/search.astro +4 -3
  76. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
  77. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
  78. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
  79. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
  80. package/apps/storefront/src/pages/style-guide/index.astro +6 -5
  81. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
  82. package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
  83. package/apps/storefront/src/pages/the-build/index.astro +2 -0
  84. package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
  85. package/apps/storefront/src/styles/admin.css +47 -0
  86. package/apps/storefront/src/styles/global.css +104 -56
  87. package/apps/storefront/src/themes/derivedTokens.ts +34 -0
  88. package/apps/storefront/src/themes/schema.ts +6 -0
  89. package/apps/storefront/test/pipeline/harness.ts +164 -0
  90. package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
  91. package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
  92. package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
  93. package/apps/storefront/vitest.workers.config.cts +67 -0
  94. package/docs/widget-library-guide.md +1496 -0
  95. package/package.json +1 -1
  96. package/packages/cli/src/declared-config.mjs +400 -5
  97. package/packages/public-runtime/package.json +1 -0
  98. package/packages/public-runtime/src/checkout.ts +13 -0
  99. package/packages/public-runtime/src/chrome.ts +93 -2
  100. package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
  101. package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
  102. package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
  103. package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
  104. package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
  105. package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
  106. package/packages/public-runtime/src/extension-contract.ts +2 -0
  107. package/packages/public-runtime/src/tenant.ts +48 -4
  108. package/pnpm-lock.runner.yaml +469 -0
  109. package/scripts/tenant/validate.mjs +27 -1
@@ -1,14 +1,16 @@
1
1
  ---
2
2
  /**
3
3
  * PACT Act (Prevent All Cigarette Trafficking Act, extended to ENDS/vapor in
4
- * 2021) compliance notice. The federal requirements it summarizes are fixed and
5
- * authored here — age verification, adult signature on delivery, and no U.S.
6
- * Postal Service carriage; only whether it renders (and the age) comes from
7
- * tenant.compliance.pactAct / .minAge. Renders NOTHING when `enabled` is false,
8
- * so absent data never shows placeholder legal text. Never hand-author this on a
9
- * marketing page.
4
+ * 2021) compliance notice. The federal requirements it summarizes are fixed —
5
+ * age verification, adult signature on delivery, and no U.S. Postal Service
6
+ * carriage — in `pactActNoticeText`, the one copy the raw-HTML path injects too;
7
+ * only whether it renders (and the age) comes from tenant.compliance.pactAct /
8
+ * .minAge. Renders NOTHING when `enabled` is false, so absent data never shows
9
+ * placeholder legal text. Never hand-author this on a marketing page. Layout.astro
10
+ * renders it as a footer band on every page.
10
11
  */
11
12
  import ComplianceNotice from "./ComplianceNotice.astro";
13
+ import { pactActNoticeText } from "@/lib/compliance/rawComplianceNotices";
12
14
 
13
15
  interface Props {
14
16
  /** From tenant.compliance.pactAct. */
@@ -20,10 +22,7 @@ interface Props {
20
22
 
21
23
  const { enabled = false, minAge = 21, variant = "block" } = Astro.props;
22
24
 
23
- const TEXT =
24
- `In compliance with the federal PACT Act, all vapor and nicotine orders require ` +
25
- `age verification (${minAge}+) and an adult signature on delivery. These products ` +
26
- `cannot be shipped through the U.S. Postal Service.`;
25
+ const TEXT = pactActNoticeText(minAge);
27
26
  ---
28
27
 
29
28
  {
@@ -32,11 +32,11 @@ const rendered = withHeadingIds(normalizeProse(sections));
32
32
  switch (s.kind) {
33
33
  case "heading":
34
34
  return (s.level ?? 2) === 3 ? (
35
- <h3 id={s.id} class="mt-6 scroll-mt-24 font-display text-xl font-semibold">
35
+ <h3 id={s.id} class="mt-6 scroll-mt-24 font-display text-[length:var(--heading-h3,1.25rem)] leading-[1.4] font-semibold">
36
36
  {s.text}
37
37
  </h3>
38
38
  ) : (
39
- <h2 id={s.id} class="mt-8 scroll-mt-24 font-display text-2xl font-semibold">
39
+ <h2 id={s.id} class="mt-8 scroll-mt-24 font-display text-[length:var(--heading-h2,1.5rem)] leading-[1.3333] font-semibold">
40
40
  {s.text}
41
41
  </h2>
42
42
  );
@@ -13,6 +13,11 @@
13
13
  * visual-parity migrations where the artwork owns the visible copy.
14
14
  * The feature image is the merchant's own hero image (block.image) when adopted,
15
15
  * else the hero collection's first product.
16
+ *
17
+ * `block.notice` renders a status pill (`.hero__notice`) above the eyebrow in
18
+ * every treatment; the slideshow and banner carry it in a band above the artwork.
19
+ * The eyebrow (`.hero__eyebrow`) reads `--color-hero-eyebrow` first, so a theme
20
+ * recolours it with `color.hero-eyebrow` and tenant CSS can target the hook class.
16
21
  */
17
22
  import type { HeroBlock } from "@/lib/storyblok/content-model";
18
23
  import type { CatalogProduct } from "@tot/public-runtime";
@@ -24,8 +29,11 @@ interface Props {
24
29
  block: HeroBlock;
25
30
  featureProduct?: CatalogProduct | null;
26
31
  instanceKey?: string;
32
+ /** The page's h1 by default; a page that already has its h1 renders the hero headline as h2. */
33
+ headingLevel?: 1 | 2;
27
34
  }
28
- const { block, featureProduct, instanceKey = "home" } = Astro.props;
35
+ const { block, featureProduct, instanceKey = "home", headingLevel = 1 } = Astro.props;
36
+ const Heading = headingLevel === 2 ? "h2" : "h1";
29
37
  const { basePath } = Astro.locals;
30
38
  const heroVariant = Astro.locals.tenant?.theme_tokens?.brand?.heroVariant ?? "bold";
31
39
  const light = heroVariant === "light";
@@ -37,18 +45,25 @@ const slideshowView = heroSlideshowView(block, instanceKey, basePath);
37
45
  const slides = slideshowView.slides;
38
46
  const slideshow = slides.length > 0;
39
47
  const slideId = slideshowView.id;
48
+ const notice = block.notice?.trim();
40
49
  ---
41
50
 
42
51
  {slideshow ? (
43
52
  <section
44
53
  class="hero-slideshow bg-[var(--color-bg)]"
54
+ data-widget="home-hero"
45
55
  aria-roledescription="carousel"
46
56
  aria-label={block.headline}
47
57
  data-hero-slideshow
48
58
  data-autoplay={slideshowView.autoplay ? "true" : "false"}
49
59
  data-interval={slideshowView.intervalMs}
50
60
  >
51
- <h1 class="sr-only">{block.headline}</h1>
61
+ <Heading class="sr-only">{block.headline}</Heading>
62
+ {notice && (
63
+ <div class="container-prose py-3">
64
+ <p class="hero__notice">{notice}</p>
65
+ </div>
66
+ )}
52
67
  <div class="hero-slideshow__slides">
53
68
  {slides.map((slide) => (
54
69
  <Fragment>
@@ -79,8 +94,13 @@ const slideId = slideshowView.id;
79
94
  )}
80
95
  </section>
81
96
  ) : banner ? (
82
- <section class="bg-[var(--color-bg)]">
83
- <h1 class="sr-only">{block.headline}</h1>
97
+ <section class="bg-[var(--color-bg)]" data-widget="home-hero">
98
+ <Heading class="sr-only">{block.headline}</Heading>
99
+ {notice && (
100
+ <div class="container-prose py-3">
101
+ <p class="hero__notice">{notice}</p>
102
+ </div>
103
+ )}
84
104
  {block.ctaHref ? (
85
105
  <a
86
106
  href={withBase(basePath, block.ctaHref)}
@@ -113,6 +133,7 @@ const slideId = slideshowView.id;
113
133
  </section>
114
134
  ) : (
115
135
  <section
136
+ data-widget="home-hero"
116
137
  class:list={[
117
138
  "relative overflow-hidden",
118
139
  light
@@ -127,25 +148,26 @@ const slideId = slideshowView.id;
127
148
  ]}
128
149
  >
129
150
  <div data-reveal>
151
+ {notice && <p class="hero__notice mb-5">{notice}</p>}
130
152
  {
131
153
  block.eyebrow && (
132
154
  <p
133
155
  class:list={[
134
- "eyebrow eyebrow--tick mb-6",
156
+ "hero__eyebrow eyebrow eyebrow--tick mb-6",
135
157
  light
136
- ? "!text-[var(--color-muted)]"
137
- : "!text-[color-mix(in_srgb,var(--color-primary-contrast)_72%,transparent)]",
158
+ ? "text-[var(--color-hero-eyebrow,var(--color-muted))]"
159
+ : "text-[var(--color-hero-eyebrow,color-mix(in_srgb,var(--color-primary-contrast)_72%,transparent))]",
138
160
  ]}
139
161
  >
140
162
  {block.eyebrow}
141
163
  </p>
142
164
  )
143
165
  }
144
- <h1 class="display-hero">
166
+ <Heading class="display-hero">
145
167
  <span class:list={["block", light && "text-[var(--color-primary)]"]}>{block.headline}</span>
146
168
  {
147
169
  block.headlineAccent && (
148
- <span class="relative block w-fit text-[var(--color-accent)]">
170
+ <span class:list={["relative block w-fit", light ? "text-[var(--color-accent-ink)]" : "text-[var(--color-accent)]"]}>
149
171
  {block.headlineAccent}
150
172
  <span
151
173
  aria-hidden="true"
@@ -154,7 +176,7 @@ const slideId = slideshowView.id;
154
176
  </span>
155
177
  )
156
178
  }
157
- </h1>
179
+ </Heading>
158
180
  {
159
181
  block.subhead && (
160
182
  <p
@@ -173,7 +195,7 @@ const slideId = slideshowView.id;
173
195
  block.ctaLabel && block.ctaHref && (
174
196
  <a
175
197
  href={withBase(basePath, block.ctaHref)}
176
- class="group mt-9 inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-7 py-3 font-display text-[0.95rem] font-semibold text-white transition-transform duration-200 hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
198
+ class="group mt-9 inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-7 py-3 font-display text-[0.95rem] font-semibold text-[var(--color-accent-contrast)] transition-transform duration-200 hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
177
199
  >
178
200
  {block.ctaLabel}
179
201
  <span aria-hidden="true" class="transition-transform duration-200 group-hover:translate-x-1">
@@ -243,7 +265,7 @@ const slideId = slideshowView.id;
243
265
  class="absolute bottom-4 left-4 right-4 flex items-center justify-between rounded-[var(--radius-md)] bg-[var(--color-surface)]/95 px-4 py-3 text-[var(--color-text)] backdrop-blur"
244
266
  >
245
267
  <span class="font-display text-sm font-medium">{featureProduct.title}</span>
246
- <span class="eyebrow !text-[var(--color-accent)]">Shop →</span>
268
+ <span class="eyebrow text-[var(--color-accent-ink)]">Shop →</span>
247
269
  </a>
248
270
  </div>
249
271
  )
@@ -48,12 +48,44 @@ function scrollbarGutterWidth(): number {
48
48
  return Number.isFinite(width) && width > 0 ? width : 0;
49
49
  }
50
50
 
51
- /** Make the storefront behind the gate non-interactive without disturbing prior inert state. */
51
+ const MAIN_LANDMARK = 'main, [role="main"]';
52
+
53
+ /**
54
+ * The elements to make inert so everything behind the gate is unreachable while the
55
+ * page's `<main>` landmark itself stays in the accessibility tree. An element that
56
+ * is or contains the main landmark is descended into rather than inerted, so only
57
+ * its contents go inert; every other element is inerted whole. A page with no main
58
+ * landmark inerts each body child. `containers` are the elements descended into (body,
59
+ * main, and main's ancestors): content added to one of them later must be inerted too.
60
+ */
61
+ export function ageGateInertTargets(host: HTMLElement): { targets: HTMLElement[]; containers: Element[] } {
62
+ const targets: HTMLElement[] = [];
63
+ const containers: Element[] = [];
64
+ const visit = (parent: Element) => {
65
+ containers.push(parent);
66
+ for (const child of Array.from(parent.children)) {
67
+ if (!(child instanceof HTMLElement) || child === host || child.hasAttribute("inert")) continue;
68
+ if (child.matches(MAIN_LANDMARK) || child.querySelector(MAIN_LANDMARK)) visit(child);
69
+ else targets.push(child);
70
+ }
71
+ };
72
+ visit(document.body);
73
+ return { targets, containers };
74
+ }
75
+
76
+ /**
77
+ * Make the storefront behind the gate non-interactive without disturbing prior inert
78
+ * state. The gate is an `aria-modal` dialog with a focus trap; inert takes keyboard,
79
+ * pointer, and assistive-technology reach away from everything else on the page.
80
+ */
52
81
  export function isolateAgeGateBackground(host: HTMLElement): BackgroundIsolation {
53
82
  const isolated = new Set<HTMLElement>();
54
83
  const previousOverflow = document.documentElement.style.overflow;
55
84
  const previousPaddingRight = document.documentElement.style.paddingRight;
56
85
  let active = true;
86
+ // Watches the containers kept out of inert, so a child appended to one later is
87
+ // inerted as well.
88
+ const containerObserver = new MutationObserver(() => refresh());
57
89
 
58
90
  const refresh = () => {
59
91
  if (!active) return;
@@ -64,15 +96,17 @@ export function isolateAgeGateBackground(host: HTMLElement): BackgroundIsolation
64
96
  document.documentElement.style.paddingRight = `${gutter}px`;
65
97
  }
66
98
  document.documentElement.style.overflow = "hidden";
67
- for (const sibling of Array.from(document.body.children)) {
68
- if (!(sibling instanceof HTMLElement) || sibling === host || sibling.hasAttribute("inert")) continue;
69
- sibling.setAttribute("inert", "");
70
- isolated.add(sibling);
99
+ const { targets, containers } = ageGateInertTargets(host);
100
+ for (const target of targets) {
101
+ target.setAttribute("inert", "");
102
+ isolated.add(target);
71
103
  }
104
+ for (const container of containers) containerObserver.observe(container, { childList: true });
72
105
  };
73
106
  const release = () => {
74
107
  if (!active) return;
75
108
  active = false;
109
+ containerObserver.disconnect();
76
110
  for (const sibling of isolated) sibling.removeAttribute("inert");
77
111
  isolated.clear();
78
112
  document.documentElement.style.overflow = previousOverflow;
@@ -227,7 +227,7 @@ export default function VariantSelector({
227
227
  stockClass = "text-sale";
228
228
  } else if (qty !== undefined && qty !== null && qty <= LOW_STOCK_THRESHOLD) {
229
229
  stockLabel = `Only ${qty} left`;
230
- stockClass = "text-accent";
230
+ stockClass = "text-accent-ink";
231
231
  } else {
232
232
  stockLabel = "In stock";
233
233
  stockClass = "text-primary";
@@ -300,7 +300,7 @@ export default function VariantSelector({
300
300
  {memberApplied && (
301
301
  <span
302
302
  data-member-price
303
- class="rounded-full bg-accent px-2 py-0.5 font-mono text-[0.68rem] uppercase tracking-[0.08em] text-primary-contrast"
303
+ class="rounded-full bg-accent px-2 py-0.5 font-mono text-[0.68rem] uppercase tracking-[0.08em] text-accent-contrast"
304
304
  >
305
305
  {pricing?.membershipRule.label ?? "Member"} price
306
306
  </span>
@@ -403,7 +403,7 @@ export default function VariantSelector({
403
403
  {option.label}
404
404
  </span>
405
405
  {"savingsLabel" in option && option.savingsLabel && (
406
- <span class="rounded-full bg-accent px-2 py-0.5 font-mono text-[0.68rem] uppercase tracking-[0.08em] text-primary-contrast">
406
+ <span class="rounded-full bg-accent px-2 py-0.5 font-mono text-[0.68rem] uppercase tracking-[0.08em] text-accent-contrast">
407
407
  {option.savingsLabel}
408
408
  </span>
409
409
  )}
@@ -21,7 +21,7 @@ const cols =
21
21
  const isExternal = (href: string) => /^https?:\/\//i.test(href);
22
22
  const hrefOf = (href: string) => (isExternal(href) ? href : withBase(basePath, href));
23
23
  ---
24
- <Section id={block.anchor} eyebrow={block.eyebrow} title={block.title} tone="bg">
24
+ <Section id={block.anchor} eyebrow={block.eyebrow} title={block.title} tone="bg" widget="marketing-card-grid">
25
25
  {block.subhead && (
26
26
  <p class="-mt-4 mb-9 max-w-[60ch] text-[var(--color-muted)]" data-reveal>
27
27
  {block.subhead}
@@ -11,7 +11,7 @@ interface Props {
11
11
  }
12
12
  const { sample } = Astro.props;
13
13
  ---
14
- <figure class="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-text)]">
14
+ <figure data-widget="marketing-code-sample" class="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-text)]">
15
15
  <figcaption class="flex items-center justify-between border-b border-white/10 px-4 py-2 font-mono text-[0.68rem] uppercase tracking-[0.12em] text-white/60">
16
16
  <span>{sample.caption ?? "Example"}</span>
17
17
  {sample.language && <span>{sample.language}</span>}
@@ -11,7 +11,7 @@ interface Props {
11
11
  }
12
12
  const { block, basePath } = Astro.props;
13
13
  ---
14
- <section class="bg-[var(--color-surface)] py-[clamp(3.5rem,8vw,6.5rem)]">
14
+ <section data-widget="marketing-cta-band" class="bg-[var(--color-surface)] py-[clamp(3.5rem,8vw,6.5rem)]">
15
15
  <div class="container-prose">
16
16
  <div class="mx-auto max-w-2xl text-center" data-reveal>
17
17
  <h2 class="display-lg">{block.title}</h2>
@@ -23,7 +23,7 @@ const faqLd = {
23
23
  };
24
24
  const faqJsonLd = safeJsonScriptContent(faqLd);
25
25
  ---
26
- <Section eyebrow={block.eyebrow} title={block.title} tone="surface">
26
+ <Section eyebrow={block.eyebrow} title={block.title} tone="surface" widget="marketing-faq">
27
27
  {block.lede && (
28
28
  <p class="mb-8 max-w-[42rem] text-[var(--color-muted)]" data-reveal>{block.lede}</p>
29
29
  )}
@@ -16,7 +16,7 @@ interface Props {
16
16
  const { block, basePath } = Astro.props;
17
17
  const isExternal = (href: string) => /^https?:\/\//i.test(href);
18
18
  ---
19
- <Section eyebrow={block.eyebrow} title={block.title} tone="surface">
19
+ <Section eyebrow={block.eyebrow} title={block.title} tone="surface" widget="marketing-integrations">
20
20
  {block.subhead && (
21
21
  <p class="-mt-4 mb-9 max-w-[60ch] text-[var(--color-muted)]" data-reveal>
22
22
  {block.subhead}
@@ -18,7 +18,7 @@ const { ctas = [], basePath, class: className } = Astro.props;
18
18
 
19
19
  const styles: Record<NonNullable<MarketingCta["variant"]>, string> = {
20
20
  primary:
21
- "rounded-full bg-accent px-6 py-3 font-display text-sm font-semibold text-[var(--color-primary-contrast)] shadow-[var(--shadow-sm)] transition-transform hover:-translate-y-0.5",
21
+ "rounded-full bg-accent px-6 py-3 font-display text-sm font-semibold text-accent-contrast shadow-[var(--shadow-sm)] transition-transform hover:-translate-y-0.5",
22
22
  secondary:
23
23
  "rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 font-display text-sm font-semibold text-[var(--color-text)] transition-colors hover:border-[var(--color-primary)]",
24
24
  ghost:
@@ -16,7 +16,7 @@ const { block, basePath } = Astro.props;
16
16
  const hasPanel = !!block.panel?.lines?.length;
17
17
  ---
18
18
 
19
- <section class="bg-[var(--color-bg)] py-[clamp(3.5rem,8vw,7rem)]">
19
+ <section data-widget="marketing-hero" class="bg-[var(--color-bg)] py-[clamp(3.5rem,8vw,7rem)]">
20
20
  <div class="container-prose">
21
21
  <div
22
22
  class:list={[
@@ -10,7 +10,7 @@ interface Props {
10
10
  }
11
11
  const { block } = Astro.props;
12
12
  ---
13
- <section class="bg-[var(--color-primary)] py-[clamp(3rem,7vw,6rem)] text-[var(--color-primary-contrast)]">
13
+ <section data-widget="marketing-proof-strip" class="bg-[var(--color-primary)] py-[clamp(3rem,7vw,6rem)] text-[var(--color-primary-contrast)]">
14
14
  <div class="container-prose">
15
15
  <div class="mx-auto max-w-3xl text-center" data-reveal>
16
16
  {block.eyebrow && (
@@ -11,7 +11,7 @@ interface Props {
11
11
  }
12
12
  const { block } = Astro.props;
13
13
  ---
14
- <Section eyebrow={block.eyebrow} title={block.title} tone="bg">
14
+ <Section eyebrow={block.eyebrow} title={block.title} tone="bg" widget="marketing-split-compare">
15
15
  {block.subhead && (
16
16
  <p class="-mt-4 mb-9 max-w-[60ch] text-[var(--color-muted)]" data-reveal>
17
17
  {block.subhead}
@@ -10,7 +10,7 @@ interface Props {
10
10
  }
11
11
  const { block } = Astro.props;
12
12
  ---
13
- <Section eyebrow={block.eyebrow} title={block.title} tone="surface">
13
+ <Section eyebrow={block.eyebrow} title={block.title} tone="surface" widget="marketing-steps">
14
14
  {block.subhead && (
15
15
  <p class="-mt-4 mb-9 max-w-[60ch] text-[var(--color-muted)]" data-reveal>
16
16
  {block.subhead}
@@ -10,12 +10,12 @@ interface Props {
10
10
  }
11
11
  const { block } = Astro.props;
12
12
  ---
13
- <Section eyebrow={block.eyebrow} title={block.title} tone="bg">
13
+ <Section eyebrow={block.eyebrow} title={block.title} tone="bg" widget="marketing-testimonials">
14
14
  <div class="grid gap-5 md:grid-cols-3" data-reveal>
15
15
  {block.quotes.map((q) => (
16
16
  <figure class="flex flex-col rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
17
17
  {q.stat && (
18
- <p class="font-display text-2xl font-bold text-[var(--color-accent)]">{q.stat}</p>
18
+ <p class="font-display text-2xl font-bold text-[var(--color-accent-ink)]">{q.stat}</p>
19
19
  )}
20
20
  <blockquote class="mt-3 flex-1 text-[var(--color-text)]">
21
21
  <p>“{q.quote}”</p>
@@ -9,7 +9,7 @@ interface Props {
9
9
  }
10
10
  const { block } = Astro.props;
11
11
  ---
12
- <div class="border-y border-[var(--color-border)] bg-[var(--color-surface)]">
12
+ <div data-widget="marketing-trust-bar" class="border-y border-[var(--color-border)] bg-[var(--color-surface)]">
13
13
  <div class="container-prose py-4">
14
14
  <ul class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-center font-mono text-[0.72rem] uppercase tracking-[0.1em] text-[var(--color-muted)]">
15
15
  {block.items.map((item) => (
@@ -64,7 +64,7 @@ const anyIllustrative = tiers.some((t) => t.figure && isIllustrative(t));
64
64
  </caption>
65
65
  <thead>
66
66
  <tr>
67
- <th scope="col" class="w-1/3 p-3 text-left align-bottom" />
67
+ <th scope="col" class="w-1/3 p-3 text-left align-bottom"><span class="sr-only">Feature</span></th>
68
68
  {tiers.map((tier) => {
69
69
  const illustrative = Boolean(tier.figure) && isIllustrative(tier);
70
70
  return (
@@ -47,7 +47,7 @@ const clearHref =
47
47
 
48
48
  {
49
49
  chips.length > 0 && (
50
- <div class="mb-5 flex flex-wrap items-center gap-2" data-active-filters>
50
+ <div class="mb-5 flex flex-wrap items-center gap-2" data-active-filters data-widget="plp-active-filters">
51
51
  <span class="sr-only">Active filters:</span>
52
52
  {chips.map((chip) => (
53
53
  <a
@@ -40,9 +40,9 @@ const clearHref =
40
40
  buildQueryString({ sort: query.sort, q: query.q, pageSize: query.pageSize });
41
41
  ---
42
42
 
43
- <aside aria-label="Filters" class="text-sm">
43
+ <aside aria-label="Filters" data-widget="plp-facet-sidebar" class="text-sm">
44
44
  <div class="mb-4 flex items-center justify-between">
45
- <h2 class="eyebrow !text-[var(--color-text)]">Filter</h2>
45
+ <h2 class="eyebrow text-[var(--color-text)]">Filter</h2>
46
46
  {hasActiveFilters && (
47
47
  <a href={clearHref} class="text-xs text-[var(--color-muted)] underline underline-offset-2 hover:text-[var(--color-accent-ink)]">
48
48
  Clear all
@@ -93,7 +93,7 @@ const clearHref =
93
93
  </span>
94
94
  {fv.value === "all" ? "All items" : fv.value}
95
95
  </span>
96
- <span class:list={["text-xs", isSel ? "opacity-80" : "text-[var(--color-muted)]"]}>
96
+ <span class:list={["text-xs", !isSel && "text-[var(--color-muted)]"]}>
97
97
  {fv.count}
98
98
  </span>
99
99
  </a>
@@ -145,7 +145,7 @@ const clearHref =
145
145
  </span>
146
146
  {bucket.label}
147
147
  </span>
148
- <span class:list={["text-xs", isSel ? "opacity-80" : "text-[var(--color-muted)]"]}>
148
+ <span class:list={["text-xs", !isSel && "text-[var(--color-muted)]"]}>
149
149
  {fv.count}
150
150
  </span>
151
151
  </a>
@@ -33,7 +33,7 @@ const hasNext = page < pageCount;
33
33
  ---
34
34
 
35
35
  {pageCount > 1 && (
36
- <nav aria-label="Pagination" class="mt-12 flex flex-col items-center gap-6">
36
+ <nav aria-label="Pagination" data-widget="plp-pagination" class="mt-12 flex flex-col items-center gap-6">
37
37
  {hasNext && (
38
38
  <a
39
39
  href={href(page + 1)}
@@ -34,7 +34,7 @@ const OPTIONS: { value: NonNullable<ListProductsQuery["sort"]>; label: string }[
34
34
  const current = query.sort ?? "featured";
35
35
  ---
36
36
 
37
- <form method="get" action={basePath} class="flex items-center gap-2 text-sm">
37
+ <form method="get" action={basePath} data-widget="plp-sort-select" class="flex items-center gap-2 text-sm">
38
38
  {/* Preserve filters/price/availability/q across a sort change. */}
39
39
  {query.q && <input type="hidden" name="q" value={query.q} />}
40
40
  {query.availability && query.availability !== "all" && (
@@ -2,9 +2,11 @@
2
2
  /**
3
3
  * Persistent left-hand navigation for the preview/dev style-guide surfaces.
4
4
  *
5
- * Markup-only (no <style>): the host page owns the `.sg-nav*` styles inside its
6
- * nonce'd <style> block, so this component stays CSP-clean under the strict
7
- * inline-style policy (build.inlineStylesheets:"never"). Rendered on the tenant
5
+ * Owns its `.sg-nav*` styles: a page's own <style> is scoped to the page's
6
+ * markup and never reaches this component's elements. Like every component
7
+ * style, the build emits them as an external sheet (inlineStylesheets:"never"),
8
+ * so the rail stays CSP-clean. It sits on the dark guide rail and meets the bar
9
+ * the guides enforce: ≥14px text and ≥44px targets. Rendered on the tenant
8
10
  * hub, the widget-library guide, and any per-theme guide that wants a persistent
9
11
  * rail so the Widget Library Guide is one click away from the top page.
10
12
  *
@@ -93,3 +95,16 @@ const toc = headings.filter((h) => h.depth === 2);
93
95
  )}
94
96
  </ul>
95
97
  </nav>
98
+
99
+ <style>
100
+ .sg-nav__eyebrow { font: 800 max(.875rem, 14px)/1.2 -apple-system, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #7fe3d4; margin: 0 0 12px; padding: 0 10px; }
101
+ .sg-nav__list { list-style: none; margin: 0; padding: 0; }
102
+ .sg-nav__list li { margin: 0; }
103
+ .sg-nav__link { display: flex; align-items: center; min-height: 44px; padding: 8px 10px; border-radius: 8px; color: #bfe0ff; text-decoration: none; font-size: max(.92rem, 14px); }
104
+ .sg-nav__link:hover { background: #16223c; }
105
+ .sg-nav__link.is-active { background: #16223c; color: #fff; font-weight: 700; }
106
+ .sg-nav__link--sub { padding-left: 20px; }
107
+ .sg-nav__toc { list-style: none; margin: 2px 0 8px 16px; padding: 0; border-left: 1px solid #26365a; }
108
+ .sg-nav__toc-link { display: flex; align-items: center; min-height: 44px; padding: 4px 10px 4px 14px; color: #9fb0c8; font-size: max(.82rem, 14px); text-decoration: none; line-height: 1.3; }
109
+ .sg-nav__toc-link:hover { color: #e6edf6; }
110
+ </style>
@@ -8,18 +8,20 @@ import Layout from "@/layouts/Layout.astro";
8
8
  import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
9
9
  import type { BlogPostSummary } from "@/lib/blog/types.js";
10
10
  import type { TheBuildIndexCopy } from "@/lib/the-build/pagination.js";
11
+ import type { BlogPresentation } from "@/lib/blog/presentation.js";
11
12
  import { theBuildPagePath } from "@/lib/the-build/pagination.js";
12
13
 
13
14
  interface Props {
14
15
  basePath: string;
15
16
  canonicalBase: string;
16
17
  copy: TheBuildIndexCopy;
18
+ presentation: BlogPresentation;
17
19
  pagePosts: BlogPostSummary[];
18
20
  page: number;
19
21
  totalPages: number;
20
22
  }
21
23
 
22
- const { basePath, canonicalBase, copy, pagePosts, page, totalPages } = Astro.props;
24
+ const { basePath, canonicalBase, copy, presentation, pagePosts, page, totalPages } = Astro.props;
23
25
 
24
26
  const href = (path: string) => `${basePath}${path}` || "/";
25
27
  const path = theBuildPagePath(page);
@@ -52,11 +54,11 @@ const itemList = {
52
54
  jsonLd={itemList}
53
55
  pagination={pagination}
54
56
  >
55
- <header class="blog-hero">
57
+ <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
56
58
  <div class="blog-shell">
57
- <p class="eyebrow eyebrow--tick">{copy.eyebrow}</p>
59
+ {copy.eyebrow && <p class="eyebrow eyebrow--tick">{copy.eyebrow}</p>}
58
60
  <h1>{copy.title}</h1>
59
- <p>{copy.tagline}</p>
61
+ {copy.tagline && <p class="blog-hero__tagline">{copy.tagline}</p>}
60
62
  </div>
61
63
  </header>
62
64
 
@@ -150,8 +152,10 @@ const itemList = {
150
152
  .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
151
153
  .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
152
154
  .blog-hero h1 { max-width: 12ch; margin-top: .75rem; color: inherit; font-size: clamp(2.9rem, 7vw, 5.6rem); }
153
- .blog-hero p:last-child { max-width: 58ch; margin-top: 1.25rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.12rem; }
155
+ .blog-hero__tagline { max-width: 58ch; margin-top: 1.25rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.12rem; }
154
156
  .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
157
+ .blog-hero--plain { padding: clamp(2rem, 5vw, 3.5rem) 0 0; background: none; color: inherit; }
158
+ .blog-hero--plain .blog-hero__tagline, .blog-hero--plain .eyebrow { color: var(--color-muted); }
155
159
  .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; padding-block: clamp(3rem, 7vw, 6rem); }
156
160
  .post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
157
161
  .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }