peregrine/web/src/components/JobCardStack.vue
pyr0ball 49e3265132 feat(web): merge Vue SPA from feature/vue-spa; add ClassicUIButton + useFeatureFlag
- Import web/ directory (Vue 3 + Vite + UnoCSS SPA) from feature/vue-spa branch
- Add web/src/components/ClassicUIButton.vue: switch-back to Streamlit via
  cookie (prgn_ui=streamlit) + ?prgn_switch=streamlit query param bridge
- Add web/src/composables/useFeatureFlag.ts: reads prgn_demo_tier cookie for
  demo toolbar visual consistency (not an authoritative gate, see issue #8)
- Update .gitignore: add .superpowers/, pytest-output.txt, docs/superpowers/
2026-03-22 18:46:11 -07:00

305 lines
9.5 KiB
Vue

<template>
<div class="card-stack" :aria-label="`${remaining} jobs remaining`">
<!-- Peek cards depth illusion behind active card -->
<div class="card-peek card-peek-2" aria-hidden="true" />
<div class="card-peek card-peek-1" aria-hidden="true" />
<!-- Active card wrapper receives pointer events -->
<div
ref="wrapperEl"
class="card-wrapper"
:class="{
'is-held': isHeld,
'is-exiting': isExiting,
}"
:style="cardStyle"
role="region"
:aria-label="job.title"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<!-- Directional tint overlay -->
<div
class="card-tint"
:class="{
'card-tint--approve': dx > 0,
'card-tint--reject': dx < 0,
}"
:style="{ opacity: tintOpacity }"
aria-hidden="true"
>
<span class="card-tint__icon">{{ dx > 0 ? '✓' : '✗' }}</span>
</div>
<JobCard
:job="job"
:expanded="isExpanded"
@expand="isExpanded = true"
@collapse="isExpanded = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import JobCard from './JobCard.vue'
import type { Job } from '../stores/review'
const props = defineProps<{
job: Job
remaining: number
}>()
const emit = defineEmits<{
approve: []
reject: []
skip: []
}>()
// ─── State ────────────────────────────────────────────────────────────────────
const wrapperEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false)
const isHeld = ref(false)
const isExiting = ref(false)
const dx = ref(0)
const dy = ref(0)
// ─── Derived style ────────────────────────────────────────────────────────────
// Max tilt at ±120px drag = ±6°
const TILT_MAX_DEG = 6
const TILT_AT_PX = 120
const cardStyle = computed(() => {
if (isExiting.value) return {} // exiting uses CSS class transition
if (!isHeld.value && dx.value === 0 && dy.value === 0) return {}
const tilt = Math.max(-TILT_MAX_DEG, Math.min(TILT_MAX_DEG, (dx.value / TILT_AT_PX) * TILT_MAX_DEG))
return { transform: `translate(${dx.value}px, ${dy.value}px) rotate(${tilt}deg)` }
})
// Tint opacity 0→0.6 at ±0→120px
const tintOpacity = computed(() =>
isHeld.value ? Math.min(Math.abs(dx.value) / TILT_AT_PX, 1) * 0.6 : 0,
)
// ─── Fling detection ──────────────────────────────────────────────────────────
const FLING_SPEED_PX_S = 600 // minimum px/s to qualify
const FLING_ALIGN = 0.707 // cos(45°) — must be within 45° of horizontal
const FLING_WINDOW_MS = 50 // rolling sample window
let velocityBuf: { x: number; y: number; t: number }[] = []
// ─── Zone detection ───────────────────────────────────────────────────────────
const ZONE_PCT = 0.2 // 20% of viewport width on each side
// ─── Pointer events ───────────────────────────────────────────────────────────
let pickupX = 0
let pickupY = 0
function onPointerDown(e: PointerEvent) {
// Let interactive children (links, buttons) receive their events
if ((e.target as Element).closest('button, a, input, select, textarea')) return
if (isExiting.value) return
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pickupX = e.clientX
pickupY = e.clientY
isHeld.value = true
velocityBuf = []
}
function onPointerMove(e: PointerEvent) {
if (!isHeld.value) return
dx.value = e.clientX - pickupX
dy.value = e.clientY - pickupY
// Rolling velocity buffer
const now = performance.now()
velocityBuf.push({ x: e.clientX, y: e.clientY, t: now })
while (velocityBuf.length > 1 && now - velocityBuf[0].t > FLING_WINDOW_MS) {
velocityBuf.shift()
}
}
function onPointerUp(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
// Fling detection — fires first so a fast flick resolves without reaching the edge zone
if (velocityBuf.length >= 2) {
const oldest = velocityBuf[0]
const newest = velocityBuf[velocityBuf.length - 1]
const dt = (newest.t - oldest.t) / 1000
if (dt > 0) {
const vx = (newest.x - oldest.x) / dt
const vy = (newest.y - oldest.y) / dt
const speed = Math.sqrt(vx * vx + vy * vy)
if (speed >= FLING_SPEED_PX_S && Math.abs(vx) / speed >= FLING_ALIGN) {
velocityBuf = []
_dismiss(vx > 0 ? 'right' : 'left')
return
}
}
}
velocityBuf = []
// Zone check — did the pointer release in an edge zone?
const vw = window.innerWidth
if (e.clientX < vw * ZONE_PCT) {
_dismiss('left')
} else if (e.clientX > vw * (1 - ZONE_PCT)) {
_dismiss('right')
} else {
_snapBack()
}
}
function onPointerCancel(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
velocityBuf = []
_snapBack()
}
// ─── Animation helpers ────────────────────────────────────────────────────────
function _snapBack() {
dx.value = 0
dy.value = 0
}
/** Fly card off-screen, then emit the action. */
async function _dismiss(direction: 'left' | 'right') {
if (!wrapperEl.value || isExiting.value) return
isExiting.value = true
const exitX = direction === 'right' ? 700 : -700
const exitTilt = direction === 'right' ? 14 : -14
wrapperEl.value.style.transform = `translate(${exitX}px, -60px) rotate(${exitTilt}deg)`
wrapperEl.value.style.opacity = '0'
await new Promise(r => setTimeout(r, 280))
emit(direction === 'right' ? 'approve' : 'reject')
}
// Keyboard-triggered dismiss (called from parent via template ref)
async function dismissApprove() { await _dismiss('right') }
async function dismissReject() { await _dismiss('left') }
function dismissSkip() { _snapBack(); emit('skip') }
// Reset when a new job is slotted in (Vue reuses the element)
watch(() => props.job.id, () => {
dx.value = 0
dy.value = 0
isExiting.value = false
isHeld.value = false
isExpanded.value = false
if (wrapperEl.value) {
// Suppress the spring transition for this frame — without this the card
// spring-animates from its exit position back to center before the new
// job renders (the "snap-back on processed cards" glitch).
wrapperEl.value.style.transition = 'none'
wrapperEl.value.style.transform = ''
wrapperEl.value.style.opacity = ''
requestAnimationFrame(() => {
if (wrapperEl.value) wrapperEl.value.style.transition = ''
})
}
})
defineExpose({ dismissApprove, dismissReject, dismissSkip })
</script>
<style scoped>
.card-stack {
position: relative;
/* Reserve space for peek cards below active card */
padding-bottom: 18px;
}
/* Peek cards — static shadows giving a stack depth feel */
.card-peek {
position: absolute;
left: 0; right: 0; bottom: 0;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
}
.card-peek-1 { transform: translateY(8px) scale(0.97); opacity: 0.55; height: 80px; }
.card-peek-2 { transform: translateY(16px) scale(0.94); opacity: 0.30; height: 80px; }
/* Active card wrapper */
.card-wrapper {
position: relative;
z-index: 1;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
box-shadow: var(--shadow-md);
/* Spring snap-back when released with no action */
transition:
transform var(--swipe-spring),
opacity 200ms ease,
box-shadow 150ms ease;
touch-action: none;
cursor: grab;
overflow: hidden;
will-change: transform;
}
.card-wrapper.is-held {
cursor: grabbing;
transition: none; /* instant response while dragging */
box-shadow: var(--shadow-xl, 0 12px 40px rgba(0,0,0,0.18));
}
/* is-exiting: override to linear ease-in for off-screen fly */
.card-wrapper.is-exiting {
transition:
transform 280ms ease-in,
opacity 240ms ease-in !important;
pointer-events: none;
}
/* Directional tint overlay */
.card-tint {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 2;
display: flex;
align-items: flex-start;
padding: var(--space-4);
transition: opacity 60ms linear;
}
.card-tint--approve { background: rgba(39, 174, 96, 0.35); }
.card-tint--reject { background: rgba(192, 57, 43, 0.35); }
.card-tint__icon {
font-size: 2rem;
font-weight: 900;
color: white;
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
opacity: 0.85;
}
.card-tint--approve .card-tint__icon { margin-left: auto; }
.card-tint--reject .card-tint__icon { margin-right: auto; }
@media (prefers-reduced-motion: reduce) {
.card-wrapper { transition: none; }
.card-wrapper.is-exiting { transition: opacity 200ms ease !important; }
}
</style>