Arcade OS. Gaming landing page.

A game-studio landing page presented as a retro operating system. The hero is a BIOS boot sequence that drops into a teal desktop with pixel-art icons and drifting clouds; the sections are real windows you can drag, focus, minimise to the taskbar, close and reopen: About, a Games folder with a preview pane, a canvas-drawn trailer player with a scrubber, a Devlog terminal that answers commands, a playable Snake, and a Wishlist form that saves with a progress bar. The taskbar has a Start menu, window buttons, a live clock and CRT scanline / phosphor-glow toggles. Below the desktop: a press ticker, a roadmap with stepped progress bars, a hi-score board your Snake score lands on, blinking pixel crew portraits, tilted system-alert dialogs and a shutdown footer. Teal, cream, magenta and yellow; no images or web fonts.

  • BIOS boot sequence
  • Draggable focusable windows
  • Minimise to taskbar
  • CRT scanlines + glow toggle
  • Playable canvas Snake
  • Procedural canvas trailer
  • Stepped progress bars
  • Count-up hi-scores
Full screen

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

The whole page. One file, nothing left out.

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

  1. 01Save it as arcade-os.tsx in any React 18+ project.
  2. 02Render <Page /> on a route of its own — it brings its own nav and footer.
  3. 03Swap the copy and tokens. Keep the DESIGN.md next to it so your AI agent follows the system.
arcade-os.tsx
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`;

The design brief. For you and your AI agent.

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

Arcade OS — design brief

Indie game-studio landing page rendered as a 1996-style desktop OS. Aesthetic: Retro OS Pixel. Single file Page.tsx (react only), scoped CSS prefix ao-.

Tokens (CSS custom properties on .ao)

TokenValueUse
--ink#1b1230borders, hard shadows, text
--cream / --cream2#f4ecd0 / #e2d6a8window faces, bars
--lt / --sh#fffbe8 / #b8aa7abevel highlight / shade
--teal / --dteal#2a9d9d / #0e4f55desktop wallpaper, page background
--mag#e0338ffocused title bars, primary buttons, snake
--yel#ffd23faccents, terminal prompt, toggles
--monoui-monospace, SF Mono, Menlo, Consolasbody, 13px / 1.45
--px"Press Start 2P", "Silkscreen", then monotitles, HUD, 9-16px, bold

Extra: terminal #0b2b30 with #9ff3d0 text; boot screen #05080c; shutdown footer black with #ff9d2e.

Shape and spacing

  • No radii. Bevel = 2px ink border + inset 2px light top-left and 2px shade bottom-right, plus a 4px hard offset shadow (.ao-bev). Sunken fields (.ao-inset) invert the inset.
  • Title bars 28px, striped magenta when focused, grey #8d8a7a otherwise. Buttons 30px min height. Window padding 14px. Section wrap 1120px max, 40px gaps.
  • Pixel-art icons are drawn from 12x12 string grids (ICONS) rendered as merged SVG rects with shape-rendering: crispEdges.

Windows (hero)

Boot overlay (6 BIOS lines, 20-block progress bar, click/any key to skip) then a stage of 100vh with dithered teal wallpaper, drifting pixel clouds, a big dim "NEON CELLAR" wordmark and a left icon column. Windows: About, Games (folder + preview pane), Trailer (canvas, 4 scenes, scrubber), Devlog (terminal with help ls log games snake wishlist clear whoami sudo), Tail Train (Snake, arrows/WASD/space plus on-screen d-pad), Wishlist (form with a fake save progress). Drag by title bar (pointer capture, clamped to stage), click raises (z counter), double-click title or _ minimises, x closes; taskbar buttons and Start menu reopen. Position uses transform: translate3d. Below 760px windows stack in normal flow, drag is off, trailer/devlog/snake start minimised.

Taskbar

Fixed bottom, 42px: Start menu, one button per open window (focused one pressed), CRT and GLOW toggles (aria-pressed), live HH:MM:SS clock.

Sections below the desktop

  1. Press ticker (linear marquee, duplicated list).
  2. ROADMAP.EXE — 20-block segmented bars that fill block by block (staggered transition-delay) when in view.
  3. HISCORES.TXT — count-up scores quantised to 24 steps; your best Snake score x100 is inserted as a blinking "YOU" row.
  4. CREW.DAT — 8x8 procedural pixel portraits with stepped blink.
  5. SYSTEM ALERTS — three tilted dialog boxes that pop in with steps(3).
  6. Shutdown footer with "Reboot to desktop" (replays the boot).

Motion

All easing is steps() or canvas-driven to feel 8-bit: boot lines and caret, window pop (clip-path steps), cloud drift, ticker, flicker on the CRT overlay (.crt::after: scanlines + vignette), phosphor glow via text-shadow, canvas trailer (rAF, paused when minimised), Snake (setInterval 115ms, paused when hidden), reveal on scroll via IntersectionObserver. prefers-reduced-motion: boot skipped, ambient animation off, reveals visible.

Do / don't

Keep the palette limited to the tokens above; no gradients except hard-stop stripes and the CRT overlay; no blur, no radii; the only images are the crew portraits.

Assets

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

Questions about Arcade OS

An indie game studio that boots like a 1996 desktop — every section is a draggable window. It is a free gaming landing page for React with a matching DESIGN.md brief.

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

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

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