Compare commits

...

4 Commits

Author SHA1 Message Date
1d35f2d433 Motion pass: view transitions, skeletons, micro-interactions
- 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 <noreply@anthropic.com>
2026-08-28 08:36:41 -04:00
4ddc427149 Stronger type-colour wash on dex cards
- Card gradient carries the primary type colour further and darker (55% ->
  15% mix with surface, was 30% -> transparent); border tint up to 38%.
  Reads clearly coloured in both themes while keeping text/chip contrast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 08:28:58 -04:00
cbfca36e68 Drop card sprite below the ghost number
- .card__art gets 36px top padding so the sprite centres in the space
  below the dex number instead of colliding with it; min-height raised to
  keep short cards showing the full sprite; spot glow moved down to match

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 08:27:13 -04:00
491d1102cc Center card sprite; ghost number to top:2px; scroll-position restore
- Card sprite sits centred in the upper area (flex:1 + place-items:center)
  instead of a forced margin; sprite-style / boxed sizing moved onto the
  img so it stays centred
- .card__ghost number nudged to top: 2px
- Feed Back-navigation restores the raw scroll offset again (dropped the
  card-anchor approach); keeps manual scrollRestoration + retry-on-reflow

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 08:25:23 -04:00
9 changed files with 264 additions and 66 deletions

View File

@ -28,6 +28,12 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
const next = entry(species.id); const next = entry(species.id);
caughtBtn.setAttribute('aria-pressed', String(next.caught)); caughtBtn.setAttribute('aria-pressed', String(next.caught));
card.classList.toggle('is-caught', 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)' },
);
}
}, },
}); });

View File

@ -37,11 +37,25 @@ export function ProgressRing() {
const value = el('span', { class: 'ring__value' }); const value = el('span', { class: 'ring__value' });
const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value)); const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value));
function update({ caught = 0, seen = 0, total = 1 }) { let first = true;
const safeTotal = Math.max(total, 1); function update(stats) {
caughtArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - caught / safeTotal))); const draw = () => {
seenArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - Math.max(seen, caught) / safeTotal))); const { caught = 0, seen = 0, total = 1 } = stats;
value.replaceChildren(String(caught), el('small', {}, `/${total}`)); 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 }; return { node, update };

View File

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

View File

