shadimage
elements
blocktinte-editorsource: ok · hit

Tinte Theme Editor

Live theme editor with AI-powered theme generation, Browse tab for tinte.dev themes, and manual color editing. Complete shadcn/ui theme customization tool. Works entirely client-side with DOM-based CSS variable management.

npx shadcn add "https://www.tryelements.dev/r/tinte-editor.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@radix-ui/react-accordion@radix-ui/react-dialog@radix-ui/react-scroll-area@radix-ui/react-tabs
iconslucide-react

Registry dependencies

buttoninputlabelpopoverdialogtabstextareaaccordion@elements/tinte-logo@elements/theme-switcher-classic

Files7 · source cached 10/14/2026, 3:10:53 PM

registry/default/blocks/tinte/tinte-editor/page.tsx
"use client";

import { ThemeSwitcherClassic } from "@/registry/default/blocks/theme-switcher/theme-switcher-classic/components/elements/theme-switcher-classic";
import { TinteEditor } from "@/registry/default/blocks/tinte/tinte-editor/components/tinte-editor";

export default function TinteEditorDemo() {
  return (
    <div className="min-h-screen bg-background">
      {/* Hero Section */}
      <div className="container mx-auto px-4 py-12 sm:py-16">
        <div className="max-w-3xl mx-auto text-center space-y-6">
          <div className="space-y-3">
            <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-tight">
              Tinte Theme Editor
            </h1>
            <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto">
              AI-powered theme generation for shadcn/ui. Click the Tinte button
              below to start customizing.
            </p>
          </div>

          {/* Theme Switcher */}
          <div className="flex justify-center items-center gap-3 pt-2">
            <span className="text-sm text-muted-foreground">
              Try switching themes
            </span>
            <ThemeSwitcherClassic />
          </div>
        </div>
      </div>

      {/* Features Grid */}
      <div className="container mx-auto px-4 pb-12">
        <div className="max-w-4xl mx-auto">
          <div className="grid gap-6 md:grid-cols-3">
            {/* Feature 1 */}
            <div className="bg-card border rounded-lg p-5 space-y-2 text-center">
              <div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center mx-auto">
                <svg
                  className="w-5 h-5 text-primary"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <title>AI Icon</title>
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
                  />
                </svg>
              </div>
              <h3 className="font-semibold">AI Generation</h3>
              <p className="text-xs text-muted-foreground">
                Generate themes with AI
              </p>
            </div>

            {/* Feature 2 */}
            <div className="bg-card border rounded-lg p-5 space-y-2 text-center">
              <div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center mx-auto">
                <svg
                  className="w-5 h-5 text-primary"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <title>Template Icon</title>
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M4 5a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zM14 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1v-3z"
                  />
                </svg>
              </div>
              <h3 className="font-semibold">Browse Themes</h3>
              <p className="text-xs text-muted-foreground">
                Explore tinte.dev community
              </p>
            </div>

            {/* Feature 3 */}
            <div className="bg-card border rounded-lg p-5 space-y-2 text-center">
              <div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center mx-auto">
                <svg
                  className="w-5 h-5 text-primary"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <title>Edit Icon</title>
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
                  />
                </svg>
              </div>
              <h3 className="font-semibold">Manual Editor</h3>
              <p className="text-xs text-muted-foreground">
                Edit colors & CSS directly
              </p>
            </div>
          </div>
        </div>
      </div>

      {/* Tinte Editor - Floating Button */}
      <TinteEditor />
    </div>
  );
}
registry/default/blocks/tinte/tinte-editor/components/tinte-editor.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { formatHex, oklch } from "culori";
import { Loader2, RefreshCw, Search, X } from "lucide-react";

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";

import { TinteLogo } from "@/registry/default/blocks/logos/tinte-logo/components/logos/tinte";
import {
  convertTinteToShadcn,
  type TinteTheme,
} from "@/registry/default/blocks/tinte/tinte-editor/lib/tinte-to-shadcn";
import { ChatInput } from "./chat-input";
import { Message as ChatMessage } from "./chat-message";
import { ColorInput } from "./color-input";

type ShadcnTokens = Record<string, string>;

interface ShadcnTheme {
  light: ShadcnTokens;
  dark: ShadcnTokens;
}

interface TinteThemePreview {
  id: string;
  slug: string;
  name: string;
  concept?: string;
  is_public: boolean;
  colors: {
    primary: string;
    secondary: string;
    accent: string;
    foreground: string;
    background: string;
  };
  rawTheme?: TinteTheme;
  overrides?: {
    shadcn?: {
      light: ShadcnTokens;
      dark: ShadcnTokens;
    };
  };
}

const TOKEN_GROUPS = [
  {
    label: "Background & Text",
    tokens: ["background", "foreground", "muted", "muted-foreground"],
  },
  {
    label: "Cards & Surfaces",
    tokens: ["card", "card-foreground", "popover", "popover-foreground"],
  },
  {
    label: "Interactive Elements",
    tokens: [
      "primary",
      "primary-foreground",
      "secondary",
      "secondary-foreground",
      "accent",
      "accent-foreground",
    ],
  },
  {
    label: "Forms & States",
    tokens: [
      "border",
      "input",
      "ring",
      "destructive",
      "destructive-foreground",
    ],
  },
  {
    label: "Charts",
    tokens: ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"],
  },
  {
    label: "Sidebar",
    tokens: [
      "sidebar-background",
      "sidebar-foreground",
      "sidebar-primary",
      "sidebar-primary-foreground",
      "sidebar-accent",
      "sidebar-accent-foreground",
      "sidebar-border",
      "sidebar-ring",
    ],
  },
] as const;

interface TinteEditorProps {
  onChange?: (theme: ShadcnTheme) => void;
}

