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>
BIN
landing/design/.thumbnail
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
22
landing/design/Ulicraft Landing.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-mood="grass" data-hero="centered" data-head="pixelify">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ulicraft — Java Survival SMP</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400;500;600;700&family=Press+Start+2P&family=Silkscreen:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&family=VT323&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
434
landing/design/app.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="creeper" aria-hidden="true">
|
||||||
|
{cells.map((c, i) => <i key={i} className={c === "1" ? "f" : ""} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="picon" aria-hidden="true">
|
||||||
|
{cells.map((c, i) => (
|
||||||
|
<i key={i} className={c === "1" ? (gold ? "go" : "on") : ""} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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 (
|
||||||
|
<div className="ip-chip">
|
||||||
|
<span className="ip-val"><span className="pin">▸</span>{ip}</span>
|
||||||
|
<button className={copied ? "copied" : ""} onClick={onCopy}>
|
||||||
|
{copied ? "Copied!" : "Copy IP"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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 (
|
||||||
|
<div className="dust" aria-hidden="true">
|
||||||
|
{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 <i key={i} style={st} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- server-list status panel ---------- */
|
||||||
|
function ServerListPanel({ players, max, version }) {
|
||||||
|
return (
|
||||||
|
<div className="serverlist">
|
||||||
|
<div className="icon"><Creeper /></div>
|
||||||
|
<div className="meta">
|
||||||
|
<div className="row1">
|
||||||
|
<span className="title">Ulicraft</span>
|
||||||
|
<span className="ver">Java {version}</span>
|
||||||
|
</div>
|
||||||
|
<p className="motd">
|
||||||
|
<span className="a">⛏ Season 3</span> · Survival SMP · <span className="g">whitelist open</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<div className="players">
|
||||||
|
<span className="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
|
||||||
|
<span><b>{players}</b>/{max}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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 <html>
|
||||||
|
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 */}
|
||||||
|
<header className="nav">
|
||||||
|
<div className="wrap nav-in">
|
||||||
|
<a className="brand" href="#top">
|
||||||
|
<Creeper />
|
||||||
|
<span className="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav className="nav-links">
|
||||||
|
<a href="#status">Status</a>
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#join">How to Join</a>
|
||||||
|
</nav>
|
||||||
|
<span className="nav-spacer" />
|
||||||
|
<a className="mc-btn primary sm" href="#join">Play Now</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* HERO */}
|
||||||
|
<main id="top">
|
||||||
|
<section className="hero" data-screen-label="Hero">
|
||||||
|
<Dust on={t.particles} />
|
||||||
|
<div className="wrap hero-grid">
|
||||||
|
<div className="hero-logo">
|
||||||
|
<img src="assets/ulicraft-logo.png" alt="Ulicraft" width="707" height="148" />
|
||||||
|
</div>
|
||||||
|
<div className="hero-copy">
|
||||||
|
<span className="eyebrow">Java Edition · Survival SMP · Season 3</span>
|
||||||
|
<h1 className="hero-tagline">{t.tagline}</h1>
|
||||||
|
<p className="hero-sub">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="hero-ip-row">
|
||||||
|
<IpChip ip={t.serverIp} />
|
||||||
|
<a className="mc-btn" href="#join">How to Join</a>
|
||||||
|
</div>
|
||||||
|
<div className="hero-meta">
|
||||||
|
<span className="live-pill">
|
||||||
|
<span className="live-dot" />
|
||||||
|
<span><b>{players}</b> playing now</span>
|
||||||
|
</span>
|
||||||
|
<span>Java {version}</span>
|
||||||
|
<span className="dot" />
|
||||||
|
<span>No mods required</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-status">
|
||||||
|
<ServerListPanel players={players} max={100} version={version} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* STATUS */}
|
||||||
|
<section id="status" className="pad" data-screen-label="Status">
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="sec-head reveal">
|
||||||
|
<span className="eyebrow">Server Status</span>
|
||||||
|
<h2 className="section-title">Live, and waiting for you.</h2>
|
||||||
|
<p className="lead">This is exactly what you'll see in your multiplayer list.</p>
|
||||||
|
</div>
|
||||||
|
<div className="reveal">
|
||||||
|
<ServerListPanel players={players} max={100} version={version} />
|
||||||
|
</div>
|
||||||
|
<div className="stat-grid">
|
||||||
|
{[
|
||||||
|
{ k: <>{players}<span className="u">/100</span></>, l: "Players Online" },
|
||||||
|
{ k: <>{peak}</>, l: "Peak Today" },
|
||||||
|
{ k: <>99.9<span className="u">%</span></>, l: "Uptime" },
|
||||||
|
{ k: <>20.0<span className="u"> tps</span></>, l: "Performance" },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div className="tile reveal" key={i} style={{ transitionDelay: i * 60 + "ms" }}>
|
||||||
|
<div className="k">{s.k}</div>
|
||||||
|
<div className="l">{s.l}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FEATURES */}
|
||||||
|
<section id="features" className="pad" data-screen-label="Features" style={{ background: "var(--bg-2)" }}>
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="sec-head reveal">
|
||||||
|
<span className="eyebrow">What makes it Ulicraft</span>
|
||||||
|
<h2 className="section-title">Built for players who stay.</h2>
|
||||||
|
<p className="lead">No reset roulette, no whales buying god gear. Just a server that respects your time.</p>
|
||||||
|
</div>
|
||||||
|
<div className="feat-grid">
|
||||||
|
{FEATURES.map((f, i) => (
|
||||||
|
<div className="feat reveal" key={i} style={{ transitionDelay: (i % 2) * 80 + "ms" }}>
|
||||||
|
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
||||||
|
<div>
|
||||||
|
<h3>{f.h}</h3>
|
||||||
|
<p>{f.p}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* HOW TO JOIN */}
|
||||||
|
<section id="join" className="pad" data-screen-label="How to Join">
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="sec-head reveal">
|
||||||
|
<span className="eyebrow">How to Join · 3 Steps</span>
|
||||||
|
<h2 className="section-title">From zero to spawning in.</h2>
|
||||||
|
<p className="lead">Ulicraft runs on Minecraft: Java Edition. Here's the whole path, start to finish.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="steps">
|
||||||
|
{/* STEP 1 */}
|
||||||
|
<div className="step reveal">
|
||||||
|
<div className="num">1</div>
|
||||||
|
<div className="body">
|
||||||
|
<span className="kicker">Account</span>
|
||||||
|
<h3>Get Minecraft: Java Edition</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="actions">
|
||||||
|
<a className="mc-btn gold sm" href="https://www.minecraft.net/get-minecraft" target="_blank" rel="noopener">Get Java Edition →</a>
|
||||||
|
<span className="hint">Microsoft account required</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STEP 2 */}
|
||||||
|
<div className="step reveal">
|
||||||
|
<div className="num">2</div>
|
||||||
|
<div className="body">
|
||||||
|
<span className="kicker">Launcher</span>
|
||||||
|
<h3>Download & set up the launcher</h3>
|
||||||
|
<p>
|
||||||
|
Install the official Minecraft Launcher, sign in with your Microsoft account, and select
|
||||||
|
the <strong style={{ color: "var(--text)" }}>Latest Release ({version})</strong> profile.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Download the launcher for Windows, macOS, or Linux.</li>
|
||||||
|
<li>Sign in → pick <strong style={{ color: "var(--text)" }}>Latest Release</strong> → press <strong style={{ color: "var(--text)" }}>Play</strong> once to finish installing.</li>
|
||||||
|
<li>Optional: allocate 4–6 GB RAM under Installations → More Options for smoother play.</li>
|
||||||
|
</ul>
|
||||||
|
<div className="actions">
|
||||||
|
<a className="mc-btn sm" href="https://www.minecraft.net/download" target="_blank" rel="noopener">Download launcher →</a>
|
||||||
|
<span className="hint">No mods or modpack needed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STEP 3 */}
|
||||||
|
<div className="step reveal">
|
||||||
|
<div className="num">3</div>
|
||||||
|
<div className="body">
|
||||||
|
<span className="kicker">Connect</span>
|
||||||
|
<h3>Join the Ulicraft server</h3>
|
||||||
|
<p>
|
||||||
|
In Minecraft, open <kbd>Multiplayer</kbd> → <kbd>Add Server</kbd>. Name it Ulicraft,
|
||||||
|
paste the address below, hit <kbd>Done</kbd>, then double-click the server to join.
|
||||||
|
</p>
|
||||||
|
<div className="actions" style={{ marginBottom: "14px" }}>
|
||||||
|
<IpChip ip={t.serverIp} />
|
||||||
|
</div>
|
||||||
|
<span className="hint">Whitelisted server — apply in our Discord first to get added.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="wrap footer-in">
|
||||||
|
<a className="brand" href="#top">
|
||||||
|
<Creeper />
|
||||||
|
<span className="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div className="footer-links">
|
||||||
|
<a className="mc-btn primary sm" href="#join">Copy IP & Play</a>
|
||||||
|
<a className="mc-btn sm" href="#" onClick={(e) => e.preventDefault()}>Discord</a>
|
||||||
|
</div>
|
||||||
|
<p className="disc">
|
||||||
|
Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.
|
||||||
|
© 2026 Ulicraft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* TWEAKS */}
|
||||||
|
<TweaksPanel title="Tweaks">
|
||||||
|
<TweakSection label="Layout" />
|
||||||
|
<TweakRadio label="Hero" value={t.heroLayout}
|
||||||
|
options={[{ value: "centered", label: "Centered" }, { value: "split", label: "Split" }, { value: "spotlight", label: "Spotlight" }]}
|
||||||
|
onChange={(v) => setTweak("heroLayout", v)} />
|
||||||
|
<TweakSection label="Mood" />
|
||||||
|
<TweakRadio label="Palette" value={t.mood}
|
||||||
|
options={[{ value: "grass", label: "Grass" }, { value: "nether", label: "Nether" }, { value: "end", label: "End" }]}
|
||||||
|
onChange={(v) => setTweak("mood", v)} />
|
||||||
|
<TweakSection label="Type" />
|
||||||
|
<TweakSelect label="Headline font" value={t.headFont}
|
||||||
|
options={[{ value: "pixelify", label: "Pixelify (readable)" }, { value: "8bit", label: "Press Start (8-bit)" }, { value: "silkscreen", label: "Silkscreen" }]}
|
||||||
|
onChange={(v) => setTweak("headFont", v)} />
|
||||||
|
<TweakSection label="Content" />
|
||||||
|
<TweakText label="Tagline" value={t.tagline} onChange={(v) => setTweak("tagline", v)} />
|
||||||
|
<TweakText label="Server IP" value={t.serverIp} onChange={(v) => setTweak("serverIp", v)} />
|
||||||
|
<TweakToggle label="Floating dust" value={t.particles} onChange={(v) => setTweak("particles", v)} />
|
||||||
|
</TweaksPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||||
BIN
landing/design/assets/ulicraft-logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
landing/design/screenshots/01-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/01-s.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/01-sec.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
landing/design/screenshots/01-sec2.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
landing/design/screenshots/01-v.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
landing/design/screenshots/02-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/02-s.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/02-sec.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/02-sec2.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/02-v.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
landing/design/screenshots/03-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/03-s.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
landing/design/screenshots/03-sec.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/03-sec2.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
landing/design/screenshots/03-v.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
landing/design/screenshots/04-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/design/screenshots/04-s.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
landing/design/screenshots/04-sec.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/04-sec2.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/04-v.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
landing/design/screenshots/05-s.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
landing/design/screenshots/06-s.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
landing/design/screenshots/hero-hq.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
579
landing/design/styles.css
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
/* ============================================================
|
||||||
|
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); }
|
||||||
|
|
||||||
|
/* ---- 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; }
|
||||||
|
}
|
||||||
541
landing/design/tweaks-panel.jsx
Normal file
@@ -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 <input type="range">, 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 (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakColor label="Palette" value={t.palette}
|
||||||
|
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||||
|
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||||
|
// onChange={(v) => setTweak('palette', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// <select> 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 <TweakSelect label={label} value={value} options={options}
|
||||||
|
onChange={(s) => 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 (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }) => (
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Native <input type=color> 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 (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div className="twk-chips" role="radiogroup">
|
||||||
|
{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 (
|
||||||
|
<button key={i} type="button" className="twk-chip" role="radio"
|
||||||
|
aria-checked={on} data-on={on ? '1' : '0'}
|
||||||
|
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||||
|
style={{ background: hero }}
|
||||||
|
onClick={() => onChange(o)}>
|
||||||
|
{sup.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
BIN
landing/design/uploads/logo-1780868316271.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2
Normal file
BIN
landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2
Normal file
BIN
landing/public/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2
Normal file
BIN
landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2
Normal file
BIN
landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2
Normal file
BIN
landing/public/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2
Normal file
BIN
landing/public/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2
Normal file
270
landing/public/fonts/fonts.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
BIN
landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2
Normal file
BIN
landing/public/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2
Normal file
BIN
landing/public/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2
Normal file
BIN
landing/public/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2
Normal file
BIN
landing/public/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2
Normal file
BIN
landing/public/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2
Normal file
BIN
landing/public/fonts/pxiKyp0ihIEF2isfFJU.woff2
Normal file
17
landing/src/components/CopyChip.astro
Normal 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>
|
||||||
17
landing/src/components/Creeper.astro
Normal 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>
|
||||||
20
landing/src/components/PixelIcon.astro
Normal 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>
|
||||||
31
landing/src/components/ServerListPanel.astro
Normal 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>
|
||||||
@@ -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.
|
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "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 = {
|
export const site = {
|
||||||
name: "Ulicraft",
|
name: "Ulicraft",
|
||||||
tagline: "LAN-party Minecraft server",
|
|
||||||
baseDomain: BASE_DOMAIN,
|
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
|
// Connect target shown to guests. No port needed — a dnsmasq SRV record
|
||||||
// (_minecraft._tcp.mc.<domain>) points clients at 25565.
|
// (_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.
|
// Caddy local CA root — guests import this to trust the offline asset mirror.
|
||||||
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
|
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
|
// 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: {
|
launcher: {
|
||||||
name: "FjordLauncherUnlocked",
|
name: "Fjord Launcher",
|
||||||
base: "/launcher/latest",
|
base: "/launcher/latest",
|
||||||
downloads: [
|
downloads: [
|
||||||
{ os: "Windows", file: "fjord-windows-setup.exe", hint: "x64 installer" },
|
{ os: "Windows", file: "fjord-windows-setup.exe", hint: "x64 installer" },
|
||||||
@@ -33,3 +75,18 @@ export const site = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as const;
|
} 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
@@ -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: "~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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
317
landing/src/pages/[...lang].astro
Normal 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>
|
||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -2,32 +2,92 @@
|
|||||||
|
|
||||||
## Summary
|
## 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 +
|
download launcher → add account → import modpack → connect. Built with **Astro +
|
||||||
TypeScript** in its own `./landing/` project; `npm run build` emits straight into
|
TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`,
|
||||||
`./www/`, which Caddy already serves at the apex domain. No runtime JS framework —
|
which Caddy/nginx serve at the apex. No runtime framework — output is plain
|
||||||
output is plain HTML/CSS plus one tiny copy-to-clipboard script.
|
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
|
## 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
|
- `astro.config.mjs` → `outDir: "../www"`. Build overwrites `www/`; `www/` is
|
||||||
gitignored (generated artifact).
|
gitignored (generated artifact).
|
||||||
- `BASE_DOMAIN` env read at build time in `src/data/site.ts`
|
- `BASE_DOMAIN` env read at build time in `src/data/site.ts`
|
||||||
(`process.env.BASE_DOMAIN ?? "ulicraft.local"`). Bake before deploy:
|
(`process.env.BASE_DOMAIN ?? "ulicraft.local"`). Bake before deploy:
|
||||||
`BASE_DOMAIN=ulicraft.local pnpm run build`.
|
`BASE_DOMAIN=ulicraft.local pnpm run build`.
|
||||||
- Logo: drop PNG at `landing/public/logo.png` → copied to `www/logo.png`,
|
- Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon).
|
||||||
referenced as `/logo.png` (favicon + hero). `image-rendering: pixelated` keeps
|
`image-rendering: pixelated` keeps it crisp.
|
||||||
the stone 3D logo 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).
|
## Fonts — vendored for offline LAN
|
||||||
- Step 1: launcher downloads — 3 OS buttons → `/launcher/latest/<stable>`.
|
|
||||||
- Step 2: add authlib-injector account, URL `http://auth.${BASE_DOMAIN}/authlib-injector`,
|
The design pulled fonts from Google Fonts CDN; that breaks on offline LAN day.
|
||||||
register link to `http://auth.${BASE_DOMAIN}`.
|
`tooling/fetch-fonts.sh` downloads the woff2 + a URL-rewritten `fonts.css` into
|
||||||
- Step 3: import packwiz `http://packwiz.${BASE_DOMAIN}/pack.toml`.
|
`landing/public/fonts/`. `index.astro` links `/fonts/fonts.css`. Re-run the
|
||||||
- Step 4: connect to server address.
|
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 `<html data-mood data-hero data-head>` + `--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. `<html lang>` + `<link rel="alternate" hreflang>` 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)
|
## 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
|
`tooling/fetch-launcher.sh` must create these as symlinks under
|
||||||
`mirror/launcher/latest/` pointing at the real versioned assets. **Keep the names
|
`mirror/launcher/latest/` pointing at the real versioned assets. **Keep the names
|
||||||
in sync** between `site.ts` and the script. Caddy mount
|
in sync** between `site.ts` and the script. Mounted so they resolve at
|
||||||
`./mirror/launcher:/srv/www/launcher:ro` makes them resolve at `/launcher/latest/…`.
|
`/launcher/latest/…` (see nginx/Caddy ingress).
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] User: place logo at `landing/public/logo.png`
|
- [x] Vendor fonts (`tooling/fetch-fonts.sh`) → `public/fonts/`
|
||||||
- [ ] `cd landing && pnpm install`
|
- [x] Port design → static Astro (components + main.css + index.astro)
|
||||||
- [ ] `BASE_DOMAIN=ulicraft.local pnpm run build` → verify `www/index.html` + `www/logo.png`
|
- [x] Real content in `site.ts`; theme knobs replace TweaksPanel
|
||||||
- [ ] Extend `fetch-launcher.sh`: after download, symlink stable names → versioned files
|
- [x] i18n: en/es/eu via `[...lang].astro` + `i18n/ui.ts`; nav language switcher
|
||||||
(resolve which asset = win setup / mac dmg / linux AppImage)
|
- [x] `BASE_DOMAIN=ulicraft.local pnpm run build` → verified `www/` output + screenshot
|
||||||
- [ ] Confirm Caddy serves apex from `./www` and `/launcher/latest/*` from mirror
|
- [ ] Extend `fetch-launcher.sh`: symlink stable names → versioned files
|
||||||
- [ ] Optional: pin launcher tag instead of `latest` for party day
|
- [ ] Confirm ingress serves apex from `./www` and `/launcher/latest/*` from mirror
|
||||||
- [ ] Optional: add a "what's in the pack" section once mod list firms up
|
- [ ] Optional: try `mood: nether` / `hero: split` for party day
|
||||||
|
- [ ] Optional: wire real player count (option C) via mc-monitor JSON if wanted
|
||||||
|
```
|
||||||
|
|||||||
44
tooling/fetch-fonts.sh
Executable file
@@ -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/<basename>.
|
||||||
|
echo "$raw" | sed -E 's#https://fonts\.gstatic\.com/[^)]+/([^)/]+\.woff2)#/fonts/\1#g' > "$out/fonts.css"
|
||||||
|
|
||||||
|
echo "✓ fonts vendored into $out"
|
||||||