@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.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 (224) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  94. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  95. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  96. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  97. package/apps/storefront/src/lib/env.ts +12 -0
  98. package/apps/storefront/src/lib/jsonld.ts +12 -17
  99. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  100. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  101. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  104. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  105. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  106. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  107. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  108. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  109. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  110. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  111. package/apps/storefront/src/lib/tot/query.ts +32 -0
  112. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  113. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  116. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  117. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  118. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  119. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  120. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  121. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  122. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  123. package/apps/storefront/src/middleware/index.ts +28 -13
  124. package/apps/storefront/src/pages/404.astro +21 -9
  125. package/apps/storefront/src/pages/[...slug].astro +36 -2
  126. package/apps/storefront/src/pages/admin.astro +29 -3
  127. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  128. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  129. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  130. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  131. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  132. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  133. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  134. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  135. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  137. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  138. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  139. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  140. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  142. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  143. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  144. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  146. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  147. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  149. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  150. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  151. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  152. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  153. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  154. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  155. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  156. package/apps/storefront/src/pages/auth/login.astro +197 -41
  157. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  158. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  159. package/apps/storefront/src/pages/blog/index.astro +98 -0
  160. package/apps/storefront/src/pages/capabilities.astro +8 -0
  161. package/apps/storefront/src/pages/cockpit.astro +433 -69
  162. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  163. package/apps/storefront/src/pages/collections/index.astro +10 -2
  164. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  166. package/apps/storefront/src/pages/index.astro +57 -0
  167. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  168. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  169. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  170. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  171. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  175. package/apps/storefront/src/styles/fonts.css +54 -0
  176. package/apps/storefront/src/styles/global.css +22 -2
  177. package/apps/storefront/src/themes/schema.ts +3 -25
  178. package/apps/storefront/tsconfig.json +1 -1
  179. package/apps/storefront/vitest.config.ts +4 -1
  180. package/package.json +1 -1
  181. package/packages/public-runtime/src/candidate-index.ts +311 -0
  182. package/packages/public-runtime/src/checkout.ts +94 -2
  183. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  184. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  185. package/packages/public-runtime/src/compliance/index.ts +22 -0
  186. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  187. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  188. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  189. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  190. package/packages/public-runtime/src/csp.ts +27 -3
  191. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  192. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  193. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  194. package/packages/public-runtime/src/extension-contract.ts +2 -1
  195. package/packages/public-runtime/src/hash.ts +25 -0
  196. package/packages/public-runtime/src/index.ts +6 -0
  197. package/packages/public-runtime/src/membership.ts +353 -0
  198. package/packages/public-runtime/src/product.ts +24 -2
  199. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  200. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  201. package/packages/public-runtime/src/tenant.ts +236 -0
  202. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  203. package/scripts/dev/publish.mjs +158 -0
  204. package/scripts/dev/transient-files.mjs +2 -1
  205. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  206. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  223. package/tenants/home/public/pages/storefront.css +23 -0
  224. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -11,6 +11,10 @@
11
11
  * reach the browser. Inline style carries the per-request CSP nonce.
12
12
  */
13
13
  import { normalizeEmailHint } from "@/lib/privacy/emailHint";
14
+ // Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
15
+ // the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
16
+ // Inter, and JetBrains Mono used by this page's inline styles.
17
+ import "@/styles/fonts.css";
14
18
 
15
19
  export const prerender = false;
16
20
 
@@ -25,6 +29,19 @@ const next = (() => {
25
29
  return n.startsWith("/") && !n.startsWith("//") ? n : "/";
26
30
  })();
27
31
 
