feat: animate the event slide with clickable step controls

On slide entry, reveal the typed fields first, then flash in the hashed
`id` and signed `sig` (~0.9s apart) while the Fill -> Hash -> Sign strip
advances. The step boxes are also buttons: click one to jump to (and
replay) that state; the first click cancels the auto-play so a presenter
can drive it manually. Gated behind prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
padreug 2026-07-22 02:57:34 +02:00
commit 5f97b48f6d

View file

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { PenLine, Hash, KeyRound, ArrowRight } from '@lucide/vue' import { PenLine, Hash, KeyRound, ArrowRight } from '@lucide/vue'
import SlideShell from './SlideShell.vue' import SlideShell from './SlideShell.vue'
@ -29,6 +30,40 @@ const steps = [
{ icon: Hash, label: 'Hash them → id', sub: 'sha256 of the serialized event' }, { icon: Hash, label: 'Hash them → id', sub: 'sha256 of the serialized event' },
{ icon: KeyRound, label: 'Sign id → sig', sub: 'Schnorr signature with your nsec' }, { icon: KeyRound, label: 'Sign id → sig', sub: 'Schnorr signature with your nsec' },
] ]
// Staged reveal: typed fields first, then `id` is hashed in, then `sig` is signed in.
// stage 0 = fields only · 1 = id computed · 2 = sig computed.
const stage = ref(0)
const timers: ReturnType<typeof setTimeout>[] = []
function isRevealed(f: Field): boolean {
if (!f.derived) return true
if (f.key === 'id') return stage.value >= 1
return stage.value >= 2 // sig
}
function clearTimers() {
timers.forEach(clearTimeout)
timers.length = 0
}
// Click a step to jump to (and replay) that state; stops the auto-play so
// manual control wins.
function selectStage(i: number) {
clearTimers()
stage.value = i
}
onMounted(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduce) {
stage.value = 2
return
}
timers.push(setTimeout(() => (stage.value = 1), 900))
timers.push(setTimeout(() => (stage.value = 2), 1800))
})
onBeforeUnmount(clearTimers)
</script> </script>
<template> <template>
@ -45,12 +80,20 @@ const steps = [
> >
"{{ f.key }}"<span class="text-zinc-500">:</span> "{{ f.key }}"<span class="text-zinc-500">:</span>
</span> </span>
<span class="whitespace-nowrap" :class="valueClass[f.kind]" <span class="whitespace-nowrap" :class="valueClass[f.kind]">
>{{ f.value }}<span v-if="i < fields.length - 1" class="text-zinc-500">,</span></span <template v-if="isRevealed(f)">
> <span :class="{ 'reveal-flash': f.derived }">{{ f.value }}</span
><span v-if="i < fields.length - 1" class="text-zinc-500">,</span>
</template>
<span v-else class="italic text-zinc-600">{{
f.key === 'id' ? 'hashing…' : 'signing…'
}}</span>
</span>
<span class="whitespace-nowrap text-sm text-zinc-600"> <span class="whitespace-nowrap text-sm text-zinc-600">
<span class="text-zinc-500">// </span>{{ f.comment <span class="text-zinc-500">// </span>{{ f.comment
}}<span v-if="f.derived" class="ml-1 rounded bg-violet-500/15 px-1 text-xs text-violet-300" }}<span
v-if="f.derived && isRevealed(f)"
class="reveal-flash ml-1 rounded bg-violet-500/15 px-1 text-xs text-violet-300"
>computed</span >computed</span
> >
</span> </span>
@ -63,19 +106,34 @@ const steps = [
<!-- How it's built & signed --> <!-- How it's built & signed -->
<div class="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center"> <div class="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center">
<template v-for="(s, i) in steps" :key="s.label"> <template v-for="(s, i) in steps" :key="s.label">
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900/60 px-4 py-3"> <button
<component :is="s.icon" class="h-6 w-6 flex-none text-violet-400" /> type="button"
:aria-current="i === stage ? 'step' : undefined"
class="flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors duration-300 hover:border-violet-500/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
:class="
i === stage
? 'border-violet-500 bg-violet-950/30 ring-1 ring-violet-500/40'
: 'border-zinc-700 bg-zinc-900/60 hover:bg-zinc-900'
"
@click="selectStage(i)"
>
<component
:is="s.icon"
class="h-6 w-6 flex-none transition-colors duration-300"
:class="i === stage ? 'text-violet-300' : 'text-violet-400/60'"
/>
<div> <div>
<p class="font-semibold text-white">{{ s.label }}</p> <p class="font-semibold text-white">{{ s.label }}</p>
<p class="text-xs text-zinc-400">{{ s.sub }}</p> <p class="text-xs text-zinc-400">{{ s.sub }}</p>
</div> </div>
</div> </button>
<ArrowRight <ArrowRight
v-if="i < steps.length - 1" v-if="i < steps.length - 1"
class="mx-auto h-5 w-5 flex-none rotate-90 text-zinc-500 sm:rotate-0" class="mx-auto h-5 w-5 flex-none rotate-90 text-zinc-500 sm:rotate-0"
/> />
</template> </template>
</div> </div>
<p class="mt-3 text-center text-xs text-zinc-600">Click a step to walk through each state.</p>
<p class="mt-6 text-center text-sm text-zinc-500"> <p class="mt-6 text-center text-sm text-zinc-500">
Anyone recomputes the hash and checks <code class="text-zinc-300">sig</code> against your Anyone recomputes the hash and checks <code class="text-zinc-300">sig</code> against your
@ -83,3 +141,30 @@ const steps = [
</p> </p>
</SlideShell> </SlideShell>
</template> </template>
<style scoped>
.reveal-flash {
display: inline-block;
border-radius: 3px;
animation: reveal 0.6s ease-out;
}
@keyframes reveal {
0% {
opacity: 0;
filter: blur(5px);
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.35);
background: rgba(139, 92, 246, 0.35);
}
100% {
opacity: 1;
filter: blur(0);
box-shadow: 0 0 0 0 transparent;
background: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-flash {
animation: none;
}
}
</style>