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 |