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
122 lines
4.3 KiB
JavaScript
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();
|
|
}
|