Settings: text size, reduce motion, landing screen, haptics
Four new preferences, all in settings.js / applied via applyTheme() so boot and live changes go through one path: - fontScale (small/default/large): scales root font-size (92/100/115%); the whole stylesheet is already rem-based so this alone rescales everything. - reduceMotion: an explicit in-app override alongside the existing `prefers-reduced-motion` OS check, via a new :root[data-reduce-motion="true"] rule in tokens.css mirroring the existing media-query kill-switch. New prefersReducedMotion() helper (checks both) replaces the three hand-rolled matchMedia checks in router.js/Card.js/PokemonDetail.js. - defaultRoute (dex/team/search): redirects on a genuinely hash-less launch only (bookmark, home-screen icon, bare URL) — explicit nav (e.g. tapping "Dex") is never touched, since it's applied once in main.js before initRouter runs. - haptics: a short navigator.vibrate() pulse on the same catch/seen/ favorite toggles that already play the pop animation, via a new lib/haptics.js buzz() (no-ops without Vibration API support, e.g. iOS Safari). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
1711249dae
commit
4b9bc5c0ab
@ -114,7 +114,10 @@ Working app with a type-themed UI:
|
|||||||
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
|
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
|
||||||
from Settings.
|
from Settings.
|
||||||
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
|
- **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,
|
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
|
||||||
cache-first LRU for sprites, in-app update toast.
|
cache-first LRU for sprites, in-app update toast.
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { TypeChip } from './TypeChip.js';
|
|||||||
import { entry, toggle } from '../store/selection.js';
|
import { entry, toggle } from '../store/selection.js';
|
||||||
import { typeHex } from '../lib/type-color.js';
|
import { typeHex } from '../lib/type-color.js';
|
||||||
import { typesForGen } from '../lib/type-resolve.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
|
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
|
||||||
@ -30,12 +32,15 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
|||||||
const next = entry(species.id);
|
const next = entry(species.id);
|
||||||
caughtBtn.setAttribute('aria-pressed', String(next.caught));
|
caughtBtn.setAttribute('aria-pressed', String(next.caught));
|
||||||
card.classList.toggle('is-caught', next.caught);
|
card.classList.toggle('is-caught', next.caught);
|
||||||
if (next.caught && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (next.caught) {
|
||||||
|
buzz();
|
||||||
|
if (!prefersReducedMotion()) {
|
||||||
caughtBtn.animate(
|
caughtBtn.animate(
|
||||||
[{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }],
|
[{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }],
|
||||||
{ duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
{ duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
15
src/lib/haptics.js
Normal file
15
src/lib/haptics.js
Normal file
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,15 @@ if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
|
|||||||
// Navigation scroll memory is per-session — start a cold launch at the top.
|
// Navigation scroll memory is per-session — start a cold launch at the top.
|
||||||
ui.set({ feedScroll: 0, searchScroll: 0 });
|
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
|
// 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
|
// Pokémon detail page is built once per visit — rebuild it when the selected
|
||||||
// game changes so its data (flavour text, learnset, matchups, evolution,
|
// game changes so its data (flavour text, learnset, matchups, evolution,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { AbilityDetail } from './views/AbilityDetail.js';
|
|||||||
import { TeamView } from './views/TeamView.js';
|
import { TeamView } from './views/TeamView.js';
|
||||||
import { NaturesView } from './views/NaturesView.js';
|
import { NaturesView } from './views/NaturesView.js';
|
||||||
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||||
|
import { prefersReducedMotion } from './store/settings.js';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
||||||
@ -31,8 +32,7 @@ function resolve(hash) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canAnimate = () =>
|
const canAnimate = () =>
|
||||||
typeof document.startViewTransition === 'function' &&
|
typeof document.startViewTransition === 'function' && !prefersReducedMotion();
|
||||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
|
|
||||||
let rerenderCurrent = null;
|
let rerenderCurrent = null;
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import { createStore } from './createStore.js';
|
|||||||
* - spriteStyle: 'default' (pixel) | 'official' | 'home'
|
* - spriteStyle: 'default' (pixel) | 'official' | 'home'
|
||||||
* - showShiny: default the detail view to shiny sprites
|
* - showShiny: default the detail view to shiny sprites
|
||||||
* - locale: reserved for localized names (PokéAPI supports many)
|
* - 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', {
|
export const settings = createStore('pdx.settings', {
|
||||||
versionGroup: 'all', // 'all' = National Dex, newest data, no generation limits
|
versionGroup: 'all', // 'all' = National Dex, newest data, no generation limits
|
||||||
@ -19,13 +23,18 @@ export const settings = createStore('pdx.settings', {
|
|||||||
showShiny: false,
|
showShiny: false,
|
||||||
showTeamNav: true,
|
showTeamNav: true,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
fontScale: 'default',
|
||||||
|
reduceMotion: false,
|
||||||
|
defaultRoute: 'dex',
|
||||||
|
haptics: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const DARKISH = new Set(['dark', 'black']);
|
const DARKISH = new Set(['dark', 'black']);
|
||||||
|
const FONT_SCALE = { small: 0.9, default: 1, large: 1.15 };
|
||||||
|
|
||||||
export function applyTheme(state = settings.get()) {
|
export function applyTheme(state = settings.get()) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const { theme, accent } = state;
|
const { theme, accent, fontScale, reduceMotion } = state;
|
||||||
|
|
||||||
if (!theme || theme === 'system') root.removeAttribute('data-theme');
|
if (!theme || theme === 'system') root.removeAttribute('data-theme');
|
||||||
else root.setAttribute('data-theme', 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');
|
if (!accent || accent === 'red') root.removeAttribute('data-accent');
|
||||||
else root.setAttribute('data-accent', 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"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (meta) {
|
if (meta) {
|
||||||
const dark =
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -140,3 +140,10 @@ a {
|
|||||||
transition-duration: 0.001ms !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { onSwipe } from '../lib/swipe.js';
|
|||||||
import { loadSnapshot } from '../data/snapshot.js';
|
import { loadSnapshot } from '../data/snapshot.js';
|
||||||
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
|
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, prefersReducedMotion } from '../store/settings.js';
|
||||||
import { ui } from '../store/ui.js';
|
import { ui } from '../store/ui.js';
|
||||||
import { entry, toggle, setNote } 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';
|
||||||
@ -17,6 +17,7 @@ import { TypeMatchups } from '../components/TypeMatchups.js';
|
|||||||
import { Locations } from '../components/Locations.js';
|
import { Locations } from '../components/Locations.js';
|
||||||
import { FlavorText } from '../components/FlavorText.js';
|
import { FlavorText } from '../components/FlavorText.js';
|
||||||
import { typeHex } from '../lib/type-color.js';
|
import { typeHex } from '../lib/type-color.js';
|
||||||
|
import { buzz } from '../lib/haptics.js';
|
||||||
import { offensiveSummary } from '../data/type-chart.js';
|
import { offensiveSummary } from '../data/type-chart.js';
|
||||||
|
|
||||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||||
@ -238,12 +239,15 @@ export async function PokemonDetail(nationalId) {
|
|||||||
onclick: () => {
|
onclick: () => {
|
||||||
toggle(nationalId, field);
|
toggle(nationalId, field);
|
||||||
syncTrack();
|
syncTrack();
|
||||||
if (entry(nationalId)[field] && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (entry(nationalId)[field]) {
|
||||||
|
buzz();
|
||||||
|
if (!prefersReducedMotion()) {
|
||||||
b.animate(
|
b.animate(
|
||||||
[{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }],
|
[{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }],
|
||||||
{ duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
{ duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
b.append(label);
|
b.append(label);
|
||||||
|
|||||||
@ -47,6 +47,27 @@ export async function SettingsView() {
|
|||||||
);
|
);
|
||||||
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
|
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, [
|
const spriteField = selectField('Sprite style', st.spriteStyle, [
|
||||||
['default', 'Pixel (modern)'],
|
['default', 'Pixel (modern)'],
|
||||||
['game', 'Game era (pixel art from the selected game)'],
|
['game', 'Game era (pixel art from the selected game)'],
|
||||||
@ -72,6 +93,21 @@ export async function SettingsView() {
|
|||||||
'Show Team in the navigation bar',
|
'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…');
|
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
|
||||||
if (navigator.storage?.estimate) {
|
if (navigator.storage?.estimate) {
|
||||||
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
||||||
@ -102,7 +138,19 @@ export async function SettingsView() {
|
|||||||
|
|
||||||
view.append(
|
view.append(
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
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'),
|
el('h2', {}, 'Your data'),
|
||||||
storageNote,
|
storageNote,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user