export function TinteEditor({ onChange }: TinteEditorProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [theme, setTheme] = useState<ShadcnTheme>({ light: {}, dark: {} });
  const themeRef = useRef<ShadcnTheme>({ light: {}, dark: {} });
  const [_originalFormats, setOriginalFormats] = useState<
    Record<string, Record<string, string>>
  >({
    light: {},
    dark: {},
  });
  const [mode, setMode] = useState<"light" | "dark">("light");
  const [loading, setLoading] = useState(false);
  const [rawCss, setRawCss] = useState("");

  const [tinteThemes, setTinteThemes] = useState<TinteThemePreview[]>([]);
  const [loadingTinteThemes, setLoadingTinteThemes] = useState(false);
  const [tinteError, setTinteError] = useState<string | null>(null);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  const [hasMore, setHasMore] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");
  const [activeSearch, setActiveSearch] = useState("");
  const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);

  useEffect(() => {
    themeRef.current = theme;
  }, [theme]);

  const [apiKeyError, setApiKeyError] = useState(false);
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: "/api/tinte/chat",
    }),
    onError: (error) => {
      console.error("Chat error:", error);
      // Check if it's an API key error
      if (error.message?.includes("OpenAI API key")) {
        setApiKeyError(true);
      }
    },
  });

  const convertToHex = useCallback((colorValue: string): string => {
    try {
      const trimmed = colorValue.trim();
      if (trimmed.startsWith("#")) {
        return trimmed; // Already hex
      }
      const colorObj = oklch(trimmed);
      if (colorObj) {
        return formatHex(colorObj);
      }
      return colorValue;
    } catch {
      return colorValue;
    }
  }, []);

  const handleApplyTheme = useCallback(
    (newTheme: { light: ShadcnTokens; dark: ShadcnTokens }) => {
      const lightHex: ShadcnTokens = {};
      const darkHex: ShadcnTokens = {};

      Object.entries(newTheme.light).forEach(([key, value]) => {
        lightHex[key] = convertToHex(value);
      });

      Object.entries(newTheme.dark).forEach(([key, value]) => {
        darkHex[key] = convertToHex(value);
      });

      const hexTheme = { light: lightHex, dark: darkHex };

      setTheme(hexTheme);
      onChange?.(hexTheme);

      setOriginalFormats({
        light: { ...lightHex },
        dark: { ...darkHex },
      });

      setHasUnsavedChanges(true);

      setTimeout(() => {
        const styleId = "tinte-dynamic-theme";
        let styleElement = document.getElementById(styleId) as HTMLStyleElement;

        if (!styleElement) {
          styleElement = document.createElement("style");
          styleElement.id = styleId;
          document.head.appendChild(styleElement);
        }

        const lightTokens = Object.entries(hexTheme.light)
          .map(([key, value]) => `  --${key}: ${value};`)
          .join("\n");

        const darkTokens = Object.entries(hexTheme.dark)
          .map(([key, value]) => `  --${key}: ${value};`)
          .join("\n");

        styleElement.textContent = `:root {\n${lightTokens}\n}\n\n.dark {\n${darkTokens}\n}`;
      }, 100);
    },
    [onChange, convertToHex],
  );

  // Detect color format
  const detectColorFormat = useCallback(
    (colorValue: string): "hex" | "oklch" | "rgb" | "hsl" | "unknown" => {
      const trimmed = colorValue.trim();
      if (trimmed.startsWith("#")) return "hex";
      if (trimmed.startsWith("oklch(")) return "oklch";
      if (trimmed.startsWith("rgb(")) return "rgb";
      if (trimmed.startsWith("hsl(")) return "hsl";
      return "unknown";
    },
    [],
  );

  // Load theme from DOM CSS variables
  const loadTheme = useCallback(async () => {
    setLoading(true);
    try {
      const root = document.documentElement;
      const computedStyle = getComputedStyle(root);

      // Get all CSS variable names that are theme-related
      const allTokens = TOKEN_GROUPS.flatMap((group) => group.tokens);

      const lightHex: ShadcnTokens = {};
      const darkHex: ShadcnTokens = {};

      // Read light mode variables
      allTokens.forEach((token) => {
        const value = computedStyle.getPropertyValue(`--${token}`).trim();
        if (value) {
          lightHex[token] = convertToHex(value);
        }
      });

      // Temporarily switch to dark mode to read dark variables
      const wasDark = root.classList.contains("dark");
      if (!wasDark) {
        root.classList.add("dark");
      }

      const darkComputedStyle = getComputedStyle(root);
      allTokens.forEach((token) => {
        const value = darkComputedStyle.getPropertyValue(`--${token}`).trim();
        if (value) {
          darkHex[token] = convertToHex(value);
        }
      });

      // Restore original theme
      if (!wasDark) {
        root.classList.remove("dark");
      }

      setTheme({ light: lightHex, dark: darkHex });
      setOriginalFormats({ light: lightHex, dark: darkHex });
    } catch (error) {
      console.error("Error loading theme from DOM:", error);
    }
    setLoading(false);
  }, [convertToHex]);

  // Fetch Tinte themes
  const fetchTinteThemes = useCallback(async (page = 1, search?: string) => {
    setLoadingTinteThemes(true);
    setTinteError(null);
    try {
      const searchParam = search ? `&search=${encodeURIComponent(search)}` : "";
      const response = await fetch(
        `https://www.tinte.dev/api/themes/public?limit=20&page=${page}${searchParam}`,
      );
      if (!response.ok) {
        throw new Error("Failed to fetch themes from Tinte");
      }
      const data = await response.json();
      setTinteThemes(data.themes || []);
      setCurrentPage(data.pagination.page);
      setHasMore(data.pagination.hasMore);
      setTotalPages(Math.ceil(data.pagination.total / data.pagination.limit));
    } catch (error) {
      console.error("Error fetching Tinte themes:", error);
      setTinteError(
        error instanceof Error ? error.message : "Failed to load themes",
      );
    } finally {
      setLoadingTinteThemes(false);
    }
  }, []);

  // Apply Tinte theme
  const applyTinteTheme = useCallback(
    (tinteTheme: TinteThemePreview) => {
      let shadcnTheme: { light: ShadcnTokens; dark: ShadcnTokens } | null =
        null;

      if (tinteTheme.rawTheme) {
        // Convert Tinte format to shadcn format
        shadcnTheme = convertTinteToShadcn(tinteTheme.rawTheme);
      } else if (
        tinteTheme.overrides?.shadcn?.light &&
        tinteTheme.overrides?.shadcn?.dark
      ) {
        // Use shadcn override only if it has light and dark color objects
        shadcnTheme = tinteTheme.overrides.shadcn;
      }

      if (shadcnTheme) {
        // Convert all colors to hex format
        const lightHex: ShadcnTokens = {};
        const darkHex: ShadcnTokens = {};

        Object.entries(shadcnTheme.light).forEach(([key, value]) => {
          lightHex[key] = convertToHex(value);
        });

        Object.entries(shadcnTheme.dark).forEach(([key, value]) => {
          darkHex[key] = convertToHex(value);
        });

        const hexTheme = { light: lightHex, dark: darkHex };

        setTheme(hexTheme);
        onChange?.(hexTheme);
        setSelectedThemeId(tinteTheme.id);
        setHasUnsavedChanges(true);
      }
    },
    [onChange, convertToHex],
  );

  // Initialize theme
  useEffect(() => {
    const root = document.documentElement;
    const isDark = root.classList.contains("dark");
    setMode(isDark ? "dark" : "light");
    loadTheme();
  }, [loadTheme]);

  // Fetch Tinte themes when dialog opens
  useEffect(() => {
    if (isOpen && tinteThemes.length === 0) {
      fetchTinteThemes();
    }
  }, [isOpen, tinteThemes.length, fetchTinteThemes]);

  const handleTokenEdit = useCallback(
    (token: string, newValue: string) => {
      setTheme((prev) => {
        const updated = {
          ...prev,
          [mode]: {
            ...prev[mode],
            [token]: newValue,
          },
        };

        onChange?.(updated);
        return updated;
      });

      // Update original formats with new value
      setOriginalFormats((prev) => ({
        ...prev,
        [mode]: {
          ...prev[mode],
          [token]: newValue,
        },
      }));

      // Mark as unsaved
      setHasUnsavedChanges(true);
    },
    [mode, onChange],
  );

  // Sync mode with DOM changes (controlled by next-themes)
  useEffect(() => {
    const observer = new MutationObserver(() => {
      const isDark = document.documentElement.classList.contains("dark");
      setMode(isDark ? "dark" : "light");
    });

    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    return () => observer.disconnect();
  }, []);

  // Generate raw CSS from theme
  const generateRawCss = useCallback(() => {
    if (!theme.light || !theme.dark) return "";

    const lightTokens = Object.entries(theme.light)
      .map(([key, value]) => `  --${key}: ${value};`)
      .join("\n");

    const darkTokens = Object.entries(theme.dark)
      .map(([key, value]) => `  --${key}: ${value};`)
      .join("\n");

    if (!lightTokens && !darkTokens) return "";

    return `:root {\n${lightTokens}\n}\n\n.dark {\n${darkTokens}\n}`;
  }, [theme]);

  // Parse raw CSS and update theme
  const parseRawCss = useCallback(
    (css: string) => {
      try {
        const light: ShadcnTokens = {};
        const dark: ShadcnTokens = {};

        // Match :root block
        const rootMatch = css.match(/:root\s*\{([^}]+)\}/);
        if (rootMatch) {
          const rootContent = rootMatch[1];
          const variableMatches = rootContent.matchAll(
            /--([^:]+):\s*([^;]+);/g,
          );
          for (const match of variableMatches) {
            const key = match[1].trim();
            const value = match[2].trim();
            light[key] = value;
          }
        }

        // Match .dark block
        const darkMatch = css.match(/\.dark\s*\{([^}]+)\}/);
        if (darkMatch) {
          const darkContent = darkMatch[1];
          const variableMatches = darkContent.matchAll(
            /--([^:]+):\s*([^;]+);/g,
          );
          for (const match of variableMatches) {
            const key = match[1].trim();
            const value = match[2].trim();
            dark[key] = value;
          }
        }

        setTheme({ light, dark });
        onChange?.({ light, dark });
      } catch (error) {
        console.error("Failed to parse CSS:", error);
      }
    },
    [onChange],
  );

  // Update raw CSS when theme changes
  useEffect(() => {
    setRawCss(generateRawCss());
  }, [generateRawCss]);

  // Write to globals.css file
  const [saveStatus, setSaveStatus] = useState<
    "idle" | "saving" | "success" | "error"
  >("idle");

  const writeToGlobals = useCallback(async () => {
    // Use ref to get the latest theme state
    const currentTheme = themeRef.current;

    if (!currentTheme.light || !currentTheme.dark) {
      console.error("Theme is not fully loaded");
      return;
    }

    setSaveStatus("saving");

    try {
      // Ensure all colors are in hex format before applying
      const lightHex: ShadcnTokens = {};
      const darkHex: ShadcnTokens = {};

      Object.entries(currentTheme.light).forEach(([key, value]) => {
        lightHex[key] = convertToHex(value);
      });

      Object.entries(currentTheme.dark).forEach(([key, value]) => {
        darkHex[key] = convertToHex(value);
      });

      const styleId = "tinte-dynamic-theme";
      let styleElement = document.getElementById(styleId) as HTMLStyleElement;

      if (!styleElement) {
        styleElement = document.createElement("style");
        styleElement.id = styleId;
        document.head.appendChild(styleElement);
      }

      const lightTokens = Object.entries(lightHex)
        .map(([key, value]) => `  --${key}: ${value};`)
        .join("\n");

      const darkTokens = Object.entries(darkHex)
        .map(([key, value]) => `  --${key}: ${value};`)
        .join("\n");

      styleElement.textContent = `:root {\n${lightTokens}\n}\n\n.dark {\n${darkTokens}\n}`;

      setSaveStatus("success");
      setHasUnsavedChanges(false);
      setTimeout(() => setSaveStatus("idle"), 2000);
    } catch (error) {
      console.error("Error applying theme to DOM:", error);
      setSaveStatus("error");
      setTimeout(() => setSaveStatus("idle"), 2000);
    }
  }, [convertToHex]);

  const _availableTokens = TOKEN_GROUPS.flatMap((group) =>
    group.tokens.filter((token) => theme[mode]?.[token] !== undefined),
  );

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      {/* Floating Ball Trigger */}
      <div className="fixed bottom-4 right-4 z-50">
        <DialogTrigger asChild>
          <button
            type="button"
            className="w-14 h-14 bg-card border-2 border-border rounded-full shadow-lg hover:scale-110 transition-all duration-200 flex items-center justify-center hover:shadow-xl"
            title="Open Theme Editor"
          >
            <TinteLogo className="w-7 h-7 drop-shadow-sm" />
          </button>
        </DialogTrigger>
      </div>

      {/* Dialog Content */}
      <DialogContent showCloseButton={false} className="sm:max-w-2xl">
        {/* Header */}
        <DialogHeader>
          <div className="flex items-center justify-between w-full">
            <div className="flex items-center gap-2">
              <TinteLogo className="w-5 h-5" />
              <DialogTitle className="text-base">Theme Editor</DialogTitle>
              <a
                href="https://tinte.dev"
                target="_blank"
                rel="noopener noreferrer"
                className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-1"
              >
                tinte.dev ↗
              </a>
            </div>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={loadTheme}
                disabled={loading}
                className="p-1.5 hover:bg-accent rounded-md transition-colors disabled:opacity-50"
                title="Reload from globals.css"
              >
                <RefreshCw
                  size={14}
                  className={loading ? "animate-spin" : ""}
                />
              </button>
              <button
                type="button"
                onClick={writeToGlobals}
                disabled={saveStatus === "saving"}
                className={`relative px-3 py-1.5 text-xs rounded-md transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
                  hasUnsavedChanges
                    ? "bg-primary text-primary-foreground hover:bg-primary/90 animate-pulse"
                    : "bg-primary/80 text-primary-foreground hover:bg-primary/90"
                }`}
              >
                {hasUnsavedChanges && saveStatus === "idle" && (
                  <span className="absolute -top-1 -right-1 flex h-3 w-3">
                    <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75" />
                    <span className="relative inline-flex rounded-full h-3 w-3 bg-destructive" />
                  </span>
                )}
                {saveStatus === "saving" && "Saving..."}
                {saveStatus === "success" && "✅ Saved!"}
                {saveStatus === "error" && "❌ Error"}
                {saveStatus === "idle" &&
                  (hasUnsavedChanges ? "💾 Save" : "Save")}
              </button>
            </div>
          </div>
        </DialogHeader>

        {/* Content */}
        <div className="flex-1 overflow-hidden flex flex-col">
          {loading ? (
            <div className="flex items-center justify-center py-8">
              <RefreshCw className="animate-spin mr-2" size={20} />
              <span>Loading theme...</span>
            </div>
          ) : (
            <Tabs
              defaultValue="editor"
              className="flex-1 flex flex-col overflow-hidden"
            >
              <TabsList className="mx-4 mt-4 mb-4">
                <TabsTrigger value="editor">Editor</TabsTrigger>
                <TabsTrigger value="browse">Browse</TabsTrigger>
                <TabsTrigger value="raw">Raw CSS</TabsTrigger>
                <TabsTrigger value="agent">Agent</TabsTrigger>
              </TabsList>

              <TabsContent
                value="editor"
                className="flex-1 h-0 flex flex-col overflow-hidden px-4 pb-4"
              >
                <div className="h-[500px] border rounded-md bg-muted/20 overflow-y-auto p-4">
                  <Accordion
                    type="single"
                    collapsible
                    className="w-full space-y-2"
                    defaultValue="Background & Text"
                  >
                    {TOKEN_GROUPS.map((group) => {
                      const groupTokens = group.tokens.filter(
                        (token) => theme[mode]?.[token] !== undefined,
                      );
                      if (groupTokens.length === 0) return null;

                      return (
                        <AccordionItem
                          value={group.label}
                          key={group.label}
                          className="rounded-md border bg-background px-4 py-1 outline-none last:border-b has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50"
                        >
                          <AccordionTrigger className="py-2 text-[15px] leading-6 hover:no-underline focus-visible:ring-0">
                            <span className="uppercase tracking-wide">
                              {group.label} ({groupTokens.length})
                            </span>
                          </AccordionTrigger>
                          <AccordionContent className="pb-2">
                            <div className="grid gap-3 sm:grid-cols-2">
                              {groupTokens.map((token) => (
                                <div key={token} className="space-y-1.5">
                                  <div className="flex items-center justify-between">
                                    <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                                      {token.replace(/-/g, " ")}
                                    </span>
                                    <span className="text-xs text-muted-foreground font-mono">
                                      {detectColorFormat(theme[mode][token])}
                                    </span>
                                  </div>
                                  <ColorInput
                                    value={theme[mode][token]}
                                    onChange={(color) =>
                                      handleTokenEdit(token, color)
                                    }
                                    label={token}
                                  />
                                </div>
                              ))}
                            </div>
                          </AccordionContent>
                        </AccordionItem>
                      );
                    })}
                  </Accordion>
                </div>
              </TabsContent>

              <TabsContent
                value="browse"
                className="flex-1 h-0 flex flex-col overflow-hidden px-4 pb-4"
              >
                <div className="flex flex-col gap-4 h-[500px]">
                  <div className="flex-1 border rounded-md bg-muted/20 overflow-y-auto p-4">
                    {loadingTinteThemes ? (
                      <div className="flex flex-col items-center justify-center h-full gap-3">
                        <Loader2 className="animate-spin" size={32} />
                        <p className="text-sm text-muted-foreground">
                          Loading themes from tinte.dev...
                        </p>
                      </div>
                    ) : tinteError ? (
                      <div className="flex flex-col items-center justify-center h-full gap-4">
                        <div className="text-4xl">⚠️</div>
                        <div className="text-center space-y-2 max-w-md">
                          <h3 className="font-semibold text-lg">
                            Failed to Load Themes
                          </h3>
                          <p className="text-sm text-muted-foreground">
                            {tinteError}
                          </p>
                          <Button
                            variant="outline"
                            onClick={() => fetchTinteThemes()}
                            className="mt-2"
                          >
                            <RefreshCw size={16} className="mr-2" />
                            Try Again
                          </Button>
                        </div>
                      </div>
                    ) : tinteThemes.length === 0 ? (
                      <div className="flex flex-col items-center justify-center h-full gap-3">
                        <p className="text-sm text-muted-foreground">
                          No themes available
                        </p>
                        <Button
                          variant="outline"
                          onClick={() => fetchTinteThemes()}
                          size="sm"
                        >
                          <RefreshCw size={16} className="mr-2" />
                          Refresh
                        </Button>
                      </div>
                    ) : (
                      <div className="space-y-3">
                        <div className="space-y-3 mb-4">
                          <div className="flex items-center gap-2">
                            <div className="relative flex-1">
                              <Input
                                type="text"
                                placeholder="Search themes..."
                                value={searchQuery}
                                onChange={(e) => setSearchQuery(e.target.value)}
                                onKeyDown={(e) => {
                                  if (e.key === "Enter") {
                                    setActiveSearch(searchQuery);
                                    fetchTinteThemes(1, searchQuery);
                                  }
                                }}
                                className="h-9 pr-8"
                              />
                              {searchQuery && (
                                <button
                                  type="button"
                                  onClick={() => {
                                    setSearchQuery("");
                                    setActiveSearch("");
                                    fetchTinteThemes(1);
                                  }}
                                  className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-sm transition-colors"
                                >
                                  <X className="h-3 w-3 text-muted-foreground" />
                                </button>
                              )}
                            </div>
                            <Button
                              variant="outline"
                              size="sm"
                              onClick={() => {
                                setActiveSearch(searchQuery);
                                fetchTinteThemes(1, searchQuery);
                              }}
                              disabled={!searchQuery}
                              className="h-9"
                            >
                              <Search className="h-3.5 w-3.5 mr-1.5" />
                              Search
                            </Button>
                            <Button
                              variant="outline"
                              size="icon"
                              onClick={() =>
                                fetchTinteThemes(currentPage, activeSearch)
                              }
                              title="Refresh themes"
                              className="h-9 w-9"
                            >
                              <RefreshCw className="h-3.5 w-3.5" />
                            </Button>
                          </div>
                          <div className="flex items-center justify-between">
                            <p className="text-xs text-muted-foreground">
                              {tinteThemes.length} themes
                              {activeSearch
                                ? ` matching "${activeSearch}"`
                                : ""}
                            </p>
                          </div>
                        </div>
                        <div className="grid gap-3">
                          {tinteThemes.map((tinteTheme) => {
                            const isSelected =
                              selectedThemeId === tinteTheme.id;
                            return (
                              <button
                                key={tinteTheme.id}
                                type="button"
                                onClick={() => applyTinteTheme(tinteTheme)}
                                className={`group text-left p-4 border-2 rounded-lg transition-all relative ${
                                  isSelected
                                    ? "border-primary bg-primary/10 shadow-md"
                                    : "border-border hover:border-primary hover:bg-accent/50"
                                }`}
                              >
                                {isSelected && (
                                  <div className="absolute top-2 right-2 bg-primary text-primary-foreground text-xs px-2 py-0.5 rounded-full font-medium">
                                    Selected
                                  </div>
                                )}
                                <div className="flex items-start justify-between gap-3">
                                  <div className="flex-1 space-y-1.5">
                                    <h4
                                      className={`font-medium transition-colors ${
                                        isSelected
                                          ? "text-primary"
                                          : "group-hover:text-primary"
                                      }`}
                                    >
                                      {tinteTheme.name}
                                    </h4>
                                    {tinteTheme.concept && (
                                      <p className="text-xs text-muted-foreground line-clamp-2">
                                        {tinteTheme.concept}
                                      </p>
                                    )}
                                  </div>
                                  <div className="flex gap-1.5 shrink-0">
                                    {[
                                      tinteTheme.colors.background,
                                      tinteTheme.colors.primary,
                                      tinteTheme.colors.secondary,
                                      tinteTheme.colors.accent,
                                      tinteTheme.colors.foreground,
                                    ].map((color, idx) => (
                                      <div
                                        key={`${tinteTheme.id}-color-${idx}`}
                                        className="w-6 h-6 rounded border border-border/50"
                                        style={{ backgroundColor: color }}
                                        title={color}
                                      />
                                    ))}
                                  </div>
                                </div>
                              </button>
                            );
                          })}
                        </div>
                      </div>
                    )}
                  </div>
                  {/* Pagination Controls */}
                  {!loadingTinteThemes &&
                    !tinteError &&
                    tinteThemes.length > 0 && (
                      <div className="flex items-center justify-between px-2 py-3 border-t">
                        <div className="text-xs text-muted-foreground">
                          Page {currentPage} of {totalPages}
                        </div>
                        <div className="flex gap-2">
                          <Button
                            variant="outline"
                            size="sm"
                            onClick={() =>
                              fetchTinteThemes(currentPage - 1, activeSearch)
                            }
                            disabled={currentPage === 1 || loadingTinteThemes}
                          >
                            Previous
                          </Button>
                          <Button
                            variant="outline"
                            size="sm"
                            onClick={() =>
                              fetchTinteThemes(currentPage + 1, activeSearch)
                            }
                            disabled={!hasMore || loadingTinteThemes}
                          >
                            Next
                          </Button>
                        </div>
                      </div>
                    )}
                </div>
              </TabsContent>

              <TabsContent
                value="raw"
                className="flex-1 h-0 flex flex-col overflow-hidden px-4 pb-4"
              >
                <Textarea
                  value={rawCss}
                  onChange={(e) => {
                    setRawCss(e.target.value);
                    parseRawCss(e.target.value);
                  }}
                  className="h-[500px] w-full bg-muted/40 font-mono text-xs resize-none border border-border focus-visible:ring-0 p-4"
                  placeholder="Paste your CSS here..."
                  spellCheck={false}
                />
              </TabsContent>

              <TabsContent
                value="agent"
                className="flex-1 h-0 flex flex-col overflow-hidden px-4 pb-4"
              >
                <div className="h-[500px] flex flex-col gap-3">
                  <div className="flex-1 border rounded-md bg-muted/20 overflow-y-auto p-4 space-y-2">
                    {apiKeyError ? (
                      <div className="flex flex-col items-center justify-center h-full gap-4">
                        <div className="text-center space-y-3 max-w-md">
                          <div className="text-4xl">🔑</div>
                          <h3 className="font-semibold text-lg">
                            OpenAI API Key Required
                          </h3>
                          <p className="text-muted-foreground text-sm">
                            To use the AI Theme Generator, you need to configure
                            your OpenAI API key.
                          </p>
                          <div className="bg-muted rounded-lg p-4 text-left space-y-2">
                            <p className="text-xs font-medium">
                              Add to your{" "}
                              <code className="bg-background px-1.5 py-0.5 rounded">
                                .env.local
                              </code>{" "}
                              file:
                            </p>
                            <pre className="bg-background p-2 rounded text-xs overflow-x-auto">
                              <code>OPENAI_API_KEY=your-api-key-here</code>
                            </pre>
                            <p className="text-xs text-muted-foreground">
                              Get your API key from{" "}
                              <a
                                href="https://platform.openai.com/api-keys"
                                target="_blank"
                                rel="noopener noreferrer"
                                className="text-primary hover:underline"
                              >
                                platform.openai.com/api-keys
                              </a>
                            </p>
                          </div>
                          <Button
                            variant="outline"
                            onClick={() => {
                              setApiKeyError(false);
                            }}
                            className="mt-2"
                          >
                            I've added the API key
                          </Button>
                        </div>
                      </div>
                    ) : messages.length === 0 ? (
                      <div className="flex flex-col items-center justify-center gap-6">
                        <div className="text-center space-y-2">
                          <h3 className="font-semibold text-lg">
                            AI Theme Generator
                          </h3>
                          <p className="text-muted-foreground text-sm max-w-md">
                            Describe your ideal theme and let AI generate a
                            complete color palette for you
                          </p>
                        </div>
                        <div className="grid gap-2 w-full max-w-md px-4">
                          <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
                            Suggested prompts:
                          </p>
                          <Button
                            variant="outline"
                            onClick={() =>
                              sendMessage({
                                text: "Create a purple theme with high contrast for accessibility",
                              })
                            }
                            className="justify-start h-auto py-3 whitespace-normal text-left"
                          >
                            Create a purple theme with high contrast
                          </Button>
                          <Button
                            variant="outline"
                            onClick={() =>
                              sendMessage({
                                text: "Generate a warm autumn theme with orange and brown tones",
                              })
                            }
                            className="justify-start h-auto py-3 whitespace-normal text-left"
                          >
                            Generate a warm autumn theme
                          </Button>
                          <Button
                            variant="outline"
                            onClick={() =>
                              sendMessage({
                                text: "Create a modern dark theme with blue accents",
                              })
                            }
                            className="justify-start h-auto py-3 whitespace-normal text-left"
                          >
                            Create a modern dark theme with blue accents
                          </Button>
                          <Button
                            variant="outline"
                            onClick={() =>
                              sendMessage({
                                text: "Design a soft pastel theme perfect for a wellness app",
                              })
                            }
                            className="justify-start h-auto py-3 whitespace-normal text-left"
                          >
                            Design a soft pastel wellness theme
                          </Button>
                        </div>
                      </div>
                    ) : (
                      messages.map((message) => (
                        <ChatMessage
                          key={message.id}
                          message={message}
                          onApplyTheme={handleApplyTheme}
                        />
                      ))
                    )}
                  </div>
                  <ChatInput
                    onSubmit={(msg) => {
                      sendMessage({ text: msg });
                    }}
                    disabled={status === "submitted" || status === "streaming"}
                  />
                </div>
              </TabsContent>
            </Tabs>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
}
registry/default/blocks/tinte/tinte-editor/components/color-input.tsx
"use client";

import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";

import { hexToHsva, hsvaToHex } from "@uiw/color-convert";
import { Colorful } from "@uiw/react-color";
import { hsl as culoriHsl, formatHex, lch, oklch, rgb } from "culori";
import { ChevronDown } from "lucide-react";

import { cn } from "@/lib/utils";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

interface ColorInputProps {
  value: string;
  onChange: (value: string) => void;
  label: string;
  disabled?: boolean;
}

const getContrastColor = (hexColor: string): string => {
  const hex = hexColor.replace("#", "");
  const r = parseInt(hex.substring(0, 2), 16);
  const g = parseInt(hex.substring(2, 4), 16);
  const b = parseInt(hex.substring(4, 6), 16);
  const brightness = (r * 299 + g * 587 + b * 114) / 1000;
  return brightness > 128 ? "#000000" : "#ffffff";
};

const toHex = (colorValue: string): string => {
  try {
    if (colorValue.startsWith("#")) return colorValue;
    if (colorValue.startsWith("oklch(")) {
      const parsed = oklch(colorValue);
      return parsed ? formatHex(parsed) : "#000000";
    }
    if (colorValue.startsWith("lch(")) {
      const parsed = lch(colorValue);
      return parsed ? formatHex(parsed) : "#000000";
    }
    if (colorValue.startsWith("hsl(")) {
      const parsed = culoriHsl(colorValue);
      return parsed ? formatHex(parsed) : "#000000";
    }
    if (colorValue.startsWith("rgb(")) {
      const parsed = rgb(colorValue);
      return parsed ? formatHex(parsed) : "#000000";
    }
    return "#000000";
  } catch {
    return "#000000";
  }
};

const detectFormat = (
  colorValue: string,
): "hex" | "rgb" | "hsl" | "oklch" | "lch" => {
  if (colorValue.startsWith("oklch(")) return "oklch";
  if (colorValue.startsWith("lch(")) return "lch";
  if (colorValue.startsWith("hsl(")) return "hsl";
  if (colorValue.startsWith("rgb(")) return "rgb";
  return "hex";
};

const fromHex = (
  hexValue: string,
  format: "hex" | "rgb" | "hsl" | "oklch" | "lch",
): string => {
  try {
    const rgbColor = rgb(hexValue);
    if (!rgbColor) return hexValue;
    switch (format) {
      case "hex":
        return hexValue;
      case "rgb": {
        const r = Math.round(rgbColor.r * 255);
        const g = Math.round(rgbColor.g * 255);
        const b = Math.round(rgbColor.b * 255);
        return `rgb(${r} ${g} ${b})`;
      }
      case "hsl": {
        const hslColor = culoriHsl(hexValue);
        if (!hslColor) return hexValue;
        const h = Math.round(hslColor.h || 0);
        const s = Math.round((hslColor.s || 0) * 100);
        const l = Math.round((hslColor.l || 0) * 100);
        return `hsl(${h} ${s}% ${l}%)`;
      }
      case "oklch": {
        const oklchColor = oklch(hexValue);
        if (!oklchColor) return hexValue;
        const l = (oklchColor.l || 0).toFixed(3);
        const c = (oklchColor.c || 0).toFixed(3);
        const h = Math.round(oklchColor.h || 0);
        return `oklch(${l} ${c} ${h})`;
      }
      case "lch": {
        const lchColor = lch(hexValue);
        if (!lchColor) return hexValue;
        const l = Math.round(lchColor.l || 0);
        const c = Math.round(lchColor.c || 0);
        const h = Math.round(lchColor.h || 0);
        return `lch(${l} ${c} ${h})`;
      }
      default:
        return hexValue;
    }
  } catch {
    return hexValue;
  }
};

export function ColorInput({
  value,
  onChange,
  label,
  disabled = false,
}: ColorInputProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [inputValue, setInputValue] = useState(value);
  const [activeTab, setActiveTab] = useState("hex");
  const originalFormat = useMemo(() => detectFormat(value), [value]);

  useEffect(() => {
    setInputValue(value);
  }, [value]);

  const hexColor = useMemo(() => toHex(inputValue), [inputValue]);
  const contrastColor = useMemo(() => {
    if (!/^#[0-9A-Fa-f]{6}$/.test(hexColor)) return "#000000";
    return getContrastColor(hexColor);
  }, [hexColor]);

  const colorValues = useMemo(() => {
    if (!/^#[0-9A-Fa-f]{6}$/.test(hexColor)) {
      return {
        hex: "#000000",
        rgb: { r: 0, g: 0, b: 0 },
        hsl: { h: 0, s: 0, l: 0 },
        oklch: { l: 0, c: 0, h: 0 },
        lch: { l: 0, c: 0, h: 0 },
      };
    }
    const rgbColor = rgb(hexColor);
    const hslColor = culoriHsl(hexColor);
    const oklchColor = oklch(hexColor);
    const lchColor = lch(hexColor);
    return {
      hex: hexColor,
      rgb: rgbColor
        ? {
            r: Math.round(rgbColor.r * 255),
            g: Math.round(rgbColor.g * 255),
            b: Math.round(rgbColor.b * 255),
          }
        : { r: 0, g: 0, b: 0 },
      hsl: hslColor
        ? {
            h: Math.round(hslColor.h || 0),
            s: Math.round((hslColor.s || 0) * 100),
            l: Math.round((hslColor.l || 0) * 100),
          }
        : { h: 0, s: 0, l: 0 },
      oklch: oklchColor
        ? {
            l: Math.round((oklchColor.l || 0) * 100),
            c: Math.round((oklchColor.c || 0) * 100),
            h: Math.round(oklchColor.h || 0),
          }
        : { l: 0, c: 0, h: 0 },
      lch: lchColor
        ? {
            l: Math.round(lchColor.l || 0),
            c: Math.round(lchColor.c || 0),
            h: Math.round(lchColor.h || 0),
          }
        : { l: 0, c: 0, h: 0 },
    };
  }, [hexColor]);

  // Compute display value based on active tab
  const displayValue = useMemo(() => {
    switch (activeTab) {
      case "hex":
        return colorValues.hex;
      case "rgb":
        return `rgb(${colorValues.rgb.r} ${colorValues.rgb.g} ${colorValues.rgb.b})`;
      case "hsl":
        return `hsl(${colorValues.hsl.h} ${colorValues.hsl.s}% ${colorValues.hsl.l}%)`;
      case "oklch":
        return `oklch(${(colorValues.oklch.l / 100).toFixed(3)} ${(colorValues.oklch.c / 100).toFixed(3)} ${colorValues.oklch.h})`;
      case "lch":
        return `lch(${colorValues.lch.l} ${colorValues.lch.c} ${colorValues.lch.h})`;
      default:
        return colorValues.hex;
    }
  }, [activeTab, colorValues]);

  const handleInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setInputValue(newValue);
      if (/^#[0-9A-Fa-f]{6}$/.test(newValue)) {
        onChange(fromHex(newValue, originalFormat));
      }
    },
    [onChange, originalFormat],
  );

  const handleInputBlur = useCallback(() => {
    if (!/^#[0-9A-Fa-f]{6}$/.test(inputValue)) {
      setInputValue(value);
    }
  }, [inputValue, value]);

  const handleColorfulChange = useCallback(
    (newColor: { hsva: { h: number; s: number; v: number; a: number } }) => {
      const hex = hsvaToHex(newColor.hsva);
      setInputValue(hex);
      onChange(fromHex(hex, originalFormat));
    },
    [onChange, originalFormat],
  );

  const handleQuickColorClick = useCallback(
    (quickColor: string) => {
      setInputValue(quickColor);
      onChange(fromHex(quickColor, originalFormat));
    },
    [onChange, originalFormat],
  );

  const handleColorSpaceChange = useCallback(
    (values: Record<string, number>, colorSpace: string) => {
      let newHex: string | undefined;
      switch (colorSpace) {
        case "rgb":
          newHex = formatHex(
            rgb({
              mode: "rgb",
              r: values.r / 255,
              g: values.g / 255,
              b: values.b / 255,
            }),
          );
          break;
        case "hsl":
          newHex = formatHex(
            culoriHsl({
              mode: "hsl",
              h: values.h,
              s: values.s / 100,
              l: values.l / 100,
            }),
          );
          break;
        case "oklch":
          newHex = formatHex(
            oklch({
              mode: "oklch",
              l: values.l / 100,
              c: values.c / 100,
              h: values.h,
            }),
          );
          break;
        case "lch":
          newHex = formatHex(
            lch({ mode: "lch", l: values.l, c: values.c, h: values.h }),
          );
          break;
        default:
          return;
      }
      if (newHex && /^#[0-9A-Fa-f]{6}$/.test(newHex)) {
        setInputValue(newHex);
        onChange(fromHex(newHex, originalFormat));
      }
    },
    [onChange, originalFormat],
  );

  const hsvaColor = useMemo(() => {
    if (!/^#[0-9A-Fa-f]{6}$/.test(hexColor)) {
      return { h: 0, s: 0, v: 0, a: 1 };
    }
    return hexToHsva(hexColor);
  }, [hexColor]);

  const quickColors = useMemo(
    () => [
      "#000000",
      "#ffffff",
      "#ef4444",
      "#22c55e",
      "#3b82f6",
      "#f59e0b",
      "#a855f7",
      "#ec4899",
    ],
    [],
  );

  return (
    <div className="flex items-center gap-3 py-2">
      <Popover
        open={isOpen && !disabled}
        onOpenChange={(open) => !disabled && setIsOpen(open)}
      >
        <PopoverTrigger asChild>
          <Button
            variant="outline"
            className={cn(
              "h-10 justify-between font-mono text-sm w-full",
              disabled && "opacity-50 cursor-not-allowed",
            )}
            disabled={disabled}
            style={{ backgroundColor: hexColor, color: contrastColor }}
          >
            <span className="truncate">{displayValue}</span>
            <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-70" />
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-[320px] p-0" align="end" sideOffset={8}>
          <div className="p-4 space-y-4">
            <Colorful
              color={hsvaColor}
              onChange={handleColorfulChange}
              disableAlpha={true}
              style={{ width: "100%" }}
            />

            <Tabs
              value={activeTab}
              onValueChange={setActiveTab}
              className="w-full"
            >
              <TabsList className="grid w-full grid-cols-5 h-9">
                <TabsTrigger value="hex" className="text-xs">
                  HEX
                </TabsTrigger>
                <TabsTrigger value="rgb" className="text-xs">
                  RGB
                </TabsTrigger>
                <TabsTrigger value="hsl" className="text-xs">
                  HSL
                </TabsTrigger>
                <TabsTrigger value="oklch" className="text-xs">
                  OKLCH
                </TabsTrigger>
                <TabsTrigger value="lch" className="text-xs">
                  LCH
                </TabsTrigger>
              </TabsList>

              <TabsContent value="hex" className="mt-3 space-y-0">
                <Input
                  value={inputValue}
                  onChange={handleInputChange}
                  onBlur={handleInputBlur}
                  placeholder="#000000"
                  className="font-mono text-sm h-9"
                  disabled={disabled}
                />
              </TabsContent>

              <TabsContent value="rgb" className="mt-3 space-y-0">
                <div className="grid grid-cols-3 gap-2">
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">R</Label>
                    <Input
                      type="number"
                      value={colorValues.rgb.r}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.rgb, r: Number(e.target.value) },
                          "rgb",
                        )
                      }
                      min="0"
                      max="255"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">G</Label>
                    <Input
                      type="number"
                      value={colorValues.rgb.g}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.rgb, g: Number(e.target.value) },
                          "rgb",
                        )
                      }
                      min="0"
                      max="255"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">B</Label>
                    <Input
                      type="number"
                      value={colorValues.rgb.b}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.rgb, b: Number(e.target.value) },
                          "rgb",
                        )
                      }
                      min="0"
                      max="255"
                      className="h-9 text-sm"
                    />
                  </div>
                </div>
              </TabsContent>

              <TabsContent value="hsl" className="mt-3 space-y-0">
                <div className="grid grid-cols-3 gap-2">
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">H</Label>
                    <Input
                      type="number"
                      value={colorValues.hsl.h}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.hsl, h: Number(e.target.value) },
                          "hsl",
                        )
                      }
                      min="0"
                      max="360"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">S</Label>
                    <Input
                      type="number"
                      value={colorValues.hsl.s}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.hsl, s: Number(e.target.value) },
                          "hsl",
                        )
                      }
                      min="0"
                      max="100"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">L</Label>
                    <Input
                      type="number"
                      value={colorValues.hsl.l}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.hsl, l: Number(e.target.value) },
                          "hsl",
                        )
                      }
                      min="0"
                      max="100"
                      className="h-9 text-sm"
                    />
                  </div>
                </div>
              </TabsContent>

              <TabsContent value="oklch" className="mt-3 space-y-0">
                <div className="grid grid-cols-3 gap-2">
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">L</Label>
                    <Input
                      type="number"
                      value={colorValues.oklch.l}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.oklch, l: Number(e.target.value) },
                          "oklch",
                        )
                      }
                      min="0"
                      max="100"
                      step="0.1"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">C</Label>
                    <Input
                      type="number"
                      value={colorValues.oklch.c}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.oklch, c: Number(e.target.value) },
                          "oklch",
                        )
                      }
                      min="0"
                      max="100"
                      step="0.1"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">H</Label>
                    <Input
                      type="number"
                      value={colorValues.oklch.h}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.oklch, h: Number(e.target.value) },
                          "oklch",
                        )
                      }
                      min="0"
                      max="360"
                      className="h-9 text-sm"
                    />
                  </div>
                </div>
              </TabsContent>

              <TabsContent value="lch" className="mt-3 space-y-0">
                <div className="grid grid-cols-3 gap-2">
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">L</Label>
                    <Input
                      type="number"
                      value={colorValues.lch.l}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.lch, l: Number(e.target.value) },
                          "lch",
                        )
                      }
                      min="0"
                      max="100"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">C</Label>
                    <Input
                      type="number"
                      value={colorValues.lch.c}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.lch, c: Number(e.target.value) },
                          "lch",
                        )
                      }
                      min="0"
                      max="150"
                      className="h-9 text-sm"
                    />
                  </div>
                  <div className="space-y-1.5">
                    <Label className="text-xs text-muted-foreground">H</Label>
                    <Input
                      type="number"
                      value={colorValues.lch.h}
                      onChange={(e) =>
                        handleColorSpaceChange(
                          { ...colorValues.lch, h: Number(e.target.value) },
                          "lch",
                        )
                      }
                      min="0"
                      max="360"
                      className="h-9 text-sm"
                    />
                  </div>
                </div>
              </TabsContent>
            </Tabs>

            <div className="space-y-2">
              <Label className="text-xs text-muted-foreground">
                Quick Colors
              </Label>
              <div className="grid grid-cols-8 gap-2">
                {quickColors.map((quickColor) => (
                  <button
                    key={quickColor}
                    type="button"
                    className="w-full aspect-square rounded border-2 border-border hover:border-foreground hover:scale-110 transition-all"
                    style={{ backgroundColor: quickColor }}
                    onClick={() => handleQuickColorClick(quickColor)}
                    disabled={disabled}
                    aria-label={`Quick color ${quickColor}`}
                  />
                ))}
              </div>
            </div>
          </div>
        </PopoverContent>
      </Popover>
    </div>
  );
}
registry/default/blocks/tinte/tinte-editor/components/chat-container.tsx
import type { HTMLAttributes } from "react";

