diff --git a/.agents/skills/evil-button-docs/SKILL.md b/.agents/skills/evil-button-docs/SKILL.md index d5a4b56..6b7f753 100644 --- a/.agents/skills/evil-button-docs/SKILL.md +++ b/.agents/skills/evil-button-docs/SKILL.md @@ -16,7 +16,7 @@ Use this skill to add a new Evil Buttons component to the docs and shadcn regist 2. Create or update `content/docs/.mdx`. - Use `scripts/generate-docs.mjs` to scaffold a page when starting from scratch. - Keep the install command as `@evilbuttons/`. - - Include a live `` using the real component. + - Include a `` using the real component (swapped for the dial-backed preview with prop controls when one exists in `components/landing/previews`). 3. Add the preview component to `components/mdx-custom-components.tsx` if the MDX page references it. 4. Add the item to `scripts/build-registry.mjs`. - Read the source file with `readFile`. @@ -43,7 +43,7 @@ ComponentName is ... ## Preview - + Deploy Doom diff --git a/.agents/skills/evil-button-docs/scripts/generate-docs.mjs b/.agents/skills/evil-button-docs/scripts/generate-docs.mjs index af016b8..7ac7637 100644 --- a/.agents/skills/evil-button-docs/scripts/generate-docs.mjs +++ b/.agents/skills/evil-button-docs/scripts/generate-docs.mjs @@ -121,7 +121,7 @@ ${title} is ${description.charAt(0).toLowerCase()}${description.slice(1)} ## Preview - + <${componentName}>Deploy Doom diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..643577d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ + # This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7583ee7..2e5f866 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Every new button must include: - **Accessible HTML:** Prefer a real ` + ); + })} + + ); +} + export function CliBlock({ commands }: { commands: string[] }) { const { packageManager, setConfig } = useConfig(); const value = `${packageCommands[packageManager]} ${commands.join(" ")}`.trim(); return ( -
-
-
- {managers.map((manager) => { - const { icon: Icon, activeTextClassName } = managerMeta[manager]; - - return ( - - ); - })} -
- -
-
-
-          {value}
-        
-
-
+ + setConfig({ packageManager: manager })} + className="-ml-1.5" + /> + + + } + > +
+        {value}
+      
+
); } diff --git a/components/copy-button.tsx b/components/copy-button.tsx index 0e532f4..51b2ae7 100644 --- a/components/copy-button.tsx +++ b/components/copy-button.tsx @@ -61,7 +61,7 @@ export default function CopyButton({ type="button" onClick={onCopy} className={cn( - "inline-flex size-8 items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", copied && "text-foreground", withBlurBg && "bg-background/80 shadow-sm backdrop-blur", className, @@ -69,7 +69,7 @@ export default function CopyButton({ aria-label={copied ? "Copied" : "Copy code"} title={copied ? "Copied" : "Copy code"} > - + ); } diff --git a/components/dial-root.tsx b/components/dial-root.tsx deleted file mode 100644 index 8652f12..0000000 --- a/components/dial-root.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { DialRoot } from "dialkit"; -import { useAppTheme } from "@/hooks/use-app-theme"; -import { useIsMobile } from "@/hooks/use-is-mobile"; - -export function AppDialRoot() { - const theme = useAppTheme(); - const isMobile = useIsMobile(); - - return ( - - ); -} \ No newline at end of file diff --git a/components/docs-frame.tsx b/components/docs-frame.tsx new file mode 100644 index 0000000..73ad348 --- /dev/null +++ b/components/docs-frame.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +type DocsFrameProps = { + /** Slim row rendered inside the outer shell, above the inner card. */ + header?: ReactNode; + children: ReactNode; + className?: string; + innerClassName?: string; +}; + +/** + * Double-bezel container shared by docs blocks: a tinted outer shell with an + * optional header row, wrapping a bordered inner card that holds the content. + */ +export function DocsFrame({ + header, + children, + className, + innerClassName, +}: DocsFrameProps) { + return ( +
+ {header ? ( +
+ {header} +
+ ) : null} +
+ {children} +
+
+ ); +} diff --git a/components/docs-page-actions.tsx b/components/docs-page-actions.tsx index 018f5da..713ea54 100644 --- a/components/docs-page-actions.tsx +++ b/components/docs-page-actions.tsx @@ -156,8 +156,9 @@ export function DocsPageActions({
- + {icon ? ( + + + + ) : null} + {children ?? label} + ); }, diff --git a/components/evil-buttons/captcha-button.tsx b/components/evil-buttons/captcha-button.tsx deleted file mode 100644 index 375e965..0000000 --- a/components/evil-buttons/captcha-button.tsx +++ /dev/null @@ -1,473 +0,0 @@ -"use client"; - -import * as React from "react"; -import { createPortal } from "react-dom"; -import { - AnimatePresence, - motion, - useReducedMotion, - type Variants, -} from "motion/react"; -import { cn } from "@/lib/utils"; -import { Twemoji } from "@/components/twemoji"; - -type CaptchaState = "idle" | "open" | "success"; - -type Tile = { - id: number; - emoji: string; - evil: boolean; -}; - -type Placement = "top" | "bottom"; - -type Coords = { - /** Viewport x of the trigger center; the panel is centered on this. */ - centerX: number; - /** Distance from the top of the viewport to the panel's top (bottom placement). */ - top: number; - /** Distance from the bottom of the viewport to the panel's bottom (top placement). */ - bottom: number; - placement: Placement; -}; - -export interface CaptchaButtonProps - extends Omit, "onClick"> { - /** Trigger label. Falls back to `label` when no children are provided. */ - children?: React.ReactNode; - /** Trigger label used when no children are provided. */ - label?: React.ReactNode; - /** Fired once the user passes the captcha. */ - onConfirm?: () => void; - /** Label flashed briefly after the captcha is passed. */ - successLabel?: React.ReactNode; - /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */ - resetAfter?: number; -} - -const EVIL_EMOJIS = ["😈", "👿", "💀", "👹", "🦇", "🕷️", "👺"]; -const INNOCENT_EMOJIS = ["😇", "🌸", "🐶", "☁️", "🍦", "🌈", "🐤", "🌻"]; - -// Each prompt names the "evil" category the user must select. -const PROMPTS = [ - "Select all the demons", - "Click every cursed soul", - "Select all that is evil", - "Select all the nightmares", - "Click everything unholy", -]; - -const TAUNTS = [ - "Not evil enough. Again.", - "Pathetic. The darkness rejects you.", - "Wrong. Even angels are disappointed.", - "Try harder, mortal.", - "That was adorably innocent. No.", - "The abyss is unimpressed.", -]; - -// Panel sizing used both for layout (w-64) and for flip math. -const PANEL_WIDTH = 256; -const PANEL_HEIGHT_ESTIMATE = 300; -const GAP = 8; - -function shuffle(input: T[]): T[] { - const arr = [...input]; - for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [arr[i], arr[j]] = [arr[j], arr[i]]; - } - return arr; -} - -function pick(input: T[], count: number): T[] { - return shuffle(input).slice(0, count); -} - -type Challenge = { - prompt: string; - tiles: Tile[]; -}; - -function buildChallenge(): Challenge { - // Between 2 and 4 evil tiles so there is always a non-trivial selection. - const evilCount = 2 + Math.floor(Math.random() * 3); - const innocentCount = 9 - evilCount; - - const evilTiles: Tile[] = pick(EVIL_EMOJIS, evilCount).map((emoji, i) => ({ - id: i, - emoji, - evil: true, - })); - const innocentTiles: Tile[] = pick(INNOCENT_EMOJIS, innocentCount).map( - (emoji, i) => ({ - id: evilCount + i, - emoji, - evil: false, - }), - ); - - return { - prompt: PROMPTS[Math.floor(Math.random() * PROMPTS.length)], - tiles: shuffle([...evilTiles, ...innocentTiles]), - }; -} - -const panelVariants: Variants = { - hidden: { opacity: 0, scale: 0.96 }, - visible: { opacity: 1, scale: 1 }, - exit: { opacity: 0, scale: 0.96 }, -}; - -export const CaptchaButton = React.forwardRef< - HTMLButtonElement, - CaptchaButtonProps ->( - ( - { - children, - label = "Do something evil", - onConfirm, - successLabel = "Access granted", - resetAfter = 1600, - className, - type = "button", - ...props - }, - ref, - ) => { - const reduceMotion = useReducedMotion(); - - const [state, setState] = React.useState("idle"); - const [challenge, setChallenge] = React.useState(null); - const [selected, setSelected] = React.useState>(new Set()); - const [taunt, setTaunt] = React.useState(null); - const [shakeKey, setShakeKey] = React.useState(0); - const [coords, setCoords] = React.useState(null); - const [mounted, setMounted] = React.useState(false); - - React.useEffect(() => setMounted(true), []); - - const triggerRef = React.useRef(null); - const panelRef = React.useRef(null); - const resetTimeoutRef = React.useRef(null); - - const setTriggerRef = (node: HTMLButtonElement | null) => { - triggerRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) ref.current = node; - }; - - const clearResetTimeout = () => { - if (resetTimeoutRef.current !== null) { - window.clearTimeout(resetTimeoutRef.current); - resetTimeoutRef.current = null; - } - }; - - React.useEffect(() => () => clearResetTimeout(), []); - - // Anchor the portaled panel to the trigger, flipping above when there is - // not enough room below. Recomputed on open, scroll, and resize. - const updateCoords = React.useCallback(() => { - const node = triggerRef.current; - if (!node) return; - const rect = node.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.bottom; - const placement: Placement = - spaceBelow < PANEL_HEIGHT_ESTIMATE + GAP && rect.top > spaceBelow - ? "top" - : "bottom"; - setCoords({ - centerX: rect.left + rect.width / 2, - top: rect.bottom + GAP, - bottom: window.innerHeight - rect.top + GAP, - placement, - }); - }, []); - - const openCaptcha = () => { - clearResetTimeout(); - setChallenge(buildChallenge()); - setSelected(new Set()); - setTaunt(null); - updateCoords(); - setState("open"); - }; - - const closeCaptcha = () => { - setState("idle"); - setChallenge(null); - setSelected(new Set()); - setTaunt(null); - // Return focus to the trigger after dismissing the dialog. - window.requestAnimationFrame(() => { - triggerRef.current?.focus(); - }); - }; - - const nextRound = () => { - setChallenge(buildChallenge()); - setSelected(new Set()); - setShakeKey((k) => k + 1); - }; - - // Dismiss on outside click / Escape, trap Tab inside the panel, move - // initial focus into the dialog, and keep the panel anchored while open. - React.useEffect(() => { - if (state !== "open") return; - - const focusFirst = () => { - const panel = panelRef.current; - if (!panel) return; - const focusable = panel.querySelector( - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', - ); - focusable?.focus(); - }; - - const rafId = window.requestAnimationFrame(focusFirst); - - const onPointerDown = (e: PointerEvent) => { - const target = e.target as Node; - if ( - !triggerRef.current?.contains(target) && - !panelRef.current?.contains(target) - ) { - closeCaptcha(); - } - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - closeCaptcha(); - return; - } - - if (e.key !== "Tab" || !panelRef.current) return; - - const focusable = Array.from( - panelRef.current.querySelectorAll( - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', - ), - ).filter((el) => !el.hasAttribute("disabled") && el.tabIndex !== -1); - - if (focusable.length === 0) { - e.preventDefault(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement as HTMLElement | null; - - if (e.shiftKey) { - if (active === first || !panelRef.current.contains(active)) { - e.preventDefault(); - last.focus(); - } - } else if (active === last || !panelRef.current.contains(active)) { - e.preventDefault(); - first.focus(); - } - }; - const onReposition = () => updateCoords(); - - document.addEventListener("pointerdown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - window.addEventListener("scroll", onReposition, true); - window.addEventListener("resize", onReposition); - return () => { - window.cancelAnimationFrame(rafId); - document.removeEventListener("pointerdown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - window.removeEventListener("scroll", onReposition, true); - window.removeEventListener("resize", onReposition); - }; - }, [state, updateCoords]); - - const toggleTile = (id: number) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const verify = () => { - if (!challenge) return; - const evilIds = challenge.tiles.filter((t) => t.evil).map((t) => t.id); - const passed = - selected.size === evilIds.length && - evilIds.every((id) => selected.has(id)); - - if (passed) { - setState("success"); - setChallenge(null); - setSelected(new Set()); - setTaunt(null); - onConfirm?.(); - if (resetAfter > 0) { - resetTimeoutRef.current = window.setTimeout(() => { - setState("idle"); - }, resetAfter); - } - } else { - setTaunt(TAUNTS[Math.floor(Math.random() * TAUNTS.length)]); - nextRound(); - } - }; - - const isSuccess = state === "success"; - const isOpen = state === "open"; - - const panel = ( - - {isOpen && challenge && coords && ( - -
-

- {challenge.prompt} -

- -
- - - {challenge.tiles.map((tile) => { - const active = selected.has(tile.id); - return ( - toggleTile(tile.id)} - whileTap={reduceMotion ? undefined : { scale: 0.88 }} - className={cn( - "flex aspect-square items-center justify-center rounded-md border text-2xl transition-colors", - active - ? "border-red-500 bg-red-500/15 ring-2 ring-red-500/50" - : "border-border bg-muted/40 hover:bg-muted", - )} - > - - {tile.emoji} - - - ); - })} - - -
- - {taunt && ( - - {taunt} - - )} - -
- - -
- )} -
- ); - - return ( -
- - - {mounted && createPortal(panel, document.body)} -
- ); - }, -); - -CaptchaButton.displayName = "CaptchaButton"; - -export default CaptchaButton; diff --git a/components/evil-buttons/chrome-button.tsx b/components/evil-buttons/chrome-button.tsx index 1bfbecc..7c935e7 100644 --- a/components/evil-buttons/chrome-button.tsx +++ b/components/evil-buttons/chrome-button.tsx @@ -1,22 +1,444 @@ -import { ReactNode } from "react"; -import LiquidChrome from "../LiquidChrome"; - -function ChromeButton({ children }: { children: ReactNode }) { - return ( - +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export type ChromeTone = "auto" | "dark" | "light"; +export type ChromeSize = "default" | "sm" | "icon"; + +export interface ChromeButtonProps + extends React.ButtonHTMLAttributes { + /** Label used when no children are provided. */ + label?: React.ReactNode; + /** Follow the theme (dark face under `.dark`), or force a dark or light face. */ + tone?: ChromeTone; + /** Pill, compact pill, or a round icon-only button. */ + size?: ChromeSize; + /** Speed multiplier for the liquid metal flowing around the border. */ + speed?: number; + /** Ripple the metal around the pointer and send a wave through it on press. */ + interactive?: boolean; +} + +const VERTEX = ` +attribute vec2 aPosition; +void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); +} +`; + +// Liquid metal: bands of polished chrome travel around the ring (by angle +// from the center), wobbled by stacked cosines so they slosh like liquid. +// Shaded as broad silver with dark troughs and thin hot highlights. The canvas +// fills the whole button, but only the border ring shows around the face. +const FRAGMENT = ` +precision highp float; +uniform vec2 uRes; +uniform float uTime; +uniform vec2 uPointer; +uniform float uHover; +uniform vec3 uPress; +uniform float uTone; + +float metal(vec2 px) { + vec2 uv = (2.0 * px - uRes) / min(uRes.x, uRes.y); + for (float i = 1.0; i < 8.0; i++) { + uv.x += 0.22 / i * cos(i * 2.6 * uv.y + uTime); + uv.y += 0.22 / i * cos(i * 1.9 * uv.x + uTime * 0.8); + } + + // Interactions push band phase and contrast directly: nudging the warp, or + // brightening already-bright metal, barely registers on a thin ring. + float phase = 0.0; + float shade = 0.0; + + // Pointer: tight dark and bright ripples swirl out from the cursor. + vec2 d = (px - uPointer) / uRes.y; + float dist = length(d); + float near = exp(-dist * dist * 1.5) * uHover; + float swirl = sin(dist * 14.0 - uTime * 7.0); + phase += swirl * 2.2 * near; + shade += swirl * 0.45 * near; + + // Press: a shock front runs out from the click, bright at its leading edge + // with a dark trough behind it. + float age = uTime - uPress.z; + if (age > 0.0 && age < 2.0) { + float r = length((px - uPress.xy) / uRes.y); + float front = r - age * 4.0; + float fade = exp(-age * 1.8); + phase += exp(-front * front * 2.0) * fade * 5.0; + shade += (exp(-front * front * 8.0) - exp(-(front + 0.45) * (front + 0.45) * 8.0)) * fade * 0.95; + } + + // Normalized so a pill's ends and sides share one angular speed. + vec2 q = (2.0 * px - uRes) / uRes; + float angle = atan(q.y, q.x); + float s = sin(angle * 3.0 - uTime * 2.2 + (uv.x + uv.y) * 1.1 + phase); + float m = 0.5 + 0.5 * s; + float c = 0.3 + 0.7 * smoothstep(0.0, 0.75, m); + c += 0.55 * min(0.035 / abs(s - 0.35), 1.0); + c -= 0.35 * min(0.02 / abs(s + 0.6), 1.0); + return clamp(c + shade, 0.0, 1.0); +} + +void main() { + float c = 0.0; + c += metal(gl_FragCoord.xy + vec2(-0.25, -0.25)); + c += metal(gl_FragCoord.xy + vec2(0.25, -0.25)); + c += metal(gl_FragCoord.xy + vec2(-0.25, 0.25)); + c += metal(gl_FragCoord.xy + vec2(0.25, 0.25)); + c *= 0.25; + // Slightly cool lights and warm shadows read as steel rather than grey. + vec3 col = mix(vec3(0.09, 0.085, 0.08), vec3(1.0, 1.0, 1.02), c); + // On a white face the ring sits a little darker so it still frames the button. + col *= uTone > 0.5 ? 0.9 : 1.0; + gl_FragColor = vec4(col, 1.0); +} +`; + +type ChromeGL = { + gl: WebGLRenderingContext; + uniforms: Record< + "uRes" | "uTime" | "uPointer" | "uHover" | "uPress" | "uTone", + WebGLUniformLocation | null + >; +}; + +function createChromeGL(canvas: HTMLCanvasElement): ChromeGL | null { + const gl = canvas.getContext("webgl", { + alpha: false, + antialias: false, + powerPreference: "low-power", + }); + if (!gl) return null; + + const compile = (type: number, source: string) => { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, source); + gl.compileShader(shader); + return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null; + }; + const vs = compile(gl.VERTEX_SHADER, VERTEX); + const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT); + if (!vs || !fs) return null; + + const program = gl.createProgram()!; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null; + gl.useProgram(program); + + // One oversized triangle covers the viewport. + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + gl.STATIC_DRAW, ); + const position = gl.getAttribLocation(program, "aPosition"); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + + const at = (name: string) => gl.getUniformLocation(program, name); + return { + gl, + uniforms: { + uRes: at("uRes"), + uTime: at("uTime"), + uPointer: at("uPointer"), + uHover: at("uHover"), + uPress: at("uPress"), + uTone: at("uTone"), + }, + }; } -export default ChromeButton; \ No newline at end of file +const DARK = { + shell: + "shadow-[0_0_0_1px_rgb(0_0_0/0.7),0_2px_4px_rgb(0_0_0/0.5),0_8px_20px_-8px_rgb(0_0_0/0.7)]", + face: + "bg-[linear-gradient(180deg,#1d1d1d_0%,#111111_60%,#0d0d0d_100%)] text-neutral-50 shadow-[inset_0_1px_3px_rgb(0_0_0/0.9),inset_0_-1px_0_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.55)]", +}; + +const LIGHT = { + shell: + "shadow-[0_0_0_1px_rgb(0_0_0/0.18),0_2px_4px_rgb(0_0_0/0.12),0_10px_24px_-8px_rgb(0_0_0/0.3)]", + face: + "bg-[linear-gradient(180deg,#ffffff_0%,#fafafa_60%,#f0f0f0_100%)] text-neutral-900 shadow-[inset_0_1px_3px_rgb(0_0_0/0.28),inset_0_-1px_0_rgb(255_255_255/0.9),0_0_0_1px_rgb(0_0_0/0.25)]", +}; + +const TONES: Record = { + // Light by default, dark inside a `.dark` ancestor, like shadcn's theming. + // Written out in full so Tailwind can see every dark: class. + auto: { + shell: `${LIGHT.shell} dark:shadow-[0_0_0_1px_rgb(0_0_0/0.7),0_2px_4px_rgb(0_0_0/0.5),0_8px_20px_-8px_rgb(0_0_0/0.7)]`, + face: `${LIGHT.face} dark:bg-[linear-gradient(180deg,#1d1d1d_0%,#111111_60%,#0d0d0d_100%)] dark:text-neutral-50 dark:shadow-[inset_0_1px_3px_rgb(0_0_0/0.9),inset_0_-1px_0_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.55)]`, + }, + dark: DARK, + light: LIGHT, +}; + +const SIZES: Record = { + default: { + shell: "h-11 p-[3px]", + face: "gap-2.5 px-5 text-[15px] [&_svg]:size-4", + }, + sm: { + shell: "h-9 p-[2.5px]", + face: "gap-2 px-3.5 text-[13px] [&_svg]:size-3.5", + }, + icon: { + shell: "size-9 p-[2.5px]", + face: "aspect-square justify-center [&_svg]:size-4", + }, +}; + +export const ChromeButton = React.forwardRef< + HTMLButtonElement, + ChromeButtonProps +>( + ( + { + children, + label = "Continue", + tone = "auto", + size = "default", + speed = 1, + interactive = true, + className, + disabled, + type = "button", + onPointerEnter, + onPointerMove, + onPointerLeave, + onPointerDown, + onClick, + ...props + }, + ref, + ) => { + const shellRef = React.useRef(null); + const hostRef = React.useRef(null); + const canvasRef = React.useRef(null); + const [painted, setPainted] = React.useState(false); + + // Live inputs the render loop reads without re-running the effect. + const input = React.useRef({ + pointer: [0, 0] as [number, number], + hoverTarget: 0, + press: [0, 0, -10] as [number, number, number], + time: 0, + speed, + tone, + interactive, + }); + input.current.speed = speed; + input.current.tone = tone; + input.current.interactive = interactive; + + const setShellRef = (node: HTMLButtonElement | null) => { + shellRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }; + + React.useEffect(() => { + const host = hostRef.current; + const shell = shellRef.current; + if (!host || !shell) return; + // A fresh canvas per run: once a context is lost (as cleanup does), the + // same canvas only ever hands back that dead context, which froze the + // metal after Strict Mode's mount, unmount, mount in development. + const canvas = document.createElement("canvas"); + canvas.style.cssText = "display:block;width:100%;height:100%"; + const chrome = createChromeGL(canvas); + if (!chrome) return; + host.appendChild(canvas); + canvasRef.current = canvas; + const { gl, uniforms } = chrome; + const state = input.current; + // Reduced motion slows the flow to a gentle drift instead of freezing it. + const reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + + let raf = 0; + let last = 0; + let hover = 0; + let visible = true; + let first = true; + + const draw = () => { + gl.uniform2f(uniforms.uRes, canvas.width, canvas.height); + gl.uniform1f(uniforms.uTime, state.time); + gl.uniform2f(uniforms.uPointer, state.pointer[0], state.pointer[1]); + gl.uniform1f(uniforms.uHover, hover); + gl.uniform3f(uniforms.uPress, ...state.press); + const light = + state.tone === "auto" ? !shell.closest(".dark") : state.tone === "light"; + gl.uniform1f(uniforms.uTone, light ? 1 : 0); + gl.drawArrays(gl.TRIANGLES, 0, 3); + if (first) { + first = false; + setPainted(true); + } + }; + + const frame = (now: number) => { + const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; + last = now; + const target = state.interactive ? state.hoverTarget : 0; + hover += (target - hover) * Math.min(1, dt * 8); + state.time += dt * state.speed * (reduceMotion ? 0.2 : 1) * (1 + hover * 1.2); + draw(); + raf = requestAnimationFrame(frame); + }; + + const start = () => { + if (raf || !visible || document.hidden) return; + last = 0; + raf = requestAnimationFrame(frame); + }; + const stop = () => { + cancelAnimationFrame(raf); + raf = 0; + }; + + const resize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.round(shell.clientWidth * dpr)); + canvas.height = Math.max(1, Math.round(shell.clientHeight * dpr)); + gl.viewport(0, 0, canvas.width, canvas.height); + draw(); + }; + + const resizeObserver = new ResizeObserver(resize); + resizeObserver.observe(shell); + // Only animate while on screen. + const intersection = new IntersectionObserver(([entry]) => { + visible = !!entry?.isIntersecting; + if (visible) start(); + else stop(); + }); + intersection.observe(shell); + const onVisibility = () => (document.hidden ? stop() : start()); + document.addEventListener("visibilitychange", onVisibility); + const onLost = (event: Event) => { + event.preventDefault(); + stop(); + setPainted(false); + }; + canvas.addEventListener("webglcontextlost", onLost); + + resize(); + start(); + + return () => { + stop(); + resizeObserver.disconnect(); + intersection.disconnect(); + document.removeEventListener("visibilitychange", onVisibility); + canvas.removeEventListener("webglcontextlost", onLost); + gl.getExtension("WEBGL_lose_context")?.loseContext(); + canvas.remove(); + if (canvasRef.current === canvas) canvasRef.current = null; + setPainted(false); + }; + }, []); + + /** Pointer position in canvas pixels, y up, as the shader expects. */ + const toCanvas = (clientX: number, clientY: number) => { + const canvas = canvasRef.current; + const shell = shellRef.current; + if (!canvas || !shell) return [0, 0] as [number, number]; + const rect = shell.getBoundingClientRect(); + return [ + (clientX - rect.left) * (canvas.width / rect.width), + (rect.bottom - clientY) * (canvas.height / rect.height), + ] as [number, number]; + }; + + const wave = (at: [number, number]) => { + input.current.press = [at[0], at[1], input.current.time]; + }; + + const tones = TONES[tone]; + const sizes = SIZES[size]; + + return ( + + ); + }, +); + +ChromeButton.displayName = "ChromeButton"; + +export default ChromeButton; diff --git a/components/evil-buttons/click-powerup.tsx b/components/evil-buttons/click-powerup.tsx index 961704f..06a3bf0 100644 --- a/components/evil-buttons/click-powerup.tsx +++ b/components/evil-buttons/click-powerup.tsx @@ -1,109 +1,203 @@ "use client"; +import * as React from "react"; +import { motion, useReducedMotion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; -import { motion } from "motion/react"; -import { useState } from "react"; -export const ClickPowerUp = ({ - children, - className, - tapDuration = 500, -}: { - children: React.ReactNode; - className?: string; +export interface ClickPowerUpProps extends Omit< + React.ButtonHTMLAttributes, + "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" +> { + /** Milliseconds the powered-up flash lasts after a click. */ tapDuration?: number; -}) => { - const [isTapped, setIsTapped] = useState(false); + /** Color of the powered-up flash. */ + accentColor?: string; +} - const handleTap = () => { - if (isTapped) return; - setIsTapped(true); - setTimeout(() => setIsTapped(false), tapDuration); - }; +type Phase = "rest" | "hover" | "tap" | "powered"; - const state = isTapped ? "tap" : "rest"; +const SPRING = { type: "spring", stiffness: 320, damping: 22 } as const; - return ( - - {/* Corner brackets */} - {[ - { - corner: "top-right", - cls: "absolute top-0 right-0 size-2 border-t border-r z-20", - }, - { - corner: "top-left", - cls: "absolute top-0 left-0 size-2 border-t border-l z-20", - }, - { - corner: "bottom-left", - cls: "absolute bottom-0 left-0 size-2 border-b border-l z-20", - }, - { - corner: "bottom-right", - cls: "absolute right-0 bottom-0 size-2 border-r border-b z-20", - }, - ].map(({ corner, cls }) => ( - ({ x: 0, y: 0, borderColor: "rgb(38 38 38)" }), - hover: (c: string) => ({ - x: c.includes("right") ? 3 : -3, - y: c.includes("bottom") ? 3 : -3, - borderColor: "rgb(38 38 38)", - }), - tap: (c: string) => ({ - x: c.includes("right") ? -2 : 2, - y: c.includes("bottom") ? -2 : 2, - borderColor: "#2CD4BD", - }), - }} - transition={{ type: "spring", stiffness: 300, damping: 20 }} - className={cls} - /> - ))} +const CORNERS = [ + { key: "tl", x: -1, y: -1, className: "top-0 left-0 border-t border-l" }, + { key: "tr", x: 1, y: -1, className: "top-0 right-0 border-t border-r" }, + { key: "bl", x: -1, y: 1, className: "bottom-0 left-0 border-b border-l" }, + { key: "br", x: 1, y: 1, className: "right-0 bottom-0 border-r border-b" }, +] as const; + +const bracketVariants: Variants = { + rest: { x: 0, y: 0, opacity: 0.45 }, + hover: ({ x, y }: { x: number; y: number }) => ({ + x: x * 4, + y: y * 4, + opacity: 1, + }), + tap: ({ x, y }: { x: number; y: number }) => ({ + x: -x * 2, + y: -y * 2, + opacity: 1, + }), + // Bouncy spring so the brackets overshoot outward, then settle. + powered: ({ x, y }: { x: number; y: number }) => ({ + x: x * 6, + y: y * 6, + opacity: 1, + transition: { type: "spring", stiffness: 600, damping: 11 }, + }), +}; + +const panelVariants: Variants = { + rest: { scaleX: 0 }, + hover: { scaleX: 1 }, + tap: { scaleX: 1 }, + powered: { scaleX: 1 }, +}; + +export const ClickPowerUp = React.forwardRef< + HTMLButtonElement, + ClickPowerUpProps +>( + ( + { + children, + className, + tapDuration = 500, + accentColor = "#2CD4BD", + onClick, + onPointerEnter, + onPointerLeave, + onPointerDown, + onPointerUp, + style, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const reduceMotion = useReducedMotion(); + const [hovered, setHovered] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + const [powered, setPowered] = React.useState(false); + const poweredTimerRef = React.useRef(undefined); + + React.useEffect( + () => () => window.clearTimeout(poweredTimerRef.current), + [], + ); - - - ); -}; + + ); + }, +); + +ClickPowerUp.displayName = "ClickPowerUp"; diff --git a/components/evil-buttons/command-button.tsx b/components/evil-buttons/command-button.tsx index be08826..f59ad9c 100644 --- a/components/evil-buttons/command-button.tsx +++ b/components/evil-buttons/command-button.tsx @@ -1,57 +1,87 @@ "use client"; import * as React from "react"; -import { motion } from "motion/react"; import { cn } from "@/lib/utils"; -export interface CommandButtonProps - extends React.ButtonHTMLAttributes { +export interface CommandButtonProps extends Omit< + React.ButtonHTMLAttributes, + "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" +> { + /** Shortcut like `"mod+s"`. `mod` is ⌘ on Apple devices and Ctrl elsewhere. */ shortcut?: string; + /** Fired when the keyboard shortcut is pressed. */ onCommand?: () => void; + /** Stop the browser's own handling of the shortcut, like the save dialog. */ preventDefault?: boolean; + /** Show the shortcut as keycaps inside the button. */ showShortcut?: boolean; } +const MODIFIERS = ["mod", "ctrl", "alt", "shift"] as const; +/** How long a fired shortcut keeps every cap pressed. */ +const FIRE_MS = 160; + function normalizeKey(value: string) { const key = value.toLowerCase(); if (key === "cmd" || key === "command" || key === "meta") return "mod"; if (key === "control") return "ctrl"; if (key === "option") return "alt"; if (key === "escape") return "esc"; + if (key === "return") return "enter"; + if (key === " " || key === "spacebar") return "space"; return key; } -function shortcutMatches(event: KeyboardEvent, shortcut: string) { - const parts = shortcut.split("+").map((part) => normalizeKey(part.trim())); - const key = normalizeKey(event.key); +function parseShortcut(shortcut: string) { + return shortcut + .split("+") + .map((part) => normalizeKey(part.trim())) + .filter(Boolean); +} + +function shortcutMatches(event: KeyboardEvent, parts: string[]) { const wantsMod = parts.includes("mod"); const wantsCtrl = parts.includes("ctrl"); - const wantsAlt = parts.includes("alt"); - const wantsShift = parts.includes("shift"); const finalKey = parts.find( - (part) => !["mod", "ctrl", "alt", "shift"].includes(part), + (part) => !(MODIFIERS as readonly string[]).includes(part), ); if (wantsMod && !(event.metaKey || event.ctrlKey)) return false; if (!wantsMod && wantsCtrl !== event.ctrlKey) return false; - if (wantsAlt !== event.altKey) return false; - if (wantsShift !== event.shiftKey) return false; - return finalKey ? key === finalKey : false; + if (parts.includes("alt") !== event.altKey) return false; + if (parts.includes("shift") !== event.shiftKey) return false; + return finalKey ? normalizeKey(event.key) === finalKey : false; } -function formatShortcut(shortcut: string) { - return shortcut - .split("+") - .map((part) => { - const key = normalizeKey(part.trim()); - if (key === "mod") return "⌘"; - if (key === "ctrl") return "Ctrl"; - if (key === "alt") return "Alt"; - if (key === "shift") return "Shift"; - if (key === "esc") return "Esc"; - return key.length === 1 ? key.toUpperCase() : key; - }) - .join(" "); +/** Whether a single shortcut part is physically held in this key event. */ +function partHeld(part: string, event: KeyboardEvent, isApple: boolean) { + if (part === "mod") return isApple ? event.metaKey : event.ctrlKey; + if (part === "ctrl") return event.ctrlKey; + if (part === "alt") return event.altKey; + if (part === "shift") return event.shiftKey; + return false; +} + +function formatPart(part: string, isApple: boolean) { + if (part === "mod") return isApple ? "⌘" : "Ctrl"; + if (part === "ctrl") return isApple ? "⌃" : "Ctrl"; + if (part === "alt") return isApple ? "⌥" : "Alt"; + if (part === "shift") return isApple ? "⇧" : "Shift"; + if (part === "enter") return "↵"; + if (part === "esc") return "Esc"; + if (part === "space") return "Space"; + if (part === "arrowup") return "↑"; + if (part === "arrowdown") return "↓"; + if (part === "arrowleft") return "←"; + if (part === "arrowright") return "→"; + return part.length === 1 ? part.toUpperCase() : part; +} + +function detectApple() { + if (typeof navigator === "undefined") return false; + return /mac|iphone|ipad|ipod/i.test( + navigator.platform || navigator.userAgent, + ); } export const CommandButton = React.forwardRef< @@ -67,25 +97,71 @@ export const CommandButton = React.forwardRef< className, children = "Save", disabled, + onClick, type = "button", ...props }, ref, ) => { - const [pulse, setPulse] = React.useState(0); + const parts = React.useMemo(() => parseShortcut(shortcut), [shortcut]); + const [isApple, setIsApple] = React.useState(false); + const [held, setHeld] = React.useState>(new Set()); + const [firing, setFiring] = React.useState(false); + const fireTimerRef = React.useRef(undefined); + const onCommandRef = React.useRef(onCommand); + + React.useEffect(() => { + onCommandRef.current = onCommand; + }, [onCommand]); + + // Platform is only known on the client, so detect it after hydration. + React.useEffect(() => setIsApple(detectApple()), []); + + const fire = React.useCallback(() => { + setFiring(true); + window.clearTimeout(fireTimerRef.current); + fireTimerRef.current = window.setTimeout(() => setFiring(false), FIRE_MS); + }, []); React.useEffect(() => { if (disabled) return; + + // Mirror which modifier caps are held so each one sinks as it's pressed. + const syncModifiers = (event: KeyboardEvent) => { + const next = new Set( + parts.filter((part) => partHeld(part, event, isApple)), + ); + setHeld((current) => + current.size === next.size && + [...next].every((part) => current.has(part)) + ? current + : next, + ); + }; + const handleKeyDown = (event: KeyboardEvent) => { - if (!shortcutMatches(event, shortcut)) return; + syncModifiers(event); + if (event.repeat || !shortcutMatches(event, parts)) return; if (preventDefault) event.preventDefault(); - setPulse((value) => value + 1); - onCommand?.(); + fire(); + onCommandRef.current?.(); }; + const clear = () => setHeld(new Set()); window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [disabled, onCommand, preventDefault, shortcut]); + window.addEventListener("keyup", syncModifiers); + window.addEventListener("blur", clear); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", syncModifiers); + window.removeEventListener("blur", clear); + clear(); + }; + }, [disabled, fire, isApple, parts, preventDefault]); + + React.useEffect(() => () => window.clearTimeout(fireTimerRef.current), []); + + const label = parts.map((part) => formatPart(part, isApple)).join(" "); return ( ); }, diff --git a/components/evil-buttons/confetti-button.tsx b/components/evil-buttons/confetti-button.tsx index 61699dd..622ce77 100644 --- a/components/evil-buttons/confetti-button.tsx +++ b/components/evil-buttons/confetti-button.tsx @@ -2,140 +2,131 @@ import * as React from "react"; import confetti from "canvas-confetti"; -import { motion, useAnimationControls } from "motion/react"; -import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; export interface ConfettiButtonProps - extends Omit, "onClick"> { - /** Button label. Falls back to `label` when no children are provided. */ - children?: React.ReactNode; + extends React.ButtonHTMLAttributes { /** Label used when no children are provided. */ label?: React.ReactNode; /** Confetti particles per burst. */ particleCount?: number; /** Confetti spread in degrees. */ spread?: number; - /** Extra vertical launch velocity. */ + /** Launch velocity of the burst. */ startVelocity?: number; /** Custom confetti colors. */ colors?: string[]; + /** Show the leading sparkle icon. */ + icon?: boolean; /** Fired after each confetti burst. */ onCelebrate?: () => void; } +/** Soft neutrals with a few cool accents, so the burst reads as polish, not a party. */ +const CONFETTI_COLORS = ["#fafafa", "#d4d4d4", "#a3a3a3", "#93c5fd", "#c4b5fd", "#fcd34d"]; +/** How long the sparkle stays lit after a burst. */ +const FIRE_MS = 420; + function burstFromElement( element: HTMLElement, - options: { - particleCount: number; - spread: number; - startVelocity: number; - colors?: string[]; - }, + options: { particleCount: number; spread: number; startVelocity: number; colors: string[] }, ) { const rect = element.getBoundingClientRect(); - const x = (rect.left + rect.width / 2) / window.innerWidth; - const y = (rect.top + rect.height / 2) / window.innerHeight; + const origin = { + x: (rect.left + rect.width / 2) / window.innerWidth, + y: (rect.top + rect.height / 3) / window.innerHeight, + }; + // One tight burst of small flat pieces that fall and fade quickly. void confetti({ - particleCount: options.particleCount, - spread: options.spread, - startVelocity: options.startVelocity, - origin: { x, y }, - colors: options.colors, + ...options, + origin, + angle: 90, + gravity: 1.1, + decay: 0.9, + ticks: 160, + scalar: 0.75, + shapes: ["square", "circle"], disableForReducedMotion: true, }); } -export const ConfettiButton = React.forwardRef< - HTMLButtonElement, - ConfettiButtonProps ->( +export const ConfettiButton = React.forwardRef( ( { children, label = "Continue", - particleCount = 120, - spread = 72, - startVelocity = 38, - colors, + particleCount = 80, + spread = 64, + startVelocity = 32, + colors = CONFETTI_COLORS, + icon = true, onCelebrate, + onClick, className, disabled, - variant, - size, type = "button", ...props }, ref, ) => { - const buttonRef = React.useRef(null); - const popControls = useAnimationControls(); + const [firing, setFiring] = React.useState(false); + const fireTimerRef = React.useRef(undefined); - const setButtonRef = (node: HTMLButtonElement | null) => { - buttonRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) ref.current = node; - }; + React.useEffect(() => () => window.clearTimeout(fireTimerRef.current), []); - const handleClick = () => { - if (disabled || !buttonRef.current) return; + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (disabled || event.defaultPrevented) return; - burstFromElement(buttonRef.current, { - particleCount, - spread, - startVelocity, - colors, - }); + burstFromElement(event.currentTarget, { particleCount, spread, startVelocity, colors }); + setFiring(true); + window.clearTimeout(fireTimerRef.current); + fireTimerRef.current = window.setTimeout(() => setFiring(false), FIRE_MS); onCelebrate?.(); - - void popControls - .start({ - scale: 1.12, - transition: { - type: "spring", - stiffness: 520, - damping: 14, - mass: 0.55, - }, - }) - .then(() => - popControls.start({ - scale: 1, - transition: { - type: "spring", - stiffness: 420, - damping: 20, - mass: 0.6, - }, - }), - ); }; - const displayLabel = children ?? label; - return ( - - - + {icon ? ( + + + + + ) : null} + {children ?? label} + ); }, ); ConfettiButton.displayName = "ConfettiButton"; -export default ConfettiButton; \ No newline at end of file +export default ConfettiButton; diff --git a/components/evil-buttons/cooldown-button.tsx b/components/evil-buttons/cooldown-button.tsx index d8ad407..acc06c3 100644 --- a/components/evil-buttons/cooldown-button.tsx +++ b/components/evil-buttons/cooldown-button.tsx @@ -9,13 +9,12 @@ import { useMotionValue, useReducedMotion, } from "motion/react"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; type CooldownState = "idle" | "cooldown"; export interface CooldownButtonProps - extends React.ComponentProps { + extends React.ButtonHTMLAttributes { /** Idle label. Falls back to `label`. */ children?: React.ReactNode; /** Idle label used when no children are provided. */ @@ -50,8 +49,6 @@ export const CooldownButton = React.forwardRef< cooldown = 3000, taunts = DEFAULT_TAUNTS, showCountdown = true, - variant = "outline", - size = "lg", onClick, className, disabled, @@ -119,18 +116,22 @@ export const CooldownButton = React.forwardRef< showCountdown && remaining > 0 ? `${taunt} ${remaining}s` : taunt; return ( - + ); }, ); diff --git a/components/evil-buttons/copy-button.tsx b/components/evil-buttons/copy-button.tsx index 1b40c08..ca8bdf2 100644 --- a/components/evil-buttons/copy-button.tsx +++ b/components/evil-buttons/copy-button.tsx @@ -4,20 +4,71 @@ import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; -export interface CopyButtonProps - extends Omit< - React.ButtonHTMLAttributes, - "value" | "onCopy" - > { +export interface CopyButtonProps extends Omit< + React.ButtonHTMLAttributes, + | "value" + | "onCopy" + | "onDrag" + | "onDragStart" + | "onDragEnd" + | "onAnimationStart" +> { + /** Text written to the clipboard. */ value: string; + /** Milliseconds before the button returns to its idle state. */ timeout?: number; + /** Label shown before copying. */ copyLabel?: React.ReactNode; + /** Label shown after a successful copy. */ copiedLabel?: React.ReactNode; + /** Label shown when the clipboard write fails. */ + errorLabel?: React.ReactNode; + /** Show the copied text in a small chip above the button after copying. */ + showValue?: boolean; + /** Fired with the copied text after a successful write. */ onCopy?: (value: string) => void; } type CopyState = "idle" | "copied" | "error"; +const SWAP = { type: "spring", stiffness: 420, damping: 30 } as const; + +/** Clipboard API first, then the legacy selection fallback for insecure pages. */ +async function writeClipboard(text: string) { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand("copy"); + textarea.remove(); + if (!ok) throw new Error("Copy command was rejected"); +} + +const ICONS: Record = { + idle: ( + <> + + + + ), + copied: , + error: ( + <> + + + + ), +}; + +const STATES: CopyState[] = ["idle", "copied", "error"]; + export const CopyButton = React.forwardRef( ( { @@ -25,145 +76,127 @@ export const CopyButton = React.forwardRef( timeout = 1500, copyLabel = "Copy", copiedLabel = "Copied", + errorLabel = "Failed", + showValue = true, onCopy, className, onClick, + type = "button", ...props }, ref, ) => { const [state, setState] = React.useState("idle"); - const timeoutRef = React.useRef(null); + const timeoutRef = React.useRef(undefined); - React.useEffect(() => { - return () => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - }; - }, []); + React.useEffect(() => () => window.clearTimeout(timeoutRef.current), []); - const handleClick = async (e: React.MouseEvent) => { - onClick?.(e); - if (e.defaultPrevented) return; + const handleClick = async (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; try { - if ( - typeof navigator !== "undefined" && - navigator.clipboard?.writeText - ) { - await navigator.clipboard.writeText(value); - } else { - throw new Error("Clipboard API unavailable"); - } + await writeClipboard(value); setState("copied"); onCopy?.(value); } catch { setState("error"); } - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - timeoutRef.current = window.setTimeout(() => { - setState("idle"); - }, timeout); + window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => setState("idle"), timeout); }; - const isCopied = state === "copied"; - const isError = state === "error"; + const labels: Record = { + idle: copyLabel, + copied: copiedLabel, + error: errorLabel, + }; return ( ); diff --git a/components/evil-buttons/demonic-button.tsx b/components/evil-buttons/demonic-button.tsx index 8a7864a..4baf350 100644 --- a/components/evil-buttons/demonic-button.tsx +++ b/components/evil-buttons/demonic-button.tsx @@ -1,235 +1,470 @@ -"use client" -import React, { useEffect, useRef, useState } from 'react' -import { motion, AnimatePresence } from 'motion/react' +"use client"; -// How long (ms) the user must hold before the horns fully grow out. -const HORN_GROW_DURATION = 2600 +import * as React from "react"; +import { cn } from "@/lib/utils"; -const HornSvg = ({ className }: { className?: string }) => { - return ( - - - - - - ) +export interface DemonicButtonProps + extends Omit< + React.ButtonHTMLAttributes, + "children" | "onClick" + > { + /** Button label. Falls back to `label`. */ + children?: React.ReactNode; + /** Label used when no children are provided. */ + label?: React.ReactNode; + /** Milliseconds of holding needed to fully summon the demon. */ + holdDuration?: number; + /** Fired once each time the charge reaches 100%. */ + onSummon?: () => void; } -type Ember = { - id: number - side: -1 | 1 - dx: number - dy: number - size: number - duration: number - color: string +/** Extra canvas room around the button so flames can spill past its edges. */ +const FIRE_BLEED_X = 40; +const FIRE_HEIGHT = 150; +/** Charge drains this many times faster than it fills once released. */ +const DRAIN_RATE = 2.4; + +type Particle = { + x: number; + y: number; + vx: number; + vy: number; + age: number; + life: number; + size: number; + seed: number; +}; + +// Blackbody-ish ramp: white-hot core, through orange and red, into smoke. +const FIRE_RAMP: [number, [number, number, number, number]][] = [ + [0, [255, 244, 214, 1]], + [0.18, [255, 196, 92, 0.95]], + [0.42, [255, 118, 38, 0.8]], + [0.68, [206, 48, 24, 0.5]], + [0.86, [96, 24, 18, 0.22]], + [1, [40, 20, 20, 0]], +]; + +const SPRITE_STEPS = 24; +const SPRITE_SIZE = 64; + +function sampleRamp(t: number) { + for (let i = 1; i < FIRE_RAMP.length; i++) { + const [t1, c1] = FIRE_RAMP[i]; + if (t <= t1) { + const [t0, c0] = FIRE_RAMP[i - 1]; + const k = (t - t0) / (t1 - t0); + return c0.map((v, j) => v + (c1[j] - v) * k) as [ + number, + number, + number, + number, + ]; + } + } + return FIRE_RAMP[FIRE_RAMP.length - 1][1]; } -const EMBER_COLORS = ["#DC2626", "#F97316", "#FACC15", "#FB923C"] +/** Pre-rendered soft radial blobs, one per step of the fire ramp. */ +function createSprites() { + return Array.from({ length: SPRITE_STEPS }, (_, i) => { + const [r, g, b, a] = sampleRamp(i / (SPRITE_STEPS - 1)); + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = SPRITE_SIZE; + const ctx = canvas.getContext("2d"); + if (ctx) { + const half = SPRITE_SIZE / 2; + const gradient = ctx.createRadialGradient(half, half, 0, half, half, half); + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${a})`); + gradient.addColorStop(0.45, `rgba(${r}, ${g}, ${b}, ${a * 0.55})`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, SPRITE_SIZE, SPRITE_SIZE); + } + return canvas; + }); +} -const lerp = (a: number, b: number, t: number) => a + (b - a) * t -const easeOut = (t: number) => 1 - (1 - t) * (1 - t) +function Horn({ side }: { side: "left" | "right" }) { + const id = React.useId().replace(/:/g, ""); + // Outer edge sweeps out and up to the tip, inner edge curls back to the base. + const shape = + "M13 64 C6 50 1.5 33 6.5 15 C8 9.5 10.5 5 13 2 C12.5 12 15 26 21 38 C24.5 45 28.5 53 31 64 Z"; -// Blend from black to a hot blood-red as `amount` goes 0 -> 1. -const bloodColor = (amount: number) => { - const r = Math.round(lerp(0, 220, amount)) - const g = Math.round(lerp(0, 38, amount)) - const b = Math.round(lerp(0, 38, amount)) - return `rgb(${r}, ${g}, ${b})` + return ( + + + + + + + + + + + + + + + + + + + + + + {/* Growth ridges wrapping around the horn. */} + + + + + + + + + + + + + ); } -export const DemonicButton = ({ label }: { label: string }) => { - const [frame, setFrame] = useState({ p: 0, t: 0 }) - const [holding, setHolding] = useState(false) - const [grown, setGrown] = useState(false) - const [embers, setEmbers] = useState([]) - - const holdingRef = useRef(false) - const progressRef = useRef(0) - const grownRef = useRef(false) - const rafRef = useRef(null) - const lastTimeRef = useRef(0) - const lastSpawnRef = useRef(0) - const emberIdRef = useRef(0) - - const spawnEmbers = (count: number, power: number) => { - setEmbers((prev) => { - const next = [...prev] - for (let i = 0; i < count; i++) { - const side: -1 | 1 = Math.random() > 0.5 ? 1 : -1 - next.push({ - id: emberIdRef.current++, - side, - dx: side * (6 + Math.random() * 22 * power) + (Math.random() - 0.5) * 14, - dy: -(18 + Math.random() * 46 * power), - size: 3 + Math.random() * 4, - duration: 0.5 + Math.random() * 0.45, - color: EMBER_COLORS[(Math.random() * EMBER_COLORS.length) | 0], - }) +export const DemonicButton = React.forwardRef< + HTMLButtonElement, + DemonicButtonProps +>( + ( + { + children, + label = "Summon", + holdDuration = 2200, + onSummon, + className, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const wrapperRef = React.useRef(null); + const buttonRef = React.useRef(null); + const canvasRef = React.useRef(null); + const glowRef = React.useRef(null); + const leftHornRef = React.useRef(null); + const rightHornRef = React.useRef(null); + + const holdingRef = React.useRef(false); + const progressRef = React.useRef(0); + const summonedRef = React.useRef(false); + const particlesRef = React.useRef([]); + const spawnDebtRef = React.useRef(0); + const spritesRef = React.useRef(null); + const rafRef = React.useRef(null); + const lastTimeRef = React.useRef(0); + const sizeRef = React.useRef({ width: 0, height: 0, dpr: 1 }); + const reduceMotionRef = React.useRef(false); + const onSummonRef = React.useRef(onSummon); + const [summoned, setSummoned] = React.useState(false); + + React.useEffect(() => { + onSummonRef.current = onSummon; + }, [onSummon]); + + const setButtonRef = (node: HTMLButtonElement | null) => { + buttonRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }; + + // Keep the canvas backing store matched to its CSS size and DPR. + React.useEffect(() => { + const wrapper = wrapperRef.current; + const canvas = canvasRef.current; + if (!wrapper || !canvas) return; + + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + reduceMotionRef.current = query.matches; + const onMotionChange = () => { + reduceMotionRef.current = query.matches; + }; + query.addEventListener("change", onMotionChange); + + const resize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const width = wrapper.offsetWidth + FIRE_BLEED_X * 2; + sizeRef.current = { width, height: FIRE_HEIGHT, dpr }; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(FIRE_HEIGHT * dpr); + }; + resize(); + const observer = new ResizeObserver(resize); + observer.observe(wrapper); + + return () => { + observer.disconnect(); + query.removeEventListener("change", onMotionChange); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, []); + + const paint = (progress: number, now: number) => { + const eased = 1 - Math.pow(1 - progress, 3); + // Slight overshoot as the horns break free, then settle. + const pop = + progress > 0.85 ? Math.sin(((progress - 0.85) / 0.15) * Math.PI) * 0.06 : 0; + const tremble = + reduceMotionRef.current || !holdingRef.current || progress >= 1 + ? 0 + : progress * progress; + const shakeX = (Math.sin(now * 0.09) + Math.sin(now * 0.137)) * 1.2 * tremble; + const shakeY = Math.cos(now * 0.113) * 0.8 * tremble; + + const hornY = 26 * (1 - eased); + const hornScale = 0.55 + 0.45 * eased + pop; + const hornTilt = 18 * (1 - eased); + if (leftHornRef.current) { + leftHornRef.current.style.transform = `translateY(${hornY}px) rotate(${-hornTilt}deg) scale(${hornScale})`; + leftHornRef.current.style.opacity = String(Math.min(1, progress * 3)); } - // Cap the pool so we never leak nodes. - return next.slice(-48) - }) - } + if (rightHornRef.current) { + rightHornRef.current.style.transform = `translateY(${hornY}px) rotate(${hornTilt}deg) scale(${hornScale})`; + rightHornRef.current.style.opacity = String(Math.min(1, progress * 3)); + } + if (buttonRef.current) { + buttonRef.current.style.transform = `translate(${shakeX}px, ${shakeY}px)`; + } + if (glowRef.current) { + const flicker = reduceMotionRef.current + ? 0 + : (Math.sin(now * 0.021) + Math.sin(now * 0.047)) * 0.05; + glowRef.current.style.opacity = String( + Math.max(0, Math.min(1, eased * 0.9 + flicker * eased)), + ); + } + }; - const loop = (now: number) => { - const last = lastTimeRef.current || now - const dt = Math.min((now - last) / 1000, 0.05) - lastTimeRef.current = now - - const dir = holdingRef.current ? 1 : -1 - let p = progressRef.current + (dir * dt) / (HORN_GROW_DURATION / 1000) - p = Math.max(0, Math.min(1, p)) - progressRef.current = p - - // Climax: first frame we reach full charge, erupt with a burst. - if (p >= 1 && !grownRef.current) { - grownRef.current = true - setGrown(true) - spawnEmbers(26, 1.6) - } - if (p < 1 && grownRef.current) { - grownRef.current = false - setGrown(false) - } + const drawFire = (intensity: number, dt: number, now: number) => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + const button = buttonRef.current; + if (!canvas || !ctx || !button) return; - // Stream embers from the horn tips while they are pushing out. - if (holdingRef.current && p > 0.04 && p < 1 && now - lastSpawnRef.current > 55) { - lastSpawnRef.current = now - spawnEmbers(2, 0.4 + p) - } + spritesRef.current ??= createSprites(); + const sprites = spritesRef.current; + const { width, height, dpr } = sizeRef.current; + const particles = particlesRef.current; + const baseY = height - button.offsetHeight / 2; + const left = FIRE_BLEED_X + 6; + const right = width - FIRE_BLEED_X - 6; + const center = width / 2; - setFrame({ p, t: now }) + if (intensity > 0.02 && !reduceMotionRef.current) { + spawnDebtRef.current += dt * (70 + 260 * intensity); + while (spawnDebtRef.current >= 1) { + spawnDebtRef.current -= 1; + // Bias spawns toward the middle so the flame has a body. + const spread = (Math.random() + Math.random()) / 2; + const life = 0.45 + Math.random() * 0.55 * (0.6 + intensity); + particles.push({ + x: left + spread * (right - left), + y: baseY + Math.random() * 6, + vx: (Math.random() - 0.5) * 18, + vy: -(35 + Math.random() * 55) * (0.55 + intensity), + age: 0, + life, + size: (9 + Math.random() * 13) * (0.55 + 0.6 * intensity), + seed: Math.random() * 1000, + }); + } + } - if (p <= 0 && !holdingRef.current) { - rafRef.current = null - return - } - rafRef.current = requestAnimationFrame(loop) - } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); - const ensureLoop = () => { - if (rafRef.current === null) { - lastTimeRef.current = 0 - rafRef.current = requestAnimationFrame(loop) - } - } + let alive = 0; + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + p.age += dt; + if (p.age >= p.life) continue; - const startHold = () => { - holdingRef.current = true - setHolding(true) - ensureLoop() - } + // Buoyancy, a gentle pull to the centerline, and turbulent sway. + p.vy -= 60 * dt; + p.vx += (center - p.x) * 0.9 * dt; + p.vx += Math.sin(now * 0.004 + p.seed) * 40 * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + particles[alive++] = p; - const stopHold = () => { - holdingRef.current = false - setHolding(false) - ensureLoop() - } + const t = p.age / p.life; + const radius = p.size * (1 - t * 0.55); + const sprite = sprites[Math.min(SPRITE_STEPS - 1, Math.floor(t * SPRITE_STEPS))]; + ctx.globalAlpha = Math.min(1, (1 - t) * 1.4); + ctx.drawImage(sprite, p.x - radius, p.y - radius, radius * 2, radius * 2); + } + particles.length = alive; + ctx.globalAlpha = 1; + }; - useEffect(() => { - return () => { - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) - } - }, []) - - const { p, t } = frame - const charging = holding && p < 1 - - // Trembling intensity ramps up as the horns strain to push out, and a - // faint shiver lingers once the demon is fully awake. - const intensity = charging ? 0.2 + p * 0.8 : grown ? 0.18 : 0 - const shakeX = (Math.sin(t * 0.07) + Math.sin(t * 0.123)) * 2.4 * intensity - const shakeY = Math.cos(t * 0.111) * 1.8 * intensity - - // Heartbeat flash between the black base and red — faster and fuller as the - // life meter fills, then a strong steady pulse once it's alive. - const flashPulse = Math.sin(t * 0.006 * (1 + p * 4)) * 0.5 + 0.5 - const redAmount = grown - ? 0.55 + flashPulse * 0.45 - : Math.min(1, p * 0.5 + flashPulse * p * 0.5) - const bg = bloodColor(redAmount) - const glow = redAmount * (grown ? 28 : 18) - - const hornY = lerp(14, -18, easeOut(p)) - const hornScale = lerp(0.6, 1.05, p) - const hornRot = lerp(22, 0, easeOut(p)) + shakeX * 0.4 + const loop = (now: number) => { + const last = lastTimeRef.current || now; + const dt = Math.min((now - last) / 1000, 0.05); + lastTimeRef.current = now; - return ( -
- {/* Ember / particle layer */} -
- - {embers.map((e) => ( - - setEmbers((prev) => prev.filter((x) => x.id !== e.id)) - } - /> - ))} - + const holding = holdingRef.current; + const step = dt / (holdDuration / 1000); + let progress = progressRef.current + (holding ? step : -step * DRAIN_RATE); + progress = Math.max(0, Math.min(1, progress)); + progressRef.current = progress; + + if (progress >= 1 && !summonedRef.current) { + summonedRef.current = true; + setSummoned(true); + onSummonRef.current?.(); + } else if (progress < 1 && summonedRef.current) { + summonedRef.current = false; + setSummoned(false); + } + + // Flames build slowly, then roar once the demon is summoned. + const intensity = progress >= 1 ? 1 : Math.pow(progress, 1.6) * 0.85; + paint(progress, now); + drawFire(intensity, dt, now); + + if (!holding && progress <= 0 && particlesRef.current.length === 0) { + rafRef.current = null; + lastTimeRef.current = 0; + return; + } + rafRef.current = requestAnimationFrame(loop); + }; + + const ensureLoop = () => { + if (rafRef.current === null) { + lastTimeRef.current = 0; + rafRef.current = requestAnimationFrame(loop); + } + }; + + const startHold = () => { + if (disabled) return; + holdingRef.current = true; + buttonRef.current?.setAttribute("data-holding", ""); + ensureLoop(); + }; + + const stopHold = () => { + if (!holdingRef.current) return; + holdingRef.current = false; + buttonRef.current?.removeAttribute("data-holding"); + ensureLoop(); + }; + + return ( +
+ + + + + + + + + +
+ ); + }, +); - - - - - - - - 0.5 ? `0 0 ${glow}px rgba(220, 38, 38, ${redAmount * 0.85})` : "none", - }} - onPointerDown={(e) => { - if (e.button !== 0 && e.pointerType === "mouse") return - e.currentTarget.setPointerCapture?.(e.pointerId) - startHold() - }} - onPointerUp={stopHold} - onPointerCancel={stopHold} - onLostPointerCapture={stopHold} - onKeyDown={(e) => { - if ((e.key === " " || e.key === "Enter") && !e.repeat) { - e.preventDefault() - startHold() - } - }} - onKeyUp={(e) => { - if (e.key === " " || e.key === "Enter") { - e.preventDefault() - stopHold() - } - }} - onContextMenu={(e) => e.preventDefault()} - > - {label} - -
- ) -} +DemonicButton.displayName = "DemonicButton"; + +export default DemonicButton; diff --git a/components/evil-buttons/dont-press-button.tsx b/components/evil-buttons/dont-press-button.tsx deleted file mode 100644 index 5eaeadf..0000000 --- a/components/evil-buttons/dont-press-button.tsx +++ /dev/null @@ -1,237 +0,0 @@ -"use client"; - -import * as React from "react"; -import { - AnimatePresence, - motion, - useMotionValue, - useReducedMotion, - useSpring, -} from "motion/react"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -type DontPressState = "idle" | "tempted" | "succumbed"; - -export interface DontPressButtonProps - extends Omit, "onClick" | "children"> { - /** Idle label. Falls back to `idleLabel` when omitted. */ - children?: React.ReactNode; - /** Label shown before temptation builds. */ - idleLabel?: React.ReactNode; - /** - * Escalating labels as hover/proximity temptation rises. - * Index is picked from how close temptation is to 1. - */ - temptLabels?: React.ReactNode[]; - /** Label flashed after the user gives in and clicks. */ - succumbLabel?: React.ReactNode; - /** Fired when the user clicks despite every warning. */ - onSuccumb?: () => void; - /** Milliseconds to stay in the succumb state before resetting. Set to 0 to stay. */ - resetAfter?: number; -} - -const DEFAULT_TEMPT_LABELS: string[] = [ - "Don't…", - "Really, don't", - "Just one peek?", - "Nobody's watching…", - "Do it.", -]; - -export const DontPressButton = React.forwardRef< - HTMLButtonElement, - DontPressButtonProps ->( - ( - { - children, - idleLabel = "Don't Press", - temptLabels = DEFAULT_TEMPT_LABELS, - succumbLabel = "You pressed it.", - onSuccumb, - resetAfter = 1600, - className, - disabled, - variant, - size, - type = "button", - ...props - }, - ref, - ) => { - const buttonRef = React.useRef(null); - const resetTimerRef = React.useRef(null); - const preferReducedMotion = useReducedMotion(); - - const [state, setState] = React.useState("idle"); - const [temptation, setTemptation] = React.useState(0); - - const temptationMv = useMotionValue(0); - const smoothTemptation = useSpring(temptationMv, { - stiffness: 180, - damping: 22, - mass: 0.7, - }); - - const setButtonRef = (node: HTMLButtonElement | null) => { - buttonRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) ref.current = node; - }; - - React.useEffect(() => { - return () => { - if (resetTimerRef.current !== null) { - window.clearTimeout(resetTimerRef.current); - } - }; - }, []); - - React.useEffect(() => { - const unsub = smoothTemptation.on("change", (value) => { - setTemptation(value); - if (state === "succumbed") return; - setState(value > 0.08 ? "tempted" : "idle"); - }); - return unsub; - }, [smoothTemptation, state]); - - React.useEffect(() => { - if (preferReducedMotion || disabled || state === "succumbed") return; - - const handleMouseMove = (e: MouseEvent) => { - const btn = buttonRef.current; - if (!btn) return; - - const rect = btn.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const dx = e.clientX - centerX; - const dy = e.clientY - centerY; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Build temptation as the cursor nears; hover is full temptation. - const proximity = Math.max(0, 1 - distance / 140); - const next = Math.min(1, proximity); - temptationMv.set(Math.max(temptationMv.get() * 0.92, next)); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [disabled, preferReducedMotion, state, temptationMv]); - - const temptIndex = - temptLabels.length === 0 - ? 0 - : Math.min( - temptLabels.length - 1, - Math.floor(temptation * temptLabels.length), - ); - - const displayLabel = - state === "succumbed" - ? succumbLabel - : state === "tempted" - ? (temptLabels[temptIndex] ?? children ?? idleLabel) - : (children ?? idleLabel); - - const handlePointerEnter = () => { - if (disabled || state === "succumbed") return; - temptationMv.set(1); - }; - - const handlePointerLeave = () => { - if (disabled || state === "succumbed") return; - temptationMv.set(0); - }; - - const handleClick = () => { - if (disabled || state === "succumbed") return; - - setState("succumbed"); - temptationMv.set(1); - onSuccumb?.(); - - if (resetAfter > 0) { - if (resetTimerRef.current !== null) { - window.clearTimeout(resetTimerRef.current); - } - resetTimerRef.current = window.setTimeout(() => { - setState("idle"); - temptationMv.set(0); - resetTimerRef.current = null; - }, resetAfter); - } - }; - - const glow = preferReducedMotion - ? "0 0 0px rgba(0,0,0,0)" - : `0 0 ${8 + temptation * 28}px rgba(220, 38, 38, ${0.15 + temptation * 0.45})`; - - const scale = preferReducedMotion ? 1 : 1 + temptation * 0.1; - - return ( - - - - ); - }, -); - -DontPressButton.displayName = "DontPressButton"; - -export default DontPressButton; diff --git a/components/evil-buttons/doubt-button.tsx b/components/evil-buttons/doubt-button.tsx index c2b7718..3025300 100644 --- a/components/evil-buttons/doubt-button.tsx +++ b/components/evil-buttons/doubt-button.tsx @@ -40,24 +40,6 @@ const DEFAULT_CONFIRMATIONS: string[] = [ "Last chance. Absolutely certain?", ]; -const WarningIcon = () => ( - - - - - -); - const CheckIcon = () => ( ( strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" - className="size-4 shrink-0" + className="size-3.5 shrink-0" aria-hidden > @@ -152,10 +134,8 @@ export const DoubtButton = React.forwardRef< const isSuccess = state === "success"; - // 0 (calm) -> 1 (maximum dread) used to escalate the accent toward red. - const t = steps === 0 ? 0 : index / steps; - const accentAlpha = 0.12 + t * 0.55; - const ringAlpha = 0.35 + t * 0.5; + // Share of doubts answered, drawn as a hairline along the bottom edge. + const progress = isSuccess ? 1 : steps === 0 ? 0 : index / steps; const currentLabel: React.ReactNode = isSuccess ? successLabel @@ -175,21 +155,14 @@ export const DoubtButton = React.forwardRef< data-state={state} onClick={handleClick} className={cn( - "relative inline-flex min-w-48 select-none flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border px-4 py-2.5 text-sm font-semibold shadow-sm outline-none transition-colors", - "focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", - isSuccess - ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 focus-visible:ring-emerald-500 dark:text-emerald-400" - : "cursor-pointer text-red-600 dark:text-red-400", + "relative inline-flex h-9 min-w-48 cursor-pointer items-center justify-center overflow-hidden rounded-[10px] px-4 text-sm font-medium text-neutral-50 outline-none select-none", + // Graded dark surface matching MinimalButton: dark outer hairline, faint inner ring, top highlight, soft drop. + "bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)]", + "transition-[scale,filter,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.35,0.64,1)] hover:brightness-110", + "active:scale-[0.97] active:brightness-95 active:duration-100 active:ease-out active:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_1px_0_rgb(255_255_255/0.08),0_0_1px_rgb(0_0_0/0.2),0_1px_3px_-2px_rgb(0_0_0/0.3)] motion-reduce:active:scale-100", + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", className, )} - style={ - isSuccess - ? undefined - : { - borderColor: `rgba(239, 68, 68, ${ringAlpha})`, - backgroundColor: `rgba(239, 68, 68, ${accentAlpha})`, - } - } {...props} > - {isSuccess ? : } + {isSuccess ? : null} {/* Invisible widest prompt reserves width so the button never jumps. */} - {steps > 0 && !isSuccess && ( - - {Array.from({ length: steps }).map((_, i) => ( - - ))} - + {steps > 0 && ( + )} ); diff --git a/components/evil-buttons/evil-eye-button.tsx b/components/evil-buttons/evil-eye-button.tsx index 03ee83b..9719239 100644 --- a/components/evil-buttons/evil-eye-button.tsx +++ b/components/evil-buttons/evil-eye-button.tsx @@ -1,149 +1,563 @@ "use client"; +import * as React from "react"; import { cn } from "@/lib/utils"; -import EvilEye, { type EvilEyeProps } from "../EvilEye"; -import { - type ButtonHTMLAttributes, - type CSSProperties, - type ReactNode, - useSyncExternalStore, -} from "react"; - -type EvilEyeTuningProps = Pick< - EvilEyeProps, - | "eyeColor" - | "intensity" - | "pupilSize" - | "irisWidth" - | "glowIntensity" - | "scale" - | "noiseScale" - | "pupilFollow" - | "flameSpeed" - | "backgroundColor" ->; - -export type EvilEyeButtonProps = ButtonHTMLAttributes & - EvilEyeTuningProps & { - children: ReactNode; - effectClassName?: string; - effectOpacity?: number; - labelClassName?: string; - }; -function subscribeToReducedMotion(callback: () => void) { - if (typeof window === "undefined") return () => undefined; - const media = window.matchMedia("(prefers-reduced-motion: reduce)"); - media.addEventListener("change", callback); - return () => media.removeEventListener("change", callback); +export interface EvilEyeButtonProps + extends React.ButtonHTMLAttributes { + /** Label used when no children are provided. */ + label?: React.ReactNode; + /** Iris and glow color, as a hex string. */ + eyeColor?: string; + /** Brightness multiplier for the flames. */ + intensity?: number; + /** Size of the slit pupil. Lower values make it tighter. */ + pupilSize?: number; + /** Width of the iris ring. */ + irisWidth?: number; + /** Strength of the glow around the iris. */ + glowIntensity?: number; + /** Zoom of the eye inside the button. Lower values zoom in. */ + scale?: number; + /** Scale of the flame noise. */ + noiseScale?: number; + /** How far the pupil tracks the cursor, 0 to 1. */ + pupilFollow?: number; + /** Speed of the flames licking around the iris. */ + flameSpeed?: number; + /** Blink every few seconds on its own, as well as on press. */ + blink?: boolean; + /** Extra classes for the eye layer behind the label. */ + eyeClassName?: string; + /** Extra classes for the label. */ + labelClassName?: string; } -function getReducedMotionSnapshot() { - return ( - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ); +/* ---------- Shader ---------- */ + +const VERTEX = ` +attribute vec2 aPosition; +void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); } +`; + +// The React Bits evil eye: polar-mapped noise makes flames around an iris +// ring, with a vertical slit pupil. uFocus narrows the slit and stokes the +// flames while hovered. +const FRAGMENT = ` +precision highp float; +uniform float uTime; +uniform vec2 uRes; +uniform sampler2D uNoise; +uniform float uPupilSize; +uniform float uIrisWidth; +uniform float uGlow; +uniform float uIntensity; +uniform float uScale; +uniform float uNoiseScale; +uniform vec2 uLook; +uniform float uFocus; +uniform vec3 uEyeColor; +uniform vec3 uBgColor; + +void main() { + vec2 uv = (gl_FragCoord.xy * 2.0 - uRes) / uRes.y; + uv /= uScale; + float ft = uTime; + + vec2 polarUv = vec2(length(uv) * 2.0, (2.0 * atan(uv.x, uv.y)) / 6.28 * 0.3); + vec4 noiseA = texture2D(uNoise, polarUv * vec2(0.2, 7.0) * uNoiseScale + vec2(-ft * 0.1, 0.0)); + vec4 noiseB = texture2D(uNoise, polarUv * vec2(0.3, 4.0) * uNoiseScale + vec2(-ft * 0.2, 0.0)); + vec4 noiseC = texture2D(uNoise, polarUv * vec2(0.1, 5.0) * uNoiseScale + vec2(-ft * 0.1, 0.0)); + + float distanceMask = 1.0 - length(uv); + + float innerRing = clamp(-1.0 * ((distanceMask - 0.7) / uIrisWidth), 0.0, 1.0); + innerRing = (innerRing * distanceMask - 0.2) / 0.28; + innerRing += noiseA.r - 0.5; + innerRing *= 1.3; + innerRing = clamp(innerRing, 0.0, 1.0); + + float outerRing = clamp(-1.0 * ((distanceMask - 0.5) / 0.2), 0.0, 1.0); + outerRing = (outerRing * distanceMask - 0.1) / 0.38; + outerRing += noiseC.r - 0.5; + outerRing *= 1.3; + outerRing = clamp(outerRing, 0.0, 1.0); + + innerRing += outerRing; + + float innerEye = distanceMask - 0.2; + innerEye *= noiseB.r * 2.0; + + vec2 pupilUv = uv - uLook * 0.32; + float slit = mix(9.0, 14.0, uFocus); + float pupil = 1.0 - length(pupilUv * vec2(slit, 2.3)); + pupil *= uPupilSize; + pupil = clamp(pupil, 0.0, 1.0); + pupil /= 0.35; + + float outerEyeGlow = 1.0 - length(uv * vec2(0.5, 1.5)); + outerEyeGlow = clamp(outerEyeGlow + 0.5, 0.0, 1.0); + outerEyeGlow += noiseC.r - 0.5; + float outerBgGlow = outerEyeGlow; + outerEyeGlow = pow(outerEyeGlow, 2.0); + outerEyeGlow += distanceMask; + outerEyeGlow *= uGlow; + outerEyeGlow = clamp(outerEyeGlow, 0.0, 1.0); + outerEyeGlow *= pow(1.0 - distanceMask, 2.0) * 2.5; + + outerBgGlow += distanceMask; + outerBgGlow = pow(outerBgGlow, 0.5); + outerBgGlow *= 0.15; -function getServerReducedMotionSnapshot() { - return false; + float lit = clamp(max(innerRing + innerEye, outerEyeGlow + outerBgGlow) - pupil, 0.0, 3.0); + vec3 color = uEyeColor * uIntensity * (1.0 + uFocus * 0.12) * lit; + gl_FragColor = vec4(color + uBgColor, 1.0); } +`; -function ReducedMotionEye({ - backgroundColor, - eyeColor, -}: { - backgroundColor: string; - eyeColor: string; -}) { - const style = { - "--evil-eye-bg": backgroundColor, - "--evil-eye-color": eyeColor, - } as CSSProperties; - - return ( -
- ); +/** Tileable fractal value noise, built once and shared by every eye. */ +let noiseCache: Uint8Array | null = null; + +function noiseTexture(size = 256) { + if (noiseCache) return noiseCache; + const data = new Uint8Array(size * size * 4); + const hash = (x: number, y: number, s: number) => { + let n = x * 374761393 + y * 668265263 + s * 1274126177; + n = Math.imul(n ^ (n >>> 13), 1274126177); + return ((n ^ (n >>> 16)) >>> 0) / 4294967296; + }; + const noise = (px: number, py: number, freq: number, seed: number) => { + const fx = (px / size) * freq; + const fy = (py / size) * freq; + const ix = Math.floor(fx); + const iy = Math.floor(fy); + const tx = fx - ix; + const ty = fy - iy; + const w = freq | 0; + const wrap = (v: number) => ((v % w) + w) % w; + const v00 = hash(wrap(ix), wrap(iy), seed); + const v10 = hash(wrap(ix + 1), wrap(iy), seed); + const v01 = hash(wrap(ix), wrap(iy + 1), seed); + const v11 = hash(wrap(ix + 1), wrap(iy + 1), seed); + return ( + v00 * (1 - tx) * (1 - ty) + + v10 * tx * (1 - ty) + + v01 * (1 - tx) * ty + + v11 * tx * ty + ); + }; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + let v = 0; + let amp = 0.4; + let total = 0; + for (let o = 0; o < 8; o++) { + v += amp * noise(x, y, 32 * (1 << o), o * 31); + total += amp; + amp *= 0.65; + } + v = Math.max(0, Math.min(1, (v / total - 0.5) * 2.2 + 0.5)); + const i = (y * size + x) * 4; + data[i] = data[i + 1] = data[i + 2] = Math.round(v * 255); + data[i + 3] = 255; + } + } + noiseCache = data; + return data; } -function EvilEyeButton({ - children, - className, - effectClassName, - effectOpacity = 0.95, - labelClassName, - eyeColor = "#ff6f37", - intensity = 1.65, - pupilSize = 0.62, - irisWidth = 0.22, - glowIntensity = 0.56, - scale = 1.15, - noiseScale = 1, - pupilFollow = 0.55, - flameSpeed = 0.8, - backgroundColor = "#050000", - ...props -}: EvilEyeButtonProps) { - const reduceMotion = useSyncExternalStore( - subscribeToReducedMotion, - getReducedMotionSnapshot, - getServerReducedMotionSnapshot, +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", ""); + const full = h.length === 3 ? h.replace(/./g, "$&$&") : h; + const n = parseInt(full.slice(0, 6), 16) || 0; + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} + +const UNIFORMS = [ + "uTime", + "uRes", + "uNoise", + "uPupilSize", + "uIrisWidth", + "uGlow", + "uIntensity", + "uScale", + "uNoiseScale", + "uLook", + "uFocus", + "uEyeColor", + "uBgColor", +] as const; + +type EyeGL = { + gl: WebGLRenderingContext; + u: Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>; +}; + +function createEyeGL(canvas: HTMLCanvasElement): EyeGL | null { + const gl = canvas.getContext("webgl", { + alpha: false, + antialias: false, + powerPreference: "low-power", + }); + if (!gl) return null; + + const compile = (type: number, source: string) => { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, source); + gl.compileShader(shader); + return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null; + }; + const vs = compile(gl.VERTEX_SHADER, VERTEX); + const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT); + if (!vs || !fs) return null; + const program = gl.createProgram()!; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null; + gl.useProgram(program); + + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + gl.STATIC_DRAW, + ); + const position = gl.getAttribLocation(program, "aPosition"); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 256, + 256, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + noiseTexture(256), ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); - return ( - - ); -} + + + + {/* Readability: a soft dark band behind the label, fading out before the + iris edge, and a rim vignette that frames the eye in the pill. */} + + {/* Glass lip along the top edge. */} + + + + {children ?? label} + + + ); + }, +); + +EvilEyeButton.displayName = "EvilEyeButton"; export default EvilEyeButton; diff --git a/components/evil-buttons/frame-button.tsx b/components/evil-buttons/frame-button.tsx index 1f06ed1..0a3e598 100644 --- a/components/evil-buttons/frame-button.tsx +++ b/components/evil-buttons/frame-button.tsx @@ -1,313 +1,214 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -"use client"; - -import { cn } from "@/lib/utils"; -import type { ComponentPropsWithoutRef } from "react"; -import React from "react"; -import { motion } from "motion/react"; -import Link from "next/link"; -import type { UrlObject } from "url"; -import type { Route } from "next"; - -type ButtonVariant = "outline" | "default" | "secondary"; -const MotionLink = motion.create(Link); -type Href = Route | UrlObject; - -type BaseProps = { - children: React.ReactNode; - variant?: ButtonVariant; - className?: string; - glow?: boolean; - size?: number | string; - offset?: number; - hoverOffset?: number; -}; - -type ButtonProps = BaseProps & - ComponentPropsWithoutRef & { - as?: "button"; - href?: never; - }; - -type AnchorProps = BaseProps & - Omit, "href"> & { - as: "link"; - href: Href; - }; - -type FrameButtonProps = ButtonProps | AnchorProps; - -export function FrameButton({ - children, - className, - variant = "default", - glow = false, - size = 20, - offset = 7.5, - hoverOffset = 7, - ...props -}: FrameButtonProps) { - const commonStyles = cn( - "group relative inline-flex overflow-visible items-center justify-center", - "border px-8 py-4", - "cursor-pointer no-underline", - "uppercase tracking-[0.2em]", - "text-sm font-medium", - "transition-all duration-300", - "select-none", - - "text-neutral-950 dark:text-neutral-50 border-[1.5px] border-current/30", - - "hover:bg-current/10", - - "active:scale-[0.985]", - - variant === "default" && [ - "dark:bg-white bg-black hover:bg-black text-white dark:text-black", - ], - - variant === "secondary" && [ - "hover:bg-black dark:hover:bg-white hover:text-white dark:hover:text-black text-black dark:text-white", - ], - - variant === "outline" && ["bg-transparent", "hover:bg-current/10"], - - className, - ); - - const glowLayer = glow ? ( -
- ) : null; - - const Content = ( - <> - {glowLayer} - {children} - - - ); - - if (props.as === "link") { - const { as, href, ...anchorProps } = props; - - return ( - - {Content} - - ); - } - - const { as, ...buttonProps } = props; - - return ( - - {Content} - - ); -} - -type IconProps = React.SVGProps; - -export function ChevronDownLeft({ className, ...props }: IconProps) { - return ( - - - - - ); -} - -export function ChevronDownRight({ className, ...props }: IconProps) { - return ( - - - - - ); -} - -export function ChevronUpLeft({ className, ...props }: IconProps) { - return ( - - - - - ); -} - -export function ChevronUpRight({ className, ...props }: IconProps) { - return ( - - - - - ); -} - -interface FrameMarkersProps { - className?: string; - size?: number | string; - offset?: number; - hoverOffset?: number; -} - -export function FrameMarkers({ - className, - size = 20, - offset = 7.5, - hoverOffset = 4, -}: FrameMarkersProps) { - const isSizeString = typeof size === "string" && size.includes("-"); - - const baseStyles = cn( - "absolute text-muted-foreground transition-all duration-300 ease-out", - "group-hover:text-accent-foreground pointer-events-none", - isSizeString ? size : "", - className, - ); - - const styleBase = isSizeString ? {} : { width: size, height: size }; - const offsetPx = `-${offset}px`; - const movePx = `${hoverOffset}px`; - const negMovePx = `-${hoverOffset}px`; - - return ( - <> - {/* Top Left */} - - {/* Top Right */} - - {/* Bottom Right */} - - {/* Bottom Left */} - - - ); -} +"use client"; + +import * as React from "react"; +import type { ComponentPropsWithoutRef } from "react"; +import { motion, type Variants } from "motion/react"; +import Link from "next/link"; +import type { Route } from "next"; +import type { UrlObject } from "url"; +import { cn } from "@/lib/utils"; + +type ButtonVariant = "default" | "secondary" | "outline"; +type Href = Route | UrlObject; + +const MotionLink = motion.create(Link); + +type BaseProps = { + children: React.ReactNode; + variant?: ButtonVariant; + className?: string; + /** Soft glow that blooms behind the button on hover. */ + glow?: boolean; + /** Corner marker size: px as a number, or a Tailwind size class like `"size-4"`. */ + size?: number | string; + /** Distance in px the markers sit outside the button's edge. */ + offset?: number; + /** How far in px the markers spread out on hover. */ + hoverOffset?: number; +}; + +type ButtonProps = BaseProps & + Omit, "children"> & { + as?: "button"; + href?: never; + }; + +type AnchorProps = BaseProps & + Omit, "href" | "children"> & { + as: "link"; + href: Href; + }; + +export type FrameButtonProps = ButtonProps | AnchorProps; + +const VARIANTS: Record = { + // Graded dark surface: dark outer hairline, faint inner ring, top highlight, soft drop, plus the shared press. + default: "border-transparent text-neutral-50 hover:brightness-110 active:brightness-95 bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)] active:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_1px_0_rgb(255_255_255/0.08),0_0_1px_rgb(0_0_0/0.2),0_1px_3px_-2px_rgb(0_0_0/0.3)]", + secondary: + "border-border bg-secondary text-secondary-foreground shadow-[0_1px_2px_rgb(0_0_0/0.05),0_2px_6px_-3px_rgb(0_0_0/0.07),inset_0_-1px_0_rgb(0_0_0/0.03)] dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),inset_0_1px_0_rgb(255_255_255/0.05)] hover:border-foreground hover:bg-foreground hover:text-background", + outline: "border-border bg-transparent text-foreground hover:bg-foreground/5 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_2px_6px_-3px_rgb(0_0_0/0.07),inset_0_-1px_0_rgb(0_0_0/0.03)] dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),inset_0_1px_0_rgb(255_255_255/0.05)]", +}; + +const SPRING = { type: "spring", stiffness: 380, damping: 24 } as const; + +// Diagonal light band that sweeps across the face on hover. +const sweepVariants: Variants = { + rest: { x: "-120%", transition: { duration: 0 } }, + hover: { x: "320%", transition: { duration: 0.9, ease: [0.4, 0, 0.2, 1] } }, + tap: { x: "320%" }, +}; + +const glowVariants: Variants = { + rest: { opacity: 0, scale: 0.9 }, + hover: { opacity: 0.35, scale: 1.08 }, + tap: { opacity: 0.5, scale: 1 }, +}; + +export function FrameButton({ + children, + className, + variant = "default", + glow = false, + size = 14, + offset = 6, + hoverOffset = 5, + ...props +}: FrameButtonProps) { + const classes = cn( + "group/frame relative inline-flex cursor-pointer items-center justify-center border px-8 py-3.5 text-xs font-medium tracking-[0.22em] uppercase no-underline outline-none select-none", + // Press eases in fast; release springs back on the slower base curve. + "transition-[scale,filter,box-shadow,background-color,border-color,color] duration-300 ease-[cubic-bezier(0.34,1.35,0.64,1)] active:scale-[0.97] active:duration-100 active:ease-out motion-reduce:active:scale-100", + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50", + VARIANTS[variant], + className, + ); + + const content = ( + <> + {glow ? ( + + ) : null} + + + + + {children} + + + + ); + + const motionProps = { + initial: "rest", + animate: "rest", + whileHover: "hover", + whileTap: "tap", + whileFocus: "hover", + } as const; + + if (props.as === "link") { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { as, href, ...anchorProps } = props; + return ( + + {content} + + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { as, type = "button", ...buttonProps } = props; + return ( + + {content} + + ); +} + +interface FrameMarkersProps { + className?: string; + size?: number | string; + offset?: number; + hoverOffset?: number; +} + +const MARKERS = [ + { key: "tl", x: -1, y: -1, className: "border-t-[1.5px] border-l-[1.5px]" }, + { key: "tr", x: 1, y: -1, className: "border-t-[1.5px] border-r-[1.5px]" }, + { key: "bl", x: -1, y: 1, className: "border-b-[1.5px] border-l-[1.5px]" }, + { key: "br", x: 1, y: 1, className: "border-b-[1.5px] border-r-[1.5px]" }, +] as const; + +const markerVariants: Variants = { + rest: { x: 0, y: 0 }, + hover: ({ x, y, spread }: { x: number; y: number; spread: number }) => ({ + x: x * spread, + y: y * spread, + }), + // Lock on: snap in tight against the button's edge. + tap: ({ x, y, offset }: { x: number; y: number; offset: number }) => ({ + x: -x * (offset - 1), + y: -y * (offset - 1), + }), +}; + +/** + * Four corner brackets around the nearest `motion` parent. They follow its + * `rest` / `hover` / `tap` variants, so the parent needs those states set. + */ +export function FrameMarkers({ + className, + size = 14, + offset = 6, + hoverOffset = 5, +}: FrameMarkersProps) { + const sizeClass = typeof size === "string" ? size : undefined; + const sizeStyle = + typeof size === "number" ? { width: size, height: size } : {}; + + return ( + <> + {MARKERS.map((marker) => ( + + ))} + + ); +} diff --git a/components/evil-buttons/glitch-button.tsx b/components/evil-buttons/glitch-button.tsx index d88e793..e4a3c5c 100644 --- a/components/evil-buttons/glitch-button.tsx +++ b/components/evil-buttons/glitch-button.tsx @@ -179,18 +179,21 @@ export function GlitchButton({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className={cn( - "relative inline-flex items-center justify-center overflow-hidden", - "px-8 py-3 font-mono text-sm font-bold uppercase tracking-widest", - "border border-neutral-700 bg-neutral-950 text-white", - "transition-transform duration-75 active:scale-[0.98]", - "outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-background", + "relative inline-flex h-9 cursor-pointer items-center justify-center overflow-hidden rounded-[10px] px-6 font-mono text-xs font-semibold tracking-widest text-neutral-50 uppercase select-none", + // Graded dark surface: dark outer hairline, faint inner ring, top highlight, soft drop. + "bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)]", + // The glitch shake keyframes own `transform`, so the press animates the separate `scale` property. + "transition-[scale,filter,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.35,0.64,1)] hover:brightness-110", + // Press eases in fast and settles the shadow; release springs back on the slower base curve. + "active:scale-[0.97] active:brightness-95 active:duration-100 active:ease-out active:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_1px_0_rgb(255_255_255/0.08),0_0_1px_rgb(0_0_0/0.2),0_1px_3px_-2px_rgb(0_0_0/0.3)] motion-reduce:active:scale-100", + "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", className, )} style={rootStyle} > - - {children} - - ); -} - -const Box = () => { - return ( -
- -
- ); -}; diff --git a/components/evil-buttons/highlight-button.tsx b/components/evil-buttons/highlight-button.tsx index edd5753..9e4043d 100644 --- a/components/evil-buttons/highlight-button.tsx +++ b/components/evil-buttons/highlight-button.tsx @@ -1,127 +1,196 @@ -"use client" +"use client"; -import * as React from "react" -import { cn } from "@/lib/utils" -import { Button, type buttonVariants } from "@/components/ui/button" -import type { VariantProps } from "class-variance-authority" +import * as React from "react"; +import { motion } from "motion/react"; +import { cn } from "@/lib/utils"; -interface HighlightButtonProps - extends React.ComponentProps<"button">, - VariantProps { - asChild?: boolean - highlightColor?: string - highlightSize?: number - borderColor?: string +export interface HighlightButtonProps extends Omit< + React.ButtonHTMLAttributes, + "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" +> { + /** Surface style of the button. */ + variant?: "default" | "secondary" | "outline"; + /** Color of the cursor spotlight and click ripple. */ + highlightColor?: string; + /** Radius in px of the cursor spotlight. */ + highlightSize?: number; + /** Color the border lights up with near the cursor. */ + borderColor?: string; } -function HighlightButton({ - className, - variant = "default", - size = "default", - asChild = false, - highlightColor = "color-mix(in oklab, currentColor 55%, transparent)", - highlightSize = 56, - borderColor = "color-mix(in oklab, currentColor 58%, transparent)", - children, - onClick, - ...props -}: HighlightButtonProps) { - const buttonRef = React.useRef(null) - const [position, setPosition] = React.useState({ x: 0, y: 0 }) - const [isHovering, setIsHovering] = React.useState(false) - const [isClicked, setIsClicked] = React.useState(false) - const [clickPosition, setClickPosition] = React.useState({ x: 0, y: 0 }) +type Ripple = { id: number; x: number; y: number; size: number }; - const handleMouseMove = React.useCallback( - (e: React.MouseEvent) => { - if (!buttonRef.current || isClicked) return - const rect = buttonRef.current.getBoundingClientRect() - setPosition({ - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }) +const VARIANTS = { + // Graded dark surface: dark outer hairline, faint inner ring, top highlight, soft drop. + default: + "border-transparent text-neutral-50 hover:brightness-110 active:brightness-95 bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)] active:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_1px_0_rgb(255_255_255/0.08),0_0_1px_rgb(0_0_0/0.2),0_1px_3px_-2px_rgb(0_0_0/0.3)]", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_2px_6px_-3px_rgb(0_0_0/0.07),inset_0_-1px_0_rgb(0_0_0/0.03)] dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),inset_0_1px_0_rgb(255_255_255/0.05)]", + outline: "border-border bg-background text-foreground hover:bg-accent/40 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_2px_6px_-3px_rgb(0_0_0/0.07),inset_0_-1px_0_rgb(0_0_0/0.03)] dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),inset_0_1px_0_rgb(255_255_255/0.05)]", +} as const; + +// Paints only the 1px border ring: the content box is cut out of the full box. +const BORDER_MASK = { + padding: 1, + mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)", + WebkitMask: + "linear-gradient(#000 0 0) content-box xor, linear-gradient(#000 0 0)", +} satisfies React.CSSProperties; + +export const HighlightButton = React.forwardRef< + HTMLButtonElement, + HighlightButtonProps +>( + ( + { + variant = "default", + highlightColor = "color-mix(in oklab, currentColor 28%, transparent)", + highlightSize = 90, + borderColor = "color-mix(in oklab, currentColor 75%, transparent)", + className, + style, + children, + onPointerMove, + onPointerDown, + onClick, + type = "button", + ...props }, - [isClicked] - ) + ref, + ) => { + const buttonRef = React.useRef(null); + const rippleIdRef = React.useRef(0); + const [ripples, setRipples] = React.useState([]); - const handleMouseEnter = React.useCallback(() => { - setIsHovering(true) - }, []) + const setRefs = React.useCallback( + (node: HTMLButtonElement | null) => { + buttonRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); - const handleMouseLeave = React.useCallback(() => { - setIsHovering(false) - setIsClicked(false) - }, []) + /** Cursor position relative to the button, written to CSS vars without re-rendering. */ + const track = (clientX: number, clientY: number) => { + const button = buttonRef.current; + if (!button) return null; + const rect = button.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + button.style.setProperty("--hl-x", `${x}px`); + button.style.setProperty("--hl-y", `${y}px`); + return { x, y, rect }; + }; - const handleClick = React.useCallback( - (e: React.MouseEvent) => { - if (!buttonRef.current) return - const rect = buttonRef.current.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - setClickPosition({ x, y }) - setIsClicked(true) - onClick?.(e) - }, - [onClick] - ) + const addRipple = (x: number, y: number, rect: DOMRect) => { + // Big enough to reach the farthest corner from where it starts. + const size = + 2 * + Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y)); + const id = ++rippleIdRef.current; + setRipples((current) => [...current, { id, x, y, size }]); + }; - return ( - - ) -} + + {children} + + + ); + }, +); -export { HighlightButton, type HighlightButtonProps } +HighlightButton.displayName = "HighlightButton"; diff --git a/components/evil-buttons/hold-button.tsx b/components/evil-buttons/hold-button.tsx index b714716..14901c7 100644 --- a/components/evil-buttons/hold-button.tsx +++ b/components/evil-buttons/hold-button.tsx @@ -26,26 +26,6 @@ export interface HoldButtonProps resetAfter?: number; } -const TrashIcon = () => ( - - - - - - - -); - const CheckIcon = () => ( ( strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" - className="size-4 shrink-0" + className="size-3.5 shrink-0" aria-hidden > @@ -175,15 +155,13 @@ export const HoldButton = React.forwardRef( buttonRef.current?.animate?.( [ { transform: "translateX(0)" }, - { transform: "translateX(-7px)" }, - { transform: "translateX(7px)" }, - { transform: "translateX(-6px)" }, - { transform: "translateX(6px)" }, - { transform: "translateX(-3px)" }, - { transform: "translateX(3px)" }, + { transform: "translateX(-4px)" }, + { transform: "translateX(4px)" }, + { transform: "translateX(-2px)" }, + { transform: "translateX(2px)" }, { transform: "translateX(0)" }, ], - { duration: 420, easing: "ease-in-out" }, + { duration: 300, easing: "ease-in-out" }, ); onAbort?.(reached); }; @@ -193,7 +171,7 @@ export const HoldButton = React.forwardRef( const content = ( <> - {isSuccess ? : } + {isSuccess ? : null} {isSuccess ? successLabel : isHolding ? holdingLabel : label} @@ -229,31 +207,29 @@ export const HoldButton = React.forwardRef( }} onContextMenu={(e) => e.preventDefault()} className={cn( - "relative inline-flex min-w-44 select-none items-center justify-center overflow-hidden rounded-md border px-4 py-2.5 text-sm font-semibold shadow-sm outline-none transition-colors", - "touch-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", - isSuccess - ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 focus-visible:ring-emerald-500 dark:text-emerald-400" - : "cursor-pointer border-red-500/40 bg-red-500/10 text-red-600 focus-visible:ring-red-500 dark:text-red-400", + "relative inline-flex h-9 min-w-44 cursor-pointer items-center justify-center overflow-hidden rounded-[10px] px-4 text-sm font-medium text-neutral-50 outline-none select-none", + // Graded dark surface matching MinimalButton: dark outer hairline, faint inner ring, top highlight, soft drop. + "bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)]", + "transition-[scale,filter,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.35,0.64,1)] hover:brightness-110", + "active:scale-[0.97] active:brightness-95 active:duration-100 active:ease-out active:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_1px_0_rgb(255_255_255/0.08),0_0_1px_rgb(0_0_0/0.2),0_1px_3px_-2px_rgb(0_0_0/0.3)] motion-reduce:active:scale-100", + "touch-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", className, )} {...props} > {/* Base label, visible where the fill has not yet covered. */} - + {content} - {/* Fill overlay that wipes across and reveals a knockout white label. */} + {/* Fill overlay that wipes across and reveals a knockout inverted label. */} {content} diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx deleted file mode 100644 index eb6390d..0000000 --- a/components/evil-buttons/hold-confirm-button.tsx +++ /dev/null @@ -1,238 +0,0 @@ -"use client"; - -import * as React from "react"; -import { - animate, - mapValue, - motionValue, - press, - springValue, - styleEffect, - svgEffect, -} from "motion"; -import { cn } from "@/lib/utils"; - -type HoldConfirmState = "idle" | "holding" | "success"; - -export interface HoldConfirmButtonProps - extends Omit< - React.ButtonHTMLAttributes, - "onClick" | "onAbort" - > { - /** Milliseconds the user must hold before `onConfirm` fires. */ - duration?: number; - /** Label shown while idle. */ - label?: React.ReactNode; - /** Optional label shown while holding. Falls back to `label`. */ - holdingLabel?: React.ReactNode; - /** Label shown after a successful hold. */ - successLabel?: React.ReactNode; - /** Smallest scale reached at 100% progress. */ - minScale?: number; - /** Diameter of the progress ring in pixels. */ - ringSize?: number; - /** Stroke width of the progress ring. */ - ringStrokeWidth?: number; - /** Color of the progress ring. */ - ringColor?: string; - /** Fired when the hold reaches 100%. */ - onConfirm?: () => void; - /** Fired when the hold is released early. Receives progress reached (0-1). */ - onAbort?: (progress: number) => void; - /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */ - resetAfter?: number; -} - -export const HoldConfirmButton = React.forwardRef< - HTMLButtonElement, - HoldConfirmButtonProps ->( - ( - { - duration = 2000, - label = "Hold to confirm", - holdingLabel, - successLabel = "Confirmed", - minScale = 0.9, - ringSize = 280, - ringStrokeWidth = 12, - ringColor = "#5eead4", - onConfirm, - onAbort, - resetAfter = 1600, - className, - disabled, - type = "button", - ...props - }, - ref, - ) => { - const [state, setState] = React.useState("idle"); - - const buttonRef = React.useRef(null); - const ringRef = React.useRef(null); - const circleRef = React.useRef(null); - const animationRef = React.useRef | null>(null); - const stateRef = React.useRef("idle"); - const resetTimeoutRef = React.useRef(null); - - const setButtonRef = (node: HTMLButtonElement | null) => { - buttonRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) ref.current = node; - }; - - React.useEffect(() => { - stateRef.current = state; - }, [state]); - - React.useEffect(() => { - const button = buttonRef.current; - const ring = ringRef.current; - const circle = circleRef.current; - if (!button || !ring || !circle || disabled) return; - - const progress = motionValue(0); - const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), { - stiffness: 420, - damping: 28, - mass: 0.6, - }); - - const ringOpacity = mapValue(progress, [0, 0.01, 1], [0, 1, 1]); - - const cancelStyle = styleEffect(button, { scale }); - const cancelRingOpacity = styleEffect(ring, { opacity: ringOpacity }); - const cancelSvg = svgEffect(circle, { pathLength: progress }); - - const stopAnimation = () => { - animationRef.current?.stop(); - animationRef.current = null; - }; - - const rewind = () => { - stopAnimation(); - animate(progress, 0, { type: "spring", stiffness: 520, damping: 32 }); - }; - - const complete = () => { - stopAnimation(); - progress.set(1); - setState("success"); - onConfirm?.(); - - if (resetAfter > 0) { - if (resetTimeoutRef.current !== null) { - window.clearTimeout(resetTimeoutRef.current); - } - resetTimeoutRef.current = window.setTimeout(() => { - rewind(); - setState("idle"); - }, resetAfter); - } - }; - - const cancelPress = press(button, () => { - if (stateRef.current !== "idle") return; - - if (resetTimeoutRef.current !== null) { - window.clearTimeout(resetTimeoutRef.current); - } - - setState("holding"); - stopAnimation(); - progress.set(0); - - animationRef.current = animate(progress, 1, { - duration: duration / 1000, - ease: "linear", - onComplete: complete, - }); - - return (_endEvent, { success }) => { - if (stateRef.current === "success") return; - - if (!success || progress.get() < 1) { - const reached = progress.get(); - setState("idle"); - rewind(); - onAbort?.(reached); - } - }; - }); - - return () => { - cancelPress(); - cancelStyle(); - cancelRingOpacity(); - cancelSvg(); - stopAnimation(); - if (resetTimeoutRef.current !== null) { - window.clearTimeout(resetTimeoutRef.current); - } - }; - }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]); - - const radius = ringSize / 2 - ringStrokeWidth / 2 - 6; - const center = ringSize / 2; - const isHolding = state === "holding"; - const isSuccess = state === "success"; - const displayLabel = isSuccess - ? successLabel - : isHolding && holdingLabel !== undefined - ? holdingLabel - : label; - - return ( -
- - - - - -
- ); - }, -); - -HoldConfirmButton.displayName = "HoldConfirmButton"; - -export default HoldConfirmButton; \ No newline at end of file diff --git a/components/evil-buttons/minecraft-button.tsx b/components/evil-buttons/minecraft-button.tsx new file mode 100644 index 0000000..2188222 --- /dev/null +++ b/components/evil-buttons/minecraft-button.tsx @@ -0,0 +1,651 @@ +"use client"; + +import * as React from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; + +/** One texture pixel, in CSS pixels. Everything snaps to this grid. */ +const PX = 3; +/** Stone tile size in texels (Minecraft block textures are 16×16). */ +const TILE = 16; + +const STONE_BASE = "#7d7d7d"; +const STONE_SHADES = ["#747474", "#6a6a6a", "#868686", "#8f8f8f", "#5f5f5f"]; + +function mulberry32(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** A seeded 16×16 stone texture, so server and client render the same tile. */ +const STONE_TEXTURE = (() => { + const rand = mulberry32(1337); + let rects = ""; + for (let y = 0; y < TILE; y++) { + for (let x = 0; x < TILE; x++) { + if (rand() < 0.45) continue; + const shade = STONE_SHADES[Math.floor(rand() * STONE_SHADES.length)]!; + rects += ``; + } + } + const svg = `${rects}`; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; +})(); + +const pixelFont: React.CSSProperties = { + // Bring your own pixel font through --font-pixel (the docs use Pixelify Sans). + fontFamily: "var(--font-pixel, ui-monospace), monospace", +}; + +const stoneStyle: React.CSSProperties = { + backgroundColor: STONE_BASE, + backgroundImage: STONE_TEXTURE, + backgroundSize: `${TILE * PX}px ${TILE * PX}px`, + imageRendering: "pixelated", +}; + +/** + * Pixels of a crack network across a cols×rows grid, ordered so that any + * prefix is a plausible "earlier" stage: every branch grows one step per round. + */ +function crackOrder(cols: number, rows: number, seed: number) { + if (cols < 2 || rows < 2) return [] as [number, number][]; + const rand = mulberry32(seed); + type Walker = { x: number; y: number; a: number; left: number }; + const walkers: Walker[] = []; + const origins = Math.max(1, Math.round(cols / 24)); + + for (let i = 0; i < origins; i++) { + const ox = ((i + 0.5) / origins) * cols + (rand() - 0.5) * 4; + const oy = rows / 2 + (rand() - 0.5) * 3; + const branches = 4 + Math.floor(rand() * 2); + for (let b = 0; b < branches; b++) { + walkers.push({ + x: ox, + y: oy, + a: (b / branches) * Math.PI * 2 + rand() * 0.8, + left: 7 + Math.floor(rand() * 12), + }); + } + } + + const seen = new Set(); + const order: [number, number][] = []; + while (walkers.some((w) => w.left > 0)) { + for (const w of [...walkers]) { + if (w.left <= 0) continue; + w.left--; + w.a += (rand() - 0.5) * 1.1; + w.x += Math.cos(w.a); + w.y += Math.sin(w.a) * 0.75; + const x = Math.round(w.x); + const y = Math.round(w.y); + if (x < 0 || y < 0 || x >= cols || y >= rows) { + w.left = 0; + continue; + } + const key = y * cols + x; + if (!seen.has(key)) { + seen.add(key); + order.push([x, y]); + } + if (rand() < 0.12 && w.left > 3) { + walkers.push({ + x: w.x, + y: w.y, + a: w.a + (rand() < 0.5 ? -0.9 : 0.9), + left: Math.floor(w.left / 2), + }); + } + } + } + return order; +} + +/* ---------- Sound: synthesized, no assets ---------- */ + +let audioCtx: AudioContext | null = null; + +function getAudio() { + if (typeof window === "undefined") return null; + if (!audioCtx) { + const Ctor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!Ctor) return null; + audioCtx = new Ctor(); + } + if (audioCtx.state === "suspended") void audioCtx.resume(); + return audioCtx; +} + +function playNoise(freq: number, duration: number, volume: number, q = 1) { + const ctx = getAudio(); + if (!ctx) return; + const length = Math.floor(ctx.sampleRate * duration); + const buffer = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1; + + const source = ctx.createBufferSource(); + source.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = "bandpass"; + filter.frequency.value = freq; + filter.Q.value = q; + const gain = ctx.createGain(); + const t = ctx.currentTime; + gain.gain.setValueAtTime(volume, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + duration); + + source.connect(filter).connect(gain).connect(ctx.destination); + source.start(t); +} + +function playPop() { + const ctx = getAudio(); + if (!ctx) return; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const t = ctx.currentTime; + const base = 480 + Math.random() * 160; + osc.type = "triangle"; + osc.frequency.setValueAtTime(base, t); + osc.frequency.exponentialRampToValueAtTime(base * 2, t + 0.07); + gain.gain.setValueAtTime(0.16, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.12); + osc.connect(gain).connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.13); +} + +const sounds = { + dig: () => playNoise(900 + Math.random() * 400, 0.08, 0.35, 1.4), + crack: () => playNoise(520, 0.16, 0.5, 0.9), + break: () => { + playNoise(320, 0.38, 0.7, 0.7); + playNoise(1300, 0.22, 0.3, 1.2); + }, + pop: playPop, + place: () => playNoise(240, 0.12, 0.55, 0.8), +}; + +/* ---------- Particles: pixel chips flung with gravity ---------- */ + +function spawnChips( + layer: HTMLElement, + origin: { x: number; y: number }, + area: { w: number; h: number }, + count: number, + force: number, +) { + for (let i = 0; i < count; i++) { + const chip = document.createElement("span"); + const size = PX * (1 + Math.floor(Math.random() * 2)); + const color = + Math.random() < 0.3 + ? STONE_BASE + : STONE_SHADES[Math.floor(Math.random() * STONE_SHADES.length)]!; + const x0 = origin.x + (Math.random() - 0.5) * area.w; + const y0 = origin.y + (Math.random() - 0.5) * area.h; + Object.assign(chip.style, { + position: "absolute", + left: `${x0}px`, + top: `${y0}px`, + width: `${size}px`, + height: `${size}px`, + background: color, + boxShadow: `inset -1px -1px 0 rgb(0 0 0 / 0.25)`, + } satisfies Partial); + layer.appendChild(chip); + + const vx = (Math.random() - 0.5) * 2 * force; + const vy = -(0.4 + Math.random()) * force; + const gravity = 900; + const duration = 450 + Math.random() * 450; + const frames: Keyframe[] = []; + for (let s = 0; s <= 10; s++) { + const t = (s / 10) * (duration / 1000); + const dx = Math.round((vx * t) / PX) * PX; + const dy = Math.round((vy * t + 0.5 * gravity * t * t) / PX) * PX; + frames.push({ + transform: `translate(${dx}px, ${dy}px)`, + opacity: s < 7 ? 1 : 1 - (s - 7) / 3, + }); + } + chip + .animate(frames, { duration, easing: "linear", fill: "forwards" }) + .finished.then( + () => chip.remove(), + () => chip.remove(), + ); + } +} + +/* ---------- Pickaxe cursor ---------- */ + +/** 16×16 diamond pickaxe sprite (drawn at 4×, head facing left), inlined so the item stays one file. */ +const PICKAXE_SPRITE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAACmUlEQVR42u1bsUorQRQ9ioUiWCh2GlexeGCTKpWgrQhiwB8QXqEoiM37Bhv9AtFP0M5WQZs0pnwPIaxB3hPCswgEGwubRPaOmtmbuTOzm8zpbnZ2ZvZkztk7d3eBgICBxpDl/iMARQALQv3VAFQBxFITHLFMQHFipXQh2WHzplKWJGDYMgFS/7y1Pm0TkHlwJRCBp+myhTkn+zT2BK4Jbu5sFIimH59fSYO7sXkSzx79IvFb46XrACPTkyQeXZwj8cPWHonbnnDZKwFcCdjQtCmM5jTwHtAPBNRMTmbnAdfV/yTeXpuhs1n/SWJV8/+OT42utnlTuQXQ8aGOCbojIAO4AHAi1Vk/SGDgCDDSvAq2BP7OL5H4TNG8eh8vHOyS+E+lkdRwLxdf9UqAAEQ1bIo8SkAUuhUQgeb+Erm9aC5vCt1eYFPdz+ty+6H9QxKvFqdIrO4drioNo1zeFDoJuMj9ve4vggf4ngA8ewKbAF1u36y3kvf5MoDl5HF179A+/tGm7QnZJSAF1Pv8MvN8p54QPMBy/73k7U49wTYB1d/1VhmJROr86olIQs0T1krTTj3BNgHxF5PPlCcED3A8XuY8gU2Aut9n4pMnwHOe4HoFxMiYJwQP8Dy+d09gE6A+qzOE9zzB9wqI4dkTggdojveiUZO6vXNP0NUEIyhF0YmVElmi3zyrSz0B3Xg/CuNd84Tre1qf4NYYdSsghl6jknX+NOPpwPKE4AHM9rWUv0nBuidw3xGKQDVqu2ChjvfJE0yfO3BXQGzxYtOOJ+oJwQN8T4AJcU/IGwHi9YS8ERBD2BMG3gPyToBxDpI3Cahg1xP6jYAYhp6QdwkYo98ISOMJpI3tj6ZcI0L3Dzq8v5QVEBAQEBCQIbwDjb7c841t6aMAAAAASUVORK5CYII="; + +/** The pointer sits on the head's top-left corner (texel 3,2); swings pivot on the grip (texel 14,13). */ +const PICKAXE_HOTSPOT = { x: 3.5 * PX, y: 2.5 * PX }; +const PICKAXE_GRIP = { x: 14.5 * PX, y: 13.5 * PX }; +/** Head lifts back clockwise, then strikes down counter-clockwise. */ +const WIND_UP = "rotate(24deg)"; +const STRIKE = "rotate(-28deg)"; + +const pickaxeStyle: React.CSSProperties = { + width: 16 * PX, + height: 16 * PX, + backgroundImage: `url("${PICKAXE_SPRITE}")`, + backgroundSize: "100% 100%", + imageRendering: "pixelated", + transformOrigin: `${PICKAXE_GRIP.x}px ${PICKAXE_GRIP.y}px`, +}; + +/* ---------- Component ---------- */ + +export interface MinecraftButtonProps + extends React.ButtonHTMLAttributes { + /** Label used when no children are provided. */ + label?: React.ReactNode; + /** Taps needed to advance one crack stage. */ + tapsPerStage?: number; + /** Stages until the button breaks. Breaks after `tapsPerStage * stages` taps. */ + stages?: number; + /** Play synthesized dig, crack, break, and pickup sounds. */ + sound?: boolean; + /** Swap the mouse cursor for a pickaxe that swings on each tap. */ + pickaxe?: boolean; + /** Auto-respawn this many ms after breaking. Omit to wait for the drop to be picked up. */ + respawnAfter?: number; + /** Fired when the crack deepens to a new stage (1-based). */ + onCrack?: (stage: number) => void; + /** Fired when the button finally breaks. */ + onBreak?: () => void; + /** Fired when the button comes back. */ + onRespawn?: () => void; +} + +export const MinecraftButton = React.forwardRef< + HTMLButtonElement, + MinecraftButtonProps +>( + ( + { + children, + label = "Singleplayer", + tapsPerStage = 5, + stages = 4, + sound = true, + pickaxe = true, + respawnAfter, + onCrack, + onBreak, + onRespawn, + onClick, + className, + style, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const buttonRef = React.useRef(null); + const layerRef = React.useRef(null); + const itemRef = React.useRef(null); + const itemBodyRef = React.useRef(null); + const [taps, setTaps] = React.useState(0); + const [broken, setBroken] = React.useState(false); + const [grid, setGrid] = React.useState({ cols: 0, rows: 0 }); + const [seed, setSeed] = React.useState(7); + const [aiming, setAiming] = React.useState(false); + const pointerRef = React.useRef({ x: 0, y: 0 }); + const cursorRef = React.useRef(null); + const swingRef = React.useRef(null); + const swingAnim = React.useRef(undefined); + + const placeCursor = React.useCallback((node: HTMLDivElement | null) => { + cursorRef.current = node; + if (!node) return; + const { x, y } = pointerRef.current; + node.style.transform = `translate(${x - PICKAXE_HOTSPOT.x}px, ${y - PICKAXE_HOTSPOT.y}px)`; + }, []); + + const swing = (keyframes: Keyframe[], duration: number) => { + const node = swingRef.current; + if (!node || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + swingAnim.current?.cancel(); + swingAnim.current = node.animate(keyframes, { + duration, + easing: "cubic-bezier(0.3, 0, 0.2, 1)", + fill: "forwards", + }); + }; + + const trackPointer = (event: React.PointerEvent) => { + const overButton = + pickaxe && + !broken && + !disabled && + event.pointerType !== "touch" && + !!buttonRef.current?.contains(event.target as Node); + pointerRef.current = { x: event.clientX, y: event.clientY }; + if (cursorRef.current) placeCursor(cursorRef.current); + if (overButton !== aiming) setAiming(overButton); + }; + + const perStage = Math.max(1, Math.floor(tapsPerStage)); + const stageCount = Math.max(1, Math.floor(stages)); + const breakAt = perStage * stageCount; + // The last stage is the break itself, so the visible crack levels stop one short. + const crackLevels = Math.max(1, stageCount - 1); + const level = Math.min(Math.floor(taps / perStage), crackLevels); + + const setButtonRef = (node: HTMLButtonElement | null) => { + buttonRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }; + + React.useEffect(() => { + const button = buttonRef.current; + if (!button) return; + const observer = new ResizeObserver(() => { + setGrid({ + cols: Math.ceil(button.offsetWidth / PX), + rows: Math.ceil(button.offsetHeight / PX), + }); + }); + observer.observe(button); + return () => observer.disconnect(); + }, []); + + const order = React.useMemo( + () => crackOrder(grid.cols, grid.rows, seed), + [grid.cols, grid.rows, seed], + ); + const visible = order.slice( + 0, + Math.round((order.length * level) / crackLevels), + ); + + const reduceMotion = () => + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const respawn = React.useCallback(() => { + setBroken(false); + setTaps(0); + setSeed((s) => s + 1); + onRespawn?.(); + requestAnimationFrame(() => { + const button = buttonRef.current; + if (!button) return; + button.focus({ preventScroll: true }); + if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + button.animate( + [{ transform: "scale(0.9)" }, { transform: "scale(1)" }], + { duration: 180, easing: "steps(3, end)" }, + ); + } + }); + }, [onRespawn]); + + const pickUp = () => { + if (sound) sounds.pop(); + const item = itemRef.current; + if (item && !reduceMotion()) { + item + .animate( + [ + { transform: "translate(-50%, -50%) scale(1)", opacity: 1 }, + { transform: "translate(-50%, -110%) scale(0.4)", opacity: 0 }, + ], + { duration: 140, easing: "ease-in", fill: "forwards" }, + ) + .finished.then(respawn, respawn); + } else { + respawn(); + } + }; + + // Dropped item: pops out, then bobs and spins like an item entity. + React.useEffect(() => { + if (!broken) return; + itemRef.current?.focus({ preventScroll: true }); + const body = itemBodyRef.current; + let loop: Animation | undefined; + let drop: Animation | undefined; + if (body && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + drop = body.animate( + [ + { transform: "translateY(0) scale(0.5)" }, + { transform: "translateY(-18px) scale(0.9)", offset: 0.4 }, + { transform: "translateY(0) scale(1)" }, + ], + { duration: 360, easing: "ease-out" }, + ); + drop.finished.then( + () => { + loop = body.animate( + [ + { transform: "translateY(0) rotateY(0deg)" }, + { transform: "translateY(-5px) rotateY(180deg)" }, + { transform: "translateY(0) rotateY(360deg)" }, + ], + { duration: 2600, iterations: Infinity, easing: "linear" }, + ); + }, + () => {}, + ); + } + const timer = + respawnAfter !== undefined + ? window.setTimeout(respawn, Math.max(0, respawnAfter)) + : undefined; + return () => { + drop?.cancel(); + loop?.cancel(); + window.clearTimeout(timer); + }; + }, [broken, respawnAfter, respawn]); + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (disabled || broken || event.defaultPrevented) return; + + // Strike from wherever the pointer-down wind-up left the pickaxe. + if (aiming) { + swing( + [ + { transform: WIND_UP }, + { transform: STRIKE, offset: 0.35 }, + { transform: "rotate(0deg)" }, + ], + 240, + ); + } + + const button = buttonRef.current; + const layer = layerRef.current; + const next = taps + 1; + const nextLevel = Math.min(Math.floor(next / perStage), crackLevels); + const breaking = next >= breakAt; + const deepened = !breaking && nextLevel > level; + const motion = !reduceMotion(); + + if (button && layer && motion) { + const rect = button.getBoundingClientRect(); + // Keyboard clicks report 0,0; chip from the center instead. + const fromPointer = event.detail > 0; + const origin = { + x: fromPointer ? event.clientX - rect.left : rect.width / 2, + y: fromPointer ? event.clientY - rect.top : rect.height / 2, + }; + if (breaking) { + spawnChips( + layer, + { x: rect.width / 2, y: rect.height / 2 }, + { w: rect.width, h: rect.height }, + Math.min(90, Math.round(rect.width / 3)), + 260, + ); + } else { + spawnChips( + layer, + origin, + { w: 6, h: 6 }, + deepened ? 10 : 4, + deepened ? 200 : 140, + ); + const kick = deepened ? 3 : 1.5; + button.animate( + [ + { transform: "translate(0, 0)" }, + { transform: `translate(${-kick}px, ${kick / 2}px)` }, + { transform: `translate(${kick}px, 0)` }, + { transform: "translate(0, 0)" }, + ], + { duration: deepened ? 180 : 110, easing: "steps(4, end)" }, + ); + } + } + + if (sound) { + if (breaking) sounds.break(); + else if (deepened) sounds.crack(); + else sounds.dig(); + } + + setTaps(next); + if (deepened) onCrack?.(nextLevel); + if (breaking) { + setBroken(true); + onBreak?.(); + } + }; + + const content = children ?? label; + + return ( + setAiming(false)} + onPointerDown={(event) => { + trackPointer(event); + if (aiming) swing([{ transform: WIND_UP }], 90); + }} + > + + + + + {/* aiming only turns on from a pointer event, so this never renders on the server. */} + {aiming + ? createPortal( +
+
+
, + document.body, + ) + : null} + + {broken ? ( + + ) : null} + + ); + }, +); + +MinecraftButton.displayName = "MinecraftButton"; + +export default MinecraftButton; diff --git a/components/evil-buttons/minimal.tsx b/components/evil-buttons/minimal.tsx index 9807868..b99dba8 100644 --- a/components/evil-buttons/minimal.tsx +++ b/components/evil-buttons/minimal.tsx @@ -1,24 +1,50 @@ -import { Button } from "@/components/ui/button"; +import * as React from "react"; import { cn } from "@/lib/utils"; -function MinimalButton({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - - ); -} + {children} + {arrow ? ( + + + + ) : null} + + ), +); + +MinimalButton.displayName = "MinimalButton"; +export { MinimalButton }; export default MinimalButton; diff --git a/components/evil-buttons/morph-status-button.tsx b/components/evil-buttons/morph-status-button.tsx index 996e3fa..d43d475 100644 --- a/components/evil-buttons/morph-status-button.tsx +++ b/components/evil-buttons/morph-status-button.tsx @@ -2,13 +2,12 @@ import * as React from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type MorphStatus = "idle" | "loading" | "success" | "error"; export interface MorphStatusButtonProps - extends Omit, "onClick"> { + extends Omit, "onClick"> { /** Idle label. Falls back to `label`. */ children?: React.ReactNode; /** Idle label used when no children are provided. */ @@ -109,8 +108,6 @@ export const MorphStatusButton = React.forwardRef< onClick, status: controlledStatus, resetAfter = 1800, - variant = "outline", - size = "lg", className, disabled, ...props @@ -194,18 +191,27 @@ export const MorphStatusButton = React.forwardRef< ); return ( - + ); }, ); diff --git a/components/evil-buttons/movie-pass.tsx b/components/evil-buttons/movie-pass.tsx index 976e603..f723861 100644 --- a/components/evil-buttons/movie-pass.tsx +++ b/components/evil-buttons/movie-pass.tsx @@ -1,14 +1,47 @@ "use client"; -import { CSSProperties, ReactNode } from "react"; +import * as React from "react"; +import { motion, useReducedMotion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; +export type MoviePassVariant = "tilt" | "snap"; + +export interface MoviePassButtonProps + extends Omit< + React.ButtonHTMLAttributes, + | "children" + | "onAnimationStart" + | "onDrag" + | "onDragEnd" + | "onDragStart" + > { + /** Label printed on the main part of the ticket. */ + children?: React.ReactNode; + /** Content of the tear-off stub. */ + stub?: React.ReactNode; + /** + * How the ticket tears on click. + * - `tilt`: both halves hinge outward from the bottom of the perforation. + * - `snap`: the stub splits off in a straight line, leaving a small gap. + * @default "tilt" + */ + variant?: MoviePassVariant; + /** + * Milliseconds before the ticket mends itself. Pass `null` to stay torn + * until the next click. + * @default 1800 + */ + resetDelay?: number | null; + /** Fired each time the ticket tears. */ + onTear?: () => void; +} + /** - * Notch radius for the four corner cutouts. The notches are punched out with a - * CSS mask instead of background-colored overlays, so the ticket shape works on - * any surface (no need to match the page background). + * Notch radius for the corner cutouts. Each half is notched on all four + * corners, so the two halves together form half-circle punches at the + * perforation. Masks keep the shape correct on any page background. */ -const NOTCH = 8; +const NOTCH = 6; const cornerNotchMask = [ `radial-gradient(circle ${NOTCH}px at 0 0, transparent ${NOTCH}px, black ${NOTCH + 0.5}px)`, @@ -17,7 +50,7 @@ const cornerNotchMask = [ `radial-gradient(circle ${NOTCH}px at 100% 100%, transparent ${NOTCH}px, black ${NOTCH + 0.5}px)`, ].join(", "); -const notchStyle: CSSProperties = { +const notchStyle: React.CSSProperties = { WebkitMaskImage: cornerNotchMask, maskImage: cornerNotchMask, WebkitMaskRepeat: "no-repeat", @@ -27,27 +60,146 @@ const notchStyle: CSSProperties = { maskComposite: "intersect", }; -function MoviePassButton({ - children, - className, - style, -}: { - children: ReactNode; - className?: string; - style?: CSSProperties; -}) { +const TEAR = { + snap: { + main: { x: -3, rotate: 0 }, + stub: { x: 3, rotate: 0 }, + }, + // Rotating around the shared bottom corners keeps the halves touching at + // the bottom while a V opens along the perforation. + tilt: { + main: { x: 0, rotate: -6 }, + stub: { x: 0, rotate: 10 }, + }, +} as const; + +const REST = { x: 0, rotate: 0 }; + +const TRANSITIONS: Record = { + snap: { type: "spring", stiffness: 700, damping: 22, mass: 0.6 }, + tilt: { type: "spring", stiffness: 320, damping: 13, mass: 0.8 }, +}; + +const MEND: Transition = { type: "spring", stiffness: 420, damping: 30 }; + +function DefaultStub() { return ( - + + NO. + 01 + ); } + +export const MoviePassButton = React.forwardRef< + HTMLButtonElement, + MoviePassButtonProps +>( + ( + { + children, + stub = , + variant = "tilt", + resetDelay = 1800, + onTear, + onClick, + className, + style, + type = "button", + ...props + }, + ref, + ) => { + const reduceMotion = useReducedMotion(); + const [torn, setTorn] = React.useState(false); + const timeoutRef = React.useRef(null); + + const clearTimer = React.useCallback(() => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + React.useEffect(() => clearTimer, [clearTimer]); + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + + clearTimer(); + + if (torn) { + setTorn(false); + return; + } + + setTorn(true); + onTear?.(); + + if (resetDelay !== null) { + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + setTorn(false); + }, resetDelay); + } + }; + + const pose = torn ? TEAR[variant] : { main: REST, stub: REST }; + const transition: Transition = reduceMotion + ? { duration: 0 } + : torn + ? TRANSITIONS[variant] + : MEND; + + return ( + + ); + }, +); + +MoviePassButton.displayName = "MoviePassButton"; + export default MoviePassButton; diff --git a/components/evil-buttons/pill-button.tsx b/components/evil-buttons/pill-button.tsx deleted file mode 100644 index ffeb11d..0000000 --- a/components/evil-buttons/pill-button.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client" - -import { useState } from "react" -import { motion } from "motion/react" - -import { cn } from "@/lib/utils" - -type RollingLabelProps = { - label: string -} - -function RollingLabel({ label }: RollingLabelProps) { - return ( - - - {label} - - {label} - - - - ) -} - -type PillFaceProps = { - label: string - className?: string -} - -function PillFace({ label, className }: PillFaceProps) { - return ( - - - - ) -} - -type PillButtonProps = { - primaryLabel: string - secondaryLabel: string - primaryClassName?: string - secondaryClassName?: string - isOpen?: boolean - defaultOpen?: boolean - onOpenChange?: (open: boolean) => void - className?: string - ariaLabel?: string | ((isOpen: boolean) => string) -} - -function PillButton({ - primaryLabel, - secondaryLabel, - primaryClassName, - secondaryClassName, - isOpen: isOpenProp, - defaultOpen = false, - onOpenChange, - className, - ariaLabel, -}: PillButtonProps) { - const [internalOpen, setInternalOpen] = useState(defaultOpen) - const isControlled = isOpenProp !== undefined - const isOpen = isControlled ? isOpenProp : internalOpen - - const setOpen = (next: boolean) => { - if (!isControlled) { - setInternalOpen(next) - } - onOpenChange?.(next) - } - - const toggle = () => setOpen(!isOpen) - - const resolvedAriaLabel = - typeof ariaLabel === "function" - ? ariaLabel(isOpen) - : ariaLabel ?? (isOpen ? secondaryLabel : primaryLabel) - - return ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - toggle() - } - }} - className={cn( - "relative h-10 w-20 cursor-pointer overflow-hidden rounded-full", - className - )} - aria-expanded={isOpen} - aria-label={resolvedAriaLabel} - > - - - - -
- ) -} - -export { PillButton, PillFace, RollingLabel } -export type { PillButtonProps, PillFaceProps, RollingLabelProps } \ No newline at end of file diff --git a/components/evil-buttons/realistic-switch.tsx b/components/evil-buttons/realistic-switch.tsx new file mode 100644 index 0000000..b0e00b5 --- /dev/null +++ b/components/evil-buttons/realistic-switch.tsx @@ -0,0 +1,298 @@ +"use client" + +import { useEffect, useState, type CSSProperties } from "react" +import { + motion, + useReducedMotion, + useSpring, + useTransform, +} from "motion/react" + +import { cn } from "@/lib/utils" + +type RealisticSwitchSize = "sm" | "default" | "lg" +type RealisticSwitchTone = "auto" | "light" | "dark" + +type RealisticSwitchProps = { + checked?: boolean + defaultChecked?: boolean + onCheckedChange?: (checked: boolean) => void + /** Cap color. Any CSS color; its walls, shading, and glow are mixed from it. */ + color?: string + /** Color of the power mark on the cap. */ + markColor?: string + /** Socket finish: follow the theme, or force ceramic white or graphite. */ + tone?: RealisticSwitchTone + /** Light the cap from inside while it is latched on. */ + illuminated?: boolean + /** Play a short mechanical click when it latches or releases. */ + sound?: boolean + size?: RealisticSwitchSize + disabled?: boolean + className?: string + "aria-label"?: string +} + +// Everything is sized in em, so one font size scales the whole switch. +const FONT_SIZE: Record = { + sm: 11, + default: 15, + lg: 19, +} + +// Geometry, in em. The cap is a box standing on its own walls inside a +// recessed socket; its height above the socket floor is the one thing that +// moves. Cap and walls slide together on the same transform, and the walls are +// clipped at the floor, so nothing is resized and nothing snaps to a pixel. +const SOCKET = 4.4 +const CAP = 3.3 +const GAP = (SOCKET - CAP) / 2 + +// Cap height in each state: up, dipped under hover, fully pressed, and +// latched down while on. +const HEIGHT = { + up: 0.95, + hover: 0.82, + pressed: 0.1, + latched: 0.34, + latchedHover: 0.3, +} + +// A touch of bounce, so a released cap settles like a spring without wobbling +// through the small hover dip. +const SPRING = { stiffness: 520, damping: 30, mass: 0.6 } + +const TONES: Record = { + light: + "[--hi:#ffffff] [--mid:#efeff2] [--lo:#d9d9df] [--well:#c9c9d1] [--well-lo:#aeaeb8] [--floor:rgb(20_20_40/0.35)] [--spill:0.35]", + dark: "[--hi:#3a3a40] [--mid:#26262b] [--lo:#18181b] [--well:#0d0d10] [--well-lo:#050506] [--floor:rgb(0_0_0/0.8)] [--spill:0.6]", + auto: "[--hi:#ffffff] [--mid:#efeff2] [--lo:#d9d9df] [--well:#c9c9d1] [--well-lo:#aeaeb8] [--floor:rgb(20_20_40/0.35)] [--spill:0.35] dark:[--hi:#3a3a40] dark:[--mid:#26262b] dark:[--lo:#18181b] dark:[--well:#0d0d10] dark:[--well-lo:#050506] dark:[--floor:rgb(0_0_0/0.8)] dark:[--spill:0.6]", +} + +const mix = (amount: number, toward: "white" | "black") => + `color-mix(in oklab, var(--cap) ${amount}%, ${toward})` + +const em = (value: number) => `${value}em` + +let audio: AudioContext | null = null + +// A switch click is a very short, bright burst of noise. Latching is pitched a +// little higher than releasing, like a real push switch's two stops. +function playClick(on: boolean) { + try { + audio ??= new AudioContext() + const length = Math.floor(audio.sampleRate * 0.03) + const buffer = audio.createBuffer(1, length, audio.sampleRate) + const data = buffer.getChannelData(0) + for (let i = 0; i < length; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 6 + } + const source = audio.createBufferSource() + source.buffer = buffer + const filter = audio.createBiquadFilter() + filter.type = "bandpass" + filter.frequency.value = on ? 3400 : 2500 + filter.Q.value = 1.1 + const gain = audio.createGain() + gain.gain.value = 0.7 + source.connect(filter).connect(gain).connect(audio.destination) + source.start() + } catch { + // No audio available: the switch still works silently. + } +} + +function RealisticSwitch({ + checked: checkedProp, + defaultChecked = false, + onCheckedChange, + color = "#e1261c", + markColor = "rgb(255 255 255 / 0.92)", + tone = "auto", + illuminated = true, + sound = false, + size = "default", + disabled = false, + className, + "aria-label": ariaLabel = "Power", +}: RealisticSwitchProps) { + const [internalChecked, setInternalChecked] = useState(defaultChecked) + const isControlled = checkedProp !== undefined + const checked = isControlled ? checkedProp : internalChecked + const [pressing, setPressing] = useState(false) + const [hovering, setHovering] = useState(false) + const reduceMotion = useReducedMotion() + + const target = pressing + ? HEIGHT.pressed + : checked + ? hovering + ? HEIGHT.latchedHover + : HEIGHT.latched + : hovering + ? HEIGHT.hover + : HEIGHT.up + const height = useSpring(target, SPRING) + + useEffect(() => { + if (reduceMotion) height.jump(target) + else height.set(target) + }, [height, target, reduceMotion]) + + // The walls fill the space under the cap, and the cast shadow spreads and + // fades as the cap rises away from the socket floor. + const capLift = useTransform(height, (h) => em(-h)) + const shadowOpacity = useTransform(height, [0, HEIGHT.up], [0.35, 1]) + const shadowScale = useTransform(height, [0, HEIGHT.up], [0.92, 1.06]) + const shadowDrop = useTransform(height, (h) => em(0.05 + h * 0.35)) + + const toggle = () => { + const next = !checked + if (!isControlled) { + setInternalChecked(next) + } + onCheckedChange?.(next) + if (sound) { + playClick(next) + } + } + + const lit = illuminated && checked + + return ( + + ) +} + +export { RealisticSwitch } +export type { RealisticSwitchProps, RealisticSwitchSize, RealisticSwitchTone } diff --git a/components/evil-buttons/reveal-button.tsx b/components/evil-buttons/reveal-button.tsx index 25c1e3e..c8bb2e9 100644 --- a/components/evil-buttons/reveal-button.tsx +++ b/components/evil-buttons/reveal-button.tsx @@ -4,12 +4,51 @@ import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; +export type RevealButtonVariant = "dark" | "light"; + +// Graded surfaces matching MinimalButton: outer hairline, faint inner ring, top highlight, soft drop. +// Revealed swaps the neutral inner ring for an emerald one. +const surfaces: Record< + RevealButtonVariant, + { + base: string; + revealed: string; + label: string; + value: string; + chip: string; + chipRevealed: string; + } +> = { + dark: { + base: "text-neutral-50 bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)]", + revealed: + "shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(52_211_153/0.35),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(16_185_129/0.35)]", + label: "text-neutral-400", + value: "text-neutral-50", + chip: "bg-black/25 text-neutral-400 shadow-[inset_0_1px_2px_rgb(0_0_0/0.45),0_0_0_1px_rgb(255_255_255/0.07)]", + chipRevealed: + "text-emerald-400 shadow-[inset_0_1px_2px_rgb(0_0_0/0.45),0_0_0_1px_rgb(52_211_153/0.3)]", + }, + light: { + base: "text-neutral-900 bg-linear-to-b from-white to-neutral-100 shadow-[0_0_0_1px_rgb(0_0_0/0.1),inset_0_1px_0_rgb(255_255_255/0.9),0_1px_2px_rgb(0_0_0/0.06),0_4px_12px_-4px_rgb(0_0_0/0.14)]", + revealed: + "shadow-[0_0_0_1px_rgb(16_185_129/0.45),inset_0_1px_0_rgb(255_255_255/0.9),0_1px_2px_rgb(0_0_0/0.06),0_4px_12px_-4px_rgb(16_185_129/0.3)]", + label: "text-neutral-500", + value: "text-neutral-900", + chip: "bg-neutral-200/60 text-neutral-500 shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_1px_rgb(0_0_0/0.06)]", + chipRevealed: + "text-emerald-600 shadow-[inset_0_1px_2px_rgb(0_0_0/0.12),0_0_0_1px_rgb(16_185_129/0.35)]", + }, +}; + export interface RevealButtonProps extends Omit, "children"> { secret?: React.ReactNode; label?: React.ReactNode; hiddenLabel?: React.ReactNode; revealMode?: "hold" | "toggle"; + /** Dark (default) or light graded surface. */ + variant?: RevealButtonVariant; maskedValue?: string; onRevealChange?: (revealed: boolean) => void; } @@ -24,6 +63,7 @@ export const RevealButton = React.forwardRef< label = "Reveal", hiddenLabel = "Hidden", revealMode = "hold", + variant = "dark", maskedValue = "•••• •••• ••••", onRevealChange, className, @@ -33,6 +73,7 @@ export const RevealButton = React.forwardRef< ref, ) => { const [revealed, setRevealed] = React.useState(false); + const surface = surfaces[variant]; const updateRevealed = (next: boolean) => { setRevealed(next); @@ -71,16 +112,24 @@ export const RevealButton = React.forwardRef< aria-live="polite" data-state={revealed ? "revealed" : "hidden"} className={cn( - "group relative inline-flex min-w-40 items-center justify-between gap-3 overflow-hidden rounded-md border border-border bg-background px-3 py-2 text-xs shadow-sm transition-colors sm:min-w-64 sm:gap-4 sm:px-4 sm:py-2.5 sm:text-sm", - "hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60", - revealed && "border-emerald-500/50 bg-emerald-500/10", + "group relative inline-flex min-w-40 cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-xl px-3 py-2 text-xs outline-none select-none sm:min-w-64 sm:gap-4 sm:px-4 sm:py-2.5 sm:text-sm", + surface.base, + "transition-[scale,filter,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.35,0.64,1)] hover:brightness-110", + "active:scale-[0.98] active:brightness-95 active:duration-100 active:ease-out motion-reduce:active:scale-100", + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", + revealed && surface.revealed, className, )} {...pressHandlers} {...props} > - + {revealed ? label : hiddenLabel} @@ -90,7 +139,10 @@ export const RevealButton = React.forwardRef< animate={{ y: 0, opacity: 1, filter: "blur(0px)" }} exit={{ y: -6, opacity: 0, filter: "blur(4px)" }} transition={{ duration: 0.16 }} - className="block max-w-28 truncate font-mono text-xs font-semibold text-foreground sm:max-w-44 sm:text-sm" + className={cn( + "block max-w-28 truncate font-mono text-xs font-semibold sm:max-w-44 sm:text-sm", + surface.value, + )} > {revealed ? secret : maskedValue} @@ -99,9 +151,10 @@ export const RevealButton = React.forwardRef< {revealed ? ( diff --git a/components/evil-buttons/shiny-button.tsx b/components/evil-buttons/shiny-button.tsx index 5747a0d..2524432 100644 --- a/components/evil-buttons/shiny-button.tsx +++ b/components/evil-buttons/shiny-button.tsx @@ -1,15 +1,87 @@ -import { ReactNode } from "react"; - -function ShinyButton({ children }: { children: ReactNode }) { - return ( - - ); +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface ShinyButtonProps extends React.ButtonHTMLAttributes { + /** Milliseconds the glint takes to sweep across on click. */ + shineDuration?: number; } -export default ShinyButton; +const ShinyButton = React.forwardRef( + ( + { + children, + className, + shineDuration = 650, + onClick, + type = "button", + ...props + }, + ref, + ) => { + const shineRef = React.useRef(null); + const faceRef = React.useRef(null); + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + // Web Animations restart cleanly on every click, even mid-sweep. + shineRef.current?.animate( + [ + { transform: "translateX(-160%) skewX(-20deg)", opacity: 0 }, + { opacity: 1, offset: 0.15 }, + { opacity: 1, offset: 0.7 }, + { transform: "translateX(420%) skewX(-20deg)", opacity: 0 }, + ], + { duration: shineDuration, easing: "cubic-bezier(0.4, 0, 0.2, 1)" }, + ); + faceRef.current?.animate( + [ + { filter: "brightness(1)" }, + { filter: "brightness(1.25)", offset: 0.25 }, + { filter: "brightness(1)" }, + ], + { duration: shineDuration * 0.8, easing: "ease-out" }, + ); + }; + return ( + + ); + }, +); + +ShinyButton.displayName = "ShinyButton"; + +export { ShinyButton }; +export default ShinyButton; diff --git a/components/evil-buttons/slide-to-detonate.tsx b/components/evil-buttons/slide-to-detonate.tsx index 5df7da1..aa07f93 100644 --- a/components/evil-buttons/slide-to-detonate.tsx +++ b/components/evil-buttons/slide-to-detonate.tsx @@ -7,49 +7,139 @@ import { useMotionValue, useReducedMotion, useTransform, + type AnimationPlaybackControls, + type Transition, } from "motion/react"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; type SlideState = "idle" | "sliding" | "success"; -const MotionButton = motion.create(Button); - export interface SlideToDetonateProps extends Omit< - React.ComponentProps, - "onClick" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "style" + React.ButtonHTMLAttributes, + | "onClick" + | "onDrag" + | "onDragStart" + | "onDragEnd" + | "onAnimationStart" + | "style" > { /** Track label shown while idle. Falls back to `label`. */ children?: React.ReactNode; /** Idle label used when no children are provided. */ label?: React.ReactNode; - /** Label flashed once the slide reaches the end and the action fires. */ - successLabel?: React.ReactNode; - /** Fired once the handle is dragged past the threshold. */ + /** Fired once the handle is released past the threshold. */ onConfirm?: () => void; /** * Fraction of the track (0-1) the handle must cross to arm the action. - * @default 0.95 + * @default 0.9 */ threshold?: number; /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */ resetAfter?: number; + /** + * How hard the fuse pushes back (0-1). At 0 the handle tracks the pointer + * one to one; higher values make it lag further behind the pointer the + * closer it gets to the end, though it still reaches the end. + * @default 0.35 + */ + resistance?: number; + /** + * How softly the handle follows the pointer (0-1). At 0 it is locked to the + * pointer; higher values let it glide behind on a spring. + * @default 0.4 + */ + smoothness?: number; + /** + * Surface style: a recessed dark groove or a light one. + * @default "dark" + */ + variant?: SlideToDetonateVariant; } +export type SlideToDetonateVariant = "dark" | "light"; + +const VARIANTS: Record< + SlideToDetonateVariant, + { track: string; trail: string; handle: string; shimmer: string } +> = { + dark: { + // Recessed track: dark hairline, faint inner ring, soft inner shadow from the top. + track: + "bg-[#1c1c1c] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_2px_6px_rgb(0_0_0/0.5),0_1px_0_rgb(255_255_255/0.04)]", + trail: + "from-white/0 to-white/12 group-data-armed/slide:to-white/20 group-data-[state=success]/slide:to-white/20", + handle: cn( + "text-neutral-50/70", + // Raised handle: graded dark surface, hairline ring, top highlight, soft drop. + "bg-linear-to-b from-[#3a3a3a] to-[#2a2a2a] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.16),0_1px_2px_rgb(0_0_0/0.3),0_4px_10px_-2px_rgb(0_0_0/0.5)]", + // Armed: the handle lights up with a soft white glow. + "data-armed:text-neutral-50 data-armed:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.18),inset_0_1px_0_rgb(255_255_255/0.2),0_1px_2px_rgb(0_0_0/0.3),0_0_16px_-2px_rgb(255_255_255/0.35)]", + "data-[state=success]:text-neutral-50", + "focus-visible:ring-white/40 focus-visible:ring-offset-[#1c1c1c]", + ), + shimmer: "250 250 250", + }, + light: { + // Recessed track: light hairline, soft inner shadow from the top, white lip below. + track: + "bg-neutral-100 shadow-[0_0_0_1px_rgb(0_0_0/0.08),inset_0_1px_3px_rgb(0_0_0/0.08),inset_0_0_0_1px_rgb(0_0_0/0.02),0_1px_0_rgb(255_255_255/0.9)]", + trail: + "from-black/0 to-black/8 group-data-armed/slide:to-black/14 group-data-[state=success]/slide:to-black/14", + handle: cn( + "text-neutral-500", + // Raised handle: white face, hairline ring, top highlight, soft drop. + "bg-linear-to-b from-white to-neutral-50 shadow-[0_0_0_1px_rgb(0_0_0/0.1),inset_0_1px_0_rgb(255_255_255),0_1px_2px_rgb(0_0_0/0.1),0_4px_10px_-2px_rgb(0_0_0/0.12)]", + // Armed: the ring darkens and a soft shadow halo gathers around it. + "data-armed:text-neutral-900 data-armed:shadow-[0_0_0_1px_rgb(0_0_0/0.22),inset_0_1px_0_rgb(255_255_255),0_1px_2px_rgb(0_0_0/0.12),0_0_14px_-2px_rgb(0_0_0/0.22)]", + "data-[state=success]:text-neutral-900", + "focus-visible:ring-black/25 focus-visible:ring-offset-neutral-100", + ), + shimmer: "23 23 23", + }, +}; + const HANDLE = 40; const TRACK_PADDING = 4; +/** Keyboard step as a fraction of the track. */ +const KEY_STEP = 0.1; +/** Farthest in px the handle stretches when pulled back past the start. */ +const PULL_BACK = 10; + +const SNAP_BACK: Transition = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 }; +const SNAP_END: Transition = { type: "spring", stiffness: 700, damping: 40 }; + +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); + +/** Pointer travel (0-1) to handle travel (0-1); resistance bends the curve so the end is heavier. */ +function resist(t: number, resistance: number) { + return Math.pow(t, 1 + resistance * 1.5); +} + +function unresist(t: number, resistance: number) { + return Math.pow(t, 1 / (1 + resistance * 1.5)); +} + +/** Spring that follows the pointer: stiff and critically damped at 0, loose and floaty at 1. */ +function followSpring(smoothness: number): Transition { + const s = clamp01(smoothness); + return { + type: "spring", + stiffness: 1400 - s * 1220, + damping: 70 - s * 46, + mass: 0.4 + s * 0.4, + }; +} const ChevronsIcon = () => ( @@ -57,16 +147,15 @@ const ChevronsIcon = () => ( ); -const CheckIcon = () => ( +const CheckIcon = ({ className }: { className?: string }) => ( @@ -81,13 +170,17 @@ export const SlideToDetonate = React.forwardRef< { children, label = "Slide to detonate", - successLabel = "Detonated", onConfirm, - threshold = 0.95, + threshold = 0.9, resetAfter = 1600, - variant = "default", + resistance = 0.35, + smoothness = 0.4, + variant = "dark", className, disabled, + onPointerDown, + onKeyDown, + onBlur, ...props }, ref, @@ -96,120 +189,251 @@ export const SlideToDetonate = React.forwardRef< const trackRef = React.useRef(null); const resetTimeoutRef = React.useRef(null); + const controlsRef = React.useRef(null); + // Pointer x at which the handle would sit at the start of the track. + const originRef = React.useRef(0); + const armedRef = React.useRef(false); + const draggingRef = React.useRef(false); const [state, setState] = React.useState("idle"); + const [armed, setArmed] = React.useState(false); const [range, setRange] = React.useState(0); + const [percent, setPercent] = React.useState(0); const x = useMotionValue(0); - // 0 (start) -> 1 (armed); drives the label fade. - const progress = useTransform(() => (range > 0 ? x.get() / range : 0)); - const labelOpacity = useTransform(progress, [0, 0.7], [1, 0]); + const progress = useTransform(() => (range > 0 ? clamp01(x.get() / range) : 0)); + const labelOpacity = useTransform(progress, [0, 0.6], [1, 0]); + const trailWidth = useTransform(() => Math.max(0, x.get()) + HANDLE); + const trailOpacity = useTransform(progress, [0, 0.15, 1], [0, 0.55, 1]); + + const safeThreshold = clamp01(threshold); + const isSuccess = state === "success"; + const locked = disabled || isSuccess; + const labelText = children ?? label; + const styles = VARIANTS[variant]; const measure = React.useCallback(() => { const node = trackRef.current; if (!node) return; - setRange(node.clientWidth - HANDLE - TRACK_PADDING * 2); + setRange(Math.max(0, node.clientWidth - HANDLE - TRACK_PADDING * 2)); }, []); React.useEffect(() => { measure(); const node = trackRef.current; if (!node || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => measure()); + const observer = new ResizeObserver(measure); observer.observe(node); return () => observer.disconnect(); }, [measure]); - React.useEffect(() => { - return () => { + React.useEffect( + () => () => { + controlsRef.current?.stop(); if (resetTimeoutRef.current !== null) { window.clearTimeout(resetTimeoutRef.current); } - }; - }, []); + }, + [], + ); - const settle = (to: number) => { - if (reduceMotion) { + // Mirror progress into a coarse percentage for aria-valuenow without re-rendering every frame. + React.useEffect( + () => + progress.on("change", (value) => { + setPercent((current) => { + const next = Math.round(value * 100); + return current === next ? current : next; + }); + }), + [progress], + ); + + const moveTo = (to: number, transition: Transition | null) => { + controlsRef.current?.stop(); + if (!transition || reduceMotion) { x.set(to); return; } - animate(x, to, { type: "spring", stiffness: 500, damping: 38 }); + controlsRef.current = animate(x, to, transition); + }; + + const setArmedState = (next: boolean) => { + if (armedRef.current === next) return; + armedRef.current = next; + setArmed(next); + // A tiny tick on phones when the fuse arms. + if (next) navigator.vibrate?.(8); }; const fire = () => { + setArmedState(false); setState("success"); - x.set(range); + moveTo(range, SNAP_END); onConfirm?.(); if (resetAfter > 0) { resetTimeoutRef.current = window.setTimeout(() => { + resetTimeoutRef.current = null; setState("idle"); - settle(0); + moveTo(0, SNAP_BACK); }, resetAfter); } }; - const handleDragEnd = () => { - if (state === "success") return; - if (range > 0 && x.get() >= range * threshold) { + const release = () => { + if (armedRef.current) { fire(); } else { setState("idle"); - settle(0); + moveTo(0, SNAP_BACK); } }; - const isSuccess = state === "success"; + const handlePointerDown = (event: React.PointerEvent) => { + onPointerDown?.(event); + if (locked || range <= 0 || event.defaultPrevented) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + + event.currentTarget.setPointerCapture(event.pointerId); + // Start from wherever the handle currently is, mapped back into pointer space. + const current = clamp01(x.get() / range); + originRef.current = event.clientX - unresist(current, resistance) * range; + draggingRef.current = true; + setState("sliding"); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (!draggingRef.current || range <= 0) return; + + const raw = event.clientX - originRef.current; + let next: number; + if (raw < 0) { + // Rubber-band a little when pulled back past the start. + next = -PULL_BACK * (1 - 1 / (1 + -raw / 60)); + } else { + next = resist(clamp01(raw / range), resistance) * range; + } + + moveTo(next, smoothness > 0 ? followSpring(smoothness) : null); + setArmedState(next / range >= safeThreshold); + }; + + const handlePointerEnd = () => { + if (!draggingRef.current) return; + draggingRef.current = false; + release(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + onKeyDown?.(event); + if (locked || range <= 0 || event.defaultPrevented) return; + + const current = clamp01(x.get() / range); + let next: number | null = null; + if (event.key === "ArrowRight" || event.key === "ArrowUp") next = current + KEY_STEP; + else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = current - KEY_STEP; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = 1; + if (next === null) return; + + event.preventDefault(); + next = clamp01(next); + if (next >= safeThreshold) { + fire(); + return; + } + setState(next > 0 ? "sliding" : "idle"); + moveTo(next * range, followSpring(smoothness)); + }; + + const handleBlur = (event: React.FocusEvent) => { + onBlur?.(event); + // Keyboard progress doesn't linger once focus leaves. + if (!isSuccess && x.get() !== 0) { + setArmedState(false); + setState("idle"); + moveTo(0, SNAP_BACK); + } + }; return (
- {/* Idle / success label centered on the track. */} + {/* Trail that fills in behind the handle. */} - {isSuccess && } - {isSuccess ? successLabel : (children ?? label)} - + aria-hidden + className={cn( + "pointer-events-none absolute top-1 bottom-1 left-1 rounded-full bg-linear-to-r", + styles.trail, + )} + style={{ width: trailWidth, opacity: trailOpacity }} + /> + + {/* Label centered in the free part of the track, right of the handle. Once detonated only the handle's check remains. */} + + {isSuccess ? null : ( + + {labelText} + + )} + {/* Draggable handle. */} - setState("sliding")} - onDragEnd={handleDragEnd} + data-armed={armed || undefined} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerEnd} + onPointerCancel={handlePointerEnd} + onLostPointerCapture={handlePointerEnd} + onKeyDown={handleKeyDown} + onBlur={handleBlur} style={{ x }} className={cn( - // Override the Button base `transition-all` so dragging the handle - // is not animated frame-by-frame (which makes it feel laggy). - "absolute left-1 top-1 z-20 size-10 rounded-full shadow-md transition-colors", - !isSuccess && "cursor-grab active:cursor-grabbing", + "absolute top-1 left-1 z-10 inline-flex size-10 touch-none items-center justify-center rounded-full outline-none", + "transition-[color,filter,box-shadow] duration-200 hover:brightness-110", + "focus-visible:ring-2 focus-visible:ring-offset-2", + styles.handle, + locked ? "cursor-default" : "cursor-grab data-[state=sliding]:cursor-grabbing", disabled && "cursor-not-allowed", )} {...props} > {isSuccess ? : } - +
); }, diff --git a/components/evil-buttons/sticky.tsx b/components/evil-buttons/sticky.tsx index a098b68..6328133 100644 --- a/components/evil-buttons/sticky.tsx +++ b/components/evil-buttons/sticky.tsx @@ -1,55 +1,184 @@ "use client"; +import * as React from "react"; +import { + motion, + useMotionTemplate, + useMotionValue, + useReducedMotion, + useSpring, + useTransform, +} from "motion/react"; import { cn } from "@/lib/utils"; -import { motion } from "motion/react"; -import { ReactNode, useRef, useState } from "react"; - -function StickyButton({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - const ref = useRef(null); - const [position, setPosition] = useState({ x: 0, y: 0 }); - - const mouseMove = (e: React.MouseEvent) => { - if (!ref.current) return; - const { clientX, clientY } = e; - const { height, width, left, top } = ref.current.getBoundingClientRect(); - - const centerX = (clientX - (left + width / 2)) * 0.4; - const centerY = (clientY - (top + height / 2)) * 0.4; - setPosition({ x: centerX, y: centerY }); - }; - - const mouseLeave = () => { - setPosition({ x: 0, y: 0 }); - }; - - const { x, y } = position; - - return ( - - {children} - - ); + +export interface StickyButtonProps + extends Omit< + React.ButtonHTMLAttributes, + "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" + > { + /** Distance in px beyond the button's edge where the pull starts. */ + radius?: number; + /** How far the body follows the cursor, as a fraction of the cursor offset. */ + strength?: number; + /** How far the label drifts inside the body, from 0 (fixed) to 1 (edge of the padding). */ + parallax?: number; + /** Maximum tilt in degrees while the body is pulled. */ + tilt?: number; +} + +// Largest label drift at `parallax = 1`, kept inside the button's padding. +const MAX_LABEL_SHIFT = { x: 12, y: 5 }; + +const BODY_SPRING = { stiffness: 170, damping: 16, mass: 0.6 }; +// The label lags a little behind the body so the two layers visibly separate. +const LABEL_SPRING = { stiffness: 120, damping: 14, mass: 0.7 }; + +/** 1 inside the button, easing smoothly to 0 at the edge of the field. */ +function falloff(t: number) { + if (t <= 0) return 1; + if (t >= 1) return 0; + return 1 - t * t * (3 - 2 * t); } +const StickyButton = React.forwardRef( + ( + { + children, + className, + radius = 120, + strength = 0.45, + parallax = 0.35, + tilt = 8, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const wrapperRef = React.useRef(null); + const reduceMotion = useReducedMotion(); + + const pullX = useMotionValue(0); + const pullY = useMotionValue(0); + const labelPullX = useMotionValue(0); + const labelPullY = useMotionValue(0); + const glowX = useMotionValue(0); + const glowY = useMotionValue(0); + const engaged = useMotionValue(0); + + const x = useSpring(pullX, BODY_SPRING); + const y = useSpring(pullY, BODY_SPRING); + const labelX = useSpring(labelPullX, LABEL_SPRING); + const labelY = useSpring(labelPullY, LABEL_SPRING); + const glowOpacity = useSpring(engaged, { stiffness: 200, damping: 30 }); + + const reach = Math.max(radius, 1) * strength; + const rotateY = useTransform(x, [-reach, reach], [-tilt, tilt]); + const rotateX = useTransform(y, [-reach, reach], [tilt, -tilt]); + const glow = useMotionTemplate`radial-gradient(120px circle at ${glowX}px ${glowY}px, color-mix(in oklab, var(--foreground) 12%, transparent), transparent 70%)`; + + React.useEffect(() => { + if (reduceMotion || disabled) return; + + const release = () => { + pullX.set(0); + pullY.set(0); + labelPullX.set(0); + labelPullY.set(0); + engaged.set(0); + }; + + const onPointerMove = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + const wrapper = wrapperRef.current; + if (!wrapper) return; + + // The wrapper never moves, so it is a stable resting frame to measure from. + const rect = wrapper.getBoundingClientRect(); + const dx = event.clientX - (rect.left + rect.width / 2); + const dy = event.clientY - (rect.top + rect.height / 2); + const outsideX = Math.max(Math.abs(dx) - rect.width / 2, 0); + const outsideY = Math.max(Math.abs(dy) - rect.height / 2, 0); + const pull = falloff(Math.hypot(outsideX, outsideY) / radius); + + if (pull === 0) { + release(); + return; + } + + pullX.set(dx * strength * pull); + pullY.set(dy * strength * pull); + const nx = Math.max(-1, Math.min(1, dx / (rect.width / 2))); + const ny = Math.max(-1, Math.min(1, dy / (rect.height / 2))); + labelPullX.set(nx * MAX_LABEL_SHIFT.x * parallax * pull); + labelPullY.set(ny * MAX_LABEL_SHIFT.y * parallax * pull); + glowX.set(event.clientX - rect.left); + glowY.set(event.clientY - rect.top); + engaged.set(pull); + }; + + window.addEventListener("pointermove", onPointerMove, { passive: true }); + document.documentElement.addEventListener("pointerleave", release); + window.addEventListener("blur", release); + return () => { + window.removeEventListener("pointermove", onPointerMove); + document.documentElement.removeEventListener("pointerleave", release); + window.removeEventListener("blur", release); + release(); + }; + }, [ + radius, + strength, + parallax, + reduceMotion, + disabled, + pullX, + pullY, + labelPullX, + labelPullY, + glowX, + glowY, + engaged, + ]); + + return ( + + + {/* Soft spotlight that tracks the cursor across the face. */} + + + {children} + + + + ); + }, +); + +StickyButton.displayName = "StickyButton"; + +export { StickyButton }; export default StickyButton; diff --git a/components/evil-buttons/troll-button.tsx b/components/evil-buttons/troll-button.tsx index 090c0ad..98e9173 100644 --- a/components/evil-buttons/troll-button.tsx +++ b/components/evil-buttons/troll-button.tsx @@ -1,54 +1,264 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { motion, useMotionValue } from "motion/react"; -import { ReactNode, useEffect, useRef } from "react"; -const MotionButton = motion.create(Button); +import * as React from "react"; +import { + AnimatePresence, + animate, + motion, + useMotionValue, + useReducedMotion, + useSpring, + useTransform, + useVelocity, + type AnimationPlaybackControls, +} from "motion/react"; +import { cn } from "@/lib/utils"; -function TrollButton({ children }: { children: ReactNode }) { - const ref = useRef(null); +export interface TrollButtonProps + extends Omit< + React.ButtonHTMLAttributes, + "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" + > { + /** Label used when no children are provided. */ + label?: React.ReactNode; + /** Label shown once the button gives up and lets itself be clicked. */ + surrenderLabel?: React.ReactNode; + /** Milliseconds of chasing before the button gets tired and gives up. */ + giveUpAfter?: number; + /** How close in px the cursor can get to the button's edge before it dodges. */ + fleeRadius?: number; + /** Maximum distance in px the button strays from its resting spot. */ + range?: number; + /** Milliseconds after a successful click before the button starts fleeing again. Set to 0 to stay caught. */ + resetAfter?: number; +} + +type TrollState = "fleeing" | "tired"; + +const DODGE_SPRING = { stiffness: 520, damping: 30, mass: 0.6 }; + +const labelVariants = { + enter: { y: 8, opacity: 0 }, + center: { y: 0, opacity: 1 }, + exit: { y: -8, opacity: 0 }, +}; + +const TrollButton = React.forwardRef( + ( + { + children, + label = "Click Me", + surrenderLabel = "Fine, click me", + giveUpAfter = 3500, + fleeRadius = 56, + range = 140, + resetAfter = 1500, + onClick, + onPointerDown, + className, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const wrapperRef = React.useRef(null); + const offsetRef = React.useRef({ x: 0, y: 0 }); + const staminaRef = React.useRef(null); + const resetTimerRef = React.useRef(undefined); + const [state, setState] = React.useState("fleeing"); + const reduceMotion = useReducedMotion(); + const evasive = state === "fleeing" && !reduceMotion && !disabled; + + const targetX = useMotionValue(0); + const targetY = useMotionValue(0); + const x = useSpring(targetX, DODGE_SPRING); + const y = useSpring(targetY, DODGE_SPRING); + // Lean into each dodge based on how fast the button is moving sideways. + const velocityX = useVelocity(x); + const rotate = useTransform(velocityX, [-2400, 0, 2400], [-10, 0, 10]); + const stamina = useMotionValue(1); + const staminaOpacity = useTransform(stamina, (value) => (value < 1 ? 1 : 0)); + + const moveTo = React.useCallback( + (nextX: number, nextY: number) => { + offsetRef.current = { x: nextX, y: nextY }; + targetX.set(nextX); + targetY.set(nextY); + }, + [targetX, targetY], + ); + + const startTiring = React.useCallback(() => { + if (staminaRef.current) return; + staminaRef.current = animate(stamina, 0, { + duration: giveUpAfter / 1000, + ease: "linear", + onComplete: () => { + moveTo(0, 0); + setState("tired"); + }, + }); + }, [giveUpAfter, moveTo, stamina]); + + const dodge = React.useCallback( + (pointerX: number, pointerY: number, force = false) => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + // Measure from the resting slot, then add the current offset to find the button. + const rest = wrapper.getBoundingClientRect(); + const { x: offsetX, y: offsetY } = offsetRef.current; + const centerX = rest.left + rest.width / 2 + offsetX; + const centerY = rest.top + rest.height / 2 + offsetY; + const dx = centerX - pointerX; + const dy = centerY - pointerY; + const outsideX = Math.max(Math.abs(dx) - rest.width / 2, 0); + const outsideY = Math.max(Math.abs(dy) - rest.height / 2, 0); + const gap = Math.hypot(outsideX, outsideY); + + if (!force && gap >= fleeRadius) return; - const x = useMotionValue(0); - const y = useMotionValue(0); + const length = Math.hypot(dx, dy) || 1; + const push = fleeRadius - gap + 24; + let nextX = offsetX + (dx / length) * push; + let nextY = offsetY + (dy / length) * push; + const rangeY = range * 0.6; - useEffect(() => { - const btn = ref.current; - if (!btn) return; + // Cornered: slip past the cursor to the opposite side of the slot. + if (Math.abs(nextX) > range || Math.abs(nextY) > rangeY) { + nextX = -Math.sign(offsetX || dx || 1) * range * 0.7; + nextY = -offsetY * 0.5; + } - const handleMouseMove = (e: MouseEvent) => { - const rect = btn.getBoundingClientRect(); + moveTo(nextX, nextY); + startTiring(); + }, + [fleeRadius, moveTo, range, startTiring], + ); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; + React.useEffect(() => { + if (!evasive) return; - const dx = e.clientX - centerX; - const dy = e.clientY - centerY; - const distance = Math.sqrt(dx * dx + dy * dy); + const onPointerMove = (event: PointerEvent) => { + if (event.pointerType === "touch") return; + dodge(event.clientX, event.clientY); + }; - // Start fleeing when cursor is within 120px of button center - if (distance < 120) { - const strength = 180; - x.jump(-(dx / distance) * strength); - y.jump(-(dy / distance) * strength); - } else { - x.jump(0); - y.jump(0); + window.addEventListener("pointermove", onPointerMove, { passive: true }); + return () => window.removeEventListener("pointermove", onPointerMove); + }, [dodge, evasive]); + + React.useEffect( + () => () => { + staminaRef.current?.stop(); + window.clearTimeout(resetTimerRef.current); + }, + [], + ); + + const handlePointerDown = (event: React.PointerEvent) => { + onPointerDown?.(event); + // Touch has no hover to flee from, so tapping triggers a dodge instead. + if (evasive && event.pointerType === "touch") { + dodge(event.clientX, event.clientY, true); } }; - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [x, y]); - - return ( - - {children} - - ); -} + const handleClick = (event: React.MouseEvent) => { + // Keyboard activation (detail 0) always goes through. + if (evasive && event.detail !== 0) { + event.preventDefault(); + return; + } + onClick?.(event); + + if (state === "tired" && resetAfter > 0) { + window.clearTimeout(resetTimerRef.current); + resetTimerRef.current = window.setTimeout(() => { + staminaRef.current = null; + stamina.set(1); + setState("fleeing"); + }, resetAfter); + } + }; + + const idleLabel = children ?? label; + const currentLabel = state === "tired" ? surrenderLabel : idleLabel; + + return ( + + + + {/* Invisible copies reserve the wider label so the button never resizes. */} + + {idleLabel} + + + {surrenderLabel} + + + + {currentLabel} + + + + + {/* Stamina drains while the button is being chased. */} + + + + ); + }, +); + +TrollButton.displayName = "TrollButton"; +export { TrollButton }; export default TrollButton; diff --git a/components/landing/evil-shader.tsx b/components/landing/evil-shader.tsx new file mode 100644 index 0000000..fae0136 --- /dev/null +++ b/components/landing/evil-shader.tsx @@ -0,0 +1,564 @@ +/* + * @evilbuttons · OpenShaders + * https://openshaders.com/@evilbuttons + * Dither field, recoloured to the brand red (oklch hue ~29°) and ported from + * WebGPU to WebGL2 so it runs in browsers without WebGPU (Linux Chrome, Firefox). + */ + +"use client"; + +import { type CSSProperties, useEffect, useRef } from "react"; + +const VERTEX_SHADER = `#version 300 es +void main() { + vec2 position = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0); +} +`; + +const UNIFORMS = `#version 300 es +precision highp float; +uniform vec2 uResolution; +uniform float uTime; +uniform float uLightMode; +uniform vec3 uDarkBackground; +uniform float uPixelRatio; +uniform vec3 uLightBackground; +out vec4 outColor; +`; + +const FIELD_SHADER = `${UNIFORMS} +const float HUE = 0.06; +const float HUE_SPREAD = 0.07; +const float HUE_TRAVEL = 2.43289733; +const float CHROMA = 0.17; +const float LIGHTNESS = 0.542461395; +const float COLOUR_CYCLE = 0.223255605; +const float THETA = 2.13308811; +const float SHEAR = 0.960379541; +const float SHRINK = 0.947900593; +const float LAYERS = 79.0; +const float WARP_FREQ_X = 0.421021253; +const float WARP_FREQ_Y = 2.2612536; +const float WARP_AMP_X = 0.147639409; +const float WARP_AMP_Y = 0.0258083399; +const float ASPECT_X = 1.95389938; +const float ASPECT_Y = 0.17984499; +const float OFFSET_X = 0.32859391; +const float OFFSET_Y = -0.0158194955; +const float TILT = -3.13121653; +const float ZOOM = 0.917030811; +const float CENTRE_X = -0.653302789; +const float CENTRE_Y = 0.165816918; +const float GLOW_SIZE = 0.00124598783; +const float FALLOFF = 0.325866044; +const float VIGNETTE = 0.0486959331; +const float FLOW_SPEED = 0.446218342; +const float FLOW_DIRECTION = -1.0; +const float BREATH_RATE = 0.455271423; +const float BREATH_AMOUNT = 0.104695484; +const float PHASE = 46.253952; +const float ECHO = 0.0; +const float ECHO_SHIFT = 0.154131204; +const float SOFTNESS = 0.00130189431; +const float LIGHT_SWING = 0.152112246; +const float TAU = 6.28318530718; + +vec3 oklchToLinear(float L, float C, float h) { + float a = C * cos(h); + float b = C * sin(h); + float l_ = L + 0.3963377774 * a + 0.2158037573 * b; + float m_ = L - 0.1055613458 * a - 0.0638541728 * b; + float s_ = L - 0.0894841775 * a - 1.2914855480 * b; + vec3 lms = vec3(l_, m_, s_); + lms = lms * lms * lms; + return mat3(4.0767416621, -1.2684380046, -0.0041960863, + -3.3077115913, 2.6097574011, -0.7034186147, + 0.2309699292, -0.3413193965, 1.7076147010) * lms; +} + +float fmod(float x, float y) { return x - y * floor(x / y); } + +float blueNoise(vec2 p, float frame) { + vec2 q = p + 5.588238 * fmod(frame, 64.0); + return fract(52.9829189 * fract(0.06711056 * q.x + 0.00583715 * q.y)); +} + +void main() { + vec2 R = uResolution; + vec2 frag = gl_FragCoord.xy; + vec2 pos = (frag - 0.5 * R) / R.y; + float t = uTime * FLOW_SPEED * FLOW_DIRECTION + PHASE; + float breath = (-sin(uTime * BREATH_RATE * 1.5) + sin(uTime * BREATH_RATE + 1.0)) * 0.25 + 0.5; + + vec2 p = (pos - vec2(CENTRE_X, CENTRE_Y)) * (ZOOM - breath * BREATH_AMOUNT); + float ct = cos(TILT); + float st = sin(TILT); + p = mat2(ct, st, -st, ct) * p; + + mat2 fold = mat2(cos(THETA), sin(THETA), -SHEAR, cos(THETA)); + + float hue0 = HUE * TAU; + float hue1 = hue0 + HUE_SPREAD * TAU; + vec3 color = vec3(0.0); + + for (float i = 1.0; i <= 96.0; i += 1.0) { + if (i > LAYERS) break; + p.x += -sin(p.y * WARP_FREQ_X + t + i * 0.007) * WARP_AMP_X; + p.y += -sin(p.x * WARP_FREQ_Y - t + i * 0.02) * WARP_AMP_Y; + p = fold * p * SHRINK; + + vec2 q = p - vec2(OFFSET_X + breath * 0.1, OFFSET_Y); + vec2 s = vec2(q.x * ASPECT_X, q.y * ASPECT_Y); + float glow = GLOW_SIZE / (dot(s, s) + SOFTNESS); + if (ECHO > 0.0) { + vec2 e = vec2((q.x - ECHO_SHIFT) * ASPECT_X, s.y); + glow += ECHO * GLOW_SIZE / (dot(e, e) + SOFTNESS); + } + glow *= 0.25 + breath * 0.4; + + float r = length(p); + float k = sin(i * COLOUR_CYCLE + t * 1.2 + r * HUE_TRAVEL) * 0.5 + 0.5; + vec3 tint = clamp(oklchToLinear(LIGHTNESS + LIGHT_SWING * k, CHROMA * (0.75 + 0.35 * k), mix(hue0, hue1, k)), vec3(0.0), vec3(1.0)); + color += glow * tint * exp2(-r * FALLOFF); + } + + vec3 x = max(color, vec3(0.0)); + color = (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14); + color = pow(clamp(color, vec3(0.0), vec3(1.0)), vec3(0.85, 0.92, 0.98)); + + float edge = smoothstep(0.5, 1.6, length(pos)); + color *= 1.0 - edge * VIGNETTE; + + vec3 dark = uDarkBackground + color * (1.0 - uDarkBackground); + float strength = max(color.r, max(color.g, color.b)); + vec3 light = uLightBackground * (1.0 - strength) + color * 0.96; + color = mix(dark, light, vec3(uLightMode)); + + color += (blueNoise(frag, floor(uTime * 24.0)) - 0.5) / 255.0; + outColor = vec4(clamp(color, vec3(0.0), vec3(1.0)), 1.0); +} +`; + +const RARITY_SHADER = `${UNIFORMS} +uniform sampler2D tScene; + +const float STRENGTH = 1.12953091; +const float SCALE = 1.1694684; +const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722); + +vec3 toInk(vec3 c) { return mix(c - uDarkBackground, uLightBackground - c, vec3(uLightMode)); } +vec3 fromInk(vec3 ink) { return mix(uDarkBackground + ink, uLightBackground - ink, vec3(uLightMode)); } +vec3 sceneInk(vec2 uv) { + return toInk(texture(tScene, clamp(uv, vec2(0.0), vec2(1.0))).rgb); +} + +vec2 fmod2(vec2 x, float y) { return x - y * floor(x / y); } + +const mat4 BAYER = mat4( + 0.94118, 0.29412, 0.76471, 0.05882, + 0.47059, 0.70588, 0.23529, 0.52941, + 0.82353, 0.11765, 0.88235, 0.17647, + 0.35294, 0.58824, 0.41176, 0.64706 +); + +vec3 dither(vec2 frag) { + float cell = max(2.0, floor(SCALE * 2.2 * uPixelRatio + 0.5)); + vec2 grid = floor(frag / cell); + vec3 soft = sceneInk(frag / uResolution); + vec3 ink = sceneInk((grid + 0.5) * cell / uResolution); + float level = dot(ink, LUMA); + float levels = 8.0; + ivec2 b = ivec2(fmod2(grid, 4.0)); + float v = pow(max(level, 0.0), 0.8) * levels + BAYER[b.x][b.y]; + float quantised = pow(floor(v) / levels, 1.25); + vec3 dithered = ink * (quantised / max(level, 1e-4)); + float presence = smoothstep(0.03, 0.14, level) * (0.38 + 0.2 * STRENGTH); + return mix(soft, dithered, vec3(presence)); +} + +void main() { + vec3 ink = dither(gl_FragCoord.xy); + vec3 color = fromInk(clamp(ink, vec3(0.0), vec3(1.0))); + outColor = vec4(clamp(color, vec3(0.0), vec3(1.0)), 1.0); +} +`; + +export type ShaderTheme = "dark" | "light"; + +export type ShaderOptions = { + theme?: ShaderTheme; + background?: { dark?: string; light?: string }; + autoplay?: boolean; + signal?: AbortSignal; + onError?: (error: Error) => void; +}; + +export type ShaderHandle = { + setTheme(theme: ShaderTheme): void; + + render(time: number): void; + destroy(): void; +}; + +export type EvilShaderProps = { + theme?: ShaderTheme; + background?: { dark?: string; light?: string }; + time?: number; + onError?: (error: Error) => void; + className?: string; + style?: CSSProperties; +}; + +export function EvilShader({ theme = "dark", background, time, onError, className, style }: EvilShaderProps) { + const canvas = useRef(null); + const shader = useRef(null); + const latestTheme = useRef(theme); + const latestTime = useRef(time); + const latestOnError = useRef(onError); + const dark = background?.dark ?? "#0a0a0a"; + const light = background?.light ?? "#ffffff"; + const animated = time === undefined; + + useEffect(() => { + latestTheme.current = theme; + shader.current?.setTheme(theme); + }, [theme]); + + useEffect(() => { + latestTime.current = time; + if (time !== undefined) shader.current?.render(time); + }, [time]); + + useEffect(() => { + latestOnError.current = onError; + }, [onError]); + + useEffect(() => { + const element = canvas.current; + if (!element) return; + let handle: ShaderHandle | null = null; + const controller = new AbortController(); + const options: ShaderOptions = { + theme: latestTheme.current, + background: { dark, light }, + autoplay: animated, + signal: controller.signal, + onError: (error) => { + if (controller.signal.aborted) return; + if (latestOnError.current) latestOnError.current(error); + else console.error(error); + }, + }; + + createShader(element, options).then((created) => { + if (controller.signal.aborted) { created.destroy(); return; } + handle = created; + shader.current = created; + if (latestTheme.current !== options.theme) created.setTheme(latestTheme.current); + if (latestTime.current !== undefined) created.render(latestTime.current); + }).catch((error: unknown) => { + if (!controller.signal.aborted) options.onError?.(error instanceof Error ? error : new Error(String(error))); + }); + return () => { + controller.abort(); + handle?.destroy(); + shader.current = null; + }; + }, [dark, light, animated]); + + return
@@ -125,12 +117,6 @@ export function HeroSection({ trapped = false }: HeroSectionProps) {

{!trapped ? (
- - Playground - ReactNode; +type LandingPageProps = { + /** Docs category per showcase href, read from the MDX frontmatter. */ + categories: Record; + /** Sections below the first screen, rendered on the server. */ + children?: ReactNode; }; -// Static stand-in shown for the always-on WebGL buttons until their grid cell -// scrolls into view. Approximates the real button's footprint (rounded, dark -// pill) so swapping in the live shader causes no layout shift. -function WebGLPlaceholder({ label }: { label: string }) { +const navIcon = + "inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +function Navbar() { + const isDark = useAppTheme() === "dark"; + return ( - - {label} - +
+ +
); } -const showcase: ButtonShowcase[] = [ - { - name: "RevealButton", - href: "/docs/reveal-button", - registryName: "reveal-button", - render: () => , - }, - { - name: "CommandButton", - href: "/docs/command-button", - registryName: "command-button", - render: () => Save, - }, - { - name: "CopyButton", - href: "/docs/copy-button", - registryName: "copy-button", - render: () => , - }, - { - name: "ClickPowerUp", - href: "/docs/click-power-up", - registryName: "click-powerup", - render: () => Doom, - }, - { - name: "DitherButton", - href: "/docs/dither-button", - registryName: "dither-button", - render: () => Run It, - }, - { - name: "HoldButton", - href: "/docs/hold-button", - registryName: "hold-button", - render: () => , - }, - { - name: "DemonicButton", - href: "/docs/demonic-button", - registryName: "demonic-button", - render: () => , - }, - { - name: "EvilEyeButton", - href: "/docs/evil-eye-button", - registryName: "evil-eye-button", - render: () => ( - }> - Doom - - ), - }, - { - name: "AquaButton", - href: "/docs/aqua-button", - registryName: "aqua-button", - render: () => Deploy Doom, - }, - { - name: "BrutalButton", - href: "/docs/brutal-button", - registryName: "brutal-button", - render: () => Click Me, - }, - { - name: "ChromeButton", - href: "/docs/chrome-button", - registryName: "chrome-button", - render: () => ( - }> - Chromy - - ), - }, - { - name: "FrameButton", - href: "/docs/frame-button", - registryName: "frame-button", - render: () => Deploy, - }, - { - name: "GlitchButton", - href: "/docs/glitch-button", - registryName: "glitch-button", - render: () => Launch, - }, - { - name: "GridButton", - href: "/docs/grid-button", - registryName: "grid-button", - render: () => Click, - }, - { - name: "HighlightButton", - href: "/docs/highlight-button", - registryName: "highlight-button", - render: () => Send, - }, - { - name: "MinimalButton", - href: "/docs/minimal-button", - registryName: "minimal", - render: () => Apply, - }, - { - name: "MoviePassButton", - href: "/docs/movie-pass", - registryName: "movie-pass", - render: () => Deploy Doom, - }, - { - name: "ShinyButton", - href: "/docs/shiny-button", - registryName: "shiny-button", - render: () => Search, - }, - { - name: "StickyButton", - href: "/docs/sticky-button", - registryName: "sticky", - render: () => Try to Click, - }, - { - name: "ThreeDButton", - href: "/docs/3d-button", - registryName: "3d-button", - render: () => Continue, - }, - { - name: "TrollButton", - href: "/docs/troll-button", - registryName: "troll-button", - render: () => Click Me, - }, - { - name: "CaptchaButton", - href: "/docs/captcha-button", - registryName: "captcha-button", - render: () => Deploy Doom, - }, - { - name: "DoubtButton", - href: "/docs/doubt-button", - registryName: "doubt-button", - render: () => Delete everything, - }, - { - name: "SlideToDetonate", - href: "/docs/slide-to-detonate", - registryName: "slide-to-detonate", - render: () => Slide to detonate, - }, - { - name: "MorphStatusButton", - href: "/docs/morph-status-button", - registryName: "morph-status-button", - render: () => ( - new Promise((resolve) => setTimeout(resolve, 1200))} - > - Save changes - - ), - }, - { - name: "CooldownButton", - href: "/docs/cooldown-button", - registryName: "cooldown-button", - render: () => Send it, - }, - { - name: "PillButton", - href: "/docs/pill-button", - registryName: "pill-button", - render: () => ( - - ), - }, - { - name: "ConfettiButton", - href: "/docs/confetti-button", - registryName: "confetti-button", - render: () => Celebrate, - }, - { - name: "HoldConfirmButton", - href: "/docs/hold-confirm-button", - registryName: "hold-confirm-button", - render: () => , - }, - { - name: "DontPressButton", - href: "/docs/dont-press-button", - registryName: "dont-press-button", - render: () => , - }, - { - name: "AshBurstButton", - href: "/docs/ash-burst-button", - registryName: "ash-burst-button", - render: () => Destroy, - }, -]; - -function ButtonCell({ item }: { item: ButtonShowcase }) { - const [copied, setCopied] = useState(false); - const timerRef = useRef(null); +function ShaderBackdrop() { + const theme = useAppTheme(); + const [failed, setFailed] = useState(false); - useEffect(() => { - return () => { - if (timerRef.current !== null) clearTimeout(timerRef.current); - }; - }, []); + if (failed) return null; - const command = `npx shadcn@latest add @evilbuttons/${item.registryName}`; + // Falls back to the CSS .landing-bg underneath when WebGL2 is unavailable. + return ( + { + console.warn("Landing shader disabled:", error.message); + setFailed(true); + }} + className="pointer-events-none absolute inset-0 rounded-[inherit]" + /> + ); +} - async function handleCopy(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - try { - await navigator.clipboard.writeText(command); - } catch { - return; - } - setCopied(true); - trackOutcome("install_command_copied", { - source: "landing_grid", - registry_name: item.registryName, - package_manager: "npm", - }); - if (timerRef.current !== null) clearTimeout(timerRef.current); - timerRef.current = window.setTimeout(() => setCopied(false), 2000); - } +// Eased stops so the fade has no visible band where it ends. +const scrimStops = [ + [0, 0.92], + [0.2, 0.8], + [0.4, 0.58], + [0.6, 0.32], + [0.8, 0.1], + [1, 0], +] as const; +/** Darkens the top of the shader so the navbar and headline stay legible. */ +function TopScrim() { return ( -
-
- {item.name} -
-
- {item.render()} -
-
- - - Docs - - -
-
+ ); } -export function LandingPage() { - const router = useRouter(); - +export function LandingPage({ categories, children }: LandingPageProps) { return ( -
-
-
- -
- {siteConfig.name} - - Evil
Buttons -
-
- -

- Animated buttons, built with an evil touch. + // never scrolls, so the page is its own scroll container. +
+ {/* First screen: an inset, rounded frame that also clips the carousel. */} +
+
+ + +
+ + +
+

+ Animated buttons, built with an{" "} + + evil + {" "} + touch.

-

- A shadcn/ui registry of {showcase.length} interactive button - components. Live previews, copy-paste docs, one-command CLI - installs. +

+ Hold, slide, doubt, glitch and detonate. Every button below is live + — play with it, pick a variant, and install it with one command.

-
+ +
- Browse Docs - - - Playground + Browse docs + + GitHub - router.push("/drop")} - className="w-full min-w-0 rounded-none font-doto text-sm font-black uppercase tracking-tight" - />
-
+
-
-

- {showcase.length} Components -

-

- © {new Date().getFullYear()} {siteConfig.author.name} -

-
-

- -
-
- {showcase.map((item) => ( - - ))} -
-
+
+ +
+
+ {children}
); } diff --git a/components/landing/playground-page.tsx b/components/landing/playground-page.tsx deleted file mode 100644 index feb7ca6..0000000 --- a/components/landing/playground-page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { AppDialRoot } from "@/components/dial-root"; -import { FitToContainer } from "@/components/landing/fit-to-container"; -import { ButtonPreview } from "@/components/landing/previews"; -import { showcase } from "@/components/landing/showcase"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { useAppTheme } from "@/hooks/use-app-theme"; -import { siteConfig } from "@/lib/seo"; -import { ArrowUpRight } from "@phosphor-icons/react"; -import "dialkit/styles.css"; -import Link from "next/link"; -import { useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -export function PlaygroundPage() { - const theme = useAppTheme(); - const [selected, setSelected] = useState(showcase[0].registryName); - const active = - showcase.find((item) => item.registryName === selected) ?? showcase[0]; - - return ( -
-
- - {siteConfig.name} - - Evil Buttons - - - -
- - - - - Docs - - -
-
- -
- - - -
- -
-

- {showcase.length} components -

-
- - Browse Docs - - - GitHub - -
-
- - -
- ); -} \ No newline at end of file diff --git a/components/landing/previews/index.tsx b/components/landing/previews/index.tsx index cdc8705..4bee703 100644 --- a/components/landing/previews/index.tsx +++ b/components/landing/previews/index.tsx @@ -4,10 +4,8 @@ import type { ComponentType } from "react"; import { RevealButtonPreview, HoldButtonPreview, - HoldConfirmButtonPreview, SlideToDetonatePreview, DoubtButtonPreview, - CaptchaButtonPreview, CooldownButtonPreview, MorphStatusButtonPreview, } from "./interaction"; @@ -26,27 +24,24 @@ import { CommandButtonPreview, CopyButtonPreview, ClickPowerUpPreview, - PillButtonPreview, + RealisticSwitchPreview, } from "./utility"; import { DemonicButtonPreview, ChromeButtonPreview, - GridButtonPreview, MinimalButtonPreview, MoviePassButtonPreview, ShinyButtonPreview, StickyButtonPreview, ThreeDButtonPreview, TrollButtonPreview, - DontPressButtonPreview, + MinecraftButtonPreview, } from "./simple"; import { StaticRevealButtonPreview, StaticHoldButtonPreview, - StaticHoldConfirmButtonPreview, StaticSlideToDetonatePreview, StaticDoubtButtonPreview, - StaticCaptchaButtonPreview, StaticCooldownButtonPreview, StaticMorphStatusButtonPreview, StaticBrutalButtonPreview, @@ -58,20 +53,19 @@ import { StaticHighlightButtonPreview, StaticConfettiButtonPreview, StaticAshBurstButtonPreview, - StaticDontPressButtonPreview, StaticCommandButtonPreview, StaticCopyButtonPreview, StaticClickPowerUpPreview, - StaticPillButtonPreview, + StaticRealisticSwitchPreview, StaticDemonicButtonPreview, StaticChromeButtonPreview, - StaticGridButtonPreview, StaticMinimalButtonPreview, StaticMoviePassButtonPreview, StaticShinyButtonPreview, StaticStickyButtonPreview, StaticThreeDButtonPreview, StaticTrollButtonPreview, + StaticMinecraftButtonPreview, } from "./static"; const dialButtonPreviews: Record = { @@ -88,7 +82,6 @@ const dialButtonPreviews: Record = { "chrome-button": ChromeButtonPreview, "frame-button": FrameButtonPreview, "glitch-button": GlitchButtonPreview, - "grid-button": GridButtonPreview, "highlight-button": HighlightButtonPreview, minimal: MinimalButtonPreview, "movie-pass": MoviePassButtonPreview, @@ -96,16 +89,14 @@ const dialButtonPreviews: Record = { sticky: StickyButtonPreview, "3d-button": ThreeDButtonPreview, "troll-button": TrollButtonPreview, - "captcha-button": CaptchaButtonPreview, "doubt-button": DoubtButtonPreview, "slide-to-detonate": SlideToDetonatePreview, "morph-status-button": MorphStatusButtonPreview, "cooldown-button": CooldownButtonPreview, - "pill-button": PillButtonPreview, + "realistic-switch": RealisticSwitchPreview, "confetti-button": ConfettiButtonPreview, - "hold-confirm-button": HoldConfirmButtonPreview, - "dont-press-button": DontPressButtonPreview, "ash-burst-button": AshBurstButtonPreview, + "minecraft-button": MinecraftButtonPreview, }; const staticButtonPreviews: Record = { @@ -122,7 +113,6 @@ const staticButtonPreviews: Record = { "chrome-button": StaticChromeButtonPreview, "frame-button": StaticFrameButtonPreview, "glitch-button": StaticGlitchButtonPreview, - "grid-button": StaticGridButtonPreview, "highlight-button": StaticHighlightButtonPreview, minimal: StaticMinimalButtonPreview, "movie-pass": StaticMoviePassButtonPreview, @@ -130,18 +120,20 @@ const staticButtonPreviews: Record = { sticky: StaticStickyButtonPreview, "3d-button": StaticThreeDButtonPreview, "troll-button": StaticTrollButtonPreview, - "captcha-button": StaticCaptchaButtonPreview, "doubt-button": StaticDoubtButtonPreview, "slide-to-detonate": StaticSlideToDetonatePreview, "morph-status-button": StaticMorphStatusButtonPreview, "cooldown-button": StaticCooldownButtonPreview, - "pill-button": StaticPillButtonPreview, + "realistic-switch": StaticRealisticSwitchPreview, "confetti-button": StaticConfettiButtonPreview, - "hold-confirm-button": StaticHoldConfirmButtonPreview, - "dont-press-button": StaticDontPressButtonPreview, "ash-burst-button": StaticAshBurstButtonPreview, + "minecraft-button": StaticMinecraftButtonPreview, }; +export function hasDialPreview(registryName: string) { + return registryName in dialButtonPreviews; +} + export function ButtonPreview({ registryName, dial = false, diff --git a/components/landing/previews/interaction.tsx b/components/landing/previews/interaction.tsx index 918c3e1..a68434f 100644 --- a/components/landing/previews/interaction.tsx +++ b/components/landing/previews/interaction.tsx @@ -1,15 +1,14 @@ "use client"; import { useDialKit } from "dialkit"; -import { useThemedDialKit } from "./theme"; +import { fromFolder, listFolder } from "./theme"; import { RevealButton } from "@/components/evil-buttons/reveal-button"; import { HoldButton } from "@/components/evil-buttons/hold-button"; -import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; import { SlideToDetonate } from "@/components/evil-buttons/slide-to-detonate"; import { DoubtButton } from "@/components/evil-buttons/doubt-button"; -import { CaptchaButton } from "@/components/evil-buttons/captcha-button"; import { CooldownButton } from "@/components/evil-buttons/cooldown-button"; import { MorphStatusButton } from "@/components/evil-buttons/morph-status-button"; +import { LiveProps } from "./live-props"; export function RevealButtonPreview() { const p = useDialKit( @@ -24,18 +23,26 @@ export function RevealButtonPreview() { options: ["hold", "toggle"], default: "hold", }, + variant: { + type: "select", + options: ["dark", "light"], + default: "dark", + }, }, { id: "reveal-button" }, ); return ( - + + + ); } @@ -53,47 +60,15 @@ export function HoldButtonPreview() { ); return ( - - ); -} - -export function HoldConfirmButtonPreview() { - const p = useThemedDialKit( - "HoldConfirmButton", - (isDark) => ({ - label: "Hold to confirm", - holdingLabel: "Keep holding…", - successLabel: "Confirmed", - duration: [2000, 500, 5000], - resetAfter: [1600, 0, 5000], - minScale: [0.9, 0.7, 1, 0.01], - ring: { - size: [280, 100, 400], - strokeWidth: [12, 4, 24], - color: isDark ? "#5eead4" : "#0d9488", - }, - }), - { id: "hold-confirm-button" }, - ); - - return ( - + + + ); } @@ -102,20 +77,30 @@ export function SlideToDetonatePreview() { "SlideToDetonate", { label: "Slide to detonate", - successLabel: "Detonated", - threshold: [0.95, 0.5, 1, 0.01], + threshold: [0.9, 0.5, 1, 0.01], + resistance: [0.35, 0, 1, 0.05], + smoothness: [0.4, 0, 1, 0.05], + variant: { + type: "select", + options: ["dark", "light"], + default: "dark", + }, resetAfter: [1600, 0, 5000], }, { id: "slide-to-detonate" }, ); return ( - + + + ); } @@ -126,38 +111,30 @@ export function DoubtButtonPreview() { label: "Delete everything", successLabel: "Too late.", resetAfter: [1600, 0, 5000], + confirmations: listFolder([ + "Are you sure?", + "Really, truly sure?", + "There is no undo. Still?", + "Think of the consequences.", + "I am obligated to ask again.", + "Last chance. Absolutely certain?", + ]), }, { id: "doubt-button" }, ); return ( - + + + ); } -export function CaptchaButtonPreview() { - const p = useDialKit( - "CaptchaButton", - { - label: "Deploy Doom", - successLabel: "Deployed", - resetAfter: [1600, 0, 5000], - }, - { id: "captcha-button" }, - ); - - return ( - - ); -} export function CooldownButtonPreview() { const p = useDialKit( @@ -166,16 +143,26 @@ export function CooldownButtonPreview() { label: "Send it", cooldown: [3000, 1000, 10000], showCountdown: true, + taunts: listFolder([ + "Patience.", + "Again? Wait.", + "Not so fast.", + "Cool it.", + "Hold your horses.", + ]), }, { id: "cooldown-button" }, ); return ( - + + + ); } @@ -188,18 +175,36 @@ export function MorphStatusButtonPreview() { successLabel: "Done", errorLabel: "It broke. Your fault.", resetAfter: [1800, 0, 5000], + status: { + type: "select", + options: [ + { value: "auto", label: "Auto (click)" }, + "idle", + "loading", + "success", + "error", + ], + default: "auto", + }, }, { id: "morph-status-button" }, ); return ( - new Promise((resolve) => setTimeout(resolve, 1200))} - /> + + new Promise((resolve) => setTimeout(resolve, 1200))} + /> + ); } \ No newline at end of file diff --git a/components/landing/previews/live-props.tsx b/components/landing/previews/live-props.tsx new file mode 100644 index 0000000..c41b75c --- /dev/null +++ b/components/landing/previews/live-props.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { + createContext, + useContext, + useLayoutEffect, + type ReactElement, +} from "react"; + +type LivePropsSink = (props: Record) => void; + +const LivePropsContext = createContext(null); + +export const LivePropsProvider = LivePropsContext.Provider; + +/** + * Wraps the button a dial preview renders and reports the props it was given, + * so the docs panel can turn the current config into a copyable prompt. + * Outside a provider it just renders the button. + */ +export function LiveProps({ children }: { children: ReactElement }) { + const report = useContext(LivePropsContext); + const props = children.props as Record; + + useLayoutEffect(() => { + report?.(props); + }); + + return children; +} + +function formatValue(value: unknown): string { + if (typeof value === "string") { + return /["{}\n]/.test(value) ? `{${JSON.stringify(value)}}` : `"${value}"`; + } + return `{${JSON.stringify(value)}}`; +} + +/** Serializes captured props as a JSX element, dropping handlers and undefined. */ +export function toJsx(name: string, props: Record) { + const { children, ...rest } = props; + const attrs = Object.entries(rest) + .filter( + ([, value]) => value !== undefined && typeof value !== "function", + ) + .map(([key, value]) => + value === true ? ` ${key}` : ` ${key}=${formatValue(value)}`, + ); + + const open = attrs.length > 0 ? `<${name}\n${attrs.join("\n")}\n` : `<${name}`; + const text = + typeof children === "string" || typeof children === "number" + ? String(children) + : null; + + if (text === null) return `${open}${attrs.length > 0 ? "/>" : " />"}`; + return `${open}>\n ${text}\n`; +} diff --git a/components/landing/previews/simple.tsx b/components/landing/previews/simple.tsx index 0ea8084..d83696f 100644 --- a/components/landing/previews/simple.tsx +++ b/components/landing/previews/simple.tsx @@ -2,81 +2,153 @@ import { useDialKit } from "dialkit"; import { DemonicButton } from "@/components/evil-buttons/demonic-button"; -import ChromeButton from "@/components/evil-buttons/chrome-button"; -import GridButton from "@/components/evil-buttons/grid-button"; +import ChromeButton, { + type ChromeSize, + type ChromeTone, +} from "@/components/evil-buttons/chrome-button"; import MinimalButton from "@/components/evil-buttons/minimal"; -import MoviePassButton from "@/components/evil-buttons/movie-pass"; +import MoviePassButton, { + type MoviePassVariant, +} from "@/components/evil-buttons/movie-pass"; import ShinyButton from "@/components/evil-buttons/shiny-button"; import StickyButton from "@/components/evil-buttons/sticky"; import { ThreeDButton } from "@/components/evil-buttons/3d-button"; import TrollButton from "@/components/evil-buttons/troll-button"; -import { DontPressButton } from "@/components/evil-buttons/dont-press-button"; +import { MinecraftButton } from "@/components/evil-buttons/minecraft-button"; import { DeferredWebGLPreview } from "./shared"; +import { LiveProps } from "./live-props"; export function DemonicButtonPreview() { const p = useDialKit( "DemonicButton", - { label: "Currupt the World" }, + { label: "Corrupt the World", holdDuration: [2200, 500, 6000] }, { id: "demonic-button" }, ); - return ; + return ( + + + + ); } export function ChromeButtonPreview() { const p = useDialKit( "ChromeButton", - { label: "Chromy" }, + { + label: "Continue", + tone: { + type: "select", + options: ["auto", "dark", "light"], + default: "auto", + }, + size: { type: "select", options: ["default", "sm"], default: "default" }, + speed: [1, 0.2, 3], + interactive: true, + }, { id: "chrome-button" }, ); return ( - {p.label} + + + {p.label} + + ); } -export function GridButtonPreview() { - const p = useDialKit("GridButton", { label: "Click" }, { id: "grid-button" }); - - return {p.label}; -} export function MinimalButtonPreview() { - const p = useDialKit("MinimalButton", { label: "Apply" }, { id: "minimal" }); + const p = useDialKit( + "MinimalButton", + { label: "Apply", arrow: true }, + { id: "minimal" }, + ); - return {p.label}; + return ( + + {p.label} + + ); } export function MoviePassButtonPreview() { const p = useDialKit( "MoviePassButton", - { label: "Deploy Doom" }, + { + label: "Admit One", + variant: { + type: "select", + options: ["tilt", "snap"], + default: "tilt", + }, + stub: { type: "text", default: "", placeholder: "NO. 01" }, + autoReset: true, + resetDelay: [1800, 300, 5000], + }, { id: "movie-pass" }, ); - return {p.label}; + return ( + + + {p.label} + + + ); } export function ShinyButtonPreview() { const p = useDialKit( "ShinyButton", - { label: "Search" }, + { label: "Search", shineDuration: [650, 200, 2000] }, { id: "shiny-button" }, ); - return {p.label}; + return ( + + {p.label} + + ); } export function StickyButtonPreview() { const p = useDialKit( "StickyButton", - { label: "Try to Click" }, + { + label: "Try to Click", + radius: [120, 0, 300], + strength: [0.45, 0, 1], + parallax: [0.35, 0, 1], + tilt: [8, 0, 20], + }, { id: "sticky" }, ); - return {p.label}; + return ( + + + {p.label} + + + ); } export function ThreeDButtonPreview() { @@ -86,25 +158,63 @@ export function ThreeDButtonPreview() { { id: "3d-button" }, ); - return {p.label}; + return ( + + {p.label} + + ); } export function TrollButtonPreview() { const p = useDialKit( "TrollButton", - { label: "Click Me" }, + { + label: "Click Me", + surrenderLabel: "Fine, click me", + giveUpAfter: [3500, 1000, 10000], + fleeRadius: [56, 16, 160], + range: [140, 40, 300], + resetAfter: [1500, 0, 5000], + }, { id: "troll-button" }, ); - return {p.label}; + return ( + + + {p.label} + + + ); } -export function DontPressButtonPreview() { +export function MinecraftButtonPreview() { const p = useDialKit( - "DontPressButton", - { idleLabel: "Don't Press" }, - { id: "dont-press-button" }, + "MinecraftButton", + { + label: "Singleplayer", + tapsPerStage: [5, 1, 10], + stages: [4, 2, 8], + sound: true, + }, + { id: "minecraft-button" }, ); - return ; -} \ No newline at end of file + return ( + + + {p.label} + + + ); +} diff --git a/components/landing/previews/static.tsx b/components/landing/previews/static.tsx index 9f09a38..40b8c0f 100644 --- a/components/landing/previews/static.tsx +++ b/components/landing/previews/static.tsx @@ -3,10 +3,8 @@ import { useIsDarkMode } from "@/hooks/use-app-theme"; import { RevealButton } from "@/components/evil-buttons/reveal-button"; import { HoldButton } from "@/components/evil-buttons/hold-button"; -import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; import { SlideToDetonate } from "@/components/evil-buttons/slide-to-detonate"; import { DoubtButton } from "@/components/evil-buttons/doubt-button"; -import { CaptchaButton } from "@/components/evil-buttons/captcha-button"; 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"; @@ -18,14 +16,13 @@ import { FrameButton } from "@/components/evil-buttons/frame-button"; import { HighlightButton } from "@/components/evil-buttons/highlight-button"; import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; import { AshBurstButton } from "@/components/evil-buttons/ash-burst-button"; -import { DontPressButton } from "@/components/evil-buttons/dont-press-button"; +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 { PillButton } from "@/components/evil-buttons/pill-button"; +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 GridButton from "@/components/evil-buttons/grid-button"; import MinimalButton from "@/components/evil-buttons/minimal"; import MoviePassButton from "@/components/evil-buttons/movie-pass"; import ShinyButton from "@/components/evil-buttons/shiny-button"; @@ -33,7 +30,7 @@ import StickyButton from "@/components/evil-buttons/sticky"; import { ThreeDButton } from "@/components/evil-buttons/3d-button"; import TrollButton from "@/components/evil-buttons/troll-button"; import { DeferredWebGLPreview } from "./shared"; -import { pillClassNames, themeColors } from "./theme"; +import { themeColors } from "./theme"; export function StaticRevealButtonPreview() { return ( @@ -59,30 +56,13 @@ export function StaticHoldButtonPreview() { ); } -export function StaticHoldConfirmButtonPreview() { - const isDark = useIsDarkMode(); - - return ( - - ); -} - export function StaticSlideToDetonatePreview() { return ( ); @@ -98,15 +78,6 @@ export function StaticDoubtButtonPreview() { ); } -export function StaticCaptchaButtonPreview() { - return ( - - ); -} export function StaticCooldownButtonPreview() { return ( @@ -179,22 +150,8 @@ export function StaticGlitchButtonPreview() { export function StaticEvilEyeButtonPreview() { return ( - - - Doom - + + I see you ); } @@ -205,40 +162,21 @@ export function StaticAquaButtonPreview() { export function StaticFrameButtonPreview() { return ( - - Deploy - + Deploy ); } export function StaticHighlightButtonPreview() { - const isDark = useIsDarkMode(); - const colors = isDark ? themeColors.dark : themeColors.light; - - return ( - - Send - - ); + return Send; } export function StaticConfettiButtonPreview() { return ( ); } @@ -246,17 +184,14 @@ export function StaticConfettiButtonPreview() { export function StaticAshBurstButtonPreview() { return ( ); } -export function StaticDontPressButtonPreview() { - return ; -} export function StaticCommandButtonPreview() { return ( @@ -281,43 +216,29 @@ export function StaticClickPowerUpPreview() { return Doom; } -export function StaticPillButtonPreview() { - const isDark = useIsDarkMode(); - const classes = pillClassNames(isDark); - - return ( - - ); +export function StaticRealisticSwitchPreview() { + return ; } export function StaticDemonicButtonPreview() { - return ; + return ; } export function StaticChromeButtonPreview() { return ( - - Chromy + + Continue ); } -export function StaticGridButtonPreview() { - return Click; -} export function StaticMinimalButtonPreview() { return Apply; } export function StaticMoviePassButtonPreview() { - return Deploy Doom; + return Admit One; } export function StaticShinyButtonPreview() { @@ -334,4 +255,8 @@ export function StaticThreeDButtonPreview() { export function StaticTrollButtonPreview() { return Click Me; -} \ No newline at end of file +} + +export function StaticMinecraftButtonPreview() { + return Singleplayer; +} diff --git a/components/landing/previews/theme.ts b/components/landing/previews/theme.ts index 89a7ca6..1f92830 100644 --- a/components/landing/previews/theme.ts +++ b/components/landing/previews/theme.ts @@ -34,14 +34,24 @@ export const themeColors = { }, } as const; -export function pillClassNames(isDark: boolean) { - return isDark - ? { - primaryClassName: "bg-neutral-950 text-neutral-200", - secondaryClassName: "bg-primary text-primary-foreground", - } - : { - primaryClassName: "bg-muted text-foreground", - secondaryClassName: "bg-primary text-primary-foreground", - }; -} \ No newline at end of file +/** + * DialKit has no list control, so array props become a folder of numbered + * fields (`item1`, `item2`, …). Clearing a text field drops it from the list. + */ +export function listFolder(items: readonly T[]) { + return Object.fromEntries( + items.map((item, i) => [`item${i + 1}`, item]), + ) as Record; +} + +export function colorFolder(colors: readonly string[]) { + return Object.fromEntries( + colors.map((color, i) => [`color${i + 1}`, { type: "color", default: color }]), + ) as Record; +} + +export function fromFolder(folder: Record) { + return Object.values(folder).filter( + (value): value is string => typeof value === "string" && value.trim() !== "", + ); +} diff --git a/components/landing/previews/utility.tsx b/components/landing/previews/utility.tsx index 2191b9d..1f35015 100644 --- a/components/landing/previews/utility.tsx +++ b/components/landing/previews/utility.tsx @@ -1,12 +1,15 @@ "use client"; import { useDialKit } from "dialkit"; -import { useIsDarkMode } from "@/hooks/use-app-theme"; -import { pillClassNames, useThemedDialKit } from "./theme"; 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 { PillButton } from "@/components/evil-buttons/pill-button"; +import { + RealisticSwitch, + type RealisticSwitchSize, + type RealisticSwitchTone, +} from "@/components/evil-buttons/realistic-switch"; +import { LiveProps } from "./live-props"; export function CommandButtonPreview() { const p = useDialKit( @@ -21,13 +24,15 @@ export function CommandButtonPreview() { ); return ( - - {p.label} - + + + {p.label} + + ); } @@ -38,18 +43,24 @@ export function CopyButtonPreview() { value: "npx evil-buttons@latest init", copyLabel: "Copy", copiedLabel: "Copied", + errorLabel: "Failed", timeout: [1500, 500, 5000], + showValue: true, }, { id: "copy-button" }, ); return ( - + + + ); } @@ -59,33 +70,46 @@ export function ClickPowerUpPreview() { { label: "Doom", tapDuration: [500, 200, 2000], + accentColor: { type: "color", default: "#2CD4BD" }, }, { id: "click-powerup" }, ); - return {p.label}; + return ( + + + {p.label} + + + ); } -export function PillButtonPreview() { - const p = useThemedDialKit( - "PillButton", - () => ({ - primaryLabel: "Off", - secondaryLabel: "On", - defaultOpen: false, - }), - { id: "pill-button" }, +export function RealisticSwitchPreview() { + const p = useDialKit( + "RealisticSwitch", + { + color: { type: "color", default: "#e1261c" }, + markColor: { type: "color", default: "#ffffff" }, + tone: { type: "select", options: ["auto", "light", "dark"], default: "auto" }, + illuminated: true, + sound: true, + defaultChecked: false, + size: { type: "select", options: ["sm", "default", "lg"], default: "lg" }, + }, + { id: "realistic-switch" }, ); - const isDark = useIsDarkMode(); - const classes = pillClassNames(isDark); return ( - + + + ); -} \ No newline at end of file +} diff --git a/components/landing/previews/visual.tsx b/components/landing/previews/visual.tsx index 2542583..062e4b1 100644 --- a/components/landing/previews/visual.tsx +++ b/components/landing/previews/visual.tsx @@ -1,4 +1,5 @@ "use client"; +import { useDialKit } from "dialkit"; import { BrutalButton } from "@/components/evil-buttons/brutal-button"; import DitherButton from "@/components/evil-buttons/dither-button"; @@ -10,7 +11,13 @@ import { HighlightButton } from "@/components/evil-buttons/highlight-button"; import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; import { AshBurstButton } from "@/components/evil-buttons/ash-burst-button"; import { DeferredWebGLPreview } from "./shared"; -import { themeColors, useThemedDialKit } from "./theme"; +import { + colorFolder, + fromFolder, + themeColors, + useThemedDialKit, +} from "./theme"; +import { LiveProps } from "./live-props"; export function BrutalButtonPreview() { const p = useThemedDialKit( @@ -32,17 +39,19 @@ export function BrutalButtonPreview() { ); return ( - - {p.label} - + + + {p.label} + + ); } @@ -59,13 +68,15 @@ export function DitherButtonPreview() { ); return ( - - {p.label} - + + + {p.label} + + ); } @@ -90,16 +101,18 @@ export function GlitchButtonPreview() { ); return ( - - {p.label} - + + + {p.label} + + ); } @@ -107,41 +120,41 @@ export function EvilEyeButtonPreview() { const p = useThemedDialKit( "EvilEyeButton", () => ({ - label: "Doom", - effectOpacity: [0.95, 0, 1, 0.01], + label: "I see you", + blink: true, eye: { eyeColor: "#ff6f37", - backgroundColor: "#000000", - intensity: [1.65, 0, 3, 0.01], + intensity: [1.3, 0, 3, 0.01], pupilSize: [0.62, 0, 1, 0.01], irisWidth: [0.22, 0, 1, 0.01], - glowIntensity: [0.56, 0, 1, 0.01], - scale: [1.15, 0.5, 2, 0.01], - noiseScale: [1, 0, 2, 0.01], - pupilFollow: [0.55, 0, 1, 0.01], + glowIntensity: [0.5, 0, 1, 0.01], + scale: [1.7, 0.6, 3, 0.01], + noiseScale: [0.8, 0, 2, 0.01], + pupilFollow: [0.8, 0, 1, 0.01], flameSpeed: [0.8, 0, 2, 0.01], }, }), - { id: "evil-eye-button-v2" }, + { id: "evil-eye-button-v3" }, ); return ( - - {p.label} - + + + {p.label} + + ); } @@ -161,9 +174,11 @@ export function AquaButtonPreview() { ); return ( - - {p.label} - + + + {p.label} + + ); } @@ -178,49 +193,58 @@ export function FrameButtonPreview() { default: "default", }, glow: false, - size: [20, 8, 40], - offset: [7.5, 0, 20, 0.5], - hoverOffset: [7, 0, 20, 0.5], + size: [14, 6, 32], + offset: [6, 0, 20, 0.5], + hoverOffset: [5, 0, 20, 0.5], }), { id: "frame-button" }, ); return ( - - {p.label} - + + + {p.label} + + ); } export function HighlightButtonPreview() { - const p = useThemedDialKit( + const p = useDialKit( "HighlightButton", - (isDark) => { - const colors = isDark ? themeColors.dark : themeColors.light; - return { - label: "Send", - highlightColor: colors.foreground, - highlightSize: [56, 20, 120], - borderColor: colors.foreground, - }; + { + label: "Send", + variant: { + type: "select", + options: ["default", "secondary", "outline"], + default: "default", + }, + highlightSize: [90, 30, 200], + // Off = the component's currentColor-derived defaults. + customColors: false, + highlightColor: { type: "color", default: "#a3a3a3" }, + borderColor: { type: "color", default: "#e5e5e5" }, }, - { id: "highlight-button-v2" }, + { id: "highlight-button-v3" }, ); return ( - - {p.label} - + + + {p.label} + + ); } @@ -229,20 +253,33 @@ export function ConfettiButtonPreview() { "ConfettiButton", () => ({ label: "Celebrate", - particleCount: [120, 20, 300], - spread: [72, 20, 180], - startVelocity: [38, 10, 80], + particleCount: [80, 20, 300], + spread: [64, 20, 180], + startVelocity: [32, 10, 80], + icon: true, + colors: colorFolder([ + "#fafafa", + "#d4d4d4", + "#a3a3a3", + "#93c5fd", + "#c4b5fd", + "#fcd34d", + ]), }), { id: "confetti-button" }, ); return ( - + + + ); } @@ -250,20 +287,35 @@ export function AshBurstButtonPreview() { const p = useThemedDialKit( "AshBurstButton", () => ({ - label: "Destroy", - particleCount: [96, 24, 180], - spread: [120, 50, 180], - startVelocity: [48, 20, 90], + label: "Delete", + particleCount: [80, 24, 180], + spread: [110, 50, 180], + startVelocity: [42, 20, 90], + icon: true, + colors: colorFolder([ + "#171717", + "#262626", + "#404040", + "#525252", + "#737373", + "#a3a3a3", + "#b91c1c", + "#f97316", + ]), }), { id: "ash-burst-button" }, ); return ( - + + + ); } \ No newline at end of file diff --git a/components/landing/showcase-carousel.tsx b/components/landing/showcase-carousel.tsx new file mode 100644 index 0000000..5a1edb0 --- /dev/null +++ b/components/landing/showcase-carousel.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { ArrowUpRight, Check, Copy } from "@phosphor-icons/react"; +import { + AnimatePresence, + motion, + useReducedMotion, + type Variants, +} from "motion/react"; +import Link from "next/link"; +import { + type PointerEvent, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { packageCommands } from "@/components/cli-block"; +import { FitToContainer } from "@/components/landing/fit-to-container"; +import { ShowcasePreview } from "@/components/landing/showcase-preview"; +import { showcase, type ShowcaseEntry } from "@/components/landing/showcase"; +import { type PackageManager, useConfig } from "@/hooks/use-config"; +import { trackOutcome } from "@/lib/tracwell"; +import { cn } from "@/lib/utils"; + +/** How long the carousel sits untouched before rotating. */ +const IDLE_MS = 3500; + +/** Horizontal travel, in px, that turns a drag into a step. */ +const SWIPE_PX = 40; + +/** Pointers landing on these drive the button itself, so they never swipe. */ +const INTERACTIVE = + 'button:not([data-carousel-step]), a, input, textarea, select, [role="slider"], [role="button"]'; + +const ease = [0.32, 0.72, 0, 1] as const; + +/** + * Slot offsets from the center card: 0 main, ±1 before/after, ±2 the blurred + * outer pair. ±3 are the off-stage spots cards enter from and exit to. + * Together they sit on the face of a cylinder: each step out turns the card + * further away (`rotate`, degrees) and pushes it back (`z`, px), so the + * stage's perspective shrinks the far edges. `x` is in card widths. + */ +const slots = [ + { x: 0, z: 0, rotate: 0, scale: 1, opacity: 1, blur: 0 }, + // Scales are matched so each card's inner edge is as tall as its + // neighbour's facing edge, keeping the top and bottom one smooth arc. + { x: 0.93, z: -140, rotate: 34, scale: 1, opacity: 1, blur: 0 }, + // Kept shallow: seen from off-axis, perspective adds ~25° of apparent turn, + // and anything much steeper collapses to a sliver. + { x: 1.54, z: -300, rotate: 46, scale: 0.86, opacity: 1, blur: 1.5 }, + { x: 2.12, z: -560, rotate: 58, scale: 0.78, opacity: 0, blur: 4 }, +]; + +/** + * Narrow cards sit under the same perspective as wide ones, which pulls their + * neighbours in until the gaps close, so phones spread the slots a touch. + */ +const NARROW_SPREAD = 1.08; +const narrowQuery = "(max-width: 639px)"; + +function useSpread() { + const narrow = useSyncExternalStore( + (onChange) => { + const query = window.matchMedia(narrowQuery); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, + () => window.matchMedia(narrowQuery).matches, + () => false, + ); + return narrow ? NARROW_SPREAD : 1; +} + +function slotStyle(offset: number, spread = 1) { + const distance = Math.abs(offset); + const side = Math.sign(offset); + const slot = slots[distance]; + return { + x: `${side * slot.x * spread * 100}%`, + z: slot.z, + // Turns each side card away from the center, so its inner edge stays + // nearest the viewer and the stage bulges outward like a drum. + rotateY: side * slot.rotate, + scale: slot.scale, + opacity: slot.opacity, + filter: `blur(${slot.blur}px)`, + zIndex: 10 - distance, + }; +} + +type Motion = { direction: number; spread: number }; + +const cardVariants: Variants = { + // `direction` is +1 when the row shifts left (next), -1 when it shifts right. + enter: ({ direction, spread }: Motion) => slotStyle(3 * direction, spread), + exit: ({ direction, spread }: Motion) => slotStyle(-3 * direction, spread), +}; + +function wrap(index: number) { + return ((index % showcase.length) + showcase.length) % showcase.length; +} + +function VariantPicker({ + variants, + value, + onChange, +}: { + variants: readonly string[]; + value: string; + onChange: (variant: string) => void; +}) { + return ( +
+ {variants.map((variant) => ( + + ))} +
+ ); +} + +const actionPill = + "inline-flex h-7 items-center gap-1.5 rounded-full border border-border bg-background/80 px-3 text-xs font-medium text-muted-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_4px_12px_-4px_rgb(0_0_0/0.12)] backdrop-blur-md transition-[color,transform,box-shadow] hover:-translate-y-px hover:text-foreground hover:shadow-[0_1px_2px_rgb(0_0_0/0.05),0_8px_16px_-6px_rgb(0_0_0/0.18)] active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +function ShowcaseCard({ + item, + category, + packageManager, +}: { + item: ShowcaseEntry; + category?: string; + packageManager: PackageManager; +}) { + const [variant, setVariant] = useState(item.variants?.[0]); + const [copied, setCopied] = useState(false); + const copiedTimeout = useRef(undefined); + + useEffect(() => () => window.clearTimeout(copiedTimeout.current), []); + + const copy = async () => { + const command = `${packageCommands[packageManager]} @evilbuttons/${item.registryName}`; + try { + await navigator.clipboard.writeText(command); + } catch { + return; + } + setCopied(true); + trackOutcome("install_command_copied", { + source: "landing_carousel", + registry_name: item.registryName, + package_manager: packageManager, + }); + window.clearTimeout(copiedTimeout.current); + copiedTimeout.current = window.setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+

