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

View File

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

View File

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

View File

@ -21,6 +21,8 @@ jobs:
- run: npm ci
- run: npm run format:check
# 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
# 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+**.
- There is **no lint or test command** and **no test suite**. The build
(`npm run build`) is the only automated check — it must pass. It runs
esbuild over every module, so it catches syntax and import errors.
- Automated checks (both run in CI, both must pass):
`npm run format:check` (Prettier) and `npm run build` (esbuild transforms
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.
## Project structure
| Path | What |
| --- | --- |
| `src/main.js` | boot: theme, nav, router, SW registration, install prompt |
| `src/router.js` | hash router; `routes` array; each view is `() => Promise<Node>` |
| `src/views/*.js` | one file per route (`DexGrid`, `PokemonDetail`, `TeamView`, …) |
| `src/components/*.js` | reusable DOM pieces (`Card`, `Sprite`, `TypeChip`, `CompareTable`, …) |
| `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/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 |
| `src/styles/tokens.css` | palette, type colours, the five themes |
| `src/styles/layout.css` | everything else |
| Path | What |
| ---------------------------- | --------------------------------------------------------------------------- |
| `src/main.js` | boot: theme, nav, router, SW registration, install prompt |
| `src/router.js` | hash router; `routes` array; each view is `() => Promise<Node>` |
| `src/views/*.js` | one file per route (`DexGrid`, `PokemonDetail`, `TeamView`, …) |
| `src/components/*.js` | reusable DOM pieces (`Card`, `Sprite`, `TypeChip`, `CompareTable`, …) |
| `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/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 |
| `src/styles/tokens.css` | palette, type colours, the five themes |
| `src/styles/layout.css` | everything else |
## Conventions
@ -60,8 +61,8 @@ npm run preview # serve the production build — REQUIRED to test the se
caught set.
- Respect `prefersReducedMotion()` (from `src/store/settings.js`) for any
animation.
- 2-space indent, semicolons, single quotes, trailing commas in multi-line
literals. Match the surrounding file; keep comments about *why*.
- Formatting is Prettier (`npm run format`). Otherwise match the
surrounding file; keep comments about _why_.
- No new runtime dependencies. Prefer adding to the snapshot over a new
runtime fetch.
@ -90,7 +91,8 @@ npm run preview # serve the production build — REQUIRED to test the se
## 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
update), a detail page (all tabs, form switch), and `npm run preview`
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
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
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
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
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
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
## Enforcement Responsibilities

View File

@ -54,18 +54,20 @@ See the **Project layout** and **How it works** sections in
## Code style
There's no linter or formatter config yet — **match the surrounding
code**:
Formatting is **Prettier** (`.prettierrc.json`). Run `npm run format`
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
multi-line literals (an `.editorconfig` covers whitespace).
- 2-space indent, semicolons, single quotes, 100-col width, trailing
commas (all handled by Prettier).
- Build DOM with the `el()` helper from `src/lib/dom.js`, not template
strings or `innerHTML` (except the deliberate `html:` prop for trusted
inline SVG).
- Stores: `set(fn)` **replaces** state — spread `...s` yourself. Read with
`get()`, react with `subscribe()`, and unsubscribe in the view's
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.
- Keep everything game-aware: if you touch typings, type effectiveness,
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.
- 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).
- Fill in the PR template. Screenshots or a short clip for anything
visual.
- 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

View File

@ -38,12 +38,12 @@ npm run snapshot # fetch the data snapshot from PokéAPI (one time)
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 dev` | Vite dev server with HMR. |
| `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 dev` | Vite dev server with HMR. |
| `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`. |
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
@ -54,6 +54,7 @@ the app has no data to show.
## Features
### Dex grid
Type-tinted cards — official artwork over a type-coloured spotlight, ghost
number, per-dex progress ring. Filter by name/number; chips 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).
### Detail page
Type-gradient hero with a shared-element morph from the tapped card, then a
tabbed sheet:
@ -87,9 +89,10 @@ compact **Appearance** dropdown, plus a per-species "form dex" checklist
tracked independently of the main caught flag.
### Team builder (`#/team`)
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
defensive matchup grid with a diverging resist ◀│▶ weak bar per type,
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).
### Search (`#/search`)
Tabbed lookup — Pokémon, Moves, Items, Abilities — all browsable offline
from the snapshot. Moves filter by type / damage class / "TMs only" /
"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.
### Progress by game (`#/progress`)
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
imported a save for); the rest sit behind a toggle. The National Dex row is
the union across all games.
### Shiny hunt tracker (`#/shiny`)
Per-hunt counters with method-aware odds (full odds, Masuda, SOS, chain
fishing, DexNav, radar, dynamax adventures, outbreaks, mass outbreaks) and
Shiny Charm, cumulative-probability readout, notes, and a "found it" that
marks the Pokémon caught.
### Save-file import
**Settings → Import from a game save.** Reads the game's own seen/owned
bitfields — not an approximation — from:
| Gen | Games | Format |
| --- | --- | --- |
| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) |
| 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 |
| 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 |
| Gen | Games | Format |
| --- | -------------------------------------------------- | -------------------------------------------------------- |
| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) |
| 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 |
| 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 |
Gen 13 are checksum-verified; Gen 45 offsets come from PKHeX and are
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.
### Settings
Theme (System / Light / Dark / Black OLED / Sepia) + accent colour; text
size; an in-app reduce-motion override (the OS preference is always
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.
### PWA
Workbox service worker: precache the app shell + snapshot; stale-while-
revalidate for PokéAPI JSON (30-day TTL); cache-first LRU for sprites;
in-app update toast. Installable with maskable icons, `beforeinstallprompt`
@ -169,12 +178,12 @@ Type chart.
### Data & storage
| Layer | Where | Notes |
| --- | --- | --- |
| 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. |
| 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. |
| Layer | Where | Notes |
| ---------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |
| 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. |
The list, search, sort, filters and game switching run **entirely off the
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
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.
Use GitHub's **private vulnerability reporting** (the *Report a
vulnerability* button under the repository's *Security* tab). Include what
Use GitHub's **private vulnerability reporting** (the _Report a
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
an acknowledgement as soon as possible.

View File

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

View File

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

17
package-lock.json generated
View File

@ -17,6 +17,7 @@
"workbox-window": "^7.4.1"
},
"devDependencies": {
"prettier": "3.9.6",
"vite": "^6.4.3",
"vite-plugin-pwa": "^1.3.0"
},
@ -4925,6 +4926,22 @@
"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": {
"version": "6.1.1",
"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",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"engines": {
"node": ">=20"
},
"license": "MIT",
"devDependencies": {
"prettier": "3.9.6",
"vite": "^6.4.3",
"vite-plugin-pwa": "^1.3.0"
},

View File

@ -111,9 +111,7 @@ async function isFresh() {
async function main() {
if (!FORCE && (await isFresh())) {
console.log(
`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`,
);
console.log(`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`);
return;
}
@ -170,15 +168,11 @@ async function main() {
pokedexKeys: data.pokedexes.map((x) => x.name),
});
});
versionGroups.sort(
(a, b) => a.generation - b.generation || a.key.localeCompare(b.key),
);
versionGroups.sort((a, b) => a.generation - b.generation || a.key.localeCompare(b.key));
// ---- Species: the data-rich part -----------------------------
const speciesIndex = await api('pokemon-species?limit=100000');
const ids = speciesIndex.results
.map((r) => idFromUrl(r.url))
.sort((a, b) => a - b);
const ids = speciesIndex.results.map((r) => idFromUrl(r.url)).sort((a, b) => a - b);
const statMap = (pk) => {
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 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}`;
console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`);
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
const [pk, sp] = await Promise.all([
api(`pokemon/${id}`),
api(`pokemon-species/${id}`),
]);
const [pk, sp] = await Promise.all([api(`pokemon/${id}`), api(`pokemon-species/${id}`)]);
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 baseStatKey = statKey(stats);
@ -226,7 +224,10 @@ async function main() {
continue;
}
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 entry = {
slug,
@ -296,7 +297,10 @@ async function main() {
.map((t) => t.type.name),
pastTypes: (pk.past_types || []).map((p) => ({
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,
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.
const versionToVg = {};
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 …`);
await mapLimit(species, CONCURRENCY, async (sp) => {
let enc;
@ -410,10 +416,7 @@ async function main() {
name: a.name,
generation: idFromUrl(a.generation.url),
isMainSeries: a.is_main_series,
effect:
(en && (en.short_effect || en.effect)) ||
(flavour && flavour.flavor_text) ||
'',
effect: (en && (en.short_effect || en.effect)) || (flavour && flavour.flavor_text) || '',
pokemon: [
...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
* 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 types = typesForGen(species, gen);
const mainType = types[0] || 'normal';
@ -61,7 +65,8 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
onclick: () => {
// Tag this sprite so the router's view transition morphs it into
// 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');
},
},
@ -85,7 +90,11 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
(() => {
const ex = versionGroup && versionGroup !== 'all' && species.exclusiveIn?.[versionGroup];
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;
})(),
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
* 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();
let pruned = false;
@ -95,9 +98,7 @@ export function EvolutionChain(chain, { style = 'default', versionGroup, genOf =
roots.forEach((r) => renderNode(r, 0));
if (pruned) {
wrap.append(
el('p', { class: 'evo__note' }, 'Cross-generation stages hidden for this game.'),
);
wrap.append(el('p', { class: 'evo__note' }, 'Cross-generation stages hidden for this game.'));
}
return wrap;
}

View File

@ -96,9 +96,7 @@ export async function openGameSheet() {
);
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
list.append(
el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`),
);
list.append(el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`));
const grid = el('div', { class: 'gsheet__grid' });
for (const vg of versionGroups) {
const active = vg.key === st.versionGroup;
@ -126,7 +124,11 @@ export async function openGameSheet() {
'span',
{ class: 'gamecard__foot' },
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) {
const list = moveData.machines || [];
if (!list.length) return null;
const hit =
list.find((x) => x.version_group.name === versionGroupKey) || list[list.length - 1];
const hit = list.find((x) => x.version_group.name === versionGroupKey) || list[list.length - 1];
try {
const mc = await getMachine(Number(hit.machine.url.replace(/\/$/, '').split('/').pop()));
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,
// not set per move.
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) {
@ -145,7 +152,11 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
const enrichers = [];
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 tmCell = method === 'machine' ? el('span', { class: 'moverow__tm' }) : null;
let bodyFilled = false;
@ -179,7 +190,9 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
fillBody(body, m.data, v);
await fillTm();
} 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.target?.name) bits.push(`Target: ${d.target.name.replace(/-/g, ' ')}`);
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?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`);
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 = [
{ 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: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') },
{ label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') },
@ -28,7 +34,11 @@ export function Nav() {
el('span', { class: 'nav__label' }, item.label),
];
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);
});
nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pocketdex'), ...links);

View File

@ -88,7 +88,11 @@ export async function openPokemonPicker(onPick, { closeAfterPick = false } = {})
'div',
{ class: 'gsheet__head' },
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);
panel.append(head, input, results);

View File

@ -40,7 +40,12 @@ export function ProgressRing() {
const value = el('span', { class: 'ring__value' }, num, tail);
const node = el(
'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,
el('div', { class: 'ring__label' }, value),
);

View File

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

View File

@ -2,8 +2,7 @@ import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
import { settings } from '../store/settings.js';
const bar = (w, h = 14) =>
el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` });
const bar = (w, h = 14) => el('span', { class: 'sk-bar', style: `width:${w};height:${h}px` });
const facts = () =>
el(
@ -43,11 +42,7 @@ export function detailSkeleton(match) {
el(
'div',
{ class: 'psheet' },
el(
'div',
{ class: 'psheet__tabs' },
...Array.from({ length: 5 }, () => bar('58px', 18)),
),
el('div', { class: 'psheet__tabs' }, ...Array.from({ length: 5 }, () => bar('58px', 18))),
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
* pixel sprites those return null and the caller falls back to HOME art.
*/
const REPO =
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions';
const REPO = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions';
const PATH_BY_VERSION_GROUP = {
'red-blue': 'generation-i/red-blue',
@ -26,8 +25,7 @@ const PATH_BY_VERSION_GROUP = {
'omega-ruby-alpha-sapphire': 'generation-vi/omegaruby-alphasapphire',
'sun-moon': 'generation-vii/ultra-sun-ultra-moon',
'ultra-sun-ultra-moon': 'generation-vii/ultra-sun-ultra-moon',
'brilliant-diamond-and-shining-pearl':
'generation-viii/brilliant-diamond-shining-pearl',
'brilliant-diamond-and-shining-pearl': 'generation-viii/brilliant-diamond-shining-pearl',
};
/** URL for a game-era pixel sprite, or null if that game has none. */

View File

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

View File

@ -5,9 +5,24 @@
* CHART[attacking][defending] holds only the non-1x multipliers.
*/
export const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark',
'steel', 'fairy',
'normal',
'fire',
'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
];
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 },
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 },
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 },
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 },
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 },
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 },
ghost: { normal: 0, psychic: 2, ghost: 2, dark: 0.5 },
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': [...] }
*/
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,
// Fairy before Gen 6.
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
* 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}`;
if (prefersReducedMotion() || duration <= 0 || from === 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.
*/
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;
const base = Math.floor((Math.floor((2 * level) / 5 + 2) * power * (atkStat / defStat)) / 50) + 2;
const stab = atkTypes.includes(moveType) ? 1.5 : 1;

View File

@ -32,7 +32,16 @@ const GB_CHARS = (() => {
t[0xa0 + i] = lo[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;
})();
@ -47,7 +56,11 @@ const GBA_CHARS = (() => {
t[0xd5 + i] = lo[i];
}
Object.assign(t, {
0xad: '.', 0xae: '-', 0xba: '/', 0xac: ',', 0xb8: '&',
0xad: '.',
0xae: '-',
0xba: '/',
0xac: ',',
0xb8: '&',
});
return t;
})();
@ -76,13 +89,21 @@ const sumBytes = (bytes, from, to) => {
* 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) {
if (bytes.length < 0x8000) return null;
const caught = bitsToList(bytes, G1.owned, 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;
return {
gen: 1,
@ -101,8 +122,26 @@ function tryGen1(bytes) {
* ------------------------------------------------------------------ */
const G2 = {
gs: { 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'] },
gs: {
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;
@ -145,7 +184,9 @@ const SECTION_SIZE = 4096;
const SECTIONS_PER_SLOT = 14;
const SLOT_SIZE = SECTION_SIZE * SECTIONS_PER_SLOT; // 57344
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_MAGIC = DEX_OFFSET + 2; // 0xDA = RSE national, 0xB9 = FRLG national
const G3_OWNED = DEX_OFFSET + 16;
@ -179,7 +220,10 @@ function tryGen3(bytes) {
if (bytes.length < SLOT_SIZE + SECTION_SIZE) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
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;
const slot = b.counter > a.counter ? b : a;
const s0 = slot.sections[0];
@ -222,8 +266,15 @@ function ndsNormalise(bytes) {
let hasMarker = false;
for (let i = 0; i <= tail.length - marker.length; i++) {
let ok = true;
for (let j = 0; j < marker.length; j++) if (tail[i + j] !== marker.charCodeAt(j)) { ok = false; break; }
if (ok) { hasMarker = true; break; }
for (let j = 0; j < marker.length; j++)
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);
}
@ -298,7 +349,12 @@ function readGen5Slot(bytes, slotBase, v) {
const caught = bitsToList(bytes, p + GEN5.caught, GEN5.species);
const seen = orSeen(bytes, p, GEN5.seen, GEN5.size, GEN5.species);
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) {

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
// finger is moving left — that scrolls such an element to the right.
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) {
const ox = getComputedStyle(n).overflowX;
if (ox === 'auto' || ox === 'scroll') {

View File

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

View File

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

View File

@ -18,10 +18,18 @@ import { prefersReducedMotion } from './store/settings.js';
const routes = [
{ 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: /^#\/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: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/types$/, view: () => TypeChartView() },

View File

@ -34,10 +34,7 @@ export function createStore(key, initial) {
return {
get: () => state,
set(patch) {
state =
typeof patch === 'function'
? patch(state)
: { ...state, ...patch };
state = typeof patch === 'function' ? patch(state) : { ...state, ...patch };
save();
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 bucket = { ...(s.games[vgKey] || {}) };
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 } };
});
}
@ -140,10 +141,7 @@ export function replaceFlags({ seen = [], caught = [], game } = {}) {
/** Aggregate seen/caught over a species list for one game (union when 'all'). */
export function stats(speciesIds, vgKey = curGame()) {
const get =
vgKey === 'all'
? unionEntry
: (id) => selection.get().games[vgKey]?.[id] || GBLANK;
const get = vgKey === 'all' ? unionEntry : (id) => selection.get().games[vgKey]?.[id] || GBLANK;
let seen = 0;
let caught = 0;
for (const id of speciesIds) {

View File

@ -51,13 +51,10 @@ export function applyTheme(state = settings.get()) {
if (meta) {
const dark =
DARKISH.has(theme) ||
((!theme || theme === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
((!theme || theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches);
meta.setAttribute(
'content',
dark
? '#0b0b0c'
: getComputedStyle(root).getPropertyValue('--accent').trim() || '#b3161a',
dark ? '#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. */
export function prefersReducedMotion() {
return (
!!settings.get().reduceMotion ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
!!settings.get().reduceMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}

View File

@ -14,7 +14,15 @@ export function addHunt({ speciesId, method, charm }) {
shinyHunts.set((s) => ({
...s,
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,
],
}));

View File

@ -41,5 +41,8 @@ export function clearTeam() {
}
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;
text-decoration: none;
line-height: 1;
transition: color 0.12s ease, background 0.12s ease;
transition:
color 0.12s ease,
background 0.12s ease;
}
.reflink:hover {
color: var(--text);
@ -226,24 +228,64 @@
background: var(--type-normal);
letter-spacing: 0.02em;
}
.type-chip[data-type="normal"] { background: var(--type-normal); }
.type-chip[data-type="fire"] { background: var(--type-fire); }
.type-chip[data-type="water"] { background: var(--type-water); }
.type-chip[data-type="electric"] { background: var(--type-electric); color: #3a3a1a; }
.type-chip[data-type="grass"] { background: var(--type-grass); }
.type-chip[data-type="ice"] { 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; }
.type-chip[data-type='normal'] {
background: var(--type-normal);
}
.type-chip[data-type='fire'] {
background: var(--type-fire);
}
.type-chip[data-type='water'] {
background: var(--type-water);
}
.type-chip[data-type='electric'] {
background: var(--type-electric);
color: #3a3a1a;
}
.type-chip[data-type='grass'] {
background: var(--type-grass);
}
.type-chip[data-type='ice'] {
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 ------------------------------------- */
.feed-head {
@ -477,13 +519,21 @@
box-shadow: var(--shadow);
content-visibility: auto;
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-delay: calc(var(--i, 0) * 18ms);
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.card:hover {
transform: translateY(-4px);
@ -532,11 +582,11 @@
object-fit: contain;
filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.28));
}
.card[data-sprite="default"] .card__art .sprite,
.card[data-sprite="game"] .card__art .sprite {
.card[data-sprite='default'] .card__art .sprite,
.card[data-sprite='game'] .card__art .sprite {
image-rendering: pixelated;
}
.card[data-sprite="game"] .card__art .sprite {
.card[data-sprite='game'] .card__art .sprite {
width: 92px;
height: 92px;
}
@ -548,7 +598,9 @@
padding: 5px;
border-radius: 12px;
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;
}
.card__body {
@ -606,15 +658,15 @@
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(3px);
}
.card__caught[aria-pressed="true"] {
.card__caught[aria-pressed='true'] {
background: var(--good);
color: #fff;
}
.card__caught[aria-pressed="true"]::before {
content: "✓";
.card__caught[aria-pressed='true']::before {
content: '✓';
}
.card__caught[aria-pressed="false"]::before {
content: "";
.card__caught[aria-pressed='false']::before {
content: '';
}
.card.is-caught {
border-color: color-mix(in srgb, var(--good) 45%, var(--border));
@ -677,7 +729,9 @@
cursor: pointer;
font: inherit;
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 {
transform: translateY(-3px);
@ -710,7 +764,7 @@
box-shadow: var(--shadow);
}
.gamecard--cover::after {
content: "";
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.28), transparent 55%);
@ -724,7 +778,9 @@
}
.gamecard--cover.is-active {
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 {
font-size: 1rem;
@ -774,10 +830,16 @@
inset: 0;
z-index: -2;
background:
radial-gradient(120% 80% at 80% 0%, color-mix(in srgb, var(--type-2) 55%, transparent), transparent 60%),
linear-gradient(160deg,
radial-gradient(
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) 62%, #000 38%) 100%);
color-mix(in srgb, var(--type-main) 62%, #000 38%) 100%
);
}
.phero__ball {
position: absolute;
@ -791,7 +853,7 @@
border-radius: 50%;
}
.phero__ball::before {
content: "";
content: '';
position: absolute;
left: -20px;
right: -20px;
@ -801,7 +863,7 @@
background: #fff;
}
.phero__ball::after {
content: "";
content: '';
position: absolute;
left: 50%;
top: 50%;
@ -912,15 +974,23 @@
padding: 14px;
border-radius: 18px;
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 {
image-rendering: pixelated;
filter: none;
}
@keyframes pop {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.phero__nav {
width: 44px;
@ -1036,9 +1106,15 @@
animation: pnote-fade 1.6s ease forwards;
}
@keyframes pnote-fade {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
0% {
opacity: 1;
}
70% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.pnote__input {
width: 100%;
@ -1092,8 +1168,14 @@
animation: fade 0.25s ease;
}
@keyframes fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
.ppanel__flavor {
font-size: 1rem;
@ -1131,8 +1213,14 @@
animation: flavorSlide 0.28s var(--ease-spring);
}
@keyframes flavorSlide {
from { opacity: 0; transform: translateX(14px); }
to { opacity: 1; transform: none; }
from {
opacity: 0;
transform: translateX(14px);
}
to {
opacity: 1;
transform: none;
}
}
.flavor__cap {
display: block;
@ -1221,12 +1309,24 @@
border-radius: 999px;
transition: width 0.7s var(--ease-spring);
}
.statbar__fill[data-stat="hp"] { background: #ff6b6b; }
.statbar__fill[data-stat="attack"] { background: #ffa94d; }
.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__fill[data-stat='hp'] {
background: #ff6b6b;
}
.statbar__fill[data-stat='attack'] {
background: #ffa94d;
}
.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 {
font-weight: 700;
border-top: 1px solid var(--border);
@ -1378,7 +1478,7 @@
color: var(--text-dim);
transition: transform 0.15s ease;
}
.moverow[aria-expanded="true"] .moverow__chev {
.moverow[aria-expanded='true'] .moverow__chev {
transform: rotate(180deg);
}
.movebody {
@ -1427,9 +1527,15 @@
letter-spacing: 0.03em;
text-transform: uppercase;
}
.moverow__cat[data-cat="physical"] { color: #e0762f; }
.moverow__cat[data-cat="special"] { color: #4b8fd6; }
.moverow__cat[data-cat="status"] { color: #8a8f98; }
.moverow__cat[data-cat='physical'] {
color: #e0762f;
}
.moverow__cat[data-cat='special'] {
color: #4b8fd6;
}
.moverow__cat[data-cat='status'] {
color: #8a8f98;
}
.moverow__stat {
font-variant-numeric: tabular-nums;
white-space: nowrap;
@ -1530,7 +1636,9 @@
box-shadow: var(--shadow);
text-decoration: none;
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 {
transform: translateX(3px);
@ -1704,7 +1812,9 @@
}
.swatch.is-on {
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 ------------------------------------------- */
@ -1802,7 +1912,7 @@
cursor: grabbing;
}
.gsheet__head::before {
content: "";
content: '';
position: absolute;
top: 6px;
left: 50%;
@ -1868,8 +1978,14 @@
animation: viewIn 0.26s ease both;
}
@keyframes viewIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
::view-transition-old(root),
@ -1894,8 +2010,12 @@
animation: shimmer 1.25s linear infinite;
}
@keyframes shimmer {
from { background-position: 220% 0; }
to { background-position: -120% 0; }
from {
background-position: 220% 0;
}
to {
background-position: -120% 0;
}
}
.sk-hero {
background: var(--surface-2);
@ -1963,7 +2083,7 @@
animation: viewIn 0.25s ease both;
}
.netpill::before {
content: "";
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
@ -2103,10 +2223,10 @@
transition: transform 0.15s ease;
margin-left: auto;
}
.feed-tools__toggle[aria-expanded="true"] .feed-tools__chev {
.feed-tools__toggle[aria-expanded='true'] .feed-tools__chev {
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);
min-width: 17px;
height: 17px;
@ -2314,11 +2434,24 @@
border-radius: 6px;
font-variant-numeric: tabular-nums;
}
.cov__cell.m4 { background: color-mix(in srgb, var(--danger) 60%, transparent); color: #fff; }
.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__cell.m4 {
background: color-mix(in srgb, var(--danger) 60%, transparent);
color: #fff;
}
.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 {
background: color-mix(in srgb, var(--danger) 12%, transparent);
box-shadow: inset 3px 0 0 var(--danger);
@ -2965,7 +3098,9 @@
image-rendering: pixelated;
filter: grayscale(1);
opacity: 0.6;
transition: filter 0.15s, opacity 0.15s;
transition:
filter 0.15s,
opacity 0.15s;
}
.formdex__tile.is-caught .formdex__sprite {
filter: none;
@ -2996,7 +3131,9 @@
text-align: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.15s, transform 0.15s;
transition:
opacity 0.15s,
transform 0.15s;
}
.formdex__tile.is-caught .formdex__check {
opacity: 1;
@ -3231,8 +3368,12 @@
border-radius: 999px;
vertical-align: middle;
}
.prog__key--caught { background: var(--good); }
.prog__key--seen { background: color-mix(in srgb, var(--accent) 35%, transparent); }
.prog__key--caught {
background: var(--good);
}
.prog__key--seen {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ---- Breeding helper --------------------------------------- */
.breedview__body {
@ -3403,15 +3544,44 @@
/* ================= Micro-interactions ============================ */
/* Tactile press feedback low specificity so any element's own
:active / transition wins if it has one. */
:where(.button, .seg__btn, .feed-chip, .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,
.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,
.flavor__pill, .lineup__add, .shiny__step, .prog__row, .tchart__row,
.tchart__col, .gamecard):active {
:where(
.button,
.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);
}
.card:active {
@ -3429,8 +3599,13 @@
animation: heroFloat 4.2s ease-in-out infinite;
}
@keyframes heroFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
/* Sliding thumb behind the active segment (JS sets --seg-i / --seg-n). */
@ -3446,7 +3621,9 @@
border-radius: 999px;
background: var(--surface);
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;
}
.seg__btn {
@ -3482,7 +3659,9 @@
padding: 20px;
transform: translateY(12px) scale(0.97);
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 {
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-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);
/* Pokémon type colors */
@ -62,7 +62,7 @@
}
}
:root[data-theme="dark"] {
:root[data-theme='dark'] {
--bg: #0b0b0c;
--surface: #17171a;
--surface-2: #202024;
@ -75,7 +75,7 @@
}
/* Pure black — for OLED screens. */
:root[data-theme="black"] {
:root[data-theme='black'] {
--bg: #000000;
--surface: #0b0b0c;
--surface-2: #17171a;
@ -89,7 +89,7 @@
}
/* Sepia — a warm, low-glare light theme. */
:root[data-theme="sepia"] {
:root[data-theme='sepia'] {
--bg: #f2e8d5;
--surface: #fbf4e4;
--surface-2: #e9dbbe;
@ -101,11 +101,26 @@
}
/* Accent overrides — layered on top of any theme. Red is the default. */
:root[data-accent="blue"] { --accent: #2f6fed; --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; }
:root[data-accent='blue'] {
--accent: #2f6fed;
--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;

View File

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

View File

@ -9,9 +9,15 @@ import { openPokemonPicker } from '../components/PokemonPicker.js';
import { segThumb } from '../lib/anim.js';
const EGG_LABEL = {
water1: 'Water 1', water2: 'Water 2', water3: 'Water 3',
ground: 'Field', humanshape: 'Human-Like', indeterminate: 'Amorphous',
plant: 'Grass', 'no-eggs': 'Undiscovered', ditto: 'Ditto',
water1: 'Water 1',
water2: 'Water 2',
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 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' });
@ -125,7 +138,12 @@ export async function BreedingView() {
),
},
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…',
);
body.append(slot);
@ -133,7 +151,13 @@ export async function BreedingView() {
const tGroups = new Set(target.eggGroups || []);
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;
}
const isDitto = tGroups.has('ditto');
@ -141,7 +165,9 @@ export async function BreedingView() {
let list;
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) {
list = snap.species.filter((s) => (s.eggGroups || []).includes('ditto'));
} else {
@ -168,12 +194,17 @@ export async function BreedingView() {
if (!prev) break;
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)
.then((pk) => {
const eggMoves = pk.moves.filter((m) =>
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);
@ -196,12 +227,20 @@ export async function BreedingView() {
'div',
{ class: 'breed__eggmove-list' },
...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(
el('p', { class: 'breed__note' }, note),

View File

@ -95,7 +95,11 @@ export async function CompareView() {
body.append(
mem.length >= 2
? 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',
{ class: 'view__header' },
el('h1', {}, 'Compare'),
el(
'p',
{},
vg ? `Types shown for ${vg.name}.` : 'Types shown for the newest games.',
),
el('p', {}, vg ? `Types shown for ${vg.name}.` : 'Types shown for the newest games.'),
),
slots,
body,

View File

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

View File

@ -86,7 +86,11 @@ export async function ItemDetail(id) {
}
if (shown.length > HOLDER_CAP) {
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}%)` : ''}`,
);
}
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?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`);
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) {
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',
{ class: 'view__header' },
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(
'div',

View File

@ -67,17 +67,13 @@ function typesForGeneration(pokemon, gen) {
.sort((a, b) => a._gen - b._gen);
const era = past.find((p) => p._gen >= gen);
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 = [
'hp',
'attack',
'defense',
'special-attack',
'special-defense',
'speed',
];
const STAT_NAMES = ['hp', 'attack', 'defense', 'special-attack', 'special-defense', 'speed'];
const NO_ENCOUNTER_DATA = new Set([
'scarlet-violet',
@ -106,10 +102,7 @@ export async function PokemonDetail(nationalId) {
let pokemon;
let species;
try {
[pokemon, species] = await Promise.all([
getPokemon(nationalId),
getSpecies(nationalId),
]);
[pokemon, species] = await Promise.all([getPokemon(nationalId), getSpecies(nationalId)]);
} catch (err) {
clear(view).append(
el(
@ -272,7 +265,13 @@ export async function PokemonDetail(nationalId) {
const on = inTeam(nationalId, teamForm());
const full = team.get().members.length >= MAX_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.disabled = !on && full;
}
@ -361,8 +360,10 @@ export async function PokemonDetail(nationalId) {
}),
);
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
? el(
'span',
@ -388,7 +389,10 @@ export async function PokemonDetail(nationalId) {
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
fact('Introduced', prettify(species.generation.name)),
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,
),
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)),
fact(
'Egg groups',
eggGroups.includes('Undiscovered') ? "Undiscovered (can't breed)" : eggGroups.join(', ') || '—',
eggGroups.includes('Undiscovered')
? "Undiscovered (can't breed)"
: eggGroups.join(', ') || '—',
),
fact(
'Egg cycles',
@ -492,13 +498,19 @@ export async function PokemonDetail(nationalId) {
);
} else if (NO_ENCOUNTER_DATA.has(st.versionGroup)) {
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 {
getEncounters(nationalId)
.then((data) => locPanel.replaceChildren(Locations(data, vg ? vg.versions : [])))
.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".
const DEFAULT_SLOT_NAME = { unown: 'A' };
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,
];
const countEl = el('span', { class: 'formdex__count' });
@ -582,7 +599,9 @@ export async function PokemonDetail(nationalId) {
// ---- Tabs -------------------------------------------
const TABS = [
{ 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: 'evo', label: 'Evolution', node: evoPanel },
{ id: 'moves', label: 'Moves', node: movesPanel },
@ -745,7 +764,11 @@ export async function PokemonDetail(nationalId) {
{ class: 'phero' },
el('div', { class: 'phero__bg' }),
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(
'div',
{ class: 'phero__top' },
@ -753,23 +776,34 @@ export async function PokemonDetail(nationalId) {
el('span', { class: 'phero__num' }, numLabel),
el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn),
),
el(
'div',
{ class: 'phero__head' },
heroName,
heroTypes,
),
el('div', { class: 'phero__head' }, heroName, heroTypes),
formBar,
cosmeticBar,
el(
'div',
{ class: 'phero__stage' },
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' }),
artHolder,
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' }),
),
),

View File

@ -53,7 +53,9 @@ export async function ProgressView() {
clear(list);
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;
// National total — caught in any game.
@ -115,7 +117,11 @@ export async function ProgressView() {
render();
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(
'header',
{ class: 'view__header' },

View File

@ -9,11 +9,31 @@ import { TypeChip } from '../components/TypeChip.js';
import { segThumb } from '../lib/anim.js';
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 TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
'normal',
'fire',
'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
];
const itemSprite = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
@ -30,7 +50,7 @@ const tmSortKey = (label) => {
if (!label) return 1e9;
const m = String(label).match(/^([A-Z]+)0*(\d+)$/);
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() {
@ -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 note = el('p', { class: 'search__note' });
@ -119,9 +146,7 @@ export async function SearchView() {
const vg = settings.get().versionGroup;
const gameHasKind = (kind) =>
snap.moves.some((m) =>
m.machines.some(
(x) => (vg === 'all' || x.vg === vg) && (x.tm || '').startsWith(kind),
),
m.machines.some((x) => (vg === 'all' || x.vg === vg) && (x.tm || '').startsWith(kind)),
);
const hasTm = gameHasKind('TM');
const hasHm = gameHasKind('HM'); // false for Gen 8+ games, which dropped HMs
@ -137,13 +162,23 @@ export async function SearchView() {
filters.append(
...[
pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => {
ui.set({ mvType: v });
run();
}),
pill(
'mvType',
[['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])],
ui.get().mvType,
(v) => {
ui.set({ mvType: v });
run();
},
),
pill(
'mvClass',
[['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']],
[
['', 'Any category'],
['physical', 'Physical'],
['special', 'Special'],
['status', 'Status'],
],
ui.get().mvClass,
(v) => {
ui.set({ mvClass: v });
@ -181,16 +216,28 @@ export async function SearchView() {
run();
},
),
pill('itSort', [['name', 'AZ'], ['id', 'Dex order']], ui.get().itSort, (v) => {
ui.set({ itSort: v });
run();
}),
pill(
'itSort',
[
['name', 'AZ'],
['id', 'Dex order'],
],
ui.get().itSort,
(v) => {
ui.set({ itSort: v });
run();
},
),
);
} else if (tab === 'abilities') {
filters.append(
pill(
'abSort',
[['name', 'AZ'], ['count', 'Most Pokémon'], ['gen', 'Newest']],
[
['name', 'AZ'],
['count', 'Most Pokémon'],
['gen', 'Newest'],
],
ui.get().abSort,
(v) => {
ui.set({ abSort: v });
@ -228,12 +275,11 @@ export async function SearchView() {
const matches = snap.species
.filter(
(s) =>
loose(s.name).includes(q) ||
String(s.id) === q ||
(s.types || []).some((t) => t === q),
loose(s.name).includes(q) || String(s.id) === q || (s.types || []).some((t) => t === q),
)
.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) {
const e = entry(s.id);
results.append(
@ -289,8 +335,10 @@ export async function SearchView() {
});
list.sort((a, b) => {
if (sort === 'power') 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 === 'power')
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 === 'tm') {
return (
@ -318,14 +366,24 @@ export async function SearchView() {
'span',
{ class: 'search__types' },
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` : ''),
),
),
);
}
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'}`;
const shown = list.slice(0, 400);
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 });
results.append(
el(
@ -356,7 +419,13 @@ export async function SearchView() {
);
}
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),
);
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);
return a.name.localeCompare(b.name);
});

View File

@ -14,16 +14,21 @@ export async function SettingsView() {
const view = el('section', { class: 'view settings' });
const st = settings.get();
const themeField = selectField('Theme', st.theme, [
['system', 'System'],
['light', 'Light'],
['dark', 'Dark'],
['black', 'Black (OLED)'],
['sepia', 'Sepia'],
], (v) => {
settings.set({ theme: v });
applyTheme();
});
const themeField = selectField(
'Theme',
st.theme,
[
['system', 'System'],
['light', 'Light'],
['dark', 'Dark'],
['black', 'Black (OLED)'],
['sepia', 'Sepia'],
],
(v) => {
settings.set({ theme: v });
applyTheme();
},
);
const ACCENTS = [
['red', '#b3161a'],
@ -55,16 +60,23 @@ export async function SettingsView() {
);
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent color'), accentRow);
const fontSizeField = selectField('Text size', st.fontScale || 'default', [
['small', 'Small'],
['default', 'Default'],
['large', 'Large'],
], (v) => {
settings.set({ fontScale: v });
applyTheme();
});
const fontSizeField = selectField(
'Text size',
st.fontScale || 'default',
[
['small', 'Small'],
['default', 'Default'],
['large', 'Large'],
],
(v) => {
settings.set({ fontScale: v });
applyTheme();
},
);
const reduceMotionField = el('label', { class: 'field field--check' },
const reduceMotionField = el(
'label',
{ class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: !!st.reduceMotion,
@ -76,14 +88,21 @@ export async function SettingsView() {
'Reduce motion',
);
const spriteField = selectField('Sprite style', st.spriteStyle, [
['default', 'Pixel (modern)'],
['game', 'Game era (pixel art from the selected game)'],
['official', 'Official artwork'],
['home', 'Pokémon HOME'],
], (v) => settings.set({ spriteStyle: v }));
const spriteField = selectField(
'Sprite style',
st.spriteStyle,
[
['default', 'Pixel (modern)'],
['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', {
type: 'checkbox',
checked: st.showShiny,
@ -92,7 +111,9 @@ export async function SettingsView() {
'Default detail view to shiny sprites',
);
const teamNavField = el('label', { class: 'field field--check' },
const teamNavField = el(
'label',
{ class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: st.showTeamNav !== false,
@ -101,7 +122,9 @@ export async function SettingsView() {
'Show Team in the navigation bar',
);
const hapticsField = el('label', { class: 'field field--check' },
const hapticsField = el(
'label',
{ class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: st.haptics !== false,
@ -110,11 +133,16 @@ export async function SettingsView() {
'Haptic feedback on catch',
);
const defaultRouteField = selectField('Landing screen', st.defaultRoute || 'dex', [
['dex', 'Dex'],
['team', 'Team'],
['search', 'Search'],
], (v) => settings.set({ defaultRoute: v }));
const defaultRouteField = selectField(
'Landing screen',
st.defaultRoute || 'dex',
[
['dex', 'Dex'],
['team', 'Team'],
['search', 'Search'],
],
(v) => settings.set({ defaultRoute: v }),
);
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
if (navigator.storage?.estimate) {
@ -157,7 +185,11 @@ export async function SettingsView() {
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`,
choices: [
{ 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' },
],
});
@ -253,50 +285,80 @@ export async function SettingsView() {
el(
'div',
{ 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.'),
),
saveInput,
saveNote,
storageNote,
el('div', { class: 'settings__actions' },
el('button', {
class: 'button',
type: 'button',
onclick: () => exportData(),
}, 'Export backup'),
el('button', {
class: 'button',
type: 'button',
onclick: () => importInput.click(),
}, 'Import backup'),
el(
'div',
{ class: 'settings__actions' },
el(
'button',
{
class: 'button',
type: 'button',
onclick: () => exportData(),
},
'Export backup',
),
el(
'button',
{
class: 'button',
type: 'button',
onclick: () => importInput.click(),
},
'Import backup',
),
importInput,
el('button', {
class: 'button button--danger',
type: 'button',
onclick: () => {
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
selection.replace({ version: 3, pokemon: {}, games: {} });
}
el(
'button',
{
class: 'button button--danger',
type: 'button',
onclick: () => {
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
selection.replace({ version: 3, pokemon: {}, games: {} });
}
},
},
}, 'Clear tracking data'),
el('button', {
class: 'button button--danger',
type: 'button',
onclick: async () => {
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 tracking data',
),
el(
'button',
{
class: 'button button--danger',
type: 'button',
onclick: async () => {
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('p', { class: 'settings__note' },
el(
'p',
{ class: 'settings__note' },
'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.',
),
);
@ -311,9 +373,7 @@ function selectField(label, value, options, onChange) {
const select = el(
'select',
{ onchange: (e) => onChange(e.target.value) },
...options.map(([v, text]) =>
el('option', { value: v, selected: v === value }, text),
),
...options.map(([v, text]) => el('option', { value: v, selected: v === value }, text)),
);
return el('label', { class: 'field' }, el('span', {}, label), select);
}

View File

@ -71,13 +71,28 @@ export async function ShinyView() {
'span',
{},
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(
'div',
{ 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,
el(
'button',
@ -137,13 +152,25 @@ export async function ShinyView() {
}
function chooseMethod(done) {
const backdrop = el('div', { 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 backdrop = el('div', {
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 panel = el(
'div',
{ 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,
el('label', { class: 'lookup__check', style: 'margin-top:12px' }, charmChk, 'Shiny Charm'),
el(
@ -173,17 +200,30 @@ export async function ShinyView() {
for (const h of hunts) list.append(huntCard(h));
}
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(
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(
'header',
{ class: 'view__header' },
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,
);

View File

@ -16,12 +16,26 @@ import { openPokemonPicker } from '../components/PokemonPicker.js';
import { segThumb } from '../lib/anim.js';
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) =>
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 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() {
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 body = el('div', { class: 'teamview__body' });
@ -75,9 +96,24 @@ export async function TeamView() {
el(
'nav',
{ class: 'teamview__refs', 'aria-label': 'Reference tools' },
el('a', { 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'),
el(
'a',
{ 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,
@ -213,9 +249,7 @@ export async function TeamView() {
const threats = analysis
.filter((a) => a.weakN >= 2 || (a.weakN >= 1 && a.resist === 0))
.sort((a, b) => b.weakN - a.weakN || a.resist - b.resist);
const unresisted = analysis
.filter((a) => a.weakN === 0 && a.resist === 0)
.map((a) => a.type);
const unresisted = analysis.filter((a) => a.weakN === 0 && a.resist === 0).map((a) => a.type);
const weakSpots = threats.length
? el(
@ -282,7 +316,11 @@ export async function TeamView() {
el(
'span',
{ 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(
@ -306,7 +344,9 @@ export async function TeamView() {
.slice()
.sort(
(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)),
)[0];
@ -449,7 +489,11 @@ export async function TeamView() {
Sprite(sp.spriteId ?? sp.id, { style, size: 48, alt: 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() {
@ -482,7 +526,13 @@ export async function TeamView() {
),
)
: 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');
@ -550,12 +600,18 @@ export async function TeamView() {
const v = forGeneration(raw, gen, genOfVg);
moveNote.textContent = `${prettify(v.type)} · ${v.damage_class ? prettify(v.damage_class) : 'Status'}${v.power ? ` · ${v.power} power` : ''}`;
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;
}
if (!v.damage_class || v.damage_class === 'status' || !v.power) {
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;
}
@ -612,13 +668,23 @@ export async function TeamView() {
el(
'p',
{ 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(
'div',
{ class: 'calc__badges' },
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';
const ABBR = {
normal: 'NOR', fire: 'FIR', 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',
normal: 'NOR',
fire: 'FIR',
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 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 hot = selected === atk || selected === def;
grid.append(
el(
'span',
{ class: `tchart__cell ${cellClass(m)}${hot ? ' is-hot' : ''}` },
cellText(m),
),
el('span', { class: `tchart__cell ${cellClass(m)}${hot ? ' is-hot' : ''}` }, cellText(m)),
);
}
}
@ -90,7 +101,11 @@ export async function TypeChartView() {
clear(summary);
if (!selected) {
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;
}
@ -99,7 +114,12 @@ export async function TypeChartView() {
const def = defensiveMatchups([selected], gen);
const chipRow = (label, list) =>
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;
summary.append(
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.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
includeAssets: [
'icon.svg',
'favicon.svg',
'apple-touch-icon.png',
],
includeAssets: ['icon.svg', 'favicon.svg', 'apple-touch-icon.png'],
manifest: {
name: 'Pocketdex',
short_name: 'Pocketdex',