import { cn } from "@/lib/utils";

export type ChatContainerProps = HTMLAttributes<HTMLDivElement>;

export const ChatContainer = ({
  className,
  children,
  ...props
}: ChatContainerProps) => (
  <div
    className={cn(
      "flex-1 overflow-y-auto p-4 space-y-2 bg-muted/20 rounded-md border",
      className,
    )}
    {...props}
  >
    {children}
  </div>
);
registry/default/blocks/tinte/tinte-editor/components/chat-input.tsx
import type { ComponentProps, FormEvent } from "react";
import { useState } from "react";

import { SendIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";

export type ChatInputProps = Omit<ComponentProps<"form">, "onSubmit"> & {
  onSubmit: (message: string) => void;
  disabled?: boolean;
  placeholder?: string;
};

export const ChatInput = ({
  className,
  onSubmit,
  disabled,
  placeholder = "Ask the AI to generate or modify your theme...",
  ...props
}: ChatInputProps) => {
  const [input, setInput] = useState("");

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (!input.trim() || disabled) return;
    onSubmit(input);
    setInput("");
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSubmit(e);
    }
  };

  return (
    <form
      className={cn("flex gap-2 border rounded-md p-2", className)}
      onSubmit={handleSubmit}
      {...props}
    >
      <Textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        disabled={disabled}
        className="flex-1 font-sans text-sm resize-none"
        style={{ minHeight: "80px" }}
      />
      <Button
        type="submit"
        size="icon"
        disabled={!input.trim() || disabled}
        className="shrink-0 self-end"
      >
        <SendIcon className="size-4" />
      </Button>
    </form>
  );
};
registry/default/blocks/tinte/tinte-editor/components/chat-message.tsx
import type { HTMLAttributes } from "react";

