@pylonsync/create-pylon 0.11.1 → 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 (142) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/_root/AGENTS.md +1 -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/helpdesk/AGENTS.md +1 -1
  91. package/templates/helpdesk/lib/seed.ts +188 -104
  92. package/templates/inventory/AGENTS.md +1 -1
  93. package/templates/inventory/lib/seed.ts +175 -44
  94. package/templates/invoices/AGENTS.md +1 -1
  95. package/templates/invoices/lib/seed.ts +74 -89
  96. package/templates/local-service/AGENTS.md +1 -1
  97. package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
  98. package/templates/local-service/app/(marketing)/layout.tsx +25 -37
  99. package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
  100. package/templates/local-service/app/(marketing)/page.tsx +145 -139
  101. package/templates/local-service/app/globals.css +11 -0
  102. package/templates/local-service/app/layout.tsx +2 -0
  103. package/templates/local-service/app.ts +9 -0
  104. package/templates/local-service/components/marketing.tsx +14 -39
  105. package/templates/local-service/lib/site.config.ts +16 -23
  106. package/templates/local-service/public/images/hero.jpg +0 -0
  107. package/templates/marketplace/AGENTS.md +1 -1
  108. package/templates/marketplace/client/SellForm.tsx +2 -0
  109. package/templates/projects/AGENTS.md +1 -1
  110. package/templates/projects/lib/seed.ts +103 -32
  111. package/templates/restaurant/AGENTS.md +1 -1
  112. package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
  113. package/templates/restaurant/app/(marketing)/page.tsx +146 -123
  114. package/templates/restaurant/app/globals.css +11 -0
  115. package/templates/restaurant/app/layout.tsx +1 -0
  116. package/templates/restaurant/app.ts +10 -0
  117. package/templates/restaurant/components/marketing.tsx +10 -0
  118. package/templates/restaurant/lib/site.config.ts +16 -14
  119. package/templates/restaurant/public/images/bar.jpg +0 -0
  120. package/templates/restaurant/public/images/branzino.jpg +0 -0
  121. package/templates/restaurant/public/images/hearth.jpg +0 -0
  122. package/templates/restaurant/public/images/hero.jpg +0 -0
  123. package/templates/saas/AGENTS.md +1 -1
  124. package/templates/shop/AGENTS.md +1 -1
  125. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  126. package/templates/shop/public/images/products/linen.jpg +0 -0
  127. package/templates/todo/AGENTS.md +1 -1
  128. package/templates/waitlist/AGENTS.md +1 -1
  129. package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
  130. package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
  131. package/templates/waitlist/app/(marketing)/page.tsx +51 -86
  132. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
  133. package/templates/waitlist/app/globals.css +23 -0
  134. package/templates/waitlist/app/layout.tsx +1 -0
  135. package/templates/waitlist/app.ts +18 -0
  136. package/templates/waitlist/components/marketing.tsx +67 -81
  137. package/templates/waitlist/lib/site.config.ts +52 -59
  138. package/templates/consumer/app/feed-client.tsx +0 -159
  139. package/templates/consumer/app/page.tsx +0 -27
  140. package/templates/consumer/functions/_keep.ts +0 -13
  141. package/templates/consumer/functions/seedPosts.ts +0 -56
  142. package/templates/consumer/tests/example.test.ts +0 -12
@@ -1,14 +1,6 @@
1
1
  import React, { Suspense, use } from "react";
2
2
  import { Link, type Metadata, type PageProps, type ServerData } from "@pylonsync/react";
3
- import {
4
- WRAP,
5
- Eyebrow,
6
- Divider,
7
- SectionHead,
8
- ImagePlaceholder,
9
- ProjectCard,
10
- initials,
11
- } from "@/components/marketing";
3
+ import { WRAP, TEXT_LINK, Divider, SectionTitle, ImagePlaceholder, ProjectCard } from "@/components/marketing";
12
4
  import { LiveSlots, ContactForm } from "./contact-form";
13
5
  import { SeedProjects } from "./seeder";
14
6
  import { siteConfig } from "@/lib/site.config";
@@ -20,10 +12,10 @@ export const metadata: Metadata = {
20
12
  openGraph: { title: siteConfig.seo.title, description: siteConfig.seo.description, type: "website" },
21
13
  };
22
14
 
23
- // The homepage "Selected work" grid reads the live Project portfolio on the
24
- // server (the `selected` + `published` ones, ordered), so curating it in the
25
- // dashboard re-curates the homepage. Before the portfolio is seeded, it falls
26
- // back to the config case studies so the section is never empty on first paint.
15
+ // The homepage work grid reads the live Project portfolio on the server (the
16
+ // `selected` + `published` ones, ordered), so curating it in the dashboard
17
+ // re-curates the homepage. Before the portfolio is seeded, it falls back to the
18
+ // config case studies so the section is never empty on first paint.
27
19
  function selectedFromConfig(): ProjectView[] {
28
20
  return siteConfig.work.items
29
21
  .filter((c) => c.selected)
@@ -37,6 +29,8 @@ function selectedFromConfig(): ProjectView[] {
37
29
  }));
38
30
  }
39
31
 
32
+ const WORK_GRID = "grid gap-x-8 gap-y-12 sm:grid-cols-2";
33
+
40
34
  function SelectedWork({ serverData }: { serverData: ServerData }) {
41
35
  const rows = use(serverData.list<ProjectRow>("Project"));
42
36
  const fromDb = rows
@@ -46,7 +40,7 @@ function SelectedWork({ serverData }: { serverData: ServerData }) {
46
40
  const projects = fromDb.length > 0 ? fromDb : selectedFromConfig();
47
41
 
48
42
  return (
49
- <div className="mt-10 grid gap-6 sm:grid-cols-2">
43
+ <div className={WORK_GRID}>
50
44
  {projects.map((p) => (
51
45
  <ProjectCard key={p.slug} p={p} />
52
46
  ))}
@@ -54,129 +48,92 @@ function SelectedWork({ serverData }: { serverData: ServerData }) {
54
48
  );
55
49
  }
56
50
 
57
- // `app/page.tsx` → `/`. Server-rendered studio site. Hero, services, work,
58
- // process, team, and testimonials are static server HTML (SEO + first paint);
59
- // the live "slots open" pill and the contact form (#contact) are client islands
60
- // driven by the public Capacity row. The "Selected work" grid reads the Project
61
- // portfolio server-side. All other copy comes from siteConfig.
51
+ function WorkSkeleton({ count }: { count: number }) {
52
+ return (
53
+ <div className={WORK_GRID}>
54
+ {Array.from({ length: count }, (_, i) => (
55
+ <div key={i}>
56
+ <div className="aspect-[4/3] animate-pulse rounded-sm bg-zinc-100" />
57
+ <div className="mt-4 h-5 w-1/3 animate-pulse rounded bg-zinc-100" />
58
+ <div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-zinc-100" />
59
+ </div>
60
+ ))}
61
+ </div>
62
+ );
63
+ }
64
+
65
+ // `app/page.tsx` → `/`. Server-rendered studio site. The hero is one serif
66
+ // sentence, then the work grid, services, process, team, testimonials, and
67
+ // contact. The live "slots open" line and the contact form (#contact) are
68
+ // client islands driven by the public Capacity row. All copy comes from
69
+ // siteConfig.
62
70
  export default function LandingPage({ serverData }: PageProps) {
63
- const { hero, logos, services, work, process, team, testimonials, contact } = siteConfig;
71
+ const { hero, services, work, process, team, testimonials, contact } = siteConfig;
64
72
 
65
73
  return (
66
- <div className="bg-white text-zinc-900">
74
+ <div className="bg-white text-ink">
67
75
  {/* ============================== HERO ============================== */}
68
- <section className={`${WRAP} pt-16 pb-14 sm:pt-20`}>
69
- <div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
70
- <div>
71
- <p className="font-mono text-[11px] uppercase tracking-[0.16em] text-brand">{hero.tagline}</p>
72
- <h1 className="mt-4 text-balance text-[2.5rem] font-semibold leading-[1.04] tracking-[-0.02em] sm:text-[3.25rem]">
73
- {hero.headline}
74
- </h1>
75
- <p className="mt-5 max-w-xl text-[17px] leading-relaxed text-zinc-500">{hero.subcopy}</p>
76
- <div className="mt-7 flex flex-wrap items-center gap-4">
77
- <a href="#contact" className="inline-flex items-center rounded-full bg-brand px-5 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90">
76
+ <section className={`${WRAP} pt-14 pb-16 sm:pt-20 sm:pb-20`}>
77
+ <h1 className="font-display max-w-5xl text-balance text-[2.75rem] leading-[1.02] tracking-[-0.015em] sm:text-[4rem] lg:text-[5rem]">
78
+ {hero.headline}
79
+ </h1>
80
+ <div className="mt-8 grid gap-6 sm:mt-10 sm:grid-cols-[1fr_auto] sm:items-end">
81
+ <div className="max-w-xl">
82
+ <p className="text-[17px] leading-relaxed text-zinc-700">{hero.subcopy}</p>
83
+ <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-2 text-[15px]">
84
+ <a href="#contact" className={TEXT_LINK}>
78
85
  {hero.ctaLabel}
79
86
  </a>
80
- <a href="#work" className="text-sm font-medium text-zinc-700 hover:text-zinc-900">
81
- {hero.secondaryCtaLabel} →
87
+ <a href="#work" className={TEXT_LINK}>
88
+ {hero.secondaryCtaLabel}
82
89
  </a>
83
90
  </div>
84
- <div className="mt-8">
85
- <LiveSlots />
86
- </div>
87
- </div>
88
-
89
- {/* Hero photo — replace the placeholder with a real shot (the studio,
90
- the team at work, a flagship project). */}
91
- <div className="relative mx-auto w-full max-w-sm lg:max-w-none">
92
- <ImagePlaceholder
93
- shape="portrait"
94
- title="A photo of your studio or work"
95
- hint="Swap for an <img> in app/page.tsx"
96
- />
97
91
  </div>
92
+ <LiveSlots />
98
93
  </div>
99
94
  </section>
100
95
 
101
- {/* ============================== LOGOS ============================= */}
102
- <section className={`${WRAP} pb-6`}>
103
- <p className="text-center font-mono text-[11px] uppercase tracking-[0.16em] text-zinc-400">
104
- {logos.eyebrow}
105
- </p>
106
- <div className="mt-5 flex flex-wrap items-center justify-center gap-x-10 gap-y-4">
107
- {logos.names.map((n) => (
108
- <span key={n} className="text-[15px] font-semibold tracking-tight text-zinc-300">
109
- {n}
110
- </span>
111
- ))}
96
+ {/* ============================== WORK ============================= */}
97
+ <section id="work" className={`${WRAP} pb-20`}>
98
+ <Suspense fallback={<WorkSkeleton count={2} />}>
99
+ <SelectedWork serverData={serverData} />
100
+ </Suspense>
101
+ <div className="mt-10 text-[15px]">
102
+ <Link href="/work" className={TEXT_LINK}>
103
+ All work
104
+ </Link>
112
105
  </div>
113
106
  </section>
114
107
 
115
108
  {/* ============================ SERVICES =========================== */}
116
109
  <Divider />
117
- <section id="services" className={`${WRAP} py-16`}>
118
- <SectionHead eyebrow={services.eyebrow} title={services.headline} />
119
- <div className="mt-10 grid gap-x-8 gap-y-10 sm:grid-cols-2">
120
- {services.items.map((s) => (
121
- <div key={s.title}>
122
- {s.icon ? (
123
- <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
124
- {s.icon}
125
- </span>
126
- ) : null}
127
- <h3 className="mt-4 text-[16px] font-semibold text-zinc-900">{s.title}</h3>
128
- <p className="mt-2 max-w-md text-[14px] leading-relaxed text-zinc-500">{s.body}</p>
129
- </div>
130
- ))}
131
- </div>
132
- </section>
133
-
134
- {/* ============================== WORK ============================= */}
135
- <Divider />
136
- <section id="work" className={`${WRAP} py-16`}>
137
- <div className="flex items-end justify-between gap-4">
138
- <SectionHead eyebrow={work.eyebrow} title={work.headline} />
139
- <Link
140
- href="/work"
141
- className="hidden shrink-0 text-[13.5px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
142
- >
143
- All work →
144
- </Link>
145
- </div>
146
- <Suspense
147
- fallback={
148
- <div className="mt-10 grid gap-6 sm:grid-cols-2">
149
- {[0, 1].map((i) => (
150
- <div key={i}>
151
- <div className="aspect-[4/3] animate-pulse rounded-2xl bg-zinc-100" />
152
- <div className="mt-4 h-4 w-1/3 animate-pulse rounded bg-zinc-100" />
153
- <div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-zinc-100" />
110
+ <section id="services" className={`${WRAP} py-16 sm:py-20`}>
111
+ <div className="grid gap-10 lg:grid-cols-[1fr_2fr]">
112
+ <SectionTitle title={services.headline} />
113
+ <ol className="divide-y divide-zinc-200 border-y border-zinc-200">
114
+ {services.items.map((s, i) => (
115
+ <li key={s.title} className="grid gap-2 py-6 sm:grid-cols-[3rem_1fr] sm:gap-6">
116
+ <span className="text-[13px] tabular-nums text-zinc-500">{String(i + 1).padStart(2, "0")}</span>
117
+ <div>
118
+ <h3 className="font-display text-[1.5rem] leading-tight">{s.title}</h3>
119
+ <p className="mt-2 max-w-lg text-[15px] leading-relaxed text-zinc-600">{s.body}</p>
154
120
  </div>
155
- ))}
156
- </div>
157
- }
158
- >
159
- <SelectedWork serverData={serverData} />
160
- </Suspense>
161
- <div className="mt-8 sm:hidden">
162
- <Link href="/work" className="text-[14px] font-medium text-brand">
163
- See all work →
164
- </Link>
121
+ </li>
122
+ ))}
123
+ </ol>
165
124
  </div>
166
125
  </section>
167
126
 
168
127
  {/* ============================ PROCESS ============================ */}
169
128
  <Divider />
170
- <section className={`${WRAP} py-16`}>
171
- <SectionHead eyebrow={process.eyebrow} title={process.headline} />
172
- <ol className="mt-10 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
129
+ <section className={`${WRAP} py-16 sm:py-20`}>
130
+ <SectionTitle title={process.headline} />
131
+ <ol className="mt-10 grid gap-8 border-t border-zinc-200 pt-8 sm:grid-cols-2 lg:grid-cols-4">
173
132
  {process.steps.map((step, i) => (
174
133
  <li key={step.title}>
175
- <span className="flex size-8 items-center justify-center rounded-full bg-zinc-900 font-mono text-[12px] font-semibold text-white">
176
- {i + 1}
177
- </span>
178
- <h3 className="mt-4 text-[15px] font-semibold text-zinc-900">{step.title}</h3>
179
- <p className="mt-2 text-[14px] leading-relaxed text-zinc-500">{step.body}</p>
134
+ <span className="text-[13px] tabular-nums text-zinc-500">{String(i + 1).padStart(2, "0")}</span>
135
+ <h3 className="font-display mt-2 text-[1.5rem] leading-tight">{step.title}</h3>
136
+ <p className="mt-2 text-[15px] leading-relaxed text-zinc-600">{step.body}</p>
180
137
  </li>
181
138
  ))}
182
139
  </ol>
@@ -184,15 +141,15 @@ export default function LandingPage({ serverData }: PageProps) {
184
141
 
185
142
  {/* ============================== TEAM ============================= */}
186
143
  <Divider />
187
- <section className={`${WRAP} py-16`}>
188
- <SectionHead eyebrow={team.eyebrow} title={team.headline} />
189
- <div className="mt-10 grid gap-8 sm:grid-cols-3">
144
+ <section className={`${WRAP} py-16 sm:py-20`}>
145
+ <SectionTitle title={team.headline} />
146
+ <div className="mt-10 grid grid-cols-2 gap-6 sm:grid-cols-3 sm:gap-8">
190
147
  {team.members.map((m) => (
191
148
  <div key={m.name}>
192
- {/* Team headshot — drop in a real photo. */}
193
- <ImagePlaceholder shape="square" title="Headshot" hint="Replace per team member" className="max-w-[200px]" />
194
- <h3 className="mt-4 text-[15px] font-semibold text-zinc-900">{m.name}</h3>
195
- <p className="text-[13.5px] text-zinc-500">{m.role}</p>
149
+ {/* Team headshot: public/images/team/<name>.jpg. */}
150
+ <ImagePlaceholder shape="square" title={m.name} src={`/images/team/${slugify(m.name)}.jpg`} />
151
+ <h3 className="mt-4 text-[15px] font-medium">{m.name}</h3>
152
+ <p className="text-[14px] text-zinc-500">{m.role}</p>
196
153
  </div>
197
154
  ))}
198
155
  </div>
@@ -202,20 +159,15 @@ export default function LandingPage({ serverData }: PageProps) {
202
159
  {testimonials ? (
203
160
  <>
204
161
  <Divider />
205
- <section className={`${WRAP} py-16`}>
206
- <SectionHead eyebrow={testimonials.eyebrow} title={testimonials.headline} />
207
- <div className="mt-10 grid gap-6 sm:grid-cols-3">
162
+ <section className={`${WRAP} py-16 sm:py-20`}>
163
+ <SectionTitle title={testimonials.headline} />
164
+ <div className="mt-10 grid gap-10 border-t border-zinc-200 pt-8 sm:grid-cols-3 sm:gap-8">
208
165
  {testimonials.items.map((t) => (
209
- <figure key={t.name} className="flex flex-col rounded-2xl border border-zinc-200 bg-paper p-6">
210
- <blockquote className="flex-1 text-[14px] leading-relaxed text-zinc-700">“{t.quote}”</blockquote>
211
- <figcaption className="mt-5 flex items-center gap-3">
212
- <span className="flex size-9 items-center justify-center rounded-full bg-zinc-200 text-[11px] font-semibold text-zinc-500">
213
- {initials(t.name)}
214
- </span>
215
- <span className="text-[13px] leading-tight">
216
- <span className="block font-medium text-zinc-900">{t.name}</span>
217
- <span className="text-zinc-500">{t.role}</span>
218
- </span>
166
+ <figure key={t.name}>
167
+ <blockquote className="font-display text-[1.375rem] leading-snug">“{t.quote}”</blockquote>
168
+ <figcaption className="mt-4 text-[14px] leading-tight">
169
+ <span className="block font-medium">{t.name}</span>
170
+ <span className="text-zinc-500">{t.role}</span>
219
171
  </figcaption>
220
172
  </figure>
221
173
  ))}
@@ -225,20 +177,17 @@ export default function LandingPage({ serverData }: PageProps) {
225
177
  ) : null}
226
178
 
227
179
  {/* ============================= CONTACT =========================== */}
228
- <Divider />
229
- <section id="contact" className={`${WRAP} py-16`}>
230
- <div className="grid gap-10 lg:grid-cols-[0.9fr_1.1fr]">
231
- <div>
232
- <Eyebrow>{contact.eyebrow}</Eyebrow>
233
- <h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
234
- {contact.headline}
235
- </h2>
236
- <p className="mt-4 max-w-md text-[15px] leading-relaxed text-zinc-500">{contact.subcopy}</p>
237
- <div className="mt-6">
238
- <LiveSlots />
180
+ <section id="contact" className="bg-paper">
181
+ <div className={`${WRAP} py-16 sm:py-20`}>
182
+ <div className="grid gap-10 lg:grid-cols-[1fr_1.2fr] lg:gap-16">
183
+ <div>
184
+ <SectionTitle title={contact.headline} body={contact.subcopy} />
185
+ <div className="mt-6">
186
+ <LiveSlots />
187
+ </div>
239
188
  </div>
189
+ <ContactForm />
240
190
  </div>
241
- <ContactForm />
242
191
  </div>
243
192
  </section>
244
193
 
@@ -7,7 +7,7 @@ import {
7
7
  type ServerData,
8
8
  type SsrResponse,
9
9
  } from "@pylonsync/react";
10
- import { ImagePlaceholder } from "@/components/marketing";
10
+ import { WRAP, WRAP_NARROW, TEXT_LINK, ImagePlaceholder } from "@/components/marketing";
11
11
  import { SeedProjects } from "../../seeder";
12
12
  import { siteConfig } from "@/lib/site.config";
13
13
  import { slugify, viewFromRow, type ProjectRow, type ProjectView } from "@/lib/agency";
@@ -58,8 +58,6 @@ export const generateMetadata: GenerateMetadata = async ({
58
58
  // so a click off the work grid hits cache instead of a live render.
59
59
  export const revalidate = 300;
60
60
 
61
- const WRAP_NARROW = "mx-auto w-full max-w-3xl px-6";
62
-
63
61
  function CaseStudy({
64
62
  serverData,
65
63
  response,
@@ -74,79 +72,79 @@ function CaseStudy({
74
72
  if (!p) {
75
73
  response.setStatus(404);
76
74
  return (
77
- <div className={`${WRAP_NARROW} py-24 text-center`}>
78
- <p className="text-[15px] font-medium text-zinc-900">That case study doesn&apos;t exist.</p>
79
- <Link href="/work" className="mt-2 inline-block text-[14px] font-medium text-brand">
80
- ← Back to all work
75
+ <div className={`${WRAP_NARROW} py-24`}>
76
+ <p className="font-display text-[2rem]">That case study does not exist.</p>
77
+ <Link href="/work" className={`mt-4 inline-block text-[15px] ${TEXT_LINK}`}>
78
+ All work
81
79
  </Link>
82
80
  </div>
83
81
  );
84
82
  }
85
83
 
86
84
  return (
87
- <article className={`${WRAP_NARROW} py-14`}>
88
- <Link href="/work" className="text-[13.5px] font-medium text-zinc-500 transition-colors hover:text-zinc-900">
89
- ← All work
90
- </Link>
91
-
92
- <header className="mt-6">
93
- <p className="font-mono text-[11px] uppercase tracking-[0.16em] text-brand">
94
- {p.client}
95
- {p.year ? ` · ${p.year}` : ""}
96
- </p>
97
- <h1 className="mt-3 text-balance text-[2rem] font-semibold leading-[1.08] tracking-[-0.02em] sm:text-[2.5rem]">
85
+ <article className="pt-10 pb-20 sm:pt-14">
86
+ <header className={WRAP}>
87
+ <Link href="/work" className={`text-[14px] ${TEXT_LINK}`}>
88
+ All work
89
+ </Link>
90
+ <h1 className="font-display mt-8 max-w-4xl text-balance text-[2.75rem] leading-[1.02] tracking-[-0.015em] sm:text-[4rem]">
98
91
  {p.title}
99
92
  </h1>
100
- <p className="mt-4 max-w-2xl text-[17px] leading-relaxed text-zinc-500">{p.summary}</p>
101
- {p.tags.length > 0 ? (
102
- <div className="mt-5 flex flex-wrap gap-1.5">
103
- {p.tags.map((t) => (
104
- <span key={t} className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-[11px] font-medium text-zinc-600">
105
- {t}
106
- </span>
107
- ))}
108
- </div>
109
- ) : null}
110
- {p.liveUrl ? (
111
- <a
112
- href={p.liveUrl}
113
- target="_blank"
114
- rel="noopener noreferrer"
115
- className="mt-6 inline-flex items-center rounded-full border border-zinc-300 px-4 py-2 text-[13.5px] font-medium text-zinc-700 transition-colors hover:border-zinc-400 hover:text-zinc-900"
116
- >
117
- Visit live site ↗
118
- </a>
119
- ) : null}
120
- </header>
93
+ <div className="mt-6 grid gap-6 sm:grid-cols-[1fr_auto] sm:items-end">
94
+ <p className="max-w-xl text-[17px] leading-relaxed text-zinc-700">{p.summary}</p>
95
+ <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[14px] sm:text-right">
96
+ <dt className="text-zinc-500">Client</dt>
97
+ <dd>{p.client}</dd>
98
+ {p.year ? (
99
+ <>
100
+ <dt className="text-zinc-500">Year</dt>
101
+ <dd>{p.year}</dd>
102
+ </>
103
+ ) : null}
104
+ {p.tags.length > 0 ? (
105
+ <>
106
+ <dt className="text-zinc-500">Scope</dt>
107
+ <dd>{p.tags.join(", ")}</dd>
108
+ </>
109
+ ) : null}
110
+ {p.liveUrl ? (
111
+ <>
112
+ <dt className="text-zinc-500">Site</dt>
113
+ <dd>
114
+ <a href={p.liveUrl} target="_blank" rel="noopener noreferrer" className={TEXT_LINK}>
115
+ Visit
116
+ </a>
117
+ </dd>
118
+ </>
119
+ ) : null}
120
+ </dl>
121
+ </div>
121
122
 
122
- {/* Hero shot — drop in a real project image. */}
123
- <div className="mt-10">
124
- <ImagePlaceholder shape="landscape" title={`${p.title} — hero shot`} hint="Swap for an <img> in app/work/[slug]/page.tsx" />
125
- </div>
123
+ {/* Product shot: public/images/work/<slug>.jpg. */}
124
+ <div className="mt-10">
125
+ <ImagePlaceholder shape="landscape" title={`${p.title} product shot`} src={`/images/work/${p.slug}.jpg`} />
126
+ </div>
127
+ </header>
126
128
 
127
- <div className="mt-12 space-y-10">
128
- <CaseSection label="The challenge" body={p.challenge} />
129
- <CaseSection label="Our approach" body={p.approach} />
130
- <CaseSection label="The outcome" body={p.outcome} />
129
+ <div className={`${WRAP_NARROW} mt-14 space-y-12`}>
130
+ <CaseSection title="The challenge" body={p.challenge} />
131
+ <CaseSection title="Our approach" body={p.approach} />
132
+ <CaseSection title="The outcome" body={p.outcome} />
131
133
  {!p.challenge && !p.approach && !p.outcome ? (
132
- <p className="text-[15px] leading-relaxed text-zinc-500">
133
- A full write-up is on the way. In the meantime, {p.summary.toLowerCase()}
134
+ <p className="text-[17px] leading-relaxed text-zinc-700">
135
+ The full write-up is not published yet. In short, {p.summary.charAt(0).toLowerCase() + p.summary.slice(1)}
134
136
  </p>
135
137
  ) : null}
136
- </div>
137
138
 
138
- {/* Contact CTA */}
139
- <div className="mt-14 rounded-2xl border border-zinc-200 bg-paper p-8 text-center">
140
- <h2 className="text-[18px] font-semibold tracking-tight text-zinc-900">Have something like this in mind?</h2>
141
- <p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-zinc-500">
142
- We take on a few projects at a time. Tell us what you&apos;re building.
143
- </p>
144
- <Link
145
- href="/#contact"
146
- className="mt-5 inline-flex items-center rounded-full bg-brand px-5 py-2.5 text-[14px] font-medium text-white transition-opacity hover:opacity-90"
147
- >
148
- Start a project
149
- </Link>
139
+ <div className="border-t border-zinc-200 pt-10">
140
+ <h2 className="font-display text-[2rem] leading-tight">Have a project like this?</h2>
141
+ <p className="mt-3 text-[16px] leading-relaxed text-zinc-600">
142
+ We take on a few projects at a time. Tell us what you are building.
143
+ </p>
144
+ <Link href="/#contact" className={`mt-4 inline-block text-[15px] ${TEXT_LINK}`}>
145
+ Start a project
146
+ </Link>
147
+ </div>
150
148
  </div>
151
149
 
152
150
  <SeedProjects />
@@ -154,12 +152,12 @@ function CaseStudy({
154
152
  );
155
153
  }
156
154
 
157
- function CaseSection({ label, body }: { label: string; body?: string | null }) {
155
+ function CaseSection({ title, body }: { title: string; body?: string | null }) {
158
156
  if (!body) return null;
159
157
  return (
160
158
  <section>
161
- <h2 className="font-mono text-[11px] uppercase tracking-[0.16em] text-zinc-400">{label}</h2>
162
- <p className="mt-3 whitespace-pre-wrap text-[16px] leading-relaxed text-zinc-700">{body}</p>
159
+ <h2 className="font-display text-[2rem] leading-tight">{title}</h2>
160
+ <p className="mt-4 whitespace-pre-wrap text-[17px] leading-relaxed text-zinc-700">{body}</p>
163
161
  </section>
164
162
  );
165
163
  }
@@ -169,14 +167,14 @@ function CaseSection({ label, body }: { label: string; body?: string | null }) {
169
167
  // unknown or the project is a draft.
170
168
  export default function CaseStudyPage({ params, serverData, response }: PageProps) {
171
169
  return (
172
- <div className="bg-white text-zinc-900">
170
+ <div className="bg-white text-ink">
173
171
  <Suspense
174
172
  fallback={
175
- <div className={`${WRAP_NARROW} py-14`}>
176
- <div className="h-4 w-20 animate-pulse rounded bg-zinc-100" />
177
- <div className="mt-6 h-10 w-2/3 animate-pulse rounded bg-zinc-100" />
178
- <div className="mt-4 h-4 w-full animate-pulse rounded bg-zinc-100" />
179
- <div className="mt-10 aspect-[4/3] animate-pulse rounded-2xl bg-zinc-100" />
173
+ <div className={`${WRAP} pt-10 sm:pt-14`}>
174
+ <div className="h-4 w-16 animate-pulse rounded bg-zinc-100" />
175
+ <div className="mt-8 h-14 w-2/3 animate-pulse rounded bg-zinc-100" />
176
+ <div className="mt-6 h-4 w-1/2 animate-pulse rounded bg-zinc-100" />
177
+ <div className="mt-10 aspect-[4/3] animate-pulse rounded-sm bg-zinc-100" />
180
178
  </div>
181
179
  }
182
180
  >
@@ -1,13 +1,13 @@
1
1
  import React, { Suspense, use } from "react";
2
2
  import { Link, type Metadata, type PageProps, type ServerData } from "@pylonsync/react";
3
- import { WRAP, SectionHead, ProjectCard } from "@/components/marketing";
3
+ import { WRAP, TEXT_LINK, ProjectCard } from "@/components/marketing";
4
4
  import { SeedProjects } from "../seeder";
5
5
  import { siteConfig } from "@/lib/site.config";
6
6
  import { slugify, viewFromRow, type ProjectRow, type ProjectView } from "@/lib/agency";
7
7
 
8
8
  export const metadata: Metadata = {
9
9
  title: `Work — ${siteConfig.brand.name}`,
10
- description: `Case studies from ${siteConfig.brand.name}: ${siteConfig.work.headline}`,
10
+ description: `Case studies from ${siteConfig.brand.name}.`,
11
11
  openGraph: { title: `Work — ${siteConfig.brand.name}`, type: "website" },
12
12
  };
13
13
 
@@ -26,6 +26,8 @@ function allFromConfig(): ProjectView[] {
26
26
  }));
27
27
  }
28
28
 
29
+ const WORK_GRID = "mt-12 grid gap-x-8 gap-y-12 sm:grid-cols-2";
30
+
29
31
  function WorkGrid({ serverData }: { serverData: ServerData }) {
30
32
  const rows = use(serverData.list<ProjectRow>("Project"));
31
33
  const fromDb = rows
@@ -35,10 +37,10 @@ function WorkGrid({ serverData }: { serverData: ServerData }) {
35
37
  const projects = fromDb.length > 0 ? fromDb : allFromConfig();
36
38
 
37
39
  if (projects.length === 0) {
38
- return <p className="mt-10 text-[15px] text-zinc-500">No published work yet.</p>;
40
+ return <p className="mt-12 text-[15px] text-zinc-600">No published work yet.</p>;
39
41
  }
40
42
  return (
41
- <div className="mt-10 grid gap-x-6 gap-y-10 sm:grid-cols-2">
43
+ <div className={WORK_GRID}>
42
44
  {projects.map((p) => (
43
45
  <ProjectCard key={p.slug} p={p} />
44
46
  ))}
@@ -48,20 +50,21 @@ function WorkGrid({ serverData }: { serverData: ServerData }) {
48
50
 
49
51
  export default function WorkIndexPage({ serverData }: PageProps) {
50
52
  return (
51
- <div className="bg-white text-zinc-900">
52
- <section className={`${WRAP} py-16`}>
53
- <SectionHead
54
- eyebrow="Work"
55
- title={siteConfig.work.headline}
56
- body="A fuller look at what we've shipped — each one a short case study."
57
- />
53
+ <div className="bg-white text-ink">
54
+ <section className={`${WRAP} pt-14 pb-16 sm:pt-20 sm:pb-20`}>
55
+ <h1 className="font-display text-[2.75rem] leading-[1.02] tracking-[-0.015em] sm:text-[4rem]">
56
+ {siteConfig.work.headline}
57
+ </h1>
58
+ <p className="mt-4 max-w-xl text-[16px] leading-relaxed text-zinc-600">
59
+ Every project we have published, each with a short case study.
60
+ </p>
58
61
  <Suspense
59
62
  fallback={
60
- <div className="mt-10 grid gap-x-6 gap-y-10 sm:grid-cols-2">
63
+ <div className={WORK_GRID}>
61
64
  {[0, 1, 2, 3].map((i) => (
62
65
  <div key={i}>
63
- <div className="aspect-[4/3] animate-pulse rounded-2xl bg-zinc-100" />
64
- <div className="mt-4 h-4 w-1/3 animate-pulse rounded bg-zinc-100" />
66
+ <div className="aspect-[4/3] animate-pulse rounded-sm bg-zinc-100" />
67
+ <div className="mt-4 h-5 w-1/3 animate-pulse rounded bg-zinc-100" />
65
68
  </div>
66
69
  ))}
67
70
  </div>
@@ -70,9 +73,9 @@ export default function WorkIndexPage({ serverData }: PageProps) {
70
73
  <WorkGrid serverData={serverData} />
71
74
  </Suspense>
72
75
 
73
- <div className="mt-12">
74
- <Link href="/#contact" className="text-[14px] font-medium text-brand">
75
- Have a project in mind? Start one →
76
+ <div className="mt-14 text-[15px]">
77
+ <Link href="/#contact" className={TEXT_LINK}>
78
+ Start a project
76
79
  </Link>
77
80
  </div>
78
81
  </section>