Modernise the dex feed; gate shiny to Gen 2+

- Feed header: large title, tappable game pill, circular progress ring,
  icon-in-field search, filter chips with live counts (All/Caught/Missing/Favorites)
- Cards: left-aligned info, number chip, corner ghost number (no longer hidden
  behind the sprite), top-down type gradient, staggered entrance animation;
  caught toggle moved to top-left
- Detail: hide the shiny toggle for Gen 1 games (shinies are Gen 2+);
  add Category (genus) to the About tab

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-27 12:16:52 -04:00
parent b4f2460341
commit 9216a53932
5 changed files with 367 additions and 153 deletions

View File

@ -5,14 +5,14 @@ import { entry, toggle } from '../store/selection.js';
import { typeHex } from '../lib/type-color.js'; import { typeHex } from '../lib/type-color.js';
/** /**
* One Pokémon in the dex grid. Tinted by its primary type: a soft gradient, * One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
* a type-coloured spotlight behind the sprite, and a big translucent number. * gradient, a type-coloured spotlight behind an oversized sprite that lifts
* The card is a link to the detail route; the caught toggle is a real button * above the card, a number chip, and a big ghost number in the corner.
* layered on top that updates itself so toggling never repaints the grid.
*/ */
export function Card(species, number, { spriteStyle = 'official', versionGroup } = {}) { export function Card(species, number, { spriteStyle = 'official', versionGroup } = {}) {
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 caughtBtn = el('button', { const caughtBtn = el('button', {
class: 'card__caught', class: 'card__caught',
@ -37,13 +37,21 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup }
dataset: { type: mainType, sprite: spriteStyle }, dataset: { type: mainType, sprite: spriteStyle },
style: `--type-main:${typeHex(mainType)}`, style: `--type-main:${typeHex(mainType)}`,
}, },
el('span', { class: 'card__bignum' }, String(number ?? species.id).padStart(3, '0')), el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
el('span', { class: 'card__spot', 'aria-hidden': 'true' }), el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 112 }), el(
el('span', { class: 'card__num' }, `#${String(number ?? species.id).padStart(4, '0')}`), 'div',
{ class: 'card__art' },
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 116 }),
),
caughtBtn,
el(
'div',
{ class: 'card__body' },
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)),
caughtBtn, ),
); );
return card; return card;

View File

@ -0,0 +1,48 @@
import { el } from '../lib/dom.js';
const NS = 'http://www.w3.org/2000/svg';
function circle(r, cls) {
const c = document.createElementNS(NS, 'circle');
c.setAttribute('cx', '26');
c.setAttribute('cy', '26');
c.setAttribute('r', String(r));
c.setAttribute('class', cls);
return c;
}
/**
* Compact circular progress used in the dex feed header. Returns
* { node, update } update({ caught, seen, total }) re-draws the arcs and
* the centre label without rebuilding.
*/
export function ProgressRing() {
const R = 22;
const CIRC = 2 * Math.PI * R;
const track = circle(R, 'ring__track');
const seenArc = circle(R, 'ring__seen');
const caughtArc = circle(R, 'ring__caught');
for (const arc of [seenArc, caughtArc]) {
arc.setAttribute('stroke-dasharray', String(CIRC));
arc.setAttribute('stroke-dashoffset', String(CIRC));
arc.setAttribute('transform', 'rotate(-90 26 26)');
}
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 52 52');
svg.setAttribute('class', 'ring__svg');
svg.append(track, seenArc, caughtArc);
const value = el('span', { class: 'ring__value' });
const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value));
function update({ caught = 0, seen = 0, total = 1 }) {
const safeTotal = Math.max(total, 1);
caughtArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - caught / safeTotal)));
seenArc.setAttribute('stroke-dashoffset', String(CIRC * (1 - Math.max(seen, caught) / safeTotal)));
value.replaceChildren(String(caught), el('small', {}, `/${total}`));
}
return { node, update };
}

View File

