Compare commits
160 Commits
abacus-rea
...
v4.58.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c001e07b7 | ||
|
|
8893675b36 | ||
|
|
4254459238 | ||
|
|
89b90723b7 | ||
|
|
6e5aec858f | ||
|
|
5611d148aa | ||
|
|
d5f60ce9d2 | ||
|
|
284fc90a53 | ||
|
|
e54ea20dbe | ||
|
|
415a1fb1fa | ||
|
|
60b3a788b3 | ||
|
|
62ff067bb9 | ||
|
|
3d774c8d82 | ||
|
|
61403f2f50 | ||
|
|
aa297d4ef7 | ||
|
|
712d318e7c | ||
|
|
cd3eb61cb5 | ||
|
|
8871050990 | ||
|
|
a6ac55b7b1 | ||
|
|
64e2464ec1 | ||
|
|
422bf3d968 | ||
|
|
bc1ad3a43a | ||
|
|
3b5d14765d | ||
|
|
9847f8f461 | ||
|
|
57c212f4f5 | ||
|
|
b2f5c19ce3 | ||
|
|
e65e96952f | ||
|
|
556a0eb194 | ||
|
|
5070d8d64f | ||
|
|
54cedbe03a | ||
|
|
fa26acfbae | ||
|
|
9ec0a71546 | ||
|
|
6448249512 | ||
|
|
a537bc18c3 | ||
|
|
33ab7aaaf0 | ||
|
|
f47b172f66 | ||
|
|
9d25e1dd35 | ||
|
|
015e30b085 | ||
|
|
8a2d5ae319 | ||
|
|
d2ff2c6a29 | ||
|
|
3cf4f92643 | ||
|
|
074488349a | ||
|
|
30a5587bca | ||
|
|
4082a246a3 | ||
|
|
9703fed94c | ||
|
|
5dc636a71c | ||
|
|
16d978db9a | ||
|
|
e711c52757 | ||
|
|
009162e22c | ||
|
|
cd30944c5e | ||
|
|
3e58cb5f92 | ||
|
|
aba9f8a94d | ||
|
|
dc19622bbb | ||
|
|
1babfde328 | ||
|
|
76d6f19d51 | ||
|
|
9ad35e65d3 | ||
|
|
d362a770d6 | ||
|
|
095cdda4ca | ||
|
|
a1a135a858 | ||
|
|
7f516526fb | ||
|
|
9f706e9dce | ||
|
|
6410b21f82 | ||
|
|
3dc9f48d12 | ||
|
|
b6410c7c22 | ||
|
|
b54aaf1a67 | ||
|
|
c6dc210bf8 | ||
|
|
c89aea7444 | ||
|
|
3564bd51dc | ||
|
|
cc315645de | ||
|
|
035d8312c7 | ||
|
|
5f9b2dfe2b | ||
|
|
1bfde8fb25 | ||
|
|
48647e4fb5 | ||
|
|
318f9469a0 | ||
|
|
4bace36561 | ||
|
|
8c2ddca28d | ||
|
|
eff44b3ad1 | ||
|
|
f81b88ae30 | ||
|
|
71b1b933b5 | ||
|
|
92d50673e5 | ||
|
|
c229faffac | ||
|
|
463841e191 | ||
|
|
3a3706cc6f | ||
|
|
721dfe426d | ||
|
|
9dba75c9d9 | ||
|
|
f9a7cb7f05 | ||
|
|
ae7463d917 | ||
|
|
02b6c70b7a | ||
|
|
4b72e0c561 | ||
|
|
e0b6a2e88b | ||
|
|
230f1dcd86 | ||
|
|
d8ec64280e | ||
|
|
1d4419364a | ||
|
|
cce8980e17 | ||
|
|
4bcce2a8db | ||
|
|
2818fd15ca | ||
|
|
29d20a6c07 | ||
|
|
be2c3f63b0 | ||
|
|
aa0bdcf686 | ||
|
|
baea602000 | ||
|
|
05dd0b30d3 | ||
|
|
4febf5905b | ||
|
|
6739d59f2b | ||
|
|
cb20019c16 | ||
|
|
d90b5d5532 | ||
|
|
7028db0263 | ||
|
|
fa3b73c691 | ||
|
|
fd4d25c2d1 | ||
|
|
6501b073b1 | ||
|
|
9b4d9c21df | ||
|
|
53d23f19bc | ||
|
|
6c14012b97 | ||
|
|
c650ffa193 | ||
|
|
28834e8a3e | ||
|
|
8681b17340 | ||
|
|
d52cc608eb | ||
|
|
6f89d9e274 | ||
|
|
a8cc2bc0f0 | ||
|
|
9dff3e7b7b | ||
|
|
92fedb698d | ||
|
|
1e90d6c620 | ||
|
|
5fcb7925eb | ||
|
|
41eaed24fc | ||
|
|
de5f36481b | ||
|
|
e5ffe3927e | ||
|
|
7c47fcdc54 | ||
|
|
4f4c73577a | ||
|
|
1e6459f9c1 | ||
|
|
477a0b367e | ||
|
|
8751649233 | ||
|
|
07c783a794 | ||
|
|
ca8cef1c36 | ||
|
|
0d47664f9f | ||
|
|
1e5467fad4 | ||
|
|
44f8b27fa1 | ||
|
|
0146ce1e67 | ||
|
|
acfb0dac0a | ||
|
|
0fbde53039 | ||
|
|
90bbe6fbb7 | ||
|
|
a03e73c849 | ||
|
|
f3dce84532 | ||
|
|
3b6284ae18 | ||
|
|
563136fb79 | ||
|
|
ead9ee9589 | ||
|
|
a12ae969be | ||
|
|
abb647ce40 | ||
|
|
d6c28f7ede | ||
|
|
cd5c15aeb2 | ||
|
|
ccaad3abc8 | ||
|
|
22f00f59f5 | ||
|
|
a85815fdf9 | ||
|
|
09004dc2c0 | ||
|
|
22c8a57a16 | ||
|
|
2d8bb4ab88 | ||
|
|
8e345cfb4c | ||
|
|
200b26c2cd | ||
|
|
66e38af457 | ||
|
|
c80477d248 | ||
|
|
9a688c1574 | ||
|
|
fd2b6338a8 |
555
CHANGELOG.md
555
CHANGELOG.md
@@ -1,3 +1,558 @@
|
||||
## [4.58.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.58.0...v4.58.1) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **navbar:** apply glassmorphism to transparent mode, not scrolled mode ([8893675](https://github.com/antialias/soroban-abacus-flashcards/commit/8893675b36b1c1534c6fe7e57fa7e0cc55f198d6))
|
||||
|
||||
## [4.58.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.10...v4.58.0) (2025-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **navbar:** add glassmorphism effect to nav links when scrolled ([89b9072](https://github.com/antialias/soroban-abacus-flashcards/commit/89b90723b7a3fc9ed12da3ba8718fccb6ce0760f))
|
||||
|
||||
## [4.57.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.9...v4.57.10) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **navbar:** remove border artifact and add 10px bottom fade ([d5f60ce](https://github.com/antialias/soroban-abacus-flashcards/commit/d5f60ce9d2fbc2a870b3bb96f5365a0e04e0afc4))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **navbar:** improve theming to match homepage dark aesthetic ([284fc90](https://github.com/antialias/soroban-abacus-flashcards/commit/284fc90a53f5f4868a3e41421760ebc813be12b5))
|
||||
|
||||
## [4.57.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.8...v4.57.9) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** add overflow hidden to Your Journey section ([415a1fb](https://github.com/antialias/soroban-abacus-flashcards/commit/415a1fb1faa263c9d69b4e781ce22da235ca2b66))
|
||||
|
||||
## [4.57.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.7...v4.57.8) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** adjust responsive breakpoints to prevent skill card clipping ([62ff067](https://github.com/antialias/soroban-abacus-flashcards/commit/62ff067bb956b17a9b3569eadc2a32abd24c27b8))
|
||||
|
||||
## [4.57.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.6...v4.57.7) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** restructure layout to center 1400px wide demo section ([61403f2](https://github.com/antialias/soroban-abacus-flashcards/commit/61403f2f506557b57716a298d4dc481d7853552f))
|
||||
* **homepage:** set min-width 1400px on container and remove max-width ([aa297d4](https://github.com/antialias/soroban-abacus-flashcards/commit/aa297d4ef7559473a147934766bfa3868552f58d))
|
||||
|
||||
## [4.57.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.5...v4.57.6) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** increase skill cards container width to prevent title wrapping ([cd3eb61](https://github.com/antialias/soroban-abacus-flashcards/commit/cd3eb61cb59e6faef37fbf609f37f7e2dc302e72))
|
||||
|
||||
## [4.57.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.4...v4.57.5) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** prevent text overflow in skill cards ([a6ac55b](https://github.com/antialias/soroban-abacus-flashcards/commit/a6ac55b7b161e0dd33a4dd5acc0df647b2a513aa))
|
||||
|
||||
## [4.57.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.3...v4.57.4) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** align container width breakpoint with grid columns ([422bf3d](https://github.com/antialias/soroban-abacus-flashcards/commit/422bf3d968b67e4683ac7ea7e487a84513f992f9))
|
||||
* **homepage:** make MiniAbacus fill container properly ([3b5d147](https://github.com/antialias/soroban-abacus-flashcards/commit/3b5d14765dfb2d61a76f66ba3ae09695ce88bb6d))
|
||||
* **homepage:** widen skill cards container to 650px ([bc1ad3a](https://github.com/antialias/soroban-abacus-flashcards/commit/bc1ad3a43a79570e1f9c61d5118d14ac4c201d71))
|
||||
|
||||
## [4.57.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.2...v4.57.3) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** increase abacus container width to 120px/150px ([57c212f](https://github.com/antialias/soroban-abacus-flashcards/commit/57c212f4f5be591f712e1c5610e1f323e56e15dd))
|
||||
|
||||
## [4.57.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.1...v4.57.2) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** increase skill card abacus container width ([e65e969](https://github.com/antialias/soroban-abacus-flashcards/commit/e65e96952f4e631722c73fc56d088fa3ff1ba858))
|
||||
|
||||
## [4.57.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.0...v4.57.1) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** add overflow hidden to skill cards ([5070d8d](https://github.com/antialias/soroban-abacus-flashcards/commit/5070d8d64f7f58887ff7259bee9ce5166c4f8af8))
|
||||
|
||||
## [4.57.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.56.0...v4.57.0) (2025-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** make skills section responsive with emojis ([9ec0a71](https://github.com/antialias/soroban-abacus-flashcards/commit/9ec0a71546ee483233ed7866dae97345bf2384d7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** prevent skill card overflow ([fa26acf](https://github.com/antialias/soroban-abacus-flashcards/commit/fa26acfbaef1a04bb225956b2f684cd5023b56fa))
|
||||
|
||||
## [4.56.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.55.0...v4.56.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** emphasize single-player and observer modes ([a537bc1](https://github.com/antialias/soroban-abacus-flashcards/commit/a537bc18c34d94ca931e483ea01e497d6f5d4e5b))
|
||||
|
||||
## [4.55.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.54.0...v4.55.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** update section title to "The Arcade" ([f47b172](https://github.com/antialias/soroban-abacus-flashcards/commit/f47b172f66bee0017c11d8f129f5b83f2ef3dcd9))
|
||||
|
||||
## [4.54.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.53.0...v4.54.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive levels slider to replace static progression ([8a2d5ae](https://github.com/antialias/soroban-abacus-flashcards/commit/8a2d5ae319af8fd66010dd5538e4b82f7fb35d40))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** extract levels slider into shared component ([015e30b](https://github.com/antialias/soroban-abacus-flashcards/commit/015e30b085ad2ef798ffd6f7f6716269e3256651))
|
||||
|
||||
## [4.53.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.2...v4.53.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive draggable flashcards with physics ([0744883](https://github.com/antialias/soroban-abacus-flashcards/commit/074488349a3ec480548223c313006aa1e9e64e5c))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** merge flashcard display with create button section ([3cf4f92](https://github.com/antialias/soroban-abacus-flashcards/commit/3cf4f92643306f055188ede508557515ef5efe98))
|
||||
|
||||
## [4.52.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.1...v4.52.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** use actual container dimensions for flashcard positioning ([4082a24](https://github.com/antialias/soroban-abacus-flashcards/commit/4082a246a33ea67617b762d5b7490a8c9af0ad49))
|
||||
|
||||
## [4.52.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.0...v4.52.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** correct flashcard transform rendering ([5dc636a](https://github.com/antialias/soroban-abacus-flashcards/commit/5dc636a71c15db28c029fd4f60e4a6c95620f953))
|
||||
|
||||
## [4.52.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.51.0...v4.52.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive draggable flashcards with physics ([e711c52](https://github.com/antialias/soroban-abacus-flashcards/commit/e711c527574412de2f9d451c7985c4f8667d269a))
|
||||
|
||||
## [4.51.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.50.1...v4.51.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** create fancy flashcard display with spread-out cards ([cd30944](https://github.com/antialias/soroban-abacus-flashcards/commit/cd30944c5e067f84d00dfdf41c37580acc589548))
|
||||
|
||||
## [4.50.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.50.0...v4.50.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** set fixed width for learning panel to prevent layout shift ([dc19622](https://github.com/antialias/soroban-abacus-flashcards/commit/dc19622bbba2fead8cd9c0b2bda3a38abba0bd41))
|
||||
* **homepage:** set fixed width for tutorial panel to prevent layout shift ([aba9f8a](https://github.com/antialias/soroban-abacus-flashcards/commit/aba9f8a94d50590cf94b6cd87f85b497e89045e7))
|
||||
|
||||
## [4.50.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.49.1...v4.50.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive learning panels with animated mini-tutorials ([76d6f19](https://github.com/antialias/soroban-abacus-flashcards/commit/76d6f19d51fe4b9594998ae4e0a8823aff389854))
|
||||
|
||||
## [4.49.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.49.0...v4.49.1) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** streamline homepage sections ([d362a77](https://github.com/antialias/soroban-abacus-flashcards/commit/d362a770d63405efee5ef8a896d34e783dd11de2))
|
||||
|
||||
## [4.49.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.48.5...v4.49.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add vibrant gradients and equal heights to game cards ([a1a135a](https://github.com/antialias/soroban-abacus-flashcards/commit/a1a135a8586e314c9d695bec6c4e58ec24e5c9cb)), closes [#4](https://github.com/antialias/soroban-abacus-flashcards/issues/4) [#00f2](https://github.com/antialias/soroban-abacus-flashcards/issues/00f2) [#667](https://github.com/antialias/soroban-abacus-flashcards/issues/667) [#764ba2](https://github.com/antialias/soroban-abacus-flashcards/issues/764ba2) [#f093](https://github.com/antialias/soroban-abacus-flashcards/issues/f093) [#f5576](https://github.com/antialias/soroban-abacus-flashcards/issues/f5576) [#43e97](https://github.com/antialias/soroban-abacus-flashcards/issues/43e97) [#38f9d7](https://github.com/antialias/soroban-abacus-flashcards/issues/38f9d7)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* make homepage game cards dynamic from game registry ([7f51652](https://github.com/antialias/soroban-abacus-flashcards/commit/7f516526fb5f5b60c1782db5c8c3e29f05caafa7))
|
||||
|
||||
## [4.48.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.48.4...v4.48.5) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** add dark gradient overlay for better text contrast on game cards ([6410b21](https://github.com/antialias/soroban-abacus-flashcards/commit/6410b21f829810af27e42d188295630bd67d6b6b))
|
||||
|
||||
## [4.48.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.48.3...v4.48.4) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** improve text contrast on game cards with text shadows ([b6410c7](https://github.com/antialias/soroban-abacus-flashcards/commit/b6410c7c225f01f42d095ca270b8da7903cbfbb0))
|
||||
|
||||
## [4.48.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.48.2...v4.48.3) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** display gradient backgrounds on all game cards ([c6dc210](https://github.com/antialias/soroban-abacus-flashcards/commit/c6dc210bf8e3a5b4d7d6e53f2a7427d335c65322))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **z-index:** add comprehensive z-index and stacking context documentation ([c89aea7](https://github.com/antialias/soroban-abacus-flashcards/commit/c89aea744478696b6f812fe53311a2dba210540f))
|
||||
|
||||
## [4.48.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.48.1...v4.48.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **nav:** ensure nav bar appears above tutorial tooltips ([cc31564](https://github.com/antialias/soroban-abacus-flashcards/commit/cc315645de30218d1b034da3e130458fe2961a69))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **hero:** unify background with rest of homepage ([035d831](https://github.com/antialias/soroban-abacus-flashcards/commit/035d8312c707cbf5b0e2a725d7b1d8ff406f842d))
|
||||
|
||||
## [4.48.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.48.0...v4.48.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **hero:** prevent SSR hydration mismatch for subtitle ([1bfde8f](https://github.com/antialias/soroban-abacus-flashcards/commit/1bfde8fb251b227ccd2528bfe1c47acffd79fa49))
|
||||
|
||||
## [4.48.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.47.2...v4.48.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **hero:** persist random subtitle per-session ([318f946](https://github.com/antialias/soroban-abacus-flashcards/commit/318f9469a0805c200c55ce4024a95fd7b8dbe6a2))
|
||||
|
||||
## [4.47.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.47.1...v4.47.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **nav:** prevent thrashing by using fixed position always ([eff44b3](https://github.com/antialias/soroban-abacus-flashcards/commit/eff44b3ad1ea0535c6965ad58012f9275cb143ec))
|
||||
* **nav:** remove unnecessary borders from transparent nav ([8c2ddca](https://github.com/antialias/soroban-abacus-flashcards/commit/8c2ddca28dbdd7743227eed4d19a9a8f662a72b5))
|
||||
|
||||
## [4.47.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.47.0...v4.47.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **hero:** prevent nav thrashing with hysteresis ([71b1b93](https://github.com/antialias/soroban-abacus-flashcards/commit/71b1b933b598c0a6a8aef1bc9f8c598c1871b2eb))
|
||||
|
||||
## [4.47.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.46.2...v4.47.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **nav:** add transparent nav bar with borders when hero visible ([463841e](https://github.com/antialias/soroban-abacus-flashcards/commit/463841e1910f4ddb9af662f036e4efb867836a83))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **hero:** use Number.isNaN instead of global isNaN ([c229faf](https://github.com/antialias/soroban-abacus-flashcards/commit/c229faffac525f3eebeb12155cb5ca4dff744472))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **hero-abacus:** add purple bead colors for dark theme ([721dfe4](https://github.com/antialias/soroban-abacus-flashcards/commit/721dfe426db4fe259f6cdeac587d008339df769b))
|
||||
* **hero:** adjust spacing between title, subtitle, and abacus ([3a3706c](https://github.com/antialias/soroban-abacus-flashcards/commit/3a3706cc6fb694c7762f065f4ab4996bb8608dc4))
|
||||
|
||||
## [4.46.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.46.1...v4.46.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **types:** properly type HomeHeroContext in AppNavBar ([f9a7cb7](https://github.com/antialias/soroban-abacus-flashcards/commit/f9a7cb7f05dfddf291d89212a77ba1c11c00c9c7))
|
||||
|
||||
## [4.46.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.46.0...v4.46.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **hero-abacus:** restructure layout to prevent visual overlap ([02b6c70](https://github.com/antialias/soroban-abacus-flashcards/commit/02b6c70b7a52f7de2954e5e0efddbed64d419d6c))
|
||||
|
||||
## [4.46.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.45.0...v4.46.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add full-page hero abacus with scroll-based nav transition ([d8ec642](https://github.com/antialias/soroban-abacus-flashcards/commit/d8ec64280ec0c2f44f2fd9c72a93a882481f650b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** improve hero abacus sizing and layout ([230f1dc](https://github.com/antialias/soroban-abacus-flashcards/commit/230f1dcd866e5b3625e19f7400f5eae478fe7d0c))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **hero-abacus:** apply dark theme to match homepage styling ([e0b6a2e](https://github.com/antialias/soroban-abacus-flashcards/commit/e0b6a2e88b3ebbaae41ed54f23f9e514604d2262))
|
||||
|
||||
## [4.45.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.3...v4.45.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **branding:** rebrand navigation from 'Soroban Generator' to 'Abaci One' ([cce8980](https://github.com/antialias/soroban-abacus-flashcards/commit/cce8980e177da1b3c344e46561d928ed98b86f6c))
|
||||
|
||||
## [4.44.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.2...v4.44.3) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** reduce operator box sizes and remove divider line ([29d20a6](https://github.com/antialias/soroban-abacus-flashcards/commit/29d20a6c0741e7427f2bb64bc9c3e950b1a3238a))
|
||||
* **levels:** use uniform padding on operator box grid ([2818fd1](https://github.com/antialias/soroban-abacus-flashcards/commit/2818fd15cacac78de6d86ba769b9b2a02800ed1e))
|
||||
|
||||
## [4.44.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.1...v4.44.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** match top/bottom margins to left padding on kyu detail boxes ([aa0bdcf](https://github.com/antialias/soroban-abacus-flashcards/commit/aa0bdcf686adcbfd1a145cf67121181d1f1194d9))
|
||||
|
||||
## [4.44.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.0...v4.44.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add fixed dimensions and margins to kyu detail boxes ([05dd0b3](https://github.com/antialias/soroban-abacus-flashcards/commit/05dd0b30d3c397b82b7b7cc93a5ea575f3aada6d))
|
||||
|
||||
## [4.44.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.2...v4.44.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** redesign kyu details with larger operators and prominent digits ([6739d59](https://github.com/antialias/soroban-abacus-flashcards/commit/6739d59f2b6189a98570e23e04c20d86d774ccce))
|
||||
|
||||
## [4.43.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.1...v4.43.2) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** remove Time and Pass sections from kyu details ([d90b5d5](https://github.com/antialias/soroban-abacus-flashcards/commit/d90b5d55322e75dd28b95376614663a506c829d4))
|
||||
|
||||
## [4.43.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.0...v4.43.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** use two-column grid for kyu details to prevent clipping ([fa3b73c](https://github.com/antialias/soroban-abacus-flashcards/commit/fa3b73c69169b4694201ffa19ae3f8b5a68dfe32))
|
||||
|
||||
## [4.43.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.42.1...v4.43.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add structured kyu exam details with card UI ([6501b07](https://github.com/antialias/soroban-abacus-flashcards/commit/6501b073b100a00982cff1ca3140921e74f31a9c))
|
||||
|
||||
## [4.42.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.42.0...v4.42.1) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** store kyu data verbatim, add formatting layer ([53d23f1](https://github.com/antialias/soroban-abacus-flashcards/commit/53d23f19bc06459462afb76ed94d9b99d583a32d))
|
||||
|
||||
## [4.42.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.41.0...v4.42.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add kyu level details display with English translations ([c650ffa](https://github.com/antialias/soroban-abacus-flashcards/commit/c650ffa1935fe370d37190b2843c0deecdcce8e7))
|
||||
|
||||
## [4.41.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.40.1...v4.41.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** right-align abacus display ([8681b17](https://github.com/antialias/soroban-abacus-flashcards/commit/8681b17340e757cf04d17f884a780a251645bb33))
|
||||
|
||||
## [4.40.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.40.0...v4.40.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** increase animation speed to 10ms for 10th Dan ([6f89d9e](https://github.com/antialias/soroban-abacus-flashcards/commit/6f89d9e274082908fc090a9c0ba310f2cb06f014))
|
||||
|
||||
## [4.40.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.39.1...v4.40.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** progressive animation speed for Dan levels ([9dff3e7](https://github.com/antialias/soroban-abacus-flashcards/commit/9dff3e7b7b1ca46ea7f19a48135124b80c5182c0))
|
||||
|
||||
## [4.39.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.39.0...v4.39.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** improve slider tick spacing to use full width ([1e90d6c](https://github.com/antialias/soroban-abacus-flashcards/commit/1e90d6c6207f29084a8dc96ccfbb1013a1a62271))
|
||||
|
||||
## [4.39.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.38.1...v4.39.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add auto-advance slider with hover pause ([41eaed2](https://github.com/antialias/soroban-abacus-flashcards/commit/41eaed24fce510bab7fd03fa2e39e829b33a7346))
|
||||
|
||||
## [4.38.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.38.0...v4.38.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** adjust slider text positioning to prevent emoji overlap ([e5ffe39](https://github.com/antialias/soroban-abacus-flashcards/commit/e5ffe3927edfb1baea7ddd216507e081f50e5d2c))
|
||||
|
||||
## [4.38.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.37.0...v4.38.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add animated calculation effect to abacus display ([4f4c735](https://github.com/antialias/soroban-abacus-flashcards/commit/4f4c73577a944518c093b3208a85482909fe3064))
|
||||
|
||||
## [4.37.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.36.0...v4.37.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add hover tracking to slider for real-time level preview ([477a0b3](https://github.com/antialias/soroban-abacus-flashcards/commit/477a0b367e32749b865b5a5405846e86d5bcef6a))
|
||||
|
||||
## [4.36.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.35.1...v4.36.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** make emoji tick marks clickable and remove redundant UI ([07c783a](https://github.com/antialias/soroban-abacus-flashcards/commit/07c783a79454f50e7302b19684be6d2e5930154d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add smooth CSS transitions for slider thumb movement ([ca8cef1](https://github.com/antialias/soroban-abacus-flashcards/commit/ca8cef1c36efeb1c8c214c74f8bd383f9295be3b))
|
||||
|
||||
## [4.35.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.35.0...v4.35.1) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** export StandaloneBead component wired to AbacusDisplayContext ([0146ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/0146ce1e67da27a24cbaa8338ba6a1a6befd6bd3))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **levels:** speed up slider animations for more responsive feel ([1e5467f](https://github.com/antialias/soroban-abacus-flashcards/commit/1e5467fad4e27b832300c49b4f73547dc47598b0))
|
||||
|
||||
## [4.35.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.34.0...v4.35.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](https://github.com/antialias/soroban-abacus-flashcards/commit/0fbde53039d3ea000c6a3be492b733479e7bf47c))
|
||||
|
||||
## [4.34.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.8...v4.34.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** redesign slider with abacus-themed beads ([f3dce84](https://github.com/antialias/soroban-abacus-flashcards/commit/f3dce84532fa706e4ec9551facde2055a060ee13))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** convert to Radix UI Slider with abacus theme ([a03e73c](https://github.com/antialias/soroban-abacus-flashcards/commit/a03e73c849c5da4337f26a74b8f12b617c66068e))
|
||||
|
||||
## [4.33.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.7...v4.33.8) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](https://github.com/antialias/soroban-abacus-flashcards/commit/563136fb79fa10b2af3a119bf0f861e3b0812b2e))
|
||||
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](https://github.com/antialias/soroban-abacus-flashcards/commit/ead9ee9589aa4d7376e9385da5da53a6b444858a))
|
||||
|
||||
## [4.33.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.6...v4.33.7) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** reduce scale factor variation to minimize margin differences ([abb647c](https://github.com/antialias/soroban-abacus-flashcards/commit/abb647ce40b8f9d0c8268ab18c139324ae3195c5))
|
||||
|
||||
## [4.33.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.5...v4.33.6) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** increase container height to prevent abacus clipping ([cd5c15a](https://github.com/antialias/soroban-abacus-flashcards/commit/cd5c15aeb260c568fe7ad9b6a4f51c4d6498b2b8))
|
||||
|
||||
## [4.33.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.4...v4.33.5) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** revert delayed column change, keep overflow hidden ([22f00f5](https://github.com/antialias/soroban-abacus-flashcards/commit/22f00f59f5facc36a846408dcd196ec54ea676b1))
|
||||
|
||||
## [4.33.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.3...v4.33.4) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** stabilize slider position and prevent abacus clipping ([09004dc](https://github.com/antialias/soroban-abacus-flashcards/commit/09004dc2c055031ee2f71c964ceee6f7b1d42ecd))
|
||||
|
||||
## [4.33.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.2...v4.33.3) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** move slider into level display pane above abacus ([2d8bb4a](https://github.com/antialias/soroban-abacus-flashcards/commit/2d8bb4ab8804f399d1ccc8a18feff9f09eca8029))
|
||||
|
||||
## [4.33.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.1...v4.33.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add fixed height to entire level display pane ([200b26c](https://github.com/antialias/soroban-abacus-flashcards/commit/200b26c2cd35d1d637ede9dcfc3dbbc7f3f19320))
|
||||
|
||||
## [4.33.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.0...v4.33.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** only animate abacus, not container with background/border ([c80477d](https://github.com/antialias/soroban-abacus-flashcards/commit/c80477d24877ddada5f3f4405abbf05e1d753b5d))
|
||||
|
||||
## [4.33.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.32.1...v4.33.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add BigInt support for 30-digit Dan level abacuses ([0ab4cc2](https://github.com/antialias/soroban-abacus-flashcards/commit/0ab4cc288066b75a6ea4371f65098db5c0fc8847))
|
||||
* **levels:** add hover interaction and smooth React Spring transitions ([fd2b633](https://github.com/antialias/soroban-abacus-flashcards/commit/fd2b6338a84c3bbc683eff216a8da3b155749f0f))
|
||||
|
||||
## [4.32.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.32.0...v4.32.1) (2025-10-20)
|
||||
|
||||
|
||||
|
||||
@@ -223,3 +223,48 @@ Three places must handle settings correctly:
|
||||
3. **Validator** (`{Game}Validator.ts`) - `getInitialState()` must accept ALL settings
|
||||
|
||||
If a setting doesn't persist, check all three locations.
|
||||
|
||||
## Z-Index and Stacking Context Management
|
||||
|
||||
When working with z-index values or encountering layering issues, refer to:
|
||||
|
||||
- **`.claude/Z_INDEX_MANAGEMENT.md`** - Complete z-index documentation
|
||||
- Z-index layering hierarchy (0-20000+)
|
||||
- Stacking context rules and gotchas
|
||||
- Current z-index audit of all components
|
||||
- Guidelines for choosing z-index values
|
||||
- Migration plan to use constants file
|
||||
- Debugging checklist for layering issues
|
||||
|
||||
**Quick Reference:**
|
||||
|
||||
**ALWAYS use the constants file:**
|
||||
```typescript
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
|
||||
// ✅ Good
|
||||
zIndex: Z_INDEX.NAV_BAR
|
||||
zIndex: Z_INDEX.MODAL
|
||||
zIndex: Z_INDEX.TOOLTIP
|
||||
|
||||
// ❌ Bad - magic numbers!
|
||||
zIndex: 100
|
||||
zIndex: 10000
|
||||
zIndex: 500
|
||||
```
|
||||
|
||||
**Layering hierarchy:**
|
||||
- Base content: 0-99
|
||||
- Navigation/UI chrome: 100-999
|
||||
- Overlays/dropdowns/tooltips: 1000-9999
|
||||
- Modals/dialogs: 10000-19999
|
||||
- Toasts: 20000+
|
||||
|
||||
**Critical reminder about stacking contexts:**
|
||||
|
||||
Z-index values are only compared within the same stacking context! Elements with `position + zIndex`, `opacity < 1`, `transform`, or `filter` create new stacking contexts where child z-indexes are relative, not global.
|
||||
|
||||
Before setting a z-index, always check:
|
||||
1. What stacking context is this element in?
|
||||
2. Am I comparing against siblings or global elements?
|
||||
3. Does my parent create a stacking context?
|
||||
|
||||
392
apps/web/.claude/Z_INDEX_MANAGEMENT.md
Normal file
392
apps/web/.claude/Z_INDEX_MANAGEMENT.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Z-Index & Stacking Context Management
|
||||
|
||||
## Overview
|
||||
|
||||
This document tracks z-index values and stacking contexts across the application to prevent layering conflicts and make reasoning about visual hierarchy easy.
|
||||
|
||||
## The Z-Index Constants System
|
||||
|
||||
**Location:** `src/constants/zIndex.ts`
|
||||
|
||||
All z-index values should be defined in this file and imported where needed:
|
||||
|
||||
```typescript
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
|
||||
// Use it like this:
|
||||
zIndex: Z_INDEX.NAV_BAR
|
||||
zIndex: Z_INDEX.MODAL
|
||||
zIndex: Z_INDEX.GAME_NAV.HAMBURGER_MENU
|
||||
```
|
||||
|
||||
## Z-Index Layering Hierarchy
|
||||
|
||||
From lowest to highest:
|
||||
|
||||
| Layer | Range | Purpose | Examples |
|
||||
|-------|-------|---------|----------|
|
||||
| **Base Content** | 0-99 | Default page content, game elements | Background elements, game tracks, cards |
|
||||
| **Navigation & UI Chrome** | 100-999 | Fixed navigation, sticky headers | AppNavBar, page headers |
|
||||
| **Overlays & Dropdowns** | 1000-9999 | Tooltips, popovers, dropdowns, tutorial tooltips | Tutorial tooltips (50-100), ConfigForm (50), dropdowns (999-1000) |
|
||||
| **Modals & Dialogs** | 10000-19999 | Modal dialogs, confirmation dialogs | Modal backdrop (10000), Modal content (10001) |
|
||||
| **Top-Level Overlays** | 20000+ | Toasts, critical notifications | Toast notifications (20000) |
|
||||
|
||||
## Stacking Context Rules
|
||||
|
||||
### What Creates a Stacking Context?
|
||||
|
||||
These CSS properties create new stacking contexts (z-index values are relative within them):
|
||||
|
||||
1. `position: fixed` or `position: sticky` with z-index
|
||||
2. `position: absolute` or `position: relative` with z-index
|
||||
3. `opacity` < 1
|
||||
4. `transform` (any value)
|
||||
5. `filter` (any value except none)
|
||||
6. `isolation: isolate`
|
||||
|
||||
### Key Insight
|
||||
|
||||
**Z-index values are only compared within the same stacking context!**
|
||||
|
||||
If Element A creates a stacking context with `z-index: 1` and Element B is outside that context with `z-index: 999`, Element B will be on top regardless of child z-indexes inside Element A.
|
||||
|
||||
### Example
|
||||
|
||||
```tsx
|
||||
// Parent creates stacking context
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
{/* This child's z-index is relative to parent, not global! */}
|
||||
<div style={{ position: 'absolute', zIndex: 999999 }}>
|
||||
I'm still under elements with zIndex: 2 outside my parent!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
I'm on top of the z-index: 999999 element above!
|
||||
</div>
|
||||
```
|
||||
|
||||
## Current Z-Index Audit (2025-10-20)
|
||||
|
||||
### ✅ Using Z_INDEX Constants (Good!)
|
||||
|
||||
| Component | Value | Source |
|
||||
|-----------|-------|--------|
|
||||
| AppNavBar (Panda section) | `Z_INDEX.NAV_BAR` (100) | `src/components/AppNavBar.tsx:464` |
|
||||
| AppNavBar hamburger | `Z_INDEX.GAME_NAV.HAMBURGER_MENU` (9999) | `src/components/AppNavBar.tsx:165` |
|
||||
| AbacusDisplayDropdown | `Z_INDEX.GAME_NAV.HAMBURGER_NESTED_DROPDOWN` (10000) | `src/components/AbacusDisplayDropdown.tsx:99` |
|
||||
|
||||
### ⚠️ Hardcoded Z-Index Values (Need Migration)
|
||||
|
||||
#### Critical Navigation Issues
|
||||
|
||||
| Component | Line | Value | Issue | Fix |
|
||||
|-----------|------|-------|-------|-----|
|
||||
| **AppNavBar (fixed section)** | 587 | `1000` | ❌ Should use `Z_INDEX.NAV_BAR` (100), but increased to 1000 to fix tutorial tooltip overlap | Define `TUTORIAL_TOOLTIP` in constants, set nav to proper layer |
|
||||
| AppNavBar (badge) | 645 | `50` | Should use constant | Add `Z_INDEX.BADGE` |
|
||||
|
||||
#### Tutorial System
|
||||
|
||||
| Component | Line | Value | Purpose |
|
||||
|-----------|------|-------|---------|
|
||||
| TutorialPlayer | 643 | `50` | Tooltip container |
|
||||
| Tutorial shared/EditorComponents | 569, 590 | `50` | Tooltip button |
|
||||
| Tutorial shared/EditorComponents | 612 | `100` | Dropdown content (must be above tooltip) |
|
||||
| Tutorial decomposition CSS | 73 | `50` | Legacy CSS |
|
||||
| TutorialEditor | 65, 812, 2339 | `1000`, `10` | Various overlays |
|
||||
|
||||
#### Modals & Overlays
|
||||
|
||||
| Component | Line | Value | Purpose |
|
||||
|-----------|------|-------|---------|
|
||||
| Modal (common) | 59 | `10000` | Modal backdrop |
|
||||
| ModerationPanel | 1994, 2009 | `10001`, `10002` | Moderation overlays |
|
||||
| ToastContext | 171 | `10001` | Toast notifications (should be 20000!) |
|
||||
| Join page | 35 | `10000` | Join page overlay |
|
||||
| EmojiPicker | 636 | `10000` | Emoji picker modal |
|
||||
|
||||
#### Dropdowns & Popovers
|
||||
|
||||
| Component | Line | Value | Purpose |
|
||||
|-----------|------|-------|---------|
|
||||
| FormatSelectField | 115 | `999` | Dropdown |
|
||||
| DeploymentInfoModal | 37, 55 | `9998`, `9999` | Info modal layers |
|
||||
| RoomInfo | 338, 562 | `9999`, `10000` | Room tooltips |
|
||||
| GameTitleMenu | 119 | `9999` | Game menu |
|
||||
| PlayerTooltip | 69 | `9999` | Player tooltip |
|
||||
|
||||
#### Game Elements
|
||||
|
||||
| Component | Line | Value | Purpose |
|
||||
|-----------|------|-------|---------|
|
||||
| Complement Race Game | Multiple | `0`, `1` | Base game layers |
|
||||
| Complement Race Track | 118, 140, 151 | `10`, `5`, `20` | Track, AI racers, player |
|
||||
| Complement Race HUD | 51, 106, 119, 137, 168 | `10`, `1000` | HUD elements |
|
||||
| GameCountdown | 58 | `1000` | Countdown overlay |
|
||||
| RouteCelebration | 31 | `9999` | Celebration overlay |
|
||||
| Matching GameCard | 203, 229, 243, 272, 386 | `9`, `10`, `-1`, `8`, `1` | Card layers |
|
||||
| Matching PlayerStatusBar | 154, 181, 202 | `10`, `10`, `5` | Status bars |
|
||||
|
||||
#### Misc UI
|
||||
|
||||
| Component | Line | Value | Purpose |
|
||||
|-----------|------|-------|---------|
|
||||
| HeroAbacus | 89, 127, 163 | `10` | Hero section layers |
|
||||
| ChampionArena | 425, 514, 554, 614 | `10`, `1`, `1`, `10` | Arena layers |
|
||||
| NetworkPlayerIndicator | 118, 145, 169, 192, 275 | `-1`, `2`, `1`, `2`, `10` | Player avatars |
|
||||
| ConfigurationForm | 521, 502 | `50` | Config overlays |
|
||||
|
||||
## The Recent Bug: Tutorial Tooltips Over Nav Bar
|
||||
|
||||
**Problem:** Tutorial tooltips (z-index: 50, 100) were appearing over the navigation bar.
|
||||
|
||||
**Root Cause:**
|
||||
- Nav bar was using `Z_INDEX.NAV_BAR` = 100 in one place
|
||||
- But also hardcoded `zIndex: 30` in the fixed positioning section (line 587)
|
||||
- Tutorial tooltips use hardcoded `zIndex: 50` and `zIndex: 100`
|
||||
- Since 50 and 100 > 30, tooltips appeared on top
|
||||
|
||||
**Temporary Fix:** Increased nav bar's hardcoded value from 30 to 1000
|
||||
|
||||
**Proper Fix Needed:**
|
||||
1. Define tutorial tooltip z-indexes in constants file
|
||||
2. Update nav bar to consistently use `Z_INDEX.NAV_BAR`
|
||||
3. Ensure NAV_BAR > TUTORIAL_TOOLTIP in the hierarchy
|
||||
4. Consider: Should tutorial tooltips be in the 1000-9999 range (overlays) rather than 50-100?
|
||||
|
||||
## Guidelines for Choosing Z-Index Values
|
||||
|
||||
### 1. **Always Import and Use Z_INDEX Constants**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
zIndex: Z_INDEX.NAV_BAR
|
||||
|
||||
// ❌ Bad
|
||||
zIndex: 100 // Magic number!
|
||||
```
|
||||
|
||||
### 2. **Add New Values to Constants File First**
|
||||
|
||||
Before using a new z-index value, add it to `src/constants/zIndex.ts`:
|
||||
|
||||
```typescript
|
||||
export const Z_INDEX = {
|
||||
// ... existing values ...
|
||||
|
||||
TUTORIAL: {
|
||||
TOOLTIP: 500, // Tutorial tooltips (overlays layer)
|
||||
DROPDOWN: 600, // Tutorial dropdown (above tooltip)
|
||||
},
|
||||
} as const
|
||||
```
|
||||
|
||||
### 3. **Choose the Right Layer**
|
||||
|
||||
Ask yourself:
|
||||
- Is this base content? → Use 0-99
|
||||
- Is this navigation/UI chrome? → Use 100-999
|
||||
- Is this a dropdown/tooltip/overlay? → Use 1000-9999
|
||||
- Is this a modal dialog? → Use 10000-19999
|
||||
- Is this a toast notification? → Use 20000+
|
||||
|
||||
### 4. **Understand Your Stacking Context**
|
||||
|
||||
Before setting z-index, ask:
|
||||
- What is my parent's stacking context?
|
||||
- Am I comparing against siblings or global elements?
|
||||
- Does my element create a new stacking context?
|
||||
|
||||
### 5. **Document Special Cases**
|
||||
|
||||
If you must deviate from the constants, document why:
|
||||
|
||||
```typescript
|
||||
// HACK: Needs to be above tutorial tooltips (50) but below modals (10000)
|
||||
// TODO: Migrate to Z_INDEX.TUTORIAL.TOOLTIP system
|
||||
zIndex: 100
|
||||
```
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Update Constants File ✅ TODO
|
||||
|
||||
Add missing constants to `src/constants/zIndex.ts`:
|
||||
|
||||
```typescript
|
||||
export const Z_INDEX = {
|
||||
// Base content layer (0-99)
|
||||
BASE: 0,
|
||||
CONTENT: 1,
|
||||
HERO_SECTION: 10, // Hero abacus components
|
||||
|
||||
// Game content layers (0-99)
|
||||
GAME_CONTENT: {
|
||||
TRACK: 0,
|
||||
CONTROLS: 1,
|
||||
RACER_AI: 5,
|
||||
RACER_PLAYER: 10,
|
||||
RACER_FLAG: 20,
|
||||
HUD: 50,
|
||||
},
|
||||
|
||||
// Navigation and UI chrome (100-999)
|
||||
NAV_BAR: 1000, // ⚠️ Currently needs to be 1000 due to tutorial tooltips
|
||||
STICKY_HEADER: 100,
|
||||
BADGE: 50,
|
||||
|
||||
// Overlays and dropdowns (1000-9999)
|
||||
TUTORIAL: {
|
||||
TOOLTIP: 500, // Tutorial tooltips
|
||||
DROPDOWN: 600, // Tutorial dropdowns (must be > tooltip)
|
||||
EDITOR: 700, // Tutorial editor
|
||||
},
|
||||
DROPDOWN: 1000,
|
||||
TOOLTIP: 1000,
|
||||
POPOVER: 1000,
|
||||
CONFIG_FORM: 1000,
|
||||
PLAYER_TOOLTIP: 1000,
|
||||
GAME_COUNTDOWN: 1000,
|
||||
|
||||
// High overlays (9000-9999)
|
||||
CELEBRATION: 9000,
|
||||
INFO_MODAL: 9998,
|
||||
|
||||
// Modal and dialog layers (10000-19999)
|
||||
MODAL_BACKDROP: 10000,
|
||||
MODAL: 10001,
|
||||
MODERATION_PANEL: 10001,
|
||||
EMOJI_PICKER: 10000,
|
||||
|
||||
// Top-level overlays (20000+)
|
||||
TOAST: 20000,
|
||||
|
||||
// Special navigation layers for game pages
|
||||
GAME_NAV: {
|
||||
HAMBURGER_MENU: 9999,
|
||||
HAMBURGER_NESTED_DROPDOWN: 10000,
|
||||
},
|
||||
} as const
|
||||
```
|
||||
|
||||
### Phase 2: Migrate High-Priority Components
|
||||
|
||||
Priority order:
|
||||
1. **Navigation components** (AppNavBar, etc.) - most critical for user experience
|
||||
2. **Tutorial system** (TutorialPlayer, tooltips) - currently conflicting
|
||||
3. **Modals and overlays** - ensure they're always on top
|
||||
4. **Game HUDs** - ensure proper layering
|
||||
5. **Everything else**
|
||||
|
||||
### Phase 3: Add Linting Rule
|
||||
|
||||
Consider adding an ESLint rule to prevent raw z-index numbers:
|
||||
|
||||
```javascript
|
||||
// Warn when zIndex is used with a number literal
|
||||
'no-magic-numbers': ['warn', {
|
||||
ignore: [0, 1, -1],
|
||||
ignoreArrayIndexes: true,
|
||||
enforceConst: true,
|
||||
}]
|
||||
```
|
||||
|
||||
## Debugging Z-Index Issues
|
||||
|
||||
### Checklist
|
||||
|
||||
When elements aren't layering correctly:
|
||||
|
||||
1. **Check the value**
|
||||
- [ ] What z-index does each element have?
|
||||
- [ ] Are they using constants or magic numbers?
|
||||
|
||||
2. **Check the stacking context**
|
||||
- [ ] What are the parent elements?
|
||||
- [ ] Do any parents create stacking contexts? (position + z-index, opacity, transform, etc.)
|
||||
- [ ] Are we comparing siblings or elements in different contexts?
|
||||
|
||||
3. **Verify the DOM hierarchy**
|
||||
- [ ] Use browser DevTools to inspect the DOM tree
|
||||
- [ ] Check the "Layers" panel in Chrome DevTools
|
||||
- [ ] Look for transforms, opacity, filters on parent elements
|
||||
|
||||
4. **Test the fix**
|
||||
- [ ] Does the fix work in all scenarios?
|
||||
- [ ] Did we introduce new conflicts?
|
||||
- [ ] Should we update the constants file?
|
||||
|
||||
### DevTools Tips
|
||||
|
||||
**Chrome DevTools:**
|
||||
1. Open DevTools → More Tools → Layers
|
||||
2. Select an element and see its stacking context
|
||||
3. View the 3D layer composition
|
||||
|
||||
**Firefox DevTools:**
|
||||
1. Inspector → Layout → scroll to "Z-index"
|
||||
2. Shows the stacking context parent
|
||||
|
||||
## Examples
|
||||
|
||||
### Good: Using Constants
|
||||
|
||||
```typescript
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
|
||||
export function MyTooltip() {
|
||||
return (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
zIndex: Z_INDEX.TOOLTIP, // ✅ Clear and maintainable
|
||||
})}>
|
||||
Tooltip content
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Bad: Magic Numbers
|
||||
|
||||
```typescript
|
||||
export function MyTooltip() {
|
||||
return (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
zIndex: 500, // ❌ Where did 500 come from? How does it relate to other elements?
|
||||
})}>
|
||||
Tooltip content
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Good: Documenting Stacking Context
|
||||
|
||||
```typescript
|
||||
// Creates a new stacking context for card contents
|
||||
<div className={css({
|
||||
position: 'relative',
|
||||
zIndex: Z_INDEX.BASE,
|
||||
transform: 'translateZ(0)', // ⚠️ Creates stacking context!
|
||||
})}>
|
||||
{/* Child z-indexes are relative to this context */}
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
zIndex: Z_INDEX.CONTENT, // Relative to parent, not global
|
||||
})}>
|
||||
Card face
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [MDN: CSS Stacking Context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context)
|
||||
- [What The Heck, z-index??](https://www.joshwcomeau.com/css/stacking-contexts/) by Josh Comeau
|
||||
- [Z-Index Playground](https://thirumanikandan.com/posts/learn-z-index-using-a-visualization-tool)
|
||||
|
||||
## Last Updated
|
||||
|
||||
2025-10-20 - Initial audit and documentation created
|
||||
@@ -103,7 +103,8 @@
|
||||
"Bash(node -e:*)",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run \\(.databaseId)\"\"')",
|
||||
"Bash(do ssh nas.home.network '/usr/local/bin/docker inspect soroban-abacus-flashcards --format=\"\"{{index .Config.Labels \\\"\"org.opencontainers.image.revision\\\"\"}}\"\"')",
|
||||
"Bash(git rev-parse HEAD)"
|
||||
"Bash(git rev-parse HEAD)",
|
||||
"Bash(gh run watch --exit-status 18662351595)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
|
||||
@@ -1,158 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack } from '../../../styled-system/patterns'
|
||||
|
||||
// Combine all levels into one array for the slider
|
||||
const allLevels = [
|
||||
{ level: '10th Kyu', emoji: '🧒', color: 'green', digits: 2, type: 'kyu' as const },
|
||||
{ level: '9th Kyu', emoji: '🧒', color: 'green', digits: 2, type: 'kyu' as const },
|
||||
{ level: '8th Kyu', emoji: '🧒', color: 'green', digits: 3, type: 'kyu' as const },
|
||||
{ level: '7th Kyu', emoji: '🧒', color: 'green', digits: 4, type: 'kyu' as const },
|
||||
{ level: '6th Kyu', emoji: '🧑', color: 'blue', digits: 5, type: 'kyu' as const },
|
||||
{ level: '5th Kyu', emoji: '🧑', color: 'blue', digits: 6, type: 'kyu' as const },
|
||||
{ level: '4th Kyu', emoji: '🧑', color: 'blue', digits: 7, type: 'kyu' as const },
|
||||
{ level: '3rd Kyu', emoji: '🧔', color: 'violet', digits: 8, type: 'kyu' as const },
|
||||
{ level: '2nd Kyu', emoji: '🧔', color: 'violet', digits: 9, type: 'kyu' as const },
|
||||
{ level: '1st Kyu', emoji: '🧔', color: 'violet', digits: 10, type: 'kyu' as const },
|
||||
{
|
||||
level: 'Pre-1st Dan',
|
||||
name: 'Jun-Shodan',
|
||||
minScore: 90,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '1st Dan',
|
||||
name: 'Shodan',
|
||||
minScore: 100,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Dan',
|
||||
name: 'Nidan',
|
||||
minScore: 120,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Dan',
|
||||
name: 'Sandan',
|
||||
minScore: 140,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '4th Dan',
|
||||
name: 'Yondan',
|
||||
minScore: 160,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '5th Dan',
|
||||
name: 'Godan',
|
||||
minScore: 180,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '6th Dan',
|
||||
name: 'Rokudan',
|
||||
minScore: 200,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '7th Dan',
|
||||
name: 'Nanadan',
|
||||
minScore: 220,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '8th Dan',
|
||||
name: 'Hachidan',
|
||||
minScore: 250,
|
||||
emoji: '🧝♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '9th Dan',
|
||||
name: 'Kudan',
|
||||
minScore: 270,
|
||||
emoji: '🧝♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '10th Dan',
|
||||
name: 'Judan',
|
||||
minScore: 290,
|
||||
emoji: '👑',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
] as const
|
||||
|
||||
export default function LevelsPage() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const currentLevel = allLevels[currentIndex]
|
||||
|
||||
// Calculate scale factor based on number of columns to fit the page
|
||||
// Smaller scale for more columns (Dan levels with 30 columns)
|
||||
const scaleFactor = Math.min(2.5, 20 / currentLevel.digits)
|
||||
|
||||
// Generate an interesting non-zero number to display on the abacus
|
||||
// Use a suffix pattern so rightmost digits stay constant as columns increase
|
||||
// This prevents beads from shifting: ones always 9, tens always 8, etc.
|
||||
const digitPattern = '123456789'
|
||||
// Use BigInt for numbers > 15 digits (Dan levels with 30 columns)
|
||||
const repeatedPattern = digitPattern.repeat(Math.ceil(currentLevel.digits / digitPattern.length))
|
||||
const digitString = repeatedPattern.slice(-currentLevel.digits)
|
||||
|
||||
// Use BigInt for large numbers to get full 30-digit precision
|
||||
const displayValue =
|
||||
currentLevel.digits > 15 ? BigInt(digitString) : Number.parseInt(digitString, 10)
|
||||
|
||||
// Dark theme styles matching the homepage
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Kyu & Dan Levels" navEmoji="📊">
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh', pb: '12' })}>
|
||||
@@ -178,7 +31,13 @@ export default function LevelsPage() {
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className={container({ maxW: '6xl', px: '4', position: 'relative' })}>
|
||||
<div
|
||||
className={container({
|
||||
maxW: '6xl',
|
||||
px: '4',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<div className={css({ textAlign: 'center', maxW: '5xl', mx: 'auto' })}>
|
||||
<h1
|
||||
className={css({
|
||||
@@ -213,177 +72,7 @@ export default function LevelsPage() {
|
||||
{/* Main content */}
|
||||
<div className={container({ maxW: '6xl', px: '4', py: '12' })}>
|
||||
<section className={stack({ gap: '8' })}>
|
||||
{/* Current Level Display */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.500'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.500'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.500'
|
||||
: 'amber.500',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
})}
|
||||
>
|
||||
{/* Level Info */}
|
||||
<div className={css({ textAlign: 'center', mb: '6' })}>
|
||||
<div className={css({ fontSize: '5xl', mb: '3' })}>{currentLevel.emoji}</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.400'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.400'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.400'
|
||||
: 'amber.400',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{currentLevel.level}
|
||||
</h2>
|
||||
{'name' in currentLevel && (
|
||||
<div className={css({ fontSize: 'md', color: 'gray.400', mb: '1' })}>
|
||||
{currentLevel.name}
|
||||
</div>
|
||||
)}
|
||||
{'minScore' in currentLevel && (
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
Minimum Score: {currentLevel.minScore} points
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Abacus Display */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mb: '6',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
overflowX: 'auto',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Digit Count */}
|
||||
<div className={css({ textAlign: 'center', color: 'gray.400', fontSize: 'sm' })}>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slider Control */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
})}
|
||||
>
|
||||
<div className={css({ mb: '4', textAlign: 'center' })}>
|
||||
<h3
|
||||
className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'white', mb: '2' })}
|
||||
>
|
||||
Explore All Levels
|
||||
</h3>
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.400' })}>
|
||||
Drag the slider to see each rank
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Range Slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={allLevels.length - 1}
|
||||
value={currentIndex}
|
||||
onChange={(e) => setCurrentIndex(Number(e.target.value))}
|
||||
className={css({
|
||||
w: '100%',
|
||||
h: '2',
|
||||
bg: 'gray.700',
|
||||
rounded: 'full',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Level Markers */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: '4',
|
||||
fontSize: 'xs',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
<span>10th Kyu</span>
|
||||
<span>1st Kyu</span>
|
||||
<span>10th Dan</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6',
|
||||
justifyContent: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'green.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Beginner (10-7 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'blue.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Intermediate (6-4 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'violet.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Advanced (3-1 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'amber.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Master (Dan ranks)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<LevelSliderDisplay />
|
||||
|
||||
{/* Info Section */}
|
||||
<div
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ const manifest: GameManifest = {
|
||||
maxPlayers: 1, // Single player only
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'],
|
||||
...getGameTheme('teal'),
|
||||
...getGameTheme('green'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const manifest: GameManifest = {
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
|
||||
...getGameTheme('purple'),
|
||||
...getGameTheme('pink'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const manifest: GameManifest = {
|
||||
maxPlayers: 8,
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['👥 Multiplayer', '🧠 Memory', '🧮 Soroban'],
|
||||
...getGameTheme('blue'),
|
||||
...getGameTheme('purple'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useContext, useMemo, useState } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack } from '../../styled-system/patterns'
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
import { useFullscreen } from '../contexts/FullscreenContext'
|
||||
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
|
||||
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
|
||||
|
||||
// Import HomeHeroContext for optional usage
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
|
||||
type HomeHeroContextValue = {
|
||||
subtitle: Subtitle
|
||||
isHeroVisible: boolean
|
||||
} | null
|
||||
|
||||
// HomeHeroContext - imported dynamically to avoid circular deps
|
||||
let HomeHeroContextModule: any = null
|
||||
try {
|
||||
HomeHeroContextModule = require('../contexts/HomeHeroContext')
|
||||
} catch {
|
||||
// Context not available
|
||||
}
|
||||
|
||||
const HomeHeroContext: React.Context<HomeHeroContextValue> =
|
||||
HomeHeroContextModule?.HomeHeroContext || React.createContext<HomeHeroContextValue>(null)
|
||||
|
||||
// Use HomeHeroContext without requiring it
|
||||
function useOptionalHomeHero(): HomeHeroContextValue {
|
||||
return useContext(HomeHeroContext)
|
||||
}
|
||||
|
||||
interface AppNavBarProps {
|
||||
variant?: 'full' | 'minimal'
|
||||
navSlot?: React.ReactNode
|
||||
@@ -514,6 +540,17 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
// Try to get home hero context (if on homepage)
|
||||
const homeHero = useOptionalHomeHero()
|
||||
|
||||
// Select a random subtitle once on mount (performance: won't change on re-renders)
|
||||
// Use homeHero subtitle if available, otherwise generate one
|
||||
const fallbackSubtitle = useMemo(() => getRandomSubtitle(), [])
|
||||
const subtitle = homeHero?.subtitle || fallbackSubtitle
|
||||
|
||||
// Show branding unless we're on homepage with visible hero
|
||||
const showBranding = !homeHero || !homeHero.isHeroVisible
|
||||
|
||||
// Auto-detect variant based on context
|
||||
const actualVariant = variant === 'full' && (isGamePage || isArcadePage) ? 'minimal' : variant
|
||||
|
||||
@@ -532,54 +569,136 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if we should use transparent styling (when hero is visible)
|
||||
const isTransparent = homeHero?.isHeroVisible
|
||||
|
||||
return (
|
||||
<header
|
||||
className={css({
|
||||
bg: 'white',
|
||||
shadow: 'sm',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 30,
|
||||
})}
|
||||
>
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '3' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.800',
|
||||
textDecoration: 'none',
|
||||
_hover: { color: 'brand.900' },
|
||||
})}
|
||||
>
|
||||
🧮 Soroban Generator
|
||||
</Link>
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<header
|
||||
className={css({
|
||||
bg: isTransparent ? 'transparent' : 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: isTransparent ? 'none' : 'blur(12px)',
|
||||
shadow: isTransparent ? 'none' : 'lg',
|
||||
borderBottom: isTransparent ? 'none' : '1px solid',
|
||||
borderColor: isTransparent ? 'transparent' : 'rgba(139, 92, 246, 0.2)',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
transition: 'all 0.3s ease',
|
||||
})}
|
||||
>
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '3' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
{/* Logo - conditionally shown based on hero visibility */}
|
||||
{showBranding ? (
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0',
|
||||
textDecoration: 'none',
|
||||
_hover: { '& > .brand-name': { color: 'rgba(196, 181, 253, 1)' } },
|
||||
opacity: 0,
|
||||
animation: 'fadeIn 0.3s ease-out forwards',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
})}
|
||||
>
|
||||
🧮 Abaci One
|
||||
</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: 'rgba(196, 181, 253, 0.8)',
|
||||
fontStyle: 'italic',
|
||||
cursor: 'help',
|
||||
_hover: { color: 'rgba(196, 181, 253, 1)' },
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={css({
|
||||
bg: 'gray.900',
|
||||
color: 'white',
|
||||
px: '3',
|
||||
py: '2',
|
||||
rounded: 'md',
|
||||
fontSize: 'sm',
|
||||
maxW: '250px',
|
||||
shadow: 'lg',
|
||||
zIndex: 50,
|
||||
})}
|
||||
>
|
||||
{subtitle.description}
|
||||
<Tooltip.Arrow
|
||||
className={css({
|
||||
fill: 'gray.900',
|
||||
})}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className={hstack({ gap: '6', alignItems: 'center' })}>
|
||||
{/* Navigation Links */}
|
||||
<nav className={hstack({ gap: '4' })}>
|
||||
<NavLink href="/create" currentPath={pathname}>
|
||||
Create
|
||||
</NavLink>
|
||||
<NavLink href="/guide" currentPath={pathname}>
|
||||
Guide
|
||||
</NavLink>
|
||||
<NavLink href="/games" currentPath={pathname}>
|
||||
Games
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className={hstack({ gap: '6', alignItems: 'center' })}>
|
||||
{/* Navigation Links */}
|
||||
<nav className={hstack({ gap: '4' })}>
|
||||
<NavLink href="/create" currentPath={pathname} isTransparent={isTransparent}>
|
||||
Create
|
||||
</NavLink>
|
||||
<NavLink href="/guide" currentPath={pathname} isTransparent={isTransparent}>
|
||||
Guide
|
||||
</NavLink>
|
||||
<NavLink href="/games" currentPath={pathname} isTransparent={isTransparent}>
|
||||
Games
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Abacus Style Dropdown */}
|
||||
<AbacusDisplayDropdown isFullscreen={false} />
|
||||
{/* Abacus Style Dropdown */}
|
||||
<AbacusDisplayDropdown isFullscreen={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
{/* Keyframes for fade-in animation */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -587,16 +706,21 @@ function NavLink({
|
||||
href,
|
||||
currentPath,
|
||||
children,
|
||||
isTransparent,
|
||||
}: {
|
||||
href: string
|
||||
currentPath: string | null
|
||||
children: React.ReactNode
|
||||
isTransparent?: boolean
|
||||
}) {
|
||||
const isActive = currentPath === href || (href !== '/' && currentPath?.startsWith(href))
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
style={{
|
||||
backdropFilter: isTransparent ? 'blur(8px)' : 'none',
|
||||
}}
|
||||
className={css({
|
||||
px: { base: '4', md: '3' },
|
||||
py: { base: '3', md: '2' },
|
||||
@@ -604,17 +728,38 @@ function NavLink({
|
||||
minW: { base: '44px', md: 'auto' },
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isActive ? 'brand.700' : 'gray.600',
|
||||
bg: isActive ? 'brand.50' : 'transparent',
|
||||
color: isTransparent
|
||||
? isActive
|
||||
? 'white'
|
||||
: 'rgba(255, 255, 255, 0.8)'
|
||||
: isActive
|
||||
? 'rgba(196, 181, 253, 1)'
|
||||
: 'rgba(209, 213, 219, 0.9)',
|
||||
bg: isTransparent
|
||||
? isActive
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(255, 255, 255, 0.08)'
|
||||
: isActive
|
||||
? 'rgba(139, 92, 246, 0.2)'
|
||||
: 'transparent',
|
||||
border: isTransparent ? '1px solid' : 'none',
|
||||
borderColor: isTransparent
|
||||
? isActive
|
||||
? 'rgba(255, 255, 255, 0.3)'
|
||||
: 'rgba(255, 255, 255, 0.15)'
|
||||
: 'transparent',
|
||||
rounded: 'lg',
|
||||
transition: 'all',
|
||||
textDecoration: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: isTransparent ? '0 2px 8px rgba(0, 0, 0, 0.2)' : 'none',
|
||||
_hover: {
|
||||
color: isActive ? 'brand.800' : 'gray.900',
|
||||
bg: isActive ? 'brand.100' : 'gray.50',
|
||||
color: isTransparent ? 'white' : 'rgba(196, 181, 253, 1)',
|
||||
bg: isTransparent ? 'rgba(255, 255, 255, 0.25)' : 'rgba(139, 92, 246, 0.25)',
|
||||
borderColor: isTransparent ? 'rgba(255, 255, 255, 0.4)' : 'transparent',
|
||||
boxShadow: isTransparent ? '0 4px 12px rgba(0, 0, 0, 0.3)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
||||
189
apps/web/src/components/HeroAbacus.tsx
Normal file
189
apps/web/src/components/HeroAbacus.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useHomeHero } from '../contexts/HomeHeroContext'
|
||||
|
||||
export function HeroAbacus() {
|
||||
const {
|
||||
subtitle,
|
||||
abacusValue,
|
||||
setAbacusValue,
|
||||
setIsHeroVisible,
|
||||
isAbacusLoaded,
|
||||
isSubtitleLoaded,
|
||||
} = useHomeHero()
|
||||
const appConfig = useAbacusConfig()
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Styling for structural elements (solid, no translucency)
|
||||
const structuralStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Detect when hero scrolls out of view
|
||||
useEffect(() => {
|
||||
if (!heroRef.current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Hero is visible if more than 20% is in viewport
|
||||
setIsHeroVisible(entry.intersectionRatio > 0.2)
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.2, 0.5, 1],
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(heroRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [setIsHeroVisible])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={css({
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
bg: 'gray.900',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
px: '4',
|
||||
py: '12',
|
||||
})}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Title and Subtitle Section - DIRECT CHILD */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl', lg: '7xl' },
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
Abaci One
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
fontWeight: 'medium',
|
||||
color: 'purple.300',
|
||||
fontStyle: 'italic',
|
||||
marginBottom: '8',
|
||||
opacity: isSubtitleLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Large Interactive Abacus - DIRECT CHILD */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: '1',
|
||||
width: '100%',
|
||||
zIndex: 10,
|
||||
opacity: isAbacusLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
transform: { base: 'scale(2)', md: 'scale(3)', lg: 'scale(4)' },
|
||||
transformOrigin: 'center center',
|
||||
transition: 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={abacusValue}
|
||||
columns={4}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={true}
|
||||
interactive={true}
|
||||
animated={true}
|
||||
customStyles={structuralStyles}
|
||||
onValueChange={setAbacusValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle hint to scroll - DIRECT CHILD */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
animation: 'bounce 2s ease-in-out infinite',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
<span>Scroll to explore</span>
|
||||
<span>↓</span>
|
||||
</div>
|
||||
|
||||
{/* Keyframes for bounce animation */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
308
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
308
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { animated, config, to, useSpring } from '@react-spring/web'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
interface Flashcard {
|
||||
id: number
|
||||
number: number
|
||||
initialX: number
|
||||
initialY: number
|
||||
initialRotation: number
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* InteractiveFlashcards - A fun, physics-based flashcard display
|
||||
* Users can drag and throw flashcards around with realistic momentum
|
||||
*/
|
||||
export function InteractiveFlashcards() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// Generate 8-15 random flashcards (client-side only to avoid hydration errors)
|
||||
const [cards, setCards] = useState<Flashcard[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// Double rAF pattern - ensures layout is fully complete
|
||||
const frameId1 = requestAnimationFrame(() => {
|
||||
const frameId2 = requestAnimationFrame(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth
|
||||
const containerHeight = containerRef.current.offsetHeight
|
||||
|
||||
// Only generate cards once we have proper dimensions
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
return
|
||||
}
|
||||
|
||||
const count = Math.floor(Math.random() * 8) + 8 // 8-15 cards
|
||||
const generated: Flashcard[] = []
|
||||
|
||||
// Position cards within the actual container bounds
|
||||
const cardWidth = 120 // approximate card width
|
||||
const cardHeight = 200 // approximate card height
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = {
|
||||
id: i,
|
||||
number: Math.floor(Math.random() * 900) + 100, // 100-999
|
||||
initialX: Math.random() * (containerWidth - cardWidth - 40) + 20,
|
||||
initialY: Math.random() * (containerHeight - cardHeight - 40) + 20,
|
||||
initialRotation: Math.random() * 40 - 20, // -20 to 20 degrees
|
||||
zIndex: i,
|
||||
}
|
||||
generated.push(card)
|
||||
}
|
||||
|
||||
setCards(generated)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Note: can't cancel nested rAF properly, but component cleanup will prevent state updates
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
height: { base: '400px', md: '500px' },
|
||||
overflow: 'hidden',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'xl',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
})}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<DraggableCard key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DraggableCardProps {
|
||||
card: Flashcard
|
||||
}
|
||||
|
||||
function DraggableCard({ card }: DraggableCardProps) {
|
||||
// Track the card's current position in state (separate from the animation values)
|
||||
const currentPositionRef = useRef({
|
||||
x: card.initialX,
|
||||
y: card.initialY,
|
||||
rotation: card.initialRotation,
|
||||
})
|
||||
|
||||
const [{ x, y, rotation, scale }, api] = useSpring(() => ({
|
||||
x: card.initialX,
|
||||
y: card.initialY,
|
||||
rotation: card.initialRotation,
|
||||
scale: 1,
|
||||
config: config.wobbly,
|
||||
}))
|
||||
|
||||
const [zIndex, setZIndex] = useState(card.zIndex)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const dragOffsetRef = useRef({ x: 0, y: 0 })
|
||||
const lastVelocityRef = useRef({ vx: 0, vy: 0 })
|
||||
const velocityHistoryRef = useRef<Array<{ vx: number; vy: number }>>([])
|
||||
const [transformOrigin, setTransformOrigin] = useState('center center')
|
||||
|
||||
const bind = useDrag(
|
||||
({ down, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy], first, xy }) => {
|
||||
// Bring card to front when dragging
|
||||
if (down) {
|
||||
setZIndex(1000)
|
||||
|
||||
// Calculate drag offset from card center on first touch
|
||||
if (first && cardRef.current) {
|
||||
const cardRect = cardRef.current.getBoundingClientRect()
|
||||
const cardWidth = cardRect.width
|
||||
const cardHeight = cardRect.height
|
||||
|
||||
// xy is in viewport coordinates, convert to position relative to card
|
||||
const clickRelativeToCard = {
|
||||
x: xy[0] - cardRect.left,
|
||||
y: xy[1] - cardRect.top,
|
||||
}
|
||||
|
||||
// Calculate offset from card center
|
||||
const cardCenterX = cardWidth / 2
|
||||
const cardCenterY = cardHeight / 2
|
||||
const offsetX = clickRelativeToCard.x - cardCenterX
|
||||
const offsetY = clickRelativeToCard.y - cardCenterY
|
||||
|
||||
dragOffsetRef.current = { x: offsetX, y: offsetY }
|
||||
|
||||
// Convert offset to transform-origin (50% + offset as percentage of card size)
|
||||
const originX = 50 + (offsetX / cardWidth) * 100
|
||||
const originY = 50 + (offsetY / cardHeight) * 100
|
||||
const transformOriginValue = `${originX}% ${originY}%`
|
||||
|
||||
console.log(
|
||||
`Drag start: click at (${clickRelativeToCard.x.toFixed(0)}, ${clickRelativeToCard.y.toFixed(0)}) in card, offset from center: (${offsetX.toFixed(0)}, ${offsetY.toFixed(0)}), origin: ${transformOriginValue}`
|
||||
)
|
||||
|
||||
setTransformOrigin(transformOriginValue)
|
||||
velocityHistoryRef.current = []
|
||||
}
|
||||
|
||||
// Smooth velocity by averaging last 3 frames
|
||||
velocityHistoryRef.current.push({ vx, vy })
|
||||
if (velocityHistoryRef.current.length > 3) {
|
||||
velocityHistoryRef.current.shift()
|
||||
}
|
||||
|
||||
const avgVx =
|
||||
velocityHistoryRef.current.reduce((sum, v) => sum + v.vx, 0) /
|
||||
velocityHistoryRef.current.length
|
||||
const avgVy =
|
||||
velocityHistoryRef.current.reduce((sum, v) => sum + v.vy, 0) /
|
||||
velocityHistoryRef.current.length
|
||||
|
||||
// Calculate rotation based on smoothed velocity and drag offset
|
||||
const velocityAngle = Math.atan2(avgVy, avgVx) * (180 / Math.PI)
|
||||
const offsetAngle =
|
||||
Math.atan2(dragOffsetRef.current.y, dragOffsetRef.current.x) * (180 / Math.PI)
|
||||
|
||||
// Card rotates to align with movement direction, offset by where we're grabbing
|
||||
const targetRotation = velocityAngle - offsetAngle + 90
|
||||
|
||||
const speed = Math.sqrt(avgVx * avgVx + avgVy * avgVy)
|
||||
|
||||
// Store smoothed velocity for throw
|
||||
lastVelocityRef.current = { vx: avgVx, vy: avgVy }
|
||||
|
||||
const finalRotation = speed > 0.01 ? targetRotation : currentPositionRef.current.rotation
|
||||
|
||||
api.start({
|
||||
x: currentPositionRef.current.x + mx,
|
||||
y: currentPositionRef.current.y + my,
|
||||
scale: 1.1,
|
||||
rotation: finalRotation,
|
||||
immediate: (key) => key !== 'rotation', // Position immediate, rotation smooth
|
||||
config: { tension: 200, friction: 30 }, // Smoother rotation spring
|
||||
})
|
||||
} else {
|
||||
// On release, reset transform origin to center
|
||||
setTransformOrigin('center center')
|
||||
|
||||
// On release, apply momentum with decay physics
|
||||
const throwVelocityX = lastVelocityRef.current.vx * 1000
|
||||
const throwVelocityY = lastVelocityRef.current.vy * 1000
|
||||
|
||||
// Calculate final rotation based on throw direction
|
||||
const throwAngle = Math.atan2(throwVelocityY, throwVelocityX) * (180 / Math.PI)
|
||||
|
||||
api.start({
|
||||
x: {
|
||||
from: currentPositionRef.current.x + mx,
|
||||
velocity: throwVelocityX,
|
||||
decay: true,
|
||||
},
|
||||
y: {
|
||||
from: currentPositionRef.current.y + my,
|
||||
velocity: throwVelocityY,
|
||||
decay: true,
|
||||
},
|
||||
scale: 1,
|
||||
rotation: throwAngle + 90, // Card aligns with throw direction
|
||||
config: config.wobbly,
|
||||
onChange: (result) => {
|
||||
// Update current position as card settles
|
||||
if (result.value.x !== undefined) {
|
||||
currentPositionRef.current.x = result.value.x
|
||||
}
|
||||
if (result.value.y !== undefined) {
|
||||
currentPositionRef.current.y = result.value.y
|
||||
}
|
||||
if (result.value.rotation !== undefined) {
|
||||
currentPositionRef.current.rotation = result.value.rotation
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
// Prevent scrolling when dragging
|
||||
preventDefault: true,
|
||||
filterTaps: true,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
ref={cardRef}
|
||||
{...bind()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: to(
|
||||
[x, y, rotation, scale],
|
||||
(x, y, r, s) => `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`
|
||||
),
|
||||
transformOrigin,
|
||||
zIndex,
|
||||
touchAction: 'none',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
_active: {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.3)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
minW: '120px',
|
||||
border: '2px solid rgba(0, 0, 0, 0.1)',
|
||||
transition: 'box-shadow 0.2s',
|
||||
_hover: {
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Abacus visualization */}
|
||||
<div
|
||||
className={css({
|
||||
transform: 'scale(0.6)',
|
||||
transformOrigin: 'center',
|
||||
})}
|
||||
>
|
||||
<AbacusReact value={card.number} columns={3} beadShape="circle" />
|
||||
</div>
|
||||
|
||||
{/* Number display */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
fontFamily: 'mono',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
</div>
|
||||
</animated.div>
|
||||
)
|
||||
}
|
||||
862
apps/web/src/components/LevelSliderDisplay.tsx
Normal file
862
apps/web/src/components/LevelSliderDisplay.tsx
Normal file
@@ -0,0 +1,862 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSpring, useTransition, animated } from '@react-spring/web'
|
||||
import * as Slider from '@radix-ui/react-slider'
|
||||
import { AbacusReact, StandaloneBead } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack } from '../../styled-system/patterns'
|
||||
import { kyuLevelDetails } from '@/data/kyuLevelDetails'
|
||||
|
||||
// Combine all levels into one array for the slider
|
||||
const allLevels = [
|
||||
{
|
||||
level: '10th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 2,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '9th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 2,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '8th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 3,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '7th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 4,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '6th Kyu',
|
||||
emoji: '🧑',
|
||||
color: 'blue',
|
||||
digits: 5,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '5th Kyu',
|
||||
emoji: '🧑',
|
||||
color: 'blue',
|
||||
digits: 6,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '4th Kyu',
|
||||
emoji: '🧑',
|
||||
color: 'blue',
|
||||
digits: 7,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Kyu',
|
||||
emoji: '🧔',
|
||||
color: 'violet',
|
||||
digits: 8,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Kyu',
|
||||
emoji: '🧔',
|
||||
color: 'violet',
|
||||
digits: 9,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '1st Kyu',
|
||||
emoji: '🧔',
|
||||
color: 'violet',
|
||||
digits: 10,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: 'Pre-1st Dan',
|
||||
name: 'Jun-Shodan',
|
||||
minScore: 90,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '1st Dan',
|
||||
name: 'Shodan',
|
||||
minScore: 100,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Dan',
|
||||
name: 'Nidan',
|
||||
minScore: 120,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Dan',
|
||||
name: 'Sandan',
|
||||
minScore: 140,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '4th Dan',
|
||||
name: 'Yondan',
|
||||
minScore: 160,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '5th Dan',
|
||||
name: 'Godan',
|
||||
minScore: 180,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '6th Dan',
|
||||
name: 'Rokudan',
|
||||
minScore: 200,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '7th Dan',
|
||||
name: 'Nanadan',
|
||||
minScore: 220,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '8th Dan',
|
||||
name: 'Hachidan',
|
||||
minScore: 250,
|
||||
emoji: '🧝♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '9th Dan',
|
||||
name: 'Kudan',
|
||||
minScore: 270,
|
||||
emoji: '🧝♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '10th Dan',
|
||||
name: 'Judan',
|
||||
minScore: 290,
|
||||
emoji: '👑',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
] as const
|
||||
|
||||
// Helper function to map level names to kyuLevelDetails keys
|
||||
function getLevelDetailsKey(levelName: string): string | null {
|
||||
// Convert "10th Kyu" → "10-kyu", "3rd Kyu" → "3-kyu", etc.
|
||||
const match = levelName.match(/^(\d+)(?:st|nd|rd|th)\s+Kyu$/)
|
||||
if (match) {
|
||||
return `${match[1]}-kyu`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse and format kyu level details into structured sections with icons
|
||||
function parseKyuDetails(rawText: string) {
|
||||
const lines = rawText.split('\n').filter((line) => line.trim() && !line.includes('shuzan.jp'))
|
||||
|
||||
// Always return sections in consistent order: Add/Sub, Multiply, Divide
|
||||
const sections: Array<{
|
||||
type: 'addSub' | 'multiply' | 'divide'
|
||||
icon: string
|
||||
label: string
|
||||
digits: string | null
|
||||
rows: string | null
|
||||
chars: string | null
|
||||
problems: string | null
|
||||
}> = [
|
||||
{
|
||||
type: 'addSub',
|
||||
icon: '➕➖',
|
||||
label: 'Add/Sub',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
{
|
||||
type: 'multiply',
|
||||
icon: '✖️',
|
||||
label: 'Multiply',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
{
|
||||
type: 'divide',
|
||||
icon: '➗',
|
||||
label: 'Divide',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
]
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Add/Sub:')) {
|
||||
const match = line.match(/(\d+)-digit.*?(\d+)口.*?(\d+)字/)
|
||||
if (match) {
|
||||
sections[0].digits = match[1]
|
||||
sections[0].rows = match[2]
|
||||
sections[0].chars = match[3]
|
||||
}
|
||||
} else if (line.includes('×:')) {
|
||||
const match = line.match(/(\d+) digits.*?\((\d+)/)
|
||||
if (match) {
|
||||
sections[1].digits = match[1]
|
||||
sections[1].problems = match[2]
|
||||
}
|
||||
} else if (line.includes('÷:')) {
|
||||
const match = line.match(/(\d+) digits.*?\((\d+)/)
|
||||
if (match) {
|
||||
sections[2].digits = match[1]
|
||||
sections[2].problems = match[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// Dark theme styles matching the homepage
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
interface LevelSliderDisplayProps {
|
||||
initialIndex?: number
|
||||
autoAdvanceEnabled?: boolean
|
||||
autoAdvanceInterval?: number
|
||||
showLegend?: boolean
|
||||
}
|
||||
|
||||
export function LevelSliderDisplay({
|
||||
initialIndex = 0,
|
||||
autoAdvanceEnabled = true,
|
||||
autoAdvanceInterval = 3000,
|
||||
showLegend = true,
|
||||
}: LevelSliderDisplayProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [isPaneHovered, setIsPaneHovered] = useState(false)
|
||||
const currentLevel = allLevels[currentIndex]
|
||||
|
||||
// State for animated abacus digits
|
||||
const [animatedDigits, setAnimatedDigits] = useState<string>('')
|
||||
|
||||
// Initialize animated digits when level changes
|
||||
useEffect(() => {
|
||||
const generateRandomDigits = (numDigits: number) => {
|
||||
return Array.from({ length: numDigits }, () => Math.floor(Math.random() * 10)).join('')
|
||||
}
|
||||
setAnimatedDigits(generateRandomDigits(currentLevel.digits))
|
||||
}, [currentLevel.digits])
|
||||
|
||||
// Animate abacus calculations - speed increases with Dan level
|
||||
useEffect(() => {
|
||||
// Calculate animation speed based on level
|
||||
// Kyu levels: 500ms
|
||||
// Pre-1st Dan: 500ms
|
||||
// 1st-10th Dan: interpolate from 500ms to 10ms
|
||||
const getAnimationInterval = () => {
|
||||
if (currentIndex < 11) {
|
||||
// Kyu levels and Pre-1st Dan: constant 500ms
|
||||
return 500
|
||||
}
|
||||
// 1st Dan through 10th Dan: speed up from 500ms to 10ms
|
||||
// Index 11 (1st Dan) → 500ms
|
||||
// Index 20 (10th Dan) → 10ms
|
||||
const danProgress = (currentIndex - 11) / 9 // 0.0 to 1.0
|
||||
return 500 - danProgress * 490 // 500ms down to 10ms
|
||||
}
|
||||
|
||||
const intervalMs = getAnimationInterval()
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setAnimatedDigits((prev) => {
|
||||
const digits = prev.split('').map(Number)
|
||||
const numColumns = digits.length
|
||||
|
||||
// Pick 1-3 adjacent columns to change (grouping effect)
|
||||
const groupSize = Math.floor(Math.random() * 3) + 1
|
||||
const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1))
|
||||
|
||||
// Change the selected columns
|
||||
for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) {
|
||||
digits[i] = Math.floor(Math.random() * 10)
|
||||
}
|
||||
|
||||
return digits.join('')
|
||||
})
|
||||
}, intervalMs)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [currentIndex])
|
||||
|
||||
// Auto-advance slider position every 3 seconds (unless pane is hovered)
|
||||
useEffect(() => {
|
||||
if (!autoAdvanceEnabled || isPaneHovered) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
// Cycle back to 0 when reaching the end
|
||||
return prev >= allLevels.length - 1 ? 0 : prev + 1
|
||||
})
|
||||
}, autoAdvanceInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [autoAdvanceEnabled, isPaneHovered, autoAdvanceInterval])
|
||||
|
||||
// Handle hover on slider track
|
||||
const handleSliderHover = (e: React.MouseEvent<HTMLSpanElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const percentage = x / rect.width
|
||||
const index = Math.round(percentage * (allLevels.length - 1))
|
||||
setCurrentIndex(Math.max(0, Math.min(allLevels.length - 1, index)))
|
||||
}
|
||||
|
||||
// Calculate scale factor based on number of columns to fit the page
|
||||
// Use constrained range to prevent huge size differences between levels
|
||||
// Min 1.2 (for 30-column Dan levels) to Max 2.0 (for 2-column Kyu levels)
|
||||
const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits))
|
||||
|
||||
// Animate scale factor with React Spring for smooth transitions
|
||||
const animatedProps = useSpring({
|
||||
scaleFactor,
|
||||
config: { tension: 350, friction: 45 },
|
||||
})
|
||||
|
||||
// Animate emoji with proper cross-fade (old fades out, new fades in)
|
||||
const emojiTransitions = useTransition(currentLevel.emoji, {
|
||||
keys: currentIndex,
|
||||
from: { opacity: 0 },
|
||||
enter: { opacity: 1 },
|
||||
leave: { opacity: 0 },
|
||||
config: { duration: 120 },
|
||||
})
|
||||
|
||||
// Convert animated digits to a number/BigInt for the abacus display
|
||||
// Use BigInt for large numbers to get full 30-digit precision
|
||||
const displayValue =
|
||||
animatedDigits.length > 15
|
||||
? BigInt(animatedDigits || '0')
|
||||
: Number.parseInt(animatedDigits || '0', 10)
|
||||
|
||||
return (
|
||||
<div className={stack({ gap: '8' })}>
|
||||
{/* Current Level Display */}
|
||||
<div
|
||||
onMouseEnter={() => setIsPaneHovered(true)}
|
||||
onMouseLeave={() => setIsPaneHovered(false)}
|
||||
className={css({
|
||||
bg: 'transparent',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.500'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.500'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.500'
|
||||
: 'amber.500',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
height: { base: 'auto', md: '700px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Abacus-themed Radix Slider */}
|
||||
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
|
||||
<div className={css({ position: 'relative', py: '12' })}>
|
||||
{/* Emoji tick marks */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
h: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
px: '0', // Use full width for tick spacing
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
w: 'full',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
{allLevels.map((level, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
opacity: index === currentIndex ? '1' : '0.3',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
_hover: { opacity: index === currentIndex ? '1' : '0.6' },
|
||||
})}
|
||||
>
|
||||
{level.emoji}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider.Root
|
||||
value={[currentIndex]}
|
||||
onValueChange={([value]) => setCurrentIndex(value)}
|
||||
min={0}
|
||||
max={allLevels.length - 1}
|
||||
step={1}
|
||||
onMouseMove={handleSliderHover}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
w: 'full',
|
||||
h: '32',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
<Slider.Track
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
position: 'relative',
|
||||
flexGrow: 1,
|
||||
rounded: 'full',
|
||||
h: '3px',
|
||||
})}
|
||||
>
|
||||
<Slider.Range className={css({ display: 'none' })} />
|
||||
</Slider.Track>
|
||||
|
||||
<Slider.Thumb
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
w: '180px',
|
||||
h: '128px',
|
||||
bg: 'transparent',
|
||||
cursor: 'grab',
|
||||
transition: 'transform 0.15s ease-out, left 0.3s ease-out',
|
||||
zIndex: 10,
|
||||
_hover: { transform: 'scale(1.15)' },
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
transform: 'scale(1.15)',
|
||||
},
|
||||
_active: { cursor: 'grabbing' },
|
||||
})}
|
||||
>
|
||||
<div className={css({ opacity: 0.75 })}>
|
||||
<StandaloneBead
|
||||
size={128}
|
||||
color={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
{emojiTransitions((style, emoji) => (
|
||||
<animated.div
|
||||
style={style}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
fontSize: '9xl',
|
||||
pointerEvents: 'none',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</animated.div>
|
||||
))}
|
||||
|
||||
{/* Level text as part of the bead display */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '-80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.400'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.400'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.400'
|
||||
: 'amber.400',
|
||||
mb: '0.5',
|
||||
})}
|
||||
>
|
||||
{currentLevel.level}
|
||||
</h2>
|
||||
{'name' in currentLevel && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
color: 'gray.300',
|
||||
mb: '0.5',
|
||||
})}
|
||||
>
|
||||
{currentLevel.name}
|
||||
</div>
|
||||
)}
|
||||
{'minScore' in currentLevel && (
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.400' })}>
|
||||
Min: {currentLevel.minScore}pts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Slider.Thumb>
|
||||
</Slider.Root>
|
||||
</div>
|
||||
|
||||
{/* Level Markers */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: '1',
|
||||
fontSize: 'xs',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
<span>10th Kyu</span>
|
||||
<span>1st Kyu</span>
|
||||
<span>10th Dan</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abacus Display with Level Details */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '4',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
{/* Level Details (only for Kyu levels) */}
|
||||
{currentLevel.type === 'kyu' &&
|
||||
(() => {
|
||||
const detailsKey = getLevelDetailsKey(currentLevel.level)
|
||||
const rawText = detailsKey
|
||||
? kyuLevelDetails[detailsKey as keyof typeof kyuLevelDetails]
|
||||
: null
|
||||
const sections = rawText ? parseKyuDetails(rawText) : []
|
||||
|
||||
// Use consistent sizing across all levels
|
||||
const sizing = { fontSize: 'md', gap: '3', iconSize: '4xl' }
|
||||
|
||||
return sections.length > 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '2',
|
||||
p: '2',
|
||||
maxW: '400px',
|
||||
alignContent: 'center',
|
||||
})}
|
||||
>
|
||||
{sections.map((section, idx) => {
|
||||
const hasData = section.digits !== null
|
||||
const levelColor =
|
||||
currentLevel.color === 'green'
|
||||
? 'green.300'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.300'
|
||||
: 'violet.300'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={css({
|
||||
bg: hasData ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.2)',
|
||||
border: '1px solid',
|
||||
borderColor: hasData ? 'gray.700' : 'gray.800',
|
||||
rounded: 'md',
|
||||
p: '3',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5',
|
||||
opacity: hasData ? 1 : 0.3,
|
||||
width: '170px',
|
||||
height: '150px',
|
||||
_hover: hasData
|
||||
? {
|
||||
borderColor: 'gray.500',
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
: {},
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: sizing.iconSize, lineHeight: '1' })}>
|
||||
{section.icon}
|
||||
</span>
|
||||
{hasData && section.digits && (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: levelColor,
|
||||
})}
|
||||
>
|
||||
{section.digits} digits
|
||||
</div>
|
||||
{(section.rows || section.chars) && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{section.rows && `${section.rows} rows`}
|
||||
{section.rows && section.chars && ' • '}
|
||||
{section.chars && `${section.chars} chars`}
|
||||
</div>
|
||||
)}
|
||||
{section.problems && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{section.problems} problems
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: hasData ? 'gray.500' : 'gray.700',
|
||||
textAlign: 'center',
|
||||
fontWeight: hasData ? 'normal' : 'bold',
|
||||
})}
|
||||
>
|
||||
{section.label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: currentLevel.type === 'kyu' ? 'flex-end' : 'center',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</animated.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Digit Count */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
color: 'gray.400',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6',
|
||||
justifyContent: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'green.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>Beginner (10-7 Kyu)</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'blue.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Intermediate (6-4 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'violet.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>Advanced (3-1 Kyu)</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'amber.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>Master (Dan ranks)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
apps/web/src/contexts/HomeHeroContext.tsx
Normal file
127
apps/web/src/contexts/HomeHeroContext.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
import { subtitles } from '../data/abaciOneSubtitles'
|
||||
|
||||
interface HomeHeroContextValue {
|
||||
subtitle: Subtitle
|
||||
abacusValue: number
|
||||
setAbacusValue: (value: number) => void
|
||||
isHeroVisible: boolean
|
||||
setIsHeroVisible: (visible: boolean) => void
|
||||
isAbacusLoaded: boolean
|
||||
isSubtitleLoaded: boolean
|
||||
}
|
||||
|
||||
const HomeHeroContext = createContext<HomeHeroContextValue | null>(null)
|
||||
|
||||
export { HomeHeroContext }
|
||||
|
||||
export function HomeHeroProvider({ children }: { children: React.ReactNode }) {
|
||||
// Use first subtitle for SSR, then select random one on client mount
|
||||
const [subtitle, setSubtitle] = useState<Subtitle>(subtitles[0])
|
||||
const [isSubtitleLoaded, setIsSubtitleLoaded] = useState(false)
|
||||
|
||||
// Select random subtitle only on client side, persist per-session
|
||||
useEffect(() => {
|
||||
// Check if we have a stored subtitle index for this session
|
||||
const storedIndex = sessionStorage.getItem('heroSubtitleIndex')
|
||||
|
||||
if (storedIndex !== null) {
|
||||
// Use the stored subtitle index
|
||||
const index = parseInt(storedIndex, 10)
|
||||
if (!Number.isNaN(index) && index >= 0 && index < subtitles.length) {
|
||||
setSubtitle(subtitles[index])
|
||||
setIsSubtitleLoaded(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new random index and store it
|
||||
const randomIndex = Math.floor(Math.random() * subtitles.length)
|
||||
sessionStorage.setItem('heroSubtitleIndex', randomIndex.toString())
|
||||
setSubtitle(subtitles[randomIndex])
|
||||
setIsSubtitleLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Shared abacus value - always start at 0 for SSR/hydration consistency
|
||||
const [abacusValue, setAbacusValue] = useState(0)
|
||||
const [isAbacusLoaded, setIsAbacusLoaded] = useState(false)
|
||||
const isLoadingFromStorage = useRef(false)
|
||||
|
||||
// Load from sessionStorage after mount (client-only, no hydration mismatch)
|
||||
useEffect(() => {
|
||||
console.log('[HeroAbacus] Loading from sessionStorage...')
|
||||
isLoadingFromStorage.current = true // Block saves during load
|
||||
|
||||
const saved = sessionStorage.getItem('heroAbacusValue')
|
||||
console.log('[HeroAbacus] Saved value from storage:', saved)
|
||||
|
||||
if (saved) {
|
||||
const parsedValue = parseInt(saved, 10)
|
||||
console.log('[HeroAbacus] Parsed value:', parsedValue)
|
||||
if (!Number.isNaN(parsedValue)) {
|
||||
console.log('[HeroAbacus] Setting abacus value to:', parsedValue)
|
||||
setAbacusValue(parsedValue)
|
||||
}
|
||||
} else {
|
||||
console.log('[HeroAbacus] No saved value found, staying at 0')
|
||||
}
|
||||
|
||||
// Use setTimeout to ensure the value has been set before we allow saves
|
||||
setTimeout(() => {
|
||||
isLoadingFromStorage.current = false
|
||||
setIsAbacusLoaded(true)
|
||||
console.log('[HeroAbacus] Load complete, allowing saves now and fading in')
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
// Persist value to sessionStorage when it changes (but skip during load)
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[HeroAbacus] Save effect triggered. Value:',
|
||||
abacusValue,
|
||||
'isLoadingFromStorage:',
|
||||
isLoadingFromStorage.current
|
||||
)
|
||||
|
||||
if (!isLoadingFromStorage.current) {
|
||||
console.log('[HeroAbacus] Saving to sessionStorage:', abacusValue)
|
||||
sessionStorage.setItem('heroAbacusValue', abacusValue.toString())
|
||||
console.log(
|
||||
'[HeroAbacus] Saved successfully. Storage now contains:',
|
||||
sessionStorage.getItem('heroAbacusValue')
|
||||
)
|
||||
} else {
|
||||
console.log('[HeroAbacus] Skipping save (currently loading from storage)')
|
||||
}
|
||||
}, [abacusValue])
|
||||
|
||||
// Track hero visibility for nav branding
|
||||
const [isHeroVisible, setIsHeroVisible] = useState(true)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
subtitle,
|
||||
abacusValue,
|
||||
setAbacusValue,
|
||||
isHeroVisible,
|
||||
setIsHeroVisible,
|
||||
isAbacusLoaded,
|
||||
isSubtitleLoaded,
|
||||
}),
|
||||
[subtitle, abacusValue, isHeroVisible, isAbacusLoaded, isSubtitleLoaded]
|
||||
)
|
||||
|
||||
return <HomeHeroContext.Provider value={value}>{children}</HomeHeroContext.Provider>
|
||||
}
|
||||
|
||||
export function useHomeHero() {
|
||||
const context = useContext(HomeHeroContext)
|
||||
if (!context) {
|
||||
throw new Error('useHomeHero must be used within HomeHeroProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
97
apps/web/src/data/abaciOneSubtitles.ts
Normal file
97
apps/web/src/data/abaciOneSubtitles.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Abaci One subtitle options with descriptions
|
||||
* Three-word rhyming subtitles for the main app navigation
|
||||
*/
|
||||
|
||||
export interface Subtitle {
|
||||
text: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const subtitles: Subtitle[] = [
|
||||
{ text: 'Speed Bead Lead', description: 'blaze through bead races' },
|
||||
{ text: 'Rod Mod Nod', description: 'tweak rod technique, approval earned' },
|
||||
{ text: 'Grid Kid Lid', description: 'lock in neat grid habits' },
|
||||
{ text: 'Count Mount Amount', description: 'stack up that number sense' },
|
||||
{ text: 'Stack Track Tack', description: 'line up beads, lock in sums' },
|
||||
{ text: 'Quick Flick Trick', description: 'rapid-fire bead tactics' },
|
||||
{ text: 'Flash Dash Math', description: 'fly through numeric challenges' },
|
||||
{ text: 'Slide Glide Pride', description: 'smooth soroban strokes' },
|
||||
{ text: 'Shift Sift Gift', description: 'sort beads, reveal talent' },
|
||||
{ text: 'Beat Seat Meet', description: 'compete head-to-head' },
|
||||
{ text: 'Brain Train Gain', description: 'mental math muscle building' },
|
||||
{ text: 'Flow Show Pro', description: 'demonstrate soroban mastery' },
|
||||
{ text: 'Fast Blast Past', description: 'surpass speed limits' },
|
||||
{ text: 'Snap Tap Map', description: 'chart your calculation path' },
|
||||
{ text: 'Row Grow Know', description: 'advance through structured drills' },
|
||||
{ text: 'Drill Skill Thrill', description: 'practice that excites' },
|
||||
{ text: 'Think Link Sync', description: 'connect mind and beads' },
|
||||
{ text: 'Boost Joust Roost', description: 'power up, compete, settle in' },
|
||||
{ text: 'Add Grad Rad', description: 'level up addition awesomely' },
|
||||
{ text: 'Sum Fun Run', description: 'enjoy the arithmetic sprint' },
|
||||
{ text: 'Track Stack Pack', description: 'organize solutions systematically' },
|
||||
{ text: 'Beat Neat Feat', description: 'clean victories, impressive wins' },
|
||||
{ text: 'Math Path Wrath', description: 'dominate numeric challenges' },
|
||||
{ text: 'Spark Mark Arc', description: 'ignite progress, track growth' },
|
||||
{ text: 'Race Pace Ace', description: 'speed up, master it' },
|
||||
{ text: 'Flex Hex Reflex', description: 'adapt calculations instantly' },
|
||||
{ text: 'Glide Pride Stride', description: 'smooth confident progress' },
|
||||
{ text: 'Flash Dash Smash', description: 'speed through, crush totals' },
|
||||
{ text: 'Stack Attack Jack', description: 'aggressive bead strategies' },
|
||||
{ text: 'Quick Pick Click', description: 'rapid bead selection' },
|
||||
{ text: 'Snap Map Tap', description: 'visualize and execute' },
|
||||
{ text: 'Mind Find Grind', description: 'discover mental endurance' },
|
||||
{ text: 'Flip Skip Rip', description: 'fast transitions, tear through' },
|
||||
{ text: 'Blend Trend Send', description: 'mix methods, share progress' },
|
||||
{ text: 'Power Tower Hour', description: 'build skills intensively' },
|
||||
{ text: 'Launch Staunch Haunch', description: 'start strong, stay firm' },
|
||||
{ text: 'Rush Crush Hush', description: 'speed quietly dominates' },
|
||||
{ text: 'Swipe Stripe Hype', description: 'sleek moves, excitement' },
|
||||
{ text: 'Train Gain Sustain', description: 'build lasting ability' },
|
||||
{ text: 'Frame Claim Flame', description: 'structure your fire' },
|
||||
{ text: 'Streak Peak Tweak', description: 'hot runs, optimize performance' },
|
||||
{ text: 'Edge Pledge Wedge', description: 'commit to precision' },
|
||||
{ text: 'Pace Grace Space', description: 'rhythm, elegance, room to grow' },
|
||||
{ text: 'Link Think Brink', description: 'connect at breakthrough edge' },
|
||||
{ text: 'Quest Test Best', description: 'challenge yourself to excel' },
|
||||
{ text: 'Drive Thrive Arrive', description: 'push hard, succeed, reach goals' },
|
||||
{ text: 'Smart Start Chart', description: 'begin wisely, track progress' },
|
||||
{ text: 'Boost Coast Toast', description: 'accelerate, cruise, celebrate' },
|
||||
{ text: 'Spark Dark Embark', description: 'ignite before dawn journeys' },
|
||||
{ text: 'Blaze Graze Amaze', description: 'burn through, touch lightly, wow' },
|
||||
{ text: 'Shift Drift Gift', description: 'adapt smoothly, reveal talent' },
|
||||
{ text: 'Zone Hone Own', description: 'focus, refine, claim mastery' },
|
||||
{ text: 'Vault Halt Exalt', description: 'leap high, pause, celebrate' },
|
||||
{ text: 'Peak Seek Streak', description: 'find heights, maintain momentum' },
|
||||
{ text: 'Glow Show Grow', description: 'shine, display, expand' },
|
||||
{ text: 'Scope Hope Rope', description: 'survey possibilities, climb up' },
|
||||
{ text: 'Core Score More', description: 'fundamentals yield better results' },
|
||||
{ text: 'Rank Bank Thank', description: 'earn status, save wins, appreciate' },
|
||||
{ text: 'Merge Surge Verge', description: 'combine forces, power up, edge closer' },
|
||||
{ text: 'Bold Gold Hold', description: 'brave attempts, prize rewards, maintain' },
|
||||
{ text: 'Rise Prize Wise', description: 'ascend, win, learn' },
|
||||
{ text: 'Move Groove Prove', description: 'act, find rhythm, demonstrate' },
|
||||
{ text: 'Trust Thrust Adjust', description: 'believe, push, refine' },
|
||||
{ text: 'Beam Dream Team', description: 'radiate, aspire, collaborate' },
|
||||
{ text: 'Spin Win Grin', description: 'rotate beads, succeed, smile' },
|
||||
{ text: 'String Ring Bring', description: 'connect, cycle, deliver' },
|
||||
{ text: 'Clear Gear Steer', description: 'focus, equip, direct' },
|
||||
{ text: 'Path Math Aftermath', description: 'route, calculate, results' },
|
||||
{ text: 'Play Slay Day', description: 'engage, dominate, own it' },
|
||||
{ text: 'Code Mode Road', description: 'pattern, style, journey' },
|
||||
{ text: 'Craft Draft Shaft', description: 'build, sketch, core structure' },
|
||||
{ text: 'Light Might Fight', description: 'illuminate, empower, compete' },
|
||||
{ text: 'Stream Dream Extreme', description: 'flow, envision, push limits' },
|
||||
{ text: 'Claim Frame Aim', description: 'assert, structure, target' },
|
||||
{ text: 'Chart Smart Start', description: 'map, intelligent, begin' },
|
||||
{ text: 'Bright Flight Height', description: 'brilliant, soar, elevation' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Get a random subtitle from the list
|
||||
* Uses current timestamp as seed for variety across sessions
|
||||
*/
|
||||
export function getRandomSubtitle(): Subtitle {
|
||||
const index = Math.floor(Math.random() * subtitles.length)
|
||||
return subtitles[index]
|
||||
}
|
||||
123
apps/web/src/data/kyuLevelDetails.ts
Normal file
123
apps/web/src/data/kyuLevelDetails.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Detailed requirements for each Kyu level in the Soroban certification system
|
||||
* Source: shuzan.jp
|
||||
*
|
||||
* Note: Stored verbatim from source. Display formatting/translation happens in the UI layer.
|
||||
*/
|
||||
|
||||
export const kyuLevelDetails = {
|
||||
'10-kyu': `Add/Sub: 2-digit, 5口, 10字
|
||||
|
||||
×: 実+法 = 3 digits (20 problems)
|
||||
|
||||
Time: 20 min; Pass ≥ 60/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'9-kyu': `Add/Sub: 2-digit, 5口, 10字
|
||||
|
||||
×: 実+法 = 3 digits (20)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200. (If only one part clears, it's treated as 10-kyu per federation notes.)
|
||||
shuzan.jp`,
|
||||
|
||||
'8-kyu': `Add/Sub: 2-digit, 8口, 16字
|
||||
|
||||
×: 実+法 = 4 digits (10)
|
||||
|
||||
÷: 法+商 = 3 digits (10)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'7-kyu': `Add/Sub: 2-digit, 10口, 20字
|
||||
|
||||
×: 実+法 = 4 digits (10)
|
||||
|
||||
÷: 法+商 = 4 digits (10)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'6-kyu': `Add/Sub: 10口, 30字
|
||||
|
||||
×: 実+法 = 5 digits (20)
|
||||
|
||||
÷: 法+商 = 4 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'5-kyu': `Add/Sub: 10口, 40字
|
||||
|
||||
×: 実+法 = 6 digits (20)
|
||||
|
||||
÷: 法+商 = 5 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'4-kyu': `Add/Sub: 10口, 50字
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-3-kyu': `Add/Sub: 10口, 50字 ×5題 and 10口, 60字 ×5題 (total 10)
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'3-kyu': `Add/Sub: 10口, 60字
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-2-kyu': `Add/Sub: 10口, 70字
|
||||
|
||||
×: 実+法 = 8 digits (20)
|
||||
|
||||
÷: 法+商 = 7 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'2-kyu': `Add/Sub: 10口, 80字
|
||||
|
||||
×: 実+法 = 9 digits (20)
|
||||
|
||||
÷: 法+商 = 8 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-1-kyu': `Add/Sub: 10口, 90字
|
||||
|
||||
×: 実+法 = 10 digits (20)
|
||||
|
||||
÷: 法+商 = 9 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'1-kyu': `Add/Sub: 10口, 100字
|
||||
|
||||
×: 実+法 = 11 digits (20)
|
||||
|
||||
÷: 法+商 = 10 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
} as const
|
||||
|
||||
export type KyuLevel = keyof typeof kyuLevelDetails
|
||||
@@ -14,59 +14,59 @@ export interface GameTheme {
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard theme presets
|
||||
* These use Panda CSS's color system and provide consistent styling
|
||||
* Standard theme presets with vibrant gradients
|
||||
* Updated for eye-catching game cards on the homepage
|
||||
*/
|
||||
export const GAME_THEMES = {
|
||||
blue: {
|
||||
color: 'blue',
|
||||
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)', // blue.100 to blue.200
|
||||
borderColor: '#bfdbfe', // blue.200
|
||||
gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', // Vibrant cyan
|
||||
borderColor: '#00f2fe',
|
||||
},
|
||||
purple: {
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)', // purple.100 to purple.200
|
||||
borderColor: '#ddd6fe', // purple.200
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', // Vibrant purple
|
||||
borderColor: '#764ba2',
|
||||
},
|
||||
green: {
|
||||
color: 'green',
|
||||
gradient: 'linear-gradient(135deg, #d1fae5, #a7f3d0)', // green.100 to green.200
|
||||
borderColor: '#a7f3d0', // green.200
|
||||
gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', // Vibrant green/teal
|
||||
borderColor: '#38f9d7',
|
||||
},
|
||||
teal: {
|
||||
color: 'teal',
|
||||
gradient: 'linear-gradient(135deg, #ccfbf1, #99f6e4)', // teal.100 to teal.200
|
||||
borderColor: '#99f6e4', // teal.200
|
||||
gradient: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)', // Vibrant teal
|
||||
borderColor: '#38ef7d',
|
||||
},
|
||||
indigo: {
|
||||
color: 'indigo',
|
||||
gradient: 'linear-gradient(135deg, #e0e7ff, #c7d2fe)', // indigo.100 to indigo.200
|
||||
borderColor: '#c7d2fe', // indigo.200
|
||||
gradient: 'linear-gradient(135deg, #5f72bd 0%, #9b23ea 100%)', // Vibrant indigo
|
||||
borderColor: '#9b23ea',
|
||||
},
|
||||
pink: {
|
||||
color: 'pink',
|
||||
gradient: 'linear-gradient(135deg, #fce7f3, #fbcfe8)', // pink.100 to pink.200
|
||||
borderColor: '#fbcfe8', // pink.200
|
||||
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', // Vibrant pink
|
||||
borderColor: '#f5576c',
|
||||
},
|
||||
orange: {
|
||||
color: 'orange',
|
||||
gradient: 'linear-gradient(135deg, #ffedd5, #fed7aa)', // orange.100 to orange.200
|
||||
borderColor: '#fed7aa', // orange.200
|
||||
gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', // Vibrant orange/coral
|
||||
borderColor: '#fee140',
|
||||
},
|
||||
yellow: {
|
||||
color: 'yellow',
|
||||
gradient: 'linear-gradient(135deg, #fef3c7, #fde68a)', // yellow.100 to yellow.200
|
||||
borderColor: '#fde68a', // yellow.200
|
||||
gradient: 'linear-gradient(135deg, #ffd89b 0%, #19547b 100%)', // Vibrant yellow/blue
|
||||
borderColor: '#ffd89b',
|
||||
},
|
||||
red: {
|
||||
color: 'red',
|
||||
gradient: 'linear-gradient(135deg, #fee2e2, #fecaca)', // red.100 to red.200
|
||||
borderColor: '#fecaca', // red.200
|
||||
gradient: 'linear-gradient(135deg, #f85032 0%, #e73827 100%)', // Vibrant red
|
||||
borderColor: '#e73827',
|
||||
},
|
||||
gray: {
|
||||
color: 'gray',
|
||||
gradient: 'linear-gradient(135deg, #f3f4f6, #e5e7eb)', // gray.100 to gray.200
|
||||
borderColor: '#e5e7eb', // gray.200
|
||||
gradient: 'linear-gradient(135deg, #868f96 0%, #596164 100%)', // Vibrant gray
|
||||
borderColor: '#596164',
|
||||
},
|
||||
} as const satisfies Record<string, GameTheme>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.32.1",
|
||||
"version": "4.58.1",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
# [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.0.0...abacus-react-v2.1.0) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add fixed height to entire level display pane ([200b26c](https://github.com/antialias/soroban-abacus-flashcards/commit/200b26c2cd35d1d637ede9dcfc3dbbc7f3f19320))
|
||||
* **levels:** increase container height to prevent abacus clipping ([cd5c15a](https://github.com/antialias/soroban-abacus-flashcards/commit/cd5c15aeb260c568fe7ad9b6a4f51c4d6498b2b8))
|
||||
* **levels:** only animate abacus, not container with background/border ([c80477d](https://github.com/antialias/soroban-abacus-flashcards/commit/c80477d24877ddada5f3f4405abbf05e1d753b5d))
|
||||
* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](https://github.com/antialias/soroban-abacus-flashcards/commit/563136fb79fa10b2af3a119bf0f861e3b0812b2e))
|
||||
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](https://github.com/antialias/soroban-abacus-flashcards/commit/ead9ee9589aa4d7376e9385da5da53a6b444858a))
|
||||
* **levels:** reduce scale factor variation to minimize margin differences ([abb647c](https://github.com/antialias/soroban-abacus-flashcards/commit/abb647ce40b8f9d0c8268ab18c139324ae3195c5))
|
||||
* **levels:** revert delayed column change, keep overflow hidden ([22f00f5](https://github.com/antialias/soroban-abacus-flashcards/commit/22f00f59f5facc36a846408dcd196ec54ea676b1))
|
||||
* **levels:** stabilize slider position and prevent abacus clipping ([09004dc](https://github.com/antialias/soroban-abacus-flashcards/commit/09004dc2c055031ee2f71c964ceee6f7b1d42ecd))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** export StandaloneBead component wired to AbacusDisplayContext ([0146ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/0146ce1e67da27a24cbaa8338ba6a1a6befd6bd3))
|
||||
* **levels:** add hover interaction and smooth React Spring transitions ([fd2b633](https://github.com/antialias/soroban-abacus-flashcards/commit/fd2b6338a84c3bbc683eff216a8da3b155749f0f))
|
||||
* **levels:** redesign slider with abacus-themed beads ([f3dce84](https://github.com/antialias/soroban-abacus-flashcards/commit/f3dce84532fa706e4ec9551facde2055a060ee13))
|
||||
* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](https://github.com/antialias/soroban-abacus-flashcards/commit/0fbde53039d3ea000c6a3be492b733479e7bf47c))
|
||||
|
||||
# [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.8.0...abacus-react-v2.0.0) (2025-10-20)
|
||||
|
||||
|
||||
|
||||
147
packages/abacus-react/src/StandaloneBead.tsx
Normal file
147
packages/abacus-react/src/StandaloneBead.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useSpring, animated } from "@react-spring/web";
|
||||
import {
|
||||
useAbacusConfig,
|
||||
getDefaultAbacusConfig,
|
||||
type BeadShape,
|
||||
} from "./AbacusContext";
|
||||
|
||||
export interface StandaloneBeadProps {
|
||||
/** Size of the bead in pixels */
|
||||
size?: number;
|
||||
/** Override the shape from context (diamond, circle, square) */
|
||||
shape?: BeadShape;
|
||||
/** Override the color from context */
|
||||
color?: string;
|
||||
/** Enable animation */
|
||||
animated?: boolean;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
/** Custom style */
|
||||
style?: React.CSSProperties;
|
||||
/** Active state for the bead */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone Bead component that respects the AbacusDisplayContext.
|
||||
* This component renders a single abacus bead with styling from the context.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* import { StandaloneBead, AbacusDisplayProvider } from '@soroban/abacus-react';
|
||||
*
|
||||
* <AbacusDisplayProvider>
|
||||
* <StandaloneBead size={28} color="#8b5cf6" />
|
||||
* </AbacusDisplayProvider>
|
||||
* ```
|
||||
*/
|
||||
export const StandaloneBead: React.FC<StandaloneBeadProps> = ({
|
||||
size = 28,
|
||||
shape: shapeProp,
|
||||
color: colorProp,
|
||||
animated: animatedProp,
|
||||
className,
|
||||
style,
|
||||
active = true,
|
||||
}) => {
|
||||
// Try to use context config, fallback to defaults if no context
|
||||
let contextConfig;
|
||||
try {
|
||||
contextConfig = useAbacusConfig();
|
||||
} catch {
|
||||
// No context provider, use defaults
|
||||
contextConfig = getDefaultAbacusConfig();
|
||||
}
|
||||
|
||||
// Use props if provided, otherwise fall back to context config
|
||||
const shape = shapeProp ?? contextConfig.beadShape;
|
||||
const enableAnimation = animatedProp ?? contextConfig.animated;
|
||||
const color = colorProp ?? "#000000";
|
||||
|
||||
const [springs, api] = useSpring(() => ({
|
||||
scale: 1,
|
||||
config: { tension: 300, friction: 20 },
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enableAnimation) {
|
||||
api.start({ scale: 1 });
|
||||
}
|
||||
}, [enableAnimation, api]);
|
||||
|
||||
const renderShape = () => {
|
||||
const halfSize = size / 2;
|
||||
const actualColor = active ? color : "rgb(211, 211, 211)";
|
||||
|
||||
switch (shape) {
|
||||
case "diamond":
|
||||
return (
|
||||
<polygon
|
||||
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
case "square":
|
||||
return (
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
rx="1"
|
||||
/>
|
||||
);
|
||||
case "circle":
|
||||
default:
|
||||
return (
|
||||
<circle
|
||||
cx={halfSize}
|
||||
cy={halfSize}
|
||||
r={halfSize}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getXOffset = () => {
|
||||
return shape === "diamond" ? size * 0.7 : size / 2;
|
||||
};
|
||||
|
||||
const getYOffset = () => {
|
||||
return size / 2;
|
||||
};
|
||||
|
||||
const AnimatedG = animated.g;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size * (shape === "diamond" ? 1.4 : 1)}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<AnimatedG
|
||||
transform={`translate(0, 0)`}
|
||||
style={
|
||||
enableAnimation
|
||||
? {
|
||||
transform: springs.scale.to((s) => `scale(${s})`),
|
||||
transformOrigin: "center",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderShape()}
|
||||
</AnimatedG>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -14,3 +14,6 @@ export type {
|
||||
AbacusDisplayConfig,
|
||||
AbacusDisplayContextType,
|
||||
} from "./AbacusContext";
|
||||
|
||||
export { StandaloneBead } from "./StandaloneBead";
|
||||
export type { StandaloneBeadProps } from "./StandaloneBead";
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -134,6 +134,9 @@ importers:
|
||||
'@types/jsdom':
|
||||
specifier: ^21.1.7
|
||||
version: 21.1.7
|
||||
'@use-gesture/react':
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1(react@18.3.1)
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
|
||||
Reference in New Issue
Block a user