@atelic-action/ui 0.17.2 → 1.1.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 (188) hide show
  1. package/README.md +432 -45
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Note.tsx +9 -39
  104. package/src/artifact/Paragraph.tsx +65 -0
  105. package/src/artifact/Read.tsx +406 -0
  106. package/src/artifact/Writeup.tsx +7 -7
  107. package/src/artifact/index.ts +1 -0
  108. package/src/chrome/SiteShell.tsx +141 -0
  109. package/src/chrome/index.ts +2 -0
  110. package/src/components/Disclosure.tsx +80 -14
  111. package/src/components/GateLogin.tsx +1 -1
  112. package/src/components/Watermark.tsx +1 -1
  113. package/src/components/index.ts +6 -2
  114. package/src/gate/index.ts +1 -1
  115. package/src/gate/middleware.ts +17 -6
  116. package/src/gate/wall.ts +60 -2
  117. package/src/lib/basePathAudit.ts +96 -0
  118. package/src/lib/displayDate.ts +25 -0
  119. package/src/lib/honeypot.ts +11 -0
  120. package/src/lib/hours.ts +28 -0
  121. package/src/lib/sitemap.ts +34 -0
  122. package/src/lib/trackEvent.ts +26 -0
  123. package/src/lib/useOpenHeight.ts +28 -0
  124. package/src/primitives/Button.tsx +3 -1
  125. package/src/primitives/Chip.tsx +3 -1
  126. package/src/primitives/ImageFrame.tsx +48 -0
  127. package/src/primitives/ProseBlocks.tsx +67 -0
  128. package/src/primitives/QuoteCard.tsx +20 -0
  129. package/src/primitives/StarRow.tsx +37 -0
  130. package/src/primitives/TickList.tsx +24 -0
  131. package/src/primitives/index.ts +5 -0
  132. package/src/routing/BasePathProvider.tsx +47 -0
  133. package/src/routing/basePath.ts +57 -0
  134. package/src/routing/index.ts +8 -0
  135. package/src/scripts/check-base-path.ts +130 -0
  136. package/src/scripts/clean-sitemap.ts +27 -0
  137. package/src/scripts/contact-canary.ts +98 -0
  138. package/src/scripts/serve-static.ts +29 -3
  139. package/src/sections/BlogIndex.tsx +57 -0
  140. package/src/sections/BlogPost.tsx +65 -0
  141. package/src/sections/CTABanner.tsx +93 -0
  142. package/src/sections/CTASection.tsx +60 -0
  143. package/src/sections/ComparisonTable.tsx +114 -0
  144. package/src/sections/ContactForm.tsx +163 -0
  145. package/src/sections/ContactInfoCard.tsx +92 -0
  146. package/src/sections/CredentialList.tsx +28 -0
  147. package/src/sections/DeviceShowcase.tsx +39 -0
  148. package/src/sections/ExpandList.tsx +58 -0
  149. package/src/sections/FAQBrowser.tsx +283 -0
  150. package/src/sections/FeatureDetail.tsx +60 -0
  151. package/src/sections/FeatureGrid.tsx +83 -0
  152. package/src/sections/Gallery.tsx +84 -0
  153. package/src/sections/Hero.tsx +110 -0
  154. package/src/sections/HoursTable.tsx +23 -0
  155. package/src/sections/JumpRail.tsx +35 -0
  156. package/src/sections/LegalPage.tsx +43 -0
  157. package/src/sections/LocationSection.tsx +66 -0
  158. package/src/sections/Locations.tsx +71 -0
  159. package/src/sections/LogoMarquee.tsx +27 -0
  160. package/src/sections/MapEmbed.tsx +75 -0
  161. package/src/sections/MediaBand.tsx +20 -0
  162. package/src/sections/MenuList.tsx +44 -0
  163. package/src/sections/PageHero.tsx +25 -0
  164. package/src/sections/PressQuotes.tsx +51 -0
  165. package/src/sections/PricingBand.tsx +43 -0
  166. package/src/sections/PricingCard.tsx +36 -0
  167. package/src/sections/ProcessSteps.tsx +45 -0
  168. package/src/sections/ProfileCards.tsx +73 -0
  169. package/src/sections/RevealSlider.tsx +156 -0
  170. package/src/sections/ReviewCarousel.tsx +223 -0
  171. package/src/sections/ReviewWall.tsx +220 -0
  172. package/src/sections/SocialStrip.tsx +83 -0
  173. package/src/sections/SplitSection.tsx +56 -0
  174. package/src/sections/index.ts +76 -0
  175. package/src/sections/siteConfig.ts +201 -0
  176. package/src/styles/app.css +673 -0
  177. package/src/styles/artifact.css +759 -401
  178. package/src/styles/base.css +49 -47
  179. package/src/styles/chrome.css +71 -71
  180. package/src/styles/components.css +150 -140
  181. package/src/styles/gate.css +29 -29
  182. package/src/styles/primitives.css +190 -25
  183. package/src/styles/sections.css +2652 -0
  184. package/src/styles/star-rating.css +2 -2
  185. package/src/vite/index.ts +9 -2
  186. package/src/vite/marketing-config.ts +128 -0
  187. package/src/components/Collapsible.tsx +0 -43
  188. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The contact relay canary: submits a token bearing test message to the live
