All-games mode, full flavour list, persistent search

- FlavorText: 'All Pokédex entries' disclosure listing every distinct
  English entry across all games (newest first, labelled by game) — not
  just the selected game's one line
- Search remembers its query and scroll position in the ui store, so Back
  or re-opening the tab restores the results; header explains the scope
  (matches all species; opens for the selected game / all games)
- GameSheet 'All games' option: National Dex, newest data, no generation
  limits — abilities/shiny ungated, types untrimmed, evolution unpruned;
  Moves/Locations prompt to pick a game

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-27 13:06:56 -04:00
parent 0665d0f2ef
commit 01269fd35a
7 changed files with 172 additions and 42 deletions

View File

@ -1,36 +1,49 @@
import { el } from '../lib/dom.js';
import { prettify } from '../data/pokedex-resolver.js';
const clean = (s) => s.replace(/\s+/g, ' ').trim();
function label(names) {
const arr = [...names].map(prettify);
return arr.length <= 3 ? arr.join(', ') : `${arr.slice(0, 3).join(', ')} +${arr.length - 3}`;
}
/**
* Pokédex flavour text for the selected game. Most games ship two versions
* (Scarlet/Violet, Sword/Shield) with different entries show a pill
* toggle and slide between them. Falls back to the most recent English
* entry when the game itself has none.
* Pokédex flavour text. The primary block shows the selected game's
* entry/entries (with a Sword/Shield-style pill toggle when they differ);
* an "All Pokédex entries" disclosure lists every distinct entry across
* every game, newest first.
*/
export function FlavorText(species, vg) {
const versions = vg ? vg.versions : [];
const english = species.flavor_text_entries.filter((f) => f.language.name === 'en');
if (!english.length) return null;
const byVersion = new Map();
for (const f of english) {
if (!byVersion.has(f.version.name)) {
byVersion.set(f.version.name, f.flavor_text.replace(/\s+/g, ' '));
// distinct text -> set of version names, in newest-first order
const order = [];
const versionsByText = new Map();
for (let i = english.length - 1; i >= 0; i--) {
const text = clean(english[i].flavor_text);
if (!versionsByText.has(text)) {
versionsByText.set(text, new Set());
order.push(text);
}
versionsByText.get(text).add(english[i].version.name);
}
let picks = versions
.filter((v) => byVersion.has(v))
.map((v) => ({ version: v, text: byVersion.get(v) }));
// Collapse identical entries (both versions often share the same text).
const seen = new Set();
picks = picks.filter((p) => (seen.has(p.text) ? false : seen.add(p.text)));
if (!picks.length && english.length) {
const last = english[english.length - 1];
picks = [{ version: last.version.name, text: last.flavor_text.replace(/\s+/g, ' '), fallback: true }];
// The selected game's distinct entries.
const wanted = vg ? vg.versions : [];
let primary = [];
for (const v of wanted) {
const hit = english.find((e) => e.version.name === v);
if (!hit) continue;
const text = clean(hit.flavor_text);
if (!primary.some((p) => p.text === text)) primary.push({ version: v, text });
}
let fallback = false;
if (!primary.length) {
primary = [{ version: [...versionsByText.get(order[0])][0], text: order[0] }];
fallback = true;
}
if (!picks.length) return null;
const quote = el('p', { class: 'ppanel__flavor' });
const caption = el('span', { class: 'flavor__cap' });
@ -38,20 +51,19 @@ export function FlavorText(species, vg) {
let index = 0;
function paint() {
const p = picks[index];
const p = primary[index];
quote.textContent = p.text;
quote.style.animation = 'none';
// eslint-disable-next-line no-unused-expressions
quote.offsetWidth;
quote.offsetWidth; // reflow so the slide-in re-runs
quote.style.animation = '';
caption.textContent = p.fallback
caption.textContent = fallback
? `${prettify(p.version)} (most recent)`
: `— Pokémon ${prettify(p.version)}`;
[...toggle.children].forEach((b, i) => b.classList.toggle('is-active', i === index));
}
if (picks.length > 1) {
picks.forEach((p, i) =>
if (primary.length > 1) {
primary.forEach((p, i) =>
toggle.append(
el(
'button',
@ -68,7 +80,35 @@ export function FlavorText(species, vg) {
),
);
}
paint();
return el('div', { class: 'flavor' }, picks.length > 1 ? toggle : null, quote, caption);
const allEntries =
order.length > 1
? el(
'details',
{ class: 'flavor__all' },
el('summary', {}, `All Pokédex entries (${order.length})`),
el(
'ul',
{ class: 'flavor__list' },
...order.map((text) =>
el(
'li',
{},
el('span', { class: 'flavor__list-ver' }, label(versionsByText.get(text))),
el('span', {}, text),
),
),
),
)
: null;
return el(
'div',
{ class: 'flavor' },
primary.length > 1 ? toggle : null,
quote,
caption,
allEntries,
);
}

View File

@ -76,6 +76,23 @@ export async function openGameSheet() {
function renderGames() {
const st = settings.get();
clear(list);
list.append(
el(
'button',
{
class: `gamecard gamecard--all${st.versionGroup === 'all' ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
settings.set({ versionGroup: 'all', pokedex: null });
close();
},
},
el('span', { class: 'gamecard__name' }, 'All games'),
el('span', { class: 'gamecard__meta' }, 'National Dex · newest data, no generation limits'),
),
);
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
list.append(
el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`),

View File

@ -6,4 +6,6 @@ import { createStore } from './createStore.js';
*/
export const ui = createStore('pdx.ui', {
detailTab: 'about',
searchQuery: '',
searchScroll: 0,
});

View File

@ -904,6 +904,38 @@
color: var(--text-dim);
text-transform: capitalize;
}
.flavor__all {
margin-top: 12px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.flavor__all summary {
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-dim);
}
.flavor__list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.flavor__list li {
display: grid;
gap: 2px;
font-size: 0.88rem;
line-height: 1.5;
}
.flavor__list-ver {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--type-main, var(--accent));
}
@media (prefers-reduced-motion: reduce) {
.flavor .ppanel__flavor {
animation: none;
@ -1434,3 +1466,11 @@
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 8px;
}
.gamecard--all {
width: 100%;
margin-bottom: 4px;
border-style: dashed;
}
.gamecard--all.is-active {
border-style: solid;
}

View File

@ -158,7 +158,8 @@ export async function DexGrid() {
ids = rows.map((r) => r.species.id);
title.textContent = prettify(dex.name || dex.key);
gamePill.firstChild.textContent = prettify(st.versionGroup);
gamePill.firstChild.textContent =
st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup);
refreshMeta();
paintGrid();
}

View File

@ -202,20 +202,28 @@ export async function PokemonDetail(nationalId) {
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
const allGames = st.versionGroup === 'all';
// ---- Moves panel ----------------------------------------
const movesPanel = el(
'div',
{ class: 'ppanel' },
MovesList(pokemon.moves, {
versionGroupKey: st.versionGroup,
gen: vgGen,
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
}),
allGames
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
: MovesList(pokemon.moves, {
versionGroupKey: st.versionGroup,
gen: vgGen,
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
}),
);
// ---- Locations panel ----------------------------------
const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
if (NO_ENCOUNTER_DATA.has(st.versionGroup)) {
if (allGames) {
locPanel.replaceChildren(
el('p', { class: 'detail__muted' }, 'Pick a game to see where to catch it.'),
);
} else if (NO_ENCOUNTER_DATA.has(st.versionGroup)) {
locPanel.replaceChildren(
el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."),
);

View File

@ -1,25 +1,43 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { entry } from '../store/selection.js';
import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
/**
* Global search across every Pokémon, not just the current dex. Runs
* entirely off the bundled snapshot, so it works offline.
* Global search across every Pokémon, independent of the selected dex. Runs
* off the bundled snapshot (offline-capable). The query and scroll position
* are kept in the `ui` store so going back or re-opening the tab lands you
* exactly where you left off.
*
* Results are game-agnostic matching is on name / national number / type.
* Opening a result shows that Pokémon for whichever game is currently
* selected (or all games), so its stats, learnset and dex text follow that
* choice, not the search.
*/
export async function SearchView() {
const snap = await loadSnapshot();
const view = el('section', { class: 'view search' });
const st = settings.get();
const scope =
st.versionGroup === 'all'
? 'Opening one shows it across all games.'
: `Opening one shows it for ${prettify(st.versionGroup)}.`;
const results = el('ul', { class: 'search__results' });
const input = el('input', {
class: 'search__input',
type: 'search',
placeholder: 'Name, number or type…',
autofocus: true,
oninput: (e) => run(e.target.value),
value: ui.get().searchQuery || '',
oninput: (e) => {
ui.set({ searchQuery: e.target.value });
run(e.target.value);
},
});
view.append(
@ -27,7 +45,7 @@ export async function SearchView() {
'header',
{ class: 'view__header' },
el('h1', {}, 'Search all Pokémon'),
el('p', {}, 'Look up any Pokémon from any game — not limited to your selected Pokédex.'),
el('p', {}, `Every Pokémon, any game. ${scope}`),
),
input,
results,
@ -72,6 +90,10 @@ export async function SearchView() {
}
}
onTeardown(view, () => {});
// Restore query + scroll on entry; remember scroll on the way out.
run(input.value);
requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0));
onTeardown(view, () => ui.set({ searchScroll: window.scrollY }));
return view;
}