Compare commits

..

3 Commits

Author SHA1 Message Date
f71f1842cd Save import: trim helper text, put it on its own line
Button "Import from a game save" now sits on its own line with the
description "A .sav / .srm from a Gen 1–3 game." beneath it, instead of a
long inline sentence. The dialog already names the detected game and
offers merge vs replace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 10:05:19 -04:00
d275117569 Simplify the import dialog copy; spell it "color"
- The save-import dialog body is now just "N seen · N caught in this
  save." — the button labels carry the merge/replace meaning.
- "colour" → "color" throughout (the visible "Accent color" field, plus
  comments and README for consistency).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 10:01:54 -04:00
840031c48a Import: ask merge vs replace; make the feed progress ring a link
- Save import now opens a small choice dialog (new lib/dialog.js) instead
  of a confirm(): "Add to my Pokédex" (merge, keeps everything) or
  "Replace mine with this". Replace resets seen/caught to exactly the
  save but keeps favourites and notes (new selection.replaceFlags). The
  dialog also explains that caught state is shared across games, so an
  imported save only ever adds species that game has and they count
  toward the National Dex too — no per-game caught flag, by design.
- The dex-feed progress ring is now an <a href="#/progress"> with a
  hover/press nudge — tapping it opens the per-game breakdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 09:52:26 -04:00
9 changed files with 191 additions and 29 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 /
matchups / abilities / learnset / artwork for the chosen form. Purely
cosmetic variants (Unown's 27 letters, Vivillon's 19 patterns, Furfrou
trims, seasonal Deerling/Sawsbuck, Flabébé line colours, Alcremie's 62
trims, seasonal Deerling/Sawsbuck, Flabébé line colors, Alcremie's 62
decorations, Pikachu caps — 278 across 50 species) get a separate compact
"Appearance" dropdown that only swaps the sprite. Cards show a "+N forms"
badge.
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
Moves / Locations); coloured animated stat bars; defensive type matchups;
Moves / Locations); colored animated stat bars; defensive type matchups;
evolution chain re-parented to the selected game's generation; learnset with
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
TM number for the selected game; wild encounter locations.
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
applies Gen 1 / pre-Gen 6 rules.
- **Games** — overlay picker with stylised version-colour cover tiles,
- **Games** — overlay picker with stylised version-color cover tiles,
sub-dex switch, and an "All games" (National, no gen limits) option.
- **Search** — tabbed lookup: Pokémon, Moves, Items and Abilities, all
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
gen-aware).
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, text size, an in-app reduce-motion override (on top of the OS
color, text size, an in-app reduce-motion override (on top of the OS
preference, which is always respected too), sprite style, haptic
feedback on catch (Vibration API), which screen to land on at launch,
JSON export/import.

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
* gradient, a type-coloured spotlight behind an oversized sprite that lifts
* gradient, a type-colored spotlight behind an oversized sprite that lifts
* above the card, a number chip, and a big ghost number in the corner.
*/
export function Card(species, number, { spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {}) {

View File

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

View File

@ -1,7 +1,7 @@
/**
* Signature colours for each game, used to render a stylised "cover" tile in
* Signature colors 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
* colours = the paired versions; games without a listed entry fall back to
* colors = the paired versions; games without a listed entry fall back to
* a per-generation hue.
*/
const GAME = {

60
src/lib/dialog.js Normal file
View File

@ -0,0 +1,60 @@
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 colour by reading the --type-* CSS
// custom properties once and memoising. Type colours don't change between
// Resolve a Pokémon type to its hex color by reading the --type-* CSS
// 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',

View File

@ -60,6 +60,29 @@ 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. */
export function stats(speciesIds) {
const p = selection.get().pokemon;

View File

@ -233,6 +233,17 @@
width: 60px;
height: 60px;
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 {
width: 100%;
@ -636,7 +647,7 @@
text-transform: capitalize;
}
/* Cover tiles: a clean split of the two version colours, no gloss. */
/* Cover tiles: a clean split of the two version colors, no gloss. */
.gamecard--cover {
position: relative;
overflow: hidden;
@ -1594,6 +1605,16 @@
.settings__install {
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 {
display: flex;
flex-direction: column;
@ -3366,3 +3387,44 @@
animation-duration: 0.34s;
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,6 +1,7 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection, importFlags } from '../store/selection.js';
import { selection, importFlags, replaceFlags } from '../store/selection.js';
import { chooseDialog } from '../lib/dialog.js';
import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.js';
@ -50,7 +51,7 @@ export async function SettingsView() {
}),
),
);
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent color'), accentRow);
const fontSizeField = selectField('Text size', st.fontScale || 'default', [
['small', 'Small'],
@ -123,7 +124,7 @@ export async function SettingsView() {
storageNote.textContent = 'Storage estimate not available in this browser.';
}
// Import the Pokédex out of a Gen 3 GBA save (.sav / .srm).
// Import the Pokédex out of a Gen 1/2/3 game save (.sav / .srm).
const saveNote = el('p', { class: 'settings__note' });
const saveInput = el('input', {
type: 'file',
@ -133,21 +134,32 @@ export async function SettingsView() {
const file = e.target.files[0];
if (!file) return;
saveNote.textContent = 'Reading…';
let dex;
try {
const dex = parseSaveDex(await file.arrayBuffer());
const who = dex.trainer || dex.game;
if (
confirm(
`${dex.game}${dex.trainer ? `${dex.trainer}` : ''}\n${dex.seen.length} seen, ${dex.caught.length} caught.\n\nMerge into your Pokédex? Nothing is removed.`,
)
) {
importFlags({ seen: dex.seen, caught: dex.caught });
saveNote.textContent = `Merged ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
} else {
saveNote.textContent = '';
}
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 choice = await chooseDialog({
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`,
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`,
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 });
saveNote.textContent = `Added ${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 {
saveNote.textContent = '';
}
e.target.value = '';
},
@ -227,10 +239,10 @@ export async function SettingsView() {
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
),
el(
'p',
{ class: 'settings__note' },
'div',
{ class: 'settings__save' },
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'),
' — 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.',
el('p', { class: 'settings__note' }, 'A .sav / .srm from a Gen 13 game.'),
),
saveInput,
saveNote,