From 3b56ebfe78a62c44980f24293c68e83adf7ec483 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Tue, 25 Aug 2026 22:59:59 +0200 Subject: [PATCH 01/51] Consolidate the easter eggs behind one shared module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why this change =============== The easter-egg games were reachable only via Ctrl+Shift+1/2/3 keyboard chords, which we are retiring: hidden chords are undiscoverable by design, and the product direction is that eggs should be found by users who pay attention to details (clicking real UI elements, trying phrases in chat). This commit is the structural groundwork for that direction; the actual new discovery triggers land on top of it. What was wrong structurally =========================== - Three byte-identical chord hooks (useDinoShortcut, useGame2048Shortcut, useSpaceInvadersShortcut) differing only in which digit they matched. - Two near-identical modal wrappers (DinoGameModal, SpaceInvadersModal) plus a third variant for the 2048 iframe. - The dino "waiting game" logic (unlock flag + Space trigger + auto-close) was copy-pasted across ChatPage and OnBoardingPage (~40 lines each), including duplicated localStorage/dinoUnlockChanged/storage sync code. - The chat phrase easter eggs ("do a barrel roll", matrix variants) were matched with inline string comparisons inside ChatPage. - All game code was statically imported through DashboardPage, so ~1,600 lines of canvas/iframe game shipped in the main bundle even for users who never open an egg. What replaces it ================ features/easter-eggs/ now owns the shared machinery: - registry.ts: every modal egg as { id, label, kind, component }, with all components lazy-loaded so each game becomes its own chunk fetched on first open. Adding a future egg = one registry entry + one component. - components/EggModalShell.tsx: one Framer Motion modal for every egg. Canvas games render bare (they own their chrome and Escape handling via onExit); iframe games get a header bar + close button + window-level Escape (the iframe swallows keys, so the shell must listen). One Suspense wraps both branches because an unsuspended lazy child would tear down the whole tree. - components/Game2048Frame.tsx: thin iframe wrapper so the self-contained vanilla-JS 2048 page fits the shared { onExit } game shape. - hooks/useDinoWaitingGame.ts: useDinoUnlocked() (live localStorage flag synced via dinoUnlockChanged + storage events) and useSpaceOpensDino() (Space opens while armed+unlocked, typing-guarded, closes when the flag flips off — via React's adjust-state-during-render pattern, not an effect, matching ChatPage's existing style and the lint rule). - hooks/useRepeatClicks.ts: generalized N-consecutive-clicks detector; first consumer is the dashboard header icon (triple-click = dino), the same gesture language the Settings cogwheel already uses. - lib/eggPhrases.ts: phrase matcher extracted verbatim from ChatPage so behavior is unchanged there and other surfaces (buddy chat later) can reuse it. Consumer changes ================ - DashboardPage: all three chords removed; triple-clicking the page-header icon now opens the dino runner (ungated, deliberately - finding it IS the unlock). 2048/invaders lose their only triggers for now; new ones follow in upcoming commits. - NotFoundPage: no longer force-opens Space Invaders on load; uses the shared shell instead of its own modal instance. A visible teaser lands separately. - ChatPage / OnBoardingPage: waiting-game logic replaced by the shared hooks; OnBoardingPage additionally closes the game when a regeneration starts (same as before) and keeps its DinoGame mount. - useDinoEasterEgg (Settings cogwheel): rewritten onto useRepeatClicks + shared unlock helpers. Its dinoUnlockChanged event is now dispatched on a microtask because the old synchronous dispatch ran during a React render phase (setState-in-render hazard flagged by lint). Consumers listen for the event asynchronously anyway. Verification ============ npm run build: PASS (pre-existing chunk-size warning only) npx eslint src tests: PASS (0 problems) npm run unit: 205 files / 1814 tests PASS npm run a11y: 52 files / 60 tests PASS format:check: my files clean; 5 unrelated files were already drifted on feature/minor-upgrades (verified against committed blobs), untouched here. --- .../dino/components/DinoGameModal.tsx | 68 ---------- src/features/dino/hooks/useDinoShortcut.ts | 35 ----- .../easter-eggs/components/EggModalShell.tsx | 128 ++++++++++++++++++ .../easter-eggs/components/Game2048Frame.tsx | 55 ++++++++ .../easter-eggs/hooks/useDinoWaitingGame.ts | 71 ++++++++++ .../easter-eggs/hooks/useRepeatClicks.ts | 30 ++++ src/features/easter-eggs/lib/eggPhrases.ts | 42 ++++++ src/features/easter-eggs/registry.ts | 56 ++++++++ .../game2048/components/Game2048Modal.tsx | 128 ------------------ .../game2048/hooks/useGame2048Shortcut.ts | 34 ----- .../settings/hooks/useDinoEasterEgg.ts | 109 ++++----------- .../components/SpaceInvadersModal.tsx | 68 ---------- .../hooks/useSpaceInvadersShortcut.ts | 36 ----- src/pages/ChatPage.tsx | 117 ++++++---------- src/pages/DashboardPage.tsx | 35 ++--- src/pages/NotFoundPage.tsx | 21 +-- src/pages/OnBoardingPage.tsx | 56 ++------ .../unit/features/dino/DinoGameModal.test.tsx | 54 -------- .../easter-eggs/EggModalShell.test.tsx | 57 ++++++++ .../features/easter-eggs/eggPhrases.test.ts | 22 +++ .../easter-eggs/useDinoWaitingGame.test.tsx | 96 +++++++++++++ .../easter-eggs/useRepeatClicks.test.tsx | 34 +++++ .../features/game2048/Game2048Modal.test.tsx | 65 --------- .../settings/useDinoEasterEgg.test.ts | 33 +++-- .../SpaceInvadersModal.test.tsx | 59 -------- 25 files changed, 717 insertions(+), 792 deletions(-) delete mode 100644 src/features/dino/components/DinoGameModal.tsx delete mode 100644 src/features/dino/hooks/useDinoShortcut.ts create mode 100644 src/features/easter-eggs/components/EggModalShell.tsx create mode 100644 src/features/easter-eggs/components/Game2048Frame.tsx create mode 100644 src/features/easter-eggs/hooks/useDinoWaitingGame.ts create mode 100644 src/features/easter-eggs/hooks/useRepeatClicks.ts create mode 100644 src/features/easter-eggs/lib/eggPhrases.ts create mode 100644 src/features/easter-eggs/registry.ts delete mode 100644 src/features/game2048/components/Game2048Modal.tsx delete mode 100644 src/features/game2048/hooks/useGame2048Shortcut.ts delete mode 100644 src/features/space-invaders/components/SpaceInvadersModal.tsx delete mode 100644 src/features/space-invaders/hooks/useSpaceInvadersShortcut.ts delete mode 100644 tests/unit/features/dino/DinoGameModal.test.tsx create mode 100644 tests/unit/features/easter-eggs/EggModalShell.test.tsx create mode 100644 tests/unit/features/easter-eggs/eggPhrases.test.ts create mode 100644 tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx create mode 100644 tests/unit/features/easter-eggs/useRepeatClicks.test.tsx delete mode 100644 tests/unit/features/game2048/Game2048Modal.test.tsx delete mode 100644 tests/unit/features/space-invaders/SpaceInvadersModal.test.tsx diff --git a/src/features/dino/components/DinoGameModal.tsx b/src/features/dino/components/DinoGameModal.tsx deleted file mode 100644 index 220f3ab42..000000000 --- a/src/features/dino/components/DinoGameModal.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; -import { useScrollLock } from "../../../components/ui/useScrollLock"; -import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens"; -import { DinoGame } from "../../chatbot/components/DinoGame"; - -/** - * Props for {@link DinoGameModal}. - */ -interface DinoGameModalProps { - /** When true, the modal is visible and the dino runner is mounted. */ - open: boolean; - /** - * Called when the user requests to close (overlay click, or via the - * DinoGame's own Esc / exit button — DinoGame calls `onExit` on Escape - * and on its in-game "Esc ✕" button, which we route here). - */ - onClose: () => void; -} - -/** - * Dashboard easter-egg wrapper that mounts the existing {@link DinoGame} - * runner inside a Framer Motion modal. - * - * Unlike {@link Game2048Modal}, this wrapper has no header bar and no - * modal-level Escape listener: {@link DinoGame} already renders its own - * score / "Esc ✕" overlay on top of the canvas and calls `onExit` on - * Escape, so adding a second header or Esc handler would duplicate chrome - * and double-fire on Esc. Bypasses the `dinoUnlocked` localStorage gate — - * the dashboard chord is a true easter egg, always available. - */ -export function DinoGameModal({ open, onClose }: DinoGameModalProps) { - // Not a `Modal`: the game owns the keyboard, and Modal's focus trap - // would fight it for the arrow keys. The one thing every overlay needs - // regardless is the page behind it holding still. - useScrollLock(open); - - const prefersReducedMotion = useReducedMotion(); - const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion)); - - return ( - - {open && ( - - e.stopPropagation()} - > - - - - )} - - ); -} diff --git a/src/features/dino/hooks/useDinoShortcut.ts b/src/features/dino/hooks/useDinoShortcut.ts deleted file mode 100644 index 1a3c6ea38..000000000 --- a/src/features/dino/hooks/useDinoShortcut.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect } from "react"; - -/** - * Listen for the Ctrl+Shift+1 keyboard chord and fire `onTrigger`. - * - * The "1" hints at the dino runner (the first easter egg in the app — it - * was the original hidden game before 2048 was added). Mirrors the - * Game2048Shortcut pattern: ignores the chord when the user is typing in - * a textarea/input/contentEditable so we don't hijack regular editing. - * - * @param onTrigger called once per chord press (auto-repeat suppressed) - */ -export function useDinoShortcut(onTrigger: () => void): void { - useEffect(() => { - const isTypingTarget = (el: Element | null) => - el instanceof HTMLElement && - (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable); - - const onKeyDown = (e: KeyboardEvent) => { - // Use e.code (physical key) instead of e.key (produced character): - // Shift+1 produces "!" on both US QWERTY and German QWERTZ, so - // e.key === "1" only matches when Shift is NOT pressed — which - // defeats the whole chord. e.code === "Digit1" is layout-stable. - if (!(e.ctrlKey && e.shiftKey && e.code === "Digit1")) return; - if (isTypingTarget(document.activeElement)) return; - if (e.repeat) return; - - e.preventDefault(); - onTrigger(); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [onTrigger]); -} diff --git a/src/features/easter-eggs/components/EggModalShell.tsx b/src/features/easter-eggs/components/EggModalShell.tsx new file mode 100644 index 000000000..d6fcd18bd --- /dev/null +++ b/src/features/easter-eggs/components/EggModalShell.tsx @@ -0,0 +1,128 @@ +import { Suspense, useEffect, useRef } from "react"; +import { X } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { useScrollLock } from "../../../components/ui/useScrollLock"; +import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens"; +import { EGG_REGISTRY, type EggId } from "../registry"; + +type EggModalShellProps = { + /** Which registered egg to show. Unknown ids render nothing. */ + eggId: EggId; + /** When true, the modal is visible and the lazily-loaded game mounts. */ + open: boolean; + /** Called when the user requests to close (Esc, exit button, overlay click). */ + onClose: () => void; +}; + +/** + * The one modal wrapper behind every modal easter egg — replaces the old + * per-game DinoGameModal / SpaceInvadersModal / Game2048Modal trio, whose + * backdrop, spring animation and scroll lock were three copies of the + * same file with a different aria-label. The game arrives through + * {@link EGG_REGISTRY} as a lazy chunk: none of the game code loads until + * an egg is actually opened. + * + * Two shapes fall out of the registry: + * + * - Canvas games (dino, invaders) own their keyboard and already draw + * their score / "Esc ✕" chrome on top of the canvas and call their + * `onExit` prop on Escape — so this shell adds no header and no Escape + * listener of its own (a second handler would double-fire). + * + * - The iframe game (2048) is a vanilla-JS page with no React props, so + * the shell renders a titled header bar with a close button and listens + * for Escape on the parent window; the frame's own same-origin listener + * (see {@link Game2048Frame}) covers presses inside the iframe — the two + * never double-fire because keydowns in a focused iframe don't bubble out. + * + * Not the shared `Modal`: games own the keyboard, and Modal's focus trap + * would fight them for the arrow keys. The one thing every overlay needs + * regardless — the page behind it holding still — comes from + * `useScrollLock`. + */ +export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) { + const egg = EGG_REGISTRY[eggId]; + + useScrollLock(open); + const prefersReducedMotion = useReducedMotion(); + const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion)); + + // Close on Escape while focus is outside the iframe (header bar, close + // button, or before the frame has loaded). Canvas games handle Escape + // themselves via `onExit`. The ref keeps the latest callback without + // re-subscribing; calling it inside the handler (not during render) + // stays clear of the set-state-in-effect rule. + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + if (!open || !egg || egg.kind !== "iframe") return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onCloseRef.current(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, egg]); + + if (!egg) return null; + + const Game = egg.component; + + return ( + + {open && ( + + e.stopPropagation()} + > + {/* One Suspense around every branch: all registry components are + lazy, and an unsuspended lazy child would tear down the tree. */} + + + + )} + + ); +} diff --git a/src/features/easter-eggs/components/Game2048Frame.tsx b/src/features/easter-eggs/components/Game2048Frame.tsx new file mode 100644 index 000000000..7612f465c --- /dev/null +++ b/src/features/easter-eggs/components/Game2048Frame.tsx @@ -0,0 +1,55 @@ +/** + * Renders the self-contained vanilla-JS 2048 page (public/easter-eggs/ + * 2048.html) inside an iframe so it fits the registry's shared + * `{ onExit }` game shape. All keyboard wiring lives in + * {@link EggModalShell} — focusing the frame here just makes arrow keys + * work immediately, without a click first. + */ +import { useEffect, useRef } from "react"; + +type Game2048FrameProps = { + /** + * Called when the user presses Escape *outside* the iframe (header, + * close button, before load). Keydowns inside a focused iframe don't + * bubble out to the parent document, so the frame installs its own + * same-origin listener after loading; the two never double-fire. + */ + onExit: () => void; +}; + +const GAME_URL = "/easter-eggs/2048.html"; + +export function Game2048Frame({ onExit }: Game2048FrameProps) { + const iframeRef = useRef(null); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + + const handleLoad = () => { + const win = iframe.contentWindow; + const doc = iframe.contentDocument; + if (!win || !doc) return; + win.focus(); + doc.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") onExit(); + }); + // The iframe is unmounted when the modal closes (AnimatePresence + // exit), discarding its contentDocument and this listener with it. + }; + + if (iframe.contentDocument?.readyState === "complete") handleLoad(); + else iframe.addEventListener("load", handleLoad); + return () => iframe.removeEventListener("load", handleLoad); + }, [onExit]); + + return ( +