import type { UIMessage } from "ai";
import { Streamdown } from "streamdown";

import { cn } from "@/lib/utils";

export type MessageProps = HTMLAttributes<HTMLDivElement> & {
  message: UIMessage;
  onApplyTheme?: (theme: { light: any; dark: any }) => void;
};

export const Message = ({
  className,
  message,
  onApplyTheme,
  ...props
}: MessageProps) => {
  return (
    <div
      className={cn(
        "flex w-full gap-2 py-2",
        message.role === "user" ? "justify-end" : "justify-start",
        className,
      )}
      {...props}
    >
      <div
        className={cn(
          "flex flex-col gap-3 max-w-[85%]",
          message.role === "user" ? "items-end" : "items-start",
        )}
      >
        {message.parts?.map((part, index) => {
          // Handle text parts
          if (part.type === "text") {
            return (
              <Streamdown className="not-prose" key={index}>
                {part.text}
              </Streamdown>
            );
          }

          // Handle tool-generate-theme parts
          if (part.type === "tool-generate-theme") {
            switch (part.state) {
              case "input-streaming":
              case "input-available": {
                // Show live-building preview with partial data
                const input = part.input as any;
                const isStreaming = part.state === "input-streaming";

                return (
                  <div
                    key={index}
                    className="rounded-lg border border-border bg-card p-3 w-full shadow-sm"
                  >
                    {/* Theme Header - Compact */}
                    <div className="mb-2">
                      <div className="flex items-center gap-2">
                        <span className={isStreaming ? "animate-pulse" : ""}>
                          ✨
                        </span>
                        <h4 className="font-semibold text-xs flex-1">
                          {input?.title || (
                            <span className="text-muted-foreground italic">
                              Generating...
                            </span>
                          )}
                        </h4>
                        {isStreaming && (
                          <span className="text-[9px] text-muted-foreground flex items-center gap-1">
                            <span className="animate-spin">⚙️</span>
                          </span>
                        )}
                      </div>
                      {input?.concept && (
                        <p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-1">
                          {input.concept}
                        </p>
                      )}
                    </div>

                    {/* Ultra Compact Color Swatches */}
                    <div className="grid grid-cols-2 gap-1.5 mb-2">
                      {/* Light Mode */}
                      <div>
                        <p className="text-[9px] font-medium mb-1 text-muted-foreground">
                          ☀️ Light
                        </p>
                        <div className="grid grid-cols-8 gap-0.5 p-1 rounded border bg-muted/20">
                          {input?.light ? (
                            Object.entries(input.light)
                              .filter(([key]) => key !== "shadow")
                              .slice(0, 32)
                              .map(([key, value]: [string, any]) => (
                                <div
                                  key={key}
                                  className="h-3 w-full rounded-[2px] animate-in fade-in duration-200"
                                  style={{ backgroundColor: value }}
                                  title={key}
                                />
                              ))
                          ) : (
                            <div className="col-span-8 h-3 flex items-center justify-center text-[8px] text-muted-foreground animate-pulse">
                              ...
                            </div>
                          )}
                        </div>
                      </div>

                      {/* Dark Mode */}
                      <div>
                        <p className="text-[9px] font-medium mb-1 text-muted-foreground">
                          🌙 Dark
                        </p>
                        <div className="grid grid-cols-8 gap-0.5 p-1 rounded border bg-muted/20">
                          {input?.dark ? (
                            Object.entries(input.dark)
                              .filter(([key]) => key !== "shadow")
                              .slice(0, 32)
                              .map(([key, value]: [string, any]) => (
                                <div
                                  key={key}
                                  className="h-3 w-full rounded-[2px] animate-in fade-in duration-200"
                                  style={{ backgroundColor: value }}
                                  title={key}
                                />
                              ))
                          ) : (
                            <div className="col-span-8 h-3 flex items-center justify-center text-[8px] text-muted-foreground animate-pulse">
                              ...
                            </div>
                          )}
                        </div>
                      </div>
                    </div>

                    {/* Compact Info */}
                    {input?.fonts && (
                      <p className="text-[9px] text-muted-foreground text-center">
                        {input.fonts.sans} • {input.fonts.mono}
                      </p>
                    )}
                  </div>
                );
              }

              case "output-available": {
                const result = part.output as any;

                if (!result?.light || !result?.dark) {
                  return null;
                }

                return (
                  <div
                    key={index}
                    className="rounded-lg border-2 border-primary/20 bg-card p-3 w-full shadow-md"
                  >
                    {/* Theme Header - Compact */}
                    <div className="mb-2">
                      <div className="flex items-center gap-2">
                        <span>✨</span>
                        <h4 className="font-semibold text-xs flex-1">
                          {result.title}
                        </h4>
                        <span className="px-1.5 py-0.5 text-[9px] bg-primary/10 text-primary rounded font-medium">
                          Ready
                        </span>
                      </div>
                      <p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">
                        {result.concept}
                      </p>
                    </div>

                    {/* Ultra Compact Color Swatches */}
                    <div className="grid grid-cols-2 gap-1.5 mb-2">
                      {/* Light Mode */}
                      <div>
                        <p className="text-[9px] font-medium mb-1 text-muted-foreground">
                          ☀️ Light
                        </p>
                        <div className="grid grid-cols-8 gap-0.5 p-1 rounded border bg-muted/20">
                          {Object.entries(result.light)
                            .filter(([key]) => key !== "shadow")
                            .slice(0, 32)
                            .map(([key, value]: [string, any]) => (
                              <div
                                key={key}
                                className="h-3 w-full rounded-[2px]"
                                style={{ backgroundColor: value }}
                                title={key}
                              />
                            ))}
                        </div>
                      </div>

                      {/* Dark Mode */}
                      <div>
                        <p className="text-[9px] font-medium mb-1 text-muted-foreground">
                          🌙 Dark
                        </p>
                        <div className="grid grid-cols-8 gap-0.5 p-1 rounded border bg-muted/20">
                          {Object.entries(result.dark)
                            .filter(([key]) => key !== "shadow")
                            .slice(0, 32)
                            .map(([key, value]: [string, any]) => (
                              <div
                                key={key}
                                className="h-3 w-full rounded-[2px]"
                                style={{ backgroundColor: value }}
                                title={key}
                              />
                            ))}
                        </div>
                      </div>
                    </div>

                    {/* Compact Info + Apply Button */}
                    <div className="space-y-1.5">
                      {result.fonts && (
                        <p className="text-[9px] text-muted-foreground text-center">
                          {result.fonts.sans} • {result.fonts.mono} • Radius:{" "}
                          {result.radius?.lg || "0.5rem"}
                        </p>
                      )}

                      <button
                        type="button"
                        onClick={() =>
                          onApplyTheme?.({
                            light: result.light,
                            dark: result.dark,
                          })
                        }
                        className="w-full bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
                      >
                        ✨ Apply Theme
                      </button>
                    </div>
                  </div>
                );
              }

              case "output-error":
                return (
                  <div
                    key={index}
                    className="rounded-lg border border-destructive bg-destructive/10 px-3 py-2 text-xs"
                  >
                    <div className="flex items-center gap-2">
                      <span>⚠️</span>
                      <span className="font-medium text-destructive">
                        Error generating theme
                      </span>
                    </div>
                    <p className="text-destructive/80 text-[10px] mt-1">
                      {part.errorText}
                    </p>
                  </div>
                );
            }
          }

          return null;
        })}
      </div>
    </div>
  );
};
registry/default/blocks/tinte/tinte-editor/lib/tinte-to-shadcn.ts
import { formatHex, formatHsl, oklch, parse, rgb } from "culori";

