Compare commits

...

3 Commits

Author SHA1 Message Date
39bffcab32 CI: gate on Prettier; docs: disclose AI assistance
- 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
2026-09-10 11:26:16 -04:00
671e72972a Apply Prettier to the whole tree
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
2026-09-10 11:24:31 -04:00
dfd848d506 Add Prettier (config + scripts), not yet applied
printWidth 100 to stay close to the existing hand-formatting; single
quotes; trailing commas. `npm run format` / `npm run format:check`.
The formatting pass itself is the next commit so it stays isolated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 11:22:46 -04:00
57 changed files with 1336 additions and 503 deletions

View File

@ -1,24 +1,24 @@
--- ---
name: Bug report name: Bug report
about: Something isn't working right about: Something isn't working right
title: "" title: ''
labels: bug labels: bug
assignees: "" assignees: ''
--- ---
**What happened** **What happened** (and what you expected instead)
<!-- and what you expected instead -->
**Steps to reproduce** **Steps to reproduce**
1.
2. 1. Go to …
3. 2. …
3. …
**Context** **Context**
- Browser / OS: - Browser / OS:
- Installed as a PWA or in a browser tab: - Installed as a PWA or in a browser tab:
- Selected game (if relevant): - Selected game (if relevant):
- Console errors (DevTools → Console): - Console errors (DevTools → Console):
**Screenshot / clip** **Screenshot / clip** (optional but very helpful)
<!-- optional but very helpful -->

View File

@ -1,9 +1,9 @@
--- ---
name: Feature request name: Feature request
about: Suggest an idea about: Suggest an idea
title: "" title: ''
labels: enhancement labels: enhancement
assignees: "" assignees: ''
--- ---
**The idea** **The idea**

View File

@ -1,18 +1,18 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: npm - package-ecosystem: npm
directory: "/" directory: '/'
schedule: schedule:
interval: weekly interval: weekly
groups: groups:
workbox: workbox:
patterns: patterns:
- "workbox-*" - 'workbox-*'
dev-dependencies: dev-dependencies:
dependency-type: development dependency-type: development
open-pull-requests-limit: 5 open-pull-requests-limit: 5
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: "/" directory: '/'
schedule: schedule:
interval: monthly interval: monthly

View File

@ -21,6 +21,8 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run format:check
# The build's `prebuild` step fetches a data snapshot from PokéAPI. # The build's `prebuild` step fetches a data snapshot from PokéAPI.
# Cache it so only the first run (or a change to the script) pays that # Cache it so only the first run (or a change to the script) pays that
# cost; CI only needs *a* snapshot to prove the build compiles. # cost; CI only needs *a* snapshot to prove the build compiles.

7
.prettierignore Normal file
View File

@ -0,0 +1,7 @@
dist
dev-dist
node_modules
package-lock.json
src/data/snapshot.json
docs/screenshots
CHANGELOG.md

5
.prettierrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}

View File

@ -21,25 +21,26 @@ npm run preview # serve the production build — REQUIRED to test the se
``` ```
- Node **20+**. - Node **20+**.
- There is **no lint or test command** and **no test suite**. The build - Automated checks (both run in CI, both must pass):
(`npm run build`) is the only automated check — it must pass. It runs `npm run format:check` (Prettier) and `npm run build` (esbuild transforms
esbuild over every module, so it catches syntax and import errors. every module, so it catches syntax and import errors). Run `npm run format`
before finishing. There is **no test suite** and no ESLint.
- `npm run snapshot -- --force` rebuilds the snapshot even if it's fresh. - `npm run snapshot -- --force` rebuilds the snapshot even if it's fresh.
## Project structure ## Project structure
| Path | What | | Path | What |
| --- | --- | | ---------------------------- | --------------------------------------------------------------------------- |
| `src/main.js` | boot: theme, nav, router, SW registration, install prompt | | `src/main.js` | boot: theme, nav, router, SW registration, install prompt |
| `src/router.js` | hash router; `routes` array; each view is `() => Promise<Node>` | | `src/router.js` | hash router; `routes` array; each view is `() => Promise<Node>` |
| `src/views/*.js` | one file per route (`DexGrid`, `PokemonDetail`, `TeamView`, …) | | `src/views/*.js` | one file per route (`DexGrid`, `PokemonDetail`, `TeamView`, …) |
| `src/components/*.js` | reusable DOM pieces (`Card`, `Sprite`, `TypeChip`, `CompareTable`, …) | | `src/components/*.js` | reusable DOM pieces (`Card`, `Sprite`, `TypeChip`, `CompareTable`, …) |
| `src/store/*.js` | `createStore()` state, persisted to `localStorage` under `pdx.*` | | `src/store/*.js` | `createStore()` state, persisted to `localStorage` under `pdx.*` |
| `src/data/*.js` | snapshot loader, pokedex resolver, lazy PokéAPI client, type chart, natures | | `src/data/*.js` | snapshot loader, pokedex resolver, lazy PokéAPI client, type chart, natures |
| `src/lib/*.js` | app-agnostic helpers (`dom`, `anim`, `damage-calc`, `savedex`, `swipe`, …) | | `src/lib/*.js` | app-agnostic helpers (`dom`, `anim`, `damage-calc`, `savedex`, `swipe`, …) |
| `scripts/build-snapshot.mjs` | the only thing that calls PokéAPI at build time | | `scripts/build-snapshot.mjs` | the only thing that calls PokéAPI at build time |
| `src/styles/tokens.css` | palette, type colours, the five themes | | `src/styles/tokens.css` | palette, type colours, the five themes |
| `src/styles/layout.css` | everything else | | `src/styles/layout.css` | everything else |
## Conventions ## Conventions
@ -60,8 +61,8 @@ npm run preview # serve the production build — REQUIRED to test the se
caught set. caught set.
- Respect `prefersReducedMotion()` (from `src/store/settings.js`) for any - Respect `prefersReducedMotion()` (from `src/store/settings.js`) for any
animation. animation.
- 2-space indent, semicolons, single quotes, trailing commas in multi-line - Formatting is Prettier (`npm run format`). Otherwise match the
literals. Match the surrounding file; keep comments about *why*. surrounding file; keep comments about _why_.
- No new runtime dependencies. Prefer adding to the snapshot over a new - No new runtime dependencies. Prefer adding to the snapshot over a new
runtime fetch. runtime fetch.
@ -90,7 +91,8 @@ npm run preview # serve the production build — REQUIRED to test the se
## Verifying a change ## Verifying a change
`npm run build` must pass. Then sanity-check by hand: the dex grid `npm run format` then `npm run build` — both must pass. Then sanity-check
by hand: the dex grid
(filters, sort, catching from a card), switching games (numbers + typings (filters, sort, catching from a card), switching games (numbers + typings
update), a detail page (all tabs, form switch), and `npm run preview` update), a detail page (all tabs, form switch), and `npm run preview`
offline (DevTools → Network → Offline → reload). Check light and dark offline (DevTools → Network → Offline → reload). Check light and dark

View File

@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall - Focusing on what is best not just for us as individuals, but for the overall
community community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of - The use of sexualized language or imagery, and sexual attention or advances of
any kind any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email address, - Publishing others' private information, such as a physical or email address,
without their explicit permission without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities

View File

@ -54,18 +54,20 @@ See the **Project layout** and **How it works** sections in
## Code style ## Code style
There's no linter or formatter config yet — **match the surrounding Formatting is **Prettier** (`.prettierrc.json`). Run `npm run format`
code**: before committing; CI runs `npm run format:check` and will fail on
unformatted code. There's no ESLint — for everything Prettier doesn't
decide, **match the surrounding code**:
- 2-space indent, semicolons, single quotes, trailing commas in - 2-space indent, semicolons, single quotes, 100-col width, trailing
multi-line literals (an `.editorconfig` covers whitespace). commas (all handled by Prettier).
- Build DOM with the `el()` helper from `src/lib/dom.js`, not template - Build DOM with the `el()` helper from `src/lib/dom.js`, not template
strings or `innerHTML` (except the deliberate `html:` prop for trusted strings or `innerHTML` (except the deliberate `html:` prop for trusted
inline SVG). inline SVG).
- Stores: `set(fn)` **replaces** state — spread `...s` yourself. Read with - Stores: `set(fn)` **replaces** state — spread `...s` yourself. Read with
`get()`, react with `subscribe()`, and unsubscribe in the view's `get()`, react with `subscribe()`, and unsubscribe in the view's
teardown (`onTeardown(view, off)`). teardown (`onTeardown(view, off)`).
- Keep comments about *why*, not *what*. Match the existing density. - Keep comments about _why_, not _what_. Match the existing density.
- Respect `prefersReducedMotion()` for any new animation. - Respect `prefersReducedMotion()` for any new animation.
- Keep everything game-aware: if you touch typings, type effectiveness, - Keep everything game-aware: if you touch typings, type effectiveness,
learnsets or evolution, honour the selected game's generation learnsets or evolution, honour the selected game's generation
@ -105,11 +107,15 @@ Describe what you tested in the PR.
- Branch off `main`. One focused change per PR. - Branch off `main`. One focused change per PR.
- Imperative commit subjects ("Add …", "Fix …"), with a body explaining - Imperative commit subjects ("Add …", "Fix …"), with a body explaining
the *why* when it isn't obvious. the _why_ when it isn't obvious.
- Don't commit `dist/` or `src/data/snapshot.json` (both git-ignored). - Don't commit `dist/` or `src/data/snapshot.json` (both git-ignored).
- Fill in the PR template. Screenshots or a short clip for anything - Fill in the PR template. Screenshots or a short clip for anything
visual. visual.
- It's fine to open a draft PR early to discuss direction. - It's fine to open a draft PR early to discuss direction.
- AI-assisted contributions are welcome — say so in the PR description if a
tool wrote a meaningful part of it, and review it yourself first. The
same bar applies either way. This project is itself built this way (see
the README).
## Reporting bugs ## Reporting bugs

View File

@ -38,12 +38,12 @@ npm run snapshot # fetch the data snapshot from PokéAPI (one time)
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
``` ```
| Script | What it does | | Script | What it does |
| --- | --- | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run snapshot` | Build `src/data/snapshot.json` from PokéAPI. Skips if the file is under 30 days old; `npm run snapshot -- --force` to rebuild anyway. | | `npm run snapshot` | Build `src/data/snapshot.json` from PokéAPI. Skips if the file is under 30 days old; `npm run snapshot -- --force` to rebuild anyway. |
| `npm run dev` | Vite dev server with HMR. | | `npm run dev` | Vite dev server with HMR. |
| `npm run build` | Runs `snapshot` (as `prebuild`), then builds to `dist/`. | | `npm run build` | Runs `snapshot` (as `prebuild`), then builds to `dist/`. |
| `npm run preview` | Serve the production build. **Use this to test the service worker / offline / install** — none of that runs under `npm run dev`. | | `npm run preview` | Serve the production build. **Use this to test the service worker / offline / install** — none of that runs under `npm run dev`. |
The snapshot is **git-ignored** — it's a build artifact. `npm install` The snapshot is **git-ignored** — it's a build artifact. `npm install`
doesn't create it; run `npm run snapshot` (or `npm run build`) first, or doesn't create it; run `npm run snapshot` (or `npm run build`) first, or
@ -54,6 +54,7 @@ the app has no data to show.
## Features ## Features
### Dex grid ### Dex grid
Type-tinted cards — official artwork over a type-coloured spotlight, ghost Type-tinted cards — official artwork over a type-coloured spotlight, ghost
number, per-dex progress ring. Filter by name/number; chips for number, per-dex progress ring. Filter by name/number; chips for
caught / missing / favourites / legendary / has-a-note; a filter drawer for caught / missing / favourites / legendary / has-a-note; a filter drawer for
@ -65,6 +66,7 @@ version-exclusive badge ("Ruby only"), derived from baked wild-encounter
data (so gift/trade-only exclusives are missed). data (so gift/trade-only exclusives are missed).
### Detail page ### Detail page
Type-gradient hero with a shared-element morph from the tapped card, then a Type-gradient hero with a shared-element morph from the tapped card, then a
tabbed sheet: tabbed sheet:
@ -87,9 +89,10 @@ compact **Appearance** dropdown, plus a per-species "form dex" checklist
tracked independently of the main caught flag. tracked independently of the main caught flag.
### Team builder (`#/team`) ### Team builder (`#/team`)
A lineup of up to 6 (specific forms included). Three modes: A lineup of up to 6 (specific forms included). Three modes:
- **Coverage** — leads with a *weak spots* summary (types that hit 2+ - **Coverage** — leads with a _weak spots_ summary (types that hit 2+
members, or that someone's weak to and nobody resists), then a full members, or that someone's weak to and nobody resists), then a full
defensive matchup grid with a diverging resist ◀│▶ weak bar per type, defensive matchup grid with a diverging resist ◀│▶ weak bar per type,
offensive STAB gaps, and at-a-glance stats. offensive STAB gaps, and at-a-glance stats.
@ -108,6 +111,7 @@ and a **Breeding** helper (`#/breeding` — egg-group browser, compatibility
check, and egg moves resolved to the base breeding stage). check, and egg moves resolved to the base breeding stage).
### Search (`#/search`) ### Search (`#/search`)
Tabbed lookup — Pokémon, Moves, Items, Abilities — all browsable offline Tabbed lookup — Pokémon, Moves, Items, Abilities — all browsable offline
from the snapshot. Moves filter by type / damage class / "TMs only" / from the snapshot. Moves filter by type / damage class / "TMs only" /
"HMs only" for the selected game and sort by power / accuracy / recency, or "HMs only" for the selected game and sort by power / accuracy / recency, or
@ -117,28 +121,31 @@ move, ability and item detail pages each list the Pokémon connected to them
selected game. Query, scroll, tab and filters persist. selected game. Query, scroll, tab and filters persist.
### Progress by game (`#/progress`) ### Progress by game (`#/progress`)
Every game's seen/caught tally against its own dex, from its own per-game Every game's seen/caught tally against its own dex, from its own per-game
bucket. Leads with the games you've played (selected at least once, or bucket. Leads with the games you've played (selected at least once, or
imported a save for); the rest sit behind a toggle. The National Dex row is imported a save for); the rest sit behind a toggle. The National Dex row is
the union across all games. the union across all games.
### Shiny hunt tracker (`#/shiny`) ### Shiny hunt tracker (`#/shiny`)
Per-hunt counters with method-aware odds (full odds, Masuda, SOS, chain Per-hunt counters with method-aware odds (full odds, Masuda, SOS, chain
fishing, DexNav, radar, dynamax adventures, outbreaks, mass outbreaks) and fishing, DexNav, radar, dynamax adventures, outbreaks, mass outbreaks) and
Shiny Charm, cumulative-probability readout, notes, and a "found it" that Shiny Charm, cumulative-probability readout, notes, and a "found it" that
marks the Pokémon caught. marks the Pokémon caught.
### Save-file import ### Save-file import
**Settings → Import from a game save.** Reads the game's own seen/owned **Settings → Import from a game save.** Reads the game's own seen/owned
bitfields — not an approximation — from: bitfields — not an approximation — from:
| Gen | Games | Format | | Gen | Games | Format |
| --- | --- | --- | | --- | -------------------------------------------------- | -------------------------------------------------------- |
| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) | | 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) |
| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal | | 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal |
| 3 | Ruby / Sapphire / Emerald, FireRed / LeafGreen | 128 KB — newer slot, rotated sections | | 3 | Ruby / Sapphire / Emerald, FireRed / LeafGreen | 128 KB — newer slot, rotated sections |
| 4 | Diamond / Pearl / Platinum, HeartGold / SoulSilver | 512 KB NDS — active slot, DeSmuME `.dsv` footer stripped | | 4 | Diamond / Pearl / Platinum, HeartGold / SoulSilver | 512 KB NDS — active slot, DeSmuME `.dsv` footer stripped |
| 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd | | 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd |
Gen 13 are checksum-verified; Gen 45 offsets come from PKHeX and are Gen 13 are checksum-verified; Gen 45 offsets come from PKHeX and are
validated structurally (nothing set past the last species; every caught validated structurally (nothing set past the last species; every caught
@ -150,6 +157,7 @@ Full local state (settings, per-game tracking, team, form tracking, shiny
hunts, played games) also exports / imports as a single JSON file. hunts, played games) also exports / imports as a single JSON file.
### Settings ### Settings
Theme (System / Light / Dark / Black OLED / Sepia) + accent colour; text Theme (System / Light / Dark / Black OLED / Sepia) + accent colour; text
size; an in-app reduce-motion override (the OS preference is always size; an in-app reduce-motion override (the OS preference is always
respected too); sprite style (modern pixel / game-era pixel / official respected too); sprite style (modern pixel / game-era pixel / official
@ -157,6 +165,7 @@ artwork / HOME); haptic feedback on catch (Vibration API); which screen to
land on at launch; JSON export / import; cache and tracking resets. land on at launch; JSON export / import; cache and tracking resets.
### PWA ### PWA
Workbox service worker: precache the app shell + snapshot; stale-while- Workbox service worker: precache the app shell + snapshot; stale-while-
revalidate for PokéAPI JSON (30-day TTL); cache-first LRU for sprites; revalidate for PokéAPI JSON (30-day TTL); cache-first LRU for sprites;
in-app update toast. Installable with maskable icons, `beforeinstallprompt` in-app update toast. Installable with maskable icons, `beforeinstallprompt`
@ -169,12 +178,12 @@ Type chart.
### Data & storage ### Data & storage
| Layer | Where | Notes | | Layer | Where | Notes |
| --- | --- | --- | | ---------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Preferences + tracking | `localStorage` (`pdx.*` keys) | Tiny, synchronous, restored on reload. | | Preferences + tracking | `localStorage` (`pdx.*` keys) | Tiny, synchronous, restored on reload. |
| Build-time snapshot | `src/data/snapshot.json` (~150 KB) | Every species (id, name, generation, current + past typings, base stats, height/weight, species flags), every regional Pokédex, every version group, plus move / item / ability name indexes and baked TM numbers and version-exclusivity. Precached by the SW. | | Build-time snapshot | `src/data/snapshot.json` (~150 KB) | Every species (id, name, generation, current + past typings, base stats, height/weight, species flags), every regional Pokédex, every version group, plus move / item / ability name indexes and baked TM numbers and version-exclusivity. Precached by the SW. |
| Detail data | `pokeapi.co`, fetched lazily per page | Stats, abilities, flavour text, evolution, encounters. Cached by the SW (stale-while-revalidate). | | Detail data | `pokeapi.co`, fetched lazily per page | Stats, abilities, flavour text, evolution, encounters. Cached by the SW (stale-while-revalidate). |
| Sprites | PokéAPI sprites repo, cache-first | Capped LRU. | | Sprites | PokéAPI sprites repo, cache-first | Capped LRU. |
The list, search, sort, filters and game switching run **entirely off the The list, search, sort, filters and game switching run **entirely off the
snapshot** — no network until you open a detail page. snapshot** — no network until you open a detail page.
@ -266,6 +275,21 @@ coordinates), no automated test suite yet, and no formal Lighthouse pass.
--- ---
## Built with AI assistance
Pocketdex is developed with heavy use of AI coding tools — primarily
Anthropic's Claude, via Claude Code. Direction, design decisions, review
and testing are human-led; a large share of the implementation is
AI-generated and then reviewed before it lands. You'll see this in the git
history (`Co-Authored-By: Claude …` trailers).
This doesn't change anything for contributors: use whatever tools you like,
disclose AI-generated PRs in the description, and expect the same review
either way. There's an [AGENTS.md](AGENTS.md) so agents have the house
rules too.
---
## Legal ## Legal
Pocketdex is an unofficial, non-commercial fan project. It is **not Pocketdex is an unofficial, non-commercial fan project. It is **not

View File

@ -10,8 +10,8 @@ branches.
Please **don't** open a public issue for a security problem. Please **don't** open a public issue for a security problem.
Use GitHub's **private vulnerability reporting** (the *Report a Use GitHub's **private vulnerability reporting** (the _Report a
vulnerability* button under the repository's *Security* tab). Include what vulnerability_ button under the repository's _Security_ tab). Include what
you found, how to reproduce it, and the impact you think it has. You'll get you found, how to reproduce it, and the impact you think it has. You'll get
an acknowledgement as soon as possible. an acknowledgement as soon as possible.

View File

@ -6,5 +6,5 @@ services:
BASE_PATH: / BASE_PATH: /
image: pocketdex image: pocketdex
ports: ports:
- "8080:80" - '8080:80'
restart: unless-stopped restart: unless-stopped

View File

@ -2,10 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="theme-color" content="#b3161a" /> <meta name="theme-color" content="#b3161a" />
<meta <meta
name="description" name="description"

17
package-lock.json generated
View File

@ -17,6 +17,7 @@
"workbox-window": "^7.4.1" "workbox-window": "^7.4.1"
}, },
"devDependencies": { "devDependencies": {
"prettier": "3.9.6",
"vite": "^6.4.3", "vite": "^6.4.3",
"vite-plugin-pwa": "^1.3.0" "vite-plugin-pwa": "^1.3.0"
}, },
@ -4925,6 +4926,22 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": { "node_modules/pretty-bytes": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",

