Forms & variants

- Snapshot: each species gains a forms[] list (slug, form id, label,
  category, types, stats, BST, height, weight) built from its non-default
  varieties — 264 forms across 203 species (Megas, G-max, regional,
  alt formes; cosmetic-only forms filtered out). Snapshot ~914 KB.
- Detail page: a form switcher (Base + each form). Selecting a form
  re-fetches its /pokemon and rebuilds the type-dependent parts —
  hero name/types/artwork, --type theming, stat bars + total, defensive
  matchups, STAB coverage, abilities, EV yield, held items, and learnset.
  Species-level data (flavour, breeding, evolution) stays put.
- Grid cards show a '+N forms' badge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-28 11:11:07 -04:00
parent 415cf3edec
commit 40667cbdb6
5 changed files with 291 additions and 100 deletions

View File

@ -71,6 +71,10 @@ Working app with a type-themed UI:
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
per-dex progress, name/number filter, caught/favorite filters.
- **Forms** — Megas, Gigantamax, regional forms and alternate formes (264
across 203 species) in the snapshot; a form switcher on the detail page
rebuilds types / stats / matchups / abilities / learnset / artwork for
the chosen form. Cards show a "+N forms" badge.
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
Moves / Locations); coloured animated stat bars; defensive type matchups;
evolution chain re-parented to the selected game's generation; learnset with

View File

@ -30,6 +30,34 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const FORM_LABELS = {
mega: 'Mega',
'mega-x': 'Mega X',
'mega-y': 'Mega Y',
gmax: 'Gigantamax',
alola: 'Alolan',
galar: 'Galarian',
hisui: 'Hisuian',
paldea: 'Paldean',
primal: 'Primal',
origin: 'Origin',
altered: 'Altered',
incarnate: 'Incarnate',
therian: 'Therian',
};
function formLabel(slug, speciesName) {
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
return FORM_LABELS[rest] || pretty(rest);
}
function formCategory(slug, speciesName) {
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
if (rest.startsWith('mega')) return 'mega';
if (rest === 'gmax') return 'gmax';
if (['alola', 'galar', 'hisui', 'paldea'].includes(rest)) return 'regional';
if (rest === 'primal') return 'primal';
return 'other';
}
const _cache = new Map();
async function api(path) {
if (_cache.has(path)) return _cache.get(path);
@ -152,14 +180,9 @@ async function main() {
.map((r) => idFromUrl(r.url))
.sort((a, b) => a - b);
console.log(`Fetching ${ids.length} species (stats, typings, flags) …`);
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
const [pk, sp] = await Promise.all([
api(`pokemon/${id}`),
api(`pokemon-species/${id}`),
]);
const statMap = (pk) => {
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
const stats = {
return {
hp: s.hp ?? 0,
atk: s.attack ?? 0,
def: s.defense ?? 0,
@ -167,7 +190,56 @@ async function main() {
spd: s['special-defense'] ?? 0,
spe: s.speed ?? 0,
};
};
const bstOf = (st) => st.hp + st.atk + st.def + st.spa + st.spd + st.spe;
const sameShape = (a, b) =>
a.types.join() === b.types.join() && a.bst === b.bst &&
a.stats.hp === b.stats.hp && a.stats.spe === b.stats.spe && a.stats.atk === b.stats.atk;
console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`);
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
const [pk, sp] = await Promise.all([
api(`pokemon/${id}`),
api(`pokemon-species/${id}`),
]);
const stats = statMap(pk);
const baseTypes = pk.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
// Non-default varieties = forms (megas, G-max, regional, alt formes).
const variantSlugs = (sp.varieties || [])
.filter((v) => !v.is_default)
.map((v) => v.pokemon.name);
const forms = [];
for (const slug of variantSlugs) {
let fp;
try {
fp = await api(`pokemon/${slug}`);
} catch {
continue;
}
const fstats = statMap(fp);
const ftypes = fp.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
const cat = formCategory(slug, sp.name);
const entry = {
slug,
id: fp.id,
name: formLabel(slug, sp.name),
category: cat,
types: ftypes,
stats: fstats,
bst: bstOf(fstats),
height: fp.height,
weight: fp.weight,
};
// Drop purely-cosmetic forms (identical stats & typing, no named category).
if (cat === 'other' && sameShape(entry, { types: baseTypes, bst: bstOf(stats), stats })) {
continue;
}
forms.push(entry);
}
return {
forms,
id,
name: sp.name,
generation: idFromUrl(sp.generation.url),

View File

@ -61,6 +61,9 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
el('span', { class: 'card__num' }, `#${num}`),
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
el('span', { class: 'card__types' }, ...types.map(TypeChip)),
species.forms?.length
? el('span', { class: 'card__forms' }, `+${species.forms.length} form${species.forms.length > 1 ? 's' : ''}`)
: null,
metric ? el('span', { class: 'card__metric' }, metric) : null,
),
);

View File

@ -2390,3 +2390,43 @@
color: var(--text-dim);
font-weight: 400;
}
/* ---- Forms ------------------------------------------------- */
.phero__form {
font-weight: 500;
font-size: 0.62em;
opacity: 0.85;
margin-left: 8px;
}
.phero__form:empty {
display: none;
}
.formbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.formbar__pill {
appearance: none;
border: 1.5px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.14);
color: var(--ink);
font: inherit;
font-size: 0.74rem;
font-weight: 600;
padding: 5px 12px;
border-radius: 999px;
cursor: pointer;
}
.formbar__pill.is-active {
background: #fff;
border-color: #fff;
color: var(--type-main);
}
.card__forms {
margin-top: 4px;
font-size: 0.62rem;
font-weight: 700;
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim));
}

