@tokenoftrust/storefront-runner 2.2.126 → 2.3.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.
Files changed (107) 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 +20 -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/chrome/model.ts +4 -0
  50. package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
  51. package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
  52. package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
  53. package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
  54. package/apps/storefront/src/lib/jsonld.ts +5 -5
  55. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
  56. package/apps/storefront/src/lib/rawChrome.ts +74 -12
  57. package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
  58. package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
  59. package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
  60. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
  61. package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
  62. package/apps/storefront/src/pages/[...slug].astro +13 -8
  63. package/apps/storefront/src/pages/blog/[slug].astro +56 -24
  64. package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
  65. package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
  66. package/apps/storefront/src/pages/blog/index.astro +2 -0
  67. package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
  68. package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
  69. package/apps/storefront/src/pages/collections/[handle].astro +5 -4
  70. package/apps/storefront/src/pages/collections/index.astro +4 -3
  71. package/apps/storefront/src/pages/index.astro +34 -27
  72. package/apps/storefront/src/pages/products/[handle].astro +5 -4
  73. package/apps/storefront/src/pages/saved.astro +4 -3
  74. package/apps/storefront/src/pages/search.astro +4 -3
  75. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
  76. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
  77. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
  78. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
  79. package/apps/storefront/src/pages/style-guide/index.astro +6 -5
  80. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
  81. package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
  82. package/apps/storefront/src/pages/the-build/index.astro +2 -0
  83. package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
  84. package/apps/storefront/src/styles/admin.css +47 -0
  85. package/apps/storefront/src/styles/global.css +104 -56
  86. package/apps/storefront/src/themes/derivedTokens.ts +34 -0
  87. package/apps/storefront/src/themes/schema.ts +6 -0
  88. package/apps/storefront/test/pipeline/harness.ts +164 -0
  89. package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
  90. package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
  91. package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
  92. package/apps/storefront/vitest.workers.config.cts +67 -0
  93. package/docs/widget-library-guide.md +1496 -0
  94. package/package.json +1 -1
  95. package/packages/cli/src/declared-config.mjs +400 -5
  96. package/packages/public-runtime/package.json +1 -0
  97. package/packages/public-runtime/src/chrome.ts +93 -2
  98. package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
  99. package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
  100. package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
  101. package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
  102. package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
  103. package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
  104. package/packages/public-runtime/src/extension-contract.ts +2 -0
  105. package/packages/public-runtime/src/tenant.ts +48 -4
  106. package/pnpm-lock.runner.yaml +469 -0
  107. package/scripts/tenant/validate.mjs +27 -1
@@ -30,6 +30,7 @@ const cspNonce = Astro.locals.cspNonce;
30
30
  const favicon = Astro.locals.tenant.theme_tokens.brand?.favicon ?? "/favicon.svg";
31
31
 
32
32
  import { loadStyleGuideThemes } from "@/lib/styleGuideThemes";
33
+ import { derivedTokenDeclarations } from "@/themes/derivedTokens";
33
34
 
