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