1/**
2 * Arcade OS — indie game studio landing page, presented as a retro desktop OS.
3 *
4 * First screen: a BIOS boot sequence that drops into a teal desktop whose
5 * sections are real windows (About, Games folder, Trailer player, Devlog
6 * terminal, Snake, Wishlist form): draggable by the title bar, click-to-focus,
7 * minimise to the taskbar, close and re-open from desktop icons or Start.
8 * Taskbar has a live clock plus CRT scanline / phosphor-glow toggles.
9 * Below the desktop: ticker, roadmap bars, hi-score board (your Snake score
10 * lands in it), crew portraits, system alerts and a shutdown footer.
11 *
12 * Everything is procedural: pixel-art SVG icons from string grids, canvas
13 * trailer + snake, CSS bevels. No images, no web fonts, no network.
14 * Motion is stepped (steps() easing / canvas) to feel like 8-bit hardware;
15 * prefers-reduced-motion skips the boot and freezes ambient motion.
16 * SSR-safe: window/document only touched inside effects.
17 */
18import {
19 useCallback,
20 useEffect,
21 useRef,
22 useState,
23 type CSSProperties,
24 type PointerEvent as RPointerEvent,
25 type ReactNode,
26} from "react";
27
28/* ------------------------------------------------------------------ */
29/* Pixel art */
30/* ------------------------------------------------------------------ */
31const PAL: Record<string, string> = {
32 k: "#1b1230",
33 c: "#f4ecd0",
34 w: "#fffdf0",
35 y: "#ffd23f",
36 m: "#e0338f",
37 t: "#2a9d9d",
38 d: "#0e4f55",
39 g: "#9a9684",
40 o: "#ff8a3d",
41};
42
43const ICONS: Record<string, string[]> = {
44 folder: [
45 "............",
46 ".kkkkk......",
47 "kyyyyyk.....",
48 "kkkkkkkkkkk.",
49 "kyyyyyyyyyyk",
50 "kyyyyyyyyyyk",
51 "kyyyyyyyyyyk",
52 "kyyyyyyyyyyk",
53 "kyyyyyyyyyyk",
54 "kyyyyyyyyyyk",
55 "kkkkkkkkkkkk",
56 "............",
57 ],
58 doc: [
59 "..kkkkkkkk..",
60 "..kccccccck.",
61 "..kcmmmmmck.",
62 "..kccccccck.",
63 "..kckkkkkck.",
64 "..kccccccck.",
65 "..kckkkkkck.",
66 "..kccccccck.",
67 "..kckkkkkck.",
68 "..kccccccck.",
69 "..kkkkkkkkk.",
70 "............",
71 ],
72 film: [
73 "............",
74 "kkkkkkkkkkkk",
75 "kddydddddddk",
76 "kddyyddddddk",
77 "kddyyydddddk",
78 "kddyyddddddk",
79 "kddydddddddk",
80 "kddddddddddk",
81 "kkkkkkkkkkkk",
82 "....kkkk....",
83 "..kkkkkkkk..",
84 "............",
85 ],
86 term: [
87 "............",
88 "kkkkkkkkkkkk",
89 "kyyyyyyyyyyk",
90 "kkkkkkkkkkkk",
91 "kddddddddddk",
92 "kdcddddddddk",
93 "kddcdddddddk",
94 "kdcddcccdddk",
95 "kddddddddddk",
96 "kkkkkkkkkkkk",
97 "............",
98 "............",
99 ],
100 heart: [
101 "............",
102 "..kkk..kkk..",
103 ".kmmmkkmmmk.",
104 "kmmwmmmmmmmk",
105 "kmwmmmmmmmmk",
106 "kmmmmmmmmmmk",
107 ".kmmmmmmmmk.",
108 "..kmmmmmmk..",
109 "...kmmmmk...",
110 "....kmmk....",
111 ".....kk.....",
112 "............",
113 ],
114 alien: [
115 "............",
116 "...k....k...",
117 "....k..k....",
118 "...mmmmmm...",
119 "..mmkmmkmm..",
120 ".mmmmmmmmmm.",
121 ".mmmmmmmmmm.",
122 ".mmmmmmmmmm.",
123 ".m.mmmmmm.m.",
124 ".m.m....m.m.",
125 "...mm..mm...",
126 "............",
127 ],
128 cart: [
129 "............",
130 "kkkkkkkkkkkk",
131 "kcccccccccck",
132 "kckkkkkkkkck",
133 "kckaaaaaakck",
134 "kckaaaaaakck",
135 "kckaaaaaakck",
136 "kckkkkkkkkck",
137 "kcccccccccck",
138 "kcccckkcccck",
139 "kkkkkkkkkkkk",
140 "............",
141 ],
142 alert: [
143 "kkkkkkkkkkkk",
144 "kaaaaaaaaaak",
145 "kaaaaccaaaak",
146 "kaaaaccaaaak",
147 "kaaaaccaaaak",
148 "kaaaaccaaaak",
149 "kaaaaccaaaak",
150 "kaaaaaaaaaak",
151 "kaaaaccaaaak",
152 "kaaaaccaaaak",
153 "kaaaaaaaaaak",
154 "kkkkkkkkkkkk",
155 ],
156 cloud: [
157 "......kkkk......",
158 "....kkwwwwkk....",
159 "..kkkwwwwwwkkk..",
160 ".kwwwwwwwwwwwwk.",
161 "kwwwwwwwwwwwwwwk",
162 ".kkkkkkkkkkkkkk.",
163 ],
164};
165
166function Px({
167 name,
168 size = 36,
169 over,
170 className,
171}: {
172 name: keyof typeof ICONS | string;
173 size?: number;
174 over?: Record<string, string>;
175 className?: string;
176}) {
177 const g = ICONS[name];
178 const h = g.length;
179 const w = g[0].length;
180 const rects: ReactNode[] = [];
181 g.forEach((row, y) => {
182 let x = 0;
183 while (x < row.length) {
184 const ch = row[x];
185 if (ch === ".") {
186 x++;
187 continue;
188 }
189 let run = 1;
190 while (x + run < row.length && row[x + run] === ch) run++;
191 const fill = (over && over[ch]) || PAL[ch] || "#000";
192 rects.push(<rect key={`${x}-${y}`} x={x} y={y} width={run} height={1} fill={fill} />);
193 x += run;
194 }
195 });
196 return (
197 <svg
198 className={className}
199 width={(size * w) / 12}
200 height={(size * h) / 12}
201 viewBox={`0 0 ${w} ${h}`}
202 shapeRendering="crispEdges"
203 aria-hidden="true"
204 focusable="false"
205 >
206 {rects}
207 </svg>
208 );
209}
210
211/* ------------------------------------------------------------------ */
212/* Data */
213/* ------------------------------------------------------------------ */
214type WinId = "about" | "games" | "trailer" | "devlog" | "snake" | "wishlist";
215type WinState = { x: number; y: number; z: number; open: boolean; min: boolean };
216
217const WIN_META: Record<WinId, { title: string; file: string; icon: string; w: number }> = {
218 about: { title: "ABOUT.TXT", file: "About", icon: "doc", w: 360 },
219 games: { title: "C:\\GAMES", file: "Games", icon: "folder", w: 410 },
220 trailer: { title: "TRAILER.AVI", file: "Trailer", icon: "film", w: 400 },
221 devlog: { title: "DEVLOG.SH", file: "Devlog", icon: "term", w: 420 },
222 snake: { title: "TAILTRAIN.EXE", file: "Tail Train", icon: "alien", w: 352 },
223 wishlist: { title: "WISHLIST.DAT", file: "Wishlist", icon: "heart", w: 380 },
224};
225const ORDER: WinId[] = ["about", "games", "trailer", "devlog", "snake", "wishlist"];
226
227const WIDE_POS: Record<WinId, [number, number]> = {
228 about: [130, 28],
229 games: [520, 28],
230 trailer: [950, 28],
231 devlog: [130, 362],
232 snake: [545, 432],
233 wishlist: [940, 366],
234};
235
236const GAMES = [
237 { id: "moth", name: "Moth Kingdom", genre: "Metroidvania", status: "Wishlist on Steam", year: "2027", price: "$19.99", color: "#e0338f", blurb: "Play a moth librarian rewiring a collapsing lantern city. 40 hand-pixelled rooms, one very large lamp." },
238 { id: "sand", name: "Sandwich Samurai", genre: "Rhythm brawler", status: "Early access", year: "2026", price: "$12.99", color: "#ffd23f", blurb: "Slice lunch to the beat. Four-lane parry combat, a 30-track chiptune ramen soundtrack." },
239 { id: "lunch", name: "Dungeon Lunch", genre: "Roguelike cooking", status: "Released", year: "2025", price: "$14.99", color: "#2a9d9d", blurb: "Descend, forage, cook and eat what you kill. 94% positive, 61 recipes, zero health bars." },
240 { id: "orbit", name: "Orbit Janitor", genre: "Cozy physics", status: "Playable demo", year: "2027", price: "Free demo", color: "#ff8a3d", blurb: "Sweep space debris with a very long tail. The demo is the Tail Train window on your desktop." },
241] as const;
242
243const DEVLOG = [
244 { c: "ok", t: "$ git log --oneline -n 5" },
245 { c: "", t: "a41f9c2 moth: wings now cast shadows (finally)" },
246 { c: "", t: "7be03d1 audio: 32 new sfx, 3 of them are cats" },
247 { c: "", t: "c90aa17 fix: door softlock in lantern hall" },
248 { c: "", t: "5d2e8f0 perf: 60fps on a 2014 laptop. nice." },
249 { c: "ok", t: "$ cat NOTES_OCT.txt" },
250 { c: "dim", t: "-- Oct 28 -- boss 2 is fun. too fun. nerfing." },
251 { c: "dim", t: "-- Oct 30 -- Luka drew 400 frames of a sneeze." },
252 { c: "ok", t: "$ _ type 'help' to talk to me" },
253];
254
255const SCORES = [
256 { n: "MOO", g: "Dungeon Lunch", s: 128400 },
257 { n: "BEX", g: "Sandwich Samurai", s: 97250 },
258 { n: "ZIP", g: "Moth Kingdom", s: 88100 },
259 { n: "NAN", g: "Orbit Janitor", s: 61900 },
260 { n: "OLI", g: "Dungeon Lunch", s: 42300 },
261];
262
263const ROADMAP = [
264 { n: "Moth Kingdom: vertical slice", pct: 85, when: "Q4 2026" },
265 { n: "Sandwich Samurai: 1.0 release", pct: 70, when: "Q1 2027" },
266 { n: "Orbit Janitor: public demo", pct: 55, when: "Q2 2027" },
267 { n: "Console ports (Switch, Deck)", pct: 25, when: "Q3 2027" },
268];
269
270const 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" };
271const portraitAlt = (n: string, r: string, src: string) => `${n}, ${r}: portrait of ${LOOK[src.match(/p\d/)?.[0] ?? ""] ?? "a person"} in a black sweater`;
272
273const CREW = [
274 { n: "Luka Vesel", r: "Pixel art", img: "/img/people/p8.webp" },
275 { n: "Ines Okafor", r: "Design", img: "/img/people/p3.webp" },
276 { n: "Bram de Wit", r: "Engine", img: "/img/people/p5.webp" },
277 { n: "Sora Tanaka", r: "Music", img: "/img/people/p6.webp" },
278];
279
280const QUOTES = [
281 "\"The cosiest 8 hours I spent in a collapsing city.\" - Pixel Parade",
282 "\"Dungeon Lunch made me hungry AND sad.\" - Joystick Weekly",
283 "\"Neon Cellar ships polish like it is a personality.\" - Indie Cargo",
284 "\"I played the Orbit Janitor demo four times. Send help.\" - @lowpolyleo",
285];
286
287/* ------------------------------------------------------------------ */
288/* Hooks */
289/* ------------------------------------------------------------------ */
290function useInView<T extends HTMLElement>(): [React.RefObject<T | null>, boolean] {
291 const ref = useRef<T | null>(null);
292 const [seen, setSeen] = useState(false);
293 useEffect(() => {
294 const el = ref.current;
295 if (!el) return;
296 if (typeof IntersectionObserver === "undefined") {
297 setSeen(true);
298 return;
299 }
300 const io = new IntersectionObserver(
301 (es) => {
302 if (es.some((e) => e.isIntersecting)) {
303 setSeen(true);
304 io.disconnect();
305 }
306 },
307 { threshold: 0.2 },
308 );
309 io.observe(el);
310 return () => io.disconnect();
311 }, []);
312 return [ref, seen];
313}
314
315function useCount(target: number, go: boolean, ms = 1100) {
316 const [v, setV] = useState(target);
317 const first = useRef(true);
318 useEffect(() => {
319 if (!go) {
320 if (first.current) {
321 first.current = false;
322 setV(0);
323 }
324 return;
325 }
326 const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
327 if (reduce) {
328 setV(target);
329 return;
330 }
331 let raf = 0;
332 const t0 = performance.now();
333 const tick = (now: number) => {
334 const p = Math.min(1, (now - t0) / ms);
335 // quantised to 24 steps for a chunky counter
336 setV(Math.round(target * (Math.floor(p * 24) / 24)));
337 if (p < 1) raf = requestAnimationFrame(tick);
338 else setV(target);
339 };
340 raf = requestAnimationFrame(tick);
341 return () => cancelAnimationFrame(raf);
342 }, [go, target, ms]);
343 return v;
344}
345
346/* ------------------------------------------------------------------ */
347/* Window frame */
348/* ------------------------------------------------------------------ */
349function Win({
350 id,
351 st,
352 focused,
353 mobile,
354 stageRef,
355 onFocus,
356 onMin,
357 onClose,
358 onMove,
359 children,
360}: {
361 id: WinId;
362 st: WinState;
363 focused: boolean;
364 mobile: boolean;
365 stageRef: React.RefObject<HTMLDivElement | null>;
366 onFocus: (id: WinId) => void;
367 onMin: (id: WinId) => void;
368 onClose: (id: WinId) => void;
369 onMove: (id: WinId, x: number, y: number) => void;
370 children: ReactNode;
371}) {
372 const meta = WIN_META[id];
373 const drag = useRef<{ dx: number; dy: number } | null>(null);
374 const [dragging, setDragging] = useState(false);
375 const hidden = !st.open || st.min;
376
377 const down = (e: RPointerEvent<HTMLDivElement>) => {
378 onFocus(id);
379 if (mobile || (e.target as HTMLElement).closest("button")) return;
380 drag.current = { dx: e.clientX - st.x, dy: e.clientY - st.y };
381 setDragging(true);
382 e.currentTarget.setPointerCapture(e.pointerId);
383 };
384 const move = (e: RPointerEvent<HTMLDivElement>) => {
385 if (!drag.current) return;
386 const r = stageRef.current?.getBoundingClientRect();
387 const W = r ? r.width : 1200;
388 const H = r ? r.height : 700;
389 const x = Math.max(-meta.w + 90, Math.min(W - 70, e.clientX - drag.current.dx));
390 const y = Math.max(0, Math.min(H - 80, e.clientY - drag.current.dy));
391 onMove(id, x, y);
392 };
393 const up = (e: RPointerEvent<HTMLDivElement>) => {
394 drag.current = null;
395 setDragging(false);
396 try {
397 e.currentTarget.releasePointerCapture(e.pointerId);
398 } catch {
399 /* noop */
400 }
401 };
402
403 const style: CSSProperties = mobile
404 ? { zIndex: st.z }
405 : { zIndex: st.z, width: meta.w, transform: `translate3d(${st.x}px, ${st.y}px, 0)` };
406
407 return (
408 <section
409 className={`ao-win ao-bev${focused ? " is-focus" : ""}${dragging ? " is-drag" : ""}`}
410 style={{ ...style, display: hidden ? "none" : undefined }}
411 onPointerDown={() => onFocus(id)}
412 aria-label={meta.title}
413 >
414 <div
415 className="ao-title"
416 onPointerDown={down}
417 onPointerMove={move}
418 onPointerUp={up}
419 onPointerCancel={up}
420 onDoubleClick={() => onMin(id)}
421 >
422 <Px name={meta.icon === "cart" ? "cart" : meta.icon} size={14} />
423 <span className="ao-title-t">{meta.title}</span>
424 <button className="ao-tb" aria-label={`Minimise ${meta.title}`} onClick={() => onMin(id)}>
425 <i className="ao-tb-min" />
426 </button>
427 <button className="ao-tb" aria-label={`Close ${meta.title}`} onClick={() => onClose(id)}>
428 <i className="ao-tb-x" />
429 </button>
430 </div>
431 <div className="ao-win-body">{children}</div>
432 </section>
433 );
434}
435
436/* ------------------------------------------------------------------ */
437/* Window contents */
438/* ------------------------------------------------------------------ */
439function About({ open }: { open: (id: WinId) => void }) {
440 return (
441 <div className="ao-pad">
442 <h2 className="ao-h">NEON CELLAR</h2>
443 <p>
444 We are eleven people in a converted bike cellar in Rotterdam, making small,
445 strange, lovingly polished games. Every sprite is placed by hand. Every
446 patch note has a joke in it.
447 </p>
448 <table className="ao-sys">
449 <tbody>
450 <tr><td>Founded</td><td>2019</td></tr>
451 <tr><td>Shipped</td><td>3 games, 1 jam, 0 crunch</td></tr>
452 <tr><td>Players</td><td>1.4 million</td></tr>
453 <tr><td>Fuel</td><td>Stroopwafels</td></tr>
454 </tbody>
455 </table>
456 <div className="ao-row">
457 <button className="ao-btn" onClick={() => open("games")}>Browse games</button>
458 <button className="ao-btn ao-btn-hot" onClick={() => open("wishlist")}>Join wishlist</button>
459 </div>
460 </div>
461 );
462}
463
464function CartIcon({ color, size = 40 }: { color: string; size?: number }) {
465 return <Px name="cart" size={size} over={{ a: color }} />;
466}
467
468function Games({ open }: { open: (id: WinId) => void }) {
469 const [sel, setSel] = useState<string>(GAMES[0].id);
470 const g = GAMES.find((x) => x.id === sel) ?? GAMES[0];
471 return (
472 <div className="ao-games">
473 <div className="ao-menu"><span>File</span><span>Edit</span><span>View</span><span>Help</span></div>
474 <div className="ao-games-body">
475 <div className="ao-files" role="listbox" aria-label="Games">
476 {GAMES.map((x) => (
477 <button
478 key={x.id}
479 role="option"
480 aria-selected={x.id === sel}
481 className={`ao-file${x.id === sel ? " on" : ""}`}
482 onClick={() => setSel(x.id)}
483 onDoubleClick={() => (x.id === "orbit" ? open("snake") : open("wishlist"))}
484 >
485 <CartIcon color={x.color} size={40} />
486 <span>{x.name}</span>
487 </button>
488 ))}
489 </div>
490 <div className="ao-prev ao-inset" aria-live="polite">
491 <div className="ao-prev-art" style={{ background: g.color }}>
492 <CartIcon color="#f4ecd0" size={56} />
493 </div>
494 <b className="ao-prev-n">{g.name}</b>
495 <small>{g.genre} / {g.year}</small>
496 <p>{g.blurb}</p>
497 <button className="ao-btn ao-btn-hot" onClick={() => (g.id === "orbit" ? open("snake") : open("wishlist"))}>
498 {g.id === "orbit" ? "Run demo" : g.status === "Released" ? "Buy " + g.price : "Wishlist " + g.price}
499 </button>
500 </div>
501 </div>
502 <div className="ao-status"><span>4 object(s)</span><span>{g.status}</span></div>
503 </div>
504 );
505}
506
507/* Trailer: a procedural 30s canvas "film" */
508const TRAILER_LEN = 30;
509function drawTrailer(ctx: CanvasRenderingContext2D, t: number) {
510 const W = 160;
511 const H = 90;
512 const R = (x: number, y: number, w: number, h: number, c: string) => {
513 ctx.fillStyle = c;
514 ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
515 };
516 const text = (s: string, x: number, y: number, c: string, size = 9) => {
517 ctx.font = `bold ${size}px ui-monospace, Menlo, Consolas, monospace`;
518 ctx.fillStyle = c;
519 ctx.fillText(s, Math.round(x), Math.round(y));
520 };
521 const type = (s: string, st: number) => s.slice(0, Math.max(0, Math.floor((t - st) * 14)));
522 const moth = (x: number, y: number, flap: boolean) => {
523 R(x, y + 2, 5, 4, "#f4ecd0");
524 R(x + 1, y, 3, 2, "#ffd23f");
525 R(x - (flap ? 3 : 2), y + (flap ? 0 : 2), 3, 3, "#e0338f");
526 R(x + 5, y + (flap ? 0 : 2), 3, 3, "#e0338f");
527 R(x + 1, y + 3, 1, 1, "#1b1230");
528 R(x + 3, y + 3, 1, 1, "#1b1230");
529 };
530 if (t < 8) {
531 const bands = ["#0e4f55", "#2a9d9d", "#7fcfb8", "#ffd23f", "#ff8a3d"];
532 bands.forEach((c, i) => R(0, i * 14, W, 15, c));
533 const sy = 62 - Math.min(1, t / 6) * 26;
534 R(104, sy, 18, 18, "#fffdf0");
535 R(106, sy - 2, 14, 22, "#fffdf0");
536 for (let x = 0; x < W; x += 2) {
537 const h1 = 56 + Math.sin((x + t * 10) * 0.07) * 6;
538 R(x, h1, 2, H - h1, "#1f7a6a");
539 const h2 = 68 + Math.sin((x + t * 24) * 0.11 + 2) * 5;
540 R(x, h2, 2, H - h2, "#0e4f55");
541 }
542 moth(36, 40 + Math.sin(t * 4) * 3, Math.floor(t * 6) % 2 === 0);
543 text(type("A MOTH. A KINGDOM.", 1), 8, 14, "#fffdf0");
544 } else if (t < 16) {
545 const u = t - 8;
546 R(0, 0, W, H, "#1b1230");
547 for (let i = 0; i < 40; i++) {
548 const on = (Math.floor(u * 3) + i) % 5 !== 0;
549 if (on) R((i * 37) % W, (i * 23) % 50, 1, 1, "#f4ecd0");
550 }
551 R(120, 10, 14, 14, "#ffd23f");
552 R(125, 8, 9, 14, "#1b1230");
553 for (let i = 0; i < 18; i++) {
554 const bx = ((i * 11 - u * 18) % (W + 20) + (W + 20)) % (W + 20) - 10;
555 const bh = 24 + ((i * 17) % 30);
556 R(bx, H - bh, 10, bh, i % 2 ? "#0e4f55" : "#1f5f6a");
557 for (let w = 0; w < 4; w++) {
558 if ((i + w + Math.floor(u * 2)) % 3 === 0) R(bx + 2 + (w % 2) * 4, H - bh + 4 + w * 5, 2, 2, "#ffd23f");
559 }
560 }
561 moth(40, 36 + Math.sin(u * 4) * 3, Math.floor(u * 6) % 2 === 0);
562 text(type("THE LANTERNS ARE GOING OUT.", 9), 6, 14, "#f4ecd0", 8);
563 } else if (t < 24) {
564 const u = t - 16;
565 R(0, 0, W, H, u % 1 < 0.08 ? "#fffdf0" : "#4a1250");
566 R(0, 72, W, 18, "#1b1230");
567 const by = 22 + Math.sin(u * 2) * 5;
568 R(94, by, 40, 36, "#e0338f");
569 R(98, by + 8, 10, 8, "#fffdf0");
570 R(120, by + 8, 10, 8, "#fffdf0");
571 R(102, by + 10, 4, 4, "#1b1230");
572 R(124, by + 10, 4, 4, "#1b1230");
573 R(100, by + 26, 28, 4, "#1b1230");
574 for (let i = 0; i < 6; i++) {
575 const bx = 90 - ((u * 40 + i * 24) % 140);
576 R(bx, 30 + ((i * 13) % 36), 4, 2, "#ffd23f");
577 }
578 moth(24 + Math.sin(u * 3) * 8, 52 + Math.cos(u * 3) * 8, Math.floor(u * 8) % 2 === 0);
579 R(6, 6, 40, 4, "#1b1230");
580 R(7, 7, Math.max(2, 38 - u * 3), 2, "#ffd23f");
581 text("BOSS: LAMP MOTHER", 6, 20, "#fffdf0", 7);
582 } else {
583 const u = t - 24;
584 R(0, 0, W, H, "#1b1230");
585 for (let y = 0; y < H; y += 3) R(0, y, W, 1, "#241a42");
586 text("MOTH KINGDOM", 24, 40, "#ffd23f", 13);
587 text("NEON CELLAR", 50, 56, "#e0338f", 8);
588 if (Math.floor(u * 2) % 2 === 0) text("WISHLIST NOW", 40, 76, "#fffdf0", 8);
589 R(0, (u * 40) % H, W, 2, "rgba(255,255,255,.12)");
590 }
591}
592
593function Trailer({ visible }: { visible: boolean }) {
594 const cv = useRef<HTMLCanvasElement | null>(null);
595 const [playing, setPlaying] = useState(false);
596 const [t, setT] = useState(0);
597 const tRef = useRef(0);
598
599 const paint = useCallback((time: number) => {
600 const c = cv.current;
601 const ctx = c?.getContext("2d");
602 if (!c || !ctx) return;
603 ctx.imageSmoothingEnabled = false;
604 drawTrailer(ctx, time);
605 }, []);
606
607 useEffect(() => {
608 paint(tRef.current);
609 }, [paint, t]);
610
611 useEffect(() => {
612 if (!playing || !visible) return;
613 let raf = 0;
614 let last = performance.now();
615 const loop = (now: number) => {
616 const dt = Math.min(0.1, (now - last) / 1000);
617 last = now;
618 tRef.current = (tRef.current + dt) % TRAILER_LEN;
619 setT(tRef.current);
620 raf = requestAnimationFrame(loop);
621 };
622 raf = requestAnimationFrame(loop);
623 return () => cancelAnimationFrame(raf);
624 }, [playing, visible]);
625
626 const fmt = (s: number) => `0:${String(Math.floor(s)).padStart(2, "0")}`;
627 return (
628 <div className="ao-pad ao-trailer">
629 <div className="ao-screen">
630 <canvas ref={cv} width={160} height={90} aria-label="Moth Kingdom trailer, a procedural pixel animation" />
631 {!playing && (
632 <button className="ao-bigplay" onClick={() => setPlaying(true)} aria-label="Play trailer">
633 <span />
634 </button>
635 )}
636 </div>
637 <div className="ao-controls">
638 <button className="ao-btn" onClick={() => setPlaying((p) => !p)} aria-label={playing ? "Pause" : "Play"}>
639 {playing ? "II" : ">"}
640 </button>
641 <input
642 type="range"
643 className="ao-scrub"
644 min={0}
645 max={TRAILER_LEN}
646 step={0.1}
647 value={t}
648 aria-label="Seek"
649 onChange={(e) => {
650 const v = Number(e.target.value);
651 tRef.current = v;
652 setT(v);
653 }}
654 />
655 <span className="ao-time">{fmt(t)}/{fmt(TRAILER_LEN)}</span>
656 </div>
657 </div>
658 );
659}
660
661function Devlog({ open }: { open: (id: WinId) => void }) {
662 const [shown, setShown] = useState(DEVLOG.length);
663 const [extra, setExtra] = useState<{ c: string; t: string }[]>([]);
664 const [val, setVal] = useState("");
665 const box = useRef<HTMLDivElement | null>(null);
666
667 useEffect(() => {
668 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
669 setShown(0);
670 let n = 0;
671 const id = window.setInterval(() => {
672 n++;
673 setShown(n);
674 if (n >= DEVLOG.length) window.clearInterval(id);
675 }, 260);
676 return () => window.clearInterval(id);
677 }, []);
678 useEffect(() => {
679 const b = box.current;
680 if (b) b.scrollTop = b.scrollHeight;
681 }, [shown, extra]);
682
683 const run = (raw: string) => {
684 const cmd = raw.trim().toLowerCase();
685 const out: { c: string; t: string }[] = [{ c: "ok", t: "$ " + raw }];
686 if (cmd === "help") out.push({ c: "", t: "commands: help ls log games snake wishlist clear whoami sudo" });
687 else if (cmd === "ls") out.push({ c: "", t: "moth/ samurai/ lunch/ orbit/ NOTES_OCT.txt" });
688 else if (cmd === "log") out.push({ c: "dim", t: "5 commits today. 1 was a revert. 1 was a cat." });
689 else if (cmd === "games") { out.push({ c: "", t: "opening C:\\GAMES ..." }); open("games"); }
690 else if (cmd === "snake") { out.push({ c: "", t: "launching TAILTRAIN.EXE ..." }); open("snake"); }
691 else if (cmd === "wishlist") { out.push({ c: "", t: "opening WISHLIST.DAT ..." }); open("wishlist"); }
692 else if (cmd === "whoami") out.push({ c: "", t: "a visitor with excellent taste" });
693 else if (cmd === "sudo") out.push({ c: "hot", t: "nice try. you are not in the sudoers file. this incident will be reported to the moth." });
694 else if (cmd === "clear") { setExtra([]); setShown(0); return; }
695 else if (cmd) out.push({ c: "hot", t: `${cmd}: command not found. try 'help'` });
696 setExtra((e) => [...e, ...out].slice(-40));
697 };
698
699 return (
700 <div className="ao-term" ref={box} onClick={(e) => (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.focus()}>
701 {DEVLOG.slice(0, shown).map((l, i) => (
702 <div key={i} className={`ao-tl ${l.c}`}>{l.t}</div>
703 ))}
704 {extra.map((l, i) => (
705 <div key={"e" + i} className={`ao-tl ${l.c}`}>{l.t}</div>
706 ))}
707 <form
708 className="ao-prompt"
709 onSubmit={(e) => {
710 e.preventDefault();
711 run(val);
712 setVal("");
713 }}
714 >
715 <span>$</span>
716 <input value={val} onChange={(e) => setVal(e.target.value)} aria-label="Terminal input" autoComplete="off" spellCheck={false} placeholder="help" />
717 </form>
718 </div>
719 );
720}
721
722/* Snake */
723const COLS = 20;
724const ROWS = 15;
725const CELL = 16;
726type Pt = { x: number; y: number };
727function Snake({ active, onBest }: { active: boolean; onBest: (n: number) => void }) {
728 const cv = useRef<HTMLCanvasElement | null>(null);
729 const box = useRef<HTMLDivElement | null>(null);
730 const g = useRef({
731 body: [{ x: 8, y: 7 }, { x: 7, y: 7 }, { x: 6, y: 7 }] as Pt[],
732 dir: { x: 1, y: 0 },
733 next: { x: 1, y: 0 },
734 food: { x: 14, y: 7 } as Pt,
735 score: 0,
736 over: false,
737 });
738 const [state, setState] = useState<"idle" | "run" | "pause" | "over">("idle");
739 const [score, setScore] = useState(0);
740 const [best, setBest] = useState(0);
741
742 const draw = useCallback(() => {
743 const c = cv.current;
744 const ctx = c?.getContext("2d");
745 if (!c || !ctx) return;
746 const s = g.current;
747 ctx.fillStyle = "#0e4f55";
748 ctx.fillRect(0, 0, COLS * CELL, ROWS * CELL);
749 ctx.fillStyle = "#14666d";
750 for (let x = 0; x < COLS; x++) for (let y = 0; y < ROWS; y++) if ((x + y) % 2 === 0) ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
751 ctx.fillStyle = "#ffd23f";
752 ctx.fillRect(s.food.x * CELL + 3, s.food.y * CELL + 3, CELL - 6, CELL - 6);
753 ctx.fillStyle = "#1b1230";
754 ctx.fillRect(s.food.x * CELL + 7, s.food.y * CELL + 1, 2, 3);
755 s.body.forEach((p, i) => {
756 ctx.fillStyle = "#1b1230";
757 ctx.fillRect(p.x * CELL + 1, p.y * CELL + 1, CELL - 2, CELL - 2);
758 ctx.fillStyle = i === 0 ? "#f4ecd0" : i % 2 ? "#e0338f" : "#c22a7a";
759 ctx.fillRect(p.x * CELL + 2, p.y * CELL + 2, CELL - 4, CELL - 4);
760 if (i === 0) {
761 ctx.fillStyle = "#1b1230";
762 ctx.fillRect(p.x * CELL + 4, p.y * CELL + 4, 3, 3);
763 ctx.fillRect(p.x * CELL + 9, p.y * CELL + 4, 3, 3);
764 }
765 });
766 }, []);
767
768 const reset = useCallback(() => {
769 g.current = { body: [{ x: 8, y: 7 }, { x: 7, y: 7 }, { x: 6, y: 7 }], dir: { x: 1, y: 0 }, next: { x: 1, y: 0 }, food: { x: 14, y: 7 }, score: 0, over: false };
770 setScore(0);
771 }, []);
772
773 const steer = useCallback((dx: number, dy: number) => {
774 const s = g.current;
775 if (dx === -s.dir.x && dy === -s.dir.y) return;
776 s.next = { x: dx, y: dy };
777 setState((st) => (st === "idle" || st === "pause" ? "run" : st));
778 }, []);
779
780 useEffect(() => {
781 draw();
782 }, [draw, state]);
783
784 useEffect(() => {
785 if (state !== "run" || !active) return;
786 const id = window.setInterval(() => {
787 const s = g.current;
788 s.dir = s.next;
789 const h = { x: s.body[0].x + s.dir.x, y: s.body[0].y + s.dir.y };
790 const hitWall = h.x < 0 || h.y < 0 || h.x >= COLS || h.y >= ROWS;
791 const hitSelf = s.body.some((p, i) => i < s.body.length - 1 && p.x === h.x && p.y === h.y);
792 if (hitWall || hitSelf) {
793 s.over = true;
794 setState("over");
795 setBest((b) => {
796 const nb = Math.max(b, s.score);
797 onBest(nb);
798 return nb;
799 });
800 draw();
801 return;
802 }
803 s.body.unshift(h);
804 if (h.x === s.food.x && h.y === s.food.y) {
805 s.score += 1;
806 setScore(s.score);
807 let f: Pt;
808 do {
809 f = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) };
810 } while (s.body.some((p) => p.x === f.x && p.y === f.y));
811 s.food = f;
812 } else s.body.pop();
813 draw();
814 }, 115);
815 return () => window.clearInterval(id);
816 }, [state, active, draw, onBest]);
817
818 const key = (e: React.KeyboardEvent) => {
819 const m: Record<string, [number, number]> = {
820 ArrowUp: [0, -1], w: [0, -1], ArrowDown: [0, 1], s: [0, 1], ArrowLeft: [-1, 0], a: [-1, 0], ArrowRight: [1, 0], d: [1, 0],
821 };
822 const v = m[e.key];
823 if (v) {
824 e.preventDefault();
825 if (state === "over") {
826 reset();
827 setState("run");
828 }
829 steer(v[0], v[1]);
830 } else if (e.key === " ") {
831 e.preventDefault();
832 toggle();
833 }
834 };
835 const toggle = () => {
836 if (state === "run") setState("pause");
837 else {
838 if (state === "over") reset();
839 setState("run");
840 }
841 box.current?.focus();
842 };
843
844 return (
845 <div className="ao-snake" ref={box} tabIndex={0} onKeyDown={key} aria-label="Snake game. Arrow keys or WASD to steer, space to pause.">
846 <div className="ao-hud">
847 <span>SCORE {String(score).padStart(3, "0")}</span>
848 <span>BEST {String(best).padStart(3, "0")}</span>
849 </div>
850 <div className="ao-snake-screen">
851 <canvas ref={cv} width={COLS * CELL} height={ROWS * CELL} />
852 {state !== "run" && (
853 <div className="ao-snake-ov">
854 <b>{state === "over" ? "GAME OVER" : state === "pause" ? "PAUSED" : "TAIL TRAIN"}</b>
855 <small>arrows / WASD to steer</small>
856 <button className="ao-btn ao-btn-hot" onClick={toggle}>{state === "over" ? "Retry" : state === "pause" ? "Resume" : "Start"}</button>
857 </div>
858 )}
859 </div>
860 <div className="ao-dpad" aria-label="On-screen controls">
861 <button className="ao-btn" aria-label="Up" onClick={() => steer(0, -1)}>^</button>
862 <button className="ao-btn" aria-label="Left" onClick={() => steer(-1, 0)}>{"<"}</button>
863 <button className="ao-btn" aria-label="Down" onClick={() => steer(0, 1)}>v</button>
864 <button className="ao-btn" aria-label="Right" onClick={() => steer(1, 0)}>{">"}</button>
865 </div>
866 </div>
867 );
868}
869
870function Wishlist() {
871 const [name, setName] = useState("");
872 const [mail, setMail] = useState("");
873 const [plat, setPlat] = useState("PC");
874 const [genres, setGenres] = useState<string[]>(["Metroidvania"]);
875 const [news, setNews] = useState(true);
876 const [phase, setPhase] = useState<"form" | "saving" | "done">("form");
877 const [pct, setPct] = useState(0);
878 const [err, setErr] = useState("");
879
880 useEffect(() => {
881 if (phase !== "saving") return;
882 const id = window.setInterval(() => {
883 setPct((p) => {
884 if (p >= 100) {
885 window.clearInterval(id);
886 setPhase("done");
887 return 100;
888 }
889 return p + 10;
890 });
891 }, 110);
892 return () => window.clearInterval(id);
893 }, [phase]);
894
895 const toggleG = (g: string) => setGenres((a) => (a.includes(g) ? a.filter((x) => x !== g) : [...a, g]));
896 const submit = (e: React.FormEvent) => {
897 e.preventDefault();
898 if (!name.trim() || !/.+@.+\..+/.test(mail)) {
899 setErr("ERROR 13: name and a valid e-mail required.");
900 return;
901 }
902 setErr("");
903 setPct(0);
904 setPhase("saving");
905 };
906
907 if (phase !== "form")
908 return (
909 <div className="ao-pad ao-center">
910 <Px name="heart" size={56} />
911 {phase === "saving" ? (
912 <>
913 <p>Writing WISHLIST.DAT ...</p>
914 <div className="ao-bar" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
915 {Array.from({ length: 10 }, (_, i) => <i key={i} className={i < pct / 10 ? "on" : ""} />)}
916 </div>
917 </>
918 ) : (
919 <>
920 <h3 className="ao-h">SAVED!</h3>
921 <p>Thanks {name.split(" ")[0] || "player"}. We will ping you when Moth Kingdom opens its doors on {plat}.</p>
922 <button className="ao-btn" onClick={() => { setPhase("form"); setName(""); setMail(""); }}>New entry</button>
923 </>
924 )}
925 </div>
926 );
927
928 return (
929 <form className="ao-pad ao-form" onSubmit={submit} noValidate>
930 <label>Player name<input className="ao-inset" value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" /></label>
931 <label>E-mail<input className="ao-inset" type="email" value={mail} onChange={(e) => setMail(e.target.value)} autoComplete="email" /></label>
932 <fieldset>
933 <legend>Platform</legend>
934 {["PC", "Switch", "Deck"].map((p) => (
935 <label key={p} className="ao-chk"><input type="radio" name="ao-plat" checked={plat === p} onChange={() => setPlat(p)} /><i className="ao-rad" />{p}</label>
936 ))}
937 </fieldset>
938 <fieldset>
939 <legend>I like</legend>
940 {["Metroidvania", "Rhythm", "Cozy", "Roguelike"].map((p) => (
941 <label key={p} className="ao-chk"><input type="checkbox" checked={genres.includes(p)} onChange={() => toggleG(p)} /><i className="ao-box" />{p}</label>
942 ))}
943 </fieldset>
944 <label className="ao-chk"><input type="checkbox" checked={news} onChange={() => setNews(!news)} /><i className="ao-box" />Monthly devlog by e-mail</label>
945 {err && <p className="ao-err" role="alert">{err}</p>}
946 <button className="ao-btn ao-btn-hot" type="submit">Save to wishlist</button>
947 </form>
948 );
949}
950
951/* ------------------------------------------------------------------ */
952/* Below-the-desktop sections */
953/* ------------------------------------------------------------------ */
954function Pane({ title, icon, children, id, className = "" }: { title: string; icon: string; children: ReactNode; id?: string; className?: string }) {
955 const [ref, seen] = useInView<HTMLElement>();
956 return (
957 <section id={id} ref={ref} className={`ao-pane ao-bev ao-rv${seen ? " in" : ""} ${className}`}>
958 <div className="ao-title is-static"><Px name={icon} size={14} /><span className="ao-title-t">{title}</span></div>
959 <div className="ao-pane-body">{children}</div>
960 </section>
961 );
962}
963
964function Roadmap() {
965 const [ref, seen] = useInView<HTMLDivElement>();
966 return (
967 <div ref={ref} className="ao-road">
968 {ROADMAP.map((r, row) => {
969 const filled = Math.round(r.pct / 5);
970 return (
971 <div className="ao-road-r" key={r.n}>
972 <div className="ao-road-h"><b>{r.n}</b><span>{r.when}</span></div>
973 <div className="ao-bar ao-bar-l" role="img" aria-label={`${r.pct} percent`}>
974 {Array.from({ length: 20 }, (_, i) => (
975 <i key={i} className={seen && i < filled ? "on" : ""} style={{ transitionDelay: `${row * 160 + i * 45}ms` }} />
976 ))}
977 <em>{r.pct}%</em>
978 </div>
979 </div>
980 );
981 })}
982 </div>
983 );
984}
985
986function ScoreRow({ rank, n, g, s, go, you }: { rank: number; n: string; g: string; s: number; go: boolean; you?: boolean }) {
987 const v = useCount(s, go);
988 return (
989 <tr className={you ? "you" : ""}>
990 <td>{String(rank).padStart(2, "0")}</td>
991 <td>{n}</td>
992 <td>{g}</td>
993 <td className="num">{String(v).padStart(6, "0")}</td>
994 </tr>
995 );
996}
997
998function HiScores({ snakeBest }: { snakeBest: number }) {
999 const [ref, seen] = useInView<HTMLDivElement>();
1000 const rows = [...SCORES.map((r) => ({ ...r, you: false }))];
1001 if (snakeBest > 0) rows.push({ n: "YOU", g: "Tail Train", s: snakeBest * 100, you: true });
1002 rows.sort((a, b) => b.s - a.s);
1003 return (
1004 <div ref={ref}>
1005 <table className="ao-score">
1006 <thead><tr><th>#</th><th>NAME</th><th>GAME</th><th className="num">SCORE</th></tr></thead>
1007 <tbody>
1008 {rows.map((r, i) => <ScoreRow key={r.n + r.g} rank={i + 1} n={r.n} g={r.g} s={r.s} go={seen} you={r.you} />)}
1009 </tbody>
1010 </table>
1011 <p className="ao-note">{snakeBest > 0 ? "You are on the board. Beat it in the Tail Train window." : "Play Tail Train on the desktop to put your name here."}</p>
1012 </div>
1013 );
1014}
1015
1016function Alerts() {
1017 const items = [
1018 { t: "Warning", c: "#e0338f", m: "Too much fun detected. Continue anyway?", b: ["Yes", "Definitely"] },
1019 { t: "Information", c: "#ffd23f", m: "Dungeon Lunch is 40% off until Sunday. Your fridge is empty anyway.", b: ["Open store"] },
1020 { t: "Press", c: "#2a9d9d", m: QUOTES[2], b: ["OK"] },
1021 ];
1022 const [ref, seen] = useInView<HTMLDivElement>();
1023 return (
1024 <div className="ao-alerts" ref={ref}>
1025 {items.map((a, i) => (
1026 <div key={a.t} className={`ao-dlg ao-bev${seen ? " in" : ""}`} style={{ transitionDelay: `${i * 220}ms`, ["--r" as string]: `${[-1.5, 1, -0.5][i]}deg` }}>
1027 <div className="ao-title is-static"><span className="ao-title-t">{a.t}</span></div>
1028 <div className="ao-dlg-b">
1029 <Px name="alert" size={32} over={{ a: a.c }} />
1030 <p>{a.m}</p>
1031 </div>
1032 <div className="ao-dlg-f">{a.b.map((b) => <button key={b} className="ao-btn">{b}</button>)}</div>
1033 </div>
1034 ))}
1035 </div>
1036 );
1037}
1038
1039/* ------------------------------------------------------------------ */
1040/* Boot */
1041/* ------------------------------------------------------------------ */
1042const BOOT = [
1043 "NEON CELLAR BIOS v4.20 (c) 1996-2026",
1044 "CPU: Moth-486DX @ 66MHz ........ OK",
1045 "Memory test: 640K ............... OK",
1046 "Joysticks detected .............. 2",
1047 "Mounting C:\\GAMES .............. 4 titles",
1048 "Loading ARCADE.SYS ...",
1049];
1050
1051function Boot({ onDone }: { onDone: () => void }) {
1052 const [n, setN] = useState(0);
1053 const [pct, setPct] = useState(0);
1054 useEffect(() => {
1055 const id = window.setInterval(() => setN((v) => Math.min(BOOT.length, v + 1)), 340);
1056 return () => window.clearInterval(id);
1057 }, []);
1058 useEffect(() => {
1059 if (n < BOOT.length) return;
1060 const id = window.setInterval(() => setPct((p) => Math.min(100, p + 10)), 120);
1061 return () => window.clearInterval(id);
1062 }, [n]);
1063 useEffect(() => {
1064 if (pct >= 100) {
1065 const id = window.setTimeout(onDone, 350);
1066 return () => window.clearTimeout(id);
1067 }
1068 }, [pct, onDone]);
1069 useEffect(() => {
1070 const k = () => onDone();
1071 window.addEventListener("keydown", k);
1072 return () => window.removeEventListener("keydown", k);
1073 }, [onDone]);
1074 return (
1075 <div className="ao-boot" onClick={onDone} role="status" aria-label="Booting Arcade OS. Click to skip.">
1076 <div className="ao-boot-logo"><Px name="alien" size={64} /></div>
1077 <div className="ao-boot-t">
1078 {BOOT.slice(0, n).map((l) => <div key={l}>{l}</div>)}
1079 <span className="ao-caret" />
1080 </div>
1081 <div className="ao-bar ao-bar-boot" aria-hidden="true">
1082 {Array.from({ length: 20 }, (_, i) => <i key={i} className={i < pct / 5 ? "on" : ""} />)}
1083 </div>
1084 <small>click or press any key to skip</small>
1085 </div>
1086 );
1087}
1088
1089/* ------------------------------------------------------------------ */
1090/* Page */
1091/* ------------------------------------------------------------------ */
1092const initWins = (): Record<WinId, WinState> => {
1093 const o = {} as Record<WinId, WinState>;
1094 ORDER.forEach((id, i) => {
1095 o[id] = { x: WIDE_POS[id][0], y: WIDE_POS[id][1], z: 10 + i, open: true, min: false };
1096 });
1097 return o;
1098};
1099
1100export function Page() {
1101 const [booted, setBooted] = useState(false);
1102 const [wins, setWins] = useState<Record<WinId, WinState>>(initWins);
1103 const [focus, setFocus] = useState<WinId>("about");
1104 const [mobile, setMobile] = useState(false);
1105 const [crt, setCrt] = useState(true);
1106 const [glow, setGlow] = useState(true);
1107 const [menu, setMenu] = useState(false);
1108 const [clock, setClock] = useState("--:--");
1109 const [js, setJs] = useState(false);
1110 const [snakeBest, setSnakeBest] = useState(0);
1111 const zTop = useRef(30);
1112 const stage = useRef<HTMLDivElement | null>(null);
1113 const root = useRef<HTMLDivElement | null>(null);
1114 const [bootKey, setBootKey] = useState(0);
1115
1116 /* mount: reduced motion skips boot; layout by viewport */
1117 useEffect(() => {
1118 setJs(true);
1119 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) setBooted(true);
1120 const mq = window.matchMedia("(max-width: 760px)");
1121 const apply = () => setMobile(mq.matches);
1122 apply();
1123 mq.addEventListener("change", apply);
1124 return () => mq.removeEventListener("change", apply);
1125 }, []);
1126
1127 useEffect(() => {
1128 const w = stage.current?.getBoundingClientRect().width ?? 1440;
1129 setWins((prev) => {
1130 const next = { ...prev };
1131 if (mobile) {
1132 (["trailer", "devlog", "snake"] as WinId[]).forEach((id) => (next[id] = { ...next[id], min: true }));
1133 } else if (w < 1280) {
1134 ORDER.forEach((id, i) => (next[id] = { ...next[id], x: 120 + i * 34, y: 16 + i * 36, min: false }));
1135 } else {
1136 ORDER.forEach((id) => (next[id] = { ...next[id], x: WIDE_POS[id][0], y: WIDE_POS[id][1], min: false }));
1137 }
1138 return next;
1139 });
1140 }, [mobile]);
1141
1142 /* live clock */
1143 useEffect(() => {
1144 const tick = () => {
1145 const d = new Date();
1146 setClock(`${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`);
1147 };
1148 tick();
1149 const id = window.setInterval(tick, 1000);
1150 return () => window.clearInterval(id);
1151 }, []);
1152
1153 /* close Start menu on outside click */
1154 useEffect(() => {
1155 if (!menu) return;
1156 const f = (e: PointerEvent) => {
1157 if (!(e.target as HTMLElement).closest(".ao-start-wrap")) setMenu(false);
1158 };
1159 window.addEventListener("pointerdown", f);
1160 return () => window.removeEventListener("pointerdown", f);
1161 }, [menu]);
1162
1163 const bump = useCallback((id: WinId) => {
1164 zTop.current += 1;
1165 const z = zTop.current;
1166 setFocus(id);
1167 setWins((w) => (w[id].z === z ? w : { ...w, [id]: { ...w[id], z } }));
1168 }, []);
1169 const openWin = useCallback(
1170 (id: WinId) => {
1171 setWins((w) => ({ ...w, [id]: { ...w[id], open: true, min: false } }));
1172 bump(id);
1173 setMenu(false);
1174 },
1175 [bump],
1176 );
1177 const minWin = useCallback((id: WinId) => setWins((w) => ({ ...w, [id]: { ...w[id], min: true } })), []);
1178 const closeWin = useCallback((id: WinId) => setWins((w) => ({ ...w, [id]: { ...w[id], open: false } })), []);
1179 const moveWin = useCallback((id: WinId, x: number, y: number) => setWins((w) => ({ ...w, [id]: { ...w[id], x, y } })), []);
1180 const task = (id: WinId) => {
1181 const s = wins[id];
1182 if (s.open && !s.min && focus === id) minWin(id);
1183 else openWin(id);
1184 };
1185 const done = useCallback(() => setBooted(true), []);
1186 const reboot = () => {
1187 setWins(initWins());
1188 setBootKey((k) => k + 1);
1189 setBooted(false);
1190 root.current?.scrollIntoView({ block: "start" });
1191 window.scrollTo({ top: 0 });
1192 };
1193
1194 const vis = (id: WinId) => wins[id].open && !wins[id].min;
1195
1196 const winContent: Record<WinId, ReactNode> = {
1197 about: <About open={openWin} />,
1198 games: <Games open={openWin} />,
1199 trailer: <Trailer visible={vis("trailer") && booted} />,
1200 devlog: <Devlog open={openWin} />,
1201 snake: <Snake active={vis("snake") && booted} onBest={setSnakeBest} />,
1202 wishlist: <Wishlist />,
1203 };
1204
1205 return (
1206 <div ref={root} className={`ao${crt ? " crt" : ""}${glow ? " glow" : ""}${js ? " js" : ""}`}>
1207 <style>{CSS}</style>
1208
1209 {/* ---------- Desktop ---------- */}
1210 <div className="ao-stage" ref={stage}>
1211 <div className="ao-wall" aria-hidden="true">
1212 <div className="ao-cloud c1"><Px name="cloud" size={96} /></div>
1213 <div className="ao-cloud c2"><Px name="cloud" size={64} /></div>
1214 <div className="ao-cloud c3"><Px name="cloud" size={80} /></div>
1215 <div className="ao-wall-t">NEON<br />CELLAR</div>
1216 </div>
1217
1218 {booted ? (
1219 <>
1220 <nav className="ao-icons" aria-label="Desktop icons">
1221 {ORDER.map((id) => (
1222 <button key={id} className="ao-icon" onClick={() => openWin(id)}>
1223 <span className="ao-icon-art"><Px name={WIN_META[id].icon} size={36} /></span>
1224 <span className="ao-icon-l">{WIN_META[id].file}</span>
1225 </button>
1226 ))}
1227 </nav>
1228 <div className="ao-desk">
1229 {ORDER.map((id) => (
1230 <Win key={id} id={id} st={wins[id]} focused={focus === id} mobile={mobile} stageRef={stage} onFocus={bump} onMin={minWin} onClose={closeWin} onMove={moveWin}>
1231 {winContent[id]}
1232 </Win>
1233 ))}
1234 </div>
1235 </>
1236 ) : (
1237 <Boot key={bootKey} onDone={done} />
1238 )}
1239 </div>
1240
1241 {/* ---------- Taskbar ---------- */}
1242 {booted && (
1243 <div className="ao-task" role="toolbar" aria-label="Taskbar">
1244 <div className="ao-start-wrap">
1245 <button className="ao-btn ao-start" aria-expanded={menu} onClick={() => setMenu((m) => !m)}>
1246 <Px name="alien" size={14} /> START
1247 </button>
1248 {menu && (
1249 <div className="ao-menu-pop ao-bev" role="menu">
1250 {ORDER.map((id) => (
1251 <button key={id} role="menuitem" onClick={() => openWin(id)}>
1252 <Px name={WIN_META[id].icon} size={16} />{WIN_META[id].title}
1253 </button>
1254 ))}
1255 <hr />
1256 <button role="menuitem" onClick={() => { setMenu(false); reboot(); }}>Restart...</button>
1257 </div>
1258 )}
1259 </div>
1260 <div className="ao-tasks">
1261 {ORDER.filter((id) => wins[id].open).map((id) => (
1262 <button key={id} className={`ao-btn ao-taskbtn${focus === id && !wins[id].min ? " on" : ""}`} onClick={() => task(id)}>
1263 <Px name={WIN_META[id].icon} size={14} />
1264 <span>{WIN_META[id].file}</span>
1265 </button>
1266 ))}
1267 </div>
1268 <div className="ao-tray">
1269 <button className={`ao-btn ao-tog${crt ? " on" : ""}`} aria-pressed={crt} onClick={() => setCrt(!crt)} title="CRT scanlines">CRT</button>
1270 <button className={`ao-btn ao-tog${glow ? " on" : ""}`} aria-pressed={glow} onClick={() => setGlow(!glow)} title="Phosphor glow">GLOW</button>
1271 <time className="ao-clock ao-inset" aria-label="Current time">{clock}</time>
1272 </div>
1273 </div>
1274 )}
1275
1276 {/* ---------- Below the desktop ---------- */}
1277 <main className="ao-below">
1278 <div className="ao-ticker" aria-label="Press quotes">
1279 <div className="ao-ticker-in">
1280 {[...QUOTES, ...QUOTES].map((q, i) => <span key={i}>{q}</span>)}
1281 </div>
1282 </div>
1283
1284 <div className="ao-wrap">
1285 <Pane title="ROADMAP.EXE" icon="film" id="roadmap">
1286 <p className="ao-lead">What the cellar is cooking. Bars fill as we ship.</p>
1287 <Roadmap />
1288 </Pane>
1289
1290 <div className="ao-two">
1291 <Pane title="HISCORES.TXT" icon="doc">
1292 <HiScores snakeBest={snakeBest} />
1293 </Pane>
1294 <Pane title="CREW.DAT" icon="folder">
1295 <div className="ao-crew">
1296 {CREW.map((c) => (
1297 <div key={c.n} className="ao-crew-c">
1298 <img className="ao-face" src={c.img} alt={portraitAlt(c.n, `${c.r.toLowerCase()} on the Arcade OS crew`, c.img)} width={72} height={72} loading="lazy" decoding="async" />
1299 <b>{c.n}</b>
1300 <small>{c.r}</small>
1301 </div>
1302 ))}
1303 </div>
1304 <p className="ao-note">+7 more. We are hiring a QA wizard and a tools engineer.</p>
1305 </Pane>
1306 </div>
1307
1308 <Pane title="SYSTEM ALERTS" icon="alert">
1309 <Alerts />
1310 </Pane>
1311 </div>
1312
1313 <footer className="ao-off">
1314 <p>It is now safe to turn off your computer.</p>
1315 <small>(c) 2026 Neon Cellar Interactive B.V. - all sprites hand placed</small>
1316 <button className="ao-btn ao-btn-hot" onClick={reboot}>Reboot to desktop</button>
1317 </footer>
1318 </main>
1319 </div>
1320 );
1321}
1322
1323export default Page;
1324
1325/* ------------------------------------------------------------------ */
1326/* Styles */
1327/* ------------------------------------------------------------------ */
1328const CSS = `
1329.ao{--ink:#1b1230;--cream:#f4ecd0;--cream2:#e2d6a8;--teal:#2a9d9d;--dteal:#0e4f55;--mag:#e0338f;--yel:#ffd23f;--lt:#fffbe8;--sh:#b8aa7a;
1330 --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono","Courier New",monospace;
1331 --px:"Press Start 2P","Silkscreen",ui-monospace,Menlo,Consolas,monospace;
1332 position:relative;box-sizing:border-box;font-family:var(--mono);font-size:13px;line-height:1.45;color:var(--ink);background:var(--dteal);
1333 -webkit-font-smoothing:none;min-height:100vh;overflow-x:hidden}
1334.ao *,.ao *::before,.ao *::after{box-sizing:border-box}
1335.ao button,.ao input{font-family:inherit;font-size:inherit;color:inherit}
1336.ao p{margin:0 0 .7em}
1337.ao svg{display:block;image-rendering:pixelated}
1338
1339/* bevels */
1340.ao-bev{background:var(--cream);border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--lt),inset -2px -2px 0 var(--sh),4px 4px 0 var(--ink)}
1341.ao-inset{background:var(--lt);border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--sh)}
1342.ao-btn{background:var(--cream);border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--lt),inset -2px -2px 0 var(--sh);padding:5px 12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;min-height:30px;line-height:1}
1343.ao-btn:active,.ao-btn.on{box-shadow:inset 2px 2px 0 var(--sh),inset -2px -2px 0 var(--lt);transform:translate(1px,1px)}
1344.ao-btn:focus-visible,.ao-icon:focus-visible,.ao-file:focus-visible,.ao-tb:focus-visible{outline:2px dashed var(--ink);outline-offset:2px}
1345.ao-btn-hot{background:var(--mag);color:var(--lt);font-weight:700}
1346.ao-h{font-family:var(--px);font-size:16px;letter-spacing:.04em;margin:0 0 10px;line-height:1.3;font-weight:700}
1347.ao-pad{padding:14px}
1348.ao-row{display:flex;gap:8px;flex-wrap:wrap}
1349.ao-center{text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px;padding:28px 14px}
1350
1351/* stage / desktop */
1352.ao-stage{position:relative;height:100vh;min-height:640px;max-height:1000px;overflow:hidden;
1353 background:var(--teal);
1354 background-image:linear-gradient(45deg,rgba(14,79,85,.35) 25%,transparent 25%,transparent 75%,rgba(14,79,85,.35) 75%),linear-gradient(45deg,rgba(14,79,85,.35) 25%,transparent 25%,transparent 75%,rgba(14,79,85,.35) 75%);
1355 background-size:8px 8px;background-position:0 0,4px 4px}
1356.ao-wall{position:absolute;inset:0;pointer-events:none;overflow:hidden}
1357.ao-wall-t{position:absolute;right:28px;bottom:70px;font-family:var(--px);font-weight:700;font-size:clamp(34px,7vw,92px);line-height:1;letter-spacing:.05em;text-align:right;color:rgba(14,79,85,.55);text-shadow:4px 4px 0 rgba(255,210,63,.18)}
1358.ao-cloud{position:absolute;opacity:.55;animation:ao-drift 90s steps(60) infinite}
1359.ao-cloud.c1{top:6%;left:-10%}.ao-cloud.c2{top:42%;left:-20%;animation-duration:140s;animation-delay:-50s}.ao-cloud.c3{top:72%;left:-14%;animation-duration:110s;animation-delay:-20s}
1360@keyframes ao-drift{to{transform:translateX(125vw)}}
1361.ao-icons{position:absolute;left:12px;top:14px;display:flex;flex-direction:column;gap:10px;z-index:3}
1362.ao-icon{width:84px;background:none;border:2px solid transparent;padding:6px 2px;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--lt);text-shadow:1px 1px 0 var(--ink)}
1363.ao-icon:hover,.ao-icon:focus-visible{border:2px dotted var(--lt);background:rgba(27,18,48,.25)}
1364.ao-icon-art{filter:drop-shadow(2px 2px 0 rgba(27,18,48,.5))}
1365.ao-icon-l{font-size:12px;font-weight:700}
1366.ao-desk{position:absolute;inset:0 0 40px 0}
1367
1368/* windows */
1369.ao-win{position:absolute;left:0;top:0;max-width:calc(100% - 12px);animation:ao-pop .22s steps(4) both}
1370@keyframes ao-pop{from{opacity:0;clip-path:inset(40% 40% 40% 40%)}to{opacity:1;clip-path:inset(0)}}
1371.ao-win.is-drag{opacity:.9}
1372.ao-title{display:flex;align-items:center;gap:6px;height:28px;padding:0 4px 0 6px;background:#8d8a7a;color:var(--lt);font-family:var(--px);font-size:9px;font-weight:700;letter-spacing:.04em;cursor:grab;touch-action:none;user-select:none;border-bottom:2px solid var(--ink)}
1373.is-focus>.ao-title{background:repeating-linear-gradient(90deg,var(--mag) 0 4px,#c92a7e 4px 6px)}
1374.ao-win.is-drag .ao-title{cursor:grabbing}
1375.ao-title.is-static{cursor:default;background:repeating-linear-gradient(90deg,var(--mag) 0 4px,#c92a7e 4px 6px)}
1376.ao-title-t{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 0 var(--ink)}
1377.ao-tb{width:20px;height:20px;padding:0;background:var(--cream);border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--lt),inset -2px -2px 0 var(--sh);display:grid;place-items:center;cursor:pointer}
1378.ao-tb:active{box-shadow:inset 2px 2px 0 var(--sh)}
1379.ao-tb-min{display:block;width:8px;height:2px;background:var(--ink);margin-top:5px}
1380.ao-tb-x{display:block;width:10px;height:10px;background:linear-gradient(45deg,transparent 40%,var(--ink) 40% 60%,transparent 60%),linear-gradient(-45deg,transparent 40%,var(--ink) 40% 60%,transparent 60%)}
1381.ao-win-body{background:var(--cream);max-height:calc(100vh - 100px);overflow:auto}
1382
1383/* about */
1384.ao-sys{width:100%;border-collapse:collapse;margin:10px 0 14px;background:var(--lt);border:2px solid var(--ink)}
1385.ao-sys td{padding:4px 8px;border-bottom:1px dotted var(--sh)}.ao-sys td:first-child{font-weight:700;width:34%;background:var(--cream2)}
1386
1387/* games */
1388.ao-menu{display:flex;gap:14px;padding:3px 10px;border-bottom:2px solid var(--ink);background:var(--cream2)}
1389.ao-games-body{display:grid;grid-template-columns:1fr 1.15fr;gap:8px;padding:8px}
1390.ao-files{display:grid;grid-template-columns:1fr 1fr;gap:4px;align-content:start}
1391.ao-file{background:none;border:2px solid transparent;padding:6px 2px;display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;text-align:center;font-size:11px;line-height:1.2}
1392.ao-file.on{background:var(--dteal);color:var(--lt);border:2px dotted var(--yel)}
1393.ao-prev{padding:10px;display:flex;flex-direction:column;gap:4px;min-height:230px}
1394.ao-prev-art{display:grid;place-items:center;height:78px;border:2px solid var(--ink);margin-bottom:4px}
1395.ao-prev-n{font-family:var(--px);font-size:10px;line-height:1.4}
1396.ao-prev p{font-size:12px;margin:4px 0 6px;flex:1}
1397.ao-status{display:flex;justify-content:space-between;padding:3px 10px;border-top:2px solid var(--ink);background:var(--cream2);font-size:11px}
1398
1399/* trailer */
1400.ao-screen{position:relative;border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--sh);background:#000;line-height:0}
1401.ao-screen canvas{width:100%;height:auto;aspect-ratio:16/9;image-rendering:pixelated;display:block}
1402.ao-bigplay{position:absolute;inset:0;background:rgba(27,18,48,.35);border:0;cursor:pointer;display:grid;place-items:center}
1403.ao-bigplay span{width:54px;height:54px;background:var(--yel);border:3px solid var(--ink);box-shadow:4px 4px 0 var(--ink);display:grid;place-items:center;position:relative}
1404.ao-bigplay span::after{content:"";border-left:16px solid var(--ink);border-top:10px solid transparent;border-bottom:10px solid transparent;margin-left:4px}
1405.ao-bigplay:hover span{background:var(--mag)}
1406.ao-controls{display:flex;gap:8px;align-items:center;margin-top:10px}
1407.ao-scrub{flex:1;min-width:0;accent-color:var(--mag);height:20px}
1408.ao-time{font-size:11px;white-space:nowrap}
1409
1410/* terminal */
1411.ao-term{background:#0b2b30;color:#9ff3d0;height:250px;overflow:auto;padding:10px 12px;font-size:12.5px;cursor:text}
1412.ao-tl{white-space:pre-wrap;word-break:break-word}.ao-tl.ok{color:var(--yel)}.ao-tl.dim{color:#6fb5a0}.ao-tl.hot{color:#ff7fbf}
1413.ao-prompt{display:flex;gap:8px;margin-top:2px;color:var(--yel)}
1414.ao-prompt input{flex:1;min-width:0;background:none;border:0;outline:0;color:#9ff3d0;caret-color:var(--yel);padding:0}
1415.ao-prompt input::placeholder{color:#3d7a73}
1416
1417/* snake */
1418.ao-snake{padding:10px;outline:0;display:flex;flex-direction:column;gap:8px}
1419.ao-snake:focus-visible .ao-snake-screen{outline:2px dashed var(--mag);outline-offset:2px}
1420.ao-hud{display:flex;justify-content:space-between;font-family:var(--px);font-size:9px;font-weight:700}
1421.ao-snake-screen{position:relative;line-height:0;border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--sh)}
1422.ao-snake-screen canvas{width:100%;height:auto;aspect-ratio:4/3;image-rendering:pixelated;display:block}
1423.ao-snake-ov{position:absolute;inset:0;background:rgba(14,79,85,.82);display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;line-height:1.3;color:var(--lt)}
1424.ao-snake-ov b{font-family:var(--px);font-size:14px;color:var(--yel);text-shadow:2px 2px 0 var(--ink)}
1425.ao-dpad{display:none;grid-template-columns:repeat(4,1fr);gap:6px}
1426.ao-dpad .ao-btn{justify-content:center;min-height:38px}
1427
1428/* form */
1429.ao-form{display:flex;flex-direction:column;gap:10px}
1430.ao-form label{display:flex;flex-direction:column;gap:3px;font-weight:700}
1431.ao-form input[type=text],.ao-form input[type=email],.ao-form input:not([type]){padding:6px 8px;outline:0}
1432.ao-form input.ao-inset:focus{outline:2px dashed var(--mag);outline-offset:1px}
1433.ao-form fieldset{border:2px solid var(--ink);margin:0;padding:6px 8px 8px;display:flex;flex-wrap:wrap;gap:4px 14px}
1434.ao-form legend{padding:0 4px;font-weight:700}
1435.ao-form label.ao-chk,.ao-chk{flex-direction:row;align-items:center;gap:6px;font-weight:400;cursor:pointer;position:relative;display:flex}
1436.ao-chk input{position:absolute;opacity:0;width:16px;height:16px;margin:0}
1437.ao-box,.ao-rad{width:16px;height:16px;border:2px solid var(--ink);background:var(--lt);box-shadow:inset 2px 2px 0 var(--sh);flex:none;position:relative}
1438.ao-rad{border-radius:0}
1439.ao-chk input:checked+.ao-box::after{content:"";position:absolute;inset:2px;background:var(--mag)}
1440.ao-chk input:checked+.ao-rad::after{content:"";position:absolute;inset:3px;background:var(--ink)}
1441.ao-chk input:focus-visible+i{outline:2px dashed var(--mag);outline-offset:2px}
1442.ao-err{color:var(--mag);font-weight:700;margin:0}
1443.ao-bar{display:flex;gap:2px;padding:3px;background:var(--lt);border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--sh);width:100%;position:relative}
1444.ao-bar i{flex:1;height:16px;background:transparent}
1445.ao-bar i.on{background:var(--mag)}
1446.ao-bar-l i{transition:background 0s steps(1)}
1447.ao-bar-l i.on{animation:ao-fill .01s both}
1448.ao-bar em{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-style:normal;font-weight:700;font-size:11px;background:var(--lt);padding:0 4px}
1449
1450/* taskbar */
1451.ao-task{position:fixed;left:0;right:0;bottom:0;height:42px;z-index:900;background:var(--cream);border-top:2px solid var(--ink);box-shadow:inset 0 2px 0 var(--lt);display:flex;align-items:center;gap:6px;padding:4px 6px}
1452.ao-start{font-family:var(--px);font-size:10px;font-weight:700;min-height:32px}
1453.ao-start-wrap{position:relative}
1454.ao-menu-pop{position:absolute;bottom:42px;left:0;min-width:200px;padding:4px;display:flex;flex-direction:column;z-index:2}
1455.ao-menu-pop button{display:flex;align-items:center;gap:8px;background:none;border:0;padding:7px 8px;text-align:left;cursor:pointer;font-weight:700}
1456.ao-menu-pop button:hover,.ao-menu-pop button:focus-visible{background:var(--dteal);color:var(--lt);outline:0}
1457.ao-menu-pop hr{border:0;border-top:2px solid var(--sh);margin:3px 0}
1458.ao-tasks{flex:1;min-width:0;display:flex;gap:4px;overflow:hidden}
1459.ao-taskbtn{min-width:0;flex:0 1 128px;justify-content:flex-start;font-size:12px}
1460.ao-taskbtn span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1461.ao-taskbtn.on{background:var(--cream2);font-weight:700}
1462.ao-tray{display:flex;gap:4px;align-items:center}
1463.ao-tog{font-size:11px;font-weight:700;min-height:28px;padding:4px 8px}
1464.ao-tog.on{background:var(--yel)}
1465.ao-clock{padding:5px 8px;font-weight:700;font-size:12px;min-width:84px;text-align:center;font-variant-numeric:tabular-nums}
1466
1467/* boot */
1468.ao-boot{position:absolute;inset:0;background:#05080c;color:#9ff3d0;z-index:50;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:16px;padding:0 max(24px,8vw);cursor:pointer;font-size:14px}
1469.ao-boot-t{min-height:150px;line-height:1.7}
1470.ao-boot small{color:#4a8a80}
1471.ao-boot-logo{animation:ao-blink 1s steps(2) infinite}
1472.ao-caret{display:inline-block;width:9px;height:15px;background:#9ff3d0;vertical-align:-2px;animation:ao-blink .8s steps(2) infinite}
1473@keyframes ao-blink{50%{opacity:0}}
1474.ao-bar-boot{max-width:420px;background:#05080c;border-color:#9ff3d0;box-shadow:none}
1475.ao-bar-boot i.on{background:#9ff3d0}
1476
1477/* below */
1478.ao-below{position:relative;background:var(--dteal);background-image:radial-gradient(rgba(42,157,157,.35) 1px,transparent 1.5px);background-size:8px 8px;padding-bottom:42px}
1479.ao-ticker{overflow:hidden;background:var(--ink);color:var(--yel);border-bottom:2px solid var(--yel);white-space:nowrap;padding:8px 0;font-weight:700}
1480.ao-ticker-in{display:inline-flex;gap:60px;padding-left:60px;animation:ao-scroll 50s linear infinite}
1481@keyframes ao-scroll{to{transform:translateX(-50%)}}
1482.ao-wrap{max-width:1120px;margin:0 auto;padding:56px 24px 40px;display:flex;flex-direction:column;gap:40px}
1483.ao-two{display:grid;grid-template-columns:1.2fr 1fr;gap:40px}
1484.ao-pane-body{padding:16px}
1485.ao-lead{margin-bottom:14px}
1486.js .ao-rv{opacity:0;transform:translateY(16px)}
1487.js .ao-rv.in{opacity:1;transform:none;transition:opacity .4s steps(4),transform .4s steps(4)}
1488.ao-road{display:flex;flex-direction:column;gap:14px}
1489.ao-road-h{display:flex;justify-content:space-between;margin-bottom:4px;gap:10px}
1490.ao-score{width:100%;border-collapse:collapse;background:var(--ink);color:var(--yel);font-weight:700}
1491.ao-score th,.ao-score td{padding:6px 10px;text-align:left}
1492.ao-score th{color:var(--lt);background:var(--dteal);font-family:var(--px);font-size:9px}
1493.ao-score .num{text-align:right;font-variant-numeric:tabular-nums}
1494.ao-score tr.you{background:var(--mag);color:var(--lt);animation:ao-blink 1s steps(2) 4}
1495.ao .ao-note{margin:12px 0 0;font-size:12px}
1496.ao-crew{display:grid;grid-template-columns:1fr 1fr;gap:14px}
1497.ao-crew-c{display:flex;flex-direction:column;align-items:center;text-align:center;gap:2px;padding:10px 4px;background:var(--lt);border:2px solid var(--ink);box-shadow:inset 2px 2px 0 var(--sh)}
1498.ao-face{width:72px;height:72px;object-fit:cover;object-position:center top;border:2px solid var(--ink);image-rendering:auto}
1499.ao-eye{animation:ao-eye 4.2s steps(1) infinite}
1500@keyframes ao-eye{0%,92%{opacity:1}94%,98%{opacity:0}100%{opacity:1}}
1501.ao-alerts{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}
1502.ao-dlg{opacity:0;transform:translateY(20px) rotate(var(--r,0deg));display:flex;flex-direction:column}
1503.ao-dlg.in{opacity:1;transform:rotate(var(--r,0deg));transition:opacity .3s steps(3),transform .3s steps(3)}
1504.ao-dlg-b{display:flex;gap:12px;padding:16px 14px;align-items:flex-start;flex:1}
1505.ao-dlg-b p{margin:0}
1506.ao-dlg-f{display:flex;gap:8px;justify-content:center;padding:0 12px 14px;flex-wrap:wrap}
1507.ao-off{background:#000;color:#ff9d2e;text-align:center;padding:90px 24px;display:flex;flex-direction:column;align-items:center;gap:14px}
1508.ao-off p{font-family:var(--px);font-size:clamp(12px,2.2vw,20px);line-height:1.6;max-width:30ch;margin:0}
1509.ao-off small{color:#8a5a20}
1510
1511/* CRT + phosphor */
1512.ao.crt::after{content:"";position:fixed;inset:0;z-index:9999;pointer-events:none;
1513 background:repeating-linear-gradient(to bottom,rgba(0,0,0,.16) 0 1px,transparent 1px 3px),radial-gradient(ellipse at center,transparent 62%,rgba(0,0,0,.38) 100%);
1514 animation:ao-flick 6s steps(1) infinite}
1515@keyframes ao-flick{0%,96%{opacity:1}97%{opacity:.82}98%{opacity:1}99%{opacity:.9}}
1516.ao.glow .ao-win-body,.ao.glow .ao-pane-body,.ao.glow .ao-ticker,.ao.glow .ao-icon{text-shadow:0 0 5px rgba(224,51,143,.38)}
1517.ao.glow .ao-term,.ao.glow .ao-boot{text-shadow:0 0 6px rgba(159,243,208,.7)}
1518.ao.glow .ao-off{text-shadow:0 0 10px rgba(255,157,46,.8)}
1519
1520/* responsive */
1521@media (max-width:1000px){.ao-two{grid-template-columns:1fr}.ao-alerts{grid-template-columns:1fr}}
1522@media (max-width:760px){
1523 .ao-stage{height:auto;min-height:100vh;max-height:none;overflow:visible;padding-bottom:50px}
1524 .ao-icons{position:relative;left:0;top:0;flex-direction:row;flex-wrap:wrap;justify-content:center;gap:2px;padding:12px 8px 4px}
1525 .ao-icon{width:58px;padding:4px 0}.ao-icon-l{font-size:10px}
1526 .ao-desk{position:relative;inset:auto;padding:8px 10px 20px;display:flex;flex-direction:column;gap:14px}
1527 .ao-win{position:relative;width:100%;max-width:none;transform:none}
1528 .ao-win-body{max-height:none}
1529 .ao-wall-t{display:none}
1530 .ao-games-body{grid-template-columns:1fr}
1531 .ao-dpad{display:grid}
1532 .ao-boot{position:fixed}
1533 .ao-tray .ao-tog{padding:4px 6px}.ao-clock{min-width:0;padding:5px 5px;font-size:11px}
1534 .ao-tasks{display:none}
1535 .ao-wrap{padding:36px 12px 28px}
1536 .ao-crew{grid-template-columns:1fr 1fr}
1537 .ao-start{font-size:9px}
1538 .ao-cloud{animation-duration:200s}
1539}
1540@media (prefers-reduced-motion:reduce){
1541 .ao-cloud,.ao-ticker-in,.ao-boot-logo,.ao-caret,.ao-eye,.ao-win,.ao.crt::after,.ao-score tr.you{animation:none!important}
1542 .ao-ticker{white-space:normal}.ao-ticker-in{flex-wrap:wrap;gap:12px 40px}
1543 .js .ao-rv,.ao-dlg{opacity:1!important;transform:none!important;transition:none!important}
1544 .ao-dlg{transform:none}
1545}
1546`;