3
+ * site's /api/contact and asserts the whole pipe worked. A pass means the
4
+ * function is deployed, its env is present, the Resend key is valid, the
5
+ * sending domain is verified, Resend accepted the message (200 plus an id),
6
+ * and the client destination the function is configured with is the one
7
+ * expected. The canary lands in CONTACT_CANARY_TO, never the client's inbox.
8
+ *
9
+ * Env: SITE_URL, CONTACT_EXPECTED_TO, CONTACT_CANARY_TOKEN.
10
+ * Usage: atelic-contact-canary (a site's daily contact-canary workflow runs it)
11
+ * Exits 1 on any failure, naming the failed assertion.
12
+ *
13
+ * A SITE_URL on the reserved `.invalid` TLD (`https://canary.invalid`) is the
14
+ * explicit off switch, for a repo with no live site (a template): the run
15
+ * passes without submitting anything. An unset SITE_URL still fails, so a
16
+ * site that never configured the canary is caught, not silently skipped.
17
+ */
18
+
19
+ /** The environment the canary reads: SITE_URL, CONTACT_EXPECTED_TO, and CONTACT_CANARY_TOKEN. */
20
+ export type CanaryEnv = Record<string, string | undefined>;
21
+
22
+ export interface CanaryResult {
23
+ verdict: "pass" | "skip" | "fail";
24
+ message: string;
25
+ }
26
+
27
+ const fail = (reason: string): CanaryResult => ({ verdict: "fail", message: `FAIL: ${reason}` });
28
+
29
+ /**
30
+ * Runs the canary against the site the environment names. `fetcher` is the
31
+ * fetch to send with, so a test can stand in for the network.
32
+ */
33
+ export async function runCanary(
34
+ env: CanaryEnv,
35
+ fetcher: typeof fetch = fetch,
36
+ now: Date = new Date(),
37
+ ): Promise<CanaryResult> {
38
+ const site = env.SITE_URL?.replace(/\/$/, "");
39
+ if (!site) return fail("SITE_URL is not set");
40
+ let host: string;
41
+ try {
42
+ host = new URL(site).hostname;
43
+ } catch {
44
+ return fail(`SITE_URL is not a URL: ${site}`);
45
+ }
46
+ if (host.endsWith(".invalid")) {
47
+ return { verdict: "skip", message: `skipped: SITE_URL is ${site}, the explicit off switch` };
48
+ }
49
+ const expectedTo = env.CONTACT_EXPECTED_TO;
50
+ const token = env.CONTACT_CANARY_TOKEN;
51
+ if (!expectedTo) return fail("CONTACT_EXPECTED_TO is not set");
52
+ if (!token) return fail("CONTACT_CANARY_TOKEN is not set");
53
+
54
+ const endpoint = `${site}/api/contact`;
55
+ let response: Response;
56
+ try {
57
+ response = await fetcher(endpoint, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ signal: AbortSignal.timeout(20000),
61
+ body: JSON.stringify({
62
+ name: "Contact canary",
63
+ email: "canary@atelic.me",
64
+ topic: "Canary",
65
+ message: `Automated contact relay check at ${now.toISOString()}. If you are reading this in the client inbox, CONTACT_CANARY_TO is misconfigured.`,
66
+ company: "",
67
+ canary: token,
68
+ }),
69
+ });
70
+ } catch (error) {
71
+ return fail(`request failed: ${error instanceof Error ? error.message : String(error)}`);
72
+ }
73
+
74
+ const body = (await response.json().catch(() => ({}))) as {
75
+ ok?: boolean;
76
+ id?: string | null;
77
+ to?: string;
78
+ error?: string;
79
+ };
80
+ if (response.status !== 200 || body.ok !== true) {
81
+ return fail(`relay returned ${response.status}${body.error ? `: ${body.error}` : ""}`);
82
+ }
83
+ if (!body.id) return fail("Resend accepted the message without an id (canary path not taken?)");
84
+ if (body.to !== expectedTo) {
85
+ return fail(`configured destination is ${body.to ?? "(none)"}, expected ${expectedTo}`);
86
+ }
87
+ return { verdict: "pass", message: `ok: Resend id ${body.id}, destination ${body.to}` };
88
+ }
89
+
90
+ /** The command: runs the canary the environment configures. */
91
+ export async function main(): Promise<void> {
92
+ const result = await runCanary(process.env);
93
+ if (result.verdict === "fail") {
94
+ console.error(`[contact-canary] ${result.message}`);
95
+ process.exit(1);
96
+ }
97
+ console.log(`[contact-canary] ${result.message}`);
98
+ }
@@ -5,10 +5,35 @@
5
5
  * never runs a server. The build it serves is the site's, found from the
