feat(avocet): animate bucket grid rise with Anime.js spring
This commit is contained in:
parent
d410fa5c80
commit
ddb6025c89
1 changed files with 27 additions and 13 deletions
|
|
@ -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 ── */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue