Selene. Space & Research landing page.

A cold, silent, spacious lunar-hospitality landing page. A pinned hero paints a procedural cratered horizon in three parallax layers with true raking-light cast shadows, an Earth that rises with scroll, and a trail of footprints that prints itself toward a gold-panelled outpost. The cursor lives in 1/6 g: title letters drift away on slow underdamped springs and the pointer kicks up regolith that arcs back down. Then a word-by-word sunrise statement, a pinned descent over a crater rim, a 29.5-day lunar cycle scrubbed by scroll (sun arc, swinging shadows, gold panels, phase-lit Earth), a module configurator where habitats drop onto the surface, a rotating Earth porthole, crew cards that float and settle, and a seat-manifest booking pass. Regolith greys, basalt black, anorthosite white, one gold accent; no images or web fonts.

  • Raking-light terrain
  • Rising Earth parallax
  • Scroll-printed footprints
  • 1/6 g cursor physics
  • Word-by-word sunrise
  • Pinned crater descent
  • 29.5-day sun & shadow scrub
  • Modules drop and settle
  • Rotating phase-lit Earth
Full screen

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

The whole page. One file, nothing left out.

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

  1. 01Save it as selene-base.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.
selene-base.tsx
1/**
2 * Selene — a lunar base for research and quiet.
3 *
4 * A cold, silent, spacious landing page for a fictional lunar-hospitality
5 * operator. Everything is procedural (canvas + inline SVG + CSS) — no images,
6 * no web fonts, no network.
7 *
8 * Motion (one rAF loop reads scroll progress of each `[data-sel-p]` wrapper and
9 * writes a `--p` CSS variable; visuals are transform/opacity only):
10 * - Hero: canvas lunar horizon in three parallax layers with true raking-light
11 * cast shadows, a procedural Earth that rises with scroll, a trail of
12 * footprints that prints itself as you scroll, and a 1/6-g cursor: the
13 * title letters drift away from the pointer on underdamped springs and the
14 * pointer kicks up regolith that arcs slowly back down.
15 * - Statement: words light up like sunrise creeping across a plain.
16 * - Crater rim: a pinned descent over a crater lip, depth/temperature counters.
17 * - Lunar day: 29.5 days scrubbed by scroll — the sun walks its arc, shadows
18 * swing and stretch, panels glow gold, Earth cycles through its phases.
19 * - Configurator: modules drop in on a slow 1/6-g ease, puff dust, link with tubes.
20 * - Earth window: a porthole with a live-rotating, phase-lit Earth.
21 * - Crew cards that float up and settle slowly; a seat-manifest booking form.
22 *
23 * Reduced motion: no pinning, no physics, no autoplay; the page renders a
24 * complete static composition. SSR-safe: window/document only inside effects.
25 */
26import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react";
27
28/* ------------------------------------------------------------------ */
29/* Pure helpers (module scope is deterministic: SSR == client) */
30/* ------------------------------------------------------------------ */
31function rng(seed: number) {
32 let a = seed >>> 0;
33 return () => {
34 a = (a + 0x6d2b79f5) >>> 0;
35 let t = a;
36 t = Math.imul(t ^ (t >>> 15), t | 1);
37 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
38 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
39 };
40}
41const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));
42const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
43const smooth = (t: number) => t * t * (3 - 2 * t);
44
45function h1(i: number) {
46 let h = Math.imul(i | 0, 374761393) ^ 0x9e3779b1;
47 h = Math.imul(h ^ (h >>> 13), 1274126177);
48 return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
49}
50function vn1(x: number) {
51 const i = Math.floor(x);
52 const f = x - i;
53 const s = smooth(f);
54 return h1(i) * (1 - s) + h1(i + 1) * s;
55}
56function h3(x: number, y: number, z: number) {
57 let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 1274126177);
58 h = Math.imul(h ^ (h >>> 13), 1103515245);
59 return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
60}
61function vn3(x: number, y: number, z: number) {
62 const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
63 const fx = smooth(x - xi), fy = smooth(y - yi), fz = smooth(z - zi);
64 const c = (a: number, b: number, d: number) => h3(xi + a, yi + b, zi + d);
65 const x00 = lerp(c(0, 0, 0), c(1, 0, 0), fx), x10 = lerp(c(0, 1, 0), c(1, 1, 0), fx);
66 const x01 = lerp(c(0, 0, 1), c(1, 0, 1), fx), x11 = lerp(c(0, 1, 1), c(1, 1, 1), fx);
67 return lerp(lerp(x00, x10, fy), lerp(x01, x11, fy), fz);
68}
69function fbm3(x: number, y: number, z: number, o: number) {
70 let a = 0.5, f = 1, s = 0, n = 0;
71 for (let i = 0; i < o; i++) {
72 s += a * vn3(x * f, y * f, z * f);
73 n += a;
74 a *= 0.5;
75 f *= 2.03;
76 }
77 return s / n;
78}
79
80type RGB = [number, number, number];
81const mix = (a: RGB, b: RGB, t: number) =>
82 `rgb(${(a[0] + (b[0] - a[0]) * t) | 0},${(a[1] + (b[1] - a[1]) * t) | 0},${(a[2] + (b[2] - a[2]) * t) | 0})`;
83
84/* ------------------------------------------------------------------ */
85/* Lunar terrain: heightfield + true raking-light shadows */
86/* ------------------------------------------------------------------ */
87type TCfg = {
88 seed: number;
89 base: number; // surface line as fraction of canvas height
90 amp: number;
91 f: number;
92 rough: number;
93 craters: number;
94 rMin: number;
95 rMax: number;
96 dMin: number;
97 dMax: number;
98 lit: RGB;
99 shade: RGB;
100 tan: number; // sun elevation slope used for cast shadows
101 sl: number; // slope-facing sensitivity
102 rocks?: number;
103 speck?: number;
104};
105
106function makeSurface(cfg: TCfg) {
107 const r = rng(cfg.seed);
108 const o1 = r() * 50, o2 = r() * 50;
109 const cr = Array.from({ length: cfg.craters }, () => ({
110 x: r() * 1.1 - 0.05,
111 r: cfg.rMin + r() * (cfg.rMax - cfg.rMin),
112 d: cfg.dMin + r() * (cfg.dMax - cfg.dMin),
113 }));
114 return (u: number) => {
115 let y =
116 cfg.base +
117 (Math.sin(u * cfg.f * 6.283 + o1) * 0.5 + Math.sin(u * cfg.f * 2.3 * 6.283 + o2) * 0.3) * cfg.amp +
118 (vn1(u * cfg.f * 14 + o1) - 0.5) * cfg.rough;
119 for (const c of cr) {
120 const q = (u - c.x) / c.r;
121 const a = Math.abs(q);
122 if (a < 1) y += c.d * Math.pow(1 - q * q, 0.7);
123 if (a < 2.2) y -= c.d * 0.34 * Math.exp(-Math.pow((a - 1.04) * 3, 2));
124 }
125 return y;
126 };
127}
128
129function paintTerrain(cv: HTMLCanvasElement, cfg: TCfg) {
130 const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
131 const W = Math.max(1, Math.round(cv.clientWidth * dpr));
132 const H = Math.max(1, Math.round(cv.clientHeight * dpr));
133 cv.width = W;
134 cv.height = H;
135 const ctx = cv.getContext("2d");
136 if (!ctx) return;
137 const f = makeSurface(cfg);
138 const ys = new Float32Array(W);
139 for (let x = 0; x < W; x++) ys[x] = f(x / W) * H;
140 let M = -1e9;
141 for (let x = W - 1; x >= 0; x--) {
142 const y = ys[x];
143 const key = -y - x * cfg.tan;
144 const sh = clamp((M - key) / (7 * dpr));
145 if (key > M) M = key;
146 const xl = Math.max(0, x - 2), xr = Math.min(W - 1, x + 2);
147 const slope = (ys[xr] - ys[xl]) / (xr - xl);
148 const facing = clamp(0.5 + slope * cfg.sl);
149 const light = clamp(facing * (1 - sh * 0.85) + (h1(x) - 0.5) * 0.05);
150 const fade = (18 + 55 * Math.min(1, Math.abs(slope))) * dpr;
151 const gg = ctx.createLinearGradient(0, y, 0, y + fade);
152 gg.addColorStop(0, mix(cfg.shade, cfg.lit, 0.07 + 0.93 * light));
153 gg.addColorStop(1, mix(cfg.shade, cfg.lit, 0.3 + 0.2 * (1 - sh)));
154 ctx.fillStyle = gg;
155 ctx.fillRect(x, y, 1, fade);
156 ctx.fillStyle = mix(cfg.shade, cfg.lit, 0.3 + 0.2 * (1 - sh));
157 ctx.fillRect(x, y + fade - 1, 1, H - y - fade + 2);
158 }
159 ctx.globalCompositeOperation = "source-atop";
160 const g = ctx.createLinearGradient(0, H * cfg.base, 0, H);
161 g.addColorStop(0, "rgba(0,0,0,0)");
162 g.addColorStop(1, "rgba(4,5,7,.62)");
163 ctx.fillStyle = g;
164 ctx.fillRect(0, 0, W, H);
165 const rr = rng(cfg.seed + 7);
166 for (let i = 0; i < (cfg.speck || 0); i++) {
167 const x = Math.floor(rr() * W);
168 const y = ys[x] + 3 + rr() * (H - ys[x]);
169 ctx.fillStyle = rr() > 0.5 ? "rgba(255,255,255,.07)" : "rgba(0,0,0,.2)";
170 ctx.fillRect(x, y, dpr * (1 + rr()), dpr);
171 }
172 ctx.globalCompositeOperation = "source-over";
173 for (let i = 0; i < (cfg.rocks || 0); i++) {
174 const x = rr() * W;
175 const size = (6 + rr() * 22) * dpr * (0.6 + (ys[Math.floor(x)] / H) * 0.9);
176 const gy = ys[Math.floor(clamp(x, 0, W - 1))] + size * 0.25;
177 const L = size * (3 + rr() * 3);
178 ctx.fillStyle = "rgba(2,3,4,.55)";
179 ctx.beginPath();
180 ctx.moveTo(x, gy - size * 0.15);
181 ctx.lineTo(x - L, gy + size * 0.05);
182 ctx.lineTo(x - L * 0.9, gy + size * 0.22);
183 ctx.lineTo(x + size * 0.3, gy + size * 0.25);
184 ctx.closePath();
185 ctx.fill();
186 const rg = ctx.createLinearGradient(x - size * 0.55, 0, x + size * 0.7, 0);
187 rg.addColorStop(0, mix(cfg.shade, cfg.lit, 0.08));
188 rg.addColorStop(1, mix(cfg.shade, cfg.lit, 0.92));
189 ctx.fillStyle = rg;
190 ctx.beginPath();
191 ctx.moveTo(x - size * 0.55, gy);
192 ctx.lineTo(x - size * 0.3, gy - size * 0.7);
193 ctx.lineTo(x + size * 0.15, gy - size * 0.95);
194 ctx.lineTo(x + size * 0.6, gy - size * 0.45);
195 ctx.lineTo(x + size * 0.7, gy);
196 ctx.closePath();
197 ctx.fill();
198 }
199}
200
201function paintStars(cv: HTMLCanvasElement, seed: number, n: number) {
202 const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
203 const W = Math.max(1, Math.round(cv.clientWidth * dpr));
204 const H = Math.max(1, Math.round(cv.clientHeight * dpr));
205 cv.width = W;
206 cv.height = H;
207 const ctx = cv.getContext("2d");
208 if (!ctx) return;
209 const r = rng(seed);
210 for (let i = 0; i < n; i++) {
211 const band = r() < 0.35;
212 let x = r() * W;
213 let y = r() * H;
214 if (band) {
215 const t = r();
216 x = t * W;
217 y = H * (0.75 - t * 0.6) + (r() - 0.5) * H * 0.22;
218 }
219 const s = r();
220 const size = (s > 0.97 ? 1.8 : s > 0.85 ? 1.2 : 0.8) * dpr;
221 const a = band ? 0.18 + r() * 0.35 : 0.3 + r() * 0.7;
222 const hue = r();
223 ctx.fillStyle =
224 hue > 0.9 ? `rgba(190,215,255,${a})` : hue > 0.8 ? `rgba(255,236,205,${a})` : `rgba(236,238,239,${a})`;
225 ctx.fillRect(x, y, size, size);
226 }
227}
228
229function TerrainCanvas({ cfg, className }: { cfg: TCfg; className?: string }) {
230 const ref = useRef<HTMLCanvasElement>(null);
231 useEffect(() => {
232 const cv = ref.current;
233 if (!cv) return;
234 let t = 0;
235 const run = () => paintTerrain(cv, cfg);
236 run();
237 const ro = new ResizeObserver(() => {
238 window.clearTimeout(t);
239 t = window.setTimeout(run, 120);
240 });
241 ro.observe(cv);
242 return () => {
243 ro.disconnect();
244 window.clearTimeout(t);
245 };
246 }, [cfg]);
247 return <canvas ref={ref} className={className} aria-hidden="true" />;
248}
249
250function StarCanvas({ seed, n, className }: { seed: number; n: number; className?: string }) {
251 const ref = useRef<HTMLCanvasElement>(null);
252 useEffect(() => {
253 const cv = ref.current;
254 if (!cv) return;
255 let t = 0;
256 const run = () => paintStars(cv, seed, n);
257 run();
258 const ro = new ResizeObserver(() => {
259 window.clearTimeout(t);
260 t = window.setTimeout(run, 120);
261 });
262 ro.observe(cv);
263 return () => {
264 ro.disconnect();
265 window.clearTimeout(t);
266 };
267 }, [seed, n]);
268 return <canvas ref={ref} className={className} aria-hidden="true" />;
269}
270
271const FAR: TCfg = {
272 seed: 11, base: 0.6, amp: 0.025, f: 1.4, rough: 0.02, craters: 7, rMin: 0.02, rMax: 0.05, dMin: 0.012, dMax: 0.03,
273 lit: [128, 134, 142], shade: [38, 42, 50], tan: 0.1, sl: 1.0,
274};
275const MID: TCfg = {
276 seed: 29, base: 0.71, amp: 0.022, f: 1.1, rough: 0.018, craters: 6, rMin: 0.04, rMax: 0.09, dMin: 0.025, dMax: 0.05,
277 lit: [150, 150, 148], shade: [30, 33, 38], tan: 0.12, sl: 1.5, speck: 700,
278};
279const NEAR: TCfg = {
280 seed: 47, base: 0.82, amp: 0.018, f: 0.9, rough: 0.012, craters: 4, rMin: 0.06, rMax: 0.14, dMin: 0.04, dMax: 0.075,
281 lit: [186, 178, 164], shade: [26, 27, 30], tan: 0.15, sl: 1.6, rocks: 16, speck: 2600,
282};
283const RIM_A: TCfg = {
284 seed: 5, base: 0.4, amp: 0.07, f: 1.1, rough: 0.04, craters: 3, rMin: 0.05, rMax: 0.12, dMin: 0.02, dMax: 0.05,
285 lit: [150, 162, 178], shade: [30, 36, 46], tan: 0.08, sl: 1.0,
286};
287const RIM_B: TCfg = {
288 seed: 61, base: 0.6, amp: 0.04, f: 1.6, rough: 0.03, craters: 7, rMin: 0.03, rMax: 0.08, dMin: 0.02, dMax: 0.05,
289 lit: [96, 116, 138], shade: [18, 24, 33], tan: 0.1, sl: 1.5, speck: 900,
290};
291const RIM_C: TCfg = {
292 seed: 83, base: 0.76, amp: 0.07, f: 0.8, rough: 0.05, craters: 3, rMin: 0.08, rMax: 0.15, dMin: 0.03, dMax: 0.06,
293 lit: [182, 172, 156], shade: [24, 24, 27], tan: 0.14, sl: 1.5, rocks: 14, speck: 2200,
294};
295
296/* ------------------------------------------------------------------ */
297/* Procedural Earth (sphere-sampled from a seamless noise texture) */
298/* ------------------------------------------------------------------ */
299const TW = 384, TH = 192;
300let EARTH_TEX: { r: Uint8Array; g: Uint8Array; b: Uint8Array; c: Uint8Array } | null = null;
301function earthTex() {
302 if (EARTH_TEX) return EARTH_TEX;
303 const r = new Uint8Array(TW * TH), g = new Uint8Array(TW * TH), b = new Uint8Array(TW * TH), c = new Uint8Array(TW * TH);
304 for (let j = 0; j < TH; j++) {
305 const lat = (0.5 - (j + 0.5) / TH) * Math.PI;
306 const cl = Math.cos(lat), sl = Math.sin(lat);
307 for (let i = 0; i < TW; i++) {
308 const lon = ((i + 0.5) / TW) * Math.PI * 2;
309 const x = cl * Math.cos(lon), y = sl, z = cl * Math.sin(lon);
310 const n = fbm3(x * 2.1 + 9, y * 2.1 + 3, z * 2.1 + 5, 4);
311 const t2 = fbm3(x * 4 + 1, y * 4 + 8, z * 4 + 2, 3);
312 let R: number, G: number, B: number;
313 if (Math.abs(lat) > 1.2 + (n - 0.5) * 0.35) {
314 R = 232; G = 238; B = 244;
315 } else if (n > 0.53) {
316 const k = clamp((t2 - 0.35) / 0.3);
317 R = lerp(62, 150, k); G = lerp(112, 134, k); B = lerp(72, 92, k);
318 if (Math.abs(lat) < 0.38 && t2 > 0.6) { R = 168; G = 148; B = 96; }
319 } else {
320 const d = clamp((0.53 - n) / 0.18);
321 R = lerp(52, 12, d); G = lerp(128, 52, d); B = lerp(150, 112, d);
322 }
323 const cn = fbm3(x * 3.1 + 40, y * 3.6 + 12, z * 3.1 + 21, 4);
324 const cc = smooth(clamp((cn - 0.5) / 0.2));
325 const idx = j * TW + i;
326 r[idx] = R; g[idx] = G; b[idx] = B; c[idx] = cc * 235;
327 }
328 }
329 EARTH_TEX = { r, g, b, c };
330 return EARTH_TEX;
331}
332
333type EGeo = { S: number; ty: Uint16Array; lf: Float32Array; nx: Float32Array; nz: Float32Array; rim: Float32Array; al: Uint8Array };
334const EGEO = new Map<number, EGeo>();
335function earthGeo(S: number): EGeo {
336 const have = EGEO.get(S);
337 if (have) return have;
338 const N = S * S;
339 const geo: EGeo = {
340 S, ty: new Uint16Array(N), lf: new Float32Array(N), nx: new Float32Array(N), nz: new Float32Array(N),
341 rim: new Float32Array(N), al: new Uint8Array(N),
342 };
343 const R = S / 2;
344 for (let py = 0; py < S; py++) {
345 for (let px = 0; px < S; px++) {
346 const nx = (px + 0.5) / R - 1, ny = 1 - (py + 0.5) / R;
347 const r2 = nx * nx + ny * ny;
348 const k = py * S + px;
349 if (r2 >= 1) continue;
350 const nz = Math.sqrt(1 - r2);
351 geo.nx[k] = nx; geo.nz[k] = nz;
352 geo.lf[k] = Math.atan2(nz, nx) / (Math.PI * 2);
353 geo.ty[k] = Math.min(TH - 1, Math.floor((0.5 - Math.asin(ny) / Math.PI) * TH));
354 geo.rim[k] = Math.pow(1 - nz, 3);
355 geo.al[k] = clamp((1 - Math.sqrt(r2)) * R * 1.1, 0, 1) * 255;
356 }
357 }
358 EGEO.set(S, geo);
359 return geo;
360}
361
362/** alpha = phase angle: 0 full, PI/2 half, PI new. Lit side is on the right while alpha<PI. */
363function drawEarth(cv: HTMLCanvasElement, S: number, rot: number, alpha: number) {
364 const tex = earthTex();
365 const geo = earthGeo(S);
366 if (cv.width !== S || cv.height !== S) { cv.width = S; cv.height = S; }
367 const ctx = cv.getContext("2d");
368 if (!ctx) return;
369 const holder = cv as HTMLCanvasElement & { __img?: ImageData };
370 const img = holder.__img && holder.__img.width === S ? holder.__img : (holder.__img = ctx.createImageData(S, S));
371 const d = img.data;
372 const Lx = Math.sin(alpha), Lz = Math.cos(alpha);
373 const off = rot / (Math.PI * 2);
374 for (let k = 0; k < S * S; k++) {
375 const a = geo.al[k];
376 const o = k * 4;
377 if (a === 0) { d[o + 3] = 0; continue; }
378 let u = geo.lf[k] + off;
379 u -= Math.floor(u);
380 const ti = geo.ty[k] * TW + Math.min(TW - 1, (u * TW) | 0);
381 const cc = tex.c[ti] / 255;
382 let R = tex.r[ti], G = tex.g[ti], B = tex.b[ti];
383 R += (238 - R) * cc; G += (242 - G) * cc; B += (248 - B) * cc;
384 const dl = geo.nx[k] * Lx + geo.nz[k] * Lz;
385 const t = smooth(clamp((dl + 0.08) / 0.5));
386 const sh = 0.035 + 0.965 * t;
387 const rim = geo.rim[k] * (0.22 + 0.95 * t);
388 d[o] = Math.min(255, R * sh + rim * 120);
389 d[o + 1] = Math.min(255, G * sh + rim * 170);
390 d[o + 2] = Math.min(255, B * sh + rim * 255);
391 d[o + 3] = a;
392 }
393 ctx.putImageData(img, 0, 0);
394}
395
396/** Earth phase angle seen from the near side, given days since local sunrise. */
397const earthAlpha = (day: number) => (((7.4 + day) / 29.53) % 1) * Math.PI * 2;
398
399/* ------------------------------------------------------------------ */
400/* Content */
401/* ------------------------------------------------------------------ */
402const NAV = [
403 ["Rim", "#sel-rim"],
404 ["Lunar day", "#sel-day"],
405 ["Modules", "#sel-modules"],
406 ["Crew", "#sel-crew"],
407 ["Manifest", "#sel-manifest"],
408] as const;
409
410const PRINTS = Array.from({ length: 30 }, (_, i) => {
411 const at = (t: number) => ({ x: 20 + t * 48 + Math.sin(t * 6.2) * 6, y: 96 - t * 24 });
412 const t = i / 29;
413 const p = at(t), q = at(Math.min(1, t + 0.04));
414 const ang = (Math.atan2((q.x - p.x) * 16, -(q.y - p.y) * 9) * 180) / Math.PI;
415 const side = i % 2 ? 1 : -1;
416 const sc = 1 - t * 0.84;
417 return { i, x: p.x, y: p.y, ang, off: side * 17 * sc, sc };
418});
419
420const STATEMENT =
421 "There is no weather here. No wind to carry sound, no cloud to hide the stars. Selene is a station built for stillness, for research and for the long view — four hundred thousand kilometres from the nearest inbox.";
422
423const STEPS_RIM = [
424 { k: "01", t: "The landing rim", d: "Pads are cut into the lip of the crater, in sunlight for 89 percent of the year. Guests arrive in daylight, the descent is felt as a slow exhale.", depth: "0 m", temp: "+54 °C" },
425 { k: "02", t: "Over the lip", d: "A heated gallery runs 400 metres inside the rim, where the temperature holds near −20 °C and the Earth never leaves the window.", depth: "−140 m", temp: "−20 °C" },
426 { k: "03", t: "The cold trap", d: "On the floor, sunlight has not arrived in two billion years. Our crews cut water ice at 40 K, the quietest place we know of.", depth: "−412 m", temp: "−233 °C" },
427];
428
429const PHASES = [
430 { n: "Dawn", r: "D+00 to D+02", d: "Sunlight arrives sideways. Shadows run twenty times the height of a person and the array unfolds, panel by panel." },
431 { n: "Long day", r: "D+03 to D+12", d: "The regolith reaches +106 °C. Work happens in the shaded galleries; the greenhouse is fed by light piped down from the mast." },
432 { n: "Dusk", r: "D+13 to D+15", d: "The sun slides along the horizon for two days. Everyone gathers at the west window for the last light." },
433 { n: "Long night", r: "D+16 to D+29", d: "−173 °C and the stars all day. Earth hangs full overhead, about forty-three times brighter than a full Moon over your head at home." },
434];
435
436type ModKey = "hab" | "lab" | "gh" | "mast";
437const MODS: { k: ModKey; name: string; x: number; line: string; beds: number; racks: number; food: number; kw: number }[] = [
438 { k: "hab", name: "Habitat", x: 170, line: "Four private berths, a galley and the long window.", beds: 4, racks: 0, food: 0, kw: -6 },
439 { k: "lab", name: "Laboratory", x: 345, line: "Eight instrument racks, a glovebox and a regolith bench.", beds: 0, racks: 8, food: 0, kw: -11 },
440 { k: "gh", name: "Greenhouse", x: 535, line: "Forty kilos of fresh greens a week under piped sunlight.", beds: 0, racks: 0, food: 40, kw: -9 },
441 { k: "mast", name: "Solar mast", x: 715, line: "A 26 kW gold-film array on a self-tracking mast.", beds: 0, racks: 0, food: 0, kw: 26 },
442];
443
444const CREW = [
445 { n: "Dr. Imani Okafor", r: "Station director, geochemist", q: "People think the quiet is empty. It is the opposite: you can hear the whole station think.", s: "Moonwalks 63", tilt: -14 },
446 { n: "Jonas Reiter", r: "Hospitality and life support", q: "I set the table for guests four hundred thousand kilometres from the nearest restaurant. Nobody has complained.", s: "Moonwalks 22", tilt: 8 },
447 { n: "Ayaka Mori", r: "Greenhouse lead", q: "The first tomato of the month always goes to whoever has been looking at Earth the longest.", s: "Moonwalks 31", tilt: -4 },
448 { n: "Cpt. Luis Ferreira", r: "Surface operations", q: "Walking is a kind of slow flying here. You learn to land softly and to take your time.", s: "Moonwalks 118", tilt: 16 },
449];
450
451const FLIGHTS = [
452 { id: "SEL-2801", date: "08 MAR 2028", from: "Cislunar Gate A", transit: "3 d 06 h" },
453 { id: "SEL-2802", date: "21 APR 2028", from: "Cislunar Gate A", transit: "3 d 04 h" },
454 { id: "SEL-2803", date: "02 JUN 2028", from: "Cislunar Gate B", transit: "3 d 08 h" },
455];
456const STAYS = [
457 { d: 7, name: "Seven days", note: "A short, bright week", price: 2400000 },
458 { d: 14, name: "One lunar day", note: "Sunrise to sunset", price: 3900000 },
459 { d: 29, name: "A full lunation", note: "Day, dusk and the long night", price: 6800000 },
460];
461const ROWS = [1, 2, 3, 4];
462const COLS = ["A", "B", "C", "D"];
463const TAKEN = new Set(["1B", "2A", "2D", "3C", "4A"]);
464const eur = (n: number) => "€" + new Intl.NumberFormat("en-GB").format(n);
465
466/* ------------------------------------------------------------------ */
467/* Small components */
468/* ------------------------------------------------------------------ */
469function StationClock() {
470 const [t, setT] = useState("--:--:--");
471 useEffect(() => {
472 const f = () => setT(new Date().toISOString().slice(11, 19));
473 f();
474 const id = window.setInterval(f, 1000);
475 return () => window.clearInterval(id);
476 }, []);
477 return <span>{t}</span>;
478}
479
480function Visor({ tilt, id }: { tilt: number; id: number }) {
481 return (
482 <svg viewBox="0 0 160 160" className="sel-visor" aria-hidden="true">
483 <defs>
484 <radialGradient id={`sv-h${id}`} cx="35%" cy="28%" r="80%">
485 <stop offset="0" stopColor="#f4f5f6" />
486 <stop offset=".6" stopColor="#b9bdc2" />
487 <stop offset="1" stopColor="#5b6066" />
488 </radialGradient>
489 <linearGradient id={`sv-v${id}`} x1="0" y1="0" x2="1" y2="1">
490 <stop offset="0" stopColor="#1b222b" />
491 <stop offset="1" stopColor="#05070a" />
492 </linearGradient>
493 <radialGradient id={`sv-e${id}`} cx="40%" cy="35%" r="65%">
494 <stop offset="0" stopColor="#8ec3e8" />
495 <stop offset=".5" stopColor="#3f7fb8" />
496 <stop offset="1" stopColor="#1f5c63" />
497 </radialGradient>
498 <clipPath id={`sv-c${id}`}><ellipse cx="80" cy="82" rx="44" ry="36" /></clipPath>
499 </defs>
500 <circle cx="80" cy="80" r="62" fill={`url(#sv-h${id})`} />
501 <ellipse cx="80" cy="82" rx="44" ry="36" fill={`url(#sv-v${id})`} />
502 <g clipPath={`url(#sv-c${id})`}>
503 <g transform={`rotate(${tilt} 80 82)`}>
504 <circle cx="102" cy="68" r="15" fill={`url(#sv-e${id})`} opacity=".92" />
505 <path d="M92 66q6-5 12 0q3 4 8 2" stroke="#e9f1f5" strokeWidth="2" fill="none" opacity=".7" />
506 </g>
507 <ellipse cx="62" cy="68" rx="14" ry="5" fill="#fff" opacity=".12" transform="rotate(-20 62 68)" />
508 </g>
509 <path d="M26 104q54 40 108 0" stroke="#e3b24a" strokeWidth="2" fill="none" opacity=".9" />
510 </svg>
511 );
512}
513
514/* ------------------------------------------------------------------ */
515/* Page */
516/* ------------------------------------------------------------------ */
517export function Page() {
518 const rootRef = useRef<HTMLDivElement>(null);
519 const navRef = useRef<HTMLElement>(null);
520 const heroEarth = useRef<HTMLCanvasElement>(null);
521 const cycEarth = useRef<HTMLCanvasElement>(null);
522 const cfgEarth = useRef<HTMLCanvasElement>(null);
523 const cfgScene = useRef<HTMLCanvasElement>(null);
524 const winEarth = useRef<HTMLCanvasElement>(null);
525 const winBox = useRef<HTMLDivElement>(null);
526 const dayRef = useRef(7);
527 const [reduced, setReduced] = useState(false);
528 const [mods, setMods] = useState<Record<ModKey, boolean>>({ hab: true, lab: false, gh: false, mast: true });
529 const [winDay, setWinDay] = useState(19);
530 const [phaseIdx, setPhaseIdx] = useState(0);
531 const [rimIdx, setRimIdx] = useState(0);
532 const [flight, setFlight] = useState(0);
533 const [stay, setStay] = useState(1);
534 const [seat, setSeat] = useState<string | null>("2C");
535 const [name, setName] = useState("");
536 const [cleared, setCleared] = useState(false);
537
538 const words = useMemo(() => STATEMENT.split(" "), []);
539 const stars = useMemo(() => {
540 const r = rng(77);
541 return Array.from({ length: 90 }, () => ({ x: r() * 800, y: r() * 300, s: 0.5 + r() * 1.2, o: 0.3 + r() * 0.7 }));
542 }, []);
543 const ridge = useMemo(() => {
544 const pts: string[] = [];
545 for (let x = 0; x <= 800; x += 16) pts.push(`${x},${(322 - vn1(x / 90 + 3) * 22 - Math.sin(x / 130) * 5).toFixed(1)}`);
546 return `M0,346 L${pts.join(" L")} L800,346 Z`;
547 }, []);
548 const cfgStars = useMemo(() => {
549 const r = rng(9);
550 return Array.from({ length: 70 }, () => ({ x: r() * 800, y: r() * 250, s: 0.5 + r() * 1.1 }));
551 }, []);
552 const rimGlints = useMemo(() => {
553 const fa = makeSurface(RIM_A), fb = makeSurface(RIM_B);
554 return {
555 gold: [0.62, 0.655, 0.69].map((u) => ({ l: u * 100, t: fa(u) * 100 - 0.9 })),
556 ice: [0.18, 0.27, 0.36, 0.55, 0.8].map((u, i) => ({ l: u * 100, t: fb(u) * 100 + 4 + (i % 3) * 4 })),
557 };
558 }, []);
559 const outpostTop = useMemo(() => makeSurface(MID)(0.7) * 100, []);
560
561 /* ---- reduced motion flag ---- */
562 useEffect(() => {
563 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
564 setReduced(mq.matches);
565 const on = () => setReduced(mq.matches);
566 mq.addEventListener("change", on);
567 return () => mq.removeEventListener("change", on);
568 }, []);
569
570 /* ---- Earths that are drawn once ---- */
571 useEffect(() => {
572 if (heroEarth.current) drawEarth(heroEarth.current, 360, 2.3, 0.7);
573 }, []);
574
575 /* ---- scroll engine ---- */
576 useEffect(() => {
577 const root = rootRef.current;
578 if (!root) return;
579 const q = (s: string) => root.querySelector<HTMLElement>(`[data-sel-out="${s}"]`);
580 const pins = Array.from(root.querySelectorAll<HTMLElement>("[data-sel-p]"));
581 const o = {
582 elev: q("elev"), rise: q("rise"),
583 depth: q("depth"), rtemp: q("rtemp"),
584 day: q("day"), sun: q("sun"), temp: q("temp"), kw: q("kw"), kwbar: q("kwbar"), ephase: q("ephase"), elevd: q("elevd"),
585 fill: q("fill"), thumb: q("thumb"),
586 };
587 const sunG = root.querySelector<SVGGElement>('[data-sel-svg="sun"]');
588 const sh1 = root.querySelector<SVGPathElement>('[data-sel-svg="sh1"]');
589 const sh2 = root.querySelector<SVGPathElement>('[data-sel-svg="sh2"]');
590 const night = root.querySelector<SVGRectElement>('[data-sel-svg="night"]');
591 const panels = root.querySelector<SVGGElement>('[data-sel-svg="panels"]');
592 const last: Record<string, number> = {};
593 let lastDay = -1, lastPhase = -1, lastRim = -1;
594 let raf = 0;
595
596 const cycle = (p: number) => {
597 const day = p * 29.5;
598 const isDay = day < 14.75;
599 const a = isDay ? day / 14.75 : 0;
600 const phi = Math.PI * a;
601 const el = isDay ? Math.sin(phi) : 0;
602 const elevAng = isDay ? (Math.PI / 2 - Math.abs(phi - Math.PI / 2)) : 0;
603 if (sunG) {
604 sunG.style.transform = `translate(${400 - Math.cos(phi) * 340}px, ${340 - Math.sin(phi) * 250}px)`;
605 sunG.style.opacity = isDay ? "1" : "0";
606 }
607 const dir = Math.cos(phi) > 0 ? 1 : -1;
608 const cot = 1 / Math.max(0.05, Math.tan(Math.max(0.03, elevAng)));
609 if (sh1) {
610 sh1.setAttribute("transform", `translate(250 346) scale(${dir * clamp(58 * cot, 6, 380)} 1)`);
611 sh1.style.opacity = isDay ? String(clamp(el * 6, 0, 0.85)) : "0";
612 }
613 if (sh2) {
614 sh2.setAttribute("transform", `translate(600 346) scale(${dir * clamp(104 * cot, 6, 420)} 1)`);
615 sh2.style.opacity = isDay ? String(clamp(el * 6, 0, 0.8)) : "0";
616 }
617 const bright = isDay ? 0.3 + 0.7 * Math.pow(el, 0.6) : 0.09;
618 if (night) night.style.opacity = String((1 - bright) * 0.9);
619 if (panels) panels.style.opacity = String(isDay ? 0.25 + 0.75 * el : 0.12);
620 const T = isDay ? -140 + 246 * Math.pow(Math.max(el, 0), 0.45) : -140 - 33 * smooth((day - 14.75) / 14.75);
621 const kw = isDay ? 48 * Math.pow(el, 0.8) : 0;
622 const dI = Math.min(29, Math.floor(day));
623 if (o.day) o.day.textContent = "D+" + String(dI).padStart(2, "0");
624 if (o.sun) o.sun.textContent = isDay ? "Rising" : "Below horizon";
625 if (o.elevd) o.elevd.textContent = ((elevAng * 180) / Math.PI).toFixed(0) + "°";
626 if (o.temp) o.temp.textContent = (T > 0 ? "+" : "−") + Math.abs(T).toFixed(0) + " °C";
627 if (o.kw) o.kw.textContent = kw.toFixed(1) + " kW";
628 if (o.kwbar) o.kwbar.style.transform = `scaleX(${(kw / 48).toFixed(3)})`;
629 if (o.fill) o.fill.style.transform = `scaleX(${p.toFixed(4)})`;
630 if (o.thumb) o.thumb.style.transform = `translateX(${(p * 100).toFixed(2)}cqw)`;
631 const al = earthAlpha(day);
632 if (o.ephase) o.ephase.textContent = ((1 + Math.cos(al)) * 50).toFixed(0) + "% lit";
633 if (dI !== lastDay) {
634 lastDay = dI;
635 dayRef.current = dI;
636 if (cycEarth.current) drawEarth(cycEarth.current, 160, 1.6 + dI * 0.2, al);
637 }
638 const ph = day < 2.5 ? 0 : day < 12.3 ? 1 : day < 15.6 ? 2 : 3;
639 if (ph !== lastPhase) { lastPhase = ph; setPhaseIdx(ph); }
640 };
641
642 const frame = () => {
643 raf = 0;
644 const vh = window.innerHeight;
645 const y = window.scrollY;
646 if (navRef.current) navRef.current.classList.toggle("sel-solid", y > 40);
647 for (const el of pins) {
648 const id = el.dataset.selP || "";
649 let p = 0;
650 if (reducedRef.current) p = id === "hero" ? 0.72 : id === "cycle" ? 0.26 : id === "rim" ? 0.5 : 1;
651 else {
652 const r = el.getBoundingClientRect();
653 p = clamp(-r.top / Math.max(1, r.height - vh));
654 }
655 if (last[id] !== undefined && Math.abs(last[id] - p) < 0.0004) continue;
656 last[id] = p;
657 el.style.setProperty("--p", p.toFixed(4));
658 if (id === "hero") {
659 if (o.elev) o.elev.textContent = (1.2 + p * 17.5).toFixed(1) + "°";
660 if (o.rise) o.rise.textContent = Math.round(p * 100) + "%";
661 } else if (id === "rim") {
662 const k = Math.min(2, Math.floor(p * 3));
663 if (k !== lastRim) { lastRim = k; setRimIdx(k); }
664 if (o.depth) o.depth.textContent = "−" + Math.round(p * 412) + " m";
665 if (o.rtemp) o.rtemp.textContent = (p < 0.2 ? "+" + Math.round(54 - p * 370) : "−" + Math.round(p * 233)) + " °C";
666 } else if (id === "cycle") cycle(p);
667 }
668 };
669 const reducedRef = { current: reduced };
670 const kick = () => {
671 if (!raf) raf = requestAnimationFrame(frame);
672 };
673 window.addEventListener("scroll", kick, { passive: true });
674 window.addEventListener("resize", kick);
675 kick();
676 return () => {
677 window.removeEventListener("scroll", kick);
678 window.removeEventListener("resize", kick);
679 if (raf) cancelAnimationFrame(raf);
680 };
681 }, [reduced]);
682
683 /* ---- reveal on view ---- */
684 useEffect(() => {
685 const root = rootRef.current;
686 if (!root) return;
687 root.classList.add("sel-js");
688 const els = Array.from(root.querySelectorAll<HTMLElement>(".sel-rv"));
689 const io = new IntersectionObserver(
690 (es) => es.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }),
691 { threshold: 0.12 },
692 );
693 els.forEach((e) => io.observe(e));
694 return () => io.disconnect();
695 }, []);
696
697 /* ---- 1/6 g hero: floaty letters + ballistic regolith dust ---- */
698 useEffect(() => {
699 const root = rootRef.current;
700 if (!root || reduced) return;
701 const stick = root.querySelector<HTMLElement>(".sel-hero .sel-stick");
702 const dust = root.querySelector<HTMLCanvasElement>(".sel-dust");
703 if (!stick || !dust) return;
704 const dctx = dust.getContext("2d");
705 const els = Array.from(stick.querySelectorAll<HTMLElement>("[data-sel-float]"));
706 const S = els.map(() => ({ x: 0, y: 0, vx: 0, vy: 0, r: 0, vr: 0 }));
707 const home = els.map(() => ({ x: 0, y: 0 }));
708 let parts: { x: number; y: number; vx: number; vy: number; l: number; s: number }[] = [];
709 let raf = 0, last = 0, lx = -1, ly = -1;
710 const size = () => {
711 const r = stick.getBoundingClientRect();
712 dust.width = Math.round(r.width);
713 dust.height = Math.round(r.height);
714 };
715 size();
716 const measure = () =>
717 els.forEach((el, i) => {
718 const r = el.getBoundingClientRect();
719 home[i].x = r.left + r.width / 2 - S[i].x;
720 home[i].y = r.top + r.height / 2 - S[i].y;
721 });
722 const spawn = (x: number, y: number, n: number, vx: number, vy: number) => {
723 for (let i = 0; i < n; i++)
724 parts.push({ x, y, vx: vx * 6 + (Math.random() - 0.5) * 90, vy: -(40 + Math.random() * 110) - Math.abs(vy) * 3, l: 1, s: 0.8 + Math.random() * 1.8 });
725 if (parts.length > 220) parts = parts.slice(-220);
726 };
727 const tick = (now: number) => {
728 raf = 0;
729 const dt = Math.min(0.033, (now - last) / 1000 || 0.016);
730 last = now;
731 let moving = false;
732 els.forEach((el, i) => {
733 const s = S[i];
734 const ax = -7 * s.x - 1.3 * s.vx;
735 const ay = -7 * s.y - 1.3 * s.vy;
736 const ar = -8 * s.r - 1.4 * s.vr;
737 s.vx += ax * dt; s.vy += ay * dt; s.vr += ar * dt;
738 s.x += s.vx * dt; s.y += s.vy * dt; s.r += s.vr * dt;
739 if (Math.abs(s.x) + Math.abs(s.y) + Math.abs(s.r) + Math.abs(s.vx) + Math.abs(s.vy) > 0.15) {
740 moving = true;
741 el.style.transform = `translate3d(${s.x.toFixed(2)}px,${s.y.toFixed(2)}px,0) rotate(${s.r.toFixed(2)}deg)`;
742 } else if (s.x !== 0 || s.y !== 0 || s.r !== 0) {
743 s.x = s.y = s.r = s.vx = s.vy = s.vr = 0;
744 el.style.transform = "";
745 }
746 });
747 if (dctx) {
748 dctx.clearRect(0, 0, dust.width, dust.height);
749 parts = parts.filter((p) => p.l > 0);
750 for (const p of parts) {
751 p.vy += 150 * dt;
752 p.x += p.vx * dt; p.y += p.vy * dt; p.l -= dt * 0.38;
753 dctx.fillStyle = `rgba(206,200,190,${Math.max(0, p.l) * 0.7})`;
754 dctx.fillRect(p.x, p.y, p.s, p.s);
755 }
756 if (parts.length) moving = true;
757 }
758 if (moving) raf = requestAnimationFrame(tick);
759 };
760 const wake = () => {
761 if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); }
762 };
763 const onMove = (e: PointerEvent) => {
764 if (lx < 0) { lx = e.clientX; ly = e.clientY; }
765 const dx = e.clientX - lx, dy = e.clientY - ly;
766 lx = e.clientX; ly = e.clientY;
767 const sp = Math.min(60, Math.hypot(dx, dy));
768 if (sp < 1) return;
769 measure();
770 els.forEach((_, i) => {
771 const hx = home[i].x + S[i].x - e.clientX, hy = home[i].y + S[i].y - e.clientY;
772 const dist = Math.hypot(hx, hy) || 1;
773 if (dist > 180) return;
774 const k = Math.pow(1 - dist / 180, 2) * sp;
775 S[i].vx += (hx / dist) * k * 4;
776 S[i].vy += (hy / dist) * k * 4 - k * 2.4;
777 S[i].vr += (hx > 0 ? 1 : -1) * k * 1.2;
778 });
779 const r = stick.getBoundingClientRect();
780 if (e.clientY - r.top > r.height * 0.6) spawn(e.clientX - r.left, e.clientY - r.top, Math.min(4, 1 + ((sp / 10) | 0)), dx * 0.2, dy);
781 wake();
782 };
783 const onDown = (e: PointerEvent) => {
784 const r = stick.getBoundingClientRect();
785 spawn(e.clientX - r.left, e.clientY - r.top, 26, 0, 4);
786 wake();
787 };
788 const onLeave = () => { lx = -1; };
789 stick.addEventListener("pointermove", onMove);
790 stick.addEventListener("pointerdown", onDown);
791 stick.addEventListener("pointerleave", onLeave);
792 window.addEventListener("resize", size);
793 return () => {
794 stick.removeEventListener("pointermove", onMove);
795 stick.removeEventListener("pointerdown", onDown);
796 stick.removeEventListener("pointerleave", onLeave);
797 window.removeEventListener("resize", size);
798 if (raf) cancelAnimationFrame(raf);
799 els.forEach((el) => (el.style.transform = ""));
800 };
801 }, [reduced]);
802
803 /* ---- configurator Earth sprite ---- */
804 useEffect(() => {
805 if (cfgEarth.current) drawEarth(cfgEarth.current, 140, 4.1, 0.5);
806 }, []);
807
808 /* ---- Earth window: rotating, phase-lit ---- */
809 const winDayRef = useRef(19);
810 useEffect(() => {
811 winDayRef.current = winDay;
812 if (reduced && winEarth.current) drawEarth(winEarth.current, 300, 1.2, earthAlpha(winDay));
813 }, [winDay, reduced]);
814 useEffect(() => {
815 const cv = winEarth.current;
816 const box = winBox.current;
817 if (!cv || !box) return;
818 drawEarth(cv, 300, 1.2, earthAlpha(winDayRef.current));
819 if (reduced) return;
820 let vis = false, raf = 0, rot = 1.2, last = 0, acc = 0;
821 const io = new IntersectionObserver((es) => {
822 vis = es[0].isIntersecting;
823 if (vis && !raf) { last = performance.now(); raf = requestAnimationFrame(loop); }
824 });
825 io.observe(box);
826 function loop(now: number) {
827 raf = 0;
828 if (!vis || !cv) return;
829 const dt = (now - last) / 1000;
830 last = now;
831 acc += dt;
832 rot += dt * 0.35;
833 if (acc > 0.05) { acc = 0; drawEarth(cv, 300, rot, earthAlpha(winDayRef.current)); }
834 raf = requestAnimationFrame(loop);
835 }
836 return () => { io.disconnect(); if (raf) cancelAnimationFrame(raf); };
837 }, [reduced]);
838
839 /* ---- derived: configurator totals, booking ---- */
840 const tot = MODS.reduce(
841 (a, m) => (mods[m.k] ? { beds: a.beds + m.beds, racks: a.racks + m.racks, food: a.food + m.food, kw: a.kw + m.kw } : a),
842 { beds: 0, racks: 0, food: 0, kw: 0 },
843 );
844 const supply = mods.mast ? 26 : 0;
845 const demand = MODS.reduce((a, m) => (mods[m.k] && m.kw < 0 ? a - m.kw : a), 0);
846 const present = MODS.filter((m) => mods[m.k]);
847 const toggle = (k: ModKey) => setMods((s) => ({ ...s, [k]: !s[k] }));
848 const preset = (p: "retreat" | "research" | "station") =>
849 setMods(p === "retreat" ? { hab: true, lab: false, gh: true, mast: true } : p === "research" ? { hab: true, lab: true, gh: false, mast: true } : { hab: true, lab: true, gh: true, mast: true });
850
851 const fl = FLIGHTS[flight], st = STAYS[stay];
852 const seatPrice = seat && (seat.endsWith("A") || seat.endsWith("D")) ? 180000 : 0;
853 const total = st.price + seatPrice;
854 const ref = useMemo(() => {
855 let h = 7;
856 for (const ch of name + fl.id + (seat || "")) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
857 return "M-" + h.toString(36).toUpperCase().padStart(6, "0").slice(0, 6);
858 }, [name, fl.id, seat]);
859 const submit = (e: FormEvent) => {
860 e.preventDefault();
861 if (name.trim() && seat) setCleared(true);
862 };
863
864 return (
865 <div ref={rootRef} className={"sel-root" + (reduced ? " sel-rm" : "")}>
866 <style>{CSS}</style>
867
868 {/* nav */}
869 <nav ref={navRef} className="sel-nav" aria-label="Primary">
870 <a href="#sel-top" className="sel-brand">
871 <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
872 <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="1" />
873 <path d="M12 2a10 10 0 0 0 0 20a6.5 10 0 0 1 0-20z" fill="currentColor" />
874 </svg>
875 Selene
876 </a>
877 <div className="sel-links">
878 {NAV.map(([l, h]) => (
879 <a key={h} href={h}>{l}</a>
880 ))}
881 </div>
882 <a className="sel-btn sel-btn-sm" href="#sel-manifest">Reserve a seat</a>
883 </nav>
884
885 {/* ============ HERO ============ */}
886 <header id="sel-top" className="sel-hero sel-pin" data-sel-p="hero">
887 <div className="sel-stick">
888 <StarCanvas seed={3} n={520} className="sel-fill" />
889 <div className="sel-earthwrap">
890 <div className="sel-earthglow" />
891 <canvas ref={heroEarth} className="sel-earth" aria-hidden="true" />
892 </div>
893 <div className="sel-layer sel-l-far"><TerrainCanvas cfg={FAR} className="sel-fill" /></div>
894 <div className="sel-layer sel-l-mid">
895 <TerrainCanvas cfg={MID} className="sel-fill" />
896 <svg className="sel-outpost" style={{ top: `calc(${outpostTop}% - 34px)` }} viewBox="0 0 84 40" aria-hidden="true">
897 <path d="M6 38 A18 18 0 0 1 42 38 Z" fill="#cfd3d6" />
898 <path d="M24 38 A18 18 0 0 1 42 38 Z" fill="#f3f5f6" />
899 <rect x="46" y="26" width="2" height="12" fill="#9aa0a6" />
900 <polygon points="47,14 78,8 80,22 49,28" fill="#e3b24a" />
901 <polygon points="47,14 78,8 79,12 48,18" fill="#fbe29a" />
902 <path d="M0 39 L-30 41 L-30 42 L84 40 Z" fill="rgba(0,0,0,.5)" />
903 </svg>
904 </div>
905 <div className="sel-layer sel-l-near">
906 <TerrainCanvas cfg={NEAR} className="sel-fill" />
907 <div className="sel-prints" aria-hidden="true">
908 {PRINTS.map((p) => (
909 <i
910 key={p.i}
911 className="sel-print"
912 style={{
913 left: `calc(${p.x}% + ${p.off.toFixed(1)}px)`,
914 top: `${p.y}%`,
915 "--i": p.i,
916 transform: `translate(-50%,-50%) rotate(${p.ang.toFixed(1)}deg) scale(${p.sc.toFixed(2)}, ${(p.sc * 0.78).toFixed(2)})`,
917 } as CSSProperties}
918 />
919 ))}
920 </div>
921 </div>
922 <canvas className="sel-dust" aria-hidden="true" />
923
924 <div className="sel-hero-copy">
925 <p className="sel-eyebrow">Lunar hospitality and research · Sinus Medii, near side</p>
926 <h1 className="sel-title" aria-label="Selene">
927 {"SELENE".split("").map((c, i) => (
928 <span key={i} className="sel-letter" data-sel-float aria-hidden="true">{c}</span>
929 ))}
930 </h1>
931 <p className="sel-sub">
932 A quiet place, 384,400 kilometres from anywhere. Stay a week, a lunar day, or the whole lunation.
933 </p>
934 <div className="sel-cta">
935 <a className="sel-btn" href="#sel-manifest" data-sel-float>Reserve a seat</a>
936 <a className="sel-ghost" href="#sel-rim">Descend to the rim</a>
937 </div>
938 </div>
939
940 <div className="sel-rise" aria-hidden="true">Earthrise, 06:12 station time. The planet climbs {` `}
941 <b data-sel-out="rise">0%</b> of the way.</div>
942
943 <dl className="sel-hud" aria-label="Station readout">
944 <div><dt>Station time (UTC)</dt><dd><StationClock /></dd></div>
945 <div><dt>Earth elevation</dt><dd data-sel-out="elev">1.2°</dd></div>
946 <div><dt>Gravity</dt><dd>0.166 g</dd></div>
947 <div><dt>Light delay</dt><dd>1.28 s</dd></div>
948 </dl>
949 <div className="sel-cue" aria-hidden="true"><span>Scroll</span><i /></div>
950 </div>
951 </header>
952
953 {/* ============ STATEMENT ============ */}
954 <section className="sel-statement sel-pin" data-sel-p="statement" aria-label="Why Selene">
955 <div className="sel-stick sel-center">
956 <p className="sel-big">
957 {words.map((w, i) => (
958 <span key={i} className="sel-word" style={{ "--w": (i / words.length).toFixed(3) } as CSSProperties}>{w} </span>
959 ))}
960 </p>
961 <div className="sel-stats">
962 {[
963 ["1.28 s", "light delay to Earth"],
964 ["0.166 g", "six times lighter on your feet"],
965 ["14 days", "of unbroken sunlight"],
966 ["0 m/s", "of wind, ever"],
967 ].map(([a, b]) => (
968 <div key={a} className="sel-stat"><b>{a}</b><span>{b}</span></div>
969 ))}
970 </div>
971 </div>
972 </section>
973
974 {/* ============ CRATER RIM ============ */}
975 <section id="sel-rim" className="sel-rim sel-pin" data-sel-p="rim" aria-label="Crater rim descent">
976 <div className="sel-stick">
977 <StarCanvas seed={21} n={380} className="sel-fill" />
978 <div className="sel-rl sel-rl-a">
979 <TerrainCanvas cfg={RIM_A} className="sel-fill" />
980 {rimGlints.gold.map((g, i) => (
981 <i key={i} className="sel-gold-glint" style={{ left: `${g.l}%`, top: `${g.t}%` }} />
982 ))}
983 </div>
984 <div className="sel-rl sel-rl-b">
985 <TerrainCanvas cfg={RIM_B} className="sel-fill" />
986 {rimGlints.ice.map((g, i) => (
987 <i key={i} className="sel-ice-glint" style={{ left: `${g.l}%`, top: `${g.t}%` }} />
988 ))}
989 </div>
990 <div className="sel-rl sel-rl-c"><TerrainCanvas cfg={RIM_C} className="sel-fill" /></div>
991 <div className="sel-scrim" />
992 <div className="sel-rim-head">
993 <p className="sel-eyebrow">01 · The rim</p>
994 <h2 className="sel-h2">Arrive over a crater lip.<br />Descend into silence.</h2>
995 </div>
996 <ol className="sel-steps">
997 {STEPS_RIM.map((s, i) => (
998 <li key={s.k} className={"sel-step" + (rimIdx === i ? " on" : "")}>
999 <span className="sel-k">{s.k}</span>
1000 <div><h3>{s.t}</h3><p>{s.d}</p></div>
1001 </li>
1002 ))}
1003 </ol>
1004 <dl className="sel-hud sel-hud-r" aria-label="Descent readout">
1005 <div><dt>Depth</dt><dd data-sel-out="depth">−0 m</dd></div>
1006 <div><dt>Surface</dt><dd data-sel-out="rtemp">+54 °C</dd></div>
1007 </dl>
1008 </div>
1009 </section>
1010
1011 {/* ============ LUNAR DAY ============ */}
1012 <section id="sel-day" className="sel-cycle sel-pin" data-sel-p="cycle" aria-label="The lunar day">
1013 <div className="sel-stick sel-cyc-stick">
1014 <div className="sel-cyc-head">
1015 <p className="sel-eyebrow">02 · The lunar day</p>
1016 <h2 className="sel-h2">Fourteen days of light.<br />Fourteen of stars.</h2>
1017 </div>
1018 <div className="sel-cyc-grid">
1019 <div className="sel-scene">
1020 <svg viewBox="0 0 800 450" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Sun crossing a black lunar sky above the station, shadows swinging with it">
1021 <defs>
1022 <linearGradient id="sel-gnd" x1="0" y1="0" x2="0" y2="1">
1023 <stop offset="0" stopColor="#9a9791" />
1024 <stop offset="1" stopColor="#2b2c2f" />
1025 </linearGradient>
1026 <radialGradient id="sel-glow">
1027 <stop offset="0" stopColor="#fff6dc" stopOpacity=".55" />
1028 <stop offset="1" stopColor="#fff6dc" stopOpacity="0" />
1029 </radialGradient>
1030 <linearGradient id="sel-gold" x1="0" y1="0" x2="1" y2="1">
1031 <stop offset="0" stopColor="#fbe29a" />
1032 <stop offset="1" stopColor="#c58f21" />
1033 </linearGradient>
1034 <linearGradient id="sel-dome" x1="0" y1="0" x2="1" y2="0">
1035 <stop offset="0" stopColor="#8d9399" />
1036 <stop offset="1" stopColor="#f4f6f7" />
1037 </linearGradient>
1038 </defs>
1039 <rect width="800" height="450" fill="#040507" />
1040 {stars.map((s, i) => (<circle key={i} cx={s.x} cy={s.y} r={s.s} fill="#eceeef" opacity={s.o} />))}
1041 <g data-sel-svg="sun" style={{ transform: "translate(60px, 340px)" }}>
1042 <circle r="64" fill="url(#sel-glow)" />
1043 <circle r="13" fill="#fff7e0" />
1044 </g>
1045 <path d={ridge} fill="#55595e" />
1046 <rect y="340" width="800" height="110" fill="url(#sel-gnd)" />
1047 <g data-sel-svg="panels" opacity=".6">
1048 {[0, 1, 2, 3].map((i) => (
1049 <polygon key={i} points={`${430 + i * 58},356 ${480 + i * 58},356 ${492 + i * 58},384 ${440 + i * 58},384`} fill="url(#sel-gold)" />
1050 ))}
1051 {[0, 1, 2, 3].map((i) => (
1052 <path key={i} d={`M${455 + i * 58} 356 L${466 + i * 58} 384`} stroke="#8a6416" strokeWidth=".8" opacity=".6" />
1053 ))}
1054 </g>
1055 <path data-sel-svg="sh1" d="M0 -2 L1 2 L1 9 L0 11Z" fill="#000" transform="translate(250 346) scale(120 1)" opacity=".5" />
1056 <path data-sel-svg="sh2" d="M0 -1 L1 1 L1 4 L0 4Z" fill="#000" transform="translate(600 346) scale(180 1)" opacity=".5" />
1057 <path d="M196 346a54 58 0 0 1 108 0z" fill="url(#sel-dome)" />
1058 <path d="M196 346a54 58 0 0 1 108 0" fill="none" stroke="#fff" strokeOpacity=".25" />
1059 <rect x="241" y="320" width="18" height="26" rx="2" fill="#2a2e33" />
1060 <rect x="212" y="322" width="9" height="5" rx="1" fill="#1b222b" />
1061 <rect x="279" y="322" width="9" height="5" rx="1" fill="#1b222b" />
1062 <rect x="598" y="242" width="4" height="104" fill="#b9bdc2" />
1063 <polygon points="570,246 630,236 634,256 574,266" fill="url(#sel-gold)" />
1064 <rect data-sel-svg="night" y="300" width="800" height="150" fill="#030405" opacity=".4" />
1065 </svg>
1066 <canvas ref={cycEarth} className="sel-cyc-earth" aria-hidden="true" />
1067 <span className="sel-cap">Window cam 3 · Earth stays put in the sky; the sun walks around it</span>
1068 </div>
1069 <div className="sel-cyc-side">
1070 <dl className="sel-readouts">
1071 <div><dt>Day</dt><dd data-sel-out="day">D+00</dd></div>
1072 <div><dt>Sun elevation</dt><dd data-sel-out="elevd">0°</dd></div>
1073 <div><dt>Surface</dt><dd data-sel-out="temp">−140 °C</dd></div>
1074 <div><dt>Earth</dt><dd data-sel-out="ephase">50% lit</dd></div>
1075 <div className="sel-wide"><dt>Solar yield <b data-sel-out="kw">0.0 kW</b></dt><dd><span className="sel-meter"><i data-sel-out="kwbar" /></span></dd></div>
1076 </dl>
1077 <ul className="sel-phases">
1078 {PHASES.map((p, i) => (
1079 <li key={p.n} className={"sel-phase" + (phaseIdx === i ? " on" : "")}>
1080 <h3>{p.n} <span>{p.r}</span></h3>
1081 <p>{p.d}</p>
1082 </li>
1083 ))}
1084 </ul>
1085 </div>
1086 </div>
1087 <div className="sel-timeline" aria-hidden="true">
1088 <div className="sel-tl-labels"><span>Dawn</span><span>Long day</span><span>Dusk</span><span>Long night</span></div>
1089 <div className="sel-tl-bar"><i data-sel-out="fill" /><b data-sel-out="thumb" /></div>
1090 </div>
1091 </div>
1092 </section>
1093
1094 {/* ============ MODULES ============ */}
1095 <section id="sel-modules" className="sel-sec" aria-label="Module configurator">
1096 <div className="sel-wrap">
1097 <div className="sel-sec-head sel-rv">
1098 <p className="sel-eyebrow">03 · Build your stay</p>
1099 <h2 className="sel-h2">Modules are lowered, slowly,<br />onto the regolith.</h2>
1100 <p className="sel-lead">Choose what your party needs. Everything is delivered in advance by the cargo lander and linked before you arrive.</p>
1101 </div>
1102 <div className="sel-cfg">
1103 <div className="sel-cfg-scene sel-rv">
1104 <svg viewBox="0 0 800 420" role="img" aria-label="Modules assembled on the lunar surface" preserveAspectRatio="xMidYMax meet">
1105 <defs>
1106 <linearGradient id="sel-g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#8d8a84" /><stop offset="1" stopColor="#25262a" /></linearGradient>
1107 <linearGradient id="sel-m1" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#7d838a" /><stop offset="1" stopColor="#f4f6f7" /></linearGradient>
1108 <linearGradient id="sel-m2" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#5f666d" /><stop offset="1" stopColor="#d9dde0" /></linearGradient>
1109 <linearGradient id="sel-gl" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#4e8f6a" stopOpacity=".55" /><stop offset="1" stopColor="#3f7fb8" stopOpacity=".35" /></linearGradient>
1110 <linearGradient id="sel-g3" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#fbe29a" /><stop offset="1" stopColor="#c58f21" /></linearGradient>
1111 </defs>
1112 <rect width="800" height="420" fill="#040507" />
1113 {cfgStars.map((s, i) => (<circle key={i} cx={s.x} cy={s.y} r={s.s} fill="#eceeef" opacity=".7" />))}
1114 <path d="M0 330 L0 300 Q90 280 170 296 T330 290 T520 298 T700 284 L800 296 L800 330Z" fill="#43474c" />
1115 <rect y="330" width="800" height="90" fill="url(#sel-g2)" />
1116 {present.slice(0, -1).map((m, i) => (
1117 <line key={m.k + present[i + 1].k} className="sel-tube" x1={m.x} y1="316" x2={present[i + 1].x} y2="316" />
1118 ))}
1119 {MODS.map((m) => (
1120 <g key={m.k} className={"sel-mod" + (mods[m.k] ? " on" : "")} style={{ "--x": m.x + "px" } as CSSProperties}>
1121 <polygon className="sel-sh" points="-40,0 -250,3 -258,12 40,14" />
1122 {m.k === "hab" && (
1123 <g>
1124 <path d="M-72 0v-40a72 40 0 0 1 144 0v40z" fill="url(#sel-m1)" />
1125 <path d="M-72 -40a72 40 0 0 1 144 0" fill="none" stroke="#fff" strokeOpacity=".3" />
1126 {[-44, -22, 0, 22, 44].map((x) => (<circle key={x} cx={x} cy="-26" r="4" fill="#12161b" />))}
1127 <rect x="-8" y="-20" width="16" height="20" rx="2" fill="#2a2e33" />
1128 </g>
1129 )}
1130 {m.k === "lab" && (
1131 <g>
1132 <rect x="-62" y="-62" width="124" height="62" rx="3" fill="url(#sel-m2)" />
1133 <rect x="-62" y="-62" width="124" height="8" fill="#fff" opacity=".18" />
1134 <path d="M-6 -62 v-30" stroke="#aeb3b8" strokeWidth="2" />
1135 <path d="M-26 -92 q26 -22 52 0 q-26 -8 -52 0z" fill="#e8ebed" />
1136 {[-40, -16, 8, 32].map((x) => (<rect key={x} x={x} y="-44" width="14" height="22" rx="1" fill="#12161b" />))}
1137 </g>
1138 )}
1139 {m.k === "gh" && (
1140 <g>
1141 <path d="M-78 0v-26a78 56 0 0 1 156 0v26z" fill="url(#sel-gl)" stroke="#a9cfe0" strokeOpacity=".5" />
1142 {[-52, -26, 0, 26, 52].map((x) => (
1143 <g key={x}><path d={`M${x} 0 v-18`} stroke="#4e8f6a" strokeWidth="2" /><ellipse cx={x - 4} cy="-20" rx="6" ry="3" fill="#6fb287" /><ellipse cx={x + 4} cy="-24" rx="6" ry="3" fill="#4e8f6a" /></g>
1144 ))}
1145 <path d="M-78 -26a78 56 0 0 1 156 0" fill="none" stroke="#fff" strokeOpacity=".35" />
1146 </g>
1147 )}
1148 {m.k === "mast" && (
1149 <g>
1150 <rect x="-3" y="-128" width="6" height="128" fill="#b9bdc2" />
1151 <polygon points="-62,-132 -4,-146 -4,-96 -62,-84" fill="url(#sel-g3)" />
1152 <polygon points="4,-146 62,-132 62,-84 4,-96" fill="url(#sel-g3)" />
1153 <polygon points="4,-146 62,-132 62,-126 4,-140" fill="#fff" opacity=".35" />
1154 <rect x="-18" y="-10" width="36" height="10" fill="#7d838a" />
1155 </g>
1156 )}
1157 <ellipse className="sel-puff" cx="0" cy="4" rx="70" ry="8" />
1158 </g>
1159 ))}
1160 </svg>
1161 <canvas ref={cfgEarth} className="sel-cfg-earth" aria-hidden="true" />
1162 <span className="sel-cap">{present.length} of 4 modules on site</span>
1163 </div>
1164 <div className="sel-cfg-panel sel-rv">
1165 <div className="sel-presets" role="group" aria-label="Presets">
1166 <button type="button" onClick={() => preset("retreat")}>Retreat</button>
1167 <button type="button" onClick={() => preset("research")}>Research</button>
1168 <button type="button" onClick={() => preset("station")}>Full station</button>
1169 </div>
1170 <div className="sel-modlist">
1171 {MODS.map((m) => (
1172 <button key={m.k} type="button" className={"sel-modbtn" + (mods[m.k] ? " on" : "")} aria-pressed={mods[m.k]} onClick={() => toggle(m.k)}>
1173 <span className="sel-tick" aria-hidden="true" />
1174 <span><b>{m.name}</b><small>{m.line}</small></span>
1175 </button>
1176 ))}
1177 </div>
1178 <div className="sel-totals" aria-live="polite">
1179 <div><b>{tot.beds}</b><span>berths</span></div>
1180 <div><b>{tot.racks}</b><span>instrument racks</span></div>
1181 <div><b>{tot.food}</b><span>kg greens / week</span></div>
1182 </div>
1183 <div className="sel-power">
1184 <div className="sel-power-row"><span>Demand</span><b>{demand} kW</b></div>
1185 <div className="sel-power-bar"><i style={{ transform: `scaleX(${clamp(demand / 40)})` }} /><u style={{ transform: `scaleX(${clamp(supply / 40)})` }} /></div>
1186 <div className="sel-power-row"><span>Solar supply</span><b className="sel-gold-t">{supply} kW</b></div>
1187 <p className={"sel-note" + (demand > supply ? " warn" : "")}>
1188 {demand > supply ? "Demand exceeds supply. Add the solar mast, or the station runs on fuel cells at night only." : "Power is balanced. The night is carried by the regolith thermal store."}
1189 </p>
1190 </div>
1191 </div>
1192 </div>
1193 </div>
1194 </section>
1195
1196 {/* ============ EARTH WINDOW ============ */}
1197 <section id="sel-window" className="sel-sec sel-win" aria-label="Earth window">
1198 <div className="sel-wrap sel-win-grid">
1199 <div className="sel-rv">
1200 <p className="sel-eyebrow">04 · The window</p>
1201 <h2 className="sel-h2">The only view<br />you will never tire of.</h2>
1202 <p className="sel-lead">From the near side the Earth does not rise and set. It hangs in one place, turning once a day and passing through its phases every month. Move the slider to see the sky from any day of your stay.</p>
1203 <label className="sel-slider">
1204 <span>Lunar day <b>D+{String(winDay).padStart(2, "0")}</b></span>
1205 <input type="range" min={0} max={29} value={winDay} onChange={(e) => setWinDay(+e.target.value)} aria-label="Lunar day of your stay" />
1206 </label>
1207 <p className="sel-fine">Earth lit: {Math.round((1 + Math.cos(earthAlpha(winDay))) * 50)}%. Earthshine on the surface is about 43 times brighter than moonlight at home, enough to read by.</p>
1208 </div>
1209 <div className="sel-port-wrap sel-rv">
1210 <div ref={winBox} className="sel-port">
1211 <div className="sel-port-glass">
1212 <StarCanvas seed={33} n={160} className="sel-fill" />
1213 <canvas ref={winEarth} className="sel-port-earth" aria-label="The Earth, turning" role="img" />
1214 <svg className="sel-port-horizon" viewBox="0 0 400 80" preserveAspectRatio="none" aria-hidden="true">
1215 <path d="M0 80V46q40-14 90-4t100-6q70 8 120-4t90 8V80z" fill="#121418" />
1216 <path d="M0 52q60-6 110 2t120-2q70 2 170-6" stroke="#5b6066" strokeWidth="1" fill="none" opacity=".6" />
1217 </svg>
1218 <i className="sel-glare" />
1219 </div>
1220 <i className="sel-bolt sel-b1" /><i className="sel-bolt sel-b2" /><i className="sel-bolt sel-b3" /><i className="sel-bolt sel-b4" />
1221 </div>
1222 <span className="sel-cap">Window 2 · Earth 7.4° above the horizon · turning once every 24 hours</span>
1223 </div>
1224 </div>
1225 </section>
1226
1227 {/* ============ CREW ============ */}
1228 <section id="sel-crew" className="sel-sec" aria-label="Mission crew">
1229 <div className="sel-wrap">
1230 <div className="sel-sec-head sel-rv">
1231 <p className="sel-eyebrow">05 · Mission crew</p>
1232 <h2 className="sel-h2">Four people who live<br />where you are visiting.</h2>
1233 </div>
1234 <div className="sel-crew">
1235 {CREW.map((c, i) => (
1236 <article key={c.n} className="sel-card sel-rv" style={{ "--d": i * 0.18 + "s" } as CSSProperties} tabIndex={0}>
1237 <Visor tilt={c.tilt} id={i} />
1238 <h3>{c.n}</h3>
1239 <p className="sel-role">{c.r}</p>
1240 <blockquote>{c.q}</blockquote>
1241 <p className="sel-stat-line">{c.s}</p>
1242 </article>
1243 ))}
1244 </div>
1245 </div>
1246 </section>
1247
1248 {/* ============ MANIFEST ============ */}
1249 <section id="sel-manifest" className="sel-sec sel-man" aria-label="Seat manifest booking">
1250 <div className="sel-wrap">
1251 <div className="sel-sec-head sel-rv">
1252 <p className="sel-eyebrow">06 · Seat manifest</p>
1253 <h2 className="sel-h2">Take a seat on the next<br />transfer from Cislunar Gate.</h2>
1254 </div>
1255 <form className="sel-man-grid sel-rv" onSubmit={submit}>
1256 <div className="sel-man-form">
1257 <fieldset>
1258 <legend>1 · Departure</legend>
1259 <div className="sel-opts">
1260 {FLIGHTS.map((f, i) => (
1261 <label key={f.id} className={"sel-opt" + (flight === i ? " on" : "")}>
1262 <input type="radio" name="flight" checked={flight === i} onChange={() => { setFlight(i); setCleared(false); }} />
1263 <b>{f.date}</b><span>{f.id} · {f.from} · transit {f.transit}</span>
1264 </label>
1265 ))}
1266 </div>
1267 </fieldset>
1268 <fieldset>
1269 <legend>2 · Length of stay</legend>
1270 <div className="sel-opts sel-opts-3">
1271 {STAYS.map((s, i) => (
1272 <label key={s.d} className={"sel-opt" + (stay === i ? " on" : "")}>
1273 <input type="radio" name="stay" checked={stay === i} onChange={() => { setStay(i); setCleared(false); }} />
1274 <b>{s.name}</b><span>{s.note}</span>
1275 </label>
1276 ))}
1277 </div>
1278 </fieldset>
1279 <fieldset>
1280 <legend>3 · Seat <small>Window seats (A, D) face Earth</small></legend>
1281 <div className="sel-cabin" role="group" aria-label="Cabin seat map">
1282 {ROWS.map((r) => (
1283 <div key={r} className="sel-seatrow">
1284 <em>{r}</em>
1285 {COLS.map((c, ci) => {
1286 const id = r + c;
1287 const taken = TAKEN.has(id);
1288 return (
1289 <button
1290 key={id}
1291 type="button"
1292 disabled={taken}
1293 aria-pressed={seat === id}
1294 aria-label={`Seat ${id}${taken ? ", taken" : ""}${c === "A" || c === "D" ? ", window" : ""}`}
1295 className={"sel-seat" + (seat === id ? " on" : "") + (ci === 1 ? " aisle" : "")}
1296 onClick={() => { setSeat(id); setCleared(false); }}
1297 >{c}</button>
1298 );
1299 })}
1300 </div>
1301 ))}
1302 </div>
1303 </fieldset>
1304 <label className="sel-field">
1305 <span>4 · Passenger name</span>
1306 <input value={name} onChange={(e) => { setName(e.target.value); setCleared(false); }} placeholder="As printed on your passport" autoComplete="name" required />
1307 </label>
1308 <button className="sel-btn" type="submit" disabled={!name.trim() || !seat}>Request clearance</button>
1309 </div>
1310
1311 <aside className={"sel-pass" + (cleared ? " cleared" : "")} aria-live="polite" aria-label="Boarding manifest">
1312 <div className="sel-pass-top"><span>Selene Transfer Manifest</span><span>{fl.id}</span></div>
1313 <dl>
1314 <div><dt>Passenger</dt><dd>{name.trim() || "— — —"}</dd></div>
1315 <div><dt>Departs</dt><dd>{fl.date}</dd></div>
1316 <div><dt>From</dt><dd>{fl.from}</dd></div>
1317 <div><dt>Transit</dt><dd>{fl.transit}</dd></div>
1318 <div><dt>Seat</dt><dd>{seat || "—"}{seatPrice ? " · window" : ""}</dd></div>
1319 <div><dt>Stay</dt><dd>{st.name} · {st.d} d</dd></div>
1320 <div><dt>Mass allowance</dt><dd>23 kg + 4 kg personal</dd></div>
1321 </dl>
1322 <div className="sel-pass-bar" aria-hidden="true">
1323 {Array.from({ length: 44 }, (_, i) => (<i key={i} style={{ width: 1 + ((i * 7 + (name.length % 5)) % 4) + "px" }} />))}
1324 </div>
1325 <div className="sel-pass-total"><span>Total, all inclusive</span><b>{eur(total)}</b></div>
1326 <div className="sel-stamp" aria-hidden={!cleared}>
1327 <span>Cleared for transit</span>
1328 <small>{ref}</small>
1329 </div>
1330 <p className="sel-fine">{cleared ? `Thank you, ${name.trim().split(" ")[0]}. Your medical briefing arrives within 48 hours.` : "Clearance includes a 6-week conditioning programme and a medical review."}</p>
1331 </aside>
1332 </form>
1333 </div>
1334 </section>
1335
1336 {/* ============ FOOTER ============ */}
1337 <footer className="sel-foot">
1338 <div className="sel-wrap sel-foot-grid">
1339 <div>
1340 <div className="sel-foot-mark" aria-hidden="true">SELENE</div>
1341 <p className="sel-fine">2.4° N · 1.7° E · Sinus Medii, near side<br />Selene Lunar Hospitality is a fictional concept.</p>
1342 </div>
1343 <div className="sel-foot-cols">
1344 <div><h4>Stay</h4><a href="#sel-rim">Crater rim</a><a href="#sel-day">The lunar day</a><a href="#sel-modules">Modules</a></div>
1345 <div><h4>Station</h4><a href="#sel-crew">Crew</a><a href="#sel-window">Earth window</a><a href="#sel-manifest">Manifest</a></div>
1346 <div><h4>Contact</h4><a href="#sel-top">Press</a><a href="#sel-top">Research access</a><a href="#sel-top">Safety</a></div>
1347 </div>
1348 </div>
1349 </footer>
1350 </div>
1351 );
1352}
1353
1354export default Page;
1355
1356/* ------------------------------------------------------------------ */
1357/* Styles (scoped under .sel-root) */
1358/* ------------------------------------------------------------------ */
1359const CSS = `
1360.sel-root{
1361 --sel-void:#06070a; --sel-basalt:#0d0f12; --sel-reg1:#171a1e; --sel-reg2:#252a2f; --sel-reg3:#434950;
1362 --sel-reg4:#767c83; --sel-reg5:#a3a49f; --sel-ano:#eceeef; --sel-warm:#b7ae9c;
1363 --sel-blue:#3f7fb8; --sel-green:#4e8f6a; --sel-gold:#e3b24a; --sel-gold-hi:#fbe29a;
1364 --sel-sans:"Outfit","Jost","Avenir Next","Helvetica Neue",system-ui,-apple-system,"Segoe UI",sans-serif;
1365 --sel-mono:"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace;
1366 --sel-serif:"Iowan Old Style","Palatino Linotype",Georgia,serif;
1367 --sel-slow:cubic-bezier(.16,.84,.3,1);
1368 font:300 16px/1.6 var(--sel-sans); color:var(--sel-ano); background:var(--sel-void);
1369 box-sizing:border-box; overflow-x:clip; -webkit-font-smoothing:antialiased; position:relative;
1370}
1371.sel-root *,.sel-root *::before,.sel-root *::after{box-sizing:border-box}
1372.sel-root a{color:inherit;text-decoration:none}
1373.sel-root h1,.sel-root h2,.sel-root h3,.sel-root h4,.sel-root p,.sel-root dl,.sel-root dd,.sel-root ol,.sel-root ul,.sel-root blockquote,.sel-root fieldset{margin:0;padding:0}
1374.sel-root ol,.sel-root ul{list-style:none}
1375.sel-root button,.sel-root input{font:inherit;color:inherit}
1376.sel-root :focus-visible{outline:2px solid var(--sel-gold);outline-offset:3px}
1377.sel-wrap{width:min(1180px,100% - 48px);margin:0 auto}
1378.sel-fill{position:absolute;inset:0;width:100%;height:100%;display:block}
1379.sel-eyebrow{font:400 11px/1.4 var(--sel-mono);letter-spacing:.2em;text-transform:uppercase;color:var(--sel-reg5)}
1380.sel-h2{font:200 clamp(32px,5.2vw,68px)/1.06 var(--sel-sans);letter-spacing:-.01em;margin-top:14px}
1381.sel-lead{font-size:clamp(16px,1.5vw,19px);color:var(--sel-reg5);max-width:46ch;margin-top:20px}
1382.sel-fine{font:400 12px/1.6 var(--sel-mono);color:var(--sel-reg4);margin-top:16px;letter-spacing:.02em}
1383.sel-cap{display:block;font:400 11px/1.4 var(--sel-mono);color:var(--sel-reg4);letter-spacing:.06em;margin-top:12px}
1384.sel-gold-t{color:var(--sel-gold)}
1385
1386/* buttons */
1387.sel-btn{display:inline-flex;align-items:center;justify-content:center;gap:10px;background:var(--sel-gold);color:#14100a;font:500 13px/1 var(--sel-mono);letter-spacing:.12em;text-transform:uppercase;padding:17px 26px;border:0;border-radius:2px;cursor:pointer;transition:transform 1.4s var(--sel-slow),background .4s,box-shadow 1.4s var(--sel-slow);box-shadow:0 0 0 0 rgba(227,178,74,0)}
1388.sel-btn:hover{transform:translateY(-4px);background:var(--sel-gold-hi);box-shadow:0 18px 40px -18px rgba(227,178,74,.55)}
1389.sel-btn:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
1390.sel-btn-sm{padding:11px 16px;font-size:11px}
1391.sel-ghost{font:400 12px/1 var(--sel-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--sel-ano);padding:17px 8px;border-bottom:1px solid var(--sel-reg3);transition:border-color .4s,color .4s}
1392.sel-ghost:hover{border-color:var(--sel-ano)}
1393
1394/* nav */
1395.sel-nav{position:fixed;inset:0 0 auto 0;z-index:50;display:flex;align-items:center;justify-content:space-between;gap:24px;padding:16px clamp(16px,3vw,40px);transition:background .6s,backdrop-filter .6s,border-color .6s;border-bottom:1px solid transparent}
1396.sel-nav.sel-solid{background:rgba(6,7,10,.72);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-color:rgba(255,255,255,.07)}
1397.sel-brand{display:inline-flex;align-items:center;gap:10px;font:300 18px/1 var(--sel-sans);letter-spacing:.34em;text-transform:uppercase}
1398.sel-links{display:flex;gap:30px;font:400 11px/1 var(--sel-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--sel-reg5)}
1399.sel-links a{transition:color .4s}.sel-links a:hover{color:var(--sel-ano)}
1400
1401/* pinned sections */
1402.sel-pin{position:relative}
1403.sel-stick{position:sticky;top:0;height:100vh;height:100svh;overflow:hidden}
1404.sel-hero{height:260vh}
1405.sel-statement{height:230vh}
1406.sel-rim{height:380vh}
1407.sel-cycle{height:440vh}
1408
1409/* hero */
1410.sel-hero{--p:0}
1411.sel-hero .sel-stick{background:radial-gradient(120% 90% at 70% 10%,#0b0e13,#030406 70%)}
1412.sel-earthwrap{position:absolute;right:15vw;top:11vh;width:clamp(150px,20vw,310px);aspect-ratio:1;transform:translate3d(0,calc((1 - var(--p)) * 54vh),0) scale(calc(.88 + var(--p) * .12));will-change:transform}
1413.sel-earth{width:100%;height:100%;display:block;position:relative}
1414.sel-earthglow{position:absolute;inset:-18%;border-radius:50%;background:radial-gradient(closest-side,rgba(110,170,230,.28),rgba(110,170,230,.07) 60%,transparent)}
1415.sel-layer{position:absolute;inset:0;transform-origin:50% 100%;will-change:transform}
1416.sel-l-far{transform:translate3d(0,calc(var(--p) * 3vh),0)}
1417.sel-l-mid{transform:translate3d(0,calc(var(--p) * 7vh),0)}
1418.sel-l-near{transform:translate3d(0,calc(var(--p) * 13vh),0)}
1419.sel-outpost{position:absolute;left:70%;width:clamp(52px,6.4vw,92px);height:auto;overflow:visible}
1420.sel-prints{position:absolute;inset:0}
1421.sel-print{position:absolute;display:block;width:22px;height:56px;border-radius:48% 48% 44% 44% / 62% 62% 38% 38%;background:rgba(6,7,9,.34);box-shadow:2px 0 0 rgba(255,255,255,.16),-1px 0 2px rgba(0,0,0,.35);opacity:clamp(0,calc(var(--p) * 52 - var(--i)),1)}
1422.sel-print::after{content:"";position:absolute;left:12%;right:12%;bottom:-8px;height:12px;border-radius:40%;background:rgba(6,7,9,.3)}
1423.sel-dust{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
1424.sel-hero-copy{position:absolute;left:clamp(18px,6vw,96px);top:clamp(86px,15vh,150px);z-index:3;max-width:min(680px,88vw);transform:translate3d(0,calc(var(--p) * -9vh),0);opacity:calc(1 - var(--p) * 1.25)}
1425.sel-title{font:200 clamp(66px,16.5vw,250px)/.92 var(--sel-sans);letter-spacing:.07em;margin:18px 0 22px -.05em;display:flex;color:var(--sel-ano);}
1426.sel-letter{display:inline-block;will-change:transform}
1427.sel-sub{font-size:clamp(16px,1.6vw,20px);color:var(--sel-reg5);max-width:38ch}
1428.sel-cta{display:flex;flex-wrap:wrap;align-items:center;gap:20px;margin-top:30px}
1429.sel-rise{position:absolute;left:clamp(18px,6vw,96px);bottom:11vh;z-index:3;font:400 11px/1.6 var(--sel-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--sel-ano);max-width:30ch;opacity:clamp(0,calc((var(--p) - .3) * 5),1);text-shadow:0 1px 8px #000}
1430.sel-rise b{color:var(--sel-gold);font-weight:400}
1431.sel-hud{position:absolute;right:clamp(16px,3vw,40px);bottom:7vh;z-index:3;display:grid;gap:8px;font:400 11px/1.3 var(--sel-mono);letter-spacing:.1em;text-transform:uppercase;text-shadow:0 1px 8px #000}
1432.sel-hud div{display:flex;justify-content:space-between;gap:28px;border-top:1px solid rgba(255,255,255,.18);padding-top:6px;min-width:210px}
1433.sel-hud dt{color:var(--sel-reg5)}.sel-hud dd{color:var(--sel-ano)}
1434.sel-cue{position:absolute;left:50%;bottom:2.6vh;z-index:3;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:8px;font:400 10px/1 var(--sel-mono);letter-spacing:.3em;text-transform:uppercase;color:var(--sel-reg5);opacity:calc(1 - var(--p) * 6)}
1435.sel-cue i{width:1px;height:34px;background:linear-gradient(var(--sel-ano),transparent);transform-origin:top;animation:sel-cue 3.4s var(--sel-slow) infinite}
1436@keyframes sel-cue{0%{transform:scaleY(0)}60%{transform:scaleY(1);opacity:1}100%{transform:scaleY(1);opacity:0}}
1437
1438/* statement */
1439.sel-statement{--p:0;background:var(--sel-void)}
1440.sel-center{display:flex;flex-direction:column;justify-content:center;gap:56px;width:min(1080px,100% - 48px);margin:0 auto}
1441.sel-big{font:200 clamp(26px,4.4vw,56px)/1.22 var(--sel-sans);letter-spacing:-.01em}
1442.sel-word{opacity:clamp(.16,calc((var(--p) * 1.35 - var(--w)) * 9 + .16),1);color:var(--sel-ano)}
1443.sel-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;opacity:clamp(0,calc((var(--p) - .55) * 5),1)}
1444.sel-stat{border-top:1px solid var(--sel-reg3);padding-top:14px;display:flex;flex-direction:column;gap:4px}
1445.sel-stat b{font:200 clamp(26px,3vw,40px)/1.1 var(--sel-sans)}
1446.sel-stat span{font:400 11px/1.5 var(--sel-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--sel-reg4)}
1447
1448/* crater rim */
1449.sel-rim{--p:0}
1450.sel-rim .sel-stick{background:#030406}
1451.sel-rl{position:absolute;inset:0;transform-origin:50% 100%;will-change:transform}
1452.sel-rl-a{transform:translate3d(0,calc(var(--p) * -2vh),0) scale(calc(1 + var(--p) * .16))}
1453.sel-rl-b{transform:translate3d(0,calc(var(--p) * 14vh),0) scale(calc(1 + var(--p) * .3))}
1454.sel-rl-c{transform:translate3d(0,calc(var(--p) * 78vh),0) scale(calc(1 + var(--p) * .45))}
1455.sel-gold-glint{position:absolute;width:7px;height:3px;background:var(--sel-gold);box-shadow:0 0 10px 2px rgba(227,178,74,.7);transform:rotate(-12deg)}
1456.sel-ice-glint{position:absolute;width:3px;height:3px;border-radius:50%;background:#cfe8ff;box-shadow:0 0 12px 3px rgba(120,180,240,.55);opacity:calc(.25 + var(--p) * .75)}
1457.sel-scrim{position:absolute;inset:0;background:linear-gradient(90deg,rgba(3,4,6,.85),rgba(3,4,6,.1) 55%,transparent),linear-gradient(0deg,rgba(3,4,6,.5),transparent 30%);pointer-events:none}
1458.sel-rim-head{position:absolute;left:clamp(18px,6vw,96px);top:clamp(88px,14vh,130px);z-index:2}
1459.sel-steps{position:absolute;left:clamp(18px,6vw,96px);bottom:8vh;z-index:2;width:min(460px,88vw);display:grid;gap:18px}
1460.sel-step{display:grid;grid-template-columns:34px 1fr;gap:12px;opacity:.28;transition:opacity 1.2s var(--sel-slow),transform 1.2s var(--sel-slow);transform:translateX(-6px)}
1461.sel-step.on{opacity:1;transform:none}
1462.sel-k{font:400 11px/1.9 var(--sel-mono);color:var(--sel-gold)}
1463.sel-step h3{font:300 22px/1.2 var(--sel-sans)}
1464.sel-step p{font-size:14px;color:var(--sel-reg5);margin-top:6px;display:none}
1465.sel-step.on p{display:block}
1466.sel-hud-r{bottom:8vh}
1467
1468/* lunar day */
1469.sel-cycle{--p:0;background:var(--sel-basalt)}
1470.sel-cyc-stick{display:flex;flex-direction:column;justify-content:center;gap:clamp(12px,2.4vh,26px);width:min(1180px,100% - 48px);margin:0 auto;padding-top:68px}
1471.sel-cyc-head .sel-h2{font-size:clamp(28px,4vw,52px)}
1472.sel-cyc-grid{display:grid;grid-template-columns:1.55fr 1fr;gap:clamp(18px,3vw,44px);align-items:start;min-height:0}
1473.sel-scene{position:relative;border:1px solid rgba(255,255,255,.08);background:#040507}
1474.sel-scene svg{display:block;width:100%;height:auto;aspect-ratio:16/9;max-height:52vh}
1475.sel-cyc-earth{position:absolute;right:7%;top:7%;width:10.5%;aspect-ratio:1}
1476.sel-scene .sel-cap{position:absolute;left:12px;bottom:6px;margin:0;color:var(--sel-ano);text-shadow:0 1px 6px #000}
1477.sel-readouts{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}
1478.sel-readouts div{border-top:1px solid var(--sel-reg2);padding:9px 0}
1479.sel-readouts dt{font:400 10px/1.4 var(--sel-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--sel-reg4)}
1480.sel-readouts dd{font:200 clamp(20px,2.4vw,30px)/1.2 var(--sel-sans)}
1481.sel-wide{grid-column:1/-1}
1482.sel-wide dt{display:flex;justify-content:space-between}
1483.sel-wide dt b{font-weight:400;color:var(--sel-gold)}
1484.sel-meter{display:block;height:3px;background:var(--sel-reg2);margin-top:8px;overflow:hidden}
1485.sel-meter i{display:block;height:100%;background:var(--sel-gold);transform-origin:left;transform:scaleX(0)}
1486.sel-phases{display:grid;gap:12px;margin-top:14px}
1487.sel-phase{opacity:.3;transition:opacity 1s var(--sel-slow)}
1488.sel-phase.on{opacity:1}
1489.sel-phase h3{font:300 18px/1.3 var(--sel-sans)}
1490.sel-phase h3 span{font:400 10px/1 var(--sel-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--sel-reg4);margin-left:8px}
1491.sel-phase p{font-size:13px;color:var(--sel-reg5);line-height:1.55;margin-top:3px}
1492.sel-phase:not(.on) p{display:none}
1493.sel-timeline{container-type:inline-size}
1494.sel-tl-labels{display:grid;grid-template-columns:10fr 41fr 10fr 39fr;font:400 10px/1 var(--sel-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--sel-reg4);margin-bottom:8px}
1495.sel-tl-bar{position:relative;height:3px;background:repeating-linear-gradient(90deg,var(--sel-reg3) 0 1px,transparent 1px calc(100%/29.5));overflow:visible}
1496.sel-tl-bar::before{content:"";position:absolute;inset:0;background:var(--sel-reg2);z-index:-1}
1497.sel-tl-bar i{position:absolute;inset:0;background:var(--sel-ano);transform-origin:left;transform:scaleX(0);opacity:.85}
1498.sel-tl-bar b{position:absolute;left:0;top:-5px;width:13px;height:13px;margin-left:-6px;border-radius:50%;background:var(--sel-gold);box-shadow:0 0 14px rgba(227,178,74,.8)}
1499
1500/* sections */
1501.sel-sec{padding:clamp(80px,12vw,160px) 0;background:var(--sel-void);position:relative}
1502.sel-sec:nth-of-type(odd){background:var(--sel-basalt)}
1503.sel-sec-head{margin-bottom:clamp(36px,5vw,64px)}
1504.sel-rv{transition:opacity 1.8s var(--sel-slow) var(--d,0s),transform 1.8s var(--sel-slow) var(--d,0s)}
1505.sel-js .sel-rv:not(.in){opacity:0;transform:translate3d(0,34px,0)}
1506
1507/* configurator */
1508.sel-cfg{display:grid;grid-template-columns:1.5fr 1fr;gap:clamp(24px,4vw,56px);align-items:start}
1509.sel-cfg-scene{position:relative;border:1px solid rgba(255,255,255,.08);background:#040507;overflow:hidden}
1510.sel-cfg-scene svg{display:block;width:100%;height:auto}
1511.sel-cfg-earth{position:absolute;left:6%;top:7%;width:9%;aspect-ratio:1}
1512.sel-cfg-scene .sel-cap{position:absolute;right:12px;bottom:4px;margin:0;color:var(--sel-ano)}
1513.sel-mod{transform:translate(var(--x),-120px);opacity:0;transition:transform 2.6s var(--sel-slow),opacity .6s}
1514.sel-mod.on{transform:translate(var(--x),330px);opacity:1}
1515.sel-sh{fill:rgba(0,0,0,.55)}
1516.sel-puff{fill:rgba(210,205,196,.4);opacity:0;transform-box:fill-box;transform-origin:center}
1517.sel-mod.on .sel-puff{animation:sel-puff 2.2s ease-out 1.5s both}
1518@keyframes sel-puff{0%{opacity:.6;transform:scale(.2)}100%{opacity:0;transform:scale(1.8,1.4)}}
1519.sel-tube{stroke:#aeb3b8;stroke-width:5;stroke-linecap:round;opacity:.85;animation:sel-fadein 1.4s ease both}
1520@keyframes sel-fadein{from{opacity:0}to{opacity:.85}}
1521.sel-presets{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
1522.sel-presets button{background:transparent;border:1px solid var(--sel-reg3);padding:8px 14px;font:400 11px/1 var(--sel-mono);letter-spacing:.12em;text-transform:uppercase;cursor:pointer;transition:border-color .4s,background .4s}
1523.sel-presets button:hover{border-color:var(--sel-ano);background:rgba(255,255,255,.04)}
1524.sel-modlist{display:grid;gap:8px}
1525.sel-modbtn{display:grid;grid-template-columns:22px 1fr;gap:14px;text-align:left;background:rgba(255,255,255,.02);border:1px solid var(--sel-reg2);padding:14px 16px;cursor:pointer;transition:border-color .5s,background .5s}
1526.sel-modbtn:hover{border-color:var(--sel-reg4)}
1527.sel-modbtn.on{border-color:var(--sel-ano);background:rgba(255,255,255,.05)}
1528.sel-modbtn b{display:block;font-weight:400;font-size:17px}
1529.sel-modbtn small{display:block;font-size:13px;color:var(--sel-reg4);margin-top:2px}
1530.sel-tick{width:16px;height:16px;border:1px solid var(--sel-reg4);margin-top:4px;position:relative;border-radius:50%}
1531.sel-modbtn.on .sel-tick{border-color:var(--sel-ano)}
1532.sel-modbtn.on .sel-tick::after{content:"";position:absolute;inset:3px;background:var(--sel-ano);border-radius:50%}
1533.sel-totals{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:22px}
1534.sel-totals div{border-top:1px solid var(--sel-reg3);padding-top:10px}
1535.sel-totals b{display:block;font:200 36px/1.1 var(--sel-sans)}
1536.sel-totals span{font:400 10px/1.3 var(--sel-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--sel-reg4)}
1537.sel-power{margin-top:22px}
1538.sel-power-row{display:flex;justify-content:space-between;font:400 11px/1 var(--sel-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--sel-reg5)}
1539.sel-power-row b{font-weight:400;color:var(--sel-ano)}
1540.sel-power-bar{position:relative;height:6px;background:var(--sel-reg2);margin:10px 0;overflow:hidden}
1541.sel-power-bar i,.sel-power-bar u{position:absolute;left:0;right:0;transform-origin:left;transition:transform 1.4s var(--sel-slow)}
1542.sel-power-bar i{top:0;height:3px;background:var(--sel-ano)}
1543.sel-power-bar u{bottom:0;height:3px;background:var(--sel-gold);text-decoration:none}
1544.sel-note{font-size:13px;color:var(--sel-reg5);margin-top:12px}
1545.sel-note.warn{color:var(--sel-warm)}
1546
1547/* earth window */
1548.sel-win-grid{display:grid;grid-template-columns:1fr 1fr;gap:clamp(28px,6vw,90px);align-items:center}
1549.sel-slider{display:block;margin-top:30px}
1550.sel-slider span{display:flex;justify-content:space-between;font:400 11px/1 var(--sel-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--sel-reg5);margin-bottom:12px}
1551.sel-slider b{font-weight:400;color:var(--sel-ano)}
1552.sel-slider input{width:100%;accent-color:var(--sel-gold);height:24px;cursor:pointer}
1553.sel-port-wrap{display:flex;flex-direction:column;align-items:center}
1554.sel-port{position:relative;width:min(100%,460px);aspect-ratio:1;border-radius:50%;padding:7%;background:conic-gradient(from 210deg,#6b7078,#c9ccd0,#4b5056,#aab0b6,#6b7078);box-shadow:0 40px 80px -30px rgba(0,0,0,.9),inset 0 0 0 2px rgba(255,255,255,.2)}
1555.sel-port::before{content:"";position:absolute;inset:5%;border-radius:50%;background:#101215;box-shadow:inset 0 6px 18px rgba(0,0,0,.9)}
1556.sel-port-glass{position:relative;width:100%;height:100%;border-radius:50%;overflow:hidden;background:#020305;isolation:isolate}
1557.sel-port-earth{position:absolute;left:50%;top:44%;width:62%;aspect-ratio:1;transform:translate(-50%,-50%)}
1558.sel-port-horizon{position:absolute;left:0;bottom:0;width:100%;height:22%}
1559.sel-glare{position:absolute;inset:0;background:linear-gradient(130deg,rgba(255,255,255,.14),transparent 34%);pointer-events:none}
1560.sel-bolt{position:absolute;width:12px;height:12px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#e7e9eb,#5b6066);box-shadow:0 1px 2px #000}
1561.sel-b1{left:50%;top:1.6%;margin-left:-6px}.sel-b2{left:50%;bottom:1.6%;margin-left:-6px}.sel-b3{top:50%;left:1.6%;margin-top:-6px}.sel-b4{top:50%;right:1.6%;margin-top:-6px}
1562
1563/* crew */
1564.sel-crew{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
1565.sel-card{background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(255,255,255,.01));border:1px solid var(--sel-reg2);padding:22px 20px 24px;display:flex;flex-direction:column;gap:6px;outline-offset:2px}
1566.sel-card.in{transition:transform 3s var(--sel-slow),border-color 1.5s,box-shadow 3s var(--sel-slow)}
1567.sel-card.in:hover,.sel-card.in:focus-visible{transform:translate3d(0,-16px,0) rotate(-.8deg);border-color:var(--sel-reg4);box-shadow:0 40px 50px -30px rgba(0,0,0,.9)}
1568.sel-visor{width:112px;height:112px;margin-bottom:10px}
1569.sel-card h3{font:300 20px/1.2 var(--sel-sans)}
1570.sel-role{font:400 11px/1.5 var(--sel-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--sel-reg4)}
1571.sel-card blockquote{font:italic 400 15px/1.55 var(--sel-serif);color:var(--sel-reg5);margin-top:14px}
1572.sel-stat-line{font:400 11px/1 var(--sel-mono);letter-spacing:.1em;color:var(--sel-gold);margin-top:auto;padding-top:18px}
1573
1574/* manifest */
1575.sel-man-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:clamp(24px,4vw,60px);align-items:start}
1576.sel-man-form{display:grid;gap:26px}
1577.sel-man-form fieldset{border:0;min-width:0}
1578.sel-man-form legend{font:400 11px/1 var(--sel-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--sel-reg5);padding:0;margin-bottom:12px}
1579.sel-man-form legend small{color:var(--sel-reg4);margin-left:10px;letter-spacing:.06em;text-transform:none}
1580.sel-opts{display:grid;gap:8px}
1581.sel-opts-3{grid-template-columns:repeat(3,1fr)}
1582.sel-opt{position:relative;display:flex;flex-direction:column;gap:3px;border:1px solid var(--sel-reg2);padding:14px 16px;cursor:pointer;transition:border-color .5s,background .5s}
1583.sel-opt:hover{border-color:var(--sel-reg4)}
1584.sel-opt.on{border-color:var(--sel-ano);background:rgba(255,255,255,.05)}
1585.sel-opt input{position:absolute;opacity:0;inset:0;cursor:pointer}
1586.sel-opt:focus-within{outline:2px solid var(--sel-gold);outline-offset:3px}
1587.sel-opt b{font:400 15px/1.3 var(--sel-mono);letter-spacing:.04em}
1588.sel-opt span{font-size:13px;color:var(--sel-reg4)}
1589.sel-cabin{display:inline-grid;gap:8px;padding:18px 22px;border:1px solid var(--sel-reg2);border-radius:60px 60px 10px 10px / 40px 40px 10px 10px}
1590.sel-seatrow{display:flex;gap:8px;align-items:center}
1591.sel-seatrow em{width:16px;font:400 10px/1 var(--sel-mono);color:var(--sel-reg4);font-style:normal}
1592.sel-seat{width:44px;height:40px;background:transparent;border:1px solid var(--sel-reg3);border-radius:8px 8px 4px 4px;cursor:pointer;font:400 11px/1 var(--sel-mono);color:var(--sel-reg5);transition:background .4s,border-color .4s,color .4s}
1593.sel-seat.aisle{margin-right:22px}
1594.sel-seat:hover:not(:disabled){border-color:var(--sel-ano)}
1595.sel-seat.on{background:var(--sel-ano);color:#101215;border-color:var(--sel-ano)}
1596.sel-seat:disabled{background:repeating-linear-gradient(45deg,var(--sel-reg2) 0 3px,transparent 3px 6px);color:var(--sel-reg3);border-color:var(--sel-reg2);cursor:not-allowed}
1597.sel-field{display:grid;gap:10px}
1598.sel-field span{font:400 11px/1 var(--sel-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--sel-reg5)}
1599.sel-field input{background:transparent;border:0;border-bottom:1px solid var(--sel-reg3);padding:10px 0;font-size:20px;font-weight:200;border-radius:0;transition:border-color .4s}
1600.sel-field input:focus{outline:none;border-color:var(--sel-gold)}
1601.sel-field input::placeholder{color:var(--sel-reg3)}
1602.sel-man-form .sel-btn{justify-self:start}
1603.sel-pass{position:sticky;top:90px;background:#e9ebec;color:#14161a;padding:24px 26px 22px;border-radius:4px;font-family:var(--sel-mono);overflow:hidden;box-shadow:0 40px 80px -40px rgba(0,0,0,.9)}
1604.sel-pass::before,.sel-pass::after{content:"";position:absolute;top:96px;width:22px;height:22px;border-radius:50%;background:var(--sel-basalt)}
1605.sel-pass::before{left:-11px}.sel-pass::after{right:-11px}
1606.sel-pass-top{display:flex;justify-content:space-between;font:500 10px/1 var(--sel-mono);letter-spacing:.18em;text-transform:uppercase;padding-bottom:16px;border-bottom:1px dashed #9aa0a6}
1607.sel-pass dl{display:grid;gap:10px;margin-top:20px}
1608.sel-pass dl div{display:flex;justify-content:space-between;gap:14px;font-size:12px}
1609.sel-pass dt{color:#767c83;text-transform:uppercase;letter-spacing:.1em;font-size:10px;padding-top:2px}
1610.sel-pass dd{text-align:right;font-weight:500;word-break:break-word}
1611.sel-pass-bar{display:flex;gap:2px;height:34px;margin:22px 0 14px;align-items:stretch}
1612.sel-pass-bar i{background:#14161a;display:block}
1613.sel-pass-total{display:flex;justify-content:space-between;align-items:baseline;border-top:1px dashed #9aa0a6;padding-top:14px}
1614.sel-pass-total span{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#767c83}
1615.sel-pass-total b{font:300 28px/1 var(--sel-sans)}
1616.sel-pass .sel-fine{color:#5a6068;margin-top:14px}
1617.sel-stamp{position:absolute;right:20px;top:128px;border:2px solid #b4801a;color:#b4801a;padding:6px 12px;text-transform:uppercase;font:500 11px/1.3 var(--sel-mono);letter-spacing:.14em;text-align:center;opacity:0;transform:rotate(-12deg) scale(1.6);transition:opacity .4s,transform 1s var(--sel-slow);pointer-events:none}
1618.sel-stamp small{display:block;font-size:10px}
1619.sel-pass.cleared .sel-stamp{opacity:.92;transform:rotate(-12deg) scale(1)}
1620
1621/* footer */
1622.sel-foot{background:var(--sel-void);padding:clamp(60px,9vw,120px) 0 40px;border-top:1px solid var(--sel-reg2);overflow:hidden}
1623.sel-foot-grid{display:grid;grid-template-columns:1.6fr 1fr;gap:40px;align-items:end}
1624.sel-foot-mark{font:200 clamp(60px,15vw,210px)/.9 var(--sel-sans);letter-spacing:.07em;color:transparent;-webkit-text-stroke:1px var(--sel-reg3)}
1625.sel-foot-cols{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
1626.sel-foot-cols h4{font:400 10px/1 var(--sel-mono);letter-spacing:.2em;text-transform:uppercase;color:var(--sel-reg4);margin-bottom:14px}
1627.sel-foot-cols a{display:block;font-size:14px;color:var(--sel-reg5);padding:4px 0;transition:color .4s}
1628.sel-foot-cols a:hover{color:var(--sel-ano)}
1629
1630/* reduced motion */
1631.sel-rm .sel-hero{height:100vh;height:100svh}
1632.sel-rm .sel-statement{height:auto;padding:120px 0}
1633.sel-rm .sel-statement .sel-stick{position:relative;height:auto}
1634.sel-rm .sel-rim,.sel-rm .sel-cycle{height:auto}
1635.sel-rm .sel-rim .sel-stick{height:100vh}
1636.sel-rm .sel-cycle .sel-stick{position:relative;height:auto;padding:90px 0}
1637.sel-rm .sel-step,.sel-rm .sel-phase{opacity:1;transform:none}
1638.sel-rm .sel-step p,.sel-rm .sel-phase p{display:block}
1639.sel-rm .sel-mod,.sel-rm .sel-card,.sel-rm .sel-rv{transition:none}
1640.sel-rm .sel-mod.on .sel-puff,.sel-rm .sel-cue i,.sel-rm .sel-tube{animation:none}
1641.sel-rm .sel-rv{opacity:1!important;transform:none!important}
1642.sel-rm .sel-cue{display:none}
1643@media (prefers-reduced-motion:reduce){
1644 .sel-root *,.sel-root *::before,.sel-root *::after{animation-duration:.001s!important;transition-duration:.001s!important}
1645}
1646
1647/* responsive */
1648@media (max-width:960px){
1649 .sel-links{display:none}
1650 .sel-cyc-grid,.sel-cfg,.sel-win-grid,.sel-man-grid{grid-template-columns:1fr}
1651 .sel-crew{grid-template-columns:repeat(2,1fr)}
1652 .sel-stats{grid-template-columns:repeat(2,1fr)}
1653 .sel-pass{position:relative;top:0}
1654 .sel-foot-grid{grid-template-columns:1fr}
1655}
1656@media (max-width:640px){
1657 .sel-wrap{width:calc(100% - 32px)}
1658 .sel-nav{padding:12px 16px}
1659 .sel-brand{font-size:15px;letter-spacing:.26em}
1660 .sel-earthwrap{right:6vw;top:8vh;width:42vw}
1661 .sel-hero-copy{top:auto;bottom:34vh}
1662 .sel-title{font-size:22vw;margin:12px 0 14px -.05em}
1663 .sel-sub{font-size:15px}
1664 .sel-cta{margin-top:18px;gap:10px}
1665 .sel-btn{padding:15px 20px}
1666 .sel-hud{display:none}
1667 .sel-rise{bottom:5vh;font-size:10px}
1668 .sel-cue{display:none}
1669 .sel-rim-head{top:84px}
1670 .sel-steps{bottom:5vh}
1671 .sel-step:not(.on){display:none}
1672 .sel-hud-r{display:none}
1673 .sel-cyc-stick{width:calc(100% - 32px);padding-top:60px;gap:10px}
1674 .sel-cyc-head .sel-h2{font-size:26px}
1675 .sel-cyc-head .sel-eyebrow{display:none}
1676 .sel-scene svg{max-height:none}
1677 .sel-phase:not(.on){display:none}
1678 .sel-phase p{font-size:12.5px}
1679 .sel-readouts dd{font-size:18px}
1680 .sel-readouts div{padding:6px 0}
1681 .sel-timeline{display:none}
1682 .sel-opts-3{grid-template-columns:1fr}
1683 .sel-crew{grid-template-columns:1fr}
1684 .sel-seat{width:40px}
1685 .sel-seat.aisle{margin-right:14px}
1686 .sel-foot-cols{grid-template-columns:repeat(2,1fr)}
1687 .sel-rm .sel-hero-copy{bottom:34vh}
1688}
1689`;

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.

