commit 649574ec11fb269ea3967f60ac53ed89b81511ed Author: st5 Date: Sat Apr 4 11:32:43 2026 -0500 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6e2fcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +*.log + +# environment files +.env +.env.* +!.env.example + +# OS/editor files +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# runtime/cache +*.pid +*.seed +*.pid.lock +.cache/ + +# coverage/build artifacts +coverage/ +dist/ +build/ diff --git a/index.js b/index.js new file mode 100644 index 0000000..c5b2fba --- /dev/null +++ b/index.js @@ -0,0 +1,104 @@ +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 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 = { + ".html": "text/html; charset=UTF-8", + ".css": "text/css; charset=UTF-8", + ".js": "application/javascript; charset=UTF-8", + ".json": "application/json; charset=UTF-8", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".ico": "image/x-icon" +}; + +function resolveFilePath(urlPath) { + if (!urlPath || urlPath === "/") { + return path.join(PUBLIC_DIR, "index.html"); + } + + 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"); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || "application/octet-stream"; + + res.writeHead(200, { "Content-Type": contentType }); + 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); + }); +}); + +server.listen(PORT, () => { + logger.info(`web running at http://localhost:${PORT}`); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cb74652 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,317 @@ +{ + "name": "typeup", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "typeup", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "winston": "^3.19.0", + "winston-console-format": "^1.0.8" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-console-format": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/winston-console-format/-/winston-console-format-1.0.8.tgz", + "integrity": "sha512-dq7t/E0D0QRi4XIOwu6HM1+5e//WPqylH88GVjKEhQVrzGFg34MCz+G7pMJcXFBen9C0kBsu5GYgbYsE2LDwKw==", + "license": "MIT", + "dependencies": { + "colors": "^1.4.0", + "logform": "^2.2.0", + "triple-beam": "^1.3.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8eb6527 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "typeup", + "version": "1.0.0", + "description": "hackathon", + "license": "ISC", + "author": "st5lish", + "type": "commonjs", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "winston": "^3.19.0", + "winston-console-format": "^1.0.8" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..bbb64de --- /dev/null +++ b/public/app.js @@ -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 = ` +

${perk.name} ยท Lv ${perk.unlockLevel}

+

${perk.description}

+
+ Cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""} + +
+ + ${owned ? "active" : levelReady ? "shop open" : "locked"} + + `; + + 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(); diff --git a/public/fonts/CozetteCrossedSevenVector.woff2 b/public/fonts/CozetteCrossedSevenVector.woff2 new file mode 100644 index 0000000..be8a801 Binary files /dev/null and b/public/fonts/CozetteCrossedSevenVector.woff2 differ diff --git a/public/fonts/creep2.ttf b/public/fonts/creep2.ttf new file mode 100644 index 0000000..ec3d0f0 Binary files /dev/null and b/public/fonts/creep2.ttf differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4b7a49d --- /dev/null +++ b/public/index.html @@ -0,0 +1,129 @@ + + + + + + typeup + + + + + + + +
+
+
+
+ stats + + +
+
+ +
+
+

wpm

+ 0 +
+
+

accuracy

+ 100% +
+
+

combo

+ x0 +
+
+

time

+ 60s +
+
+ +
+ + + + +
+ + +
+ + + + + + + + diff --git a/public/stats.html b/public/stats.html new file mode 100644 index 0000000..9f2ebd6 --- /dev/null +++ b/public/stats.html @@ -0,0 +1,48 @@ + + + + + + + + +
+
+
+ back +
+
    +
    +
    +

    rounds played

    + 0 +
    +
    +

    top wpm

    + 0 +
    +
    +

    base accuracy

    + 0% +
    +
    +

    average wpm

    + 0 +
    +
    +

    average accuracy

    + 0% +
    +
    + +
    +

    recents

    +
      +
        +
        +
        +
        + + + + diff --git a/public/stats.js b/public/stats.js new file mode 100644 index 0000000..a46c510 --- /dev/null +++ b/public/stats.js @@ -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(); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..e1cb8d6 --- /dev/null +++ b/public/styles.css @@ -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; + } +}