From 1d35f2d433674e80a2ce9293cd2d68eb18a94640 Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 28 Aug 2026 08:36:41 -0400 Subject: [PATCH] Motion pass: view transitions, skeletons, micro-interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Router uses the View Transitions API to cross-fade placeholder -> content on navigation (falls back to a plain swap where unsupported or when the user prefers reduced motion) - Shaped shimmer skeletons for the Pokémon / move / item detail pages (src/components/skeletons.js) so there's no layout shift on load - Views fade+rise in; progress ring draws its arc from empty on mount; caught / seen / favourite toggles pop when switched on - Offline indicator pill (online/offline events) + 'Back online' toast - All new motion gated behind prefers-reduced-motion Co-Authored-By: Claude Sonnet 5 --- src/components/Card.js | 6 ++ src/components/ProgressRing.js | 24 ++++++-- src/components/skeletons.js | 50 +++++++++++++++ src/main.js | 16 +++++ src/router.js | 49 +++++++++------ src/styles/layout.css | 107 +++++++++++++++++++++++++++++++++ src/views/PokemonDetail.js | 6 ++ 7 files changed, 235 insertions(+), 23 deletions(-) create mode 100644 src/components/skeletons.js diff --git a/src/components/Card.js b/src/components/Card.js index d72bc5d..82166a0 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -28,6 +28,12 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup, const next = entry(species.id); caughtBtn.setAttribute('aria-pressed', String(next.caught)); card.classList.toggle('is-caught', next.caught); + if (next.caught && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + caughtBtn.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }], + { duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' }, + ); + } }, }); diff --git a/src/components/ProgressRing.js b/src/components/ProgressRing.js index 8b01ba9..d99e6c2 100644 --- a/src/components/ProgressRing.js +++ b/src/components/ProgressRing.js @@ -37,11 +37,25 @@ export function ProgressRing() { const value = el('span', { class: 'ring__value' }); const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value)); - function update({ caught = 0, seen = 0, total = 1 }) { - const safeTotal = Math.max(total, 1); - caughtArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - caught / safeTotal))); - seenArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - Math.max(seen, caught) / safeTotal))); - value.replaceChildren(String(caught), el('small', {}, `/${total}`)); + let first = true; + function update(stats) { + const draw = () => { + const { caught = 0, seen = 0, total = 1 } = stats; + const safeTotal = Math.max(total, 1); + caughtArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - caught / safeTotal))); + seenArc.setAttribute( + 'stroke-dashoffset', + String(CIRC * (1 - Math.max(seen, caught) / safeTotal)), + ); + value.replaceChildren(String(caught), el('small', {}, `/${total}`)); + }; + // Defer the first draw a frame so the arc animates in from empty. + if (first) { + first = false; + requestAnimationFrame(draw); + } else { + draw(); + } } return { node, update }; diff --git a/src/components/skeletons.js b/src/components/skeletons.js new file mode 100644 index 0000000..0ba7117 --- /dev/null +++ b/src/components/skeletons.js @@ -0,0 +1,50 @@ +import { el } from '../lib/dom.js'; + +const bar = (w, h = 14) => + el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` }); + +const facts = () => + el( + 'div', + { class: 'pfacts' }, + ...Array.from({ length: 6 }, () => + el('div', { class: 'fact' }, bar('42px', 9), bar('72px', 15)), + ), + ); + +/** Placeholder shown while a Pokémon detail page loads. */ +export function detailSkeleton() { + return el( + 'section', + { class: 'view pdetail sk' }, + el( + 'div', + { 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' }), + ), + el( + 'div', + { class: 'psheet' }, + el( + 'div', + { class: 'psheet__tabs' }, + ...Array.from({ length: 5 }, () => bar('58px', 18)), + ), + el('div', { class: 'ppanel' }, bar('92%'), bar('84%'), bar('66%'), facts()), + ), + ); +} + +/** Placeholder for move / item detail pages. */ +export function lookupSkeleton() { + return el( + 'section', + { class: 'view lookup sk' }, + bar('84px', 16), + el('div', { class: 'sk-hero sk-hero--sm' }), + facts(), + el('div', { class: 'detail__section' }, bar('90px', 16), bar('96%'), bar('70%')), + ); +} diff --git a/src/main.js b/src/main.js index a3fb7a1..9e0ccee 100644 --- a/src/main.js +++ b/src/main.js @@ -49,6 +49,22 @@ const updateSW = registerSW({ }, }); +// --- Offline indicator ------------------------------------------------- +const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline'); +function syncNet() { + if (navigator.onLine) { + netPill.remove(); + } else if (!netPill.isConnected) { + document.body.append(netPill); + } +} +window.addEventListener('offline', syncNet); +window.addEventListener('online', () => { + syncNet(); + showToast('Back online.', 'Dismiss'); +}); +syncNet(); + function showToast(message, actionLabel, onAction) { const toast = el( 'div', diff --git a/src/router.js b/src/router.js index ac4b38c..c2f7dff 100644 --- a/src/router.js +++ b/src/router.js @@ -5,12 +5,13 @@ import { SearchView } from './views/SearchView.js'; import { SettingsView } from './views/SettingsView.js'; import { MoveDetail } from './views/MoveDetail.js'; import { ItemDetail } from './views/ItemDetail.js'; +import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; const routes = [ { pattern: /^#?\/?$/, view: () => DexGrid() }, - { pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) }, - { pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])) }, - { pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])) }, + { pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton }, + { pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton }, + { pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton }, { pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/settings$/, view: () => SettingsView() }, ]; @@ -23,6 +24,10 @@ function resolve(hash) { return { route: routes[0], match: [] }; } +const canAnimate = () => + typeof document.startViewTransition === 'function' && + !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let rerenderCurrent = null; /** Re-run the active route (e.g. after the selected game changed). */ @@ -33,31 +38,38 @@ export function rerender() { export function initRouter(host) { let current = null; + function mount(node, animate) { + if (current) current.dispatchEvent(new CustomEvent('view:teardown')); + current = node; + if (animate && canAnimate()) { + document.startViewTransition(() => host.replaceChildren(node)); + } else { + host.replaceChildren(node); + } + } + async function render() { const hash = location.hash || '#/'; const { route, match } = resolve(hash); - if (current) { - current.dispatchEvent(new CustomEvent('view:teardown')); - current.remove(); - current = null; - } - - const pending = el('div', { class: 'view view--loading' }, 'Loading…'); - host.replaceChildren(pending); - // Views manage their own scroll: detail pages jump to the top, the feed - // and search restore where the user left off. + // Instant feedback: a shaped skeleton for detail pages, a light + // placeholder otherwise. + mount( + route.skeleton + ? route.skeleton() + : el('div', { class: 'view view--loading' }, 'Loading…'), + false, + ); window.scrollTo(0, 0); try { const node = await route.view(match); - // Guard against a fast second navigation while awaiting. - if ((location.hash || '#/') !== hash) return; - current = node; - host.replaceChildren(node); + if ((location.hash || '#/') !== hash) return; // superseded + // Skeleton/placeholder -> content morphs via the View Transitions API. + mount(node, true); } catch (err) { console.error(err); - host.replaceChildren( + mount( el( 'div', { class: 'view view--error' }, @@ -65,6 +77,7 @@ export function initRouter(host) { el('p', {}, err.message || String(err)), el('a', { href: '#/', class: 'button' }, 'Back to the dex'), ), + false, ); } } diff --git a/src/styles/layout.css b/src/styles/layout.css index 7ded742..ad750d8 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1718,3 +1718,110 @@ .gamecard--all.is-active { border-style: solid; } + +/* ================= Motion: transitions, skeletons, indicators ======== */ +.view:not(.sk):not(.view--loading) { + animation: viewIn 0.26s ease both; +} +@keyframes viewIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.26s; +} + +/* ---- Skeletons ---------------------------------------------- */ +.sk-bar { + display: block; + border-radius: 7px; + margin: 7px 0; + background-color: var(--surface-2); + background-image: linear-gradient( + 100deg, + transparent 20%, + color-mix(in srgb, var(--text) 9%, transparent) 45%, + transparent 70% + ); + background-size: 220% 100%; + background-repeat: no-repeat; + animation: shimmer 1.25s linear infinite; +} +@keyframes shimmer { + from { background-position: 220% 0; } + to { background-position: -120% 0; } +} +.sk-hero { + background: var(--surface-2); + border-radius: var(--radius-lg); + padding: 16px 20px 44px; +} +.sk-hero--sm { + min-height: 100px; + padding: 0; +} +.sk-art { + width: min(230px, 56vw); + height: min(230px, 56vw); + margin: 16px auto 0; + border-radius: 26px; + background: color-mix(in srgb, var(--text) 6%, var(--surface-2)); +} +.sk .psheet { + margin-top: -28px; +} +.sk .psheet__tabs { + gap: 12px; + border: none; +} +.sk .ppanel .sk-bar { + margin: 12px 0; +} +.sk .pfacts .sk-bar { + margin: 3px 0; +} + +/* ---- Online / offline indicator ------------------------------ */ +.netpill { + position: fixed; + left: 50%; + bottom: calc(var(--nav-size) + env(safe-area-inset-bottom) + 12px); + transform: translateX(-50%); + z-index: 45; + display: flex; + align-items: center; + gap: 7px; + padding: 7px 14px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + color: #fff; + background: #333842; + box-shadow: var(--shadow); + animation: viewIn 0.25s ease both; +} +.netpill::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: #ffb454; +} +@media (min-width: 1024px) { + .netpill { + bottom: 20px; + left: calc(50% + 110px); + } +} + +@media (prefers-reduced-motion: reduce) { + .view:not(.sk):not(.view--loading), + .netpill { + animation: none; + } + .sk-bar { + animation: none; + } +} diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 904146f..5e25c92 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -135,6 +135,12 @@ export async function PokemonDetail(nationalId) { onclick: () => { toggle(nationalId, field); syncTrack(); + if (entry(nationalId)[field] && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + b.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }], + { duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' }, + ); + } }, }); b.append(label);