Compare commits

..

No commits in common. "f71f1842cd02fea031dc44ceeeeb4377e6d9e020" and "78df1a32e85fce56b4494a077aca88e1e224dd16" have entirely different histories.

9 changed files with 29 additions and 191 deletions

View File

@ -92,19 +92,19 @@ Working app with a type-themed UI:
snapshot; a pill switcher on the detail page rebuilds types / stats / snapshot; a pill switcher on the detail page rebuilds types / stats /
matchups / abilities / learnset / artwork for the chosen form. Purely matchups / abilities / learnset / artwork for the chosen form. Purely
cosmetic variants (Unown's 27 letters, Vivillon's 19 patterns, Furfrou cosmetic variants (Unown's 27 letters, Vivillon's 19 patterns, Furfrou
trims, seasonal Deerling/Sawsbuck, Flabébé line colors, Alcremie's 62 trims, seasonal Deerling/Sawsbuck, Flabébé line colours, Alcremie's 62
decorations, Pikachu caps — 278 across 50 species) get a separate compact decorations, Pikachu caps — 278 across 50 species) get a separate compact
"Appearance" dropdown that only swaps the sprite. Cards show a "+N forms" "Appearance" dropdown that only swaps the sprite. Cards show a "+N forms"
badge. badge.
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution / - **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
Moves / Locations); colored animated stat bars; defensive type matchups; Moves / Locations); coloured animated stat bars; defensive type matchups;
evolution chain re-parented to the selected game's generation; learnset with evolution chain re-parented to the selected game's generation; learnset with
per-move power/type/accuracy/PP/effect (era-accurate via `past_values`, per-move power/type/accuracy/PP/effect (era-accurate via `past_values`,
incl. pre-Gen-4 physical/special-by-type), the TM/HM group ordered by incl. pre-Gen-4 physical/special-by-type), the TM/HM group ordered by
TM number for the selected game; wild encounter locations. TM number for the selected game; wild encounter locations.
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart - **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
applies Gen 1 / pre-Gen 6 rules. applies Gen 1 / pre-Gen 6 rules.
- **Games** — overlay picker with stylised version-color cover tiles, - **Games** — overlay picker with stylised version-colour cover tiles,
sub-dex switch, and an "All games" (National, no gen limits) option. sub-dex switch, and an "All games" (National, no gen limits) option.
- **Search** — tabbed lookup: Pokémon, Moves, Items and Abilities, all - **Search** — tabbed lookup: Pokémon, Moves, Items and Abilities, all
browsable offline from the snapshot. Moves filter by type / damage class browsable offline from the snapshot. Moves filter by type / damage class
@ -133,7 +133,7 @@ Working app with a type-themed UI:
chart (tap a type for its offensive + defensive breakdown; both chart (tap a type for its offensive + defensive breakdown; both
gen-aware). gen-aware).
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent - **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
color, text size, an in-app reduce-motion override (on top of the OS colour, text size, an in-app reduce-motion override (on top of the OS
preference, which is always respected too), sprite style, haptic preference, which is always respected too), sprite style, haptic
feedback on catch (Vibration API), which screen to land on at launch, feedback on catch (Vibration API), which screen to land on at launch,
JSON export/import. JSON export/import.

View File