// ============================================================================
// TINTE THEME TYPES (Flexoki-inspired continuous scale)
// ============================================================================

export interface TinteBlock {
  bg: string;
  bg_2: string;
  ui: string;
  ui_2: string;
  ui_3: string;
  tx_3: string;
  tx_2: string;
  tx: string;
  pr: string;
  sc: string;
  ac_1: string;
  ac_2: string;
  ac_3: string;
}

export interface TinteTheme {
  light: TinteBlock;
  dark: TinteBlock;
  fonts?: {
    sans: string;
    serif: string;
    mono: string;
  };
  radius?: {
    sm: string;
    md: string;
    lg: string;
    xl: string;
  };
  shadows?: {
    color: string;
    opacity: string;
    offsetX: string;
    offsetY: string;
    blur: string;
    spread: string;
  };
}

// ============================================================================
// PALETTE GENERATOR (Tailwind 50-950 ramps from single color)
// ============================================================================

interface PaletteColor {
  name: string;
  value: string;
}

function toOklch(color: string): { l: number; c: number; h: number } {
  const oklchColor = oklch(color);
  if (!oklchColor) throw new Error("Invalid color");

  return {
    l: oklchColor.l || 0,
    c: oklchColor.c || 0,
    h: oklchColor.h || 0,
  };
}

