hensei-web/docs/drag-drop-prd.md

1003 lines
No EOL
27 KiB
Markdown

# Drag-and-Drop Implementation PRD
## Overview
Custom drag-and-drop solution for Granblue Fantasy party management grids (CharacterGrid, SummonGrid, WeaponGrid) using Svelte 5 runes. This is a client-side prototype focused on interaction patterns, built with API integration in mind for future persistence.
## Requirements
### CharacterGrid
- **Layout**: 5 horizontal slots
- **Behavior**: Sequential filling (no gaps allowed)
- **Drag Rules**:
- All slots are draggable and droppable
- Empty slots automatically sort to the end
- Dragging to reorder maintains sequential filling
### SummonGrid
- **Layout**: Main + Friend (vertical) + 2x2 sub-summons
- **Positions**:
- Main: position -1 (non-draggable)
- Sub-summons: positions 0-3 (draggable)
- Friend: position 6 (non-draggable)
- **Drag Rules**:
- Sub-summons can have gaps
- Can swap filled slots
- Can move to empty slots
### WeaponGrid
- **Layout**: Mainhand (vertical) + 3x3 sub-weapons
- **Positions**:
- Mainhand: position -1 (non-draggable)
- Sub-weapons: positions 0-8 (draggable)
- **Drag Rules**:
- Sub-weapons can have gaps
- Can swap filled slots
- Can move to empty slots
### Cross-Container Support
- Extra characters (separate container)
- Subaura summons (positions 4-5)
- Extra weapons (positions 9+)
## Implementation Phases
## Phase 1: Core Drag-and-Drop System ✅ COMPLETED
### Tasks:
- [x] Create core drag-and-drop composable (`$lib/composables/drag-drop.svelte.ts`)
- [x] Implement reactive drag state management using runes
- [x] Add drag source and drop target tracking
- [x] Create coordinate and element detection helpers
- [x] Implement unified pointer event handlers (mouse/touch)
- [x] Add operation queue for future API sync
- [x] Implement error recovery mechanisms
- [x] Create draggable item wrapper component (`$lib/components/dnd/DraggableItem.svelte`)
- [x] Wrap unit components (CharacterUnit, WeaponUnit, SummonUnit)
- [x] Handle drag start/end events
- [x] Add visual feedback (opacity, cursor changes)
- [x] Implement custom drag preview/ghost image
- [x] Add touch gesture support with long-press detection
- [x] Create drop zone component (`$lib/components/dnd/DropZone.svelte`)
- [x] Manage drop targets
- [x] Implement hover state detection
- [x] Add validation callbacks with console.log
- [x] Implement visual feedback for valid/invalid drops
- [x] Handle touch hover simulation
## Phase 2: Grid-Specific Implementation
### CharacterGrid Tasks:
- [ ] Implement draggable character slots
- [ ] Add sequential filling logic (no gaps allowed)
- [ ] Implement auto-sort to move empty slots to end
- [ ] Add position swapping between characters
- [ ] Test character reordering
- [ ] Add operation recording for future API sync
### SummonGrid Tasks:
- [ ] Make main/friend slots non-draggable
- [ ] Implement draggable sub-summons (2x2 grid)
- [ ] Allow gaps between filled slots
- [ ] Implement position swapping between filled slots
- [ ] Add move to empty slot functionality
- [ ] Test all summon grid interactions
- [ ] Add operation recording for future API sync
### WeaponGrid Tasks:
- [ ] Make mainhand slot non-draggable
- [ ] Implement draggable sub-weapons (3x3 grid)
- [ ] Allow gaps between filled slots
- [ ] Implement position swapping between filled slots
- [ ] Add move to empty slot functionality
- [ ] Test all weapon grid interactions
- [ ] Add operation recording for future API sync
## Phase 3: Cross-Container Dragging
### Tasks:
- [ ] Enable dragging between main and extra character slots
- [ ] Enable dragging between main and subaura summon slots
- [ ] Enable dragging between main and extra weapon slots
- [ ] Add validation rules with console.log output
- [ ] Test cross-container interactions
- [ ] Implement operation batching for complex moves
## Phase 4: Testing & Polish
### Tasks:
- [ ] Create comprehensive test route at `/test/drag-drop`
- [ ] Add mock data for all grid types
- [ ] Test all drag scenarios thoroughly
- [ ] Test touch device interactions
- [ ] Add smooth animations and transitions
- [ ] Optimize performance for smooth dragging
- [ ] Add visual indicators for drag states
- [ ] Test error recovery scenarios
## Phase 5: API Integration Preparation
### Tasks:
- [ ] Review operation queue implementation
- [ ] Add optimistic UI updates pattern
- [ ] Implement rollback mechanism for failed operations
- [ ] Add sync status indicators
- [ ] Document API contract requirements
## Technical Design
### Core Composable Structure with API-Ready State
```typescript
// $lib/composables/drag-drop.svelte.ts
// Operation types for future API sync
interface DragOperation {
id: string
type: 'move' | 'swap' | 'reorder'
timestamp: number
source: {
container: string
position: number
itemId: string
}
target: {
container: string
position: number
itemId?: string
}
status: 'pending' | 'synced' | 'failed'
retryCount: number
}
interface TouchState {
touchStartPos: { x: number, y: number } | null
touchStartTime: number
longPressTimer: number | null
touchThreshold: number // 10px minimum movement to start drag
longPressDuration: number // 500ms for long press
currentTouch: Touch | null
}
interface DragState {
isDragging: boolean
draggedItem: {
type: 'character' | 'weapon' | 'summon'
data: GridCharacter | GridWeapon | GridSummon
source: {
container: string
position: number
}
} | null
hoveredOver: {
container: string
position: number
} | null
validDrop: boolean
dragPreview: HTMLElement | null
operationQueue: DragOperation[]
lastError: Error | null
touchState: TouchState
}
export function createDragDropContext() {
const state = $state<DragState>({
isDragging: false,
draggedItem: null,
hoveredOver: null,
validDrop: false,
dragPreview: null,
operationQueue: [],
lastError: null,
touchState: {
touchStartPos: null,
touchStartTime: 0,
longPressTimer: null,
touchThreshold: 10,
longPressDuration: 500,
currentTouch: null
}
})
// Unified pointer event handling
function handlePointerDown(e: PointerEvent, item: GridItem, source: Position) {
if (e.pointerType === 'touch') {
initiateTouchDrag(e, item, source)
} else {
// Mouse handling remains immediate
startDrag(item, source)
}
}
function initiateTouchDrag(e: PointerEvent, item: GridItem, source: Position) {
state.touchState.touchStartPos = { x: e.clientX, y: e.clientY }
state.touchState.touchStartTime = Date.now()
// Long press timer for touch devices
state.touchState.longPressTimer = window.setTimeout(() => {
startDrag(item, source)
// Haptic feedback if available
if ('vibrate' in navigator) {
navigator.vibrate(50)
}
}, state.touchState.longPressDuration)
}
function handlePointerMove(e: PointerEvent) {
if (!state.touchState.touchStartPos) return
const distance = Math.sqrt(
Math.pow(e.clientX - state.touchState.touchStartPos.x, 2) +
Math.pow(e.clientY - state.touchState.touchStartPos.y, 2)
)
// Cancel long press if user moves too much
if (distance > state.touchState.touchThreshold && state.touchState.longPressTimer) {
clearTimeout(state.touchState.longPressTimer)
state.touchState.longPressTimer = null
}
}
// Core drag operations with error handling
function startDrag(item: GridItem, source: Position) {
try {
state.isDragging = true
state.draggedItem = {
type: detectItemType(item),
data: item,
source
}
createDragPreview(item)
} catch (error) {
handleDragError(error as Error)
}
}
function createDragPreview(item: GridItem) {
const preview = document.createElement('div')
preview.className = 'drag-preview'
preview.innerHTML = `
<div class="drag-preview-content">
<img src="${item.icon}" alt="${item.name}" />
<span>${item.name}</span>
</div>
`
document.body.appendChild(preview)
state.dragPreview = preview
}
function endDrag() {
try {
if (state.validDrop && state.draggedItem && state.hoveredOver) {
// Record operation for future API sync
const operation: DragOperation = {
id: crypto.randomUUID(),
type: determineOperationType(state.draggedItem.source, state.hoveredOver),
timestamp: Date.now(),
source: {
...state.draggedItem.source,
itemId: state.draggedItem.data.id
},
target: state.hoveredOver,
status: 'pending',
retryCount: 0
}
// Queue operation
state.operationQueue.push(operation)
// Perform local state update (optimistic)
performLocalUpdate(operation)
// Future: Trigger API sync here
// scheduleSyncOperation(operation)
}
} catch (error) {
handleDragError(error as Error)
} finally {
cleanupDragState()
}
}
function handleDragError(error: Error) {
console.error('🔥 Drag operation failed:', error)
state.lastError = error
// Rollback UI state if needed
rollbackLastOperation()
// Reset drag state
cleanupDragState()
// Could emit error event or show toast
// eventBus.emit('drag-error', error)
}
function cleanupDragState() {
state.isDragging = false
state.draggedItem = null
state.hoveredOver = null
state.validDrop = false
// Clean up drag preview
if (state.dragPreview) {
state.dragPreview.remove()
state.dragPreview = null
}
// Clear touch state
if (state.touchState.longPressTimer) {
clearTimeout(state.touchState.longPressTimer)
}
state.touchState = {
...state.touchState,
touchStartPos: null,
longPressTimer: null,
currentTouch: null
}
}
function rollbackLastOperation() {
const lastOp = state.operationQueue.at(-1)
if (lastOp && lastOp.status === 'pending') {
// Revert the optimistic update
console.log('🔄 Rolling back operation:', lastOp.id)
// Implementation depends on grid type
state.operationQueue = state.operationQueue.filter(op => op.id !== lastOp.id)
}
}
// API sync preparation
async function syncOperations() {
const pending = state.operationQueue.filter(op => op.status === 'pending')
for (const operation of pending) {
try {
// Future API call would go here
// await api.syncDragOperation(operation)
operation.status = 'synced'
console.log('✅ Operation synced:', operation.id)
} catch (error) {
operation.status = 'failed'
operation.retryCount++
if (operation.retryCount < 3) {
// Retry later
setTimeout(() => retryOperation(operation), 1000 * operation.retryCount)
} else {
// Max retries reached, handle failure
handleSyncFailure(operation)
}
}
}
}
function validateDrop(source: Position, target: Position): boolean {
console.group('🎯 Drop Validation')
console.log('Source:', source)
console.log('Target:', target)
try {
// Check item type compatibility
if (!isCompatibleType(source, target)) {
console.log('❌ Type mismatch')
return false
}
// Check position restrictions
if (!isValidPosition(target)) {
console.log('❌ Invalid target position')
return false
}
console.log('✅ Drop allowed')
return true
} catch (error) {
console.error('❌ Validation error:', error)
return false
} finally {
console.groupEnd()
}
}
return {
get state() { return state },
handlePointerDown,
handlePointerMove,
startDrag,
updateHover,
endDrag,
validateDrop,
syncOperations,
getQueuedOperations: () => state.operationQueue.filter(op => op.status === 'pending'),
clearQueue: () => { state.operationQueue = [] }
}
}
```
### Enhanced Component Integration
```svelte
<!-- DraggableItem.svelte -->
<script lang="ts">
import { getContext, onMount } from 'svelte'
interface Props {
item: GridItem
container: string
position: number
canDrag?: boolean
customPreview?: boolean
}
let { item, container, position, canDrag = true, customPreview = false }: Props = $props()
const dragContext = getContext('drag-drop')
let elementRef: HTMLElement
// Handle both mouse and touch events
function handleDragStart(e: DragEvent) {
if (!canDrag) {
e.preventDefault()
return
}
// Custom drag preview
if (customPreview) {
const ghost = createCustomGhost()
e.dataTransfer?.setDragImage(ghost, e.offsetX, e.offsetY)
// Clean up ghost after frame
requestAnimationFrame(() => ghost.remove())
}
// Set drag data
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData('application/json', JSON.stringify({
item,
container,
position
}))
dragContext.startDrag(item, { container, position })
}
function createCustomGhost(): HTMLElement {
const ghost = document.createElement('div')
ghost.className = 'drag-ghost'
ghost.style.position = 'absolute'
ghost.style.top = '-1000px'
ghost.style.left = '-1000px'
ghost.style.transform = 'rotate(5deg)'
ghost.style.opacity = '0.8'
// Clone the dragged element's content
ghost.innerHTML = elementRef.innerHTML
document.body.appendChild(ghost)
return ghost
}
// Touch support via pointer events
function handlePointerDown(e: PointerEvent) {
if (!canDrag) return
dragContext.handlePointerDown(e, item, { container, position })
}
function handlePointerMove(e: PointerEvent) {
dragContext.handlePointerMove(e)
}
function handlePointerUp(e: PointerEvent) {
if (dragContext.state.touchState.longPressTimer) {
clearTimeout(dragContext.state.touchState.longPressTimer)
}
}
onMount(() => {
// Add touch event listeners for better mobile support
if ('ontouchstart' in window) {
elementRef?.addEventListener('touchstart', handleTouchStart, { passive: false })
elementRef?.addEventListener('touchmove', handleTouchMove, { passive: false })
elementRef?.addEventListener('touchend', handleTouchEnd)
}
return () => {
if ('ontouchstart' in window) {
elementRef?.removeEventListener('touchstart', handleTouchStart)
elementRef?.removeEventListener('touchmove', handleTouchMove)
elementRef?.removeEventListener('touchend', handleTouchEnd)
}
}
})
// Prevent default touch behavior for better drag experience
function handleTouchStart(e: TouchEvent) {
if (!canDrag) return
e.preventDefault() // Prevent scrolling while dragging
}
function handleTouchMove(e: TouchEvent) {
if (dragContext.state.isDragging) {
e.preventDefault()
// Update drag preview position
updateDragPreviewPosition(e.touches[0])
}
}
function handleTouchEnd(e: TouchEvent) {
if (dragContext.state.isDragging) {
// Find drop target
const touch = e.changedTouches[0]
const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY)
// Trigger drop on target
dragContext.endDrag()
}
}
function updateDragPreviewPosition(touch: Touch) {
if (dragContext.state.dragPreview) {
dragContext.state.dragPreview.style.left = `${touch.clientX}px`
dragContext.state.dragPreview.style.top = `${touch.clientY}px`
}
}
</script>
<div
bind:this={elementRef}
draggable={canDrag}
ondragstart={handleDragStart}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
class:dragging={dragContext.state.isDragging &&
dragContext.state.draggedItem?.source.position === position}
class:can-drag={canDrag}
data-container={container}
data-position={position}
>
<slot />
</div>
<style lang="scss">
.dragging {
opacity: 0.5;
cursor: grabbing;
}
.can-drag {
cursor: grab;
touch-action: none; /* Prevent touch scrolling */
user-select: none;
&:active {
cursor: grabbing;
}
}
</style>
```
### Grid State Management with Operation Queue
```typescript
// Grid component state (e.g., CharacterGrid.svelte)
import { createDragDropContext } from '$lib/composables/drag-drop.svelte.ts'
// Local state that will sync with API
let characters = $state<GridCharacter[]>([
{ id: '1', position: 0, character: mockCharacter1 },
{ id: '2', position: 1, character: mockCharacter2 },
])
// Track sync status
let syncStatus = $state<'idle' | 'syncing' | 'error'>('idle')
let unsyncedChanges = $derived(dragContext.getQueuedOperations().length > 0)
const dragContext = createDragDropContext()
// Handle drop with optimistic update
function handleCharacterDrop(operation: DragOperation) {
// Optimistically update local state
const tempCharacters = [...characters]
if (operation.type === 'swap') {
// Swap positions
const sourceIndex = tempCharacters.findIndex(c => c.position === operation.source.position)
const targetIndex = tempCharacters.findIndex(c => c.position === operation.target.position)
if (sourceIndex !== -1 && targetIndex !== -1) {
[tempCharacters[sourceIndex], tempCharacters[targetIndex]] =
[tempCharacters[targetIndex], tempCharacters[sourceIndex]]
}
} else if (operation.type === 'move') {
// Move to empty slot
const sourceChar = tempCharacters.find(c => c.position === operation.source.position)
if (sourceChar) {
sourceChar.position = operation.target.position
}
}
// Apply optimistic update
characters = tempCharacters
// Queue for sync (will be processed when API is integrated)
console.log('📝 Operation queued for sync:', operation)
}
// Future: Auto-sync with API
$effect(() => {
if (unsyncedChanges && syncStatus === 'idle') {
// Debounce sync attempts
const timer = setTimeout(() => {
syncWithAPI()
}, 1000)
return () => clearTimeout(timer)
}
})
async function syncWithAPI() {
syncStatus = 'syncing'
try {
// Future API implementation
// await dragContext.syncOperations()
console.log('🔄 Would sync operations here:', dragContext.getQueuedOperations())
syncStatus = 'idle'
} catch (error) {
syncStatus = 'error'
console.error('Sync failed:', error)
}
}
```
## Error Recovery Patterns
### Automatic Retry Logic
```typescript
class DragOperationError extends Error {
constructor(
message: string,
public operation: DragOperation,
public recoverable: boolean = true
) {
super(message)
this.name = 'DragOperationError'
}
}
async function retryOperation(operation: DragOperation) {
console.log(`🔄 Retrying operation ${operation.id} (attempt ${operation.retryCount + 1})`)
try {
// Future API call
// await api.syncDragOperation(operation)
operation.status = 'synced'
} catch (error) {
if (error instanceof DragOperationError && !error.recoverable) {
// Unrecoverable error, rollback
rollbackOperation(operation)
} else {
// Schedule another retry
operation.retryCount++
if (operation.retryCount < 3) {
setTimeout(() => retryOperation(operation), Math.pow(2, operation.retryCount) * 1000)
}
}
}
}
```
### User Feedback for Sync Status
```svelte
<!-- SyncStatusIndicator.svelte -->
<script lang="ts">
interface Props {
status: 'idle' | 'syncing' | 'error'
pendingCount: number
}
let { status, pendingCount }: Props = $props()
</script>
{#if pendingCount > 0}
<div class="sync-indicator" class:error={status === 'error'}>
{#if status === 'syncing'}
<span class="spinner"></span> Saving changes...
{:else if status === 'error'}
<span class="error-icon"></span> Sync failed - changes pending
<button onclick={retry}>Retry</button>
{:else}
<span class="pending-icon"></span> {pendingCount} unsaved {pendingCount === 1 ? 'change' : 'changes'}
{/if}
</div>
{/if}
```
## Custom Drag Preview Specifications
### Visual Customization
```scss
.drag-preview {
position: fixed;
pointer-events: none;
z-index: 10000;
transform: rotate(3deg) scale(1.05);
opacity: 0.9;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
transition: transform 0.2s ease-out;
.drag-preview-content {
background: var(--bg-primary);
border: 2px solid var(--accent-color);
border-radius: 8px;
padding: 8px;
display: flex;
align-items: center;
gap: 8px;
img {
width: 40px;
height: 40px;
border-radius: 4px;
}
span {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
}
}
// Touch device specific styles
@media (hover: none) {
.drag-preview {
transform: translateY(-50px) scale(1.1); // Offset from finger
}
}
```
## Touch/Mobile Interaction Patterns
### Long Press Visual Feedback
```scss
.draggable-item {
position: relative;
&.long-press-active::before {
content: '';
position: absolute;
inset: -4px;
border: 2px solid var(--accent-color);
border-radius: 8px;
animation: pulse 0.5s ease-in-out;
pointer-events: none;
}
}
@keyframes pulse {
0% {
opacity: 0;
transform: scale(0.95);
}
50% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.05);
}
}
```
### Touch Gesture Configurations
```typescript
interface TouchConfig {
longPressDuration: number // 500ms default
touchThreshold: number // 10px movement threshold
scrollLockThreshold: number // 5px to lock scrolling
hapticFeedback: boolean // Enable vibration
visualFeedback: boolean // Show press indicator
}
// Allow customization per grid type
const characterGridTouchConfig: TouchConfig = {
longPressDuration: 400, // Faster for experienced users
touchThreshold: 15, // More forgiving
scrollLockThreshold: 5,
hapticFeedback: true,
visualFeedback: true
}
```
## Validation Rules
### Console Logging Pattern (Enhanced)
```typescript
function validateDrop(source: Position, target: Position): boolean {
console.group('🎯 Drop Validation')
console.log('Source:', source)
console.log('Target:', target)
console.log('Queue Length:', state.operationQueue.length)
try {
// Check if operation would create invalid state
const wouldCreateInvalidState = checkStateValidity(source, target)
if (wouldCreateInvalidState) {
console.log('❌ Would create invalid state:', wouldCreateInvalidState)
return false
}
// Check item type compatibility
if (source.type !== target.expectedType) {
console.log('❌ Type mismatch:', source.type, 'vs', target.expectedType)
return false
}
// Check position restrictions
if (target.position === -1 && !target.allowMainhand) {
console.log('❌ Cannot drop in mainhand slot')
return false
}
// Check container transfer rules
if (source.container !== target.container) {
const transferAllowed = validateContainerTransfer(source.container, target.container)
if (!transferAllowed) {
console.log('❌ Container transfer not allowed')
return false
}
console.log('✅ Valid container transfer')
}
console.log('✅ Drop allowed')
return true
} catch (error) {
console.error('❌ Validation error:', error)
state.lastError = error as Error
return false
} finally {
console.groupEnd()
}
}
```
## Testing Scenarios
### Touch Device Testing
1. Long press to initiate drag
2. Drag across viewport boundaries
3. Multi-touch rejection (only one drag at a time)
4. Scroll prevention during drag
5. Touch cancel handling (incoming call, notification)
### Error Recovery Testing
1. Simulate failed API calls
2. Test retry mechanism with exponential backoff
3. Verify rollback of optimistic updates
4. Test max retry limit behavior
5. Verify error state UI feedback
### Operation Queue Testing
1. Queue multiple operations rapidly
2. Verify operations maintain order
3. Test queue persistence (page refresh simulation)
4. Test sync status indicators
5. Verify batch operation handling
### Drag Preview Testing
1. Custom preview rendering
2. Preview position relative to cursor/touch
3. Preview cleanup on drag end
4. Preview visibility across z-indexes
5. Performance with complex preview content
## Performance Optimizations
### Touch Performance
```typescript
// Debounce touch move events
let touchMoveFrame: number | null = null
function handleTouchMove(e: TouchEvent) {
if (touchMoveFrame) return
touchMoveFrame = requestAnimationFrame(() => {
updateDragPosition(e.touches[0])
touchMoveFrame = null
})
}
```
### Operation Queue Optimization
```typescript
// Batch operations for efficiency
function batchOperations(operations: DragOperation[]): BatchedOperation {
return {
id: crypto.randomUUID(),
operations: operations,
timestamp: Date.now(),
type: 'batch'
}
}
// Deduplicate redundant operations
function deduplicateQueue(queue: DragOperation[]): DragOperation[] {
const seen = new Map<string, DragOperation>()
for (const op of queue) {
const key = `${op.source.itemId}-${op.target.position}`
if (!seen.has(key) || op.timestamp > seen.get(key)!.timestamp) {
seen.set(key, op)
}
}
return Array.from(seen.values())
}
```
## Future API Contract Requirements
### Expected Endpoints
```typescript
interface DragDropAPI {
// Single operation
syncOperation(operation: DragOperation): Promise<{ success: boolean, error?: string }>
// Batch operations
syncBatch(operations: DragOperation[]): Promise<BatchSyncResult>
// Get current state (for reconciliation)
getGridState(gridType: string, partyId: string): Promise<GridState>
// Validate operation before performing
validateOperation(operation: DragOperation): Promise<ValidationResult>
}
```
### Optimistic Update Pattern
```typescript
async function performOperationWithOptimisticUpdate(operation: DragOperation) {
// 1. Apply optimistic update immediately
applyLocalUpdate(operation)
// 2. Queue for sync
state.operationQueue.push(operation)
// 3. Attempt sync (non-blocking)
syncOperation(operation).catch(error => {
// 4. Rollback on failure
if (error.code === 'INVALID_STATE') {
rollbackOperation(operation)
// Fetch fresh state from server
refreshGridState()
}
})
}
```
## Success Metrics
- [ ] All drag operations feel smooth (60fps)
- [ ] Touch interactions feel native and responsive
- [ ] Visual feedback is clear and immediate
- [ ] Error recovery is transparent to users
- [ ] Operation queue handles offline scenarios gracefully
- [ ] Custom drag previews enhance user experience
- [ ] Console logs provide useful debugging info
- [ ] No breaking changes to existing components
- [ ] Code is clean and follows Svelte 5 patterns
- [ ] Test page demonstrates all features including error scenarios
- [ ] API integration points are clearly defined
## Implementation Notes
- Use Svelte 5 runes exclusively ($state, $derived, $effect)
- Implement pointer events for unified mouse/touch handling
- Keep operation queue in memory (localStorage for persistence if needed)
- Use RequestAnimationFrame for smooth animations
- Implement proper cleanup in component unmount
- Add comprehensive error boundaries
- Document API contract assumptions
- Follow existing SCSS module patterns
- Comment complex logic thoroughly
- Consider accessibility in future phases