History

chore: sync local state after restore (push updates, no pull)

Kris Yotam committed 3 months agomain

Commit 4fcc5201 parent 2a1de3c36 files · +3774 −327Browse files

Unified · Split

36 files
  1. .gitignore +3 −0
  2. AGENTS.md +6 −0
  3. package-lock.json +132 −117
  4. package.json +2 −1
  5. scripts/test-arithmetic.ts +74 −0
  6. src/app/(app)/layout.tsx +0 −3
  7. src/app/(app)/paths/[slug]/page.tsx +102 −0
  8. src/app/(app)/paths/page.tsx +92 −13
  9. src/app/(app)/play/mental-arithmetic/page.tsx +5 −0
  10. src/app/(app)/play/page.tsx +102 −45
  11. src/app/(app)/problems/page.tsx +135 −11
  12. src/app/(app)/profile/page.tsx +372 −10
  13. src/app/(app)/profile/settings/page.tsx +49 −12
  14. src/app/api/arithmetic/attempts/route.ts +78 −0
  15. src/app/api/arithmetic/end/route.ts +43 −0
  16. src/app/api/arithmetic/sessions/route.ts +52 −0
  17. src/app/globals.css +736 −6
  18. src/app/layout.tsx +6 −3
  19. src/app/page.tsx +57 −98
  20. src/app/sign-in/[[...sign-in]]/page.tsx +9 −0
  21. src/app/sign-up/[[...sign-up]]/page.tsx +9 −0
  22. src/components/arithmetic/mentalArithmeticPlay.tsx +673 −0
  23. src/components/math/mathJax.tsx +8 −2
  24. src/components/navigation/surfaceNav.tsx +59 −0
  25. src/components/navigation/themeToggle.tsx +56 −0
  26. src/lib/arithmetic/generator.ts +393 −0
  27. src/lib/arithmetic/index.ts +2 −0
  28. src/lib/arithmetic/types.ts +64 −0
  29. src/lib/auth/session.ts +7 −4
  30. src/lib/auth/user.ts +76 −0
  31. src/lib/db/migrations/0001_arithmetic.sql +41 −0
  32. src/lib/db/migrations/0002_clerk_users.sql +11 −0
  33. src/lib/db/schema.ts +81 −2
  34. src/lib/guides.tsx +220 −0
  35. src/proxy.ts +15 −0
  36. src/types/index.ts +4 −0
