Apply type-themed styling to grid, games, search

- Cards: primary-type gradient tint, radial spotlight behind the sprite,
  ghost dex number, official artwork by default, spring hover lift
- Game picker cards + search rows: rounder, soft shadows, hover motion
- Progress bar gradient; pill-shaped search inputs
- Shared typeHex() helper (src/lib/type-color.js), reused by detail + cards

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-27 12:04:21 -04:00
parent c9247cd677
commit b4f2460341
6 changed files with 158 additions and 79 deletions

View File

@ -49,14 +49,26 @@ src/
## Status
Scaffold — build steps 15 of the design are in place: responsive shell,
snapshot pipeline, dex grid with per-dex progress, game picker, lazy detail
page, offline search, settings with export/import, and the service worker.
Working app with a type-themed UI:
Not yet done: evolution chain, version-exclusive badges, richer filters
(type/generation/legendary — the snapshot would need legendary flags),
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
per-dex progress, name/number filter, caught/favorite filters.
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
Moves / Locations); coloured animated stat bars; defensive type matchups;
evolution chain re-parented to the selected game's generation; learnset with
per-move power/type/accuracy/PP/effect (era-accurate via `past_values`,
incl. pre-Gen-4 physical/special-by-type); wild encounter locations.
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
applies Gen 1 / pre-Gen 6 rules.
- **Games / Search / Settings** — game picker with sub-dex switch, offline
global search, theme + sprite-style + JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
cache-first LRU for sprites, in-app update toast.
Not yet done: version-exclusive badges, type/generation filters on the grid,
an interactive region map (PokéAPI has no map imagery or coordinates),
skeleton loaders, install-prompt handling, PNG/maskable raster icons
(currently an SVG icon only), and a Lighthouse PWA pass.
(currently SVG only), and a Lighthouse PWA pass.
## Notes

View File

@ -2,14 +2,17 @@ import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
import { entry, toggle } from '../store/selection.js';
import { typeHex } from '../lib/type-color.js';
/**
* One Pokémon in the dex grid. The card is a link to the detail route; the
* caught toggle is a real button layered on top and updates itself in place
* so toggling never repaints the whole grid.
* 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.
*/
export function Card(species, number, { spriteStyle = 'default', versionGroup } = {}) {
export function Card(species, number, { spriteStyle = 'official', versionGroup } = {}) {
const state = entry(species.id);
const mainType = (species.types || [])[0] || 'normal';
const caughtBtn = el('button', {
class: 'card__caught',
@ -31,15 +34,15 @@ export function Card(species, number, { spriteStyle = 'default', versionGroup }
{
class: `card${state.caught ? ' is-caught' : ''}`,
href: `#/pokemon/${species.id}`,
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__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')}`),
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 96 }),
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,
);

21
src/lib/type-color.js Normal file
View File

@ -0,0 +1,21 @@
// Resolve a Pokémon type to its hex colour by reading the --type-* CSS
// custom properties once and memoising. Type colours don't change between
// light and dark, so the cache is safe for the session.
const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark',
'steel', 'fairy',
];
let cache;
export function typeHex(type) {
if (!cache) {
cache = {};
const cs = getComputedStyle(document.documentElement);
for (const t of TYPES) {
cache[t] = cs.getPropertyValue(`--type-${t}`).trim() || '#8a8f98';
}
}
return cache[type] || '#8a8f98';
}

View File

@ -14,7 +14,7 @@ export const settings = createStore('pdx.settings', {
versionGroup: 'scarlet-violet',
pokedex: null,
theme: 'system',
spriteStyle: 'default',
spriteStyle: 'official',
showShiny: false,
locale: 'en',
});

View File

@ -206,7 +206,8 @@
}
.progress-header__titles h1 {
margin: 0;
font-size: 1.5rem;
font-size: 1.7rem;
font-weight: 700;
text-transform: capitalize;
}
.progress-header__sub,
@ -219,8 +220,8 @@
margin: 0 6px;
}
.progress-header__track {
margin-top: 10px;
height: 6px;
margin-top: 12px;
height: 8px;
border-radius: 999px;
background: var(--surface-2);
overflow: hidden;
@ -228,8 +229,9 @@
.progress-header__bar {
height: 100%;
width: 0;
background: var(--good);
transition: width 0.3s ease;
border-radius: 999px;
background: linear-gradient(90deg, var(--good), color-mix(in srgb, var(--good) 60%, #74c0fc));
transition: width 0.4s var(--ease-spring);
}
.dexgrid__controls {
@ -241,27 +243,32 @@
.dexgrid__search,
.search__input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 16px;
border: 1.5px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text);
font: inherit;
}
.dexgrid__search:focus,
.search__input:focus {
outline: none;
border-color: var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
gap: var(--gap);
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
}
}
@media (min-width: 1200px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr));
}
}
.grid__empty {
@ -276,47 +283,78 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
gap: 5px;
padding: 16px 12px 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%
);
border: 1px solid color-mix(in srgb, var(--type-main) 22%, var(--border));
box-shadow: var(--shadow);
content-visibility: auto;
contain-intrinsic-size: 210px;
transition: transform 0.12s ease, border-color 0.12s ease;
contain-intrinsic-size: 232px;
transition: transform 0.14s var(--ease-spring), box-shadow 0.14s ease;
}
.card > * {
position: relative;
z-index: 1;
}
.card:hover {
transform: translateY(-2px);
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.card.is-caught {
border-color: color-mix(in srgb, var(--good) 55%, var(--border));
}
.card.is-caught::after {
content: "";
.card__spot {
position: absolute;
inset: 0;
border-radius: var(--radius);
background: color-mix(in srgb, var(--good) 8%, transparent);
top: 30px;
left: 50%;
width: 118px;
height: 118px;
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%
);
}
.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 .sprite {
width: 112px;
height: 112px;
object-fit: contain;
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.22));
}
.card[data-sprite="default"] .sprite,
.card[data-sprite="game"] .sprite {
image-rendering: pixelated;
}
.card__num {
font-size: 0.72rem;
color: var(--text-dim);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.card .sprite {
width: 96px;
height: 96px;
object-fit: contain;
image-rendering: pixelated;
}
.card__name {
font-weight: 700;
font-size: 0.9rem;
font-size: 0.95rem;
text-transform: capitalize;
text-align: center;
}
@ -328,31 +366,34 @@
}
.card__caught {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
top: 10px;
right: 10px;
z-index: 2;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
font-size: 0.7rem;
border: none;
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.8rem;
color: #333;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(3px);
}
.card__caught[aria-pressed="true"] {
background: var(--good);
border-color: var(--good);
color: #fff;
}
.card__caught[aria-pressed="true"]::before {
content: "✓";
font-size: 0.85rem;
}
.card__caught[aria-pressed="false"]::before {
content: "";
}
.card.is-caught {
border-color: color-mix(in srgb, var(--good) 45%, var(--border));
}
/* ---------- Game picker ------------------------------------------- */
.gamepicker__subdex {
@ -367,15 +408,16 @@
font-size: 0.85rem;
}
.gamepicker__gen {
font-size: 0.8rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin: 22px 0 10px;
font-weight: 600;
margin: 26px 0 12px;
}
.gamepicker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 12px;
}
.gamecard {
@ -383,20 +425,23 @@
flex-direction: column;
gap: 3px;
text-align: left;
padding: 12px 14px;
border: 1px solid var(--border);
padding: 14px 16px;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: inherit;
cursor: pointer;
font: inherit;
box-shadow: var(--shadow);
transition: transform 0.14s var(--ease-spring), border-color 0.14s ease;
}
.gamecard:hover {
transform: translateY(-3px);
border-color: var(--accent);
}
.gamecard.is-active {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
}
.gamecard__name {
font-weight: 700;
@ -979,24 +1024,28 @@
.search__row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
gap: 14px;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow);
text-decoration: none;
color: inherit;
transition: transform 0.12s var(--ease-spring), border-color 0.12s ease;
}
.search__row:hover {
transform: translateX(3px);
border-color: var(--accent);
}
.search__row.is-caught {
border-color: color-mix(in srgb, var(--good) 55%, var(--border));
}
.search__row .sprite {
width: 48px;
height: 48px;
image-rendering: pixelated;
width: 52px;
height: 52px;
flex: none;
object-fit: contain;
}
.search__num {
color: var(--text-dim);

View File

@ -11,6 +11,7 @@ import { EvolutionChain } from '../components/EvolutionChain.js';
import { MovesList } from '../components/MovesList.js';
import { TypeMatchups } from '../components/TypeMatchups.js';
import { Locations } from '../components/Locations.js';
import { typeHex } from '../lib/type-color.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
@ -31,13 +32,6 @@ const NO_ENCOUNTER_DATA = new Set([
'legends-arceus',
]);
function typeHex(type) {
const v = getComputedStyle(document.documentElement)
.getPropertyValue(`--type-${type}`)
.trim();
return v || '#8a8f98';
}
export async function PokemonDetail(nationalId) {
const view = el('section', { class: 'view pdetail' });
const snap = await loadSnapshot();