32
+ // RFC 9470 step-up: tot core told /api/auth/verify (or /api/auth/magic-exchange,
33
+ // which redirects to /auth/magic instead) that it needs a fresher credential
34
+ // before it will complete sign-in. `reason` is distinct from `error` — see
35
+ // `loginRedirect` in `@/lib/auth/route`.
36
+ const reason = url.searchParams.get("reason");
37
+ const requiredAcr = url.searchParams.get("requiredAcr") ?? "";
38
+ const stepUpRequired = reason === "step-up-required";
39
+ const successPending = reason === "success-pending";
40
+ const ACR_LABELS: Record<string, string> = {
41
+ "tot:aal:sms": "a text message (SMS) code",
42
+ };
43
+ const stepUpFactorLabel = requiredAcr ? (ACR_LABELS[requiredAcr] ?? "an additional verification step") : "an additional verification step";
44
+
28
45
  const ERROR_MESSAGES: Record<string, string> = {
29
46
  bad_email: "That email doesn't look right — please try again.",
30
47
  unconfigured: "Sign-in isn't available right now. Please try again shortly.",
@@ -32,6 +49,7 @@ const ERROR_MESSAGES: Record<string, string> = {
32
49
  expired: "That code has expired — request a fresh one.",
33
50
  too_many_attempts: "Too many attempts. Request a new code to continue.",
34
51
  verify_failed: "We couldn't verify your sign-in. Please request a fresh code and try again.",
52
+ error: "We couldn't verify that. Please try again.",
35
53
  };
36
54
  const errorMessage = errorCode ? (ERROR_MESSAGES[errorCode] ?? "Something went wrong — try again.") : null;
37
55
  // A stale/failed code should drop back to the email step so a new code can be requested.
@@ -54,59 +72,166 @@ if (devMode) {
54
72
  <meta name="viewport" content="width=device-width, initial-scale=1" />
55
73
  <meta name="robots" content="noindex, nofollow" />
56
74
  <title>Sign in — Regulated Storefront by Token of Trust©</title>
75
+ {/* Web fonts are self-hosted via `@/styles/fonts.css` (imported above). */}
57
76
  <style nonce={cspNonce}>
58
- :root { color-scheme: light dark; }
77
+ /* storefront.tokenoftrust.store visual system — the code-entry door wears the
78
+ same skin as the marketing storefront it belongs to (in lockstep with the
79
+ magic-link landing, auth/magic.astro): warm cream + deep navy, the signature
80
+ teal→blue gradient (--grad), Plus Jakarta / Inter / JetBrains. Token values
81
+ mirror /tenants/home/pages/storefront.css. Theme via the triple: :root
82
+ default light, @media dark (the site's dark-hero palette), and [data-theme]. */
83
+ :root {
84
+ --bg: #faf8f4; --surface: #ffffff;
85
+ --ink: #10294b; --ink-2: #1b2a40; --muted: #4d5a70; --faint: #8593a8;
86
+ --accent: #0d9391; --accent-ink: #0b6360; --accent-wash: #e4f4f2; --accent-line: #c1e7e2;
87
+ --border: #e6e2d9;
88
+ --warn: #bf3d2c; --warn-wash: #fbecea; --warn-line: #f0ccc6;
89
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
90
+ --sh: 0 18px 50px rgba(16, 41, 75, .13);
91
+ --display: "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
92
+ --body: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
93
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
94
+ color-scheme: light;
95
+ }
96
+ @media (prefers-color-scheme: dark) {
97
+ :root {
98
+ --bg: #121118; --surface: #1b1a26;
99
+ --ink: #f3f5f9; --ink-2: #c7cede; --muted: #98a1b5; --faint: #6f7994;
100
+ --accent: #5fe0cf; --accent-ink: #7fe3d6; --accent-wash: #10302d; --accent-line: #1f524c;
101
+ --border: #2a2836;
102
+ --warn: #f2857f; --warn-wash: #2a1414; --warn-line: #5a2626;
103
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
104
+ --sh: 0 18px 44px -18px rgba(0, 0, 0, .6);
105
+ color-scheme: dark;
106
+ }
107
+ }
108
+ :root[data-theme="dark"] {
109
+ --bg: #121118; --surface: #1b1a26;
110
+ --ink: #f3f5f9; --ink-2: #c7cede; --muted: #98a1b5; --faint: #6f7994;
111
+ --accent: #5fe0cf; --accent-ink: #7fe3d6; --accent-wash: #10302d; --accent-line: #1f524c;
112
+ --border: #2a2836;
113
+ --warn: #f2857f; --warn-wash: #2a1414; --warn-line: #5a2626;
114
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
115
+ --sh: 0 18px 44px -18px rgba(0, 0, 0, .6);
116
+ color-scheme: dark;
117
+ }
118
+ :root[data-theme="light"] {
119
+ --bg: #faf8f4; --surface: #ffffff;
120
+ --ink: #10294b; --ink-2: #1b2a40; --muted: #4d5a70; --faint: #8593a8;
121
+ --accent: #0d9391; --accent-ink: #0b6360; --accent-wash: #e4f4f2; --accent-line: #c1e7e2;
122
+ --border: #e6e2d9;
123
+ --warn: #bf3d2c; --warn-wash: #fbecea; --warn-line: #f0ccc6;
124
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
125
+ --sh: 0 18px 50px rgba(16, 41, 75, .13);
126
+ color-scheme: light;
127
+ }
128
+
59
129
  * { box-sizing: border-box; }
60
130
  body {
61
- font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
62
131
  margin: 0; min-height: 100vh; display: grid; place-items: center;
63
- padding: 2rem 1.25rem; background: #f5f4f0; color: #1c1c1c;
64
- }
65
- @media (prefers-color-scheme: dark) {
66
- body { background: #14141a; color: #ececf1; }
132
+ padding: 2rem 1.25rem;
133
+ background:
134
+ radial-gradient(34rem 18rem at 92% -8%, var(--accent-wash) 0%, transparent 60%),
135
+ var(--bg);
136
+ background-repeat: no-repeat;
137
+ color: var(--ink-2); font-family: var(--body); font-size: 15px; line-height: 1.6;
138
+ letter-spacing: -.011em; -webkit-font-smoothing: antialiased;
67
139
  }
68
140
  .card {
69
- width: 100%; max-width: 26rem; background: #fff; border: 1px solid #e6e4de;
70
- border-radius: 14px; padding: 2rem 1.75rem; box-shadow: 0 6px 24px rgba(0,0,0,.06);
141
+ width: 100%; max-width: 26rem; background: var(--surface);
142
+ border: 1px solid var(--accent-line); border-radius: 20px;
143
+ padding: 2.1rem 1.9rem 1.9rem; position: relative; overflow: hidden;
144
+ box-shadow: var(--sh); animation: rise .5s cubic-bezier(.2, .9, .3, 1) both;
145
+ }
146
+ .card::before {
147
+ content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
148
+ background: var(--grad);
149
+ }
150
+ @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
151
+
152
+ .mark {
153
+ width: 46px; height: 46px; border-radius: 13px; display: grid; place-items: center;
154
+ color: #fff; background: var(--grad);
155
+ box-shadow: 0 8px 20px -6px rgba(13, 147, 145, .6); position: relative; margin: 0 0 1.15rem;
156
+ }
157
+ .mark::after {
158
+ content: ""; position: absolute; inset: -5px; border-radius: 17px;
159
+ border: 1.5px solid var(--accent); opacity: 0; animation: halo 2.8s ease-in-out infinite;
160
+ }
161
+ @keyframes halo { 0%, 100% { opacity: 0; transform: scale(.96); } 50% { opacity: .5; transform: scale(1.04); } }
162
+ .mark svg { width: 24px; height: 24px; }
163
+
164
+ .eyebrow {
165
+ font-family: var(--mono); font-size: .66rem; font-weight: 600; text-transform: uppercase;
166
+ letter-spacing: .13em; color: var(--accent-ink); margin: 0 0 .55rem;
167
+ }
168
+ h1 {
169
+ font-family: var(--display); font-weight: 800; letter-spacing: -.025em; color: var(--ink);
170
+ font-size: 1.55rem; margin: 0 0 .5rem; text-wrap: balance;
171
+ }
172
+ .sub { color: var(--muted); font-size: .95rem; line-height: 1.55; margin: 0 0 1.4rem; }
173
+ label {
174
+ display: block; font-family: var(--mono); font-size: .66rem; font-weight: 600;
175
+ text-transform: uppercase; letter-spacing: .12em; color: var(--faint); margin: 0 0 .4rem;
71
176
  }
72
- @media (prefers-color-scheme: dark) {
73
- .card { background: #1e1e28; border-color: #33333f; box-shadow: none; }
74
- }
75
- .eyebrow { font: 600 .68rem/1 ui-monospace, monospace; letter-spacing: .12em;
76
- text-transform: uppercase; color: #8a7f66; margin: 0 0 .75rem; }
77
- h1 { font-size: 1.4rem; letter-spacing: -.01em; margin: 0 0 .4rem; }
78
- .sub { color: #6a6a6a; margin: 0 0 1.5rem; font-size: .95rem; }
79
- @media (prefers-color-scheme: dark) { .sub { color: #a2a2ad; } }
80
- label { display: block; font-weight: 600; font-size: .82rem; margin: 0 0 .4rem; }
81
177
  input[type=email], input[type=text] {
82
- width: 100%; padding: .7rem .8rem; font-size: 1rem; border-radius: 9px;
83
- border: 1px solid #cfccc4; background: #fff; color: inherit;
178
+ width: 100%; border: 1px solid var(--border); border-radius: 11px;
179
+ padding: .8rem .85rem; color: var(--ink); background: var(--bg);
180
+ font: 600 .95rem/1.35 var(--body);
84
181
  }
85
- @media (prefers-color-scheme: dark) {
86
- input[type=email], input[type=text] { background: #14141a; border-color: #40404d; }
182
+ input[type=email]:focus-visible, input[type=text]:focus-visible {
183
+ outline: 2px solid var(--accent); outline-offset: 2px;
184
+ }
185
+ input[type=text].code {
186
+ letter-spacing: .4em; font: 600 1.25rem var(--mono); text-align: center;
87
187
  }
88
- input[type=text].code { letter-spacing: .4em; font: 600 1.25rem ui-monospace, monospace; text-align: center; }
89
188
  button {
90
- width: 100%; margin-top: 1rem; padding: .75rem 1rem; border: 0; border-radius: 9px;
91
- background: #1a1a1a; color: #fff; font-weight: 600; font-size: .95rem; cursor: pointer;
92
- }
93
- @media (prefers-color-scheme: dark) { button { background: #ececf1; color: #14141a; } }
94
- button:hover { opacity: .92; }
95
- .err { background: #fdecec; border: 1px solid #f5c2c2; color: #9a1c1c;
96
- border-radius: 9px; padding: .6rem .8rem; font-size: .88rem; margin: 0 0 1.15rem; }
97
- @media (prefers-color-scheme: dark) { .err { background: #3a1f22; border-color: #6b2f34; color: #f6b9b9; } }
98
- .dev { background: #eef4ff; border: 1px solid #c3d4f5; color: #234; border-radius: 9px;
99
- padding: .6rem .8rem; font-size: .82rem; margin: 0 0 1.15rem; }
100
- .dev code { font: 600 .8em ui-monospace, monospace; }
101
- @media (prefers-color-scheme: dark) { .dev { background: #1b2740; border-color: #2f4468; color: #cfe0ff; } }
102
- .note { font-size: .82rem; color: #8a8a8a; margin: 1.1rem 0 0; }
103
- .who { font-weight: 600; }
104
- a { color: inherit; }
105
- .foot { margin-top: 1.5rem; font-size: .72rem; color: #9a9a9a; text-align: center; }
189
+ width: 100%; margin-top: 1rem; display: inline-flex; align-items: center;
190
+ justify-content: center; gap: .55rem; font-family: var(--display); font-weight: 700;
191
+ font-size: 1rem; color: #fff; background: var(--grad); border: 0; border-radius: 999px;
192
+ padding: .9rem 1.3rem; cursor: pointer; box-shadow: 0 10px 24px rgba(13, 147, 145, .32);
193
+ transition: transform .12s, box-shadow .12s, filter .12s;
194
+ }
195
+ button:hover { transform: translateY(-1px); filter: saturate(1.08); box-shadow: 0 14px 30px rgba(13, 147, 145, .4); }
196
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
197
+ button svg { width: 17px; height: 17px; }
198
+ /* Quiet secondary action (e.g. "resend SMS code") a text-link-weight button
199
+ so it doesn't compete with the card's one primary gradient CTA. */
200
+ .btn-quiet {
201
+ width: auto; margin: 0; background: none; box-shadow: none; border: 0;
202
+ color: var(--accent-ink); font: 600 .85rem var(--display); padding: 0; cursor: pointer;
203
+ }
204
+ .btn-quiet:hover { transform: none; filter: none; text-decoration: underline; }
205
+ .err {
206
+ display: flex; gap: .55rem; align-items: flex-start; background: var(--warn-wash);
207
+ border: 1px solid var(--warn-line); color: var(--warn); border-radius: 11px;
208
+ padding: .75rem .9rem; font-size: .9rem; line-height: 1.5; margin: 0 0 1.15rem;
209
+ }
210
+ .dev {
211
+ background: var(--accent-wash); border: 1px solid var(--accent-line); color: var(--ink-2);
212
+ border-radius: 11px; padding: .65rem .85rem; font-size: .82rem; line-height: 1.5; margin: 0 0 1.15rem;
213
+ }
214
+ .dev code { font: 600 .8em var(--mono); color: var(--accent-ink); }
215
+ .note { font-size: .82rem; color: var(--faint); margin: 1.15rem 0 0; }
216
+ .note a { color: var(--accent-ink); font-weight: 600; text-decoration: none; }
217
+ .note a:hover { text-decoration: underline; }
218
+ .note a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
219
+ .who { font-weight: 600; color: var(--ink); }
220
+ .foot {
221
+ margin-top: 1.5rem; font-family: var(--mono); font-size: .7rem; letter-spacing: .04em;
222
+ color: var(--faint); text-align: center;
223
+ }
224
+ @media (prefers-reduced-motion: reduce) {
225
+ .card, .mark::after { animation: none !important; }
226
+ button { transition: none; }
227
+ }
106
228
  </style>
107
229
  </head>
108
230
  <body>
109
231
  <main class="card">
232
+ <span class="mark" aria-hidden="true">
233
+ <svg viewBox="0 0 24 24" fill="none"><path d="M12 2.5 20 6v6c0 4.6-3.2 7.9-8 9.5-4.8-1.6-8-4.9-8-9.5V6l8-3.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m8.5 12 2.4 2.4L15.8 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
234
+ </span>
110
235
  <p class="eyebrow">Token of Trust© · Regulated Storefront</p>
111
236
  {devMode && (devAuthOn ? (
112
237
  <p class="dev"><strong>Local dev — console bypass</strong> (<code>DEV_AUTH=1</code>). No email is sent;
@@ -118,7 +243,32 @@ if (devMode) {
118
243
  ))}
119
244
  {errorMessage && <p class="err">{errorMessage}</p>}
120
245
 
121
- {showCodeStep ? (
246
+ {stepUpRequired || successPending ? (
247
+ <>
248
+ <h1>{successPending ? "Almost there" : "Verify with SMS to continue"}</h1>
249
+ <p class="sub">
250
+ {successPending ? "Your identity is verified — sign-in just needs one more step. " : null}
251
+ We sent {stepUpFactorLabel} to the phone on file for <span class="who">{email}</span>. Enter it below to finish signing in.
252
+ </p>
253
+ <form method="post" action="/api/auth/step-up-verify" autocomplete="one-time-code">
254
+ <input type="hidden" name="identifier" value={email} />
255
+ <input type="hidden" name="next" value={next} />
256
+ <label for="stepup-code">SMS code</label>
257
+ <input class="code" id="stepup-code" name="code" type="text" inputmode="numeric"
258
+ pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required autofocus />
259
+ <button type="submit">
260
+ Verify &amp; continue
261
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
262
+ </button>
263
+ </form>
264
+ <p class="note" style="margin-top:1.15rem;">Didn't get a code?</p>
265
+ <form method="post" action="/api/auth/step-up-send">
266
+ <input type="hidden" name="identifier" value={email} />
267
+ <button type="submit" class="btn-quiet">Resend SMS code</button>
268
+ </form>
269
+ <p class="note"><a href={`/auth/login${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`}>Use a different email</a></p>
270
+ </>
271
+ ) : showCodeStep ? (
122
272
  <>
123
273
  <h1>Check your email</h1>
124
274
  <p class="sub">We sent a 6-digit code to <span class="who">{email}</span> if it has access to this preview. Enter it below.</p>
@@ -128,7 +278,10 @@ if (devMode) {
128
278
  <label for="code">Access code</label>
129
279
  <input class="code" id="code" name="code" type="text" inputmode="numeric"
130
280
  pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required autofocus />
131
- <button type="submit">Verify &amp; view preview</button>
281
+ <button type="submit">
282
+ Verify &amp; view preview
283
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
284
+ </button>
132
285
  </form>
133
286
  <p class="note"><a href={`/auth/login${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`}>Use a different email</a> · <a href="https://mcp.tokenoftrust.com/early-access/storefront">Don't have access? Request early access →</a></p>
134
287
  </>
@@ -141,7 +294,10 @@ if (devMode) {
141
294
  <label for="email">Work email</label>
142
295
  <input id="email" name="email" type="email" value={email}
143
296
  autocomplete="email" placeholder="you@company.com" required autofocus />
144
- <button type="submit">Email me a code</button>
297
+ <button type="submit">
298
+ Email me a code
299
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 6.5h16v11H4z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m4.5 7 7.5 5.5L19.5 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
300
+ </button>
145
301
  </form>
146
302
  <p class="note">Access is granted by role — no signup needed. Don't have access yet? <a href="https://mcp.tokenoftrust.com/early-access/storefront">Request early access →</a></p>
147
303
  </>
@@ -10,6 +10,10 @@
10
10
  * in a hidden form field and leaves only via the POST.
11
11
  */
12
12
  import { normalizeEmailHint, redactEmailForHint } from "@/lib/privacy/emailHint";
13
+ // Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
14
+ // the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
15
+ // Inter, and JetBrains Mono used by this page's inline styles.
16
+ import "@/styles/fonts.css";
13
17
 
14
18
  export const prerender = false;
15
19
 
@@ -44,6 +48,22 @@ const next = (() => {
44
48
  return n.startsWith("/") && !n.startsWith("//") ? n : "/dashboard";
45
49
  })();
46
50
 
51
+ // RFC 9470 step-up: /api/auth/magic-exchange redirects here (not to /auth/login)
52
+ // with `reason` distinct from `error` — see `magicRedirect` there. Unlike the
53
+ // email-code path, this redirect never carries the real email (only the
54
+ // redacted `email_hint`), so we can't render the SMS-completion card here
55
+ // no-JS-safe with a hidden identifier — hand off to /auth/login, which has the
56
+ // full email and the step-up card, once the human re-enters it.
57
+ const reason = url.searchParams.get("reason");
58
+ const requiredAcr = url.searchParams.get("requiredAcr") ?? "";
59
+ const stepUpRequired = reason === "step-up-required";
60
+ const successPending = reason === "success-pending";
61
+ const ACR_LABELS: Record<string, string> = {
62
+ "tot:aal:sms": "a text message (SMS) code",
63
+ };
64
+ const stepUpFactorLabel = requiredAcr ? (ACR_LABELS[requiredAcr] ?? "an additional verification step") : "an additional verification step";
65
+ const loginHref = `/auth/login${next !== "/dashboard" ? `?next=${encodeURIComponent(next)}` : ""}`;
66
+
47
67
  const ERROR_MESSAGES: Record<string, string> = {
48
68
  invalid: "This sign-in link isn't valid or has already been used.",
49
69
  expired: "This sign-in link has expired.",
@@ -77,23 +97,22 @@ const showResendForm = !!errorMessage && !!tenant && resendStatus !== "sent";
77
97
  <meta name="viewport" content="width=device-width, initial-scale=1" />
78
98
  <meta name="robots" content="noindex, nofollow" />
79
99
  <title>Confirm sign in — Regulated Storefront by Token of Trust©</title>
80
- <link rel="preconnect" href="https://fonts.googleapis.com" />
81
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
82
- <link
83
- rel="stylesheet"
84
- href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;600&display=swap"
85
- />
100
+ {/* Web fonts are self-hosted via `@/styles/fonts.css` (imported above). */}
86
101
  <style nonce={cspNonce}>
87
- /* Signed-in developer cockpit design system — the sign-in door matches the
88
- room it opens into (apps/storefront/src/pages/cockpit.astro). Theme via the
89
- token triple: :root default light, @media dark, and [data-theme] overrides. */
102
+ /* storefront.tokenoftrust.store visual system — the sign-in door wears the
103
+ same skin as the marketing storefront it belongs to: warm cream + deep
104
+ navy, the signature teal→blue gradient (--grad), Plus Jakarta / Inter /
105
+ JetBrains. Token values mirror /tenants/home/pages/storefront.css. Theme
106
+ via the triple: :root default light, @media dark (the site's dark-hero
107
+ palette), and [data-theme] overrides. */
90
108
  :root {
91
- --bg: #f7f9fc; --surface: #ffffff;
92
- --ink: #0a2540; --ink-2: #33415b; --muted: #55637a; --faint: #8593a8;
93
- --accent: #0e9f8e; --accent-ink: #0b7a6d; --accent-wash: #e7f6f4; --accent-line: #b8e6df;
94
- --border: #e3e8ef;
95
- --warn: #c23b3b; --warn-wash: #fbecec; --warn-line: #f0cccc;
96
- --sh: 0 10px 34px -14px rgba(10, 37, 64, .22);
109
+ --bg: #faf8f4; --surface: #ffffff;
110
+ --ink: #10294b; --ink-2: #1b2a40; --muted: #4d5a70; --faint: #8593a8;
111
+ --accent: #0d9391; --accent-ink: #0b6360; --accent-wash: #e4f4f2; --accent-line: #c1e7e2;
112
+ --border: #e6e2d9;
113
+ --warn: #bf3d2c; --warn-wash: #fbecea; --warn-line: #f0ccc6;
114
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
115
+ --sh: 0 18px 50px rgba(16, 41, 75, .13);
97
116
  --display: "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
98
117
  --body: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
99
118
  --mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
@@ -101,31 +120,34 @@ const showResendForm = !!errorMessage && !!tenant && resendStatus !== "sent";
101
120
  }
102
121
  @media (prefers-color-scheme: dark) {
103
122
  :root {
104
- --bg: #071320; --surface: #0e2134;
105
- --ink: #eaf1f8; --ink-2: #bdccdc; --muted: #90a3b7; --faint: #6c8098;
106
- --accent: #2fd4bf; --accent-ink: #7fe7da; --accent-wash: #0f2e30; --accent-line: #1e514b;
107
- --border: #1b3348;
123
+ --bg: #121118; --surface: #1b1a26;
124
+ --ink: #f3f5f9; --ink-2: #c7cede; --muted: #98a1b5; --faint: #6f7994;
125
+ --accent: #5fe0cf; --accent-ink: #7fe3d6; --accent-wash: #10302d; --accent-line: #1f524c;
126
+ --border: #2a2836;
108
127
  --warn: #f2857f; --warn-wash: #2a1414; --warn-line: #5a2626;
109
- --sh: 0 18px 44px -22px rgba(0, 0, 0, .7);
128
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
129
+ --sh: 0 18px 44px -18px rgba(0, 0, 0, .6);
110
130
  color-scheme: dark;
111
131
  }
112
132
  }
113
133
  :root[data-theme="dark"] {
114
- --bg: #071320; --surface: #0e2134;
115
- --ink: #eaf1f8; --ink-2: #bdccdc; --muted: #90a3b7; --faint: #6c8098;
116
- --accent: #2fd4bf; --accent-ink: #7fe7da; --accent-wash: #0f2e30; --accent-line: #1e514b;
117
- --border: #1b3348;
134
+ --bg: #121118; --surface: #1b1a26;
135
+ --ink: #f3f5f9; --ink-2: #c7cede; --muted: #98a1b5; --faint: #6f7994;
136
+ --accent: #5fe0cf; --accent-ink: #7fe3d6; --accent-wash: #10302d; --accent-line: #1f524c;
137
+ --border: #2a2836;
118
138
  --warn: #f2857f; --warn-wash: #2a1414; --warn-line: #5a2626;
119
- --sh: 0 18px 44px -22px rgba(0, 0, 0, .7);
139
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
140
+ --sh: 0 18px 44px -18px rgba(0, 0, 0, .6);
120
141
  color-scheme: dark;
121
142
  }
122
143
  :root[data-theme="light"] {
123
- --bg: #f7f9fc; --surface: #ffffff;
124
- --ink: #0a2540; --ink-2: #33415b; --muted: #55637a; --faint: #8593a8;
125
- --accent: #0e9f8e; --accent-ink: #0b7a6d; --accent-wash: #e7f6f4; --accent-line: #b8e6df;
126
- --border: #e3e8ef;
127
- --warn: #c23b3b; --warn-wash: #fbecec; --warn-line: #f0cccc;
128
- --sh: 0 10px 34px -14px rgba(10, 37, 64, .22);
144
+ --bg: #faf8f4; --surface: #ffffff;
145
+ --ink: #10294b; --ink-2: #1b2a40; --muted: #4d5a70; --faint: #8593a8;
146
+ --accent: #0d9391; --accent-ink: #0b6360; --accent-wash: #e4f4f2; --accent-line: #c1e7e2;
147
+ --border: #e6e2d9;
148
+ --warn: #bf3d2c; --warn-wash: #fbecea; --warn-line: #f0ccc6;
149
+ --grad: linear-gradient(115deg, #0d9391, #3b82c4);
150
+ --sh: 0 18px 50px rgba(16, 41, 75, .13);
129
151
  color-scheme: light;
130
152
  }
131
153
 
@@ -148,14 +170,14 @@ const showResendForm = !!errorMessage && !!tenant && resendStatus !== "sent";
148
170
  }
149
171
  .card::before {
150
172
  content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
151
- background: linear-gradient(90deg, var(--accent), var(--accent-line));
173
+ background: var(--grad);
152
174
  }
153
175
  @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
154
176
 
155
177
  .mark {
156
178
  width: 46px; height: 46px; border-radius: 13px; display: grid; place-items: center;
157
- color: #fff; background: linear-gradient(150deg, var(--accent), var(--accent-ink));
158
- box-shadow: 0 6px 18px -6px rgba(14, 159, 142, .75); position: relative;
179
+ color: #fff; background: var(--grad);
180
+ box-shadow: 0 8px 20px -6px rgba(13, 147, 145, .6); position: relative;
159
181
  }
160
182
  .mark::after {
161
183
  content: ""; position: absolute; inset: -5px; border-radius: 17px;
@@ -194,12 +216,12 @@ const showResendForm = !!errorMessage && !!tenant && resendStatus !== "sent";
194
216
  form { margin: 0; }
195
217
  .cta {
196
218
  width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: .55rem;
197
- font-family: var(--display); font-weight: 700; font-size: 1rem; color: #fff; background: var(--accent);
219
+ font-family: var(--display); font-weight: 700; font-size: 1rem; color: #fff; background: var(--grad);
198
220
  border: 0; border-radius: 999px; padding: .9rem 1.3rem; cursor: pointer;
199
- box-shadow: 0 10px 26px -10px rgba(14, 159, 142, .85);
200
- transition: transform .12s, background .12s, box-shadow .12s;
221
+ box-shadow: 0 10px 24px rgba(13, 147, 145, .32);
222
+ transition: transform .12s, box-shadow .12s, filter .12s;
201
223
  }
202
- .cta:hover { transform: translateY(-1px); background: var(--accent-ink); box-shadow: 0 14px 30px -10px rgba(14, 159, 142, .95); }
224
+ .cta:hover { transform: translateY(-1px); filter: saturate(1.08); box-shadow: 0 14px 30px rgba(13, 147, 145, .4); }
203
225
  .cta:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
204
226
  .cta svg { width: 17px; height: 17px; }
205
227
 
@@ -253,7 +275,17 @@ const showResendForm = !!errorMessage && !!tenant && resendStatus !== "sent";
253
275
  <svg viewBox="0 0 24 24" fill="none"><path d="M12 2.5 20 6v6c0 4.6-3.2 7.9-8 9.5-4.8-1.6-8-4.9-8-9.5V6l8-3.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m8.5 12 2.4 2.4L15.8 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
254
276
  </span>
255
277
  <p class="eyebrow">Token of Trust© · Regulated Storefront</p>
256
- {resendStatus === "sent" ? (
278
+ {stepUpRequired || successPending ? (
279
+ <>
280
+ <h1>{successPending ? "Almost there" : "Verify with SMS to continue"}</h1>
281
+ <p class="sub">
282
+ {successPending
283
+ ? "Your identity is verified — sign-in just needs one more step: verify with SMS to continue."
284
+ : `Your email is confirmed. For extra security, please verify ${stepUpFactorLabel} before we can finish signing you in.`}
285
+ </p>
286
+ <p class="altrow"><a class="altlink" href={loginHref}>Continue with SMS verification →</a></p>
287
+ </>
288
+ ) : resendStatus === "sent" ? (
257
289
  <>
258
290
  <h1>Check your email</h1>
259
291
  <p class="sub">If that address is still on this store's invite list, a fresh sign-in link is on the way.</p>
@@ -286,8 +318,8 @@ const showResendForm = !!errorMessage && !!tenant && resendStatus !== "sent";
286
318
  </>
287
319
  ) : (
288
320
  <>
289
- <h1>Confirm sign in</h1>
290
- <p class="sub">Confirm it's you to finish signing in.</p>
321
+ <h1>Accept your invitation</h1>
322
+ <p class="sub">Confirm it's you to accept this invitation and finish signing in.</p>
291
323
  {appLabel ? (
292
324
  <div class="ident">
293
325
  <span class="ava" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="M4 9.5 5.2 5h13.6L20 9.5M4 9.5V19h16V9.5M4 9.5h16M9 19v-5h6v5" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>
@@ -310,13 +342,13 @@ const showResendForm = !!errorMessage && !!tenant && resendStatus !== "sent";
310
342
  <input type="hidden" name="email_hint" value={emailHint} />
311
343
  <input type="hidden" name="next" value={next} />
312
344
  <button class="cta" type="submit">
313
- Confirm sign in
345
+ Accept &amp; sign in
314
346
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
315
347
  </button>
316
348
  </form>
317
349
  <p class="note">
318
350
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="2"/></svg>
319
- This is a single-use link — confirming completes your sign-in.
351
+ This is a single-use link — accepting completes your sign-in.
320
352
  </p>
321
353
  </>
322
354
  )}
@@ -0,0 +1,107 @@
1
+ ---
2
+ import Layout from "@/layouts/Layout.astro";
3
+ import { getBlogPost, resolveBlogRedirect } from "@/lib/blog/provider.js";
4
+ import { breadcrumbLd } from "@/lib/jsonld.js";
5
+
6
+ const { tenant, canonicalBase, basePath } = Astro.locals;
7
+ const slug = Astro.params.slug ?? "";
8
+ const post = await getBlogPost(tenant.tenant_id, slug);
9
+ if (!post) {
10
+ const redirect = await resolveBlogRedirect(tenant.tenant_id, slug);
11
+ if (redirect) return Astro.redirect(`${basePath}/blog/${redirect}/`, 301);
12
+ return new Response(null, { status: 404 });
13
+ }
14
+
15
+ const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString();
16
+ const articleLd = {
17
+ "@context": "https://schema.org",
18
+ "@type": "BlogPosting",
19
+ headline: post.title,
20
+ description: post.description,
21
+ url: canonical,
22
+ mainEntityOfPage: canonical,
23
+ datePublished: post.publishedAt,
24
+ dateModified: post.modifiedAt,
25
+ author: { "@type": "Person", name: post.author },
26
+ publisher: { "@type": "Organization", name: tenant.displayName, url: canonicalBase },
27
+ ...(post.featuredImage ? { image: new URL(post.featuredImage.src, canonicalBase + "/").toString() } : {}),
28
+ };
29
+ const crumbs = breadcrumbLd([
30
+ { label: tenant.displayName, href: "/" },
31
+ { label: "Blog", href: "/blog/" },
32
+ { label: post.title, href: `/blog/${post.slug}/` },
33
+ ], canonicalBase);
34
+ const blogHref = `${basePath}/blog/`;
35
+ ---
36
+
37
+ <Layout
38
+ title={post.seoTitle || post.title}
39
+ description={post.description}
40
+ path={`/blog/${post.slug}/`}
41
+ ogType="article"
42
+ ogImage={post.featuredImage?.src}
43
+ jsonLd={[crumbs, articleLd]}
44
+ >
45
+ <article class="article-shell">
46
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={blogHref}>Blog</a><span aria-hidden="true">/</span><span>{post.categories[0] ?? "Article"}</span></nav>
47
+ <header>
48
+ {post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
49
+ <h1>{post.title}</h1>
50
+ <p class="dek">{post.excerpt}</p>
51
+ <div class="byline">
52
+ <span>By {post.author}</span>
53
+ <span aria-hidden="true">·</span>
54
+ <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
55
+ </div>
56
+ </header>
57
+ {post.featuredImage && (
58
+ <img
59
+ class="featured"
60
+ src={post.featuredImage.src}
61
+ alt={post.featuredImage.alt}
62
+ width={post.featuredImage.width}
63
+ height={post.featuredImage.height}
64
+ fetchpriority="high"
65
+ decoding="async"
66
+ />
67
+ )}
68
+ <div class="article-grid">
69
+ <aside>
70
+ <a href={blogHref}>← All articles</a>
71
+ {post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
72
+ </aside>
73
+ <div class="blog-body" set:html={post.contentHtml}></div>
74
+ </div>
75
+ </article>
76
+ </Layout>
77
+
78
+ <style>
79
+ .article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
80
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; color: var(--color-muted); font-size: .84rem; }
81
+ .breadcrumbs a { color: var(--color-accent-ink); font-weight: 700; }
82
+ header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
83
+ header .eyebrow { justify-content: center; }
84
+ h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
85
+ .dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
86
+ .byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
87
+ .featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
88
+ .article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
89
+ aside { align-self: start; position: sticky; top: 7rem; }
90
+ aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
91
+ .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
92
+ .article-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
93
+ .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
94
+ .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; }
95
+ .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; }
96
+ .blog-body :global(h2) { font-size: clamp(1.75rem, 3vw, 2.35rem); }
97
+ .blog-body :global(h3) { font-size: clamp(1.35rem, 2.4vw, 1.75rem); }
98
+ .blog-body :global(a) { color: var(--color-accent-ink); text-decoration: underline; text-underline-offset: .18em; }
99
+ .blog-body :global(ul), .blog-body :global(ol) { padding-left: 1.4rem; }
100
+ .blog-body :global(li) { margin-block: .45rem; }
101
+ .blog-body :global(blockquote) { padding: .8rem 1.25rem; border-left: 4px solid var(--color-accent); background: var(--color-surface); color: var(--color-muted); }
102
+ .blog-body :global(img) { display: block; width: auto; max-width: 100%; margin-inline: auto; border-radius: var(--radius-md); }
103
+ .blog-body :global(figcaption) { margin-top: .5rem; color: var(--color-muted); text-align: center; font-size: .78rem; }
104
+ .blog-body :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
105
+ .blog-body :global(th), .blog-body :global(td) { padding: .7rem; border: 1px solid var(--color-border); text-align: left; }
106
+ @media (max-width: 760px) { .article-grid { grid-template-columns: 1fr; } aside { position: static; } .article-tags { display: none; } }
107
+ </style>