Compare commits

..

2 Commits

Author SHA1 Message Date
61ceb6abf3 Declutter feed header; add theme + accent options
- Feed header: the game pill is gone (the nav Games button opens the sheet);
  the active game is now a quiet text label under the title
- Settings > Theme: add Black (OLED) and Sepia alongside System/Light/Dark
- Settings > Accent colour: six swatches (red default, blue, green, amber,
  violet, rose) layered over any theme via [data-accent]; persisted
- applyTheme() takes the settings object; system theme now keys off the
  absence of [data-theme] so explicit light/sepia don't fight the media query

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:24:48 -04:00
fa6df9d60b Sorting + richer snapshot; Legendary filter; shiny toggle to hero top
- Snapshot now carries per-species base stats + BST, height, weight, base
  EXP, capture rate, growth rate, colour, egg groups, gender rate,
  legendary/mythical/baby flags, and past typings (~72 KB gzip)
- Dex feed: Sort control (dex / name / BST / each stat / height / weight /
  base EXP / catch rate / recently caught) with a direction toggle;
  sort + filter persisted in the ui store; cards show the sorted metric
- New 'Legendary' filter chip (legendary or mythical) with a live count
- Detail hero: shiny toggle moved up beside the favourite, top of the hero

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:20:19 -04:00
9 changed files with 393 additions and 137 deletions

View File

