diff --git a/app/globals.css b/app/globals.css index adffb0d..4c9d2b1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -251,3 +251,97 @@ html.dark .docs-content .shiki span { font-weight: var(--shiki-dark-font-weight) !important; text-decoration: var(--shiki-dark-text-decoration) !important; } + +/* DialKit — match Evil Buttons page tokens and sharp corners */ +.dialkit-root { + --dial-radius: 0; + --dial-panel-offset-top: 5.5rem; + font-family: var(--font-sans), system-ui, sans-serif; +} + +.dialkit-panel[data-position="top-left"] { + top: var(--dial-panel-offset-top) !important; + left: 1.5rem !important; +} + +.dialkit-panel[data-position="bottom-left"] { + bottom: 3.75rem !important; + left: 1rem !important; + max-width: calc(100vw - 2rem); +} + +.dialkit-panel[data-position="bottom-left"] .dialkit-panel-inner { + max-width: min(280px, calc(100vw - 2rem)); +} + +.dialkit-root[data-theme="light"] { + --dial-surface: color-mix(in oklab, var(--foreground) 4%, transparent); + --dial-surface-hover: color-mix(in oklab, var(--foreground) 8%, transparent); + --dial-surface-active: color-mix(in oklab, var(--foreground) 10%, transparent); + --dial-surface-subtle: color-mix(in oklab, var(--foreground) 6%, transparent); + --dial-text-root: var(--foreground); + --dial-text-section: var(--muted-foreground); + --dial-text-label: var(--muted-foreground); + --dial-text-focus: var(--foreground); + --dial-text-primary: var(--foreground); + --dial-text-secondary: var(--muted-foreground); + --dial-text-tertiary: color-mix(in oklab, var(--muted-foreground) 70%, transparent); + --dial-border: var(--border); + --dial-border-hover: color-mix(in oklab, var(--foreground) 18%, transparent); + --dial-glass-bg: var(--popover); + --dial-dropdown-bg: var(--popover); + --dial-backdrop-blur: 0px; + --dial-shadow: 0 1px 0 var(--border); + --dial-shadow-collapsed: 0 1px 0 var(--border); + --dial-shadow-dropdown: 0 8px 24px color-mix(in oklab, var(--foreground) 12%, transparent); +} + +.dialkit-root[data-theme="dark"] { + --dial-surface: color-mix(in oklab, var(--foreground) 6%, transparent); + --dial-surface-hover: color-mix(in oklab, var(--foreground) 10%, transparent); + --dial-surface-active: color-mix(in oklab, var(--foreground) 12%, transparent); + --dial-surface-subtle: color-mix(in oklab, var(--foreground) 8%, transparent); + --dial-text-root: var(--foreground); + --dial-text-section: var(--muted-foreground); + --dial-text-label: var(--muted-foreground); + --dial-text-focus: var(--foreground); + --dial-text-primary: var(--foreground); + --dial-text-secondary: var(--muted-foreground); + --dial-text-tertiary: color-mix(in oklab, var(--muted-foreground) 70%, transparent); + --dial-border: var(--border); + --dial-border-hover: color-mix(in oklab, var(--foreground) 22%, transparent); + --dial-glass-bg: var(--popover); + --dial-dropdown-bg: var(--popover); + --dial-backdrop-blur: 0px; + --dial-shadow: 0 1px 0 var(--border); + --dial-shadow-collapsed: 0 1px 0 var(--border); + --dial-shadow-dropdown: 0 12px 32px color-mix(in oklab, black 45%, transparent); +} + +.dialkit-panel-inner { + border-radius: 0 !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + padding-bottom: 12px !important; +} + +.dialkit-root + .dialkit-folder-root + > .dialkit-folder-content + > .dialkit-folder-inner { + padding-bottom: 8px !important; +} + +.dialkit-root .dialkit-panel-inner[data-collapsed="true"] { + border-radius: 0 !important; +} + +.dialkit-root .dialkit-toggle-thumb, +.dialkit-root .dialkit-shortcuts-row, +.dialkit-root .dialkit-shortcuts-row-key, +.dialkit-root .dialkit-select-trigger, +.dialkit-root .dialkit-select-content, +.dialkit-root .dialkit-preset-item, +.dialkit-root .dialkit-color-swatch { + border-radius: 0 !important; +} diff --git a/app/layout.tsx b/app/layout.tsx index 4e1cd17..4998583 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,6 @@ import { Geist, Geist_Mono, Inter, Doto } from "next/font/google"; +import { AppDialRoot } from "@/components/dial-root"; +import "dialkit/styles.css"; import "./globals.css"; import { JsonLd } from "@/components/seo/json-ld"; import { cn } from "@/lib/utils"; @@ -56,6 +58,7 @@ export default function RootLayout({ {children} + ); diff --git a/components/dial-root.tsx b/components/dial-root.tsx new file mode 100644 index 0000000..8652f12 --- /dev/null +++ b/components/dial-root.tsx @@ -0,0 +1,17 @@ +"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/landing/landing-page.tsx b/components/landing/landing-page.tsx index a2bf650..a26e9a2 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -1,375 +1,106 @@ "use client"; -import { DeferredMount } from "@/components/landing/deferred-mount"; import { FitToContainer } from "@/components/landing/fit-to-container"; +import { ButtonPreview } from "@/components/landing/previews"; +import { showcase } from "@/components/landing/showcase"; import { ThemeSync } from "@/components/theme-sync"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { useAppTheme } from "@/hooks/use-app-theme"; import { siteConfig } from "@/lib/seo"; -import { ArrowUpRight, Copy } from "@phosphor-icons/react"; +import { ArrowUpRight } from "@phosphor-icons/react"; import Link from "next/link"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; -import { AquaButton } from "@/components/evil-buttons/aqua-button"; -import { BrutalButton } from "@/components/evil-buttons/brutal-button"; -import { CaptchaButton } from "@/components/evil-buttons/captcha-button"; -import ChromeButton from "@/components/evil-buttons/chrome-button"; -import { ClickPowerUp } from "@/components/evil-buttons/click-powerup"; -import { CommandButton } from "@/components/evil-buttons/command-button"; -import { CooldownButton } from "@/components/evil-buttons/cooldown-button"; -import { CopyButton } from "@/components/evil-buttons/copy-button"; -import { DoubtButton } from "@/components/evil-buttons/doubt-button"; -import DitherButton from "@/components/evil-buttons/dither-button"; -import EvilEyeButton from "@/components/evil-buttons/evil-eye-button"; -import { FrameButton } from "@/components/evil-buttons/frame-button"; -import GlitchButton from "@/components/evil-buttons/glitch-button"; -import GridButton from "@/components/evil-buttons/grid-button"; -import { HighlightButton } from "@/components/evil-buttons/highlight-button"; -import { HoldButton } from "@/components/evil-buttons/hold-button"; -import MinimalButton from "@/components/evil-buttons/minimal"; -import { MorphStatusButton } from "@/components/evil-buttons/morph-status-button"; -import MoviePassButton from "@/components/evil-buttons/movie-pass"; -import { RevealButton } from "@/components/evil-buttons/reveal-button"; -import ShinyButton from "@/components/evil-buttons/shiny-button"; -import { SlideToDetonate } from "@/components/evil-buttons/slide-to-detonate"; -import StickyButton from "@/components/evil-buttons/sticky"; -import { ThreeDButton } from "@/components/evil-buttons/3d-button"; -import TrollButton from "@/components/evil-buttons/troll-button"; -import { DemonicButton } from "../evil-buttons/demonic-button"; -import { PillButton } from "@/components/evil-buttons/pill-button"; -import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; -import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; - -type ButtonShowcase = { - name: string; - href: string; - registryName: string; - render: () => ReactNode; -}; +export function LandingPage() { + const theme = useAppTheme(); + const [selected, setSelected] = useState(showcase[0].registryName); + const active = + showcase.find((item) => item.registryName === selected) ?? showcase[0]; -// 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 }) { 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: () => , - }, -]; - -function ButtonCell({ item }: { item: ButtonShowcase }) { - const [copied, setCopied] = useState(false); - const timerRef = useRef(null); - - useEffect(() => { - return () => { - if (timerRef.current !== null) clearTimeout(timerRef.current); - }; - }, []); - - const command = `npx shadcn@latest add @evilbuttons/${item.registryName}`; - - async function handleCopy(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - await navigator.clipboard.writeText(command); - setCopied(true); - if (timerRef.current !== null) clearTimeout(timerRef.current); - timerRef.current = window.setTimeout(() => setCopied(false), 2000); - } +
+ - return ( -
-
- {item.name} -
-
- {item.render()} -
-
+
- - Docs + {siteConfig.name} + + Evil Buttons + - -
-
- ); -} -export function LandingPage() { - return ( -
- +
+ + -
-
-
- {siteConfig.name} -

- Evil
Buttons -

-
+ Docs + -

- 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. -

-
- - Browse Docs - - - GitHub - -
-
- -
-

- {showcase.length} Components -

-

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

-
-
- {showcase.map((item) => ( - - ))} -
+
+ + +
+ +
+

+ {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 new file mode 100644 index 0000000..f659df0 --- /dev/null +++ b/components/landing/previews/index.tsx @@ -0,0 +1,78 @@ +"use client"; + +import type { ComponentType } from "react"; +import { + RevealButtonPreview, + HoldButtonPreview, + HoldConfirmButtonPreview, + SlideToDetonatePreview, + DoubtButtonPreview, + CaptchaButtonPreview, + CooldownButtonPreview, + MorphStatusButtonPreview, +} from "./interaction"; +import { + BrutalButtonPreview, + DitherButtonPreview, + GlitchButtonPreview, + EvilEyeButtonPreview, + AquaButtonPreview, + FrameButtonPreview, + HighlightButtonPreview, + ConfettiButtonPreview, +} from "./visual"; +import { + CommandButtonPreview, + CopyButtonPreview, + ClickPowerUpPreview, + PillButtonPreview, +} from "./utility"; +import { + DemonicButtonPreview, + ChromeButtonPreview, + GridButtonPreview, + MinimalButtonPreview, + MoviePassButtonPreview, + ShinyButtonPreview, + StickyButtonPreview, + ThreeDButtonPreview, + TrollButtonPreview, +} from "./simple"; + +const buttonPreviews: Record = { + "reveal-button": RevealButtonPreview, + "command-button": CommandButtonPreview, + "copy-button": CopyButtonPreview, + "click-powerup": ClickPowerUpPreview, + "dither-button": DitherButtonPreview, + "hold-button": HoldButtonPreview, + "demonic-button": DemonicButtonPreview, + "evil-eye-button": EvilEyeButtonPreview, + "aqua-button": AquaButtonPreview, + "brutal-button": BrutalButtonPreview, + "chrome-button": ChromeButtonPreview, + "frame-button": FrameButtonPreview, + "glitch-button": GlitchButtonPreview, + "grid-button": GridButtonPreview, + "highlight-button": HighlightButtonPreview, + minimal: MinimalButtonPreview, + "movie-pass": MoviePassButtonPreview, + "shiny-button": ShinyButtonPreview, + 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, + "confetti-button": ConfettiButtonPreview, + "hold-confirm-button": HoldConfirmButtonPreview, +}; + +export function ButtonPreview({ registryName }: { registryName: string }) { + const Preview = buttonPreviews[registryName]; + if (!Preview) return null; + return ; +} \ No newline at end of file diff --git a/components/landing/previews/interaction.tsx b/components/landing/previews/interaction.tsx new file mode 100644 index 0000000..918c3e1 --- /dev/null +++ b/components/landing/previews/interaction.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useDialKit } from "dialkit"; +import { useThemedDialKit } 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"; + +export function RevealButtonPreview() { + const p = useDialKit( + "RevealButton", + { + label: "Reveal", + hiddenLabel: "Hidden", + maskedValue: "•••• •••• ••••", + secret: "sk_live_••••_9xQ4", + revealMode: { + type: "select", + options: ["hold", "toggle"], + default: "hold", + }, + }, + { id: "reveal-button" }, + ); + + return ( + + ); +} + +export function HoldButtonPreview() { + const p = useDialKit( + "HoldButton", + { + label: "Hold to delete", + holdingLabel: "Keep holding…", + successLabel: "Deleted", + duration: [1500, 500, 5000], + resetAfter: [1400, 0, 5000], + }, + { id: "hold-button" }, + ); + + 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 ( + + ); +} + +export function SlideToDetonatePreview() { + const p = useDialKit( + "SlideToDetonate", + { + label: "Slide to detonate", + successLabel: "Detonated", + threshold: [0.95, 0.5, 1, 0.01], + resetAfter: [1600, 0, 5000], + }, + { id: "slide-to-detonate" }, + ); + + return ( + + ); +} + +export function DoubtButtonPreview() { + const p = useDialKit( + "DoubtButton", + { + label: "Delete everything", + successLabel: "Too late.", + resetAfter: [1600, 0, 5000], + }, + { 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( + "CooldownButton", + { + label: "Send it", + cooldown: [3000, 1000, 10000], + showCountdown: true, + }, + { id: "cooldown-button" }, + ); + + return ( + + ); +} + +export function MorphStatusButtonPreview() { + const p = useDialKit( + "MorphStatusButton", + { + label: "Save changes", + loadingLabel: "Working…", + successLabel: "Done", + errorLabel: "It broke. Your fault.", + resetAfter: [1800, 0, 5000], + }, + { id: "morph-status-button" }, + ); + + return ( + new Promise((resolve) => setTimeout(resolve, 1200))} + /> + ); +} \ No newline at end of file diff --git a/components/landing/previews/shared.tsx b/components/landing/previews/shared.tsx new file mode 100644 index 0000000..76ee2e7 --- /dev/null +++ b/components/landing/previews/shared.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { DeferredMount } from "@/components/landing/deferred-mount"; +import type { ReactNode } from "react"; + +export function WebGLPlaceholder({ label }: { label: string }) { + return ( + + {label} + + ); +} + +export function DeferredWebGLPreview({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + }> + {children} + + ); +} \ No newline at end of file diff --git a/components/landing/previews/simple.tsx b/components/landing/previews/simple.tsx new file mode 100644 index 0000000..dd2a98c --- /dev/null +++ b/components/landing/previews/simple.tsx @@ -0,0 +1,99 @@ +"use client"; + +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 MinimalButton from "@/components/evil-buttons/minimal"; +import MoviePassButton 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 { DeferredWebGLPreview } from "./shared"; + +export function DemonicButtonPreview() { + const p = useDialKit( + "DemonicButton", + { label: "Currupt the World" }, + { id: "demonic-button" }, + ); + + return ; +} + +export function ChromeButtonPreview() { + const p = useDialKit( + "ChromeButton", + { label: "Chromy" }, + { id: "chrome-button" }, + ); + + return ( + + {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" }); + + return {p.label}; +} + +export function MoviePassButtonPreview() { + const p = useDialKit( + "MoviePassButton", + { label: "Deploy Doom" }, + { id: "movie-pass" }, + ); + + return {p.label}; +} + +export function ShinyButtonPreview() { + const p = useDialKit( + "ShinyButton", + { label: "Search" }, + { id: "shiny-button" }, + ); + + return {p.label}; +} + +export function StickyButtonPreview() { + const p = useDialKit( + "StickyButton", + { label: "Try to Click" }, + { id: "sticky" }, + ); + + return {p.label}; +} + +export function ThreeDButtonPreview() { + const p = useDialKit( + "ThreeDButton", + { label: "Continue" }, + { id: "3d-button" }, + ); + + return {p.label}; +} + +export function TrollButtonPreview() { + const p = useDialKit( + "TrollButton", + { label: "Click Me" }, + { id: "troll-button" }, + ); + + return {p.label}; +} \ No newline at end of file diff --git a/components/landing/previews/theme.ts b/components/landing/previews/theme.ts new file mode 100644 index 0000000..89a7ca6 --- /dev/null +++ b/components/landing/previews/theme.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useDialKit, type DialConfig, type UseDialOptions } from "dialkit"; +import { useIsDarkMode } from "@/hooks/use-app-theme"; + +export function useThemedDialKit( + name: string, + getConfig: (isDark: boolean) => T, + options?: UseDialOptions, +) { + const isDark = useIsDarkMode(); + return useDialKit(name, getConfig(isDark), options); +} + +// DialKit color controls require #RRGGBB hex — oklch() breaks the native picker. +export const themeColors = { + light: { + background: "#ffffff", + foreground: "#1a1a1a", + muted: "#f5f5f5", + primary: "#1a1a1a", + primaryForeground: "#fafafa", + border: "#e5e5e5", + accent: "#f5f5f5", + }, + dark: { + background: "#1a1a1a", + foreground: "#fafafa", + muted: "#404040", + primary: "#e5e5e5", + primaryForeground: "#1a1a1a", + border: "#333333", + accent: "#404040", + }, +} 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 diff --git a/components/landing/previews/utility.tsx b/components/landing/previews/utility.tsx new file mode 100644 index 0000000..2191b9d --- /dev/null +++ b/components/landing/previews/utility.tsx @@ -0,0 +1,91 @@ +"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"; + +export function CommandButtonPreview() { + const p = useDialKit( + "CommandButton", + { + label: "Save", + shortcut: "mod+s", + showShortcut: true, + preventDefault: true, + }, + { id: "command-button" }, + ); + + return ( + + {p.label} + + ); +} + +export function CopyButtonPreview() { + const p = useDialKit( + "CopyButton", + { + value: "npx evil-buttons@latest init", + copyLabel: "Copy", + copiedLabel: "Copied", + timeout: [1500, 500, 5000], + }, + { id: "copy-button" }, + ); + + return ( + + ); +} + +export function ClickPowerUpPreview() { + const p = useDialKit( + "ClickPowerUp", + { + label: "Doom", + tapDuration: [500, 200, 2000], + }, + { id: "click-powerup" }, + ); + + return {p.label}; +} + +export function PillButtonPreview() { + const p = useThemedDialKit( + "PillButton", + () => ({ + primaryLabel: "Off", + secondaryLabel: "On", + defaultOpen: false, + }), + { id: "pill-button" }, + ); + 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 new file mode 100644 index 0000000..2dfdb30 --- /dev/null +++ b/components/landing/previews/visual.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { BrutalButton } from "@/components/evil-buttons/brutal-button"; +import DitherButton from "@/components/evil-buttons/dither-button"; +import { GlitchButton } from "@/components/evil-buttons/glitch-button"; +import EvilEyeButton from "@/components/evil-buttons/evil-eye-button"; +import { AquaButton } from "@/components/evil-buttons/aqua-button"; +import { FrameButton } from "@/components/evil-buttons/frame-button"; +import { HighlightButton } from "@/components/evil-buttons/highlight-button"; +import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; +import { DeferredWebGLPreview } from "./shared"; +import { themeColors, useThemedDialKit } from "./theme"; + +export function BrutalButtonPreview() { + const p = useThemedDialKit( + "BrutalButton", + (isDark) => { + const colors = isDark ? themeColors.dark : themeColors.light; + return { + label: "Click Me", + color: colors.background, + textColor: colors.foreground, + borderColor: colors.foreground, + shadowColor: colors.foreground, + hasBorder: true, + hasShadow: true, + radius: [0, 0, 24], + }; + }, + { id: "brutal-button-v2" }, + ); + + return ( + + {p.label} + + ); +} + +export function DitherButtonPreview() { + const p = useThemedDialKit( + "DitherButton", + (isDark) => ({ + label: "Run It", + ditherColor: isDark ? "#a3a3a3" : "#737373", + ditherOpacity: [1, 0, 1, 0.01], + ditherSize: [4, 1, 32], + }), + { id: "dither-button" }, + ); + + return ( + + {p.label} + + ); +} + +export function GlitchButtonPreview() { + const p = useThemedDialKit( + "GlitchButton", + (isDark) => ({ + label: "Launch", + glitchInterval: [3500, 500, 8000], + glitchDuration: [450, 100, 1000], + channelA: isDark ? "#ef4444" : "#dc2626", + channelB: isDark ? "#22d3ee" : "#0891b2", + intensity: [1, 0, 3, 0.1], + trigger: { + type: "select", + options: ["auto", "hover", "always"], + default: "hover", + }, + scanlines: true, + }), + { id: "glitch-button" }, + ); + + return ( + + {p.label} + + ); +} + +export function EvilEyeButtonPreview() { + const p = useThemedDialKit( + "EvilEyeButton", + () => ({ + label: "Doom", + effectOpacity: [0.95, 0, 1, 0.01], + eye: { + eyeColor: "#ff6f37", + backgroundColor: "#000000", + intensity: [1.65, 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], + flameSpeed: [0.8, 0, 2, 0.01], + }, + }), + { id: "evil-eye-button-v2" }, + ); + + return ( + + + {p.label} + + + ); +} + +export function AquaButtonPreview() { + const p = useThemedDialKit( + "AquaButton", + () => ({ + label: "Deploy Doom", + variant: { + type: "select", + options: ["primary", "secondary"], + default: "primary", + }, + }), + { id: "aqua-button" }, + ); + + return ( + + {p.label} + + ); +} + +export function FrameButtonPreview() { + const p = useThemedDialKit( + "FrameButton", + () => ({ + label: "Deploy", + variant: { + type: "select", + options: ["default", "secondary", "outline"], + default: "default", + }, + glow: false, + size: [20, 8, 40], + offset: [7.5, 0, 20, 0.5], + hoverOffset: [7, 0, 20, 0.5], + }), + { id: "frame-button" }, + ); + + return ( + + {p.label} + + ); +} + +export function HighlightButtonPreview() { + const p = useThemedDialKit( + "HighlightButton", + (isDark) => { + const colors = isDark ? themeColors.dark : themeColors.light; + return { + label: "Send", + highlightColor: colors.foreground, + highlightSize: [56, 20, 120], + borderColor: colors.foreground, + }; + }, + { id: "highlight-button-v2" }, + ); + + return ( + + {p.label} + + ); +} + +export function ConfettiButtonPreview() { + const p = useThemedDialKit( + "ConfettiButton", + () => ({ + label: "Celebrate", + particleCount: [120, 20, 300], + spread: [72, 20, 180], + startVelocity: [38, 10, 80], + }), + { id: "confetti-button" }, + ); + + return ( + + ); +} \ No newline at end of file diff --git a/components/landing/showcase.ts b/components/landing/showcase.ts new file mode 100644 index 0000000..b92adcc --- /dev/null +++ b/components/landing/showcase.ts @@ -0,0 +1,37 @@ +export type ShowcaseEntry = { + name: string; + href: string; + registryName: string; +}; + +export const showcase: ShowcaseEntry[] = [ + { name: "RevealButton", href: "/docs/reveal-button", registryName: "reveal-button" }, + { 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" }, + { name: "DitherButton", href: "/docs/dither-button", registryName: "dither-button" }, + { 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: "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: "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: "MinimalButton", href: "/docs/minimal-button", registryName: "minimal" }, + { name: "MoviePassButton", href: "/docs/movie-pass", registryName: "movie-pass" }, + { 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: "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: "ConfettiButton", href: "/docs/confetti-button", registryName: "confetti-button" }, + { name: "HoldConfirmButton", href: "/docs/hold-confirm-button", registryName: "hold-confirm-button" }, +]; \ No newline at end of file diff --git a/components/theme-sync.tsx b/components/theme-sync.tsx index c827359..b2b7ae1 100644 --- a/components/theme-sync.tsx +++ b/components/theme-sync.tsx @@ -1,49 +1,28 @@ "use client"; import { useEffect } from "react"; +import { + applyTheme, + readPreferredTheme, + THEME_STORAGE_KEY, + toggleTheme, + type ThemePreference, +} from "@/lib/theme-preference"; -const THEME_STORAGE_KEY = "theme"; -const DARK_THEME = "dark"; -const LIGHT_THEME = "light"; const EDITABLE_SELECTOR = "input, textarea, select, [contenteditable='true']"; -function readPreferredTheme() { - const stored = localStorage.getItem(THEME_STORAGE_KEY); - - if (stored === DARK_THEME || stored === LIGHT_THEME) { - return stored; - } - - return window.matchMedia("(prefers-color-scheme: dark)").matches - ? DARK_THEME - : LIGHT_THEME; -} - -function applyTheme(theme: typeof DARK_THEME | typeof LIGHT_THEME) { - document.documentElement.classList.toggle("dark", theme === DARK_THEME); -} - export function ThemeSync() { useEffect(() => { applyTheme(readPreferredTheme()); const onKeyDown = (event: KeyboardEvent) => { - if (event.key.toLowerCase() !== "d") { - return; - } - + if (event.key.toLowerCase() !== "d") return; if ((event.target as HTMLElement | null)?.closest(EDITABLE_SELECTOR)) { return; } event.preventDefault(); - - const nextTheme = document.documentElement.classList.contains("dark") - ? LIGHT_THEME - : DARK_THEME; - - applyTheme(nextTheme); - localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + toggleTheme(); }; window.addEventListener("keydown", onKeyDown); @@ -52,3 +31,5 @@ export function ThemeSync() { return null; } + +export { THEME_STORAGE_KEY, type ThemePreference }; \ No newline at end of file diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000..792e686 --- /dev/null +++ b/components/theme-toggle.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Moon, Sun } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { useAppTheme } from "@/hooks/use-app-theme"; +import { toggleTheme } from "@/lib/theme-preference"; + +export function ThemeToggle() { + const theme = useAppTheme(); + const isDark = theme === "dark"; + + return ( + + ); +} \ No newline at end of file diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..f03ac34 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CaretDownIcon, CheckIcon, CaretUpIcon } from "@phosphor-icons/react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/hooks/use-app-theme.ts b/hooks/use-app-theme.ts new file mode 100644 index 0000000..7567ef7 --- /dev/null +++ b/hooks/use-app-theme.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +export type AppTheme = "light" | "dark"; + +function getThemeSnapshot(): AppTheme { + if (typeof document === "undefined") return "light"; + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +function subscribeToTheme(onStoreChange: () => void) { + if (typeof document === "undefined") return () => undefined; + + const observer = new MutationObserver(onStoreChange); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); +} + +export function useAppTheme(): AppTheme { + return useSyncExternalStore(subscribeToTheme, getThemeSnapshot, () => "light"); +} + +export function useIsDarkMode() { + return useAppTheme() === "dark"; +} \ No newline at end of file diff --git a/hooks/use-is-mobile.ts b/hooks/use-is-mobile.ts new file mode 100644 index 0000000..0d827fe --- /dev/null +++ b/hooks/use-is-mobile.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +const MOBILE_QUERY = "(max-width: 639px)"; + +function subscribe(onStoreChange: () => void) { + if (typeof window === "undefined") return () => undefined; + + const media = window.matchMedia(MOBILE_QUERY); + media.addEventListener("change", onStoreChange); + return () => media.removeEventListener("change", onStoreChange); +} + +function getSnapshot() { + return window.matchMedia(MOBILE_QUERY).matches; +} + +function getServerSnapshot() { + return false; +} + +export function useIsMobile() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} \ No newline at end of file diff --git a/lib/theme-preference.ts b/lib/theme-preference.ts new file mode 100644 index 0000000..b362436 --- /dev/null +++ b/lib/theme-preference.ts @@ -0,0 +1,26 @@ +export const THEME_STORAGE_KEY = "theme"; + +export type ThemePreference = "light" | "dark"; + +export function readPreferredTheme(): ThemePreference { + if (typeof window === "undefined") return "light"; + + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (stored === "dark" || stored === "light") return stored; + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +export function applyTheme(theme: ThemePreference) { + document.documentElement.classList.toggle("dark", theme === "dark"); +} + +export function toggleTheme() { + const nextTheme: ThemePreference = + document.documentElement.classList.contains("dark") ? "light" : "dark"; + + applyTheme(nextTheme); + localStorage.setItem(THEME_STORAGE_KEY, nextTheme); +} \ No newline at end of file diff --git a/package.json b/package.json index 039d4b0..1c4fcac 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dialkit": "^1.3.0", "fumadocs-core": "^16.8.5", "fumadocs-mdx": "^14.3.2", "motion": "^12.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9e5a7d..ff9f264 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dialkit: + specifier: ^1.3.0 + version: 1.3.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) fumadocs-core: specifier: ^16.8.5 version: 16.8.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) @@ -2333,6 +2336,30 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dialkit@1.3.0: + resolution: {integrity: sha512-//tnKhG+6gWhH3h9L7RpRPQZJkRBj0qQyUu1Og1l0VI6Nn/7iKaZZDFs/hG+Jsrtvwp4hlLrrmzj8M6hkXR3EA==} + peerDependencies: + motion: '>=11.0.0' + motion-v: '>=2.0.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + solid-js: '>=1.6.0' + svelte: '>=5.8.0' + vue: '>=3.3.0' + peerDependenciesMeta: + motion-v: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -6760,6 +6787,13 @@ snapshots: dependencies: dequal: 2.0.3 + dialkit@1.3.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + diff@8.0.4: {} doctrine@2.1.0: