{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "theme-panel", "title": "Theme Panel", "description": "Drop-in live theme editor for shadcn projects. Edit CSS variables, switch presets, copy CSS — like Radix UI's ThemePanel.", "dependencies": [ "culori", "lucide-react", "radix-ui", "class-variance-authority", "clsx", "tailwind-merge" ], "devDependencies": [ "@types/culori" ], "files": [ { "path": "src/components/theme-panel/theme-panel.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n Check,\n Copy,\n Dice5,\n Link2,\n Moon,\n MousePointerSquareDashed,\n Redo2,\n RotateCcw,\n Settings2,\n Sparkles,\n Sun,\n SwatchBook,\n Undo2,\n Upload,\n X,\n} from \"lucide-react\";\n\nimport {\n Badge,\n Button,\n Input,\n Label,\n ScrollArea,\n Separator,\n Slider,\n Tabs,\n TabsContent,\n TabsList,\n TabsTrigger,\n} from \"./ui\";\nimport { cn } from \"@/lib/utils\";\n\nimport { OklchColorPicker } from \"./oklch-color-picker\";\nimport { THEME_PRESETS } from \"./theme-presets\";\nimport { useThemeVars } from \"./use-theme-vars\";\nimport { exportThemeAsCss, parseOklchString, oklchToString } from \"./utils\";\nimport {\n CHART_VARS,\n PRIMARY_VARS,\n SIDEBAR_VARS,\n SURFACE_VARS,\n type ThemeVarName,\n} from \"./types\";\nimport { generateThemeFromAccent, randomTheme } from \"./scale-generator\";\nimport { parseGlobalsCss } from \"./css-parser\";\nimport { getShareableUrl } from \"./url-state\";\nimport { CONTRAST_PAIRS, wcagContrast } from \"./contrast\";\nimport { InspectOverlay } from \"./inspect-overlay\";\nimport type { ElementInfo } from \"./inspect-utils\";\n\ntype Props = {\n /** Show only when NODE_ENV === \"development\" (default true) */\n devOnly?: boolean;\n /** Position of the panel + floating trigger (desktop). On mobile it always docks bottom. */\n position?: \"top-right\" | \"top-left\" | \"bottom-right\" | \"bottom-left\";\n};\n\nconst LABELS: Record = {\n background: \"Background\",\n foreground: \"Foreground\",\n card: \"Card\",\n \"card-foreground\": \"Card fg\",\n popover: \"Popover\",\n \"popover-foreground\": \"Popover fg\",\n primary: \"Primary\",\n \"primary-foreground\": \"Primary fg\",\n secondary: \"Secondary\",\n \"secondary-foreground\": \"Secondary fg\",\n muted: \"Muted\",\n \"muted-foreground\": \"Muted fg\",\n accent: \"Accent\",\n \"accent-foreground\": \"Accent fg\",\n destructive: \"Destructive\",\n border: \"Border\",\n input: \"Input\",\n ring: \"Ring\",\n \"chart-1\": \"Chart 1\",\n \"chart-2\": \"Chart 2\",\n \"chart-3\": \"Chart 3\",\n \"chart-4\": \"Chart 4\",\n \"chart-5\": \"Chart 5\",\n sidebar: \"Sidebar\",\n \"sidebar-foreground\": \"Sidebar fg\",\n \"sidebar-primary\": \"Sidebar primary\",\n \"sidebar-primary-foreground\": \"Sidebar primary fg\",\n \"sidebar-accent\": \"Sidebar accent\",\n \"sidebar-accent-foreground\": \"Sidebar accent fg\",\n \"sidebar-border\": \"Sidebar border\",\n \"sidebar-ring\": \"Sidebar ring\",\n};\n\nexport function ThemePanel({ devOnly = true, position = \"top-right\" }: Props) {\n const [mounted, setMounted] = React.useState(false);\n React.useEffect(() => setMounted(true), []);\n\n const isDev =\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\";\n\n if (!mounted) return null;\n if (devOnly && !isDev) return null;\n\n return ;\n}\n\ntype Corner = \"top-right\" | \"top-left\" | \"bottom-right\" | \"bottom-left\";\n\nconst CORNER_KEY = \"shadcn-theme-panel:corner\";\nconst HIDDEN_KEY = \"shadcn-theme-panel:hidden\";\nconst DRAG_THRESHOLD = 4; // px before a press becomes a drag\n\nfunction ThemePanelInner({\n position,\n}: {\n position: NonNullable;\n}) {\n const theme = useThemeVars();\n const [open, setOpen] = React.useState(false);\n const [corner, setCorner] = React.useState(position);\n const [hidden, setHidden] = React.useState(false);\n const [inspectMode, setInspectMode] = React.useState(false);\n const [inspectSelected, setInspectSelected] =\n React.useState(null);\n const [activeTab, setActiveTab] = React.useState(\"brand\");\n const triggerRef = React.useRef(null);\n\n const onTokenClick = React.useCallback((varName: string) => {\n setActiveTab(varToTab(varName));\n setInspectMode(false);\n setInspectSelected(null);\n setOpen(true);\n }, []);\n\n // Hydrate corner + hidden from localStorage\n React.useEffect(() => {\n if (typeof window === \"undefined\") return;\n const c = window.localStorage.getItem(CORNER_KEY) as Corner | null;\n if (\n c === \"top-right\" ||\n c === \"top-left\" ||\n c === \"bottom-right\" ||\n c === \"bottom-left\"\n ) {\n setCorner(c);\n }\n const h = window.localStorage.getItem(HIDDEN_KEY);\n if (h === \"1\") setHidden(true);\n }, []);\n\n const persistCorner = React.useCallback((c: Corner) => {\n window.localStorage.setItem(CORNER_KEY, c);\n }, []);\n const persistHidden = React.useCallback((v: boolean) => {\n window.localStorage.setItem(HIDDEN_KEY, v ? \"1\" : \"0\");\n }, []);\n\n React.useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n const target = e.target as HTMLElement | null;\n const inField = target?.matches?.(\n \"input, textarea, [contenteditable=true]\",\n );\n const key = e.key.toLowerCase();\n const meta = e.metaKey || e.ctrlKey;\n\n // Shift+T → toggle visibility entirely (hide trigger + panel)\n if (!inField && e.shiftKey && key === \"t\") {\n e.preventDefault();\n setHidden((v) => {\n const next = !v;\n persistHidden(next);\n if (next) {\n setOpen(false);\n setInspectMode(false);\n }\n return next;\n });\n return;\n }\n\n // I → toggle inspect mode\n if (!inField && !e.shiftKey && !meta && key === \"i\") {\n e.preventDefault();\n setInspectMode((v) => {\n if (!v) setOpen(false);\n return !v;\n });\n return;\n }\n\n if (hidden) return;\n\n if (!inField && !e.shiftKey && key === \"t\") {\n e.preventDefault();\n setOpen((v) => !v);\n return;\n }\n if (!open) return;\n\n if (e.key === \"Escape\") setOpen(false);\n if (!inField && key === \"d\") {\n e.preventDefault();\n theme.toggleMode();\n }\n if (meta && key === \"z\" && !e.shiftKey) {\n e.preventDefault();\n theme.undo();\n }\n if (meta && (key === \"y\" || (key === \"z\" && e.shiftKey))) {\n e.preventDefault();\n theme.redo();\n }\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, hidden, theme, persistHidden]);\n\n // Drag handling — direct DOM manipulation via ref to avoid React re-renders\n const dragRef = React.useRef<{\n pointerId: number;\n startX: number;\n startY: number;\n rafId: number | null;\n pendingDx: number;\n pendingDy: number;\n startedDrag: boolean;\n } | null>(null);\n\n const onTriggerPointerDown = (e: React.PointerEvent) => {\n if (e.button !== 0) return;\n const btn = e.currentTarget;\n dragRef.current = {\n pointerId: e.pointerId,\n startX: e.clientX,\n startY: e.clientY,\n rafId: null,\n pendingDx: 0,\n pendingDy: 0,\n startedDrag: false,\n };\n btn.setPointerCapture(e.pointerId);\n };\n\n const onTriggerPointerMove = (e: React.PointerEvent) => {\n const d = dragRef.current;\n const btn = triggerRef.current;\n if (!d || !btn || d.pointerId !== e.pointerId) return;\n const dx = e.clientX - d.startX;\n const dy = e.clientY - d.startY;\n if (!d.startedDrag && Math.hypot(dx, dy) > DRAG_THRESHOLD) {\n d.startedDrag = true;\n btn.dataset.dragging = \"true\";\n }\n if (!d.startedDrag) return;\n d.pendingDx = dx;\n d.pendingDy = dy;\n if (d.rafId === null) {\n d.rafId = requestAnimationFrame(() => {\n d.rafId = null;\n if (!triggerRef.current) return;\n triggerRef.current.style.transform = `translate3d(${d.pendingDx}px, ${d.pendingDy}px, 0)`;\n });\n }\n };\n\n const onTriggerPointerUp = (e: React.PointerEvent) => {\n const d = dragRef.current;\n const btn = triggerRef.current;\n if (!d || d.pointerId !== e.pointerId) return;\n dragRef.current = null;\n if (d.rafId !== null) cancelAnimationFrame(d.rafId);\n e.currentTarget.releasePointerCapture(e.pointerId);\n\n if (btn) {\n btn.style.transform = \"\";\n delete btn.dataset.dragging;\n }\n\n if (d.startedDrag) {\n const w = window.innerWidth;\n const h = window.innerHeight;\n const top = e.clientY < h / 2;\n const left = e.clientX < w / 2;\n const next: Corner =\n `${top ? \"top\" : \"bottom\"}-${left ? \"left\" : \"right\"}` as Corner;\n setCorner(next);\n persistCorner(next);\n } else {\n setOpen(true);\n }\n };\n\n const triggerPos = {\n \"top-right\": \"top-4 right-4\",\n \"top-left\": \"top-4 left-4\",\n \"bottom-right\": \"bottom-4 right-4\",\n \"bottom-left\": \"bottom-4 left-4\",\n }[corner];\n\n const desktopPanelPos = {\n \"top-right\": \"sm:top-4 sm:right-4\",\n \"top-left\": \"sm:top-4 sm:left-4\",\n \"bottom-right\": \"sm:bottom-4 sm:right-4\",\n \"bottom-left\": \"sm:bottom-4 sm:left-4\",\n }[corner];\n\n const currentVars =\n theme.mode === \"dark\" ? theme.state.dark : theme.state.light;\n\n if (hidden) return null;\n\n return (\n <>\n {\n setInspectMode(false);\n setInspectSelected(null);\n }}\n onTokenClick={onTokenClick}\n />\n\n {!open && !inspectMode && (\n \n \n \n )}\n\n {open && !inspectMode && (\n \n setOpen(false)}\n onInspect={() => {\n setInspectMode(true);\n setOpen(false);\n }}\n />\n\n \n
\n \n \n Brand\n \n \n Surface\n \n \n Other\n \n \n Presets\n \n \n Share\n \n \n
\n\n \n \n theme.applyTheme(state)}\n />\n \n \n \n \n
\n {PRIMARY_VARS.map((name) => (\n theme.setVar(name, v)}\n pairedColor={getPairedColor(name, currentVars)}\n />\n ))}\n
\n
\n \n\n \n \n \n
\n {SURFACE_VARS.map((name) => (\n theme.setVar(name, v)}\n pairedColor={getPairedColor(name, currentVars)}\n />\n ))}\n
\n
\n \n\n \n \n \n
\n {CHART_VARS.map((name) => (\n \n \n {name.replace(\"chart-\", \"\")}\n \n \n ))}\n
\n
\n {CHART_VARS.map((name) => (\n theme.setVar(name, v)}\n />\n ))}\n
\n \n \n
\n {SIDEBAR_VARS.map((name) => (\n theme.setVar(name, v)}\n pairedColor={getPairedColor(name, currentVars)}\n />\n ))}\n
\n
\n \n\n \n \n
\n \n theme.applyTheme(randomTheme())}\n title=\"Generate a random theme\"\n >\n \n Random\n \n
\n
\n {THEME_PRESETS.map((preset) => (\n theme.applyTheme(preset.state)}\n />\n ))}\n
\n \n
\n \n\n \n theme.applyTheme(state)}\n />\n \n \n\n
\n \n \n Reset\n \n \n
\n \n )}\n \n );\n}\n\nfunction PanelHeader({\n mode,\n canUndo,\n canRedo,\n onUndo,\n onRedo,\n onToggleMode,\n onClose,\n onInspect,\n}: {\n mode: \"light\" | \"dark\";\n canUndo: boolean;\n canRedo: boolean;\n onUndo: () => void;\n onRedo: () => void;\n onToggleMode: () => void;\n onClose: () => void;\n onInspect: () => void;\n}) {\n return (\n
\n
\n \n Theme Panel\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n {mode === \"dark\" ? (\n \n ) : (\n \n )}\n \n \n \n \n
\n
\n );\n}\n\nfunction varToTab(varName: string): string {\n if (varName.startsWith(\"chart-\") || varName.startsWith(\"sidebar\"))\n return \"other\";\n if (\n varName === \"background\" ||\n varName === \"foreground\" ||\n varName === \"card\" ||\n varName === \"card-foreground\" ||\n varName === \"popover\" ||\n varName === \"popover-foreground\" ||\n varName === \"muted\" ||\n varName === \"muted-foreground\" ||\n varName === \"border\" ||\n varName === \"input\"\n )\n return \"surface\";\n return \"brand\";\n}\n\nfunction SectionHeader({ title, hint }: { title: string; hint?: string }) {\n return (\n
\n

\n {title}\n

\n {hint &&

{hint}

}\n
\n );\n}\n\nfunction RadiusControl({\n value,\n onChange,\n}: {\n value: number;\n onChange: (n: number) => void;\n}) {\n return (\n
\n
\n \n \n {value.toFixed(3)}rem\n \n
\n onChange(v ?? value)}\n />\n
\n 0\n 0.5\n 1.0\n 1.5\n
\n
\n );\n}\n\nfunction GenerateFromAccent({\n current,\n onApply,\n}: {\n current: string;\n onApply: (state: ReturnType) => void;\n}) {\n const [color, setColor] = React.useState(current);\n\n React.useEffect(() => setColor(current), [current]);\n\n const generate = () => {\n const parsed = parseOklchString(color);\n if (!parsed) return;\n onApply(generateThemeFromAccent(parsed));\n };\n\n return (\n
\n
\n \n
\n

Generate from accent

\n

\n Derive a full palette (light + dark) from one OKLCH color.\n

\n
\n
\n
\n \n setColor(e.target.value)}\n className=\"h-9 font-mono text-xs flex-1\"\n spellCheck={false}\n placeholder=\"oklch(0.6 0.2 250)\"\n />\n
\n
\n \n {\n const h = Math.floor(Math.random() * 360);\n setColor(oklchToString({ l: 0.55, c: 0.2, h }));\n }}\n title=\"Pick a random hue\"\n >\n \n \n
\n
\n );\n}\n\nfunction PresetCard({\n preset,\n isActive,\n onApply,\n}: {\n preset: (typeof THEME_PRESETS)[number];\n isActive: boolean;\n onApply: () => void;\n}) {\n const swatches = [\n preset.state.light.primary,\n preset.state.light.accent,\n preset.state.light.background,\n preset.state.dark.background,\n ].filter(Boolean) as string[];\n\n return (\n \n
\n
\n
\n {preset.name}\n {isActive && (\n \n \n Active\n \n )}\n
\n {preset.description && (\n

\n {preset.description}\n

\n )}\n
\n
\n {swatches.map((color, i) => (\n \n ))}\n
\n
\n \n );\n}\n\nfunction ContrastReport({\n currentVars,\n}: {\n currentVars: Record;\n}) {\n return (\n
\n \n
\n {CONTRAST_PAIRS.map((pair) => {\n const fg = currentVars[pair.fg];\n const bg = currentVars[pair.bg];\n if (!fg || !bg) return null;\n return (\n \n
\n
\n \n \n
\n {pair.label}\n
\n \n
\n );\n })}\n
\n \n );\n}\n\nfunction ContrastInline({ fg, bg }: { fg: string; bg: string }) {\n const r = React.useMemo(() => wcagContrast(fg, bg), [fg, bg]);\n if (!r) return null;\n const tier = r.AAA ? \"AAA\" : r.AA ? \"AA\" : r.AALarge ? \"AA L\" : \"fail\";\n const variant = r.AAA ? \"default\" : r.AA ? \"secondary\" : \"destructive\";\n return (\n \n {r.ratio.toFixed(2)} {tier}\n \n );\n}\n\nfunction ShareTab({\n state,\n onImport,\n}: {\n state: ReturnType[\"state\"];\n onImport: (state: ReturnType[\"state\"]) => void;\n}) {\n const css = React.useMemo(() => exportThemeAsCss(state), [state]);\n const [copiedCss, setCopiedCss] = React.useState(false);\n const [copiedUrl, setCopiedUrl] = React.useState(false);\n const [importValue, setImportValue] = React.useState(\"\");\n const [importStatus, setImportStatus] = React.useState<{\n ok: boolean;\n message: string;\n } | null>(null);\n\n const onCopyCss = async () => {\n try {\n await navigator.clipboard.writeText(css);\n setCopiedCss(true);\n setTimeout(() => setCopiedCss(false), 1600);\n } catch {\n /* noop */\n }\n };\n\n const onCopyUrl = async () => {\n try {\n const url = getShareableUrl(state);\n await navigator.clipboard.writeText(url);\n setCopiedUrl(true);\n setTimeout(() => setCopiedUrl(false), 1600);\n } catch {\n /* noop */\n }\n };\n\n const onImportClick = () => {\n const result = parseGlobalsCss(importValue);\n if (result.parsed.light + result.parsed.dark === 0) {\n setImportStatus({\n ok: false,\n message: \"No theme variables detected in input.\",\n });\n return;\n }\n onImport(result.state);\n setImportStatus({\n ok: true,\n message: `Imported ${result.parsed.light} light + ${result.parsed.dark} dark vars${result.parsed.radius ? \" + radius\" : \"\"}.`,\n });\n };\n\n return (\n \n
\n
\n \n \n {copiedUrl ? (\n <>\n \n Link copied\n \n ) : (\n <>\n \n Copy shareable link\n \n )}\n \n
\n\n \n\n
\n
\n

\n Export CSS\n

\n \n {copiedCss ? (\n <>\n \n Copied\n \n ) : (\n <>\n \n Copy\n \n )}\n \n
\n
\n            {css}\n          
\n
\n\n \n\n
\n \n {\n setImportValue(e.target.value);\n setImportStatus(null);\n }}\n spellCheck={false}\n placeholder=\":root { --primary: oklch(...); ... }\"\n className=\"w-full h-32 rounded-md border bg-background px-3 py-2 text-[10px] font-mono resize-y focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n />\n
\n \n \n Import\n \n {importStatus && (\n \n {importStatus.message}\n \n )}\n
\n
\n
\n
\n );\n}\n\nfunction getPairedColor(\n name: ThemeVarName,\n vars: Record,\n): string | undefined {\n const pair = CONTRAST_PAIRS.find((p) => p.fg === name || p.bg === name);\n if (!pair) return undefined;\n return name === pair.fg ? vars[pair.bg] : vars[pair.fg];\n}\n\nfunction equalsPrimary(\n current: Record,\n light: Record,\n dark: Record,\n mode: \"light\" | \"dark\",\n): boolean {\n const ref = mode === \"dark\" ? dark : light;\n const keys: ThemeVarName[] = [\"primary\", \"background\", \"accent\"];\n return keys.every((k) => current[k] === ref[k]);\n}\n", "type": "registry:component", "target": "components/theme-panel/theme-panel.tsx" }, { "path": "src/components/theme-panel/oklch-color-picker.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AlertTriangle } from \"lucide-react\";\nimport { Slider } from \"./ui\";\nimport { Input } from \"./ui\";\nimport { Label } from \"./ui\";\nimport { Badge } from \"./ui\";\nimport { cn } from \"@/lib/utils\";\nimport {\n isInSrgbGamut,\n oklchToHex,\n oklchToString,\n parseOklchString,\n type Oklch,\n} from \"./utils\";\nimport { wcagContrast } from \"./contrast\";\n\ntype Props = {\n value: string;\n onChange: (next: string) => void;\n label?: string;\n className?: string;\n /** When provided, show the WCAG contrast ratio between this value and the paired color */\n pairedColor?: string;\n pairedRole?: \"fg-on-bg\" | \"bg-with-fg\";\n};\n\nexport function OklchColorPicker({\n value,\n onChange,\n label,\n className,\n pairedColor,\n}: Props) {\n const parsed = React.useMemo(() => {\n return parseOklchString(value) ?? { l: 0.5, c: 0, h: 0 };\n }, [value]);\n\n const update = (patch: Partial) => {\n const next = { ...parsed, ...patch };\n onChange(oklchToString(next));\n };\n\n const hex = React.useMemo(() => oklchToHex(parsed), [parsed]);\n const inGamut = React.useMemo(() => isInSrgbGamut(parsed), [parsed]);\n\n const contrast = React.useMemo(() => {\n if (!pairedColor) return null;\n return wcagContrast(value, pairedColor);\n }, [pairedColor, value]);\n\n const swatchStyle: React.CSSProperties = { background: value };\n\n const lTrack: React.CSSProperties = {\n background: `linear-gradient(to right, ${oklchToString({ ...parsed, l: 0 })}, ${oklchToString({ ...parsed, l: 1 })})`,\n };\n const cTrack: React.CSSProperties = {\n background: `linear-gradient(to right, ${oklchToString({ ...parsed, c: 0 })}, ${oklchToString({ ...parsed, c: 0.3 })})`,\n };\n const hTrack: React.CSSProperties = {\n background: hueGradient(parsed),\n };\n\n return (\n
\n {label && (\n
\n \n
\n {!inGamut && (\n \n \n gamut\n \n )}\n {contrast && }\n navigator.clipboard?.writeText(value)}\n title=\"Copy CSS value\"\n >\n copy\n \n
\n
\n )}\n\n
\n \n
\n onChange(e.target.value)}\n className=\"h-8 font-mono text-xs\"\n spellCheck={false}\n />\n
\n {hex.toUpperCase()}\n
\n
\n
\n\n update({ l })}\n trackStyle={lTrack}\n />\n update({ c })}\n trackStyle={cTrack}\n />\n update({ h })}\n trackStyle={hTrack}\n />\n
\n );\n}\n\nfunction ContrastBadge({\n result,\n}: {\n result: NonNullable>;\n}) {\n const tier = result.AAA ? \"AAA\" : result.AA ? \"AA\" : result.AALarge ? \"AA L\" : \"fail\";\n const variant = result.AAA\n ? \"default\"\n : result.AA\n ? \"secondary\"\n : \"destructive\";\n return (\n \n {result.ratio.toFixed(1)} {tier}\n \n );\n}\n\nfunction ChannelSlider({\n label,\n suffix,\n min,\n max,\n step,\n value,\n onChange,\n trackStyle,\n}: {\n label: string;\n suffix: string;\n min: number;\n max: number;\n step: number;\n value: number;\n onChange: (n: number) => void;\n trackStyle: React.CSSProperties;\n}) {\n return (\n
\n
\n {label}\n {suffix}\n
\n \n onChange(v ?? value)}\n className=\"absolute inset-0 [&_[data-slot=slider-track]]:bg-transparent [&_[data-slot=slider-range]]:bg-transparent [&_[data-slot=slider-thumb]]:size-4 [&_[data-slot=slider-thumb]]:border-2 [&_[data-slot=slider-thumb]]:border-white [&_[data-slot=slider-thumb]]:bg-foreground [&_[data-slot=slider-thumb]]:shadow-md\"\n />\n
\n \n );\n}\n\nfunction hueGradient(base: Oklch): string {\n const stops: string[] = [];\n for (let h = 0; h <= 360; h += 30) {\n stops.push(oklchToString({ ...base, h }));\n }\n return `linear-gradient(to right, ${stops.join(\", \")})`;\n}\n", "type": "registry:component", "target": "components/theme-panel/oklch-color-picker.tsx" }, { "path": "src/components/theme-panel/use-theme-vars.ts", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { ThemeMode, ThemeState, ThemeVarName, ThemeVars } from \"./types\";\nimport { DEFAULT_PRESET } from \"./theme-presets\";\nimport { clearVar, writeVar } from \"./utils\";\nimport { clearUrlToken, readStateFromCurrentUrl } from \"./url-state\";\n\nconst STORAGE_KEY = \"shadcn-theme-panel:state\";\nconst MODE_KEY = \"shadcn-theme-panel:mode\";\nconst HISTORY_LIMIT = 50;\n\ntype Persisted = {\n state: ThemeState;\n mode: ThemeMode;\n};\n\nfunction getInitialMode(): ThemeMode {\n if (typeof window === \"undefined\") return \"light\";\n const stored = window.localStorage.getItem(MODE_KEY);\n if (stored === \"light\" || stored === \"dark\") return stored;\n if (window.matchMedia?.(\"(prefers-color-scheme: dark)\").matches)\n return \"dark\";\n return \"light\";\n}\n\nfunction loadPersisted(): Persisted | null {\n if (typeof window === \"undefined\") return null;\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY);\n if (!raw) return null;\n return JSON.parse(raw) as Persisted;\n } catch {\n return null;\n }\n}\n\nfunction applyState(state: ThemeState, mode: ThemeMode) {\n if (typeof document === \"undefined\") return;\n const html = document.documentElement;\n html.classList.toggle(\"dark\", mode === \"dark\");\n\n const vars = mode === \"dark\" ? state.dark : state.light;\n for (const [name, value] of Object.entries(vars)) {\n if (value) writeVar(name, value);\n }\n writeVar(\"radius\", `${state.radius}rem`);\n}\n\nfunction mergeWithDefaults(state: ThemeState): ThemeState {\n return {\n radius: state.radius ?? DEFAULT_PRESET.state.radius,\n light: { ...DEFAULT_PRESET.state.light, ...state.light },\n dark: { ...DEFAULT_PRESET.state.dark, ...state.dark },\n };\n}\n\nexport function useThemeVars() {\n const [state, setState] = React.useState(DEFAULT_PRESET.state);\n const [mode, setModeState] = React.useState(\"light\");\n const [hydrated, setHydrated] = React.useState(false);\n\n // history for undo/redo\n const historyRef = React.useRef([DEFAULT_PRESET.state]);\n const indexRef = React.useRef(0);\n const [historyTick, setHistoryTick] = React.useState(0);\n\n React.useEffect(() => {\n const fromUrl = readStateFromCurrentUrl();\n const persisted = loadPersisted();\n const initialMode = getInitialMode();\n\n let initState: ThemeState;\n let initMode: ThemeMode;\n\n if (fromUrl) {\n initState = mergeWithDefaults(fromUrl);\n initMode = persisted?.mode ?? initialMode;\n clearUrlToken();\n } else if (persisted) {\n initState = mergeWithDefaults(persisted.state);\n initMode = persisted.mode ?? initialMode;\n } else {\n initState = DEFAULT_PRESET.state;\n initMode = initialMode;\n }\n\n setState(initState);\n setModeState(initMode);\n historyRef.current = [initState];\n indexRef.current = 0;\n applyState(initState, initMode);\n setHydrated(true);\n }, []);\n\n const persist = React.useCallback((next: ThemeState, nextMode: ThemeMode) => {\n if (typeof window === \"undefined\") return;\n window.localStorage.setItem(\n STORAGE_KEY,\n JSON.stringify({ state: next, mode: nextMode } satisfies Persisted),\n );\n window.localStorage.setItem(MODE_KEY, nextMode);\n }, []);\n\n const pushHistory = React.useCallback((next: ThemeState) => {\n const trimmed = historyRef.current.slice(0, indexRef.current + 1);\n trimmed.push(next);\n if (trimmed.length > HISTORY_LIMIT) trimmed.shift();\n historyRef.current = trimmed;\n indexRef.current = trimmed.length - 1;\n setHistoryTick((t) => t + 1);\n }, []);\n\n const setVar = React.useCallback(\n (name: ThemeVarName, value: string) => {\n setState((prev) => {\n const next: ThemeState = {\n ...prev,\n [mode]: { ...prev[mode], [name]: value } as ThemeVars,\n };\n applyState(next, mode);\n persist(next, mode);\n pushHistory(next);\n return next;\n });\n },\n [mode, persist, pushHistory],\n );\n\n const setRadius = React.useCallback(\n (radius: number) => {\n setState((prev) => {\n const next = { ...prev, radius };\n applyState(next, mode);\n persist(next, mode);\n pushHistory(next);\n return next;\n });\n },\n [mode, persist, pushHistory],\n );\n\n const setMode = React.useCallback(\n (next: ThemeMode) => {\n setModeState(next);\n applyState(state, next);\n persist(state, next);\n },\n [state, persist],\n );\n\n const toggleMode = React.useCallback(() => {\n setMode(mode === \"light\" ? \"dark\" : \"light\");\n }, [mode, setMode]);\n\n const applyTheme = React.useCallback(\n (next: ThemeState) => {\n const merged = mergeWithDefaults(next);\n setState(merged);\n applyState(merged, mode);\n persist(merged, mode);\n pushHistory(merged);\n },\n [mode, persist, pushHistory],\n );\n\n const resetTheme = React.useCallback(() => {\n const baseline = DEFAULT_PRESET.state;\n setState(baseline);\n for (const name of Object.keys(baseline.light) as ThemeVarName[]) {\n clearVar(name);\n }\n clearVar(\"radius\");\n applyState(baseline, mode);\n persist(baseline, mode);\n pushHistory(baseline);\n }, [mode, persist, pushHistory]);\n\n const undo = React.useCallback(() => {\n if (indexRef.current <= 0) return;\n indexRef.current--;\n const next = historyRef.current[indexRef.current];\n setState(next);\n applyState(next, mode);\n persist(next, mode);\n setHistoryTick((t) => t + 1);\n }, [mode, persist]);\n\n const redo = React.useCallback(() => {\n if (indexRef.current >= historyRef.current.length - 1) return;\n indexRef.current++;\n const next = historyRef.current[indexRef.current];\n setState(next);\n applyState(next, mode);\n persist(next, mode);\n setHistoryTick((t) => t + 1);\n }, [mode, persist]);\n\n const canUndo = historyTick >= 0 && indexRef.current > 0;\n const canRedo =\n historyTick >= 0 && indexRef.current < historyRef.current.length - 1;\n\n return {\n state,\n mode,\n hydrated,\n setVar,\n setRadius,\n setMode,\n toggleMode,\n applyTheme,\n resetTheme,\n undo,\n redo,\n canUndo,\n canRedo,\n };\n}\n\nexport type UseThemeVarsReturn = ReturnType;\n", "type": "registry:hook", "target": "components/theme-panel/use-theme-vars.ts" }, { "path": "src/components/theme-panel/theme-presets.ts", "content": "import type { ThemePreset } from \"./types\";\n\nexport const THEME_PRESETS: ThemePreset[] = [\n {\n id: \"shadcn-zinc\",\n name: \"Zinc\",\n description: \"Default shadcn neutral\",\n state: {\n radius: 0.625,\n light: {\n background: \"oklch(1 0 0)\",\n foreground: \"oklch(0.145 0 0)\",\n card: \"oklch(1 0 0)\",\n \"card-foreground\": \"oklch(0.145 0 0)\",\n popover: \"oklch(1 0 0)\",\n \"popover-foreground\": \"oklch(0.145 0 0)\",\n primary: \"oklch(0.205 0 0)\",\n \"primary-foreground\": \"oklch(0.985 0 0)\",\n secondary: \"oklch(0.97 0 0)\",\n \"secondary-foreground\": \"oklch(0.205 0 0)\",\n muted: \"oklch(0.97 0 0)\",\n \"muted-foreground\": \"oklch(0.556 0 0)\",\n accent: \"oklch(0.97 0 0)\",\n \"accent-foreground\": \"oklch(0.205 0 0)\",\n destructive: \"oklch(0.577 0.245 27.325)\",\n border: \"oklch(0.922 0 0)\",\n input: \"oklch(0.922 0 0)\",\n ring: \"oklch(0.708 0 0)\",\n \"chart-1\": \"oklch(0.646 0.222 41.116)\",\n \"chart-2\": \"oklch(0.6 0.118 184.704)\",\n \"chart-3\": \"oklch(0.398 0.07 227.392)\",\n \"chart-4\": \"oklch(0.828 0.189 84.429)\",\n \"chart-5\": \"oklch(0.769 0.188 70.08)\",\n sidebar: \"oklch(0.985 0 0)\",\n \"sidebar-foreground\": \"oklch(0.145 0 0)\",\n \"sidebar-primary\": \"oklch(0.205 0 0)\",\n \"sidebar-primary-foreground\": \"oklch(0.985 0 0)\",\n \"sidebar-accent\": \"oklch(0.97 0 0)\",\n \"sidebar-accent-foreground\": \"oklch(0.205 0 0)\",\n \"sidebar-border\": \"oklch(0.922 0 0)\",\n \"sidebar-ring\": \"oklch(0.708 0 0)\",\n },\n dark: {\n background: \"oklch(0.145 0 0)\",\n foreground: \"oklch(0.985 0 0)\",\n card: \"oklch(0.205 0 0)\",\n \"card-foreground\": \"oklch(0.985 0 0)\",\n popover: \"oklch(0.205 0 0)\",\n \"popover-foreground\": \"oklch(0.985 0 0)\",\n primary: \"oklch(0.922 0 0)\",\n \"primary-foreground\": \"oklch(0.205 0 0)\",\n secondary: \"oklch(0.269 0 0)\",\n \"secondary-foreground\": \"oklch(0.985 0 0)\",\n muted: \"oklch(0.269 0 0)\",\n \"muted-foreground\": \"oklch(0.708 0 0)\",\n accent: \"oklch(0.269 0 0)\",\n \"accent-foreground\": \"oklch(0.985 0 0)\",\n destructive: \"oklch(0.704 0.191 22.216)\",\n border: \"oklch(1 0 0 / 10%)\",\n input: \"oklch(1 0 0 / 15%)\",\n ring: \"oklch(0.556 0 0)\",\n \"chart-1\": \"oklch(0.488 0.243 264.376)\",\n \"chart-2\": \"oklch(0.696 0.17 162.48)\",\n \"chart-3\": \"oklch(0.769 0.188 70.08)\",\n \"chart-4\": \"oklch(0.627 0.265 303.9)\",\n \"chart-5\": \"oklch(0.645 0.246 16.439)\",\n sidebar: \"oklch(0.205 0 0)\",\n \"sidebar-foreground\": \"oklch(0.985 0 0)\",\n \"sidebar-primary\": \"oklch(0.488 0.243 264.376)\",\n \"sidebar-primary-foreground\": \"oklch(0.985 0 0)\",\n \"sidebar-accent\": \"oklch(0.269 0 0)\",\n \"sidebar-accent-foreground\": \"oklch(0.985 0 0)\",\n \"sidebar-border\": \"oklch(1 0 0 / 10%)\",\n \"sidebar-ring\": \"oklch(0.556 0 0)\",\n },\n },\n },\n {\n id: \"ocean\",\n name: \"Ocean\",\n description: \"Cool blues, calm contrast\",\n state: {\n radius: 0.75,\n light: {\n background: \"oklch(0.99 0.01 240)\",\n foreground: \"oklch(0.2 0.04 250)\",\n card: \"oklch(1 0 0)\",\n \"card-foreground\": \"oklch(0.2 0.04 250)\",\n popover: \"oklch(1 0 0)\",\n \"popover-foreground\": \"oklch(0.2 0.04 250)\",\n primary: \"oklch(0.55 0.18 240)\",\n \"primary-foreground\": \"oklch(0.99 0 0)\",\n secondary: \"oklch(0.96 0.02 240)\",\n \"secondary-foreground\": \"oklch(0.3 0.06 250)\",\n muted: \"oklch(0.96 0.02 240)\",\n \"muted-foreground\": \"oklch(0.5 0.04 250)\",\n accent: \"oklch(0.93 0.05 220)\",\n \"accent-foreground\": \"oklch(0.25 0.06 250)\",\n destructive: \"oklch(0.6 0.22 25)\",\n border: \"oklch(0.9 0.02 240)\",\n input: \"oklch(0.9 0.02 240)\",\n ring: \"oklch(0.55 0.18 240)\",\n },\n dark: {\n background: \"oklch(0.16 0.03 250)\",\n foreground: \"oklch(0.97 0.01 240)\",\n card: \"oklch(0.21 0.04 250)\",\n \"card-foreground\": \"oklch(0.97 0.01 240)\",\n popover: \"oklch(0.21 0.04 250)\",\n \"popover-foreground\": \"oklch(0.97 0.01 240)\",\n primary: \"oklch(0.7 0.17 235)\",\n \"primary-foreground\": \"oklch(0.16 0.03 250)\",\n secondary: \"oklch(0.27 0.04 250)\",\n \"secondary-foreground\": \"oklch(0.97 0.01 240)\",\n muted: \"oklch(0.27 0.04 250)\",\n \"muted-foreground\": \"oklch(0.72 0.03 240)\",\n accent: \"oklch(0.32 0.06 235)\",\n \"accent-foreground\": \"oklch(0.97 0.01 240)\",\n destructive: \"oklch(0.7 0.2 22)\",\n border: \"oklch(1 0 0 / 12%)\",\n input: \"oklch(1 0 0 / 16%)\",\n ring: \"oklch(0.7 0.17 235)\",\n },\n },\n },\n {\n id: \"violet\",\n name: \"Violet\",\n description: \"Vibrant purple, classic shadcn\",\n state: {\n radius: 0.5,\n light: {\n background: \"oklch(1 0 0)\",\n foreground: \"oklch(0.18 0.02 280)\",\n card: \"oklch(1 0 0)\",\n \"card-foreground\": \"oklch(0.18 0.02 280)\",\n popover: \"oklch(1 0 0)\",\n \"popover-foreground\": \"oklch(0.18 0.02 280)\",\n primary: \"oklch(0.55 0.22 280)\",\n \"primary-foreground\": \"oklch(0.99 0 0)\",\n secondary: \"oklch(0.96 0.01 280)\",\n \"secondary-foreground\": \"oklch(0.25 0.04 280)\",\n muted: \"oklch(0.96 0.01 280)\",\n \"muted-foreground\": \"oklch(0.52 0.03 280)\",\n accent: \"oklch(0.94 0.04 290)\",\n \"accent-foreground\": \"oklch(0.3 0.08 280)\",\n destructive: \"oklch(0.6 0.24 25)\",\n border: \"oklch(0.92 0.01 280)\",\n input: \"oklch(0.92 0.01 280)\",\n ring: \"oklch(0.55 0.22 280)\",\n },\n dark: {\n background: \"oklch(0.14 0.02 280)\",\n foreground: \"oklch(0.98 0 0)\",\n card: \"oklch(0.2 0.03 280)\",\n \"card-foreground\": \"oklch(0.98 0 0)\",\n popover: \"oklch(0.2 0.03 280)\",\n \"popover-foreground\": \"oklch(0.98 0 0)\",\n primary: \"oklch(0.7 0.2 280)\",\n \"primary-foreground\": \"oklch(0.14 0.02 280)\",\n secondary: \"oklch(0.27 0.04 280)\",\n \"secondary-foreground\": \"oklch(0.98 0 0)\",\n muted: \"oklch(0.27 0.04 280)\",\n \"muted-foreground\": \"oklch(0.72 0.04 280)\",\n accent: \"oklch(0.34 0.08 285)\",\n \"accent-foreground\": \"oklch(0.98 0 0)\",\n destructive: \"oklch(0.7 0.2 22)\",\n border: \"oklch(1 0 0 / 12%)\",\n input: \"oklch(1 0 0 / 16%)\",\n ring: \"oklch(0.7 0.2 280)\",\n },\n },\n },\n {\n id: \"forest\",\n name: \"Forest\",\n description: \"Earthy greens, soft warmth\",\n state: {\n radius: 0.875,\n light: {\n background: \"oklch(0.99 0.005 140)\",\n foreground: \"oklch(0.22 0.04 150)\",\n card: \"oklch(1 0 0)\",\n \"card-foreground\": \"oklch(0.22 0.04 150)\",\n popover: \"oklch(1 0 0)\",\n \"popover-foreground\": \"oklch(0.22 0.04 150)\",\n primary: \"oklch(0.5 0.15 150)\",\n \"primary-foreground\": \"oklch(0.99 0 0)\",\n secondary: \"oklch(0.96 0.02 130)\",\n \"secondary-foreground\": \"oklch(0.28 0.05 150)\",\n muted: \"oklch(0.96 0.02 130)\",\n \"muted-foreground\": \"oklch(0.5 0.04 150)\",\n accent: \"oklch(0.92 0.06 130)\",\n \"accent-foreground\": \"oklch(0.28 0.06 150)\",\n destructive: \"oklch(0.58 0.22 30)\",\n border: \"oklch(0.9 0.02 140)\",\n input: \"oklch(0.9 0.02 140)\",\n ring: \"oklch(0.5 0.15 150)\",\n },\n dark: {\n background: \"oklch(0.16 0.02 150)\",\n foreground: \"oklch(0.97 0.01 140)\",\n card: \"oklch(0.21 0.03 150)\",\n \"card-foreground\": \"oklch(0.97 0.01 140)\",\n popover: \"oklch(0.21 0.03 150)\",\n \"popover-foreground\": \"oklch(0.97 0.01 140)\",\n primary: \"oklch(0.7 0.16 145)\",\n \"primary-foreground\": \"oklch(0.16 0.02 150)\",\n secondary: \"oklch(0.27 0.03 150)\",\n \"secondary-foreground\": \"oklch(0.97 0.01 140)\",\n muted: \"oklch(0.27 0.03 150)\",\n \"muted-foreground\": \"oklch(0.72 0.03 140)\",\n accent: \"oklch(0.32 0.05 140)\",\n \"accent-foreground\": \"oklch(0.97 0.01 140)\",\n destructive: \"oklch(0.7 0.2 25)\",\n border: \"oklch(1 0 0 / 10%)\",\n input: \"oklch(1 0 0 / 15%)\",\n ring: \"oklch(0.7 0.16 145)\",\n },\n },\n },\n {\n id: \"rose\",\n name: \"Rose\",\n description: \"Warm coral with soft neutrals\",\n state: {\n radius: 1,\n light: {\n background: \"oklch(0.99 0.005 20)\",\n foreground: \"oklch(0.2 0.03 20)\",\n card: \"oklch(1 0 0)\",\n \"card-foreground\": \"oklch(0.2 0.03 20)\",\n popover: \"oklch(1 0 0)\",\n \"popover-foreground\": \"oklch(0.2 0.03 20)\",\n primary: \"oklch(0.62 0.21 18)\",\n \"primary-foreground\": \"oklch(0.99 0 0)\",\n secondary: \"oklch(0.96 0.02 20)\",\n \"secondary-foreground\": \"oklch(0.28 0.04 20)\",\n muted: \"oklch(0.96 0.02 20)\",\n \"muted-foreground\": \"oklch(0.52 0.03 20)\",\n accent: \"oklch(0.93 0.05 25)\",\n \"accent-foreground\": \"oklch(0.3 0.06 20)\",\n destructive: \"oklch(0.58 0.24 28)\",\n border: \"oklch(0.91 0.02 20)\",\n input: \"oklch(0.91 0.02 20)\",\n ring: \"oklch(0.62 0.21 18)\",\n },\n dark: {\n background: \"oklch(0.15 0.02 20)\",\n foreground: \"oklch(0.98 0.005 20)\",\n card: \"oklch(0.21 0.03 20)\",\n \"card-foreground\": \"oklch(0.98 0.005 20)\",\n popover: \"oklch(0.21 0.03 20)\",\n \"popover-foreground\": \"oklch(0.98 0.005 20)\",\n primary: \"oklch(0.72 0.19 18)\",\n \"primary-foreground\": \"oklch(0.15 0.02 20)\",\n secondary: \"oklch(0.28 0.04 20)\",\n \"secondary-foreground\": \"oklch(0.98 0.005 20)\",\n muted: \"oklch(0.28 0.04 20)\",\n \"muted-foreground\": \"oklch(0.72 0.03 20)\",\n accent: \"oklch(0.34 0.07 22)\",\n \"accent-foreground\": \"oklch(0.98 0.005 20)\",\n destructive: \"oklch(0.7 0.22 25)\",\n border: \"oklch(1 0 0 / 12%)\",\n input: \"oklch(1 0 0 / 16%)\",\n ring: \"oklch(0.72 0.19 18)\",\n },\n },\n },\n];\n\nexport const DEFAULT_PRESET = THEME_PRESETS[0];\n", "type": "registry:lib", "target": "components/theme-panel/theme-presets.ts" }, { "path": "src/components/theme-panel/utils.ts", "content": "import { converter, formatHex, formatCss, parse } from \"culori\";\nimport type { ThemeState, ThemeVars } from \"./types\";\nimport { THEME_VAR_NAMES } from \"./types\";\n\nconst toOklch = converter(\"oklch\");\nconst toRgb = converter(\"rgb\");\n\nexport function isInSrgbGamut({ l, c, h }: Oklch): boolean {\n const rgb = toRgb({ mode: \"oklch\", l, c, h });\n if (!rgb) return false;\n const r = rgb.r ?? 0;\n const g = rgb.g ?? 0;\n const b = rgb.b ?? 0;\n const eps = 0.001;\n return (\n r >= -eps && r <= 1 + eps &&\n g >= -eps && g <= 1 + eps &&\n b >= -eps && b <= 1 + eps\n );\n}\n\nexport type Oklch = { l: number; c: number; h: number; alpha?: number };\n\nexport function parseOklchString(value: string): Oklch | null {\n if (!value) return null;\n const parsed = parse(value);\n if (!parsed) return null;\n const ok = toOklch(parsed);\n if (!ok) return null;\n return {\n l: clamp(ok.l ?? 0, 0, 1),\n c: clamp(ok.c ?? 0, 0, 0.4),\n h: ((ok.h ?? 0) + 360) % 360,\n alpha: ok.alpha,\n };\n}\n\nexport function oklchToString({ l, c, h, alpha }: Oklch): string {\n const lv = round(l, 3);\n const cv = round(c, 3);\n const hv = round(h, 2);\n if (alpha !== undefined && alpha < 1) {\n return `oklch(${lv} ${cv} ${hv} / ${round(alpha * 100, 1)}%)`;\n }\n return `oklch(${lv} ${cv} ${hv})`;\n}\n\nexport function oklchToHex({ l, c, h }: Oklch): string {\n const rgb = toRgb({ mode: \"oklch\", l, c, h });\n if (!rgb) return \"#000000\";\n return formatHex(rgb) ?? \"#000000\";\n}\n\nexport function hexToOklch(hex: string): Oklch | null {\n return parseOklchString(hex);\n}\n\nfunction clamp(n: number, min: number, max: number) {\n return Math.min(max, Math.max(min, n));\n}\n\nfunction round(n: number, places: number) {\n const f = Math.pow(10, places);\n return Math.round(n * f) / f;\n}\n\nexport function readVar(name: string): string | null {\n if (typeof window === \"undefined\") return null;\n const value = getComputedStyle(document.documentElement)\n .getPropertyValue(`--${name}`)\n .trim();\n return value || null;\n}\n\nexport function writeVar(name: string, value: string) {\n if (typeof document === \"undefined\") return;\n document.documentElement.style.setProperty(`--${name}`, value);\n}\n\nexport function clearVar(name: string) {\n if (typeof document === \"undefined\") return;\n document.documentElement.style.removeProperty(`--${name}`);\n}\n\nexport function snapshotVars(): ThemeVars {\n const result: ThemeVars = {};\n for (const name of THEME_VAR_NAMES) {\n const v = readVar(name);\n if (v) result[name] = v;\n }\n return result;\n}\n\nexport function exportThemeAsCss(state: ThemeState): string {\n const lightLines = renderBlock(state.light, state.radius);\n const darkLines = renderBlock(state.dark, undefined);\n return `:root {\\n${lightLines}\\n}\\n\\n.dark {\\n${darkLines}\\n}\\n`;\n}\n\nfunction renderBlock(vars: ThemeVars, radius: number | undefined) {\n const lines: string[] = [];\n for (const name of THEME_VAR_NAMES) {\n const v = vars[name];\n if (v) lines.push(` --${name}: ${v};`);\n }\n if (radius !== undefined) {\n lines.push(` --radius: ${radius}rem;`);\n }\n return lines.join(\"\\n\");\n}\n\nexport { formatHex, formatCss };\n", "type": "registry:lib", "target": "components/theme-panel/utils.ts" }, { "path": "src/components/theme-panel/types.ts", "content": "export type ThemeMode = \"light\" | \"dark\";\n\nexport type ThemeVarName =\n | \"background\"\n | \"foreground\"\n | \"card\"\n | \"card-foreground\"\n | \"popover\"\n | \"popover-foreground\"\n | \"primary\"\n | \"primary-foreground\"\n | \"secondary\"\n | \"secondary-foreground\"\n | \"muted\"\n | \"muted-foreground\"\n | \"accent\"\n | \"accent-foreground\"\n | \"destructive\"\n | \"border\"\n | \"input\"\n | \"ring\"\n | \"chart-1\"\n | \"chart-2\"\n | \"chart-3\"\n | \"chart-4\"\n | \"chart-5\"\n | \"sidebar\"\n | \"sidebar-foreground\"\n | \"sidebar-primary\"\n | \"sidebar-primary-foreground\"\n | \"sidebar-accent\"\n | \"sidebar-accent-foreground\"\n | \"sidebar-border\"\n | \"sidebar-ring\";\n\nexport type ThemeVars = Partial>;\n\nexport type ThemeState = {\n light: ThemeVars;\n dark: ThemeVars;\n radius: number;\n};\n\nexport type ThemePreset = {\n id: string;\n name: string;\n description?: string;\n state: ThemeState;\n};\n\nexport const THEME_VAR_NAMES: ThemeVarName[] = [\n \"background\",\n \"foreground\",\n \"card\",\n \"card-foreground\",\n \"popover\",\n \"popover-foreground\",\n \"primary\",\n \"primary-foreground\",\n \"secondary\",\n \"secondary-foreground\",\n \"muted\",\n \"muted-foreground\",\n \"accent\",\n \"accent-foreground\",\n \"destructive\",\n \"border\",\n \"input\",\n \"ring\",\n \"chart-1\",\n \"chart-2\",\n \"chart-3\",\n \"chart-4\",\n \"chart-5\",\n \"sidebar\",\n \"sidebar-foreground\",\n \"sidebar-primary\",\n \"sidebar-primary-foreground\",\n \"sidebar-accent\",\n \"sidebar-accent-foreground\",\n \"sidebar-border\",\n \"sidebar-ring\",\n];\n\nexport const PRIMARY_VARS: ThemeVarName[] = [\n \"primary\",\n \"primary-foreground\",\n \"secondary\",\n \"secondary-foreground\",\n \"accent\",\n \"accent-foreground\",\n \"destructive\",\n \"ring\",\n];\n\nexport const SURFACE_VARS: ThemeVarName[] = [\n \"background\",\n \"foreground\",\n \"card\",\n \"card-foreground\",\n \"popover\",\n \"popover-foreground\",\n \"muted\",\n \"muted-foreground\",\n \"border\",\n \"input\",\n];\n\nexport const CHART_VARS: ThemeVarName[] = [\n \"chart-1\",\n \"chart-2\",\n \"chart-3\",\n \"chart-4\",\n \"chart-5\",\n];\n\nexport const SIDEBAR_VARS: ThemeVarName[] = [\n \"sidebar\",\n \"sidebar-foreground\",\n \"sidebar-primary\",\n \"sidebar-primary-foreground\",\n \"sidebar-accent\",\n \"sidebar-accent-foreground\",\n \"sidebar-border\",\n \"sidebar-ring\",\n];\n", "type": "registry:lib", "target": "components/theme-panel/types.ts" }, { "path": "src/components/theme-panel/contrast.ts", "content": "import { converter, parse } from \"culori\";\n\nconst toRgb = converter(\"rgb\");\n\nexport type WcagResult = {\n ratio: number;\n AA: boolean;\n AAA: boolean;\n AALarge: boolean;\n};\n\nexport function wcagContrast(fg: string, bg: string): WcagResult | null {\n const a = relativeLuminance(fg);\n const b = relativeLuminance(bg);\n if (a === null || b === null) return null;\n const lighter = Math.max(a, b);\n const darker = Math.min(a, b);\n const ratio = (lighter + 0.05) / (darker + 0.05);\n return {\n ratio,\n AA: ratio >= 4.5,\n AAA: ratio >= 7,\n AALarge: ratio >= 3,\n };\n}\n\nfunction relativeLuminance(color: string): number | null {\n const parsed = parse(color);\n if (!parsed) return null;\n const rgb = toRgb(parsed);\n if (!rgb) return null;\n const r = chan(rgb.r ?? 0);\n const g = chan(rgb.g ?? 0);\n const b = chan(rgb.b ?? 0);\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\nfunction chan(c: number): number {\n const v = clamp(c, 0, 1);\n return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);\n}\n\nfunction clamp(n: number, min: number, max: number) {\n return Math.min(max, Math.max(min, n));\n}\n\nexport const CONTRAST_PAIRS: Array<{ fg: string; bg: string; label: string }> =\n [\n { fg: \"foreground\", bg: \"background\", label: \"Body\" },\n { fg: \"primary-foreground\", bg: \"primary\", label: \"Primary\" },\n { fg: \"secondary-foreground\", bg: \"secondary\", label: \"Secondary\" },\n { fg: \"accent-foreground\", bg: \"accent\", label: \"Accent\" },\n { fg: \"card-foreground\", bg: \"card\", label: \"Card\" },\n { fg: \"popover-foreground\", bg: \"popover\", label: \"Popover\" },\n { fg: \"muted-foreground\", bg: \"muted\", label: \"Muted\" },\n ];\n", "type": "registry:lib", "target": "components/theme-panel/contrast.ts" }, { "path": "src/components/theme-panel/scale-generator.ts", "content": "import type { ThemeState, ThemeVars } from \"./types\";\nimport { oklchToString, parseOklchString, type Oklch } from \"./utils\";\n\n/**\n * Derive a complete shadcn theme (light + dark) from a single accent OKLCH color.\n * The strategy:\n * - background/foreground: near-extreme L, low C, accent hue\n * - primary = the accent\n * - primary-foreground: high contrast vs primary\n * - secondary/muted/accent: low-chroma surfaces tinted toward accent hue\n * - border/input: surface variants slightly darker\n * - ring = primary\n * - destructive: fixed red\n */\nexport function generateThemeFromAccent(accent: Oklch): ThemeState {\n const h = accent.h;\n const c = accent.c;\n\n const light: ThemeVars = {\n background: oklchToString({ l: 1, c: 0, h }),\n foreground: oklchToString({ l: 0.18, c: Math.min(0.03, c * 0.2), h }),\n card: oklchToString({ l: 1, c: 0, h }),\n \"card-foreground\": oklchToString({\n l: 0.18,\n c: Math.min(0.03, c * 0.2),\n h,\n }),\n popover: oklchToString({ l: 1, c: 0, h }),\n \"popover-foreground\": oklchToString({\n l: 0.18,\n c: Math.min(0.03, c * 0.2),\n h,\n }),\n primary: oklchToString(accent),\n \"primary-foreground\": pickFg(accent, \"light\"),\n secondary: oklchToString({ l: 0.96, c: Math.min(0.02, c * 0.2), h }),\n \"secondary-foreground\": oklchToString({\n l: 0.25,\n c: Math.min(0.05, c * 0.4),\n h,\n }),\n muted: oklchToString({ l: 0.96, c: Math.min(0.02, c * 0.2), h }),\n \"muted-foreground\": oklchToString({\n l: 0.5,\n c: Math.min(0.04, c * 0.3),\n h,\n }),\n accent: oklchToString({ l: 0.93, c: Math.min(0.06, c * 0.4), h }),\n \"accent-foreground\": oklchToString({\n l: 0.28,\n c: Math.min(0.06, c * 0.4),\n h,\n }),\n destructive: \"oklch(0.6 0.23 25)\",\n border: oklchToString({ l: 0.91, c: Math.min(0.02, c * 0.2), h }),\n input: oklchToString({ l: 0.91, c: Math.min(0.02, c * 0.2), h }),\n ring: oklchToString(accent),\n };\n\n const darkAccent: Oklch = brighten(accent, 0.15);\n const dark: ThemeVars = {\n background: oklchToString({ l: 0.15, c: Math.min(0.02, c * 0.2), h }),\n foreground: oklchToString({ l: 0.98, c: 0, h }),\n card: oklchToString({ l: 0.21, c: Math.min(0.03, c * 0.25), h }),\n \"card-foreground\": oklchToString({ l: 0.98, c: 0, h }),\n popover: oklchToString({ l: 0.21, c: Math.min(0.03, c * 0.25), h }),\n \"popover-foreground\": oklchToString({ l: 0.98, c: 0, h }),\n primary: oklchToString(darkAccent),\n \"primary-foreground\": pickFg(darkAccent, \"dark\"),\n secondary: oklchToString({ l: 0.27, c: Math.min(0.04, c * 0.3), h }),\n \"secondary-foreground\": oklchToString({ l: 0.98, c: 0, h }),\n muted: oklchToString({ l: 0.27, c: Math.min(0.04, c * 0.3), h }),\n \"muted-foreground\": oklchToString({\n l: 0.72,\n c: Math.min(0.03, c * 0.25),\n h,\n }),\n accent: oklchToString({ l: 0.33, c: Math.min(0.07, c * 0.45), h }),\n \"accent-foreground\": oklchToString({ l: 0.98, c: 0, h }),\n destructive: \"oklch(0.7 0.2 22)\",\n border: \"oklch(1 0 0 / 12%)\",\n input: \"oklch(1 0 0 / 16%)\",\n ring: oklchToString(darkAccent),\n };\n\n return { radius: 0.625, light, dark };\n}\n\nfunction brighten({ l, c, h }: Oklch, by: number): Oklch {\n return { l: clamp(l + by, 0, 1), c, h };\n}\n\nfunction pickFg(base: Oklch, surface: \"light\" | \"dark\"): string {\n // If the base is dark enough, pick a near-white fg; if light, pick a near-black fg.\n // L < 0.5 → light fg, L >= 0.5 → dark fg.\n const goLight = base.l < 0.55;\n if (goLight) return oklchToString({ l: 0.98, c: 0, h: base.h });\n return oklchToString({\n l: 0.18,\n c: Math.min(0.03, base.c * 0.2),\n h: base.h,\n });\n}\n\nfunction clamp(n: number, min: number, max: number) {\n return Math.min(max, Math.max(min, n));\n}\n\nexport function generateThemeFromString(value: string): ThemeState | null {\n const accent = parseOklchString(value);\n if (!accent) return null;\n return generateThemeFromAccent(accent);\n}\n\nexport function randomTheme(): ThemeState {\n const h = Math.floor(Math.random() * 360);\n const c = 0.16 + Math.random() * 0.1;\n const l = 0.5 + Math.random() * 0.15;\n return generateThemeFromAccent({ l, c, h });\n}\n", "type": "registry:lib", "target": "components/theme-panel/scale-generator.ts" }, { "path": "src/components/theme-panel/css-parser.ts", "content": "import type { ThemeState, ThemeVars, ThemeVarName } from \"./types\";\nimport { THEME_VAR_NAMES } from \"./types\";\nimport { DEFAULT_PRESET } from \"./theme-presets\";\n\nconst VAR_LINE = /--([a-z0-9-]+)\\s*:\\s*([^;]+);/gi;\n\nexport type ParseResult = {\n state: ThemeState;\n parsed: { light: number; dark: number; radius: boolean };\n warnings: string[];\n};\n\nexport function parseGlobalsCss(css: string): ParseResult {\n const warnings: string[] = [];\n const lightBlock = extractBlock(css, \":root\");\n const darkBlock = extractBlock(css, \".dark\");\n\n const light = parseBlock(lightBlock);\n const dark = parseBlock(darkBlock);\n\n if (!lightBlock) warnings.push(\"No :root { ... } block found.\");\n if (!darkBlock) warnings.push(\"No .dark { ... } block found — dark mode left unchanged.\");\n\n const radius = parseRadius(lightBlock) ?? parseRadius(darkBlock);\n const radiusValue = radius ?? DEFAULT_PRESET.state.radius;\n\n return {\n state: {\n radius: radiusValue,\n light: { ...DEFAULT_PRESET.state.light, ...light },\n dark: { ...DEFAULT_PRESET.state.dark, ...dark },\n },\n parsed: {\n light: Object.keys(light).length,\n dark: Object.keys(dark).length,\n radius: radius !== null && radius !== undefined,\n },\n warnings,\n };\n}\n\nfunction extractBlock(css: string, selector: string): string | null {\n const idx = css.indexOf(selector);\n if (idx < 0) return null;\n const open = css.indexOf(\"{\", idx);\n if (open < 0) return null;\n let depth = 1;\n let i = open + 1;\n while (i < css.length && depth > 0) {\n const ch = css[i];\n if (ch === \"{\") depth++;\n else if (ch === \"}\") depth--;\n i++;\n }\n return css.slice(open + 1, i - 1);\n}\n\nfunction parseBlock(block: string | null): ThemeVars {\n if (!block) return {};\n const result: ThemeVars = {};\n const knownSet = new Set(THEME_VAR_NAMES);\n for (const match of block.matchAll(VAR_LINE)) {\n const name = match[1];\n const value = match[2].trim();\n if (knownSet.has(name)) {\n result[name as ThemeVarName] = value;\n }\n }\n return result;\n}\n\nfunction parseRadius(block: string | null): number | null {\n if (!block) return null;\n const match = block.match(/--radius\\s*:\\s*([^;]+);/);\n if (!match) return null;\n const raw = match[1].trim();\n const remMatch = raw.match(/^([\\d.]+)\\s*rem$/);\n if (remMatch) return parseFloat(remMatch[1]);\n const pxMatch = raw.match(/^([\\d.]+)\\s*px$/);\n if (pxMatch) return parseFloat(pxMatch[1]) / 16;\n const num = parseFloat(raw);\n return isFinite(num) ? num : null;\n}\n", "type": "registry:lib", "target": "components/theme-panel/css-parser.ts" }, { "path": "src/components/theme-panel/url-state.ts", "content": "import type { ThemeState } from \"./types\";\n\nconst PARAM = \"t\";\n\nexport function encodeStateToUrl(state: ThemeState): string {\n const json = JSON.stringify(state);\n if (typeof window === \"undefined\") return \"\";\n const b64 = btoa(unescape(encodeURIComponent(json)));\n return b64.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nexport function decodeStateFromUrl(token: string): ThemeState | null {\n try {\n const b64 = token.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padded = b64 + \"=\".repeat((4 - (b64.length % 4)) % 4);\n const json = decodeURIComponent(escape(atob(padded)));\n const parsed = JSON.parse(json);\n if (parsed && typeof parsed === \"object\" && parsed.light && parsed.dark) {\n return parsed as ThemeState;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport function getShareableUrl(state: ThemeState): string {\n if (typeof window === \"undefined\") return \"\";\n const url = new URL(window.location.href);\n url.searchParams.set(PARAM, encodeStateToUrl(state));\n return url.toString();\n}\n\nexport function readStateFromCurrentUrl(): ThemeState | null {\n if (typeof window === \"undefined\") return null;\n const params = new URLSearchParams(window.location.search);\n const token = params.get(PARAM);\n if (!token) return null;\n return decodeStateFromUrl(token);\n}\n\nexport function clearUrlToken(): void {\n if (typeof window === \"undefined\") return;\n const url = new URL(window.location.href);\n if (!url.searchParams.has(PARAM)) return;\n url.searchParams.delete(PARAM);\n window.history.replaceState({}, \"\", url.toString());\n}\n", "type": "registry:lib", "target": "components/theme-panel/url-state.ts" }, { "path": "src/components/theme-panel/inspect-overlay.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n buildElementInfo,\n clearVarCache,\n distancesBetween,\n isInternal,\n type ElementInfo,\n} from \"./inspect-utils\";\n\ntype Props = {\n active: boolean;\n selected: ElementInfo | null;\n onSelect: (info: ElementInfo | null) => void;\n onExit: () => void;\n onTokenClick?: (varName: string) => void;\n};\n\nexport function InspectOverlay({\n active,\n selected,\n onSelect,\n onExit,\n onTokenClick,\n}: Props) {\n const [hover, setHover] = React.useState(null);\n const rafRef = React.useRef(null);\n const lastClientX = React.useRef(0);\n const lastClientY = React.useRef(0);\n\n // Re-resolve selected element's rect on scroll/resize so the outline tracks it\n const [tick, setTick] = React.useState(0);\n\n React.useEffect(() => {\n if (!active) return;\n clearVarCache();\n\n const update = () => {\n rafRef.current = null;\n const el = document.elementFromPoint(\n lastClientX.current,\n lastClientY.current,\n );\n if (!el || isInternal(el)) {\n setHover((prev) => (prev ? null : prev));\n return;\n }\n setHover(buildElementInfo(el));\n };\n\n const onMove = (e: MouseEvent) => {\n lastClientX.current = e.clientX;\n lastClientY.current = e.clientY;\n if (rafRef.current === null) {\n rafRef.current = requestAnimationFrame(update);\n }\n };\n\n const onClick = (e: MouseEvent) => {\n const target = e.target as Element | null;\n if (!target || isInternal(target)) return;\n e.preventDefault();\n e.stopPropagation();\n onSelect(buildElementInfo(target));\n };\n\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n if (selected) onSelect(null);\n else onExit();\n }\n };\n\n const onScroll = () => setTick((t) => t + 1);\n\n window.addEventListener(\"mousemove\", onMove, { passive: true });\n window.addEventListener(\"click\", onClick, { capture: true });\n window.addEventListener(\"keydown\", onKey);\n window.addEventListener(\"scroll\", onScroll, { passive: true });\n window.addEventListener(\"resize\", onScroll);\n document.body.style.cursor = \"crosshair\";\n\n return () => {\n window.removeEventListener(\"mousemove\", onMove);\n window.removeEventListener(\"click\", onClick, { capture: true } as EventListenerOptions);\n window.removeEventListener(\"keydown\", onKey);\n window.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"resize\", onScroll);\n document.body.style.cursor = \"\";\n if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n };\n }, [active, selected, onSelect, onExit]);\n\n // Whenever tick changes, re-read the rects of the current hover/selected element\n const liveSelected = React.useMemo(() => {\n if (!selected) return null;\n if (!document.body.contains(selected.el)) return null;\n return { ...selected, rect: selected.el.getBoundingClientRect() };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [selected, tick]);\n\n const liveHover = React.useMemo(() => {\n if (!hover) return null;\n return { ...hover, rect: hover.el.getBoundingClientRect() };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [hover, tick]);\n\n if (!active) return null;\n\n return (\n \n {liveHover && (!liveSelected || liveHover.el !== liveSelected.el) && (\n \n )}\n {liveSelected && }\n {liveSelected && liveHover && liveHover.el !== liveSelected.el && (\n \n )}\n {liveSelected && (\n onSelect(null)}\n onTokenClick={onTokenClick}\n />\n )}\n \n \n );\n}\n\nfunction Outline({\n info,\n variant,\n}: {\n info: ElementInfo;\n variant: \"hover\" | \"selected\";\n}) {\n const { rect } = info;\n const isHover = variant === \"hover\";\n const color = isHover ? \"rgb(244 63 94)\" : \"rgb(56 189 248)\";\n\n return (\n <>\n \n