Abilities lookup + detail; Natures reference

- Snapshot bakes an abilities index (373: name, generation, effect, and the
  species that have it). Snapshot ~872 KB / 133 KB gzip.
- Search gains an 'Abilities' tab — browse all 373 offline, filter/search by
  name or effect, sort A–Z / most-Pokemon / newest
- #/ability/:id detail: effect + a grid of every Pokemon with the ability
  (all from the snapshot, no fetch)
- Pokemon detail: the Abilities line is now tappable, linking to each ability
- #/natures: the full 25-nature +10%/-10% table; linked from the Team view

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-28 11:00:14 -04:00
parent 79a5efaf11
commit 415cf3edec
11 changed files with 328 additions and 7 deletions

View File

@ -220,6 +220,31 @@ async function main() {
});
moves.sort((a, b) => a.id - b.id);
// ---- Abilities index ------------------------------------------
const abilityIndex = await api('ability?limit=100000');
console.log(`Fetching ${abilityIndex.results.length} abilities …`);
const abilities = await mapLimit(abilityIndex.results, CONCURRENCY, async (r) => {
const a = await api(r.url);
const en = (a.effect_entries || []).find((e) => e.language.name === 'en');
const flavour = [...(a.flavor_text_entries || [])]
.reverse()
.find((e) => e.language.name === 'en');
return {
id: a.id,
name: a.name,
generation: idFromUrl(a.generation.url),
isMainSeries: a.is_main_series,
effect:
(en && (en.short_effect || en.effect)) ||
(flavour && flavour.flavor_text) ||
'',
pokemon: [
...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)),
],
};
});
abilities.sort((a, b) => a.id - b.id);
// ---- Item categories (gives every item a category cheaply) ----
const catIndex = await api('item-category?limit=100');
const items = [];
@ -240,6 +265,7 @@ async function main() {
species: species.length,
moves: moves.length,
items: items.length,
abilities: abilities.length,
pokedexes: pokedexes.length,
versionGroups: versionGroups.length,
},
@ -249,6 +275,7 @@ async function main() {
species,
moves,
items,
abilities,
pokedexes,
versionGroups,
};

39
src/data/natures.js Normal file
View File

@ -0,0 +1,39 @@
/**
* The 25 natures. Each raises one stat by 10% and lowers another by 10%;
* the five where up === down are neutral. HP is never affected.
*/
export const STAT_LABEL = {
atk: 'Attack',
def: 'Defense',
spa: 'Sp. Atk',
spd: 'Sp. Def',
spe: 'Speed',
};
export const NATURES = [
{ name: 'Hardy', up: 'atk', down: 'atk' },
{ name: 'Lonely', up: 'atk', down: 'def' },
{ name: 'Brave', up: 'atk', down: 'spe' },
{ name: 'Adamant', up: 'atk', down: 'spa' },
{ name: 'Naughty', up: 'atk', down: 'spd' },
{ name: 'Bold', up: 'def', down: 'atk' },
{ name: 'Docile', up: 'def', down: 'def' },
{ name: 'Relaxed', up: 'def', down: 'spe' },
{ name: 'Impish', up: 'def', down: 'spa' },
{ name: 'Lax', up: 'def', down: 'spd' },
{ name: 'Timid', up: 'spe', down: 'atk' },
{ name: 'Hasty', up: 'spe', down: 'def' },
{ name: 'Serious', up: 'spe', down: 'spe' },
{ name: 'Jolly', up: 'spe', down: 'spa' },
{ name: 'Naive', up: 'spe', down: 'spd' },
{ name: 'Modest', up: 'spa', down: 'atk' },
{ name: 'Mild', up: 'spa', down: 'def' },
{ name: 'Quiet', up: 'spa', down: 'spe' },
{ name: 'Bashful', up: 'spa', down: 'spa' },
{ name: 'Rash', up: 'spa', down: 'spd' },
{ name: 'Calm', up: 'spd', down: 'atk' },
{ name: 'Gentle', up: 'spd', down: 'def' },
{ name: 'Sassy', up: 'spd', down: 'spe' },
{ name: 'Careful', up: 'spd', down: 'spa' },
{ name: 'Quirky', up: 'spd', down: 'spd' },
];

