"use client"; import { Slider as SliderPrimitive } from "@base-ui/react/slider"; import { CheckIcon, CopyIcon, RotateCcwIcon, SparklesIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type CSSProperties, type ReactNode, useEffect, useId, useRef, useState } from "react"; import { Slider } from "@/components/ui/slider"; import { cn } from "@/lib/utils"; export type Density = "compact" | "cozy" | "roomy"; export interface ThemeValue { hue: number; radius: number; density: Density; } export interface ThemePreset { name: string; hue: number; } export interface ThemeStudioProps { defaultValue?: Partial; presets?: ThemePreset[]; onChange?: (value: ThemeValue) => void; preview?: ReactNode; className?: string; } const DENSITY: Record = { compact: 0.8, cozy: 1, roomy: 1.25 }; const DEFAULT_PRESETS: ThemePreset[] = [ { name: "Indigo", hue: 268 }, { name: "Violet", hue: 300 }, { name: "Rose", hue: 12 }, { name: "Amber", hue: 62 }, { name: "Emerald", hue: 158 }, { name: "Sky", hue: 230 }, ]; // One chroma/lightness pair per theme keeps every hue legible against white text and dark surfaces. const accent = (hue: number, dark = false) => `oklch(${dark ? 0.7 : 0.56} ${dark ? 0.15 : 0.18} ${Math.round(hue)})`; export function toCss({ hue, radius, density }: ThemeValue) { return `:root { --primary: ${accent(hue)}; --primary-foreground: oklch(0.99 0 0); --radius: ${(radius / 16).toFixed(3).replace(/\.?0+$/, "")}rem; --spacing: ${(0.25 * DENSITY[density]).toFixed(4).replace(/\.?0+$/, "")}rem; } .dark { --primary: ${accent(hue, true)}; --primary-foreground: oklch(0.18 0 0); }`; } export function ThemeStudio({ defaultValue, presets = DEFAULT_PRESETS, onChange, preview, className }: ThemeStudioProps) { const reduce = useReducedMotion(); const [initial] = useState(() => ({ hue: 268, radius: 10, density: "cozy", ...defaultValue })); const [value, setValue] = useState(initial); const [view, setView] = useState<"preview" | "css">("preview"); const [copied, setCopied] = useState(false); const [live, setLive] = useState(false); const copyTimer = useRef(undefined); useEffect(() => () => clearTimeout(copyTimer.current), []); const update = (patch: Partial) => setValue((v) => { const next = { ...v, ...patch }; onChange?.(next); return next; }); const copy = async () => { try { await navigator.clipboard.writeText(toCss(value)); } catch {} setCopied(true); clearTimeout(copyTimer.current); copyTimer.current = window.setTimeout(() => setCopied(false), 1600); }; const vars = { "--studio-hue": value.hue, "--studio-radius": `${value.radius}px`, "--studio-density": DENSITY[value.density], } as CSSProperties; const activePreset = presets.find((p) => Math.abs(p.hue - value.hue) < 2); return (
{presets.map((p) => { const on = activePreset?.name === p.name; return ( ); })}
{ setLive(true); update({ hue: Array.isArray(v) ? v[0] : v }); }} onValueCommitted={() => setLive(false)} thumbAlignment="edge" value={value.hue} > accent(i * 30)).join(", ")})`, }} > "Accent hue"} className="block size-4.5 rounded-full border-2 border-white shadow-[0_1px_3px_--theme(--color-black/30%)] outline-none transition-[scale] duration-150 has-focus-visible:ring-2 has-focus-visible:ring-ring has-focus-visible:ring-offset-2 has-focus-visible:ring-offset-card data-dragging:scale-110" style={{ background: accent(value.hue) }} />
"Corner radius"} max={20} min={0} onValueChange={(v) => { setLive(true); update({ radius: Array.isArray(v) ? v[0] : v }); }} onValueCommitted={() => setLive(false)} value={value.radius} /> update({ density: d })} options={[ { value: "compact", label: "Compact" }, { value: "cozy", label: "Cozy" }, { value: "roomy", label: "Roomy" }, ]} value={value.density} />
{view === "preview" ? ( {preview ?? } ) : ( )}
); } function CssCode({ css }: { css: string }) { return ( {css.split("\n").map((line, i) => { const m = line.match(/^(\s*)(--[\w-]+)(:\s*)(.+)(;)$/); return ( {m ? ( <> {m[1]} {m[2]} {m[3]} {m[4]} {m[5]} ) : ( line || " " )} ); })} ); } function Field({ label, value, children }: { label: string; value?: string; children: ReactNode }) { return (
{label} {value && {value}}
{children}
); } function Segmented({ value, onChange, options, label, }: { value: T; onChange: (v: T) => void; options: { value: T; label: string }[]; label: string; }) { const id = useId(); return (
{options.map((o) => ( ))}
); } function SamplePreview() { const [on, setOn] = useState(true); const pad = "calc(var(--studio-density) * 1rem)"; const gap = "calc(var(--studio-density) * 0.75rem)"; return (

Brightwater Studio

Team plan · 8 seats

Pro

Weekly digest

Mondays at 9:00

Storage 68%
); }