Compare commits

...

24 Commits

Author SHA1 Message Date
semantic-release-bot
0169ab5128 chore(release): 4.63.8 [skip ci]
## [4.63.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.7...v4.63.8) (2025-10-21)

### Bug Fixes

* **mobile:** restore abacus visibility in "Your Journey" section ([c96036d](c96036d86b))
2025-10-21 17:08:03 +00:00
Thomas Hallock
c96036d86b fix(mobile): restore abacus visibility in "Your Journey" section
On mobile screens (base breakpoint), the level details cards were taking
up all vertical space within the 500px maxHeight constraint, pushing the
abacus completely out of view.

Solution: Hide level details on mobile (display: { base: 'none', lg: 'grid' })
so mobile users see only the slider and abacus, while desktop users see
all components. Also added minH: 0 to containers to ensure proper flex
shrinking behavior.

Changes to LevelSliderDisplay.tsx:
- Level details grid now hidden on base breakpoint, visible on lg+
- Simplified grid columns to single value since it's desktop-only
- Added minH: 0 to flex containers for proper shrinking

Tested on iPhone 14 (390px viewport).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 12:06:48 -05:00
semantic-release-bot
653db575ff chore(release): 4.63.7 [skip ci]
## [4.63.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.6...v4.63.7) (2025-10-21)

### Bug Fixes

* **mobile:** reduce height of Your Journey section on mobile ([8944035](89440355bf))
2025-10-21 17:03:24 +00:00
Thomas Hallock
89440355bf fix(mobile): reduce height of Your Journey section on mobile
Added maxHeight constraint and reduced padding to ensure the abacus
stays visible while scrubbing the slider on mobile devices.

Changes:
- Added maxHeight: 500px on mobile (base), none on desktop (md)
- Reduced padding from 6 to 4 on mobile (base)

This ensures users can see both the slider and abacus simultaneously
on iPhone displays without scrolling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 12:02:10 -05:00
semantic-release-bot
632e840ca7 chore(release): 4.63.6 [skip ci]
## [4.63.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.5...v4.63.6) (2025-10-21)

### Bug Fixes

* **mobile:** optimize Your Journey section for iPhone displays ([9167fb4](9167fb40d6))
2025-10-21 16:57:23 +00:00
Thomas Hallock
9167fb40d6 fix(mobile): optimize Your Journey section for iPhone displays
Fixed horizontal overflow issues on small screens (iPhone 14, 390px viewport):

Changes to LevelSliderDisplay component:
- Made abacus display container stack vertically on mobile (base) and horizontal on large screens (lg)
- Made level details grid responsive: 2 cols (mobile), 3 cols (sm), 2 cols (lg)
- Removed fixed widths from detail cards, using flexible widths with min/max constraints
- Added horizontal scroll (overflow-x: auto) to abacus container as fallback
- Reduced slider thumb size on mobile: 120px x 96px (base) vs 180px x 128px (md)
- Scaled down bead in slider thumb to 75% on mobile
- Reduced emoji tick sizes: 2xl (mobile), 3xl (sm), 4xl (md)

These changes ensure the "Your Journey" slider and abacus display fit properly
on mobile devices without horizontal overflow while maintaining the desktop experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 11:56:07 -05:00
semantic-release-bot
1d7486ed48 chore(release): 4.63.5 [skip ci]
## [4.63.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.4...v4.63.5) (2025-10-21)

### Bug Fixes

* **flashcards:** store grab offset in local coordinates to prevent jump ([39d93a9](39d93a9e9f))
2025-10-21 16:29:31 +00:00
Thomas Hallock
39d93a9e9f fix(flashcards): store grab offset in local coordinates to prevent jump
Problem: Cards jumped when grabbed, especially if already rotated. The grab
point would slip during rotation instead of staying under the cursor.

Root cause: Grab offset was stored in screen space with the card already
rotated. When we later rotated this offset during drag, we were applying
rotation on top of rotation.

Solution: Convert grab offset to local (unrotated) coordinates when grabbing:
- Calculate screen-space offset from card center
- Rotate by -currentRotation to get local coordinates
- Store in grabOffsetRef
- During drag, rotate this local offset by the current rotation angle

