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:
parent
f9f2b8f1e8
commit
344e641475
12
README.md
12
README.md
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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' },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user