This commit is contained in:
st5 committed 2026-04-04 11:32:43 -05:00
commit 649574ec11
11 files changed
+1739

No files matched your search

+562
View File
@@ -0,0 +1,562 @@
const STORAGE_KEY = "levelup-typing-save-v1";
const WORD_BANKS = {
easy: [
"apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn",
"typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper"
],
normal: [
"speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade",
"streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum"
],
hard: [
"architect", "backspace", "fragment", "notation", "velocity", "parallel", "hexadecimal", "interface", "algorithm", "synchronize",
"compilation", "resolution", "parameter", "validation", "hyperlink", "generator", "trajectory", "oscillation", "framework", "benchmark"
],
expert: [
"extraordinary", "counterclockwise", "asymptotically", "deconstruction", "characteristically", "electromagnetic", "microarchitecture", "misconfiguration", "representational", "interoperability",
"cryptographically", "institutionalize", "uncharacteristic", "transcontinental", "hyperparameter", "instrumentation", "multiprocessing", "disproportionate", "comprehensibility", "photosynthesis"
]
};
const DIFFICULTY = {
easy: { label: "easy", xpMult: 0.9, roundWords: 38 },
normal: { label: "normal", xpMult: 1, roundWords: 45 },
hard: { label: "hard", xpMult: 1.25, roundWords: 50 },
expert: { label: "expert", xpMult: 1.5, roundWords: 54 }
};
const PERKS = [
{
id: "combo-boost",
name: "combo boost",
unlockLevel: 2,
cost: 1,
description: "Earn 15% more XP while your best combo is 5 or higher."
},
{
id: "accuracy-bounty",
name: "accuracy bounty",
unlockLevel: 4,
cost: 2,
description: "If you finish with 96%+ accuracy, gain +30 bonus XP."
},
{
id: "time-warp",
name: "time warp",
unlockLevel: 6,
cost: 2,
description: "Start rounds with +8 seconds for easier high-streak runs."
},
{
id: "focus-engine",
name: "focus engine",
unlockLevel: 8,
cost: 3,
description: "At 98%+ accuracy, your WPM contributes an extra 25% XP."
}
];
const defaultState = {
profileName: "rookie typer",
difficulty: "normal",
words: [],
index: 0,
typedChars: 0,
correctWords: 0,
wrongWords: 0,
combo: 0,
bestCombo: 0,
timer: null,
elapsedSeconds: 0,
roundSeconds: 60,
running: false,
xp: 0,
level: 1,
shards: 0,
lastRoundXp: 0,
ownedPerks: [],
stats: {
totalRounds: 0,
bestWpm: 0,
bestAccuracy: 0,
averageWpm: 0,
averageAccuracy: 0
},
recentRounds: []
};
const state = { ...defaultState };
const els = {
textDisplay: document.getElementById("textDisplay"),
typeInput: document.getElementById("typeInput"),
newRoundBtn: document.getElementById("newRoundBtn"),
settingsBtn: document.getElementById("settingsBtn"),
closeSettingsBtn: document.getElementById("closeSettingsBtn"),
settingsModal: document.getElementById("settingsModal"),
resetConfirmModal: document.getElementById("resetConfirmModal"),
cancelResetBtn: document.getElementById("cancelResetBtn"),
confirmResetBtn: document.getElementById("confirmResetBtn"),
playAgainBtn: document.getElementById("playAgainBtn"),
results: document.getElementById("results"),
resultsSummary: document.getElementById("resultsSummary"),
wpm: document.getElementById("wpm"),
accuracy: document.getElementById("accuracy"),
combo: document.getElementById("combo"),
time: document.getElementById("time"),
profileTitle: document.getElementById("profileTitle"),
levelText: document.getElementById("levelText"),
xpText: document.getElementById("xpText"),
shardText: document.getElementById("shardText"),
xpFill: document.getElementById("xpFill"),
xpGain: document.getElementById("xpGain"),
perks: document.getElementById("perks"),
profileName: document.getElementById("profileName"),
difficultySelect: document.getElementById("difficultySelect"),
difficultyBonus: document.getElementById("difficultyBonus"),
resetProgressBtn: document.getElementById("resetProgressBtn")
};
function xpNeededForLevel(level) {
return 120 + (level - 1) * 60;
}
function sanitizeDifficulty(value) {
return DIFFICULTY[value] ? value : "normal";
}
function saveProgress() {
const payload = {
profileName: state.profileName,
difficulty: state.difficulty,
xp: state.xp,
level: state.level,
shards: state.shards,
lastRoundXp: state.lastRoundXp,
ownedPerks: state.ownedPerks,
stats: state.stats,
recentRounds: state.recentRounds
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function loadProgress() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw);
state.profileName = typeof parsed.profileName === "string" && parsed.profileName.trim()
? parsed.profileName.trim().slice(0, 24)
: defaultState.profileName;
state.difficulty = sanitizeDifficulty(parsed.difficulty);
state.xp = Number.isFinite(parsed.xp) ? Math.max(0, Math.floor(parsed.xp)) : 0;
state.level = Number.isFinite(parsed.level) ? Math.max(1, Math.floor(parsed.level)) : 1;
state.shards = Number.isFinite(parsed.shards) ? Math.max(0, Math.floor(parsed.shards)) : 0;
state.lastRoundXp = Number.isFinite(parsed.lastRoundXp) ? Math.max(0, Math.floor(parsed.lastRoundXp)) : 0;
state.ownedPerks = Array.isArray(parsed.ownedPerks)
? parsed.ownedPerks.filter((id) => PERKS.some((perk) => perk.id === id))
: [];
const parsedStats = parsed.stats || {};
state.stats = {
totalRounds: Number.isFinite(parsedStats.totalRounds) ? Math.max(0, Math.floor(parsedStats.totalRounds)) : 0,
bestWpm: Number.isFinite(parsedStats.bestWpm) ? Math.max(0, Math.floor(parsedStats.bestWpm)) : 0,
bestAccuracy: Number.isFinite(parsedStats.bestAccuracy) ? Math.max(0, Math.floor(parsedStats.bestAccuracy)) : 0,
averageWpm: Number.isFinite(parsedStats.averageWpm) ? Math.max(0, Number(parsedStats.averageWpm)) : 0,
averageAccuracy: Number.isFinite(parsedStats.averageAccuracy) ? Math.max(0, Number(parsedStats.averageAccuracy)) : 0
};
state.recentRounds = Array.isArray(parsed.recentRounds)
? parsed.recentRounds.slice(0, 20)
: [];
} catch (error) {
localStorage.removeItem(STORAGE_KEY);
}
}
function trackRound(summary, earnedXp) {
state.stats.totalRounds += 1;
state.stats.bestWpm = Math.max(state.stats.bestWpm, summary.wpm);
state.stats.bestAccuracy = Math.max(state.stats.bestAccuracy, summary.accuracy);
const rounds = state.stats.totalRounds;
state.stats.averageWpm = ((state.stats.averageWpm * (rounds - 1)) + summary.wpm) / rounds;
state.stats.averageAccuracy = ((state.stats.averageAccuracy * (rounds - 1)) + summary.accuracy) / rounds;
const snapshot = {
stamp: Date.now(),
wpm: summary.wpm,
accuracy: summary.accuracy,
bestCombo: summary.bestCombo,
xp: earnedXp,
difficulty: state.difficulty
};
state.recentRounds.unshift(snapshot);
if (state.recentRounds.length > 20) {
state.recentRounds.length = 20;
}
}
function openSettings() {
els.settingsModal.hidden = false;
}
function closeSettings() {
els.settingsModal.hidden = true;
}
function openResetConfirm() {
els.resetConfirmModal.hidden = false;
}
function closeResetConfirm() {
els.resetConfirmModal.hidden = true;
}
function pickWords() {
const config = DIFFICULTY[state.difficulty];
const pool = WORD_BANKS[state.difficulty];
const result = [];
for (let i = 0; i < config.roundWords; i += 1) {
result.push(pool[Math.floor(Math.random() * pool.length)]);
}
return result;
}
function hasPerk(id) {
return state.ownedPerks.includes(id);
}
function renderWords() {
els.textDisplay.innerHTML = "";
state.words.forEach((word, idx) => {
const span = document.createElement("span");
span.className = "word";
if (idx === state.index) span.classList.add("current");
span.textContent = `${word} `;
els.textDisplay.appendChild(span);
});
}
function renderPerks() {
els.perks.innerHTML = "";
PERKS.forEach((perk) => {
const levelReady = state.level >= perk.unlockLevel;
const owned = hasPerk(perk.id);
const canBuy = levelReady && !owned && state.shards >= perk.cost;
const card = document.createElement("article");
card.className = "perk";
card.innerHTML = `
<h3>${perk.name} · Lv ${perk.unlockLevel}</h3>
<p>${perk.description}</p>
<div class="perk-actions">
<span class="perk-cost">Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}</span>
<button class="perk-buy" data-perk-id="${perk.id}" ${canBuy ? "" : "disabled"}>
${owned ? "owned" : levelReady ? "buy" : "locked"}
</button>
</div>
<span class="status ${owned ? "active" : levelReady ? "unlocked" : "locked"}">
${owned ? "active" : levelReady ? "shop open" : "locked"}
</span>
`;
els.perks.appendChild(card);
});
}
function renderSettings() {
const config = DIFFICULTY[state.difficulty];
els.profileName.value = state.profileName;
els.difficultySelect.value = state.difficulty;
els.difficultyBonus.textContent = `xp bonus: x${config.xpMult.toFixed(2)}`;
}
function renderProgress() {
const levelNeed = xpNeededForLevel(state.level);
const ratio = Math.min((state.xp / levelNeed) * 100, 100);
els.profileTitle.textContent = state.profileName;
els.levelText.textContent = `level ${state.level}`;
els.xpText.textContent = `${state.xp} / ${levelNeed} xp`;
els.shardText.textContent = `perk shards: ${state.shards}`;
els.xpFill.style.width = `${ratio}%`;
els.xpGain.textContent = `+${state.lastRoundXp} xp last round`;
renderSettings();
renderPerks();
}
function updateLiveStats() {
const elapsed = Math.max(state.elapsedSeconds / 60, 1 / 60);
const wordsTypedEstimate = state.typedChars / 5;
const wpm = Math.round(wordsTypedEstimate / elapsed);
const totalWords = state.correctWords + state.wrongWords;
const acc = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
els.wpm.textContent = String(wpm);
els.accuracy.textContent = `${acc}%`;
els.combo.textContent = `x${state.combo}`;
}
function updateTimer() {
const remaining = Math.max(state.roundSeconds - state.elapsedSeconds, 0);
els.time.textContent = `${remaining}s`;
els.time.style.color = remaining <= 10 ? "#ffd587" : "";
}
function startTimer() {
if (state.running) return;
state.running = true;
state.timer = setInterval(() => {
state.elapsedSeconds += 1;
updateTimer();
updateLiveStats();
if (state.elapsedSeconds >= state.roundSeconds) {
finishRound();
}
}, 1000);
}
function awardXp(summary) {
let xp = 35;
xp += Math.floor(summary.wpm * 1.4);
xp += Math.floor(summary.accuracy * 0.55);
xp += Math.floor(summary.bestCombo * 2.5);
if (hasPerk("combo-boost") && summary.bestCombo >= 5) {
xp = Math.floor(xp * 1.15);
}
if (hasPerk("accuracy-bounty") && summary.accuracy >= 96) {
xp += 30;
}
if (hasPerk("focus-engine") && summary.accuracy >= 98) {
xp += Math.floor(summary.wpm * 0.25);
}
xp = Math.floor(xp * DIFFICULTY[state.difficulty].xpMult);
let pending = xp;
let levelUps = 0;
while (pending > 0) {
const need = xpNeededForLevel(state.level) - state.xp;
if (pending >= need) {
pending -= need;
state.level += 1;
levelUps += 1;
state.xp = 0;
} else {
state.xp += pending;
pending = 0;
}
}
state.shards += levelUps;
state.lastRoundXp = xp;
return xp;
}
function markWordResult(correct) {
const wordNodes = els.textDisplay.querySelectorAll(".word");
const node = wordNodes[state.index];
if (!node) return;
node.classList.remove("current");
node.classList.add(correct ? "correct" : "incorrect");
}
function setCurrentWord() {
const wordNodes = els.textDisplay.querySelectorAll(".word");
const current = wordNodes[state.index];
if (current) {
current.classList.add("current");
current.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
function finishRound() {
clearInterval(state.timer);
state.running = false;
els.typeInput.disabled = true;
const elapsedMinutes = Math.max(state.elapsedSeconds / 60, 1 / 60);
const wpm = Math.round((state.typedChars / 5) / elapsedMinutes);
const totalWords = state.correctWords + state.wrongWords;
const accuracy = totalWords === 0 ? 100 : Math.round((state.correctWords / totalWords) * 100);
const summary = {
wpm,
accuracy,
bestCombo: state.bestCombo
};
const earnedXp = awardXp(summary);
trackRound(summary, earnedXp);
renderProgress();
updateLiveStats();
saveProgress();
els.results.hidden = false;
els.resultsSummary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earnedXp} xp!`;
}
function resetRound() {
clearInterval(state.timer);
state.words = pickWords();
state.index = 0;
state.typedChars = 0;
state.correctWords = 0;
state.wrongWords = 0;
state.combo = 0;
state.bestCombo = 0;
state.elapsedSeconds = 0;
state.roundSeconds = hasPerk("time-warp") ? 68 : 60;
state.running = false;
renderWords();
updateLiveStats();
updateTimer();
els.typeInput.disabled = false;
els.typeInput.value = "";
els.typeInput.focus();
els.results.hidden = true;
}
function setProfileName(value) {
const clean = value.trim().slice(0, 24);
state.profileName = clean || defaultState.profileName;
renderProgress();
saveProgress();
}
function setDifficulty(value) {
state.difficulty = sanitizeDifficulty(value);
renderProgress();
resetRound();
saveProgress();
}
function buyPerk(perkId) {
const perk = PERKS.find((item) => item.id === perkId);
if (!perk) return;
if (state.level < perk.unlockLevel) return;
if (hasPerk(perkId)) return;
if (state.shards < perk.cost) return;
state.shards -= perk.cost;
state.ownedPerks.push(perk.id);
renderProgress();
resetRound();
saveProgress();
}
function resetProgress() {
localStorage.removeItem(STORAGE_KEY);
clearInterval(state.timer);
Object.assign(state, { ...defaultState, ownedPerks: [], stats: { ...defaultState.stats }, recentRounds: [] });
renderProgress();
resetRound();
saveProgress();
}
els.typeInput.addEventListener("input", () => {
const value = els.typeInput.value;
if (!state.running && value.length > 0) {
startTimer();
}
if (!value.endsWith(" ")) return;
const typedWord = value.trim();
const targetWord = state.words[state.index];
const correct = typedWord === targetWord;
state.typedChars += typedWord.length + 1;
if (correct) {
state.correctWords += 1;
state.combo += 1;
if (state.combo > state.bestCombo) {
state.bestCombo = state.combo;
}
} else {
state.wrongWords += 1;
state.combo = 0;
}
markWordResult(correct);
state.index += 1;
els.typeInput.value = "";
if (state.index >= state.words.length) {
finishRound();
return;
}
setCurrentWord();
updateLiveStats();
});
els.newRoundBtn.addEventListener("click", resetRound);
els.playAgainBtn.addEventListener("click", resetRound);
els.settingsBtn.addEventListener("click", openSettings);
els.closeSettingsBtn.addEventListener("click", closeSettings);
els.profileName.addEventListener("change", (event) => setProfileName(event.target.value));
els.difficultySelect.addEventListener("change", (event) => setDifficulty(event.target.value));
els.resetProgressBtn.addEventListener("click", openResetConfirm);
els.cancelResetBtn.addEventListener("click", closeResetConfirm);
els.confirmResetBtn.addEventListener("click", () => {
resetProgress();
closeResetConfirm();
closeSettings();
});
els.settingsModal.addEventListener("click", (event) => {
if (event.target === els.settingsModal) {
closeSettings();
}
});
els.resetConfirmModal.addEventListener("click", (event) => {
if (event.target === els.resetConfirmModal) {
closeResetConfirm();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !els.resetConfirmModal.hidden) {
closeResetConfirm();
return;
}
if (event.key === "Escape" && !els.settingsModal.hidden) {
closeSettings();
}
});
els.perks.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLButtonElement)) return;
const perkId = target.dataset.perkId;
if (!perkId) return;
buyPerk(perkId);
});
loadProgress();
renderProgress();
resetRound();
Binary file not shown.
Binary file not shown.
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>typeup</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=Space+Grotesk:wght@400;500;700&family=Sora:wght@600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="layout">
<section class="panel panel-main">
<header class="game-head">
<div class="head-actions">
<a href="stats.html" class="btn btn-ghost">stats</a>
<button id="settingsBtn" class="btn btn-ghost" type="button">settings</button>
<button id="newRoundBtn" class="btn" type="button">reset</button>
</div>
</header>
<div class="stats-grid">
<article class="stat-card">
<p>wpm</p>
<strong id="wpm">0</strong>
</article>
<article class="stat-card">
<p>accuracy</p>
<strong id="accuracy">100%</strong>
</article>
<article class="stat-card">
<p>combo</p>
<strong id="combo">x0</strong>
</article>
<article class="stat-card">
<p>time</p>
<strong id="time">60s</strong>
</article>
</div>
<section class="text-shell" id="textDisplay" aria-live="polite"></section>
<label class="input-wrap" for="typeInput">
<input
id="typeInput"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
placeholder="start typing to begin..."
/>
</label>
<section class="results" id="results" hidden>
<h2>round complete!</h2>
<p id="resultsSummary"></p>
<button id="playAgainBtn" class="btn">go again</button>
</section>
</section>
<aside class="panel panel-side">
<section class="profile">
<h2 id="profileTitle">rookie typer</h2>
<div class="level-line">
<span id="levelText">level 1</span>
<span id="xpText">0 / 120 xp</span>
</div>
<div class="level-line">
<span id="shardText">shards: 0</span>
</div>
<div class="xp-bar">
<div id="xpFill"></div>
</div>
<p id="xpGain" class="xp-gain">+0 xp last round</p>
</section>
<section>
<p class="label">shop</p>
<div id="perks" class="perks"></div>
</section>
</aside>
</main>
<div id="settingsModal" class="modal" hidden>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<div class="modal-head">
<h2 id="settingsTitle">settings</h2>
<button id="closeSettingsBtn" class="btn btn-ghost" type="button">close</button>
</div>
<div class="settings-grid">
<label for="profileName">name</label>
<input id="profileName" type="text" maxlength="24" placeholder="enter your name..." />
<label for="difficultySelect">difficulty</label>
<select id="difficultySelect">
<option value="easy">easy</option>
<option value="normal" selected>normal</option>
<option value="hard">hard</option>
<option value="expert">expert</option>
</select>
<p id="difficultyBonus" class="setting-note">xp bonus: x1.00</p>
<button id="resetProgressBtn" class="btn btn-ghost" type="button">reset save</button>
</div>
</div>
</div>
<div id="resetConfirmModal" class="modal" hidden>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="resetConfirmTitle">
<div class="modal-head">
<h2 id="resetConfirmTitle">reset progress?</h2>
</div>
<p class="setting-note">this will erase your level, xp, perks, and history.</p>
<div class="modal-actions">
<button id="cancelResetBtn" class="btn btn-ghost" type="button">cancel</button>
<button id="confirmResetBtn" class="btn" type="button">yes, reset</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="stats-layout">
<section class="panel panel-main">
<header class="stats-header">
<a href="index.html" class="btn">back</a>
</header>
<ul></ul>
<div class="stats-grid-large">
<article class="stat-card">
<p>rounds played</p>
<strong id="sTotalRounds">0</strong>
</article>
<article class="stat-card">
<p>top wpm</p>
<strong id="sBestWpm">0</strong>
</article>
<article class="stat-card">
<p>base accuracy</p>
<strong id="sBestAcc">0%</strong>
</article>
<article class="stat-card">
<p>average wpm</p>
<strong id="sAvgWpm">0</strong>
</article>
<article class="stat-card">
<p>average accuracy</p>
<strong id="sAvgAcc">0%</strong>
</article>
</div>
<section class="results" style="margin-top: 1rem">
<h2>recents</h2>
<ul></ul>
<ul id="historyList" class="history-list"></ul>
</section>
</section>
</main>
<script src="stats.js"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
const STORAGE_KEY = "levelup-typing-save-v1";
function readSave() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (error) {
return null;
}
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function renderHistory(recentRounds) {
const list = document.getElementById("historyList");
if (!list) return;
list.innerHTML = "";
if (!recentRounds || recentRounds.length === 0) {
const item = document.createElement("li");
item.className = "history-item";
item.textContent = "no rounds played yet... complete a run to see your history!";
list.appendChild(item);
return;
}
recentRounds.forEach((round) => {
const item = document.createElement("li");
item.className = "history-item";
const date = new Date(round.stamp || Date.now());
const when = `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
item.textContent = `${when} | ${round.wpm || 0} wpm / ${round.accuracy || 0}% acc / combo ${round.bestCombo || 0} / +${round.xp || 0} xp / ${(round.difficulty || "normal").toLowerCase()}`;
list.appendChild(item);
});
}
function boot() {
const save = readSave() || {};
const stats = save.stats || {};
setText("sTotalRounds", String(stats.totalRounds || 0));
setText("sBestWpm", String(stats.bestWpm || 0));
setText("sBestAcc", `${Math.round(stats.bestAccuracy || 0)}%`);
setText("sAvgWpm", String(Math.round(stats.averageWpm || 0)));
setText("sAvgAcc", `${Math.round(stats.averageAccuracy || 0)}%`);
renderHistory(Array.isArray(save.recentRounds) ? save.recentRounds : []);
}
boot();
+473
View File
@@ -0,0 +1,473 @@
@font-face {
font-family: "CozetteCrossedSevenVector";
src: url("fonts/CozetteCrossedSevenVector.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
--bg-1: #04070d;
--bg-2: #0a1021;
--card: rgba(8, 12, 22, 0.9);
--border: rgba(111, 151, 172, 0.28);
--accent: #8af6c9;
--accent-2: #f5cc80;
--text: #edf6ff;
--muted: #89a1b5;
--bad: #f18f8f;
}
* {
box-sizing: border-box;
font-family: "CozetteCrossedSevenVector", monospace;
font-size: 13px;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background: var(--bg-1);
overflow-x: hidden;
text-transform: lowercase;
}
.orb {
position: fixed;
width: 320px;
height: 320px;
filter: blur(80px);
opacity: 0.25;
pointer-events: none;
}
.orb-left {
top: -80px;
left: -60px;
background: #8fe8d0;
}
.orb-right {
bottom: -120px;
right: -40px;
background: #ffcf70;
}
.layout {
width: min(1140px, 94vw);
margin: 2.2rem auto;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 1.2rem;
}
.panel {
border: 1px solid var(--border);
background: var(--card);
backdrop-filter: blur(10px);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
}
.panel-main {
padding: 1.4rem;
}
.panel-side {
padding: 1.2rem;
}
.game-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.head-actions {
display: flex;
gap: 0.5rem;
}
.label {
color: var(--accent-2);
text-transform: lowercase;
letter-spacing: 0.14em;
font-size: 0.72rem;
margin: 0 0 0.2rem;
}
h1,
h2 {
margin: 0;
font-family: "CozetteCrossedSevenVector", monospace;
}
h1 {
font-size: clamp(1.35rem, 2vw, 2rem);
}
h2 {
font-size: 1.16rem;
}
.btn {
border: none;
padding: 0.58rem 1.1rem;
font-weight: 700;
font-family: inherit;
color: #052218;
background: var(--accent);
cursor: pointer;
transition: transform 170ms ease, filter 170ms ease;
}
a.btn {
text-decoration: none;
display: inline-flex;
align-items: center;
}
.btn-ghost {
color: var(--text);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(194, 228, 210, 0.2);
}
.btn-ghost:hover {
filter: brightness(1.12);
}
.btn:hover {
transform: translateY(-2px);
filter: brightness(1.05);
}
.stats-grid {
margin-top: 1.15rem;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.7rem;
}
.stat-card {
border: 1px solid rgba(148, 196, 231, 0.24);
padding: 0.66rem;
background: rgba(5, 10, 18, 0.6);
}
.stat-card p {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
}
.stat-card strong {
font-size: 1.42rem;
display: inline-block;
margin-top: 0.18rem;
}
.text-shell {
margin-top: 1rem;
min-height: 170px;
max-height: 250px;
overflow-y: auto;
border: 1px solid rgba(145, 175, 218, 0.25);
background: rgba(4, 8, 15, 0.72);
padding: 0.95rem;
line-height: 1.8;
font-size: clamp(1rem, 1.5vw, 1.2rem);
}
.word {
padding-right: 0.45rem;
transition: color 100ms ease;
}
.word.current {
color: #d6ff84;
}
.word.correct {
color: #a8f7bf;
}
.word.incorrect {
color: var(--bad);
text-decoration: underline;
text-decoration-thickness: 2px;
}
.input-wrap {
margin-top: 0.95rem;
display: block;
}
.input-wrap span {
display: block;
margin-bottom: 0.38rem;
color: var(--muted);
font-size: 0.84rem;
}
#typeInput {
width: 100%;
border: 1px solid rgba(145, 205, 218, 0.32);
background: rgba(6, 14, 18, 0.86);
color: var(--text);
font-size: 1.04rem;
padding: 0.8rem 0.85rem;
outline: none;
}
#typeInput:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(159, 255, 169, 0.18);
}
.results {
margin-top: 1rem;
border: 1px solid rgba(158, 228, 164, 0.32);
padding: 0.9rem;
background: rgba(8, 16, 18, 0.62);
animation: rise 240ms ease;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.level-line {
margin-top: 0.35rem;
display: flex;
justify-content: space-between;
color: var(--muted);
font-size: 0.88rem;
}
.xp-bar {
margin-top: 0.5rem;
height: 12px;
width: 100%;
overflow: hidden;
background: rgba(255, 255, 255, 0.12);
}
#xpFill {
height: 100%;
width: 0;
background: #87f5b0;
transition: width 320ms ease;
}
.xp-gain {
color: #baf1ff;
font-size: 0.86rem;
}
.settings-grid {
margin-top: 0.45rem;
display: grid;
gap: 0.45rem;
}
.settings-grid label {
color: var(--muted);
font-size: 0.8rem;
}
.settings-grid input,
.settings-grid select {
width: 100%;
border: 1px solid rgba(145, 205, 218, 0.32);
background: rgba(6, 14, 18, 0.86);
color: var(--text);
font-size: 0.92rem;
padding: 0.56rem 0.62rem;
outline: none;
}
.settings-grid input:focus,
.settings-grid select:focus {
border-color: var(--accent);
}
.setting-note {
margin: 0.2rem 0;
color: #b9f4ff;
font-size: 0.82rem;
}
.perks {
margin-top: 0.7rem;
display: grid;
gap: 0.55rem;
}
.perk {
border: 1px solid rgba(130, 160, 188, 0.24);
padding: 0.7rem;
background: rgba(8, 14, 24, 0.66);
}
.perk h3 {
margin: 0;
font-size: 0.94rem;
}
.perk p {
margin: 0.28rem 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.perk-actions {
margin-top: 0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.perk-cost {
color: #c8eecf;
font-size: 0.77rem;
}
.perk-buy {
border: none;
padding: 0.28rem 0.74rem;
font-size: 0.75rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
background: #b6f8ba;
color: #193027;
}
.perk-buy:disabled {
cursor: not-allowed;
opacity: 0.44;
}
.perk .status {
margin-top: 0.44rem;
display: inline-block;
padding: 0.18rem 0.54rem;
font-size: 0.72rem;
letter-spacing: 0.03em;
}
.status.locked {
background: rgba(255, 255, 255, 0.12);
color: #c0ccc6;
}
.status.unlocked {
background: rgba(167, 255, 141, 0.18);
color: #b9ffad;
}
.status.active {
background: rgba(255, 231, 118, 0.2);
color: #ffe58f;
}
.modal {
position: fixed;
inset: 0;
background: rgba(2, 4, 10, 0.72);
display: grid;
place-items: center;
z-index: 20;
padding: 1rem;
}
.modal-card {
width: min(520px, 100%);
border: 1px solid rgba(145, 175, 218, 0.25);
background: rgba(7, 11, 20, 0.98);
padding: 1rem;
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.modal-actions {
margin-top: 0.7rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
[hidden] {
display: none !important;
}
.stats-layout {
width: min(1040px, 94vw);
margin: 2rem auto;
display: grid;
gap: 1rem;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
}
.stats-grid-large {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.8rem;
}
.history-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}
.history-item {
border: 1px solid rgba(130, 160, 188, 0.24);
padding: 0.62rem;
background: rgba(8, 14, 24, 0.66);
font-size: 0.9rem;
color: var(--text);
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stats-grid-large {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.head-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
}