From 1711249daed0eb7118212998946ceb4bacfbd83f Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 4 Sep 2026 12:24:22 -0400 Subject: [PATCH] =?UTF-8?q?Add=20a=20notes=20UI=20for=20the=20per-Pok?= =?UTF-8?q?=C3=A9mon=20note=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 7 ++++- src/components/Card.js | 3 ++ src/styles/layout.css | 63 ++++++++++++++++++++++++++++++++++++++ src/views/DexGrid.js | 5 ++- src/views/PokemonDetail.js | 55 ++++++++++++++++++++++++++++++++- 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6870a72..2772dde 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,12 @@ Working app with a type-themed UI: per-dex progress, name/number filter, caught/favorite filters, plus a filter drawer: type, generation, ability, egg group, minimum BST, and "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 snapshot; a pill switcher on the detail page rebuilds types / stats / matchups / abilities / learnset / artwork for the chosen form. Purely diff --git a/src/components/Card.js b/src/components/Card.js index 480c5d6..e72dc5b 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -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__spot', 'aria-hidden': 'true' }), + state.note && state.note.trim() + ? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝') + : null, el( 'div', { class: 'card__art' }, diff --git a/src/styles/layout.css b/src/styles/layout.css index 8e70ea3..b39103f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -559,6 +559,20 @@ .card.is-caught { 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) { .card { animation: none; @@ -936,6 +950,55 @@ border-color: #fff; 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 { display: flex; gap: 4px; diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index 3f877e5..55796b8 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -20,6 +20,7 @@ const FILTERS = [ { key: 'missing', label: 'Missing', test: (e) => !e.caught }, { key: 'favorite', label: 'Favorites', test: (e) => e.favorite }, { key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical }, + { key: 'notes', label: 'Notes', test: (e) => !!(e.note && e.note.trim()) }, ]; const SORTS = [ @@ -407,13 +408,15 @@ export async function DexGrid() { let caught = 0; let favorite = 0; let legendary = 0; + let notes = 0; for (const { species } of rows) { const e = p[species.id]; if (e?.caught) caught++; if (e?.favorite) favorite++; 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() { diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 2b580f5..3866077 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -5,7 +5,7 @@ import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { settings } from '../store/settings.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 { isFormCaught, toggleFormCaught } from '../store/formTracking.js'; import { Sprite, spriteUrl } from '../components/Sprite.js'; @@ -280,6 +280,57 @@ export async function PokemonDetail(nationalId) { } 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'; let statBars = []; @@ -690,6 +741,7 @@ export async function PokemonDetail(nationalId) { 'div', { class: 'psheet' }, el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn), + noteBlock, el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons), body, ), @@ -706,6 +758,7 @@ export async function PokemonDetail(nationalId) { const off = settings.subscribe(() => syncTrack()); const offTeam = team.subscribe(syncTeamBtn); onTeardown(view, () => { + saveNote(noteInput.value); // flush any unsaved keystrokes off(); offTeam(); offSwipe();