This ensures the grab point stays perfectly under the cursor regardless of
the card's initial or current rotation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 11:28:18 -05:00
semantic-release-bot
6d1bad142b chore(release): 4.63.4 [skip ci]
## [4.63.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.3...v4.63.4) (2025-10-21)

### Bug Fixes

* **flashcards:** keep grab point under cursor with proper coordinate conversion ([1869216](1869216d2f))
2025-10-21 16:28:08 +00:00
Thomas Hallock
1869216d2f fix(flashcards): keep grab point under cursor with proper coordinate conversion
Fixed the issue where cards would slip out from under the cursor when rotating.
The grab point now stays perfectly under your cursor throughout the drag.

The problem: Simple delta positioning didn't account for rotation causing the
grab point to rotate away from the cursor position.

The solution: Properly convert between coordinate systems:
1. Calculate rotated grab offset (2D rotation matrix)
2. Determine card center: cursor position - rotated grab offset (viewport space)
3. Convert from viewport coordinates to container coordinates
4. Calculate top-left position from center for CSS translate()

Key changes:
- Pass containerRef down to DraggableCard components
- Get container bounds for viewport→container conversion
- Apply rotation matrix to grab offset
- Position card so rotated grab point aligns with cursor

Now when you grab a card by its edge and drag, the exact point you grabbed
stays glued to your cursor while the card rotates around its center.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 11:26:54 -05:00
semantic-release-bot
e4ae3aefef chore(release): 4.63.3 [skip ci]
## [4.63.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.2...v4.63.3) (2025-10-21)

### Bug Fixes

* **flashcards:** revert to simple delta positioning to prevent card jumping ([d018b69](d018b699c4))
2025-10-21 16:22:59 +00:00
Thomas Hallock
d018b699c4 fix(flashcards): revert to simple delta positioning to prevent card jumping
Reverted the complex rotation-compensated positioning that was causing
cards to jump to the right of the viewport on click.

The issue: The previous attempt to keep the grab point "stuck" to the cursor
while rotating was mixing screen coordinates with container-relative coords,
causing cards to teleport on pointer down.

The solution: Use simple delta positioning (cursor movement from drag start)
and let CSS handle rotation around the card center. While this means the grab
point won't stay perfectly under the cursor as the card rotates, it's much
better than cards jumping unexpectedly.

The rotation still works - cards rotate based on grab point physics - but
the positioning is now stable and predictable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 11:21:49 -05:00
semantic-release-bot
be323bfbc5 chore(release): 4.63.2 [skip ci]
## [4.63.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.1...v4.63.2) (2025-10-21)

### Bug Fixes

* **flashcards:** correct pivot point to rotate around card center ([50fc3fd](50fc3fdf7f))
2025-10-21 16:19:23 +00:00
Thomas Hallock
50fc3fdf7f fix(flashcards): correct pivot point to rotate around card center
Fixed issue where cards appeared to pivot around the grab point in
viewport space instead of rotating around their own center.

The problem: When dragging, the card would stay "pinned" at the grab
point in screen space, making it rotate around that viewport location.

The solution: As the card rotates, calculate the rotated grab offset
and reposition the card so that:
1. The grab point stays under the cursor
2. The card rotates around its own center
3. The visual result feels like the card is "stuck" to your finger

Implementation:
- Convert rotation angle to radians
- Apply 2D rotation matrix to grab offset vector
- Calculate card center position: cursor - rotated grab offset
- Convert center position to top-left for CSS translate positioning

This creates the natural feeling of grabbing and spinning a physical
card by its edge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 11:18:04 -05:00
semantic-release-bot
e52d907087 chore(release): 4.63.1 [skip ci]
## [4.63.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.0...v4.63.1) (2025-10-21)

### Bug Fixes

* **flashcards:** increase rotation sensitivity 10x for visible grab point physics ([c0fa926](c0fa926d16))
2025-10-21 16:08:00 +00:00
Thomas Hallock
c0fa926d16 fix(flashcards): increase rotation sensitivity 10x for visible grab point physics
The previous scale factor of 5000 made rotation changes too subtle to see.
Reduced to 500 (10x more sensitive) so card rotation is clearly visible
when dragging from off-center grab points.

Also added detailed rotation logging during drag to help debug:
- Shows current rotation angle
- Shows rotation influence being applied
- Shows cross product value from physics calculation

