From 1ed8fcb70907163af9be85bf1aa5f6654953853f Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 4 Sep 2026 11:35:03 -0400 Subject: [PATCH] Add a per-species form dex (Unown letters, Vivillon patterns, ...) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tab on the detail page — " 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/store/formTracking.js | 20 ++++++++++ src/styles/layout.css | 74 +++++++++++++++++++++++++++++++++++++ src/views/PokemonDetail.js | 75 +++++++++++++++++++++++++++++++++++++- 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/store/formTracking.js diff --git a/src/store/formTracking.js b/src/store/formTracking.js new file mode 100644 index 0000000..9ea5aa1 --- /dev/null +++ b/src/store/formTracking.js @@ -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] }, + })); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index e8fd803..bdc6340 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 50cc0da..e1c4f18 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -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(