@ -2,13 +2,15 @@
/** /**
* Build a compact, offline-first data snapshot from PokéAPI. * Build a compact, offline-first data snapshot from PokéAPI.
* *
* The app needs three things to work fully offline before any lazy detail * The app runs its list, search, sort, filters and game switching entirely
* fetch: the species list (id, name, generation, types), the regional * off this file no network until you open a detail page. It carries, per
* Pokédex lists (which species, in what order, with regional numbers), and * species: name, generation, current + past typings, the six base stats and
* the version groups (games) with their associated Pokédexes. * their total, height, weight, base experience, and species flags
* (legendary / mythical / baby, capture rate, growth rate, colour, egg
* groups, gender rate). Plus every regional Pokédex and every version group.
* *
* Everything richer than that stats, abilities, flavor text, evolution * Richer detail (abilities, moves, flavour text, evolution, encounters) is
* is fetched on demand by the app and cached by the service worker. * fetched on demand by the app and cached by the service worker.
* *
* Output: src/data/snapshot.json (imported by src/data/snapshot.js) * Output: src/data/snapshot.json (imported by src/data/snapshot.js)
* Usage: node scripts/build-snapshot.mjs [--force] * Usage: node scripts/build-snapshot.mjs [--force]
@ -22,19 +24,18 @@ const OUT = resolve(__dirname, '../src/data/snapshot.json');
const BASE = 'https://pokeapi.co/api/v2'; const BASE = 'https://pokeapi.co/api/v2';
const MAX_AGE_DAYS = 30; const MAX_AGE_DAYS = 30;
const FORCE = process.argv.includes('--force'); const FORCE = process.argv.includes('--force');
const CONCURRENCY = 8; const CONCURRENCY = 16;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const pretty = (k) => const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const _cache = new Map(); const _cache = new Map();
async function api(path) { async function api(path) {
if (_cache.has(path)) return _cache.get(path); if (_cache.has(path)) return _cache.get(path);
const url = path.startsWith('http') ? path : `${BASE}/${path}`; const url = path.startsWith('http') ? path : `${BASE}/${path}`;
let lastErr; let lastErr;
for (let attempt = 0; attempt < 4; attempt++) { for (let attempt = 0; attempt < 5; attempt++) {
try { try {
const res = await fetch(url); const res = await fetch(url);
if (res.ok) { if (res.ok) {
@ -55,13 +56,19 @@ async function api(path) {
async function mapLimit(items, limit, fn) { async function mapLimit(items, limit, fn) {
const out = new Array(items.length); const out = new Array(items.length);
let i = 0; let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { let done = 0;
while (i < items.length) { const total = items.length;
const workers = Array.from({ length: Math.min(limit, total) }, async () => {
while (i < total) {
const idx = i++; const idx = i++;
out[idx] = await fn(items[idx], idx); out[idx] = await fn(items[idx], idx);
if (++done % 200 === 0 || done === total) {
process.stdout.write(`\r${done}/${total} species`);
}
} }
}); });
await Promise.all(workers); await Promise.all(workers);
if (total) process.stdout.write('\n');
return out; return out;
} }
@ -85,71 +92,29 @@ async function main() {
console.log('Building snapshot from PokéAPI …'); console.log('Building snapshot from PokéAPI …');
const t0 = Date.now(); const t0 = Date.now();
// ---- Types: build pokemonName -> [type, ...] ----------------------------- // ---- Type name list ------------------------------------------------
const typeIndex = await api('type?limit=100'); const typeIndex = await api('type?limit=100');
const usableTypes = typeIndex.results const types = typeIndex.results
.map((r) => r.name) .map((r) => r.name)
.filter((n) => !['unknown', 'shadow', 'stellar'].includes(n)); .filter((n) => !['unknown', 'shadow', 'stellar'].includes(n));
const typesByPokemon = new Map(); // ---- Generations (for display names / regions) ------------------
await mapLimit(usableTypes, CONCURRENCY, async (t) => {
const data = await api(`type/${t}`);
for (const { slot, pokemon } of data.pokemon) {
const arr = typesByPokemon.get(pokemon.name) || [];
arr.push({ slot, type: t });
typesByPokemon.set(pokemon.name, arr);
}
});
const typesFor = (name) =>
(typesByPokemon.get(name) || [])
.sort((a, b) => a.slot - b.slot)
.map((x) => x.type);
const typesForSpecies = (name) => {
const direct = typesFor(name);
if (direct.length) return direct;
// Fall back to a default form variant, e.g. "deoxys" -> "deoxys-normal".
for (const key of typesByPokemon.keys()) {
if (key === name || key.startsWith(`${name}-`)) {
const t = typesFor(key);
if (t.length) return t;
}
}
return [];
};
// ---- Generations: species -> { name, generation } ----------------------
const genIndex = await api('generation?limit=50'); const genIndex = await api('generation?limit=50');
const generations = []; const generations = [];
const speciesMeta = new Map(); await mapLimit(genIndex.results, 8, async (g) => {
await mapLimit(genIndex.results, CONCURRENCY, async (g) => {
const data = await api(`generation/${g.name}`); const data = await api(`generation/${g.name}`);
generations.push({ generations.push({
id: data.id, id: data.id,
name: pretty(data.main_region?.name || g.name), name: pretty(data.main_region?.name || g.name),
region: data.main_region?.name || null, region: data.main_region?.name || null,
}); });
for (const sp of data.pokemon_species) {
const id = idFromUrl(sp.url);
speciesMeta.set(id, { id, name: sp.name, generation: data.id });
}
}); });
generations.sort((a, b) => a.id - b.id); generations.sort((a, b) => a.id - b.id);
const speciesIds = [...speciesMeta.keys()].sort((a, b) => a - b); // ---- Pokédexes ----------------------------------------------------
const species = speciesIds.map((id) => {
const m = speciesMeta.get(id);
return {
id,
name: m.name,
generation: m.generation,
types: typesForSpecies(m.name),
};
});
// ---- Pokédexes --------------------------------------------------------
const dexIndex = await api('pokedex?limit=100'); const dexIndex = await api('pokedex?limit=100');
const pokedexes = []; const pokedexes = [];
await mapLimit(dexIndex.results, CONCURRENCY, async (d) => { await mapLimit(dexIndex.results, 8, async (d) => {
const data = await api(`pokedex/${d.name}`); const data = await api(`pokedex/${d.name}`);
pokedexes.push({ pokedexes.push({
key: data.name, key: data.name,
@ -164,10 +129,10 @@ async function main() {
}); });
pokedexes.sort((a, b) => a.key.localeCompare(b.key)); pokedexes.sort((a, b) => a.key.localeCompare(b.key));
// ---- Version groups (the "games") ----------------------------------- // ---- Version groups (the "games") -----------------------------
const vgIndex = await api('version-group?limit=100'); const vgIndex = await api('version-group?limit=100');
const versionGroups = []; const versionGroups = [];
await mapLimit(vgIndex.results, CONCURRENCY, async (v) => { await mapLimit(vgIndex.results, 8, async (v) => {
const data = await api(`version-group/${v.name}`); const data = await api(`version-group/${v.name}`);
versionGroups.push({ versionGroups.push({
key: data.name, key: data.name,
@ -181,7 +146,57 @@ async function main() {
(a, b) => a.generation - b.generation || a.key.localeCompare(b.key), (a, b) => a.generation - b.generation || a.key.localeCompare(b.key),
); );
// ---- Write ---------------------------------------------------------- // ---- Species: the data-rich part -----------------------------
const speciesIndex = await api('pokemon-species?limit=100000');
const ids = speciesIndex.results
.map((r) => idFromUrl(r.url))
.sort((a, b) => a - b);
console.log(`Fetching ${ids.length} species (stats, typings, flags) …`);
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
const [pk, sp] = await Promise.all([
api(`pokemon/${id}`),
api(`pokemon-species/${id}`),
]);
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
const stats = {
hp: s.hp ?? 0,
atk: s.attack ?? 0,
def: s.defense ?? 0,
spa: s['special-attack'] ?? 0,
spd: s['special-defense'] ?? 0,
spe: s.speed ?? 0,
};
return {
id,
name: sp.name,
generation: idFromUrl(sp.generation.url),
types: pk.types
.slice()
.sort((a, b) => a.slot - b.slot)
.map((t) => t.type.name),
pastTypes: (pk.past_types || []).map((p) => ({
gen: idFromUrl(p.generation.url),
types: p.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name),
})),
stats,
bst: stats.hp + stats.atk + stats.def + stats.spa + stats.spd + stats.spe,
height: pk.height,
weight: pk.weight,
baseExp: pk.base_experience ?? null,
isLegendary: sp.is_legendary,
isMythical: sp.is_mythical,
isBaby: sp.is_baby,
captureRate: sp.capture_rate,
baseHappiness: sp.base_happiness,
growthRate: sp.growth_rate?.name || null,
color: sp.color?.name || null,
eggGroups: sp.egg_groups.map((g) => g.name),
genderRate: sp.gender_rate,
};
});
// ---- Write ----------------------------------------------------
const snapshot = { const snapshot = {
meta: { meta: {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
@ -192,7 +207,7 @@ async function main() {
versionGroups: versionGroups.length, versionGroups: versionGroups.length,
}, },
}, },
types: usableTypes, types,
generations, generations,
species, species,
pokedexes, pokedexes,
@ -200,14 +215,12 @@ async function main() {
}; };
await mkdir(dirname(OUT), { recursive: true }); await mkdir(dirname(OUT), { recursive: true });
await writeFile(OUT, JSON.stringify(snapshot)); const body = JSON.stringify(snapshot);
const kb = (JSON.stringify(snapshot).length / 1024).toFixed(0); await writeFile(OUT, body);
console.log( console.log(
`Wrote ${OUT}${species.length} species, ${pokedexes.length} pokédexes, ` + `Wrote ${OUT}${species.length} species, ${pokedexes.length} pokédexes, ` +
`${versionGroups.length} version groups (${kb} KB) in ${( `${versionGroups.length} version groups (${(body.length / 1024).toFixed(0)} KB) in ` +
(Date.now() - t0) / `${((Date.now() - t0) / 1000).toFixed(1)}s`,
1000
).toFixed(1)}s`,
); );
} }

View File

@ -9,7 +9,7 @@ import { typeHex } from '../lib/type-color.js';
* gradient, a type-coloured spotlight behind an oversized sprite that lifts * gradient, a type-coloured spotlight behind an oversized sprite that lifts
* above the card, a number chip, and a big ghost number in the corner. * above the card, a number chip, and a big ghost number in the corner.
*/ */
export function Card(species, number, { spriteStyle = 'official', versionGroup, boxed = false } = {}) { export function Card(species, number, { spriteStyle = 'official', versionGroup, boxed = false, metric = null } = {}) {
const state = entry(species.id); const state = entry(species.id);
const mainType = (species.types || [])[0] || 'normal'; const mainType = (species.types || [])[0] || 'normal';
const num = String(number ?? species.id).padStart(3, '0'); const num = String(number ?? species.id).padStart(3, '0');
@ -53,6 +53,7 @@ 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' }, ...(species.types || []).map(TypeChip)), el('span', { class: 'card__types' }, ...(species.types || []).map(TypeChip)),
metric ? el('span', { class: 'card__metric' }, metric) : null,
), ),
); );