@ -10,7 +10,7 @@ import { prettify } from '../data/pokedex-resolver.js';
/** /**
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down * One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
* gradient, a type-colored spotlight behind an oversized sprite that lifts * gradient, a type-coloured spotlight behind an oversized sprite that lifts
* above the card, a number chip, and a big ghost number in the corner. * 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 } = {}) {

View File

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

View File

@ -1,7 +1,7 @@
/** /**
* Signature colors for each game, used to render a stylised "cover" tile in * Signature colours for each game, used to render a stylised "cover" tile in
* the game picker (real box art is Nintendo's and not in the API). Two * the game picker (real box art is Nintendo's and not in the API). Two
* colors = the paired versions; games without a listed entry fall back to * colours = the paired versions; games without a listed entry fall back to
* a per-generation hue. * a per-generation hue.
*/ */
const GAME = { const GAME = {

View File

@ -1,60 +0,0 @@
import { el } from './dom.js';
/**
* A small centered modal with one or more choices. Resolves to the chosen
* option's `key`, or `null` if dismissed (backdrop, Escape, or a choice
* whose key is null).
*
* const pick = await chooseDialog({
* title: 'Import save',
* body: '84 seen, 40 caught.',
* choices: [
* { key: 'merge', label: 'Add to my Pokédex' },
* { key: 'replace', label: 'Replace mine', class: 'button--danger' },
* { key: null, label: 'Cancel', class: 'button--ghost' },
* ],
* });
*/
export function chooseDialog({ title, body, choices }) {
return new Promise((resolve) => {
const backdrop = el('div', {
class: 'sheet-backdrop dialog-backdrop is-open',
onclick: (e) => {
if (e.target === backdrop) done(null);
},
});
const panel = el(
'div',
{ class: 'dialog', role: 'dialog', 'aria-modal': 'true' },
el('h2', { class: 'dialog__title' }, title),
body ? el('p', { class: 'dialog__body' }, body) : null,
el(
'div',
{ class: 'dialog__actions' },
...choices.map((c) =>
el(
'button',
{
class: `button ${c.class || ''}`.trim(),
type: 'button',
onclick: () => done(c.key ?? null),
},
c.label,
),
),
),
);
const onKey = (e) => {
if (e.key === 'Escape') done(null);
};
function done(key) {
document.removeEventListener('keydown', onKey);
backdrop.remove();
resolve(key);
}
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);
requestAnimationFrame(() => panel.querySelector('.button')?.focus());
});
}

View File

@ -1,5 +1,5 @@
// Resolve a Pokémon type to its hex color by reading the --type-* CSS // Resolve a Pokémon type to its hex colour by reading the --type-* CSS
// custom properties once and memoising. Type colors do not change between // custom properties once and memoising. Type colours don't change between
// light and dark, so the cache is safe for the session. // light and dark, so the cache is safe for the session.
const TYPES = [ const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', 'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',

View File

@ -60,29 +60,6 @@ export function importFlags({ seen = [], caught = [] } = {}) {
}); });
} }
/**
* Set seen/caught to exactly the given lists favourites and notes are
* kept, everything else's seen/caught is cleared. For "replace with this
* save" imports.
*/
export function replaceFlags({ seen = [], caught = [] } = {}) {
selection.set((s) => {
const stamp = new Date().toISOString();
const pokemon = {};
for (const [id, e] of Object.entries(s.pokemon)) {
if (e.favorite || (e.note && e.note.trim())) {
pokemon[id] = { seen: false, caught: false, favorite: !!e.favorite, note: e.note || '', updatedAt: e.updatedAt || stamp };
}
}
const bump = (id, patch) => {
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
};
for (const id of seen) bump(id, { seen: true });
for (const id of caught) bump(id, { seen: true, caught: true });
return { ...s, pokemon };
});
}
/** Aggregate seen/caught counts over an arbitrary list of national ids. */ /** Aggregate seen/caught counts over an arbitrary list of national ids. */
export function stats(speciesIds) { export function stats(speciesIds) {
const p = selection.get().pokemon; const p = selection.get().pokemon;

View File

@ -233,17 +233,6 @@
width: 60px; width: 60px;
height: 60px; height: 60px;
flex: none; flex: none;
display: block;
text-decoration: none;
color: inherit;
border-radius: 50%;
transition: transform 0.12s var(--ease-spring);
}
.ring:hover {
transform: scale(1.06);
}
.ring:active {
transform: scale(0.95);
} }
.ring__svg { .ring__svg {
width: 100%; width: 100%;
@ -647,7 +636,7 @@
text-transform: capitalize; text-transform: capitalize;
} }
/* Cover tiles: a clean split of the two version colors, no gloss. */ /* Cover tiles: a clean split of the two version colours, no gloss. */
.gamecard--cover { .gamecard--cover {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -1605,16 +1594,6 @@
.settings__install { .settings__install {
margin: 10px 0 0; margin: 10px 0 0;
} }
.settings__save {
margin: 10px 0 0;
}
.settings__save .button {
display: inline-flex;
}
.settings__save .settings__note {
margin: 6px 0 0;
font-size: 0.82rem;
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -3387,44 +3366,3 @@
animation-duration: 0.34s; animation-duration: 0.34s;
animation-timing-function: var(--ease-spring); animation-timing-function: var(--ease-spring);
} }
/* ---- Centered modal dialog ---------------------------------- */
.dialog-backdrop {
align-items: center;
padding: 20px;
}
.dialog {
width: 100%;
max-width: 420px;
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 20px;
transform: translateY(12px) scale(0.97);
opacity: 0;
transition: transform 0.2s var(--ease-spring), opacity 0.2s ease;
}
.dialog-backdrop.is-open .dialog {
transform: none;
opacity: 1;
}
.dialog__title {
margin: 0;
font-size: 1.1rem;
}
.dialog__body {
margin: 8px 0 0;
font-size: 0.9rem;
color: var(--text-dim);
line-height: 1.5;
}
.dialog__actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 18px;
}
.dialog__actions .button {
width: 100%;
justify-content: center;
}

