Add a per-species form dex (Unown letters, Vivillon patterns, ...)

New tab on the detail page — "<Species> Dex" — for any species with more
than one cosmetic form: a checklist grid (sprite + label + tap-to-toggle)
tracking which appearances you've caught, separate from the species' own
caught flag. Persisted in a new pdx.formTracking store keyed by form slug.

Only offered when the species is actually in the currently selected
game's dex (reuses the page's existing prev/next row lookup), so Unown
gets its dex in HeartGold/SoulSilver but not in Sword/Shield, where it
was never released.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-04 11:35:03 -04:00
parent fc2128dabe
commit 1ed8fcb709
3 changed files with 168 additions and 1 deletions

20
src/store/formTracking.js Normal file
View File

@ -0,0 +1,20 @@
import { createStore } from './createStore.js';
/**
* Caught-tracking for purely-cosmetic form sets (Unown's letters, Vivillon's
* patterns, Alcremie's decorations). Keyed by form slug rather than
* national dex id, since these are separate `pdx.selection`-style
* collectibles that all share one species entry.
*/
export const formTracking = createStore('pdx.formTracking', { caught: {} });
export function isFormCaught(slug) {
return !!formTracking.get().caught[slug];
}
export function toggleFormCaught(slug) {
formTracking.set((s) => ({
...s,
caught: { ...s.caught, [slug]: !s.caught[slug] },
}));
}

View File

@ -2566,6 +2566,80 @@
.formbar__select option {
color: initial;
}
.formdex__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.formdex__count {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-dim);
white-space: nowrap;
}
.formdex {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
gap: 8px;
margin-top: 10px;
}
.formdex__tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px 6px;
border: none;
border-radius: 14px;
background: var(--surface-2);
cursor: pointer;
filter: grayscale(1);
opacity: 0.55;
transition: filter 0.15s, opacity 0.15s, background 0.15s;
}
.formdex__tile.is-caught {
filter: none;
opacity: 1;
background: color-mix(in srgb, var(--type-main) 16%, var(--surface-2));
}
.formdex__sprite {
width: 40px;
height: 40px;
object-fit: contain;
image-rendering: pixelated;
}
.formdex__label {
font-size: 0.62rem;
font-weight: 700;
text-align: center;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.formdex__check {
position: absolute;
top: 4px;
right: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--good);
color: #fff;
font-size: 0.6rem;
line-height: 16px;
text-align: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.15s, transform 0.15s;
}
.formdex__tile.is-caught .formdex__check {
opacity: 1;
transform: scale(1);
}
.card__forms {
margin-top: 4px;
font-size: 0.62rem;

View File

@ -7,6 +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 { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
import { Sprite, spriteUrl } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { StatBar } from '../components/StatBar.js';
@ -424,14 +425,86 @@ export async function PokemonDetail(nationalId) {
);
}
// ---- Form dex (Unown letters, Vivillon patterns, Alcremie decorations…) ----
// A checklist of purely-cosmetic appearances, tracked separately from the
// species' own caught flag. Only offered when this Pokémon is actually in
// the currently selected game's dex (`pos >= 0`) — no Vivillon checklist
// for a game it was never released in.
let formDexPanel = null;
if (cosmeticForms.length > 1 && pos >= 0) {
const slots = [
{ slug: snapSpecies.name, id: nationalId, name: 'Default', sprite: null },
...cosmeticForms,
];
const countEl = el('span', { class: 'formdex__count' });
const syncCount = () => {
const n = slots.filter((f) => isFormCaught(f.slug)).length;
countEl.textContent = `${n} / ${slots.length} caught`;
};
const grid = el(
'div',
{ class: 'formdex' },
...slots.map((f) => {
const img = el('img', {
class: 'sprite formdex__sprite',
loading: 'lazy',
decoding: 'async',
alt: f.name,
src: f.sprite || spriteUrl(f.id, 'default'),
});
img.addEventListener(
'error',
() => {
img.src = spriteUrl(nationalId, 'default');
},
{ once: true },
);
const tile = el(
'button',
{
type: 'button',
class: `formdex__tile${isFormCaught(f.slug) ? ' is-caught' : ''}`,
onclick: () => {
toggleFormCaught(f.slug);
tile.classList.toggle('is-caught', isFormCaught(f.slug));
syncCount();
},
},
img,
el('span', { class: 'formdex__label' }, f.name),
el('span', { class: 'formdex__check', 'aria-hidden': 'true' }, '✓'),
);
return tile;
}),
);
syncCount();
formDexPanel = el(
'div',
{ class: 'ppanel' },
el(
'div',
{ class: 'formdex__head' },
el('h2', { class: 'ppanel__sub' }, `${prettify(snapSpecies.name)} dex`),
countEl,
),
el(
'p',
{ class: 'detail__muted' },
"Track which appearances you've caught — separate from the Pokémon's own caught flag.",
),
grid,
);
}
// ---- Tabs -------------------------------------------
const TABS = [
{ id: 'about', label: 'About', node: aboutPanel },
formDexPanel ? { id: 'formdex', label: `${prettify(snapSpecies.name)} Dex`, node: formDexPanel } : null,
{ id: 'stats', label: 'Stats', node: statsPanel },
{ id: 'evo', label: 'Evolution', node: evoPanel },
{ id: 'moves', label: 'Moves', node: movesPanel },
{ id: 'loc', label: `Locations`, node: locPanel },
];
].filter(Boolean);
const body = el('div', { class: 'psheet__body' });
const tabButtons = TABS.map((t) =>
el(