Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d978db9a | ||
|
|
e711c52757 | ||
|
|
009162e22c | ||
|
|
cd30944c5e | ||
|
|
3e58cb5f92 | ||
|
|
aba9f8a94d | ||
|
|
dc19622bbb | ||
|
|
1babfde328 | ||
|
|
76d6f19d51 | ||
|
|
9ad35e65d3 | ||
|
|
d362a770d6 | ||
|
|
095cdda4ca | ||
|
|
a1a135a858 | ||
|
|
7f516526fb | ||
|
|
9f706e9dce | ||
|
|
6410b21f82 | ||
|
|
3dc9f48d12 | ||
|
|
b6410c7c22 |
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,3 +1,65 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,24 +8,34 @@ import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
import { getAvailableGames } from '@/lib/arcade/game-registry'
|
||||
import { InteractiveFlashcards } from '@/components/InteractiveFlashcards'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
import { token } from '../../styled-system/tokens'
|
||||
|
||||
// Mini abacus that cycles through random 3-digit numbers
|
||||
function MiniAbacus() {
|
||||
const [currentValue, setCurrentValue] = useState(123)
|
||||
// Mini abacus that cycles through a sequence of values
|
||||
function MiniAbacus({
|
||||
values,
|
||||
columns = 3,
|
||||
interval = 2500,
|
||||
}: {
|
||||
values: number[]
|
||||
columns?: number
|
||||
interval?: number
|
||||
}) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
useEffect(() => {
|
||||
// Cycle through random 3-digit numbers every 2.5 seconds
|
||||
const interval = setInterval(() => {
|
||||
const randomNum = Math.floor(Math.random() * 1000) // 0-999
|
||||
setCurrentValue(randomNum)
|
||||
}, 2500)
|
||||
if (values.length === 0) return
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % values.length)
|
||||
}, interval)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [values, interval])
|
||||
|
||||
// Dark theme styles for the abacus
|
||||
const darkStyles = {
|
||||
@@ -53,8 +63,8 @@ function MiniAbacus() {
|
||||
>
|
||||
<div className={css({ transform: 'scale(0.6)', transformOrigin: 'center center' })}>
|
||||
<AbacusReact
|
||||
value={currentValue}
|
||||
columns={3}
|
||||
value={values[currentIndex] || 0}
|
||||
columns={columns}
|
||||
beadShape={appConfig.beadShape}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
@@ -64,15 +74,46 @@ function MiniAbacus() {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
// Extract just the "Friends of 5" step (2+3=5) for homepage demo
|
||||
const [selectedSkillIndex, setSelectedSkillIndex] = useState(1) // Default to "Friends techniques"
|
||||
const fullTutorial = getTutorialForEditor()
|
||||
const friendsOf5Tutorial = {
|
||||
...fullTutorial,
|
||||
id: 'friends-of-5-demo',
|
||||
title: 'Friends of 5',
|
||||
description: 'Learn the "Friends of 5" technique: adding 3 to make 5',
|
||||
steps: fullTutorial.steps.filter((step) => step.id === 'complement-2'),
|
||||
}
|
||||
|
||||
// Create different tutorials for each skill level
|
||||
const skillTutorials = [
|
||||
// Skill 0: Read and set numbers (0-9999)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'read-numbers-demo',
|
||||
title: 'Read and Set Numbers',
|
||||
description: 'Master abacus number representation from zero to thousands',
|
||||
steps: fullTutorial.steps.filter((step) => step.id.startsWith('basic-')),
|
||||
},
|
||||
// Skill 1: Friends techniques (5 = 2+3)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'friends-of-5-demo',
|
||||
title: 'Friends of 5',
|
||||
description: 'Add and subtract using complement pairs: 5 = 2+3',
|
||||
steps: fullTutorial.steps.filter((step) => step.id === 'complement-2'),
|
||||
},
|
||||
// Skill 2: Multiply & divide (12×34)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'multiply-demo',
|
||||
title: 'Multiplication',
|
||||
description: 'Fluent multi-digit calculations with advanced techniques',
|
||||
steps: fullTutorial.steps.filter((step) => step.id.includes('complement')).slice(0, 3),
|
||||
},
|
||||
// Skill 3: Mental calculation (Speed math)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'mental-calc-demo',
|
||||
title: 'Mental Calculation',
|
||||
description: 'Visualize and compute without the physical tool (Anzan)',
|
||||
steps: fullTutorial.steps.slice(-3),
|
||||
},
|
||||
]
|
||||
|
||||
const selectedTutorial = skillTutorials[selectedSkillIndex]
|
||||
|
||||
return (
|
||||
<HomeHeroProvider>
|
||||
@@ -123,9 +164,16 @@ export default function HomePage() {
|
||||
})}
|
||||
>
|
||||
{/* Tutorial on the left */}
|
||||
<div className={css({ flex: '1' })}>
|
||||
<div
|
||||
className={css({
|
||||
flex: '1',
|
||||
minW: { base: '100%', md: '500px' },
|
||||
maxW: { base: '100%', md: '500px' },
|
||||
})}
|
||||
>
|
||||
<TutorialPlayer
|
||||
tutorial={friendsOf5Tutorial}
|
||||
key={selectedTutorial.id}
|
||||
tutorial={selectedTutorial}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
hideNavigation={true}
|
||||
@@ -140,8 +188,7 @@ export default function HomePage() {
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
minW: '340px',
|
||||
maxW: { base: '100%', md: '420px' },
|
||||
w: { base: '100%', md: '420px' },
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
@@ -157,122 +204,145 @@ export default function HomePage() {
|
||||
<div className={stack({ gap: '5' })}>
|
||||
{[
|
||||
{
|
||||
icon: '🔢',
|
||||
title: 'Read and set numbers',
|
||||
desc: 'Master abacus number representation from zero to thousands',
|
||||
example: '0-9999',
|
||||
badge: 'Foundation',
|
||||
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
|
||||
columns: 3,
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Friends techniques',
|
||||
desc: 'Add and subtract using complement pairs and mental shortcuts',
|
||||
example: '5 = 2+3',
|
||||
badge: 'Core',
|
||||
values: [2, 5, 3],
|
||||
columns: 1,
|
||||
},
|
||||
{
|
||||
icon: '✖️➗',
|
||||
title: 'Multiply & divide',
|
||||
desc: 'Fluent multi-digit calculations with advanced techniques',
|
||||
example: '12×34',
|
||||
badge: 'Advanced',
|
||||
values: [12, 24, 36, 48],
|
||||
columns: 2,
|
||||
},
|
||||
{
|
||||
icon: '🧠',
|
||||
title: 'Mental calculation',
|
||||
desc: 'Visualize and compute without the physical tool (Anzan)',
|
||||
example: 'Speed math',
|
||||
badge: 'Expert',
|
||||
values: [7, 14, 21, 28, 35],
|
||||
columns: 2,
|
||||
},
|
||||
].map((skill, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
bg: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
|
||||
borderRadius: 'xl',
|
||||
p: '4',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
|
||||
borderColor: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
width: '75px',
|
||||
height: '115px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'lg',
|
||||
})}
|
||||
>
|
||||
{i === 0 ? <MiniAbacus /> : skill.icon}
|
||||
</div>
|
||||
<div className={stack({ gap: '2', flex: '1' })}>
|
||||
<div className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{skill.title}
|
||||
].map((skill, i) => {
|
||||
const isSelected = i === selectedSkillIndex
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedSkillIndex(i)}
|
||||
className={css({
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
|
||||
borderRadius: 'xl',
|
||||
p: '4',
|
||||
border: '1px solid',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.4)'
|
||||
: 'rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: isSelected
|
||||
? '0 6px 16px rgba(250, 204, 21, 0.2)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.5)'
|
||||
: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: isSelected
|
||||
? '0 8px 20px rgba(250, 204, 21, 0.3)'
|
||||
: '0 6px 16px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
width: '75px',
|
||||
height: '115px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
bg: isSelected
|
||||
? 'rgba(250, 204, 21, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'lg',
|
||||
})}
|
||||
>
|
||||
<MiniAbacus values={skill.values} columns={skill.columns} />
|
||||
</div>
|
||||
<div className={stack({ gap: '2', flex: '1' })}>
|
||||
<div className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{skill.title}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(250, 204, 21, 0.2)',
|
||||
color: 'yellow.400',
|
||||
fontSize: '2xs',
|
||||
fontWeight: 'semibold',
|
||||
px: '2',
|
||||
py: '0.5',
|
||||
borderRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{skill.badge}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'gray.300',
|
||||
fontSize: 'xs',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{skill.desc}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(250, 204, 21, 0.2)',
|
||||
color: 'yellow.400',
|
||||
fontSize: '2xs',
|
||||
fontSize: 'xs',
|
||||
fontFamily: 'mono',
|
||||
fontWeight: 'semibold',
|
||||
mt: '1',
|
||||
bg: 'rgba(250, 204, 21, 0.1)',
|
||||
px: '2',
|
||||
py: '0.5',
|
||||
py: '1',
|
||||
borderRadius: 'md',
|
||||
w: 'fit-content',
|
||||
})}
|
||||
>
|
||||
{skill.badge}
|
||||
{skill.example}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'gray.300',
|
||||
fontSize: 'xs',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{skill.desc}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'yellow.400',
|
||||
fontSize: 'xs',
|
||||
fontFamily: 'mono',
|
||||
fontWeight: 'semibold',
|
||||
mt: '1',
|
||||
bg: 'rgba(250, 204, 21, 0.1)',
|
||||
px: '2',
|
||||
py: '1',
|
||||
borderRadius: 'md',
|
||||
w: 'fit-content',
|
||||
})}
|
||||
>
|
||||
{skill.example}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,85 +368,24 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
|
||||
<GameCard
|
||||
icon="🧠"
|
||||
title="Memory Lightning"
|
||||
description="Memorize soroban numbers"
|
||||
players="1-8 players"
|
||||
tags={['Memory', 'Pattern Recognition']}
|
||||
gradient="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<GameCard
|
||||
icon="⚔️"
|
||||
title="Matching Pairs"
|
||||
description="Match complement numbers"
|
||||
players="1-4 players"
|
||||
tags={['Friends of 5', 'Friends of 10']}
|
||||
gradient="linear-gradient(135deg, #f093fb 0%, #f5576c 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<GameCard
|
||||
icon="🏁"
|
||||
title="Complement Race"
|
||||
description="Race against time"
|
||||
players="1-4 players"
|
||||
tags={['Speed', 'Practice', 'Survival']}
|
||||
gradient="linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<GameCard
|
||||
icon="🔢"
|
||||
title="Card Sorting"
|
||||
description="Arrange numbers visually"
|
||||
players="Solo challenge"
|
||||
tags={['Visual Literacy', 'Ordering']}
|
||||
gradient="linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* For Kids & Families Section */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
For Kids & Families
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Simple enough for kids to start on their own, structured enough for parents to
|
||||
trust
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '8' })}>
|
||||
<FeaturePanel
|
||||
icon="🧒"
|
||||
title="Self-Directed for Children"
|
||||
features={[
|
||||
'Big, obvious buttons and clear instructions',
|
||||
'Progress at your own pace',
|
||||
'Works with or without a physical abacus',
|
||||
]}
|
||||
accentColor="purple"
|
||||
/>
|
||||
<FeaturePanel
|
||||
icon="👨👩👧"
|
||||
title="Trusted by Parents"
|
||||
features={[
|
||||
'Structured curriculum following Japanese methods',
|
||||
'Traditional kyu/dan progression levels',
|
||||
'Track progress and celebrate achievements',
|
||||
]}
|
||||
accentColor="blue"
|
||||
/>
|
||||
{getAvailableGames().map((game) => {
|
||||
const playersText =
|
||||
game.manifest.maxPlayers === 1
|
||||
? 'Solo challenge'
|
||||
: `1-${game.manifest.maxPlayers} players`
|
||||
return (
|
||||
<GameCard
|
||||
key={game.manifest.name}
|
||||
icon={game.manifest.icon}
|
||||
title={game.manifest.displayName}
|
||||
description={game.manifest.description}
|
||||
players={playersText}
|
||||
tags={game.manifest.chips}
|
||||
gradient={game.manifest.gradient}
|
||||
href="/games"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -540,8 +549,8 @@ export default function HomePage() {
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Additional Tools Section */}
|
||||
<section className={stack({ gap: '6' })}>
|
||||
{/* Flashcard Generator Section */}
|
||||
<section className={stack({ gap: '8', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
@@ -551,36 +560,123 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Additional Tools
|
||||
Create Custom Flashcards
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Design beautiful flashcards for learning and practice
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '8' })}>
|
||||
<FeaturePanel
|
||||
icon="🎨"
|
||||
title="Flashcard Creator"
|
||||
features={[
|
||||
'Multiple formats: PDF, PNG, SVG, HTML',
|
||||
'Custom bead shapes, colors, and layouts',
|
||||
'All paper sizes: A3, A4, A5, US Letter',
|
||||
]}
|
||||
accentColor="blue"
|
||||
ctaText="Create Flashcards →"
|
||||
ctaHref="/create"
|
||||
/>
|
||||
<FeaturePanel
|
||||
icon="🧮"
|
||||
title="Interactive Abacus"
|
||||
features={[
|
||||
'Practice anytime in your browser',
|
||||
'Multiple color schemes and bead styles',
|
||||
'Sound effects and animations',
|
||||
]}
|
||||
accentColor="purple"
|
||||
ctaText="Try the Abacus →"
|
||||
ctaHref="/guide"
|
||||
/>
|
||||
{/* Interactive Flashcards Display */}
|
||||
<div
|
||||
className={css({
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
mb: '8',
|
||||
})}
|
||||
>
|
||||
<InteractiveFlashcards />
|
||||
</div>
|
||||
|
||||
{/* Features and CTA */}
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'block',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
transform: 'translateY(-4px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: 'blue.500',
|
||||
shadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Features */}
|
||||
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
|
||||
{[
|
||||
{
|
||||
icon: '📄',
|
||||
title: 'Multiple Formats',
|
||||
desc: 'PDF, PNG, SVG, HTML',
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customizable',
|
||||
desc: 'Bead shapes, colors, layouts',
|
||||
},
|
||||
{
|
||||
icon: '📐',
|
||||
title: 'All Paper Sizes',
|
||||
desc: 'A3, A4, A5, US Letter',
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
p: '4',
|
||||
rounded: 'lg',
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
mb: '1',
|
||||
})}
|
||||
>
|
||||
{feature.title}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>
|
||||
{feature.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
px: '6',
|
||||
py: '3',
|
||||
rounded: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'blue.500',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>Create Flashcards</span>
|
||||
<span>→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -607,48 +703,112 @@ function GameCard({
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<Link href={href} style={{ height: '100%', display: 'block' }}>
|
||||
<div
|
||||
style={{ background: gradient }}
|
||||
className={css({
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'lg',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
_hover: {
|
||||
transform: 'translateY(-6px) scale(1.02)',
|
||||
shadow: '0 25px 50px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3xl', mb: '3' })}>{icon}</div>
|
||||
<h3 className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'white', mb: '2' })}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={css({ fontSize: 'sm', color: 'rgba(255, 255, 255, 0.9)', mb: '2' })}>
|
||||
{description}
|
||||
</p>
|
||||
<p className={css({ fontSize: 'xs', color: 'rgba(255, 255, 255, 0.7)', mb: '3' })}>
|
||||
{players}
|
||||
</p>
|
||||
<div className={hstack({ gap: '2', flexWrap: 'wrap' })}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
fontWeight: 'semibold',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{/* Vibrant gradient background */}
|
||||
<div
|
||||
style={{ background: gradient }}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
})}
|
||||
/>
|
||||
{/* Dark gradient overlay for text readability */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.5) 100%)',
|
||||
zIndex: 1,
|
||||
})}
|
||||
/>
|
||||
{/* Content */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
mb: '3',
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
textShadow: '0 2px 8px rgba(0, 0, 0, 0.5)',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
mb: '2',
|
||||
textShadow: '0 1px 4px rgba(0, 0, 0, 0.4)',
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
mb: '3',
|
||||
textShadow: '0 1px 4px rgba(0, 0, 0, 0.4)',
|
||||
})}
|
||||
>
|
||||
{players}
|
||||
</p>
|
||||
<div className={hstack({ gap: '2', flexWrap: 'wrap' })}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
fontWeight: 'semibold',
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.4)',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
191
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
191
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { animated, config, 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
|
||||
}
|
||||
|
||||
const CONTAINER_WIDTH = 800
|
||||
const CONTAINER_HEIGHT = 500
|
||||
|
||||
/**
|
||||
* InteractiveFlashcards - A fun, physics-based flashcard display
|
||||
* Users can drag and throw flashcards around with realistic momentum
|
||||
*/
|
||||
export function InteractiveFlashcards() {
|
||||
// Generate 8-15 random flashcards (client-side only to avoid hydration errors)
|
||||
const [cards, setCards] = useState<Flashcard[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const count = Math.floor(Math.random() * 8) + 8 // 8-15 cards
|
||||
const generated: Flashcard[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
generated.push({
|
||||
id: i,
|
||||
number: Math.floor(Math.random() * 900) + 100, // 100-999
|
||||
initialX: Math.random() * (CONTAINER_WIDTH - 200) + 100,
|
||||
initialY: Math.random() * (CONTAINER_HEIGHT - 200) + 100,
|
||||
initialRotation: Math.random() * 40 - 20, // -20 to 20 degrees
|
||||
zIndex: i,
|
||||
})
|
||||
}
|
||||
|
||||
setCards(generated)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
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)',
|
||||
})}
|
||||
>
|
||||
{/* Hint text */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '4',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
color: 'white',
|
||||
opacity: '0.6',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
>
|
||||
Drag and throw the flashcards!
|
||||
</div>
|
||||
|
||||
{cards.map((card) => (
|
||||
<DraggableCard key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DraggableCardProps {
|
||||
card: Flashcard
|
||||
}
|
||||
|
||||
function DraggableCard({ card }: DraggableCardProps) {
|
||||
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 bind = useDrag(
|
||||
({ down, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy] }) => {
|
||||
// Bring card to front when dragging
|
||||
if (down) {
|
||||
setZIndex(1000)
|
||||
}
|
||||
|
||||
api.start({
|
||||
x: down ? card.initialX + mx : card.initialX + mx + vx * 100 * dx,
|
||||
y: down ? card.initialY + my : card.initialY + my + vy * 100 * dy,
|
||||
scale: down ? 1.1 : 1,
|
||||
rotation: down ? card.initialRotation + mx / 20 : card.initialRotation + vx * 10,
|
||||
immediate: down,
|
||||
config: down ? config.stiff : config.wobbly,
|
||||
})
|
||||
|
||||
// Update initial position after release for next drag
|
||||
if (!down) {
|
||||
card.initialX = card.initialX + mx + vx * 100 * dx
|
||||
card.initialY = card.initialY + my + vy * 100 * dy
|
||||
card.initialRotation = card.initialRotation + vx * 10
|
||||
}
|
||||
},
|
||||
{
|
||||
// Prevent scrolling when dragging
|
||||
preventDefault: true,
|
||||
filterTaps: true,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
{...bind()}
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
scale,
|
||||
zIndex,
|
||||
position: 'absolute',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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.48.3",
|
||||
"version": "4.52.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
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