diff --git a/src/components/Nav.js b/src/components/Nav.js index d3fd682..487011f 100644 --- a/src/components/Nav.js +++ b/src/components/Nav.js @@ -1,6 +1,7 @@ import { el } from '../lib/dom.js'; import { openGameSheet } from './GameSheet.js'; import { settings } from '../store/settings.js'; +import { onTapCode } from '../lib/tap-code.js'; const ITEMS = [ { label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' }, @@ -25,20 +26,6 @@ export function Nav() { const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' }); let links = []; - // Easter egg: seven quick taps on the wordmark opens the hidden game - // (the Konami code does the same on a keyboard). - let taps = 0; - let tapTimer = null; - function bumpBrand() { - taps += 1; - clearTimeout(tapTimer); - tapTimer = setTimeout(() => (taps = 0), 1500); - if (taps >= 7) { - taps = 0; - location.hash = '#/whos-that'; - } - } - function build() { const st = settings.get(); const items = ITEMS.filter((it) => !it.optional || st[it.optional]); @@ -55,10 +42,13 @@ export function Nav() { ) : el('a', { class: 'nav__link', href: item.href }, ...inner); }); - nav.replaceChildren( - el('span', { class: 'nav__brand', onclick: bumpBrand }, 'Pocketdex'), - ...links, - ); + // Easter egg: the wordmark only shows on wide screens; on mobile the + // same shortcut lives on the dex feed's game title (see DexGrid). + const brand = el('span', { class: 'nav__brand' }, 'Pocketdex'); + onTapCode(brand, () => { + if ((location.hash || '') !== '#/whos-that') location.hash = '#/whos-that'; + }); + nav.replaceChildren(brand, ...links); nav._items = items; sync(); } diff --git a/src/lib/tap-code.js b/src/lib/tap-code.js new file mode 100644 index 0000000..7908735 --- /dev/null +++ b/src/lib/tap-code.js @@ -0,0 +1,23 @@ +/** + * Fire `fn` after `count` taps/clicks land on `node` within `windowMs` of + * each other — a quiet way to hide a shortcut behind a bit of fidgeting. + * Returns an unbind. + */ +export function onTapCode(node, fn, { count = 7, windowMs = 1500 } = {}) { + let n = 0; + let timer = null; + const onClick = () => { + n += 1; + clearTimeout(timer); + timer = setTimeout(() => (n = 0), windowMs); + if (n >= count) { + n = 0; + fn(); + } + }; + node.addEventListener('click', onClick); + return () => { + clearTimeout(timer); + node.removeEventListener('click', onClick); + }; +} diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index 8cdea27..f4da41c 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -8,6 +8,7 @@ import { Card } from '../components/Card.js'; import { ProgressRing } from '../components/ProgressRing.js'; import { Sprite } from '../components/Sprite.js'; import { typesForGen } from '../lib/type-resolve.js'; +import { onTapCode } from '../lib/tap-code.js'; const TYPES = [ 'normal', @@ -429,16 +430,18 @@ export async function DexGrid() { } syncToolsBadge(); + // Easter egg: a flurry of taps on the game title opens the hidden game. + // (The Konami code does the same with a keyboard.) + const feedId = el('div', { class: 'feed-head__id' }, title, gameLabel); + onTapCode(feedId, () => { + if ((location.hash || '') !== '#/whos-that') location.hash = '#/whos-that'; + }); + view.append( el( 'header', { class: 'feed-head' }, - el( - 'div', - { class: 'feed-head__top' }, - el('div', { class: 'feed-head__id' }, title, gameLabel), - ring.node, - ), + el('div', { class: 'feed-head__top' }, feedId, ring.node), el( 'div', { class: 'field-search' }, diff --git a/test/tap-code.test.js b/test/tap-code.test.js new file mode 100644 index 0000000..e02c83e --- /dev/null +++ b/test/tap-code.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { onTapCode } from '../src/lib/tap-code.js'; + +const tap = (node) => node.dispatchEvent(new Event('click')); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe('onTapCode', () => { + it('fires after `count` taps inside the window', () => { + const node = new EventTarget(); + const fn = vi.fn(); + onTapCode(node, fn, { count: 7, windowMs: 1500 }); + for (let i = 0; i < 6; i++) tap(node); + expect(fn).not.toHaveBeenCalled(); + tap(node); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('resets when taps are too far apart', () => { + const node = new EventTarget(); + const fn = vi.fn(); + onTapCode(node, fn, { count: 3, windowMs: 500 }); + tap(node); + tap(node); + vi.advanceTimersByTime(600); // window elapsed -> counter clears + tap(node); + tap(node); + expect(fn).not.toHaveBeenCalled(); + tap(node); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('unbind stops it', () => { + const node = new EventTarget(); + const fn = vi.fn(); + const off = onTapCode(node, fn, { count: 2 }); + off(); + tap(node); + tap(node); + expect(fn).not.toHaveBeenCalled(); + }); +});