@ -196,66 +196,162 @@
.type-chip[data-type="steel"] { background: var(--type-steel); } .type-chip[data-type="steel"] { background: var(--type-steel); }
.type-chip[data-type="fairy"] { background: var(--type-fairy); color: #3d1f3b; } .type-chip[data-type="fairy"] { background: var(--type-fairy); color: #3d1f3b; }
/* ---------- Dex grid ---------------------------------------------- */ /* ---------- Dex feed header ------------------------------------- */
.progress-header { .feed-head {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background: var(--bg); margin: -8px 0 18px;
padding: 8px 0 12px; padding: 14px 0 12px;
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(12px);
} }
.progress-header__titles h1 { .feed-head__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.feed-head__id h1 {
margin: 0; margin: 0;
font-size: 1.7rem; font-size: 1.9rem;
font-weight: 700; font-weight: 700;
line-height: 1.1;
text-transform: capitalize; text-transform: capitalize;
} }
.progress-header__sub, .game-pill {
.progress-header__counts { display: inline-flex;
margin: 2px 0 0; align-items: center;
color: var(--text-dim); gap: 6px;
font-size: 0.9rem; margin-top: 8px;
} padding: 5px 8px 5px 12px;
.progress-header__dot {
margin: 0 6px;
}
.progress-header__track {
margin-top: 12px;
height: 8px;
border-radius: 999px; border-radius: 999px;
background: var(--surface-2); background: var(--surface-2);
overflow: hidden; color: var(--text-dim);
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
text-transform: capitalize;
} }
.progress-header__bar { .game-pill:hover {
height: 100%; color: var(--text);
width: 0; }
border-radius: 999px; .game-pill__chev {
background: linear-gradient(90deg, var(--good), color-mix(in srgb, var(--good) 60%, #74c0fc)); font-size: 0.9rem;
transition: width 0.4s var(--ease-spring); line-height: 1;
} }
.dexgrid__controls { .ring {
display: flex; position: relative;
flex-direction: column; width: 60px;
gap: 10px; height: 60px;
margin: 8px 0 16px; flex: none;
} }
.dexgrid__search, .ring__svg {
width: 100%;
height: 100%;
}
.ring__track {
fill: none;
stroke: var(--surface-2);
stroke-width: 5;
}
.ring__seen {
fill: none;
stroke: color-mix(in srgb, var(--good) 40%, var(--surface-2));
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s var(--ease-spring);
}
.ring__caught {
fill: none;
stroke: var(--good);
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s var(--ease-spring);
}
.ring__label {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ring__value small {
font-size: 0.6rem;
font-weight: 600;
color: var(--text-dim);
}
.field-search {
position: relative;
margin: 14px 0 12px;
}
.field-search__icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--text-dim);
display: flex;
}
.field-search__input,
.search__input { .search__input {
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px 12px 44px;
border: 1.5px solid var(--border); border: 1.5px solid var(--border);
border-radius: 999px; border-radius: 999px;
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
.dexgrid__search:focus, .search__input {
padding-left: 16px;
}
.field-search__input:focus,
.search__input:focus { .search__input:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
} }
.feed-chips {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
}
.feed-chips::-webkit-scrollbar {
display: none;
}
.feed-chip {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
appearance: none;
border: 1.5px solid var(--border);
background: var(--surface);
color: var(--text-dim);
padding: 7px 14px;
border-radius: 999px;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.feed-chip.is-active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
.feed-chip__count {
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.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));
@ -283,92 +379,109 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 5px; padding: 16px 14px 14px;
padding: 16px 12px 14px;
border-radius: var(--radius); border-radius: var(--radius);
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
overflow: hidden; overflow: hidden;
background: linear-gradient( background: linear-gradient(
165deg, 180deg,
color-mix(in srgb, var(--type-main) 26%, var(--surface)) 0%, color-mix(in srgb, var(--type-main) 30%, var(--surface)) 0%,
var(--surface) 60% color-mix(in srgb, var(--type-main) 9%, var(--surface)) 52%,
var(--surface) 100%
); );
border: 1px solid color-mix(in srgb, var(--type-main) 22%, var(--border)); border: 1px solid color-mix(in srgb, var(--type-main) 20%, var(--border));
box-shadow: var(--shadow); box-shadow: var(--shadow);
content-visibility: auto; content-visibility: auto;
contain-intrinsic-size: 232px; contain-intrinsic-size: auto 236px;
transition: transform 0.14s var(--ease-spring), box-shadow 0.14s ease; transition: transform 0.14s var(--ease-spring), box-shadow 0.14s ease;
animation: cardIn 0.4s var(--ease-spring) backwards;
animation-delay: calc(var(--i, 0) * 18ms);
} }
.card > * { @keyframes cardIn {
position: relative; from { opacity: 0; transform: translateY(10px); }
z-index: 1; to { opacity: 1; transform: none; }
} }
.card:hover { .card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
} }
.card__ghost {
position: absolute;
top: -4px;
right: 6px;
z-index: 0;
font-size: 3rem;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--type-main) 34%, transparent);
pointer-events: none;
}
.card__spot { .card__spot {
position: absolute; position: absolute;
top: 30px; top: 14px;
left: 50%; left: 50%;
width: 118px; width: 128px;
height: 118px; height: 128px;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background: radial-gradient( background: radial-gradient(
circle, circle,
color-mix(in srgb, var(--type-main) 42%, transparent) 0%, color-mix(in srgb, var(--type-main) 46%, transparent) 0%,
transparent 68% transparent 66%
); );
} }
.card__bignum { .card__art {
position: absolute; position: relative;
top: 74px; z-index: 1;
left: 50%; width: 120px;
transform: translate(-50%, -50%); height: 120px;
z-index: 0;
font-size: 4.2rem;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--type-main) 24%, transparent);
pointer-events: none;
} }
.card .sprite { .card__art .sprite {
width: 112px; width: 100%;
height: 112px; height: 100%;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.22)); filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.28));
} }
.card[data-sprite="default"] .sprite, .card[data-sprite="default"] .card__art .sprite,
.card[data-sprite="game"] .sprite { .card[data-sprite="game"] .card__art .sprite {
image-rendering: pixelated; image-rendering: pixelated;
} }
.card__body {
position: relative;
z-index: 1;
align-self: stretch;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card__num { .card__num {
font-size: 0.72rem; font-size: 0.7rem;
font-weight: 700;
color: var(--text-dim); color: var(--text-dim);
font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.card__name { .card__name {
font-weight: 700; font-weight: 700;
font-size: 0.95rem; font-size: 0.98rem;
text-transform: capitalize; text-transform: capitalize;
text-align: center; line-height: 1.15;
} }
.card__types { .card__types {
display: flex; display: flex;
gap: 4px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; margin-top: 2px;
} }
.card__caught { .card__caught {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 10px; left: 10px;
z-index: 2; z-index: 3;
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
@ -378,7 +491,7 @@
place-items: center; place-items: center;
font-size: 0.8rem; font-size: 0.8rem;
color: #333; color: #333;
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
} }
.card__caught[aria-pressed="true"] { .card__caught[aria-pressed="true"] {
@ -394,6 +507,11 @@
.card.is-caught { .card.is-caught {
border-color: color-mix(in srgb, var(--good) 45%, var(--border)); border-color: color-mix(in srgb, var(--good) 45%, var(--border));
} }
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
}
/* ---------- Game picker ------------------------------------------- */ /* ---------- Game picker ------------------------------------------- */
.gamepicker__subdex { .gamepicker__subdex {

View File

@ -4,15 +4,18 @@ import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.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 { ProgressHeader } from '../components/ProgressHeader.js'; import { ProgressRing } from '../components/ProgressRing.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: 'uncaught', label: 'Not caught', 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 },
]; ];
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>';
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();
@ -20,9 +23,21 @@ export async function DexGrid() {
const grid = el('div', { class: 'grid' }); const grid = el('div', { class: 'grid' });
let query = ''; let query = '';
let filterKey = 'all'; let filterKey = 'all';
let rows = [];
let ids = [];
const ring = ProgressRing();
const title = el('h1', {});
const gamePill = el(
'a',
{ class: 'game-pill', href: '#/games' },
el('span', {}),
el('span', { class: 'game-pill__chev', 'aria-hidden': 'true' }, '⌄'),
);
const search = el('input', { const search = el('input', {
class: 'dexgrid__search', class: 'field-search__input',
type: 'search', type: 'search',
placeholder: 'Filter this Pokédex…', placeholder: 'Filter this Pokédex…',
oninput: (e) => { oninput: (e) => {
@ -31,37 +46,69 @@ export async function DexGrid() {
}, },
}); });
const filterBar = el( const filterBar = el('div', { class: 'feed-chips' });
'div', const chipEls = FILTERS.map((f) => {
{ class: 'chips' }, const count = el('span', { class: 'feed-chip__count' });
...FILTERS.map((f) => const btn = el(
el(
'button', 'button',
{ {
class: `chip${f.key === filterKey ? ' is-active' : ''}`, class: 'feed-chip',
type: 'button', type: 'button',
onclick: () => { onclick: () => {
filterKey = f.key; filterKey = f.key;
[...filterBar.children].forEach((c, i) => chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key));
c.classList.toggle('is-active', FILTERS[i].key === filterKey),
);
paintGrid(); paintGrid();
}, },
}, },
f.label, el('span', {}, f.label),
), count,
),
); );
filterBar.append(btn);
return { btn, count };
});
chipEls[0].btn.classList.add('is-active');
const header = ProgressHeader({ title: '', subtitle: '' });
view.append( view.append(
header.node, el(
el('div', { class: 'dexgrid__controls' }, search, filterBar), 'header',
{ class: 'feed-head' },
el(
'div',
{ class: 'feed-head__top' },
el('div', { class: 'feed-head__id' }, title, gamePill),
ring.node,
),
el(
'div',
{ class: 'field-search' },
el('span', { class: 'field-search__icon', html: SEARCH_ICON, 'aria-hidden': 'true' }),
search,
),
filterBar,
),
grid, grid,
); );
let rows = []; function counts() {
let ids = []; const p = selection.get().pokemon;
let caught = 0;
let favorite = 0;
for (const id of ids) {
const e = p[id];
if (!e) continue;
if (e.caught) caught++;
if (e.favorite) favorite++;
}
return { all: ids.length, caught, missing: ids.length - caught, favorite };
}
function refreshMeta() {
ring.update(stats(ids));
const c = counts();
chipEls.forEach(({ count }, i) => {
count.textContent = c[FILTERS[i].key];
});
}
function paintGrid() { function paintGrid() {
const st = settings.get(); const st = settings.get();
@ -79,27 +126,23 @@ export async function DexGrid() {
) { ) {
continue; continue;
} }
const e = pokemonState[species.id] || { const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
seen: false,
caught: false,
favorite: false,
};
if (!filterTest(e)) continue; if (!filterTest(e)) continue;
frag.append( const card = Card(species, number, {
Card(species, number, {
spriteStyle: st.spriteStyle, spriteStyle: st.spriteStyle,
versionGroup: st.versionGroup, versionGroup: st.versionGroup,
}), });
); if (shown < 30) card.style.setProperty('--i', String(shown));
frag.append(card);
shown++; shown++;
} }
clear(grid); clear(grid);
if (shown === 0) { grid.append(
grid.append(el('p', { class: 'grid__empty' }, 'Nothing matches those filters.')); shown === 0
} else { ? el('p', { class: 'grid__empty' }, 'Nothing matches those filters.')
grid.append(frag); : frag,
} );
} }
function rebuild() { function rebuild() {
@ -108,28 +151,19 @@ export async function DexGrid() {
rows = dexRows(snap, dex); rows = dexRows(snap, dex);
ids = rows.map((r) => r.species.id); ids = rows.map((r) => r.species.id);
header.setTitles( title.textContent = prettify(dex.name || dex.key);
prettify(dex.name || dex.key), gamePill.firstChild.textContent = prettify(st.versionGroup);
el( refreshMeta();
'span',
{},
`${prettify(st.versionGroup)} · `,
el('a', { href: '#/games', class: 'link' }, 'change game'),
),
);
header.update(stats(ids));
paintGrid(); paintGrid();
} }
rebuild(); rebuild();
const offSettings = settings.subscribe(rebuild); const offSettings = settings.subscribe(rebuild);
// Selection changes only affect counts + which cards pass the filter.
const offSelection = selection.subscribe(() => { const offSelection = selection.subscribe(() => {
header.update(stats(ids)); refreshMeta();
if (filterKey !== 'all') paintGrid(); if (filterKey !== 'all') paintGrid();
}); });
onTeardown(view, () => { onTeardown(view, () => {
offSettings(); offSettings();
offSelection(); offSelection();

View File

@ -77,11 +77,14 @@ export async function PokemonDetail(nationalId) {
view.style.setProperty('--type-2', typeHex(types[1] || mainType)); view.style.setProperty('--type-2', typeHex(types[1] || mainType));
view.dataset.type = mainType; view.dataset.type = mainType;
// ---- Artwork + shiny ---------------------------------------------- // ---- Artwork + shiny --------------------------------------------
// Shiny Pokémon were introduced in Gen 2 — no toggle for Gen 1 games.
const shinyAvailable = vgGen >= 2;
const artStyle = st.spriteStyle === 'default' ? 'official' : st.spriteStyle; const artStyle = st.spriteStyle === 'default' ? 'official' : st.spriteStyle;
let shiny = st.showShiny; let shiny = shinyAvailable && st.showShiny;
const artHolder = el('div', { class: 'phero__art' }); const artHolder = el('div', { class: 'phero__art' });
const shinyBtn = el('button', { const shinyBtn = shinyAvailable
? el('button', {
class: 'phero__shiny', class: 'phero__shiny',
type: 'button', type: 'button',
title: 'Toggle shiny', title: 'Toggle shiny',
@ -89,7 +92,8 @@ export async function PokemonDetail(nationalId) {
shiny = !shiny; shiny = !shiny;
paintArt(); paintArt();
}, },
}); })
: null;
function paintArt() { function paintArt() {
artHolder.replaceChildren( artHolder.replaceChildren(
Sprite(nationalId, { Sprite(nationalId, {
@ -100,9 +104,11 @@ export async function PokemonDetail(nationalId) {
size: 320, size: 320,
}), }),
); );
if (shinyBtn) {
shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny'; shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny';
shinyBtn.classList.toggle('is-on', shiny); shinyBtn.classList.toggle('is-on', shiny);
} }
}
paintArt(); paintArt();
// ---- Seen / caught / favorite ----------------------------------- // ---- Seen / caught / favorite -----------------------------------