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

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: full-bleed moon-base image scaled by --p (push-in) under a contrast scrim; HUD readouts follow progress.
  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: the same image cropped to the crater wall, zooming toward the flag with --p; 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 web fonts or network. Hero and crater rim use the photographic render in /img/space/ (canvas Earth, stars and inline SVG remain for the other sections). Class prefix sel-. SSR-safe: window/document only in effects.

Assets

Images live in public/img/space/: moon-base.webp (1329w), moon-base-1200.webp, moon-base-640.webp. Copy the whole img/space/ folder next to the page so root-relative /img/space/... URLs resolve. The hero image is fetchPriority="high"; the rim crop is loading="lazy". Text over imagery sits on gradient scrims (AA contrast).

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

Questions about Selene

A lunar base for research and quiet — Earth rises as you scroll, footprints print themselves. It is a free space & research landing page for React with a matching DESIGN.md brief.

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

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

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