diff --git a/app/globals.css b/app/globals.css index 506b6c69..8a0fc1e5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -709,3 +709,308 @@ 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; + } +} + +@keyframes egg-wobble { + 0%, 84%, 100% { transform: none; } + 87% { transform: rotate(-14deg); } + 90% { transform: rotate(10deg); } + 93% { transform: rotate(-6deg); } + 96% { transform: rotate(3deg); } +} + +html[data-glitch] body { + animation: page-glitch 0.42s steps(1) infinite; +} + +@keyframes page-glitch { + 0% { transform: none; filter: none; clip-path: none; } + 12% { transform: translate(-6px, 2px) skewX(-2deg); filter: hue-rotate(90deg) saturate(3); } + 24% { transform: translate(5px, -1px); filter: invert(1) hue-rotate(180deg); clip-path: inset(18% 0 52% 0); } + 36% { transform: none; filter: contrast(2.2) saturate(0); clip-path: none; } + 48% { transform: translate(9px, 0) skewX(4deg); filter: hue-rotate(-60deg); clip-path: inset(0 0 70% 0); } + 60% { transform: translate(-3px, 3px); filter: none; clip-path: inset(40% 0 20% 0); } + 72% { transform: translate(2px, -4px) scaleY(1.02); filter: saturate(4) contrast(1.4); clip-path: none; } + 86% { transform: translate(-8px, 0); filter: invert(1); clip-path: inset(62% 0 8% 0); } +} + +.glitch-storm { + position: fixed; + inset: 0; + z-index: 2147482700; + pointer-events: none; + overflow: hidden; + background: repeating-linear-gradient(to bottom, rgb(0 0 0 / 0.18) 0 1px, transparent 1px 3px); +} + +.glitch-storm > span { + position: absolute; + left: 0; + right: 0; + mix-blend-mode: difference; + opacity: 0.85; +} + +.glitch-storm-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(2rem, 7vw, 5rem); + color: #fff; + mix-blend-mode: difference; + text-shadow: 3px 0 #ff2a3c, -3px 0 #00f0ff; + animation: glitch-label 0.2s steps(1) infinite; +} + +@keyframes glitch-label { + 0% { transform: translate(0, 0); opacity: 1; } + 33% { transform: translate(-4px, 2px); opacity: 0.6; } + 66% { transform: translate(5px, -2px); opacity: 1; } +} + +.everything-deleted[data-phase="restoring"] { + animation: deleted-restore 1.6s ease-in forwards; +} + +.everything-deleted-progress { + animation: deleted-progress 1.2s ease-out forwards; +} + +@keyframes deleted-progress { + from { width: 0; } + to { width: 100%; } +} + +@keyframes deleted-restore { + 0%, 70% { opacity: 1; } + 100% { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + html[data-glitch] body, + .glitch-storm-label { + 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/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..67d8e0ba 100644 --- a/components/detonation/detonation-overlay.tsx +++ b/components/detonation/detonation-overlay.tsx @@ -2,6 +2,8 @@ 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"; /* @@ -254,7 +256,9 @@ 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); useEffect(() => { onDoneRef.current = onDone; @@ -406,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; } } @@ -515,6 +520,7 @@ export default function DetonationOverlay({ return createPortal(

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

or press Esc

