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 · lightui-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,
};
}