Selene — design brief

Quiet Lunar Monochrome. Cold, silent, spacious: the opposite of a warm Mars palette. The sky is always black (no atmosphere), light is raking and hard, everything moves slowly as if in 1/6 g. A single gold accent exists only as sunlit solar panels and the primary action.

Palette (CSS variables on .sel-root)

TokenValueUse
--sel-void#06070apage background, sky
--sel-basalt#0d0f12alternate section background
--sel-reg1 / reg2#171a1e / #252a2fhairlines, tracks, card borders
--sel-reg3#434950strong borders, ghost underline
--sel-reg4#767c83muted labels
--sel-reg5#a3a49fsecondary text
--sel-warm#b7ae9cwarm regolith, warnings
--sel-ano#eceeefanorthosite white: primary text, lit ground
--sel-blue / --sel-green#3f7fb8 / #4e8f6aEarth only (visor reflections, greenhouse glass)
--sel-gold / --sel-gold-hi#e3b24a / #fbe29asolar panels, CTA, meters, focus ring

Terrain colours are per layer in TCfg (lit/shade RGB): far cool grey, mid neutral, near warm grey, rim-floor bluish.

Type

  • Sans (display + body): Outfit / Jost / Avenir Next / Helvetica Neue / system. Display weight 200, body 300; hero title 200 at up to 250px with 0.07em tracking.
  • Mono (labels, readouts, buttons): JetBrains Mono / SF Mono / ui-monospace; 10-13px, uppercase, 0.1-0.2em tracking.
  • Serif italic (crew quotes only): Iowan Old Style / Palatino / Georgia.

