Compare commits

...

3 Commits

Author SHA1 Message Date
4b9bc5c0ab Settings: text size, reduce motion, landing screen, haptics
Four new preferences, all in settings.js / applied via applyTheme() so
boot and live changes go through one path:

- fontScale (small/default/large): scales root font-size (92/100/115%);
  the whole stylesheet is already rem-based so this alone rescales
  everything.
- reduceMotion: an explicit in-app override alongside the existing
  `prefers-reduced-motion` OS check, via a new
  :root[data-reduce-motion="true"] rule in tokens.css mirroring the
  existing media-query kill-switch. New prefersReducedMotion() helper
  (checks both) replaces the three hand-rolled matchMedia checks in
  router.js/Card.js/PokemonDetail.js.
- defaultRoute (dex/team/search): redirects on a genuinely hash-less
  launch only (bookmark, home-screen icon, bare URL) — explicit nav
  (e.g. tapping "Dex") is never touched, since it's applied once in
  main.js before initRouter runs.
- haptics: a short navigator.vibrate() pulse on the same catch/seen/
  favorite toggles that already play the pop animation, via a new
  lib/haptics.js buzz() (no-ops without Vibration API support, e.g. iOS
  Safari).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 12:35:26 -04:00
1711249dae Add a notes UI for the per-Pokémon note field
The selection store already had a note field and setNote() (from the
JSON export/import work) but nothing in the UI ever wrote to it. Adds:

- PokemonDetail: an auto-resizing textarea under the Seen/Caught/Team
  row. Saves debounced (500ms) as you type, immediately on blur, and
  flushes any unsaved keystrokes on teardown so navigating away right
  after typing never loses them. A brief "Saved" flash confirms the
  write.
- Card: a small 📝 mark on any card whose Pokémon has a note.
- DexGrid: a "Notes" filter chip (same pattern as Caught/Favorites) to
  browse everything you've annotated.

Notes already ride along in the existing settings/selection JSON
export-import, so no changes needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 12:24:22 -04:00
c9807127d6 Dex grid: ability, egg group, min BST and fully-evolved filters
Extends the existing filter drawer (type/gen) with four more:

- Ability — snapshot abilities already carry a reverse pokemon[] index,
  so this is a plain lookup, no new data needed.
- Egg group — options derived from the species list's own eggGroups.
- Min BST — a number field; hard filter, distinct from sorting by BST.
- Fully evolved only — new species.evolvesFromId field (from
  pokemon-species' evolves_from_species, already fetched during the
  snapshot build, so no extra requests). "Fully evolved" is computed
  client-side as "no other species points here via evolvesFromId" —
  correctly handles branching lines (Eevee's evolutions all count as
  fully evolved; Eevee itself doesn't).

All four persist via the ui store and count toward the filter badge, same
as the existing filters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-04 12:14:36 -04:00
13 changed files with 399 additions and 26 deletions

View File

@ -70,7 +70,15 @@ src/
Working app with a type-themed UI: Working app with a type-themed UI:
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number), - **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
per-dex progress, name/number filter, caught/favorite filters. per-dex progress, name/number filter, caught/favorite filters, plus a
filter drawer: type, generation, ability, egg group, minimum BST, and
"fully evolved only" (derived client-side from each species' evolves-from
link — no extra fetches). A "Notes" chip filters to Pokémon with a note;
cards with one get a small 📝 mark.
- **Notes** — a free-text note per Pokémon on its detail page (trade plans,
where you caught it, anything), autosaving as you type and flushing
immediately on blur or navigation so a quick tab-away never drops a
keystroke. Rides along in the existing JSON export/import.
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the - **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
snapshot; a pill switcher on the detail page rebuilds types / stats / snapshot; a pill switcher on the detail page rebuilds types / stats /
matchups / abilities / learnset / artwork for the chosen form. Purely matchups / abilities / learnset / artwork for the chosen form. Purely
@ -106,7 +114,10 @@ Working app with a type-themed UI:
Clefairy is Normal, etc.), and era move data. Toggleable in the nav Clefairy is Normal, etc.), and era move data. Toggleable in the nav
from Settings. from Settings.
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent - **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, sprite style, JSON export/import. colour, text size, an in-app reduce-motion override (on top of the OS
preference, which is always respected too), sprite style, haptic
feedback on catch (Vibration API), which screen to land on at launch,
JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON, - **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
cache-first LRU for sprites, in-app update toast. cache-first LRU for sprites, in-app update toast.

