Progress view "Your games" now has an Edit mode: tap a game to open a
dialog to **reset** its caught/seen (keeps it listed) or **remove** it
from the list (also clears its bucket). The currently-selected game can't
be removed (it'd re-add on next launch) — reset only. New
`clearGame(vgKey)` in the selection store.
Lighthouse pass (headless, local): SEO 100, a11y 96, best-practices 96.
- add public/robots.txt (SEO 92 → 100)
- ProgressRing: keep the aria-label in sync with the visible count
(fixes label-content-name-mismatch)
- .type-chip: text-shadow for white-on-bright-type-colour legibility,
0.68 → 0.7rem
Still open (design calls, see notes): type-chip colour contrast is
~2–3.7:1 for the lighter types (white on the canonical type colours);
some sub-12px labels; performance is dominated by the ~1 MB snapshot and
the ~1000-card grid.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
31 tests in test/, run in a plain Node env with a tiny localStorage shim
(no jsdom — its Node-22.4 transitive-dep breakage isn't worth working
around for logic tests):
- type-chart: modern effectiveness, dual-type stacking, and the era rules
(no Fairy pre-6, Steel resists Ghost/Dark pre-6, the Gen 1 Ghost→Psychic
and Bug↔Poison quirks).
- damage-calc: nature multipliers, the Gen 3+ stat formula (Garchomp
reference values), STAB/effectiveness/immunity, pre/post-Gen-6 crit.
- savedex: a synthetic Gen 1 SRAM (bitfields + checksum) round-trips;
checksum-mismatch and size guards.
- selection: normalizeSelection v2→v3 migration, per-game isolation,
global favourite, stats vs statsNational.
- pokedex-resolver: prettify, dex resolution against a mini snapshot.
`npm test` wired into CI after format:check.
Also: docker-compose host port 8080 → 4753 (README updated).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
- ci.yml runs `npm run format:check` before the build.
- CONTRIBUTING / AGENTS: point at `npm run format` instead of the old
"no formatter" note.
- README: new "Built with AI assistance" section — the project is
developed with Claude / Claude Code, human-directed and reviewed;
visible in the Co-Authored-By trailers. CONTRIBUTING asks contributors
to disclose AI-assisted PRs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
Pure formatting — no behaviour change. Verified: build passes, all 14
routes render, service worker active, no console errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
- .github/workflows/ci.yml — npm ci + npm run build on push/PR, with the
PokéAPI snapshot cached so only the first run pays the fetch cost.
- .github/dependabot.yml — weekly npm, monthly actions, grouped.
- .github/ISSUE_TEMPLATE/ — bug report, feature request, config with
discussion / upstream-data links.
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1 (contact method is a
placeholder to fill in).
- SECURITY.md — private reporting + the known dev-only Vite/esbuild advisory.
- AGENTS.md — machine-facing version of CONTRIBUTING for AI coding agents.
- docs/screenshots/ + a strip in the README (resized + pngquant'd, ~240 KB).
- public/og.png + Open Graph / Twitter card meta in index.html.
- .nvmrc (20); gitignore .claude/ .idea/ .vscode/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
Rename the app from the working-title "Pokédex" to Pocketdex across the
nav brand, install toast, PWA manifest, index.html title/meta, and
package.json. Generic in-universe uses of "Pokédex" (sub-dex label, "all
Pokédex entries", save-import messages) are left as-is.
Docs for going public:
- README.md rewritten: quick start, full feature list, how-it-works
(data/storage split, per-game tracking, routing), deployment, legal
disclaimer, contributing pointer.
- CONTRIBUTING.md: ground rules (no copyrighted assets, framework-free,
offline-first), setup, project map, code style, common tasks, manual
test checklist, PR conventions.
- LICENSE: MIT (+ note that it covers source only, not Pokémon data).
- .editorconfig, .github/PULL_REQUEST_TEMPLATE.md.
- package.json: add "engines": node >=20.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
Catching a Pokémon in Blue was counting it as caught in Gold, Emerald and
every other game, because the whole app ran on one caught record keyed by
National Dex id. Progress by game just intersected that set with each
game's species list, so the per-game bars were meaningless.
selection store is now:
pokemon: { [id]: { favorite, note } } -- global (describe the mon)
games: { [vg]: { [id]: { seen, caught } } } -- per game
- entry(id) / toggle(id, field) / stats(ids) default to the selected game;
favorite/note stay global. 'all' (All-games mode) reads as the union of
every game -- "caught anywhere" -- and is a real bucket you write to when
no specific game is selected.
- v2 stores migrate on load (normalizeSelection): favorite/note lift out,
seen/caught drop into the 'all' bucket so nothing is falsely attributed
to a game. Same path runs on JSON backup import.
- ProgressView: each game row counts its own bucket; National row =
statsNational (union). Intro text slimmed.
- Save import writes into one game bucket -- the selected game if it's a
candidate for that save, else the parser's first guess -- and the dialog
names which game it's importing to.
- DexGrid feed (ring, filter counts, caught-sort), PokemonDetail track
buttons, search dots, shiny "found it" all follow the selected game.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
The Natures / Type chart / Compare / Breeding links under the Team header
were plain dot-separated text. Make them a row of pill chips with small
accent icon badges (.toollinks / .toollink).
Progress by game showed a caught total for all 32 version groups the
moment anything was marked, since the tracking model is one unified
caught record. Add a "played games" set (pdx.playedGames, vg keys),
auto-marked when a game is selected or a save is imported (savedex now
returns candidate versionGroups per generation). #/progress leads with a
"Your games" section and tucks the rest behind a "Show all other games"
toggle (persisted). Included in JSON export/import.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
savedex.js now also reads 512 KB DS saves:
- Gen 4 (D/P/Pt, HG/SS): dex struct at slot+0x12 — caught 0x40 bytes at
+0x16, four per-form "seen" copies at +0x56/0x96/0xD6/0x116 OR'd
together, 493 species.
- Gen 5 (B/W, B2/W2): dex block at slot+0x21600 (BW) / +0x21400 (B2W2) —
caught at +0x08, four "seen" copies at +0x5C/0xB0/0x104/0x158, 649
species; the offset that validates picks the game.
- Handles both save slots (picks the fuller one), a DeSmuME `.dsv`
footer, and oversized flash dumps.
No block CRC — validated structurally instead (nothing set past the last
species, every caught species also seen), so a wrong offset is rejected
rather than importing garbage. Offsets are from PKHeX; worth checking
against a real save.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
- The save-import dialog body is now just "N seen · N caught in this
save." — the button labels carry the merge/replace meaning.
- "colour" → "color" throughout (the visible "Accent color" field, plus
comments and README for consistency).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
savedex.js now sniffs the file and parses whichever generation it is:
- Gen 1 (R/B/Y): 32 KB SRAM, fixed offsets — owned 0x25A3, seen 0x25B6
(151 species), name 0x2598, one-byte complement checksum.
- Gen 2 (G/S/C): tries Gold/Silver offsets (0x2A4C/0x2A6C, checksum
0x2D69) and Crystal (0x2A27/0x2A47, checksum 0x2D0D); the 16-bit
checksum picks which, with a caught⊆seen / no-overflow sanity fallback.
- Gen 3: unchanged (parseGen3Dex kept as an alias).
parseSaveDex(buffer) is the new entry point; the Settings picker relabels
to "Import from a game save". Verified against synthetic Gen 1 / GS /
Crystal saves and end-to-end through the UI (a Gen 1 save merged
11 caught / 84 seen).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
lib/savedex.js parses a Generation III save (Ruby/Sapphire/Emerald/
FireRed/LeafGreen): picks the newer of the two save slots, resolves the
rotated section order via each section's footer, and reads the seen /
owned bitfields out of `struct Pokedex` in section 0 (bit dexNo-1, 386
species). Also decodes the trainer name and detects the national-dex
magic byte. Soft checksum check.
Settings > Your data: "Import from a GBA save" — file picker → confirm →
selection.importFlags() merges seen/caught in (never removes). Verified
against synthetic saves (slot selection, rotation, FRLG vs RSE, bit
decode) and end-to-end through the Settings UI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
build-snapshot: a new pass fetches /pokemon/:id/encounters for every
species and records, per game, the versions it has wild encounters in —
when that's a non-empty strict subset of the game's versions,
species.exclusiveIn[vg] holds them. Wild-only, so gift/trade/evolution
exclusives are missed.
Card: with a specific game selected, shows a "<Version> only" pill
(e.g. Seedot -> "Ruby only", Lotad -> "Sapphire only"). 220 species
flagged across all games; nothing shown in National mode.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
Team slots are now { id, form } instead of bare ids (old saves migrate
on read via slot()). The detail page's team button adds whatever
mechanical form is selected — "+ Mega X" / "✓ Mega X in team" — and
TeamView merges that form's baked snapshot types/stats/BST into the
member so Coverage, Compare and the Calc all analyse the form (Mega
Charizard X shows as Fire/Dragon, BST 634). Lineup shows the form name
and sprite; remove targets the exact slot. The Add picker still adds
base forms — Megas etc. come from their own page.
CompareTable takes an optional spriteId; addToTeam/removeFromTeam/inTeam
gain a form argument.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
New view (linked from Settings > Your data): the unified caught record
counted against every game's dex at once — National total on top, then
each version group grouped by generation with a seen/caught bar and
X / Y count. Tapping a game switches the active version group and jumps
to the dex.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
Extracted the Team Compare table into components/CompareTable.js and
reused it for a new #/compare route: its own picker (up to 4 Pokémon,
persisted in ui.compareIds), gen-aware types, per-stat max highlight.
Linked from the Team header alongside Natures / Type chart. TeamView's
Compare tab now just calls CompareTable too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
- Rasterised the SVG to pwa-192 / pwa-512 / pwa-maskable-512 (logo in the
safe zone, full-bleed background) and a real apple-touch-icon.png;
manifest now lists PNG "any" + a dedicated "maskable".
- Manifest shortcuts: Team, Search, Type chart.
- lib/install.js captures beforeinstallprompt so the app can offer
installation itself — a one-time toast, and an "Install app" button in
Settings (falls back to a "use your browser menu" hint where the event
never fires, e.g. iOS).
- index.html: apple-touch-icon PNG + apple-mobile-web-app meta.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
New #/types route (linked from the Team header alongside Natures): the
full 18x18 attack/defend grid, gen-aware from the selected game — drops
Fairy pre-Gen 6, Steel/Dark pre-Gen 2, and applies the Gen 1 / pre-Gen 6
quirks the type-chart module already models. Tap a type on either edge to
highlight its row+column and get an offensive + defensive breakdown as
type chips.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
- AbilityDetail: "Pokémon with this ability" grid now gets a filter box
and the same game/generation narrowing as the move and item pages, plus
a note when the ability postdates the selected game (Libero in
Ruby/Sapphire -> "introduced in Gen 8").
- TeamView "At a glance": Fastest / Bulkiest names now link to their
Pokémon pages.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
The "Learned by N Pokémon" fact was a dead number. Now the move page
renders that list as a clickable sprite grid (links to each Pokémon),
with a filter box once it's more than ~24 long.
PokéAPI's learned_by_pokemon is flat across all games, so for a specific
game it's narrowed to species that existed by that generation — a
"Crystal" view of Surf shows 69 Gen 1–2 Pokémon, not the full 231. Not
perfectly game-exact (a mon that only gained the move in a later gen can
slip through) but far better than guess-and-check. National ("all") shows
the unfiltered list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
TM/HM numbers are now baked into the snapshot (build-snapshot resolves
every machine id -> "TM25" etc. in a post-pass, ~2400 lookups), so the
Search moves list no longer lazily fetches /machine per row — it shows
numbers synchronously and can sort by them.
- New "TM / HM number" sort option, shown only while a machine filter is
on, and dropped back to A–Z when neither is.
- "TMs only" now means strictly TMs; added a parallel "HMs only". Either
or both. In "All games" mode a move can be a TM in one game and an HM
in another, so machineFor picks the newest entry of a requested kind
rather than just the newest entry (fixes "HMs only" showing 0).
Removes the getMachine/mapLimit lazy-resolution path (and its now-unused
stale-token guard) from SearchView.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
The learnset's "By TM / HM" group was alphabetical. TM numbers aren't in
the snapshot — they resolve lazily per move via /machine/{id}, which the
group's enrichers already fetch on expand. So: await those resolutions
(fillTm is now async and stashes the label on the move), and once the
whole group is enriched, re-append the rows in TM/HM/TR-then-number order
— TM01, TM02 … HM01 …, matching the in-game list. Verified against
Charizard in FireRed/LeafGreen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
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
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
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
New "Calc" mode alongside Coverage/Compare. Pick (or quick-pick from your
current team) an attacker, a move, and a defender; get a damage range, %
of the defender's HP, hits-to-KO, and STAB/effectiveness badges.
- src/lib/damage-calc.js: the standard Gen 3+ stat and damage formulas
(statAt, calcDamage), evaluated across the 85–100% roll.
- Move data is era-accurate: picking a move fetches it live for
past_values (power/type/class changes), reusing MovesList's
forGeneration (now exported) via a snapshot-shaped shim so the instant
preview and the refined post-fetch result use the same code path.
- Level/nature/EV inputs per side (defaults: level 100, neutral nature,
0 EV, 31 IV assumed) plus a crit toggle.
- Explicitly out of scope and called out in the UI: items, abilities,
weather, terrain — this is a base-stats estimate, not a battle-exact
number.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
Coverage was hardcoded to Gen 9. Now it reads the selected game: only
that generation's types get rows (no Fairy pre-Gen 6, no Steel/Dark
pre-Gen 2), member typings are era-accurate via the snapshot's pastTypes,
and the type chart / STAB maths run at the game's generation. Compare's
Type row follows suit. The panel re-renders on a game switch.
New "Weak spots" block above the grid: types that hit 2+ members
super-effectively, or that a member is weak to and nobody resists (those
get a "0 resist" flag), each listing the exposed members. Plus a
"no resist to" line for types the whole team only takes neutrally but
can't wall. The grid itself swaps the plain "weak" count for a diverging
resist/weak bar and tints rows where 2+ members are weak.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
Pull sprite-only variants into the snapshot — Unown letters, Vivillon
patterns, Furfrou trims, seasonal Deerling/Sawsbuck, Flabébé-line colours,
Alcremie decorations, Pikachu caps (278 across 50 species). They share the
base Pokémon's typing/stats/abilities/learnset, so they carry only
{ slug, id, name, sprite } and never trigger a /pokemon fetch.
build-snapshot.mjs: cosmetic varieties (previously dropped) and the
/pokemon-form entries for species with >1 form now land in
species.cosmeticForms.
PokemonDetail: a single <select> "Appearance" dropdown, separate from the
mechanical form pills, that swaps only the hero artwork + label. Picking a
cosmetic look while a mechanical form is active reverts to base first.
Card "+N forms" badge counts cosmetic variants too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
- Snapshot: each species gains a forms[] list (slug, form id, label,
category, types, stats, BST, height, weight) built from its non-default
varieties — 264 forms across 203 species (Megas, G-max, regional,
alt formes; cosmetic-only forms filtered out). Snapshot ~914 KB.
- Detail page: a form switcher (Base + each form). Selecting a form
re-fetches its /pokemon and rebuilds the type-dependent parts —
hero name/types/artwork, --type theming, stat bars + total, defensive
matchups, STAB coverage, abilities, EV yield, held items, and learnset.
Species-level data (flavour, breeding, evolution) stays put.
- Grid cards show a '+N forms' badge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Snapshot now bakes a moves index (937: type, damage class, power,
accuracy, pp, priority, generation, machine ids per version group) and an
items index (2223: id, name, category — resolved via the 54 item
categories). ~806 KB / 117 KB gzip.
- Search Moves/Items tabs show the FULL list when the query is empty:
- Moves: filter by type / damage class / 'TMs only' (for the selected
game), sort by A–Z / power / accuracy / newest; TM numbers resolve
lazily via /machine. Rich rows (type + class + power) with no fetch.
- Items: filter by category, sort A–Z / dex order; sprite + category tag.
- Result count shown; capped at 400 with a 'refine to see more' note.
- Both tabs are now fully offline (dropped the online name-index calls).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Search becomes a tabbed lookup: Pokémon (offline snapshot) / Moves /
Items. Moves + Items lazy-load a name index from PokéAPI (SW-cached) and
filter client-side; short move result sets get type/class/power chips
- MoveDetail (#/move/:id): type-themed header, power/accuracy/PP/priority,
effect text with %-substitution, target, ailment/crit/drain/stat-change
summary, learned-by count
- ItemDetail (#/item/:id): item sprite, category, cost, fling power,
attributes, held-by count, effect text
- Loose name matching (hyphens vs spaces); run-token guards stale async
renders when the query or tab changes mid-fetch
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>