From 60b01dba8b0860fef0fcafd9bcc98488e90ae95b Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Fri, 9 Oct 2026 15:57:05 +0530 Subject: [PATCH 1/4] Hide more easter eggs around the site - Konami code (up up down down left right left right B A) toggles hell mode: blood-red filter, flickering vignette, pitchfork cursor - Flipping the theme 6 times in 3s blows the lights: flashlight cursor - A minute idle and red eyes open in the dark at the edges, watching - Rage-clicking empty space cracks the screen - Typing "boom" detonates the page from the middle - Leaving the tab makes the title beg you to come back - A console greeting with a hint, and a burn-it slider on the 404 Finds are remembered in localStorage and toasted with an x/7 counter; the detonation end card shows the count too. --- app/globals.css | 153 +++++++++++++++++++ app/layout.tsx | 2 + app/not-found.tsx | 3 + components/detonation/detonation-overlay.tsx | 5 +- components/detonation/events.ts | 15 +- components/detonation/page-detonation.tsx | 13 +- components/easter-eggs/boom-word.tsx | 14 ++ components/easter-eggs/console-greeting.tsx | 21 +++ components/easter-eggs/easter-eggs.tsx | 26 ++++ components/easter-eggs/egg-toast.tsx | 79 ++++++++++ components/easter-eggs/eggs.ts | 64 ++++++++ components/easter-eggs/hell-mode.tsx | 47 ++++++ components/easter-eggs/lights-out.tsx | 74 +++++++++ components/easter-eggs/screen-cracks.tsx | 153 +++++++++++++++++++ components/easter-eggs/tab-title.tsx | 42 +++++ components/easter-eggs/use-key-sequence.ts | 27 ++++ components/easter-eggs/watching-eyes.tsx | 128 ++++++++++++++++ 17 files changed, 860 insertions(+), 6 deletions(-) create mode 100644 components/easter-eggs/boom-word.tsx create mode 100644 components/easter-eggs/console-greeting.tsx create mode 100644 components/easter-eggs/easter-eggs.tsx create mode 100644 components/easter-eggs/egg-toast.tsx create mode 100644 components/easter-eggs/eggs.ts create mode 100644 components/easter-eggs/hell-mode.tsx create mode 100644 components/easter-eggs/lights-out.tsx create mode 100644 components/easter-eggs/screen-cracks.tsx create mode 100644 components/easter-eggs/tab-title.tsx create mode 100644 components/easter-eggs/use-key-sequence.ts create mode 100644 components/easter-eggs/watching-eyes.tsx diff --git a/app/globals.css b/app/globals.css index 506b6c69..0dd8771b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -709,3 +709,156 @@ html.dark .docs-content .shiki span { 100% 100%, 100% 100%; } + +/* + * Easter eggs (components/easter-eggs). Overlays stack under the detonation + * (2147483000) and the egg toast (2147483100). + */ +html[data-hell] body { + filter: sepia(1) saturate(6) hue-rotate(-48deg) brightness(0.85) contrast(1.15); +} + +html[data-hell], +html[data-hell] * { + cursor: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='32' viewBox='0 0 24 32'%3E%3Cpath d='M4 3v7a8 8 0 0 0 16 0V3M12 3v27' fill='none' stroke='%23000' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M4 3v7a8 8 0 0 0 16 0V3M12 3v27' fill='none' stroke='%23ff2a14' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") + 12 3, + auto !important; +} + +.hell-vignette { + position: fixed; + inset: 0; + z-index: 2147482000; + pointer-events: none; + background: radial-gradient(ellipse at center, transparent 40%, rgb(110 0 0 / 0.6) 100%); + mix-blend-mode: multiply; + animation: hell-flicker 3.2s steps(1) infinite; +} + +@keyframes hell-flicker { + 0%, 100% { opacity: 1; } + 41% { opacity: 0.7; } + 43% { opacity: 1; } + 77% { opacity: 0.85; } + 78% { opacity: 1; } +} + +.lights-out { + position: fixed; + inset: 0; + z-index: 2147482500; + pointer-events: none; + background: radial-gradient( + circle at var(--x, 50%) var(--y, 50%), + transparent 0, + transparent 70px, + rgb(0 0 0 / 0.9) 150px, + rgb(0 0 0 / 0.97) 320px + ); + animation: lights-off 0.7s steps(1); +} + +.lights-out[data-phase="flicker"] { + animation: lights-on 1s steps(1) forwards; +} + +@keyframes lights-off { + 0% { opacity: 0; } + 15% { opacity: 1; } + 30% { opacity: 0.2; } + 45% { opacity: 1; } + 60% { opacity: 0.5; } + 70%, 100% { opacity: 1; } +} + +@keyframes lights-on { + 0% { opacity: 1; } + 20% { opacity: 0; } + 32% { opacity: 1; } + 50% { opacity: 0.1; } + 60% { opacity: 0.9; } + 75%, 100% { opacity: 0; } +} + +@keyframes lights-fade { + to { opacity: 0; } +} + +.watching-eyes { + position: fixed; + inset: 0; + z-index: 2147482400; + pointer-events: none; + transition: opacity 0.25s; +} + +.watching-eyes::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 30%, rgb(0 0 0 / 0.85) 100%); + animation: eyes-dim 3s ease-out backwards; +} + +.watching-eyes[data-leaving] { + opacity: 0; +} + +.watching-eyes-pair { + position: absolute; + translate: -50% -50%; + overflow: visible; + filter: drop-shadow(0 0 6px rgb(255 30 20 / 0.85)); + animation: eyes-open 0.9s ease-out backwards; +} + +.watching-eyes-lids { + transform-box: fill-box; + transform-origin: center; + animation: eyes-blink 5s infinite; + transition: transform 0.15s; +} + +.watching-eyes[data-leaving] .watching-eyes-lids { + animation: none; + transform: scaleY(0.05); +} + +@keyframes eyes-dim { + from { opacity: 0; } +} + +@keyframes eyes-open { + from { opacity: 0; transform: scaleY(0.05); } +} + +@keyframes eyes-blink { + 0%, 92%, 100% { transform: none; } + 95% { transform: scaleY(0.08); } +} + +.screen-crack { + position: absolute; + overflow: visible; + animation: crack-life 4.5s ease-out forwards; +} + +@keyframes crack-life { + 0% { opacity: 0; transform: scale(0.92); } + 4% { opacity: 1; transform: none; } + 80% { opacity: 1; } + 100% { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .hell-vignette, + .lights-out, + .watching-eyes-lids { + animation: none; + } + + .lights-out[data-phase="flicker"] { + animation: lights-fade 0.4s forwards; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index d1733dbf..06a22477 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,6 +15,7 @@ import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { TracwellAnalytics } from "@/components/tracwell-analytics"; import { PageDetonation } from "@/components/detonation/page-detonation"; +import { EasterEggs } from "@/components/easter-eggs/easter-eggs"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -93,6 +94,7 @@ export default function RootLayout({ */} {children} + ); diff --git a/app/not-found.tsx b/app/not-found.tsx index bc615188..1efa05d3 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { SlideToDetonate } from "@/components/detonation/slide-to-detonate"; export default function NotFound() { return ( @@ -29,6 +30,8 @@ export default function NotFound() { Browse docs + + ); } diff --git a/components/detonation/detonation-overlay.tsx b/components/detonation/detonation-overlay.tsx index d2e9e254..b2a42cf1 100644 --- a/components/detonation/detonation-overlay.tsx +++ b/components/detonation/detonation-overlay.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { eggProgress } from "@/components/easter-eggs/eggs"; import type { DetonationOrigin } from "./events"; /* @@ -255,6 +256,7 @@ export default function DetonationOverlay({ const rebuildRef = useRef<{ start: number; from: number } | null>(null); const onDoneRef = useRef(onDone); const [phase, setPhase] = useState("burning"); + const [progress] = useState(eggProgress); useEffect(() => { onDoneRef.current = onDone; @@ -515,6 +517,7 @@ export default function DetonationOverlay({ return createPortal(

- Easter egg found + Easter egg {progress.found}/{progress.total} found

`. */ -export function detonatePage(origin: DetonationOrigin) { +export function detonatePage( + origin: DetonationOrigin, + egg: EasterEgg = "detonate", +) { window.dispatchEvent( - new CustomEvent(DETONATE_EVENT, { detail: origin }), + new CustomEvent(DETONATE_EVENT, { + detail: { ...origin, egg }, + }), ); } diff --git a/components/detonation/page-detonation.tsx b/components/detonation/page-detonation.tsx index 272a8253..15b7fb83 100644 --- a/components/detonation/page-detonation.tsx +++ b/components/detonation/page-detonation.tsx @@ -2,7 +2,12 @@ import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; -import { DETONATE_EVENT, type DetonationOrigin } from "./events"; +import { findEgg } from "@/components/easter-eggs/eggs"; +import { + DETONATE_EVENT, + type DetonationDetail, + type DetonationOrigin, +} from "./events"; // Only fetched once something actually detonates. const DetonationOverlay = dynamic(() => import("./detonation-overlay"), { @@ -18,9 +23,11 @@ export function PageDetonation() { useEffect(() => { const onDetonate = (event: Event) => { - const { detail } = event as CustomEvent; + const { detail } = event as CustomEvent; + // The overlay's end card announces it, so no toast. + findEgg(detail.egg); // One fire at a time. - setOrigin((current) => current ?? detail); + setOrigin((current) => current ?? { x: detail.x, y: detail.y }); }; window.addEventListener(DETONATE_EVENT, onDetonate); return () => window.removeEventListener(DETONATE_EVENT, onDetonate); diff --git a/components/easter-eggs/boom-word.tsx b/components/easter-eggs/boom-word.tsx new file mode 100644 index 00000000..7af07698 --- /dev/null +++ b/components/easter-eggs/boom-word.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { detonatePage } from "@/components/detonation/events"; +import { useKeySequence } from "./use-key-sequence"; + +const BOOM = ["b", "o", "o", "m"] as const; + +/** Type "boom" anywhere outside an input and the page goes up from the middle. */ +export function BoomWord() { + useKeySequence(BOOM, () => { + detonatePage({ x: window.innerWidth / 2, y: window.innerHeight / 2 }, "boom"); + }); + return null; +} diff --git a/components/easter-eggs/console-greeting.tsx b/components/easter-eggs/console-greeting.tsx new file mode 100644 index 00000000..bdf1b6cc --- /dev/null +++ b/components/easter-eggs/console-greeting.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; +import { EASTER_EGGS } from "./eggs"; + +let greeted = false; + +/** For whoever opens devtools: a hello, and a nudge toward the first egg. */ +export function ConsoleGreeting() { + useEffect(() => { + if (greeted) return; + greeted = true; + console.log( + "%c EVIL BUTTONS %c\n\nOpening the console? Bold.\n%d easter eggs are hidden on this site. Start with ↑ ↑ ↓ ↓ ← → ← → B A.", + "background:#e5262d;color:#fff;font:700 14px/2 monospace;padding:2px 6px;border-radius:4px", + "color:inherit;font:12px/1.6 monospace", + EASTER_EGGS.length, + ); + }, []); + return null; +} diff --git a/components/easter-eggs/easter-eggs.tsx b/components/easter-eggs/easter-eggs.tsx new file mode 100644 index 00000000..fe5d3432 --- /dev/null +++ b/components/easter-eggs/easter-eggs.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { BoomWord } from "./boom-word"; +import { ConsoleGreeting } from "./console-greeting"; +import { EggToaster } from "./egg-toast"; +import { HellMode } from "./hell-mode"; +import { LightsOut } from "./lights-out"; +import { ScreenCracks } from "./screen-cracks"; +import { TabTitle } from "./tab-title"; +import { WatchingEyes } from "./watching-eyes"; + +/** Every hidden egg on the site, mounted once in the root layout. */ +export function EasterEggs() { + return ( + <> + + + + + + + + + + ); +} diff --git a/components/easter-eggs/egg-toast.tsx b/components/easter-eggs/egg-toast.tsx new file mode 100644 index 00000000..783a16fd --- /dev/null +++ b/components/easter-eggs/egg-toast.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useSyncExternalStore } from "react"; + +type Toast = { + key: number; + title: string; + note?: string; + found: number; + total: number; +}; + +const DISMISS_AFTER = 4200; + +let current: Toast | null = null; +let nextKey = 0; +const listeners = new Set<() => void>(); + +function emit(toast: Toast | null) { + current = toast; + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function showEggToast(toast: Omit) { + emit({ ...toast, key: nextKey++ }); +} + +export function EggToaster() { + const toast = useSyncExternalStore( + subscribe, + () => current, + () => null, + ); + + useEffect(() => { + if (!toast) return; + const timeout = window.setTimeout(() => { + if (current?.key === toast.key) emit(null); + }, DISMISS_AFTER); + return () => window.clearTimeout(timeout); + }, [toast]); + + return ( +
+ + {toast ? ( + + + {toast.found}/{toast.total} + + + {toast.title} + {toast.note ? ( + {toast.note} + ) : null} + + + ) : null} + +
+ ); +} diff --git a/components/easter-eggs/eggs.ts b/components/easter-eggs/eggs.ts new file mode 100644 index 00000000..d81f39a8 --- /dev/null +++ b/components/easter-eggs/eggs.ts @@ -0,0 +1,64 @@ +import { showEggToast } from "./egg-toast"; + +export const EASTER_EGGS = [ + "detonate", + "boom", + "hell", + "lights", + "eyes", + "cracks", + "tab", +] as const; + +export type EasterEgg = (typeof EASTER_EGGS)[number]; + +const STORAGE_KEY = "evilbuttons:eggs"; + +function readFound(): Set { + try { + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + if (!Array.isArray(stored)) return new Set(); + return new Set(EASTER_EGGS.filter((egg) => stored.includes(egg))); + } catch { + return new Set(); + } +} + +export function eggProgress() { + return { found: readFound().size, total: EASTER_EGGS.length }; +} + +type EggToast = { + title: string; + note?: string; + /** Only toast the first time this egg is found. */ + onlyFirst?: boolean; +}; + +/** Records `egg` as found and, unless told to stay quiet, says so in a toast. */ +export function findEgg(egg: EasterEgg, toast?: EggToast) { + const found = readFound(); + const isNew = !found.has(egg); + found.add(egg); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...found])); + } catch { + // Private mode: the egg still hatches, it just isn't remembered. + } + if (toast && (isNew || !toast.onlyFirst)) { + showEggToast({ + title: toast.title, + note: toast.note, + found: found.size, + total: EASTER_EGGS.length, + }); + } +} + +const EDITABLE_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); + +export function isEditableTarget(target: EventTarget | null) { + const element = target as HTMLElement | null; + if (!element) return false; + return EDITABLE_TAGS.has(element.tagName) || element.isContentEditable; +} diff --git a/components/easter-eggs/hell-mode.tsx b/components/easter-eggs/hell-mode.tsx new file mode 100644 index 00000000..06563c28 --- /dev/null +++ b/components/easter-eggs/hell-mode.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useRef, useState } from "react"; +import { findEgg } from "./eggs"; +import { useKeySequence } from "./use-key-sequence"; + +const KONAMI = [ + "ArrowUp", + "ArrowUp", + "ArrowDown", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowLeft", + "ArrowRight", + "b", + "a", +] as const; + +/** ↑ ↑ ↓ ↓ ← → ← → B A: the site goes to hell (styles live under `html[data-hell]`). */ +export function HellMode() { + const [on, setOn] = useState(false); + // Hell is dark: light mode only goes parchment under the filter. The theme + // is switched without saving it, and put back on the way out. + const wasLightRef = useRef(false); + + useKeySequence(KONAMI, () => { + const html = document.documentElement; + const next = !html.hasAttribute("data-hell"); + if (next) { + wasLightRef.current = !html.classList.contains("dark"); + html.classList.add("dark"); + } else if (wasLightRef.current) { + html.classList.remove("dark"); + } + html.toggleAttribute("data-hell", next); + setOn(next); + if (next) { + findEgg("hell", { + title: "Welcome to hell mode.", + note: "Enter the code again to escape.", + }); + } + }); + + return on ?
: null; +} diff --git a/components/easter-eggs/lights-out.tsx b/components/easter-eggs/lights-out.tsx new file mode 100644 index 00000000..6451ff01 --- /dev/null +++ b/components/easter-eggs/lights-out.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { findEgg } from "./eggs"; + +/** Theme flips inside WINDOW ms that count as abuse. */ +const FLIPS = 6; +const WINDOW = 3000; +const DARK_FOR = 9000; + +type Phase = "off" | "dark" | "flicker"; + +/** Flick the theme back and forth enough and the lights blow: you get a flashlight. */ +export function LightsOut() { + const [phase, setPhase] = useState("off"); + const overlayRef = useRef(null); + + // Watch the class rather than the toggles, so the buttons and the `d` shortcut all count. + useEffect(() => { + const html = document.documentElement; + let dark = html.classList.contains("dark"); + const flips: number[] = []; + const observer = new MutationObserver(() => { + const next = html.classList.contains("dark"); + if (next === dark) return; + dark = next; + const now = performance.now(); + flips.push(now); + while (flips.length && now - flips[0] > WINDOW) flips.shift(); + if (flips.length >= FLIPS) { + flips.length = 0; + setPhase((current) => (current === "off" ? "dark" : current)); + } + }); + observer.observe(html, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (phase !== "dark") return; + findEgg("lights", { + title: "You broke the lights.", + note: "Hope you like the dark.", + }); + + const aim = (x: number, y: number) => { + overlayRef.current?.style.setProperty("--x", `${x}px`); + overlayRef.current?.style.setProperty("--y", `${y}px`); + }; + const onPointer = (event: PointerEvent) => aim(event.clientX, event.clientY); + aim(window.innerWidth / 2, window.innerHeight / 2); + window.addEventListener("pointermove", onPointer, { passive: true }); + window.addEventListener("pointerdown", onPointer, { passive: true }); + const timeout = window.setTimeout(() => setPhase("flicker"), DARK_FOR); + return () => { + window.removeEventListener("pointermove", onPointer); + window.removeEventListener("pointerdown", onPointer); + window.clearTimeout(timeout); + }; + }, [phase]); + + if (phase === "off") return null; + return ( +
{ + if (phase === "flicker") setPhase("off"); + }} + /> + ); +} diff --git a/components/easter-eggs/screen-cracks.tsx b/components/easter-eggs/screen-cracks.tsx new file mode 100644 index 00000000..6e92bf08 --- /dev/null +++ b/components/easter-eggs/screen-cracks.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { findEgg } from "./eggs"; + +/** Clicks, within STREAK_MS and STREAK_RADIUS px of each other, that count as a rage. */ +const RAGE_CLICKS = 5; +const STREAK_MS = 1200; +const STREAK_RADIUS = 90; +const MAX_CRACKS = 8; +const SIZE = 420; + +/** Clicks on anything you'd legitimately mash are left alone. */ +const INTERACTIVE = + "a, button, input, textarea, select, label, summary, canvas, video, [contenteditable], [role=button], [role=slider], [role=switch], [role=tab], [role=link], [role=menuitem], [role=option], [role=checkbox], [role=dialog], [role=alertdialog], [data-detonation]"; + +type Crack = { id: number; x: number; y: number; seed: number }; + +function mulberry32(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Jagged radial cracks from the impact, joined by broken rings like struck glass. */ +function crackPath(seed: number) { + const random = mulberry32(seed); + const spokes = 9 + Math.floor(random() * 6); + const angles = Array.from( + { length: spokes }, + (_, i) => ((i + random() * 0.6) / spokes) * Math.PI * 2, + ).sort((a, b) => a - b); + + let d = ""; + // Where each spoke crosses each ring, so the rings can hop between spokes. + const crossings: Array> = [[], []]; + const rings = [28 + random() * 18, 70 + random() * 30]; + + for (const start of angles) { + let angle = start; + let radius = 4 + random() * 6; + let x = Math.cos(angle) * radius; + let y = Math.sin(angle) * radius; + d += `M${x.toFixed(1)} ${y.toFixed(1)}`; + const reach = 90 + random() * 120; + const ringHits: Array<[number, number] | null> = [null, null]; + while (radius < reach) { + radius += 12 + random() * 22; + angle += (random() - 0.5) * 0.35; + x = Math.cos(angle) * radius; + y = Math.sin(angle) * radius; + d += `L${x.toFixed(1)} ${y.toFixed(1)}`; + rings.forEach((ring, i) => { + if (!ringHits[i] && radius >= ring) ringHits[i] = [x, y]; + }); + } + ringHits.forEach((hit, i) => crossings[i].push(hit ?? [Number.NaN, Number.NaN])); + } + + for (const ring of crossings) { + for (let i = 0; i < ring.length; i++) { + const [ax, ay] = ring[i]; + const [bx, by] = ring[(i + 1) % ring.length]; + if (Number.isNaN(ax) || Number.isNaN(bx) || random() < 0.3) continue; + // A kink in the middle keeps the ring from looking drawn with a compass. + const mx = (ax + bx) / 2 + (random() - 0.5) * 10; + const my = (ay + by) / 2 + (random() - 0.5) * 10; + d += `M${ax.toFixed(1)} ${ay.toFixed(1)}L${mx.toFixed(1)} ${my.toFixed(1)}L${bx.toFixed(1)} ${by.toFixed(1)}`; + } + } + return d; +} + +function CrackMark({ crack, onDone }: { crack: Crack; onDone: () => void }) { + const [d] = useState(() => crackPath(crack.seed)); + return ( + + + + + + ); +} + +/** Rage-click empty space and the screen gives way. */ +export function ScreenCracks() { + const [cracks, setCracks] = useState([]); + + useEffect(() => { + let streak: Array<{ x: number; y: number; t: number }> = []; + let nextId = 0; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + const target = event.target as Element | null; + if (target?.closest?.(INTERACTIVE)) return; + + const now = performance.now(); + const point = { x: event.clientX, y: event.clientY, t: now }; + streak = streak.filter( + (click) => + now - click.t < STREAK_MS && + Math.hypot(click.x - point.x, click.y - point.y) < STREAK_RADIUS, + ); + streak.push(point); + if (streak.length < RAGE_CLICKS) return; + + if (streak.length === RAGE_CLICKS) { + findEgg("cracks", { title: "Easy.", note: "That's a screen, not a button." }); + } + if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + document.body.animate( + [ + { transform: "translate(0, 0)" }, + { transform: "translate(-4px, 3px)" }, + { transform: "translate(3px, -2px)" }, + { transform: "translate(0, 0)" }, + ], + { duration: 160 }, + ); + } + const crack = { id: nextId++, x: point.x, y: point.y, seed: Math.floor(Math.random() * 1e9) }; + setCracks((current) => [...current, crack].slice(-MAX_CRACKS)); + }; + + window.addEventListener("pointerdown", onPointerDown, { passive: true }); + return () => window.removeEventListener("pointerdown", onPointerDown); + }, []); + + if (cracks.length === 0) return null; + return ( +
+ {cracks.map((crack) => ( + setCracks((current) => current.filter((c) => c.id !== crack.id))} + /> + ))} +
+ ); +} diff --git a/components/easter-eggs/tab-title.tsx b/components/easter-eggs/tab-title.tsx new file mode 100644 index 00000000..2f987393 --- /dev/null +++ b/components/easter-eggs/tab-title.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect } from "react"; +import { findEgg } from "./eggs"; + +const AWAY = "👁 Come back…"; +const BACK = "😈 Knew you would."; + +/** Leave the tab and it begs; come back and it gloats, then quietly restores its title. */ +export function TabTitle() { + useEffect(() => { + let saved: string | null = null; + let restore: number | null = null; + + const onVisibilityChange = () => { + if (document.hidden) { + if (restore !== null) window.clearTimeout(restore); + saved ??= document.title; + document.title = AWAY; + return; + } + if (saved === null) return; + const original = saved; + saved = null; + // Navigation while away already replaced the title; leave that one alone. + if (document.title !== AWAY) return; + document.title = BACK; + restore = window.setTimeout(() => { + restore = null; + if (document.title === BACK) document.title = original; + }, 2200); + findEgg("tab", { title: "Your tab missed you.", note: "Check the title.", onlyFirst: true }); + }; + + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + if (restore !== null) window.clearTimeout(restore); + }; + }, []); + return null; +} diff --git a/components/easter-eggs/use-key-sequence.ts b/components/easter-eggs/use-key-sequence.ts new file mode 100644 index 00000000..10bb3f3e --- /dev/null +++ b/components/easter-eggs/use-key-sequence.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; +import { isEditableTarget } from "./eggs"; + +/** Calls `onMatch` when `sequence` (`KeyboardEvent.key` values, letters lowercase) is typed outside inputs. */ +export function useKeySequence(sequence: readonly string[], onMatch: () => void) { + const onMatchRef = useRef(onMatch); + useEffect(() => { + onMatchRef.current = onMatch; + }, [onMatch]); + + useEffect(() => { + let progress = 0; + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) return; + if (isEditableTarget(event.target)) return; + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + if (key === sequence[progress]) progress += 1; + else progress = key === sequence[0] ? 1 : 0; + if (progress === sequence.length) { + progress = 0; + onMatchRef.current(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [sequence]); +} diff --git a/components/easter-eggs/watching-eyes.tsx b/components/easter-eggs/watching-eyes.tsx new file mode 100644 index 00000000..a0408c18 --- /dev/null +++ b/components/easter-eggs/watching-eyes.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { findEgg } from "./eggs"; + +/** How long the page must sit untouched before something notices. */ +const IDLE_AFTER = 60_000; +const PAIRS = 4; + +type Point = { x: number; y: number }; + +type Pair = Point & { + size: number; + delay: number; + blink: number; + /** Pupil offset in viewBox units, toward where the pointer was last seen. */ + look: Point; +}; + +/** Places pairs along the edges, away from whatever was being read in the middle. */ +function placePairs(width: number, height: number, pointer: Point): Pair[] { + const pairs: Pair[] = []; + for (let i = 0; i < PAIRS; i++) { + const side = i % 4; + const along = 0.15 + Math.random() * 0.7; + const inset = 0.05 + Math.random() * 0.1; + const x = side === 0 ? inset : side === 1 ? 1 - inset : along; + const y = side === 2 ? inset : side === 3 ? 1 - inset : along; + const dx = pointer.x - x * width; + const dy = pointer.y - y * height; + const distance = Math.hypot(dx, dy) || 1; + pairs.push({ + x: x * width, + y: y * height, + look: { x: (dx / distance) * 1.8, y: (dy / distance) * 1.2 }, + size: 14 + Math.random() * 12, + delay: 0.6 + i * 1.1 + Math.random() * 0.6, + blink: 3.5 + Math.random() * 3, + }); + } + return pairs; +} + +/** Leave the page alone for a minute and red eyes open in the dark around it. */ +export function WatchingEyes() { + const [pairs, setPairs] = useState(null); + const [leaving, setLeaving] = useState(false); + + useEffect(() => { + let lastActive = performance.now(); + let showing = false; + let leaveTimeout: number | null = null; + let pointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + + const onActivity = (event: Event) => { + lastActive = performance.now(); + if (event instanceof PointerEvent) { + pointer = { x: event.clientX, y: event.clientY }; + } + if (!showing || leaveTimeout !== null) return; + // Caught: they blink shut and slip away. + setLeaving(true); + leaveTimeout = window.setTimeout(() => { + leaveTimeout = null; + showing = false; + setPairs(null); + setLeaving(false); + findEgg("eyes", { title: "Something was watching you.", note: "It still is." }); + }, 260); + }; + + const check = window.setInterval(() => { + if (showing || document.hidden) return; + if (performance.now() - lastActive < IDLE_AFTER) return; + // Don't pile onto another egg (the burnt page, the flashlight) that already owns the screen. + if (document.querySelector(".lights-out, [data-detonation]")) return; + showing = true; + setPairs(placePairs(window.innerWidth, window.innerHeight, pointer)); + }, 2000); + + const events = ["pointermove", "pointerdown", "keydown", "wheel", "touchstart"] as const; + for (const name of events) { + window.addEventListener(name, onActivity, { passive: true, capture: true }); + } + window.addEventListener("scroll", onActivity, { passive: true, capture: true }); + return () => { + window.clearInterval(check); + if (leaveTimeout !== null) window.clearTimeout(leaveTimeout); + for (const name of events) { + window.removeEventListener(name, onActivity, { capture: true }); + } + window.removeEventListener("scroll", onActivity, { capture: true }); + }; + }, []); + + if (!pairs) return null; + return ( +
+ {pairs.map((pair, index) => ( + + + {[-8, 8].map((cx) => ( + + + + + + ))} + + + ))} +
+ ); +} From 6d5f5464922cee74925858700a7c61c3c29dbed9 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Fri, 9 Oct 2026 16:04:59 +0530 Subject: [PATCH 2/4] Make the easter eggs discoverable - An egg counter (x/7) in the landing nav, landing footer and docs sidebar opens an Egg hunt panel: a riddle for each unfound egg, and how it was done for each found one - Every find toast now hints at another unfound egg and opens the hunt when clicked; the detonation end card links to it too - First-time visitors get a one-off teaser toast after 12s - Flipping the theme twice whispers that the bulbs are old - Lower thresholds so eggs happen in normal use: eyes after 30s idle, lights after 4 flips, cracks after 4 clicks - Hell mode's forced dark no longer counts as a theme flip --- app/globals.css | 8 ++ components/detonation/detonation-overlay.tsx | 15 +++ components/docs-sidebar.tsx | 2 + components/easter-eggs/console-greeting.tsx | 2 +- components/easter-eggs/easter-eggs.tsx | 4 + components/easter-eggs/egg-hunt.tsx | 121 +++++++++++++++++++ components/easter-eggs/egg-teaser.tsx | 31 +++++ components/easter-eggs/egg-toast.tsx | 31 +++-- components/easter-eggs/eggs.ts | 119 +++++++++++++++--- components/easter-eggs/hunt-store.ts | 24 ++++ components/easter-eggs/lights-out.tsx | 26 +++- components/easter-eggs/screen-cracks.tsx | 2 +- components/easter-eggs/watching-eyes.tsx | 2 +- components/landing/landing-footer.tsx | 3 + components/landing/landing-page.tsx | 2 + 15 files changed, 360 insertions(+), 32 deletions(-) create mode 100644 components/easter-eggs/egg-hunt.tsx create mode 100644 components/easter-eggs/egg-teaser.tsx create mode 100644 components/easter-eggs/hunt-store.ts diff --git a/app/globals.css b/app/globals.css index 0dd8771b..ad13bb84 100644 --- a/app/globals.css +++ b/app/globals.css @@ -862,3 +862,11 @@ html[data-hell] * { animation: lights-fade 0.4s forwards; } } + +@keyframes egg-wobble { + 0%, 84%, 100% { transform: none; } + 87% { transform: rotate(-14deg); } + 90% { transform: rotate(10deg); } + 93% { transform: rotate(-6deg); } + 96% { transform: rotate(3deg); } +} diff --git a/components/detonation/detonation-overlay.tsx b/components/detonation/detonation-overlay.tsx index b2a42cf1..fcb58703 100644 --- a/components/detonation/detonation-overlay.tsx +++ b/components/detonation/detonation-overlay.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { eggProgress } from "@/components/easter-eggs/eggs"; +import { openEggHunt } from "@/components/easter-eggs/hunt-store"; import type { DetonationOrigin } from "./events"; /* @@ -255,6 +256,7 @@ export default function DetonationOverlay({ const phaseRef = useRef("burning"); const rebuildRef = useRef<{ start: number; from: number } | null>(null); const onDoneRef = useRef(onDone); + const huntAfterRef = useRef(false); const [phase, setPhase] = useState("burning"); const [progress] = useState(eggProgress); @@ -408,6 +410,7 @@ export default function DetonationOverlay({ fade = 1 - clamp01((now - doneAt) / 1000 / FADE_OUT); if (fade <= 0) { onDoneRef.current(); + if (huntAfterRef.current) openEggHunt(); return; } } @@ -557,6 +560,18 @@ export default function DetonationOverlay({ Rebuild the page

or press Esc

+ {progress.found < progress.total ? ( + + ) : null}
) : null} diff --git a/components/docs-sidebar.tsx b/components/docs-sidebar.tsx index c75a631c..1d0212f8 100644 --- a/components/docs-sidebar.tsx +++ b/components/docs-sidebar.tsx @@ -23,6 +23,7 @@ import { SheetTrigger, } from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; +import { EggHuntButton } from "@/components/easter-eggs/egg-hunt"; type DocsNavPage = { title: string; @@ -175,6 +176,7 @@ function DocsSidebarChrome({ > +

+ · +

{legalLinks.map((link, index) => ( diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index a555cbea..a33c0915 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -19,6 +19,7 @@ import { import { useAppTheme } from "@/hooks/use-app-theme"; import { siteConfig } from "@/lib/seo"; import { toggleTheme } from "@/lib/theme-preference"; +import { EggHuntButton } from "@/components/easter-eggs/egg-hunt"; type LandingPageProps = { /** Docs category per showcase href, read from the MDX frontmatter. */ @@ -67,6 +68,7 @@ function Navbar() { > + + + ) : ( +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/components/easter-eggs/glitch-storm.tsx b/components/easter-eggs/glitch-storm.tsx new file mode 100644 index 00000000..fb9144bf --- /dev/null +++ b/components/easter-eggs/glitch-storm.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; + +const GLITCH_FOR = 2600; +const FRAME_MS = 70; +const COLORS = ["#ff2a3c", "#00f0ff", "#ffffff", "#ff00d4"]; + +type Bar = { top: number; height: number; shift: number; color: string }; + +function randomBars(): Bar[] { + return Array.from({ length: 4 + Math.floor(Math.random() * 6) }, () => ({ + top: Math.random() * 100, + height: 0.4 + Math.random() * 7, + shift: (Math.random() - 0.5) * 18, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + })); +} + +/** Mash the GlitchButton and the glitch escapes into the whole page for a moment. */ +export function GlitchStorm() { + const [bars, setBars] = useState(null); + + useEffect(() => { + let interval: number | null = null; + let timeout: number | null = null; + const stop = () => { + if (interval !== null) window.clearInterval(interval); + if (timeout !== null) window.clearTimeout(timeout); + interval = timeout = null; + document.documentElement.removeAttribute("data-glitch"); + setBars(null); + }; + + const offEgg = onEgg("glitch", () => { + if (interval !== null) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + document.documentElement.setAttribute("data-glitch", ""); + setBars(randomBars()); + if (!reduceMotion) interval = window.setInterval(() => setBars(randomBars()), FRAME_MS); + timeout = window.setTimeout(() => { + stop(); + findEgg("glitch", { title: "Corrupted.", note: "The glitch got out of its button." }); + }, reduceMotion ? 700 : GLITCH_FOR); + }); + return () => { + offEgg(); + stop(); + }; + }, []); + + if (!bars) return null; + return ( +
+ {bars.map((bar, index) => ( + + ))} +

SIGNAL CORRUPTED

+
+ ); +} diff --git a/components/easter-eggs/hell-mode.tsx b/components/easter-eggs/hell-mode.tsx index 06563c28..c64cb56e 100644 --- a/components/easter-eggs/hell-mode.tsx +++ b/components/easter-eggs/hell-mode.tsx @@ -1,47 +1,54 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { findEgg } from "./eggs"; -import { useKeySequence } from "./use-key-sequence"; - -const KONAMI = [ - "ArrowUp", - "ArrowUp", - "ArrowDown", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - "ArrowLeft", - "ArrowRight", - "b", - "a", -] as const; - -/** ↑ ↑ ↓ ↓ ← → ← → B A: the site goes to hell (styles live under `html[data-hell]`). */ +import { onEgg } from "./triggers"; + +const HELL_FOR = 12_000; + +/** The demon gets out: the site goes to hell for a while (styles live under `html[data-hell]`). */ export function HellMode() { const [on, setOn] = useState(false); - // Hell is dark: light mode only goes parchment under the filter. The theme - // is switched without saving it, and put back on the way out. - const wasLightRef = useRef(false); - useKeySequence(KONAMI, () => { + useEffect(() => { const html = document.documentElement; - const next = !html.hasAttribute("data-hell"); - if (next) { - wasLightRef.current = !html.classList.contains("dark"); - html.classList.add("dark"); - } else if (wasLightRef.current) { - html.classList.remove("dark"); - } - html.toggleAttribute("data-hell", next); - setOn(next); - if (next) { - findEgg("hell", { - title: "Welcome to hell mode.", - note: "Enter the code again to escape.", - }); - } - }); + let timeout: number | null = null; + // Hell is dark: light mode only goes parchment under the filter. The theme + // is switched without saving it, and put back on the way out. + let wasLight = false; + + const leave = () => { + if (timeout !== null) window.clearTimeout(timeout); + timeout = null; + if (!html.hasAttribute("data-hell")) return; + if (wasLight) html.classList.remove("dark"); + html.removeAttribute("data-hell"); + setOn(false); + }; + + const offEgg = onEgg("hell", () => { + if (!html.hasAttribute("data-hell")) { + wasLight = !html.classList.contains("dark"); + html.classList.add("dark"); + html.setAttribute("data-hell", ""); + setOn(true); + } + // Summoning again keeps the gates open longer. + if (timeout !== null) window.clearTimeout(timeout); + timeout = window.setTimeout(leave, HELL_FOR); + findEgg("hell", { title: "You let it out.", note: "Hell mode for 12 seconds. Esc if you can't take it." }); + }); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") leave(); + }; + window.addEventListener("keydown", onKeyDown); + return () => { + offEgg(); + window.removeEventListener("keydown", onKeyDown); + leave(); + }; + }, []); return on ?
: null; } diff --git a/components/easter-eggs/lights-out.tsx b/components/easter-eggs/lights-out.tsx index 56751b00..b90cfffe 100644 --- a/components/easter-eggs/lights-out.tsx +++ b/components/easter-eggs/lights-out.tsx @@ -1,63 +1,27 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { showEggToast } from "./egg-toast"; -import { findEgg, useFoundEggs } from "./eggs"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; -/** Theme flips inside WINDOW ms that count as abuse. */ -const FLIPS = 4; -const WINDOW = 4000; const DARK_FOR = 9000; type Phase = "off" | "dark" | "flicker"; -/** Flick the theme back and forth enough and the lights blow: you get a flashlight. */ +/** Flick the RealisticSwitch hard enough and the fuse blows: you get a flashlight. */ export function LightsOut() { const [phase, setPhase] = useState("off"); const overlayRef = useRef(null); - const found = useFoundEggs().includes("lights"); - const foundRef = useRef(found); - useEffect(() => { - foundRef.current = found; - }, [found]); - // Watch the class rather than the toggles, so the buttons and the `d` shortcut all count. - useEffect(() => { - const html = document.documentElement; - let dark = html.classList.contains("dark"); - let hell = html.hasAttribute("data-hell"); - const flips: number[] = []; - let nudged = false; - const observer = new MutationObserver(() => { - const next = html.classList.contains("dark"); - const nextHell = html.hasAttribute("data-hell"); - const hellChanged = nextHell !== hell; - hell = nextHell; - if (next === dark) return; - dark = next; - // Hell mode forces dark on its way in and out; that's not the visitor flipping. - if (hellChanged) return; - const now = performance.now(); - flips.push(now); - while (flips.length && now - flips[0] > WINDOW) flips.shift(); - // Halfway there, the bulbs complain, so people know to keep going. - if (flips.length === 2 && !nudged && !foundRef.current) { - nudged = true; - showEggToast({ title: "The bulbs flicker…", note: "They're old. Don't push it." }); - } - if (flips.length >= FLIPS) { - flips.length = 0; - setPhase((current) => (current === "off" ? "dark" : current)); - } - }); - observer.observe(html, { attributes: true, attributeFilter: ["class", "data-hell"] }); - return () => observer.disconnect(); - }, []); + useEffect( + () => onEgg("lights", () => setPhase((current) => (current === "off" ? "dark" : current))), + [], + ); useEffect(() => { if (phase !== "dark") return; findEgg("lights", { - title: "You broke the lights.", + title: "You blew the fuse.", note: "Hope you like the dark.", }); diff --git a/components/easter-eggs/screen-cracks.tsx b/components/easter-eggs/screen-cracks.tsx index 458fd8c9..8763f2ce 100644 --- a/components/easter-eggs/screen-cracks.tsx +++ b/components/easter-eggs/screen-cracks.tsx @@ -2,18 +2,11 @@ import { useEffect, useState } from "react"; import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; -/** Clicks, within STREAK_MS and STREAK_RADIUS px of each other, that count as a rage. */ -const RAGE_CLICKS = 4; -const STREAK_MS = 1200; -const STREAK_RADIUS = 90; const MAX_CRACKS = 8; const SIZE = 420; -/** Clicks on anything you'd legitimately mash are left alone. */ -const INTERACTIVE = - "a, button, input, textarea, select, label, summary, canvas, video, [contenteditable], [role=button], [role=slider], [role=switch], [role=tab], [role=link], [role=menuitem], [role=option], [role=checkbox], [role=dialog], [role=alertdialog], [data-detonation]"; - type Crack = { id: number; x: number; y: number; seed: number }; function mulberry32(seed: number) { @@ -93,49 +86,36 @@ function CrackMark({ crack, onDone }: { crack: Crack; onDone: () => void }) { ); } -/** Rage-click empty space and the screen gives way. */ +/** Smash the BrutalButton and the screen gives way around it; keep smashing for more. */ export function ScreenCracks() { const [cracks, setCracks] = useState([]); useEffect(() => { - let streak: Array<{ x: number; y: number; t: number }> = []; let nextId = 0; - - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0) return; - const target = event.target as Element | null; - if (target?.closest?.(INTERACTIVE)) return; - - const now = performance.now(); - const point = { x: event.clientX, y: event.clientY, t: now }; - streak = streak.filter( - (click) => - now - click.t < STREAK_MS && - Math.hypot(click.x - point.x, click.y - point.y) < STREAK_RADIUS, - ); - streak.push(point); - if (streak.length < RAGE_CLICKS) return; - - if (streak.length === RAGE_CLICKS) { - findEgg("cracks", { title: "Easy.", note: "That's a screen, not a button." }); - } + return onEgg("cracks", (point) => { + if (!point) return; + findEgg("cracks", { title: "Easy.", note: "That's a screen, not a punching bag.", onlyFirst: true }); if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) { document.body.animate( [ { transform: "translate(0, 0)" }, - { transform: "translate(-4px, 3px)" }, - { transform: "translate(3px, -2px)" }, + { transform: "translate(-5px, 4px)" }, + { transform: "translate(4px, -3px)" }, { transform: "translate(0, 0)" }, ], - { duration: 160 }, + { duration: 180 }, ); } - const crack = { id: nextId++, x: point.x, y: point.y, seed: Math.floor(Math.random() * 1e9) }; + // Each hit lands somewhere around the button, not on the same spot. + const spread = nextId === 0 ? 0 : 70; + const crack = { + id: nextId++, + x: point.x + (Math.random() - 0.5) * spread * 2, + y: point.y + (Math.random() - 0.5) * spread, + seed: Math.floor(Math.random() * 1e9), + }; setCracks((current) => [...current, crack].slice(-MAX_CRACKS)); - }; - - window.addEventListener("pointerdown", onPointerDown, { passive: true }); - return () => window.removeEventListener("pointerdown", onPointerDown); + }); }, []); if (cracks.length === 0) return null; diff --git a/components/easter-eggs/tab-title.tsx b/components/easter-eggs/tab-title.tsx deleted file mode 100644 index 2f987393..00000000 --- a/components/easter-eggs/tab-title.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { findEgg } from "./eggs"; - -const AWAY = "👁 Come back…"; -const BACK = "😈 Knew you would."; - -/** Leave the tab and it begs; come back and it gloats, then quietly restores its title. */ -export function TabTitle() { - useEffect(() => { - let saved: string | null = null; - let restore: number | null = null; - - const onVisibilityChange = () => { - if (document.hidden) { - if (restore !== null) window.clearTimeout(restore); - saved ??= document.title; - document.title = AWAY; - return; - } - if (saved === null) return; - const original = saved; - saved = null; - // Navigation while away already replaced the title; leave that one alone. - if (document.title !== AWAY) return; - document.title = BACK; - restore = window.setTimeout(() => { - restore = null; - if (document.title === BACK) document.title = original; - }, 2200); - findEgg("tab", { title: "Your tab missed you.", note: "Check the title.", onlyFirst: true }); - }; - - document.addEventListener("visibilitychange", onVisibilityChange); - return () => { - document.removeEventListener("visibilitychange", onVisibilityChange); - if (restore !== null) window.clearTimeout(restore); - }; - }, []); - return null; -} diff --git a/components/easter-eggs/triggers.ts b/components/easter-eggs/triggers.ts new file mode 100644 index 00000000..67566855 --- /dev/null +++ b/components/easter-eggs/triggers.ts @@ -0,0 +1,40 @@ +/** Eggs whose effects live in the root layout, set off by the site's buttons. */ +export type EggEffect = "hell" | "deleted" | "eyes" | "cracks" | "glitch" | "lights"; + +export type EggPoint = { x: number; y: number }; + +const eventName = (effect: EggEffect) => `evilbuttons:egg:${effect}`; + +/** Sets off an egg's effect, optionally from a point on screen (in CSS px). */ +export function fireEgg(effect: EggEffect, point?: EggPoint) { + window.dispatchEvent(new CustomEvent(eventName(effect), { detail: point })); +} + +/** Listens for `fireEgg(effect)`; returns the unsubscribe. */ +export function onEgg(effect: EggEffect, listener: (point: EggPoint | undefined) => void) { + const handler = (event: Event) => listener((event as CustomEvent).detail); + window.addEventListener(eventName(effect), handler); + return () => window.removeEventListener(eventName(effect), handler); +} + +/** Centre of an element, for effects that start at the button. */ +export function centreOf(element: Element): EggPoint { + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; +} + +/** Counts events inside a sliding window; `hit()` returns how many are in it. */ +export function createBurstCounter(windowMs: number) { + let times: number[] = []; + return { + hit() { + const now = performance.now(); + times = times.filter((t) => now - t < windowMs); + times.push(now); + return times.length; + }, + reset() { + times = []; + }, + }; +} diff --git a/components/easter-eggs/use-key-sequence.ts b/components/easter-eggs/use-key-sequence.ts deleted file mode 100644 index 10bb3f3e..00000000 --- a/components/easter-eggs/use-key-sequence.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useRef } from "react"; -import { isEditableTarget } from "./eggs"; - -/** Calls `onMatch` when `sequence` (`KeyboardEvent.key` values, letters lowercase) is typed outside inputs. */ -export function useKeySequence(sequence: readonly string[], onMatch: () => void) { - const onMatchRef = useRef(onMatch); - useEffect(() => { - onMatchRef.current = onMatch; - }, [onMatch]); - - useEffect(() => { - let progress = 0; - const onKeyDown = (event: KeyboardEvent) => { - if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) return; - if (isEditableTarget(event.target)) return; - const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; - if (key === sequence[progress]) progress += 1; - else progress = key === sequence[0] ? 1 : 0; - if (progress === sequence.length) { - progress = 0; - onMatchRef.current(); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [sequence]); -} diff --git a/components/easter-eggs/watching-eyes.tsx b/components/easter-eggs/watching-eyes.tsx index 18338213..05d06bb9 100644 --- a/components/easter-eggs/watching-eyes.tsx +++ b/components/easter-eggs/watching-eyes.tsx @@ -1,127 +1,113 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; -/** How long the page must sit untouched before something notices. */ -const IDLE_AFTER = 30_000; -const PAIRS = 4; +const PAIRS = 6; +const WATCH_FOR = 7000; -type Point = { x: number; y: number }; - -type Pair = Point & { - size: number; - delay: number; - blink: number; - /** Pupil offset in viewBox units, toward where the pointer was last seen. */ - look: Point; -}; +type Pair = { x: number; y: number; size: number; delay: number; blink: number }; /** Places pairs along the edges, away from whatever was being read in the middle. */ -function placePairs(width: number, height: number, pointer: Point): Pair[] { +function placePairs(width: number, height: number): Pair[] { const pairs: Pair[] = []; for (let i = 0; i < PAIRS; i++) { const side = i % 4; - const along = 0.15 + Math.random() * 0.7; + const along = 0.12 + Math.random() * 0.76; const inset = 0.05 + Math.random() * 0.1; const x = side === 0 ? inset : side === 1 ? 1 - inset : along; const y = side === 2 ? inset : side === 3 ? 1 - inset : along; - const dx = pointer.x - x * width; - const dy = pointer.y - y * height; - const distance = Math.hypot(dx, dy) || 1; pairs.push({ x: x * width, y: y * height, - look: { x: (dx / distance) * 1.8, y: (dy / distance) * 1.2 }, size: 14 + Math.random() * 12, - delay: 0.6 + i * 1.1 + Math.random() * 0.6, + delay: 0.15 + i * 0.35 + Math.random() * 0.3, blink: 3.5 + Math.random() * 3, }); } return pairs; } -/** Leave the page alone for a minute and red eyes open in the dark around it. */ +/** Stare into the EvilEyeButton long enough and eyes open all around the page, following you. */ export function WatchingEyes() { const [pairs, setPairs] = useState(null); const [leaving, setLeaving] = useState(false); + const rootRef = useRef(null); + + useEffect( + () => + onEgg("eyes", () => { + setLeaving(false); + setPairs((current) => current ?? placePairs(window.innerWidth, window.innerHeight)); + }), + [], + ); useEffect(() => { - let lastActive = performance.now(); - let showing = false; - let leaveTimeout: number | null = null; - let pointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + if (!pairs) return; + findEgg("eyes", { title: "It stared back.", note: "They all did." }); - const onActivity = (event: Event) => { - lastActive = performance.now(); - if (event instanceof PointerEvent) { - pointer = { x: event.clientX, y: event.clientY }; - } - if (!showing || leaveTimeout !== null) return; - // Caught: they blink shut and slip away. - setLeaving(true); - leaveTimeout = window.setTimeout(() => { - leaveTimeout = null; - showing = false; - setPairs(null); - setLeaving(false); - findEgg("eyes", { title: "Something was watching you.", note: "It still is." }); - }, 260); + // Pupils track the pointer directly, without re-rendering. + const look = (x: number, y: number) => { + rootRef.current?.querySelectorAll("[data-pupil]").forEach((pupil) => { + const pair = pairs[Number(pupil.dataset.pupil)]; + const dx = x - pair.x; + const dy = y - pair.y; + const distance = Math.hypot(dx, dy) || 1; + pupil.setAttribute( + "transform", + `translate(${((dx / distance) * 1.8).toFixed(2)} ${((dy / distance) * 1.2).toFixed(2)})`, + ); + }); }; + const onPointerMove = (event: PointerEvent) => look(event.clientX, event.clientY); + look(window.innerWidth / 2, window.innerHeight / 2); - const check = window.setInterval(() => { - if (showing || document.hidden) return; - if (performance.now() - lastActive < IDLE_AFTER) return; - // Don't pile onto another egg (the burnt page, the flashlight) that already owns the screen. - if (document.querySelector(".lights-out, [data-detonation]")) return; - showing = true; - setPairs(placePairs(window.innerWidth, window.innerHeight, pointer)); - }, 2000); - - const events = ["pointermove", "pointerdown", "keydown", "wheel", "touchstart"] as const; - for (const name of events) { - window.addEventListener(name, onActivity, { passive: true, capture: true }); - } - window.addEventListener("scroll", onActivity, { passive: true, capture: true }); + let gone: number | null = null; + const leave = () => { + if (gone !== null) return; + setLeaving(true); + gone = window.setTimeout(() => setPairs(null), 300); + }; + const timeout = window.setTimeout(leave, WATCH_FOR); + window.addEventListener("pointermove", onPointerMove, { passive: true }); + window.addEventListener("pointerdown", leave, { passive: true }); return () => { - window.clearInterval(check); - if (leaveTimeout !== null) window.clearTimeout(leaveTimeout); - for (const name of events) { - window.removeEventListener(name, onActivity, { capture: true }); - } - window.removeEventListener("scroll", onActivity, { capture: true }); + window.clearTimeout(timeout); + if (gone !== null) window.clearTimeout(gone); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerdown", leave); }; - }, []); + }, [pairs]); if (!pairs) return null; return ( -
+
{pairs.map((pair, index) => ( - + - - {[-8, 8].map((cx) => ( - - - - + {[-8, 8].map((cx) => ( + + + + + - ))} - - + + ))} + + ))}
); diff --git a/components/landing/previews/interaction.tsx b/components/landing/previews/interaction.tsx index ba0b5db3..ebf44711 100644 --- a/components/landing/previews/interaction.tsx +++ b/components/landing/previews/interaction.tsx @@ -5,7 +5,7 @@ import { fromFolder, listFolder } from "./theme"; import { RevealButton } from "@/components/evil-buttons/reveal-button"; import { HoldButton } from "@/components/evil-buttons/hold-button"; import { SlideToDetonate } from "@/components/detonation/slide-to-detonate"; -import { DoubtButton } from "@/components/evil-buttons/doubt-button"; +import { DoubtButton } from "@/components/easter-eggs/buttons"; import { CooldownButton } from "@/components/evil-buttons/cooldown-button"; import { MorphStatusButton } from "@/components/evil-buttons/morph-status-button"; import { LiveProps } from "./live-props"; diff --git a/components/landing/previews/simple.tsx b/components/landing/previews/simple.tsx index d83696fe..5a9686b4 100644 --- a/components/landing/previews/simple.tsx +++ b/components/landing/previews/simple.tsx @@ -1,7 +1,7 @@ "use client"; import { useDialKit } from "dialkit"; -import { DemonicButton } from "@/components/evil-buttons/demonic-button"; +import { DemonicButton } from "@/components/easter-eggs/buttons"; import ChromeButton, { type ChromeSize, type ChromeTone, diff --git a/components/landing/previews/static.tsx b/components/landing/previews/static.tsx index 5ff719d7..1521eb4c 100644 --- a/components/landing/previews/static.tsx +++ b/components/landing/previews/static.tsx @@ -4,13 +4,17 @@ import { useIsDarkMode } from "@/hooks/use-app-theme"; import { RevealButton } from "@/components/evil-buttons/reveal-button"; import { HoldButton } from "@/components/evil-buttons/hold-button"; import { SlideToDetonate } from "@/components/detonation/slide-to-detonate"; -import { DoubtButton } from "@/components/evil-buttons/doubt-button"; +import { + BrutalButton, + DemonicButton, + DoubtButton, + EvilEyeButton, + GlitchButton, + RealisticSwitch, +} from "@/components/easter-eggs/buttons"; import { CooldownButton } from "@/components/evil-buttons/cooldown-button"; import { MorphStatusButton } from "@/components/evil-buttons/morph-status-button"; -import { BrutalButton } from "@/components/evil-buttons/brutal-button"; import DitherButton from "@/components/evil-buttons/dither-button"; -import { GlitchButton } from "@/components/evil-buttons/glitch-button"; -import EvilEyeButton from "@/components/evil-buttons/evil-eye-button"; import { AquaButton } from "@/components/evil-buttons/aqua-button"; import { FrameButton } from "@/components/evil-buttons/frame-button"; import { HighlightButton } from "@/components/evil-buttons/highlight-button"; @@ -20,8 +24,6 @@ import { MinecraftButton } from "@/components/evil-buttons/minecraft-button"; import { CommandButton } from "@/components/evil-buttons/command-button"; import { CopyButton } from "@/components/evil-buttons/copy-button"; import { ClickPowerUp } from "@/components/evil-buttons/click-powerup"; -import { RealisticSwitch } from "@/components/evil-buttons/realistic-switch"; -import { DemonicButton } from "@/components/evil-buttons/demonic-button"; import ChromeButton from "@/components/evil-buttons/chrome-button"; import MinimalButton from "@/components/evil-buttons/minimal"; import MoviePassButton from "@/components/evil-buttons/movie-pass"; diff --git a/components/landing/previews/utility.tsx b/components/landing/previews/utility.tsx index 1f350152..97db3ffd 100644 --- a/components/landing/previews/utility.tsx +++ b/components/landing/previews/utility.tsx @@ -5,10 +5,10 @@ import { CommandButton } from "@/components/evil-buttons/command-button"; import { CopyButton } from "@/components/evil-buttons/copy-button"; import { ClickPowerUp } from "@/components/evil-buttons/click-powerup"; import { - RealisticSwitch, type RealisticSwitchSize, type RealisticSwitchTone, } from "@/components/evil-buttons/realistic-switch"; +import { RealisticSwitch } from "@/components/easter-eggs/buttons"; import { LiveProps } from "./live-props"; export function CommandButtonPreview() { diff --git a/components/landing/previews/visual.tsx b/components/landing/previews/visual.tsx index 062e4b10..6000bd7c 100644 --- a/components/landing/previews/visual.tsx +++ b/components/landing/previews/visual.tsx @@ -1,10 +1,8 @@ "use client"; import { useDialKit } from "dialkit"; -import { BrutalButton } from "@/components/evil-buttons/brutal-button"; +import { BrutalButton, EvilEyeButton, GlitchButton } from "@/components/easter-eggs/buttons"; import DitherButton from "@/components/evil-buttons/dither-button"; -import { GlitchButton } from "@/components/evil-buttons/glitch-button"; -import EvilEyeButton from "@/components/evil-buttons/evil-eye-button"; import { AquaButton } from "@/components/evil-buttons/aqua-button"; import { FrameButton } from "@/components/evil-buttons/frame-button"; import { HighlightButton } from "@/components/evil-buttons/highlight-button"; diff --git a/components/mdx-custom-components.tsx b/components/mdx-custom-components.tsx index b12ea3f3..0a44c435 100644 --- a/components/mdx-custom-components.tsx +++ b/components/mdx-custom-components.tsx @@ -8,25 +8,27 @@ import { isValidElement, type ReactNode } from "react"; import MoviePassButton from "./evil-buttons/movie-pass"; import ShinyButton from "./evil-buttons/shiny-button"; import DitherButton from "./evil-buttons/dither-button"; -import EvilEyeButton from "./evil-buttons/evil-eye-button"; import TrollButton from "./evil-buttons/troll-button"; import ChromeButton from "./evil-buttons/chrome-button"; -import { BrutalButton } from "./evil-buttons/brutal-button"; import { AquaButton } from "./evil-buttons/aqua-button"; import { ThreeDButton } from "./evil-buttons/3d-button"; import { FrameButton } from "./evil-buttons/frame-button"; import { HighlightButton } from "./evil-buttons/highlight-button"; -import { GlitchButton } from "./evil-buttons/glitch-button"; import { CommandButton } from "./evil-buttons/command-button"; import { CopyButton } from "./evil-buttons/copy-button"; import { RevealButton } from "./evil-buttons/reveal-button"; -import {DemonicButton} from "./evil-buttons/demonic-button"; import { HoldButton } from "./evil-buttons/hold-button"; -import { DoubtButton } from "./evil-buttons/doubt-button"; import { SlideToDetonate } from "./detonation/slide-to-detonate"; +import { + BrutalButton, + DemonicButton, + DoubtButton, + EvilEyeButton, + GlitchButton, + RealisticSwitch, +} from "./easter-eggs/buttons"; import { MorphStatusButton } from "./evil-buttons/morph-status-button"; import { CooldownButton } from "./evil-buttons/cooldown-button"; -import { RealisticSwitch } from "./evil-buttons/realistic-switch"; import { ConfettiButton } from "./evil-buttons/confetti-button"; import { AshBurstButton } from "./evil-buttons/ash-burst-button"; import { MinecraftButton } from "./evil-buttons/minecraft-button"; From cefbd8067754415c0967ca08e361c42d4d9194d8 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Fri, 9 Oct 2026 16:37:31 +0530 Subject: [PATCH 4/4] Add troll, confetti and creeper eggs; redesign the egg count New eggs, ten in all: - TrollButton: catch it and the page flips upside down for 6s - ConfettiButton: click it 5 times fast for a page-wide skull storm - MinecraftButton: break it and a creeper hisses, then the page blows apart into pixels that sharpen back up The count was a thin mono "0/7" that read like a date. It's now a pill with a bold found number and a muted total (nav, docs sidebar, footer, toast), a row of egg pips with "6 of 10 found" in the hunt panel, and "N of M found" on the detonation card. --- app/globals.css | 68 ++++++++++ components/detonation/detonation-overlay.tsx | 2 +- components/docs-sidebar.tsx | 2 +- components/easter-eggs/buttons.tsx | 49 ++++++++ components/easter-eggs/creeper.tsx | 123 +++++++++++++++++++ components/easter-eggs/easter-eggs.tsx | 6 + components/easter-eggs/egg-hunt.tsx | 38 +++--- components/easter-eggs/egg-toast.tsx | 9 +- components/easter-eggs/eggs.ts | 18 +++ components/easter-eggs/skull-storm.tsx | 68 ++++++++++ components/easter-eggs/triggers.ts | 11 +- components/easter-eggs/troll-flip.tsx | 47 +++++++ components/landing/landing-footer.tsx | 2 +- components/landing/landing-page.tsx | 2 +- components/landing/previews/simple.tsx | 4 +- components/landing/previews/static.tsx | 6 +- components/landing/previews/visual.tsx | 8 +- components/mdx-custom-components.tsx | 6 +- 18 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 components/easter-eggs/creeper.tsx create mode 100644 components/easter-eggs/skull-storm.tsx create mode 100644 components/easter-eggs/troll-flip.tsx diff --git a/app/globals.css b/app/globals.css index 369e5d9d..8a0fc1e5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -946,3 +946,71 @@ html[data-glitch] body { animation: none; } } + +html[data-flip-turning] body { + transition: transform 0.9s cubic-bezier(0.6, -0.3, 0.3, 1.3); +} + +html[data-flipped] body { + transform: rotate(180deg); +} + +.creeper { + position: fixed; + z-index: 2147482900; + pointer-events: none; + translate: -50% -50%; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.creeper-face { + width: 120px; + height: 120px; + image-rendering: pixelated; + box-shadow: 0 0 0 4px #0c160a, 0 18px 40px -10px rgb(0 0 0 / 0.6); + animation: creeper-swell 1.7s ease-in forwards; +} + +.creeper-hiss { + color: #6cc957; + font-size: 22px; + text-shadow: 0 0 2px #0c160a, 0 2px 0 #0c160a; + animation: glitch-label 0.12s steps(1) infinite; +} + +/* Swells and flashes white, like it's about to go. */ +@keyframes creeper-swell { + 0% { transform: scale(0.3) translateY(40px); opacity: 0; filter: none; } + 20% { transform: scale(1); opacity: 1; } + 45%, 65%, 85% { filter: brightness(3) saturate(0); } + 55%, 75% { filter: none; } + 100% { transform: scale(1.35); filter: brightness(4) saturate(0); } +} + +.creeper-flash { + position: fixed; + inset: 0; + z-index: 2147482900; + pointer-events: none; + background: #fff; + animation: creeper-flash 0.5s ease-out forwards; +} + +@keyframes creeper-flash { + from { opacity: 0.85; } + to { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + html[data-flip-turning] body { + transition: none; + } + + .creeper-face, + .creeper-hiss { + animation: none; + } +} diff --git a/components/detonation/detonation-overlay.tsx b/components/detonation/detonation-overlay.tsx index fcb58703..67d8e0ba 100644 --- a/components/detonation/detonation-overlay.tsx +++ b/components/detonation/detonation-overlay.tsx @@ -540,7 +540,7 @@ export default function DetonationOverlay({ >

- Easter egg {progress.found}/{progress.total} found + Easter egg · {progress.found} of {progress.total} found

- + ) { @@ -140,3 +144,48 @@ export function RealisticSwitch({ /> ); } + +/** Actually catch the troll and it gets its revenge: the page goes upside down. */ +export function TrollButton({ onClick, ...props }: React.ComponentProps) { + return ( + { + onClick?.(event); + fireEgg("flip"); + }} + /> + ); +} + +/** One burst is a celebration; spam it and the party turns. */ +export function ConfettiButton({ onClick, ...props }: React.ComponentProps) { + const [counter] = React.useState(() => createBurstCounter(SMASH_WINDOW + 600)); + return ( + { + onClick?.(event); + if (counter.hit() >= PARTY_CLICKS) { + counter.reset(); + fireEgg("skulls", centreOf(event.currentTarget)); + } + }} + /> + ); +} + +/** Mine the stone button until it breaks, and something green was waiting behind it. */ +export function MinecraftButton({ onBreak, ...props }: React.ComponentProps) { + const buttonRef = React.useRef(null); + return ( + { + onBreak?.(); + fireEgg("creeper", buttonRef.current ? centreOf(buttonRef.current) : undefined); + }} + /> + ); +} diff --git a/components/easter-eggs/creeper.tsx b/components/easter-eggs/creeper.tsx new file mode 100644 index 00000000..6ec4133a --- /dev/null +++ b/components/easter-eggs/creeper.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { findEgg } from "./eggs"; +import { type EggPoint, onEgg } from "./triggers"; + +const HISS_MS = 1700; +/** Pixel sizes the page passes through after the blast, coarse to sharp. */ +const PIXEL_STEPS = [28, 22, 16, 12, 8, 5, 3]; +const PIXEL_STEP_MS = 230; + +// The classic face on an 8x8 grid: 1 is a dark pixel. +const FACE = [ + "00000000", + "01100110", + "01100110", + "00011000", + "00111100", + "00111100", + "00100100", + "00000000", +]; +const GREENS = ["#4fa83d", "#5dbb46", "#3f8f31", "#6cc957", "#479a38"]; + +function CreeperFace() { + return ( + + {FACE.flatMap((row, y) => + [...row].map((cell, x) => ( + + )), + )} + + ); +} + +type State = { phase: "hiss"; at: EggPoint } | { phase: "pixels"; size: number } | null; + +/** Break the MinecraftButton and a creeper was waiting behind it. */ +export function Creeper() { + const [state, setState] = useState(null); + + useEffect(() => { + const timers: number[] = []; + const clear = () => timers.splice(0).forEach((id) => window.clearTimeout(id)); + + const offEgg = onEgg("creeper", (point) => { + clear(); + const at = point ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + setState({ phase: "hiss", at }); + timers.push( + window.setTimeout(() => { + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!reduceMotion) { + document.body.animate( + [ + { transform: "translate(0, 0)" }, + { transform: "translate(-14px, 9px)" }, + { transform: "translate(11px, -8px)" }, + { transform: "translate(-6px, 4px)" }, + { transform: "translate(0, 0)" }, + ], + { duration: 450 }, + ); + } + // The blast leaves the page in blocks, which slowly sharpen back up. + const steps = reduceMotion ? [] : PIXEL_STEPS; + steps.forEach((size, index) => { + timers.push(window.setTimeout(() => setState({ phase: "pixels", size }), index * PIXEL_STEP_MS)); + }); + timers.push(window.setTimeout(() => setState(null), steps.length * PIXEL_STEP_MS)); + findEgg("creeper", { title: "Ssssss… BOOM.", note: "Aw man." }); + }, HISS_MS), + ); + }); + return () => { + offEgg(); + clear(); + }; + }, []); + + useEffect(() => { + if (state?.phase !== "pixels") return; + document.body.style.filter = "url(#egg-pixelate)"; + return () => { + document.body.style.filter = ""; + }; + }, [state?.phase]); + + if (!state) return null; + if (state.phase === "pixels") { + const half = state.size / 2; + return ( + <> + + + + + + + + + +
+ + ); + } + return ( +
+
+ +
+

sssssss…

+
+ ); +} diff --git a/components/easter-eggs/easter-eggs.tsx b/components/easter-eggs/easter-eggs.tsx index 4ab868a0..9b95dbcc 100644 --- a/components/easter-eggs/easter-eggs.tsx +++ b/components/easter-eggs/easter-eggs.tsx @@ -1,6 +1,7 @@ "use client"; import { ConsoleGreeting } from "./console-greeting"; +import { Creeper } from "./creeper"; import { EggHuntDialog } from "./egg-hunt"; import { EggTeaser } from "./egg-teaser"; import { EggToaster } from "./egg-toast"; @@ -9,6 +10,8 @@ import { GlitchStorm } from "./glitch-storm"; import { HellMode } from "./hell-mode"; import { LightsOut } from "./lights-out"; import { ScreenCracks } from "./screen-cracks"; +import { SkullStorm } from "./skull-storm"; +import { TrollFlip } from "./troll-flip"; import { WatchingEyes } from "./watching-eyes"; /** @@ -24,6 +27,9 @@ export function EasterEggs() { + + + diff --git a/components/easter-eggs/egg-hunt.tsx b/components/easter-eggs/egg-hunt.tsx index 6ad49baa..de71ee54 100644 --- a/components/easter-eggs/egg-hunt.tsx +++ b/components/easter-eggs/egg-hunt.tsx @@ -34,16 +34,20 @@ export function EggHuntDialog() { ? "You found every one. The buttons fear you now." : `${EASTER_EGGS.length} of the buttons hide an easter egg. Play rough with them; every hint below is a real clue.`} -
-
-
+
+
+ {EASTER_EGGS.map((egg) => + found.includes(egg) ? ( + + ) : ( + + ), + )}
- - {found.length}/{EASTER_EGGS.length} - +

+ {found.length} of{" "} + {EASTER_EGGS.length} found +

@@ -92,6 +96,7 @@ export function EggHuntDialog() { /** The always-visible way in: an egg with your count, for headers and footers. */ export function EggHuntButton({ className, label = false }: { className?: string; label?: boolean }) { const found = useFoundEggs(); + const complete = found.length === EASTER_EGGS.length; return ( ); } diff --git a/components/easter-eggs/egg-toast.tsx b/components/easter-eggs/egg-toast.tsx index 9a56cfff..14a2a77f 100644 --- a/components/easter-eggs/egg-toast.tsx +++ b/components/easter-eggs/egg-toast.tsx @@ -71,9 +71,14 @@ export function EggToaster() { transition={{ type: "spring", stiffness: 420, damping: 32 }} className="pointer-events-auto flex max-w-md cursor-pointer items-center gap-3 rounded-2xl bg-neutral-950/95 py-2 pr-4 pl-2.5 text-left text-neutral-50 shadow-[0_0_0_1px_rgb(255_255_255/0.08),0_12px_32px_-8px_rgb(0_0_0/0.6),0_0_24px_-6px_rgb(230_40_40/0.5)] backdrop-blur transition-colors hover:bg-neutral-900 focus-visible:ring-2 focus-visible:ring-brand focus-visible:outline-none" > - + - {toast.progress ? `${toast.progress.found}/${toast.progress.total}` : null} + {toast.progress ? ( + + {toast.progress.found} + /{toast.progress.total} + + ) : null} {toast.title} diff --git a/components/easter-eggs/eggs.ts b/components/easter-eggs/eggs.ts index 1bdde5f9..3dc68e0f 100644 --- a/components/easter-eggs/eggs.ts +++ b/components/easter-eggs/eggs.ts @@ -9,6 +9,9 @@ export const EASTER_EGGS = [ "cracks", "glitch", "lights", + "flip", + "skulls", + "creeper", ] as const; export type EasterEgg = (typeof EASTER_EGGS)[number]; @@ -50,6 +53,21 @@ export const EGG_INFO: Record { + let interval: number | null = null; + let timeout: number | null = null; + const stop = () => { + if (interval !== null) window.clearInterval(interval); + if (timeout !== null) window.clearTimeout(timeout); + interval = timeout = null; + }; + + const offEgg = onEgg("skulls", (point) => { + if (interval !== null) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const shapes = ["💀", "🔥", "🖤"].map((text) => confetti.shapeFromText({ text, scalar: 3 })); + const origin = point + ? { x: point.x / window.innerWidth, y: point.y / window.innerHeight } + : { x: 0.5, y: 0.6 }; + + findEgg("skulls", { title: "Party's over.", note: "You had to keep clicking." }); + confetti({ + particleCount: reduceMotion ? 30 : 90, + spread: 120, + startVelocity: 55, + origin, + shapes, + scalar: 3, + ticks: 260, + zIndex: Z_INDEX, + disableForReducedMotion: false, + }); + if (reduceMotion) return; + // Then it rains skulls from the top for a while. + interval = window.setInterval(() => { + confetti({ + particleCount: 6, + angle: 270, + spread: 40, + startVelocity: 8, + gravity: 0.7, + drift: (Math.random() - 0.5) * 1.5, + origin: { x: Math.random(), y: -0.1 }, + shapes, + scalar: 2.6, + ticks: 420, + flat: true, + zIndex: Z_INDEX, + }); + }, 90); + timeout = window.setTimeout(stop, STORM_MS); + }); + return () => { + offEgg(); + stop(); + }; + }, []); + return null; +} diff --git a/components/easter-eggs/triggers.ts b/components/easter-eggs/triggers.ts index 67566855..8bf987b7 100644 --- a/components/easter-eggs/triggers.ts +++ b/components/easter-eggs/triggers.ts @@ -1,5 +1,14 @@ /** Eggs whose effects live in the root layout, set off by the site's buttons. */ -export type EggEffect = "hell" | "deleted" | "eyes" | "cracks" | "glitch" | "lights"; +export type EggEffect = + | "hell" + | "deleted" + | "eyes" + | "cracks" + | "glitch" + | "lights" + | "flip" + | "skulls" + | "creeper"; export type EggPoint = { x: number; y: number }; diff --git a/components/easter-eggs/troll-flip.tsx b/components/easter-eggs/troll-flip.tsx new file mode 100644 index 00000000..20e6a140 --- /dev/null +++ b/components/easter-eggs/troll-flip.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; + +const FLIPPED_FOR = 6000; +const TURN_MS = 900; + +/** Catch the TrollButton and it gets its revenge: the page turns upside down for a bit. */ +export function TrollFlip() { + useEffect(() => { + const html = document.documentElement; + const timers: number[] = []; + const clear = () => timers.splice(0).forEach((id) => window.clearTimeout(id)); + + const flipBack = () => { + if (!html.hasAttribute("data-flipped")) return; + clear(); + html.removeAttribute("data-flipped"); + timers.push(window.setTimeout(() => html.removeAttribute("data-flip-turning"), TURN_MS)); + }; + + const offEgg = onEgg("flip", () => { + if (html.hasAttribute("data-flipped")) return; + clear(); + // Turn the transition on first so the flip animates instead of snapping. + html.setAttribute("data-flip-turning", ""); + requestAnimationFrame(() => html.setAttribute("data-flipped", "")); + timers.push(window.setTimeout(flipBack, FLIPPED_FOR)); + findEgg("flip", { title: "Gotcha.", note: "Now you're the one being trolled." }); + }); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") flipBack(); + }; + window.addEventListener("keydown", onKeyDown); + return () => { + offEgg(); + clear(); + window.removeEventListener("keydown", onKeyDown); + html.removeAttribute("data-flipped"); + html.removeAttribute("data-flip-turning"); + }; + }, []); + return null; +} diff --git a/components/landing/landing-footer.tsx b/components/landing/landing-footer.tsx index 0acd2f65..d6bf52a0 100644 --- a/components/landing/landing-footer.tsx +++ b/components/landing/landing-footer.tsx @@ -65,7 +65,7 @@ export function LandingFooter() {
· - +

{legalLinks.map((link, index) => ( diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index a33c0915..d572a7ef 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -68,7 +68,7 @@ function Navbar() { > - +