View File

@ -9,13 +9,16 @@
"prebuild": "node scripts/build-snapshot.mjs", "prebuild": "node scripts/build-snapshot.mjs",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"prettier": "3.9.6",
"vite": "^6.4.3", "vite": "^6.4.3",
"vite-plugin-pwa": "^1.3.0" "vite-plugin-pwa": "^1.3.0"
}, },

View File

@ -111,9 +111,7 @@ async function isFresh() {
async function main() { async function main() {
if (!FORCE && (await isFresh())) { if (!FORCE && (await isFresh())) {
console.log( console.log(`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`);
`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`,
);
return; return;
} }
@ -170,15 +168,11 @@ async function main() {
pokedexKeys: data.pokedexes.map((x) => x.name), pokedexKeys: data.pokedexes.map((x) => x.name),
}); });
}); });
versionGroups.sort( versionGroups.sort((a, b) => a.generation - b.generation || a.key.localeCompare(b.key));
(a, b) => a.generation - b.generation || a.key.localeCompare(b.key),
);
// ---- Species: the data-rich part ----------------------------- // ---- Species: the data-rich part -----------------------------
const speciesIndex = await api('pokemon-species?limit=100000'); const speciesIndex = await api('pokemon-species?limit=100000');
const ids = speciesIndex.results const ids = speciesIndex.results.map((r) => idFromUrl(r.url)).sort((a, b) => a - b);
.map((r) => idFromUrl(r.url))
.sort((a, b) => a - b);
const statMap = (pk) => { const statMap = (pk) => {
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat])); const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
@ -192,17 +186,21 @@ async function main() {
}; };
}; };
const bstOf = (st) => st.hp + st.atk + st.def + st.spa + st.spd + st.spe; const bstOf = (st) => st.hp + st.atk + st.def + st.spa + st.spd + st.spe;
const abilKey = (pk) => pk.abilities.map((a) => a.ability.name).sort().join(','); const abilKey = (pk) =>
pk.abilities
.map((a) => a.ability.name)
.sort()
.join(',');
const statKey = (st) => `${st.hp}/${st.atk}/${st.def}/${st.spa}/${st.spd}/${st.spe}`; const statKey = (st) => `${st.hp}/${st.atk}/${st.def}/${st.spa}/${st.spd}/${st.spe}`;
console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`); console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`);
const species = await mapLimit(ids, CONCURRENCY, async (id) => { const species = await mapLimit(ids, CONCURRENCY, async (id) => {
const [pk, sp] = await Promise.all([ const [pk, sp] = await Promise.all([api(`pokemon/${id}`), api(`pokemon-species/${id}`)]);
api(`pokemon/${id}`),
api(`pokemon-species/${id}`),
]);
const stats = statMap(pk); const stats = statMap(pk);
const baseTypes = pk.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name); const baseTypes = pk.types
.slice()
.sort((a, b) => a.slot - b.slot)
.map((t) => t.type.name);
const baseAbil = abilKey(pk); const baseAbil = abilKey(pk);
const baseStatKey = statKey(stats); const baseStatKey = statKey(stats);
@ -226,7 +224,10 @@ async function main() {
continue; continue;
} }
const fstats = statMap(fp); const fstats = statMap(fp);
const ftypes = fp.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name); const ftypes = fp.types
.slice()
.sort((a, b) => a.slot - b.slot)
.map((t) => t.type.name);
const cat = formCategory(slug, sp.name); const cat = formCategory(slug, sp.name);
const entry = { const entry = {
slug, slug,
@ -296,7 +297,10 @@ async function main() {
.map((t) => t.type.name), .map((t) => t.type.name),
pastTypes: (pk.past_types || []).map((p) => ({ pastTypes: (pk.past_types || []).map((p) => ({
gen: idFromUrl(p.generation.url), gen: idFromUrl(p.generation.url),
types: p.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name), types: p.types
.slice()
.sort((a, b) => a.slot - b.slot)
.map((t) => t.type.name),
})), })),
stats, stats,
bst: stats.hp + stats.atk + stats.def + stats.spa + stats.spd + stats.spe, bst: stats.hp + stats.atk + stats.def + stats.spa + stats.spd + stats.spe,
@ -326,7 +330,9 @@ async function main() {
// exclusive there. Wild-only — gift/trade/evolution exclusives are missed. // exclusive there. Wild-only — gift/trade/evolution exclusives are missed.
const versionToVg = {}; const versionToVg = {};
for (const vg of versionGroups) for (const v of vg.versions) versionToVg[v] = vg.key; for (const vg of versionGroups) for (const v of vg.versions) versionToVg[v] = vg.key;
const vgVersionCount = Object.fromEntries(versionGroups.map((vg) => [vg.key, vg.versions.length])); const vgVersionCount = Object.fromEntries(
versionGroups.map((vg) => [vg.key, vg.versions.length]),
);
console.log(`Fetching wild encounters for ${species.length} species …`); console.log(`Fetching wild encounters for ${species.length} species …`);
await mapLimit(species, CONCURRENCY, async (sp) => { await mapLimit(species, CONCURRENCY, async (sp) => {
let enc; let enc;
@ -410,10 +416,7 @@ async function main() {
name: a.name, name: a.name,
generation: idFromUrl(a.generation.url), generation: idFromUrl(a.generation.url),
isMainSeries: a.is_main_series, isMainSeries: a.is_main_series,
effect: effect: (en && (en.short_effect || en.effect)) || (flavour && flavour.flavor_text) || '',
(en && (en.short_effect || en.effect)) ||
(flavour && flavour.flavor_text) ||
'',
pokemon: [ pokemon: [
...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)), ...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)),
], ],

View File

@ -13,7 +13,11 @@ import { prettify } from '../data/pokedex-resolver.js';
* gradient, a type-colored spotlight behind an oversized sprite that lifts * gradient, a type-colored spotlight behind an oversized sprite that lifts
* above the card, a number chip, and a big ghost number in the corner. * above the card, a number chip, and a big ghost number in the corner.
*/ */
export function Card(species, number, { spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {}) { export function Card(
species,
number,
{ spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {},
) {
const state = entry(species.id); const state = entry(species.id);
const types = typesForGen(species, gen); const types = typesForGen(species, gen);
const mainType = types[0] || 'normal'; const mainType = types[0] || 'normal';
@ -61,7 +65,8 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
onclick: () => { onclick: () => {
// Tag this sprite so the router's view transition morphs it into // Tag this sprite so the router's view transition morphs it into
// the detail hero. // the detail hero.
for (const n of document.querySelectorAll('.card__art.is-morph')) n.classList.remove('is-morph'); for (const n of document.querySelectorAll('.card__art.is-morph'))
n.classList.remove('is-morph');
art.classList.add('is-morph'); art.classList.add('is-morph');
}, },
}, },
@ -85,7 +90,11 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
(() => { (() => {
const ex = versionGroup && versionGroup !== 'all' && species.exclusiveIn?.[versionGroup]; const ex = versionGroup && versionGroup !== 'all' && species.exclusiveIn?.[versionGroup];
return ex return ex
? el('span', { class: 'card__excl', title: 'Version exclusive (wild)' }, `${ex.map(prettify).join(' & ')} only`) ? el(
'span',
{ class: 'card__excl', title: 'Version exclusive (wild)' },
`${ex.map(prettify).join(' & ')} only`,
)
: null; : null;
})(), })(),
metric ? el('span', { class: 'card__metric' }, metric) : null, metric ? el('span', { class: 'card__metric' }, metric) : null,

View File

@ -34,7 +34,10 @@ function nodeEl(stage, style, versionGroup, depth) {
* surviving ancestor so Pikachu Raichu still shows in Red/Blue even * surviving ancestor so Pikachu Raichu still shows in Red/Blue even
* though Pichu doesn't, and Bellossom never appears there. * though Pichu doesn't, and Bellossom never appears there.
*/ */
export function EvolutionChain(chain, { style = 'default', versionGroup, genOf = () => 1, maxGen = 9 } = {}) { export function EvolutionChain(
chain,
{ style = 'default', versionGroup, genOf = () => 1, maxGen = 9 } = {},
) {
const nodes = new Map(); const nodes = new Map();
let pruned = false; let pruned = false;
@ -95,9 +98,7 @@ export function EvolutionChain(chain, { style = 'default', versionGroup, genOf =
roots.forEach((r) => renderNode(r, 0)); roots.forEach((r) => renderNode(r, 0));
if (pruned) { if (pruned) {
wrap.append( wrap.append(el('p', { class: 'evo__note' }, 'Cross-generation stages hidden for this game.'));
el('p', { class: 'evo__note' }, 'Cross-generation stages hidden for this game.'),
);
} }
return wrap; return wrap;
} }

View File

@ -96,9 +96,7 @@ export async function openGameSheet() {
); );
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) { for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
list.append( list.append(el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`));
el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`),
);
const grid = el('div', { class: 'gsheet__grid' }); const grid = el('div', { class: 'gsheet__grid' });
for (const vg of versionGroups) { for (const vg of versionGroups) {
const active = vg.key === st.versionGroup; const active = vg.key === st.versionGroup;
@ -126,7 +124,11 @@ export async function openGameSheet() {
'span', 'span',
{ class: 'gamecard__foot' }, { class: 'gamecard__foot' },
el('span', { class: 'gamecard__meta' }, vg.versions.map(prettify).join(' / ')), el('span', { class: 'gamecard__meta' }, vg.versions.map(prettify).join(' / ')),
el('span', { class: 'gamecard__dex' }, `${dexCount} ${dexCount === 1 ? 'dex' : 'dexes'}`), el(
'span',
{ class: 'gamecard__dex' },
`${dexCount} ${dexCount === 1 ? 'dex' : 'dexes'}`,
),
), ),
), ),
); );

View File

@ -6,8 +6,7 @@ import { TypeChip } from './TypeChip.js';
export async function machineNumber(moveData, versionGroupKey) { export async function machineNumber(moveData, versionGroupKey) {
const list = moveData.machines || []; const list = moveData.machines || [];
if (!list.length) return null; if (!list.length) return null;
const hit = const hit = list.find((x) => x.version_group.name === versionGroupKey) || list[list.length - 1];
list.find((x) => x.version_group.name === versionGroupKey) || list[list.length - 1];
try { try {
const mc = await getMachine(Number(hit.machine.url.replace(/\/$/, '').split('/').pop())); const mc = await getMachine(Number(hit.machine.url.replace(/\/$/, '').split('/').pop()));
const m = mc.item.name.match(/^([a-z]+)0*(\d+)$/i); const m = mc.item.name.match(/^([a-z]+)0*(\d+)$/i);
@ -53,7 +52,15 @@ function englishText(entries, key) {
// Before Gen 4, a move's physical/special class was decided by its TYPE, // Before Gen 4, a move's physical/special class was decided by its TYPE,
// not set per move. // not set per move.
const PHYSICAL_TYPES = new Set([ const PHYSICAL_TYPES = new Set([
'normal', 'fighting', 'poison', 'ground', 'flying', 'bug', 'rock', 'ghost', 'steel', 'normal',
'fighting',
'poison',
'ground',
'flying',
'bug',
'rock',
'ghost',
'steel',
]); ]);
export function forGeneration(d, gen, genOfVg) { export function forGeneration(d, gen, genOfVg) {
@ -145,7 +152,11 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
const enrichers = []; const enrichers = [];
for (const m of moves) { for (const m of moves) {
const meta = el('span', { class: 'moverow__meta' }, el('span', { class: 'moverow__pending' }, '…')); const meta = el(
'span',
{ class: 'moverow__meta' },
el('span', { class: 'moverow__pending' }, '…'),
);
const body = el('div', { class: 'movebody', hidden: true }); const body = el('div', { class: 'movebody', hidden: true });
const tmCell = method === 'machine' ? el('span', { class: 'moverow__tm' }) : null; const tmCell = method === 'machine' ? el('span', { class: 'moverow__tm' }) : null;
let bodyFilled = false; let bodyFilled = false;
@ -179,7 +190,9 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
fillBody(body, m.data, v); fillBody(body, m.data, v);
await fillTm(); await fillTm();
} catch { } catch {
body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Details unavailable.')); body.replaceChildren(
el('span', { class: 'moverow__pending' }, 'Details unavailable.'),
);
} }
} }
}, },
@ -268,9 +281,12 @@ function fillBody(body, d, v) {
if (d.priority) bits.push(`Priority ${d.priority > 0 ? '+' : ''}${d.priority}`); if (d.priority) bits.push(`Priority ${d.priority > 0 ? '+' : ''}${d.priority}`);
if (d.target?.name) bits.push(`Target: ${d.target.name.replace(/-/g, ' ')}`); if (d.target?.name) bits.push(`Target: ${d.target.name.replace(/-/g, ' ')}`);
if (d.meta?.ailment?.name && d.meta.ailment.name !== 'none') { if (d.meta?.ailment?.name && d.meta.ailment.name !== 'none') {
bits.push(`May ${d.meta.ailment.name.replace(/-/g, ' ')}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`); bits.push(
`May ${d.meta.ailment.name.replace(/-/g, ' ')}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`,
);
} }
if (d.meta?.drain) bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`); if (d.meta?.drain)
bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`);
if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`); if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`);
if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`); if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`);
if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`); if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`);

View File

@ -4,7 +4,13 @@ import { settings } from '../store/settings.js';
const ITEMS = [ const ITEMS = [
{ label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' }, { label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' },
{ label: 'Team', icon: '⛨', href: '#/team', match: (h) => h.startsWith('#/team'), optional: 'showTeamNav' }, {
label: 'Team',
icon: '⛨',
href: '#/team',
match: (h) => h.startsWith('#/team'),
optional: 'showTeamNav',
},
{ label: 'Games', icon: '◉', action: openGameSheet }, { label: 'Games', icon: '◉', action: openGameSheet },
{ label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') }, { label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') },
{ label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') }, { label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') },
@ -28,7 +34,11 @@ export function Nav() {
el('span', { class: 'nav__label' }, item.label), el('span', { class: 'nav__label' }, item.label),
]; ];
return item.action return item.action
? el('button', { class: 'nav__link', type: 'button', onclick: () => item.action() }, ...inner) ? el(
'button',
{ class: 'nav__link', type: 'button', onclick: () => item.action() },
...inner,
)
: el('a', { class: 'nav__link', href: item.href }, ...inner); : el('a', { class: 'nav__link', href: item.href }, ...inner);
}); });
nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pocketdex'), ...links); nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pocketdex'), ...links);

