feat(session): animate total count with split flap
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
createFlapState,
|
||||||
|
finishFlap,
|
||||||
|
flapSlots,
|
||||||
|
requestFlap,
|
||||||
|
} from '../flap-number.mjs';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: [Number, String], required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = ref(createFlapState(props.value));
|
||||||
|
const motionQuery = typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
: null;
|
||||||
|
let reducedMotion = Boolean(motionQuery?.matches);
|
||||||
|
let completionVersion = state.value.version;
|
||||||
|
const completedSlots = new Set();
|
||||||
|
|
||||||
|
const slots = computed(() => state.value.animating
|
||||||
|
? flapSlots(state.value.from, state.value.to)
|
||||||
|
: flapSlots(state.value.settled, state.value.settled));
|
||||||
|
|
||||||
|
function resetCompletionTracking() {
|
||||||
|
completionVersion = state.value.version;
|
||||||
|
completedSlots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyValue(value) {
|
||||||
|
const previousVersion = state.value.version;
|
||||||
|
state.value = requestFlap(state.value, value, { reducedMotion });
|
||||||
|
if (state.value.version !== previousVersion || !state.value.animating) resetCompletionTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFlapEnd(index) {
|
||||||
|
if (!state.value.animating) return;
|
||||||
|
if (completionVersion !== state.value.version) resetCompletionTracking();
|
||||||
|
completedSlots.add(index);
|
||||||
|
const changedSlots = slots.value.filter(slot => slot.changed).length;
|
||||||
|
if (completedSlots.size < changedSlots) return;
|
||||||
|
state.value = finishFlap(state.value);
|
||||||
|
resetCompletionTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMotionPreference(event) {
|
||||||
|
reducedMotion = event.matches;
|
||||||
|
applyValue(props.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.value, applyValue);
|
||||||
|
motionQuery?.addEventListener?.('change', handleMotionPreference);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
motionQuery?.removeEventListener?.('change', handleMotionPreference);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="flap-number" :aria-label="String(value)">
|
||||||
|
<span
|
||||||
|
v-for="(slot, index) in slots"
|
||||||
|
:key="`${state.version}:${index}`"
|
||||||
|
class="flap-slot"
|
||||||
|
:class="{ flipping: state.animating && slot.changed }"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span v-if="!state.animating || !slot.changed" class="flap-digit stable">{{ slot.to }}</span>
|
||||||
|
<template v-else>
|
||||||
|
<span class="flap-digit flap-new-top">{{ slot.to }}</span>
|
||||||
|
<span class="flap-digit flap-old-bottom">{{ slot.from }}</span>
|
||||||
|
<span class="flap-digit flap-old-top">{{ slot.from }}</span>
|
||||||
|
<span class="flap-digit flap-new-bottom" @animationend.stop="handleFlapEnd(index)">{{ slot.to }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flap-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 1lh;
|
||||||
|
line-height: inherit;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flap-slot {
|
||||||
|
position: relative;
|
||||||
|
width: 1ch;
|
||||||
|
height: 1lh;
|
||||||
|
overflow: hidden;
|
||||||
|
contain: paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flap-digit {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flap-slot.flipping {
|
||||||
|
perspective: 90px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flap-slot.flipping::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
top: 50%;
|
||||||
|
left: 12%;
|
||||||
|
right: 12%;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, .09);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flap-new-top { z-index: 1; clip-path: inset(0 0 50% 0); }
|
||||||
|
.flap-old-bottom { z-index: 1; clip-path: inset(50% 0 0 0); }
|
||||||
|
.flap-old-top {
|
||||||
|
z-index: 4;
|
||||||
|
clip-path: inset(0 0 50% 0);
|
||||||
|
transform-origin: center 50%;
|
||||||
|
animation: flap-top-out 110ms cubic-bezier(.55, 0, 1, .45) both;
|
||||||
|
}
|
||||||
|
.flap-new-bottom {
|
||||||
|
z-index: 5;
|
||||||
|
clip-path: inset(50% 0 0 0);
|
||||||
|
transform-origin: center 50%;
|
||||||
|
animation: flap-bottom-in 120ms cubic-bezier(.22, 1, .36, 1) 100ms both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flap-top-out {
|
||||||
|
from { transform: rotateX(0); }
|
||||||
|
to { transform: rotateX(-90deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flap-bottom-in {
|
||||||
|
from { transform: rotateX(90deg); }
|
||||||
|
to { transform: rotateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.flap-digit { animation: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
function normalizeFlapValue(value) {
|
||||||
|
return String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFlapState(value) {
|
||||||
|
const settled = normalizeFlapValue(value);
|
||||||
|
return {
|
||||||
|
settled,
|
||||||
|
from: settled,
|
||||||
|
to: settled,
|
||||||
|
animating: false,
|
||||||
|
queued: null,
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestFlap(state, value, { reducedMotion = false } = {}) {
|
||||||
|
const next = normalizeFlapValue(value);
|
||||||
|
if (reducedMotion) return createFlapState(next);
|
||||||
|
if (state.animating) {
|
||||||
|
if (next === state.to) return { ...state, queued: null };
|
||||||
|
return { ...state, queued: next };
|
||||||
|
}
|
||||||
|
if (next === state.settled) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
from: state.settled,
|
||||||
|
to: next,
|
||||||
|
animating: true,
|
||||||
|
queued: null,
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishFlap(state) {
|
||||||
|
if (!state.animating) return state;
|
||||||
|
const settled = state.to;
|
||||||
|
const queued = state.queued;
|
||||||
|
const stable = {
|
||||||
|
...state,
|
||||||
|
settled,
|
||||||
|
from: settled,
|
||||||
|
to: settled,
|
||||||
|
animating: false,
|
||||||
|
queued: null,
|
||||||
|
};
|
||||||
|
return queued !== null && queued !== settled ? requestFlap(stable, queued) : stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flapSlots(fromValue, toValue) {
|
||||||
|
const from = normalizeFlapValue(fromValue);
|
||||||
|
const to = normalizeFlapValue(toValue);
|
||||||
|
const width = Math.max(from.length, to.length);
|
||||||
|
const oldText = from.padStart(width, ' ');
|
||||||
|
const newText = to.padStart(width, ' ');
|
||||||
|
return Array.from({ length: width }, (_, index) => ({
|
||||||
|
from: oldText[index],
|
||||||
|
to: newText[index],
|
||||||
|
changed: oldText[index] !== newText[index],
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { state, FOLDER_SVG } from '../store.js';
|
import { state, FOLDER_SVG } from '../store.js';
|
||||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||||
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
||||||
|
import FlapNumber from '../components/FlapNumber.vue';
|
||||||
import {
|
import {
|
||||||
escapeHTML,
|
escapeHTML,
|
||||||
fmtRelative,
|
fmtRelative,
|
||||||
@@ -954,7 +955,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
<button class="msg-nav-btn" @click="navTo('prev')" :disabled="currentMsgIdx === 0" title="Previous">
|
<button class="msg-nav-btn" @click="navTo('prev')" :disabled="currentMsgIdx === 0" title="Previous">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="msg-nav-pos"><span class="msg-nav-current">{{ currentMsgIdx + 1 }}</span> / {{ totalMsgs }}</span>
|
<span class="msg-nav-pos"><span class="msg-nav-current">{{ currentMsgIdx + 1 }}</span> / <FlapNumber :value="totalMsgs" /></span>
|
||||||
<button class="msg-nav-btn" @click="navTo('next')" :disabled="currentMsgIdx >= totalMsgs - 1" title="Next">
|
<button class="msg-nav-btn" @click="navTo('next')" :disabled="currentMsgIdx >= totalMsgs - 1" title="Next">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l4 4-4 4"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l4 4-4 4"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createFlapState,
|
||||||
|
finishFlap,
|
||||||
|
flapSlots,
|
||||||
|
requestFlap,
|
||||||
|
} from '../app/src/renderer/src/flap-number.mjs';
|
||||||
|
|
||||||
|
test('flap slots animate only digits that changed', () => {
|
||||||
|
assert.deepEqual(flapSlots(822, 823), [
|
||||||
|
{ from: '8', to: '8', changed: false },
|
||||||
|
{ from: '2', to: '2', changed: false },
|
||||||
|
{ from: '2', to: '3', changed: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flap state queues rapid updates without interrupting the active flap', () => {
|
||||||
|
let state = createFlapState(822);
|
||||||
|
state = requestFlap(state, 823);
|
||||||
|
state = requestFlap(state, 824);
|
||||||
|
|
||||||
|
assert.equal(state.from, '822');
|
||||||
|
assert.equal(state.to, '823');
|
||||||
|
assert.equal(state.queued, '824');
|
||||||
|
|
||||||
|
state = finishFlap(state);
|
||||||
|
assert.equal(state.from, '823');
|
||||||
|
assert.equal(state.to, '824');
|
||||||
|
assert.equal(state.animating, true);
|
||||||
|
|
||||||
|
state = finishFlap(state);
|
||||||
|
assert.equal(state.settled, '824');
|
||||||
|
assert.equal(state.animating, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the latest request clears an older queued value when it matches the active target', () => {
|
||||||
|
let state = createFlapState(822);
|
||||||
|
state = requestFlap(state, 823);
|
||||||
|
state = requestFlap(state, 824);
|
||||||
|
state = requestFlap(state, 823);
|
||||||
|
|
||||||
|
assert.equal(state.to, '823');
|
||||||
|
assert.equal(state.queued, null);
|
||||||
|
state = finishFlap(state);
|
||||||
|
assert.equal(state.settled, '823');
|
||||||
|
assert.equal(state.animating, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reduced motion updates the settled value immediately', () => {
|
||||||
|
const state = requestFlap(createFlapState(822), 823, { reducedMotion: true });
|
||||||
|
|
||||||
|
assert.equal(state.settled, '823');
|
||||||
|
assert.equal(state.animating, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued empty string remains a valid latest value for the generic component', () => {
|
||||||
|
let state = createFlapState('1');
|
||||||
|
state = requestFlap(state, '2');
|
||||||
|
state = requestFlap(state, '');
|
||||||
|
state = finishFlap(state);
|
||||||
|
|
||||||
|
assert.equal(state.to, '');
|
||||||
|
assert.equal(state.animating, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SessionDetail applies flap motion only to the total message count', () => {
|
||||||
|
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /<FlapNumber\s+:value="totalMsgs"/);
|
||||||
|
assert.match(source, /msg-nav-current[^>]*>\{\{ currentMsgIdx \+ 1 \}\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FlapNumber shares one relative geometry and finishes from animationend', () => {
|
||||||
|
const source = readFileSync(new URL('../app/src/renderer/src/components/FlapNumber.vue', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /width:\s*1ch/);
|
||||||
|
assert.match(source, /height:\s*1lh/);
|
||||||
|
assert.match(source, /@animationend\.stop=/);
|
||||||
|
assert.doesNotMatch(source, /height:\s*15px/);
|
||||||
|
assert.doesNotMatch(source, /setTimeout/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user