function oklchToRgb(
  l: number,
  c: number,
  h: number,
): { r: number; g: number; b: number } {
  const rgbColor = rgb({ mode: "oklch", l, c, h });
  if (!rgbColor) throw new Error("Invalid OKLCH values");

  return {
    r: rgbColor.r || 0,
    g: rgbColor.g || 0,
    b: rgbColor.b || 0,
  };
}

function rgbToHex(rgbColor: { r: number; g: number; b: number }): string {
  return formatHex({ mode: "rgb", ...rgbColor });
}

function interpolate(start: number, end: number, factor: number): number {
  return start + (end - start) * factor;
}

function findBestShadePosition(baseColor: string): number {
  const baseOklch = toOklch(baseColor);
  const targetLuminances = [
    0.985, 0.967, 0.922, 0.87, 0.708, 0.556, 0.439, 0.371, 0.269, 0.205, 0.145,
  ];

  let bestIndex = 0;
  let minDifference = Math.abs(targetLuminances[0] - baseOklch.l);

  for (let i = 1; i < targetLuminances.length; i++) {
    const difference = Math.abs(targetLuminances[i] - baseOklch.l);
    if (difference < minDifference) {
      minDifference = difference;
      bestIndex = i;
    }
  }

  return bestIndex;
}

function generateTailwindPalette(baseColor: string): PaletteColor[] {
  const stops = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
  const baseOklch = toOklch(baseColor);
  const basePosition = findBestShadePosition(baseColor);

  const lightEndpoint = {
    l: 0.985,
    c: Math.max(0.002, baseOklch.c * 0.05),
    h: baseOklch.h,
  };
  const darkEndpoint = {
    l: 0.145,
    c: Math.max(0.02, baseOklch.c * 0.6),
    h: baseOklch.h,
  };

  const palette: PaletteColor[] = [];

  for (let i = 0; i < stops.length; i++) {
    let interpolatedColor: { l: number; c: number; h: number };

    if (i === basePosition) {
      interpolatedColor = baseOklch;
    } else if (i < basePosition) {
      const factor = (basePosition - i) / basePosition;
      interpolatedColor = {
        l: interpolate(baseOklch.l, lightEndpoint.l, factor),
        c: interpolate(baseOklch.c, lightEndpoint.c, factor * 0.8),
        h: baseOklch.h,
      };
    } else {
      const factor = (i - basePosition) / (stops.length - 1 - basePosition);
      interpolatedColor = {
        l: interpolate(baseOklch.l, darkEndpoint.l, factor),
        c: interpolate(baseOklch.c, darkEndpoint.c, factor * 0.9),
        h: baseOklch.h,
      };
    }

    const rgbColor = oklchToRgb(
      interpolatedColor.l,
      interpolatedColor.c,
      interpolatedColor.h,
    );
    const hex = rgbToHex(rgbColor);

    palette.push({
      name: stops[i].toString(),
      value: hex,
    });
  }

  return palette;
}

