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';
/**
* One Pokémon in the dex grid. Tinted by its primary type: a soft gradient,
* a type-coloured spotlight behind the sprite, and a big translucent number.
* The card is a link to the detail route; the caught toggle is a real button
* layered on top that updates itself so toggling never repaints the grid.
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
* 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.
*/
export function Card(species, number, { spriteStyle = 'official', versionGroup } = {}) {
const state = entry(species.id);
const mainType = (species.types || [])[0] || 'normal';
const num = String(number ?? species.id).padStart(3, '0');
const caughtBtn = el('button', {
class: 'card__caught',
@ -37,13 +37,21 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup }
dataset: { type: mainType, sprite: spriteStyle },
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' }),
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 112 }),
el('span', { class: 'card__num' }, `#${String(number ?? species.id).padStart(4, '0')}`),
el(
'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__types' }, ...(species.types || []).map(TypeChip)),
caughtBtn,
),
);
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="fairy"] { background: var(--type-fairy); color: #3d1f3b; }
/* ---------- Dex grid ---------------------------------------------- */
.progress-header {
/* ---------- Dex feed header ------------------------------------- */
.feed-head {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
padding: 8px 0 12px;
margin: -8px 0 18px;
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;
font-size: 1.7rem;
font-size: 1.9rem;
font-weight: 700;
line-height: 1.1;
text-transform: capitalize;
}
.progress-header__sub,
.progress-header__counts {
margin: 2px 0 0;
color: var(--text-dim);
font-size: 0.9rem;
}
.progress-header__dot {
margin: 0 6px;
}
.progress-header__track {
margin-top: 12px;
height: 8px;
.game-pill {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 5px 8px 5px 12px;
border-radius: 999px;
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 {
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--good), color-mix(in srgb, var(--good) 60%, #74c0fc));
transition: width 0.4s var(--ease-spring);
.game-pill:hover {
color: var(--text);
}
.game-pill__chev {
font-size: 0.9rem;
line-height: 1;
}
.dexgrid__controls {
display: flex;
flex-direction: column;
gap: 10px;
margin: 8px 0 16px;
.ring {
position: relative;
width: 60px;
height: 60px;
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 {
width: 100%;
padding: 12px 16px;
padding: 12px 16px 12px 44px;
border: 1.5px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text);
font: inherit;
}
.dexgrid__search:focus,
.search__input {
padding-left: 16px;
}
.field-search__input:focus,
.search__input:focus {
outline: none;
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
@ -283,92 +379,109 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 16px 12px 14px;
padding: 16px 14px 14px;
border-radius: var(--radius);
text-decoration: none;
color: inherit;
overflow: hidden;
background: linear-gradient(
165deg,
color-mix(in srgb, var(--type-main) 26%, var(--surface)) 0%,
var(--surface) 60%
180deg,
color-mix(in srgb, var(--type-main) 30%, var(--surface)) 0%,
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);
content-visibility: auto;
contain-intrinsic-size: 232px;
contain-intrinsic-size: auto 236px;
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 > * {
position: relative;
z-index: 1;
@keyframes cardIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.card:hover {
transform: translateY(-4px);
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 {
position: absolute;
top: 30px;
top: 14px;
left: 50%;
width: 118px;
height: 118px;
width: 128px;
height: 128px;
transform: translateX(-50%);
z-index: 0;
pointer-events: none;
background: radial-gradient(
circle,
color-mix(in srgb, var(--type-main) 42%, transparent) 0%,
transparent 68%
color-mix(in srgb, var(--type-main) 46%, transparent) 0%,
transparent 66%
);
}
.card__bignum {
position: absolute;
top: 74px;
left: 50%;
transform: translate(-50%, -50%);
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__art {
position: relative;
z-index: 1;
width: 120px;
height: 120px;
}
.card .sprite {
width: 112px;
height: 112px;
.card__art .sprite {
width: 100%;
height: 100%;
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="game"] .sprite {
.card[data-sprite="default"] .card__art .sprite,
.card[data-sprite="game"] .card__art .sprite {
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 {
font-size: 0.72rem;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-dim);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.card__name {
font-weight: 700;
font-size: 0.95rem;
font-size: 0.98rem;
text-transform: capitalize;
text-align: center;
line-height: 1.15;
}
.card__types {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: center;
margin-top: 2px;
}
.card__caught {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
left: 10px;
z-index: 3;
width: 28px;
height: 28px;
border-radius: 50%;
@ -378,7 +491,7 @@
place-items: center;
font-size: 0.8rem;
color: #333;
background: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(3px);
}
.card__caught[aria-pressed="true"] {
@ -394,6 +507,11 @@
.card.is-caught {
border-color: color-mix(in srgb, var(--good) 45%, var(--border));
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
}
}
/* ---------- Game picker ------------------------------------------- */
.gamepicker__subdex {

View File

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

View File

@ -77,11 +77,14 @@ export async function PokemonDetail(nationalId) {
view.style.setProperty('--type-2', typeHex(types[1] || 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;
let shiny = st.showShiny;
let shiny = shinyAvailable && st.showShiny;
const artHolder = el('div', { class: 'phero__art' });
const shinyBtn = el('button', {
const shinyBtn = shinyAvailable
? el('button', {
class: 'phero__shiny',
type: 'button',
title: 'Toggle shiny',
@ -89,7 +92,8 @@ export async function PokemonDetail(nationalId) {
shiny = !shiny;
paintArt();
},
});
})
: null;
function paintArt() {
artHolder.replaceChildren(
Sprite(nationalId, {
@ -100,9 +104,11 @@ export async function PokemonDetail(nationalId) {
size: 320,
}),
);
if (shinyBtn) {
shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny';
shinyBtn.classList.toggle('is-on', shiny);
}
}
paintArt();
// ---- Seen / caught / favorite -----------------------------------