Spacing and shape

Wrap width 1180px with 24px gutters (16px below 640px). Sections have 80-160px vertical padding. Radii are near zero (2-4px) except the porthole (circle), boarding pass (4px) and cabin outline. Hairline 1px borders, no shadows except the porthole, the boarding pass and card hover.

Motion (slow lunar ease cubic-bezier(.16,.84,.3,1), 1.2-3s)

One rAF loop reads getBoundingClientRect of each [data-sel-p] wrapper, writes --p (0..1), and drives transform/opacity only.

  1. Hero: canvas terrain (3 layers, true cast shadows from a sun-slope sweep), Earth sprite rising by --p, footprints revealed with opacity: clamp(0, p*52 - i, 1).
  2. 1/6 g cursor: title letters and CTA are underdamped springs (k=7, c=1.3) kicked away from the pointer with upward bias; pointer movement over the ground kicks regolith particles under 150 px/s^2 gravity; click bursts dust.
  3. Statement: each word's opacity is clamp(.16, (p*1.35 - w)*9 + .16, 1).
  4. Crater rim: three terrain layers move down/scale at different rates; step captions and depth/temperature counters follow progress.
  5. Lunar day: p maps to 29.5 days; sun walks an arc, shadows scale/flip with cot(elevation), gold panels and ground brightness follow elevation, Earth redrawn per day with its real phase, timeline fill and thumb scrub.
  6. Configurator: modules transition from above the frame with a 2.6s ease, puff dust on landing, tubes fade in between neighbours.
  7. Earth window: sphere-sampled procedural Earth rotates while visible (IntersectionObserver), phase from a day slider.
  8. Crew cards rise on view and float up 16px on hover over 3s. Manifest stamp scales in on clearance.

Reduced motion: no pinning, physics or autoplay; pinned sections become static stacked compositions, all phase and step text is shown.

Sections

  1. Nav (fixed, condenses to blurred basalt) 2. Hero 3. Statement + 4 stats 4. Crater rim 5. Lunar day 6. Module configurator (presets, aria-pressed toggles, power balance) 7. Earth window 8. Crew (4 visor cards) 9. Seat manifest (departure, stay, seat map, name, live boarding pass) 10. Footer with outlined wordmark.

Rules

Imports only react. No images, web fonts or network. Illustrations: canvas (terrain, stars, Earth) and inline SVG. Class prefix sel-. SSR-safe: window/document only in effects.