@pylonsync/create-pylon 0.11.0 → 0.11.3

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 (154) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/_root/AGENTS.md +31 -1
  4. package/templates/agency/AGENTS.md +1 -1
  5. package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
  6. package/templates/agency/app/(marketing)/layout.tsx +36 -64
  7. package/templates/agency/app/(marketing)/not-found.tsx +6 -8
  8. package/templates/agency/app/(marketing)/page.tsx +93 -144
  9. package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
  10. package/templates/agency/app/(marketing)/work/page.tsx +20 -17
  11. package/templates/agency/app/globals.css +15 -6
  12. package/templates/agency/app.ts +10 -0
  13. package/templates/agency/components/marketing.tsx +40 -104
  14. package/templates/agency/lib/site.config.ts +34 -52
  15. package/templates/agency/public/images/hero.jpg +0 -0
  16. package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
  17. package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
  18. package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
  19. package/templates/agency/public/images/work/atlas-health.jpg +0 -0
  20. package/templates/agency/public/images/work/cohort.jpg +0 -0
  21. package/templates/agency/public/images/work/ledger.jpg +0 -0
  22. package/templates/agency/public/images/work/vela.jpg +0 -0
  23. package/templates/ai-chat/AGENTS.md +1 -1
  24. package/templates/ai-studio/AGENTS.md +1 -1
  25. package/templates/barebones/AGENTS.md +1 -1
  26. package/templates/chat/AGENTS.md +1 -1
  27. package/templates/consumer/AGENTS.md +1 -1
  28. package/templates/consumer/README.md +39 -23
  29. package/templates/consumer/app/(app)/explore/page.tsx +13 -0
  30. package/templates/consumer/app/(app)/layout.tsx +17 -0
  31. package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
  32. package/templates/consumer/app/(app)/page.tsx +15 -0
  33. package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
  34. package/templates/consumer/app/error.tsx +1 -1
  35. package/templates/consumer/app/globals.css +25 -0
  36. package/templates/consumer/app/layout.tsx +3 -10
  37. package/templates/consumer/app/llms.ts +10 -10
  38. package/templates/consumer/app/not-found.tsx +9 -22
  39. package/templates/consumer/app/robots.ts +3 -4
  40. package/templates/consumer/app/sitemap.ts +7 -21
  41. package/templates/consumer/app.ts +152 -33
  42. package/templates/consumer/components/social/app-chrome.tsx +109 -0
  43. package/templates/consumer/components/social/avatar.tsx +50 -0
  44. package/templates/consumer/components/social/comment-form.tsx +51 -0
  45. package/templates/consumer/components/social/create-post.tsx +155 -0
  46. package/templates/consumer/components/social/edit-profile.tsx +103 -0
  47. package/templates/consumer/components/social/explore-screen.tsx +23 -0
  48. package/templates/consumer/components/social/home-screen.tsx +161 -0
  49. package/templates/consumer/components/social/post-card.tsx +77 -0
  50. package/templates/consumer/components/social/post-grid.tsx +46 -0
  51. package/templates/consumer/components/social/post-parts.tsx +146 -0
  52. package/templates/consumer/components/social/post-screen.tsx +173 -0
  53. package/templates/consumer/components/social/profile-screen.tsx +146 -0
  54. package/templates/consumer/components/social/rich-text.tsx +22 -0
  55. package/templates/consumer/components/social/session.tsx +43 -0
  56. package/templates/consumer/components/social/use-social.ts +86 -0
  57. package/templates/consumer/functions/addComment.ts +27 -0
  58. package/templates/consumer/functions/createPost.ts +39 -0
  59. package/templates/consumer/functions/ensureProfile.ts +39 -0
  60. package/templates/consumer/functions/seedFeed.ts +38 -0
  61. package/templates/consumer/functions/updateProfile.ts +61 -0
  62. package/templates/consumer/lib/seed.ts +192 -0
  63. package/templates/consumer/lib/site.ts +6 -0
  64. package/templates/consumer/lib/social.ts +245 -0
  65. package/templates/consumer/tests/social.test.ts +157 -0
  66. package/templates/creator/AGENTS.md +1 -1
  67. package/templates/creator/app/(marketing)/layout.tsx +35 -69
  68. package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
  69. package/templates/creator/app/(marketing)/not-found.tsx +8 -6
  70. package/templates/creator/app/(marketing)/page.tsx +90 -106
  71. package/templates/creator/app/globals.css +14 -0
  72. package/templates/creator/app/layout.tsx +1 -0
  73. package/templates/creator/app.ts +10 -0
  74. package/templates/creator/components/marketing.tsx +28 -89
  75. package/templates/creator/lib/site.config.ts +51 -29
  76. package/templates/creator/public/images/headshot.jpg +0 -0
  77. package/templates/crm/AGENTS.md +1 -1
  78. package/templates/crm/lib/seed.ts +153 -21
  79. package/templates/directory/AGENTS.md +1 -1
  80. package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
  81. package/templates/directory/app/(marketing)/layout.tsx +37 -88
  82. package/templates/directory/app/(marketing)/not-found.tsx +11 -10
  83. package/templates/directory/app/(marketing)/page.tsx +12 -33
  84. package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
  85. package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
  86. package/templates/directory/app/globals.css +15 -4
  87. package/templates/directory/app.ts +10 -0
  88. package/templates/directory/components/marketing.tsx +27 -91
  89. package/templates/directory/lib/site.config.ts +13 -24
  90. package/templates/expo/mobile/apps/expo/README.md +1 -1
  91. package/templates/expo/mobile/apps/expo/package.json +55 -54
  92. package/templates/expo/mobile/apps/expo/src/entitlements.ts +24 -6
  93. package/templates/helpdesk/AGENTS.md +1 -1
  94. package/templates/helpdesk/lib/seed.ts +188 -104
  95. package/templates/inventory/AGENTS.md +1 -1
  96. package/templates/inventory/lib/seed.ts +175 -44
  97. package/templates/invoices/AGENTS.md +1 -1
  98. package/templates/invoices/lib/seed.ts +74 -89
  99. package/templates/local-service/AGENTS.md +1 -1
  100. package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
  101. package/templates/local-service/app/(marketing)/layout.tsx +25 -37
  102. package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
  103. package/templates/local-service/app/(marketing)/page.tsx +145 -139
  104. package/templates/local-service/app/globals.css +11 -0
  105. package/templates/local-service/app/layout.tsx +2 -0
  106. package/templates/local-service/app.ts +9 -0
  107. package/templates/local-service/components/marketing.tsx +14 -39
  108. package/templates/local-service/lib/site.config.ts +16 -23
  109. package/templates/local-service/public/images/hero.jpg +0 -0
  110. package/templates/marketplace/AGENTS.md +1 -1
  111. package/templates/marketplace/client/SellForm.tsx +2 -0
  112. package/templates/projects/AGENTS.md +1 -1
  113. package/templates/projects/lib/seed.ts +103 -32
  114. package/templates/restaurant/AGENTS.md +1 -1
  115. package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
  116. package/templates/restaurant/app/(marketing)/page.tsx +146 -123
  117. package/templates/restaurant/app/globals.css +11 -0
  118. package/templates/restaurant/app/layout.tsx +1 -0
  119. package/templates/restaurant/app.ts +10 -0
  120. package/templates/restaurant/components/marketing.tsx +10 -0
  121. package/templates/restaurant/lib/site.config.ts +16 -14
  122. package/templates/restaurant/public/images/bar.jpg +0 -0
  123. package/templates/restaurant/public/images/branzino.jpg +0 -0
  124. package/templates/restaurant/public/images/hearth.jpg +0 -0
  125. package/templates/restaurant/public/images/hero.jpg +0 -0
  126. package/templates/saas/AGENTS.md +1 -1
  127. package/templates/shop/AGENTS.md +1 -1
  128. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  129. package/templates/shop/public/images/products/linen.jpg +0 -0
  130. package/templates/todo/AGENTS.md +1 -1
  131. package/templates/waitlist/AGENTS.md +1 -1
  132. package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
  133. package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
  134. package/templates/waitlist/app/(marketing)/page.tsx +51 -86
  135. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
  136. package/templates/waitlist/app/globals.css +23 -0
  137. package/templates/waitlist/app/layout.tsx +1 -0
  138. package/templates/waitlist/app.ts +18 -0
  139. package/templates/waitlist/components/marketing.tsx +67 -81
  140. package/templates/waitlist/lib/site.config.ts +52 -59
  141. package/templates/agency/app/not-found.tsx +0 -44
  142. package/templates/ai-chat/app/not-found.tsx +0 -44
  143. package/templates/ai-studio/app/not-found.tsx +0 -44
  144. package/templates/consumer/app/feed-client.tsx +0 -159
  145. package/templates/consumer/app/page.tsx +0 -27
  146. package/templates/consumer/functions/_keep.ts +0 -13
  147. package/templates/consumer/functions/seedPosts.ts +0 -56
  148. package/templates/consumer/tests/example.test.ts +0 -12
  149. package/templates/creator/app/not-found.tsx +0 -44
  150. package/templates/directory/app/not-found.tsx +0 -44
  151. package/templates/local-service/app/not-found.tsx +0 -44
  152. package/templates/restaurant/app/not-found.tsx +0 -44
  153. package/templates/shop/app/not-found.tsx +0 -44
  154. package/templates/waitlist/app/not-found.tsx +0 -44
