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:
parent
c9247cd677
commit
b4f2460341
24
README.md
24
README.md
@ -49,14 +49,26 @@ src/
|
||||
|
||||
## Status
|
||||
|
||||
Scaffold — build steps 1–5 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
|
||||
|
||||
|
||||
@ -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
21
src/lib/type-color.js
Normal 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';
|
||||
}
|
||||
@ -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',
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user