View File

@ -125,11 +125,21 @@ export async function PokemonDetail(nationalId) {
recent: [nationalId, ...(s.recent || []).filter((x) => x !== nationalId)].slice(0, 12),
}));
const types = typesForGeneration(pokemon, vgGen);
const mainType = types[0];
view.style.setProperty('--type-main', typeHex(mainType));
view.style.setProperty('--type-2', typeHex(types[1] || mainType));
view.dataset.type = mainType;
// Form state: null = the base Pokémon; otherwise a snapshot form entry
// whose full /pokemon data lives in `pk`.
const snapSpecies = snap.speciesById.get(nationalId);
const forms = (snapSpecies && snapSpecies.forms) || [];
let activeForm = null;
let pk = pokemon;
let types = typesForGeneration(pk, vgGen);
let mainType = types[0];
function applyThemeVars() {
view.style.setProperty('--type-main', typeHex(mainType));
view.style.setProperty('--type-2', typeHex(types[1] || mainType));
view.dataset.type = mainType;
}
applyThemeVars();
// ---- Artwork + shiny --------------------------------------------
// Shiny Pokémon were introduced in Gen 2 — no toggle for Gen 1 games.
@ -172,7 +182,7 @@ export async function PokemonDetail(nationalId) {
: null;
function paintArt() {
artHolder.replaceChildren(
Sprite(nationalId, {
Sprite(activeForm ? activeForm.id : nationalId, {
style: artStyle,
shiny,
versionGroup: st.versionGroup,
@ -241,65 +251,108 @@ export async function PokemonDetail(nationalId) {
}
syncTrack();
// ---- About panel -----------------------------------------------
const abilityList = pokemon.abilities.filter((a) => !a.is_hidden || vgGen >= 5);
const abilitiesNode = el(
'span',
{},
...abilityList.flatMap((a, i) => {
const link = el(
'a',
{ class: 'link', href: `#/ability/${idFromUrl(a.ability.url)}` },
prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''),
);
return i === 0 ? [link] : [', ', link];
}),
);
const allGames = st.versionGroup === 'all';
let statBars = [];
const evYield =
pokemon.stats
.filter((s) => s.effort > 0)
.map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`)
.join(', ') || '—';
const heldItems =
pokemon.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
// ---- Form-dependent content (rebuilt when a form is selected) -----
function formFactsNode(p) {
const abilityList = p.abilities.filter((a) => !a.is_hidden || vgGen >= 5);
const abilitiesNode = el(
'span',
{},
...abilityList.flatMap((a, i) => {
const link = el(
'a',
{ class: 'link', href: `#/ability/${idFromUrl(a.ability.url)}` },
prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''),
);
return i === 0 ? [link] : [', ', link];
}),
);
const evYield =
p.stats.filter((s) => s.effort > 0).map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`).join(', ') ||
'—';
const heldItems = p.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
return el(
'div',
{},
el(
'dl',
{ class: 'pfacts' },
fact('Height', `${(p.height / 10).toFixed(1)} m`),
fact('Weight', `${(p.weight / 10).toFixed(1)} kg`),
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
fact('Introduced', prettify(species.generation.name)),
species.genera?.length
? fact('Category', (species.genera.find((g) => g.language.name === 'en') || {}).genus || '—')
: null,
),
el('h3', { class: 'ppanel__sub' }, 'Training'),
el(
'dl',
{ class: 'pfacts' },
fact('EV yield', evYield),
fact('Base EXP', p.base_experience ?? '—'),
fact(
'Catch rate',
species.capture_rate != null
? `${species.capture_rate} / 255 (~${Math.round((species.capture_rate / 255) * 100)}% max)`
: '—',
),
fact('Base friendship', species.base_happiness ?? '—'),
fact(
'Growth rate',
species.growth_rate?.name
? `${prettify(species.growth_rate.name)}${
GROWTH_EXP[species.growth_rate.name]
? ` · ${GROWTH_EXP[species.growth_rate.name].toLocaleString()} EXP`
: ''
}`
: '—',
),
fact('Held items', heldItems),
),
);
}
function statsContent(p) {
const stat = (name) => p.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
const total = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
return [
el('div', { class: 'pstats' }, ...statBars),
el(
'div',
{ class: 'statbar statbar--total' },
el('span', { class: 'statbar__label' }, 'Total'),
el('span', { class: 'statbar__value' }, String(total)),
el('div', { class: 'statbar__track' }),
),
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
TypeMatchups(types, vgGen),
offensiveBlock(types, vgGen),
];
}
function movesContent(p) {
return allGames
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
: MovesList(p.moves, {
versionGroupKey: st.versionGroup,
gen: vgGen,
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
});
}
// ---- About panel (FlavorText + form facts + species breeding) ----
const eggGroups = species.egg_groups.map((g) => prettify(g.name));
const hatch = species.hatch_counter;
const growth = species.growth_rate?.name;
const rate = species.capture_rate;
const factsHolder = el('div', {}, formFactsNode(pk));
const aboutPanel = el(
'div',
{ class: 'ppanel' },
FlavorText(species, vg),
el(
'dl',
{ class: 'pfacts' },
fact('Height', `${(pokemon.height / 10).toFixed(1)} m`),
fact('Weight', `${(pokemon.weight / 10).toFixed(1)} kg`),
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
fact('Introduced', prettify(species.generation.name)),
species.genera?.length
? fact(
'Category',
(species.genera.find((g) => g.language.name === 'en') || {}).genus || '—',
)
: null,
),
el('h3', { class: 'ppanel__sub' }, 'Training'),
el(
'dl',
{ class: 'pfacts' },
fact('EV yield', evYield),
fact('Base EXP', pokemon.base_experience ?? '—'),
fact('Catch rate', rate != null ? `${rate} / 255 (~${Math.round((rate / 255) * 100)}% max)` : '—'),
fact('Base friendship', species.base_happiness ?? '—'),
fact(
'Growth rate',
growth ? `${prettify(growth)}${GROWTH_EXP[growth] ? ` · ${GROWTH_EXP[growth].toLocaleString()} EXP` : ''}` : '—',
),
fact('Held items', heldItems),
),
factsHolder,
el('h3', { class: 'ppanel__sub' }, 'Breeding'),
el(
'dl',
@ -316,45 +369,14 @@ export async function PokemonDetail(nationalId) {
),
);
// ---- Stats panel ---------------------------------------------
const stat = (name) =>
pokemon.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
const statTotal = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
const statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
const statsPanel = el(
'div',
{ class: 'ppanel' },
el('div', { class: 'pstats' }, ...statBars),
el(
'div',
{ class: 'statbar statbar--total' },
el('span', { class: 'statbar__label' }, 'Total'),
el('span', { class: 'statbar__value' }, String(statTotal)),
el('div', { class: 'statbar__track' }),
),
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
TypeMatchups(types, vgGen),
offensiveBlock(types, vgGen),
);
const statsPanel = el('div', { class: 'ppanel' }, ...statsContent(pk));
// ---- Evolution panel --------------------------------------
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
const allGames = st.versionGroup === 'all';
// ---- Moves panel ----------------------------------------
const movesPanel = el(
'div',
{ class: 'ppanel' },
allGames
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
: MovesList(pokemon.moves, {
versionGroupKey: st.versionGroup,
gen: vgGen,
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
}),
);
const movesPanel = el('div', { class: 'ppanel' }, movesContent(pk));
// ---- Locations panel ----------------------------------
const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
@ -427,6 +449,55 @@ export async function PokemonDetail(nationalId) {
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
: `#${String(nationalId).padStart(4, '0')} · National`;
const heroForm = el('span', { class: 'phero__form' });
const heroName = el(
'h1',
{ class: 'phero__name' },
el('span', {}, species.name.replace(/-/g, ' ')),
heroForm,
);
const heroTypes = el('div', { class: 'phero__types' }, ...types.map(TypeChip));
// Form switcher: Base + each snapshot form. Selecting one re-fetches that
// form's /pokemon and rebuilds the type-dependent parts of the page.
let formBar = null;
if (forms.length) {
const opts = [{ slug: null, name: 'Base' }, ...forms];
const pills = opts.map((o) =>
el(
'button',
{
class: 'formbar__pill',
type: 'button',
onclick: () => switchForm(o.slug ? o : null, pills, opts),
},
o.name,
),
);
pills[0].classList.add('is-active');
formBar = el('div', { class: 'formbar' }, ...pills);
}
async function switchForm(form, pills, opts) {
activeForm = form;
pills.forEach((b, i) => b.classList.toggle('is-active', opts[i].slug === (form ? form.slug : null)));
heroForm.textContent = form ? form.name : '';
try {
pk = form ? await getPokemon(form.slug) : pokemon;
} catch {
pk = pokemon;
}
types = typesForGeneration(pk, vgGen);
mainType = types[0];
applyThemeVars();
paintArt();
heroTypes.replaceChildren(...types.map(TypeChip));
factsHolder.replaceChildren(formFactsNode(pk));
statsPanel.replaceChildren(...statsContent(pk));
movesPanel.replaceChildren(movesContent(pk));
if (ui.get().detailTab === 'stats') animateStats();
}
clear(view).append(
el(
'div',
@ -444,9 +515,10 @@ export async function PokemonDetail(nationalId) {
el(
'div',
{ class: 'phero__head' },
el('h1', { class: 'phero__name' }, species.name.replace(/-/g, ' ')),
el('div', { class: 'phero__types' }, ...types.map(TypeChip)),
heroName,
heroTypes,
),
formBar,
el(
'div',
{ class: 'phero__stage' },