34
35
  const t = (await loadStyleGuideThemes(
35
36
  tenant ?? "",
@@ -62,9 +63,10 @@ const componentVars: Record<string, string> = {
62
63
  ...REFERENCE_COMPONENT_VARS,
63
64
  ...((t.preview?.componentVars ?? {}) as Record<string, string>),
64
65
  };
65
- const componentVarsInline = Object.entries(componentVars)
66
- .map(([k, v]) => `${k}:${v}`)
67
- .join(";");
66
+ const componentVarsInline = [
67
+ ...Object.entries(componentVars).map(([k, v]) => `${k}:${v}`),
68
+ ...derivedTokenDeclarations(componentVars),
69
+ ].join(";");
68
70
  ---
69
71
 
70
72
  <!doctype html>
@@ -88,34 +90,36 @@ const componentVarsInline = Object.entries(componentVars)
88
90
  .sg-bar { position: sticky; top: 0; z-index: 500; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: #0f1729; color: #e6edf6; padding: 12px 20px; font: 600 .95rem -apple-system, sans-serif; }
89
91
  .sg-bar .flag { font-size: .78rem; letter-spacing: .1em; text-transform: uppercase; background: #f59e0b; color: #221600; font-weight: 800; padding: 4px 8px; border-radius: 5px; }
90
92
  .sg-bar .role { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; background: #0e9f8e; color: #00201b; font-weight: 800; padding: 4px 8px; border-radius: 999px; }
91
- .sg-bar a { color: #bfe0ff; }
93
+ .sg-bar a { color: #bfe0ff; display: inline-flex; align-items: center; min-height: 44px; }
92
94
  .sg-bar a.right { margin-left: auto; }
93
95
  .sg-page { background: var(--color-bg); color: var(--color-text); min-height: 100vh; padding-bottom: 40px; }
94
- .sg-lbl { max-width: var(--container-max, 1240px); margin: 26px auto 6px; padding: 0 20px; font: 800 .78rem/1.3 -apple-system, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: var(--color-primary); }
96
+ .sg-lbl { max-width: var(--container-max, 1240px); margin: 26px auto 6px; padding: 0 20px; font: 800 max(.875rem, 14px)/1.3 -apple-system, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: var(--color-primary); }
95
97
  .sg-frame { max-width: var(--container-max, 1240px); margin: 0 auto 6px; padding: 0 20px; }
96
98
  .sg-frame > * { border: 1px dashed var(--color-border); border-radius: 8px; }
99
+ /* Live pages carry a border-box reset (global.css or the theme sheet); the frames match it. */
100
+ .sg-frame, .sg-frame * { box-sizing: border-box; }
97
101
  /* Show the demoed mega panel + age gate open, in normal flow, for review. */
98
102
  .sg-open .mega { position: static; opacity: 1; visibility: visible; transform: none; box-shadow: var(--shadow-md); margin-top: 6px; }
99
103
  .sg-agegate-frame .age-gate { position: static; display: flex; inset: auto; background: color-mix(in srgb, var(--color-text) 10%, transparent); border-radius: 8px; }
100
- .sg-note { max-width: var(--container-max, 1240px); margin: 8px auto 0; padding: 16px 20px 0; border-top: 1px solid var(--color-border); font-size: .82rem; color: var(--color-muted); }
104
+ .sg-note { max-width: var(--container-max, 1240px); margin: 8px auto 0; padding: 16px 20px 0; border-top: 1px solid var(--color-border); font-size: max(.875rem, 14px); color: var(--color-muted); }
101
105
  </style>
102
106
  </head>
103
107
  <body>
104
- <!-- A toolbar, not page content — a real <header> here would duplicate
108
+ <!-- A labelled nav, not a <header>: a real <header> here would duplicate
105
109
  the demoed .site-header's own banner landmark (axe
106
110
  landmark-no-duplicate-banner/landmark-unique). -->
107
- <div class="sg-bar">
111
+ <nav class="sg-bar" aria-label="Style guide toolbar">
108
112
  <span class="flag">Preview · shared chrome · noindex</span>
109
113
  <span class="role">{t.role}</span>
110
114
  <b>{t.name}</b>
111
115
  <a class="right" href={`/_style-guide/${tenant}/guide/${t.id}/`}>widget library guide →</a>
112
116
  <a href={`/_style-guide/${tenant}/${t.id}/`}>← component guide</a>
113
- </div>
117
+ </nav>
114
118
 
115
119
  <main class="sg-page" style={componentVarsInline}>
116
120
  <h1 class="sg-lbl">Shared chrome preview — {t.name}</h1>
117
121
  <p class="sg-lbl">Announcement + top utility bar</p>
118
- <div class="sg-frame">
122
+ <div class="sg-frame" data-tot-platform-sample>
119
123
  <div>
120
124
  <div class="topbar-warning"><div class="container"><p>WARNING: This product contains nicotine. Nicotine is an addictive chemical.</p></div></div>
121
125
  <div class="announce"><div class="container">Free US shipping on orders over $100</div></div>
@@ -129,7 +133,7 @@ const componentVarsInline = Object.entries(componentVars)
129
133
  </div>
130
134
 
131
135
  <p class="sg-lbl">Header — brand · search · actions · primary nav + mega panel</p>
132
- <div class="sg-frame">
136
+ <div class="sg-frame" data-tot-platform-sample>
133
137
  <header class="site-header sg-open">
134
138
  <div class="container header-row">
135
139
  <a class="brand" href="#"><span class="b-prime">Prime</span><span class="b-vapor">Vapor</span><span class="b-club">Club</span></a>
@@ -161,7 +165,7 @@ const componentVarsInline = Object.entries(componentVars)
161
165
  </div>
162
166
 
163
167
  <p class="sg-lbl">Brand tiles + category cards</p>
164
- <div class="sg-frame">
168
+ <div class="sg-frame" data-tot-platform-sample>
165
169
  <section class="section container">
166
170
  <div class="sec-head"><span class="eyebrow">Shop by</span><h2>Top brands</h2></div>
167
171
  <div class="tile-grid">
@@ -179,12 +183,12 @@ const componentVarsInline = Object.entries(componentVars)
179
183
  </div>
180
184
 
181
185
  <p class="sg-lbl">Compliance strip</p>
182
- <div class="sg-frame">
186
+ <div class="sg-frame" data-tot-platform-sample>
183
187
  <div class="compliance-strip"><div class="container"><p>Must be 21+ to purchase. Products intended for adults of legal smoking age only.</p></div></div>
184
188
  </div>
185
189
 
186
190
  <p class="sg-lbl">Age gate (21+ interstitial) — shown open for review</p>
187
- <div class="sg-frame sg-agegate-frame">
191
+ <div class="sg-frame sg-agegate-frame" data-tot-platform-sample>
188
192
  <div class="age-gate is-open">
189
193
  <div class="age-gate__panel">
190
194
  <p class="age-gate__mark"><span class="is-red">Prime</span>VaporClub</p>
@@ -200,7 +204,7 @@ const componentVarsInline = Object.entries(componentVars)
200
204
  </div>
201
205
 
202
206
  <p class="sg-lbl">Footer — columns · newsletter · payments · trust marks</p>
203
- <div class="sg-frame">
207
+ <div class="sg-frame" data-tot-platform-sample>
204
208
  <footer class="site-footer">
205
209
  <div class="container">
206
210
  <div class="footer-grid">
@@ -21,6 +21,7 @@ import "@/styles/global.css";
21
21
  import type { MarkdownInstance } from "astro";
22
22
  import StyleGuideNav from "@/components/style-guide/StyleGuideNav.astro";
23
23
  import { loadStyleGuideThemes } from "@/lib/styleGuideThemes";
24
+ import { derivedTokenDeclarations } from "@/themes/derivedTokens";
24
25
 
25
26
  const { tenant, theme } = Astro.params;
26
27
  const cspNonce = Astro.locals.cspNonce;
@@ -53,7 +54,7 @@ const REFERENCE_COMPONENT_VARS: Record<string, string> = {
53
54
  "--color-bg": "#f4f1ea", "--color-surface": "#fcfaf5", "--color-surface-2": "#ece7db",
54
55
  "--color-text": "#1c2624", "--color-muted": "#5c6663",
55
56
  "--color-primary": "#1f3a34", "--color-primary-contrast": "#f4f1ea",
56
- "--color-accent": "#b5612f", "--color-accent-ink": "#8a481f", "--color-border": "#dcd6c9",
57
+ "--color-accent": "#b5612f", "--color-border": "#dcd6c9",
57
58
  "--color-success": "#3f6b4f", "--color-sale": "#9e3b2e",
58
59
  "--radius-sm": "3px", "--radius-md": "6px", "--radius-lg": "12px", "--radius-full": "999px",
59
60
  "--shadow-sm": "0 1px 2px rgba(28,38,36,.06)", "--shadow-md": "0 6px 24px -8px rgba(28,38,36,.14)",
@@ -63,9 +64,10 @@ const componentVars: Record<string, string> = {
63
64
  ...REFERENCE_COMPONENT_VARS,
64
65
  ...((t.preview?.componentVars ?? {}) as Record<string, string>),
65
66
  };
66
- const componentVarsInline = Object.entries(componentVars)
67
- .map(([k, v]) => `${k}:${v}`)
68
- .join(";");
67
+ const componentVarsInline = [
68
+ ...Object.entries(componentVars).map(([k, v]) => `${k}:${v}`),
69
+ ...derivedTokenDeclarations(componentVars),
70
+ ].join(";");
69
71
  ---
70
72
 
71
73
  <!doctype html>
@@ -91,35 +93,29 @@ const componentVarsInline = Object.entries(componentVars)
91
93
  .sg-shell { display: grid; grid-template-columns: 264px minmax(0, 1fr); align-items: start; }
92
94
  .sg-side { position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto; background: #0f1729; color: #e6edf6; padding: 22px 14px 40px; }
93
95
 
94
- /* Left-nav (StyleGuideNav markup) — styled here so the component stays CSP-clean. */
95
- .sg-nav__eyebrow { font: 800 .72rem/1.2 -apple-system, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #7fe3d4; margin: 0 0 12px; padding: 0 10px; }
96
- .sg-nav__list { list-style: none; margin: 0; padding: 0; }
97
- .sg-nav__list li { margin: 0; }
98
- .sg-nav__link { display: block; padding: 9px 10px; border-radius: 8px; color: #bfe0ff; text-decoration: none; font-size: .92rem; min-height: 40px; }
99
- .sg-nav__link:hover { background: #16223c; }
100
- .sg-nav__link.is-active { background: #16223c; color: #fff; font-weight: 700; }
101
- .sg-nav__link--sub { padding-left: 20px; font-size: .88rem; }
102
- .sg-nav__toc { list-style: none; margin: 2px 0 8px; padding: 0; border-left: 1px solid #26365a; margin-left: 16px; }
103
- .sg-nav__toc-link { display: block; padding: 5px 10px 5px 14px; color: #9fb0c8; font-size: .82rem; text-decoration: none; line-height: 1.3; }
104
- .sg-nav__toc-link:hover { color: #e6edf6; }
105
96
 
106
97
  /* Themed prose column. .rte styles come from global.css; token vars are
107
- supplied by componentVarsInline on this wrapper so they always resolve. */
98
+ supplied by componentVarsInline on this wrapper so they always resolve.
99
+ The markdown <Content /> renders outside this page's style scope, so
100
+ every rule reaching into it is :global(). */
108
101
  .sg-content { background: var(--color-bg); color: var(--color-text); min-height: 100vh; padding: 34px clamp(20px, 4vw, 56px) 96px; }
109
102
  .sg-content .rte { max-width: 900px; }
110
- .sg-content h1 { font-size: clamp(1.9rem, 4vw, 2.7rem); line-height: 1.1; margin: 0 0 10px; color: var(--color-text); }
111
- .sg-content .rte h2 { border-top: 1px solid var(--color-border); padding-top: 1.4em; scroll-margin-top: 68px; }
112
- .sg-content .rte h3 { scroll-margin-top: 68px; }
113
- .sg-content blockquote { border-left: 3px solid var(--color-accent); margin: 1em 0; padding: 2px 0 2px 16px; color: var(--color-muted); }
114
- .sg-content pre { background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 14px 16px; overflow-x: auto; font-family: var(--font-mono, ui-monospace, Menlo, monospace); font-size: .84rem; line-height: 1.5; }
115
- .sg-content pre code { background: none; padding: 0; }
116
- .sg-content code { font-family: var(--font-mono, ui-monospace, Menlo, monospace); font-size: .88em; }
117
- .sg-content :not(pre) > code { background: var(--color-surface-2); padding: 1px 5px; border-radius: 4px; }
118
- .sg-content table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
119
- .sg-content th, .sg-content td { border: 1px solid var(--color-border); padding: 6px 10px; text-align: left; font-size: .9rem; }
103
+ .sg-content :global(h1) { font-size: clamp(1.9rem, 4vw, 2.7rem); line-height: 1.1; margin: 0 0 10px; color: var(--color-text); }
104
+ .sg-content .rte :global(h2) { border-top: 1px solid var(--color-border); padding-top: 1.4em; scroll-margin-top: 68px; }
105
+ .sg-content .rte :global(h3) { scroll-margin-top: 68px; }
106
+ .sg-content :global(blockquote) { border-left: 3px solid var(--color-accent); margin: 1em 0; padding: 2px 0 2px 16px; color: var(--color-muted); }
107
+ .sg-content :global(pre) { background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 14px 16px; overflow-x: auto; font-family: var(--font-mono, ui-monospace, Menlo, monospace); font-size: .84rem; line-height: 1.5; }
108
+ .sg-content :global(pre code) { background: none; padding: 0; }
109
+ /* The highlighter's github-dark comment gray (#6a737d) is 3:1 on its own
110
+ #24292e ground; lift it to AA. Its color is an inline style, hence !important. */
111
+ .sg-content :global(.astro-code span[style*="#6A737D" i]) { color: #9ea7b3 !important; }
112
+ .sg-content :global(code) { font-family: var(--font-mono, ui-monospace, Menlo, monospace); font-size: .88em; }
113
+ .sg-content :global(:not(pre) > code) { background: var(--color-surface-2); padding: 1px 5px; border-radius: 4px; }
114
+ .sg-content :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
115
+ .sg-content :global(th), .sg-content :global(td) { border: 1px solid var(--color-border); padding: 6px 10px; text-align: left; font-size: .9rem; }
120
116
 
121
117
  @media (max-width: 900px) {
122
- .sg-shell { grid-template-columns: 1fr; }
118
+ .sg-shell { grid-template-columns: minmax(0, 1fr); }
123
119
  .sg-side { position: static; max-height: none; }
124
120
  }
125
121
  </style>
@@ -53,15 +53,6 @@ const navTheme = themes[0]?.id;
53
53
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; background: #0f1729; color: #e6edf6; line-height: 1.55; }
54
54
  .sg-shell { display: grid; grid-template-columns: 264px minmax(0, 1fr); align-items: start; }
55
55
  .sg-side { position: sticky; top: 0; align-self: start; max-height: 100vh; overflow-y: auto; padding: 22px 14px 40px; border-right: 1px solid #1c2a44; }
56
- .sg-nav__eyebrow { font: 800 .72rem/1.2 -apple-system, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #7fe3d4; margin: 0 0 12px; padding: 0 10px; }
57
- .sg-nav__list { list-style: none; margin: 0; padding: 0; }
58
- .sg-nav__list li { margin: 0; }
59
- .sg-nav__link { display: block; padding: 9px 10px; border-radius: 8px; color: #bfe0ff; text-decoration: none; font-size: .92rem; min-height: 40px; }
60
- .sg-nav__link:hover { background: #16223c; }
61
- .sg-nav__link.is-active { background: #16223c; color: #fff; font-weight: 700; }
62
- .sg-nav__link--sub { padding-left: 20px; font-size: .88rem; }
63
- .sg-nav__toc { list-style: none; margin: 2px 0 8px; padding: 0; }
64
- .sg-nav__toc-link { display: block; padding: 4px 10px 4px 26px; color: #9fb0c8; font-size: .82rem; text-decoration: none; }
65
56
  .wrap { max-width: 860px; margin: 0 auto; padding: 40px 24px 80px; }
66
57
  .flag { display: inline-block; font-size: .72rem; letter-spacing: .1em; text-transform: uppercase; background: #f59e0b; color: #221600; font-weight: 800; padding: 4px 10px; border-radius: 6px; }
67
58
  h1 { font-size: 1.9rem; margin: 18px 0 6px; }
@@ -75,12 +66,14 @@ const navTheme = themes[0]?.id;
75
66
  .badge.legacy { background: #47536e; color: #dfe6f2; }
76
67
  .status { margin-left: auto; font-size: .78rem; color: #9fb0c8; font-family: ui-monospace, Menlo, monospace; }
77
68
  .name { font-weight: 700; font-size: 1.05rem; }
78
- .desc { color: #9fb0c8; font-size: .9rem; margin-top: 8px; }
69
+ .desc { color: #9fb0c8; font-size: max(.9rem, 14px); margin-top: 8px; }
79
70
  .sub-links { display: flex; gap: 16px; flex-wrap: wrap; margin: 8px 0 16px 4px; }
80
- .sub-links a { color: #bfe0ff; font-size: .82rem; text-decoration: none; display: inline-flex; align-items: center; min-height: 32px; }
71
+ .sub-links a { color: #bfe0ff; font-size: max(.875rem, 14px); text-decoration: none; display: inline-flex; align-items: center; min-height: 44px; }
81
72
  .sub-links a:hover { text-decoration: underline; }
82
- .note { margin-top: 28px; font-size: .82rem; color: #7f8fab; border-top: 1px solid #24324f; padding-top: 16px; }
83
- @media (max-width: 900px) { .sg-shell { grid-template-columns: 1fr; } .sg-side { position: static; max-height: none; border-right: 0; border-bottom: 1px solid #1c2a44; } }
73
+ .back { margin: 14px 0 0; }
74
+ .back a { color: #bfe0ff; font-size: max(.875rem, 14px); text-decoration: none; display: inline-flex; align-items: center; min-height: 44px; }
75
+ .note { margin-top: 28px; font-size: max(.875rem, 14px); color: #7f8fab; border-top: 1px solid #24324f; padding-top: 16px; }
76
+ @media (max-width: 900px) { .sg-shell { grid-template-columns: minmax(0, 1fr); } .sg-side { position: static; max-height: none; border-right: 0; border-bottom: 1px solid #1c2a44; } }
84
77
  </style>
85
78
  </head>
86
79
  <body>
@@ -90,7 +83,7 @@ const navTheme = themes[0]?.id;
90
83
  </aside>
91
84
  <main class="wrap">
92
85
  <span class="flag">Preview · dev only · noindex</span>
93
- <p style="margin:14px 0 0"><a href="/_style-guide/" style="color:#bfe0ff;font-size:.85rem;text-decoration:none">← all clients</a></p>
86
+ <p class="back"><a href="/_style-guide/">← all clients</a></p>
94
87
  <h1>{tenant} — theme style guides</h1>
95
88
  <p class="sub">Open any guide independently to compare looks side-by-side. Nothing here reskins the live site. The <b>Widget Library Guide</b> (left nav) documents every shared component.</p>
96
89
 
@@ -70,7 +70,8 @@ const tenants = [...byTenant.entries()]
70
70
  .sub { color: #9fb0c8; margin: 0 0 32px; }
71
71
  .tenant { margin-bottom: 30px; }
72
72
  .tenant > h2 { font-size: 1.05rem; margin: 0 0 12px; display: flex; align-items: baseline; gap: 10px; }
73
- .tenant > h2 a { color: #bfe0ff; font-size: .82rem; font-weight: 600; text-decoration: none; }
73
+ .tenant > h2 a { color: #bfe0ff; font-size: max(.875rem, 14px); font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; min-height: 44px; }
74
+ .sub a { color: #bfe0ff; display: inline-flex; align-items: center; min-height: 44px; }
74
75
  .card { display: block; text-decoration: none; color: inherit; background: #16223c; border: 1px solid #24324f; border-radius: 14px; padding: 16px 20px; margin-bottom: 12px; transition: border-color .15s, transform .15s; }
75
76
  .card:hover { border-color: #3f5680; transform: translateY(-2px); }
76
77
  .row { display: flex; align-items: center; gap: 12px; }
@@ -80,12 +81,12 @@ const tenants = [...byTenant.entries()]
80
81
  .badge.legacy { background: #47536e; color: #dfe6f2; }
81
82
  .status { margin-left: auto; font-size: .78rem; color: #9fb0c8; font-family: ui-monospace, Menlo, monospace; }
82
83
  .name { font-weight: 700; font-size: 1.02rem; }
83
- .desc { color: #9fb0c8; font-size: .88rem; margin-top: 6px; }
84
- .note { margin-top: 28px; font-size: .82rem; color: #7f8fab; border-top: 1px solid #24324f; padding-top: 16px; }
84
+ .desc { color: #9fb0c8; font-size: max(.88rem, 14px); margin-top: 6px; }
85
+ .note { margin-top: 28px; font-size: max(.875rem, 14px); color: #7f8fab; border-top: 1px solid #24324f; padding-top: 16px; }
85
86
  </style>
86
87
  </head>
87
88
  <body>
88
- <div class="wrap">
89
+ <main class="wrap">
89
90
  <span class="flag">Preview · dev only · noindex</span>
90
91
  <h1>Client style guides</h1>
91
92
  <p class="sub">Every client tenant and its theme guides. Open a guide to preview the standard components and page sections re-skinned under that theme. Nothing here re-skins a live site.</p>
@@ -117,6 +118,6 @@ const tenants = [...byTenant.entries()]
117
118
  These are preview/dev review surfaces — excluded from the sitemap and production marketing
118
119
  publishing. Approve a tenant's <b>current</b> guide before propagating its style concept across the site.
119
120
  </p>
120
- </div>
121
+ </main>
121
122
  </body>
122
123
  </html>
@@ -16,17 +16,26 @@
16
16
  *
17
17
  * Preview sessions also consult the image-capture rewrite overlay so rewritten
18
18
  * `public/*.css` (and similar text assets) serve `/_a/<sha>` references.
19
+ *
20
+ * Version selection, in order: a candidate/revision pin (`tot_preview_version` +
21
+ * token cookie); else, for a shared-preview viewer (preview host, or a valid token
22
+ * for this tenant), the version the content `preview` channel serves — what the
23
+ * preview document itself rendered; else the request's channel pointer.
19
24
  */
20
25
  import type { APIContext } from "astro";
21
26
  import {
22
27
  assetContentType,
23
28
  parseTenantAssetPath,
29
+ resolveChannelVersion,
24
30
  serveTenantAsset,
25
31
  type R2BucketLike,
26
32
  } from "@tot/public-runtime";
27
33
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
28
34
  import { readR2, readKv, readEnv } from "@/lib/env";
29
- import { resolvePreviewVersionFromCookies } from "@/lib/assets/preview-version-cookie";
35
+ import {
36
+ resolvePreviewVersionFromCookies,
37
+ resolveSharedPreviewAssetVersion,
38
+ } from "@/lib/assets/preview-version-cookie";
30
39
 
31
40
  export const prerender = false;
32
41
 
@@ -73,14 +82,30 @@ export async function GET(context: APIContext): Promise<Response> {
73
82
  }
74
83
 
75
84
  const urlTenant = context.params.id ?? "";
76
- const previewVersionId = await resolvePreviewVersionFromCookies({
85
+ const url = new URL(context.request.url);
86
+ const secret = await readEnv("CUSTOMIZATION_PREVIEW_SECRET");
87
+ const tokenCookie = context.cookies.get("tot_preview")?.value;
88
+ const pinnedVersionId = await resolvePreviewVersionFromCookies({
77
89
  tenantId: urlTenant,
78
90
  previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
79
- previewTokenCookie: context.cookies.get("tot_preview")?.value,
80
- secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"),
91
+ previewTokenCookie: tokenCookie,
92
+ secret,
81
93
  });
94
+ // No candidate/revision pin: a shared-preview viewer reads the version the
95
+ // preview channel serves — the same version its document rendered.
96
+ const sharedPreview = pinnedVersionId
97
+ ? { preview: false, versionId: null }
98
+ : await resolveSharedPreviewAssetVersion({
99
+ tenantId: urlTenant,
100
+ previewHost: url.hostname.toLowerCase().startsWith("preview."),
101
+ previewToken: url.searchParams.get("__preview") ?? tokenCookie,
102
+ secret,
103
+ readPreviewChannelVersion: () =>
104
+ resolveChannelVersion((key) => kv.get(key), urlTenant, "preview"),
105
+ });
106
+ const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
82
107
 
83
- const pathname = new URL(context.request.url).pathname;
108
+ const pathname = url.pathname;
84
109
  if (previewVersionId) {
85
110
  const overlay = await tryServePreviewRewriteOverlay({
86
111
  kvGet: (key) => kv.get(key),
@@ -96,7 +121,7 @@ export async function GET(context: APIContext): Promise<Response> {
96
121
  // Runtime binding structurally satisfies the shared-runtime R2 seam.
97
122
  bucket: bucket as unknown as R2BucketLike,
98
123
  kvGet: (key) => kv.get(key),
99
- channel: context.locals.customizationEnv ?? "live",
124
+ channel: sharedPreview.preview ? "test" : (context.locals.customizationEnv ?? "live"),
100
125
  ...(previewVersionId ? { versionId: previewVersionId } : {}),
101
126
  ifNoneMatch: context.request.headers.get("if-none-match"),
102
127
  },
@@ -107,7 +132,7 @@ export async function GET(context: APIContext): Promise<Response> {
107
132
  // (that would poison the public asset with candidate bytes, and Vary doesn't cover
108
133
  // the auth cookie). Force no-store for the preview-version path; the normal
109
134
  // channel-served asset keeps its revalidating cache header.
110
- if (res && previewVersionId) {
135
+ if (res && (previewVersionId || sharedPreview.preview)) {
111
136
  res.headers.set("cache-control", "no-store");
112
137
  res.headers.set("x-robots-tag", "noindex, nofollow");
113
138
  }
@@ -5,8 +5,12 @@
5
5
  // visibility chokepoint); unlisted posts resolve here but carry noindex and
6
6
  // stay out of the index/sitemap/RSS (see collection.ts isVisibleOnLive).
7
7
  import Layout from "@/layouts/Layout.astro";
8
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
8
9
  import ReactionBar from "@/components/blog/ReactionBar.astro";
9
- import { getBuildPost, resolveBuildRedirect, THE_BUILD } from "@/lib/the-build/provider.js";
10
+ import ShareRow from "@/components/blog/ShareRow.astro";
11
+ import { getBuildPost, getTheBuildSettings, resolveBuildRedirect, THE_BUILD } from "@/lib/the-build/provider.js";
12
+ import { bylineAuthor, resolveBlogPresentation, shareLinks } from "@/lib/blog/presentation.js";
13
+ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
10
14
  import { getTranslations } from "@/lib/blog/collection.js";
11
15
  import { buildAlternates } from "@/lib/seo/alternates.js";
12
16
  import { breadcrumbLd } from "@/lib/jsonld.js";
@@ -27,6 +31,9 @@ if (!post) {
27
31
  }
28
32
 
29
33
  const canonical = new URL(`/the-build/${post.slug}/`, canonicalBase + "/").toString();
34
+ const presentation = resolveBlogPresentation(await getTheBuildSettings(tenant.tenant_id, content));
35
+ // A post that names no author has no byline and no structured-data author.
36
+ const author = bylineAuthor(post.author);
30
37
  const articleLd = {
31
38
  "@context": "https://schema.org",
32
39
  "@type": "BlogPosting",
@@ -36,15 +43,21 @@ const articleLd = {
36
43
  mainEntityOfPage: canonical,
37
44
  datePublished: post.publishedAt,
38
45
  dateModified: post.modifiedAt,
39
- author: { "@type": "Person", name: post.author },
46
+ ...(author ? { author: { "@type": "Person", name: author } } : {}),
40
47
  publisher: { "@type": "Organization", name: tenant.displayName, url: canonicalBase },
41
48
  ...(post.featuredImage ? { image: new URL(post.featuredImage.src, canonicalBase + "/").toString() } : {}),
42
49
  };
43
- const crumbs = breadcrumbLd([
44
- { label: tenant.displayName, href: "/" },
50
+ const trail = siteTrail(
51
+ homeCrumbLabel(await content.getChrome()),
45
52
  { label: "The Build", href: "/the-build/" },
46
53
  { label: post.title, href: `/the-build/${post.slug}/` },
47
- ], canonicalBase);
54
+ );
55
+ const crumbs = breadcrumbLd(trail, canonicalBase);
56
+ const share = shareLinks(presentation.share, {
57
+ url: canonical,
58
+ title: post.title,
59
+ image: post.featuredImage ? new URL(post.featuredImage.src, canonicalBase + "/").toString() : undefined,
60
+ });
48
61
  const buildHref = `${basePath}/the-build/`;
49
62
 
50
63
  // I2 — hreflang alternates + per-variant <html lang>. `getTranslations` returns
@@ -73,14 +86,14 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
73
86
  lang={lang}
74
87
  >
75
88
  <article class="article-shell">
76
- <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={buildHref}>The Build</a><span aria-hidden="true">/</span><span>{post.categories[0] ?? "Article"}</span></nav>
89
+ <Breadcrumbs crumbs={trail} class="breadcrumbs" />
77
90
  <header>
78
- {post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
91
+ {presentation.postEyebrow && post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
79
92
  <h1>{post.title}</h1>
80
- <p class="dek">{post.excerpt}</p>
93
+ {presentation.dek && post.excerpt && <p class="dek">{post.excerpt}</p>}
81
94
  <div class="byline">
82
- <span>By {post.author}</span>
83
- <span aria-hidden="true">·</span>
95
+ {author && <span>By {author}</span>}
96
+ {author && <span aria-hidden="true">·</span>}
84
97
  <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
85
98
  </div>
86
99
  </header>
@@ -95,21 +108,29 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
95
108
  decoding="async"
96
109
  />
97
110
  )}
98
- <div class="article-grid">
99
- <aside>
100
- <a href={buildHref}>← All articles</a>
101
- {post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
102
- </aside>
103
- <div class="blog-body" set:html={post.contentHtml}></div>
111
+ <div class:list={["article-grid", { "article-grid--single": !presentation.aside }]}>
112
+ {presentation.aside && (
113
+ <aside>
114
+ <a href={buildHref}>← All articles</a>
115
+ {post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
116
+ </aside>
117
+ )}
118
+ <div class="blog-main">
119
+ <div class="blog-body" set:html={post.contentHtml}></div>
120
+ {!presentation.aside && post.tags.length > 0 && (
121
+ <ul class="post-tags" aria-label="Tags">{post.tags.map((tag) => <li><span>{tag}</span></li>)}</ul>
122
+ )}
123
+ <ShareRow links={share} />
124
+ </div>
104
125
  </div>
105
- <ReactionBar collection="the-build" slug={post.slug} />
126
+ {presentation.reactions && <ReactionBar collection="the-build" slug={post.slug} />}
106
127
  </article>
107
128
  </Layout>
108
129
 
109
130
  <style>
110
131
  .article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
111
- .breadcrumbs { display: flex; gap: .55rem; align-items: center; color: var(--color-muted); font-size: .84rem; }
112
- .breadcrumbs a { color: var(--color-accent-ink); font-weight: 700; }
132
+ .article-shell :global(.breadcrumbs) { font-size: .84rem; }
133
+ .article-shell :global(.breadcrumbs a) { color: var(--color-accent-ink); font-weight: 700; }
113
134
  header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
114
135
  header .eyebrow { justify-content: center; }
115
136
  h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
@@ -120,7 +141,10 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
120
141
  aside { align-self: start; position: sticky; top: 7rem; }
121
142
  aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
122
143
  .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
123
- .article-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
144
+ .article-tags span, .post-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
145
+ .article-grid--single { grid-template-columns: minmax(0, 720px); }
146
+ .blog-main { min-width: 0; }
147
+ .post-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin: 2rem 0 0; padding: 0; list-style: none; }
124
148
  .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
125
149
  .blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
126
150
  .blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
@@ -5,6 +5,7 @@
5
5
  // with the-build content renders the same way (per-tenant/multi-collection
6
6
  // invariant — see docs/plans/2026-08-21-blog-platform-workstream.md §1).
7
7
  import TheBuildIndexView from "@/components/the-build/TheBuildIndexView.astro";
8
+ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
9
  import { getTheBuildSettings, listBuildPosts } from "@/lib/the-build/provider.js";
9
10
  import { paginateBlogPosts } from "@/lib/blog/pagination.js";
10
11
  import { resolveTheBuildIndexCopy } from "@/lib/the-build/pagination.js";
@@ -24,6 +25,7 @@ const { posts: pagePosts, totalPages } = paginateBlogPosts(posts, copy.pageSize,
24
25
  basePath={basePath}
25
26
  canonicalBase={canonicalBase}
26
27
  copy={copy}
28
+ presentation={resolveBlogPresentation(settings)}
27
29
  pagePosts={pagePosts}
28
30
  page={1}
29
31
  totalPages={totalPages}
@@ -4,6 +4,7 @@
4
4
  // TheBuildIndexView so the tenant-copy + pagination contract can't drift
5
5
  // between the two routes (mirrors src/pages/blog/page/[n].astro).
6
6
  import TheBuildIndexView from "@/components/the-build/TheBuildIndexView.astro";
7
+ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
7
8
  import { getTheBuildSettings, listBuildPosts } from "@/lib/the-build/provider.js";
8
9
  import { paginateBlogPosts } from "@/lib/blog/pagination.js";
9
10
  import { parseTheBuildPageParam, resolveTheBuildIndexCopy } from "@/lib/the-build/pagination.js";
@@ -28,6 +29,7 @@ if (!sliced) return new Response(null, { status: 404 });
28
29
  basePath={basePath}
29
30
  canonicalBase={canonicalBase}
30
31
  copy={copy}
32
+ presentation={resolveBlogPresentation(settings)}
31
33
  pagePosts={sliced.posts}
32
34
  page={sliced.page}
33
35
  totalPages={sliced.totalPages}
@@ -437,6 +437,53 @@ code {
437
437
  color: #ffffff;
438
438
  }
439
439
 
440
+ /* A disabled action must look disabled. This sheet is unlayered, so it beats
441
+ global.css's `@layer base` :disabled default; without this rule a disabled
442
+ .btn.primary is pixel-identical to a live one. Declared after every .btn
443
+ variant at equal specificity so it wins over each of them; a component rule
444
+ that paints a .btn variant must exclude :disabled or restyle it (pinned by
445
+ admin-disabled.contract.test.ts). */
446
+ button:disabled,
447
+ button[aria-disabled="true"] {
448
+ cursor: not-allowed;
449
+ }
450
+
451
+ .btn:disabled,
452
+ .btn[aria-disabled="true"],
453
+ .icon-btn:disabled,
454
+ .icon-btn[aria-disabled="true"] {
455
+ border-color: var(--admin-line);
456
+ background: var(--admin-surface-soft);
457
+ color: var(--admin-muted-2);
458
+ box-shadow: none;
459
+ filter: none;
460
+ cursor: not-allowed;
461
+ }
462
+
463
+ /* The visible "why is this disabled" line beside a disabled action. */
464
+ .action-reason {
465
+ display: block;
466
+ max-width: 34ch;
467
+ margin: 4px 0 0 auto;
468
+ color: var(--admin-muted);
469
+ font-size: 12px;
470
+ line-height: 1.4;
471
+ white-space: normal;
472
+ text-align: right;
473
+ }
474
+
475
+ .action-reason[hidden] {
476
+ display: none;
477
+ }
478
+
479
+ .review-actions .action-reason,
480
+ .agg-launch .action-reason {
481
+ flex-basis: 100%;
482
+ max-width: none;
483
+ margin: 0;
484
+ text-align: left;
485
+ }
486
+
440
487
  .floating-ask {
441
488
  position: fixed;
442
489
  right: 24px;