6
6
  * working directory.
7
7
  *
8
+ * A marketing build served under a base path (its config's `basePath`, or the
9
+ * `VITE_BASE_PATH` rehearsal override) is served both ways it is served for
10
+ * real: under the prefix, as the cut's own Vercel host does through its two
11
+ * rewrites, and without it, as the host owner's proxy forwards each request
12
+ * with the prefix stripped (moved from template-marketing in 0.19.0).
13
+ *
8
14
  * Usage: atelic-serve-static (PORT sets the port, 4173 unless given)
9
15
  */
10
16
  import { existsSync, statSync } from "node:fs";
11
17
  import { join, normalize } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { normalizeBasePath, stripBase } from "../routing/basePath";
20
+ import { siteConfigPath } from "./site-config";
21
+
22
+ /**
23
+ * The base path the build in `cwd` was made under: `VITE_BASE_PATH` when set,
24
+ * else the `basePath` its src/site.config.ts exports, else the root. A site
25
+ * with no config, or one whose config carries no base, is served at the root.
26
+ */
27
+ export async function siteBasePath(
28
+ cwd: string,
29
+ env: Record<string, string | undefined> = process.env,
30
+ ): Promise<string> {
31
+ if (env.VITE_BASE_PATH !== undefined) return normalizeBasePath(env.VITE_BASE_PATH);
32
+ const path = siteConfigPath(cwd);
33
+ if (!existsSync(path)) return "";
34
+ const module = (await import(pathToFileURL(path).href)) as { site?: { basePath?: string } };
35
+ return normalizeBasePath(module.site?.basePath);
36
+ }
12
37
 
13
38
  /** The file a request path names under `root`, by Vercel's clean URL rules; none on a miss. */
14
39
  export function resolveFile(root: string, pathname: string): string | undefined {
@@ -24,9 +49,10 @@ export function resolveFile(root: string, pathname: string): string | undefined
24
49
  }
25
50
 
26
51
  /** The command: serves dist/client of the working directory until stopped. */