View File

@ -312,6 +312,11 @@ async function main() {
color: sp.color?.name || null, color: sp.color?.name || null,
eggGroups: sp.egg_groups.map((g) => g.name), eggGroups: sp.egg_groups.map((g) => g.name),
genderRate: sp.gender_rate, genderRate: sp.gender_rate,
// Species this evolves FROM (null for base forms) — lets the app
// derive "fully evolved" client-side without fetching every
// evolution chain: a species nothing points to via this field has no
// further evolution.
evolvesFromId: sp.evolves_from_species ? idFromUrl(sp.evolves_from_species.url) : null,
}; };
}); });

View File

@ -4,6 +4,8 @@ import { TypeChip } from './TypeChip.js';
import { entry, toggle } from '../store/selection.js'; import { entry, toggle } from '../store/selection.js';
import { typeHex } from '../lib/type-color.js'; import { typeHex } from '../lib/type-color.js';
import { typesForGen } from '../lib/type-resolve.js'; import { typesForGen } from '../lib/type-resolve.js';
import { prefersReducedMotion } from '../store/settings.js';
import { buzz } from '../lib/haptics.js';
/** /**
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down * One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
@ -30,11 +32,14 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
const next = entry(species.id); const next = entry(species.id);
caughtBtn.setAttribute('aria-pressed', String(next.caught)); caughtBtn.setAttribute('aria-pressed', String(next.caught));
card.classList.toggle('is-caught', next.caught); card.classList.toggle('is-caught', next.caught);
if (next.caught && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (next.caught) {
caughtBtn.animate( buzz();
[{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }], if (!prefersReducedMotion()) {
{ duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' }, caughtBtn.animate(
); [{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }],
{ duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' },
);
}
} }
}, },
}); });
@ -49,6 +54,9 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
}, },
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num), el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
el('span', { class: 'card__spot', 'aria-hidden': 'true' }), el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
state.note && state.note.trim()
? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝')
: null,
el( el(
'div', 'div',
{ class: 'card__art' }, { class: 'card__art' },

15
src/lib/haptics.js Normal file
View File

@ -0,0 +1,15 @@
import { settings } from '../store/settings.js';
/**
* A short haptic pulse for a positive action (catch, favorite). Gated on
* the user's haptics setting and on Vibration API support most notably
* absent on iOS Safari, where this silently does nothing.
*/
export function buzz(pattern = 15) {
if (!settings.get().haptics || !navigator.vibrate) return;
try {
navigator.vibrate(pattern);
} catch {
/* unsupported in this context (e.g. no user-activation) — ignore */
}
}

View File

@ -15,6 +15,15 @@ if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
// Navigation scroll memory is per-session — start a cold launch at the top. // Navigation scroll memory is per-session — start a cold launch at the top.
ui.set({ feedScroll: 0, searchScroll: 0 }); ui.set({ feedScroll: 0, searchScroll: 0 });
// Land on the configured default screen for a fresh launch (no hash yet at
// all — a bookmark, the home-screen icon, or typing the bare URL). Explicit
// navigation, like tapping "Dex" in the nav, is never redirected — that
// only ever touches location.hash after this point.
if (!location.hash) {
const DEFAULT_ROUTES = { dex: '#/', team: '#/team', search: '#/search' };
location.hash = DEFAULT_ROUTES[settings.get().defaultRoute] || '#/';
}
// React to preference changes. The dex feed updates itself in place, but the // React to preference changes. The dex feed updates itself in place, but the
// Pokémon detail page is built once per visit — rebuild it when the selected // Pokémon detail page is built once per visit — rebuild it when the selected
// game changes so its data (flavour text, learnset, matchups, evolution, // game changes so its data (flavour text, learnset, matchups, evolution,

