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' });