27
- export function main(cwd: string = process.cwd()): void {
52
+ export async function main(cwd: string = process.cwd()): Promise<void> {
28
53
  const root = join(cwd, "dist", "client");
29
54
  const port = Number(process.env.PORT ?? 4173);
55
+ const base = await siteBasePath(cwd);
30
56
 
31
57
  Bun.serve({
32
58
  // Loopback only: this serves the build to the local browser under test and
@@ -34,7 +60,7 @@ export function main(cwd: string = process.cwd()): void {
34
60
  hostname: "127.0.0.1",
35
61
  port,
36
62
  fetch(request) {
37
- const file = resolveFile(root, new URL(request.url).pathname);
63
+ const file = resolveFile(root, stripBase(base, new URL(request.url).pathname));
38
64
  if (file) return new Response(Bun.file(file));
39
65
  return new Response(Bun.file(join(root, "404.html")), {
40
66
  status: 404,
@@ -43,5 +69,5 @@ export function main(cwd: string = process.cwd()): void {
43
69
  },
44
70
  });
45
71
 
46
- console.log(`serving ${root} on http://127.0.0.1:${port}`);
72
+ console.log(`serving ${root} on http://127.0.0.1:${port}${base ? ` (and under ${base})` : ""}`);
47
73
  }
@@ -0,0 +1,57 @@
1
+ import { ArrowUpRight } from "lucide-react";
2
+ import { displayDate } from "../lib/displayDate";
3
+ import { useWithBase } from "../routing/BasePathProvider";
4
+
5
+ export interface BlogIndexItem {
6
+ slug: string;
7
+ title: string;
8
+ description?: string;
9
+ publishedAt: string;
10
+ /** Reading time in minutes, when the caller has computed one. */
11
+ readingMinutes?: number;
12
+ }
13
+
14
+ export interface BlogIndexProps {
15
+ items: BlogIndexItem[];
16
+ /** Base path the slugs hang from. Defaults to "/blog". */
17
+ basePath?: string;
18
+ id?: string;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * The blog listing: every post, newest first, as a plain readable stack
24
+ * rather than a card grid. These are articles, not products, and a list
25
+ * that reads top to bottom suits a small archive better than a gallery
26
+ * that pads it out.
27
+ *
28
+ * Every entry is a real anchor to a prerendered page, which is what lets
29
+ * the build's link crawler discover the posts without an explicit page
30
+ * list in the vite config.
31
+ */
32
+ export function BlogIndex({ items, basePath = "/blog", id, className }: BlogIndexProps) {
33
+ const withBase = useWithBase();
34
+ return (
35
+ <section className={["section", "blog-index", className].filter(Boolean).join(" ")} id={id}>
36
+ <div className="wrap">
37
+ <ol className="bi-list">
38
+ {items.map((item) => (
39
+ <li className="bi-item" key={item.slug}>
40
+ <a className="bi-link" href={withBase(`${basePath}/${item.slug}`)}>
41
+ <span className="bi-meta">
42
+ <time dateTime={item.publishedAt}>{displayDate(item.publishedAt)}</time>
43
+ {item.readingMinutes ? <span> · {item.readingMinutes} min read</span> : null}
44
+ </span>
45
+ <h2 className="bi-title">
46
+ {item.title}
47
+ <ArrowUpRight className="bi-arrow" size={20} strokeWidth={2} aria-hidden="true" />
48
+ </h2>
49
+ {item.description && <p className="bi-excerpt">{item.description}</p>}
50
+ </a>
51
+ </li>
52
+ ))}
53
+ </ol>
54
+ </div>
55
+ </section>
56
+ );
57
+ }
@@ -0,0 +1,65 @@
1
+ import { ArrowLeft } from "lucide-react";
2
+ import { displayDate } from "../lib/displayDate";
3
+ import { type ProseBlock, ProseBlocks } from "../primitives/ProseBlocks";
4
+ import { useWithBase } from "../routing/BasePathProvider";
5
+
6
+ export interface BlogPostDoc {
7
+ title: string;
8
+ description?: string;
9
+ publishedAt: string;
10
+ updatedAt?: string | null;
11
+ author?: string;
12
+ blocks: ProseBlock[];
13
+ }
14
+
15
+ export interface BlogPostProps {
16
+ doc: BlogPostDoc;
17
+ /** Where the "all posts" link goes. Defaults to "/blog". */
18
+ basePath?: string;
19
+ backLabel?: string;
20
+ id?: string;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * A single post: byline, dateline, body, and a way back to the archive.
26
+ *
27
+ * The article's own `h1` lives here rather than in a PageHero, because a post
28
+ * title is the content rather than a page banner, and a hero would push 800
29
+ * words of the thing someone came to read below the fold.
30
+ *
31
+ * `dateModified` renders only when the source carries one. A "last updated"
32
+ * line invented at port time would be a freshness claim about writing nobody
33
+ * has touched.
34
+ */
35
+ export function BlogPost({
36
+ doc,
37
+ basePath = "/blog",
38
+ backLabel = "All posts",
39
+ id,
40
+ className,
41
+ }: BlogPostProps) {
42
+ const withBase = useWithBase();
43
+ return (
44
+ <article className={["section", "blog-post", className].filter(Boolean).join(" ")} id={id}>
45
+ <div className="wrap bp-wrap">
46
+ <a className="bp-back" href={withBase(basePath)}>
47
+ <ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />
48
+ {backLabel}
49
+ </a>
50
+
51
+ <h1 className="bp-title">{doc.title}</h1>
52
+
53
+ <p className="bp-meta">
54
+ {doc.author && <span className="bp-author">{doc.author}</span>}
55
+ <time dateTime={doc.publishedAt}>{displayDate(doc.publishedAt)}</time>
56
+ {doc.updatedAt && doc.updatedAt !== doc.publishedAt && (
57
+ <span className="bp-updated">Updated {displayDate(doc.updatedAt)}</span>
58
+ )}
59
+ </p>
60
+
61
+ <ProseBlocks blocks={doc.blocks} className="bp-body" />
62
+ </div>
63
+ </article>
64
+ );
65
+ }
@@ -0,0 +1,93 @@
1
+ import type { ReactNode } from "react";
2
+ import { Eyebrow, Lead } from "../primitives";
3
+ import { Button } from "../primitives/Button";
4
+ import { useWithBase } from "../routing/BasePathProvider";
5
+
6
+ export interface CTABannerCTA {
7
+ label: string;
8
+ href: string;
9
+ newTab?: boolean;
10
+ }
11
+
12
+ export interface CTABannerProps {
13
+ id?: string;
14
+ eyebrow?: string;
15
+ /** Headline; use <em className="serif"> spans for the script accent. */
16
+ title: ReactNode;
17
+ lead?: ReactNode;
18
+ /**
19
+ * Full-bleed background image behind the content. Omit it and the banner
20
+ * renders the token gradient fallback (the Hero treatment), so the section
21
+ * works before photography exists.
22
+ */
23
+ image?: { src: string; alt?: string };
24
+ primaryCta?: CTABannerCTA;
25
+ secondaryCta?: CTABannerCTA;
26
+ /**
27
+ * Wash over the image so the copy pops: "dark" (light text over a dark
28
+ * gradient, the hero treatment) or "light" (ink text over a surface wash,
29
+ * for bright imagery). The wash fades toward the far side so the image
30
+ * still reads.
31
+ */
32
+ tone?: "dark" | "light";
33
+ }
34
+
35
+ /**
36
+ * Mid-page full-bleed CTA banner: the image spans the viewport as a backdrop
37
+ * with eyebrow, title, lead, and actions overlaid. The section-scale sibling
38
+ * of Hero — same treatment, banner height, usable anywhere in the page flow.
39
+ */
40
+ export function CTABanner({
41
+ id,
42
+ eyebrow,
43
+ title,
44
+ lead,
45
+ image,
46
+ primaryCta,
47
+ secondaryCta,
48
+ tone = "dark",
49
+ }: CTABannerProps) {
50
+ const withBase = useWithBase();
51
+ const onDark = tone === "dark";
52
+ return (
53
+ <section className={["cta-banner", tone].join(" ")} id={id}>
54
+ {image ? (
55
+ <img
56
+ className="cta-banner-media"
57
+ src={withBase(image.src)}
58
+ alt={image.alt ?? ""}
59
+ loading="lazy"
60
+ />
61
+ ) : (
62
+ <div className="cta-banner-media cta-banner-fallback" aria-hidden="true" />
63
+ )}
64
+ <div className="cta-banner-wash" aria-hidden="true" />
65
+ <div className="wrap">
66
+ <div className="cta-banner-content">
67
+ {eyebrow && <Eyebrow onDark={onDark}>{eyebrow}</Eyebrow>}
68
+ <h2>{title}</h2>
69
+ {lead && <Lead>{lead}</Lead>}
70
+ {(primaryCta || secondaryCta) && (
71
+ <div className="cta-banner-actions">
72
+ {primaryCta && (
73
+ <Button href={primaryCta.href} newTab={primaryCta.newTab} arrow>
74
+ {primaryCta.label}
75
+ </Button>
76
+ )}
77
+ {secondaryCta && (
78
+ <Button
79
+ variant="ghost"
80
+ onDark={onDark}
81
+ href={secondaryCta.href}
82
+ newTab={secondaryCta.newTab}
83
+ >
84
+ {secondaryCta.label}
85
+ </Button>
86
+ )}
87
+ </div>
88
+ )}
89
+ </div>
90
+ </div>
91
+ </section>
92
+ );
93
+ }
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from "react";
2
+ import { Eyebrow, Lead } from "../primitives";
3
+ import { Button } from "../primitives/Button";
4
+ import type { SiteConfig } from "./siteConfig";
5
+
6
+ export interface CTASectionProps {
7
+ /** The site's primary action, `site.cta`; a whole `SiteConfig` passes as it is. */
8
+ site: Pick<SiteConfig, "cta">;
9
+ eyebrow?: string;
10
+ title: ReactNode;
11
+ lead?: ReactNode;
12
+ /**
13
+ * Overrides the primary action; by default the site's primary CTA
14
+ * (site.cta) renders so every page closes with the same conversion
15
+ * action in the same style.
16
+ */
17
+ primary?: { label: string; href: string; newTab?: boolean };
18
+ /** Page-contextual secondary action; always a ghost button, always second. */
19
+ secondary?: { label: string; href: string; newTab?: boolean };
20
+ surface?: "default" | "alt";
21
+ }
22
+
23
+ /** The standard closing CTA: primary action from the site config, ghost secondary. */
24
+ export function CTASection({
25
+ site,
26
+ eyebrow,
27
+ title,
28
+ lead,
29
+ primary,
30
+ secondary,
31
+ surface = "alt",
32
+ }: CTASectionProps) {
33
+ const classes = ["section", "final-cta", surface === "alt" && "surface-alt"]
34
+ .filter(Boolean)
35
+ .join(" ");
36
+ const primaryAction = primary ?? {
37
+ label: site.cta.label,
38
+ href: site.cta.href,
39
+ newTab: site.cta.external,
40
+ };
41
+ return (
42
+ <section className={classes}>
43
+ <div className="wrap center">
44
+ {eyebrow && <Eyebrow center>{eyebrow}</Eyebrow>}
45
+ <h2>{title}</h2>
46
+ {lead && <Lead className="cta-lead">{lead}</Lead>}
47
+ <div className="cta-row center">
48
+ <Button href={primaryAction.href} newTab={primaryAction.newTab} size="lg" arrow>
49
+ {primaryAction.label}
50
+ </Button>
51
+ {secondary && (
52
+ <Button href={secondary.href} newTab={secondary.newTab} variant="ghost" size="lg">
53
+ {secondary.label}
54
+ </Button>
55
+ )}
56
+ </div>
57
+ </div>
58
+ </section>
59
+ );
60
+ }
@@ -0,0 +1,114 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * A graded before and after: one row per graded item, each side carrying a grade and
5
+ * the points behind it. Built for case studies, where the reader's question is
6
+ * "what actually changed", and the answer has to survive being scanned.
7
+ *
8
+ * The grade sits beside its points rather than above them, and an "after"
9
+ * grade takes the primary color only when it differs from its "before", so
10
+ * running an eye down the last column finds every change without reading.
11
+ *
12
+ * Row labels, grades and copy all arrive as props: the component knows
13
+ * nothing about clients or scoring scales.
14
+ */
15
+
16
+ export interface ComparisonState {
17
+ /**
18
+ * A grade for this side. A single character renders as a large letter;
19
+ * anything longer renders as a small mono note, which is how an ungraded
20
+ * state ("not measured") says so without being handed a letter it did not
21
+ * earn.
22
+ */
23
+ grade: string;
24
+ /** What was true. One point renders as prose, several as a list. */
25
+ points: ReactNode[];
26
+ }
27
+
28
+ export interface ComparisonRow {
29
+ id: string;
30
+ /** The row label, e.g. the thing being graded. */
31
+ stage: string;
32
+ before: ComparisonState;
33
+ after: ComparisonState;
34
+ }
35
+
36
+ export interface ComparisonTableProps {
37
+ rows: ComparisonRow[];
38
+ /** Column headers. Defaults suit a before and after. */
39
+ columns?: { stage?: string; before?: string; after?: string };
40
+ /** Small print under the table, e.g. what the grades mean. */
41
+ footnote?: ReactNode;
42
+ className?: string;
43
+ }
44
+
45
+ function Cell({ state, highlight }: { state: ComparisonState; highlight: boolean }) {
46
+ const isLetter = state.grade.length === 1;
47
+ return (
48
+ <div className="cmp-cell">
49
+ <span
50
+ className={["cmp-grade", isLetter ? "is-letter" : "is-note", highlight ? "is-moved" : ""]
51
+ .filter(Boolean)
52
+ .join(" ")}
53
+ >
54
+ {state.grade}
55
+ </span>
56
+ <div className="cmp-points">
57
+ {state.points.length === 1 ? (
58
+ state.points[0]
59
+ ) : (
60
+ <ul>
61
+ {state.points.map((point, i) => (
62
+ // biome-ignore lint/suspicious/noArrayIndexKey: a row's points are static and never reorder.
63
+ <li key={i}>{point}</li>
64
+ ))}
65
+ </ul>
66
+ )}
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export function ComparisonTable({ rows, columns, footnote, className }: ComparisonTableProps) {
73
+ const heads = {
74
+ stage: columns?.stage ?? "Area",
75
+ before: columns?.before ?? "Before",
76
+ after: columns?.after ?? "After",
77
+ };
78
+
79
+ return (
80
+ <div className={["comparison", className].filter(Boolean).join(" ")}>
81
+ <div className="cmp-scroll">
82
+ <table className="cmp-table">
83
+ <colgroup>
84
+ <col className="cmp-col-stage" />
85
+ <col className="cmp-col-half" />
86
+ <col className="cmp-col-half" />
87
+ </colgroup>
88
+ <thead>
89
+ <tr>
90
+ <th>{heads.stage}</th>
91
+ <th>{heads.before}</th>
92
+ <th>{heads.after}</th>
93
+ </tr>
94
+ </thead>
95
+ <tbody>
96
+ {rows.map((row) => (
97
+ <tr key={row.id}>
98
+ <th scope="row">{row.stage}</th>
99
+ <td>
100
+ <Cell state={row.before} highlight={false} />
101
+ </td>
102
+ <td className="is-after">
103
+ <Cell state={row.after} highlight={row.before.grade !== row.after.grade} />
104
+ </td>
105
+ </tr>
106
+ ))}
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+
111
+ {footnote && <p className="cmp-footnote">{footnote}</p>}
112
+ </div>
113
+ );
114
+ }