View File

@ -9,6 +9,7 @@ import { AbilityDetail } from './views/AbilityDetail.js';
import { TeamView } from './views/TeamView.js'; import { TeamView } from './views/TeamView.js';
import { NaturesView } from './views/NaturesView.js'; import { NaturesView } from './views/NaturesView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
const routes = [ const routes = [
{ pattern: /^#?\/?$/, view: () => DexGrid() }, { pattern: /^#?\/?$/, view: () => DexGrid() },
@ -31,8 +32,7 @@ function resolve(hash) {
} }
const canAnimate = () => const canAnimate = () =>
typeof document.startViewTransition === 'function' && typeof document.startViewTransition === 'function' && !prefersReducedMotion();
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let rerenderCurrent = null; let rerenderCurrent = null;

View File

@ -3,12 +3,16 @@ import { createStore } from './createStore.js';
/** /**
* User preferences. Small, synchronous, read on boot. * User preferences. Small, synchronous, read on boot.
* *
* - versionGroup: the selected "game" (PokéAPI version-group key) * - versionGroup: the selected "game" (PokéAPI version-group key)
* - pokedex: the active regional dex within that game (null = first one) * - pokedex: the active regional dex within that game (null = first one)
* - theme: 'system' | 'light' | 'dark' * - theme: 'system' | 'light' | 'dark'
* - spriteStyle: 'default' (pixel) | 'official' | 'home' * - spriteStyle: 'default' (pixel) | 'official' | 'home'
* - showShiny: default the detail view to shiny sprites * - showShiny: default the detail view to shiny sprites
* - locale: reserved for localized names (PokéAPI supports many) * - locale: reserved for localized names (PokéAPI supports many)
* - fontScale: 'small' | 'default' | 'large' text size
* - reduceMotion: in-app override off even if the OS doesn't ask for it
* - defaultRoute: 'dex' | 'team' | 'search' landing screen on cold launch
* - haptics: short vibration on catch (Vibration API, Android only)
*/ */
export const settings = createStore('pdx.settings', { export const settings = createStore('pdx.settings', {
versionGroup: 'all', // 'all' = National Dex, newest data, no generation limits versionGroup: 'all', // 'all' = National Dex, newest data, no generation limits
@ -19,13 +23,18 @@ export const settings = createStore('pdx.settings', {
showShiny: false, showShiny: false,
showTeamNav: true, showTeamNav: true,
locale: 'en', locale: 'en',
fontScale: 'default',
reduceMotion: false,
defaultRoute: 'dex',
haptics: true,
}); });
const DARKISH = new Set(['dark', 'black']); const DARKISH = new Set(['dark', 'black']);
const FONT_SCALE = { small: 0.9, default: 1, large: 1.15 };
export function applyTheme(state = settings.get()) { export function applyTheme(state = settings.get()) {
const root = document.documentElement; const root = document.documentElement;
const { theme, accent } = state; const { theme, accent, fontScale, reduceMotion } = state;
if (!theme || theme === 'system') root.removeAttribute('data-theme'); if (!theme || theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme); else root.setAttribute('data-theme', theme);
@ -33,6 +42,11 @@ export function applyTheme(state = settings.get()) {
if (!accent || accent === 'red') root.removeAttribute('data-accent'); if (!accent || accent === 'red') root.removeAttribute('data-accent');
else root.setAttribute('data-accent', accent); else root.setAttribute('data-accent', accent);
root.style.fontSize = `${(FONT_SCALE[fontScale] ?? 1) * 100}%`;
if (reduceMotion) root.setAttribute('data-reduce-motion', 'true');
else root.removeAttribute('data-reduce-motion');
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (meta) { if (meta) {
const dark = const dark =
@ -47,3 +61,11 @@ export function applyTheme(state = settings.get()) {
); );
} }
} }
/** True if animations should be skipped — OS preference or the in-app override. */
export function prefersReducedMotion() {
return (
!!settings.get().reduceMotion ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}

View File

@ -15,6 +15,10 @@ export const ui = createStore('pdx.ui', {
filter: 'all', filter: 'all',
filterType: '', filterType: '',
filterGen: 0, filterGen: 0,
filterAbility: 0,
filterEggGroup: '',
filterMinBst: 0,
filterFullyEvolved: false,
recent: [], recent: [],
toolsOpen: false, toolsOpen: false,
teamMode: 'coverage', teamMode: 'coverage',

View File

@ -559,6 +559,20 @@
.card.is-caught { .card.is-caught {
border-color: color-mix(in srgb, var(--good) 45%, var(--border)); border-color: color-mix(in srgb, var(--good) 45%, var(--border));
} }
.card__notemark {
position: absolute;
top: 10px;
right: 10px;
z-index: 3;
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(3px);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.card { .card {
animation: none; animation: none;
@ -936,6 +950,55 @@
border-color: #fff; border-color: #fff;
color: #ff4d6d; color: #ff4d6d;
} }
.pnote {
margin: 10px 0 0;
}
.pnote__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
margin-bottom: 4px;
}
.pnote__status {
font-size: 0.68rem;
font-weight: 600;
color: var(--good);
text-transform: none;
letter-spacing: normal;
opacity: 0;
}
.pnote__status.is-visible {
animation: pnote-fade 1.6s ease forwards;
}
@keyframes pnote-fade {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
.pnote__input {
width: 100%;
min-height: 2.2em;
max-height: 12em;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 0.85rem;
line-height: 1.4;
resize: none;
overflow: hidden;
}
.pnote__input:focus {
outline: none;
border-color: var(--type-main, var(--accent));
}
.psheet__tabs { .psheet__tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;
@ -1837,6 +1900,29 @@
max-width: none; max-width: none;
flex: 1; flex: 1;
} }
.feed-minbst {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
padding: 7px 12px;
border: 1.5px solid var(--border);
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
color: var(--text-dim);
white-space: nowrap;
}
.feed-minbst input {
width: 4.5em;
min-width: 0;
border: none;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 700;
padding: 0;
}
.recent { .recent {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -140,3 +140,10 @@ a {
transition-duration: 0.001ms !important; transition-duration: 0.001ms !important;
} }
} }
/* Settings > Reduce motion an explicit in-app override alongside the OS
preference above, for anyone who wants it off without changing OS-level
settings (or whose OS setting this app can't see, e.g. some WebViews). */
:root[data-reduce-motion='true'] * {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}

View File

@ -20,6 +20,7 @@ const FILTERS = [
{ key: 'missing', label: 'Missing', test: (e) => !e.caught }, { key: 'missing', label: 'Missing', test: (e) => !e.caught },
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite }, { key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical }, { key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
{ key: 'notes', label: 'Notes', test: (e) => !!(e.note && e.note.trim()) },
]; ];
const SORTS = [ const SORTS = [
@ -82,6 +83,10 @@ export async function DexGrid() {
let filterKey = ui.get().filter || 'all'; let filterKey = ui.get().filter || 'all';
let filterType = ui.get().filterType || ''; let filterType = ui.get().filterType || '';
let filterGen = Number(ui.get().filterGen) || 0; let filterGen = Number(ui.get().filterGen) || 0;
let filterAbility = Number(ui.get().filterAbility) || 0;
let filterEggGroup = ui.get().filterEggGroup || '';
let filterMinBst = Number(ui.get().filterMinBst) || 0;
let filterFullyEvolved = !!ui.get().filterFullyEvolved;
let sortKey = ui.get().sort || 'dex'; let sortKey = ui.get().sort || 'dex';
let sortDesc = !!ui.get().sortDesc; let sortDesc = !!ui.get().sortDesc;
let rows = []; let rows = [];
@ -210,6 +215,87 @@ export async function DexGrid() {
'🎲', '🎲',
); );
// "Fully evolved" = no other species lists this one as evolvesFromId —
// derived client-side rather than fetching every evolution chain.
const hasNextEvo = new Set(snap.species.map((s) => s.evolvesFromId).filter(Boolean));
const isFullyEvolved = (sp) => !hasNextEvo.has(sp.id);
const eggGroupList = [...new Set(snap.species.flatMap((s) => s.eggGroups || []))].sort();
const abilityList = snap.abilities.slice().sort((a, b) => a.name.localeCompare(b.name));
const abilitySelect = el(
'select',
{
class: 'feed-sort__select',
onchange: (e) => {
filterAbility = Number(e.target.value) || 0;
ui.set({ filterAbility });
paintGrid();
resetScroll();
syncToolsBadge();
},
},
el('option', { value: '0' }, 'Any ability'),
...abilityList.map((a) =>
el('option', { value: String(a.id), selected: a.id === filterAbility }, prettify(a.name)),
),
);
const eggGroupSelect = el(
'select',
{
class: 'feed-sort__select',
onchange: (e) => {
filterEggGroup = e.target.value;
ui.set({ filterEggGroup });
paintGrid();
resetScroll();
syncToolsBadge();
},
},
el('option', { value: '' }, 'Any egg group'),
...eggGroupList.map((g) => el('option', { value: g, selected: g === filterEggGroup }, prettify(g))),
);
const minBstField = el(
'label',
{ class: 'feed-minbst' },
el('span', {}, 'Min BST'),
el('input', {
type: 'number',
min: '0',
max: '1000',
step: '10',
placeholder: '0',
value: filterMinBst || '',
onchange: (e) => {
filterMinBst = Math.max(0, Number(e.target.value) || 0);
e.target.value = filterMinBst || '';
ui.set({ filterMinBst });
paintGrid();
resetScroll();
syncToolsBadge();
},
}),
);
const fullyEvolvedCheck = el(
'label',
{ class: 'lookup__check' },
el('input', {
type: 'checkbox',
checked: filterFullyEvolved,
onchange: (e) => {
filterFullyEvolved = e.target.checked;
ui.set({ filterFullyEvolved });
paintGrid();
resetScroll();
syncToolsBadge();
},
}),
'Fully evolved only',
);
// Changing the list order/contents makes the old scroll offset meaningless. // Changing the list order/contents makes the old scroll offset meaningless.
function resetScroll() { function resetScroll() {
ui.set({ feedScroll: 0 }); ui.set({ feedScroll: 0 });
@ -263,6 +349,8 @@ export async function DexGrid() {
filterBar, filterBar,
el('div', { class: 'feed-sort' }, el('span', { class: 'feed-sort__label' }, 'Sort'), sortSelect, dirBtn), el('div', { class: 'feed-sort' }, el('span', { class: 'feed-sort__label' }, 'Sort'), sortSelect, dirBtn),
el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn), el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn),
el('div', { class: 'feed-sort feed-filters' }, abilitySelect, eggGroupSelect, minBstField),
el('div', { class: 'feed-sort feed-filters' }, fullyEvolvedCheck),
); );
const toolsToggle = el( const toolsToggle = el(
'button', 'button',
@ -281,7 +369,13 @@ export async function DexGrid() {
); );
function syncToolsBadge() { function syncToolsBadge() {
const n = const n =
(filterKey !== 'all' ? 1 : 0) + (filterType ? 1 : 0) + (filterGen ? 1 : 0); (filterKey !== 'all' ? 1 : 0) +
(filterType ? 1 : 0) +
(filterGen ? 1 : 0) +
(filterAbility ? 1 : 0) +
(filterEggGroup ? 1 : 0) +
(filterMinBst ? 1 : 0) +
(filterFullyEvolved ? 1 : 0);
toolsToggle.dataset.active = n ? String(n) : ''; toolsToggle.dataset.active = n ? String(n) : '';
} }
syncToolsBadge(); syncToolsBadge();
@ -314,13 +408,15 @@ export async function DexGrid() {
let caught = 0; let caught = 0;
let favorite = 0; let favorite = 0;
let legendary = 0; let legendary = 0;
let notes = 0;
for (const { species } of rows) { for (const { species } of rows) {
const e = p[species.id]; const e = p[species.id];
if (e?.caught) caught++; if (e?.caught) caught++;
if (e?.favorite) favorite++; if (e?.favorite) favorite++;
if (species.isLegendary || species.isMythical) legendary++; if (species.isLegendary || species.isMythical) legendary++;
if (e?.note && e.note.trim()) notes++;
} }
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary }; return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary, notes };
} }
function refreshMeta() { function refreshMeta() {
@ -337,6 +433,7 @@ export async function DexGrid() {
const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9; const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
const boxed = st.spriteStyle === 'game' && gen <= 2; const boxed = st.spriteStyle === 'game' && gen <= 2;
const filterTest = FILTERS.find((f) => f.key === filterKey).test; const filterTest = FILTERS.find((f) => f.key === filterKey).test;
const abilityMon = filterAbility ? new Set(snap.abilityById.get(filterAbility)?.pokemon || []) : null;
let list = rows.filter(({ species, number }) => { let list = rows.filter(({ species, number }) => {
if ( if (
@ -349,6 +446,10 @@ export async function DexGrid() {
} }
if (filterGen && species.generation !== filterGen) return false; if (filterGen && species.generation !== filterGen) return false;
if (filterType && !typesForGen(species, gen).includes(filterType)) return false; if (filterType && !typesForGen(species, gen).includes(filterType)) return false;
if (abilityMon && !abilityMon.has(species.id)) return false;
if (filterEggGroup && !(species.eggGroups || []).includes(filterEggGroup)) return false;
if (filterMinBst && (species.bst || 0) < filterMinBst) return false;
if (filterFullyEvolved && !isFullyEvolved(species)) return false;
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false }; const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
return filterTest(e, species); return filterTest(e, species);
}); });

View File

@ -3,9 +3,9 @@ import { onSwipe } from '../lib/swipe.js';
import { loadSnapshot } from '../data/snapshot.js'; import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
import { settings } from '../store/settings.js'; import { settings, prefersReducedMotion } from '../store/settings.js';
import { ui } from '../store/ui.js'; import { ui } from '../store/ui.js';
import { entry, toggle } from '../store/selection.js'; import { entry, toggle, setNote } from '../store/selection.js';
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js'; import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
import { isFormCaught, toggleFormCaught } from '../store/formTracking.js'; import { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
import { Sprite, spriteUrl } from '../components/Sprite.js'; import { Sprite, spriteUrl } from '../components/Sprite.js';
@ -17,6 +17,7 @@ import { TypeMatchups } from '../components/TypeMatchups.js';
import { Locations } from '../components/Locations.js'; import { Locations } from '../components/Locations.js';
import { FlavorText } from '../components/FlavorText.js'; import { FlavorText } from '../components/FlavorText.js';
import { typeHex } from '../lib/type-color.js'; import { typeHex } from '../lib/type-color.js';
import { buzz } from '../lib/haptics.js';
import { offensiveSummary } from '../data/type-chart.js'; import { offensiveSummary } from '../data/type-chart.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
@ -238,11 +239,14 @@ export async function PokemonDetail(nationalId) {
onclick: () => { onclick: () => {
toggle(nationalId, field); toggle(nationalId, field);
syncTrack(); syncTrack();
if (entry(nationalId)[field] && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (entry(nationalId)[field]) {
b.animate( buzz();
[{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }], if (!prefersReducedMotion()) {
{ duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' }, b.animate(
); [{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }],
{ duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' },
);
}
} }
}, },
}); });
@ -280,6 +284,57 @@ export async function PokemonDetail(nationalId) {
} }
syncTrack(); syncTrack();
// ---- Personal note ------------------------------------------------
// Autosaves (debounced) as you type, and flushes immediately on blur or
// when leaving the page so a quick navigation never drops a keystroke.
const noteStatus = el('span', { class: 'pnote__status', 'aria-live': 'polite' });
let noteTimer = null;
let noteSaved = entry(nationalId).note || '';
function autosize(ta) {
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight}px`;
}
function flashSaved() {
noteStatus.textContent = 'Saved';
noteStatus.classList.remove('is-visible');
void noteStatus.offsetWidth; // restart the fade
noteStatus.classList.add('is-visible');
}
function saveNote(value) {
clearTimeout(noteTimer);
if (value === noteSaved) return;
noteSaved = value;
setNote(nationalId, value);
flashSaved();
}
const noteInput = el('textarea', {
class: 'pnote__input',
placeholder: 'Add a note — trade plans, where you caught it, anything…',
rows: 1,
oninput: (e) => {
autosize(e.target);
clearTimeout(noteTimer);
noteTimer = setTimeout(() => saveNote(e.target.value), 500);
},
onfocus: (e) => autosize(e.target),
onblur: (e) => saveNote(e.target.value),
});
noteInput.value = noteSaved;
// The textarea isn't attached (let alone laid out) yet when it's built —
// this view still has to be mounted, possibly through a view transition.
// Retry like the feed's scroll restore does, so a non-empty note that
// wraps to several lines starts fully visible instead of clipped to one.
requestAnimationFrame(() => autosize(noteInput));
setTimeout(() => autosize(noteInput), 120);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => autosize(noteInput));
const noteBlock = el(
'div',
{ class: 'pnote' },
el('label', { class: 'pnote__label', for: 'pnote-input' }, 'Note', noteStatus),
noteInput,
);
noteInput.id = 'pnote-input';
const allGames = st.versionGroup === 'all'; const allGames = st.versionGroup === 'all';
let statBars = []; let statBars = [];
@ -690,6 +745,7 @@ export async function PokemonDetail(nationalId) {
'div', 'div',
{ class: 'psheet' }, { class: 'psheet' },
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn), el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
noteBlock,
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons), el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
body, body,
), ),
@ -706,6 +762,7 @@ export async function PokemonDetail(nationalId) {
const off = settings.subscribe(() => syncTrack()); const off = settings.subscribe(() => syncTrack());
const offTeam = team.subscribe(syncTeamBtn); const offTeam = team.subscribe(syncTeamBtn);
onTeardown(view, () => { onTeardown(view, () => {
saveNote(noteInput.value); // flush any unsaved keystrokes
off(); off();
offTeam(); offTeam();
offSwipe(); offSwipe();

View File

@ -47,6 +47,27 @@ export async function SettingsView() {
); );
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow); const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
const fontSizeField = selectField('Text size', st.fontScale || 'default', [
['small', 'Small'],
['default', 'Default'],
['large', 'Large'],
], (v) => {
settings.set({ fontScale: v });
applyTheme();
});
const reduceMotionField = el('label', { class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: !!st.reduceMotion,
onchange: (e) => {
settings.set({ reduceMotion: e.target.checked });
applyTheme();
},
}),
'Reduce motion',
);
const spriteField = selectField('Sprite style', st.spriteStyle, [ const spriteField = selectField('Sprite style', st.spriteStyle, [
['default', 'Pixel (modern)'], ['default', 'Pixel (modern)'],
['game', 'Game era (pixel art from the selected game)'], ['game', 'Game era (pixel art from the selected game)'],
@ -72,6 +93,21 @@ export async function SettingsView() {
'Show Team in the navigation bar', 'Show Team in the navigation bar',
); );
const hapticsField = el('label', { class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: st.haptics !== false,
onchange: (e) => settings.set({ haptics: e.target.checked }),
}),
'Haptic feedback on catch',
);
const defaultRouteField = selectField('Landing screen', st.defaultRoute || 'dex', [
['dex', 'Dex'],
['team', 'Team'],
['search', 'Search'],
], (v) => settings.set({ defaultRoute: v }));
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…'); const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
if (navigator.storage?.estimate) { if (navigator.storage?.estimate) {
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => { navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
@ -102,7 +138,19 @@ export async function SettingsView() {
view.append( view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')), el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField, teamNavField), el(
'div',
{ class: 'settings__group' },
themeField,
accentField,
fontSizeField,
reduceMotionField,
spriteField,
shinyField,
hapticsField,
teamNavField,
defaultRouteField,
),
el('h2', {}, 'Your data'), el('h2', {}, 'Your data'),
storageNote, storageNote,