feat(landing): pixel design ported to static Astro + en/es/eu i18n

Adopt the "Carved from stone" pixel design (landing/design/) as the apex
onboarding page, ported from its React/Babel-via-CDN prototype to static
Astro so it works on an offline LAN. Real modded-LAN content replaces the
prototype's fictional public-SMP copy.

- Vendor web fonts locally (tooling/fetch-fonts.sh -> public/fonts/) so the
  page renders without the Google Fonts CDN at party time.
- Port the design system (main.css: mood/hero/bevels) and split markup into
  Astro components (Creeper, PixelIcon, CopyChip, ServerListPanel).
- site.ts holds language-neutral config + theme knobs (mood/hero/headFont/
  dust) that replace the design's in-browser TweaksPanel; LITERALS holds
  never-translated product/in-game terms.
- i18n: English (/), Spanish (/es/), Euskera (/eu/) generated from one
  [...lang].astro via getStaticPaths; copy lives in src/i18n/ui.ts. Nav has
  an EN/ES/EU switcher; html lang + hreflang set per page.
- Status panel shows a static server-list row + honest stat tiles (no fake
  live count). Copy + scroll-reveal are the only client JS.

Build emits to ../www (gitignored). Euskera strings pending a native review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 18:18:36 +02:00
parent 5cec2993d7
commit 3d52951355
60 changed files with 3388 additions and 228 deletions

View File

@@ -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;
---
<div class:list={["ip-chip", variant === "url" && "url"]}>
<span class="ip-val"><span class="pin">▸</span>{value}</span>
<button type="button" data-copy={value} data-label={label} data-copied={copiedLabel}>{label}</button>
</div>

View File

@@ -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("");
---
<div class="creeper" aria-hidden="true">
{cells.map((c) => <i class={c === "1" ? "f" : undefined} />)}
</div>

View File

@@ -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<Props["glyph"], string[]> = {
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";
---
<div class="picon" aria-hidden="true">
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
</div>

View File

@@ -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;
---
<div class="serverlist">
<div class="icon"><Creeper /></div>
<div class="meta">
<div class="row1">
<span class="title">{site.name}</span>
<span class="ver">Java {site.mcVersion}</span>
</div>
<p class="motd">
<span class="a">⛏ {modded}</span> · {motd}
</p>
</div>
<div class="stat">
<div class="players">
<span class="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
<span>{upTo} <b>{status.slots}</b></span>
</div>
</div>
</div>

View File

@@ -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.<domain>) 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<string, string> = {
pixelify: "'Pixelify Sans', system-ui, sans-serif",
"8bit": "'Press Start 2P', monospace",
silkscreen: "'Silkscreen', system-ui, sans-serif",
};

340
landing/src/i18n/ui.ts Normal file
View File

@@ -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<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
// URL path per locale (en is default, served at root).
export const LANG_PATH: Record<Lang, string> = { 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<Lang, UI> = {
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: "~50100 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: "~50100 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 ~50100 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.",
},
},
};

View File