View File

@ -22,8 +22,10 @@ export async function loadSnapshot() {
data.generationById = new Map(data.generations.map((g) => [g.id, g]));
data.moves = data.moves || [];
data.items = data.items || [];
data.abilities = data.abilities || [];
data.moveById = new Map(data.moves.map((m) => [m.id, m]));
data.itemById = new Map(data.items.map((it) => [it.id, it]));
data.abilityById = new Map(data.abilities.map((a) => [a.id, a]));
data.itemCategories = [...new Set(data.items.map((it) => it.category))].sort();
cached = data;

View File

@ -5,7 +5,9 @@ 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';
const routes = [
@ -13,7 +15,9 @@ const routes = [
{ 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() },
];

View File

@ -24,4 +24,5 @@ export const ui = createStore('pdx.ui', {
mvTm: false,
itCat: '',
itSort: 'name',
abSort: 'name',
});

View File

@ -2305,3 +2305,88 @@
border-radius: 6px;
text-transform: capitalize;
}
/* ---- Abilities ---------------------------------------------- */
.search__row--ability {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
padding: 11px 14px;
}
.search__ab-eff {
flex-basis: 100%;
font-size: 0.78rem;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ab-mons {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 8px;
}
.ab-mon {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
border-radius: var(--radius-sm);
background: var(--surface-2);
text-decoration: none;
color: inherit;
font-size: 0.72rem;
font-weight: 600;
text-transform: capitalize;
text-align: center;
}
.ab-mon:hover {
background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
}
.ab-mon .sprite {
width: 48px;
height: 48px;
object-fit: contain;
}
/* ---- Natures table --------------------------------------- */
.natures {
display: flex;
flex-direction: column;
}
.natures__row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
padding: 9px 6px;
border-bottom: 1px solid var(--border);
font-size: 0.88rem;
}
.natures__row--head {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
.natures__name {
font-weight: 700;
}
.natures__up {
color: var(--good);
font-weight: 600;
}
.natures__down {
color: var(--danger);
font-weight: 600;
}
.natures__row.is-neutral {
color: var(--text-dim);
}
.natures__row.is-neutral .natures__up,
.natures__row.is-neutral .natures__down {
color: var(--text-dim);
font-weight: 400;
}

View File

@ -0,0 +1,66 @@
import { el, clear } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { Sprite } from '../components/Sprite.js';
export async function AbilityDetail(id) {
const view = el('section', { class: 'view lookup' });
const snap = await loadSnapshot();
const a = snap.abilityById.get(id);
if (!a) {
view.append(
el(
'div',
{ class: 'view--error' },
el('h1', {}, 'Unknown ability'),
el('a', { class: 'button', href: '#/search' }, 'Back to lookup'),
),
);
return view;
}
const style = settings.get().spriteStyle;
const mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
mons.sort((x, y) => x.id - y.id);
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
'header',
{ class: 'lookup__head lookup__head--item' },
el(
'div',
{},
el('h1', {}, a.name.replace(/-/g, ' ')),
el('span', { class: 'lookup__class' }, `Introduced Gen ${a.generation}`),
),
),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, 'Effect'),
el('p', {}, a.effect || 'No description available.'),
),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, `Pokémon with this ability (${mons.length})`),
mons.length
? el(
'div',
{ class: 'ab-mons' },
...mons.map((sp) =>
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
)
: el('p', { class: 'detail__muted' }, 'None on record.'),
),
);
return view;
}

36
src/views/NaturesView.js Normal file
View File

@ -0,0 +1,36 @@
import { el } from '../lib/dom.js';
import { NATURES, STAT_LABEL } from '../data/natures.js';
export function NaturesView() {
const view = el('section', { class: 'view lookup' });
view.append(
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Natures'),
el('p', {}, 'Each nature raises one stat by 10% and lowers another by 10%. HP is never affected; five natures are neutral.'),
),
el(
'div',
{ class: 'natures' },
el(
'div',
{ class: 'natures__row natures__row--head' },
el('span', {}, 'Nature'),
el('span', { class: 'natures__up' }, '▲ raises'),
el('span', { class: 'natures__down' }, '▼ lowers'),
),
...NATURES.map((n) => {
const neutral = n.up === n.down;
return el(
'div',
{ class: `natures__row${neutral ? ' is-neutral' : ''}` },
el('span', { class: 'natures__name' }, n.name),
el('span', { class: 'natures__up' }, neutral ? '—' : STAT_LABEL[n.up]),
el('span', { class: 'natures__down' }, neutral ? '—' : STAT_LABEL[n.down]),
);
}),
),
);
return view;
}

View File

@ -242,9 +242,19 @@ export async function PokemonDetail(nationalId) {
syncTrack();
// ---- About panel -----------------------------------------------
const abilities = pokemon.abilities
.filter((a) => !a.is_hidden || vgGen >= 5)
.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''));
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 evYield =
pokemon.stats
@ -267,7 +277,7 @@ export async function PokemonDetail(nationalId) {
{ class: 'pfacts' },
fact('Height', `${(pokemon.height / 10).toFixed(1)} m`),
fact('Weight', `${(pokemon.weight / 10).toFixed(1)} kg`),
abilities.length && vgGen >= 3 ? fact('Abilities', abilities.join(', ')) : null,
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
fact('Introduced', prettify(species.generation.name)),
species.genera?.length
? fact(

View File

@ -22,6 +22,7 @@ const TABS = [
{ id: 'pokemon', label: 'Pokémon' },
{ id: 'moves', label: 'Moves' },
{ id: 'items', label: 'Items' },
{ id: 'abilities', label: 'Abilities' },
];
const tmLabel = (raw) => {
@ -140,12 +141,30 @@ export async function SearchView() {
run();
}),
);
} else if (tab === 'abilities') {
filters.append(
pill(
'abSort',
[['name', 'AZ'], ['count', 'Most Pokémon'], ['gen', 'Newest']],
ui.get().abSort,
(v) => {
ui.set({ abSort: v });
run();
},
),
);
}
}
function syncUI() {
input.placeholder =
tab === 'pokemon' ? 'Name, number or type…' : tab === 'moves' ? 'Move name…' : 'Item name…';
tab === 'pokemon'
? 'Name, number or type…'
: tab === 'moves'
? 'Move name…'
: tab === 'items'
? 'Item name…'
: 'Ability name or effect…';
buildFilters();
}
@ -285,6 +304,32 @@ export async function SearchView() {
}
}
// ---- Abilities (from snapshot, offline) ---------------------
function renderAbilities(q) {
const sort = ui.get().abSort;
let list = snap.abilities.filter(
(a) => !q || loose(a.name).includes(q) || a.effect.toLowerCase().includes(q),
);
list.sort((a, b) => {
if (sort === 'count') return b.pokemon.length - a.pokemon.length || a.name.localeCompare(b.name);
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
return a.name.localeCompare(b.name);
});
note.textContent = `${list.length} abilit${list.length === 1 ? 'y' : 'ies'}`;
for (const a of list.slice(0, 400)) {
results.append(
el(
'a',
{ class: 'search__row search__row--ability', href: `#/ability/${a.id}` },
el('span', { class: 'search__name' }, a.name.replace(/-/g, ' ')),
el('span', { class: 'search__cat' }, `Gen ${a.generation}`),
el('span', { class: 'search__cat' }, `${a.pokemon.length} 🐾`),
el('span', { class: 'search__ab-eff' }, a.effect || '—'),
),
);
}
}
function run() {
const token = ++runToken;
const q = loose(query);
@ -299,7 +344,8 @@ export async function SearchView() {
return;
}
if (tab === 'moves') renderMoves(q, token);
else renderItems(q);
else if (tab === 'items') renderItems(q);
else renderAbilities(q);
}
run();

View File

@ -60,7 +60,12 @@ export async function TeamView() {
'header',
{ class: 'view__header' },
el('h1', {}, 'Team'),
el('p', {}, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats.`),
el(
'p',
{},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures reference'),
),
),
seg,
lineup,