feat(avocet): easter eggs — hired confetti, century mark, clean sweep, midnight labeler, cursor trail

This commit is contained in:
pyr0ball 2026-03-03 16:24:47 -08:00
parent deddd763ea
commit 47973aeba6
2 changed files with 184 additions and 7 deletions

View file

@ -41,3 +41,114 @@ export function useHackerMode() {
return { toggle, restore } return { toggle, restore }
} }
/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */
export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) {
if (typeof requestAnimationFrame === 'undefined') return
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;'
canvas.width = window.innerWidth
canvas.height = window.innerHeight
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')!
const COLORS = ['#2A6080','#B8622A','#5A9DBF','#D4854A','#FFC107','#4CAF50']
const particles = Array.from({ length: 80 }, () => ({
x: originX,
y: originY,
vx: (Math.random() - 0.5) * 14,
vy: (Math.random() - 0.6) * 12,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
size: 5 + Math.random() * 6,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.3,
life: 1.0,
}))
let raf = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
let alive = false
for (const p of particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.35 // gravity
p.vx *= 0.98 // air friction
p.angle += p.spin
p.life -= 0.016
if (p.life <= 0) continue
alive = true
ctx.save()
ctx.globalAlpha = p.life
ctx.fillStyle = p.color
ctx.translate(p.x, p.y)
ctx.rotate(p.angle)
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6)
ctx.restore()
}
if (alive) {
raf = requestAnimationFrame(draw)
} else {
cancelAnimationFrame(raf)
canvas.remove()
}
}
raf = requestAnimationFrame(draw)
}
/** Enable cursor trail in hacker mode — returns a cleanup function. */
export function useCursorTrail() {
const DOT_COUNT = 10
const dots: HTMLElement[] = []
let positions: { x: number; y: number }[] = []
let mouseX = 0
let mouseY = 0
let raf = 0
for (let i = 0; i < DOT_COUNT; i++) {
const dot = document.createElement('div')
dot.style.cssText = [
'position:fixed',
'pointer-events:none',
'z-index:9998',
'border-radius:50%',
'background:#5A9DBF',
'transition:opacity 0.1s',
].join(';')
document.body.appendChild(dot)
dots.push(dot)
}
function onMouseMove(e: MouseEvent) {
mouseX = e.clientX
mouseY = e.clientY
}
function animate() {
positions.unshift({ x: mouseX, y: mouseY })
if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT)
dots.forEach((dot, i) => {
const pos = positions[i]
if (!pos) { dot.style.opacity = '0'; return }
const scale = 1 - i / DOT_COUNT
const size = Math.round(8 * scale)
dot.style.left = `${pos.x - size / 2}px`
dot.style.top = `${pos.y - size / 2}px`
dot.style.width = `${size}px`
dot.style.height = `${size}px`
dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}`
})
raf = requestAnimationFrame(animate)
}
window.addEventListener('mousemove', onMouseMove)
raf = requestAnimationFrame(animate)
return function cleanup() {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(raf)
dots.forEach(d => d.remove())
}
}

View file

@ -6,9 +6,12 @@
<template v-if="store.totalRemaining > 0"> <template v-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining {{ store.totalRemaining }} remaining
</template> </template>
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span> <span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
<span v-if="speedRound" class="badge badge-speed"> Speed round!</span> <span v-if="speedRound" class="badge badge-speed"> Speed round!</span>
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span> <span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
<span v-if="centuryMark" class="badge badge-century">💯 Century!</span>
<span v-if="cleanSweep" class="badge badge-sweep">🧹 Clean sweep!</span>
<span v-if="midnightLabeler" class="badge badge-midnight">🦉 Midnight labeler!</span>
</span> </span>
<div class="header-actions"> <div class="header-actions">
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action"> Undo</button> <button @click="handleUndo" :disabled="!store.lastAction" class="btn-action"> Undo</button>
@ -61,12 +64,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
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'
import { useMotion } from '../composables/useMotion' import { useMotion } from '../composables/useMotion'
import { useLabelKeyboard } from '../composables/useLabelKeyboard' import { useLabelKeyboard } from '../composables/useLabelKeyboard'
import { fireConfetti, useCursorTrail } from '../composables/useEasterEgg'
import EmailCardStack from '../components/EmailCardStack.vue' import EmailCardStack from '../components/EmailCardStack.vue'
import LabelBucketGrid from '../components/LabelBucketGrid.vue' import LabelBucketGrid from '../components/LabelBucketGrid.vue'
import UndoToast from '../components/UndoToast.vue' import UndoToast from '../components/UndoToast.vue'
@ -88,6 +92,24 @@ const onRoll = ref(false)
const speedRound = ref(false) const speedRound = ref(false)
const fiftyDeep = ref(false) const fiftyDeep = ref(false)
// New easter egg state
const centuryMark = ref(false)
const cleanSweep = ref(false)
const midnightLabeler = ref(false)
let midnightShownThisSession = false
let trailCleanup: (() => void) | null = null
let themeObserver: MutationObserver | null = null
function syncCursorTrail() {
const isHacker = document.documentElement.dataset.theme === 'hacker'
if (isHacker && !trailCleanup) {
trailCleanup = useCursorTrail()
} else if (!isHacker && trailCleanup) {
trailCleanup()
trailCleanup = null
}
}
async function fetchBatch() { async function fetchBatch() {
loading.value = true loading.value = true
apiError.value = false apiError.value = false
@ -96,6 +118,12 @@ async function fetchBatch() {
if (error || !data) { apiError.value = true; return } if (error || !data) { apiError.value = true; return }
store.queue = data.items store.queue = data.items
store.totalRemaining = data.total store.totalRemaining = data.total
// Clean sweep queue exhausted in this batch
if (data.total === 0 && data.items.length === 0 && store.sessionLabeled > 0) {
cleanSweep.value = true
setTimeout(() => { cleanSweep.value = false }, 4000)
}
} }
function checkSpeedRound(): boolean { function checkSpeedRound(): boolean {
@ -138,6 +166,27 @@ async function handleLabel(name: string) {
setTimeout(() => { speedRound.value = false }, 2500) setTimeout(() => { speedRound.value = false }, 2500)
} }
// Hired confetti
if (name === 'hired') {
fireConfetti()
}
// Century mark
if (store.sessionLabeled === 100) {
centuryMark.value = true
setTimeout(() => { centuryMark.value = false }, 4000)
}
// Midnight labeler once per session
if (!midnightShownThisSession) {
const h = new Date().getHours()
if (h >= 0 && h < 3) {
midnightShownThisSession = true
midnightLabeler.value = true
setTimeout(() => { midnightLabeler.value = false }, 5000)
}
}
await useApiFetch('/api/label', { await useApiFetch('/api/label', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -207,6 +256,20 @@ onMounted(async () => {
const { data } = await useApiFetch<any[]>('/api/config/labels') const { data } = await useApiFetch<any[]>('/api/config/labels')
if (data) labels.value = data if (data) labels.value = data
await fetchBatch() await fetchBatch()
// Cursor trail activate immediately if already in hacker mode, then watch for changes
syncCursorTrail()
themeObserver = new MutationObserver(syncCursorTrail)
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
themeObserver = null
if (trailCleanup) {
trailCleanup()
trailCleanup = null
}
}) })
</script> </script>
@ -253,9 +316,12 @@ onMounted(async () => {
to { transform: scale(1); opacity: 1; } to { transform: scale(1); opacity: 1; }
} }
.badge-roll { background: #ff6b35; color: #fff; } .badge-roll { background: #ff6b35; color: #fff; }
.badge-speed { background: #7c3aed; color: #fff; } .badge-speed { background: #7c3aed; color: #fff; }
.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); } .badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); }
.badge-century { background: #ffd700; color: #1a2338; }
.badge-sweep { background: var(--app-primary, #2A6080); color: #fff; }
.badge-midnight { background: #1a1a2e; color: #7c9dcf; border: 1px solid #7c9dcf; }
.header-actions { .header-actions {
display: flex; display: flex;