Terra. Climate & AgTech landing page.

A climate-farming landing page that grows as you read it. The hero is a pinned vertical cross-section from sky to root zone: scrolling raises procedural SVG wheat, cover crops and a tree, branches their roots through five soil horizons, drops carbon particles from the sky and banks them as green flecks in the earth while a per-hectare counter climbs. A season slider that is always on screen re-tints the site from spring to winter, moving the sun, thinning foliage and laying snow. Then topographic contour lines that draw across the field, a live carbon ledger with growing bars and a drawn trend line, a hover-reveal field map where crop rows are drawn into each plot, a mycelium network with travelling signals, grower quotes that grow in, and a wheat field rising under the sign-up form. Soil browns, moss, wheat, cream and a vivid chlorophyll green; serif editorial type, no images or web fonts.

  • Scroll-grown cross-section
  • Procedural roots draw down
  • Falling carbon particles
  • Season palette slider
  • Contour lines draw in
  • Hover-reveal field map
  • Growing bars and trend line
  • Mycelium signals
Full screen

The preview is interactive — scroll inside it to run the animations.

The whole page. One file, nothing left out.

1,160 lines · 62.1 KB · imports React only. This is the exact file behind the preview above — every section, interaction and style.

  1. 01Save it as terra-regen.tsx in any React 18+ project.
  2. 02Render <Page /> on a route of its own — it brings its own nav and footer.
  3. 03Swap the copy and tokens. Keep the DESIGN.md next to it so your AI agent follows the system.