@@ -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`,
};
});
---
<!doctype html>
<html
lang={t.htmlLang}
data-mood={theme.mood}
data-hero={theme.hero}
data-head={theme.headFont}
style={`--font-head: ${headFont}`}
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {t.hero.howToJoin}</title>
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
</head>
<body>
<!-- NAV -->
<header class="nav">
<div class="wrap nav-in">
<a class="brand" href="#top">
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<nav class="nav-links">
<a href="#status">{t.nav.status}</a>
<a href="#features">{t.nav.features}</a>
<a href="#join">{t.nav.join}</a>
</nav>
<span class="nav-spacer"></span>
<div class="lang-switch" aria-label="Language">
{LANGS.map((l) => (
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
))}
</div>
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
</div>
</header>
<main id="top">
<!-- HERO -->
<section class="hero">
{theme.dust && (
<div class="dust" aria-hidden="true">
{dustBits.map((b) => (
<i
style={`position:absolute;left:${b.left};bottom:-20px;width:${b.size};height:${b.size};background:${b.bg};opacity:${b.opacity};image-rendering:pixelated;animation:${b.anim}`}
/>
))}
</div>
)}
<div class="wrap hero-grid">
<div class="hero-logo">
<img src="/logo.png" alt={site.name} width="707" height="148" />
</div>
<div class="hero-copy">
<span class="eyebrow">{t.hero.eyebrow} {site.mcVersion}</span>
<h1 class="hero-tagline">{t.hero.tagline}</h1>
<p class="hero-sub">{t.hero.sub}</p>
<div class="hero-ip-row">
<CopyChip value={site.serverAddress} variant="ip" label={t.hero.copyIp} copiedLabel={t.copied} />
<a class="mc-btn" href="#join">{t.hero.howToJoin}</a>
</div>
<div class="hero-meta">
<span>Java {site.mcVersion}</span>
<span class="dot"></span>
<span>{t.hero.metaModpack}</span>
<span class="dot"></span>
<span>{t.hero.metaLan}</span>
</div>
</div>
<div class="hero-status">
<ServerListPanel
modded={t.status.modded}
motd={t.status.motd}
upTo={t.status.upTo}
/>
</div>
</div>
</section>
<!-- STATUS -->
<section id="status" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.status.eyebrow}</span>
<h2 class="section-title">{t.status.title}</h2>
<p class="lead">{t.status.lead}</p>
</div>
<div class="reveal">
<ServerListPanel
modded={t.status.modded}
motd={t.status.motd}
upTo={t.status.upTo}
/>
</div>
<div class="stat-grid">
{site.stats.map((s, i) => (
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
<div class="k">{s.key}</div>
<div class="l">{t.statLabels[i]}</div>
</div>
))}
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" class="pad" style="background: var(--bg-2)">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.features.eyebrow}</span>
<h2 class="section-title">{t.features.title}</h2>
<p class="lead">{t.features.lead}</p>
</div>
<div class="feat-grid">
{site.features.map((f, i) => (
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
<PixelIcon glyph={f.glyph} gold={f.gold} />
<div>
<h3>{t.features.items[i].h}</h3>
<p>{t.features.items[i].p}</p>
</div>
</div>
))}
</div>
</div>
</section>
<!-- HOW TO JOIN -->
<section id="join" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.join.eyebrow}</span>
<h2 class="section-title">{t.join.title}</h2>
<p class="lead">{t.join.lead}</p>
</div>
<div class="steps">
<!-- STEP 1 -->
<div class="step reveal">
<div class="num">1</div>
<div class="body">
<span class="kicker">{t.join.s1.kicker}</span>
<h3>{t.join.s1.h.replace("{name}", launcherName)}</h3>
<p>{t.join.s1.p.replace(/\{name\}/g, launcherName)}</p>
<div class="actions">
{launcher.downloads.map((d) => (
<a class="mc-btn sm" href={`${launcher.base}/${d.file}`}>
{d.os} <span class="hint">· {d.hint}</span>
</a>
))}
</div>
</div>
</div>
<!-- STEP 2 -->
<div class="step reveal">
<div class="num">2</div>
<div class="body">
<span class="kicker">{t.join.s2.kicker}</span>
<h3>{t.join.s2.h}</h3>
<p>
{t.join.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {t.join.s2.pb}
</p>
<div class="actions" style="margin-bottom:14px">
<CopyChip value={site.authlibUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
</div>
<p>
{t.join.s2.registerPre} <a href={site.authUrl}>{site.authUrl}</a>.
</p>
<span class="hint">
{t.join.s2.caPre} <a href={site.caCertUrl}>{t.join.s2.caLink}</a> {t.join.s2.caPost}
</span>
</div>
</div>
<!-- STEP 3 -->
<div class="step reveal">
<div class="num">3</div>
<div class="body">
<span class="kicker">{t.join.s3.kicker}</span>
<h3>{t.join.s3.h}</h3>
<p>{t.join.s3.p}</p>
<div class="actions">
<CopyChip value={site.packwizUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
</div>
</div>
</div>
<!-- STEP 4 -->
<div class="step reveal">
<div class="num">4</div>
<div class="body">
<span class="kicker">{t.join.s4.kicker}</span>
<h3>{t.join.s4.h}</h3>
<p>
{t.join.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{t.join.s4.pb}
</p>
<div class="actions">
<CopyChip value={site.serverAddress} variant="ip" label={t.join.s4.copy} copiedLabel={t.copied} />
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- FOOTER -->
<footer class="footer">
<div class="wrap footer-in">
<a class="brand" href="#top">
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<div class="footer-links">
<a class="mc-btn primary sm" href="#join">{t.footer.join}</a>
</div>
<p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain}
</p>
</div>
</footer>
<script>
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
function copyText(text: string) {
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
return;
}
} catch {}
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 {}
document.body.removeChild(ta);
}
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
let timer: number | undefined;
btn.addEventListener("click", () => {
copyText(btn.dataset.copy ?? "");
const label = btn.dataset.label ?? "Copy";
btn.textContent = btn.dataset.copied ?? "Copied!";
btn.classList.add("copied");
clearTimeout(timer);
timer = window.setTimeout(() => {
btn.textContent = label;
btn.classList.remove("copied");
}, 1600);
});
});
// Scroll reveal — visible at rest; .anim added when scrolled into view.
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add("anim");
io.unobserve(e.target);
}
}
},
{ rootMargin: "0px 0px -10% 0px" }
);
els.forEach((el) => io.observe(el));
}
</script>
</body>
</html>

View File

@@ -1,199 +0,0 @@
---
import { site } from "../data/site";
const { launcher } = site;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — join the server</title>
<meta name="description" content={`${site.name}: ${site.tagline}`} />
<link rel="icon" type="image/png" href="/logo.png" />
</head>
<body>
<main>
<header class="hero">
<img class="logo" src="/logo.png" alt={`${site.name} logo`} width="128" height="128" />
<h1>{site.name}</h1>
<p class="tagline">{site.tagline}</p>
<p class="addr">
<span class="addr-label">Server</span>
<code id="server-addr">{site.serverAddress}</code>
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
</p>
</header>
<ol class="steps">
<li>
<h2><span class="num">1</span> Download the launcher</h2>
<p>Grab <strong>{launcher.name}</strong> for your system.</p>
<div class="downloads">
{launcher.downloads.map((d) => (
<a class="dl" href={`${launcher.base}/${d.file}`}>
<span class="dl-os">{d.os}</span>
<span class="dl-hint">{d.hint}</span>
</a>
))}
</div>
</li>
<li>
<h2><span class="num">2</span> Add your account</h2>
<p>In the launcher, add an <strong>authlib-injector</strong> account using this URL:</p>
<p>
<code>{site.authlibUrl}</code>
<button class="copy" data-copy={site.authlibUrl} type="button">Copy</button>
</p>
<p>
Need credentials? Register at <a href={site.authUrl}>{site.authUrl}</a>, then log in
with that username and password.
</p>
<p class="note">
Offline party? Download and trust the
<a href={site.caCertUrl}>local CA certificate</a> so the game-file
mirror works over HTTPS.
</p>
</li>
<li>
<h2><span class="num">3</span> Import the modpack</h2>
<p>Add a new instance from this packwiz URL:</p>
<p>
<code>{site.packwizUrl}</code>
<button class="copy" data-copy={site.packwizUrl} type="button">Copy</button>
</p>
</li>
<li>
<h2><span class="num">4</span> Connect</h2>
<p>Launch the instance, add a server, and join:</p>
<p>
<code>{site.serverAddress}</code>
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
</p>
</li>
</ol>
<footer>
<p>{site.name} · {site.baseDomain}</p>
</footer>
</main>
<script>
// Copy-to-clipboard for every [data-copy] button.
document.querySelectorAll<HTMLButtonElement>("button.copy").forEach((btn) => {
btn.addEventListener("click", async () => {
const text = btn.dataset.copy ?? "";
try {
await navigator.clipboard.writeText(text);
const prev = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => (btn.textContent = prev), 1200);
} catch {
btn.textContent = "Copy failed";
}
});
});
</script>
<style>
:root {
--stone: #7a7a7a;
--bg: #f4f4f2;
--card: #ffffff;
--ink: #1c1c1c;
--muted: #6b6b6b;
--line: #e2e2df;
--accent: #4f7942;
}
* { box-sizing: border-box; }
html { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
line-height: 1.55;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.25rem 4rem;
}
.hero { text-align: center; margin-bottom: 2.5rem; }
.logo {
image-rendering: pixelated;
border-radius: 12px;
margin-bottom: 0.5rem;
}
h1 { font-size: 2.4rem; margin: 0.25rem 0 0; letter-spacing: -0.5px; }
.tagline { color: var(--muted); margin: 0.25rem 0 1.25rem; }
.addr {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.addr-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
}
code {
background: #ededea;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.2rem 0.5rem;
font-size: 0.95rem;
word-break: break-all;
}
.copy {
border: 1px solid var(--line);
background: var(--card);
border-radius: 6px;
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
}
.copy:hover { border-color: var(--stone); }
.steps { list-style: none; padding: 0; margin: 0; display: grid; gap: 1rem; }
.steps > li {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1.25rem 1.5rem;
}
.steps h2 { font-size: 1.2rem; margin: 0 0 0.5rem; display: flex; align-items: center; gap: 0.6rem; }
.num {
display: inline-grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 0.95rem;
}
.steps p { margin: 0.4rem 0; }
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-top: 0.75rem; }
.dl {
display: flex;
flex-direction: column;
text-decoration: none;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.8rem 1rem;
}
.dl:hover { border-color: var(--accent); }
.dl-os { font-weight: 600; }
.dl-hint { font-size: 0.8rem; color: var(--muted); }
footer { text-align: center; color: var(--muted); font-size: 0.85rem; margin-top: 2.5rem; }
a { color: var(--accent); }
</style>
</body>
</html>

608
landing/src/styles/main.css Normal file
View File

@@ -0,0 +1,608 @@
/* ============================================================
ULICRAFT — "Carved from stone"
Design system: dark overworld, MC-GUI bevels, pixel type
============================================================ */
/* ---- Mood palettes (data-mood on <html>) ---- */
: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; }
}