View File

@ -13,23 +13,36 @@ import { createStore } from './createStore.js';
export const settings = createStore('pdx.settings', { export const settings = createStore('pdx.settings', {
versionGroup: 'scarlet-violet', versionGroup: 'scarlet-violet',
pokedex: null, pokedex: null,
theme: 'system', theme: 'system', // system | light | dark | black | sepia
accent: 'red', // red | blue | green | amber | violet | rose
spriteStyle: 'official', spriteStyle: 'official',
showShiny: false, showShiny: false,
locale: 'en', locale: 'en',
}); });
export function applyTheme(theme = settings.get().theme) { const DARKISH = new Set(['dark', 'black']);
export function applyTheme(state = settings.get()) {
const root = document.documentElement; const root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme'); const { theme, accent } = state;
if (!theme || theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme); else root.setAttribute('data-theme', theme);
if (!accent || accent === 'red') root.removeAttribute('data-accent');
else root.setAttribute('data-accent', accent);
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (meta) { if (meta) {
const dark = const dark =
theme === 'dark' || DARKISH.has(theme) ||
(theme === 'system' && ((!theme || theme === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches); window.matchMedia('(prefers-color-scheme: dark)').matches);
meta.setAttribute('content', dark ? '#0b0b0c' : '#b3161a'); meta.setAttribute(
'content',
dark
? '#0b0b0c'
: getComputedStyle(root).getPropertyValue('--accent').trim() || '#b3161a',
);
} }
} }

View File

@ -8,4 +8,7 @@ export const ui = createStore('pdx.ui', {
detailTab: 'about', detailTab: 'about',
searchQuery: '', searchQuery: '',
searchScroll: 0, searchScroll: 0,
sort: 'dex',
sortDesc: false,
filter: 'all',
}); });