terra-regen.tsx
1/**
2 * Terra — regenerative agriculture & climate-farming collective.
3 *
4 * The page itself grows. The hero is a pinned vertical cross-section from sky
5 * to root zone: as you scroll, procedural SVG wheat, cover crops and a tree
6 * rise out of the soil, root systems branch downward through five soil
7 * horizons, carbon particles fall from the sky and are banked as green flecks
8 * in the earth, and a live per-hectare counter climbs. A season slider (always
9 * on screen) re-tints the whole site spring -> summer -> autumn -> winter,
10 * moving the sun, thinning the foliage and laying snow on the ground.
11 *
12 * Below: topographic contour lines that draw across the field, a live carbon
13 * ledger with growing bars and a drawn trend line, an interactive field map
14 * (hover / focus / tap a plot and its crop rows are drawn in and its soil
15 * data appears), a mycelium network with travelling signals, grower voices
16 * that grow in, and a wheat field that rises under the sign-up form.
17 *
18 * Everything is procedural SVG + CSS custom properties driven from a single
19 * rAF scroll loop (no React state per frame). No images, no web fonts, no
20 * network. prefers-reduced-motion renders the fully grown, static scene.
21 * SSR-safe: window/document are only touched inside effects.
22 */
23import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
24
25/* ------------------------------------------------------------------ */
26/* Procedural generation (deterministic) */
27/* ------------------------------------------------------------------ */
28function rng(seed: number) {
29 let a = seed >>> 0;
30 return () => {
31 a = (a + 0x6d2b79f5) >>> 0;
32 let t = a;
33 t = Math.imul(t ^ (t >>> 15), t | 1);
34 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
35 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
36 };
37}
38const f1 = (n: number) => Math.round(n * 10) / 10;
39const HZ = 458; // horizon y in the 1200x1000 scene
40
41type Stroke = { d: string; delay: number; w: number; o?: number };
42type Leaf = { x: number; y: number; rx: number; ry: number; rot: number; delay: number; c: 1 | 2 };
43
44function buildScene() {
45 const r = rng(11);
46 const stems: Stroke[] = [];
47 const blades: Stroke[] = [];
48 const ears: { x: number; y: number; delay: number; rot: number }[] = [];
49 const leaves: Leaf[] = [];
50 const flowers: { x: number; y: number; delay: number }[] = [];
51 const roots: Stroke[] = [];
52 const bases: { x: number; delay: number }[] = [];
53
54 const addRoot = (x: number, y: number, ang: number, len: number, depth: number, maxD: number, d0: number, w: number) => {
55 const ex = x + Math.sin(ang) * len;
56 const ey = y + Math.cos(ang) * len;
57 const cx = x + Math.sin(ang + (r() - 0.5) * 0.9) * len * 0.5;
58 const cy = y + Math.cos(ang) * len * 0.45;
59 roots.push({ d: `M${f1(x)} ${f1(y)}Q${f1(cx)} ${f1(cy)} ${f1(ex)} ${f1(ey)}`, delay: d0, w, o: 0.95 - depth * 0.12 });
60 if (depth < maxD) {
61 const n = depth === 0 ? 2 : 2;
62 for (let i = 0; i < n; i++) {
63 const t = 0.35 + r() * 0.45;
64 const px = x + (ex - x) * t;
65 const py = y + (ey - y) * t;
66 const side = i % 2 ? 1 : -1;
67 addRoot(px, py, ang + side * (0.55 + r() * 0.6), len * (0.42 + r() * 0.18), depth + 1, maxD, d0 + 0.1 + depth * 0.04, Math.max(0.8, w * 0.6));
68 }
69 }
70 };
71
72 // wheat
73 for (let i = 0; i < 11; i++) {
74 const x = 110 + i * 92 + r() * 26;
75 const h = 130 + r() * 90;
76 const lean = (r() - 0.5) * 46;
77 const d = r() * 0.28;
78 const qx = x + lean * 0.15;
79 const qy = HZ - h * 0.55;
80 const tx = x + lean;
81 const ty = HZ - h;
82 stems.push({ d: `M${f1(x)} ${HZ}Q${f1(qx)} ${f1(qy)} ${f1(tx)} ${f1(ty)}`, delay: d, w: 3 });
83 [0.28, 0.46, 0.64].forEach((t, k) => {
84 const px = (1 - t) * (1 - t) * x + 2 * (1 - t) * t * qx + t * t * tx;
85 const py = (1 - t) * (1 - t) * HZ + 2 * (1 - t) * t * qy + t * t * ty;
86 const side = k % 2 ? 1 : -1;
87 blades.push({ d: `M${f1(px)} ${f1(py)}q${side * 20} ${-26 - k * 3} ${side * 42} ${-12 - k * 5}`, delay: d + 0.2 + k * 0.07, w: 3.4 });
88 });
89 ears.push({ x: tx, y: ty, delay: d + 0.45, rot: lean * 0.5 });
90 bases.push({ x, delay: d });
91 // fibrous roots
92 for (let k = 0; k < 4; k++) addRoot(x, HZ + 4, (k - 1.5) * 0.42 + (r() - 0.5) * 0.15, 150 + r() * 110, 0, 1, 0.02 + r() * 0.1, 2.6);
93 }
94 // broadleaf cover crop / clover
95 for (let i = 0; i < 6; i++) {
96 const x = 160 + i * 168 + r() * 40;
97 const h = 60 + r() * 55;
98 const d = 0.05 + r() * 0.25;
99 const lean = (r() - 0.5) * 20;
100 stems.push({ d: `M${f1(x)} ${HZ}Q${f1(x + lean)} ${f1(HZ - h * 0.6)} ${f1(x + lean * 1.4)} ${f1(HZ - h)}`, delay: d, w: 2.6 });
101 for (let k = 0; k < 5; k++) {
102 const t = 0.25 + k * 0.17;
103 const side = k % 2 ? 1 : -1;
104 leaves.push({ x: x + lean * t * 1.3 + side * 9, y: HZ - h * t, rx: 13, ry: 7, rot: side * 28 - 90 + 90, delay: d + 0.2 + k * 0.05, c: k % 2 ? 1 : 2 });
105 }
106 flowers.push({ x: x + lean * 1.4, y: HZ - h - 4, delay: d + 0.55 });
107 addRoot(x, HZ + 4, (r() - 0.5) * 0.2, 210 + r() * 40, 0, 2, 0.05, 3);
108 }
109 // hedge on the left + tree on the right
110 for (let k = 0; k < 6; k++) leaves.push({ x: 40 + (k % 3) * 26 + r() * 8, y: HZ - 28 - Math.floor(k / 3) * 26, rx: 28, ry: 22, rot: 0, delay: 0.3 + k * 0.04, c: k % 2 ? 1 : 2 });
111 stems.push({ d: `M1096 ${HZ}Q1092 ${HZ - 70} 1100 ${HZ - 170}`, delay: 0.1, w: 11 });
112 stems.push({ d: `M1098 ${HZ - 100}Q1070 ${HZ - 130} 1052 ${HZ - 168}`, delay: 0.32, w: 5 });
113 stems.push({ d: `M1099 ${HZ - 130}Q1130 ${HZ - 160} 1150 ${HZ - 190}`, delay: 0.36, w: 5 });
114 const canopy: [number, number, number][] = [[1100, 100, 54], [1056, 130, 42], [1150, 128, 44], [1100, 150, 50], [1070, 88, 36], [1138, 90, 36]];
115 canopy.forEach(([x, y, rr], k) => leaves.push({ x, y, rx: rr, ry: rr * 0.82, rot: 0, delay: 0.42 + k * 0.05, c: k % 2 ? 1 : 2 }));
116 for (let k = 0; k < 7; k++) addRoot(1096, HZ + 6, (k - 3) * 0.34 + (r() - 0.5) * 0.1, 280 + r() * 190, 0, 2, 0.05 + r() * 0.1, 5.2);
117
118 // soil
119 const specks: { x: number; y: number; r: number; c: string }[] = [];
120 const cols = ["#8a6a45", "#2b1c12", "#a8855a", "#3b2616", "#c4a572"];
121 for (let i = 0; i < 260; i++) {
122 const y = HZ + 10 + r() * 530;
123 specks.push({ x: r() * 1200, y, r: 0.8 + r() * (y > 780 ? 5 : 2.2), c: cols[Math.floor(r() * cols.length)] });
124 }
125 const store: { x: number; y: number; delay: number }[] = [];
126 for (let i = 0; i < 70; i++) store.push({ x: 40 + r() * 1120, y: HZ + 24 + r() * 330, delay: r() * 0.6 });
127 const falling: { x: number; dx: number; dy: number; dur: number; del: number }[] = [];
128 for (let i = 0; i < 22; i++) falling.push({ x: 60 + r() * 1080, dx: (r() - 0.5) * 80, dy: 300 + r() * 230, dur: 7 + r() * 6, del: -r() * 12 });
129 const worms = [
130 `M180 ${HZ + 70}q30 -16 60 0t60 0t60 0`,
131 `M820 ${HZ + 150}q-30 14 -60 0t-60 0t-60 0t-40 6`,
132 `M420 ${HZ + 230}q26 -14 52 0t52 0`,
133 ];
134 return { stems, blades, ears, leaves, flowers, roots, bases, specks, store, falling, worms };
135}
136const SCENE = buildScene();
137
138const wave = (y: number, amp: number, ph: number) => {
139 let d = `M0 ${y}`;
140 for (let x = 0; x <= 1200; x += 40) d += `L${x} ${f1(y + Math.sin(x * 0.011 + ph) * amp + Math.sin(x * 0.027 + ph * 2) * amp * 0.5)}`;
141 return d + "L1200 1000L0 1000Z";
142};
143const STRATA = [
144 { y: 920, fill: "#4d4a46", amp: 5, ph: 3 },
145 { y: 790, fill: "#8a6a45", amp: 7, ph: 2 },
146 { y: 640, fill: "#6b4a2e", amp: 6, ph: 1 },
147 { y: 500, fill: "#4a3020", amp: 4, ph: 0.4 },
148 { y: HZ + 2, fill: "#2b1c12", amp: 2.5, ph: 5 },
149];
150const STRATA_PATHS = STRATA.map((s) => ({ ...s, d: wave(s.y, s.amp, s.ph) }));
151
152/* Contours */
153function buildContours() {
154 const hills = [
155 { cx: 300, cy: 280, n: 13, step: 24, ax: 1.5, ay: 1, ph: 0.4 },
156 { cx: 820, cy: 430, n: 15, step: 22, ax: 1.2, ay: 1, ph: 2.1 },
157 { cx: 1050, cy: 150, n: 8, step: 20, ax: 1.1, ay: 0.9, ph: 4.2 },
158 ];
159 const out: { d: string; idx: boolean; delay: number; label?: { x: number; y: number; t: string } }[] = [];
160 hills.forEach((h, hi) => {
161 for (let i = 1; i <= h.n; i++) {
162 const base = i * h.step;
163 let d = "";
164 for (let a = 0; a <= 6.2832 + 0.01; a += 0.1) {
165 const k = 1 + 0.16 * Math.sin(2 * a + h.ph + i * 0.08) + 0.1 * Math.sin(3 * a + h.ph * 2) + 0.05 * Math.sin(5 * a + i * 0.3);
166 const x = h.cx + Math.cos(a) * base * h.ax * k;
167 const y = h.cy + Math.sin(a) * base * h.ay * k;
168 d += (a === 0 ? "M" : "L") + f1(x) + " " + f1(y);
169 }
170 const idx = i % 4 === 0;
171 out.push({
172 d: d + "Z",
173 idx,
174 delay: ((h.n - i) / h.n) * 0.7 + hi * 0.06,
175 label: idx ? { x: h.cx + base * h.ax * 1.0 + 4, y: h.cy + 4, t: `${380 + (h.n - i) * 10} m` } : undefined,
176 });
177 }
178 });
179 return out;
180}
181const CONTOURS = buildContours();
182
183/* Plots (field map) */
184type Plot = { id: string; name: string; crop: string; practice: string; since: number; ha: number; som: number; carbon: number; kind: "wheat" | "clover" | "pasture" | "orchard" | "fallow"; poly: string; clip: string; cx: number; cy: number; ang: number };
185const PLOT_DATA: Omit<Plot, "poly" | "clip" | "cx" | "cy" | "ang">[] = [
186 { id: "p1", name: "North Meadow", crop: "Perennial pasture", practice: "Rotational grazing", since: 2019, ha: 14.2, som: 6.8, carbon: 1.9, kind: "pasture" },
187 { id: "p2", name: "Hedge Row", crop: "Spelt + hedgerow", practice: "Alley cropping", since: 2020, ha: 8.6, som: 5.4, carbon: 2.7, kind: "wheat" },
188 { id: "p3", name: "Long Acre", crop: "Winter rye", practice: "No-till, cover crop", since: 2021, ha: 18.0, som: 4.9, carbon: 1.2, kind: "wheat" },
189 { id: "p4", name: "Alder Brook", crop: "Riparian buffer", practice: "Native planting", since: 2018, ha: 5.1, som: 8.2, carbon: 3.4, kind: "orchard" },
190 { id: "p5", name: "Clover Flat", crop: "Red clover ley", practice: "Nitrogen-fixing ley", since: 2022, ha: 11.7, som: 4.1, carbon: 1.1, kind: "clover" },
191 { id: "p6", name: "Stone Wall", crop: "Heritage wheat", practice: "Minimum till, compost", since: 2020, ha: 16.4, som: 5.2, carbon: 1.5, kind: "wheat" },
192 { id: "p7", name: "The Orchard", crop: "Apple + clover", practice: "Silvopasture", since: 2017, ha: 6.9, som: 7.1, carbon: 3.1, kind: "orchard" },
193 { id: "p8", name: "Pond Field", crop: "Mixed vegetables", practice: "Compost, living mulch", since: 2023, ha: 3.4, som: 3.8, carbon: 0.9, kind: "clover" },
194 { id: "p9", name: "South Fallow", crop: "Fallow / rewilding", practice: "Natural succession", since: 2016, ha: 9.8, som: 7.6, carbon: 2.2, kind: "fallow" },
195 { id: "p10", name: "Wind Hill", crop: "Barley + vetch", practice: "Relay intercrop", since: 2021, ha: 20.3, som: 4.6, carbon: 1.4, kind: "wheat" },
196 { id: "p11", name: "Mill Pasture", crop: "Herbal ley", practice: "Mob grazing", since: 2019, ha: 13.5, som: 6.2, carbon: 2.0, kind: "pasture" },
197 { id: "p12", name: "East Terrace", crop: "Oats + peas", practice: "Contour strips", since: 2022, ha: 10.2, som: 4.3, carbon: 1.3, kind: "clover" },
198];
199function buildPlots(): Plot[] {
200 const r = rng(5);
201 const V: [number, number][][] = [];
202 for (let j = 0; j <= 3; j++) {
203 V.push([]);
204 for (let i = 0; i <= 4; i++) {
205 const edgeX = i === 0 || i === 4;
206 const edgeY = j === 0 || j === 3;
207 V[j].push([60 + i * 170 + (edgeX ? (r() - 0.5) * 10 : (r() - 0.5) * 44), 40 + j * 145 + (edgeY ? (r() - 0.5) * 10 : (r() - 0.5) * 40)]);
208 }
209 }
210 return PLOT_DATA.map((p, k) => {
211 const i = k % 4;
212 const j = Math.floor(k / 4);
213 const pts = [V[j][i], V[j][i + 1], V[j + 1][i + 1], V[j + 1][i]];
214 const cx = pts.reduce((s, q) => s + q[0], 0) / 4;
215 const cy = pts.reduce((s, q) => s + q[1], 0) / 4;
216 const poly = pts.map((q) => `${f1(q[0])},${f1(q[1])}`).join(" ");
217 const ang = (r() - 0.5) * 50 + (p.kind === "pasture" ? 90 : 0);
218 return { ...p, poly, clip: poly, cx, cy, ang };
219 });
220}
221const PLOTS = buildPlots();
222
223/* Mycelium */
224function buildMyc() {
225 const r = rng(23);
226 const nodes: { x: number; y: number }[] = [{ x: 300, y: 40 }];
227 for (let i = 0; i < 44; i++) nodes.push({ x: 20 + r() * 560, y: 40 + r() * 330 });
228 const edges: { a: number; b: number; d: string; delay: number }[] = [];
229 nodes.forEach((n, i) => {
230 if (i === 0) return;
231 const near = nodes
232 .map((m, j) => ({ j, dist: Math.hypot(m.x - n.x, m.y - n.y) }))
233 .filter((o) => o.j < i)
234 .sort((a, b) => a.dist - b.dist)
235 .slice(0, i < 3 ? 1 : 2);
236 near.forEach((o) => {
237 const m = nodes[o.j];
238 const mx = (n.x + m.x) / 2 + (r() - 0.5) * 30;
239 const my = (n.y + m.y) / 2 + (r() - 0.5) * 30;
240 edges.push({ a: i, b: o.j, d: `M${f1(m.x)} ${f1(m.y)}Q${f1(mx)} ${f1(my)} ${f1(n.x)} ${f1(n.y)}`, delay: Math.min(0.75, (n.y - 40) / 420 + r() * 0.1) });
241 });
242 });
243 return { nodes, edges };
244}
245const MYC = buildMyc();
246
247/* ------------------------------------------------------------------ */
248/* Seasons */
249/* ------------------------------------------------------------------ */
250const SEASONS = ["Spring", "Summer", "Autumn", "Winter"];
251const STOPS = [
252 { sky1: "#cfe8d8", sky2: "#f6f0d6", leaf: "#6fae3b", leaf2: "#9ccf4a", wheat: "#b8c46a", bg: "#f3ecda", sun: "#ffe08a", bloom: 0.9, snow: 0, sy: 0, hill: "#7fae5a" },
253 { sky1: "#8cc8e8", sky2: "#fbe9b0", leaf: "#3e8f2d", leaf2: "#5fb540", wheat: "#e2b94a", bg: "#f5ecd0", sun: "#ffd24a", bloom: 1, snow: 0, sy: -40, hill: "#5f9b3c" },
254 { sky1: "#e8b98a", sky2: "#f7dcae", leaf: "#b86a2a", leaf2: "#d89a35", wheat: "#c98a3a", bg: "#f1e2c6", sun: "#ff9a4a", bloom: 0.72, snow: 0, sy: 40, hill: "#b9803a" },
255 { sky1: "#b9c6cf", sky2: "#e9ecec", leaf: "#7d8a6a", leaf2: "#a3ad92", wheat: "#b9a98a", bg: "#ebe9e2", sun: "#f1f5f7", bloom: 0.3, snow: 1, sy: 80, hill: "#9aa58f" },
256];
257const hex2 = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
258const mixHex = (a: string, b: string, t: number) => {
259 const A = hex2(a);
260 const B = hex2(b);
261 return "#" + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, "0")).join("");
262};
263function applySeason(el: HTMLElement, s: number) {
264 const i = Math.min(2, Math.floor(s));
265 const t = s - i;
266 const a = STOPS[i];
267 const b = STOPS[i + 1];
268 const set = (k: string, v: string) => el.style.setProperty(k, v);
269 (["sky1", "sky2", "leaf", "leaf2", "wheat", "bg", "sun", "hill"] as const).forEach((k) => set(`--${k}`, mixHex(a[k], b[k], t)));
270 set("--bloom", String(a.bloom + (b.bloom - a.bloom) * t));
271 set("--snow", String(a.snow + (b.snow - a.snow) * t));
272 set("--sy", `${a.sy + (b.sy - a.sy) * t}px`);
273}
274
275/* ------------------------------------------------------------------ */
276/* Hooks */
277/* ------------------------------------------------------------------ */
278function useInView<T extends HTMLElement>(threshold = 0.25): [React.RefObject<T | null>, boolean] {
279 const ref = useRef<T | null>(null);
280 const [seen, setSeen] = useState(false);
281 useEffect(() => {
282 const el = ref.current;
283 if (!el) return;
284 if (typeof IntersectionObserver === "undefined") {
285 setSeen(true);
286 return;
287 }
288 const io = new IntersectionObserver(
289 (es) => {
290 if (es.some((e) => e.isIntersecting)) {
291 setSeen(true);
292 io.disconnect();
293 }
294 },
295 { threshold },
296 );
297 io.observe(el);
298 return () => io.disconnect();
299 }, [threshold]);
300 return [ref, seen];
301}
302const v = (o: Record<string, string | number>) => o as unknown as CSSProperties;
303
304/* ------------------------------------------------------------------ */
305/* Hero scene */
306/* ------------------------------------------------------------------ */
307function Scene() {
308 const S = SCENE;
309 const ticks = [0, 30, 60, 120, 200];
310 const tickY = [HZ, 500, 640, 790, 920];
311 const labels = [
312 ["O", "Litter layer", HZ + 22],
313 ["A", "Topsoil, 6% organic matter", 570],
314 ["B", "Subsoil", 715],
315 ["C", "Parent material", 860],
316 ["R", "Bedrock", 965],
317 ] as const;
318 return (
319 <svg className="tr-scene" viewBox="0 0 1200 1000" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Cross-section from sky to root zone: crops, a tree and their roots growing through five soil layers while carbon is stored in the soil">
320 <defs>
321 <linearGradient id="tr-sky" x1="0" y1="0" x2="0" y2="1">
322 <stop offset="0" stopColor="var(--sky1)" />
323 <stop offset="1" stopColor="var(--sky2)" />
324 </linearGradient>
325 <linearGradient id="tr-fade" x1="0" y1="0" x2="0" y2="1">
326 <stop offset="0" stopColor="#000" stopOpacity="0" />
327 <stop offset="1" stopColor="#000" stopOpacity=".28" />
328 </linearGradient>
329 </defs>
330 <rect width="1200" height={HZ + 4} fill="url(#tr-sky)" />
331 <g transform={`translate(0 ${HZ - 330})`}>
332 <circle className="tr-sun" cx="860" cy="120" r="46" fill="var(--sun)" />
333 <circle className="tr-sun tr-halo" cx="860" cy="120" r="78" fill="var(--sun)" />
334 <g className="tr-clouds" fill="#fff" opacity=".7">
335 <g className="tr-cl a"><ellipse cx="200" cy="90" rx="70" ry="16" /><ellipse cx="240" cy="76" rx="40" ry="14" /></g>
336 <g className="tr-cl b"><ellipse cx="620" cy="150" rx="90" ry="14" /><ellipse cx="660" cy="136" rx="46" ry="13" /></g>
337 <g className="tr-cl c"><ellipse cx="1000" cy="60" rx="60" ry="12" /></g>
338 </g>
339 <path d="M0 300Q150 240 300 280T620 270T900 285T1200 250V340H0Z" fill="var(--hill)" opacity=".28" />
340 <path d="M0 320Q200 280 420 308T800 300T1200 296V340H0Z" fill="var(--hill)" opacity=".5" />
341 </g>
342
343 {/* soil horizons */}
344 {STRATA_PATHS.map((s) => <path key={s.y} d={s.d} fill={s.fill} />)}
345 <rect y={HZ} width="1200" height="542" fill="url(#tr-fade)" />
346 {S.specks.map((p, i) => <circle key={i} cx={f1(p.x)} cy={f1(p.y)} r={f1(p.r)} fill={p.c} opacity=".55" />)}
347 {S.worms.map((d, i) => <path key={i} d={d} fill="none" stroke="#d99a8e" strokeWidth="4" strokeLinecap="round" opacity=".7" />)}
348
349 {/* snow */}
350 <path className="tr-snow" d={`M0 ${HZ - 4}Q60 ${HZ - 12} 120 ${HZ - 4}T240 ${HZ - 4}T360 ${HZ - 4}T480 ${HZ - 4}T600 ${HZ - 4}T720 ${HZ - 4}T840 ${HZ - 4}T960 ${HZ - 4}T1080 ${HZ - 4}T1200 ${HZ - 4}V${HZ + 6}H0Z`} fill="#f7fbfc" />
351
352 {/* roots */}
353 <g fill="none" strokeLinecap="round" stroke="#efdcb4">
354 {S.roots.map((p, i) => <path key={i} className="tr-dr" pathLength={1} d={p.d} strokeWidth={p.w} style={v({ "--d": p.delay, opacity: p.o ?? 1 })} />)}
355 </g>
356
357 {/* stored carbon */}
358 <g className="tr-store">
359 {S.store.map((c, i) => <rect key={i} className="tr-fleck" x={f1(c.x)} y={f1(c.y)} width="5" height="5" style={v({ "--d": c.delay })} />)}
360 </g>
361
362 {/* plants */}
363 <g>
364 {S.stems.map((p, i) => <path key={i} className="tr-ds" pathLength={1} d={p.d} fill="none" stroke={p.w > 8 ? "#5b3d26" : p.w > 4 ? "#6b4a2e" : "var(--stem, #8b9a4a)"} strokeWidth={p.w} strokeLinecap="round" style={v({ "--d": p.delay })} />)}
365 {S.blades.map((p, i) => <path key={i} className="tr-ds" pathLength={1} d={p.d} fill="none" stroke="var(--leaf)" strokeWidth={p.w} strokeLinecap="round" style={v({ "--d": p.delay })} />)}
366 {S.leaves.map((l, i) => (
367 <ellipse key={i} className="tr-sc" cx={f1(l.x)} cy={f1(l.y)} rx={l.rx} ry={l.ry} fill={l.c === 1 ? "var(--leaf)" : "var(--leaf2)"} transform={`rotate(${l.rot})`} style={v({ "--d": l.delay, "--o": "50% 50%" })} />
368 ))}
369 {S.flowers.map((fl, i) => (
370 <g key={i} className="tr-sc" style={v({ "--d": fl.delay })}>
371 {[0, 72, 144, 216, 288].map((a) => <circle key={a} cx={f1(fl.x + Math.cos((a * Math.PI) / 180) * 5)} cy={f1(fl.y + Math.sin((a * Math.PI) / 180) * 5)} r="3.6" fill="var(--wheat)" />)}
372 <circle cx={fl.x} cy={fl.y} r="2.6" fill="#7a4a1c" />
373 </g>
374 ))}
375 {S.ears.map((e, i) => (
376 <g key={i} className="tr-sc" transform={`rotate(${f1(e.rot)})`} style={v({ "--d": e.delay })}>
377 {[0, 1, 2, 3, 4].map((k) => (
378 <g key={k}>
379 <ellipse cx={f1(e.x - 4)} cy={f1(e.y - 4 - k * 7)} rx="2.8" ry="5.6" fill="var(--wheat)" transform={`rotate(-24 ${f1(e.x - 4)} ${f1(e.y - 4 - k * 7)})`} />
380 <ellipse cx={f1(e.x + 4)} cy={f1(e.y - 7 - k * 7)} rx="2.8" ry="5.6" fill="var(--wheat)" transform={`rotate(24 ${f1(e.x + 4)} ${f1(e.y - 7 - k * 7)})`} />
381 </g>
382 ))}
383 <path d={`M${f1(e.x)} ${f1(e.y - 38)}l0 -22`} stroke="var(--wheat)" strokeWidth="1.5" />
384 </g>
385 ))}
386 </g>
387
388 {/* falling carbon */}
389 <g className="tr-fall" fill="var(--sun)">
390 {S.falling.map((c, i) => (
391 <g key={i} className="tr-co" style={v({ "--dx": `${f1(c.dx)}px`, "--dy": `${f1(c.dy)}px`, animationDuration: `${f1(c.dur)}s`, animationDelay: `${f1(c.del)}s` })}>
392 <circle cx={f1(c.x)} cy="150" r="5" fill="#fff" stroke="#2a1d14" strokeWidth="1.2" />
393 <text x={f1(c.x)} y="153" fontSize="7" textAnchor="middle" fill="#2a1d14" fontFamily="ui-monospace,monospace" fontWeight="700">C</text>
394 </g>
395 ))}
396 </g>
397
398 {/* labels */}
399 <g className="tr-lab" fontFamily="ui-monospace,SFMono-Regular,Menlo,monospace" fontSize="13">
400 {labels.map(([k, t, y]) => (
401 <g key={k}>
402 <text x="22" y={y} fill="#f3ecda" opacity=".9"><tspan fontWeight="700" fill="#3be34f">{k}</tspan> {t}</text>
403 </g>
404 ))}
405 {ticks.map((t, i) => (
406 <g key={t} opacity=".7">
407 <path d={`M1140 ${tickY[i]}h38`} stroke="#f3ecda" strokeWidth="1" />
408 <text x="1176" y={tickY[i] - 5} textAnchor="end" fill="#f3ecda">{t} cm</text>
409 </g>
410 ))}
411 </g>
412 </svg>
413 );
414}
415
416/* ------------------------------------------------------------------ */
417/* Sections */
418/* ------------------------------------------------------------------ */
419const STEPS = [
420 ["01", "Seed", "Living roots feed soil life from day one."],
421 ["02", "Shoot", "Cover crops and wheat turn sunlight into sugar."],
422 ["03", "Root", "Roots push carbon down through every horizon."],
423 ["04", "Store", "Fungi lock it into stable humus for decades."],
424];
425
426function Hero({ reduced }: { reduced: boolean }) {
427 return (
428 <header className={`tr-hero${reduced ? " is-static" : ""}`} id="top">
429 <div className="tr-stage" data-hero style={v({ "--p": reduced ? 1 : 0, "--gs": reduced ? 1 : 0, "--gr": reduced ? 1 : 0, "--gc": reduced ? 1 : 0, "--tp": 0 })}>
430 <Scene />
431 <div className="tr-hero-t">
432 <p className="tr-eyebrow">Terra / regenerative farming collective</p>
433 <h1>Grow the <br className="tr-br" /><em>ground</em> back.</h1>
434 <p className="tr-lede">We help farms rebuild living soil, and we pay for the carbon it keeps. Scroll to plant a season.</p>
435 </div>
436 <div className="tr-hint" aria-hidden="true"><span>scroll to grow</span><i /></div>
437 <div className="tr-counter" aria-live="off">
438 <small>carbon banked in this hectare</small>
439 <b><span data-counter>{reduced ? "+3.80" : "+0.00"}</span> <i>t CO₂e / yr</i></b>
440 </div>
441 <ol className="tr-steps" data-steps>
442 {STEPS.map(([n, t, d], i) => (
443 <li key={n} data-i={i}>
444 <span>{n}</span>
445 <b>{t}</b>
446 <em>{d}</em>
447 </li>
448 ))}
449 </ol>
450 </div>
451 </header>
452 );
453}
454
455function Contours() {
456 return (
457 <section className="tr-sec tr-contour" id="land" data-prog>
458 <svg className="tr-contour-svg" viewBox="0 0 1200 560" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
459 {CONTOURS.map((c, i) => (
460 <g key={i}>
461 <path className={`tr-dc${c.idx ? " idx" : ""}`} pathLength={1} d={c.d} fill="none" style={v({ "--d": c.delay })} />
462 {c.label && <text className="tr-dc-l" x={f1(c.label.x)} y={f1(c.label.y)} style={v({ "--d": c.delay + 0.2 })}>{c.label.t}</text>}
463 </g>
464 ))}
465 <path className="tr-dc fence" pathLength={1} d="M120 80L520 60L560 320L180 360Z" fill="none" style={v({ "--d": 0.85 })} />
466 </svg>
467 <div className="tr-wrap tr-contour-in">
468 <div className="tr-card">
469 <p className="tr-eyebrow dark">01 / Read the land</p>
470 <h2>Plant the water <em>before</em> the crop.</h2>
471 <p>
472 Every Terra farm begins with a contour survey. We map slope, flow and
473 infiltration, then place keylines, swales and cover so rain walks
474 slowly through the field instead of running off it.
475 </p>
476 <dl className="tr-stats">
477 <div><dt>-38%</dt><dd>runoff after two seasons</dd></div>
478 <div><dt>2.1x</dt><dd>faster water infiltration</dd></div>
479 <div><dt>0</dt><dd>days of bare soil</dd></div>
480 </dl>
481 </div>
482 </div>
483 </section>
484 );
485}
486
487const PRACTICES = [
488 { n: "Agroforestry", v: 3.1 },
489 { n: "Rotational grazing", v: 1.6 },
490 { n: "Cover crops", v: 1.2 },
491 { n: "Compost & biochar", v: 1.0 },
492 { n: "No-till", v: 0.6 },
493];
494const TREND = [4.1, 12.8, 31, 58, 96, 141, 184.2];
495
496function Ledger() {
497 const [ref, seen] = useInView<HTMLDivElement>(0.2);
498 const [tons, setTons] = useState(184206.4);
499 useEffect(() => {
500 const t0 = Date.now();
501 const id = window.setInterval(() => setTons(184206.4 + ((Date.now() - t0) / 1000) * 0.38), 100);
502 return () => window.clearInterval(id);
503 }, []);
504 const pts = TREND.map((val, i) => `${40 + i * 90},${210 - (val / 190) * 180}`);
505 const line = "M" + pts.join("L");
506 return (
507 <section className="tr-sec tr-ledger" id="carbon">
508 <div className="tr-wrap" ref={ref}>
509 <p className="tr-eyebrow">02 / The ledger</p>
510 <h2>Every tonne, <em>counted in soil.</em></h2>
511 <div className="tr-big" aria-live="off">
512 <b>{tons.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}</b>
513 <span>tonnes CO₂e verified in Terra soils since 2019</span>
514 </div>
515 <div className={`tr-ledger-grid${seen ? " in" : ""}`}>
516 <div className="tr-panel">
517 <h3>Per hectare, per year</h3>
518 {PRACTICES.map((p, i) => (
519 <div className="tr-bar" key={p.n}>
520 <span>{p.n}</span>
521 <div><i style={v({ "--w": p.v / 3.2, "--i": i })} /></div>
522 <b>{p.v.toFixed(1)} t</b>
523 </div>
524 ))}
525 </div>
526 <div className="tr-panel">
527 <h3>Cumulative, thousand tonnes</h3>
528 <svg viewBox="0 0 640 240" role="img" aria-label="Carbon banked per year rising from 4 thousand tonnes in 2019 to 184 thousand in 2026">
529 {[0, 1, 2, 3].map((k) => <path key={k} d={`M40 ${30 + k * 60}H620`} stroke="rgba(243,236,218,.14)" />)}
530 <path className="tr-line" pathLength={1} d={line} fill="none" />
531 <path className="tr-area" d={`${line}L${40 + 6 * 90} 210L40 210Z`} />
532 {pts.map((p, i) => {
533 const [x, y] = p.split(",");
534 return (
535 <g key={i}>
536 <circle className="tr-dot" cx={x} cy={y} r="4.5" style={v({ "--i": i })} />
537 <text x={x} y="232" textAnchor="middle" className="tr-ax">{2019 + i}</text>
538 </g>
539 );
540 })}
541 </svg>
542 <p className="tr-note">Verified by deep-core sampling every five years. Credits retire only after verification.</p>
543 </div>
544 </div>
545 </div>
546 </section>
547 );
548}
549
550function FieldMap() {
551 const [active, setActive] = useState<string | null>(null);
552 const [pinned, setPinned] = useState<string | null>(null);
553 const cur = PLOTS.find((p) => p.id === (active ?? pinned)) ?? null;
554 const [ref, seen] = useInView<HTMLDivElement>(0.15);
555 const fillFor = (k: Plot["kind"]) =>
556 ({ wheat: "var(--wheat)", clover: "var(--leaf2)", pasture: "var(--leaf)", orchard: "#5a6b33", fallow: "#a58b63" })[k];
557 return (
558 <section className="tr-sec tr-plots" id="plots">
559 <div className="tr-wrap">
560 <p className="tr-eyebrow dark">03 / The farm map</p>
561 <h2>Twelve plots, <em>each with a story.</em></h2>
562 <div className={`tr-map-grid${seen ? " in" : ""}`} ref={ref}>
563 <div className="tr-map">
564 <svg viewBox="0 0 800 520" role="group" aria-label="Field map of Hollin Farm with twelve plots. Hover, focus or tap a plot for details.">
565 <defs>
566 {PLOTS.map((p) => <clipPath key={p.id} id={`tr-c-${p.id}`}><polygon points={p.clip} /></clipPath>)}
567 </defs>
568 <rect width="800" height="520" fill="rgba(42,29,20,.06)" />
569 {PLOTS.map((p) => {
570 const on = cur?.id === p.id;
571 const lines = Array.from({ length: 16 }, (_, k) => k);
572 return (
573 <g
574 key={p.id}
575 className={`tr-plot${on ? " on" : ""}`}
576 tabIndex={0}
577 role="button"
578 aria-label={`${p.name}, ${p.crop}`}
579 aria-pressed={pinned === p.id}
580 onMouseEnter={() => setActive(p.id)}
581 onMouseLeave={() => setActive(null)}
582 onFocus={() => setActive(p.id)}
583 onBlur={() => setActive(null)}
584 onClick={() => setPinned((s) => (s === p.id ? null : p.id))}
585 onKeyDown={(e) => {
586 if (e.key === "Enter" || e.key === " ") {
587 e.preventDefault();
588 setPinned((s) => (s === p.id ? null : p.id));
589 }
590 }}
591 >
592 <polygon points={p.poly} className="tr-plot-f" fill={fillFor(p.kind)} />
593 <g clipPath={`url(#tr-c-${p.id})`}>
594 <g transform={`rotate(${f1(p.ang)} ${f1(p.cx)} ${f1(p.cy)})`}>
595 {lines.map((k) => (
596 <path key={k} className="tr-row" pathLength={1} d={`M${f1(p.cx - 130)} ${f1(p.cy - 90 + k * 12)}H${f1(p.cx + 130)}`} style={v({ "--i": k })} />
597 ))}
598 </g>
599 </g>
600 <polygon points={p.poly} className="tr-plot-o" fill="none" />
601 <text x={f1(p.cx)} y={f1(p.cy + 4)} textAnchor="middle" className="tr-plot-t">{p.name}</text>
602 </g>
603 );
604 })}
605 <path className="tr-river" d="M-10 262C120 240 190 300 330 270S560 230 650 275 760 300 810 285" fill="none" />
606 <text x="700" y="304" className="tr-river-t" transform="rotate(8 700 304)">Alder Brook</text>
607 </svg>
608 <div className="tr-legend" aria-hidden="true">
609 <span><i style={{ background: "var(--wheat)" }} />Grain</span>
610 <span><i style={{ background: "var(--leaf2)" }} />Ley / veg</span>
611 <span><i style={{ background: "var(--leaf)" }} />Pasture</span>
612 <span><i style={{ background: "#5a6b33" }} />Trees</span>
613 <span><i style={{ background: "#a58b63" }} />Rewilding</span>
614 </div>
615 </div>
616 <aside className="tr-info" aria-live="polite">
617 {cur ? (
618 <>
619 <p className="tr-eyebrow dark">{cur.crop}</p>
620 <h3>{cur.name}</h3>
621 <p className="tr-info-p">{cur.practice}, regenerative since {cur.since}.</p>
622 <dl>
623 <div><dt>Area</dt><dd>{cur.ha} ha</dd></div>
624 <div><dt>Organic matter</dt><dd>{cur.som}%</dd></div>
625 <div><dt>Carbon</dt><dd>{cur.carbon} t/ha/yr</dd></div>
626 </dl>
627 <div className="tr-som"><i style={v({ "--w": Math.min(1, cur.som / 9) })} /></div>
628 <small>Soil organic matter vs. a 9% native-prairie benchmark.</small>
629 </>
630 ) : (
631 <>
632 <p className="tr-eyebrow dark">Hollin Farm / 128 ha</p>
633 <h3>Hover a plot.</h3>
634 <p className="tr-info-p">Crop rows grow into the field and its soil numbers appear here. Tap or press Enter to pin one.</p>
635 </>
636 )}
637 </aside>
638 </div>
639 </div>
640 </section>
641 );
642}
643
644function Mycelium() {
645 const [ref, seen] = useInView<HTMLDivElement>(0.25);
646 const signals = MYC.edges.filter((_, i) => i % 9 === 0).slice(0, 6);
647 return (
648 <section className="tr-sec tr-myc" id="soil">
649 <div className="tr-wrap tr-myc-grid" ref={ref}>
650 <div className={`tr-myc-svg${seen ? " in" : ""}`}>
651 <svg viewBox="0 0 600 400" role="img" aria-label="A procedurally drawn fungal network linking plant roots, with signals travelling along it">
652 {MYC.edges.map((e, i) => <path key={i} className="tr-hy" pathLength={1} d={e.d} fill="none" style={v({ "--d": e.delay })} />)}
653 {MYC.nodes.map((n, i) => <circle key={i} className="tr-nd" cx={f1(n.x)} cy={f1(n.y)} r={i === 0 ? 8 : 3} style={v({ "--d": i === 0 ? 0 : Math.min(0.8, (n.y - 40) / 420) })} />)}
654 {signals.map((e, i) => <circle key={i} className="tr-sig" r="3.4" style={v({ offsetPath: `path("${e.d}")`, animationDelay: `${i * 0.9}s` })} />)}
655 </svg>
656 </div>
657 <div>
658 <p className="tr-eyebrow">04 / Under one footstep</p>
659 <h2>The <em>wood-wide</em> web does the heavy lifting.</h2>
660 <p className="tr-lead">
661 Mycorrhizal fungi trade minerals for sugar and bind carbon into
662 aggregates that outlast any single crop. Tillage tears the web;
663 living cover rebuilds it. We measure both.
664 </p>
665 <div className="tr-count-row">
666 <Count to={1000} suffix="M" label="bacteria in a teaspoon" go={seen} />
667 <Count to={48} suffix=" m" label="fungal hyphae per gram" go={seen} />
668 <Count to={19} suffix="%" label="more water held per point of organic matter" go={seen} />
669 </div>
670 </div>
671 </div>
672 </section>
673 );
674}
675
676function Count({ to, suffix, label, go }: { to: number; suffix: string; label: string; go: boolean }) {
677 const [val, setVal] = useState(to);
678 const mounted = useRef(false);
679 useEffect(() => {
680 if (!mounted.current) {
681 mounted.current = true;
682 if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) setVal(0);
683 return;
684 }
685 if (!go) return;
686 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
687 setVal(to);
688 return;
689 }
690 let raf = 0;
691 const t0 = performance.now();
692 const tick = (now: number) => {
693 const p = Math.min(1, (now - t0) / 1600);
694 setVal(Math.round(to * (1 - Math.pow(1 - p, 3))));
695 if (p < 1) raf = requestAnimationFrame(tick);
696 };
697 raf = requestAnimationFrame(tick);
698 return () => cancelAnimationFrame(raf);
699 }, [go, to]);
700 return (
701 <div className="tr-count">
702 <b>{val.toLocaleString("en-US")}{suffix}</b>
703 <span>{label}</span>
704 </div>
705 );
706}
707
708const LOOK: Record<string, string> = { p1: "a woman with a gray bob", p2: "a bearded man with dark hair", p3: "a woman with braided hair", p4: "a man with short curly hair", p5: "a smiling man with a short beard", p6: "a woman with long wavy dark hair", p7: "a bearded man in round glasses", p8: "a smiling man with swept-back hair", p9: "a woman with natural curly hair" };
709const portraitAlt = (n: string, r: string, src: string) => `${n}, ${r}: portrait of ${LOOK[src.match(/p\d/)?.[0] ?? ""] ?? "a person"} in a black sweater`;
710
711const VOICES = [
712 { q: "We stopped fighting the field and started feeding it. Year four, the wheat stands in a drought and the earthworms are back in the headlands.", n: "Maren Holt", img: "/img/people/p1.webp", r: "Long Acre, 62 ha, Lower Saxony" },
713 { q: "Terra paid for the hedgerows before the first harvest from them. That is the only reason my bank said yes.", n: "Joaquin Ferrer", img: "/img/people/p2.webp", r: "Vall Seca orchards, Catalonia" },
714 { q: "The map showed me my worst corner was my best carbon. I would never have guessed. Now I plan around it.", n: "Aoife Brennan", img: "/img/people/p6.webp", r: "Mill Pasture, 40 ha, County Meath" },
715];
716
717function Voices() {
718 const [ref, seen] = useInView<HTMLDivElement>(0.2);
719 return (
720 <section className="tr-sec tr-voices" id="growers">
721 <div className="tr-wrap" ref={ref}>
722 <p className="tr-eyebrow dark">05 / Growers</p>
723 <h2>Farmers who <em>left the soil better.</em></h2>
724 <div className={`tr-vgrid${seen ? " in" : ""}`}>
725 {VOICES.map((x, i) => (
726 <figure key={x.n} className="tr-voice" style={v({ "--i": i })}>
727 <svg viewBox="0 0 40 40" width="34" height="34" aria-hidden="true"><path d="M20 38V16" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /><path d="M20 22C8 22 6 12 6 6c8 0 14 4 14 16ZM20 18c0-9 5-12 14-12 0 7-4 12-14 12Z" fill="var(--leaf)" /></svg>
728 <blockquote>{x.q}</blockquote>
729 <figcaption><img src={x.img} alt={portraitAlt(x.n, `farmer at ${x.r}`, x.img)} width={52} height={52} loading="lazy" decoding="async" /><b>{x.n}</b><span>{x.r}</span></figcaption>
730 </figure>
731 ))}
732 </div>
733 </div>
734 </section>
735 );
736}
737
738function Field() {
739 const stalks = useMemo(() => {
740 const r = rng(77);
741 return Array.from({ length: 56 }, (_, i) => ({ x: i * 22 + r() * 12, h: 60 + r() * 90, d: r() * 0.4, s: 3 + r() * 3, del: -r() * 5 }));
742 }, []);
743 return (
744 <svg className="tr-field" viewBox="0 0 1240 180" preserveAspectRatio="none" aria-hidden="true">
745 {stalks.map((s, i) => (
746 <g key={i} className="tr-fs" style={v({ "--d": s.d, animationDuration: `${s.s}s`, animationDelay: `${s.del}s`, transformOrigin: `${f1(s.x)}px 180px` })}>
747 <path d={`M${f1(s.x)} 180Q${f1(s.x + 4)} ${180 - s.h / 2} ${f1(s.x + 2)} ${180 - s.h}`} stroke="var(--wheat)" strokeWidth="2.5" fill="none" />
748 <ellipse cx={f1(s.x + 2)} cy={180 - s.h - 8} rx="3.4" ry="9" fill="var(--wheat)" />
749 <path d={`M${f1(s.x)} ${180 - s.h * 0.3}q-14 -10 -20 -26`} stroke="var(--leaf)" strokeWidth="2.5" fill="none" />
750 </g>
751 ))}
752 </svg>
753 );
754}
755
756function Join() {
757 const [done, setDone] = useState(false);
758 const [ha, setHa] = useState("50-200");
759 const [mail, setMail] = useState("");
760 const [err, setErr] = useState("");
761 return (
762 <section className="tr-sec tr-join" id="join" data-prog>
763 <div className="tr-wrap tr-join-in">
764 <p className="tr-eyebrow">06 / Begin</p>
765 <h2>Start with a <em>soil test.</em></h2>
766 <p className="tr-lead">We core, map and model your land for free. If the numbers work, we co-fund the first three years of transition.</p>
767 {done ? (
768 <div className="tr-done" role="status"><b>Seed planted.</b> A Terra agronomist will write to {mail} within two working days.</div>
769 ) : (
770 <form
771 className="tr-form"
772 noValidate
773 onSubmit={(e) => {
774 e.preventDefault();
775 if (!/.+@.+\..+/.test(mail)) return setErr("Please enter a valid e-mail.");
776 setErr("");
777 setDone(true);
778 }}
779 >
780 <label>Farm size
781 <select value={ha} onChange={(e) => setHa(e.target.value)}>
782 {["under 20", "20-50", "50-200", "200-1000", "over 1000"].map((x) => <option key={x} value={x}>{x} ha</option>)}
783 </select>
784 </label>
785 <label>E-mail
786 <input type="email" value={mail} onChange={(e) => setMail(e.target.value)} placeholder="you@farm.example" autoComplete="email" />
787 </label>
788 <button type="submit">Request a soil test</button>
789 {err && <p className="tr-err" role="alert">{err}</p>}
790 </form>
791 )}
792 </div>
793 <Field />
794 <footer className="tr-foot">
795 <span>Terra Regenerative Collective, 2026</span>
796 <span>Soil is the largest living carbon store we can rebuild.</span>
797 </footer>
798 </section>
799 );
800}
801
802/* ------------------------------------------------------------------ */
803/* Page */
804/* ------------------------------------------------------------------ */
805function Nav({ children }: { children?: ReactNode }) {
806 return (
807 <nav className="tr-nav" aria-label="Main">
808 <a href="#top" className="tr-logo">
809 <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M12 22V9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /><path d="M12 14C5 14 3 8 3 3c6 0 9 3 9 11ZM12 11c0-6 3-8 9-8 0 5-3 8-9 8Z" fill="#3be34f" stroke="currentColor" strokeWidth="1.2" /></svg>
810 Terra
811 </a>
812 <div className="tr-links">
813 <a href="#land">Land</a><a href="#carbon">Carbon</a><a href="#plots">Plots</a><a href="#soil">Soil</a><a href="#growers">Growers</a>
814 </div>
815 <a className="tr-cta" href="#join">Soil test</a>
816 {children}
817 </nav>
818 );
819}
820
821export function Page() {
822 const root = useRef<HTMLDivElement | null>(null);
823 const [season, setSeason] = useState(0.35);
824 const [reduced, setReduced] = useState(false);
825
826 useEffect(() => {
827 if (root.current) applySeason(root.current, season);
828 }, [season]);
829
830 useEffect(() => {
831 const el = root.current;
832 if (!el) return;
833 const rm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
834 setReduced(rm);
835 if (rm) return;
836 const stage = el.querySelector<HTMLElement>("[data-hero]");
837 const track = stage?.parentElement ?? null;
838 const counter = el.querySelector<HTMLElement>("[data-counter]");
839 const steps = el.querySelector<HTMLElement>("[data-steps]");
840 const progs = Array.from(el.querySelectorAll<HTMLElement>("[data-prog]"));
841 const clamp = (x: number) => Math.max(0, Math.min(1, x));
842 let raf = 0;
843 let lastP = -1;
844 let lastStep = -1;
845 const update = () => {
846 raf = 0;
847 const vh = window.innerHeight;
848 if (stage && track) {
849 const rect = track.getBoundingClientRect();
850 const p = clamp(-rect.top / Math.max(1, rect.height - vh));
851 if (Math.abs(p - lastP) > 0.0004) {
852 lastP = p;
853 const gs = clamp(p / 0.42);
854 const gr = clamp((p - 0.28) / 0.5);
855 const gc = clamp((p - 0.5) / 0.45);
856 stage.style.setProperty("--p", p.toFixed(4));
857 stage.style.setProperty("--gs", gs.toFixed(4));
858 stage.style.setProperty("--gr", gr.toFixed(4));
859 stage.style.setProperty("--gc", gc.toFixed(4));
860 if (counter) counter.textContent = "+" + (gc * 3.8).toFixed(2);
861 const step = p < 0.1 ? 0 : p < 0.38 ? 1 : p < 0.7 ? 2 : 3;
862 if (step !== lastStep && steps) {
863 lastStep = step;
864 steps.dataset.step = String(step);
865 }
866 }
867 }
868 progs.forEach((s) => {
869 const r = s.getBoundingClientRect();
870 if (r.bottom < -100 || r.top > vh + 100) return;
871 s.style.setProperty("--sp", clamp((vh - r.top) / (vh * 0.85 + r.height * 0.5)).toFixed(4));
872 });
873 };
874 const onScroll = () => {
875 if (!raf) raf = requestAnimationFrame(update);
876 };
877 update();
878 window.addEventListener("scroll", onScroll, { passive: true });
879 window.addEventListener("resize", onScroll);
880 return () => {
881 window.removeEventListener("scroll", onScroll);
882 window.removeEventListener("resize", onScroll);
883 if (raf) cancelAnimationFrame(raf);
884 };
885 }, []);
886
887 const sIdx = Math.min(3, Math.round(season));
888 return (
889 <div className={`tr${reduced ? " reduced" : ""}`} ref={root}>
890 <style>{CSS}</style>
891 <Nav />
892 <Hero reduced={reduced} />
893 <div className="tr-edge" aria-hidden="true">
894 <svg aria-hidden="true" focusable="false" viewBox="0 0 1200 60" preserveAspectRatio="none"><path d="M0 0H1200V30Q1100 55 1000 34T800 34T600 26T400 38T200 26T0 36Z" fill="#2b1c12" /></svg>
895 </div>
896 <main>
897 <Contours />
898 <Ledger />
899 <FieldMap />
900 <Mycelium />
901 <Voices />
902 <Join />
903 </main>
904 <div className="tr-season" role="group" aria-label="Season">
905 <span className="tr-season-n">{SEASONS[sIdx]}</span>
906 <input
907 type="range"
908 min={0}
909 max={3}
910 step={0.01}
911 value={season}
912 aria-label="Season, spring to winter"
913 aria-valuetext={SEASONS[sIdx]}
914 onChange={(e) => setSeason(Number(e.target.value))}
915 />
916 <div className="tr-season-t" aria-hidden="true">{SEASONS.map((s) => <span key={s}>{s.slice(0, 3)}</span>)}</div>
917 </div>
918 </div>
919 );
920}
921
922export default Page;
923
924/* ------------------------------------------------------------------ */
925/* Styles */
926/* ------------------------------------------------------------------ */
927const CSS = `
928.tr{--soil:#2b1c12;--soil2:#5b3d26;--moss:#5a6b33;--ink:#2a1d14;--cream:#f3ecda;--chl:#3be34f;
929 --sky1:#cfe8d8;--sky2:#f6f0d6;--leaf:#6fae3b;--leaf2:#9ccf4a;--wheat:#b8c46a;--bg:#f3ecda;--sun:#ffe08a;--bloom:.9;--snow:0;--sy:0px;--hill:#7fae5a;
930 --serif:"Fraunces","Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
931 --sans:"Instrument Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
932 --mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
933 box-sizing:border-box;position:relative;font-family:var(--sans);font-size:16px;line-height:1.55;color:var(--ink);background:var(--bg);transition:background-color .3s;overflow-x:clip}
934.tr *,.tr *::before,.tr *::after{box-sizing:border-box}
935.tr h1,.tr h2,.tr h3{font-family:var(--serif);font-weight:500;line-height:1.02;letter-spacing:-.02em;margin:0}
936.tr h2{font-size:clamp(34px,5.4vw,70px);margin-bottom:22px}
937.tr h3{font-size:22px;letter-spacing:-.01em}
938.tr em{font-style:italic;color:var(--moss)}
939.tr p{margin:0 0 1em}
940.tr a{color:inherit}
941.tr-wrap{max-width:1160px;margin:0 auto;padding:0 28px;position:relative}
942.tr-eyebrow{font-family:var(--mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;margin:0 0 16px;color:var(--cream);opacity:.85}
943.tr-eyebrow.dark{color:var(--moss);opacity:1}
944.tr-sec{position:relative;padding:120px 0}
945.tr-lead{font-size:19px;max-width:52ch}
946
947/* nav */
948.tr-nav{position:fixed;top:0;left:0;right:0;z-index:40;display:flex;align-items:center;gap:24px;padding:14px 28px;color:var(--cream);mix-blend-mode:normal}
949.tr .tr-logo{display:flex;align-items:center;gap:8px;font-family:var(--serif);font-size:24px;text-decoration:none;font-weight:600;color:var(--ink);background:rgba(243,236,218,.85);padding:4px 12px 4px 8px;border-radius:99px}
950.tr-links{display:flex;gap:4px;margin-left:auto;background:rgba(243,236,218,.85);padding:4px;border-radius:99px;color:var(--ink)}
951.tr-links a{padding:6px 12px;border-radius:99px;font-size:14px;text-decoration:none}
952.tr-links a:hover{background:var(--ink);color:var(--cream)}
953.tr-cta{background:var(--chl);color:#0c2a10!important;padding:9px 16px;border-radius:99px;font-weight:700;font-size:14px;text-decoration:none}
954.tr a:focus-visible,.tr button:focus-visible,.tr select:focus-visible,.tr input:focus-visible,.tr .tr-plot:focus-visible{outline:3px solid var(--chl);outline-offset:3px}
955
956/* hero */
957.tr-hero{position:relative;height:480vh;background:var(--soil)}
958.tr-hero.is-static{height:100vh;min-height:680px}
959.tr-stage{position:sticky;top:0;height:100vh;min-height:620px;overflow:hidden;background:var(--soil)}
960.tr-scene{position:absolute;inset:0;width:100%;height:100%;display:block}
961.tr-sun{transform:translateY(var(--sy));transition:transform .2s}
962.tr-halo{opacity:.25}
963.tr-cl{animation:tr-cloud 60s linear infinite alternate}
964.tr-cl.b{animation-duration:80s}.tr-cl.c{animation-duration:50s}
965@keyframes tr-cloud{from{transform:translateX(-60px)}to{transform:translateX(80px)}}
966.tr-snow{opacity:var(--snow)}
967.tr-ds,.tr-dr{--q:clamp(0,calc((var(--gs) * 1.4 - var(--d,0)) * 3),1);stroke-dasharray:1;stroke-dashoffset:calc(1 - var(--q))}
968.tr-dr{--q:clamp(0,calc((var(--gr) * 1.4 - var(--d,0)) * 3),1)}
969.tr-ds,.tr-dr{opacity:clamp(0,calc(var(--q) * 60),1)}
970.tr-dr{opacity:clamp(0,calc(var(--q) * 60),1)}
971.tr-sc{--q:clamp(0,calc((var(--gs) * 1.4 - var(--d,0)) * 3),1);transform-box:fill-box;transform-origin:50% 100%}
972ellipse.tr-sc{transform-origin:50% 50%}
973.tr-sc{opacity:clamp(0,calc(var(--q) * 60),1);scale:calc(var(--q) * var(--bloom,1))}
974.tr-fleck{--q:clamp(0,calc((var(--gc) * 1.5 - var(--d,0)) * 3),1);fill:var(--chl);opacity:clamp(0,calc(var(--q) * .95),.95)}
975.tr-fall{opacity:clamp(0,calc(var(--p) * 6 - .1),.95)}
976.tr-co{animation:tr-fall 9s linear infinite}
977@keyframes tr-fall{0%{transform:translate(0,0);opacity:0}10%{opacity:1}60%{opacity:1}100%{transform:translate(var(--dx),var(--dy));opacity:0}}
978.tr-lab{opacity:clamp(0,calc(var(--gr) * 3 - .3),1)}
979.tr-hero-t{position:absolute;left:0;right:0;top:10vh;text-align:center;padding:0 24px;opacity:clamp(0,calc(1 - var(--p) * 5.5),1);transform:translateY(calc(var(--p) * -90px));pointer-events:none;color:var(--ink)}
980.tr-hero.is-static .tr-hero-t{opacity:1;transform:none}
981.tr-hero-t .tr-eyebrow{color:var(--ink);opacity:.75}
982.tr-br{display:none}
983.tr-hero h1{font-size:clamp(46px,7.8vw,118px);white-space:nowrap;font-weight:400;line-height:.92;letter-spacing:-.035em}
984.tr-hero h1 em{color:#2f6b1b;font-weight:300}
985.tr .tr-lede{font-size:clamp(16px,1.7vw,21px);max-width:44ch;margin:20px auto 0}
986.tr-hint{position:absolute;left:50%;bottom:92px;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--cream);opacity:clamp(0,calc(1 - var(--p) * 14),1)}
987.tr-hint i{width:2px;height:34px;background:var(--chl);animation:tr-hint 1.6s ease-in-out infinite;transform-origin:top}
988@keyframes tr-hint{0%{transform:scaleY(0)}60%{transform:scaleY(1)}100%{transform:scaleY(1);opacity:0}}
989.tr-counter{position:absolute;left:28px;bottom:96px;background:rgba(27,16,9,.84);padding:14px 20px 12px;border-radius:4px;color:var(--cream);opacity:clamp(0,calc(var(--p) * 5),1);pointer-events:none}
990.tr-counter small{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;opacity:.8}
991.tr-counter b{font-family:var(--serif);font-weight:400;font-size:clamp(40px,6vw,76px);line-height:1;color:var(--chl);font-variant-numeric:tabular-nums}
992.tr-counter i{font-family:var(--mono);font-style:normal;font-size:13px;color:var(--cream)}
993.tr-steps{position:absolute;right:28px;bottom:96px;list-style:none;margin:0;padding:16px 20px;width:min(320px,42vw);height:122px;background:rgba(27,16,9,.84);border-radius:4px;color:var(--cream);opacity:clamp(0,calc(var(--p) * 8),1)}
994.tr-steps li{position:absolute;inset:16px 20px;opacity:0;transform:translateY(14px);transition:opacity .4s,transform .4s;display:flex;flex-direction:column;gap:2px}
995.tr-steps li span{font-family:var(--mono);font-size:12px;color:var(--chl)}
996.tr-steps li b{font-family:var(--serif);font-weight:500;font-size:34px;line-height:1}
997.tr-steps li em{font-style:normal;font-size:14px;color:var(--cream);opacity:.85}
998.tr-steps[data-step="0"] li[data-i="0"],.tr-steps[data-step="1"] li[data-i="1"],.tr-steps[data-step="2"] li[data-i="2"],.tr-steps[data-step="3"] li[data-i="3"]{opacity:1;transform:none}
999.tr-hero.is-static .tr-steps li[data-i="3"]{opacity:1;transform:none}
1000.tr-hero.is-static .tr-hint{display:none}
1001.tr-edge{height:48px;margin-top:-1px;background:var(--bg);line-height:0;transition:background-color .3s}
1002.tr-edge svg{width:100%;height:48px;display:block}
1003
1004/* contours */
1005.tr-contour{overflow:hidden;--sp:0;padding:140px 0 120px}
1006.tr-contour-svg{position:absolute;inset:0;width:100%;height:100%}
1007.tr-dc{--q:clamp(0,calc((var(--sp) * 1.5 - var(--d,0)) * 3),1);stroke:var(--moss);stroke-width:1.2;opacity:.55;stroke-dasharray:1;stroke-dashoffset:calc(1 - var(--q))}
1008.tr-dc.idx{stroke-width:2.4;opacity:.9}
1009.tr-dc.fence{stroke:var(--chl);stroke-width:3;stroke-dasharray:1;opacity:1;filter:drop-shadow(0 0 4px rgba(59,227,79,.5))}
1010.tr-dc-l{--q:clamp(0,calc((var(--sp) * 1.5 - var(--d,0)) * 3),1);font-family:var(--mono);font-size:11px;fill:var(--ink);opacity:var(--q)}
1011.reduced .tr-contour,.reduced .tr-join{--sp:1}
1012.tr-contour-in{display:flex;justify-content:flex-end}
1013.tr-card{width:min(540px,100%);background:rgba(243,236,218,.92);padding:40px;border:1.5px solid var(--ink);border-radius:4px;box-shadow:10px 10px 0 var(--soil2)}
1014.tr-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:26px 0 0;border-top:1.5px solid var(--ink);padding-top:18px}
1015.tr-stats div{display:flex;flex-direction:column-reverse}
1016.tr-stats dt{font-family:var(--serif);font-size:38px;line-height:1;font-weight:500}
1017.tr-stats dd{margin:0;font-size:12.5px;line-height:1.3;opacity:.8}
1018
1019/* ledger */
1020.tr-ledger{background:var(--soil);color:var(--cream)}
1021.tr-ledger em{color:var(--chl)}
1022.tr-big{display:flex;flex-direction:column;gap:4px;margin:0 0 56px}
1023.tr-big b{font-family:var(--serif);font-weight:400;font-size:clamp(46px,9.5vw,132px);line-height:1;letter-spacing:-.03em;color:var(--chl);font-variant-numeric:tabular-nums;text-shadow:0 0 40px rgba(59,227,79,.25)}
1024.tr-big span{font-family:var(--mono);font-size:13px;letter-spacing:.1em;text-transform:uppercase;opacity:.8}
1025.tr-ledger-grid{display:grid;grid-template-columns:1fr 1.15fr;gap:24px}
1026.tr-panel{border:1px solid rgba(243,236,218,.25);padding:28px;border-radius:4px;background:rgba(243,236,218,.04)}
1027.tr-panel h3{margin-bottom:20px;font-size:20px}
1028.tr-bar{display:grid;grid-template-columns:130px 1fr 52px;gap:12px;align-items:center;margin:0 0 14px;font-size:14px}
1029.tr-bar div{height:14px;background:rgba(243,236,218,.1);overflow:hidden}
1030.tr-bar i{display:block;height:100%;background:var(--chl);transform-origin:left;transform:scaleX(0);transition:transform 1.2s cubic-bezier(.2,.7,.2,1);transition-delay:calc(var(--i) * .12s)}
1031.in .tr-bar i{transform:scaleX(var(--w))}
1032.tr-bar b{font-family:var(--mono);font-weight:500;text-align:right;font-size:13px}
1033.tr-ledger svg{width:100%;height:auto;display:block}
1034.tr-line{stroke:var(--chl);stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset 2s ease .2s}
1035.in .tr-line{stroke-dashoffset:0}
1036.tr-area{fill:rgba(59,227,79,.12);opacity:0;transition:opacity 1s 1.6s}
1037.in .tr-area{opacity:1}
1038.tr-dot{fill:var(--soil);stroke:var(--chl);stroke-width:2.5;opacity:0;transition:opacity .3s calc(.3s + var(--i) * .26s)}
1039.in .tr-dot{opacity:1}
1040.tr-ax{font-family:var(--mono);font-size:11px;fill:var(--cream);opacity:.7}
1041.tr-note{font-size:13px;opacity:.7;margin:14px 0 0}
1042
1043/* plots */
1044.tr-plots{background:var(--bg);transition:background-color .3s}
1045.tr-map-grid{display:grid;grid-template-columns:1.7fr 1fr;gap:28px;align-items:start}
1046.tr-map{border:1.5px solid var(--ink);background:rgba(255,255,255,.35);padding:12px;border-radius:4px}
1047.tr-map svg{width:100%;height:auto;display:block}
1048.tr-plot{cursor:pointer;outline:none}
1049.tr-plot-f{opacity:.38;transition:opacity .3s}
1050.tr-plot-o{stroke:var(--ink);stroke-width:1.5;stroke-linejoin:round}
1051.tr-plot.on .tr-plot-f{opacity:.85}
1052.tr-plot.on .tr-plot-o{stroke-width:3;stroke:var(--ink)}
1053.tr-row{stroke:#2a1d14;stroke-width:1.6;opacity:.7;stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset .8s ease;transition-delay:calc(var(--i) * 30ms)}
1054.tr-plot.on .tr-row{stroke-dashoffset:0}
1055.tr-plot-t{font-family:var(--mono);font-size:11px;font-weight:700;fill:var(--ink);paint-order:stroke;stroke:rgba(243,236,218,.85);stroke-width:3px;pointer-events:none}
1056.tr-river{stroke:#7fb8c4;stroke-width:12;stroke-linecap:round;opacity:.85;pointer-events:none}
1057.tr-river-t{font-family:var(--serif);font-style:italic;font-size:12px;fill:#2c5a66;pointer-events:none}
1058.tr-legend{display:flex;flex-wrap:wrap;gap:6px 16px;font-family:var(--mono);font-size:11px;padding:10px 4px 2px}
1059.tr-legend span{display:flex;align-items:center;gap:6px}.tr-legend i{width:12px;height:12px;border:1px solid var(--ink)}
1060.tr-info{position:sticky;top:90px;border:1.5px solid var(--ink);padding:28px;background:var(--cream);border-radius:4px;box-shadow:8px 8px 0 var(--soil2);min-height:300px}
1061.tr-info h3{font-size:36px;margin-bottom:10px}
1062.tr-info-p{font-size:15px}
1063.tr-info dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0;border-top:1.5px solid var(--ink);padding-top:14px}
1064.tr-info dl div{display:flex;flex-direction:column-reverse}
1065.tr-info dt{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;opacity:.7}
1066.tr-info dd{margin:0;font-family:var(--serif);font-size:24px;line-height:1.1}
1067.tr-som{height:10px;background:rgba(42,29,20,.12);overflow:hidden}
1068.tr-som i{display:block;height:100%;background:var(--chl);transform-origin:left;transform:scaleX(var(--w));transition:transform .6s cubic-bezier(.2,.7,.2,1)}
1069.tr-info small{display:block;margin-top:8px;font-size:12px;opacity:.7}
1070
1071/* mycelium */
1072.tr-myc{background:#1f150d;color:var(--cream)}
1073.tr-myc em{color:var(--chl)}
1074.tr-myc-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:56px;align-items:center}
1075.tr-myc-svg svg{width:100%;height:auto;display:block}
1076.tr-hy{stroke:rgba(243,236,218,.7);stroke-width:1.2;stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset 1.4s ease;transition-delay:calc(var(--d) * 2.2s)}
1077.in .tr-hy{stroke-dashoffset:0}
1078.tr-nd{fill:var(--chl);opacity:0;transition:opacity .4s calc(var(--d) * 2.2s + .8s)}
1079.in .tr-nd{opacity:1}
1080.tr-sig{fill:#fff;offset-distance:0%;opacity:0;filter:drop-shadow(0 0 4px var(--chl))}
1081.in .tr-sig{animation:tr-sig 3.4s linear 2.6s infinite}
1082@keyframes tr-sig{0%{offset-distance:0%;opacity:0}10%{opacity:1}90%{opacity:1}100%{offset-distance:100%;opacity:0}}
1083.tr-count-row{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:34px;border-top:1px solid rgba(243,236,218,.3);padding-top:22px}
1084.tr-count b{display:block;font-family:var(--serif);font-weight:400;font-size:clamp(28px,3.4vw,44px);color:var(--chl);line-height:1;font-variant-numeric:tabular-nums}
1085.tr-count span{font-size:12.5px;line-height:1.3;display:block;margin-top:6px;opacity:.8}
1086
1087/* voices */
1088.tr-voices{background:var(--bg);transition:background-color .3s;padding-bottom:140px}
1089.tr-vgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
1090.tr-voice{margin:0;padding:30px;background:var(--cream);border:1.5px solid var(--ink);border-radius:4px;display:flex;flex-direction:column;gap:16px;clip-path:inset(100% 0 0 0);transition:clip-path 1s cubic-bezier(.2,.7,.2,1);transition-delay:calc(var(--i) * .18s);color:var(--ink)}
1091.in .tr-voice{clip-path:inset(0 0 0 0)}
1092.tr-voice:nth-child(2){margin-top:32px}
1093.tr-voice blockquote{margin:0;font-family:var(--serif);font-size:21px;line-height:1.3;flex:1}
1094.tr-voice figcaption{display:grid;grid-template-columns:52px minmax(0,1fr);column-gap:14px;align-items:center;font-size:13px}
1095.tr-voice figcaption img{grid-row:1 / span 2;width:52px;height:52px;border-radius:50%;object-fit:cover;object-position:center top;border:1.5px solid var(--ink)}
1096.tr-voice figcaption span{opacity:.7}
1097
1098/* join */
1099.tr-join{background:var(--soil);color:var(--cream);overflow:hidden;--sp:0;padding-bottom:0}
1100.tr-join em{color:var(--chl)}
1101.tr-join-in{padding-bottom:120px;z-index:2}
1102.tr-form{display:grid;grid-template-columns:200px 1fr auto;gap:12px;align-items:end;max-width:760px;margin-top:28px}
1103.tr-form label{display:flex;flex-direction:column;gap:6px;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
1104.tr-form input,.tr-form select{font:inherit;font-family:var(--sans);font-size:16px;letter-spacing:0;text-transform:none;padding:13px 14px;border:1.5px solid var(--cream);background:transparent;color:var(--cream);border-radius:4px;width:100%}
1105.tr-form select option{color:var(--ink)}
1106.tr-form input::placeholder{color:rgba(243,236,218,.5)}
1107.tr-form button{font:inherit;font-weight:700;padding:14px 22px;background:var(--chl);color:#0c2a10;border:0;border-radius:4px;cursor:pointer;transition:transform .15s}
1108.tr-form button:hover{transform:translateY(-2px)}
1109.tr-err{grid-column:1/-1;color:#ff9d8a;margin:0;font-size:14px}
1110.tr-done{margin-top:28px;padding:20px;border:1.5px solid var(--chl);max-width:560px;border-radius:4px}
1111.tr-field{position:absolute;left:0;right:0;bottom:46px;width:100%;height:180px;transform-origin:50% 100%;transform:scaleY(clamp(0,calc(var(--sp) * 1.3 - .2),1));opacity:clamp(0,calc(var(--sp) * 4 - .5),1)}
1112.tr-fs{animation:tr-sway 4s ease-in-out infinite alternate}
1113@keyframes tr-sway{from{transform:rotate(-2.5deg)}to{transform:rotate(2.5deg)}}
1114.tr-foot{position:relative;z-index:2;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:14px 28px;font-family:var(--mono);font-size:12px;background:#1a110a;margin-top:140px;color:var(--cream)}
1115.tr-join{padding-bottom:0}
1116.tr-join .tr-field{bottom:46px}
1117
1118/* season slider */
1119.tr-season{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:60;width:min(420px,calc(100vw - 32px));background:rgba(243,236,218,.94);border:1.5px solid var(--ink);border-radius:14px;padding:8px 16px 8px;box-shadow:5px 5px 0 var(--soil2);display:grid;grid-template-columns:auto 1fr;gap:2px 14px;align-items:center}
1120.tr-season-n{font-family:var(--serif);font-size:20px;min-width:76px;color:var(--ink)}
1121.tr-season input{width:100%;accent-color:#1fa33a;margin:0;height:24px}
1122.tr-season-t{grid-column:2;display:flex;justify-content:space-between;font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink);opacity:.7}
1123
1124@media (max-width:960px){
1125 .tr-ledger-grid,.tr-map-grid,.tr-myc-grid{grid-template-columns:1fr}
1126 .tr-info{position:static;min-height:0}
1127 .tr-vgrid{grid-template-columns:1fr}.tr-voice:nth-child(2){margin-top:0}
1128 .tr-links{display:none}.tr-nav{padding:12px 16px}.tr-cta{margin-left:auto}
1129 .tr-sec{padding:84px 0}
1130}
1131@media (max-width:900px){.tr-br{display:inline}.tr-hero h1{font-size:clamp(46px,15vw,92px);white-space:normal}}
1132@media (max-width:640px){
1133 .tr-wrap{padding:0 16px}
1134 .tr-lab{display:none}
1135 .tr-counter{left:16px;bottom:100px;padding:10px 14px}
1136 .tr-steps{right:16px;left:16px;width:auto;bottom:206px;height:84px}
1137 .tr-steps li b{font-size:24px}
1138 .tr-steps li em{font-size:13px}
1139 .tr-hero-t{top:11vh}
1140 .tr-card{padding:26px}
1141 .tr-stats{grid-template-columns:1fr 1fr 1fr}.tr-stats dt{font-size:28px}
1142 .tr-bar{grid-template-columns:96px 1fr 44px;font-size:12.5px}
1143 .tr-panel{padding:18px}
1144 .tr-count-row{grid-template-columns:1fr}
1145 .tr-form{grid-template-columns:1fr}
1146 .tr-info h3{font-size:30px}
1147 .tr-foot{margin-top:100px}
1148}
1149@media (prefers-reduced-motion:reduce){
1150 .tr *,.tr *::before,.tr *::after{animation:none!important;transition:none!important}
1151 .tr-hint{display:none}
1152 .tr-voice{clip-path:none}
1153 .tr-bar i{transform:scaleX(var(--w))}
1154 .tr-line{stroke-dashoffset:0}.tr-area,.tr-dot,.tr-nd{opacity:1}
1155 .tr-hy{stroke-dashoffset:0}
1156 .tr-row{stroke-dashoffset:0}
1157 .tr-field{--sp:1}
1158}
1159`;