@ -13,7 +13,7 @@ applyTheme();
// Own the scroll position ourselves (views restore it deliberately). // Own the scroll position ourselves (views restore it deliberately).
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
// Navigation scroll memory is per-session — start a cold launch at the top. // Navigation scroll memory is per-session — start a cold launch at the top.
ui.set({ feedScroll: 0, feedAnchor: '', searchScroll: 0 }); ui.set({ feedScroll: 0, searchScroll: 0 });
// React to preference changes. The dex feed updates itself in place, but the // React to preference changes. The dex feed updates itself in place, but the
// Pokémon detail page is built once per visit — rebuild it when the selected // Pokémon detail page is built once per visit — rebuild it when the selected
@ -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) { function showToast(message, actionLabel, onAction) {
const toast = el( const toast = el(
'div', 'div',

View File

@ -5,12 +5,13 @@ import { SearchView } from './views/SearchView.js';
import { SettingsView } from './views/SettingsView.js'; import { SettingsView } from './views/SettingsView.js';
import { MoveDetail } from './views/MoveDetail.js'; import { MoveDetail } from './views/MoveDetail.js';
import { ItemDetail } from './views/ItemDetail.js'; import { ItemDetail } from './views/ItemDetail.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
const routes = [ const routes = [
{ pattern: /^#?\/?$/, view: () => DexGrid() }, { pattern: /^#?\/?$/, view: () => DexGrid() },
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) }, { pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton },
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])) }, { pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])) }, { pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() }, { pattern: /^#\/settings$/, view: () => SettingsView() },
]; ];
@ -23,6 +24,10 @@ function resolve(hash) {
return { route: routes[0], match: [] }; return { route: routes[0], match: [] };
} }
const canAnimate = () =>
typeof document.startViewTransition === 'function' &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let rerenderCurrent = null; let rerenderCurrent = null;
/** Re-run the active route (e.g. after the selected game changed). */ /** Re-run the active route (e.g. after the selected game changed). */
@ -33,31 +38,38 @@ export function rerender() {
export function initRouter(host) { export function initRouter(host) {
let current = null; 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() { async function render() {
const hash = location.hash || '#/'; const hash = location.hash || '#/';
const { route, match } = resolve(hash); const { route, match } = resolve(hash);
if (current) { // Instant feedback: a shaped skeleton for detail pages, a light
current.dispatchEvent(new CustomEvent('view:teardown')); // placeholder otherwise.
current.remove(); mount(
current = null; route.skeleton
} ? route.skeleton()
: el('div', { class: 'view view--loading' }, 'Loading…'),
const pending = el('div', { class: 'view view--loading' }, 'Loading…'); false,
host.replaceChildren(pending); );
// Views manage their own scroll: detail pages jump to the top, the feed
// and search restore where the user left off.
window.scrollTo(0, 0); window.scrollTo(0, 0);
try { try {
const node = await route.view(match); const node = await route.view(match);
// Guard against a fast second navigation while awaiting. if ((location.hash || '#/') !== hash) return; // superseded
if ((location.hash || '#/') !== hash) return; // Skeleton/placeholder -> content morphs via the View Transitions API.
current = node; mount(node, true);
host.replaceChildren(node);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
host.replaceChildren( mount(
el( el(
'div', 'div',
{ class: 'view view--error' }, { class: 'view view--error' },
@ -65,6 +77,7 @@ export function initRouter(host) {
el('p', {}, err.message || String(err)), el('p', {}, err.message || String(err)),
el('a', { href: '#/', class: 'button' }, 'Back to the dex'), el('a', { href: '#/', class: 'button' }, 'Back to the dex'),
), ),
false,
); );
} }
} }

View File

@ -10,7 +10,6 @@ export const ui = createStore('pdx.ui', {
searchScroll: 0, searchScroll: 0,
searchTab: 'pokemon', searchTab: 'pokemon',
feedScroll: 0, feedScroll: 0,
feedAnchor: '',
sort: 'dex', sort: 'dex',
sortDesc: false, sortDesc: false,
filter: 'all', filter: 'all',

View File

@ -408,12 +408,12 @@
color: inherit; color: inherit;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
180deg, 170deg,
color-mix(in srgb, var(--type-main) 30%, var(--surface)) 0%, color-mix(in srgb, var(--type-main) 55%, var(--surface)) 0%,
color-mix(in srgb, var(--type-main) 9%, var(--surface)) 52%, color-mix(in srgb, var(--type-main) 26%, var(--surface)) 55%,
var(--surface) 100% color-mix(in srgb, var(--type-main) 15%, var(--surface)) 100%
); );
border: 1px solid color-mix(in srgb, var(--type-main) 20%, var(--border)); border: 1px solid color-mix(in srgb, var(--type-main) 38%, var(--border));
box-shadow: var(--shadow); box-shadow: var(--shadow);
content-visibility: auto; content-visibility: auto;
contain-intrinsic-size: auto 250px; contain-intrinsic-size: auto 250px;
@ -431,7 +431,7 @@
} }
.card__ghost { .card__ghost {
position: absolute; position: absolute;
top: 10px; top: 2px;
right: 8px; right: 8px;
z-index: 0; z-index: 0;
font-size: 3rem; font-size: 3rem;
@ -444,7 +444,7 @@
} }
.card__spot { .card__spot {
position: absolute; position: absolute;
top: 14px; top: 48px;
left: 50%; left: 50%;
width: 128px; width: 128px;
height: 128px; height: 128px;
@ -460,13 +460,15 @@
.card__art { .card__art {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 120px; flex: 1 1 auto;
height: 120px; min-height: 152px;
margin-top: 10px; /* flex column grows the card — no overlap with the body */ padding-top: 36px; /* drop the sprite clear of the ghost number */
display: grid;
place-items: center; /* centre the sprite in the space that's left */
} }
.card__art .sprite { .card__art .sprite {
width: 100%; width: 116px;
height: 100%; height: 116px;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.28)); filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.28));
} }
@ -474,21 +476,19 @@
.card[data-sprite="game"] .card__art .sprite { .card[data-sprite="game"] .card__art .sprite {
image-rendering: pixelated; image-rendering: pixelated;
} }
.card[data-sprite="game"] .card__art { .card[data-sprite="game"] .card__art .sprite {
width: 96px; width: 92px;
height: 96px; height: 92px;
} }
/* Gen 12 game sprites ship with an opaque white background show them as /* Gen 12 game sprites ship with an opaque white background show them as
a small framed tile rather than a box that swallows the card. */ a small framed tile rather than a box that swallows the card. */
.card[data-boxed] .card__art { .card[data-boxed] .card__art .sprite {
width: 78px; width: 74px;
height: 78px; height: 74px;
padding: 5px; padding: 5px;
border-radius: 12px; border-radius: 12px;
background: #f7f7f5; background: #f7f7f5;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.14); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.14);
}
.card[data-boxed] .card__art .sprite {
filter: none; filter: none;
} }
.card__body { .card__body {
@ -1718,3 +1718,110 @@
.gamecard--all.is-active { .gamecard--all.is-active {
border-style: solid; 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;
}
}

View File

@ -71,13 +71,6 @@ export async function DexGrid() {
const snap = await loadSnapshot(); const snap = await loadSnapshot();
const grid = el('div', { class: 'grid' }); const grid = el('div', { class: 'grid' });
// Remember which card was opened so Back can land on it exactly.
grid.addEventListener('click', (e) => {
const card = e.target.closest('.card');
if (card && !e.target.closest('.card__caught')) {
ui.set({ feedAnchor: card.getAttribute('href') });
}
});
let query = ''; let query = '';
let filterKey = ui.get().filter || 'all'; let filterKey = ui.get().filter || 'all';
let sortKey = ui.get().sort || 'dex'; let sortKey = ui.get().sort || 'dex';
@ -160,26 +153,20 @@ export async function DexGrid() {
// Changing the list order/contents makes the old scroll offset meaningless. // Changing the list order/contents makes the old scroll offset meaningless.
function resetScroll() { function resetScroll() {
ui.set({ feedScroll: 0, feedAnchor: '' }); ui.set({ feedScroll: 0 });
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
// Prefer landing on the exact card that was opened — robust against the // Return to the saved scroll offset. Retry a few times — the grid's height
// grid's estimated off-screen card heights and late header reflow (webfont // isn't final until the webfont loads and off-screen cards settle — but
// load). Re-correct a few times unless the user has already scrolled away. // stop once we've reached the target or the user starts scrolling.
function restoreScroll() { function restoreScroll() {
const anchor = ui.get().feedAnchor; const y = ui.get().feedScroll || 0;
const target = anchor && grid.querySelector(`.card[href="${anchor}"]`); if (!y) return;
if (!target) {
window.scrollTo(0, ui.get().feedScroll || 0);
return;
}
let applied = null; let applied = null;
const go = () => { const go = () => {
if (applied != null && Math.abs(window.scrollY - applied) > 8) return; // user moved if (applied != null && Math.abs(window.scrollY - applied) > 8) return; // user moved
const headH = document.querySelector('.feed-head')?.getBoundingClientRect().height || 0; window.scrollTo(0, y);
target.style.scrollMarginTop = `${Math.round(headH) + 16}px`;
target.scrollIntoView({ block: 'start' });
applied = Math.round(window.scrollY); applied = Math.round(window.scrollY);
}; };
go(); go();
@ -310,7 +297,7 @@ export async function DexGrid() {
const offSettings = settings.subscribe((s) => { const offSettings = settings.subscribe((s) => {
if (s.versionGroup !== mountedVG) { if (s.versionGroup !== mountedVG) {
mountedVG = s.versionGroup; mountedVG = s.versionGroup;
ui.set({ feedScroll: 0, feedAnchor: '' }); ui.set({ feedScroll: 0 });
} }
rebuild(); rebuild();
}); });

View File

@ -135,6 +135,12 @@ export async function PokemonDetail(nationalId) {
onclick: () => { onclick: () => {
toggle(nationalId, field); toggle(nationalId, field);
syncTrack(); 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); b.append(label);