Animation pass: shared-element morph, sliding tabs, count-ups
New lib/anim.js (countUp, fadeSlideIn, segThumb), all reduced-motion aware: - Shared-element morph: tapping a card tags its art with view-transition-name; the detail skeleton now renders the real sprite at hero size (router passes the match, animates the skeleton mount), so the sprite flies from the grid into the hero and the skeleton→content swap is seamless. - Segmented controls (Team / Search / Breeding) get a sliding pill that measures each button and eases between them. - Count-ups: the dex-feed progress number and the detail Stats total tick up. - Detail tab content fades/rises on switch. - Global :active scale-press on buttons/chips/rows, plus soft background/border transitions. - Hero sprite has a slow idle bob. - Router swallows the AbortError when a fast follow-up nav skips an in-flight view transition. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
2e4d9ab329
commit
a437f539fc
@ -45,6 +45,12 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
||||
},
|
||||
});
|
||||
|
||||
const art = el(
|
||||
'div',
|
||||
{ class: 'card__art' },
|
||||
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 116 }),
|
||||
);
|
||||
|
||||
const card = el(
|
||||
'a',
|
||||
{
|
||||
@ -52,17 +58,19 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
||||
href: `#/pokemon/${species.id}`,
|
||||
dataset: data,
|
||||
style: `--type-main:${typeHex(mainType)}`,
|
||||
onclick: () => {
|
||||
// Tag this sprite so the router's view transition morphs it into
|
||||
// the detail hero.
|
||||
for (const n of document.querySelectorAll('.card__art.is-morph')) n.classList.remove('is-morph');
|
||||
art.classList.add('is-morph');
|
||||
},
|
||||
},
|
||||
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
|
||||
el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
|
||||
state.note && state.note.trim()
|
||||
? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝')
|
||||
: null,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'card__art' },
|
||||
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 116 }),
|
||||
),
|
||||
art,
|
||||
caughtBtn,
|
||||
el(
|
||||
'div',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { el } from '../lib/dom.js';
|
||||
import { countUp } from '../lib/anim.js';
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
@ -34,10 +35,13 @@ export function ProgressRing() {
|
||||
svg.setAttribute('class', 'ring__svg');
|
||||
svg.append(track, seenArc, caughtArc);
|
||||
|
||||
const value = el('span', { class: 'ring__value' });
|
||||
const num = el('span', { class: 'ring__num' }, '0');
|
||||
const tail = el('small', {});
|
||||
const value = el('span', { class: 'ring__value' }, num, tail);
|
||||
const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value));
|
||||
|
||||
let first = true;
|
||||
let shown = 0;
|
||||
function update(stats) {
|
||||
const draw = () => {
|
||||
const { caught = 0, seen = 0, total = 1 } = stats;
|
||||
@ -47,7 +51,9 @@ export function ProgressRing() {
|
||||
'stroke-dashoffset',
|
||||
String(CIRC * (1 - Math.max(seen, caught) / safeTotal)),
|
||||
);
|
||||
value.replaceChildren(String(caught), el('small', {}, `/${total}`));
|
||||
tail.textContent = `/${total}`;
|
||||
countUp(num, caught, { from: shown, duration: first ? 700 : 350 });
|
||||
shown = caught;
|
||||
};
|
||||
// Defer the first draw a frame so the arc animates in from empty.
|
||||
if (first) {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { el } from '../lib/dom.js';
|
||||
import { Sprite } from './Sprite.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
|
||||
const bar = (w, h = 14) =>
|
||||
el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` });
|
||||
@ -13,7 +15,21 @@ const facts = () =>
|
||||
);
|
||||
|
||||
/** Placeholder shown while a Pokémon detail page loads. */
|
||||
export function detailSkeleton() {
|
||||
export function detailSkeleton(match) {
|
||||
const id = match && Number(match[1]);
|
||||
// Real sprite in the hero spot (cheap, usually cached) so the tapped
|
||||
// card's art can morph straight into it.
|
||||
const art = id
|
||||
? el(
|
||||
'div',
|
||||
{ class: 'sk-art sk-art--live', style: 'view-transition-name: pkmn-sprite' },
|
||||
Sprite(id, {
|
||||
style: settings.get().spriteStyle === 'default' ? 'official' : settings.get().spriteStyle,
|
||||
alt: '',
|
||||
size: 220,
|
||||
}),
|
||||
)
|
||||
: el('div', { class: 'sk-art' });
|
||||
return el(
|
||||
'section',
|
||||
{ class: 'view pdetail sk' },
|
||||
@ -22,7 +38,7 @@ export function detailSkeleton() {
|
||||
{ class: 'phero sk-hero' },
|
||||
el('div', { class: 'phero__top' }, bar('72px', 34), bar('130px', 14), bar('34px', 34)),
|
||||
el('div', { class: 'phero__head' }, bar('55%', 30)),
|
||||
el('div', { class: 'sk-art' }),
|
||||
art,
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
|
||||
62
src/lib/anim.js
Normal file
62
src/lib/anim.js
Normal file
@ -0,0 +1,62 @@
|
||||
import { prefersReducedMotion } from '../store/settings.js';
|
||||
|
||||
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
/**
|
||||
* Tween a number into `node`'s text. Snaps straight to the value when the
|
||||
* user prefers reduced motion. Returns a cancel fn.
|
||||
*/
|
||||
export function countUp(node, to, { from = 0, duration = 550, decimals = 0, prefix = '', suffix = '', ease = easeOutCubic } = {}) {
|
||||
const fmt = (v) => `${prefix}${decimals ? v.toFixed(decimals) : Math.round(v)}${suffix}`;
|
||||
if (prefersReducedMotion() || duration <= 0 || from === to) {
|
||||
node.textContent = fmt(to);
|
||||
return () => {};
|
||||
}
|
||||
let raf = 0;
|
||||
const t0 = performance.now();
|
||||
const tick = (now) => {
|
||||
const p = Math.min(1, (now - t0) / duration);
|
||||
node.textContent = fmt(from + (to - from) * ease(p));
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a sliding pill behind an already-built `.seg` (its `.seg__btn`
|
||||
* children can be unequal widths — the thumb measures them). Returns a
|
||||
* setter `setActive(index)`. The move itself is a CSS transition, so
|
||||
* reduced-motion users just get an instant jump.
|
||||
*/
|
||||
export function segThumb(seg, _count, active = 0) {
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'seg__thumb';
|
||||
seg.prepend(thumb);
|
||||
let current = active;
|
||||
const place = (i) => {
|
||||
current = i;
|
||||
const b = seg.querySelectorAll('.seg__btn')[i];
|
||||
if (!b || !b.offsetWidth) return;
|
||||
thumb.style.width = `${b.offsetWidth}px`;
|
||||
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
|
||||
};
|
||||
requestAnimationFrame(() => place(active));
|
||||
if (document.fonts?.ready) document.fonts.ready.then(() => place(current));
|
||||
return place;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short WAAPI fade + rise on an element that was just inserted. No-op
|
||||
* under reduced motion.
|
||||
*/
|
||||
export function fadeSlideIn(node, { duration = 240, dy = 8 } = {}) {
|
||||
if (prefersReducedMotion() || !node.animate) return;
|
||||
node.animate(
|
||||
[
|
||||
{ opacity: 0, transform: `translateY(${dy}px)` },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
{ duration, easing: 'cubic-bezier(.22,1,.36,1)' },
|
||||
);
|
||||
}
|
||||
@ -58,7 +58,12 @@ export function initRouter(host) {
|
||||
if (current) current.dispatchEvent(new CustomEvent('view:teardown'));
|
||||
current = node;
|
||||
if (animate && canAnimate()) {
|
||||
document.startViewTransition(() => host.replaceChildren(node));
|
||||
const t = document.startViewTransition(() => host.replaceChildren(node));
|
||||
// A fast follow-up navigation skips the in-flight transition; that's
|
||||
// expected — swallow the AbortError from every promise it exposes.
|
||||
t?.ready?.catch(() => {});
|
||||
t?.finished?.catch(() => {});
|
||||
t?.updateCallbackDone?.catch(() => {});
|
||||
} else {
|
||||
host.replaceChildren(node);
|
||||
}
|
||||
@ -69,12 +74,14 @@ export function initRouter(host) {
|
||||
const { route, match } = resolve(hash);
|
||||
|
||||
// Instant feedback: a shaped skeleton for detail pages, a light
|
||||
// placeholder otherwise.
|
||||
// placeholder otherwise. Detail skeletons animate in so the tapped
|
||||
// card's sprite can morph into the hero (shared-element transition).
|
||||
const isPokemon = /^#\/pokemon\/\d+$/.test(hash);
|
||||
mount(
|
||||
route.skeleton
|
||||
? route.skeleton()
|
||||
? route.skeleton(match)
|
||||
: el('div', { class: 'view view--loading' }, 'Loading…'),
|
||||
false,
|
||||
isPokemon,
|
||||
);
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
|
||||
@ -1838,6 +1838,22 @@
|
||||
border-radius: 26px;
|
||||
background: color-mix(in srgb, var(--text) 6%, var(--surface-2));
|
||||
}
|
||||
/* Holds the real sprite at hero size so the card → hero morph lands
|
||||
without a size jump when the content swaps in. */
|
||||
.sk-art--live {
|
||||
width: min(300px, 64vw);
|
||||
height: min(300px, 64vw);
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
filter: drop-shadow(0 16px 24px rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
.sk-art--live img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.sk .psheet {
|
||||
margin-top: -28px;
|
||||
}
|
||||
@ -3277,3 +3293,71 @@
|
||||
.shiny__new {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ================= Micro-interactions ============================ */
|
||||
/* Tactile press feedback — low specificity so any element's own
|
||||
:active / transition wins if it has one. */
|
||||
:where(.button, .seg__btn, .feed-chip, .breed__chip, .formbar__pill,
|
||||
.flavor__pill, .lineup__add, .lineup__clear, .shiny__step, .prog__row,
|
||||
.tchart__row, .tchart__col, .gamecard, .picker__row, .weakspot__chip) {
|
||||
transition: transform 0.09s var(--ease-spring), background-color 0.16s ease,
|
||||
color 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
}
|
||||
:where(.button, .seg__btn, .feed-chip, .breed__chip, .formbar__pill,
|
||||
.flavor__pill, .lineup__add, .shiny__step, .prog__row, .tchart__row,
|
||||
.tchart__col, .gamecard):active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.card:active {
|
||||
transform: scale(0.975);
|
||||
}
|
||||
|
||||
/* The dex-feed ring number pops when it changes. */
|
||||
.ring__num {
|
||||
display: inline-block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Hero sprite: a slow idle bob. Frozen by the reduced-motion kill-switch. */
|
||||
.phero__art img {
|
||||
animation: heroFloat 4.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes heroFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
/* Sliding thumb behind the active segment (JS sets --seg-i / --seg-n). */
|
||||
.seg {
|
||||
position: relative;
|
||||
}
|
||||
.seg__thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
width: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.28s var(--ease-spring), width 0.28s var(--ease-spring);
|
||||
pointer-events: none;
|
||||
}
|
||||
.seg__btn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.seg:has(.seg__thumb) .seg__btn.is-active {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Shared-element morph: the tapped card's art flies to the hero. */
|
||||
.card__art.is-morph {
|
||||
view-transition-name: pkmn-sprite;
|
||||
}
|
||||
/* Isolate the morphing sprite so only it animates, not the whole page. */
|
||||
::view-transition-group(pkmn-sprite) {
|
||||
animation-duration: 0.34s;
|
||||
animation-timing-function: var(--ease-spring);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { getPokemon } from '../data/api.js';
|
||||
import { prettify } from '../data/pokedex-resolver.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { openPokemonPicker } from '../components/PokemonPicker.js';
|
||||
import { segThumb } from '../lib/anim.js';
|
||||
|
||||
const EGG_LABEL = {
|
||||
water1: 'Water 1', water2: 'Water 2', water3: 'Water 3',
|
||||
@ -29,13 +30,14 @@ export async function BreedingView() {
|
||||
let group = groups.includes(ui.get().breedGroup) ? ui.get().breedGroup : groups[0];
|
||||
let target = snap.speciesById.get(ui.get().breedTarget) || null;
|
||||
|
||||
const SEG = [
|
||||
['groups', 'Egg groups'],
|
||||
['compat', 'Can breed with'],
|
||||
];
|
||||
const seg = el(
|
||||
'div',
|
||||
{ class: 'seg' },
|
||||
...[
|
||||
['groups', 'Egg groups'],
|
||||
['compat', 'Can breed with'],
|
||||
].map(([id, label]) =>
|
||||
...SEG.map(([id, label], i) =>
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
@ -44,7 +46,10 @@ export async function BreedingView() {
|
||||
onclick: () => {
|
||||
mode = id;
|
||||
ui.set({ breedMode: id });
|
||||
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', ['groups', 'compat'][i] === id));
|
||||
seg
|
||||
.querySelectorAll('.seg__btn')
|
||||
.forEach((b, j) => b.classList.toggle('is-active', SEG[j][0] === id));
|
||||
setThumb(i);
|
||||
render();
|
||||
},
|
||||
},
|
||||
@ -52,6 +57,7 @@ export async function BreedingView() {
|
||||
),
|
||||
),
|
||||
);
|
||||
const setThumb = segThumb(seg, SEG.length, Math.max(0, SEG.findIndex(([id]) => id === mode)));
|
||||
|
||||
const body = el('div', { class: 'breedview__body' });
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import { Locations } from '../components/Locations.js';
|
||||
import { FlavorText } from '../components/FlavorText.js';
|
||||
import { typeHex } from '../lib/type-color.js';
|
||||
import { buzz } from '../lib/haptics.js';
|
||||
import { countUp, fadeSlideIn } from '../lib/anim.js';
|
||||
import { offensiveSummary } from '../data/type-chart.js';
|
||||
|
||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||
@ -158,6 +159,7 @@ export async function PokemonDetail(nationalId) {
|
||||
let shiny = shinyAvailable && st.showShiny;
|
||||
const artHolder = el('div', {
|
||||
class: `phero__art${boxedArt ? ' phero__art--boxed' : ''}`,
|
||||
style: 'view-transition-name: pkmn-sprite',
|
||||
});
|
||||
const shinyBtn = shinyAvailable
|
||||
? el('button', {
|
||||
@ -341,6 +343,7 @@ export async function PokemonDetail(nationalId) {
|
||||
|
||||
const allGames = st.versionGroup === 'all';
|
||||
let statBars = [];
|
||||
let statTotalNode = null;
|
||||
|
||||
// ---- Form-dependent content (rebuilt when a form is selected) -----
|
||||
function formFactsNode(p) {
|
||||
@ -420,13 +423,15 @@ export async function PokemonDetail(nationalId) {
|
||||
const stat = (name) => p.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
|
||||
const total = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
|
||||
statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
|
||||
statTotalNode = el('span', { class: 'statbar__value' }, String(total));
|
||||
statTotalNode.dataset.total = String(total);
|
||||
return [
|
||||
el('div', { class: 'pstats' }, ...statBars),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'statbar statbar--total' },
|
||||
el('span', { class: 'statbar__label' }, 'Total'),
|
||||
el('span', { class: 'statbar__value' }, String(total)),
|
||||
statTotalNode,
|
||||
el('div', { class: 'statbar__track' }),
|
||||
),
|
||||
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
|
||||
@ -599,6 +604,7 @@ export async function PokemonDetail(nationalId) {
|
||||
tabButtons[i].setAttribute('aria-selected', String(active));
|
||||
});
|
||||
body.replaceChildren(tab.node);
|
||||
fadeSlideIn(tab.node);
|
||||
if (tab.id === 'stats') animateStats();
|
||||
ui.set({ detailTab: tab.id });
|
||||
}
|
||||
@ -615,6 +621,10 @@ export async function PokemonDetail(nationalId) {
|
||||
fill.dataset.animated = '1';
|
||||
});
|
||||
}
|
||||
if (statTotalNode && !statTotalNode.dataset.counted) {
|
||||
statTotalNode.dataset.counted = '1';
|
||||
countUp(statTotalNode, Number(statTotalNode.dataset.total), { duration: 650 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { entry } from '../store/selection.js';
|
||||
import { prettify } from '../data/pokedex-resolver.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { TypeChip } from '../components/TypeChip.js';
|
||||
import { segThumb } from '../lib/anim.js';
|
||||
|
||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
|
||||
@ -53,7 +54,7 @@ export async function SearchView() {
|
||||
const seg = el(
|
||||
'div',
|
||||
{ class: 'seg' },
|
||||
...TABS.map((t) =>
|
||||
...TABS.map((t, i) =>
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
@ -62,7 +63,10 @@ export async function SearchView() {
|
||||
onclick: () => {
|
||||
tab = t.id;
|
||||
ui.set({ searchTab: t.id });
|
||||
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab));
|
||||
seg
|
||||
.querySelectorAll('.seg__btn')
|
||||
.forEach((b, j) => b.classList.toggle('is-active', TABS[j].id === tab));
|
||||
setThumb(i);
|
||||
syncUI();
|
||||
run();
|
||||
},
|
||||
@ -71,6 +75,7 @@ export async function SearchView() {
|
||||
),
|
||||
),
|
||||
);
|
||||
const setThumb = segThumb(seg, TABS.length, Math.max(0, TABS.findIndex((t) => t.id === tab)));
|
||||
|
||||
const filters = el('div', { class: 'lookup__filters' });
|
||||
const note = el('p', { class: 'search__note' });
|
||||
|
||||
@ -13,6 +13,7 @@ import { Sprite } from '../components/Sprite.js';
|
||||
import { TypeChip } from '../components/TypeChip.js';
|
||||
import { CompareTable } from '../components/CompareTable.js';
|
||||
import { openPokemonPicker } from '../components/PokemonPicker.js';
|
||||
import { segThumb } from '../lib/anim.js';
|
||||
|
||||
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
|
||||
@ -35,7 +36,7 @@ export async function TeamView() {
|
||||
const seg = el(
|
||||
'div',
|
||||
{ class: 'seg' },
|
||||
...MODES.map(([id, label]) =>
|
||||
...MODES.map(([id, label], i) =>
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
@ -44,7 +45,10 @@ export async function TeamView() {
|
||||
onclick: () => {
|
||||
mode = id;
|
||||
ui.set({ teamMode: id });
|
||||
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', MODES[i][0] === id));
|
||||
seg
|
||||
.querySelectorAll('.seg__btn')
|
||||
.forEach((b, j) => b.classList.toggle('is-active', MODES[j][0] === id));
|
||||
setThumb(i);
|
||||
render();
|
||||
},
|
||||
},
|
||||
@ -52,6 +56,7 @@ export async function TeamView() {
|
||||
),
|
||||
),
|
||||
);
|
||||
const setThumb = segThumb(seg, MODES.length, Math.max(0, MODES.findIndex(([id]) => id === mode)));
|
||||
|
||||
const lineup = el('div', { class: 'lineup' });
|
||||
const body = el('div', { class: 'teamview__body' });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user