diff --git a/src/components/Card.js b/src/components/Card.js index 7b88fe6..d08940a 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -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('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')), - el('span', { class: 'card__types' }, ...(species.types || []).map(TypeChip)), + 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)), + ), ); return card; diff --git a/src/components/ProgressRing.js b/src/components/ProgressRing.js new file mode 100644 index 0000000..8b01ba9 --- /dev/null +++ b/src/components/ProgressRing.js @@ -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 }; +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 8454891..f829eaf 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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 { diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index e6faafb..55b740f 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -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 = + ''; + 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( - 'button', - { - class: `chip${f.key === filterKey ? ' is-active' : ''}`, - type: 'button', - onclick: () => { - filterKey = f.key; - [...filterBar.children].forEach((c, i) => - c.classList.toggle('is-active', FILTERS[i].key === filterKey), - ); - paintGrid(); - }, + 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: 'feed-chip', + type: 'button', + onclick: () => { + filterKey = f.key; + 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, { - spriteStyle: st.spriteStyle, - versionGroup: st.versionGroup, - }), - ); + 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(); diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 3152927..95bb4b0 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -77,19 +77,23 @@ 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', { - class: 'phero__shiny', - type: 'button', - title: 'Toggle shiny', - onclick: () => { - shiny = !shiny; - paintArt(); - }, - }); + const shinyBtn = shinyAvailable + ? el('button', { + class: 'phero__shiny', + type: 'button', + title: 'Toggle shiny', + onclick: () => { + shiny = !shiny; + paintArt(); + }, + }) + : null; function paintArt() { artHolder.replaceChildren( Sprite(nationalId, { @@ -100,8 +104,10 @@ export async function PokemonDetail(nationalId) { size: 320, }), ); - shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny'; - shinyBtn.classList.toggle('is-on', shiny); + if (shinyBtn) { + shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny'; + shinyBtn.classList.toggle('is-on', shiny); + } } paintArt();