From a437f539fc78994d0ef01fb232153a347559e1ab Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 09:22:08 -0400 Subject: [PATCH] Animation pass: shared-element morph, sliding tabs, count-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/components/Card.js | 18 ++++++-- src/components/ProgressRing.js | 10 +++- src/components/skeletons.js | 20 +++++++- src/lib/anim.js | 62 +++++++++++++++++++++++++ src/router.js | 15 ++++-- src/styles/layout.css | 84 ++++++++++++++++++++++++++++++++++ src/views/BreedingView.js | 16 +++++-- src/views/PokemonDetail.js | 12 ++++- src/views/SearchView.js | 9 +++- src/views/TeamView.js | 9 +++- 10 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 src/lib/anim.js diff --git a/src/components/Card.js b/src/components/Card.js index 8066813..27d61f5 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -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', diff --git a/src/components/ProgressRing.js b/src/components/ProgressRing.js index d99e6c2..3278f25 100644 --- a/src/components/ProgressRing.js +++ b/src/components/ProgressRing.js @@ -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) { diff --git a/src/components/skeletons.js b/src/components/skeletons.js index 0ba7117..4ee5282 100644 --- a/src/components/skeletons.js +++ b/src/components/skeletons.js @@ -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', diff --git a/src/lib/anim.js b/src/lib/anim.js new file mode 100644 index 0000000..9f87614 --- /dev/null +++ b/src/lib/anim.js @@ -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)' }, + ); +} diff --git a/src/router.js b/src/router.js index 18e4b7b..4043644 100644 --- a/src/router.js +++ b/src/router.js @@ -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); diff --git a/src/styles/layout.css b/src/styles/layout.css index bc9afe1..a5dee69 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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); +} diff --git a/src/views/BreedingView.js b/src/views/BreedingView.js index cdfb629..14ded8e 100644 --- a/src/views/BreedingView.js +++ b/src/views/BreedingView.js @@ -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' }); diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 09c7776..b68594a 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -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 }); + } }); } diff --git a/src/views/SearchView.js b/src/views/SearchView.js index d9d86b7..ea3dd08 100644 --- a/src/views/SearchView.js +++ b/src/views/SearchView.js @@ -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' }); diff --git a/src/views/TeamView.js b/src/views/TeamView.js index 7c7094c..0fd4203 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -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' });