This will help test and tune the rotation feel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 11:06:48 -05:00
semantic-release-bot
1fd0474cd5 chore(release): 4.63.0 [skip ci]
## [4.63.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.62.1...v4.63.0) (2025-10-21)

### Features

* **flashcards:** add grab point physics for realistic rotation ([bf37eb1](bf37eb1928))
2025-10-21 16:05:21 +00:00
Thomas Hallock
bf37eb1928 feat(flashcards): add grab point physics for realistic rotation
Implements physics-based rotation that responds to where the user grabs
the card. When you grab a card off-center and drag it, it rotates
naturally based on the grab point and drag direction.

Features:
- Calculate grab offset from card center on pointer down
- Apply rotation using cross product of grab offset and drag direction
- Rotation clamped to ±45° to prevent excessive spinning
- Final rotation preserved when card is released
- Console logging for grab point coordinates and rotation changes

Physics details:
- Cross product (grabOffset.x * deltaY - grabOffset.y * deltaX) determines
  rotation direction and magnitude
- Grabbing left side + dragging right = clockwise rotation
- Grabbing right side + dragging left = counter-clockwise rotation
- Scale factor of 5000 provides smooth, realistic rotation feel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 11:04:07 -05:00
semantic-release-bot
9f56c9728c chore(release): 4.62.1 [skip ci]
## [4.62.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.62.0...v4.62.1) (2025-10-21)

### Bug Fixes

* **flashcards:** improve shadow speed logging with separate throttling ([0f51366](0f51366fd5))
2025-10-21 15:59:26 +00:00
Thomas Hallock
0f51366fd5 fix(flashcards): improve shadow speed logging with separate throttling
Fixed logging issue where speed logs weren't showing during drag:
- Added separate lastLogTimeRef for logging throttle
- Logs now appear every ~200ms during drag (was never logging before)
- Velocity calculation unchanged, only logging throttle fixed

Now you can see speed values in console during drag to verify
shadow responsiveness.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 10:58:07 -05:00
semantic-release-bot
fe1e8979c8 chore(release): 4.62.0 [skip ci]
## [4.62.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.3...v4.62.0) (2025-10-21)

### Features

* **flashcards:** add dynamic shadow based on drag speed ([92148a4](92148a4cf8))
2025-10-21 15:52:28 +00:00
Thomas Hallock
92148a4cf8 feat(flashcards): add dynamic shadow based on drag speed
First physics enhancement - shadow changes based on how fast you drag:
- Tracks drag velocity (distance/time) during pointer move
- Shadow grows larger and darker with faster dragging
- Base: 8px offset, 24px blur, 0.3 opacity
- Fast: 32px offset, 64px blur, 0.6 opacity
- Smooth decay when released

Console logging included:
- [Shadow] logs on drag start/release
- Speed/distance/time logged during drag (throttled to ~100ms)

Test: Drag cards slowly vs fast and watch shadow change

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 10:51:08 -05:00
semantic-release-bot
7088a7096a chore(release): 4.61.3 [skip ci]
## [4.61.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.2...v4.61.3) (2025-10-21)

### Code Refactoring

* **flashcards:** completely rewrite drag-drop with simple approach ([5f0ad14](5f0ad14133))
2025-10-21 15:45:03 +00:00
Thomas Hallock
5f0ad14133 refactor(flashcards): completely rewrite drag-drop with simple approach
Replaced complex react-spring + useDrag implementation with simple
PointerEvents-based drag and drop:

- Uses native onPointerDown/Move/Up events
- Tracks position with useState (no animation library)
- Cards stay exactly where dropped (no physics or snap-back)
- Simple scale-up effect while dragging
- Much more predictable and maintainable

Removed dependencies on:
- @react-spring/web
- @use-gesture/react
- Complex velocity tracking and decay physics
- Transform-origin calculations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 10:43:43 -05:00
4 changed files with 323 additions and 187 deletions

View File

