Compare commits

...

14 Commits

Author SHA1 Message Date
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
3 changed files with 205 additions and 11 deletions

View File

@@ -1,3 +1,52 @@
## [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)

View File

@@ -81,7 +81,7 @@ export function InteractiveFlashcards() {
})}
>
{cards.map((card) => (
<DraggableCard key={card.id} card={card} />
<DraggableCard key={card.id} card={card} containerRef={containerRef} />
))}
</div>
)
@@ -89,21 +89,30 @@ export function InteractiveFlashcards() {
interface DraggableCardProps {
card: Flashcard
containerRef: React.RefObject<HTMLDivElement>
}
function DraggableCard({ card }: DraggableCardProps) {
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] = useState(card.initialRotation)
const [rotation, setRotation] = useState(card.initialRotation) // Now dynamic!
const [zIndex, setZIndex] = useState(card.zIndex)
const [isDragging, setIsDragging] = useState(false)
const [dragSpeed, setDragSpeed] = useState(0) // Speed for dynamic shadow
// 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
const handlePointerDown = (e: React.PointerEvent) => {
setIsDragging(true)
setZIndex(1000) // Bring to front
setDragSpeed(0)
// Capture the pointer
e.currentTarget.setPointerCapture(e.pointerId)
@@ -115,6 +124,31 @@ function DraggableCard({ card }: DraggableCardProps) {
cardX: position.x,
cardY: position.y,
}
// Calculate grab offset from card center
if (cardRef.current) {
const rect = cardRef.current.getBoundingClientRect()
const cardCenterX = rect.left + rect.width / 2
const cardCenterY = rect.top + rect.height / 2
grabOffsetRef.current = {
x: e.clientX - cardCenterX,
y: e.clientY - cardCenterY,
}
console.log(
`[GrabPoint] Grabbed at offset: (${grabOffsetRef.current.x.toFixed(0)}, ${grabOffsetRef.current.y.toFixed(0)})px from center`
)
}
// 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) => {
@@ -124,10 +158,95 @@ function DraggableCard({ card }: DraggableCardProps) {
const deltaX = e.clientX - dragStartRef.current.x
const deltaY = e.clientY - dragStartRef.current.y
// Update card position
// 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: dragStartRef.current.cardX + deltaX,
y: dragStartRef.current.cardY + deltaY,
x: cardCenterContainerX - cardRect.width / 2,
y: cardCenterContainerY - cardRect.height / 2,
})
}
@@ -135,12 +254,38 @@ function DraggableCard({ card }: DraggableCardProps) {
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 (
<div
ref={cardRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -159,20 +304,20 @@ function DraggableCard({ card }: DraggableCardProps) {
})}
>
<div
style={{
boxShadow, // Dynamic shadow based on drag speed
}}
className={css({
bg: 'white',
rounded: 'lg',
p: '4',
boxShadow: isDragging
? '0 16px 48px rgba(0, 0, 0, 0.5)'
: '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',
transition: 'box-shadow 0.1s', // Quick transition for responsive feel
})}
>
{/* Abacus visualization */}

View File

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