Tinte Theme Editor

PreviousNext

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.

Docs
elementsblock

Preview

Loading preview…
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>
  );
}

Installation

npx shadcn@latest add @elements/tinte-editor

Usage

import { TinteEditor } from "@/components/tinte-editor"
<TinteEditor />