feat(avocet): animate bucket grid rise with Anime.js spring

This commit is contained in:
pyr0ball 2026-03-08 07:17:56 -07:00
parent d410fa5c80
commit ddb6025c89

View file

@ -69,7 +69,7 @@
/> />
</div> </div>
<div class="bucket-grid-footer" :class="{ 'grid-active': isHeld }"> <div ref="gridEl" class="bucket-grid-footer" :class="{ 'grid-active': isHeld }">
<LabelBucketGrid <LabelBucketGrid
:labels="labels" :labels="labels"
:is-bucket-mode="isHeld" :is-bucket-mode="isHeld"
@ -90,7 +90,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import { animate, spring } from 'animejs'
import { useLabelStore } from '../stores/label' import { useLabelStore } from '../stores/label'
import { useApiFetch } from '../composables/useApi' import { useApiFetch } from '../composables/useApi'
import { useHaptics } from '../composables/useHaptics' import { useHaptics } from '../composables/useHaptics'
@ -105,6 +106,8 @@ const store = useLabelStore()
const haptics = useHaptics() const haptics = useHaptics()
const motion = useMotion() // only needed to pass to child actual value used in App.vue const motion = useMotion() // only needed to pass to child actual value used in App.vue
const gridEl = ref<HTMLElement | null>(null)
const loading = ref(true) const loading = ref(true)
const apiError = ref(false) const apiError = ref(false)
const isHeld = ref(false) const isHeld = ref(false)
@ -113,6 +116,15 @@ const hoveredBucket = ref<string | null>(null)
const labels = ref<any[]>([]) const labels = ref<any[]>([])
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null) const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
watch(isHeld, (held) => {
if (!motion.rich.value || !gridEl.value) return
animate(gridEl.value,
held
? { y: -8, opacity: 0.45, ease: spring({ mass: 1, stiffness: 80, damping: 10 }), duration: 250 }
: { y: 0, opacity: 1, ease: spring({ mass: 1, stiffness: 80, damping: 10 }), duration: 250 }
)
})
// Easter egg state // Easter egg state
const consecutiveLabeled = ref(0) const consecutiveLabeled = ref(0)
const recentLabels = ref<number[]>([]) const recentLabels = ref<number[]>([])
@ -314,8 +326,8 @@ onUnmounted(() => {
padding: 1rem; padding: 1rem;
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
height: 100dvh; /* hard cap — prevents grid from drifting below fold */ min-height: 100dvh;
overflow: hidden; overflow-x: hidden; /* prevent card animations from causing horizontal scroll */
} }
.queue-status { .queue-status {
@ -424,13 +436,10 @@ onUnmounted(() => {
.card-stack-wrapper { .card-stack-wrapper {
flex: 1; flex: 1;
min-height: 0; /* allow flex child to shrink — default auto prevents this */ min-height: 0;
overflow-y: auto;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
transition: opacity 200ms ease;
} }
/* When held: escape the overflow clip so the ball floats freely, /* When held: escape overflow clip so ball floats freely above the footer. */
and rise above the footer (z-index 10) so the ball is visible. */
.card-stack-wrapper.is-held { .card-stack-wrapper.is-held {
overflow: visible; overflow: visible;
position: relative; position: relative;
@ -441,16 +450,21 @@ onUnmounted(() => {
can be scrolled freely. "hired" (10th button) may clip on very small screens can be scrolled freely. "hired" (10th button) may clip on very small screens
that is intentional per design. */ that is intentional per design. */
.bucket-grid-footer { .bucket-grid-footer {
position: sticky;
bottom: 0;
background: var(--color-bg, var(--color-surface, #f0f4fc)); background: var(--color-bg, var(--color-surface, #f0f4fc));
padding: 0.5rem 0 0.75rem; padding: 0.5rem 0 0.75rem;
z-index: 10; z-index: 10;
transition: transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 200ms ease,
background 200ms ease;
} }
/* During toss: switch to fixed so the grid is guaranteed in-viewport
regardless of scroll position, then fade so ball aura shows through. */
.bucket-grid-footer.grid-active { .bucket-grid-footer.grid-active {
position: fixed;
bottom: 0;
left: 0;
right: 0;
transform: translateY(-8px); transform: translateY(-8px);
opacity: 0.45; /* semi-transparent so ball aura is visible through it */ opacity: 0.45;
} }
/* ── Toss edge zones ── */ /* ── Toss edge zones ── */