From b4f2460341b0bf9753a874cc98361f6dfee28245 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 27 Aug 2026 12:04:21 -0400 Subject: [PATCH] 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 --- README.md | 24 ++++-- src/components/Card.js | 23 +++--- src/lib/type-color.js | 21 +++++ src/store/settings.js | 2 +- src/styles/layout.css | 159 ++++++++++++++++++++++++------------- src/views/PokemonDetail.js | 8 +- 6 files changed, 158 insertions(+), 79 deletions(-) create mode 100644 src/lib/type-color.js diff --git a/README.md b/README.md index b817899..b3960c1 100644 --- a/README.md +++ b/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 diff --git a/src/components/Card.js b/src/components/Card.js index e59f604..7b88fe6 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -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, ); diff --git a/src/lib/type-color.js b/src/lib/type-color.js new file mode 100644 index 0000000..f52bb3d --- /dev/null +++ b/src/lib/type-color.js @@ -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'; +} diff --git a/src/store/settings.js b/src/store/settings.js index f076fcb..cdc6eb0 100644 --- a/src/store/settings.js +++ b/src/store/settings.js @@ -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', }); diff --git a/src/styles/layout.css b/src/styles/layout.css index fdb1e62..8454891 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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); diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index e58cad3..3152927 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -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();