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),
|
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
|
||||||
per-dex progress, name/number filter, caught/favorite filters.
|
per-dex progress, name/number filter, caught/favorite filters.
|
||||||
- **Forms** — Megas, Gigantamax, regional forms and alternate formes (264
|
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
|
||||||
across 203 species) in the snapshot; a form switcher on the detail page
|
snapshot; a pill switcher on the detail page rebuilds types / stats /
|
||||||
rebuilds types / stats / matchups / abilities / learnset / artwork for
|
matchups / abilities / learnset / artwork for the chosen form. Purely
|
||||||
the chosen form. Cards show a "+N forms" badge.
|
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 /
|
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
|
||||||
Moves / Locations); coloured animated stat bars; defensive type matchups;
|
Moves / Locations); coloured animated stat bars; defensive type matchups;
|
||||||
evolution chain re-parented to the selected game's generation; learnset with
|
evolution chain re-parented to the selected game's generation; learnset with
|
||||||
|
|||||||
@ -211,6 +211,13 @@ async function main() {
|
|||||||
.filter((v) => !v.is_default)
|
.filter((v) => !v.is_default)
|
||||||
.map((v) => v.pokemon.name);
|
.map((v) => v.pokemon.name);
|
||||||
const forms = [];
|
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) {
|
for (const slug of variantSlugs) {
|
||||||
let fp;
|
let fp;
|
||||||
try {
|
try {
|
||||||
@ -232,19 +239,54 @@ async function main() {
|
|||||||
height: fp.height,
|
height: fp.height,
|
||||||
weight: fp.weight,
|
weight: fp.weight,
|
||||||
};
|
};
|
||||||
// Drop purely-cosmetic forms: same typing, stats AND abilities as the
|
// Purely-cosmetic forms: same typing, stats AND abilities as the base,
|
||||||
// base, and not a named category (mega/gmax/regional/primal).
|
// and not a named category (mega/gmax/regional/primal).
|
||||||
const cosmetic =
|
const cosmetic =
|
||||||
cat === 'other' &&
|
cat === 'other' &&
|
||||||
ftypes.join() === baseTypes.join() &&
|
ftypes.join() === baseTypes.join() &&
|
||||||
statKey(fstats) === baseStatKey &&
|
statKey(fstats) === baseStatKey &&
|
||||||
abilKey(fp) === baseAbil;
|
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);
|
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 {
|
return {
|
||||||
forms,
|
forms,
|
||||||
|
...(cosmeticForms.length ? { cosmeticForms } : {}),
|
||||||
id,
|
id,
|
||||||
name: sp.name,
|
name: sp.name,
|
||||||
generation: idFromUrl(sp.generation.url),
|
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__num' }, `#${num}`),
|
||||||
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
|
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
|
||||||
el('span', { class: 'card__types' }, ...types.map(TypeChip)),
|
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' : ''}`)
|
const n = (species.forms?.length || 0) + (species.cosmeticForms?.length || 0);
|
||||||
: null,
|
return n ? el('span', { class: 'card__forms' }, `+${n} form${n > 1 ? 's' : ''}`) : null;
|
||||||
|
})(),
|
||||||
metric ? el('span', { class: 'card__metric' }, metric) : null,
|
metric ? el('span', { class: 'card__metric' }, metric) : null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -829,6 +829,9 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
.phero__pixelart {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
.phero__art--boxed {
|
.phero__art--boxed {
|
||||||
width: min(180px, 46vw);
|
width: min(180px, 46vw);
|
||||||
height: min(180px, 46vw);
|
height: min(180px, 46vw);
|
||||||
@ -2424,6 +2427,35 @@
|
|||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
color: var(--type-main);
|
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 {
|
.card__forms {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { settings } from '../store/settings.js';
|
|||||||
import { ui } from '../store/ui.js';
|
import { ui } from '../store/ui.js';
|
||||||
import { entry, toggle } from '../store/selection.js';
|
import { entry, toggle } from '../store/selection.js';
|
||||||
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.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 { TypeChip } from '../components/TypeChip.js';
|
||||||
import { StatBar } from '../components/StatBar.js';
|
import { StatBar } from '../components/StatBar.js';
|
||||||
import { EvolutionChain } from '../components/EvolutionChain.js';
|
import { EvolutionChain } from '../components/EvolutionChain.js';
|
||||||
@ -129,7 +129,11 @@ export async function PokemonDetail(nationalId) {
|
|||||||
// whose full /pokemon data lives in `pk`.
|
// whose full /pokemon data lives in `pk`.
|
||||||
const snapSpecies = snap.speciesById.get(nationalId);
|
const snapSpecies = snap.speciesById.get(nationalId);
|
||||||
const forms = (snapSpecies && snapSpecies.forms) || [];
|
const forms = (snapSpecies && snapSpecies.forms) || [];
|
||||||
|
const cosmeticForms = (snapSpecies && snapSpecies.cosmeticForms) || [];
|
||||||
let activeForm = null;
|
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 pk = pokemon;
|
||||||
|
|
||||||
let types = typesForGeneration(pk, vgGen);
|
let types = typesForGeneration(pk, vgGen);
|
||||||
@ -181,15 +185,36 @@ export async function PokemonDetail(nationalId) {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
function paintArt() {
|
function paintArt() {
|
||||||
artHolder.replaceChildren(
|
let node;
|
||||||
Sprite(activeForm ? activeForm.id : nationalId, {
|
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,
|
style: artStyle,
|
||||||
shiny,
|
shiny,
|
||||||
versionGroup: st.versionGroup,
|
versionGroup: st.versionGroup,
|
||||||
alt: species.name,
|
alt: species.name,
|
||||||
size: 320,
|
size: 320,
|
||||||
}),
|
});
|
||||||
);
|
}
|
||||||
|
artHolder.replaceChildren(node);
|
||||||
if (shinyBtn) {
|
if (shinyBtn) {
|
||||||
shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny';
|
shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny';
|
||||||
shinyBtn.classList.toggle('is-on', 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));
|
const heroTypes = el('div', { class: 'phero__types' }, ...types.map(TypeChip));
|
||||||
|
|
||||||
// Form switcher: Base + each snapshot form. Selecting one re-fetches that
|
// Form switcher: Base + each mechanical form (Mega / regional / alt forme).
|
||||||
// form's /pokemon and rebuilds the type-dependent parts of the page.
|
// Selecting one re-fetches that form's /pokemon and rebuilds the
|
||||||
|
// type-dependent parts of the page.
|
||||||
let formBar = null;
|
let formBar = null;
|
||||||
|
let formPills = [];
|
||||||
|
let formOpts = [];
|
||||||
if (forms.length) {
|
if (forms.length) {
|
||||||
const opts = [{ slug: null, name: 'Base' }, ...forms];
|
formOpts = [{ slug: null, name: 'Base' }, ...forms];
|
||||||
const pills = opts.map((o) =>
|
formPills = formOpts.map((o) =>
|
||||||
el(
|
el(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
class: 'formbar__pill',
|
class: 'formbar__pill',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
onclick: () => switchForm(o.slug ? o : null, pills, opts),
|
onclick: () => switchForm(o.slug ? o : null),
|
||||||
},
|
},
|
||||||
o.name,
|
o.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
pills[0].classList.add('is-active');
|
formPills[0].classList.add('is-active');
|
||||||
formBar = el('div', { class: 'formbar' }, ...pills);
|
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;
|
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 : '';
|
heroForm.textContent = form ? form.name : '';
|
||||||
try {
|
try {
|
||||||
pk = form ? await getPokemon(form.slug) : pokemon;
|
pk = form ? await getPokemon(form.slug) : pokemon;
|
||||||
@ -498,6 +533,47 @@ export async function PokemonDetail(nationalId) {
|
|||||||
if (ui.get().detailTab === 'stats') animateStats();
|
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(
|
clear(view).append(
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
@ -519,6 +595,7 @@ export async function PokemonDetail(nationalId) {
|
|||||||
heroTypes,
|
heroTypes,
|
||||||
),
|
),
|
||||||
formBar,
|
formBar,
|
||||||
|
cosmeticBar,
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'phero__stage' },
|
{ class: 'phero__stage' },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user