// ============================================================================
// TINTE TO SHADCN CONVERSION
// ============================================================================

const DEFAULT_FONTS = {
  "font-sans": "Inter, system-ui, sans-serif",
  "font-serif": "Georgia, serif",
  "font-mono": "JetBrains Mono, monospace",
};

const DEFAULT_BASE = {
  "radius-sm": "0.25rem",
  "radius-md": "0.5rem",
  "radius-lg": "0.75rem",
  "radius-xl": "1rem",
  radius: "0.5rem",
};

const DEFAULT_SHADOWS = {
  "shadow-color": "#000000",
  "shadow-opacity": "0.1",
  "shadow-offset-x": "0px",
  "shadow-offset-y": "2px",
  "shadow-blur": "4px",
  "shadow-spread": "0px",
};

type ThemeMode = "light" | "dark";

const ANCHORS = {
  light: { primary: 600, border: 200, muted: 100, mutedFg: 600, accent: 300 },
  dark: { primary: 400, border: 800, muted: 900, mutedFg: 300, accent: 700 },
} as const;

const clamp01 = (v: number) => Math.max(0, Math.min(1, v));

const L = (hex: string) => {
  const c = rgb(hex);
  if (!c) return 0;
  const lin = (x: number) =>
    x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
  return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
};

const contrast = (a: string, b: string) => {
  const la = L(a),
    lb = L(b);
  const lighter = Math.max(la, lb),
    darker = Math.min(la, lb);
  return (lighter + 0.05) / (darker + 0.05);
};