{item.name}

+
+
+
+ {category ? ( + + {category} + + ) : null} + {item.variants && variant ? ( +
+ +
+ ) : null} + + + +
+ + Docs + + + +
+
+
+
+ ); +} + +/** Soft blurred light behind the stage: brand red in the middle, foreground at the sides. */ +function GlowBackdrop() { + return ( + + + + + + + + + + + + + ); +} + +/** + * Five live cards — two before, the main one, two after. Left idle, the row drifts right: + * the main card shrinks into the after slot, the before card grows into the + * middle, and the next one slides in behind it. Hovering or focusing the + * stage holds the rotation; only the main card is interactive. + */ +export function ShowcaseCarousel({ + categories, +}: { + categories: Record; +}) { + const [{ active, direction }, setState] = useState({ + active: 0, + direction: -1, + }); + const [held, setHeld] = useState(false); + const reducedMotion = useReducedMotion(); + const { packageManager } = useConfig(); + const spread = useSpread(); + const custom: Motion = { direction, spread }; + const autoplay = !held && !reducedMotion; + + const step = (delta: number) => + setState((prev) => ({ active: wrap(prev.active + delta), direction: Math.sign(delta) })); + + const swipeStart = useRef<{ x: number; y: number } | null>(null); + // Set when a drag ends on a side card, so its click doesn't step again. + const swiped = useRef(false); + + const onPointerDown = (event: PointerEvent) => { + swiped.current = false; + if (event.button !== 0 || (event.target as Element).closest(INTERACTIVE)) return; + swipeStart.current = { x: event.clientX, y: event.clientY }; + }; + + const onPointerUp = (event: PointerEvent) => { + const start = swipeStart.current; + swipeStart.current = null; + if (!start) return; + const dx = event.clientX - start.x; + const dy = event.clientY - start.y; + if (Math.abs(dx) < SWIPE_PX || Math.abs(dx) < Math.abs(dy)) return; + swiped.current = true; + step(dx < 0 ? 1 : -1); + }; + + // Restarts on every move, so the idle clock always counts from the last one. + useEffect(() => { + if (!autoplay) return; + const timeout = window.setTimeout(() => step(-1), IDLE_MS); + return () => window.clearTimeout(timeout); + }, [autoplay, active]); + + const visible = [-2, -1, 0, 1, 2].map((offset) => ({ + offset, + item: showcase[wrap(active + offset)], + })); + + return ( +
+ +
setHeld(true)} + onPointerLeave={() => setHeld(false)} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + onPointerCancel={() => (swipeStart.current = null)} + onFocus={() => setHeld(true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setHeld(false); + }} + className="relative mx-auto grid h-full w-[min(30rem,calc(100vw-3rem))] touch-pan-y grid-rows-[minmax(0,1fr)] items-center select-none [perspective:1400px]" + > + + {visible.map(({ offset, item }) => { + const main = offset === 0; + + return ( + +
+ +
+ {!main ? ( +
+
+ ); +} diff --git a/components/landing/showcase-preview.tsx b/components/landing/showcase-preview.tsx new file mode 100644 index 0000000..d8ef5bf --- /dev/null +++ b/components/landing/showcase-preview.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { AquaButton, type AquaButtonVariant } from "@/components/evil-buttons/aqua-button"; +import { FrameButton } from "@/components/evil-buttons/frame-button"; +import { HighlightButton } from "@/components/evil-buttons/highlight-button"; +import MoviePassButton, { + type MoviePassVariant, +} from "@/components/evil-buttons/movie-pass"; +import { + RevealButton, + type RevealButtonVariant, +} from "@/components/evil-buttons/reveal-button"; +import { + SlideToDetonate, + type SlideToDetonateVariant, +} from "@/components/evil-buttons/slide-to-detonate"; +import { ButtonPreview } from "@/components/landing/previews"; + +type OutlineVariant = "default" | "secondary" | "outline"; + +/** + * Landing-card preview: the static docs preview, re-rendered with the picked + * `variant` for the components that have one. + */ +export function ShowcasePreview({ + registryName, + variant, +}: { + registryName: string; + variant?: string; +}) { + switch (registryName) { + case "reveal-button": + return ( + + ); + case "aqua-button": + return ( + Deploy Doom + ); + case "frame-button": + return Deploy; + case "highlight-button": + return ( + Send + ); + case "movie-pass": + return ( + + Admit One + + ); + case "slide-to-detonate": + return ( + + ); + default: + return ; + } +} diff --git a/components/landing/showcase.ts b/components/landing/showcase.ts index f0668a2..31f5c6f 100644 --- a/components/landing/showcase.ts +++ b/components/landing/showcase.ts @@ -2,10 +2,12 @@ export type ShowcaseEntry = { name: string; href: string; registryName: string; + /** Values of the component's `variant` prop, default first. */ + variants?: readonly string[]; }; export const showcase: ShowcaseEntry[] = [ - { name: "RevealButton", href: "/docs/reveal-button", registryName: "reveal-button" }, + { name: "RevealButton", href: "/docs/reveal-button", registryName: "reveal-button", variants: ["dark", "light"] }, { name: "CommandButton", href: "/docs/command-button", registryName: "command-button" }, { name: "CopyButton", href: "/docs/copy-button", registryName: "copy-button" }, { name: "ClickPowerUp", href: "/docs/click-power-up", registryName: "click-powerup" }, @@ -13,27 +15,24 @@ export const showcase: ShowcaseEntry[] = [ { name: "HoldButton", href: "/docs/hold-button", registryName: "hold-button" }, { name: "DemonicButton", href: "/docs/demonic-button", registryName: "demonic-button" }, { name: "EvilEyeButton", href: "/docs/evil-eye-button", registryName: "evil-eye-button" }, - { name: "AquaButton", href: "/docs/aqua-button", registryName: "aqua-button" }, + { name: "AquaButton", href: "/docs/aqua-button", registryName: "aqua-button", variants: ["primary", "secondary"] }, { name: "BrutalButton", href: "/docs/brutal-button", registryName: "brutal-button" }, { name: "ChromeButton", href: "/docs/chrome-button", registryName: "chrome-button" }, - { name: "FrameButton", href: "/docs/frame-button", registryName: "frame-button" }, + { name: "FrameButton", href: "/docs/frame-button", registryName: "frame-button", variants: ["default", "secondary", "outline"] }, { name: "GlitchButton", href: "/docs/glitch-button", registryName: "glitch-button" }, - { name: "GridButton", href: "/docs/grid-button", registryName: "grid-button" }, - { name: "HighlightButton", href: "/docs/highlight-button", registryName: "highlight-button" }, + { name: "HighlightButton", href: "/docs/highlight-button", registryName: "highlight-button", variants: ["default", "secondary", "outline"] }, { name: "MinimalButton", href: "/docs/minimal-button", registryName: "minimal" }, - { name: "MoviePassButton", href: "/docs/movie-pass", registryName: "movie-pass" }, + { name: "MoviePassButton", href: "/docs/movie-pass", registryName: "movie-pass", variants: ["tilt", "snap"] }, { name: "ShinyButton", href: "/docs/shiny-button", registryName: "shiny-button" }, { name: "StickyButton", href: "/docs/sticky-button", registryName: "sticky" }, { name: "ThreeDButton", href: "/docs/3d-button", registryName: "3d-button" }, { name: "TrollButton", href: "/docs/troll-button", registryName: "troll-button" }, - { name: "CaptchaButton", href: "/docs/captcha-button", registryName: "captcha-button" }, { name: "DoubtButton", href: "/docs/doubt-button", registryName: "doubt-button" }, - { name: "SlideToDetonate", href: "/docs/slide-to-detonate", registryName: "slide-to-detonate" }, + { name: "SlideToDetonate", href: "/docs/slide-to-detonate", registryName: "slide-to-detonate", variants: ["dark", "light"] }, { name: "MorphStatusButton", href: "/docs/morph-status-button", registryName: "morph-status-button" }, { name: "CooldownButton", href: "/docs/cooldown-button", registryName: "cooldown-button" }, - { name: "PillButton", href: "/docs/pill-button", registryName: "pill-button" }, + { name: "RealisticSwitch", href: "/docs/realistic-switch", registryName: "realistic-switch" }, { name: "ConfettiButton", href: "/docs/confetti-button", registryName: "confetti-button" }, - { name: "HoldConfirmButton", href: "/docs/hold-confirm-button", registryName: "hold-confirm-button" }, - { name: "DontPressButton", href: "/docs/dont-press-button", registryName: "dont-press-button" }, { name: "AshBurstButton", href: "/docs/ash-burst-button", registryName: "ash-burst-button" }, + { name: "MinecraftButton", href: "/docs/minecraft-button", registryName: "minecraft-button" }, ]; \ No newline at end of file diff --git a/components/landing/stats-section.tsx b/components/landing/stats-section.tsx new file mode 100644 index 0000000..89a80f7 --- /dev/null +++ b/components/landing/stats-section.tsx @@ -0,0 +1,100 @@ +import { ArrowUpRightIcon, GithubLogoIcon } from "@phosphor-icons/react/dist/ssr"; +import { showcase } from "@/components/landing/showcase"; +import { TracwellCard } from "@/components/tracwell-card"; +import type { LandingStats } from "@/lib/landing-stats"; +import { siteConfig } from "@/lib/seo"; +import { cn } from "@/lib/utils"; + +const compact = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}); + +type Stat = { label: string; value: number; href?: string }; + +const lgColumns: Record = { + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", +}; + +export function StatsSection({ stats }: { stats: LandingStats }) { + // Numbers that couldn't be fetched are left out rather than shown as zero. + const tiles: Stat[] = []; + if (stats.stars !== null) { + tiles.push({ label: "GitHub stars", value: stats.stars, href: siteConfig.github }); + } + tiles.push({ label: "Components", value: showcase.length }); + if (stats.visitors !== null) { + tiles.push({ label: "Visitors, last 30 days", value: stats.visitors }); + } + if (stats.installsCopied !== null) { + tiles.push({ label: "Installs copied, last 30 days", value: stats.installsCopied }); + } + + return ( +
+
+
+
+

+ In the open +

+

+ Built in public, measured in public. +

+
+ + + Star on GitHub + +
+ +
*:last-child:nth-child(odd)]:col-span-2 lg:[&>*]:col-span-1!", + lgColumns[tiles.length], + )} + > + {tiles.map((tile) => ( +
+
+ {tile.label} + {tile.href ? ( + + ) : null} +
+
+ {compact.format(tile.value)} +
+ {tile.href ? ( + + ) : null} +
+ ))} +
+ +
+ +
+
+
+ ); +} diff --git a/components/live-preview.tsx b/components/live-preview.tsx new file mode 100644 index 0000000..bfa3be0 --- /dev/null +++ b/components/live-preview.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { ArrowCounterClockwise, Check, Copy } from "@phosphor-icons/react"; +import { DialRoot, DialStore } from "dialkit"; +import "dialkit/styles.css"; +import { useCallback, useRef, useState, type ReactNode } from "react"; +import { DocsFrame } from "@/components/docs-frame"; +import { ButtonPreview, hasDialPreview } from "@/components/landing/previews"; +import { + LivePropsProvider, + toJsx, +} from "@/components/landing/previews/live-props"; +import { useAppTheme } from "@/hooks/use-app-theme"; + +export type LiveSource = { + registryName: string; + installCommand: string; + /** The component import from the page's Usage snippet. */ + importLine: string; + docsUrl: string; +}; + +type LivePreviewProps = { + source: LiveSource; + /** Rendered instead when the item has no dial-backed preview. */ + fallback: ReactNode; +}; + +function componentName(importLine: string) { + return importLine.match(/^import\s+(?:\{\s*)?(\w+)/)?.[1] ?? "Button"; +} + +function buildPrompt(source: LiveSource, props: Record) { + const name = componentName(source.importLine); + + return `Add the Evil Buttons ${name} component to this project, configured exactly as below. + +1. Install it with the shadcn CLI: + +${source.installCommand} + +2. Import it where it is needed: + +${source.importLine} + +3. Render it with these props: + +${toJsx(name, props)} + +Keep every prop value as listed. Wire any event callbacks (onClick, onConfirm, and so on) to the real action at the call site. Docs: ${source.docsUrl} +`; +} + +const toolbarButton = + "inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"; + +/** + * Docs preview wired to DialKit: the button on top, its props as live + * controls underneath, with reset and copy-as-agent-prompt actions. + * Remounts on theme change so theme-aware defaults (colors) reset to the + * matching palette. + */ +export function LivePreview({ source, fallback }: LivePreviewProps) { + const theme = useAppTheme(); + const propsRef = useRef>({}); + const [copied, setCopied] = useState(false); + const copiedTimeout = useRef(undefined); + + const report = useCallback((props: Record) => { + propsRef.current = props; + }, []); + + const reset = () => { + for (const panel of DialStore.getPanels("panel")) { + DialStore.resetValues(panel.id); + } + }; + + const copyPrompt = async () => { + try { + await navigator.clipboard.writeText( + buildPrompt(source, propsRef.current), + ); + } catch { + return; + } + setCopied(true); + window.clearTimeout(copiedTimeout.current); + copiedTimeout.current = window.setTimeout(() => setCopied(false), 1500); + }; + + if (!hasDialPreview(source.registryName)) { + return ( + + {fallback} + + ); + } + + return ( + +
+ + + +
+
+
+

Props

+
+ + +
+
+ +
+
+ ); +} diff --git a/components/mdx-custom-components.tsx b/components/mdx-custom-components.tsx index e03917e..36cdd8e 100644 --- a/components/mdx-custom-components.tsx +++ b/components/mdx-custom-components.tsx @@ -7,7 +7,6 @@ import type { MDXComponents } from "mdx/types"; import { isValidElement, type ReactNode } from "react"; import MoviePassButton from "./evil-buttons/movie-pass"; import ShinyButton from "./evil-buttons/shiny-button"; -import GridButton from "./evil-buttons/grid-button"; import DitherButton from "./evil-buttons/dither-button"; import EvilEyeButton from "./evil-buttons/evil-eye-button"; import TrollButton from "./evil-buttons/troll-button"; @@ -23,16 +22,14 @@ 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 { CaptchaButton } from "./evil-buttons/captcha-button"; import { DoubtButton } from "./evil-buttons/doubt-button"; import { SlideToDetonate } from "./evil-buttons/slide-to-detonate"; import { MorphStatusButton } from "./evil-buttons/morph-status-button"; import { CooldownButton } from "./evil-buttons/cooldown-button"; -import { PillButton } from "./evil-buttons/pill-button"; +import { RealisticSwitch } from "./evil-buttons/realistic-switch"; import { ConfettiButton } from "./evil-buttons/confetti-button"; -import { HoldConfirmButton } from "./evil-buttons/hold-confirm-button"; -import { DontPressButton } from "./evil-buttons/dont-press-button"; import { AshBurstButton } from "./evil-buttons/ash-burst-button"; +import { MinecraftButton } from "./evil-buttons/minecraft-button"; import { MorphStatusButtonDemo, MorphStatusButtonFailDemo, @@ -106,7 +103,6 @@ export function getCustomMDXComponents(): MDXComponents { MoviePassButton, MinimalButton, EvilButton: ClickPowerUp, - GridButton, DitherButton, EvilEyeButton, Link, @@ -123,17 +119,15 @@ export function getCustomMDXComponents(): MDXComponents { RevealButton, DemonicButton, HoldButton, - CaptchaButton, DoubtButton, SlideToDetonate, MorphStatusButton, MorphStatusButtonDemo, MorphStatusButtonFailDemo, CooldownButton, - PillButton, + RealisticSwitch, ConfettiButton, - HoldConfirmButton, - DontPressButton, AshBurstButton, + MinecraftButton, }; } diff --git a/components/mdx.tsx b/components/mdx.tsx index 7164a3c..ec51eef 100644 --- a/components/mdx.tsx +++ b/components/mdx.tsx @@ -7,7 +7,7 @@ import { import type { MDXComponents } from "mdx/types"; import { getCustomMDXComponents } from "@/components/mdx-custom-components"; import CopyButton from "@/components/copy-button"; -import { getIconForLanguageExtension } from "@/assets/language/icons"; +import { DocsFrame } from "@/components/docs-frame"; function cn(...values: Array) { return values.filter(Boolean).join(" "); @@ -35,29 +35,35 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { return { h1: ({ className, ...props }: ComponentPropsWithoutRef<"h1">) => (

), h2: ({ className, ...props }: ComponentPropsWithoutRef<"h2">) => (

), h3: ({ className, ...props }: ComponentPropsWithoutRef<"h3">) => ( -

+

), p: ({ className, ...props }: ComponentPropsWithoutRef<"p">) => ( -

+

), a: ({ className, children, ...props }: ComponentPropsWithoutRef<"a">) => ( ), ul: ({ className, ...props }: ComponentPropsWithoutRef<"ul">) => ( -