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
3434 # typescript
3535 *.tsbuildinfo
3636 next-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
99 "version": "3.0.0",
1010 "license": "MIT",
1111 "dependencies": {
12− "@workos-inc/authkit-nextjs": "latest",
12+ "@clerk/nextjs": "^7.5.17",
1313 "better-react-mathjax": "^2.1.0",
1414 "drizzle-orm": "^0.44.0",
1515 "geist": "^1.4.1",
@@ -45,6 +45,85 @@ · 29 unmodified lines
4545 "url": "https://github.com/sponsors/sindresorhus"
4646 }
4747 },
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+ },
48127 "node_modules/@drizzle-team/brocli": {
49128 "version": "0.10.2",
50129 "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -1650,17 +1729,11 @@ · 1599 unmodified lines
16501729 "node": ">= 10"
16511730 }
16521731 },
1653− "node_modules/@sindresorhus/fnv1a": {
1654− "version": "3.1.0",
1655− "resolved": "https://registry.npmjs.org/@sindresorhus/fnv1a/-/fnv1a-3.1.0.tgz",
1656− "integrity": "sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==",
1657− "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− }
1732+ "node_modules/@stablelib/base64": {
1733+ "version": "1.0.1",
1734+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
1735+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
1736+ "license": "MIT"
16641737 },
16651738 "node_modules/@swc/helpers": {
16661739 "version": "0.5.15",
@@ -1954,6 +2027,16 @@ · 287 unmodified lines
19542027 "tailwindcss": "4.3.0"
19552028 }
19562029 },
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+ },
19572040 "node_modules/@types/node": {
19582041 "version": "25.9.1",
19592042 "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -1984,40 +2067,6 @@ · 24 unmodified lines
19842067 "@types/react": "^19.2.0"
19852068 }
19862069 },
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− },
20212070 "node_modules/@xmldom/xmldom": {
20222071 "version": "0.9.10",
20232072 "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
@@ -2093,15 +2142,6 @@ · 69 unmodified lines
20932142 "node": ">=18"
20942143 }
20952144 },
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− },
21052145 "node_modules/csstype": {
21062146 "version": "3.2.3",
21072147 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2109,6 +2149,15 @@ · 1 unmodified line
21092149 "dev": true,
21102150 "license": "MIT"
21112151 },
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+ },
21122161 "node_modules/detect-libc": {
21132162 "version": "2.1.2",
21142163 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2325,11 +2374,11 @@ · 210 unmodified lines
23252374 "node": ">=6"
23262375 }
23272376 },
2328− "node_modules/eventemitter3": {
2329− "version": "5.0.4",
2330− "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
2331− "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
2332− "license": "MIT"
2377+ "node_modules/fast-sha256": {
2378+ "version": "1.3.0",
2379+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
2380+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
2381+ "license": "Unlicense"
23332382 },
23342383 "node_modules/fsevents": {
23352384 "version": "2.3.3",
@@ -2368,6 +2417,12 @@ · 32 unmodified lines
23682417 "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
23692418 }
23702419 },
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+ },
23712426 "node_modules/graceful-fs": {
23722427 "version": "4.2.11",
23732428 "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2375,30 +2430,6 @@ · 1 unmodified line
23752430 "dev": true,
23762431 "license": "ISC"
23772432 },
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− },
24022433 "node_modules/jiti": {
24032434 "version": "2.7.0",
24042435 "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2409,13 +2440,13 @@ · 4 unmodified lines
24092440 "jiti": "lib/jiti-cli.mjs"
24102441 }
24112442 },
2412− "node_modules/jose": {
2413− "version": "5.10.0",
2414− "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
2415− "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
2443+ "node_modules/js-cookie": {
2444+ "version": "3.0.7",
2445+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
2446+ "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
24162447 "license": "MIT",
2417− "funding": {
2418− "url": "https://github.com/sponsors/panva"
2448+ "engines": {
2449+ "node": ">=20"
24192450 }
24202451 },
24212452 "node_modules/lightningcss": {
@@ -2825,12 +2856,6 @@ · 403 unmodified lines
28252856 "node": "^10 || ^12 || >=14"
28262857 }
28272858 },
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− },
28342859 "node_modules/picocolors": {
28352860 "version": "1.1.1",
28362861 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3024,6 +3049,16 @@ · 187 unmodified lines
30243049 "sre": "bin/sre"
30253050 }
30263051 },
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+ },
30273062 "node_modules/styled-jsx": {
30283063 "version": "5.1.6",
30293064 "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -3581,7 +3616,7 @@ · 551 unmodified lines
35813616 "version": "6.0.3",
35823617 "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
35833618 "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
3584− "devOptional": true,
3619+ "dev": true,
35853620 "license": "Apache-2.0",
35863621 "bin": {
35873622 "tsc": "bin/tsc",
@@ -3591,12 +3626,6 @@ · 3 unmodified lines
35913626 "node": ">=14.17"
35923627 }
35933628 },
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− },
36003629 "node_modules/undici-types": {
36013630 "version": "7.24.6",
36023631 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
@@ -3604,20 +3633,6 @@ · 1 unmodified line
36043633 "dev": true,
36053634 "license": "MIT"
36063635 },
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− },
36213636 "node_modules/wicked-good-xpath": {
36223637 "version": "1.3.0",
36233638 "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
88 "build": "next build",
99 "start": "next start",
1010 "lint": "next lint",
11+ "test:arithmetic": "tsx scripts/test-arithmetic.ts",
1112 "db:generate": "drizzle-kit generate",
1213 "db:migrate": "tsx scripts/migrate.ts",
1314 "db:seed": "tsx scripts/seed.ts",
@@ -19,7 +20,7 @@ · 5 unmodified lines
1920 },
2021 "license": "MIT",
2122 "dependencies": {
22− "@workos-inc/authkit-nextjs": "latest",
23+ "@clerk/nextjs": "^7.5.17",
2324 "better-react-mathjax": "^2.1.0",
2425 "drizzle-orm": "^0.44.0",
2526 "geist": "^1.4.1",
scripts/test-arithmetic.ts+74−0
@@ -0,0 +1,74 @@
1+import {
2+ arithmeticCategories,
3+ checkArithmeticAnswer,
4+ createSeededRng,
5+ generateArithmeticProblem,
6+ scoreArithmeticAttempt,
7+ type ArithmeticSessionConfig,
8+ type ArithmeticStar,
9+} from "../src/lib/arithmetic";
10+
11+function assert(condition: unknown, message: string): asserts condition {
12+ if (!condition) {
13+ throw new Error(message);
14+ }
15+}
16+
17+const stars: ArithmeticStar[] = [1, 2, 3, 4, 5];
18+
19+for (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+
51+const fractionConfig: ArithmeticSessionConfig = {
52+ mode: "standard",
53+ category: "division",
54+ star: 5,
55+ runMode: "60s",
56+};
57+const fractionProblem = {
58+ ...generateArithmeticProblem(fractionConfig, createSeededRng(9)),
59+ canonicalAnswer: "1/2",
60+ answerFormat: "fraction" as const,
61+};
62+
63+assert(checkArithmeticAnswer(fractionProblem, "0.5"), "0.5 should equal 1/2");
64+assert(checkArithmeticAnswer(fractionProblem, "2/4"), "2/4 should equal 1/2");
65+assert(!checkArithmeticAnswer(fractionProblem, "0.6"), "0.6 should not equal 1/2");
66+
67+const binaryProblem = generateArithmeticProblem(
68+ { mode: "standard", category: "binary", star: 2, runMode: "60s" },
69+ createSeededRng(12)
70+);
71+assert(/^[01]+$/.test(binaryProblem.canonicalAnswer), "binary answer should be base two");
72+assert(checkArithmeticAnswer(binaryProblem, binaryProblem.canonicalAnswer), "binary answer failed");
73+
74+console.log("arithmetic tests passed");
src/app/(app)/layout.tsx+0−3
@@ -1,12 +1,9 @@
1−import { Header } from "@/components/navigation/header";
2−
31 export default function AppLayout({ children }: { children: React.ReactNode }) {
42 return (
53 <div
64 className="min-h-screen flex flex-col"
75 style={{ background: "var(--bg-canvas)" }}
86 >
9− <Header />
107 <main className="flex-1">{children}</main>
118 </div>
129 );
src/app/(app)/paths/[slug]/page.tsx+102−0
@@ -0,0 +1,102 @@
1+import { notFound } from "next/navigation";
2+import { SurfaceNav } from "@/components/navigation/surfaceNav";
3+import { getGuide, guides } from "@/lib/guides";
4+
5+interface Props {
6+ params: Promise<{ slug: string }>;
7+}
8+
9+export function generateStaticParams() {
10+ return guides.map((guide) => ({ slug: guide.slug }));
11+}
12+
13+export 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+
56+const 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 @@
1−export default function PathsPage() {
1+import { SurfaceNav } from "@/components/navigation/surfaceNav";
2+import { guides } from "@/lib/guides";
3+
4+export default function GuidesPage() {
25 return (
3− <div className="max-w-4xl mx-auto px-6 py-12">
4− <h1
5− className="text-2xl font-semibold mb-4"
6− style={{ color: "var(--text-primary)" }}
7− >
8− Textbook Paths
9− </h1>
10− <p style={{ color: "var(--text-secondary)" }}>
11− Work through a textbook from start to finish. Each path contains all
12− problems from a specific text with synopses and page references.
13− </p>
14− </div>
6+ <>
7+ <style>{guideIndexStyles}</style>
8+ <main className="guides-shell">
9+ <div className="guides-frame">
10+ <SurfaceNav variant="light" />
11+
12+ <header className="guides-hero">
13+ <p>SumTex Guides</p>
14+ <h1>Guides</h1>
15+ <div>
16+ Platform and user-authored documents for algebra, calculus, analysis, and proof technique.
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+ </>
1540 );
1641 }
42+
43+const 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 @@
1+import { MentalArithmeticPlay } from "@/components/arithmetic/mentalArithmeticPlay";
2+
3+export default function MentalArithmeticPage() {
4+ return <MentalArithmeticPlay />;
5+}
src/app/(app)/play/page.tsx+102−45
@@ -1,51 +1,108 @@
1+import { SurfaceNav } from "@/components/navigation/surfaceNav";
2+import { arithmeticCategories } from "@/lib/arithmetic";
3+
4+const 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+
112 export default function PlayPage() {
213 return (
3− <div className="max-w-4xl mx-auto px-6 py-12">
4− <h1
5− className="text-2xl font-semibold mb-4"
6− style={{ color: "var(--text-primary)" }}
7− >
8− Play
9− </h1>
10− <p style={{ color: "var(--text-secondary)" }}>
11− Select a mode to begin solving problems.
12− </p>
13− <div className="grid grid-cols-2 gap-6 mt-8">
14− <div
15− className="p-6 rounded-lg border cursor-pointer transition-colors"
16− style={{
17− background: "var(--bg-primary)",
18− borderColor: "var(--border-color)",
19− }}
20− >
21− <h2
22− className="text-lg font-medium mb-2"
23− style={{ color: "var(--text-primary)" }}
24− >
25− Timed
26− </h2>
27− <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
28− 3 minutes. Solve as many problems as you can. 1.5x XP multiplier.
29− </p>
30− </div>
31− <div
32− className="p-6 rounded-lg border cursor-pointer transition-colors"
33− style={{
34− background: "var(--bg-primary)",
35− borderColor: "var(--border-color)",
36− }}
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>
14+ <>
15+ <style>{playHubStyles}</style>
16+ <div className="play-shell">
17+ <div className="play-frame">
18+ <SurfaceNav variant="dark" />
19+
20+ <header className="play-hero">
21+ <div>
22+ <p className="play-kicker">SumTex Play</p>
23+ <h1>Modes of Arithmetic</h1>
24+ <p>
25+ Choose the drill type first. Stars, timing, custom settings, and keypad entry stay inside the run page.
26+ </p>
27+ </div>
28+ <a className="play-cta" href="/play/mental-arithmetic?category=addition&star=2">
29+ Start addition
30+ </a>
31+ </header>
32+
33+ <section className="play-grid" aria-label="Arithmetic modes">
34+ {arithmeticModes.map((mode, index) => (
35+ <a key={mode.title} className="play-mode live" href={mode.href}>
36+ <div className="play-mode-index">{String(index + 1).padStart(2, "0")}</div>
37+ <div className="play-mode-body">
38+ <div className="play-mode-top">
39+ <h2>{mode.title}</h2>
40+ <span className="play-status live">{mode.status}</span>
41+ </div>
42+ <p>{mode.description}</p>
43+ <div className="play-mode-detail">{mode.detail}</div>
44+ </div>
45+ </a>
46+ ))}
47+ </section>
4748 </div>
4849 </div>
49− </div>
50+ </>
5051 );
5152 }
53+
54+const 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 @@
1+import { SurfaceNav } from "@/components/navigation/surfaceNav";
2+
3+const 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+
148 export default function ProblemsPage() {
249 return (
3− <div className="max-w-4xl mx-auto px-6 py-12">
4− <h1
5− className="text-2xl font-semibold mb-4"
6− style={{ color: "var(--text-primary)" }}
7− >
8− Problems
9− </h1>
10− <p style={{ color: "var(--text-secondary)" }}>
11− Browse problems by subject, difficulty, or textbook.
12− </p>
13− </div>
50+ <>
51+ <style>{problemHubStyles}</style>
52+ <div className="problem-shell">
53+ <div className="problem-frame">
54+ <SurfaceNav variant="dark" />
55+
56+ <header className="problem-hero">
57+ <div>
58+ <p className="problem-kicker">SumTex Problems</p>
59+ <h1>Choose a subject</h1>
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+ </>
1484 );
1585 }
86+
87+const 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+
3+import { useMemo, useState } from "react";
4+import Link from "next/link";
5+import { SurfaceNav } from "@/components/navigation/surfaceNav";
6+
7+type FeedTab = "activity" | "play" | "problems" | "guides";
8+type FeedKind = "play" | "problems" | "guides";
9+
10+interface 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+
20+const 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+
28+const 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+
36+const 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+
111+const 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+
118+const tabLabels: Record<FeedTab, string> = {
119+ activity: "Activity",
120+ play: "Play",
121+ problems: "Problems",
122+ guides: "Guides",
123+};
124+
125+const 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+
1132 export 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+
253+function 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+
278+function Metric({ label, value, tone }: { label: string; value: string; tone?: string }) {
279+ const stat = stats.find((item) => item.label === label);
280+
2281 return (
3− <div className="max-w-4xl mx-auto px-6 py-12">
4− <h1
5− className="text-2xl font-semibold mb-4"
6− style={{ color: "var(--text-primary)" }}
7− >
8− Profile
9− </h1>
10− <p style={{ color: "var(--text-secondary)" }}>
11− Edit your profile, bio, and links.
12− </p>
282+ <div className="act-stat">
283+ <div className={`act-stat-badge${tone === "green" ? " green" : ""}`}>{stat?.mark ?? label.slice(0, 1)}</div>
284+ <div className={`act-stat-value${tone === "green" ? " green" : ""}`}>{value}</div>
285+ <div className="act-stat-label">{label}</div>
13286 </div>
14287 );
15288 }
289+
290+const 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 @@
1−export default function SettingsPage() {
1+import { UserProfile } from "@clerk/nextjs";
2+import { currentUser } from "@clerk/nextjs/server";
3+import { SurfaceNav } from "@/components/navigation/surfaceNav";
4+
5+export default async function SettingsPage() {
6+ const user = await currentUser();
7+
28 return (
3− <div className="max-w-4xl mx-auto px-6 py-12">
4− <h1
5− className="text-2xl font-semibold mb-4"
6− style={{ color: "var(--text-primary)" }}
7− >
8− Settings
9− </h1>
10− <p style={{ color: "var(--text-secondary)" }}>
11− Manage your API keys, custom CSS, and account settings.
12− </p>
13− </div>
9+ <main className="settings-shell">
10+ <style>{settingsStyles}</style>
11+ <div className="settings-frame">
12+ <SurfaceNav variant="light" />
13+ <section className="settings-head">
14+ <p>SumTex Profile</p>
15+ <h1>Account management</h1>
16+ </section>
17+ {user ? (
18+ <UserProfile
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>
1436 );
1537 }
38+
39+const 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 @@
1+import { NextResponse } from "next/server";
2+import { auth } from "@clerk/nextjs/server";
3+import { and, eq, sql } from "drizzle-orm";
4+import { z } from "zod";
5+import { getCurrentLocalUser } from "@/lib/auth/user";
6+import { db } from "@/lib/db";
7+import { arithmeticAttempts, arithmeticSessions } from "@/lib/db/schema";
8+
9+const 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+
28+export 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 @@
1+import { NextResponse } from "next/server";
2+import { auth } from "@clerk/nextjs/server";
3+import { and, eq } from "drizzle-orm";
4+import { z } from "zod";
5+import { getCurrentLocalUser } from "@/lib/auth/user";
6+import { db } from "@/lib/db";
7+import { arithmeticSessions } from "@/lib/db/schema";
8+
9+const 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+
16+export 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 @@
1+import { NextResponse } from "next/server";
2+import { auth } from "@clerk/nextjs/server";
3+import { z } from "zod";
4+import { getCurrentLocalUser } from "@/lib/auth/user";
5+import { db } from "@/lib/db";
6+import { arithmeticSessions } from "@/lib/db/schema";
7+
8+const 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+
21+export 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
99 --text-secondary: #666666;
1010 --text-muted: #888888;
1111 --border-color: #e0e0e0;
12− --link-color: rgba(10, 132, 255, 1);
13− --link-visited: #af52de;
12+ --link-color: var(--text-primary);
13+ --link-visited: var(--text-secondary);
1414 --accent: #333333;
1515 --accent-muted: #666666;
1616
@@ -52,8 +52,8 @@ · 35 unmodified lines
5252 --text-secondary: hsl(0 0% 75%);
5353 --text-muted: hsl(0 0% 50%);
5454 --border-color: hsl(0 0% 20%);
55− --link-color: #79a8ff;
56− --link-visited: #f78fe7;
55+ --link-color: var(--text-primary);
56+ --link-visited: var(--text-secondary);
5757 --accent: hsl(0 0% 100%);
5858 --accent-muted: hsl(0 0% 75%);
5959
@@ -140,10 +140,740 @@ body { · 80 unmodified lines
140140 }
141141
142142 a {
143− color: var(--link-color);
143+ color: inherit;
144144 text-decoration: none;
145145 }
146146
147147 a:hover {
148− text-decoration: underline;
148+ text-decoration: none;
149+}
150+
151+button,
152+input {
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+ }
149879 }
src/app/layout.tsx+6−3
@@ -1,4 +1,5 @@
11 import type { Metadata } from "next";
2+import { ClerkProvider } from "@clerk/nextjs";
23 import { GeistSans } from "geist/font/sans";
34 import { GeistMono } from "geist/font/mono";
45 import { ThemeProvider } from "@/lib/theme";
@@ -19,9 +20,11 @@ export default function RootLayout({ · 14 unmodified lines
1920 return (
2021 <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
2122 <body>
22− <ThemeProvider>
23− <MathJaxProvider>{children}</MathJaxProvider>
24− </ThemeProvider>
23+ <ClerkProvider dynamic>
24+ <ThemeProvider>
25+ <MathJaxProvider>{children}</MathJaxProvider>
26+ </ThemeProvider>
27+ </ClerkProvider>
2528 </body>
2629 </html>
2730 );
src/app/page.tsx+57−98
@@ -1,105 +1,64 @@
1−import { Header } from "@/components/navigation/header";
2−
3−export default function LandingPage() {
1+export default function HomePage() {
42 return (
5− <div
6− className="min-h-screen flex flex-col"
7− style={{ background: "var(--bg-canvas)" }}
8− >
9− <Header />
10− <main className="flex-1 flex flex-col items-center justify-center px-6">
11− <div className="max-w-2xl text-center">
12− <h1
13− className="text-4xl font-semibold tracking-tight mb-4"
14− style={{ color: "var(--text-primary)" }}
15− >
16− SumTeX
17− </h1>
18− <p
19− className="text-lg mb-8 leading-relaxed"
20− style={{ color: "var(--text-secondary)" }}
3+ <main className="relative min-h-screen overflow-hidden bg-[#111111] text-[#f2f0ea]">
4+ <div className="absolute right-8 top-5 flex items-center gap-3 text-xs text-[#8d8d8d]">
5+ <a href="/play/mental-arithmetic" className="rounded-full border border-[#303030] px-4 py-2 text-[#d8d5ce] no-underline">
6+ Mental Arithmetic
7+ </a>
8+ <a href="/profile" className="flex h-8 w-8 items-center justify-center rounded-full bg-[#202020] text-[#d8d5ce] no-underline">
9+ S
10+ </a>
11+ </div>
12+
13+ <div className="pointer-events-none absolute inset-0">
14+ <span className="absolute left-[6%] top-[16%] text-[#777]">+</span>
15+ <span className="absolute left-[25%] top-[38%] text-[#777]">.</span>
16+ <span className="absolute right-[18%] top-[28%] text-[#777]">.</span>
17+ <span className="absolute bottom-[28%] left-[58%] text-[#777]">+</span>
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"
2136 >
22− Mathematics is learned through textbooks and repetition, not videos.
23− Real problems from Rudin, Spivak, Artin, Munkres, and more. Solve
24− them. Track your progress. Get better.
25− </p>
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>
37+ Go
38+ </button>
39+ </form>
40+ </section>
5041
51− <div
52− className="mt-16 grid grid-cols-3 gap-8 max-w-3xl w-full"
53− style={{ color: "var(--text-secondary)" }}
54− >
55− <div className="text-center">
56− <div
57− className="text-2xl font-semibold mb-1"
58− style={{ color: "var(--text-primary)" }}
59− >
60− Textbooks
61− </div>
62− <p className="text-sm">
63− Problems pulled from qualified mathematical texts. Spivak,
64− Rudin, Artin, Munkres.
65− </p>
66− </div>
67− <div className="text-center">
68− <div
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>
42+ <footer className="absolute inset-x-0 bottom-6 grid grid-cols-3 items-end px-10 text-xs text-[#676767]">
43+ <div />
44+ <div className="text-center">
45+ <div>Generated drills</div>
46+ <div className="mt-1 font-mono text-base text-[#aaa]">11 categories</div>
47+ </div>
48+ <div className="justify-self-end">
49+ <a href="/play" className="text-[#676767] no-underline">
50+ Play
51+ </a>
52+ <span className="mx-2">.</span>
53+ <a href="/problems" className="text-[#676767] no-underline">
54+ Problems
55+ </a>
56+ <span className="mx-2">.</span>
57+ <a href="/sign-in" className="text-[#676767] no-underline">
58+ Sign in
59+ </a>
9160 </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
10261 </footer>
103− </div>
62+ </main>
10463 );
10564 }
src/app/sign-in/[[...sign-in]]/page.tsx+9−0
@@ -0,0 +1,9 @@
1+import { SignIn } from "@clerk/nextjs";
2+
3+export 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 @@
1+import { SignUp } from "@clerk/nextjs";
2+
3+export 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+
3+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+import { useRouter, useSearchParams } from "next/navigation";
5+import { SurfaceNav } from "@/components/navigation/surfaceNav";
6+import {
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+
23+type AttemptRow = {
24+ expression: string;
25+ answer: string;
26+ userAnswer: string;
27+ correct: boolean;
28+};
29+
30+const 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+
38+const 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+
44+const magnitudes: Array<{ id: BigMagnitude; label: string }> = [
45+ { id: "thousands", label: "Thousands" },
46+ { id: "millions", label: "Millions" },
47+ { id: "billions", label: "Billions" },
48+];
49+
50+export 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+
483+function 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+
504+function 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+
529+function 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+
552+function 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+
569+function 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+
578+function parseCategory(value: string | null): ArithmeticCategory {
579+ return arithmeticCategories.some((item) => item.id === value)
580+ ? (value as ArithmeticCategory)
581+ : "addition";
582+}
583+
584+function 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+
591+const 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
88 }
99
1010 export function MathDisplay({ latex, inline = false }: MathDisplayProps) {
11− const wrapped = inline ? `$${latex}$` : `$$${latex}$$`;
12− return <MathJax>{wrapped}</MathJax>;
11+ return (
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+ );
1319 }
src/components/navigation/surfaceNav.tsx+59−0
@@ -0,0 +1,59 @@
1+"use client";
2+
3+import { Show, SignInButton, UserButton } from "@clerk/nextjs";
4+import { ThemeToggle } from "@/components/navigation/themeToggle";
5+
6+const 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+
17+export 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+
3+import { useTheme } from "@/lib/theme";
4+
5+export 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 @@
1+import type {
2+ ArithmeticAttemptResult,
3+ ArithmeticCategory,
4+ ArithmeticCustomConfig,
5+ ArithmeticProblem,
6+ ArithmeticSessionConfig,
7+ ArithmeticStar,
8+ AnswerFormat,
9+ BigMagnitude,
10+ Rng,
11+} from "./types";
12+
13+const 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+
21+const 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+
27+export 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+
41+export 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+
54+export 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+
88+export 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+
104+export 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+
120+function 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+
130+function 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+
147+function 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+
152+function 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+
176+function 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+
188+function 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+
205+function 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+
214+function 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+
225+function 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+
239+function 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+
256+function 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+
267+function 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+
288+function 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+
297+function randInt(rng: Rng, min: number, max: number): number {
298+ return Math.floor(rng.next() * (max - min + 1)) + min;
299+}
300+
301+function pick<T>(rng: Rng, items: readonly T[]): T {
302+ return items[Math.floor(rng.next() * items.length)]!;
303+}
304+
305+function clamp(value: number, min: number, max: number): number {
306+ return Math.min(max, Math.max(min, value));
307+}
308+
309+function formatNumber(value: number): string {
310+ return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)));
311+}
312+
313+type Rational = {
314+ num: number;
315+ den: number;
316+};
317+
318+function 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+
346+function 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+
351+function 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+
366+function gcd(a: number, b: number): number {
367+ while (b !== 0) {
368+ [a, b] = [b, a % b];
369+ }
370+ return a || 1;
371+}
372+
373+function abs(value: number): number {
374+ return value < 0 ? -value : value;
375+}
376+
377+export 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 @@
1+export * from "./generator";
2+export * from "./types";
src/lib/arithmetic/types.ts+64−0
@@ -0,0 +1,64 @@
1+export 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+
14+export type ArithmeticSubmode = "sqrt" | "square" | "cube";
15+export type ArithmeticStar = 1 | 2 | 3 | 4 | 5;
16+export type ArithmeticRunMode = "untimed" | "30s" | "60s" | "120s" | "300s";
17+export type BigMagnitude = "thousands" | "millions" | "billions";
18+export type AnswerFormat = "integer" | "decimal" | "fraction" | "binary";
19+
20+export 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+
31+export 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+
41+export 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+
54+export type ArithmeticAttemptResult = {
55+ problem: ArithmeticProblem;
56+ userAnswer: string;
57+ correct: boolean;
58+ elapsedMs: number;
59+ scoreDelta: number;
60+};
61+
62+export type Rng = {
63+ next(): number;
64+};
src/lib/auth/session.ts+7−4
@@ -1,12 +1,15 @@
11 import "server-only";
2−import { withAuth } from "@workos-inc/authkit-nextjs";
2+import { auth } from "@clerk/nextjs/server";
33
44 export async function getSession() {
5− const session = await withAuth();
6− return session;
5+ return auth();
76 }
87
98 export 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+
1114 return session;
1215 }
src/lib/auth/user.ts+76−0
@@ -0,0 +1,76 @@
1+import "server-only";
2+import { eq } from "drizzle-orm";
3+import { currentUser } from "@clerk/nextjs/server";
4+import { db } from "@/lib/db";
5+import { users } from "@/lib/db/schema";
6+
7+type ClerkUser = NonNullable<Awaited<ReturnType<typeof currentUser>>>;
8+
9+export async function getCurrentLocalUser() {
10+ const clerkUser = await currentUser();
11+ if (!clerkUser) {
12+ return null;
13+ }
14+
15+ return ensureLocalUser(clerkUser);
16+}
17+
18+export 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+
60+function primaryEmail(clerkUser: ClerkUser): string {
61+ return (
62+ clerkUser.primaryEmailAddress?.emailAddress ||
63+ clerkUser.emailAddresses[0]?.emailAddress ||
64+ `${clerkUser.id}@users.sumtex.local`
65+ );
66+}
67+
68+function 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 @@
1+CREATE 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+
19+CREATE 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+
36+CREATE INDEX IF NOT EXISTS "arithmetic_sessions_user_idx" ON "arithmetic_sessions" ("user_id");
37+CREATE INDEX IF NOT EXISTS "arithmetic_sessions_category_idx" ON "arithmetic_sessions" ("category");
38+CREATE INDEX IF NOT EXISTS "arithmetic_sessions_started_idx" ON "arithmetic_sessions" ("started_at");
39+CREATE INDEX IF NOT EXISTS "arithmetic_attempts_session_idx" ON "arithmetic_attempts" ("session_id");
40+CREATE INDEX IF NOT EXISTS "arithmetic_attempts_category_idx" ON "arithmetic_attempts" ("category");
41+CREATE 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 @@
1+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "clerk_id" text;
2+
3+UPDATE "users"
4+SET "clerk_id" = "workos_id"
5+WHERE "clerk_id" IS NULL
6+ AND "workos_id" IS NOT NULL;
7+
8+ALTER TABLE "users" ALTER COLUMN "clerk_id" SET NOT NULL;
9+CREATE UNIQUE INDEX IF NOT EXISTS "users_clerk_id_idx" ON "users" ("clerk_id");
10+DROP INDEX IF EXISTS "users_workos_id_idx";
11+ALTER TABLE "users" DROP COLUMN IF EXISTS "workos_id";
src/lib/db/schema.ts+81−2
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 integer,
77 boolean,
88 timestamp,
9+ jsonb,
910 uniqueIndex,
1011 index,
1112 } from "drizzle-orm/pg-core";
@@ -19,7 +20,7 @@ export const users = pgTable( · 7 unmodified lines
1920 "users",
2021 {
2122 id: uuid("id").primaryKey().defaultRandom(),
22− workosId: text("workos_id").notNull(),
23+ clerkId: text("clerk_id").notNull(),
2324 username: varchar("username", { length: 40 }).notNull(),
2425 displayName: varchar("display_name", { length: 100 }),
2526 email: varchar("email", { length: 255 }).notNull(),
@@ -36,7 +37,7 @@ export const users = pgTable( · 10 unmodified lines
3637 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
3738 },
3839 (t) => [
39− uniqueIndex("users_workos_id_idx").on(t.workosId),
40+ uniqueIndex("users_clerk_id_idx").on(t.clerkId),
4041 uniqueIndex("users_username_idx").on(t.username),
4142 uniqueIndex("users_email_idx").on(t.email),
4243 ]
@@ -299,6 +300,65 @@ export const gameSessions = pgTable("game_sessions", { · 256 unmodified lines
299300 endedAt: timestamp("ended_at", { withTimezone: true }),
300301 });
301302
303+// ============================================================
304+// ARITHMETIC
305+// ============================================================
306+
307+export 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+
335+export 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+
302362 // ============================================================
303363 // LEADERBOARD
304364 // ============================================================
@@ -403,6 +463,7 @@ export const userApiKeys = pgTable( · 98 unmodified lines
403463 export const usersRelations = relations(users, ({ many }) => ({
404464 progress: many(userProgress),
405465 sessions: many(gameSessions),
466+ arithmeticSessions: many(arithmeticSessions),
406467 submittedProblems: many(problems),
407468 apiKeys: many(userApiKeys),
408469 threads: many(forumThreads),
@@ -502,6 +563,24 @@ export const gameSessionsRelations = relations(gameSessions, ({ one }) => ({ · 93 unmodified lines
502563 }),
503564 }));
504565
566+export 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+
577+export const arithmeticAttemptsRelations = relations(arithmeticAttempts, ({ one }) => ({
578+ session: one(arithmeticSessions, {
579+ fields: [arithmeticAttempts.sessionId],
580+ references: [arithmeticSessions.id],
581+ }),
582+}));
583+
505584 export const forumThreadsRelations = relations(forumThreads, ({ one, many }) => ({
506585 category: one(forumCategories, {
507586 fields: [forumThreads.categoryId],
src/lib/guides.tsx+220−0
@@ -0,0 +1,220 @@
1+import type { ReactNode } from "react";
2+import { MathDisplay } from "@/components/math/mathJax";
3+
4+export 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+
20+export 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+
218+export function getGuide(slug: string) {
219+ return guides.find((guide) => guide.slug === slug);
220+}
src/proxy.ts+15−0
@@ -0,0 +1,15 @@
1+import { clerkMiddleware } from "@clerk/nextjs/server";
2+import type { NextFetchEvent, NextRequest } from "next/server";
3+
4+const clerkProxy = clerkMiddleware();
5+
6+export function proxy(request: NextRequest, event: NextFetchEvent) {
7+ return clerkProxy(request, event);
8+}
9+
10+export 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
99 collections,
1010 userProgress,
1111 gameSessions,
12+ arithmeticSessions,
13+ arithmeticAttempts,
1214 leaderboardEntries,
1315 forumCategories,
1416 forumThreads,
@@ -25,6 +27,8 @@ export type Roadmap = InferSelectModel<typeof roadmaps>; · 10 unmodified lines
2527 export type Collection = InferSelectModel<typeof collections>;
2628 export type UserProgress = InferSelectModel<typeof userProgress>;
2729 export type GameSession = InferSelectModel<typeof gameSessions>;
30+export type ArithmeticSession = InferSelectModel<typeof arithmeticSessions>;
31+export type ArithmeticAttempt = InferSelectModel<typeof arithmeticAttempts>;
2832 export type LeaderboardEntry = InferSelectModel<typeof leaderboardEntries>;
2933 export type ForumCategory = InferSelectModel<typeof forumCategories>;
3034 export type ForumThread = InferSelectModel<typeof forumThreads>;