diff --git a/index.js b/index.js
index c5b2fba..0c61104 100644
--- a/index.js
+++ b/index.js
@@ -1,45 +1,10 @@
const http = require("http");
-const fs = require("fs");
-const path = require("path");
-const { createLogger, format, transports } = require("winston");
-const { consoleFormat } = require("winston-console-format");
-const util = require("util");
+const { readFile, stat } = require("fs");
+const { extname, join, normalize } = require("path");
-const logger = createLogger({
- level: "silly",
- format: format.combine(
- format.timestamp(),
- format.ms(),
- format.errors({ stack: true }),
- format.splat(),
- format.json()
- ),
- defaultMeta: { service: "Test" },
- transports: [
- new transports.Console({
- format: format.combine(
- format.colorize({ all: true }),
- format.padLevels(),
- consoleFormat({
- showMeta: true,
- metaStrip: ["timestamp", "service"],
- inspectOptions: {
- depth: Infinity,
- colors: true,
- maxArrayLength: Infinity,
- breakLength: 120,
- compact: Infinity,
- },
- })
- ),
- }),
- ],
-});
-
-const PORT = process.env.PORT || 3000;
-const PUBLIC_DIR = path.join(__dirname, "public");
-
-const MIME_TYPES = {
+const port = process.env.PORT || 3000;
+const public_dir = join(__dirname, "public");
+const mime_types = {
".html": "text/html; charset=UTF-8",
".css": "text/css; charset=UTF-8",
".js": "application/javascript; charset=UTF-8",
@@ -54,51 +19,35 @@ const MIME_TYPES = {
".ico": "image/x-icon"
};
-function resolveFilePath(urlPath) {
- if (!urlPath || urlPath === "/") {
- return path.join(PUBLIC_DIR, "index.html");
- }
+const safe_path = (url_path) => {
+ if (!url_path || url_path === "/") return join(public_dir, "index.html");
+ const cleaned = normalize(url_path).replace(/^(\.\.([/\\]|$))+/, "");
+ return join(public_dir, cleaned);
+};
- const safePath = path.normalize(urlPath).replace(/^(\.\.([/\\]|$))+/, "");
- return path.join(PUBLIC_DIR, safePath);
-}
-
-function sendFile(filePath, res) {
- fs.readFile(filePath, (err, data) => {
- if (err) {
- if (err.code === "ENOENT") {
- res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" });
- res.end("404 Not Found");
- return;
- }
-
- res.writeHead(500, { "Content-Type": "text/plain; charset=UTF-8" });
- res.end("500 Internal Server Error");
+const send = (file_path, res) => {
+ readFile(file_path, (err, data) => {
+ if (err?.code === "ENOENT") {
+ res.writeHead(404, { "Content-Type": "text/plain; charset=UTF-8" });
+ res.end("404 not found");
return;
}
-
- const ext = path.extname(filePath).toLowerCase();
- const contentType = MIME_TYPES[ext] || "application/octet-stream";
-
- res.writeHead(200, { "Content-Type": contentType });
+ if (err) {
+ res.writeHead(500, { "Content-Type": "text/plain; charset=UTF-8" });
+ res.end("500 internal server error");
+ return;
+ }
+ const type = mime_types[extname(file_path).toLowerCase()] || "application/octet-stream";
+ res.writeHead(200, { "Content-Type": type });
res.end(data);
});
-}
+};
-const server = http.createServer((req, res) => {
- const reqPath = (req.url || "/").split("?")[0];
- const filePath = resolveFilePath(reqPath);
-
- fs.stat(filePath, (err, stat) => {
- if (!err && stat.isDirectory()) {
- sendFile(path.join(filePath, "index.html"), res);
- return;
- }
-
- sendFile(filePath, res);
+http
+ .createServer((req, res) => {
+ const file_path = safe_path((req.url || "/").split("?")[0]);
+ stat(file_path, (err, info) => send(!err && info.isDirectory() ? join(file_path, "index.html") : file_path, res));
+ })
+ .listen(port, () => {
+ console.log(`web running at http://localhost:${port}`);
});
-});
-
-server.listen(PORT, () => {
- logger.info(`web running at http://localhost:${PORT}`);
-});
diff --git a/public/app.js b/public/app.js
index bbb64de..a27e734 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,562 +1,387 @@
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"
- ]
+ 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 DIFFICULTY_CONFIG = {
+ easy: { xp_mult: 0.9, round_words: 38 },
+ normal: { xp_mult: 1, round_words: 45 },
+ hard: { xp_mult: 1.25, round_words: 50 },
+ expert: { xp_mult: 1.5, round_words: 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."
- }
+ { id: "combo-boost", name: "combo boost", unlock_level: 2, cost: 1, description: "earn 15% more xp while your best combo is 5 or higher." },
+ { id: "accuracy-bounty", name: "accuracy bounty", unlock_level: 4, cost: 2, description: "if you finish with 96%+ accuracy, gain +30 bonus xp." },
+ { id: "time-warp", name: "time warp", unlock_level: 6, cost: 2, description: "start rounds with +8 seconds for easier high-streak runs." },
+ { id: "focus-engine", name: "focus engine", unlock_level: 8, cost: 3, description: "at 98%+ accuracy, your wpm contributes an extra 25% xp." }
];
-const defaultState = {
- profileName: "rookie typer",
+const DEFAULT_STATE = {
+ profile_name: "rookie typer",
difficulty: "normal",
words: [],
index: 0,
- typedChars: 0,
- correctWords: 0,
- wrongWords: 0,
+ typed_chars: 0,
+ correct_words: 0,
+ wrong_words: 0,
combo: 0,
- bestCombo: 0,
+ best_combo: 0,
timer: null,
- elapsedSeconds: 0,
- roundSeconds: 60,
+ elapsed_seconds: 0,
+ round_seconds: 60,
running: false,
xp: 0,
level: 1,
shards: 0,
- lastRoundXp: 0,
- ownedPerks: [],
- stats: {
- totalRounds: 0,
- bestWpm: 0,
- bestAccuracy: 0,
- averageWpm: 0,
- averageAccuracy: 0
- },
- recentRounds: []
+ last_round_xp: 0,
+ owned_perks: [],
+ stats: { total_rounds: 0, best_wpm: 0, best_accuracy: 0, average_wpm: 0, average_accuracy: 0 },
+ recent_rounds: []
};
-const state = { ...defaultState };
+const state = { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [] };
+const q = (id) => document.getElementById(id);
+const num = (v, d = 0) => (Number.isFinite(v) ? Number(v) : d);
+const snake_or_camel = (obj, snake, camel, fallback = undefined) => (obj?.[snake] ?? obj?.[camel] ?? fallback);
-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")
+const dom = {
+ text_display: q("textDisplay"), type_input: q("typeInput"), new_round_btn: q("newRoundBtn"), settings_btn: q("settingsBtn"),
+ close_settings_btn: q("closeSettingsBtn"), settings_modal: q("settingsModal"), reset_confirm_modal: q("resetConfirmModal"),
+ cancel_reset_btn: q("cancelResetBtn"), confirm_reset_btn: q("confirmResetBtn"), play_again_btn: q("playAgainBtn"),
+ results: q("results"), results_summary: q("resultsSummary"), wpm: q("wpm"), accuracy: q("accuracy"), combo: q("combo"),
+ time: q("time"), profile_title: q("profileTitle"), level_text: q("levelText"), xp_text: q("xpText"), shard_text: q("shardText"),
+ xp_fill: q("xpFill"), xp_gain: q("xpGain"), perks: q("perks"), profile_name: q("profileName"), difficulty_select: q("difficultySelect"),
+ difficulty_bonus: q("difficultyBonus"), reset_progress_btn: q("resetProgressBtn")
};
-function xpNeededForLevel(level) {
- return 120 + (level - 1) * 60;
-}
+const xp_needed_for_level = (level) => 120 + (level - 1) * 60;
+const sanitize_difficulty = (v) => (DIFFICULTY_CONFIG[v] ? v : "normal");
+const has_perk = (id) => state.owned_perks.includes(id);
+const open_settings = () => { dom.settings_modal.hidden = false; };
+const close_settings = () => { dom.settings_modal.hidden = true; };
+const open_reset_confirm = () => { dom.reset_confirm_modal.hidden = false; };
+const close_reset_confirm = () => { dom.reset_confirm_modal.hidden = true; };
-function sanitizeDifficulty(value) {
- return DIFFICULTY[value] ? value : "normal";
-}
-
-function saveProgress() {
- const payload = {
- profileName: state.profileName,
+function save_progress() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ profile_name: state.profile_name,
difficulty: state.difficulty,
xp: state.xp,
level: state.level,
shards: state.shards,
- lastRoundXp: state.lastRoundXp,
- ownedPerks: state.ownedPerks,
+ last_round_xp: state.last_round_xp,
+ owned_perks: state.owned_perks,
stats: state.stats,
- recentRounds: state.recentRounds
- };
-
- localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
+ recent_rounds: state.recent_rounds
+ }));
}
-function loadProgress() {
+function load_progress() {
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))
- : [];
+ state.profile_name = String(snake_or_camel(parsed, "profile_name", "profileName", DEFAULT_STATE.profile_name)).trim().slice(0, 24) || DEFAULT_STATE.profile_name;
+ state.difficulty = sanitize_difficulty(parsed.difficulty);
+ state.xp = Math.max(0, Math.floor(num(parsed.xp)));
+ state.level = Math.max(1, Math.floor(num(parsed.level, 1)));
+ state.shards = Math.max(0, Math.floor(num(parsed.shards)));
+ state.last_round_xp = Math.max(0, Math.floor(num(snake_or_camel(parsed, "last_round_xp", "lastRoundXp"))));
- const parsedStats = parsed.stats || {};
+ const owned = snake_or_camel(parsed, "owned_perks", "ownedPerks", []);
+ state.owned_perks = Array.isArray(owned) ? owned.filter((id) => PERKS.some((p) => p.id === id)) : [];
+
+ const stats = 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
+ total_rounds: Math.max(0, Math.floor(num(snake_or_camel(stats, "total_rounds", "totalRounds")))),
+ best_wpm: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_wpm", "bestWpm")))),
+ best_accuracy: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_accuracy", "bestAccuracy")))),
+ average_wpm: Math.max(0, num(snake_or_camel(stats, "average_wpm", "averageWpm"))),
+ average_accuracy: Math.max(0, num(snake_or_camel(stats, "average_accuracy", "averageAccuracy")))
};
- state.recentRounds = Array.isArray(parsed.recentRounds)
- ? parsed.recentRounds.slice(0, 20)
+ const rounds = snake_or_camel(parsed, "recent_rounds", "recentRounds", []);
+ state.recent_rounds = Array.isArray(rounds)
+ ? rounds.slice(0, 20).map((r) => ({
+ stamp: num(r.stamp, Date.now()),
+ wpm: num(r.wpm),
+ accuracy: num(r.accuracy),
+ best_combo: num(snake_or_camel(r, "best_combo", "bestCombo")),
+ xp: num(r.xp),
+ difficulty: sanitize_difficulty(r.difficulty)
+ }))
: [];
- } catch (error) {
+ } catch {
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];
+function pick_words() {
+ const cfg = DIFFICULTY_CONFIG[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;
+ return Array.from({ length: cfg.round_words }, () => pool[Math.floor(Math.random() * pool.length)]);
}
-function hasPerk(id) {
- return state.ownedPerks.includes(id);
-}
-
-function renderWords() {
- els.textDisplay.innerHTML = "";
+function render_words() {
+ dom.text_display.innerHTML = "";
state.words.forEach((word, idx) => {
const span = document.createElement("span");
- span.className = "word";
- if (idx === state.index) span.classList.add("current");
+ span.className = `word${idx === state.index ? " current" : ""}`;
span.textContent = `${word} `;
- els.textDisplay.appendChild(span);
+ dom.text_display.appendChild(span);
});
}
-function renderPerks() {
- els.perks.innerHTML = "";
-
+function render_perks() {
+ dom.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 level_ready = state.level >= perk.unlock_level;
+ const owned = has_perk(perk.id);
+ const can_buy = level_ready && !owned && state.shards >= perk.cost;
const card = document.createElement("article");
card.className = "perk";
- card.innerHTML = `
-
${perk.name} · Lv ${perk.unlockLevel}
+ card.innerHTML = `${perk.name} · lv ${perk.unlock_level}
${perk.description}
- Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}
-
+ cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}
+
-
- ${owned ? "active" : levelReady ? "shop open" : "locked"}
-
- `;
-
- els.perks.appendChild(card);
+ ${owned ? "active" : level_ready ? "shop open" : "locked"}`;
+ dom.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 render_settings() {
+ dom.profile_name.value = state.profile_name;
+ dom.difficulty_select.value = state.difficulty;
+ dom.difficulty_bonus.textContent = `xp bonus: x${DIFFICULTY_CONFIG[state.difficulty].xp_mult.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 render_progress() {
+ const level_need = xp_needed_for_level(state.level);
+ dom.profile_title.textContent = state.profile_name;
+ dom.level_text.textContent = `level ${state.level}`;
+ dom.xp_text.textContent = `${state.xp} / ${level_need} xp`;
+ dom.shard_text.textContent = `perk shards: ${state.shards}`;
+ dom.xp_fill.style.width = `${Math.min((state.xp / level_need) * 100, 100)}%`;
+ dom.xp_gain.textContent = `+${state.last_round_xp} xp last round`;
+ render_settings();
+ render_perks();
}
-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 update_live_stats() {
+ const elapsed = Math.max(state.elapsed_seconds / 60, 1 / 60);
+ const wpm = Math.round((state.typed_chars / 5) / elapsed);
+ const total = state.correct_words + state.wrong_words;
+ const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100;
+ dom.wpm.textContent = String(wpm);
+ dom.accuracy.textContent = `${accuracy}%`;
+ dom.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 update_timer() {
+ const remaining = Math.max(state.round_seconds - state.elapsed_seconds, 0);
+ dom.time.textContent = `${remaining}s`;
+ dom.time.style.color = remaining <= 10 ? "#ffd587" : "";
}
-function startTimer() {
+function start_timer() {
if (state.running) return;
-
state.running = true;
state.timer = setInterval(() => {
- state.elapsedSeconds += 1;
- updateTimer();
- updateLiveStats();
-
- if (state.elapsedSeconds >= state.roundSeconds) {
- finishRound();
- }
+ state.elapsed_seconds += 1;
+ update_timer();
+ update_live_stats();
+ if (state.elapsed_seconds >= state.round_seconds) finish_round();
}, 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);
+function award_xp(summary) {
+ let xp = 35 + Math.floor(summary.wpm * 1.4) + Math.floor(summary.accuracy * 0.55) + Math.floor(summary.best_combo * 2.5);
+ if (has_perk("combo-boost") && summary.best_combo >= 5) xp = Math.floor(xp * 1.15);
+ if (has_perk("accuracy-bounty") && summary.accuracy >= 96) xp += 30;
+ if (has_perk("focus-engine") && summary.accuracy >= 98) xp += Math.floor(summary.wpm * 0.25);
+ xp = Math.floor(xp * DIFFICULTY_CONFIG[state.difficulty].xp_mult);
let pending = xp;
- let levelUps = 0;
-
+ let level_ups = 0;
while (pending > 0) {
- const need = xpNeededForLevel(state.level) - state.xp;
+ const need = xp_needed_for_level(state.level) - state.xp;
if (pending >= need) {
pending -= need;
state.level += 1;
- levelUps += 1;
state.xp = 0;
+ level_ups += 1;
} else {
state.xp += pending;
pending = 0;
}
}
-
- state.shards += levelUps;
- state.lastRoundXp = xp;
+ state.shards += level_ups;
+ state.last_round_xp = xp;
return xp;
}
-function markWordResult(correct) {
- const wordNodes = els.textDisplay.querySelectorAll(".word");
- const node = wordNodes[state.index];
- if (!node) return;
+function track_round(summary, earned_xp) {
+ const s = state.stats;
+ s.total_rounds += 1;
+ s.best_wpm = Math.max(s.best_wpm, summary.wpm);
+ s.best_accuracy = Math.max(s.best_accuracy, summary.accuracy);
+ s.average_wpm = ((s.average_wpm * (s.total_rounds - 1)) + summary.wpm) / s.total_rounds;
+ s.average_accuracy = ((s.average_accuracy * (s.total_rounds - 1)) + summary.accuracy) / s.total_rounds;
+ state.recent_rounds.unshift({ stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, best_combo: summary.best_combo, xp: earned_xp, difficulty: state.difficulty });
+ state.recent_rounds = state.recent_rounds.slice(0, 20);
+}
+function mark_word_result(correct) {
+ const node = dom.text_display.querySelectorAll(".word")[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 set_current_word() {
+ const node = dom.text_display.querySelectorAll(".word")[state.index];
+ if (!node) return;
+ node.classList.add("current");
+ node.scrollIntoView({ block: "center", behavior: "smooth" });
}
-function finishRound() {
+function finish_round() {
clearInterval(state.timer);
state.running = false;
- els.typeInput.disabled = true;
+ dom.type_input.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 elapsed_m = Math.max(state.elapsed_seconds / 60, 1 / 60);
+ const wpm = Math.round((state.typed_chars / 5) / elapsed_m);
+ const total = state.correct_words + state.wrong_words;
+ const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100;
+ const summary = { wpm, accuracy, best_combo: state.best_combo };
- const summary = {
- wpm,
- accuracy,
- bestCombo: state.bestCombo
- };
+ const earned_xp = award_xp(summary);
+ track_round(summary, earned_xp);
+ render_progress();
+ update_live_stats();
+ save_progress();
- 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!`;
+ dom.results.hidden = false;
+ dom.results_summary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earned_xp} xp!`;
}
-function resetRound() {
+function reset_round() {
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;
+ Object.assign(state, {
+ words: pick_words(),
+ index: 0,
+ typed_chars: 0,
+ correct_words: 0,
+ wrong_words: 0,
+ combo: 0,
+ best_combo: 0,
+ elapsed_seconds: 0,
+ round_seconds: has_perk("time-warp") ? 68 : 60,
+ running: false
+ });
+ render_words();
+ update_live_stats();
+ update_timer();
+ dom.type_input.disabled = false;
+ dom.type_input.value = "";
+ dom.type_input.focus();
+ dom.results.hidden = true;
}
-function setProfileName(value) {
- const clean = value.trim().slice(0, 24);
- state.profileName = clean || defaultState.profileName;
- renderProgress();
- saveProgress();
+function set_profile_name(value) {
+ state.profile_name = value.trim().slice(0, 24) || DEFAULT_STATE.profile_name;
+ render_progress();
+ save_progress();
}
-function setDifficulty(value) {
- state.difficulty = sanitizeDifficulty(value);
- renderProgress();
- resetRound();
- saveProgress();
+function set_difficulty(value) {
+ state.difficulty = sanitize_difficulty(value);
+ render_progress();
+ reset_round();
+ save_progress();
}
-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;
-
+function buy_perk(perk_id) {
+ const perk = PERKS.find((p) => p.id === perk_id);
+ if (!perk || has_perk(perk_id) || state.level < perk.unlock_level || state.shards < perk.cost) return;
state.shards -= perk.cost;
- state.ownedPerks.push(perk.id);
- renderProgress();
- resetRound();
- saveProgress();
+ state.owned_perks.push(perk.id);
+ render_progress();
+ reset_round();
+ save_progress();
}
-function resetProgress() {
+function reset_progress() {
localStorage.removeItem(STORAGE_KEY);
clearInterval(state.timer);
-
- Object.assign(state, { ...defaultState, ownedPerks: [], stats: { ...defaultState.stats }, recentRounds: [] });
- renderProgress();
- resetRound();
- saveProgress();
+ Object.assign(state, { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [], owned_perks: [] });
+ render_progress();
+ reset_round();
+ save_progress();
}
-els.typeInput.addEventListener("input", () => {
- const value = els.typeInput.value;
-
- if (!state.running && value.length > 0) {
- startTimer();
- }
-
+dom.type_input.addEventListener("input", () => {
+ const value = dom.type_input.value;
+ if (!state.running && value.length) start_timer();
if (!value.endsWith(" ")) return;
- const typedWord = value.trim();
- const targetWord = state.words[state.index];
- const correct = typedWord === targetWord;
-
- state.typedChars += typedWord.length + 1;
+ const typed_word = value.trim();
+ const correct = typed_word === state.words[state.index];
+ state.typed_chars += typed_word.length + 1;
if (correct) {
- state.correctWords += 1;
+ state.correct_words += 1;
state.combo += 1;
- if (state.combo > state.bestCombo) {
- state.bestCombo = state.combo;
- }
+ state.best_combo = Math.max(state.best_combo, state.combo);
} else {
- state.wrongWords += 1;
+ state.wrong_words += 1;
state.combo = 0;
}
- markWordResult(correct);
+ mark_word_result(correct);
state.index += 1;
- els.typeInput.value = "";
-
- if (state.index >= state.words.length) {
- finishRound();
- return;
- }
-
- setCurrentWord();
- updateLiveStats();
+ dom.type_input.value = "";
+ if (state.index >= state.words.length) return finish_round();
+ set_current_word();
+ update_live_stats();
});
-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();
+dom.new_round_btn.addEventListener("click", reset_round);
+dom.play_again_btn.addEventListener("click", reset_round);
+dom.settings_btn.addEventListener("click", open_settings);
+dom.close_settings_btn.addEventListener("click", close_settings);
+dom.profile_name.addEventListener("change", (e) => set_profile_name(e.target.value));
+dom.difficulty_select.addEventListener("change", (e) => set_difficulty(e.target.value));
+dom.reset_progress_btn.addEventListener("click", open_reset_confirm);
+dom.cancel_reset_btn.addEventListener("click", close_reset_confirm);
+dom.confirm_reset_btn.addEventListener("click", () => {
+ reset_progress();
+ close_reset_confirm();
+ close_settings();
+});
+dom.settings_modal.addEventListener("click", (e) => e.target === dom.settings_modal && close_settings());
+dom.reset_confirm_modal.addEventListener("click", (e) => e.target === dom.reset_confirm_modal && close_reset_confirm());
+document.addEventListener("keydown", (e) => {
+ if (e.key !== "Escape") return;
+ if (!dom.reset_confirm_modal.hidden) return close_reset_confirm();
+ if (!dom.settings_modal.hidden) close_settings();
});
-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;
+dom.perks.addEventListener("click", (e) => {
+ const target = e.target;
if (!(target instanceof HTMLButtonElement)) return;
- const perkId = target.dataset.perkId;
- if (!perkId) return;
- buyPerk(perkId);
+ const perk_id = target.dataset.perkId;
+ if (perk_id) buy_perk(perk_id);
});
-loadProgress();
-renderProgress();
-resetRound();
+load_progress();
+render_progress();
+reset_round();
diff --git a/public/index.html b/public/index.html
index 4b7a49d..040d956 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,16 +4,9 @@
typeup
-
-
-
-
-
- wpm
- 0
-
-
- accuracy
- 100%
-
-
- combo
- x0
-
-
- time
- 60s
-
+
wpm
0
+
accuracy
100%
+
combo
x0
+
time
60s
@@ -67,21 +48,13 @@
@@ -96,7 +69,6 @@
-
-
xp bonus: x1.00
@@ -113,9 +84,7 @@
-
-
reset progress?
-
+
reset progress?
this will erase your level, xp, perks, and history.
diff --git a/public/stats.html b/public/stats.html
index 9f2ebd6..468a46e 100644
--- a/public/stats.html
+++ b/public/stats.html
@@ -8,41 +8,20 @@
-
-
+
-
- rounds played
- 0
-
-
- top wpm
- 0
-
-
- base accuracy
- 0%
-
-
- average wpm
- 0
-
-
- average accuracy
- 0%
-
+
rounds played
0
+
top wpm
0
+
best accuracy
0%
+
average wpm
0
+
average accuracy
0%
-
-
-
diff --git a/public/stats.js b/public/stats.js
index a46c510..5a20e5e 100644
--- a/public/stats.js
+++ b/public/stats.js
@@ -1,58 +1,46 @@
const STORAGE_KEY = "levelup-typing-save-v1";
-
-function readSave() {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return null;
-
+const q = (id) => document.getElementById(id);
+const set_text = (id, value) => {
+ const el = q(id);
+ if (el) el.textContent = value;
+};
+const read_save = () => {
try {
- return JSON.parse(raw);
- } catch (error) {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
+ } catch {
return null;
}
-}
+};
-function setText(id, value) {
- const el = document.getElementById(id);
- if (el) el.textContent = value;
-}
-
-function renderHistory(recentRounds) {
- const list = document.getElementById("historyList");
+function render_history(recent_rounds) {
+ const list = q("historyList");
if (!list) return;
-
list.innerHTML = "";
-
- if (!recentRounds || recentRounds.length === 0) {
+ if (!recent_rounds?.length) {
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) => {
+ recent_rounds.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()}`;
+ const best_combo = round.best_combo ?? round.bestCombo ?? 0;
+ item.className = "history-item";
+ item.textContent = `${when} | ${round.wpm || 0} wpm / ${round.accuracy || 0}% acc / combo ${best_combo} / +${round.xp || 0} xp / ${(round.difficulty || "normal").toLowerCase()}`;
list.appendChild(item);
});
}
-function boot() {
- const save = readSave() || {};
+(function boot() {
+ const save = read_save() || {};
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();
+ set_text("sTotalRounds", String(stats.total_rounds ?? stats.totalRounds ?? 0));
+ set_text("sBestWpm", String(stats.best_wpm ?? stats.bestWpm ?? 0));
+ set_text("sBestAcc", `${Math.round(stats.best_accuracy ?? stats.bestAccuracy ?? 0)}%`);
+ set_text("sAvgWpm", String(Math.round(stats.average_wpm ?? stats.averageWpm ?? 0)));
+ set_text("sAvgAcc", `${Math.round(stats.average_accuracy ?? stats.averageAccuracy ?? 0)}%`);
+ render_history(Array.isArray(save.recent_rounds ?? save.recentRounds) ? save.recent_rounds ?? save.recentRounds : []);
+})();
diff --git a/public/styles.css b/public/styles.css
index e1cb8d6..9137db3 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -1,3 +1,5 @@
+@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap');
+
@font-face {
font-family: "CozetteCrossedSevenVector";
src: url("fonts/CozetteCrossedSevenVector.woff2") format("woff2");
@@ -7,8 +9,7 @@
}
:root {
- --bg-1: #04070d;
- --bg-2: #0a1021;
+ --bg: #04070d;
--card: rgba(8, 12, 22, 0.9);
--border: rgba(111, 151, 172, 0.28);
--accent: #8af6c9;
@@ -20,7 +21,7 @@
* {
box-sizing: border-box;
- font-family: "CozetteCrossedSevenVector", monospace;
+ font-family: "Fira Code", monospace;
font-size: 13px;
}
@@ -28,145 +29,89 @@ body {
margin: 0;
min-height: 100vh;
color: var(--text);
- background: var(--bg-1);
- overflow-x: hidden;
+ background: var(--bg);
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,
+.stats-layout {
+ width: min(1140px, 94vw);
+ margin: 2rem auto;
+ display: grid;
+ gap: 1rem;
}
.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-main { padding: 1.2rem; }
+.panel-side { padding: 1rem; }
-.panel-side {
- padding: 1.2rem;
-}
-
-.game-head {
+.game-head,
+.stats-header,
+.modal-head,
+.level-line,
+.perk-actions,
+.modal-actions {
display: flex;
- justify-content: space-between;
align-items: center;
- gap: 1rem;
-}
-
-.head-actions {
- display: flex;
+ justify-content: space-between;
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;
-}
+.head-actions { display: flex; gap: 0.5rem; }
+.label { margin: 0; color: var(--accent-2); letter-spacing: 0.14em; }
+h1, h2 { margin: 0; }
.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:hover { transform: translateY(-2px); filter: brightness(1.05); }
+.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); }
+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;
+.stats-grid,
+.stats-grid-large,
+.perks,
+.history-list,
+.settings-grid {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 0.7rem;
+ gap: 0.55rem;
}
-.stat-card {
- border: 1px solid rgba(148, 196, 231, 0.24);
- padding: 0.66rem;
- background: rgba(5, 10, 18, 0.6);
+.stats-grid { margin-top: 1rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.stats-grid-large { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+
+.stat-card,
+.perk,
+.history-item {
+ border: 1px solid rgba(130, 160, 188, 0.24);
+ background: rgba(8, 14, 24, 0.66);
+ padding: 0.7rem;
}
-.stat-card p {
- margin: 0;
- color: var(--muted);
- font-size: 0.8rem;
-}
+.stat-card p,
+.setting-note,
+.perk p,
+.perk-cost,
+.input-wrap span { margin: 0; color: var(--muted); }
-.stat-card strong {
- font-size: 1.42rem;
- display: inline-block;
- margin-top: 0.18rem;
-}
+.stat-card strong { display: inline-block; margin-top: 0.2rem; font-size: 1.3rem; }
.text-shell {
margin-top: 1rem;
@@ -177,51 +122,28 @@ a.btn {
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 { 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; }
-.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 {
+.input-wrap { margin-top: 0.95rem; display: block; }
+#typeInput,
+.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: 1.04rem;
- padding: 0.8rem 0.85rem;
+ padding: 0.6rem;
outline: none;
}
-#typeInput:focus {
+#typeInput:focus,
+.settings-grid input:focus,
+.settings-grid select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(159, 255, 169, 0.18);
}
@@ -229,166 +151,30 @@ a.btn {
.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;
+ padding: 0.9rem;
}
-@keyframes rise {
- from {
- opacity: 0;
- transform: translateY(5px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
+.mt-1 { margin-top: 1rem; }
+.xp-bar { margin-top: 0.5rem; height: 12px; overflow: hidden; background: rgba(255, 255, 255, 0.12); }
+#xpFill { height: 100%; width: 0; background: #87f5b0; transition: width 320ms ease; }
+.xp-gain { color: #baf1ff; margin-bottom: 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;
-}
+.perk-buy { border: none; padding: 0.28rem 0.74rem; cursor: pointer; background: #b6f8ba; color: #193027; }
+.perk-buy:disabled { cursor: not-allowed; opacity: 0.44; }
+.status { margin-top: 0.44rem; display: inline-block; padding: 0.18rem 0.54rem; }
+.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;
+ z-index: 20;
+ padding: 1rem;
background: rgba(2, 4, 10, 0.72);
display: grid;
place-items: center;
- z-index: 20;
- padding: 1rem;
}
.modal-card {
@@ -398,76 +184,12 @@ a.btn {
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);
-}
+[hidden] { display: none !important; }
+.history-list { margin: 0; padding: 0; list-style: none; }
@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;
- }
+ .layout { grid-template-columns: 1fr; }
+ .stats-grid,
+ .stats-grid-large { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .head-actions { flex-wrap: wrap; justify-content: flex-end; }
}