+ {progress.found < progress.total ? ( + + ) : null}

) : null} diff --git a/components/detonation/events.ts b/components/detonation/events.ts index 4ef65325..7723f44a 100644 --- a/components/detonation/events.ts +++ b/components/detonation/events.ts @@ -1,12 +1,23 @@ +import type { EasterEgg } from "@/components/easter-eggs/eggs"; + /** Viewport point, in CSS px, where the blast goes off. */ export type DetonationOrigin = { x: number; y: number }; +type DetonationDetail = DetonationOrigin & { egg: EasterEgg }; + +export type { DetonationDetail }; + export const DETONATE_EVENT = "evilbuttons:detonate"; /** Sets the whole page on fire from `origin`. Picked up by ``. */ -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/docs-sidebar.tsx b/components/docs-sidebar.tsx index c75a631c..9c61c0e2 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({ > + ) { + return ( + { + onSummon?.(); + fireEgg("hell"); + }} + /> + ); +} + +/** Get through every doubt and it really does delete everything. */ +export function DoubtButton({ onConfirm, ...props }: React.ComponentProps) { + return ( + { + onConfirm?.(); + fireEgg("deleted"); + }} + /> + ); +} + +/** Stare into the eye long enough and the page stares back. */ +export function EvilEyeButton({ + onPointerEnter, + onPointerLeave, + ...props +}: React.ComponentProps) { + const timeoutRef = React.useRef(null); + const stop = () => { + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + }; + React.useEffect(() => stop, []); + + return ( + { + onPointerEnter?.(event); + if (event.pointerType === "touch") return; + stop(); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + fireEgg("eyes"); + }, STARE_MS); + }} + onPointerLeave={(event) => { + onPointerLeave?.(event); + stop(); + }} + /> + ); +} + +/** Smash it and something gives: the screen, around the button. */ +export function BrutalButton({ onClick, ...props }: React.ComponentProps) { + const [counter] = React.useState(() => createBurstCounter(SMASH_WINDOW)); + return ( + { + onClick?.(event); + if (counter.hit() >= SMASH_CLICKS) fireEgg("cracks", centreOf(event.currentTarget)); + }} + /> + ); +} + +/** Mash it and the glitch gets out of the button and into the page. */ +export function GlitchButton({ onClick, ...props }: React.ComponentProps) { + const [counter] = React.useState(() => createBurstCounter(SMASH_WINDOW)); + return ( + { + onClick?.(event); + if (counter.hit() >= SMASH_CLICKS) { + counter.reset(); + fireEgg("glitch", centreOf(event.currentTarget)); + } + }} + /> + ); +} + +/** Flick it on and off fast enough and the fuse blows. */ +export function RealisticSwitch({ + onCheckedChange, + ...props +}: React.ComponentProps) { + const [counter] = React.useState(() => createBurstCounter(FLIP_WINDOW)); + const found = useFoundEggs().includes("lights"); + return ( + { + onCheckedChange?.(checked); + const flips = counter.hit(); + // Halfway there the bulbs complain, so people know to keep going. + if (flips === 2 && !found) { + showEggToast({ title: "The bulbs flicker…", note: "Old wiring. Don't push it." }); + } + if (flips >= FLIPS) { + counter.reset(); + fireEgg("lights"); + } + }} + /> + ); +} + +/** 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/console-greeting.tsx b/components/easter-eggs/console-greeting.tsx new file mode 100644 index 00000000..a76c4908 --- /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 of the buttons hide easter eggs. Clues live behind the egg in the header.\nA freebie: hold the DemonicButton all the way.", + "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/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 new file mode 100644 index 00000000..9b95dbcc --- /dev/null +++ b/components/easter-eggs/easter-eggs.tsx @@ -0,0 +1,39 @@ +"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"; +import { EverythingDeleted } from "./everything-deleted"; +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"; + +/** + * The effects behind every hidden egg, mounted once in the root layout. The + * site's copies of the buttons (./buttons) set them off. + */ +export function EasterEggs() { + return ( + <> + + + + + + + + + + + + + + + ); +} diff --git a/components/easter-eggs/egg-hunt.tsx b/components/easter-eggs/egg-hunt.tsx new file mode 100644 index 00000000..de71ee54 --- /dev/null +++ b/components/easter-eggs/egg-hunt.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { CheckIcon, EggCrackIcon, EggIcon } from "@phosphor-icons/react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { EASTER_EGGS, EGG_INFO, resetEggs, useFoundEggs } from "./eggs"; +import { openEggHunt, setEggHuntOpen, useEggHuntOpen } from "./hunt-store"; + +/** The hunt: every egg, as a riddle until it's found. */ +export function EggHuntDialog() { + const open = useEggHuntOpen(); + const found = useFoundEggs(); + const complete = found.length === EASTER_EGGS.length; + + return ( + + +
+
+ + + Egg hunt + + + {complete + ? "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} of{" "} + {EASTER_EGGS.length} found +

+
+
+ +
    + {EASTER_EGGS.map((egg) => { + const info = EGG_INFO[egg]; + const isFound = found.includes(egg); + return ( +
  • + + {isFound ? : } + +
    +

    + {isFound ? info.name : "???"} +

    +

    {isFound ? info.how : info.hint}

    +
    +
  • + ); + })} +
+ +
+ Progress is saved in this browser. + {found.length > 0 ? ( + + ) : null} +
+ +
+ ); +} + +/** 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-teaser.tsx b/components/easter-eggs/egg-teaser.tsx new file mode 100644 index 00000000..3815d0d4 --- /dev/null +++ b/components/easter-eggs/egg-teaser.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useEffect } from "react"; +import { showEggToast } from "./egg-toast"; +import { EASTER_EGGS, eggProgress } from "./eggs"; + +const TEASED_KEY = "evilbuttons:teased"; +const TEASE_AFTER = 12_000; + +/** Once per browser, a first-time visitor gets told the hunt exists. */ +export function EggTeaser() { + useEffect(() => { + try { + if (localStorage.getItem(TEASED_KEY)) return; + } catch { + return; + } + const timeout = window.setTimeout(() => { + if (document.hidden || eggProgress().found > 0) return; + try { + localStorage.setItem(TEASED_KEY, "1"); + } catch {} + showEggToast({ + title: `Psst. ${EASTER_EGGS.length} of these buttons hide easter eggs.`, + note: "Play rough with them.", + next: "Click for clues.", + }); + }, TEASE_AFTER); + return () => window.clearTimeout(timeout); + }, []); + return null; +} diff --git a/components/easter-eggs/egg-toast.tsx b/components/easter-eggs/egg-toast.tsx new file mode 100644 index 00000000..14a2a77f --- /dev/null +++ b/components/easter-eggs/egg-toast.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { EggIcon } from "@phosphor-icons/react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useSyncExternalStore } from "react"; +import { openEggHunt } from "./hunt-store"; + +type Toast = { + key: number; + title: string; + note?: string; + /** Shown as an x/y chip; whispers (nudges, the teaser) leave it out. */ + progress?: { found: number; total: number }; + /** A second line pointing at the next egg. */ + next?: string; +}; + +const DISMISS_AFTER = 5500; + +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 ? ( + { + emit(null); + openEggHunt(); + }} + initial={{ opacity: 0, y: 16, scale: 0.96 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={{ opacity: 0, y: 8, scale: 0.98 }} + 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.title} + {toast.note ? {toast.note} : null} + {toast.next ? ( + {toast.next} + ) : null} + + + ) : null} + +
+ ); +} diff --git a/components/easter-eggs/eggs.ts b/components/easter-eggs/eggs.ts new file mode 100644 index 00000000..3dc68e0f --- /dev/null +++ b/components/easter-eggs/eggs.ts @@ -0,0 +1,163 @@ +import { useSyncExternalStore } from "react"; +import { showEggToast } from "./egg-toast"; + +export const EASTER_EGGS = [ + "detonate", + "hell", + "deleted", + "eyes", + "cracks", + "glitch", + "lights", + "flip", + "skulls", + "creeper", +] as const; + +export type EasterEgg = (typeof EASTER_EGGS)[number]; + +/** What the hunt panel shows: a riddle until found, then how it was done. */ +export const EGG_INFO: Record = { + detonate: { + name: "Detonation", + hint: "One slider means exactly what it says.", + how: "Slide SlideToDetonate all the way.", + }, + hell: { + name: "Hell mode", + hint: "Something with horns wants out. Hold on long enough to let it.", + how: "Hold DemonicButton until the demon is summoned.", + }, + deleted: { + name: "Everything deleted", + hint: "It keeps asking if you're sure. Be sure.", + how: "Confirm every doubt on DoubtButton.", + }, + eyes: { + name: "Watchers", + hint: "Stare into the eye. Don't blink, don't look away.", + how: "Hover EvilEyeButton for a few seconds.", + }, + cracks: { + name: "Cracked", + hint: "The brutal one can take a hit. Can the screen?", + how: "Smash BrutalButton four times fast.", + }, + glitch: { + name: "Corrupted", + hint: "The glitch is contained in its button. For now.", + how: "Mash GlitchButton four times fast.", + }, + lights: { + name: "Lights out", + hint: "That switch has old wiring. Flick it like you mean it.", + how: "Flip RealisticSwitch four times fast.", + }, + flip: { + name: "Trolled", + hint: "The one that runs away has a temper. Catch it anyway.", + how: "Catch TrollButton once it gets tired.", + }, + skulls: { + name: "Party's over", + hint: "One burst of confetti is restrained. Five is a different party.", + how: "Click ConfettiButton five times fast.", + }, + creeper: { + name: "Ssssss", + hint: "Break the stone button. Something green was hiding behind it.", + how: "Mine MinecraftButton until it breaks.", + }, +}; + +const STORAGE_KEY = "evilbuttons:eggs"; +const NONE: readonly EasterEgg[] = []; + +function readFound(): EasterEgg[] { + try { + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + if (!Array.isArray(stored)) return []; + return EASTER_EGGS.filter((egg) => stored.includes(egg)); + } catch { + return []; + } +} + +let snapshot: readonly EasterEgg[] | null = null; +const listeners = new Set<() => void>(); + +function changed() { + snapshot = null; + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + // Another tab found one. + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) changed(); + }; + window.addEventListener("storage", onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", onStorage); + }; +} + +function getSnapshot() { + snapshot ??= readFound(); + return snapshot; +} + +/** The eggs found in this browser, kept live. */ +export function useFoundEggs() { + return useSyncExternalStore(subscribe, getSnapshot, () => NONE); +} + +export function eggProgress() { + return { found: getSnapshot().length, total: EASTER_EGGS.length }; +} + +function write(found: readonly EasterEgg[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(found)); + } catch { + // Private mode: the egg still hatches, it just isn't remembered. + } + changed(); +} + +export function resetEggs() { + write([]); +} + +/** A riddle for an egg still out there, so every find leads to the next. */ +export function nextHint(found: readonly EasterEgg[] = getSnapshot()) { + const left = EASTER_EGGS.filter((egg) => !found.includes(egg)); + if (left.length === 0) return null; + return EGG_INFO[left[Math.floor(Math.random() * left.length)]].hint; +} + +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 before = getSnapshot(); + const isNew = !before.includes(egg); + const found = isNew ? EASTER_EGGS.filter((e) => e === egg || before.includes(e)) : before; + if (isNew) write(found); + if (toast && (isNew || !toast.onlyFirst)) { + const complete = found.length === EASTER_EGGS.length; + showEggToast({ + title: toast.title, + note: toast.note, + progress: { found: found.length, total: EASTER_EGGS.length }, + next: complete ? "All of them. You are truly evil." : `Next: ${nextHint(found)}`, + }); + } +} diff --git a/components/easter-eggs/everything-deleted.tsx b/components/easter-eggs/everything-deleted.tsx new file mode 100644 index 00000000..2742dfe3 --- /dev/null +++ b/components/easter-eggs/everything-deleted.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { showcase } from "@/components/landing/showcase"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; + +const LINE_MS = 55; +const HOLD_DELETED = 1400; +const RESTORE_MS = 1600; + +type Phase = "deleting" | "deleted" | "restoring"; + +function deletionLog() { + const files = showcase.map( + (component) => `removed 'components/evil-buttons/${component.registryName}.tsx'`, + ); + return [ + "$ sudo rm -rf --no-preserve-root ./everything", + ...files, + "removed 'app/page.tsx'", + "removed 'app/docs'", + "removed 'your-confidence.txt'", + "removed directory './everything'", + ]; +} + +/** Get through every doubt on the DoubtButton and it really does delete everything. Briefly. */ +export function EverythingDeleted() { + const [phase, setPhase] = useState(null); + const [lines, setLines] = useState(0); + const [log] = useState(deletionLog); + const restoreButtonRef = useRef(null); + + useEffect( + () => + onEgg("deleted", () => { + setLines(0); + setPhase((current) => current ?? "deleting"); + }), + [], + ); + + useEffect(() => { + if (phase === "deleting") { + let count = 0; + const interval = window.setInterval(() => { + count += 1; + setLines(count); + if (count >= log.length) { + window.clearInterval(interval); + setPhase("deleted"); + } + }, LINE_MS); + return () => window.clearInterval(interval); + } + if (phase === "deleted") { + findEgg("deleted", { title: "You were sure.", note: "Everything is gone. Mostly." }); + restoreButtonRef.current?.focus({ preventScroll: true }); + const timeout = window.setTimeout(() => setPhase("restoring"), HOLD_DELETED + 2200); + return () => window.clearTimeout(timeout); + } + if (phase === "restoring") { + const timeout = window.setTimeout(() => setPhase(null), RESTORE_MS); + return () => window.clearTimeout(timeout); + } + }, [phase, log.length]); + + useEffect(() => { + if (!phase) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setPhase("restoring"); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [phase]); + + if (!phase) return null; + return ( +
+ {phase === "deleting" ? ( +
+ {log.slice(0, lines).map((line, index) => ( +

+ {line} +

+ ))} +
+ ) : ( +
+

+ {phase === "deleted" ? "Everything deleted." : "Restoring from backup…"} +

+ {phase === "deleted" ? ( + <> +

You were warned. Several times.

+ + + ) : ( +
+
+
+ )} +
+ )} +
+ ); +} 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 new file mode 100644 index 00000000..c64cb56e --- /dev/null +++ b/components/easter-eggs/hell-mode.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { findEgg } from "./eggs"; +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); + + useEffect(() => { + const html = document.documentElement; + 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/hunt-store.ts b/components/easter-eggs/hunt-store.ts new file mode 100644 index 00000000..011f275f --- /dev/null +++ b/components/easter-eggs/hunt-store.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; + +let open = false; +const listeners = new Set<() => void>(); + +export function setEggHuntOpen(next: boolean) { + open = next; + listeners.forEach((listener) => listener()); +} + +export function openEggHunt() { + setEggHuntOpen(true); +} + +export function useEggHuntOpen() { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => open, + () => false, + ); +} diff --git a/components/easter-eggs/lights-out.tsx b/components/easter-eggs/lights-out.tsx new file mode 100644 index 00000000..b90cfffe --- /dev/null +++ b/components/easter-eggs/lights-out.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; + +const DARK_FOR = 9000; + +type Phase = "off" | "dark" | "flicker"; + +/** 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); + + useEffect( + () => onEgg("lights", () => setPhase((current) => (current === "off" ? "dark" : current))), + [], + ); + + useEffect(() => { + if (phase !== "dark") return; + findEgg("lights", { + title: "You blew the fuse.", + 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..8763f2ce --- /dev/null +++ b/components/easter-eggs/screen-cracks.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; + +const MAX_CRACKS = 8; +const SIZE = 420; + +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 ( + + + + + + ); +} + +/** Smash the BrutalButton and the screen gives way around it; keep smashing for more. */ +export function ScreenCracks() { + const [cracks, setCracks] = useState([]); + + useEffect(() => { + let nextId = 0; + 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(-5px, 4px)" }, + { transform: "translate(4px, -3px)" }, + { transform: "translate(0, 0)" }, + ], + { duration: 180 }, + ); + } + // 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)); + }); + }, []); + + 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/skull-storm.tsx b/components/easter-eggs/skull-storm.tsx new file mode 100644 index 00000000..b86317d2 --- /dev/null +++ b/components/easter-eggs/skull-storm.tsx @@ -0,0 +1,68 @@ +"use client"; + +import confetti from "canvas-confetti"; +import { useEffect } from "react"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; + +const STORM_MS = 2600; +const Z_INDEX = 2147482800; + +/** Spam the ConfettiButton and the party turns: a skull storm over the whole page. */ +export function SkullStorm() { + 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; + }; + + 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 new file mode 100644 index 00000000..8bf987b7 --- /dev/null +++ b/components/easter-eggs/triggers.ts @@ -0,0 +1,49 @@ +/** Eggs whose effects live in the root layout, set off by the site's buttons. */ +export type EggEffect = + | "hell" + | "deleted" + | "eyes" + | "cracks" + | "glitch" + | "lights" + | "flip" + | "skulls" + | "creeper"; + +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/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/easter-eggs/watching-eyes.tsx b/components/easter-eggs/watching-eyes.tsx new file mode 100644 index 00000000..05d06bb9 --- /dev/null +++ b/components/easter-eggs/watching-eyes.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { findEgg } from "./eggs"; +import { onEgg } from "./triggers"; + +const PAIRS = 6; +const WATCH_FOR = 7000; + +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): Pair[] { + const pairs: Pair[] = []; + for (let i = 0; i < PAIRS; i++) { + const side = i % 4; + 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; + pairs.push({ + x: x * width, + y: y * height, + size: 14 + Math.random() * 12, + delay: 0.15 + i * 0.35 + Math.random() * 0.3, + blink: 3.5 + Math.random() * 3, + }); + } + return pairs; +} + +/** 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(() => { + if (!pairs) return; + findEgg("eyes", { title: "It stared back.", note: "They all did." }); + + // 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); + + 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.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) => ( + + + + + + + + ))} + + + ))} +
+ ); +} diff --git a/components/landing/landing-footer.tsx b/components/landing/landing-footer.tsx index d0f92b62..d6bf52a0 100644 --- a/components/landing/landing-footer.tsx +++ b/components/landing/landing-footer.tsx @@ -2,6 +2,7 @@ import { ShaderBackdrop, TopScrim } from "@/components/landing/shader-backdrop"; import { siteConfig } from "@/lib/seo"; +import { EggHuntButton } from "@/components/easter-eggs/egg-hunt"; const LICENSE_URL = `${siteConfig.github}/blob/main/LICENSE`; @@ -63,6 +64,8 @@ export function LandingFooter() { {siteConfig.author.name}
+ · +

{legalLinks.map((link, index) => ( diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index a555cbea..d572a7ef 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() { > +