View File

@ -219,29 +219,13 @@
line-height: 1.1; line-height: 1.1;
text-transform: capitalize; text-transform: capitalize;
} }
.game-pill { .feed-head__game {
display: inline-flex; display: block;
align-items: center; margin-top: 4px;
gap: 6px;
margin-top: 8px;
padding: 5px 10px 5px 12px;
border: none;
border-radius: 999px;
background: var(--surface-2);
color: var(--text-dim);
font: inherit;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
text-decoration: none; color: var(--text-dim);
text-transform: capitalize; text-transform: capitalize;
cursor: pointer;
}
.game-pill:hover {
color: var(--text);
}
.game-pill__chev {
font-size: 0.9rem;
line-height: 1;
} }
.ring { .ring {
@ -355,6 +339,42 @@
opacity: 0.7; opacity: 0.7;
} }
.feed-sort {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.feed-sort__label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-dim);
}
.feed-sort__select {
flex: 1;
max-width: 220px;
padding: 7px 12px;
border: 1.5px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.feed-sort__dir {
width: 34px;
height: 34px;
flex: none;
border: 1.5px solid var(--border);
border-radius: 50%;
background: var(--surface);
color: var(--text);
font-size: 1rem;
cursor: pointer;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
@ -497,6 +517,17 @@
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 2px; margin-top: 2px;
} }
.card__metric {
margin-top: 6px;
align-self: flex-start;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--type-main) 24%, var(--surface-2));
color: var(--text);
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.card__caught { .card__caught {
position: absolute; position: absolute;
top: 10px; top: 10px;
@ -678,8 +709,14 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.phero__num { .phero__num {
flex: 1;
min-width: 0;
text-align: center;
opacity: 0.85; opacity: 0.85;
text-transform: capitalize; text-transform: capitalize;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.phero__head { .phero__head {
margin-top: 14px; margin-top: 14px;
@ -754,19 +791,23 @@
.phero__nav:hover { .phero__nav:hover {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
.phero__actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.phero__shiny { .phero__shiny {
position: absolute;
right: 20px;
bottom: 108px;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
padding: 6px 14px; padding: 6px 12px;
font: inherit; font: inherit;
font-size: 0.75rem; font-size: 0.74rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
color: var(--ink); color: var(--ink);
background: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.18);
white-space: nowrap;
} }
.phero__shiny.is-on { .phero__shiny.is-on {
background: #fff; background: #fff;
@ -902,7 +943,6 @@
display: block; display: block;
font-size: 0.76rem; font-size: 0.76rem;
color: var(--text-dim); color: var(--text-dim);
text-transform: capitalize;
} }
.flavor__all { .flavor__all {
margin-top: 12px; margin-top: 12px;
@ -1323,6 +1363,28 @@
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
.swatches {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.swatch {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--sw);
cursor: pointer;
padding: 0;
transition: transform 0.12s var(--ease-spring);
}
.swatch:hover {
transform: scale(1.1);
}
.swatch.is-on {
border-color: var(--text);
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--sw);
}
/* ---------- Toast ------------------------------------------- */ /* ---------- Toast ------------------------------------------- */
.toast { .toast {

View File

@ -47,8 +47,9 @@
--type-fairy: #ec8fe6; --type-fairy: #ec8fe6;
} }
/* System default: follow the OS only when no explicit theme is chosen. */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { :root:not([data-theme]) {
--bg: #0b0b0c; --bg: #0b0b0c;
--surface: #17171a; --surface: #17171a;
--surface-2: #202024; --surface-2: #202024;
@ -73,6 +74,39 @@
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
} }
/* Pure black — for OLED screens. */
:root[data-theme="black"] {
--bg: #000000;
--surface: #0b0b0c;
--surface-2: #17171a;
--text: #f4f4f6;
--text-dim: #9a9aa4;
--border: #222226;
--accent: #ff5a5f;
--accent-text: #16161a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.6), 0 10px 30px rgba(0, 0, 0, 0.55);
--shadow-lg: 0 20px 50px -12px rgba(0, 0, 0, 0.65);
}
/* Sepia — a warm, low-glare light theme. */
:root[data-theme="sepia"] {
--bg: #f2e8d5;
--surface: #fbf4e4;
--surface-2: #e9dbbe;
--text: #3b3022;
--text-dim: #7c6c52;
--border: #dccbaa;
--accent: #a8410f;
--accent-text: #ffffff;
}
/* Accent overrides — layered on top of any theme. Red is the default. */
:root[data-accent="blue"] { --accent: #2f6fed; --accent-text: #ffffff; }
:root[data-accent="green"] { --accent: #1f9d57; --accent-text: #ffffff; }
:root[data-accent="amber"] { --accent: #d98a00; --accent-text: #1a1200; }
:root[data-accent="violet"] { --accent: #7b52e0; --accent-text: #ffffff; }
:root[data-accent="rose"] { --accent: #e0417a; --accent-text: #ffffff; }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }

View File

@ -2,40 +2,88 @@ import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js'; import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { selection, stats } from '../store/selection.js'; import { selection, stats } from '../store/selection.js';
import { Card } from '../components/Card.js'; import { Card } from '../components/Card.js';
import { ProgressRing } from '../components/ProgressRing.js'; import { ProgressRing } from '../components/ProgressRing.js';
import { openGameSheet } from '../components/GameSheet.js';
const FILTERS = [ const FILTERS = [
{ key: 'all', label: 'All', test: () => true }, { key: 'all', label: 'All', test: () => true },
{ key: 'caught', label: 'Caught', test: (e) => e.caught }, { key: 'caught', label: 'Caught', test: (e) => e.caught },
{ key: 'missing', label: 'Missing', test: (e) => !e.caught }, { key: 'missing', label: 'Missing', test: (e) => !e.caught },
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite }, { key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
]; ];
const SORTS = [
{ key: 'dex', label: 'Dex number', desc: false },
{ key: 'name', label: 'Name (AZ)', desc: false },
{ key: 'bst', label: 'Base stat total', desc: true },
{ key: 'hp', label: 'HP', desc: true },
{ key: 'atk', label: 'Attack', desc: true },
{ key: 'def', label: 'Defense', desc: true },
{ key: 'spa', label: 'Sp. Attack', desc: true },
{ key: 'spd', label: 'Sp. Defense', desc: true },
{ key: 'spe', label: 'Speed', desc: true },
{ key: 'height', label: 'Height', desc: true },
{ key: 'weight', label: 'Weight', desc: true },
{ key: 'baseExp', label: 'Base EXP', desc: true },
{ key: 'catchrate', label: 'Catch rate', desc: true },
{ key: 'caught', label: 'Recently caught', desc: true },
];
const STAT_SHORT = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' };
const SEARCH_ICON = const SEARCH_ICON =
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>'; '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
function sortValue(row, key) {
const sp = row.species;
switch (key) {
case 'dex': return row.number ?? sp.id;
case 'name': return sp.name;
case 'bst': return sp.bst ?? 0;
case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe':
return sp.stats?.[key] ?? 0;
case 'height': return sp.height ?? 0;
case 'weight': return sp.weight ?? 0;
case 'baseExp': return sp.baseExp ?? 0;
case 'catchrate': return sp.captureRate ?? 0;
case 'caught': return selection.get().pokemon[sp.id]?.updatedAt || '';
default: return 0;
}
}
function metricLabel(key, sp) {
switch (key) {
case 'bst': return `BST ${sp.bst}`;
case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe':
return `${STAT_SHORT[key]} ${sp.stats?.[key] ?? 0}`;
case 'height': return `${((sp.height ?? 0) / 10).toFixed(1)} m`;
case 'weight': return `${((sp.weight ?? 0) / 10).toFixed(1)} kg`;
case 'baseExp': return sp.baseExp != null ? `${sp.baseExp} EXP` : null;
case 'catchrate': return `Catch ${sp.captureRate ?? '?'}`;
default: return null;
}
}
export async function DexGrid() { export async function DexGrid() {
const view = el('section', { class: 'view dexgrid' }); const view = el('section', { class: 'view dexgrid' });
const snap = await loadSnapshot(); const snap = await loadSnapshot();
const grid = el('div', { class: 'grid' }); const grid = el('div', { class: 'grid' });
let query = ''; let query = '';
let filterKey = 'all'; let filterKey = ui.get().filter || 'all';
let sortKey = ui.get().sort || 'dex';
let sortDesc = !!ui.get().sortDesc;
let rows = []; let rows = [];
let ids = []; let ids = [];
const ring = ProgressRing(); const ring = ProgressRing();
const title = el('h1', {}); const title = el('h1', {});
const gamePill = el( // Which game is active — a quiet label, not a control. Change it from the
'button', // Games button in the nav.
{ class: 'game-pill', type: 'button', onclick: () => openGameSheet() }, const gameLabel = el('span', { class: 'feed-head__game' });
el('span', {}),
el('span', { class: 'game-pill__chev', 'aria-hidden': 'true' }, '⌄'),
);
const search = el('input', { const search = el('input', {
class: 'field-search__input', class: 'field-search__input',
@ -53,10 +101,11 @@ export async function DexGrid() {
const btn = el( const btn = el(
'button', 'button',
{ {
class: 'feed-chip', class: `feed-chip${f.key === filterKey ? ' is-active' : ''}`,
type: 'button', type: 'button',
onclick: () => { onclick: () => {
filterKey = f.key; filterKey = f.key;
ui.set({ filter: f.key });
chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key)); chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key));
paintGrid(); paintGrid();
}, },
@ -67,7 +116,37 @@ export async function DexGrid() {
filterBar.append(btn); filterBar.append(btn);
return { btn, count }; return { btn, count };
}); });
chipEls[0].btn.classList.add('is-active');
const sortSelect = el(
'select',
{
class: 'feed-sort__select',
onchange: (e) => {
sortKey = e.target.value;
sortDesc = SORTS.find((s) => s.key === sortKey).desc;
ui.set({ sort: sortKey, sortDesc });
syncDirBtn();
paintGrid();
},
},
...SORTS.map((s) => el('option', { value: s.key, selected: s.key === sortKey }, s.label)),
);
const dirBtn = el('button', {
class: 'feed-sort__dir',
type: 'button',
title: 'Reverse order',
onclick: () => {
sortDesc = !sortDesc;
ui.set({ sortDesc });
syncDirBtn();
paintGrid();
},
});
function syncDirBtn() {
dirBtn.textContent = sortDesc ? '↓' : '↑';
dirBtn.setAttribute('aria-label', sortDesc ? 'Descending' : 'Ascending');
}
syncDirBtn();
view.append( view.append(
el( el(
@ -76,7 +155,7 @@ export async function DexGrid() {
el( el(
'div', 'div',
{ class: 'feed-head__top' }, { class: 'feed-head__top' },
el('div', { class: 'feed-head__id' }, title, gamePill), el('div', { class: 'feed-head__id' }, title, gameLabel),
ring.node, ring.node,
), ),
el( el(
@ -86,6 +165,13 @@ export async function DexGrid() {
search, search,
), ),
filterBar, filterBar,
el(
'div',
{ class: 'feed-sort' },
el('span', { class: 'feed-sort__label' }, 'Sort'),
sortSelect,
dirBtn,
),
), ),
grid, grid,
); );
@ -94,13 +180,14 @@ export async function DexGrid() {
const p = selection.get().pokemon; const p = selection.get().pokemon;
let caught = 0; let caught = 0;
let favorite = 0; let favorite = 0;
for (const id of ids) { let legendary = 0;
const e = p[id]; for (const { species } of rows) {
if (!e) continue; const e = p[species.id];
if (e.caught) caught++; if (e?.caught) caught++;
if (e.favorite) favorite++; if (e?.favorite) favorite++;
if (species.isLegendary || species.isMythical) legendary++;
} }
return { all: ids.length, caught, missing: ids.length - caught, favorite }; return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary };
} }
function refreshMeta() { function refreshMeta() {
@ -114,38 +201,49 @@ export async function DexGrid() {
function paintGrid() { function paintGrid() {
const st = settings.get(); const st = settings.get();
const pokemonState = selection.get().pokemon; const pokemonState = selection.get().pokemon;
// Gen 12 game-era sprites have opaque white backgrounds — frame them.
const boxed = const boxed =
st.spriteStyle === 'game' && st.spriteStyle === 'game' &&
(snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2; (snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2;
const filterTest = FILTERS.find((f) => f.key === filterKey).test; const filterTest = FILTERS.find((f) => f.key === filterKey).test;
const frag = document.createDocumentFragment();
let shown = 0;
for (const { species, number } of rows) { let list = rows.filter(({ species, number }) => {
if ( if (
query && query &&
!species.name.includes(query) && !species.name.includes(query) &&
String(number) !== query && String(number) !== query &&
String(species.id) !== query String(species.id) !== query
) { ) {
continue; return false;
} }
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false }; const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
if (!filterTest(e)) continue; return filterTest(e, species);
});
const dir = sortDesc ? -1 : 1;
list = list
.map((row) => ({ row, v: sortValue(row, sortKey) }))
.sort((a, b) => {
if (a.v < b.v) return -dir;
if (a.v > b.v) return dir;
return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id);
})
.map((x) => x.row);
const frag = document.createDocumentFragment();
list.forEach(({ species, number }, i) => {
const card = Card(species, number, { const card = Card(species, number, {
spriteStyle: st.spriteStyle, spriteStyle: st.spriteStyle,
versionGroup: st.versionGroup, versionGroup: st.versionGroup,
boxed, boxed,
metric: metricLabel(sortKey, species),
}); });
if (shown < 30) card.style.setProperty('--i', String(shown)); if (i < 30) card.style.setProperty('--i', String(i));
frag.append(card); frag.append(card);
shown++; });
}
clear(grid); clear(grid);
grid.append( grid.append(
shown === 0 list.length === 0
? el('p', { class: 'grid__empty' }, 'Nothing matches those filters.') ? el('p', { class: 'grid__empty' }, 'Nothing matches those filters.')
: frag, : frag,
); );
@ -158,7 +256,7 @@ export async function DexGrid() {
ids = rows.map((r) => r.species.id); ids = rows.map((r) => r.species.id);
title.textContent = prettify(dex.name || dex.key); title.textContent = prettify(dex.name || dex.key);
gamePill.firstChild.textContent = gameLabel.textContent =
st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup); st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup);
refreshMeta(); refreshMeta();
paintGrid(); paintGrid();
@ -169,7 +267,8 @@ export async function DexGrid() {
const offSettings = settings.subscribe(rebuild); const offSettings = settings.subscribe(rebuild);
const offSelection = selection.subscribe(() => { const offSelection = selection.subscribe(() => {
refreshMeta(); refreshMeta();
if (filterKey !== 'all') paintGrid(); if (filterKey !== 'all' && filterKey !== 'legendary') paintGrid();
else if (sortKey === 'caught') paintGrid();
}); });
onTeardown(view, () => { onTeardown(view, () => {
offSettings(); offSettings();

View File

@ -300,7 +300,7 @@ export async function PokemonDetail(nationalId) {
{ class: 'phero__top' }, { class: 'phero__top' },
el('a', { class: 'phero__back', href: '#/' }, ' Dex'), el('a', { class: 'phero__back', href: '#/' }, ' Dex'),
el('span', { class: 'phero__num' }, numLabel), el('span', { class: 'phero__num' }, numLabel),
favBtn, el('div', { class: 'phero__actions' }, shinyBtn, favBtn),
), ),
el( el(
'div', 'div',
@ -319,7 +319,6 @@ export async function PokemonDetail(nationalId) {
? el('a', { class: 'phero__nav phero__nav--next', href: `#/pokemon/${next.species.id}`, title: next.species.name }, '') ? el('a', { class: 'phero__nav phero__nav--next', href: `#/pokemon/${next.species.id}`, title: next.species.name }, '')
: el('span', { class: 'phero__nav' }), : el('span', { class: 'phero__nav' }),
), ),
shinyBtn,
), ),
el( el(
'div', 'div',

View File

@ -10,11 +10,43 @@ export async function SettingsView() {
['system', 'System'], ['system', 'System'],
['light', 'Light'], ['light', 'Light'],
['dark', 'Dark'], ['dark', 'Dark'],
['black', 'Black (OLED)'],
['sepia', 'Sepia'],
], (v) => { ], (v) => {
settings.set({ theme: v }); settings.set({ theme: v });
applyTheme(v); applyTheme();
}); });
const ACCENTS = [
['red', '#b3161a'],
['blue', '#2f6fed'],
['green', '#1f9d57'],
['amber', '#d98a00'],
['violet', '#7b52e0'],
['rose', '#e0417a'],
];
const accentRow = el(
'div',
{ class: 'swatches' },
...ACCENTS.map(([key, hex]) =>
el('button', {
class: `swatch${st.accent === key ? ' is-on' : ''}`,
type: 'button',
style: `--sw:${hex}`,
title: key,
'aria-label': `${key} accent`,
onclick: () => {
settings.set({ accent: key });
applyTheme();
[...accentRow.children].forEach((b, i) =>
b.classList.toggle('is-on', ACCENTS[i][0] === key),
);
},
}),
),
);
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
const spriteField = selectField('Sprite style', st.spriteStyle, [ const spriteField = selectField('Sprite style', st.spriteStyle, [
['default', 'Pixel (modern)'], ['default', 'Pixel (modern)'],
['game', 'Game era (pixel art from the selected game)'], ['game', 'Game era (pixel art from the selected game)'],
@ -61,7 +93,7 @@ export async function SettingsView() {
view.append( view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')), el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
el('div', { class: 'settings__group' }, themeField, spriteField, shinyField), el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField),
el('h2', {}, 'Your data'), el('h2', {}, 'Your data'),
storageNote, storageNote,