@@ -1,3 +1,87 @@
## [4.63.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.7...v4.63.8) (2025-10-21)
### Bug Fixes
* **mobile:** restore abacus visibility in "Your Journey" section ([c96036d](https://github.com/antialias/soroban-abacus-flashcards/commit/c96036d86b6de2e25f7ecd3d00dd36221badc3b1))
## [4.63.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.6...v4.63.7) (2025-10-21)
### Bug Fixes
* **mobile:** reduce height of Your Journey section on mobile ([8944035](https://github.com/antialias/soroban-abacus-flashcards/commit/89440355bf494e54072d2d1a1f228c33ec43d52d))
## [4.63.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.5...v4.63.6) (2025-10-21)
### Bug Fixes
* **mobile:** optimize Your Journey section for iPhone displays ([9167fb4](https://github.com/antialias/soroban-abacus-flashcards/commit/9167fb40d68b7bdbe310b647083586434ceb6043))
## [4.63.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.4...v4.63.5) (2025-10-21)
### Bug Fixes
* **flashcards:** store grab offset in local coordinates to prevent jump ([39d93a9](https://github.com/antialias/soroban-abacus-flashcards/commit/39d93a9e9f48a7d1ce10763cad62a600851a41d5))
## [4.63.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.3...v4.63.4) (2025-10-21)
### Bug Fixes
* **flashcards:** keep grab point under cursor with proper coordinate conversion ([1869216](https://github.com/antialias/soroban-abacus-flashcards/commit/1869216d2fda77303c0b79d4f613c6dcdaf5324b))
## [4.63.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.2...v4.63.3) (2025-10-21)
### Bug Fixes
* **flashcards:** revert to simple delta positioning to prevent card jumping ([d018b69](https://github.com/antialias/soroban-abacus-flashcards/commit/d018b699c46aea90e9cdc3309e797ff2d7447ecf))
## [4.63.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.1...v4.63.2) (2025-10-21)
### Bug Fixes
* **flashcards:** correct pivot point to rotate around card center ([50fc3fd](https://github.com/antialias/soroban-abacus-flashcards/commit/50fc3fdf7f2c9b7412f6d7d890f5e0d52cb86a9b))
## [4.63.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.0...v4.63.1) (2025-10-21)
### Bug Fixes
* **flashcards:** increase rotation sensitivity 10x for visible grab point physics ([c0fa926](https://github.com/antialias/soroban-abacus-flashcards/commit/c0fa926d16d02c1bfe880b7f0056a760e8461b3b))
## [4.63.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.62.1...v4.63.0) (2025-10-21)
### Features
* **flashcards:** add grab point physics for realistic rotation ([bf37eb1](https://github.com/antialias/soroban-abacus-flashcards/commit/bf37eb1928de8d07673234e2faa1fa6268c45686))
## [4.62.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.62.0...v4.62.1) (2025-10-21)
### Bug Fixes
* **flashcards:** improve shadow speed logging with separate throttling ([0f51366](https://github.com/antialias/soroban-abacus-flashcards/commit/0f51366fd56540e691df4931b6350c03043484f1))
## [4.62.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.3...v4.62.0) (2025-10-21)
### Features
* **flashcards:** add dynamic shadow based on drag speed ([92148a4](https://github.com/antialias/soroban-abacus-flashcards/commit/92148a4cf87e828ba2e5ec1740fb51d9667c1d73))
## [4.61.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.2...v4.61.3) (2025-10-21)
### Code Refactoring
* **flashcards:** completely rewrite drag-drop with simple approach ([5f0ad14](https://github.com/antialias/soroban-abacus-flashcards/commit/5f0ad14133340d073e861f5721cb48e1abab03ff))
## [4.61.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.1...v4.61.2) (2025-10-21)

View File

@@ -1,9 +1,7 @@
'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 {
@@ -16,12 +14,11 @@ interface Flashcard {
}
/**
* InteractiveFlashcards - A fun, physics-based flashcard display
* Users can drag and throw flashcards around with realistic momentum
* InteractiveFlashcards - A fun flashcard display where you can drag cards around
* Cards stay where you drop them - simple and intuitive
*/
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(() => {
@@ -84,7 +81,7 @@ export function InteractiveFlashcards() {
})}
>
{cards.map((card) => (
<DraggableCard key={card.id} card={card} />
<DraggableCard key={card.id} card={card} containerRef={containerRef} />
))}
</div>
)
@@ -92,207 +89,245 @@ export function InteractiveFlashcards() {
interface DraggableCardProps {
card: Flashcard
containerRef: React.RefObject<HTMLDivElement>
}
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,
}))
function DraggableCard({ card, containerRef }: DraggableCardProps) {
// Track position - starts at initial, updates when dragged
const [position, setPosition] = useState({ x: card.initialX, y: card.initialY })
const [rotation, setRotation] = useState(card.initialRotation) // Now dynamic!
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 [isDragging, setIsDragging] = useState(false)
const [dragSpeed, setDragSpeed] = useState(0) // Speed for dynamic shadow
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)
// Track drag state
const dragStartRef = useRef<{ x: number; y: number; cardX: number; cardY: number } | null>(null)
const grabOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) // Offset from card center where grabbed
const baseRotationRef = useRef(card.initialRotation) // Starting rotation
const lastMoveTimeRef = useRef<number>(0)
const lastMovePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const lastLogTimeRef = useRef<number>(0) // Separate throttling for logging
const cardRef = useRef<HTMLDivElement>(null) // Reference to card element
// 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
const handlePointerDown = (e: React.PointerEvent) => {
setIsDragging(true)
setZIndex(1000) // Bring to front
setDragSpeed(0)
// xy is in viewport coordinates, convert to position relative to card
const clickRelativeToCard = {
x: xy[0] - cardRect.left,
y: xy[1] - cardRect.top,
}
// Capture the pointer
e.currentTarget.setPointerCapture(e.pointerId)
// 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')
// Update current position to where the card was dropped
currentPositionRef.current.x = currentPositionRef.current.x + mx
currentPositionRef.current.y = currentPositionRef.current.y + my
// First, snap the spring to the dropped position immediately
api.set({
x: currentPositionRef.current.x,
y: currentPositionRef.current.y,
})
// 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)
// Start position decay and rotation/scale animations
api.start({
x: {
velocity: throwVelocityX,
config: { decay: true },
},
y: {
velocity: throwVelocityY,
config: { decay: true },
},
scale: {
value: 1,
config: config.wobbly,
},
rotation: {
value: throwAngle + 90, // Card aligns with throw direction
config: config.wobbly,
},
onChange: (result) => {
// Continue updating position as card settles with momentum
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,
// Record where the drag started (pointer position and card position)
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
cardX: position.x,
cardY: position.y,
}
)
// Calculate grab offset from card center IN LOCAL COORDINATES (unrotated)
if (cardRef.current) {
const rect = cardRef.current.getBoundingClientRect()
const cardCenterX = rect.left + rect.width / 2
const cardCenterY = rect.top + rect.height / 2
// Screen-space offset from center
const screenOffsetX = e.clientX - cardCenterX
const screenOffsetY = e.clientY - cardCenterY
// Convert to local coordinates by rotating by -rotation
const currentRotationRad = (rotation * Math.PI) / 180
const cosRot = Math.cos(-currentRotationRad)
const sinRot = Math.sin(-currentRotationRad)
grabOffsetRef.current = {
x: screenOffsetX * cosRot - screenOffsetY * sinRot,
y: screenOffsetX * sinRot + screenOffsetY * cosRot,
}
console.log(
`[GrabPoint] Grabbed at local offset: (${grabOffsetRef.current.x.toFixed(0)}, ${grabOffsetRef.current.y.toFixed(0)})px (screen offset: ${screenOffsetX.toFixed(0)}, ${screenOffsetY.toFixed(0)}px, rotation: ${rotation.toFixed(1)}°)`
)
}
// Store the current rotation as the base for this drag
baseRotationRef.current = rotation
// Initialize velocity tracking
const now = Date.now()
lastMoveTimeRef.current = now
lastMovePositionRef.current = { x: e.clientX, y: e.clientY }
lastLogTimeRef.current = now
console.log('[Shadow] Drag started, speed reset to 0')
}
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging || !dragStartRef.current) return
// Calculate how far the pointer has moved since drag started
const deltaX = e.clientX - dragStartRef.current.x
const deltaY = e.clientY - dragStartRef.current.y
// Calculate velocity for dynamic shadow
const now = Date.now()
const timeDelta = now - lastMoveTimeRef.current
if (timeDelta > 0) {
// Distance moved since last frame
const distX = e.clientX - lastMovePositionRef.current.x
const distY = e.clientY - lastMovePositionRef.current.y
const distance = Math.sqrt(distX * distX + distY * distY)
// Speed in pixels per millisecond, then convert to reasonable scale
const speed = distance / timeDelta
const scaledSpeed = Math.min(speed * 100, 100) // Cap at 100 for reasonable shadow size
setDragSpeed(scaledSpeed)
// Log occasionally (every ~200ms) to avoid console spam
const timeSinceLastLog = now - lastLogTimeRef.current
if (timeSinceLastLog > 200) {
console.log(
`[Shadow] Speed: ${scaledSpeed.toFixed(1)}, distance: ${distance.toFixed(0)}px, timeDelta: ${timeDelta}ms`
)
lastLogTimeRef.current = now
}
lastMoveTimeRef.current = now
lastMovePositionRef.current = { x: e.clientX, y: e.clientY }
}
// Calculate rotation based on grab point physics
// Cross product of grab offset and drag direction determines rotation
// If grabbed on left and dragged right → clockwise rotation
// If grabbed on right and dragged left → counter-clockwise rotation
const crossProduct = grabOffsetRef.current.x * deltaY - grabOffsetRef.current.y * deltaX
const rotationInfluence = crossProduct / 500 // Reduced scale factor for more visible rotation
const newRotation = baseRotationRef.current + rotationInfluence
// Clamp rotation to prevent excessive spinning
const clampedRotation = Math.max(-45, Math.min(45, newRotation))
setRotation(clampedRotation)
// Log rotation changes occasionally (same throttle as shadow logging)
const timeSinceLastLog = now - lastLogTimeRef.current
if (timeSinceLastLog > 200) {
console.log(
`[GrabPoint] Rotation: ${clampedRotation.toFixed(1)}° (influence: ${rotationInfluence.toFixed(1)}°, cross: ${crossProduct.toFixed(0)})`
)
}
// Update card position - keep grab point under cursor while rotating
// Calculate the rotated grab offset
const rotationRad = (clampedRotation * Math.PI) / 180
const cosRot = Math.cos(rotationRad)
const sinRot = Math.sin(rotationRad)
// Rotate the grab offset by the current rotation angle
const rotatedGrabX = grabOffsetRef.current.x * cosRot - grabOffsetRef.current.y * sinRot
const rotatedGrabY = grabOffsetRef.current.x * sinRot + grabOffsetRef.current.y * cosRot
// Get container bounds for coordinate conversion
if (!containerRef.current || !cardRef.current) {
// Fallback to simple delta if refs not ready
setPosition({
x: dragStartRef.current.cardX + deltaX,
y: dragStartRef.current.cardY + deltaY,
})
return
}
const containerRect = containerRef.current.getBoundingClientRect()
const cardRect = cardRef.current.getBoundingClientRect()
// Current cursor position in viewport space
const cursorViewportX = e.clientX
const cursorViewportY = e.clientY
// Card center should be at: cursor - rotated grab offset (viewport space)
const cardCenterViewportX = cursorViewportX - rotatedGrabX
const cardCenterViewportY = cursorViewportY - rotatedGrabY
// Convert card center from viewport space to container space
const cardCenterContainerX = cardCenterViewportX - containerRect.left
const cardCenterContainerY = cardCenterViewportY - containerRect.top
// position.x/y represents translate() which positions the top-left corner
// So we need: top-left = center - (width/2, height/2)
setPosition({
x: cardCenterContainerX - cardRect.width / 2,
y: cardCenterContainerY - cardRect.height / 2,
})
}
const handlePointerUp = (e: React.PointerEvent) => {
setIsDragging(false)
dragStartRef.current = null
console.log('[Shadow] Drag released, speed decaying to 0')
console.log(
`[GrabPoint] Final rotation: ${rotation.toFixed(1)}° (base was ${baseRotationRef.current.toFixed(1)}°)`
)
// Gradually decay speed back to 0 for smooth shadow transition
const decayInterval = setInterval(() => {
setDragSpeed((prev) => {
const newSpeed = prev * 0.8 // Decay by 20% each frame
if (newSpeed < 1) {
clearInterval(decayInterval)
return 0
}
return newSpeed
})
}, 50) // Update every 50ms
// Release the pointer capture
e.currentTarget.releasePointerCapture(e.pointerId)
}
// Calculate dynamic shadow based on drag speed
// Base shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
// Fast drag: 0 32px 64px rgba(0, 0, 0, 0.6)
const shadowY = 8 + (dragSpeed / 100) * 24 // 8px to 32px
const shadowBlur = 24 + (dragSpeed / 100) * 40 // 24px to 64px
const shadowOpacity = 0.3 + (dragSpeed / 100) * 0.3 // 0.3 to 0.6
const boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0, 0, 0, ${shadowOpacity})`
return (
<animated.div
<div
ref={cardRef}
{...bind()}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
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,
transform: `translate(${position.x}px, ${position.y}px) rotate(${rotation}deg) scale(${isDragging ? 1.05 : 1})`,
zIndex,
touchAction: 'none',
cursor: 'grab',
cursor: isDragging ? 'grabbing' : 'grab',
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
className={css({
userSelect: 'none',
_active: {
cursor: 'grabbing',
},
})}
>
<div
style={{
boxShadow, // Dynamic shadow based on drag speed
}}
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)',
},
transition: 'box-shadow 0.1s', // Quick transition for responsive feel
})}
>
{/* Abacus visualization */}
@@ -317,6 +352,6 @@ function DraggableCard({ card }: DraggableCardProps) {
{card.number}
</div>
</div>
</animated.div>
</div>
)
}

View File

@@ -412,8 +412,9 @@ export function LevelSliderDisplay({
? 'violet.500'
: 'amber.500',
rounded: 'xl',
p: { base: '6', md: '8' },
p: { base: '4', md: '8' },
height: { base: 'auto', md: '700px' },
maxHeight: { base: '500px', md: 'none' },
display: 'flex',
flexDirection: 'column',
})}
@@ -448,7 +449,7 @@ export function LevelSliderDisplay({
key={index}
onClick={() => setCurrentIndex(index)}
className={css({
fontSize: '4xl',
fontSize: { base: '2xl', sm: '3xl', md: '4xl' },
opacity: index === currentIndex ? '1' : '0.3',
transition: 'all 0.2s',
cursor: 'pointer',
@@ -500,8 +501,8 @@ export function LevelSliderDisplay({
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
w: '180px',
h: '128px',
w: { base: '120px', md: '180px' },
h: { base: '96px', md: '128px' },
bg: 'transparent',
cursor: 'grab',
transition: 'transform 0.15s ease-out, left 0.3s ease-out',
@@ -514,7 +515,12 @@ export function LevelSliderDisplay({
_active: { cursor: 'grabbing' },
})}
>
<div className={css({ opacity: 0.75 })}>
<div
className={css({
opacity: 0.75,
transform: { base: 'scale(0.75)', md: 'scale(1)' },
})}
>
<StandaloneBead
size={128}
color={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
@@ -607,14 +613,16 @@ export function LevelSliderDisplay({
<div
className={css({
display: 'flex',
flexDirection: { base: 'column', lg: 'row' },
gap: '4',
p: '6',
p: { base: '4', md: '6' },
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.700',
overflow: 'hidden',
flex: 1,
minH: 0, // Allow flex shrinking
})}
>
{/* Level Details (only for Kyu levels) */}
@@ -633,12 +641,14 @@ export function LevelSliderDisplay({
<div
className={css({
flex: '0 0 auto',
display: 'grid',
display: { base: 'none', lg: 'grid' },
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '2',
p: '2',
w: '100%',
maxW: '400px',
alignContent: 'center',
justifyItems: 'center',
})}
>
{sections.map((section, idx) => {
@@ -666,8 +676,10 @@ export function LevelSliderDisplay({
justifyContent: 'center',
gap: '1.5',
opacity: hasData ? 1 : 0.3,
width: '170px',
height: '150px',
w: { base: '100%', sm: 'auto' },
minW: { sm: '140px' },
maxW: { base: '170px', sm: '170px' },
minH: '150px',
_hover: hasData
? {
borderColor: 'gray.500',
@@ -733,13 +745,18 @@ export function LevelSliderDisplay({
) : null
})()}
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
{/* Abacus (centered on mobile, right-aligned for Kyu on desktop, centered for Dan) */}
<div
className={css({
display: 'flex',
justifyContent: currentLevel.type === 'kyu' ? 'flex-end' : 'center',
justifyContent:
currentLevel.type === 'kyu' ? { base: 'center', lg: 'flex-end' } : 'center',
alignItems: 'center',
flex: 1,
overflowX: 'auto',
overflowY: 'hidden',
minW: 0, // Allow flex shrinking
minH: 0, // Allow flex shrinking
})}
>
<animated.div

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.61.2",
"version": "4.63.8",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [