Cosmetic forms: compact Appearance picker

Pull sprite-only variants into the snapshot — Unown letters, Vivillon
patterns, Furfrou trims, seasonal Deerling/Sawsbuck, Flabébé-line colours,
Alcremie decorations, Pikachu caps (278 across 50 species). They share the
base Pokémon's typing/stats/abilities/learnset, so they carry only
{ slug, id, name, sprite } and never trigger a /pokemon fetch.

build-snapshot.mjs: cosmetic varieties (previously dropped) and the
/pokemon-form entries for species with >1 form now land in
species.cosmeticForms.

PokemonDetail: a single <select> "Appearance" dropdown, separate from the
mechanical form pills, that swaps only the hero artwork + label. Picking a
cosmetic look while a mechanical form is active reverts to base first.
Card "+N forms" badge counts cosmetic variants too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-08-28 11:59:20 -04:00
parent f9f2b8f1e8
commit 344e641475
5 changed files with 180 additions and 24 deletions

View File

@ -71,10 +71,14 @@ 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.
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
snapshot; a pill switcher on the detail page rebuilds types / stats /
matchups / abilities / learnset / artwork for the chosen form. Purely
cosmetic variants (Unown's 27 letters, Vivillon's 19 patterns, Furfrou
trims, seasonal Deerling/Sawsbuck, Flabébé line colours, Alcremie's 62
decorations, Pikachu caps — 278 across 50 species) get a separate compact
"Appearance" dropdown that only swaps the sprite. 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