@@ -1,15 +1,15 @@
1
1
  import React from "react";
2
2
  import { Link, type PageAuth } from "@pylonsync/react";
3
3
  import { siteConfig } from "@/lib/site.config";
4
+ import { WRAP } from "@/components/marketing";
4
5
 
5
6
  // `(marketing)` is a ROUTE GROUP: the parens segment is stripped from every
6
7
  // URL (so `(marketing)/page.tsx` still serves `/`), and this layout wraps only
7
- // the pages inside the group — a slim nav up top and a footer below, both
8
- // driven by lib/site.config.ts. /login and /dashboard sit outside the group
9
- // and render bare in the root shell. `auth.user_id` is resolved server-side
10
- // from the session cookie before any HTML is sent, so the nav shows
11
- // "Dashboard" once the owner is signed in and "Sign in" otherwise — no flash,
12
- // no client fetch.
8
+ // the pages inside the group. A one-line nav up top and a one-line footer
9
+ // below, both driven by lib/site.config.ts. /login and /dashboard sit outside
10
+ // the group and render bare in the root shell. `auth.user_id` is resolved
11
+ // server-side from the session cookie before any HTML is sent, so the nav
12
+ // shows "Dashboard" once the owner is signed in and "Sign in" otherwise.
13
13
  interface LayoutProps {
14
14
  children: React.ReactNode;
15
15
  auth: PageAuth;
@@ -17,39 +17,25 @@ interface LayoutProps {
17
17
 
18
18
  export default function MarketingLayout({ children, auth }: LayoutProps) {
19
19
  // A guest session (minted by <EnsureGuest> for the live counter) has a
20
- // `guest_…` user id — that's an anonymous visitor, NOT the signed-in owner,
21
- // so it shouldn't flip the nav to "Dashboard".
20
+ // `guest_…` user id. That is an anonymous visitor, not the signed-in owner,
21
+ // so it must not flip the nav to "Dashboard".
22
22
  const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
23
23
  const { brand } = siteConfig;
24
24
 
25
25
  return (
26
- <>
27
- <header className="sticky top-0 z-30 bg-white/80 backdrop-blur">
28
- <div className="mx-auto flex h-14 max-w-3xl items-center justify-between px-6">
29
- <Link href="/" className="flex items-center gap-2">
30
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
31
- {brand.letter}
32
- </span>
33
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
34
- {brand.name}
35
- </span>
26
+ <div className="flex min-h-screen flex-col bg-ink text-chalk">
27
+ <header>
28
+ <div className={`${WRAP} flex h-16 items-center justify-between`}>
29
+ <Link href="/" className="font-display text-[17px] font-bold tracking-tight text-chalk">
30
+ {brand.name}
36
31
  </Link>
37
- <nav className="flex items-center gap-2">
38
- {signedIn ? (
39
- <Link
40
- href="/dashboard"
41
- className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
42
- >
43
- Dashboard
44
- </Link>
45
- ) : (
46
- <Link
47
- href="/login"
48
- className="rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900"
49
- >
50
- Sign in
51
- </Link>
52
- )}
32
+ <nav>
33
+ <Link
34
+ href={signedIn ? "/dashboard" : "/login"}
35
+ className="text-[14px] text-chalk-2 transition-colors hover:text-chalk"
36
+ >
37
+ {signedIn ? "Dashboard" : "Sign in"}
38
+ </Link>
53
39
  </nav>
54
40
  </div>
55
41
  </header>
@@ -57,64 +43,32 @@ export default function MarketingLayout({ children, auth }: LayoutProps) {
57
43
  <main className="flex-1">{children}</main>
58
44
 
59
45
  <SiteFooter />
60
- </>
46
+ </div>
61
47
  );
62
48
  }
63
49
 
64
50
  function SiteFooter() {
65
51
  const { brand } = siteConfig;
66
52
  return (
67
- <footer className="border-t border-zinc-200/70 bg-white">
68
- <div className="mx-auto max-w-3xl px-6 py-12">
69
- <div className="flex flex-col items-start justify-between gap-6 sm:flex-row">
70
- <div className="max-w-sm">
71
- <Link href="/" className="inline-flex items-center gap-2">
72
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
73
- {brand.letter}
74
- </span>
75
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
76
- {brand.name}
77
- </span>
78
- </Link>
79
- <p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
80
- {brand.footerBlurb}
81
- </p>
82
- </div>
83
- <div className="flex items-center gap-4">
84
- {brand.socials.map((s) => (
85
- <a
86
- key={s.label}
87
- href={s.href}
88
- aria-label={s.label}
89
- className="text-zinc-400 transition-colors hover:text-zinc-900"
90
- >
91
- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
92
- <path d={s.path} />
93
- </svg>
94
- </a>
95
- ))}
96
- <a
97
- href={`mailto:${brand.email}`}
98
- aria-label="Email"
99
- className="text-zinc-400 transition-colors hover:text-zinc-900"
100
- >
101
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
102
- <rect x="3" y="5" width="18" height="14" rx="2" />
103
- <path d="m3 7 9 6 9-6" />
104
- </svg>
105
- </a>
106
- </div>
107
- </div>
108
- <div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400 sm:flex-row sm:items-center">
109
- <span>
110
- © {new Date().getFullYear()} {brand.copyrightName}
111
- </span>
112
- <span>
113
- Built with{" "}
114
- <a href="https://pylonsync.com" className="font-medium text-zinc-600 hover:text-zinc-900">
115
- Pylon
53
+ <footer className="border-t border-line">
54
+ <div
55
+ className={`${WRAP} flex flex-col gap-3 py-6 font-mono-ui text-[12px] text-chalk-2 sm:flex-row sm:items-center sm:justify-between`}
56
+ >
57
+ <span>
58
+ {brand.copyrightName} {new Date().getFullYear()}
59
+ </span>
60
+ <div className="flex items-center gap-5">
61
+ {brand.socials.map((s) => (
62
+ <a key={s.label} href={s.href} className="transition-colors hover:text-chalk">
63
+ {s.label}
116
64
  </a>
117
- </span>
65
+ ))}
66
+ <a href={`mailto:${brand.email}`} className="transition-colors hover:text-chalk">
67
+ {brand.email}
68
+ </a>
69
+ <a href="https://pylonsync.com" className="transition-colors hover:text-chalk">
70
+ Built with Pylon
71
+ </a>
118
72
  </div>
119
73
  </div>
120
74
  </footer>
@@ -4,19 +4,18 @@ import { Link, type NotFoundProps } from "@pylonsync/react";
4
4
  // `(marketing)/not-found.tsx` → rendered at HTTP 404 for any unmatched URL
5
5
  // (and when a page calls `response.notFound()`). It lives inside the
6
6
  // `(marketing)` group on purpose: a group segment adds no URL prefix, so this
7
- // is still the root 404 boundary, but it wraps in the marketing layout — a
7
+ // is still the root 404 boundary, but it wraps in the marketing layout, so a
8
8
  // missing URL gets the site nav + footer instead of a bare page. Hydrated, so
9
9
  // the link is a client nav.
10
10
  export default function NotFound(_props: NotFoundProps) {
11
11
  return (
12
- <div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
13
- <h1 className="text-3xl font-semibold tracking-tight">404</h1>
14
- <p className="mt-2 text-zinc-500">We couldn&apos;t find that page.</p>
15
- <Link
16
- href="/"
17
- className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
18
- >
19
- Back home
12
+ <div className="mx-auto flex min-h-[60vh] w-full max-w-6xl flex-col justify-center px-6">
13
+ <p className="font-mono-ui text-[13px] text-brand">404</p>
14
+ <h1 className="font-display mt-4 text-3xl font-bold tracking-tight text-chalk">
15
+ That page does not exist.
16
+ </h1>
17
+ <Link href="/" className="mt-6 text-[15px] text-chalk-2 underline underline-offset-4 hover:text-chalk">
18
+ Back to the home page
20
19
  </Link>
21
20
  </div>
22
21
  );
@@ -1,12 +1,11 @@
1
1
  import React from "react";
2
2
  import { type Metadata } from "@pylonsync/react";
3
- import { WRAP, Eyebrow, Badge, Divider, SectionHead, FeatureGrid, initials } from "@/components/marketing";
4
- import { WaitlistHero } from "./waitlist-hero";
3
+ import { WRAP, FactList, ProductMock } from "@/components/marketing";
4
+ import { SignupForm, LiveCount } from "./waitlist-hero";
5
5
  import { siteConfig } from "@/lib/site.config";
6
6
 
7
- // SEO metadata — server-rendered into <head>, so the page is fully indexable
8
- // (view source and the copy is in the HTML). All copy lives in
9
- // lib/site.config.ts; edit it there to rebrand.
7
+ // SEO metadata: server-rendered into <head>, so the page is fully indexable.
8
+ // All copy lives in lib/site.config.ts; edit it there to rebrand.
10
9
  export const metadata: Metadata = {
11
10
  title: siteConfig.seo.title,
12
11
  description: siteConfig.seo.description,
@@ -17,102 +16,68 @@ export const metadata: Metadata = {
17
16
  },
18
17
  };
19
18
 
20
- // `app/(marketing)/page.tsx` → `/` (the group segment adds no URL prefix). A server-rendered, single-page coming-soon landing.
21
- // The headline, value props, proof, and FAQ are static server HTML (great for
22
- // SEO + first paint); the email form and live counter are a client island
23
- // (<WaitlistHero>) that hydrates in the browser. Every string is sourced from
24
- // `siteConfig`, so the whole page rebrands from one file.
19
+ // `app/(marketing)/page.tsx` → `/`. A server-rendered, single-page launch
20
+ // page. The headline, product mock, facts, and FAQ are static server HTML; the
21
+ // email form and the live count are client islands (<SignupForm>, <LiveCount>)
22
+ // that hydrate in the browser. Every string comes from `siteConfig`.
25
23
  //
26
- // Note: this page intentionally does NOT read `auth`, so it stays a public,
27
- // cacheable render. Signed-in state (the nav's "Dashboard" link) is resolved in
28
- // the layout.
24
+ // This page does not read `auth`, so it stays a public, cacheable render.
25
+ // Signed-in state (the nav's "Dashboard" link) is resolved in the layout.
29
26
  export default function LandingPage() {
30
- const { hero, counter, valueProps, socialProof, faq } = siteConfig;
27
+ const { hero, counter, mock, facts, faq } = siteConfig;
31
28
 
32
29
  return (
33
- <div className="bg-white text-zinc-900">
30
+ <div>
34
31
  {/* ============================= HERO ============================= */}
35
- <section className={`${WRAP} pt-20 pb-16 sm:pt-28`}>
36
- <div className="flex flex-col items-center text-center">
37
- <Badge>{hero.badge}</Badge>
38
- <h1 className="mt-6 max-w-2xl text-balance text-[2.5rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[3.25rem]">
32
+ <section className={`${WRAP} grid items-center gap-12 pt-16 pb-20 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-16 lg:pt-24 lg:pb-28`}>
33
+ <div>
34
+ <h1 className="font-display text-balance text-[2.5rem] font-bold leading-[1.02] tracking-[-0.02em] text-chalk sm:text-[3.5rem]">
39
35
  {hero.headline}
40
36
  </h1>
41
- <p className="mt-5 max-w-xl text-balance text-[17px] leading-relaxed text-zinc-500">
42
- {hero.subcopy}
43
- </p>
44
- </div>
45
- <div className="mt-10">
46
- <WaitlistHero hero={hero} counter={counter} />
37
+ <p className="mt-6 max-w-md text-[17px] leading-relaxed text-chalk-2">{hero.subcopy}</p>
38
+ <div className="mt-8 max-w-md">
39
+ <SignupForm hero={hero} />
40
+ </div>
41
+ <p className="font-mono-ui mt-6 text-[13px] text-brand">{hero.launchNote}</p>
47
42
  </div>
43
+ <ProductMock
44
+ windowTitle={mock.windowTitle}
45
+ sidebar={mock.sidebar}
46
+ activeSidebarIndex={mock.activeSidebarIndex}
47
+ rows={mock.rows}
48
+ />
48
49
  </section>
49
50
 
50
- {/* ========================== VALUE PROPS ========================= */}
51
- <Divider />
52
- <section className={`${WRAP} py-16`}>
53
- <SectionHead eyebrow={valueProps.eyebrow} title={valueProps.headline} />
54
- <div className="mt-10">
55
- <FeatureGrid items={valueProps.items} />
51
+ {/* ============================= FACTS ============================= */}
52
+ <section className={`${WRAP} pb-20`}>
53
+ <h2 className="font-display text-[1.5rem] font-medium tracking-[-0.01em] text-chalk">
54
+ {facts.headline}
55
+ </h2>
56
+ <div className="mt-8">
57
+ <FactList items={facts.items} />
56
58
  </div>
59
+ {counter.enabled ? (
60
+ <div className="mt-12 border-t border-line pt-5">
61
+ <LiveCount seed={counter.seedCount ?? 0} label={counter.label} />
62
+ </div>
63
+ ) : null}
57
64
  </section>
58
65
 
59
- {/* ========================= SOCIAL PROOF ========================= */}
60
- {socialProof && socialProof.quotes && socialProof.quotes.length > 0 ? (
61
- <>
62
- <Divider />
63
- <section className={`${WRAP} py-16`}>
64
- <p className="font-mono text-[11px] uppercase tracking-[0.14em] text-zinc-400">
65
- {socialProof.label}
66
- </p>
67
- <div className="mt-8 grid gap-5 sm:grid-cols-3">
68
- {socialProof.quotes.map((q) => (
69
- <figure
70
- key={q.name + q.role}
71
- className="flex flex-col rounded-2xl border border-zinc-200 bg-paper p-6"
72
- >
73
- <blockquote className="text-[14px] leading-relaxed text-zinc-600">
74
- &ldquo;{q.quote}&rdquo;
75
- </blockquote>
76
- <figcaption className="mt-6 flex items-center gap-3">
77
- <span className="flex size-8 items-center justify-center rounded-full bg-zinc-200 text-[11px] font-semibold text-zinc-500">
78
- {initials(q.name)}
79
- </span>
80
- <div className="leading-tight">
81
- <div className="text-[13px] font-semibold">{q.name}</div>
82
- <div className="text-[12px] text-zinc-500">{q.role}</div>
83
- </div>
84
- </figcaption>
85
- </figure>
86
- ))}
87
- </div>
88
- </section>
89
- </>
90
- ) : null}
91
-
92
66
  {/* ============================== FAQ ============================= */}
93
67
  {faq && faq.items.length > 0 ? (
94
- <>
95
- <Divider />
96
- <section className={`${WRAP} py-16`}>
97
- <Eyebrow>{faq.eyebrow}</Eyebrow>
98
- <h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
99
- {faq.headline}
100
- </h2>
101
- <div className="mt-8 divide-y divide-zinc-200/70 border-y border-zinc-200/70">
102
- {faq.items.map((f) => (
103
- <details key={f.q} className="group py-5">
104
- <summary className="flex cursor-pointer items-center justify-between text-[15px] font-medium text-zinc-900 marker:hidden [&::-webkit-details-marker]:hidden">
105
- {f.q}
106
- <span className="text-brand transition-transform group-open:rotate-45">+</span>
107
- </summary>
108
- <p className="mt-3 max-w-2xl text-[14px] leading-relaxed text-zinc-500">
109
- {f.a}
110
- </p>
111
- </details>
112
- ))}
113
- </div>
114
- </section>
115
- </>
68
+ <section className={`${WRAP} pb-24`}>
69
+ <h2 className="font-display text-[1.5rem] font-medium tracking-[-0.01em] text-chalk">
70
+ {faq.headline}
71
+ </h2>
72
+ <dl className="mt-8 grid gap-x-8 gap-y-8 sm:grid-cols-2">
73
+ {faq.items.map((f) => (
74
+ <div key={f.q} className="border-t border-line pt-5">
75
+ <dt className="text-[15px] font-medium text-chalk">{f.q}</dt>
76
+ <dd className="mt-2 text-[14px] leading-relaxed text-chalk-2">{f.a}</dd>
77
+ </div>
78
+ ))}
79
+ </dl>
80
+ </section>
116
81
  ) : null}
117
82
  </div>
118
83
  );
@@ -5,40 +5,22 @@ import { db, callFn } from "@pylonsync/react";
5
5
  import { EnsureGuest } from "@pylonsync/client";
6
6
  import type { WaitlistConfig } from "@/lib/site.config";
7
7
 
8
- // The interactive top of the landing page: the email-capture form and the LIVE
9
- // signup counter. The counter is a live `db.useQuery("WaitlistStat")` over the
10
- // public, PII-free aggregate row, so the moment anyone (this tab or another)
11
- // submits an email, joinWaitlist updates that row and the new count syncs to
12
- // every open tab through the replica. No refresh, no polling.
8
+ // The two client islands on the landing page: the email-capture form (in the
9
+ // hero) and the LIVE signup count (under the facts). The count is a live
10
+ // `db.useQuery("WaitlistStat")` over the public, PII-free aggregate row, so
11
+ // the moment anyone (this tab or another) submits an email, joinWaitlist
12
+ // updates that row and the new count syncs to every open tab through the
13
+ // replica. No refresh, no polling.
13
14
  //
14
15
  // The signup form (joinWaitlist) is a public mutation, so it works for any
15
- // anonymous visitor. The counter needs a live sync connection, so it's wrapped
16
- // in <EnsureGuest>, which mints an anonymous guest session on first load — that
17
- // session is what the sync engine connects with. It holds no PII, and the
18
- // Signup table stays unreadable to it; the page only ever reads the aggregate
19
- // count, never an email.
20
-
21
- type HeroProps = {
22
- hero: WaitlistConfig["hero"];
23
- counter: WaitlistConfig["counter"];
24
- };
25
-
26
- export function WaitlistHero({ hero, counter }: HeroProps) {
27
- return (
28
- <div className="flex flex-col items-center text-center">
29
- <SignupForm hero={hero} />
30
- {counter.enabled ? (
31
- <div className="mt-8">
32
- <LiveCounter seed={counter.seedCount ?? 0} label={counter.label} />
33
- </div>
34
- ) : null}
35
- </div>
36
- );
37
- }
16
+ // anonymous visitor. The count needs a live sync connection, so it's wrapped
17
+ // in <EnsureGuest>, which mints an anonymous guest session on first load. That
18
+ // session holds no PII, and the Signup table stays unreadable to it; the page
19
+ // only ever reads the aggregate count, never an email.
38
20
 
39
21
  /* ----------------------------- signup form ---------------------------- */
40
22
 
41
- function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
23
+ export function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
42
24
  const [email, setEmail] = useState("");
43
25
  const [status, setStatus] = useState<"idle" | "sending" | "done">("idle");
44
26
  const [alreadyJoined, setAlreadyJoined] = useState(false);
@@ -57,14 +39,13 @@ function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
57
39
  );
58
40
  setAlreadyJoined(res.alreadyJoined);
59
41
  setStatus("done");
60
- // The live counter updates itself via the reactive push — no manual
61
- // increment needed here.
42
+ // The live count updates itself via the reactive push.
62
43
  } catch (err) {
63
44
  const msg = err instanceof Error ? err.message : String(err);
64
45
  setError(
65
46
  /valid email|INVALID_ARGS/i.test(msg)
66
47
  ? "Enter a valid email address."
67
- : "Something went wrong — try again in a moment.",
48
+ : "Something went wrong. Try again in a moment.",
68
49
  );
69
50
  setStatus("idle");
70
51
  }
@@ -72,22 +53,19 @@ function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
72
53
 
73
54
  if (status === "done") {
74
55
  return (
75
- <div className="w-full max-w-md rounded-2xl border border-brand/30 bg-brand-soft/60 px-6 py-5 text-center">
76
- <div className="mx-auto flex size-9 items-center justify-center rounded-full bg-brand text-white">
77
- <CheckIcon />
78
- </div>
79
- <p className="mt-3 text-[15px] font-medium text-zinc-900">
80
- {alreadyJoined ? "You're already on the list." : hero.successMessage}
56
+ <div className="border-t border-brand pt-4">
57
+ <p className="text-[15px] font-medium text-chalk">
58
+ {alreadyJoined ? "You are already on the list." : hero.successMessage}
81
59
  </p>
82
- <p className="mt-1 text-[13px] text-zinc-500">
83
- Watch the counter below — it just ticked up for everyone.
60
+ <p className="mt-1 font-mono-ui text-[12px] text-chalk-2">
61
+ The count below went up for everyone.
84
62
  </p>
85
63
  </div>
86
64
  );
87
65
  }
88
66
 
89
67
  return (
90
- <form onSubmit={onSubmit} className="w-full max-w-md">
68
+ <form onSubmit={onSubmit}>
91
69
  <div className="flex flex-col gap-2 sm:flex-row">
92
70
  <input
93
71
  type="email"
@@ -98,28 +76,24 @@ function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
98
76
  placeholder={hero.emailPlaceholder}
99
77
  aria-label="Email address"
100
78
  required
101
- className="h-11 flex-1 rounded-full border border-zinc-300 bg-white px-4 text-[15px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
79
+ className="h-11 flex-1 rounded-md border border-line bg-paper px-3.5 text-[15px] text-chalk outline-none transition placeholder:text-chalk-2 focus:border-brand"
102
80
  />
103
81
  <button
104
82
  type="submit"
105
83
  disabled={status === "sending"}
106
- className="inline-flex h-11 items-center justify-center rounded-full bg-zinc-900 px-6 text-[15px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
84
+ className="inline-flex h-11 shrink-0 items-center justify-center rounded-md bg-brand px-5 text-[15px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
107
85
  >
108
86
  {status === "sending" ? "Joining…" : hero.ctaLabel}
109
87
  </button>
110
88
  </div>
111
- {error ? (
112
- <p className="mt-2 text-[13px] text-red-600">{error}</p>
113
- ) : (
114
- <p className="mt-2 text-[13px] text-zinc-400">
115
- No spam. One invite, the day we launch.
116
- </p>
117
- )}
89
+ <p className={`mt-2.5 font-mono-ui text-[12px] ${error ? "text-red-400" : "text-chalk-2"}`}>
90
+ {error ?? hero.formHint}
91
+ </p>
118
92
  </form>
119
93
  );
120
94
  }
121
95
 
122
- /* ----------------------------- live counter ---------------------------- */
96
+ /* ----------------------------- live count ---------------------------- */
123
97
 
124
98
  interface WaitlistStatRow {
125
99
  id: string;
@@ -127,26 +101,26 @@ interface WaitlistStatRow {
127
101
  updatedAt: string;
128
102
  }
129
103
 
130
- function LiveCounter({ seed, label }: { seed: number; label: string }) {
104
+ export function LiveCount({ seed, label }: { seed: number; label: string }) {
131
105
  return (
132
- <EnsureGuest fallback={<CounterView value={seed} label={label} />}>
133
- <LiveCounterInner seed={seed} label={label} />
106
+ <EnsureGuest fallback={<CountView value={seed} label={label} />}>
107
+ <LiveCountInner seed={seed} label={label} />
134
108
  </EnsureGuest>
135
109
  );
136
110
  }
137
111
 
138
- function LiveCounterInner({ seed, label }: { seed: number; label: string }) {
112
+ function LiveCountInner({ seed, label }: { seed: number; label: string }) {
139
113
  // Live entity query over the public WaitlistStat row. `db.useQuery` re-renders
140
- // the instant the row changes — in THIS tab and every other open tab, because
114
+ // the instant the row changes, in THIS tab and every other open tab, because
141
115
  // the change syncs through the shared replica. `loading` is true until the
142
116
  // first sync settles; until then we show the seed (never a flash of 0).
143
117
  const { data, loading } = db.useQuery<WaitlistStatRow>("WaitlistStat");
144
118
  const real = data.length > 0 ? data[0].count : 0;
145
119
  const value = seed + real;
146
- return <CounterView value={value} label={label} live={!loading} />;
120
+ return <CountView value={value} label={label} live={!loading} />;
147
121
  }
148
122
 
149
- function CounterView({
123
+ function CountView({
150
124
  value,
151
125
  label,
152
126
  live,
@@ -157,26 +131,20 @@ function CounterView({
157
131
  }) {
158
132
  const shown = useCountUp(value);
159
133
  return (
160
- <div className="inline-flex items-center gap-2.5 rounded-full border border-zinc-200 bg-white px-4 py-2 shadow-sm">
161
- {live ? (
162
- <span className="relative flex size-2">
163
- <span className="absolute inline-flex size-2 animate-ping rounded-full bg-brand/70" />
164
- <span className="relative inline-flex size-2 rounded-full bg-brand" />
165
- </span>
166
- ) : (
167
- <span className="inline-flex size-2 rounded-full bg-zinc-300" />
168
- )}
169
- <span className="text-[15px] font-semibold tabular-nums text-zinc-900">
170
- {shown.toLocaleString()}
171
- </span>
172
- <span className="text-[13px] text-zinc-500">{label}</span>
173
- </div>
134
+ <p className="flex items-center gap-3 font-mono-ui text-[14px] text-chalk-2">
135
+ <span
136
+ aria-hidden
137
+ className={live ? "size-2 rounded-full bg-brand" : "size-2 rounded-full bg-line"}
138
+ />
139
+ <span className="tabular-nums text-chalk">{shown.toLocaleString()}</span>
140
+ <span>{label}</span>
141
+ <span className="text-[12px]">{live ? "live" : ""}</span>
142
+ </p>
174
143
  );
175
144
  }
176
145
 
177
- // Smoothly tween the displayed number toward `target` whenever it changes, so a
178
- // live increment animates up instead of snapping. First paint shows the value
179
- // directly (no animation).
146
+ // Tween the displayed number toward `target` whenever it changes, so a live
147
+ // increment counts up instead of snapping. First paint shows the value directly.
180
148
  function useCountUp(target: number): number {
181
149
  const [shown, setShown] = useState(target);
182
150
  const fromRef = useRef(target);
@@ -208,11 +176,3 @@ function useCountUp(target: number): number {
208
176
 
209
177
  return shown;
210
178
  }
211
-
212
- function CheckIcon() {
213
- return (
214
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
215
- <path d="M20 6 9 17l-5-5" />
216
- </svg>
217
- );
218
- }
@@ -19,6 +19,13 @@
19
19
  --brand: #4f46e5;
20
20
  --brand-soft: #eef2ff;
21
21
  --paper: #fafafa;
22
+ /* The marketing pages: near-black page, off-white type. Set from
23
+ lib/site.config.ts on <html>. */
24
+ --ink: #0b0b0c;
25
+ --ink-2: #19191b;
26
+ --line: #26262a;
27
+ --chalk: #ededea;
28
+ --chalk-2: #8f8f95;
22
29
  --background: oklch(1 0 0);
23
30
  --foreground: oklch(0.141 0.005 285.823);
24
31
  --card: oklch(1 0 0);
@@ -112,6 +119,11 @@
112
119
  --color-brand: var(--brand);
113
120
  --color-brand-soft: var(--brand-soft);
114
121
  --color-paper: var(--paper);
122
+ --color-ink: var(--ink);
123
+ --color-ink-2: var(--ink-2);
124
+ --color-line: var(--line);
125
+ --color-chalk: var(--chalk);
126
+ --color-chalk-2: var(--chalk-2);
115
127
  --color-chart-1: var(--chart-1);
116
128
  --color-chart-2: var(--chart-2);
117
129
  --color-chart-3: var(--chart-3);
@@ -153,3 +165,14 @@
153
165
  cursor: pointer;
154
166
  }
155
167
  }
168
+
169
+ /* Headlines. Space Grotesk is declared in app.ts. */
170
+ .font-display {
171
+ font-family: var(--font-display, "Space Grotesk", "Helvetica Neue", Arial, sans-serif);
172
+ }
173
+
174
+ /* Small details: the launch date, the live count, the form hint. JetBrains Mono
175
+ is declared in app.ts. */
176
+ .font-mono-ui {
177
+ font-family: var(--font-mono-ui, "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace);
178
+ }
@@ -23,6 +23,7 @@ export default function RootLayout({ children }: LayoutProps) {
23
23
  "--brand": colors.brand,
24
24
  "--brand-soft": colors.brandSoft,
25
25
  "--paper": colors.paper,
26
+ "--ink": colors.ink,
26
27
  } as React.CSSProperties
27
28
  }
28
29
  >