diff --git a/landing/design/.thumbnail b/landing/design/.thumbnail
new file mode 100644
index 0000000..91b3354
Binary files /dev/null and b/landing/design/.thumbnail differ
diff --git a/landing/design/Ulicraft Landing.html b/landing/design/Ulicraft Landing.html
new file mode 100644
index 0000000..a9233e0
--- /dev/null
+++ b/landing/design/Ulicraft Landing.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Ulicraft — Java Survival SMP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/landing/design/app.jsx b/landing/design/app.jsx
new file mode 100644
index 0000000..997cb53
--- /dev/null
+++ b/landing/design/app.jsx
@@ -0,0 +1,434 @@
+// Ulicraft landing — pixel components, live status, copy-IP, scroll reveals, tweaks
+const { useState, useEffect, useRef, useCallback } = React;
+
+/* ---------- pixel art helpers ---------- */
+// 8x8 creeper face
+const CREEPER = [
+ "00000000",
+ "01100110",
+ "01100110",
+ "00011000",
+ "00111100",
+ "00111100",
+ "00100100",
+ "00000000",
+];
+function Creeper() {
+ const cells = CREEPER.join("").split("");
+ return (
+
+ {cells.map((c, i) => )}
+
+ );
+}
+
+// 7x7 feature glyphs
+const GLYPHS = {
+ shield: ["0111110","1111111","1111111","1111111","0111110","0011100","0001000"],
+ diamond:["0001000","0011100","0111110","1111111","0111110","0011100","0001000"],
+ orb: ["0011100","0111110","1111111","1111111","1111111","0111110","0011100"],
+ heart: ["0110110","1111111","1111111","1111111","0111110","0011100","0001000"],
+};
+function PixelIcon({ glyph, gold }) {
+ const cells = GLYPHS[glyph].join("").split("");
+ return (
+
+ {cells.map((c, i) => (
+
+ ))}
+
+ );
+}
+
+/* ---------- copy to clipboard ---------- */
+function copyText(text) {
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text);
+ return;
+ }
+ } catch (e) {}
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.position = "fixed";
+ ta.style.opacity = "0";
+ document.body.appendChild(ta);
+ ta.select();
+ try { document.execCommand("copy"); } catch (e) {}
+ document.body.removeChild(ta);
+}
+
+function IpChip({ ip }) {
+ const [copied, setCopied] = useState(false);
+ const onCopy = () => {
+ copyText(ip);
+ setCopied(true);
+ clearTimeout(onCopy._t);
+ onCopy._t = setTimeout(() => setCopied(false), 1600);
+ };
+ return (
+
+ ▸ {ip}
+
+ {copied ? "Copied!" : "Copy IP"}
+
+
+ );
+}
+
+/* ---------- live player count ---------- */
+function useLivePlayers(base = 37, max = 100) {
+ const [n, setN] = useState(base);
+ useEffect(() => {
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ const id = setInterval(() => {
+ setN((p) => {
+ const step = Math.floor(Math.random() * 5) - 2;
+ return Math.max(base - 6, Math.min(max - 4, p + step));
+ });
+ }, 2600);
+ return () => clearInterval(id);
+ }, [base, max]);
+ return n;
+}
+
+/* ---------- floating pixel dust ---------- */
+function Dust({ on }) {
+ if (!on) return null;
+ const bits = Array.from({ length: 16 }, (_, i) => i);
+ return (
+
+ {bits.map((i) => {
+ const size = 3 + (i % 3) * 2;
+ const st = {
+ position: "absolute",
+ left: ((i * 61) % 100) + "%",
+ bottom: "-20px",
+ width: size + "px",
+ height: size + "px",
+ background: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
+ opacity: 0.18 + (i % 3) * 0.06,
+ imageRendering: "pixelated",
+ animation: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
+ };
+ return ;
+ })}
+
+ );
+}
+
+/* ---------- server-list status panel ---------- */
+function ServerListPanel({ players, max, version }) {
+ return (
+
+
+
+
+ Ulicraft
+ Java {version}
+
+
+ ⛏ Season 3 · Survival SMP · whitelist open
+
+
+
+
+
+ {players} /{max}
+
+
+
+ );
+}
+
+/* ---------- scroll reveal (fail-safe: visible at rest, .anim added when in view) ---------- */
+function useReveal() {
+ useEffect(() => {
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ let els = [...document.querySelectorAll(".reveal")];
+ let stop = false;
+ const tick = () => {
+ if (stop) return;
+ const vh = window.innerHeight;
+ for (let i = els.length - 1; i >= 0; i--) {
+ const r = els[i].getBoundingClientRect();
+ if (r.top < vh * 0.9 && r.bottom > 0) {
+ els[i].classList.add("anim");
+ els.splice(i, 1);
+ }
+ }
+ if (els.length) requestAnimationFrame(tick);
+ else stop = true;
+ };
+ requestAnimationFrame(tick);
+ return () => { stop = true; };
+ }, []);
+}
+
+/* ============================================================ */
+const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+ "heroLayout": "centered",
+ "mood": "grass",
+ "headFont": "pixelify",
+ "serverIp": "play.ulicraft.net",
+ "tagline": "Hardcore survival, built to last.",
+ "particles": true
+}/*EDITMODE-END*/;
+
+const HEAD_FONTS = {
+ pixelify: "'Pixelify Sans', system-ui, sans-serif",
+ "8bit": "'Press Start 2P', monospace",
+ silkscreen: "'Silkscreen', system-ui, sans-serif",
+};
+
+const FEATURES = [
+ { glyph: "shield", gold: false, h: "Grief-proof claims",
+ p: "Lock down your base with golden-shovel land claims. Your builds stay yours — even while you're offline." },
+ { glyph: "diamond", gold: true, h: "Zero pay-to-win",
+ p: "The store sells cosmetics and nothing else. Every diamond is earned in-game, never bought." },
+ { glyph: "orb", gold: false, h: "A living world",
+ p: "Seasonal map resets, custom bosses, and community events keep the overworld worth logging into." },
+ { glyph: "heart", gold: true, h: "Real community",
+ p: "A whitelisted, moderated server with an active Discord. Toxicity meets the ban hammer, fast." },
+];
+
+function App() {
+ const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+ const players = useLivePlayers(37, 100);
+ const peak = 84;
+ const version = "1.21.4";
+
+ // guarantee pixel head fonts are downloaded before first paint settles
+ useEffect(() => {
+ if (!document.fonts || !document.fonts.load) return;
+ ["600 32px 'Pixelify Sans'", "700 32px 'Pixelify Sans'",
+ "400 32px 'Press Start 2P'", "400 32px 'Silkscreen'", "700 32px 'Silkscreen'"]
+ .forEach((f) => document.fonts.load(f).catch(() => {}));
+ }, []);
+
+ // apply mood / hero / font to
+ useEffect(() => {
+ const r = document.documentElement;
+ r.setAttribute("data-mood", t.mood);
+ r.setAttribute("data-hero", t.heroLayout);
+ r.setAttribute("data-head", t.headFont);
+ r.style.setProperty("--font-head", HEAD_FONTS[t.headFont] || HEAD_FONTS.pixelify);
+ }, [t.mood, t.heroLayout, t.headFont]);
+
+ useReveal();
+
+ return (
+ <>
+ {/* NAV */}
+
+
+ {/* HERO */}
+
+
+
+
+
+
+
+
+
Java Edition · Survival SMP · Season 3
+
{t.tagline}
+
+ A whitelisted Java SMP with land claims, seasonal events, and zero pay-to-win.
+ Grab the IP, fire up your launcher, and stake your claim.
+
+
+
+
+
+ {players} playing now
+
+ Java {version}
+
+ No mods required
+
+
+
+
+
+
+
+
+ {/* STATUS */}
+
+
+
+
Server Status
+
Live, and waiting for you.
+
This is exactly what you'll see in your multiplayer list.
+
+
+
+
+
+ {[
+ { k: <>{players}
/100 >, l: "Players Online" },
+ { k: <>{peak}>, l: "Peak Today" },
+ { k: <>99.9
% >, l: "Uptime" },
+ { k: <>20.0
tps >, l: "Performance" },
+ ].map((s, i) => (
+
+ ))}
+
+
+
+
+ {/* FEATURES */}
+
+
+
+
What makes it Ulicraft
+
Built for players who stay.
+
No reset roulette, no whales buying god gear. Just a server that respects your time.
+
+
+ {FEATURES.map((f, i) => (
+
+ ))}
+
+
+
+
+ {/* HOW TO JOIN */}
+
+
+
+
How to Join · 3 Steps
+
From zero to spawning in.
+
Ulicraft runs on Minecraft: Java Edition. Here's the whole path, start to finish.
+
+
+
+ {/* STEP 1 */}
+
+
1
+
+
Account
+
Get Minecraft: Java Edition
+
+ Create a free Microsoft account, then buy and download Minecraft: Java & Bedrock
+ Edition from minecraft.net. Already own Java Edition? Skip straight to step 2.
+
+
+
+
+
+ {/* STEP 2 */}
+
+
2
+
+
Launcher
+
Download & set up the launcher
+
+ Install the official Minecraft Launcher, sign in with your Microsoft account, and select
+ the Latest Release ({version}) profile.
+
+
+ Download the launcher for Windows, macOS, or Linux.
+ Sign in → pick Latest Release → press Play once to finish installing.
+ Optional: allocate 4–6 GB RAM under Installations → More Options for smoother play.
+
+
+
+
+
+ {/* STEP 3 */}
+
+
3
+
+
Connect
+
Join the Ulicraft server
+
+ In Minecraft, open Multiplayer → Add Server . Name it Ulicraft,
+ paste the address below, hit Done , then double-click the server to join.
+
+
+
+
+
Whitelisted server — apply in our Discord first to get added.
+
+
+
+
+
+
+
+ {/* FOOTER */}
+
+
+ {/* TWEAKS */}
+
+
+ setTweak("heroLayout", v)} />
+
+ setTweak("mood", v)} />
+
+ setTweak("headFont", v)} />
+
+ setTweak("tagline", v)} />
+ setTweak("serverIp", v)} />
+ setTweak("particles", v)} />
+
+ >
+ );
+}
+
+ReactDOM.createRoot(document.getElementById("root")).render( );
diff --git a/landing/design/assets/ulicraft-logo.png b/landing/design/assets/ulicraft-logo.png
new file mode 100644
index 0000000..86dbead
Binary files /dev/null and b/landing/design/assets/ulicraft-logo.png differ
diff --git a/landing/design/screenshots/01-full.png b/landing/design/screenshots/01-full.png
new file mode 100644
index 0000000..e34b83a
Binary files /dev/null and b/landing/design/screenshots/01-full.png differ
diff --git a/landing/design/screenshots/01-s.png b/landing/design/screenshots/01-s.png
new file mode 100644
index 0000000..5433a55
Binary files /dev/null and b/landing/design/screenshots/01-s.png differ
diff --git a/landing/design/screenshots/01-sec.png b/landing/design/screenshots/01-sec.png
new file mode 100644
index 0000000..d438dcd
Binary files /dev/null and b/landing/design/screenshots/01-sec.png differ
diff --git a/landing/design/screenshots/01-sec2.png b/landing/design/screenshots/01-sec2.png
new file mode 100644
index 0000000..d438dcd
Binary files /dev/null and b/landing/design/screenshots/01-sec2.png differ
diff --git a/landing/design/screenshots/01-v.png b/landing/design/screenshots/01-v.png
new file mode 100644
index 0000000..8762469
Binary files /dev/null and b/landing/design/screenshots/01-v.png differ
diff --git a/landing/design/screenshots/02-full.png b/landing/design/screenshots/02-full.png
new file mode 100644
index 0000000..497cfdc
Binary files /dev/null and b/landing/design/screenshots/02-full.png differ
diff --git a/landing/design/screenshots/02-s.png b/landing/design/screenshots/02-s.png
new file mode 100644
index 0000000..0959771
Binary files /dev/null and b/landing/design/screenshots/02-s.png differ
diff --git a/landing/design/screenshots/02-sec.png b/landing/design/screenshots/02-sec.png
new file mode 100644
index 0000000..59ef32a
Binary files /dev/null and b/landing/design/screenshots/02-sec.png differ
diff --git a/landing/design/screenshots/02-sec2.png b/landing/design/screenshots/02-sec2.png
new file mode 100644
index 0000000..59ef32a
Binary files /dev/null and b/landing/design/screenshots/02-sec2.png differ
diff --git a/landing/design/screenshots/02-v.png b/landing/design/screenshots/02-v.png
new file mode 100644
index 0000000..4c05352
Binary files /dev/null and b/landing/design/screenshots/02-v.png differ
diff --git a/landing/design/screenshots/03-full.png b/landing/design/screenshots/03-full.png
new file mode 100644
index 0000000..2c6a269
Binary files /dev/null and b/landing/design/screenshots/03-full.png differ
diff --git a/landing/design/screenshots/03-s.png b/landing/design/screenshots/03-s.png
new file mode 100644
index 0000000..49f6157
Binary files /dev/null and b/landing/design/screenshots/03-s.png differ
diff --git a/landing/design/screenshots/03-sec.png b/landing/design/screenshots/03-sec.png
new file mode 100644
index 0000000..c5b0292
Binary files /dev/null and b/landing/design/screenshots/03-sec.png differ
diff --git a/landing/design/screenshots/03-sec2.png b/landing/design/screenshots/03-sec2.png
new file mode 100644
index 0000000..c5b0292
Binary files /dev/null and b/landing/design/screenshots/03-sec2.png differ
diff --git a/landing/design/screenshots/03-v.png b/landing/design/screenshots/03-v.png
new file mode 100644
index 0000000..7c36544
Binary files /dev/null and b/landing/design/screenshots/03-v.png differ
diff --git a/landing/design/screenshots/04-full.png b/landing/design/screenshots/04-full.png
new file mode 100644
index 0000000..2c6a269
Binary files /dev/null and b/landing/design/screenshots/04-full.png differ
diff --git a/landing/design/screenshots/04-s.png b/landing/design/screenshots/04-s.png
new file mode 100644
index 0000000..e2c9d0f
Binary files /dev/null and b/landing/design/screenshots/04-s.png differ
diff --git a/landing/design/screenshots/04-sec.png b/landing/design/screenshots/04-sec.png
new file mode 100644
index 0000000..0959771
Binary files /dev/null and b/landing/design/screenshots/04-sec.png differ
diff --git a/landing/design/screenshots/04-sec2.png b/landing/design/screenshots/04-sec2.png
new file mode 100644
index 0000000..0959771
Binary files /dev/null and b/landing/design/screenshots/04-sec2.png differ
diff --git a/landing/design/screenshots/04-v.png b/landing/design/screenshots/04-v.png
new file mode 100644
index 0000000..90e63ff
Binary files /dev/null and b/landing/design/screenshots/04-v.png differ
diff --git a/landing/design/screenshots/05-s.png b/landing/design/screenshots/05-s.png
new file mode 100644
index 0000000..0959771
Binary files /dev/null and b/landing/design/screenshots/05-s.png differ
diff --git a/landing/design/screenshots/06-s.png b/landing/design/screenshots/06-s.png
new file mode 100644
index 0000000..b215903
Binary files /dev/null and b/landing/design/screenshots/06-s.png differ
diff --git a/landing/design/screenshots/hero-hq.png b/landing/design/screenshots/hero-hq.png
new file mode 100644
index 0000000..0466240
Binary files /dev/null and b/landing/design/screenshots/hero-hq.png differ
diff --git a/landing/design/styles.css b/landing/design/styles.css
new file mode 100644
index 0000000..6ce0a7b
--- /dev/null
+++ b/landing/design/styles.css
@@ -0,0 +1,579 @@
+/* ============================================================
+ ULICRAFT — "Carved from stone"
+ Design system: dark overworld, MC-GUI bevels, pixel type
+ ============================================================ */
+
+/* ---- Mood palettes (data-mood on ) ---- */
+:root,
+:root[data-mood="grass"] {
+ --bg: oklch(0.165 0.014 150);
+ --bg-2: oklch(0.205 0.016 150);
+ --surface: oklch(0.235 0.017 150);
+ --surface-2: oklch(0.285 0.018 150);
+ --slot: oklch(0.185 0.014 150);
+ --bevel-hi: oklch(0.40 0.018 150);
+ --bevel-lo: oklch(0.115 0.012 150);
+ --line: oklch(0.33 0.016 150);
+
+ --accent: oklch(0.72 0.16 145); /* grass */
+ --accent-hi: oklch(0.80 0.15 145);
+ --accent-lo: oklch(0.55 0.15 145);
+ --accent-ink: oklch(0.17 0.05 150); /* text on accent */
+
+ --gold: oklch(0.82 0.135 85);
+ --glow: oklch(0.72 0.16 145 / 0.30);
+
+ --text: oklch(0.95 0.008 110);
+ --muted: oklch(0.72 0.012 145);
+ --dim: oklch(0.55 0.012 145);
+}
+
+:root[data-mood="nether"] {
+ --bg: oklch(0.165 0.018 35);
+ --bg-2: oklch(0.205 0.022 32);
+ --surface: oklch(0.235 0.026 32);
+ --surface-2: oklch(0.285 0.030 32);
+ --slot: oklch(0.185 0.020 32);
+ --bevel-hi: oklch(0.42 0.040 35);
+ --bevel-lo: oklch(0.115 0.016 32);
+ --line: oklch(0.34 0.030 32);
+
+ --accent: oklch(0.64 0.19 32); /* nether red */
+ --accent-hi: oklch(0.72 0.18 38);
+ --accent-lo: oklch(0.50 0.17 30);
+ --accent-ink: oklch(0.16 0.04 32);
+
+ --gold: oklch(0.83 0.14 75);
+ --glow: oklch(0.64 0.19 32 / 0.34);
+
+ --text: oklch(0.95 0.010 60);
+ --muted: oklch(0.74 0.020 45);
+ --dim: oklch(0.56 0.020 40);
+}
+
+:root[data-mood="end"] {
+ --bg: oklch(0.155 0.018 305);
+ --bg-2: oklch(0.195 0.022 305);
+ --surface: oklch(0.225 0.026 305);
+ --surface-2: oklch(0.275 0.030 305);
+ --slot: oklch(0.175 0.020 305);
+ --bevel-hi: oklch(0.42 0.040 305);
+ --bevel-lo: oklch(0.110 0.016 305);
+ --line: oklch(0.34 0.030 305);
+
+ --accent: oklch(0.74 0.135 175); /* end teal */
+ --accent-hi: oklch(0.82 0.13 175);
+ --accent-lo: oklch(0.58 0.13 178);
+ --accent-ink: oklch(0.16 0.04 200);
+
+ --gold: oklch(0.84 0.13 95);
+ --glow: oklch(0.70 0.16 300 / 0.34);
+
+ --text: oklch(0.96 0.010 300);
+ --muted: oklch(0.76 0.020 300);
+ --dim: oklch(0.58 0.020 300);
+}
+
+/* ---- Fonts (switchable head face via --font-head) ---- */
+:root {
+ --font-head: 'Pixelify Sans', system-ui, sans-serif;
+ --font-body: 'Space Grotesk', system-ui, sans-serif;
+ --font-pixel: 'Press Start 2P', monospace; /* tiny eyebrow labels */
+ --font-mono: 'VT323', monospace; /* IP / terminal */
+ --maxw: 1160px;
+}
+
+* { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-body);
+ font-size: 17px;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ overflow-x: hidden;
+}
+
+/* faint pixel-grid stone texture over everything */
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background-image:
+ repeating-linear-gradient(0deg, oklch(1 0 0 / 0.018) 0 1px, transparent 1px 4px),
+ repeating-linear-gradient(90deg, oklch(0 0 0 / 0.06) 0 1px, transparent 1px 4px);
+ background-size: 4px 4px, 4px 4px;
+ mix-blend-mode: overlay;
+ opacity: 0.5;
+}
+
+img { display: block; max-width: 100%; }
+
+::selection { background: var(--accent); color: var(--accent-ink); }
+
+/* ---- Typography helpers ---- */
+.eyebrow {
+ font-family: var(--font-pixel);
+ font-size: clamp(9px, 1.1vw, 11px);
+ letter-spacing: 0.12em;
+ color: var(--accent);
+ text-transform: uppercase;
+ line-height: 1.8;
+}
+h1, h2, h3 {
+ font-family: var(--font-head);
+ font-weight: 600;
+ line-height: 1.02;
+ letter-spacing: 0.01em;
+ margin: 0;
+}
+.section-title {
+ font-size: clamp(34px, 5vw, 58px);
+}
+.lead { color: var(--muted); }
+.mono { font-family: var(--font-mono); }
+
+/* ---- Layout ---- */
+.wrap { width: min(var(--maxw), calc(100% - 48px)); margin-inline: auto; }
+section { position: relative; z-index: 1; }
+.pad { padding-block: clamp(64px, 9vw, 130px); }
+.sec-head { max-width: 640px; margin-bottom: 48px; }
+.sec-head .eyebrow { display: block; margin-bottom: 14px; }
+.sec-head p { margin: 16px 0 0; font-size: 18px; }
+
+/* ============================================================
+ MINECRAFT-STYLE BUTTON
+ ============================================================ */
+.mc-btn {
+ --b: var(--surface-2);
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-family: var(--font-head);
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--text);
+ background: var(--b);
+ border: 0;
+ padding: 15px 24px 17px;
+ cursor: pointer;
+ text-decoration: none;
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-hi),
+ inset -2px -2px 0 var(--bevel-lo),
+ 0 4px 0 oklch(0 0 0 / 0.5),
+ 0 8px 18px oklch(0 0 0 / 0.4);
+ transition: transform .08s ease, filter .12s ease;
+ image-rendering: pixelated;
+ text-shadow: 0 2px 0 oklch(0 0 0 / 0.35);
+ white-space: nowrap;
+}
+.mc-btn:hover { filter: brightness(1.12); }
+.mc-btn:active {
+ transform: translateY(4px);
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-hi),
+ inset -2px -2px 0 var(--bevel-lo),
+ 0 0 0 oklch(0 0 0 / 0.5),
+ 0 2px 8px oklch(0 0 0 / 0.4);
+}
+.mc-btn.primary {
+ --b: var(--accent);
+ --bevel-hi: var(--accent-hi);
+ --bevel-lo: var(--accent-lo);
+ color: var(--accent-ink);
+ text-shadow: 0 2px 0 oklch(1 0 0 / 0.22);
+}
+.mc-btn.gold {
+ --b: var(--gold);
+ --bevel-hi: oklch(0.90 0.10 90);
+ --bevel-lo: oklch(0.66 0.13 75);
+ color: oklch(0.20 0.05 80);
+ text-shadow: 0 2px 0 oklch(1 0 0 / 0.25);
+}
+.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
+
+/* ---- Copy-IP chip ---- */
+.ip-chip {
+ display: inline-flex;
+ align-items: stretch;
+ background: var(--slot);
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-lo),
+ inset -2px -2px 0 var(--bevel-hi);
+ overflow: hidden;
+}
+.ip-chip .ip-val {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 18px;
+ font-family: var(--font-mono);
+ font-size: 28px;
+ line-height: 1;
+ color: var(--gold);
+ letter-spacing: 0.02em;
+}
+.ip-chip .ip-val .pin { color: var(--dim); font-size: 22px; }
+.ip-chip button {
+ border: 0;
+ cursor: pointer;
+ padding: 14px 18px;
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: 15px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
+ transition: filter .12s ease;
+ white-space: nowrap;
+}
+.ip-chip button:hover { filter: brightness(1.1); }
+.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
+
+/* ============================================================
+ NAV
+ ============================================================ */
+.nav {
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ background: oklch(0.165 0.014 150 / 0.72);
+ -webkit-backdrop-filter: blur(14px) saturate(140%);
+ backdrop-filter: blur(14px) saturate(140%);
+ border-bottom: 1px solid var(--line);
+}
+.nav-in {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ height: 68px;
+}
+.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
+.brand .name {
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: 22px;
+ letter-spacing: 0.02em;
+}
+.nav-links { display: flex; gap: 6px; margin-left: 12px; }
+.nav-links a {
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 15px;
+ font-weight: 500;
+ padding: 8px 12px;
+ transition: color .12s ease, background .12s ease;
+}
+.nav-links a:hover { color: var(--text); background: var(--surface); }
+.nav-spacer { flex: 1; }
+
+/* ---- Creeper pixel mark ---- */
+.creeper {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ grid-template-rows: repeat(8, 1fr);
+ width: 34px;
+ height: 34px;
+ background: var(--accent);
+ box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
+ image-rendering: pixelated;
+}
+.creeper i { background: transparent; }
+.creeper i.f { background: oklch(0.16 0.03 150); }
+
+/* ============================================================
+ HERO
+ ============================================================ */
+.hero {
+ position: relative;
+ padding-top: clamp(48px, 7vw, 90px);
+ padding-bottom: clamp(56px, 8vw, 110px);
+ overflow: hidden;
+}
+.hero::before { /* spotlight glow behind logo */
+ content: "";
+ position: absolute;
+ top: -10%;
+ left: 50%;
+ width: 1100px;
+ height: 760px;
+ transform: translateX(-50%);
+ background: radial-gradient(closest-side, var(--glow), transparent 72%);
+ pointer-events: none;
+ filter: blur(8px);
+}
+.hero-grid { position: relative; display: grid; gap: clamp(36px, 5vw, 64px); align-items: center; }
+
+/* layout: centered (default) */
+:root[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
+:root[data-hero="centered"] .hero-cta { justify-content: center; }
+:root[data-hero="centered"] .hero-meta { justify-content: center; }
+:root[data-hero="centered"] .hero-copy { max-width: 720px; }
+:root[data-hero="centered"] .hero-status { display: none; }
+
+/* layout: split (copy left, live panel right) */
+:root[data-hero="split"] .hero-grid { grid-template-columns: 1.1fr 0.9fr; }
+:root[data-hero="split"] .hero-logo { max-width: 560px; }
+:root[data-hero="split"] .hero-logo img { margin-inline: 0; }
+
+/* layout: spotlight (giant logo, minimal text, centered) */
+:root[data-hero="spotlight"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
+:root[data-hero="spotlight"] .hero-cta { justify-content: center; }
+:root[data-hero="spotlight"] .hero-meta { justify-content: center; }
+:root[data-hero="spotlight"] .hero-logo { max-width: 900px; }
+:root[data-hero="spotlight"] .hero-tagline { font-size: clamp(20px, 2.4vw, 28px); }
+:root[data-hero="spotlight"] .hero-status { display: none; }
+:root[data-hero="spotlight"] .hero-features-note { display: none; }
+
+.hero-logo { width: 100%; max-width: 760px; }
+.hero-logo img {
+ width: 100%;
+ image-rendering: pixelated;
+ filter: drop-shadow(0 8px 0 oklch(0 0 0 / 0.45)) drop-shadow(0 18px 30px oklch(0 0 0 / 0.55));
+ margin-inline: auto;
+}
+.hero-copy { display: flex; flex-direction: column; gap: 22px; }
+.hero-tagline {
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: clamp(24px, 3vw, 38px);
+ line-height: 1.08;
+ letter-spacing: 0.01em;
+ text-wrap: balance;
+}
+.hero-tagline .hl { color: var(--accent); }
+.hero-sub { color: var(--muted); font-size: 18px; max-width: 52ch; margin: 0; }
+.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
+.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; color: var(--dim); font-size: 14.5px; }
+.hero-meta .dot { width: 6px; height: 6px; background: var(--dim); }
+.hero-ip-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
+
+/* status pill */
+.live-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ padding: 7px 14px 7px 11px;
+ background: var(--slot);
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+ font-size: 14px;
+ color: var(--muted);
+ font-weight: 500;
+}
+.live-dot {
+ width: 9px; height: 9px; background: var(--accent);
+ box-shadow: 0 0 0 3px var(--glow);
+ animation: pulse 2.2s ease-in-out infinite;
+}
+@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
+@media (prefers-reduced-motion: reduce){ .live-dot{ animation:none } }
+.live-pill b { color: var(--text); font-variant-numeric: tabular-nums; }
+
+/* ============================================================
+ SERVER-LIST STATUS PANEL
+ ============================================================ */
+.serverlist {
+ display: flex;
+ gap: 18px;
+ align-items: center;
+ background: var(--slot);
+ padding: 16px;
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-hi),
+ inset -2px -2px 0 var(--bevel-lo),
+ 0 8px 24px oklch(0 0 0 / 0.4);
+}
+.serverlist .icon {
+ width: 72px; height: 72px; flex-shrink: 0;
+ display: grid; place-items: center;
+ background: var(--bg-2);
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+}
+.serverlist .icon .creeper { width: 48px; height: 48px; }
+.serverlist .meta { flex: 1; min-width: 0; }
+.serverlist .row1 { display: flex; align-items: baseline; gap: 12px; flex-wrap: nowrap; }
+.serverlist .title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
+.serverlist .ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
+.serverlist .motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
+.serverlist .motd .g { color: var(--gold); }
+.serverlist .motd .a { color: var(--accent); }
+.serverlist .stat { text-align: right; flex-shrink: 0; }
+.serverlist .players { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); display:flex; align-items:center; gap:8px; justify-content:flex-end; }
+.serverlist .players b { color: var(--text); }
+
+/* signal bars */
+.bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
+.bars i { width: 4px; background: var(--accent); image-rendering: pixelated; }
+.bars i:nth-child(1){height:25%}
+.bars i:nth-child(2){height:45%}
+.bars i:nth-child(3){height:65%}
+.bars i:nth-child(4){height:85%}
+.bars i:nth-child(5){height:100%}
+
+/* stat tiles */
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin-top: 28px;
+}
+.tile {
+ background: var(--surface);
+ padding: 22px;
+ box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
+}
+.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
+.tile .k .u { color: var(--accent); font-size: 22px; }
+.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
+
+/* ============================================================
+ FEATURES
+ ============================================================ */
+.feat-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 18px;
+}
+.feat {
+ display: flex;
+ gap: 18px;
+ background: var(--surface);
+ padding: 26px;
+ box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
+ transition: transform .14s ease, filter .14s ease;
+}
+.feat:hover { transform: translateY(-3px); filter: brightness(1.06); }
+.feat h3 { font-size: 21px; margin-bottom: 8px; }
+.feat p { margin: 0; color: var(--muted); font-size: 16px; }
+
+/* pixel icon */
+.picon {
+ width: 52px; height: 52px; flex-shrink: 0;
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ grid-template-rows: repeat(7, 1fr);
+ background: var(--slot);
+ padding: 4px;
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+ image-rendering: pixelated;
+}
+.picon i { background: transparent; }
+.picon i.on { background: var(--accent); }
+.picon i.go { background: var(--gold); }
+
+/* ============================================================
+ HOW TO JOIN — steps
+ ============================================================ */
+.steps { display: flex; flex-direction: column; gap: 20px; }
+.step {
+ display: grid;
+ grid-template-columns: 92px 1fr;
+ gap: 28px;
+ background: var(--surface);
+ padding: 30px;
+ box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
+}
+.step .num {
+ width: 92px; height: 92px;
+ display: grid; place-items: center;
+ background: var(--slot);
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: 46px;
+ color: var(--accent);
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+}
+.step .body { padding-top: 2px; }
+.step .kicker { font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; }
+.step h3 { font-size: 26px; margin: 10px 0 12px; }
+.step p { margin: 0 0 16px; color: var(--muted); font-size: 16.5px; max-width: 64ch; }
+.step .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
+.step ul { margin: 0 0 16px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
+.step ul li { display: flex; gap: 12px; color: var(--muted); font-size: 16px; }
+.step ul li::before { content: ""; width: 10px; height: 10px; margin-top: 8px; flex-shrink: 0; background: var(--accent); }
+.step .hint { color: var(--dim); font-size: 14px; }
+.step kbd {
+ font-family: var(--font-mono); font-size: 18px; line-height: 1;
+ background: var(--bg-2); color: var(--text);
+ padding: 4px 8px; box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
+}
+
+/* ============================================================
+ FOOTER
+ ============================================================ */
+.footer { border-top: 1px solid var(--line); background: var(--bg-2); }
+.footer-in { display: flex; flex-wrap: wrap; gap: 32px; align-items: center; justify-content: space-between; padding-block: 40px; }
+.footer .brand .name { font-size: 20px; }
+.footer-links { display: flex; gap: 8px; flex-wrap: wrap; }
+.footer .disc { color: var(--dim); font-size: 13px; max-width: 46ch; }
+
+/* ---- floating dust ---- */
+.dust { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
+@keyframes rise {
+ 0% { transform: translateY(0) translateX(0); opacity: 0; }
+ 10% { opacity: 1; }
+ 90% { opacity: 1; }
+ 100% { transform: translateY(-110vh) translateX(24px); opacity: 0; }
+}
+@media (prefers-reduced-motion: reduce) { .dust { display: none; } }
+.hero-grid { z-index: 1; }
+
+/* ---- head-font: Press Start 2P is wide/tall — scale down ---- */
+:root[data-head="8bit"] .section-title { font-size: clamp(20px, 3vw, 38px); line-height: 1.25; }
+:root[data-head="8bit"] .hero-tagline { font-size: clamp(15px, 2vw, 26px); line-height: 1.4; }
+:root[data-head="8bit"] .brand .name { font-size: 15px; }
+:root[data-head="8bit"] .mc-btn { font-size: 12px; }
+:root[data-head="8bit"] .mc-btn.sm { font-size: 11px; }
+:root[data-head="8bit"] .step h3 { font-size: 17px; line-height: 1.35; }
+:root[data-head="8bit"] .step .num { font-size: 30px; }
+:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
+:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
+:root[data-head="8bit"] .tile .k { font-size: 26px; }
+:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
+:root[data-head="8bit"] .live-pill { font-size: 12px; }
+
+/* ---- head-font: Silkscreen is small-cap-ish — nudge ---- */
+:root[data-head="silkscreen"] .section-title { letter-spacing: 0; }
+
+/* ============================================================
+ SCROLL REVEAL (fail-safe: visible at rest; animates only when JS adds .anim)
+ ============================================================ */
+.reveal { will-change: opacity, transform; }
+@keyframes reveal-in {
+ from { opacity: 0; transform: translateY(22px); }
+ to { opacity: 1; transform: none; }
+}
+@media (prefers-reduced-motion: no-preference) {
+ .reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
+}
+
+/* ============================================================
+ RESPONSIVE
+ ============================================================ */
+@media (max-width: 880px) {
+ :root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
+ :root[data-hero="split"] .hero-status { display: block; }
+ .stat-grid { grid-template-columns: repeat(2, 1fr); }
+ .feat-grid { grid-template-columns: 1fr; }
+ .nav-links { display: none; }
+ .serverlist { flex-wrap: wrap; }
+ .serverlist .stat { text-align: left; }
+}
+@media (max-width: 560px) {
+ .step { grid-template-columns: 1fr; gap: 18px; }
+ .step .num { width: 64px; height: 64px; font-size: 32px; }
+ .ip-chip .ip-val { font-size: 22px; }
+ .stat-grid { grid-template-columns: 1fr 1fr; }
+}
diff --git a/landing/design/tweaks-panel.jsx b/landing/design/tweaks-panel.jsx
new file mode 100644
index 0000000..bec00c3
--- /dev/null
+++ b/landing/design/tweaks-panel.jsx
@@ -0,0 +1,541 @@
+// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
+
+/* BEGIN USAGE */
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
+// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw , segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "palette": ["#D97757", "#29261b", "#f6f4ef"],
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('palette', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
+// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
+// options are many or long. For color tweaks always curate 3-4 options rather than
+// a free picker; an option can also be a whole 2–5 color palette (the stored value
+// is the array). The Tweak* controls are a floor, not a ceiling — build custom
+// controls inside the panel if a tweak calls for UI they don't cover.
+/* END USAGE */
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8, ");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+
+ .twk-chips{display:flex;gap:6px}
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
+ .twk-chip:hover{transform:translateY(-1px);
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
+ 0 2px 6px rgba(0,0,0,.15)}
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i:first-child{box-shadow:none}
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
+ // can react — the parent message only reaches the host, not peers.
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+ e.stopPropagation()}
+ onClick={dismiss}>✕
+
+
+ {children}
+
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value} }
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(!value)}>
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ // Segments wrap mid-word once per-segment width runs out. The track is
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
+ // back to a dropdown rather than wrap.
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
+ if (!fitsAsSegments) {
+ // emits strings — map back to the original option value so the
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
+ const resolve = (s) => {
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
+ };
+ return onChange(resolve(s))} />;
+ }
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+ onChange(e.target.value)}>
+ {options.map((o) => {
+ const v = typeof o === 'object' ? o.value : o;
+ const l = typeof o === 'object' ? o.label : o;
+ return {l} ;
+ })}
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit} }
+
+ );
+}
+
+// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
+// read on both #111 and #fafafa without per-option configuration. Hex input
+// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
+function __twkIsLight(hex) {
+ const h = String(hex).replace('#', '');
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
+ const n = parseInt(x.slice(0, 6), 16);
+ if (Number.isNaN(n)) return true;
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ return r * 299 + g * 587 + b * 114 > 148000;
+}
+
+const __TwkCheck = ({ light }) => (
+
+
+
+);
+
+// TweakColor — curated color/palette picker. Each option is either a single
+// hex string or an array of 1-5 hex strings; the card adapts — a lone color
+// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
+// rest stacked in a sharp column on the right. onChange emits the
+// option in the shape it was passed (string stays string, array stays array).
+// Without options it falls back to the native color input for back-compat.
+function TweakColor({ label, value, options, onChange }) {
+ if (!options || !options.length) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+ }
+ // Native emits lowercase hex per the HTML spec, so
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
+ // which returns the primitive undefined (no .toLowerCase).
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
+ const cur = key(value);
+ return (
+
+
+ {options.map((o, i) => {
+ const colors = Array.isArray(o) ? o : [o];
+ const [hero, ...rest] = colors;
+ const sup = rest.slice(0, 4);
+ const on = key(o) === cur;
+ return (
+ onChange(o)}>
+ {sup.length > 0 && (
+
+ {sup.map((c, j) => )}
+
+ )}
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
+
+ );
+ })}
+
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+ {label}
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/landing/design/uploads/logo-1780868316271.png b/landing/design/uploads/logo-1780868316271.png
new file mode 100644
index 0000000..86dbead
Binary files /dev/null and b/landing/design/uploads/logo-1780868316271.png differ
diff --git a/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2 b/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2
new file mode 100644
index 0000000..fe867e9
Binary files /dev/null and b/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2 differ
diff --git a/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2 b/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2
new file mode 100644
index 0000000..1bcb86a
Binary files /dev/null and b/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2 differ
diff --git a/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2 b/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2
new file mode 100644
index 0000000..cc32cbf
Binary files /dev/null and b/landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2 differ
diff --git a/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2 b/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2
new file mode 100644
index 0000000..ce97d0f
Binary files /dev/null and b/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2 differ
diff --git a/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2 b/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2
new file mode 100644
index 0000000..db732c2
Binary files /dev/null and b/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2 differ
diff --git a/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2 b/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2
new file mode 100644
index 0000000..0f3474e
Binary files /dev/null and b/landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2 differ
diff --git a/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2 b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2
new file mode 100644
index 0000000..3ba1443
Binary files /dev/null and b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2 differ
diff --git a/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2 b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2
new file mode 100644
index 0000000..947a979
Binary files /dev/null and b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2 differ
diff --git a/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2 b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2
new file mode 100644
index 0000000..d3829f9
Binary files /dev/null and b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2 differ
diff --git a/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2 b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2
new file mode 100644
index 0000000..6dcf15a
Binary files /dev/null and b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2 differ
diff --git a/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2 b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2
new file mode 100644
index 0000000..0919c5f
Binary files /dev/null and b/landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2 differ
diff --git a/landing/public/fonts/fonts.css b/landing/public/fonts/fonts.css
new file mode 100644
index 0000000..8aa44ed
--- /dev/null
+++ b/landing/public/fonts/fonts.css
@@ -0,0 +1,270 @@
+/* cyrillic */
+@font-face {
+ font-family: 'Pixelify Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Pixelify Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Pixelify Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Pixelify Sans';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Pixelify Sans';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Pixelify Sans';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Press Start 2P';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Press Start 2P';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek */
+@font-face {
+ font-family: 'Press Start 2P';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Press Start 2P';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Press Start 2P';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Silkscreen';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Silkscreen';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Silkscreen';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Silkscreen';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Space Grotesk';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'VT323';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'VT323';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'VT323';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/fonts/pxiKyp0ihIEF2isfFJU.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2 b/landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2
new file mode 100644
index 0000000..1fb8afe
Binary files /dev/null and b/landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2 differ
diff --git a/landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2 b/landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2
new file mode 100644
index 0000000..15bb84c
Binary files /dev/null and b/landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2 differ
diff --git a/landing/public/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2 b/landing/public/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2
new file mode 100644
index 0000000..77cb2ed
Binary files /dev/null and b/landing/public/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2 differ
diff --git a/landing/public/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2 b/landing/public/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2
new file mode 100644
index 0000000..285a17a
Binary files /dev/null and b/landing/public/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2 differ
diff --git a/landing/public/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2 b/landing/public/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2
new file mode 100644
index 0000000..d9f1698
Binary files /dev/null and b/landing/public/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2 differ
diff --git a/landing/public/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2 b/landing/public/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2
new file mode 100644
index 0000000..5383719
Binary files /dev/null and b/landing/public/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2 differ
diff --git a/landing/public/fonts/pxiKyp0ihIEF2isfFJU.woff2 b/landing/public/fonts/pxiKyp0ihIEF2isfFJU.woff2
new file mode 100644
index 0000000..fd760b5
Binary files /dev/null and b/landing/public/fonts/pxiKyp0ihIEF2isfFJU.woff2 differ
diff --git a/landing/src/components/CopyChip.astro b/landing/src/components/CopyChip.astro
new file mode 100644
index 0000000..10bc22e
--- /dev/null
+++ b/landing/src/components/CopyChip.astro
@@ -0,0 +1,17 @@
+---
+// MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
+// "url" = smaller, wraps (auth / packwiz URLs). Copy handled by the global
+// [data-copy] script in [...lang].astro; data-label / data-copied drive the
+// (localized) button text and transient feedback.
+interface Props {
+ value: string;
+ variant?: "ip" | "url";
+ label?: string;
+ copiedLabel?: string;
+}
+const { value, variant = "ip", label = "Copy", copiedLabel = "Copied!" } = Astro.props;
+---
+
+ ▸ {value}
+ {label}
+
diff --git a/landing/src/components/Creeper.astro b/landing/src/components/Creeper.astro
new file mode 100644
index 0000000..dd2002d
--- /dev/null
+++ b/landing/src/components/Creeper.astro
@@ -0,0 +1,17 @@
+---
+// 8x8 creeper face — pure markup, styled by .creeper in main.css.
+const CREEPER = [
+ "00000000",
+ "01100110",
+ "01100110",
+ "00011000",
+ "00111100",
+ "00111100",
+ "00100100",
+ "00000000",
+];
+const cells = CREEPER.join("").split("");
+---
+
+ {cells.map((c) => )}
+
diff --git a/landing/src/components/PixelIcon.astro b/landing/src/components/PixelIcon.astro
new file mode 100644
index 0000000..37638a7
--- /dev/null
+++ b/landing/src/components/PixelIcon.astro
@@ -0,0 +1,20 @@
+---
+// 7x7 feature glyphs — styled by .picon in main.css.
+interface Props {
+ glyph: "shield" | "diamond" | "orb" | "heart";
+ gold?: boolean;
+}
+const { glyph, gold = false } = Astro.props;
+
+const GLYPHS: Record = {
+ shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
+ diamond: ["0001000", "0011100", "0111110", "1111111", "0111110", "0011100", "0001000"],
+ orb: ["0011100", "0111110", "1111111", "1111111", "1111111", "0111110", "0011100"],
+ heart: ["0110110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
+};
+const cells = GLYPHS[glyph].join("").split("");
+const onClass = gold ? "go" : "on";
+---
+
+ {cells.map((c) => )}
+
diff --git a/landing/src/components/ServerListPanel.astro b/landing/src/components/ServerListPanel.astro
new file mode 100644
index 0000000..7d5169b
--- /dev/null
+++ b/landing/src/components/ServerListPanel.astro
@@ -0,0 +1,31 @@
+---
+// Static server-list panel — mirrors the multiplayer-list row. No live count
+// (plan 09-landing.md option B); shows version, motd and slot cap.
+import Creeper from "./Creeper.astro";
+import { site } from "../data/site";
+interface Props {
+ modded: string;
+ motd: string;
+ upTo: string;
+}
+const { modded, motd, upTo } = Astro.props;
+const { status } = site;
+---
+
+
+
+
+
+
+ {upTo} {status.slots}
+
+
+
diff --git a/landing/src/data/site.ts b/landing/src/data/site.ts
index f6ebe61..60abd19 100644
--- a/landing/src/data/site.ts
+++ b/landing/src/data/site.ts
@@ -1,11 +1,28 @@
-// Single source of truth for the landing page content.
+// Single source of truth for the landing page's *non-translatable* config:
+// domains, URLs, theme, launcher files, and the structural shape of the
+// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
+// Minecraft / pack facts, surfaced in a few places.
+const MC_VERSION = "1.21.1";
+
export const site = {
name: "Ulicraft",
- tagline: "LAN-party Minecraft server",
baseDomain: BASE_DOMAIN,
+ mcVersion: MC_VERSION,
+
+ // Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
+ // mood: "grass" | "nether" | "end"
+ // hero: "centered" | "split" | "spotlight"
+ // headFont: "pixelify" | "8bit" | "silkscreen"
+ // dust: floating pixel particles in the hero
+ theme: {
+ mood: "grass",
+ hero: "centered",
+ headFont: "pixelify",
+ dust: true,
+ },
// Connect target shown to guests. No port needed — a dnsmasq SRV record
// (_minecraft._tcp.mc.) points clients at 25565.
@@ -21,10 +38,35 @@ export const site = {
// Caddy local CA root — guests import this to trust the offline asset mirror.
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
+ // Static server-list panel (Status section). No fake live count — see plan
+ // 09-landing.md option B.
+ status: {
+ slots: 10,
+ },
+
+ // Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
+ // come from ui.ts (same order).
+ stats: [
+ { key: "50+" },
+ { key: MC_VERSION },
+ { key: "10" },
+ { key: "6h" },
+ ],
+
+ // Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill.
+ // Text (h/p) comes from ui.ts (same order).
+ features: [
+ { glyph: "diamond", gold: true },
+ { glyph: "shield", gold: false },
+ { glyph: "orb", gold: false },
+ { glyph: "heart", gold: true },
+ ],
+
// Launcher downloads. Stable filenames are symlinks created by
- // tooling/fetch-launcher.sh — keep these in sync with that script.
+ // tooling/fetch-launcher.sh — keep these in sync with that script. OS names
+ // and hints are technical, kept language-neutral here.
launcher: {
- name: "FjordLauncherUnlocked",
+ name: "Fjord Launcher",
base: "/launcher/latest",
downloads: [
{ os: "Windows", file: "fjord-windows-setup.exe", hint: "x64 installer" },
@@ -33,3 +75,18 @@ export const site = {
],
},
} as const;
+
+// Product / in-game UI literals — never translated (they match what guests see
+// on screen or type verbatim).
+export const LITERALS = {
+ authlib: "authlib-injector",
+ multiplayer: "Multiplayer",
+ addServer: "Add Server",
+} as const;
+
+// Head-font CSS stacks, keyed by theme.headFont.
+export const HEAD_FONTS: Record = {
+ pixelify: "'Pixelify Sans', system-ui, sans-serif",
+ "8bit": "'Press Start 2P', monospace",
+ silkscreen: "'Silkscreen', system-ui, sans-serif",
+};
diff --git a/landing/src/i18n/ui.ts b/landing/src/i18n/ui.ts
new file mode 100644
index 0000000..51f7454
--- /dev/null
+++ b/landing/src/i18n/ui.ts
@@ -0,0 +1,340 @@
+// Translatable UI copy for the landing page. Structure is identical across
+// locales; the page (src/pages/[...lang].astro) renders against it.
+//
+// Conventions:
+// - "{name}" in step1 is replaced with the launcher name at render time.
+// - Sentences containing links/kbd/literals are split into parts (a/b/pre/
+// post/link) so the markup stays in the template, not in the strings.
+// - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
+// live in site.ts LITERALS and are never translated.
+
+export type Lang = "en" | "es" | "eu";
+export const LANGS: Lang[] = ["en", "es", "eu"];
+export const LANG_LABEL: Record = { en: "EN", es: "ES", eu: "EU" };
+// URL path per locale (en is default, served at root).
+export const LANG_PATH: Record = { en: "/", es: "/es/", eu: "/eu/" };
+
+export interface UI {
+ htmlLang: string;
+ copied: string; // transient copy-button feedback
+ nav: { status: string; features: string; join: string; play: string };
+ hero: {
+ eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
+ tagline: string;
+ sub: string;
+ copyIp: string;
+ howToJoin: string;
+ metaModpack: string;
+ metaLan: string;
+ };
+ status: {
+ eyebrow: string;
+ title: string;
+ lead: string;
+ modded: string;
+ motd: string;
+ upTo: string;
+ };
+ statLabels: [string, string, string, string];
+ features: {
+ eyebrow: string;
+ title: string;
+ lead: string;
+ items: { h: string; p: string }[];
+ };
+ join: {
+ eyebrow: string;
+ title: string;
+ lead: string;
+ s1: { kicker: string; h: string; p: string };
+ s2: {
+ kicker: string;
+ h: string;
+ pa: string; // before the authlib-injector literal
+ pb: string; // after it
+ copy: string;
+ registerPre: string;
+ caPre: string;
+ caLink: string;
+ caPost: string;
+ };
+ s3: { kicker: string; h: string; p: string };
+ s4: {
+ kicker: string;
+ h: string;
+ pa: string; // before the Multiplayer/Add Server kbds
+ pb: string; // after them
+ copy: string;
+ };
+ };
+ footer: { join: string; disc: string };
+}
+
+export const ui: Record = {
+ en: {
+ htmlLang: "en",
+ copied: "Copied!",
+ nav: { status: "Status", features: "Features", join: "How to Join", play: "Play Now" },
+ hero: {
+ eyebrow: "Modded LAN · NeoForge",
+ tagline: "Kitchen-sink survival, built to outlast the weekend.",
+ sub:
+ "A self-hosted modded server for the crew — a curated tech + magic + " +
+ "exploration pack, your own skins, one-click setup. Grab the address, " +
+ "import the pack, dig in.",
+ copyIp: "Copy IP",
+ howToJoin: "How to Join",
+ metaModpack: "NeoForge modpack",
+ metaLan: "LAN-only",
+ },
+ status: {
+ eyebrow: "Server",
+ title: "This is what you'll see.",
+ lead: "Exactly the entry that shows up in your multiplayer list.",
+ modded: "Modded",
+ motd: "NeoForge modpack · Simple Voice Chat · LAN-only",
+ upTo: "up to",
+ },
+ statLabels: ["Mods", "NeoForge", "Player slots", "World backups"],
+ features: {
+ eyebrow: "What makes it Ulicraft",
+ title: "Built for the crew.",
+ lead:
+ "Self-hosted, curated, and persistent — a modded server that respects " +
+ "your time and outlives the LAN.",
+ items: [
+ {
+ h: "Kitchen-sink pack",
+ p: "~50–100 hand-picked mods — tech, magic, exploration and QoL. NeoForge 1.21.1, curated, no bloat.",
+ },
+ {
+ h: "Your identity, self-hosted",
+ p: "Drasl runs accounts and skins on the LAN. No Mojang login needed — your skin and cape just work.",
+ },
+ {
+ h: "One-click setup",
+ p: "packwiz installs and updates the whole modpack inside your launcher, so everyone stays in sync.",
+ },
+ {
+ h: "Built to last",
+ p: "Not a weekend throwaway — it runs on the homelab with 6-hour world backups. Your base survives.",
+ },
+ ],
+ },
+ join: {
+ eyebrow: "How to Join · 4 Steps",
+ title: "From zero to spawning in.",
+ lead: "One-time setup. After this, the launcher keeps you in sync.",
+ s1: {
+ kicker: "Launcher",
+ h: "Download {name}",
+ p: "Grab {name} for your system — a Prism-based launcher with authlib-injector support built in.",
+ },
+ s2: {
+ kicker: "Account",
+ h: "Add your account",
+ pa: "In the launcher, add an",
+ pb: "account using this URL, then log in with your Ulicraft credentials.",
+ copy: "Copy",
+ registerPre: "Need credentials? Register at",
+ caPre: "Offline party? Download and trust the",
+ caLink: "local CA certificate",
+ caPost: "so the game-file mirror works over HTTPS.",
+ },
+ s3: {
+ kicker: "Modpack",
+ h: "Import the modpack",
+ p: "Add a new instance from this packwiz URL — it installs and updates the whole pack.",
+ },
+ s4: {
+ kicker: "Connect",
+ h: "Join the server",
+ pa: "Launch the instance, open",
+ pb: ", paste the address, and join.",
+ copy: "Copy IP",
+ },
+ },
+ footer: {
+ join: "How to Join",
+ disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
+ },
+ },
+
+ es: {
+ htmlLang: "es",
+ copied: "¡Copiado!",
+ nav: { status: "Estado", features: "Características", join: "Cómo entrar", play: "Jugar ahora" },
+ hero: {
+ eyebrow: "LAN con mods · NeoForge",
+ tagline: "Supervivencia todo incluido, para durar más que el finde.",
+ sub:
+ "Un servidor con mods autoalojado para la cuadrilla: un pack curado de " +
+ "tecnología, magia y exploración, tus propias skins y configuración en un " +
+ "clic. Copia la dirección, importa el pack y a cavar.",
+ copyIp: "Copiar IP",
+ howToJoin: "Cómo entrar",
+ metaModpack: "Pack NeoForge",
+ metaLan: "Solo LAN",
+ },
+ status: {
+ eyebrow: "Servidor",
+ title: "Esto es lo que verás.",
+ lead: "La misma entrada que aparece en tu lista de multijugador.",
+ modded: "Con mods",
+ motd: "Pack NeoForge · Simple Voice Chat · Solo LAN",
+ upTo: "hasta",
+ },
+ statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"],
+ features: {
+ eyebrow: "Qué hace especial a Ulicraft",
+ title: "Hecho para la cuadrilla.",
+ lead:
+ "Autoalojado, curado y persistente: un servidor con mods que respeta tu " +
+ "tiempo y sobrevive a la LAN.",
+ items: [
+ {
+ h: "Pack todo incluido",
+ p: "~50–100 mods elegidos a mano: tecnología, magia, exploración y QoL. NeoForge 1.21.1, curado y sin relleno.",
+ },
+ {
+ h: "Tu identidad, autoalojada",
+ p: "Drasl gestiona cuentas y skins en la LAN. Sin login de Mojang: tu skin y tu capa funcionan sin más.",
+ },
+ {
+ h: "Configuración en un clic",
+ p: "packwiz instala y actualiza todo el modpack dentro de tu launcher, así todos vais sincronizados.",
+ },
+ {
+ h: "Hecho para durar",
+ p: "No es de usar y tirar: corre en el homelab con copias del mundo cada 6 horas. Tu base sobrevive.",
+ },
+ ],
+ },
+ join: {
+ eyebrow: "Cómo entrar · 4 pasos",
+ title: "De cero a aparecer en el mundo.",
+ lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
+ s1: {
+ kicker: "Launcher",
+ h: "Descarga {name}",
+ p: "Coge {name} para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
+ },
+ s2: {
+ kicker: "Cuenta",
+ h: "Añade tu cuenta",
+ pa: "En el launcher, añade una cuenta",
+ pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
+ copy: "Copiar",
+ registerPre: "¿Sin credenciales? Regístrate en",
+ caPre: "¿Fiesta sin internet? Descarga y confía en el",
+ caLink: "certificado CA local",
+ caPost: "para que el mirror de archivos funcione por HTTPS.",
+ },
+ s3: {
+ kicker: "Modpack",
+ h: "Importa el modpack",
+ p: "Añade una instancia nueva desde esta URL de packwiz: instala y actualiza todo el pack.",
+ },
+ s4: {
+ kicker: "Conectar",
+ h: "Entra al servidor",
+ pa: "Lanza la instancia, abre",
+ pb: ", pega la dirección y entra.",
+ copy: "Copiar IP",
+ },
+ },
+ footer: {
+ join: "Cómo entrar",
+ disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
+ },
+ },
+
+ eu: {
+ htmlLang: "eu",
+ copied: "Kopiatuta!",
+ nav: { status: "Egoera", features: "Ezaugarriak", join: "Nola sartu", play: "Jokatu orain" },
+ hero: {
+ eyebrow: "Mod-dun LANa · NeoForge",
+ tagline: "Mod ugariko biziraupena, irauteko egina.",
+ sub:
+ "Koadrilarentzako mod-dun zerbitzari auto-ostatatua: teknologia, magia eta " +
+ "esplorazio pack zaindua, zure azalak eta konfigurazioa klik bakarrean. " +
+ "Hartu helbidea, inportatu packa eta hasi zulatzen.",
+ copyIp: "Kopiatu IPa",
+ howToJoin: "Nola sartu",
+ metaModpack: "NeoForge packa",
+ metaLan: "LAN soilik",
+ },
+ status: {
+ eyebrow: "Zerbitzaria",
+ title: "Hau ikusiko duzu.",
+ lead: "Zure multijokalari-zerrendan agertzen den sarrera bera.",
+ modded: "Mod-duna",
+ motd: "NeoForge packa · Simple Voice Chat · LAN soilik",
+ upTo: "gehienez",
+ },
+ statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"],
+ features: {
+ eyebrow: "Zerk egiten du Ulicraft berezi",
+ title: "Koadrilarentzat eginda.",
+ lead:
+ "Auto-ostatatua, zaindua eta iraunkorra: zure denbora errespetatzen duen " +
+ "eta LANa gainditzen duen mod-dun zerbitzaria.",
+ items: [
+ {
+ h: "Pack osoa",
+ p: "Eskuz aukeratutako ~50–100 mod: teknologia, magia, esplorazioa eta QoL. NeoForge 1.21.1, zaindua eta betegarririk gabe.",
+ },
+ {
+ h: "Zure nortasuna, auto-ostatatua",
+ p: "Draslek kontuak eta azalak LANean kudeatzen ditu. Mojang loginik gabe: zure azalak eta kapak besterik gabe dabiltza.",
+ },
+ {
+ h: "Konfigurazioa klik batean",
+ p: "packwiz-ek modpack osoa instalatu eta eguneratzen du zure launcherrean, denok sinkronizatuta egoteko.",
+ },
+ {
+ h: "Irauteko eginda",
+ p: "Ez da behin erabili eta botatzekoa: homelab-ean dabil, munduaren babeskopiak 6 orduro. Zure basea bizirik.",
+ },
+ ],
+ },
+ join: {
+ eyebrow: "Nola sartu · 4 urrats",
+ title: "Zerotik mundura agertzera.",
+ lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
+ s1: {
+ kicker: "Launcherra",
+ h: "Deskargatu {name}",
+ p: "Hartu {name} zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
+ },
+ s2: {
+ kicker: "Kontua",
+ h: "Gehitu zure kontua",
+ pa: "Launcherrean, gehitu",
+ pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
+ copy: "Kopiatu",
+ registerPre: "Kredentzialik ez? Erregistratu hemen:",
+ caPre: "Internetik gabeko festa? Deskargatu eta fidatu",
+ caLink: "tokiko CA ziurtagiriaz",
+ caPost: "fitxategi-mirrorrak HTTPS bidez ibil dadin.",
+ },
+ s3: {
+ kicker: "Modpacka",
+ h: "Inportatu modpacka",
+ p: "Gehitu instantzia berri bat packwiz URL honetatik: pack osoa instalatu eta eguneratzen du.",
+ },
+ s4: {
+ kicker: "Konektatu",
+ h: "Sartu zerbitzarira",
+ pa: "Abiarazi instantzia, ireki",
+ pb: ", itsatsi helbidea eta sartu.",
+ copy: "Kopiatu IPa",
+ },
+ },
+ footer: {
+ join: "Nola sartu",
+ disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
+ },
+ },
+};
diff --git a/landing/src/pages/[...lang].astro b/landing/src/pages/[...lang].astro
new file mode 100644
index 0000000..c8ef2de
--- /dev/null
+++ b/landing/src/pages/[...lang].astro
@@ -0,0 +1,317 @@
+---
+import { site, LITERALS, HEAD_FONTS } from "../data/site";
+import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
+import Creeper from "../components/Creeper.astro";
+import PixelIcon from "../components/PixelIcon.astro";
+import CopyChip from "../components/CopyChip.astro";
+import ServerListPanel from "../components/ServerListPanel.astro";
+import "../styles/main.css";
+
+// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
+export function getStaticPaths() {
+ return [
+ { params: { lang: undefined }, props: { locale: "en" as Lang } },
+ { params: { lang: "es" }, props: { locale: "es" as Lang } },
+ { params: { lang: "eu" }, props: { locale: "eu" as Lang } },
+ ];
+}
+
+const { locale } = Astro.props;
+const t = ui[locale];
+const { theme, launcher } = site;
+const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
+const launcherName = launcher.name;
+
+// Build-time floating dust bits (index-derived, no runtime RNG).
+const dustBits = Array.from({ length: 16 }, (_, i) => {
+ const size = 3 + (i % 3) * 2;
+ return {
+ left: `${(i * 61) % 100}%`,
+ size: `${size}px`,
+ bg: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
+ opacity: 0.18 + (i % 3) * 0.06,
+ anim: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
+ };
+});
+---
+
+
+
+
+
+
+ {site.name} — {t.hero.howToJoin}
+
+
+
+ {LANGS.map((l) => )}
+
+
+
+
+
+
+
+
+ {theme.dust && (
+
+ {dustBits.map((b) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
{t.hero.eyebrow} {site.mcVersion}
+
{t.hero.tagline}
+
{t.hero.sub}
+
+
+ Java {site.mcVersion}
+
+ {t.hero.metaModpack}
+
+ {t.hero.metaLan}
+
+
+
+
+
+
+
+
+
+
+
+
+
{t.status.eyebrow}
+
{t.status.title}
+
{t.status.lead}
+
+
+
+
+
+ {site.stats.map((s, i) => (
+
+
{s.key}
+
{t.statLabels[i]}
+
+ ))}
+
+
+
+
+
+
+
+
+
{t.features.eyebrow}
+
{t.features.title}
+
{t.features.lead}
+
+
+ {site.features.map((f, i) => (
+
+
+
+
{t.features.items[i].h}
+
{t.features.items[i].p}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
{t.join.eyebrow}
+
{t.join.title}
+
{t.join.lead}
+
+
+
+
+
+
1
+
+
{t.join.s1.kicker}
+
{t.join.s1.h.replace("{name}", launcherName)}
+
{t.join.s1.p.replace(/\{name\}/g, launcherName)}
+
+
+
+
+
+
+
2
+
+
{t.join.s2.kicker}
+
{t.join.s2.h}
+
+ {t.join.s2.pa} {LITERALS.authlib} {t.join.s2.pb}
+
+
+
+
+
+ {t.join.s2.registerPre} {site.authUrl} .
+
+
+ {t.join.s2.caPre} {t.join.s2.caLink} {t.join.s2.caPost}
+
+
+
+
+
+
+
3
+
+
{t.join.s3.kicker}
+
{t.join.s3.h}
+
{t.join.s3.p}
+
+
+
+
+
+
+
+
+
4
+
+
{t.join.s4.kicker}
+
{t.join.s4.h}
+
+ {t.join.s4.pa} {LITERALS.multiplayer} → {LITERALS.addServer} {t.join.s4.pb}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/landing/src/pages/index.astro b/landing/src/pages/index.astro
deleted file mode 100644
index 56d7105..0000000
--- a/landing/src/pages/index.astro
+++ /dev/null
@@ -1,199 +0,0 @@
----
-import { site } from "../data/site";
-const { launcher } = site;
----
-
-
-
-
-
-
- {site.name} — join the server
-
-
-
-
-
-
-
-
-
- 1 Download the launcher
- Grab {launcher.name} for your system.
-
-
-
-
- 2 Add your account
- In the launcher, add an authlib-injector account using this URL:
-
- {site.authlibUrl}
- Copy
-
-
- Need credentials? Register at {site.authUrl} , then log in
- with that username and password.
-
-
- Offline party? Download and trust the
- local CA certificate so the game-file
- mirror works over HTTPS.
-
-
-
-
- 3 Import the modpack
- Add a new instance from this packwiz URL:
-
- {site.packwizUrl}
- Copy
-
-
-
-
- 4 Connect
- Launch the instance, add a server, and join:
-
- {site.serverAddress}
- Copy
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/landing/src/styles/main.css b/landing/src/styles/main.css
new file mode 100644
index 0000000..6033c4e
--- /dev/null
+++ b/landing/src/styles/main.css
@@ -0,0 +1,608 @@
+/* ============================================================
+ ULICRAFT — "Carved from stone"
+ Design system: dark overworld, MC-GUI bevels, pixel type
+ ============================================================ */
+
+/* ---- Mood palettes (data-mood on ) ---- */
+:root,
+:root[data-mood="grass"] {
+ --bg: oklch(0.165 0.014 150);
+ --bg-2: oklch(0.205 0.016 150);
+ --surface: oklch(0.235 0.017 150);
+ --surface-2: oklch(0.285 0.018 150);
+ --slot: oklch(0.185 0.014 150);
+ --bevel-hi: oklch(0.40 0.018 150);
+ --bevel-lo: oklch(0.115 0.012 150);
+ --line: oklch(0.33 0.016 150);
+
+ --accent: oklch(0.72 0.16 145); /* grass */
+ --accent-hi: oklch(0.80 0.15 145);
+ --accent-lo: oklch(0.55 0.15 145);
+ --accent-ink: oklch(0.17 0.05 150); /* text on accent */
+
+ --gold: oklch(0.82 0.135 85);
+ --glow: oklch(0.72 0.16 145 / 0.30);
+
+ --text: oklch(0.95 0.008 110);
+ --muted: oklch(0.72 0.012 145);
+ --dim: oklch(0.55 0.012 145);
+}
+
+:root[data-mood="nether"] {
+ --bg: oklch(0.165 0.018 35);
+ --bg-2: oklch(0.205 0.022 32);
+ --surface: oklch(0.235 0.026 32);
+ --surface-2: oklch(0.285 0.030 32);
+ --slot: oklch(0.185 0.020 32);
+ --bevel-hi: oklch(0.42 0.040 35);
+ --bevel-lo: oklch(0.115 0.016 32);
+ --line: oklch(0.34 0.030 32);
+
+ --accent: oklch(0.64 0.19 32); /* nether red */
+ --accent-hi: oklch(0.72 0.18 38);
+ --accent-lo: oklch(0.50 0.17 30);
+ --accent-ink: oklch(0.16 0.04 32);
+
+ --gold: oklch(0.83 0.14 75);
+ --glow: oklch(0.64 0.19 32 / 0.34);
+
+ --text: oklch(0.95 0.010 60);
+ --muted: oklch(0.74 0.020 45);
+ --dim: oklch(0.56 0.020 40);
+}
+
+:root[data-mood="end"] {
+ --bg: oklch(0.155 0.018 305);
+ --bg-2: oklch(0.195 0.022 305);
+ --surface: oklch(0.225 0.026 305);
+ --surface-2: oklch(0.275 0.030 305);
+ --slot: oklch(0.175 0.020 305);
+ --bevel-hi: oklch(0.42 0.040 305);
+ --bevel-lo: oklch(0.110 0.016 305);
+ --line: oklch(0.34 0.030 305);
+
+ --accent: oklch(0.74 0.135 175); /* end teal */
+ --accent-hi: oklch(0.82 0.13 175);
+ --accent-lo: oklch(0.58 0.13 178);
+ --accent-ink: oklch(0.16 0.04 200);
+
+ --gold: oklch(0.84 0.13 95);
+ --glow: oklch(0.70 0.16 300 / 0.34);
+
+ --text: oklch(0.96 0.010 300);
+ --muted: oklch(0.76 0.020 300);
+ --dim: oklch(0.58 0.020 300);
+}
+
+/* ---- Fonts (switchable head face via --font-head) ---- */
+:root {
+ --font-head: 'Pixelify Sans', system-ui, sans-serif;
+ --font-body: 'Space Grotesk', system-ui, sans-serif;
+ --font-pixel: 'Press Start 2P', monospace; /* tiny eyebrow labels */
+ --font-mono: 'VT323', monospace; /* IP / terminal */
+ --maxw: 1160px;
+}
+
+* { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-body);
+ font-size: 17px;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ overflow-x: hidden;
+}
+
+/* faint pixel-grid stone texture over everything */
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background-image:
+ repeating-linear-gradient(0deg, oklch(1 0 0 / 0.018) 0 1px, transparent 1px 4px),
+ repeating-linear-gradient(90deg, oklch(0 0 0 / 0.06) 0 1px, transparent 1px 4px);
+ background-size: 4px 4px, 4px 4px;
+ mix-blend-mode: overlay;
+ opacity: 0.5;
+}
+
+img { display: block; max-width: 100%; }
+
+::selection { background: var(--accent); color: var(--accent-ink); }
+
+/* ---- Typography helpers ---- */
+.eyebrow {
+ font-family: var(--font-pixel);
+ font-size: clamp(9px, 1.1vw, 11px);
+ letter-spacing: 0.12em;
+ color: var(--accent);
+ text-transform: uppercase;
+ line-height: 1.8;
+}
+h1, h2, h3 {
+ font-family: var(--font-head);
+ font-weight: 600;
+ line-height: 1.02;
+ letter-spacing: 0.01em;
+ margin: 0;
+}
+.section-title {
+ font-size: clamp(34px, 5vw, 58px);
+}
+.lead { color: var(--muted); }
+.mono { font-family: var(--font-mono); }
+
+/* in-copy links (steps, hints) — accent, not browser blue */
+.step a, .hint a, .hero-sub a { color: var(--accent); text-underline-offset: 2px; }
+
+/* ---- Layout ---- */
+.wrap { width: min(var(--maxw), calc(100% - 48px)); margin-inline: auto; }
+section { position: relative; z-index: 1; }
+.pad { padding-block: clamp(64px, 9vw, 130px); }
+.sec-head { max-width: 640px; margin-bottom: 48px; }
+.sec-head .eyebrow { display: block; margin-bottom: 14px; }
+.sec-head p { margin: 16px 0 0; font-size: 18px; }
+
+/* ============================================================
+ MINECRAFT-STYLE BUTTON
+ ============================================================ */
+.mc-btn {
+ --b: var(--surface-2);
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-family: var(--font-head);
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--text);
+ background: var(--b);
+ border: 0;
+ padding: 15px 24px 17px;
+ cursor: pointer;
+ text-decoration: none;
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-hi),
+ inset -2px -2px 0 var(--bevel-lo),
+ 0 4px 0 oklch(0 0 0 / 0.5),
+ 0 8px 18px oklch(0 0 0 / 0.4);
+ transition: transform .08s ease, filter .12s ease;
+ image-rendering: pixelated;
+ text-shadow: 0 2px 0 oklch(0 0 0 / 0.35);
+ white-space: nowrap;
+}
+.mc-btn:hover { filter: brightness(1.12); }
+.mc-btn:active {
+ transform: translateY(4px);
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-hi),
+ inset -2px -2px 0 var(--bevel-lo),
+ 0 0 0 oklch(0 0 0 / 0.5),
+ 0 2px 8px oklch(0 0 0 / 0.4);
+}
+.mc-btn.primary {
+ --b: var(--accent);
+ --bevel-hi: var(--accent-hi);
+ --bevel-lo: var(--accent-lo);
+ color: var(--accent-ink);
+ text-shadow: 0 2px 0 oklch(1 0 0 / 0.22);
+}
+.mc-btn.gold {
+ --b: var(--gold);
+ --bevel-hi: oklch(0.90 0.10 90);
+ --bevel-lo: oklch(0.66 0.13 75);
+ color: oklch(0.20 0.05 80);
+ text-shadow: 0 2px 0 oklch(1 0 0 / 0.25);
+}
+.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
+
+/* ---- Copy-IP chip ---- */
+.ip-chip {
+ display: inline-flex;
+ align-items: stretch;
+ background: var(--slot);
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-lo),
+ inset -2px -2px 0 var(--bevel-hi);
+ overflow: hidden;
+}
+.ip-chip .ip-val {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 18px;
+ font-family: var(--font-mono);
+ font-size: 28px;
+ line-height: 1;
+ color: var(--gold);
+ letter-spacing: 0.02em;
+}
+.ip-chip .ip-val .pin { color: var(--dim); font-size: 22px; }
+.ip-chip button {
+ border: 0;
+ cursor: pointer;
+ padding: 14px 18px;
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: 15px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
+ transition: filter .12s ease;
+ white-space: nowrap;
+}
+.ip-chip button:hover { filter: brightness(1.1); }
+.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
+
+/* url variant: long auth/packwiz URLs — smaller, wraps instead of overflowing */
+.ip-chip.url { max-width: 100%; }
+.ip-chip.url .ip-val {
+ font-family: var(--font-mono);
+ font-size: 18px;
+ padding: 8px 14px;
+ word-break: break-all;
+ color: var(--text);
+}
+.ip-chip.url button { font-size: 14px; padding: 10px 16px; }
+
+/* ============================================================
+ NAV
+ ============================================================ */
+.nav {
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ background: oklch(0.165 0.014 150 / 0.72);
+ -webkit-backdrop-filter: blur(14px) saturate(140%);
+ backdrop-filter: blur(14px) saturate(140%);
+ border-bottom: 1px solid var(--line);
+}
+.nav-in {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ height: 68px;
+}
+.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
+.brand .name {
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: 22px;
+ letter-spacing: 0.02em;
+}
+.nav-links { display: flex; gap: 6px; margin-left: 12px; }
+
+/* language switcher */
+.lang-switch { display: flex; gap: 2px; }
+.lang-switch a {
+ font-family: var(--font-pixel);
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ color: var(--dim);
+ text-decoration: none;
+ padding: 7px 8px;
+ line-height: 1;
+ transition: color .12s ease, background .12s ease;
+}
+.lang-switch a:hover { color: var(--text); background: var(--surface); }
+.lang-switch a.on { color: var(--accent-ink); background: var(--accent); }
+.nav-links a {
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 15px;
+ font-weight: 500;
+ padding: 8px 12px;
+ transition: color .12s ease, background .12s ease;
+}
+.nav-links a:hover { color: var(--text); background: var(--surface); }
+.nav-spacer { flex: 1; }
+
+/* ---- Creeper pixel mark ---- */
+.creeper {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ grid-template-rows: repeat(8, 1fr);
+ width: 34px;
+ height: 34px;
+ background: var(--accent);
+ box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
+ image-rendering: pixelated;
+}
+.creeper i { background: transparent; }
+.creeper i.f { background: oklch(0.16 0.03 150); }
+
+/* ============================================================
+ HERO
+ ============================================================ */
+.hero {
+ position: relative;
+ padding-top: clamp(48px, 7vw, 90px);
+ padding-bottom: clamp(56px, 8vw, 110px);
+ overflow: hidden;
+}
+.hero::before { /* spotlight glow behind logo */
+ content: "";
+ position: absolute;
+ top: -10%;
+ left: 50%;
+ width: 1100px;
+ height: 760px;
+ transform: translateX(-50%);
+ background: radial-gradient(closest-side, var(--glow), transparent 72%);
+ pointer-events: none;
+ filter: blur(8px);
+}
+.hero-grid { position: relative; display: grid; gap: clamp(36px, 5vw, 64px); align-items: center; }
+
+/* layout: centered (default) */
+:root[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
+:root[data-hero="centered"] .hero-cta { justify-content: center; }
+:root[data-hero="centered"] .hero-meta { justify-content: center; }
+:root[data-hero="centered"] .hero-copy { max-width: 720px; }
+:root[data-hero="centered"] .hero-status { display: none; }
+
+/* layout: split (copy left, live panel right) */
+:root[data-hero="split"] .hero-grid { grid-template-columns: 1.1fr 0.9fr; }
+:root[data-hero="split"] .hero-logo { max-width: 560px; }
+:root[data-hero="split"] .hero-logo img { margin-inline: 0; }
+
+/* layout: spotlight (giant logo, minimal text, centered) */
+:root[data-hero="spotlight"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
+:root[data-hero="spotlight"] .hero-cta { justify-content: center; }
+:root[data-hero="spotlight"] .hero-meta { justify-content: center; }
+:root[data-hero="spotlight"] .hero-logo { max-width: 900px; }
+:root[data-hero="spotlight"] .hero-tagline { font-size: clamp(20px, 2.4vw, 28px); }
+:root[data-hero="spotlight"] .hero-status { display: none; }
+:root[data-hero="spotlight"] .hero-features-note { display: none; }
+
+.hero-logo { width: 100%; max-width: 760px; }
+.hero-logo img {
+ width: 100%;
+ image-rendering: pixelated;
+ filter: drop-shadow(0 8px 0 oklch(0 0 0 / 0.45)) drop-shadow(0 18px 30px oklch(0 0 0 / 0.55));
+ margin-inline: auto;
+}
+.hero-copy { display: flex; flex-direction: column; gap: 22px; }
+.hero-tagline {
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: clamp(24px, 3vw, 38px);
+ line-height: 1.08;
+ letter-spacing: 0.01em;
+ text-wrap: balance;
+}
+.hero-tagline .hl { color: var(--accent); }
+.hero-sub { color: var(--muted); font-size: 18px; max-width: 52ch; margin: 0; }
+.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
+.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; color: var(--dim); font-size: 14.5px; }
+.hero-meta .dot { width: 6px; height: 6px; background: var(--dim); }
+.hero-ip-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
+
+/* status pill */
+.live-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ padding: 7px 14px 7px 11px;
+ background: var(--slot);
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+ font-size: 14px;
+ color: var(--muted);
+ font-weight: 500;
+}
+.live-dot {
+ width: 9px; height: 9px; background: var(--accent);
+ box-shadow: 0 0 0 3px var(--glow);
+ animation: pulse 2.2s ease-in-out infinite;
+}
+@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
+@media (prefers-reduced-motion: reduce){ .live-dot{ animation:none } }
+.live-pill b { color: var(--text); font-variant-numeric: tabular-nums; }
+
+/* ============================================================
+ SERVER-LIST STATUS PANEL
+ ============================================================ */
+.serverlist {
+ display: flex;
+ gap: 18px;
+ align-items: center;
+ background: var(--slot);
+ padding: 16px;
+ box-shadow:
+ inset 2px 2px 0 var(--bevel-hi),
+ inset -2px -2px 0 var(--bevel-lo),
+ 0 8px 24px oklch(0 0 0 / 0.4);
+}
+.serverlist .icon {
+ width: 72px; height: 72px; flex-shrink: 0;
+ display: grid; place-items: center;
+ background: var(--bg-2);
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+}
+.serverlist .icon .creeper { width: 48px; height: 48px; }
+.serverlist .meta { flex: 1; min-width: 0; }
+.serverlist .row1 { display: flex; align-items: baseline; gap: 12px; flex-wrap: nowrap; }
+.serverlist .title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
+.serverlist .ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
+.serverlist .motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
+.serverlist .motd .g { color: var(--gold); }
+.serverlist .motd .a { color: var(--accent); }
+.serverlist .stat { text-align: right; flex-shrink: 0; }
+.serverlist .players { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); display:flex; align-items:center; gap:8px; justify-content:flex-end; }
+.serverlist .players b { color: var(--text); }
+
+/* signal bars */
+.bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
+.bars i { width: 4px; background: var(--accent); image-rendering: pixelated; }
+.bars i:nth-child(1){height:25%}
+.bars i:nth-child(2){height:45%}
+.bars i:nth-child(3){height:65%}
+.bars i:nth-child(4){height:85%}
+.bars i:nth-child(5){height:100%}
+
+/* stat tiles */
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin-top: 28px;
+}
+.tile {
+ background: var(--surface);
+ padding: 22px;
+ box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
+}
+.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
+.tile .k .u { color: var(--accent); font-size: 22px; }
+.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
+
+/* ============================================================
+ FEATURES
+ ============================================================ */
+.feat-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 18px;
+}
+.feat {
+ display: flex;
+ gap: 18px;
+ background: var(--surface);
+ padding: 26px;
+ box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
+ transition: transform .14s ease, filter .14s ease;
+}
+.feat:hover { transform: translateY(-3px); filter: brightness(1.06); }
+.feat h3 { font-size: 21px; margin-bottom: 8px; }
+.feat p { margin: 0; color: var(--muted); font-size: 16px; }
+
+/* pixel icon */
+.picon {
+ width: 52px; height: 52px; flex-shrink: 0;
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ grid-template-rows: repeat(7, 1fr);
+ background: var(--slot);
+ padding: 4px;
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+ image-rendering: pixelated;
+}
+.picon i { background: transparent; }
+.picon i.on { background: var(--accent); }
+.picon i.go { background: var(--gold); }
+
+/* ============================================================
+ HOW TO JOIN — steps
+ ============================================================ */
+.steps { display: flex; flex-direction: column; gap: 20px; }
+.step {
+ display: grid;
+ grid-template-columns: 92px 1fr;
+ gap: 28px;
+ background: var(--surface);
+ padding: 30px;
+ box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
+}
+.step .num {
+ width: 92px; height: 92px;
+ display: grid; place-items: center;
+ background: var(--slot);
+ font-family: var(--font-head);
+ font-weight: 600;
+ font-size: 46px;
+ color: var(--accent);
+ box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
+}
+.step .body { padding-top: 2px; }
+.step .kicker { font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; }
+.step h3 { font-size: 26px; margin: 10px 0 12px; }
+.step p { margin: 0 0 16px; color: var(--muted); font-size: 16.5px; max-width: 64ch; }
+.step .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
+.step ul { margin: 0 0 16px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
+.step ul li { display: flex; gap: 12px; color: var(--muted); font-size: 16px; }
+.step ul li::before { content: ""; width: 10px; height: 10px; margin-top: 8px; flex-shrink: 0; background: var(--accent); }
+.step .hint { color: var(--dim); font-size: 14px; }
+.step kbd {
+ font-family: var(--font-mono); font-size: 18px; line-height: 1;
+ background: var(--bg-2); color: var(--text);
+ padding: 4px 8px; box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
+}
+
+/* ============================================================
+ FOOTER
+ ============================================================ */
+.footer { border-top: 1px solid var(--line); background: var(--bg-2); }
+.footer-in { display: flex; flex-wrap: wrap; gap: 32px; align-items: center; justify-content: space-between; padding-block: 40px; }
+.footer .brand .name { font-size: 20px; }
+.footer-links { display: flex; gap: 8px; flex-wrap: wrap; }
+.footer .disc { color: var(--dim); font-size: 13px; max-width: 46ch; }
+
+/* ---- floating dust ---- */
+.dust { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
+@keyframes rise {
+ 0% { transform: translateY(0) translateX(0); opacity: 0; }
+ 10% { opacity: 1; }
+ 90% { opacity: 1; }
+ 100% { transform: translateY(-110vh) translateX(24px); opacity: 0; }
+}
+@media (prefers-reduced-motion: reduce) { .dust { display: none; } }
+.hero-grid { z-index: 1; }
+
+/* ---- head-font: Press Start 2P is wide/tall — scale down ---- */
+:root[data-head="8bit"] .section-title { font-size: clamp(20px, 3vw, 38px); line-height: 1.25; }
+:root[data-head="8bit"] .hero-tagline { font-size: clamp(15px, 2vw, 26px); line-height: 1.4; }
+:root[data-head="8bit"] .brand .name { font-size: 15px; }
+:root[data-head="8bit"] .mc-btn { font-size: 12px; }
+:root[data-head="8bit"] .mc-btn.sm { font-size: 11px; }
+:root[data-head="8bit"] .step h3 { font-size: 17px; line-height: 1.35; }
+:root[data-head="8bit"] .step .num { font-size: 30px; }
+:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
+:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
+:root[data-head="8bit"] .tile .k { font-size: 26px; }
+:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
+:root[data-head="8bit"] .live-pill { font-size: 12px; }
+
+/* ---- head-font: Silkscreen is small-cap-ish — nudge ---- */
+:root[data-head="silkscreen"] .section-title { letter-spacing: 0; }
+
+/* ============================================================
+ SCROLL REVEAL (fail-safe: visible at rest; animates only when JS adds .anim)
+ ============================================================ */
+.reveal { will-change: opacity, transform; }
+@keyframes reveal-in {
+ from { opacity: 0; transform: translateY(22px); }
+ to { opacity: 1; transform: none; }
+}
+@media (prefers-reduced-motion: no-preference) {
+ .reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
+}
+
+/* ============================================================
+ RESPONSIVE
+ ============================================================ */
+@media (max-width: 880px) {
+ :root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
+ :root[data-hero="split"] .hero-status { display: block; }
+ .stat-grid { grid-template-columns: repeat(2, 1fr); }
+ .feat-grid { grid-template-columns: 1fr; }
+ .nav-links { display: none; }
+ .serverlist { flex-wrap: wrap; }
+ .serverlist .stat { text-align: left; }
+}
+@media (max-width: 560px) {
+ .step { grid-template-columns: 1fr; gap: 18px; }
+ .step .num { width: 64px; height: 64px; font-size: 32px; }
+ .ip-chip .ip-val { font-size: 22px; }
+ .stat-grid { grid-template-columns: 1fr 1fr; }
+}
diff --git a/plan/09-landing.md b/plan/09-landing.md
index d8924f4..2349e4e 100644
--- a/plan/09-landing.md
+++ b/plan/09-landing.md
@@ -2,32 +2,92 @@
## Summary
-Apex `ulicraft.local` serves a clean static page guiding guests through joining:
+Apex `ulicraft.local` serves a static page guiding guests through joining:
download launcher → add account → import modpack → connect. Built with **Astro +
-TypeScript** in its own `./landing/` project; `npm run build` emits straight into
-`./www/`, which Caddy already serves at the apex domain. No runtime JS framework —
-output is plain HTML/CSS plus one tiny copy-to-clipboard script.
+TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`,
+which Caddy/nginx serve at the apex. No runtime framework — output is plain
+HTML/CSS plus one tiny vanilla script (copy-to-clipboard + scroll reveal).
+
+The visual is the **"Carved from stone"** pixel design ("Claude Design",
+`landing/design/`): dark overworld palette, MC-GUI bevel buttons, pixel type,
+creeper mark, server-list panel. That design was a React/Babel-via-CDN prototype
+for a *fictional public SMP*; it was **ported to static Astro** with the real
+modded-LAN content and offline-safe assets.
## Stack & wiring
-- `landing/` — Astro project (own `package.json`, `tsconfig`).
+- `landing/` — Astro project (own `package.json`, `tsconfig`). pnpm only.
- `astro.config.mjs` → `outDir: "../www"`. Build overwrites `www/`; `www/` is
gitignored (generated artifact).
- `BASE_DOMAIN` env read at build time in `src/data/site.ts`
(`process.env.BASE_DOMAIN ?? "ulicraft.local"`). Bake before deploy:
`BASE_DOMAIN=ulicraft.local pnpm run build`.
-- Logo: drop PNG at `landing/public/logo.png` → copied to `www/logo.png`,
- referenced as `/logo.png` (favicon + hero). `image-rendering: pixelated` keeps
- the stone 3D logo crisp.
+- Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon).
+ `image-rendering: pixelated` keeps it crisp.
-## Content (src/data/site.ts is the single source)
+### Source layout
+```
+landing/
+├── design/ # "Claude Design" prototype (reference, not built)
+├── public/
+│ ├── logo.png # hero wordmark (from design/assets/ulicraft-logo.png)
+│ └── fonts/ # vendored woff2 + fonts.css (offline-safe)
+└── src/
+ ├── data/site.ts # single source: content + theme knobs
+ ├── styles/main.css # ported design system (mood/hero/bevels)
+ ├── components/ # Creeper, PixelIcon, CopyChip, ServerListPanel
+ └── pages/index.astro # composition + copy/reveal script + dust
+```
-- Hero: logo, name, server address `mc.${BASE_DOMAIN}:25565` (copy button).
-- Step 1: launcher downloads — 3 OS buttons → `/launcher/latest/`.
-- Step 2: add authlib-injector account, URL `http://auth.${BASE_DOMAIN}/authlib-injector`,
- register link to `http://auth.${BASE_DOMAIN}`.
-- Step 3: import packwiz `http://packwiz.${BASE_DOMAIN}/pack.toml`.
-- Step 4: connect to server address.
+## Fonts — vendored for offline LAN
+
+The design pulled fonts from Google Fonts CDN; that breaks on offline LAN day.
+`tooling/fetch-fonts.sh` downloads the woff2 + a URL-rewritten `fonts.css` into
+`landing/public/fonts/`. `index.astro` links `/fonts/fonts.css`. Re-run the
+script only if the font set changes (keep families in sync with `main.css`
+`--font-*`): Pixelify Sans, Space Grotesk, Press Start 2P, Silkscreen, VT323.
+
+## Theme config (replaces the design's in-browser TweaksPanel)
+
+`site.ts` `theme` is baked at build — edit + rebuild to change:
+- `mood`: `grass` (default) | `nether` | `end`
+- `hero`: `centered` (default) | `split` | `spotlight`
+- `headFont`: `pixelify` (default) | `8bit` | `silkscreen`
+- `dust`: floating hero particles (CSS animation; respects reduced-motion)
+
+`index.astro` sets `` + `--font-head` from these.
+
+## Languages (i18n)
+
+Three locales, static, build-time, offline-safe — **English** (default, `/`),
+**Spanish** (`/es/`), **Euskera** (`/eu/`).
+
+- One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three
+ routes (`/`, `/es/`, `/eu/`). No runtime/JS routing.
+- Translatable copy lives in `src/i18n/ui.ts` (`ui[locale]`), keyed identically
+ across locales. `site.ts` holds only language-neutral config (URLs, theme,
+ launcher files, stat/feature *structure*).
+- Product / in-game literals never translated — `site.ts` `LITERALS`
+ (`authlib-injector`, `Multiplayer`, `Add Server`). OS names + version too.
+- Sentences with links/kbd are split into parts (pre/post/link, pa/pb) so markup
+ stays in the template, not the strings.
+- Nav has a pixel `EN · ES · EU` switcher (`.lang-switch`); active locale
+ highlighted. `` + ` ` set per page.
+- Adding a locale: extend the `Lang` union + `ui`/`LANG_*` maps in `ui.ts`, add a
+ `getStaticPaths` entry. No template change.
+
+## Content (real modded-LAN flow, all in site.ts)
+
+- Hero: wordmark, eyebrow `Modded LAN · NeoForge 1.21.1`, tagline, sub, server
+ address `mc.${BASE_DOMAIN}` (copy chip), "How to Join".
+- Status: static server-list panel (no fake live count — **option B**) + 4 honest
+ stat tiles (Mods / NeoForge ver / Player slots / Backups).
+- Features ×4: kitchen-sink pack · self-hosted Drasl identity+skins · one-click
+ packwiz · built-to-last (homelab + 6h backups).
+- How to Join ×4: **1** Fjord launcher (3 OS) → **2** authlib account
+ (`auth.${BASE_DOMAIN}/authlib-injector`) + register link + CA-cert note →
+ **3** packwiz import (`packwiz.${BASE_DOMAIN}/pack.toml`) → **4** connect.
+- Footer: Mojang trademark disclaimer.
## Launcher download links (Option A — stable symlinks)
@@ -38,16 +98,18 @@ Page links to fixed names so they survive launcher version bumps:
`tooling/fetch-launcher.sh` must create these as symlinks under
`mirror/launcher/latest/` pointing at the real versioned assets. **Keep the names
-in sync** between `site.ts` and the script. Caddy mount
-`./mirror/launcher:/srv/www/launcher:ro` makes them resolve at `/launcher/latest/…`.
+in sync** between `site.ts` and the script. Mounted so they resolve at
+`/launcher/latest/…` (see nginx/Caddy ingress).
## Tasks
-- [ ] User: place logo at `landing/public/logo.png`
-- [ ] `cd landing && pnpm install`
-- [ ] `BASE_DOMAIN=ulicraft.local pnpm run build` → verify `www/index.html` + `www/logo.png`
-- [ ] Extend `fetch-launcher.sh`: after download, symlink stable names → versioned files
- (resolve which asset = win setup / mac dmg / linux AppImage)
-- [ ] Confirm Caddy serves apex from `./www` and `/launcher/latest/*` from mirror
-- [ ] Optional: pin launcher tag instead of `latest` for party day
-- [ ] Optional: add a "what's in the pack" section once mod list firms up
+- [x] Vendor fonts (`tooling/fetch-fonts.sh`) → `public/fonts/`
+- [x] Port design → static Astro (components + main.css + index.astro)
+- [x] Real content in `site.ts`; theme knobs replace TweaksPanel
+- [x] i18n: en/es/eu via `[...lang].astro` + `i18n/ui.ts`; nav language switcher
+- [x] `BASE_DOMAIN=ulicraft.local pnpm run build` → verified `www/` output + screenshot
+- [ ] Extend `fetch-launcher.sh`: symlink stable names → versioned files
+- [ ] Confirm ingress serves apex from `./www` and `/launcher/latest/*` from mirror
+- [ ] Optional: try `mood: nether` / `hero: split` for party day
+- [ ] Optional: wire real player count (option C) via mc-monitor JSON if wanted
+```
diff --git a/tooling/fetch-fonts.sh b/tooling/fetch-fonts.sh
new file mode 100755
index 0000000..ff74dcf
--- /dev/null
+++ b/tooling/fetch-fonts.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Vendor the landing-page web fonts locally so the apex site works on an
+# offline LAN (no Google Fonts CDN at party time).
+#
+# Pulls woff2 + a rewritten @font-face stylesheet from Google Fonts into
+# landing/public/fonts/. Re-run only when the font set changes.
+#
+# Halts on any error (cautious-by-default).
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+out="$here/landing/public/fonts"
+mkdir -p "$out"
+
+# Modern Chrome UA → Google returns woff2 (not ttf).
+UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
+
+# Families + weights used by the landing (keep in sync with main.css --font-*).
+CSS_URL="https://fonts.googleapis.com/css2"
+CSS_URL+="?family=Pixelify+Sans:wght@600;700"
+CSS_URL+="&family=Space+Grotesk:wght@400;500;600;700"
+CSS_URL+="&family=Press+Start+2P"
+CSS_URL+="&family=Silkscreen:wght@400;700"
+CSS_URL+="&family=VT323"
+CSS_URL+="&display=swap"
+
+echo "→ fetching @font-face CSS"
+raw="$(curl -fsSL -A "$UA" "$CSS_URL")"
+
+echo "→ downloading woff2 files"
+# Pull every gstatic woff2 URL, download to out/, dedupe by basename.
+echo "$raw" | grep -oE 'https://fonts\.gstatic\.com/[^)]+\.woff2' | sort -u | while read -r url; do
+ fn="$(basename "$url")"
+ if [ ! -f "$out/$fn" ]; then
+ curl -fsSL -o "$out/$fn" "$url"
+ echo " $fn"
+ fi
+done
+
+echo "→ writing fonts.css (URLs rewritten to /fonts/…)"
+# Rewrite absolute gstatic URLs to local /fonts/.
+echo "$raw" | sed -E 's#https://fonts\.gstatic\.com/[^)]+/([^)/]+\.woff2)#/fonts/\1#g' > "$out/fonts.css"
+
+echo "✓ fonts vendored into $out"