View File

@ -1,7 +1,6 @@
import { el, onTeardown } from '../lib/dom.js'; import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js'; import { settings, applyTheme } from '../store/settings.js';
import { selection, importFlags, replaceFlags } from '../store/selection.js'; import { selection, importFlags } from '../store/selection.js';
import { chooseDialog } from '../lib/dialog.js';
import { team } from '../store/team.js'; import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js'; import { formTracking } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.js'; import { shinyHunts } from '../store/shinyHunts.js';
@ -51,7 +50,7 @@ export async function SettingsView() {
}), }),
), ),
); );
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent color'), accentRow); const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
const fontSizeField = selectField('Text size', st.fontScale || 'default', [ const fontSizeField = selectField('Text size', st.fontScale || 'default', [
['small', 'Small'], ['small', 'Small'],
@ -124,7 +123,7 @@ export async function SettingsView() {
storageNote.textContent = 'Storage estimate not available in this browser.'; storageNote.textContent = 'Storage estimate not available in this browser.';
} }
// Import the Pokédex out of a Gen 1/2/3 game save (.sav / .srm). // Import the Pokédex out of a Gen 3 GBA save (.sav / .srm).
const saveNote = el('p', { class: 'settings__note' }); const saveNote = el('p', { class: 'settings__note' });
const saveInput = el('input', { const saveInput = el('input', {
type: 'file', type: 'file',
@ -134,33 +133,22 @@ export async function SettingsView() {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
saveNote.textContent = 'Reading…'; saveNote.textContent = 'Reading…';
let dex;
try { try {
dex = parseSaveDex(await file.arrayBuffer()); const dex = parseSaveDex(await file.arrayBuffer());
} catch (err) {
saveNote.textContent = err.message || 'Could not read that save file.';
e.target.value = '';
return;
}
const who = dex.trainer || dex.game; const who = dex.trainer || dex.game;
const choice = await chooseDialog({ if (
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`, confirm(
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`, `${dex.game}${dex.trainer ? `${dex.trainer}` : ''}\n${dex.seen.length} seen, ${dex.caught.length} caught.\n\nMerge into your Pokédex? Nothing is removed.`,
choices: [ )
{ key: 'merge', label: 'Add to my Pokédex' }, ) {
{ key: 'replace', label: 'Replace mine with this', class: 'button--danger' },
{ key: null, label: 'Cancel', class: 'button--ghost' },
],
});
if (choice === 'merge') {
importFlags({ seen: dex.seen, caught: dex.caught }); importFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`; saveNote.textContent = `Merged ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
} else if (choice === 'replace') {
replaceFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Replaced tracking with ${who}'s Pokédex (favourites and notes kept).`;
} else { } else {
saveNote.textContent = ''; saveNote.textContent = '';
} }
} catch (err) {
saveNote.textContent = err.message || 'Could not read that save file.';
}
e.target.value = ''; e.target.value = '';
}, },
}); });
@ -239,10 +227,10 @@ export async function SettingsView() {
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'), el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
), ),
el( el(
'div', 'p',
{ class: 'settings__save' }, { class: 'settings__note' },
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 from a Gen 13 game.'), ' — a .sav / .srm from Gen 1 (R/B/Y), Gen 2 (G/S/C) or Gen 3 (R/S/E, FR/LG). Reads the games own Pokédex; merges seen/caught, removes nothing.',
), ),
saveInput, saveInput,
saveNote, saveNote,