@ -211,6 +211,13 @@ async function main() {
.filter((v) => !v.is_default)
.map((v) => v.pokemon.name);
const forms = [];
// Sprite-only variants (Pikachu caps, Alcremie decorations, Gastrodon
// east/west, Unown letters, Vivillon patterns, Furfrou trims, seasonal
// Deerling/Sawsbuck, Flabébé/Floette/Florges colours…). Share the base
// Pokémon's typing, stats, abilities and learnset — only the art differs,
// so they get a lightweight { slug, id, name, sprite } record and a
// separate, compact picker in the UI.
const cosmeticForms = [];
for (const slug of variantSlugs) {
let fp;
try {
@ -232,19 +239,54 @@ async function main() {
height: fp.height,
weight: fp.weight,
};
// Drop purely-cosmetic forms: same typing, stats AND abilities as the
// base, and not a named category (mega/gmax/regional/primal).
// Purely-cosmetic forms: same typing, stats AND abilities as the base,
// and not a named category (mega/gmax/regional/primal).
const cosmetic =
cat === 'other' &&
ftypes.join() === baseTypes.join() &&
statKey(fstats) === baseStatKey &&
abilKey(fp) === baseAbil;
if (cosmetic) continue;
if (cosmetic) {
cosmeticForms.push({
slug,
id: fp.id,
name: formLabel(slug, sp.name),
sprite: fp.sprites?.front_default || null,
});
continue;
}
forms.push(entry);
}
// Cosmetic forms that live on /pokemon-form rather than as varieties
// (Unown, Vivillon, Furfrou, Deerling, Sawsbuck, Flabébé line…).
if ((pk.forms || []).length > 1) {
const known = new Set([...forms, ...cosmeticForms].map((f) => f.slug));
for (const fref of pk.forms) {
if (fref.name === pk.name || known.has(fref.name)) continue;
let fm;
try {
fm = await api(fref.url);
} catch {
continue;
}
if (fm.is_default || fm.is_battle_only || fm.is_mega) continue;
const rest = fm.name.startsWith(`${sp.name}-`)
? fm.name.slice(sp.name.length + 1)
: fm.form_name || fm.name;
cosmeticForms.push({
slug: fm.name,
id: fm.id,
name: pretty(rest || fm.form_name || fm.name),
sprite: fm.sprites?.front_default || null,
});
known.add(fm.name);
}
}
return {
forms,
...(cosmeticForms.length ? { cosmeticForms } : {}),
id,
name: sp.name,
generation: idFromUrl(sp.generation.url),

View File

@ -61,9 +61,10 @@ 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,
(() => {
const n = (species.forms?.length || 0) + (species.cosmeticForms?.length || 0);
return n ? el('span', { class: 'card__forms' }, `+${n} form${n > 1 ? 's' : ''}`) : null;
})(),
metric ? el('span', { class: 'card__metric' }, metric) : null,
),
);

View File

@ -829,6 +829,9 @@
height: 100%;
object-fit: contain;
}
.phero__pixelart {
image-rendering: pixelated;
}
.phero__art--boxed {
width: min(180px, 46vw);
height: min(180px, 46vw);
@ -2424,6 +2427,35 @@
border-color: #fff;
color: var(--type-main);
}
.formbar--cosmetic {
align-items: center;
gap: 8px;
}
.formbar__caption {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.8;
}
.formbar__select {
appearance: none;
border: 1.5px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.14)
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23fff' d='M1 1l5 5 5-5'/%3E%3C/svg%3E")
no-repeat right 12px center;
color: var(--ink);
font: inherit;
font-size: 0.78rem;
font-weight: 600;
padding: 6px 32px 6px 12px;
border-radius: 999px;
cursor: pointer;
max-width: 15rem;
}
.formbar__select option {
color: initial;
}
.card__forms {
margin-top: 4px;
font-size: 0.62rem;

View File

@ -7,7 +7,7 @@ import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { entry, toggle } from '../store/selection.js';
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
import { Sprite } from '../components/Sprite.js';
import { Sprite, spriteUrl } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { StatBar } from '../components/StatBar.js';
import { EvolutionChain } from '../components/EvolutionChain.js';
@ -129,7 +129,11 @@ export async function PokemonDetail(nationalId) {
// whose full /pokemon data lives in `pk`.
const snapSpecies = snap.speciesById.get(nationalId);
const forms = (snapSpecies && snapSpecies.forms) || [];
const cosmeticForms = (snapSpecies && snapSpecies.cosmeticForms) || [];
let activeForm = null;
// Sprite-only variant chosen from the "Appearance" picker (Unown letter,
// Vivillon pattern, Furfrou trim…). Only swaps the artwork + label.
let activeCosmetic = null;
let pk = pokemon;
let types = typesForGeneration(pk, vgGen);
@ -181,15 +185,36 @@ export async function PokemonDetail(nationalId) {
)
: null;
function paintArt() {
artHolder.replaceChildren(
Sprite(activeForm ? activeForm.id : nationalId, {
let node;
if (activeCosmetic) {
// Cosmetic variants rarely have official artwork — use the pixel sprite
// PokéAPI ships for the exact form, degrading to the base sprite.
node = el('img', {
class: 'sprite phero__pixelart',
loading: 'lazy',
decoding: 'async',
width: 320,
height: 320,
alt: `${species.name} (${activeCosmetic.name})`,
src: activeCosmetic.sprite || spriteUrl(nationalId, 'default'),
});
node.addEventListener(
'error',
() => {
node.src = spriteUrl(nationalId, 'default');
},
{ once: true },
);
} else {
node = Sprite(activeForm ? activeForm.id : nationalId, {
style: artStyle,
shiny,
versionGroup: st.versionGroup,
alt: species.name,
size: 320,
}),
);
});
}
artHolder.replaceChildren(node);
if (shinyBtn) {
shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny';
shinyBtn.classList.toggle('is-on', shiny);
@ -458,29 +483,39 @@ export async function PokemonDetail(nationalId) {
);
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.
// Form switcher: Base + each mechanical form (Mega / regional / alt forme).
// Selecting one re-fetches that form's /pokemon and rebuilds the
// type-dependent parts of the page.
let formBar = null;
let formPills = [];
let formOpts = [];
if (forms.length) {
const opts = [{ slug: null, name: 'Base' }, ...forms];
const pills = opts.map((o) =>
formOpts = [{ slug: null, name: 'Base' }, ...forms];
formPills = formOpts.map((o) =>
el(
'button',
{
class: 'formbar__pill',
type: 'button',
onclick: () => switchForm(o.slug ? o : null, pills, opts),
onclick: () => switchForm(o.slug ? o : null),
},
o.name,
),
);
pills[0].classList.add('is-active');
formBar = el('div', { class: 'formbar' }, ...pills);
formPills[0].classList.add('is-active');
formBar = el('div', { class: 'formbar' }, ...formPills);
}
async function switchForm(form, pills, opts) {
function syncFormPills() {
const slug = activeForm ? activeForm.slug : null;
formPills.forEach((b, i) => b.classList.toggle('is-active', formOpts[i].slug === slug));
}
async function switchForm(form) {
activeForm = form;
pills.forEach((b, i) => b.classList.toggle('is-active', opts[i].slug === (form ? form.slug : null)));
activeCosmetic = null;
if (cosmeticSelect) cosmeticSelect.value = '';
syncFormPills();
heroForm.textContent = form ? form.name : '';
try {
pk = form ? await getPokemon(form.slug) : pokemon;
@ -498,6 +533,47 @@ export async function PokemonDetail(nationalId) {
if (ui.get().detailTab === 'stats') animateStats();
}
// Appearance picker: purely-cosmetic variants (Unown letters, Vivillon
// patterns, Furfrou trims…). Can be 60+ per species, so one compact
// dropdown rather than a row of pills. Only swaps art + label.
let cosmeticBar = null;
let cosmeticSelect = null;
if (cosmeticForms.length) {
cosmeticSelect = el(
'select',
{
class: 'formbar__select',
'aria-label': 'Appearance',
onchange: (e) => {
const slug = e.target.value;
activeCosmetic = slug ? cosmeticForms.find((f) => f.slug === slug) : null;
if (activeCosmetic && activeForm) {
// A cosmetic look only applies to the base Pokémon; drop the
// mechanical form first, then re-assert the cosmetic choice.
const keep = activeCosmetic;
switchForm(null);
activeCosmetic = keep;
cosmeticSelect.value = slug;
}
heroForm.textContent = activeCosmetic
? activeCosmetic.name
: activeForm
? activeForm.name
: '';
paintArt();
},
},
el('option', { value: '' }, `Default (${cosmeticForms.length + 1})`),
...cosmeticForms.map((f) => el('option', { value: f.slug }, f.name)),
);
cosmeticBar = el(
'label',
{ class: 'formbar formbar--cosmetic' },
el('span', { class: 'formbar__caption' }, 'Appearance'),
cosmeticSelect,
);
}
clear(view).append(
el(
'div',
@ -519,6 +595,7 @@ export async function PokemonDetail(nationalId) {
heroTypes,
),
formBar,
cosmeticBar,
el(
'div',
{ class: 'phero__stage' },