const bestTextFor = (bg: string) => {
  const w = "#ffffff",
    k = "#000000";
  return contrast(w, bg) >= contrast(k, bg) ? w : k;
};

const tweakL = (hex: string, dL: number) => {
  const c = oklch(hex);
  if (!c) return hex;
  return formatHex({
    mode: "oklch" as const,
    l: clamp01(c.l + dL),
    c: Math.max(0, c.c),
    h: c.h,
  });
};

function buildNeutralRamp(block: TinteBlock): string[] {
  const seed = block.ui || block.ui_2 || block.ui_3 || block.bg || "#808080";
  return generateTailwindPalette(seed).map((s) => s.value);
}

function buildRamp(seed?: string): string[] {
  return generateTailwindPalette(seed || "#64748b").map((s) => s.value);
}

const pick = (ramp: string[], step: number) => {
  const idx = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950].indexOf(
    step,
  );
  return ramp[Math.max(0, idx)];
};

const surface = (bg: string, mode: ThemeMode, delta = 0.02) => {
  return tweakL(bg, mode === "light" ? +delta : -delta);
};

function computeShadowVars(
  tokens: Record<string, string>,
): Record<string, string> {
  const shadowColor = tokens["shadow-color"] || "hsl(0 0% 0%)";
  const opacity = parseFloat(tokens["shadow-opacity"] || "0.1");
  const offsetX = tokens["shadow-offset-x"] || "0px";
  const offsetY = tokens["shadow-offset-y"] || "2px";
  const blur = tokens["shadow-blur"] || "4px";
  const spread = tokens["shadow-spread"] || "0px";

  let hslColor = shadowColor;
  if (shadowColor.startsWith("#")) {
    const parsed = parse(shadowColor);
    if (parsed) {
      const hslString = formatHsl(parsed);
      hslColor = hslString.replace(/hsl\(|\)|\s+/g, "").replace(/,/g, " ");
    } else {
      hslColor = "0 0% 0%";
    }
  } else if (shadowColor.startsWith("hsl(")) {
    hslColor = shadowColor.replace(/hsl\(|\)/g, "").replace(/,/g, " ");
  }

  const colorWithOpacity = (opacityMultiplier: number) =>
    `hsl(${hslColor} / ${(opacity * opacityMultiplier).toFixed(2)})`;

  const secondLayer = (fixedOffsetY: string, fixedBlur: string): string => {
    const spread2 = `${(
      parseFloat(spread.replace("px", "") || "0") - 1
    ).toString()}px`;
    return `${offsetX} ${fixedOffsetY} ${fixedBlur} ${spread2} ${colorWithOpacity(
      1.0,
    )}`;
  };

  return {
    "shadow-2xs": `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      0.5,
    )}`,
    "shadow-xs": `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      0.5,
    )}`,
    "shadow-sm": `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      1.0,
    )}, ${secondLayer("1px", "2px")}`,
    shadow: `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      1.0,
    )}, ${secondLayer("1px", "2px")}`,
    "shadow-md": `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      1.0,
    )}, ${secondLayer("2px", "4px")}`,
    "shadow-lg": `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      1.0,
    )}, ${secondLayer("4px", "6px")}`,
    "shadow-xl": `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      1.0,
    )}, ${secondLayer("8px", "10px")}`,
    "shadow-2xl": `${offsetX} ${offsetY} ${blur} ${spread} ${colorWithOpacity(
      2.5,
    )}`,
  };
}

function mapTinteBlockToShadcn(
  block: TinteBlock,
  mode: ThemeMode,
  extendedTheme?: TinteTheme,
): Record<string, string> {
  const bg = block.bg || (mode === "light" ? "#ffffff" : "#0b0b0f");
  const fg = block.tx || bestTextFor(bg);

  const neutralRamp = buildNeutralRamp(block);
  const primaryRamp = buildRamp(block.pr);
  const secondaryRamp = buildRamp(block.sc);
  const accentRamp = buildRamp(block.ac_1 || block.ac_2 || block.pr);

  const A = ANCHORS[mode];
  const primary = pick(primaryRamp, A.primary);
  const secondary = pick(secondaryRamp, mode === "light" ? 500 : 400);
  const accent = pick(accentRamp, A.accent);
  const muted = pick(neutralRamp, A.muted);
  const border = pick(neutralRamp, A.border);

  const ensureFg = (on: string) => bestTextFor(on);
  const ring = tweakL(primary, mode === "light" ? +0.1 : -0.1);
  const card = surface(bg, mode, 0.03);
  const popover = surface(bg, mode, 0.03);

  const destructiveSeed = block.ac_3 || "#ef4444";
  const destructiveRamp = buildRamp(destructiveSeed);
  const destructive = pick(destructiveRamp, mode === "light" ? 500 : 400);

  const chart1 = pick(primaryRamp, 500);
  const chart2 = pick(accentRamp, 500);
  const chart3 = pick(primaryRamp, 300);
  const chart4 = pick(accentRamp, 700);
  const chart5 = pick(primaryRamp, 700);

  const sidebar = bg;
  const sidebarAccent = surface(bg, mode, 0.04);

  const result: Record<string, string> = {
    background: bg,
    foreground: fg,
    card,
    "card-foreground": ensureFg(card),
    popover,
    "popover-foreground": ensureFg(popover),
    primary,
    "primary-foreground": ensureFg(primary),
    secondary,
    "secondary-foreground": ensureFg(secondary),
    muted,
    "muted-foreground": pick(neutralRamp, A.mutedFg),
    accent,
    "accent-foreground": ensureFg(accent),
    destructive,
    "destructive-foreground": ensureFg(destructive),
    border,
    input: tweakL(border, mode === "light" ? -0.1 : +0.1),
    ring,
    "chart-1": chart1,
    "chart-2": chart2,
    "chart-3": chart3,
    "chart-4": chart4,
    "chart-5": chart5,
    sidebar,
    "sidebar-foreground": ensureFg(sidebar),
    "sidebar-primary": primary,
    "sidebar-primary-foreground": ensureFg(primary),
    "sidebar-accent": sidebarAccent,
    "sidebar-accent-foreground": ensureFg(sidebarAccent),
    "sidebar-border": border,
    "sidebar-ring": ring,
    ...DEFAULT_BASE,
  };

  // Add fonts if available
  if (extendedTheme?.fonts) {
    result["font-sans"] =
      `"${extendedTheme.fonts.sans}", ${DEFAULT_FONTS["font-sans"]}`;
    result["font-serif"] =
      `"${extendedTheme.fonts.serif}", ${DEFAULT_FONTS["font-serif"]}`;
    result["font-mono"] =
      `"${extendedTheme.fonts.mono}", ${DEFAULT_FONTS["font-mono"]}`;
  } else {
    Object.assign(result, DEFAULT_FONTS);
  }

  // Add border radius if available
  if (extendedTheme?.radius) {
    result["radius-sm"] = extendedTheme.radius.sm;
    result["radius-md"] = extendedTheme.radius.md;
    result["radius-lg"] = extendedTheme.radius.lg;
    result["radius-xl"] = extendedTheme.radius.xl;
    result.radius = extendedTheme.radius.md;
  }

  // Add shadow properties if available
  if (extendedTheme?.shadows) {
    result["shadow-color"] = extendedTheme.shadows.color;
    result["shadow-opacity"] = extendedTheme.shadows.opacity;
    result["shadow-offset-x"] = extendedTheme.shadows.offsetX;
    result["shadow-offset-y"] = extendedTheme.shadows.offsetY;
    result["shadow-blur"] = extendedTheme.shadows.blur;
    result["shadow-spread"] = extendedTheme.shadows.spread;
  } else {
    Object.assign(result, DEFAULT_SHADOWS);
  }

  // Add computed shadow variables
  const shadowVars = computeShadowVars(result);
  Object.assign(result, shadowVars);

  return result;
}

export function convertTinteToShadcn(tinte: TinteTheme) {
  const lightBlock = mapTinteBlockToShadcn(tinte.light, "light", tinte);
  const darkBlock = mapTinteBlockToShadcn(tinte.dark, "dark", tinte);

  return {
    light: lightBlock,
    dark: darkBlock,
  };
}

More from elements

Browse all components in this library →