diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29a1636 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Dependencies +**/node_modules/ + +# Build output +**/dist/ +**/.vite/ + +# Env files +.env +.env.* +!.env.example + +# OS/editor cruft +.DS_Store +*.local diff --git a/index.html b/frontend/index.html similarity index 97% rename from index.html rename to frontend/index.html index 711167f..16bafb6 100644 --- a/index.html +++ b/frontend/index.html @@ -7,7 +7,6 @@ -
@@ -107,6 +106,6 @@
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a5c4868 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1062 @@ +{ + "name": "theme-guess-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "theme-guess-frontend", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e4a7e36 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "theme-guess-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Canvas-based syntax-theme guessing game (Vite + TypeScript, no framework).", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/src/theme.js b/frontend/src/data/themes.ts similarity index 97% rename from src/theme.js rename to frontend/src/data/themes.ts index 1f65a60..ef1ae6b 100644 --- a/src/theme.js +++ b/frontend/src/data/themes.ts @@ -6,7 +6,9 @@ // Tokyo Night (enkia/tokyo-night-vscode palette), Dracula and Catppuccin // Mocha (official published palettes). -export const CATEGORY_META = [ +import type { CategoryMeta, Themes } from '../types'; + +export const CATEGORY_META: CategoryMeta[] = [ { id: 'background', label: 'Background', icon: '๐ŸŒ‘' }, { id: 'keyword', label: 'Keywords', icon: '๐Ÿ”‘' }, { id: 'string', label: 'Strings', icon: '๐Ÿงต' }, @@ -21,7 +23,7 @@ export const CATEGORY_META = [ { id: 'comment', label: 'Comments', icon: '๐Ÿ’ฌ' }, ]; -export const THEMES = { +export const THEMES: Themes = { gruvbox: { name: 'Gruvbox Dark', colors: { @@ -106,6 +108,7 @@ if (accent.hue > 180 && !primary.locked) { export default palette; `; + // A short, different snippet used for the pre-game theme reveal. It's // deliberately missing some token kinds the real game asks about (no // numeric literals, no true/false/null/undefined/this) so the preview diff --git a/src/colorUtils.js b/frontend/src/engine/colorUtils.ts similarity index 59% rename from src/colorUtils.js rename to frontend/src/engine/colorUtils.ts index 597dd74..c9159f6 100644 --- a/src/colorUtils.js +++ b/frontend/src/engine/colorUtils.ts @@ -1,23 +1,25 @@ // Color math: hex/rgb helpers, interpolation, and a perceptual (CIELAB) // distance used to score how close a guessed color is to the real one. -export function hexToRgb(hex) { +import type { RGB } from '../types'; + +export function hexToRgb(hex: string): RGB { const h = hex.replace('#', '').trim(); const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; const int = parseInt(full, 16) || 0; return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 }; } -export function rgbToHex({ r, g, b }) { - const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'); +export function rgbToHex({ r, g, b }: RGB): string { + const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'); return `#${c(r)}${c(g)}${c(b)}`; } -export function isValidHex(hex) { +export function isValidHex(hex: string): boolean { return /^#?[0-9a-f]{6}$/i.test(hex); } -export function lerpRgb(a, b, t) { +export function lerpRgb(a: RGB, b: RGB, t: number): RGB { return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, @@ -25,16 +27,28 @@ export function lerpRgb(a, b, t) { }; } -export function easeOutCubic(t) { +export function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); } -function srgbToLinear(c) { +interface Lab { + l: number; + a: number; + b: number; +} + +interface XYZ { + x: number; + y: number; + z: number; +} + +function srgbToLinear(c: number): number { const cs = c / 255; return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4); } -function rgbToXyz({ r, g, b }) { +function rgbToXyz({ r, g, b }: RGB): XYZ { const R = srgbToLinear(r); const G = srgbToLinear(g); const B = srgbToLinear(b); @@ -45,26 +59,27 @@ function rgbToXyz({ r, g, b }) { }; } -const REF_WHITE = { x: 0.95047, y: 1.0, z: 1.08883 }; +const REF_WHITE: XYZ = { x: 0.95047, y: 1.0, z: 1.08883 }; -function xyzToLab({ x, y, z }) { - const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); +function xyzToLab({ x, y, z }: XYZ): Lab { + const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); const fx = f(x / REF_WHITE.x); const fy = f(y / REF_WHITE.y); const fz = f(z / REF_WHITE.z); return { l: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) }; } -export function hexToLab(hex) { +export function hexToLab(hex: string): Lab { return xyzToLab(rgbToXyz(hexToRgb(hex))); } -export function deltaE76(labA, labB) { +export function deltaE76(labA: Lab, labB: Lab): number { return Math.sqrt((labA.l - labB.l) ** 2 + (labA.a - labB.a) ** 2 + (labA.b - labB.b) ** 2); } // 100 at identical colors, decaying smoothly as perceptual distance grows. -export function similarityFromHex(hexA, hexB) { +export function similarityFromHex(hexA: string | null, hexB: string): number { + if (!hexA) return 0; const dE = deltaE76(hexToLab(hexA), hexToLab(hexB)); const sim = 100 * Math.exp(-dE / 45); return Math.max(0, Math.min(100, sim)); diff --git a/src/particles.js b/frontend/src/engine/particles.ts similarity index 76% rename from src/particles.js rename to frontend/src/engine/particles.ts index 00cafab..21c003c 100644 --- a/src/particles.js +++ b/frontend/src/engine/particles.ts @@ -3,17 +3,39 @@ const CONFETTI_COLORS = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019']; -export class ParticleSystem { - constructor() { - this.ripples = []; - this.particles = []; - } +interface Ripple { + x: number; + y: number; + r: number; + maxR: number; + life: number; + color: string; +} - spawnRipple(x, y, color = '#ebdbb2') { +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + life: number; + decay: number; + size: number; + gravity: number; + color: string; + confetti: boolean; + rot: number; + vr: number; +} + +export class ParticleSystem { + private ripples: Ripple[] = []; + private particles: Particle[] = []; + + spawnRipple(x: number, y: number, color = '#ebdbb2'): void { this.ripples.push({ x, y, r: 2, maxR: 46, life: 1, color }); } - spawnBurst(x, y, color, count = 14) { + spawnBurst(x: number, y: number, color: string, count = 14): void { for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = 50 + Math.random() * 110; @@ -26,11 +48,14 @@ export class ParticleSystem { size: 1.5 + Math.random() * 2.5, gravity: 90, color, + confetti: false, + rot: 0, + vr: 0, }); } } - spawnConfetti(count, width) { + spawnConfetti(count: number, width: number): void { for (let i = 0; i < count; i++) { this.particles.push({ x: Math.random() * width, @@ -49,12 +74,12 @@ export class ParticleSystem { } } - clear() { + clear(): void { this.particles.length = 0; this.ripples.length = 0; } - update(dtMs) { + update(dtMs: number): void { const s = dtMs / 1000; for (const r of this.ripples) { r.r += (r.maxR - r.r) * 0.18; @@ -65,14 +90,14 @@ export class ParticleSystem { for (const p of this.particles) { p.x += p.vx * s; p.y += p.vy * s; - p.vy += (p.gravity || 100) * s; + p.vy += p.gravity * s; if (p.confetti) p.rot += p.vr * s; p.life -= p.decay * s; } this.particles = this.particles.filter((p) => p.life > 0); } - draw(ctx) { + draw(ctx: CanvasRenderingContext2D): void { for (const r of this.ripples) { ctx.globalAlpha = Math.max(r.life, 0) * 0.6; ctx.strokeStyle = r.color; diff --git a/src/sound.js b/frontend/src/engine/sound.ts similarity index 74% rename from src/sound.js rename to frontend/src/engine/sound.ts index 14d359e..de97c41 100644 --- a/src/sound.js +++ b/frontend/src/engine/sound.ts @@ -1,13 +1,17 @@ // Tiny synthesized SFX โ€” no audio assets, just oscillators with a // quick exponential envelope. Created lazily on first user gesture. -class Sound { - constructor() { - this.ctx = null; - this.muted = false; +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; } +} - ensure() { +class Sound { + private ctx: AudioContext | null = null; + muted = false; + + private ensure(): AudioContext | null { if (!this.ctx) { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; @@ -17,7 +21,7 @@ class Sound { return this.ctx; } - tone(freq, dur = 0.12, type = 'sine', gain = 0.05, when = 0) { + private tone(freq: number, dur = 0.12, type: OscillatorType = 'sine', gain = 0.05, when = 0): void { if (this.muted) return; const ctx = this.ensure(); if (!ctx) return; @@ -35,14 +39,16 @@ class Sound { osc.stop(t0 + dur + 0.03); } - playOpen() { this.tone(660, 0.07, 'triangle', 0.035); } + playOpen(): void { + this.tone(660, 0.07, 'triangle', 0.035); + } - playApply() { + playApply(): void { this.tone(523.25, 0.09, 'sine', 0.05); this.tone(783.99, 0.12, 'sine', 0.045, 0.06); } - playReveal(score) { + playReveal(score: number): void { const notes = score >= 85 ? [523.25, 659.25, 783.99, 1046.5] : score >= 60 @@ -51,7 +57,7 @@ class Sound { notes.forEach((f, i) => this.tone(f, 0.2, 'triangle', 0.05, i * 0.1)); } - toggleMute() { + toggleMute(): boolean { this.muted = !this.muted; return this.muted; } diff --git a/src/tokenizer.js b/frontend/src/engine/tokenizer.ts similarity index 64% rename from src/tokenizer.js rename to frontend/src/engine/tokenizer.ts index 9b8f38c..0cc54dc 100644 --- a/src/tokenizer.js +++ b/frontend/src/engine/tokenizer.ts @@ -2,7 +2,13 @@ // just consistent enough to bucket every word in the sample into the // same "kind" categories a syntax theme would color. -const KEYWORDS = new Set([ +import type { Token, TokenType } from '../types'; + +function toLookup(words: readonly string[]): Record { + return Object.fromEntries(words.map((w) => [w, true] as const)); +} + +const KEYWORDS = toLookup([ 'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'of', 'in', 'new', 'class', 'extends', 'constructor', 'import', 'from', 'export', 'default', 'static', 'async', 'await', 'try', 'catch', 'finally', @@ -10,15 +16,18 @@ const KEYWORDS = new Set([ 'yield', 'super', 'do', ]); -const CONSTANTS = new Set(['true', 'false', 'null', 'undefined', 'this']); +const CONSTANTS = toLookup(['true', 'false', 'null', 'undefined', 'this']); const OPS3 = ['===', '!==', '**=']; const OPS2 = ['=>', '==', '!=', '<=', '>=', '&&', '||', '+=', '-=', '*=', '/=', '++', '--']; const PUNCT = '{}()[];,.'; const OP_CHARS = '+-*/%=<>!?:&|^~'; -function scan(src) { - const raw = []; +/** Token shape mid-scan, before line/col layout has been assigned. */ +type RawToken = Pick; + +function scan(src: string): RawToken[] { + const raw: RawToken[] = []; let i = 0; const n = src.length; while (i < n) { @@ -70,8 +79,8 @@ function scan(src) { // Defensive: split any token that smuggled in a newline (block comments, // multi-line strings) so layout math stays a simple line/col grid. -function expandNewlines(raw) { - const tokens = []; +function expandNewlines(raw: RawToken[]): RawToken[] { + const tokens: RawToken[] = []; for (const t of raw) { if (t.type !== 'newline' && t.text.includes('\n')) { const parts = t.text.split('\n'); @@ -86,40 +95,45 @@ function expandNewlines(raw) { return tokens; } -function classify(tokens) { +function classifyIdentifier(tokens: RawToken[], k: number): TokenType { + const word = tokens[k].text; + let p = k - 1; + while (p >= 0 && (tokens[p].type === 'whitespace' || tokens[p].type === 'newline')) p--; + let nx = k + 1; + while (nx < tokens.length && (tokens[nx].type === 'whitespace' || tokens[nx].type === 'newline')) nx++; + const prev = p >= 0 ? tokens[p] : null; + const next = nx < tokens.length ? tokens[nx] : null; + const isCall = !!next && next.type === 'punctuation' && next.text === '('; + const isProp = !!prev && prev.type === 'punctuation' && prev.text === '.'; + const isClassCtx = !!prev && prev.type === 'keyword' && ['class', 'new', 'extends'].includes(prev.text); + + if (KEYWORDS[word]) return 'keyword'; + if (CONSTANTS[word]) return 'constant'; + if (/^[A-Z]/.test(word) && (isClassCtx || !isCall)) return 'type'; + if (isCall) return 'function'; + if (isProp) return 'property'; + return 'variable'; +} + +function classify(tokens: RawToken[]): Token[] { + const result: Token[] = []; let line = 0; let col = 0; for (let k = 0; k < tokens.length; k++) { const t = tokens[k]; - if (t.type === 'newline') { line++; col = 0; continue; } - - if (t.type === 'identifier') { - const word = t.text; - let p = k - 1; - while (p >= 0 && (tokens[p].type === 'whitespace' || tokens[p].type === 'newline')) p--; - let nx = k + 1; - while (nx < tokens.length && (tokens[nx].type === 'whitespace' || tokens[nx].type === 'newline')) nx++; - const prev = p >= 0 ? tokens[p] : null; - const next = nx < tokens.length ? tokens[nx] : null; - const isCall = !!next && next.type === 'punctuation' && next.text === '('; - const isProp = !!prev && prev.type === 'punctuation' && prev.text === '.'; - const isClassCtx = !!prev && prev.type === 'keyword' && ['class', 'new', 'extends'].includes(prev.text); - - if (KEYWORDS.has(word)) t.type = 'keyword'; - else if (CONSTANTS.has(word)) t.type = 'constant'; - else if (/^[A-Z]/.test(word) && (isClassCtx || !isCall)) t.type = 'type'; - else if (isCall) t.type = 'function'; - else if (isProp) t.type = 'property'; - else t.type = 'variable'; + if (t.type === 'newline') { + result.push({ ...t, line, col }); + line++; + col = 0; + continue; } - - t.line = line; - t.col = col; + const type = t.type === 'identifier' ? classifyIdentifier(tokens, k) : t.type; + result.push({ type, text: t.text, line, col }); col += t.text.length; } - return tokens; + return result; } -export function tokenize(source) { +export function tokenize(source: string): Token[] { return classify(expandNewlines(scan(source))); } diff --git a/frontend/src/game/ThemeGuessGame.ts b/frontend/src/game/ThemeGuessGame.ts new file mode 100644 index 0000000..2e4a4aa --- /dev/null +++ b/frontend/src/game/ThemeGuessGame.ts @@ -0,0 +1,401 @@ +import { tokenize } from '../engine/tokenizer'; +import { CATEGORY_META, THEMES, CODE_SAMPLE } from '../data/themes'; +import { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils'; +import { ParticleSystem } from '../engine/particles'; +import { sound } from '../engine/sound'; +import type { CategoryId, CategoryStateMap, RGB, ThemeId, Token } from '../types'; +import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD, TRANSITION_MS, UNSET_BG_RGB, UNSET_FG_RGB } from './layoutConstants'; +import { drawFrame } from './renderer'; +import { computeMatchResult } from './scoring'; +import { renderCategoryPanel, setActiveCard, markCategoryAssigned, getCardRect } from './panelView'; +import { renderBreakdown, renderScoreMeter, verdictFor } from './resultView'; + +type CompareMode = 'yours' | 'original'; + +/** Grabs a required element by id, throwing early with a useful message + * instead of failing later on a null dereference. */ +function requireEl(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing required element #${id}`); + return el as T; +} + +export class ThemeGuessGame { + private readonly canvas = requireEl('code-canvas'); + private readonly ctx = this.canvas.getContext('2d')!; + private readonly panel = requireEl('category-panel'); + private readonly popover = requireEl('picker-popover'); + private readonly popoverLabel = requireEl('popover-label'); + private readonly popoverColor = requireEl('popover-color'); + private readonly popoverHex = requireEl('popover-hex'); + private readonly progressFill = requireEl('progress-fill'); + private readonly progressLabel = requireEl('progress-label'); + private readonly revealBtn = requireEl('reveal-btn'); + private readonly resetBtn = requireEl('reset-btn'); + private readonly resultModal = requireEl('result-modal'); + private readonly scoreNumber = requireEl('score-number'); + private readonly scoreMeter = requireEl('score-meter'); + private readonly scoreVerdict = requireEl('score-verdict'); + private readonly breakdownEl = requireEl('score-breakdown'); + private readonly compareToggle = requireEl('compare-toggle'); + private readonly closeResultBtn = requireEl('close-result-btn'); + private readonly playAgainBtn = requireEl('play-again-btn'); + + private readonly fx = new ParticleSystem(); + + private themeId: ThemeId; + private activeCategory: CategoryId | null = null; + private compareMode: CompareMode = 'yours'; + private lastTime = performance.now(); + private pickerOrigin: RGB | null = null; + + private tokens: Token[] = []; + private lineCount = 1; + private maxCols = 0; + private charWidth = 0; + private width = 0; + private height = 0; + private categories!: CategoryStateMap; + + constructor(themeId: ThemeId) { + this.themeId = themeId; + this.build(); + this.bindEvents(); + requestAnimationFrame((t) => this.loop(t)); + } + + // ---------- setup ---------- + + private build(): void { + this.tokens = tokenize(CODE_SAMPLE); + + this.lineCount = 1; + this.maxCols = 0; + let col = 0; + for (const t of this.tokens) { + if (t.type === 'newline') { this.lineCount++; col = 0; } + else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); } + } + + const counts: Partial> = {}; + for (const t of this.tokens) { + if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue; + counts[t.type] = (counts[t.type] ?? 0) + 1; + } + const maxCount = Math.max(1, ...Object.values(counts)); + + const theme = THEMES[this.themeId]; + const categories = {} as CategoryStateMap; + for (const def of CATEGORY_META) { + const count = counts[def.id] ?? 0; + const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB; + categories[def.id] = { + ...def, + actualHex: theme.colors[def.id], + weight: def.id === 'background' ? maxCount : Math.max(count, 3), + count, + assignedHex: null, + currentRgb: { ...unset }, + fromRgb: { ...unset }, + toRgb: { ...unset }, + transitionStart: 0, + pulsePhase: Math.random() * Math.PI * 2, + }; + } + this.categories = categories; + + this.setupCanvasSize(); + this.renderPanel(); + this.updateProgress(); + } + + private setupCanvasSize(): void { + const dpr = window.devicePixelRatio || 1; + this.ctx.font = `${FONT_SIZE}px ${FONT_STACK}`; + this.charWidth = this.ctx.measureText('M').width; + this.width = GUTTER + PAD * 2 + this.maxCols * this.charWidth; + this.height = PAD * 2 + this.lineCount * LINE_HEIGHT; + this.canvas.style.width = `${this.width}px`; + this.canvas.style.height = `${this.height}px`; + this.canvas.width = this.width * dpr; + this.canvas.height = this.height * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + private renderPanel(): void { + renderCategoryPanel(this.panel, this.categories, (id, card) => { + this.setActiveCategory(id); + this.openPicker(id, card.getBoundingClientRect()); + }); + } + + // ---------- theme switching ---------- + + setTheme(themeId: ThemeId): void { + this.themeId = themeId; + const theme = THEMES[themeId]; + for (const def of CATEGORY_META) { + const cat = this.categories[def.id]; + cat.actualHex = theme.colors[def.id]; + cat.assignedHex = null; + const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB; + cat.currentRgb = { ...unset }; + cat.fromRgb = { ...unset }; + cat.toRgb = { ...unset }; + cat.transitionStart = 0; + } + this.compareMode = 'yours'; + this.activeCategory = null; + this.popover.classList.add('hidden'); + this.resultModal.classList.add('hidden'); + this.renderPanel(); + this.updateProgress(); + this.fx.clear(); + } + + // ---------- events ---------- + + private bindEvents(): void { + this.canvas.addEventListener('click', (e) => this.onCanvasClick(e)); + this.canvas.addEventListener('mousemove', (e) => this.onCanvasHover(e)); + + this.popoverColor.addEventListener('input', () => this.onPickerLiveInput(this.popoverColor.value)); + this.popoverHex.addEventListener('input', () => { + let v = this.popoverHex.value.trim(); + if (!v.startsWith('#')) v = `#${v}`; + if (isValidHex(v)) { + this.popoverColor.value = v.length === 4 + ? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}` + : v; + this.onPickerLiveInput(this.popoverColor.value); + } + }); + requireEl('popover-apply').addEventListener('click', () => this.confirmPicker()); + requireEl('popover-cancel').addEventListener('click', () => this.closePicker(true)); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !this.popover.classList.contains('hidden')) this.closePicker(true); + }); + document.addEventListener('click', (e) => { + if (this.popover.classList.contains('hidden')) return; + const target = e.target as HTMLElement; + if (this.popover.contains(target) || target === this.canvas || target.closest('.cat-card')) return; + this.closePicker(true); + }); + + this.revealBtn.addEventListener('click', () => this.reveal()); + this.resetBtn.addEventListener('click', () => this.resetColors()); + + this.compareToggle.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('.toggle-btn'); + if (!btn?.dataset.mode) return; + this.setCompareMode(btn.dataset.mode as CompareMode); + }); + this.closeResultBtn.addEventListener('click', () => this.resultModal.classList.add('hidden')); + this.playAgainBtn.addEventListener('click', () => { + this.resultModal.classList.add('hidden'); + this.resetColors(); + }); + } + + private onCanvasHover(e: MouseEvent): void { + const { x, y } = this.toCanvasCoords(e); + const token = this.tokenAt(x, y); + this.canvas.style.cursor = token ? 'pointer' : 'default'; + } + + private onCanvasClick(e: MouseEvent): void { + const { x, y } = this.toCanvasCoords(e); + const token = this.tokenAt(x, y); + this.fx.spawnRipple(x, y, token ? rgbToHex(this.categories[token.type as CategoryId].currentRgb) : '#a89984'); + if (!token) return; + sound.playOpen(); + const id = token.type as CategoryId; + this.setActiveCategory(id); + this.openPicker(id, getCardRect(this.panel, id)); + } + + private toCanvasCoords(e: MouseEvent): { x: number; y: number } { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.width / rect.width; + const scaleY = this.height / rect.height; + return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; + } + + private tokenAt(px: number, py: number): Token | null { + const col = Math.floor((px - GUTTER - PAD) / this.charWidth); + const line = Math.floor((py - PAD) / LINE_HEIGHT); + if (line < 0 || col < 0) return null; + for (const t of this.tokens) { + if (t.type === 'whitespace' || t.type === 'newline') continue; + if (t.line === line && col >= t.col && col < t.col + t.text.length) return t; + } + return null; + } + + // ---------- category / picker ---------- + + private setActiveCategory(id: CategoryId): void { + this.activeCategory = id; + setActiveCard(this.panel, id); + } + + private openPicker(id: CategoryId, anchorRect: DOMRect): void { + const cat = this.categories[id]; + this.pickerOrigin = { ...cat.currentRgb }; + const startHex = cat.assignedHex || rgbToHex(cat.currentRgb); + this.popoverLabel.textContent = `${cat.icon} ${cat.label}`; + this.popoverColor.value = startHex; + this.popoverHex.value = startHex; + this.popover.classList.remove('hidden'); + + const pw = 220; + const ph = 160; + let left = anchorRect.left; + let top = anchorRect.bottom + 10; + if (left + pw > window.innerWidth - 10) left = window.innerWidth - pw - 10; + if (top + ph > window.innerHeight - 10) top = anchorRect.top - ph - 10; + this.popover.style.left = `${Math.max(10, left)}px`; + this.popover.style.top = `${Math.max(10, top)}px`; + this.popoverHex.focus(); + this.popoverHex.select(); + } + + private onPickerLiveInput(hex: string): void { + if (!this.activeCategory) return; + this.popoverHex.value = hex; + const cat = this.categories[this.activeCategory]; + cat.currentRgb = hexToRgb(hex); + cat.transitionStart = 0; + } + + private confirmPicker(): void { + if (!this.activeCategory) return; + this.applyColor(this.activeCategory, this.popoverColor.value); + this.closePicker(false); + } + + private closePicker(cancelled: boolean): void { + if (cancelled && this.activeCategory) { + const cat = this.categories[this.activeCategory]; + cat.currentRgb = cat.assignedHex ? hexToRgb(cat.assignedHex) : this.pickerOrigin ?? cat.currentRgb; + } + this.popover.classList.add('hidden'); + setActiveCard(this.panel, null); + this.activeCategory = null; + } + + private applyColor(id: CategoryId, hex: string): void { + const cat = this.categories[id]; + cat.assignedHex = hex; + cat.fromRgb = { ...cat.currentRgb }; + cat.toRgb = hexToRgb(hex); + cat.transitionStart = this.lastTime; + + markCategoryAssigned(this.panel, id, hex); + + for (const c of this.tokenCentersFor(id, 10)) this.fx.spawnBurst(c.x, c.y, hex, 10); + sound.playApply(); + this.updateProgress(); + } + + private tokenCentersFor(id: CategoryId, max: number): { x: number; y: number }[] { + const matches = this.tokens.filter((t) => t.type === id); + const sample: { x: number; y: number }[] = []; + const step = Math.max(1, Math.floor(matches.length / max)); + for (let i = 0; i < matches.length && sample.length < max; i += step) { + const t = matches[i]; + sample.push({ + x: GUTTER + PAD + (t.col + t.text.length / 2) * this.charWidth, + y: PAD + t.line * LINE_HEIGHT + LINE_HEIGHT / 2, + }); + } + return sample; + } + + private updateProgress(): void { + const total = CATEGORY_META.length; + const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length; + this.progressFill.style.width = `${(done / total) * 100}%`; + this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`; + this.revealBtn.disabled = done < total; + } + + private resetColors(): void { + for (const def of CATEGORY_META) { + const cat = this.categories[def.id]; + cat.assignedHex = null; + cat.fromRgb = { ...cat.currentRgb }; + cat.toRgb = { ...(def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB) }; + cat.transitionStart = this.lastTime; + } + this.compareMode = 'yours'; + this.renderPanel(); + this.updateProgress(); + this.fx.clear(); + } + + private setCompareMode(mode: CompareMode): void { + if (mode === this.compareMode) return; + this.compareMode = mode; + for (const btn of Array.from(this.compareToggle.children)) { + btn.classList.toggle('active', (btn as HTMLElement).dataset.mode === mode); + } + for (const def of CATEGORY_META) { + const cat = this.categories[def.id]; + const target = mode === 'original' ? cat.actualHex : cat.assignedHex; + if (!target) continue; + cat.fromRgb = { ...cat.currentRgb }; + cat.toRgb = hexToRgb(target); + cat.transitionStart = this.lastTime; + } + } + + // ---------- scoring / reveal ---------- + + private reveal(): void { + const { overall, rows } = computeMatchResult(this.categories); + + renderBreakdown(this.breakdownEl, rows); + renderScoreMeter(this.scoreMeter, this.scoreNumber, overall); + this.scoreVerdict.textContent = verdictFor(overall); + + this.resultModal.classList.remove('hidden'); + sound.playReveal(overall); + if (overall >= 75) this.fx.spawnConfetti(160, this.width); + } + + // ---------- render loop ---------- + + private loop(t: number): void { + const dt = t - this.lastTime; + this.lastTime = t; + this.tickTransitions(t); + this.fx.update(dt); + this.draw(t); + requestAnimationFrame((next) => this.loop(next)); + } + + private tickTransitions(now: number): void { + for (const def of CATEGORY_META) { + const cat = this.categories[def.id]; + if (!cat.transitionStart) continue; + const p = Math.min(1, (now - cat.transitionStart) / TRANSITION_MS); + cat.currentRgb = lerpRgb(cat.fromRgb, cat.toRgb, easeOutCubic(p)); + if (p >= 1) cat.transitionStart = 0; + } + } + + private draw(now: number): void { + drawFrame({ + ctx: this.ctx, + width: this.width, + height: this.height, + charWidth: this.charWidth, + lineCount: this.lineCount, + tokens: this.tokens, + categories: this.categories, + activeCategory: this.activeCategory, + particles: this.fx, + }, now); + } +} diff --git a/frontend/src/game/layoutConstants.ts b/frontend/src/game/layoutConstants.ts new file mode 100644 index 0000000..a100060 --- /dev/null +++ b/frontend/src/game/layoutConstants.ts @@ -0,0 +1,17 @@ +// Layout/animation constants shared by the game renderer, the game +// controller, and the pre-game theme preview. + +import type { RGB } from '../types'; + +export const FONT_STACK = "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace"; + +export const FONT_SIZE = 17; +export const LINE_HEIGHT = 26; +export const GUTTER = 46; +export const PAD = 24; + +export const UNSET_BG_RGB: RGB = { r: 48, g: 44, b: 42 }; +export const UNSET_FG_RGB: RGB = { r: 168, g: 153, b: 132 }; + +export const TRANSITION_MS = 550; +export const METER_BLOCKS = 20; diff --git a/frontend/src/game/panelView.ts b/frontend/src/game/panelView.ts new file mode 100644 index 0000000..77b9ace --- /dev/null +++ b/frontend/src/game/panelView.ts @@ -0,0 +1,55 @@ +import { CATEGORY_META } from '../data/themes'; +import type { CategoryId, CategoryStateMap } from '../types'; + +function cardFor(panel: HTMLElement, id: CategoryId): HTMLElement { + const card = panel.querySelector(`[data-id="${id}"]`); + if (!card) throw new Error(`Missing category card for "${id}"`); + return card; +} + +/** (Re)builds the category cards grid from scratch, wiring each card's + * click straight back to the caller so this module stays DOM-only. */ +export function renderCategoryPanel( + panel: HTMLElement, + categories: CategoryStateMap, + onSelect: (id: CategoryId, card: HTMLElement) => void, +): void { + panel.innerHTML = ''; + for (const def of CATEGORY_META) { + const count = categories[def.id].count; + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'cat-card'; + card.dataset.id = def.id; + card.innerHTML = ` + + + ${def.icon} ${def.label} + ${count || '1'} token${count === 1 ? '' : 's'} + `; + card.addEventListener('click', () => onSelect(def.id, card)); + panel.appendChild(card); + } +} + +export function setActiveCard(panel: HTMLElement, id: CategoryId | null): void { + for (const card of Array.from(panel.children)) { + card.classList.toggle('active', (card as HTMLElement).dataset.id === id); + } +} + +/** Paints a card's swatch, bounces it, and marks the card as done. */ +export function markCategoryAssigned(panel: HTMLElement, id: CategoryId, hex: string): void { + const card = cardFor(panel, id); + const swatch = card.querySelector('[data-swatch]'); + if (!swatch) return; + swatch.style.background = hex; + swatch.classList.remove('pop'); + void swatch.offsetWidth; + swatch.classList.add('pop'); + card.classList.add('assigned'); +} + +export function getCardRect(panel: HTMLElement, id: CategoryId): DOMRect { + return cardFor(panel, id).getBoundingClientRect(); +} diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts new file mode 100644 index 0000000..41ea4ae --- /dev/null +++ b/frontend/src/game/renderer.ts @@ -0,0 +1,66 @@ +import { rgbToHex } from '../engine/colorUtils'; +import type { ParticleSystem } from '../engine/particles'; +import type { CategoryId, CategoryStateMap, Token } from '../types'; +import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants'; + +/** Everything one frame of the code canvas needs to draw itself. Kept + * separate from `ThemeGuessGame` so rendering has no DOM/event concerns. */ +export interface RenderState { + ctx: CanvasRenderingContext2D; + width: number; + height: number; + charWidth: number; + lineCount: number; + tokens: Token[]; + categories: CategoryStateMap; + activeCategory: CategoryId | null; + particles: ParticleSystem; +} + +export function drawFrame(state: RenderState, now: number): void { + const { ctx } = state; + ctx.clearRect(0, 0, state.width, state.height); + + const bg = state.categories.background; + ctx.fillStyle = rgbToHex(bg.currentRgb); + ctx.globalAlpha = bg.assignedHex ? 1 : 0.85 + 0.1 * Math.sin(now / 500 + bg.pulsePhase); + ctx.fillRect(0, 0, state.width, state.height); + ctx.globalAlpha = 1; + + ctx.fillStyle = 'rgba(0,0,0,.16)'; + ctx.fillRect(0, 0, GUTTER, state.height); + + if (state.activeCategory) { + const glowAlpha = 0.16 + 0.12 * Math.sin(now / 220); + ctx.fillStyle = `rgba(255,255,255,${glowAlpha})`; + for (const t of state.tokens) { + if (t.type !== state.activeCategory) continue; + const x = GUTTER + PAD + t.col * state.charWidth; + const yTop = PAD + t.line * LINE_HEIGHT; + ctx.fillRect(x - 3, yTop + 1, t.text.length * state.charWidth + 6, LINE_HEIGHT - 2); + } + } + + ctx.fillStyle = '#665c54'; + ctx.font = `${FONT_SIZE - 3}px ${FONT_STACK}`; + ctx.textBaseline = 'alphabetic'; + for (let l = 0; l < state.lineCount; l++) { + ctx.fillText(String(l + 1).padStart(2, ' '), 8, PAD + l * LINE_HEIGHT + FONT_SIZE - 4); + } + + for (const t of state.tokens) { + if (t.type === 'whitespace' || t.type === 'newline') continue; + const cat = state.categories[t.type as CategoryId]; + const x = GUTTER + PAD + t.col * state.charWidth; + const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 4; + ctx.font = t.type === 'comment' + ? `italic ${FONT_SIZE}px ${FONT_STACK}` + : `${FONT_SIZE}px ${FONT_STACK}`; + ctx.fillStyle = rgbToHex(cat.currentRgb); + ctx.globalAlpha = cat.assignedHex ? 1 : 0.5 + 0.25 * Math.sin(now / 450 + cat.pulsePhase); + ctx.fillText(t.text, x, y); + } + ctx.globalAlpha = 1; + + state.particles.draw(ctx); +} diff --git a/frontend/src/game/resultView.ts b/frontend/src/game/resultView.ts new file mode 100644 index 0000000..b4eda7f --- /dev/null +++ b/frontend/src/game/resultView.ts @@ -0,0 +1,73 @@ +import { easeOutCubic } from '../engine/colorUtils'; +import { METER_BLOCKS } from './layoutConstants'; +import type { MatchResultRow } from '../types'; + +export function barColor(sim: number): string { + if (sim < 40) return '#ff5555'; + if (sim < 65) return '#ffa657'; + if (sim < 82) return '#ffd23f'; + return '#5fd75f'; +} + +export function verdictFor(score: number): string { + if (score >= 92) return '๐Ÿ† PIXEL PERFECT!'; + if (score >= 80) return '๐Ÿ”ฅ SO CLOSE!'; + if (score >= 60) return '๐ŸŒค๏ธ GETTING WARM'; + if (score >= 40) return '๐ŸŽญ BOLD REMIX'; + return '๐Ÿ˜… NEW GAME +'; +} + +/** Renders one row per category comparing the player's guess against the + * real color, each bar animating in with a staggered fade. */ +export function renderBreakdown(container: HTMLElement, rows: MatchResultRow[]): void { + container.innerHTML = ''; + rows.forEach((row, i) => { + const el = document.createElement('div'); + el.className = 'breakdown-row'; + el.style.animationDelay = `${i * 60}ms`; + el.innerHTML = ` +
+ + +
+
+
${row.icon} ${row.label}
+
+
+
${Math.round(row.sim)}%
`; + container.appendChild(el); + requestAnimationFrame(() => { + const fill = el.querySelector('.breakdown-bar-fill'); + if (!fill) return; + fill.style.width = `${row.sim}%`; + fill.style.background = barColor(row.sim); + }); + }); +} + +/** Builds the pixel-block score meter and animates it, and the big + * percentage readout, up to the final overall score together. */ +export function renderScoreMeter(meterEl: HTMLElement, numberEl: HTMLElement, overall: number): void { + meterEl.innerHTML = ''; + const litColor = barColor(overall); + for (let i = 0; i < METER_BLOCKS; i++) { + const b = document.createElement('span'); + b.className = 'meter-block'; + b.style.setProperty('--lit-color', litColor); + meterEl.appendChild(b); + } + const blocks = meterEl.children; + const targetLit = Math.round((overall / 100) * METER_BLOCKS); + + const start = performance.now(); + const duration = 1100; + const animate = (t: number) => { + const p = Math.min(1, (t - start) / duration); + const eased = easeOutCubic(p); + numberEl.textContent = `${Math.round(overall * eased)}%`; + const lit = Math.round(targetLit * eased); + for (let i = 0; i < blocks.length; i++) blocks[i].classList.toggle('lit', i < lit); + if (p < 1) requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); +} diff --git a/frontend/src/game/scoring.ts b/frontend/src/game/scoring.ts new file mode 100644 index 0000000..8503601 --- /dev/null +++ b/frontend/src/game/scoring.ts @@ -0,0 +1,21 @@ +import { similarityFromHex } from '../engine/colorUtils'; +import { CATEGORY_META } from '../data/themes'; +import type { CategoryStateMap, MatchResult } from '../types'; + +/** Weighted-by-frequency average of how close each guessed color landed + * to the theme's real color, in perceptual (CIELAB) distance. */ +export function computeMatchResult(categories: CategoryStateMap): MatchResult { + let totalWeight = 0; + let weightedSum = 0; + const rows: MatchResult['rows'] = []; + + for (const def of CATEGORY_META) { + const cat = categories[def.id]; + const sim = similarityFromHex(cat.assignedHex, cat.actualHex); + totalWeight += cat.weight; + weightedSum += sim * cat.weight; + rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim }); + } + + return { overall: Math.round(weightedSum / totalWeight), rows }; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..77bc860 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,56 @@ +import './style.css'; +import { ThemeGuessGame } from './game/ThemeGuessGame'; +import { THEMES } from './data/themes'; +import { sound } from './engine/sound'; +import { createThemeGrid } from './ui/themeGrid'; +import { runThemePreview } from './ui/previewFlow'; +import type { ThemeId } from './types'; + +function requireEl(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing required element #${id}`); + return el as T; +} + +const introModal = requireEl('intro-modal'); +const themeGridEl = requireEl('theme-grid'); +const startBtn = requireEl('start-btn'); +const helpBtn = requireEl('help-btn'); +const muteBtn = requireEl('mute-btn'); +const themeNameBadge = requireEl('theme-name-badge'); +const changeThemeBtn = requireEl('change-theme-btn'); +const resultModal = requireEl('result-modal'); + +const previewElements = { + overlay: requireEl('preview-overlay'), + countdown: requireEl('preview-countdown'), + caption: requireEl('preview-caption'), + canvas: requireEl('preview-canvas'), +}; + +let game: ThemeGuessGame | null = null; + +const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen()); + +function startWithSelectedTheme(): void { + const themeId: ThemeId = themeGrid.selected; + introModal.classList.add('hidden'); + runThemePreview(previewElements, themeId, () => { + if (game) game.setTheme(themeId); + else game = new ThemeGuessGame(themeId); + themeNameBadge.textContent = THEMES[themeId].name; + sound.playApply(); + }, () => sound.playOpen()); +} + +startBtn.addEventListener('click', startWithSelectedTheme); +helpBtn.addEventListener('click', () => introModal.classList.remove('hidden')); +changeThemeBtn.addEventListener('click', () => { + resultModal.classList.add('hidden'); + introModal.classList.remove('hidden'); +}); + +muteBtn.addEventListener('click', () => { + const muted = sound.toggleMute(); + muteBtn.textContent = muted ? '๐Ÿ”‡' : '๐Ÿ”Š'; +}); diff --git a/src/preview.js b/frontend/src/preview/themePreview.ts similarity index 71% rename from src/preview.js rename to frontend/src/preview/themePreview.ts index d7a08df..027049c 100644 --- a/src/preview.js +++ b/frontend/src/preview/themePreview.ts @@ -1,22 +1,21 @@ -import { tokenize } from './tokenizer.js'; -import { THEMES, PREVIEW_SAMPLE } from './theme.js'; +import { tokenize } from '../engine/tokenizer'; +import { THEMES, PREVIEW_SAMPLE } from '../data/themes'; +import type { ThemeId } from '../types'; +import { FONT_STACK } from '../game/layoutConstants'; const FONT_SIZE = 16; const LINE_HEIGHT = 24; const PAD = 20; -function fontStack() { - return "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace"; -} - // Draws the (different, intentionally incomplete) preview snippet fully // colored with a theme's *real* colors โ€” no guessing, just a flash of // what the target theme actually looks like. -export function renderThemePreview(canvas, themeId) { +export function renderThemePreview(canvas: HTMLCanvasElement, themeId: ThemeId): void { const ctx = canvas.getContext('2d'); + if (!ctx) return; const tokens = tokenize(PREVIEW_SAMPLE); - ctx.font = `${FONT_SIZE}px ${fontStack()}`; + ctx.font = `${FONT_SIZE}px ${FONT_STACK}`; const charWidth = ctx.measureText('M').width; let lineCount = 1; @@ -46,9 +45,9 @@ export function renderThemePreview(canvas, themeId) { const x = PAD + t.col * charWidth; const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 3; ctx.font = t.type === 'comment' - ? `italic ${FONT_SIZE}px ${fontStack()}` - : `${FONT_SIZE}px ${fontStack()}`; - ctx.fillStyle = theme.colors[t.type] || theme.colors.punctuation; + ? `italic ${FONT_SIZE}px ${FONT_STACK}` + : `${FONT_SIZE}px ${FONT_STACK}`; + ctx.fillStyle = theme.colors[t.type as keyof typeof theme.colors] ?? theme.colors.punctuation; ctx.fillText(t.text, x, y); } } diff --git a/style.css b/frontend/src/style.css similarity index 100% rename from style.css rename to frontend/src/style.css diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..f60f7bc --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,99 @@ +// Shared domain types for the tokenizer, theme data, and game engine. + +/** Every lexical bucket the tokenizer can assign, including the two + * layout-only kinds ('whitespace', 'newline') and the transient + * pre-classification kind ('identifier') that never survives past + * `tokenize()`. */ +export type TokenType = + | 'keyword' + | 'string' + | 'number' + | 'constant' + | 'function' + | 'variable' + | 'property' + | 'type' + | 'operator' + | 'punctuation' + | 'comment' + | 'whitespace' + | 'newline' + | 'identifier'; + +export interface Token { + type: TokenType; + text: string; + /** 0-indexed source line, assigned during tokenization. */ + line: number; + /** 0-indexed character column within its line. */ + col: number; +} + +/** The token kinds the player can actually paint a color onto. Mirrors + * `TokenType` minus the layout-only and transient members, plus + * 'background', which isn't a token at all but is themed the same way. */ +export type CategoryId = + | 'background' + | 'keyword' + | 'string' + | 'number' + | 'constant' + | 'function' + | 'variable' + | 'property' + | 'type' + | 'operator' + | 'punctuation' + | 'comment'; + +export interface CategoryMeta { + id: CategoryId; + label: string; + icon: string; +} + +export type ThemeColors = Record; + +export interface ThemeDefinition { + name: string; + colors: ThemeColors; +} + +export type ThemeId = string; +export type Themes = Record; + +export interface RGB { + r: number; + g: number; + b: number; +} + +/** Runtime state for one paintable category: its real target color, the + * player's guess, and the animated color currently on screen. */ +export interface CategoryState extends CategoryMeta { + actualHex: string; + /** Scoring weight, derived from how often this kind appears in the code. */ + weight: number; + count: number; + assignedHex: string | null; + currentRgb: RGB; + fromRgb: RGB; + toRgb: RGB; + /** `performance.now()` timestamp a color transition started, or 0 when idle. */ + transitionStart: number; + /** Per-category phase offset so "unset" pulse animations don't sync up. */ + pulsePhase: number; +} + +export type CategoryStateMap = Record; + +export interface MatchResultRow extends CategoryMeta { + guess: string | null; + actualHex: string; + sim: number; +} + +export interface MatchResult { + overall: number; + rows: MatchResultRow[]; +} diff --git a/frontend/src/ui/previewFlow.ts b/frontend/src/ui/previewFlow.ts new file mode 100644 index 0000000..c554f0f --- /dev/null +++ b/frontend/src/ui/previewFlow.ts @@ -0,0 +1,49 @@ +import { renderThemePreview } from '../preview/themePreview'; +import { THEMES } from '../data/themes'; +import type { ThemeId } from '../types'; + +export interface PreviewFlowElements { + overlay: HTMLElement; + countdown: HTMLElement; + caption: HTMLElement; + canvas: HTMLCanvasElement; +} + +const COUNTDOWN_SECONDS = 5; + +function bumpCountdown(el: HTMLElement, text: string): void { + el.textContent = text; + el.classList.remove('tick'); + void el.offsetWidth; + el.classList.add('tick'); +} + +/** Flashes a different, real-colored snippet of the chosen theme for a + * few seconds before handing control to `onDone`. `onTick` fires once per + * second (including the first frame) so the caller can layer in sound. */ +export function runThemePreview( + elements: PreviewFlowElements, + themeId: ThemeId, + onDone: () => void, + onTick?: () => void, +): void { + renderThemePreview(elements.canvas, themeId); + elements.caption.textContent = `LOADING: ${THEMES[themeId].name.toUpperCase()}`; + elements.overlay.classList.remove('hidden'); + + let remaining = COUNTDOWN_SECONDS; + bumpCountdown(elements.countdown, String(remaining)); + onTick?.(); + + const timer = window.setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + window.clearInterval(timer); + elements.overlay.classList.add('hidden'); + onDone(); + return; + } + bumpCountdown(elements.countdown, String(remaining)); + onTick?.(); + }, 1000); +} diff --git a/frontend/src/ui/themeGrid.ts b/frontend/src/ui/themeGrid.ts new file mode 100644 index 0000000..cf2b2d8 --- /dev/null +++ b/frontend/src/ui/themeGrid.ts @@ -0,0 +1,50 @@ +import { THEMES } from '../data/themes'; +import type { ThemeId } from '../types'; + +const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'] as const; + +export interface ThemeGrid { + readonly selected: ThemeId; + setSelected(id: ThemeId): void; +} + +/** Renders the selectable theme cards into `container` and keeps them in + * sync with the current selection. `onSelect` fires on every click, + * including re-clicking the already-selected card. */ +export function createThemeGrid( + container: HTMLElement, + initialId: ThemeId, + onSelect: (id: ThemeId) => void, +): ThemeGrid { + let selected = initialId; + + function render(): void { + container.innerHTML = ''; + for (const id in THEMES) { + const theme = THEMES[id]; + const card = document.createElement('button'); + card.type = 'button'; + card.className = `theme-card${id === selected ? ' selected' : ''}`; + card.dataset.id = id; + card.style.setProperty('--card-bg', theme.colors.background); + card.innerHTML = ` + + ${SWATCH_KEYS.map((k) => ``).join('')} + + ${theme.name}`; + card.addEventListener('click', () => { + selected = id; + render(); + onSelect(id); + }); + container.appendChild(card); + } + } + + render(); + + return { + get selected() { return selected; }, + setSelected(id: ThemeId) { selected = id; render(); }, + }; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8f6e58a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3f380a0 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 5173, + }, +}); diff --git a/src/game.js b/src/game.js deleted file mode 100644 index 98a84b8..0000000 --- a/src/game.js +++ /dev/null @@ -1,510 +0,0 @@ -import { tokenize } from './tokenizer.js'; -import { CATEGORY_META, THEMES, CODE_SAMPLE } from './theme.js'; -import { hexToRgb, rgbToHex, lerpRgb, easeOutCubic, isValidHex, similarityFromHex } from './colorUtils.js'; -import { ParticleSystem } from './particles.js'; -import { sound } from './sound.js'; - -const FONT_SIZE = 17; -const LINE_HEIGHT = 26; -const GUTTER = 46; -const PAD = 24; -const UNSET_BG_RGB = { r: 48, g: 44, b: 42 }; -const UNSET_FG_RGB = { r: 168, g: 153, b: 132 }; -const TRANSITION_MS = 550; -const METER_BLOCKS = 20; - -function barColor(sim) { - if (sim < 40) return '#ff5555'; - if (sim < 65) return '#ffa657'; - if (sim < 82) return '#ffd23f'; - return '#5fd75f'; -} - -function verdictFor(score) { - if (score >= 92) return '๐Ÿ† PIXEL PERFECT!'; - if (score >= 80) return '๐Ÿ”ฅ SO CLOSE!'; - if (score >= 60) return '๐ŸŒค๏ธ GETTING WARM'; - if (score >= 40) return '๐ŸŽญ BOLD REMIX'; - return '๐Ÿ˜… NEW GAME +'; -} - -export class ThemeGuessGame { - constructor(themeId) { - this.canvas = document.getElementById('code-canvas'); - this.ctx = this.canvas.getContext('2d'); - this.panel = document.getElementById('category-panel'); - this.popover = document.getElementById('picker-popover'); - this.popoverLabel = document.getElementById('popover-label'); - this.popoverColor = document.getElementById('popover-color'); - this.popoverHex = document.getElementById('popover-hex'); - this.progressFill = document.getElementById('progress-fill'); - this.progressLabel = document.getElementById('progress-label'); - this.revealBtn = document.getElementById('reveal-btn'); - this.resetBtn = document.getElementById('reset-btn'); - this.resultModal = document.getElementById('result-modal'); - this.scoreNumber = document.getElementById('score-number'); - this.scoreMeter = document.getElementById('score-meter'); - this.scoreVerdict = document.getElementById('score-verdict'); - this.breakdownEl = document.getElementById('score-breakdown'); - this.compareToggle = document.getElementById('compare-toggle'); - this.closeResultBtn = document.getElementById('close-result-btn'); - this.playAgainBtn = document.getElementById('play-again-btn'); - - this.themeId = themeId; - this.fx = new ParticleSystem(); - this.activeCategory = null; - this.compareMode = 'yours'; - this.lastTime = performance.now(); - - this.build(); - this.bindEvents(); - requestAnimationFrame(this.loop.bind(this)); - } - - // ---------- setup ---------- - - build() { - this.tokens = tokenize(CODE_SAMPLE); - - this.lineCount = 1; - this.maxCols = 0; - let col = 0; - for (const t of this.tokens) { - if (t.type === 'newline') { this.lineCount++; col = 0; } - else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); } - } - - const counts = {}; - for (const t of this.tokens) { - if (t.type === 'whitespace' || t.type === 'newline') continue; - counts[t.type] = (counts[t.type] || 0) + 1; - } - const maxCount = Math.max(1, ...Object.values(counts)); - - const theme = THEMES[this.themeId]; - this.categories = {}; - for (const def of CATEGORY_META) { - const count = counts[def.id] || 0; - const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB; - this.categories[def.id] = { - ...def, - actualHex: theme.colors[def.id], - weight: def.id === 'background' ? maxCount : Math.max(count, 3), - count, - assignedHex: null, - currentRgb: { ...unset }, - fromRgb: { ...unset }, - toRgb: { ...unset }, - transitionStart: 0, - pulsePhase: Math.random() * Math.PI * 2, - }; - } - - this.setupCanvasSize(); - this.renderPanel(); - this.updateProgress(); - } - - setupCanvasSize() { - const dpr = window.devicePixelRatio || 1; - this.ctx.font = `${FONT_SIZE}px ${this.fontStack()}`; - this.charWidth = this.ctx.measureText('M').width; - this.width = GUTTER + PAD * 2 + this.maxCols * this.charWidth; - this.height = PAD * 2 + this.lineCount * LINE_HEIGHT; - this.canvas.style.width = `${this.width}px`; - this.canvas.style.height = `${this.height}px`; - this.canvas.width = this.width * dpr; - this.canvas.height = this.height * dpr; - this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - } - - fontStack() { - return "'JetBrains Mono','Fira Code',ui-monospace,SFMono-Regular,Consolas,monospace"; - } - - renderPanel() { - this.panel.innerHTML = ''; - for (const def of CATEGORY_META) { - const card = document.createElement('button'); - card.type = 'button'; - card.className = 'cat-card'; - card.dataset.id = def.id; - card.innerHTML = ` - - - ${def.icon} ${def.label} - ${this.categories[def.id].count || '1'} token${this.categories[def.id].count === 1 ? '' : 's'} - `; - card.addEventListener('click', () => { - this.setActiveCategory(def.id); - this.openPicker(def.id, card.getBoundingClientRect()); - }); - this.panel.appendChild(card); - } - } - - // ---------- theme switching ---------- - - setTheme(themeId) { - this.themeId = themeId; - const theme = THEMES[themeId]; - for (const def of CATEGORY_META) { - const cat = this.categories[def.id]; - cat.actualHex = theme.colors[def.id]; - cat.assignedHex = null; - const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB; - cat.currentRgb = { ...unset }; - cat.fromRgb = { ...unset }; - cat.toRgb = { ...unset }; - cat.transitionStart = 0; - } - this.compareMode = 'yours'; - this.activeCategory = null; - this.popover.classList.add('hidden'); - this.resultModal.classList.add('hidden'); - this.renderPanel(); - this.updateProgress(); - this.fx.clear(); - } - - // ---------- events ---------- - - bindEvents() { - this.canvas.addEventListener('click', (e) => this.onCanvasClick(e)); - this.canvas.addEventListener('mousemove', (e) => this.onCanvasHover(e)); - - this.popoverColor.addEventListener('input', () => this.onPickerLiveInput(this.popoverColor.value)); - this.popoverHex.addEventListener('input', () => { - let v = this.popoverHex.value.trim(); - if (!v.startsWith('#')) v = `#${v}`; - if (isValidHex(v)) { - this.popoverColor.value = v.length === 4 - ? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}` - : v; - this.onPickerLiveInput(this.popoverColor.value); - } - }); - document.getElementById('popover-apply').addEventListener('click', () => this.confirmPicker()); - document.getElementById('popover-cancel').addEventListener('click', () => this.closePicker(true)); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && !this.popover.classList.contains('hidden')) this.closePicker(true); - }); - document.addEventListener('click', (e) => { - if (this.popover.classList.contains('hidden')) return; - if (this.popover.contains(e.target) || e.target === this.canvas || e.target.closest('.cat-card')) return; - this.closePicker(true); - }); - - this.revealBtn.addEventListener('click', () => this.reveal()); - this.resetBtn.addEventListener('click', () => this.resetColors()); - - this.compareToggle.addEventListener('click', (e) => { - const btn = e.target.closest('.toggle-btn'); - if (!btn) return; - this.setCompareMode(btn.dataset.mode); - }); - this.closeResultBtn.addEventListener('click', () => this.resultModal.classList.add('hidden')); - this.playAgainBtn.addEventListener('click', () => { - this.resultModal.classList.add('hidden'); - this.resetColors(); - }); - } - - onCanvasHover(e) { - const { x, y } = this.toCanvasCoords(e); - const token = this.tokenAt(x, y); - this.canvas.style.cursor = token ? 'pointer' : 'default'; - } - - onCanvasClick(e) { - const { x, y } = this.toCanvasCoords(e); - const token = this.tokenAt(x, y); - this.fx.spawnRipple(x, y, token ? rgbToHex(this.categories[token.type].currentRgb) : '#a89984'); - if (!token) return; - sound.playOpen(); - this.setActiveCategory(token.type); - const card = this.panel.querySelector(`[data-id="${token.type}"]`); - this.openPicker(token.type, card.getBoundingClientRect()); - } - - toCanvasCoords(e) { - const rect = this.canvas.getBoundingClientRect(); - const scaleX = this.width / rect.width; - const scaleY = this.height / rect.height; - return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; - } - - tokenAt(px, py) { - const col = Math.floor((px - GUTTER - PAD) / this.charWidth); - const line = Math.floor((py - PAD) / LINE_HEIGHT); - if (line < 0 || col < 0) return null; - for (const t of this.tokens) { - if (t.type === 'whitespace' || t.type === 'newline') continue; - if (t.line === line && col >= t.col && col < t.col + t.text.length) return t; - } - return null; - } - - // ---------- category / picker ---------- - - setActiveCategory(id) { - this.activeCategory = id; - for (const card of this.panel.children) card.classList.toggle('active', card.dataset.id === id); - } - - openPicker(id, anchorRect) { - const cat = this.categories[id]; - this.pickerOrigin = { ...cat.currentRgb }; - const startHex = cat.assignedHex || rgbToHex(cat.currentRgb); - this.popoverLabel.textContent = `${cat.icon} ${cat.label}`; - this.popoverColor.value = startHex; - this.popoverHex.value = startHex; - this.popover.classList.remove('hidden'); - - const pw = 220; - const ph = 160; - let left = anchorRect.left; - let top = anchorRect.bottom + 10; - if (left + pw > window.innerWidth - 10) left = window.innerWidth - pw - 10; - if (top + ph > window.innerHeight - 10) top = anchorRect.top - ph - 10; - this.popover.style.left = `${Math.max(10, left)}px`; - this.popover.style.top = `${Math.max(10, top)}px`; - this.popoverHex.focus(); - this.popoverHex.select(); - } - - onPickerLiveInput(hex) { - if (!this.activeCategory) return; - this.popoverHex.value = hex; - const cat = this.categories[this.activeCategory]; - cat.currentRgb = hexToRgb(hex); - cat.transitionStart = 0; - } - - confirmPicker() { - if (!this.activeCategory) return; - const hex = this.popoverColor.value; - this.applyColor(this.activeCategory, hex); - this.closePicker(false); - } - - closePicker(cancelled) { - if (cancelled && this.activeCategory) { - const cat = this.categories[this.activeCategory]; - cat.currentRgb = cat.assignedHex ? hexToRgb(cat.assignedHex) : this.pickerOrigin; - } - this.popover.classList.add('hidden'); - for (const card of this.panel.children) card.classList.remove('active'); - this.activeCategory = null; - } - - applyColor(id, hex) { - const cat = this.categories[id]; - cat.assignedHex = hex; - cat.fromRgb = { ...cat.currentRgb }; - cat.toRgb = hexToRgb(hex); - cat.transitionStart = this.lastTime; - - const card = this.panel.querySelector(`[data-id="${id}"]`); - const swatch = card.querySelector('[data-swatch]'); - swatch.style.background = hex; - swatch.classList.remove('pop'); - void swatch.offsetWidth; - swatch.classList.add('pop'); - card.classList.add('assigned'); - - const centers = this.tokenCentersFor(id, 10); - for (const c of centers) this.fx.spawnBurst(c.x, c.y, hex, 10); - sound.playApply(); - this.updateProgress(); - } - - tokenCentersFor(id, max) { - const matches = this.tokens.filter((t) => t.type === id); - const sample = []; - const step = Math.max(1, Math.floor(matches.length / max)); - for (let i = 0; i < matches.length && sample.length < max; i += step) { - const t = matches[i]; - sample.push({ - x: GUTTER + PAD + (t.col + t.text.length / 2) * this.charWidth, - y: PAD + t.line * LINE_HEIGHT + LINE_HEIGHT / 2, - }); - } - return sample; - } - - updateProgress() { - const total = CATEGORY_META.length; - const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length; - this.progressFill.style.width = `${(done / total) * 100}%`; - this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`; - this.revealBtn.disabled = done < total; - } - - resetColors() { - for (const def of CATEGORY_META) { - const cat = this.categories[def.id]; - cat.assignedHex = null; - cat.fromRgb = { ...cat.currentRgb }; - cat.toRgb = { ...(def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB) }; - cat.transitionStart = this.lastTime; - } - this.compareMode = 'yours'; - this.renderPanel(); - this.updateProgress(); - this.fx.clear(); - } - - setCompareMode(mode) { - if (mode === this.compareMode) return; - this.compareMode = mode; - for (const btn of this.compareToggle.children) btn.classList.toggle('active', btn.dataset.mode === mode); - for (const def of CATEGORY_META) { - const cat = this.categories[def.id]; - const target = mode === 'original' ? cat.actualHex : cat.assignedHex; - cat.fromRgb = { ...cat.currentRgb }; - cat.toRgb = hexToRgb(target); - cat.transitionStart = this.lastTime; - } - } - - // ---------- scoring / reveal ---------- - - reveal() { - let totalWeight = 0; - let weightedSum = 0; - const rows = []; - for (const def of CATEGORY_META) { - const cat = this.categories[def.id]; - const sim = similarityFromHex(cat.assignedHex, cat.actualHex); - totalWeight += cat.weight; - weightedSum += sim * cat.weight; - rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim }); - } - const overall = Math.round(weightedSum / totalWeight); - - this.renderResult(overall, rows); - this.resultModal.classList.remove('hidden'); - sound.playReveal(overall); - if (overall >= 75) this.fx.spawnConfetti(160, this.width); - } - - renderResult(overall, rows) { - this.breakdownEl.innerHTML = ''; - rows.forEach((row, i) => { - const el = document.createElement('div'); - el.className = 'breakdown-row'; - el.style.animationDelay = `${i * 60}ms`; - el.innerHTML = ` -
- - -
-
-
${row.icon} ${row.label}
-
-
-
${Math.round(row.sim)}%
`; - this.breakdownEl.appendChild(el); - requestAnimationFrame(() => { - el.querySelector('.breakdown-bar-fill').style.width = `${row.sim}%`; - el.querySelector('.breakdown-bar-fill').style.background = barColor(row.sim); - }); - }); - - this.scoreVerdict.textContent = verdictFor(overall); - - this.scoreMeter.innerHTML = ''; - const litColor = barColor(overall); - for (let i = 0; i < METER_BLOCKS; i++) { - const b = document.createElement('span'); - b.className = 'meter-block'; - b.style.setProperty('--lit-color', litColor); - this.scoreMeter.appendChild(b); - } - const blocks = this.scoreMeter.children; - const targetLit = Math.round((overall / 100) * METER_BLOCKS); - - const start = performance.now(); - const duration = 1100; - const animate = (t) => { - const p = Math.min(1, (t - start) / duration); - const eased = easeOutCubic(p); - const val = Math.round(overall * eased); - this.scoreNumber.textContent = `${val}%`; - const lit = Math.round(targetLit * eased); - for (let i = 0; i < blocks.length; i++) blocks[i].classList.toggle('lit', i < lit); - if (p < 1) requestAnimationFrame(animate); - }; - requestAnimationFrame(animate); - } - - // ---------- render loop ---------- - - loop(t) { - const dt = t - this.lastTime; - this.lastTime = t; - this.tickTransitions(t); - this.fx.update(dt); - this.draw(t); - requestAnimationFrame(this.loop.bind(this)); - } - - tickTransitions(now) { - for (const def of CATEGORY_META) { - const cat = this.categories[def.id]; - if (!cat.transitionStart) continue; - const p = Math.min(1, (now - cat.transitionStart) / TRANSITION_MS); - cat.currentRgb = lerpRgb(cat.fromRgb, cat.toRgb, easeOutCubic(p)); - if (p >= 1) cat.transitionStart = 0; - } - } - - draw(now) { - const ctx = this.ctx; - ctx.clearRect(0, 0, this.width, this.height); - - const bg = this.categories.background; - ctx.fillStyle = rgbToHex(bg.currentRgb); - ctx.globalAlpha = bg.assignedHex ? 1 : 0.85 + 0.1 * Math.sin(now / 500 + bg.pulsePhase); - ctx.fillRect(0, 0, this.width, this.height); - ctx.globalAlpha = 1; - - ctx.fillStyle = 'rgba(0,0,0,.16)'; - ctx.fillRect(0, 0, GUTTER, this.height); - - if (this.activeCategory) { - const glowAlpha = 0.16 + 0.12 * Math.sin(now / 220); - ctx.fillStyle = `rgba(255,255,255,${glowAlpha})`; - for (const t of this.tokens) { - if (t.type !== this.activeCategory) continue; - const x = GUTTER + PAD + t.col * this.charWidth; - const yTop = PAD + t.line * LINE_HEIGHT; - ctx.fillRect(x - 3, yTop + 1, t.text.length * this.charWidth + 6, LINE_HEIGHT - 2); - } - } - - ctx.fillStyle = '#665c54'; - ctx.font = `${FONT_SIZE - 3}px ${this.fontStack()}`; - ctx.textBaseline = 'alphabetic'; - for (let l = 0; l < this.lineCount; l++) { - ctx.fillText(String(l + 1).padStart(2, ' '), 8, PAD + l * LINE_HEIGHT + FONT_SIZE - 4); - } - - for (const t of this.tokens) { - if (t.type === 'whitespace' || t.type === 'newline') continue; - const cat = this.categories[t.type]; - const x = GUTTER + PAD + t.col * this.charWidth; - const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 4; - ctx.font = t.type === 'comment' - ? `italic ${FONT_SIZE}px ${this.fontStack()}` - : `${FONT_SIZE}px ${this.fontStack()}`; - ctx.fillStyle = rgbToHex(cat.currentRgb); - ctx.globalAlpha = cat.assignedHex ? 1 : 0.5 + 0.25 * Math.sin(now / 450 + cat.pulsePhase); - ctx.fillText(t.text, x, y); - } - ctx.globalAlpha = 1; - - this.fx.draw(ctx); - } -} diff --git a/src/main.js b/src/main.js deleted file mode 100644 index 5d1a9e3..0000000 --- a/src/main.js +++ /dev/null @@ -1,102 +0,0 @@ -import { ThemeGuessGame } from './game.js'; -import { THEMES } from './theme.js'; -import { sound } from './sound.js'; -import { renderThemePreview } from './preview.js'; - -const introModal = document.getElementById('intro-modal'); -const themeGrid = document.getElementById('theme-grid'); -const startBtn = document.getElementById('start-btn'); -const helpBtn = document.getElementById('help-btn'); -const muteBtn = document.getElementById('mute-btn'); -const themeNameBadge = document.getElementById('theme-name-badge'); -const changeThemeBtn = document.getElementById('change-theme-btn'); -const resultModal = document.getElementById('result-modal'); - -const previewOverlay = document.getElementById('preview-overlay'); -const previewCountdown = document.getElementById('preview-countdown'); -const previewCaption = document.getElementById('preview-caption'); -const previewCanvas = document.getElementById('preview-canvas'); - -const SWATCH_KEYS = ['keyword', 'string', 'function', 'type']; -const COUNTDOWN_SECONDS = 5; - -let selectedTheme = 'gruvbox'; -let game = null; - -function renderThemeGrid() { - themeGrid.innerHTML = ''; - for (const id in THEMES) { - const theme = THEMES[id]; - const card = document.createElement('button'); - card.type = 'button'; - card.className = `theme-card${id === selectedTheme ? ' selected' : ''}`; - card.dataset.id = id; - card.style.setProperty('--card-bg', theme.colors.background); - card.innerHTML = ` - - ${SWATCH_KEYS.map((k) => ``).join('')} - - ${theme.name}`; - card.addEventListener('click', () => { - selectedTheme = id; - renderThemeGrid(); - sound.playOpen(); - }); - themeGrid.appendChild(card); - } -} - -function bumpCountdown(text) { - previewCountdown.textContent = text; - previewCountdown.classList.remove('tick'); - void previewCountdown.offsetWidth; - previewCountdown.classList.add('tick'); -} - -// Flashes a *different* snippet, fully colored with the real theme (and -// deliberately missing a few token kinds so it isn't a complete answer -// key), for a few seconds before the round actually starts. -function runThemePreview(themeId, onDone) { - renderThemePreview(previewCanvas, themeId); - previewCaption.textContent = `LOADING: ${THEMES[themeId].name.toUpperCase()}`; - previewOverlay.classList.remove('hidden'); - - let remaining = COUNTDOWN_SECONDS; - bumpCountdown(remaining); - sound.playOpen(); - - const timer = setInterval(() => { - remaining -= 1; - if (remaining <= 0) { - clearInterval(timer); - previewOverlay.classList.add('hidden'); - onDone(); - return; - } - bumpCountdown(remaining); - sound.playOpen(); - }, 1000); -} - -function startWithSelectedTheme() { - introModal.classList.add('hidden'); - runThemePreview(selectedTheme, () => { - if (game) game.setTheme(selectedTheme); - else game = new ThemeGuessGame(selectedTheme); - themeNameBadge.textContent = THEMES[selectedTheme].name; - sound.playApply(); - }); -} - -renderThemeGrid(); -startBtn.addEventListener('click', startWithSelectedTheme); -helpBtn.addEventListener('click', () => introModal.classList.remove('hidden')); -changeThemeBtn.addEventListener('click', () => { - resultModal.classList.add('hidden'); - introModal.classList.remove('hidden'); -}); - -muteBtn.addEventListener('click', () => { - const muted = sound.toggleMute(); - muteBtn.textContent = muted ? '๐Ÿ”‡' : '๐Ÿ”Š'; -});