Add a notes UI for the per-Pokémon note field

The selection store already had a note field and setNote() (from the
JSON export/import work) but nothing in the UI ever wrote to it. Adds:

- PokemonDetail: an auto-resizing textarea under the Seen/Caught/Team
  row. Saves debounced (500ms) as you type, immediately on blur, and
  flushes any unsaved keystrokes on teardown so navigating away right
  after typing never loses them. A brief "Saved" flash confirms the
  write.
- Card: a small 📝 mark on any card whose Pokémon has a note.
- DexGrid: a "Notes" filter chip (same pattern as Caught/Favorites) to
  browse everything you've annotated.

Notes already ride along in the existing settings/selection JSON
export-import, so no changes needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-04 12:24:22 -04:00
parent c9807127d6
commit 1711249dae
5 changed files with 130 additions and 3 deletions

View File

@ -73,7 +73,12 @@ Working app with a type-themed UI:
per-dex progress, name/number filter, caught/favorite filters, plus a per-dex progress, name/number filter, caught/favorite filters, plus a
filter drawer: type, generation, ability, egg group, minimum BST, and filter drawer: type, generation, ability, egg group, minimum BST, and
"fully evolved only" (derived client-side from each species' evolves-from "fully evolved only" (derived client-side from each species' evolves-from
link — no extra fetches). link — no extra fetches). A "Notes" chip filters to Pokémon with a note;
cards with one get a small 📝 mark.
- **Notes** — a free-text note per Pokémon on its detail page (trade plans,
where you caught it, anything), autosaving as you type and flushing
immediately on blur or navigation so a quick tab-away never drops a
keystroke. Rides along in the existing JSON export/import.
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the - **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
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

View File

@ -49,6 +49,9 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
}, },
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num), el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
el('span', { class: 'card__spot', 'aria-hidden': 'true' }), el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
state.note && state.note.trim()
? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝')
: null,
el( el(
'div', 'div',
{ class: 'card__art' }, { class: 'card__art' },

View File

@ -559,6 +559,20 @@
.card.is-caught { .card.is-caught {
border-color: color-mix(in srgb, var(--good) 45%, var(--border)); border-color: color-mix(in srgb, var(--good) 45%, var(--border));
} }
.card__notemark {
position: absolute;
top: 10px;
right: 10px;
z-index: 3;
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(3px);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.card { .card {
animation: none; animation: none;
@ -936,6 +950,55 @@
border-color: #fff; border-color: #fff;
color: #ff4d6d; color: #ff4d6d;
} }
.pnote {
margin: 10px 0 0;
}
.pnote__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
margin-bottom: 4px;
}
.pnote__status {
font-size: 0.68rem;
font-weight: 600;
color: var(--good);
text-transform: none;
letter-spacing: normal;
opacity: 0;
}
.pnote__status.is-visible {
animation: pnote-fade 1.6s ease forwards;
}
@keyframes pnote-fade {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
.pnote__input {
width: 100%;
min-height: 2.2em;
max-height: 12em;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 0.85rem;
line-height: 1.4;
resize: none;
overflow: hidden;
}
.pnote__input:focus {
outline: none;
border-color: var(--type-main, var(--accent));
}
.psheet__tabs { .psheet__tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;

View File

@ -20,6 +20,7 @@ const FILTERS = [
{ key: 'missing', label: 'Missing', test: (e) => !e.caught }, { key: 'missing', label: 'Missing', test: (e) => !e.caught },
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite }, { key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical }, { key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
{ key: 'notes', label: 'Notes', test: (e) => !!(e.note && e.note.trim()) },
]; ];
const SORTS = [ const SORTS = [
@ -407,13 +408,15 @@ export async function DexGrid() {
let caught = 0; let caught = 0;
let favorite = 0; let favorite = 0;
let legendary = 0; let legendary = 0;
let notes = 0;
for (const { species } of rows) { for (const { species } of rows) {
const e = p[species.id]; const e = p[species.id];
if (e?.caught) caught++; if (e?.caught) caught++;
if (e?.favorite) favorite++; if (e?.favorite) favorite++;
if (species.isLegendary || species.isMythical) legendary++; if (species.isLegendary || species.isMythical) legendary++;
if (e?.note && e.note.trim()) notes++;
} }
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary }; return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary, notes };
} }
function refreshMeta() { function refreshMeta() {

View File

@ -5,7 +5,7 @@ import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js'; import { ui } from '../store/ui.js';
import { entry, toggle } from '../store/selection.js'; import { entry, toggle, setNote } from '../store/selection.js';
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js'; import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
import { isFormCaught, toggleFormCaught } from '../store/formTracking.js'; import { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
import { Sprite, spriteUrl } from '../components/Sprite.js'; import { Sprite, spriteUrl } from '../components/Sprite.js';
@ -280,6 +280,57 @@ export async function PokemonDetail(nationalId) {
} }
syncTrack(); syncTrack();
// ---- Personal note ------------------------------------------------
// Autosaves (debounced) as you type, and flushes immediately on blur or
// when leaving the page so a quick navigation never drops a keystroke.
const noteStatus = el('span', { class: 'pnote__status', 'aria-live': 'polite' });
let noteTimer = null;
let noteSaved = entry(nationalId).note || '';
function autosize(ta) {
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight}px`;
}
function flashSaved() {
noteStatus.textContent = 'Saved';
noteStatus.classList.remove('is-visible');
void noteStatus.offsetWidth; // restart the fade
noteStatus.classList.add('is-visible');
}
function saveNote(value) {
clearTimeout(noteTimer);
if (value === noteSaved) return;
noteSaved = value;
setNote(nationalId, value);
flashSaved();
}
const noteInput = el('textarea', {
class: 'pnote__input',
placeholder: 'Add a note — trade plans, where you caught it, anything…',
rows: 1,
oninput: (e) => {
autosize(e.target);
clearTimeout(noteTimer);
noteTimer = setTimeout(() => saveNote(e.target.value), 500);
},
onfocus: (e) => autosize(e.target),
onblur: (e) => saveNote(e.target.value),
});
noteInput.value = noteSaved;
// The textarea isn't attached (let alone laid out) yet when it's built —
// this view still has to be mounted, possibly through a view transition.
// Retry like the feed's scroll restore does, so a non-empty note that
// wraps to several lines starts fully visible instead of clipped to one.
requestAnimationFrame(() => autosize(noteInput));
setTimeout(() => autosize(noteInput), 120);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => autosize(noteInput));
const noteBlock = el(
'div',
{ class: 'pnote' },
el('label', { class: 'pnote__label', for: 'pnote-input' }, 'Note', noteStatus),
noteInput,
);
noteInput.id = 'pnote-input';
const allGames = st.versionGroup === 'all'; const allGames = st.versionGroup === 'all';
let statBars = []; let statBars = [];
@ -690,6 +741,7 @@ export async function PokemonDetail(nationalId) {
'div', 'div',
{ class: 'psheet' }, { class: 'psheet' },
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn), el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
noteBlock,
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons), el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
body, body,
), ),
@ -706,6 +758,7 @@ export async function PokemonDetail(nationalId) {
const off = settings.subscribe(() => syncTrack()); const off = settings.subscribe(() => syncTrack());
const offTeam = team.subscribe(syncTeamBtn); const offTeam = team.subscribe(syncTeamBtn);
onTeardown(view, () => { onTeardown(view, () => {
saveNote(noteInput.value); // flush any unsaved keystrokes
off(); off();
offTeam(); offTeam();
offSwipe(); offSwipe();