diff --git a/README.md b/README.md index 2772dde..24b8b5a 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,10 @@ Working app with a type-themed UI: Clefairy is Normal, etc.), and era move data. Toggleable in the nav from Settings. - **Settings** — theme (System / Light / Dark / Black / Sepia) + accent - colour, sprite style, JSON export/import. + colour, 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. - **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON, cache-first LRU for sprites, in-app update toast. diff --git a/src/components/Card.js b/src/components/Card.js index e72dc5b..bd90d3a 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -4,6 +4,8 @@ import { TypeChip } from './TypeChip.js'; import { entry, toggle } from '../store/selection.js'; import { typeHex } from '../lib/type-color.js'; import { typesForGen } from '../lib/type-resolve.js'; +import { prefersReducedMotion } from '../store/settings.js'; +import { buzz } from '../lib/haptics.js'; /** * One Pokémon in the dex feed. Tinted by its primary type: a soft top-down @@ -30,11 +32,14 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup, const next = entry(species.id); caughtBtn.setAttribute('aria-pressed', String(next.caught)); card.classList.toggle('is-caught', next.caught); - if (next.caught && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - caughtBtn.animate( - [{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }], - { duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' }, - ); + if (next.caught) { + buzz(); + if (!prefersReducedMotion()) { + caughtBtn.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }], + { duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' }, + ); + } } }, }); diff --git a/src/lib/haptics.js b/src/lib/haptics.js new file mode 100644 index 0000000..88204bb --- /dev/null +++ b/src/lib/haptics.js @@ -0,0 +1,15 @@ +import { settings } from '../store/settings.js'; + +/** + * A short haptic pulse for a positive action (catch, favorite…). Gated on + * the user's haptics setting and on Vibration API support — most notably + * absent on iOS Safari, where this silently does nothing. + */ +export function buzz(pattern = 15) { + if (!settings.get().haptics || !navigator.vibrate) return; + try { + navigator.vibrate(pattern); + } catch { + /* unsupported in this context (e.g. no user-activation) — ignore */ + } +} diff --git a/src/main.js b/src/main.js index 9e0ccee..715752b 100644 --- a/src/main.js +++ b/src/main.js @@ -15,6 +15,15 @@ if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; // Navigation scroll memory is per-session — start a cold launch at the top. ui.set({ feedScroll: 0, searchScroll: 0 }); +// Land on the configured default screen for a fresh launch (no hash yet at +// all — a bookmark, the home-screen icon, or typing the bare URL). Explicit +// navigation, like tapping "Dex" in the nav, is never redirected — that +// only ever touches location.hash after this point. +if (!location.hash) { + const DEFAULT_ROUTES = { dex: '#/', team: '#/team', search: '#/search' }; + location.hash = DEFAULT_ROUTES[settings.get().defaultRoute] || '#/'; +} + // React to preference changes. The dex feed updates itself in place, but the // Pokémon detail page is built once per visit — rebuild it when the selected // game changes so its data (flavour text, learnset, matchups, evolution, diff --git a/src/router.js b/src/router.js index 22f1a07..22fb943 100644 --- a/src/router.js +++ b/src/router.js @@ -9,6 +9,7 @@ import { AbilityDetail } from './views/AbilityDetail.js'; import { TeamView } from './views/TeamView.js'; import { NaturesView } from './views/NaturesView.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; +import { prefersReducedMotion } from './store/settings.js'; const routes = [ { pattern: /^#?\/?$/, view: () => DexGrid() }, @@ -31,8 +32,7 @@ function resolve(hash) { } const canAnimate = () => - typeof document.startViewTransition === 'function' && - !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + typeof document.startViewTransition === 'function' && !prefersReducedMotion(); let rerenderCurrent = null; diff --git a/src/store/settings.js b/src/store/settings.js index 232e232..3b67c22 100644 --- a/src/store/settings.js +++ b/src/store/settings.js @@ -3,12 +3,16 @@ import { createStore } from './createStore.js'; /** * User preferences. Small, synchronous, read on boot. * - * - versionGroup: the selected "game" (PokéAPI version-group key) - * - pokedex: the active regional dex within that game (null = first one) - * - theme: 'system' | 'light' | 'dark' - * - spriteStyle: 'default' (pixel) | 'official' | 'home' - * - showShiny: default the detail view to shiny sprites - * - locale: reserved for localized names (PokéAPI supports many) + * - versionGroup: the selected "game" (PokéAPI version-group key) + * - pokedex: the active regional dex within that game (null = first one) + * - theme: 'system' | 'light' | 'dark' + * - spriteStyle: 'default' (pixel) | 'official' | 'home' + * - showShiny: default the detail view to shiny sprites + * - locale: reserved for localized names (PokéAPI supports many) + * - fontScale: 'small' | 'default' | 'large' text size + * - reduceMotion: in-app override — off even if the OS doesn't ask for it + * - defaultRoute: 'dex' | 'team' | 'search' — landing screen on cold launch + * - haptics: short vibration on catch (Vibration API, Android only) */ export const settings = createStore('pdx.settings', { versionGroup: 'all', // 'all' = National Dex, newest data, no generation limits @@ -19,13 +23,18 @@ export const settings = createStore('pdx.settings', { showShiny: false, showTeamNav: true, locale: 'en', + fontScale: 'default', + reduceMotion: false, + defaultRoute: 'dex', + haptics: true, }); const DARKISH = new Set(['dark', 'black']); +const FONT_SCALE = { small: 0.9, default: 1, large: 1.15 }; export function applyTheme(state = settings.get()) { const root = document.documentElement; - const { theme, accent } = state; + const { theme, accent, fontScale, reduceMotion } = state; if (!theme || theme === 'system') root.removeAttribute('data-theme'); else root.setAttribute('data-theme', theme); @@ -33,6 +42,11 @@ export function applyTheme(state = settings.get()) { if (!accent || accent === 'red') root.removeAttribute('data-accent'); else root.setAttribute('data-accent', accent); + root.style.fontSize = `${(FONT_SCALE[fontScale] ?? 1) * 100}%`; + + if (reduceMotion) root.setAttribute('data-reduce-motion', 'true'); + else root.removeAttribute('data-reduce-motion'); + const meta = document.querySelector('meta[name="theme-color"]'); if (meta) { const dark = @@ -47,3 +61,11 @@ 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 + ); +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index f0dafa9..4acd833 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -140,3 +140,10 @@ a { transition-duration: 0.001ms !important; } } +/* Settings > Reduce motion — an explicit in-app override alongside the OS + preference above, for anyone who wants it off without changing OS-level + settings (or whose OS setting this app can't see, e.g. some WebViews). */ +:root[data-reduce-motion='true'] * { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; +} diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 3866077..9592083 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -3,7 +3,7 @@ import { onSwipe } from '../lib/swipe.js'; import { loadSnapshot } from '../data/snapshot.js'; 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 { settings, prefersReducedMotion } from '../store/settings.js'; import { ui } from '../store/ui.js'; import { entry, toggle, setNote } from '../store/selection.js'; import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js'; @@ -17,6 +17,7 @@ import { TypeMatchups } from '../components/TypeMatchups.js'; import { Locations } from '../components/Locations.js'; import { FlavorText } from '../components/FlavorText.js'; import { typeHex } from '../lib/type-color.js'; +import { buzz } from '../lib/haptics.js'; import { offensiveSummary } from '../data/type-chart.js'; const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); @@ -238,11 +239,14 @@ export async function PokemonDetail(nationalId) { onclick: () => { toggle(nationalId, field); syncTrack(); - if (entry(nationalId)[field] && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - b.animate( - [{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }], - { duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' }, - ); + if (entry(nationalId)[field]) { + buzz(); + if (!prefersReducedMotion()) { + b.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }], + { duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' }, + ); + } } }, }); diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index 8a89e6d..51a88e6 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -47,6 +47,27 @@ export async function SettingsView() { ); const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow); + 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' }, + el('input', { + type: 'checkbox', + checked: !!st.reduceMotion, + onchange: (e) => { + settings.set({ reduceMotion: e.target.checked }); + applyTheme(); + }, + }), + 'Reduce motion', + ); + const spriteField = selectField('Sprite style', st.spriteStyle, [ ['default', 'Pixel (modern)'], ['game', 'Game era (pixel art from the selected game)'], @@ -72,6 +93,21 @@ export async function SettingsView() { 'Show Team in the navigation bar', ); + const hapticsField = el('label', { class: 'field field--check' }, + el('input', { + type: 'checkbox', + checked: st.haptics !== false, + onchange: (e) => settings.set({ haptics: e.target.checked }), + }), + 'Haptic feedback on catch', + ); + + 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) { navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => { @@ -102,7 +138,19 @@ export async function SettingsView() { view.append( el('header', { class: 'view__header' }, el('h1', {}, 'Settings')), - el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField, teamNavField), + el( + 'div', + { class: 'settings__group' }, + themeField, + accentField, + fontSizeField, + reduceMotionField, + spriteField, + shinyField, + hapticsField, + teamNavField, + defaultRouteField, + ), el('h2', {}, 'Your data'), storageNote,