diff --git a/web/src/components/EmailCardStack.test.ts b/web/src/components/EmailCardStack.test.ts
index 6bb0aaa..ed8f614 100644
--- a/web/src/components/EmailCardStack.test.ts
+++ b/web/src/components/EmailCardStack.test.ts
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue'
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
const item = {
id: 'abc',
@@ -45,15 +45,70 @@ describe('EmailCardStack', () => {
expect(wrapperClasses).not.toContain('dismiss-skip')
})
- it('emits drag-start on dragstart event', async () => {
+ // JSDOM doesn't implement setPointerCapture — mock it on the element.
+ // Also use dispatchEvent(new PointerEvent) directly because @vue/test-utils
+ // .trigger() tries to assign clientX on a MouseEvent (read-only in JSDOM).
+ function mockPointerCapture(element: Element) {
+ ;(element as any).setPointerCapture = vi.fn()
+ ;(element as any).releasePointerCapture = vi.fn()
+ }
+
+ function fire(element: Element, type: string, init: PointerEventInit) {
+ element.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init }))
+ }
+
+ it('emits drag-start on pointerdown', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
- await w.find('.card-stack').trigger('dragstart')
+ const el = w.find('.card-wrapper').element
+ mockPointerCapture(el)
+ fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
+ await w.vm.$nextTick()
expect(w.emitted('drag-start')).toBeTruthy()
})
- it('emits drag-end on dragend event', async () => {
+ it('emits drag-end on pointerup', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
- await w.find('.card-stack').trigger('dragend')
+ const el = w.find('.card-wrapper').element
+ mockPointerCapture(el)
+ fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
+ fire(el, 'pointerup', { pointerId: 1, clientX: 200, clientY: 300 })
+ await w.vm.$nextTick()
expect(w.emitted('drag-end')).toBeTruthy()
})
+
+ it('emits discard when released in left zone (x < 7% viewport)', async () => {
+ const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
+ const el = w.find('.card-wrapper').element
+ mockPointerCapture(el)
+ // JSDOM window.innerWidth defaults to 1024; 7% = 71.7px
+ fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
+ fire(el, 'pointermove', { pointerId: 1, clientX: 30, clientY: 300 })
+ fire(el, 'pointerup', { pointerId: 1, clientX: 30, clientY: 300 })
+ await w.vm.$nextTick()
+ expect(w.emitted('discard')).toBeTruthy()
+ })
+
+ it('emits skip when released in right zone (x > 93% viewport)', async () => {
+ const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
+ const el = w.find('.card-wrapper').element
+ mockPointerCapture(el)
+ // JSDOM window.innerWidth defaults to 1024; 93% = 952px
+ fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
+ fire(el, 'pointermove', { pointerId: 1, clientX: 1000, clientY: 300 })
+ fire(el, 'pointerup', { pointerId: 1, clientX: 1000, clientY: 300 })
+ await w.vm.$nextTick()
+ expect(w.emitted('skip')).toBeTruthy()
+ })
+
+ it('does not emit action on pointerup without movement past zone', async () => {
+ const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
+ const el = w.find('.card-wrapper').element
+ mockPointerCapture(el)
+ fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
+ fire(el, 'pointerup', { pointerId: 1, clientX: 512, clientY: 300 })
+ await w.vm.$nextTick()
+ expect(w.emitted('discard')).toBeFalsy()
+ expect(w.emitted('skip')).toBeFalsy()
+ expect(w.emitted('label')).toBeFalsy()
+ })
})
diff --git a/web/src/components/EmailCardStack.vue b/web/src/components/EmailCardStack.vue
index 1ef5b5f..3f6a5a6 100644
--- a/web/src/components/EmailCardStack.vue
+++ b/web/src/components/EmailCardStack.vue
@@ -2,10 +2,6 @@
@@ -15,8 +11,12 @@
import { ref, computed } from 'vue'
-import { useSwipe } from '@vueuse/core'
import { useMotion } from '../composables/useMotion'
import EmailCard from './EmailCard.vue'
import type { QueueItem } from '../stores/label'
@@ -42,30 +41,112 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
- label: [name: string]
- skip: []
- discard: []
- 'drag-start': []
- 'drag-end': []
+ label: [name: string]
+ skip: []
+ discard: []
+ 'drag-start': []
+ 'drag-end': []
+ 'zone-hover': ['discard' | 'skip' | null]
+ 'bucket-hover': [string | null]
}>()
-const motion = useMotion()
-const cardEl = ref(null)
-const stackEl = ref(null)
+const motion = useMotion()
+const cardEl = ref(null)
const isExpanded = ref(false)
-const dragX = ref(0)
-const { isSwiping, lengthX } = useSwipe(cardEl, {
- threshold: 60,
- onSwipeEnd(_, dir) {
- if (dir === 'left') emit('discard')
- if (dir === 'right') emit('skip')
- dragX.value = 0
- },
- onSwipe() {
- if (motion.rich.value) dragX.value = lengthX.value * -1
- },
-})
+// Toss gesture state
+const isHeld = ref(false)
+const pickupX = ref(0)
+const pickupY = ref(0)
+const deltaX = ref(0)
+const deltaY = ref(0)
+const hoveredZone = ref<'discard' | 'skip' | null>(null)
+const hoveredBucketName = ref(null)
+
+// Zone threshold: 7% of viewport width on each side
+const ZONE_PCT = 0.07
+
+function onPointerDown(e: PointerEvent) {
+ if (!motion.rich.value) return
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
+ pickupX.value = e.clientX
+ pickupY.value = e.clientY
+ deltaX.value = 0
+ deltaY.value = 0
+ isHeld.value = true
+ hoveredZone.value = null
+ hoveredBucketName.value = null
+ emit('drag-start')
+}
+
+function onPointerMove(e: PointerEvent) {
+ if (!isHeld.value) return
+ deltaX.value = e.clientX - pickupX.value
+ deltaY.value = e.clientY - pickupY.value
+
+ const vw = window.innerWidth
+ const zone: 'discard' | 'skip' | null =
+ e.clientX < vw * ZONE_PCT ? 'discard' :
+ e.clientX > vw * (1 - ZONE_PCT) ? 'skip' :
+ null
+ if (zone !== hoveredZone.value) {
+ hoveredZone.value = zone
+ emit('zone-hover', zone)
+ }
+
+ // Bucket detection via hit-test (works through overlapping elements).
+ // Optional chain guards against JSDOM in tests (doesn't implement elementsFromPoint).
+ const els = document.elementsFromPoint?.(e.clientX, e.clientY) ?? []
+ const bucket = els.find(el => el.hasAttribute('data-label-key'))
+ const bucketName = bucket?.getAttribute('data-label-key') ?? null
+ if (bucketName !== hoveredBucketName.value) {
+ hoveredBucketName.value = bucketName
+ emit('bucket-hover', bucketName)
+ }
+}
+
+function onPointerUp(e: PointerEvent) {
+ if (!isHeld.value) return
+ ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
+ isHeld.value = false
+ emit('drag-end')
+ emit('zone-hover', null)
+ emit('bucket-hover', null)
+
+ if (hoveredZone.value === 'discard') {
+ hoveredZone.value = null
+ hoveredBucketName.value = null
+ emit('discard')
+ } else if (hoveredZone.value === 'skip') {
+ hoveredZone.value = null
+ hoveredBucketName.value = null
+ emit('skip')
+ } else if (hoveredBucketName.value) {
+ const name = hoveredBucketName.value
+ hoveredZone.value = null
+ hoveredBucketName.value = null
+ emit('label', name)
+ } else {
+ // Snap back — reset deltas
+ deltaX.value = 0
+ deltaY.value = 0
+ hoveredZone.value = null
+ hoveredBucketName.value = null
+ }
+}
+
+function onPointerCancel(e: PointerEvent) {
+ if (!isHeld.value) return
+ ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
+ isHeld.value = false
+ deltaX.value = 0
+ deltaY.value = 0
+ hoveredZone.value = null
+ hoveredBucketName.value = null
+ emit('drag-end')
+ emit('zone-hover', null)
+ emit('bucket-hover', null)
+}
const dismissClass = computed(() => {
if (!props.dismissType) return null
@@ -73,52 +154,25 @@ const dismissClass = computed(() => {
})
const cardStyle = computed(() => {
- if (!motion.rich.value || !isSwiping.value) return {}
- const tilt = dragX.value * 0.05
- const opacity = Math.abs(dragX.value) > 20 ? 0.9 : 1
- const color = dragX.value < -20 ? 'rgba(244,67,54,0.15)'
- : dragX.value > 20 ? 'rgba(255,152,0,0.15)'
- : 'transparent'
+ if (!motion.rich.value || !isHeld.value) return {}
+
+ // Aura color: zone > bucket > neutral
+ const aura =
+ hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
+ hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
+ hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
+ 'transparent'
+
return {
- transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`,
- opacity,
- background: color,
- transition: isSwiping.value ? 'none' : 'all 0.3s ease',
+ transform: `translate(${deltaX.value}px, ${deltaY.value}px) scale(0.35)`,
+ borderRadius: '50%',
+ background: aura,
+ transition: 'border-radius 150ms ease, background 150ms ease',
+ cursor: 'grabbing',
+ zIndex: 100,
+ userSelect: 'none',
}
})
-
-function onDragStart(e: DragEvent) {
- if (motion.rich.value && e.dataTransfer) {
- // Custom drag ghost: small crumpled-paper ball
- const ghost = document.createElement('div')
- ghost.setAttribute('aria-hidden', 'true')
- Object.assign(ghost.style, {
- position: 'fixed',
- top: '-200px',
- left: '0',
- width: '80px',
- height: '80px',
- borderRadius: '50%',
- background: '#e4ebf5',
- border: '3px solid #2A6080',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- fontSize: '1.75rem',
- boxShadow: '0 4px 20px rgba(0,0,0,0.25)',
- transform: 'rotate(-5deg)',
- })
- ghost.textContent = '✉️'
- document.body.appendChild(ghost)
- e.dataTransfer.setDragImage(ghost, 40, 40)
- // Remove after browser captures — RAF is too fast, 0ms timeout works
- setTimeout(() => {
- if (document.body.contains(ghost)) document.body.removeChild(ghost)
- }, 0)
- }
- emit('drag-start')
-}
-function onDragEnd() { emit('drag-end') }