.gitignore+3−0
@@ -34,3 +34,6 @@ yarn-error.log* · 33 unmodified lines
34# typescript34# typescript
35*.tsbuildinfo35*.tsbuildinfo
36next-env.d.ts36next-env.d.ts
37
38# clerk configuration (can include secrets)
39/.clerk/
AGENTS.md+6−0
@@ -0,0 +1,6 @@
1# SumTex Agent Notes
2
3- Before visual work on SumTex gameplay surfaces, inspect the xAI reference saved at `/home/krisyotam/dl/xai-style.html` and its assets in `/home/krisyotam/dl/xai-style_files/`.
4- The current play surfaces should follow that reference: black canvas, large sparse type, thin dividers, muted secondary text, outline pill controls, and no blue UI.
5- Profile/activity design should follow `/home/krisyotam/src/notes.krisyotam.com/src/app/activity`.
6- Links must not render blue or underlined.
package-lock.json+132−117
@@ -9,7 +9,7 @@ · 8 unmodified lines
9 "version": "3.0.0",9 "version": "3.0.0",
10 "license": "MIT",10 "license": "MIT",
11 "dependencies": {11 "dependencies": {
12 "@workos-inc/authkit-nextjs": "latest",12 "@clerk/nextjs": "^7.5.17",
13 "better-react-mathjax": "^2.1.0",13 "better-react-mathjax": "^2.1.0",
14 "drizzle-orm": "^0.44.0",14 "drizzle-orm": "^0.44.0",
15 "geist": "^1.4.1",15 "geist": "^1.4.1",
@@ -45,6 +45,85 @@ · 29 unmodified lines
45 "url": "https://github.com/sponsors/sindresorhus"45 "url": "https://github.com/sponsors/sindresorhus"
46 }46 }
47 },47 },
48 "node_modules/@clerk/backend": {
49 "version": "3.11.4",
50 "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.11.4.tgz",
51 "integrity": "sha512-w4SaKIMuXYJY9bU70ZHxNZtam0GYWlT0IIN7QHsZ1p9LC+v6P/JyHqVK9PNiXjcjVTZymMxkm5d9N6//9MBDzg==",
52 "license": "MIT",
53 "dependencies": {
54 "@clerk/shared": "^4.25.2",
55 "standardwebhooks": "^1.0.0",
56 "tslib": "2.8.1"
57 },
58 "engines": {
59 "node": ">=20.9.0"
60 }
61 },
62 "node_modules/@clerk/nextjs": {
63 "version": "7.5.17",
64 "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.5.17.tgz",
65 "integrity": "sha512-6RtpFRQ9VhQzMe9vjhVgcVHM6oeSEn+ZP8TYTbnluJG18F+tcIM8FHn4QMGn0tZvPfDxmODKeyZR4gz8FJ620g==",
66 "license": "MIT",
67 "dependencies": {
68 "@clerk/backend": "^3.11.4",
69 "@clerk/react": "^6.12.2",
70 "@clerk/shared": "^4.25.2",
71 "server-only": "0.0.1",
72 "tslib": "2.8.1"
73 },
74 "engines": {
75 "node": ">=20.9.0"
76 },
77 "peerDependencies": {
78 "next": "^15.2.8 || ^15.3.8 || ^15.4.10 || ^15.5.9 || ^15.6.0-0 || ^16.0.10 || ^16.1.0-0",
79 "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
80 "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
81 }
82 },
83 "node_modules/@clerk/react": {
84 "version": "6.12.2",
85 "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.12.2.tgz",
86 "integrity": "sha512-Y+OGZlYfRW+3b+fjdbvSYyYB/IraGitrlLJKYaHxeZYNIwvoUNH0zlreSxUSmxYZ0E2yETSEvkCXszvuZUMa3A==",
87 "license": "MIT",
88 "dependencies": {
89 "@clerk/shared": "^4.25.2",
90 "tslib": "2.8.1"
91 },
92 "engines": {
93 "node": ">=20.9.0"
94 },
95 "peerDependencies": {
96 "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
97 "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
98 }
99 },
100 "node_modules/@clerk/shared": {
101 "version": "4.25.2",
102 "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.25.2.tgz",
103 "integrity": "sha512-v7zgDh0eVLl3KzRHbKYTsPQ+4Xcx8A2oiU5iZ9WhME024WudwkjjPJRK12RycY0VA1Sj8i4Pi5zEpecqB/gp1Q==",
104 "license": "MIT",
105 "dependencies": {
106 "@tanstack/query-core": "^5.100.6",
107 "dequal": "2.0.3",
108 "glob-to-regexp": "0.4.1",
109 "js-cookie": "3.0.7"
110 },
111 "engines": {
112 "node": ">=20.9.0"
113 },
114 "peerDependencies": {
115 "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
116 "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
117 },
118 "peerDependenciesMeta": {
119 "react": {
120 "optional": true
121 },
122 "react-dom": {
123 "optional": true
124 }
125 }
126 },
48 "node_modules/@drizzle-team/brocli": {127 "node_modules/@drizzle-team/brocli": {
49 "version": "0.10.2",128 "version": "0.10.2",
50 "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",129 "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -1650,17 +1729,11 @@ · 1599 unmodified lines
1650 "node": ">= 10"1729 "node": ">= 10"
1651 }1730 }
1652 },1731 },
1653 "node_modules/@sindresorhus/fnv1a": {1732 "node_modules/@stablelib/base64": {
1654 "version": "3.1.0",1733 "version": "1.0.1",
1655 "resolved": "https://registry.npmjs.org/@sindresorhus/fnv1a/-/fnv1a-3.1.0.tgz",1734 "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
1656 "integrity": "sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==",1735 "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
1657 "license": "MIT",1736 "license": "MIT"
1658 "engines": {
1659 "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
1660 },
1661 "funding": {
1662 "url": "https://github.com/sponsors/sindresorhus"
1663 }
1664 },1737 },
1665 "node_modules/@swc/helpers": {1738 "node_modules/@swc/helpers": {
1666 "version": "0.5.15",1739 "version": "0.5.15",
@@ -1954,6 +2027,16 @@ · 287 unmodified lines
1954 "tailwindcss": "4.3.0"2027 "tailwindcss": "4.3.0"
1955 }2028 }
1956 },2029 },
2030 "node_modules/@tanstack/query-core": {
2031 "version": "5.101.2",
2032 "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
2033 "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
2034 "license": "MIT",
2035 "funding": {
2036 "type": "github",
2037 "url": "https://github.com/sponsors/tannerlinsley"
2038 }
2039 },
1957 "node_modules/@types/node": {2040 "node_modules/@types/node": {
1958 "version": "25.9.1",2041 "version": "25.9.1",
1959 "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",2042 "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -1984,40 +2067,6 @@ · 24 unmodified lines
1984 "@types/react": "^19.2.0"2067 "@types/react": "^19.2.0"
1985 }2068 }
1986 },2069 },
1987 "node_modules/@workos-inc/authkit-nextjs": {
1988 "version": "4.1.1",
1989 "resolved": "https://registry.npmjs.org/@workos-inc/authkit-nextjs/-/authkit-nextjs-4.1.1.tgz",
1990 "integrity": "sha512-j7kf3xw2inF8UdhrOOQMoIkCPJaf7CppmJtagDqpeLO9uLoVpnickJCOkrCpY16hIWcJPGdmXTOeaEPIYtu5XQ==",
1991 "license": "MIT",
1992 "dependencies": {
1993 "@sindresorhus/fnv1a": "^3.1.0",
1994 "@workos-inc/node": "^9.0.0",
1995 "iron-session": "^8.0.4",
1996 "jose": "^5.10.0",
1997 "path-to-regexp": "^6.3.0",
1998 "valibot": "^1.2.0"
1999 },
2000 "engines": {
2001 "node": ">=22.11.0"
2002 },
2003 "peerDependencies": {
2004 "next": "^13.5.9 || ^14.2.26 || ^15.2.3 || ^16",
2005 "react": "^18.0 || ^19.0.0",
2006 "react-dom": "^18.0 || ^19.0.0"
2007 }
2008 },
2009 "node_modules/@workos-inc/node": {
2010 "version": "9.3.1",
2011 "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-9.3.1.tgz",
2012 "integrity": "sha512-z6+V5lPAdEtJ2IklAsOUJWv5guihkMbtC8WHmLCKOJYLa/H0j9bRj/OmxTs7T+bzL2dhItxPRarhxlAAq/0jgQ==",
2013 "license": "MIT",
2014 "dependencies": {
2015 "eventemitter3": "^5.0.4"
2016 },
2017 "engines": {
2018 "node": ">=22.11.0"
2019 }
2020 },
2021 "node_modules/@xmldom/xmldom": {2070 "node_modules/@xmldom/xmldom": {
2022 "version": "0.9.10",2071 "version": "0.9.10",
2023 "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",2072 "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
@@ -2093,15 +2142,6 @@ · 69 unmodified lines
2093 "node": ">=18"2142 "node": ">=18"
2094 }2143 }
2095 },2144 },
2096 "node_modules/cookie": {
2097 "version": "0.7.2",
2098 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
2099 "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
2100 "license": "MIT",
2101 "engines": {
2102 "node": ">= 0.6"
2103 }
2104 },
2105 "node_modules/csstype": {2145 "node_modules/csstype": {
2106 "version": "3.2.3",2146 "version": "3.2.3",
2107 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",2147 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2109,6 +2149,15 @@ · 1 unmodified line
2109 "dev": true,2149 "dev": true,
2110 "license": "MIT"2150 "license": "MIT"
2111 },2151 },
2152 "node_modules/dequal": {
2153 "version": "2.0.3",
2154 "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
2155 "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
2156 "license": "MIT",
2157 "engines": {
2158 "node": ">=6"
2159 }
2160 },
2112 "node_modules/detect-libc": {2161 "node_modules/detect-libc": {
2113 "version": "2.1.2",2162 "version": "2.1.2",
2114 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",2163 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2325,11 +2374,11 @@ · 210 unmodified lines
2325 "node": ">=6"2374 "node": ">=6"
2326 }2375 }
2327 },2376 },
2328 "node_modules/eventemitter3": {2377 "node_modules/fast-sha256": {
2329 "version": "5.0.4",2378 "version": "1.3.0",
2330 "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",2379 "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
2331 "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",2380 "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
2332 "license": "MIT"2381 "license": "Unlicense"
2333 },2382 },
2334 "node_modules/fsevents": {2383 "node_modules/fsevents": {
2335 "version": "2.3.3",2384 "version": "2.3.3",
@@ -2368,6 +2417,12 @@ · 32 unmodified lines
2368 "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"2417 "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
2369 }2418 }
2370 },2419 },
2420 "node_modules/glob-to-regexp": {
2421 "version": "0.4.1",
2422 "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
2423 "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
2424 "license": "BSD-2-Clause"
2425 },
2371 "node_modules/graceful-fs": {2426 "node_modules/graceful-fs": {
2372 "version": "4.2.11",2427 "version": "4.2.11",
2373 "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",2428 "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2375,30 +2430,6 @@ · 1 unmodified line
2375 "dev": true,2430 "dev": true,
2376 "license": "ISC"2431 "license": "ISC"
2377 },2432 },
2378 "node_modules/iron-session": {
2379 "version": "8.0.4",
2380 "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz",
2381 "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==",
2382 "funding": [
2383 "https://github.com/sponsors/vvo",
2384 "https://github.com/sponsors/brc-dd"
2385 ],
2386 "license": "MIT",
2387 "dependencies": {
2388 "cookie": "^0.7.2",
2389 "iron-webcrypto": "^1.2.1",
2390 "uncrypto": "^0.1.3"
2391 }
2392 },
2393 "node_modules/iron-webcrypto": {
2394 "version": "1.2.1",
2395 "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
2396 "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
2397 "license": "MIT",
2398 "funding": {
2399 "url": "https://github.com/sponsors/brc-dd"
2400 }
2401 },
2402 "node_modules/jiti": {2433 "node_modules/jiti": {
2403 "version": "2.7.0",2434 "version": "2.7.0",
2404 "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",2435 "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2409,13 +2440,13 @@ · 4 unmodified lines
2409 "jiti": "lib/jiti-cli.mjs"2440 "jiti": "lib/jiti-cli.mjs"
2410 }2441 }
2411 },2442 },
2412 "node_modules/jose": {2443 "node_modules/js-cookie": {
2413 "version": "5.10.0",2444 "version": "3.0.7",
2414 "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",2445 "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
2415 "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",2446 "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
2416 "license": "MIT",2447 "license": "MIT",
2417 "funding": {2448 "engines": {
2418 "url": "https://github.com/sponsors/panva"2449 "node": ">=20"
2419 }2450 }
2420 },2451 },
2421 "node_modules/lightningcss": {2452 "node_modules/lightningcss": {
@@ -2825,12 +2856,6 @@ · 403 unmodified lines
2825 "node": "^10 || ^12 || >=14"2856 "node": "^10 || ^12 || >=14"
2826 }2857 }
2827 },2858 },
2828 "node_modules/path-to-regexp": {
2829 "version": "6.3.0",
2830 "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
2831 "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
2832 "license": "MIT"
2833 },
2834 "node_modules/picocolors": {2859 "node_modules/picocolors": {
2835 "version": "1.1.1",2860 "version": "1.1.1",
2836 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",2861 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3024,6 +3049,16 @@ · 187 unmodified lines
3024 "sre": "bin/sre"3049 "sre": "bin/sre"
3025 }3050 }
3026 },3051 },
3052 "node_modules/standardwebhooks": {
3053 "version": "1.0.0",
3054 "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
3055 "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
3056 "license": "MIT",
3057 "dependencies": {
3058 "@stablelib/base64": "^1.0.0",
3059 "fast-sha256": "^1.3.0"
3060 }
3061 },
3027 "node_modules/styled-jsx": {3062 "node_modules/styled-jsx": {
3028 "version": "5.1.6",3063 "version": "5.1.6",
3029 "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",3064 "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -3581,7 +3616,7 @@ · 551 unmodified lines
3581 "version": "6.0.3",3616 "version": "6.0.3",
3582 "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",3617 "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
3583 "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",3618 "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
3584 "devOptional": true,3619 "dev": true,
3585 "license": "Apache-2.0",3620 "license": "Apache-2.0",
3586 "bin": {3621 "bin": {
3587 "tsc": "bin/tsc",3622 "tsc": "bin/tsc",
@@ -3591,12 +3626,6 @@ · 3 unmodified lines
3591 "node": ">=14.17"3626 "node": ">=14.17"
3592 }3627 }
3593 },3628 },
3594 "node_modules/uncrypto": {
3595 "version": "0.1.3",
3596 "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
3597 "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
3598 "license": "MIT"
3599 },
3600 "node_modules/undici-types": {3629 "node_modules/undici-types": {
3601 "version": "7.24.6",3630 "version": "7.24.6",
3602 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",3631 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
@@ -3604,20 +3633,6 @@ · 1 unmodified line
3604 "dev": true,3633 "dev": true,
3605 "license": "MIT"3634 "license": "MIT"
3606 },3635 },
3607 "node_modules/valibot": {
3608 "version": "1.4.1",
3609 "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.1.tgz",
3610 "integrity": "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==",
3611 "license": "MIT",
3612 "peerDependencies": {
3613 "typescript": ">=5"
3614 },
3615 "peerDependenciesMeta": {
3616 "typescript": {
3617 "optional": true
3618 }
3619 }
3620 },
3621 "node_modules/wicked-good-xpath": {3636 "node_modules/wicked-good-xpath": {
3622 "version": "1.3.0",3637 "version": "1.3.0",
3623 "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz",3638 "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz",
package.json+2−1
@@ -8,6 +8,7 @@ · 7 unmodified lines
8 "build": "next build",8 "build": "next build",
9 "start": "next start",9 "start": "next start",
10 "lint": "next lint",10 "lint": "next lint",
11 "test:arithmetic": "tsx scripts/test-arithmetic.ts",
11 "db:generate": "drizzle-kit generate",12 "db:generate": "drizzle-kit generate",
12 "db:migrate": "tsx scripts/migrate.ts",13 "db:migrate": "tsx scripts/migrate.ts",
13 "db:seed": "tsx scripts/seed.ts",14 "db:seed": "tsx scripts/seed.ts",
@@ -19,7 +20,7 @@ · 5 unmodified lines
19 },20 },
20 "license": "MIT",21 "license": "MIT",
21 "dependencies": {22 "dependencies": {
22 "@workos-inc/authkit-nextjs": "latest",23 "@clerk/nextjs": "^7.5.17",
23 "better-react-mathjax": "^2.1.0",24 "better-react-mathjax": "^2.1.0",
24 "drizzle-orm": "^0.44.0",25 "drizzle-orm": "^0.44.0",
25 "geist": "^1.4.1",26 "geist": "^1.4.1",
scripts/test-arithmetic.ts+74−0
@@ -0,0 +1,74 @@
1import {
2 arithmeticCategories,
3 checkArithmeticAnswer,
4 createSeededRng,
5 generateArithmeticProblem,
6 scoreArithmeticAttempt,
7 type ArithmeticSessionConfig,
8 type ArithmeticStar,
9} from "../src/lib/arithmetic";
10
11function assert(condition: unknown, message: string): asserts condition {
12 if (!condition) {
13 throw new Error(message);
14 }
15}
16
17const stars: ArithmeticStar[] = [1, 2, 3, 4, 5];
18
19for (const category of arithmeticCategories) {
20 for (const star of stars) {
21 const config: ArithmeticSessionConfig = {
22 mode: "standard",
23 category: category.id,
24 submode: category.id === "roots-powers" ? "sqrt" : undefined,
25 star,
26 runMode: "60s",
27 magnitude: category.id.startsWith("big-") ? "thousands" : undefined,
28 };
29 const rng = createSeededRng(star * 1000 + category.id.length);
30
31 for (let index = 0; index < 25; index += 1) {
32 const problem = generateArithmeticProblem(config, rng);
33 assert(problem.expression.length > 0, `${category.id} produced empty expression`);
34 assert(problem.canonicalAnswer.length > 0, `${category.id} produced empty answer`);
35 assert(
36 checkArithmeticAnswer(problem, problem.canonicalAnswer),
37 `${category.id} failed canonical answer check`
38 );
39 assert(
40 scoreArithmeticAttempt({ correct: true, elapsedMs: 1000, problem }, config) > 0,
41 `${category.id} failed positive scoring`
42 );
43 assert(
44 scoreArithmeticAttempt({ correct: false, elapsedMs: 1000, problem }, config) === 0,
45 `${category.id} failed incorrect scoring`
46 );
47 }
48 }
49}
50
51const fractionConfig: ArithmeticSessionConfig = {
52 mode: "standard",
53 category: "division",
54 star: 5,
55 runMode: "60s",
56};
57const fractionProblem = {
58 ...generateArithmeticProblem(fractionConfig, createSeededRng(9)),
59 canonicalAnswer: "1/2",
60 answerFormat: "fraction" as const,
61};
62
63assert(checkArithmeticAnswer(fractionProblem, "0.5"), "0.5 should equal 1/2");
64assert(checkArithmeticAnswer(fractionProblem, "2/4"), "2/4 should equal 1/2");
65assert(!checkArithmeticAnswer(fractionProblem, "0.6"), "0.6 should not equal 1/2");
66
67const binaryProblem = generateArithmeticProblem(
68 { mode: "standard", category: "binary", star: 2, runMode: "60s" },
69 createSeededRng(12)
70);
71assert(/^[01]+$/.test(binaryProblem.canonicalAnswer), "binary answer should be base two");
72assert(checkArithmeticAnswer(binaryProblem, binaryProblem.canonicalAnswer), "binary answer failed");
73
74console.log("arithmetic tests passed");
src/app/(app)/layout.tsx+0−3
@@ -1,12 +1,9 @@
1import { Header } from "@/components/navigation/header";
2
3export default function AppLayout({ children }: { children: React.ReactNode }) {1export default function AppLayout({ children }: { children: React.ReactNode }) {
4 return (2 return (
5 <div3 <div
6 className="min-h-screen flex flex-col"4 className="min-h-screen flex flex-col"
7 style={{ background: "var(--bg-canvas)" }}5 style={{ background: "var(--bg-canvas)" }}
8 >6 >
9 <Header />
10 <main className="flex-1">{children}</main>7 <main className="flex-1">{children}</main>
11 </div>8 </div>
12 );9 );
src/app/(app)/paths/[slug]/page.tsx+102−0
@@ -0,0 +1,102 @@
1import { notFound } from "next/navigation";
2import { SurfaceNav } from "@/components/navigation/surfaceNav";
3import { getGuide, guides } from "@/lib/guides";
4
5interface Props {
6 params: Promise<{ slug: string }>;
7}
8
9export function generateStaticParams() {
10 return guides.map((guide) => ({ slug: guide.slug }));
11}
12
13export default async function GuidePage({ params }: Props) {
14 const { slug } = await params;
15 const guide = getGuide(slug);
16
17 if (!guide) {
18 notFound();
19 }
20
21 return (
22 <>
23 <style>{guidePageStyles}</style>
24 <main className="guide-doc-shell">
25 <div className="guide-doc-frame">
26 <SurfaceNav variant="light" />
27
28 <article className="guide-doc-card">
29 <span className="guide-doc-dot" />
30 <header className="guide-doc-header">
31 <div className="guide-doc-meta">
32 <span>{guide.source}</span>
33 <span>{guide.readTime}</span>
34 <span>{guide.updatedAt}</span>
35 </div>
36 <h1>{guide.title}</h1>
37 <p>{guide.dek}</p>
38 <div className="guide-doc-author">Created by {guide.author}</div>
39 </header>
40
41 <div className="guide-doc-body">
42 {guide.sections.map((section) => (
43 <section key={section.heading}>
44 <h2>{section.heading}</h2>
45 {section.body}
46 </section>
47 ))}
48 </div>
49 </article>
50 </div>
51 </main>
52 </>
53 );
54}
55
56const guidePageStyles = `
57 .guide-doc-shell {
58 --surface-bg: #ffffff;
59 --surface-card: #ffffff;
60 --surface-text: #303030;
61 --surface-strong: #111;
62 --surface-muted: #555;
63 --surface-faint: #9a9a9a;
64 --surface-line: #e3e3e3;
65 min-height: 100vh;
66 background: var(--surface-bg);
67 color: var(--surface-text);
68 padding: 28px 5vw 86px;
69 }
70
71 .dark .guide-doc-shell {
72 --surface-bg: #080808;
73 --surface-card: #111;
74 --surface-text: #d6d3cc;
75 --surface-strong: #fff;
76 --surface-muted: #aaa;
77 --surface-faint: #707070;
78 --surface-line: #242424;
79 }
80
81 .guide-doc-shell a { color: inherit; text-decoration: none; }
82 .guide-doc-frame { margin: 0 auto; max-width: 1180px; }
83 .guide-doc-card { background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 8px; box-shadow: 0 24px 58px rgba(0,0,0,0.09); margin: 52px auto 0; max-width: 760px; min-height: 860px; padding: clamp(34px, 6vw, 64px); position: relative; }
84 .dark .guide-doc-card { box-shadow: none; }
85 .guide-doc-dot { background: #aeb4bf; border-radius: 50%; height: 9px; position: absolute; right: 16px; top: 16px; width: 9px; }
86 .guide-doc-meta { color: var(--surface-faint); display: flex; flex-wrap: wrap; gap: 14px; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em; margin-bottom: 28px; text-transform: uppercase; }
87 .guide-doc-header h1 { color: var(--surface-strong); font-size: clamp(42px, 7vw, 68px); font-weight: 650; letter-spacing: 0; line-height: 0.98; margin: 0; }
88 .guide-doc-header p { color: var(--surface-muted); font-size: 18px; line-height: 1.55; margin: 22px 0 0; }
89 .guide-doc-author { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em; margin-top: 24px; text-transform: uppercase; }
90 .guide-doc-body { margin-top: 56px; }
91 .guide-doc-body section { border-top: 1px solid var(--surface-line); padding: 30px 0 4px; }
92 .guide-doc-body h2 { color: var(--surface-strong); font-size: 28px; font-weight: 650; letter-spacing: 0; line-height: 1.12; margin: 0 0 14px; }
93 .guide-doc-body p { color: var(--surface-text); font-size: 16px; line-height: 1.72; margin: 0 0 16px; }
94 .guide-doc-body ul { color: var(--surface-text); margin: 0 0 16px; padding-left: 20px; }
95 .guide-doc-body li { margin: 6px 0; }
96 .guide-doc-body mjx-container[jax="CHTML"][display="true"] { margin: 26px 0; overflow-x: auto; overflow-y: hidden; padding: 10px 0; }
97 @media (max-width: 720px) {
98 .guide-doc-shell { padding: 28px 16px 56px; }
99 .guide-doc-card { margin-top: 34px; padding: 34px 24px; }
100 .guide-doc-meta { gap: 10px; }
101 }
102`;
src/app/(app)/paths/page.tsx+92−13
@@ -1,16 +1,95 @@
1export default function PathsPage() {1import { SurfaceNav } from "@/components/navigation/surfaceNav";
2import { guides } from "@/lib/guides";
3
4export default function GuidesPage() {
2 return (5 return (
3 <div className="max-w-4xl mx-auto px-6 py-12">6 <>
4 <h17 <style>{guideIndexStyles}</style>
5 className="text-2xl font-semibold mb-4"8 <main className="guides-shell">
6 style={{ color: "var(--text-primary)" }}9 <div className="guides-frame">
7 >10 <SurfaceNav variant="light" />
8 Textbook Paths11
9 </h1>12 <header className="guides-hero">
10 <p style={{ color: "var(--text-secondary)" }}>13 <p>SumTex Guides</p>
11 Work through a textbook from start to finish. Each path contains all14 <h1>Guides</h1>
12 problems from a specific text with synopses and page references.15 <div>
13 </p>16 Platform and user-authored documents for algebra, calculus, analysis, and proof technique.
14 </div>17 </div>
18 </header>
19
20 <section className="guide-board" aria-label="Guide documents">
21 {guides.map((guide, index) => (
22 <a key={guide.slug} href={`/paths/${guide.slug}`} className="guide-card">
23 <span className={`guide-dot guide-dot-${index % 3}`} />
24 <div className="guide-card-meta">
25 <span>{guide.source}</span>
26 <span>{guide.readTime}</span>
27 </div>
28 <h2>{guide.title}</h2>
29 <p>{guide.dek}</p>
30 <div className="guide-card-footer">
31 <span>Created by {guide.author}</span>
32 <span>{guide.level}</span>
33 </div>
34 </a>
35 ))}
36 </section>
37 </div>
38 </main>
39 </>
15 );40 );
16}41}
42
43const guideIndexStyles = `
44 .guides-shell {
45 --surface-bg: #ffffff;
46 --surface-bg-soft: #f7f7f7;
47 --surface-card: #ffffff;
48 --surface-text: #171717;
49 --surface-strong: #000;
50 --surface-muted: #555;
51 --surface-faint: #9a9a9a;
52 --surface-line: #e3e3e3;
53 min-height: 100vh;
54 background: var(--surface-bg);
55 color: var(--surface-text);
56 padding: 28px 5vw 76px;
57 }
58
59 .dark .guides-shell {
60 --surface-bg: #080808;
61 --surface-bg-soft: #101010;
62 --surface-card: #111;
63 --surface-text: #f4f2ee;
64 --surface-strong: #fff;
65 --surface-muted: #a0a0a0;
66 --surface-faint: #6d6d6d;
67 --surface-line: #242424;
68 }
69
70 .guides-shell a { color: inherit; text-decoration: none; }
71 .guides-frame { margin: 0 auto; max-width: 1440px; }
72 .guides-hero { margin: 52px 0 58px; max-width: 820px; }
73 .guides-hero p { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.18em; margin: 0 0 22px; text-transform: uppercase; }
74 .guides-hero h1 { color: var(--surface-strong); font-size: clamp(52px, 7vw, 104px); font-weight: 400; letter-spacing: 0; line-height: 0.96; margin: 0; }
75 .guides-hero div { color: var(--surface-muted); font-size: 18px; line-height: 1.55; margin-top: 24px; max-width: 660px; }
76 .guide-board { align-items: start; display: grid; gap: 24px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
77 .guide-card { background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 8px; box-shadow: 0 18px 42px rgba(0,0,0,0.08); display: flex; flex-direction: column; min-height: 520px; padding: 34px 28px 26px; position: relative; transition: border-color 160ms ease, transform 160ms ease; }
78 .dark .guide-card { box-shadow: none; }
79 .guide-card:hover { border-color: var(--surface-muted); transform: translateY(-2px); }
80 .guide-dot { border-radius: 50%; height: 9px; position: absolute; right: 14px; top: 14px; width: 9px; }
81 .guide-dot-0 { background: #6fd88c; }
82 .guide-dot-1 { background: #aeb4bf; }
83 .guide-dot-2 { background: #111; }
84 .dark .guide-dot-2 { background: #f4f2ee; }
85 .guide-card-meta, .guide-card-footer { color: var(--surface-faint); display: flex; font-family: var(--font-mono); font-size: 12px; justify-content: space-between; letter-spacing: 0.08em; text-transform: uppercase; }
86 .guide-card h2 { color: var(--surface-strong); font-size: clamp(28px, 3vw, 42px); font-weight: 600; letter-spacing: 0; line-height: 1.05; margin: 44px 0 18px; }
87 .guide-card p { color: var(--surface-muted); font-size: 15px; line-height: 1.55; margin: 0; }
88 .guide-card-footer { margin-top: auto; padding-top: 34px; }
89 @media (max-width: 1100px) { .guide-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
90 @media (max-width: 720px) {
91 .guides-shell { padding: 28px 20px 56px; }
92 .guide-board { grid-template-columns: 1fr; }
93 .guide-card { min-height: 430px; }
94 }
95`;
src/app/(app)/play/mental-arithmetic/page.tsx+5−0
@@ -0,0 +1,5 @@
1import { MentalArithmeticPlay } from "@/components/arithmetic/mentalArithmeticPlay";
2
3export default function MentalArithmeticPage() {
4 return <MentalArithmeticPlay />;
5}
src/app/(app)/play/page.tsx+102−45
@@ -1,51 +1,108 @@
1import { SurfaceNav } from "@/components/navigation/surfaceNav";
2import { arithmeticCategories } from "@/lib/arithmetic";
3
4const arithmeticModes = arithmeticCategories.map((category) => ({
5 title: category.label,
6 status: "Live",
7 href: `/play/mental-arithmetic?category=${category.id}&star=2`,
8 description: category.description,
9 detail: category.id === "roots-powers" ? "sqrt n / n^2 / n^3" : "1 to 5 stars",
10}));
11
1export default function PlayPage() {12export default function PlayPage() {
2 return (13 return (
3 <div className="max-w-4xl mx-auto px-6 py-12">14 <>
4 <h115 <style>{playHubStyles}</style>
5 className="text-2xl font-semibold mb-4"16 <div className="play-shell">
6 style={{ color: "var(--text-primary)" }}17 <div className="play-frame">
7 >18 <SurfaceNav variant="dark" />
8 Play19
9 </h1>20 <header className="play-hero">
10 <p style={{ color: "var(--text-secondary)" }}>21 <div>
11 Select a mode to begin solving problems.22 <p className="play-kicker">SumTex Play</p>
12 </p>23 <h1>Modes of Arithmetic</h1>
13 <div className="grid grid-cols-2 gap-6 mt-8">24 <p>
14 <div25 Choose the drill type first. Stars, timing, custom settings, and keypad entry stay inside the run page.
15 className="p-6 rounded-lg border cursor-pointer transition-colors"26 </p>
16 style={{27 </div>
17 background: "var(--bg-primary)",28 <a className="play-cta" href="/play/mental-arithmetic?category=addition&star=2">
18 borderColor: "var(--border-color)",29 Start addition
19 }}30 </a>
20 >31 </header>
21 <h232
22 className="text-lg font-medium mb-2"33 <section className="play-grid" aria-label="Arithmetic modes">
23 style={{ color: "var(--text-primary)" }}34 {arithmeticModes.map((mode, index) => (
24 >35 <a key={mode.title} className="play-mode live" href={mode.href}>
25 Timed36 <div className="play-mode-index">{String(index + 1).padStart(2, "0")}</div>
26 </h2>37 <div className="play-mode-body">
27 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>38 <div className="play-mode-top">
28 3 minutes. Solve as many problems as you can. 1.5x XP multiplier.39 <h2>{mode.title}</h2>
29 </p>40 <span className="play-status live">{mode.status}</span>
30 </div>41 </div>
31 <div42 <p>{mode.description}</p>
32 className="p-6 rounded-lg border cursor-pointer transition-colors"43 <div className="play-mode-detail">{mode.detail}</div>
33 style={{44 </div>
34 background: "var(--bg-primary)",45 </a>
35 borderColor: "var(--border-color)",46 ))}
36 }}47 </section>
37 >
38 <h2
39 className="text-lg font-medium mb-2"
40 style={{ color: "var(--text-primary)" }}
41 >
42 Zen
43 </h2>
44 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
45 No timer. Work through problems at your own pace.
46 </p>
47 </div>48 </div>
48 </div>49 </div>
49 </div>50 </>
50 );51 );
51}52}
53
54const playHubStyles = `
55 .play-shell {
56 --surface-bg: #ffffff;
57 --surface-bg-soft: #f6f6f6;
58 --surface-text: #171717;
59 --surface-strong: #000000;
60 --surface-muted: #555555;
61 --surface-faint: #8a8a8a;
62 --surface-line: #dedede;
63 --surface-pill: #f7f7f7;
64 min-height: 100vh;
65 background: var(--surface-bg);
66 color: var(--surface-text);
67 padding: 28px 5vw 70px;
68 }
69
70 .dark .play-shell {
71 --surface-bg: #080808;
72 --surface-bg-soft: #0d0d0d;
73 --surface-text: #f4f2ee;
74 --surface-strong: #f5f3ef;
75 --surface-muted: #8d8d8d;
76 --surface-faint: #777;
77 --surface-line: #242424;
78 --surface-pill: #151515;
79 }
80
81 .play-shell a { color: inherit; text-decoration: none; }
82 .play-frame { margin: 0 auto; max-width: 1440px; }
83 .play-hero { align-items: flex-start; display: flex; gap: 32px; justify-content: space-between; margin: 0 0 62px; }
84 .play-kicker { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.18em; line-height: 1; margin: 0 0 22px; text-transform: uppercase; }
85 .play-hero h1 { color: var(--surface-strong); font-size: clamp(52px, 7vw, 108px); font-weight: 400; letter-spacing: 0; line-height: 0.95; margin: 0; }
86 .play-hero p { color: var(--surface-muted); font-size: 18px; line-height: 1.5; margin: 24px 0 0; max-width: 680px; }
87 .play-cta { align-items: center; background: transparent; border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); display: inline-flex; font-family: var(--font-mono); font-size: 13px; font-weight: 600; justify-content: center; letter-spacing: 0.14em; min-height: 54px; padding: 0 28px; text-transform: uppercase; white-space: nowrap; }
88 .play-cta:hover { background: var(--surface-pill); border-color: var(--surface-muted); }
89 .play-grid { border-top: 1px solid var(--surface-line); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
90 .play-mode { border-bottom: 1px solid var(--surface-line); color: var(--surface-text); display: grid; gap: 28px; grid-template-columns: 56px 1fr; min-height: 220px; padding: 34px 34px 32px 0; position: relative; }
91 .play-mode:nth-child(odd) { border-right: 1px solid var(--surface-line); padding-right: 44px; }
92 .play-mode:nth-child(even) { padding-left: 44px; }
93 .play-mode.live:hover { background: var(--surface-bg-soft); }
94 .play-mode-index { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.14em; padding-top: 5px; }
95 .play-mode-top { align-items: center; display: flex; gap: 18px; justify-content: space-between; }
96 .play-mode h2 { color: var(--surface-strong); font-size: clamp(28px, 3vw, 46px); font-weight: 400; letter-spacing: 0; line-height: 1.08; margin: 0; }
97 .play-mode p { color: var(--surface-muted); font-size: 17px; line-height: 1.5; margin: 30px 0 0; max-width: 540px; }
98 .play-mode-detail { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; margin-top: 32px; text-transform: uppercase; }
99 .play-status { border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-faint); flex: 0 0 auto; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.12em; padding: 7px 10px; text-transform: uppercase; }
100 .play-status.live { border-color: var(--surface-strong); color: var(--surface-strong); }
101
102 @media (max-width: 900px) {
103 .play-shell { padding: 28px 20px 56px; }
104 .play-hero { align-items: stretch; flex-direction: column; margin-bottom: 40px; }
105 .play-grid { grid-template-columns: 1fr; }
106 .play-mode, .play-mode:nth-child(even), .play-mode:nth-child(odd) { border-right: 0; grid-template-columns: 42px 1fr; min-height: 190px; padding: 28px 0; }
107 }
108`;
src/app/(app)/problems/page.tsx+135−11
@@ -1,15 +1,139 @@
1import { SurfaceNav } from "@/components/navigation/surfaceNav";
2
3const modes = [
4 {
5 title: "Algebra",
6 status: "Planned",
7 description: "Pre-algebra, intermediate algebra, and advanced algebra in one progression.",
8 detail: "Pre / Intermediate / Advanced",
9 },
10 {
11 title: "Trigonometry",
12 status: "Planned",
13 description: "Angles, identities, equations, graphs, and applied triangle work.",
14 detail: "Identities / Graphs / Applications",
15 },
16 {
17 title: "Geometry",
18 status: "Planned",
19 description: "Synthetic, analytic, and proof-oriented geometry problem sets.",
20 detail: "Euclidean / Coordinate / Proofs",
21 },
22 {
23 title: "Calculus",
24 status: "Planned",
25 description: "Calculus 1, 2, and 3 tracks for limits, integrals, series, and multivariable work.",
26 detail: "I / II / III",
27 },
28 {
29 title: "Real Analysis",
30 status: "Planned",
31 description: "Sequences, topology of the line, continuity, differentiation, integration, and metric spaces.",
32 detail: "Proof-heavy",
33 },
34 {
35 title: "Complex Analysis",
36 status: "Planned",
37 description: "Holomorphic functions, residues, contour integration, and conformal maps.",
38 detail: "Functions / Integrals / Residues",
39 },
40 {
41 title: "Function Analysis",
42 status: "Planned",
43 description: "Function spaces, operators, norms, convergence, and structural problem drills.",
44 detail: "Spaces / Operators / Norms",
45 },
46];
47
1export default function ProblemsPage() {48export default function ProblemsPage() {
2 return (49 return (
3 <div className="max-w-4xl mx-auto px-6 py-12">50 <>
4 <h151 <style>{problemHubStyles}</style>
5 className="text-2xl font-semibold mb-4"52 <div className="problem-shell">
6 style={{ color: "var(--text-primary)" }}53 <div className="problem-frame">
7 >54 <SurfaceNav variant="dark" />
8 Problems55
9 </h1>56 <header className="problem-hero">
10 <p style={{ color: "var(--text-secondary)" }}>57 <div>
11 Browse problems by subject, difficulty, or textbook.58 <p className="problem-kicker">SumTex Problems</p>
12 </p>59 <h1>Choose a subject</h1>
13 </div>60 <p>
61 Problem tracks begin here. Arithmetic lives under Play; broader math problem sets start with Algebra.
62 </p>
63 </div>
64 </header>
65
66 <section className="problem-grid" aria-label="Problem subjects">
67 {modes.map((mode, index) => (
68 <div key={mode.title} className="problem-mode disabled" aria-disabled="true">
69 <div className="problem-mode-index">{String(index + 1).padStart(2, "0")}</div>
70 <div className="problem-mode-body">
71 <div className="problem-mode-top">
72 <h2>{mode.title}</h2>
73 <span className="problem-status">{mode.status}</span>
74 </div>
75 <p>{mode.description}</p>
76 <div className="problem-mode-detail">{mode.detail}</div>
77 </div>
78 </div>
79 ))}
80 </section>
81 </div>
82 </div>
83 </>
14 );84 );
15}85}
86
87const problemHubStyles = `
88 .problem-shell {
89 --surface-bg: #ffffff;
90 --surface-bg-soft: #f6f6f6;
91 --surface-text: #171717;
92 --surface-strong: #000000;
93 --surface-muted: #555555;
94 --surface-faint: #8a8a8a;
95 --surface-line: #dedede;
96 --surface-pill: #f7f7f7;
97 min-height: 100vh;
98 background: var(--surface-bg);
99 color: var(--surface-text);
100 padding: 28px 5vw 70px;
101 }
102
103 .dark .problem-shell {
104 --surface-bg: #080808;
105 --surface-bg-soft: #0d0d0d;
106 --surface-text: #f4f2ee;
107 --surface-strong: #f5f3ef;
108 --surface-muted: #8d8d8d;
109 --surface-faint: #777;
110 --surface-line: #242424;
111 --surface-pill: #151515;
112 }
113
114 .problem-shell a { color: inherit; text-decoration: none; }
115 .problem-frame { margin: 0 auto; max-width: 1440px; }
116 .problem-hero { align-items: flex-start; display: flex; gap: 32px; justify-content: space-between; margin: 0 0 62px; }
117 .problem-kicker { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.18em; line-height: 1; margin: 0 0 22px; text-transform: uppercase; }
118 .problem-hero h1 { color: var(--surface-strong); font-size: clamp(52px, 7vw, 108px); font-weight: 400; letter-spacing: 0; line-height: 0.95; margin: 0; }
119 .problem-hero p { color: var(--surface-muted); font-size: 18px; line-height: 1.5; margin: 24px 0 0; max-width: 680px; }
120 .problem-grid { border-top: 1px solid var(--surface-line); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
121 .problem-mode { border-bottom: 1px solid var(--surface-line); color: var(--surface-text); display: grid; gap: 28px; grid-template-columns: 56px 1fr; min-height: 220px; padding: 34px 34px 32px 0; position: relative; }
122 .problem-mode:nth-child(odd) { border-right: 1px solid var(--surface-line); padding-right: 44px; }
123 .problem-mode:nth-child(even) { padding-left: 44px; }
124 .problem-mode.disabled { cursor: default; }
125 .problem-mode-index { color: var(--surface-faint); font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.14em; padding-top: 5px; }
126 .problem-mode-top { align-items: center; display: flex; gap: 18px; justify-content: space-between; }
127 .problem-mode h2 { color: var(--surface-strong); font-size: clamp(28px, 3vw, 46px); font-weight: 400; letter-spacing: 0; line-height: 1.08; margin: 0; }
128 .problem-mode p { color: var(--surface-muted); font-size: 17px; line-height: 1.5; margin: 30px 0 0; max-width: 540px; }
129 .problem-mode-detail { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; margin-top: 32px; text-transform: uppercase; }
130 .problem-status { border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-faint); flex: 0 0 auto; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.12em; padding: 7px 10px; text-transform: uppercase; }
131 .problem-mode-body { opacity: 0.72; }
132
133 @media (max-width: 900px) {
134 .problem-shell { padding: 28px 20px 56px; }
135 .problem-hero { align-items: stretch; flex-direction: column; margin-bottom: 40px; }
136 .problem-grid { grid-template-columns: 1fr; }
137 .problem-mode, .problem-mode:nth-child(even), .problem-mode:nth-child(odd) { border-right: 0; grid-template-columns: 42px 1fr; min-height: 190px; padding: 28px 0; }
138 }
139`;
src/app/(app)/profile/page.tsx+372−10
@@ -1,15 +1,377 @@
1"use client";
2
3import { useMemo, useState } from "react";
4import Link from "next/link";
5import { SurfaceNav } from "@/components/navigation/surfaceNav";
6
7type FeedTab = "activity" | "play" | "problems" | "guides";
8type FeedKind = "play" | "problems" | "guides";
9
10interface FeedItem {
11 title: string;
12 meta: string;
13 action: "published" | "practiced" | "completed" | "reviewed" | "started" | "solved";
14 kind: FeedKind;
15 subtype: string;
16 score?: string;
17 href?: string;
18}
19
20const stats = [
21 { label: "Sessions", value: "128", mark: "S", tone: "green" },
22 { label: "Attempts", value: "4,920", mark: "A" },
23 { label: "Accuracy", value: "91%", mark: "%", tone: "green" },
24 { label: "Best Run", value: "742", mark: "#" },
25 { label: "Guides", value: "3", mark: "G", tone: "green" },
26];
27
28const badges = [
29 { label: "Founder", value: "SumTex", tone: "strong" },
30 { label: "Developer", value: "Core", tone: "green" },
31 { label: "Account age", value: "6 months" },
32 { label: "Status", value: "Active" },
33 { label: "Role", value: "Platform" },
34];
35
36const feedItems: FeedItem[] = [
37 {
38 title: "Completed a 5-star subtraction run",
39 meta: "48/52 correct / mental arithmetic",
40 action: "completed",
41 kind: "play",
42 subtype: "subtraction",
43 score: "742",
44 href: "/play/mental-arithmetic?category=subtraction&star=5",
45 },
46 {
47 title: "Published A field guide to algebraic factoring",
48 meta: "Guide / algebra / 9 min",
49 action: "published",
50 kind: "guides",
51 subtype: "algebra",
52 score: "new",
53 href: "/paths/algebraic-factoring-field-guide",
54 },
55 {
56 title: "Practiced big multiply in millions",
57 meta: "31/34 correct / thousands to millions",
58 action: "practiced",
59 kind: "play",
60 subtype: "big multiply",
61 score: "611",
62 href: "/play/mental-arithmetic?category=big-multiply&star=4",
63 },
64 {
65 title: "Solved an intermediate algebra problem set",
66 meta: "Problems / algebra / 12 solved",
67 action: "solved",
68 kind: "problems",
69 subtype: "algebra",
70 score: "12",
71 href: "/problems",
72 },
73 {
74 title: "Reviewed Limits without mysticism",
75 meta: "Guide / calculus / epsilon delta",
76 action: "reviewed",
77 kind: "guides",
78 subtype: "calculus",
79 score: "updated",
80 href: "/paths/limits-without-mysticism",
81 },
82 {
83 title: "Practiced roots and powers",
84 meta: "27/30 correct / squares and cubes",
85 action: "practiced",
86 kind: "play",
87 subtype: "roots and powers",
88 score: "488",
89 href: "/play/mental-arithmetic?category=roots-powers&star=3",
90 },
91 {
92 title: "Started Complex numbers as geometry",
93 meta: "Guide / complex analysis / roots and rotations",
94 action: "started",
95 kind: "guides",
96 subtype: "complex analysis",
97 score: "8 min",
98 href: "/paths/complex-numbers-as-geometry",
99 },
100 {
101 title: "Solved a trigonometry warmup",
102 meta: "Problems / trigonometry / identities",
103 action: "solved",
104 kind: "problems",
105 subtype: "trigonometry",
106 score: "6",
107 href: "/problems",
108 },
109];
110
111const attempts = [
112 { expression: "874 - 392 = 482", result: "Correct", category: "subtraction" },
113 { expression: "43 x 17 = 731", result: "Correct", category: "multiplication" },
114 { expression: "sqrt(324) = 18", result: "Correct", category: "roots and powers" },
115 { expression: "15% of 360 = 54", result: "Correct", category: "percents" },
116];
117
118const tabLabels: Record<FeedTab, string> = {
119 activity: "Activity",
120 play: "Play",
121 problems: "Problems",
122 guides: "Guides",
123};
124
125const tabFilters: Record<FeedTab, string[]> = {
126 activity: ["all", "published", "practiced", "completed", "solved", "reviewed", "started"],
127 play: ["all", "subtraction", "big multiply", "roots and powers", "percents"],
128 problems: ["all", "algebra", "trigonometry", "geometry", "calculus"],
129 guides: ["all", "algebra", "calculus", "complex analysis"],
130};
131
1export default function ProfilePage() {132export default function ProfilePage() {
133 const [activeTab, setActiveTab] = useState<FeedTab>("activity");
134 const [filter, setFilter] = useState("all");
135
136 const shownItems = useMemo(() => {
137 return feedItems.filter((item) => {
138 const tabMatch = activeTab === "activity" || item.kind === activeTab;
139 const filterMatch = filter === "all" || item.subtype === filter || item.action === filter;
140 return tabMatch && filterMatch;
141 });
142 }, [activeTab, filter]);
143
144 function setTab(tab: FeedTab) {
145 setActiveTab(tab);
146 setFilter("all");
147 }
148
149 return (
150 <div className="act-shell">
151 <style>{profileStyles}</style>
152 <main className="act-main">
153 <SurfaceNav variant="light" />
154
155 <section className="act-profile">
156 <div className="act-avatar">
157 <img className="act-avatar-light" src="https://krisyotam.com/cdn/assets/logos/krisyotam-light.png" alt="Kris Yotam" />
158 <img className="act-avatar-dark" src="https://krisyotam.com/cdn/assets/logos/krisyotam-dark.png" alt="Kris Yotam" />
159 </div>
160 <div className="act-identity">
161 <div className="act-handle">@krisyotam</div>
162 <h1>Kris Yotam</h1>
163 <p className="act-bio">
164 Building SumTex as a place for fast arithmetic practice, long-form mathematical guides, and eventually serious problem solving.
165 </p>
166 <p>Maintaining since 2026</p>
167 </div>
168 </section>
169
170 <section className="act-badges" aria-label="Profile badges">
171 {badges.map((badge) => (
172 <div key={badge.label} className={`act-badge${badge.tone ? ` ${badge.tone}` : ""}`}>
173 <span>{badge.label}</span>
174 <strong>{badge.value}</strong>
175 </div>
176 ))}
177 </section>
178
179 <section className="act-stats" aria-label="Profile stats">
180 {stats.map((stat) => (
181 <Metric key={stat.label} label={stat.label} value={stat.value} tone={stat.tone} />
182 ))}
183 </section>
184
185 <section className="act-feed">
186 <div className="act-feed-tabs">
187 <div className="act-tab-group">
188 {(Object.keys(tabLabels) as FeedTab[]).map((tab) => (
189 <button
190 key={tab}
191 className={`act-tab${activeTab === tab ? " active" : ""}`}
192 type="button"
193 onClick={() => setTab(tab)}
194 >
195 {tabLabels[tab]}
196 </button>
197 ))}
198 </div>
199 <div className="act-feed-actions">
200 <label className="act-filter-label">
201 <span>{activeTab === "activity" ? "Type" : "Subtype"}</span>
202 <select value={filter} onChange={(event) => setFilter(event.target.value)}>
203 {tabFilters[activeTab].map((option) => (
204 <option key={option} value={option}>
205 {option}
206 </option>
207 ))}
208 </select>
209 </label>
210 <Link className="act-tab" href="/profile/settings">Manage account</Link>
211 <Link className="act-tab" href="/play/mental-arithmetic?category=subtraction&star=5">Play</Link>
212 </div>
213 </div>
214
215 <div className="act-feed-list">
216 {shownItems.map((item) => (
217 <FeedRow key={`${item.action}-${item.title}`} item={item} />
218 ))}
219 {shownItems.length === 0 && (
220 <div className="act-empty">No demo activity matches this filter.</div>
221 )}
222 </div>
223 </section>
224
225 <section className="act-feed act-attempts">
226 <div className="act-feed-tabs">
227 <div>
228 <button className="act-tab active" type="button">Recent attempts</button>
229 </div>
230 <Link className="act-tab" href="/play">Modes</Link>
231 </div>
232 <div className="act-feed-list">
233 {attempts.map((attempt) => (
234 <div key={attempt.expression} className="act-feed-item">
235 <div className="act-feed-row">
236 <div>
237 <span className="act-feed-title">{attempt.expression}</span>
238 <span className="act-feed-time">{attempt.category}</span>
239 </div>
240 <div className="act-feed-side">
241 <span className="act-pill green">{attempt.result}</span>
242 </div>
243 </div>
244 </div>
245 ))}
246 </div>
247 </section>
248 </main>
249 </div>
250 );
251}
252
253function FeedRow({ item }: { item: FeedItem }) {
254 const row = (
255 <div className="act-feed-row">
256 <div>
257 <div className="act-kind-line">
258 <span className={`act-action act-action-${item.action}`}>{item.action}</span>
259 <span className="act-kind">{item.kind}</span>
260 <span className="act-kind">{item.subtype}</span>
261 </div>
262 <span className="act-feed-title">{item.title}</span>
263 <span className="act-feed-time">{item.meta}</span>
264 </div>
265 <div className="act-feed-side">
266 {item.score && <span className="act-pill">{item.score}</span>}
267 </div>
268 </div>
269 );
270
271 return (
272 <div className="act-feed-item">
273 {item.href ? <Link href={item.href}>{row}</Link> : row}
274 </div>
275 );
276}
277
278function Metric({ label, value, tone }: { label: string; value: string; tone?: string }) {
279 const stat = stats.find((item) => item.label === label);
280
2 return (281 return (
3 <div className="max-w-4xl mx-auto px-6 py-12">282 <div className="act-stat">
4 <h1283 <div className={`act-stat-badge${tone === "green" ? " green" : ""}`}>{stat?.mark ?? label.slice(0, 1)}</div>
5 className="text-2xl font-semibold mb-4"284 <div className={`act-stat-value${tone === "green" ? " green" : ""}`}>{value}</div>
6 style={{ color: "var(--text-primary)" }}285 <div className="act-stat-label">{label}</div>
7 >
8 Profile
9 </h1>
10 <p style={{ color: "var(--text-secondary)" }}>
11 Edit your profile, bio, and links.
12 </p>
13 </div>286 </div>
14 );287 );
15}288}
289
290const profileStyles = `
291 .act-shell {
292 --surface-bg: #ffffff;
293 --surface-card: #ffffff;
294 --surface-text: #333;
295 --surface-strong: #000;
296 --surface-muted: #666;
297 --surface-faint: #888;
298 --surface-line: #e0e0e0;
299 --surface-soft: #f4f4f4;
300 min-height: 100vh;
301 background: var(--surface-bg);
302 color: var(--surface-text);
303 }
304
305 .dark .act-shell {
306 --surface-bg: #0d0d0d;
307 --surface-card: #111;
308 --surface-text: #fff;
309 --surface-strong: #fff;
310 --surface-muted: #bdbdbd;
311 --surface-faint: #888;
312 --surface-line: #2a2a2a;
313 --surface-soft: #1c1c1c;
314 }
315
316 .act-shell a { color: inherit; text-decoration: none; }
317 .act-main { margin: 0 auto; max-width: 980px; padding: 28px 20px 72px; }
318 .act-profile { align-items: center; display: flex; gap: 24px; justify-content: flex-start; margin: 10px 0 42px; text-align: left; width: 100%; }
319 .act-avatar { align-items: center; background: var(--surface-card); border: 2px solid var(--surface-strong); border-radius: 50%; display: flex; flex-shrink: 0; height: 104px; justify-content: center; overflow: hidden; width: 104px; }
320 .dark .act-avatar { border-color: #fff; }
321 .act-avatar img { display: block; height: 100%; object-fit: cover; width: 100%; }
322 .act-avatar-dark { display: none !important; }
323 .dark .act-avatar-light { display: none !important; }
324 .dark .act-avatar-dark { display: block !important; }
325 .act-handle { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.12em; margin-bottom: 6px; text-transform: uppercase; }
326 .act-identity h1 { color: var(--surface-strong); font-size: 34px; font-weight: 600; letter-spacing: 0; line-height: 1.1; margin: 0 0 8px; }
327 .act-identity p { color: var(--surface-muted); font-size: 14px; line-height: 1.45; margin: 0; max-width: 520px; }
328 .act-identity .act-bio { margin-bottom: 8px; }
329 .act-badges { display: flex; flex-wrap: wrap; gap: 10px; margin: -22px 0 28px; }
330 .act-badge { align-items: center; background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 999px; display: inline-flex; gap: 9px; min-height: 34px; padding: 5px 12px; }
331 .act-badge span { color: var(--surface-faint); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
332 .act-badge strong { color: var(--surface-strong); font-family: var(--font-mono); font-size: 12px; font-weight: 600; text-transform: uppercase; }
333 .act-badge.strong { background: var(--surface-strong); border-color: var(--surface-strong); }
334 .act-badge.strong span, .act-badge.strong strong { color: var(--surface-bg); }
335 .act-badge.green { border-color: rgba(47, 111, 66, 0.35); }
336 .act-badge.green strong { color: #2f6f42; }
337 .act-stats { display: grid; gap: 16px; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0 0 28px; }
338 .act-stat { align-items: center; background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 12px; display: flex; flex-direction: column; padding: 15px 12px 13px; text-align: center; }
339 .act-stat-badge { align-items: center; background: var(--surface-soft); border-radius: 999px; color: var(--surface-muted); display: flex; font-family: var(--font-mono); font-size: 13px; height: 30px; justify-content: center; margin-bottom: 12px; width: 30px; }
340 .act-stat-badge.green { background: rgba(47, 111, 66, 0.12); color: #2f6f42; }
341 .act-stat-value { color: var(--surface-strong); font-family: var(--font-mono); font-size: 24px; font-weight: 600; line-height: 1.1; }
342 .act-stat-value.green { color: #2f6f42; }
343 .act-stat-label { color: var(--surface-faint); font-size: 12px; line-height: 1.2; margin-top: 5px; }
344 .act-feed { background: transparent; margin-top: 22px; }
345 .act-feed-tabs { align-items: center; border-bottom: 1px solid var(--surface-line); display: flex; gap: 18px; justify-content: space-between; padding: 10px; }
346 .act-tab-group, .act-feed-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 4px; }
347 .act-feed-actions { justify-content: flex-end; }
348 .act-tab { background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--surface-muted); cursor: pointer; display: inline-flex; font-size: 13px; line-height: 1; margin-bottom: -11px; padding: 8px 10px 18px; text-decoration: none; }
349 .act-tab.active { border-bottom-color: var(--surface-strong); color: var(--surface-strong); }
350 .act-filter-label { align-items: center; color: var(--surface-faint); display: inline-flex; font-family: var(--font-mono); font-size: 11px; gap: 8px; letter-spacing: 0.08em; margin-right: 4px; text-transform: uppercase; }
351 .act-filter-label select { appearance: none; background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); cursor: pointer; font-family: var(--font-mono); font-size: 12px; height: 32px; line-height: 1; padding: 0 28px 0 12px; text-transform: lowercase; }
352 .act-feed-list { display: flex; flex-direction: column; gap: 10px; padding-top: 18px; }
353 .act-feed-item { background: var(--surface-card); border: 1px solid var(--surface-line); border-radius: 12px; overflow: hidden; }
354 .act-feed-row { align-items: center; background: transparent; border: 0; color: var(--surface-text); display: grid; gap: 16px; grid-template-columns: 1fr auto; padding: 16px 18px; text-align: left; width: 100%; }
355 .act-kind-line { align-items: center; display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 8px; }
356 .act-action, .act-kind { border-radius: 999px; display: inline-flex; font-family: var(--font-mono); font-size: 11px; line-height: 1; padding: 4px 8px; text-transform: lowercase; }
357 .act-action { background: #101010; border: 1px solid #101010; color: #fff; }
358 .dark .act-action { background: #fff; border-color: #fff; color: #000; }
359 .act-action-published { background: #2f6f42; border-color: #2f6f42; color: #fff; }
360 .act-action-practiced, .act-action-completed, .act-action-solved { background: var(--surface-soft); border: 1px solid var(--surface-line); color: var(--surface-strong); }
361 .act-kind { background: transparent; border: 1px solid var(--surface-line); color: var(--surface-faint); }
362 .act-feed-title { color: var(--surface-strong); display: block; font-size: 15px; font-weight: 500; }
363 .act-feed-time, .act-pill { color: var(--surface-faint); font-size: 12px; }
364 .act-feed-side { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
365 .act-pill { background: var(--surface-soft); border: 1px solid var(--surface-line); border-radius: 999px; font-family: var(--font-mono); padding: 4px 9px; text-transform: lowercase; }
366 .act-pill.green { color: #2f6f42; }
367 .act-empty { border: 1px solid var(--surface-line); border-radius: 12px; color: var(--surface-muted); font-size: 14px; padding: 24px; text-align: center; }
368 @media (max-width: 900px) {
369 .act-profile { align-items: center; flex-direction: column; text-align: center; }
370 .act-badges { justify-content: center; margin-top: -20px; }
371 .act-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
372 .act-feed-tabs { align-items: flex-start; flex-direction: column; gap: 8px; }
373 .act-feed-actions { justify-content: flex-start; }
374 .act-feed-row { grid-template-columns: 1fr; }
375 .act-feed-side { justify-content: flex-start; }
376 }
377`;
src/app/(app)/profile/settings/page.tsx+49−12
@@ -1,15 +1,52 @@
1export default function SettingsPage() {1import { UserProfile } from "@clerk/nextjs";
2import { currentUser } from "@clerk/nextjs/server";
3import { SurfaceNav } from "@/components/navigation/surfaceNav";
4
5export default async function SettingsPage() {
6 const user = await currentUser();
7
2 return (8 return (
3 <div className="max-w-4xl mx-auto px-6 py-12">9 <main className="settings-shell">
4 <h110 <style>{settingsStyles}</style>
5 className="text-2xl font-semibold mb-4"11 <div className="settings-frame">
6 style={{ color: "var(--text-primary)" }}12 <SurfaceNav variant="light" />
7 >13 <section className="settings-head">
8 Settings14 <p>SumTex Profile</p>
9 </h1>15 <h1>Account management</h1>
10 <p style={{ color: "var(--text-secondary)" }}>16 </section>
11 Manage your API keys, custom CSS, and account settings.17 {user ? (
12 </p>18 <UserProfile
13 </div>19 routing="path"
20 path="/profile/settings"
21 appearance={{
22 elements: {
23 rootBox: "settings-clerk-root",
24 cardBox: "settings-clerk-card",
25 },
26 }}
27 />
28 ) : (
29 <section className="settings-empty">
30 <h2>Sign in to manage your account.</h2>
31 <a href="/sign-in">Sign in</a>
32 </section>
33 )}
34 </div>
35 </main>
14 );36 );
15}37}
38
39const settingsStyles = `
40 .settings-shell { min-height: 100vh; background: #f0f0f0; color: #333; padding: 28px 20px 72px; }
41 .dark .settings-shell { background: #0d0d0d; color: #fff; }
42 .settings-frame { margin: 0 auto; max-width: 980px; }
43 .settings-head { margin: 0 0 28px; }
44 .settings-head p { color: #777; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; margin: 0 0 10px; text-transform: uppercase; }
45 .settings-head h1 { color: inherit; font-size: clamp(38px, 6vw, 72px); font-weight: 400; letter-spacing: 0; line-height: 1; margin: 0; }
46 .settings-empty { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 28px; }
47 .dark .settings-empty { background: #111; border-color: #2a2a2a; }
48 .settings-empty h2 { color: inherit; font-size: 22px; font-weight: 500; letter-spacing: 0; margin: 0 0 18px; }
49 .settings-empty a { border: 1px solid #d8d8d8; border-radius: 999px; color: inherit; display: inline-flex; font-family: var(--font-mono); font-size: 12px; height: 36px; align-items: center; padding: 0 14px; text-decoration: none; text-transform: uppercase; }
50 .settings-clerk-root { width: 100%; }
51 .settings-clerk-card { border-radius: 8px; box-shadow: none; width: 100%; }
52`;
src/app/api/arithmetic/attempts/route.ts+78−0
@@ -0,0 +1,78 @@
1import { NextResponse } from "next/server";
2import { auth } from "@clerk/nextjs/server";
3import { and, eq, sql } from "drizzle-orm";
4import { z } from "zod";
5import { getCurrentLocalUser } from "@/lib/auth/user";
6import { db } from "@/lib/db";
7import { arithmeticAttempts, arithmeticSessions } from "@/lib/db/schema";
8
9const payloadSchema = z.object({
10 sessionId: z.string().uuid().nullable(),
11 problem: z.object({
12 category: z.string(),
13 submode: z.string().optional(),
14 star: z.number().int().min(1).max(5),
15 expression: z.string(),
16 canonicalAnswer: z.string(),
17 answerFormat: z.string(),
18 acceptsFractions: z.boolean(),
19 acceptsDecimals: z.boolean(),
20 metadata: z.record(z.string(), z.unknown()),
21 }),
22 userAnswer: z.string(),
23 correct: z.boolean(),
24 elapsedMs: z.number().int().min(0),
25 scoreDelta: z.number().int().min(0),
26});
27
28export async function POST(request: Request) {
29 const clerkSession = await auth();
30 const payload = payloadSchema.parse(await request.json());
31
32 if (!clerkSession.isAuthenticated || !payload.sessionId) {
33 return NextResponse.json({ persisted: false });
34 }
35
36 const user = await getCurrentLocalUser();
37
38 if (!user) {
39 return NextResponse.json({ persisted: false });
40 }
41
42 const arithmeticSession = await db.query.arithmeticSessions.findFirst({
43 where: and(
44 eq(arithmeticSessions.id, payload.sessionId),
45 eq(arithmeticSessions.userId, user.id)
46 ),
47 });
48
49 if (!arithmeticSession) {
50 return NextResponse.json({ persisted: false }, { status: 404 });
51 }
52
53 await db.insert(arithmeticAttempts).values({
54 sessionId: payload.sessionId,
55 generatedPrompt: payload.problem,
56 displayExpression: payload.problem.expression,
57 canonicalAnswer: payload.problem.canonicalAnswer,
58 userAnswer: payload.userAnswer,
59 correct: payload.correct,
60 elapsedMs: payload.elapsedMs,
61 scoreDelta: payload.scoreDelta,
62 category: payload.problem.category,
63 submode: payload.problem.submode ?? null,
64 star: payload.problem.star,
65 metadata: payload.problem.metadata,
66 });
67
68 await db
69 .update(arithmeticSessions)
70 .set({
71 score: sql`${arithmeticSessions.score} + ${payload.scoreDelta}`,
72 totalAttempts: sql`${arithmeticSessions.totalAttempts} + 1`,
73 correctAttempts: sql`${arithmeticSessions.correctAttempts} + ${payload.correct ? 1 : 0}`,
74 })
75 .where(eq(arithmeticSessions.id, payload.sessionId));
76
77 return NextResponse.json({ persisted: true });
78}
src/app/api/arithmetic/end/route.ts+43−0
@@ -0,0 +1,43 @@
1import { NextResponse } from "next/server";
2import { auth } from "@clerk/nextjs/server";
3import { and, eq } from "drizzle-orm";
4import { z } from "zod";
5import { getCurrentLocalUser } from "@/lib/auth/user";
6import { db } from "@/lib/db";
7import { arithmeticSessions } from "@/lib/db/schema";
8
9const payloadSchema = z.object({
10 sessionId: z.string().uuid().nullable(),
11 score: z.number().int().min(0),
12 totalAttempts: z.number().int().min(0),
13 correctAttempts: z.number().int().min(0),
14});
15
16export async function POST(request: Request) {
17 const session = await auth();
18 const payload = payloadSchema.parse(await request.json());
19
20 if (!session.isAuthenticated || !payload.sessionId) {
21 return NextResponse.json({ persisted: false });
22 }
23
24 const user = await getCurrentLocalUser();
25
26 if (!user) {
27 return NextResponse.json({ persisted: false });
28 }
29
30 await db
31 .update(arithmeticSessions)
32 .set({
33 score: payload.score,
34 totalAttempts: payload.totalAttempts,
35 correctAttempts: payload.correctAttempts,
36 endedAt: new Date(),
37 })
38 .where(
39 and(eq(arithmeticSessions.id, payload.sessionId), eq(arithmeticSessions.userId, user.id))
40 );
41
42 return NextResponse.json({ persisted: true });
43}
src/app/api/arithmetic/sessions/route.ts+52−0
@@ -0,0 +1,52 @@
1import { NextResponse } from "next/server";
2import { auth } from "@clerk/nextjs/server";
3import { z } from "zod";
4import { getCurrentLocalUser } from "@/lib/auth/user";
5import { db } from "@/lib/db";
6import { arithmeticSessions } from "@/lib/db/schema";
7
8const payloadSchema = z.object({
9 config: z.object({
10 mode: z.enum(["standard", "custom"]),
11 category: z.string(),
12 submode: z.string().optional(),
13 star: z.number().int().min(1).max(5),
14 runMode: z.enum(["untimed", "30s", "60s", "120s", "300s"]),
15 magnitude: z.string().optional(),
16 customConfig: z.unknown().optional(),
17 }),
18 seed: z.number().int(),
19});
20
21export async function POST(request: Request) {
22 const clerkSession = await auth();
23
24 if (!clerkSession.isAuthenticated) {
25 return NextResponse.json({ persisted: false, sessionId: null });
26 }
27
28 const payload = payloadSchema.parse(await request.json());
29 const user = await getCurrentLocalUser();
30
31 if (!user) {
32 return NextResponse.json({ persisted: false, sessionId: null });
33 }
34
35 const durationSeconds =
36 payload.config.runMode === "untimed" ? null : Number(payload.config.runMode.replace("s", ""));
37 const [createdSession] = await db
38 .insert(arithmeticSessions)
39 .values({
40 userId: user.id,
41 mode: payload.config.mode,
42 category: payload.config.category,
43 submode: payload.config.submode ?? null,
44 star: payload.config.star,
45 customConfig: payload.config.customConfig,
46 seed: payload.seed,
47 durationSeconds,
48 })
49 .returning({ id: arithmeticSessions.id });
50
51 return NextResponse.json({ persisted: true, sessionId: createdSession?.id ?? null });
52}
src/app/globals.css+736−6
@@ -9,8 +9,8 @@ · 8 unmodified lines
9 --text-secondary: #666666;9 --text-secondary: #666666;
10 --text-muted: #888888;10 --text-muted: #888888;
11 --border-color: #e0e0e0;11 --border-color: #e0e0e0;
12 --link-color: rgba(10, 132, 255, 1);12 --link-color: var(--text-primary);
13 --link-visited: #af52de;13 --link-visited: var(--text-secondary);
14 --accent: #333333;14 --accent: #333333;
15 --accent-muted: #666666;15 --accent-muted: #666666;
16 16
@@ -52,8 +52,8 @@ · 35 unmodified lines
52 --text-secondary: hsl(0 0% 75%);52 --text-secondary: hsl(0 0% 75%);
53 --text-muted: hsl(0 0% 50%);53 --text-muted: hsl(0 0% 50%);
54 --border-color: hsl(0 0% 20%);54 --border-color: hsl(0 0% 20%);
55 --link-color: #79a8ff;55 --link-color: var(--text-primary);
56 --link-visited: #f78fe7;56 --link-visited: var(--text-secondary);
57 --accent: hsl(0 0% 100%);57 --accent: hsl(0 0% 100%);
58 --accent-muted: hsl(0 0% 75%);58 --accent-muted: hsl(0 0% 75%);
59 59
@@ -140,10 +140,740 @@ body { · 80 unmodified lines
140}140}
141 141
142a {142a {
143 color: var(--link-color);143 color: inherit;
144 text-decoration: none;144 text-decoration: none;
145}145}
146 146
147a:hover {147a:hover {
148 text-decoration: underline;148 text-decoration: none;
149}
150
151button,
152input {
153 font: inherit;
154}
155
156.math-display {
157 display: block;
158 font-family: var(--font-mono);
159 overflow-x: auto;
160 white-space: nowrap;
161}
162
163.math-display-inline {
164 display: inline;
165 overflow: visible;
166 white-space: normal;
167}
168
169.math-display-block {
170 border: 1px solid var(--surface-line, var(--border-color));
171 border-radius: 8px;
172 color: var(--surface-strong, var(--text-primary));
173 margin: 22px 0;
174 padding: 16px 18px;
175}
176
177.math-display-render {
178 display: none;
179}
180
181.auth-shell {
182 align-items: center;
183 background: #080808;
184 display: flex;
185 justify-content: center;
186 min-height: 100vh;
187 padding: 28px;
188}
189
190.surface-nav {
191 align-items: center;
192 display: flex;
193 justify-content: space-between;
194 margin: 0 0 48px;
195}
196
197.surface-brand {
198 font-family: var(--font-mono);
199 font-size: 13px;
200 font-weight: 600;
201 letter-spacing: 0.16em;
202 text-decoration: none;
203 text-transform: uppercase;
204}
205
206.surface-nav-links {
207 align-items: center;
208 display: flex;
209 flex-wrap: wrap;
210 gap: 6px;
211 justify-content: flex-end;
212}
213
214.surface-nav-links a,
215.surface-theme-toggle,
216.surface-auth-button {
217 align-items: center;
218 background: transparent;
219 border-radius: 999px;
220 cursor: pointer;
221 display: inline-flex;
222 font-family: var(--font-mono);
223 font-size: 12px;
224 height: 36px;
225 justify-content: center;
226 line-height: 1;
227 padding: 0 14px;
228 text-decoration: none;
229 text-transform: uppercase;
230}
231
232.surface-theme-toggle {
233 padding: 0;
234 width: 36px;
235}
236
237.surface-user-avatar {
238 height: 36px;
239 width: 36px;
240}
241
242.surface-user-popover {
243 border-radius: 8px;
244}
245
246.surface-nav-dark .surface-brand {
247 color: var(--surface-strong, #f4f2ee);
248}
249
250.surface-nav-dark .surface-nav-links a,
251.surface-nav-dark .surface-theme-toggle,
252.surface-nav-dark .surface-auth-button {
253 border: 1px solid var(--surface-line, #dedede);
254 color: var(--surface-muted, #aaa);
255}
256
257.surface-nav-dark .surface-nav-links a:hover,
258.surface-nav-dark .surface-theme-toggle:hover,
259.surface-nav-dark .surface-auth-button:hover {
260 border-color: var(--surface-muted, #666);
261 color: var(--surface-strong, #f4f2ee);
262}
263
264.surface-nav-light .surface-brand {
265 color: var(--surface-strong, #333);
266}
267
268.dark .surface-nav-light .surface-brand {
269 color: var(--surface-strong, #fff);
270}
271
272.surface-nav-light .surface-nav-links a,
273.surface-nav-light .surface-theme-toggle,
274.surface-nav-light .surface-auth-button {
275 border: 1px solid var(--surface-line, #dedede);
276 color: var(--surface-muted, #555);
277}
278
279.surface-nav-light .surface-nav-links a:hover,
280.surface-nav-light .surface-theme-toggle:hover,
281.surface-nav-light .surface-auth-button:hover {
282 border-color: var(--surface-muted, #777);
283 color: var(--surface-strong, #333);
284}
285
286.dark .surface-nav-light .surface-nav-links a,
287.dark .surface-nav-light .surface-theme-toggle,
288.dark .surface-nav-light .surface-auth-button {
289 border-color: var(--surface-line, #2a2a2a);
290 color: var(--surface-muted, #aaa);
291}
292
293.dark .surface-nav-light .surface-nav-links a:hover,
294.dark .surface-nav-light .surface-theme-toggle:hover,
295.dark .surface-nav-light .surface-auth-button:hover {
296 border-color: var(--surface-muted, #666);
297 color: var(--surface-strong, #fff);
298}
299
300@media (max-width: 900px) {
301 .surface-nav {
302 align-items: flex-start;
303 flex-direction: column;
304 gap: 18px;
305 margin-bottom: 38px;
306 }
307
308 .surface-nav-links {
309 justify-content: flex-start;
310 }
311}
312
313.sumtex-dark-shell {
314 min-height: calc(100vh - var(--topbar-height));
315 background: #080808;
316 color: #f4f2ee;
317}
318
319.ma-shell {
320 min-height: calc(100vh - var(--topbar-height));
321 background: #080808;
322 color: #f4f2ee;
323 padding: 34px 5vw 56px;
324}
325
326.ma-frame {
327 margin: 0 auto;
328 max-width: 1440px;
329}
330
331.ma-hero {
332 display: flex;
333 align-items: flex-start;
334 justify-content: space-between;
335 gap: 32px;
336 margin: 0 0 46px;
337}
338
339.ma-kicker {
340 color: #777;
341 font-family: var(--font-mono);
342 font-size: 13px;
343 letter-spacing: 0.18em;
344 line-height: 1;
345 margin: 0 0 22px;
346 text-transform: uppercase;
347}
348
349.ma-title {
350 color: #f5f3ef;
351 font-size: clamp(44px, 6vw, 92px);
352 font-weight: 400;
353 letter-spacing: 0;
354 line-height: 0.95;
355 margin: 0;
356}
357
358.ma-subtitle {
359 color: #8d8d8d;
360 font-size: 18px;
361 line-height: 1.5;
362 margin: 22px 0 0;
363 max-width: 620px;
364}
365
366.ma-primary-action {
367 align-items: center;
368 background: transparent;
369 border: 1px solid #3a3a3a;
370 border-radius: 999px;
371 color: #f1eee8;
372 cursor: pointer;
373 display: inline-flex;
374 font-family: var(--font-mono);
375 font-size: 13px;
376 font-weight: 600;
377 justify-content: center;
378 letter-spacing: 0.14em;
379 min-height: 54px;
380 padding: 0 28px;
381 text-transform: uppercase;
382 transition: border-color 0.2s ease, background 0.2s ease;
383 white-space: nowrap;
384}
385
386.ma-primary-action:hover {
387 background: #151515;
388 border-color: #686868;
389}
390
391.ma-layout {
392 align-items: start;
393 display: grid;
394 gap: 44px;
395 grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
396}
397
398.ma-rail {
399 border-left: 1px solid #242424;
400 display: flex;
401 flex-direction: column;
402 gap: 32px;
403 padding-left: 26px;
404}
405
406.ma-group {
407 display: grid;
408 gap: 12px;
409}
410
411.ma-label {
412 color: #777;
413 font-family: var(--font-mono);
414 font-size: 12px;
415 letter-spacing: 0.14em;
416 line-height: 1;
417 text-transform: uppercase;
418}
419
420.ma-category-list {
421 display: grid;
422 gap: 1px;
423}
424
425.ma-line-option,
426.ma-toggle-row,
427.ma-number-row {
428 background: transparent;
429 border: 0;
430 border-bottom: 1px solid #1d1d1d;
431 color: #d8d5ce;
432 cursor: pointer;
433 display: flex;
434 gap: 16px;
435 justify-content: space-between;
436 min-height: 46px;
437 padding: 12px 0;
438 text-align: left;
439}
440
441.ma-line-option:hover,
442.ma-line-option.active {
443 color: #fff;
444}
445
446.ma-line-option.active .ma-option-name::before {
447 background: #f4f2ee;
448}
449
450.ma-option-name {
451 align-items: center;
452 display: inline-flex;
453 font-size: 15px;
454 gap: 10px;
455}
456
457.ma-option-name::before {
458 background: #363636;
459 border-radius: 999px;
460 content: "";
461 display: inline-block;
462 height: 7px;
463 width: 7px;
464}
465
466.ma-option-meta {
467 color: #6f6f6f;
468 font-size: 12px;
469 max-width: 130px;
470 text-align: right;
471}
472
473.ma-pill-row {
474 display: flex;
475 flex-wrap: wrap;
476 gap: 8px;
477}
478
479.ma-pill {
480 background: transparent;
481 border: 1px solid #343434;
482 border-radius: 999px;
483 color: #b8b4ad;
484 cursor: pointer;
485 font-family: var(--font-mono);
486 font-size: 12px;
487 min-height: 36px;
488 padding: 0 14px;
489 text-transform: uppercase;
490}
491
492.ma-pill.active {
493 background: #f1eee8;
494 border-color: #f1eee8;
495 color: #080808;
496}
497
498.ma-stage {
499 border-left: 1px solid #242424;
500 min-height: 650px;
501 padding-left: clamp(28px, 5vw, 76px);
502}
503
504.ma-metrics {
505 display: grid;
506 gap: 1px;
507 grid-template-columns: repeat(3, minmax(0, 1fr));
508 margin: 0 0 42px;
509}
510
511.ma-metric {
512 border-top: 1px solid #242424;
513 padding: 18px 22px 0 0;
514}
515
516.ma-metric-label {
517 color: #777;
518 font-family: var(--font-mono);
519 font-size: 11px;
520 letter-spacing: 0.14em;
521 text-transform: uppercase;
522}
523
524.ma-metric-value {
525 color: #f1eee8;
526 font-family: var(--font-mono);
527 font-size: 30px;
528 line-height: 1.2;
529 margin-top: 8px;
530}
531
532.ma-problem-wrap {
533 margin: 0 auto;
534 max-width: 760px;
535 text-align: center;
536}
537
538.ma-expression {
539 color: #f7f4ef;
540 font-family: var(--font-mono);
541 font-size: clamp(46px, 7vw, 96px);
542 font-weight: 500;
543 letter-spacing: 0;
544 line-height: 1.05;
545 margin: 50px 0 34px;
546 min-height: 110px;
547}
548
549.ma-expression.correct {
550 color: #7fb68d;
551}
552
553.ma-expression.wrong {
554 color: #c98462;
555}
556
557.ma-answer {
558 background: #111;
559 border: 1px solid #2f2f2f;
560 border-radius: 999px;
561 color: #f4f2ee;
562 font-family: var(--font-mono);
563 font-size: 28px;
564 height: 66px;
565 outline: none;
566 padding: 0 26px;
567 text-align: center;
568 width: min(100%, 560px);
569}
570
571.ma-answer:focus {
572 border-color: #777;
573}
574
575.ma-keypad {
576 display: grid;
577 gap: 10px;
578 grid-template-columns: repeat(4, minmax(0, 1fr));
579 margin: 24px auto 0;
580 max-width: 560px;
581}
582
583.ma-key {
584 background: #101010;
585 border: 1px solid #292929;
586 border-radius: 999px;
587 color: #d8d5ce;
588 cursor: pointer;
589 font-family: var(--font-mono);
590 font-size: 15px;
591 height: 52px;
592}
593
594.ma-key:hover {
595 background: #191919;
596 border-color: #555;
597}
598
599.ma-empty {
600 align-items: center;
601 display: flex;
602 flex-direction: column;
603 justify-content: center;
604 min-height: 450px;
605 text-align: center;
606}
607
608.ma-empty h2 {
609 color: #f4f2ee;
610 font-size: clamp(42px, 6vw, 82px);
611 font-weight: 400;
612 letter-spacing: 0;
613 line-height: 1;
614 margin: 0;
615}
616
617.ma-empty p {
618 color: #8d8d8d;
619 font-size: 17px;
620 line-height: 1.5;
621 margin: 20px 0 0;
622 max-width: 520px;
623}
624
625.ma-recent {
626 border-top: 1px solid #242424;
627 margin-top: 52px;
628 padding-top: 22px;
629}
630
631.ma-recent h3 {
632 color: #777;
633 font-family: var(--font-mono);
634 font-size: 12px;
635 letter-spacing: 0.14em;
636 margin: 0 0 14px;
637 text-transform: uppercase;
638}
639
640.ma-recent-row {
641 border-bottom: 1px solid #1c1c1c;
642 color: #a8a39a;
643 display: grid;
644 font-family: var(--font-mono);
645 font-size: 13px;
646 gap: 16px;
647 grid-template-columns: 1fr auto;
648 padding: 12px 0;
649}
650
651.ma-number-row {
652 align-items: center;
653 color: #d8d5ce;
654 cursor: default;
655}
656
657.ma-number-row input {
658 background: #111;
659 border: 1px solid #333;
660 border-radius: 999px;
661 color: #f4f2ee;
662 height: 34px;
663 padding: 0 12px;
664 text-align: center;
665 width: 78px;
666}
667
668.ma-toggle-row {
669 align-items: center;
670 color: #d8d5ce;
671 cursor: default;
672}
673
674.ma-toggle-row input {
675 accent-color: #f1eee8;
676}
677
678.act-shell {
679 min-height: calc(100vh - var(--topbar-height));
680 background: var(--bg-canvas);
681 color: var(--text-primary);
682}
683
684.act-main {
685 margin: 0 auto;
686 max-width: 980px;
687 padding: 44px 20px 72px;
688}
689
690.act-profile {
691 align-items: center;
692 display: flex;
693 gap: 24px;
694 justify-content: center;
695 margin: 10px auto 34px;
696 text-align: left;
697}
698
699.act-avatar {
700 align-items: center;
701 background: var(--bg-primary);
702 border: 1px solid var(--border-color);
703 border-radius: 50%;
704 display: flex;
705 font-size: 42px;
706 font-weight: 600;
707 height: 104px;
708 justify-content: center;
709 overflow: hidden;
710 width: 104px;
711}
712
713.act-identity h1 {
714 color: var(--text-primary);
715 font-size: 32px;
716 font-weight: 600;
717 letter-spacing: 0;
718 line-height: 1.1;
719 margin: 0 0 8px;
720}
721
722.act-identity p {
723 color: var(--text-secondary);
724 font-size: 14px;
725 line-height: 1.45;
726 margin: 0;
727}
728
729.act-stats {
730 display: grid;
731 gap: 12px;
732 grid-template-columns: repeat(5, minmax(0, 1fr));
733 margin: 0 0 28px;
734}
735
736.act-stat {
737 background: var(--bg-primary);
738 border: 1px solid var(--border-color);
739 border-radius: 8px;
740 padding: 16px;
741}
742
743.act-stat-badge {
744 align-items: center;
745 background: var(--bg-tertiary);
746 border-radius: 999px;
747 color: var(--text-secondary);
748 display: flex;
749 height: 30px;
750 justify-content: center;
751 margin-bottom: 12px;
752 width: 30px;
753}
754
755.act-stat-value {
756 color: var(--text-primary);
757 font-family: var(--font-mono);
758 font-size: 24px;
759 font-weight: 600;
760 line-height: 1.1;
761}
762
763.act-stat-label {
764 color: var(--text-muted);
765 font-size: 12px;
766 line-height: 1.2;
767 margin-top: 5px;
768}
769
770.act-feed {
771 background: var(--bg-primary);
772 border: 1px solid var(--border-color);
773 border-radius: 8px;
774 overflow: hidden;
775}
776
777.act-feed-tabs {
778 align-items: center;
779 border-bottom: 1px solid var(--border-color);
780 display: flex;
781 justify-content: space-between;
782 padding: 10px;
783}
784
785.act-tab {
786 background: transparent;
787 border: 0;
788 border-radius: 6px;
789 color: var(--text-secondary);
790 cursor: pointer;
791 font-size: 13px;
792 padding: 8px 12px;
793}
794
795.act-tab.active {
796 background: var(--bg-tertiary);
797 color: var(--text-primary);
798}
799
800.act-feed-list {
801 display: grid;
802}
803
804.act-feed-row {
805 align-items: center;
806 background: transparent;
807 border: 0;
808 border-bottom: 1px solid var(--border-color);
809 color: var(--text-primary);
810 display: grid;
811 gap: 16px;
812 grid-template-columns: 1fr auto;
813 padding: 16px 18px;
814 text-align: left;
815 width: 100%;
816}
817
818.act-feed-title {
819 display: block;
820 font-size: 15px;
821 font-weight: 500;
822}
823
824.act-feed-time,
825.act-pill {
826 color: var(--text-muted);
827 font-size: 12px;
828}
829
830.act-feed-side {
831 align-items: center;
832 display: flex;
833 gap: 12px;
834}
835
836.act-feed-detail {
837 border-bottom: 1px solid var(--border-color);
838 color: var(--text-secondary);
839 font-size: 14px;
840 line-height: 1.55;
841 padding: 0 18px 18px;
842}
843
844.act-feed-empty {
845 color: var(--text-secondary);
846 padding: 24px 18px;
847}
848
849@media (max-width: 900px) {
850 .ma-shell {
851 padding: 28px 20px 48px;
852 }
853
854 .ma-hero {
855 align-items: stretch;
856 flex-direction: column;
857 margin-bottom: 34px;
858 }
859
860 .ma-layout {
861 grid-template-columns: 1fr;
862 }
863
864 .ma-rail,
865 .ma-stage {
866 border-left: 0;
867 padding-left: 0;
868 }
869
870 .act-profile {
871 align-items: center;
872 flex-direction: column;
873 text-align: center;
874 }
875
876 .act-stats {
877 grid-template-columns: repeat(2, minmax(0, 1fr));
878 }
149}879}
src/app/layout.tsx+6−3
@@ -1,4 +1,5 @@
1import type { Metadata } from "next";1import type { Metadata } from "next";
2import { ClerkProvider } from "@clerk/nextjs";
2import { GeistSans } from "geist/font/sans";3import { GeistSans } from "geist/font/sans";
3import { GeistMono } from "geist/font/mono";4import { GeistMono } from "geist/font/mono";
4import { ThemeProvider } from "@/lib/theme";5import { ThemeProvider } from "@/lib/theme";
@@ -19,9 +20,11 @@ export default function RootLayout({ · 14 unmodified lines
19 return (20 return (
20 <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>21 <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
21 <body>22 <body>
22 <ThemeProvider>23 <ClerkProvider dynamic>
23 <MathJaxProvider>{children}</MathJaxProvider>24 <ThemeProvider>
24 </ThemeProvider>25 <MathJaxProvider>{children}</MathJaxProvider>
26 </ThemeProvider>
27 </ClerkProvider>
25 </body>28 </body>
26 </html>29 </html>
27 );30 );
src/app/page.tsx+57−98
@@ -1,105 +1,64 @@
1import { Header } from "@/components/navigation/header";1export default function HomePage() {
2
3export default function LandingPage() {
4 return (2 return (
5 <div3 <main className="relative min-h-screen overflow-hidden bg-[#111111] text-[#f2f0ea]">
6 className="min-h-screen flex flex-col"4 <div className="absolute right-8 top-5 flex items-center gap-3 text-xs text-[#8d8d8d]">
7 style={{ background: "var(--bg-canvas)" }}5 <a href="/play/mental-arithmetic" className="rounded-full border border-[#303030] px-4 py-2 text-[#d8d5ce] no-underline">
8 >6 Mental Arithmetic
9 <Header />7 </a>
10 <main className="flex-1 flex flex-col items-center justify-center px-6">8 <a href="/profile" className="flex h-8 w-8 items-center justify-center rounded-full bg-[#202020] text-[#d8d5ce] no-underline">
11 <div className="max-w-2xl text-center">9 S
12 <h110 </a>
13 className="text-4xl font-semibold tracking-tight mb-4"11 </div>
14 style={{ color: "var(--text-primary)" }}12
15 >13 <div className="pointer-events-none absolute inset-0">
16 SumTeX14 <span className="absolute left-[6%] top-[16%] text-[#777]">+</span>
17 </h1>15 <span className="absolute left-[25%] top-[38%] text-[#777]">.</span>
18 <p16 <span className="absolute right-[18%] top-[28%] text-[#777]">.</span>
19 className="text-lg mb-8 leading-relaxed"17 <span className="absolute bottom-[28%] left-[58%] text-[#777]">+</span>
20 style={{ color: "var(--text-secondary)" }}18 <span className="absolute bottom-[18%] right-[7%] text-[#777]">.</span>
19 </div>
20
21 <section className="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center px-5 pb-24">
22 <h1 className="mb-7 font-serif text-5xl font-semibold tracking-normal sm:text-6xl">
23 SumTex <span className="align-baseline text-xl italic text-[#7a7a7a]">v1</span>
24 </h1>
25 <form action="/problems" className="flex h-14 w-full items-center rounded-full border border-[#303030] bg-[#1f1f1f] px-4 shadow-[0_0_0_1px_rgba(255,255,255,0.02)]">
26 <span className="mr-3 text-[#8a8a8a]">Search</span>
27 <input
28 name="q"
29 placeholder="Mental arithmetic, calculus, algebra"
30 className="min-w-0 flex-1 bg-transparent text-sm text-[#dedbd2] outline-none placeholder:text-[#696969]"
31 />
32 <button
33 type="submit"
34 className="ml-3 flex h-10 w-10 items-center justify-center rounded-full bg-[#747474] text-lg leading-none text-[#111111]"
35 aria-label="Search"
21 >36 >
22 Mathematics is learned through textbooks and repetition, not videos.37 Go
23 Real problems from Rudin, Spivak, Artin, Munkres, and more. Solve38 </button>
24 them. Track your progress. Get better.39 </form>
25 </p>40 </section>
26 <div className="flex items-center justify-center gap-4">
27 <a
28 href="/play"
29 className="px-5 py-2.5 text-sm font-medium rounded no-underline transition-colors"
30 style={{
31 background: "var(--text-primary)",
32 color: "var(--bg-primary)",
33 }}
34 >
35 Start solving
36 </a>
37 <a
38 href="/problems"
39 className="px-5 py-2.5 text-sm font-medium rounded no-underline transition-colors"
40 style={{
41 background: "var(--bg-primary)",
42 color: "var(--text-primary)",
43 border: "1px solid var(--border-color)",
44 }}
45 >
46 Browse problems
47 </a>
48 </div>
49 </div>
50 41
51 <div42 <footer className="absolute inset-x-0 bottom-6 grid grid-cols-3 items-end px-10 text-xs text-[#676767]">
52 className="mt-16 grid grid-cols-3 gap-8 max-w-3xl w-full"43 <div />
53 style={{ color: "var(--text-secondary)" }}44 <div className="text-center">
54 >45 <div>Generated drills</div>
55 <div className="text-center">46 <div className="mt-1 font-mono text-base text-[#aaa]">11 categories</div>
56 <div47 </div>
57 className="text-2xl font-semibold mb-1"48 <div className="justify-self-end">
58 style={{ color: "var(--text-primary)" }}49 <a href="/play" className="text-[#676767] no-underline">
59 >50 Play
60 Textbooks51 </a>
61 </div>52 <span className="mx-2">.</span>
62 <p className="text-sm">53 <a href="/problems" className="text-[#676767] no-underline">
63 Problems pulled from qualified mathematical texts. Spivak,54 Problems
64 Rudin, Artin, Munkres.55 </a>
65 </p>56 <span className="mx-2">.</span>
66 </div>57 <a href="/sign-in" className="text-[#676767] no-underline">
67 <div className="text-center">58 Sign in
68 <div59 </a>
69 className="text-2xl font-semibold mb-1"
70 style={{ color: "var(--text-primary)" }}
71 >
72 Paths
73 </div>
74 <p className="text-sm">
75 Work through a textbook end to end. Track your progress across
76 chapters.
77 </p>
78 </div>
79 <div className="text-center">
80 <div
81 className="text-2xl font-semibold mb-1"
82 style={{ color: "var(--text-primary)" }}
83 >
84 Community
85 </div>
86 <p className="text-sm">
87 Submit problems, discuss solutions, build your profile. Open
88 source questions.
89 </p>
90 </div>
91 </div>60 </div>
92 </main>
93 <footer
94 className="py-4 text-center text-xs border-t"
95 style={{
96 color: "var(--text-muted)",
97 borderColor: "var(--border-color)",
98 background: "var(--bg-primary)",
99 }}
100 >
101 SumTeX by Kris Yotam
102 </footer>61 </footer>
103 </div>62 </main>
104 );63 );
105}64}
src/app/sign-in/[[...sign-in]]/page.tsx+9−0
@@ -0,0 +1,9 @@
1import { SignIn } from "@clerk/nextjs";
2
3export default function SignInPage() {
4 return (
5 <main className="auth-shell">
6 <SignIn routing="path" path="/sign-in" signUpUrl="/sign-up" />
7 </main>
8 );
9}
src/app/sign-up/[[...sign-up]]/page.tsx+9−0
@@ -0,0 +1,9 @@
1import { SignUp } from "@clerk/nextjs";
2
3export default function SignUpPage() {
4 return (
5 <main className="auth-shell">
6 <SignUp routing="path" path="/sign-up" signInUrl="/sign-in" />
7 </main>
8 );
9}
src/components/arithmetic/mentalArithmeticPlay.tsx+673−0
@@ -0,0 +1,673 @@
1"use client";
2
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { useRouter, useSearchParams } from "next/navigation";
5import { SurfaceNav } from "@/components/navigation/surfaceNav";
6import {
7 arithmeticCategories,
8 checkArithmeticAnswer,
9 createSeededRng,
10 defaultCustomConfig,
11 generateArithmeticProblem,
12 scoreArithmeticAttempt,
13 type ArithmeticCategory,
14 type ArithmeticCustomConfig,
15 type ArithmeticProblem,
16 type ArithmeticRunMode,
17 type ArithmeticSessionConfig,
18 type ArithmeticStar,
19 type ArithmeticSubmode,
20 type BigMagnitude,
21} from "@/lib/arithmetic";
22
23type AttemptRow = {
24 expression: string;
25 answer: string;
26 userAnswer: string;
27 correct: boolean;
28};
29
30const runModes: Array<{ id: ArithmeticRunMode; label: string }> = [
31 { id: "untimed", label: "Practice" },
32 { id: "30s", label: "30s" },
33 { id: "60s", label: "60s" },
34 { id: "120s", label: "120s" },
35 { id: "300s", label: "300s" },
36];
37
38const submodes: Array<{ id: ArithmeticSubmode; label: string }> = [
39 { id: "sqrt", label: "sqrt n" },
40 { id: "square", label: "n^2" },
41 { id: "cube", label: "n^3" },
42];
43
44const magnitudes: Array<{ id: BigMagnitude; label: string }> = [
45 { id: "thousands", label: "Thousands" },
46 { id: "millions", label: "Millions" },
47 { id: "billions", label: "Billions" },
48];
49
50export function MentalArithmeticPlay() {
51 const router = useRouter();
52 const searchParams = useSearchParams();
53 const [category, setCategory] = useState<ArithmeticCategory>(() => parseCategory(searchParams.get("category")));
54 const [star, setStar] = useState<ArithmeticStar>(() => parseStar(searchParams.get("star")));
55 const [runMode, setRunMode] = useState<ArithmeticRunMode>("60s");
56 const [submode, setSubmode] = useState<ArithmeticSubmode>("square");
57 const [magnitude, setMagnitude] = useState<BigMagnitude>("thousands");
58 const [customEnabled, setCustomEnabled] = useState(false);
59 const [customConfig, setCustomConfig] = useState<ArithmeticCustomConfig>(defaultCustomConfig);
60 const [sessionId, setSessionId] = useState<string | null>(null);
61 const [seed, setSeed] = useState(1);
62 const [problem, setProblem] = useState<ArithmeticProblem | null>(null);
63 const [answer, setAnswer] = useState("");
64 const [startedAt, setStartedAt] = useState<number | null>(null);
65 const [problemStartedAt, setProblemStartedAt] = useState<number | null>(null);
66 const [timeLeft, setTimeLeft] = useState<number | null>(60);
67 const [running, setRunning] = useState(false);
68 const [ended, setEnded] = useState(false);
69 const [score, setScore] = useState(0);
70 const [total, setTotal] = useState(0);
71 const [correct, setCorrect] = useState(0);
72 const [recent, setRecent] = useState<AttemptRow[]>([]);
73 const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);
74 const rngRef = useRef(createSeededRng(1));
75 const inputRef = useRef<HTMLInputElement>(null);
76
77 const config = useMemo<ArithmeticSessionConfig>(
78 () => ({
79 mode: customEnabled ? "custom" : "standard",
80 category,
81 submode: category === "roots-powers" ? submode : undefined,
82 star,
83 runMode,
84 magnitude: category.startsWith("big-") ? magnitude : undefined,
85 customConfig: customEnabled ? customConfig : undefined,
86 }),
87 [category, customConfig, customEnabled, magnitude, runMode, star, submode]
88 );
89
90 const duration = runMode === "untimed" ? null : Number(runMode.replace("s", ""));
91 const binaryKeypad = problem?.answerFormat === "binary";
92 const selectedCategory = arithmeticCategories.find((item) => item.id === category);
93 const pageTitle = customEnabled
94 ? `Custom arithmetic · ${star} ${star === 1 ? "star" : "stars"}`
95 : `${selectedCategory?.label ?? "Arithmetic"} · ${star} ${star === 1 ? "star" : "stars"}`;
96
97 const nextProblem = useCallback(() => {
98 const generated = generateArithmeticProblem(config, rngRef.current);
99 setProblem(generated);
100 setProblemStartedAt(Date.now());
101 setAnswer("");
102 setFeedback(null);
103 requestAnimationFrame(() => inputRef.current?.focus());
104 }, [config]);
105
106 const start = useCallback(async () => {
107 const nextSeed = Math.floor(Math.random() * 2_147_483_647);
108 rngRef.current = createSeededRng(nextSeed);
109 setSeed(nextSeed);
110 setScore(0);
111 setTotal(0);
112 setCorrect(0);
113 setRecent([]);
114 setStartedAt(Date.now());
115 setTimeLeft(duration);
116 setRunning(true);
117 setEnded(false);
118 setSessionId(null);
119
120 const generated = generateArithmeticProblem(config, rngRef.current);
121 setProblem(generated);
122 setProblemStartedAt(Date.now());
123 setAnswer("");
124
125 const response = await fetch("/api/arithmetic/sessions", {
126 method: "POST",
127 headers: { "content-type": "application/json" },
128 body: JSON.stringify({ config, seed: nextSeed }),
129 });
130 const data = (await response.json()) as { sessionId?: string | null };
131 setSessionId(data.sessionId ?? null);
132
133 requestAnimationFrame(() => inputRef.current?.focus());
134 }, [config, duration]);
135
136 const end = useCallback(
137 async (override?: { score: number; total: number; correct: number; sessionId: string | null }) => {
138 setRunning(false);
139 setEnded(true);
140 setProblem(null);
141
142 const payload = {
143 sessionId: override?.sessionId ?? sessionId,
144 score: override?.score ?? score,
145 totalAttempts: override?.total ?? total,
146 correctAttempts: override?.correct ?? correct,
147 };
148
149 if (payload.sessionId) {
150 await fetch("/api/arithmetic/end", {
151 method: "POST",
152 headers: { "content-type": "application/json" },
153 body: JSON.stringify(payload),
154 });
155 }
156 },
157 [correct, score, sessionId, total]
158 );
159
160 const submit = useCallback(async () => {
161 if (!problem || !running || !problemStartedAt || !answer.trim()) {
162 return;
163 }
164
165 const elapsedMs = Date.now() - problemStartedAt;
166 const isCorrect = checkArithmeticAnswer(problem, answer);
167 const scoreDelta = scoreArithmeticAttempt(
168 { correct: isCorrect, elapsedMs, problem },
169 config
170 );
171 const nextScore = score + scoreDelta;
172 const nextTotal = total + 1;
173 const nextCorrect = correct + (isCorrect ? 1 : 0);
174
175 setScore(nextScore);
176 setTotal(nextTotal);
177 setCorrect(nextCorrect);
178 setFeedback(isCorrect ? "correct" : "wrong");
179 setRecent((items) =>
180 [
181 {
182 expression: problem.expression,
183 answer: problem.canonicalAnswer,
184 userAnswer: answer,
185 correct: isCorrect,
186 },
187 ...items,
188 ].slice(0, 8)
189 );
190
191 if (sessionId) {
192 void fetch("/api/arithmetic/attempts", {
193 method: "POST",
194 headers: { "content-type": "application/json" },
195 body: JSON.stringify({
196 sessionId,
197 problem,
198 userAnswer: answer,
199 correct: isCorrect,
200 elapsedMs,
201 scoreDelta,
202 }),
203 });
204 }
205
206 nextProblem();
207 }, [answer, config, correct, nextProblem, problem, problemStartedAt, running, score, sessionId, total]);
208
209 useEffect(() => {
210 if (!running || duration === null || startedAt === null) {
211 return;
212 }
213
214 const interval = window.setInterval(() => {
215 const remaining = Math.max(0, duration - Math.floor((Date.now() - startedAt) / 1000));
216 setTimeLeft(remaining);
217 if (remaining === 0) {
218 window.clearInterval(interval);
219 void end();
220 }
221 }, 250);
222
223 return () => window.clearInterval(interval);
224 }, [duration, end, running, startedAt]);
225
226 useEffect(() => {
227 const handler = (event: KeyboardEvent) => {
228 if (!running) {
229 return;
230 }
231
232 const allowed = binaryKeypad ? /^[01]$/ : /^[0-9./-]$/;
233 if (allowed.test(event.key)) {
234 event.preventDefault();
235 setAnswer((value) => value + event.key);
236 } else if (event.key === "Backspace") {
237 event.preventDefault();
238 setAnswer((value) => value.slice(0, -1));
239 } else if (event.key === "Enter") {
240 event.preventDefault();
241 void submit();
242 }
243 };
244
245 window.addEventListener("keydown", handler);
246 return () => window.removeEventListener("keydown", handler);
247 }, [binaryKeypad, running, submit]);
248
249 useEffect(() => {
250 const params = new URLSearchParams(searchParams.toString());
251 params.set("category", category);
252 params.set("star", String(star));
253 router.replace(`/play/mental-arithmetic?${params.toString()}`, { scroll: false });
254 }, [category, router, searchParams, star]);
255
256 function keypadPress(key: string) {
257 inputRef.current?.focus();
258 if (key === "backspace") {
259 setAnswer((value) => value.slice(0, -1));
260 } else if (key === "clear") {
261 setAnswer("");
262 } else if (key === "enter") {
263 void submit();
264 } else {
265 setAnswer((value) => value + key);
266 }
267 }
268
269 function toggleOperation(operation: ArithmeticCustomConfig["operations"][number]) {
270 setCustomConfig((current) => {
271 const hasOperation = current.operations.includes(operation);
272 const operations = hasOperation
273 ? current.operations.filter((item) => item !== operation)
274 : [...current.operations, operation];
275
276 return { ...current, operations: operations.length ? operations : [operation] };
277 });
278 }
279
280 return (
281 <>
282 <style>{mentalArithmeticStyles}</style>
283 <div className="ma-shell">
284 <div className="ma-frame">
285 <SurfaceNav variant="dark" labels="compact" />
286
287 <header className="ma-hero">
288 <div>
289 <p className="ma-kicker">SumTex Play</p>
290 <h1 className="ma-title">{pageTitle}</h1>
291 <p className="ma-subtitle">
292 Fast generated arithmetic drills with timed runs, clean keyboard entry, and signed-in history.
293 </p>
294 </div>
295 <button type="button" onClick={running ? () => void end() : start} className="ma-primary-action">
296 {running ? "End run" : "Start run"}
297 </button>
298 </header>
299
300 <div className="ma-layout">
301 <aside className="ma-rail">
302 <div className="ma-group">
303 <div className="ma-label">Mode</div>
304 <label className="ma-toggle-row">
305 <span>Custom mode</span>
306 <input
307 type="checkbox"
308 checked={customEnabled}
309 onChange={(event) => setCustomEnabled(event.target.checked)}
310 />
311 </label>
312 </div>
313
314 {!customEnabled ? (
315 <>
316 <div className="ma-group">
317 <div className="ma-label">Category</div>
318 <div className="ma-category-list">
319 {arithmeticCategories.map((item) => (
320 <button
321 key={item.id}
322 type="button"
323 onClick={() => setCategory(item.id)}
324 className={`ma-line-option${category === item.id ? " active" : ""}`}
325 >
326 <span className="ma-option-name">{item.label}</span>
327 <span className="ma-option-meta">{item.description}</span>
328 </button>
329 ))}
330 </div>
331 </div>
332
333 {category === "roots-powers" ? (
334 <div className="ma-group">
335 <div className="ma-label">Submode</div>
336 <Segmented
337 items={submodes}
338 value={submode}
339 onChange={(value) => setSubmode(value as ArithmeticSubmode)}
340 />
341 </div>
342 ) : null}
343
344 {category.startsWith("big-") ? (
345 <div className="ma-group">
346 <div className="ma-label">Magnitude</div>
347 <Segmented
348 items={magnitudes}
349 value={magnitude}
350 onChange={(value) => setMagnitude(value as BigMagnitude)}
351 />
352 </div>
353 ) : null}
354 </>
355 ) : (
356 <div className="ma-group">
357 <div className="ma-label">Custom</div>
358 <div className="ma-pill-row">
359 {(["addition", "subtraction", "multiplication", "division"] as const).map((operation) => (
360 <button
361 key={operation}
362 type="button"
363 className={`ma-pill${customConfig.operations.includes(operation) ? " active" : ""}`}
364 onClick={() => toggleOperation(operation)}
365 >
366 {operation}
367 </button>
368 ))}
369 </div>
370 <NumberControl
371 label="Min digits"
372 value={customConfig.minDigits}
373 onChange={(value) => setCustomConfig((current) => ({ ...current, minDigits: value }))}
374 />
375 <NumberControl
376 label="Max digits"
377 value={customConfig.maxDigits}
378 onChange={(value) => setCustomConfig((current) => ({ ...current, maxDigits: value }))}
379 />
380 <CheckControl
381 label="Negatives"
382 checked={customConfig.allowNegatives}
383 onChange={(value) => setCustomConfig((current) => ({ ...current, allowNegatives: value }))}
384 />
385 <CheckControl
386 label="Decimals"
387 checked={customConfig.allowDecimals}
388 onChange={(value) => setCustomConfig((current) => ({ ...current, allowDecimals: value }))}
389 />
390 <CheckControl
391 label="Fractions"
392 checked={customConfig.allowFractions}
393 onChange={(value) => setCustomConfig((current) => ({ ...current, allowFractions: value }))}
394 />
395 <CheckControl
396 label="Clean division"
397 checked={customConfig.cleanDivision}
398 onChange={(value) => setCustomConfig((current) => ({ ...current, cleanDivision: value }))}
399 />
400 </div>
401 )}
402
403 <div className="ma-group">
404 <div className="ma-label">Stars</div>
405 <div className="ma-pill-row">
406 {([1, 2, 3, 4, 5] as ArithmeticStar[]).map((value) => (
407 <button
408 key={value}
409 type="button"
410 className={`ma-pill${star === value ? " active" : ""}`}
411 onClick={() => setStar(value)}
412 >
413 {value}
414 </button>
415 ))}
416 </div>
417 </div>
418
419 <div className="ma-group">
420 <div className="ma-label">Run</div>
421 <Segmented items={runModes} value={runMode} onChange={(value) => setRunMode(value as ArithmeticRunMode)} />
422 </div>
423 </aside>
424
425 <section className="ma-stage">
426 <div className="ma-metrics">
427 <Metric label="Score" value={score} />
428 <Metric label={runMode === "untimed" ? "Mode" : "Time"} value={runMode === "untimed" ? "Open" : `${timeLeft ?? duration}s`} />
429 <Metric label="Accuracy" value={total ? `${Math.round((correct / total) * 100)}%` : "0%"} />
430 </div>
431
432 {problem ? (
433 <div className="ma-problem-wrap">
434 <div className={`ma-expression${feedback ? ` ${feedback}` : ""}`}>{problem.expression}</div>
435
436 <input
437 ref={inputRef}
438 value={answer}
439 onChange={(event) => setAnswer(event.target.value)}
440 className="ma-answer"
441 inputMode={binaryKeypad ? "numeric" : "decimal"}
442 autoFocus
443 />
444
445 <Keypad binary={binaryKeypad} onPress={keypadPress} />
446 </div>
447 ) : (
448 <div className="ma-empty">
449 <h2>{ended ? "Run complete" : "Ready"}</h2>
450 <p>
451 {ended && !sessionId
452 ? "Guest runs stay on this device. Sign in to save future arithmetic history."
453 : "Choose a drill and start. The answer input stays keypad-first."}
454 </p>
455 </div>
456 )}
457
458 {recent.length ? (
459 <div className="ma-recent">
460 <h3>Recent attempts</h3>
461 <div>
462 {recent.map((item, index) => (
463 <div key={`${item.expression}-${index}`} className="ma-recent-row">
464 <span>
465 {item.expression} = {item.userAnswer || "blank"}
466 </span>
467 <span style={{ color: item.correct ? "#7fb68d" : "#c98462" }}>
468 {item.correct ? "Correct" : item.answer}
469 </span>
470 </div>
471 ))}
472 </div>
473 </div>
474 ) : null}
475 </section>
476 </div>
477 </div>
478 </div>
479 </>
480 );
481}
482
483function Keypad({ binary, onPress }: { binary: boolean; onPress: (key: string) => void }) {
484 const keys = binary
485 ? ["1", "0", "backspace", "clear", "enter"]
486 : ["7", "8", "9", "backspace", "4", "5", "6", "clear", "1", "2", "3", "-", "0", ".", "/", "enter"];
487
488 return (
489 <div className="ma-keypad">
490 {keys.map((key) => (
491 <button
492 key={key}
493 type="button"
494 className="ma-key"
495 onClick={() => onPress(key)}
496 >
497 {key === "backspace" ? "Bksp" : key === "clear" ? "C" : key === "enter" ? "Enter" : key}
498 </button>
499 ))}
500 </div>
501 );
502}
503
504function Segmented({
505 items,
506 value,
507 onChange,
508}: {
509 items: Array<{ id: string; label: string }>;
510 value: string;
511 onChange: (value: string) => void;
512}) {
513 return (
514 <div className="ma-pill-row">
515 {items.map((item) => (
516 <button
517 key={item.id}
518 type="button"
519 className={`ma-pill${value === item.id ? " active" : ""}`}
520 onClick={() => onChange(item.id)}
521 >
522 {item.label}
523 </button>
524 ))}
525 </div>
526 );
527}
528
529function NumberControl({
530 label,
531 value,
532 onChange,
533}: {
534 label: string;
535 value: number;
536 onChange: (value: number) => void;
537}) {
538 return (
539 <label className="ma-number-row">
540 {label}
541 <input
542 type="number"
543 min={1}
544 max={9}
545 value={value}
546 onChange={(event) => onChange(Number(event.target.value))}
547 />
548 </label>
549 );
550}
551
552function CheckControl({
553 label,
554 checked,
555 onChange,
556}: {
557 label: string;
558 checked: boolean;
559 onChange: (value: boolean) => void;
560}) {
561 return (
562 <label className="ma-toggle-row">
563 {label}
564 <input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} />
565 </label>
566 );
567}
568
569function Metric({ label, value }: { label: string; value: string | number }) {
570 return (
571 <div className="ma-metric">
572 <div className="ma-metric-label">{label}</div>
573 <div className="ma-metric-value">{value}</div>
574 </div>
575 );
576}
577
578function parseCategory(value: string | null): ArithmeticCategory {
579 return arithmeticCategories.some((item) => item.id === value)
580 ? (value as ArithmeticCategory)
581 : "addition";
582}
583
584function parseStar(value: string | null): ArithmeticStar {
585 const numeric = Number(value);
586 return ([1, 2, 3, 4, 5] as ArithmeticStar[]).includes(numeric as ArithmeticStar)
587 ? (numeric as ArithmeticStar)
588 : 2;
589}
590
591const mentalArithmeticStyles = `
592 .ma-shell {
593 --surface-bg: #ffffff;
594 --surface-bg-soft: #f6f6f6;
595 --surface-control: #ffffff;
596 --surface-text: #171717;
597 --surface-strong: #000000;
598 --surface-muted: #555555;
599 --surface-faint: #8a8a8a;
600 --surface-line: #dedede;
601 --surface-line-soft: #e9e9e9;
602 --surface-invert: #000000;
603 --surface-invert-text: #ffffff;
604 min-height: 100vh;
605 background: var(--surface-bg);
606 color: var(--surface-text);
607 padding: 28px 5vw 56px;
608 }
609 .dark .ma-shell {
610 --surface-bg: #080808;
611 --surface-bg-soft: #0d0d0d;
612 --surface-control: #111;
613 --surface-text: #f4f2ee;
614 --surface-strong: #f5f3ef;
615 --surface-muted: #8d8d8d;
616 --surface-faint: #777;
617 --surface-line: #242424;
618 --surface-line-soft: #1d1d1d;
619 --surface-invert: #f1eee8;
620 --surface-invert-text: #080808;
621 }
622 .ma-shell a { color: inherit; text-decoration: none; }
623 .ma-frame { margin: 0 auto; max-width: 1440px; }
624 .ma-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; margin: 0 0 46px; }
625 .ma-kicker, .ma-label, .ma-metric-label, .ma-recent h3 { color: var(--surface-faint); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; line-height: 1; text-transform: uppercase; }
626 .ma-kicker { font-size: 13px; letter-spacing: 0.18em; margin: 0 0 22px; }
627 .ma-title { color: var(--surface-strong); font-size: clamp(44px, 6vw, 92px); font-weight: 400; letter-spacing: 0; line-height: 0.95; margin: 0; }
628 .ma-subtitle { color: var(--surface-muted); font-size: 18px; line-height: 1.5; margin: 22px 0 0; max-width: 620px; }
629 .ma-primary-action { align-items: center; background: transparent; border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); cursor: pointer; display: inline-flex; font-family: var(--font-mono); font-size: 13px; font-weight: 600; justify-content: center; letter-spacing: 0.14em; min-height: 54px; padding: 0 28px; text-transform: uppercase; white-space: nowrap; }
630 .ma-primary-action:hover { background: var(--surface-bg-soft); border-color: var(--surface-muted); }
631 .ma-layout { align-items: start; display: grid; gap: 44px; grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); }
632 .ma-rail { border-left: 1px solid var(--surface-line); display: flex; flex-direction: column; gap: 32px; padding-left: 26px; }
633 .ma-group { display: grid; gap: 12px; }
634 .ma-category-list { display: grid; gap: 1px; }
635 .ma-line-option, .ma-toggle-row, .ma-number-row { background: transparent; border: 0; border-bottom: 1px solid var(--surface-line-soft); color: var(--surface-text); display: flex; gap: 16px; justify-content: space-between; min-height: 46px; padding: 12px 0; text-align: left; }
636 .ma-line-option { cursor: pointer; }
637 .ma-line-option:hover, .ma-line-option.active { color: var(--surface-strong); }
638 .ma-option-name { align-items: center; display: inline-flex; font-size: 15px; gap: 10px; }
639 .ma-option-name:before { background: var(--surface-line); border-radius: 999px; content: ""; display: inline-block; height: 7px; width: 7px; }
640 .ma-line-option.active .ma-option-name:before { background: var(--surface-strong); }
641 .ma-option-meta { color: var(--surface-faint); font-size: 12px; max-width: 130px; text-align: right; }
642 .ma-pill-row { display: flex; flex-wrap: wrap; gap: 8px; }
643 .ma-pill { background: transparent; border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-muted); cursor: pointer; font-family: var(--font-mono); font-size: 12px; min-height: 36px; padding: 0 14px; text-transform: uppercase; }
644 .ma-pill.active { background: var(--surface-invert); border-color: var(--surface-invert); color: var(--surface-invert-text); }
645 .ma-stage { border-left: 1px solid var(--surface-line); min-height: 650px; padding-left: clamp(28px, 5vw, 76px); }
646 .ma-metrics { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 0 42px; }
647 .ma-metric { border-top: 1px solid var(--surface-line); padding: 18px 22px 0 0; }
648 .ma-metric-value { color: var(--surface-strong); font-family: var(--font-mono); font-size: 30px; line-height: 1.2; margin-top: 8px; }
649 .ma-problem-wrap { margin: 0 auto; max-width: 760px; text-align: center; }
650 .ma-expression { color: var(--surface-strong); font-family: var(--font-mono); font-size: clamp(46px, 7vw, 96px); font-weight: 500; letter-spacing: 0; line-height: 1.05; margin: 50px 0 34px; min-height: 110px; }
651 .ma-expression.correct { color: #7fb68d; }
652 .ma-expression.wrong { color: #c98462; }
653 .ma-answer { background: var(--surface-control); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); font-family: var(--font-mono); font-size: 28px; height: 66px; outline: none; padding: 0 26px; text-align: center; width: min(100%, 560px); }
654 .ma-answer:focus { border-color: var(--surface-muted); }
655 .ma-keypad { display: grid; gap: 10px; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 24px auto 0; max-width: 560px; }
656 .ma-key { background: var(--surface-control); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-text); cursor: pointer; font-family: var(--font-mono); font-size: 15px; height: 52px; }
657 .ma-key:hover { background: var(--surface-bg-soft); border-color: var(--surface-muted); }
658 .ma-empty { align-items: center; display: flex; flex-direction: column; justify-content: center; min-height: 450px; text-align: center; }
659 .ma-empty h2 { color: var(--surface-strong); font-size: clamp(42px, 6vw, 82px); font-weight: 400; letter-spacing: 0; line-height: 1; margin: 0; }
660 .ma-empty p { color: var(--surface-muted); font-size: 17px; line-height: 1.5; margin: 20px 0 0; max-width: 520px; }
661 .ma-recent { border-top: 1px solid var(--surface-line); margin-top: 52px; padding-top: 22px; }
662 .ma-recent h3 { margin: 0 0 14px; }
663 .ma-recent-row { border-bottom: 1px solid var(--surface-line-soft); color: var(--surface-muted); display: grid; font-family: var(--font-mono); font-size: 13px; gap: 16px; grid-template-columns: 1fr auto; padding: 12px 0; }
664 .ma-number-row, .ma-toggle-row { align-items: center; cursor: default; }
665 .ma-number-row input { background: var(--surface-control); border: 1px solid var(--surface-line); border-radius: 999px; color: var(--surface-strong); height: 34px; padding: 0 12px; text-align: center; width: 78px; }
666 .ma-toggle-row input { accent-color: var(--surface-invert); }
667 @media (max-width: 900px) {
668 .ma-shell { padding: 28px 20px 48px; }
669 .ma-hero { align-items: stretch; flex-direction: column; margin-bottom: 34px; }
670 .ma-layout { grid-template-columns: 1fr; }
671 .ma-rail, .ma-stage { border-left: 0; padding-left: 0; }
672 }
673`;
src/components/math/mathJax.tsx+8−2
@@ -8,6 +8,12 @@ interface MathDisplayProps { · 7 unmodified lines
8}8}
9 9
10export function MathDisplay({ latex, inline = false }: MathDisplayProps) {10export function MathDisplay({ latex, inline = false }: MathDisplayProps) {
11 const wrapped = inline ? `$${latex}$` : `$$${latex}$$`;11 return (
12 return <MathJax>{wrapped}</MathJax>;12 <span className={inline ? "math-display math-display-inline" : "math-display math-display-block"}>
13 <span className="math-display-fallback">{latex}</span>
14 <span className="math-display-render">
15 <MathJax dynamic inline={inline}>{inline ? `$${latex}$` : `$$${latex}$$`}</MathJax>
16 </span>
17 </span>
18 );
13}19}
src/components/navigation/surfaceNav.tsx+59−0
@@ -0,0 +1,59 @@
1"use client";
2
3import { Show, SignInButton, UserButton } from "@clerk/nextjs";
4import { ThemeToggle } from "@/components/navigation/themeToggle";
5
6const navLinks = [
7 { href: "/play", label: "Play" },
8 { href: "/problems", label: "Problems" },
9 { href: "/paths", label: "Guides" },
10 { href: "/roadmaps", label: "Roadmaps" },
11 { href: "/collections", label: "Collections" },
12 { href: "/forum", label: "Forum" },
13 { href: "/leaderboard", label: "Leaderboard" },
14 { href: "/profile", label: "Profile" },
15];
16
17export function SurfaceNav({
18 variant = "dark",
19 labels = "full",
20}: {
21 variant?: "dark" | "light";
22 labels?: "full" | "compact";
23}) {
24 return (
25 <nav className={`surface-nav surface-nav-${variant}`} aria-label="Primary">
26 <a className="surface-brand" href="/">
27 SumTex
28 </a>
29 <div className="surface-nav-links">
30 {navLinks.map((link) => (
31 <a key={link.href} href={link.href}>
32 {labels === "compact" && link.label === "Play" ? "Modes" : link.label}
33 </a>
34 ))}
35 <ThemeToggle className="surface-theme-toggle" />
36 <Show
37 when="signed-in"
38 fallback={
39 <SignInButton mode="modal">
40 <button type="button" className="surface-auth-button">
41 Sign in
42 </button>
43 </SignInButton>
44 }
45 >
46 <UserButton
47 appearance={{
48 elements: {
49 avatarBox: "surface-user-avatar",
50 userButtonPopoverCard: "surface-user-popover",
51 },
52 }}
53 userProfileUrl="/profile/settings"
54 />
55 </Show>
56 </div>
57 </nav>
58 );
59}
src/components/navigation/themeToggle.tsx+56−0
@@ -0,0 +1,56 @@
1"use client";
2
3import { useTheme } from "@/lib/theme";
4
5export function ThemeToggle({ className = "" }: { className?: string }) {
6 const { theme, toggle } = useTheme();
7 const nextTheme = theme === "light" ? "dark" : "light";
8
9 return (
10 <button
11 type="button"
12 onClick={toggle}
13 className={className}
14 aria-label={`Switch to ${nextTheme} mode`}
15 title={`Switch to ${nextTheme} mode`}
16 >
17 {theme === "light" ? (
18 <svg
19 aria-hidden="true"
20 width="16"
21 height="16"
22 viewBox="0 0 24 24"
23 fill="none"
24 stroke="currentColor"
25 strokeWidth="2"
26 strokeLinecap="round"
27 strokeLinejoin="round"
28 >
29 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
30 </svg>
31 ) : (
32 <svg
33 aria-hidden="true"
34 width="16"
35 height="16"
36 viewBox="0 0 24 24"
37 fill="none"
38 stroke="currentColor"
39 strokeWidth="2"
40 strokeLinecap="round"
41 strokeLinejoin="round"
42 >
43 <circle cx="12" cy="12" r="5" />
44 <line x1="12" y1="1" x2="12" y2="3" />
45 <line x1="12" y1="21" x2="12" y2="23" />
46 <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
47 <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
48 <line x1="1" y1="12" x2="3" y2="12" />
49 <line x1="21" y1="12" x2="23" y2="12" />
50 <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
51 <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
52 </svg>
53 )}
54 </button>
55 );
56}
src/lib/arithmetic/generator.ts+393−0
@@ -0,0 +1,393 @@
1import type {
2 ArithmeticAttemptResult,
3 ArithmeticCategory,
4 ArithmeticCustomConfig,
5 ArithmeticProblem,
6 ArithmeticSessionConfig,
7 ArithmeticStar,
8 AnswerFormat,
9 BigMagnitude,
10 Rng,
11} from "./types";
12
13const STAR_DIGITS: Record<ArithmeticStar, [number, number]> = {
14 1: [1, 1],
15 2: [1, 2],
16 3: [2, 2],
17 4: [2, 3],
18 5: [3, 3],
19};
20
21const BIG_RANGES: Record<BigMagnitude, [number, number]> = {
22 thousands: [1_000, 9_999],
23 millions: [1_000_000, 9_999_999],
24 billions: [1_000_000_000, 9_999_999_999],
25};
26
27export function createSeededRng(seed: number): Rng {
28 let state = seed >>> 0;
29
30 return {
31 next() {
32 state = (state + 0x6d2b79f5) >>> 0;
33 let t = state;
34 t = Math.imul(t ^ (t >>> 15), t | 1);
35 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
36 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
37 },
38 };
39}
40
41export function defaultCustomConfig(): ArithmeticCustomConfig {
42 return {
43 operations: ["addition", "subtraction", "multiplication", "division"],
44 minDigits: 1,
45 maxDigits: 2,
46 allowNegatives: false,
47 allowDecimals: false,
48 allowFractions: true,
49 cleanDivision: true,
50 percentMode: "part",
51 };
52}
53
54export function generateArithmeticProblem(
55 config: ArithmeticSessionConfig,
56 rng: Rng
57): ArithmeticProblem {
58 const category: ArithmeticCategory =
59 config.mode === "custom" ? pick(rng, config.customConfig?.operations ?? ["addition"]) : config.category;
60 const resolvedConfig = { ...config, category };
61
62 switch (category) {
63 case "addition":
64 return binaryOpProblem(resolvedConfig, rng, "+", (a, b) => a + b);
65 case "subtraction":
66 return binaryOpProblem(resolvedConfig, rng, "-", (a, b) => a - b);
67 case "multiplication":
68 return binaryOpProblem(resolvedConfig, rng, "x", (a, b) => a * b);
69 case "division":
70 return divisionProblem(resolvedConfig, rng);
71 case "mixed":
72 return mixedProblem(resolvedConfig, rng);
73 case "roots-powers":
74 return rootsPowersProblem(resolvedConfig, rng);
75 case "percents":
76 return percentProblem(resolvedConfig, rng);
77 case "binary":
78 return binaryModeProblem(resolvedConfig, rng);
79 case "big-multiply":
80 return bigMultiplyProblem(resolvedConfig, rng);
81 case "big-divide":
82 return bigDivideProblem(resolvedConfig, rng);
83 case "big-percents":
84 return bigPercentProblem(resolvedConfig, rng);
85 }
86}
87
88export function checkArithmeticAnswer(problem: ArithmeticProblem, userAnswer: string): boolean {
89 if (problem.answerFormat === "binary") {
90 const cleaned = userAnswer.trim().replace(/^0b/i, "");
91 return /^[01]+$/.test(cleaned) && cleaned.replace(/^0+(?=\d)/, "") === problem.canonicalAnswer;
92 }
93
94 const expected = parseRational(problem.canonicalAnswer);
95 const actual = parseRational(userAnswer);
96
97 if (!expected || !actual) {
98 return false;
99 }
100
101 return expected.num === actual.num && expected.den === actual.den;
102}
103
104export function scoreArithmeticAttempt(
105 attempt: Pick<ArithmeticAttemptResult, "correct" | "elapsedMs" | "problem">,
106 session: Pick<ArithmeticSessionConfig, "runMode">
107): number {
108 if (!attempt.correct) {
109 return 0;
110 }
111
112 const star = attempt.problem.star;
113 const base = 8 + star * 4;
114 const speed = session.runMode === "untimed" ? 1 : Math.max(1, 4 - Math.floor(attempt.elapsedMs / 2500));
115 const categoryBonus = attempt.problem.category.startsWith("big-") ? 8 : 0;
116
117 return base + speed + categoryBonus;
118}
119
120function binaryOpProblem(
121 config: ArithmeticSessionConfig,
122 rng: Rng,
123 symbol: string,
124 solve: (a: number, b: number) => number
125): ArithmeticProblem {
126 const { a, b } = operandsFor(config, rng);
127 return makeProblem(config, `${a} ${symbol} ${b}`, String(solve(a, b)), "integer");
128}
129
130function divisionProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
131 const [minDigits, maxDigits] = digitRange(config);
132 const divisor = randDigits(rng, minDigits, Math.max(minDigits, maxDigits - 1));
133 const quotient = randDigits(rng, minDigits, maxDigits);
134 const clean = config.mode !== "custom" || config.customConfig?.cleanDivision !== false;
135
136 if (clean || config.star <= 3) {
137 return makeProblem(config, `${divisor * quotient} / ${divisor}`, String(quotient), "integer");
138 }
139
140 const dividend = divisor * quotient + randInt(rng, 1, divisor - 1);
141 return makeProblem(config, `${dividend} / ${divisor}`, reduceFraction(dividend, divisor), "fraction", {
142 acceptsFractions: true,
143 acceptsDecimals: true,
144 });
145}
146
147function mixedProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
148 const op = pick(rng, ["addition", "subtraction", "multiplication", "division"] as const);
149 return generateArithmeticProblem({ ...config, category: op }, rng);
150}
151
152function rootsPowersProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
153 const submode = config.submode ?? "square";
154 const star = config.star;
155 const maxBase = submode === "cube" ? 5 + star * 5 : 10 + star * 18;
156 const base = randInt(rng, Math.max(2, star), maxBase);
157
158 if (submode === "sqrt") {
159 return makeProblem(
160 { ...config, submode },
161 `sqrt(${base * base})`,
162 String(base),
163 "integer"
164 );
165 }
166
167 const power = submode === "cube" ? 3 : 2;
168 return makeProblem(
169 { ...config, submode },
170 `${base}^${power}`,
171 String(Math.pow(base, power)),
172 "integer"
173 );
174}
175
176function percentProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
177 const rates = [5, 10, 12.5, 15, 20, 25, 30, 40, 50, 75];
178 const rate = config.star >= 4 ? randInt(rng, 2, 95) : pick(rng, rates);
179 const base = randInt(rng, 2, 20 + config.star * 30) * 4;
180 const answer = (base * rate) / 100;
181
182 return makeProblem(config, `${formatNumber(rate)}% of ${base}`, formatNumber(answer), "decimal", {
183 acceptsFractions: true,
184 acceptsDecimals: true,
185 });
186}
187
188function binaryModeProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
189 const max = Math.pow(2, 3 + config.star) - 1;
190 const a = randInt(rng, 1, max);
191 const b = randInt(rng, 1, max);
192 const op = pick(rng, ["+", "-", "x"] as const);
193 const left = op === "-" ? Math.max(a, b) : a;
194 const right = op === "-" ? Math.min(a, b) : b;
195 const answer = op === "+" ? left + right : op === "-" ? left - right : left * right;
196
197 return makeProblem(
198 config,
199 `${left.toString(2)} ${op} ${right.toString(2)}`,
200 answer.toString(2),
201 "binary"
202 );
203}
204
205function bigMultiplyProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
206 const magnitude = config.magnitude ?? "thousands";
207 const [min, max] = BIG_RANGES[magnitude];
208 const a = randInt(rng, min, max);
209 const b = randDigits(rng, Math.max(1, config.star - 1), Math.min(3, config.star));
210
211 return makeProblem(config, `${a} x ${b}`, String(a * b), "integer", { magnitude });
212}
213
214function bigDivideProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
215 const magnitude = config.magnitude ?? "thousands";
216 const [min, max] = BIG_RANGES[magnitude];
217 const quotient = randInt(rng, min, max);
218 const divisor = randDigits(rng, 1, Math.min(3, config.star));
219
220 return makeProblem(config, `${quotient * divisor} / ${divisor}`, String(quotient), "integer", {
221 magnitude,
222 });
223}
224
225function bigPercentProblem(config: ArithmeticSessionConfig, rng: Rng): ArithmeticProblem {
226 const magnitude = config.magnitude ?? "thousands";
227 const [min, max] = BIG_RANGES[magnitude];
228 const base = randInt(rng, min, max);
229 const rate = config.star <= 2 ? pick(rng, [1, 5, 10, 20, 25, 50]) : randInt(rng, 2, 35);
230 const answer = (base * rate) / 100;
231
232 return makeProblem(config, `${rate}% of ${base}`, formatNumber(answer), "decimal", {
233 magnitude,
234 acceptsFractions: true,
235 acceptsDecimals: true,
236 });
237}
238
239function operandsFor(config: ArithmeticSessionConfig, rng: Rng) {
240 const [minDigits, maxDigits] = digitRange(config);
241 let a = randDigits(rng, minDigits, maxDigits);
242 let b = randDigits(rng, minDigits, maxDigits);
243
244 if (config.mode === "custom" && config.customConfig?.allowNegatives) {
245 a *= rng.next() < 0.3 ? -1 : 1;
246 b *= rng.next() < 0.3 ? -1 : 1;
247 }
248
249 if (config.category === "subtraction" && config.mode !== "custom" && b > a) {
250 [a, b] = [b, a];
251 }
252
253 return { a, b };
254}
255
256function digitRange(config: ArithmeticSessionConfig): [number, number] {
257 if (config.mode === "custom" && config.customConfig) {
258 return [
259 clamp(Math.floor(config.customConfig.minDigits), 1, 9),
260 clamp(Math.floor(config.customConfig.maxDigits), 1, 9),
261 ];
262 }
263
264 return STAR_DIGITS[config.star];
265}
266
267function makeProblem(
268 config: ArithmeticSessionConfig,
269 expression: string,
270 canonicalAnswer: string,
271 answerFormat: AnswerFormat,
272 options: Record<string, unknown> = {}
273): ArithmeticProblem {
274 return {
275 id: crypto.randomUUID(),
276 category: config.category,
277 submode: config.submode,
278 star: config.star,
279 expression,
280 canonicalAnswer,
281 answerFormat,
282 acceptsFractions: Boolean(options.acceptsFractions) || answerFormat === "fraction",
283 acceptsDecimals: Boolean(options.acceptsDecimals) || answerFormat === "decimal",
284 metadata: options,
285 };
286}
287
288function randDigits(rng: Rng, minDigits: number, maxDigits: number): number {
289 const loDigits = Math.min(minDigits, maxDigits);
290 const hiDigits = Math.max(minDigits, maxDigits);
291 const digits = randInt(rng, loDigits, hiDigits);
292 const min = digits === 1 ? 1 : Math.pow(10, digits - 1);
293 const max = Math.pow(10, digits) - 1;
294 return randInt(rng, min, max);
295}
296
297function randInt(rng: Rng, min: number, max: number): number {
298 return Math.floor(rng.next() * (max - min + 1)) + min;
299}
300
301function pick<T>(rng: Rng, items: readonly T[]): T {
302 return items[Math.floor(rng.next() * items.length)]!;
303}
304
305function clamp(value: number, min: number, max: number): number {
306 return Math.min(max, Math.max(min, value));
307}
308
309function formatNumber(value: number): string {
310 return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)));
311}
312
313type Rational = {
314 num: number;
315 den: number;
316};
317
318function parseRational(input: string): Rational | null {
319 const value = input.trim().replace(/\s+/g, "");
320
321 if (!value) {
322 return null;
323 }
324
325 if (/^-?\d+\/-?\d+$/.test(value)) {
326 const [rawNum, rawDen] = value.split("/");
327 const den = Number(rawDen);
328 if (den === 0) {
329 return null;
330 }
331 return normalize({ num: Number(rawNum), den });
332 }
333
334 if (/^-?(?:\d+|\d*\.\d+)$/.test(value)) {
335 const negative = value.startsWith("-");
336 const unsigned = negative ? value.slice(1) : value;
337 const [whole, decimal = ""] = unsigned.split(".");
338 const den = Math.pow(10, decimal.length);
339 const num = Number((whole || "0") + decimal) * (negative ? -1 : 1);
340 return normalize({ num, den });
341 }
342
343 return null;
344}
345
346function reduceFraction(num: number, den: number): string {
347 const rational = normalize({ num, den });
348 return rational.den === 1 ? String(rational.num) : `${rational.num}/${rational.den}`;
349}
350
351function normalize(rational: Rational): Rational {
352 let { num, den } = rational;
353
354 if (den < 0) {
355 num = -num;
356 den = -den;
357 }
358
359 const divisor = gcd(abs(num), den);
360 return {
361 num: num / divisor,
362 den: den / divisor,
363 };
364}
365
366function gcd(a: number, b: number): number {
367 while (b !== 0) {
368 [a, b] = [b, a % b];
369 }
370 return a || 1;
371}
372
373function abs(value: number): number {
374 return value < 0 ? -value : value;
375}
376
377export const arithmeticCategories: Array<{
378 id: ArithmeticCategory;
379 label: string;
380 description: string;
381}> = [
382 { id: "addition", label: "Addition", description: "Fast integer sums" },
383 { id: "subtraction", label: "Subtraction", description: "Positive and signed differences" },
384 { id: "multiplication", label: "Multiplication", description: "Tables through three digits" },
385 { id: "division", label: "Division", description: "Clean division, then fractions" },
386 { id: "mixed", label: "Mixed ops", description: "Rotating arithmetic operators" },
387 { id: "roots-powers", label: "Roots and powers", description: "Square roots, squares, cubes" },
388 { id: "percents", label: "Percents", description: "Everyday percent calculations" },
389 { id: "binary", label: "Binary", description: "Base-two arithmetic" },
390 { id: "big-multiply", label: "Big multiply", description: "Large numbers by smaller factors" },
391 { id: "big-divide", label: "Big divide", description: "Large clean quotients" },
392 { id: "big-percents", label: "Big percents", description: "Percent work on large quantities" },
393];
src/lib/arithmetic/index.ts+2−0
@@ -0,0 +1,2 @@
1export * from "./generator";
2export * from "./types";
src/lib/arithmetic/types.ts+64−0
@@ -0,0 +1,64 @@
1export type ArithmeticCategory =
2 | "addition"
3 | "subtraction"
4 | "multiplication"
5 | "division"
6 | "mixed"
7 | "roots-powers"
8 | "percents"
9 | "binary"
10 | "big-multiply"
11 | "big-divide"
12 | "big-percents";
13
14export type ArithmeticSubmode = "sqrt" | "square" | "cube";
15export type ArithmeticStar = 1 | 2 | 3 | 4 | 5;
16export type ArithmeticRunMode = "untimed" | "30s" | "60s" | "120s" | "300s";
17export type BigMagnitude = "thousands" | "millions" | "billions";
18export type AnswerFormat = "integer" | "decimal" | "fraction" | "binary";
19
20export type ArithmeticCustomConfig = {
21 operations: Array<"addition" | "subtraction" | "multiplication" | "division">;
22 minDigits: number;
23 maxDigits: number;
24 allowNegatives: boolean;
25 allowDecimals: boolean;
26 allowFractions: boolean;
27 cleanDivision: boolean;
28 percentMode: "part" | "rate" | "base";
29};
30
31export type ArithmeticSessionConfig = {
32 mode: "standard" | "custom";
33 category: ArithmeticCategory;
34 submode?: ArithmeticSubmode;
35 star: ArithmeticStar;
36 runMode: ArithmeticRunMode;
37 magnitude?: BigMagnitude;
38 customConfig?: ArithmeticCustomConfig;
39};
40
41export type ArithmeticProblem = {
42 id: string;
43 category: ArithmeticCategory;
44 submode?: ArithmeticSubmode;
45 star: ArithmeticStar;
46 expression: string;
47 canonicalAnswer: string;
48 answerFormat: AnswerFormat;
49 acceptsFractions: boolean;
50 acceptsDecimals: boolean;
51 metadata: Record<string, unknown>;
52};
53
54export type ArithmeticAttemptResult = {
55 problem: ArithmeticProblem;
56 userAnswer: string;
57 correct: boolean;
58 elapsedMs: number;
59 scoreDelta: number;
60};
61
62export type Rng = {
63 next(): number;
64};
src/lib/auth/session.ts+7−4
@@ -1,12 +1,15 @@
1import "server-only";1import "server-only";
2import { withAuth } from "@workos-inc/authkit-nextjs";2import { auth } from "@clerk/nextjs/server";
3 3
4export async function getSession() {4export async function getSession() {
5 const session = await withAuth();5 return auth();
6 return session;
7}6}
8 7
9export async function requireSession() {8export async function requireSession() {
10 const session = await withAuth({ ensureSignedIn: true });9 const session = await auth();
10 if (!session.isAuthenticated) {
11 throw new Error("Unauthorized");
12 }
13
11 return session;14 return session;
12}15}
src/lib/auth/user.ts+76−0
@@ -0,0 +1,76 @@
1import "server-only";
2import { eq } from "drizzle-orm";
3import { currentUser } from "@clerk/nextjs/server";
4import { db } from "@/lib/db";
5import { users } from "@/lib/db/schema";
6
7type ClerkUser = NonNullable<Awaited<ReturnType<typeof currentUser>>>;
8
9export async function getCurrentLocalUser() {
10 const clerkUser = await currentUser();
11 if (!clerkUser) {
12 return null;
13 }
14
15 return ensureLocalUser(clerkUser);
16}
17
18export async function ensureLocalUser(clerkUser: ClerkUser) {
19 const existing = await db.query.users.findFirst({
20 where: eq(users.clerkId, clerkUser.id),
21 });
22
23 const email = primaryEmail(clerkUser);
24 const displayName =
25 clerkUser.fullName ||
26 [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ");
27 const username = usernameFromClerk(clerkUser, email);
28
29 if (existing) {
30 const [updated] = await db
31 .update(users)
32 .set({
33 displayName: displayName || existing.displayName,
34 email,
35 avatarUrl: clerkUser.imageUrl,
36 lastActiveAt: new Date(),
37 updatedAt: new Date(),
38 })
39 .where(eq(users.id, existing.id))
40 .returning();
41
42 return updated ?? existing;
43 }
44
45 const [created] = await db
46 .insert(users)
47 .values({
48 clerkId: clerkUser.id,
49 username,
50 displayName: displayName || username,
51 email,
52 avatarUrl: clerkUser.imageUrl,
53 lastActiveAt: new Date(),
54 })
55 .returning();
56
57 return created;
58}
59
60function primaryEmail(clerkUser: ClerkUser): string {
61 return (
62 clerkUser.primaryEmailAddress?.emailAddress ||
63 clerkUser.emailAddresses[0]?.emailAddress ||
64 `${clerkUser.id}@users.sumtex.local`
65 );
66}
67
68function usernameFromClerk(clerkUser: ClerkUser, email: string): string {
69 const base = (clerkUser.username || email.split("@")[0] || "user")
70 .split("@")[0]
71 .toLowerCase()
72 .replace(/[^a-z0-9_]/g, "")
73 .slice(0, 28);
74 const suffix = clerkUser.id.replace(/[^a-zA-Z0-9]/g, "").slice(-8).toLowerCase();
75 return `${base || "user"}${suffix}`;
76}
src/lib/db/migrations/0001_arithmetic.sql+41−0
@@ -0,0 +1,41 @@
1CREATE TABLE IF NOT EXISTS "arithmetic_sessions" (
2 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE cascade,
4 "mode" varchar(20) NOT NULL,
5 "category" varchar(40) NOT NULL,
6 "submode" varchar(20),
7 "star" integer,
8 "custom_config" jsonb,
9 "seed" integer NOT NULL,
10 "duration_seconds" integer,
11 "score" integer DEFAULT 0 NOT NULL,
12 "total_attempts" integer DEFAULT 0 NOT NULL,
13 "correct_attempts" integer DEFAULT 0 NOT NULL,
14 "started_at" timestamp with time zone DEFAULT now() NOT NULL,
15 "ended_at" timestamp with time zone,
16 "created_at" timestamp with time zone DEFAULT now() NOT NULL
17);
18
19CREATE TABLE IF NOT EXISTS "arithmetic_attempts" (
20 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
21 "session_id" uuid NOT NULL REFERENCES "arithmetic_sessions"("id") ON DELETE cascade,
22 "generated_prompt" jsonb NOT NULL,
23 "display_expression" text NOT NULL,
24 "canonical_answer" varchar(120) NOT NULL,
25 "user_answer" varchar(120) NOT NULL,
26 "correct" boolean NOT NULL,
27 "elapsed_ms" integer NOT NULL,
28 "score_delta" integer DEFAULT 0 NOT NULL,
29 "category" varchar(40) NOT NULL,
30 "submode" varchar(20),
31 "star" integer,
32 "metadata" jsonb,
33 "created_at" timestamp with time zone DEFAULT now() NOT NULL
34);
35
36CREATE INDEX IF NOT EXISTS "arithmetic_sessions_user_idx" ON "arithmetic_sessions" ("user_id");
37CREATE INDEX IF NOT EXISTS "arithmetic_sessions_category_idx" ON "arithmetic_sessions" ("category");
38CREATE INDEX IF NOT EXISTS "arithmetic_sessions_started_idx" ON "arithmetic_sessions" ("started_at");
39CREATE INDEX IF NOT EXISTS "arithmetic_attempts_session_idx" ON "arithmetic_attempts" ("session_id");
40CREATE INDEX IF NOT EXISTS "arithmetic_attempts_category_idx" ON "arithmetic_attempts" ("category");
41CREATE INDEX IF NOT EXISTS "arithmetic_attempts_created_idx" ON "arithmetic_attempts" ("created_at");
src/lib/db/migrations/0002_clerk_users.sql+11−0
@@ -0,0 +1,11 @@
1ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "clerk_id" text;
2
3UPDATE "users"
4SET "clerk_id" = "workos_id"
5WHERE "clerk_id" IS NULL
6 AND "workos_id" IS NOT NULL;
7
8ALTER TABLE "users" ALTER COLUMN "clerk_id" SET NOT NULL;
9CREATE UNIQUE INDEX IF NOT EXISTS "users_clerk_id_idx" ON "users" ("clerk_id");
10DROP INDEX IF EXISTS "users_workos_id_idx";
11ALTER TABLE "users" DROP COLUMN IF EXISTS "workos_id";
src/lib/db/schema.ts+81−2
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 integer,6 integer,
7 boolean,7 boolean,
8 timestamp,8 timestamp,
9 jsonb,
9 uniqueIndex,10 uniqueIndex,
10 index,11 index,
11} from "drizzle-orm/pg-core";12} from "drizzle-orm/pg-core";
@@ -19,7 +20,7 @@ export const users = pgTable( · 7 unmodified lines
19 "users",20 "users",
20 {21 {
21 id: uuid("id").primaryKey().defaultRandom(),22 id: uuid("id").primaryKey().defaultRandom(),
22 workosId: text("workos_id").notNull(),23 clerkId: text("clerk_id").notNull(),
23 username: varchar("username", { length: 40 }).notNull(),24 username: varchar("username", { length: 40 }).notNull(),
24 displayName: varchar("display_name", { length: 100 }),25 displayName: varchar("display_name", { length: 100 }),
25 email: varchar("email", { length: 255 }).notNull(),26 email: varchar("email", { length: 255 }).notNull(),
@@ -36,7 +37,7 @@ export const users = pgTable( · 10 unmodified lines
36 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),37 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
37 },38 },
38 (t) => [39 (t) => [
39 uniqueIndex("users_workos_id_idx").on(t.workosId),40 uniqueIndex("users_clerk_id_idx").on(t.clerkId),
40 uniqueIndex("users_username_idx").on(t.username),41 uniqueIndex("users_username_idx").on(t.username),
41 uniqueIndex("users_email_idx").on(t.email),42 uniqueIndex("users_email_idx").on(t.email),
42 ]43 ]
@@ -299,6 +300,65 @@ export const gameSessions = pgTable("game_sessions", { · 256 unmodified lines
299 endedAt: timestamp("ended_at", { withTimezone: true }),300 endedAt: timestamp("ended_at", { withTimezone: true }),
300});301});
301 302
303// ============================================================
304// ARITHMETIC
305// ============================================================
306
307export const arithmeticSessions = pgTable(
308 "arithmetic_sessions",
309 {
310 id: uuid("id").primaryKey().defaultRandom(),
311 userId: uuid("user_id")
312 .notNull()
313 .references(() => users.id, { onDelete: "cascade" }),
314 mode: varchar("mode", { length: 20 }).notNull(),
315 category: varchar("category", { length: 40 }).notNull(),
316 submode: varchar("submode", { length: 20 }),
317 star: integer("star"),
318 customConfig: jsonb("custom_config"),
319 seed: integer("seed").notNull(),
320 durationSeconds: integer("duration_seconds"),
321 score: integer("score").notNull().default(0),
322 totalAttempts: integer("total_attempts").notNull().default(0),
323 correctAttempts: integer("correct_attempts").notNull().default(0),
324 startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
325 endedAt: timestamp("ended_at", { withTimezone: true }),
326 createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
327 },
328 (t) => [
329 index("arithmetic_sessions_user_idx").on(t.userId),
330 index("arithmetic_sessions_category_idx").on(t.category),
331 index("arithmetic_sessions_started_idx").on(t.startedAt),
332 ]
333);
334
335export const arithmeticAttempts = pgTable(
336 "arithmetic_attempts",
337 {
338 id: uuid("id").primaryKey().defaultRandom(),
339 sessionId: uuid("session_id")
340 .notNull()
341 .references(() => arithmeticSessions.id, { onDelete: "cascade" }),
342 generatedPrompt: jsonb("generated_prompt").notNull(),
343 displayExpression: text("display_expression").notNull(),
344 canonicalAnswer: varchar("canonical_answer", { length: 120 }).notNull(),
345 userAnswer: varchar("user_answer", { length: 120 }).notNull(),
346 correct: boolean("correct").notNull(),
347 elapsedMs: integer("elapsed_ms").notNull(),
348 scoreDelta: integer("score_delta").notNull().default(0),
349 category: varchar("category", { length: 40 }).notNull(),
350 submode: varchar("submode", { length: 20 }),
351 star: integer("star"),
352 metadata: jsonb("metadata"),
353 createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
354 },
355 (t) => [
356 index("arithmetic_attempts_session_idx").on(t.sessionId),
357 index("arithmetic_attempts_category_idx").on(t.category),
358 index("arithmetic_attempts_created_idx").on(t.createdAt),
359 ]
360);
361
302// ============================================================362// ============================================================
303// LEADERBOARD363// LEADERBOARD
304// ============================================================364// ============================================================
@@ -403,6 +463,7 @@ export const userApiKeys = pgTable( · 98 unmodified lines
403export const usersRelations = relations(users, ({ many }) => ({463export const usersRelations = relations(users, ({ many }) => ({
404 progress: many(userProgress),464 progress: many(userProgress),
405 sessions: many(gameSessions),465 sessions: many(gameSessions),
466 arithmeticSessions: many(arithmeticSessions),
406 submittedProblems: many(problems),467 submittedProblems: many(problems),
407 apiKeys: many(userApiKeys),468 apiKeys: many(userApiKeys),
408 threads: many(forumThreads),469 threads: many(forumThreads),
@@ -502,6 +563,24 @@ export const gameSessionsRelations = relations(gameSessions, ({ one }) => ({ · 93 unmodified lines
502 }),563 }),
503}));564}));
504 565
566export const arithmeticSessionsRelations = relations(
567 arithmeticSessions,
568 ({ one, many }) => ({
569 user: one(users, {
570 fields: [arithmeticSessions.userId],
571 references: [users.id],
572 }),
573 attempts: many(arithmeticAttempts),
574 })
575);
576
577export const arithmeticAttemptsRelations = relations(arithmeticAttempts, ({ one }) => ({
578 session: one(arithmeticSessions, {
579 fields: [arithmeticAttempts.sessionId],
580 references: [arithmeticSessions.id],
581 }),
582}));
583
505export const forumThreadsRelations = relations(forumThreads, ({ one, many }) => ({584export const forumThreadsRelations = relations(forumThreads, ({ one, many }) => ({
506 category: one(forumCategories, {585 category: one(forumCategories, {
507 fields: [forumThreads.categoryId],586 fields: [forumThreads.categoryId],
src/lib/guides.tsx+220−0
@@ -0,0 +1,220 @@
1import type { ReactNode } from "react";
2import { MathDisplay } from "@/components/math/mathJax";
3
4export type Guide = {
5 slug: string;
6 title: string;
7 dek: string;
8 author: string;
9 source: "Platform" | "User";
10 level: string;
11 readTime: string;
12 tags: string[];
13 updatedAt: string;
14 sections: Array<{
15 heading: string;
16 body: ReactNode;
17 }>;
18};
19
20export const guides: Guide[] = [
21 {
22 slug: "algebraic-factoring-field-guide",
23 title: "A field guide to algebraic factoring",
24 dek: "Recognize the common shapes behind school algebra before the symbols get noisy.",
25 author: "@krisyotam",
26 source: "Platform",
27 level: "Algebra",
28 readTime: "9 min",
29 tags: ["algebra", "factoring", "patterns"],
30 updatedAt: "July 14, 2026",
31 sections: [
32 {
33 heading: "The point",
34 body: (
35 <>
36 <p>
37 Factoring is pattern recognition with a proof attached. You are rewriting one expression as a product so that roots, signs, and cancellations become visible.
38 </p>
39 <MathDisplay latex="x^2 + 5x + 6 = (x + 2)(x + 3)" />
40 <p>
41 The right side is not a trick. It expands back into the left side, so the rewrite preserves the expression while revealing its structure.
42 </p>
43 </>
44 ),
45 },
46 {
47 heading: "Start with the greatest common factor",
48 body: (
49 <>
50 <p>
51 Before looking for a famous pattern, remove anything shared by every term. This reduces the work and keeps later steps smaller.
52 </p>
53 <MathDisplay latex="12x^3 - 18x^2 = 6x^2(2x - 3)" />
54 </>
55 ),
56 },
57 {
58 heading: "Quadratics by sum and product",
59 body: (
60 <>
61 <p>
62 For a monic quadratic, find two numbers whose product is the constant term and whose sum is the linear coefficient.
63 </p>
64 <MathDisplay latex="x^2 + bx + c = (x + m)(x + n), \\quad m+n=b, \\quad mn=c" />
65 <p>
66 This test is fast because it turns a symbolic problem into a short integer search.
67 </p>
68 </>
69 ),
70 },
71 {
72 heading: "Difference of squares",
73 body: (
74 <>
75 <p>
76 Two square terms separated by subtraction factor into conjugates. The middle terms cancel during expansion.
77 </p>
78 <MathDisplay latex="a^2 - b^2 = (a-b)(a+b)" />
79 <p>
80 Typical examples include $x^2 - 49$, $9y^2 - 4$, and $16m^4 - n^2$.
81 </p>
82 </>
83 ),
84 },
85 ],
86 },
87 {
88 slug: "limits-without-mysticism",
89 title: "Limits without mysticism",
90 dek: "A first calculus guide for reading limit notation as a claim about control.",
91 author: "@krisyotam",
92 source: "User",
93 level: "Calculus",
94 readTime: "11 min",
95 tags: ["calculus", "limits", "epsilon delta"],
96 updatedAt: "July 14, 2026",
97 sections: [
98 {
99 heading: "What a limit says",
100 body: (
101 <>
102 <p>
103 A limit is not asking what happens at a single point. It asks what values can be forced near a target by choosing inputs close enough to a point.
104 </p>
105 <MathDisplay latex="\\lim_{x \\to a} f(x) = L" />
106 <p>
107 Read this as: values of $f(x)$ can be made arbitrarily close to $L$ by taking $x$ close enough to $a$.
108 </p>
109 </>
110 ),
111 },
112 {
113 heading: "The epsilon delta form",
114 body: (
115 <>
116 <p>
117 The formal definition turns closeness into two tolerances. Epsilon measures output error. Delta measures input distance.
118 </p>
119 <MathDisplay latex="0 < |x-a| < \\delta \\implies |f(x)-L| < \\varepsilon" />
120 <p>
121 The game is to pick a delta that works for any requested epsilon.
122 </p>
123 </>
124 ),
125 },
126 {
127 heading: "A linear example",
128 body: (
129 <>
130 <p>
131 For $f(x)=3x+1$ at $x=2$, the expected limit is $7$. The error simplifies cleanly.
132 </p>
133 <MathDisplay latex="|(3x+1)-7| = |3x-6| = 3|x-2|" />
134 <p>
135 Choosing $\delta = \varepsilon/3$ makes the output error smaller than epsilon.
136 </p>
137 </>
138 ),
139 },
140 {
141 heading: "Why this matters",
142 body: (
143 <>
144 <p>
145 Derivatives, continuity, and integrals all depend on limits. The notation is compact, but the underlying idea is consistent: control output behavior by controlling input behavior.
146 </p>
147 </>
148 ),
149 },
150 ],
151 },
152 {
153 slug: "complex-numbers-as-geometry",
154 title: "Complex numbers as geometry",
155 dek: "Use the plane, not just rules, to understand multiplication by complex numbers.",
156 author: "@krisyotam",
157 source: "Platform",
158 level: "Complex Analysis",
159 readTime: "8 min",
160 tags: ["complex numbers", "geometry", "analysis"],
161 updatedAt: "July 14, 2026",
162 sections: [
163 {
164 heading: "The plane model",
165 body: (
166 <>
167 <p>
168 A complex number has a real part and an imaginary part, so it can be plotted as a point or vector in the plane.
169 </p>
170 <MathDisplay latex="z = a + bi \\quad \\leftrightarrow \\quad (a,b)" />
171 </>
172 ),
173 },
174 {
175 heading: "Modulus and argument",
176 body: (
177 <>
178 <p>
179 The modulus is distance from the origin. The argument is angle from the positive real axis.
180 </p>
181 <MathDisplay latex="|z| = \\sqrt{a^2+b^2}, \\quad \\arg(z)=\\theta" />
182 <p>
183 This makes polar form natural.
184 </p>
185 <MathDisplay latex="z = r(\\cos \\theta + i\\sin \\theta)" />
186 </>
187 ),
188 },
189 {
190 heading: "Multiplication rotates and scales",
191 body: (
192 <>
193 <p>
194 Multiplying complex numbers multiplies their lengths and adds their angles.
195 </p>
196 <MathDisplay latex="r_1 e^{i\\theta_1} \\cdot r_2 e^{i\\theta_2} = r_1r_2 e^{i(\\theta_1+\\theta_2)}" />
197 <p>
198 This is the reason $i$ behaves like a quarter-turn: multiplying by $i$ rotates by $90^\\circ$.
199 </p>
200 </>
201 ),
202 },
203 {
204 heading: "Roots become symmetry",
205 body: (
206 <>
207 <p>
208 The $n$th roots of a complex number sit evenly around a circle. Algebra becomes a regular polygon.
209 </p>
210 <MathDisplay latex="z_k = r^{1/n} e^{i(\\theta + 2\\pi k)/n}, \\quad k=0,1,\\dots,n-1" />
211 </>
212 ),
213 },
214 ],
215 },
216];
217
218export function getGuide(slug: string) {
219 return guides.find((guide) => guide.slug === slug);
220}
src/proxy.ts+15−0
@@ -0,0 +1,15 @@
1import { clerkMiddleware } from "@clerk/nextjs/server";
2import type { NextFetchEvent, NextRequest } from "next/server";
3
4const clerkProxy = clerkMiddleware();
5
6export function proxy(request: NextRequest, event: NextFetchEvent) {
7 return clerkProxy(request, event);
8}
9
10export const config = {
11 matcher: [
12 "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
13 "/(api|trpc)(.*)",
14 ],
15};
src/types/index.ts+4−0
@@ -9,6 +9,8 @@ import type { · 8 unmodified lines
9 collections,9 collections,
10 userProgress,10 userProgress,
11 gameSessions,11 gameSessions,
12 arithmeticSessions,
13 arithmeticAttempts,
12 leaderboardEntries,14 leaderboardEntries,
13 forumCategories,15 forumCategories,
14 forumThreads,16 forumThreads,
@@ -25,6 +27,8 @@ export type Roadmap = InferSelectModel<typeof roadmaps>; · 10 unmodified lines
25export type Collection = InferSelectModel<typeof collections>;27export type Collection = InferSelectModel<typeof collections>;
26export type UserProgress = InferSelectModel<typeof userProgress>;28export type UserProgress = InferSelectModel<typeof userProgress>;
27export type GameSession = InferSelectModel<typeof gameSessions>;29export type GameSession = InferSelectModel<typeof gameSessions>;
30export type ArithmeticSession = InferSelectModel<typeof arithmeticSessions>;
31export type ArithmeticAttempt = InferSelectModel<typeof arithmeticAttempts>;
28export type LeaderboardEntry = InferSelectModel<typeof leaderboardEntries>;32export type LeaderboardEntry = InferSelectModel<typeof leaderboardEntries>;
29export type ForumCategory = InferSelectModel<typeof forumCategories>;33export type ForumCategory = InferSelectModel<typeof forumCategories>;
30export type ForumThread = InferSelectModel<typeof forumThreads>;34export type ForumThread = InferSelectModel<typeof forumThreads>;