dex/src/router.js
chris 4b9bc5c0ab Settings: text size, reduce motion, landing screen, haptics
Four new preferences, all in settings.js / applied via applyTheme() so
boot and live changes go through one path:

- fontScale (small/default/large): scales root font-size (92/100/115%);
  the whole stylesheet is already rem-based so this alone rescales
  everything.
- reduceMotion: an explicit in-app override alongside the existing
  `prefers-reduced-motion` OS check, via a new
  :root[data-reduce-motion="true"] rule in tokens.css mirroring the
  existing media-query kill-switch. New prefersReducedMotion() helper
  (checks both) replaces the three hand-rolled matchMedia checks in
  router.js/Card.js/PokemonDetail.js.
- defaultRoute (dex/team/search): redirects on a genuinely hash-less
  launch only (bookmark, home-screen icon, bare URL) — explicit nav
  (e.g. tapping "Dex") is never touched, since it's applied once in
  main.js before initRouter runs.
- haptics: a short navigator.vibrate() pulse on the same catch/seen/
  favorite toggles that already play the pop animation, via a new
  lib/haptics.js buzz() (no-ops without Vibration API support, e.g. iOS
  Safari).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 12:35:26 -04:00

95 lines
3.1 KiB
JavaScript

import { el } from './lib/dom.js';
import { DexGrid } from './views/DexGrid.js';
import { PokemonDetail } from './views/PokemonDetail.js';
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 { AbilityDetail } from './views/AbilityDetail.js';
import { TeamView } from './views/TeamView.js';
import { NaturesView } from './views/NaturesView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
const routes = [
{ pattern: /^#?\/?$/, view: () => DexGrid() },
{ 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: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/team$/, view: () => TeamView() },
{ pattern: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
];
function resolve(hash) {
for (const route of routes) {
const match = hash.match(route.pattern);
if (match) return { route, match };
}
return { route: routes[0], match: [] };
}
const canAnimate = () =>
typeof document.startViewTransition === 'function' && !prefersReducedMotion();
let rerenderCurrent = null;
/** Re-run the active route (e.g. after the selected game changed). */
export function rerender() {
if (rerenderCurrent) rerenderCurrent();
}
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);
// 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);
if ((location.hash || '#/') !== hash) return; // superseded
// Skeleton/placeholder -> content morphs via the View Transitions API.
mount(node, true);
} catch (err) {
console.error(err);
mount(
el(
'div',
{ class: 'view view--error' },
el('h1', {}, 'Something went wrong'),
el('p', {}, err.message || String(err)),
el('a', { href: '#/', class: 'button' }, 'Back to the dex'),
),
false,
);
}
}
rerenderCurrent = render;
window.addEventListener('hashchange', render);
render();
}