View File

@ -88,7 +88,11 @@ export async function openPokemonPicker(onPick, { closeAfterPick = false } = {})
'div', 'div',
{ class: 'gsheet__head' }, { class: 'gsheet__head' },
el('h2', {}, 'Add a Pokémon'), el('h2', {}, 'Add a Pokémon'),
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'), el(
'button',
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
'✕',
),
); );
dragToClose(panel, head, close); dragToClose(panel, head, close);
panel.append(head, input, results); panel.append(head, input, results);

View File

@ -40,7 +40,12 @@ export function ProgressRing() {
const value = el('span', { class: 'ring__value' }, num, tail); const value = el('span', { class: 'ring__value' }, num, tail);
const node = el( const node = el(
'a', 'a',
{ class: 'ring', href: '#/progress', title: 'Progress by game', 'aria-label': 'Progress by game' }, {
class: 'ring',
href: '#/progress',
title: 'Progress by game',
'aria-label': 'Progress by game',
},
svg, svg,
el('div', { class: 'ring__label' }, value), el('div', { class: 'ring__label' }, value),
); );

View File

@ -1,8 +1,7 @@
import { el } from '../lib/dom.js'; import { el } from '../lib/dom.js';
import { gameSpriteUrl } from '../data/game-sprites.js'; import { gameSpriteUrl } from '../data/game-sprites.js';
const SPRITES = const SPRITES = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';
function pixel(id, shiny) { function pixel(id, shiny) {
return `${SPRITES}${shiny ? '/shiny' : ''}/${id}.png`; return `${SPRITES}${shiny ? '/shiny' : ''}/${id}.png`;

View File

@ -2,8 +2,7 @@ import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js'; import { Sprite } from './Sprite.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.js';
const bar = (w, h = 14) => const bar = (w, h = 14) => el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` });
el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` });
const facts = () => const facts = () =>
el( el(
@ -43,11 +42,7 @@ export function detailSkeleton(match) {
el( el(
'div', 'div',
{ class: 'psheet' }, { class: 'psheet' },
el( el('div', { class: 'psheet__tabs' }, ...Array.from({ length: 5 }, () => bar('58px', 18))),
'div',
{ class: 'psheet__tabs' },
...Array.from({ length: 5 }, () => bar('58px', 18)),
),
el('div', { class: 'ppanel' }, bar('92%'), bar('84%'), bar('66%'), facts()), el('div', { class: 'ppanel' }, bar('92%'), bar('84%'), bar('66%'), facts()),
), ),
); );

View File

@ -4,8 +4,7 @@
* group) to the closest sprite folder. Games newer than Gen 7 shipped no * group) to the closest sprite folder. Games newer than Gen 7 shipped no
* pixel sprites those return null and the caller falls back to HOME art. * pixel sprites those return null and the caller falls back to HOME art.
*/ */
const REPO = const REPO = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions';
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions';
const PATH_BY_VERSION_GROUP = { const PATH_BY_VERSION_GROUP = {
'red-blue': 'generation-i/red-blue', 'red-blue': 'generation-i/red-blue',
@ -26,8 +25,7 @@ const PATH_BY_VERSION_GROUP = {
'omega-ruby-alpha-sapphire': 'generation-vi/omegaruby-alphasapphire', 'omega-ruby-alpha-sapphire': 'generation-vi/omegaruby-alphasapphire',
'sun-moon': 'generation-vii/ultra-sun-ultra-moon', 'sun-moon': 'generation-vii/ultra-sun-ultra-moon',
'ultra-sun-ultra-moon': 'generation-vii/ultra-sun-ultra-moon', 'ultra-sun-ultra-moon': 'generation-vii/ultra-sun-ultra-moon',
'brilliant-diamond-and-shining-pearl': 'brilliant-diamond-and-shining-pearl': 'generation-viii/brilliant-diamond-shining-pearl',
'generation-viii/brilliant-diamond-shining-pearl',
}; };
/** URL for a game-era pixel sprite, or null if that game has none. */ /** URL for a game-era pixel sprite, or null if that game has none. */

View File

@ -4,18 +4,14 @@
*/ */
export function prettify(key) { export function prettify(key) {
return key return key.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
.replace(/-/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
} }
/** Regional dexes belonging to a version group, in the API's order. */ /** Regional dexes belonging to a version group, in the API's order. */
export function dexesForVersionGroup(snap, versionGroupKey) { export function dexesForVersionGroup(snap, versionGroupKey) {
const vg = snap.versionGroupByKey.get(versionGroupKey); const vg = snap.versionGroupByKey.get(versionGroupKey);
const keys = vg && vg.pokedexKeys.length ? vg.pokedexKeys : ['national']; const keys = vg && vg.pokedexKeys.length ? vg.pokedexKeys : ['national'];
return keys return keys.map((k) => snap.pokedexByKey.get(k)).filter(Boolean);
.map((k) => snap.pokedexByKey.get(k))
.filter(Boolean);
} }
/** /**

View File

@ -5,9 +5,24 @@
* CHART[attacking][defending] holds only the non-1x multipliers. * CHART[attacking][defending] holds only the non-1x multipliers.
*/ */
export const TYPES = [ export const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', 'normal',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'fire',
'steel', 'fairy', 'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
]; ];
const CHART = { const CHART = {
@ -15,14 +30,48 @@ const CHART = {
fire: { fire: 0.5, water: 0.5, grass: 2, ice: 2, bug: 2, rock: 0.5, dragon: 0.5, steel: 2 }, fire: { fire: 0.5, water: 0.5, grass: 2, ice: 2, bug: 2, rock: 0.5, dragon: 0.5, steel: 2 },
water: { fire: 2, water: 0.5, grass: 0.5, ground: 2, rock: 2, dragon: 0.5 }, water: { fire: 2, water: 0.5, grass: 0.5, ground: 2, rock: 2, dragon: 0.5 },
electric: { water: 2, electric: 0.5, grass: 0.5, ground: 0, flying: 2, dragon: 0.5 }, electric: { water: 2, electric: 0.5, grass: 0.5, ground: 0, flying: 2, dragon: 0.5 },
grass: { fire: 0.5, water: 2, grass: 0.5, poison: 0.5, ground: 2, flying: 0.5, bug: 0.5, rock: 2, dragon: 0.5, steel: 0.5 }, grass: {
fire: 0.5,
water: 2,
grass: 0.5,
poison: 0.5,
ground: 2,
flying: 0.5,
bug: 0.5,
rock: 2,
dragon: 0.5,
steel: 0.5,
},
ice: { fire: 0.5, water: 0.5, grass: 2, ice: 0.5, ground: 2, flying: 2, dragon: 2, steel: 0.5 }, ice: { fire: 0.5, water: 0.5, grass: 2, ice: 0.5, ground: 2, flying: 2, dragon: 2, steel: 0.5 },
fighting: { normal: 2, ice: 2, poison: 0.5, flying: 0.5, psychic: 0.5, bug: 0.5, rock: 2, ghost: 0, dark: 2, steel: 2, fairy: 0.5 }, fighting: {
normal: 2,
ice: 2,
poison: 0.5,
flying: 0.5,
psychic: 0.5,
bug: 0.5,
rock: 2,
ghost: 0,
dark: 2,
steel: 2,
fairy: 0.5,
},
poison: { grass: 2, poison: 0.5, ground: 0.5, rock: 0.5, ghost: 0.5, steel: 0, fairy: 2 }, poison: { grass: 2, poison: 0.5, ground: 0.5, rock: 0.5, ghost: 0.5, steel: 0, fairy: 2 },
ground: { fire: 2, electric: 2, grass: 0.5, poison: 2, flying: 0, bug: 0.5, rock: 2, steel: 2 }, ground: { fire: 2, electric: 2, grass: 0.5, poison: 2, flying: 0, bug: 0.5, rock: 2, steel: 2 },
flying: { electric: 0.5, grass: 2, fighting: 2, bug: 2, rock: 0.5, steel: 0.5 }, flying: { electric: 0.5, grass: 2, fighting: 2, bug: 2, rock: 0.5, steel: 0.5 },
psychic: { fighting: 2, poison: 2, psychic: 0.5, dark: 0, steel: 0.5 }, psychic: { fighting: 2, poison: 2, psychic: 0.5, dark: 0, steel: 0.5 },
bug: { fire: 0.5, grass: 2, fighting: 0.5, poison: 0.5, flying: 0.5, psychic: 2, ghost: 0.5, dark: 2, steel: 0.5, fairy: 0.5 }, bug: {
fire: 0.5,
grass: 2,
fighting: 0.5,
poison: 0.5,
flying: 0.5,
psychic: 2,
ghost: 0.5,
dark: 2,
steel: 0.5,
fairy: 0.5,
},
rock: { fire: 2, ice: 2, fighting: 0.5, ground: 0.5, flying: 2, bug: 2, steel: 0.5 }, rock: { fire: 2, ice: 2, fighting: 0.5, ground: 0.5, flying: 2, bug: 2, steel: 0.5 },
ghost: { normal: 0, psychic: 2, ghost: 2, dark: 0.5 }, ghost: { normal: 0, psychic: 2, ghost: 2, dark: 0.5 },
dragon: { dragon: 2, steel: 0.5, fairy: 0 }, dragon: { dragon: 2, steel: 0.5, fairy: 0 },
@ -94,7 +143,7 @@ export function offensiveSummary(atkTypes, gen = 9) {
* Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] } * Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] }
*/ */
export function defensiveMatchups(defTypes, gen = 9) { export function defensiveMatchups(defTypes, gen = 9) {
const buckets = { '4': [], '2': [], '0.5': [], '0.25': [], '0': [] }; const buckets = { 4: [], 2: [], 0.5: [], 0.25: [], 0: [] };
// Types that didn't exist yet can't be attacking: Steel/Dark before Gen 2, // Types that didn't exist yet can't be attacking: Steel/Dark before Gen 2,
// Fairy before Gen 6. // Fairy before Gen 6.
const absent = new Set(gen < 2 ? ['steel', 'dark', 'fairy'] : gen < 6 ? ['fairy'] : []); const absent = new Set(gen < 2 ? ['steel', 'dark', 'fairy'] : gen < 6 ? ['fairy'] : []);

View File

@ -6,7 +6,11 @@ const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
* Tween a number into `node`'s text. Snaps straight to the value when the * Tween a number into `node`'s text. Snaps straight to the value when the
* user prefers reduced motion. Returns a cancel fn. * user prefers reduced motion. Returns a cancel fn.
*/ */
export function countUp(node, to, { from = 0, duration = 550, decimals = 0, prefix = '', suffix = '', ease = easeOutCubic } = {}) { export function countUp(
node,
to,
{ from = 0, duration = 550, decimals = 0, prefix = '', suffix = '', ease = easeOutCubic } = {},
) {
const fmt = (v) => `${prefix}${decimals ? v.toFixed(decimals) : Math.round(v)}${suffix}`; const fmt = (v) => `${prefix}${decimals ? v.toFixed(decimals) : Math.round(v)}${suffix}`;
if (prefersReducedMotion() || duration <= 0 || from === to) { if (prefersReducedMotion() || duration <= 0 || from === to) {
node.textContent = fmt(to); node.textContent = fmt(to);

View File

@ -32,7 +32,17 @@ export function statAt(base, level, { iv = 31, ev = 0, nature = 'Hardy', statKey
* *
* Returns null for status moves or 0-power moves. * Returns null for status moves or 0-power moves.
*/ */
export function calcDamage({ level, power, moveType, atkTypes, defTypes, atkStat, defStat, gen, crit = false }) { export function calcDamage({
level,
power,
moveType,
atkTypes,
defTypes,
atkStat,
defStat,
gen,
crit = false,
}) {
if (!power) return null; if (!power) return null;
const base = Math.floor((Math.floor((2 * level) / 5 + 2) * power * (atkStat / defStat)) / 50) + 2; const base = Math.floor((Math.floor((2 * level) / 5 + 2) * power * (atkStat / defStat)) / 50) + 2;
const stab = atkTypes.includes(moveType) ? 1.5 : 1; const stab = atkTypes.includes(moveType) ? 1.5 : 1;

View File

@ -32,7 +32,16 @@ const GB_CHARS = (() => {
t[0xa0 + i] = lo[i]; t[0xa0 + i] = lo[i];
} }
for (let i = 0; i < 10; i++) t[0xf6 + i] = String(i); for (let i = 0; i < 10; i++) t[0xf6 + i] = String(i);
Object.assign(t, { 0xe8: '.', 0xe3: '-', 0xf3: '/', 0xf4: ',', 0x9a: '(', 0x9b: ')', 0xe6: '?', 0xe7: '!' }); Object.assign(t, {
0xe8: '.',
0xe3: '-',
0xf3: '/',
0xf4: ',',
0x9a: '(',
0x9b: ')',
0xe6: '?',
0xe7: '!',
});
return t; return t;
})(); })();
@ -47,7 +56,11 @@ const GBA_CHARS = (() => {
t[0xd5 + i] = lo[i]; t[0xd5 + i] = lo[i];
} }
Object.assign(t, { Object.assign(t, {
0xad: '.', 0xae: '-', 0xba: '/', 0xac: ',', 0xb8: '&', 0xad: '.',
0xae: '-',
0xba: '/',
0xac: ',',
0xb8: '&',
}); });
return t; return t;
})(); })();
@ -76,13 +89,21 @@ const sumBytes = (bytes, from, to) => {
* Generation I Red / Blue / Yellow (International, 32 KB SRAM) * Generation I Red / Blue / Yellow (International, 32 KB SRAM)
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
const G1 = { name: 0x2598, owned: 0x25a3, seen: 0x25b6, species: 151, ckByte: 0x3523, ckFrom: 0x2598, ckTo: 0x3522 }; const G1 = {
name: 0x2598,
owned: 0x25a3,
seen: 0x25b6,
species: 151,
ckByte: 0x3523,
ckFrom: 0x2598,
ckTo: 0x3522,
};
function tryGen1(bytes) { function tryGen1(bytes) {
if (bytes.length < 0x8000) return null; if (bytes.length < 0x8000) return null;
const caught = bitsToList(bytes, G1.owned, G1.species); const caught = bitsToList(bytes, G1.owned, G1.species);
const seen = bitsToList(bytes, G1.seen, G1.species); const seen = bitsToList(bytes, G1.seen, G1.species);
const checksumOk = ((~sumBytes(bytes, G1.ckFrom, G1.ckTo)) & 0xff) === bytes[G1.ckByte]; const checksumOk = (~sumBytes(bytes, G1.ckFrom, G1.ckTo) & 0xff) === bytes[G1.ckByte];
if (!seen.length && !caught.length) return null; if (!seen.length && !caught.length) return null;
return { return {
gen: 1, gen: 1,
@ -101,8 +122,26 @@ function tryGen1(bytes) {
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
const G2 = { const G2 = {
gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver', vgs: ['gold-silver'] }, gs: {
cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal', vgs: ['crystal'] }, name: 0x200b,
owned: 0x2a4c,
seen: 0x2a6c,
ck: 0x2d69,
ckFrom: 0x2009,
ckTo: 0x2d68,
label: 'Gold / Silver',
vgs: ['gold-silver'],
},
cr: {
name: 0x2009,
owned: 0x2a27,
seen: 0x2a47,
ck: 0x2d0d,
ckFrom: 0x2009,
ckTo: 0x2b82,
label: 'Crystal',
vgs: ['crystal'],
},
}; };
const G2_SPECIES = 251; const G2_SPECIES = 251;
@ -145,7 +184,9 @@ const SECTION_SIZE = 4096;
const SECTIONS_PER_SLOT = 14; const SECTIONS_PER_SLOT = 14;
const SLOT_SIZE = SECTION_SIZE * SECTIONS_PER_SLOT; // 57344 const SLOT_SIZE = SECTION_SIZE * SECTIONS_PER_SLOT; // 57344
const SIGNATURE = 0x08012025; const SIGNATURE = 0x08012025;
const SECTION_DATA_SIZE = [3884, 3968, 3968, 3968, 3848, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 2000]; const SECTION_DATA_SIZE = [
3884, 3968, 3968, 3968, 3848, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 3968, 2000,
];
const DEX_OFFSET = 0x18; const DEX_OFFSET = 0x18;
const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national const DEX_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national
const G3_OWNED = DEX_OFFSET + 16; const G3_OWNED = DEX_OFFSET + 16;
@ -179,7 +220,10 @@ function tryGen3(bytes) {
if (bytes.length < SLOT_SIZE + SECTION_SIZE) return null; if (bytes.length < SLOT_SIZE + SECTION_SIZE) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const a = readSlot(view, 0); const a = readSlot(view, 0);
const b = bytes.length >= 2 * SLOT_SIZE ? readSlot(view, SLOT_SIZE) : { sections: {}, counter: -1, valid: 0 }; const b =
bytes.length >= 2 * SLOT_SIZE
? readSlot(view, SLOT_SIZE)
: { sections: {}, counter: -1, valid: 0 };
if (a.valid < 3 && b.valid < 3) return null; if (a.valid < 3 && b.valid < 3) return null;
const slot = b.counter > a.counter ? b : a; const slot = b.counter > a.counter ? b : a;
const s0 = slot.sections[0]; const s0 = slot.sections[0];
@ -222,8 +266,15 @@ function ndsNormalise(bytes) {
let hasMarker = false; let hasMarker = false;
for (let i = 0; i <= tail.length - marker.length; i++) { for (let i = 0; i <= tail.length - marker.length; i++) {
let ok = true; let ok = true;
for (let j = 0; j < marker.length; j++) if (tail[i + j] !== marker.charCodeAt(j)) { ok = false; break; } for (let j = 0; j < marker.length; j++)
if (ok) { hasMarker = true; break; } if (tail[i + j] !== marker.charCodeAt(j)) {
ok = false;
break;
}
if (ok) {
hasMarker = true;
break;
}
} }
if (hasMarker || bytes.length >= 0x80000) return bytes.subarray(0, 0x80000); if (hasMarker || bytes.length >= 0x80000) return bytes.subarray(0, 0x80000);
} }
@ -298,7 +349,12 @@ function readGen5Slot(bytes, slotBase, v) {
const caught = bitsToList(bytes, p + GEN5.caught, GEN5.species); const caught = bitsToList(bytes, p + GEN5.caught, GEN5.species);
const seen = orSeen(bytes, p, GEN5.seen, GEN5.size, GEN5.species); const seen = orSeen(bytes, p, GEN5.seen, GEN5.size, GEN5.species);
if (!seen.length || caught.some((n) => !seen.includes(n))) return null; if (!seen.length || caught.some((n) => !seen.includes(n))) return null;
return { seen, caught, label: v.label, vg: v.dex === GEN5.bw.dex ? 'black-white' : 'black-2-white-2' }; return {
seen,
caught,
label: v.label,
vg: v.dex === GEN5.bw.dex ? 'black-white' : 'black-2-white-2',
};
} }
function tryGen5(raw) { function tryGen5(raw) {

View File

@ -20,7 +20,11 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
// the page? (moves list, tab strip, coverage grid…) `dir` < 0 means the // the page? (moves list, tab strip, coverage grid…) `dir` < 0 means the
// finger is moving left — that scrolls such an element to the right. // finger is moving left — that scrolls such an element to the right.
const scrollsInside = (node, dir) => { const scrollsInside = (node, dir) => {
for (let n = node; n && n !== document.body && n !== target.parentElement; n = n.parentElement) { for (
let n = node;
n && n !== document.body && n !== target.parentElement;
n = n.parentElement
) {
if (n.scrollWidth - n.clientWidth > 2) { if (n.scrollWidth - n.clientWidth > 2) {
const ox = getComputedStyle(n).overflowX; const ox = getComputedStyle(n).overflowX;
if (ox === 'auto' || ox === 'scroll') { if (ox === 'auto' || ox === 'scroll') {

View File

@ -2,9 +2,24 @@
// custom properties once and memoising. Type colors do not change between // custom properties once and memoising. Type colors do not change between
// light and dark, so the cache is safe for the session. // light and dark, so the cache is safe for the session.
const TYPES = [ const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', 'normal',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'fire',
'steel', 'fairy', 'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
]; ];
let cache; let cache;

View File

@ -41,9 +41,7 @@ settings.subscribe((s) => {
if ((location.hash || '').startsWith('#/pokemon/')) rerender(); if ((location.hash || '').startsWith('#/pokemon/')) rerender();
} }
}); });
window window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => applyTheme());
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => applyTheme());
const app = document.getElementById('app'); const app = document.getElementById('app');
app.replaceChildren(); app.replaceChildren();

View File

@ -18,10 +18,18 @@ import { prefersReducedMotion } from './store/settings.js';
const routes = [ const routes = [
{ pattern: /^#?\/?$/, view: () => DexGrid() }, { pattern: /^#?\/?$/, view: () => DexGrid() },
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton }, {
pattern: /^#\/pokemon\/(\d+)$/,
view: (m) => PokemonDetail(Number(m[1])),
skeleton: detailSkeleton,
},
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton }, { pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton }, { pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton }, {
pattern: /^#\/ability\/(\d+)$/,
view: (m) => AbilityDetail(Number(m[1])),
skeleton: lookupSkeleton,
},
{ pattern: /^#\/team$/, view: () => TeamView() }, { pattern: /^#\/team$/, view: () => TeamView() },
{ pattern: /^#\/natures$/, view: () => NaturesView() }, { pattern: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/types$/, view: () => TypeChartView() }, { pattern: /^#\/types$/, view: () => TypeChartView() },

View File

@ -34,10 +34,7 @@ export function createStore(key, initial) {
return { return {
get: () => state, get: () => state,
set(patch) { set(patch) {
state = state = typeof patch === 'function' ? patch(state) : { ...state, ...patch };
typeof patch === 'function'
? patch(state)
: { ...state, ...patch };
save(); save();
for (const fn of subscribers) fn(state); for (const fn of subscribers) fn(state);
}, },

View File

@ -121,7 +121,8 @@ export function importFlags({ seen = [], caught = [], game } = {}) {
const stamp = new Date().toISOString(); const stamp = new Date().toISOString();
const bucket = { ...(s.games[vgKey] || {}) }; const bucket = { ...(s.games[vgKey] || {}) };
for (const id of seen) bucket[id] = { ...(bucket[id] || GBLANK), seen: true, updatedAt: stamp }; for (const id of seen) bucket[id] = { ...(bucket[id] || GBLANK), seen: true, updatedAt: stamp };
for (const id of caught) bucket[id] = { ...(bucket[id] || GBLANK), seen: true, caught: true, updatedAt: stamp }; for (const id of caught)
bucket[id] = { ...(bucket[id] || GBLANK), seen: true, caught: true, updatedAt: stamp };
return { ...s, games: { ...s.games, [vgKey]: bucket } }; return { ...s, games: { ...s.games, [vgKey]: bucket } };
}); });
} }
@ -140,10 +141,7 @@ export function replaceFlags({ seen = [], caught = [], game } = {}) {
/** Aggregate seen/caught over a species list for one game (union when 'all'). */ /** Aggregate seen/caught over a species list for one game (union when 'all'). */
export function stats(speciesIds, vgKey = curGame()) { export function stats(speciesIds, vgKey = curGame()) {
const get = const get = vgKey === 'all' ? unionEntry : (id) => selection.get().games[vgKey]?.[id] || GBLANK;
vgKey === 'all'
? unionEntry
: (id) => selection.get().games[vgKey]?.[id] || GBLANK;
let seen = 0; let seen = 0;
let caught = 0; let caught = 0;
for (const id of speciesIds) { for (const id of speciesIds) {

View File

@ -51,13 +51,10 @@ export function applyTheme(state = settings.get()) {
if (meta) { if (meta) {
const dark = const dark =
DARKISH.has(theme) || DARKISH.has(theme) ||
((!theme || theme === 'system') && ((!theme || theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches);
window.matchMedia('(prefers-color-scheme: dark)').matches);
meta.setAttribute( meta.setAttribute(
'content', 'content',
dark dark ? '#0b0b0c' : getComputedStyle(root).getPropertyValue('--accent').trim() || '#b3161a',
? '#0b0b0c'
: getComputedStyle(root).getPropertyValue('--accent').trim() || '#b3161a',
); );
} }
} }
@ -65,7 +62,6 @@ export function applyTheme(state = settings.get()) {
/** True if animations should be skipped — OS preference or the in-app override. */ /** True if animations should be skipped — OS preference or the in-app override. */
export function prefersReducedMotion() { export function prefersReducedMotion() {
return ( return (
!!settings.get().reduceMotion || !!settings.get().reduceMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches
window.matchMedia('(prefers-reduced-motion: reduce)').matches
); );
} }

View File

@ -14,7 +14,15 @@ export function addHunt({ speciesId, method, charm }) {
shinyHunts.set((s) => ({ shinyHunts.set((s) => ({
...s, ...s,
hunts: [ hunts: [
{ id: uid(), speciesId, method, charm: !!charm, count: 0, notes: '', startedAt: new Date().toISOString() }, {
id: uid(),
speciesId,
method,
charm: !!charm,
count: 0,
notes: '',
startedAt: new Date().toISOString(),
},
...s.hunts, ...s.hunts,
], ],
})); }));

View File

@ -41,5 +41,8 @@ export function clearTeam() {
} }
export function inTeam(id, form = null) { export function inTeam(id, form = null) {
return team.get().members.map(slot).some((m) => sameSlot(m, { id, form: form || null })); return team
.get()
.members.map(slot)
.some((m) => sameSlot(m, { id, form: form || null }));
} }

View File

@ -142,7 +142,9 @@
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
line-height: 1; line-height: 1;
transition: color 0.12s ease, background 0.12s ease; transition:
color 0.12s ease,
background 0.12s ease;
} }
.reflink:hover { .reflink:hover {
color: var(--text); color: var(--text);
@ -226,24 +228,64 @@
background: var(--type-normal); background: var(--type-normal);
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.type-chip[data-type="normal"] { background: var(--type-normal); } .type-chip[data-type='normal'] {
.type-chip[data-type="fire"] { background: var(--type-fire); } background: var(--type-normal);
.type-chip[data-type="water"] { background: var(--type-water); } }
.type-chip[data-type="electric"] { background: var(--type-electric); color: #3a3a1a; } .type-chip[data-type='fire'] {
.type-chip[data-type="grass"] { background: var(--type-grass); } background: var(--type-fire);
.type-chip[data-type="ice"] { background: var(--type-ice); color: #1c3b38; } }
.type-chip[data-type="fighting"] { background: var(--type-fighting); } .type-chip[data-type='water'] {
.type-chip[data-type="poison"] { background: var(--type-poison); } background: var(--type-water);
.type-chip[data-type="ground"] { background: var(--type-ground); } }
.type-chip[data-type="flying"] { background: var(--type-flying); } .type-chip[data-type='electric'] {
.type-chip[data-type="psychic"] { background: var(--type-psychic); } background: var(--type-electric);
.type-chip[data-type="bug"] { background: var(--type-bug); } color: #3a3a1a;
.type-chip[data-type="rock"] { background: var(--type-rock); color: #2e2a1c; } }
.type-chip[data-type="ghost"] { background: var(--type-ghost); } .type-chip[data-type='grass'] {
.type-chip[data-type="dragon"] { background: var(--type-dragon); } background: var(--type-grass);
.type-chip[data-type="dark"] { background: var(--type-dark); } }
.type-chip[data-type="steel"] { background: var(--type-steel); } .type-chip[data-type='ice'] {
.type-chip[data-type="fairy"] { background: var(--type-fairy); color: #3d1f3b; } background: var(--type-ice);
color: #1c3b38;
}
.type-chip[data-type='fighting'] {
background: var(--type-fighting);
}
.type-chip[data-type='poison'] {
background: var(--type-poison);
}
.type-chip[data-type='ground'] {
background: var(--type-ground);
}
.type-chip[data-type='flying'] {
background: var(--type-flying);
}
.type-chip[data-type='psychic'] {
background: var(--type-psychic);
}
.type-chip[data-type='bug'] {
background: var(--type-bug);
}
.type-chip[data-type='rock'] {
background: var(--type-rock);
color: #2e2a1c;
}
.type-chip[data-type='ghost'] {
background: var(--type-ghost);
}
.type-chip[data-type='dragon'] {
background: var(--type-dragon);
}
.type-chip[data-type='dark'] {
background: var(--type-dark);
}
.type-chip[data-type='steel'] {
background: var(--type-steel);
}
.type-chip[data-type='fairy'] {
background: var(--type-fairy);
color: #3d1f3b;
}
/* ---------- Dex feed header ------------------------------------- */ /* ---------- Dex feed header ------------------------------------- */
.feed-head { .feed-head {
@ -477,13 +519,21 @@
box-shadow: var(--shadow); box-shadow: var(--shadow);
content-visibility: auto; content-visibility: auto;
contain-intrinsic-size: auto 250px; contain-intrinsic-size: auto 250px;
transition: transform 0.14s var(--ease-spring), box-shadow 0.14s ease; transition:
transform 0.14s var(--ease-spring),
box-shadow 0.14s ease;
animation: cardIn 0.4s var(--ease-spring) backwards; animation: cardIn 0.4s var(--ease-spring) backwards;
animation-delay: calc(var(--i, 0) * 18ms); animation-delay: calc(var(--i, 0) * 18ms);
} }
@keyframes cardIn { @keyframes cardIn {
from { opacity: 0; transform: translateY(10px); } from {
to { opacity: 1; transform: none; } opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
} }
.card:hover { .card:hover {
transform: translateY(-4px); transform: translateY(-4px);
@ -532,11 +582,11 @@
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.28)); filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.28));
} }
.card[data-sprite="default"] .card__art .sprite, .card[data-sprite='default'] .card__art .sprite,
.card[data-sprite="game"] .card__art .sprite { .card[data-sprite='game'] .card__art .sprite {
image-rendering: pixelated; image-rendering: pixelated;
} }
.card[data-sprite="game"] .card__art .sprite { .card[data-sprite='game'] .card__art .sprite {
width: 92px; width: 92px;
height: 92px; height: 92px;
} }
@ -548,7 +598,9 @@
padding: 5px; padding: 5px;
border-radius: 12px; border-radius: 12px;
background: #f7f7f5; background: #f7f7f5;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.14); box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.08),
0 2px 6px rgba(0, 0, 0, 0.14);
filter: none; filter: none;
} }
.card__body { .card__body {
@ -606,15 +658,15 @@
background: rgba(255, 255, 255, 0.75); background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
} }
.card__caught[aria-pressed="true"] { .card__caught[aria-pressed='true'] {
background: var(--good); background: var(--good);
color: #fff; color: #fff;
} }
.card__caught[aria-pressed="true"]::before { .card__caught[aria-pressed='true']::before {
content: "✓"; content: '✓';
} }
.card__caught[aria-pressed="false"]::before { .card__caught[aria-pressed='false']::before {
content: ""; content: '';
} }
.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));
@ -677,7 +729,9 @@
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: transform 0.14s var(--ease-spring), border-color 0.14s ease; transition:
transform 0.14s var(--ease-spring),
border-color 0.14s ease;
} }
.gamecard:hover { .gamecard:hover {
transform: translateY(-3px); transform: translateY(-3px);
@ -710,7 +764,7 @@
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.gamecard--cover::after { .gamecard--cover::after {
content: ""; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.28), transparent 55%); background: linear-gradient(to top, rgba(0, 0, 0, 0.28), transparent 55%);
@ -724,7 +778,9 @@
} }
.gamecard--cover.is-active { .gamecard--cover.is-active {
background: linear-gradient(105deg, var(--g1) 0%, var(--g1) 42%, var(--g2) 58%, var(--g2) 100%); background: linear-gradient(105deg, var(--g1) 0%, var(--g1) 42%, var(--g2) 58%, var(--g2) 100%);
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); box-shadow:
0 0 0 2px var(--surface),
0 0 0 4px var(--accent);
} }
.gamecard--cover .gamecard__name { .gamecard--cover .gamecard__name {
font-size: 1rem; font-size: 1rem;
@ -774,10 +830,16 @@
inset: 0; inset: 0;
z-index: -2; z-index: -2;
background: background:
radial-gradient(120% 80% at 80% 0%, color-mix(in srgb, var(--type-2) 55%, transparent), transparent 60%), radial-gradient(
linear-gradient(160deg, 120% 80% at 80% 0%,
color-mix(in srgb, var(--type-2) 55%, transparent),
transparent 60%
),
linear-gradient(
160deg,
color-mix(in srgb, var(--type-main) 92%, #fff 8%) 0%, color-mix(in srgb, var(--type-main) 92%, #fff 8%) 0%,
color-mix(in srgb, var(--type-main) 62%, #000 38%) 100%); color-mix(in srgb, var(--type-main) 62%, #000 38%) 100%
);
} }
.phero__ball { .phero__ball {
position: absolute; position: absolute;
@ -791,7 +853,7 @@
border-radius: 50%; border-radius: 50%;
} }
.phero__ball::before { .phero__ball::before {
content: ""; content: '';
position: absolute; position: absolute;
left: -20px; left: -20px;
right: -20px; right: -20px;
@ -801,7 +863,7 @@
background: #fff; background: #fff;
} }
.phero__ball::after { .phero__ball::after {
content: ""; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
@ -912,15 +974,23 @@
padding: 14px; padding: 14px;
border-radius: 18px; border-radius: 18px;
background: #f7f7f5; background: #f7f7f5;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 0 0 1px rgba(0, 0, 0, 0.08); box-shadow:
0 10px 24px rgba(0, 0, 0, 0.22),
inset 0 0 0 1px rgba(0, 0, 0, 0.08);
} }
.phero__art--boxed img { .phero__art--boxed img {
image-rendering: pixelated; image-rendering: pixelated;
filter: none; filter: none;
} }
@keyframes pop { @keyframes pop {
from { transform: scale(0.8); opacity: 0; } from {
to { transform: scale(1); opacity: 1; } transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
} }
.phero__nav { .phero__nav {
width: 44px; width: 44px;
@ -1036,9 +1106,15 @@
animation: pnote-fade 1.6s ease forwards; animation: pnote-fade 1.6s ease forwards;
} }
@keyframes pnote-fade { @keyframes pnote-fade {
0% { opacity: 1; } 0% {
70% { opacity: 1; } opacity: 1;
100% { opacity: 0; } }
70% {
opacity: 1;
}
100% {
opacity: 0;
}
} }
.pnote__input { .pnote__input {
width: 100%; width: 100%;
@ -1092,8 +1168,14 @@
animation: fade 0.25s ease; animation: fade 0.25s ease;
} }
@keyframes fade { @keyframes fade {
from { opacity: 0; transform: translateY(4px); } from {
to { opacity: 1; transform: none; } opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
} }
.ppanel__flavor { .ppanel__flavor {
font-size: 1rem; font-size: 1rem;
@ -1131,8 +1213,14 @@
animation: flavorSlide 0.28s var(--ease-spring); animation: flavorSlide 0.28s var(--ease-spring);
} }
@keyframes flavorSlide { @keyframes flavorSlide {
from { opacity: 0; transform: translateX(14px); } from {
to { opacity: 1; transform: none; } opacity: 0;
transform: translateX(14px);
}
to {
opacity: 1;
transform: none;
}
} }
.flavor__cap { .flavor__cap {
display: block; display: block;
@ -1221,12 +1309,24 @@
border-radius: 999px; border-radius: 999px;
transition: width 0.7s var(--ease-spring); transition: width 0.7s var(--ease-spring);
} }
.statbar__fill[data-stat="hp"] { background: #ff6b6b; } .statbar__fill[data-stat='hp'] {
.statbar__fill[data-stat="attack"] { background: #ffa94d; } background: #ff6b6b;
.statbar__fill[data-stat="defense"] { background: #ffd43b; } }
.statbar__fill[data-stat="special-attack"] { background: #74c0fc; } .statbar__fill[data-stat='attack'] {
.statbar__fill[data-stat="special-defense"] { background: #63e6be; } background: #ffa94d;
.statbar__fill[data-stat="speed"] { background: #da77f2; } }
.statbar__fill[data-stat='defense'] {
background: #ffd43b;
}
.statbar__fill[data-stat='special-attack'] {
background: #74c0fc;
}
.statbar__fill[data-stat='special-defense'] {
background: #63e6be;
}
.statbar__fill[data-stat='speed'] {
background: #da77f2;
}
.statbar--total { .statbar--total {
font-weight: 700; font-weight: 700;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@ -1378,7 +1478,7 @@
color: var(--text-dim); color: var(--text-dim);
transition: transform 0.15s ease; transition: transform 0.15s ease;
} }
.moverow[aria-expanded="true"] .moverow__chev { .moverow[aria-expanded='true'] .moverow__chev {
transform: rotate(180deg); transform: rotate(180deg);
} }
.movebody { .movebody {
@ -1427,9 +1527,15 @@
letter-spacing: 0.03em; letter-spacing: 0.03em;
text-transform: uppercase; text-transform: uppercase;
} }
.moverow__cat[data-cat="physical"] { color: #e0762f; } .moverow__cat[data-cat='physical'] {
.moverow__cat[data-cat="special"] { color: #4b8fd6; } color: #e0762f;
.moverow__cat[data-cat="status"] { color: #8a8f98; } }
.moverow__cat[data-cat='special'] {
color: #4b8fd6;
}
.moverow__cat[data-cat='status'] {
color: #8a8f98;
}
.moverow__stat { .moverow__stat {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
@ -1530,7 +1636,9 @@
box-shadow: var(--shadow); box-shadow: var(--shadow);
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
transition: transform 0.12s var(--ease-spring), border-color 0.12s ease; transition:
transform 0.12s var(--ease-spring),
border-color 0.12s ease;
} }
.search__row:hover { .search__row:hover {
transform: translateX(3px); transform: translateX(3px);
@ -1704,7 +1812,9 @@
} }
.swatch.is-on { .swatch.is-on {
border-color: var(--text); border-color: var(--text);
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--sw); box-shadow:
0 0 0 2px var(--bg),
0 0 0 4px var(--sw);
} }
/* ---------- Toast ------------------------------------------- */ /* ---------- Toast ------------------------------------------- */
@ -1802,7 +1912,7 @@
cursor: grabbing; cursor: grabbing;
} }
.gsheet__head::before { .gsheet__head::before {
content: ""; content: '';
position: absolute; position: absolute;
top: 6px; top: 6px;
left: 50%; left: 50%;
@ -1868,8 +1978,14 @@
animation: viewIn 0.26s ease both; animation: viewIn 0.26s ease both;
} }
@keyframes viewIn { @keyframes viewIn {
from { opacity: 0; transform: translateY(6px); } from {
to { opacity: 1; transform: none; } opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
} }
::view-transition-old(root), ::view-transition-old(root),
@ -1894,8 +2010,12 @@
animation: shimmer 1.25s linear infinite; animation: shimmer 1.25s linear infinite;
} }
@keyframes shimmer { @keyframes shimmer {
from { background-position: 220% 0; } from {
to { background-position: -120% 0; } background-position: 220% 0;
}
to {
background-position: -120% 0;
}
} }
.sk-hero { .sk-hero {
background: var(--surface-2); background: var(--surface-2);
@ -1963,7 +2083,7 @@
animation: viewIn 0.25s ease both; animation: viewIn 0.25s ease both;
} }
.netpill::before { .netpill::before {
content: ""; content: '';
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
@ -2103,10 +2223,10 @@
transition: transform 0.15s ease; transition: transform 0.15s ease;
margin-left: auto; margin-left: auto;
} }
.feed-tools__toggle[aria-expanded="true"] .feed-tools__chev { .feed-tools__toggle[aria-expanded='true'] .feed-tools__chev {
transform: rotate(180deg); transform: rotate(180deg);
} }
.feed-tools__toggle[data-active]:not([data-active=""])::before { .feed-tools__toggle[data-active]:not([data-active=''])::before {
content: attr(data-active); content: attr(data-active);
min-width: 17px; min-width: 17px;
height: 17px; height: 17px;
@ -2314,11 +2434,24 @@
border-radius: 6px; border-radius: 6px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.cov__cell.m4 { background: color-mix(in srgb, var(--danger) 60%, transparent); color: #fff; } .cov__cell.m4 {
.cov__cell.m2 { background: color-mix(in srgb, var(--danger) 28%, transparent); } background: color-mix(in srgb, var(--danger) 60%, transparent);
.cov__cell.m05 { background: color-mix(in srgb, var(--good) 24%, transparent); } color: #fff;
.cov__cell.m025 { background: color-mix(in srgb, var(--good) 48%, transparent); color: #fff; } }
.cov__cell.m0 { background: var(--surface-2); color: var(--text-dim); } .cov__cell.m2 {
background: color-mix(in srgb, var(--danger) 28%, transparent);
}
.cov__cell.m05 {
background: color-mix(in srgb, var(--good) 24%, transparent);
}
.cov__cell.m025 {
background: color-mix(in srgb, var(--good) 48%, transparent);
color: #fff;
}
.cov__cell.m0 {
background: var(--surface-2);
color: var(--text-dim);
}
.cov__row--threat { .cov__row--threat {
background: color-mix(in srgb, var(--danger) 12%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent);
box-shadow: inset 3px 0 0 var(--danger); box-shadow: inset 3px 0 0 var(--danger);
@ -2965,7 +3098,9 @@
image-rendering: pixelated; image-rendering: pixelated;
filter: grayscale(1); filter: grayscale(1);
opacity: 0.6; opacity: 0.6;
transition: filter 0.15s, opacity 0.15s; transition:
filter 0.15s,
opacity 0.15s;
} }
.formdex__tile.is-caught .formdex__sprite { .formdex__tile.is-caught .formdex__sprite {
filter: none; filter: none;
@ -2996,7 +3131,9 @@
text-align: center; text-align: center;
opacity: 0; opacity: 0;
transform: scale(0.6); transform: scale(0.6);
transition: opacity 0.15s, transform 0.15s; transition:
opacity 0.15s,
transform 0.15s;
} }
.formdex__tile.is-caught .formdex__check { .formdex__tile.is-caught .formdex__check {
opacity: 1; opacity: 1;
@ -3231,8 +3368,12 @@
border-radius: 999px; border-radius: 999px;
vertical-align: middle; vertical-align: middle;
} }
.prog__key--caught { background: var(--good); } .prog__key--caught {
.prog__key--seen { background: color-mix(in srgb, var(--accent) 35%, transparent); } background: var(--good);
}
.prog__key--seen {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ---- Breeding helper --------------------------------------- */ /* ---- Breeding helper --------------------------------------- */
.breedview__body { .breedview__body {
@ -3403,15 +3544,44 @@
/* ================= Micro-interactions ============================ */ /* ================= Micro-interactions ============================ */
/* Tactile press feedback low specificity so any element's own /* Tactile press feedback low specificity so any element's own
:active / transition wins if it has one. */ :active / transition wins if it has one. */
:where(.button, .seg__btn, .feed-chip, .breed__chip, .formbar__pill, :where(
.flavor__pill, .lineup__add, .lineup__clear, .shiny__step, .prog__row, .button,
.tchart__row, .tchart__col, .gamecard, .picker__row, .weakspot__chip) { .seg__btn,
transition: transform 0.09s var(--ease-spring), background-color 0.16s ease, .feed-chip,
color 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; .breed__chip,
.formbar__pill,
.flavor__pill,
.lineup__add,
.lineup__clear,
.shiny__step,
.prog__row,
.tchart__row,
.tchart__col,
.gamecard,
.picker__row,
.weakspot__chip
) {
transition:
transform 0.09s var(--ease-spring),
background-color 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease,
border-color 0.16s ease;
} }
:where(.button, .seg__btn, .feed-chip, .breed__chip, .formbar__pill, :where(
.flavor__pill, .lineup__add, .shiny__step, .prog__row, .tchart__row, .button,
.tchart__col, .gamecard):active { .seg__btn,
.feed-chip,
.breed__chip,
.formbar__pill,
.flavor__pill,
.lineup__add,
.shiny__step,
.prog__row,
.tchart__row,
.tchart__col,
.gamecard
):active {
transform: scale(0.96); transform: scale(0.96);
} }
.card:active { .card:active {
@ -3429,8 +3599,13 @@
animation: heroFloat 4.2s ease-in-out infinite; animation: heroFloat 4.2s ease-in-out infinite;
} }
@keyframes heroFloat { @keyframes heroFloat {
0%, 100% { transform: translateY(0); } 0%,
50% { transform: translateY(-6px); } 100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
} }
/* Sliding thumb behind the active segment (JS sets --seg-i / --seg-n). */ /* Sliding thumb behind the active segment (JS sets --seg-i / --seg-n). */
@ -3446,7 +3621,9 @@
border-radius: 999px; border-radius: 999px;
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: transform 0.28s var(--ease-spring), width 0.28s var(--ease-spring); transition:
transform 0.28s var(--ease-spring),
width 0.28s var(--ease-spring);
pointer-events: none; pointer-events: none;
} }
.seg__btn { .seg__btn {
@ -3482,7 +3659,9 @@
padding: 20px; padding: 20px;
transform: translateY(12px) scale(0.97); transform: translateY(12px) scale(0.97);
opacity: 0; opacity: 0;
transition: transform 0.2s var(--ease-spring), opacity 0.2s ease; transition:
transform 0.2s var(--ease-spring),
opacity 0.2s ease;
} }
.dialog-backdrop.is-open .dialog { .dialog-backdrop.is-open .dialog {
transform: none; transform: none;

View File

@ -23,7 +23,7 @@
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.08); --shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 20px 50px -12px rgba(0, 0, 0, 0.28); --shadow-lg: 0 20px 50px -12px rgba(0, 0, 0, 0.28);
--font: "Poppins", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --font: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
/* Pokémon type colors */ /* Pokémon type colors */
@ -62,7 +62,7 @@
} }
} }
:root[data-theme="dark"] { :root[data-theme='dark'] {
--bg: #0b0b0c; --bg: #0b0b0c;
--surface: #17171a; --surface: #17171a;
--surface-2: #202024; --surface-2: #202024;
@ -75,7 +75,7 @@
} }
/* Pure black — for OLED screens. */ /* Pure black — for OLED screens. */
:root[data-theme="black"] { :root[data-theme='black'] {
--bg: #000000; --bg: #000000;
--surface: #0b0b0c; --surface: #0b0b0c;
--surface-2: #17171a; --surface-2: #17171a;
@ -89,7 +89,7 @@
} }
/* Sepia — a warm, low-glare light theme. */ /* Sepia — a warm, low-glare light theme. */
:root[data-theme="sepia"] { :root[data-theme='sepia'] {
--bg: #f2e8d5; --bg: #f2e8d5;
--surface: #fbf4e4; --surface: #fbf4e4;
--surface-2: #e9dbbe; --surface-2: #e9dbbe;
@ -101,11 +101,26 @@
} }
/* Accent overrides — layered on top of any theme. Red is the default. */ /* Accent overrides — layered on top of any theme. Red is the default. */
:root[data-accent="blue"] { --accent: #2f6fed; --accent-text: #ffffff; } :root[data-accent='blue'] {
:root[data-accent="green"] { --accent: #1f9d57; --accent-text: #ffffff; } --accent: #2f6fed;
:root[data-accent="amber"] { --accent: #d98a00; --accent-text: #1a1200; } --accent-text: #ffffff;
:root[data-accent="violet"] { --accent: #7b52e0; --accent-text: #ffffff; } }
:root[data-accent="rose"] { --accent: #e0417a; --accent-text: #ffffff; } :root[data-accent='green'] {
--accent: #1f9d57;
--accent-text: #ffffff;
}
:root[data-accent='amber'] {
--accent: #d98a00;
--accent-text: #1a1200;
}
:root[data-accent='violet'] {
--accent: #7b52e0;
--accent-text: #ffffff;
}
:root[data-accent='rose'] {
--accent: #e0417a;
--accent-text: #ffffff;
}
* { * {
box-sizing: border-box; box-sizing: border-box;

View File

@ -101,9 +101,7 @@ export async function AbilityDetail(id) {
) )
: null, : null,
mons.length > 24 ? filterInput : null, mons.length > 24 ? filterInput : null,
mons.length mons.length ? grid : el('p', { class: 'detail__muted' }, 'None on record.'),
? grid
: el('p', { class: 'detail__muted' }, 'None on record.'),
), ),
); );
return view; return view;

View File

@ -9,9 +9,15 @@ import { openPokemonPicker } from '../components/PokemonPicker.js';
import { segThumb } from '../lib/anim.js'; import { segThumb } from '../lib/anim.js';
const EGG_LABEL = { const EGG_LABEL = {
water1: 'Water 1', water2: 'Water 2', water3: 'Water 3', water1: 'Water 1',
ground: 'Field', humanshape: 'Human-Like', indeterminate: 'Amorphous', water2: 'Water 2',
plant: 'Grass', 'no-eggs': 'Undiscovered', ditto: 'Ditto', water3: 'Water 3',
ground: 'Field',
humanshape: 'Human-Like',
indeterminate: 'Amorphous',
plant: 'Grass',
'no-eggs': 'Undiscovered',
ditto: 'Ditto',
}; };
const eggLabel = (g) => EGG_LABEL[g] || prettify(g); const eggLabel = (g) => EGG_LABEL[g] || prettify(g);
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
@ -57,7 +63,14 @@ export async function BreedingView() {
), ),
), ),
); );
const setThumb = segThumb(seg, SEG.length, Math.max(0, SEG.findIndex(([id]) => id === mode))); const setThumb = segThumb(
seg,
SEG.length,
Math.max(
0,
SEG.findIndex(([id]) => id === mode),
),
);
const body = el('div', { class: 'breedview__body' }); const body = el('div', { class: 'breedview__body' });
@ -125,7 +138,12 @@ export async function BreedingView() {
), ),
}, },
target target
? el('span', {}, Sprite(target.id, { style, size: 40, alt: target.name }), ` ${prettify(target.name)}`) ? el(
'span',
{},
Sprite(target.id, { style, size: 40, alt: target.name }),
` ${prettify(target.name)}`,
)
: 'Choose a Pokémon…', : 'Choose a Pokémon…',
); );
body.append(slot); body.append(slot);
@ -133,7 +151,13 @@ export async function BreedingView() {
const tGroups = new Set(target.eggGroups || []); const tGroups = new Set(target.eggGroups || []);
if (tGroups.has('no-eggs')) { if (tGroups.has('no-eggs')) {
body.append(el('p', { class: 'detail__muted' }, `${prettify(target.name)} is in the Undiscovered egg group — it cant be bred.`)); body.append(
el(
'p',
{ class: 'detail__muted' },
`${prettify(target.name)} is in the Undiscovered egg group — it cant be bred.`,
),
);
return; return;
} }
const isDitto = tGroups.has('ditto'); const isDitto = tGroups.has('ditto');
@ -141,7 +165,9 @@ export async function BreedingView() {
let list; let list;
if (isDitto) { if (isDitto) {
list = snap.species.filter((s) => !s.eggGroups.includes('no-eggs') && !s.eggGroups.includes('ditto')); list = snap.species.filter(
(s) => !s.eggGroups.includes('no-eggs') && !s.eggGroups.includes('ditto'),
);
} else if (genderless) { } else if (genderless) {
list = snap.species.filter((s) => (s.eggGroups || []).includes('ditto')); list = snap.species.filter((s) => (s.eggGroups || []).includes('ditto'));
} else { } else {
@ -168,12 +194,17 @@ export async function BreedingView() {
if (!prev) break; if (!prev) break;
base = prev; base = prev;
} }
const eggMovesBox = el('div', { class: 'breed__eggmoves' }, el('p', { class: 'detail__muted' }, 'Loading egg moves…')); const eggMovesBox = el(
'div',
{ class: 'breed__eggmoves' },
el('p', { class: 'detail__muted' }, 'Loading egg moves…'),
);
getPokemon(base.id) getPokemon(base.id)
.then((pk) => { .then((pk) => {
const eggMoves = pk.moves.filter((m) => const eggMoves = pk.moves.filter((m) =>
m.version_group_details.some( m.version_group_details.some(
(d) => d.move_learn_method.name === 'egg' && (vg === 'all' || d.version_group.name === vg), (d) =>
d.move_learn_method.name === 'egg' && (vg === 'all' || d.version_group.name === vg),
), ),
); );
clear(eggMovesBox); clear(eggMovesBox);
@ -196,12 +227,20 @@ export async function BreedingView() {
'div', 'div',
{ class: 'breed__eggmove-list' }, { class: 'breed__eggmove-list' },
...eggMoves.map((m) => ...eggMoves.map((m) =>
el('a', { class: 'link', href: `#/move/${idFromUrl(m.move.url)}` }, m.move.name.replace(/-/g, ' ')), el(
'a',
{ class: 'link', href: `#/move/${idFromUrl(m.move.url)}` },
m.move.name.replace(/-/g, ' '),
),
), ),
), ),
); );
}) })
.catch(() => clear(eggMovesBox).append(el('p', { class: 'detail__muted' }, 'Egg moves unavailable offline.'))); .catch(() =>
clear(eggMovesBox).append(
el('p', { class: 'detail__muted' }, 'Egg moves unavailable offline.'),
),
);
body.append( body.append(
el('p', { class: 'breed__note' }, note), el('p', { class: 'breed__note' }, note),

View File

@ -95,7 +95,11 @@ export async function CompareView() {
body.append( body.append(
mem.length >= 2 mem.length >= 2
? CompareTable(mem, { gen, style }) ? CompareTable(mem, { gen, style })
: el('p', { class: 'detail__muted' }, 'Add two or more Pokémon to compare their types and base stats.'), : el(
'p',
{ class: 'detail__muted' },
'Add two or more Pokémon to compare their types and base stats.',
),
); );
} }
@ -105,11 +109,7 @@ export async function CompareView() {
'header', 'header',
{ class: 'view__header' }, { class: 'view__header' },
el('h1', {}, 'Compare'), el('h1', {}, 'Compare'),
el( el('p', {}, vg ? `Types shown for ${vg.name}.` : 'Types shown for the newest games.'),
'p',
{},
vg ? `Types shown for ${vg.name}.` : 'Types shown for the newest games.',
),
), ),
slots, slots,
body, body,

View File

@ -10,8 +10,24 @@ import { Sprite } from '../components/Sprite.js';
import { typesForGen } from '../lib/type-resolve.js'; import { typesForGen } from '../lib/type-resolve.js';
const TYPES = [ const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', 'normal',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy', 'fire',
'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
]; ];
const FILTERS = [ const FILTERS = [
@ -47,30 +63,55 @@ const SEARCH_ICON =
function sortValue(row, key) { function sortValue(row, key) {
const sp = row.species; const sp = row.species;
switch (key) { switch (key) {
case 'dex': return row.number ?? sp.id; case 'dex':
case 'name': return sp.name; return row.number ?? sp.id;
case 'bst': return sp.bst ?? 0; case 'name':
case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe': return sp.name;
case 'bst':
return sp.bst ?? 0;
case 'hp':
case 'atk':
case 'def':
case 'spa':
case 'spd':
case 'spe':
return sp.stats?.[key] ?? 0; return sp.stats?.[key] ?? 0;
case 'height': return sp.height ?? 0; case 'height':
case 'weight': return sp.weight ?? 0; return sp.height ?? 0;
case 'baseExp': return sp.baseExp ?? 0; case 'weight':
case 'catchrate': return sp.captureRate ?? 0; return sp.weight ?? 0;
case 'caught': return gameEntry(sp.id).updatedAt || ''; case 'baseExp':
default: return 0; return sp.baseExp ?? 0;
case 'catchrate':
return sp.captureRate ?? 0;
case 'caught':
return gameEntry(sp.id).updatedAt || '';
default:
return 0;
} }
} }
function metricLabel(key, sp) { function metricLabel(key, sp) {
switch (key) { switch (key) {
case 'bst': return `BST ${sp.bst}`; case 'bst':
case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe': return `BST ${sp.bst}`;
case 'hp':
case 'atk':
case 'def':
case 'spa':
case 'spd':
case 'spe':
return `${STAT_SHORT[key]} ${sp.stats?.[key] ?? 0}`; return `${STAT_SHORT[key]} ${sp.stats?.[key] ?? 0}`;
case 'height': return `${((sp.height ?? 0) / 10).toFixed(1)} m`; case 'height':
case 'weight': return `${((sp.weight ?? 0) / 10).toFixed(1)} kg`; return `${((sp.height ?? 0) / 10).toFixed(1)} m`;
case 'baseExp': return sp.baseExp != null ? `${sp.baseExp} EXP` : null; case 'weight':
case 'catchrate': return `Catch ${sp.captureRate ?? '?'}`; return `${((sp.weight ?? 0) / 10).toFixed(1)} kg`;
default: return null; case 'baseExp':
return sp.baseExp != null ? `${sp.baseExp} EXP` : null;
case 'catchrate':
return `Catch ${sp.captureRate ?? '?'}`;
default:
return null;
} }
} }
@ -254,7 +295,9 @@ export async function DexGrid() {
}, },
}, },
el('option', { value: '' }, 'Any egg group'), el('option', { value: '' }, 'Any egg group'),
...eggGroupList.map((g) => el('option', { value: g, selected: g === filterEggGroup }, prettify(g))), ...eggGroupList.map((g) =>
el('option', { value: g, selected: g === filterEggGroup }, prettify(g)),
),
); );
const minBstField = el( const minBstField = el(
@ -347,7 +390,13 @@ export async function DexGrid() {
'div', 'div',
{ class: `feed-tools${ui.get().toolsOpen ? ' is-open' : ''}` }, { class: `feed-tools${ui.get().toolsOpen ? ' is-open' : ''}` },
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' }, abilitySelect, eggGroupSelect, minBstField),
el('div', { class: 'feed-sort feed-filters' }, fullyEvolvedCheck), el('div', { class: 'feed-sort feed-filters' }, fullyEvolvedCheck),
@ -431,7 +480,9 @@ 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; 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 (
@ -490,8 +541,7 @@ export async function DexGrid() {
ids = rows.map((r) => r.species.id); ids = rows.map((r) => r.species.id);
title.textContent = prettify(dex.name || dex.key); title.textContent = prettify(dex.name || dex.key);
gameLabel.textContent = gameLabel.textContent = st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup);
st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup);
refreshMeta(); refreshMeta();
paintGrid(); paintGrid();
} }

View File

@ -86,7 +86,11 @@ export async function ItemDetail(id) {
} }
if (shown.length > HOLDER_CAP) { if (shown.length > HOLDER_CAP) {
holdersGrid.append( holdersGrid.append(
el('p', { class: 'detail__muted' }, `+${shown.length - HOLDER_CAP} more — filter to narrow.`), el(
'p',
{ class: 'detail__muted' },
`+${shown.length - HOLDER_CAP} more — filter to narrow.`,
),
); );
} }
} }

View File

@ -60,7 +60,8 @@ export async function MoveDetail(id) {
`May ${prettify(d.meta.ailment.name)}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`, `May ${prettify(d.meta.ailment.name)}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`,
); );
} }
if (d.meta?.drain) bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`); if (d.meta?.drain)
bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`);
if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`); if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`);
if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`); if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`);
if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`); if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`);
@ -128,7 +129,11 @@ export async function MoveDetail(id) {
} }
if (shown.length > LEARNER_CAP) { if (shown.length > LEARNER_CAP) {
learnersGrid.append( learnersGrid.append(
el('p', { class: 'detail__muted' }, `+${shown.length - LEARNER_CAP} more — filter to narrow.`), el(
'p',
{ class: 'detail__muted' },
`+${shown.length - LEARNER_CAP} more — filter to narrow.`,
),
); );
} }
} }

