dex/src/router.js
chris fb299d11f9 Hidden mini-game: "Who's that Pokémon?"
Guess the Pokémon from its silhouette (official artwork, filter:brightness(0)
until revealed). Streak + best + win% persisted in pdx.whosThat. Three
misses auto-reveal; hints (letter count, then first letter) after wrong
guesses; guess matching is punctuation/space/case-insensitive.

Not in the nav or any link. Reached by the Konami code (↑↑↓↓←→←→BA,
lib/konami.js wired in main.js), seven quick taps on the nav wordmark, or
the bare #/whos-that hash once you know it.

9 new unit tests for the konami matcher (sequence, case-insensitivity,
rewind, partial).

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

122 lines
4.3 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 { TypeChartView } from './views/TypeChartView.js';
import { CompareView } from './views/CompareView.js';
import { ProgressView } from './views/ProgressView.js';
import { BreedingView } from './views/BreedingView.js';
import { ShinyView } from './views/ShinyView.js';
import { WhosThatView } from './views/WhosThatView.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: /^#\/types$/, view: () => TypeChartView() },
{ pattern: /^#\/compare$/, view: () => CompareView() },
{ pattern: /^#\/progress$/, view: () => ProgressView() },
{ pattern: /^#\/breeding$/, view: () => BreedingView() },
{ pattern: /^#\/shiny$/, view: () => ShinyView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
];
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()) {
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);
}
}
async function render() {
const hash = location.hash || '#/';
const { route, match } = resolve(hash);
// Instant feedback: a shaped skeleton for detail pages, a light
// 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(match)
: el('div', { class: 'view view--loading' }, 'Loading…'),
isPokemon,
);
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();
}