The design brief. For you and your AI agent.

Tokens, type, layout, components and motion — written from the real values in the file, so a model can extend the page without drifting.

Terra — design brief

Regenerative-agriculture landing page whose composition grows with scroll. Aesthetic: Organic Earth Editorial. Single file Page.tsx (react only), scoped CSS prefix tr-.

Tokens (CSS custom properties on .tr)

TokenValueUse
--soil / --soil2#2b1c12 / #5b3d26dark sections, hero ground, hard shadows
--moss#5a6b33italics, contours, eyebrows
--ink#2a1d14text, borders
--cream#f3ecdacards, text on dark
--chl#3be34fthe one vivid accent: counters, stored carbon, buttons, focus rings
--bg, --sky1/2, --leaf, --leaf2, --wheat, --sun, --hillinterpolatedseasonal, set by the slider
--bloom, --snow, --synumbersfoliage scale, snow opacity, sun offset
TypeFraunces first, then Iowan Old Style / Palatino / Georgiadisplay + quotes (400-500, tight tracking, italic accents)
BodyInstrument Sans first, then system-ui16px / 1.55
Dataui-monospaceeyebrows, labels, tickers, 11-13px uppercase

Season stops (slider 0-3, linear interpolation): Spring (sage sky, fresh greens, #f3ecda page), Summer (blue sky, deep green, golden wheat), Autumn (peach sky, rust and amber), Winter (grey sky, muted leaf, snow on the ground, foliage at 30%).

Shape and spacing

Sections 120px vertical padding, 1160px wrap, 28px gutters (16px at mobile). 4px radii, 1.5px ink borders, hard offset shadows in --soil2 for cards. Pills only for nav and the season control.

Sections

  1. Hero / cross-section: 480vh track, sticky 100vh stage. A 1200x900 SVG scene (slice): sky, sun, clouds, hills, five wavy soil horizons with specks and worms, depth ruler and horizon labels. Seeded generators produce 11 wheat plants (stem, blades, ears), 6 cover crops with leaves and flowers, a hedge, a tree and about 200 branching roots. Caption and counter overlays.
  2. Contours: three seeded hills as distorted concentric rings with index contours and elevation labels, plus a chlorophyll field boundary, all drawn by scroll progress.
  3. Ledger (dark): live ticking tonnage, per-practice bars (scaleX), cumulative line chart drawn with dashoffset.
  4. Field map: 12 jittered plots in a 4x3 grid over a river; hover, focus or click draws crop rows into the plot and fills the info panel.
  5. Mycelium: seeded fungal network drawing top-down with travelling offset-path signals and count-up stats.
  6. Growers: three quotes revealed with clip-path from the ground up.
  7. Join: soil-test form and a swaying wheat field that rises with scroll.

Fixed nav and a fixed season slider (bottom center).

Motion

Single rAF scroll loop writes CSS variables (--p, --gs, --gr, --gc on the stage, --sp on sections); SVG parts derive their state with calc() and clamp() from those variables plus a per-element --d delay (stroke-dashoffset on pathLength=1 strokes, transform scale on leaves). No React state per frame. Ambient: cloud drift, falling carbon, wheat sway, signal pulses. prefers-reduced-motion: scene rendered fully grown (p = 1), all animation and transitions off, section reveals visible.

Assets

Grower portraits (voices): Placeholder portraits live in public/img/people/ (p1.webp to p9.webp, 328×328). Copy the folder next to the page so root-relative /img/people/... URLs resolve, or swap in your own photos. They are lazy-loaded and cropped with object-fit: cover; object-position: center top.

Questions about Terra

Regenerative farming where the page itself grows — sky to root zone as you scroll. It is a free climate & agtech landing page for React with a matching DESIGN.md brief.

Open the page, click Copy full source or Download .tsx, and drop the single Page file into your React project. Copy the public/img folder next to it for the demo photography, or swap in your own images.

Yes. It is MIT-licensed: free for personal and commercial projects, no signup and no attribution required.

Yes. The DESIGN.md describes tokens, type scale, layout, components and motion so Claude Code, Cursor, Lovable or v0 can extend the page without drifting from its look.