View File

@ -9,7 +9,11 @@ export function NaturesView() {
'header', 'header',
{ class: 'view__header' }, { class: 'view__header' },
el('h1', {}, 'Natures'), el('h1', {}, 'Natures'),
el('p', {}, 'Each nature raises one stat by 10% and lowers another by 10%. HP is never affected; five natures are neutral.'), el(
'p',
{},
'Each nature raises one stat by 10% and lowers another by 10%. HP is never affected; five natures are neutral.',
),
), ),
el( el(
'div', 'div',

View File

@ -67,17 +67,13 @@ function typesForGeneration(pokemon, gen) {
.sort((a, b) => a._gen - b._gen); .sort((a, b) => a._gen - b._gen);
const era = past.find((p) => p._gen >= gen); const era = past.find((p) => p._gen >= gen);
const list = era ? era.types : pokemon.types; const list = era ? era.types : pokemon.types;
return list.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name); return list
.slice()
.sort((a, b) => a.slot - b.slot)
.map((t) => t.type.name);
} }
const STAT_NAMES = [ const STAT_NAMES = ['hp', 'attack', 'defense', 'special-attack', 'special-defense', 'speed'];
'hp',
'attack',
'defense',
'special-attack',
'special-defense',
'speed',
];
const NO_ENCOUNTER_DATA = new Set([ const NO_ENCOUNTER_DATA = new Set([
'scarlet-violet', 'scarlet-violet',
@ -106,10 +102,7 @@ export async function PokemonDetail(nationalId) {
let pokemon; let pokemon;
let species; let species;
try { try {
[pokemon, species] = await Promise.all([ [pokemon, species] = await Promise.all([getPokemon(nationalId), getSpecies(nationalId)]);
getPokemon(nationalId),
getSpecies(nationalId),
]);
} catch (err) { } catch (err) {
clear(view).append( clear(view).append(
el( el(
@ -272,7 +265,13 @@ export async function PokemonDetail(nationalId) {
const on = inTeam(nationalId, teamForm()); const on = inTeam(nationalId, teamForm());
const full = team.get().members.length >= MAX_TEAM; const full = team.get().members.length >= MAX_TEAM;
const label = activeForm ? `${activeForm.name} in team` : 'In team'; const label = activeForm ? `${activeForm.name} in team` : 'In team';
teamBtn.textContent = on ? `${label}` : full ? 'Team full' : activeForm ? ` ${activeForm.name}` : ' Team'; teamBtn.textContent = on
? `${label}`
: full
? 'Team full'
: activeForm
? ` ${activeForm.name}`
: ' Team';
teamBtn.classList.toggle('is-on', on); teamBtn.classList.toggle('is-on', on);
teamBtn.disabled = !on && full; teamBtn.disabled = !on && full;
} }
@ -361,8 +360,10 @@ export async function PokemonDetail(nationalId) {
}), }),
); );
const evYield = const evYield =
p.stats.filter((s) => s.effort > 0).map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`).join(', ') || p.stats
'—'; .filter((s) => s.effort > 0)
.map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`)
.join(', ') || '—';
const heldItems = p.held_items?.length const heldItems = p.held_items?.length
? el( ? el(
'span', 'span',
@ -388,7 +389,10 @@ export async function PokemonDetail(nationalId) {
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null, abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
fact('Introduced', prettify(species.generation.name)), fact('Introduced', prettify(species.generation.name)),
species.genera?.length species.genera?.length
? fact('Category', (species.genera.find((g) => g.language.name === 'en') || {}).genus || '—') ? fact(
'Category',
(species.genera.find((g) => g.language.name === 'en') || {}).genus || '—',
)
: null, : null,
), ),
el('h3', { class: 'ppanel__sub' }, 'Training'), el('h3', { class: 'ppanel__sub' }, 'Training'),
@ -466,7 +470,9 @@ export async function PokemonDetail(nationalId) {
el('div', { class: 'fact' }, el('dt', {}, 'Gender'), genderCell(species.gender_rate)), el('div', { class: 'fact' }, el('dt', {}, 'Gender'), genderCell(species.gender_rate)),
fact( fact(
'Egg groups', 'Egg groups',
eggGroups.includes('Undiscovered') ? "Undiscovered (can't breed)" : eggGroups.join(', ') || '—', eggGroups.includes('Undiscovered')
? "Undiscovered (can't breed)"
: eggGroups.join(', ') || '—',
), ),
fact( fact(
'Egg cycles', 'Egg cycles',
@ -492,13 +498,19 @@ export async function PokemonDetail(nationalId) {
); );
} else if (NO_ENCOUNTER_DATA.has(st.versionGroup)) { } else if (NO_ENCOUNTER_DATA.has(st.versionGroup)) {
locPanel.replaceChildren( locPanel.replaceChildren(
el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."), el(
'p',
{ class: 'detail__muted' },
"PokéAPI doesn't have wild-encounter data for this game yet.",
),
); );
} else { } else {
getEncounters(nationalId) getEncounters(nationalId)
.then((data) => locPanel.replaceChildren(Locations(data, vg ? vg.versions : []))) .then((data) => locPanel.replaceChildren(Locations(data, vg ? vg.versions : [])))
.catch(() => .catch(() =>
locPanel.replaceChildren(el('p', { class: 'detail__muted' }, 'Location data unavailable offline.')), locPanel.replaceChildren(
el('p', { class: 'detail__muted' }, 'Location data unavailable offline.'),
),
); );
} }
@ -516,7 +528,12 @@ export async function PokemonDetail(nationalId) {
// generic "Default". // generic "Default".
const DEFAULT_SLOT_NAME = { unown: 'A' }; const DEFAULT_SLOT_NAME = { unown: 'A' };
const slots = [ const slots = [
{ slug: snapSpecies.name, id: nationalId, name: DEFAULT_SLOT_NAME[snapSpecies.name] || 'Default', sprite: null }, {
slug: snapSpecies.name,
id: nationalId,
name: DEFAULT_SLOT_NAME[snapSpecies.name] || 'Default',
sprite: null,
},
...cosmeticForms, ...cosmeticForms,
]; ];
const countEl = el('span', { class: 'formdex__count' }); const countEl = el('span', { class: 'formdex__count' });
@ -582,7 +599,9 @@ export async function PokemonDetail(nationalId) {
// ---- Tabs ------------------------------------------- // ---- Tabs -------------------------------------------
const TABS = [ const TABS = [
{ id: 'about', label: 'About', node: aboutPanel }, { id: 'about', label: 'About', node: aboutPanel },
formDexPanel ? { id: 'formdex', label: `${prettify(snapSpecies.name)} Dex`, node: formDexPanel } : null, formDexPanel
? { id: 'formdex', label: `${prettify(snapSpecies.name)} Dex`, node: formDexPanel }
: null,
{ id: 'stats', label: 'Stats', node: statsPanel }, { id: 'stats', label: 'Stats', node: statsPanel },
{ id: 'evo', label: 'Evolution', node: evoPanel }, { id: 'evo', label: 'Evolution', node: evoPanel },
{ id: 'moves', label: 'Moves', node: movesPanel }, { id: 'moves', label: 'Moves', node: movesPanel },
@ -745,7 +764,11 @@ export async function PokemonDetail(nationalId) {
{ class: 'phero' }, { class: 'phero' },
el('div', { class: 'phero__bg' }), el('div', { class: 'phero__bg' }),
el('div', { class: 'phero__ball', 'aria-hidden': 'true' }), el('div', { class: 'phero__ball', 'aria-hidden': 'true' }),
el('span', { class: 'phero__bignum', 'aria-hidden': 'true' }, `#${String(nationalId).padStart(4, '0')}`), el(
'span',
{ class: 'phero__bignum', 'aria-hidden': 'true' },
`#${String(nationalId).padStart(4, '0')}`,
),
el( el(
'div', 'div',
{ class: 'phero__top' }, { class: 'phero__top' },
@ -753,23 +776,34 @@ export async function PokemonDetail(nationalId) {
el('span', { class: 'phero__num' }, numLabel), el('span', { class: 'phero__num' }, numLabel),
el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn), el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn),
), ),
el( el('div', { class: 'phero__head' }, heroName, heroTypes),
'div',
{ class: 'phero__head' },
heroName,
heroTypes,
),
formBar, formBar,
cosmeticBar, cosmeticBar,
el( el(
'div', 'div',
{ class: 'phero__stage' }, { class: 'phero__stage' },
prev prev
? el('a', { class: 'phero__nav phero__nav--prev', href: `#/pokemon/${prev.species.id}`, title: prev.species.name }, '') ? el(
'a',
{
class: 'phero__nav phero__nav--prev',
href: `#/pokemon/${prev.species.id}`,
title: prev.species.name,
},
'',
)
: el('span', { class: 'phero__nav' }), : el('span', { class: 'phero__nav' }),
artHolder, artHolder,
next next
? el('a', { class: 'phero__nav phero__nav--next', href: `#/pokemon/${next.species.id}`, title: next.species.name }, '') ? el(
'a',
{
class: 'phero__nav phero__nav--next',
href: `#/pokemon/${next.species.id}`,
title: next.species.name,
},
'',
)
: el('span', { class: 'phero__nav' }), : el('span', { class: 'phero__nav' }),
), ),
), ),

View File

@ -53,7 +53,9 @@ export async function ProgressView() {
clear(list); clear(list);
const gens = versionGroupsByGeneration(snap); const gens = versionGroupsByGeneration(snap);
const anyPlayed = gens.some(({ versionGroups }) => versionGroups.some((vg) => isPlayed(vg.key))); const anyPlayed = gens.some(({ versionGroups }) =>
versionGroups.some((vg) => isPlayed(vg.key)),
);
const showAll = ui.get().progShowAll || !anyPlayed; const showAll = ui.get().progShowAll || !anyPlayed;
// National total — caught in any game. // National total — caught in any game.
@ -115,7 +117,11 @@ export async function ProgressView() {
render(); render();
clear(view).append( clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/settings' }, ' Settings')), el(
'nav',
{ class: 'lookup__nav' },
el('a', { class: 'link', href: '#/settings' }, ' Settings'),
),
el( el(
'header', 'header',
{ class: 'view__header' }, { class: 'view__header' },

View File

@ -9,11 +9,31 @@ import { TypeChip } from '../components/TypeChip.js';
import { segThumb } from '../lib/anim.js'; import { segThumb } from '../lib/anim.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim(); const loose = (s) =>
s
.toLowerCase()
.replace(/[-\s]+/g, ' ')
.trim();
const DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' }; const DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' };
const TYPES = [ const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', 'normal',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy', 'fire',
'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
]; ];
const itemSprite = (name) => const itemSprite = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`; `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
@ -30,7 +50,7 @@ const tmSortKey = (label) => {
if (!label) return 1e9; if (!label) return 1e9;
const m = String(label).match(/^([A-Z]+)0*(\d+)$/); const m = String(label).match(/^([A-Z]+)0*(\d+)$/);
if (!m) return 1e9 - 1; if (!m) return 1e9 - 1;
return (({ TM: 0, HM: 1, TR: 2 })[m[1]] ?? 3) * 1000 + Number(m[2]); return ({ TM: 0, HM: 1, TR: 2 }[m[1]] ?? 3) * 1000 + Number(m[2]);
}; };
export async function SearchView() { export async function SearchView() {
@ -75,7 +95,14 @@ export async function SearchView() {
), ),
), ),
); );
const setThumb = segThumb(seg, TABS.length, Math.max(0, TABS.findIndex((t) => t.id === tab))); const setThumb = segThumb(
seg,
TABS.length,
Math.max(
0,
TABS.findIndex((t) => t.id === tab),
),
);
const filters = el('div', { class: 'lookup__filters' }); const filters = el('div', { class: 'lookup__filters' });
const note = el('p', { class: 'search__note' }); const note = el('p', { class: 'search__note' });
@ -119,9 +146,7 @@ export async function SearchView() {
const vg = settings.get().versionGroup; const vg = settings.get().versionGroup;
const gameHasKind = (kind) => const gameHasKind = (kind) =>
snap.moves.some((m) => snap.moves.some((m) =>
m.machines.some( m.machines.some((x) => (vg === 'all' || x.vg === vg) && (x.tm || '').startsWith(kind)),
(x) => (vg === 'all' || x.vg === vg) && (x.tm || '').startsWith(kind),
),
); );
const hasTm = gameHasKind('TM'); const hasTm = gameHasKind('TM');
const hasHm = gameHasKind('HM'); // false for Gen 8+ games, which dropped HMs const hasHm = gameHasKind('HM'); // false for Gen 8+ games, which dropped HMs
@ -137,13 +162,23 @@ export async function SearchView() {
filters.append( filters.append(
...[ ...[
pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => { pill(
ui.set({ mvType: v }); 'mvType',
run(); [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])],
}), ui.get().mvType,
(v) => {
ui.set({ mvType: v });
run();
},
),
pill( pill(
'mvClass', 'mvClass',
[['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']], [
['', 'Any category'],
['physical', 'Physical'],
['special', 'Special'],
['status', 'Status'],
],
ui.get().mvClass, ui.get().mvClass,
(v) => { (v) => {
ui.set({ mvClass: v }); ui.set({ mvClass: v });
@ -181,16 +216,28 @@ export async function SearchView() {
run(); run();
}, },
), ),
pill('itSort', [['name', 'AZ'], ['id', 'Dex order']], ui.get().itSort, (v) => { pill(
ui.set({ itSort: v }); 'itSort',
run(); [
}), ['name', 'AZ'],
['id', 'Dex order'],
],
ui.get().itSort,
(v) => {
ui.set({ itSort: v });
run();
},
),
); );
} else if (tab === 'abilities') { } else if (tab === 'abilities') {
filters.append( filters.append(
pill( pill(
'abSort', 'abSort',
[['name', 'AZ'], ['count', 'Most Pokémon'], ['gen', 'Newest']], [
['name', 'AZ'],
['count', 'Most Pokémon'],
['gen', 'Newest'],
],
ui.get().abSort, ui.get().abSort,
(v) => { (v) => {
ui.set({ abSort: v }); ui.set({ abSort: v });
@ -228,12 +275,11 @@ export async function SearchView() {
const matches = snap.species const matches = snap.species
.filter( .filter(
(s) => (s) =>
loose(s.name).includes(q) || loose(s.name).includes(q) || String(s.id) === q || (s.types || []).some((t) => t === q),
String(s.id) === q ||
(s.types || []).some((t) => t === q),
) )
.slice(0, 60); .slice(0, 60);
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.')); if (!matches.length)
return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
for (const s of matches) { for (const s of matches) {
const e = entry(s.id); const e = entry(s.id);
results.append( results.append(
@ -289,8 +335,10 @@ export async function SearchView() {
}); });
list.sort((a, b) => { list.sort((a, b) => {
if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name); if (sort === 'power')
if (sort === 'accuracy') return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name); return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name);
if (sort === 'accuracy')
return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name);
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name); if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
if (sort === 'tm') { if (sort === 'tm') {
return ( return (
@ -318,14 +366,24 @@ export async function SearchView() {
'span', 'span',
{ class: 'search__types' }, { class: 'search__types' },
TypeChip(m.type), TypeChip(m.type),
el('span', { class: 'moverow__cat', dataset: { cat: m.damageClass || '' } }, DMG[m.damageClass] || '—'), el(
'span',
{ class: 'moverow__cat', dataset: { cat: m.damageClass || '' } },
DMG[m.damageClass] || '—',
),
el('span', { class: 'search__num' }, m.power != null ? `${m.power} pw` : ''), el('span', { class: 'search__num' }, m.power != null ? `${m.power} pw` : ''),
), ),
), ),
); );
} }
if (list.length > shown.length) { if (list.length > shown.length) {
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); results.append(
el(
'p',
{ class: 'search__empty' },
`Showing ${shown.length} of ${list.length} — refine to see more.`,
),
);
} }
} }
@ -343,7 +401,12 @@ export async function SearchView() {
note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`; note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`;
const shown = list.slice(0, 400); const shown = list.slice(0, 400);
for (const it of shown) { for (const it of shown) {
const icon = el('img', { class: 'search__item-icon', loading: 'lazy', alt: '', src: itemSprite(it.name) }); const icon = el('img', {
class: 'search__item-icon',
loading: 'lazy',
alt: '',
src: itemSprite(it.name),
});
icon.addEventListener('error', () => icon.remove(), { once: true }); icon.addEventListener('error', () => icon.remove(), { once: true });
results.append( results.append(
el( el(
@ -356,7 +419,13 @@ export async function SearchView() {
); );
} }
if (list.length > shown.length) { if (list.length > shown.length) {
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); results.append(
el(
'p',
{ class: 'search__empty' },
`Showing ${shown.length} of ${list.length} — refine to see more.`,
),
);
} }
} }
@ -367,7 +436,8 @@ export async function SearchView() {
(a) => !q || loose(a.name).includes(q) || a.effect.toLowerCase().includes(q), (a) => !q || loose(a.name).includes(q) || a.effect.toLowerCase().includes(q),
); );
list.sort((a, b) => { list.sort((a, b) => {
if (sort === 'count') return b.pokemon.length - a.pokemon.length || a.name.localeCompare(b.name); if (sort === 'count')
return b.pokemon.length - a.pokemon.length || a.name.localeCompare(b.name);
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name); if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });

View File

@ -14,16 +14,21 @@ export async function SettingsView() {
const view = el('section', { class: 'view settings' }); const view = el('section', { class: 'view settings' });
const st = settings.get(); const st = settings.get();
const themeField = selectField('Theme', st.theme, [ const themeField = selectField(
['system', 'System'], 'Theme',
['light', 'Light'], st.theme,
['dark', 'Dark'], [
['black', 'Black (OLED)'], ['system', 'System'],
['sepia', 'Sepia'], ['light', 'Light'],
], (v) => { ['dark', 'Dark'],
settings.set({ theme: v }); ['black', 'Black (OLED)'],
applyTheme(); ['sepia', 'Sepia'],
}); ],
(v) => {
settings.set({ theme: v });
applyTheme();
},
);
const ACCENTS = [ const ACCENTS = [
['red', '#b3161a'], ['red', '#b3161a'],
@ -55,16 +60,23 @@ export async function SettingsView() {
); );
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent color'), accentRow); const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent color'), accentRow);
const fontSizeField = selectField('Text size', st.fontScale || 'default', [ const fontSizeField = selectField(
['small', 'Small'], 'Text size',
['default', 'Default'], st.fontScale || 'default',
['large', 'Large'], [
], (v) => { ['small', 'Small'],
settings.set({ fontScale: v }); ['default', 'Default'],
applyTheme(); ['large', 'Large'],
}); ],
(v) => {
settings.set({ fontScale: v });
applyTheme();
},
);
const reduceMotionField = el('label', { class: 'field field--check' }, const reduceMotionField = el(
'label',
{ class: 'field field--check' },
el('input', { el('input', {
type: 'checkbox', type: 'checkbox',
checked: !!st.reduceMotion, checked: !!st.reduceMotion,
@ -76,14 +88,21 @@ export async function SettingsView() {
'Reduce motion', 'Reduce motion',
); );
const spriteField = selectField('Sprite style', st.spriteStyle, [ const spriteField = selectField(
['default', 'Pixel (modern)'], 'Sprite style',
['game', 'Game era (pixel art from the selected game)'], st.spriteStyle,
['official', 'Official artwork'], [
['home', 'Pokémon HOME'], ['default', 'Pixel (modern)'],
], (v) => settings.set({ spriteStyle: v })); ['game', 'Game era (pixel art from the selected game)'],
['official', 'Official artwork'],
['home', 'Pokémon HOME'],
],
(v) => settings.set({ spriteStyle: v }),
);
const shinyField = el('label', { class: 'field field--check' }, const shinyField = el(
'label',
{ class: 'field field--check' },
el('input', { el('input', {
type: 'checkbox', type: 'checkbox',
checked: st.showShiny, checked: st.showShiny,
@ -92,7 +111,9 @@ export async function SettingsView() {
'Default detail view to shiny sprites', 'Default detail view to shiny sprites',
); );
const teamNavField = el('label', { class: 'field field--check' }, const teamNavField = el(
'label',
{ class: 'field field--check' },
el('input', { el('input', {
type: 'checkbox', type: 'checkbox',
checked: st.showTeamNav !== false, checked: st.showTeamNav !== false,
@ -101,7 +122,9 @@ export async function SettingsView() {
'Show Team in the navigation bar', 'Show Team in the navigation bar',
); );
const hapticsField = el('label', { class: 'field field--check' }, const hapticsField = el(
'label',
{ class: 'field field--check' },
el('input', { el('input', {
type: 'checkbox', type: 'checkbox',
checked: st.haptics !== false, checked: st.haptics !== false,
@ -110,11 +133,16 @@ export async function SettingsView() {
'Haptic feedback on catch', 'Haptic feedback on catch',
); );
const defaultRouteField = selectField('Landing screen', st.defaultRoute || 'dex', [ const defaultRouteField = selectField(
['dex', 'Dex'], 'Landing screen',
['team', 'Team'], st.defaultRoute || 'dex',
['search', 'Search'], [
], (v) => settings.set({ defaultRoute: v })); ['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) {
@ -157,7 +185,11 @@ export async function SettingsView() {
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`, body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`,
choices: [ choices: [
{ key: 'merge', label: `Add to ${prettify(target)}` }, { key: 'merge', label: `Add to ${prettify(target)}` },
{ key: 'replace', label: `Replace ${prettify(target)} with this`, class: 'button--danger' }, {
key: 'replace',
label: `Replace ${prettify(target)} with this`,
class: 'button--danger',
},
{ key: null, label: 'Cancel', class: 'button--ghost' }, { key: null, label: 'Cancel', class: 'button--ghost' },
], ],
}); });
@ -253,50 +285,80 @@ export async function SettingsView() {
el( el(
'div', 'div',
{ class: 'settings__save' }, { class: 'settings__save' },
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'), el(
'button',
{ class: 'button', type: 'button', onclick: () => saveInput.click() },
'Import from a game save',
),
el('p', { class: 'settings__note' }, 'A .sav / .srm / .dsv from a Gen 15 game.'), el('p', { class: 'settings__note' }, 'A .sav / .srm / .dsv from a Gen 15 game.'),
), ),
saveInput, saveInput,
saveNote, saveNote,
storageNote, storageNote,
el('div', { class: 'settings__actions' }, el(
el('button', { 'div',
class: 'button', { class: 'settings__actions' },
type: 'button', el(
onclick: () => exportData(), 'button',
}, 'Export backup'), {
el('button', { class: 'button',
class: 'button', type: 'button',
type: 'button', onclick: () => exportData(),
onclick: () => importInput.click(), },
}, 'Import backup'), 'Export backup',
),
el(
'button',
{
class: 'button',
type: 'button',
onclick: () => importInput.click(),
},
'Import backup',
),
importInput, importInput,
el('button', { el(
class: 'button button--danger', 'button',
type: 'button', {
onclick: () => { class: 'button button--danger',
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) { type: 'button',
selection.replace({ version: 3, pokemon: {}, games: {} }); onclick: () => {
} if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
selection.replace({ version: 3, pokemon: {}, games: {} });
}
},
}, },
}, 'Clear tracking data'), 'Clear tracking data',
el('button', { ),
class: 'button button--danger', el(
type: 'button', 'button',
onclick: async () => { {
if (!('caches' in window)) return; class: 'button button--danger',
if (confirm('Clear cached PokéAPI data and sprites? They will re-download when online.')) { type: 'button',
for (const key of await caches.keys()) await caches.delete(key); onclick: async () => {
alert('Caches cleared.'); if (!('caches' in window)) return;
} if (
confirm('Clear cached PokéAPI data and sprites? They will re-download when online.')
) {
for (const key of await caches.keys()) await caches.delete(key);
alert('Caches cleared.');
}
},
}, },
}, 'Clear cached API data'), 'Clear cached API data',
),
), ),
el('h2', {}, 'About'), el('h2', {}, 'About'),
el('p', { class: 'settings__note' }, el(
'p',
{ class: 'settings__note' },
'Data from ', 'Data from ',
el('a', { href: 'https://pokeapi.co/', class: 'link', target: '_blank', rel: 'noreferrer' }, 'PokéAPI'), el(
'a',
{ href: 'https://pokeapi.co/', class: 'link', target: '_blank', rel: 'noreferrer' },
'PokéAPI',
),
'. Tracking data and preferences are stored locally in your browser.', '. Tracking data and preferences are stored locally in your browser.',
), ),
); );
@ -311,9 +373,7 @@ function selectField(label, value, options, onChange) {
const select = el( const select = el(
'select', 'select',
{ onchange: (e) => onChange(e.target.value) }, { onchange: (e) => onChange(e.target.value) },
...options.map(([v, text]) => ...options.map(([v, text]) => el('option', { value: v, selected: v === value }, text)),
el('option', { value: v, selected: v === value }, text),
),
); );
return el('label', { class: 'field' }, el('span', {}, label), select); return el('label', { class: 'field' }, el('span', {}, label), select);
} }

View File

@ -71,13 +71,28 @@ export async function ShinyView() {
'span', 'span',
{}, {},
el('span', { class: 'shiny__name' }, (sp?.name || `#${h.speciesId}`).replace(/-/g, ' ')), el('span', { class: 'shiny__name' }, (sp?.name || `#${h.speciesId}`).replace(/-/g, ' ')),
el('span', { class: 'shiny__method' }, methodById(h.method).label + (h.charm ? ' · charm' : '')), el(
'span',
{ class: 'shiny__method' },
methodById(h.method).label + (h.charm ? ' · charm' : ''),
),
), ),
), ),
el( el(
'div', 'div',
{ class: 'shiny__counter' }, { class: 'shiny__counter' },
el('button', { type: 'button', class: 'shiny__step', onclick: () => { bumpHunt(h.id, -1); refresh(); } }, ''), el(
'button',
{
type: 'button',
class: 'shiny__step',
onclick: () => {
bumpHunt(h.id, -1);
refresh();
},
},
'',
),
count, count,
el( el(
'button', 'button',
@ -137,13 +152,25 @@ export async function ShinyView() {
} }
function chooseMethod(done) { function chooseMethod(done) {
const backdrop = el('div', { class: 'sheet-backdrop is-open', onclick: (e) => e.target === backdrop && backdrop.remove() }); const backdrop = el('div', {
const methodSel = el('select', { class: 'search__input' }, ...METHODS.map((m) => el('option', { value: m.id }, m.label))); class: 'sheet-backdrop is-open',
onclick: (e) => e.target === backdrop && backdrop.remove(),
});
const methodSel = el(
'select',
{ class: 'search__input' },
...METHODS.map((m) => el('option', { value: m.id }, m.label)),
);
const charmChk = el('input', { type: 'checkbox' }); const charmChk = el('input', { type: 'checkbox' });
const panel = el( const panel = el(
'div', 'div',
{ class: 'gsheet' }, { class: 'gsheet' },
el('div', { class: 'gsheet__head' }, el('h2', {}, 'Hunt method'), el('button', { class: 'gsheet__close', onclick: () => backdrop.remove() }, '✕')), el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Hunt method'),
el('button', { class: 'gsheet__close', onclick: () => backdrop.remove() }, '✕'),
),
methodSel, methodSel,
el('label', { class: 'lookup__check', style: 'margin-top:12px' }, charmChk, 'Shiny Charm'), el('label', { class: 'lookup__check', style: 'margin-top:12px' }, charmChk, 'Shiny Charm'),
el( el(
@ -173,17 +200,30 @@ export async function ShinyView() {
for (const h of hunts) list.append(huntCard(h)); for (const h of hunts) list.append(huntCard(h));
} }
list.append( list.append(
el('button', { type: 'button', class: 'lineup__add shiny__new', onclick: newHunt }, '', el('span', {}, 'New hunt')), el(
'button',
{ type: 'button', class: 'lineup__add shiny__new', onclick: newHunt },
'',
el('span', {}, 'New hunt'),
),
); );
} }
clear(view).append( clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/settings' }, ' Settings')), el(
'nav',
{ class: 'lookup__nav' },
el('a', { class: 'link', href: '#/settings' }, ' Settings'),
),
el( el(
'header', 'header',
{ class: 'view__header' }, { class: 'view__header' },
el('h1', {}, 'Shiny hunts'), el('h1', {}, 'Shiny hunts'),
el('p', {}, 'Count encounters and watch the cumulative odds climb. “Found it” marks the Pokémon caught.'), el(
'p',
{},
'Count encounters and watch the cumulative odds climb. “Found it” marks the Pokémon caught.',
),
), ),
list, list,
); );

View File

@ -16,12 +16,26 @@ import { openPokemonPicker } from '../components/PokemonPicker.js';
import { segThumb } from '../lib/anim.js'; import { segThumb } from '../lib/anim.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim(); const loose = (s) =>
s
.toLowerCase()
.replace(/[-\s]+/g, ' ')
.trim();
const multClass = (m) => const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1'; m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1';
const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`); const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
const effWord = (eff) => const effWord = (eff) =>
eff === 0 ? 'No effect' : eff >= 4 ? '4× effective' : eff >= 2 ? 'Super effective' : eff <= 0.25 ? 'Barely effective' : eff <= 0.5 ? 'Not very effective' : null; eff === 0
? 'No effect'
: eff >= 4
? '4× effective'
: eff >= 2
? 'Super effective'
: eff <= 0.25
? 'Barely effective'
: eff <= 0.5
? 'Not very effective'
: null;
export async function TeamView() { export async function TeamView() {
const view = el('section', { class: 'view teamview' }); const view = el('section', { class: 'view teamview' });
@ -56,7 +70,14 @@ export async function TeamView() {
), ),
), ),
); );
const setThumb = segThumb(seg, MODES.length, Math.max(0, MODES.findIndex(([id]) => id === mode))); const setThumb = segThumb(
seg,
MODES.length,
Math.max(
0,
MODES.findIndex(([id]) => id === mode),
),
);
const lineup = el('div', { class: 'lineup' }); const lineup = el('div', { class: 'lineup' });
const body = el('div', { class: 'teamview__body' }); const body = el('div', { class: 'teamview__body' });
@ -75,9 +96,24 @@ export async function TeamView() {
el( el(
'nav', 'nav',
{ class: 'teamview__refs', 'aria-label': 'Reference tools' }, { class: 'teamview__refs', 'aria-label': 'Reference tools' },
el('a', { class: 'reflink', href: '#/natures' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '✦'), 'Natures'), el(
el('a', { class: 'reflink', href: '#/types' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '▦'), 'Type chart'), 'a',
el('a', { class: 'reflink', href: '#/breeding' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '⬡'), 'Breeding'), { class: 'reflink', href: '#/natures' },
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '✦'),
'Natures',
),
el(
'a',
{ class: 'reflink', href: '#/types' },
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '▦'),
'Type chart',
),
el(
'a',
{ class: 'reflink', href: '#/breeding' },
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '⬡'),
'Breeding',
),
), ),
), ),
lineup, lineup,
@ -213,9 +249,7 @@ export async function TeamView() {
const threats = analysis const threats = analysis
.filter((a) => a.weakN >= 2 || (a.weakN >= 1 && a.resist === 0)) .filter((a) => a.weakN >= 2 || (a.weakN >= 1 && a.resist === 0))
.sort((a, b) => b.weakN - a.weakN || a.resist - b.resist); .sort((a, b) => b.weakN - a.weakN || a.resist - b.resist);
const unresisted = analysis const unresisted = analysis.filter((a) => a.weakN === 0 && a.resist === 0).map((a) => a.type);
.filter((a) => a.weakN === 0 && a.resist === 0)
.map((a) => a.type);
const weakSpots = threats.length const weakSpots = threats.length
? el( ? el(
@ -282,7 +316,11 @@ export async function TeamView() {
el( el(
'span', 'span',
{ class: 'cov__bal-side cov__bal-side--r' }, { class: 'cov__bal-side cov__bal-side--r' },
el('span', { class: 'cov__bal-weak', style: `width:${Math.min(a.weakN, 6) * 10}px` }, a.weakN || ''), el(
'span',
{ class: 'cov__bal-weak', style: `width:${Math.min(a.weakN, 6) * 10}px` },
a.weakN || '',
),
), ),
); );
return el( return el(
@ -306,7 +344,9 @@ export async function TeamView() {
.slice() .slice()
.sort( .sort(
(a, b) => (a, b) =>
(b.stats?.hp || 0) + (b.stats?.def || 0) + (b.stats?.spd || 0) - (b.stats?.hp || 0) +
(b.stats?.def || 0) +
(b.stats?.spd || 0) -
((a.stats?.hp || 0) + (a.stats?.def || 0) + (a.stats?.spd || 0)), ((a.stats?.hp || 0) + (a.stats?.def || 0) + (a.stats?.spd || 0)),
)[0]; )[0];
@ -449,7 +489,11 @@ export async function TeamView() {
Sprite(sp.spriteId ?? sp.id, { style, size: 48, alt: sp.name }), Sprite(sp.spriteId ?? sp.id, { style, size: 48, alt: sp.name }),
el('span', {}, prettify(sp.name)), el('span', {}, prettify(sp.name)),
) )
: el('button', { type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() }, `Choose ${sideLabel}`), : el(
'button',
{ type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() },
`Choose ${sideLabel}`,
),
); );
} }
function pick() { function pick() {
@ -482,7 +526,13 @@ export async function TeamView() {
), ),
) )
: null; : null;
return el('div', { class: 'calc__side' }, el('h3', { class: 'ppanel__sub' }, sideLabel), holder, quick); return el(
'div',
{ class: 'calc__side' },
el('h3', { class: 'ppanel__sub' }, sideLabel),
holder,
quick,
);
} }
const atkSlot = slot('atk', 'Attacker'); const atkSlot = slot('atk', 'Attacker');
@ -550,12 +600,18 @@ export async function TeamView() {
const v = forGeneration(raw, gen, genOfVg); const v = forGeneration(raw, gen, genOfVg);
moveNote.textContent = `${prettify(v.type)} · ${v.damage_class ? prettify(v.damage_class) : 'Status'}${v.power ? ` · ${v.power} power` : ''}`; moveNote.textContent = `${prettify(v.type)} · ${v.damage_class ? prettify(v.damage_class) : 'Status'}${v.power ? ` · ${v.power} power` : ''}`;
if (!atk || !def) { if (!atk || !def) {
clear(result).append(el('p', { class: 'detail__muted' }, 'Choose an attacker and a defender.')); clear(result).append(
el('p', { class: 'detail__muted' }, 'Choose an attacker and a defender.'),
);
return; return;
} }
if (!v.damage_class || v.damage_class === 'status' || !v.power) { if (!v.damage_class || v.damage_class === 'status' || !v.power) {
clear(result).append( clear(result).append(
el('p', { class: 'detail__muted' }, `${prettify(state.move.name)} doesn't deal direct damage.`), el(
'p',
{ class: 'detail__muted' },
`${prettify(state.move.name)} doesn't deal direct damage.`,
),
); );
return; return;
} }
@ -612,13 +668,23 @@ export async function TeamView() {
el( el(
'p', 'p',
{ class: 'calc__sub' }, { class: 'calc__sub' },
hitsMin === hitsMax ? `${hitsMin} hit${hitsMin > 1 ? 's' : ''} to KO` : `${hitsMin}${hitsMax} hits to KO`, hitsMin === hitsMax
? `${hitsMin} hit${hitsMin > 1 ? 's' : ''} to KO`
: `${hitsMin}${hitsMax} hits to KO`,
), ),
el( el(
'div', 'div',
{ class: 'calc__badges' }, { class: 'calc__badges' },
dmg.stab ? el('span', { class: 'calc__badge calc__badge--stab' }, 'STAB') : null, dmg.stab ? el('span', { class: 'calc__badge calc__badge--stab' }, 'STAB') : null,
word ? el('span', { class: `calc__badge${dmg.eff >= 2 || dmg.eff === 4 ? ' calc__badge--up' : ' calc__badge--down'}` }, word) : null, word
? el(
'span',
{
class: `calc__badge${dmg.eff >= 2 || dmg.eff === 4 ? ' calc__badge--up' : ' calc__badge--down'}`,
},
word,
)
: null,
), ),
); );
} }

View File

@ -7,9 +7,24 @@ import { TypeChip } from '../components/TypeChip.js';
import { typeHex } from '../lib/type-color.js'; import { typeHex } from '../lib/type-color.js';
const ABBR = { const ABBR = {
normal: 'NOR', fire: 'FIR', water: 'WAT', electric: 'ELE', grass: 'GRA', ice: 'ICE', normal: 'NOR',
fighting: 'FIG', poison: 'POI', ground: 'GRD', flying: 'FLY', psychic: 'PSY', bug: 'BUG', fire: 'FIR',
rock: 'ROC', ghost: 'GHO', dragon: 'DRA', dark: 'DRK', steel: 'STE', fairy: 'FAI', water: 'WAT',
electric: 'ELE',
grass: 'GRA',
ice: 'ICE',
fighting: 'FIG',
poison: 'POI',
ground: 'GRD',
flying: 'FLY',
psychic: 'PSY',
bug: 'BUG',
rock: 'ROC',
ghost: 'GHO',
dragon: 'DRA',
dark: 'DRK',
steel: 'STE',
fairy: 'FAI',
}; };
const cellText = (m) => (m === 0 ? '0' : m === 0.5 ? '½' : m === 2 ? '2' : ''); const cellText = (m) => (m === 0 ? '0' : m === 0.5 ? '½' : m === 2 ? '2' : '');
const cellClass = (m) => (m === 0 ? 'm0' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : 'm1'); const cellClass = (m) => (m === 0 ? 'm0' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : 'm1');
@ -78,11 +93,7 @@ export async function TypeChartView() {
const m = multiplier(atk, [def], gen); const m = multiplier(atk, [def], gen);
const hot = selected === atk || selected === def; const hot = selected === atk || selected === def;
grid.append( grid.append(
el( el('span', { class: `tchart__cell ${cellClass(m)}${hot ? ' is-hot' : ''}` }, cellText(m)),
'span',
{ class: `tchart__cell ${cellClass(m)}${hot ? ' is-hot' : ''}` },
cellText(m),
),
); );
} }
} }
@ -90,7 +101,11 @@ export async function TypeChartView() {
clear(summary); clear(summary);
if (!selected) { if (!selected) {
summary.append( summary.append(
el('p', { class: 'detail__muted' }, 'Tap a type on the edge to see everything it hits and everything that hits it.'), el(
'p',
{ class: 'detail__muted' },
'Tap a type on the edge to see everything it hits and everything that hits it.',
),
); );
return; return;
} }
@ -99,7 +114,12 @@ export async function TypeChartView() {
const def = defensiveMatchups([selected], gen); const def = defensiveMatchups([selected], gen);
const chipRow = (label, list) => const chipRow = (label, list) =>
list.length list.length
? el('div', { class: 'tchart__srow' }, el('span', { class: 'tchart__slabel' }, label), el('span', { class: 'matchups__types' }, ...list.map(TypeChip))) ? el(
'div',
{ class: 'tchart__srow' },
el('span', { class: 'tchart__slabel' }, label),
el('span', { class: 'matchups__types' }, ...list.map(TypeChip)),
)
: null; : null;
summary.append( summary.append(
el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — attacking`), el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — attacking`),

View File

@ -21,11 +21,7 @@ export default defineConfig({
// The bundled PokéAPI snapshot can be a few hundred KB. // The bundled PokéAPI snapshot can be a few hundred KB.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
}, },
includeAssets: [ includeAssets: ['icon.svg', 'favicon.svg', 'apple-touch-icon.png'],
'icon.svg',
'favicon.svg',
'apple-touch-icon.png',
],
manifest: { manifest: {
name: 'Pocketdex', name: 'Pocketdex',
short_name: 'Pocketdex', short_name: 'Pocketdex',